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