Skip to content

Instantly share code, notes, and snippets.

@xobust
Created April 9, 2026 14:19
Show Gist options
  • Select an option

  • Save xobust/7516473cecd32482e264d7a0e1132eed to your computer and use it in GitHub Desktop.

Select an option

Save xobust/7516473cecd32482e264d7a0e1132eed to your computer and use it in GitHub Desktop.
Test feasability of using ffmpeg concat to merge ordered chapters mkv
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

xobust commented Apr 9, 2026

Copy link
Copy Markdown
Author
 dotnet test tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj \
        --filter "FullyQualifiedName~OrderedChapterConcatTests" \
        -p:RunAnalyzers=false \
        --logger "console;verbosity=normal"
Restore complete (0.2s)
  Jellyfin.CodeAnalysis netstandard2.0 succeeded (0.0s) → src/Jellyfin.CodeAnalysis/bin/Debug/netstandard2.0/Jellyfin.CodeAnalysis.dll
  Jellyfin.MediaEncoding.Keyframes net10.0 succeeded (0.1s) → src/Jellyfin.MediaEncoding.Keyframes/bin/Debug/net10.0/Jellyfin.MediaEncoding.Keyframes.dll
  Jellyfin.MediaEncoding.Keyframes.Tests net10.0 succeeded (0.1s) → tests/Jellyfin.MediaEncoding.Keyframes.Tests/bin/Debug/net10.0/Jellyfin.MediaEncoding.Keyframes.Tests.dll
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.02]   Discovering: Jellyfin.MediaEncoding.Keyframes.Tests
[xUnit.net 00:00:00.02]   Discovering: Jellyfin.MediaEncoding.Keyframes.Tests
[xUnit.net 00:00:00.04]   Discovered:  Jellyfin.MediaEncoding.Keyframes.Tests
[xUnit.net 00:00:00.04]   Discovered:  Jellyfin.MediaEncoding.Keyframes.Tests
[xUnit.net 00:00:00.04]   Starting:    Jellyfin.MediaEncoding.Keyframes.Tests
[xUnit.net 00:00:00.04]   Starting:    Jellyfin.MediaEncoding.Keyframes.Tests
[POC] Output directory: /var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output
[POC] Output directory: /var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output
[POC] segment_a: 479976 bytes, 4 s source
[POC] segment_a: 479976 bytes, 4 s source
[POC] segment_b: 744937 bytes, 4 s source
[POC] segment_b: 744937 bytes, 4 s source
[POC] concat config written to: /var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output/ordered.concat
[POC] concat config written to: /var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output/ordered.concat
[POC] concat config contents:
[POC] concat config contents:
file '/var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output/segment_a.mkv'
file '/var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output/segment_a.mkv'
inpoint 0.500000000
inpoint 0.500000000
outpoint 2.000000000
outpoint 2.000000000
file '/var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output/segment_b.mkv'
file '/var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output/segment_b.mkv'
inpoint 1.000000000
inpoint 1.000000000
outpoint 3.500000000
outpoint 3.500000000
file '/var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output/segment_a.mkv'
file '/var/folders/bl/_z4vsx516js0hpgz4mtvgw7w0000gn/T/jellyfin-ordered-chapters-poc-output/segment_a.mkv'
inpoint 2.000000000
inpoint 2.000000000
outpoint 3.000000000
outpoint 3.000000000
[POC] output.mkv: 776910 bytes
[POC] output.mkv: 776910 bytes
[POC] expected duration: 5.000 s
[POC] expected duration: 5.000 s
[POC] actual duration:   5.049 s
[POC] actual duration:   5.049 s
[POC] difference:        0.049 s
[POC] difference:        0.049 s
[xUnit.net 00:00:00.38]   Finished:    Jellyfin.MediaEncoding.Keyframes.Tests
[xUnit.net 00:00:00.38]   Finished:    Jellyfin.MediaEncoding.Keyframes.Tests
  Passed Jellyfin.MediaEncoding.Keyframes.OrderedChapters.OrderedChapterConcatTests.ConcatDemuxer_WithInpointOutpoint_RemuxesExpectedVirtualDuration [323 ms]

Test Run Successful.
Total tests: 1
     Passed: 1
 Total time: 0.6015 Seconds
  Jellyfin.MediaEncoding.Keyframes.Tests test net10.0 succeeded (0.7s)

Test summary: total: 1, failed: 0, succeeded: 1, skipped: 0, duration: 0.7s
Build succeeded in 1.4s

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment