View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
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   * Media analysis plugin that takes a video stream and generates preview images that can be shown on the timeline.
73   * This will be done using FFmpeg.
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    /** Resulting collection in the working file repository */
86    public static final String COLLECTION_ID = "timelinepreviews";
87  
88    /** List of available operations on jobs */
89    protected enum Operation {
90      TimelinePreview
91    };
92  
93    /** Path to the executable */
94    protected String binary = FFMPEG_BINARY_DEFAULT;
95  
96    /** The key to look for in the service configuration file to override the DEFAULT_FFMPEG_BINARY */
97    public static final String FFMPEG_BINARY_CONFIG = "org.opencastproject.composer.ffmpeg.path";
98  
99    /** The default path to the FFmpeg binary */
100   public static final String FFMPEG_BINARY_DEFAULT = "ffmpeg";
101 
102   /** Name of the constant used to retrieve the horizontal resolution */
103   public static final String OPT_RESOLUTION_X = "resolutionX";
104 
105   /** Default value for the horizontal resolution */
106   public static final int DEFAULT_RESOLUTION_X = 160;
107 
108   /** Name of the constant used to retrieve the vertical resolution */
109   public static final String OPT_RESOLUTION_Y = "resolutionY";
110 
111   /** Default value for the vertical resolution */
112   public static final int DEFAULT_RESOLUTION_Y = -1;
113 
114   /** Name of the constant used to retrieve the output file format */
115   public static final String OPT_OUTPUT_FORMAT = "outputFormat";
116 
117   /** Default value for the format of the output image file */
118   public static final String DEFAULT_OUTPUT_FORMAT = ".png";
119 
120   /** Name of the constant used to retrieve the mimetype */
121   public static final String OPT_MIMETYPE = "mimetype";
122 
123   /** Default value for the mimetype of the generated image */
124   public static final String DEFAULT_MIMETYPE = "image/png";
125 
126 
127   /** The default job load of a timeline previews job */
128   public static final float DEFAULT_TIMELINEPREVIEWS_JOB_LOAD = 0.1f;
129 
130   /** The key to look for in the service configuration file to override the DEFAULT_TIMELINEPREVIEWS_JOB_LOAD */
131   public static final String TIMELINEPREVIEWS_JOB_LOAD_KEY = "job.load.timelinepreviews";
132 
133   /** The load introduced on the system by creating a caption job */
134   private float timelinepreviewsJobLoad = DEFAULT_TIMELINEPREVIEWS_JOB_LOAD;
135 
136   /** The logging facility */
137   protected static final Logger logger = LoggerFactory
138       .getLogger(TimelinePreviewsServiceImpl.class);
139 
140   /** The horizontal resolution of a single preview image */
141   protected int resolutionX = DEFAULT_RESOLUTION_X;
142 
143   /** The vertical resolution of a single preview image */
144   protected int resolutionY = DEFAULT_RESOLUTION_Y;
145 
146   /** The file format of the generated preview images file */
147   protected String outputFormat = DEFAULT_OUTPUT_FORMAT;
148 
149   /** The mimetype that will be set for the generated Attachment containing the timeline previews image */
150   protected String mimetype = DEFAULT_MIMETYPE;
151 
152 
153   /** Reference to the receipt service */
154   protected ServiceRegistry serviceRegistry = null;
155 
156   /** The workspace to use when retrieving remote media files */
157   protected Workspace workspace = null;
158 
159   /** The security service */
160   protected SecurityService securityService = null;
161 
162   /** The user directory service */
163   protected UserDirectoryService userDirectoryService = null;
164 
165   /** The organization directory service */
166   protected OrganizationDirectoryService organizationDirectoryService = null;
167 
168   /**
169    * Creates a new instance of the timeline previews service.
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    * {@inheritDoc}
187    *
188    * @see org.osgi.service.cm.ManagedService#updated(java.util.Dictionary)
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     // Horizontal resolution
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     // Vertical resolution
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     // Output file format
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     // Output mimetype
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    * Starts generation of timeline preview images for the given video track
263    * and returns an attachment containing one image that contains all the
264    * timeline preview images.
265    *
266    * @param job
267    * @param track the element to analyze
268    * @param imageCount number of preview images that will be generated
269    * @return an attachment containing the resulting timeline previews image
270    * @throws TimelinePreviewsException
271    * @throws org.opencastproject.mediapackage.MediaPackageException
272    */
273   protected Attachment generatePreviewImages(Job job, Track track, int imageCount)
274           throws TimelinePreviewsException, MediaPackageException {
275 
276     // Make sure the element can be analyzed using this analysis implementation
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       // calculate number of tiles for row and column in tiled image
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       // Set the mimetype
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    * {@inheritDoc}
331    *
332    * @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
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    * Executes the FFmpeg command to generate a timeline previews image
362    *
363    * @param track the track to generate the timeline previews image for
364    * @param seconds the length of a segment that one preview image should represent
365    * @param width the width of a single preview image
366    * @param height the height of a single preview image
367    * @param tileX the horizontal number of preview images that are stored in the timeline previews image
368    * @param tileY the vertical number of preview images that are stored in the timeline previews image
369    * @param duration the duration for which preview images should be generated
370    * @return an attachment containing the timeline previews image
371    * @throws TimelinePreviewsException
372    */
373   protected Attachment createPreviewsFFmpeg(Track track, double seconds, int width, int height, int tileX, int tileY,
374           double duration) throws TimelinePreviewsException {
375 
376     // copy source file into workspace
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         // For longer videos, this operation only considers keyframes. This
396         // significantly speeds up this command. The difference in output is
397         // minimal and not relevant for the user. Nothing would crash without
398         // this duration check: short videos would just repeat keyframes in the
399         // output image, making the preview less useful.
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           // it is ok, no output file was generated by ffmpeg
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     // put timeline previews image into workspace
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     // create media package element
469     MediaPackageElementBuilder mpElementBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
470     // it is up to the workflow operation handler to set the attachment flavor
471     Attachment timelinepreviewsMpe = (Attachment) mpElementBuilder.elementFromURI(
472             previewsFileUri, MediaPackageElement.Type.Attachment, track.getFlavor());
473 
474     // add reference to track
475     timelinepreviewsMpe.referTo(track);
476 
477     // add additional properties to attachment
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     // set the flavor and an ID
484     timelinepreviewsMpe.setFlavor(track.getFlavor());
485     timelinepreviewsMpe.setIdentifier(IdImpl.fromUUID().toString());
486 
487     return timelinepreviewsMpe;
488   }
489 
490   /**
491    * Sets the workspace
492    *
493    * @param workspace
494    *            an instance of the workspace
495    */
496   @Reference
497   protected void setWorkspace(Workspace workspace) {
498     this.workspace = workspace;
499   }
500 
501   /**
502    * Sets the receipt service
503    *
504    * @param serviceRegistry
505    *            the service registry
506    */
507   @Reference
508   protected void setServiceRegistry(ServiceRegistry serviceRegistry) {
509     this.serviceRegistry = serviceRegistry;
510   }
511 
512   /**
513    * {@inheritDoc}
514    *
515    * @see org.opencastproject.job.api.AbstractJobProducer#getServiceRegistry()
516    */
517   @Override
518   protected ServiceRegistry getServiceRegistry() {
519     return serviceRegistry;
520   }
521 
522   /**
523    * Callback for setting the security service.
524    *
525    * @param securityService
526    *            the securityService to set
527    */
528   @Reference
529   public void setSecurityService(SecurityService securityService) {
530     this.securityService = securityService;
531   }
532 
533   /**
534    * Callback for setting the user directory service.
535    *
536    * @param userDirectoryService
537    *            the userDirectoryService to set
538    */
539   @Reference
540   public void setUserDirectoryService(
541       UserDirectoryService userDirectoryService) {
542     this.userDirectoryService = userDirectoryService;
543   }
544 
545   /**
546    * Sets a reference to the organization directory service.
547    *
548    * @param organizationDirectory
549    *            the organization directory
550    */
551   @Reference
552   public void setOrganizationDirectoryService(
553       OrganizationDirectoryService organizationDirectory) {
554     this.organizationDirectoryService = organizationDirectory;
555   }
556 
557   /**
558    * {@inheritDoc}
559    *
560    * @see org.opencastproject.job.api.AbstractJobProducer#getSecurityService()
561    */
562   @Override
563   protected SecurityService getSecurityService() {
564     return securityService;
565   }
566 
567   /**
568    * {@inheritDoc}
569    *
570    * @see org.opencastproject.job.api.AbstractJobProducer#getUserDirectoryService()
571    */
572   @Override
573   protected UserDirectoryService getUserDirectoryService() {
574     return userDirectoryService;
575   }
576 
577   /**
578    * {@inheritDoc}
579    *
580    * @see org.opencastproject.job.api.AbstractJobProducer#getOrganizationDirectoryService()
581    */
582   @Override
583   protected OrganizationDirectoryService getOrganizationDirectoryService() {
584     return organizationDirectoryService;
585   }
586 
587 }