mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(cli): resolve sub-composition <audio> src relative to its own file (#489)
`lintAudioSrcNotFound` resolves every `<audio src>` against the project root, which is correct for index.html but wrong for sub-compositions — their srcs are written relative to the sub-composition file (e.g. `../assets/foo.mp3`), and the bundler rewrites them at compile time. Carry the sub-composition path alongside each html source and run `<audio src>` strings starting with `../` through the existing `rewriteAssetPath` helper before checking existence on disk. Mirrors the runtime/bundler behaviour so the lint check sees the same path the renderer will fetch. Original (un-rewritten) src is still surfaced in the finding message so authors can grep for it in their HTML.
This commit is contained in:
@@ -324,6 +324,49 @@ describe("audio_src_not_found", () => {
|
|||||||
const occurrences = (finding?.message.match(/song\.mp3/g) ?? []).length;
|
const occurrences = (finding?.message.match(/song\.mp3/g) ?? []).length;
|
||||||
expect(occurrences).toBe(1);
|
expect(occurrences).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves sub-composition src relative to the sub-composition file (../assets/...)", () => {
|
||||||
|
// A sub-composition at compositions/captions.html referencing
|
||||||
|
// ../assets/bgm.mp3 means {projectRoot}/assets/bgm.mp3 — the bundler
|
||||||
|
// rewrites that path before serving, so the lint check has to mirror it.
|
||||||
|
const subComp = `<html><body>
|
||||||
|
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||||
|
<audio id="music" src="../assets/bgm.mp3" data-start="0" data-track-index="0" data-volume="1"></audio>
|
||||||
|
</div>
|
||||||
|
<script>window.__timelines = window.__timelines || {}; window.__timelines["captions"] = gsap.timeline({ paused: true });</script>
|
||||||
|
</body></html>`;
|
||||||
|
const project = makeProject(validHtml(), { "captions.html": subComp });
|
||||||
|
mkdirSync(join(project.dir, "assets"), { recursive: true });
|
||||||
|
writeFileSync(join(project.dir, "assets", "bgm.mp3"), "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("flags sub-composition src that resolves to a missing file via ../", () => {
|
||||||
|
const subComp = `<html><body>
|
||||||
|
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||||
|
<audio id="music" src="../assets/missing.mp3" data-start="0" data-track-index="0" data-volume="1"></audio>
|
||||||
|
</div>
|
||||||
|
<script>window.__timelines = window.__timelines || {}; window.__timelines["captions"] = gsap.timeline({ paused: true });</script>
|
||||||
|
</body></html>`;
|
||||||
|
const project = makeProject(validHtml(), { "captions.html": subComp });
|
||||||
|
// No assets/ directory at all.
|
||||||
|
|
||||||
|
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).toBeDefined();
|
||||||
|
// The original (un-rewritten) src is what surfaces in the message so the
|
||||||
|
// author can grep for it in their HTML.
|
||||||
|
expect(finding?.message).toContain("../assets/missing.mp3");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("multiple_root_compositions", () => {
|
describe("multiple_root_compositions", () => {
|
||||||
|
|||||||
@@ -2,8 +2,22 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|||||||
import { join, resolve, extname } from "node:path";
|
import { join, resolve, extname } from "node:path";
|
||||||
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
|
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
|
||||||
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
|
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
|
||||||
|
import { rewriteAssetPath } from "@hyperframes/core";
|
||||||
import type { ProjectDir } from "./project.js";
|
import type { ProjectDir } from "./project.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An HTML source paired with the sub-composition path it came from, if any.
|
||||||
|
* Sub-composition relative paths (`../assets/foo.mp3`) need to be resolved
|
||||||
|
* against the sub-composition's directory before checking the filesystem —
|
||||||
|
* the root index.html is the only source where a bare `resolve(projectDir, src)`
|
||||||
|
* is correct.
|
||||||
|
*/
|
||||||
|
interface HtmlSource {
|
||||||
|
html: string;
|
||||||
|
/** `data-composition-src` value (e.g. "compositions/scene.html"); undefined for the root. */
|
||||||
|
compSrcPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectLintResult {
|
export interface ProjectLintResult {
|
||||||
results: Array<{ file: string; result: HyperframeLintResult }>;
|
results: Array<{ file: string; result: HyperframeLintResult }>;
|
||||||
totalErrors: number;
|
totalErrors: number;
|
||||||
@@ -32,14 +46,14 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
|
|||||||
totalInfos += rootResult.infoCount;
|
totalInfos += rootResult.infoCount;
|
||||||
|
|
||||||
// Lint sub-compositions in compositions/ directory, collecting HTML for project-level checks
|
// Lint sub-compositions in compositions/ directory, collecting HTML for project-level checks
|
||||||
const allHtmlSources = [rootHtml];
|
const allHtmlSources: HtmlSource[] = [{ html: rootHtml }];
|
||||||
const compositionsDir = resolve(project.dir, "compositions");
|
const compositionsDir = resolve(project.dir, "compositions");
|
||||||
if (existsSync(compositionsDir)) {
|
if (existsSync(compositionsDir)) {
|
||||||
const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html"));
|
const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html"));
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const filePath = join(compositionsDir, file);
|
const filePath = join(compositionsDir, file);
|
||||||
const html = readFileSync(filePath, "utf-8");
|
const html = readFileSync(filePath, "utf-8");
|
||||||
allHtmlSources.push(html);
|
allHtmlSources.push({ html, compSrcPath: `compositions/${file}` });
|
||||||
const result = lintHyperframeHtml(html, { filePath, isSubComposition: true });
|
const result = lintHyperframeHtml(html, { filePath, isSubComposition: true });
|
||||||
results.push({ file: `compositions/${file}`, result });
|
results.push({ file: `compositions/${file}`, result });
|
||||||
totalErrors += result.errorCount;
|
totalErrors += result.errorCount;
|
||||||
@@ -83,7 +97,10 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
|
|||||||
* placing an audio file in the project but forgetting the <audio> tag, which
|
* placing an audio file in the project but forgetting the <audio> tag, which
|
||||||
* results in a silent render.
|
* results in a silent render.
|
||||||
*/
|
*/
|
||||||
function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): HyperframeLintFinding[] {
|
function lintProjectAudioFiles(
|
||||||
|
projectDir: string,
|
||||||
|
htmlSources: HtmlSource[],
|
||||||
|
): HyperframeLintFinding[] {
|
||||||
const findings: HyperframeLintFinding[] = [];
|
const findings: HyperframeLintFinding[] = [];
|
||||||
|
|
||||||
// Scan project root for audio files (non-recursive — only top-level)
|
// Scan project root for audio files (non-recursive — only top-level)
|
||||||
@@ -99,7 +116,7 @@ function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): Hyper
|
|||||||
if (audioFiles.length === 0) return findings;
|
if (audioFiles.length === 0) return findings;
|
||||||
|
|
||||||
// Check if any HTML source contains an <audio> element
|
// Check if any HTML source contains an <audio> element
|
||||||
const hasAudioElement = htmlSources.some((html) => /<audio\b/i.test(html));
|
const hasAudioElement = htmlSources.some(({ html }) => /<audio\b/i.test(html));
|
||||||
|
|
||||||
if (!hasAudioElement) {
|
if (!hasAudioElement) {
|
||||||
findings.push({
|
findings.push({
|
||||||
@@ -121,19 +138,27 @@ function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): Hyper
|
|||||||
* in the project directory. The renderer will silently skip missing audio,
|
* in the project directory. The renderer will silently skip missing audio,
|
||||||
* producing a silent video with no indication of what went wrong.
|
* producing a silent video with no indication of what went wrong.
|
||||||
*/
|
*/
|
||||||
function lintAudioSrcNotFound(projectDir: string, htmlSources: string[]): HyperframeLintFinding[] {
|
function lintAudioSrcNotFound(
|
||||||
|
projectDir: string,
|
||||||
|
htmlSources: HtmlSource[],
|
||||||
|
): HyperframeLintFinding[] {
|
||||||
const findings: HyperframeLintFinding[] = [];
|
const findings: HyperframeLintFinding[] = [];
|
||||||
|
|
||||||
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||||
|
|
||||||
const missingSrcs: string[] = [];
|
const missingSrcs: string[] = [];
|
||||||
for (const html of htmlSources) {
|
for (const { html, compSrcPath } of htmlSources) {
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = audioSrcRe.exec(html)) !== null) {
|
while ((match = audioSrcRe.exec(html)) !== null) {
|
||||||
const src = match[1]!;
|
const src = match[1]!;
|
||||||
if (/^(https?:|data:|blob:)/i.test(src)) continue;
|
if (/^(https?:|data:|blob:)/i.test(src)) continue;
|
||||||
if (/^__[A-Z_]+__$/.test(src)) continue; // Skip template placeholders
|
if (/^__[A-Z_]+__$/.test(src)) continue; // Skip template placeholders
|
||||||
const resolved = resolve(projectDir, src);
|
// Sub-composition srcs are written relative to the sub-composition file
|
||||||
|
// (e.g. "../assets/foo.mp3"); the bundler rewrites them to root-relative
|
||||||
|
// before serving. Mirror that rewrite here so the existence check sees
|
||||||
|
// the same path the renderer will. Root-html srcs pass through unchanged.
|
||||||
|
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
||||||
|
const resolved = resolve(projectDir, rootRelative);
|
||||||
if (!existsSync(resolved)) {
|
if (!existsSync(resolved)) {
|
||||||
missingSrcs.push(src);
|
missingSrcs.push(src);
|
||||||
}
|
}
|
||||||
@@ -192,7 +217,7 @@ function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding
|
|||||||
* Extracts each attribute independently (order-insensitive) to handle any HTML attribute order.
|
* Extracts each attribute independently (order-insensitive) to handle any HTML attribute order.
|
||||||
* Deduplicates by (src, start, duration) to avoid flagging the same audio reached via sub-compositions.
|
* Deduplicates by (src, start, duration) to avoid flagging the same audio reached via sub-compositions.
|
||||||
*/
|
*/
|
||||||
function lintDuplicateAudioTracks(htmlSources: string[]): HyperframeLintFinding[] {
|
function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFinding[] {
|
||||||
const findings: HyperframeLintFinding[] = [];
|
const findings: HyperframeLintFinding[] = [];
|
||||||
function extractAttr(tag: string, name: string): string | null {
|
function extractAttr(tag: string, name: string): string | null {
|
||||||
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
|
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
|
||||||
@@ -203,7 +228,7 @@ function lintDuplicateAudioTracks(htmlSources: string[]): HyperframeLintFinding[
|
|||||||
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
|
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const html of htmlSources) {
|
for (const { html } of htmlSources) {
|
||||||
// Regex with g flag must be created inside the loop — a shared g-regex
|
// Regex with g flag must be created inside the loop — a shared g-regex
|
||||||
// carries lastIndex across strings, silently skipping matches.
|
// carries lastIndex across strings, silently skipping matches.
|
||||||
const audioTagRe = /<audio\b[^>]*>/gi;
|
const audioTagRe = /<audio\b[^>]*>/gi;
|
||||||
|
|||||||
Reference in New Issue
Block a user