fix(lint): warn when a clip ends past the root composition duration (#4633)

This commit is contained in:
Miguel Ángel
2026-09-28 05:58:25 +00:00
committed by GitHub
parent fa044a89e5
commit 13bee74960
7 changed files with 95 additions and 6 deletions
@@ -9,6 +9,7 @@ const STRUCTURE_CODES = new Set([
"timeline_element_missing_timing",
"caption_track_kind_missing",
"multiple_caption_tracks",
"clip_ends_past_root_duration",
]);
function htmlFiles(dir: string): string[] {
+58
View File
@@ -14,6 +14,7 @@ const STRUCTURE = new Set([
"timeline_element_missing_timing",
"caption_track_kind_missing",
"multiple_caption_tracks",
"clip_ends_past_root_duration",
]);
const has = (found: Awaited<ReturnType<typeof codes>>, code: string) =>
found.some((f) => f.code === code);
@@ -138,3 +139,60 @@ describe("caption rules", () => {
expect(has(await codes(mixed), "multiple_caption_tracks")).toBe(false);
});
});
describe("clip_ends_past_root_duration", () => {
const PAST = "clip_ends_past_root_duration";
it("names a direct clip that ends past the root, with its end and the root duration", async () => {
const [finding, ...rest] = await codes(
'<div id="outro" class="clip" data-start="8" data-duration="4">x</div>',
);
expect(rest).toEqual([]);
expect(finding).toMatchObject({ code: PAST, severity: "warning", elementId: "outro" });
expect(finding?.message).toContain('<div id="outro"> runs from 8s to 12s');
expect(finding?.message).toContain("data-duration of 10s");
expect(finding?.fixHint).toBe(
'Extend the root data-duration to 12, or make <div id="outro"> end at or before 10s.',
);
});
it("passes a clip ending at the root or within the shared tolerance past it", async () => {
expect(await codes('<div class="clip" data-start="6" data-duration="4">x</div>')).toEqual([]);
expect(await codes('<div class="clip" data-start="4" data-duration="6.04">x</div>')).toEqual(
[],
);
expect(await codes('<div class="clip" data-start="4" data-duration="6.05">x</div>')).toEqual(
[],
);
const [finding] = await codes('<div class="clip" data-start="4" data-duration="6.06">x</div>');
expect(finding?.message).toContain("runs from 4s to 10.06s");
});
it("reads a legacy data-end, and skips a start that references another clip", async () => {
const [finding] = await codes('<div class="clip" data-start="8" data-end="12">x</div>');
expect(finding).toMatchObject({ code: PAST });
expect(finding?.message).toContain("runs from 8s to 12s");
expect(
await codes('<div class="clip" data-start="intro + 2" data-duration="20">x</div>'),
).toEqual([]);
});
it("judges a sub-composition by its host, not its inner clips", async () => {
const [finding] = await codes(
'<div id="scene" data-composition-src="compositions/scene.html" data-start="7" data-duration="5"></div>',
);
expect(finding).toMatchObject({ code: PAST, elementId: "scene" });
expect(finding?.message).toContain("runs from 7s to 12s");
const inline =
'<div id="inner" data-composition-id="inner" data-start="0" data-duration="5">' +
'<div class="clip" data-start="0" data-duration="20">x</div></div>';
expect(await codes(inline)).toEqual([]);
});
it("skips media whose length only the file knows", async () => {
expect(await codes('<video src="a.mp4" data-start="9"></video>')).toEqual([]);
});
it("leaves a root with no data-duration to the missing-duration rules", async () => {
const html = page('<div class="clip" data-start="8" data-duration="4">x</div>').replace(
' data-duration="10"',
"",
);
const { findings } = await lintHyperframeHtml(html);
expect(findings.filter((f) => f.code === PAST)).toEqual([]);
});
});
+28 -1
View File
@@ -1,11 +1,14 @@
import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
import { readDecodedAttr } from "../utils";
import { readDecodedAttr, truncateSnippet } from "../utils";
import {
isSubCompositionHost,
topLevelElements,
trackKindOf,
type StructureNode,
} from "@hyperframes/parsers/top-level-elements";
import { readClipTiming } from "@hyperframes/parsers/composition-contract";
import { readDataDurationSeconds } from "@hyperframes/parsers/media-duration";
import { TIMING_TOLERANCE_SECONDS } from "@hyperframes/parsers/composition-duration";
interface TagNode extends StructureNode<TagNode> {
children: TagNode[];
@@ -167,6 +170,29 @@ function captionFindings(rows: TagNode[], severity: Severity): HyperframeLintFin
return findings;
}
const hundredths = (seconds: number) => Math.round(seconds * 100) / 100;
function clipsPastRootFindings(root: TagNode, rows: TagNode[]): HyperframeLintFinding[] {
if (root.attrs["data-composition-id"] === undefined) return [];
const rootDuration = readDataDurationSeconds((name) => root.attrs[name]);
if (rootDuration === null) return [];
const limit = hundredths(rootDuration);
return rows.flatMap((row) => {
const { start, end } = readClipTiming({ getAttribute: (name) => row.attrs[name] ?? null });
if (start === null || end === null || end <= rootDuration + TIMING_TOLERANCE_SECONDS) return [];
return [
{
code: "clip_ends_past_root_duration",
severity: "warning",
message: `${describe(row)} runs from ${hundredths(start)}s to ${hundredths(end)}s, past the root composition's data-duration of ${limit}s, so it is cut off in previews, posters and renders.`,
elementId: row.attrs.id,
fixHint: `Extend the root data-duration to ${hundredths(end)}, or make ${describe(row)} end at or before ${limit}s.`,
snippet: truncateSnippet(row.open.raw),
},
];
});
}
export const structureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
(ctx) => {
// The timeline shows the root composition's rows; a sub-composition file is the leaf where layout lives.
@@ -179,6 +205,7 @@ export const structureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]
...nestedStructureFindings(rows, severity),
...missingDurationFindings(rows, severity),
...captionFindings(rows, severity),
...clipsPastRootFindings(root, rows),
];
},
];
@@ -18,6 +18,8 @@ export interface ResolveCompositionDurationInput {
clipEndsSeconds: readonly (number | null)[];
}
export const TIMING_TOLERANCE_SECONDS = 0.05;
const isPositiveFinite = (value: number | null): value is number =>
value !== null && Number.isFinite(value) && value > 0;
+4 -4
View File
@@ -1,10 +1,10 @@
import { parseHTML } from "linkedom";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
import type { GsapAnimation } from "./gsapSerialize.js";
import { TIMING_TOLERANCE_SECONDS } from "./compositionDuration.js";
import { isSubCompositionHost, topLevelElements } from "./topLevelElements.js";
import type { StructureNode } from "./topLevelElements.js";
const EPSILON = 0.05;
const NON_RENDERED = new Set(["script", "style", "template", "noscript", "link", "meta"]);
export type TimingFinding =
@@ -94,8 +94,8 @@ function windowFinding(
const duration = attrNumber(el, "data-duration");
if (dataStart === null) return null;
const dataEnd = duration === null ? null : dataStart + duration;
const beforeStart = anim.method !== "set" && start < dataStart - EPSILON;
const afterEnd = dataEnd !== null && start > dataEnd + EPSILON;
const beforeStart = anim.method !== "set" && start < dataStart - TIMING_TOLERANCE_SECONDS;
const afterEnd = dataEnd !== null && start > dataEnd + TIMING_TOLERANCE_SECONDS;
if (!beforeStart && !afterEnd) return null;
const elementId = el.getAttribute("id");
return {
@@ -183,7 +183,7 @@ export function timingMismatches(html: string): TimingMismatchReport {
const timelineEnd = Math.max(0, ...ends.filter((e): e is number => e !== null));
const rootDuration = attrNumber(root, "data-duration");
if (rootDuration !== null && timelineEnd > rootDuration + EPSILON) {
if (rootDuration !== null && timelineEnd > rootDuration + TIMING_TOLERANCE_SECONDS) {
report.findings.push({ kind: "timeline-exceeds-root-duration", rootDuration, timelineEnd });
}
return report;
+1 -1
View File
@@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
"hash": "bc03be27ef68003b",
"hash": "b632d12d11eb54e8",
"files": 11
},
"hyperframes-creative": {
+1
View File
@@ -86,6 +86,7 @@ Surfaced here; full rationale in the linked reference. Do not violate:
- To know what is on a project's timeline (tracks, clips, starts, ends, what plays), run `npx hyperframes timeline [--json]` instead of reading `index.html` and every sub-composition file.
- Match existing composition IDs and timeline keys.
- Adding a clip: set its `data-start`/`data-duration` intentionally against the clips around it. `data-track-index` is a Studio display lane, not a timing constraint, so it does not need to be free.
- A clip that ends past the root `data-duration` is cut off: extend the root `data-duration` to the clip's end in the same edit (`lint` warns `clip_ends_past_root_duration`).
- `data-hidden` on any composition element hides it in BOTH preview and render, overriding its time window; it is non-destructive/reversible and toggled by Studio's timeline eye icon.
- Adding a sub-composition: verify its internal `data-composition-id` before wiring the host.