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