mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -115,6 +115,7 @@ export {
|
||||
parseImageElements,
|
||||
extractVideoFramesRange,
|
||||
extractAllVideoFrames,
|
||||
resolveProjectRelativeSrc,
|
||||
getFrameAtTime,
|
||||
createFrameLookupTable,
|
||||
FrameLookupTable,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user