FfmpegSegmenter.java
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.tika.inference;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.apache.tika.exception.TikaException;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.inference.Modality;
import org.apache.tika.utils.FileProcessResult;
import org.apache.tika.utils.ProcessUtils;
/**
* {@link MediaSegmenter} on ffmpeg and ffprobe from the PATH. Cut files are small on purpose:
* mono Opus for audio, silent downscaled H.264 for video, since they travel base64 in JSON.
*/
final class FfmpegSegmenter implements MediaSegmenter {
// per call; ProcessUtils clips both to what remains of the parse's total timeout
private static final long PROBE_TIMEOUT_MS = 60_000;
private static final long CUT_TIMEOUT_MS = 300_000;
// ffprobe prints every stream before the format's duration: room for thousands
private static final int PROBE_BUFFER = 1_000_000;
private static final int CUT_BUFFER = 20_000;
private static volatile Boolean available;
/** Whether ffmpeg and ffprobe answer on the PATH; probed once per JVM, outside any parse budget. */
@Override
public boolean available() {
Boolean known = available;
if (known == null) {
known = answers("ffmpeg") && answers("ffprobe");
available = known;
}
return known;
}
private static boolean answers(String tool) {
try {
FileProcessResult r = ProcessUtils.execute(
new ProcessBuilder(tool, "-version"), 10_000, 1000, 1000);
return r.getExitValue() == 0;
} catch (IOException e) {
return false;
}
}
@Override
public Probe probe(Path media, ParseContext context) throws IOException, TikaException {
ProcessBuilder pb = new ProcessBuilder("ffprobe", "-v", "error", "-show_entries",
"format=duration:stream=codec_type:stream_disposition=attached_pic",
"-of", "default=nw=1", media.toAbsolutePath().toString());
FileProcessResult r = ProcessUtils.execute(pb, context, PROBE_TIMEOUT_MS, PROBE_BUFFER,
CUT_BUFFER);
if (r.isTimeout()) {
throw new Timeout("ffprobe ran out of the parse's time budget after "
+ r.getProcessTimeMillis() + " ms");
}
if (r.getExitValue() != 0) {
throw new TikaException("ffprobe failed: " + r.getStderr());
}
if (r.isStdoutTruncated()) {
throw new TikaException("ffprobe output over " + PROBE_BUFFER + " chars");
}
return parse(r.getStdout());
}
/** One stream at a time: a cover-art stream is not a picture channel, whichever stream it is. */
static Probe parse(String stdout) throws TikaException {
double seconds = -1;
boolean audio = false;
boolean video = false;
boolean streamIsVideo = false;
boolean streamIsCover = false;
for (String line : stdout.split("\\R")) {
if (line.startsWith("codec_type=")) {
video |= streamIsVideo && !streamIsCover;
streamIsVideo = line.equals("codec_type=video");
streamIsCover = false;
audio |= line.equals("codec_type=audio");
} else if (line.equals("DISPOSITION:attached_pic=1")) {
streamIsCover = true;
} else if (line.startsWith("duration=")) {
try {
seconds = Double.parseDouble(line.substring("duration=".length()));
} catch (NumberFormatException e) {
// "N/A" for streams without a container duration
}
}
}
video |= streamIsVideo && !streamIsCover;
if (seconds <= 0 || Double.isNaN(seconds) || Double.isInfinite(seconds)) {
throw new TikaException("ffprobe found no duration");
}
return new Probe(Math.round(seconds * 1000), audio, video);
}
@Override
public Path cut(Path media, Cell cell, Modality modality, Path dir, ParseContext context)
throws IOException, TikaException {
boolean audio = modality == Modality.AUDIO;
Path out = dir.resolve(cell.index() + (audio ? ".ogg" : ".mp4"));
if (Files.exists(out)) {
return out;
}
List<String> cmd = new ArrayList<>(List.of("ffmpeg", "-hide_banner", "-loglevel", "error",
"-y", "-ss", ms(cell.startMs()), "-t", ms(cell.endMs() - cell.startMs()),
"-i", media.toAbsolutePath().toString()));
if (audio) {
cmd.addAll(List.of("-vn", "-ac", "1", "-c:a", "libopus", "-b:a", "32k"));
} else {
cmd.addAll(List.of("-an", "-vf", "scale=-2:360", "-c:v", "libx264", "-preset",
"veryfast", "-crf", "28", "-movflags", "+faststart"));
}
cmd.add(out.toAbsolutePath().toString());
FileProcessResult r = ProcessUtils.execute(new ProcessBuilder(cmd), context,
CUT_TIMEOUT_MS, CUT_BUFFER, CUT_BUFFER);
if (r.isTimeout() || r.getExitValue() != 0 || !Files.exists(out)) {
// a partial file must not be taken for the cut on a retry
Files.deleteIfExists(out);
}
if (r.isTimeout()) {
throw new Timeout("ffmpeg ran out of the parse's time budget on segment "
+ cell.index() + " after " + r.getProcessTimeMillis() + " ms");
}
if (r.getExitValue() != 0 || !Files.exists(out)) {
throw new TikaException("ffmpeg failed on segment " + cell.index() + ": "
+ r.getStderr());
}
return out;
}
private static String ms(long millis) {
return String.format(Locale.ROOT, "%d.%03d", millis / 1000, millis % 1000);
}
}