fix(streaming): hitches in VoDs & Clips resulting from ffmpeg concatenation #44
@@ -16,6 +16,7 @@ import java.util.Comparator;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,37 +132,18 @@ public class StreamActionsService {
|
|||||||
"trimOffset must be >= 0 and duration must be > 0 (got " + trimOffset + ", " + duration + ")"));
|
"trimOffset must be >= 0 and duration must be > 0 (got " + trimOffset + ", " + duration + ")"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Path headFragment = null;
|
|
||||||
Path concatList = null;
|
|
||||||
try {
|
try {
|
||||||
// Only the first segment is trimmed, so re-encode just that fragment and
|
String concatInput = "concat:" + segments.stream()
|
||||||
// stream-copy the rest. A zero offset means the first segment already
|
.map(p -> p.toAbsolutePath().toString())
|
||||||
// starts at the requested point, so re-encoding it would be wasted work.
|
.collect(Collectors.joining("|"));
|
||||||
List<Path> fragments;
|
|
||||||
if (trimOffset > 0) {
|
|
||||||
headFragment = Files.createTempFile("reencode-section-", ".ts");
|
|
||||||
encodeHead(segments.getFirst(), trimOffset, headFragment);
|
|
||||||
|
|
||||||
fragments = new ArrayList<>();
|
|
||||||
fragments.add(headFragment);
|
|
||||||
fragments.addAll(segments.subList(1, segments.size()));
|
|
||||||
} else {
|
|
||||||
fragments = segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The concat demuxer (unlike the concat: byte protocol) rebases each
|
|
||||||
// fragment's timestamps to run continuously across the joins, absorbing
|
|
||||||
// the discontinuity between the PTS-0 head and the copied fragments.
|
|
||||||
concatList = writeConcatList(fragments);
|
|
||||||
|
|
||||||
List<String> command = List.of(
|
List<String> command = List.of(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
"-f", "concat",
|
"-i", concatInput,
|
||||||
"-safe", "0",
|
"-t", String.valueOf(duration + trimOffset),
|
||||||
"-i", concatList.toAbsolutePath().toString(),
|
|
||||||
"-t", String.valueOf(duration),
|
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
|
"-avoid_negative_ts", "make_zero",
|
||||||
outputFile.toAbsolutePath().toString()
|
outputFile.toAbsolutePath().toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -180,9 +162,6 @@ public class StreamActionsService {
|
|||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
||||||
return CompletableFuture.failedFuture(e);
|
return CompletableFuture.failedFuture(e);
|
||||||
} finally {
|
|
||||||
deleteTempQuietly(headFragment);
|
|
||||||
deleteTempQuietly(concatList);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,52 +236,4 @@ public class StreamActionsService {
|
|||||||
throw new IOException("Failed to probe segment duration: " + segment, e);
|
throw new IOException("Failed to probe segment duration: " + segment, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void encodeHead(Path firstSegment, float trimOffset, Path head)
|
|
||||||
throws IOException, InterruptedException {
|
|
||||||
|
|
||||||
List<String> command = List.of(
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i", firstSegment.toAbsolutePath().toString(),
|
|
||||||
"-ss", String.valueOf(trimOffset),
|
|
||||||
"-c:v", "libx264",
|
|
||||||
"-preset", "veryfast",
|
|
||||||
"-crf", "20",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
"-c:a", "aac",
|
|
||||||
"-b:a", "160k",
|
|
||||||
"-muxdelay", "0",
|
|
||||||
"-muxpreload", "0",
|
|
||||||
"-f", "mpegts",
|
|
||||||
head.toAbsolutePath().toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug("Re-encoding head fragment '{}' from {}s", firstSegment, trimOffset);
|
|
||||||
commandRunner.run(command, line -> { /* head pass is short; no progress reporting */ });
|
|
||||||
|
|
||||||
if (!Files.exists(head) || Files.size(head) == 0) {
|
|
||||||
throw new IOException("Head re-encode produced no output — trimOffset " + trimOffset
|
|
||||||
+ "s may exceed the duration of " + firstSegment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path writeConcatList(List<Path> fragments) throws IOException {
|
|
||||||
Path listFile = Files.createTempFile("concat-list-", ".txt");
|
|
||||||
List<String> lines = fragments.stream()
|
|
||||||
.map(p -> "file '" + p.toAbsolutePath().toString().replace("'", "'\\''") + "'")
|
|
||||||
.toList();
|
|
||||||
Files.write(listFile, lines);
|
|
||||||
return listFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void deleteTempQuietly(Path tempFile) {
|
|
||||||
if (tempFile != null) {
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(tempFile);
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Failed to delete temp file '{}': {}", tempFile, e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user