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