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.composer.impl;
23  
24  import static java.lang.String.format;
25  import static org.opencastproject.composer.impl.EncoderEngine.CMD_SUFFIX;
26  import static org.opencastproject.serviceregistry.api.Incidents.NO_DETAILS;
27  import static org.opencastproject.util.data.Option.none;
28  import static org.opencastproject.util.data.Option.some;
29  import static org.opencastproject.util.data.Tuple.tuple;
30  
31  import org.opencastproject.composer.api.ComposerService;
32  import org.opencastproject.composer.api.EncoderException;
33  import org.opencastproject.composer.api.EncodingProfile;
34  import org.opencastproject.composer.api.LaidOutElement;
35  import org.opencastproject.composer.api.VideoClip;
36  import org.opencastproject.composer.layout.Dimension;
37  import org.opencastproject.composer.layout.Layout;
38  import org.opencastproject.composer.layout.Serializer;
39  import org.opencastproject.inspection.api.MediaInspectionException;
40  import org.opencastproject.inspection.api.MediaInspectionService;
41  import org.opencastproject.job.api.AbstractJobProducer;
42  import org.opencastproject.job.api.Job;
43  import org.opencastproject.job.api.JobBarrier;
44  import org.opencastproject.mediapackage.AdaptivePlaylist;
45  import org.opencastproject.mediapackage.Attachment;
46  import org.opencastproject.mediapackage.MediaPackageElement;
47  import org.opencastproject.mediapackage.MediaPackageElementBuilder;
48  import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
49  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
50  import org.opencastproject.mediapackage.MediaPackageElementParser;
51  import org.opencastproject.mediapackage.MediaPackageException;
52  import org.opencastproject.mediapackage.Track;
53  import org.opencastproject.mediapackage.VideoStream;
54  import org.opencastproject.mediapackage.identifier.IdImpl;
55  import org.opencastproject.security.api.OrganizationDirectoryService;
56  import org.opencastproject.security.api.SecurityService;
57  import org.opencastproject.security.api.UserDirectoryService;
58  import org.opencastproject.serviceregistry.api.ServiceRegistry;
59  import org.opencastproject.serviceregistry.api.ServiceRegistryException;
60  import org.opencastproject.smil.api.SmilException;
61  import org.opencastproject.smil.api.SmilService;
62  import org.opencastproject.smil.entity.api.Smil;
63  import org.opencastproject.smil.entity.media.api.SmilMediaObject;
64  import org.opencastproject.smil.entity.media.container.api.SmilMediaContainer;
65  import org.opencastproject.smil.entity.media.element.api.SmilMediaElement;
66  import org.opencastproject.smil.entity.media.param.api.SmilMediaParam;
67  import org.opencastproject.smil.entity.media.param.api.SmilMediaParamGroup;
68  import org.opencastproject.util.FileSupport;
69  import org.opencastproject.util.JsonObj;
70  import org.opencastproject.util.LoadUtil;
71  import org.opencastproject.util.MimeTypes;
72  import org.opencastproject.util.NotFoundException;
73  import org.opencastproject.util.ReadinessIndicator;
74  import org.opencastproject.util.UnknownFileTypeException;
75  import org.opencastproject.util.data.Collections;
76  import org.opencastproject.util.data.Option;
77  import org.opencastproject.util.data.Tuple;
78  import org.opencastproject.workspace.api.Workspace;
79  
80  import com.google.gson.Gson;
81  
82  import org.apache.commons.io.FileUtils;
83  import org.apache.commons.io.FilenameUtils;
84  import org.apache.commons.io.IOUtils;
85  import org.apache.commons.lang3.StringUtils;
86  import org.apache.commons.lang3.math.NumberUtils;
87  import org.osgi.service.cm.ConfigurationException;
88  import org.osgi.service.cm.ManagedService;
89  import org.osgi.service.component.ComponentContext;
90  import org.osgi.service.component.annotations.Activate;
91  import org.osgi.service.component.annotations.Component;
92  import org.osgi.service.component.annotations.Deactivate;
93  import org.osgi.service.component.annotations.Modified;
94  import org.osgi.service.component.annotations.Reference;
95  import org.slf4j.Logger;
96  import org.slf4j.LoggerFactory;
97  
98  import java.io.File;
99  import java.io.FileInputStream;
100 import java.io.FileWriter;
101 import java.io.IOException;
102 import java.io.InputStream;
103 import java.io.PrintWriter;
104 import java.net.URI;
105 import java.net.URISyntaxException;
106 import java.text.DecimalFormat;
107 import java.text.DecimalFormatSymbols;
108 import java.util.ArrayList;
109 import java.util.Arrays;
110 import java.util.Collection;
111 import java.util.Dictionary;
112 import java.util.HashMap;
113 import java.util.HashSet;
114 import java.util.LinkedList;
115 import java.util.List;
116 import java.util.Locale;
117 import java.util.Map;
118 import java.util.Map.Entry;
119 import java.util.Optional;
120 import java.util.Properties;
121 import java.util.Set;
122 import java.util.stream.Collectors;
123 
124 /** FFMPEG based implementation of the composer service api. */
125 @Component(
126   property = {
127     "service.description=Composer (Encoder) Local Service",
128     "service.pid=org.opencastproject.composer.impl.ComposerServiceImpl"
129   },
130   immediate = true,
131   service = { ComposerService.class, ManagedService.class }
132 )
133 public class ComposerServiceImpl extends AbstractJobProducer implements ComposerService, ManagedService {
134   /**
135    * The indexes the composite job uses to create a Job
136    */
137   private static final int BACKGROUND_COLOR_INDEX = 6;
138   private static final int COMPOSITE_TRACK_SIZE_INDEX = 5;
139   private static final int LOWER_TRACK_INDEX = 1;
140   private static final int LOWER_TRACK_LAYOUT_INDEX = 2;
141   private static final int PROFILE_ID_INDEX = 0;
142   private static final int UPPER_TRACK_INDEX = 3;
143   private static final int UPPER_TRACK_LAYOUT_INDEX = 4;
144   private static final int WATERMARK_INDEX = 8;
145   private static final int WATERMARK_LAYOUT_INDEX = 9;
146   private static final int AUDIO_SOURCE_INDEX = 7;
147   /**
148    * Error codes
149    */
150   private static final int WORKSPACE_GET_IO_EXCEPTION = 1;
151   private static final int WORKSPACE_GET_NOT_FOUND = 2;
152   private static final int WORKSPACE_PUT_COLLECTION_IO_EXCEPTION = 3;
153   private static final int PROFILE_NOT_FOUND = 4;
154   private static final int ENCODING_FAILED = 7;
155   private static final int TRIMMING_FAILED = 8;
156   private static final int COMPOSITE_FAILED = 9;
157   private static final int CONCAT_FAILED = 10;
158   private static final int CONCAT_LESS_TRACKS = 11;
159   private static final int CONCAT_NO_DIMENSION = 12;
160   private static final int IMAGE_TO_VIDEO_FAILED = 13;
161   private static final int CONVERT_IMAGE_FAILED = 14;
162   private static final int IMAGE_EXTRACTION_FAILED = 15;
163   private static final int IMAGE_EXTRACTION_UNKNOWN_DURATION = 16;
164   private static final int IMAGE_EXTRACTION_TIME_OUTSIDE_DURATION = 17;
165   private static final int IMAGE_EXTRACTION_NO_VIDEO = 18;
166   private static final int PROCESS_SMIL_FAILED = 19;
167   private static final int MULTI_ENCODE_FAILED = 20;
168   private static final int NO_STREAMS = 23;
169 
170   /** The logging instance */
171   private static final Logger logger = LoggerFactory.getLogger(ComposerServiceImpl.class);
172 
173   /** Default location of the ffmepg binary (resembling the installer) */
174   private static final String FFMPEG_BINARY_DEFAULT = "ffmpeg";
175 
176   /** Configuration for the FFmpeg binary */
177   private static final String CONFIG_FFMPEG_PATH = "org.opencastproject.composer.ffmpeg.path";
178 
179   /** The collection name */
180   private static final String COLLECTION = "composer";
181 
182   /** Used to mark a track unavailable to composite. */
183   private static final String NOT_AVAILABLE = "n/a";
184 
185   /** The formatter for load values */
186   private static final DecimalFormat df = new DecimalFormat("#.#");
187 
188   /** Configuration for process-smil transition duration */
189   public static final String PROCESS_SMIL_CLIP_TRANSITION_DURATION = "org.composer.process_smil.edit.transition.duration";
190 
191   /** default transition duration for process_smil in seconds */
192   public static final float DEFAULT_PROCESS_SMIL_CLIP_TRANSITION_DURATION = 2.0f;
193 
194   /** The maximum job load allowed for operations that use multiple profile (ProcessSmil, MultiEncode) */
195   public static final float DEFAULT_JOB_LOAD_MAX_MULTIPLE_PROFILES = 0.8f;
196   /** The default factor used to multiply the sum of encoding profiles load job for ProcessSmil */
197   public static final float DEFAULT_PROCESS_SMIL_JOB_LOAD_FACTOR = 0.5f;
198   public static final float DEFAULT_MULTI_ENCODE_JOB_LOAD_FACTOR = 0.5f;
199 
200   public static final String JOB_LOAD_MAX_MULTIPLE_PROFILES = "job.load.max.multiple.profiles";
201   public static final String JOB_LOAD_FACTOR_PROCESS_SMIL = "job.load.factor.process.smil";
202   public static final String JOB_LOAD_FACTOR_MULTI_ENCODE = "job.load.factor.multiencode";
203 
204   private float maxMultipleProfilesJobLoad = DEFAULT_JOB_LOAD_MAX_MULTIPLE_PROFILES;
205   private float processSmilJobLoadFactor = DEFAULT_PROCESS_SMIL_JOB_LOAD_FACTOR;
206   private float multiEncodeJobLoadFactor = DEFAULT_MULTI_ENCODE_JOB_LOAD_FACTOR;
207 
208   // NOMINAL TRIM - remove this from the beginning of all mylti-encoded videos
209   // This was added to deal with lipsync issue with ffmpeg4, using libfdk_aac appears to fix it
210   public static final int DEFAULT_MULTI_ENCODE_TRIM_MILLISECONDS = 0;
211   public static final String MULTI_ENCODE_TRIM_MILLISECONDS = "org.composer.multi_encode.trim.milliseconds";
212   private int multiEncodeTrim = DEFAULT_MULTI_ENCODE_TRIM_MILLISECONDS;
213 
214   // Add fade to multi_encode - Fade in and fade out duration
215   public static final int DEFAULT_MULTI_ENCODE_FADE_MILLISECONDS = 0;
216   public static final String MULTI_ENCODE_FADE_MILLISECONDS = "org.composer.multi_encode.fade.milliseconds";
217   private int multiEncodeFade = DEFAULT_MULTI_ENCODE_FADE_MILLISECONDS;
218 
219   /** default transition */
220   private int transitionDuration = (int) (DEFAULT_PROCESS_SMIL_CLIP_TRANSITION_DURATION * 1000);
221 
222   /** List of available operations on jobs */
223   enum Operation {
224     Encode, Image, ImageConversion, Mux, Trim, Composite, Concat, ImageToVideo, ParallelEncode, Demux, ProcessSmil, MultiEncode
225   }
226 
227   /** tracked encoder engines */
228   private Set<EncoderEngine> activeEncoder = new HashSet<>();
229 
230   /** Encoding profile manager */
231   private EncodingProfileScanner profileScanner = null;
232 
233   /** Reference to the media inspection service */
234   private MediaInspectionService inspectionService = null;
235 
236   /** Reference to the workspace service */
237   private Workspace workspace = null;
238 
239   /** Reference to the receipt service */
240   private ServiceRegistry serviceRegistry;
241 
242   /** The organization directory service */
243   private OrganizationDirectoryService organizationDirectoryService = null;
244 
245   /** The security service */
246   private SecurityService securityService = null;
247 
248   /** SmilService for process SMIL */
249   private SmilService smilService;
250 
251   /** The user directory service */
252   private UserDirectoryService userDirectoryService = null;
253 
254   /** Path to the FFmpeg binary */
255   private String ffmpegBinary = FFMPEG_BINARY_DEFAULT;
256 
257   /** Creates a new composer service instance. */
258   public ComposerServiceImpl() {
259     super(JOB_TYPE);
260   }
261 
262   /**
263    * OSGi callback on component activation.
264    *
265    * @param cc
266    *          the component context
267    */
268   @Override
269   @Activate
270   public void activate(ComponentContext cc) {
271     super.activate(cc);
272     ffmpegBinary = StringUtils.defaultString(cc.getBundleContext().getProperty(CONFIG_FFMPEG_PATH),
273             FFMPEG_BINARY_DEFAULT);
274     logger.debug("ffmpeg binary: {}", ffmpegBinary);
275     logger.info("Activating composer service");
276   }
277 
278   /**
279    * OSGi callback on component deactivation.
280    */
281   @Deactivate
282   public void deactivate() {
283     logger.info("Deactivating composer service");
284     for (EncoderEngine engine: activeEncoder) {
285       engine.close();
286     }
287     logger.debug("Closed encoder engine factory");
288   }
289 
290   /**
291    * {@inheritDoc}
292    *
293    * @see org.opencastproject.composer.api.ComposerService#encode(org.opencastproject.mediapackage.Track,
294    *      java.lang.String)
295    */
296   @Override
297   public Job encode(Track sourceTrack, String profileId) throws EncoderException, MediaPackageException {
298     try {
299       final EncodingProfile profile = profileScanner.getProfile(profileId);
300       return serviceRegistry.createJob(JOB_TYPE, Operation.Encode.toString(),
301               Arrays.asList(profileId, MediaPackageElementParser.getAsXml(sourceTrack)), profile.getJobLoad());
302     } catch (ServiceRegistryException e) {
303       throw new EncoderException("Unable to create a job", e);
304     }
305   }
306 
307   /**
308    * Load track into workspace and return a file handler, filing an incident if something went wrong.
309    *
310    * @param job The job in which context this operation is executed
311    * @param name Name of the track to load into the workspace
312    * @param track Track to load into the workspace
313    * @return File handler for track
314    * @throws EncoderException Could not load file into workspace
315    */
316   private File loadTrackIntoWorkspace(final Job job, final String name, final Track track, boolean unique)
317           throws EncoderException {
318     try {
319       return workspace.get(track.getURI(), unique);
320     } catch (NotFoundException e) {
321       incident().recordFailure(job, WORKSPACE_GET_NOT_FOUND, e,
322               getWorkspaceMediapackageParams(name, track), NO_DETAILS);
323       throw new EncoderException(format("%s track %s not found", name, track));
324     } catch (IOException e) {
325       incident().recordFailure(job, WORKSPACE_GET_IO_EXCEPTION, e,
326               getWorkspaceMediapackageParams(name, track), NO_DETAILS);
327       throw new EncoderException(format("Unable to access %s track %s", name, track));
328     }
329   }
330 
331   /**
332    * Load URI into workspace by URI and return a file handler, filing an incident if something went wrong.
333    *
334    * @param job
335    *          The job in which context this operation is executed
336    * @param name
337    *          Name of the track to load into the workspace
338    * @param uri
339    *          URI of Track to load into the workspace
340    * @return File handler for track
341    * @throws EncoderException
342    *           Could not load file into workspace
343    */
344   private File loadURIIntoWorkspace(final Job job, final String name, final URI uri) throws EncoderException {
345     try {
346       return workspace.get(uri);
347     } catch (NotFoundException e) {
348       incident().recordFailure(job, WORKSPACE_GET_NOT_FOUND, e, getWorkspaceCollectionParams(name, name, uri),
349               NO_DETAILS);
350       throw new EncoderException(String.format("%s uri %s not found", name, uri));
351     } catch (IOException e) {
352       incident().recordFailure(job, WORKSPACE_GET_IO_EXCEPTION, e, getWorkspaceCollectionParams(name, name, uri),
353               NO_DETAILS);
354       throw new EncoderException(String.format("Unable to access %s uri %s", name, uri));
355     }
356   }
357 
358   /**
359    * Encodes audio and video track to a file. If both an audio and a video track are given, they are muxed together into
360    * one movie container.
361    *
362    * @param tracks
363    *          tracks to use for processing
364    * @param profileId
365    *          the encoding profile
366    * @param properties
367    *          encoding properties
368    * @return the encoded track or none if the operation does not return a track. This may happen for example when doing
369    *         two pass encodings where the first pass only creates metadata for the second one
370    * @throws EncoderException
371    *           if encoding fails
372    */
373   private Option<Track> encode(final Job job, Map<String, Track> tracks, String profileId)
374           throws EncoderException, MediaPackageException {
375 
376     final String targetTrackId = IdImpl.fromUUID().toString();
377 
378     Map<String, File> files = new HashMap<>();
379     // Get the tracks and make sure they exist
380     for (Entry<String, Track> track: tracks.entrySet()) {
381       files.put(track.getKey(), loadTrackIntoWorkspace(job, track.getKey(), track.getValue(), false));
382     }
383 
384     // Get the encoding profile
385     final EncodingProfile profile = getProfile(job, profileId);
386 
387     List <String> trackMsg = new LinkedList<>();
388     for (Entry<String, Track> track: tracks.entrySet()) {
389       trackMsg.add(format("%s: %s", track.getKey(), track.getValue().getIdentifier()));
390     }
391     logger.info("Encoding {} into {} using profile {}", StringUtils.join(trackMsg, ", "), targetTrackId, profileId);
392 
393     // Do the work
394     final EncoderEngine encoder = getEncoderEngine();
395     List<File> output;
396     try {
397       output = encoder.process(files, profile, null);
398     } catch (EncoderException e) {
399       Map<String, String> params = new HashMap<>();
400       for (Entry<String, Track> track: tracks.entrySet()) {
401         params.put(track.getKey(), track.getValue().getIdentifier());
402       }
403       params.put("profile", profile.getIdentifier());
404       params.put("properties", "EMPTY");
405       incident().recordFailure(job, ENCODING_FAILED, e, params, detailsFor(e, encoder));
406       throw e;
407     } finally {
408       activeEncoder.remove(encoder);
409     }
410 
411     // We expect zero or one file as output
412     if (output.size() == 0) {
413       return none();
414     } else if (output.size() != 1) {
415       // Ensure we do not leave behind old files in the workspace
416       for (File file : output) {
417         FileUtils.deleteQuietly(file);
418       }
419       throw new EncoderException("Composite does not support multiple files as output");
420     }
421 
422     // Put the file in the workspace
423     URI workspaceURI = putToCollection(job, output.get(0), "encoded file");
424 
425     // Have the encoded track inspected and return the result
426     Track inspectedTrack = inspect(job, workspaceURI);
427     inspectedTrack.setIdentifier(targetTrackId);
428 
429     return some(inspectedTrack);
430   }
431 
432   /**
433    * Encodes audio and video track to a file. If both an audio and a video track are given, they are muxed together into
434    * one movie container.
435    *
436    * @param job
437    *          Job in which context the encoding is done
438    * @param mediaTrack
439    *          Source track
440    * @param profileId
441    *          the encoding profile
442    * @return the encoded track or none if the operation does not return a track. This may happen for example when doing
443    *         two pass encodings where the first pass only creates metadata for the second one
444    * @throws EncoderException
445    *           if encoding fails
446    */
447   private List <Track> parallelEncode(Job job, Track mediaTrack, String profileId)
448           throws EncoderException, MediaPackageException {
449     if (job == null) {
450       throw new EncoderException("The Job parameter must not be null");
451     }
452     // Get the tracks and make sure they exist
453     final File mediaFile = loadTrackIntoWorkspace(job, "source", mediaTrack, false);
454 
455     // Create the engine
456     final EncodingProfile profile = getProfile(profileId);
457     final EncoderEngine encoderEngine = getEncoderEngine();
458 
459     // conditional settings based on frame height and width
460     final Optional<VideoStream> videoStream = Arrays.stream(mediaTrack.getStreams())
461             .filter((stream -> stream instanceof VideoStream))
462             .map(stream -> (VideoStream) stream)
463             .findFirst();
464     final int height = videoStream.map(vs -> vs.getFrameHeight()).orElse(0);
465     final int width = videoStream.map(vs -> vs.getFrameWidth()).orElse(0);
466     Map<String, String> properties = new HashMap<>();
467     for (String key: profile.getExtensions().keySet()) {
468       if (key.startsWith(CMD_SUFFIX + ".if-height-geq-")) {
469         final int heightCondition = Integer.parseInt(key.substring((CMD_SUFFIX + ".if-height-geq-").length()));
470         if (heightCondition <= height) {
471           properties.put(key, profile.getExtension(key));
472         }
473       } else if (key.startsWith(CMD_SUFFIX + ".if-height-lt-")) {
474         final int heightCondition = Integer.parseInt(key.substring((CMD_SUFFIX + ".if-height-lt-").length()));
475         if (heightCondition > height) {
476           properties.put(key, profile.getExtension(key));
477         }
478       } else if (key.startsWith(CMD_SUFFIX + ".if-width-or-height-geq-")) {
479         final String[] resCondition = key.substring((CMD_SUFFIX + ".if-width-or-height-geq-").length()).split("-");
480         final int widthCondition = Integer.parseInt(resCondition[0]);
481         final int heightCondition = Integer.parseInt(resCondition[1]);
482 
483         if (heightCondition <= height || widthCondition <= width) {
484           properties.put(key, profile.getExtension(key));
485         }
486       }
487     }
488 
489     // List of encoded tracks
490     LinkedList<Track> encodedTracks = new LinkedList<>();
491     // Do the work
492     Map<String, File> source = new HashMap<>();
493     source.put("video", mediaFile);
494     List<File> outputFiles = encoderEngine.process(source, profile, properties);
495     var returnURLs = new ArrayList<URI>();
496     var tagsForUrls = new ArrayList<List<String>>();
497     activeEncoder.remove(encoderEngine);
498     int i = 0;
499     var fileMapping = new HashMap<String, String>();
500     for (File file: outputFiles) {
501       fileMapping.put(file.getName(), job.getId() + "_" + i + "." + FilenameUtils.getExtension(file.getName()));
502       i++;
503     }
504     boolean isHLS = false;
505     for (File file: outputFiles) {
506       // Rewrite HLS references if necessary
507       if (AdaptivePlaylist.isPlaylist(file)) {
508         isHLS = true;
509         logger.debug("Rewriting HLS references in {}", file);
510         try {
511           AdaptivePlaylist.hlsRewriteFileReference(file, fileMapping);
512         } catch (IOException e) {
513           throw new EncoderException("Unable to rewrite HLS references", e);
514         }
515       }
516 
517       // Put the file in the workspace
518       final List<String> tags = profile.getTags();
519       try (InputStream in = new FileInputStream(file)) {
520         var encodedFileName = file.getName();
521         var workspaceFilename = fileMapping.get(encodedFileName);
522         var url = workspace.putInCollection(COLLECTION, workspaceFilename, in);
523         returnURLs.add(url);
524 
525         var tagsForUrl = new ArrayList<String>();
526         for (final String tag : tags) {
527           if (encodedFileName.endsWith(profile.getSuffix(tag))) {
528             tagsForUrl.add(tag);
529           }
530         }
531         tagsForUrls.add(tagsForUrl);
532 
533         logger.info("Copied the encoded file to the workspace at {}", url);
534       } catch (Exception e) {
535         throw new EncoderException("Unable to put the encoded file into the workspace", e);
536       }
537     }
538 
539     // Have the encoded track inspected and return the result
540     for (Track inspectedTrack: inspect(job, returnURLs, tagsForUrls)) {
541       final String targetTrackId = IdImpl.fromUUID().toString();
542       inspectedTrack.setIdentifier(targetTrackId);
543       if (isHLS) {
544         AdaptivePlaylist.setLogicalName(inspectedTrack);
545       }
546 
547       encodedTracks.add(inspectedTrack);
548     }
549 
550     // Clean up workspace
551     for (File encodingOutput: outputFiles) {
552       if (encodingOutput.delete()) {
553         logger.info("Deleted the local copy of the encoded file at {}", encodingOutput.getAbsolutePath());
554       } else {
555         logger.warn("Unable to delete the encoding output at {}", encodingOutput);
556       }
557     }
558 
559     return encodedTracks;
560   }
561 
562   /**
563    * {@inheritDoc}
564    *
565    * @see org.opencastproject.composer.api.ComposerService#encode(org.opencastproject.mediapackage.Track,
566    *      java.lang.String)
567    */
568   @Override
569   public Job parallelEncode(Track sourceTrack, String profileId) throws EncoderException, MediaPackageException {
570     try {
571       final EncodingProfile profile = profileScanner.getProfile(profileId);
572       logger.info("Starting parallel encode with profile {} with job load {}", profileId, df.format(profile.getJobLoad()));
573       return serviceRegistry.createJob(JOB_TYPE, Operation.ParallelEncode.toString(),
574               Arrays.asList(profileId, MediaPackageElementParser.getAsXml(sourceTrack)), profile.getJobLoad());
575     } catch (ServiceRegistryException e) {
576       throw new EncoderException("Unable to create a job", e);
577     }
578   }
579 
580   /**
581    * {@inheritDoc}
582    *
583    * @see org.opencastproject.composer.api.ComposerService#trim(org.opencastproject.mediapackage.Track,
584    *      java.lang.String, long, long)
585    */
586   @Override
587   public Job trim(final Track sourceTrack, final String profileId, final long start, final long duration)
588           throws EncoderException, MediaPackageException {
589     try {
590       final EncodingProfile profile = profileScanner.getProfile(profileId);
591       return serviceRegistry.createJob(JOB_TYPE, Operation.Trim.toString(),
592               Arrays.asList(profileId, MediaPackageElementParser.getAsXml(sourceTrack), Long.toString(start),
593                       Long.toString(duration)), profile.getJobLoad());
594     } catch (ServiceRegistryException e) {
595       throw new EncoderException("Unable to create a job", e);
596     }
597   }
598 
599   /**
600    * Trims the given track using the encoding profile <code>profileId</code> and the given starting point and duration
601    * in miliseconds.
602    *
603    * @param job
604    *          the associated job
605    * @param sourceTrack
606    *          the source track
607    * @param profileId
608    *          the encoding profile identifier
609    * @param start
610    *          the trimming in-point in millis
611    * @param duration
612    *          the trimming duration in millis
613    * @return the trimmed track or none if the operation does not return a track. This may happen for example when doing
614    *         two pass encodings where the first pass only creates metadata for the second one
615    * @throws EncoderException
616    *           if trimming fails
617    */
618   private Option<Track> trim(Job job, Track sourceTrack, String profileId, long start, long duration)
619           throws EncoderException {
620     String targetTrackId = IdImpl.fromUUID().toString();
621 
622     // Get the track and make sure it exists
623     final File trackFile = loadTrackIntoWorkspace(job, "source", sourceTrack, false);
624 
625     // Get the encoding profile
626     final EncodingProfile profile = getProfile(job, profileId);
627 
628     // Create the engine
629     final EncoderEngine encoderEngine = getEncoderEngine();
630 
631     File output;
632     try {
633       output = encoderEngine.trim(trackFile, profile, start, duration, null);
634     } catch (EncoderException e) {
635       Map<String, String> params = new HashMap<>();
636       params.put("track", sourceTrack.getURI().toString());
637       params.put("profile", profile.getIdentifier());
638       params.put("start", Long.toString(start));
639       params.put("duration", Long.toString(duration));
640       incident().recordFailure(job, TRIMMING_FAILED, e, params, detailsFor(e, encoderEngine));
641       throw e;
642     } finally {
643       activeEncoder.remove(encoderEngine);
644     }
645 
646     // trim did not return a file
647     if (!output.exists() || output.length() == 0)
648       return none();
649 
650     // Put the file in the workspace
651     URI workspaceURI = putToCollection(job, output, "trimmed file");
652 
653     // Have the encoded track inspected and return the result
654     Track inspectedTrack = inspect(job, workspaceURI);
655     inspectedTrack.setIdentifier(targetTrackId);
656     return some(inspectedTrack);
657   }
658 
659   /**
660    * {@inheritDoc}
661    *
662    * @see org.opencastproject.composer.api.ComposerService#mux(org.opencastproject.mediapackage.Track,
663    *      org.opencastproject.mediapackage.Track, java.lang.String)
664    */
665   @Override
666   public Job mux(Track videoTrack, Track audioTrack, String profileId) throws EncoderException, MediaPackageException {
667     try {
668       final EncodingProfile profile = profileScanner.getProfile(profileId);
669       return serviceRegistry.createJob(JOB_TYPE, Operation.Mux.toString(),
670               Arrays.asList(profileId, MediaPackageElementParser.getAsXml(videoTrack),
671                       MediaPackageElementParser.getAsXml(audioTrack)), profile.getJobLoad());
672     } catch (ServiceRegistryException e) {
673       throw new EncoderException("Unable to create a job", e);
674     }
675   }
676 
677   /**
678    * Muxes the audio and video track into one movie container.
679    *
680    * @param job
681    *          the associated job
682    * @param videoTrack
683    *          the video track
684    * @param audioTrack
685    *          the audio track
686    * @param profileId
687    *          the profile identifier
688    * @return the muxed track
689    * @throws EncoderException
690    *           if encoding fails
691    * @throws MediaPackageException
692    *           if serializing the mediapackage elements fails
693    */
694   private Option<Track> mux(Job job, Track videoTrack, Track audioTrack, String profileId) throws EncoderException,
695           MediaPackageException {
696     return encode(job, Collections.map(tuple("audio", audioTrack), tuple("video", videoTrack)), profileId);
697   }
698 
699   /**
700    * {@inheritDoc}
701    */
702   @Override
703   public Job composite(Dimension compositeTrackSize, Option<LaidOutElement<Track>> upperTrack,
704           LaidOutElement<Track> lowerTrack, Option<LaidOutElement<Attachment>> watermark, String profileId,
705           String background, String sourceAudioName) throws EncoderException, MediaPackageException {
706     List<String> arguments = new ArrayList<>(10);
707     arguments.add(PROFILE_ID_INDEX, profileId);
708     arguments.add(LOWER_TRACK_INDEX, MediaPackageElementParser.getAsXml(lowerTrack.getElement()));
709     arguments.add(LOWER_TRACK_LAYOUT_INDEX, Serializer.json(lowerTrack.getLayout()).toJson());
710     if (upperTrack.isNone()) {
711       arguments.add(UPPER_TRACK_INDEX, NOT_AVAILABLE);
712       arguments.add(UPPER_TRACK_LAYOUT_INDEX, NOT_AVAILABLE);
713     } else {
714       arguments.add(UPPER_TRACK_INDEX, MediaPackageElementParser.getAsXml(upperTrack.get().getElement()));
715       arguments.add(UPPER_TRACK_LAYOUT_INDEX, Serializer.json(upperTrack.get().getLayout()).toJson());
716     }
717     arguments.add(COMPOSITE_TRACK_SIZE_INDEX, Serializer.json(compositeTrackSize).toJson());
718     arguments.add(BACKGROUND_COLOR_INDEX, background);
719     arguments.add(AUDIO_SOURCE_INDEX, sourceAudioName);
720     if (watermark.isSome()) {
721       LaidOutElement<Attachment> watermarkLaidOutElement = watermark.get();
722       arguments.add(WATERMARK_INDEX, MediaPackageElementParser.getAsXml(watermarkLaidOutElement.getElement()));
723       arguments.add(WATERMARK_LAYOUT_INDEX, Serializer.json(watermarkLaidOutElement.getLayout()).toJson());
724     }
725     try {
726       final EncodingProfile profile = profileScanner.getProfile(profileId);
727       return serviceRegistry.createJob(JOB_TYPE, Operation.Composite.toString(), arguments, profile.getJobLoad());
728     } catch (ServiceRegistryException e) {
729       throw new EncoderException("Unable to create composite job", e);
730     }
731   }
732 
733   private Option<Track> composite(Job job, Dimension compositeTrackSize, LaidOutElement<Track> lowerLaidOutElement,
734           Option<LaidOutElement<Track>> upperLaidOutElement, Option<LaidOutElement<Attachment>> watermarkOption,
735           String profileId, String backgroundColor, String audioSourceName) throws EncoderException, MediaPackageException {
736 
737     // Get the encoding profile
738     final EncodingProfile profile = getProfile(job, profileId);
739 
740     // Create the engine
741     final EncoderEngine encoderEngine = getEncoderEngine();
742 
743     final String targetTrackId = IdImpl.fromUUID().toString();
744     Option<File> upperVideoFile = Option.none();
745     try {
746       // Get the tracks and make sure they exist
747       final File lowerVideoFile = loadTrackIntoWorkspace(job, "lower video", lowerLaidOutElement.getElement(), false);
748 
749       if (upperLaidOutElement.isSome()) {
750         upperVideoFile = Option.option(
751                 loadTrackIntoWorkspace(job, "upper video", upperLaidOutElement.get().getElement(), false));
752       }
753       File watermarkFile = null;
754       if (watermarkOption.isSome()) {
755         try {
756           watermarkFile = workspace.get(watermarkOption.get().getElement().getURI());
757         } catch (NotFoundException e) {
758           incident().recordFailure(job, WORKSPACE_GET_NOT_FOUND, e,
759                   getWorkspaceMediapackageParams("watermark image", watermarkOption.get().getElement()),
760                   NO_DETAILS);
761           throw new EncoderException("Requested watermark image " + watermarkOption.get().getElement()
762                   + " is not found");
763         } catch (IOException e) {
764           incident().recordFailure(job, WORKSPACE_GET_IO_EXCEPTION, e,
765                   getWorkspaceMediapackageParams("watermark image", watermarkOption.get().getElement()),
766                   NO_DETAILS);
767           throw new EncoderException("Unable to access right watermark image " + watermarkOption.get().getElement());
768         }
769         if (upperLaidOutElement.isSome()) {
770           logger.info("Composing lower video track {} {} and upper video track {} {} including watermark {} {} into {}",
771                   lowerLaidOutElement.getElement().getIdentifier(), lowerLaidOutElement.getElement().getURI(),
772                   upperLaidOutElement.get().getElement().getIdentifier(), upperLaidOutElement.get().getElement().getURI(),
773                   watermarkOption.get().getElement().getIdentifier(), watermarkOption.get().getElement().getURI(),
774                   targetTrackId);
775         } else {
776           logger.info("Composing video track {} {} including watermark {} {} into {}",
777                   lowerLaidOutElement.getElement().getIdentifier(), lowerLaidOutElement.getElement().getURI(),
778                   watermarkOption.get().getElement().getIdentifier(), watermarkOption.get().getElement().getURI(),
779                   targetTrackId);
780         }
781       } else {
782         if (upperLaidOutElement.isSome()) {
783           logger.info("Composing lower video track {} {} and upper video track {} {} into {}",
784                   lowerLaidOutElement.getElement().getIdentifier(), lowerLaidOutElement.getElement().getURI(),
785                   upperLaidOutElement.get().getElement().getIdentifier(), upperLaidOutElement.get().getElement().getURI(),
786                   targetTrackId);
787         } else {
788           logger.info("Composing video track {} {} into {}", lowerLaidOutElement.getElement().getIdentifier(),
789                   lowerLaidOutElement.getElement().getURI(), targetTrackId);
790         }
791       }
792 
793       // set properties for encoding profile
794       Map<String, String> properties = new HashMap<>();
795 
796       // always
797       final Layout lowerLayout = lowerLaidOutElement.getLayout();
798       final String lowerPosition = lowerLayout.getOffset().getX() + ":" + lowerLayout.getOffset().getY();
799       final String scaleLower = lowerLayout.getDimension().getWidth() + ":"
800               + lowerLayout.getDimension().getHeight();
801       final String padLower = compositeTrackSize.getWidth() + ":" + compositeTrackSize.getHeight() + ":"
802               + lowerPosition + ":" + backgroundColor;
803       properties.put("scaleLower", scaleLower);
804       properties.put("padLower", padLower);
805 
806       // if dual stream
807       if (upperVideoFile.isSome() && upperLaidOutElement.isSome()) {
808         final Layout upperLayout = upperLaidOutElement.get().getLayout();
809         final String upperPosition = upperLayout.getOffset().getX() + ":" + upperLayout.getOffset().getY();
810         final String scaleUpper = upperLayout.getDimension().getWidth() + ":"
811                 + upperLayout.getDimension().getHeight();
812 
813         properties.put("scaleUpper", scaleUpper);
814         properties.put("upperPosition", upperPosition);
815         properties.put("upperFile", upperVideoFile.get().getAbsolutePath());
816 
817         // audio mapping
818         boolean lowerAudio = lowerLaidOutElement.getElement().hasAudio();
819         boolean upperAudio = upperLaidOutElement.get().getElement().hasAudio();
820 
821         if (lowerAudio && upperAudio && (ComposerService.BOTH.equalsIgnoreCase(audioSourceName)
822                 || StringUtils.isBlank(audioSourceName))) {
823           properties.put(CMD_SUFFIX + ".audioMapping", ";[0:a][1:a]amix=inputs=2[aout] -map [out] -map [aout]");
824         } else if (lowerAudio && !ComposerService.UPPER.equalsIgnoreCase(audioSourceName)) {
825           properties.put(CMD_SUFFIX + ".audioMapping", " -map [out] -map 0:a");
826         } else if (upperAudio && !ComposerService.LOWER.equalsIgnoreCase(audioSourceName)) {
827           properties.put(CMD_SUFFIX + ".audioMapping", " -map [out] -map 1:a");
828         } else {
829           properties.put(CMD_SUFFIX + ".audioMapping", " -map [out]");
830         }
831       }
832 
833       // if watermark
834       if (watermarkOption.isSome()) {
835         final LaidOutElement<Attachment> watermarkLayout = watermarkOption.get();
836         String watermarkPosition =
837                 watermarkLayout.getLayout().getOffset().getX() + ":" + watermarkLayout.getLayout().getOffset().getY();
838         properties.put("watermarkPosition", watermarkPosition);
839         properties.put("watermarkFile", watermarkFile.getAbsoluteFile().toString());
840       }
841 
842       // set conditional variables defined in encoding profile
843       for (String key: profile.getExtensions().keySet()) {
844         if (key.startsWith(CMD_SUFFIX + ".if-single-stream")
845                 && (upperLaidOutElement.isNone() || upperVideoFile.isNone())) {
846           properties.put(key, profile.getExtension(key));
847         } else if (key.startsWith(CMD_SUFFIX + ".if-dual-stream")
848                 && upperVideoFile.isSome() && upperLaidOutElement.isSome()) {
849           properties.put(key, profile.getExtension(key));
850         } else if (key.startsWith(CMD_SUFFIX + ".if-watermark") && watermarkOption.isSome()) {
851           properties.put(key, profile.getExtension(key));
852         } else if (key.startsWith(CMD_SUFFIX + ".if-no-watermark") && watermarkOption.isNone()) {
853           properties.put(key, profile.getExtension(key));
854         }
855       }
856 
857       List<File> output;
858       try {
859         Map<String, File> source = new HashMap<>();
860         if (upperVideoFile.isSome()) {
861           source.put("audio", upperVideoFile.get());
862         }
863         source.put("video", lowerVideoFile);
864         output = encoderEngine.process(source, profile, properties);
865       } catch (EncoderException e) {
866         Map<String, String> params = new HashMap<>();
867         if (upperLaidOutElement.isSome()) {
868           params.put("upper", upperLaidOutElement.get().getElement().getURI().toString());
869         }
870         params.put("lower", lowerLaidOutElement.getElement().getURI().toString());
871         if (watermarkFile != null)
872           params.put("watermark", watermarkOption.get().getElement().getURI().toString());
873         params.put("profile", profile.getIdentifier());
874         params.put("properties", properties.toString());
875         incident().recordFailure(job, COMPOSITE_FAILED, e, params, detailsFor(e, encoderEngine));
876         throw e;
877       } finally {
878         activeEncoder.remove(encoderEngine);
879       }
880 
881       // We expect one file as output
882       if (output.size() != 1) {
883         // Ensure we do not leave behind old files in the workspace
884         for (File file : output) {
885           FileUtils.deleteQuietly(file);
886         }
887         throw new EncoderException("Composite does not support multiple files as output");
888       }
889 
890 
891       // Put the file in the workspace
892       URI workspaceURI = putToCollection(job, output.get(0), "compound file");
893 
894       // Have the compound track inspected and return the result
895 
896       Track inspectedTrack = inspect(job, workspaceURI);
897       inspectedTrack.setIdentifier(targetTrackId);
898 
899       return some(inspectedTrack);
900     } catch (Exception e) {
901       if (upperLaidOutElement.isSome()) {
902         logger.warn("Error composing {}  and {}:",
903                 lowerLaidOutElement.getElement(), upperLaidOutElement.get().getElement(), e);
904       } else {
905         logger.warn("Error composing {}:", lowerLaidOutElement.getElement(), e);
906       }
907       if (e instanceof EncoderException) {
908         throw (EncoderException) e;
909       } else {
910         throw new EncoderException(e);
911       }
912     }
913   }
914 
915   @Override
916   public Job concat(String profileId, Dimension outputDimension, boolean sameCodec, Track... tracks) throws EncoderException,
917           MediaPackageException {
918     return concat(profileId, outputDimension, -1.0f, sameCodec, tracks);
919   }
920 
921   @Override
922   public Job concat(String profileId, Dimension outputDimension, float outputFrameRate, boolean sameCodec, Track... tracks) throws EncoderException,
923           MediaPackageException {
924     ArrayList<String> arguments = new ArrayList<String>();
925     arguments.add(0, profileId);
926     if (outputDimension != null) {
927       arguments.add(1, Serializer.json(outputDimension).toJson());
928     } else {
929       arguments.add(1, "");
930     }
931     arguments.add(2, String.format(Locale.US, "%f", outputFrameRate));
932     arguments.add(3, Boolean.toString(sameCodec));
933     for (int i = 0; i < tracks.length; i++) {
934       arguments.add(i + 4, MediaPackageElementParser.getAsXml(tracks[i]));
935     }
936     try {
937       final EncodingProfile profile = profileScanner.getProfile(profileId);
938       return serviceRegistry.createJob(JOB_TYPE, Operation.Concat.toString(), arguments, profile.getJobLoad());
939     } catch (ServiceRegistryException e) {
940       throw new EncoderException("Unable to create concat job", e);
941     }
942   }
943 
944   private Option<Track> concat(Job job, List<Track> tracks, String profileId, Dimension outputDimension,
945           float outputFrameRate, boolean sameCodec)
946           throws EncoderException, MediaPackageException {
947 
948     if (tracks.size() < 2) {
949       Map<String, String> params = new HashMap<>();
950       params.put("tracks-size", Integer.toString(tracks.size()));
951       params.put("tracks", StringUtils.join(tracks, ","));
952       incident().recordFailure(job, CONCAT_LESS_TRACKS, params);
953       throw new EncoderException("The track parameter must at least have two tracks present");
954     }
955 
956     boolean onlyAudio = true;
957     for (Track t : tracks) {
958       if (t.hasVideo()) {
959         onlyAudio = false;
960         break;
961       }
962     }
963 
964     if (!sameCodec && !onlyAudio && outputDimension == null) {
965       Map<String, String> params = new HashMap<>();
966       params.put("tracks", StringUtils.join(tracks, ","));
967       incident().recordFailure(job, CONCAT_NO_DIMENSION, params);
968       throw new EncoderException("The output dimension id parameter must not be null when concatenating video");
969     }
970 
971     final String targetTrackId = IdImpl.fromUUID().toString();
972     // Get the tracks and make sure they exist
973     List<File> trackFiles = new ArrayList<>();
974     int i = 0;
975     for (Track track : tracks) {
976       if (!track.hasAudio() && !track.hasVideo()) {
977         Map<String, String> params = new HashMap<>();
978         params.put("track-id", track.getIdentifier());
979         params.put("track-url", track.getURI().toString());
980         incident().recordFailure(job, NO_STREAMS, params);
981         throw new EncoderException("Track has no audio or video stream available: " + track);
982       }
983       trackFiles.add(i++, loadTrackIntoWorkspace(job, "concat", track, false));
984     }
985 
986     // Create the engine
987     final EncoderEngine encoderEngine = getEncoderEngine();
988 
989     if (onlyAudio) {
990       logger.info("Concatenating audio tracks {} into {}", trackFiles, targetTrackId);
991     } else {
992       logger.info("Concatenating video tracks {} into {}", trackFiles, targetTrackId);
993     }
994 
995     // Get the encoding profile
996     EncodingProfile profile = getProfile(job, profileId);
997     String concatCommand;
998     File fileList = null;
999     // Creating video filter command for concat
1000     if (sameCodec) {
1001       // create file list to use Concat demuxer - lossless - pack contents into a single container
1002       fileList = new File(workspace.rootDirectory(), "concat_tracklist_" + job.getId() + ".txt");
1003       fileList.deleteOnExit();
1004       try (PrintWriter printer = new PrintWriter(new FileWriter(fileList, true))) {
1005         for (Track track : tracks) {
1006           printer.append("file '").append(workspace.get(track.getURI()).getAbsolutePath()).append("'\n");
1007         }
1008       } catch (IOException e) {
1009         throw new EncoderException("Cannot create file list for concat", e);
1010       } catch (NotFoundException e) {
1011         throw new EncoderException("Cannot find track filename in workspace for concat", e);
1012       }
1013       concatCommand = "-f concat -safe 0 -i " + fileList.getAbsolutePath();
1014     } else {
1015       concatCommand = buildConcatCommand(onlyAudio, outputDimension, outputFrameRate, trackFiles, tracks);
1016     }
1017 
1018     Map<String, String> properties = new HashMap<>();
1019     properties.put(CMD_SUFFIX + ".concatCommand", concatCommand);
1020 
1021     File output;
1022     try {
1023       output = encoderEngine.encode(trackFiles.get(0), profile, properties);
1024     } catch (EncoderException e) {
1025       Map<String, String> params = new HashMap<>();
1026       List<String> trackList = new ArrayList<>();
1027       for (Track t : tracks) {
1028         trackList.add(t.getURI().toString());
1029       }
1030       params.put("tracks", StringUtils.join(trackList, ","));
1031       params.put("profile", profile.getIdentifier());
1032       params.put("properties", properties.toString());
1033       incident().recordFailure(job, CONCAT_FAILED, e, params, detailsFor(e, encoderEngine));
1034       throw e;
1035     } finally {
1036       activeEncoder.remove(encoderEngine);
1037       if (fileList != null) {
1038         FileSupport.deleteQuietly(fileList);
1039       }
1040     }
1041 
1042     // concat did not return a file
1043     if (!output.exists() || output.length() == 0)
1044       return none();
1045 
1046     // Put the file in the workspace
1047     URI workspaceURI = putToCollection(job, output, "concatenated file");
1048 
1049     // Have the concat track inspected and return the result
1050     Track inspectedTrack = inspect(job, workspaceURI);
1051     inspectedTrack.setIdentifier(targetTrackId);
1052 
1053     return some(inspectedTrack);
1054   }
1055 
1056   @Override
1057   public Job imageToVideo(Attachment sourceImageAttachment, String profileId, double time) throws EncoderException,
1058           MediaPackageException {
1059     try {
1060       final EncodingProfile profile = profileScanner.getProfile(profileId);
1061       if (profile == null) {
1062         throw new MediaPackageException(String.format("Encoding profile %s not found", profileId));
1063       }
1064       return serviceRegistry.createJob(JOB_TYPE, Operation.ImageToVideo.toString(), Arrays.asList(
1065               profileId, MediaPackageElementParser.getAsXml(sourceImageAttachment), Double.toString(time)),
1066               profile.getJobLoad());
1067     } catch (ServiceRegistryException e) {
1068       throw new EncoderException("Unable to create image to video job", e);
1069     }
1070   }
1071 
1072   private Option<Track> imageToVideo(Job job, Attachment sourceImage, String profileId, Double time)
1073           throws EncoderException, MediaPackageException {
1074 
1075     // Get the encoding profile
1076     final EncodingProfile profile = getProfile(job, profileId);
1077 
1078     final String targetTrackId = IdImpl.fromUUID().toString();
1079     // Get the attachment and make sure it exist
1080     File imageFile;
1081     try {
1082       imageFile = workspace.get(sourceImage.getURI());
1083     } catch (NotFoundException e) {
1084       incident().recordFailure(job, WORKSPACE_GET_NOT_FOUND, e,
1085               getWorkspaceMediapackageParams("source image", sourceImage), NO_DETAILS);
1086       throw new EncoderException("Requested source image " + sourceImage + " is not found");
1087     } catch (IOException e) {
1088       incident().recordFailure(job, WORKSPACE_GET_IO_EXCEPTION, e,
1089               getWorkspaceMediapackageParams("source image", sourceImage), NO_DETAILS);
1090       throw new EncoderException("Unable to access source image " + sourceImage);
1091     }
1092 
1093     // Create the engine
1094     final EncoderEngine encoderEngine = getEncoderEngine();
1095 
1096     logger.info("Converting image attachment {} into video {}", sourceImage.getIdentifier(), targetTrackId);
1097 
1098     Map<String, String> properties = new HashMap<>();
1099     if (time == -1)
1100       time = 0D;
1101 
1102     DecimalFormatSymbols ffmpegFormat = new DecimalFormatSymbols();
1103     ffmpegFormat.setDecimalSeparator('.');
1104     DecimalFormat df = new DecimalFormat("0.000", ffmpegFormat);
1105     properties.put("time", df.format(time));
1106 
1107     File output;
1108     try {
1109       output = encoderEngine.encode(imageFile, profile, properties);
1110     } catch (EncoderException e) {
1111       Map<String, String> params = new HashMap<>();
1112       params.put("image", sourceImage.getURI().toString());
1113       params.put("profile", profile.getIdentifier());
1114       params.put("properties", properties.toString());
1115       incident().recordFailure(job, IMAGE_TO_VIDEO_FAILED, e, params, detailsFor(e, encoderEngine));
1116       throw e;
1117     } finally {
1118       activeEncoder.remove(encoderEngine);
1119     }
1120 
1121     // encoding did not return a file
1122     if (!output.exists() || output.length() == 0)
1123       return none();
1124 
1125     // Put the file in the workspace
1126     URI workspaceURI = putToCollection(job, output, "converted image file");
1127 
1128     // Have the compound track inspected and return the result
1129     Track inspectedTrack = inspect(job, workspaceURI);
1130     inspectedTrack.setIdentifier(targetTrackId);
1131 
1132     return some(inspectedTrack);
1133   }
1134 
1135   /**
1136    * {@inheritDoc}
1137    *
1138    * @see org.opencastproject.composer.api.ComposerService#image(Track, String, double...)
1139    */
1140   @Override
1141   public Job image(Track sourceTrack, String profileId, double... times) throws EncoderException, MediaPackageException {
1142     if (sourceTrack == null)
1143       throw new IllegalArgumentException("SourceTrack cannot be null");
1144 
1145     if (times.length == 0)
1146       throw new IllegalArgumentException("At least one time argument has to be specified");
1147 
1148     List<String> parameters = new ArrayList<>();
1149     parameters.add(profileId);
1150     parameters.add(MediaPackageElementParser.getAsXml(sourceTrack));
1151     parameters.add(Boolean.TRUE.toString());
1152     for (double time : times) {
1153       parameters.add(Double.toString(time));
1154     }
1155 
1156     try {
1157       final EncodingProfile profile = profileScanner.getProfile(profileId);
1158       return serviceRegistry.createJob(JOB_TYPE, Operation.Image.toString(), parameters, profile.getJobLoad());
1159     } catch (ServiceRegistryException e) {
1160       throw new EncoderException("Unable to create a job", e);
1161     }
1162   }
1163 
1164   @Override
1165   public List<Attachment> imageSync(Track sourceTrack, String profileId, double... time) throws EncoderException,
1166       MediaPackageException {
1167     Job job = null;
1168     try {
1169       final EncodingProfile profile = profileScanner.getProfile(profileId);
1170       job = serviceRegistry
1171           .createJob(
1172               JOB_TYPE, Operation.Image.toString(), null, null, false, profile.getJobLoad());
1173       job.setStatus(Job.Status.RUNNING);
1174       job = serviceRegistry.updateJob(job);
1175       final List<Attachment> images = extractImages(job, sourceTrack, profileId, null, time);
1176       job.setStatus(Job.Status.FINISHED);
1177       return images;
1178     } catch (ServiceRegistryException | NotFoundException e) {
1179       throw new EncoderException("Unable to create a job", e);
1180     } finally {
1181       finallyUpdateJob(job);
1182     }
1183   }
1184 
1185   @Override
1186   public Job image(Track sourceTrack, String profileId, Map<String, String> properties) throws EncoderException,
1187           MediaPackageException {
1188     if (sourceTrack == null)
1189       throw new IllegalArgumentException("SourceTrack cannot be null");
1190 
1191     List<String> arguments = new ArrayList<String>();
1192     arguments.add(profileId);
1193     arguments.add(MediaPackageElementParser.getAsXml(sourceTrack));
1194     arguments.add(Boolean.FALSE.toString());
1195     arguments.add(getPropertiesAsString(properties));
1196 
1197     try {
1198       final EncodingProfile profile = profileScanner.getProfile(profileId);
1199       return serviceRegistry.createJob(JOB_TYPE, Operation.Image.toString(), arguments, profile.getJobLoad());
1200     } catch (ServiceRegistryException e) {
1201       throw new EncoderException("Unable to create a job", e);
1202     }
1203   }
1204 
1205   /**
1206    * Extracts an image from <code>sourceTrack</code> at the given point in time.
1207    *
1208    * @param job
1209    *          the associated job
1210    * @param sourceTrack
1211    *          the source track
1212    * @param profileId
1213    *          the identifier of the encoding profile to use
1214    * @param properties
1215    *          the properties applied to the encoding profile
1216    * @param times
1217    *          (one or more) times in seconds
1218    * @return the images as an attachment element list
1219    * @throws EncoderException
1220    *           if extracting the image fails
1221    */
1222   private List<Attachment> extractImages(Job job, Track sourceTrack, String profileId, Map<String, String> properties,
1223           double... times) throws EncoderException {
1224     if (sourceTrack == null) {
1225       throw new EncoderException("SourceTrack cannot be null");
1226     }
1227     logger.info("creating an image using video track {}", sourceTrack.getIdentifier());
1228 
1229     // Get the encoding profile
1230     final EncodingProfile profile = getProfile(job, profileId);
1231 
1232     // Create the encoding engine
1233     final EncoderEngine encoderEngine = getEncoderEngine();
1234 
1235     // Finally get the file that needs to be encoded
1236     File videoFile = loadTrackIntoWorkspace(job, "video", sourceTrack, true);
1237 
1238     // Do the work
1239     List<File> encodingOutput;
1240     try {
1241       encodingOutput = encoderEngine.extract(videoFile, profile, properties, times);
1242       // check for validity of output
1243       if (encodingOutput == null || encodingOutput.isEmpty()) {
1244         logger.error("Image extraction from video {} with profile {} failed: no images were produced",
1245                 sourceTrack.getURI(), profile.getIdentifier());
1246         throw new EncoderException("Image extraction failed: no images were produced");
1247       }
1248     } catch (EncoderException e) {
1249       Map<String, String> params = new HashMap<>();
1250       params.put("video", sourceTrack.getURI().toString());
1251       params.put("profile", profile.getIdentifier());
1252       params.put("positions", Arrays.toString(times));
1253       incident().recordFailure(job, IMAGE_EXTRACTION_FAILED, e, params, detailsFor(e, encoderEngine));
1254       throw e;
1255     } finally {
1256       activeEncoder.remove(encoderEngine);
1257     }
1258 
1259     int i = 0;
1260     List<URI> workspaceURIs = new LinkedList<>();
1261     for (File output : encodingOutput) {
1262 
1263       if (!output.exists() || output.length() == 0) {
1264         logger.warn("Extracted image {} is empty!", output);
1265         throw new EncoderException("Extracted image " + output.toString() + " is empty!");
1266       }
1267 
1268       // Put the file in the workspace
1269 
1270       try (InputStream in = new FileInputStream(output)) {
1271         URI returnURL = workspace.putInCollection(COLLECTION,
1272                 job.getId() + "_" + i++ + "." + FilenameUtils.getExtension(output.getAbsolutePath()), in);
1273         logger.debug("Copied image file to the workspace at {}", returnURL);
1274         workspaceURIs.add(returnURL);
1275       } catch (Exception e) {
1276         cleanup(encodingOutput.toArray(new File[encodingOutput.size()]));
1277         cleanupWorkspace(workspaceURIs.toArray(new URI[workspaceURIs.size()]));
1278         incident().recordFailure(job, WORKSPACE_PUT_COLLECTION_IO_EXCEPTION, e,
1279                 getWorkspaceCollectionParams("extracted image file", COLLECTION, output.toURI()), NO_DETAILS);
1280         throw new EncoderException("Unable to put image file into the workspace", e);
1281       }
1282     }
1283 
1284     // cleanup
1285     cleanup(encodingOutput.toArray(new File[encodingOutput.size()]));
1286     cleanup(videoFile);
1287 
1288     MediaPackageElementBuilder builder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
1289     List<Attachment> imageAttachments = new LinkedList<>();
1290     for (URI url : workspaceURIs) {
1291       Attachment attachment = (Attachment) builder.elementFromURI(url, Attachment.TYPE, null);
1292       try {
1293         attachment.setSize(workspace.get(url).length());
1294       } catch (NotFoundException | IOException e) {
1295         logger.warn("Could not get file size of {}", url);
1296       }
1297       imageAttachments.add(attachment);
1298     }
1299 
1300     return imageAttachments;
1301   }
1302 
1303   /**
1304    * {@inheritDoc}
1305    *
1306    * @see org.opencastproject.composer.api.ComposerService#convertImage(org.opencastproject.mediapackage.Attachment,
1307    *      java.lang.String...)
1308    */
1309   @Override
1310   public Job convertImage(Attachment image, String... profileIds) throws EncoderException, MediaPackageException {
1311     if (image == null)
1312       throw new IllegalArgumentException("Source image cannot be null");
1313 
1314     if (profileIds == null)
1315       throw new IllegalArgumentException("At least one encoding profile must be set");
1316 
1317     Gson gson = new Gson();
1318     List<String> params = Arrays.asList(gson.toJson(profileIds), MediaPackageElementParser.getAsXml(image));
1319     float jobLoad = Arrays.stream(profileIds)
1320             .map(p -> profileScanner.getProfile(p).getJobLoad())
1321             .max(Float::compare)
1322             .orElse(0.f);
1323     try {
1324       return serviceRegistry.createJob(JOB_TYPE, Operation.ImageConversion.toString(), params, jobLoad);
1325     } catch (ServiceRegistryException e) {
1326       throw new EncoderException("Unable to create a job", e);
1327     }
1328   }
1329 
1330   /**
1331    * {@inheritDoc}
1332    *
1333    * @see org.opencastproject.composer.api.ComposerService#convertImageSync(
1334           org.opencastproject.mediapackage.Attachment, java.lang.String...)
1335    */
1336   @Override
1337   public List<Attachment> convertImageSync(Attachment image, String... profileIds) throws EncoderException,
1338       MediaPackageException {
1339     Job job = null;
1340     try {
1341       final float jobLoad = (float) Arrays.stream(profileIds)
1342         .map(p -> profileScanner.getProfile(p))
1343         .mapToDouble(EncodingProfile::getJobLoad)
1344         .max()
1345         .orElse(0);
1346       job = serviceRegistry
1347           .createJob(
1348               JOB_TYPE, Operation.Image.toString(), null, null, false, jobLoad);
1349       job.setStatus(Job.Status.RUNNING);
1350       job = serviceRegistry.updateJob(job);
1351       List<Attachment> results = convertImage(job, image, profileIds);
1352       job.setStatus(Job.Status.FINISHED);
1353       if (results.isEmpty()) {
1354         throw new EncoderException(format(
1355                 "Unable to convert image %s with encoding profiles %s. The result set is empty.",
1356                 image.getURI().toString(), profileIds));
1357       }
1358       return results;
1359     } catch (ServiceRegistryException | NotFoundException e) {
1360       throw new EncoderException("Unable to create a job", e);
1361     } finally {
1362       finallyUpdateJob(job);
1363     }
1364   }
1365 
1366   /**
1367    * Converts an image from <code>sourceImage</code> to a new format.
1368    *
1369    * @param job
1370    *          the associated job
1371    * @param sourceImage
1372    *          the source image
1373    * @param profileIds
1374    *          the identifier of the encoding profiles to use
1375    * @return the list of converted images as an attachment.
1376    * @throws EncoderException
1377    *           if converting the image fails
1378    */
1379   private List<Attachment> convertImage(Job job, Attachment sourceImage, String... profileIds) throws EncoderException,
1380           MediaPackageException {
1381     List<Attachment> convertedImages = new ArrayList<>();
1382     final EncoderEngine encoderEngine = getEncoderEngine();
1383     try {
1384       for (String profileId : profileIds) {
1385         logger.info("Converting {} using encoding profile {}", sourceImage, profileId);
1386 
1387         // Get the encoding profile
1388         final EncodingProfile profile = getProfile(job, profileId);
1389 
1390         // Finally get the file that needs to be encoded
1391         File imageFile;
1392         try {
1393           imageFile = workspace.get(sourceImage.getURI());
1394         } catch (NotFoundException e) {
1395           incident().recordFailure(job, WORKSPACE_GET_NOT_FOUND, e,
1396                   getWorkspaceMediapackageParams("source image", sourceImage), NO_DETAILS);
1397           throw new EncoderException("Requested attachment " + sourceImage + " was not found", e);
1398         } catch (IOException e) {
1399           incident().recordFailure(job, WORKSPACE_GET_IO_EXCEPTION, e,
1400                   getWorkspaceMediapackageParams("source image", sourceImage), NO_DETAILS);
1401           throw new EncoderException("Error accessing attachment " + sourceImage, e);
1402         }
1403 
1404         // Do the work
1405         File output;
1406         try {
1407           output = encoderEngine.encode(imageFile, profile, null);
1408         } catch (EncoderException e) {
1409           Map<String, String> params = new HashMap<>();
1410           params.put("image", sourceImage.getURI().toString());
1411           params.put("profile", profile.getIdentifier());
1412           incident().recordFailure(job, CONVERT_IMAGE_FAILED, e, params, detailsFor(e, encoderEngine));
1413           throw e;
1414         }
1415 
1416         // encoding did not return a file
1417         if (!output.exists() || output.length() == 0)
1418           throw new EncoderException(format(
1419               "Image conversion job %d didn't created an output file for the source image %s with encoding profile %s",
1420               job.getId(), sourceImage.getURI().toString(), profileId));
1421 
1422         // Put the file in the workspace
1423         URI workspaceURI = putToCollection(job, output, "converted image file");
1424 
1425         MediaPackageElementBuilder builder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
1426         Attachment convertedImage = (Attachment) builder.elementFromURI(workspaceURI, Attachment.TYPE, null);
1427         convertedImage.setSize(output.length());
1428         convertedImage.setIdentifier(IdImpl.fromUUID().toString());
1429         try {
1430           convertedImage.setMimeType(MimeTypes.fromURI(convertedImage.getURI()));
1431         } catch (UnknownFileTypeException e) {
1432           logger.warn("Mime type unknown for file {}. Setting none.", convertedImage.getURI(), e);
1433         }
1434 
1435         convertedImages.add(convertedImage);
1436       }
1437     } catch (Throwable t) {
1438       for (Attachment convertedImage : convertedImages) {
1439         try {
1440           workspace.delete(convertedImage.getURI());
1441         } catch (NotFoundException ex) {
1442           // do nothing here
1443         } catch (IOException ex) {
1444           logger.warn("Unable to delete converted image {} from workspace", convertedImage.getURI(), ex);
1445         }
1446       }
1447       throw t;
1448     } finally {
1449       activeEncoder.remove(encoderEngine);
1450     }
1451     return convertedImages;
1452   }
1453 
1454   /**
1455    * {@inheritDoc}
1456    *
1457    * @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
1458    */
1459   @Override
1460   protected String process(Job job) throws ServiceRegistryException {
1461     String operation = job.getOperation();
1462     List<String> arguments = job.getArguments();
1463     try {
1464       Operation op = Operation.valueOf(operation);
1465       Track firstTrack;
1466       Track secondTrack;
1467       String encodingProfile = arguments.get(0);
1468       final String serialized;
1469 
1470       switch (op) {
1471         case Encode:
1472           firstTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(1));
1473           serialized = encode(job, Collections.map(tuple("video", firstTrack)), encodingProfile).map(
1474                   MediaPackageElementParser.getAsXml()).getOrElse("");
1475           break;
1476         case ParallelEncode:
1477           firstTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(1));
1478           serialized = MediaPackageElementParser.getArrayAsXml(parallelEncode(job, firstTrack, encodingProfile));
1479           break;
1480         case Image:
1481           firstTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(1));
1482           List<Attachment> resultingElements;
1483           if (Boolean.parseBoolean(arguments.get(2))) {
1484             double[] times = new double[arguments.size() - 3];
1485             for (int i = 3; i < arguments.size(); i++) {
1486               times[i - 3] = Double.parseDouble(arguments.get(i));
1487             }
1488             resultingElements = extractImages(job, firstTrack, encodingProfile, null, times);
1489           } else {
1490             Map<String, String> properties = parseProperties(arguments.get(3));
1491             resultingElements = extractImages(job, firstTrack, encodingProfile, properties);
1492           }
1493           serialized = MediaPackageElementParser.getArrayAsXml(resultingElements);
1494           break;
1495         case ImageConversion:
1496           Gson gson = new Gson();
1497           String[] encodingProfilesArr = gson.fromJson(arguments.get(0), String[].class);
1498           Attachment sourceImage = (Attachment) MediaPackageElementParser.getFromXml(arguments.get(1));
1499           List<Attachment> convertedImages = convertImage(job, sourceImage, encodingProfilesArr);
1500           serialized = MediaPackageElementParser.getArrayAsXml(convertedImages);
1501           break;
1502         case Mux:
1503           firstTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(1));
1504           secondTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(2));
1505           serialized = mux(job, firstTrack, secondTrack, encodingProfile).map(
1506                   MediaPackageElementParser.getAsXml()).getOrElse("");
1507           break;
1508         case Trim:
1509           firstTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(1));
1510           long start = Long.parseLong(arguments.get(2));
1511           long duration = Long.parseLong(arguments.get(3));
1512           serialized = trim(job, firstTrack, encodingProfile, start, duration).map(
1513                   MediaPackageElementParser.getAsXml()).getOrElse("");
1514           break;
1515         case Composite:
1516           Attachment watermarkAttachment;
1517           firstTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(LOWER_TRACK_INDEX));
1518           Layout lowerLayout = Serializer.layout(JsonObj.jsonObj(arguments.get(LOWER_TRACK_LAYOUT_INDEX)));
1519           LaidOutElement<Track> lowerLaidOutElement = new LaidOutElement<>(firstTrack, lowerLayout);
1520           Option<LaidOutElement<Track>> upperLaidOutElement = Option.none();
1521           if (NOT_AVAILABLE.equals(arguments.get(UPPER_TRACK_INDEX))
1522                   && NOT_AVAILABLE.equals(arguments.get(UPPER_TRACK_LAYOUT_INDEX))) {
1523             logger.trace("This composite action does not use a second track.");
1524           } else {
1525             secondTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(UPPER_TRACK_INDEX));
1526             Layout upperLayout = Serializer.layout(JsonObj.jsonObj(arguments.get(UPPER_TRACK_LAYOUT_INDEX)));
1527             upperLaidOutElement = Option.option(new LaidOutElement<Track>(secondTrack, upperLayout));
1528           }
1529           Dimension compositeTrackSize = Serializer
1530                   .dimension(JsonObj.jsonObj(arguments.get(COMPOSITE_TRACK_SIZE_INDEX)));
1531           String backgroundColor = arguments.get(BACKGROUND_COLOR_INDEX);
1532           String audioSourceName = arguments.get(AUDIO_SOURCE_INDEX);
1533 
1534           Option<LaidOutElement<Attachment>> watermarkOption = Option.none();
1535           //If there is a watermark defined, it needs both args 8 and 9
1536           if (arguments.size() > WATERMARK_INDEX && arguments.size() <= WATERMARK_LAYOUT_INDEX + 1) {
1537             watermarkAttachment = (Attachment) MediaPackageElementParser.getFromXml(arguments.get(WATERMARK_INDEX));
1538             Layout watermarkLayout = Serializer.layout(JsonObj.jsonObj(arguments.get(WATERMARK_LAYOUT_INDEX)));
1539             watermarkOption = Option.some(new LaidOutElement<>(watermarkAttachment, watermarkLayout));
1540           } else if (arguments.size() > WATERMARK_LAYOUT_INDEX + 1) {
1541             throw new IndexOutOfBoundsException("Too many composite arguments!");
1542           }
1543           serialized = composite(job, compositeTrackSize, lowerLaidOutElement, upperLaidOutElement, watermarkOption,
1544                   encodingProfile, backgroundColor, audioSourceName).map(MediaPackageElementParser.getAsXml()).getOrElse("");
1545           break;
1546         case Concat:
1547           String dimensionString = arguments.get(1);
1548           String frameRateString = arguments.get(2);
1549           Dimension outputDimension = null;
1550           if (StringUtils.isNotBlank(dimensionString))
1551             outputDimension = Serializer.dimension(JsonObj.jsonObj(dimensionString));
1552           float outputFrameRate = NumberUtils.toFloat(frameRateString, -1.0f);
1553           boolean sameCodec = Boolean.parseBoolean(arguments.get(3));
1554           List<Track> tracks = new ArrayList<>();
1555           for (int i = 4; i < arguments.size(); i++) {
1556             tracks.add(i - 4, (Track) MediaPackageElementParser.getFromXml(arguments.get(i)));
1557           }
1558           serialized = concat(job, tracks, encodingProfile, outputDimension, outputFrameRate, sameCodec).map(
1559                   MediaPackageElementParser.getAsXml()).getOrElse("");
1560           break;
1561         case ImageToVideo:
1562           Attachment image = (Attachment) MediaPackageElementParser.getFromXml(arguments.get(1));
1563           double time = Double.parseDouble(arguments.get(2));
1564           serialized = imageToVideo(job, image, encodingProfile, time)
1565                   .map(MediaPackageElementParser.getAsXml()).getOrElse("");
1566           break;
1567         case Demux:
1568           firstTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(1));
1569           List<Track> outTracks = demux(job, firstTrack, encodingProfile);
1570           serialized = StringUtils.trimToEmpty(MediaPackageElementParser.getArrayAsXml(outTracks));
1571           break;
1572         case ProcessSmil:
1573           Smil smil = this.smilService.fromXml(arguments.get(0)).getSmil(); // Pass the entire smil
1574           String trackParamGroupId = arguments.get(1); // Only process this track
1575           String mediaType = arguments.get(2); // v=video,a=audio,otherwise both
1576           List<String> encodingProfiles = arguments.subList(3, arguments.size());
1577           outTracks = processSmil(job, smil, trackParamGroupId, mediaType, encodingProfiles);
1578           serialized = StringUtils.trimToEmpty(MediaPackageElementParser.getArrayAsXml(outTracks));
1579           break;
1580         case MultiEncode:
1581           firstTrack = (Track) MediaPackageElementParser.getFromXml(arguments.get(0));
1582           List<String> encodingProfiles2 = arguments.subList(1, arguments.size());
1583           outTracks = multiEncode(job, firstTrack, encodingProfiles2);
1584           serialized = StringUtils.trimToEmpty(MediaPackageElementParser.getArrayAsXml(outTracks));
1585           break;
1586         default:
1587           throw new IllegalStateException("Don't know how to handle operation '" + operation + "'");
1588       }
1589 
1590       return serialized;
1591     } catch (IllegalArgumentException e) {
1592       throw new ServiceRegistryException(format("Cannot handle operations of type '%s'", operation), e);
1593     } catch (IndexOutOfBoundsException e) {
1594       throw new ServiceRegistryException(format("Invalid arguments for operation '%s'", operation), e);
1595     } catch (Exception e) {
1596       throw new ServiceRegistryException(format("Error handling operation '%s'", operation), e);
1597     }
1598   }
1599 
1600   /**
1601    * {@inheritDoc}
1602    *
1603    * @see org.opencastproject.composer.api.ComposerService#listProfiles()
1604    */
1605   @Override
1606   public EncodingProfile[] listProfiles() {
1607     Collection<EncodingProfile> profiles = profileScanner.getProfiles().values();
1608     return profiles.toArray(new EncodingProfile[profiles.size()]);
1609   }
1610 
1611   /**
1612    * {@inheritDoc}
1613    *
1614    * @see org.opencastproject.composer.api.ComposerService#getProfile(java.lang.String)
1615    */
1616   @Override
1617   public EncodingProfile getProfile(String profileId) {
1618     return profileScanner.getProfiles().get(profileId);
1619   }
1620 
1621   protected List<Track> inspect(Job job, List<URI> uris, List<List<String>> tags) throws EncoderException {
1622     // Start inspection jobs
1623     Job[] inspectionJobs = new Job[uris.size()];
1624     for (int i = 0; i < uris.size(); i++) {
1625       try {
1626         inspectionJobs[i] = inspectionService.inspect(uris.get(i));
1627       } catch (MediaInspectionException e) {
1628         incident().recordJobCreationIncident(job, e);
1629         throw new EncoderException(String.format("Media inspection of %s failed", uris.get(i)), e);
1630       }
1631     }
1632 
1633     // Wait for inspection jobs
1634     JobBarrier barrier = new JobBarrier(job, serviceRegistry, inspectionJobs);
1635     if (!barrier.waitForJobs().isSuccess()) {
1636       for (Map.Entry<Job, Job.Status> result : barrier.getStatus().getStatus().entrySet()) {
1637         if (result.getValue() != Job.Status.FINISHED) {
1638           logger.error("Media inspection failed in job {}: {}", result.getKey(), result.getValue());
1639         }
1640       }
1641       throw new EncoderException("Inspection of encoded file failed");
1642     }
1643 
1644     // De-serialize tracks
1645     List<Track> results = new ArrayList<>(uris.size());
1646     int i = 0;
1647     for (Job inspectionJob: inspectionJobs) {
1648       try {
1649         Track track = (Track) MediaPackageElementParser.getFromXml(inspectionJob.getPayload());
1650         List<String> tagsForTrack = tags.get(i);
1651         for (String tag : tagsForTrack) {
1652           track.addTag(tag);
1653         }
1654         results.add(track);
1655       } catch (MediaPackageException e) {
1656         throw new EncoderException(e);
1657       }
1658       i++;
1659     }
1660     return results;
1661   }
1662 
1663   protected List<Track> inspect(Job job, List<URI> uris) throws EncoderException {
1664     List<List<String>> tags = java.util.Collections.nCopies(uris.size(), new ArrayList<String>());
1665     return inspect(job, uris, tags);
1666   }
1667 
1668   protected Track inspect(Job job, URI workspaceURI) throws EncoderException {
1669     return inspect(job, java.util.Collections.singletonList(workspaceURI)).get(0);
1670   }
1671 
1672   /**
1673    * Deletes any valid file in the list.
1674    *
1675    * @param encodingOutput
1676    *          list of files to be deleted
1677    */
1678   private void cleanup(File... encodingOutput) {
1679     for (File file : encodingOutput) {
1680       if (file != null && file.isFile()) {
1681         String path = file.getAbsolutePath();
1682         if (file.delete()) {
1683           logger.info("Deleted local copy of encoding file at {}", path);
1684         } else {
1685           logger.warn("Could not delete local copy of encoding file at {}", path);
1686         }
1687       }
1688     }
1689   }
1690 
1691   private void cleanupWorkspace(URI... workspaceURIs) {
1692     for (URI url : workspaceURIs) {
1693       try {
1694         workspace.delete(url);
1695       } catch (Exception e) {
1696         logger.warn("Could not delete {} from workspace: {}", url, e.getMessage());
1697       }
1698     }
1699   }
1700 
1701   private EncoderEngine getEncoderEngine() {
1702     EncoderEngine engine = new EncoderEngine(ffmpegBinary);
1703     activeEncoder.add(engine);
1704     return engine;
1705   }
1706 
1707   private EncodingProfile getProfile(Job job, String profileId) throws EncoderException {
1708     final EncodingProfile profile = profileScanner.getProfile(profileId);
1709     if (profile == null) {
1710       final String msg = format("Profile %s is unknown", profileId);
1711       logger.error(msg);
1712       incident().recordFailure(job, PROFILE_NOT_FOUND, Collections.map(tuple("profile", profileId)));
1713       throw new EncoderException(msg);
1714     }
1715     return profile;
1716   }
1717 
1718   private Map<String, String> getWorkspaceMediapackageParams(String description, MediaPackageElement element) {
1719     return Collections.map(tuple("description", description),
1720             tuple("type", element.getElementType().toString()),
1721             tuple("url", element.getURI().toString()));
1722   }
1723 
1724   private Map<String, String> getWorkspaceCollectionParams(String description, String collectionId, URI url) {
1725     Map<String, String> params = new HashMap<>();
1726     params.put("description", description);
1727     params.put("collection", collectionId);
1728     params.put("url", url.toString());
1729     return params;
1730   }
1731 
1732   private String buildConcatCommand(boolean onlyAudio, Dimension dimension, float outputFrameRate, List<File> files, List<Track> tracks) {
1733     StringBuilder sb = new StringBuilder();
1734 
1735     // Add input file paths
1736     for (File f : files) {
1737       sb.append("-i ").append(f.getAbsolutePath()).append(" ");
1738     }
1739     sb.append("-filter_complex ");
1740 
1741     boolean hasAudio = false;
1742     if (!onlyAudio) {
1743       // fps video filter if outputFrameRate is valid
1744       String fpsFilter = StringUtils.EMPTY;
1745       if (outputFrameRate > 0) {
1746         fpsFilter = format(Locale.US, "fps=fps=%f,", outputFrameRate);
1747       }
1748       // Add video scaling and check for audio
1749       int characterCount = 0;
1750       for (int i = 0; i < files.size(); i++) {
1751         if ((i % 25) == 0)
1752           characterCount++;
1753         sb.append("[").append(i).append(":v]").append(fpsFilter)
1754                 .append("scale=iw*min(").append(dimension.getWidth()).append("/iw\\,").append(dimension.getHeight())
1755                 .append("/ih):ih*min(").append(dimension.getWidth()).append("/iw\\,").append(dimension.getHeight())
1756                 .append("/ih),pad=").append(dimension.getWidth()).append(":").append(dimension.getHeight())
1757                 .append(":(ow-iw)/2:(oh-ih)/2").append(",setdar=")
1758                 .append((float) dimension.getWidth() / (float) dimension.getHeight()).append("[");
1759         int character = ('a' + i + 1 - ((characterCount - 1) * 25));
1760         for (int y = 0; y < characterCount; y++) {
1761           sb.append((char) character);
1762         }
1763         sb.append("];");
1764         if (tracks.get(i).hasAudio())
1765           hasAudio = true;
1766       }
1767 
1768       // Add silent audio streams if at least one audio stream is available
1769       if (hasAudio) {
1770         for (int i = 0; i < files.size(); i++) {
1771           if (!tracks.get(i).hasAudio())
1772             sb.append("aevalsrc=0:d=1[silent").append(i + 1).append("];");
1773         }
1774       }
1775     }
1776 
1777     // Add concat segments
1778     int characterCount = 0;
1779     for (int i = 0; i < files.size(); i++) {
1780       if ((i % 25) == 0)
1781         characterCount++;
1782 
1783       int character = ('a' + i + 1 - ((characterCount - 1) * 25));
1784       if (!onlyAudio) {
1785         sb.append("[");
1786         for (int y = 0; y < characterCount; y++) {
1787           sb.append((char) character);
1788         }
1789         sb.append("]");
1790       }
1791 
1792       if (tracks.get(i).hasAudio()) {
1793         sb.append("[").append(i).append(":a]");
1794       } else if (hasAudio) {
1795         sb.append("[silent").append(i + 1).append("]");
1796       }
1797     }
1798 
1799     // Add concat command and output mapping
1800     sb.append("concat=n=").append(files.size()).append(":v=");
1801     if (onlyAudio) {
1802       sb.append("0");
1803     } else {
1804       sb.append("1");
1805     }
1806     sb.append(":a=");
1807 
1808     if (!onlyAudio) {
1809       if (hasAudio) {
1810         sb.append("1[v][a] -map [v] -map [a] ");
1811       } else {
1812         sb.append("0[v] -map [v] ");
1813       }
1814     } else {
1815       sb.append("1[a] -map [a]");
1816     }
1817     return sb.toString();
1818   }
1819 
1820   // Rewrite files to use the new names that will happen when files are saved by calling
1821   // putToCollection
1822   protected void hlsFixReference(long id, List<File> outputs) throws IOException {
1823       Map<String, String> nameMap = outputs.stream().collect(Collectors.<File, String, String> toMap(
1824               file -> FilenameUtils.getName(file.getAbsolutePath()), file -> renameJobFile(id, file)));
1825       for (File file : outputs) {
1826         if (AdaptivePlaylist.isPlaylist(file))
1827           AdaptivePlaylist.hlsRewriteFileReference(file, nameMap); // fix references
1828       }
1829     }
1830 
1831   /**
1832    * Generate a "unique" name by job identifier
1833    */
1834   private String renameJobFile(long jobId, File file) {
1835       return workspace.toSafeName(format("%s.%s", jobId, FilenameUtils.getName(file.getAbsolutePath())));
1836   }
1837 
1838   protected void hlsSetReference(Track track) throws IOException {
1839     if (!AdaptivePlaylist.checkForMaster(new File(track.getURI().getPath())))
1840       track.setLogicalName(FilenameUtils.getName(track.getURI().getPath()));
1841   }
1842 
1843   private List<URI> putToCollection(Job job, List<File> files, String description) throws EncoderException {
1844     List<URI> returnURLs = new ArrayList<>(files.size());
1845     for (File file: files) {
1846       try (InputStream in = new FileInputStream(file)) {
1847         URI newFileURI = workspace.putInCollection(COLLECTION, renameJobFile(job.getId(), file), in);
1848         logger.info("Copied the {} to the workspace at {}", description, newFileURI);
1849         returnURLs.add(newFileURI);
1850       } catch (Exception e) {
1851         incident().recordFailure(job, WORKSPACE_PUT_COLLECTION_IO_EXCEPTION, e,
1852                 getWorkspaceCollectionParams(description, COLLECTION, file.toURI()), NO_DETAILS);
1853         returnURLs.forEach(this::cleanupWorkspace);
1854         throw new EncoderException("Unable to put the " + description + " into the workspace", e);
1855       } finally {
1856         cleanup(file);
1857       }
1858     }
1859     return returnURLs;
1860   }
1861 
1862   private URI putToCollection(Job job, File output, String description) throws EncoderException {
1863     return putToCollection(job, java.util.Collections.singletonList(output), description).get(0);
1864   }
1865 
1866   private static List<Tuple<String, String>> detailsFor(EncoderException ex, EncoderEngine engine) {
1867     final List<Tuple<String, String>> d = new ArrayList<>();
1868     d.add(tuple("encoder-engine-class", engine.getClass().getName()));
1869     return d;
1870   }
1871 
1872   private Map<String, String> parseProperties(String serializedProperties) throws IOException {
1873     Properties properties = new Properties();
1874     try (InputStream in = IOUtils.toInputStream(serializedProperties, "UTF-8")) {
1875       properties.load(in);
1876       Map<String, String> map = new HashMap<>();
1877       for (Entry<Object, Object> e : properties.entrySet()) {
1878         map.put((String) e.getKey(), (String) e.getValue());
1879       }
1880       return map;
1881     }
1882   }
1883 
1884   private String getPropertiesAsString(Map<String, String> props) {
1885     StringBuilder sb = new StringBuilder();
1886     for (Entry<String, String> entry : props.entrySet()) {
1887       sb.append(entry.getKey());
1888       sb.append("=");
1889       sb.append(entry.getValue());
1890       sb.append("\n");
1891     }
1892     return sb.toString();
1893   }
1894 
1895   /**
1896    * Sets the media inspection service
1897    *
1898    * @param mediaInspectionService
1899    *          an instance of the media inspection service
1900    */
1901   @Reference
1902   protected void setMediaInspectionService(MediaInspectionService mediaInspectionService) {
1903     this.inspectionService = mediaInspectionService;
1904   }
1905 
1906   /**
1907    * Sets the workspace
1908    *
1909    * @param workspace
1910    *          an instance of the workspace
1911    */
1912   @Reference
1913   protected void setWorkspace(Workspace workspace) {
1914     this.workspace = workspace;
1915   }
1916 
1917   /**
1918    * Sets the service registry
1919    *
1920    * @param serviceRegistry
1921    *          the service registry
1922    */
1923   @Reference
1924   protected void setServiceRegistry(ServiceRegistry serviceRegistry) {
1925     this.serviceRegistry = serviceRegistry;
1926   }
1927 
1928   /**
1929    * {@inheritDoc}
1930    *
1931    * @see org.opencastproject.job.api.AbstractJobProducer#getServiceRegistry()
1932    */
1933   @Override
1934   protected ServiceRegistry getServiceRegistry() {
1935     return serviceRegistry;
1936   }
1937 
1938   /**
1939    * Sets the profile scanner.
1940    *
1941    * @param scanner
1942    *          the profile scanner
1943    */
1944   @Reference
1945   protected void setProfileScanner(EncodingProfileScanner scanner) {
1946     this.profileScanner = scanner;
1947   }
1948 
1949   /**
1950    * Callback for setting the security service.
1951    *
1952    * @param securityService
1953    *          the securityService to set
1954    */
1955   @Reference
1956   public void setSecurityService(SecurityService securityService) {
1957     this.securityService = securityService;
1958   }
1959 
1960   /**
1961    * Callback for setting the user directory service.
1962    *
1963    * @param userDirectoryService
1964    *          the userDirectoryService to set
1965    */
1966   @Reference
1967   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
1968     this.userDirectoryService = userDirectoryService;
1969   }
1970 
1971   /**
1972    * Sets a reference to the organization directory service.
1973    *
1974    * @param organizationDirectory
1975    *          the organization directory
1976    */
1977   @Reference
1978   public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectory) {
1979     this.organizationDirectoryService = organizationDirectory;
1980   }
1981 
1982   /**
1983    * {@inheritDoc}
1984    *
1985    * @see org.opencastproject.job.api.AbstractJobProducer#getSecurityService()
1986    */
1987   @Override
1988   protected SecurityService getSecurityService() {
1989     return securityService;
1990   }
1991 
1992   @Reference
1993   public void setSmilService(SmilService smilService) {
1994     this.smilService = smilService;
1995   }
1996 
1997   /**
1998    * {@inheritDoc}
1999    *
2000    * @see org.opencastproject.job.api.AbstractJobProducer#getUserDirectoryService()
2001    */
2002   @Override
2003   protected UserDirectoryService getUserDirectoryService() {
2004     return userDirectoryService;
2005   }
2006 
2007   /**
2008    * {@inheritDoc}
2009    *
2010    * @see org.opencastproject.job.api.AbstractJobProducer#getOrganizationDirectoryService()
2011    */
2012   @Override
2013   protected OrganizationDirectoryService getOrganizationDirectoryService() {
2014     return organizationDirectoryService;
2015   }
2016 
2017   @Reference(target = "(artifact=encodingprofile)")
2018   public void setEncodingProfileReadinessIndicator(ReadinessIndicator unused) {
2019     //  Wait for the encoding profiles to load
2020   }
2021 
2022   @Override
2023   public Job demux(Track sourceTrack, String profileId) throws EncoderException, MediaPackageException {
2024     try {
2025       return serviceRegistry.createJob(JOB_TYPE, Operation.Demux.toString(),
2026               Arrays.asList(profileId, MediaPackageElementParser.getAsXml(sourceTrack)));
2027     } catch (ServiceRegistryException e) {
2028       throw new EncoderException("Unable to create a job", e);
2029     }
2030   }
2031 
2032   private List<Track> demux(final Job job, Track videoTrack, String encodingProfile) throws EncoderException {
2033     if (job == null)
2034       throw new IllegalArgumentException("The Job parameter must not be null");
2035 
2036     try {
2037       // Get the track and make sure it exists
2038       final File videoFile = (videoTrack != null) ? loadTrackIntoWorkspace(job, "source", videoTrack, false) : null;
2039 
2040       // Get the encoding profile
2041       EncodingProfile profile = getProfile(job, encodingProfile);
2042       // Create the engine/get
2043       logger.info("Encoding video track {} using profile '{}'", videoTrack.getIdentifier(), profile);
2044       final EncoderEngine encoderEngine = getEncoderEngine();
2045 
2046       // Do the work
2047       List<File> outputs;
2048       try {
2049         Map<String, File> source = new HashMap<>();
2050         source.put("video", videoFile);
2051         outputs = encoderEngine.process(source, profile, null);
2052       } catch (EncoderException e) {
2053         Map<String, String> params = new HashMap<>();
2054         params.put("video", (videoFile != null) ? videoTrack.getURI().toString() : "EMPTY");
2055         params.put("profile", profile.getIdentifier());
2056         params.put("properties", "EMPTY");
2057         incident().recordFailure(job, ENCODING_FAILED, e, params, detailsFor(e, encoderEngine));
2058         throw e;
2059       } finally {
2060         activeEncoder.remove(encoderEngine);
2061       }
2062 
2063       // demux did not return a file
2064       if (outputs.isEmpty() || !outputs.get(0).exists() || outputs.get(0).length() == 0)
2065         return null;
2066 
2067       List<URI> workspaceURIs = putToCollection(job, outputs, "demuxed file");
2068       List<Track> tracks = inspect(job, workspaceURIs);
2069       tracks.forEach(track -> track.setIdentifier(IdImpl.fromUUID().toString()));
2070       return tracks;
2071     } catch (Exception e) {
2072       logger.warn("Demux/MultiOutputEncode operation failed to encode " + videoTrack, e);
2073       if (e instanceof EncoderException) {
2074         throw (EncoderException) e;
2075       } else {
2076         throw new EncoderException(e);
2077       }
2078     }
2079   }
2080 
2081   /**
2082    * OSGI callback when the configuration is updated. This method is only here to prevent the
2083    * configuration admin service from calling the service deactivate and activate methods
2084    * for a config update. It does not have to do anything as the updates are handled by updated().
2085    */
2086   @Modified
2087   public void modified(Map<String, Object> config)
2088      throws ConfigurationException {
2089     logger.debug("Modified");
2090   }
2091 
2092   @Override
2093   public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
2094     if (properties == null) {
2095       logger.info("No configuration available, using defaults");
2096       return;
2097     }
2098 
2099     maxMultipleProfilesJobLoad = LoadUtil.getConfiguredLoadValue(properties, JOB_LOAD_MAX_MULTIPLE_PROFILES,
2100             DEFAULT_JOB_LOAD_MAX_MULTIPLE_PROFILES, serviceRegistry);
2101     processSmilJobLoadFactor = LoadUtil.getConfiguredLoadValue(properties, JOB_LOAD_FACTOR_PROCESS_SMIL,
2102             DEFAULT_PROCESS_SMIL_JOB_LOAD_FACTOR, serviceRegistry);
2103     multiEncodeJobLoadFactor = LoadUtil.getConfiguredLoadValue(properties, JOB_LOAD_FACTOR_MULTI_ENCODE,
2104             DEFAULT_MULTI_ENCODE_JOB_LOAD_FACTOR, serviceRegistry);
2105     // This should throw exceptions if the property is misconfigured
2106     String multiEncodeFadeStr = StringUtils.trimToNull((String) properties.get(MULTI_ENCODE_FADE_MILLISECONDS));
2107     multiEncodeTrim = DEFAULT_MULTI_ENCODE_FADE_MILLISECONDS;
2108     if (multiEncodeFadeStr != null) {
2109       multiEncodeFade = Integer.parseInt(multiEncodeFadeStr);
2110     }
2111     String multiEncodeTrimStr = StringUtils.trimToNull((String) properties.get(MULTI_ENCODE_TRIM_MILLISECONDS));
2112     multiEncodeTrim = DEFAULT_MULTI_ENCODE_TRIM_MILLISECONDS;
2113     if (multiEncodeTrimStr != null) {
2114       multiEncodeTrim = Integer.parseInt(multiEncodeTrimStr);
2115     }
2116     transitionDuration = (int) (1000 * LoadUtil.getConfiguredLoadValue(properties,
2117             PROCESS_SMIL_CLIP_TRANSITION_DURATION, DEFAULT_PROCESS_SMIL_CLIP_TRANSITION_DURATION, serviceRegistry));
2118   }
2119 
2120   /**
2121    * ProcessSmil processes editing of one source group (which may contain multiple source tracks) to one set of outputs
2122    * (to one or more encoding profiles). Note that the source tracks are expected to have the same dimensions.
2123    *
2124    * @param smil
2125    *          - smil containing with video names and clip sections from them
2126    * @param trackparamId
2127    *          - group id
2128    * @param mediaType
2129    *          - VIDEO_ONLY, AUDIO_ONLY, or "" if neither is true
2130    * @param profileIds
2131    *          - list of encoding profile Ids
2132    * @return Compose Job
2133    * @throws EncoderException
2134    *           - if encoding fails
2135    * @throws MediaPackageException
2136    *           - if missing files or bad mp
2137    */
2138   @Override
2139   public Job processSmil(Smil smil, String trackparamId, String mediaType, List<String> profileIds)
2140           throws EncoderException, MediaPackageException {
2141     try {
2142       ArrayList<String> al = new ArrayList<>();
2143       al.add(smil.toXML());
2144       al.add(trackparamId); // place holder for param ID
2145       al.add(mediaType); // audio, video or av
2146       for (String i : profileIds) {
2147         al.add(i);
2148       }
2149       float load = calculateJobLoadForMultipleProfiles(profileIds, processSmilJobLoadFactor);
2150       try {
2151         for (SmilMediaParamGroup paramGroup : smil.getHead().getParamGroups()) {
2152           for (SmilMediaParam param : paramGroup.getParams()) {
2153             if (SmilMediaParam.PARAM_NAME_TRACK_ID.equals(param.getName())) {
2154               if (trackparamId == null || trackparamId.equals(paramGroup.getId())) { // any track or specific groupid
2155                 al.set(1, paramGroup.getId());
2156                 return (serviceRegistry.createJob(JOB_TYPE, Operation.ProcessSmil.toString(), al, load));
2157               }
2158             }
2159           }
2160         }
2161       } catch (ServiceRegistryException e) {
2162         throw new EncoderException("Unable to create a job", e);
2163       } catch (Exception e) {
2164         throw new EncoderException("Unable to create a job - Exception in Parsing Smil", e);
2165       }
2166     } catch (Exception e) {
2167       throw new EncoderException("Unable to create a job - Exception processing XML in ProcessSmil", e);
2168     }
2169     throw new EncoderException("Unable to create a job - Cannot find paramGroup");
2170   }
2171 
2172   private List<EncodingProfile> findSuitableProfiles(List<String> encodingProfiles, String mediaType) {
2173     List<EncodingProfile> profiles = new ArrayList<>();
2174     for (String profileId1 : encodingProfiles) { // Check for mismatched profiles/media types
2175       EncodingProfile profile = profileScanner.getProfile(profileId1);
2176       // warn about bad encoding profiles, but encode anyway, the profile type is not enforced
2177       if (VIDEO_ONLY.equals(mediaType) && profile.getApplicableMediaType() == EncodingProfile.MediaType.Audio) {
2178         logger.warn("Profile '{}' supports {} but media is Video Only", profileId1, profile.getApplicableMediaType());
2179       } else if (AUDIO_ONLY.equals(mediaType) && profile.getApplicableMediaType() == EncodingProfile.MediaType.Visual) {
2180         logger.warn("Profile '{}' supports {} but media is Audio Only", profileId1, profile.getApplicableMediaType());
2181       }
2182       profiles.add(profile);
2183     }
2184     return (profiles);
2185   }
2186 
2187   /**
2188    * Fetch specified or first SmilMediaParamGroup from smil
2189    *
2190    * @param smil
2191    *          - smil object
2192    * @param trackParamGroupId
2193    *          - id for a particular param group or null
2194    * @return a named track group by id, if id is not specified, get first param group
2195    * @throws EncoderException
2196    */
2197   private SmilMediaParamGroup getSmilMediaParamGroup(Smil smil, String trackParamGroupId) throws EncoderException {
2198     try { // Find a track group id if not specified, get first param group
2199       if (trackParamGroupId == null)
2200         for (SmilMediaParamGroup paramGroup : smil.getHead().getParamGroups()) {
2201           for (SmilMediaParam param : paramGroup.getParams()) {
2202             if (SmilMediaParam.PARAM_NAME_TRACK_ID.equals(param.getName())) {
2203               trackParamGroupId = paramGroup.getId();
2204               break;
2205             }
2206           }
2207         }
2208       return ((SmilMediaParamGroup) smil.get(trackParamGroupId)); // If we want to concat multiple files
2209     } catch (SmilException ex) {
2210       throw new EncoderException("Smil does not contain a paramGroup element with Id " + trackParamGroupId, ex);
2211     }
2212   }
2213 
2214   /**
2215    * Splice segments given by smil document for the given track to the new one. This function reads the smil file and
2216    * reduce them to arguments to send to the encoder
2217    *
2218    * @param job
2219    *          processing job
2220    * @param smil
2221    *          smil document with media segments description
2222    * @param trackParamGroupId
2223    *          source track group
2224    * @param mediaType
2225    *          VIDEO_ONLY or AUDIO_ONLY or "" if it has both
2226    * @param encodingProfiles
2227    *          - profiles
2228    * @return serialized array of processed tracks
2229    * @throws EncoderException
2230    *           if an error occurred
2231    * @throws MediaPackageException
2232    *           - bad Mediapackage
2233    * @throws URISyntaxException
2234    */
2235   protected List<Track> processSmil(Job job, Smil smil, String trackParamGroupId, String mediaType,
2236           List<String> encodingProfiles) throws EncoderException, MediaPackageException, URISyntaxException {
2237 
2238     List<EncodingProfile> profiles = findSuitableProfiles(encodingProfiles, mediaType);
2239     // If there are no usable encoding profiles, throw exception
2240     if (profiles.size() == 0)
2241       throw new EncoderException(
2242               "ProcessSmil - Media is not supported by the assigned encoding Profiles '" + encodingProfiles + "'");
2243 
2244     SmilMediaParamGroup trackParamGroup;
2245     ArrayList<String> inputfile = new ArrayList<>();
2246     Map<String, String> props = new HashMap<>();
2247 
2248     ArrayList<VideoClip> videoclips = new ArrayList<>();
2249     trackParamGroup = getSmilMediaParamGroup(smil, trackParamGroupId);
2250 
2251     String sourceTrackId = null;
2252     MediaPackageElementFlavor sourceTrackFlavor = null;
2253     String sourceTrackUri = null;
2254     File sourceFile = null;
2255 
2256     // get any source track from track group to get metadata
2257     for (SmilMediaParam param : trackParamGroup.getParams()) {
2258       if (SmilMediaParam.PARAM_NAME_TRACK_ID.equals(param.getName())) {
2259         sourceTrackId = param.getValue();
2260       } else if (SmilMediaParam.PARAM_NAME_TRACK_SRC.equals(param.getName())) {
2261         sourceTrackUri = param.getValue();
2262       } else if (SmilMediaParam.PARAM_NAME_TRACK_FLAVOR.equals(param.getName())) {
2263         sourceTrackFlavor = MediaPackageElementFlavor.parseFlavor(param.getValue());
2264       }
2265     }
2266 
2267     logger.info("ProcessSmil: Start processing track {}", sourceTrackUri);
2268     sourceFile = loadURIIntoWorkspace(job, "source", new URI(sourceTrackUri));
2269     inputfile.add(sourceFile.getAbsolutePath()); // default source - add to source table as 0
2270     props.put("in.video.path", sourceFile.getAbsolutePath());
2271     int srcIndex = inputfile.indexOf(sourceFile.getAbsolutePath()); // index = 0
2272     try {
2273       List<File> outputs;
2274       // parse body elements
2275       for (SmilMediaObject element : smil.getBody().getMediaElements()) {
2276         // body should contain par elements
2277         if (element.isContainer()) {
2278           SmilMediaContainer container = (SmilMediaContainer) element;
2279           if (SmilMediaContainer.ContainerType.PAR == container.getContainerType()) {
2280             // par element should contain media elements
2281             for (SmilMediaObject elementChild : container.getElements()) {
2282               if (!elementChild.isContainer()) {
2283                 SmilMediaElement media = (SmilMediaElement) elementChild;
2284                 if (trackParamGroupId.equals(media.getParamGroup())) {
2285                   long begin = media.getClipBeginMS();
2286                   long end = media.getClipEndMS();
2287                   URI clipTrackURI = media.getSrc();
2288                   File clipSourceFile = null;
2289                   if (clipTrackURI != null) {
2290                     clipSourceFile = loadURIIntoWorkspace(job, "Source", clipTrackURI);
2291                   }
2292                   if (sourceFile == null) {
2293                     sourceFile = clipSourceFile; // need one source file
2294                   }
2295                   int index = -1;
2296 
2297                   if (clipSourceFile != null) { // clip has different source
2298                     index = inputfile.indexOf(clipSourceFile.getAbsolutePath()); // Look for known tracks
2299                     if (index < 0) { // if new unknown track
2300                       inputfile.add(clipSourceFile.getAbsolutePath()); // add track
2301                       props.put("in.video.path" + index, sourceFile.getAbsolutePath());
2302                       index = inputfile.indexOf(clipSourceFile.getAbsolutePath());
2303                     }
2304                   } else {
2305                     index = srcIndex; // default source track
2306                   }
2307                   logger.debug("Adding edit clip index " + index + " begin " + begin + " end " + end + " to "
2308                           + sourceTrackId);
2309                   videoclips.add(new VideoClip(index, begin, end));
2310                 }
2311               } else {
2312                 throw new EncoderException("Smil container '"
2313                         + ((SmilMediaContainer) elementChild).getContainerType().toString() + "'is not supported yet");
2314               }
2315             }
2316           } else {
2317             throw new EncoderException(
2318                     "Smil container '" + container.getContainerType().toString() + "'is not supported yet");
2319           }
2320         }
2321       }
2322       List<Long> edits = new ArrayList<>(); // collect edit points
2323       for (VideoClip clip : videoclips) {
2324         edits.add((long) clip.getSrc());
2325         edits.add(clip.getStartMS());
2326         edits.add(clip.getEndMS());
2327       }
2328       List<File> inputs = new ArrayList<>(); // collect input source tracks
2329       for (String f : inputfile) {
2330         inputs.add(new File(f));
2331       }
2332       EncoderEngine encoderEngine = getEncoderEngine();
2333       try {
2334         outputs = encoderEngine.multiTrimConcat(inputs, edits, profiles, transitionDuration,
2335                 !AUDIO_ONLY.equals(mediaType), !VIDEO_ONLY.equals(mediaType));
2336       } catch (EncoderException e) {
2337         Map<String, String> params = new HashMap<>();
2338         List<String> profileList = new ArrayList<>();
2339         for (EncodingProfile p : profiles) {
2340           profileList.add(p.getIdentifier().toString());
2341         }
2342         params.put("videos", StringUtils.join(inputs, ","));
2343         params.put("profiles", StringUtils.join(profileList,","));
2344         incident().recordFailure(job, PROCESS_SMIL_FAILED, e, params, detailsFor(e, encoderEngine));
2345         throw e;
2346       } finally {
2347         activeEncoder.remove(encoderEngine);
2348       }
2349       logger.info("ProcessSmil returns {} media files ", outputs.size());
2350       boolean isHLS = outputs.parallelStream().anyMatch(AdaptivePlaylist.isHLSFilePred);
2351       /**
2352         * putToCollection changes the name of the output file (a.mp4 -> 12345-a.mp4) so that it
2353         * is "unique" in the collections directory.
2354        */
2355       if (isHLS) {
2356         hlsFixReference(job.getId(), outputs); // for inspection in collection
2357       }
2358       logger.info("ProcessSmil/MultiTrimConcat returns {} media files {}", outputs.size(), outputs);
2359       List<URI> workspaceURIs = putToCollection(job, outputs, "processSmil files");
2360       List<Track> tracks = inspect(job, workspaceURIs);
2361       if (isHLS) {
2362         tracks.forEach(eachtrack -> AdaptivePlaylist.setLogicalName(eachtrack));
2363       }
2364       tracks.forEach(track -> track.setIdentifier(IdImpl.fromUUID().toString()));
2365       return tracks;
2366     } catch (Exception e) { // clean up all the stored files
2367       throw new EncoderException("ProcessSmil operation failed to run ", e);
2368     }
2369   }
2370 
2371   @Override
2372   public Job multiEncode(Track sourceTrack, List<String> profileIds) throws EncoderException, MediaPackageException {
2373     try {
2374       // Job Load is based on number of encoding profiles
2375       float load = calculateJobLoadForMultipleProfiles(profileIds, multiEncodeJobLoadFactor);
2376       ArrayList<String> args = new ArrayList<>();
2377       args.add(MediaPackageElementParser.getAsXml(sourceTrack));
2378       args.addAll(profileIds);
2379       return serviceRegistry.createJob(JOB_TYPE, Operation.MultiEncode.toString(), args, load);
2380     } catch (ServiceRegistryException e) {
2381       throw new EncoderException("Unable to create a job", e);
2382     }
2383   }
2384 
2385   /**
2386    * A single encoding process that produces multiple outputs from a single track(s) using a list of encoding profiles.
2387    * Each output can be tagged by the profile name. This supports adaptive bitrate outputs.
2388    *
2389    * @param job
2390    *          - encoding job
2391    * @param track
2392    *          - source track
2393    * @param profileIds
2394    *          - list of encoding profile Ids, can include one adaptive profile
2395    * @return encoded files
2396    * @throws EncoderException
2397    *           - if can't encode
2398    * @throws IllegalArgumentException
2399    *           - if missing arguments
2400    *
2401    */
2402   protected List<Track> multiEncode(final Job job, Track track, List<String> profileIds)
2403           throws EncoderException, IllegalArgumentException {
2404     if (job == null)
2405       throw new IllegalArgumentException("The Job parameter must not be null");
2406     if (track == null)
2407       throw new IllegalArgumentException("Source track cannot be null");
2408     if (profileIds == null || profileIds.isEmpty())
2409       throw new IllegalArgumentException("Cannot encode without encoding profiles");
2410     List<File> outputs = null;
2411     try {
2412       final File videoFile = loadTrackIntoWorkspace(job, "source", track, false);
2413       // Get the encoding profiles
2414       List<EncodingProfile> profiles = new ArrayList<>();
2415       for (String profileId : profileIds) {
2416         EncodingProfile profile = getProfile(job, profileId);
2417         profiles.add(profile);
2418       }
2419       final long nominalTrim = multiEncodeTrim; // in ms - minimal amount if needed to adjust lipsync
2420       List<Long> edits = null;
2421       if (nominalTrim > 0) {
2422         edits = new ArrayList<>(); // Nominal edit points if there is need to automatically trim off the beginning
2423         edits.add((long) 0);
2424         edits.add(nominalTrim);
2425         edits.add(track.getDuration() - nominalTrim);
2426       }
2427       logger.info("Encoding source track {} using profiles '{}'", track.getIdentifier(), profileIds);
2428       // Do the work
2429       EncoderEngine encoderEngine = getEncoderEngine();
2430       try {
2431         outputs = encoderEngine.multiTrimConcat(Arrays.asList(videoFile), null, profiles, multiEncodeFade,
2432                 track.hasVideo(),
2433                 track.hasAudio());
2434       } catch (EncoderException e) {
2435         Map<String, String> params = new HashMap<>();
2436         params.put("videos", videoFile.getName());
2437         params.put("profiles", StringUtils.join(profileIds, ","));
2438         incident().recordFailure(job, MULTI_ENCODE_FAILED, e, params, detailsFor(e, encoderEngine));
2439         throw e;
2440       } finally {
2441         activeEncoder.remove(encoderEngine);
2442       }
2443       logger.info("MultiEncode returns {} media files {} ", outputs.size(), outputs);
2444       List<File> saveFiles = outputs; // names may be changed in the following ops
2445       boolean isHLS = outputs.parallelStream().anyMatch(AdaptivePlaylist.isHLSFilePred);
2446       if (isHLS)
2447         hlsFixReference(job.getId(), outputs); // for inspection in collection
2448 
2449       List<URI> workspaceURIs = putToCollection(job, saveFiles, "multiencode files");
2450       List<Track> tracks = inspect(job, workspaceURIs);
2451       if (isHLS) // Keep a snapshot of its name and we will reconciled them later
2452         tracks.forEach(eachtrack -> AdaptivePlaylist.setLogicalName(eachtrack));
2453       tracks.forEach(eachtrack -> eachtrack.setIdentifier(IdImpl.fromUUID().toString()));
2454       return tracks;
2455     } catch (Exception e) {
2456       throw new EncoderException("MultiEncode operation failed to run ", e);
2457     }
2458   }
2459 
2460   private float calculateJobLoadForMultipleProfiles(List<String> profileIds, float adjustmentFactor)
2461           throws EncoderException {
2462     // Job load is calculated based on the encoding profiles. They are summed up and multiplied by a factor.
2463     // The factor represents the adjustment that should be made assuming each profile job load was specified
2464     // based on it running with 1 input -> 1 output so normally will be a number 0 < n < 1.
2465     float load = 0.0f;
2466     for (String profileId : profileIds) {
2467       EncodingProfile profile = profileScanner.getProfile(profileId);
2468       if (profile == null) {
2469         throw new EncoderException("Encoding profile not found: " + profileId);
2470       }
2471       load += profile.getJobLoad();
2472     }
2473     load *= adjustmentFactor;
2474     if (load > maxMultipleProfilesJobLoad) {
2475       load = maxMultipleProfilesJobLoad;
2476     }
2477     return load;
2478   }
2479 }