View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  
23  package org.opencastproject.videoeditor.ffmpeg;
24  
25  import org.opencastproject.util.IoSupport;
26  import org.opencastproject.videoeditor.impl.VideoClip;
27  import org.opencastproject.videoeditor.impl.VideoEditorProperties;
28  
29  import org.apache.commons.lang3.StringUtils;
30  import org.osgi.framework.BundleContext;
31  import org.slf4j.Logger;
32  import org.slf4j.LoggerFactory;
33  
34  import java.io.BufferedReader;
35  import java.io.InputStreamReader;
36  import java.text.DecimalFormat;
37  import java.text.DecimalFormatSymbols;
38  import java.util.ArrayList;
39  import java.util.Arrays;
40  import java.util.List;
41  import java.util.Locale;
42  import java.util.Properties;
43  
44  /**
45   * FFmpeg wrappers:
46   * processEdits:    process SMIL definitions of segments into one consecutive video
47   *                  There is a fade in and a fade out at the beginning and end of each clip
48   *
49   */
50  public class FFmpegEdit {
51  
52    private static final Logger logger = LoggerFactory.getLogger(FFmpegEdit.class);
53    private static final String FFMPEG_BINARY_DEFAULT = "ffmpeg";
54    private static final String CONFIG_FFMPEG_PATH = "org.opencastproject.composer.ffmpeg.path";
55  
56    private static final String DEFAULT_FFMPEG_PROPERTIES = "-preset faster -crf 18";
57    private static final String DEFAULT_AUDIO_FADE = "0.2";
58    private static final String DEFAULT_VIDEO_FADE = "0.2";
59    private static String binary = FFMPEG_BINARY_DEFAULT;
60  
61    protected float vfade;
62    protected float afade;
63    protected String ffmpegProperties = DEFAULT_FFMPEG_PROPERTIES;
64    protected String ffmpegScaleFilter = null;
65    protected String videoCodec = null;  // By default, use the same codec as source
66    protected String audioCodec = null;
67  
68    public static void init(BundleContext bundleContext) {
69      String path = bundleContext.getProperty(CONFIG_FFMPEG_PATH);
70  
71      if (StringUtils.isNotBlank(path)) {
72        binary = path.trim();
73      }
74    }
75  
76    public FFmpegEdit() {
77      this.afade = Float.parseFloat(DEFAULT_AUDIO_FADE);
78      this.vfade = Float.parseFloat(DEFAULT_VIDEO_FADE);
79      this.ffmpegProperties = DEFAULT_FFMPEG_PROPERTIES;
80    }
81  
82    /*
83     * Init with properties
84     */
85    public FFmpegEdit(Properties properties) {
86      String fade = properties.getProperty(VideoEditorProperties.AUDIO_FADE, DEFAULT_AUDIO_FADE);
87      this.afade = Float.parseFloat(fade);
88      fade = properties.getProperty(VideoEditorProperties.VIDEO_FADE, DEFAULT_VIDEO_FADE);
89      this.vfade = Float.parseFloat(fade);
90      this.ffmpegProperties = properties.getProperty(VideoEditorProperties.FFMPEG_PROPERTIES, DEFAULT_FFMPEG_PROPERTIES);
91      this.ffmpegScaleFilter = properties.getProperty(VideoEditorProperties.FFMPEG_SCALE_FILTER, null);
92      this.videoCodec = properties.getProperty(VideoEditorProperties.VIDEO_CODEC, null);
93      this.audioCodec = properties.getProperty(VideoEditorProperties.AUDIO_CODEC, null);
94    }
95  
96    public String processEdits(List<String> inputfiles, String dest, String outputSize, List<VideoClip> cleanclips)
97            throws Exception {
98      return processEdits(inputfiles, dest, outputSize, cleanclips, true, true);
99    }
100 
101   public String processEdits(List<String> inputfiles, String dest, String outputSize, List<VideoClip> cleanclips,
102           boolean hasAudio, boolean hasVideo) throws Exception {
103     List<String> cmd = makeEdits(inputfiles, dest, outputSize, cleanclips, hasAudio, hasVideo);
104     return run(cmd);
105   }
106 
107   /* Run the ffmpeg command with the params
108    * Takes a list of words as params, the output is logged
109    */
110   private String run(List<String> params) {
111     BufferedReader in = null;
112     Process encoderProcess = null;
113     try {
114       params.add(0, "-nostats");
115       params.add(0, "-nostdin");
116       params.add(0, "-hide_banner");
117       params.add(0, binary);
118       logger.info("executing command: " + StringUtils.join(params, " "));
119       ProcessBuilder pbuilder = new ProcessBuilder(params);
120       pbuilder.redirectErrorStream(true);
121       encoderProcess = pbuilder.start();
122       in = new BufferedReader(new InputStreamReader(
123               encoderProcess.getInputStream()));
124       String line;
125       int n = 5;
126       while ((line = in.readLine()) != null) {
127         if (n-- > 0) {
128           logger.info(line);
129         }
130       }
131 
132       // wait until the task is finished
133       encoderProcess.waitFor();
134       int exitCode = encoderProcess.exitValue();
135       if (exitCode != 0) {
136         throw new Exception("Ffmpeg exited abnormally with status " + exitCode);
137       }
138 
139     } catch (Exception ex) {
140       logger.error("VideoEditor ffmpeg failed", ex);
141       return ex.toString();
142     } finally {
143       IoSupport.closeQuietly(in);
144       IoSupport.closeQuietly(encoderProcess);
145     }
146     return null;
147   }
148 
149   /*
150    * Construct the ffmpeg command from  src, in-out points and output resolution
151    * Inputfile is an ordered list of video src
152    * clips is a list of edit points indexing into the video src list
153    * outputResolution when specified is the size to which all the clips will scale
154    * hasAudio and hasVideo specify media type of the input files
155    * NOTE: This command will fail if the sizes are mismatched or
156    * if some of the clips aren't same as specified mediatype
157    * (hasn't audio or video stream while hasAudio, hasVideo parameter set)
158    */
159   public List<String> makeEdits(List<String> inputfiles, String dest, String outputResolution,
160           List<VideoClip> clips, boolean hasAudio, boolean hasVideo) throws Exception {
161 
162     if (!hasAudio && !hasVideo) {
163       throw new IllegalArgumentException("Inputfiles should have at least audio or video stream.");
164     }
165 
166     DecimalFormat f = new DecimalFormat("0.00", new DecimalFormatSymbols(Locale.US));
167     int n = clips.size();
168     int i;
169     String outmap = "";
170     String scale = "";
171     List<String> command = new ArrayList<String>();
172     List<String> vpads = new ArrayList<String>();
173     List<String> apads = new ArrayList<String>();
174     List<String> clauses = new ArrayList<String>(); // The clauses are ordered
175 
176     if (n > 1) { // Create the input pads if we have multiple segments
177       for (i = 0; i < n ; i++) {
178         if (hasVideo) {
179           vpads.add("[v" + i + "]");  // post filter
180         }
181         if (hasAudio) {
182           apads.add("[a" + i + "]");
183         }
184       }
185     }
186     if (hasVideo) {
187       if (outputResolution != null && outputResolution.length() > 3) { // format is "<width>x<height>"
188         // scale each clip to the same size
189         scale = ",scale=" + outputResolution;
190       }
191       else if (ffmpegScaleFilter != null) {
192         // Use scale filter if configured
193         scale = ",scale=" +  ffmpegScaleFilter;
194       }
195     }
196 
197     for (i = 0; i < n; i++) { // Examine each clip
198       // get clip and add fades to each clip
199       VideoClip vclip = clips.get(i);
200       int fileindx = vclip.getSrc();   // get source file by index
201       double inpt = vclip.getStartInSeconds();     // get in points
202       double duration = vclip.getDurationInSeconds();
203 
204       String clip = "";
205       if (hasVideo) {
206         String vfadeFilter = "";
207         /* Only include fade into the filter graph if necessary */
208         if (vfade > 0.00001) {
209           double vend = duration - vfade;
210           vfadeFilter = ",fade=t=in:st=0:d=" + vfade + ",fade=t=out:st=" + f.format(vend) + ":d=" + vfade;
211         }
212         /* Add filters for video */
213         clip = "[" + fileindx + ":v]trim=" + f.format(inpt) + ":duration=" + f.format(duration)
214                   + scale + ",setpts=PTS-STARTPTS" + vfadeFilter + "[v" + i + "]";
215 
216         clauses.add(clip);
217       }
218 
219       if (hasAudio) {
220         String afadeFilter = "";
221         /* Only include fade into the filter graph if necessary */
222         if (afade > 0.00001) {
223           double aend = duration - afade;
224           afadeFilter = ",afade=t=in:st=0:d=" + afade + ",afade=t=out:st=" + f.format(aend) + ":d=" + afade;
225         }
226         /* Add filters for audio */
227         clip = "[" + fileindx + ":a]atrim=" + f.format(inpt) + ":duration=" + f.format(duration)
228                   + ",asetpts=PTS-STARTPTS" + afadeFilter + "[a"
229                   + i + "]";
230         clauses.add(clip);
231       }
232     }
233     if (n > 1) { // concat the outpads when there are more then 1 per stream
234                   // use unsafe because different files may have different SAR/framerate
235       if (hasVideo) {
236         clauses.add(StringUtils.join(vpads, "") + "concat=n=" + n + ":unsafe=1[ov0]"); // concat video clips
237       }
238       if (hasAudio) {
239         clauses.add(StringUtils.join(apads, "") + "concat=n=" + n
240                 + ":v=0:a=1[oa0]"); // concat audio clips in stream 0, video in stream 1
241       }
242       outmap = "o";                 // if more than one clip
243     }
244     for (String o : inputfiles) {
245       command.add("-i");   // Add inputfile in the order of entry
246       command.add(o);
247     }
248     command.add("-filter_complex");
249     command.add(StringUtils.join(clauses, ";"));
250     String[] options = ffmpegProperties.split(" ");
251     command.addAll(Arrays.asList(options));
252     if (hasAudio) {
253       command.add("-map");
254       command.add("[" + outmap + "a0]");
255     }
256     if (hasVideo) {
257       command.add("-map");
258       command.add("[" + outmap + "v0]");
259     }
260     if (hasVideo && videoCodec != null) { // If using different codecs from source, add them here
261       command.add("-c:v");
262       command.add(videoCodec);
263     }
264     if (hasAudio && audioCodec != null) {
265       command.add("-c:a");
266       command.add(audioCodec);
267     }
268     command.add(dest);
269 
270     return command;
271   }
272 }