Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,60 @@
import { describe, expect, it } from "vitest";
import { estimateHdrExtractionBytes } from "./captureHdrResources.js";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { estimateHdrExtractionBytes, planHdrResources } from "./captureHdrResources.js";
import type { CompositionMetadata } from "../shared.js";

function videoComposition(src: string): CompositionMetadata {
return {
duration: 5,
width: 1920,
height: 1080,
audios: [],
images: [],
videos: [{ id: "a-roll", src, start: 0, end: 5, mediaStart: 0, loop: false, hasAudio: false }],
};
}

describe("planHdrResources non-ASCII src resolution (PRINFRA-349)", () => {
it("decodes a percent-encoded CJK <video src> back to the real on-disk path", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-hdr-cjk-"));
try {
const realName = "视频1.mp4";
writeFileSync(join(projectDir, realName), "x");
// The compiled DOM carries the URL-encoded attribute value.
const encoded = encodeURIComponent(realName); // %E8%A7%86%E9%A2%911.mp4
const prep = planHdrResources({
composition: videoComposition(encoded),
nativeHdrVideoIds: new Set(["a-roll"]),
nativeHdrImageIds: new Set(),
projectDir,
compiledDir: projectDir,
});
// Must be the decoded filesystem path ffmpeg can open, not the %-encoded string.
expect(prep.hdrVideoSrcPaths.get("a-roll")).toBe(join(projectDir, realName));
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});

it("leaves an ASCII src untouched", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-hdr-ascii-"));
try {
writeFileSync(join(projectDir, "clip.mp4"), "x");
const prep = planHdrResources({
composition: videoComposition("clip.mp4"),
nativeHdrVideoIds: new Set(["a-roll"]),
nativeHdrImageIds: new Set(),
projectDir,
compiledDir: projectDir,
});
expect(prep.hdrVideoSrcPaths.get("a-roll")).toBe(join(projectDir, "clip.mp4"));
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
});

describe("estimateHdrExtractionBytes", () => {
it("sums 6 bytes per pixel per frame across videos", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
normalizeObjectFit,
queryElementStacking,
resampleRgb48leObjectFit,
resolveProjectRelativeSrc,
runFfmpeg,
} from "@hyperframes/engine";
import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
Expand Down Expand Up @@ -67,7 +68,6 @@ export function planHdrResources(args: {
nativeHdrImageIds: Set<string>;
projectDir: string;
compiledDir: string;
existsSync: (p: string) => boolean;
}): HdrResourcePrep {
const { composition, nativeHdrVideoIds, nativeHdrImageIds, projectDir, compiledDir } = args;
const hdrVideoIds = composition.videos
Expand All @@ -76,12 +76,14 @@ export function planHdrResources(args: {
const hdrVideoSrcPaths = new Map<string, string>();
for (const v of composition.videos) {
if (!hdrVideoIds.includes(v.id)) continue;
let srcPath = v.src;
if (!srcPath.startsWith("/")) {
const fromCompiled = join(compiledDir, srcPath);
srcPath = args.existsSync(fromCompiled) ? fromCompiled : join(projectDir, srcPath);
}
hdrVideoSrcPaths.set(v.id, srcPath);
// Resolve via the shared SDR resolver so a percent-encoded `<video src>`
// (`视频1.mp4` → `%E8%A7%86%E9%A2%911.mp4` in the compiled DOM URL) decodes
// back to the real on-disk filename before ffmpeg sees it. The old
// hand-rolled join passed the encoded string straight through, so HDR
// pre-extraction failed with "No such file" on non-ASCII media
// (PRINFRA-349 symptom c). resolveProjectRelativeSrc also handles query
// strings, origin-root URLs, and `..` traversal identically to the SDR path.
hdrVideoSrcPaths.set(v.id, resolveProjectRelativeSrc(v.src, projectDir, compiledDir));
}
const hdrVideoStartTimes = new Map<string, number>();
for (const v of composition.videos) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,6 @@ export async function runCaptureHdrStage(
nativeHdrImageIds,
projectDir,
compiledDir,
existsSync,
});

const domSession = await createCaptureSession(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveProjectRelativeSrc } from "@hyperframes/engine";
import type {
ExtractedFrames,
ExtractionResult,
Expand Down Expand Up @@ -121,6 +125,74 @@ describe("appendAutoDetectedVideoAudio", () => {
});
});

// The HDR probes in this stage resolve `<video>`/`<img>` srcs with
// resolveProjectRelativeSrc and NO isAbsolute() pre-check. These pin the src
// shapes that a pre-check would silently break — an earlier revision of the
// PRINFRA-349 fix short-circuited on isAbsolute and left root-relative srcs
// percent-encoded, so the HDR image never resolved and the render shipped SDR.
describe("HDR probe src resolution (PRINFRA-349)", () => {
it("decodes a percent-encoded CJK src to the real on-disk path", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-probe-cjk-"));
const compiledDir = mkdtempSync(join(tmpdir(), "hf-probe-compiled-"));
try {
const realName = "图1.png";
writeFileSync(join(projectDir, realName), "x");
// The compiled DOM carries the URL-encoded attribute value.
const encoded = encodeURIComponent(realName); // %E5%9B%BE1.png
expect(resolveProjectRelativeSrc(encoded, projectDir, compiledDir)).toBe(
join(projectDir, realName),
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(compiledDir, { recursive: true, force: true });
}
});

it("decodes a percent-encoded CJK src served from a browser origin-root URL", () => {
// Regression guard: `isAbsolute("/assets/%E5%9B%BE1.png")` is true on POSIX,
// so a pre-check would return it verbatim, existsSync would fail, and the
// image would never enter nativeHdrImageIds — a silent SDR render.
const projectDir = mkdtempSync(join(tmpdir(), "hf-probe-root-"));
try {
mkdirSync(join(projectDir, "assets"));
const realName = "图1.png";
writeFileSync(join(projectDir, "assets", realName), "x");
const rootRelative = `/assets/${encodeURIComponent(realName)}`;
expect(resolveProjectRelativeSrc(rootRelative, projectDir, projectDir)).toBe(
join(projectDir, "assets", realName),
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});

it("prefers compiledDir over projectDir when both hold the asset", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-probe-proj-"));
const compiledDir = mkdtempSync(join(tmpdir(), "hf-probe-comp-"));
try {
writeFileSync(join(projectDir, "clip.mp4"), "x");
writeFileSync(join(compiledDir, "clip.mp4"), "x");
expect(resolveProjectRelativeSrc("clip.mp4", projectDir, compiledDir)).toBe(
join(compiledDir, "clip.mp4"),
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(compiledDir, { recursive: true, force: true });
}
});

it("returns an existing absolute path unchanged", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-probe-abs-"));
try {
const abs = join(projectDir, "clip.mp4");
writeFileSync(abs, "x");
expect(resolveProjectRelativeSrc(abs, projectDir, projectDir)).toBe(abs);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
});

describe("shouldCopyExtractedFrames", () => {
it("copies frames on Windows (symlinkSync throws EPERM without Developer Mode)", () => {
expect(shouldCopyExtractedFrames("win32")).toBe(true);
Expand Down
70 changes: 50 additions & 20 deletions packages/producer/src/services/render/stages/extractVideosStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
*/

import { existsSync } from "node:fs";
import { isAbsolute, join } from "node:path";
import { join } from "node:path";
import {
type CaptureVideoMetadataHint,
type EngineConfig,
Expand Down Expand Up @@ -117,6 +117,37 @@ export function shouldCopyExtractedFrames(platform: NodeJS.Platform): boolean {
return platform === "win32";
}

/**
* Probe an IMAGE file's color space, returning null when it can't be read.
*
* The image probe widened which files it resolves (PRINFRA-349: the shared
* resolver percent-decodes non-ASCII names, so `%E5%9B%BE1.png` now finds
* `图1.png`). Files that used to silently fail to resolve are therefore
* reachable for the first time — including truncated / 0-byte assets, on
* which ffprobe exits non-zero and `extractMediaMetadata` throws. The image
* probe runs inside a bare `Promise.all`, so an unguarded throw would abort
* the whole render over one unreadable image; an image that can't be probed
* is treated as SDR and skipped. (The VIDEO probe deliberately does NOT use
* this: its errors route through the opt-in failure policy below —
* `resolveVideoExtractionPolicy` / `throwHdrProbeFailures` — which preserves
* the legacy throw in "off" mode and typed enforcement in "enforce".)
*/
async function probeImageColorSpaceSafely(
path: string,
log: ProducerLogger | undefined,
): Promise<VideoColorSpace | null> {
try {
const meta = await extractMediaMetadata(path);
return meta.colorSpace;
} catch (error) {
log?.warn("HDR color-space probe failed; treating source as SDR", {
path,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}

export type VideoExtractionStageErrorCode = "VIDEO_SOURCE_UNRENDERABLE" | "VIDEO_EXTRACTION_FAILED";

export interface VideoExtractionStageFailureSummary {
Expand Down Expand Up @@ -283,14 +314,15 @@ export async function runExtractVideosStage(
log?.info("Probing video color spaces...", { videoCount: composition.videos.length });
const probeFailures = await Promise.all(
composition.videos.map(async (v) => {
// Use the shared resolver so a `<video src="../assets/foo">` in a
// sub-composition resolves the same way the browser would (see
// resolveProjectRelativeSrc in videoFrameExtractor for the full
// explanation). isAbsolute (not `startsWith("/")`) so Windows
// absolute paths like `C:\...` skip the join correctly.
const videoPath = isAbsolute(v.src)
? v.src
: resolveProjectRelativeSrc(v.src, projectDir, compiledDir);
// Shared resolver so a `<video src="../assets/foo">` in a
// sub-composition resolves the same way the browser would, and a
// percent-encoded non-ASCII name decodes to its real on-disk path.
// Called with NO isAbsolute() pre-check (PRINFRA-349): the resolver
// already returns an absolute path that exists, and otherwise treats
// a leading slash as a browser origin-root URL — pre-checking would
// hand back `/assets/%E5%9B%BE1.png` undecoded and re-open the bug
// for root-relative srcs.
const videoPath = resolveProjectRelativeSrc(v.src, projectDir, compiledDir);
if (!existsSync(videoPath)) return null;
try {
// Retries are separately opt-in from the failure gate. With the
Expand Down Expand Up @@ -339,21 +371,19 @@ export async function runExtractVideosStage(
if (job.config.hdrMode !== "force-sdr" && composition.images.length > 0) {
const probed = await Promise.all(
composition.images.map(async (img) => {
let imgPath = img.src;
if (!imgPath.startsWith("/")) {
const fromCompiled = existsSync(join(compiledDir, imgPath))
? join(compiledDir, imgPath)
: join(projectDir, imgPath);
imgPath = fromCompiled;
}
// Same shared resolver as the video probe above — a percent-encoded
// non-ASCII `<img src>` must decode to the on-disk path, or the HDR image
// never enters nativeHdrImageIds and the composition silently renders
// through the SDR fallback with wrong color (PRINFRA-349 symptom c).
const imgPath = resolveProjectRelativeSrc(img.src, projectDir, compiledDir);
if (!existsSync(imgPath)) return null;
const meta = await extractMediaMetadata(imgPath);
if (isHdrColorSpace(meta.colorSpace)) {
const colorSpace = await probeImageColorSpaceSafely(imgPath, log);
if (isHdrColorSpace(colorSpace)) {
nativeHdrImageIds.add(img.id);
imageTransfers.set(img.id, detectTransfer(meta.colorSpace));
imageTransfers.set(img.id, detectTransfer(colorSpace));
hdrImageSrcPaths.set(img.id, imgPath);
}
return meta.colorSpace;
return colorSpace;
}),
);
imageColorSpaces.push(...probed);
Expand Down
Loading