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  package org.opencastproject.inspection.ffmpeg;
22  
23  import org.opencastproject.inspection.ffmpeg.api.AudioStreamMetadata;
24  import org.opencastproject.inspection.ffmpeg.api.MediaAnalyzer;
25  import org.opencastproject.inspection.ffmpeg.api.MediaAnalyzerException;
26  import org.opencastproject.inspection.ffmpeg.api.MediaContainerMetadata;
27  import org.opencastproject.inspection.ffmpeg.api.SubtitleStreamMetadata;
28  import org.opencastproject.inspection.ffmpeg.api.VideoStreamMetadata;
29  import org.opencastproject.util.IoSupport;
30  
31  import org.json.simple.JSONArray;
32  import org.json.simple.JSONObject;
33  import org.json.simple.parser.JSONParser;
34  import org.json.simple.parser.ParseException;
35  import org.slf4j.Logger;
36  import org.slf4j.LoggerFactory;
37  
38  import java.io.BufferedReader;
39  import java.io.File;
40  import java.io.IOException;
41  import java.io.InputStreamReader;
42  import java.util.ArrayList;
43  import java.util.List;
44  import java.util.Map;
45  
46  /**
47   * This MediaAnalyzer implementation uses the ffprobe binary of FFmpeg for media analysis. Also this implementation does
48   * not keep control-, text- or other non-audio or video streams and purposefully ignores them during the
49   * <code>postProcess()</code> step.
50   */
51  public class FFmpegAnalyzer implements MediaAnalyzer {
52  
53    /** Path to the executable */
54    protected String binary;
55  
56    public static final String FFPROBE_BINARY_CONFIG = "org.opencastproject.inspection.ffprobe.path";
57    public static final String FFPROBE_BINARY_DEFAULT = "ffprobe";
58  
59    /** Logging facility */
60    private static final Logger logger = LoggerFactory.getLogger(FFmpegAnalyzer.class);
61  
62    /** Whether the calculation of the frames is accurate or not */
63    private boolean accurateFrameCount;
64  
65    public FFmpegAnalyzer(boolean accurateFrameCount) {
66      this.accurateFrameCount = accurateFrameCount;
67      // instantiated using MediaAnalyzerFactory via newInstance()
68      this.binary = FFPROBE_BINARY_DEFAULT;
69    }
70  
71    /**
72     * Returns the binary used to provide media inspection functionality.
73     *
74     * @return the binary
75     */
76    protected String getBinary() {
77      return binary;
78    }
79  
80    public void setBinary(String binary) {
81      this.binary = binary;
82    }
83  
84    @Override
85    public MediaContainerMetadata analyze(File media) throws MediaAnalyzerException {
86      if (binary == null) {
87        throw new IllegalStateException("Binary is not set");
88      }
89  
90      List<String> command = new ArrayList<>();
91      command.add(binary);
92      command.add("-show_format");
93      command.add("-show_streams");
94      if (accurateFrameCount)
95        command.add("-count_frames");
96      command.add("-of");
97      command.add("json");
98      command.add(media.getAbsolutePath().replaceAll(" ", "\\ "));
99  
100     /* Execute ffprobe and obtain the result */
101     logger.debug("Running {} {}", binary, command);
102 
103     MediaContainerMetadata metadata = new MediaContainerMetadata();
104 
105     final StringBuilder sb = new StringBuilder();
106     Process encoderProcess = null;
107     try {
108       encoderProcess = new ProcessBuilder(command)
109           .redirectError(ProcessBuilder.Redirect.DISCARD)
110           .start();
111 
112       // tell encoder listeners about output
113       try (var in = new BufferedReader(new InputStreamReader(encoderProcess.getInputStream()))) {
114         String line;
115         while ((line = in.readLine()) != null) {
116           logger.debug(line);
117           sb.append(line).append(System.getProperty("line.separator"));
118         }
119       }
120       // wait until the task is finished
121       int exitCode = encoderProcess.waitFor();
122       if (exitCode != 0) {
123         throw new MediaAnalyzerException("Frame analyzer " + binary + " exited with code " + exitCode);
124       }
125     } catch (IOException | InterruptedException e) {
126       logger.error("Error executing ffprobe", e);
127       throw new MediaAnalyzerException("Error while running " + binary, e);
128     } finally {
129       IoSupport.closeQuietly(encoderProcess);
130     }
131 
132     JSONParser parser = new JSONParser();
133 
134     try {
135       JSONObject jsonObject = (JSONObject) parser.parse(sb.toString());
136       Object obj;
137       Double duration;
138 
139       /* Get format specific stuff */
140       JSONObject jsonFormat = (JSONObject) jsonObject.get("format");
141 
142       /* File Name */
143       obj = jsonFormat.get("filename");
144       if (obj != null) {
145         metadata.setFileName((String) obj);
146       }
147 
148       /* Format */
149       obj = jsonFormat.get("format_long_name");
150       if (obj != null) {
151         metadata.setFormat((String) obj);
152       }
153 
154       /*
155        * Mediainfo does not return a duration if there is no stream but FFprobe will return 0. For compatibility
156        * reasons, check if there are any streams before reading the duration:
157        */
158       obj = jsonFormat.get("nb_streams");
159       if (obj != null && (Long) obj > 0) {
160         obj = jsonFormat.get("duration");
161         if (obj != null) {
162           duration = Double.parseDouble((String) obj) * 1000;
163           metadata.setDuration(duration.longValue());
164         }
165       }
166 
167       /* File Size */
168       obj = jsonFormat.get("size");
169       if (obj != null) {
170         metadata.setSize(Long.parseLong((String) obj));
171       }
172 
173       /* Bitrate */
174       obj = jsonFormat.get("bit_rate");
175       if (obj != null) {
176         metadata.setBitRate(Float.parseFloat((String) obj));
177       }
178 
179       /* Loop through streams */
180       /*
181        * FFprobe will return an empty stream array if there are no streams. Thus we do not need to check.
182        */
183       JSONArray streams = (JSONArray) jsonObject.get("streams");
184       for (JSONObject stream : (Iterable<JSONObject>) streams) {
185         /* Check type of string */
186         String codecType = (String) stream.get("codec_type");
187 
188         /* Handle audio streams ----------------------------- */
189 
190         if ("audio".equals(codecType)) {
191           /* Extract audio stream metadata */
192           AudioStreamMetadata aMetadata = new AudioStreamMetadata();
193 
194           /* Codec */
195           obj = stream.get("codec_long_name");
196           if (obj != null) {
197             aMetadata.setFormat((String) obj);
198           }
199 
200           /* Duration */
201           obj = stream.get("duration");
202           if (obj != null) {
203             duration = new Double((String) obj) * 1000;
204             aMetadata.setDuration(duration.longValue());
205           } else {
206             /*
207              * If no duration for this stream is specified assume the duration of the file for this as well.
208              */
209             aMetadata.setDuration(metadata.getDuration());
210           }
211 
212           /* Bitrate */
213           obj = stream.get("bit_rate");
214           if (obj != null) {
215             aMetadata.setBitRate(new Float((String) obj));
216           }
217 
218           /* Channels */
219           obj = stream.get("channels");
220           if (obj != null) {
221             aMetadata.setChannels(((Long) obj).intValue());
222           }
223 
224           /* Sample Rate */
225           obj = stream.get("sample_rate");
226           if (obj != null) {
227             aMetadata.setSamplingRate(Integer.parseInt((String) obj));
228           }
229 
230           /* Frame Count */
231           obj = stream.get("nb_read_frames");
232           if (obj != null) {
233             aMetadata.setFrames(Long.parseLong((String) obj));
234           } else {
235 
236             /* alternate JSON element if accurate frame count is not requested from ffmpeg */
237             obj = stream.get("nb_frames");
238             if (obj != null) {
239               aMetadata.setFrames(Long.parseLong((String) obj));
240             }
241           }
242 
243           /* Add video stream metadata to overall metadata */
244           metadata.getAudioStreamMetadata().add(aMetadata);
245 
246           /* Handle video streams ----------------------------- */
247 
248         } else if ("video".equals(codecType)) {
249           /* Extract video stream metadata */
250           VideoStreamMetadata vMetadata = new VideoStreamMetadata();
251 
252           /* Codec */
253           obj = stream.get("codec_long_name");
254           if (obj != null) {
255             vMetadata.setFormat((String) obj);
256           }
257 
258           /* Duration */
259           obj = stream.get("duration");
260           if (obj != null) {
261             duration = new Double((String) obj) * 1000;
262             vMetadata.setDuration(duration.longValue());
263           } else {
264             /*
265              * If no duration for this stream is specified assume the duration of the file for this as well.
266              */
267             vMetadata.setDuration(metadata.getDuration());
268           }
269 
270           /* Bitrate */
271           obj = stream.get("bit_rate");
272           if (obj != null) {
273             vMetadata.setBitRate(new Float((String) obj));
274           }
275 
276           /* Width */
277           obj = stream.get("width");
278           if (obj != null) {
279             vMetadata.setFrameWidth(((Long) obj).intValue());
280           }
281 
282           /* Height */
283           obj = stream.get("height");
284           if (obj != null) {
285             vMetadata.setFrameHeight(((Long) obj).intValue());
286           }
287 
288           /* Profile */
289           obj = stream.get("profile");
290           if (obj != null) {
291             vMetadata.setFormatProfile((String) obj);
292           }
293 
294           /* Aspect Ratio */
295           obj = stream.get("sample_aspect_ratio");
296           if (obj != null) {
297             vMetadata.setPixelAspectRatio(parseFloat((String) obj));
298           }
299 
300           /* Frame Rate */
301           obj = stream.get("avg_frame_rate");
302           if (obj != null) {
303             vMetadata.setFrameRate(parseFloat((String) obj));
304           }
305 
306           /* Frame Count */
307           obj = stream.get("nb_read_frames");
308           if (obj != null) {
309             vMetadata.setFrames(Long.parseLong((String) obj));
310           } else {
311 
312             /* alternate JSON element if accurate frame count is not requested from ffmpeg */
313             obj = stream.get("nb_frames");
314             if (obj != null) {
315               vMetadata.setFrames(Long.parseLong((String) obj));
316             } else if (vMetadata.getDuration() != null && vMetadata.getFrameRate() != null) {
317               long framesEstimation = Double.valueOf(vMetadata.getDuration() / 1000.0 * vMetadata.getFrameRate())
318                   .longValue();
319               if (framesEstimation >= 1) {
320                 vMetadata.setFrames(framesEstimation);
321               }
322             }
323           }
324 
325           /* Add video stream metadata to overall metadata */
326           metadata.getVideoStreamMetadata().add(vMetadata);
327 
328           /* Handle subtitle streams ----------------------------- */
329 
330         } else if ("subtitle".equals(codecType)) {
331           /* Extract subtitle stream metadata */
332           SubtitleStreamMetadata sMetadata = new SubtitleStreamMetadata();
333 
334           /* Codec */
335           obj = stream.get("codec_long_name");
336           if (obj != null) {
337             sMetadata.setFormat((String) obj);
338           }
339 
340           metadata.getSubtitleStreamMetadata().add(sMetadata);
341         }
342       }
343 
344     } catch (ParseException e) {
345       logger.error("Error parsing ffprobe output: {}", e.getMessage());
346     }
347 
348     return metadata;
349   }
350 
351   /**
352    * Allows configuration {@inheritDoc}
353    *
354    * @see org.opencastproject.inspection.ffmpeg.api.MediaAnalyzer#setConfig(java.util.Map)
355    */
356   @Override
357   public void setConfig(Map<String, Object> config) {
358     if (config != null) {
359       if (config.containsKey(FFPROBE_BINARY_CONFIG)) {
360         String binary = (String) config.get(FFPROBE_BINARY_CONFIG);
361         setBinary(binary);
362         logger.debug("FFmpegAnalyzer config binary: " + binary);
363       }
364     }
365   }
366 
367   private float parseFloat(String val) {
368     if (val.contains("/")) {
369       String[] v = val.split("/");
370       if (Float.parseFloat(v[1]) == 0) {
371         return 0;
372       } else {
373         return Float.parseFloat(v[0]) / Float.parseFloat(v[1]);
374       }
375     } else if (val.contains(":")) {
376       String[] v = val.split(":");
377       if (Float.parseFloat(v[1]) == 0) {
378         return 0;
379       } else {
380         return Float.parseFloat(v[0]) / Float.parseFloat(v[1]);
381       }
382     } else {
383       return Float.parseFloat(val);
384     }
385   }
386 
387 }