mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
Field signal ts=1784040753 (#hyperframes-cli-feedback): a composition with ~40 heavy overlay DOM elements — `filter:blur`, oversized `radial-gradient`, and `clip-path` animations — captures solid-black for the first ~half of the render, recovering near the end. Reproduces identically via drawElement AND forced --no-browser-gpu screenshot capture AND `snapshot`, so the capture layer itself is the offender, not encoder/mux. Independent of duration (padding the timeline grows the bad zone proportionally, doesn't shift it). Presence alone matters — even opacity:0 / visibility:hidden / unused overlays contribute. Reporter's workaround was splitting into per-transition mini-compositions + FFmpeg concat. Add compositionCheck rule `composition_heavy_overlay_count_high` (warning). Counts DOM elements that carry any of: inline `style` filter:blur / clip-path (non-none) / radial-gradient, or a class/id whose top-level CSS rule body sets one of those. `display:none` elements are counted-out (removed from render tree); opacity:0 / visibility:hidden overlays are counted-in per the field-signal repro shape. Warns at 25 to give lead time before the observed 40-element bad zone. Skips registry source and installed-block files, mirroring `composition_file_too_large`. Includes a `ts=1784040753` reference in fixHint so authors can trace the risk shape. Stack: PR #5 of 9 (base via/parity-telemetry-gate). Signed-off-by: Via Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1246 lines
58 KiB
TypeScript
1246 lines
58 KiB
TypeScript
import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from "../context";
|
|
import {
|
|
findHtmlTag,
|
|
readAttr,
|
|
readDecodedAttr,
|
|
readJsonAttr,
|
|
stripJsComments,
|
|
truncateSnippet,
|
|
WINDOW_TIMELINE_ASSIGN_PATTERN,
|
|
} from "../utils";
|
|
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
|
|
|
|
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
|
|
// to inspect and revise reliably in a single composition.
|
|
const MAX_COMPOSITION_LINES = 300;
|
|
const MAX_TIMED_ELEMENTS_PER_TRACK = 3;
|
|
const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"]);
|
|
const CAPTION_CUE_TOKEN =
|
|
/^(?:caption(?:[-_](?:group|word|line|block|cue|text))?|subtitle(?:[-_](?:group|line|cue|text))?|cg-.+)$/i;
|
|
|
|
// composition_heavy_overlay_count_high — warn when a composition carries this
|
|
// many or more elements whose CSS uses filter:blur, clip-path (non-none), or
|
|
// radial-gradient. Field signal ts=1784040753 (#hyperframes-cli-feedback):
|
|
// a composition with ~40 such elements captures solid-black for the first
|
|
// ~half of the render, recovering near the end. Presence alone matters —
|
|
// opacity:0 and visibility:hidden overlays still contribute — so the rule
|
|
// counts every one that isn't display:none-hidden. Threshold sits below the
|
|
// observed 40-element repro (25) so authors get lead time; adjust here if
|
|
// noise/signal shifts, since a per-rule config option would also require
|
|
// plumbing through HyperframeLinterOptions across every embedder.
|
|
const HEAVY_OVERLAY_ELEMENT_COUNT_WARN = 25;
|
|
const HEAVY_OVERLAY_EXEMPT_TAGS = new Set([
|
|
"audio",
|
|
"body",
|
|
"br",
|
|
"defs",
|
|
"head",
|
|
"hr",
|
|
"html",
|
|
"link",
|
|
"meta",
|
|
"script",
|
|
"source",
|
|
"style",
|
|
"template",
|
|
"title",
|
|
"use",
|
|
"video",
|
|
]);
|
|
// Matches any of: `filter: <...>blur(...)`, `clip-path: <non-none-value>`,
|
|
// or `radial-gradient(...)`. Property terminator is `;` or `}`; value class
|
|
// excludes both so we don't over-match into the next declaration. `clip-path`
|
|
// escapes when its value starts with a CSS-wide keyword that leaves the render
|
|
// tree unaffected (none / inherit / initial / unset) — the whitespace-eating
|
|
// `\s*` lives *inside* the negative lookahead so the engine can't backtrack
|
|
// `\s*` from outside to 0-width and slip past the keyword guard.
|
|
const HEAVY_OVERLAY_CSS_PATTERN =
|
|
/(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
|
|
const INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
|
|
|
|
// `parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004`. Sub-second
|
|
// authored adjacencies survive parse + add as a value a few ulps above the
|
|
// next clip's start; a strict `>` fires the overlap rule on adjacencies that
|
|
// are exact in the source HTML. 1μs sits ~11 orders of magnitude above the
|
|
// observed drift (worst ~2e-16s across every realistic decimal pair) and 4
|
|
// below one 60fps frame (~16.67ms), so this only ever swallows float slop.
|
|
const OVERLAP_EPSILON_SECONDS = 1e-6;
|
|
|
|
function countPhysicalLines(source: string): number {
|
|
if (source.length === 0) return 0;
|
|
|
|
const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
|
|
return withoutFinalNewline.split("\n").length;
|
|
}
|
|
|
|
function countStructuralLines(source: string): number {
|
|
return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
|
|
}
|
|
|
|
function isCaptionCue(tag: OpenTag): boolean {
|
|
const classTokens = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
|
const id = readAttr(tag.raw, "id");
|
|
return (
|
|
classTokens.some((token) => CAPTION_CUE_TOKEN.test(token)) ||
|
|
Boolean(id && CAPTION_CUE_TOKEN.test(id))
|
|
);
|
|
}
|
|
|
|
export function isRegistrySourceFile(filePath?: string): boolean {
|
|
if (!filePath) return false;
|
|
|
|
const normalized = filePath.replace(/\\/g, "/");
|
|
return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized);
|
|
}
|
|
|
|
export function isRegistryInstalledFile(rawSource: string): boolean {
|
|
return /^\s*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(rawSource.slice(0, 512));
|
|
}
|
|
|
|
function isCompositionRootOrMount(rawTag: string): boolean {
|
|
return Boolean(
|
|
readDecodedAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"),
|
|
);
|
|
}
|
|
|
|
// Asset references inside CSS `url(...)`/`url("...")`/`url('...')` functions.
|
|
// Returns the inner path without quotes; comments are stripped first so
|
|
// `/* url(foo) */` is ignored. Bare `url()` and `data:` are excluded by the
|
|
// rules that consume this — the helper just yields raw URL values.
|
|
function extractCssUrlReferences(css: string): string[] {
|
|
const out: string[] = [];
|
|
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
const urlPattern = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = urlPattern.exec(noComments)) !== null) {
|
|
const raw = (m[2] ?? "").trim();
|
|
if (raw) out.push(raw);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Top-level CSS selectors (comma-split) in a stylesheet, skipping at-rule headers
|
|
// (@media/@keyframes/...) and keyframe stops. Heuristic — the lint layer has no
|
|
// full CSS parser, and rules elsewhere in this file scan CSS the same way.
|
|
function extractCssSelectors(css: string): string[] {
|
|
const out: string[] = [];
|
|
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
const ruleHeader = /([^{}]+)\{/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = ruleHeader.exec(noComments)) !== null) {
|
|
const header = (m[1] ?? "").trim();
|
|
if (!header || header.startsWith("@")) continue;
|
|
for (const sel of header.split(",")) {
|
|
const s = sel.trim();
|
|
if (s) out.push(s);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Class tokens in a selector's leftmost compound (before the first descendant /
|
|
// child / sibling combinator). `.frame .title` → ["frame"]; `.a.b > .c` → ["a","b"].
|
|
function leftmostCompoundClasses(selector: string): string[] {
|
|
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
|
|
return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
|
|
}
|
|
|
|
// Id token in a selector's leftmost compound. `#hero .title` → "hero";
|
|
// `.a#b > .c` → "b"; `.a .b` → null. Companion to leftmostCompoundClasses;
|
|
// splits on the same combinator set so the two agree on where "leftmost" ends.
|
|
function leftmostCompoundId(selector: string): string | null {
|
|
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
|
|
return leftmost.match(/#([\w-]+)/)?.[1] ?? null;
|
|
}
|
|
|
|
// Class tokens + ids whose rule body sets a "heavy overlay" property
|
|
// (filter:blur, clip-path non-none, or radial-gradient). Only top-level rules
|
|
// are scanned — the flat `[^{}]*` body class naturally skips @keyframes
|
|
// bodies (which contain nested `{...}` stops) and other @-rules, so keyframe
|
|
// selectors like `0%`/`100%` don't leak in.
|
|
function collectHeavyOverlayHooks(styles: ExtractedBlock[]): {
|
|
classes: Set<string>;
|
|
ids: Set<string>;
|
|
} {
|
|
const classes = new Set<string>();
|
|
const ids = new Set<string>();
|
|
for (const style of styles) {
|
|
const noComments = style.content.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
const ruleWithBody = /([^{}]+)\{([^{}]*)\}/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = ruleWithBody.exec(noComments)) !== null) {
|
|
const header = (m[1] ?? "").trim();
|
|
const body = m[2] ?? "";
|
|
if (!header || header.startsWith("@")) continue;
|
|
if (!HEAVY_OVERLAY_CSS_PATTERN.test(body)) continue;
|
|
for (const sel of header.split(",")) {
|
|
const trimmed = sel.trim();
|
|
if (!trimmed) continue;
|
|
for (const cls of leftmostCompoundClasses(trimmed)) classes.add(cls);
|
|
const idToken = leftmostCompoundId(trimmed);
|
|
if (idToken) ids.add(idToken);
|
|
}
|
|
}
|
|
}
|
|
return { classes, ids };
|
|
}
|
|
|
|
// Distinct selectors across all <style> blocks whose leftmost compound keys off one
|
|
// of the root element's own classes — the ones that break under id-scoping.
|
|
function rootClassStyledSelectors(styles: ExtractedBlock[], rootClasses: string[]): string[] {
|
|
const offenders: string[] = [];
|
|
for (const style of styles) {
|
|
for (const selector of extractCssSelectors(style.content)) {
|
|
const hitsRoot = leftmostCompoundClasses(selector).some((c) => rootClasses.includes(c));
|
|
if (hitsRoot && !offenders.includes(selector)) offenders.push(selector);
|
|
}
|
|
}
|
|
return offenders;
|
|
}
|
|
|
|
/** Declared variable ids from an <html> tag's raw text; null when the JSON is unparseable. */
|
|
function collectDeclaredVariableIds(htmlTagRaw: string): Set<string> | null {
|
|
const declared = new Set<string>();
|
|
const raw = readJsonAttr(htmlTagRaw, "data-composition-variables");
|
|
if (!raw) return declared;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!Array.isArray(parsed)) return declared;
|
|
for (const entry of parsed) {
|
|
const id = (entry as { id?: unknown } | null)?.id;
|
|
if (typeof id === "string") declared.add(id);
|
|
}
|
|
return declared;
|
|
}
|
|
|
|
/**
|
|
* Union declared variable ids from every element carrying
|
|
* `data-composition-variables`: full-document comps hold it on `<html>`;
|
|
* template/fragment sub-comps hold it on their composition root div. Returns
|
|
* null if any occurrence has unparseable JSON.
|
|
*/
|
|
function collectAllDeclaredVariableIds(tags: readonly OpenTag[]): Set<string> | null {
|
|
const all = new Set<string>();
|
|
for (const tag of tags) {
|
|
if (!readAttr(tag.raw, "data-composition-variables")) continue;
|
|
const ids = collectDeclaredVariableIds(tag.raw);
|
|
if (ids === null) return null;
|
|
for (const id of ids) all.add(id);
|
|
}
|
|
return all;
|
|
}
|
|
|
|
/**
|
|
* Declared ids to validate `data-var-*` bindings against, or null to skip the
|
|
* file: unparseable declarations (reported elsewhere), or a fragment with no
|
|
* `<html>` and no declarations of its own (its values come from a host's
|
|
* data-variable-values, which this file can't see).
|
|
*/
|
|
function declaredIdsForBindingCheck(tags: readonly OpenTag[]): Set<string> | null {
|
|
const declared = collectAllDeclaredVariableIds(tags);
|
|
if (declared === null) return null;
|
|
if (declared.size === 0 && !findHtmlTag(tags)) return null;
|
|
return declared;
|
|
}
|
|
|
|
function isInsideInertTemplate(tag: OpenTag, tags: readonly OpenTag[]): boolean {
|
|
return tags.some(
|
|
(candidate) =>
|
|
candidate.name === "template" &&
|
|
candidate.closeIndex != null &&
|
|
tag.index > candidate.index &&
|
|
tag.index < candidate.closeIndex,
|
|
);
|
|
}
|
|
|
|
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
|
// duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries.
|
|
({ tags }) => {
|
|
const tagsByCompositionId = new Map<string, string[]>();
|
|
for (const tag of tags) {
|
|
if (isInsideInertTemplate(tag, tags)) continue;
|
|
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
|
|
if (!compositionId || compositionId.trim().length === 0) continue;
|
|
|
|
const matchingTags = tagsByCompositionId.get(compositionId) ?? [];
|
|
matchingTags.push(tag.raw);
|
|
tagsByCompositionId.set(compositionId, matchingTags);
|
|
}
|
|
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const [compositionId, matchingTags] of tagsByCompositionId) {
|
|
if (matchingTags.length < 2) continue;
|
|
|
|
findings.push({
|
|
code: "duplicate_composition_id",
|
|
severity: "error",
|
|
message: `Composition id "${compositionId}" is used by ${matchingTags.length} elements. Each data-composition-id value must be unique within a composition file.`,
|
|
fixHint:
|
|
"Keep data-composition-id on exactly one element, the composition root. Remove it from metadata or duplicate hosts, especially a <meta> tag carrying the same data-composition-id as the root <div>, which causes a silent duplicate-id collision.",
|
|
snippet: truncateSnippet(matchingTags[0] ?? ""),
|
|
});
|
|
}
|
|
|
|
return findings;
|
|
},
|
|
|
|
// invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
|
|
// href, inline-style url(), and <style> url() asset references on
|
|
// compositions. Sub-compositions live under compositions/ but are served
|
|
// with the project root as their base URL, so any `../`-traversing path
|
|
// climbs above the project root and 404s in Studio preview. Renders
|
|
// tolerate it because the server-side bundler rewrites `../foo` against
|
|
// each sub-composition's source path; the runtime now mirrors that fallback
|
|
// (see rewriteSubCompositionAssetPaths in runtime/compositionLoader.ts), but
|
|
// the authoring-time signal is still wrong — flag it at lint time so the
|
|
// baked path is plain root-relative and matches what the bundler emits.
|
|
//
|
|
// Mirrors the runtime fallback's surface: `[src]` / `[href]` attribute
|
|
// values, `[style]` inline url(), and `<style>` block url() references.
|
|
// Skips absolute URLs (http(s)://, //, data:, /-prefixed root-relative),
|
|
// hash anchors, and plain relative paths (`assets/x.mp4`) — only `../`
|
|
// traversal is flagged. Subsumes the older `../capture/`-specific rule.
|
|
// fallow-ignore-next-line complexity
|
|
({ tags, styles, rawSource, options }) => {
|
|
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
|
|
const offenders: string[] = [];
|
|
const collect = (value: string | null) => {
|
|
if (!value) return;
|
|
const trimmed = value.trim();
|
|
if (!trimmed.startsWith("../") && trimmed !== "..") return;
|
|
offenders.push(trimmed);
|
|
};
|
|
|
|
for (const tag of tags) {
|
|
collect(readAttr(tag.raw, "src"));
|
|
collect(readAttr(tag.raw, "href"));
|
|
// Use readJsonAttr for `style` — inline url('...') values contain the
|
|
// opposite quote, which readAttr's [^"']+ class would truncate.
|
|
const styleAttr = readJsonAttr(tag.raw, "style");
|
|
if (styleAttr) {
|
|
for (const url of extractCssUrlReferences(styleAttr)) collect(url);
|
|
}
|
|
}
|
|
for (const style of styles) {
|
|
for (const url of extractCssUrlReferences(style.content)) collect(url);
|
|
}
|
|
|
|
if (offenders.length === 0) return [];
|
|
|
|
// Group counts by leading path token (e.g. ../capture/, ../assets/, ../../assets/)
|
|
// so the message names the offending prefixes instead of a bare count.
|
|
const prefixCounts = new Map<string, number>();
|
|
for (const path of offenders) {
|
|
const prefix = path.match(/^(?:\.\.\/)+[^/]+\//)?.[0] ?? path;
|
|
prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1);
|
|
}
|
|
const prefixSummary = Array.from(prefixCounts.entries())
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([prefix, count]) => (count > 1 ? `${prefix} (${count})` : prefix))
|
|
.join(", ");
|
|
|
|
return [
|
|
{
|
|
code: "invalid_parent_traversal_in_asset_path",
|
|
severity: "error",
|
|
message:
|
|
`Found ${offenders.length} asset path(s) traversing above the project root with "../" ` +
|
|
`(${prefixSummary}). Renders rewrite this against each sub-composition's source path, but Studio preview and other live consumers resolve against the project root and 404.`,
|
|
fixHint:
|
|
'Use plain root-relative paths (e.g. "assets/...", "capture/...", "fonts/...") — compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.',
|
|
},
|
|
];
|
|
},
|
|
|
|
// composition_file_too_large
|
|
({ rawSource, options }) => {
|
|
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
|
|
const lineCount = countStructuralLines(rawSource);
|
|
if (lineCount <= MAX_COMPOSITION_LINES) return [];
|
|
|
|
const splitTarget = options.isSubComposition
|
|
? "Split this sub-composition further into smaller .html files"
|
|
: "Split coherent scenes or layers into separate .html files under compositions/";
|
|
|
|
return [
|
|
{
|
|
code: "composition_file_too_large",
|
|
severity: "warning",
|
|
message: `This HTML composition file has ${lineCount} lines. Smaller sub-compositions are easier to read, iterate on, and diff.`,
|
|
fixHint: `${splitTarget}, then mount them from the parent with data-composition-src so each file stays small enough to inspect, revise, and validate independently.`,
|
|
},
|
|
];
|
|
},
|
|
|
|
// timeline_track_too_dense
|
|
// fallow-ignore-next-line complexity
|
|
({ tags, options }) => {
|
|
const trackCounts = new Map<string, number>();
|
|
for (const tag of tags) {
|
|
if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
|
|
if (isCaptionCue(tag)) continue;
|
|
if (isCompositionRootOrMount(tag.raw)) continue;
|
|
if (!readAttr(tag.raw, "data-start")) continue;
|
|
|
|
const track = readAttr(tag.raw, "data-track-index");
|
|
if (!track) continue;
|
|
trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
|
|
}
|
|
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const [track, count] of trackCounts) {
|
|
if (count <= MAX_TIMED_ELEMENTS_PER_TRACK) continue;
|
|
const splitTarget = options.isSubComposition
|
|
? "Move coherent scene groups into smaller .html files"
|
|
: "Move coherent scene groups into separate .html files under compositions/";
|
|
findings.push({
|
|
code: "timeline_track_too_dense",
|
|
severity: "warning",
|
|
message: `Track ${track} has ${count} timed elements in this HTML file. Smaller sub-compositions keep timelines easier to read, iterate on, and diff.`,
|
|
fixHint: `${splitTarget} and mount them from the parent with data-composition-src so the timeline stays easier to inspect, revise, and validate.`,
|
|
});
|
|
}
|
|
|
|
return findings;
|
|
},
|
|
|
|
// timed_element_missing_visibility_hidden
|
|
// fallow-ignore-next-line complexity
|
|
({ tags }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const tag of tags) {
|
|
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
|
|
if (!readAttr(tag.raw, "data-start")) continue;
|
|
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
if (readAttr(tag.raw, "data-composition-src")) continue;
|
|
const classAttr = readAttr(tag.raw, "class") || "";
|
|
const styleAttr = readAttr(tag.raw, "style") || "";
|
|
const hasClip = classAttr.split(/\s+/).includes("clip");
|
|
const hasHiddenStyle =
|
|
/visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
|
|
if (!hasClip && !hasHiddenStyle) {
|
|
const elementId = readAttr(tag.raw, "id") || undefined;
|
|
findings.push({
|
|
code: "timed_element_missing_visibility_hidden",
|
|
severity: "info",
|
|
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip", visibility:hidden, or opacity:0. Consider adding initial hidden state if the element should not be visible before its start time.`,
|
|
elementId,
|
|
fixHint:
|
|
'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
|
|
snippet: truncateSnippet(tag.raw),
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// deprecated_data_layer + deprecated_data_end
|
|
// fallow-ignore-next-line complexity
|
|
({ tags }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const tag of tags) {
|
|
if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
|
|
const elementId = readAttr(tag.raw, "id") || undefined;
|
|
findings.push({
|
|
code: "deprecated_data_layer",
|
|
severity: "error",
|
|
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
|
|
elementId,
|
|
fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
|
|
snippet: truncateSnippet(tag.raw),
|
|
});
|
|
}
|
|
if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
|
|
const elementId = readAttr(tag.raw, "id") || undefined;
|
|
findings.push({
|
|
code: "deprecated_data_end",
|
|
severity: "error",
|
|
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-end without data-duration. Use data-duration in source HTML.`,
|
|
elementId,
|
|
fixHint:
|
|
"Replace data-end with data-duration. The compiler generates data-end from data-duration automatically.",
|
|
snippet: truncateSnippet(tag.raw),
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// split_data_attribute_selector
|
|
({ scripts, styles }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
const splitDataAttrSelectorPattern =
|
|
/\[data-composition-id=(["'])([^"'\]]+)\1\s+(data-[\w:-]+)=(["'])([^"'\]]*)\4\]/g;
|
|
const scan = (content: string) => {
|
|
splitDataAttrSelectorPattern.lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = splitDataAttrSelectorPattern.exec(content)) !== null) {
|
|
const compId = match[2] ?? "";
|
|
const attrName = match[3] ?? "";
|
|
const attrValue = match[5] ?? "";
|
|
findings.push({
|
|
code: "split_data_attribute_selector",
|
|
severity: "error",
|
|
message:
|
|
`Selector "${match[0]}" combines two attributes inside one CSS attribute selector. ` +
|
|
"Browsers reject it, so GSAP timelines or querySelector calls will fail before registering.",
|
|
selector: match[0],
|
|
fixHint: `Use separate attribute selectors: [data-composition-id="${compId}"][${attrName}="${attrValue}"].`,
|
|
snippet: truncateSnippet(match[0]),
|
|
});
|
|
}
|
|
};
|
|
for (const style of styles) scan(style.content);
|
|
for (const script of scripts) scan(script.content);
|
|
return findings;
|
|
},
|
|
|
|
// template_literal_selector
|
|
({ scripts }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const script of scripts) {
|
|
const templateLiteralSelectorPattern =
|
|
/(?:querySelector|querySelectorAll)\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`\s*\)/g;
|
|
let tlMatch: RegExpExecArray | null;
|
|
while ((tlMatch = templateLiteralSelectorPattern.exec(script.content)) !== null) {
|
|
findings.push({
|
|
code: "template_literal_selector",
|
|
severity: "error",
|
|
message:
|
|
"querySelector uses a template literal variable (e.g. `${compId}`). " +
|
|
"The HTML bundler's CSS parser crashes on these. Use a hardcoded string instead.",
|
|
fixHint:
|
|
"Replace the template literal variable with a hardcoded string. The bundler's CSS parser cannot handle interpolated variables in script content.",
|
|
snippet: truncateSnippet(tlMatch[0]),
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// timed_element_missing_clip_class
|
|
// fallow-ignore-next-line complexity
|
|
({ tags }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
const skipTags = new Set(["audio", "video", "script", "style", "template"]);
|
|
for (const tag of tags) {
|
|
if (skipTags.has(tag.name)) continue;
|
|
// Skip composition hosts
|
|
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
if (readAttr(tag.raw, "data-composition-src")) continue;
|
|
|
|
const hasStart = readAttr(tag.raw, "data-start") !== null;
|
|
const hasDuration = readAttr(tag.raw, "data-duration") !== null;
|
|
// data-track-index alone marks a layer container, not a time-bounded clip
|
|
if (!hasStart && !hasDuration) continue;
|
|
|
|
const classAttr = readAttr(tag.raw, "class") || "";
|
|
const hasClip = classAttr.split(/\s+/).includes("clip");
|
|
if (hasClip) continue;
|
|
|
|
const elementId = readAttr(tag.raw, "id") || undefined;
|
|
findings.push({
|
|
code: "timed_element_missing_clip_class",
|
|
severity: "error",
|
|
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`,
|
|
elementId,
|
|
fixHint:
|
|
'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
|
|
snippet: truncateSnippet(tag.raw),
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// overlapping_clips_same_track
|
|
// fallow-ignore-next-line complexity
|
|
({ tags }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
|
|
type ClipInfo = { start: number; end: number; elementId?: string; snippet: string };
|
|
const trackMap = new Map<string, ClipInfo[]>();
|
|
|
|
for (const tag of tags) {
|
|
const startStr = readAttr(tag.raw, "data-start");
|
|
const durationStr = readAttr(tag.raw, "data-duration");
|
|
const trackStr = readAttr(tag.raw, "data-track-index");
|
|
if (!startStr || !durationStr || !trackStr) continue;
|
|
|
|
const start = Number(startStr);
|
|
const duration = Number(durationStr);
|
|
const track = trackStr;
|
|
|
|
// Skip non-numeric (relative timing references like "intro-comp")
|
|
if (Number.isNaN(start) || Number.isNaN(duration)) continue;
|
|
|
|
const clips = trackMap.get(track) || [];
|
|
clips.push({
|
|
start,
|
|
end: start + duration,
|
|
elementId: readAttr(tag.raw, "id") || undefined,
|
|
snippet: truncateSnippet(tag.raw) || "",
|
|
});
|
|
trackMap.set(track, clips);
|
|
}
|
|
|
|
for (const [track, clips] of trackMap) {
|
|
clips.sort((a, b) => a.start - b.start);
|
|
for (let i = 0; i < clips.length - 1; i++) {
|
|
const current = clips[i];
|
|
const next = clips[i + 1];
|
|
if (!current || !next) continue;
|
|
if (current.end - next.start > OVERLAP_EPSILON_SECONDS) {
|
|
findings.push({
|
|
code: "overlapping_clips_same_track",
|
|
severity: "error",
|
|
message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
|
|
fixHint:
|
|
"Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return findings;
|
|
},
|
|
|
|
// root_composition_missing_data_start
|
|
({ rootTag, options }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
if (options.isSubComposition) return findings;
|
|
if (!rootTag) return findings;
|
|
const compId = readDecodedAttr(rootTag.raw, "data-composition-id");
|
|
if (!compId) return findings;
|
|
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
|
|
if (!hasStart) {
|
|
findings.push({
|
|
code: "root_composition_missing_data_start",
|
|
severity: "error",
|
|
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
|
|
fixHint: 'Add data-start="0" to the root composition element.',
|
|
snippet: truncateSnippet(rootTag.raw),
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// standalone_composition_wrapped_in_template
|
|
({ rawSource, options }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
if (options.isSubComposition) return findings;
|
|
const trimmed = rawSource.trimStart().toLowerCase();
|
|
if (trimmed.startsWith("<template")) {
|
|
findings.push({
|
|
code: "standalone_composition_wrapped_in_template",
|
|
severity: "error",
|
|
message:
|
|
"Root index.html is wrapped in a <template> tag. " +
|
|
"Only sub-compositions loaded via data-composition-src should use <template> wrappers. " +
|
|
"The runtime cannot play a standalone composition inside a template.",
|
|
fixHint:
|
|
"Remove the <template> wrapper. Use <!DOCTYPE html><html>...<div data-composition-id>...</div>...</html> instead.",
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// root_composition_missing_html_wrapper
|
|
({ rawSource, rootTag, options }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
if (options.isSubComposition) return findings;
|
|
const trimmed = rawSource.trimStart().toLowerCase();
|
|
// Compositions inside <template> are caught by standalone_composition_wrapped_in_template
|
|
if (trimmed.startsWith("<template")) return findings;
|
|
const hasDoctype = trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
|
|
const hasComposition = rawSource.includes("data-composition-id");
|
|
if (hasComposition && !hasDoctype) {
|
|
findings.push({
|
|
code: "root_composition_missing_html_wrapper",
|
|
severity: "error",
|
|
message:
|
|
"Composition starts with a bare element instead of a proper HTML document. " +
|
|
"An index.html that contains data-composition-id but no <!DOCTYPE html>, <html>, or <body> " +
|
|
"is a fragment — browsers quirks-mode it, the preview server cannot load it, and " +
|
|
"the bundler will fail to inject runtime scripts.",
|
|
fixHint:
|
|
'Wrap the composition in <!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>...</body></html>.',
|
|
snippet: rootTag ? truncateSnippet(rootTag.raw) : undefined,
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// missing_data_no_timeline
|
|
// The producer polls window.__timelines[id] with a 45-second timeout waiting
|
|
// for GSAP timeline registration. Compositions that never call
|
|
// window.__timelines[id] = tl stall for 45 s every render. Adding
|
|
// data-no-timeline to the root element tells the producer to skip the poll.
|
|
({ rootTag, rootCompositionId, scripts, rawSource, options }) => {
|
|
if (options.isSubComposition) return [];
|
|
if (!rootCompositionId || !rootTag) return [];
|
|
// readAttr only matches valued attrs (attr="..."); data-no-timeline is
|
|
// typically boolean (no value). Strip quoted attribute values first to
|
|
// avoid matching attr names that appear inside other values
|
|
// (e.g. title="add data-no-timeline here"), then check with a boundary
|
|
// that rejects hyphenated variants (data-no-timeline-start has '-' next,
|
|
// not a word-break char).
|
|
const tagNoValues = rootTag.raw.replace(/"[^"]*"|'[^']*'/g, '""');
|
|
if (/(?:^|\s)data-no-timeline(?=[\s>=/]|$)/i.test(tagNoValues)) return [];
|
|
// Can't scan external script files for timeline registration; skip to avoid
|
|
// false positives on compositions that register via a bundled JS file.
|
|
if (/<script\b[^>]*\bsrc\s*=/i.test(rawSource)) return [];
|
|
const registersTimeline = scripts.some((s) => s.content.includes("window.__timelines["));
|
|
if (registersTimeline) return [];
|
|
return [
|
|
{
|
|
code: "missing_data_no_timeline",
|
|
severity: "warning",
|
|
message:
|
|
"This composition has no `window.__timelines` registration but is missing `data-no-timeline`. " +
|
|
"The producer polls for timeline registration for up to 45 seconds before timing out, " +
|
|
"adding 45 s to every render.",
|
|
fixHint:
|
|
'Add `data-no-timeline` to the root element to skip the poll: `<div data-composition-id="..." data-no-timeline ...>`.',
|
|
snippet: truncateSnippet(rootTag.raw),
|
|
},
|
|
];
|
|
},
|
|
|
|
// requestanimationframe_in_composition
|
|
({ scripts, rawSource, options }) => {
|
|
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const script of scripts) {
|
|
const stripped = stripJsComments(script.content);
|
|
if (/requestAnimationFrame\s*\(/.test(stripped)) {
|
|
findings.push({
|
|
code: "requestanimationframe_in_composition",
|
|
severity: "error",
|
|
message:
|
|
"`requestAnimationFrame` runs on wall-clock time, not the GSAP timeline. It will not sync with frame capture and may cause flickering or missed frames during rendering.",
|
|
fixHint:
|
|
"Use GSAP tweens or onUpdate callbacks instead of requestAnimationFrame for animation logic.",
|
|
snippet: truncateSnippet(script.content),
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// invalid_variable_values_json
|
|
// Host elements (`[data-composition-src]`) carry per-instance values via
|
|
// `data-variable-values`. The runtime swallows JSON errors silently and
|
|
// falls back to declared defaults, which masks typos. This rule surfaces
|
|
// the parse failure so authors notice before render time.
|
|
// fallow-ignore-next-line complexity
|
|
({ tags }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const tag of tags) {
|
|
const raw = readJsonAttr(tag.raw, "data-variable-values");
|
|
if (!raw) continue;
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : "unknown";
|
|
findings.push({
|
|
code: "invalid_variable_values_json",
|
|
severity: "error",
|
|
message: `data-variable-values is not valid JSON (${reason}).`,
|
|
fixHint:
|
|
'Wrap the attribute value in single quotes and the JSON keys/values in double quotes, e.g. data-variable-values=\'{"title":"Hello"}\'.',
|
|
elementId: readAttr(tag.raw, "id") || undefined,
|
|
snippet: truncateSnippet(tag.raw),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
findings.push({
|
|
code: "invalid_variable_values_json",
|
|
severity: "error",
|
|
message:
|
|
'data-variable-values must be a JSON object keyed by variable id (e.g. {"title":"Hello"}).',
|
|
fixHint:
|
|
"Replace the value with a JSON object whose keys are variable ids declared in the sub-composition's data-composition-variables.",
|
|
elementId: readAttr(tag.raw, "id") || undefined,
|
|
snippet: truncateSnippet(tag.raw),
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// unknown_variable_binding
|
|
// data-var-src / data-var-text bind an element to a declared variable id;
|
|
// the runtime silently keeps the authored fallback when the id resolves to
|
|
// nothing, so a typo'd binding is invisible until a customer's override
|
|
// does nothing. Skipped for fragment files (no <html>): their values come
|
|
// from a host's data-variable-values, which this file can't see.
|
|
({ tags }) => {
|
|
// Declarations live on <html> (full-document comps) OR the composition root
|
|
// div (template/fragment sub-comps); declaredIdsForBindingCheck unions both
|
|
// and returns null for files this rule should skip.
|
|
const declared = declaredIdsForBindingCheck(tags);
|
|
if (!declared) return [];
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const tag of tags) {
|
|
for (const attr of ["data-var-src", "data-var-text"]) {
|
|
const id = readAttr(tag.raw, attr)?.trim();
|
|
if (!id || declared.has(id)) continue;
|
|
findings.push({
|
|
code: "unknown_variable_binding",
|
|
severity: "warning",
|
|
message: `<${tag.name}> binds ${attr}="${id}" but no variable "${id}" is declared in data-composition-variables — the binding will silently keep the authored fallback.`,
|
|
fixHint: `Declare the variable on the composition root (<html>, or the [data-composition-id] root element for a template/fragment comp): data-composition-variables='[{"id":"${id}","type":"${attr === "data-var-src" ? "image" : "string"}","label":"${id}","default":"..."}]', or fix the binding id.`,
|
|
elementId: readAttr(tag.raw, "id") || undefined,
|
|
snippet: truncateSnippet(tag.raw),
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// invalid_composition_variables_declaration
|
|
// The runtime parses `data-composition-variables` and silently returns []
|
|
// on any structural problem. Surface JSON / shape failures so authors
|
|
// catch them at lint time rather than wondering why their `getVariables()`
|
|
// defaults aren't applied.
|
|
// fallow-ignore-next-line complexity
|
|
({ tags }) => {
|
|
const htmlTag = findHtmlTag(tags);
|
|
if (!htmlTag) return [];
|
|
const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
|
|
if (!raw) return [];
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : "unknown";
|
|
return [
|
|
{
|
|
code: "invalid_composition_variables_declaration",
|
|
severity: "error",
|
|
message: `data-composition-variables is not valid JSON (${reason}).`,
|
|
fixHint:
|
|
'Provide a JSON array of variable declarations: data-composition-variables=\'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
|
|
snippet: truncateSnippet(htmlTag.raw),
|
|
},
|
|
];
|
|
}
|
|
|
|
if (!Array.isArray(parsed)) {
|
|
return [
|
|
{
|
|
code: "invalid_composition_variables_declaration",
|
|
severity: "error",
|
|
message: "data-composition-variables must be a JSON array of variable declarations.",
|
|
fixHint:
|
|
'Wrap declarations in [] and give each an id, type, label, and default: \'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
|
|
snippet: truncateSnippet(htmlTag.raw),
|
|
},
|
|
];
|
|
}
|
|
|
|
const findings: HyperframeLintFinding[] = [];
|
|
const knownTypes = new Set<string>(COMPOSITION_VARIABLE_TYPES);
|
|
for (let i = 0; i < parsed.length; i += 1) {
|
|
const entry = parsed[i];
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
findings.push({
|
|
code: "invalid_composition_variables_declaration",
|
|
severity: "error",
|
|
message: `data-composition-variables entry [${i}] must be an object with id, type, label, and default.`,
|
|
snippet: truncateSnippet(htmlTag.raw),
|
|
});
|
|
continue;
|
|
}
|
|
const e = entry as Record<string, unknown>;
|
|
const missing: string[] = [];
|
|
if (typeof e.id !== "string") missing.push("id");
|
|
if (typeof e.type !== "string" || !knownTypes.has(e.type)) missing.push("type");
|
|
if (typeof e.label !== "string") missing.push("label");
|
|
if (!("default" in e)) missing.push("default");
|
|
if (missing.length > 0) {
|
|
findings.push({
|
|
code: "invalid_composition_variables_declaration",
|
|
severity: "error",
|
|
message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum, font, image.`,
|
|
snippet: truncateSnippet(htmlTag.raw),
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// html_dir_attribute_breaks_render — valid non-LTR dir values on
|
|
// <html> renders correctly in preview/snapshot but produces a fully
|
|
// blank/black video from render, with no other lint/validate/inspect
|
|
// check catching it (output file size, far smaller than expected, is the
|
|
// only tell). Confirmed independently by two separate reports, both
|
|
// diagnosing the same exact trigger and the same fix: drop dir from
|
|
// <html>, keep lang, and scope `direction: rtl` to individual
|
|
// text-containing elements via CSS instead (text still bidi-shapes
|
|
// correctly). Advisory-only — this does not attempt to fix the render
|
|
// pipeline's own root cause (suspected to be a capture step that clips a
|
|
// fixed top-left-origin screenshot region, which RTL layout can shift the
|
|
// actual content away from), only surfaces the already-confirmed footgun
|
|
// before someone hits it blind.
|
|
({ tags }) => {
|
|
const htmlTag = findHtmlTag(tags);
|
|
if (!htmlTag) return [];
|
|
const dir = readAttr(htmlTag.raw, "dir");
|
|
if (!dir) return [];
|
|
const normalizedDir = dir.toLowerCase();
|
|
if (normalizedDir !== "rtl" && normalizedDir !== "auto") return [];
|
|
const scopedDirection = normalizedDir === "auto" ? 'dir="auto"' : `direction: ${normalizedDir}`;
|
|
return [
|
|
{
|
|
code: "html_dir_attribute_breaks_render",
|
|
severity: "error",
|
|
message: `<html dir="${dir}"> renders correctly in preview/snapshot but produces a fully blank/black video from render — a confirmed, silent failure.`,
|
|
fixHint: `Remove dir="${dir}" from <html>. Keep lang, and scope ${scopedDirection} to individual text-containing elements instead — text still shapes correctly via the browser's own bidi algorithm.`,
|
|
snippet: truncateSnippet(htmlTag.raw),
|
|
},
|
|
];
|
|
},
|
|
|
|
// subcomposition_blanks_before_host
|
|
// Warns when a full-bleed sub-composition slot ends before the host composition
|
|
// does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
|
|
// the high-signal shape — a sole/dominant external mount starting at ~0 — so it
|
|
// stays silent on intentional short clips (an intro followed by other clips that
|
|
// carry the timeline forward).
|
|
// fallow-ignore-next-line complexity
|
|
({ tags, rootTag }) => {
|
|
if (!rootTag) return [];
|
|
const rootDuration = Number(readAttr(rootTag.raw, "data-duration"));
|
|
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
|
|
|
|
// Two independent knobs that happen to share a 0.5s magnitude. Tuned for
|
|
// real hosts (tens to hundreds of seconds); on a very short host (~6s) the
|
|
// EPSILON slack would let a ~10% blank tail pass unflagged — acceptable
|
|
// because the silent-blank trap this rule targets only matters at scale.
|
|
const EPSILON = 0.5; // seconds; tolerance for "ends/covers near the host end"
|
|
const START_TOLERANCE = 0.5; // seconds; "starts at the composition start"
|
|
const round3 = (n: number) => Math.round(n * 1000) / 1000;
|
|
|
|
// Timed children of the root. An element with data-start but no usable
|
|
// data-duration is treated as covering the tail (end = Infinity), so an
|
|
// unknown-length sibling suppresses the warning rather than triggering it.
|
|
const timed = tags
|
|
.filter((tag) => tag.index !== rootTag.index && readAttr(tag.raw, "data-start") !== null)
|
|
.map((tag) => {
|
|
const start = Number(readAttr(tag.raw, "data-start")) || 0;
|
|
const dur = Number(readAttr(tag.raw, "data-duration"));
|
|
const end = Number.isFinite(dur) && dur > 0 ? start + dur : Infinity;
|
|
return { tag, start, end };
|
|
});
|
|
|
|
// `tags` is a flat list (no nesting depth), so a timed element nested
|
|
// *inside* a candidate slot is treated as a tail-covering sibling rather
|
|
// than a descendant. Acceptable: external src mounts are empty by
|
|
// convention (content is loaded from the linked file), so the only
|
|
// false-negative path is rare and matches the flat-tag scope of the
|
|
// sibling rules in this file.
|
|
const tailCovered = (exceptIndex: number) =>
|
|
timed.some((t) => t.tag.index !== exceptIndex && t.end >= rootDuration - EPSILON);
|
|
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const t of timed) {
|
|
if (readAttr(t.tag.raw, "data-composition-src") === null) continue; // external slot only
|
|
if (t.start > START_TOLERANCE) continue; // must start at the composition start
|
|
if (!Number.isFinite(t.end)) continue; // known, finite slot length
|
|
if (t.end >= rootDuration - EPSILON) continue; // already fills the host window
|
|
if (tailCovered(t.tag.index)) continue; // another clip covers the tail — not full-bleed
|
|
const elementId = readAttr(t.tag.raw, "id") || undefined;
|
|
const gap = round3(rootDuration - t.end);
|
|
findings.push({
|
|
code: "subcomposition_blanks_before_host",
|
|
severity: "warning",
|
|
message: `<${t.tag.name}${elementId ? ` id="${elementId}"` : ""}> sub-composition ends at ${round3(t.end)}s but the composition runs to ${round3(rootDuration)}s — its slot will be blank for ~${gap}s.`,
|
|
elementId,
|
|
fixHint: `data-duration is the slot's visible window. Set this sub-composition's data-duration to ${round3(rootDuration - t.start)} to fill the host window, or add another clip to cover the remaining ~${gap}s.`,
|
|
snippet: truncateSnippet(t.tag.raw),
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// subcomposition_root_styled_by_class
|
|
// A sub-composition's <style> is scoped at render time to
|
|
// `[data-composition-id="<id>"] <selector>` so scenes inlined into one document
|
|
// can't leak styles into each other. A rule whose LEFTMOST selector is the ROOT
|
|
// element's own class (e.g. `.frame { ... }` on the same element that carries
|
|
// data-composition-id) therefore becomes a DESCENDANT selector that can never
|
|
// match the root — the whole scene renders unstyled (tiny text top-left, images
|
|
// at natural size). lint/validate/inspect evaluate the file in isolation (no
|
|
// scoping) and Studio previews each scene in its own iframe (no scoping), so the
|
|
// break is invisible until the composited MP4 render. Style the root via `#root`
|
|
// (the scoper special-cases the root id) and descendants via plain selectors,
|
|
// like the registry blocks — the runtime already scopes each scene by id, so a
|
|
// class namespace on the root is redundant.
|
|
({ rootTag, rootCompositionId, styles, options }) => {
|
|
if (!options.isSubComposition) return [];
|
|
if (isRegistrySourceFile(options.filePath)) return [];
|
|
if (!rootTag || !rootCompositionId) return [];
|
|
|
|
const rootClasses = (readAttr(rootTag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
|
if (rootClasses.length === 0) return [];
|
|
|
|
const offenders = rootClassStyledSelectors(styles, rootClasses);
|
|
if (offenders.length === 0) return [];
|
|
|
|
const example = offenders.slice(0, 3).join(", ");
|
|
return [
|
|
{
|
|
code: "subcomposition_root_styled_by_class",
|
|
severity: "error",
|
|
message:
|
|
`Root element has class="${rootClasses.join(" ")}" and is styled by ${offenders.length} rule(s) keyed off that class (e.g. ${example}). ` +
|
|
`At render, every sub-composition rule is scoped to [data-composition-id="${rootCompositionId}"] <selector>, so a selector whose leftmost part is the ROOT's own class becomes a descendant selector that cannot match the root — the scene renders unstyled (tiny text top-left, full-size images). ` +
|
|
`lint/validate/inspect and Studio's per-frame iframe preview do not scope, so this passes every static check and looks correct in preview.`,
|
|
selector: example,
|
|
fixHint: `Give the root id="root" and style it with \`#root { ... }\` plus plain descendant selectors (\`.kicker\`, \`#hero\`) — the runtime already scopes each sub-composition by data-composition-id, so a class namespace on the root is redundant and breaks under scoping.`,
|
|
snippet: truncateSnippet(rootTag.raw),
|
|
},
|
|
];
|
|
},
|
|
|
|
// root_composition_missing_duration_source
|
|
//
|
|
// The render engine (packages/engine/src/services/frameCapture.ts) needs a
|
|
// positive window.__hf.duration to know how many frames to capture. GSAP
|
|
// timelines set this automatically. Non-GSAP runtimes (CSS, WAAPI, Lottie)
|
|
// are now auto-inferred by the runtime too (see
|
|
// packages/core/src/runtime/init.ts resolveAdapterDurationFloorSeconds and
|
|
// the adapters' getInferredDurationSeconds) — so data-duration is optional
|
|
// wherever the runtime can work it out on its own.
|
|
//
|
|
// This rule fires for cases where the total render length is not reliably
|
|
// determinable without an explicit data-duration:
|
|
// - No GSAP timeline AND no data-duration AND no non-GSAP animation
|
|
// signal at all (nothing for any adapter to discover — render fails).
|
|
// - Three.js used with no data-duration (no discoverable AnimationClip
|
|
// duration in this codebase's adapter — see adapters/three.ts).
|
|
// - Any infinite CSS animation-iteration-count with no data-duration,
|
|
// EVEN when a finite CSS animation is present alongside it. An unbounded
|
|
// animation makes the intended total length ambiguous — the runtime will
|
|
// infer a finite sibling's length if one exists, but that's a fallback,
|
|
// not a declaration of intent, so we still require data-duration here.
|
|
// (This is intentionally stricter than the runtime's own inference.)
|
|
// Purely finite CSS/WAAPI animations and Lottie are excluded — the runtime
|
|
// infers those unambiguously, so requiring data-duration there would be a
|
|
// false positive against the runtime's own auto-inference. Note lint is
|
|
// advisory by default (see shouldBlockRender) — it only blocks render under
|
|
// --strict/--strict-all — so a strict flag here nudges toward an explicit,
|
|
// guaranteed-correct value without failing renders that would succeed.
|
|
// fallow-ignore-next-line complexity
|
|
({ rootTag, scripts, styles, tags, options }) => {
|
|
if (options.isSubComposition) return [];
|
|
if (!rootTag) return [];
|
|
// Not every file linted as a "root" HTML document is a video composition
|
|
// — e.g. a slideshow demo.html mounts <hyperframes-player src="index.html">
|
|
// with no data-composition-id of its own. Nothing to capture there, so
|
|
// there's no duration contract to enforce.
|
|
if (readDecodedAttr(rootTag.raw, "data-composition-id") === null) return [];
|
|
if (readAttr(rootTag.raw, "data-duration") !== null) return [];
|
|
|
|
// Strip comments before scanning for signals — a commented-out
|
|
// `.animate(...)` call or `/* animation: spin 2s infinite; */` must not
|
|
// satisfy the "has a duration source" check, or the composition still
|
|
// fails at render with zero duration despite lint passing.
|
|
const allScriptTexts = scripts.map((s) => stripJsComments(s.content));
|
|
const hasGsapTimeline = allScriptTexts.some((t) => /gsap\.timeline\s*\(/.test(t));
|
|
const hasRegisteredTimeline = allScriptTexts.some((t) =>
|
|
WINDOW_TIMELINE_ASSIGN_PATTERN.test(t),
|
|
);
|
|
// A GSAP timeline drives duration via window.__timelines regardless of
|
|
// data-duration — nothing to flag once one is registered.
|
|
if (hasGsapTimeline && hasRegisteredTimeline) return [];
|
|
|
|
const allCss = styles.map((s) => s.content).join("\n");
|
|
const allInlineStyles = tags.map((t) => readAttr(t.raw, "style") || "").join("\n");
|
|
const combinedCss = `${allCss}\n${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
|
|
const usesLottie =
|
|
tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null) ||
|
|
allScriptTexts.some((t) => /lottie\.(loadAnimation)\b|__hfLottie\b/.test(t));
|
|
const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t));
|
|
// `.animate([...], ...)` catches the array-literal keyframes form;
|
|
// `.animate({...}, ...)` catches the object-literal (PropertyIndexedKeyframes)
|
|
// form; `.animate(someVar, ...)` catches keyframes built up in a variable
|
|
// first.
|
|
const usesWaapi = allScriptTexts.some((t) => /\.animate\s*\(\s*[[{$A-Za-z_]/.test(t));
|
|
const hasCssAnimationName = /\banimation(?:-name)?\s*:/.test(combinedCss);
|
|
const hasInfiniteCssAnimation =
|
|
/\banimation(?:-iteration-count)?\s*:[^;{}]*(?<![\w-])infinite(?![\w-])/.test(combinedCss);
|
|
|
|
const hasAnyNonGsapSignal = usesLottie || usesThree || usesWaapi || hasCssAnimationName;
|
|
|
|
if (!hasAnyNonGsapSignal) {
|
|
// No GSAP timeline, no data-duration, and nothing for any adapter to
|
|
// discover — the composition has no source of truth for duration at
|
|
// all. This is the exact shape of the 27K "zero duration" render
|
|
// failures this rule exists to catch before render time.
|
|
return [
|
|
{
|
|
code: "root_composition_missing_duration_source",
|
|
severity: "error",
|
|
message:
|
|
"Root composition has no data-duration, no GSAP timeline, and no CSS/WAAPI/Lottie/Three.js " +
|
|
"animation for the runtime to infer a duration from. The render engine cannot determine " +
|
|
'how long to capture and will fail with "Composition has zero duration".',
|
|
fixHint:
|
|
'Add data-duration="<seconds>" to the root element, or add a paused GSAP timeline registered ' +
|
|
"on window.__timelines.",
|
|
snippet: truncateSnippet(rootTag.raw),
|
|
},
|
|
];
|
|
}
|
|
|
|
if (usesThree) {
|
|
// No AnimationMixer/AnimationClip discovery in the three.js adapter
|
|
// today (see adapters/three.ts) — genuinely not inferable.
|
|
return [
|
|
{
|
|
code: "root_composition_missing_duration_source",
|
|
severity: "error",
|
|
message:
|
|
"Root composition uses Three.js with no data-duration. The runtime cannot discover a " +
|
|
"Three.js scene's duration automatically (no AnimationClip/AnimationMixer inspection) — " +
|
|
'render will fail with "Composition has zero duration".',
|
|
fixHint: 'Add data-duration="<seconds>" to the root element.',
|
|
snippet: truncateSnippet(rootTag.raw),
|
|
},
|
|
];
|
|
}
|
|
|
|
if (hasInfiniteCssAnimation && !usesLottie && !usesWaapi) {
|
|
// An infinite/unbounded CSS animation makes the intended total length
|
|
// ambiguous, so we require an explicit data-duration even when a finite
|
|
// CSS animation is present alongside it. This is deliberately stricter
|
|
// than the runtime's own inference: the CSS adapter's
|
|
// getInferredDurationSeconds (see adapters/css.ts) returns the longest
|
|
// finite animation end-time when one exists (so a finite sibling would
|
|
// render at that length) and null when every animation is unbounded (so
|
|
// a render with no finite source fails outright). Either way the author
|
|
// hasn't declared how long the video should be — a decorative infinite
|
|
// spinner next to a 3s fade doesn't tell us the clip is meant to be 3s
|
|
// — so we flag it and let them state intent. The message stays honest
|
|
// about both outcomes rather than claiming the render always fails.
|
|
return [
|
|
{
|
|
code: "root_composition_missing_duration_source",
|
|
severity: "error",
|
|
message:
|
|
"Root composition uses a CSS animation with animation-iteration-count: infinite and no " +
|
|
"data-duration, so the intended total length is ambiguous. If a finite animation is also " +
|
|
"present the runtime infers that length; with no finite source the render fails with " +
|
|
'"Composition has zero duration". Declare the intended length explicitly.',
|
|
fixHint:
|
|
'Add data-duration="<seconds>" to the root element with the intended total length.',
|
|
snippet: truncateSnippet(rootTag.raw),
|
|
},
|
|
];
|
|
}
|
|
|
|
// Finite CSS animation, WAAPI .animate(), or Lottie — the runtime infers
|
|
// duration from these at render time (see resolveAdapterDurationFloorSeconds
|
|
// in runtime/init.ts). Not an error; data-duration is optional here.
|
|
return [];
|
|
},
|
|
|
|
// composition_heavy_overlay_count_high
|
|
// Field signal ts=1784040753 (#hyperframes-cli-feedback): a composition
|
|
// with ~40 heavy overlay DOM elements — `filter:blur`, oversized
|
|
// `radial-gradient`, and `clip-path` animations — captures solid-black for
|
|
// the first ~half of the render, recovering near the end. Reproduces
|
|
// identically via drawElement AND forced --no-browser-gpu screenshot
|
|
// capture AND `snapshot`, so the offender is the capture layer itself, not
|
|
// encoder/mux. Independent of duration (padding the timeline grows the bad
|
|
// zone proportionally, doesn't shift it). Reporter's workaround was to
|
|
// split into per-transition mini compositions + FFmpeg concat.
|
|
//
|
|
// Presence alone matters: opacity:0 and visibility:hidden overlays still
|
|
// contribute to the capture-layer regression, so they're counted-in. The
|
|
// only escape hatch is `display: none` — an element removed from the render
|
|
// tree can't feed the compositor. Warn at 25, well below the observed
|
|
// 40-element repro, to give authors lead time before hitting the bug.
|
|
// fallow-ignore-next-line complexity
|
|
({ tags, styles, rawSource, options }) => {
|
|
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
|
|
const { classes: heavyClassTokens, ids: heavyIds } = collectHeavyOverlayHooks(styles);
|
|
|
|
let heavyCount = 0;
|
|
for (const tag of tags) {
|
|
if (HEAVY_OVERLAY_EXEMPT_TAGS.has(tag.name)) continue;
|
|
// Structural containers (root + mounted sub-compositions) aren't overlay
|
|
// content — the heavy children live inside them, and each such child is
|
|
// its own tag entry that we score directly. Counting the container too
|
|
// would double-attribute the risk to one authoring surface.
|
|
if (isCompositionRootOrMount(tag.raw)) continue;
|
|
|
|
// readJsonAttr lets a `style` value carry the opposite quote character
|
|
// (inline `background: url("x.png")` etc.), which readAttr would truncate.
|
|
const styleAttr = readJsonAttr(tag.raw, "style") ?? "";
|
|
// display:none removes the element from the render tree, so the capture
|
|
// layer never sees it — the only reliable way to keep an "unused" heavy
|
|
// overlay in the source without paying the compositor cost.
|
|
if (styleAttr && INLINE_STYLE_DISPLAY_NONE_PATTERN.test(styleAttr)) continue;
|
|
|
|
let heavy = false;
|
|
if (styleAttr && HEAVY_OVERLAY_CSS_PATTERN.test(styleAttr)) heavy = true;
|
|
|
|
if (!heavy && (heavyClassTokens.size > 0 || heavyIds.size > 0)) {
|
|
const classList = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
|
if (classList.some((cls) => heavyClassTokens.has(cls))) heavy = true;
|
|
if (!heavy) {
|
|
const idValue = readAttr(tag.raw, "id");
|
|
if (idValue && heavyIds.has(idValue)) heavy = true;
|
|
}
|
|
}
|
|
|
|
if (heavy) heavyCount += 1;
|
|
}
|
|
|
|
if (heavyCount < HEAVY_OVERLAY_ELEMENT_COUNT_WARN) return [];
|
|
|
|
const splitTarget = options.isSubComposition
|
|
? "Split this sub-composition further into per-transition mini-compositions"
|
|
: "Split coherent scenes / transitions into separate .html files under compositions/";
|
|
|
|
return [
|
|
{
|
|
code: "composition_heavy_overlay_count_high",
|
|
severity: "warning",
|
|
message:
|
|
`This composition has ${heavyCount} elements carrying "heavy overlay" CSS ` +
|
|
`(filter:blur, radial-gradient, or clip-path). Field signal: a composition with ` +
|
|
`~40 such elements — including opacity:0 / visibility:hidden ones — captures ` +
|
|
`solid-black for the first ~half of the render, recovering near the end. Reproduces ` +
|
|
`identically via drawElement, forced screenshot capture, and snapshot, so the capture ` +
|
|
`layer itself is the offender (not encoder/mux). Independent of duration. Presence ` +
|
|
`alone matters; only display:none elements are excluded here.`,
|
|
fixHint:
|
|
`${splitTarget} and concat the pieces (FFmpeg or the runtime's slideshow) so each ` +
|
|
`capture only sees a small subset of heavy overlays at once. Even hidden overlays ` +
|
|
`(opacity:0 / visibility:hidden) contribute — either remove truly unused ones from ` +
|
|
`the source or scope them into their own per-transition sub-composition. If an ` +
|
|
`overlay is genuinely inert for the whole clip, use display:none so it never enters ` +
|
|
`the render tree. Field ref ts=1784040753 (#hyperframes-cli-feedback).`,
|
|
},
|
|
];
|
|
},
|
|
];
|