1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 package org.opencastproject.timelinepreviews.ffmpeg;
23
24 import org.opencastproject.job.api.AbstractJobProducer;
25 import org.opencastproject.job.api.Job;
26 import org.opencastproject.mediapackage.Attachment;
27 import org.opencastproject.mediapackage.MediaPackageElement;
28 import org.opencastproject.mediapackage.MediaPackageElementBuilder;
29 import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
30 import org.opencastproject.mediapackage.MediaPackageElementParser;
31 import org.opencastproject.mediapackage.MediaPackageException;
32 import org.opencastproject.mediapackage.Track;
33 import org.opencastproject.security.api.OrganizationDirectoryService;
34 import org.opencastproject.security.api.SecurityService;
35 import org.opencastproject.security.api.UserDirectoryService;
36 import org.opencastproject.serviceregistry.api.ServiceRegistry;
37 import org.opencastproject.serviceregistry.api.ServiceRegistryException;
38 import org.opencastproject.timelinepreviews.api.TimelinePreviewsException;
39 import org.opencastproject.timelinepreviews.api.TimelinePreviewsService;
40 import org.opencastproject.util.IoSupport;
41 import org.opencastproject.util.LoadUtil;
42 import org.opencastproject.util.MimeTypes;
43 import org.opencastproject.util.NotFoundException;
44 import org.opencastproject.util.UnknownFileTypeException;
45 import org.opencastproject.workspace.api.Workspace;
46
47 import org.apache.commons.io.FileUtils;
48 import org.apache.commons.io.FilenameUtils;
49 import org.apache.commons.lang3.StringUtils;
50 import org.osgi.service.cm.ConfigurationException;
51 import org.osgi.service.cm.ManagedService;
52 import org.osgi.service.component.ComponentContext;
53 import org.osgi.service.component.annotations.Component;
54 import org.osgi.service.component.annotations.Reference;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
57
58 import java.io.BufferedReader;
59 import java.io.File;
60 import java.io.FileInputStream;
61 import java.io.FileNotFoundException;
62 import java.io.IOException;
63 import java.io.InputStreamReader;
64 import java.net.URI;
65 import java.util.Arrays;
66 import java.util.Dictionary;
67 import java.util.List;
68 import java.util.UUID;
69
70
71
72
73
74 @Component(
75 immediate = true,
76 service = { TimelinePreviewsService.class,ManagedService.class },
77 property = {
78 "service.description=TimelinePreviews Service"
79 }
80 )
81 public class TimelinePreviewsServiceImpl extends AbstractJobProducer implements
82 TimelinePreviewsService, ManagedService {
83
84
85 public static final String COLLECTION_ID = "timelinepreviews";
86
87
88 protected enum Operation {
89 TimelinePreview
90 };
91
92
93 protected String binary = FFMPEG_BINARY_DEFAULT;
94
95
96 public static final String FFMPEG_BINARY_CONFIG = "org.opencastproject.composer.ffmpeg.path";
97
98
99 public static final String FFMPEG_BINARY_DEFAULT = "ffmpeg";
100
101
102 public static final String OPT_RESOLUTION_X = "resolutionX";
103
104
105 public static final int DEFAULT_RESOLUTION_X = 160;
106
107
108 public static final String OPT_RESOLUTION_Y = "resolutionY";
109
110
111 public static final int DEFAULT_RESOLUTION_Y = -1;
112
113
114 public static final String OPT_OUTPUT_FORMAT = "outputFormat";
115
116
117 public static final String DEFAULT_OUTPUT_FORMAT = ".png";
118
119
120 public static final String OPT_MIMETYPE = "mimetype";
121
122
123 public static final String DEFAULT_MIMETYPE = "image/png";
124
125
126
127 public static final float DEFAULT_TIMELINEPREVIEWS_JOB_LOAD = 0.1f;
128
129
130 public static final String TIMELINEPREVIEWS_JOB_LOAD_KEY = "job.load.timelinepreviews";
131
132
133 private float timelinepreviewsJobLoad = DEFAULT_TIMELINEPREVIEWS_JOB_LOAD;
134
135
136 protected static final Logger logger = LoggerFactory
137 .getLogger(TimelinePreviewsServiceImpl.class);
138
139
140 protected int resolutionX = DEFAULT_RESOLUTION_X;
141
142
143 protected int resolutionY = DEFAULT_RESOLUTION_Y;
144
145
146 protected String outputFormat = DEFAULT_OUTPUT_FORMAT;
147
148
149 protected String mimetype = DEFAULT_MIMETYPE;
150
151
152
153 protected ServiceRegistry serviceRegistry = null;
154
155
156 protected Workspace workspace = null;
157
158
159 protected SecurityService securityService = null;
160
161
162 protected UserDirectoryService userDirectoryService = null;
163
164
165 protected OrganizationDirectoryService organizationDirectoryService = null;
166
167
168
169
170 public TimelinePreviewsServiceImpl() {
171 super(JOB_TYPE);
172 this.binary = FFMPEG_BINARY_DEFAULT;
173 }
174
175 @Override
176 public void activate(ComponentContext cc) {
177 super.activate(cc);
178 logger.info("Activate ffmpeg timeline previews service");
179 final String path = cc.getBundleContext().getProperty(FFMPEG_BINARY_CONFIG);
180 this.binary = path == null ? FFMPEG_BINARY_DEFAULT : path;
181 logger.debug("Configuration {}: {}", FFMPEG_BINARY_CONFIG, FFMPEG_BINARY_DEFAULT);
182 }
183
184
185
186
187
188
189 @Override
190 public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
191 if (properties == null) {
192 return;
193 }
194 logger.debug("Configuring the timeline previews service");
195
196
197 if (properties.get(OPT_RESOLUTION_X) != null) {
198 String res = (String) properties.get(OPT_RESOLUTION_X);
199 try {
200 resolutionX = Integer.parseInt(res);
201 logger.info("Horizontal resolution set to {} pixels", resolutionX);
202 } catch (Exception e) {
203 throw new ConfigurationException(OPT_RESOLUTION_X, "Found illegal value '" + res
204 + "' for timeline previews horizontal resolution");
205 }
206 }
207
208 if (properties.get(OPT_RESOLUTION_Y) != null) {
209 String res = (String) properties.get(OPT_RESOLUTION_Y);
210 try {
211 resolutionY = Integer.parseInt(res);
212 logger.info("Vertical resolution set to {} pixels", resolutionY);
213 } catch (Exception e) {
214 throw new ConfigurationException(OPT_RESOLUTION_Y, "Found illegal value '" + res
215 + "' for timeline previews vertical resolution");
216 }
217 }
218
219 if (properties.get(OPT_OUTPUT_FORMAT) != null) {
220 String format = (String) properties.get(OPT_OUTPUT_FORMAT);
221 try {
222 outputFormat = format;
223 logger.info("Output file format set to \"{}\"", outputFormat);
224 } catch (Exception e) {
225 throw new ConfigurationException(OPT_OUTPUT_FORMAT, "Found illegal value '" + format
226 + "' for timeline previews output file format");
227 }
228 }
229
230 if (properties.get(OPT_MIMETYPE) != null) {
231 String type = (String) properties.get(OPT_MIMETYPE);
232 try {
233 mimetype = type;
234 logger.info("Mime type set to \"{}\"", mimetype);
235 } catch (Exception e) {
236 throw new ConfigurationException(OPT_MIMETYPE, "Found illegal value '" + type
237 + "' for timeline previews mimetype");
238 }
239 }
240
241 timelinepreviewsJobLoad = LoadUtil.getConfiguredLoadValue(properties, TIMELINEPREVIEWS_JOB_LOAD_KEY,
242 DEFAULT_TIMELINEPREVIEWS_JOB_LOAD, serviceRegistry);
243 }
244
245 @Override
246 public Job createTimelinePreviewImages(Track track, int imageCount) throws TimelinePreviewsException,
247 MediaPackageException {
248 try {
249 List<String> parameters = Arrays.asList(MediaPackageElementParser.getAsXml(track), Integer.toString(imageCount));
250
251 return serviceRegistry.createJob(JOB_TYPE,
252 Operation.TimelinePreview.toString(),
253 parameters,
254 timelinepreviewsJobLoad);
255 } catch (ServiceRegistryException e) {
256 throw new TimelinePreviewsException("Unable to create timelinepreviews job", e);
257 }
258 }
259
260
261
262
263
264
265
266
267
268
269
270
271
272 protected Attachment generatePreviewImages(Job job, Track track, int imageCount)
273 throws TimelinePreviewsException, MediaPackageException {
274
275
276 if (!track.hasVideo()) {
277 logger.error("Element {} is not a video track", track.getIdentifier());
278 throw new TimelinePreviewsException("Element is not a video track");
279 }
280
281 try {
282
283 if (track.getDuration() == null) {
284 throw new MediaPackageException("Track " + track + " does not have a duration");
285 }
286
287 double duration = track.getDuration() / 1000.0;
288 double seconds = duration / (double)(imageCount);
289 seconds = seconds <= 0.0 ? 1.0 : seconds;
290
291
292 int imageSize = (int) Math.ceil(Math.sqrt(imageCount));
293
294 Attachment composedImage = createPreviewsFFmpeg(track, seconds, resolutionX, resolutionY, imageSize, imageSize,
295 duration);
296
297
298 if (composedImage == null) {
299 throw new IllegalStateException("Unable to compose image");
300 }
301
302
303 try {
304 composedImage.setMimeType(MimeTypes.parseMimeType(mimetype));
305 } catch (IllegalArgumentException e) {
306 logger.warn("Invalid mimetype provided for timeline previews image");
307 try {
308 composedImage.setMimeType(MimeTypes.fromURI(composedImage.getURI()));
309 } catch (UnknownFileTypeException ex) {
310 logger.warn("No valid mimetype could be found for timeline previews image");
311 }
312 }
313
314 composedImage.getProperties().put("imageCount", String.valueOf(imageCount));
315
316 return composedImage;
317
318 } catch (Exception e) {
319 logger.warn("Error creating timeline preview images for " + track, e);
320 if (e instanceof TimelinePreviewsException) {
321 throw (TimelinePreviewsException) e;
322 } else {
323 throw new TimelinePreviewsException(e);
324 }
325 }
326 }
327
328
329
330
331
332
333 @Override
334 protected String process(Job job) throws Exception {
335 Operation op = null;
336 String operation = job.getOperation();
337 List<String> arguments = job.getArguments();
338 try {
339 op = Operation.valueOf(operation);
340 switch (op) {
341 case TimelinePreview:
342 Track track = (Track) MediaPackageElementParser
343 .getFromXml(arguments.get(0));
344 int imageCount = Integer.parseInt(arguments.get(1));
345 Attachment timelinePreviewsMpe = generatePreviewImages(job, track, imageCount);
346 return MediaPackageElementParser.getAsXml(timelinePreviewsMpe);
347 default:
348 throw new IllegalStateException("Don't know how to handle operation '" + operation + "'");
349 }
350 } catch (IllegalArgumentException e) {
351 throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'", e);
352 } catch (IndexOutOfBoundsException e) {
353 throw new ServiceRegistryException("This argument list for operation '" + op + "' does not meet expectations", e);
354 } catch (Exception e) {
355 throw new ServiceRegistryException("Error handling operation '" + op + "'", e);
356 }
357 }
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372 protected Attachment createPreviewsFFmpeg(Track track, double seconds, int width, int height, int tileX, int tileY,
373 double duration) throws TimelinePreviewsException {
374
375
376 File mediaFile;
377 try {
378 mediaFile = workspace.get(track.getURI());
379 } catch (NotFoundException e) {
380 throw new TimelinePreviewsException(
381 "Error finding the media file in the workspace", e);
382 } catch (IOException e) {
383 throw new TimelinePreviewsException(
384 "Error reading the media file in the workspace", e);
385 }
386
387 String imageFilePath = FilenameUtils.removeExtension(mediaFile.getAbsolutePath()) + '_' + UUID.randomUUID()
388 + "_timelinepreviews" + outputFormat;
389 int exitCode = 1;
390 String[] command = new String[] {
391 binary,
392 "-loglevel", "error",
393 "-t", String.valueOf(duration - seconds / 2.0),
394
395
396
397
398
399 "-skip_frame", duration > 15 * 60.0 ? "nokey" : "default",
400 "-i", mediaFile.getAbsolutePath(),
401 "-vf", "fps=1/" + seconds + ",scale=" + width + ":" + height + ",tile=" + tileX + "x" + tileY,
402 imageFilePath
403 };
404
405 logger.debug("Start timeline previews ffmpeg process: {}", StringUtils.join(command, " "));
406 logger.info("Create timeline preview images file for track '{}' at {}", track.getIdentifier(), imageFilePath);
407
408 ProcessBuilder pbuilder = new ProcessBuilder(command);
409
410 pbuilder.redirectErrorStream(true);
411 Process ffmpegProcess = null;
412 exitCode = 1;
413 BufferedReader errStream = null;
414 try {
415 ffmpegProcess = pbuilder.start();
416
417 errStream = new BufferedReader(new InputStreamReader(ffmpegProcess.getInputStream()));
418 String line = errStream.readLine();
419 while (line != null) {
420 logger.error("FFmpeg error: " + line);
421 line = errStream.readLine();
422 }
423 exitCode = ffmpegProcess.waitFor();
424 } catch (IOException ex) {
425 throw new TimelinePreviewsException("Starting ffmpeg process failed", ex);
426 } catch (InterruptedException ex) {
427 throw new TimelinePreviewsException("Timeline preview creation was unexpectedly interrupted", ex);
428 } finally {
429 IoSupport.closeQuietly(ffmpegProcess);
430 IoSupport.closeQuietly(errStream);
431 if (exitCode != 0) {
432 try {
433 FileUtils.forceDelete(new File(imageFilePath));
434 } catch (IOException e) {
435
436 }
437 }
438 }
439
440 if (exitCode != 0) {
441 throw new TimelinePreviewsException("Generating timeline preview for track " + track.getIdentifier()
442 + " failed: ffmpeg process exited abnormally with exit code " + exitCode);
443 }
444
445
446 FileInputStream timelinepreviewsFileInputStream = null;
447 URI previewsFileUri = null;
448 try {
449 timelinepreviewsFileInputStream = new FileInputStream(imageFilePath);
450 previewsFileUri = workspace.putInCollection(COLLECTION_ID,
451 FilenameUtils.getName(imageFilePath), timelinepreviewsFileInputStream);
452 logger.info("Copied the created timeline preview images file to the workspace {}", previewsFileUri.toString());
453 } catch (FileNotFoundException ex) {
454 throw new TimelinePreviewsException(
455 String.format("Timeline previews image file '%s' not found", imageFilePath), ex);
456 } catch (IOException ex) {
457 throw new TimelinePreviewsException(
458 String.format("Can't write timeline preview images file '%s' to workspace", imageFilePath), ex);
459 } catch (IllegalArgumentException ex) {
460 throw new TimelinePreviewsException(ex);
461 } finally {
462 IoSupport.closeQuietly(timelinepreviewsFileInputStream);
463 logger.info("Deleted local timeline preview images file at {}", imageFilePath);
464 FileUtils.deleteQuietly(new File(imageFilePath));
465 }
466
467
468 MediaPackageElementBuilder mpElementBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
469
470 Attachment timelinepreviewsMpe = (Attachment) mpElementBuilder.elementFromURI(
471 previewsFileUri, MediaPackageElement.Type.Attachment, track.getFlavor());
472
473
474 timelinepreviewsMpe.referTo(track);
475
476
477 timelinepreviewsMpe.getProperties().put("imageSizeX", String.valueOf(tileX));
478 timelinepreviewsMpe.getProperties().put("imageSizeY", String.valueOf(tileY));
479 timelinepreviewsMpe.getProperties().put("resolutionX", String.valueOf(resolutionX));
480 timelinepreviewsMpe.getProperties().put("resolutionY", String.valueOf(resolutionY));
481
482
483 timelinepreviewsMpe.setFlavor(track.getFlavor());
484 timelinepreviewsMpe.generateIdentifier();
485
486 return timelinepreviewsMpe;
487 }
488
489
490
491
492
493
494
495 @Reference
496 protected void setWorkspace(Workspace workspace) {
497 this.workspace = workspace;
498 }
499
500
501
502
503
504
505
506 @Reference
507 protected void setServiceRegistry(ServiceRegistry serviceRegistry) {
508 this.serviceRegistry = serviceRegistry;
509 }
510
511
512
513
514
515
516 @Override
517 protected ServiceRegistry getServiceRegistry() {
518 return serviceRegistry;
519 }
520
521
522
523
524
525
526
527 @Reference
528 public void setSecurityService(SecurityService securityService) {
529 this.securityService = securityService;
530 }
531
532
533
534
535
536
537
538 @Reference
539 public void setUserDirectoryService(
540 UserDirectoryService userDirectoryService) {
541 this.userDirectoryService = userDirectoryService;
542 }
543
544
545
546
547
548
549
550 @Reference
551 public void setOrganizationDirectoryService(
552 OrganizationDirectoryService organizationDirectory) {
553 this.organizationDirectoryService = organizationDirectory;
554 }
555
556
557
558
559
560
561 @Override
562 protected SecurityService getSecurityService() {
563 return securityService;
564 }
565
566
567
568
569
570
571 @Override
572 protected UserDirectoryService getUserDirectoryService() {
573 return userDirectoryService;
574 }
575
576
577
578
579
580
581 @Override
582 protected OrganizationDirectoryService getOrganizationDirectoryService() {
583 return organizationDirectoryService;
584 }
585
586 }