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