Created
April 9, 2026 14:19
-
-
Save xobust/7516473cecd32482e264d7a0e1132eed to your computer and use it in GitHub Desktop.
Test feasability of using ffmpeg concat to merge ordered chapters mkv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| using System.Diagnostics; | |
| using System.Globalization; | |
| using System.IO; | |
| using System.Text; | |
| using Xunit; | |
| namespace Jellyfin.MediaEncoding.Keyframes.OrderedChapters | |
| { | |
| public class OrderedChapterConcatTests | |
| { | |
| // Output is written to a stable directory so artefacts survive the test run and can be inspected. | |
| private static readonly string OutputRoot = Path.Combine( | |
| Path.GetTempPath(), "jellyfin-ordered-chapters-poc-output"); | |
| [Fact] | |
| public void ConcatDemuxer_WithInpointOutpoint_RemuxesExpectedVirtualDuration() | |
| { | |
| if (!TryLocateTool("ffmpeg", out var ffmpegPath) || | |
| !TryLocateTool("ffprobe", out var ffprobePath)) | |
| { | |
| // Environment-dependent integration test: if tools are absent, do not fail build. | |
| return; | |
| } | |
| Directory.CreateDirectory(OutputRoot); | |
| var segmentA = Path.Combine(OutputRoot, "segment_a.mkv"); | |
| var segmentB = Path.Combine(OutputRoot, "segment_b.mkv"); | |
| var concatConfig = Path.Combine(OutputRoot, "ordered.concat"); | |
| var outputFile = Path.Combine(OutputRoot, "output.mkv"); | |
| Console.WriteLine($"[POC] Output directory: {OutputRoot}"); | |
| // All-intra GOP (-g 1) keeps inpoint/outpoint slicing stable for a copy-remux POC. | |
| CreateSyntheticSegment(ffmpegPath, segmentA, "testsrc=size=320x240:rate=25:duration=4", "sine=frequency=440:sample_rate=48000:duration=4"); | |
| Console.WriteLine($"[POC] segment_a: {new FileInfo(segmentA).Length} bytes, 4 s source"); | |
| CreateSyntheticSegment(ffmpegPath, segmentB, "testsrc2=size=320x240:rate=25:duration=4", "sine=frequency=880:sample_rate=48000:duration=4"); | |
| Console.WriteLine($"[POC] segment_b: {new FileInfo(segmentB).Length} bytes, 4 s source"); | |
| // Virtual timeline: three slices from two files. | |
| // part 1 — segment_a [0.5 s → 2.0 s] = 1.5 s | |
| // part 2 — segment_b [1.0 s → 3.5 s] = 2.5 s | |
| // part 3 — segment_a [2.0 s → 3.0 s] = 1.0 s | |
| // expected total = 5.0 s | |
| var parts = new[] | |
| { | |
| new ConcatPart(segmentA, 0.5, 2.0), | |
| new ConcatPart(segmentB, 1.0, 3.5), | |
| new ConcatPart(segmentA, 2.0, 3.0), | |
| }; | |
| WriteConcatConfig(concatConfig, parts); | |
| Console.WriteLine($"[POC] concat config written to: {concatConfig}"); | |
| Console.WriteLine($"[POC] concat config contents:\n{File.ReadAllText(concatConfig)}"); | |
| var remux = RunProcess( | |
| ffmpegPath, | |
| $"-hide_banner -loglevel error -y -f concat -safe 0 -i \"{concatConfig}\" -c copy \"{outputFile}\""); | |
| Assert.True(remux.ExitCode == 0, $"ffmpeg remux failed.\nstdout:\n{remux.StdOut}\nstderr:\n{remux.StdErr}"); | |
| Assert.True(File.Exists(outputFile), "Expected ffmpeg to produce output file."); | |
| var actualDuration = ProbeDurationSeconds(ffprobePath, outputFile); | |
| var expectedDuration = 1.5 + 2.5 + 1.0; | |
| Console.WriteLine($"[POC] output.mkv: {new FileInfo(outputFile).Length} bytes"); | |
| Console.WriteLine($"[POC] expected duration: {expectedDuration:F3} s"); | |
| Console.WriteLine($"[POC] actual duration: {actualDuration:F3} s"); | |
| Console.WriteLine($"[POC] difference: {Math.Abs(actualDuration - expectedDuration):F3} s"); | |
| Assert.InRange(actualDuration, expectedDuration - 0.35, expectedDuration + 0.35); | |
| } | |
| private static void CreateSyntheticSegment(string ffmpegPath, string outputPath, string videoFilter, string audioFilter) | |
| { | |
| var result = RunProcess( | |
| ffmpegPath, | |
| $"-hide_banner -loglevel error -y -f lavfi -i \"{videoFilter}\" -f lavfi -i \"{audioFilter}\" " + | |
| "-c:v libx264 -preset ultrafast -g 1 -sc_threshold 0 -pix_fmt yuv420p " + | |
| "-c:a aac -b:a 128k " + | |
| $"\"{outputPath}\""); | |
| Assert.True(result.ExitCode == 0, $"ffmpeg synthetic segment generation failed for {outputPath}.\nstdout:\n{result.StdOut}\nstderr:\n{result.StdErr}"); | |
| } | |
| private static void WriteConcatConfig(string path, IEnumerable<ConcatPart> parts) | |
| { | |
| // Encoding.UTF8 emits a BOM which ffmpeg's concat parser rejects on line 1. | |
| using var writer = new StreamWriter(path, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); | |
| foreach (var part in parts) | |
| { | |
| writer.WriteLine("file '{0}'", EscapeConcatPath(part.FilePath)); | |
| writer.WriteLine("inpoint {0}", part.InpointSeconds.ToString("F9", CultureInfo.InvariantCulture)); | |
| writer.WriteLine("outpoint {0}", part.OutpointSeconds.ToString("F9", CultureInfo.InvariantCulture)); | |
| writer.WriteLine(); | |
| } | |
| } | |
| private static double ProbeDurationSeconds(string ffprobePath, string inputFile) | |
| { | |
| var result = RunProcess( | |
| ffprobePath, | |
| $"-v error -show_entries format=duration -of default=nokey=1:noprint_wrappers=1 \"{inputFile}\""); | |
| Assert.True(result.ExitCode == 0, $"ffprobe failed.\nstdout:\n{result.StdOut}\nstderr:\n{result.StdErr}"); | |
| var text = (result.StdOut ?? string.Empty).Trim(); | |
| Assert.True(double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var duration), | |
| $"Unable to parse ffprobe duration output: '{text}'"); | |
| return duration; | |
| } | |
| private static ProcessResult RunProcess(string fileName, string arguments) | |
| { | |
| using var process = new Process | |
| { | |
| StartInfo = new ProcessStartInfo | |
| { | |
| FileName = fileName, | |
| Arguments = arguments, | |
| RedirectStandardOutput = true, | |
| RedirectStandardError = true, | |
| UseShellExecute = false, | |
| CreateNoWindow = true, | |
| }, | |
| }; | |
| process.Start(); | |
| var stdOut = process.StandardOutput.ReadToEnd(); | |
| var stdErr = process.StandardError.ReadToEnd(); | |
| process.WaitForExit(); | |
| return new ProcessResult(process.ExitCode, stdOut, stdErr); | |
| } | |
| private static bool TryLocateTool(string toolName, out string toolPath) | |
| { | |
| var which = RunProcess("/usr/bin/env", $"which {toolName}"); | |
| toolPath = (which.StdOut ?? string.Empty).Trim(); | |
| return which.ExitCode == 0 && !string.IsNullOrWhiteSpace(toolPath); | |
| } | |
| private static string EscapeConcatPath(string filePath) | |
| { | |
| return filePath.Replace("'", "'\\''", StringComparison.Ordinal); | |
| } | |
| private sealed record ProcessResult(int ExitCode, string StdOut, string StdErr); | |
| private sealed record ConcatPart(string FilePath, double InpointSeconds, double OutpointSeconds); | |
| } | |
| } |
xobust
commented
Apr 9, 2026
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment