From 6fc92308d638a75d753cf160f656e7ec3f9066ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 01:11:23 -0400 Subject: [PATCH] fix(engine): resolve root-absolute media from project (#2399) --- .../engine/src/services/audioMixer.test.ts | 32 +++++++++++++++++++ packages/engine/src/services/audioMixer.ts | 4 +-- .../src/services/videoFrameExtractor.test.ts | 7 ++++ .../src/services/videoFrameExtractor.ts | 12 ++++--- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index 0af853adc..999bc3228 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -438,6 +438,38 @@ describe("processCompositionAudio", () => { const prepareArgs = runFfmpegMock.mock.calls[0]?.[0]; expect(prepareArgs).toContain(join(baseDir, "assets", filename)); }); + + it("prepares browser root-absolute audio srcs from the project root", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + + mkdirSync(join(baseDir, ".media"), { recursive: true }); + writeFileSync(join(baseDir, ".media", "tone.wav"), "stub"); + + const result = await processCompositionAudio( + [ + { + id: "tone", + src: "/.media/tone.wav", + start: 0, + end: 1, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 1, + ); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(runFfmpegMock.mock.calls[0]?.[0]).toContain(join(baseDir, ".media", "tone.wav")); + }); }); describe("parseAudioElements — relative data-start resolution", () => { diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 557f6d47a..a6439912b 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -5,7 +5,7 @@ */ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs"; -import { isAbsolute, join, dirname } from "path"; +import { join, dirname } from "path"; import { parseHTML } from "linkedom"; import { extractAudioMetadata } from "../utils/ffprobe.js"; import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js"; @@ -530,7 +530,7 @@ export async function processCompositionAudio( } try { let srcPath = element.src; - if (!isAbsolute(srcPath) && !isHttpUrl(srcPath)) { + if (!isHttpUrl(srcPath)) { // Same browser-vs-filesystem path semantics as videos — see // resolveProjectRelativeSrc in videoFrameExtractor for the full why. srcPath = resolveProjectRelativeSrc(element.src, baseDir, compiledDir); diff --git a/packages/engine/src/services/videoFrameExtractor.test.ts b/packages/engine/src/services/videoFrameExtractor.test.ts index 82394a1f1..a8ce64fd3 100644 --- a/packages/engine/src/services/videoFrameExtractor.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.test.ts @@ -143,6 +143,13 @@ describe("resolveProjectRelativeSrc — sub-composition path clamping", () => { ); }); + it("resolves a browser root-absolute URL from the project root", () => { + const projectDir = join(tmp, "project"); + expect(resolveProjectRelativeSrc("/assets/foo.mp4", projectDir)).toBe( + join(projectDir, "assets/foo.mp4"), + ); + }); + it("clamps a leading `../` so `../assets/foo.mp4` resolves to assets/foo.mp4", () => { const projectDir = join(tmp, "project"); expect(resolveProjectRelativeSrc("../assets/foo.mp4", projectDir)).toBe( diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 33c7473b7..817177fa0 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -670,6 +670,12 @@ export function resolveProjectRelativeSrc( ): string { const qIdx = src.indexOf("?"); const cleanSrc = qIdx >= 0 ? src.slice(0, qIdx) : src; + + // Preserve explicit filesystem paths when they really exist. Otherwise a + // leading slash is a browser origin-root URL (`/assets/foo.mp4`), which the + // file server serves from the project root rather than the host filesystem. + if (isAbsolute(cleanSrc) && existsSync(cleanSrc)) return cleanSrc; + const candidates: string[] = []; const addCandidate = (candidate: string): void => { @@ -746,11 +752,7 @@ export async function extractAllVideoFrames( if (signal?.aborted) break; try { let videoPath = video.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 - // (e.g. C:\tmp\hf-vfr-test-X\C:\tmp\hf-vfr-test-X\vfr_screen.mp4). - if (!isAbsolute(videoPath) && !isHttpUrl(videoPath)) { + if (!isHttpUrl(videoPath)) { videoPath = resolveProjectRelativeSrc(video.src, baseDir, compiledDir); }