mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -133,6 +133,20 @@ describe("computeCacheKey", () => {
|
||||
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", () => {
|
||||
const a = 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,
|
||||
* studio iteration) re-extract identical frames from the same source file,
|
||||
* 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
|
||||
* re-invoking ffmpeg.
|
||||
*
|
||||
@@ -84,6 +85,8 @@ export interface CacheKeyInput {
|
||||
fps: number;
|
||||
/** Output image format. */
|
||||
format: CacheFrameFormat;
|
||||
/** Optional source transform applied during extraction. */
|
||||
transform?: string;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
@@ -124,7 +127,16 @@ export function readKeyStat(videoPath: string): { mtimeMs: number; size: number
|
||||
|
||||
function canonicalKeyBlob(input: CacheKeyInput): string {
|
||||
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,
|
||||
m: input.mtimeMs,
|
||||
s: input.size,
|
||||
@@ -132,7 +144,9 @@ function canonicalKeyBlob(input: CacheKeyInput): string {
|
||||
d: durationForKey,
|
||||
f: input.fps,
|
||||
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 {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
parseVideoElements,
|
||||
parseImageElements,
|
||||
extractAllVideoFrames,
|
||||
extractVideoFramesRange,
|
||||
createFrameLookupTable,
|
||||
resolveProjectRelativeSrc,
|
||||
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 () => {
|
||||
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
||||
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 });
|
||||
}, 60_000);
|
||||
|
||||
// Regression test for the segment-scope HDR preflight fix: pre-fix,
|
||||
// convertSdrToHdr re-encoded the entire source, so a 30-minute SDR source
|
||||
// contributing a 2-second clip took ~200× longer than needed. Post-fix the
|
||||
// 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)}`);
|
||||
}
|
||||
|
||||
it("applies SDR→HDR conversion during extraction without normalized intermediates", async () => {
|
||||
const SDR_LONG = await synthCfrClip("sdr-long.mp4", 10);
|
||||
const HDR_SHORT = await synthHdrTaggedClip("hdr-short.mp4", 2);
|
||||
const outputDir = join(FIXTURE_DIR, "out-hdr-segment");
|
||||
const plainOutputDir = join(FIXTURE_DIR, "out-hdr-plain-sdr");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
mkdirSync(plainOutputDir, { recursive: true });
|
||||
|
||||
const videos: VideoElement[] = [
|
||||
{ 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.phaseBreakdown.hdrPreflightCount).toBe(1);
|
||||
expect(existsSync(join(outputDir, "_hdr_normalized"))).toBe(false);
|
||||
|
||||
const convertedPath = join(outputDir, "_hdr_normalized", "sdr_hdr.mp4");
|
||||
expect(existsSync(convertedPath)).toBe(true);
|
||||
const convertedMeta = await extractVideoMetadata(convertedPath);
|
||||
// Pre-fix duration matched the 10s source; post-fix it matches the 2s segment
|
||||
// (±0.2s for encoder keyframe/seek alignment).
|
||||
expect(convertedMeta.durationSeconds).toBeGreaterThan(1.8);
|
||||
expect(convertedMeta.durationSeconds).toBeLessThan(2.5);
|
||||
const sdrFrames = result.extracted.find((item) => item.videoId === "sdr");
|
||||
expect(sdrFrames?.totalFrames).toBe(60);
|
||||
|
||||
const plain = await extractVideoFramesRange(SDR_LONG, "plain-sdr", 0, 2, {
|
||||
fps: 30,
|
||||
outputDir: plainOutputDir,
|
||||
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);
|
||||
|
||||
// Asserts frame-count correctness for a full VFR file. One-pass CFR image
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type HdrTransfer,
|
||||
} from "../utils/hdr.js";
|
||||
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
||||
@@ -82,9 +81,15 @@ export interface ExtractionOptions {
|
||||
outputDir: string;
|
||||
quality?: number;
|
||||
format?: VideoFrameFormat;
|
||||
sdrToHdrTransfer?: HdrTransfer;
|
||||
}
|
||||
|
||||
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`.
|
||||
@@ -290,6 +295,16 @@ export async function extractVideoFramesRange(
|
||||
if (!metadata.isVFR) {
|
||||
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 (metadata.isVFR) args.push("-fps_mode", "cfr", "-r", String(fps));
|
||||
|
||||
@@ -329,7 +344,14 @@ export async function extractVideoFramesRange(
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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)),
|
||||
);
|
||||
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;
|
||||
|
||||
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
|
||||
// it. If you need both transfers, render two separate compositions.
|
||||
const targetTransfer = hdrInfo.dominantTransfer;
|
||||
const convertDir = join(options.outputDir, "_hdr_normalized");
|
||||
mkdirSync(convertDir, { recursive: true });
|
||||
|
||||
for (let i = 0; i < resolvedVideos.length; i++) {
|
||||
if (signal?.aborted) break;
|
||||
const cs = videoColorSpaces[i] ?? null;
|
||||
if (!isHdrColorSpaceUtil(cs)) {
|
||||
// SDR video in a mixed timeline — convert to the dominant HDR transfer
|
||||
// so the encoder tags the final video correctly (PQ vs HLG).
|
||||
// SDR video in a mixed timeline — extract through a BT.709→BT.2020
|
||||
// colorspace filter so the encoder tags the final video correctly
|
||||
// (PQ vs HLG) without a separate normalized intermediate.
|
||||
const entry = resolvedVideos[i];
|
||||
const metadata = videoMetadata[i];
|
||||
if (!entry || !metadata) continue;
|
||||
@@ -690,45 +648,8 @@ export async function extractAllVideoFrames(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scope the re-encode to the segment the composition actually uses.
|
||||
// Long sources (e.g. 30-minute screen recordings) contributing short
|
||||
// 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)}`,
|
||||
});
|
||||
}
|
||||
sdrToHdrTransfers[i] = targetTransfer;
|
||||
breakdown.hdrPreflightCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -747,6 +668,7 @@ export async function extractAllVideoFrames(
|
||||
// with the other parallel arrays so Phase 3's `cacheKeyInputs[i]`
|
||||
// lookup doesn't point at a stale slot after the splice.
|
||||
cacheKeyInputs.splice(i, 1);
|
||||
sdrToHdrTransfers.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -786,6 +708,7 @@ export async function extractAllVideoFrames(
|
||||
i: number,
|
||||
metadata: VideoMetadata,
|
||||
cacheFormat: CacheFrameFormat,
|
||||
sdrToHdrTransfer?: HdrTransfer,
|
||||
): Promise<ExtractedFrames | null> {
|
||||
if (!cacheRootDir) return null;
|
||||
const keyInput = cacheKeyInputs[i];
|
||||
@@ -804,6 +727,7 @@ export async function extractAllVideoFrames(
|
||||
duration: keyDuration,
|
||||
fps: options.fps,
|
||||
format: cacheFormat,
|
||||
transform: sdrToHdrTransfer ? sdrToHdrTransformKey(sdrToHdrTransfer) : undefined,
|
||||
});
|
||||
|
||||
if (lookup.hit) {
|
||||
@@ -827,7 +751,7 @@ export async function extractAllVideoFrames(
|
||||
video.id,
|
||||
video.mediaStart,
|
||||
videoDuration,
|
||||
{ ...options, format: cacheFormat },
|
||||
{ ...options, format: cacheFormat, sdrToHdrTransfer },
|
||||
signal,
|
||||
config,
|
||||
partialDir,
|
||||
@@ -859,6 +783,7 @@ export async function extractAllVideoFrames(
|
||||
metadata: VideoMetadata;
|
||||
videoDuration: number;
|
||||
format: CacheFrameFormat;
|
||||
sdrToHdrTransfer?: HdrTransfer;
|
||||
dedupeKey: string;
|
||||
};
|
||||
|
||||
@@ -883,7 +808,8 @@ export async function extractAllVideoFrames(
|
||||
}
|
||||
|
||||
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 {
|
||||
work: {
|
||||
@@ -893,6 +819,7 @@ export async function extractAllVideoFrames(
|
||||
metadata,
|
||||
videoDuration,
|
||||
format,
|
||||
sdrToHdrTransfer,
|
||||
dedupeKey,
|
||||
},
|
||||
};
|
||||
@@ -939,6 +866,7 @@ export async function extractAllVideoFrames(
|
||||
work.index,
|
||||
work.metadata,
|
||||
work.format,
|
||||
work.sdrToHdrTransfer,
|
||||
);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -947,7 +875,7 @@ export async function extractAllVideoFrames(
|
||||
work.video.id,
|
||||
work.video.mediaStart,
|
||||
work.videoDuration,
|
||||
{ ...options, format: work.format },
|
||||
{ ...options, format: work.format, sdrToHdrTransfer: work.sdrToHdrTransfer },
|
||||
signal,
|
||||
config,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user