mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions The #1 render failure bucket in production telemetry (PostHog project 356858, dashboard 1783183 "HyperFrames — Bottom-Line & Activation"; ~65-69K occurrences / ~27-28K affected users over 30 days, ~80% via AI-agent authoring flows) is a `data-composition-src` reference pointing at a scene file that is empty, malformed, or missing. Root cause, traced end-to-end: - The literal error "Composition HTML is empty or could not be parsed: <path>" is real (not a PostHog paraphrase) — thrown by a since-reverted guard in packages/core/src/compiler/inlineSubCompositions.ts (#1364), then changed to a silent skip in #1678 to avoid aborting renders on partial content during authoring. #1629 added per-assembler guards for 3 skill workflows (product-launch-video, faceless-explainer, pr-to-video), but general-video and hand-authored flows — where the dominant filename `scene-title.html` (40K+/68K of the bucket) originates — have no assembler and thus no guard. #1678 assumed the assembler guards from #1629 covered this pre-render; they only covered 3 of the many authoring flows. - On current `main`, an empty/malformed data-composition-src file no longer crashes or throws during render — it's silently dropped by the tolerant inliner. Reproduced locally: `hyperframes render` on a project with an empty scene-title.html "succeeds" after ~93s (two 45s pollSubCompositionTimelines timeouts) with the scene silently missing from the output video. `hyperframes validate` also reports "No console errors" for the same broken project. - The raw `Cannot destructure property 'firstElementChild' of 'documentElement' as it is null` crash reproduces directly against linkedom (the DOMParser polyfill packages/cli/src/utils/dom.ts installs in the real CLI runtime) for empty and non-HTML input — confirmed with a standalone repro script, not just inferred. jsdom/happy-dom (used in this repo's own test environment) are spec-compliant and never produce a null documentElement, which is why this needed a linkedom-specific test file. Fix: - New shared helper `checkSubCompositionUsability` (packages/core/src/compiler/subCompositionValidity.ts) is the single source of truth for "is this data-composition-src file usable" — mirrors the inliner's own parse/template/body logic so all callers agree. - `inlineSubCompositions.ts` (preview/studio bundling) now uses the shared helper internally but keeps its #1678 tolerant skip-and-continue behavior unchanged — mid-authoring iteration on a partial project must keep working. `onMissingComposition` now also receives a human-readable reason. - New render-only pre-flight (`assertSubCompositionsUsable` in packages/producer/src/services/htmlCompiler.ts) walks every data-composition-src reference (including nested ones, root-relative, matching parseSubCompositions' own resolution) before any compilation work starts, and throws naming every offending file at once. This is unconditional — not gated behind --strict — because a render that silently drops a scene is strictly worse than one that refuses to start. Confirmed locally: render now fails in ~0.4s with an actionable message instead of "succeeding" after 93s with a missing scene. - New `hyperframes lint` rule `missing_or_empty_sub_composition` (packages/cli/src/utils/lintProject.ts) surfaces the same check as a file-scoped, actionable lint error (already unconditional — lint exits 1 on any error). - `hyperframes validate` now also runs this check before launching a browser, so it no longer reports "No console errors" for a project with a broken sub-composition. - `packages/core/src/parsers/htmlParser.ts`: guarded every `documentElement`-may-be-null access (parseHtml, updateElementInHtml, addElementToHtml, removeElementFromHtml, extractCompositionMetadata, validateCompositionHtml) with a new typed `CompositionHtmlParseError` (or, for validateCompositionHtml's collect-and-report contract, a typed validation failure) instead of a raw crash. Tests: empty file, whitespace-only, malformed/non-HTML, missing file, nested sub-compositions (both happy path and broken-grandchild), and the happy path — at the shared-helper, lint, and render pre-flight layers. Not changed: the AI-agent authoring skills (skills/*). general-video and hand-authored flows have no assemble-index.mjs equivalent to guard, so the fix is at the CLI/render layer instead — flow-agnostic, covers every authoring path, and the skills' existing "run lint/validate and stop on failure" guidance now actually catches this class of mistake once run. Not run in this environment: the producer package's full regression-harness test suite (`bun test` in packages/producer) — it performs heavy real rendering (S3 asset downloads, Google Fonts fetches, full video encodes) and did not complete in a reasonable time in this sandbox. Verified instead via the targeted test file for all touched code (76/76 passing), whole-repo typecheck/build/oxlint, `fallow audit` (complexity/duplication/dead-code gate, clean), and manual end-to-end CLI runs (render/lint/validate) against reproduction projects, including a nested sub-composition scenario. CI should run the full producer suite before merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(parsers,lint): port empty-composition pre-flight to extracted packages Rebased onto main, which extracted @hyperframes/lint from core (lint depends only on parsers, not core). Relocate checkSubCompositionUsability from core to @hyperframes/parsers so both core (inliner) and lint can consume it without a core<->lint cycle; core keeps a @deprecated re-export shim. Correctness fixes from code review: - checkSubCompositionUsability now returns "no-composition-root" when the <template>/<body> content has no [data-composition-id] element (previously a marker-free placeholder body passed both guards). - lint's missing/empty sub-composition rule now only checks files reachable via data-composition-src from the root (matching render pre-flight), instead of a raw filesystem walk that false-positived on orphaned files. - drop `as string` cast in inlineSubCompositions in favor of an explicit null guard (per CLAUDE.md). Review-comment items: - move EmptyCompositionError JSDoc above the class (was above the adapter fn). - correct stale circular-ref comment to match actual silent-skip behavior. - rewrite self-contradicting lint message ("silently drop") to describe the new loud render-pre-flight abort. - add the __PLACEHOLDER__ (/^__[A-Z_]+__$/) skip to the render pre-flight so it agrees with lint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
613 lines
23 KiB
TypeScript
613 lines
23 KiB
TypeScript
export { shouldBlockRender } from "./shouldBlockRender.js";
|
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path";
|
|
import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
|
|
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
|
|
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
|
|
import { parseHTML } from "linkedom";
|
|
import { lintHyperframeHtml } from "./hyperframeLinter.js";
|
|
import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js";
|
|
import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity";
|
|
|
|
/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */
|
|
function parseSubCompHtml(html: string): ParsableDocumentLike {
|
|
return parseHTML(html).document as unknown as ParsableDocumentLike;
|
|
}
|
|
|
|
interface HtmlSource {
|
|
html: string;
|
|
compSrcPath?: string;
|
|
}
|
|
|
|
interface CssSource {
|
|
content: string;
|
|
rootRelativePath?: string;
|
|
}
|
|
|
|
export interface ProjectLintResult {
|
|
results: Array<{ file: string; result: HyperframeLintResult }>;
|
|
totalErrors: number;
|
|
totalWarnings: number;
|
|
totalInfos: number;
|
|
}
|
|
|
|
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
|
|
const STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
|
const OPEN_TAG_RE = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
|
|
const MASK_IMAGE_URL_RE =
|
|
/\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi;
|
|
|
|
function readHtmlAttr(tag: string, name: string): string | null {
|
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const match = tag.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
|
|
return match?.[1] ?? match?.[2] ?? null;
|
|
}
|
|
|
|
function isLocalStylesheetHref(href: string): boolean {
|
|
return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href);
|
|
}
|
|
|
|
function collectExternalStyles(
|
|
projectDir: string,
|
|
html: string,
|
|
compSrcPath?: string,
|
|
): Array<{ href: string; content: string }> {
|
|
const styles: Array<{ href: string; content: string }> = [];
|
|
const linkRe = /<link\b[^>]*>/gi;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = linkRe.exec(html)) !== null) {
|
|
const tag = match[0];
|
|
const rel = tag.match(/\brel\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
|
|
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
|
|
const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
|
|
if (!isLocalStylesheetHref(href)) continue;
|
|
const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
|
|
const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative);
|
|
if (!stylesheet) continue;
|
|
styles.push({ href, content: readFileSync(stylesheet.resolved, "utf-8") });
|
|
}
|
|
return styles;
|
|
}
|
|
|
|
function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] {
|
|
const sources: CssSource[] = [];
|
|
|
|
let styleMatch: RegExpExecArray | null;
|
|
const stylePattern = new RegExp(STYLE_BLOCK_RE.source, STYLE_BLOCK_RE.flags);
|
|
while ((styleMatch = stylePattern.exec(html)) !== null) {
|
|
sources.push({ content: styleMatch[1] ?? "" });
|
|
}
|
|
|
|
const linkRe = /<link\b[^>]*>/gi;
|
|
let linkMatch: RegExpExecArray | null;
|
|
while ((linkMatch = linkRe.exec(html)) !== null) {
|
|
const tag = linkMatch[0];
|
|
const rel = readHtmlAttr(tag, "rel") ?? "";
|
|
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
|
|
const href = readHtmlAttr(tag, "href") ?? "";
|
|
if (!isLocalStylesheetHref(href)) continue;
|
|
|
|
const rootRelativePath = compSrcPath ? join(dirname(compSrcPath), href) : href;
|
|
const stylesheet = resolveExistingLocalAsset(projectDir, rootRelativePath);
|
|
if (!stylesheet) continue;
|
|
sources.push({
|
|
content: readFileSync(stylesheet.resolved, "utf-8"),
|
|
rootRelativePath: stylesheet.rootRelativePath,
|
|
});
|
|
}
|
|
|
|
let tagMatch: RegExpExecArray | null;
|
|
const tagPattern = new RegExp(OPEN_TAG_RE.source, OPEN_TAG_RE.flags);
|
|
while ((tagMatch = tagPattern.exec(html)) !== null) {
|
|
const tag = tagMatch[0];
|
|
const style = readHtmlAttr(tag, "style");
|
|
if (!style) continue;
|
|
sources.push({ content: style });
|
|
}
|
|
|
|
return sources;
|
|
}
|
|
|
|
function isRemoteOrInlineUrl(url: string): boolean {
|
|
return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
|
|
}
|
|
|
|
function cleanAssetUrl(url: string): string {
|
|
return url.trim().split(/[?#]/, 1)[0] ?? "";
|
|
}
|
|
|
|
function isWithinProjectRoot(projectDir: string, candidate: string): boolean {
|
|
const projectRoot = resolve(projectDir);
|
|
const relativePath = relative(projectRoot, candidate);
|
|
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
}
|
|
|
|
function addCandidate(candidates: string[], candidate: string): void {
|
|
if (!candidates.includes(candidate)) candidates.push(candidate);
|
|
}
|
|
|
|
function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
|
|
const cleanUrl = cleanAssetUrl(url);
|
|
const projectRoot = resolve(projectDir);
|
|
const candidates: string[] = [];
|
|
|
|
for (const variant of decodeUrlPathVariants(cleanUrl)) {
|
|
const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
|
|
const resolved = resolve(projectRoot, projectRelative);
|
|
if (isWithinProjectRoot(projectRoot, resolved)) {
|
|
addCandidate(candidates, resolved);
|
|
continue;
|
|
}
|
|
|
|
const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
|
|
const clamped = normalized.replace(/^(\.\.\/)+/, "");
|
|
if (clamped && !clamped.startsWith("..")) {
|
|
addCandidate(candidates, resolve(projectRoot, clamped));
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function resolveExistingLocalAsset(
|
|
projectDir: string,
|
|
url: string,
|
|
): { resolved: string; rootRelativePath: string } | null {
|
|
const projectRoot = resolve(projectDir);
|
|
const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
|
|
if (!resolved) return null;
|
|
return { resolved, rootRelativePath: relative(projectRoot, resolved) };
|
|
}
|
|
|
|
function resolveCssAssetCandidates(
|
|
projectDir: string,
|
|
url: string,
|
|
htmlCompSrcPath?: string,
|
|
cssRootRelativePath?: string,
|
|
): string[] {
|
|
if (url.startsWith("/")) return resolveLocalAssetCandidates(projectDir, url);
|
|
if (cssRootRelativePath) {
|
|
return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url));
|
|
}
|
|
if (htmlCompSrcPath) {
|
|
return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
|
|
}
|
|
return resolveLocalAssetCandidates(projectDir, url);
|
|
}
|
|
|
|
export async function lintProject(projectDir: string): Promise<ProjectLintResult> {
|
|
const indexPath = resolve(projectDir, "index.html");
|
|
const results: Array<{ file: string; result: HyperframeLintResult }> = [];
|
|
let totalErrors = 0;
|
|
let totalWarnings = 0;
|
|
let totalInfos = 0;
|
|
|
|
const rootHtml = readFileSync(indexPath, "utf-8");
|
|
const rootResult = await lintHyperframeHtml(rootHtml, {
|
|
filePath: indexPath,
|
|
externalStyles: collectExternalStyles(projectDir, rootHtml),
|
|
});
|
|
results.push({ file: "index.html", result: rootResult });
|
|
totalErrors += rootResult.errorCount;
|
|
totalWarnings += rootResult.warningCount;
|
|
totalInfos += rootResult.infoCount;
|
|
|
|
const allHtmlSources: HtmlSource[] = [{ html: rootHtml }];
|
|
const compositionsDir = resolve(projectDir, "compositions");
|
|
if (existsSync(compositionsDir)) {
|
|
const collectHtmlFiles = (dir: string, rel: string): string[] => {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
|
|
else if (entry.isFile() && entry.name.endsWith(".html")) out.push(relPath);
|
|
}
|
|
return out;
|
|
};
|
|
const files = collectHtmlFiles(compositionsDir, "").sort();
|
|
for (const file of files) {
|
|
const filePath = join(compositionsDir, file);
|
|
const html = readFileSync(filePath, "utf-8");
|
|
const compSrcPath = `compositions/${file}`;
|
|
allHtmlSources.push({ html, compSrcPath });
|
|
const result = await lintHyperframeHtml(html, {
|
|
filePath,
|
|
isSubComposition: true,
|
|
externalStyles: collectExternalStyles(projectDir, html, compSrcPath),
|
|
});
|
|
results.push({ file: `compositions/${file}`, result });
|
|
totalErrors += result.errorCount;
|
|
totalWarnings += result.warningCount;
|
|
totalInfos += result.infoCount;
|
|
}
|
|
}
|
|
|
|
const projectFindings = [
|
|
...lintProjectAudioFiles(projectDir, allHtmlSources),
|
|
...lintAudioSrcNotFound(projectDir, allHtmlSources),
|
|
...lintMissingLocalAsset(projectDir, allHtmlSources),
|
|
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
|
|
...lintMultipleRootCompositions(projectDir),
|
|
...lintDuplicateAudioTracks(allHtmlSources),
|
|
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
|
|
];
|
|
if (projectFindings.length > 0) {
|
|
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 };
|
|
}
|
|
|
|
function lintProjectAudioFiles(
|
|
projectDir: string,
|
|
htmlSources: HtmlSource[],
|
|
): HyperframeLintFinding[] {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
|
|
let audioFiles: string[];
|
|
try {
|
|
audioFiles = readdirSync(projectDir).filter((f) =>
|
|
AUDIO_EXTENSIONS.has(extname(f).toLowerCase()),
|
|
);
|
|
} catch {
|
|
return findings;
|
|
}
|
|
|
|
if (audioFiles.length === 0) return findings;
|
|
|
|
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-duration="__DURATION__" data-track-index="0" data-volume="1"></audio> element inside the composition root. Replace __DURATION__ with the audio length in seconds.',
|
|
});
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
function lintAudioSrcNotFound(
|
|
projectDir: string,
|
|
htmlSources: HtmlSource[],
|
|
): HyperframeLintFinding[] {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
|
|
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
|
|
const missingSrcs: string[] = [];
|
|
for (const { html, compSrcPath } of htmlSources) {
|
|
let match: RegExpExecArray | null;
|
|
while ((match = audioSrcRe.exec(html)) !== null) {
|
|
const src = match[1]!;
|
|
if (/^(https?:|data:|blob:)/i.test(src)) continue;
|
|
if (/^__[A-Z_]+__$/.test(src)) continue;
|
|
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
|
if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
|
|
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;
|
|
}
|
|
|
|
function maskRange(src: string, pattern: RegExp): string {
|
|
return src.replace(pattern, (m) => " ".repeat(m.length));
|
|
}
|
|
|
|
function maskNonScannableRanges(html: string): string {
|
|
let out = maskRange(html, /<!--[\s\S]*?-->/g);
|
|
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
|
|
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
|
|
return out;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function lintMissingLocalAsset(
|
|
projectDir: string,
|
|
htmlSources: HtmlSource[],
|
|
): HyperframeLintFinding[] {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
|
|
const localAssetSrcRe = /<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
|
|
const missingByTag = new Map<string, Map<string, string>>();
|
|
|
|
for (const { html, compSrcPath } of htmlSources) {
|
|
const scannable = maskNonScannableRanges(html);
|
|
const re = new RegExp(localAssetSrcRe.source, localAssetSrcRe.flags);
|
|
let match: RegExpExecArray | null;
|
|
while ((match = re.exec(scannable)) !== null) {
|
|
const tagName = (match[1] ?? "").toLowerCase();
|
|
const rawSrc = match[2] ?? "";
|
|
const src = cleanAssetUrl(rawSrc);
|
|
if (!src) continue;
|
|
if (isRemoteOrInlineUrl(src)) continue;
|
|
if (/^__[A-Z_]+__$/.test(src)) continue;
|
|
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
|
const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
|
|
if (resolvedAsset) continue;
|
|
|
|
const resolvedKey = resolve(projectDir, rootRelative);
|
|
let bucket = missingByTag.get(tagName);
|
|
if (!bucket) {
|
|
bucket = new Map<string, string>();
|
|
missingByTag.set(tagName, bucket);
|
|
}
|
|
if (!bucket.has(resolvedKey)) bucket.set(resolvedKey, src);
|
|
}
|
|
}
|
|
|
|
for (const [tagName, byResolved] of missingByTag) {
|
|
const unique = [...byResolved.values()];
|
|
findings.push({
|
|
code: "missing_local_asset",
|
|
severity: "error",
|
|
message:
|
|
`<${tagName}> element references local file(s) not found in the project: ${unique.join(", ")}. ` +
|
|
"The renderer will silently skip these and produce a video with missing visuals.",
|
|
fixHint:
|
|
unique.length === 1
|
|
? `Add "${unique[0]}" to the project directory, or update the src attribute to point to an existing file. ` +
|
|
"Common cause: captured asset filenames are unreliable (heygen-logo.svg often contains Google, nvidia-logo.svg may contain Autodesk, etc.). " +
|
|
"Open the contact sheets and verify the file actually exists at this path before referencing it."
|
|
: "Add the missing files to the project directory, or update the src attributes to point to existing files. " +
|
|
"Captured asset filenames are unreliable — verify against capture/contact-sheets/ and capture/extracted/asset-descriptions.md.",
|
|
});
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
function lintTextureMaskAssetNotFound(
|
|
projectDir: string,
|
|
htmlSources: HtmlSource[],
|
|
): HyperframeLintFinding[] {
|
|
const missing = new Map<string, string>();
|
|
|
|
for (const { html, compSrcPath } of htmlSources) {
|
|
for (const cssSource of collectCssSources(projectDir, html, compSrcPath)) {
|
|
let match: RegExpExecArray | null;
|
|
const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags);
|
|
while ((match = pattern.exec(cssSource.content)) !== null) {
|
|
const rawUrl = match[1] ?? match[2] ?? match[3] ?? "";
|
|
const url = cleanAssetUrl(rawUrl);
|
|
if (!url || isRemoteOrInlineUrl(url)) continue;
|
|
if (/^__[A-Z_]+__$/.test(url)) continue;
|
|
|
|
const candidates = resolveCssAssetCandidates(
|
|
projectDir,
|
|
url,
|
|
compSrcPath,
|
|
cssSource.rootRelativePath,
|
|
);
|
|
if (candidates.some(existsSync)) continue;
|
|
missing.set(url, candidates[0] ?? resolve(projectDir, url));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (missing.size === 0) return [];
|
|
const urls = [...missing.keys()];
|
|
return [
|
|
{
|
|
code: "texture_mask_asset_not_found",
|
|
severity: "error",
|
|
message: `CSS mask-image references file(s) not found in the project: ${urls.join(", ")}.`,
|
|
fixHint:
|
|
urls.length === 1
|
|
? `Add "${urls[0]}" to the project, or update the mask-image URL to point to an existing texture mask.`
|
|
: "Add the missing texture mask files to the project, or update the mask-image URLs to point to existing files.",
|
|
},
|
|
];
|
|
}
|
|
|
|
function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding[] {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
try {
|
|
const rootHtmlFiles = readdirSync(projectDir).filter((f) => f.endsWith(".html"));
|
|
const rootCompositions: string[] = [];
|
|
for (const file of rootHtmlFiles) {
|
|
if (file === "caption-skin.html") continue;
|
|
const content = readFileSync(join(projectDir, file), "utf-8");
|
|
if (/data-composition-id/i.test(content)) {
|
|
rootCompositions.push(file);
|
|
}
|
|
}
|
|
if (rootCompositions.length > 1) {
|
|
findings.push({
|
|
code: "multiple_root_compositions",
|
|
severity: "error",
|
|
message: `Multiple root-level HTML files with data-composition-id: ${rootCompositions.join(", ")}. The runtime may discover both as entry points, causing duplicate audio playback.`,
|
|
fixHint:
|
|
"A project should have exactly one root index.html with data-composition-id. Remove or rename extra files.",
|
|
});
|
|
}
|
|
} catch {
|
|
/* directory read failed — skip */
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFinding[] {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
function extractAttr(tag: string, name: string): string | null {
|
|
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
|
|
const m = tag.match(re);
|
|
return m?.[1] ?? null;
|
|
}
|
|
|
|
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const { html } of htmlSources) {
|
|
const audioTagRe = /<audio\b[^>]*>/gi;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = audioTagRe.exec(html)) !== null) {
|
|
const tag = match[0];
|
|
const trackStr = extractAttr(tag, "data-track-index");
|
|
const startStr = extractAttr(tag, "data-start");
|
|
const durStr = extractAttr(tag, "data-duration");
|
|
const src = extractAttr(tag, "src") ?? "unknown";
|
|
if (!trackStr || !startStr) continue;
|
|
|
|
const trackIndex = parseInt(trackStr, 10);
|
|
const start = parseFloat(startStr);
|
|
const duration = durStr ? parseFloat(durStr) : Infinity;
|
|
const key = `${src}:${start}:${duration}:${trackIndex}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
|
|
tracks.push({ trackIndex, start, end: start + duration, src });
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < tracks.length; i++) {
|
|
for (let j = i + 1; j < tracks.length; j++) {
|
|
const a = tracks[i]!;
|
|
const b = tracks[j]!;
|
|
if (a.trackIndex !== b.trackIndex) continue;
|
|
if (a.start < b.end && b.start < a.end) {
|
|
findings.push({
|
|
code: "duplicate_audio_track",
|
|
severity: "warning",
|
|
message: `Multiple <audio> elements on track ${a.trackIndex} overlap (${a.src} at ${a.start}-${Number.isFinite(a.end) ? a.end.toFixed(1) : "end"}s, ${b.src} at ${b.start}-${Number.isFinite(b.end) ? b.end.toFixed(1) : "end"}s). This causes layered audio playback.`,
|
|
fixHint: "Use non-overlapping time windows or different track indices.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
/**
|
|
* Error if a `data-composition-src` reference points at a file that is
|
|
* missing, empty, or does not parse to usable HTML. This is the #1 render
|
|
* failure bucket in production telemetry: a scene-authoring step (an AI
|
|
* agent, most commonly) writes the reference before — or without ever —
|
|
* writing valid content into the scene file.
|
|
*
|
|
* The render pre-flight check (`assertSubCompositionsUsable` in
|
|
* `packages/producer/src/services/htmlCompiler.ts`) now aborts the render
|
|
* loudly and immediately when this happens, rather than silently dropping
|
|
* the scene — so catching it here, before the render even starts, means the
|
|
* failure surfaces at lint/validate time with the same message instead of
|
|
* only at render time.
|
|
*
|
|
* Only follows files actually reachable via `data-composition-src` starting
|
|
* from the root composition — mirroring the reachability semantics of
|
|
* `assertSubCompositionsUsable`. A raw filesystem walk of every `.html`
|
|
* under `compositions/` would flag orphaned/unreferenced files that the
|
|
* renderer never visits, producing false-positive lint/validate failures on
|
|
* projects that actually render fine. Lint, render, and the inliner must
|
|
* never disagree about whether a given file would actually render
|
|
* something.
|
|
*/
|
|
function lintMissingOrEmptySubComposition(
|
|
projectDir: string,
|
|
rootHtml: string,
|
|
): HyperframeLintFinding[] {
|
|
// Dedup by src path — the same reference can appear from nested sub-comps.
|
|
const checked = new Map<string, { srcPath: string; problem: string }>();
|
|
const visited = new Set<string>();
|
|
|
|
// fallow-ignore-next-line complexity
|
|
const walk = (html: string): void => {
|
|
const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
const scannable = maskNonScannableRanges(html);
|
|
let match: RegExpExecArray | null;
|
|
while ((match = compositionSrcRe.exec(scannable)) !== null) {
|
|
const srcPath = (match[1] ?? "").trim();
|
|
if (!srcPath) continue;
|
|
if (/^__[A-Z_]+__$/.test(srcPath)) continue; // template placeholder
|
|
|
|
// data-composition-src is always written root-relative (even from a
|
|
// nested sub-composition) — matches the resolution the renderer uses
|
|
// in packages/producer/src/services/htmlCompiler.ts (parseSubCompositions
|
|
// / assertSubCompositionsUsable).
|
|
const filePath = resolve(projectDir, srcPath);
|
|
|
|
// Circular reference guard — same as assertSubCompositionsUsable.
|
|
// Already-visited files were already checked (or are mid-walk); skip
|
|
// re-checking/re-recursing but still let a later distinct reference to
|
|
// the same broken file surface (checked is keyed by srcPath, not filePath).
|
|
if (visited.has(filePath)) continue;
|
|
visited.add(filePath);
|
|
|
|
if (!existsSync(filePath)) {
|
|
if (!checked.has(srcPath)) {
|
|
checked.set(srcPath, { srcPath, problem: "the file does not exist" });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const fileHtml = readFileSync(filePath, "utf-8");
|
|
const validity = checkSubCompositionUsability(fileHtml, parseSubCompHtml);
|
|
if (!validity.ok) {
|
|
if (!checked.has(srcPath)) {
|
|
checked.set(srcPath, {
|
|
srcPath,
|
|
problem: validity.detail ?? "the file is empty or could not be parsed",
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Usable — recurse into it so nested references are validated too,
|
|
// but only because this file is itself reachable from the root.
|
|
walk(fileHtml);
|
|
}
|
|
};
|
|
|
|
walk(rootHtml);
|
|
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const { srcPath, problem } of checked.values()) {
|
|
findings.push({
|
|
code: "missing_or_empty_sub_composition",
|
|
severity: "error",
|
|
message: `data-composition-src references "${srcPath}", but ${problem}.`,
|
|
fixHint:
|
|
`Fix this before rendering — the render pre-flight rejects unusable sub-compositions. ` +
|
|
`Write valid HTML into "${srcPath}" — it needs a <template> or <body> containing an element with ` +
|
|
`data-composition-id, data-width, and data-height. Preview/studio still tolerates and skips the ` +
|
|
"scene while you author it. If a scene-authoring step is still running, wait for it to finish " +
|
|
"before referencing the file, or re-run the step that generates it.",
|
|
});
|
|
}
|
|
|
|
return findings;
|
|
}
|