1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
65
66 public class EncoderEngine implements AutoCloseable {
67
68
69 static final String CMD_SUFFIX = "ffmpeg.command";
70 static final String ADAPTIVE_TYPE_SUFFIX = "adaptive.type";
71
72 static final String PROP_TRIMMING_START_TIME = "trim.start";
73
74 static final String PROP_TRIMMING_DURATION = "trim.duration";
75
76 private static final boolean REDIRECT_ERROR_STREAM = true;
77
78 private static Logger logger = LoggerFactory.getLogger(EncoderEngine.class);
79
80 private String binary = "ffmpeg";
81
82 private Set<Process> processes = new HashSet<>();
83
84 private final Pattern outputPattern = Pattern.compile("Output .* (\\S+) to '(.*)':");
85
86 private final Pattern outputPatternHLS = Pattern.compile("Opening '([^']+)\\.tmp'|([^']+)' for writing");
87
88
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
97
98 EncoderEngine(String binary) {
99 this.binary = binary;
100 }
101
102
103
104
105
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
118
119
120
121
122
123
124
125
126
127
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
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
165
166
167
168
169
170
171
172
173
174
175
176
177 List<File> process(Map<String, File> source, EncodingProfile profile, Map<String, String> properties)
178 throws EncoderException {
179
180 Map<String, String> params = new HashMap<>();
181 if (properties != null)
182 params.putAll(properties);
183
184 if (source.isEmpty()) {
185 throw new IllegalArgumentException("At least one track must be specified.");
186 }
187
188 for (Map.Entry<String, File> f: source.entrySet()) {
189 final String input = FilenameUtils.normalize(f.getValue().getAbsolutePath());
190 final String pre = "in." + f.getKey();
191 params.put(pre + ".path", input);
192 params.put(pre + ".name", FilenameUtils.getBaseName(input));
193 params.put(pre + ".suffix", FilenameUtils.getExtension(input));
194 params.put(pre + ".filename", FilenameUtils.getName(input));
195 params.put(pre + ".mimetype", MimetypesFileTypeMap.getDefaultFileTypeMap().getContentType(input));
196 }
197 final File parentFile = source.getOrDefault("video", source.getOrDefault("audio",
198 source.values().stream().findFirst().get()));
199
200 final String outDir = parentFile.getAbsoluteFile().getParent();
201 final String outFileName = FilenameUtils.getBaseName(parentFile.getName())
202 + "_" + UUID.randomUUID().toString();
203 params.put("out.dir", outDir);
204 params.put("out.name", outFileName);
205 if (profile.getSuffix() != null) {
206 final String outSuffix = processParameters(profile.getSuffix(), params);
207 params.put("out.suffix", outSuffix);
208 }
209
210 for (String tag : profile.getTags()) {
211 final String suffix = processParameters(profile.getSuffix(tag), params);
212 params.put("out.suffix." + tag, suffix);
213 }
214
215
216 final List<String> command = buildCommand(profile, params);
217 logger.info("Executing encoding command: {}", command);
218
219 List<File> outFiles = new ArrayList<>();
220 BufferedReader in = null;
221 Process encoderProcess = null;
222 try {
223 ProcessBuilder processBuilder = new ProcessBuilder(command);
224 processBuilder.redirectErrorStream(REDIRECT_ERROR_STREAM);
225 encoderProcess = processBuilder.start();
226 processes.add(encoderProcess);
227
228
229 in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()));
230 String line;
231 while ((line = in.readLine()) != null) {
232 handleEncoderOutput(outFiles, line);
233 }
234
235
236 int exitCode = encoderProcess.waitFor();
237 if (exitCode != 0) {
238 throw new EncoderException("Encoder exited abnormally with status " + exitCode);
239 }
240
241 logger.info("Tracks {} successfully encoded using profile '{}'", source, profile.getIdentifier());
242 return outFiles;
243 } catch (Exception e) {
244 logger.warn("Error while encoding {} using profile '{}'",
245 source, profile.getIdentifier(), e);
246
247
248 for (File outFile : outFiles) {
249 if (FileUtils.deleteQuietly(outFile)) {
250 logger.debug("Removed output file of failed encoding process: {}", outFile);
251 }
252 }
253 throw new EncoderException(e);
254 } finally {
255 IoSupport.closeQuietly(in);
256 IoSupport.closeQuietly(encoderProcess);
257 }
258 }
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277 protected List<File> process(List<String> commandopts) throws EncoderException {
278 logger.trace("Process raw command - {}", commandopts);
279
280
281 Process encoderProcess = null;
282 BufferedReader in = null;
283 List<File> outFiles = new ArrayList<>();
284 try {
285 List<String> command = new ArrayList<>();
286 command.add(binary);
287 command.addAll(commandopts);
288 logger.info("Executing encoding command: {}", StringUtils.join(command, " "));
289
290 ProcessBuilder pbuilder = new ProcessBuilder(command);
291 pbuilder.redirectErrorStream(REDIRECT_ERROR_STREAM);
292 encoderProcess = pbuilder.start();
293
294 in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()));
295 String line;
296 while ((line = in.readLine()) != null) {
297 handleEncoderOutput(outFiles, line);
298 }
299
300 encoderProcess.waitFor();
301 int exitCode = encoderProcess.exitValue();
302 if (exitCode != 0) {
303 throw new EncoderException("Encoder exited abnormally with status " + exitCode);
304 }
305 logger.info("Video track successfully encoded '{}'",
306 new Object[] { StringUtils.join(commandopts, " ") });
307 return outFiles;
308 } catch (Exception e) {
309 logger.warn("Error while encoding video tracks using '{}': {}",
310 new Object[] { StringUtils.join(commandopts, " "), e.getMessage() });
311
312 for (File outFile : outFiles) {
313 if (FileUtils.deleteQuietly(outFile)) {
314 logger.debug("Removed output file of failed encoding process: {}", outFile);
315 }
316 }
317 throw new EncoderException(e);
318 } finally {
319 IoSupport.closeQuietly(in);
320 IoSupport.closeQuietly(encoderProcess);
321 }
322 }
323
324
325
326
327
328
329
330 private void cleanup(List<File> outputFiles) {
331 for (File file : outputFiles) {
332 if (file != null && file.isFile()) {
333 String path = file.getAbsolutePath();
334 if (file.delete()) {
335 logger.info("Deleted file {}", path);
336 } else {
337 logger.warn("Could not delete file {}", path);
338 }
339 }
340 }
341 }
342
343
344
345
346
347
348
349
350 private List<String> buildCommand(final EncodingProfile profile, final Map<String, String> argumentReplacements)
351 throws EncoderException {
352 List<String> command = new ArrayList<>();
353 command.add(binary);
354 command.add("-nostdin");
355 command.add("-nostats");
356
357 String commandline = profile.getExtension(CMD_SUFFIX);
358
359
360
361
362
363 for (String key: argumentReplacements.keySet()) {
364 if (key.startsWith(CMD_SUFFIX + '.')) {
365 final String shortKey = key.substring(CMD_SUFFIX.length() + 1);
366 commandline = commandline.replace("#{" + shortKey + "}", argumentReplacements.get(key));
367 }
368 }
369
370 String processedCommandLine = processParameters(commandline, argumentReplacements);
371 try {
372 command.addAll(Arrays.asList(CommandLineUtils.translateCommandline(processedCommandLine)));
373 } catch (Exception e) {
374 throw new EncoderException("Could not process encoding profile command line", e);
375 }
376 return command;
377 }
378
379
380
381
382
383
384
385 File trim(File mediaSource, EncodingProfile format, long start, long duration, Map<String, String> properties) throws EncoderException {
386 if (properties == null)
387 properties = new HashMap<>();
388 double startD = (double) start / 1000;
389 double durationD = (double) duration / 1000;
390 DecimalFormatSymbols ffmpegFormat = new DecimalFormatSymbols();
391 ffmpegFormat.setDecimalSeparator('.');
392 DecimalFormat df = new DecimalFormat("00.00000", ffmpegFormat);
393 properties.put(PROP_TRIMMING_START_TIME, df.format(startD));
394 properties.put(PROP_TRIMMING_DURATION, df.format(durationD));
395 return encode(mediaSource, format, properties);
396 }
397
398
399
400
401
402
403 private String processParameters(String cmd, final Map<String, String> args) {
404
405 String cmdBefore = null;
406 while (!cmd.equals(cmdBefore)) {
407 cmdBefore = cmd;
408 for (Map.Entry<String, String> e : args.entrySet()) {
409 cmd = cmd.replace("#{" + e.getKey() + "}", e.getValue());
410 }
411 }
412
413
414 cmd = cmd.replace("#{space}", " ");
415
416
417 return cmd.replaceAll("#\\{.*?\\}", "");
418 }
419
420 @Override
421 public void close() {
422 for (Process process: processes) {
423 if (process.isAlive()) {
424 logger.debug("Destroying encoding process {}", process);
425 process.destroy();
426 }
427 }
428 }
429
430
431
432
433
434
435
436
437 private void handleEncoderOutput(List<File> output, String message) {
438 message = message.trim();
439 if ("".equals(message))
440 return;
441
442
443 if (StringUtils.startsWithAny(message.toLowerCase(),
444 "ffmpeg version", "configuration", "lib", "size=", "frame=", "built with")) {
445 logger.trace(message);
446
447
448 } else if (StringUtils.startsWith(message, "Output #")) {
449 logger.debug(message);
450 Matcher matcher = outputPattern.matcher(message);
451 if (matcher.find()) {
452 String type = matcher.group(1);
453 String outputPath = matcher.group(2);
454 if (!StringUtils.equals("NUL", outputPath) && !StringUtils.equals("/dev/null", outputPath)
455 && !StringUtils.equals("/dev/null", outputPath)
456 && !StringUtils.startsWith("pipe:", outputPath)) {
457 File outputFile = new File(outputPath);
458 if (!type.startsWith("hls")) {
459 logger.info("Identified output file {}", outputFile);
460 output.add(outputFile);
461 }
462 }
463 }
464 } else if (StringUtils.startsWith(message, "[hls @ ")) {
465 logger.debug(message);
466 Matcher matcher = outputPatternHLS.matcher(message);
467 if (matcher.find()) {
468 final String outputPath = Objects.toString(matcher.group(1), matcher.group(2));
469 if (!StringUtils.equals("NUL", outputPath) && !StringUtils.equals("/dev/null", outputPath)
470 && !StringUtils.startsWith("pipe:", outputPath)) {
471 File outputFile = new File(outputPath);
472
473
474 if (!output.contains(outputFile)) {
475 logger.info("Identified HLS output file {}", outputFile);
476 output.add(outputFile);
477 }
478 }
479 }
480
481
482 } else if (StringUtils.startsWithAny(message.toLowerCase(),
483 "artist", "compatible_brands", "copyright", "creation_time", "description", "composer", "date", "duration",
484 "encoder", "handler_name", "input #", "last message repeated", "major_brand", "metadata", "minor_version",
485 "output #", "program", "side data:", "stream #", "stream mapping", "title", "video:", "[libx264 @ ", "Press [")) {
486 logger.debug(message);
487
488
489 } else {
490 logger.info(message);
491 }
492 }
493
494
495
496
497
498
499
500
501 public List<String> commandSplit(String str) {
502 ArrayList<String> al = new ArrayList<String>();
503 final Pattern regex = Pattern.compile("\"([^\"]*)\"|\'([^\']*)\'|\\S+");
504 Matcher m = regex.matcher(str);
505 while (m.find()) {
506 if (m.group(1) != null) {
507
508 al.add(m.group(1));
509 } else if (m.group(2) != null) {
510
511 al.add(m.group(2));
512 } else {
513
514 al.add(m.group());
515 }
516 }
517 return (al);
518 }
519
520
521
522
523
524
525
526
527
528
529 public String joinNonNullString(String[] srlist, String separator) {
530 StringBuffer sb = new StringBuffer();
531 for (int i = 0; i < srlist.length; i++) {
532 if (srlist[i] == null || srlist[i].isEmpty())
533 continue;
534 else {
535 if (sb.length() > 0)
536 sb.append(separator);
537 sb.append(srlist[i]);
538 }
539 }
540 return sb.toString();
541 }
542
543
544
545
546
547 protected class OutputAggregate {
548 private final List<EncodingProfile> pf;
549 private final ArrayList<String> outputs = new ArrayList<>();
550 private final ArrayList<String> outputFiles = new ArrayList<>();
551 private final ArrayList<String> outputSuffixes = new ArrayList<>();
552 private boolean hasAdaptiveProfile = false;
553 private final ArrayList<String> vpads;
554 private final ArrayList<String> apads;
555 private final ArrayList<String> vfilter;
556 private final ArrayList<String> afilter;
557 private String vInputPad = "";
558 private String aInputPad = "";
559 private String vsplit = "";
560 private String asplit = "";
561 private final ArrayList<String> vstream;
562 private final ArrayList<String> astream;
563
564 public OutputAggregate(List<EncodingProfile> profiles,
565 Map<String, String> params, String vInputPad, String aInputPad) throws EncoderException {
566 ArrayList<EncodingProfile> deliveryProfiles = new ArrayList<EncodingProfile>(profiles.size());
567 EncodingProfile groupProfile = null;
568 for (EncodingProfile ep: profiles) {
569 String adaptiveType = ep.getExtension(ADAPTIVE_TYPE_SUFFIX);
570 if (adaptiveType == null) {
571 deliveryProfiles.add(ep);
572 } else {
573 if ("HLS".equalsIgnoreCase(adaptiveType)) {
574 groupProfile = ep;
575 hasAdaptiveProfile = true;
576 }
577 else
578 throw new EncoderException("Only HLS is supported" + ep.getIdentifier() + " ffmpeg command");
579 }
580 }
581 this.pf = deliveryProfiles;
582 int size = this.pf.size();
583
584 if (vInputPad == null && aInputPad == null)
585 throw new EncoderException("At least one of video or audio input must be specified");
586
587 vfilter = new ArrayList<>(Collections.nCopies(size, null));
588 afilter = new ArrayList<>(Collections.nCopies(size, null));
589
590 apads = new ArrayList<>(Collections.nCopies(size, null));
591 vpads = new ArrayList<>(Collections.nCopies(size, null));
592
593 vstream = new ArrayList<>(Collections.nCopies(size, null));
594 astream = new ArrayList<>(Collections.nCopies(size, null));
595
596 vsplit = (size > 1) ? (vInputPad + "split=" + size) : null;
597 asplit = (size > 1) ? (aInputPad + "asplit=" + size) : null;
598 this.vInputPad = vInputPad;
599 this.aInputPad = aInputPad;
600 if (groupProfile != null)
601 outputAggregateReal(deliveryProfiles, groupProfile, params, vInputPad, aInputPad);
602 else
603 outputAggregateReal(deliveryProfiles, params, vInputPad, aInputPad);
604 }
605
606
607
608
609
610 private void setAudioFilters() {
611 if (pf.size() == 1) {
612 if (afilter.get(0) != null)
613 afilter.set(0, aInputPad + afilter.get(0) + apads.get(0));
614 astream.set(0, apads.get(0));
615 } else
616 for (int i = 0; i < pf.size(); i++) {
617 if (afilter.get(i) != null) {
618 afilter.set(i, "[oa0" + i + "]" + afilter.get(i) + apads.get(i));
619 asplit += "[oa0" + i + "]";
620 astream.set(i, "[oa0" + i + "]");
621 } else {
622 asplit += apads.get(i);
623 astream.set(i, apads.get(i));
624 }
625 }
626 afilter.removeAll(Arrays.asList((String) null));
627 }
628
629
630
631
632 private void setVideoFilters() {
633 if (pf.size() == 1) {
634 if (vfilter.get(0) != null)
635 vfilter.set(0, vInputPad + vfilter.get(0) + vpads.get(0));
636 vstream.set(0, vpads.get(0));
637 } else
638 for (int i = 0; i < pf.size(); i++) {
639 if (vfilter.get(i) != null) {
640 vfilter.set(i, "[ov0" + i + "]" + vfilter.get(i) + vpads.get(i));
641 vsplit += "[ov0" + i + "]";
642 vstream.set(i, "[ov0" + i + "]");
643 } else {
644 vsplit += vpads.get(i);
645 vstream.set(i, vpads.get(i));
646 }
647 }
648
649 vfilter.removeAll(Arrays.asList((String) null));
650 }
651
652 public List<String> getOutFiles() {
653 return outputFiles;
654 }
655
656
657
658
659
660 public List<String> getOutput() {
661 return outputs;
662 }
663
664
665
666
667
668
669 public List<String> getSegmentOutputSuffixes() {
670 return outputSuffixes;
671 }
672
673
674
675
676
677
678 public boolean hasAdaptivePlaylist() {
679 return hasAdaptiveProfile;
680 }
681
682
683
684
685
686 public String getVsplit() {
687 return vsplit;
688 }
689
690 public String getAsplit() {
691 return asplit;
692 }
693
694 public String getVideoFilter() {
695 if (vfilter.isEmpty())
696 return null;
697 return StringUtils.join(vfilter, ";");
698 }
699
700 public String getAudioFilter() {
701 if (afilter.isEmpty())
702 return null;
703 return StringUtils.join(afilter, ";");
704 }
705
706
707
708
709
710
711
712
713 public String adjustForNoComplexFilter(String pad) {
714 final Pattern outpad = Pattern.compile("\\[(\\d+:[av\\d{1,2}])\\]");
715 try {
716 Matcher matcher = outpad.matcher(pad);
717 if (matcher.matches()) {
718 return matcher.group(1);
719 }
720 } catch (Exception e) {
721 }
722 return pad;
723 }
724
725
726
727
728
729
730
731
732
733
734 protected String processParameters(String cmd, Map<String, String> params) {
735 String r = cmd;
736 for (Map.Entry<String, String> e : params.entrySet()) {
737 r = r.replace("#{" + e.getKey() + "}", e.getValue());
738 }
739 return r;
740 }
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761 public void outputAggregateReal(List<EncodingProfile> profiles, EncodingProfile groupProfile,
762 Map<String, String> params, String vInputPad, String aInputPad) throws EncoderException {
763 int size = profiles.size();
764
765
766 try {
767 String outSuffix = processParameters(groupProfile.getSuffix(), params);
768 params.put("out.suffix", outSuffix);
769 } catch (Exception e) {
770 throw new EncoderException("Missing Encoding Profiles");
771 }
772 String ffmpgGCmd = groupProfile.getExtension(CMD_SUFFIX);
773
774 if (ffmpgGCmd == null)
775 throw new EncoderException("Missing ffmpeg Encoding Profile " + groupProfile.getIdentifier() + " ffmpeg command");
776 for (Map.Entry<String, String> e : params.entrySet()) {
777 ffmpgGCmd = ffmpgGCmd.replace("#{" + e.getKey() + "}", e.getValue());
778 }
779 ffmpgGCmd = ffmpgGCmd.replace("#{space}", " ");
780 int indx = 0;
781
782 for (EncodingProfile profile : profiles) {
783 String cmd = "";
784
785 outputSuffixes.add(processParameters(profile.getSuffix(), params));
786 String ffmpgCmd = profile.getExtension(CMD_SUFFIX);
787 if (ffmpgCmd == null)
788 throw new EncoderException("Missing Encoding Profile " + profile.getIdentifier() + " ffmpeg command");
789
790 params.remove("out.dir");
791 params.remove("out.name");
792 params.remove("out.suffix");
793 for (Map.Entry<String, String> e : params.entrySet()) {
794 ffmpgCmd = ffmpgCmd.replace("#{" + e.getKey() + "}", e.getValue());
795 }
796 ffmpgCmd = ffmpgCmd.replace("#{space}", " ");
797 List<String> cmdToken;
798 try {
799 cmdToken = commandSplit(ffmpgCmd);
800 } catch (Exception e) {
801 throw new EncoderException("Could not parse encoding profile command line", e);
802 }
803
804 for (int i = 0; i < cmdToken.size(); i++) {
805 if (cmdToken.get(i).contains("#{out.name}")) {
806 if (i == cmdToken.size() - 1) {
807 cmdToken = cmdToken.subList(0, i);
808 break;
809 } else {
810 List<String> copy = cmdToken.subList(0, i - 1);
811 copy.addAll(cmdToken.subList(i + 1, cmdToken.size() - 1));
812 cmdToken = copy;
813 }
814 }
815 }
816
817 int i = 0;
818 String maxrate = null;
819 while (i < cmdToken.size()) {
820 String opt = cmdToken.get(i);
821 if (opt.startsWith("-vf") || opt.startsWith("-filter:v")) {
822 vfilter.set(indx, cmdToken.get(i + 1).replace("\"", ""));
823 i++;
824 } else if (opt.startsWith("-filter_complex") || opt.startsWith("-lavfi")) {
825
826 i++;
827 logger.error("Command does not support complex filters - only simple -af or -vf filters are supported");
828 throw new EncoderException(
829 "Cannot parse complex filters in" + profile.getIdentifier() + " for this operation");
830 } else if (opt.startsWith("-af") || opt.startsWith("-filter:a")) {
831 afilter.set(indx, cmdToken.get(i + 1).replace("\"", ""));
832 i++;
833 } else if ("-i".equals(opt)) {
834 i++;
835 } else if (opt.startsWith("-c:") || opt.startsWith("-codec:") || opt.contains("-vcodec")
836 || opt.contains("-acodec")) {
837 String str = cmdToken.get(i + 1);
838 if (str.contains("copy"))
839 i++;
840 else if (opt.startsWith("-codec:") || opt.contains("-vcodec")) {
841 cmd = cmd + " " + adjustABRVMaps("-c:v", indx);
842 }
843 else if (opt.startsWith("-acodec:"))
844 cmd = cmd + " " + adjustABRVMaps("-c:a", indx);
845 else
846 cmd = cmd + " " + adjustABRVMaps(opt, indx);
847 } else {
848 cmd = cmd + " " + adjustABRVMaps(opt, indx);
849 }
850 i++;
851 }
852
853
854 cmd = cmd.replaceAll("#\\{.*?\\}", "");
855
856 if (size == 1) {
857 if (afilter.get(indx) == null)
858 apads.set(indx, adjustForNoComplexFilter(aInputPad));
859 else
860 apads.set(indx, "[oa" + indx + "]");
861 if (vfilter.get(indx) == null)
862 vpads.set(indx, adjustForNoComplexFilter(vInputPad));
863 else
864 vpads.set(indx, "[ov" + indx + "]");
865
866 } else {
867 vpads.set(indx, "[ov" + indx + "]");
868 apads.set(indx, "[oa" + indx + "]");
869 }
870 cmd = StringUtils.trimToNull(cmd);
871 if (cmd != null) {
872
873
874 if (vInputPad != null) {
875 outputs.add("-map " + vpads.get(indx));
876 }
877 if (aInputPad != null) {
878 outputs.add("-map " + apads.get(indx));
879 }
880 outputs.add(cmd);
881 indx++;
882 }
883 }
884 setVideoFilters();
885 setAudioFilters();
886 setHLSVarStreamMap(ffmpgGCmd, vInputPad != null, aInputPad != null);
887 }
888
889
890
891
892
893
894
895
896
897
898
899 private void setHLSVarStreamMap(String ffmpgCmd, boolean hasVideo, boolean hasAudio) {
900 StringBuilder varStreamMap = new StringBuilder();
901 varStreamMap.append(" -var_stream_map '");
902
903 for (int i = 0; i < pf.size(); i++) {
904 int j = 0;
905 String[] maps = new String[2];
906 if (hasVideo && vstream.get(i) != null) {
907 maps[j] = "v:" + i;
908 ++j;
909 }
910 if (hasAudio && astream.get(i) != null) {
911 maps[j] = "a:" + i;
912 }
913
914 varStreamMap.append(joinNonNullString(maps, ","));
915 varStreamMap.append(" ");
916 }
917
918 varStreamMap.append("' ");
919 varStreamMap.append(ffmpgCmd);
920 varStreamMap.append(" ");
921 outputs.add(varStreamMap.toString());
922 }
923
924
925
926
927
928
929
930
931
932
933
934 public String adjustABRVMaps(String option, int position) {
935 if (option.endsWith(":v") || option.endsWith(":a")) {
936 return option + ":" + Integer.toString(position);
937 } else if (mappableOptions.contains(option)) {
938 return option + ":v:" + Integer.toString(position);
939 } else
940 return option;
941 }
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959 public void outputAggregateReal(List<EncodingProfile> profiles, Map<String, String> params,
960 String vInputPad, String aInputPad) throws EncoderException {
961
962 int size = profiles.size();
963 int indx = 0;
964 for (EncodingProfile profile : profiles) {
965 String cmd = "";
966 String outSuffix;
967
968 String outFileName = params.get("out.name.base") + "_" + IdImpl.fromUUID().toString();
969 params.put("out.name", outFileName);
970 try {
971 outSuffix = processParameters(profile.getSuffix(), params);
972 params.put("out.suffix", outSuffix);
973 } catch (Exception e) {
974 throw new EncoderException("Missing Encoding Profiles");
975 }
976
977 String ffmpgCmd = profile.getExtension(CMD_SUFFIX);
978 if (ffmpgCmd == null)
979 throw new EncoderException("Missing Encoding Profile " + profile.getIdentifier() + " ffmpeg command");
980 for (Map.Entry<String, String> e : params.entrySet()) {
981 ffmpgCmd = ffmpgCmd.replace("#{" + e.getKey() + "}", e.getValue());
982 }
983 ffmpgCmd = ffmpgCmd.replace("#{space}", " ");
984 String[] arguments;
985 try {
986 arguments = CommandLineUtils.translateCommandline(ffmpgCmd);
987 } catch (Exception e) {
988 throw new EncoderException("Could not parse encoding profile command line", e);
989 }
990 List<String> cmdToken = Arrays.asList(arguments);
991
992 int i = 0;
993 while (i < cmdToken.size()) {
994 String opt = cmdToken.get(i);
995 if (opt.startsWith("-vf") || opt.startsWith("-filter:v")) {
996 vfilter.set(indx, cmdToken.get(i + 1).replace("\"", ""));
997 i++;
998 } else if (opt.startsWith("-filter_complex") || opt.startsWith("-lavfi")) {
999
1000 i++;
1001 logger.error("Command does not support complex filters - only simple -af or -vf filters are supported");
1002 throw new EncoderException(
1003 "Cannot parse complex filters in" + profile.getIdentifier() + " for this operation");
1004 } else if (opt.startsWith("-af") || opt.startsWith("-filter:a")) {
1005 afilter.set(indx, cmdToken.get(i + 1).replace("\"", ""));
1006 i++;
1007 } else if ("-i".equals(opt)) {
1008 i++;
1009 } else if (opt.startsWith("-c:") || opt.startsWith("-codec:") || opt.contains("-vcodec")
1010 || opt.contains("-acodec")) {
1011 String str = cmdToken.get(i + 1);
1012 if (str.contains("copy"))
1013 i++;
1014 else
1015 cmd = cmd + " " + opt;
1016 } else {
1017 cmd = cmd + " " + opt;
1018 }
1019 i++;
1020 }
1021
1022 cmd = cmd.replaceAll("#\\{.*?\\}", "");
1023
1024 if (size == 1) {
1025 if (afilter.get(indx) == null)
1026 apads.set(indx, adjustForNoComplexFilter(aInputPad));
1027 else
1028 apads.set(indx, "[oa" + indx + "]");
1029 if (vfilter.get(indx) == null)
1030 vpads.set(indx, adjustForNoComplexFilter(vInputPad));
1031 else
1032 vpads.set(indx, "[ov" + indx + "]");
1033
1034 } else {
1035 vpads.set(indx, "[ov" + indx + "]");
1036 apads.set(indx, "[oa" + indx + "]");
1037 }
1038 cmd = StringUtils.trimToNull(cmd);
1039 if (cmd != null) {
1040 outputFiles.add(cmdToken.get(cmdToken.size() - 1));
1041 if (vInputPad != null) {
1042 outputs.add("-map " + vpads.get(indx));
1043 }
1044 if (aInputPad != null) {
1045 outputs.add("-map " + apads.get(indx));
1046 }
1047 outputs.add(cmd);
1048 indx++;
1049 }
1050 }
1051 setVideoFilters();
1052 setAudioFilters();
1053 }
1054 }
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066 private static List<VideoClip> sortSegments(List<VideoClip> edits, double gap) {
1067 LinkedList<VideoClip> ll = new LinkedList<VideoClip>();
1068 Iterator<VideoClip> it = edits.iterator();
1069 VideoClip clip;
1070 VideoClip nextclip;
1071 int lastSrc = -1;
1072 while (it.hasNext()) {
1073 clip = it.next();
1074 if (lastSrc < 0) {
1075 lastSrc = clip.getSrc();
1076 } else if (lastSrc != clip.getSrc()) {
1077 return edits;
1078 }
1079 }
1080 Collections.sort(edits);
1081 List<VideoClip> clips = new ArrayList<VideoClip>();
1082 it = edits.iterator();
1083 while (it.hasNext()) {
1084 clip = it.next();
1085 if (clip.getDuration() > gap) {
1086 ll.add(clip);
1087 }
1088 }
1089 clip = ll.pop();
1090
1091 while (!ll.isEmpty()) {
1092 if (ll.peek() != null) {
1093 nextclip = ll.pop();
1094 if ((nextclip.getSrc() == clip.getSrc()) && (nextclip.getStart() - clip.getEnd()) < gap) {
1095
1096 clip.setEnd(nextclip.getEnd());
1097 } else {
1098 clips.add(clip);
1099 clip = nextclip;
1100 }
1101 }
1102 }
1103 clips.add(clip);
1104 return clips;
1105 }
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123 private List<String> makeEdits(List<VideoClip> clips, int transitionDuration, Boolean hasVideo,
1124 Boolean hasAudio) throws Exception {
1125 double vfade = transitionDuration / 1000;
1126 double afade = vfade;
1127 DecimalFormatSymbols ffmpegFormat = new DecimalFormatSymbols();
1128 ffmpegFormat.setDecimalSeparator('.');
1129 DecimalFormat f = new DecimalFormat("0.00", ffmpegFormat);
1130 List<String> vpads = new ArrayList<>();
1131 List<String> apads = new ArrayList<>();
1132 List<String> clauses = new ArrayList<>();
1133 int n = 0;
1134 if (clips != null)
1135 n = clips.size();
1136 String outmap = "o";
1137 if (n > 1) {
1138 for (int i = 0; i < n; i++) {
1139 vpads.add("[v" + i + "]");
1140 apads.add("[a" + i + "]");
1141 }
1142 outmap = "";
1143
1144 for (int i = 0; i < n; i++) {
1145
1146 VideoClip vclip = clips.get(i);
1147 int fileindx = vclip.getSrc();
1148 double inpt = vclip.getStart();
1149 double duration = vclip.getDuration();
1150 double vend = Math.max(duration - vfade, 0);
1151 double aend = Math.max(duration - afade, 0);
1152 if (hasVideo) {
1153 String vvclip;
1154 vvclip = "[" + fileindx + ":v]trim=" + f.format(inpt) + ":duration=" + f.format(duration)
1155 + ",setpts=PTS-STARTPTS"
1156 + ((vfade > 0) ? ",fade=t=in:st=0:d=" + vfade + ",fade=t=out:st=" + f.format(vend) + ":d=" + vfade
1157 : "")
1158 + "[" + outmap + "v" + i + "]";
1159 clauses.add(vvclip);
1160 }
1161 if (hasAudio) {
1162 String aclip;
1163 aclip = "[" + fileindx + ":a]atrim=" + f.format(inpt) + ":duration=" + f.format(duration)
1164 + ",asetpts=PTS-STARTPTS"
1165 + ((afade > 0)
1166 ? ",afade=t=in:st=0:d=" + afade + ",afade=t=out:st=" + f.format(aend) + ":d=" + afade
1167 : "")
1168 + "[" + outmap + "a" + i + "]";
1169 clauses.add(aclip);
1170 }
1171 }
1172
1173 if (hasVideo)
1174 clauses.add(StringUtils.join(vpads, "") + "concat=n=" + n + ":unsafe=1[ov]");
1175 if (hasAudio)
1176 clauses.add(StringUtils.join(apads, "") + "concat=n=" + n + ":v=0:a=1[oa]");
1177 } else if (n == 1) {
1178 VideoClip vclip = clips.get(0);
1179 int fileindx = vclip.getSrc();
1180 double inpt = vclip.getStart();
1181 double duration = vclip.getDuration();
1182 double vend = Math.max(duration - vfade, 0);
1183 double aend = Math.max(duration - afade, 0);
1184
1185 if (hasVideo) {
1186 String vvclip;
1187
1188 vvclip = "[" + fileindx + ":v]trim=" + f.format(inpt) + ":duration=" + f.format(duration)
1189 + ",setpts=PTS-STARTPTS"
1190 + ((vfade > 0) ? ",fade=t=in:st=0:d=" + vfade + ",fade=t=out:st=" + f.format(vend) + ":d=" + vfade : "")
1191 + "[ov]";
1192
1193 clauses.add(vvclip);
1194 }
1195 if (hasAudio) {
1196 String aclip;
1197 aclip = "[" + fileindx + ":a]atrim=" + f.format(inpt) + ":duration=" + f.format(duration)
1198 + ",asetpts=PTS-STARTPTS"
1199 + ((afade > 0) ? ",afade=t=in:st=0:d=" + afade + ",afade=t=out:st=" + f.format(aend) + ":d=" + afade
1200 : "")
1201 + "[oa]";
1202
1203 clauses.add(aclip);
1204 }
1205 }
1206 return clauses;
1207 }
1208
1209 private Map<String, String> getParamsFromFile(File parentFile) {
1210 Map<String, String> params = new HashMap<>();
1211 String videoInput = FilenameUtils.normalize(parentFile.getAbsolutePath());
1212 params.put("in.video.path", videoInput);
1213 params.put("in.video.name", FilenameUtils.getBaseName(videoInput));
1214 params.put("in.name", FilenameUtils.getBaseName(videoInput));
1215 params.put("in.video.suffix", FilenameUtils.getExtension(videoInput));
1216 params.put("in.video.filename", FilenameUtils.getName(videoInput));
1217 params.put("in.video.mimetype", MimetypesFileTypeMap.getDefaultFileTypeMap().getContentType(videoInput));
1218 String outDir = parentFile.getAbsoluteFile().getParent();
1219 params.put("out.dir", outDir);
1220 String outFileName = FilenameUtils.getBaseName(parentFile.getName());
1221 params.put("out.name.base", outFileName);
1222 params.put("out.name", outFileName);
1223 return params;
1224 }
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243 public List<File> multiTrimConcat(List<File> inputs, List<Long> edits, List<EncodingProfile> profiles,
1244 int transitionDuration) throws EncoderException {
1245 return multiTrimConcat(inputs, edits, profiles, transitionDuration, true, true);
1246
1247 }
1248
1249 public List<File> multiTrimConcat(List<File> inputs, List<Long> edits, List<EncodingProfile> profiles,
1250 int transitionDuration, boolean hasVideo, boolean hasAudio)
1251 throws EncoderException, IllegalArgumentException {
1252 if (inputs == null || inputs.size() < 1) {
1253 throw new IllegalArgumentException("At least one track must be specified.");
1254 }
1255 if (edits == null && inputs.size() > 1) {
1256 throw new IllegalArgumentException("If there is no editing, only one track can be specified.");
1257 }
1258 List<VideoClip> clips = null;
1259 if (edits != null) {
1260 clips = new ArrayList<VideoClip>(edits.size() / 3);
1261 int adjust = 0;
1262
1263
1264 for (int i = 0; i < edits.size(); i += 3) {
1265 if (edits.get(i + 1) < transitionDuration)
1266 adjust = transitionDuration / 2000;
1267 else
1268 adjust = 0;
1269 clips.add(new VideoClip(edits.get(i).intValue(), (double) edits.get(i + 1) / 1000 + adjust,
1270 (double) edits.get(i + 2) / 1000));
1271 }
1272 try {
1273 clips = sortSegments(clips, transitionDuration / 1000);
1274 } catch (Exception e) {
1275 logger.error("Illegal edits, cannot sort segment", e);
1276 throw new EncoderException("Cannot understand the edit points", e);
1277 }
1278 }
1279
1280 Map<String, String> params = null;
1281 if (inputs.size() > 0) {
1282 params = getParamsFromFile(inputs.get(0));
1283 }
1284 if (profiles == null || profiles.size() == 0) {
1285 logger.error("Missing encoding profiles");
1286 throw new EncoderException("Missing encoding profile(s)");
1287 }
1288 try {
1289 List<String> command = new ArrayList<>();
1290 List<String> clauses = makeEdits(clips, transitionDuration, hasVideo, hasAudio);
1291
1292
1293 String videoOut = (clips == null) ? "[0:v]" : "[ov]";
1294 String audioOut = (clips == null) ? "[0:a]" : "[oa]";
1295 OutputAggregate outmaps = new OutputAggregate(profiles, params, (hasVideo ? videoOut : null),
1296 (hasAudio ? audioOut : null));
1297 if (hasAudio) {
1298 clauses.add(outmaps.getAsplit());
1299 clauses.add(outmaps.getAudioFilter());
1300 }
1301 if (hasVideo) {
1302 clauses.add(outmaps.getVsplit());
1303 clauses.add(outmaps.getVideoFilter());
1304 }
1305 clauses.removeIf(Objects::isNull);
1306 command.add("-nostats");
1307 command.add("-hide_banner");
1308 for (File o : inputs) {
1309 command.add("-i");
1310 command.add(o.getCanonicalPath());
1311 }
1312 if (!clauses.isEmpty()) {
1313 command.add("-filter_complex");
1314 command.add(StringUtils.join(clauses, ";"));
1315 }
1316 for (String outpad : outmaps.getOutput()) {
1317 command.addAll(commandSplit(outpad));
1318 }
1319 if (outmaps.hasAdaptivePlaylist()) {
1320 List<File> results = process(command);
1321
1322 List<File> segments = results.stream().filter(AdaptivePlaylist.isHLSFilePred.negate())
1323 .collect(Collectors.toList());
1324 segments.sort((File f1, File f2) -> f1.getName().compareTo(f2.getName()));
1325 List<String> suffixes = outmaps.getSegmentOutputSuffixes();
1326 HashMap<File, File> renames = new HashMap<File, File>();
1327 results.forEach((f) -> {
1328 renames.put(f, f);
1329 });
1330 for (int i = 0; i < segments.size(); i++) {
1331 File file = segments.get(i);
1332
1333 String newname = FilenameUtils.concat(file.getParent(),
1334 FilenameUtils.getBaseName(file.getName()) + suffixes.get(i));
1335 renames.put(file, new File(newname));
1336 }
1337
1338 return AdaptivePlaylist.hlsRenameAllFiles(results, renames);
1339 }
1340 return process(command);
1341 } catch (Exception e) {
1342 logger.error("MultiTrimConcat failed to run command {} ", e.getMessage());
1343 throw new EncoderException("Cannot encode the inputs",e);
1344 }
1345 }
1346
1347 }