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