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  
23  package org.opencastproject.composer.impl;
24  
25  import org.opencastproject.composer.api.EncoderException;
26  import org.opencastproject.composer.api.EncodingProfile;
27  import org.opencastproject.composer.api.VideoClip;
28  import org.opencastproject.mediapackage.AdaptivePlaylist;
29  import org.opencastproject.mediapackage.identifier.IdImpl;
30  import org.opencastproject.util.IoSupport;
31  
32  import org.apache.commons.io.FileUtils;
33  import org.apache.commons.io.FilenameUtils;
34  import org.apache.commons.lang3.StringUtils;
35  import org.codehaus.plexus.util.cli.CommandLineUtils;
36  import org.slf4j.Logger;
37  import org.slf4j.LoggerFactory;
38  
39  import java.io.BufferedReader;
40  import java.io.File;
41  import java.io.InputStreamReader;
42  import java.text.DecimalFormat;
43  import java.text.DecimalFormatSymbols;
44  import java.util.ArrayList;
45  import java.util.Arrays;
46  import java.util.Collections;
47  import java.util.HashMap;
48  import java.util.HashSet;
49  import java.util.Iterator;
50  import java.util.LinkedList;
51  import java.util.List;
52  import java.util.Map;
53  import java.util.Objects;
54  import java.util.Set;
55  import java.util.UUID;
56  import java.util.regex.Matcher;
57  import java.util.regex.Pattern;
58  import java.util.stream.Collectors;
59  import java.util.stream.Stream;
60  
61  import javax.activation.MimetypesFileTypeMap;
62  
63  /**
64   * Abstract base class for encoder engines.
65   */
66  public class EncoderEngine implements AutoCloseable {
67  
68    /** The ffmpeg commandline suffix */
69    static final String CMD_SUFFIX = "ffmpeg.command";
70    static final String ADAPTIVE_TYPE_SUFFIX = "adaptive.type"; // HLS only
71    /** The trimming start time property name */
72    static final String PROP_TRIMMING_START_TIME = "trim.start";
73    /** The trimming duration property name */
74    static final String PROP_TRIMMING_DURATION = "trim.duration";
75    /** If true STDERR and STDOUT of the spawned process will be mixed so that both can be read via STDIN */
76    private static final boolean REDIRECT_ERROR_STREAM = true;
77  
78    private static Logger logger = LoggerFactory.getLogger(EncoderEngine.class);
79    /** the encoder binary */
80    private String binary = "ffmpeg";
81    /** Set of processes to clean up */
82    private Set<Process> processes = new HashSet<>();
83  
84    private final Pattern outputPattern = Pattern.compile("Output .* (\\S+) to '(.*)':");
85    // ffmpeg4 generates HLS output files and may use a .tmp suffix while writing
86    private final Pattern outputPatternHLS = Pattern.compile("Opening '([^']+)\\.tmp'|([^']+)' for writing");
87  
88    // These are common video options that may be mapped in HLS streams. This will help catch some common mistakes
89    private static List<String> mappableOptions = Stream.of("-bf", "-b_strategy", "-bitrate", "-bufsize", "-crf",
90           "-f", "-flags", "-force_key_frames", "-g", "-level", "-keyint", "-keyint_min", "-maxrate", "-minrate",
91           "-pix_fmt", "-preset", "-profile",
92           "-r", "-refs", "-s", "-sc_threshold", "-tune", "-x264opts", "-x264-params")
93           .collect(Collectors.toList());
94  
95    /**
96     * Creates a new abstract encoder engine with or without support for multiple job submission.
97     */
98    EncoderEngine(String binary) {
99      this.binary = binary;
100   }
101 
102   /**
103    * {@inheritDoc}
104    *
105    * @see EncoderEngine#encode(File, EncodingProfile, Map)
106    */
107   File encode(File mediaSource, EncodingProfile format, Map<String, String> properties)
108           throws EncoderException {
109     List<File> output = process(Collections.singletonMap("video", mediaSource), format, properties);
110     if (output.size() != 1) {
111       throw new EncoderException(String.format("Encode expects one output file (%s found)", output.size()));
112     }
113     return output.get(0);
114   }
115 
116   /**
117    * Extract several images from a video file.
118    *
119    * @param mediaSource
120    *          File to extract images from
121    * @param format
122    *          Encoding profile to use for extraction
123    * @param properties
124    * @param times
125    *          Times at which to extract the images
126    * @return  List of image files
127    * @throws EncoderException Something went wrong during image extraction
128    */
129   List<File> extract(File mediaSource, EncodingProfile format, Map<String, String> properties, double... times)
130           throws EncoderException {
131 
132     List<File> extractedImages = new LinkedList<>();
133     try {
134       // Extract one image if no times are specified
135       if (times.length == 0) {
136         extractedImages.add(encode(mediaSource, format, properties));
137       }
138       for (double time : times) {
139         Map<String, String> params = new HashMap<>();
140         if (properties != null) {
141           params.putAll(properties);
142         }
143 
144         DecimalFormatSymbols ffmpegFormat = new DecimalFormatSymbols();
145         ffmpegFormat.setDecimalSeparator('.');
146         DecimalFormat df = new DecimalFormat("0.00000", ffmpegFormat);
147         params.put("time", df.format(time));
148 
149         extractedImages.add(encode(mediaSource, format, params));
150       }
151     } catch (Exception e) {
152       cleanup(extractedImages);
153       if (e instanceof EncoderException) {
154         throw (EncoderException) e;
155       } else {
156         throw new EncoderException("Image extraction failed", e);
157       }
158     }
159 
160     return extractedImages;
161   }
162 
163   /**
164    * Executes the command line encoder with the given set of files and properties and using the provided encoding
165    * profile.
166    *
167    * @param source
168    *          the source files for encoding
169    * @param profile
170    *          the profile identifier
171    * @param properties
172    *          the encoding properties to be interpreted by the actual encoder implementation
173    * @return the processed file
174    * @throws EncoderException
175    *           if processing fails
176    */
177   List<File> process(Map<String, File> source, EncodingProfile profile, Map<String, String> properties)
178           throws EncoderException {
179     // Fist, update the parameters
180     Map<String, String> params = new HashMap<>();
181     if (properties != null)
182       params.putAll(properties);
183     // build command
184     if (source.isEmpty()) {
185       throw new IllegalArgumentException("At least one track must be specified.");
186     }
187     // Set encoding parameters
188     for (Map.Entry<String, File> f: source.entrySet()) {
189       final String input = FilenameUtils.normalize(f.getValue().getAbsolutePath());
190       final String pre = "in." + f.getKey();
191       params.put(pre + ".path", input);
192       params.put(pre + ".name", FilenameUtils.getBaseName(input));
193       params.put(pre + ".suffix", FilenameUtils.getExtension(input));
194       params.put(pre + ".filename", FilenameUtils.getName(input));
195       params.put(pre + ".mimetype", MimetypesFileTypeMap.getDefaultFileTypeMap().getContentType(input));
196     }
197     final File parentFile = source.getOrDefault("video", source.get("audio"));
198 
199     final String outDir = parentFile.getAbsoluteFile().getParent();
200     final String outFileName = FilenameUtils.getBaseName(parentFile.getName())
201             + "_" + UUID.randomUUID().toString();
202     params.put("out.dir", outDir);
203     params.put("out.name", outFileName);
204     if (profile.getSuffix() != null) {
205       final String outSuffix = processParameters(profile.getSuffix(), params);
206       params.put("out.suffix", outSuffix);
207     }
208 
209     for (String tag : profile.getTags()) {
210       final String suffix = processParameters(profile.getSuffix(tag), params);
211       params.put("out.suffix." + tag, suffix);
212     }
213 
214     // create encoder process.
215     final List<String> command = buildCommand(profile, params);
216     logger.info("Executing encoding command: {}", command);
217 
218     List<File> outFiles = new ArrayList<>();
219     BufferedReader in = null;
220     Process encoderProcess = null;
221     try {
222       ProcessBuilder processBuilder = new ProcessBuilder(command);
223       processBuilder.redirectErrorStream(REDIRECT_ERROR_STREAM);
224       encoderProcess = processBuilder.start();
225       processes.add(encoderProcess);
226 
227       // tell encoder listeners about output
228       in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()));
229       String line;
230       while ((line = in.readLine()) != null) {
231         handleEncoderOutput(outFiles, line);
232       }
233 
234       // wait until the task is finished
235       int exitCode = encoderProcess.waitFor();
236       if (exitCode != 0) {
237         throw new EncoderException("Encoder exited abnormally with status " + exitCode);
238       }
239 
240       logger.info("Tracks {} successfully encoded using profile '{}'", source, profile.getIdentifier());
241       return outFiles;
242     } catch (Exception e) {
243       logger.warn("Error while encoding {}  using profile '{}'",
244               source, profile.getIdentifier(), e);
245 
246       // Ensure temporary data are removed
247       for (File outFile : outFiles) {
248         if (FileUtils.deleteQuietly(outFile)) {
249           logger.debug("Removed output file of failed encoding process: {}", outFile);
250         }
251       }
252       throw new EncoderException(e);
253     } finally {
254       IoSupport.closeQuietly(in);
255       IoSupport.closeQuietly(encoderProcess);
256     }
257   }
258 
259   /*
260    * Runs the raw command string thru the encoder. The string commandopts is ffmpeg specific, it just needs the binary.
261    * The calling function is responsible in doing all the appropriate substitutions using the encoding profiles,
262    * creating the directory for storage, etc. Encoding profiles and input names are included here for logging and
263    * returns
264    *
265    * @param commandopts - tokenized ffmpeg command
266    *
267    * @param inputs - input files in the command, used for reporting
268    *
269    * @param profiles - encoding profiles, used for reporting
270    *
271    * @return encoded - media as a result of running the command
272    *
273    * @throws EncoderException if it fails
274    */
275 
276   protected List<File> process(List<String> commandopts) throws EncoderException {
277     logger.trace("Process raw command -  {}", commandopts);
278     // create encoder process. using working dir of the
279     // current java process
280     Process encoderProcess = null;
281     BufferedReader in = null;
282     List<File> outFiles = new ArrayList<>();
283     try {
284       List<String> command = new ArrayList<>();
285       command.add(binary);
286       command.addAll(commandopts);
287       logger.info("Executing encoding command: {}", StringUtils.join(command, " "));
288 
289       ProcessBuilder pbuilder = new ProcessBuilder(command);
290       pbuilder.redirectErrorStream(REDIRECT_ERROR_STREAM);
291       encoderProcess = pbuilder.start();
292       // tell encoder listeners about output
293       in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()));
294       String line;
295       while ((line = in.readLine()) != null) {
296         handleEncoderOutput(outFiles, line); // get names of output files
297       }
298       // wait until the task is finished
299       encoderProcess.waitFor();
300       int exitCode = encoderProcess.exitValue();
301       if (exitCode != 0) {
302         throw new EncoderException("Encoder exited abnormally with status " + exitCode);
303       }
304       logger.info("Video track successfully encoded '{}'",
305               new Object[] { StringUtils.join(commandopts, " ") });
306       return outFiles; // return output as a list of files
307     } catch (Exception e) {
308       logger.warn("Error while encoding video tracks using '{}': {}",
309               new Object[] {  StringUtils.join(commandopts, " "), e.getMessage() });
310       // Ensure temporary data are removed
311       for (File outFile : outFiles) {
312         if (FileUtils.deleteQuietly(outFile)) {
313           logger.debug("Removed output file of failed encoding process: {}", outFile);
314         }
315       }
316       throw new EncoderException(e);
317     } finally {
318       IoSupport.closeQuietly(in);
319       IoSupport.closeQuietly(encoderProcess);
320     }
321   }
322 
323   /**
324    * Deletes all valid files found in a list
325    *
326    * @param outputFiles
327    *          list containing files
328    */
329   private void cleanup(List<File> outputFiles) {
330     for (File file : outputFiles) {
331       if (file != null && file.isFile()) {
332         String path = file.getAbsolutePath();
333         if (file.delete()) {
334           logger.info("Deleted file {}", path);
335         } else {
336           logger.warn("Could not delete file {}", path);
337         }
338       }
339     }
340   }
341 
342   /**
343    * Creates the command that is sent to the commandline encoder.
344    *
345    * @return the commandline
346    * @throws EncoderException
347    *           in case of any error
348    */
349   private List<String> buildCommand(final EncodingProfile profile, final Map<String, String> argumentReplacements)
350           throws EncoderException {
351     List<String> command = new ArrayList<>();
352     command.add(binary);
353     command.add("-nostdin");
354     command.add("-nostats");
355 
356     String commandline = profile.getExtension(CMD_SUFFIX);
357 
358     // Handle command line extensions before parsing:
359     // Example:
360     //   ffmpeg.command = #{concatCmd} -c copy out.mp4
361     //   ffmpeg.command.concatCmd = -i ...
362     for (String key: argumentReplacements.keySet()) {
363       if (key.startsWith(CMD_SUFFIX + '.')) {
364         final String shortKey = key.substring(CMD_SUFFIX.length() + 1);
365         commandline = commandline.replace("#{" + shortKey + "}", argumentReplacements.get(key));
366       }
367     }
368 
369     String[] arguments;
370     try {
371       arguments = CommandLineUtils.translateCommandline(commandline);
372     } catch (Exception e) {
373       throw new EncoderException("Could not parse encoding profile command line", e);
374     }
375 
376     for (String arg: arguments) {
377       String result = processParameters(arg, argumentReplacements);
378       if (StringUtils.isNotBlank(result)) {
379         command.add(result);
380       }
381     }
382     return command;
383   }
384 
385   /**
386    * {@inheritDoc}
387    *
388    * @see EncoderEngine#trim(File,
389    *      EncodingProfile, long, long, Map)
390    */
391   File trim(File mediaSource, EncodingProfile format, long start, long duration, Map<String, String> properties) throws EncoderException {
392     if (properties == null)
393       properties = new HashMap<>();
394     double startD = (double) start / 1000;
395     double durationD = (double) duration / 1000;
396     DecimalFormatSymbols ffmpegFormat = new DecimalFormatSymbols();
397     ffmpegFormat.setDecimalSeparator('.');
398     DecimalFormat df = new DecimalFormat("00.00000", ffmpegFormat);
399     properties.put(PROP_TRIMMING_START_TIME, df.format(startD));
400     properties.put(PROP_TRIMMING_DURATION, df.format(durationD));
401     return encode(mediaSource, format, properties);
402   }
403 
404   /**
405    * Processes the command options by replacing the templates with their actual values.
406    *
407    * @return the commandline
408    */
409   private String processParameters(String cmd, final Map<String, String> args) {
410     for (Map.Entry<String, String> e: args.entrySet()) {
411       cmd = cmd.replace("#{" + e.getKey() + "}", e.getValue());
412     }
413 
414     // Also replace spaces
415     cmd = cmd.replace("#{space}", " ");
416 
417     /* Remove unused commandline parts */
418     return cmd.replaceAll("#\\{.*?\\}", "");
419   }
420 
421   @Override
422   public void close() {
423     for (Process process: processes) {
424       if (process.isAlive()) {
425         logger.debug("Destroying encoding process {}", process);
426         process.destroy();
427       }
428     }
429   }
430 
431   /**
432    * Handles the encoder output by analyzing it first and then firing it off to the registered listeners.
433    * Has provisions to deal with HLS outputs which uses templates
434    *
435    * @param message
436    *          the message returned by the encoder
437    */
438   private void handleEncoderOutput(List<File> output, String message) {
439     message = message.trim();
440     if ("".equals(message))
441       return;
442 
443     // Others go to trace logging
444     if (StringUtils.startsWithAny(message.toLowerCase(),
445           "ffmpeg version", "configuration", "lib", "size=", "frame=", "built with")) {
446       logger.trace(message);
447 
448     // Handle output files
449     } else if (StringUtils.startsWith(message, "Output #")) {
450       logger.debug(message);
451       Matcher matcher = outputPattern.matcher(message);
452       if (matcher.find()) {
453         String type = matcher.group(1);
454         String outputPath = matcher.group(2);
455         if (!StringUtils.equals("NUL", outputPath) && !StringUtils.equals("/dev/null", outputPath)
456                 && !StringUtils.equals("/dev/null", outputPath)
457                 && !StringUtils.startsWith("pipe:", outputPath)) {
458           File outputFile = new File(outputPath);
459           if (!type.startsWith("hls")) {
460             logger.info("Identified output file {}", outputFile);
461             output.add(outputFile);
462           }
463         }
464       }
465     } else if (StringUtils.startsWith(message, "[hls @ ")) {
466       logger.debug(message);
467       Matcher matcher = outputPatternHLS.matcher(message);
468       if (matcher.find()) {
469         final String outputPath = Objects.toString(matcher.group(1), matcher.group(2));
470         if (!StringUtils.equals("NUL", outputPath) && !StringUtils.equals("/dev/null", outputPath)
471                 && !StringUtils.startsWith("pipe:", outputPath)) {
472           File outputFile = new File(outputPath);
473           // HLS generates the filenames based on a template with %v and %d replaced
474           // HLS writes into the same manifest file to add each segment
475           if (!output.contains(outputFile)) {
476             logger.info("Identified HLS output file {}", outputFile);
477             output.add(outputFile);
478           }
479         }
480       }
481 
482     // Some to debug
483     } else if (StringUtils.startsWithAny(message.toLowerCase(),
484           "artist", "compatible_brands", "copyright", "creation_time", "description", "composer", "date", "duration",
485             "encoder", "handler_name", "input #", "last message repeated", "major_brand", "metadata", "minor_version",
486             "output #", "program", "side data:", "stream #", "stream mapping", "title", "video:", "[libx264 @ ", "Press [")) {
487       logger.debug(message);
488 
489     // And the rest is likely to deserve at least info
490     } else {
491       logger.info(message);
492     }
493   }
494 
495   /**
496    * Splits a line into tokens - mindful of single and double quoted string as single token Apache common and guava do
497    * not deal with quotes
498    *
499    * @param str
500    * @return an array of string tokens
501    */
502   public List<String> commandSplit(String str) {
503     ArrayList<String> al = new ArrayList<String>();
504     final Pattern regex = Pattern.compile("\"([^\"]*)\"|\'([^\']*)\'|\\S+");
505     Matcher m = regex.matcher(str);
506     while (m.find()) {
507       if (m.group(1) != null) {
508         // double-quoted string without the quotes
509         al.add(m.group(1));
510       } else if (m.group(2) != null) {
511         // single-quoted string without the quotes
512         al.add(m.group(2));
513       } else {
514         // Add unquoted word
515         al.add(m.group());
516       }
517     }
518     return (al);
519   }
520 
521   /**
522    * Use a separator to join a string entry only if it is not null or empty
523    *
524    * @param srlist
525    *          -array of string
526    * @param separator
527    *          - to join the string
528    * @return a string
529    */
530   public String joinNonNullString(String[] srlist, String separator) {
531     StringBuffer sb = new StringBuffer();
532     for (int i = 0; i < srlist.length; i++) {
533       if (srlist[i] == null || srlist[i].isEmpty())
534         continue;
535       else {
536         if (sb.length() > 0)
537           sb.append(separator);
538         sb.append(srlist[i]);
539       }
540     }
541     return sb.toString();
542   }
543 
544   /**
545    * Rewrite multiple profiles to ffmpeg complex filter filtergraph chains - inputs are passed in as options, eq: [0aa]
546    * and [0vv] Any filters in the encoding profiles are moved into a clause in the complex filter chain for each output
547    */
548   protected class OutputAggregate {
549     private final List<EncodingProfile> pf;
550     private final ArrayList<String> outputs = new ArrayList<>();
551     private final ArrayList<String> outputFiles = new ArrayList<>();
552     private final ArrayList<String> outputSuffixes = new ArrayList<>(); // for HLS
553     private boolean hasAdaptiveProfile = false;
554     private final ArrayList<String> vpads; // output pads for each segment
555     private final ArrayList<String> apads;
556     private final ArrayList<String> vfilter; // filters for each output format
557     private final ArrayList<String> afilter;
558     private String vInputPad = "";
559     private String aInputPad = "";
560     private String vsplit = "";
561     private String asplit = "";
562     private final ArrayList<String> vstream; // output video name
563     private final ArrayList<String> astream; // output audio name
564 
565     public OutputAggregate(List<EncodingProfile> profiles,
566             Map<String, String> params, String vInputPad, String aInputPad) throws EncoderException {
567       ArrayList<EncodingProfile> deliveryProfiles = new ArrayList<EncodingProfile>(profiles.size());
568       EncodingProfile groupProfile = null;
569       for (EncodingProfile ep: profiles) {
570         String adaptiveType = ep.getExtension(ADAPTIVE_TYPE_SUFFIX);
571         if (adaptiveType == null) {
572           deliveryProfiles.add(ep);
573         } else {
574           if ("HLS".equalsIgnoreCase(adaptiveType)) {
575             groupProfile = ep;
576             hasAdaptiveProfile = true;
577           }
578           else
579             throw new EncoderException("Only HLS is supported" + ep.getIdentifier() + " ffmpeg command");
580         }
581       }
582       this.pf = deliveryProfiles;
583       int size = this.pf.size();
584 
585       if (vInputPad == null && aInputPad == null)
586         throw new EncoderException("At least one of video or audio input must be specified");
587       // Init
588       vfilter = new ArrayList<>(Collections.nCopies(size, null));
589       afilter = new ArrayList<>(Collections.nCopies(size, null));
590       // name of output pads to map to files
591       apads = new ArrayList<>(Collections.nCopies(size, null));
592       vpads = new ArrayList<>(Collections.nCopies(size, null));
593 
594       vstream = new ArrayList<>(Collections.nCopies(size, null));
595       astream = new ArrayList<>(Collections.nCopies(size, null));
596 
597       vsplit = (size > 1) ? (vInputPad + "split=" + size) : null; // number of splits
598       asplit = (size > 1) ? (aInputPad + "asplit=" + size) : null;
599       this.vInputPad = vInputPad;
600       this.aInputPad = aInputPad;
601       if (groupProfile != null)
602         outputAggregateReal(deliveryProfiles, groupProfile, params, vInputPad, aInputPad);
603       else
604         outputAggregateReal(deliveryProfiles, params, vInputPad, aInputPad);
605     }
606 
607 
608     /*
609      * set the audio filter if there are any in the profiles or identity
610      */
611     private void setAudioFilters() {
612       if (pf.size() == 1) {
613         if (afilter.get(0) != null)
614           afilter.set(0, aInputPad + afilter.get(0) + apads.get(0)); // Use audio filter on input directly
615           astream.set(0, apads.get(0));
616       } else
617         for (int i = 0; i < pf.size(); i++) {
618           if (afilter.get(i) != null) {
619             afilter.set(i, "[oa0" + i + "]" + afilter.get(i) + apads.get(i)); // Use audio filter on apad
620             asplit += "[oa0" + i + "]";
621             astream.set(i, "[oa0" + i + "]");
622           } else {
623             asplit += apads.get(i); // straight to output
624             astream.set(i, apads.get(i));
625           }
626         }
627       afilter.removeAll(Arrays.asList((String) null));
628     }
629 
630     /*
631      * set the video filter if there are any in the profiles
632      */
633     private void setVideoFilters() {
634       if (pf.size() == 1) {
635         if (vfilter.get(0) != null)
636           vfilter.set(0, vInputPad + vfilter.get(0) + vpads.get(0)); // send to filter first
637           vstream.set(0, vpads.get(0));
638       } else
639         for (int i = 0; i < pf.size(); i++) {
640           if (vfilter.get(i) != null) {
641             vfilter.set(i, "[ov0" + i + "]" + vfilter.get(i) + vpads.get(i)); // send to filter first
642             vsplit += "[ov0" + i + "]";
643             vstream.set(i, "[ov0" + i + "]");
644           } else {
645             vsplit += vpads.get(i);// straight to output
646             vstream.set(i, vpads.get(i));
647           }
648         }
649 
650       vfilter.removeAll(Arrays.asList((String) null));
651     }
652 
653     public List<String> getOutFiles() {
654       return outputFiles;
655     }
656 
657     /**
658      *
659      * @return output pads - the "-map xyz" clauses
660      */
661     public List<String> getOutput() {
662       return outputs;
663     }
664 
665     /**
666      * Get the profile suffixes with source file string interpolation done
667      *
668      * @return the suffixes iff adaptive, otherwise empty
669      */
670     public List<String> getSegmentOutputSuffixes() {
671       return outputSuffixes;
672     }
673 
674     /**
675      * Check for adaptive playlist output - output may need remapping
676      *
677      * @return if true
678      */
679     public boolean hasAdaptivePlaylist() {
680       return hasAdaptiveProfile;
681     }
682 
683     /**
684      *
685      * @return filter split clause for ffmpeg
686      */
687     public String getVsplit() {
688       return vsplit;
689     }
690 
691     public String getAsplit() {
692       return asplit;
693     }
694 
695     public String getVideoFilter() {
696       if (vfilter.isEmpty())
697         return null;
698       return StringUtils.join(vfilter, ";");
699     }
700 
701     public String getAudioFilter() {
702       if (afilter.isEmpty())
703         return null;
704       return StringUtils.join(afilter, ";");
705     }
706 
707     /**
708      * If this is a raw mapping not used with complex filter, strip the square brackets if there are any
709      *
710      * @param pad
711      *          - such as 0:a, [0:v], [1:1],[0:12],[main],[overlay]
712      * @return adjusted pad
713      */
714     public String adjustForNoComplexFilter(String pad) {
715       final Pattern outpad = Pattern.compile("\\[(\\d+:[av\\d{1,2}])\\]");
716       try {
717         Matcher matcher = outpad.matcher(pad); // throws exception if pad is null
718         if (matcher.matches()) {
719           return matcher.group(1);
720         }
721       } catch (Exception e) {
722       }
723       return pad;
724     }
725 
726     /**
727      * Replace all the templates with real values for each profile
728      *
729      * @param cmd
730      *          from profile
731      * @param params
732      *          from input
733      * @return command
734      */
735     protected String processParameters(String cmd, Map<String, String> params) {
736       String r = cmd;
737       for (Map.Entry<String, String> e : params.entrySet()) {
738         r = r.replace("#{" + e.getKey() + "}", e.getValue());
739       }
740       return r;
741     }
742 
743     /**
744      * Translate the profiles to work with complex filter clauses in ffmpeg, it splits one output into multiple, one for
745      * each encoding profile. This also generates the manifests for HLS using the group profile (HLS only). Each
746      * encoding profile must have a bitrate or one will be generated for all the profiles.
747      * This requires ffmpeg version later than 4.1
748      *
749      * @param profiles
750      *          - list of encoding profiles
751      * @param groupProfile
752      *          - encoding profile that applies to all output and has precedence, currently only HLS options
753      * @param params
754      *          - values for substitution
755      * @param vInputPad
756      *          - name of video pad as input, eg: [0v] null if no video
757      * @param aInputPad
758      *          - name of audio pad as input, eg [0a], null if no audio
759      * @throws EncoderException
760      *           - if it fails
761      */
762     public void outputAggregateReal(List<EncodingProfile> profiles, EncodingProfile groupProfile,
763             Map<String, String> params, String vInputPad, String aInputPad) throws EncoderException {
764       int size = profiles.size();
765 
766       // substitute the output file suffix for group
767       try {
768         String outSuffix = processParameters(groupProfile.getSuffix(), params);
769         params.put("out.suffix", outSuffix); // Add profile suffix
770       } catch (Exception e) {
771         throw new EncoderException("Missing Encoding Profiles");
772       }
773       String ffmpgGCmd = groupProfile.getExtension(CMD_SUFFIX); // Get ffmpeg command from profile
774 
775       if (ffmpgGCmd == null)
776         throw new EncoderException("Missing ffmpeg Encoding Profile " + groupProfile.getIdentifier() + " ffmpeg command");
777       for (Map.Entry<String, String> e : params.entrySet()) { // replace output filenames
778         ffmpgGCmd = ffmpgGCmd.replace("#{" + e.getKey() + "}", e.getValue());
779       }
780       ffmpgGCmd = ffmpgGCmd.replace("#{space}", " ");
781       int indx = 0; // individual quality profiles - names are not needed anymore
782       // Only quality(bitrate/resolution/etc) and position matters
783       for (EncodingProfile profile : profiles) {
784         String cmd = "";
785         // substitute the output file name
786         outputSuffixes.add(processParameters(profile.getSuffix(), params)); // preferred suffixes
787         String ffmpgCmd = profile.getExtension(CMD_SUFFIX); // Get ffmpeg command from profile
788         if (ffmpgCmd == null)
789           throw new EncoderException("Missing Encoding Profile " + profile.getIdentifier() + " ffmpeg command");
790         // Leave this so they will be removed
791         params.remove("out.dir");
792         params.remove("out.name");
793         params.remove("out.suffix");
794         for (Map.Entry<String, String> e : params.entrySet()) { // replace output filenames
795           ffmpgCmd = ffmpgCmd.replace("#{" + e.getKey() + "}", e.getValue());
796         }
797         ffmpgCmd = ffmpgCmd.replace("#{space}", " ");
798         List<String> cmdToken;
799         try {
800           cmdToken = commandSplit(ffmpgCmd);
801         } catch (Exception e) {
802           throw new EncoderException("Could not parse encoding profile command line", e);
803         }
804         //List<String> cmdToken = Arrays.asList(arguments);
805         for (int i = 0; i < cmdToken.size(); i++) {
806           if (cmdToken.get(i).contains("#{out.name}")) {
807             if (i == cmdToken.size() - 1) { // last item, most likely
808               cmdToken = cmdToken.subList(0, i);
809               break;
810             } else { // in the middle of the list
811               List<String> copy = cmdToken.subList(0, i - 1);
812               copy.addAll(cmdToken.subList(i + 1, cmdToken.size() - 1));
813               cmdToken = copy;
814             }
815           }
816         }
817         // Find and remove input and filters from ffmpeg command from the profile
818         int i = 0;
819         String maxrate = null;
820         while (i < cmdToken.size()) {
821           String opt = cmdToken.get(i);
822           if (opt.startsWith("-vf") || opt.startsWith("-filter:v")) { // video filters
823             vfilter.set(indx, cmdToken.get(i + 1).replace("\"", "")); // store without quotes
824             i++;
825           } else if (opt.startsWith("-filter_complex") || opt.startsWith("-lavfi")) { // safer to quit now than to
826             // baffle users with strange errors later
827             i++;
828             logger.error("Command does not support complex filters - only simple -af or -vf filters are supported");
829             throw new EncoderException(
830                     "Cannot parse complex filters in" + profile.getIdentifier() + " for this operation");
831           } else if (opt.startsWith("-af") || opt.startsWith("-filter:a")) { // audio filter
832             afilter.set(indx, cmdToken.get(i + 1).replace("\"", "")); // store without quotes
833             i++;
834           } else if ("-i".equals(opt)) {
835             i++; // inputs are now mapped, remove from command
836           } else if (opt.startsWith("-c:") || opt.startsWith("-codec:") || opt.contains("-vcodec")
837                   || opt.contains("-acodec")) { // cannot copy codec in complex filter
838             String str = cmdToken.get(i + 1);
839             if (str.contains("copy")) // c
840               i++;
841             else if (opt.startsWith("-codec:") || opt.contains("-vcodec")) { // becomes -c:v
842               cmd = cmd + " " + adjustABRVMaps("-c:v", indx);
843             }
844             else if (opt.startsWith("-acodec:"))
845               cmd = cmd + " " + adjustABRVMaps("-c:a", indx);
846             else
847               cmd = cmd + " " + adjustABRVMaps(opt, indx);
848           } else { // keep the rest
849             cmd = cmd + " " + adjustABRVMaps(opt, indx);
850           }
851           i++;
852         }
853 
854         /* Remove unused commandline parts */
855         cmd = cmd.replaceAll("#\\{.*?\\}", "");
856         // Find the output map based on splits and filters
857         if (size == 1) { // no split
858           if (afilter.get(indx) == null)
859             apads.set(indx, adjustForNoComplexFilter(aInputPad));
860           else
861             apads.set(indx, "[oa" + indx + "]");
862           if (vfilter.get(indx) == null)
863             vpads.set(indx, adjustForNoComplexFilter(vInputPad)); // No split, no filter - straight from input
864           else
865             vpads.set(indx, "[ov" + indx + "]");
866 
867         } else { // split
868           vpads.set(indx, "[ov" + indx + "]"); // name the output pads from split -> input to final format
869           apads.set(indx, "[oa" + indx + "]"); // name the output audio pads
870         }
871         cmd = StringUtils.trimToNull(cmd); // remove all leading/trailing white spaces
872         if (cmd != null) {
873           // No direct output from encoding profile
874           // outputFiles.add(cmdToken.get(cmdToken.size() - 1));
875           if (vInputPad != null) {
876             outputs.add("-map " + vpads.get(indx));
877           }
878           if (aInputPad != null) {
879             outputs.add("-map " + apads.get(indx)); // map video and audio input
880           }
881           outputs.add(cmd); // profiles appended in order, they are numbered 0,1,2,3...
882           indx++; // indx for this profile
883         }
884       }
885       setVideoFilters();
886       setAudioFilters();
887       setHLSVarStreamMap(ffmpgGCmd, vInputPad != null, aInputPad != null); // Only HLS is supported so far
888     }
889 
890     /**
891      * Sets the mapping of outputs to HLS streams.
892      *
893      * @param ffmpgCmd
894      *          - ffmpeg command with substitution from the encoding profile
895      * @param hasVideo
896      *          - use video stream
897      * @param hasAudio
898      *          - use audio stream
899      */
900     private void setHLSVarStreamMap(String ffmpgCmd, boolean hasVideo, boolean hasAudio) {
901       StringBuilder varStreamMap = new StringBuilder();
902       varStreamMap.append(" -var_stream_map '");
903 
904       for (int i = 0; i < pf.size(); i++) {
905         int j = 0;
906         String[] maps = new String[2];
907         if (hasVideo && vstream.get(i) != null) { // Has video
908           maps[j] = "v:" + i;
909           ++j;
910         }
911         if (hasAudio && astream.get(i) != null) { // Has audio
912           maps[j] = "a:" + i;
913         }
914         // each target delivery is v:i,a:i
915         varStreamMap.append(joinNonNullString(maps, ","));
916         varStreamMap.append(" ");
917       }
918 
919       varStreamMap.append("' ");
920       varStreamMap.append(ffmpgCmd);
921       varStreamMap.append(" ");
922       outputs.add(varStreamMap.toString()); // treat as another output
923     }
924 
925     /**
926      * When the inputs are routed to ABR, some options need to have a v:int suffix for video and a:0 for audio Any
927      * options ending with ":v" will get a number, otherwise try and guess use option:(v or a) notables (eg: b:v, c:v),
928      * options such as ab or vb will not work
929      *
930      * @param option
931      *          - ffmpeg option
932      * @param position
933      *          - position in the command
934      */
935     public String adjustABRVMaps(String option, int position) {
936       if (option.endsWith(":v") || option.endsWith(":a")) {
937         return option + ":" + Integer.toString(position);
938       } else if (mappableOptions.contains(option)) {
939         return option + ":v:" + Integer.toString(position);
940       } else
941         return option;
942     }
943 
944 
945     /**
946      * Translate the profiles to work with complex filter clauses in ffmpeg, it splits one output into multiple, one for
947      * each encoding profile
948      *
949      * @param profiles
950      *          - list of encoding profiles
951      * @param params
952      *          - values for substitution
953      * @param vInputPad
954      *          - name of video pad as input, eg: [0v] null if no video
955      * @param aInputPad
956      *          - name of audio pad as input, eg [0a], null if no audio
957      * @throws EncoderException
958      *           - if it fails
959      */
960     public void outputAggregateReal(List<EncodingProfile> profiles, Map<String, String> params,
961               String vInputPad, String aInputPad) throws EncoderException {
962 
963       int size = profiles.size();
964       int indx = 0; // profiles
965       for (EncodingProfile profile : profiles) {
966         String cmd = "";
967         String outSuffix;
968         // generate random name as we only have one base name
969         String outFileName = params.get("out.name.base") + "_" + IdImpl.fromUUID().toString();
970         params.put("out.name", outFileName); // Output file name for this profile
971         try {
972           outSuffix = processParameters(profile.getSuffix(), params);
973           params.put("out.suffix", outSuffix); // Add profile suffix
974         } catch (Exception e) {
975           throw new EncoderException("Missing Encoding Profiles");
976         }
977         // substitute the output file name
978         String ffmpgCmd = profile.getExtension(CMD_SUFFIX); // Get ffmpeg command from profile
979         if (ffmpgCmd == null)
980           throw new EncoderException("Missing Encoding Profile " + profile.getIdentifier() + " ffmpeg command");
981         for (Map.Entry<String, String> e : params.entrySet()) { // replace output filenames
982           ffmpgCmd = ffmpgCmd.replace("#{" + e.getKey() + "}", e.getValue());
983         }
984         ffmpgCmd = ffmpgCmd.replace("#{space}", " ");
985         String[] arguments;
986         try {
987           arguments = CommandLineUtils.translateCommandline(ffmpgCmd);
988         } catch (Exception e) {
989           throw new EncoderException("Could not parse encoding profile command line", e);
990         }
991         List<String> cmdToken = Arrays.asList(arguments);
992         // Find and remove input and filters from ffmpeg command from the profile
993         int i = 0;
994         while (i < cmdToken.size()) {
995           String opt = cmdToken.get(i);
996           if (opt.startsWith("-vf") || opt.startsWith("-filter:v")) { // video filters
997             vfilter.set(indx, cmdToken.get(i + 1).replace("\"", "")); // store without quotes
998             i++;
999           } else if (opt.startsWith("-filter_complex") || opt.startsWith("-lavfi")) { // safer to quit now than to
1000             // baffle users with strange errors later
1001             i++;
1002             logger.error("Command does not support complex filters - only simple -af or -vf filters are supported");
1003             throw new EncoderException(
1004                     "Cannot parse complex filters in" + profile.getIdentifier() + " for this operation");
1005           } else if (opt.startsWith("-af") || opt.startsWith("-filter:a")) { // audio filter
1006             afilter.set(indx, cmdToken.get(i + 1).replace("\"", "")); // store without quotes
1007             i++;
1008           } else if ("-i".equals(opt)) {
1009             i++; // inputs are now mapped, remove from command
1010           } else if (opt.startsWith("-c:") || opt.startsWith("-codec:") || opt.contains("-vcodec")
1011                   || opt.contains("-acodec")) { // cannot copy codec in complex filter
1012             String str = cmdToken.get(i + 1);
1013             if (str.contains("copy")) // c
1014               i++;
1015             else
1016               cmd = cmd + " " + opt;
1017           } else { // keep the rest
1018             cmd = cmd + " " + opt;
1019           }
1020           i++;
1021         }
1022         /* Remove unused commandline parts */
1023         cmd = cmd.replaceAll("#\\{.*?\\}", "");
1024         // Find the output map based on splits and filters
1025         if (size == 1) { // no split
1026           if (afilter.get(indx) == null)
1027             apads.set(indx, adjustForNoComplexFilter(aInputPad));
1028           else
1029             apads.set(indx, "[oa" + indx + "]");
1030           if (vfilter.get(indx) == null)
1031             vpads.set(indx, adjustForNoComplexFilter(vInputPad)); // No split, no filter - straight from input
1032           else
1033             vpads.set(indx, "[ov" + indx + "]");
1034 
1035         } else { // split
1036           vpads.set(indx, "[ov" + indx + "]"); // name the output pads from split -> input to final format
1037           apads.set(indx, "[oa" + indx + "]"); // name the output audio pads
1038         }
1039         cmd = StringUtils.trimToNull(cmd); // remove all leading/trailing white spaces
1040         if (cmd != null) {
1041           outputFiles.add(cmdToken.get(cmdToken.size() - 1));
1042           if (vInputPad != null) {
1043             outputs.add("-map " + vpads.get(indx));
1044           }
1045           if (aInputPad != null) {
1046             outputs.add("-map " + apads.get(indx)); // map video and audio input
1047           }
1048           outputs.add(cmd); // profiles appended in order, they are numbered 0,1,2,3...
1049           indx++; // indx for this profile
1050         }
1051       }
1052       setVideoFilters();
1053       setAudioFilters();
1054     }
1055   }
1056 
1057   /**
1058    * Clean up the edit points, make sure the gap between consecutive segments are larger than the transition Otherwise
1059    * it can be very slow to run and output will be ugly because the fades will extend the clip
1060    *
1061    * @param edits
1062    *          - clips to be stitched together
1063    * @param gap
1064    *          = transitionDuration / 1000; default gap size - same as fade
1065    * @return a list of sanitized video clips
1066    */
1067   private static List<VideoClip> sortSegments(List<VideoClip> edits, double gap) {
1068     LinkedList<VideoClip> ll = new LinkedList<VideoClip>();
1069     Iterator<VideoClip> it = edits.iterator();
1070     VideoClip clip;
1071     VideoClip nextclip;
1072     int lastSrc = -1;
1073     while (it.hasNext()) { // Skip sort if there are multiple sources
1074       clip = it.next();
1075       if (lastSrc < 0) {
1076         lastSrc = clip.getSrc();
1077       } else if (lastSrc != clip.getSrc()) {
1078         return edits;
1079       }
1080     }
1081     Collections.sort(edits); // Sort clips if all clips are from the same src
1082     List<VideoClip> clips = new ArrayList<VideoClip>();
1083     it = edits.iterator();
1084     while (it.hasNext()) { // Check for legal durations
1085       clip = it.next();
1086       if (clip.getDuration() > gap) { // Keep segments at least as long as transition fade
1087         ll.add(clip);
1088       }
1089     }
1090     clip = ll.pop(); // initialize
1091     // Clean up segments so that the cut out is at least as long as the transition gap (default is fade out-fade in)
1092     while (!ll.isEmpty()) { // Check that 2 consecutive segments from same src are at least GAP secs apart
1093       if (ll.peek() != null) {
1094         nextclip = ll.pop(); // check next consecutive segment
1095         if ((nextclip.getSrc() == clip.getSrc()) && (nextclip.getStart() - clip.getEnd()) < gap) { // collapse two
1096           // segments into one
1097           clip.setEnd(nextclip.getEnd()); // by using inpt of seg 1 and outpoint of seg 2
1098         } else {
1099           clips.add(clip); // keep last segment
1100           clip = nextclip; // check next segment
1101         }
1102       }
1103     }
1104     clips.add(clip); // add last segment
1105     return clips;
1106   }
1107 
1108   /**
1109    * Create the trim part of the complex filter and return the clauses for the complex filter. The transition is fade to
1110    * black then fade from black. The outputs are mapped to [ov] and [oa]
1111    *
1112    * @param clips
1113    *          - video segments as indices into the media files by time
1114    * @param transitionDuration
1115    *          - length of transition in MS between each segment
1116    * @param hasVideo
1117    *          - has video, from inspection
1118    * @param hasAudio
1119    *          - has audio
1120    * @return complex filter clauses to do editing for ffmpeg
1121    * @throws Exception
1122    *           - if it fails
1123    */
1124   private List<String> makeEdits(List<VideoClip> clips, int transitionDuration, Boolean hasVideo,
1125           Boolean hasAudio) throws Exception {
1126     double vfade = transitionDuration / 1000; // video and audio have the same transition duration
1127     double afade = vfade;
1128     DecimalFormatSymbols ffmpegFormat = new DecimalFormatSymbols();
1129     ffmpegFormat.setDecimalSeparator('.');
1130     DecimalFormat f = new DecimalFormat("0.00", ffmpegFormat);
1131     List<String> vpads = new ArrayList<>();
1132     List<String> apads = new ArrayList<>();
1133     List<String> clauses = new ArrayList<>(); // The clauses are ordered
1134     int n = 0;
1135     if (clips != null)
1136       n = clips.size();
1137     String outmap = "o";
1138     if (n > 1) { // Create the input pads if we have multiple segments
1139       for (int i = 0; i < n; i++) {
1140         vpads.add("[v" + i + "]"); // post filter
1141         apads.add("[a" + i + "]");
1142       }
1143       outmap = "";
1144       // Create the trims
1145       for (int i = 0; i < n; i++) { // Each clip
1146         // get clip and add fades to each clip
1147         VideoClip vclip = clips.get(i);
1148         int fileindx = vclip.getSrc(); // get source file by index
1149         double inpt = vclip.getStart(); // get in points
1150         double duration = vclip.getDuration();
1151         double vend = Math.max(duration - vfade, 0);
1152         double aend = Math.max(duration - afade, 0);
1153         if (hasVideo) {
1154           String vvclip;
1155           vvclip = "[" + fileindx + ":v]trim=" + f.format(inpt) + ":duration=" + f.format(duration)
1156                   + ",setpts=PTS-STARTPTS"
1157                   + ((vfade > 0) ? ",fade=t=in:st=0:d=" + vfade + ",fade=t=out:st=" + f.format(vend) + ":d=" + vfade
1158                           : "")
1159                   + "[" + outmap + "v" + i + "]";
1160           clauses.add(vvclip);
1161         }
1162         if (hasAudio) {
1163           String aclip;
1164           aclip = "[" + fileindx + ":a]atrim=" + f.format(inpt) + ":duration=" + f.format(duration)
1165                   + ",asetpts=PTS-STARTPTS"
1166                   + ((afade > 0)
1167                           ? ",afade=t=in:st=0:d=" + afade + ",afade=t=out:st=" + f.format(aend) + ":d=" + afade
1168                           : "")
1169                   + "[" + outmap + "a" + i + "]";
1170           clauses.add(aclip);
1171         }
1172       }
1173       // use unsafe because different files may have different SAR/framerate
1174       if (hasVideo)
1175         clauses.add(StringUtils.join(vpads, "") + "concat=n=" + n + ":unsafe=1[ov]"); // concat video clips
1176       if (hasAudio)
1177         clauses.add(StringUtils.join(apads, "") + "concat=n=" + n + ":v=0:a=1[oa]"); // concat audio clips in stream 0,
1178     } else if (n == 1) { // single segment
1179       VideoClip vclip = clips.get(0);
1180       int fileindx = vclip.getSrc(); // get source file by index
1181       double inpt = vclip.getStart(); // get in points
1182       double duration = vclip.getDuration();
1183       double vend = Math.max(duration - vfade, 0);
1184       double aend = Math.max(duration - afade, 0);
1185 
1186       if (hasVideo) {
1187         String vvclip;
1188 
1189         vvclip = "[" + fileindx + ":v]trim=" + f.format(inpt) + ":duration=" + f.format(duration)
1190                 + ",setpts=PTS-STARTPTS"
1191                 + ((vfade > 0) ? ",fade=t=in:st=0:d=" + vfade + ",fade=t=out:st=" + f.format(vend) + ":d=" + vfade : "")
1192                 + "[ov]";
1193 
1194         clauses.add(vvclip);
1195       }
1196       if (hasAudio) {
1197         String aclip;
1198         aclip = "[" + fileindx + ":a]atrim=" + f.format(inpt) + ":duration=" + f.format(duration)
1199                 + ",asetpts=PTS-STARTPTS"
1200                 + ((afade > 0) ? ",afade=t=in:st=0:d=" + afade + ",afade=t=out:st=" + f.format(aend) + ":d=" + afade
1201                         : "")
1202                 + "[oa]";
1203 
1204         clauses.add(aclip);
1205       }
1206     }
1207     return clauses; // if no edits, there are no clauses
1208   }
1209 
1210   private Map<String, String> getParamsFromFile(File parentFile) {
1211     Map<String, String> params = new HashMap<>();
1212     String videoInput = FilenameUtils.normalize(parentFile.getAbsolutePath());
1213     params.put("in.video.path", videoInput);
1214     params.put("in.video.name", FilenameUtils.getBaseName(videoInput));
1215     params.put("in.name", FilenameUtils.getBaseName(videoInput)); // One of the names
1216     params.put("in.video.suffix", FilenameUtils.getExtension(videoInput));
1217     params.put("in.video.filename", FilenameUtils.getName(videoInput));
1218     params.put("in.video.mimetype", MimetypesFileTypeMap.getDefaultFileTypeMap().getContentType(videoInput));
1219     String outDir = parentFile.getAbsoluteFile().getParent(); // Use first file dir
1220     params.put("out.dir", outDir);
1221     String outFileName = FilenameUtils.getBaseName(parentFile.getName());
1222     params.put("out.name.base", outFileName); // Base file name used
1223     params.put("out.name", outFileName); // file name used - may be replaced
1224     return params;
1225   }
1226 
1227   /**
1228    * Concatenate segments of one or more input tracks specified by trim points into the track the edits are passed in as
1229    * double so that it is generic. The tracks are assumed to have the same resolution.
1230    *
1231    * @param inputs
1232    *          - input tracks as a list of files
1233    * @param edits
1234    *          - edits are a flat list of triplets, each triplet represent one clip: index (int) into input tracks, trim in point(long)
1235    *          in milliseconds and trim out point (long) in milliseconds for each segment
1236    * @param profiles
1237    *          - encoding profiles for each delivery target - [optional] one adaptive profile to apply to the outputs to
1238    *          generate manifests/playlists
1239    * @param transitionDuration
1240    *          in ms, transition time between each edited segment
1241    * @throws EncoderException
1242    *           - if it fails
1243    */
1244   public List<File> multiTrimConcat(List<File> inputs, List<Long> edits, List<EncodingProfile> profiles,
1245           int transitionDuration) throws EncoderException {
1246     return multiTrimConcat(inputs, edits, profiles, transitionDuration, true, true);
1247 
1248   }
1249 
1250   public List<File> multiTrimConcat(List<File> inputs, List<Long> edits, List<EncodingProfile> profiles,
1251           int transitionDuration, boolean hasVideo, boolean hasAudio)
1252           throws EncoderException, IllegalArgumentException {
1253     if (inputs == null || inputs.size() < 1) {
1254       throw new IllegalArgumentException("At least one track must be specified.");
1255     }
1256     if (edits == null && inputs.size() > 1) {
1257       throw new IllegalArgumentException("If there is no editing, only one track can be specified.");
1258     }
1259     List<VideoClip> clips = null;
1260     if (edits != null) {
1261       clips = new ArrayList<VideoClip>(edits.size() / 3);
1262       int adjust = 0;
1263       // When the first clip starts at 0, and there is a fade, lip sync can be off,
1264       // this adjustment will mitigate the problem
1265       for (int i = 0; i < edits.size(); i += 3) {
1266         if (edits.get(i + 1) < transitionDuration) // If taken from the beginning of video
1267           adjust = transitionDuration / 2000; // add half the fade duration in seconds
1268         else
1269           adjust = 0;
1270         clips.add(new VideoClip(edits.get(i).intValue(), (double) edits.get(i + 1) / 1000 + adjust,
1271               (double) edits.get(i + 2) / 1000));
1272       }
1273       try {
1274         clips = sortSegments(clips, transitionDuration / 1000); // remove bad edit points
1275       } catch (Exception e) {
1276         logger.error("Illegal edits, cannot sort segment", e);
1277       throw new EncoderException("Cannot understand the edit points", e);
1278       }
1279     }
1280     // Set encoding parameters
1281     Map<String, String> params = null;
1282     if (inputs.size() > 0) { // Shared parameters - the rest are profile specific
1283       params = getParamsFromFile(inputs.get(0));
1284     }
1285     if (profiles == null || profiles.size() == 0) {
1286       logger.error("Missing encoding profiles");
1287       throw new EncoderException("Missing encoding profile(s)");
1288     }
1289     try {
1290       List<String> command = new ArrayList<>();
1291       List<String> clauses = makeEdits(clips, transitionDuration, hasVideo, hasAudio); // map inputs into [ov]
1292                                                                                                // and [oa]
1293       // Entry point for multiencode here, if edits is empty, then use raw channels instead of output from edits
1294       String videoOut = (clips == null) ? "[0:v]" : "[ov]";
1295       String audioOut = (clips == null) ? "[0:a]" : "[oa]";
1296       OutputAggregate outmaps = new OutputAggregate(profiles, params, (hasVideo ? videoOut : null),
1297               (hasAudio ? audioOut : null)); // map outputs from ov and oa
1298       if (hasAudio) {
1299         clauses.add(outmaps.getAsplit());
1300         clauses.add(outmaps.getAudioFilter());
1301       }
1302       if (hasVideo) {
1303         clauses.add(outmaps.getVsplit());
1304         clauses.add(outmaps.getVideoFilter());
1305       }
1306       clauses.removeIf(Objects::isNull); // remove all empty filters
1307       command.add("-nostats"); // no progress report
1308       command.add("-hide_banner"); // no configuration/library info
1309       for (File o : inputs) {
1310         command.add("-i"); // Add inputfile in the order of entry
1311         command.add(o.getCanonicalPath());
1312       }
1313       if (!clauses.isEmpty()) {
1314         command.add("-filter_complex");
1315         command.add(StringUtils.join(clauses, ";"));
1316       }
1317       for (String outpad : outmaps.getOutput()) {
1318         command.addAll(commandSplit(outpad)); // split by space
1319       }
1320       if (outmaps.hasAdaptivePlaylist()) {
1321         List<File> results = process(command); // Run the ffmpeg command
1322         // Sort list of segmented mp4s because the output segments are numbered
1323         List<File> segments = results.stream().filter(AdaptivePlaylist.isHLSFilePred.negate())
1324                 .collect(Collectors.toList());
1325         segments.sort((File f1, File f2) -> f1.getName().compareTo(f2.getName()));
1326         List<String> suffixes = outmaps.getSegmentOutputSuffixes();
1327         HashMap<File, File> renames = new HashMap<File, File>();
1328         results.forEach((f) -> {
1329           renames.put(f, f); // init
1330         });
1331         for (int i = 0; i < segments.size(); i++) {
1332           File file = segments.get(i);
1333           // Construct a new name with old name (unique within this group) and profile suffix
1334           String newname = FilenameUtils.concat(file.getParent(),
1335                   FilenameUtils.getBaseName(file.getName()) + suffixes.get(i));
1336           renames.put(file, new File(newname)); // only segments change names
1337         }
1338         // Adjust the playlists to use new names
1339         return AdaptivePlaylist.hlsRenameAllFiles(results, renames);
1340       }
1341       return process(command); // Run the ffmpeg command and return outputs
1342     } catch (Exception e) {
1343       logger.error("MultiTrimConcat failed to run command {} ", e.getMessage());
1344       throw new EncoderException("Cannot encode the inputs",e);
1345     }
1346   }
1347 
1348 }