fix(cli): timeline lists clips nested in a timed element and lint flags ones that never show (#5732)

* fix(cli): timeline lists clips nested in a timed element and lint flags ones that never show

* docs(lint): wrap the nested-clip entries

* docs(skills): format the nested-clip note and refresh the skills manifest

* fix(cli): keep timeline edits from colliding with a clip's own wrapper

* fix(cli): cut clips nested in a wrapper at their sub-composition's in-point

* refactor(lint): split the nested-clip visibility checks to keep the audit green

* style(cli): restore the timeline test's formatting
This commit is contained in:
Miguel Ángel
2026-10-17 02:27:17 +00:00
committed by GitHub
parent 4e6ee6d230
commit eab6ca15b1
14 changed files with 226 additions and 19 deletions
+11 -1
View File
@@ -20,7 +20,8 @@ Severities:
Source: `packages/lint/src/rules/structure.ts`.
The four codes marked **Warning or error** are warnings when the CLI lints a project, and errors when Studio lints the file it is editing.
The five codes marked **Warning or error** are warnings when the CLI lints a project, and errors when Studio
lints the file it is editing.
### `nested_structure_needs_subcomposition`
@@ -52,6 +53,15 @@ The four codes marked **Warning or error** are warnings when the CLI lints a pro
**Fix:** Extend the root `data-duration`, or make the clip end at or before it.
### `nested_clip_never_visible`
**Warning or error.** A timed clip sits inside another timed element, and its time window never overlaps that
element's, so it never appears. A nested clip's `data-start` counts from the composition's start, not from the
element around it. Runs in sub-composition files too.
**Fix:** Give the clip a `data-start` inside its parent's window, in composition time, or move it out of the
parent.
## Composition contract
Source: `packages/lint/src/rules/composition.ts`.
@@ -20,6 +20,7 @@ function row(
host: null,
hostRow: null,
children: [],
parentRow: null,
trackIndex: 0,
src: "audio/a.wav",
sourceFile: null,
+15
View File
@@ -137,6 +137,19 @@ export function diff(before: string, after: string): string {
return output.length <= 8_000 ? output : `${output.slice(0, 7_997)}...`;
}
function isAncestorOf(ancestor: TimelineRow, row: TimelineRow, timeline: ProjectTimeline): boolean {
for (let pointer = row.parentRow; pointer; ) {
const parent = rowAt(timeline, pointer);
if (parent === ancestor) return true;
pointer = parent.parentRow;
}
return false;
}
function sameLineage(a: TimelineRow, b: TimelineRow, timeline: ProjectTimeline): boolean {
return isAncestorOf(a, b, timeline) || isAncestorOf(b, a, timeline);
}
function overlap(
row: TimelineRow,
timeline: ProjectTimeline,
@@ -146,6 +159,7 @@ function overlap(
return allRows(timeline).find(
(candidate) =>
candidate !== row &&
!sameLineage(candidate, row, timeline) &&
candidate.file === row.file &&
candidate.trackIndex === row.trackIndex &&
spansShareTime(start, end, candidate.start, candidate.end),
@@ -429,6 +443,7 @@ export function mutationConflict(
if (verb === "duplicate") {
const conflict = allRows(timeline).find(
(candidate) =>
!sameLineage(candidate, row, timeline) &&
candidate.file === row.file &&
candidate.trackIndex === row.trackIndex &&
isInsideSpan(nextStart, candidate.start, candidate.end),
@@ -18,12 +18,12 @@ const SCENE = `<template id="scene-template"><div data-composition-id="scene" da
beforeAll(ensureDOMParser);
/** The video rows of a project made of `index` and `compositions/scene.html`. */
async function videoRows(index: string, prefix: string) {
async function videoRows(index: string, prefix: string, scene = SCENE) {
const dir = mkdtempSync(join(tmpdir(), prefix));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), index);
writeFileSync(join(dir, "compositions", "scene.html"), SCENE);
writeFileSync(join(dir, "compositions", "scene.html"), scene);
const timeline = await describeProject(join(dir, "index.html"));
return timeline.tracks.find((t) => t.kind === "video")!.rows;
}
@@ -43,6 +43,17 @@ describe("a sub-composition's in-point", () => {
]);
});
it("cuts a clip nested in a timed wrapper like the wrapper's own clips", async () => {
const videos = await videoRows(
`<div data-composition-id="main" data-duration="9"><div data-playback-start="5" id="half" data-composition-src="compositions/scene.html" data-start="5" data-duration="4"></div></div>`,
"hf-timeline-inpoint-nested-",
`<div data-composition-id="scene" data-duration="12"><div id="w" data-start="0" data-duration="12">
<video id="n" src="../sample.mp4" muted data-start="4" data-duration="4" data-media-start="5"></video></div></div>`,
);
expect(videos.map((r) => [r.id, r.absStart, r.absEnd, r.mediaStart])).toEqual([["n", 5, 8, 6]]);
});
it("moves each cut clip's media in-point by the part the cut skips", async () => {
const videos = await videoRows(SPLIT_INDEX, "hf-timeline-inpoint-media-");
@@ -116,6 +116,44 @@ describe("describeProject", () => {
]);
});
it("lists clips nested in a timed wrapper at their playback time and says when one never shows", async () => {
const { rows, text } = await rowsOf(`<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="10">
<div id="shot2" data-start="5" data-duration="5" data-track-index="0">
<h1 id="seen" data-start="6" data-duration="2">In</h1>
<h1 id="lost" data-start="0" data-duration="2">Out</h1>
</div>
</div></body></html>`);
const byId = (id: string) => rows.find((r) => r.id === id)!;
const at = (p: { kind: string; index: number }) =>
rows.find((r) => r.trackKind === p.kind && r.index === p.index)!.id;
expect(byId("shot2").children.map(at).sort()).toEqual(["lost", "seen"]);
expect(byId("seen")).toMatchObject({ absStart: 6, absEnd: 8, warnings: [], nested: false });
expect(at(byId("seen").parentRow!)).toBe("shot2");
expect(byId("lost").warnings).toEqual([
"never visible: plays 0-2s, but #shot2 only shows 5-10s; a nested data-start counts from the composition, not from #shot2",
]);
expect(text).toContain("#lost lost 0-2s warning: never visible");
expect(text).not.toContain("no stable name");
});
it("lists a sub-composition mounted inside a timed wrapper with every clip it holds", async () => {
const { rows } = await rowsOf(`<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="10">
<div id="shot" data-start="0" data-duration="5" data-track-index="0">
<div id="scene" data-composition-src="compositions/title.html" data-start="0" data-duration="5"></div>
</div>
</div></body></html>`);
const childIds = (id: string) =>
rows
.find((r) => r.id === id)!
.children.map((p) => rows.find((r) => r.trackKind === p.kind && r.index === p.index)?.id);
expect(childIds("shot")).toEqual(["scene"]);
expect(childIds("scene")).toEqual(["t1", "t2"]);
});
it("reports each half's out-of-sync offset from its source pair, at the project fps", async () => {
const { rows, text } = await rowsOf(`<html><body>
<div data-composition-id="main" data-duration="10" data-fps="24">
+46 -13
View File
@@ -19,6 +19,7 @@ import {
type MediaTag,
} from "@hyperframes/parsers/media-duration";
import {
isSubCompositionHost,
topLevelElements,
trackKindOf,
type StructureNode,
@@ -67,8 +68,10 @@ export interface TimelineRow extends ClipFact {
host: string | null;
/** Where that host row is (its kind's `rows[index]`), for hosts whose id is missing or repeated. */
hostRow: RowPointer | null;
/** Where this row's sub-composition clips are (one level only); each is a full row in its kind's `rows`. */
/** Rows directly inside this one: its sub-composition's clips, or timed elements nested in it. */
children: RowPointer[];
/** The timed row in the same file that contains this clip; `null` at a file's top level. */
parentRow: RowPointer | null;
/** Signed frames from its `data-sync-origin` partner (+ = late); `null` unpaired or rates differ. */
syncOffsetFrames: number | null;
mediaStart: number | null;
@@ -80,10 +83,14 @@ export interface RowPointer {
}
/** A row before flattening: children are still row objects and refs are not assigned. */
type ClipDraft = Omit<TimelineRow, "index" | "nested" | "host" | "hostRow" | "children"> & {
type ClipDraft = Omit<
TimelineRow,
"index" | "nested" | "host" | "hostRow" | "children" | "parentRow"
> & {
elementId: string | null;
hfId: string | null;
children: ClipDraft[];
inside: ClipDraft[];
};
export interface TimelineTrack {
@@ -276,6 +283,7 @@ interface RowTiming {
authored: number | null;
absStart: number;
children: ClipDraft[];
inside: ClipDraft[];
duration: DurationResolution;
}
@@ -291,11 +299,14 @@ async function resolveRowTiming(scope: DocScope, node: DomNode, depth: number):
host && depth === 0
? await readSubComposition(host, scope, compositionOriginSeconds(absStart, el), slots)
: [];
const inside = isSubCompositionHost(node)
? []
: await Promise.all(topLevelElements(node).map((inner) => describeRow(scope, inner, depth)));
const kind = el.tagName.toLowerCase();
const duration = MEDIA_TAG.test(kind)
? await resolveMediaRowDuration(scope, el, kind as MediaTag, authored)
: resolveContainerDuration(authored, children);
return { start, authored, absStart, children, duration };
return { start, authored, absStart, children, inside, duration };
}
function describeRowFields(scope: DocScope, node: DomNode, timing: RowTiming): ClipDraft {
@@ -326,6 +337,7 @@ function describeRowFields(scope: DocScope, node: DomNode, timing: RowTiming): C
durationSource: timing.duration.durationSource,
pendingReason: timing.duration.pendingReason,
children: timing.children,
inside: timing.inside,
ref: "",
warnings: [],
elementId: el.id || null,
@@ -384,7 +396,8 @@ function cutRow(row: ClipDraft, slots: readonly HostSlot[]): ClipDraft[] {
if (kept.end === kept.start && row.absEnd > row.absStart) return [];
const skipped = (kept.start - row.absStart) * (row.playbackRate ?? 1);
const mediaStart = row.mediaStart === null ? null : roundMs(row.mediaStart + skipped);
return [{ ...row, absStart: roundMs(kept.start), absEnd: roundMs(kept.end), mediaStart }];
const inside = row.inside.flatMap((inner) => cutRow(inner, slots));
return [{ ...row, absStart: roundMs(kept.start), absEnd: roundMs(kept.end), mediaStart, inside }];
}
/** The file's real path when it is a regular file inside the project (symlinks resolved), else null. */
@@ -401,10 +414,14 @@ function realFileInside(projectDir: string, path: string): string | null {
/** One flat, `absStart`-ordered list per kind holding every clip once; `children` become refs into those lists. */
function flatten(top: ClipDraft[]): TimelineTrack[] {
const entries = [
...top.map((clip) => ({ clip, host: null })),
...top.flatMap((host) => host.children.map((clip) => ({ clip, host }))),
];
type Entry = { clip: ClipDraft; host: ClipDraft | null; parent: ClipDraft | null };
const entries: Entry[] = [];
const add = (clip: ClipDraft, host: ClipDraft | null, parent: ClipDraft | null) => {
entries.push({ clip, host, parent });
for (const inner of clip.inside) add(inner, host, clip);
for (const child of clip.children) add(child, clip, null);
};
for (const clip of top) add(clip, null, null);
const byKind = TRACK_ORDER.map((kind) => ({
kind,
entries: entries
@@ -426,19 +443,21 @@ function flatten(top: ClipDraft[]): TimelineTrack[] {
}
return byKind.map(({ kind, entries: kindEntries }) => ({
kind,
rows: kindEntries.map(({ clip, host }, index) => {
rows: kindEntries.map(({ clip, host, parent }, index) => {
const uniqueId = clip.elementId && fileIds.get(clip.file)?.get(clip.elementId) === 1;
const ref = uniqueId
? `#${clip.elementId}`
: clip.hfId
? `hf:${clip.hfId}`
: `${kind}/${index}`;
const warnings =
uniqueId || clip.hfId
const warnings = [
...(uniqueId || clip.hfId
? []
: [
'row has no stable name; add id="<name>" to the element in this file, then retry with #<name>',
];
]),
...neverVisibleWarning(clip, parent),
];
return {
...clip,
index,
@@ -447,12 +466,26 @@ function flatten(top: ClipDraft[]): TimelineTrack[] {
nested: host !== null,
host: host && host.id,
hostRow: host && at(host),
children: clip.children.map(at),
children: [...clip.children, ...clip.inside].map(at),
parentRow: parent && at(parent),
};
}),
}));
}
/** The runtime shows a nested clip only inside its parent's window, and its data-start counts from the composition. */
export const NEVER_VISIBLE = "never visible";
function neverVisibleWarning(clip: ClipDraft, parent: ClipDraft | null): string[] {
if (!parent || clip.kind === "audio") return [];
if (clip.absEnd <= clip.absStart || parent.absEnd <= parent.absStart) return [];
if (clip.absEnd > parent.absStart && clip.absStart < parent.absEnd) return [];
const name = parent.elementId ? `#${parent.elementId}` : `its parent <${parent.kind}>`;
return [
`${NEVER_VISIBLE}: plays ${clip.absStart}-${clip.absEnd}s, but ${name} only shows ${parent.absStart}-${parent.absEnd}s; a nested data-start counts from the composition, not from ${name}`,
];
}
function projectFps(source: string): number {
const parsed = parseFpsWithDefault(readCompositionFps(source) ?? undefined);
return fpsToNumber(parsed.ok ? parsed.value : { num: 30, den: 1 });
+8 -2
View File
@@ -1,4 +1,9 @@
import type { ProjectTimeline, TimelineRow, TimelineTrack } from "./describeProject.js";
import {
NEVER_VISIBLE,
type ProjectTimeline,
type TimelineRow,
type TimelineTrack,
} from "./describeProject.js";
const BAR_WIDTH = 40;
const n = (v: number) => String(Math.round(v * 1000) / 1000);
@@ -48,6 +53,7 @@ function details(row: TimelineRow): string {
row.sourceFile && !row.children.length && "children=unread",
row.laneError && `lanes unreadable: ${row.laneError}`,
...lanes,
...row.warnings.filter((w) => w.startsWith(NEVER_VISIBLE)).map((w) => `warning: ${w}`),
]
.filter(Boolean)
.join(" ");
@@ -63,7 +69,7 @@ function line(row: TimelineRow, total: number): string {
}
function header({ kind, rows }: TimelineTrack): string {
const nested = rows.filter((r) => r.nested).length;
const nested = rows.filter((r) => r.nested || r.parentRow).length;
const split = nested ? `: ${rows.length - nested} top-level, ${nested} nested` : "";
return `${kind} (${rows.length}${split})`;
}
@@ -20,6 +20,7 @@ const row: TimelineRow = {
host: null,
hostRow: null,
children: [],
parentRow: null,
trackIndex: 0,
src: null,
sourceFile: null,
@@ -248,6 +248,27 @@ describe("timeline edit command", () => {
};
const endOf = (html: string, id: string) =>
Number(clip(html, id).start) + Number(clip(html, id).duration);
it.each([
["moves a hidden clip into its wrapper's window", ["move", "#lost", "8"], "lost", "8"],
["moves a wrapper without colliding with its own clips", ["move", "#shot2", "4"], "shot2", "4"],
["duplicates a clip inside its wrapper", ["duplicate", "#seen"], "seen", "6"],
])("%s", (_name, args, id, start) => {
const dir = mkdtempSync(join(tmpdir(), "hf-timeline-nested-"));
writeFileSync(
join(dir, "index.html"),
`<div data-composition-id="main" data-duration="12"><div id="shot2" data-hf-id="shot2" data-start="5" data-duration="5" data-track-index="0"><div id="seen" data-hf-id="seen" data-start="6" data-duration="2">In</div><div id="lost" data-hf-id="lost" data-start="0" data-duration="2">Out</div></div></div>`,
);
try {
const result = run(dir, ...args);
expect(result.stderr).not.toContain("overlap");
expect(result.status).toBe(0);
expect(clip(readFileSync(join(dir, "index.html"), "utf8"), id).start).toBe(start);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
const visibleAt = (html: string, time: number, ids: string[]) =>
ids.filter((id) => isClipVisibleAt(time, Number(clip(html, id).start), endOf(html, id), 40));
@@ -10,6 +10,7 @@ const STRUCTURE_CODES = new Set([
"caption_track_kind_missing",
"multiple_caption_tracks",
"clip_ends_past_root_duration",
"nested_clip_never_visible",
]);
function htmlFiles(dir: string): string[] {
+24
View File
@@ -15,10 +15,34 @@ const STRUCTURE = new Set([
"caption_track_kind_missing",
"multiple_caption_tracks",
"clip_ends_past_root_duration",
"nested_clip_never_visible",
]);
const has = (found: Awaited<ReturnType<typeof codes>>, code: string) =>
found.some((f) => f.code === code);
describe("nested_clip_never_visible", () => {
const shot = (clipStart: number) =>
`<div id="shot2" class="clip" data-start="5" data-duration="5"><video id="v1" class="clip" src="a.mp4" data-start="${clipStart}" data-duration="2"></video></div>`;
const neverVisible = (found: Awaited<ReturnType<typeof codes>>) =>
found.filter((f) => f.code === "nested_clip_never_visible");
it("flags a nested clip whose window misses its parent's", async () => {
const [finding] = neverVisible(await codes(shot(0)));
expect(finding).toMatchObject({ elementId: "v1", severity: "warning" });
expect(finding?.message).toContain("only shows from 5s to 10s");
});
it("leaves a nested clip that plays inside its parent's window", async () => {
expect(neverVisible(await codes(shot(6)))).toEqual([]);
});
it("leaves nested audio alone: it plays whether or not its parent shows", async () => {
const body = `<div id="shot2" class="clip" data-start="5" data-duration="5"><audio id="a1" class="clip" src="a.mp3" data-start="0" data-duration="2"></audio></div>`;
expect(neverVisible(await codes(body))).toEqual([]);
});
it("checks sub-composition files too", async () => {
expect(neverVisible(await codes(shot(0), { isSubComposition: true }))).toHaveLength(1);
});
});
describe("nested_structure_needs_subcomposition", () => {
it("flags a timed div that contains another div", async () => {
const found = await codes(
+43
View File
@@ -61,6 +61,7 @@ const NODE_ATTRS = [
"data-track-kind",
"data-composition-id",
"data-composition-src",
"data-hf-media-start-basis",
];
function toNode(open: OpenTag): TagNode {
@@ -193,6 +194,40 @@ function clipsPastRootFindings(root: TagNode, rows: TagNode[]): HyperframeLintFi
});
}
function followsParentVisibility(clip: TagNode): boolean {
return clip.tag !== "audio" && clip.attrs["data-hf-media-start-basis"] !== "global";
}
function isTimed(timing: {
start: number | null;
end: number | null;
}): timing is { start: number; end: number } {
return timing.start !== null && timing.end !== null;
}
/** The runtime shows a nested clip only while its parent shows, and both data-starts count from the composition. */
function neverVisibleNestedFindings(parent: TagNode, severity: Severity): HyperframeLintFinding[] {
const window = readClipTiming({ getAttribute: (name) => parent.attrs[name] ?? null });
return topLevelElements(parent).flatMap((clip) => {
const inner = isSubCompositionHost(clip) ? [] : neverVisibleNestedFindings(clip, severity);
const own = readClipTiming({ getAttribute: (name) => clip.attrs[name] ?? null });
if (!followsParentVisibility(clip) || !isTimed(window) || !isTimed(own)) return inner;
if (own.end > window.start && own.start < window.end) return inner;
const shows = `${hundredths(window.start)}s to ${hundredths(window.end)}s`;
return [
{
code: "nested_clip_never_visible",
severity,
message: `${describe(clip)} plays from ${hundredths(own.start)}s to ${hundredths(own.end)}s, but it sits inside ${describe(parent)}, which only shows from ${shows}, so it never appears. A nested data-start counts from the composition's start, not from ${describe(parent)}.`,
elementId: clip.attrs.id,
fixHint: `Give ${describe(clip)} a data-start between ${shows} in composition time, or move it out of ${describe(parent)}.`,
snippet: truncateSnippet(clip.open.raw),
},
...inner,
];
});
}
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.
@@ -208,4 +243,12 @@ export const structureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]
...clipsPastRootFindings(root, rows),
];
},
(ctx) => {
const root = buildTree(ctx);
if (!root) return [];
const severity = ctx.options.host === "studio" ? "error" : "warning";
return topLevelElements(root)
.filter((row) => !isSubCompositionHost(row))
.flatMap((row) => neverVisibleNestedFindings(row, severity));
},
];
+1 -1
View File
@@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
"hash": "010a65915fc7b45e",
"hash": "66fc3e623a910923",
"files": 11
},
"hyperframes-creative": {
+3
View File
@@ -87,6 +87,9 @@ Surfaced here; full rationale in the linked reference. Do not violate:
- 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`).
- A clip nested inside another timed element counts its `data-start` from the composition, not from that
element, and shows only while that element shows. `timeline` lists it under its parent and warns
`never visible` when the windows miss (`lint` reports `nested_clip_never_visible`).
- `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.