mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat: lint for audio tag existingon found project audio
This commit is contained in:
@@ -150,6 +150,181 @@ describe("lintProject", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function validHtmlWithAudio(compId = "main"): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="${compId}" data-width="1920" data-height="1080">
|
||||
<audio id="music" src="song.mp3" data-start="0" data-track-index="0" data-volume="1"></audio>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["${compId}"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
describe("audio_file_without_element", () => {
|
||||
it("warns when audio file exists but no <audio> element", () => {
|
||||
const project = makeProject(validHtml());
|
||||
writeFileSync(join(project.dir, "music.mp3"), "fake");
|
||||
|
||||
const { totalWarnings, results } = lintProject(project);
|
||||
|
||||
expect(totalWarnings).toBeGreaterThan(0);
|
||||
const first = results[0];
|
||||
expect(first).toBeDefined();
|
||||
const finding = first?.result.findings.find((f) => f.code === "audio_file_without_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.message).toContain("music.mp3");
|
||||
});
|
||||
|
||||
it("does not warn when audio file exists and <audio> element is present", () => {
|
||||
const project = makeProject(validHtmlWithAudio());
|
||||
writeFileSync(join(project.dir, "song.mp3"), "fake");
|
||||
|
||||
const { results } = lintProject(project);
|
||||
|
||||
const first = results[0];
|
||||
expect(first).toBeDefined();
|
||||
const finding = first?.result.findings.find((f) => f.code === "audio_file_without_element");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn when no audio files exist", () => {
|
||||
const project = makeProject(validHtml());
|
||||
|
||||
const { results } = lintProject(project);
|
||||
|
||||
const first = results[0];
|
||||
expect(first).toBeDefined();
|
||||
const finding = first?.result.findings.find((f) => f.code === "audio_file_without_element");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects multiple audio file extensions", () => {
|
||||
const project = makeProject(validHtml());
|
||||
writeFileSync(join(project.dir, "narration.wav"), "fake");
|
||||
writeFileSync(join(project.dir, "bgm.ogg"), "fake");
|
||||
|
||||
const { results } = lintProject(project);
|
||||
|
||||
const first = results[0];
|
||||
expect(first).toBeDefined();
|
||||
const finding = first?.result.findings.find((f) => f.code === "audio_file_without_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("narration.wav");
|
||||
expect(finding?.message).toContain("bgm.ogg");
|
||||
});
|
||||
|
||||
it("does not warn when <audio> element is in a sub-composition", () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"captions.html": validHtmlWithAudio("captions"),
|
||||
});
|
||||
writeFileSync(join(project.dir, "song.mp3"), "fake");
|
||||
|
||||
const { results } = lintProject(project);
|
||||
|
||||
const first = results[0];
|
||||
expect(first).toBeDefined();
|
||||
const finding = first?.result.findings.find((f) => f.code === "audio_file_without_element");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio_src_not_found", () => {
|
||||
it("errors when <audio> src references a file that does not exist", () => {
|
||||
const project = makeProject(validHtmlWithAudio());
|
||||
// song.mp3 is referenced in validHtmlWithAudio but not on disk
|
||||
|
||||
const { totalErrors, results } = lintProject(project);
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
const first = results[0];
|
||||
expect(first).toBeDefined();
|
||||
const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("song.mp3");
|
||||
});
|
||||
|
||||
it("does not error when <audio> src file exists", () => {
|
||||
const project = makeProject(validHtmlWithAudio());
|
||||
writeFileSync(join(project.dir, "song.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("does not error when <audio> src is an HTTP URL", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<audio id="music" src="https://cdn.example.com/song.mp3" 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);
|
||||
|
||||
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("detects missing src in sub-compositions", () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"captions.html": validHtmlWithAudio("captions"),
|
||||
});
|
||||
// song.mp3 referenced in sub-comp but not on disk
|
||||
|
||||
const { totalErrors, results } = lintProject(project);
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
const first = results[0];
|
||||
expect(first).toBeDefined();
|
||||
const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("resolves relative paths from project root", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" 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["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
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("deduplicates missing files across compositions", () => {
|
||||
const project = makeProject(validHtmlWithAudio(), {
|
||||
"captions.html": validHtmlWithAudio("captions"),
|
||||
});
|
||||
// Both reference song.mp3 which doesn't exist
|
||||
|
||||
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();
|
||||
// Should mention song.mp3 only once despite two references
|
||||
const occurrences = (finding?.message.match(/song\.mp3/g) ?? []).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldBlockRender", () => {
|
||||
it("default: does not block on errors", () => {
|
||||
expect(shouldBlockRender(false, false, 5, 0)).toBe(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { join, resolve, extname } from "node:path";
|
||||
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
|
||||
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
|
||||
import type { ProjectDir } from "./project.js";
|
||||
|
||||
export interface ProjectLintResult {
|
||||
@@ -10,6 +11,8 @@ export interface ProjectLintResult {
|
||||
totalInfos: number;
|
||||
}
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
|
||||
|
||||
/**
|
||||
* Lint the root index.html and all sub-compositions in the compositions/ directory.
|
||||
* Returns aggregated results across all files.
|
||||
@@ -28,13 +31,15 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
|
||||
totalWarnings += rootResult.warningCount;
|
||||
totalInfos += rootResult.infoCount;
|
||||
|
||||
// Lint sub-compositions in compositions/ directory
|
||||
// Lint sub-compositions in compositions/ directory, collecting HTML for project-level checks
|
||||
const allHtmlSources = [rootHtml];
|
||||
const compositionsDir = resolve(project.dir, "compositions");
|
||||
if (existsSync(compositionsDir)) {
|
||||
const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html"));
|
||||
for (const file of files) {
|
||||
const filePath = join(compositionsDir, file);
|
||||
const html = readFileSync(filePath, "utf-8");
|
||||
allHtmlSources.push(html);
|
||||
const result = lintHyperframeHtml(html, { filePath });
|
||||
results.push({ file: `compositions/${file}`, result });
|
||||
totalErrors += result.errorCount;
|
||||
@@ -43,9 +48,111 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Project-level checks ──────────────────────────────────────────────
|
||||
|
||||
const projectFindings = [
|
||||
...lintProjectAudioFiles(project.dir, allHtmlSources),
|
||||
...lintAudioSrcNotFound(project.dir, allHtmlSources),
|
||||
];
|
||||
if (projectFindings.length > 0) {
|
||||
// Append project-level findings to the root index.html result
|
||||
for (const finding of projectFindings) {
|
||||
rootResult.findings.push(finding);
|
||||
if (finding.severity === "error") {
|
||||
rootResult.errorCount++;
|
||||
rootResult.ok = false;
|
||||
totalErrors++;
|
||||
} else if (finding.severity === "warning") {
|
||||
rootResult.warningCount++;
|
||||
totalWarnings++;
|
||||
} else {
|
||||
rootResult.infoCount++;
|
||||
totalInfos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { results, totalErrors, totalWarnings, totalInfos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for audio files in the project directory that have no corresponding
|
||||
* <audio> element in any composition HTML. This catches the common mistake of
|
||||
* placing an audio file in the project but forgetting the <audio> tag, which
|
||||
* results in a silent render.
|
||||
*/
|
||||
function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
// Scan project root for audio files (non-recursive — only top-level)
|
||||
let audioFiles: string[];
|
||||
try {
|
||||
audioFiles = readdirSync(projectDir).filter((f) =>
|
||||
AUDIO_EXTENSIONS.has(extname(f).toLowerCase()),
|
||||
);
|
||||
} catch {
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (audioFiles.length === 0) return findings;
|
||||
|
||||
// Check if any HTML source contains an <audio> element
|
||||
const hasAudioElement = htmlSources.some((html) => /<audio\b/i.test(html));
|
||||
|
||||
if (!hasAudioElement) {
|
||||
findings.push({
|
||||
code: "audio_file_without_element",
|
||||
severity: "warning",
|
||||
message: `Found audio file(s) in project (${audioFiles.join(", ")}) but no <audio> element in any composition. The rendered video will be silent.`,
|
||||
fixHint:
|
||||
'Add an <audio id="my-audio" src="' +
|
||||
audioFiles[0] +
|
||||
'" data-start="0" data-track-index="0" data-volume="1"></audio> element inside the composition root.',
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for <audio> elements whose src points to a file that doesn't exist
|
||||
* in the project directory. The renderer will silently skip missing audio,
|
||||
* producing a silent video with no indication of what went wrong.
|
||||
*/
|
||||
function lintAudioSrcNotFound(projectDir: string, htmlSources: string[]): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
const missingSrcs: string[] = [];
|
||||
for (const html of htmlSources) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = audioSrcRe.exec(html)) !== null) {
|
||||
const src = match[1]!;
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) continue;
|
||||
const resolved = resolve(projectDir, src);
|
||||
if (!existsSync(resolved)) {
|
||||
missingSrcs.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingSrcs.length > 0) {
|
||||
const unique = [...new Set(missingSrcs)];
|
||||
findings.push({
|
||||
code: "audio_src_not_found",
|
||||
severity: "error",
|
||||
message: `<audio> element references file(s) not found in the project: ${unique.join(", ")}. The rendered video will be silent.`,
|
||||
fixHint:
|
||||
unique.length === 1
|
||||
? `Add the file "${unique[0]}" to the project directory, or update the src attribute to point to an existing file.`
|
||||
: `Add the missing files to the project directory, or update the src attributes to point to existing files.`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a render should be blocked based on lint results and strict mode.
|
||||
* --strict blocks on errors; --strict-all blocks on errors or warnings.
|
||||
|
||||
Reference in New Issue
Block a user