All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.opencastproject.composer.impl.EncoderEngine Maven / Gradle / Ivy

/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community License, Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of the License
 * at:
 *
 *   http://opensource.org/licenses/ecl2.txt
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
 * License for the specific language governing permissions and limitations under
 * the License.
 *
 */


package org.opencastproject.composer.impl;

import org.opencastproject.composer.api.EncoderException;
import org.opencastproject.composer.api.EncodingProfile;
import org.opencastproject.composer.api.VideoClip;
import org.opencastproject.mediapackage.AdaptivePlaylist;
import org.opencastproject.mediapackage.identifier.IdImpl;
import org.opencastproject.util.IoSupport;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.codehaus.plexus.util.cli.CommandLineUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.activation.MimetypesFileTypeMap;

/**
 * Abstract base class for encoder engines.
 */
public class EncoderEngine implements AutoCloseable {

  /** The ffmpeg commandline suffix */
  static final String CMD_SUFFIX = "ffmpeg.command";
  static final String ADAPTIVE_TYPE_SUFFIX = "adaptive.type"; // HLS only
  /** The trimming start time property name */
  static final String PROP_TRIMMING_START_TIME = "trim.start";
  /** The trimming duration property name */
  static final String PROP_TRIMMING_DURATION = "trim.duration";
  /** If true STDERR and STDOUT of the spawned process will be mixed so that both can be read via STDIN */
  private static final boolean REDIRECT_ERROR_STREAM = true;

  private static Logger logger = LoggerFactory.getLogger(EncoderEngine.class);
  /** the encoder binary */
  private String binary = "ffmpeg";
  /** Set of processes to clean up */
  private Set processes = new HashSet<>();

  private final Pattern outputPattern = Pattern.compile("Output .* (\\S+) to '(.*)':");
  // ffmpeg4 generates HLS output files and may use a .tmp suffix while writing
  private final Pattern outputPatternHLS = Pattern.compile("Opening '([^']+)\\.tmp'|([^']+)' for writing");

  // These are common video options that may be mapped in HLS streams. This will help catch some common mistakes
  private static List mappableOptions = Stream.of("-bf", "-b_strategy", "-bitrate", "-bufsize", "-crf",
         "-f", "-flags", "-force_key_frames", "-g", "-level", "-keyint", "-keyint_min", "-maxrate", "-minrate",
         "-pix_fmt", "-preset", "-profile",
         "-r", "-refs", "-s", "-sc_threshold", "-tune", "-x264opts", "-x264-params")
         .collect(Collectors.toList());

  /**
   * Creates a new abstract encoder engine with or without support for multiple job submission.
   */
  EncoderEngine(String binary) {
    this.binary = binary;
  }

  /**
   * {@inheritDoc}
   *
   * @see EncoderEngine#encode(File, EncodingProfile, Map)
   */
  File encode(File mediaSource, EncodingProfile format, Map properties)
          throws EncoderException {
    List output = process(Collections.singletonMap("video", mediaSource), format, properties);
    if (output.size() != 1) {
      throw new EncoderException(String.format("Encode expects one output file (%s found)", output.size()));
    }
    return output.get(0);
  }

  /**
   * Extract several images from a video file.
   *
   * @param mediaSource
   *          File to extract images from
   * @param format
   *          Encoding profile to use for extraction
   * @param properties
   * @param times
   *          Times at which to extract the images
   * @return  List of image files
   * @throws EncoderException Something went wrong during image extraction
   */
  List extract(File mediaSource, EncodingProfile format, Map properties, double... times)
          throws EncoderException {

    List extractedImages = new LinkedList<>();
    try {
      // Extract one image if no times are specified
      if (times.length == 0) {
        extractedImages.add(encode(mediaSource, format, properties));
      }
      for (double time : times) {
        Map params = new HashMap<>();
        if (properties != null) {
          params.putAll(properties);
        }

        DecimalFormatSymbols ffmpegFormat = new DecimalFormatSymbols();
        ffmpegFormat.setDecimalSeparator('.');
        DecimalFormat df = new DecimalFormat("0.00000", ffmpegFormat);
        params.put("time", df.format(time));

        extractedImages.add(encode(mediaSource, format, params));
      }
    } catch (Exception e) {
      cleanup(extractedImages);
      if (e instanceof EncoderException) {
        throw (EncoderException) e;
      } else {
        throw new EncoderException("Image extraction failed", e);
      }
    }

    return extractedImages;
  }

  /**
   * Executes the command line encoder with the given set of files and properties and using the provided encoding
   * profile.
   *
   * @param source
   *          the source files for encoding
   * @param profile
   *          the profile identifier
   * @param properties
   *          the encoding properties to be interpreted by the actual encoder implementation
   * @return the processed file
   * @throws EncoderException
   *           if processing fails
   */
  List process(Map source, EncodingProfile profile, Map properties)
          throws EncoderException {
    // Fist, update the parameters
    Map params = new HashMap<>();
    if (properties != null)
      params.putAll(properties);
    // build command
    if (source.isEmpty()) {
      throw new IllegalArgumentException("At least one track must be specified.");
    }
    // Set encoding parameters
    for (Map.Entry f: source.entrySet()) {
      final String input = FilenameUtils.normalize(f.getValue().getAbsolutePath());
      final String pre = "in." + f.getKey();
      params.put(pre + ".path", input);
      params.put(pre + ".name", FilenameUtils.getBaseName(input));
      params.put(pre + ".suffix", FilenameUtils.getExtension(input));
      params.put(pre + ".filename", FilenameUtils.getName(input));
      params.put(pre + ".mimetype", MimetypesFileTypeMap.getDefaultFileTypeMap().getContentType(input));
    }
    final File parentFile = source.getOrDefault("video", source.get("audio"));

    final String outDir = parentFile.getAbsoluteFile().getParent();
    final String outFileName = FilenameUtils.getBaseName(parentFile.getName())
            + "_" + UUID.randomUUID().toString();
    params.put("out.dir", outDir);
    params.put("out.name", outFileName);
    if (profile.getSuffix() != null) {
      final String outSuffix = processParameters(profile.getSuffix(), params);
      params.put("out.suffix", outSuffix);
    }

    for (String tag : profile.getTags()) {
      final String suffix = processParameters(profile.getSuffix(tag), params);
      params.put("out.suffix." + tag, suffix);
    }

    // create encoder process.
    final List command = buildCommand(profile, params);
    logger.info("Executing encoding command: {}", command);

    List outFiles = new ArrayList<>();
    BufferedReader in = null;
    Process encoderProcess = null;
    try {
      ProcessBuilder processBuilder = new ProcessBuilder(command);
      processBuilder.redirectErrorStream(REDIRECT_ERROR_STREAM);
      encoderProcess = processBuilder.start();
      processes.add(encoderProcess);

      // tell encoder listeners about output
      in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()));
      String line;
      while ((line = in.readLine()) != null) {
        handleEncoderOutput(outFiles, line);
      }

      // wait until the task is finished
      int exitCode = encoderProcess.waitFor();
      if (exitCode != 0) {
        throw new EncoderException("Encoder exited abnormally with status " + exitCode);
      }

      logger.info("Tracks {} successfully encoded using profile '{}'", source, profile.getIdentifier());
      return outFiles;
    } catch (Exception e) {
      logger.warn("Error while encoding {}  using profile '{}'",
              source, profile.getIdentifier(), e);

      // Ensure temporary data are removed
      for (File outFile : outFiles) {
        if (FileUtils.deleteQuietly(outFile)) {
          logger.debug("Removed output file of failed encoding process: {}", outFile);
        }
      }
      throw new EncoderException(e);
    } finally {
      IoSupport.closeQuietly(in);
      IoSupport.closeQuietly(encoderProcess);
    }
  }

  /*
   * Runs the raw command string thru the encoder. The string commandopts is ffmpeg specific, it just needs the binary.
   * The calling function is responsible in doing all the appropriate substitutions using the encoding profiles,
   * creating the directory for storage, etc. Encoding profiles and input names are included here for logging and
   * returns
   *
   * @param commandopts - tokenized ffmpeg command
   *
   * @param inputs - input files in the command, used for reporting
   *
   * @param profiles - encoding profiles, used for reporting
   *
   * @return encoded - media as a result of running the command
   *
   * @throws EncoderException if it fails
   */

  protected List process(List commandopts) throws EncoderException {
    logger.trace("Process raw command -  {}", commandopts);
    // create encoder process. using working dir of the
    // current java process
    Process encoderProcess = null;
    BufferedReader in = null;
    List outFiles = new ArrayList<>();
    try {
      List command = new ArrayList<>();
      command.add(binary);
      command.addAll(commandopts);
      logger.info("Executing encoding command: {}", StringUtils.join(command, " "));

      ProcessBuilder pbuilder = new ProcessBuilder(command);
      pbuilder.redirectErrorStream(REDIRECT_ERROR_STREAM);
      encoderProcess = pbuilder.start();
      // tell encoder listeners about output
      in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()));
      String line;
      while ((line = in.readLine()) != null) {
        handleEncoderOutput(outFiles, line); // get names of output files
      }
      // wait until the task is finished
      encoderProcess.waitFor();
      int exitCode = encoderProcess.exitValue();
      if (exitCode != 0) {
        throw new EncoderException("Encoder exited abnormally with status " + exitCode);
      }
      logger.info("Video track successfully encoded '{}'",
              new Object[] { StringUtils.join(commandopts, " ") });
      return outFiles; // return output as a list of files
    } catch (Exception e) {
      logger.warn("Error while encoding video tracks using '{}': {}",
              new Object[] {  StringUtils.join(commandopts, " "), e.getMessage() });
      // Ensure temporary data are removed
      for (File outFile : outFiles) {
        if (FileUtils.deleteQuietly(outFile)) {
          logger.debug("Removed output file of failed encoding process: {}", outFile);
        }
      }
      throw new EncoderException(e);
    } finally {
      IoSupport.closeQuietly(in);
      IoSupport.closeQuietly(encoderProcess);
    }
  }

  /**
   * Deletes all valid files found in a list
   *
   * @param outputFiles
   *          list containing files
   */
  private void cleanup(List outputFiles) {
    for (File file : outputFiles) {
      if (file != null && file.isFile()) {
        String path = file.getAbsolutePath();
        if (file.delete()) {
          logger.info("Deleted file {}", path);
        } else {
          logger.warn("Could not delete file {}", path);
        }
      }
    }
  }

  /**
   * Creates the command that is sent to the commandline encoder.
   *
   * @return the commandline
   * @throws EncoderException
   *           in case of any error
   */
  private List buildCommand(final EncodingProfile profile, final Map argumentReplacements)
          throws EncoderException {
    List command = new ArrayList<>();
    command.add(binary);
    command.add("-nostdin");
    command.add("-nostats");

    String commandline = profile.getExtension(CMD_SUFFIX);

    // Handle command line extensions before parsing:
    // Example:
    //   ffmpeg.command = #{concatCmd} -c copy out.mp4
    //   ffmpeg.command.concatCmd = -i ...
    for (String key: argumentReplacements.keySet()) {
      if (key.startsWith(CMD_SUFFIX + '.')) {
        final String shortKey = key.substring(CMD_SUFFIX.length() + 1);
        commandline = commandline.replace("#{" + shortKey + "}", argumentReplacements.get(key));
      }
    }

    String[] arguments;
    try {
      arguments = CommandLineUtils.translateCommandline(commandline);
    } catch (Exception e) {
      throw new EncoderException("Could not parse encoding profile command line", e);
    }

    for (String arg: arguments) {
      String result = processParameters(arg, argumentReplacements);
      if (StringUtils.isNotBlank(result)) {
        command.add(result);
      }
    }
    return command;
  }

  /**
   * {@inheritDoc}
   *
   * @see EncoderEngine#trim(File,
   *      EncodingProfile, long, long, Map)
   */
  File trim(File mediaSource, EncodingProfile format, long start, long duration, Map properties) throws EncoderException {
    if (properties == null)
      properties = new HashMap<>();
    double startD = (double) start / 1000;
    double durationD = (double) duration / 1000;
    DecimalFormatSymbols ffmpegFormat = new DecimalFormatSymbols();
    ffmpegFormat.setDecimalSeparator('.');
    DecimalFormat df = new DecimalFormat("00.00000", ffmpegFormat);
    properties.put(PROP_TRIMMING_START_TIME, df.format(startD));
    properties.put(PROP_TRIMMING_DURATION, df.format(durationD));
    return encode(mediaSource, format, properties);
  }

  /**
   * Processes the command options by replacing the templates with their actual values.
   *
   * @return the commandline
   */
  private String processParameters(String cmd, final Map args) {
    for (Map.Entry e: args.entrySet()) {
      cmd = cmd.replace("#{" + e.getKey() + "}", e.getValue());
    }

    // Also replace spaces
    cmd = cmd.replace("#{space}", " ");

    /* Remove unused commandline parts */
    return cmd.replaceAll("#\\{.*?\\}", "");
  }

  @Override
  public void close() {
    for (Process process: processes) {
      if (process.isAlive()) {
        logger.debug("Destroying encoding process {}", process);
        process.destroy();
      }
    }
  }

  /**
   * Handles the encoder output by analyzing it first and then firing it off to the registered listeners.
   * Has provisions to deal with HLS outputs which uses templates
   *
   * @param message
   *          the message returned by the encoder
   */
  private void handleEncoderOutput(List output, String message) {
    message = message.trim();
    if ("".equals(message))
      return;

    // Others go to trace logging
    if (StringUtils.startsWithAny(message.toLowerCase(),
          "ffmpeg version", "configuration", "lib", "size=", "frame=", "built with")) {
      logger.trace(message);

    // Handle output files
    } else if (StringUtils.startsWith(message, "Output #")) {
      logger.debug(message);
      Matcher matcher = outputPattern.matcher(message);
      if (matcher.find()) {
        String type = matcher.group(1);
        String outputPath = matcher.group(2);
        if (!StringUtils.equals("NUL", outputPath) && !StringUtils.equals("/dev/null", outputPath)
                && !StringUtils.equals("/dev/null", outputPath)
                && !StringUtils.startsWith("pipe:", outputPath)) {
          File outputFile = new File(outputPath);
          if (!type.startsWith("hls")) {
            logger.info("Identified output file {}", outputFile);
            output.add(outputFile);
          }
        }
      }
    } else if (StringUtils.startsWith(message, "[hls @ ")) {
      logger.debug(message);
      Matcher matcher = outputPatternHLS.matcher(message);
      if (matcher.find()) {
        final String outputPath = Objects.toString(matcher.group(1), matcher.group(2));
        if (!StringUtils.equals("NUL", outputPath) && !StringUtils.equals("/dev/null", outputPath)
                && !StringUtils.startsWith("pipe:", outputPath)) {
          File outputFile = new File(outputPath);
          // HLS generates the filenames based on a template with %v and %d replaced
          // HLS writes into the same manifest file to add each segment
          if (!output.contains(outputFile)) {
            logger.info("Identified HLS output file {}", outputFile);
            output.add(outputFile);
          }
        }
      }

    // Some to debug
    } else if (StringUtils.startsWithAny(message.toLowerCase(),
          "artist", "compatible_brands", "copyright", "creation_time", "description", "composer", "date", "duration",
            "encoder", "handler_name", "input #", "last message repeated", "major_brand", "metadata", "minor_version",
            "output #", "program", "side data:", "stream #", "stream mapping", "title", "video:", "[libx264 @ ", "Press [")) {
      logger.debug(message);

    // And the rest is likely to deserve at least info
    } else {
      logger.info(message);
    }
  }

  /**
   * Splits a line into tokens - mindful of single and double quoted string as single token Apache common and guava do
   * not deal with quotes
   *
   * @param str
   * @return an array of string tokens
   */
  public List commandSplit(String str) {
    ArrayList al = new ArrayList();
    final Pattern regex = Pattern.compile("\"([^\"]*)\"|\'([^\']*)\'|\\S+");
    Matcher m = regex.matcher(str);
    while (m.find()) {
      if (m.group(1) != null) {
        // double-quoted string without the quotes
        al.add(m.group(1));
      } else if (m.group(2) != null) {
        // single-quoted string without the quotes
        al.add(m.group(2));
      } else {
        // Add unquoted word
        al.add(m.group());
      }
    }
    return (al);
  }

  /**
   * Use a separator to join a string entry only if it is not null or empty
   *
   * @param srlist
   *          -array of string
   * @param separator
   *          - to join the string
   * @return a string
   */
  public String joinNonNullString(String[] srlist, String separator) {
    StringBuffer sb = new StringBuffer();
    for (int i = 0; i < srlist.length; i++) {
      if (srlist[i] == null || srlist[i].isEmpty())
        continue;
      else {
        if (sb.length() > 0)
          sb.append(separator);
        sb.append(srlist[i]);
      }
    }
    return sb.toString();
  }

  /**
   * Rewrite multiple profiles to ffmpeg complex filter filtergraph chains - inputs are passed in as options, eq: [0aa]
   * and [0vv] Any filters in the encoding profiles are moved into a clause in the complex filter chain for each output
   */
  protected class OutputAggregate {
    private final List pf;
    private final ArrayList outputs = new ArrayList<>();
    private final ArrayList outputFiles = new ArrayList<>();
    private final ArrayList outputSuffixes = new ArrayList<>(); // for HLS
    private boolean hasAdaptiveProfile = false;
    private final ArrayList vpads; // output pads for each segment
    private final ArrayList apads;
    private final ArrayList vfilter; // filters for each output format
    private final ArrayList afilter;
    private String vInputPad = "";
    private String aInputPad = "";
    private String vsplit = "";
    private String asplit = "";
    private final ArrayList vstream; // output video name
    private final ArrayList astream; // output audio name

    public OutputAggregate(List profiles,
            Map params, String vInputPad, String aInputPad) throws EncoderException {
      ArrayList deliveryProfiles = new ArrayList(profiles.size());
      EncodingProfile groupProfile = null;
      for (EncodingProfile ep: profiles) {
        String adaptiveType = ep.getExtension(ADAPTIVE_TYPE_SUFFIX);
        if (adaptiveType == null) {
          deliveryProfiles.add(ep);
        } else {
          if ("HLS".equalsIgnoreCase(adaptiveType)) {
            groupProfile = ep;
            hasAdaptiveProfile = true;
          }
          else
            throw new EncoderException("Only HLS is supported" + ep.getIdentifier() + " ffmpeg command");
        }
      }
      this.pf = deliveryProfiles;
      int size = this.pf.size();

      if (vInputPad == null && aInputPad == null)
        throw new EncoderException("At least one of video or audio input must be specified");
      // Init
      vfilter = new ArrayList<>(Collections.nCopies(size, null));
      afilter = new ArrayList<>(Collections.nCopies(size, null));
      // name of output pads to map to files
      apads = new ArrayList<>(Collections.nCopies(size, null));
      vpads = new ArrayList<>(Collections.nCopies(size, null));

      vstream = new ArrayList<>(Collections.nCopies(size, null));
      astream = new ArrayList<>(Collections.nCopies(size, null));

      vsplit = (size > 1) ? (vInputPad + "split=" + size) : null; // number of splits
      asplit = (size > 1) ? (aInputPad + "asplit=" + size) : null;
      this.vInputPad = vInputPad;
      this.aInputPad = aInputPad;
      if (groupProfile != null)
        outputAggregateReal(deliveryProfiles, groupProfile, params, vInputPad, aInputPad);
      else
        outputAggregateReal(deliveryProfiles, params, vInputPad, aInputPad);
    }


    /*
     * set the audio filter if there are any in the profiles or identity
     */
    private void setAudioFilters() {
      if (pf.size() == 1) {
        if (afilter.get(0) != null)
          afilter.set(0, aInputPad + afilter.get(0) + apads.get(0)); // Use audio filter on input directly
          astream.set(0, apads.get(0));
      } else
        for (int i = 0; i < pf.size(); i++) {
          if (afilter.get(i) != null) {
            afilter.set(i, "[oa0" + i + "]" + afilter.get(i) + apads.get(i)); // Use audio filter on apad
            asplit += "[oa0" + i + "]";
            astream.set(i, "[oa0" + i + "]");
          } else {
            asplit += apads.get(i); // straight to output
            astream.set(i, apads.get(i));
          }
        }
      afilter.removeAll(Arrays.asList((String) null));
    }

    /*
     * set the video filter if there are any in the profiles
     */
    private void setVideoFilters() {
      if (pf.size() == 1) {
        if (vfilter.get(0) != null)
          vfilter.set(0, vInputPad + vfilter.get(0) + vpads.get(0)); // send to filter first
          vstream.set(0, vpads.get(0));
      } else
        for (int i = 0; i < pf.size(); i++) {
          if (vfilter.get(i) != null) {
            vfilter.set(i, "[ov0" + i + "]" + vfilter.get(i) + vpads.get(i)); // send to filter first
            vsplit += "[ov0" + i + "]";
            vstream.set(i, "[ov0" + i + "]");
          } else {
            vsplit += vpads.get(i);// straight to output
            vstream.set(i, vpads.get(i));
          }
        }

      vfilter.removeAll(Arrays.asList((String) null));
    }

    public List getOutFiles() {
      return outputFiles;
    }

    /**
     *
     * @return output pads - the "-map xyz" clauses
     */
    public List getOutput() {
      return outputs;
    }

    /**
     * Get the profile suffixes with source file string interpolation done
     *
     * @return the suffixes iff adaptive, otherwise empty
     */
    public List getSegmentOutputSuffixes() {
      return outputSuffixes;
    }

    /**
     * Check for adaptive playlist output - output may need remapping
     *
     * @return if true
     */
    public boolean hasAdaptivePlaylist() {
      return hasAdaptiveProfile;
    }

    /**
     *
     * @return filter split clause for ffmpeg
     */
    public String getVsplit() {
      return vsplit;
    }

    public String getAsplit() {
      return asplit;
    }

    public String getVideoFilter() {
      if (vfilter.isEmpty())
        return null;
      return StringUtils.join(vfilter, ";");
    }

    public String getAudioFilter() {
      if (afilter.isEmpty())
        return null;
      return StringUtils.join(afilter, ";");
    }

    /**
     * If this is a raw mapping not used with complex filter, strip the square brackets if there are any
     *
     * @param pad
     *          - such as 0:a, [0:v], [1:1],[0:12],[main],[overlay]
     * @return adjusted pad
     */
    public String adjustForNoComplexFilter(String pad) {
      final Pattern outpad = Pattern.compile("\\[(\\d+:[av\\d{1,2}])\\]");
      try {
        Matcher matcher = outpad.matcher(pad); // throws exception if pad is null
        if (matcher.matches()) {
          return matcher.group(1);
        }
      } catch (Exception e) {
      }
      return pad;
    }

    /**
     * Replace all the templates with real values for each profile
     *
     * @param cmd
     *          from profile
     * @param params
     *          from input
     * @return command
     */
    protected String processParameters(String cmd, Map params) {
      String r = cmd;
      for (Map.Entry e : params.entrySet()) {
        r = r.replace("#{" + e.getKey() + "}", e.getValue());
      }
      return r;
    }

    /**
     * Translate the profiles to work with complex filter clauses in ffmpeg, it splits one output into multiple, one for
     * each encoding profile. This also generates the manifests for HLS using the group profile (HLS only). Each
     * encoding profile must have a bitrate or one will be generated for all the profiles.
     * This requires ffmpeg version later than 4.1
     *
     * @param profiles
     *          - list of encoding profiles
     * @param groupProfile
     *          - encoding profile that applies to all output and has precedence, currently only HLS options
     * @param params
     *          - values for substitution
     * @param vInputPad
     *          - name of video pad as input, eg: [0v] null if no video
     * @param aInputPad
     *          - name of audio pad as input, eg [0a], null if no audio
     * @throws EncoderException
     *           - if it fails
     */
    public void outputAggregateReal(List profiles, EncodingProfile groupProfile,
            Map params, String vInputPad, String aInputPad) throws EncoderException {
      int size = profiles.size();

      // substitute the output file suffix for group
      try {
        String outSuffix = processParameters(groupProfile.getSuffix(), params);
        params.put("out.suffix", outSuffix); // Add profile suffix
      } catch (Exception e) {
        throw new EncoderException("Missing Encoding Profiles");
      }
      String ffmpgGCmd = groupProfile.getExtension(CMD_SUFFIX); // Get ffmpeg command from profile

      if (ffmpgGCmd == null)
        throw new EncoderException("Missing ffmpeg Encoding Profile " + groupProfile.getIdentifier() + " ffmpeg command");
      for (Map.Entry e : params.entrySet()) { // replace output filenames
        ffmpgGCmd = ffmpgGCmd.replace("#{" + e.getKey() + "}", e.getValue());
      }
      ffmpgGCmd = ffmpgGCmd.replace("#{space}", " ");
      int indx = 0; // individual quality profiles - names are not needed anymore
      // Only quality(bitrate/resolution/etc) and position matters
      for (EncodingProfile profile : profiles) {
        String cmd = "";
        // substitute the output file name
        outputSuffixes.add(processParameters(profile.getSuffix(), params)); // preferred suffixes
        String ffmpgCmd = profile.getExtension(CMD_SUFFIX); // Get ffmpeg command from profile
        if (ffmpgCmd == null)
          throw new EncoderException("Missing Encoding Profile " + profile.getIdentifier() + " ffmpeg command");
        // Leave this so they will be removed
        params.remove("out.dir");
        params.remove("out.name");
        params.remove("out.suffix");
        for (Map.Entry e : params.entrySet()) { // replace output filenames
          ffmpgCmd = ffmpgCmd.replace("#{" + e.getKey() + "}", e.getValue());
        }
        ffmpgCmd = ffmpgCmd.replace("#{space}", " ");
        List cmdToken;
        try {
          cmdToken = commandSplit(ffmpgCmd);
        } catch (Exception e) {
          throw new EncoderException("Could not parse encoding profile command line", e);
        }
        //List cmdToken = Arrays.asList(arguments);
        for (int i = 0; i < cmdToken.size(); i++) {
          if (cmdToken.get(i).contains("#{out.name}")) {
            if (i == cmdToken.size() - 1) { // last item, most likely
              cmdToken = cmdToken.subList(0, i);
              break;
            } else { // in the middle of the list
              List copy = cmdToken.subList(0, i - 1);
              copy.addAll(cmdToken.subList(i + 1, cmdToken.size() - 1));
              cmdToken = copy;
            }
          }
        }
        // Find and remove input and filters from ffmpeg command from the profile
        int i = 0;
        String maxrate = null;
        while (i < cmdToken.size()) {
          String opt = cmdToken.get(i);
          if (opt.startsWith("-vf") || opt.startsWith("-filter:v")) { // video filters
            vfilter.set(indx, cmdToken.get(i + 1).replace("\"", "")); // store without quotes
            i++;
          } else if (opt.startsWith("-filter_complex") || opt.startsWith("-lavfi")) { // safer to quit now than to
            // baffle users with strange errors later
            i++;
            logger.error("Command does not support complex filters - only simple -af or -vf filters are supported");
            throw new EncoderException(
                    "Cannot parse complex filters in" + profile.getIdentifier() + " for this operation");
          } else if (opt.startsWith("-af") || opt.startsWith("-filter:a")) { // audio filter
            afilter.set(indx, cmdToken.get(i + 1).replace("\"", "")); // store without quotes
            i++;
          } else if ("-i".equals(opt)) {
            i++; // inputs are now mapped, remove from command
          } else if (opt.startsWith("-c:") || opt.startsWith("-codec:") || opt.contains("-vcodec")
                  || opt.contains("-acodec")) { // cannot copy codec in complex filter
            String str = cmdToken.get(i + 1);
            if (str.contains("copy")) // c
              i++;
            else if (opt.startsWith("-codec:") || opt.contains("-vcodec")) { // becomes -c:v
              cmd = cmd + " " + adjustABRVMaps("-c:v", indx);
            }
            else if (opt.startsWith("-acodec:"))
              cmd = cmd + " " + adjustABRVMaps("-c:a", indx);
            else
              cmd = cmd + " " + adjustABRVMaps(opt, indx);
          } else { // keep the rest
            cmd = cmd + " " + adjustABRVMaps(opt, indx);
          }
          i++;
        }

        /* Remove unused commandline parts */
        cmd = cmd.replaceAll("#\\{.*?\\}", "");
        // Find the output map based on splits and filters
        if (size == 1) { // no split
          if (afilter.get(indx) == null)
            apads.set(indx, adjustForNoComplexFilter(aInputPad));
          else
            apads.set(indx, "[oa" + indx + "]");
          if (vfilter.get(indx) == null)
            vpads.set(indx, adjustForNoComplexFilter(vInputPad)); // No split, no filter - straight from input
          else
            vpads.set(indx, "[ov" + indx + "]");

        } else { // split
          vpads.set(indx, "[ov" + indx + "]"); // name the output pads from split -> input to final format
          apads.set(indx, "[oa" + indx + "]"); // name the output audio pads
        }
        cmd = StringUtils.trimToNull(cmd); // remove all leading/trailing white spaces
        if (cmd != null) {
          // No direct output from encoding profile
          // outputFiles.add(cmdToken.get(cmdToken.size() - 1));
          if (vInputPad != null) {
            outputs.add("-map " + vpads.get(indx));
          }
          if (aInputPad != null) {
            outputs.add("-map " + apads.get(indx)); // map video and audio input
          }
          outputs.add(cmd); // profiles appended in order, they are numbered 0,1,2,3...
          indx++; // indx for this profile
        }
      }
      setVideoFilters();
      setAudioFilters();
      setHLSVarStreamMap(ffmpgGCmd, vInputPad != null, aInputPad != null); // Only HLS is supported so far
    }

    /**
     * Sets the mapping of outputs to HLS streams.
     *
     * @param ffmpgCmd
     *          - ffmpeg command with substitution from the encoding profile
     * @param hasVideo
     *          - use video stream
     * @param hasAudio
     *          - use audio stream
     */
    private void setHLSVarStreamMap(String ffmpgCmd, boolean hasVideo, boolean hasAudio) {
      StringBuilder varStreamMap = new StringBuilder();
      varStreamMap.append(" -var_stream_map '");

      for (int i = 0; i < pf.size(); i++) {
        int j = 0;
        String[] maps = new String[2];
        if (hasVideo && vstream.get(i) != null) { // Has video
          maps[j] = "v:" + i;
          ++j;
        }
        if (hasAudio && astream.get(i) != null) { // Has audio
          maps[j] = "a:" + i;
        }
        // each target delivery is v:i,a:i
        varStreamMap.append(joinNonNullString(maps, ","));
        varStreamMap.append(" ");
      }

      varStreamMap.append("' ");
      varStreamMap.append(ffmpgCmd);
      varStreamMap.append(" ");
      outputs.add(varStreamMap.toString()); // treat as another output
    }

    /**
     * When the inputs are routed to ABR, some options need to have a v:int suffix for video and a:0 for audio Any
     * options ending with ":v" will get a number, otherwise try and guess use option:(v or a) notables (eg: b:v, c:v),
     * options such as ab or vb will not work
     *
     * @param option
     *          - ffmpeg option
     * @param position
     *          - position in the command
     */
    public String adjustABRVMaps(String option, int position) {
      if (option.endsWith(":v") || option.endsWith(":a")) {
        return option + ":" + Integer.toString(position);
      } else if (mappableOptions.contains(option)) {
        return option + ":v:" + Integer.toString(position);
      } else
        return option;
    }


    /**
     * Translate the profiles to work with complex filter clauses in ffmpeg, it splits one output into multiple, one for
     * each encoding profile
     *
     * @param profiles
     *          - list of encoding profiles
     * @param params
     *          - values for substitution
     * @param vInputPad
     *          - name of video pad as input, eg: [0v] null if no video
     * @param aInputPad
     *          - name of audio pad as input, eg [0a], null if no audio
     * @throws EncoderException
     *           - if it fails
     */
    public void outputAggregateReal(List profiles, Map params,
              String vInputPad, String aInputPad) throws EncoderException {

      int size = profiles.size();
      int indx = 0; // profiles
      for (EncodingProfile profile : profiles) {
        String cmd = "";
        String outSuffix;
        // generate random name as we only have one base name
        String outFileName = params.get("out.name.base") + "_" + IdImpl.fromUUID().toString();
        params.put("out.name", outFileName); // Output file name for this profile
        try {
          outSuffix = processParameters(profile.getSuffix(), params);
          params.put("out.suffix", outSuffix); // Add profile suffix
        } catch (Exception e) {
          throw new EncoderException("Missing Encoding Profiles");
        }
        // substitute the output file name
        String ffmpgCmd = profile.getExtension(CMD_SUFFIX); // Get ffmpeg command from profile
        if (ffmpgCmd == null)
          throw new EncoderException("Missing Encoding Profile " + profile.getIdentifier() + " ffmpeg command");
        for (Map.Entry e : params.entrySet()) { // replace output filenames
          ffmpgCmd = ffmpgCmd.replace("#{" + e.getKey() + "}", e.getValue());
        }
        ffmpgCmd = ffmpgCmd.replace("#{space}", " ");
        String[] arguments;
        try {
          arguments = CommandLineUtils.translateCommandline(ffmpgCmd);
        } catch (Exception e) {
          throw new EncoderException("Could not parse encoding profile command line", e);
        }
        List cmdToken = Arrays.asList(arguments);
        // Find and remove input and filters from ffmpeg command from the profile
        int i = 0;
        while (i < cmdToken.size()) {
          String opt = cmdToken.get(i);
          if (opt.startsWith("-vf") || opt.startsWith("-filter:v")) { // video filters
            vfilter.set(indx, cmdToken.get(i + 1).replace("\"", "")); // store without quotes
            i++;
          } else if (opt.startsWith("-filter_complex") || opt.startsWith("-lavfi")) { // safer to quit now than to
            // baffle users with strange errors later
            i++;
            logger.error("Command does not support complex filters - only simple -af or -vf filters are supported");
            throw new EncoderException(
                    "Cannot parse complex filters in" + profile.getIdentifier() + " for this operation");
          } else if (opt.startsWith("-af") || opt.startsWith("-filter:a")) { // audio filter
            afilter.set(indx, cmdToken.get(i + 1).replace("\"", "")); // store without quotes
            i++;
          } else if ("-i".equals(opt)) {
            i++; // inputs are now mapped, remove from command
          } else if (opt.startsWith("-c:") || opt.startsWith("-codec:") || opt.contains("-vcodec")
                  || opt.contains("-acodec")) { // cannot copy codec in complex filter
            String str = cmdToken.get(i + 1);
            if (str.contains("copy")) // c
              i++;
            else
              cmd = cmd + " " + opt;
          } else { // keep the rest
            cmd = cmd + " " + opt;
          }
          i++;
        }
        /* Remove unused commandline parts */
        cmd = cmd.replaceAll("#\\{.*?\\}", "");
        // Find the output map based on splits and filters
        if (size == 1) { // no split
          if (afilter.get(indx) == null)
            apads.set(indx, adjustForNoComplexFilter(aInputPad));
          else
            apads.set(indx, "[oa" + indx + "]");
          if (vfilter.get(indx) == null)
            vpads.set(indx, adjustForNoComplexFilter(vInputPad)); // No split, no filter - straight from input
          else
            vpads.set(indx, "[ov" + indx + "]");

        } else { // split
          vpads.set(indx, "[ov" + indx + "]"); // name the output pads from split -> input to final format
          apads.set(indx, "[oa" + indx + "]"); // name the output audio pads
        }
        cmd = StringUtils.trimToNull(cmd); // remove all leading/trailing white spaces
        if (cmd != null) {
          outputFiles.add(cmdToken.get(cmdToken.size() - 1));
          if (vInputPad != null) {
            outputs.add("-map " + vpads.get(indx));
          }
          if (aInputPad != null) {
            outputs.add("-map " + apads.get(indx)); // map video and audio input
          }
          outputs.add(cmd); // profiles appended in order, they are numbered 0,1,2,3...
          indx++; // indx for this profile
        }
      }
      setVideoFilters();
      setAudioFilters();
    }
  }

  /**
   * Clean up the edit points, make sure the gap between consecutive segments are larger than the transition Otherwise
   * it can be very slow to run and output will be ugly because the fades will extend the clip
   *
   * @param edits
   *          - clips to be stitched together
   * @param gap
   *          = transitionDuration / 1000; default gap size - same as fade
   * @return a list of sanitized video clips
   */
  private static List sortSegments(List edits, double gap) {
    LinkedList ll = new LinkedList();
    Iterator it = edits.iterator();
    VideoClip clip;
    VideoClip nextclip;
    int lastSrc = -1;
    while (it.hasNext()) { // Skip sort if there are multiple sources
      clip = it.next();
      if (lastSrc < 0) {
        lastSrc = clip.getSrc();
      } else if (lastSrc != clip.getSrc()) {
        return edits;
      }
    }
    Collections.sort(edits); // Sort clips if all clips are from the same src
    List clips = new ArrayList();
    it = edits.iterator();
    while (it.hasNext()) { // Check for legal durations
      clip = it.next();
      if (clip.getDuration() > gap) { // Keep segments at least as long as transition fade
        ll.add(clip);
      }
    }
    clip = ll.pop(); // initialize
    // Clean up segments so that the cut out is at least as long as the transition gap (default is fade out-fade in)
    while (!ll.isEmpty()) { // Check that 2 consecutive segments from same src are at least GAP secs apart
      if (ll.peek() != null) {
        nextclip = ll.pop(); // check next consecutive segment
        if ((nextclip.getSrc() == clip.getSrc()) && (nextclip.getStart() - clip.getEnd()) < gap) { // collapse two
          // segments into one
          clip.setEnd(nextclip.getEnd()); // by using inpt of seg 1 and outpoint of seg 2
        } else {
          clips.add(clip); // keep last segment
          clip = nextclip; // check next segment
        }
      }
    }
    clips.add(clip); // add last segment
    return clips;
  }

  /**
   * Create the trim part of the complex filter and return the clauses for the complex filter. The transition is fade to
   * black then fade from black. The outputs are mapped to [ov] and [oa]
   *
   * @param clips
   *          - video segments as indices into the media files by time
   * @param transitionDuration
   *          - length of transition in MS between each segment
   * @param hasVideo
   *          - has video, from inspection
   * @param hasAudio
   *          - has audio
   * @return complex filter clauses to do editing for ffmpeg
   * @throws Exception
   *           - if it fails
   */
  private List makeEdits(List clips, int transitionDuration, Boolean hasVideo,
          Boolean hasAudio) throws Exception {
    double vfade = transitionDuration / 1000; // video and audio have the same transition duration
    double afade = vfade;
    DecimalFormatSymbols ffmpegFormat = new DecimalFormatSymbols();
    ffmpegFormat.setDecimalSeparator('.');
    DecimalFormat f = new DecimalFormat("0.00", ffmpegFormat);
    List vpads = new ArrayList<>();
    List apads = new ArrayList<>();
    List clauses = new ArrayList<>(); // The clauses are ordered
    int n = 0;
    if (clips != null)
      n = clips.size();
    String outmap = "o";
    if (n > 1) { // Create the input pads if we have multiple segments
      for (int i = 0; i < n; i++) {
        vpads.add("[v" + i + "]"); // post filter
        apads.add("[a" + i + "]");
      }
      outmap = "";
      // Create the trims
      for (int i = 0; i < n; i++) { // Each clip
        // get clip and add fades to each clip
        VideoClip vclip = clips.get(i);
        int fileindx = vclip.getSrc(); // get source file by index
        double inpt = vclip.getStart(); // get in points
        double duration = vclip.getDuration();
        double vend = Math.max(duration - vfade, 0);
        double aend = Math.max(duration - afade, 0);
        if (hasVideo) {
          String vvclip;
          vvclip = "[" + fileindx + ":v]trim=" + f.format(inpt) + ":duration=" + f.format(duration)
                  + ",setpts=PTS-STARTPTS"
                  + ((vfade > 0) ? ",fade=t=in:st=0:d=" + vfade + ",fade=t=out:st=" + f.format(vend) + ":d=" + vfade
                          : "")
                  + "[" + outmap + "v" + i + "]";
          clauses.add(vvclip);
        }
        if (hasAudio) {
          String aclip;
          aclip = "[" + fileindx + ":a]atrim=" + f.format(inpt) + ":duration=" + f.format(duration)
                  + ",asetpts=PTS-STARTPTS"
                  + ((afade > 0)
                          ? ",afade=t=in:st=0:d=" + afade + ",afade=t=out:st=" + f.format(aend) + ":d=" + afade
                          : "")
                  + "[" + outmap + "a" + i + "]";
          clauses.add(aclip);
        }
      }
      // use unsafe because different files may have different SAR/framerate
      if (hasVideo)
        clauses.add(StringUtils.join(vpads, "") + "concat=n=" + n + ":unsafe=1[ov]"); // concat video clips
      if (hasAudio)
        clauses.add(StringUtils.join(apads, "") + "concat=n=" + n + ":v=0:a=1[oa]"); // concat audio clips in stream 0,
    } else if (n == 1) { // single segment
      VideoClip vclip = clips.get(0);
      int fileindx = vclip.getSrc(); // get source file by index
      double inpt = vclip.getStart(); // get in points
      double duration = vclip.getDuration();
      double vend = Math.max(duration - vfade, 0);
      double aend = Math.max(duration - afade, 0);

      if (hasVideo) {
        String vvclip;

        vvclip = "[" + fileindx + ":v]trim=" + f.format(inpt) + ":duration=" + f.format(duration)
                + ",setpts=PTS-STARTPTS"
                + ((vfade > 0) ? ",fade=t=in:st=0:d=" + vfade + ",fade=t=out:st=" + f.format(vend) + ":d=" + vfade : "")
                + "[ov]";

        clauses.add(vvclip);
      }
      if (hasAudio) {
        String aclip;
        aclip = "[" + fileindx + ":a]atrim=" + f.format(inpt) + ":duration=" + f.format(duration)
                + ",asetpts=PTS-STARTPTS"
                + ((afade > 0) ? ",afade=t=in:st=0:d=" + afade + ",afade=t=out:st=" + f.format(aend) + ":d=" + afade
                        : "")
                + "[oa]";

        clauses.add(aclip);
      }
    }
    return clauses; // if no edits, there are no clauses
  }

  private Map getParamsFromFile(File parentFile) {
    Map params = new HashMap<>();
    String videoInput = FilenameUtils.normalize(parentFile.getAbsolutePath());
    params.put("in.video.path", videoInput);
    params.put("in.video.name", FilenameUtils.getBaseName(videoInput));
    params.put("in.name", FilenameUtils.getBaseName(videoInput)); // One of the names
    params.put("in.video.suffix", FilenameUtils.getExtension(videoInput));
    params.put("in.video.filename", FilenameUtils.getName(videoInput));
    params.put("in.video.mimetype", MimetypesFileTypeMap.getDefaultFileTypeMap().getContentType(videoInput));
    String outDir = parentFile.getAbsoluteFile().getParent(); // Use first file dir
    params.put("out.dir", outDir);
    String outFileName = FilenameUtils.getBaseName(parentFile.getName());
    params.put("out.name.base", outFileName); // Base file name used
    params.put("out.name", outFileName); // file name used - may be replaced
    return params;
  }

  /**
   * Concatenate segments of one or more input tracks specified by trim points into the track the edits are passed in as
   * double so that it is generic. The tracks are assumed to have the same resolution.
   *
   * @param inputs
   *          - input tracks as a list of files
   * @param edits
   *          - edits are a flat list of triplets, each triplet represent one clip: index (int) into input tracks, trim in point(long)
   *          in milliseconds and trim out point (long) in milliseconds for each segment
   * @param profiles
   *          - encoding profiles for each delivery target - [optional] one adaptive profile to apply to the outputs to
   *          generate manifests/playlists
   * @param transitionDuration
   *          in ms, transition time between each edited segment
   * @throws EncoderException
   *           - if it fails
   */
  public List multiTrimConcat(List inputs, List edits, List profiles,
          int transitionDuration) throws EncoderException {
    return multiTrimConcat(inputs, edits, profiles, transitionDuration, true, true);

  }

  public List multiTrimConcat(List inputs, List edits, List profiles,
          int transitionDuration, boolean hasVideo, boolean hasAudio)
          throws EncoderException, IllegalArgumentException {
    if (inputs == null || inputs.size() < 1) {
      throw new IllegalArgumentException("At least one track must be specified.");
    }
    if (edits == null && inputs.size() > 1) {
      throw new IllegalArgumentException("If there is no editing, only one track can be specified.");
    }
    List clips = null;
    if (edits != null) {
      clips = new ArrayList(edits.size() / 3);
      int adjust = 0;
      // When the first clip starts at 0, and there is a fade, lip sync can be off,
      // this adjustment will mitigate the problem
      for (int i = 0; i < edits.size(); i += 3) {
        if (edits.get(i + 1) < transitionDuration) // If taken from the beginning of video
          adjust = transitionDuration / 2000; // add half the fade duration in seconds
        else
          adjust = 0;
        clips.add(new VideoClip(edits.get(i).intValue(), (double) edits.get(i + 1) / 1000 + adjust,
              (double) edits.get(i + 2) / 1000));
      }
      try {
        clips = sortSegments(clips, transitionDuration / 1000); // remove bad edit points
      } catch (Exception e) {
        logger.error("Illegal edits, cannot sort segment", e);
      throw new EncoderException("Cannot understand the edit points", e);
      }
    }
    // Set encoding parameters
    Map params = null;
    if (inputs.size() > 0) { // Shared parameters - the rest are profile specific
      params = getParamsFromFile(inputs.get(0));
    }
    if (profiles == null || profiles.size() == 0) {
      logger.error("Missing encoding profiles");
      throw new EncoderException("Missing encoding profile(s)");
    }
    try {
      List command = new ArrayList<>();
      List clauses = makeEdits(clips, transitionDuration, hasVideo, hasAudio); // map inputs into [ov]
                                                                                               // and [oa]
      // Entry point for multiencode here, if edits is empty, then use raw channels instead of output from edits
      String videoOut = (clips == null) ? "[0:v]" : "[ov]";
      String audioOut = (clips == null) ? "[0:a]" : "[oa]";
      OutputAggregate outmaps = new OutputAggregate(profiles, params, (hasVideo ? videoOut : null),
              (hasAudio ? audioOut : null)); // map outputs from ov and oa
      if (hasAudio) {
        clauses.add(outmaps.getAsplit());
        clauses.add(outmaps.getAudioFilter());
      }
      if (hasVideo) {
        clauses.add(outmaps.getVsplit());
        clauses.add(outmaps.getVideoFilter());
      }
      clauses.removeIf(Objects::isNull); // remove all empty filters
      command.add("-nostats"); // no progress report
      command.add("-hide_banner"); // no configuration/library info
      for (File o : inputs) {
        command.add("-i"); // Add inputfile in the order of entry
        command.add(o.getCanonicalPath());
      }
      if (!clauses.isEmpty()) {
        command.add("-filter_complex");
        command.add(StringUtils.join(clauses, ";"));
      }
      for (String outpad : outmaps.getOutput()) {
        command.addAll(commandSplit(outpad)); // split by space
      }
      if (outmaps.hasAdaptivePlaylist()) {
        List results = process(command); // Run the ffmpeg command
        // Sort list of segmented mp4s because the output segments are numbered
        List segments = results.stream().filter(AdaptivePlaylist.isHLSFilePred.negate())
                .collect(Collectors.toList());
        segments.sort((File f1, File f2) -> f1.getName().compareTo(f2.getName()));
        List suffixes = outmaps.getSegmentOutputSuffixes();
        HashMap renames = new HashMap();
        results.forEach((f) -> {
          renames.put(f, f); // init
        });
        for (int i = 0; i < segments.size(); i++) {
          File file = segments.get(i);
          // Construct a new name with old name (unique within this group) and profile suffix
          String newname = FilenameUtils.concat(file.getParent(),
                  FilenameUtils.getBaseName(file.getName()) + suffixes.get(i));
          renames.put(file, new File(newname)); // only segments change names
        }
        // Adjust the playlists to use new names
        return AdaptivePlaylist.hlsRenameAllFiles(results, renames);
      }
      return process(command); // Run the ffmpeg command and return outputs
    } catch (Exception e) {
      logger.error("MultiTrimConcat failed to run command {} ", e.getMessage());
      throw new EncoderException("Cannot encode the inputs",e);
    }
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy