perf(engine): one-pass SDR-to-HDR extraction with cache-key transform (#1902)

* perf(engine): one-pass SDR-to-HDR extraction with cache-key transform

Mixed-HDR compositions converted each SDR source with a full libx264
re-encode (convertSdrToHdr) before extraction. The BT.709 to BT.2020
colorspace remap now runs as a filter inside the extraction pass
itself; convertSdrToHdr and the _hdr_normalized intermediate are
deleted. Same shape as the earlier one-pass VFR change.

Also fixes a cache-poisoning bug this exposed: the HDR preflight
rewrote entry.videoPath AFTER the cache-key snapshot, so a mixed-HDR
render cached converted frames under the plain source key and a later
SDR render of the same trim would have served HDR-tinted frames. The
cache key now carries an optional transform discriminator; keys
without a transform stay byte-compatible with existing entries.

* fix(engine): attribute SDR-to-HDR extract failures, pin filter-order intent

Review hardening for one-pass SDR-to-HDR:

- ffmpeg failures now carry an 'SDR→HDR conversion failed (colorspace
  filter in extract pass)' prefix when the remap is in the chain, so a
  filter-less ffmpeg build fails loudly with attribution instead of a
  generic extract error.
- Comments pin the fps-before-colorspace ordering intent and mark
  sdrToHdrTransfers as the canonical read for both the cache key and
  extraction options.
- Cross-render cache-poisoning regression test now compares frame
  BYTES across the cache boundary: mixed-HDR render then plain-SDR
  render of the same trim must produce different pixels, and a repeat
  plain render must hit the plain entry with byte-identical frames.
This commit is contained in:
Miguel Ángel
2026-07-03 15:08:51 -07:00
committed by GitHub
parent 397c3ba7a8
commit 48f158a0c2
4 changed files with 204 additions and 184 deletions
@@ -133,6 +133,20 @@ describe("computeCacheKey", () => {
expect(a).not.toBe(b); expect(a).not.toBe(b);
}); });
it("changes when a source transform is applied", () => {
const plain = computeCacheKey(base(sourceFile));
const transformedInput = { ...base(sourceFile), transform: "sdr2hdr-pq" };
const transformed = computeCacheKey(transformedInput);
expect(transformed).not.toBe(plain);
});
it("keeps undefined transform byte-compatible with omitted transform", () => {
const omitted = computeCacheKey(base(sourceFile));
const input = { ...base(sourceFile), transform: undefined };
const explicitUndefined = computeCacheKey(input);
expect(explicitUndefined).toBe(omitted);
});
it("normalizes non-finite duration so Infinity doesn't produce unstable keys", () => { it("normalizes non-finite duration so Infinity doesn't produce unstable keys", () => {
const a = computeCacheKey({ ...base(sourceFile), duration: Infinity }); const a = computeCacheKey({ ...base(sourceFile), duration: Infinity });
const b = computeCacheKey({ ...base(sourceFile), duration: Infinity }); const b = computeCacheKey({ ...base(sourceFile), duration: Infinity });
@@ -5,7 +5,8 @@
* after capture. Repeat renders of the same composition (preview → final, * after capture. Repeat renders of the same composition (preview → final,
* studio iteration) re-extract identical frames from the same source file, * studio iteration) re-extract identical frames from the same source file,
* burning ffmpeg time that adds no value. This module keys extracted frame * burning ffmpeg time that adds no value. This module keys extracted frame
* bundles on the (path, mtime, size, mediaStart, duration, fps, format) * bundles on the (path, mtime, size, mediaStart, duration, fps, format,
* optional transform)
* tuple so re-renders resolve to a pre-extracted directory instead of * tuple so re-renders resolve to a pre-extracted directory instead of
* re-invoking ffmpeg. * re-invoking ffmpeg.
* *
@@ -84,6 +85,8 @@ export interface CacheKeyInput {
fps: number; fps: number;
/** Output image format. */ /** Output image format. */
format: CacheFrameFormat; format: CacheFrameFormat;
/** Optional source transform applied during extraction. */
transform?: string;
} }
export interface CacheEntry { export interface CacheEntry {
@@ -124,7 +127,16 @@ export function readKeyStat(videoPath: string): { mtimeMs: number; size: number
function canonicalKeyBlob(input: CacheKeyInput): string { function canonicalKeyBlob(input: CacheKeyInput): string {
const durationForKey = Number.isFinite(input.duration) ? input.duration : -1; const durationForKey = Number.isFinite(input.duration) ? input.duration : -1;
return JSON.stringify({ const blob: {
p: string;
m: number;
s: number;
ms: number;
d: number;
f: number;
fmt: CacheFrameFormat;
t?: string;
} = {
p: input.videoPath, p: input.videoPath,
m: input.mtimeMs, m: input.mtimeMs,
s: input.size, s: input.size,
@@ -132,7 +144,9 @@ function canonicalKeyBlob(input: CacheKeyInput): string {
d: durationForKey, d: durationForKey,
f: input.fps, f: input.fps,
fmt: input.format, fmt: input.format,
}); };
if (input.transform !== undefined) blob.t = input.transform;
return JSON.stringify(blob);
} }
/** /**
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { import {
existsSync, existsSync,
mkdirSync, mkdirSync,
readFileSync,
mkdtempSync, mkdtempSync,
readdirSync, readdirSync,
rmSync, rmSync,
@@ -16,6 +17,7 @@ import {
parseVideoElements, parseVideoElements,
parseImageElements, parseImageElements,
extractAllVideoFrames, extractAllVideoFrames,
extractVideoFramesRange,
createFrameLookupTable, createFrameLookupTable,
resolveProjectRelativeSrc, resolveProjectRelativeSrc,
resolveFrameFormat, resolveFrameFormat,
@@ -868,6 +870,54 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
}); });
} }
async function synthHdrTaggedClip(name: string, durationSeconds: number): Promise<string> {
const src = join(FIXTURE_DIR, name);
const synth = await runFfmpeg([
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
`testsrc2=s=320x180:d=${durationSeconds}:rate=30`,
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-color_primaries",
"bt2020",
"-color_trc",
"smpte2084",
"-colorspace",
"bt2020nc",
src,
]);
if (!synth.success) {
throw new Error(`HDR fixture synthesis failed (${name}): ${synth.stderr.slice(-400)}`);
}
return src;
}
function cacheEntryNames(cacheDir: string): string[] {
return readdirSync(cacheDir).filter((name) => name.startsWith(SCHEMA_PREFIX));
}
function extractedFor(result: ExtractionResult, videoId: string): ExtractedFrames {
const extracted = result.extracted.find((item) => item.videoId === videoId);
if (!extracted) throw new Error(`missing extraction result for ${videoId}`);
return extracted;
}
function framePath(result: ExtractionResult, videoId: string, frameIndex: number): string {
const extracted = extractedFor(result, videoId);
const frame = extracted?.framePaths.get(frameIndex);
if (!frame) throw new Error(`missing frame ${frameIndex} for ${videoId}`);
return frame;
}
it("reuses extracted frames on a warm cache hit", async () => { it("reuses extracted frames on a warm cache hit", async () => {
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-")); const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
const SRC = await synthCfrClip("cache-src.mp4", 2); const SRC = await synthCfrClip("cache-src.mp4", 2);
@@ -957,65 +1007,13 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
rmSync(CACHE_DIR, { recursive: true, force: true }); rmSync(CACHE_DIR, { recursive: true, force: true });
}, 60_000); }, 60_000);
// Regression test for the segment-scope HDR preflight fix: pre-fix, it("applies SDR→HDR conversion during extraction without normalized intermediates", async () => {
// convertSdrToHdr re-encoded the entire source, so a 30-minute SDR source const SDR_LONG = await synthCfrClip("sdr-long.mp4", 10);
// contributing a 2-second clip took ~200× longer than needed. Post-fix the const HDR_SHORT = await synthHdrTaggedClip("hdr-short.mp4", 2);
// converted file's duration matches the used segment.
it("bounds the SDR→HDR preflight re-encode to the used segment", async () => {
const SDR_LONG = join(FIXTURE_DIR, "sdr-long.mp4");
const HDR_SHORT = join(FIXTURE_DIR, "hdr-short.mp4");
const sdrResult = await runFfmpeg([
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=s=320x180:d=10:rate=30",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
SDR_LONG,
]);
if (!sdrResult.success) {
throw new Error(`SDR fixture synthesis failed: ${sdrResult.stderr.slice(-400)}`);
}
// Tag as bt2020nc / smpte2084 so the preflight path considers the timeline mixed-HDR.
const hdrResult = await runFfmpeg([
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=s=320x180:d=2:rate=30",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-color_primaries",
"bt2020",
"-color_trc",
"smpte2084",
"-colorspace",
"bt2020nc",
HDR_SHORT,
]);
if (!hdrResult.success) {
throw new Error(`HDR fixture synthesis failed: ${hdrResult.stderr.slice(-400)}`);
}
const outputDir = join(FIXTURE_DIR, "out-hdr-segment"); const outputDir = join(FIXTURE_DIR, "out-hdr-segment");
const plainOutputDir = join(FIXTURE_DIR, "out-hdr-plain-sdr");
mkdirSync(outputDir, { recursive: true }); mkdirSync(outputDir, { recursive: true });
mkdirSync(plainOutputDir, { recursive: true });
const videos: VideoElement[] = [ const videos: VideoElement[] = [
{ id: "sdr", src: SDR_LONG, start: 0, end: 2, mediaStart: 0, loop: false, hasAudio: false }, { id: "sdr", src: SDR_LONG, start: 0, end: 2, mediaStart: 0, loop: false, hasAudio: false },
@@ -1036,14 +1034,80 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
}); });
expect(result.errors).toEqual([]); expect(result.errors).toEqual([]);
expect(result.phaseBreakdown.hdrPreflightCount).toBe(1); expect(result.phaseBreakdown.hdrPreflightCount).toBe(1);
expect(existsSync(join(outputDir, "_hdr_normalized"))).toBe(false);
const convertedPath = join(outputDir, "_hdr_normalized", "sdr_hdr.mp4"); const sdrFrames = result.extracted.find((item) => item.videoId === "sdr");
expect(existsSync(convertedPath)).toBe(true); expect(sdrFrames?.totalFrames).toBe(60);
const convertedMeta = await extractVideoMetadata(convertedPath);
// Pre-fix duration matched the 10s source; post-fix it matches the 2s segment const plain = await extractVideoFramesRange(SDR_LONG, "plain-sdr", 0, 2, {
// (±0.2s for encoder keyframe/seek alignment). fps: 30,
expect(convertedMeta.durationSeconds).toBeGreaterThan(1.8); outputDir: plainOutputDir,
expect(convertedMeta.durationSeconds).toBeLessThan(2.5); format: "jpg",
});
expect(plain.totalFrames).toBe(60);
expect(
readFileSync(framePath(result, "sdr", 0)).equals(readFileSync(plain.framePaths.get(0)!)),
).toBe(false);
}, 60_000);
it("keeps SDR→HDR cache entries distinct from plain SDR entries", async () => {
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-hdr-cache-test-"));
const SDR = await synthCfrClip("cache-hdr-sdr.mp4", 1);
const HDR = await synthHdrTaggedClip("cache-hdr-hdr.mp4", 1);
try {
const mixedOutputDir = join(FIXTURE_DIR, "out-cache-hdr-mixed");
mkdirSync(mixedOutputDir, { recursive: true });
const mixed = await extractAllVideoFrames(
[
cfrClipElement("sdr-transform", SDR, 1),
{ ...cfrClipElement("hdr-peer", HDR, 1), start: 1, end: 2 },
],
FIXTURE_DIR,
{ fps: 30, outputDir: mixedOutputDir },
undefined,
{ extractCacheDir: CACHE_DIR },
);
expect(mixed.errors).toEqual([]);
expect(mixed.phaseBreakdown.hdrPreflightCount).toBe(1);
expect(mixed.phaseBreakdown.cacheHits).toBe(0);
expect(mixed.phaseBreakdown.cacheMisses).toBe(2);
const plain = await extractWithCache(
cfrClipElement("sdr-plain", SDR, 1),
"out-cache-hdr-plain",
CACHE_DIR,
);
expect(plain.errors).toEqual([]);
expect(plain.phaseBreakdown.cacheHits).toBe(0);
expect(plain.phaseBreakdown.cacheMisses).toBe(1);
expect(cacheEntryNames(CACHE_DIR)).toHaveLength(3);
// Cross-render poisoning regression: the plain-SDR render must not be
// served the BT.2020-converted frames the mixed render cached for the
// SAME source+trim. Compare actual frame bytes across the cache
// boundary, not just entry counts.
expect(
readFileSync(framePath(plain, "sdr-plain", 0)).equals(
readFileSync(framePath(mixed, "sdr-transform", 0)),
),
).toBe(false);
// And a repeat plain render must HIT the plain entry and serve
// byte-identical plain frames (proves the hit path keys correctly too).
const plainAgain = await extractWithCache(
cfrClipElement("sdr-plain-again", SDR, 1),
"out-cache-hdr-plain-again",
CACHE_DIR,
);
expect(plainAgain.phaseBreakdown.cacheHits).toBe(1);
expect(
readFileSync(framePath(plainAgain, "sdr-plain-again", 0)).equals(
readFileSync(framePath(plain, "sdr-plain", 0)),
),
).toBe(true);
} finally {
rmSync(CACHE_DIR, { recursive: true, force: true });
}
}, 60_000); }, 60_000);
// Asserts frame-count correctness for a full VFR file. One-pass CFR image // Asserts frame-count correctness for a full VFR file. One-pass CFR image
@@ -19,7 +19,6 @@ import {
type HdrTransfer, type HdrTransfer,
} from "../utils/hdr.js"; } from "../utils/hdr.js";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js"; import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js"; import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js"; import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -82,9 +81,15 @@ export interface ExtractionOptions {
outputDir: string; outputDir: string;
quality?: number; quality?: number;
format?: VideoFrameFormat; format?: VideoFrameFormat;
sdrToHdrTransfer?: HdrTransfer;
} }
const EXTRACT_CACHE_MIN_AGE_MS = 60 * 60 * 1000; const EXTRACT_CACHE_MIN_AGE_MS = 60 * 60 * 1000;
const SDR_TO_HDR_COLORSPACE_FILTER = "colorspace=all=bt2020:iall=bt709:range=tv";
function sdrToHdrTransformKey(transfer: HdrTransfer): string {
return `sdr2hdr-${transfer}`;
}
/** /**
* Per-phase timings and counters emitted by `extractAllVideoFrames`. * Per-phase timings and counters emitted by `extractAllVideoFrames`.
@@ -290,6 +295,16 @@ export async function extractVideoFramesRange(
if (!metadata.isVFR) { if (!metadata.isVFR) {
vfFilters.push(`fps=${fps}`); vfFilters.push(`fps=${fps}`);
} }
if (options.sdrToHdrTransfer) {
// Ordering intent: fps sampling runs BEFORE the colorspace remap so only
// kept frames are converted. The remap is pointwise per-frame, so the
// output is identical either way for the SDR (BT.709, 8-bit) inputs this
// flag is set for. If format=nv12 (macOS HDR-source decode) ever combines
// with this flag, revisit: nv12 subsampling before a BT.2020 remap is an
// untested interaction (today the flags are mutually exclusive — the
// remap only applies to SDR sources, nv12 only to HDR sources).
vfFilters.push(SDR_TO_HDR_COLORSPACE_FILTER);
}
if (vfFilters.length > 0) args.push("-vf", vfFilters.join(",")); if (vfFilters.length > 0) args.push("-vf", vfFilters.join(","));
if (metadata.isVFR) args.push("-fps_mode", "cfr", "-r", String(fps)); if (metadata.isVFR) args.push("-fps_mode", "cfr", "-r", String(fps));
@@ -329,7 +344,14 @@ export async function extractVideoFramesRange(
return; return;
} }
if (code !== 0) { if (code !== 0) {
reject(new Error(`FFmpeg exited with code ${code}: ${stderr.slice(-500)}`)); // With the SDR-to-HDR remap folded into this pass, a filter failure
// (e.g. an ffmpeg built without the colorspace filter) would otherwise
// surface as a generic extract error and the operator has to grep the
// filter chain to learn it was the HDR conversion. Attribute it.
const hdrPrefix = options.sdrToHdrTransfer
? `SDR→HDR conversion failed (colorspace filter in extract pass, target ${options.sdrToHdrTransfer}): `
: "";
reject(new Error(`${hdrPrefix}FFmpeg exited with code ${code}: ${stderr.slice(-500)}`));
return; return;
} }
@@ -365,75 +387,6 @@ export async function extractVideoFramesRange(
}); });
} }
/**
* Convert an SDR (BT.709) video to BT.2020 wide-gamut so it can be composited
* alongside HDR content without looking washed out.
*
* Uses FFmpeg's `colorspace` filter to remap BT.709 → BT.2020 (no real tone
* mapping — just a primaries swap so the input fits inside the wider HDR
* gamut), then re-tags the stream with the caller's target HDR transfer
* function (PQ for HDR10, HLG for broadcast HDR). The output transfer must
* match the dominant transfer of the surrounding HDR content; otherwise the
* downstream encoder will tag the final video with the wrong curve.
*
* `startTime` and `duration` bound the re-encode to the segment the composition
* actually uses. Without them a 30-minute screen recording that contributes a
* 2-second clip was transcoded in full — a >100× waste for long sources.
*/
async function convertSdrToHdr(
inputPath: string,
outputPath: string,
startTime: number,
duration: number,
targetTransfer: HdrTransfer,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
): Promise<void> {
// Positive duration is required — FFmpeg's `-t 0` silently produces a 0-byte
// output that the downstream extractor then treats as a valid (empty) file.
if (duration <= 0) {
throw new Error(`convertSdrToHdr: duration must be positive (got ${duration})`);
}
const timeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
// smpte2084 = PQ (HDR10), arib-std-b67 = HLG.
const colorTrc = targetTransfer === "pq" ? "smpte2084" : "arib-std-b67";
const args = [
"-ss",
String(startTime),
"-i",
inputPath,
"-t",
String(duration),
"-vf",
"colorspace=all=bt2020:iall=bt709:range=tv",
"-color_primaries",
"bt2020",
"-color_trc",
colorTrc,
"-colorspace",
"bt2020nc",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"16",
"-c:a",
"copy",
"-y",
outputPath,
];
const result = await runFfmpeg(args, { signal, timeout });
if (!result.success) {
throw new Error(
`SDR→HDR conversion failed (exit ${result.exitCode}): ${result.stderr.slice(-300)}`,
);
}
}
/** /**
* Resolve the used-segment duration for a video, falling back to the source's * Resolve the used-segment duration for a video, falling back to the source's
* natural duration when the caller hasn't specified bounds (end=Infinity) or * natural duration when the caller hasn't specified bounds (end=Infinity) or
@@ -646,6 +599,12 @@ export async function extractAllVideoFrames(
resolvedVideos.map(({ videoPath }) => extractMediaMetadata(videoPath)), resolvedVideos.map(({ videoPath }) => extractMediaMetadata(videoPath)),
); );
const videoColorSpaces = videoMetadata.map((m) => m.colorSpace); const videoColorSpaces = videoMetadata.map((m) => m.colorSpace);
// Canonical per-index record of the SDR-to-HDR transform decision. BOTH the
// cache key (transform discriminator) and the extraction options read from
// this array via the prepared work items — never set one side independently
// or cache lookups and written frames drift apart (the poisoning bug this
// field exists to fix).
const sdrToHdrTransfers: Array<HdrTransfer | undefined> = resolvedVideos.map(() => undefined);
breakdown.hdrProbeMs = Date.now() - phase2ProbeStart; breakdown.hdrProbeMs = Date.now() - phase2ProbeStart;
const hdrPreflightStart = Date.now(); const hdrPreflightStart = Date.now();
@@ -665,15 +624,14 @@ export async function extractAllVideoFrames(
// for the whole render, and any source not on that curve is normalized to // for the whole render, and any source not on that curve is normalized to
// it. If you need both transfers, render two separate compositions. // it. If you need both transfers, render two separate compositions.
const targetTransfer = hdrInfo.dominantTransfer; const targetTransfer = hdrInfo.dominantTransfer;
const convertDir = join(options.outputDir, "_hdr_normalized");
mkdirSync(convertDir, { recursive: true });
for (let i = 0; i < resolvedVideos.length; i++) { for (let i = 0; i < resolvedVideos.length; i++) {
if (signal?.aborted) break; if (signal?.aborted) break;
const cs = videoColorSpaces[i] ?? null; const cs = videoColorSpaces[i] ?? null;
if (!isHdrColorSpaceUtil(cs)) { if (!isHdrColorSpaceUtil(cs)) {
// SDR video in a mixed timeline — convert to the dominant HDR transfer // SDR video in a mixed timeline — extract through a BT.709→BT.2020
// so the encoder tags the final video correctly (PQ vs HLG). // colorspace filter so the encoder tags the final video correctly
// (PQ vs HLG) without a separate normalized intermediate.
const entry = resolvedVideos[i]; const entry = resolvedVideos[i];
const metadata = videoMetadata[i]; const metadata = videoMetadata[i];
if (!entry || !metadata) continue; if (!entry || !metadata) continue;
@@ -690,45 +648,8 @@ export async function extractAllVideoFrames(
continue; continue;
} }
// Scope the re-encode to the segment the composition actually uses. sdrToHdrTransfers[i] = targetTransfer;
// Long sources (e.g. 30-minute screen recordings) contributing short breakdown.hdrPreflightCount += 1;
// clips were transcoded in full pre-fix — a >100× waste.
let segDuration = entry.video.end - entry.video.start;
if (!Number.isFinite(segDuration) || segDuration <= 0) {
const sourceRemaining = metadata.durationSeconds - entry.video.mediaStart;
segDuration = sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
}
const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`);
try {
await convertSdrToHdr(
entry.videoPath,
convertedPath,
entry.video.mediaStart,
segDuration,
targetTransfer,
signal,
config,
);
entry.videoPath = convertedPath;
// The converted intermediate carries BT.2020-mapped pixels but the
// cache key snapshot above still describes the ORIGINAL source.
// Publishing converted frames under that key would poison later
// plain-SDR renders of the same trim, so bypass the cache for
// converted entries. (The follow-up transform-keyed cache change
// re-enables caching for these with a discriminated key.)
cacheKeyInputs[i] = null;
// Segment-scoped re-encode starts the new file at t=0, so downstream
// extraction must seek from 0, not the original mediaStart. Shallow-copy
// to avoid mutating the caller's VideoElement.
entry.video = { ...entry.video, mediaStart: 0 };
breakdown.hdrPreflightCount += 1;
} catch (err) {
errors.push({
videoId: entry.video.id,
error: `SDR→HDR conversion failed: ${err instanceof Error ? err.message : String(err)}`,
});
}
} }
} }
} }
@@ -747,6 +668,7 @@ export async function extractAllVideoFrames(
// with the other parallel arrays so Phase 3's `cacheKeyInputs[i]` // with the other parallel arrays so Phase 3's `cacheKeyInputs[i]`
// lookup doesn't point at a stale slot after the splice. // lookup doesn't point at a stale slot after the splice.
cacheKeyInputs.splice(i, 1); cacheKeyInputs.splice(i, 1);
sdrToHdrTransfers.splice(i, 1);
} }
} }
} }
@@ -786,6 +708,7 @@ export async function extractAllVideoFrames(
i: number, i: number,
metadata: VideoMetadata, metadata: VideoMetadata,
cacheFormat: CacheFrameFormat, cacheFormat: CacheFrameFormat,
sdrToHdrTransfer?: HdrTransfer,
): Promise<ExtractedFrames | null> { ): Promise<ExtractedFrames | null> {
if (!cacheRootDir) return null; if (!cacheRootDir) return null;
const keyInput = cacheKeyInputs[i]; const keyInput = cacheKeyInputs[i];
@@ -804,6 +727,7 @@ export async function extractAllVideoFrames(
duration: keyDuration, duration: keyDuration,
fps: options.fps, fps: options.fps,
format: cacheFormat, format: cacheFormat,
transform: sdrToHdrTransfer ? sdrToHdrTransformKey(sdrToHdrTransfer) : undefined,
}); });
if (lookup.hit) { if (lookup.hit) {
@@ -827,7 +751,7 @@ export async function extractAllVideoFrames(
video.id, video.id,
video.mediaStart, video.mediaStart,
videoDuration, videoDuration,
{ ...options, format: cacheFormat }, { ...options, format: cacheFormat, sdrToHdrTransfer },
signal, signal,
config, config,
partialDir, partialDir,
@@ -859,6 +783,7 @@ export async function extractAllVideoFrames(
metadata: VideoMetadata; metadata: VideoMetadata;
videoDuration: number; videoDuration: number;
format: CacheFrameFormat; format: CacheFrameFormat;
sdrToHdrTransfer?: HdrTransfer;
dedupeKey: string; dedupeKey: string;
}; };
@@ -883,7 +808,8 @@ export async function extractAllVideoFrames(
} }
const format = resolveFrameFormat(metadata, options.format); const format = resolveFrameFormat(metadata, options.format);
const dedupeKey = `${videoPath}\0${video.mediaStart}\0${videoDuration}\0${options.fps}\0${format}`; const sdrToHdrTransfer = sdrToHdrTransfers[index];
const dedupeKey = `${videoPath}\0${video.mediaStart}\0${videoDuration}\0${options.fps}\0${format}\0${sdrToHdrTransfer ?? ""}`;
return { return {
work: { work: {
@@ -893,6 +819,7 @@ export async function extractAllVideoFrames(
metadata, metadata,
videoDuration, videoDuration,
format, format,
sdrToHdrTransfer,
dedupeKey, dedupeKey,
}, },
}; };
@@ -939,6 +866,7 @@ export async function extractAllVideoFrames(
work.index, work.index,
work.metadata, work.metadata,
work.format, work.format,
work.sdrToHdrTransfer,
); );
if (cached) return cached; if (cached) return cached;
@@ -947,7 +875,7 @@ export async function extractAllVideoFrames(
work.video.id, work.video.id,
work.video.mediaStart, work.video.mediaStart,
work.videoDuration, work.videoDuration,
{ ...options, format: work.format }, { ...options, format: work.format, sdrToHdrTransfer: work.sdrToHdrTransfer },
signal, signal,
config, config,
); );