Skip to content

Commit b39602a

Browse files
committed
fix(helper): fall back to container duration when stream tag missing
The mpegts muxer does not always tag per-stream duration on helper-encoded segments. Observed empty stream=duration on every TS produced during a Tubi window pull while the server's identical re-encode tagged it fine; the probe silently returned zero, the announced metrics carried zero on the wire, and the server validator had no zero-rejection rule so the bytes still moved. Re-shape ProbeStreamDurationAsync to fetch stream=index,duration in a single ffprobe call, fall back to format=duration only when the selected stream actually exists, and warn-log a probe miss so a future regression surfaces in the watchdog log instead of as a wall of validator rejections downstream. Adds integration tests against the bundled ffmpeg toolset that exercise both the stream-tagged and stream-untagged TS shapes plus the "no audio stream" guard. Pins the hasAudio toggle on BuildSegmentCommand so the synthetic-silence branch stays distinct from the real-input branch.
1 parent d23943d commit b39602a

3 files changed

Lines changed: 446 additions & 73 deletions

File tree

src/WKVRCProxy.Tests/FfmpegHelperTests.cs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,106 @@ public void TranscodeWorkerProcess_UsesHigherNvencPresetForQuality()
233233
Assert.Equal("p5", command.Arguments[presetIndex + 1]);
234234
}
235235

236+
// Audio mapping is the load-bearing flag for the 2026-05-22 Tubi
237+
// no-sound incident: when has_audio=false reaches the helper, the
238+
// command must inject a silent lavfi input AND map from that input
239+
// (1:a:0). When has_audio=true the command must map the input's own
240+
// audio (0:a:0?) with the optional-suffix so a video-only source
241+
// doesn't kill the encode. These tests pin both shapes so a future
242+
// refactor of the synthetic-silence branch cannot silently swap them.
243+
244+
[Fact]
245+
public void BuildSegmentCommand_WithHasAudioTrue_MapsInputAudio()
246+
{
247+
var lease = NewLease();
248+
var encoder = new HardwareEncoderCapability("h264_nvenc", HardwareEncoderBackend.Nvenc, "NVIDIA NVENC");
249+
250+
TranscodeFfmpegCommand command = TranscodeWorkerProcess.BuildSegmentCommand(
251+
"ffmpeg.exe", lease, encoder, "seg.ts",
252+
targetWidth: 1280, targetHeight: 720, targetBitrateKbps: 2800,
253+
hasAudio: true);
254+
255+
AssertOrderedTokens(command.Arguments, "-map", "0:a:0?");
256+
Assert.DoesNotContain(command.Arguments, a => a.StartsWith("anullsrc", StringComparison.Ordinal));
257+
Assert.DoesNotContain("-shortest", command.Arguments);
258+
// Exactly one input URL on the command line.
259+
Assert.Equal(1, CountTokenOccurrences(command.Arguments, "-i"));
260+
}
261+
262+
[Fact]
263+
public void BuildSegmentCommand_WithHasAudioFalse_AddsSyntheticSilenceAndMapsIt()
264+
{
265+
var lease = NewLease();
266+
var encoder = new HardwareEncoderCapability("h264_nvenc", HardwareEncoderBackend.Nvenc, "NVIDIA NVENC");
267+
268+
TranscodeFfmpegCommand command = TranscodeWorkerProcess.BuildSegmentCommand(
269+
"ffmpeg.exe", lease, encoder, "seg.ts",
270+
targetWidth: 1280, targetHeight: 720, targetBitrateKbps: 2800,
271+
hasAudio: false);
272+
273+
Assert.Contains(command.Arguments, a => a.StartsWith(
274+
"anullsrc=channel_layout=stereo:sample_rate=48000",
275+
StringComparison.Ordinal));
276+
AssertOrderedTokens(command.Arguments, "-map", "1:a:0");
277+
Assert.Contains("-shortest", command.Arguments);
278+
// Two inputs: the source video, then the synthetic silence track.
279+
Assert.Equal(2, CountTokenOccurrences(command.Arguments, "-i"));
280+
}
281+
282+
[Fact]
283+
public void BuildSegmentCommand_AlwaysPinsAacAudioParameters()
284+
{
285+
TranscodeFfmpegCommand command = BuildSegmentCommand(HardwareEncoderBackend.Nvenc, "h264_nvenc");
286+
287+
AssertOrderedTokens(command.Arguments, "-c:a", "aac");
288+
AssertOrderedTokens(command.Arguments, "-b:a", "128k");
289+
AssertOrderedTokens(command.Arguments, "-ac", "2");
290+
AssertOrderedTokens(command.Arguments, "-ar", "48000");
291+
}
292+
293+
[Fact]
294+
public void BuildSegmentCommand_SoftwareFallbackHonorsHasAudioFlag()
295+
{
296+
var lease = NewLease();
297+
var encoder = new HardwareEncoderCapability("h264_nvenc", HardwareEncoderBackend.Nvenc, "NVIDIA NVENC");
298+
299+
TranscodeFfmpegCommand withAudio = TranscodeWorkerProcess.BuildSegmentCommandSoftwareFallback(
300+
"ffmpeg.exe", lease, encoder, "seg.ts",
301+
targetWidth: 640, targetHeight: 360, targetBitrateKbps: 900,
302+
hasAudio: true);
303+
AssertOrderedTokens(withAudio.Arguments, "-map", "0:a:0?");
304+
Assert.DoesNotContain(withAudio.Arguments, a => a.StartsWith("anullsrc", StringComparison.Ordinal));
305+
306+
TranscodeFfmpegCommand withoutAudio = TranscodeWorkerProcess.BuildSegmentCommandSoftwareFallback(
307+
"ffmpeg.exe", lease, encoder, "seg.ts",
308+
targetWidth: 640, targetHeight: 360, targetBitrateKbps: 900,
309+
hasAudio: false);
310+
Assert.Contains(withoutAudio.Arguments, a => a.StartsWith("anullsrc", StringComparison.Ordinal));
311+
AssertOrderedTokens(withoutAudio.Arguments, "-map", "1:a:0");
312+
}
313+
314+
private static int CountTokenOccurrences(IReadOnlyList<string> args, string token)
315+
{
316+
int n = 0;
317+
foreach (string a in args)
318+
if (a == token) n++;
319+
return n;
320+
}
321+
322+
// Asserts that `second` appears immediately after `first` somewhere
323+
// in the argument list. ffmpeg argv pairs are positional (e.g. -map
324+
// value), so adjacency is the right invariant -- not just "both
325+
// present somewhere".
326+
private static void AssertOrderedTokens(IReadOnlyList<string> args, string first, string second)
327+
{
328+
for (int i = 0; i < args.Count - 1; i++)
329+
{
330+
if (args[i] == first && args[i + 1] == second) return;
331+
}
332+
Assert.Fail($"Expected '{first} {second}' adjacent in ffmpeg argv; got: "
333+
+ string.Join(" ", args));
334+
}
335+
236336
[Fact]
237337
public void HelperBenchmark_SelectsHighestPassingQuality()
238338
{
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
using System.Diagnostics;
2+
using WKVRCProxy;
3+
using Xunit;
4+
5+
namespace WKVRCProxy.Tests;
6+
7+
// Integration tests for the helper-side ffprobe path. The shipped probe
8+
// silently returned zero for every helper-encoded TS file in 2026-05-22
9+
// playback because the mpegts muxer left per-stream duration tags empty;
10+
// the validator on the server then had to backstop with a re-probe after
11+
// the upload. These tests synthesize the same shape locally so the
12+
// fallback to container-level duration stays wired and a future probe
13+
// regression fails CI instead of producing silent zeros at runtime.
14+
//
15+
// All tests are skipped (via early return) when the bundled ffmpeg toolset
16+
// is not present alongside the test bin dir -- that lets the suite still
17+
// pass on CI runs where dist/tools/ wasn't populated.
18+
public class HelperLeaseWorkerProbeTests
19+
{
20+
[Fact]
21+
public async Task ProbeStreamDuration_ReadsStreamLevelDuration_ForVanillaTs()
22+
{
23+
if (!BundledFfmpegAvailable(out string ffmpegPath, out _))
24+
return; // skip: tools not staged
25+
26+
string outputPath = NewTempTsPath();
27+
try
28+
{
29+
await RunFfmpegAsync(ffmpegPath, BuildPlainEncodeArgs(outputPath, durationSec: 2));
30+
31+
double? video = await HelperLeaseWorker.ProbeStreamDurationAsync(
32+
ffmpegPath, outputPath, "v:0", CancellationToken.None);
33+
Assert.NotNull(video);
34+
Assert.InRange(video!.Value, 1.5, 2.5);
35+
36+
double? audio = await HelperLeaseWorker.ProbeStreamDurationAsync(
37+
ffmpegPath, outputPath, "a:0", CancellationToken.None);
38+
Assert.NotNull(audio);
39+
Assert.InRange(audio!.Value, 1.5, 2.5);
40+
}
41+
finally
42+
{
43+
TryDelete(outputPath);
44+
}
45+
}
46+
47+
// Reproduces the 2026-05-22 helper-output shape: -output_ts_offset
48+
// pushes the initial PTS, -avoid_negative_ts disabled keeps it from
49+
// being shifted, and the mpegts muxer ends up not tagging per-stream
50+
// duration. The previous probe shape returned empty stdout for this
51+
// file; with the format-level fallback the probe now returns the
52+
// container duration so the wire metric is a real number again.
53+
[Fact]
54+
public async Task ProbeStreamDuration_FallsBackToFormatLevel_ForHelperShapedTs()
55+
{
56+
if (!BundledFfmpegAvailable(out string ffmpegPath, out _))
57+
return;
58+
59+
string outputPath = NewTempTsPath();
60+
try
61+
{
62+
await RunFfmpegAsync(
63+
ffmpegPath,
64+
BuildHelperShapedEncodeArgs(outputPath, startOffsetSec: 32.032, durationSec: 4));
65+
66+
double? video = await HelperLeaseWorker.ProbeStreamDurationAsync(
67+
ffmpegPath, outputPath, "v:0", CancellationToken.None);
68+
Assert.NotNull(video);
69+
Assert.InRange(video!.Value, 3.5, 4.5);
70+
71+
double? audio = await HelperLeaseWorker.ProbeStreamDurationAsync(
72+
ffmpegPath, outputPath, "a:0", CancellationToken.None);
73+
Assert.NotNull(audio);
74+
Assert.InRange(audio!.Value, 3.5, 4.5);
75+
}
76+
finally
77+
{
78+
TryDelete(outputPath);
79+
}
80+
}
81+
82+
// When the lease wired HasAudio=true but the encoded TS turned out to
83+
// be video-only (helper bug, broken source, missing audio rendition),
84+
// the audio probe must still return null instead of mis-reporting the
85+
// video length as audio. The server validator then rejects with
86+
// audio_duration_zero and the lease falls back to server CPU.
87+
[Fact]
88+
public async Task ProbeStreamDuration_ReturnsNull_ForMissingStreamType()
89+
{
90+
if (!BundledFfmpegAvailable(out string ffmpegPath, out _))
91+
return;
92+
93+
string outputPath = NewTempTsPath();
94+
try
95+
{
96+
await RunFfmpegAsync(
97+
ffmpegPath,
98+
BuildVideoOnlyEncodeArgs(outputPath, durationSec: 2));
99+
100+
double? video = await HelperLeaseWorker.ProbeStreamDurationAsync(
101+
ffmpegPath, outputPath, "v:0", CancellationToken.None);
102+
Assert.NotNull(video);
103+
Assert.InRange(video!.Value, 1.5, 2.5);
104+
105+
double? audio = await HelperLeaseWorker.ProbeStreamDurationAsync(
106+
ffmpegPath, outputPath, "a:0", CancellationToken.None);
107+
Assert.Null(audio);
108+
}
109+
finally
110+
{
111+
TryDelete(outputPath);
112+
}
113+
}
114+
115+
[Fact]
116+
public async Task ProbeStreamDuration_ReturnsNull_ForEmptyFile()
117+
{
118+
if (!BundledFfmpegAvailable(out string ffmpegPath, out _))
119+
return;
120+
121+
string outputPath = NewTempTsPath();
122+
try
123+
{
124+
File.WriteAllBytes(outputPath, Array.Empty<byte>());
125+
126+
double? video = await HelperLeaseWorker.ProbeStreamDurationAsync(
127+
ffmpegPath, outputPath, "v:0", CancellationToken.None);
128+
Assert.Null(video);
129+
}
130+
finally
131+
{
132+
TryDelete(outputPath);
133+
}
134+
}
135+
136+
[Fact]
137+
public async Task ProbeStreamDuration_ReturnsNull_WhenFfprobeNotAlongsideFfmpeg()
138+
{
139+
// Synthesize a directory that has ffmpeg.exe but not ffprobe.exe.
140+
// TryResolveFfprobePath looks adjacent to the supplied ffmpeg path;
141+
// when it returns null the probe must surface as null (not throw,
142+
// not silently fall through to a stale binary on PATH).
143+
string isolated = Path.Combine(Path.GetTempPath(),
144+
"wkvrc-noffprobe-" + Guid.NewGuid().ToString("N"));
145+
Directory.CreateDirectory(isolated);
146+
string fakeFfmpeg = Path.Combine(isolated, "ffmpeg.exe");
147+
File.WriteAllBytes(fakeFfmpeg, new byte[] { 0x4D, 0x5A });
148+
try
149+
{
150+
double? video = await HelperLeaseWorker.ProbeStreamDurationAsync(
151+
fakeFfmpeg, "anything.ts", "v:0", CancellationToken.None);
152+
Assert.Null(video);
153+
}
154+
finally
155+
{
156+
try { Directory.Delete(isolated, recursive: true); } catch { }
157+
}
158+
}
159+
160+
private static bool BundledFfmpegAvailable(out string ffmpegPath, out string ffprobePath)
161+
{
162+
foreach (string root in CandidateRoots())
163+
{
164+
string toolsDir = Path.Combine(root, "dist", "tools");
165+
string mpeg = Path.Combine(toolsDir, "ffmpeg.exe");
166+
string probe = Path.Combine(toolsDir, "ffprobe.exe");
167+
if (File.Exists(mpeg) && File.Exists(probe))
168+
{
169+
ffmpegPath = mpeg;
170+
ffprobePath = probe;
171+
return true;
172+
}
173+
}
174+
ffmpegPath = "";
175+
ffprobePath = "";
176+
return false;
177+
}
178+
179+
private static IEnumerable<string> CandidateRoots()
180+
{
181+
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
182+
string? d = AppContext.BaseDirectory;
183+
while (!string.IsNullOrEmpty(d))
184+
{
185+
if (seen.Add(d)) yield return d;
186+
d = Directory.GetParent(d)?.FullName;
187+
}
188+
}
189+
190+
private static string NewTempTsPath() =>
191+
Path.Combine(Path.GetTempPath(), "wkvrc-probe-" + Guid.NewGuid().ToString("N") + ".ts");
192+
193+
private static void TryDelete(string p)
194+
{
195+
try { if (File.Exists(p)) File.Delete(p); } catch { }
196+
}
197+
198+
// testsrc2 + sine, libx264 + aac, plain mpegts -- the muxer tags
199+
// per-stream duration in this shape.
200+
private static string[] BuildPlainEncodeArgs(string outputPath, int durationSec) => new[]
201+
{
202+
"-hide_banner", "-nostdin", "-y", "-loglevel", "error",
203+
"-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=" + durationSec,
204+
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=" + durationSec,
205+
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
206+
"-pix_fmt", "yuv420p", "-g", "60",
207+
"-c:a", "aac", "-b:a", "128k", "-ac", "2",
208+
"-t", durationSec.ToString(System.Globalization.CultureInfo.InvariantCulture),
209+
"-f", "mpegts", outputPath,
210+
};
211+
212+
// Mirrors the helper's encode shape: -output_ts_offset + -avoid_negative_ts disabled.
213+
private static string[] BuildHelperShapedEncodeArgs(
214+
string outputPath, double startOffsetSec, int durationSec) => new[]
215+
{
216+
"-hide_banner", "-nostdin", "-y", "-loglevel", "error",
217+
"-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=" + durationSec,
218+
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=" + durationSec,
219+
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
220+
"-pix_fmt", "yuv420p", "-g", "60",
221+
"-c:a", "aac", "-b:a", "128k", "-ac", "2",
222+
"-t", durationSec.ToString(System.Globalization.CultureInfo.InvariantCulture),
223+
"-output_ts_offset", startOffsetSec.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture),
224+
"-avoid_negative_ts", "disabled",
225+
"-f", "mpegts", outputPath,
226+
};
227+
228+
private static string[] BuildVideoOnlyEncodeArgs(string outputPath, int durationSec) => new[]
229+
{
230+
"-hide_banner", "-nostdin", "-y", "-loglevel", "error",
231+
"-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=" + durationSec,
232+
"-c:v", "libx264", "-preset", "ultrafast",
233+
"-pix_fmt", "yuv420p", "-g", "60",
234+
"-an",
235+
"-t", durationSec.ToString(System.Globalization.CultureInfo.InvariantCulture),
236+
"-f", "mpegts", outputPath,
237+
};
238+
239+
private static async Task RunFfmpegAsync(string ffmpegPath, string[] args)
240+
{
241+
var psi = new ProcessStartInfo
242+
{
243+
FileName = ffmpegPath,
244+
UseShellExecute = false,
245+
RedirectStandardOutput = true,
246+
RedirectStandardError = true,
247+
CreateNoWindow = true,
248+
};
249+
foreach (string a in args) psi.ArgumentList.Add(a);
250+
using var p = new Process { StartInfo = psi };
251+
p.Start();
252+
var stderrTask = p.StandardError.ReadToEndAsync();
253+
var stdoutTask = p.StandardOutput.ReadToEndAsync();
254+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
255+
await p.WaitForExitAsync(cts.Token);
256+
string stderr = await stderrTask;
257+
await stdoutTask;
258+
if (p.ExitCode != 0)
259+
throw new InvalidOperationException(
260+
"ffmpeg exit " + p.ExitCode + ": " + stderr);
261+
}
262+
}

0 commit comments

Comments
 (0)