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;