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