From 7b812714c5e4ca2f6d54321e06352a02f1910537 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 18:28:57 -0400 Subject: [PATCH 01/13] feat(cli): hyperframes timeline prints a project's tracks and clips --- packages/cli/src/cli.ts | 1 + packages/cli/src/commands/compositions.ts | 66 +--------- packages/cli/src/commands/timeline.ts | 31 +++++ .../cli/src/timeline/describeProject.test.ts | 84 ++++++++++++ packages/cli/src/timeline/describeProject.ts | 124 ++++++++++++++++++ packages/cli/src/timeline/formatTimeline.ts | 47 +++++++ packages/cli/src/utils/resolveStart.ts | 65 +++++++++ packages/core/package.json | 10 ++ packages/core/src/clipFacts.ts | 56 ++++++++ .../studio/src/player/lib/describeClips.ts | 57 +------- 10 files changed, 422 insertions(+), 119 deletions(-) create mode 100644 packages/cli/src/commands/timeline.ts create mode 100644 packages/cli/src/timeline/describeProject.test.ts create mode 100644 packages/cli/src/timeline/describeProject.ts create mode 100644 packages/cli/src/timeline/formatTimeline.ts create mode 100644 packages/cli/src/utils/resolveStart.ts create mode 100644 packages/core/src/clipFacts.ts 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..154ecf10db 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 { resolveStart } from "../utils/resolveStart.js"; 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"); 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/timeline/describeProject.test.ts b/packages/cli/src/timeline/describeProject.test.ts new file mode 100644 index 0000000000..1c5cdb327e --- /dev/null +++ b/packages/cli/src/timeline/describeProject.test.ts @@ -0,0 +1,84 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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"]], + ]); + 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("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); + }); +}); + +describe("formatTimeline", () => { + 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 (1)"); + expect(text).toContain("vol=0.5 group=vo volume[0:0.2 2:1]"); + expect(text).toContain("rate=2"); + }); +}); diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts new file mode 100644 index 0000000000..08c00796fb --- /dev/null +++ b/packages/cli/src/timeline/describeProject.ts @@ -0,0 +1,124 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } 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 { resolveReferencedDuration, resolveStart } from "../utils/resolveStart.js"; + +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; + /** Clips of a sub-composition, times 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 }; +} + +function readLanes(el: Element): ClipLane[] { + const raw = el.getAttribute(HF_AUDIO_AUTOMATION_ATTR); + if (!raw) return []; + try { + const fx = el.getAttribute(HF_AUDIO_FX_ATTR); + const chain = fx ? parseAudioFxChain(fx) : undefined; + return resolveAutomation(parseAutomation(raw), chain).lanes.map((lane) => ({ + target: lane.target, + points: lane.points.map(({ t, v }) => ({ t, v })), + })); + } catch { + return []; + } +} + +function describeRow(doc: Document, node: DomNode, baseDir: string, depth: number): TimelineRow { + const { el } = node; + const startCache = new Map(); + const start = resolveStart(doc, el, startCache, new Set()); + const authored = resolveReferencedDuration(doc, el, startCache, new Set()); + const host = el.getAttribute("data-composition-src"); + const children = host && depth === 0 ? readSubComposition(host, baseDir) : []; + const inner = children.reduce((max, c) => Math.max(max, c.end), 0); + const duration = authored ?? inner; + const rate = parseNumeric(el.getAttribute("data-playback-rate")); + return { + id: el.id || el.getAttribute("data-composition-id") || `${el.tagName.toLowerCase()}`, + label: null, + kind: el.tagName.toLowerCase(), + trackKind: trackKindOf(node).kind, + start, + duration, + end: start + duration, + trackIndex: parseNumeric(el.getAttribute("data-track-index")) ?? 0, + src: el.getAttribute("src") ?? host, + sourceFile: host, + volume: parseNumeric(el.getAttribute("data-volume")), + lanes: readLanes(el), + playbackRate: rate === 1 ? null : rate, + audioGroup: el.getAttribute(HF_AUDIO_GROUP_ATTR), + role: null, + durationAuthored: authored !== null, + children, + }; +} + +function readSubComposition(src: string, baseDir: string): TimelineRow[] { + const file = resolve(baseDir, src); + if (!existsSync(file)) return []; + const doc = new DOMParser().parseFromString(readFileSync(file, "utf-8"), "text/html"); + const template = doc.querySelector("template"); + const scope = template?.content ?? doc; + const root = scope.querySelector("[data-composition-id]"); + if (!root) return []; + return topLevelElements(toNode(root)) + .map((node) => describeRow(doc, node, dirname(file), 1)) + .sort(byStart); +} + +/** 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 rows = topLevelElements(toNode(root)) + .map((node) => describeRow(doc, node, dirname(indexPath), 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..1e3e8063bf --- /dev/null +++ b/packages/cli/src/timeline/formatTimeline.ts @@ -0,0 +1,47 @@ +import type { ProjectTimeline, TimelineRow } from "./describeProject.js"; + +const BAR_WIDTH = 40; +const n = (v: number) => String(Math.round(v * 100) / 100); + +function bar(row: TimelineRow, total: number): string { + if (total <= 0) return " ".repeat(BAR_WIDTH); + const from = Math.min(BAR_WIDTH - 1, Math.floor((row.start / total) * BAR_WIDTH)); + const to = row.durationAuthored || row.duration > 0 ? (row.end / total) * BAR_WIDTH : BAR_WIDTH; + const width = Math.max(1, Math.min(BAR_WIDTH, Math.ceil(to)) - from); + const fill = row.durationAuthored || row.duration > 0 ? "█" : "░"; + 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=${n(row.volume)}`, + row.playbackRate !== null && `rate=${n(row.playbackRate)}`, + row.audioGroup && `group=${row.audioGroup}`, + !row.durationAuthored && "duration=unauthored", + ...lanes, + ] + .filter(Boolean) + .join(" "); +} + +function line(row: TimelineRow, total: number, indent: string): string { + const times = `${n(row.start)}-${n(row.end)}s`; + return `${indent}|${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, " ")); + for (const child of row.children) + out.push(line(child, row.duration || timeline.duration, " ")); + } + } + return out.join("\n"); +} diff --git a/packages/cli/src/utils/resolveStart.ts b/packages/cli/src/utils/resolveStart.ts new file mode 100644 index 0000000000..5274b835d4 --- /dev/null +++ b/packages/cli/src/utils/resolveStart.ts @@ -0,0 +1,65 @@ +import { parseNumeric, parseStartExpression } from "@hyperframes/core"; + +function findReferenceTargetEl(doc: Document, refId: string): Element | null { + return doc.getElementById(refId) ?? doc.querySelector(`[data-composition-id="${refId}"]`); +} + +export 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); + } +} + +export 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; +} 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.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/studio/src/player/lib/describeClips.ts b/packages/studio/src/player/lib/describeClips.ts index 695c52a6b5..4a068fb3cb 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, ClipLane } 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. */ From 59e86d3a0658734256905445348fe77cfa718598 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 18:30:35 -0400 Subject: [PATCH 02/13] docs(cli): list the timeline command --- docs/developers/cli.mdx | 1 + 1 file changed, 1 insertion(+) 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/` | From 55cd797d0755ca7fb020dc0aaa0ac2f19c80ecd4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 18:41:15 -0400 Subject: [PATCH 03/13] feat(cli): timeline command reports unreadable lanes; add skill docs --- .../cli/src/timeline/describeProject.test.ts | 14 +++++++-- packages/cli/src/timeline/describeProject.ts | 20 ++++++++----- packages/cli/src/timeline/formatTimeline.ts | 6 ++-- skills-manifest.json | 2 +- skills/hyperframes-cli/SKILL.md | 4 +-- .../references/upgrade-info-misc.md | 29 ++++++++++++++++++- 6 files changed, 59 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/timeline/describeProject.test.ts b/packages/cli/src/timeline/describeProject.test.ts index 1c5cdb327e..ec4eae676f 100644 --- a/packages/cli/src/timeline/describeProject.test.ts +++ b/packages/cli/src/timeline/describeProject.test.ts @@ -12,6 +12,7 @@ const INDEX = `
+
`; @@ -36,7 +37,7 @@ describe("describeProject", () => { expect(timeline.tracks.map((t) => [t.kind, t.rows.map((r) => r.id)])).toEqual([ ["video", ["a-roll"]], ["graphics", ["logo", "title"]], - ["audio", ["vo"]], + ["audio", ["vo", "bad"]], ]); const [video] = timeline.tracks[0]!.rows; expect(video).toMatchObject({ start: 0, duration: 4, playbackRate: 2, src: "a.mp4" }); @@ -65,6 +66,15 @@ describe("describeProject", () => { ]); }); + 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("marks a clip without an authored duration instead of reporting 0 as a fact", () => { const logo = describeProject(project()) .tracks.flatMap((t) => t.rows) @@ -77,7 +87,7 @@ describe("formatTimeline", () => { 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 (1)"); + expect(text).toContain("audio (2)"); expect(text).toContain("vol=0.5 group=vo volume[0:0.2 2:1]"); expect(text).toContain("rate=2"); }); diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts index 08c00796fb..0cc8c2092c 100644 --- a/packages/cli/src/timeline/describeProject.ts +++ b/packages/cli/src/timeline/describeProject.ts @@ -21,6 +21,8 @@ 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; /** Clips of a sub-composition, times local to the host. One level only. */ children: TimelineRow[]; } @@ -47,18 +49,19 @@ function toNode(el: Element): DomNode { return { tag: el.tagName, attrs, children: Array.from(el.children).map(toNode), el }; } -function readLanes(el: Element): ClipLane[] { +function readLanes(el: Element): { lanes: ClipLane[]; laneError: string | null } { const raw = el.getAttribute(HF_AUDIO_AUTOMATION_ATTR); - if (!raw) return []; + if (!raw) return { lanes: [], laneError: null }; try { const fx = el.getAttribute(HF_AUDIO_FX_ATTR); const chain = fx ? parseAudioFxChain(fx) : undefined; - return resolveAutomation(parseAutomation(raw), chain).lanes.map((lane) => ({ + const lanes = resolveAutomation(parseAutomation(raw), chain).lanes.map((lane) => ({ target: lane.target, points: lane.points.map(({ t, v }) => ({ t, v })), })); - } catch { - return []; + return { lanes, laneError: null }; + } catch (err) { + return { lanes: [], laneError: err instanceof Error ? err.message : String(err) }; } } @@ -72,10 +75,11 @@ function describeRow(doc: Document, node: DomNode, baseDir: string, depth: numbe 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") || `${el.tagName.toLowerCase()}`, + id: el.id || el.getAttribute("data-composition-id") || kind, label: null, - kind: el.tagName.toLowerCase(), + kind, trackKind: trackKindOf(node).kind, start, duration, @@ -84,7 +88,7 @@ function describeRow(doc: Document, node: DomNode, baseDir: string, depth: numbe src: el.getAttribute("src") ?? host, sourceFile: host, volume: parseNumeric(el.getAttribute("data-volume")), - lanes: readLanes(el), + ...readLanes(el), playbackRate: rate === 1 ? null : rate, audioGroup: el.getAttribute(HF_AUDIO_GROUP_ATTR), role: null, diff --git a/packages/cli/src/timeline/formatTimeline.ts b/packages/cli/src/timeline/formatTimeline.ts index 1e3e8063bf..4c09c2217e 100644 --- a/packages/cli/src/timeline/formatTimeline.ts +++ b/packages/cli/src/timeline/formatTimeline.ts @@ -5,10 +5,11 @@ const n = (v: number) => String(Math.round(v * 100) / 100); 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.start / total) * BAR_WIDTH)); - const to = row.durationAuthored || row.duration > 0 ? (row.end / total) * BAR_WIDTH : BAR_WIDTH; + const to = known ? (row.end / total) * BAR_WIDTH : BAR_WIDTH; const width = Math.max(1, Math.min(BAR_WIDTH, Math.ceil(to)) - from); - const fill = row.durationAuthored || row.duration > 0 ? "█" : "░"; + const fill = known ? "█" : "░"; return " ".repeat(from) + fill.repeat(width) + " ".repeat(BAR_WIDTH - from - width); } @@ -22,6 +23,7 @@ function details(row: TimelineRow): string { row.playbackRate !== null && `rate=${n(row.playbackRate)}`, row.audioGroup && `group=${row.audioGroup}`, !row.durationAuthored && "duration=unauthored", + row.laneError && `lanes unreadable: ${row.laneError}`, ...lanes, ] .filter(Boolean) diff --git a/skills-manifest.json b/skills-manifest.json index b344086f66..73afd04260 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -30,7 +30,7 @@ "files": 7 }, "hyperframes-cli": { - "hash": "13f4e5fda3baf7c8", + "hash": "d5dd9f959a9cdf4a", "files": 11 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index 459307346e..cc1e8fcac7 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. @@ -140,7 +140,7 @@ The following references and owning skills are mandatory command contracts, not | `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` | +| `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..612ea70637 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,33 @@ 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 0-2.32s +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, with times local to that sub-composition. Deeper nesting is not expanded. +- `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`, `trackIndex`, `src`, `sourceFile`, `volume`, `lanes`, `playbackRate`, `audioGroup`, `durationAuthored`, `laneError`, and `children` (the sub-composition's rows). + ## compositions, docs ```bash From 3694a8459d8dd7574c43beb54888e90f924af4af Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 19:22:55 -0400 Subject: [PATCH 04/13] fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes --- packages/cli/src/help.ts | 1 + .../cli/src/timeline/describeProject.test.ts | 44 ++++++++++++++ packages/cli/src/timeline/describeProject.ts | 60 +++++++++++++++---- packages/cli/src/timeline/formatTimeline.ts | 4 +- packages/core/package-subpaths.json | 6 ++ skills-manifest.json | 2 +- skills/hyperframes-cli/SKILL.md | 22 +++---- 7 files changed, 112 insertions(+), 27 deletions(-) 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 index ec4eae676f..accfa61b97 100644 --- a/packages/cli/src/timeline/describeProject.test.ts +++ b/packages/cli/src/timeline/describeProject.test.ts @@ -75,15 +75,59 @@ describe("describeProject", () => { 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("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", + ); }); }); 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}/); diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts index 0cc8c2092c..4dbdc0129f 100644 --- a/packages/cli/src/timeline/describeProject.ts +++ b/packages/cli/src/timeline/describeProject.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { readFileSync, statSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; import { HF_AUDIO_AUTOMATION_ATTR, parseAutomation, @@ -49,12 +49,21 @@ function toNode(el: Element): DomNode { 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 ? parseAudioFxChain(fx) : undefined; + 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 })), @@ -65,13 +74,22 @@ function readLanes(el: Element): { lanes: ClipLane[]; laneError: string | null } } } -function describeRow(doc: Document, node: DomNode, baseDir: string, depth: number): TimelineRow { +/** 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; +} + +function describeRow(scope: DocScope, node: DomNode, depth: number): TimelineRow { const { el } = node; - const startCache = new Map(); + const { doc, startCache } = scope; const start = resolveStart(doc, el, startCache, new Set()); const authored = resolveReferencedDuration(doc, el, startCache, new Set()); const host = el.getAttribute("data-composition-src"); - const children = host && depth === 0 ? readSubComposition(host, baseDir) : []; + const children = host && depth === 0 ? readSubComposition(host, scope) : []; const inner = children.reduce((max, c) => Math.max(max, c.end), 0); const duration = authored ?? inner; const rate = parseNumeric(el.getAttribute("data-playback-rate")); @@ -97,25 +115,41 @@ function describeRow(doc: Document, node: DomNode, baseDir: string, depth: numbe }; } -function readSubComposition(src: string, baseDir: string): TimelineRow[] { - const file = resolve(baseDir, src); - if (!existsSync(file)) return []; +function readSubComposition(src: string, parent: DocScope): TimelineRow[] { + const file = resolve(parent.dir, src); + const inside = relative(parent.projectDir, file); + if (inside.startsWith("..") || !isFile(file)) return []; const doc = new DOMParser().parseFromString(readFileSync(file, "utf-8"), "text/html"); const template = doc.querySelector("template"); - const scope = template?.content ?? doc; - const root = scope.querySelector("[data-composition-id]"); + 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, + }; return topLevelElements(toNode(root)) - .map((node) => describeRow(doc, node, dirname(file), 1)) + .map((node) => describeRow(scope, node, 1)) .sort(byStart); } +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + /** 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 }; const rows = topLevelElements(toNode(root)) - .map((node) => describeRow(doc, node, dirname(indexPath), 0)) + .map((node) => describeRow(scope, node, 0)) .sort(byStart); const declared = parseNumeric(root.getAttribute("data-duration")); return { diff --git a/packages/cli/src/timeline/formatTimeline.ts b/packages/cli/src/timeline/formatTimeline.ts index 4c09c2217e..e3f900e4f6 100644 --- a/packages/cli/src/timeline/formatTimeline.ts +++ b/packages/cli/src/timeline/formatTimeline.ts @@ -1,7 +1,7 @@ import type { ProjectTimeline, TimelineRow } from "./describeProject.js"; const BAR_WIDTH = 40; -const n = (v: number) => String(Math.round(v * 100) / 100); +const n = (v: number) => String(Math.round(v * 1000) / 1000); function bar(row: TimelineRow, total: number): string { if (total <= 0) return " ".repeat(BAR_WIDTH); @@ -19,7 +19,7 @@ function details(row: TimelineRow): string { ); return [ row.src && `src=${row.src}`, - row.volume !== null && `vol=${n(row.volume)}`, + row.volume !== null && `vol=${row.volume}`, row.playbackRate !== null && `rate=${n(row.playbackRate)}`, row.audioGroup && `group=${row.audioGroup}`, !row.durationAuthored && "duration=unauthored", 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/skills-manifest.json b/skills-manifest.json index 73afd04260..e034ead966 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -30,7 +30,7 @@ "files": 7 }, "hyperframes-cli": { - "hash": "d5dd9f959a9cdf4a", + "hash": "6691d01bb94ef8a3", "files": 11 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index cc1e8fcac7..2e0a773475 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -129,17 +129,17 @@ 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` | +| 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`. From 960d83e372292352a779a128eee5abd1ab3a15f3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 20:13:23 -0400 Subject: [PATCH 05/13] fix(cli): timeline resolves symlinks before the project containment check --- .../cli/src/timeline/describeProject.test.ts | 16 +++++++++++++++- packages/cli/src/timeline/describeProject.ts | 19 +++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/timeline/describeProject.test.ts b/packages/cli/src/timeline/describeProject.test.ts index accfa61b97..4487fd71b3 100644 --- a/packages/cli/src/timeline/describeProject.test.ts +++ b/packages/cli/src/timeline/describeProject.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; @@ -90,6 +90,20 @@ describe("describeProject", () => { rmSync(join(dir, "..", "hf-outside.html")); }); + 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) diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts index 4dbdc0129f..e0bec8347e 100644 --- a/packages/cli/src/timeline/describeProject.ts +++ b/packages/cli/src/timeline/describeProject.ts @@ -1,5 +1,5 @@ -import { readFileSync, statSync } from "node:fs"; -import { dirname, relative, resolve } from "node:path"; +import { readFileSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import { HF_AUDIO_AUTOMATION_ATTR, parseAutomation, @@ -116,9 +116,8 @@ function describeRow(scope: DocScope, node: DomNode, depth: number): TimelineRow } function readSubComposition(src: string, parent: DocScope): TimelineRow[] { - const file = resolve(parent.dir, src); - const inside = relative(parent.projectDir, file); - if (inside.startsWith("..") || !isFile(file)) return []; + const file = realFileInside(parent.projectDir, resolve(parent.dir, src)); + 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]"); @@ -134,11 +133,15 @@ function readSubComposition(src: string, parent: DocScope): TimelineRow[] { .sort(byStart); } -function isFile(path: string): boolean { +/** 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 { - return statSync(path).isFile(); + const real = realpathSync(path); + const inside = relative(realpathSync(projectDir), real); + if (inside.startsWith("..") || isAbsolute(inside)) return null; + return statSync(real).isFile() ? real : null; } catch { - return false; + return null; } } From 70cb5ea54b1039d9413367faeb4d90bb3428d4d6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 20:29:02 -0400 Subject: [PATCH 06/13] fix(cli): timeline accepts project folders whose name starts with two dots --- packages/cli/src/timeline/describeProject.test.ts | 12 ++++++++++++ packages/cli/src/timeline/describeProject.ts | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/timeline/describeProject.test.ts b/packages/cli/src/timeline/describeProject.test.ts index 4487fd71b3..8349580727 100644 --- a/packages/cli/src/timeline/describeProject.test.ts +++ b/packages/cli/src/timeline/describeProject.test.ts @@ -90,6 +90,18 @@ describe("describeProject", () => { 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-")); diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts index e0bec8347e..3c98e90708 100644 --- a/packages/cli/src/timeline/describeProject.ts +++ b/packages/cli/src/timeline/describeProject.ts @@ -1,5 +1,5 @@ import { readFileSync, realpathSync, statSync } from "node:fs"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { HF_AUDIO_AUTOMATION_ATTR, parseAutomation, @@ -138,7 +138,7 @@ function realFileInside(projectDir: string, path: string): string | null { try { const real = realpathSync(path); const inside = relative(realpathSync(projectDir), real); - if (inside.startsWith("..") || isAbsolute(inside)) return null; + if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return null; return statSync(real).isFile() ? real : null; } catch { return null; From e1ce495be54feeb634f41f4c7f1f7174e70c3c56 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 20:44:59 -0400 Subject: [PATCH 07/13] refactor(cli): timeline and compositions share the engine's start resolver --- packages/cli/src/commands/compositions.ts | 6 +- packages/cli/src/timeline/describeProject.ts | 4 +- packages/cli/src/utils/resolveStart.ts | 65 ------------------- packages/core/src/clipFacts.test.ts | 62 ++++++++++++++++++ packages/engine/src/index.ts | 1 + .../engine/src/services/referenceResolver.ts | 2 +- .../studio/src/player/lib/describeClips.ts | 2 +- 7 files changed, 70 insertions(+), 72 deletions(-) delete mode 100644 packages/cli/src/utils/resolveStart.ts create mode 100644 packages/core/src/clipFacts.test.ts diff --git a/packages/cli/src/commands/compositions.ts b/packages/cli/src/commands/compositions.ts index 154ecf10db..c467e8e001 100644 --- a/packages/cli/src/commands/compositions.ts +++ b/packages/cli/src/commands/compositions.ts @@ -10,7 +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 { resolveStart } from "../utils/resolveStart.js"; +import { resolveReferencedStart } from "@hyperframes/engine"; import { withMeta } from "../utils/updateCheck.js"; interface CompositionInfo { @@ -78,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"); @@ -148,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/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts index 3c98e90708..28f3f5609b 100644 --- a/packages/cli/src/timeline/describeProject.ts +++ b/packages/cli/src/timeline/describeProject.ts @@ -15,7 +15,7 @@ import { type StructureNode, type TrackKind, } from "@hyperframes/parsers"; -import { resolveReferencedDuration, resolveStart } from "../utils/resolveStart.js"; +import { resolveReferencedDuration, resolveReferencedStart } from "@hyperframes/engine"; export interface TimelineRow extends ClipFact { trackKind: TrackKind; @@ -86,7 +86,7 @@ interface DocScope { function describeRow(scope: DocScope, node: DomNode, depth: number): TimelineRow { const { el } = node; const { doc, startCache } = scope; - const start = resolveStart(doc, el, startCache, new Set()); + const start = resolveReferencedStart(doc, el, startCache, new Set()); const authored = resolveReferencedDuration(doc, el, startCache, new Set()); const host = el.getAttribute("data-composition-src"); const children = host && depth === 0 ? readSubComposition(host, scope) : []; diff --git a/packages/cli/src/utils/resolveStart.ts b/packages/cli/src/utils/resolveStart.ts deleted file mode 100644 index 5274b835d4..0000000000 --- a/packages/cli/src/utils/resolveStart.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { parseNumeric, parseStartExpression } from "@hyperframes/core"; - -function findReferenceTargetEl(doc: Document, refId: string): Element | null { - return doc.getElementById(refId) ?? doc.querySelector(`[data-composition-id="${refId}"]`); -} - -export 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); - } -} - -export 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; -} 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/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 4a068fb3cb..4c92515aa6 100644 --- a/packages/studio/src/player/lib/describeClips.ts +++ b/packages/studio/src/player/lib/describeClips.ts @@ -3,7 +3,7 @@ import type { TimelineElement } from "../store/playerStore"; import { elementAutomationLanes } from "../components/automationLaneData"; import { byStart, formatClipLine, type ClipFact } from "@hyperframes/core/clip-facts"; export { byStart } from "@hyperframes/core/clip-facts"; -export type { ClipFact, ClipLane } 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 { From d0c34e05a876b84b4094633b9096c8037aaa5b8d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 21:58:23 -0400 Subject: [PATCH 08/13] docs(skills): point agents at the timeline command where they decide how to read a project --- skills-manifest.json | 6 +++--- skills/hyperframes-cli/SKILL.md | 2 +- skills/hyperframes-core/SKILL.md | 25 +++++++++++++------------ skills/hyperframes/SKILL.md | 16 ++++++++-------- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index e034ead966..91a82fcb28 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,7 +18,7 @@ "files": 4 }, "hyperframes": { - "hash": "5b055fe7a020fcc5", + "hash": "7b1238ac0d46f2ef", "files": 26 }, "hyperframes-animation": { @@ -30,11 +30,11 @@ "files": 7 }, "hyperframes-cli": { - "hash": "6691d01bb94ef8a3", + "hash": "5892bfe8f02b4736", "files": 11 }, "hyperframes-core": { - "hash": "054fe5577b064927", + "hash": "3b3acf32da13ebe7", "files": 11 }, "hyperframes-creative": { diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index 2e0a773475..24227efc31 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -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. 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. diff --git a/skills/hyperframes-core/SKILL.md b/skills/hyperframes-core/SKILL.md index 0ffe812706..f7555e09d4 100644 --- a/skills/hyperframes-core/SKILL.md +++ b/skills/hyperframes-core/SKILL.md @@ -18,18 +18,18 @@ This skill is the **technical contract** — how to build one hyperframes projec ## References -| File | Read it to… | -| --------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `references/minimal-composition.md` | start from the smallest renderable composition skeleton | -| `references/composition-patterns.md` | choose monolithic vs modular; structure a modular `index.html`; pick a sub-comp archetype | -| `references/data-attributes.md` | look up any `data-*` (root / clip / sub-comp host / legacy aliases); use `class="clip"` | -| `references/tracks-and-clips.md` | understand what `data-track-index` does (and does not) control, z-index, time a clip relative to another | -| `references/creator-editing-recipes.md` | copy truthful cut/trim/reorder/retime/freeze/camera/mask/crossfade/audio editing recipes and their limits | -| `references/sub-compositions.md` | wire a sub-composition (host attrs, `