mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(cli): reject blank default composition entries (#3392)
* fix(cli): reject blank default composition entry * fix(cli): complete blank entry safeguards
This commit is contained in:
@@ -69,6 +69,112 @@ describe("external symlink assets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("blank_root_with_standalone_composition", () => {
|
||||
it("errors when the default entry is blank but an authored standalone composition lives under compositions", async () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"index.html": `<!doctype html><html><body>
|
||||
<div data-composition-id="bona-brand-card" data-width="1920" data-height="1080" data-start="0" data-duration="5">
|
||||
<div id="main-clip" class="clip" data-start="0" data-duration="5" data-track-index="0">BONA</div>
|
||||
</div>
|
||||
<script>window.__timelines = { "bona-brand-card": gsap.timeline({ paused: true }) };</script>
|
||||
</body></html>`,
|
||||
});
|
||||
|
||||
const { results, totalErrors } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((result) => result.result.findings)
|
||||
.find((item) => item.code === "blank_root_with_standalone_composition");
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("compositions/index.html");
|
||||
expect(finding?.message).toContain("index.html");
|
||||
expect(finding?.fixHint).toContain("data-composition-src");
|
||||
});
|
||||
|
||||
it("treats non-rendering script, style, link, meta, and template children as blank", async () => {
|
||||
const shellOnlyRoot = validHtml().replace(
|
||||
"</div>",
|
||||
`<script type="application/json">{}</script>
|
||||
<style>.unused { color: white; }</style>
|
||||
<link rel="stylesheet" href="data:text/css,.unused%7Bcolor:white%7D">
|
||||
<meta name="description" content="shell">
|
||||
<template id="row-template"><div>row</div></template>
|
||||
</div>`,
|
||||
);
|
||||
const project = makeProject(shellOnlyRoot, {
|
||||
"authored.html": `<!doctype html><html><body>
|
||||
<div data-composition-id="authored" data-width="1920" data-height="1080" data-start="0" data-duration="5">
|
||||
<div class="clip" data-start="0" data-duration="5">Visible</div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
});
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((result) => result.result.findings)
|
||||
.find((item) => item.code === "blank_root_with_standalone_composition");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not fire when index.html already contains authored clip content", async () => {
|
||||
const authoredRoot = validHtml().replace(
|
||||
"</div>",
|
||||
'<div class="clip" data-start="0" data-duration="10">Master content</div></div>',
|
||||
);
|
||||
const project = makeProject(authoredRoot, {
|
||||
"alternate.html": `<!doctype html><html><body>
|
||||
<div data-composition-id="alternate" data-width="1920" data-height="1080" data-start="0" data-duration="5">
|
||||
<div class="clip" data-start="0" data-duration="5">Alternate</div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
});
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((result) => result.result.findings)
|
||||
.find((item) => item.code === "blank_root_with_standalone_composition");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not treat a template-wrapped sub-composition as a misplaced standalone entry", async () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"scene.html": `<template>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<div class="clip" data-start="0" data-duration="5">Scene</div>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((result) => result.result.findings)
|
||||
.find((item) => item.code === "blank_root_with_standalone_composition");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still catches a standalone composition that contains an unrelated nested template", async () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"card.html": `<!doctype html><html><body>
|
||||
<div data-composition-id="card" data-width="1920" data-height="1080" data-start="0" data-duration="5">
|
||||
<div class="clip" data-start="0" data-duration="5">Card</div>
|
||||
<template id="repeated-row"><div class="row">Row</div></template>
|
||||
</div>
|
||||
</body></html>`,
|
||||
});
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((result) => result.result.findings)
|
||||
.find((item) => item.code === "blank_root_with_standalone_composition");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing_or_empty_sub_composition", () => {
|
||||
function htmlWithSubComp(srcPath: string): string {
|
||||
return `<html><body>
|
||||
|
||||
@@ -230,6 +230,7 @@ export async function lintProject(
|
||||
...lintMissingLocalAsset(projectDir, allHtmlSources),
|
||||
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
|
||||
...(!entryFile ? lintMultipleRootCompositions(projectDir) : []),
|
||||
...(!entryFile ? lintBlankRootWithStandaloneComposition(rootHtml, allHtmlSources) : []),
|
||||
...lintDuplicateAudioTracks(allHtmlSources),
|
||||
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
|
||||
...(await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))),
|
||||
@@ -254,6 +255,46 @@ export async function lintProject(
|
||||
return { results, totalErrors, totalWarnings, totalInfos };
|
||||
}
|
||||
|
||||
function lintBlankRootWithStandaloneComposition(
|
||||
rootHtml: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const { document: rootDocument } = parseHTML(rootHtml);
|
||||
const root = rootDocument.querySelector("body [data-composition-id]");
|
||||
// A no-media scaffold has no rendered descendants and can silently mask an authored file below.
|
||||
// A scaffold that retained its A-roll <video>/<audio> is visibly non-blank, so this rule leaves it
|
||||
// alone even when another composition is unmounted.
|
||||
if (!root || root.querySelector("*:not(script):not(style):not(link):not(meta):not(template)")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const standaloneCandidates: string[] = [];
|
||||
for (const source of htmlSources) {
|
||||
if (!source.compSrcPath) continue;
|
||||
const { document } = parseHTML(source.html);
|
||||
const composition = document.querySelector("body [data-composition-id]");
|
||||
if (!composition) continue;
|
||||
const authoredTimedContent = Array.from(
|
||||
composition.querySelectorAll(
|
||||
".clip, [data-start], [data-end], video, audio, img, svg, canvas",
|
||||
),
|
||||
).some((element) => !element.hasAttribute("data-composition-src"));
|
||||
if (authoredTimedContent) standaloneCandidates.push(source.compSrcPath);
|
||||
}
|
||||
|
||||
if (standaloneCandidates.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
code: "blank_root_with_standalone_composition",
|
||||
severity: "error",
|
||||
message: `The default index.html composition has no renderable content, but ${standaloneCandidates.join(", ")} contains a standalone timed composition. Default check, snapshot, preview, and render commands open index.html, so they will capture only its background.`,
|
||||
fixHint:
|
||||
`Move the authored composition into index.html, or mount it from index.html with data-composition-src and the sub-composition <template> contract. ` +
|
||||
`If the separate file is intentional, render it explicitly with --composition ${standaloneCandidates[0]}.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function lintProjectAudioFiles(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
|
||||
Reference in New Issue
Block a user