fix(engine): resolve encoded media src paths

This commit is contained in:
Miguel Ángel
2026-05-24 15:58:42 -04:00
parent 7461f1df30
commit 7ad10a2dff
5 changed files with 134 additions and 25 deletions
@@ -330,6 +330,27 @@ describe("audio_src_not_found", () => {
expect(finding).toBeUndefined(); expect(finding).toBeUndefined();
}); });
it("does not error for percent-encoded non-Latin filenames that exist on disk", () => {
const encodedFilename =
"%D9%87%D9%86%D8%A7%20%D9%85%D8%B1%D9%88%D8%A7%20-%20%D9%85%D8%A8%D8%A7%D8%B1%D9%83.mp4";
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<audio id="music" src="assets/${encodedFilename}" data-start="0" data-track-index="0" data-volume="1"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const project = makeProject(html);
mkdirSync(join(project.dir, "assets"), { recursive: true });
writeFileSync(join(project.dir, "assets", decodeURIComponent(encodedFilename)), "fake");
const { results } = lintProject(project);
const first = results[0];
expect(first).toBeDefined();
const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found");
expect(finding).toBeUndefined();
});
it("deduplicates missing files across compositions", () => { it("deduplicates missing files across compositions", () => {
const project = makeProject(validHtmlWithAudio(), { const project = makeProject(validHtmlWithAudio(), {
"captions.html": validHtmlWithAudio("captions"), "captions.html": validHtmlWithAudio("captions"),
+16 -2
View File
@@ -113,6 +113,21 @@ function cleanAssetUrl(url: string): string {
return url.trim().split(/[?#]/, 1)[0] ?? ""; return url.trim().split(/[?#]/, 1)[0] ?? "";
} }
function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
const cleanUrl = cleanAssetUrl(url);
const variants = [cleanUrl];
try {
const decodedUrl = decodeURIComponent(cleanUrl);
if (decodedUrl !== cleanUrl) variants.unshift(decodedUrl);
} catch {
// Malformed percent sequences can be literal filenames.
}
return [...new Set(variants)].map((variant) =>
variant.startsWith("/") ? resolve(projectDir, variant.slice(1)) : resolve(projectDir, variant),
);
}
function resolveCssAssetPath( function resolveCssAssetPath(
projectDir: string, projectDir: string,
url: string, url: string,
@@ -265,8 +280,7 @@ function lintAudioSrcNotFound(
// before serving. Mirror that rewrite here so the existence check sees // before serving. Mirror that rewrite here so the existence check sees
// the same path the renderer will. Root-html srcs pass through unchanged. // the same path the renderer will. Root-html srcs pass through unchanged.
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src; const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
const resolved = resolve(projectDir, rootRelative); if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
if (!existsSync(resolved)) {
missingSrcs.push(src); missingSrcs.push(src);
} }
} }
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -64,4 +64,42 @@ describe("processCompositionAudio", () => {
expect(filter).toContain("volume=0"); expect(filter).toContain("volume=0");
expect(filter).toContain("[mixed]volume=1[out]"); expect(filter).toContain("[mixed]volume=1[out]");
}); });
it("prepares percent-encoded non-Latin audio srcs from decoded filesystem paths", async () => {
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
tempDirs.push(baseDir, workDir);
const encodedFilename =
"%D9%87%D9%86%D8%A7%20%D9%85%D8%B1%D9%88%D8%A7%20-%20%D9%85%D8%A8%D8%A7%D8%B1%D9%83.mp4";
const filename = decodeURIComponent(encodedFilename);
mkdirSync(join(baseDir, "assets"), { recursive: true });
writeFileSync(join(baseDir, "assets", filename), "stub");
const result = await processCompositionAudio(
[
{
id: "voice",
src: `assets/${encodedFilename}`,
start: 0,
end: 2,
mediaStart: 0,
layer: 0,
volume: 1,
type: "audio",
},
],
baseDir,
workDir,
join(baseDir, "out.m4a"),
2,
);
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
expect(runFfmpegMock).toHaveBeenCalledTimes(2);
const prepareArgs = runFfmpegMock.mock.calls[0]?.[0];
expect(prepareArgs).toContain(join(baseDir, "assets", filename));
});
}); });
@@ -137,6 +137,25 @@ describe("resolveProjectRelativeSrc — sub-composition path clamping", () => {
join(compiledDir, "assets/foo.mp4"), join(compiledDir, "assets/foo.mp4"),
); );
}); });
it("resolves percent-encoded non-Latin filenames across scripts", () => {
const projectDir = join(tmp, "project");
const cases = [
["arabic", "%D9%87%D9%86%D8%A7-%D9%85%D8%B1%D9%88%D8%A7.mp4"],
["japanese", "%E6%97%A5%E6%9C%AC%E8%AA%9E.mp4"],
["cyrillic", "%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82.mp4"],
["korean", "%ED%95%9C%EA%B8%80.mp4"],
] as const;
for (const [, encodedFilename] of cases) {
const filename = decodeURIComponent(encodedFilename);
writeFileSync(join(projectDir, "assets", filename), "");
expect(resolveProjectRelativeSrc(`assets/${encodedFilename}`, projectDir)).toBe(
join(projectDir, "assets", filename),
);
}
});
}); });
describe("parseVideoElements", () => { describe("parseVideoElements", () => {
@@ -516,30 +516,47 @@ export function resolveProjectRelativeSrc(
): string { ): string {
const qIdx = src.indexOf("?"); const qIdx = src.indexOf("?");
const cleanSrc = qIdx >= 0 ? src.slice(0, qIdx) : src; const cleanSrc = qIdx >= 0 ? src.slice(0, qIdx) : src;
const fromCompiled = compiledDir ? join(compiledDir, cleanSrc) : null;
const fromBase = join(baseDir, cleanSrc);
const candidates: string[] = []; const candidates: string[] = [];
if (fromCompiled) candidates.push(fromCompiled);
candidates.push(fromBase); const srcVariants = [cleanSrc];
// If the joined result escapes the project root (either via leading `..` try {
// or mid-path traversal that path.join collapsed past baseDir), retry const decodedSrc = decodeURIComponent(cleanSrc);
// with the basename re-anchored at the project root. This mirrors the if (decodedSrc !== cleanSrc) srcVariants.unshift(decodedSrc);
// browser URL clamp without relying on a particular `..` shape. } catch {
const baseAbs = resolve(baseDir); // Keep malformed percent sequences as literal filenames.
const fromBaseAbs = resolve(fromBase);
if (!fromBaseAbs.startsWith(baseAbs + sep) && fromBaseAbs !== baseAbs) {
// Normalize first (`assets/../../assets/foo.mp4` → `../assets/foo.mp4`)
// then strip any remaining leading `..` segments. Stripping `..` from the
// raw input would leave dangling siblings (`assets/../../assets/foo`
// would become `assets/assets/foo` instead of `assets/foo`).
const normalized = posix.normalize(cleanSrc.replace(/\\/g, "/"));
const stripped = normalized.replace(/^(\.\.\/)+/, "");
if (stripped && stripped !== src && !stripped.startsWith("..")) {
if (compiledDir) candidates.push(join(compiledDir, stripped));
candidates.push(join(baseDir, stripped));
}
} }
return candidates.find(existsSync) ?? fromBase;
const addCandidate = (candidate: string): void => {
if (!candidates.includes(candidate)) candidates.push(candidate);
};
for (const variant of srcVariants) {
const fromCompiled = compiledDir ? join(compiledDir, variant) : null;
const fromBase = join(baseDir, variant);
// If the joined result escapes the project root (either via leading `..`
// or mid-path traversal that path.join collapsed past baseDir), retry
// with the basename re-anchored at the project root. This mirrors the
// browser URL clamp without relying on a particular `..` shape.
const baseAbs = resolve(baseDir);
const fromBaseAbs = resolve(fromBase);
if (!fromBaseAbs.startsWith(baseAbs + sep) && fromBaseAbs !== baseAbs) {
// Normalize first (`assets/../../assets/foo.mp4` → `../assets/foo.mp4`)
// then strip any remaining leading `..` segments. Stripping `..` from the
// raw input would leave dangling siblings (`assets/../../assets/foo`
// would become `assets/assets/foo` instead of `assets/foo`).
const normalized = posix.normalize(variant.replace(/\\/g, "/"));
const stripped = normalized.replace(/^(\.\.\/)+/, "");
if (stripped && stripped !== variant && !stripped.startsWith("..")) {
if (compiledDir) addCandidate(join(compiledDir, stripped));
addCandidate(join(baseDir, stripped));
}
}
if (fromCompiled) addCandidate(fromCompiled);
addCandidate(fromBase);
}
return candidates.find(existsSync) ?? join(baseDir, cleanSrc);
} }
export async function extractAllVideoFrames( export async function extractAllVideoFrames(