mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(lint): warn when a clip ends past the root composition duration (#4633)
This commit is contained in:
@@ -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[] {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"files": 11
|
||||
},
|
||||
"hyperframes-core": {
|
||||
"hash": "bc03be27ef68003b",
|
||||
"hash": "b632d12d11eb54e8",
|
||||
"files": 11
|
||||
},
|
||||
"hyperframes-creative": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user