From fdd986f7e057899e10db24e9e11a0e8b2bb2ed3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 22 Oct 2026 20:43:13 -0400 Subject: [PATCH 01/11] fix(cli): check copies hostile videos at the composition size, not the source size --- packages/cli/src/utils/checkBrowser.test.ts | 59 +++++++++++++++++-- packages/cli/src/utils/checkBrowser.ts | 15 ++++- packages/core/src/index.ts | 1 + .../src/helpers/proxyTranscoder.test.ts | 47 +++++++++++++++ .../src/helpers/proxyTranscoder.ts | 19 ++++-- 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index 7e898b3c9ed..b196f65de62 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -28,9 +28,19 @@ const mocks = vi.hoisted(() => ({ bundleWithLocalizedFonts: vi.fn(async () => ""), serverClose: vi.fn(async () => undefined), resolveProxy: - vi.fn<(projectDir: string, absoluteSourcePath: string, variant?: string) => Promise>(), + vi.fn< + ( + projectDir: string, + absoluteSourcePath: string, + variant?: string, + box?: { width: number; height: number }, + ) => Promise + >(), FfmpegUnavailableError: class FfmpegUnavailableError extends Error {}, - getProxyCachePath: vi.fn((projectDir: string, sourcePath: string) => `${sourcePath}.proxy.mp4`), + getProxyCachePath: vi.fn( + (projectDir: string, sourcePath: string, _variant?: string, _box?: unknown) => + `${sourcePath}.proxy.mp4`, + ), scanProjectMediaCodecMap: vi.fn< (...args: unknown[]) => Promise< Record< @@ -88,12 +98,19 @@ vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({ getProxyCachePath: mocks.getProxyCachePath, resolveProxies: ( projectDir: string, - sources: Array<{ sourcePath: string; variant: string }>, + sources: Array<{ + sourcePath: string; + variant: string; + box?: { width: number; height: number }; + }>, onSettled: () => void = () => {}, ) => Promise.allSettled( - sources.map(({ sourcePath, variant }) => - mocks.resolveProxy(projectDir, sourcePath, variant).finally(onSettled), + sources.map(({ sourcePath, variant, box }) => + (box + ? mocks.resolveProxy(projectDir, sourcePath, variant, box) + : mocks.resolveProxy(projectDir, sourcePath, variant) + ).finally(onSettled), ), ), })); @@ -1099,6 +1116,38 @@ describe("preResolveHostileMediaProxies", () => { expect(settled).toBe(true); }); + it("makes the copy at the size that fills the composition, not at the source size", async () => { + const projectDir = mkProjectDir(); + mocks.scanProjectMediaCodecMap.mockResolvedValue({ + "/clip.mp4": { + codecName: "hevc", + browserHostile: true, + representativeMime: null, + hasAlpha: false, + }, + }); + mocks.resolveProxy.mockResolvedValue("/cache/clip.mp4"); + const html = + '
'; + + 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 }; + expect(mocks.resolveProxy).toHaveBeenCalledWith( + projectDir, + join(projectDir, "clip.mp4"), + "h264", + box, + ); + expect(mocks.getProxyCachePath).toHaveBeenCalledWith( + projectDir, + join(projectDir, "clip.mp4"), + "h264", + box, + ); + }); + it("does nothing (no scan, no resolveProxy) when autoProxy is off in hyperframes.json", async () => { const projectDir = mkProjectDir(); writeFileSync( diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 74f88083cca..fdd81bc72e2 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -33,6 +33,8 @@ import type { LayoutIssue, LayoutIssueCode, LayoutRect } from "./layoutAudit.js" import { serveStaticProjectHtml } from "./staticProjectServer.js"; import { collectTweenRecords, findTweenTails, tweenTailMessage } from "./gsapTweenTails.js"; import { resolveAutoProxy } from "./projectConfig.js"; +import { findCompositionDimensions } from "./compositionViewport.js"; +import { quantizePreviewProxyBox, type PreviewProxyBox } from "@hyperframes/core"; import { decideMediaProxyEligibility, proxyVariantFor, @@ -162,6 +164,10 @@ export async function preResolveHostileMediaProxies( if (hostileEntries.length === 0) return none; const startedAt = Date.now(); + // The page asks for a copy that fills the composition, so make that one: a full-size 4K copy + // takes several times longer and the page would ask for its own copy as well. + const stage = findCompositionDimensions(html); + const box = (stage && quantizePreviewProxyBox(stage.width, stage.height)) || undefined; const sources = hostileEntries.map(([pathname, facts]) => { const file = pathname.replace(/^\/+/, ""); return { @@ -169,6 +175,7 @@ export async function preResolveHostileMediaProxies( file, sourcePath: resolve(projectDir, file), variant: proxyVariantFor(facts), + box, }; }); const total = hostileEntries.length; @@ -210,10 +217,14 @@ export async function preResolveHostileMediaProxies( function proxyCached( projectDir: string, - source: { sourcePath: string; variant: ReturnType }, + source: { + sourcePath: string; + variant: ReturnType; + box?: PreviewProxyBox; + }, ): boolean { try { - return existsSync(getProxyCachePath(projectDir, source.sourcePath, source.variant)); + return existsSync(getProxyCachePath(projectDir, source.sourcePath, source.variant, source.box)); } catch { return false; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3cee99b92f7..9fc953aa89a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -270,6 +270,7 @@ export { PREVIEW_PROXY_BOX_PARAM, formatPreviewProxyBox, parsePreviewProxyBox, + quantizePreviewProxyBox, type PreviewProxyBox, } from "./previewProxyBox"; diff --git a/packages/studio-server/src/helpers/proxyTranscoder.test.ts b/packages/studio-server/src/helpers/proxyTranscoder.test.ts index f4ba57d4af2..d95e3cd12c6 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.test.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.test.ts @@ -2,6 +2,7 @@ import { execFileSync, spawn as realSpawn, spawnSync, type ChildProcess } from " import { EventEmitter, once } from "node:events"; import { existsSync, + mkdirSync, mkdtempSync, readdirSync, realpathSync, @@ -647,6 +648,52 @@ describe("resolveProxy", () => { expect(settled).toBe(sources.length); }); + it("makes a batch's copy at the box it is given, under that box's own cache name", async () => { + const { spawn, calls } = createSpawnSpy(); + const { resolveProxies, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH); + const projectDir = tmpProject(); + const sourcePath = join(projectDir, "clip.mov"); + writeFileSync(sourcePath, "source"); + const box = { width: 2048, height: 1448 }; + + const batch = resolveProxies(projectDir, [{ sourcePath, variant: "h264", box }]); + await answerUntilIdle(calls, (call) => succeed(call)); + + const [result] = await batch; + expect(result).toEqual({ + status: "fulfilled", + value: getProxyCachePath(projectDir, sourcePath, "h264", box), + }); + expect(result.status === "fulfilled" && result.value).not.toBe( + getProxyCachePath(projectDir, sourcePath, "h264"), + ); + }); + + it("clears a partial copy left by a stopped process before it starts a batch, and keeps a live one", async () => { + const { spawn, calls } = createSpawnSpy(); + const { resolveProxies } = await loadModule(spawn, FFMPEG_PATH); + const projectDir = tmpProject(); + const sourcePath = join(projectDir, "clip.mov"); + writeFileSync(sourcePath, "source"); + const cacheDir = join(projectDir, ".transcode-cache"); + mkdirSync(cacheDir, { recursive: true }); + const name = (id: string) => `.tmp-${id}-${"a".repeat(64)}.mp4`; + const dead = join(cacheDir, name("11111111-1111-1111-1111-111111111111")); + const live = join(cacheDir, name("22222222-2222-2222-2222-222222222222")); + writeFileSync(dead, "partial"); + writeFileSync(live, "partial"); + const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000); + utimesSync(dead, minutesAgo(11), minutesAgo(11)); + utimesSync(live, minutesAgo(1), minutesAgo(1)); + + const batch = resolveProxies(projectDir, [{ sourcePath, variant: "h264" }]); + await answerUntilIdle(calls, (call) => succeed(call)); + await batch; + + expect(existsSync(dead)).toBe(false); + expect(existsSync(live)).toBe(true); + }); + it("settles each request of a batch on its own when one of them fails", async () => { const { spawn, calls } = createSpawnSpy(); const { resolveProxies } = await loadModule(spawn, FFMPEG_PATH); diff --git a/packages/studio-server/src/helpers/proxyTranscoder.ts b/packages/studio-server/src/helpers/proxyTranscoder.ts index 70855fd1fe0..5c724efeda3 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.ts @@ -49,6 +49,8 @@ export const PROXY_PENDING_RETRY_AFTER_SECONDS = 2; const ENVIRONMENT_FAILURE_TTL_MS = 5 * PROXY_PENDING_RETRY_AFTER_SECONDS * 1000; const MAX_FAILURE_CACHE_ENTRIES = 128; export const DEFAULT_PROXY_WAIT_TIMEOUT_MS = 2 * 60 * 1000; +// A copy being written grows its temp file all the time; one untouched this long was left by a stopped process. +const DEAD_TEMP_MS = 10 * 60 * 1000; export class ProxyTranscodeError extends Error { readonly exitCode: number | null; @@ -315,9 +317,9 @@ function forget(job: ProxyJob): void { if (inFlight.get(job.cachePath)?.job === job) inFlight.delete(job.cachePath); } -function maintainProxyCache(cacheDir: string): void { +function maintainProxyCache(cacheDir: string, staleTempMs?: number): void { try { - cleanupProxyCache(cacheDir, { protectedPaths: new Set(inFlight.keys()) }); + cleanupProxyCache(cacheDir, { protectedPaths: new Set(inFlight.keys()), staleTempMs }); } catch (error) { // Cache maintenance must never turn a playable preview into an error. console.warn( @@ -660,16 +662,23 @@ export async function resolveProxy( * flight (other callers can still fill the queue); each waits at most TRANSCODE_TIMEOUT_MS. */ export async function resolveProxies( projectDir: string, - sources: ReadonlyArray<{ sourcePath: string; variant: ProxyVariant }>, + sources: ReadonlyArray<{ sourcePath: string; variant: ProxyVariant; box?: PreviewProxyBox }>, onSettled: () => void = () => {}, ): Promise[]> { + const first = sources[0]; + if (first) { + maintainProxyCache( + dirname(getProxyCachePath(projectDir, first.sourcePath, first.variant)), + DEAD_TEMP_MS, + ); + } const results: PromiseSettledResult[] = []; let next = 0; const worker = async (): Promise => { for (let index = next++; index < sources.length; index = next++) { - const { sourcePath, variant } = sources[index]!; + const { sourcePath, variant, box } = sources[index]!; results[index] = await waitForProxy( - resolveProxy(projectDir, sourcePath, variant), + resolveProxy(projectDir, sourcePath, variant, box), TRANSCODE_TIMEOUT_MS, ).then( (value) => ({ status: "fulfilled", value }), From c78aa2182bf4859fc626bf6bb4ecbe66764b643f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 22 Oct 2026 20:59:06 -0400 Subject: [PATCH 02/11] fix(cli): the check server serves the copy the page names with hf-proxy-box --- packages/cli/src/utils/checkBrowser.ts | 6 ++-- .../cli/src/utils/staticProjectServer.test.ts | 32 +++++++++++++++++++ packages/cli/src/utils/staticProjectServer.ts | 20 ++++++++++-- .../src/helpers/proxyTranscoder.ts | 8 +++-- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index fdd81bc72e2..de7acc1015a 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -164,10 +164,8 @@ export async function preResolveHostileMediaProxies( if (hostileEntries.length === 0) return none; const startedAt = Date.now(); - // The page asks for a copy that fills the composition, so make that one: a full-size 4K copy - // takes several times longer and the page would ask for its own copy as well. const stage = findCompositionDimensions(html); - const box = (stage && quantizePreviewProxyBox(stage.width, stage.height)) || undefined; + const stageBox = (stage && quantizePreviewProxyBox(stage.width, stage.height)) || undefined; const sources = hostileEntries.map(([pathname, facts]) => { const file = pathname.replace(/^\/+/, ""); return { @@ -175,7 +173,7 @@ export async function preResolveHostileMediaProxies( file, sourcePath: resolve(projectDir, file), variant: proxyVariantFor(facts), - box, + box: stageBox, }; }); const total = hostileEntries.length; diff --git a/packages/cli/src/utils/staticProjectServer.test.ts b/packages/cli/src/utils/staticProjectServer.test.ts index 2992bd8958f..0064a080625 100644 --- a/packages/cli/src/utils/staticProjectServer.test.ts +++ b/packages/cli/src/utils/staticProjectServer.test.ts @@ -363,6 +363,38 @@ describe("serveStaticProjectHtml transparent media proxies", () => { ); }); + it("resolves the copy named by hf-proxy-box, the one check made before the page opened", async () => { + const projectDir = mk(); + writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes"); + const proxyPath = join(projectDir, "proxy.mp4"); + writeFileSync(proxyPath, "boxed-h264-bytes"); + mocks.resolveProxy.mockResolvedValue(proxyPath); + server = await serveStaticProjectHtml(projectDir, ""); + + const res = await fetch(`${server.url}clip.mp4?hf-proxy=h264&hf-proxy-box=2048x1448`); + + expect(await res.text()).toBe("boxed-h264-bytes"); + expect(mocks.resolveProxy).toHaveBeenCalledWith( + projectDir, + join(projectDir, "clip.mp4"), + "h264", + { width: 2048, height: 1448 }, + { signal: expect.any(AbortSignal) }, + ); + }); + + it.each(["clip.mp4?hf-proxy=h264&hf-proxy-box=1000x1000", "clip.mp4?hf-proxy-box=2048x1448"])( + "answers 404 without a copy for %s, as the Studio preview does", + async (path) => { + const projectDir = mk(); + writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes"); + server = await serveStaticProjectHtml(projectDir, ""); + + expect((await fetch(`${server.url}${path}`)).status).toBe(404); + expect(mocks.resolveProxy).not.toHaveBeenCalled(); + }, + ); + it.each(["mxf", "mts", "m2ts", "ts", "mkv", "m4v"])( "serves a proxy for .%s camera/container media", async (extension) => { diff --git a/packages/cli/src/utils/staticProjectServer.ts b/packages/cli/src/utils/staticProjectServer.ts index c91d3e00282..bfabafbd72a 100644 --- a/packages/cli/src/utils/staticProjectServer.ts +++ b/packages/cli/src/utils/staticProjectServer.ts @@ -2,6 +2,11 @@ import { createServer, request as httpRequest, type Server, type ServerResponse import { Duplex } from "node:stream"; import { createReadStream, existsSync, statSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; +import { + PREVIEW_PROXY_BOX_PARAM, + parsePreviewProxyBox, + type PreviewProxyBox, +} from "@hyperframes/core"; import { getMimeType } from "@hyperframes/core/studio-api"; import { decodeWellFormedEscapes } from "@hyperframes/studio-server"; import { resolveAutoProxy } from "./projectConfig.js"; @@ -205,6 +210,7 @@ async function serveProxyRequest( projectDir: string, filePath: string, request: ProxyVariantRequest, + box: PreviewProxyBox | undefined, autoProxy: boolean, rangeHeader: string | undefined, res: ServerResponse, @@ -236,7 +242,7 @@ async function serveProxyRequest( return; } recordProxyRequest(); - const proxyPath = await resolveProxy(projectDir, filePath, variant, undefined, { signal }); + const proxyPath = await resolveProxy(projectDir, filePath, variant, box, { signal }); // The await above can span a whole transcode; the client may be gone. if (res.writableEnded || res.destroyed) return; serveFileWithRange(proxyPath, rangeHeader, res, PROXY_VARIANT_CONFIG[variant].contentType); @@ -277,10 +283,17 @@ export async function serveStaticProjectHtml( const queryIndex = url.indexOf("?"); const pathOnly = queryIndex === -1 ? url : url.slice(0, queryIndex); - const proxyParam = - queryIndex === -1 ? null : new URLSearchParams(url.slice(queryIndex + 1)).get("hf-proxy"); + const query = new URLSearchParams(queryIndex === -1 ? "" : url.slice(queryIndex + 1)); + const proxyParam = query.get("hf-proxy"); const proxyRequest = proxyParam !== null && isProxyVariantRequest(proxyParam) ? proxyParam : null; + const boxParam = query.get(PREVIEW_PROXY_BOX_PARAM); + const proxyBox = boxParam === null ? undefined : parsePreviewProxyBox(boxParam); + if (proxyBox === null || (proxyBox && !proxyRequest)) { + res.writeHead(404); + res.end(); + return; + } const requestPath = decodeWellFormedEscapes(pathOnly).replace(/^\//, ""); for (const root of roots) { @@ -293,6 +306,7 @@ export async function serveStaticProjectHtml( projectDir, filePath, proxyRequest, + proxyBox, autoProxy, req.headers.range, res, diff --git a/packages/studio-server/src/helpers/proxyTranscoder.ts b/packages/studio-server/src/helpers/proxyTranscoder.ts index 5c724efeda3..f66df4b86b1 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.ts @@ -49,8 +49,7 @@ export const PROXY_PENDING_RETRY_AFTER_SECONDS = 2; const ENVIRONMENT_FAILURE_TTL_MS = 5 * PROXY_PENDING_RETRY_AFTER_SECONDS * 1000; const MAX_FAILURE_CACHE_ENTRIES = 128; export const DEFAULT_PROXY_WAIT_TIMEOUT_MS = 2 * 60 * 1000; -// A copy being written grows its temp file all the time; one untouched this long was left by a stopped process. -const DEAD_TEMP_MS = 10 * 60 * 1000; +const UNTOUCHED_TEMP_IS_DEAD_MS = 10 * 60 * 1000; export class ProxyTranscodeError extends Error { readonly exitCode: number | null; @@ -669,7 +668,10 @@ export async function resolveProxies( if (first) { maintainProxyCache( dirname(getProxyCachePath(projectDir, first.sourcePath, first.variant)), - DEAD_TEMP_MS, + Math.min( + Number(process.env.HYPERFRAMES_PROXY_CACHE_STALE_TEMP_MS) || UNTOUCHED_TEMP_IS_DEAD_MS, + UNTOUCHED_TEMP_IS_DEAD_MS, + ), ); } const results: PromiseSettledResult[] = []; From 950bf3eb90cfa5ed747dceb747a30e85295c00f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 00:41:26 -0400 Subject: [PATCH 03/11] feat(cli): check --prepare-media makes the video copies alone, and a stopped copy resumes A long source is copied in one-minute pieces that stay in the cache, so a copy that is killed or runs out of time continues from its finished pieces. check --prepare-media runs only that step: no browser, no report, a per-file result as JSON, and exit 1 when a copy failed. --- docs/packages/cli.mdx | 7 + packages/cli/src/commands/check.test.ts | 75 +++++ packages/cli/src/commands/check.ts | 52 +++ packages/cli/src/utils/checkBrowser.test.ts | 8 +- packages/cli/src/utils/checkBrowser.ts | 22 +- .../src/helpers/mediaMetadata.ts | 11 +- .../src/helpers/proxyCache.test.ts | 21 ++ .../studio-server/src/helpers/proxyCache.ts | 6 +- .../src/helpers/proxyTranscoder.test.ts | 64 ++++ .../src/helpers/proxyTranscoder.ts | 297 +++++++++++++----- 10 files changed, 483 insertions(+), 80 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 0a976f2dc67..a5f1419e5f9 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -1047,6 +1047,13 @@ cover every finding from the full check. | `--layout` | Layout knobs, currently `proseCoverageFloor=0.05` (0–1, default 0.15) | | `--browser-gpu` / `--no-browser-gpu` | Hardware GPU capture, or deterministic SwiftShader (default: auto-detect) | | `--proxy` / `--no-proxy` | Auto-transcode browser-hostile codecs before checking (default: on) | +| `--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. `--caption-zone` and `--frame-check` refuse a ranged check when none of their configured seek samples falls inside `--range`; a requested gate never silently diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index dbf82183c3e..20b2f2efa32 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -369,6 +369,69 @@ describe("contrast sample selection", () => { }); }); +describe("check --prepare-media", () => { + async function runPrepare( + files: Array<{ file: string; state: "ready" | "cached" | "failed"; reason?: string }>, + rawArgs: string[], + ) { + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report); + const prepareMedia = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => files); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await runCommand( + createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline, + prepareMedia, + withMeta: (value) => value, + }), + { rawArgs }, + ); + return { runPipeline, prepareMedia, log }; + } + + it("makes the copies without running the check, and reports every file", async () => { + const files = [ + { file: "a.mov", state: "ready" as const }, + { file: "b.mov", state: "cached" as const }, + ]; + const { runPipeline, prepareMedia, log } = await runPrepare(files, [ + "--prepare-media", + "--json", + "--no-proxy", + ]); + + expect(runPipeline).not.toHaveBeenCalled(); + expect(prepareMedia).toHaveBeenCalledWith( + PROJECT, + expect.objectContaining({ autoProxy: false }), + ); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: true, + prepared: files, + total: 2, + ready: 2, + failed: 0, + }); + expect(consumeCommandResult().exitCode).toBe(0); + }); + + it("exits 1 and names the reason when a copy failed", async () => { + const { log } = await runPrepare( + [{ file: "a.mov", state: "failed", reason: "ffmpeg exited with code 1" }], + ["--prepare-media", "--json"], + ); + + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + ready: 0, + failed: 1, + prepared: [{ file: "a.mov", state: "failed", reason: "ffmpeg exited with code 1" }], + }); + expect(consumeCommandResult().exitCode).toBe(1); + }); +}); + it("parses the caption-zone grammar and enables the frame gate", async () => { const { report } = await runScenario(fakeDriver()); const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report); @@ -376,6 +439,7 @@ it("parses the caption-zone grammar and enables the frame gate", async () => { const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); @@ -411,6 +475,7 @@ it("preserves caption-zone after bare --frame-check", async () => { const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); @@ -446,6 +511,7 @@ it("preserves --json after bare --frame-check", async () => { const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); @@ -472,6 +538,7 @@ it("prints a dash-prefixed --frame-check value's parse failure exactly once, not const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); @@ -491,6 +558,7 @@ it("no longer swallows --json after a --layout value (the wider bug class beyond const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); @@ -517,6 +585,7 @@ it("includes local HDR auto-promotion attribution in --json output", async () => const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline: vi.fn(async () => report), + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); @@ -548,6 +617,7 @@ it("threads --no-proxy into the browser check options", async () => { const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); @@ -571,6 +641,7 @@ it("waits for the project lock declared in HF_TOOL_CONTEXT before running the pi const command = createCheckCommand({ resolveProject: () => ({ ...PROJECT, dir }), runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); try { @@ -591,6 +662,7 @@ it("rejects malformed caption-zone specs instead of silently disabling the gate" const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => ({ ...value, _meta: { version: "test" } }), }); @@ -1164,6 +1236,7 @@ describe("check pipeline", () => { const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline: vi.fn(async () => report), + prepareMedia: vi.fn(async () => []), withMeta: (value) => ({ ...value, _meta: { version: "test" } }), }); @@ -2320,6 +2393,7 @@ describe("layout flag grammar", () => { const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); @@ -2474,6 +2548,7 @@ describe("sampled finding reporting", () => { const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline: vi.fn(async () => report), + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index b0ab34e69af..16e1403d7e2 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -36,12 +36,14 @@ import { } from "../utils/checkPipeline.js"; import type { CaptionZoneOptions, FrameCheckOptions, LayoutOptions } from "../utils/checkTypes.js"; import { resolveLocalBrowserGpuMode } from "../browser/gpuPolicy.js"; +import { preResolveHostileMediaProxies, type PreparedMediaFile } from "../utils/checkBrowser.js"; export const examples: Example[] = [ ["Run the full verification gate", "hyperframes check"], ["Output one agent-readable envelope", "hyperframes check --json"], ["Persist the five audited contrast frames", "hyperframes check --snapshots"], ["Also fail on warnings", "hyperframes check --strict"], + ["Make only the browser-playable video copies, then exit", "hyperframes check --prepare-media"], ]; type SavedCheck = Pick; @@ -50,6 +52,7 @@ type SavedCheckReport = CheckReport & { saved?: SavedCheck }; export interface CheckCommandDependencies { resolveProject(dir: string | undefined): ProjectDir; runPipeline(project: ProjectDir, options: CheckOptions): Promise; + prepareMedia(project: ProjectDir, options: CheckOptions): Promise; withMeta(value: object): object; } @@ -70,6 +73,11 @@ const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = { return report; } }, + async prepareMedia(project, options) { + const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js"); + const html = await bundleWithLocalizedFonts(project.dir); + return (await preResolveHostileMediaProxies(project.dir, html, options.autoProxy)).files; + }, withMeta, }; @@ -211,6 +219,12 @@ const CHECK_COMMAND_ARGS = { description: "Exit non-zero on warnings too", default: false, }, + "prepare-media": { + type: "boolean", + description: + "Only make the browser-playable copies of videos Chrome cannot decode, then exit (no browser, no report). Stopped copies resume on the next run", + default: false, + }, proxy: { type: "boolean", description: @@ -264,6 +278,10 @@ export function createCheckCommand( if (!asJson) { console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); } + if (args["prepare-media"] === true) { + await runPrepareMedia(dependencies, project, options, asJson); + return; + } const report = await withProjectLock(project.dir, "check", () => dependencies.runPipeline(project, options), ); @@ -294,6 +312,40 @@ export function createCheckCommand( }); } +async function runPrepareMedia( + dependencies: CheckCommandDependencies, + project: ProjectDir, + options: CheckOptions, + asJson: boolean, +): Promise { + const files = await withProjectLock(project.dir, "check", () => + dependencies.prepareMedia(project, options), + ); + const failed = files.filter((file) => file.state === "failed"); + if (asJson) { + console.log( + JSON.stringify( + dependencies.withMeta({ + ok: failed.length === 0, + prepared: files, + total: files.length, + ready: files.length - failed.length, + failed: failed.length, + }), + null, + 2, + ), + ); + } else { + const mark = failed.length === 0 ? c.success("✓") : c.error("✗"); + console.log( + `${mark} Browser-playable copies: ${files.length - failed.length}/${files.length} ready`, + ); + for (const file of failed) console.log(` ${file.file}: ${file.reason}`); + } + setCommandExitCode(failed.length === 0 ? 0 : 1); +} + function parseCheckOptions(args: Record): CheckOptions { const maxTransitionSamples = positiveInteger(args["max-transition-samples"], 0); return { diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index b196f65de62..d4e68020536 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -1183,6 +1183,7 @@ describe("preResolveHostileMediaProxies", () => { }), ], failedPaths: new Set(["/clip.mp4"]), + files: [{ file: "clip.mp4", state: "failed", reason: "ffmpeg exited with code 1" }], }); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("media proxy pre-resolve: 0/1 ready, 1 failed"), @@ -1254,7 +1255,11 @@ describe("preResolveHostileMediaProxies", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); writeFileSync(join(projectDir, "a.mov.proxy.mp4"), ""); - await preResolveHostileMediaProxies(projectDir, ""); + const { files } = await preResolveHostileMediaProxies(projectDir, ""); + expect(files).toEqual([ + { file: "a.mov", state: "cached" }, + { file: "b.mov", state: "ready" }, + ]); expect(errorSpy.mock.calls.map(([line]) => String(line).slice(0, 48))).toEqual([ "[hyperframes] Making browser-playable copies of ", "[hyperframes] browser-playable copies: 1/2", @@ -1280,6 +1285,7 @@ describe("preResolveHostileMediaProxies", () => { await expect(preResolveHostileMediaProxies(projectDir, "")).resolves.toEqual({ findings: [], failedPaths: new Set(), + files: [], }); expect(errorSpy).toHaveBeenCalledWith( diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index de7acc1015a..101ffecdd36 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -147,7 +147,7 @@ export async function preResolveHostileMediaProxies( html: string, autoProxyOverride?: boolean, ): Promise { - const none: ProxyPreResolution = { findings: [], failedPaths: new Set() }; + const none: ProxyPreResolution = { findings: [], failedPaths: new Set(), files: [] }; if (!resolveAutoProxy(projectDir, autoProxyOverride)) return none; let codecMap: Awaited>; try { @@ -177,7 +177,8 @@ export async function preResolveHostileMediaProxies( }; }); const total = hostileEntries.length; - const toMake = sources.filter((source) => !proxyCached(projectDir, source)).length; + const cached = sources.map((source) => proxyCached(projectDir, source)); + const toMake = cached.filter((isCached) => !isCached).length; if (toMake > 0) { console.error( `[hyperframes] Making browser-playable copies of ${toMake} video${toMake === 1 ? "" : "s"} Chrome cannot decode. Only the first check of these files waits for this; later checks reuse the copies.`, @@ -210,7 +211,14 @@ export async function preResolveHostileMediaProxies( ), ); } - return { findings, failedPaths: new Set(failures.map(({ pathname }) => pathname)) }; + const files = sources.map(({ file }, index): PreparedMediaFile => { + const result = results[index]!; + if (result.status === "rejected") { + return { file, state: "failed", reason: normalizeErrorMessage(result.reason) }; + } + return { file, state: cached[index] ? "cached" : "ready" }; + }); + return { findings, failedPaths: new Set(failures.map(({ pathname }) => pathname)), files }; } function proxyCached( @@ -228,10 +236,18 @@ function proxyCached( } } +export interface PreparedMediaFile { + file: string; + state: "ready" | "cached" | "failed"; + reason?: string; +} + interface ProxyPreResolution { findings: RuntimeDraft[]; /** Root-relative paths of the videos whose copy failed. */ failedPaths: Set; + /** One entry per browser-hostile video, in project order. */ + files: PreparedMediaFile[]; } function proxyFailedDraft(message: string): RuntimeDraft { diff --git a/packages/studio-server/src/helpers/mediaMetadata.ts b/packages/studio-server/src/helpers/mediaMetadata.ts index ccfb415fa03..c43b0a6987c 100644 --- a/packages/studio-server/src/helpers/mediaMetadata.ts +++ b/packages/studio-server/src/helpers/mediaMetadata.ts @@ -76,6 +76,8 @@ export interface MediaMetadata { hasAudio?: boolean; /** Video stream carries alpha (pix_fmt or the VP8/VP9 alpha_mode tag). */ hasAlpha?: boolean; + /** Container duration in seconds, when ffprobe reports one. */ + durationSeconds?: number; probeError?: string; } @@ -215,7 +217,7 @@ export async function probeMediaMetadata( "-v", "error", "-show_entries", - "stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample:stream_disposition=attached_pic:stream_tags=alpha_mode", + "stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample:stream_disposition=attached_pic:stream_tags=alpha_mode:format=duration", "-of", "json", "--", @@ -232,7 +234,10 @@ export async function probeMediaMetadata( } try { - const parsed = JSON.parse(String(result.stdout || "{}")) as { streams?: FfprobeStream[] }; + const parsed = JSON.parse(String(result.stdout || "{}")) as { + streams?: FfprobeStream[]; + format?: { duration?: string }; + }; const stream = parsed.streams?.find((item) => { if (kind === "image") return item.codec_type === "video"; return item.codec_type === kind && item.disposition?.attached_pic !== 1; @@ -241,6 +246,8 @@ export async function probeMediaMetadata( if (kind === "video") { metadata.hasAudio = (parsed.streams ?? []).some((item) => item.codec_type === "audio"); metadata.hasAlpha = stream !== undefined && videoStreamHasAlpha(stream); + const duration = Number(parsed.format?.duration); + if (Number.isFinite(duration) && duration > 0) metadata.durationSeconds = duration; } return metadata; } catch { diff --git a/packages/studio-server/src/helpers/proxyCache.test.ts b/packages/studio-server/src/helpers/proxyCache.test.ts index 2515463a018..d183ddf1a2a 100644 --- a/packages/studio-server/src/helpers/proxyCache.test.ts +++ b/packages/studio-server/src/helpers/proxyCache.test.ts @@ -115,6 +115,27 @@ describe("cleanupProxyCache", () => { expect(existsSync(inFlight)).toBe(true); }); + it("sweeps stale pieces of a stopped long copy and keeps fresh ones", () => { + const cache = cacheDir(); + const now = 1_800_000_000_000; + const key = basename(proxy(cache, "long"), ".mp4"); + const stalePiece = join(cache, `.part-${key}-0.mp4`); + const stalePieceTemp = join(cache, `.tmp-${randomUUID()}-.part-${key}-1.mp4`); + const freshPiece = join(cache, `.part-${key}-2.mp4`); + writeEntry(stalePiece, 3, now - 2 * 60 * 60 * 1000); + writeEntry(stalePieceTemp, 3, now - 2 * 60 * 60 * 1000); + writeEntry(freshPiece, 3, now - 60 * 1000); + + const result = cleanupProxyCache(cache, { + now, + staleTempMs: 60 * 60 * 1000, + minSweepIntervalMs: 0, + }); + + expect(result.removed.sort()).toEqual([stalePiece, stalePieceTemp].sort()); + expect(existsSync(freshPiece)).toBe(true); + }); + it("never removes a file the transcoder did not name, even from an idle cache over budget", () => { const cache = cacheDir(); const now = 1_800_000_000_000; diff --git a/packages/studio-server/src/helpers/proxyCache.ts b/packages/studio-server/src/helpers/proxyCache.ts index 96cd7c7a9f6..3303e19c0a7 100644 --- a/packages/studio-server/src/helpers/proxyCache.ts +++ b/packages/studio-server/src/helpers/proxyCache.ts @@ -9,7 +9,9 @@ const PROXY_EXTENSIONS: ReadonlySet = new Set( Object.values(PROXY_VARIANT_CONFIG).map(({ extension }) => extension), ); const TRANSCODER_PROXY_NAME = /^[0-9a-f]{64}\.\w+$/; -const TRANSCODER_TEMP_NAME = /^\.tmp-[0-9a-f-]{36}-[0-9a-f]{64}\.\w+$/; +// A temp copy, or a temp piece of a long copy (`.part--`); a finished piece waits for a restart under its `.part-` name. +const TRANSCODER_TEMP_NAME = /^\.tmp-[0-9a-f-]{36}-(?:\.part-[0-9a-f]{64}-\d+|[0-9a-f]{64})\.\w+$/; +const TRANSCODER_PIECE_NAME = /^\.part-[0-9a-f]{64}-\d+\.\w+$/; export interface ProxyCacheCleanupOptions { maxBytes?: number; @@ -81,7 +83,7 @@ function readCacheInventory( modifiedAt: stat.mtimeMs, protected: protectedPaths.has(path), }; - if (TRANSCODER_TEMP_NAME.test(dirent.name)) { + if (TRANSCODER_TEMP_NAME.test(dirent.name) || TRANSCODER_PIECE_NAME.test(dirent.name)) { if (now - stat.mtimeMs >= staleTempMs) staleTemps.push(entry); } else if ( TRANSCODER_PROXY_NAME.test(dirent.name) && diff --git a/packages/studio-server/src/helpers/proxyTranscoder.test.ts b/packages/studio-server/src/helpers/proxyTranscoder.test.ts index d95e3cd12c6..520551a02ac 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.test.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.test.ts @@ -144,6 +144,7 @@ async function loadModule( isHdr = false, hdrTransfer: string | null = isHdr ? "pq" : null, codecName?: string, + durationSeconds?: number, ): Promise { vi.resetModules(); vi.doMock("node:child_process", () => { @@ -157,12 +158,75 @@ async function loadModule( probeMediaMetadata: async () => ({ kind: "video", color: { isHdr, hdrTransfer, codecName }, + durationSeconds, }), probeFirstFrameColour: async () => ({}), })); return import("./proxyTranscoder.js"); } +describe("resolveProxy on a long source", () => { + const startsOf = (calls: SpawnCall[]): string[] => + calls + .filter((call) => call.args.includes("-ss")) + .map((call) => call.args[call.args.indexOf("-ss") + 1]!); + + it("copies in pieces, and a stopped copy continues from its finished pieces", async () => { + const { spawn, calls } = createSpawnSpy(); + const { resolveProxy, getProxyCachePath, clearFailedTranscodesForTest } = await loadModule( + spawn, + FFMPEG_PATH, + false, + null, + undefined, + 200, + ); + const projectDir = tmpProject(); + const sourcePath = join(projectDir, "long.mov"); + writeFileSync(sourcePath, "source-bytes"); + const cachePath = getProxyCachePath(projectDir, sourcePath); + const cacheDirectory = join(projectDir, ".transcode-cache"); + + const first = resolveProxy(projectDir, sourcePath).catch((error: unknown) => error); + await flush(); + succeed(calls[0]!); + await flush(50); + calls[1]!.proc.emit("close", null, "SIGKILL"); + expect(await first).toBeInstanceOf(Error); + expect(startsOf(calls)).toEqual(["0", "60"]); + expect(readdirSync(cacheDirectory)).toHaveLength(1); + + clearFailedTranscodesForTest(); + const second = resolveProxy(projectDir, sourcePath); + for (let answered = calls.length; ; answered = calls.length) { + await flush(50); + if (calls.length === answered) break; + calls.slice(answered).forEach((call) => succeed(call)); + } + + 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(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 projectDir = tmpProject(); + const sourcePath = join(projectDir, "short.mov"); + writeFileSync(sourcePath, "source-bytes"); + + const result = resolveProxy(projectDir, sourcePath); + await flush(); + expect(calls).toHaveLength(1); + expect(calls[0]!.args).not.toContain("-ss"); + succeed(calls[0]!); + await result; + }); +}); + describe("resolveProxy", () => { it("bounds a caller wait without cancelling the shared transcode promise", async () => { const { waitForProxy, ProxyWaitTimeoutError } = await loadModule( diff --git a/packages/studio-server/src/helpers/proxyTranscoder.ts b/packages/studio-server/src/helpers/proxyTranscoder.ts index f66df4b86b1..d3e94cecdac 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.ts @@ -1,7 +1,17 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { existsSync, renameSync, statSync, unlinkSync, utimesSync } from "node:fs"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + existsSync, + mkdtempSync, + renameSync, + rmSync, + statSync, + unlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { formatPreviewProxyBox, hdrToSdrToneMapFilter, @@ -44,6 +54,10 @@ const MAX_QUEUED_TRANSCODES = boundedEnvInteger("HYPERFRAMES_PROXY_MAX_QUEUE", 8 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; +const CHUNK_PREFIX = ".part-"; +const CONCAT_TIMEOUT_MS = 2 * 60 * 1000; const FAILURE_CACHE_TTL_MS = 60 * 1000; export const PROXY_PENDING_RETRY_AFTER_SECONDS = 2; const ENVIRONMENT_FAILURE_TTL_MS = 5 * PROXY_PENDING_RETRY_AFTER_SECONDS * 1000; @@ -417,19 +431,137 @@ function stderrReason(stderrTail: string): string { return line ? `: ${line.replace(/[.!]+$/, "")}` : ""; } -async function runFfmpeg( - sourcePath: string, - outputPath: string, - variant: ProxyVariant, - box: PreviewProxyBox | undefined, - signal: AbortSignal, +interface ChunkRange { + startSeconds: number; + lengthSeconds: number; +} + +/** Null when the source is short enough (or its length unknown) for one pass. */ +function chunkRanges(durationSeconds: number | undefined): ChunkRange[] | null { + if (!durationSeconds || durationSeconds <= PROXY_CHUNK_SECONDS * 1.5) return null; + const count = Math.floor(durationSeconds / PROXY_CHUNK_SECONDS); + // The remainder joins the last piece, so no piece is shorter than a chunk. + return Array.from({ length: count }, (_, index) => ({ + startSeconds: index * PROXY_CHUNK_SECONDS, + lengthSeconds: + index === count - 1 ? durationSeconds - index * PROXY_CHUNK_SECONDS : PROXY_CHUNK_SECONDS, + })); +} + +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; + +/** + * Copies a long source in pieces. Each finished piece stays under a stable + * name in the cache directory, so a copy that is killed or runs out of time + * continues with the first missing piece instead of starting over. The pieces + * start on keyframes of their own, so joining them without re-encoding is seamless. + */ +async function encodeInChunks( + ranges: ChunkRange[], + chunkPrefix: string, + tempPath: string, + encode: EncodeFn, + concat: (listPath: string, target: string) => Promise, ): Promise { - const metadata = await probeMediaMetadata(sourcePath); - const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true }); - if (!ffmpegPath) { - throw new FfmpegUnavailableError(); + const extension = extname(tempPath); + const deadline = Date.now() + TRANSCODE_TIMEOUT_MS; + const pieces = ranges.map((_, index) => `${chunkPrefix}${index}${extension}`); + 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); + renameSync(pieceTemp, piece); + } finally { + if (existsSync(pieceTemp)) unlinkSync(pieceTemp); + } } - const keepsAlpha = variant === "vp8"; + const listDir = mkdtempSync(join(tmpdir(), "hf-proxy-join-")); + try { + const listPath = join(listDir, "pieces.txt"); + writeFileSync( + listPath, + pieces + .map((piece) => `file '${piece.replace(/\\/g, "/").replace(/'/g, "'\\''")}'\n`) + .join(""), + ); + await concat(listPath, tempPath); + } finally { + rmSync(listDir, { recursive: true, force: true }); + } + for (const piece of pieces) if (existsSync(piece)) unlinkSync(piece); +} + +// Fixed recipe; changing either array means bumping PROXY_PARAMS_VERSION. +const H264_ARGS = [ + "-c:v", + "libx264", + "-profile:v", + "high", + "-pix_fmt", + "yuv420p", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-crf", + "18", + "-preset", + "veryfast", + "-c:a", + "aac", + "-movflags", + "+faststart", +]; +const VP8_ARGS = [ + "-c:v", + "libvpx", + "-b:v", + "0", + "-crf", + "23", + "-deadline", + "good", + "-pix_fmt", + "yuva420p", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-cpu-used", + "4", + "-auto-alt-ref", + "0", + "-metadata:s:v:0", + "alpha_mode=1", + "-ac", + "2", + "-c:a", + "libopus", +]; + +async function buildVideoFilter( + ffmpegPath: string, + sourcePath: string, + metadata: Awaited>, + keepsAlpha: boolean, + box: PreviewProxyBox | undefined, +): Promise { const { hdrTransfer } = metadata.color; const toneMap = (hdrTransfer === "pq" || hdrTransfer === "hlg") && !keepsAlpha; if (toneMap) await ensureHdrFilters(ffmpegPath); @@ -437,70 +569,80 @@ async function runFfmpeg( const evenScale = proxyScaleFilter(box); const pixelFormat = keepsAlpha ? "yuva420p" : "yuv420p"; // The tone map ends in RGB; older ffmpeg (seen on 5.1) converts it with BT.601 unless the matrix is named. - const videoFilter = toneMap + return toneMap ? [ hdrToSdrToneMapFilter(metadata.color, firstFrame), `${evenScale}:out_color_matrix=bt709:out_range=tv`, `format=${pixelFormat}`, ].join(",") : [evenScale, `format=${pixelFormat}`].join(","); +} - return new Promise((resolvePromise, reject) => { - const alphaDecoder = keepsAlpha ? alphaDecoderFor(metadata.color.codecName) : null; - const decoderArgs = alphaDecoder ? ["-c:v", alphaDecoder] : []; - const commonArgs = ["-y", ...decoderArgs, "-i", sourcePath, "-vf", videoFilter]; - const h264Args = [ - "-c:v", - "libx264", - "-profile:v", - "high", - "-pix_fmt", - "yuv420p", - "-colorspace", - "bt709", - "-color_primaries", - "bt709", - "-color_trc", - "bt709", - "-crf", - "18", - "-preset", - "veryfast", - "-c:a", - "aac", - "-movflags", - "+faststart", - ]; - const vp8Args = [ - "-c:v", - "libvpx", - "-b:v", - "0", - "-crf", - "23", - "-deadline", - "good", - "-pix_fmt", - "yuva420p", - "-colorspace", - "bt709", - "-color_primaries", - "bt709", - "-color_trc", - "bt709", - "-cpu-used", - "4", - "-auto-alt-ref", - "0", - "-metadata:s:v:0", - "alpha_mode=1", - "-ac", - "2", - "-c:a", - "libopus", - ]; - const args = [...commonArgs, ...(variant === "vp8" ? vp8Args : h264Args), outputPath]; +async function runFfmpeg( + sourcePath: string, + outputPath: string, + chunkPrefix: string, + variant: ProxyVariant, + box: PreviewProxyBox | undefined, + signal: AbortSignal, +): Promise { + const metadata = await probeMediaMetadata(sourcePath); + const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true }); + if (!ffmpegPath) { + throw new FfmpegUnavailableError(); + } + const keepsAlpha = variant === "vp8"; + 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) => + spawnFfmpeg( + ffmpegPath, + [ + "-y", + ...(range ? ["-ss", String(range.startSeconds)] : []), + ...decoderArgs, + "-i", + sourcePath, + ...(range ? ["-t", String(range.lengthSeconds)] : []), + "-vf", + videoFilter, + ...(variant === "vp8" ? VP8_ARGS : H264_ARGS), + target, + ], + timeoutMs, + signal, + ); + 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), + ); +} +function concatArgs(listPath: string, target: string, variant: ProxyVariant): string[] { + return [ + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + listPath, + "-c", + "copy", + ...(variant === "h264" ? ["-movflags", "+faststart"] : []), + target, + ]; +} + +function spawnFfmpeg( + ffmpegPath: string, + args: string[], + timeoutMs: number, + signal: AbortSignal, +): Promise { + 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 @@ -508,7 +650,7 @@ async function runFfmpeg( signal.throwIfAborted(); const proc = spawn(ffmpegPath, args, { stdio: ["ignore", "ignore", "pipe"], - timeout: TRANSCODE_TIMEOUT_MS, + timeout: timeoutMs, killSignal: "SIGKILL", signal, windowsHide: true, @@ -527,7 +669,7 @@ async function runFfmpeg( } else if (signal) { reject( new ProxyTranscodeError( - `ffmpeg killed by ${signal} (timeout ${TRANSCODE_TIMEOUT_MS}ms or external kill)`, + `ffmpeg killed by ${signal} (timeout ${timeoutMs}ms or external kill)`, null, stderrTail, ), @@ -562,7 +704,18 @@ async function transcodeToCache( mkdirWithinProject(projectDir, cacheDir); const tempPath = join(cacheDir, `.tmp-${randomUUID()}-${basename(cachePath)}`); try { - await runFfmpeg(absoluteSourcePath, tempPath, variant, box, job.abandoned.signal); + const chunkPrefix = join( + cacheDir, + `${CHUNK_PREFIX}${basename(cachePath, extname(cachePath))}-`, + ); + await runFfmpeg( + absoluteSourcePath, + tempPath, + chunkPrefix, + variant, + box, + job.abandoned.signal, + ); renameSync(tempPath, cachePath); maintainProxyCache(cacheDir); return cachePath; From ce5e5f69652527739654671cc07ebd7cc4ce96dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 00:45:07 -0400 Subject: [PATCH 04/11] fix(studio-server): keep finished copy pieces while their copy runs The short limit for dead partial copies would delete the first pieces of a long copy that is still encoding. Finished pieces now follow their own one-hour limit and are never swept while their copy is in flight. --- .../src/helpers/proxyCache.test.ts | 16 +++++++--- .../studio-server/src/helpers/proxyCache.ts | 31 ++++++++++++++++--- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/studio-server/src/helpers/proxyCache.test.ts b/packages/studio-server/src/helpers/proxyCache.test.ts index d183ddf1a2a..9a4222c0fb0 100644 --- a/packages/studio-server/src/helpers/proxyCache.test.ts +++ b/packages/studio-server/src/helpers/proxyCache.test.ts @@ -115,25 +115,31 @@ describe("cleanupProxyCache", () => { expect(existsSync(inFlight)).toBe(true); }); - it("sweeps stale pieces of a stopped long copy and keeps fresh ones", () => { + it("sweeps stale pieces of a stopped long copy, but not while its copy runs or within the hour", () => { const cache = cacheDir(); const now = 1_800_000_000_000; + const hour = 60 * 60 * 1000; const key = basename(proxy(cache, "long"), ".mp4"); + const runningKey = basename(proxy(cache, "running"), ".mp4"); const stalePiece = join(cache, `.part-${key}-0.mp4`); const stalePieceTemp = join(cache, `.tmp-${randomUUID()}-.part-${key}-1.mp4`); const freshPiece = join(cache, `.part-${key}-2.mp4`); - writeEntry(stalePiece, 3, now - 2 * 60 * 60 * 1000); - writeEntry(stalePieceTemp, 3, now - 2 * 60 * 60 * 1000); - writeEntry(freshPiece, 3, now - 60 * 1000); + const runningPiece = join(cache, `.part-${runningKey}-0.mp4`); + writeEntry(stalePiece, 3, now - 2 * hour); + writeEntry(stalePieceTemp, 3, now - 2 * hour); + writeEntry(freshPiece, 3, now - 11 * 60 * 1000); + writeEntry(runningPiece, 3, now - 2 * hour); const result = cleanupProxyCache(cache, { now, - staleTempMs: 60 * 60 * 1000, + staleTempMs: 10 * 60 * 1000, minSweepIntervalMs: 0, + protectedPaths: new Set([join(cache, `${runningKey}.mp4`)]), }); expect(result.removed.sort()).toEqual([stalePiece, stalePieceTemp].sort()); expect(existsSync(freshPiece)).toBe(true); + expect(existsSync(runningPiece)).toBe(true); }); it("never removes a file the transcoder did not name, even from an idle cache over budget", () => { diff --git a/packages/studio-server/src/helpers/proxyCache.ts b/packages/studio-server/src/helpers/proxyCache.ts index 3303e19c0a7..4de5f97e92d 100644 --- a/packages/studio-server/src/helpers/proxyCache.ts +++ b/packages/studio-server/src/helpers/proxyCache.ts @@ -9,9 +9,11 @@ const PROXY_EXTENSIONS: ReadonlySet = new Set( Object.values(PROXY_VARIANT_CONFIG).map(({ extension }) => extension), ); const TRANSCODER_PROXY_NAME = /^[0-9a-f]{64}\.\w+$/; -// A temp copy, or a temp piece of a long copy (`.part--`); a finished piece waits for a restart under its `.part-` name. +// A temp copy, or a temp piece of a long copy (`.part--`). const TRANSCODER_TEMP_NAME = /^\.tmp-[0-9a-f-]{36}-(?:\.part-[0-9a-f]{64}-\d+|[0-9a-f]{64})\.\w+$/; -const TRANSCODER_PIECE_NAME = /^\.part-[0-9a-f]{64}-\d+\.\w+$/; +// A finished piece waits here for a stopped copy to continue; it outlives the short temp limit. +const TRANSCODER_PIECE_NAME = /^\.part-([0-9a-f]{64})-\d+(\.\w+)$/; +const STALE_PIECE_MS = 60 * 60 * 1000; export interface ProxyCacheCleanupOptions { maxBytes?: number; @@ -65,6 +67,25 @@ function shouldSkipSweep(cacheDir: string, now: number, minSweepIntervalMs: numb return false; } +function isLeftoverName(name: string): boolean { + return TRANSCODER_TEMP_NAME.test(name) || TRANSCODER_PIECE_NAME.test(name); +} + +/** A temp file is dead once untouched for `staleTempMs`; a finished piece once untouched for an hour and its copy is not running. */ +function leftoverIsDead( + cacheDir: string, + name: string, + modifiedAt: number, + protectedPaths: ReadonlySet, + now: number, + staleTempMs: number, +): boolean { + const piece = TRANSCODER_PIECE_NAME.exec(name); + if (!piece) return now - modifiedAt >= staleTempMs; + const copyIsRunning = protectedPaths.has(join(cacheDir, `${piece[1]}${piece[2]}`)); + return !copyIsRunning && now - modifiedAt >= STALE_PIECE_MS; +} + function readCacheInventory( cacheDir: string, protectedPaths: ReadonlySet, @@ -83,8 +104,10 @@ function readCacheInventory( modifiedAt: stat.mtimeMs, protected: protectedPaths.has(path), }; - if (TRANSCODER_TEMP_NAME.test(dirent.name) || TRANSCODER_PIECE_NAME.test(dirent.name)) { - if (now - stat.mtimeMs >= staleTempMs) staleTemps.push(entry); + if (isLeftoverName(dirent.name)) { + if (leftoverIsDead(cacheDir, dirent.name, stat.mtimeMs, protectedPaths, now, staleTempMs)) { + staleTemps.push(entry); + } } else if ( TRANSCODER_PROXY_NAME.test(dirent.name) && PROXY_EXTENSIONS.has(extname(dirent.name)) From 90557abed7c330a9bb1db85a6c4cc7fa8dd9d656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 01:04:08 -0400 Subject: [PATCH 05/11] refactor(studio-server): shorten two comments The comment gate limits a comment line to 120 characters and a run to four lines. --- packages/studio-server/src/helpers/proxyCache.ts | 2 +- packages/studio-server/src/helpers/proxyTranscoder.ts | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/studio-server/src/helpers/proxyCache.ts b/packages/studio-server/src/helpers/proxyCache.ts index 4de5f97e92d..8685c1d9d30 100644 --- a/packages/studio-server/src/helpers/proxyCache.ts +++ b/packages/studio-server/src/helpers/proxyCache.ts @@ -71,7 +71,7 @@ function isLeftoverName(name: string): boolean { return TRANSCODER_TEMP_NAME.test(name) || TRANSCODER_PIECE_NAME.test(name); } -/** A temp file is dead once untouched for `staleTempMs`; a finished piece once untouched for an hour and its copy is not running. */ +/** Temp files die after `staleTempMs`; a finished piece after an hour, and never while its copy runs. */ function leftoverIsDead( cacheDir: string, name: string, diff --git a/packages/studio-server/src/helpers/proxyTranscoder.ts b/packages/studio-server/src/helpers/proxyTranscoder.ts index d3e94cecdac..3aa5df973d3 100644 --- a/packages/studio-server/src/helpers/proxyTranscoder.ts +++ b/packages/studio-server/src/helpers/proxyTranscoder.ts @@ -458,12 +458,7 @@ function ffmpegBudgetError(chunksKept: number, chunkCount: number): ProxyTransco type EncodeFn = (outputPath: string, timeoutMs: number, range?: ChunkRange) => Promise; -/** - * Copies a long source in pieces. Each finished piece stays under a stable - * name in the cache directory, so a copy that is killed or runs out of time - * continues with the first missing piece instead of starting over. The pieces - * start on keyframes of their own, so joining them without re-encoding is seamless. - */ +/** Copies a long source in pieces that stay in the cache, so a stopped copy continues from the first missing one. */ async function encodeInChunks( ranges: ChunkRange[], chunkPrefix: string, From 415bf3dbb80dc86520858f415dd6cc712059ecc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 01:13:24 -0400 Subject: [PATCH 06/11] refactor(studio-server): drop comments the names already carry The comment ratchet lets a file's comment share only go down. --- packages/studio-server/src/helpers/mediaMetadata.ts | 1 - packages/studio-server/src/helpers/proxyCache.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/studio-server/src/helpers/mediaMetadata.ts b/packages/studio-server/src/helpers/mediaMetadata.ts index c43b0a6987c..78fff6cb5c1 100644 --- a/packages/studio-server/src/helpers/mediaMetadata.ts +++ b/packages/studio-server/src/helpers/mediaMetadata.ts @@ -76,7 +76,6 @@ export interface MediaMetadata { hasAudio?: boolean; /** Video stream carries alpha (pix_fmt or the VP8/VP9 alpha_mode tag). */ hasAlpha?: boolean; - /** Container duration in seconds, when ffprobe reports one. */ durationSeconds?: number; probeError?: string; } diff --git a/packages/studio-server/src/helpers/proxyCache.ts b/packages/studio-server/src/helpers/proxyCache.ts index 8685c1d9d30..fba82e1489d 100644 --- a/packages/studio-server/src/helpers/proxyCache.ts +++ b/packages/studio-server/src/helpers/proxyCache.ts @@ -9,9 +9,7 @@ const PROXY_EXTENSIONS: ReadonlySet = new Set( Object.values(PROXY_VARIANT_CONFIG).map(({ extension }) => extension), ); const TRANSCODER_PROXY_NAME = /^[0-9a-f]{64}\.\w+$/; -// A temp copy, or a temp piece of a long copy (`.part--`). const TRANSCODER_TEMP_NAME = /^\.tmp-[0-9a-f-]{36}-(?:\.part-[0-9a-f]{64}-\d+|[0-9a-f]{64})\.\w+$/; -// A finished piece waits here for a stopped copy to continue; it outlives the short temp limit. const TRANSCODER_PIECE_NAME = /^\.part-([0-9a-f]{64})-\d+(\.\w+)$/; const STALE_PIECE_MS = 60 * 60 * 1000; @@ -71,7 +69,6 @@ function isLeftoverName(name: string): boolean { return TRANSCODER_TEMP_NAME.test(name) || TRANSCODER_PIECE_NAME.test(name); } -/** Temp files die after `staleTempMs`; a finished piece after an hour, and never while its copy runs. */ function leftoverIsDead( cacheDir: string, name: string, From 72ae34958e8f796f85e5e9466f03f2d4528cf023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 01:59:00 -0400 Subject: [PATCH 07/11] fix(cli): the check server pre-warms the copy the page asks for, not the source-size one The server started a source-size copy of every hostile video next to Chrome on each check, because its pre-warm passed no box while the page and the up-front copy use the composition's box. The pre-warm now takes the same box, found by the one helper the up-front copy uses. --- packages/cli/src/utils/checkBrowser.ts | 7 +++-- packages/cli/src/utils/compositionViewport.ts | 6 +++++ .../cli/src/utils/staticProjectServer.test.ts | 20 ++++++++++++++ packages/cli/src/utils/staticProjectServer.ts | 11 +++++++- .../src/helpers/mediaProxyPreview.test.ts | 27 +++++++++++++++++++ .../src/helpers/mediaProxyPreview.ts | 3 ++- 6 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 101ffecdd36..38e3db4b1c6 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -33,8 +33,8 @@ import type { LayoutIssue, LayoutIssueCode, LayoutRect } from "./layoutAudit.js" import { serveStaticProjectHtml } from "./staticProjectServer.js"; import { collectTweenRecords, findTweenTails, tweenTailMessage } from "./gsapTweenTails.js"; import { resolveAutoProxy } from "./projectConfig.js"; -import { findCompositionDimensions } from "./compositionViewport.js"; -import { quantizePreviewProxyBox, type PreviewProxyBox } from "@hyperframes/core"; +import { findStageProxyBox } from "./compositionViewport.js"; +import type { PreviewProxyBox } from "@hyperframes/core"; import { decideMediaProxyEligibility, proxyVariantFor, @@ -164,8 +164,7 @@ export async function preResolveHostileMediaProxies( if (hostileEntries.length === 0) return none; const startedAt = Date.now(); - const stage = findCompositionDimensions(html); - const stageBox = (stage && quantizePreviewProxyBox(stage.width, stage.height)) || undefined; + const stageBox = findStageProxyBox(html); const sources = hostileEntries.map(([pathname, facts]) => { const file = pathname.replace(/^\/+/, ""); return { diff --git a/packages/cli/src/utils/compositionViewport.ts b/packages/cli/src/utils/compositionViewport.ts index 9502ac23b23..2b9364e1cb1 100644 --- a/packages/cli/src/utils/compositionViewport.ts +++ b/packages/cli/src/utils/compositionViewport.ts @@ -1,3 +1,4 @@ +import { quantizePreviewProxyBox, type PreviewProxyBox } from "@hyperframes/core"; import { ensureDOMParser } from "./dom.js"; const DEFAULT_VIEWPORT = { width: 1920, height: 1080 } as const; @@ -28,6 +29,11 @@ export function findCompositionDimensions(html: string): { width: number; height return { width, height }; } +export function findStageProxyBox(html: string): PreviewProxyBox | undefined { + const stage = findCompositionDimensions(html); + return (stage && quantizePreviewProxyBox(stage.width, stage.height)) || undefined; +} + export function resolveCompositionViewportFromHtml(html: string): { width: number; height: number; diff --git a/packages/cli/src/utils/staticProjectServer.test.ts b/packages/cli/src/utils/staticProjectServer.test.ts index 0064a080625..f6d360f51e5 100644 --- a/packages/cli/src/utils/staticProjectServer.test.ts +++ b/packages/cli/src/utils/staticProjectServer.test.ts @@ -363,6 +363,26 @@ describe("serveStaticProjectHtml transparent media proxies", () => { ); }); + it("pre-warms the copy the page will ask for, not the source-size one", async () => { + const projectDir = mk(); + const html = + '
'; + const { injectMediaCodecMapIntoHtml } = + await import("@hyperframes/studio-server/media-proxy-preview"); + + server = await serveStaticProjectHtml(projectDir, html); + + expect(injectMediaCodecMapIntoHtml).toHaveBeenLastCalledWith( + html, + projectDir, + [{ html }], + undefined, + true, + expect.any(AbortSignal), + { width: 2048, height: 1448 }, + ); + }); + it("resolves the copy named by hf-proxy-box, the one check made before the page opened", async () => { const projectDir = mk(); writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes"); diff --git a/packages/cli/src/utils/staticProjectServer.ts b/packages/cli/src/utils/staticProjectServer.ts index bfabafbd72a..2a19572973f 100644 --- a/packages/cli/src/utils/staticProjectServer.ts +++ b/packages/cli/src/utils/staticProjectServer.ts @@ -9,6 +9,7 @@ import { } from "@hyperframes/core"; import { getMimeType } from "@hyperframes/core/studio-api"; import { decodeWellFormedEscapes } from "@hyperframes/studio-server"; +import { findStageProxyBox } from "./compositionViewport.js"; import { resolveAutoProxy } from "./projectConfig.js"; import { injectMediaCodecMap } from "./compositionServer.js"; import { @@ -269,7 +270,15 @@ export async function serveStaticProjectHtml( const autoProxy = resolveAutoProxy(projectDir, autoProxyOverride); const copies = new AbortController(); const servedHtml = autoProxy - ? await injectMediaCodecMap(html, projectDir, [{ html }], undefined, true, copies.signal) + ? await injectMediaCodecMap( + html, + projectDir, + [{ html }], + undefined, + true, + copies.signal, + findStageProxyBox(html), + ) : html; // fallow-ignore-next-line complexity diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.test.ts b/packages/studio-server/src/helpers/mediaProxyPreview.test.ts index d4196703dfb..febb29967d4 100644 --- a/packages/studio-server/src/helpers/mediaProxyPreview.test.ts +++ b/packages/studio-server/src/helpers/mediaProxyPreview.test.ts @@ -84,6 +84,33 @@ describe("injectMediaCodecMapIntoHtml pre-warm", () => { expect(html).toContain("/videos/broll.mov"); }); + it("pre-warms the copy the page will ask for when given its box", async () => { + const projectDir = tmpProject(); + const { injectMediaCodecMapIntoHtml, resolveProxy } = await loadHelper({ + "/videos/broll.mov": hostile("hevc", 'video/mp4; codecs="hvc1.1.6.L120.B0"'), + }); + const box = { width: 2048, height: 1448 }; + + await injectMediaCodecMapIntoHtml( + "", + projectDir, + [{ html: "" }], + undefined, + true, + undefined, + box, + ); + await settle(); + + expect(resolveProxy).toHaveBeenCalledWith( + projectDir, + resolve(projectDir, "videos/broll.mov"), + "h264", + box, + { signal: undefined }, + ); + }); + it("counts the pre-warms it requests, and none for a widely-decoded codec", async () => { const projectDir = tmpProject(); const { injectMediaCodecMapIntoHtml, mediaProxyDemand } = await loadHelper({ diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.ts b/packages/studio-server/src/helpers/mediaProxyPreview.ts index d3c62ce7750..3ef8c41e08f 100644 --- a/packages/studio-server/src/helpers/mediaProxyPreview.ts +++ b/packages/studio-server/src/helpers/mediaProxyPreview.ts @@ -95,6 +95,7 @@ export async function injectMediaCodecMapIntoHtml( prewarm = true, /** Aborting it stops the pre-warms nobody else waits for. */ signal?: AbortSignal, + box?: PreviewProxyBox, ): Promise { let map: MediaCodecMap; try { @@ -115,7 +116,7 @@ export async function injectMediaCodecMapIntoHtml( projectDir, resolve(projectDir, rootRelativePathname.replace(/^\/+/, "")), proxyVariantFor(facts), - undefined, + box, { signal }, ).catch(() => { // Swallowed: the pre-warm is best-effort. A real `?hf-proxy=` request From bc1e44a1d21f67c28bd8f000aa1799f16ca724b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 08:34:34 -0400 Subject: [PATCH 08/11] fix(cli): end a check on no progress, not on a 120 s wall clock The check ended 120 s after Chrome was ready no matter what it was doing, so a long project whose sampling and overlap pass each kept working still failed (the overlap pass was one page call that reported nothing for its whole run). The deadline is now an idle clock: every phase entered, every sample finished, every overlap batch of eight times and every operation that settles restarts it, and the check ends only after 120 s with none. The overlap grid is asked for in batches so it can report between them. --- packages/cli/src/commands/check.test.ts | 50 ++++++++++++++++++++ packages/cli/src/utils/checkDeadline.test.ts | 44 +++++++++++++++-- packages/cli/src/utils/checkDeadline.ts | 18 +++++-- packages/cli/src/utils/checkPipeline.ts | 22 ++++++++- 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 20b2f2efa32..4b8e4b39dcb 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -1704,6 +1704,56 @@ describe("check pipeline", () => { ); }); + it("slow samples that each finish keep the check alive past 120 s", async () => { + vi.useFakeTimers(); + try { + const collectLayout = vi.fn(async (_time: number, _tolerance: number) => { + await new Promise((resolve) => setTimeout(resolve, 30_000)); + return []; + }); + const driver = fakeDriver({ collectLayout }); + const { deps } = dependencies(driver); + deps.runBrowserCheck = async (_project, options, motion, deadline) => { + deadline!.start(); + return runAuditGrid(driver, options, motion, undefined, deadline!.enter); + }; + const pending = runCheckPipeline(PROJECT, DEFAULT_CHECK_OPTIONS, deps); + await vi.advanceTimersByTimeAsync(2_000_000); + const report = await pending; + expect(report.runtime.findings.map((finding) => finding.code)).not.toContain( + "check_runtime_failure", + ); + expect(collectLayout.mock.calls.length * 30).toBeGreaterThan(120); + } finally { + vi.useRealTimers(); + } + }); + + it("a long overlap pass that keeps reporting progress finishes instead of hitting a wall clock", async () => { + vi.useFakeTimers(); + try { + const collectOverlapGrid = vi.fn(async (times: number[]) => { + await new Promise((resolve) => setTimeout(resolve, 10_000 * times.length)); + return times.map((): AnchoredLayoutIssue[] => []); + }); + const driver = fakeDriver({ collectOverlapGrid }); + const { deps } = dependencies(driver); + deps.runBrowserCheck = async (_project, options, motion, deadline) => { + deadline!.start(); + return runAuditGrid(driver, options, motion, undefined, deadline!.enter); + }; + const pending = runCheckPipeline(PROJECT, DEFAULT_CHECK_OPTIONS, deps); + await vi.advanceTimersByTimeAsync(2_000_000); + const report = await pending; + expect(report.runtime.findings.map((finding) => finding.code)).not.toContain( + "check_runtime_failure", + ); + expect(collectOverlapGrid.mock.calls.length).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + it("a deadline that fires in the overlap pass names that pass in its finding", async () => { vi.useFakeTimers(); try { diff --git a/packages/cli/src/utils/checkDeadline.test.ts b/packages/cli/src/utils/checkDeadline.test.ts index feec6241819..deb30ae7f8e 100644 --- a/packages/cli/src/utils/checkDeadline.test.ts +++ b/packages/cli/src/utils/checkDeadline.test.ts @@ -11,7 +11,7 @@ function rejectOnAbort(signal: AbortSignal): Promise { }); } -it("readiness and later sampling spend one 120-second budget", async () => { +it("an idle check ends 120 s after its last progress", async () => { vi.useFakeTimers(); const deadline = new CheckDeadline(); deadline.start(); @@ -112,13 +112,49 @@ it("an expired check names the phase it was in and the seconds each phase took", deadline.enter("samples"); await vi.advanceTimersByTimeAsync(50_000); deadline.enter("overlap"); - await vi.advanceTimersByTimeAsync(7_000); + await vi.advanceTimersByTimeAsync(120_000); expect(((await outcome) as Error).message).toBe( - "Check did not finish within its deadline (120 s from when Chrome was ready), while running the overlap pass. " + + "Check did not finish within its deadline (no progress for 120 s after Chrome was ready), while running the overlap pass. " + "Seconds per phase: making video copies 247.0 (before the deadline), starting Chrome 2.0 (before the deadline), " + - "loading the page 3.0, waiting for the page to be ready 60.0, checking samples 50.0, running the overlap pass 7.0.", + "loading the page 3.0, waiting for the page to be ready 60.0, checking samples 50.0, running the overlap pass 120.0.", ); } finally { await deadline.close(); } }); + +it("keeps going while each step makes progress, and ends once none does", async () => { + vi.useFakeTimers(); + const deadline = new CheckDeadline(); + const outcome = deadline + .run(() => rejectOnAbort(deadline.signal)) + .catch((error: unknown) => error); + try { + deadline.start(); + for (let step = 0; step < 10; step++) { + await vi.advanceTimersByTimeAsync(90_000); + deadline.enter("overlap"); + } + expect(deadline.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(120_000); + expect(await outcome).toBeInstanceOf(CheckDeadlineError); + } finally { + await deadline.close(); + } +}); + +it("an operation that settles restarts the idle clock", async () => { + vi.useFakeTimers(); + const deadline = new CheckDeadline(); + try { + deadline.start(); + await vi.advanceTimersByTimeAsync(100_000); + await deadline.run(async () => "done"); + await vi.advanceTimersByTimeAsync(100_000); + expect(deadline.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(20_000); + expect(deadline.signal.aborted).toBe(true); + } finally { + await deadline.close(); + } +}); diff --git a/packages/cli/src/utils/checkDeadline.ts b/packages/cli/src/utils/checkDeadline.ts index 097ff553d17..a7011b3694d 100644 --- a/packages/cli/src/utils/checkDeadline.ts +++ b/packages/cli/src/utils/checkDeadline.ts @@ -3,7 +3,7 @@ import { normalizeErrorMessage } from "./errorMessage.js"; import { withRemainingBudget } from "../capture/captureTimeout.js"; import { BrowserCleanupError, closeManagedBrowser } from "../browser/launch.js"; -const CHECK_DEADLINE_MS = 120_000; +const CHECK_IDLE_MS = 120_000; const SCRIPT_START = "[hyperframes:script:start] "; const SCRIPT_FINISH = "[hyperframes:script:finish] "; @@ -32,7 +32,7 @@ export class CheckDeadlineError extends Error { } } -/** One Node clock starts at the composition browser and spans readiness, sampling and crops. */ +/** Ends a check that makes no progress for CHECK_IDLE_MS; every phase entered and every operation settled restarts it. */ export class CheckDeadline { private readonly abort = new AbortController(); private readonly operations = new Set>(); @@ -59,6 +59,7 @@ export class CheckDeadline { if (this.work) this.spend(this.work.phase, now - this.work.since); this.work = { phase, since: now }; this.spend(phase, 0); + this.rearm(); }; private spend(phase: CheckPhase, ms: number): void { @@ -72,19 +73,24 @@ export class CheckDeadline { const seconds = ((phase === this.work?.phase ? ms + open : ms) / 1000).toFixed(1); return `${PHASE_LABELS[phase]} ${seconds}${BEFORE_DEADLINE.has(phase) ? " (before the deadline)" : ""}`; }); - return ` (${CHECK_DEADLINE_MS / 1000} s from when Chrome was ready), while ${PHASE_LABELS[this.work.phase]}. Seconds per phase: ${parts.join(", ")}.`; + return ` (no progress for ${CHECK_IDLE_MS / 1000} s after Chrome was ready), while ${PHASE_LABELS[this.work.phase]}. Seconds per phase: ${parts.join(", ")}.`; } start = (): void => { if (this.phase !== "waiting") return; this.phase = "started"; this.enter("load"); + }; + + private rearm(): void { + if (this.phase !== "started" || this.expired) return; + clearTimeout(this.timer); this.timer = setTimeout(() => { this.expired = new CheckDeadlineError(this.scripts.at(-1), this.describe()); this.rejectExpiry(this.expired); this.abort.abort(this.expired); - }, CHECK_DEADLINE_MS); - }; + }, CHECK_IDLE_MS); + } ownBrowser = (browser: Browser): void => { if (this.expired) throw this.expired; @@ -116,6 +122,8 @@ export class CheckDeadline { if (this.expired) return Promise.reject(this.expired); const pending = operation(); this.operations.add(pending); + const settled = (): void => this.rearm(); + pending.then(settled, settled); return Promise.race([pending, this.expiry]); } diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index b03eff4051f..0e60a77d5b6 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -531,6 +531,7 @@ async function collectGridSamples( taken.push(index); const time = times[index] ?? 0; perTime[index] = await collectSampleAt(worker, options, grid, motion, sets, time); + enter("samples"); } }; const helpWith = async (helper: CheckAuditDriver) => { @@ -567,6 +568,7 @@ async function collectGridSamples( collected, options.range, craftEnabled(driver, options), + () => enter("overlap"), ); enter("samples"); return collected; @@ -598,6 +600,7 @@ const OVERLAP_SAMPLE_FPS = 8; const OVERLAP_MAX_SAMPLES = 240; // Part of it re-looks at an overlap half a second on where the grid is too sparse to show it held. const OVERLAP_HOLD_PROBES = 60; +const OVERLAP_BATCH = 8; function evenlyPicked(times: number[], count: number): number[] { if (times.length <= count) return times; @@ -676,12 +679,26 @@ async function denseOverlapTimes( } /** Reruns content_overlap on a fine grid, unconditionally rather than gated on fingerprint change, since an animation aliased to the sparse grid collides between identical-fingerprint samples. */ +async function collectOverlapGridInBatches( + driver: CheckAuditDriver, + times: number[], + progress: () => void, +): Promise { + const issues: AnchoredLayoutIssue[][] = []; + for (let from = 0; from < times.length; from += OVERLAP_BATCH) { + issues.push(...(await driver.collectOverlapGrid(times.slice(from, from + OVERLAP_BATCH)))); + progress(); + } + return issues; +} + async function collectMotionOverlapSamples( driver: CheckAuditDriver, grid: SampleGrid, collected: GridSamples, range: [number, number] | undefined, craft: boolean, + progress: () => void, ): Promise { const baseTimes = new Set(grid.layoutSamples); const shiftedTimes = await denseOverlapTimes(driver, grid, range); @@ -692,16 +709,19 @@ async function collectMotionOverlapSamples( if (time >= grid.duration) continue; await driver.seekGeometry(time); collected.craftFrames.push(...(await collectCraftFrame(driver, time))); + progress(); } } const probeHold = holdProber(driver, collected, range ? range[1] : grid.duration); - const gridIssues = await driver.collectOverlapGrid(shiftedTimes.filter((t) => !baseTimes.has(t))); + const gridTimes = shiftedTimes.filter((t) => !baseTimes.has(t)); + const gridIssues = await collectOverlapGridInBatches(driver, gridTimes, progress); let sample = 0; for (const [index, time] of shiftedTimes.entries()) { if (baseTimes.has(time)) continue; const issues = gridIssues[sample++] ?? []; collected.layoutIssues.push(...issues); await probeHold(time, shiftedTimes[index + 1] ?? Number.POSITIVE_INFINITY, issues); + progress(); } } From b501527e2c4e37f6997692466fe4801569472fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 08:36:16 -0400 Subject: [PATCH 09/11] refactor(cli): shorten the idle deadline comment The comment gate allows 120 characters on a comment line. --- packages/cli/src/utils/checkDeadline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/checkDeadline.ts b/packages/cli/src/utils/checkDeadline.ts index a7011b3694d..06b0d36ee24 100644 --- a/packages/cli/src/utils/checkDeadline.ts +++ b/packages/cli/src/utils/checkDeadline.ts @@ -32,7 +32,7 @@ export class CheckDeadlineError extends Error { } } -/** Ends a check that makes no progress for CHECK_IDLE_MS; every phase entered and every operation settled restarts it. */ +/** Ends a check that makes no progress for CHECK_IDLE_MS; each phase entered or operation settled restarts it. */ export class CheckDeadline { private readonly abort = new AbortController(); private readonly operations = new Set>(); From 611175a0b176b3c13067aebc251e740e1658bc7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 08:59:09 -0400 Subject: [PATCH 10/11] test(cli): check's overlap grid runs in page tasks of at most 8 samples --- packages/cli/src/utils/checkBrowser.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index d4e68020536..18e5419b925 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -285,7 +285,7 @@ it("splits the samples over a second Chrome and reports what one Chrome reports" expect(sampled(unopened.messages)).toEqual(sampled(serial.messages)); }); -it("runs the dense overlap grid in one page task, keeping each sample's time on what it logs", async () => { +it("runs the dense overlap grid in page tasks of at most 8 samples, keeping each sample's time on what it logs", async () => { mountCanvasFixture( `

Hi

`, ); @@ -341,13 +341,20 @@ it("runs the dense overlap grid in one page task, keeping each sample's time on ); expect(denseAudits.length).toBeGreaterThan(10); - expect(new Set(denseAudits).size).toBe(1); + const perTask = Object.values(Object.groupBy(denseAudits, (calls) => calls)).map( + (g) => g?.length, + ); + expect(perTask.every((count) => (count ?? 0) <= 8)).toBe(true); + expect(perTask).toHaveLength(Math.ceil(denseAudits.length / 8)); expect(result.runtimeFindings).toContainEqual( expect.objectContaining({ code: "console_error", message: "thrown at 2.5", time: 2.5 }), ); - expect(result.runtimeFindings).toContainEqual( - expect.objectContaining({ message: "queued during the grid", time: 0, timeRange: [0, 10] }), + const queued = result.runtimeFindings.filter( + (finding) => finding.message === "queued during the grid", ); + expect(queued).toHaveLength(perTask.length); + expect(queued[0]).toMatchObject({ time: 0, timeRange: [0, expect.any(Number)] }); + expect(queued.at(-1)?.timeRange?.[1]).toBe(10); expect( result.runtimeFindings.find((finding) => finding.message === "thrown at 2.5"), ).not.toHaveProperty("timeRange"); From fe4b2e5472904b2587a99face7d3cbb3c81691b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 09:45:57 -0400 Subject: [PATCH 11/11] fix(cli): check test types for the cli lib, studio-server size budget for resumable copies --- packages/cli/src/utils/checkBrowser.test.ts | 8 ++++---- scripts/package-size-budgets.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index 18e5419b925..213a0f17dde 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -341,10 +341,10 @@ it("runs the dense overlap grid in page tasks of at most 8 samples, keeping each ); expect(denseAudits.length).toBeGreaterThan(10); - const perTask = Object.values(Object.groupBy(denseAudits, (calls) => calls)).map( - (g) => g?.length, - ); - expect(perTask.every((count) => (count ?? 0) <= 8)).toBe(true); + const tasks = new Map(); + for (const calls of denseAudits) tasks.set(calls, (tasks.get(calls) ?? 0) + 1); + const perTask = [...tasks.values()]; + expect(Math.max(...perTask)).toBeLessThanOrEqual(8); expect(perTask).toHaveLength(Math.ceil(denseAudits.length / 8)); expect(result.runtimeFindings).toContainEqual( expect.objectContaining({ code: "console_error", message: "thrown at 2.5", time: 2.5 }), diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 60136d0215d..17670d47d54 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -52,7 +52,7 @@ "unpacked": 24360208 }, "@hyperframes/studio-server": { - "packed": 141718, + "packed": 142100, "unpacked": 559199 } }