diff --git a/docs/developers/cli.mdx b/docs/developers/cli.mdx index 36baba88d2..79c694d01f 100644 --- a/docs/developers/cli.mdx +++ b/docs/developers/cli.mdx @@ -35,6 +35,7 @@ remains authoritative: run `npx hyperframes --help`. | --- | --- | | Add a Catalog item | `npx hyperframes add ` | | List compositions | `npx hyperframes compositions` | +| Print tracks and clips | `npx hyperframes timeline` (`--json` for agents) | | Inspect keyframe behavior | `npx hyperframes keyframes` | | Compare two or more versions | `npx hyperframes compare v1/ v2/` | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9aca6e8464..357782ff7f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -158,6 +158,7 @@ const commandLoaders = { layout: () => import("./commands/layout.js").then((m) => m.default), info: () => import("./commands/info.js").then((m) => m.default), compositions: () => import("./commands/compositions.js").then((m) => m.default), + timeline: () => import("./commands/timeline.js").then((m) => m.default), benchmark: () => import("./commands/benchmark.js").then((m) => m.default), browser: () => import("./commands/browser.js").then((m) => m.default), "remove-background": () => import("./commands/remove-background.js").then((m) => m.default), diff --git a/packages/cli/src/commands/compositions.ts b/packages/cli/src/commands/compositions.ts index 2376cb28a6..c467e8e001 100644 --- a/packages/cli/src/commands/compositions.ts +++ b/packages/cli/src/commands/compositions.ts @@ -1,5 +1,4 @@ import { defineCommand } from "citty"; -import { parseNumeric, parseStartExpression } from "@hyperframes/core"; import type { Example } from "./_examples.js"; import { existsSync, readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; @@ -11,6 +10,7 @@ export const examples: Example[] = [ import { c } from "../ui/colors.js"; import { ensureDOMParser } from "../utils/dom.js"; import { resolveProject } from "../utils/project.js"; +import { resolveReferencedStart } from "@hyperframes/engine"; import { withMeta } from "../utils/updateCheck.js"; interface CompositionInfo { @@ -46,70 +46,6 @@ function estimateDurationFromScripts(root: ParentNode): number { return duration; } -function findReferenceTargetEl(doc: Document, refId: string): Element | null { - return doc.getElementById(refId) ?? doc.querySelector(`[data-composition-id="${refId}"]`); -} - -function resolveStart( - doc: Document, - el: Element, - startCache: Map, - visiting: Set, -): number { - const cached = startCache.get(el); - if (cached !== undefined) return cached; - if (visiting.has(el)) return 0; - visiting.add(el); - - try { - const expression = parseStartExpression(el.getAttribute("data-start")); - if (!expression) { - startCache.set(el, 0); - return 0; - } - - if (expression.kind === "absolute") { - const value = Math.max(0, expression.value); - startCache.set(el, value); - return value; - } - - const target = findReferenceTargetEl(doc, expression.refId); - if (!target) { - startCache.set(el, 0); - return 0; - } - - const targetStart = resolveStart(doc, target, startCache, visiting); - const targetDuration = resolveReferencedDuration(doc, target, startCache, visiting); - const resolved = - targetDuration != null && targetDuration > 0 - ? Math.max(0, targetStart + targetDuration + expression.offset) - : Math.max(0, targetStart + expression.offset); - startCache.set(el, resolved); - return resolved; - } finally { - visiting.delete(el); - } -} - -function resolveReferencedDuration( - doc: Document, - el: Element, - startCache: Map, - visiting: Set, -): number | null { - const durationAttr = parseNumeric(el.getAttribute("data-duration")); - if (durationAttr != null && durationAttr > 0) return durationAttr; - const endAttr = parseNumeric(el.getAttribute("data-end")); - if (endAttr != null) { - const start = resolveStart(doc, el, startCache, visiting); - const delta = endAttr - start; - if (Number.isFinite(delta) && delta > 0) return delta; - } - return null; -} - export function parseCompositions(html: string, baseDir: string): CompositionInfo[] { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); @@ -142,7 +78,7 @@ export function parseCompositions(html: string, baseDir: string): CompositionInf timedChildren.forEach((el) => { elementCount++; - const start = resolveStart(doc, el, startCache, visiting); + const start = resolveReferencedStart(doc, el, startCache, visiting); const endAttr = el.getAttribute("data-end"); const durationAttr = el.getAttribute("data-duration"); @@ -212,7 +148,7 @@ export function parseSubComposition( const visiting = new Set(); timedEls.forEach((el) => { elementCount = Math.max(elementCount, timedEls.length); - const start = resolveStart(doc, el, startCache, visiting); + const start = resolveReferencedStart(doc, el, startCache, visiting); const endAttr = el.getAttribute("data-end"); const durAttr = el.getAttribute("data-duration"); diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts new file mode 100644 index 0000000000..7224ca15a0 --- /dev/null +++ b/packages/cli/src/commands/timeline.ts @@ -0,0 +1,31 @@ +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { describeProject } from "../timeline/describeProject.js"; +import { formatTimeline } from "../timeline/formatTimeline.js"; +import { ensureDOMParser } from "../utils/dom.js"; +import { resolveProject } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; + +export const examples: Example[] = [ + ["Show every track and clip of the project in the current directory", "hyperframes timeline"], + ["Same, as JSON for an agent", "hyperframes timeline ./my-video --json"], +]; + +export default defineCommand({ + meta: { + name: "timeline", + description: "Print the project's tracks and clips (start, duration, source, volume, rate)", + }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output as JSON", default: false }, + }, + async run({ args }) { + const project = resolveProject(args.dir); + ensureDOMParser(); + const timeline = describeProject(project.indexPath); + console.log( + args.json ? JSON.stringify(withMeta({ timeline }), null, 2) : formatTimeline(timeline), + ); + }, +}); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index ea0c1a6833..8c187d3cde 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -57,6 +57,7 @@ const GROUPS: Group[] = [ ["compare", "Render composition variants into one labeled comparison sheet"], ["info", "Print project metadata"], ["compositions", "List all compositions in a project"], + ["timeline", "Print the project's tracks and clips"], ["docs", "View inline documentation in the terminal"], ], }, diff --git a/packages/cli/src/timeline/describeProject.test.ts b/packages/cli/src/timeline/describeProject.test.ts new file mode 100644 index 0000000000..69a7ff7026 --- /dev/null +++ b/packages/cli/src/timeline/describeProject.test.ts @@ -0,0 +1,316 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { ensureDOMParser } from "../utils/dom.js"; +import { describeProject } from "./describeProject.js"; +import { formatTimeline } from "./formatTimeline.js"; + +const INDEX = ` +
+ +
+ + +
+
`; + +const TITLE = ``; + +let dir = ""; +const project = () => { + dir = mkdtempSync(join(tmpdir(), "hf-timeline-")); + mkdirSync(join(dir, "compositions")); + writeFileSync(join(dir, "index.html"), INDEX); + writeFileSync(join(dir, "compositions", "title.html"), TITLE); + return join(dir, "index.html"); +}; + +beforeAll(ensureDOMParser); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe("describeProject", () => { + it("groups rows into tracks by kind with resolved timing and clip facts", () => { + const timeline = describeProject(project()); + 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"]], + ["audio", ["vo", "bad"]], + ]); + const [video] = timeline.tracks[0]!.rows; + expect(video).toMatchObject({ start: 0, duration: 4, playbackRate: 2, src: "a.mp4" }); + const title = timeline.tracks[1]!.rows.find((r) => r.id === "title")!; + expect(title).toMatchObject({ start: 5, end: 8, sourceFile: "compositions/title.html" }); + const vo = timeline.tracks[2]!.rows[0]!; + expect(vo).toMatchObject({ volume: 0.5, audioGroup: "vo" }); + expect(vo.lanes).toEqual([ + { + target: "volume", + points: [ + { t: 0, v: 0.2 }, + { t: 2, v: 1 }, + ], + }, + ]); + }); + + it("nests a sub-composition's clips one level down with local times", () => { + const title = describeProject(project()) + .tracks.flatMap((t) => t.rows) + .find((r) => r.id === "title")!; + expect(title.children.map((c) => [c.id, c.start, c.end])).toEqual([ + ["t1", 0, 2], + ["t2", 2.5, 3.5], + ]); + }); + + it("reports unreadable automation instead of showing no lanes", () => { + const bad = describeProject(project()) + .tracks.flatMap((t) => t.rows) + .find((r) => r.id === "bad")!; + expect(bad.lanes).toEqual([]); + expect(bad.laneError).toMatch(/not valid JSON/); + expect(formatTimeline(describeProject(project()))).toContain("lanes unreadable:"); + }); + + it("does not read a sub-composition outside the project or a directory", () => { + const index = project(); + writeFileSync(join(dir, "..", "hf-outside.html"), TITLE); + writeFileSync( + index, + `
`, + ); + const rows = describeProject(index).tracks.flatMap((t) => t.rows); + expect(rows.map((r) => [r.id, r.children.length])).toEqual([ + ["o", 0], + ["d", 0], + ]); + rmSync(join(dir, "..", "hf-outside.html")); + }); + + it("reads a sub-composition whose folder name starts with two dots", () => { + const index = project(); + mkdirSync(join(dir, "..scenes")); + writeFileSync(join(dir, "..scenes", "s.html"), TITLE); + writeFileSync( + index, + `
`, + ); + const [row] = describeProject(index).tracks.flatMap((t) => t.rows); + expect(row!.children.length).toBe(2); + }); + + it("does not follow a symlink out of the project", () => { + const index = project(); + const outside = mkdtempSync(join(tmpdir(), "hf-outside-")); + writeFileSync(join(outside, "secret.html"), TITLE); + symlinkSync(join(outside, "secret.html"), join(dir, "compositions", "link.html")); + writeFileSync( + index, + `
`, + ); + const [row] = describeProject(index).tracks.flatMap((t) => t.rows); + expect(row!.children).toEqual([]); + rmSync(outside, { recursive: true, force: true }); + }); + + it("marks a clip without an authored duration instead of reporting 0 as a fact", () => { + const logo = describeProject(project()) + .tracks.flatMap((t) => t.rows) + .find((r) => r.id === "logo")!; + expect(logo.durationAuthored).toBe(false); + expect(formatTimeline(describeProject(project()))).toContain( + "logo 0-0s src=logo.png duration=unauthored", + ); + }); +}); + +// A direct clip declared with a big data-start (20) reads as "the later one", +// but a clip nested in a host that starts at 5 with its own local start of 1 +// actually plays at 6 on the main timeline: earlier than the direct clip. +const INVERSION_INDEX = `
+ +
+
`; +const INVERSION_SCENE = ``; + +const inversionProject = () => { + dir = mkdtempSync(join(tmpdir(), "hf-timeline-abs-")); + mkdirSync(join(dir, "compositions")); + writeFileSync(join(dir, "index.html"), INVERSION_INDEX); + writeFileSync(join(dir, "compositions", "scene.html"), INVERSION_SCENE); + return join(dir, "index.html"); +}; + +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", () => { + const rows = describeProject(inversionProject()).tracks.flatMap((t) => t.rows); + 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")!; + + // 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. + expect(direct).toMatchObject({ + start: 20, + end: 25, + absStart: 20, + absEnd: 25, + file: "index.html", + }); + expect(host).toMatchObject({ start: 5, end: 15, absStart: 5, absEnd: 15, file: "index.html" }); + expect(nested).toMatchObject({ + start: 1, + end: 3, + absStart: 6, + absEnd: 8, + file: "compositions/scene.html", + }); + expect(nested.absStart).toBeLessThan(direct.absStart); + }); + + it("places a nested media clip with a negative start where the runtime plays it", () => { + dir = mkdtempSync(join(tmpdir(), "hf-timeline-neg-")); + mkdirSync(join(dir, "compositions")); + writeFileSync(join(dir, "index.html"), INVERSION_INDEX); + writeFileSync( + join(dir, "compositions", "scene.html"), + INVERSION_SCENE.replace('data-start="1"', 'data-start="-3"'), + ); + const host = describeProject(join(dir, "index.html")) + .tracks.flatMap((t) => t.rows) + .find((r) => r.id === "host")!; + // 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 }); + }); + + it("resolves a media start given as an expression like any other clip, not as a literal", () => { + dir = mkdtempSync(join(tmpdir(), "hf-timeline-expr-")); + mkdirSync(join(dir, "compositions")); + writeFileSync(join(dir, "index.html"), INVERSION_INDEX); + writeFileSync( + join(dir, "compositions", "scene.html"), + INVERSION_SCENE.replace( + "", + ``, + ), + ); + const host = describeProject(join(dir, "index.html")) + .tracks.flatMap((t) => t.rows) + .find((r) => r.id === "host")!; + // 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 }); + }); + + it("clamps a negative media start to 0 when the host starts at 0, as the runtime does", () => { + dir = mkdtempSync(join(tmpdir(), "hf-timeline-neg0-")); + writeFileSync( + join(dir, "index.html"), + `
`, + ); + const v = describeProject(join(dir, "index.html")).tracks.flatMap((t) => t.rows)[0]; + expect(v).toMatchObject({ id: "v", absStart: 0, absEnd: 2 }); + }); + + it("prints the absolute time first and the local time in parentheses for a nested row", () => { + const text = formatTimeline(describeProject(inversionProject())); + expect(text).toContain("direct 20-25s"); + expect(text).toContain("nested 6-8s (local 1-3s) in compositions/scene.html"); + }); +}); + +describe("formatTimeline", () => { + it("treats playback rate 1 as unset and does not expand a host nested inside a sub-composition", () => { + const index = project(); + writeFileSync( + join(dir, "compositions", "title.html"), + ``, + ); + const title = 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([ + ["v", null, 0], + ["deep", null, 0], + ]); + }); + + it("prints a small volume unrounded to two decimals", () => { + const index = project(); + writeFileSync( + index, + `
`, + ); + const text = formatTimeline(describeProject(index)); + expect(text).toContain("vol=0.009772"); + expect(text).toContain("2.317-3.317s"); + }); + + it("prints one bar per row under its track heading", () => { + const text = formatTimeline(describeProject(project())); + expect(text).toMatch(/^timeline 10s\n\nvideo \(1\)\n \|█{16}/); + expect(text).toContain("audio (2)"); + expect(text).toContain("vol=0.5 group=vo volume[0:0.2 2:1]"); + expect(text).toContain("rate=2"); + }); +}); + +const SKILL_DOC = join( + dirname(fileURLToPath(import.meta.url)), + "../../../../skills/hyperframes-cli/references/upgrade-info-misc.md", +); + +// The documented one-liners run verbatim; only the example query values are swapped for the fixture's. +const oneLiners = (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")); + +const hasJq = (() => { + try { + execFileSync("jq", ["--version"]); + return true; + } catch { + return false; + } +})(); + +const runOneLiner = (line: string): string => + execFileSync("bash", ["-c", line], { + env: { ...process.env, TL: JSON.stringify({ timeline: describeProject(inversionProject()) }) }, + encoding: "utf8", + }); + +describe("skill query one-liners", () => { + it("documents four node one-liners, each answering from the fixture", () => { + const lines = oneLiners("node -e"); + expect(lines).toHaveLength(4); + const [at = "", find, track, gaps] = lines.map(runOneLiner); + expect(at.split("\n").filter(Boolean)).toEqual([ + "host index.html", + "nested compositions/scene.html", + ]); + expect(find).toBe("compositions/scene.html video 6 8\n"); + expect(track).toBe("direct 20 25\n"); + expect(gaps).toBe(""); + }); + + it.skipIf(!hasJq)("documents four jq one-liners that agree with the node ones", () => { + const lines = oneLiners("jq"); + expect(lines).toHaveLength(4); + const [at, find, track, gaps] = lines.map((l) => + JSON.parse(`[${runOneLiner(l).replace(/}\s*{/g, "},{")}]`), + ); + expect(at[0].map((r: { id: string }) => r.id)).toEqual(["host", "nested"]); + 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([]); + }); +}); diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts new file mode 100644 index 0000000000..eab1d4dba9 --- /dev/null +++ b/packages/cli/src/timeline/describeProject.ts @@ -0,0 +1,204 @@ +import { readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { + HF_AUDIO_AUTOMATION_ATTR, + parseAutomation, + resolveAutomation, +} from "@hyperframes/core/audio-automation"; +import { HF_AUDIO_FX_ATTR, parseAudioFxChain } from "@hyperframes/core/audio-fx"; +import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups"; +import { byStart, type ClipFact, type ClipLane } from "@hyperframes/core/clip-facts"; +import { parseNumeric } from "@hyperframes/core"; +import { + topLevelElements, + trackKindOf, + type StructureNode, + type TrackKind, +} from "@hyperframes/parsers"; +import { resolveMediaStartSeconds } from "@hyperframes/core/media-timing"; +import { resolveReferencedDuration, resolveReferencedStart } from "@hyperframes/engine"; + +export interface TimelineRow extends ClipFact { + trackKind: TrackKind; + /** False when the source does not author a duration (media length is only known at render). */ + durationAuthored: boolean; + /** Why `data-automation` / `data-fx-chain` could not be read; `null` when fine or absent. */ + laneError: string | null; + /** Start and end on the main timeline, in seconds. `start`/`end` are local to the owning file's composition. */ + absStart: number; + 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[]; +} + +export interface TimelineTrack { + kind: TrackKind; + rows: TimelineRow[]; +} + +export interface ProjectTimeline { + duration: number; + tracks: TimelineTrack[]; +} + +interface DomNode extends StructureNode { + el: Element; +} + +const TRACK_ORDER: readonly TrackKind[] = ["video", "graphics", "captions", "audio"]; + +function toNode(el: Element): DomNode { + const attrs: Record = {}; + for (const { name, value } of Array.from(el.attributes)) attrs[name] = value; + return { tag: el.tagName, attrs, children: Array.from(el.children).map(toNode), el }; +} + +/** An unreadable chain drops only the fx lanes; the clip's own `volume` lane still shows. */ +function safeChain(raw: string): ReturnType | undefined { + try { + return parseAudioFxChain(raw); + } catch { + return undefined; + } +} + +function readLanes(el: Element): { lanes: ClipLane[]; laneError: string | null } { + const raw = el.getAttribute(HF_AUDIO_AUTOMATION_ATTR); + if (!raw) return { lanes: [], laneError: null }; + try { + const fx = el.getAttribute(HF_AUDIO_FX_ATTR); + const chain = fx ? safeChain(fx) : undefined; + const lanes = resolveAutomation(parseAutomation(raw), chain).lanes.map((lane) => ({ + target: lane.target, + points: lane.points.map(({ t, v }) => ({ t, v })), + })); + return { lanes, laneError: null }; + } catch (err) { + return { lanes: [], laneError: err instanceof Error ? err.message : String(err) }; + } +} + +/** One per document: `startCache` memoises `data-start` references across all its rows. */ +interface DocScope { + doc: Document; + dir: string; + startCache: Map; + /** Sub-composition files must stay inside the project. */ + projectDir: string; + /** Main-timeline start of this document's root: 0 for index.html, the host's start for a sub-composition. */ + origin: number; + /** Project-relative path of this document, with `/` separators. */ + file: string; +} + +const roundMs = (v: number) => Math.round(v * 1000) / 1000; + +/** Nested media follows the runtime's own rule (core `resolveMediaStartSeconds`); everything else is host-relative. */ +function mainTimelineStart(scope: DocScope, el: Element, start: number): number { + const ordinaryStart = () => scope.origin + start; + if (!/^(video|audio)$/i.test(el.tagName)) return ordinaryStart(); + return resolveMediaStartSeconds({ + authoredStart: parseNumeric(el.getAttribute("data-start")), + hostStart: scope.origin, + hasAutoStart: el.hasAttribute("data-hf-auto-start"), + basis: el.getAttribute("data-hf-media-start-basis"), + ordinaryStart, + }); +} + +function describeRow(scope: DocScope, node: DomNode, depth: number): TimelineRow { + const { el } = node; + const { doc, startCache } = scope; + const start = resolveReferencedStart(doc, el, startCache, new Set()); + const authored = resolveReferencedDuration(doc, el, startCache, new Set()); + const host = el.getAttribute("data-composition-src"); + const absStart = mainTimelineStart(scope, el, start); + const children = host && depth === 0 ? readSubComposition(host, scope, absStart) : []; + const inner = children.reduce((max, c) => Math.max(max, c.end), 0); + const duration = authored ?? inner; + const rate = parseNumeric(el.getAttribute("data-playback-rate")); + const kind = el.tagName.toLowerCase(); + return { + id: el.id || el.getAttribute("data-composition-id") || kind, + label: null, + kind, + trackKind: trackKindOf(node).kind, + start, + duration, + end: start + duration, + absStart: roundMs(absStart), + absEnd: roundMs(absStart + duration), + file: scope.file, + trackIndex: parseNumeric(el.getAttribute("data-track-index")) ?? 0, + src: el.getAttribute("src") ?? host, + sourceFile: host, + volume: parseNumeric(el.getAttribute("data-volume")), + ...readLanes(el), + playbackRate: rate === 1 ? null : rate, + audioGroup: el.getAttribute(HF_AUDIO_GROUP_ATTR), + role: null, + durationAuthored: authored !== null, + children, + }; +} + +function readSubComposition(src: string, parent: DocScope, origin: number): TimelineRow[] { + const authored = resolve(parent.dir, src); + const file = realFileInside(parent.projectDir, authored); + if (!file) return []; + const doc = new DOMParser().parseFromString(readFileSync(file, "utf-8"), "text/html"); + const template = doc.querySelector("template"); + const root = (template?.content ?? doc).querySelector("[data-composition-id]"); + if (!root) return []; + const scope: DocScope = { + doc, + dir: dirname(file), + startCache: new Map(), + projectDir: parent.projectDir, + origin, + file: relative(parent.projectDir, authored).split(sep).join("/"), + }; + return topLevelElements(toNode(root)) + .map((node) => describeRow(scope, node, 1)) + .sort(byStart); +} + +/** The file's real path when it is a regular file inside the project (symlinks resolved), else null. */ +function realFileInside(projectDir: string, path: string): string | null { + try { + const real = realpathSync(path); + const inside = relative(realpathSync(projectDir), real); + if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return null; + return statSync(real).isFile() ? real : null; + } catch { + return null; + } +} + +/** Needs a global DOMParser (`ensureDOMParser`). Reads `index.html` and one level of sub-compositions. */ +export function describeProject(indexPath: string): ProjectTimeline { + const doc = new DOMParser().parseFromString(readFileSync(indexPath, "utf-8"), "text/html"); + const root = doc.querySelector("[data-composition-id]") ?? doc.body; + const dir = dirname(indexPath); + const scope: DocScope = { + doc, + dir, + startCache: new Map(), + projectDir: dir, + origin: 0, + file: basename(indexPath), + }; + const rows = topLevelElements(toNode(root)) + .map((node) => describeRow(scope, node, 0)) + .sort(byStart); + 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), + }; +} diff --git a/packages/cli/src/timeline/formatTimeline.ts b/packages/cli/src/timeline/formatTimeline.ts new file mode 100644 index 0000000000..11969e1745 --- /dev/null +++ b/packages/cli/src/timeline/formatTimeline.ts @@ -0,0 +1,53 @@ +import type { ProjectTimeline, TimelineRow } from "./describeProject.js"; + +const BAR_WIDTH = 40; +const n = (v: number) => String(Math.round(v * 1000) / 1000); + +function bar(row: TimelineRow, total: number): string { + if (total <= 0) return " ".repeat(BAR_WIDTH); + const known = row.durationAuthored || row.duration > 0; + const from = Math.min(BAR_WIDTH - 1, Math.floor((row.absStart / total) * BAR_WIDTH)); + const to = known ? (row.absEnd / total) * BAR_WIDTH : BAR_WIDTH; + const width = Math.max(1, Math.min(BAR_WIDTH, Math.ceil(to)) - from); + const fill = known ? "█" : "░"; + return " ".repeat(from) + fill.repeat(width) + " ".repeat(BAR_WIDTH - from - width); +} + +function details(row: TimelineRow): string { + const lanes = row.lanes.map( + (l) => `${l.target}[${l.points.map((p) => `${n(p.t)}:${n(p.v)}`).join(" ")}]`, + ); + return [ + row.src && `src=${row.src}`, + row.volume !== null && `vol=${row.volume}`, + row.playbackRate !== null && `rate=${n(row.playbackRate)}`, + row.audioGroup && `group=${row.audioGroup}`, + !row.durationAuthored && "duration=unauthored", + row.sourceFile && !row.children.length && "children=unread", + row.laneError && `lanes unreadable: ${row.laneError}`, + ...lanes, + ] + .filter(Boolean) + .join(" "); +} + +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}` + : span(row.start, row.end); + return `${nested ? " " : " "}|${bar(row, total)}| ${row.id} ${times} ${details(row)}`.trimEnd(); +} + +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)); + } + } + return out.join("\n"); +} diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index d69cac3b59..fad9258dce 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -176,6 +176,12 @@ "types": "./dist/audioAutomation.d.ts", "environments": ["browser", "bun", "node"] }, + "./clip-facts": { + "source": "./src/clipFacts.ts", + "runtime": "./dist/clipFacts.js", + "types": "./dist/clipFacts.d.ts", + "environments": ["browser", "bun", "node"] + }, "./audio-gain": { "source": "./src/audioGain.ts", "runtime": "./dist/audioGain.js", diff --git a/packages/core/package.json b/packages/core/package.json index 3c1280aa92..f84a75efe8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -190,6 +190,12 @@ "import": "./src/audioAutomation.ts", "types": "./src/audioAutomation.ts" }, + "./clip-facts": { + "bun": "./src/clipFacts.ts", + "node": "./dist/clipFacts.js", + "import": "./src/clipFacts.ts", + "types": "./src/clipFacts.ts" + }, "./audio-gain": { "bun": "./src/audioGain.ts", "node": "./dist/audioGain.js", @@ -520,6 +526,10 @@ "import": "./dist/audioAutomation.js", "types": "./dist/audioAutomation.d.ts" }, + "./clip-facts": { + "import": "./dist/clipFacts.js", + "types": "./dist/clipFacts.d.ts" + }, "./audio-gain": { "import": "./dist/audioGain.js", "types": "./dist/audioGain.d.ts" diff --git a/packages/core/src/clipFacts.test.ts b/packages/core/src/clipFacts.test.ts new file mode 100644 index 0000000000..0002e18961 --- /dev/null +++ b/packages/core/src/clipFacts.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { byStart, formatClipLine, type ClipFact } from "./clipFacts.js"; + +const clip = (over: Partial = {}): ClipFact => ({ + id: "a", + label: null, + kind: "video", + start: 1, + duration: 2, + end: 3, + trackIndex: 0, + src: null, + sourceFile: null, + volume: null, + lanes: [], + playbackRate: null, + audioGroup: null, + role: null, + ...over, +}); + +describe("formatClipLine", () => { + it("prints only the always-present fields for a bare clip", () => { + expect(formatClipLine(clip())).toBe('- video "a" start=1 duration=2 end=3 track=0'); + }); + + it("prints every optional field when present, rounded to milliseconds", () => { + const line = formatClipLine( + clip({ + src: "a.mp4", + volume: 0.12345, + playbackRate: 2, + audioGroup: "vo", + role: "bed", + sourceFile: "s.html", + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 0.2 }, + { t: 1.23456, v: 1 }, + ], + }, + ], + }), + ); + expect(line).toBe( + '- video "a" src=a.mp4 start=1 duration=2 end=3 track=0 volume=0.123 rate=2 group=vo role=bed file=s.html volume-lane=[0:0.2, 1.235:1]', + ); + }); + + it("keeps volume 0 (muted) instead of dropping it as falsy", () => { + expect(formatClipLine(clip({ volume: 0 }))).toContain("volume=0"); + }); +}); + +describe("byStart", () => { + it("orders by start, then track", () => { + const rows = [clip({ id: "c", start: 2 }), clip({ id: "b", trackIndex: 1 }), clip({ id: "a" })]; + expect(rows.sort(byStart).map((r) => r.id)).toEqual(["a", "b", "c"]); + }); +}); diff --git a/packages/core/src/clipFacts.ts b/packages/core/src/clipFacts.ts new file mode 100644 index 0000000000..9372b2e136 --- /dev/null +++ b/packages/core/src/clipFacts.ts @@ -0,0 +1,56 @@ +/** Plain-field clip facts shared by the Ask-agent prompt, `studio_look` and `hyperframes timeline`. */ + +export interface ClipLane { + /** `volume`, or `fx..`. */ + target: string; + /** `t` is seconds from the start of the clip, not the composition. */ + points: { t: number; v: number }[]; +} + +export interface ClipFact { + id: string; + label: string | null; + kind: string; + start: number; + duration: number; + end: number; + /** The `data-track-index` as written in the source file. */ + trackIndex: number; + src: string | null; + sourceFile: string | null; + /** `null` when `data-volume` is not authored (the clip plays at 1). */ + volume: number | null; + lanes: ClipLane[]; + /** `null` at normal speed: not authored, or 1 (the manifest defaults it to 1). */ + playbackRate: number | null; + audioGroup: string | null; + role: string | null; +} + +export const byStart = (a: ClipFact, b: ClipFact) => + a.start - b.start || a.trackIndex - b.trackIndex; + +const roundTo3 = (n: number) => Math.round(n * 1000) / 1000; + +const num = (n: number) => String(roundTo3(n)); + +export function formatClipLine(clip: ClipFact): string { + const parts = [ + `${clip.kind} "${clip.id}"`, + clip.src && `src=${clip.src}`, + `start=${num(clip.start)}`, + `duration=${num(clip.duration)}`, + `end=${num(clip.end)}`, + `track=${clip.trackIndex}`, + clip.volume !== null && `volume=${num(clip.volume)}`, + clip.playbackRate !== null && `rate=${num(clip.playbackRate)}`, + clip.audioGroup && `group=${clip.audioGroup}`, + clip.role && `role=${clip.role}`, + clip.sourceFile && `file=${clip.sourceFile}`, + ...clip.lanes.map( + (lane) => + `${lane.target}-lane=[${lane.points.map((p) => `${num(p.t)}:${num(p.v)}`).join(", ")}]`, + ), + ]; + return `- ${parts.filter(Boolean).join(" ")}`; +} diff --git a/packages/core/src/mediaTiming.test.ts b/packages/core/src/mediaTiming.test.ts new file mode 100644 index 0000000000..ae2be88c16 --- /dev/null +++ b/packages/core/src/mediaTiming.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { resolveMediaStartSeconds } from "./mediaTiming"; + +const ordinary = () => 99; +const base = { authoredStart: 3, hostStart: 10, hasAutoStart: false, ordinaryStart: ordinary }; + +describe("resolveMediaStartSeconds", () => { + it("adds the host start to a literal start by default", () => { + expect(resolveMediaStartSeconds(base)).toBe(13); + }); + + it("keeps a legacy root-global start as is", () => { + expect(resolveMediaStartSeconds({ ...base, basis: "global" })).toBe(3); + }); + + it.each([ + ["no literal start", { authoredStart: null }], + ["an auto-injected start", { hasAutoStart: true }], + ["a host at t=0", { hostStart: 0 }], + ])("defers to ordinary resolution for %s", (_name, override) => { + expect(resolveMediaStartSeconds({ ...base, ...override })).toBe(99); + }); +}); diff --git a/packages/core/src/mediaTiming.ts b/packages/core/src/mediaTiming.ts index 348b4ff8b7..3c24c17bb1 100644 --- a/packages/core/src/mediaTiming.ts +++ b/packages/core/src/mediaTiming.ts @@ -18,3 +18,23 @@ export function resolveAbsoluteMediaStartSeconds(input: { ? input.authoredStart : input.hostStart + input.authoredStart; } + +/** The one rule for a media element's root-timeline start; the runtime and the CLI both call it. + * With no literal start, an auto-injected start, or a host at t<=0 there is nothing for the basis + * to disambiguate, so the ordinary start resolution applies. */ +export function resolveMediaStartSeconds(input: { + authoredStart: number | null; + hostStart: number; + hasAutoStart: boolean; + basis?: string | null; + ordinaryStart: () => number; +}): number { + if (input.hasAutoStart || input.authoredStart == null || input.hostStart <= 0) { + return input.ordinaryStart(); + } + return resolveAbsoluteMediaStartSeconds({ + authoredStart: input.authoredStart, + hostStart: input.hostStart, + basis: input.basis, + }); +} diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index aa3fe5ae42..fafbf191ac 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -11,7 +11,7 @@ import { } from "./playbackRate"; import { isMediaElement } from "./domRealm"; import { parseStartExpression } from "./startExpression"; -import { MEDIA_START_BASIS_ATTR, resolveAbsoluteMediaStartSeconds } from "../mediaTiming"; +import { MEDIA_START_BASIS_ATTR, resolveMediaStartSeconds } from "../mediaTiming"; export function createRuntimeStartTimeResolver(params: { timelineRegistry?: Record; @@ -190,17 +190,12 @@ export function createRuntimeStartTimeResolver(params: { const resolveMediaStartForElement = (element: Element): number => { const compositionRoot = element.closest("[data-composition-id]"); const hostStart = compositionRoot ? resolveStartForElementInternal(compositionRoot, 0) : 0; - const authoredStart = parseStrictFiniteTimingNumber(element.getAttribute("data-start")); - // No literal start (absent, or a `data-start="intro + 2"` reference), an - // auto-injected start, or a host at t=0 — nothing for the basis to - // disambiguate, so the ordinary start resolution is already correct. - if (element.hasAttribute("data-hf-auto-start") || authoredStart == null || hostStart <= 0) { - return resolveStartForElementInternal(element, hostStart); - } - return resolveAbsoluteMediaStartSeconds({ - authoredStart, + return resolveMediaStartSeconds({ + authoredStart: parseStrictFiniteTimingNumber(element.getAttribute("data-start")), hostStart, + hasAutoStart: element.hasAttribute("data-hf-auto-start"), basis: element.getAttribute(MEDIA_START_BASIS_ATTR), + ordinaryStart: () => resolveStartForElementInternal(element, hostStart), }); }; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 0839c49adb..a9e8874818 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -233,6 +233,7 @@ export { export { resolveReferencedStart, + resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js"; diff --git a/packages/engine/src/services/referenceResolver.ts b/packages/engine/src/services/referenceResolver.ts index b43fcea65d..5f9dc516a7 100644 --- a/packages/engine/src/services/referenceResolver.ts +++ b/packages/engine/src/services/referenceResolver.ts @@ -88,7 +88,7 @@ export function resolveReferencedStart( * Returns null when only the natural media duration would settle it (unknown * at parse time) — the caller then treats the reference as duration-0. */ -function resolveReferencedDuration( +export function resolveReferencedDuration( doc: RefResolverDoc, el: RefResolverEl, startCache: Map, diff --git a/packages/studio/src/player/lib/describeClips.ts b/packages/studio/src/player/lib/describeClips.ts index 695c52a6b5..4c92515aa6 100644 --- a/packages/studio/src/player/lib/describeClips.ts +++ b/packages/studio/src/player/lib/describeClips.ts @@ -1,34 +1,9 @@ /** Plain-field clip facts from the player store; feeds the Ask-agent prompt and `studio_look`. */ import type { TimelineElement } from "../store/playerStore"; import { elementAutomationLanes } from "../components/automationLaneData"; -import { roundTo3 } from "../../utils/rounding"; - -export interface ClipLane { - /** `volume`, or `fx..`. */ - target: string; - /** `t` is seconds from the start of the clip, not the composition. */ - points: { t: number; v: number }[]; -} - -export interface ClipFact { - id: string; - label: string | null; - kind: string; - start: number; - duration: number; - end: number; - /** The `data-track-index` as written in the source file. */ - trackIndex: number; - src: string | null; - sourceFile: string | null; - /** `null` when `data-volume` is not authored (the clip plays at 1). */ - volume: number | null; - lanes: ClipLane[]; - /** `null` at normal speed: not authored, or 1 (the manifest defaults it to 1). */ - playbackRate: number | null; - audioGroup: string | null; - role: string | null; -} +import { byStart, formatClipLine, type ClipFact } from "@hyperframes/core/clip-facts"; +export { byStart } from "@hyperframes/core/clip-facts"; +export type { ClipFact } from "@hyperframes/core/clip-facts"; /** The store holds preview URLs; the agent edits project files, so drop the origin and preview prefix. */ function projectRelativeSrc(src: string): string { @@ -57,36 +32,10 @@ export function describeClip(element: TimelineElement): ClipFact { }; } -export const byStart = (a: ClipFact, b: ClipFact) => - a.start - b.start || a.trackIndex - b.trackIndex; - export function describeClips(elements: readonly TimelineElement[]): ClipFact[] { return elements.map(describeClip).sort(byStart); } -const num = (n: number) => String(roundTo3(n)); - -function formatClipLine(clip: ClipFact): string { - const parts = [ - `${clip.kind} "${clip.id}"`, - clip.src && `src=${clip.src}`, - `start=${num(clip.start)}`, - `duration=${num(clip.duration)}`, - `end=${num(clip.end)}`, - `track=${clip.trackIndex}`, - clip.volume !== null && `volume=${num(clip.volume)}`, - clip.playbackRate !== null && `rate=${num(clip.playbackRate)}`, - clip.audioGroup && `group=${clip.audioGroup}`, - clip.role && `role=${clip.role}`, - clip.sourceFile && `file=${clip.sourceFile}`, - ...clip.lanes.map( - (lane) => - `${lane.target}-lane=[${lane.points.map((p) => `${num(p.t)}:${num(p.v)}`).join(", ")}]`, - ), - ]; - return `- ${parts.filter(Boolean).join(" ")}`; -} - const PROMPT_CLIP_CAP = 200; /** The Ask-agent prompt's Timeline block; empty when the timeline has no clips. */ diff --git a/skills-manifest.json b/skills-manifest.json index b344086f66..b1b072e380 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,7 +18,7 @@ "files": 4 }, "hyperframes": { - "hash": "5b055fe7a020fcc5", + "hash": "05525e9c53582e4b", "files": 26 }, "hyperframes-animation": { @@ -30,11 +30,11 @@ "files": 7 }, "hyperframes-cli": { - "hash": "13f4e5fda3baf7c8", + "hash": "8fbf0e81f55bbf39", "files": 11 }, "hyperframes-core": { - "hash": "054fe5577b064927", + "hash": "0347ec802ad1bbe3", "files": 11 }, "hyperframes-creative": { diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index 459307346e..0e296a27e7 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -3,7 +3,7 @@ name: hyperframes-cli description: > Use the HyperFrames CLI development loop: init, add, catalog, capture, lint, check, snapshot, compare, grade-compare, preview, play, present, beats, keyframes, single or batch render, publish, - cloud, cloudrun, feedback, lambda, doctor, browser, info, upgrade, skills, compositions, docs, + cloud, cloudrun, feedback, lambda, doctor, browser, info, upgrade, skills, compositions, timeline, docs, benchmark, telemetry, transcribe, auth, tts, and remove-background. Also use when diagnosing build or render failures. validate, inspect, and layout are deprecated aliases; use check. Covers local, HeyGen-hosted cloud, AWS Lambda, and Google Cloud Run rendering. @@ -17,7 +17,7 @@ Run commands as `npx hyperframes ...` unless project instructions provide a wrap 1. **Scaffold:** `npx hyperframes init ` (centered blank). Or capture a site. Pass `--example=` only to start from a named example. 2. **Find the move:** before authoring motion by hand, search for a primitive that already does it: `npx hyperframes catalog --query "reveal a headline one line at a time"`. Ask for the effect you want rather than the mechanism you have in mind. Install with `npx hyperframes add ` (see `/hyperframes-registry`). Author by hand only once nothing fits. -3. **Author:** write the composition using `/hyperframes-core`. +3. **Author:** write the composition using `/hyperframes-core`. 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: nested rows carry absolute main-timeline `absStart`/`absEnd` and their owning `file`, not just their local, per-sub-composition time. Prefer `--json` over the text form; it costs fewer tokens for the same or better correctness. See `references/upgrade-info-misc.md` for one-liners that answer common questions without reading the whole output. 4. **Get fast feedback while editing:** run `npx hyperframes lint` after the first HTML pass and after structural changes. 5. **Run the final gate:** run `npx hyperframes check`; it reruns lint before opening the browser. Do not prepend a redundant standalone lint invocation. Add `--snapshots` for annotated overview frames and finding crops. 6. **Inspect sub-compositions:** when `index.html` mounts `data-composition-src`, capture midpoint snapshots and inspect each mounted scene. @@ -129,18 +129,18 @@ Keep clean-run feedback concise. For any bug or friction, capture a **reproducti The following references and owning skills are mandatory command contracts, not optional background reading. Before running a command in the table, read its matching row. -| Need | Reference | -| -------------------------------------------------------------------------------------- | ------------------------------------- | -| `init`, `capture`, `skills` | `references/init-and-scaffold.md` | -| `lint`, `check`, motion sidecars, `snapshot` | `references/lint-validate-inspect.md` | -| `compare`, `grade-compare`, variable-driven `render --batch` | `references/compare-and-batch.md` | -| `beats` for an existing project's Studio beat grid | `references/beats.md` | -| `preview`, `play`, `render`, `publish`, Studio context, feedback | `references/preview-render.md` | -| `doctor`, browser management | `references/doctor-browser.md` | -| `auth`, HeyGen-hosted cloud rendering, and template variables | `references/cloud.md` | -| AWS Lambda deployment and rendering | `references/lambda.md` | -| Google Cloud Run deployment and rendering | `references/cloudrun.md` | -| `info`, `upgrade`, `compositions`, `docs`, `benchmark`, telemetry, media preprocessing | `references/upgrade-info-misc.md` | +| Need | Reference | +| -------------------------------------------------------------------------------------------------- | ------------------------------------- | +| `init`, `capture`, `skills` | `references/init-and-scaffold.md` | +| `lint`, `check`, motion sidecars, `snapshot` | `references/lint-validate-inspect.md` | +| `compare`, `grade-compare`, variable-driven `render --batch` | `references/compare-and-batch.md` | +| `beats` for an existing project's Studio beat grid | `references/beats.md` | +| `preview`, `play`, `render`, `publish`, Studio context, feedback | `references/preview-render.md` | +| `doctor`, browser management | `references/doctor-browser.md` | +| `auth`, HeyGen-hosted cloud rendering, and template variables | `references/cloud.md` | +| AWS Lambda deployment and rendering | `references/lambda.md` | +| Google Cloud Run deployment and rendering | `references/cloudrun.md` | +| `info`, `upgrade`, `compositions`, `timeline`, `docs`, `benchmark`, telemetry, media preprocessing | `references/upgrade-info-misc.md` | For composition variables, also read `/hyperframes-core` → `references/variables-and-media.md`. For `hyperframes add` and `hyperframes catalog`, use `/hyperframes-registry`. Before `hyperframes present`, read `/slideshow`; before `hyperframes keyframes`, read `/hyperframes-keyframes`. For TTS, transcription, captions, or background removal choices, use `/media-use`. diff --git a/skills/hyperframes-cli/references/upgrade-info-misc.md b/skills/hyperframes-cli/references/upgrade-info-misc.md index 2fdc89537e..38d5233685 100644 --- a/skills/hyperframes-cli/references/upgrade-info-misc.md +++ b/skills/hyperframes-cli/references/upgrade-info-misc.md @@ -1,4 +1,4 @@ -# info, upgrade, compositions, docs, benchmark, telemetry, asset preprocessing +# info, upgrade, compositions, timeline, docs, benchmark, telemetry, asset preprocessing Catch-all reference for commands that don't fit the main dev loop. @@ -25,6 +25,51 @@ Compares the installed CLI version against npm latest. `--project [dir]` bumps a **project's** pinned scripts instead of the global install: it rewrites every `npx …hyperframes@…` in `/package.json` (default cwd) to npm-latest. Always invoke it unpinned (`npx hyperframes@latest upgrade --project`) — a project scaffolded on an old CLI stays frozen otherwise. `--project . --check` reports the delta without writing; add `--json` for `{ changed, from, to, path }`. Pass the dir explicitly whenever another flag follows `--project` — on older releases a bare `--project` consumes the next flag as its directory value. +## timeline + +```bash +npx hyperframes timeline [project-dir] # tracks and clips as a table with bars +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 s`, then one block per track kind (`video`, `graphics`, `captions`, `audio`) with a row per top-level element: + +``` +graphics (2) + |██████ | sec-connector 0-6.7s src=compositions/connector-morph.html + |██████████████ | box 15.67-17.99s (local 0-2.32s) in 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 `[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 -s)` (time inside that sub-composition), then `in ` (the file that declares the clip). Deeper nesting is not expanded (`children=unread` on that row). +- `duration=unauthored` (dotted bar) means the element has no `data-duration`/`data-end`, so its length is only known at render time (typically media). Add a `data-duration` if the length matters. +- `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`, `laneError`, and `children` (the sub-composition's rows, one level). + +### 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" +# 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`, per-instance vars) and animate inside it | -| `references/variables-and-media.md` | declare variables; place `