fix(cli): a long video copy reports progress, resumes in 15 s pieces and stops only when stalled (#6307)

* fix(cli): a long video copy reports progress, resumes in 15 s pieces and stops only when stalled

* perf(core): a video copy's size box follows the stage within a fifth, not a half

* docs(cli): shorten two comments to the line limit

* fix(cli): the copy heartbeat drops a finished copy; size budgets follow the stall and progress code

* chore: size budgets rise by this change's measured bytes
This commit is contained in:
Miguel Ángel
2026-10-25 13:53:03 +00:00
committed by GitHub
parent 89af290808
commit ada9932d4e
12 changed files with 512 additions and 79 deletions
+6 -4
View File
@@ -1067,10 +1067,12 @@ cover every finding from the full check.
| `--prepare-media` | Only make the browser-playable video copies, then exit: no browser, no report. `--json` lists each file as `ready`, `cached` or `failed`; exit 1 if any failed |
A video Chrome cannot decode (HEVC, ProRes) is copied once, and the first check waits
for it. A source longer than a few minutes is copied in one-minute pieces that are kept,
so a copy that is stopped continues from its finished pieces on the next run. For a long
project, run `hyperframes check --prepare-media` first, then the check itself finds every
copy ready.
for it. A source longer than 22 seconds is copied in 15-second pieces that are kept, so a
copy that is stopped continues from its finished pieces on the next run. While it works, the
check prints each copy's progress to stderr every 10 seconds (stdout stays clean for `--json`),
and ends a copy only when it makes no progress for 3 minutes (`HYPERFRAMES_PROXY_STALL_MS`).
For a long project, run `hyperframes check --prepare-media` first, then the check itself finds
every copy ready.
`--caption-zone` and `--frame-check` refuse a ranged check when none of their
configured seek samples falls inside `--range`; a requested gate never silently
+130 -7
View File
@@ -25,6 +25,15 @@ import {
import type { ProjectDir } from "./project.js";
const mocks = vi.hoisted(() => ({
stopListeners: new Set<() => void>(),
proxyProgress: {
feed: undefined as
| undefined
| ((
report: (index: number, progress: { doneSeconds: number; totalSeconds?: number }) => void,
) => void),
},
proxySignal: undefined as AbortSignal | undefined,
bundleWithLocalizedFonts: vi.fn(async () => "<html></html>"),
serverClose: vi.fn(async () => undefined),
resolveProxy:
@@ -56,6 +65,13 @@ const mocks = vi.hoisted(() => ({
>(async () => ({})),
}));
vi.mock("../browser/launch.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../browser/launch.js")>()),
onStopSignal: (listener: () => void) => {
mocks.stopListeners.add(listener);
return () => mocks.stopListeners.delete(listener);
},
}));
vi.mock("./bundleWithLocalizedFonts.js", () => ({
// Keep browser-check unit tests focused on the page driver and audit pipeline.
// The real helper cold-loads the producer/font graph, which is covered by its
@@ -103,16 +119,24 @@ vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({
variant: string;
box?: { width: number; height: number };
}>,
onSettled: () => void = () => {},
) =>
Promise.allSettled(
sources.map(({ sourcePath, variant, box }) =>
onSettled: (index: number) => void = () => {},
onProgress: (
index: number,
progress: { doneSeconds: number; totalSeconds?: number },
) => void = () => {},
signal?: AbortSignal,
) => {
mocks.proxySignal = signal;
mocks.proxyProgress.feed?.(onProgress);
return Promise.allSettled(
sources.map(({ sourcePath, variant, box }, index) =>
(box
? mocks.resolveProxy(projectDir, sourcePath, variant, box)
: mocks.resolveProxy(projectDir, sourcePath, variant)
).finally(onSettled),
).finally(() => onSettled(index)),
),
),
);
},
}));
const PROJECT: ProjectDir = {
@@ -1194,7 +1218,7 @@ describe("preResolveHostileMediaProxies", () => {
await preResolveHostileMediaProxies(projectDir, html);
// The page asks for the quantized stage size, so the copy and its cache check use that box.
const box = { width: 2048, height: 1448 };
const box = { width: 2048, height: 1218 };
expect(mocks.resolveProxy).toHaveBeenCalledWith(
projectDir,
join(projectDir, "clip.mp4"),
@@ -1303,6 +1327,105 @@ describe("preResolveHostileMediaProxies", () => {
expect(stdoutSpy).not.toHaveBeenCalled();
});
it("says how far a copy has got every few seconds, so a slow copy reads as slow", async () => {
vi.useFakeTimers();
try {
const projectDir = mkProjectDir();
mocks.scanProjectMediaCodecMap.mockResolvedValue({
"/clip.mov": {
codecName: "hevc",
browserHostile: true,
representativeMime: null,
hasAlpha: false,
},
});
let finish: (() => void) | undefined;
mocks.resolveProxy.mockImplementation(
() => new Promise<string>((done) => (finish = () => done("/cache/clip.mp4"))),
);
mocks.proxyProgress.feed = (report) => report(0, { doneSeconds: 30, totalSeconds: 120 });
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const pending = preResolveHostileMediaProxies(projectDir, "<html></html>");
await vi.advanceTimersByTimeAsync(10_000);
mocks.proxyProgress.feed = undefined;
finish?.();
await pending;
expect(errorSpy.mock.calls.map(([line]) => line)).toContain(
"[hyperframes] browser-playable copy of clip.mov: 25% (10s so far)",
);
} finally {
mocks.proxyProgress.feed = undefined;
vi.useRealTimers();
}
});
it("stops reporting a copy once it is done, so a finished one is not read as stuck", async () => {
vi.useFakeTimers();
try {
const projectDir = mkProjectDir();
const hostile = {
codecName: "hevc",
browserHostile: true,
representativeMime: null,
hasAlpha: false,
};
mocks.scanProjectMediaCodecMap.mockResolvedValue({ "/a.mov": hostile, "/b.mov": hostile });
let finishB: (() => void) | undefined;
mocks.resolveProxy.mockImplementation((_dir, sourcePath) =>
sourcePath.endsWith("a.mov")
? Promise.resolve("/cache/a.mp4")
: new Promise<string>((done) => (finishB = () => done("/cache/b.mp4"))),
);
mocks.proxyProgress.feed = (report) => {
report(0, { doneSeconds: 118, totalSeconds: 120 });
report(1, { doneSeconds: 30, totalSeconds: 120 });
};
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const pending = preResolveHostileMediaProxies(projectDir, "<html></html>");
await vi.advanceTimersByTimeAsync(10_000);
mocks.proxyProgress.feed = undefined;
finishB?.();
await pending;
const lines = errorSpy.mock.calls.map(([line]) => String(line));
expect(lines).toContain("[hyperframes] browser-playable copy of b.mov: 25% (10s so far)");
expect(lines.some((line) => line.includes("copy of a.mov"))).toBe(false);
} finally {
mocks.proxyProgress.feed = undefined;
vi.useRealTimers();
}
});
it("ends the copies it is making when it is stopped by a signal", async () => {
const projectDir = mkProjectDir();
mocks.scanProjectMediaCodecMap.mockResolvedValue({
"/clip.mov": {
codecName: "hevc",
browserHostile: true,
representativeMime: null,
hasAlpha: false,
},
});
let finish: (() => void) | undefined;
mocks.resolveProxy.mockImplementation(
() => new Promise<string>((done) => (finish = () => done("/cache/clip.mp4"))),
);
vi.spyOn(console, "error").mockImplementation(() => undefined);
const pending = preResolveHostileMediaProxies(projectDir, "<html></html>");
await vi.waitFor(() => expect(mocks.proxySignal).toBeDefined());
expect(mocks.proxySignal!.aborted).toBe(false);
for (const listener of mocks.stopListeners) listener();
expect(mocks.proxySignal!.aborted).toBe(true);
finish?.();
await pending;
expect(mocks.stopListeners.size).toBe(0);
});
it("says it is making copies, and counts them, only when some are not made yet", async () => {
const projectDir = mkProjectDir();
const codec = {
+55 -4
View File
@@ -1,6 +1,6 @@
import type { CheckDeadline } from "./checkDeadline.js";
import { logCheckProgress } from "./checkProgress.js";
import { BrowserCleanupError, closeManagedBrowser } from "../browser/launch.js";
import { BrowserCleanupError, closeManagedBrowser, onStopSignal } from "../browser/launch.js";
import { collectMotionBlurDrafts, dropMotionBlurConsoleEcho } from "./checkMotionBlur.js";
import { checkSnapshotDirectory } from "./checkSnapshotDirectory.js";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
@@ -50,6 +50,7 @@ import {
FfmpegUnavailableError,
getProxyCachePath,
resolveProxies,
type ProxyProgress,
} from "@hyperframes/studio-server/proxy-transcoder";
import { rectToBbox } from "./checkTypes.js";
import type {
@@ -136,6 +137,43 @@ interface FinishedContrast {
bg: string;
}
const COPY_HEARTBEAT_MS = 10_000;
function elapsedText(ms: number): string {
const seconds = Math.round(ms / 1000);
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
/** Says on stderr how far each copy is, so a slow copy reads as slow and a stuck one as stuck. */
function startCopyHeartbeat(
sources: ReadonlyArray<{ file: string }>,
cached: readonly boolean[],
): {
progress: (index: number, progress: ProxyProgress) => void;
settled: (index: number) => void;
stop: () => void;
} {
const startedAt = Date.now();
const latest = new Map<number, ProxyProgress>();
const timer = setInterval(() => {
for (const [index, { doneSeconds, totalSeconds }] of latest) {
if (cached[index]) continue;
const done = totalSeconds
? `${Math.min(100, Math.floor((doneSeconds / totalSeconds) * 100))}%`
: "";
console.error(
`[hyperframes] browser-playable copy of ${sources[index]!.file}: ${done || `${Math.floor(doneSeconds)}s done`} (${elapsedText(Date.now() - startedAt)} so far)`,
);
}
}, COPY_HEARTBEAT_MS);
timer.unref();
return {
progress: (index, progress) => void latest.set(index, progress),
settled: (index) => void latest.delete(index),
stop: () => clearInterval(timer),
};
}
/**
* Awaits the H.264 authoring proxy for every browser-hostile local video
* asset in `html` BEFORE the timed render-ready wait starts. `check`'s
@@ -190,9 +228,22 @@ export async function preResolveHostileMediaProxies(
);
}
let settled = 0;
const results = await resolveProxies(projectDir, sources, () => {
settled += 1;
if (toMake > 0) console.error(`[hyperframes] browser-playable copies: ${settled}/${total}`);
const heartbeat = startCopyHeartbeat(sources, cached);
const stopped = new AbortController();
const forgetStop = onStopSignal(() => stopped.abort());
const results = await resolveProxies(
projectDir,
sources,
(index) => {
settled += 1;
heartbeat.settled(index);
if (toMake > 0) console.error(`[hyperframes] browser-playable copies: ${settled}/${total}`);
},
heartbeat.progress,
stopped.signal,
).finally(() => {
heartbeat.stop();
forgetStop();
});
const failures = sources.flatMap((source, index) => {
const result = results[index]!;
@@ -445,7 +445,7 @@ describe("serveStaticProjectHtml transparent media proxies", () => {
undefined,
true,
expect.any(AbortSignal),
{ width: 2048, height: 1448 },
{ width: 2048, height: 1218 },
);
});
+6
View File
@@ -10,6 +10,12 @@ describe("hdrToSdrToneMapFilter", () => {
expect(hdrToSdrToneMapFilter(PQ, PQ)).toBe(TONE_MAP);
});
it("resizes in the first zscale, before the tone map, when it is given a size", () => {
expect(hdrToSdrToneMapFilter(PQ, PQ, { width: "1920", height: "1080" })).toBe(
"zscale=w=1920:h=1080:t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:r=tv",
);
});
it("sets the probed tags the first frame lacks", () => {
expect(hdrToSdrToneMapFilter(PQ, {})).toBe(
`setparams=colorspace=bt2020nc:color_primaries=bt2020:color_trc=smpte2084,${TONE_MAP}`,
+3 -1
View File
@@ -15,6 +15,7 @@ function known(value: string | undefined): string | undefined {
export function hdrToSdrToneMapFilter(
probed: ToneMapSourceColour,
firstFrame: ToneMapSourceColour,
size?: { width: string; height: string },
): string {
const fill = (key: keyof ToneMapSourceColour, fallback?: string) =>
known(firstFrame[key]) ? undefined : (known(probed[key]) ?? fallback);
@@ -24,7 +25,8 @@ export function hdrToSdrToneMapFilter(
["color_trc", fill("colorTransfer")],
].filter(([, value]) => value);
const setTags = tags.length ? `setparams=${tags.map((tag) => tag.join("=")).join(":")},` : "";
return `${setTags}zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:r=tv`;
const resizeBeforeToneMap = size ? `w=${size.width}:h=${size.height}:` : "";
return `${setTags}zscale=${resizeBeforeToneMap}t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:r=tv`;
}
/** ffmpeg args printing the first shown frame's tags; ffprobe's packet-bounded reads miss edit-list pre-roll. */
+10 -1
View File
@@ -3,10 +3,19 @@ import { parsePreviewProxyBox, quantizePreviewProxyBox } from "./previewProxyBox
describe("preview proxy box", () => {
it("rounds each side up to the next rung", () => {
expect(quantizePreviewProxyBox(1080, 1920)).toEqual({ width: 1448, height: 2048 });
expect(quantizePreviewProxyBox(1080, 1920)).toEqual({ width: 1218, height: 2048 });
expect(quantizePreviewProxyBox(1, 256)).toEqual({ width: 256, height: 256 });
});
it("keeps a 1080p stage's box within a fifth of the stage on each side", () => {
expect(quantizePreviewProxyBox(1920, 1080)).toEqual({ width: 2048, height: 1218 });
for (let side = 256; side <= 8192; side += 7) {
const box = quantizePreviewProxyBox(side, side)!;
expect(box.width).toBeGreaterThanOrEqual(side);
expect(box.width / side).toBeLessThan(1.2);
}
});
it("has no bound for an empty box or one past the largest rung", () => {
expect(quantizePreviewProxyBox(0, 1920)).toBeNull();
expect(quantizePreviewProxyBox(8193, 100)).toBeNull();
+5 -2
View File
@@ -3,8 +3,11 @@ export const PREVIEW_PROXY_BOX_PARAM = "hf-proxy-box";
export type PreviewProxyBox = { width: number; height: number };
// 256 * 2^(k/2), even: nearby boxes share one cached copy, and a rung overshoots by at most ~41%.
const RUNGS = [256, 362, 512, 724, 1024, 1448, 2048, 2896, 4096, 5792, 8192];
// 256 * 2^(k/4), even: nearby boxes share one cached copy, and a rung overshoots by at most ~19%.
const RUNGS = [
256, 304, 362, 430, 512, 608, 724, 862, 1024, 1218, 1448, 1722, 2048, 2436, 2896, 3444, 4096,
4870, 5792, 6888, 8192,
];
function rungAtLeast(pixels: number): number | null {
if (!(pixels > 0)) return null;
+6 -6
View File
@@ -799,7 +799,7 @@ describe("a copy sized to how large the video shows", () => {
// A 1080x1920 stage at half size on a 2x screen is 1080x1920 device pixels.
setProxyDisplayScale(0.5);
await settle();
expect(boxOf(el)).toBe("1448x2048");
expect(boxOf(el)).toBe("1218x2048");
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe(el.src);
});
@@ -807,7 +807,7 @@ describe("a copy sized to how large the video shows", () => {
const { el, setProxyDisplayScale } = await framedVideo(2160, 3840);
setProxyDisplayScale(0.5);
await settle();
expect(boxOf(el)).toBe("2896x4096");
expect(boxOf(el)).toBe("2436x4096");
});
it("asks for the source-size copy when the host never says", async () => {
@@ -828,12 +828,12 @@ describe("a copy sized to how large the video shows", () => {
setProxyDisplayScale(1);
setProxyDisplayScale(1);
await settle();
expect(boxOf(el)).toBe("2896x4096");
expect(boxOf(el)).toBe("2436x4096");
expect(el.load).toHaveBeenCalledTimes(2);
setProxyDisplayScale(0.25);
await settle();
expect(boxOf(el)).toBe("2896x4096");
expect(boxOf(el)).toBe("2436x4096");
expect(el.load).toHaveBeenCalledTimes(2);
});
@@ -845,7 +845,7 @@ describe("a copy sized to how large the video shows", () => {
setProxyDisplayScale(1);
setProxyDisplayScale(2);
await settle();
expect(boxOf(el)).toBe("5792x8192");
expect(boxOf(el)).toBe("4870x8192");
expect(el.load).toHaveBeenCalledTimes(2);
});
@@ -866,6 +866,6 @@ describe("a copy sized to how large the video shows", () => {
setProxyDisplayScale(1);
made = true;
await vi.advanceTimersByTimeAsync(2_000);
expect(boxOf(el)).toBe("2896x4096");
expect(boxOf(el)).toBe("2436x4096");
});
});
@@ -58,13 +58,20 @@ function meanLuma(path: string, filters = "", inputArgs: string[] = []): number
// this test file and the module are authored together).
const MAX_CONCURRENT = 2;
const MAX_QUEUED = 8;
// Mirrors PROXY_CHUNK_SECONDS in proxyTranscoder.ts.
const PIECE_SECONDS = 15;
type FakeProc = EventEmitter & { stderr: EventEmitter; stdout: EventEmitter };
type FakeProc = EventEmitter & {
stderr: EventEmitter;
stdout: EventEmitter;
kill: (signal?: string) => void;
};
function createFakeProc(): FakeProc {
const proc = new EventEmitter() as FakeProc;
proc.stderr = new EventEmitter();
proc.stdout = new EventEmitter();
proc.kill = vi.fn((signal?: string) => void proc.emit("close", null, signal));
return proc;
}
@@ -166,6 +173,8 @@ async function loadModule(
}
describe("resolveProxy on a long source", () => {
// Three pieces: two full ones and a last that takes the remainder.
const LONG_SECONDS = 3 * PIECE_SECONDS + 5;
const startsOf = (calls: SpawnCall[]): string[] =>
calls
.filter((call) => call.args.includes("-ss"))
@@ -179,7 +188,7 @@ describe("resolveProxy on a long source", () => {
false,
null,
undefined,
200,
LONG_SECONDS,
);
const projectDir = tmpProject();
const sourcePath = join(projectDir, "long.mov");
@@ -193,11 +202,14 @@ describe("resolveProxy on a long source", () => {
await flush(50);
calls[1]!.proc.emit("close", null, "SIGKILL");
expect(await first).toBeInstanceOf(Error);
expect(startsOf(calls)).toEqual(["0", "60"]);
expect(startsOf(calls)).toEqual(["0", String(PIECE_SECONDS)]);
expect(readdirSync(cacheDirectory)).toHaveLength(1);
clearFailedTranscodesForTest();
const second = resolveProxy(projectDir, sourcePath);
const heard: Array<{ doneSeconds: number; totalSeconds?: number }> = [];
const second = resolveProxy(projectDir, sourcePath, "h264", undefined, {
onProgress: (progress) => heard.push(progress),
});
for (let answered = calls.length; ; answered = calls.length) {
await flush(50);
if (calls.length === answered) break;
@@ -206,14 +218,22 @@ describe("resolveProxy on a long source", () => {
expect(await second).toBe(cachePath);
// Only the pieces that were missing were encoded again; the last one runs to the end.
expect(startsOf(calls)).toEqual(["0", "60", "60", "120"]);
expect(startsOf(calls)).toEqual([
"0",
String(PIECE_SECONDS),
String(PIECE_SECONDS),
String(2 * PIECE_SECONDS),
]);
// The retry says at once how much the stopped run left done, then counts on from it.
expect(heard[0]).toEqual({ doneSeconds: PIECE_SECONDS, totalSeconds: LONG_SECONDS });
expect(heard.at(-1)).toEqual({ doneSeconds: LONG_SECONDS, totalSeconds: LONG_SECONDS });
expect(calls.at(-1)!.args).toEqual(expect.arrayContaining(["-f", "concat", "-c", "copy"]));
expect(readdirSync(cacheDirectory)).toEqual([cachePath.split("/").at(-1)]);
});
it("keeps a short source to one pass", async () => {
const { spawn, calls } = createSpawnSpy();
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, false, null, undefined, 80);
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, false, null, undefined, 20);
const projectDir = tmpProject();
const sourcePath = join(projectDir, "short.mov");
writeFileSync(sourcePath, "source-bytes");
@@ -438,6 +458,141 @@ describe("resolveProxy", () => {
await result;
});
it("resizes HDR input to the box before the tone map, so the map runs on the smaller frame", async () => {
const { spawn, calls } = createSpawnSpy();
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, true);
const projectDir = tmpProject();
const sourcePath = join(projectDir, "hdr.mov");
writeFileSync(sourcePath, "source-bytes");
const result = resolveProxy(projectDir, sourcePath, "h264", { width: 2048, height: 1218 });
await flush();
calls[0]!.proc.stdout.emit(
"data",
Buffer.from(" ..C zscale V->V zimg\n T.C tonemap V->V tone map\n"),
);
calls[0]!.proc.emit("close", 0);
await flush();
const filter = calls[1]!.args[calls[1]!.args.indexOf("-vf") + 1]!;
expect(filter.indexOf("zscale=w=")).toBeGreaterThanOrEqual(0);
expect(filter.indexOf("zscale=w=")).toBeLessThan(filter.indexOf("tonemap="));
expect(filter).toContain("2048/iw");
succeed(calls[1]!);
await result;
});
it("tells a listener how far the encode has got, from ffmpeg's own progress lines", async () => {
const { spawn, calls } = createSpawnSpy();
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, false, null, undefined, 20);
const projectDir = tmpProject();
const sourcePath = join(projectDir, "progress.mov");
writeFileSync(sourcePath, "source-bytes");
const heard: Array<{ doneSeconds: number; totalSeconds?: number }> = [];
const result = resolveProxy(projectDir, sourcePath, "h264", undefined, {
onProgress: (progress) => heard.push(progress),
});
await flush();
expect(calls[0]!.args).toEqual(expect.arrayContaining(["-progress", "pipe:1"]));
calls[0]!.proc.stdout.emit("data", Buffer.from("frame=10\nout_time_us=12000000\nprogre"));
calls[0]!.proc.stdout.emit(
"data",
Buffer.from("ss=continue\nout_time_us=18500000\nprogress=continue\n"),
);
expect(heard).toEqual([
{ doneSeconds: 12, totalSeconds: 20 },
{ doneSeconds: 18.5, totalSeconds: 20 },
]);
succeed(calls[0]!);
await result;
});
describe("a copy that stops making progress", () => {
afterEach(() => vi.useRealTimers());
it("is ended once it makes no progress for the stall window, not before", async () => {
vi.useFakeTimers();
const { spawn, calls } = createSpawnSpy();
const { resolveProxy, TRANSCODE_STALL_MS } = await loadModule(
spawn,
FFMPEG_PATH,
false,
null,
undefined,
80,
);
const projectDir = tmpProject();
const sourcePath = join(projectDir, "hung.mov");
writeFileSync(sourcePath, "source-bytes");
const result = resolveProxy(projectDir, sourcePath).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(TRANSCODE_STALL_MS - 1);
expect(calls[0]!.proc.kill).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(calls[0]!.proc.kill).toHaveBeenCalledWith("SIGKILL");
expect(await result).toEqual(
expect.objectContaining({ message: expect.stringMatching(/no progress/) }),
);
});
it("keeps going past the old fifteen minute cap while it moves on", async () => {
vi.useFakeTimers();
const { spawn, calls } = createSpawnSpy();
const { resolveProxy, TRANSCODE_STALL_MS } = await loadModule(
spawn,
FFMPEG_PATH,
false,
null,
undefined,
20,
);
const projectDir = tmpProject();
const sourcePath = join(projectDir, "slow.mov");
writeFileSync(sourcePath, "source-bytes");
const result = resolveProxy(projectDir, sourcePath);
await vi.advanceTimersByTimeAsync(0);
const step = TRANSCODE_STALL_MS / 2;
for (let at = 1; at <= (20 * 60_000) / step; at++) {
await vi.advanceTimersByTimeAsync(step);
calls[0]!.proc.stdout.emit("data", Buffer.from(`out_time_us=${at * 1_000_000}\n`));
}
expect(calls[0]!.proc.kill).not.toHaveBeenCalled();
succeed(calls[0]!);
await result;
});
});
it("hands the abort signal of a batch to its copies, so a stopped check ends them", async () => {
const { spawn, calls } = createSpawnSpy();
const { resolveProxies } = await loadModule(spawn, FFMPEG_PATH, false, null, undefined, 80);
const projectDir = tmpProject();
const sourcePath = join(projectDir, "stopped.mov");
writeFileSync(sourcePath, "source-bytes");
const stopped = new AbortController();
const results = resolveProxies(
projectDir,
[{ sourcePath, variant: "h264" }],
undefined,
undefined,
stopped.signal,
);
await flush();
expect(calls[0]!.options?.signal?.aborted).toBe(false);
stopped.abort();
await flush();
expect(calls[0]!.options?.signal?.aborted).toBe(true);
calls[0]!.proc.emit("close", null, "SIGKILL");
expect((await results)[0]!.status).toBe("rejected");
});
it("preserves alpha on HDR-tagged VP8 proxies by bypassing the opaque tonemap chain", async () => {
const { spawn, calls } = createSpawnSpy();
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, true);
@@ -35,7 +35,7 @@ import { mkdirWithinProject, realpath, realProjectRoot } from "./safePath.js";
* dropped from the queue or its ffmpeg is killed.
*/
export const PROXY_PARAMS_VERSION = "v5";
export const PROXY_PARAMS_VERSION = "v6";
export const CACHE_DIR_NAME = ".transcode-cache";
@@ -53,9 +53,15 @@ const MAX_CONCURRENT_TRANSCODES = boundedEnvInteger("HYPERFRAMES_PROXY_MAX_CONCU
const MAX_QUEUED_TRANSCODES = boundedEnvInteger("HYPERFRAMES_PROXY_MAX_QUEUE", 8, 0, 256);
const STDERR_TAIL_MAX_CHARS = 4000;
export const TRANSCODE_TIMEOUT_MS = 15 * 60 * 1000;
/** A longer source is copied in pieces this long; a stopped copy keeps its finished pieces. */
const PROXY_CHUNK_SECONDS = 60;
/** A copy that makes no progress this long is ended: a hung encode, not a slow one. */
export const TRANSCODE_STALL_MS = boundedEnvInteger(
"HYPERFRAMES_PROXY_STALL_MS",
3 * 60 * 1000,
10_000,
60 * 60 * 1000,
);
/** A longer source is copied in pieces this long; a stopped copy keeps its finished pieces and loses at most one. */
export const PROXY_CHUNK_SECONDS = 15;
const CHUNK_PREFIX = ".part-";
const CONCAT_TIMEOUT_MS = 2 * 60 * 1000;
const FAILURE_CACHE_TTL_MS = 60 * 1000;
@@ -248,6 +254,19 @@ interface ProxyJob {
/** Set while the job waits for a slot. */
queued?: { start: () => void; drop: () => void };
abandoned: AbortController;
progress: Set<(progress: ProxyProgress) => void>;
lastProgress?: ProxyProgress;
}
/** How far a copy has got: seconds of the source done, out of its length when that is known. */
export interface ProxyProgress {
doneSeconds: number;
totalSeconds?: number;
}
function reportProgress(job: ProxyJob, progress: ProxyProgress): void {
job.lastProgress = progress;
for (const listener of job.progress) listener(progress);
}
class ProxyDroppedError extends ProxyTranscodeError {
@@ -411,11 +430,16 @@ export function clearFailedTranscodesForTest(): void {
}
/** Even dimensions; with a box, the source shrinks until its tighter side fills the box, never grows. */
function proxyScaleFilter(box?: PreviewProxyBox): string {
if (!box) return "scale=trunc(iw/2)*2:trunc(ih/2)*2";
function proxyScaleSize(box?: PreviewProxyBox): { width: string; height: string } {
if (!box) return { width: "trunc(iw/2)*2", height: "trunc(ih/2)*2" };
const factor = `min(1\\,max(${box.width}/iw\\,${box.height}/ih))`;
const side = (d: string) => `min(trunc(${d}/2)*2\\,ceil(${d}*${factor}/2)*2)`;
return `scale=${side("iw")}:${side("ih")}`;
return { width: side("iw"), height: side("ih") };
}
function proxyScaleFilter(box?: PreviewProxyBox): string {
const { width, height } = proxyScaleSize(box);
return `scale=${width}:${height}`;
}
const STDERR_ERROR_LINE = /error|invalid|denied|no space|not found|unknown/i;
@@ -448,15 +472,7 @@ function chunkRanges(durationSeconds: number | undefined): ChunkRange[] | null {
}));
}
function ffmpegBudgetError(chunksKept: number, chunkCount: number): ProxyTranscodeError {
return new ProxyTranscodeError(
`ffmpeg ran out of its ${TRANSCODE_TIMEOUT_MS}ms budget with ${chunksKept}/${chunkCount} pieces done; the finished pieces are kept and the next attempt continues from them`,
null,
"",
);
}
type EncodeFn = (outputPath: string, timeoutMs: number, range?: ChunkRange) => Promise<void>;
type EncodeFn = (outputPath: string, range?: ChunkRange) => Promise<void>;
/** Copies a long source in pieces that stay in the cache, so a stopped copy continues from the first missing one. */
async function encodeInChunks(
@@ -465,19 +481,24 @@ async function encodeInChunks(
tempPath: string,
encode: EncodeFn,
concat: (listPath: string, target: string) => Promise<void>,
onDone: (doneSeconds: number) => void = () => {},
): Promise<void> {
const extension = extname(tempPath);
const deadline = Date.now() + TRANSCODE_TIMEOUT_MS;
const pieces = ranges.map((_, index) => `${chunkPrefix}${index}${extension}`);
let doneSeconds = 0;
for (const [index, range] of ranges.entries()) {
if (existsSync(pieces[index]!)) doneSeconds += range.lengthSeconds;
}
if (doneSeconds) onDone(doneSeconds);
for (const [index, range] of ranges.entries()) {
const piece = pieces[index]!;
if (existsSync(piece)) continue;
const timeoutMs = deadline - Date.now();
if (timeoutMs <= 0) throw ffmpegBudgetError(index, ranges.length);
const pieceTemp = join(dirname(piece), `.tmp-${randomUUID()}-${basename(piece)}`);
try {
await encode(pieceTemp, timeoutMs, range);
await encode(pieceTemp, range);
renameSync(pieceTemp, piece);
doneSeconds += range.lengthSeconds;
onDone(doneSeconds);
} finally {
if (existsSync(pieceTemp)) unlinkSync(pieceTemp);
}
@@ -566,7 +587,7 @@ async function buildVideoFilter(
// The tone map ends in RGB; older ffmpeg (seen on 5.1) converts it with BT.601 unless the matrix is named.
return toneMap
? [
hdrToSdrToneMapFilter(metadata.color, firstFrame),
hdrToSdrToneMapFilter(metadata.color, firstFrame, box && proxyScaleSize(box)),
`${evenScale}:out_color_matrix=bt709:out_range=tv`,
`format=${pixelFormat}`,
].join(",")
@@ -580,6 +601,7 @@ async function runFfmpeg(
variant: ProxyVariant,
box: PreviewProxyBox | undefined,
signal: AbortSignal,
onProgress: (progress: ProxyProgress) => void,
): Promise<void> {
const metadata = await probeMediaMetadata(sourcePath);
const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true });
@@ -590,11 +612,15 @@ async function runFfmpeg(
const videoFilter = await buildVideoFilter(ffmpegPath, sourcePath, metadata, keepsAlpha, box);
const alphaDecoder = keepsAlpha ? alphaDecoderFor(metadata.color.codecName) : null;
const decoderArgs = alphaDecoder ? ["-c:v", alphaDecoder] : [];
const encode: EncodeFn = (target, timeoutMs, range) =>
const totalSeconds = metadata.durationSeconds;
const encode: EncodeFn = (target, range) =>
spawnFfmpeg(
ffmpegPath,
[
"-y",
"-progress",
"pipe:1",
"-nostats",
...(range ? ["-ss", String(range.startSeconds)] : []),
...decoderArgs,
"-i",
@@ -605,13 +631,21 @@ async function runFfmpeg(
...(variant === "vp8" ? VP8_ARGS : H264_ARGS),
target,
],
timeoutMs,
TRANSCODE_STALL_MS,
signal,
(encodedSeconds) =>
onProgress({ doneSeconds: (range?.startSeconds ?? 0) + encodedSeconds, totalSeconds }),
);
const ranges = chunkRanges(metadata.durationSeconds);
if (!ranges) return encode(outputPath, TRANSCODE_TIMEOUT_MS);
await encodeInChunks(ranges, chunkPrefix, outputPath, encode, (listPath, target) =>
spawnFfmpeg(ffmpegPath, concatArgs(listPath, target, variant), CONCAT_TIMEOUT_MS, signal),
const ranges = chunkRanges(totalSeconds);
if (!ranges) return encode(outputPath);
await encodeInChunks(
ranges,
chunkPrefix,
outputPath,
encode,
(listPath, target) =>
spawnFfmpeg(ffmpegPath, concatArgs(listPath, target, variant), CONCAT_TIMEOUT_MS, signal),
(doneSeconds) => onProgress({ doneSeconds, totalSeconds }),
);
}
@@ -631,44 +665,77 @@ function concatArgs(listPath: string, target: string, variant: ProxyVariant): st
];
}
/** ffmpeg's `-progress` lines name the output time reached, in microseconds. */
const OUT_TIME = /^out_time_(?:us|ms)=(\d+)$/;
/** Runs ffmpeg and ends it when it makes no progress for `stallMs`; a slow encode that moves on is left to finish.
* `onProgress` hears the seconds of output made so far (from `-progress pipe:1`). */
function spawnFfmpeg(
ffmpegPath: string,
args: string[],
timeoutMs: number,
stallMs: number,
signal: AbortSignal,
onProgress?: (encodedSeconds: number) => void,
): Promise<void> {
return new Promise((resolvePromise, reject) => {
// Hard ceiling so a hung ffmpeg can never permanently occupy one of the
// global transcode slots: the child is killed and the slot released via
// the caller's finally. Generous because long assets transcode at
// roughly real time; a healthy encode of any authoring asset fits.
signal.throwIfAborted();
const proc = spawn(ffmpegPath, args, {
stdio: ["ignore", "ignore", "pipe"],
timeout: timeoutMs,
stdio: ["ignore", "pipe", "pipe"],
killSignal: "SIGKILL",
signal,
windowsHide: true,
});
let stalled = false;
let timer: NodeJS.Timeout | undefined;
const armStall = (): void => {
clearTimeout(timer);
timer = setTimeout(() => {
stalled = true;
proc.kill("SIGKILL");
}, stallMs);
timer.unref();
};
armStall();
let furthest = 0;
let lineTail = "";
proc.stdout?.on("data", (chunk: Buffer) => {
const lines = (lineTail + chunk.toString()).split("\n");
lineTail = lines.pop() ?? "";
for (const line of lines) {
const reached = OUT_TIME.exec(line.trim());
if (!reached) continue;
const seconds = Number(reached[1]) / 1e6;
if (seconds <= furthest) continue;
furthest = seconds;
armStall();
onProgress?.(seconds);
}
});
let stderrTail = "";
proc.stderr?.on("data", (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_MAX_CHARS);
});
proc.on("error", (err) => {
clearTimeout(timer);
if (signal.aborted) return;
reject(new FfmpegUnavailableError(`failed to spawn ffmpeg: ${err.message}`, stderrTail));
});
proc.on("close", (code, signal) => {
proc.on("close", (code, killedBy) => {
clearTimeout(timer);
if (code === 0) {
resolvePromise();
} else if (signal) {
} else if (stalled) {
reject(
new ProxyTranscodeError(
`ffmpeg killed by ${signal} (timeout ${timeoutMs}ms or external kill)`,
`ffmpeg made no progress for ${Math.round(stallMs / 1000)}s and was ended; finished pieces are kept for the next attempt`,
null,
stderrTail,
),
);
} else if (killedBy) {
reject(
new ProxyTranscodeError(`ffmpeg killed by ${killedBy} (external kill)`, null, stderrTail),
);
} else {
reject(
new ProxyTranscodeError(
@@ -710,6 +777,7 @@ async function transcodeToCache(
variant,
box,
job.abandoned.signal,
(progress) => reportProgress(job, progress),
);
renameSync(tempPath, cachePath);
maintainProxyCache(cacheDir);
@@ -779,6 +847,7 @@ export async function resolveProxy(
priority: options.priority === true,
callers: 0,
abandoned: new AbortController(),
progress: new Set(),
};
const promise = transcodeToCache(
job,
@@ -802,15 +871,26 @@ export async function resolveProxy(
inFlight.set(cachePath, entry);
}
if (options.priority) prioritize(entry.job);
return joinJob(entry.promise, entry.job, options.signal);
const listener = options.onProgress;
const joined = joinJob(entry.promise, entry.job, options.signal);
if (listener) {
entry.job.progress.add(listener);
if (entry.job.lastProgress) listener(entry.job.lastProgress);
const stop = (): void => void entry.job.progress.delete(listener);
joined.then(stop, stop);
}
return joined;
}
/** Batch resolve for check and publish: at most MAX_CONCURRENT_TRANSCODES of its own requests in
* flight (other callers can still fill the queue); each waits at most TRANSCODE_TIMEOUT_MS. */
* flight (other callers can still fill the queue). A copy is ended only when it stops making
* progress (TRANSCODE_STALL_MS), so a long source that keeps moving is never cut by a clock. */
export async function resolveProxies(
projectDir: string,
sources: ReadonlyArray<{ sourcePath: string; variant: ProxyVariant; box?: PreviewProxyBox }>,
onSettled: () => void = () => {},
onSettled: (index: number) => void = () => {},
onProgress: (index: number, progress: ProxyProgress) => void = () => {},
signal?: AbortSignal,
): Promise<PromiseSettledResult<string>[]> {
const first = sources[0];
if (first) {
@@ -827,14 +907,14 @@ export async function resolveProxies(
const worker = async (): Promise<void> => {
for (let index = next++; index < sources.length; index = next++) {
const { sourcePath, variant, box } = sources[index]!;
results[index] = await waitForProxy(
resolveProxy(projectDir, sourcePath, variant, box),
TRANSCODE_TIMEOUT_MS,
).then(
results[index] = await resolveProxy(projectDir, sourcePath, variant, box, {
onProgress: (progress) => onProgress(index, progress),
signal,
}).then(
(value) => ({ status: "fulfilled", value }),
(reason: unknown) => ({ status: "rejected", reason }),
);
onSettled();
onSettled(index);
}
};
const workers = Math.min(MAX_CONCURRENT_TRANSCODES, sources.length);
@@ -845,6 +925,8 @@ export async function resolveProxies(
export interface ResolveProxyOptions {
priority?: boolean;
signal?: AbortSignal;
/** Hears how far the shared copy has got, from the first caller's start to the copy's end. */
onProgress?: (progress: ProxyProgress) => void;
}
function joinJob(promise: Promise<string>, job: ProxyJob, signal?: AbortSignal): Promise<string> {
+2 -2
View File
@@ -36,7 +36,7 @@
"unpacked": 541601
},
"@hyperframes/producer": {
"packed": 768546,
"packed": 768601,
"unpacked": 2808835
},
"@hyperframes/sdk": {
@@ -52,7 +52,7 @@
"unpacked": 24360208
},
"@hyperframes/studio-server": {
"packed": 144500,
"packed": 145266,
"unpacked": 568500
}
}