diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index bec3a8c1167..e325024d42c 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..4b8e4b39dcb 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" } }), }); @@ -1631,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 { @@ -2320,6 +2443,7 @@ describe("layout flag grammar", () => { const command = createCheckCommand({ resolveProject: () => PROJECT, runPipeline, + prepareMedia: vi.fn(async () => []), withMeta: (value) => value, }); @@ -2474,6 +2598,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 7e898b3c9ed..213a0f17dde 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), ), ), })); @@ -268,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

`, ); @@ -324,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 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 }), ); - 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"); @@ -1099,6 +1123,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( @@ -1134,6 +1190,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"), @@ -1205,7 +1262,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", @@ -1231,6 +1292,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 74f88083cca..38e3db4b1c6 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 { findStageProxyBox } from "./compositionViewport.js"; +import type { PreviewProxyBox } from "@hyperframes/core"; import { decideMediaProxyEligibility, proxyVariantFor, @@ -145,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 { @@ -162,6 +164,7 @@ export async function preResolveHostileMediaProxies( if (hostileEntries.length === 0) return none; const startedAt = Date.now(); + const stageBox = findStageProxyBox(html); const sources = hostileEntries.map(([pathname, facts]) => { const file = pathname.replace(/^\/+/, ""); return { @@ -169,10 +172,12 @@ export async function preResolveHostileMediaProxies( file, sourcePath: resolve(projectDir, file), variant: proxyVariantFor(facts), + box: stageBox, }; }); 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.`, @@ -205,24 +210,43 @@ 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( 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; } } +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/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..06b0d36ee24 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; each phase entered or 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(); } } 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 2992bd8958f..f6d360f51e5 100644 --- a/packages/cli/src/utils/staticProjectServer.test.ts +++ b/packages/cli/src/utils/staticProjectServer.test.ts @@ -363,6 +363,58 @@ 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"); + 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..2a19572973f 100644 --- a/packages/cli/src/utils/staticProjectServer.ts +++ b/packages/cli/src/utils/staticProjectServer.ts @@ -2,8 +2,14 @@ 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 { findStageProxyBox } from "./compositionViewport.js"; import { resolveAutoProxy } from "./projectConfig.js"; import { injectMediaCodecMap } from "./compositionServer.js"; import { @@ -205,6 +211,7 @@ async function serveProxyRequest( projectDir: string, filePath: string, request: ProxyVariantRequest, + box: PreviewProxyBox | undefined, autoProxy: boolean, rangeHeader: string | undefined, res: ServerResponse, @@ -236,7 +243,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); @@ -263,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 @@ -277,10 +292,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 +315,7 @@ export async function serveStaticProjectHtml( projectDir, filePath, proxyRequest, + proxyBox, autoProxy, req.headers.range, res, 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/mediaMetadata.ts b/packages/studio-server/src/helpers/mediaMetadata.ts index ccfb415fa03..78fff6cb5c1 100644 --- a/packages/studio-server/src/helpers/mediaMetadata.ts +++ b/packages/studio-server/src/helpers/mediaMetadata.ts @@ -76,6 +76,7 @@ export interface MediaMetadata { hasAudio?: boolean; /** Video stream carries alpha (pix_fmt or the VP8/VP9 alpha_mode tag). */ hasAlpha?: boolean; + durationSeconds?: number; probeError?: string; } @@ -215,7 +216,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 +233,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 +245,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/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 diff --git a/packages/studio-server/src/helpers/proxyCache.test.ts b/packages/studio-server/src/helpers/proxyCache.test.ts index 2515463a018..9a4222c0fb0 100644 --- a/packages/studio-server/src/helpers/proxyCache.test.ts +++ b/packages/studio-server/src/helpers/proxyCache.test.ts @@ -115,6 +115,33 @@ describe("cleanupProxyCache", () => { expect(existsSync(inFlight)).toBe(true); }); + 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`); + 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: 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", () => { 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..fba82e1489d 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+$/; +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+)$/; +const STALE_PIECE_MS = 60 * 60 * 1000; export interface ProxyCacheCleanupOptions { maxBytes?: number; @@ -63,6 +65,24 @@ 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); +} + +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, @@ -81,8 +101,10 @@ function readCacheInventory( modifiedAt: stat.mtimeMs, protected: protectedPaths.has(path), }; - if (TRANSCODER_TEMP_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)) diff --git a/packages/studio-server/src/helpers/proxyTranscoder.test.ts b/packages/studio-server/src/helpers/proxyTranscoder.test.ts index f4ba57d4af2..520551a02ac 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, @@ -143,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", () => { @@ -156,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( @@ -647,6 +712,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..3aa5df973d3 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,11 +54,16 @@ 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; const MAX_FAILURE_CACHE_ENTRIES = 128; export const DEFAULT_PROXY_WAIT_TIMEOUT_MS = 2 * 60 * 1000; +const UNTOUCHED_TEMP_IS_DEAD_MS = 10 * 60 * 1000; export class ProxyTranscodeError extends Error { readonly exitCode: number | null; @@ -315,9 +330,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( @@ -416,19 +431,132 @@ 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 that stay in the cache, so a stopped copy continues from the first missing one. */ +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); @@ -436,70 +564,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 @@ -507,7 +645,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, @@ -526,7 +664,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, ), @@ -561,7 +699,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; @@ -660,16 +809,26 @@ 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)), + Math.min( + Number(process.env.HYPERFRAMES_PROXY_CACHE_STALE_TEMP_MS) || UNTOUCHED_TEMP_IS_DEAD_MS, + UNTOUCHED_TEMP_IS_DEAD_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 }), 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 } }