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