|
| 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