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
185 if (source.isEmpty()) {
186 throw new IllegalArgumentException("At least one track must be specified.");
187 }
188
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
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
230 in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()));
231 String line;
232 while ((line = in.readLine()) != null) {
233 handleEncoderOutput(outFiles, line);
234 }
235
236
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
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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278 protected List<File> process(List<String> commandopts) throws EncoderException {
279 logger.trace("Process raw command - {}", commandopts);
280
281
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
295 in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()));
296 String line;
297 while ((line = in.readLine()) != null) {
298 handleEncoderOutput(outFiles, line);
299 }
300
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;
309 } catch (Exception e) {
310 logger.warn("Error while encoding video tracks using '{}': {}",
311 new Object[] { StringUtils.join(commandopts, " "), e.getMessage() });
312
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
327
328
329
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
346
347
348
349
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
361
362
363
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
382
383
384
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
403
404
405
406 private String processParameters(String cmd, final Map<String, String> args) {
407
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
417 cmd = cmd.replace("#{space}", " ");
418
419
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
435
436
437
438
439
440 private void handleEncoderOutput(List<File> output, String message) {
441 message = message.trim();
442 if ("".equals(message)) {
443 return;
444 }
445
446
447 if (StringUtils.startsWithAny(message.toLowerCase(),
448 "ffmpeg version", "configuration", "lib", "size=", "frame=", "built with")) {
449 logger.trace(message);
450
451
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
477
478 if (!output.contains(outputFile)) {
479 logger.info("Identified HLS output file {}", outputFile);
480 output.add(outputFile);
481 }
482 }
483 }
484
485
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
494 } else {
495 logger.info(message);
496 }
497 }
498
499
500
501
502
503
504
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
513 al.add(m.group(1));
514 } else if (m.group(2) != null) {
515
516 al.add(m.group(2));
517 } else {
518
519 al.add(m.group());
520 }
521 }
522 return (al);
523 }
524
525
526
527
528
529
530
531
532
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
551
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<>();
558 private boolean hasAdaptiveProfile = false;
559 private final ArrayList<String> vpads;
560 private final ArrayList<String> apads;
561 private final ArrayList<String> vfilter;
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;
568 private final ArrayList<String> astream;
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
594 vfilter = new ArrayList<>(Collections.nCopies(size, null));
595 afilter = new ArrayList<>(Collections.nCopies(size, null));
596
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;
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
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));
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));
628 asplit += "[oa0" + i + "]";
629 astream.set(i, "[oa0" + i + "]");
630 } else {
631 asplit += apads.get(i);
632 astream.set(i, apads.get(i));
633 }
634 }
635 }
636
637 afilter.removeAll(Arrays.asList((String) null));
638 }
639
640
641
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));
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));
653 vsplit += "[ov0" + i + "]";
654 vstream.set(i, "[ov0" + i + "]");
655 } else {
656 vsplit += vpads.get(i);
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
672
673 public List<String> getOutput() {
674 return outputs;
675 }
676
677
678
679
680
681
682 public List<String> getSegmentOutputSuffixes() {
683 return outputSuffixes;
684 }
685
686
687
688
689
690
691 public boolean hasAdaptivePlaylist() {
692 return hasAdaptiveProfile;
693 }
694
695
696
697
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
723
724
725
726
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);
732 if (matcher.matches()) {
733 return matcher.group(1);
734 }
735 } catch (Exception e) {
736 }
737 return pad;
738 }
739
740
741
742
743
744
745
746
747
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
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
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
781 try {
782 String outSuffix = processParameters(groupProfile.getSuffix(), params);
783 params.put("out.suffix", outSuffix);
784 } catch (Exception e) {
785 throw new EncoderException("Missing Encoding Profiles");
786 }
787 String ffmpgGCmd = groupProfile.getExtension(CMD_SUFFIX);
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()) {
794 ffmpgGCmd = ffmpgGCmd.replace("#{" + e.getKey() + "}", e.getValue());
795 }
796 ffmpgGCmd = ffmpgGCmd.replace("#{space}", " ");
797 int indx = 0;
798
799 for (EncodingProfile profile : profiles) {
800 String cmd = "";
801
802 outputSuffixes.add(processParameters(profile.getSuffix(), params));
803 String ffmpgCmd = profile.getExtension(CMD_SUFFIX);
804 if (ffmpgCmd == null) {
805 throw new EncoderException("Missing Encoding Profile " + profile.getIdentifier() + " ffmpeg command");
806 }
807
808 params.remove("out.dir");
809 params.remove("out.name");
810 params.remove("out.suffix");
811 for (Map.Entry<String, String> e : params.entrySet()) {
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
822 for (int i = 0; i < cmdToken.size(); i++) {
823 if (cmdToken.get(i).contains("#{out.name}")) {
824 if (i == cmdToken.size() - 1) {
825 cmdToken = cmdToken.subList(0, i);
826 break;
827 } else {
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
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")) {
840 vfilter.set(indx, cmdToken.get(i + 1).replace("\"", ""));
841 i++;
842 } else if (opt.startsWith("-filter_complex") || opt.startsWith("-lavfi")) {
843
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")) {
849 afilter.set(indx, cmdToken.get(i + 1).replace("\"", ""));
850 i++;
851 } else if ("-i".equals(opt)) {
852 i++;
853 } else if (opt.startsWith("-c:") || opt.startsWith("-codec:") || opt.contains("-vcodec")
854 || opt.contains("-acodec")) {
855 String str = cmdToken.get(i + 1);
856 if (str.contains("copy")) {
857 i++;
858 } else if (opt.startsWith("-codec:") || opt.contains("-vcodec")) {
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 {
866 cmd = cmd + " " + adjustABRVMaps(opt, indx);
867 }
868 i++;
869 }
870
871
872 cmd = cmd.replaceAll("#\\{.*?\\}", "");
873
874 if (size == 1) {
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));
882 } else {
883 vpads.set(indx, "[ov" + indx + "]");
884 }
885
886 } else {
887 vpads.set(indx, "[ov" + indx + "]");
888 apads.set(indx, "[oa" + indx + "]");
889 }
890 cmd = StringUtils.trimToNull(cmd);
891 if (cmd != null) {
892
893
894 if (vInputPad != null) {
895 outputs.add("-map " + vpads.get(indx));
896 }
897 if (aInputPad != null) {
898 outputs.add("-map " + apads.get(indx));
899 }
900 outputs.add(cmd);
901 indx++;
902 }
903 }
904 setVideoFilters();
905 setAudioFilters();
906 setHLSVarStreamMap(ffmpgGCmd, vInputPad != null, aInputPad != null);
907 }
908
909
910
911
912
913
914
915
916
917
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) {
927 maps[j] = "v:" + i;
928 ++j;
929 }
930 if (hasAudio && astream.get(i) != null) {
931 maps[j] = "a:" + i;
932 }
933
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());
942 }
943
944
945
946
947
948
949
950
951
952
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
967
968
969
970
971
972
973
974
975
976
977
978
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;
985 for (EncodingProfile profile : profiles) {
986 String cmd = "";
987 String outSuffix;
988
989 String outFileName = params.get("out.name.base") + "_" + IdImpl.fromUUID().toString();
990 params.put("out.name", outFileName);
991 try {
992 outSuffix = processParameters(profile.getSuffix(), params);
993 params.put("out.suffix", outSuffix);
994 } catch (Exception e) {
995 throw new EncoderException("Missing Encoding Profiles");
996 }
997
998 String ffmpgCmd = profile.getExtension(CMD_SUFFIX);
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()) {
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
1014 int i = 0;
1015 while (i < cmdToken.size()) {
1016 String opt = cmdToken.get(i);
1017 if (opt.startsWith("-vf") || opt.startsWith("-filter:v")) {
1018 vfilter.set(indx, cmdToken.get(i + 1).replace("\"", ""));
1019 i++;
1020 } else if (opt.startsWith("-filter_complex") || opt.startsWith("-lavfi")) {
1021
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")) {
1027 afilter.set(indx, cmdToken.get(i + 1).replace("\"", ""));
1028 i++;
1029 } else if ("-i".equals(opt)) {
1030 i++;
1031 } else if (opt.startsWith("-c:") || opt.startsWith("-codec:") || opt.contains("-vcodec")
1032 || opt.contains("-acodec")) {
1033 String str = cmdToken.get(i + 1);
1034 if (str.contains("copy")) {
1035 i++;
1036 } else {
1037 cmd = cmd + " " + opt;
1038 }
1039 } else {
1040 cmd = cmd + " " + opt;
1041 }
1042 i++;
1043 }
1044
1045 cmd = cmd.replaceAll("#\\{.*?\\}", "");
1046
1047 if (size == 1) {
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));
1055 } else {
1056 vpads.set(indx, "[ov" + indx + "]");
1057 }
1058
1059 } else {
1060 vpads.set(indx, "[ov" + indx + "]");
1061 apads.set(indx, "[oa" + indx + "]");
1062 }
1063 cmd = StringUtils.trimToNull(cmd);
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));
1071 }
1072 outputs.add(cmd);
1073 indx++;
1074 }
1075 }
1076 setVideoFilters();
1077 setAudioFilters();
1078 }
1079 }
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
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()) {
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);
1106 List<VideoClip> clips = new ArrayList<VideoClip>();
1107 it = edits.iterator();
1108 while (it.hasNext()) {
1109 clip = it.next();
1110 if (clip.getDuration() > gap) {
1111 ll.add(clip);
1112 }
1113 }
1114 clip = ll.pop();
1115
1116 while (!ll.isEmpty()) {
1117 if (ll.peek() != null) {
1118 nextclip = ll.pop();
1119 if ((nextclip.getSrc() == clip.getSrc()) && (nextclip.getStart() - clip.getEnd()) < gap) {
1120
1121 clip.setEnd(nextclip.getEnd());
1122 } else {
1123 clips.add(clip);
1124 clip = nextclip;
1125 }
1126 }
1127 }
1128 clips.add(clip);
1129 return clips;
1130 }
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148 private List<String> makeEdits(List<VideoClip> clips, int transitionDuration, Boolean hasVideo,
1149 Boolean hasAudio) throws Exception {
1150 double vfade = transitionDuration / 1000;
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<>();
1158 int n = 0;
1159 if (clips != null) {
1160 n = clips.size();
1161 }
1162 String outmap = "o";
1163 if (n > 1) {
1164 for (int i = 0; i < n; i++) {
1165 vpads.add("[v" + i + "]");
1166 apads.add("[a" + i + "]");
1167 }
1168 outmap = "";
1169
1170 for (int i = 0; i < n; i++) {
1171
1172 VideoClip vclip = clips.get(i);
1173 int fileindx = vclip.getSrc();
1174 double inpt = vclip.getStart();
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
1199 if (hasVideo) {
1200 clauses.add(StringUtils.join(vpads, "") + "concat=n=" + n + ":unsafe=1[ov]");
1201 }
1202 if (hasAudio) {
1203 clauses.add(StringUtils.join(apads, "") + "concat=n=" + n + ":v=0:a=1[oa]");
1204 }
1205 } else if (n == 1) {
1206 VideoClip vclip = clips.get(0);
1207 int fileindx = vclip.getSrc();
1208 double inpt = vclip.getStart();
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;
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));
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();
1247 params.put("out.dir", outDir);
1248 String outFileName = FilenameUtils.getBaseName(parentFile.getName());
1249 params.put("out.name.base", outFileName);
1250 params.put("out.name", outFileName);
1251 return params;
1252 }
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
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
1292
1293 for (int i = 0; i < edits.size(); i += 3) {
1294 if (edits.get(i + 1) < transitionDuration) {
1295 adjust = transitionDuration / 2000;
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);
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
1310 Map<String, String> params = null;
1311 if (inputs.size() > 0) {
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);
1321
1322
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));
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);
1336 command.add("-nostats");
1337 command.add("-hide_banner");
1338 for (File o : inputs) {
1339 command.add("-i");
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));
1348 }
1349 if (outmaps.hasAdaptivePlaylist()) {
1350 List<File> results = process(command);
1351
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);
1359 });
1360 for (int i = 0; i < segments.size(); i++) {
1361 File file = segments.get(i);
1362
1363 String newname = FilenameUtils.concat(file.getParent(),
1364 FilenameUtils.getBaseName(file.getName()) + suffixes.get(i));
1365 renames.put(file, new File(newname));
1366 }
1367
1368 return AdaptivePlaylist.hlsRenameAllFiles(results, renames);
1369 }
1370 return process(command);
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 }