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  package org.opencastproject.waveform.ffmpeg;
22  
23  import org.opencastproject.job.api.AbstractJobProducer;
24  import org.opencastproject.job.api.Job;
25  import org.opencastproject.mediapackage.Attachment;
26  import org.opencastproject.mediapackage.MediaPackageElement.Type;
27  import org.opencastproject.mediapackage.MediaPackageElementBuilder;
28  import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
29  import org.opencastproject.mediapackage.MediaPackageElementParser;
30  import org.opencastproject.mediapackage.MediaPackageException;
31  import org.opencastproject.mediapackage.Track;
32  import org.opencastproject.security.api.OrganizationDirectoryService;
33  import org.opencastproject.security.api.SecurityService;
34  import org.opencastproject.security.api.UserDirectoryService;
35  import org.opencastproject.serviceregistry.api.ServiceRegistry;
36  import org.opencastproject.serviceregistry.api.ServiceRegistryException;
37  import org.opencastproject.util.IoSupport;
38  import org.opencastproject.util.LoadUtil;
39  import org.opencastproject.util.NotFoundException;
40  import org.opencastproject.waveform.api.WaveformService;
41  import org.opencastproject.waveform.api.WaveformServiceException;
42  import org.opencastproject.workspace.api.Workspace;
43  
44  import org.apache.commons.io.FileUtils;
45  import org.apache.commons.io.FilenameUtils;
46  import org.apache.commons.lang3.StringUtils;
47  import org.osgi.service.cm.ConfigurationException;
48  import org.osgi.service.cm.ManagedService;
49  import org.osgi.service.component.ComponentContext;
50  import org.osgi.service.component.annotations.Activate;
51  import org.osgi.service.component.annotations.Component;
52  import org.osgi.service.component.annotations.Reference;
53  import org.slf4j.Logger;
54  import org.slf4j.LoggerFactory;
55  
56  import java.io.BufferedReader;
57  import java.io.File;
58  import java.io.FileInputStream;
59  import java.io.FileNotFoundException;
60  import java.io.IOException;
61  import java.io.InputStreamReader;
62  import java.net.URI;
63  import java.util.Arrays;
64  import java.util.Dictionary;
65  import java.util.List;
66  import java.util.concurrent.TimeUnit;
67  
68  
69  /**
70   * This service creates a waveform image from a media file with at least one audio channel.
71   * This will be done using ffmpeg.
72   */
73  @Component(
74      immediate = true,
75      service = { WaveformService.class,ManagedService.class },
76      property = {
77          "service.description=Waveform Service"
78      }
79  )
80  public class WaveformServiceImpl extends AbstractJobProducer implements WaveformService, ManagedService {
81  
82    /** The logging facility */
83    protected static final Logger logger = LoggerFactory.getLogger(WaveformServiceImpl.class);
84  
85    /** Path to the executable */
86    protected String binary = DEFAULT_FFMPEG_BINARY;
87  
88    /** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_JOB_LOAD */
89    public static final String WAVEFORM_JOB_LOAD_CONFIG_KEY = "job.load.waveform";
90  
91    /** The default job load of a waveform job */
92    public static final float DEFAULT_WAVEFORM_JOB_LOAD = 0.1f;
93  
94    /** The key to look for in the service configuration file to override the DEFAULT_FFMPEG_BINARY */
95    public static final String FFMPEG_BINARY_CONFIG_KEY = "org.opencastproject.composer.ffmpeg.path";
96  
97    /** The default path to the ffmpeg binary */
98    public static final String DEFAULT_FFMPEG_BINARY = "ffmpeg";
99  
100   /** The default waveform image scale algorithm */
101   public static final String DEFAULT_WAVEFORM_SCALE = "lin";
102 
103   /** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_SCALE */
104   public static final String WAVEFORM_SCALE_CONFIG_KEY = "waveform.scale";
105 
106   /** The default value if the waveforms (per audio channel) should be renderen next to each other (if true)
107    * or on top of each other (if false) */
108   public static final boolean DEFAULT_WAVEFORM_SPLIT_CHANNELS = false;
109 
110   /** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_SPLIT_CHANNELS */
111   public static final String WAVEFORM_SPLIT_CHANNELS_CONFIG_KEY = "waveform.split.channels";
112 
113   /** The default waveform colors per audio channel */
114   public static final String[] DEFAULT_WAVEFORM_COLOR = { "black" };
115 
116   /** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_COLOR */
117   public static final String WAVEFORM_COLOR_CONFIG_KEY = "waveform.color";
118 
119   public static final String DEFAULT_WAVEFORM_FILTER_MODE = "peak";
120 
121   public static final String WAVEFORM_FILTER_MODE_CONFIG_KEY = "waveform.filter-mode";
122 
123   /** The default filter to be optionally prepended to the showwavespic filter */
124   public static final String DEFAULT_WAVEFORM_FILTER_PRE = null;
125 
126   /** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_FILTER_PRE */
127   public static final String WAVEFORM_FILTER_PRE_CONFIG_KEY = "waveform.filter.pre";
128 
129   /** The default filter to be optionally appended to the showwavespic filter */
130   public static final String DEFAULT_WAVEFORM_FILTER_POST = null;
131 
132   /** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_FILTER_POST */
133   public static final String WAVEFORM_FILTER_POST_CONFIG_KEY = "waveform.filter.post";
134 
135   /** Resulting collection in the working file repository */
136   public static final String COLLECTION_ID = "waveform";
137 
138   /** List of available operations on jobs */
139   enum Operation {
140     Waveform
141   };
142 
143   /** The waveform job load */
144   private float waveformJobLoad = DEFAULT_WAVEFORM_JOB_LOAD;
145 
146   /** The waveform image scale algorithm */
147   private String waveformScale = DEFAULT_WAVEFORM_SCALE;
148 
149   /** The value if the waveforms (per audio channel) should be rendered next to each other (if true)
150    * or on top of each other (if false) */
151   private boolean waveformSplitChannels = DEFAULT_WAVEFORM_SPLIT_CHANNELS;
152 
153   /** The waveform colors per audio channel */
154   private String[] waveformColor = DEFAULT_WAVEFORM_COLOR;
155 
156   private String waveformFilterMode = DEFAULT_WAVEFORM_FILTER_MODE;
157 
158   /** Filter to be prepended to the showwavespic filter */
159   private String waveformFilterPre = DEFAULT_WAVEFORM_FILTER_PRE;
160 
161   /** Filter to be appended to the showwavespic filter */
162   private String waveformFilterPost = DEFAULT_WAVEFORM_FILTER_POST;
163 
164   /** Reference to the service registry */
165   private ServiceRegistry serviceRegistry = null;
166 
167   /** The workspace to use when retrieving remote media files */
168   private Workspace workspace = null;
169 
170   /** The security service */
171   private SecurityService securityService = null;
172 
173   /** The user directory service */
174   private UserDirectoryService userDirectoryService = null;
175 
176   /** The organization directory service */
177   private OrganizationDirectoryService organizationDirectoryService = null;
178 
179   public WaveformServiceImpl() {
180     super(JOB_TYPE);
181   }
182 
183   @Override
184   @Activate
185   public void activate(ComponentContext cc) {
186     super.activate(cc);
187     logger.info("Activate ffmpeg waveform service");
188     final String path = cc.getBundleContext().getProperty(FFMPEG_BINARY_CONFIG_KEY);
189     binary = (path == null ? DEFAULT_FFMPEG_BINARY : path);
190     logger.debug("ffmpeg binary set to {}", binary);
191   }
192 
193   @Override
194   public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
195     if (properties == null) {
196       return;
197     }
198     logger.debug("Configuring the waveform service");
199     waveformJobLoad = LoadUtil.getConfiguredLoadValue(properties,
200             WAVEFORM_JOB_LOAD_CONFIG_KEY, DEFAULT_WAVEFORM_JOB_LOAD, serviceRegistry);
201 
202     Object val = properties.get(WAVEFORM_SCALE_CONFIG_KEY);
203     if (val != null) {
204       if (StringUtils.isNotEmpty((String) val)) {
205         if (!"lin".equals(val) && !"log".equals(val)) {
206           logger.warn("Waveform scale configuration value '{}' is not in set of predefined values (lin, log). "
207                   + "The waveform image extraction job may fail.", val);
208         }
209         waveformScale = (String) val;
210       }
211     }
212 
213     val = properties.get(WAVEFORM_SPLIT_CHANNELS_CONFIG_KEY);
214     if (val != null) {
215       waveformSplitChannels = Boolean.parseBoolean((String) val);
216     }
217 
218     val = properties.get(WAVEFORM_COLOR_CONFIG_KEY);
219     if (val != null && StringUtils.isNotEmpty((String) val)) {
220       String colorValue = (String) val;
221       if (StringUtils.isNotEmpty(colorValue) && StringUtils.isNotBlank(colorValue)) {
222         waveformColor = StringUtils.split(colorValue, ", |:;");
223       }
224     }
225 
226     val = properties.get(WAVEFORM_FILTER_MODE_CONFIG_KEY);
227     if (val != null && StringUtils.isNotEmpty((String) val)) {
228       if (!"average".equals(val) && !"peak".equals(val)) {
229         logger.warn("Waveform filter mode configuration value '{}' is not in set of predefined values (average, peak). "
230                 + "The waveform image extraction job may fail.", val);
231       }
232       waveformFilterMode = (String) val;
233     }
234 
235     val = properties.get(WAVEFORM_FILTER_PRE_CONFIG_KEY);
236     if (val != null) {
237       waveformFilterPre = StringUtils.trimToNull((String) val);
238     } else {
239       waveformFilterPre = null;
240     }
241 
242     val = properties.get(WAVEFORM_FILTER_POST_CONFIG_KEY);
243     if (val != null) {
244       waveformFilterPost = StringUtils.trimToNull((String) val);
245     } else {
246       waveformFilterPost = null;
247     }
248   }
249 
250   /**
251    * {@inheritDoc}
252    *
253    * @see org.opencastproject.waveform.api.WaveformService#createWaveformImage(org.opencastproject.mediapackage.Track,
254    *         int, int, int, int, String)
255    */
256   @Override
257   public Job createWaveformImage(
258       Track sourceTrack, int pixelsPerMinute, int minWidth, int maxWidth, int height, String color
259   ) throws MediaPackageException, WaveformServiceException {
260     try {
261       return serviceRegistry.createJob(jobType, Operation.Waveform.toString(),
262           Arrays.asList(
263               MediaPackageElementParser.getAsXml(sourceTrack),
264               Integer.toString(pixelsPerMinute),
265               Integer.toString(minWidth),
266               Integer.toString(maxWidth),
267               Integer.toString(height),
268               color
269           ),
270           waveformJobLoad
271       );
272     } catch (ServiceRegistryException ex) {
273       throw new WaveformServiceException("Unable to create waveform job", ex);
274     }
275   }
276 
277   /**
278    * {@inheritDoc}
279    *
280    * @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
281    */
282   @Override
283   protected String process(Job job) throws Exception {
284     Operation op = null;
285     String operation = job.getOperation();
286     List<String> arguments = job.getArguments();
287     try {
288       op = Operation.valueOf(operation);
289       switch (op) {
290         case Waveform:
291           Track track = (Track) MediaPackageElementParser.getFromXml(arguments.get(0));
292           int pixelsPerMinute = Integer.parseInt(arguments.get(1));
293           int minWidth = Integer.parseInt(arguments.get(2));
294           int maxWidth = Integer.parseInt(arguments.get(3));
295           int height = Integer.parseInt(arguments.get(4));
296           String color = arguments.get(5);
297           Attachment waveformMpe = extractWaveform(track, pixelsPerMinute, minWidth, maxWidth, height, color);
298           return MediaPackageElementParser.getAsXml(waveformMpe);
299         default:
300           throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'");
301       }
302     } catch (IndexOutOfBoundsException e) {
303       throw new ServiceRegistryException("This argument list for operation '" + op + "' does not meet expectations", e);
304     } catch (MediaPackageException | WaveformServiceException e) {
305       throw new ServiceRegistryException("Error handling operation '" + op + "'", e);
306     }
307   }
308 
309   /**
310    * Create and run waveform extraction ffmpeg command.
311    *
312    * @param track source audio/video track with at least one audio channel
313    * @param pixelsPerMinute width of waveform image in pixels per minute
314    * @param minWidth minimum width of waveform image
315    * @param maxWidth maximum width of waveform image
316    * @param height height of waveform image
317    * @param color color of waveform image
318    * @return waveform image attachment
319    * @throws WaveformServiceException if processing fails
320    */
321   private Attachment extractWaveform(
322       Track track, int pixelsPerMinute, int minWidth, int maxWidth, int height, String color
323   ) throws WaveformServiceException {
324     if (!track.hasAudio()) {
325       throw new WaveformServiceException("Track has no audio");
326     }
327 
328     // copy source file into workspace
329     File mediaFile;
330     try {
331       mediaFile = workspace.get(track.getURI());
332     } catch (NotFoundException e) {
333       throw new WaveformServiceException(
334           "Error finding the media file in the workspace", e);
335     } catch (IOException e) {
336       throw new WaveformServiceException(
337           "Error reading the media file in the workspace", e);
338     }
339 
340     String waveformFilePath = FilenameUtils.removeExtension(mediaFile.getAbsolutePath())
341             .concat('-' + track.getIdentifier()).concat("-waveform.png");
342 
343     int width = getWaveformImageWidth(track, pixelsPerMinute, minWidth, maxWidth);
344 
345     // create ffmpeg command
346     String[] command = new String[] {
347         binary,
348         "-nostats", "-nostdin", "-hide_banner",
349         "-i", mediaFile.getAbsolutePath(),
350         "-lavfi", createWaveformFilter(width, height, color),
351         "-frames:v", "1",
352         "-an", "-vn", "-sn",
353         waveformFilePath
354     };
355     logger.debug("Start waveform ffmpeg process: {}", StringUtils.join(command, " "));
356     logger.info("Create waveform image file for track '{}' at {}", track.getIdentifier(), waveformFilePath);
357 
358     // run ffmpeg
359     ProcessBuilder pb = new ProcessBuilder(command);
360     pb.redirectErrorStream(true);
361     Process ffmpegProcess = null;
362     int exitCode = 1;
363     BufferedReader errStream = null;
364     try {
365       ffmpegProcess = pb.start();
366 
367       errStream = new BufferedReader(new InputStreamReader(ffmpegProcess.getInputStream()));
368       String line = errStream.readLine();
369       while (line != null) {
370         logger.debug(line);
371         line = errStream.readLine();
372       }
373 
374       exitCode = ffmpegProcess.waitFor();
375     } catch (IOException ex) {
376       throw new WaveformServiceException("Start ffmpeg process failed", ex);
377     } catch (InterruptedException ex) {
378       throw new WaveformServiceException("Waiting for encoder process exited was interrupted unexpectedly", ex);
379     } finally {
380       IoSupport.closeQuietly(ffmpegProcess);
381       IoSupport.closeQuietly(errStream);
382       if (exitCode != 0) {
383         try {
384           FileUtils.forceDelete(new File(waveformFilePath));
385         } catch (IOException e) {
386           // it is ok, no output file was generated by ffmpeg
387         }
388       }
389     }
390 
391     if (exitCode != 0) {
392       throw new WaveformServiceException(String.format("The encoder process exited abnormally with exit code %s "
393               + "using command\n%s", exitCode, String.join(" ", command)));
394     }
395 
396     // put waveform image into workspace
397     FileInputStream waveformFileInputStream = null;
398     URI waveformFileUri;
399     try {
400       waveformFileInputStream = new FileInputStream(waveformFilePath);
401       waveformFileUri = workspace.putInCollection(COLLECTION_ID,
402               FilenameUtils.getName(waveformFilePath), waveformFileInputStream);
403       logger.info("Copied the created waveform to the workspace {}", waveformFileUri);
404     } catch (FileNotFoundException ex) {
405       throw new WaveformServiceException(String.format("Waveform image file '%s' not found", waveformFilePath), ex);
406     } catch (IOException ex) {
407       throw new WaveformServiceException(String.format(
408               "Can't write waveform image file '%s' to workspace", waveformFilePath), ex);
409     } catch (IllegalArgumentException ex) {
410       throw new WaveformServiceException(ex);
411     } finally {
412       IoSupport.closeQuietly(waveformFileInputStream);
413       logger.info("Deleted local waveform image file at {}", waveformFilePath);
414       FileUtils.deleteQuietly(new File(waveformFilePath));
415     }
416 
417     // create media package element
418     MediaPackageElementBuilder mpElementBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
419     // it is up to the workflow operation handler to set the attachment flavor
420     Attachment waveformMpe = (Attachment) mpElementBuilder.elementFromURI(
421             waveformFileUri, Type.Attachment, track.getFlavor());
422     waveformMpe.generateIdentifier();
423     return waveformMpe;
424   }
425 
426   /**
427    * Create an ffmpeg waveform filter with parameters based on input track and service configuration.
428    *
429    * @param width width of waveform image
430    * @param height height of waveform image
431    * @param color color of waveform image
432    * @return ffmpeg filter parameter
433    */
434   private String createWaveformFilter(int width, int height, String color) {
435     StringBuilder filterBuilder = new StringBuilder();
436     if (waveformFilterPre != null) {
437       filterBuilder.append(waveformFilterPre);
438       filterBuilder.append(",");
439     }
440     String[] waveformOperationColors = null;
441     //If the color was set, override the defaults
442     if (StringUtils.isNotBlank(color)) {
443       waveformOperationColors = StringUtils.split(color, "|");
444     } else {
445       waveformOperationColors = waveformColor;
446     }
447     filterBuilder.append("showwavespic=");
448     filterBuilder.append("split_channels=");
449     filterBuilder.append(waveformSplitChannels ? 1 : 0);
450     filterBuilder.append(":s=");
451     filterBuilder.append(width);
452     filterBuilder.append("x");
453     filterBuilder.append(height);
454     filterBuilder.append(":scale=");
455     filterBuilder.append(waveformScale);
456     filterBuilder.append(":filter=");
457     filterBuilder.append(waveformFilterMode);
458     filterBuilder.append(":colors=");
459     filterBuilder.append(StringUtils.join(Arrays.asList(waveformOperationColors), "|"));
460     if (waveformFilterPost != null) {
461       filterBuilder.append(",");
462       filterBuilder.append(waveformFilterPost);
463     }
464     return filterBuilder.toString();
465   }
466 
467   /**
468    * Return the waveform image width build from input track and service configuration.
469    *
470    * @param track source audio/video track with at least one audio channel
471    * @param pixelsPerMinute width of waveform image in pixels per minute
472    * @param minWidth minimum width of waveform image
473    * @param maxWidth maximum width of waveform image
474    * @return waveform image width
475    */
476   private int getWaveformImageWidth(Track track, int pixelsPerMinute, int minWidth, int maxWidth) {
477     int imageWidth = minWidth;
478     if (track.getDuration() > 0) {
479       int trackDurationMinutes = (int) TimeUnit.MILLISECONDS.toMinutes(track.getDuration());
480       if (pixelsPerMinute > 0 && trackDurationMinutes > 0) {
481         imageWidth = Math.max(minWidth, trackDurationMinutes * pixelsPerMinute);
482         imageWidth = Math.min(maxWidth, imageWidth);
483       }
484     }
485     return imageWidth;
486   }
487 
488   @Override
489   protected ServiceRegistry getServiceRegistry() {
490     return serviceRegistry;
491   }
492 
493   @Override
494   protected SecurityService getSecurityService() {
495     return securityService;
496   }
497 
498   @Override
499   protected UserDirectoryService getUserDirectoryService() {
500     return userDirectoryService;
501   }
502 
503   @Override
504   protected OrganizationDirectoryService getOrganizationDirectoryService() {
505     return organizationDirectoryService;
506   }
507 
508   @Reference
509   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
510     this.serviceRegistry = serviceRegistry;
511   }
512 
513   @Reference
514   public void setSecurityService(SecurityService securityService) {
515     this.securityService = securityService;
516   }
517 
518   @Reference
519   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
520     this.userDirectoryService = userDirectoryService;
521   }
522 
523   @Reference
524   public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectoryService) {
525     this.organizationDirectoryService = organizationDirectoryService;
526   }
527 
528   @Reference
529   public void setWorkspace(Workspace workspace) {
530     this.workspace = workspace;
531   }
532 }