Skip to content

Commit 720bb9d

Browse files
Merge pull request #1065 from heygen-com/fix/nonlatin-media-src-resolution
fix(engine): resolve encoded non-Latin media paths
2 parents 7461f1d + a68b4b6 commit 720bb9d

7 files changed

Lines changed: 318 additions & 45 deletions

File tree

packages/cli/src/utils/lintProject.test.ts

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import { describe, it, expect, afterEach } from "vitest";
2-
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
2+
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
55
import { lintProject, shouldBlockRender } from "./lintProject.js";
66
import type { ProjectDir } from "./project.js";
77

88
function tmpProject(name: string): string {
9-
const dir = join(tmpdir(), `hf-test-${name}-${Date.now()}`);
10-
mkdirSync(dir, { recursive: true });
11-
return dir;
9+
return mkdtempSync(join(tmpdir(), `hf-test-${name}-`));
1210
}
1311

1412
function validHtml(compId = "main"): string {
@@ -121,6 +119,29 @@ describe("lintProject", () => {
121119
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
122120
});
123121

122+
it("lints percent-encoded linked CSS filenames that exist decoded on disk", () => {
123+
const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.css";
124+
const project = makeProject(validHtml(), {
125+
"scene.html": `<html><head><link rel="stylesheet" href="${encodedFilename}"></head><body>
126+
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080" data-start="0" data-duration="2"></div>
127+
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
128+
</body></html>`,
129+
});
130+
writeFileSync(
131+
join(project.dir, "compositions", decodeURIComponent(encodedFilename)),
132+
'[data-composition-id="scene"] .title { opacity: 0; }',
133+
);
134+
135+
const { results } = lintProject(project);
136+
const subResult = results.find((result) => result.file === "compositions/scene.html");
137+
const finding = subResult?.result.findings.find(
138+
(item) => item.code === "composition_self_attribute_selector",
139+
);
140+
141+
expect(finding).toBeDefined();
142+
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
143+
});
144+
124145
it("aggregates errors across index.html and sub-compositions", () => {
125146
const project = makeProject(htmlWithMissingMediaId(), {
126147
"overlay.html": htmlWithMissingMediaId(),
@@ -182,6 +203,29 @@ function validHtmlWithAudio(compId = "main"): string {
182203
</body></html>`;
183204
}
184205

206+
function validHtmlWithAudioSrc(src: string): string {
207+
return `<html><body>
208+
<div data-composition-id="main" data-width="1920" data-height="1080">
209+
<audio id="music" src="${src}" data-start="0" data-track-index="0" data-volume="1"></audio>
210+
</div>
211+
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
212+
</body></html>`;
213+
}
214+
215+
function validHtmlWithMaskImageUrl(url: string): string {
216+
return `<html><body>
217+
<div data-composition-id="main" data-width="1920" data-height="1080">
218+
<div class="hf-texture-text hf-texture-lava">TEXT</div>
219+
</div>
220+
<style>
221+
.hf-texture-lava {
222+
mask-image: url("${url}");
223+
}
224+
</style>
225+
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
226+
</body></html>`;
227+
}
228+
185229
describe("audio_file_without_element", () => {
186230
it("warns when audio file exists but no <audio> element", () => {
187231
const project = makeProject(validHtml());
@@ -330,6 +374,46 @@ describe("audio_src_not_found", () => {
330374
expect(finding).toBeUndefined();
331375
});
332376

377+
it("does not error for percent-encoded non-Latin filenames that exist on disk", () => {
378+
const encodedFilename =
379+
"%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";
380+
const project = makeProject(validHtmlWithAudioSrc(`assets/${encodedFilename}`));
381+
mkdirSync(join(project.dir, "assets"), { recursive: true });
382+
writeFileSync(join(project.dir, "assets", decodeURIComponent(encodedFilename)), "fake");
383+
384+
const { results } = lintProject(project);
385+
386+
const first = results[0];
387+
expect(first).toBeDefined();
388+
const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found");
389+
expect(finding).toBeUndefined();
390+
});
391+
392+
it("does not error for malformed percent sequences that are literal filenames", () => {
393+
const filename = "100%-discount.mp4";
394+
const project = makeProject(validHtmlWithAudioSrc(`assets/${filename}`));
395+
mkdirSync(join(project.dir, "assets"), { recursive: true });
396+
writeFileSync(join(project.dir, "assets", filename), "fake");
397+
398+
const { results } = lintProject(project);
399+
400+
const first = results[0];
401+
expect(first).toBeDefined();
402+
const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found");
403+
expect(finding).toBeUndefined();
404+
});
405+
406+
it("does not treat decoded traversal as an existing file outside the project", () => {
407+
const project = makeProject(
408+
validHtmlWithAudioSrc("assets/foo/%2E%2E/%2E%2E/%2E%2E/etc/passwd"),
409+
);
410+
411+
const { results } = lintProject(project);
412+
413+
const finding = results[0]?.result.findings.find((f) => f.code === "audio_src_not_found");
414+
expect(finding).toBeDefined();
415+
});
416+
333417
it("deduplicates missing files across compositions", () => {
334418
const project = makeProject(validHtmlWithAudio(), {
335419
"captions.html": validHtmlWithAudio("captions"),
@@ -467,6 +551,30 @@ describe("texture_mask_asset_not_found", () => {
467551
expect(finding).toBeUndefined();
468552
});
469553

554+
it("checks mask-image URLs inside percent-encoded linked CSS filenames", () => {
555+
const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.css";
556+
const project = makeProject(validHtml(), {
557+
"scene.html": `<html><head><link rel="stylesheet" href="${encodedFilename}"></head><body>
558+
<div data-composition-id="scene" data-width="1920" data-height="1080">
559+
<div class="hf-texture-text hf-texture-lava">TEXT</div>
560+
</div>
561+
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
562+
</body></html>`,
563+
});
564+
writeFileSync(
565+
join(project.dir, "compositions", decodeURIComponent(encodedFilename)),
566+
'.hf-texture-lava { mask-image: url("masks/missing.png"); }',
567+
);
568+
569+
const { results } = lintProject(project);
570+
const finding = results[0]?.result.findings.find(
571+
(item) => item.code === "texture_mask_asset_not_found",
572+
);
573+
574+
expect(finding).toBeDefined();
575+
expect(finding?.message).toContain("masks/missing.png");
576+
});
577+
470578
it("resolves root-absolute mask-image URLs from the project root", () => {
471579
const html = `<html><body>
472580
<div data-composition-id="main" data-width="1920" data-height="1080">
@@ -493,6 +601,33 @@ describe("texture_mask_asset_not_found", () => {
493601

494602
expect(finding).toBeUndefined();
495603
});
604+
605+
it("does not error for percent-encoded non-Latin mask filenames that exist on disk", () => {
606+
const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.png";
607+
const project = makeProject(validHtmlWithMaskImageUrl(`assets/${encodedFilename}`));
608+
mkdirSync(join(project.dir, "assets"), { recursive: true });
609+
writeFileSync(join(project.dir, "assets", decodeURIComponent(encodedFilename)), "fake");
610+
611+
const { results } = lintProject(project);
612+
const finding = results[0]?.result.findings.find(
613+
(item) => item.code === "texture_mask_asset_not_found",
614+
);
615+
616+
expect(finding).toBeUndefined();
617+
});
618+
619+
it("does not treat decoded mask traversal as an existing file outside the project", () => {
620+
const project = makeProject(
621+
validHtmlWithMaskImageUrl("assets/foo/%2E%2E/%2E%2E/%2E%2E/etc/passwd"),
622+
);
623+
624+
const { results } = lintProject(project);
625+
const finding = results[0]?.result.findings.find(
626+
(item) => item.code === "texture_mask_asset_not_found",
627+
);
628+
629+
expect(finding).toBeDefined();
630+
});
496631
});
497632

498633
describe("multiple_root_compositions", () => {

packages/cli/src/utils/lintProject.ts

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { existsSync, readFileSync, readdirSync } from "node:fs";
2-
import { dirname, join, resolve, extname } from "node:path";
2+
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path";
33
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
44
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
5-
import { rewriteAssetPath } from "@hyperframes/core";
5+
import { decodeUrlPathVariants, rewriteAssetPath } from "@hyperframes/core";
66
import type { ProjectDir } from "./project.js";
77

88
/**
@@ -62,9 +62,9 @@ function collectExternalStyles(
6262
const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
6363
if (!isLocalStylesheetHref(href)) continue;
6464
const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
65-
const resolved = resolve(projectDir, rootRelative);
66-
if (!existsSync(resolved)) continue;
67-
styles.push({ href, content: readFileSync(resolved, "utf-8") });
65+
const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative);
66+
if (!stylesheet) continue;
67+
styles.push({ href, content: readFileSync(stylesheet.resolved, "utf-8") });
6868
}
6969
return styles;
7070
}
@@ -88,9 +88,12 @@ function collectCssSources(projectDir: string, html: string, compSrcPath?: strin
8888
if (!isLocalStylesheetHref(href)) continue;
8989

9090
const rootRelativePath = compSrcPath ? join(dirname(compSrcPath), href) : href;
91-
const resolved = resolve(projectDir, rootRelativePath);
92-
if (!existsSync(resolved)) continue;
93-
sources.push({ content: readFileSync(resolved, "utf-8"), rootRelativePath });
91+
const stylesheet = resolveExistingLocalAsset(projectDir, rootRelativePath);
92+
if (!stylesheet) continue;
93+
sources.push({
94+
content: readFileSync(stylesheet.resolved, "utf-8"),
95+
rootRelativePath: stylesheet.rootRelativePath,
96+
});
9497
}
9598

9699
let tagMatch: RegExpExecArray | null;
@@ -113,16 +116,63 @@ function cleanAssetUrl(url: string): string {
113116
return url.trim().split(/[?#]/, 1)[0] ?? "";
114117
}
115118

116-
function resolveCssAssetPath(
119+
function isWithinProjectRoot(projectDir: string, candidate: string): boolean {
120+
const projectRoot = resolve(projectDir);
121+
const relativePath = relative(projectRoot, candidate);
122+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
123+
}
124+
125+
function addCandidate(candidates: string[], candidate: string): void {
126+
if (!candidates.includes(candidate)) candidates.push(candidate);
127+
}
128+
129+
function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
130+
const cleanUrl = cleanAssetUrl(url);
131+
const projectRoot = resolve(projectDir);
132+
const candidates: string[] = [];
133+
134+
for (const variant of decodeUrlPathVariants(cleanUrl)) {
135+
const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
136+
const resolved = resolve(projectRoot, projectRelative);
137+
if (isWithinProjectRoot(projectRoot, resolved)) {
138+
addCandidate(candidates, resolved);
139+
continue;
140+
}
141+
142+
const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
143+
const clamped = normalized.replace(/^(\.\.\/)+/, "");
144+
if (clamped && !clamped.startsWith("..")) {
145+
addCandidate(candidates, resolve(projectRoot, clamped));
146+
}
147+
}
148+
149+
return candidates;
150+
}
151+
152+
function resolveExistingLocalAsset(
153+
projectDir: string,
154+
url: string,
155+
): { resolved: string; rootRelativePath: string } | null {
156+
const projectRoot = resolve(projectDir);
157+
const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
158+
if (!resolved) return null;
159+
return { resolved, rootRelativePath: relative(projectRoot, resolved) };
160+
}
161+
162+
function resolveCssAssetCandidates(
117163
projectDir: string,
118164
url: string,
119165
htmlCompSrcPath?: string,
120166
cssRootRelativePath?: string,
121-
): string {
122-
if (url.startsWith("/")) return resolve(projectDir, url.slice(1));
123-
if (cssRootRelativePath) return resolve(projectDir, join(dirname(cssRootRelativePath), url));
124-
if (htmlCompSrcPath) return resolve(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
125-
return resolve(projectDir, url);
167+
): string[] {
168+
if (url.startsWith("/")) return resolveLocalAssetCandidates(projectDir, url);
169+
if (cssRootRelativePath) {
170+
return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url));
171+
}
172+
if (htmlCompSrcPath) {
173+
return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
174+
}
175+
return resolveLocalAssetCandidates(projectDir, url);
126176
}
127177

128178
/**
@@ -265,8 +315,7 @@ function lintAudioSrcNotFound(
265315
// before serving. Mirror that rewrite here so the existence check sees
266316
// the same path the renderer will. Root-html srcs pass through unchanged.
267317
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
268-
const resolved = resolve(projectDir, rootRelative);
269-
if (!existsSync(resolved)) {
318+
if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
270319
missingSrcs.push(src);
271320
}
272321
}
@@ -304,14 +353,14 @@ function lintTextureMaskAssetNotFound(
304353
if (!url || isRemoteOrInlineUrl(url)) continue;
305354
if (/^__[A-Z_]+__$/.test(url)) continue;
306355

307-
const resolved = resolveCssAssetPath(
356+
const candidates = resolveCssAssetCandidates(
308357
projectDir,
309358
url,
310359
compSrcPath,
311360
cssSource.rootRelativePath,
312361
);
313-
if (existsSync(resolved)) continue;
314-
missing.set(url, resolved);
362+
if (candidates.some(existsSync)) continue;
363+
missing.set(url, candidates[0] ?? resolve(projectDir, url));
315364
}
316365
}
317366
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export {
137137
rewriteAssetPath,
138138
rewriteCssAssetUrls,
139139
} from "./compiler/rewriteSubCompPaths";
140+
export { decodeUrlPathVariants } from "./utils/urlPath";
140141

141142
// Inline scripts
142143
export {

packages/core/src/utils/urlPath.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function decodeUrlPathVariants(path: string): string[] {
2+
const variants = [path];
3+
try {
4+
const decoded = decodeURIComponent(path);
5+
if (decoded !== path) variants.unshift(decoded);
6+
} catch {
7+
// Malformed percent sequences may be literal filesystem names.
8+
}
9+
10+
return variants;
11+
}

packages/engine/src/services/audioMixer.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
55

@@ -64,4 +64,42 @@ describe("processCompositionAudio", () => {
6464
expect(filter).toContain("volume=0");
6565
expect(filter).toContain("[mixed]volume=1[out]");
6666
});
67+
68+
it("prepares percent-encoded non-Latin audio srcs from decoded filesystem paths", async () => {
69+
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
70+
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
71+
tempDirs.push(baseDir, workDir);
72+
73+
const encodedFilename =
74+
"%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";
75+
const filename = decodeURIComponent(encodedFilename);
76+
mkdirSync(join(baseDir, "assets"), { recursive: true });
77+
writeFileSync(join(baseDir, "assets", filename), "stub");
78+
79+
const result = await processCompositionAudio(
80+
[
81+
{
82+
id: "voice",
83+
src: `assets/${encodedFilename}`,
84+
start: 0,
85+
end: 2,
86+
mediaStart: 0,
87+
layer: 0,
88+
volume: 1,
89+
type: "audio",
90+
},
91+
],
92+
baseDir,
93+
workDir,
94+
join(baseDir, "out.m4a"),
95+
2,
96+
);
97+
98+
expect(result.success).toBe(true);
99+
expect(result.error).toBeUndefined();
100+
expect(runFfmpegMock).toHaveBeenCalledTimes(2);
101+
102+
const prepareArgs = runFfmpegMock.mock.calls[0]?.[0];
103+
expect(prepareArgs).toContain(join(baseDir, "assets", filename));
104+
});
67105
});

0 commit comments

Comments
 (0)