mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
];
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"files": 11
|
||||
},
|
||||
"hyperframes-core": {
|
||||
"hash": "010a65915fc7b45e",
|
||||
"hash": "66fc3e623a910923",
|
||||
"files": 11
|
||||
},
|
||||
"hyperframes-creative": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user