From 9044ca5f2247b78a0c6cbfeea8d32c8f9b537ced Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:42:15 +0800 Subject: [PATCH 1/2] fix(skills): handle HDR video outro inputs --- skills/pocketjs-video-outro/SKILL.md | 15 +++- .../scripts/make-outro.ts | 71 +++++++++++++++---- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/skills/pocketjs-video-outro/SKILL.md b/skills/pocketjs-video-outro/SKILL.md index 38d437c..17994b4 100644 --- a/skills/pocketjs-video-outro/SKILL.md +++ b/skills/pocketjs-video-outro/SKILL.md @@ -23,10 +23,14 @@ Design choices baked into the pipeline: (~20-30px, ease-out cubic), ~0.35s apart, then holds. - **Audio is the source's, never synthesized.** The card is silent; the original track is preserved and gently faded out under the transition (no voiceover). +- **Shareable SDR output.** HLG/PQ phone footage is perceptually tone-mapped to + BT.709 before compositing, and the browser-rendered sRGB card is converted into + the same color space. This avoids mixing an SDR card directly into HDR code values. ## Requirements -- `ffmpeg` / `ffprobe` on PATH. +- `ffmpeg` / `ffprobe` on PATH. HDR inputs require FFmpeg 8+ for swscale's + transfer/primaries conversion and perceptual tone mapping; SDR inputs do not. - A Chromium-family browser (Google Chrome, Chromium, Edge, or Brave) — used only to screenshot the card layers. The script auto-detects it. @@ -49,6 +53,9 @@ whole point; always look): V=~/Downloads/clip_outro.mp4 ffmpeg -y -sseof -0.6 -i "$V" -frames:v 1 /tmp/card.png # final card ffmpeg -nostats -sseof -2 -i "$V" -af volumedetect -f null - 2>&1 | grep mean_volume +ffprobe -v error -select_streams v:0 \ + -show_entries stream=color_range,color_space,color_transfer,color_primaries \ + -of default=nw=1 "$V" # HDR inputs must finish as tv + bt709/bt709/bt709 ``` ## Options @@ -68,6 +75,9 @@ ffmpeg -nostats -sseof -2 -i "$V" -af volumedetect -f null - 2>&1 | grep mean_vo - Probes width/height/fps/duration; the card is rendered at the source's native resolution and re-timed to its fps, so the crossfade is seamless. +- **Color:** SDR inputs keep the existing path. HLG and PQ inputs (including the + HLG base layer in iPhone Dolby Vision clips) are tone-mapped to 8-bit BT.709 SDR; + Dolby Vision metadata is intentionally not carried into the shareable H.264 file. - **Type scales** with `scale = min(W,H)/1080`, so 720p, 1080p, and 4K all look proportional. On portrait/narrow frames the tagline wraps to two lines (verified). - **Audio:** maps the source's *first* audio stream (`0:a:0`) and downmixes to @@ -99,5 +109,8 @@ ffmpeg -nostats -sseof -2 -i "$V" -af volumedetect -f null - 2>&1 | grep mean_vo 4:4:4 or non-faststart MP4s. - `xfade` needs both sides normalized to identical size/fps/sar/pix_fmt; the graph does this. If you feed a variable-frame-rate capture, the `fps` filter conforms it. +- Do not remove the explicit sRGB-to-BT.709 conversion from the card layers when + changing HDR handling. Retagging Chrome's SDR PNG values as HLG/PQ is not a valid + color conversion and makes the card shift on an HDR-aware display. - The tagline on a very wide single line can approach the frame edge; it has `max-width: 92vw` and will wrap before overflowing. diff --git a/skills/pocketjs-video-outro/scripts/make-outro.ts b/skills/pocketjs-video-outro/scripts/make-outro.ts index 21242b0..e65d767 100755 --- a/skills/pocketjs-video-outro/scripts/make-outro.ts +++ b/skills/pocketjs-video-outro/scripts/make-outro.ts @@ -5,7 +5,8 @@ // Chrome, then composites it onto the input with a crossfade and a staggered text // entrance (logo -> tagline -> url, each fades in and eases up). The input's primary // audio track is preserved and gently faded out under the card; the card itself is -// silent (no voiceover). +// silent (no voiceover). HLG/PQ sources are tone-mapped to BT.709 SDR so the SDR +// browser card and source share one color space in the final H.264 file. // // Usage: // bun skills/pocketjs-video-outro/scripts/make-outro.ts -i input.mov [-o out.mp4] @@ -117,17 +118,24 @@ async function findChrome(): Promise { async function probe(input: string) { const one = async (args: string[]) => (await $`ffprobe ${args}`.quiet().text()).trim(); - const w = Number((await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width", "-of", "csv=p=0", input])).split("\n")[0]); - const h = Number((await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=height", "-of", "csv=p=0", input])).split("\n")[0]); - const rfr = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "csv=p=0", input])).split("\n")[0]; - const dur = Number((await one(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", input])).split("\n")[0]); - const aRaw = await one(["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", input]); + // CSV gains a trailing empty field for side-data-heavy iPhone HDR streams, + // turning values such as `1920` into `1920,`. Keep scalar probes plain. + const plainOutput = "default=nw=1:nk=1"; + const w = Number((await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width", "-of", plainOutput, input])).split("\n")[0]); + const h = Number((await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=height", "-of", plainOutput, input])).split("\n")[0]); + const rfr = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", plainOutput, input])).split("\n")[0]; + const colorSpace = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_space", "-of", plainOutput, input])).split("\n")[0]; + const colorTransfer = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_transfer", "-of", plainOutput, input])).split("\n")[0]; + const colorPrimaries = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_primaries", "-of", plainOutput, input])).split("\n")[0]; + const colorRange = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_range", "-of", plainOutput, input])).split("\n")[0]; + const dur = Number((await one(["-v", "error", "-show_entries", "format=duration", "-of", plainOutput, input])).split("\n")[0]); + const aRaw = await one(["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", plainOutput, input]); const audioStreams = aRaw ? aRaw.split("\n").filter(Boolean).length : 0; const [num, den] = rfr.split("/"); const fps = den && Number(den) > 0 ? Number(num) / Number(den) : Number(rfr); if (!w || !h || !dur) throw new Error("could not probe input width/height/duration"); - return { w, h, fps, dur, audioStreams }; + return { w, h, fps, dur, audioStreams, colorSpace, colorTransfer, colorPrimaries, colorRange }; } async function shotLayer(chrome: string, layer: string, out: string, w: number, h: number, params: URLSearchParams) { @@ -141,6 +149,15 @@ function round(n: number): number { return Math.round(n); } +const SWSCALE_MATRICES = ["bt601", "bt470", "smpte170m", "bt470bg", "bt709", "fcc", "smpte240m", "bt2020", "bt2020nc"]; +const SWSCALE_PRIMARIES = ["bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "film", "bt2020", "smpte428", "smpte431", "smpte432", "jedec-p22", "ebu3213"]; + +function hdrColorValue(value: string, supported: readonly string[], fallback: string, field: string): string { + if (!value || value === "unknown" || value === "unspecified" || value === "reserved") return fallback; + if (!supported.includes(value)) throw new Error(`unsupported HDR ${field}: ${value}`); + return value; +} + async function main() { const a = parseArgs(process.argv.slice(2)); if (!existsSync(HTML)) throw new Error(`template missing: ${HTML}`); @@ -148,7 +165,20 @@ async function main() { await $`command -v ffprobe`.quiet().then(undefined, () => { throw new Error("ffprobe not found on PATH"); }); const chrome = await findChrome(); - const { w, h, fps, dur, audioStreams } = await probe(a.input); + const { w, h, fps, dur, audioStreams, colorSpace, colorTransfer, colorPrimaries, colorRange } = await probe(a.input); + const isHdr = colorTransfer === "arib-std-b67" || colorTransfer === "smpte2084"; + const hdrColorSpace = isHdr + ? hdrColorValue(colorSpace, SWSCALE_MATRICES, "bt2020nc", "matrix") + : ""; + const hdrPrimaries = isHdr + ? hdrColorValue(colorPrimaries, SWSCALE_PRIMARIES, "bt2020", "primaries") + : ""; + if (isHdr) { + const scaleHelp = await $`ffmpeg -hide_banner -h filter=scale`.quiet().text(); + if (!scaleHelp.includes("out_transfer") || !scaleHelp.includes("intent")) { + throw new Error("HDR input requires an ffmpeg build with swscale tone mapping support (FFmpeg 8 or newer)"); + } + } // type tracks resolution across landscape & portrait const scale = Math.min(w, h) / 1080; @@ -168,6 +198,7 @@ async function main() { const u0 = t0 + 0.6; console.error(`input : ${w}x${h} @ ${fps.toFixed(3)}fps dur=${dur}s audio-streams=${audioStreams}`); + console.error(`color : ${[colorSpace, colorTransfer, colorPrimaries, colorRange].filter(Boolean).join("/") || "unspecified"}${isHdr ? " -> bt709 SDR" : ""}`); console.error(`card : scale=${scale.toFixed(4)} outro=${a.outro}s xfade=${a.xfade}s (offset=${offset.toFixed(3)}s)`); console.error(`output: ${a.output}`); @@ -184,16 +215,25 @@ async function main() { console.error(`rendered card layers (${w}x${h})`); const f = (n: number) => n.toFixed(2); + const bt709 = "setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709"; + const colorFlags = "lanczos+accurate_rnd+full_chroma_int"; + const cardColor = isHdr + ? `setparams=range=pc:color_primaries=bt709:color_trc=iec61966-2-1:colorspace=gbr,scale=${w}:${h}:in_range=pc:out_range=tv:in_primaries=bt709:out_primaries=bt709:in_transfer=iec61966-2-1:out_transfer=bt709:out_color_matrix=bt709:intent=perceptual:flags=${colorFlags},` + : `scale=${w}:${h},`; + const mainColor = isHdr + ? `scale=${w}:${h}:in_color_matrix=${hdrColorSpace}:out_color_matrix=bt709:in_range=${colorRange === "pc" ? "pc" : "tv"}:out_range=tv:in_primaries=${hdrPrimaries}:out_primaries=bt709:in_transfer=${colorTransfer}:out_transfer=bt709:intent=perceptual:flags=${colorFlags},setsar=1,format=yuv420p,${bt709}` + : `scale=${w}:${h},setsar=1,format=yuv420p`; + const outputColor = isHdr ? `,${bt709}` : ""; const graphLines = [ - `[1:v]scale=${w}:${h},setsar=1,fps=${fps},format=yuv420p,setpts=PTS-STARTPTS[bg];`, - `[2:v]fps=${fps},format=yuva420p,fade=t=in:st=${f(l0)}:d=0.60:alpha=1,setpts=PTS-STARTPTS[lg];`, - `[3:v]fps=${fps},format=yuva420p,fade=t=in:st=${f(t0)}:d=0.60:alpha=1,setpts=PTS-STARTPTS[tg];`, - `[4:v]fps=${fps},format=yuva420p,fade=t=in:st=${f(u0)}:d=0.50:alpha=1,setpts=PTS-STARTPTS[ur];`, + `[1:v]${cardColor}setsar=1,fps=${fps},format=yuv420p${outputColor},setpts=PTS-STARTPTS[bg];`, + `[2:v]${cardColor}fps=${fps},format=yuva420p${outputColor},fade=t=in:st=${f(l0)}:d=0.60:alpha=1,setpts=PTS-STARTPTS[lg];`, + `[3:v]${cardColor}fps=${fps},format=yuva420p${outputColor},fade=t=in:st=${f(t0)}:d=0.60:alpha=1,setpts=PTS-STARTPTS[tg];`, + `[4:v]${cardColor}fps=${fps},format=yuva420p${outputColor},fade=t=in:st=${f(u0)}:d=0.50:alpha=1,setpts=PTS-STARTPTS[ur];`, `[bg][lg]overlay=x=0:y='${slideL}*pow(1-clip((t-${f(l0)})/0.60,0,1),3)'[o1];`, `[o1][tg]overlay=x=0:y='${slideT}*pow(1-clip((t-${f(t0)})/0.60,0,1),3)'[o2];`, - `[o2][ur]overlay=x=0:y='${slideU}*pow(1-clip((t-${f(u0)})/0.50,0,1),3)',format=yuv420p,setpts=PTS-STARTPTS[outro];`, - `[0:v]fps=${fps},scale=${w}:${h},setsar=1,format=yuv420p,setpts=PTS-STARTPTS[main];`, - `[main][outro]xfade=transition=fade:duration=${a.xfade}:offset=${offset.toFixed(3)},format=yuv420p[v];`, + `[o2][ur]overlay=x=0:y='${slideU}*pow(1-clip((t-${f(u0)})/0.50,0,1),3)',format=yuv420p${outputColor},setpts=PTS-STARTPTS[outro];`, + `[0:v]fps=${fps},${mainColor},setpts=PTS-STARTPTS[main];`, + `[main][outro]xfade=transition=fade:duration=${a.xfade}:offset=${offset.toFixed(3)},format=yuv420p${outputColor}[v];`, ]; const maps: string[] = ["-map", "[v]"]; @@ -222,6 +262,7 @@ async function main() { "-filter_complex_script", graphPath, ...maps, "-c:v", "libx264", "-profile:v", "high", "-crf", String(a.crf), "-preset", a.preset, "-pix_fmt", "yuv420p", + ...(isHdr ? ["-color_range", "tv", "-colorspace", "bt709", "-color_trc", "bt709", "-color_primaries", "bt709"] : []), "-movflags", "+faststart", "-shortest", a.output, ]; From 82c4692bbb3ebb6692ffb1924b43b4ca4920c74a Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:47:09 +0800 Subject: [PATCH 2/2] fix(skills): parse outro video metadata as JSON --- package.json | 2 +- .../scripts/make-outro.ts | 84 +++++++++++++------ test/video-outro.test.ts | 59 +++++++++++++ 3 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 test/video-outro.test.ts diff --git a/package.json b/package.json index 0625afd..3290d3e 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "serve": "bun host-web/serve.ts", "golden": "bun test/golden.ts", "e2e": "bun test/e2e-ppsspp.ts", - "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test test/release-check.test.ts test/release-notes.test.ts test/platform-contracts.test.ts test/host-build-inputs.test.ts test/platform-runtime.test.ts test/app-check.test.ts test/font-bake.test.ts test/touch.test.ts test/vita-package.test.ts test/psp-toolchain.test.ts test/cli.test.ts test/npm-package.test.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/cursor.test.ts test/action-handler-vue-vapor.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts && bun scripts/build.ts im-main >/dev/null && bun test --conditions=browser test/im-sim.test.ts", + "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test test/release-check.test.ts test/release-notes.test.ts test/platform-contracts.test.ts test/host-build-inputs.test.ts test/platform-runtime.test.ts test/app-check.test.ts test/font-bake.test.ts test/touch.test.ts test/vita-package.test.ts test/psp-toolchain.test.ts test/cli.test.ts test/npm-package.test.ts test/video-outro.test.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/cursor.test.ts test/action-handler-vue-vapor.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts && bun scripts/build.ts im-main >/dev/null && bun test --conditions=browser test/im-sim.test.ts", "tape": "bun scripts/tape.ts", "tape:check": "bun scripts/tape.ts replay hero-main test/tapes/hero-main.tape.json --assert test/tapes/hero-main.hashes.json", "devtools": "bun scripts/devtools.ts", diff --git a/skills/pocketjs-video-outro/scripts/make-outro.ts b/skills/pocketjs-video-outro/scripts/make-outro.ts index e65d767..286c07d 100755 --- a/skills/pocketjs-video-outro/scripts/make-outro.ts +++ b/skills/pocketjs-video-outro/scripts/make-outro.ts @@ -116,28 +116,62 @@ async function findChrome(): Promise { throw new Error("no Chromium-family browser found (Chrome/Chromium/Edge/Brave) for rendering"); } -async function probe(input: string) { - const one = async (args: string[]) => (await $`ffprobe ${args}`.quiet().text()).trim(); - // CSV gains a trailing empty field for side-data-heavy iPhone HDR streams, - // turning values such as `1920` into `1920,`. Keep scalar probes plain. - const plainOutput = "default=nw=1:nk=1"; - const w = Number((await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width", "-of", plainOutput, input])).split("\n")[0]); - const h = Number((await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=height", "-of", plainOutput, input])).split("\n")[0]); - const rfr = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", plainOutput, input])).split("\n")[0]; - const colorSpace = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_space", "-of", plainOutput, input])).split("\n")[0]; - const colorTransfer = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_transfer", "-of", plainOutput, input])).split("\n")[0]; - const colorPrimaries = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_primaries", "-of", plainOutput, input])).split("\n")[0]; - const colorRange = (await one(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_range", "-of", plainOutput, input])).split("\n")[0]; - const dur = Number((await one(["-v", "error", "-show_entries", "format=duration", "-of", plainOutput, input])).split("\n")[0]); - const aRaw = await one(["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", plainOutput, input]); - const audioStreams = aRaw ? aRaw.split("\n").filter(Boolean).length : 0; - - const [num, den] = rfr.split("/"); - const fps = den && Number(den) > 0 ? Number(num) / Number(den) : Number(rfr); - if (!w || !h || !dur) throw new Error("could not probe input width/height/duration"); +type ProbeStream = { + codec_type?: string; + width?: number | string; + height?: number | string; + r_frame_rate?: string; + avg_frame_rate?: string; + color_space?: string; + color_transfer?: string; + color_primaries?: string; + color_range?: string; +}; + +type ProbeOutput = { + streams?: ProbeStream[]; + format?: { duration?: number | string }; +}; + +function frameRate(value: string | undefined): number { + if (!value) return 0; + const [num, den] = value.split("/").map(Number); + if (Number.isFinite(num) && Number.isFinite(den) && den > 0) return num / den; + const fps = Number(value); + return Number.isFinite(fps) ? fps : 0; +} + +export function parseProbeOutput(raw: string) { + let output: ProbeOutput; + try { + output = JSON.parse(raw) as ProbeOutput; + } catch { + throw new Error("could not parse ffprobe output"); + } + + const video = output.streams?.find((stream) => stream.codec_type === "video"); + const w = Number(video?.width); + const h = Number(video?.height); + const fps = frameRate(video?.r_frame_rate) || frameRate(video?.avg_frame_rate); + const dur = Number(output.format?.duration); + const audioStreams = output.streams?.filter((stream) => stream.codec_type === "audio").length ?? 0; + const colorSpace = video?.color_space ?? ""; + const colorTransfer = video?.color_transfer ?? ""; + const colorPrimaries = video?.color_primaries ?? ""; + const colorRange = video?.color_range ?? ""; + + if (![w, h, fps, dur].every((value) => Number.isFinite(value) && value > 0)) { + throw new Error("could not probe input width/height/fps/duration"); + } return { w, h, fps, dur, audioStreams, colorSpace, colorTransfer, colorPrimaries, colorRange }; } +async function probe(input: string) { + const entries = "stream=codec_type,width,height,r_frame_rate,avg_frame_rate,color_space,color_transfer,color_primaries,color_range:format=duration"; + const raw = await $`ffprobe -v error -show_entries ${entries} -of json ${input}`.quiet().text(); + return parseProbeOutput(raw); +} + async function shotLayer(chrome: string, layer: string, out: string, w: number, h: number, params: URLSearchParams) { const u = pathToFileURL(HTML); u.search = new URLSearchParams({ layer, ...Object.fromEntries(params) }).toString(); @@ -276,9 +310,11 @@ async function main() { } } -try { - await main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } } diff --git a/test/video-outro.test.ts b/test/video-outro.test.ts new file mode 100644 index 0000000..0a62bad --- /dev/null +++ b/test/video-outro.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { parseProbeOutput } from "../skills/pocketjs-video-outro/scripts/make-outro.ts"; + +describe("parseProbeOutput", () => { + test("reads an iPhone HDR MOV with audio and data streams", () => { + const result = parseProbeOutput(JSON.stringify({ + streams: [ + { + codec_type: "video", + width: 1920, + height: 1080, + r_frame_rate: "30/1", + avg_frame_rate: "30/1", + color_space: "bt2020nc", + color_transfer: "arib-std-b67", + color_primaries: "bt2020", + color_range: "tv", + side_data_list: [ + { side_data_type: "DOVI configuration record" }, + { side_data_type: "Ambient viewing environment" }, + ], + }, + { codec_type: "audio" }, + { codec_type: "audio" }, + { codec_type: "data" }, + ], + format: { duration: "77.200000" }, + })); + + expect(result).toEqual({ + w: 1920, + h: 1080, + fps: 30, + dur: 77.2, + audioStreams: 2, + colorSpace: "bt2020nc", + colorTransfer: "arib-std-b67", + colorPrimaries: "bt2020", + colorRange: "tv", + }); + }); + + test("falls back to the average frame rate", () => { + const result = parseProbeOutput(JSON.stringify({ + streams: [ + { codec_type: "video", width: "1080", height: "1920", r_frame_rate: "0/0", avg_frame_rate: "30000/1001" }, + ], + format: { duration: 12.5 }, + })); + + expect(result.fps).toBeCloseTo(29.97, 2); + }); + + test("rejects incomplete metadata", () => { + expect(() => parseProbeOutput('{"streams":[],"format":{}}')).toThrow( + "could not probe input width/height/fps/duration", + ); + }); +});