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