fix(engine,producer): URL-clamp sub-comp src paths and warn on silent extraction misses

A <video src='../assets/foo.mp4'> inside a sub-composition silently dropped
from extraction; the rendered output froze on the first decoded frame for
the entire clip, with no error in stdout.

Root cause: browser URL resolver clamps '..' at origin root (studio preview
loads fine), but path.join(projectDir, '../assets/foo.mp4') normalizes to
parent-of-project/assets/foo.mp4, which usually doesn't exist. existsSync
returns false, extraction is skipped, no frame lookup is built, the
per-frame injector has nothing to swap, and the <video> element's first
decoded frame paints every screenshot.

- Adds resolveProjectRelativeSrc in videoFrameExtractor that mirrors browser
  clamping (literal join first, then leading '..' stripped).
- Surfaces a loud stderr warning when the resolver misses.
- Mirrors fix in audioMixer.ts (same bug for <audio src='../'>) and
  renderOrchestrator HDR probe loop.
- +6 regression tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-05-04 20:27:56 -07:00
co-authored by Claude Opus 4.7
parent 0e541673e0
commit 2f96d5c7ab
5 changed files with 118 additions and 16 deletions
+1
View File
@@ -115,6 +115,7 @@ export {
parseImageElements,
extractVideoFramesRange,
extractAllVideoFrames,
resolveProjectRelativeSrc,
getFrameAtTime,
createFrameLookupTable,
FrameLookupTable,
+4 -6
View File
@@ -12,6 +12,7 @@ import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
import type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
export type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
@@ -325,13 +326,10 @@ export async function processCompositionAudio(
}
try {
let srcPath = element.src;
// Use isAbsolute() rather than startsWith("/"). On Windows, absolute paths
// like "C:\…" are not detected by the latter, so we'd re-join them under
// baseDir and produce duplicated, nonexistent paths.
if (!isAbsolute(srcPath) && !isHttpUrl(srcPath)) {
const fromCompiled = compiledDir ? join(compiledDir, srcPath) : null;
srcPath =
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, srcPath);
// Same browser-vs-filesystem path semantics as videos — see
// resolveProjectRelativeSrc in videoFrameExtractor for the full why.
srcPath = resolveProjectRelativeSrc(element.src, baseDir, compiledDir);
}
if (isHttpUrl(srcPath)) {
@@ -9,6 +9,7 @@ import {
parseImageElements,
extractAllVideoFrames,
createFrameLookupTable,
resolveProjectRelativeSrc,
type VideoElement,
type ExtractedFrames,
} from "./videoFrameExtractor.js";
@@ -23,6 +24,67 @@ import { runFfmpeg } from "../utils/runFfmpeg.js";
// synthesized VFR fixture.
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;
// Regression: a long-standing footgun where `<video src="../assets/foo">`
// inside a sub-composition silently dropped the video from extraction. The
// browser's URL resolver clamps `..` at the served origin's root (so the
// page renders fine in the studio), but `path.join(projectDir, "../assets/foo")`
// normalizes to <parentOfProjectDir>/assets/foo, which doesn't exist. Result:
// no extracted frames, no per-frame injection, the rendered output shows the
// <video>'s first decoded frame for the whole clip duration. The resolver
// now mirrors browser semantics by stripping leading `..` segments as a
// fallback when the literal join doesn't exist.
describe("resolveProjectRelativeSrc — sub-composition path clamping", () => {
let tmp: string;
beforeAll(() => {
tmp = mkdtempSync(join(tmpdir(), "hf-resolver-"));
mkdirSync(join(tmp, "project", "assets"), { recursive: true });
// Empty file is enough for existsSync — this test is about path resolution.
require("node:fs").writeFileSync(join(tmp, "project", "assets", "foo.mp4"), "");
});
afterAll(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("returns the literal join when the file exists at projectDir/src", () => {
const projectDir = join(tmp, "project");
expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir)).toBe(
join(projectDir, "assets/foo.mp4"),
);
});
it("clamps a leading `../` (sub-comp authoring) so `../assets/foo.mp4` resolves to assets/foo.mp4", () => {
const projectDir = join(tmp, "project");
expect(resolveProjectRelativeSrc("../assets/foo.mp4", projectDir)).toBe(
join(projectDir, "assets/foo.mp4"),
);
});
it("clamps multiple leading `../../../` segments", () => {
const projectDir = join(tmp, "project");
expect(resolveProjectRelativeSrc("../../../assets/foo.mp4", projectDir)).toBe(
join(projectDir, "assets/foo.mp4"),
);
});
it("returns the (non-existent) base-dir path on miss so callers get a stable error message", () => {
const projectDir = join(tmp, "project");
expect(resolveProjectRelativeSrc("../assets/missing.mp4", projectDir)).toBe(
join(projectDir, "../assets/missing.mp4"),
);
});
it("prefers compiled-dir over base-dir when the file exists in both", () => {
const projectDir = join(tmp, "project");
const compiledDir = join(tmp, "compiled");
mkdirSync(join(compiledDir, "assets"), { recursive: true });
require("node:fs").writeFileSync(join(compiledDir, "assets", "foo.mp4"), "");
expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir, compiledDir)).toBe(
join(compiledDir, "assets/foo.mp4"),
);
});
});
describe("parseVideoElements", () => {
it("parses videos without an id or data-start attribute", () => {
const videos = parseVideoElements('<video src="clip.mp4"></video>');
@@ -459,6 +459,37 @@ async function convertVfrToCfr(
}
}
/**
* Resolve a relative `<video src>` to a filesystem path the way the browser
* resolves it as a URL. Browsers clamp `..` segments at the served origin's
* root; `path.join(projectDir, "../assets/foo")` does not. So a sub-comp
* `<video src="../assets/foo">` loads in the page (browser clamps to
* `<projectDir>/assets/foo`) but the filesystem-side resolver lands at
* `<parentOfProjectDir>/assets/foo` — file missing, extraction skipped,
* the rendered output shows the video's first frame for the whole clip.
* Mirror the clamp here.
*
* Returns the first existing candidate, or the base-dir join on miss so
* the caller's existsSync check produces a stable error path.
*/
export function resolveProjectRelativeSrc(
src: string,
baseDir: string,
compiledDir?: string,
): string {
const candidates = [
compiledDir && join(compiledDir, src),
join(baseDir, src),
...(src.startsWith("..")
? (() => {
const clamped = src.replace(/^(\.\.[\\/])+/, "");
return [compiledDir && join(compiledDir, clamped), join(baseDir, clamped)];
})()
: []),
].filter(Boolean) as string[];
return candidates.find(existsSync) ?? join(baseDir, src);
}
export async function extractAllVideoFrames(
videos: VideoElement[],
baseDir: string,
@@ -496,9 +527,7 @@ export async function extractAllVideoFrames(
// baseDir and produce duplicated, nonexistent paths
// (e.g. C:\tmp\hf-vfr-test-X\C:\tmp\hf-vfr-test-X\vfr_screen.mp4).
if (!isAbsolute(videoPath) && !isHttpUrl(videoPath)) {
const fromCompiled = compiledDir ? join(compiledDir, videoPath) : null;
videoPath =
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);
videoPath = resolveProjectRelativeSrc(video.src, baseDir, compiledDir);
}
if (isHttpUrl(videoPath)) {
@@ -508,6 +537,15 @@ export async function extractAllVideoFrames(
}
if (!existsSync(videoPath)) {
// Loud: silent miss leaves the rendered video frozen at frame 0 with
// no error in stdout — extremely confusing for authors.
process.stderr.write(
`[hyperframes:render] WARNING: video <${video.id}> src="${video.src}" ` +
`could not be resolved on disk (looked for ${videoPath}). ` +
`The rendered output will show this video's first frame for the entire clip duration. ` +
`If your <video> lives inside a sub-composition, prefer project-root-relative paths ` +
`(e.g. src="assets/foo.mp4") over "../assets/foo.mp4".\n`,
);
errors.push({ videoId: video.id, error: `Video file not found: ${videoPath}` });
continue;
}
@@ -33,6 +33,7 @@ import {
type EngineConfig,
resolveConfig,
extractAllVideoFrames,
resolveProjectRelativeSrc,
type ExtractedFrames,
type ExtractionPhaseBreakdown,
createFrameLookupTable,
@@ -2312,13 +2313,15 @@ export async function executeRenderJob(
if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
await Promise.all(
composition.videos.map(async (v) => {
let videoPath = v.src;
if (!videoPath.startsWith("/")) {
const fromCompiled = existsSync(join(compiledDir, videoPath))
? join(compiledDir, videoPath)
: join(projectDir, videoPath);
videoPath = fromCompiled;
}
// 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). Without this, the HDR probe silently no-ops on
// those videos and the downstream extraction bug shows up
// amplified — videos register but never extract.
const videoPath = v.src.startsWith("/")
? v.src
: resolveProjectRelativeSrc(v.src, projectDir, compiledDir);
if (!existsSync(videoPath)) return;
const meta = await extractMediaMetadata(videoPath);
if (isHdrColorSpace(meta.colorSpace)) {