fix(cli): timeline lists every clip of a kind in one flat list, nested ones included (#4173)

Text: one absolute-start list per kind, nested clips marked with host and file.

JSON: a track's rows hold every clip of that kind; children are index pointers, not copies.
This commit is contained in:
Miguel Ángel
2026-09-19 10:36:30 -04:00
committed by GitHub
parent f4e00ce2b5
commit ac0d8ced2a
5 changed files with 336 additions and 81 deletions
+250 -46
View File
@@ -14,7 +14,13 @@ import { MEDIA_DURATION_FIXTURES } from "@hyperframes/parsers/media-duration-fix
import { fileURLToPath } from "node:url";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
import { createProbeGate, describeProject, type MeasureMedia } from "./describeProject.js";
import {
createProbeGate,
describeProject,
type MeasureMedia,
type ProjectTimeline,
type TimelineRow,
} from "./describeProject.js";
import { formatTimeline } from "./formatTimeline.js";
const REAL_AUDIO = fileURLToPath(
@@ -36,6 +42,10 @@ const INDEX = `<html><body>
const TITLE = `<template><div data-composition-id="title"><h1 id="t1" data-start="0" data-duration="2">Hi</h1><h2 id="t2" data-start="t1 + 0.5" data-duration="1">There</h2></div></template>`;
const allRows = (t: ProjectTimeline) => t.tracks.flatMap((x) => x.rows);
const childrenOf = (t: ProjectTimeline, row: TimelineRow) =>
allRows(t).filter((r) => row.children.some((c) => c.kind === r.trackKind && c.index === r.index));
let dir = "";
const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
@@ -73,7 +83,7 @@ describe("describeProject", () => {
expect(timeline.duration).toBe(10);
expect(timeline.tracks.map((t) => [t.kind, t.rows.map((r) => r.id)])).toEqual([
["video", ["a-roll"]],
["graphics", ["logo", "title"]],
["graphics", ["logo", "title", "t1", "t2"]],
["audio", ["vo", "bad"]],
]);
const [video] = timeline.tracks[0]!.rows;
@@ -94,10 +104,9 @@ describe("describeProject", () => {
});
it("nests a sub-composition's clips one level down with local times", async () => {
const title = (await describeProject(project())).tracks
.flatMap((t) => t.rows)
.find((r) => r.id === "title")!;
expect(title.children.map((c) => [c.id, c.start, c.end])).toEqual([
const timeline = await describeProject(project());
const title = allRows(timeline).find((r) => r.id === "title")!;
expect(childrenOf(timeline, title).map((c) => [c.id, c.start, c.end])).toEqual([
["t1", 0, 2],
["t2", 2.5, 3.5],
]);
@@ -328,10 +337,10 @@ const inversionProject = () => {
describe("absolute main-timeline time", () => {
it("gives a nested clip an absolute start smaller than a later-declared direct clip's, plus the owning file", async () => {
const rows = (await describeProject(inversionProject())).tracks.flatMap((t) => t.rows);
const rows = allRows(await describeProject(inversionProject()));
const direct = rows.find((r) => r.id === "direct")!;
const host = rows.find((r) => r.id === "host")!;
const nested = host.children.find((c) => c.id === "nested")!;
const nested = rows.find((r) => r.id === "nested")!;
// Hand-computed: host starts at 5, nested is 1s into it, so nested's
// absolute start is 5 + 1 = 6, smaller than direct's 20.
@@ -351,6 +360,12 @@ describe("absolute main-timeline time", () => {
file: "compositions/scene.html",
});
expect(nested.absStart).toBeLessThan(direct.absStart);
expect(nested).toMatchObject({
nested: true,
host: "host",
hostRow: { kind: "graphics", index: host.index },
});
expect(host.children).toEqual([{ kind: "video", index: nested.index }]);
});
it("places a nested media clip with a negative start where the runtime plays it", async () => {
@@ -361,11 +376,9 @@ describe("absolute main-timeline time", () => {
join(dir, "compositions", "scene.html"),
INVERSION_SCENE.replace('data-start="1"', 'data-start="-3"'),
);
const host = (await describeProject(join(dir, "index.html"))).tracks
.flatMap((t) => t.rows)
.find((r) => r.id === "host")!;
const rows = allRows(await describeProject(join(dir, "index.html")));
// Host starts at 5 and the runtime adds the raw -3: it plays at 2, not at the clamped 5.
expect(host.children.find((c) => c.id === "nested")).toMatchObject({ absStart: 2, absEnd: 4 });
expect(rows.find((r) => r.id === "nested")).toMatchObject({ absStart: 2, absEnd: 4 });
});
it("resolves a media start given as an expression like any other clip, not as a literal", async () => {
@@ -379,11 +392,9 @@ describe("absolute main-timeline time", () => {
`</video><video id="after" src="a.mp4" data-start="nested + 1" data-duration="1" data-track-index="1"></video>`,
),
);
const host = (await describeProject(join(dir, "index.html"))).tracks
.flatMap((t) => t.rows)
.find((r) => r.id === "host")!;
const rows = allRows(await describeProject(join(dir, "index.html")));
// nested ends at local 3, so "nested + 1" is local 4; the host at 5 puts it at 9 on the main timeline.
expect(host.children.find((c) => c.id === "after")).toMatchObject({ start: 4, absStart: 9 });
expect(rows.find((r) => r.id === "after")).toMatchObject({ start: 4, absStart: 9 });
});
it("clamps a negative media start to 0 when the host starts at 0, as the runtime does", async () => {
@@ -418,9 +429,10 @@ describe("absolute main-timeline time", () => {
join(dir, "compositions", "b.html"),
`<template><div data-composition-id="b"><audio id="sfx" src="s.mp3" data-start="0" data-duration="0.4" data-track-index="0"></audio></div></template>`,
);
const rows = (await describeProject(join(dir, "index.html"))).tracks.flatMap((t) => t.rows);
const clip = rows.find((r) => r.id === "a")!.children[0]!;
const sfx = rows.find((r) => r.id === "b")!.children[0]!;
const timeline = await describeProject(join(dir, "index.html"));
const rows = allRows(timeline);
const clip = childrenOf(timeline, rows.find((r) => r.id === "a")!)[0]!;
const sfx = childrenOf(timeline, rows.find((r) => r.id === "b")!)[0]!;
expect(clip).toMatchObject({ absStart: 0, absEnd: 5.27 });
expect(sfx).toMatchObject({ absStart: 5.2, absEnd: 5.6 });
expect(sfx.absStart).toBeLessThan(clip.absEnd);
@@ -447,8 +459,9 @@ describe("absolute main-timeline time", () => {
join(dir, "compositions", "second.html"),
`<template><div data-composition-id="second"><video id="v2" src="v2.mp4" data-start="0" data-duration="5" data-track-index="0"></video></div></template>`,
);
const rows = (await describeProject(join(dir, "index.html"))).tracks.flatMap((t) => t.rows);
const videos = rows.flatMap((r) => r.children).filter((c) => c.kind === "video");
const videos = allRows(await describeProject(join(dir, "index.html"))).filter(
(r) => r.nested && r.kind === "video",
);
const byAbsStart = [...videos].sort((a, b) => a.absStart - b.absStart);
expect(byAbsStart.map((v) => v.id)).toEqual(["v1", "v2"]);
expect(byAbsStart[1]).toMatchObject({ id: "v2", absStart: 6 });
@@ -457,7 +470,7 @@ describe("absolute main-timeline time", () => {
it("prints the absolute time first and the local time in parentheses for a nested row", async () => {
const text = formatTimeline(await describeProject(inversionProject()));
expect(text).toContain("direct 20-25s");
expect(text).toContain("nested 6-8s (local 1-3s) in compositions/scene.html");
expect(text).toContain("nested 6-8s (local 1-3s) nested in host compositions/scene.html");
});
});
@@ -468,10 +481,11 @@ describe("formatTimeline", () => {
join(dir, "compositions", "title.html"),
`<template><div data-composition-id="t"><video id="v" src="v.mp4" data-start="0" data-duration="1" data-playback-rate="1"></video><div id="deep" data-composition-src="title.html" data-start="0" data-duration="1"></div></div></template>`,
);
const title = (await describeProject(index)).tracks
.flatMap((t) => t.rows)
.find((r) => r.id === "title")!;
expect(title.children.map((c) => [c.id, c.playbackRate, c.children.length])).toEqual([
const timeline = await describeProject(index);
const title = allRows(timeline).find((r) => r.id === "title")!;
expect(
childrenOf(timeline, title).map((c) => [c.id, c.playbackRate, c.children.length]),
).toEqual([
["v", null, 0],
["deep", null, 0],
]);
@@ -492,6 +506,7 @@ describe("formatTimeline", () => {
const text = formatTimeline(await describeProject(project()));
expect(text).toMatch(/^timeline 10s\n\nvideo \(1\)\n \|█{16}/);
expect(text).toContain("audio (2)");
expect(text).toContain("graphics (4: 2 top-level, 2 nested)");
expect(text).toContain("vol=0.5 group=vo volume[0:0.2 2:1]");
expect(text).toContain("rate=2");
});
@@ -502,12 +517,151 @@ const SKILL_DOC = join(
"../../../../skills/hyperframes-cli/references/upgrade-info-misc.md",
);
const EVAL_INDEX = `<div data-composition-id="main" data-duration="40">
<video id="v-replay1" src="r1.mp4" data-start="16.05" data-duration="4" data-track-index="0"></video>
<video id="v-replay2" src="r2.mp4" data-start="24.09" data-duration="4" data-track-index="0"></video>
<div id="sec-sfx" data-composition-src="compositions/terminal-sfx.html" data-start="10" data-duration="10" data-track-index="1"></div>
<div id="sec-music" data-composition-src="compositions/terminal-music.html" data-start="12" data-duration="10" data-track-index="2"></div>
</div>`;
const EVAL_SFX = `<template><div data-composition-id="terminal-sfx">
<video id="tsfx-pet1" src="p1.mp4" data-start="0.4" data-duration="1" data-track-index="0"></video>
<video id="tsfx-pet2" src="p2.mp4" data-start="5.67" data-duration="1" data-track-index="0"></video>
<audio id="tsfx-hit" src="h.mp3" data-start="1" data-duration="1" data-track-index="1"></audio>
</div></template>`;
const EVAL_MUSIC = `<template><div data-composition-id="terminal-music">
<video id="tmus-pet1" src="m1.mp4" data-start="1" data-duration="1" data-track-index="0"></video>
<video id="tmus-pet2" src="m2.mp4" data-start="2.5" data-duration="1" data-track-index="0"></video>
</div></template>`;
const evalTimeline = () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-eval-"));
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), EVAL_INDEX);
writeFileSync(join(dir, "compositions", "terminal-sfx.html"), EVAL_SFX);
writeFileSync(join(dir, "compositions", "terminal-music.html"), EVAL_MUSIC);
return describeProject(join(dir, "index.html"));
};
/** Ids, absent ids, and same ids across kinds and hosts; every clip has a distinct src. */
const collidingTimeline = () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-collide-"));
mkdirSync(join(dir, "compositions"));
writeFileSync(
join(dir, "index.html"),
`<div data-composition-id="main" data-duration="30">
<video src="a.mp4" data-start="1" data-duration="2" data-track-index="0"></video>
<video src="b.mp4" data-start="9" data-duration="2" data-track-index="0"></video>
<div id="h" data-composition-src="compositions/s1.html" data-start="3" data-duration="3" data-track-index="1"></div>
<div id="h" data-composition-src="compositions/s2.html" data-start="12" data-duration="3" data-track-index="2"></div>
<video id="x" src="x.mp4" data-start="20" data-duration="2" data-track-index="3"></video>
<audio id="x" src="x.mp3" data-start="22" data-duration="2" data-track-index="4"></audio>
</div>`,
);
const sub = (src: string) =>
`<template><div data-composition-id="s"><video id="c" src="${src}" data-start="0" data-duration="1" data-track-index="0"></video></div></template>`;
writeFileSync(join(dir, "compositions", "s1.html"), sub("c1.mp4"));
writeFileSync(join(dir, "compositions", "s2.html"), sub("c2.mp4"));
return describeProject(join(dir, "index.html"));
};
describe("kind tracks list every clip once", () => {
it("counts nested videos in the header and lists them in absolute order with host and file", async () => {
const timeline = await evalTimeline();
const text = formatTimeline(timeline);
// Hand-computed: 2 top-level + 4 nested = 6; nested abs = host start (10 or 12) + local start.
expect(text).toContain("video (6: 2 top-level, 4 nested)");
const video = text.slice(text.indexOf("video ("), text.indexOf("graphics"));
const pattern =
/(v-replay|tsfx-pet|tmus-pet)\d [\d.]+-[\d.]+s( \(local [^)]*\) nested in \S+ \S+)?/g;
expect(video.match(pattern)).toEqual([
"tsfx-pet1 10.4-11.4s (local 0.4-1.4s) nested in sec-sfx compositions/terminal-sfx.html",
"tmus-pet1 13-14s (local 1-2s) nested in sec-music compositions/terminal-music.html",
"tmus-pet2 14.5-15.5s (local 2.5-3.5s) nested in sec-music compositions/terminal-music.html",
"tsfx-pet2 15.67-16.67s (local 5.67-6.67s) nested in sec-sfx compositions/terminal-sfx.html",
"v-replay1 16.05-20.05s",
"v-replay2 24.09-28.09s",
]);
expect(text).toContain("audio (1: 0 top-level, 1 nested)");
});
it("gives JSON the complete flat rows, with children as refs to rows and no copies", async () => {
const timeline = JSON.parse(JSON.stringify(await evalTimeline())) as ProjectTimeline;
const video = timeline.tracks.find((t) => t.kind === "video")!;
expect(video.rows.map((r) => [r.index, r.id, r.nested, r.host])).toEqual([
[0, "tsfx-pet1", true, "sec-sfx"],
[1, "tmus-pet1", true, "sec-music"],
[2, "tmus-pet2", true, "sec-music"],
[3, "tsfx-pet2", true, "sec-sfx"],
[4, "v-replay1", false, null],
[5, "v-replay2", false, null],
]);
const graphics = timeline.tracks.find((t) => t.kind === "graphics")!;
const sfx = graphics.rows.find((r) => r.id === "sec-sfx")!;
expect(sfx.children).toEqual([
{ kind: "video", index: 0 },
{ kind: "audio", index: 0 },
{ kind: "video", index: 3 },
]);
expect(video.rows[0]!.hostRow).toEqual({ kind: "graphics", index: sfx.index });
expect(graphics.rows[sfx.index]).toBe(sfx);
expect(
allRows(timeline).every((r) => r.children.every((c) => typeof c.index === "number")),
).toBe(true);
});
it("prints and indexes each clip once when ids are missing, repeated, or shared across kinds", async () => {
const timeline = await collidingTimeline();
const text = formatTimeline(timeline);
// Hand-computed absolute starts: a 1, c1 3+0, b 9, c2 12+0, x.mp4 20.
const videos = ["a.mp4", "c1.mp4", "b.mp4", "c2.mp4", "x.mp4"];
expect(text).toContain("video (5: 3 top-level, 2 nested)");
expect(text.match(/src=\S+\.mp4/g)).toEqual(videos.map((v) => `src=${v}`));
expect(text.match(/src=x\.mp3/g)).toHaveLength(1);
const rows = allRows(timeline);
expect(timeline.tracks.find((t) => t.kind === "video")!.rows.map((r) => r.src)).toEqual(videos);
for (const row of rows) {
const pointers = [...row.children, ...(row.hostRow ? [row.hostRow] : [])];
for (const { kind, index } of pointers) {
const track = timeline.tracks.find((t) => t.kind === kind)!;
expect(track.rows[index]!.index).toBe(index);
}
}
for (const track of timeline.tracks) {
expect(track.rows.map((r) => r.index)).toEqual(track.rows.map((_, i) => i));
}
const [h1, h2] = rows.filter((r) => r.id === "h");
expect(childrenOf(timeline, h1!).map((c) => c.src)).toEqual(["c1.mp4"]);
expect(childrenOf(timeline, h2!).map((c) => c.src)).toEqual(["c2.mp4"]);
});
it("does not count a sub-composition inside a sub-composition", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-timeline-deep-"));
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), EVAL_INDEX);
writeFileSync(
join(dir, "compositions", "terminal-sfx.html"),
`<template><div data-composition-id="terminal-sfx"><div id="inner" data-composition-src="compositions/terminal-music.html" data-start="0" data-duration="3" data-track-index="0"></div></div></template>`,
);
writeFileSync(join(dir, "compositions", "terminal-music.html"), EVAL_MUSIC);
const timeline = await describeProject(join(dir, "index.html"));
const video = timeline.tracks.find((t) => t.kind === "video")!;
expect(video.rows.map((r) => r.id)).toEqual([
"tmus-pet1",
"tmus-pet2",
"v-replay1",
"v-replay2",
]);
expect(formatTimeline(timeline)).toContain("children=unread");
});
});
// The documented one-liners run verbatim; only the example query values are swapped for the fixture's.
const oneLiners = (kind: "jq" | "node -e"): string[] =>
const docLines = (kind: "jq" | "node -e"): string[] =>
readFileSync(SKILL_DOC, "utf8")
.split("\n")
.filter((l) => l.startsWith(`${kind} `) && l.endsWith('<<<"$TL"'))
.map((l) => l.replace("12.5", "7").replace("tsfx-pet2", "nested"));
.filter((l) => l.startsWith(`${kind} `) && l.endsWith('<<<"$TL"'));
const oneLiners = (kind: "jq" | "node -e"): string[] =>
docLines(kind).map((l) => l.replace("12.5", "7").replace("tsfx-pet2", "nested"));
const hasJq = (() => {
try {
@@ -518,44 +672,94 @@ const hasJq = (() => {
}
})();
const runOneLiner = async (line: string): Promise<string> => {
const own = inversionProject();
const runOneLiner = async (line: string, timeline?: ProjectTimeline): Promise<string> => {
const own = timeline ? null : inversionProject();
try {
return execFileSync("bash", ["-c", line], {
env: { ...process.env, TL: JSON.stringify({ timeline: await describeProject(own) }) },
env: {
...process.env,
TL: JSON.stringify({ timeline: timeline ?? (await describeProject(own!)) }),
},
encoding: "utf8",
});
} finally {
rmSync(dirname(own), { recursive: true, force: true });
if (own) rmSync(dirname(own), { recursive: true, force: true });
}
};
const parseStream = (out: string) => JSON.parse(`[${out.replace(/}\s*{/g, "},{")}]`);
describe("skill query one-liners", () => {
it("documents four node one-liners, each answering from the fixture", async () => {
it("documents five node one-liners, each answering from the fixture", async () => {
const lines = oneLiners("node -e");
expect(lines).toHaveLength(4);
const [at = "", find, track, gaps] = await Promise.all(lines.map(runOneLiner));
expect(lines).toHaveLength(5);
const [at = "", find, track, gaps, nth] = await Promise.all(lines.map((l) => runOneLiner(l)));
expect(at.split("\n").filter(Boolean)).toEqual([
"host index.html",
"nested compositions/scene.html",
"host index.html",
]);
expect(find).toBe("compositions/scene.html video 6 8\n");
expect(track).toBe("direct 20 25\n");
expect(gaps).toBe("");
expect(track).toBe("nested 6 8\ndirect 20 25\n");
expect(gaps).toBe("nested direct 12\n");
expect(nth).toBe("1 direct 20 index.html\n");
});
it.skipIf(!hasJq)("documents four jq one-liners that agree with the node ones", async () => {
it.skipIf(!hasJq)("documents five jq one-liners that agree with the node ones", async () => {
const lines = oneLiners("jq");
expect(lines).toHaveLength(4);
const [at, find, track, gaps] = (await Promise.all(lines.map(runOneLiner))).map((out) =>
JSON.parse(`[${out.replace(/}\s*{/g, "},{")}]`),
expect(lines).toHaveLength(5);
const [at, find, track, gaps, nth] = (await Promise.all(lines.map((l) => runOneLiner(l)))).map(
parseStream,
);
expect(at[0].map((r: { id: string }) => r.id)).toEqual(["host", "nested"]);
expect(at[0].map((r: { id: string }) => r.id)).toEqual(["nested", "host"]);
expect(find).toEqual([
{ file: "compositions/scene.html", trackKind: "video", absStart: 6, absEnd: 8 },
]);
expect(track).toEqual([{ id: "direct", absStart: 20, absEnd: 25 }]);
expect(gaps[0]).toEqual([]);
expect(track).toEqual([
{ id: "nested", absStart: 6, absEnd: 8 },
{ id: "direct", absStart: 20, absEnd: 25 },
]);
expect(gaps[0]).toEqual([{ a: "nested", b: "direct", delta: 12 }]);
expect(nth).toEqual([
{ index: 1, id: "direct", absStart: 20, file: "index.html", nested: false },
]);
});
it("answers the eval questions exactly on the eval fixture, counting no clip twice", async () => {
const timeline = await evalTimeline();
const ids = ["tsfx-pet1", "tmus-pet1", "tmus-pet2", "tsfx-pet2", "v-replay1", "v-replay2"];
const run = (l: string) => runOneLiner(l.replace("12.5", "15.7"), timeline);
const [at = "", find = "", track = "", , nth = ""] = await Promise.all(
docLines("node -e").map(run),
);
expect(at.split("\n").filter(Boolean)).toEqual([
"tsfx-pet2 compositions/terminal-sfx.html",
"sec-sfx index.html",
"sec-music index.html",
]);
expect(find).toBe("compositions/terminal-sfx.html video 15.67 16.67\n");
expect(
track
.split("\n")
.filter(Boolean)
.map((l) => l.split(" ")[0]),
).toEqual(ids);
expect(nth).toBe("1 tmus-pet1 13 compositions/terminal-music.html\n");
if (!hasJq) return;
const [jqAt = "", , jqTrack = "", , jqNth = ""] = await Promise.all(docLines("jq").map(run));
expect(parseStream(jqAt)[0].map((r: { id: string }) => r.id)).toEqual([
"tsfx-pet2",
"sec-sfx",
"sec-music",
]);
expect(parseStream(jqTrack).map((r: { id: string }) => r.id)).toEqual(ids);
expect(parseStream(jqNth)).toEqual([
{
index: 1,
id: "tmus-pet1",
absStart: 13,
file: "compositions/terminal-music.html",
nested: true,
},
]);
});
});
+54 -9
View File
@@ -48,12 +48,31 @@ export interface TimelineRow extends ClipFact {
absEnd: number;
/** Project-relative path of the file that declares this clip. */
file: string;
/** Clips of a sub-composition, `start`/`end` local to the host. One level only. */
children: TimelineRow[];
/** Position of this row in its own kind's `rows`. Not an id: never use it to name a clip. */
index: number;
/** True for a clip declared inside a sub-composition. */
nested: boolean;
/** Plain id of the row whose `data-composition-src` declares this clip; `null` unless `nested`. */
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`. */
children: RowPointer[];
}
export interface RowPointer {
kind: TrackKind;
index: number;
}
/** A row before flattening: children are still row objects and refs are not assigned. */
type ClipDraft = Omit<TimelineRow, "index" | "nested" | "host" | "hostRow" | "children"> & {
children: ClipDraft[];
};
export interface TimelineTrack {
kind: TrackKind;
/** Every clip of this kind, nested ones included, by `absStart`. */
rows: TimelineRow[];
}
@@ -170,7 +189,7 @@ interface DurationResolution {
function resolveContainerDuration(
authored: number | null,
children: readonly TimelineRow[],
children: readonly { end: number }[],
): DurationResolution {
if (authored !== null)
return { duration: authored, durationSource: "authored", pendingReason: null };
@@ -234,7 +253,7 @@ function mainTimelineStart(scope: DocScope, el: Element, start: number): number
});
}
async function describeRow(scope: DocScope, node: DomNode, depth: number): Promise<TimelineRow> {
async function describeRow(scope: DocScope, node: DomNode, depth: number): Promise<ClipDraft> {
const { el } = node;
const { doc, startCache } = scope;
const start = resolveReferencedStart(doc, el, startCache, new Set());
@@ -277,7 +296,7 @@ async function readSubComposition(
src: string,
parent: DocScope,
origin: number,
): Promise<TimelineRow[]> {
): Promise<ClipDraft[]> {
const authored = resolve(parent.dir, src);
const file = realFileInside(parent.projectDir, authored);
if (!file) return [];
@@ -313,6 +332,35 @@ 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 }))),
];
const byKind = TRACK_ORDER.map((kind) => ({
kind,
entries: entries
.filter((e) => e.clip.trackKind === kind)
.sort((a, b) => a.clip.absStart - b.clip.absStart),
})).filter((t) => t.entries.length > 0);
const pointers = new Map<ClipDraft, RowPointer>(
byKind.flatMap((t) => t.entries.map((e, index) => [e.clip, { kind: t.kind, index }] as const)),
);
const at = (clip: ClipDraft) => pointers.get(clip)!;
return byKind.map(({ kind, entries: kindEntries }) => ({
kind,
rows: kindEntries.map(({ clip, host }, index) => ({
...clip,
index,
nested: host !== null,
host: host && host.id,
hostRow: host && at(host),
children: clip.children.map(at),
})),
}));
}
/** Needs a global DOMParser (`ensureDOMParser`). Reads `index.html` and one level of sub-compositions. */
export async function describeProject(
indexPath: string,
@@ -337,9 +385,6 @@ export async function describeProject(
const declared = parseNumeric(root.getAttribute("data-duration"));
return {
duration: declared ?? rows.reduce((max, r) => Math.max(max, r.end), 0),
tracks: TRACK_ORDER.map((kind) => ({
kind,
rows: rows.filter((r) => r.trackKind === kind),
})).filter((t) => t.rows.length > 0),
tracks: flatten(rows),
};
}
+13 -10
View File
@@ -1,4 +1,4 @@
import type { ProjectTimeline, TimelineRow } from "./describeProject.js";
import type { ProjectTimeline, TimelineRow, TimelineTrack } from "./describeProject.js";
const BAR_WIDTH = 40;
const n = (v: number) => String(Math.round(v * 1000) / 1000);
@@ -49,21 +49,24 @@ function details(row: TimelineRow): string {
const span = (a: number, b: number) => `${n(a)}-${n(b)}s`;
function line(row: TimelineRow, total: number, nested: boolean): string {
const times = nested
? `${span(row.absStart, row.absEnd)} (local ${span(row.start, row.end)}) in ${row.file}`
function line(row: TimelineRow, total: number): string {
const times = row.host
? `${span(row.absStart, row.absEnd)} (local ${span(row.start, row.end)}) nested in ${row.host} ${row.file}`
: span(row.start, row.end);
return `${nested ? " " : " "}|${bar(row, total)}| ${row.id} ${times} ${details(row)}`.trimEnd();
return ` |${bar(row, total)}| ${row.id} ${times} ${details(row)}`.trimEnd();
}
function header({ kind, rows }: TimelineTrack): string {
const nested = rows.filter((r) => r.nested).length;
const split = nested ? `: ${rows.length - nested} top-level, ${nested} nested` : "";
return `${kind} (${rows.length}${split})`;
}
export function formatTimeline(timeline: ProjectTimeline): string {
const out = [`timeline ${n(timeline.duration)}s`];
for (const track of timeline.tracks) {
out.push("", `${track.kind} (${track.rows.length})`);
for (const row of track.rows) {
out.push(line(row, timeline.duration, false));
for (const child of row.children) out.push(line(child, timeline.duration, true));
}
out.push("", header(track));
for (const row of track.rows) out.push(line(row, timeline.duration));
}
return out.join("\n");
}
+1 -1
View File
@@ -30,7 +30,7 @@
"files": 7
},
"hyperframes-cli": {
"hash": "3fa86bc183a976c3",
"hash": "986414090bf6442f",
"files": 11
},
"hyperframes-core": {
@@ -34,40 +34,43 @@ npx hyperframes timeline [project-dir] --json
Reach for `timeline` instead of opening `index.html` and each `data-composition-src` file when you need to know what is on the timeline: which clips exist, when they start and end, what they play, and how loud. It reads the project's files statically (no browser).
Text output is `timeline <N>s`, then one block per track kind (`video`, `graphics`, `captions`, `audio`) with a row per top-level element:
Text output is `timeline <N>s`, then one block per track kind (`video`, `graphics`, `captions`, `audio`) with one row per clip of that kind, ordered by absolute start:
```
graphics (2)
graphics (2: 1 top-level, 1 nested)
|██████ | sec-connector 0-6.7s src=compositions/connector-morph.html
|██████████████ | box 15.67-17.99s (local 0-2.32s) in compositions/connector-morph.html
|██████████████ | box 15.67-17.99s (local 0-2.32s) nested in sec-connector compositions/connector-morph.html
audio (1)
| █ | vo 1.6-3.6s src=vo.mp3 vol=0.5 group=vo volume[0:0.2 2:1]
```
- The bar is 40 columns over the whole timeline. Times are seconds.
- `src=`, `vol=`, `rate=` (playback rate, only when not 1), `group=` (audio group), and `<target>[t:v ...]` (automation lane points, `t` in seconds from the clip start) appear only when the clip has them.
- Clips of a sub-composition are indented one level. Their time is printed as the absolute main-timeline start-end first, then `(local <start>-<end>s)` (time inside that sub-composition), then `in <file>` (the file that declares the clip). Deeper nesting is not expanded (`children=unread` on that row).
- The header counts every clip of that kind, nested ones included: `video (6: 2 top-level, 4 nested)`. The list is in absolute order, so the Nth row is the Nth clip of that kind on the main timeline. A clip inside a sub-composition prints its absolute start-end, then `(local <start>-<end>s)` (time inside that sub-composition), then `nested in <host row id> <file that declares it>`. Only one level of nesting is read: a sub-composition inside a sub-composition is listed as a row saying `children=unread`, and the clips inside it are not counted.
- With no `data-duration`/`data-end`, a media row still gets a resolved length and says where it came from: `duration=media` means ffprobe measured the source (with playback start and rate applied); `duration=default` means an `img` got the 3s default; `duration=inferred` means a composition host summed its children; `pending: <reason>` (dotted bar, `duration` 0) means the source could not be probed (missing file, remote `src`, ffprobe error). A non-media leaf with nothing to resolve prints no source.
- `lanes unreadable: ...` means the clip's `data-automation` or `data-fx-chain` did not parse; fix the attribute.
`--json` prints `{ timeline: { duration, tracks: [{ kind, rows: [...] }] } }`. Each row has `id`, `kind` (tag), `trackKind`, `start`, `duration`, `end` (local to the row's own file), **`absStart`, `absEnd`, `file`** (main-timeline time and the project-relative file that declares the clip — use these, not `start`/`end`, to compare clips across nesting), `trackIndex`, `src`, `sourceFile`, `volume`, `lanes`, `playbackRate`, `audioGroup`, `durationAuthored`, **`durationSource`** (`"authored" | "media" | "default" | "inner" | "pending"`, or `null` for a non-media leaf with nothing to resolve), **`pendingReason`** (why nothing resolved; `null` unless `durationSource` is `"pending"`), `laneError`, and `children` (the sub-composition's rows, one level).
`--json` prints `{ timeline: { duration, tracks: [{ kind, rows: [...] }] } }`. A track's `rows` are every clip of that kind, nested ones included, by `absStart`; read them, never only the top level. Each row has `id`, `kind` (tag), `trackKind`, `start`, `duration`, `end` (local to the row's own file), **`absStart`, `absEnd`, `file`** (main-timeline time and the project-relative file that declares the clip — use these, not `start`/`end`, to compare clips across nesting), `trackIndex`, `src`, `sourceFile`, `volume`, `lanes`, `playbackRate`, `audioGroup`, `durationAuthored`, **`durationSource`** (`"authored" | "media" | "default" | "inner" | "pending"`, or `null` for a non-media leaf with nothing to resolve), **`pendingReason`** (why nothing resolved; `null` unless `durationSource` is `"pending"`), `laneError`, **`index`** (the row's position in its kind's `rows`; a position, not an id), **`nested`** (declared inside a sub-composition), **`host`** (plain id of the row that hosts a nested clip, else `null`), **`hostRow`** (`{kind, index}` of that host row), and `children` (`{kind, index}` pointers to the sub-composition's clips, one level; every clip is already a full row in its kind's `rows`, so nothing needs following).
### Query one-liners (jq, node fallback if jq is absent)
```bash
TL=$(npx hyperframes timeline --json)
# 1. what plays at absolute time T=12.5
jq --argjson t 12.5 '[.. | objects | select(.absStart? != null and .absStart<=$t and .absEnd>$t)]' <<<"$TL"
node -e 'const t=12.5,j=JSON.parse(require("fs").readFileSync(0,"utf8"));const w=r=>r.forEach(x=>{if(x.absStart<=t&&x.absEnd>t)console.log(x.id,x.file);w(x.children||[])});j.timeline.tracks.forEach(tr=>w(tr.rows))' <<<"$TL"
jq --argjson t 12.5 '[.timeline.tracks[].rows[] | select(.absStart<=$t and .absEnd>$t)]' <<<"$TL"
node -e 'const t=12.5,j=JSON.parse(require("fs").readFileSync(0,"utf8"));j.timeline.tracks.forEach(tr=>tr.rows.forEach(x=>{if(x.absStart<=t&&x.absEnd>t)console.log(x.id,x.file)}))' <<<"$TL"
# 2. find a clip by id or src -> file, track, absStart, absEnd
jq --arg q tsfx-pet2 '[.. | objects | select(.id?==$q or .src?==$q)] | .[] | {file,trackKind,absStart,absEnd}' <<<"$TL"
node -e 'const q="tsfx-pet2",j=JSON.parse(require("fs").readFileSync(0,"utf8"));const w=r=>r.forEach(x=>{if(x.id===q||x.src===q)console.log(x.file,x.trackKind,x.absStart,x.absEnd);w(x.children||[])});j.timeline.tracks.forEach(tr=>w(tr.rows))' <<<"$TL"
# 3. clips of one track, in absolute order
jq --arg k video '(.timeline.tracks[]|select(.kind==$k).rows)|sort_by(.absStart)|.[]|{id,absStart,absEnd}' <<<"$TL"
node -e 'const k="video",j=JSON.parse(require("fs").readFileSync(0,"utf8"));j.timeline.tracks.find(t=>t.kind===k).rows.slice().sort((a,b)=>a.absStart-b.absStart).forEach(r=>console.log(r.id,r.absStart,r.absEnd))' <<<"$TL"
# 4. gaps and overlaps within a track (positive = gap, negative = overlap)
jq --arg k video '(.timeline.tracks[]|select(.kind==$k).rows)|sort_by(.absStart) as $r|[range(0;($r|length)-1)|{a:$r[.].id,b:$r[.+1].id,delta:($r[.+1].absStart-$r[.].absEnd)}]' <<<"$TL"
node -e 'const k="video",j=JSON.parse(require("fs").readFileSync(0,"utf8"));const r=j.timeline.tracks.find(t=>t.kind===k).rows.slice().sort((a,b)=>a.absStart-b.absStart);for(let i=0;i<r.length-1;i++)console.log(r[i].id,r[i+1].id,r[i+1].absStart-r[i].absEnd)' <<<"$TL"
jq --arg q tsfx-pet2 '[.timeline.tracks[].rows[] | select(.id==$q or .src==$q)] | .[] | {file,trackKind,absStart,absEnd}' <<<"$TL"
node -e 'const q="tsfx-pet2",j=JSON.parse(require("fs").readFileSync(0,"utf8"));j.timeline.tracks.forEach(tr=>tr.rows.forEach(x=>{if(x.id===q||x.src===q)console.log(x.file,x.trackKind,x.absStart,x.absEnd)}))' <<<"$TL"
# 3. every clip of one kind, nested included, in absolute order
jq --arg k video '.timeline.tracks[] | select(.kind==$k).rows[] | {id,absStart,absEnd}' <<<"$TL"
node -e 'const k="video",j=JSON.parse(require("fs").readFileSync(0,"utf8"));j.timeline.tracks.find(t=>t.kind===k).rows.forEach(r=>console.log(r.id,r.absStart,r.absEnd))' <<<"$TL"
# 4. gaps and overlaps within a kind (positive = gap, negative = overlap)
jq --arg k video '(.timeline.tracks[] | select(.kind==$k).rows) as $r|[range(0;($r|length)-1)|{a:$r[.].id,b:$r[.+1].id,delta:($r[.+1].absStart-$r[.].absEnd)}]' <<<"$TL"
node -e 'const k="video",j=JSON.parse(require("fs").readFileSync(0,"utf8"));const r=j.timeline.tracks.find(t=>t.kind===k).rows;for(let i=0;i<r.length-1;i++)console.log(r[i].id,r[i+1].id,r[i+1].absStart-r[i].absEnd)' <<<"$TL"
# 5. the Nth clip of a kind by absolute start (N=2)
jq --arg k video --argjson n 2 '.timeline.tracks[] | select(.kind==$k).rows[$n-1] | {index,id,absStart,file,nested}' <<<"$TL"
node -e 'const k="video",n=2,j=JSON.parse(require("fs").readFileSync(0,"utf8"));const r=j.timeline.tracks.find(t=>t.kind===k).rows[n-1];console.log(r.index,r.id,r.absStart,r.file)' <<<"$TL"
```
## compositions, docs