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