Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions packages/lint/src/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,103 @@ describe("composition rules", () => {
});
});

describe("timed_element_exceeds_composition", () => {
const CODE = "timed_element_exceeds_composition";
const find = (findings: { code: string }[]) => findings.find((f) => f.code === CODE);

it("errors when a visible video extends beyond the explicit render window", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="10" data-fps="30">
<video id="customer-video" class="clip" src="assets/video.mp4" data-start="0" data-duration="48.47"></video>
</div>
</body></html>`);

const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("48.47s");
expect(finding?.message).toContain("10s");
expect(finding?.fixHint).toContain("extend the root data-duration");
});

it("accepts an element that ends at the composition boundary", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div id="root" data-composition-id="main" data-duration="10">
<video class="clip" src="assets/video.mp4" data-start="2" data-duration="8"></video>
</div>
</body></html>`);

expect(find(result.findings)).toBeUndefined();
});

it("allows one frame of floating-point and frame-rounding tolerance", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div id="root" data-composition-id="main" data-duration="10" data-fps="25">
<div id="within-one-frame" class="clip" data-start="9" data-duration="1.04"></div>
<div id="beyond-one-frame" class="clip" data-start="9" data-duration="1.041"></div>
</div>
</body></html>`);

const findings = result.findings.filter((finding) => finding.code === CODE);
expect(findings).toHaveLength(1);
expect(findings[0]?.elementId).toBe("beyond-one-frame");
});

it("ignores hidden elements and elements inside a hidden ancestor", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div id="root" data-composition-id="main" data-duration="10">
<video data-hidden src="assets/parked.mp4" data-start="0" data-duration="20"></video>
<div data-hidden>
<audio src="assets/parked.mp3" data-start="0" data-duration="20"></audio>
</div>
</div>
</body></html>`);

expect(find(result.findings)).toBeUndefined();
});

it("does not compare against inferred root duration", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div id="root" data-composition-id="main">
<video class="clip" src="assets/video.mp4" data-start="0" data-duration="48.47"></video>
</div>
</body></html>`);

expect(find(result.findings)).toBeUndefined();
});

it("does not confuse an intentional source trim with timeline overflow", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div id="root" data-composition-id="main" data-duration="10">
<video class="clip" src="assets/video.mp4" data-media-start="40" data-start="6" data-duration="4"></video>
</div>
</body></html>`);

expect(find(result.findings)).toBeUndefined();
});

it("skips unresolved relative starts instead of guessing their absolute end", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div id="root" data-composition-id="main" data-duration="10">
<div id="first" class="clip" data-start="0" data-duration="5"></div>
<div class="clip" data-start="first" data-duration="6"></div>
</div>
</body></html>`);

expect(find(result.findings)).toBeUndefined();
});

it("checks a sub-composition host against its owning composition root", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div id="root" data-composition-id="main" data-duration="10">
<div id="scene" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="4" data-duration="8"></div>
</div>
</body></html>`);

expect(find(result.findings)).toBeDefined();
});
});

describe("subcomposition guidance", () => {
it("warns when any HTML composition file is over 300 lines", async () => {
const html = Array.from({ length: 301 }, (_, i) =>
Expand Down
92 changes: 92 additions & 0 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const HEAVY_OVERLAY_EXEMPT_TAGS = new Set([
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;
const DEFAULT_COMPOSITION_FPS = 30;

function readTagTiming(rawTag: string) {
return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
Expand Down Expand Up @@ -256,6 +257,89 @@ function isInsideInertTemplate(tag: OpenTag, tags: readonly OpenTag[]): boolean
);
}

function isInsideCompositionRoot(tag: OpenTag, rootTag: OpenTag): boolean {
return (
tag.index > rootTag.index && (rootTag.closeIndex == null || tag.index < rootTag.closeIndex)
);
}

function isHiddenByTimelineAncestor(tag: OpenTag, tags: readonly OpenTag[]): boolean {
if (readDecodedAttr(tag.raw, "data-hidden") !== null) return true;

return tags.some(
(candidate) =>
candidate.index < tag.index &&
candidate.closeIndex != null &&
tag.index < candidate.closeIndex &&
readDecodedAttr(candidate.raw, "data-hidden") !== null,
);
}

type ExplicitCompositionBoundary = {
duration: number;
fps: number;
lastAllowedEndFrame: number;
};

function frameAt(seconds: number, fps: number): number {
return Math.ceil(seconds * fps - 1e-9);
}

function readExplicitCompositionBoundary(rootTag: OpenTag): ExplicitCompositionBoundary | null {
const rootDurationRaw = readAttr(rootTag.raw, "data-duration");
if (rootDurationRaw === null) return null;
const duration = Number(rootDurationRaw);
if (!Number.isFinite(duration) || duration <= 0) return null;

const authoredFps = Number(readAttr(rootTag.raw, "data-fps"));
const fps =
Number.isFinite(authoredFps) && authoredFps > 0 ? authoredFps : DEFAULT_COMPOSITION_FPS;
return { duration, fps, lastAllowedEndFrame: frameAt(duration, fps) + 1 };
}

function readAuthoredTimelineEnd(tag: OpenTag): number | null {
if (readAttr(tag.raw, "data-duration") === null) return null;
const timing = readTagTiming(tag.raw);
return timing.duration === null ? null : timing.end;
}

function timedElementOverflowFinding(
tag: OpenTag,
end: number,
rootDuration: number,
): HyperframeLintFinding {
const round3 = (value: number) => Math.round(value * 1000) / 1000;
const elementId = readAttr(tag.raw, "id") || undefined;
const overflow = end - rootDuration;
return {
code: "timed_element_exceeds_composition",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> ends at ${round3(end)}s, but the composition ends at ${round3(rootDuration)}s. The final ~${round3(overflow)}s will not be rendered.`,
elementId,
fixHint: `If the full element is intended, extend the root data-duration to at least ${round3(end)}. If the cutoff is intentional, shorten this element's data-duration so it ends by ${round3(rootDuration)}s; use data-media-start to choose a media source offset.`,
snippet: truncateSnippet(tag.raw),
};
}

function lintTimedElementsBeyondComposition({
tags,
rootTag,
}: LintContext): HyperframeLintFinding[] {
if (!rootTag) return [];
const boundary = readExplicitCompositionBoundary(rootTag);
if (!boundary) return [];

const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (!isInsideCompositionRoot(tag, rootTag)) continue;
if (isInsideInertTemplate(tag, tags) || isHiddenByTimelineAncestor(tag, tags)) continue;
const end = readAuthoredTimelineEnd(tag);
if (end === null || frameAt(end, boundary.fps) <= boundary.lastAllowedEndFrame) continue;
findings.push(timedElementOverflowFinding(tag, end, boundary.duration));
}
return findings;
}

export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries.
({ tags }) => {
Expand Down Expand Up @@ -297,6 +381,14 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},

// timed_element_exceeds_composition
// An explicit root data-duration is the hard render window. Any visible
// authored timing that ends materially beyond it is unreachable in export,
// even though preview/snapshot tooling can still make the extra timeline look
// valid. Compare authored timeline slots only — never a media file's natural
// source duration, which may intentionally be trimmed with data-media-start.
lintTimedElementsBeyondComposition,

// 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
Expand Down
Loading