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