Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/lint/src/rules/structure.catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
58 changes: 58 additions & 0 deletions packages/lint/src/rules/structure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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([]);
});
});
29 changes: 28 additions & 1 deletion packages/lint/src/rules/structure.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -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.
Expand All @@ -179,6 +205,7 @@ export const structureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]
...nestedStructureFindings(rows, severity),
...missingDurationFindings(rows, severity),
...captionFindings(rows, severity),
...clipsPastRootFindings(root, rows),
];
},
];
2 changes: 2 additions & 0 deletions packages/parsers/src/compositionDuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
8 changes: 4 additions & 4 deletions packages/parsers/src/timingMismatches.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
"hash": "bc03be27ef68003b",
"hash": "b632d12d11eb54e8",
"files": 11
},
"hyperframes-creative": {
Expand Down
1 change: 1 addition & 0 deletions skills/hyperframes-core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading