From e7b16ae2b3daa3ceea9450b282632ece0bcd1e4f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:31:51 -0400 Subject: [PATCH 01/40] feat(cli): add timeline agent mutation commands --- packages/cli/src/commands/timeline.ts | 250 ++++++++++++++++++++++- packages/cli/src/timeline/a2Mutations.ts | 119 +++++++++++ 2 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/timeline/a2Mutations.ts diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 6ec6448e01..ffbab92f43 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -24,6 +24,12 @@ import { ensureDOMParser } from "../utils/dom.js"; import { setCommandExitCode } from "../utils/commandResult.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; +import { + duplicateElement, + parseSetAssignments, + setAttributes, + stampHfIds, +} from "../timeline/a2Mutations.js"; export const examples: Example[] = [ ["Show every track and clip of the project in the current directory", "hyperframes timeline"], @@ -31,7 +37,7 @@ export const examples: Example[] = [ ["Delete a clip and return a receipt", "hyperframes timeline delete '#hero' --json"], ]; -type MutationVerb = "move" | "trim" | "split" | "delete"; +type MutationVerb = "move" | "trim" | "split" | "delete" | "set" | "duplicate"; type MutationDecision = | { ok: true; after: string; nextStart: number; nextDuration: number } @@ -298,6 +304,49 @@ function deleteMutation(context: MutationContext): MutationDecision { }; } +function setMutation(context: MutationContext, args: Record): MutationDecision { + const positionalAssignments = (args._ ?? []) + .slice(1) + .filter((value): value is string => typeof value === "string"); + const namedAssignments = ["volume", "rate", "track"].flatMap((field) => { + const value = args[field]; + return typeof value === "string" ? [`${field}=${value}`] : []; + }); + const assignments = parseSetAssignments([...positionalAssignments, ...namedAssignments]); + if (!assignments.ok) return assignments; + const patched = setAttributes(context.before, context.resolved.target, assignments.assignments); + if (!patched.matched) { + return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; + } + return { + ok: true, + after: patched.html, + nextStart: context.row.start, + nextDuration: context.row.duration, + }; +} + +function duplicateMutation(context: MutationContext, args: Record): MutationDecision { + const expression = typeof args.at === "string" ? args.at : String(context.row.end); + const time = parseMutationTime(context, expression, "pass a valid insertion time"); + if (!time.ok) return time; + const duplicate = duplicateElement( + context.before, + context.resolved.target, + `${splitBaseId(context.row)}-copy`, + time.seconds, + ); + if (!duplicate.matched) { + return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; + } + return { + ok: true, + after: duplicate.html, + nextStart: time.seconds, + nextDuration: context.row.duration, + }; +} + function decideMutation( verb: MutationVerb, context: MutationContext, @@ -312,6 +361,10 @@ function decideMutation( return splitMutation(context, args); case "delete": return deleteMutation(context); + case "set": + return setMutation(context, args); + case "duplicate": + return duplicateMutation(context, args); } } @@ -563,6 +616,168 @@ function applyMutation( } } +function positional(args: Record): string[] { + return Array.isArray(args._) ? args._.filter((value): value is string => typeof value === "string") : []; +} + +async function runIds(args: Record): Promise { + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const json = args.json === true; + ensureDOMParser(); + const beforeTimeline = await describeProject(project.indexPath); + const files = [...new Set(["index.html", ...allRows(beforeTimeline).map((row) => row.file)])]; + const inputs = files.flatMap((file) => { + const before = readFileSync(join(project.dir, file), "utf-8"); + const after = stampHfIds(before); + return after === before + ? [] + : [{ sourceFile: file, absPath: join(project.dir, file), before, after }]; + }); + const receipts = inputs.length > 0 ? applyFileMutations(project.dir, inputs) : []; + const afterTimeline = await describeProject(project.indexPath); + const result = { + ok: true, + receipt: receipts.map((receipt) => publicReceipt(receipt)), + file: files, + before: allRows(beforeTimeline), + after: allRows(afterTimeline), + diff: "", + warnings: [], + }; + if (json) console.log(JSON.stringify(withMeta(result), null, 2)); + else console.log(`ids: stamped ${receipts.length} file${receipts.length === 1 ? "" : "s"}`); +} + +async function runApply(args: Record): Promise { + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const json = args.json === true; + const plan = args.plan === true; + const file = typeof args.file === "string" ? args.file : positional(args)[1]; + if (!file) return refusal("an edit plan is required", "pass an edits.json path or -", json); + const raw = file === "-" ? readFileSync(0, "utf-8") : readFileSync(file, "utf-8"); + let edits: unknown; + try { + edits = JSON.parse(raw); + } catch { + return refusal("edit plan is not valid JSON", "pass a JSON array of edits", json); + } + if (!Array.isArray(edits)) return refusal("edit plan must be a JSON array", "pass a JSON array of edits", json); + ensureDOMParser(); + const timeline = await describeProject(project.indexPath); + const sourceByFile = new Map(); + const beforeByFile = new Map(); + for (const fileName of new Set(allRows(timeline).map((row) => row.file))) { + const source = readFileSync(join(project.dir, fileName), "utf-8"); + sourceByFile.set(fileName, source); + beforeByFile.set(fileName, source); + } + for (const edit of edits) { + if (!isRecord(edit) || typeof edit.verb !== "string" || typeof edit.ref !== "string") { + return refusal("each edit needs a verb and ref", "pass {verb, ref, ...} objects", json); + } + const verb = edit.verb; + if (!(verb === "move" || verb === "trim" || verb === "split" || verb === "delete" || verb === "set" || verb === "duplicate")) { + return refusal(`unsupported edit verb ${verb}`, "use move, trim, split, delete, set, or duplicate", json); + } + const resolved = resolveRef(timeline, edit.ref); + if (!resolved.ok) return refusal(resolved.reason, resolved.fix, json); + const row = resolved.row; + const before = sourceByFile.get(row.file); + if (before === undefined) return refusal(`${row.file} was not found`, "choose an existing clip", json); + const parseTime = (expression: string) => + parseTimeExpression(expression, { + row, + duration: timeline.duration, + fps: fpsFor(project.indexPath), + resolveAnchor: (anchorRef) => { + const anchor = resolveRef(timeline, anchorRef); + return anchor.ok ? anchor.row : undefined; + }, + }); + const context: MutationContext = { + ref: edit.ref, + row, + before, + resolved, + parseTime, + duration: row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, + }; + const decision = decideMutation(verb, context, { ...edit, _: [edit.ref] }); + if (!decision.ok) return refusal(decision.reason, decision.fix, json); + const conflict = mutationConflict(verb, false, row, timeline, decision.nextStart, decision.nextDuration); + if (conflict) return refusal(conflict.reason, conflict.fix, json); + sourceByFile.set(row.file, decision.after); + } + const inputs = [...sourceByFile].flatMap(([fileName, after]) => { + const before = beforeByFile.get(fileName)!; + return after === before + ? [] + : [{ sourceFile: fileName, absPath: join(project.dir, fileName), before, after, expectedVersion: fileContentVersion(before) }]; + }); + const afterTimeline = await describeProject(project.indexPath, undefined, sourceByFile); + const result = { + ok: true, + planned: plan, + receipt: null as unknown, + file: inputs.map((input) => input.sourceFile), + before: allRows(timeline), + after: allRows(afterTimeline), + diff: inputs.map((input) => diff(input.before, input.after)).filter(Boolean).join("\n"), + warnings: [], + }; + if (!plan) { + const receipts = inputs.length > 0 ? applyFileMutations(project.dir, inputs) : []; + result.receipt = receipts.map((receipt) => publicReceipt(receipt)); + } + if (json) console.log(JSON.stringify(withMeta(result), null, 2)); + else console.log(`${plan ? "planned" : "applied"} ${inputs.length} file${inputs.length === 1 ? "" : "s"}`); +} + +async function runUndo(args: Record): Promise { + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const json = args.json === true; + const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1]; + if (!input) return refusal("an undo receipt is required", "pass the receipt JSON or its file", json); + let raw: string; + try { + raw = readFileSync(input, "utf-8"); + } catch { + raw = input; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return refusal("undo receipt is not valid JSON", "pass the applied JSON receipt", json); + } + const value = isRecord(parsed) && isRecord(parsed.receipt) ? parsed.receipt : parsed; + if (!isRecord(value) || typeof value.file !== "string" || typeof value.version !== "string" || typeof value.backupPath !== "string") { + return refusal("undo receipt is missing file, version, or backupPath", "pass an applied timeline receipt", json); + } + const backup = join(project.dir, value.backupPath); + const target = join(project.dir, value.file); + const before = readFileSync(target, "utf-8"); + const after = readFileSync(backup, "utf-8"); + const receipts = applyFileMutations(project.dir, [{ sourceFile: value.file, absPath: target, before, after, expectedVersion: value.version }]); + const result = { ok: true, receipt: receipts.map((receipt) => publicReceipt(receipt)), file: value.file }; + if (json) console.log(JSON.stringify(withMeta(result), null, 2)); + else console.log(`undid ${value.file}`); +} + +function publicReceipt(receipt: AppliedFileMutation) { + return { + file: receipt.sourceFile, + version: receipt.version, + writeToken: receipt.writeToken, + changed: receipt.changed, + backupPath: receipt.backupPath, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function mutationRefusal( verb: MutationVerb, after: string, @@ -596,6 +811,7 @@ function mutationCommand(verb: MutationVerb) { args: { ref: { type: "positional", required: true }, time: { type: "positional", required: verb === "move" || verb === "split" }, + at: { type: "string" }, dir: { type: "string" }, start: { type: "string" }, end: { type: "string" }, @@ -622,6 +838,38 @@ export default defineCommand({ trim: () => mutationCommand("trim"), split: () => mutationCommand("split"), delete: () => mutationCommand("delete"), + set: () => mutationCommand("set"), + duplicate: () => mutationCommand("duplicate"), + ids: () => defineCommand({ + meta: { name: "ids", description: "Stamp stable ids on timeline clips" }, + args: { dir: { type: "string" }, json: { type: "boolean", default: false } }, + async run({ args }) { + await runIds(args); + }, + }), + apply: () => defineCommand({ + meta: { name: "apply", description: "Apply an atomic timeline edit plan" }, + args: { + file: { type: "positional", required: true }, + dir: { type: "string" }, + json: { type: "boolean", default: false }, + plan: { type: "boolean", default: false }, + }, + async run({ args }) { + await runApply(args); + }, + }), + undo: () => defineCommand({ + meta: { name: "undo", description: "Restore a timeline mutation receipt" }, + args: { + receipt: { type: "positional", required: true }, + dir: { type: "string" }, + json: { type: "boolean", default: false }, + }, + async run({ args }) { + await runUndo(args); + }, + }), }, async run({ args }) { if (args._?.[0]) return; diff --git a/packages/cli/src/timeline/a2Mutations.ts b/packages/cli/src/timeline/a2Mutations.ts new file mode 100644 index 0000000000..be55a38828 --- /dev/null +++ b/packages/cli/src/timeline/a2Mutations.ts @@ -0,0 +1,119 @@ +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; +import type { SourceMutationTarget } from "@hyperframes/studio-server"; +import { parseHTML } from "linkedom"; + +export type SetField = "volume" | "rate" | "track"; + +export interface SetAssignment { + field: SetField; + value: string; +} + +export function stampHfIds(source: string): string { + return ensureHfIds(source); +} + +export function parseSetAssignments(values: readonly string[]): + | { ok: true; assignments: SetAssignment[] } + | { ok: false; reason: string; fix: string } { + const assignments: SetAssignment[] = []; + for (const value of values) { + const match = /^(volume|rate|track)=(.+)$/.exec(value); + if (!match) { + return { + ok: false, + reason: `unsupported set assignment ${value}`, + fix: "use volume=, rate=, or track=", + }; + } + const field = match[1] as SetField; + const number = Number(match[2]); + if (!Number.isFinite(number) || (field === "track" && !Number.isInteger(number))) { + return { + ok: false, + reason: `${field} must be a valid ${field === "track" ? "integer" : "number"}`, + fix: `pass ${field}=`, + }; + } + if (field === "rate" && number <= 0) { + return { ok: false, reason: "rate must be positive", fix: "pass rate=" }; + } + assignments.push({ field, value: String(number) }); + } + return { ok: true, assignments }; +} + +export function setAttributes( + source: string, + target: SourceMutationTarget, + assignments: readonly SetAssignment[], +): { html: string; matched: boolean } { + const document = parseHTML(source).document; + const element = findTarget(document, target); + if (!element) return { html: source, matched: false }; + for (const assignment of assignments) { + const attribute = + assignment.field === "volume" + ? "data-volume" + : assignment.field === "rate" + ? "data-playback-rate" + : "data-track-index"; + element.setAttribute(attribute, assignment.value); + } + return { html: document.toString(), matched: true }; +} + +export function duplicateElement( + source: string, + target: SourceMutationTarget, + newId: string, + at: number, +): { html: string; matched: boolean; newId: string | null } { + const document = parseHTML(source).document; + const element = findTarget(document, target); + if (!element || !element.parentElement) return { html: source, matched: false, newId: null }; + const start = numericAttribute(element, "data-start"); + const duration = numericAttribute(element, "data-duration"); + const track = numericAttribute(element, "data-track-index") ?? 0; + if (start === null || duration === null) return { html: source, matched: false, newId: null }; + let uniqueId = newId; + let suffix = 2; + while (document.getElementById(uniqueId)) uniqueId = `${newId}-${suffix++}`; + for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) { + if (candidate === element || candidate.getAttribute("data-track-index") !== String(track)) continue; + const candidateStart = numericAttribute(candidate, "data-start"); + if (candidateStart !== null && candidateStart >= at) { + candidate.setAttribute("data-start", String(candidateStart + duration)); + } + } + const clone = element.cloneNode(true); + if (clone.nodeType !== 1) { + return { html: source, matched: false, newId: null }; + } + clone.setAttribute("id", uniqueId); + clone.removeAttribute("data-hf-id"); + for (const child of Array.from(clone.querySelectorAll("[data-hf-id]"))) child.removeAttribute("data-hf-id"); + clone.setAttribute("data-start", String(at)); + element.parentElement.insertBefore(clone, element.nextSibling); + return { html: document.toString(), matched: true, newId: uniqueId }; +} + +function numericAttribute(element: Element, name: string): number | null { + const value = Number(element.getAttribute(name)); + return Number.isFinite(value) ? value : null; +} + +function findTarget(document: Document, target: SourceMutationTarget): Element | null { + if (target.hfId) { + const element = Array.from(document.querySelectorAll("[data-hf-id]")).find( + (candidate) => candidate.getAttribute("data-hf-id") === target.hfId, + ); + if (element) return element; + } + if (target.id) { + const element = document.getElementById(target.id); + if (element) return element; + } + if (!target.selector) return null; + return document.querySelectorAll(target.selector)[target.selectorIndex ?? 0] ?? null; +} From 320aac294cb3155d609ff394a15b225af9cbec98 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:34:23 -0400 Subject: [PATCH 02/40] fix(cli): type timeline a2 mutations --- packages/cli/src/commands/timeline.ts | 2 +- packages/cli/src/timeline/a2Mutations.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index ffbab92f43..95f52cf254 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -305,7 +305,7 @@ function deleteMutation(context: MutationContext): MutationDecision { } function setMutation(context: MutationContext, args: Record): MutationDecision { - const positionalAssignments = (args._ ?? []) + const positionalAssignments = positional(args) .slice(1) .filter((value): value is string => typeof value === "string"); const namedAssignments = ["volume", "rate", "track"].flatMap((field) => { diff --git a/packages/cli/src/timeline/a2Mutations.ts b/packages/cli/src/timeline/a2Mutations.ts index be55a38828..9817a94688 100644 --- a/packages/cli/src/timeline/a2Mutations.ts +++ b/packages/cli/src/timeline/a2Mutations.ts @@ -87,7 +87,7 @@ export function duplicateElement( } } const clone = element.cloneNode(true); - if (clone.nodeType !== 1) { + if (!isElementNode(clone)) { return { html: source, matched: false, newId: null }; } clone.setAttribute("id", uniqueId); @@ -117,3 +117,7 @@ function findTarget(document: Document, target: SourceMutationTarget): Element | if (!target.selector) return null; return document.querySelectorAll(target.selector)[target.selectorIndex ?? 0] ?? null; } + +function isElementNode(node: Node): node is Element { + return node.nodeType === 1 && "setAttribute" in node && "querySelectorAll" in node; +} From 23f0d145cff9ddf228b969017aa103a501d1ca84 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:37:28 -0400 Subject: [PATCH 03/40] style(cli): format timeline a2 commands --- packages/cli/src/commands/timeline.ts | 153 ++++++++++++++++------- packages/cli/src/timeline/a2Mutations.ts | 12 +- 2 files changed, 114 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 95f52cf254..5752609005 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -326,7 +326,10 @@ function setMutation(context: MutationContext, args: Record): M }; } -function duplicateMutation(context: MutationContext, args: Record): MutationDecision { +function duplicateMutation( + context: MutationContext, + args: Record, +): MutationDecision { const expression = typeof args.at === "string" ? args.at : String(context.row.end); const time = parseMutationTime(context, expression, "pass a valid insertion time"); if (!time.ok) return time; @@ -617,7 +620,9 @@ function applyMutation( } function positional(args: Record): string[] { - return Array.isArray(args._) ? args._.filter((value): value is string => typeof value === "string") : []; + return Array.isArray(args._) + ? args._.filter((value): value is string => typeof value === "string") + : []; } async function runIds(args: Record): Promise { @@ -661,7 +666,8 @@ async function runApply(args: Record): Promise { } catch { return refusal("edit plan is not valid JSON", "pass a JSON array of edits", json); } - if (!Array.isArray(edits)) return refusal("edit plan must be a JSON array", "pass a JSON array of edits", json); + if (!Array.isArray(edits)) + return refusal("edit plan must be a JSON array", "pass a JSON array of edits", json); ensureDOMParser(); const timeline = await describeProject(project.indexPath); const sourceByFile = new Map(); @@ -676,14 +682,28 @@ async function runApply(args: Record): Promise { return refusal("each edit needs a verb and ref", "pass {verb, ref, ...} objects", json); } const verb = edit.verb; - if (!(verb === "move" || verb === "trim" || verb === "split" || verb === "delete" || verb === "set" || verb === "duplicate")) { - return refusal(`unsupported edit verb ${verb}`, "use move, trim, split, delete, set, or duplicate", json); + if ( + !( + verb === "move" || + verb === "trim" || + verb === "split" || + verb === "delete" || + verb === "set" || + verb === "duplicate" + ) + ) { + return refusal( + `unsupported edit verb ${verb}`, + "use move, trim, split, delete, set, or duplicate", + json, + ); } const resolved = resolveRef(timeline, edit.ref); if (!resolved.ok) return refusal(resolved.reason, resolved.fix, json); const row = resolved.row; const before = sourceByFile.get(row.file); - if (before === undefined) return refusal(`${row.file} was not found`, "choose an existing clip", json); + if (before === undefined) + return refusal(`${row.file} was not found`, "choose an existing clip", json); const parseTime = (expression: string) => parseTimeExpression(expression, { row, @@ -700,11 +720,19 @@ async function runApply(args: Record): Promise { before, resolved, parseTime, - duration: row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, + duration: + row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, }; const decision = decideMutation(verb, context, { ...edit, _: [edit.ref] }); if (!decision.ok) return refusal(decision.reason, decision.fix, json); - const conflict = mutationConflict(verb, false, row, timeline, decision.nextStart, decision.nextDuration); + const conflict = mutationConflict( + verb, + false, + row, + timeline, + decision.nextStart, + decision.nextDuration, + ); if (conflict) return refusal(conflict.reason, conflict.fix, json); sourceByFile.set(row.file, decision.after); } @@ -712,7 +740,15 @@ async function runApply(args: Record): Promise { const before = beforeByFile.get(fileName)!; return after === before ? [] - : [{ sourceFile: fileName, absPath: join(project.dir, fileName), before, after, expectedVersion: fileContentVersion(before) }]; + : [ + { + sourceFile: fileName, + absPath: join(project.dir, fileName), + before, + after, + expectedVersion: fileContentVersion(before), + }, + ]; }); const afterTimeline = await describeProject(project.indexPath, undefined, sourceByFile); const result = { @@ -722,7 +758,10 @@ async function runApply(args: Record): Promise { file: inputs.map((input) => input.sourceFile), before: allRows(timeline), after: allRows(afterTimeline), - diff: inputs.map((input) => diff(input.before, input.after)).filter(Boolean).join("\n"), + diff: inputs + .map((input) => diff(input.before, input.after)) + .filter(Boolean) + .join("\n"), warnings: [], }; if (!plan) { @@ -730,14 +769,18 @@ async function runApply(args: Record): Promise { result.receipt = receipts.map((receipt) => publicReceipt(receipt)); } if (json) console.log(JSON.stringify(withMeta(result), null, 2)); - else console.log(`${plan ? "planned" : "applied"} ${inputs.length} file${inputs.length === 1 ? "" : "s"}`); + else + console.log( + `${plan ? "planned" : "applied"} ${inputs.length} file${inputs.length === 1 ? "" : "s"}`, + ); } async function runUndo(args: Record): Promise { const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); const json = args.json === true; const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1]; - if (!input) return refusal("an undo receipt is required", "pass the receipt JSON or its file", json); + if (!input) + return refusal("an undo receipt is required", "pass the receipt JSON or its file", json); let raw: string; try { raw = readFileSync(input, "utf-8"); @@ -751,15 +794,30 @@ async function runUndo(args: Record): Promise { return refusal("undo receipt is not valid JSON", "pass the applied JSON receipt", json); } const value = isRecord(parsed) && isRecord(parsed.receipt) ? parsed.receipt : parsed; - if (!isRecord(value) || typeof value.file !== "string" || typeof value.version !== "string" || typeof value.backupPath !== "string") { - return refusal("undo receipt is missing file, version, or backupPath", "pass an applied timeline receipt", json); + if ( + !isRecord(value) || + typeof value.file !== "string" || + typeof value.version !== "string" || + typeof value.backupPath !== "string" + ) { + return refusal( + "undo receipt is missing file, version, or backupPath", + "pass an applied timeline receipt", + json, + ); } const backup = join(project.dir, value.backupPath); const target = join(project.dir, value.file); const before = readFileSync(target, "utf-8"); const after = readFileSync(backup, "utf-8"); - const receipts = applyFileMutations(project.dir, [{ sourceFile: value.file, absPath: target, before, after, expectedVersion: value.version }]); - const result = { ok: true, receipt: receipts.map((receipt) => publicReceipt(receipt)), file: value.file }; + const receipts = applyFileMutations(project.dir, [ + { sourceFile: value.file, absPath: target, before, after, expectedVersion: value.version }, + ]); + const result = { + ok: true, + receipt: receipts.map((receipt) => publicReceipt(receipt)), + file: value.file, + }; if (json) console.log(JSON.stringify(withMeta(result), null, 2)); else console.log(`undid ${value.file}`); } @@ -840,36 +898,39 @@ export default defineCommand({ delete: () => mutationCommand("delete"), set: () => mutationCommand("set"), duplicate: () => mutationCommand("duplicate"), - ids: () => defineCommand({ - meta: { name: "ids", description: "Stamp stable ids on timeline clips" }, - args: { dir: { type: "string" }, json: { type: "boolean", default: false } }, - async run({ args }) { - await runIds(args); - }, - }), - apply: () => defineCommand({ - meta: { name: "apply", description: "Apply an atomic timeline edit plan" }, - args: { - file: { type: "positional", required: true }, - dir: { type: "string" }, - json: { type: "boolean", default: false }, - plan: { type: "boolean", default: false }, - }, - async run({ args }) { - await runApply(args); - }, - }), - undo: () => defineCommand({ - meta: { name: "undo", description: "Restore a timeline mutation receipt" }, - args: { - receipt: { type: "positional", required: true }, - dir: { type: "string" }, - json: { type: "boolean", default: false }, - }, - async run({ args }) { - await runUndo(args); - }, - }), + ids: () => + defineCommand({ + meta: { name: "ids", description: "Stamp stable ids on timeline clips" }, + args: { dir: { type: "string" }, json: { type: "boolean", default: false } }, + async run({ args }) { + await runIds(args); + }, + }), + apply: () => + defineCommand({ + meta: { name: "apply", description: "Apply an atomic timeline edit plan" }, + args: { + file: { type: "positional", required: true }, + dir: { type: "string" }, + json: { type: "boolean", default: false }, + plan: { type: "boolean", default: false }, + }, + async run({ args }) { + await runApply(args); + }, + }), + undo: () => + defineCommand({ + meta: { name: "undo", description: "Restore a timeline mutation receipt" }, + args: { + receipt: { type: "positional", required: true }, + dir: { type: "string" }, + json: { type: "boolean", default: false }, + }, + async run({ args }) { + await runUndo(args); + }, + }), }, async run({ args }) { if (args._?.[0]) return; diff --git a/packages/cli/src/timeline/a2Mutations.ts b/packages/cli/src/timeline/a2Mutations.ts index 9817a94688..c7300fb6a4 100644 --- a/packages/cli/src/timeline/a2Mutations.ts +++ b/packages/cli/src/timeline/a2Mutations.ts @@ -13,9 +13,9 @@ export function stampHfIds(source: string): string { return ensureHfIds(source); } -export function parseSetAssignments(values: readonly string[]): - | { ok: true; assignments: SetAssignment[] } - | { ok: false; reason: string; fix: string } { +export function parseSetAssignments( + values: readonly string[], +): { ok: true; assignments: SetAssignment[] } | { ok: false; reason: string; fix: string } { const assignments: SetAssignment[] = []; for (const value of values) { const match = /^(volume|rate|track)=(.+)$/.exec(value); @@ -80,7 +80,8 @@ export function duplicateElement( let suffix = 2; while (document.getElementById(uniqueId)) uniqueId = `${newId}-${suffix++}`; for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) { - if (candidate === element || candidate.getAttribute("data-track-index") !== String(track)) continue; + if (candidate === element || candidate.getAttribute("data-track-index") !== String(track)) + continue; const candidateStart = numericAttribute(candidate, "data-start"); if (candidateStart !== null && candidateStart >= at) { candidate.setAttribute("data-start", String(candidateStart + duration)); @@ -92,7 +93,8 @@ export function duplicateElement( } clone.setAttribute("id", uniqueId); clone.removeAttribute("data-hf-id"); - for (const child of Array.from(clone.querySelectorAll("[data-hf-id]"))) child.removeAttribute("data-hf-id"); + for (const child of Array.from(clone.querySelectorAll("[data-hf-id]"))) + child.removeAttribute("data-hf-id"); clone.setAttribute("data-start", String(at)); element.parentElement.insertBefore(clone, element.nextSibling); return { html: document.toString(), matched: true, newId: uniqueId }; From d8d7ff1832501def56697471e00030f1d40b3498 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:49:57 -0400 Subject: [PATCH 04/40] test(cli): cover timeline a2 verbs --- .../cli/src/timeline/timeline.e2e.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/cli/src/timeline/timeline.e2e.test.ts b/packages/cli/src/timeline/timeline.e2e.test.ts index e64ca7e071..06d5aa52f9 100644 --- a/packages/cli/src/timeline/timeline.e2e.test.ts +++ b/packages/cli/src/timeline/timeline.e2e.test.ts @@ -213,4 +213,69 @@ describe("timeline edit command", () => { rmSync(appliedDir, { recursive: true, force: true }); } }); + + it("stamps stable ids with ids", () => { + const dir = project(); + try { + const indexPath = join(dir, "index.html"); + writeFileSync(indexPath, readFileSync(indexPath, "utf8").replace(/ data-hf-id="[^"]+"/g, "")); + const result = run(dir, "ids"); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(indexPath, "utf8")).toMatch(/data-hf-id=/); + const output = JSON.parse(result.stdout) as { after: Array<{ ref: string }> }; + expect(output.after.some((row) => row.ref.startsWith("hf:"))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("sets clip attributes", () => { + const dir = project(); + try { + const result = run(dir, "set", "#clip", "volume=0.4", "rate=1.5", "track=2"); + expect(result.status, result.stderr).toBe(0); + const html = readFileSync(join(dir, "index.html"), "utf8"); + expect(html).toContain('data-volume="0.4"'); + expect(html).toContain('data-playback-rate="1.5"'); + expect(html).toContain('data-track-index="2"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("duplicates with insert-and-ripple", () => { + const dir = project(); + try { + const result = run(dir, "duplicate", "#clip", "--at", "3"); + expect(result.status, result.stderr).toBe(0); + const html = readFileSync(join(dir, "index.html"), "utf8"); + expect(html).toContain('id="clip-copy"'); + expect(html).toContain('id="neighbour" data-hf-id="neighbour" data-start="7"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("applies a JSON plan atomically and undoes its receipt", () => { + const dir = project(); + try { + const planPath = join(dir, "edits.json"); + writeFileSync(planPath, JSON.stringify([{ verb: "set", ref: "#clip", volume: "0.25" }])); + const before = readFileSync(join(dir, "index.html"), "utf8"); + const planned = run(dir, "apply", planPath, "--plan"); + expect(planned.status, planned.stderr).toBe(0); + expect(JSON.parse(planned.stdout)).toMatchObject({ ok: true, planned: true }); + expect(readFileSync(join(dir, "index.html"), "utf8")).toBe(before); + + const applied = run(dir, "apply", planPath); + expect(applied.status, applied.stderr).toBe(0); + const appliedJson = JSON.parse(applied.stdout) as { receipt: Array> }; + expect(readFileSync(join(dir, "index.html"), "utf8")).toContain('data-volume="0.25"'); + const undone = run(dir, "undo", JSON.stringify(appliedJson.receipt[0])); + expect(undone.status, undone.stderr).toBe(0); + expect(readFileSync(join(dir, "index.html"), "utf8")).toBe(before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); From 75a05554b9791e30f3778adb69320b2cd5ace048 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:54:18 -0400 Subject: [PATCH 05/40] test(cli): assert ids keep named refs --- packages/cli/src/timeline/timeline.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/timeline/timeline.e2e.test.ts b/packages/cli/src/timeline/timeline.e2e.test.ts index 06d5aa52f9..80b4a96f24 100644 --- a/packages/cli/src/timeline/timeline.e2e.test.ts +++ b/packages/cli/src/timeline/timeline.e2e.test.ts @@ -223,7 +223,7 @@ describe("timeline edit command", () => { expect(result.status, result.stderr).toBe(0); expect(readFileSync(indexPath, "utf8")).toMatch(/data-hf-id=/); const output = JSON.parse(result.stdout) as { after: Array<{ ref: string }> }; - expect(output.after.some((row) => row.ref.startsWith("hf:"))).toBe(true); + expect(output.after.some((row) => row.ref === "#clip")).toBe(true); } finally { rmSync(dir, { recursive: true, force: true }); } From 4e9c88685fe5e5cf1469cd8494b5a045638d41a8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:00:22 -0400 Subject: [PATCH 06/40] refactor: centralize timeline duplicate mutation --- packages/cli/src/commands/timeline.ts | 20 +++-- packages/cli/src/timeline/a2Mutations.ts | 89 ++----------------- .../src/helpers/duplicateElement.ts | 68 ++++++++++++++ packages/studio-server/src/index.ts | 4 + 4 files changed, 95 insertions(+), 86 deletions(-) create mode 100644 packages/studio-server/src/helpers/duplicateElement.ts diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 5752609005..b0fb7d396c 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -1,11 +1,12 @@ import { applyFileMutations, + duplicateElementInHtml, fileContentVersion, patchElementInHtml, removeElementFromHtml, splitElementInHtml, } from "@hyperframes/studio-server"; -import type { AppliedFileMutation } from "@hyperframes/studio-server"; +import type { AppliedFileMutation, PatchOperation } from "@hyperframes/studio-server"; import { fpsToNumber, parseFpsWithDefault } from "@hyperframes/core"; import { readCompositionFps } from "../utils/compositionFps.js"; import { readFileSync } from "node:fs"; @@ -25,10 +26,9 @@ import { setCommandExitCode } from "../utils/commandResult.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; import { - duplicateElement, parseSetAssignments, - setAttributes, stampHfIds, + type SetAssignment, } from "../timeline/a2Mutations.js"; export const examples: Example[] = [ @@ -304,6 +304,16 @@ function deleteMutation(context: MutationContext): MutationDecision { }; } +function setOperation(assignment: SetAssignment): PatchOperation { + const property = + assignment.field === "volume" + ? "data-volume" + : assignment.field === "rate" + ? "data-playback-rate" + : "data-track-index"; + return { type: "html-attribute", property, value: assignment.value }; +} + function setMutation(context: MutationContext, args: Record): MutationDecision { const positionalAssignments = positional(args) .slice(1) @@ -314,7 +324,7 @@ function setMutation(context: MutationContext, args: Record): M }); const assignments = parseSetAssignments([...positionalAssignments, ...namedAssignments]); if (!assignments.ok) return assignments; - const patched = setAttributes(context.before, context.resolved.target, assignments.assignments); + const patched = patchElementInHtml(context.before, context.resolved.target, assignments.assignments.map(setOperation)); if (!patched.matched) { return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; } @@ -333,7 +343,7 @@ function duplicateMutation( const expression = typeof args.at === "string" ? args.at : String(context.row.end); const time = parseMutationTime(context, expression, "pass a valid insertion time"); if (!time.ok) return time; - const duplicate = duplicateElement( + const duplicate = duplicateElementInHtml( context.before, context.resolved.target, `${splitBaseId(context.row)}-copy`, diff --git a/packages/cli/src/timeline/a2Mutations.ts b/packages/cli/src/timeline/a2Mutations.ts index c7300fb6a4..66fa6d7369 100644 --- a/packages/cli/src/timeline/a2Mutations.ts +++ b/packages/cli/src/timeline/a2Mutations.ts @@ -1,6 +1,4 @@ import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; -import type { SourceMutationTarget } from "@hyperframes/studio-server"; -import { parseHTML } from "linkedom"; export type SetField = "volume" | "rate" | "track"; @@ -26,7 +24,7 @@ export function parseSetAssignments( fix: "use volume=, rate=, or track=", }; } - const field = match[1] as SetField; + const field = parseSetField(match[1]); const number = Number(match[2]); if (!Number.isFinite(number) || (field === "track" && !Number.isInteger(number))) { return { @@ -43,83 +41,12 @@ export function parseSetAssignments( return { ok: true, assignments }; } -export function setAttributes( - source: string, - target: SourceMutationTarget, - assignments: readonly SetAssignment[], -): { html: string; matched: boolean } { - const document = parseHTML(source).document; - const element = findTarget(document, target); - if (!element) return { html: source, matched: false }; - for (const assignment of assignments) { - const attribute = - assignment.field === "volume" - ? "data-volume" - : assignment.field === "rate" - ? "data-playback-rate" - : "data-track-index"; - element.setAttribute(attribute, assignment.value); +function parseSetField(value: string): SetField { + switch (value) { + case "volume": + case "rate": + case "track": + return value; } - return { html: document.toString(), matched: true }; -} - -export function duplicateElement( - source: string, - target: SourceMutationTarget, - newId: string, - at: number, -): { html: string; matched: boolean; newId: string | null } { - const document = parseHTML(source).document; - const element = findTarget(document, target); - if (!element || !element.parentElement) return { html: source, matched: false, newId: null }; - const start = numericAttribute(element, "data-start"); - const duration = numericAttribute(element, "data-duration"); - const track = numericAttribute(element, "data-track-index") ?? 0; - if (start === null || duration === null) return { html: source, matched: false, newId: null }; - let uniqueId = newId; - let suffix = 2; - while (document.getElementById(uniqueId)) uniqueId = `${newId}-${suffix++}`; - for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) { - if (candidate === element || candidate.getAttribute("data-track-index") !== String(track)) - continue; - const candidateStart = numericAttribute(candidate, "data-start"); - if (candidateStart !== null && candidateStart >= at) { - candidate.setAttribute("data-start", String(candidateStart + duration)); - } - } - const clone = element.cloneNode(true); - if (!isElementNode(clone)) { - return { html: source, matched: false, newId: null }; - } - clone.setAttribute("id", uniqueId); - clone.removeAttribute("data-hf-id"); - for (const child of Array.from(clone.querySelectorAll("[data-hf-id]"))) - child.removeAttribute("data-hf-id"); - clone.setAttribute("data-start", String(at)); - element.parentElement.insertBefore(clone, element.nextSibling); - return { html: document.toString(), matched: true, newId: uniqueId }; -} - -function numericAttribute(element: Element, name: string): number | null { - const value = Number(element.getAttribute(name)); - return Number.isFinite(value) ? value : null; -} - -function findTarget(document: Document, target: SourceMutationTarget): Element | null { - if (target.hfId) { - const element = Array.from(document.querySelectorAll("[data-hf-id]")).find( - (candidate) => candidate.getAttribute("data-hf-id") === target.hfId, - ); - if (element) return element; - } - if (target.id) { - const element = document.getElementById(target.id); - if (element) return element; - } - if (!target.selector) return null; - return document.querySelectorAll(target.selector)[target.selectorIndex ?? 0] ?? null; -} - -function isElementNode(node: Node): node is Element { - return node.nodeType === 1 && "setAttribute" in node && "querySelectorAll" in node; + throw new Error(`unsupported set field ${value}`); } diff --git a/packages/studio-server/src/helpers/duplicateElement.ts b/packages/studio-server/src/helpers/duplicateElement.ts new file mode 100644 index 0000000000..262c48fdb0 --- /dev/null +++ b/packages/studio-server/src/helpers/duplicateElement.ts @@ -0,0 +1,68 @@ +import { parseHTML } from "linkedom"; +import type { SourceMutationTarget } from "./sourceMutation.js"; + +export interface DuplicateElementResult { + html: string; + matched: boolean; + newId: string | null; +} + +export function duplicateElementInHtml( + source: string, + target: SourceMutationTarget, + newId: string, + at: number, +): DuplicateElementResult { + const document = parseHTML(source).document; + const element = findTarget(document, target); + if (!element || !element.parentElement) return { html: source, matched: false, newId: null }; + const duration = numericAttribute(element, "data-duration"); + const track = numericAttribute(element, "data-track-index") ?? 0; + if (duration === null || numericAttribute(element, "data-start") === null) { + return { html: source, matched: false, newId: null }; + } + let uniqueId = newId; + let suffix = 2; + while (document.getElementById(uniqueId)) uniqueId = `${newId}-${suffix++}`; + for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) { + if (candidate === element || candidate.getAttribute("data-track-index") !== String(track)) { + continue; + } + const start = numericAttribute(candidate, "data-start"); + if (start !== null && start >= at) candidate.setAttribute("data-start", String(start + duration)); + } + const clone = element.cloneNode(true); + if (!isElementNode(clone)) return { html: source, matched: false, newId: null }; + clone.setAttribute("id", uniqueId); + clone.removeAttribute("data-hf-id"); + for (const child of Array.from(clone.querySelectorAll("[data-hf-id]"))) { + child.removeAttribute("data-hf-id"); + } + clone.setAttribute("data-start", String(at)); + element.parentElement.insertBefore(clone, element.nextSibling); + return { html: document.toString(), matched: true, newId: uniqueId }; +} + +function numericAttribute(element: Element, name: string): number | null { + const value = Number(element.getAttribute(name)); + return Number.isFinite(value) ? value : null; +} + +function findTarget(document: Document, target: SourceMutationTarget): Element | null { + if (target.hfId) { + const element = Array.from(document.querySelectorAll("[data-hf-id]")).find( + (candidate) => candidate.getAttribute("data-hf-id") === target.hfId, + ); + if (element) return element; + } + if (target.id) { + const element = document.getElementById(target.id); + if (element) return element; + } + if (!target.selector) return null; + return document.querySelectorAll(target.selector)[target.selectorIndex ?? 0] ?? null; +} + +function isElementNode(node: Node): node is Element { + return node.nodeType === 1 && "setAttribute" in node && "querySelectorAll" in node; +} diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index 665e4f96a3..3933c86fd6 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -18,6 +18,10 @@ export { type PatchOperation, type SourceMutationTarget, } from "./helpers/sourceMutation.js"; +export { + duplicateElementInHtml, + type DuplicateElementResult, +} from "./helpers/duplicateElement.js"; export { applyFileMutations, type AppliedFileMutation, From 949a56a0cf493098df2f6f6fcc013156158b8fe2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:09:19 -0400 Subject: [PATCH 07/40] fix(cli): narrow set assignment field --- packages/cli/src/timeline/a2Mutations.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/timeline/a2Mutations.ts b/packages/cli/src/timeline/a2Mutations.ts index 66fa6d7369..a3a1303bee 100644 --- a/packages/cli/src/timeline/a2Mutations.ts +++ b/packages/cli/src/timeline/a2Mutations.ts @@ -24,7 +24,15 @@ export function parseSetAssignments( fix: "use volume=, rate=, or track=", }; } - const field = parseSetField(match[1]); + const fieldValue = match[1]; + if (!fieldValue) { + return { + ok: false, + reason: `unsupported set assignment ${value}`, + fix: "use volume=, rate=, or track=", + }; + } + const field = parseSetField(fieldValue); const number = Number(match[2]); if (!Number.isFinite(number) || (field === "track" && !Number.isInteger(number))) { return { From 2a64d9b79ca5ac7ce23e08b38db7835388f06604 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:16:15 -0400 Subject: [PATCH 08/40] style(cli): format timeline mutation exports --- packages/cli/src/commands/timeline.ts | 12 ++++++------ .../studio-server/src/helpers/duplicateElement.ts | 3 ++- packages/studio-server/src/index.ts | 5 +---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index b0fb7d396c..20ef364050 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -25,11 +25,7 @@ import { ensureDOMParser } from "../utils/dom.js"; import { setCommandExitCode } from "../utils/commandResult.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; -import { - parseSetAssignments, - stampHfIds, - type SetAssignment, -} from "../timeline/a2Mutations.js"; +import { parseSetAssignments, stampHfIds, type SetAssignment } from "../timeline/a2Mutations.js"; export const examples: Example[] = [ ["Show every track and clip of the project in the current directory", "hyperframes timeline"], @@ -324,7 +320,11 @@ function setMutation(context: MutationContext, args: Record): M }); const assignments = parseSetAssignments([...positionalAssignments, ...namedAssignments]); if (!assignments.ok) return assignments; - const patched = patchElementInHtml(context.before, context.resolved.target, assignments.assignments.map(setOperation)); + const patched = patchElementInHtml( + context.before, + context.resolved.target, + assignments.assignments.map(setOperation), + ); if (!patched.matched) { return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; } diff --git a/packages/studio-server/src/helpers/duplicateElement.ts b/packages/studio-server/src/helpers/duplicateElement.ts index 262c48fdb0..0d562eea42 100644 --- a/packages/studio-server/src/helpers/duplicateElement.ts +++ b/packages/studio-server/src/helpers/duplicateElement.ts @@ -29,7 +29,8 @@ export function duplicateElementInHtml( continue; } const start = numericAttribute(candidate, "data-start"); - if (start !== null && start >= at) candidate.setAttribute("data-start", String(start + duration)); + if (start !== null && start >= at) + candidate.setAttribute("data-start", String(start + duration)); } const clone = element.cloneNode(true); if (!isElementNode(clone)) return { html: source, matched: false, newId: null }; diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index 3933c86fd6..f4ba3e73df 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -18,10 +18,7 @@ export { type PatchOperation, type SourceMutationTarget, } from "./helpers/sourceMutation.js"; -export { - duplicateElementInHtml, - type DuplicateElementResult, -} from "./helpers/duplicateElement.js"; +export { duplicateElementInHtml, type DuplicateElementResult } from "./helpers/duplicateElement.js"; export { applyFileMutations, type AppliedFileMutation, From efd406a6e1830179e516179ee40add3151b7c36d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:20:27 -0400 Subject: [PATCH 09/40] refactor(cli): split apply plan decisions --- packages/cli/src/commands/timeline.ts | 123 ++++++++++++++------------ 1 file changed, 66 insertions(+), 57 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 20ef364050..00c206b6e3 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -663,6 +663,69 @@ async function runIds(args: Record): Promise { else console.log(`ids: stamped ${receipts.length} file${receipts.length === 1 ? "" : "s"}`); } +type ApplyEditResult = + | { ok: true; file: string; after: string } + | { ok: false; reason: string; fix: string }; + +function applyPlanEdit( + edit: unknown, + timeline: ProjectTimeline, + project: ReturnType, + sourceByFile: Map, +): ApplyEditResult { + if (!isRecord(edit) || typeof edit.verb !== "string" || typeof edit.ref !== "string") { + return { + ok: false, + reason: "each edit needs a verb and ref", + fix: "pass {verb, ref, ...} objects", + }; + } + const verb = edit.verb; + const supported = ["move", "trim", "split", "delete", "set", "duplicate"]; + if (!supported.includes(verb)) { + return { + ok: false, + reason: `unsupported edit verb ${verb}`, + fix: "use move, trim, split, delete, set, or duplicate", + }; + } + const resolved = resolveRef(timeline, edit.ref); + if (!resolved.ok) return { ok: false, reason: resolved.reason, fix: resolved.fix }; + const row = resolved.row; + const before = sourceByFile.get(row.file); + if (before === undefined) + return { ok: false, reason: `${row.file} was not found`, fix: "choose an existing clip" }; + const context: MutationContext = { + ref: edit.ref, + row, + before, + resolved, + parseTime: (expression) => + parseTimeExpression(expression, { + row, + duration: timeline.duration, + fps: fpsFor(project.indexPath), + resolveAnchor: (anchorRef) => { + const anchor = resolveRef(timeline, anchorRef); + return anchor.ok ? anchor.row : undefined; + }, + }), + duration: row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, + }; + const decision = decideMutation(verb as MutationVerb, context, { ...edit, _: [edit.ref] }); + if (!decision.ok) return decision; + const conflict = mutationConflict( + verb as MutationVerb, + edit.overwrite === true, + row, + timeline, + decision.nextStart, + decision.nextDuration, + ); + if (conflict) return conflict; + return { ok: true, file: row.file, after: decision.after }; +} + async function runApply(args: Record): Promise { const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); const json = args.json === true; @@ -688,63 +751,9 @@ async function runApply(args: Record): Promise { beforeByFile.set(fileName, source); } for (const edit of edits) { - if (!isRecord(edit) || typeof edit.verb !== "string" || typeof edit.ref !== "string") { - return refusal("each edit needs a verb and ref", "pass {verb, ref, ...} objects", json); - } - const verb = edit.verb; - if ( - !( - verb === "move" || - verb === "trim" || - verb === "split" || - verb === "delete" || - verb === "set" || - verb === "duplicate" - ) - ) { - return refusal( - `unsupported edit verb ${verb}`, - "use move, trim, split, delete, set, or duplicate", - json, - ); - } - const resolved = resolveRef(timeline, edit.ref); - if (!resolved.ok) return refusal(resolved.reason, resolved.fix, json); - const row = resolved.row; - const before = sourceByFile.get(row.file); - if (before === undefined) - return refusal(`${row.file} was not found`, "choose an existing clip", json); - const parseTime = (expression: string) => - parseTimeExpression(expression, { - row, - duration: timeline.duration, - fps: fpsFor(project.indexPath), - resolveAnchor: (anchorRef) => { - const anchor = resolveRef(timeline, anchorRef); - return anchor.ok ? anchor.row : undefined; - }, - }); - const context: MutationContext = { - ref: edit.ref, - row, - before, - resolved, - parseTime, - duration: - row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, - }; - const decision = decideMutation(verb, context, { ...edit, _: [edit.ref] }); - if (!decision.ok) return refusal(decision.reason, decision.fix, json); - const conflict = mutationConflict( - verb, - false, - row, - timeline, - decision.nextStart, - decision.nextDuration, - ); - if (conflict) return refusal(conflict.reason, conflict.fix, json); - sourceByFile.set(row.file, decision.after); + const result = applyPlanEdit(edit, timeline, project, sourceByFile); + if (!result.ok) return refusal(result.reason, result.fix, json); + sourceByFile.set(result.file, result.after); } const inputs = [...sourceByFile].flatMap(([fileName, after]) => { const before = beforeByFile.get(fileName)!; From 3a069f469849778e5d2bc3add255641c6faca42a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:22:11 -0400 Subject: [PATCH 10/40] fix(cli): tag apply plan refusals --- packages/cli/src/commands/timeline.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 00c206b6e3..4cffde14f6 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -713,7 +713,7 @@ function applyPlanEdit( duration: row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, }; const decision = decideMutation(verb as MutationVerb, context, { ...edit, _: [edit.ref] }); - if (!decision.ok) return decision; + if (!decision.ok) return { ok: false, reason: decision.reason, fix: decision.fix }; const conflict = mutationConflict( verb as MutationVerb, edit.overwrite === true, @@ -722,7 +722,7 @@ function applyPlanEdit( decision.nextStart, decision.nextDuration, ); - if (conflict) return conflict; + if (conflict) return { ok: false, reason: conflict.reason, fix: conflict.fix }; return { ok: true, file: row.file, after: decision.after }; } From d2a448698d619086149c0e1b73c0ff0ba8125b24 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:34:50 -0400 Subject: [PATCH 11/40] refactor(cli): move A2 runners beside timeline helpers --- packages/cli/src/commands/timeline.ts | 239 ++--------------------- packages/cli/src/timeline/a2Commands.ts | 246 ++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 227 deletions(-) create mode 100644 packages/cli/src/timeline/a2Commands.ts diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 4cffde14f6..48623847e6 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -26,6 +26,7 @@ import { setCommandExitCode } from "../utils/commandResult.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; import { parseSetAssignments, stampHfIds, type SetAssignment } from "../timeline/a2Mutations.js"; +import { runApply, runIds, runUndo } from "../timeline/a2Commands.js"; export const examples: Example[] = [ ["Show every track and clip of the project in the current directory", "hyperframes timeline"], @@ -33,13 +34,13 @@ export const examples: Example[] = [ ["Delete a clip and return a receipt", "hyperframes timeline delete '#hero' --json"], ]; -type MutationVerb = "move" | "trim" | "split" | "delete" | "set" | "duplicate"; +export type MutationVerb = "move" | "trim" | "split" | "delete" | "set" | "duplicate"; type MutationDecision = | { ok: true; after: string; nextStart: number; nextDuration: number } | { ok: false; reason: string; fix: string }; -interface MutationContext { +export interface MutationContext { ref: string; row: TimelineRow; before: string; @@ -52,16 +53,16 @@ type ParsedMutationTime = | { ok: true; seconds: number } | { ok: false; reason: string; fix: string }; -const allRows = (timeline: ProjectTimeline): TimelineRow[] => +export const allRows = (timeline: ProjectTimeline): TimelineRow[] => timeline.tracks.flatMap((track) => track.rows); -function refusal(reason: string, fix: string, json: boolean): void { +export function refusal(reason: string, fix: string, json: boolean): void { setCommandExitCode(2); const payload = { ok: false, reason, fix }; console.error(json ? JSON.stringify(payload, null, 2) : `${reason}; ${fix}.`); } -function fpsFor(indexPath: string): number { +export function fpsFor(indexPath: string): number { const parsed = parseFpsWithDefault( readCompositionFps(readFileSync(indexPath, "utf-8")) ?? undefined, ); @@ -106,7 +107,7 @@ function parseMutationTime( return { ok: true, seconds: value.seconds }; } -function diff(before: string, after: string): string { +export function diff(before: string, after: string): string { if (before === after) return ""; const beforeLines = before.split("\n"); const afterLines = after.split("\n"); @@ -360,7 +361,7 @@ function duplicateMutation( }; } -function decideMutation( +export function decideMutation( verb: MutationVerb, context: MutationContext, args: Record, @@ -567,7 +568,7 @@ function rowsForFile(timeline: ProjectTimeline, file: string): TimelineRow[] { return allRows(timeline).filter((candidate) => candidate.file === file); } -function rowAt( +export function rowAt( timeline: ProjectTimeline, pointer: { kind: TimelineRow["trackKind"]; index: number }, ): TimelineRow { @@ -629,229 +630,13 @@ function applyMutation( } } -function positional(args: Record): string[] { +export function positional(args: Record): string[] { return Array.isArray(args._) ? args._.filter((value): value is string => typeof value === "string") : []; } -async function runIds(args: Record): Promise { - const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); - const json = args.json === true; - ensureDOMParser(); - const beforeTimeline = await describeProject(project.indexPath); - const files = [...new Set(["index.html", ...allRows(beforeTimeline).map((row) => row.file)])]; - const inputs = files.flatMap((file) => { - const before = readFileSync(join(project.dir, file), "utf-8"); - const after = stampHfIds(before); - return after === before - ? [] - : [{ sourceFile: file, absPath: join(project.dir, file), before, after }]; - }); - const receipts = inputs.length > 0 ? applyFileMutations(project.dir, inputs) : []; - const afterTimeline = await describeProject(project.indexPath); - const result = { - ok: true, - receipt: receipts.map((receipt) => publicReceipt(receipt)), - file: files, - before: allRows(beforeTimeline), - after: allRows(afterTimeline), - diff: "", - warnings: [], - }; - if (json) console.log(JSON.stringify(withMeta(result), null, 2)); - else console.log(`ids: stamped ${receipts.length} file${receipts.length === 1 ? "" : "s"}`); -} - -type ApplyEditResult = - | { ok: true; file: string; after: string } - | { ok: false; reason: string; fix: string }; - -function applyPlanEdit( - edit: unknown, - timeline: ProjectTimeline, - project: ReturnType, - sourceByFile: Map, -): ApplyEditResult { - if (!isRecord(edit) || typeof edit.verb !== "string" || typeof edit.ref !== "string") { - return { - ok: false, - reason: "each edit needs a verb and ref", - fix: "pass {verb, ref, ...} objects", - }; - } - const verb = edit.verb; - const supported = ["move", "trim", "split", "delete", "set", "duplicate"]; - if (!supported.includes(verb)) { - return { - ok: false, - reason: `unsupported edit verb ${verb}`, - fix: "use move, trim, split, delete, set, or duplicate", - }; - } - const resolved = resolveRef(timeline, edit.ref); - if (!resolved.ok) return { ok: false, reason: resolved.reason, fix: resolved.fix }; - const row = resolved.row; - const before = sourceByFile.get(row.file); - if (before === undefined) - return { ok: false, reason: `${row.file} was not found`, fix: "choose an existing clip" }; - const context: MutationContext = { - ref: edit.ref, - row, - before, - resolved, - parseTime: (expression) => - parseTimeExpression(expression, { - row, - duration: timeline.duration, - fps: fpsFor(project.indexPath), - resolveAnchor: (anchorRef) => { - const anchor = resolveRef(timeline, anchorRef); - return anchor.ok ? anchor.row : undefined; - }, - }), - duration: row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, - }; - const decision = decideMutation(verb as MutationVerb, context, { ...edit, _: [edit.ref] }); - if (!decision.ok) return { ok: false, reason: decision.reason, fix: decision.fix }; - const conflict = mutationConflict( - verb as MutationVerb, - edit.overwrite === true, - row, - timeline, - decision.nextStart, - decision.nextDuration, - ); - if (conflict) return { ok: false, reason: conflict.reason, fix: conflict.fix }; - return { ok: true, file: row.file, after: decision.after }; -} - -async function runApply(args: Record): Promise { - const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); - const json = args.json === true; - const plan = args.plan === true; - const file = typeof args.file === "string" ? args.file : positional(args)[1]; - if (!file) return refusal("an edit plan is required", "pass an edits.json path or -", json); - const raw = file === "-" ? readFileSync(0, "utf-8") : readFileSync(file, "utf-8"); - let edits: unknown; - try { - edits = JSON.parse(raw); - } catch { - return refusal("edit plan is not valid JSON", "pass a JSON array of edits", json); - } - if (!Array.isArray(edits)) - return refusal("edit plan must be a JSON array", "pass a JSON array of edits", json); - ensureDOMParser(); - const timeline = await describeProject(project.indexPath); - const sourceByFile = new Map(); - const beforeByFile = new Map(); - for (const fileName of new Set(allRows(timeline).map((row) => row.file))) { - const source = readFileSync(join(project.dir, fileName), "utf-8"); - sourceByFile.set(fileName, source); - beforeByFile.set(fileName, source); - } - for (const edit of edits) { - const result = applyPlanEdit(edit, timeline, project, sourceByFile); - if (!result.ok) return refusal(result.reason, result.fix, json); - sourceByFile.set(result.file, result.after); - } - const inputs = [...sourceByFile].flatMap(([fileName, after]) => { - const before = beforeByFile.get(fileName)!; - return after === before - ? [] - : [ - { - sourceFile: fileName, - absPath: join(project.dir, fileName), - before, - after, - expectedVersion: fileContentVersion(before), - }, - ]; - }); - const afterTimeline = await describeProject(project.indexPath, undefined, sourceByFile); - const result = { - ok: true, - planned: plan, - receipt: null as unknown, - file: inputs.map((input) => input.sourceFile), - before: allRows(timeline), - after: allRows(afterTimeline), - diff: inputs - .map((input) => diff(input.before, input.after)) - .filter(Boolean) - .join("\n"), - warnings: [], - }; - if (!plan) { - const receipts = inputs.length > 0 ? applyFileMutations(project.dir, inputs) : []; - result.receipt = receipts.map((receipt) => publicReceipt(receipt)); - } - if (json) console.log(JSON.stringify(withMeta(result), null, 2)); - else - console.log( - `${plan ? "planned" : "applied"} ${inputs.length} file${inputs.length === 1 ? "" : "s"}`, - ); -} - -async function runUndo(args: Record): Promise { - const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); - const json = args.json === true; - const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1]; - if (!input) - return refusal("an undo receipt is required", "pass the receipt JSON or its file", json); - let raw: string; - try { - raw = readFileSync(input, "utf-8"); - } catch { - raw = input; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return refusal("undo receipt is not valid JSON", "pass the applied JSON receipt", json); - } - const value = isRecord(parsed) && isRecord(parsed.receipt) ? parsed.receipt : parsed; - if ( - !isRecord(value) || - typeof value.file !== "string" || - typeof value.version !== "string" || - typeof value.backupPath !== "string" - ) { - return refusal( - "undo receipt is missing file, version, or backupPath", - "pass an applied timeline receipt", - json, - ); - } - const backup = join(project.dir, value.backupPath); - const target = join(project.dir, value.file); - const before = readFileSync(target, "utf-8"); - const after = readFileSync(backup, "utf-8"); - const receipts = applyFileMutations(project.dir, [ - { sourceFile: value.file, absPath: target, before, after, expectedVersion: value.version }, - ]); - const result = { - ok: true, - receipt: receipts.map((receipt) => publicReceipt(receipt)), - file: value.file, - }; - if (json) console.log(JSON.stringify(withMeta(result), null, 2)); - else console.log(`undid ${value.file}`); -} - -function publicReceipt(receipt: AppliedFileMutation) { - return { - file: receipt.sourceFile, - version: receipt.version, - writeToken: receipt.writeToken, - changed: receipt.changed, - backupPath: receipt.backupPath, - }; -} - -function isRecord(value: unknown): value is Record { +export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -865,7 +650,7 @@ function mutationRefusal( return { reason: `${ref} was not found`, fix: "choose an existing clip" }; } -function mutationConflict( +export function mutationConflict( verb: MutationVerb, overwrite: boolean, row: TimelineRow, diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts new file mode 100644 index 0000000000..5ab78551e0 --- /dev/null +++ b/packages/cli/src/timeline/a2Commands.ts @@ -0,0 +1,246 @@ +import { applyFileMutations, fileContentVersion } from "@hyperframes/studio-server"; +import type { AppliedFileMutation } from "@hyperframes/studio-server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + describeProject, + type ProjectTimeline, + type TimelineRow, +} from "./describeProject.js"; +import { resolveRef } from "./resolveRef.js"; +import { parseTimeExpression } from "./timeExpr.js"; +import { ensureDOMParser } from "../utils/dom.js"; +import { resolveProject } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; +import { stampHfIds } from "./a2Mutations.js"; +import { + allRows, + decideMutation, + diff, + fpsFor, + isRecord, + mutationConflict, + positional, + refusal, + rowAt, + type MutationContext, + type MutationVerb, +} from "../commands/timeline.js"; + +export async function runIds(args: Record): Promise { + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const json = args.json === true; + ensureDOMParser(); + const beforeTimeline = await describeProject(project.indexPath); + const files = [...new Set(["index.html", ...allRows(beforeTimeline).map((row) => row.file)])]; + const inputs = files.flatMap((file) => { + const before = readFileSync(join(project.dir, file), "utf-8"); + const after = stampHfIds(before); + return after === before + ? [] + : [{ sourceFile: file, absPath: join(project.dir, file), before, after }]; + }); + const receipts = inputs.length > 0 ? applyFileMutations(project.dir, inputs) : []; + const afterTimeline = await describeProject(project.indexPath); + const result = { + ok: true, + receipt: receipts.map((receipt) => publicReceipt(receipt)), + file: files, + before: allRows(beforeTimeline), + after: allRows(afterTimeline), + diff: "", + warnings: [], + }; + if (json) console.log(JSON.stringify(withMeta(result), null, 2)); + else console.log(`ids: stamped ${receipts.length} file${receipts.length === 1 ? "" : "s"}`); +} + +type ApplyEditResult = + | { ok: true; file: string; after: string } + | { ok: false; reason: string; fix: string }; + +function applyPlanEdit( + edit: unknown, + timeline: ProjectTimeline, + project: ReturnType, + sourceByFile: Map, +): ApplyEditResult { + if (!isRecord(edit) || typeof edit.verb !== "string" || typeof edit.ref !== "string") { + return { + ok: false, + reason: "each edit needs a verb and ref", + fix: "pass {verb, ref, ...} objects", + }; + } + const verb = edit.verb; + const supported = ["move", "trim", "split", "delete", "set", "duplicate"]; + if (!supported.includes(verb)) { + return { + ok: false, + reason: `unsupported edit verb ${verb}`, + fix: "use move, trim, split, delete, set, or duplicate", + }; + } + const resolved = resolveRef(timeline, edit.ref); + if (!resolved.ok) return { ok: false, reason: resolved.reason, fix: resolved.fix }; + const row = resolved.row; + const before = sourceByFile.get(row.file); + if (before === undefined) + return { ok: false, reason: `${row.file} was not found`, fix: "choose an existing clip" }; + const context: MutationContext = { + ref: edit.ref, + row, + before, + resolved, + parseTime: (expression) => + parseTimeExpression(expression, { + row, + duration: timeline.duration, + fps: fpsFor(project.indexPath), + resolveAnchor: (anchorRef) => { + const anchor = resolveRef(timeline, anchorRef); + return anchor.ok ? anchor.row : undefined; + }, + }), + duration: row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, + }; + const decision = decideMutation(verb as MutationVerb, context, { ...edit, _: [edit.ref] }); + if (!decision.ok) return { ok: false, reason: decision.reason, fix: decision.fix }; + const conflict = mutationConflict( + verb as MutationVerb, + edit.overwrite === true, + row, + timeline, + decision.nextStart, + decision.nextDuration, + ); + if (conflict) return { ok: false, reason: conflict.reason, fix: conflict.fix }; + return { ok: true, file: row.file, after: decision.after }; +} + +export async function runApply(args: Record): Promise { + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const json = args.json === true; + const plan = args.plan === true; + const file = typeof args.file === "string" ? args.file : positional(args)[1]; + if (!file) return refusal("an edit plan is required", "pass an edits.json path or -", json); + const raw = file === "-" ? readFileSync(0, "utf-8") : readFileSync(file, "utf-8"); + let edits: unknown; + try { + edits = JSON.parse(raw); + } catch { + return refusal("edit plan is not valid JSON", "pass a JSON array of edits", json); + } + if (!Array.isArray(edits)) + return refusal("edit plan must be a JSON array", "pass a JSON array of edits", json); + ensureDOMParser(); + const timeline = await describeProject(project.indexPath); + const sourceByFile = new Map(); + const beforeByFile = new Map(); + for (const fileName of new Set(allRows(timeline).map((row) => row.file))) { + const source = readFileSync(join(project.dir, fileName), "utf-8"); + sourceByFile.set(fileName, source); + beforeByFile.set(fileName, source); + } + for (const edit of edits) { + const result = applyPlanEdit(edit, timeline, project, sourceByFile); + if (!result.ok) return refusal(result.reason, result.fix, json); + sourceByFile.set(result.file, result.after); + } + const inputs = [...sourceByFile].flatMap(([fileName, after]) => { + const before = beforeByFile.get(fileName)!; + return after === before + ? [] + : [ + { + sourceFile: fileName, + absPath: join(project.dir, fileName), + before, + after, + expectedVersion: fileContentVersion(before), + }, + ]; + }); + const afterTimeline = await describeProject(project.indexPath, undefined, sourceByFile); + const result = { + ok: true, + planned: plan, + receipt: null as unknown, + file: inputs.map((input) => input.sourceFile), + before: allRows(timeline), + after: allRows(afterTimeline), + diff: inputs + .map((input) => diff(input.before, input.after)) + .filter(Boolean) + .join("\n"), + warnings: [], + }; + if (!plan) { + const receipts = inputs.length > 0 ? applyFileMutations(project.dir, inputs) : []; + result.receipt = receipts.map((receipt) => publicReceipt(receipt)); + } + if (json) console.log(JSON.stringify(withMeta(result), null, 2)); + else + console.log( + `${plan ? "planned" : "applied"} ${inputs.length} file${inputs.length === 1 ? "" : "s"}`, + ); +} + +export async function runUndo(args: Record): Promise { + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const json = args.json === true; + const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1]; + if (!input) + return refusal("an undo receipt is required", "pass the receipt JSON or its file", json); + let raw: string; + try { + raw = readFileSync(input, "utf-8"); + } catch { + raw = input; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return refusal("undo receipt is not valid JSON", "pass the applied JSON receipt", json); + } + const value = isRecord(parsed) && isRecord(parsed.receipt) ? parsed.receipt : parsed; + if ( + !isRecord(value) || + typeof value.file !== "string" || + typeof value.version !== "string" || + typeof value.backupPath !== "string" + ) { + return refusal( + "undo receipt is missing file, version, or backupPath", + "pass an applied timeline receipt", + json, + ); + } + const backup = join(project.dir, value.backupPath); + const target = join(project.dir, value.file); + const before = readFileSync(target, "utf-8"); + const after = readFileSync(backup, "utf-8"); + const receipts = applyFileMutations(project.dir, [ + { sourceFile: value.file, absPath: target, before, after, expectedVersion: value.version }, + ]); + const result = { + ok: true, + receipt: receipts.map((receipt) => publicReceipt(receipt)), + file: value.file, + }; + if (json) console.log(JSON.stringify(withMeta(result), null, 2)); + else console.log(`undid ${value.file}`); +} + +function publicReceipt(receipt: AppliedFileMutation) { + return { + file: receipt.sourceFile, + version: receipt.version, + writeToken: receipt.writeToken, + changed: receipt.changed, + backupPath: receipt.backupPath, + }; +} + + From c205ba28923021db83d903eded8ed7cefa639440 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:41:17 -0400 Subject: [PATCH 12/40] fix(cli): remove stale A2 imports --- packages/cli/src/commands/timeline.ts | 2 +- packages/cli/src/timeline/a2Commands.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 48623847e6..db652cc51a 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -25,7 +25,7 @@ import { ensureDOMParser } from "../utils/dom.js"; import { setCommandExitCode } from "../utils/commandResult.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; -import { parseSetAssignments, stampHfIds, type SetAssignment } from "../timeline/a2Mutations.js"; +import { parseSetAssignments, type SetAssignment } from "../timeline/a2Mutations.js"; import { runApply, runIds, runUndo } from "../timeline/a2Commands.js"; export const examples: Example[] = [ diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index 5ab78551e0..c664e137d6 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -5,7 +5,6 @@ import { join } from "node:path"; import { describeProject, type ProjectTimeline, - type TimelineRow, } from "./describeProject.js"; import { resolveRef } from "./resolveRef.js"; import { parseTimeExpression } from "./timeExpr.js"; @@ -243,4 +242,3 @@ function publicReceipt(receipt: AppliedFileMutation) { }; } - From aed308bd18721964136267e3bf3023ab601da79c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:43:44 -0400 Subject: [PATCH 13/40] style(cli): format A2 command module --- packages/cli/src/timeline/a2Commands.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index c664e137d6..0f7fc1f421 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -2,10 +2,7 @@ import { applyFileMutations, fileContentVersion } from "@hyperframes/studio-serv import type { AppliedFileMutation } from "@hyperframes/studio-server"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { - describeProject, - type ProjectTimeline, -} from "./describeProject.js"; +import { describeProject, type ProjectTimeline } from "./describeProject.js"; import { resolveRef } from "./resolveRef.js"; import { parseTimeExpression } from "./timeExpr.js"; import { ensureDOMParser } from "../utils/dom.js"; @@ -53,7 +50,6 @@ export async function runIds(args: Record): Promise { if (json) console.log(JSON.stringify(withMeta(result), null, 2)); else console.log(`ids: stamped ${receipts.length} file${receipts.length === 1 ? "" : "s"}`); } - type ApplyEditResult = | { ok: true; file: string; after: string } | { ok: false; reason: string; fix: string }; @@ -241,4 +237,3 @@ function publicReceipt(receipt: AppliedFileMutation) { backupPath: receipt.backupPath, }; } - From fb66f2d8c07cf7e2a598dafa0d141f400d470c1b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:45:38 -0400 Subject: [PATCH 14/40] fix(cli): avoid A2 command import cycle --- packages/cli/src/commands/timeline.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index db652cc51a..bdb6110ce2 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -26,7 +26,6 @@ import { setCommandExitCode } from "../utils/commandResult.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; import { parseSetAssignments, type SetAssignment } from "../timeline/a2Mutations.js"; -import { runApply, runIds, runUndo } from "../timeline/a2Commands.js"; export const examples: Example[] = [ ["Show every track and clip of the project in the current directory", "hyperframes timeline"], @@ -707,7 +706,7 @@ export default defineCommand({ meta: { name: "ids", description: "Stamp stable ids on timeline clips" }, args: { dir: { type: "string" }, json: { type: "boolean", default: false } }, async run({ args }) { - await runIds(args); + await (await import("../timeline/a2Commands.js")).runIds(args); }, }), apply: () => @@ -720,7 +719,7 @@ export default defineCommand({ plan: { type: "boolean", default: false }, }, async run({ args }) { - await runApply(args); + await (await import("../timeline/a2Commands.js")).runApply(args); }, }), undo: () => @@ -732,7 +731,7 @@ export default defineCommand({ json: { type: "boolean", default: false }, }, async run({ args }) { - await runUndo(args); + await (await import("../timeline/a2Commands.js")).runUndo(args); }, }), }, From 821f9735d7e60b65ff66cbc33341f40a53ae893f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:53:33 -0400 Subject: [PATCH 15/40] refactor(cli): isolate shared timeline mutation logic --- packages/cli/src/commands/timeline.ts | 666 +----------------------- packages/cli/src/timeline/a2Commands.ts | 2 +- packages/cli/src/timeline/a2Shared.ts | 553 ++++++++++++++++++++ 3 files changed, 563 insertions(+), 658 deletions(-) create mode 100644 packages/cli/src/timeline/a2Shared.ts diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index bdb6110ce2..f4b74d4320 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -1,31 +1,18 @@ -import { - applyFileMutations, - duplicateElementInHtml, - fileContentVersion, - patchElementInHtml, - removeElementFromHtml, - splitElementInHtml, -} from "@hyperframes/studio-server"; -import type { AppliedFileMutation, PatchOperation } from "@hyperframes/studio-server"; -import { fpsToNumber, parseFpsWithDefault } from "@hyperframes/core"; -import { readCompositionFps } from "../utils/compositionFps.js"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; -import { - describeProject, - type ProjectTimeline, - type TimelineRow, -} from "../timeline/describeProject.js"; +import { describeProject, type ProjectTimeline, type TimelineRow } from "../timeline/describeProject.js"; import { formatTimeline } from "../timeline/formatTimeline.js"; -import { resolveRef } from "../timeline/resolveRef.js"; -import { parseTimeExpression } from "../timeline/timeExpr.js"; import { ensureDOMParser } from "../utils/dom.js"; -import { setCommandExitCode } from "../utils/commandResult.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; -import { parseSetAssignments, type SetAssignment } from "../timeline/a2Mutations.js"; +import { runApply, runIds, runUndo } from "../timeline/a2Commands.js"; +import { + allRows, + diff, + rowAt, + runMutation, + type MutationVerb, +} from "../timeline/a2Shared.js"; export const examples: Example[] = [ ["Show every track and clip of the project in the current directory", "hyperframes timeline"], @@ -33,646 +20,11 @@ export const examples: Example[] = [ ["Delete a clip and return a receipt", "hyperframes timeline delete '#hero' --json"], ]; -export type MutationVerb = "move" | "trim" | "split" | "delete" | "set" | "duplicate"; - -type MutationDecision = - | { ok: true; after: string; nextStart: number; nextDuration: number } - | { ok: false; reason: string; fix: string }; - -export interface MutationContext { - ref: string; - row: TimelineRow; - before: string; - resolved: Extract, { ok: true }>; - parseTime: (expression: string) => ReturnType; - duration: number; -} - -type ParsedMutationTime = - | { ok: true; seconds: number } - | { ok: false; reason: string; fix: string }; - -export const allRows = (timeline: ProjectTimeline): TimelineRow[] => - timeline.tracks.flatMap((track) => track.rows); - -export function refusal(reason: string, fix: string, json: boolean): void { - setCommandExitCode(2); - const payload = { ok: false, reason, fix }; - console.error(json ? JSON.stringify(payload, null, 2) : `${reason}; ${fix}.`); -} - -export function fpsFor(indexPath: string): number { - const parsed = parseFpsWithDefault( - readCompositionFps(readFileSync(indexPath, "utf-8")) ?? undefined, - ); - return fpsToNumber(parsed.ok ? parsed.value : { num: 30, den: 1 }); -} - -function declaredFps(source: string): number | null { - const raw = readCompositionFps(source); - if (raw === null) return null; - const parsed = parseFpsWithDefault(raw); - return parsed.ok ? fpsToNumber(parsed.value) : null; -} - -function splitBaseId(row: TimelineRow): string { - return row.ref.startsWith("hf:") ? row.ref.slice(3) : row.id; -} - -function nextFreeSplitId(source: string, base: string): string { - const document = new DOMParser().parseFromString(source, "text/html"); - const existing = new Set(Array.from(document.querySelectorAll("[id]"), (element) => element.id)); - const match = /^(.*)-(\d+)$/.exec(base); - const prefix = match && existing.has(match[1]!) ? match[1]! : base; - let suffix = 2; - while (existing.has(`${prefix}-${suffix}`)) suffix += 1; - return `${prefix}-${suffix}`; -} - -function parseMutationTime( - context: MutationContext, - expression: string, - fix: string, -): ParsedMutationTime { - const value = context.parseTime(expression); - if (!value.ok) return { ok: false, reason: value.reason, fix }; - if (value.seconds < 0 || value.seconds > context.duration) { - return { - ok: false, - reason: `time ${value.seconds} is outside the composition duration`, - fix: "pass a time between 0 and the composition duration", - }; - } - return { ok: true, seconds: value.seconds }; -} - -export function diff(before: string, after: string): string { - if (before === after) return ""; - const beforeLines = before.split("\n"); - const afterLines = after.split("\n"); - let prefix = 0; - while ( - prefix < beforeLines.length && - prefix < afterLines.length && - beforeLines[prefix] === afterLines[prefix] - ) { - prefix += 1; - } - let suffix = 0; - while ( - suffix < beforeLines.length - prefix && - suffix < afterLines.length - prefix && - beforeLines[beforeLines.length - suffix - 1] === afterLines[afterLines.length - suffix - 1] - ) { - suffix += 1; - } - const changedBefore = beforeLines.slice(prefix, beforeLines.length - suffix); - const changedAfter = afterLines.slice(prefix, afterLines.length - suffix); - const lines = [ - "--- before", - "+++ after", - ...changedBefore.map((line) => `-${line}`), - ...changedAfter.map((line) => `+${line}`), - ]; - const output = lines.join("\n"); - return output.length <= 8_000 ? output : `${output.slice(0, 7_997)}...`; -} - -function overlap( - row: TimelineRow, - timeline: ProjectTimeline, - start: number, - end: number, -): TimelineRow | undefined { - return allRows(timeline).find( - (candidate) => - candidate !== row && - candidate.file === row.file && - candidate.trackIndex === row.trackIndex && - Math.max(start, candidate.start) < Math.min(end, candidate.end), - ); -} - -function moveMutation(context: MutationContext, args: Record): MutationDecision { - const expression = typeof args.time === "string" ? args.time : ""; - const time = parseMutationTime(context, expression, "pass a valid time expression"); - if (!time.ok) return time; - const end = time.seconds + context.row.duration; - if (end > context.duration) { - return { - ok: false, - reason: `move would end at ${end}, beyond composition duration ${context.duration}`, - fix: `choose a start at or before the latest valid start ${context.duration - context.row.duration}`, - }; - } - const patched = patchElementInHtml(context.before, context.resolved.target, [ - { type: "html-attribute", property: "data-start", value: String(time.seconds) }, - ]); - if (!patched.matched) { - return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; - } - return { - ok: true, - after: patched.html, - nextStart: time.seconds, - nextDuration: context.row.duration, - }; -} - -function splitMutation(context: MutationContext, args: Record): MutationDecision { - const expression = typeof args.time === "string" ? args.time : ""; - const time = parseMutationTime(context, expression, "pass a valid time expression"); - if (!time.ok) return time; - const split = splitElementInHtml( - context.before, - context.resolved.target, - time.seconds, - nextFreeSplitId(context.before, splitBaseId(context.row)), - { - start: context.row.start, - duration: context.row.duration, - track: context.row.trackIndex, - }, - ); - if (!split.matched || !split.newId) { - return { - ok: false, - reason: `${context.ref} cannot be split at ${time.seconds}`, - fix: "choose a time inside the clip", - }; - } - return { - ok: true, - after: split.html, - nextStart: context.row.start, - nextDuration: context.row.duration, - }; -} - -function trimMutation(context: MutationContext, args: Record): MutationDecision { - const bounds = trimBounds(context, args); - if (!bounds.ok) return bounds; - const patched = patchElementInHtml(context.before, context.resolved.target, [ - { type: "html-attribute", property: "data-start", value: String(bounds.nextStart) }, - { type: "html-attribute", property: "data-duration", value: String(bounds.nextDuration) }, - ]); - if (!patched.matched) { - return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; - } - return { - ok: true, - after: patched.html, - nextStart: bounds.nextStart, - nextDuration: bounds.nextDuration, - }; -} - -function trimBounds( - context: MutationContext, - args: Record, -): MutationDecision | { ok: true; nextStart: number; nextDuration: number } { - const input = trimInput(args); - if (!input.ok) return input; - const start = trimStart(context, input.start); - if (!start.ok) return start; - return finishTrim( - context, - start.seconds, - trimEnd(context, input.end), - trimDuration(context, input.duration), - ); -} - -function trimInput(args: Record) { - const input = { - start: typeof args.start === "string" ? args.start : undefined, - end: typeof args.end === "string" ? args.end : undefined, - duration: typeof args.duration === "string" ? args.duration : undefined, - }; - if (input.start || input.end || input.duration) return { ok: true as const, ...input }; - return { - ok: false as const, - reason: "trim requires --start, --end, or --duration", - fix: "pass one trim option", - }; -} - -function finishTrim( - context: MutationContext, - nextStart: number, - end: ReturnType, - duration: ReturnType, -): MutationDecision | { ok: true; nextStart: number; nextDuration: number } { - if (end && !end.ok) return end; - if (duration && !duration.ok) return duration; - const nextDuration = duration?.seconds ?? (end ? end.seconds - nextStart : context.row.duration); - if (nextDuration <= 0) { - return { - ok: false, - reason: "trim duration must be positive", - fix: "choose a later end or positive duration", - }; - } - return { ok: true, nextStart, nextDuration }; -} - -function trimStart(context: MutationContext, expression: string | undefined) { - if (!expression) return { ok: true as const, seconds: context.row.start }; - return parseMutationTime(context, expression, "pass a valid time expression"); -} - -function trimEnd(context: MutationContext, expression: string | undefined) { - if (!expression) return undefined; - return parseMutationTime(context, expression, "pass a valid time expression"); -} - -function trimDuration(context: MutationContext, expression: string | undefined) { - if (!expression) return undefined; - return parseMutationTime(context, expression, "pass a valid duration"); -} - -function deleteMutation(context: MutationContext): MutationDecision { - return { - ok: true, - after: removeElementFromHtml(context.before, context.resolved.target), - nextStart: context.row.start, - nextDuration: context.row.duration, - }; -} - -function setOperation(assignment: SetAssignment): PatchOperation { - const property = - assignment.field === "volume" - ? "data-volume" - : assignment.field === "rate" - ? "data-playback-rate" - : "data-track-index"; - return { type: "html-attribute", property, value: assignment.value }; -} - -function setMutation(context: MutationContext, args: Record): MutationDecision { - const positionalAssignments = positional(args) - .slice(1) - .filter((value): value is string => typeof value === "string"); - const namedAssignments = ["volume", "rate", "track"].flatMap((field) => { - const value = args[field]; - return typeof value === "string" ? [`${field}=${value}`] : []; - }); - const assignments = parseSetAssignments([...positionalAssignments, ...namedAssignments]); - if (!assignments.ok) return assignments; - const patched = patchElementInHtml( - context.before, - context.resolved.target, - assignments.assignments.map(setOperation), - ); - if (!patched.matched) { - return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; - } - return { - ok: true, - after: patched.html, - nextStart: context.row.start, - nextDuration: context.row.duration, - }; -} - -function duplicateMutation( - context: MutationContext, - args: Record, -): MutationDecision { - const expression = typeof args.at === "string" ? args.at : String(context.row.end); - const time = parseMutationTime(context, expression, "pass a valid insertion time"); - if (!time.ok) return time; - const duplicate = duplicateElementInHtml( - context.before, - context.resolved.target, - `${splitBaseId(context.row)}-copy`, - time.seconds, - ); - if (!duplicate.matched) { - return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; - } - return { - ok: true, - after: duplicate.html, - nextStart: time.seconds, - nextDuration: context.row.duration, - }; -} - -export function decideMutation( - verb: MutationVerb, - context: MutationContext, - args: Record, -): MutationDecision { - switch (verb) { - case "move": - return moveMutation(context, args); - case "trim": - return trimMutation(context, args); - case "split": - return splitMutation(context, args); - case "delete": - return deleteMutation(context); - case "set": - return setMutation(context, args); - case "duplicate": - return duplicateMutation(context, args); - } -} - -async function runMutation(verb: MutationVerb, args: Record): Promise { - const setup = await prepareMutation(args); - if (!setup.ok) return refusal(setup.reason, setup.fix, setup.json); - const decision = decideMutation(verb, setup.context, args); - if (!decision.ok) return refusal(decision.reason, decision.fix, setup.json); - await finishMutation(setup, verb, decision); -} - -interface MutationSetup { - ok: true; - project: ReturnType; - ref: string; - json: boolean; - plan: boolean; - overwrite: boolean; - timeline: ProjectTimeline; - indexSource: string; - row: TimelineRow; - resolved: Extract, { ok: true }>; - filePath: string; - before: string; - expectedVersion: string; - context: MutationContext; -} - -type MutationSetupResult = - | MutationSetup - | { ok: false; reason: string; fix: string; json: boolean }; - -async function prepareMutation(args: Record): Promise { - const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); - const ref = typeof args.ref === "string" ? args.ref : ""; - const json = args.json === true; - const plan = args.plan === true; - const overwrite = args.overwrite === true; - const snap = args.snap === true; - ensureDOMParser(); - const indexSource = readFileSync(project.indexPath, "utf-8"); - const initialTimeline = await describeProject( - project.indexPath, - undefined, - new Map([["index.html", indexSource]]), - ); - const projectFps = declaredFps(indexSource); - if (snap && projectFps === null) { - return { - ok: false, - reason: "project fps is unknown", - fix: "set data-fps on the project, then rerun with --snap", - json, - }; - } - const initialResolved = resolveRef(initialTimeline, ref); - if (!initialResolved.ok) - return { ok: false, reason: initialResolved.reason, fix: initialResolved.fix, json }; - const initialRow = initialResolved.row; - const filePath = join(project.dir, initialRow.file); - const before = readFileSync(filePath, "utf-8"); - const expectedVersion = fileContentVersion(before); - const sources = new Map([ - ["index.html", indexSource], - [initialRow.file, before], - ]); - const timeline = await describeProject(project.indexPath, undefined, sources); - const resolved = resolveRef(timeline, ref); - if (!resolved.ok) return { ok: false, reason: resolved.reason, fix: resolved.fix, json }; - const row = resolved.row; - const anchor = (anchorRef: string) => { - const found = resolveRef(timeline, anchorRef); - return found.ok ? found.row : undefined; - }; - const parseTime = (expression: string) => { - const parsed = parseTimeExpression(expression, { - row, - duration: timeline.duration, - fps: fpsFor(project.indexPath), - resolveAnchor: anchor, - }); - if (!parsed.ok || !snap) return parsed; - return { ok: true as const, seconds: Math.round(parsed.seconds * projectFps!) / projectFps! }; - }; - return { - ok: true, - project, - ref, - json, - plan, - overwrite, - timeline, - indexSource, - row, - resolved, - filePath, - before, - expectedVersion, - context: { - ref, - row, - before, - resolved, - parseTime, - // Nested rows use the visible host clip duration as their composition bound. - duration: - row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, - }, - }; -} - -async function finishMutation( - setup: MutationSetup, - verb: MutationVerb, - decision: Extract, -): Promise { - const { after, nextStart, nextDuration } = decision; - const { ref, row, timeline, json, overwrite, project, before } = setup; - const refusalMessage = mutationRefusal(verb, after, before, ref); - if (refusalMessage) return refusal(refusalMessage.reason, refusalMessage.fix, json); - const conflict = mutationConflict(verb, overwrite, row, timeline, nextStart, nextDuration); - if (conflict) return refusal(conflict.reason, conflict.fix, json); - const describeSource = (source: string): Promise => - describeProject( - project.indexPath, - undefined, - new Map([ - ["index.html", setup.indexSource], - [row.file, source], - ]), - ); - const describedBefore = await describeSource(before); - const result = mutationResult(row, describedBefore, setup.plan); - if (setup.plan) return printPlan(result, json, timeline, describeSource, after, before); - return applyAndPrint({ - setup, - verb, - json, - row, - nextStart, - nextDuration, - after, - before, - result, - describeSource, - }); -} - -async function applyAndPrint(args: { - setup: MutationSetup; - verb: MutationVerb; - json: boolean; - row: TimelineRow; - nextStart: number; - nextDuration: number; - after: string; - before: string; - result: ReturnType; - describeSource: (source: string) => Promise; -}): Promise { - const receipt = applyMutation(args.setup, args.after); - if (receipt && "error" in receipt) - return refusal(receipt.error, "re-run hyperframes timeline", args.json); - if (!receipt && args.after !== args.before) { - return refusal("mutation produced no receipt", "re-run hyperframes timeline", args.json); - } - args.result.after = rowsForFile(await args.describeSource(args.after), args.row.file); - args.result.receipt = receipt - ? { - file: receipt.sourceFile, - version: receipt.version, - writeToken: receipt.writeToken, - changed: receipt.changed, - backupPath: receipt.backupPath, - } - : null; - if (args.json) { - console.log(JSON.stringify(withMeta(args.result), null, 2)); - return; - } - console.log( - `${args.verb} ${args.row.ref}: ${args.row.start}-${args.row.end}s -> ${args.nextStart}-${args.nextStart + args.nextDuration}s\nreceipt: ${receipt?.version ?? "unchanged"}`, - ); -} - function rowsForFile(timeline: ProjectTimeline, file: string): TimelineRow[] { return allRows(timeline).filter((candidate) => candidate.file === file); } export function rowAt( - timeline: ProjectTimeline, - pointer: { kind: TimelineRow["trackKind"]; index: number }, -): TimelineRow { - const track = timeline.tracks.find((candidate) => candidate.kind === pointer.kind); - if (!track) throw new Error(`missing track ${pointer.kind}`); - const row = track.rows[pointer.index]; - if (!row) throw new Error(`missing row ${pointer.kind}/${pointer.index}`); - return row; -} - -function mutationResult(row: TimelineRow, before: ProjectTimeline, planned: boolean) { - return { - ok: true, - receipt: null as unknown, - file: row.file, - before: rowsForFile(before, row.file), - after: [] as TimelineRow[], - warnings: row.warnings, - planned, - }; -} - -async function printPlan( - result: ReturnType, - json: boolean, - timeline: ProjectTimeline, - describeSource: (source: string) => Promise, - after: string, - before: string, -): Promise { - const plannedTimeline = await describeSource(after); - result.after = rowsForFile(plannedTimeline, result.file); - if (json) console.log(JSON.stringify(withMeta(result), null, 2)); - else - console.log( - `${formatTimeline(timeline)}\n\nplanned:\n${formatTimeline(plannedTimeline)}\n\ndiff:\n${diff(before, after)}`, - ); -} - -function applyMutation( - setup: MutationSetup, - after: string, -): AppliedFileMutation | { error: string } | undefined { - try { - return applyFileMutations(setup.project.dir, [ - { - sourceFile: setup.row.file, - absPath: setup.filePath, - before: setup.before, - after, - expectedVersion: setup.expectedVersion, - }, - ])[0]; - } catch (error) { - if (error instanceof Error && error.message === "file changed since the timeline was read") { - return { error: error.message }; - } - throw error; - } -} - -export function positional(args: Record): string[] { - return Array.isArray(args._) - ? args._.filter((value): value is string => typeof value === "string") - : []; -} - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function mutationRefusal( - verb: MutationVerb, - after: string, - before: string, - ref: string, -): { reason: string; fix: string } | null { - if (verb !== "delete" || after !== before) return null; - return { reason: `${ref} was not found`, fix: "choose an existing clip" }; -} - -export function mutationConflict( - verb: MutationVerb, - overwrite: boolean, - row: TimelineRow, - timeline: ProjectTimeline, - nextStart: number, - nextDuration: number, -): { reason: string; fix: string } | null { - if ((verb !== "move" && verb !== "trim") || overwrite) return null; - const conflict = overlap(row, timeline, nextStart, nextStart + nextDuration); - if (!conflict) return null; - return { - reason: `${row.ref} would overlap ${conflict.ref} at ${nextStart}-${nextStart + nextDuration}`, - fix: "pass --overwrite or move the named neighbour", - }; -} - -function mutationCommand(verb: MutationVerb) { - return defineCommand({ - meta: { name: verb, description: `${verb} a timeline clip` }, - args: { - ref: { type: "positional", required: true }, - time: { type: "positional", required: verb === "move" || verb === "split" }, - at: { type: "string" }, dir: { type: "string" }, start: { type: "string" }, end: { type: "string" }, diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index 0f7fc1f421..73ad798860 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -21,7 +21,7 @@ import { rowAt, type MutationContext, type MutationVerb, -} from "../commands/timeline.js"; +} from "./a2Shared.js"; export async function runIds(args: Record): Promise { const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts new file mode 100644 index 0000000000..0e813a9a28 --- /dev/null +++ b/packages/cli/src/timeline/a2Shared.ts @@ -0,0 +1,553 @@ +import { + applyFileMutations, + duplicateElementInHtml, + fileContentVersion, + patchElementInHtml, + removeElementFromHtml, + splitElementInHtml, +} from "@hyperframes/studio-server"; +import type { AppliedFileMutation, PatchOperation } from "@hyperframes/studio-server"; +import { fpsToNumber, parseFpsWithDefault } from "@hyperframes/core"; +import { readCompositionFps } from "../utils/compositionFps.js"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js"; +import { resolveRef } from "./resolveRef.js"; +import { parseTimeExpression } from "./timeExpr.js"; +import { ensureDOMParser } from "../utils/dom.js"; +import { setCommandExitCode } from "../utils/commandResult.js"; +import { resolveProject } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; +import { parseSetAssignments, type SetAssignment } from "./a2Mutations.js"; + +export type MutationVerb = "move" | "trim" | "split" | "delete" | "set" | "duplicate"; + +type MutationDecision = + | { ok: true; after: string; nextStart: number; nextDuration: number } + | { ok: false; reason: string; fix: string }; + +export interface MutationContext { + ref: string; + row: TimelineRow; + before: string; + resolved: Extract, { ok: true }>; + parseTime: (expression: string) => ReturnType; + duration: number; +} + +type ParsedMutationTime = + | { ok: true; seconds: number } + | { ok: false; reason: string; fix: string }; + +export const allRows = (timeline: ProjectTimeline): TimelineRow[] => + timeline.tracks.flatMap((track) => track.rows); + +export function refusal(reason: string, fix: string, json: boolean): void { + setCommandExitCode(2); + const payload = { ok: false, reason, fix }; + console.error(json ? JSON.stringify(payload, null, 2) : `${reason}; ${fix}.`); +} + +export function fpsFor(indexPath: string): number { + const parsed = parseFpsWithDefault( + readCompositionFps(readFileSync(indexPath, "utf-8")) ?? undefined, + ); + return fpsToNumber(parsed.ok ? parsed.value : { num: 30, den: 1 }); +} + +function declaredFps(source: string): number | null { + const raw = readCompositionFps(source); + if (raw === null) return null; + const parsed = parseFpsWithDefault(raw); + return parsed.ok ? fpsToNumber(parsed.value) : null; +} + +function splitBaseId(row: TimelineRow): string { + return row.ref.startsWith("hf:") ? row.ref.slice(3) : row.id; +} + +function nextFreeSplitId(source: string, base: string): string { + const document = new DOMParser().parseFromString(source, "text/html"); + const existing = new Set(Array.from(document.querySelectorAll("[id]"), (element) => element.id)); + const match = /^(.*)-(\d+)$/.exec(base); + const prefix = match && existing.has(match[1]!) ? match[1]! : base; + let suffix = 2; + while (existing.has(`${prefix}-${suffix}`)) suffix += 1; + return `${prefix}-${suffix}`; +} + +function parseMutationTime( + context: MutationContext, + expression: string, + fix: string, +): ParsedMutationTime { + const value = context.parseTime(expression); + if (!value.ok) return { ok: false, reason: value.reason, fix }; + if (value.seconds < 0 || value.seconds > context.duration) { + return { + ok: false, + reason: `time ${value.seconds} is outside the composition duration`, + fix: "pass a time between 0 and the composition duration", + }; + } + return { ok: true, seconds: value.seconds }; +} + +export function diff(before: string, after: string): string { + if (before === after) return ""; + const beforeLines = before.split("\n"); + const afterLines = after.split("\n"); + let prefix = 0; + while ( + prefix < beforeLines.length && + prefix < afterLines.length && + beforeLines[prefix] === afterLines[prefix] + ) { + prefix += 1; + } + let suffix = 0; + while ( + suffix < beforeLines.length - prefix && + suffix < afterLines.length - prefix && + beforeLines[beforeLines.length - suffix - 1] === afterLines[afterLines.length - suffix - 1] + ) { + suffix += 1; + } + const changedBefore = beforeLines.slice(prefix, beforeLines.length - suffix); + const changedAfter = afterLines.slice(prefix, afterLines.length - suffix); + const lines = [ + "--- before", + "+++ after", + ...changedBefore.map((line) => `-${line}`), + ...changedAfter.map((line) => `+${line}`), + ]; + const output = lines.join("\n"); + return output.length <= 8_000 ? output : `${output.slice(0, 7_997)}...`; +} + +function overlap( + row: TimelineRow, + timeline: ProjectTimeline, + start: number, + end: number, +): TimelineRow | undefined { + return allRows(timeline).find( + (candidate) => + candidate !== row && + candidate.file === row.file && + candidate.trackIndex === row.trackIndex && + Math.max(start, candidate.start) < Math.min(end, candidate.end), + ); +} + +function moveMutation(context: MutationContext, args: Record): MutationDecision { + const expression = typeof args.time === "string" ? args.time : ""; + const time = parseMutationTime(context, expression, "pass a valid time expression"); + if (!time.ok) return time; + const end = time.seconds + context.row.duration; + if (end > context.duration) { + return { + ok: false, + reason: `move would end at ${end}, beyond composition duration ${context.duration}`, + fix: `choose a start at or before the latest valid start ${context.duration - context.row.duration}`, + }; + } + const patched = patchElementInHtml(context.before, context.resolved.target, [ + { type: "html-attribute", property: "data-start", value: String(time.seconds) }, + ]); + if (!patched.matched) { + return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; + } + return { + ok: true, + after: patched.html, + nextStart: time.seconds, + nextDuration: context.row.duration, + }; +} + +function splitMutation(context: MutationContext, args: Record): MutationDecision { + const expression = typeof args.time === "string" ? args.time : ""; + const time = parseMutationTime(context, expression, "pass a valid time expression"); + if (!time.ok) return time; + const split = splitElementInHtml( + context.before, + context.resolved.target, + time.seconds, + nextFreeSplitId(context.before, splitBaseId(context.row)), + { + start: context.row.start, + duration: context.row.duration, + track: context.row.trackIndex, + }, + ); + if (!split.matched || !split.newId) { + return { + ok: false, + reason: `${context.ref} cannot be split at ${time.seconds}`, + fix: "choose a time inside the clip", + }; + } + return { + ok: true, + after: split.html, + nextStart: context.row.start, + nextDuration: context.row.duration, + }; +} + +function trimMutation(context: MutationContext, args: Record): MutationDecision { + const bounds = trimBounds(context, args); + if (!bounds.ok) return bounds; + const patched = patchElementInHtml(context.before, context.resolved.target, [ + { type: "html-attribute", property: "data-start", value: String(bounds.nextStart) }, + { type: "html-attribute", property: "data-duration", value: String(bounds.nextDuration) }, + ]); + if (!patched.matched) { + return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; + } + return { + ok: true, + after: patched.html, + nextStart: bounds.nextStart, + nextDuration: bounds.nextDuration, + }; +} + +function trimBounds( + context: MutationContext, + args: Record, +): MutationDecision | { ok: true; nextStart: number; nextDuration: number } { + const input = trimInput(args); + if (!input.ok) return input; + const start = trimStart(context, input.start); + if (!start.ok) return start; + return finishTrim( + context, + start.seconds, + trimEnd(context, input.end), + trimDuration(context, input.duration), + ); +} + +function trimInput(args: Record) { + const input = { + start: typeof args.start === "string" ? args.start : undefined, + end: typeof args.end === "string" ? args.end : undefined, + duration: typeof args.duration === "string" ? args.duration : undefined, + }; + if (input.start || input.end || input.duration) return { ok: true as const, ...input }; + return { + ok: false as const, + reason: "trim requires --start, --end, or --duration", + fix: "pass one trim option", + }; +} + +function finishTrim( + context: MutationContext, + nextStart: number, + end: ReturnType, + duration: ReturnType, +): MutationDecision | { ok: true; nextStart: number; nextDuration: number } { + if (end && !end.ok) return end; + if (duration && !duration.ok) return duration; + const nextDuration = duration?.seconds ?? (end ? end.seconds - nextStart : context.row.duration); + if (nextDuration <= 0) { + return { + ok: false, + reason: "trim duration must be positive", + fix: "choose a later end or positive duration", + }; + } + return { ok: true, nextStart, nextDuration }; +} + +function trimStart(context: MutationContext, expression: string | undefined) { + if (!expression) return { ok: true as const, seconds: context.row.start }; + return parseMutationTime(context, expression, "pass a valid time expression"); +} + +function trimEnd(context: MutationContext, expression: string | undefined) { + if (!expression) return undefined; + return parseMutationTime(context, expression, "pass a valid time expression"); +} + +function trimDuration(context: MutationContext, expression: string | undefined) { + if (!expression) return undefined; + return parseMutationTime(context, expression, "pass a valid duration"); +} + +function deleteMutation(context: MutationContext): MutationDecision { + return { + ok: true, + after: removeElementFromHtml(context.before, context.resolved.target), + nextStart: context.row.start, + nextDuration: context.row.duration, + }; +} + +function setOperation(assignment: SetAssignment): PatchOperation { + const property = + assignment.field === "volume" + ? "data-volume" + : assignment.field === "rate" + ? "data-playback-rate" + : "data-track-index"; + return { type: "html-attribute", property, value: assignment.value }; +} + +function setMutation(context: MutationContext, args: Record): MutationDecision { + const positionalAssignments = positional(args) + .slice(1) + .filter((value): value is string => typeof value === "string"); + const namedAssignments = ["volume", "rate", "track"].flatMap((field) => { + const value = args[field]; + return typeof value === "string" ? [`${field}=${value}`] : []; + }); + const assignments = parseSetAssignments([...positionalAssignments, ...namedAssignments]); + if (!assignments.ok) return assignments; + const patched = patchElementInHtml( + context.before, + context.resolved.target, + assignments.assignments.map(setOperation), + ); + if (!patched.matched) { + return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; + } + return { + ok: true, + after: patched.html, + nextStart: context.row.start, + nextDuration: context.row.duration, + }; +} + +function duplicateMutation( + context: MutationContext, + args: Record, +): MutationDecision { + const expression = typeof args.at === "string" ? args.at : String(context.row.end); + const time = parseMutationTime(context, expression, "pass a valid insertion time"); + if (!time.ok) return time; + const duplicate = duplicateElementInHtml( + context.before, + context.resolved.target, + `${splitBaseId(context.row)}-copy`, + time.seconds, + ); + if (!duplicate.matched) { + return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" }; + } + return { + ok: true, + after: duplicate.html, + nextStart: time.seconds, + nextDuration: context.row.duration, + }; +} + +export function decideMutation( + verb: MutationVerb, + context: MutationContext, + args: Record, +): MutationDecision { + switch (verb) { + case "move": + return moveMutation(context, args); + case "trim": + return trimMutation(context, args); + case "split": + return splitMutation(context, args); + case "delete": + return deleteMutation(context); + case "set": + return setMutation(context, args); + case "duplicate": + return duplicateMutation(context, args); + } +} + +async function runMutation(verb: MutationVerb, args: Record): Promise { + const setup = await prepareMutation(args); + if (!setup.ok) return refusal(setup.reason, setup.fix, setup.json); + const decision = decideMutation(verb, setup.context, args); + if (!decision.ok) return refusal(decision.reason, decision.fix, setup.json); + await finishMutation(setup, verb, decision); +} + +interface MutationSetup { + ok: true; + project: ReturnType; + ref: string; + json: boolean; + plan: boolean; + overwrite: boolean; + timeline: ProjectTimeline; + indexSource: string; + row: TimelineRow; + resolved: Extract, { ok: true }>; + filePath: string; + before: string; + expectedVersion: string; + context: MutationContext; +} + +type MutationSetupResult = + | MutationSetup + | { ok: false; reason: string; fix: string; json: boolean }; + +async function prepareMutation(args: Record): Promise { + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const ref = typeof args.ref === "string" ? args.ref : ""; + const json = args.json === true; + const plan = args.plan === true; + const overwrite = args.overwrite === true; + const snap = args.snap === true; + ensureDOMParser(); + const indexSource = readFileSync(project.indexPath, "utf-8"); + const initialTimeline = await describeProject( + project.indexPath, + undefined, + new Map([["index.html", indexSource]]), + ); + const projectFps = declaredFps(indexSource); + if (snap && projectFps === null) { + return { + ok: false, + reason: "project fps is unknown", + fix: "set data-fps on the project, then rerun with --snap", + json, + }; + } + const initialResolved = resolveRef(initialTimeline, ref); + if (!initialResolved.ok) + return { ok: false, reason: initialResolved.reason, fix: initialResolved.fix, json }; + const initialRow = initialResolved.row; + const filePath = join(project.dir, initialRow.file); + const before = readFileSync(filePath, "utf-8"); + const expectedVersion = fileContentVersion(before); + const sources = new Map([ + ["index.html", indexSource], + [initialRow.file, before], + ]); + const timeline = await describeProject(project.indexPath, undefined, sources); + const resolved = resolveRef(timeline, ref); + if (!resolved.ok) return { ok: false, reason: resolved.reason, fix: resolved.fix, json }; + const row = resolved.row; + const anchor = (anchorRef: string) => { + const found = resolveRef(timeline, anchorRef); + return found.ok ? found.row : undefined; + }; + const parseTime = (expression: string) => { + const parsed = parseTimeExpression(expression, { + row, + duration: timeline.duration, + fps: fpsFor(project.indexPath), + resolveAnchor: anchor, + }); + if (!parsed.ok || !snap) return parsed; + return { ok: true as const, seconds: Math.round(parsed.seconds * projectFps!) / projectFps! }; + }; + return { + ok: true, + project, + ref, + json, + plan, + overwrite, + timeline, + indexSource, + row, + resolved, + filePath, + before, + expectedVersion, + context: { + ref, + row, + before, + resolved, + parseTime, + // Nested rows use the visible host clip duration as their composition bound. + duration: + row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, + }, + }; +} + +async function finishMutation( + setup: MutationSetup, + verb: MutationVerb, + decision: Extract, +): Promise { + const { after, nextStart, nextDuration } = decision; + const { ref, row, timeline, json, overwrite, project, before } = setup; + const refusalMessage = mutationRefusal(verb, after, before, ref); + if (refusalMessage) return refusal(refusalMessage.reason, refusalMessage.fix, json); + const conflict = mutationConflict(verb, overwrite, row, timeline, nextStart, nextDuration); + if (conflict) return refusal(conflict.reason, conflict.fix, json); + const describeSource = (source: string): Promise => + describeProject( + project.indexPath, + undefined, + new Map([ + ["index.html", setup.indexSource], + [row.file, source], + ]), + ); + const describedBefore = await describeSource(before); + const result = mutationResult(row, describedBefore, setup.plan); + if (setup.plan) return printPlan(result, json, timeline, describeSource, after, before); + return applyAndPrint({ + setup, + verb, + json, + row, + nextStart, + nextDuration, + after, + before, + result, + describeSource, + }); +} + +async function applyAndPrint(args: { + setup: MutationSetup; + verb: MutationVerb; + json: boolean; + row: TimelineRow; + nextStart: number; + nextDuration: number; + after: string; + before: string; + result: ReturnType; + describeSource: (source: string) => Promise; +}): Promise { + const receipt = applyMutation(args.setup, args.after); + if (receipt && "error" in receipt) + return refusal(receipt.error, "re-run hyperframes timeline", args.json); + if (!receipt && args.after !== args.before) { + return refusal("mutation produced no receipt", "re-run hyperframes timeline", args.json); + } + args.result.after = rowsForFile(await args.describeSource(args.after), args.row.file); + args.result.receipt = receipt + ? { + file: receipt.sourceFile, + version: receipt.version, + writeToken: receipt.writeToken, + changed: receipt.changed, + backupPath: receipt.backupPath, + } + : null; + if (args.json) { + console.log(JSON.stringify(withMeta(args.result), null, 2)); + return; + } + console.log( + `${args.verb} ${args.row.ref}: ${args.row.start}-${args.row.end}s -> ${args.nextStart}-${args.nextStart + args.nextDuration}s\nreceipt: ${receipt?.version ?? "unchanged"}`, + ); +} + + From dd26ab9e5150fdafbc5c3435d5580038a439155a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:55:53 -0400 Subject: [PATCH 16/40] fix(cli): restore timeline mutation command factory --- packages/cli/src/commands/timeline.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index f4b74d4320..1337f3be95 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -20,6 +20,28 @@ export const examples: Example[] = [ ["Delete a clip and return a receipt", "hyperframes timeline delete '#hero' --json"], ]; +function mutationCommand(verb: MutationVerb) { + return defineCommand({ + meta: { name: verb, description: `${verb} a timeline clip` }, + args: { + ref: { type: "positional", required: true }, + time: { type: "positional", required: verb === "move" || verb === "split" }, + at: { type: "string" }, + dir: { type: "string" }, + start: { type: "string" }, + end: { type: "string" }, + duration: { type: "string" }, + plan: { type: "boolean", default: false }, + json: { type: "boolean", default: false }, + overwrite: { type: "boolean", default: false }, + snap: { type: "boolean", default: false }, + }, + async run({ args }) { + await runMutation(verb, args); + }, + }); +} + function rowsForFile(timeline: ProjectTimeline, file: string): TimelineRow[] { return allRows(timeline).filter((candidate) => candidate.file === file); } From e136e5ddceedb894fb0228ee973880c9009b52d7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:59:12 -0400 Subject: [PATCH 17/40] fix(cli): remove stale command-surface remnants --- packages/cli/src/commands/timeline.ts | 30 ++------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 1337f3be95..0c9e0abb6d 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -1,18 +1,12 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; -import { describeProject, type ProjectTimeline, type TimelineRow } from "../timeline/describeProject.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"; import { runApply, runIds, runUndo } from "../timeline/a2Commands.js"; -import { - allRows, - diff, - rowAt, - runMutation, - type MutationVerb, -} from "../timeline/a2Shared.js"; +import { runMutation, type MutationVerb } from "../timeline/a2Shared.js"; export const examples: Example[] = [ ["Show every track and clip of the project in the current directory", "hyperframes timeline"], @@ -42,26 +36,6 @@ function mutationCommand(verb: MutationVerb) { }); } -function rowsForFile(timeline: ProjectTimeline, file: string): TimelineRow[] { - return allRows(timeline).filter((candidate) => candidate.file === file); -} - -export function rowAt( - dir: { type: "string" }, - start: { type: "string" }, - end: { type: "string" }, - duration: { type: "string" }, - plan: { type: "boolean", default: false }, - json: { type: "boolean", default: false }, - overwrite: { type: "boolean", default: false }, - snap: { type: "boolean", default: false }, - }, - async run({ args }) { - await runMutation(verb, args); - }, - }); -} - export default defineCommand({ meta: { name: "timeline", description: "Print and edit the project's tracks and clips" }, args: { From cd5b452b4bcdc0c5e27a23a48e312b58a5fc2afe Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 20:03:21 -0400 Subject: [PATCH 18/40] fix(cli): complete shared timeline support extraction --- packages/cli/src/timeline/a2Shared.ts | 101 +++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index 0e813a9a28..81eba55b2e 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -368,7 +368,7 @@ export function decideMutation( } } -async function runMutation(verb: MutationVerb, args: Record): Promise { +export async function runMutation(verb: MutationVerb, args: Record): Promise { const setup = await prepareMutation(args); if (!setup.ok) return refusal(setup.reason, setup.fix, setup.json); const decision = decideMutation(verb, setup.context, args); @@ -550,4 +550,103 @@ async function applyAndPrint(args: { ); } +function rowsForFile(timeline: ProjectTimeline, file: string): TimelineRow[] { + return allRows(timeline).filter((candidate) => candidate.file === file); +} + +export function rowAt( + timeline: ProjectTimeline, + pointer: { kind: TimelineRow["trackKind"]; index: number }, +): TimelineRow { + const track = timeline.tracks.find((candidate) => candidate.kind === pointer.kind); + if (!track) throw new Error(`missing track ${pointer.kind}`); + const row = track.rows[pointer.index]; + if (!row) throw new Error(`missing row ${pointer.kind}/${pointer.index}`); + return row; +} + +function mutationResult(row: TimelineRow, before: ProjectTimeline, planned: boolean) { + return { + ok: true, + receipt: null as unknown, + file: row.file, + before: rowsForFile(before, row.file), + after: [] as TimelineRow[], + warnings: row.warnings, + planned, + }; +} + +async function printPlan( + result: ReturnType, + json: boolean, + timeline: ProjectTimeline, + describeSource: (source: string) => Promise, + after: string, + before: string, +): Promise { + const plannedTimeline = await describeSource(after); + result.after = rowsForFile(plannedTimeline, result.file); + if (json) console.log(JSON.stringify(withMeta(result), null, 2)); + else + console.log( + `${formatTimeline(timeline)}\n\nplanned:\n${formatTimeline(plannedTimeline)}\n\ndiff:\n${diff(before, after)}`, + ); +} + +function applyMutation( + setup: MutationSetup, + after: string, +): AppliedFileMutation | { error: string } | undefined { + try { + return applyFileMutations(setup.project.dir, [ + { + sourceFile: setup.row.file, + absPath: setup.filePath, + before: setup.before, + after, + expectedVersion: setup.expectedVersion, + }, + ])[0]; + } catch (error) { + if (error instanceof Error && error.message === "file changed since the timeline was read") { + return { error: error.message }; + } + throw error; + } +} + +export function positional(args: Record): string[] { + return Array.isArray(args._) + ? args._.filter((value): value is string => typeof value === "string") + : []; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function mutationRefusal( + verb: MutationVerb, + after: string, + before: string, + ref: string, +): { reason: string; fix: string } | null { + if (verb !== "delete" || after !== before) return null; + return { reason: `${ref} was not found`, fix: "choose an existing clip" }; +} + +export function mutationConflict( + verb: MutationVerb, + overwrite: boolean, + row: TimelineRow, + timeline: ProjectTimeline, + nextStart: number, + nextDuration: number, +): { reason: string; fix: string } | null { + if ((verb !== "move" && verb !== "trim") || overwrite) return null; + const conflict = overlap(row, timeline, nextStart, nextStart + nextDuration); + if (!conflict) return null; + return { + reason: `${row.ref} would overlap ${conflict.ref} at ${nextStart}-${nextStart + nextDuration}`, + fix: "pass --overwrite or move the named neighbour", From f9543359cfdba8685a1c19abe2b93774e3a1107c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 20:05:43 -0400 Subject: [PATCH 19/40] fix(cli): close shared conflict helper --- packages/cli/src/timeline/a2Shared.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index 81eba55b2e..325f69ca84 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -650,3 +650,5 @@ export function mutationConflict( return { reason: `${row.ref} would overlap ${conflict.ref} at ${nextStart}-${nextStart + nextDuration}`, fix: "pass --overwrite or move the named neighbour", + }; +} From a81f20eea41fa9473a458b9daae9ef642bd8f2ea Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 20:07:33 -0400 Subject: [PATCH 20/40] fix(cli): import shared timeline formatter --- packages/cli/src/timeline/a2Shared.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index 325f69ca84..f6b40b14ee 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -12,6 +12,7 @@ import { readCompositionFps } from "../utils/compositionFps.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js"; +import { formatTimeline } from "./formatTimeline.js"; import { resolveRef } from "./resolveRef.js"; import { parseTimeExpression } from "./timeExpr.js"; import { ensureDOMParser } from "../utils/dom.js"; From 02a8c565062f148034da243b8fa5afebadf3231b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 20:10:01 -0400 Subject: [PATCH 21/40] style(cli): format shared mutation runner --- packages/cli/src/timeline/a2Shared.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index f6b40b14ee..c4b7ee881d 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -369,7 +369,10 @@ export function decideMutation( } } -export async function runMutation(verb: MutationVerb, args: Record): Promise { +export async function runMutation( + verb: MutationVerb, + args: Record, +): Promise { const setup = await prepareMutation(args); if (!setup.ok) return refusal(setup.reason, setup.fix, setup.json); const decision = decideMutation(verb, setup.context, args); From 59db2ec583225190d92b07a04e8d41ca5cd5511f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:38:27 -0400 Subject: [PATCH 22/40] refactor(cli): split mutation command orchestration --- packages/cli/src/commands/timeline.ts | 3 +- .../cli/src/timeline/a2MutationCommand.ts | 312 ++++++++++++++++++ packages/cli/src/timeline/a2Shared.ts | 288 ---------------- 3 files changed, 314 insertions(+), 289 deletions(-) create mode 100644 packages/cli/src/timeline/a2MutationCommand.ts diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 0c9e0abb6d..1e06d78b5b 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -6,7 +6,8 @@ import { ensureDOMParser } from "../utils/dom.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; import { runApply, runIds, runUndo } from "../timeline/a2Commands.js"; -import { runMutation, type MutationVerb } from "../timeline/a2Shared.js"; +import { runMutation } from "../timeline/a2MutationCommand.js"; +import type { MutationVerb } from "../timeline/a2Shared.js"; export const examples: Example[] = [ ["Show every track and clip of the project in the current directory", "hyperframes timeline"], diff --git a/packages/cli/src/timeline/a2MutationCommand.ts b/packages/cli/src/timeline/a2MutationCommand.ts new file mode 100644 index 0000000000..742f52c659 --- /dev/null +++ b/packages/cli/src/timeline/a2MutationCommand.ts @@ -0,0 +1,312 @@ +import { applyFileMutations } from "@hyperframes/studio-server"; +import type { AppliedFileMutation } from "@hyperframes/studio-server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js"; +import { formatTimeline } from "./formatTimeline.js"; +import { resolveRef } from "./resolveRef.js"; +import { parseTimeExpression } from "./timeExpr.js"; +import { ensureDOMParser } from "../utils/dom.js"; +import { resolveProject } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; +import { + allRows, + diff, + fpsFor, + isRecord, + mutationConflict, + positional, + refusal, + rowAt, + type MutationContext, + type MutationVerb, +} from "./a2Shared.js"; + +export async function runMutation( + verb: MutationVerb, + args: Record, +): Promise { + const setup = await prepareMutation(args); + if (!setup.ok) return refusal(setup.reason, setup.fix, setup.json); + const decision = decideMutation(verb, setup.context, args); + if (!decision.ok) return refusal(decision.reason, decision.fix, setup.json); + await finishMutation(setup, verb, decision); +} + +interface MutationSetup { + ok: true; + project: ReturnType; + ref: string; + json: boolean; + plan: boolean; + overwrite: boolean; + timeline: ProjectTimeline; + indexSource: string; + row: TimelineRow; + resolved: Extract, { ok: true }>; + filePath: string; + before: string; + expectedVersion: string; + context: MutationContext; +} + +type MutationSetupResult = + | MutationSetup + | { ok: false; reason: string; fix: string; json: boolean }; + +async function prepareMutation(args: Record): Promise { + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const ref = typeof args.ref === "string" ? args.ref : ""; + const json = args.json === true; + const plan = args.plan === true; + const overwrite = args.overwrite === true; + const snap = args.snap === true; + ensureDOMParser(); + const indexSource = readFileSync(project.indexPath, "utf-8"); + const initialTimeline = await describeProject( + project.indexPath, + undefined, + new Map([["index.html", indexSource]]), + ); + const projectFps = declaredFps(indexSource); + if (snap && projectFps === null) { + return { + ok: false, + reason: "project fps is unknown", + fix: "set data-fps on the project, then rerun with --snap", + json, + }; + } + const initialResolved = resolveRef(initialTimeline, ref); + if (!initialResolved.ok) + return { ok: false, reason: initialResolved.reason, fix: initialResolved.fix, json }; + const initialRow = initialResolved.row; + const filePath = join(project.dir, initialRow.file); + const before = readFileSync(filePath, "utf-8"); + const expectedVersion = fileContentVersion(before); + const sources = new Map([ + ["index.html", indexSource], + [initialRow.file, before], + ]); + const timeline = await describeProject(project.indexPath, undefined, sources); + const resolved = resolveRef(timeline, ref); + if (!resolved.ok) return { ok: false, reason: resolved.reason, fix: resolved.fix, json }; + const row = resolved.row; + const anchor = (anchorRef: string) => { + const found = resolveRef(timeline, anchorRef); + return found.ok ? found.row : undefined; + }; + const parseTime = (expression: string) => { + const parsed = parseTimeExpression(expression, { + row, + duration: timeline.duration, + fps: fpsFor(project.indexPath), + resolveAnchor: anchor, + }); + if (!parsed.ok || !snap) return parsed; + return { ok: true as const, seconds: Math.round(parsed.seconds * projectFps!) / projectFps! }; + }; + return { + ok: true, + project, + ref, + json, + plan, + overwrite, + timeline, + indexSource, + row, + resolved, + filePath, + before, + expectedVersion, + context: { + ref, + row, + before, + resolved, + parseTime, + // Nested rows use the visible host clip duration as their composition bound. + duration: + row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, + }, + }; +} + +async function finishMutation( + setup: MutationSetup, + verb: MutationVerb, + decision: Extract, +): Promise { + const { after, nextStart, nextDuration } = decision; + const { ref, row, timeline, json, overwrite, project, before } = setup; + const refusalMessage = mutationRefusal(verb, after, before, ref); + if (refusalMessage) return refusal(refusalMessage.reason, refusalMessage.fix, json); + const conflict = mutationConflict(verb, overwrite, row, timeline, nextStart, nextDuration); + if (conflict) return refusal(conflict.reason, conflict.fix, json); + const describeSource = (source: string): Promise => + describeProject( + project.indexPath, + undefined, + new Map([ + ["index.html", setup.indexSource], + [row.file, source], + ]), + ); + const describedBefore = await describeSource(before); + const result = mutationResult(row, describedBefore, setup.plan); + if (setup.plan) return printPlan(result, json, timeline, describeSource, after, before); + return applyAndPrint({ + setup, + verb, + json, + row, + nextStart, + nextDuration, + after, + before, + result, + describeSource, + }); +} + +async function applyAndPrint(args: { + setup: MutationSetup; + verb: MutationVerb; + json: boolean; + row: TimelineRow; + nextStart: number; + nextDuration: number; + after: string; + before: string; + result: ReturnType; + describeSource: (source: string) => Promise; +}): Promise { + const receipt = applyMutation(args.setup, args.after); + if (receipt && "error" in receipt) + return refusal(receipt.error, "re-run hyperframes timeline", args.json); + if (!receipt && args.after !== args.before) { + return refusal("mutation produced no receipt", "re-run hyperframes timeline", args.json); + } + args.result.after = rowsForFile(await args.describeSource(args.after), args.row.file); + args.result.receipt = receipt + ? { + file: receipt.sourceFile, + version: receipt.version, + writeToken: receipt.writeToken, + changed: receipt.changed, + backupPath: receipt.backupPath, + } + : null; + if (args.json) { + console.log(JSON.stringify(withMeta(args.result), null, 2)); + return; + } + console.log( + `${args.verb} ${args.row.ref}: ${args.row.start}-${args.row.end}s -> ${args.nextStart}-${args.nextStart + args.nextDuration}s\nreceipt: ${receipt?.version ?? "unchanged"}`, + ); +} + +function rowsForFile(timeline: ProjectTimeline, file: string): TimelineRow[] { + return allRows(timeline).filter((candidate) => candidate.file === file); +} + +export function rowAt( + timeline: ProjectTimeline, + pointer: { kind: TimelineRow["trackKind"]; index: number }, +): TimelineRow { + const track = timeline.tracks.find((candidate) => candidate.kind === pointer.kind); + if (!track) throw new Error(`missing track ${pointer.kind}`); + const row = track.rows[pointer.index]; + if (!row) throw new Error(`missing row ${pointer.kind}/${pointer.index}`); + return row; +} + +function mutationResult(row: TimelineRow, before: ProjectTimeline, planned: boolean) { + return { + ok: true, + receipt: null as unknown, + file: row.file, + before: rowsForFile(before, row.file), + after: [] as TimelineRow[], + warnings: row.warnings, + planned, + }; +} + +async function printPlan( + result: ReturnType, + json: boolean, + timeline: ProjectTimeline, + describeSource: (source: string) => Promise, + after: string, + before: string, +): Promise { + const plannedTimeline = await describeSource(after); + result.after = rowsForFile(plannedTimeline, result.file); + if (json) console.log(JSON.stringify(withMeta(result), null, 2)); + else + console.log( + `${formatTimeline(timeline)}\n\nplanned:\n${formatTimeline(plannedTimeline)}\n\ndiff:\n${diff(before, after)}`, + ); +} + +function applyMutation( + setup: MutationSetup, + after: string, +): AppliedFileMutation | { error: string } | undefined { + try { + return applyFileMutations(setup.project.dir, [ + { + sourceFile: setup.row.file, + absPath: setup.filePath, + before: setup.before, + after, + expectedVersion: setup.expectedVersion, + }, + ])[0]; + } catch (error) { + if (error instanceof Error && error.message === "file changed since the timeline was read") { + return { error: error.message }; + } + throw error; + } +} + +export function positional(args: Record): string[] { + return Array.isArray(args._) + ? args._.filter((value): value is string => typeof value === "string") + : []; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function mutationRefusal( + verb: MutationVerb, + after: string, + before: string, + ref: string, +): { reason: string; fix: string } | null { + if (verb !== "delete" || after !== before) return null; + return { reason: `${ref} was not found`, fix: "choose an existing clip" }; +} + +export function mutationConflict( + verb: MutationVerb, + overwrite: boolean, + row: TimelineRow, + timeline: ProjectTimeline, + nextStart: number, + nextDuration: number, +): { reason: string; fix: string } | null { + if ((verb !== "move" && verb !== "trim") || overwrite) return null; + const conflict = overlap(row, timeline, nextStart, nextStart + nextDuration); + if (!conflict) return null; + return { + reason: `${row.ref} would overlap ${conflict.ref} at ${nextStart}-${nextStart + nextDuration}`, + fix: "pass --overwrite or move the named neighbour", + }; +} + diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index c4b7ee881d..c67d31944c 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -368,291 +368,3 @@ export function decideMutation( return duplicateMutation(context, args); } } - -export async function runMutation( - verb: MutationVerb, - args: Record, -): Promise { - const setup = await prepareMutation(args); - if (!setup.ok) return refusal(setup.reason, setup.fix, setup.json); - const decision = decideMutation(verb, setup.context, args); - if (!decision.ok) return refusal(decision.reason, decision.fix, setup.json); - await finishMutation(setup, verb, decision); -} - -interface MutationSetup { - ok: true; - project: ReturnType; - ref: string; - json: boolean; - plan: boolean; - overwrite: boolean; - timeline: ProjectTimeline; - indexSource: string; - row: TimelineRow; - resolved: Extract, { ok: true }>; - filePath: string; - before: string; - expectedVersion: string; - context: MutationContext; -} - -type MutationSetupResult = - | MutationSetup - | { ok: false; reason: string; fix: string; json: boolean }; - -async function prepareMutation(args: Record): Promise { - const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); - const ref = typeof args.ref === "string" ? args.ref : ""; - const json = args.json === true; - const plan = args.plan === true; - const overwrite = args.overwrite === true; - const snap = args.snap === true; - ensureDOMParser(); - const indexSource = readFileSync(project.indexPath, "utf-8"); - const initialTimeline = await describeProject( - project.indexPath, - undefined, - new Map([["index.html", indexSource]]), - ); - const projectFps = declaredFps(indexSource); - if (snap && projectFps === null) { - return { - ok: false, - reason: "project fps is unknown", - fix: "set data-fps on the project, then rerun with --snap", - json, - }; - } - const initialResolved = resolveRef(initialTimeline, ref); - if (!initialResolved.ok) - return { ok: false, reason: initialResolved.reason, fix: initialResolved.fix, json }; - const initialRow = initialResolved.row; - const filePath = join(project.dir, initialRow.file); - const before = readFileSync(filePath, "utf-8"); - const expectedVersion = fileContentVersion(before); - const sources = new Map([ - ["index.html", indexSource], - [initialRow.file, before], - ]); - const timeline = await describeProject(project.indexPath, undefined, sources); - const resolved = resolveRef(timeline, ref); - if (!resolved.ok) return { ok: false, reason: resolved.reason, fix: resolved.fix, json }; - const row = resolved.row; - const anchor = (anchorRef: string) => { - const found = resolveRef(timeline, anchorRef); - return found.ok ? found.row : undefined; - }; - const parseTime = (expression: string) => { - const parsed = parseTimeExpression(expression, { - row, - duration: timeline.duration, - fps: fpsFor(project.indexPath), - resolveAnchor: anchor, - }); - if (!parsed.ok || !snap) return parsed; - return { ok: true as const, seconds: Math.round(parsed.seconds * projectFps!) / projectFps! }; - }; - return { - ok: true, - project, - ref, - json, - plan, - overwrite, - timeline, - indexSource, - row, - resolved, - filePath, - before, - expectedVersion, - context: { - ref, - row, - before, - resolved, - parseTime, - // Nested rows use the visible host clip duration as their composition bound. - duration: - row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, - }, - }; -} - -async function finishMutation( - setup: MutationSetup, - verb: MutationVerb, - decision: Extract, -): Promise { - const { after, nextStart, nextDuration } = decision; - const { ref, row, timeline, json, overwrite, project, before } = setup; - const refusalMessage = mutationRefusal(verb, after, before, ref); - if (refusalMessage) return refusal(refusalMessage.reason, refusalMessage.fix, json); - const conflict = mutationConflict(verb, overwrite, row, timeline, nextStart, nextDuration); - if (conflict) return refusal(conflict.reason, conflict.fix, json); - const describeSource = (source: string): Promise => - describeProject( - project.indexPath, - undefined, - new Map([ - ["index.html", setup.indexSource], - [row.file, source], - ]), - ); - const describedBefore = await describeSource(before); - const result = mutationResult(row, describedBefore, setup.plan); - if (setup.plan) return printPlan(result, json, timeline, describeSource, after, before); - return applyAndPrint({ - setup, - verb, - json, - row, - nextStart, - nextDuration, - after, - before, - result, - describeSource, - }); -} - -async function applyAndPrint(args: { - setup: MutationSetup; - verb: MutationVerb; - json: boolean; - row: TimelineRow; - nextStart: number; - nextDuration: number; - after: string; - before: string; - result: ReturnType; - describeSource: (source: string) => Promise; -}): Promise { - const receipt = applyMutation(args.setup, args.after); - if (receipt && "error" in receipt) - return refusal(receipt.error, "re-run hyperframes timeline", args.json); - if (!receipt && args.after !== args.before) { - return refusal("mutation produced no receipt", "re-run hyperframes timeline", args.json); - } - args.result.after = rowsForFile(await args.describeSource(args.after), args.row.file); - args.result.receipt = receipt - ? { - file: receipt.sourceFile, - version: receipt.version, - writeToken: receipt.writeToken, - changed: receipt.changed, - backupPath: receipt.backupPath, - } - : null; - if (args.json) { - console.log(JSON.stringify(withMeta(args.result), null, 2)); - return; - } - console.log( - `${args.verb} ${args.row.ref}: ${args.row.start}-${args.row.end}s -> ${args.nextStart}-${args.nextStart + args.nextDuration}s\nreceipt: ${receipt?.version ?? "unchanged"}`, - ); -} - -function rowsForFile(timeline: ProjectTimeline, file: string): TimelineRow[] { - return allRows(timeline).filter((candidate) => candidate.file === file); -} - -export function rowAt( - timeline: ProjectTimeline, - pointer: { kind: TimelineRow["trackKind"]; index: number }, -): TimelineRow { - const track = timeline.tracks.find((candidate) => candidate.kind === pointer.kind); - if (!track) throw new Error(`missing track ${pointer.kind}`); - const row = track.rows[pointer.index]; - if (!row) throw new Error(`missing row ${pointer.kind}/${pointer.index}`); - return row; -} - -function mutationResult(row: TimelineRow, before: ProjectTimeline, planned: boolean) { - return { - ok: true, - receipt: null as unknown, - file: row.file, - before: rowsForFile(before, row.file), - after: [] as TimelineRow[], - warnings: row.warnings, - planned, - }; -} - -async function printPlan( - result: ReturnType, - json: boolean, - timeline: ProjectTimeline, - describeSource: (source: string) => Promise, - after: string, - before: string, -): Promise { - const plannedTimeline = await describeSource(after); - result.after = rowsForFile(plannedTimeline, result.file); - if (json) console.log(JSON.stringify(withMeta(result), null, 2)); - else - console.log( - `${formatTimeline(timeline)}\n\nplanned:\n${formatTimeline(plannedTimeline)}\n\ndiff:\n${diff(before, after)}`, - ); -} - -function applyMutation( - setup: MutationSetup, - after: string, -): AppliedFileMutation | { error: string } | undefined { - try { - return applyFileMutations(setup.project.dir, [ - { - sourceFile: setup.row.file, - absPath: setup.filePath, - before: setup.before, - after, - expectedVersion: setup.expectedVersion, - }, - ])[0]; - } catch (error) { - if (error instanceof Error && error.message === "file changed since the timeline was read") { - return { error: error.message }; - } - throw error; - } -} - -export function positional(args: Record): string[] { - return Array.isArray(args._) - ? args._.filter((value): value is string => typeof value === "string") - : []; -} - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function mutationRefusal( - verb: MutationVerb, - after: string, - before: string, - ref: string, -): { reason: string; fix: string } | null { - if (verb !== "delete" || after !== before) return null; - return { reason: `${ref} was not found`, fix: "choose an existing clip" }; -} - -export function mutationConflict( - verb: MutationVerb, - overwrite: boolean, - row: TimelineRow, - timeline: ProjectTimeline, - nextStart: number, - nextDuration: number, -): { reason: string; fix: string } | null { - if ((verb !== "move" && verb !== "trim") || overwrite) return null; - const conflict = overlap(row, timeline, nextStart, nextStart + nextDuration); - if (!conflict) return null; - return { - reason: `${row.ref} would overlap ${conflict.ref} at ${nextStart}-${nextStart + nextDuration}`, - fix: "pass --overwrite or move the named neighbour", - }; -} From 56263ef2ddfa03a13fc7b587c964da4be327cdce Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:41:49 -0400 Subject: [PATCH 23/40] fix(cli): keep shared mutation primitives in support module --- .../cli/src/timeline/a2MutationCommand.ts | 6 ++- packages/cli/src/timeline/a2Shared.ts | 44 +++++++++++++++++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/timeline/a2MutationCommand.ts b/packages/cli/src/timeline/a2MutationCommand.ts index 742f52c659..4344fb4186 100644 --- a/packages/cli/src/timeline/a2MutationCommand.ts +++ b/packages/cli/src/timeline/a2MutationCommand.ts @@ -1,4 +1,4 @@ -import { applyFileMutations } from "@hyperframes/studio-server"; +import { applyFileMutations, fileContentVersion } from "@hyperframes/studio-server"; import type { AppliedFileMutation } from "@hyperframes/studio-server"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -11,6 +11,8 @@ import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; import { allRows, + decideMutation, + declaredFps, diff, fpsFor, isRecord, @@ -19,6 +21,7 @@ import { refusal, rowAt, type MutationContext, + type MutationDecision, type MutationVerb, } from "./a2Shared.js"; @@ -309,4 +312,3 @@ export function mutationConflict( fix: "pass --overwrite or move the named neighbour", }; } - diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index c67d31944c..d1ce221227 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -23,7 +23,7 @@ import { parseSetAssignments, type SetAssignment } from "./a2Mutations.js"; export type MutationVerb = "move" | "trim" | "split" | "delete" | "set" | "duplicate"; -type MutationDecision = +export type MutationDecision = | { ok: true; after: string; nextStart: number; nextDuration: number } | { ok: false; reason: string; fix: string }; @@ -56,7 +56,7 @@ export function fpsFor(indexPath: string): number { return fpsToNumber(parsed.ok ? parsed.value : { num: 30, den: 1 }); } -function declaredFps(source: string): number | null { +export function declaredFps(source: string): number | null { const raw = readCompositionFps(source); if (raw === null) return null; const parsed = parseFpsWithDefault(raw); @@ -126,7 +126,7 @@ export function diff(before: string, after: string): string { return output.length <= 8_000 ? output : `${output.slice(0, 7_997)}...`; } -function overlap( +export function overlap( row: TimelineRow, timeline: ProjectTimeline, start: number, @@ -368,3 +368,41 @@ export function decideMutation( return duplicateMutation(context, args); } } + +export function rowAt( + timeline: ProjectTimeline, + pointer: { kind: TimelineRow["trackKind"]; index: number }, +): TimelineRow { + const track = timeline.tracks.find((candidate) => candidate.kind === pointer.kind); + if (!track) throw new Error(`missing track ${pointer.kind}`); + const row = track.rows[pointer.index]; + if (!row) throw new Error(`missing row ${pointer.kind}/${pointer.index}`); + return row; +} + +export function positional(args: Record): string[] { + return Array.isArray(args._) + ? args._.filter((value): value is string => typeof value === "string") + : []; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function mutationConflict( + verb: MutationVerb, + overwrite: boolean, + row: TimelineRow, + timeline: ProjectTimeline, + nextStart: number, + nextDuration: number, +): { reason: string; fix: string } | null { + if ((verb !== "move" && verb !== "trim") || overwrite) return null; + const conflict = overlap(row, timeline, nextStart, nextStart + nextDuration); + if (!conflict) return null; + return { + reason: `${row.ref} would overlap ${conflict.ref} at ${nextStart}-${nextStart + nextDuration}`, + fix: "pass --overwrite or move the named neighbour", + }; +} From 7f52d615999716e65c306f1d75d2b7219cd826e3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:44:47 -0400 Subject: [PATCH 24/40] fix(cli): use shared mutation support helpers --- .../cli/src/timeline/a2MutationCommand.ts | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/packages/cli/src/timeline/a2MutationCommand.ts b/packages/cli/src/timeline/a2MutationCommand.ts index 4344fb4186..98b55a966e 100644 --- a/packages/cli/src/timeline/a2MutationCommand.ts +++ b/packages/cli/src/timeline/a2MutationCommand.ts @@ -214,17 +214,6 @@ function rowsForFile(timeline: ProjectTimeline, file: string): TimelineRow[] { return allRows(timeline).filter((candidate) => candidate.file === file); } -export function rowAt( - timeline: ProjectTimeline, - pointer: { kind: TimelineRow["trackKind"]; index: number }, -): TimelineRow { - const track = timeline.tracks.find((candidate) => candidate.kind === pointer.kind); - if (!track) throw new Error(`missing track ${pointer.kind}`); - const row = track.rows[pointer.index]; - if (!row) throw new Error(`missing row ${pointer.kind}/${pointer.index}`); - return row; -} - function mutationResult(row: TimelineRow, before: ProjectTimeline, planned: boolean) { return { ok: true, @@ -276,16 +265,6 @@ function applyMutation( } } -export function positional(args: Record): string[] { - return Array.isArray(args._) - ? args._.filter((value): value is string => typeof value === "string") - : []; -} - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function mutationRefusal( verb: MutationVerb, after: string, @@ -295,20 +274,3 @@ function mutationRefusal( if (verb !== "delete" || after !== before) return null; return { reason: `${ref} was not found`, fix: "choose an existing clip" }; } - -export function mutationConflict( - verb: MutationVerb, - overwrite: boolean, - row: TimelineRow, - timeline: ProjectTimeline, - nextStart: number, - nextDuration: number, -): { reason: string; fix: string } | null { - if ((verb !== "move" && verb !== "trim") || overwrite) return null; - const conflict = overlap(row, timeline, nextStart, nextStart + nextDuration); - if (!conflict) return null; - return { - reason: `${row.ref} would overlap ${conflict.ref} at ${nextStart}-${nextStart + nextDuration}`, - fix: "pass --overwrite or move the named neighbour", - }; -} From 39b9fadfc817df890846d26a7a6f26583183c7be Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:47:41 -0400 Subject: [PATCH 25/40] refactor(cli): centralize A2 refusals --- packages/cli/src/timeline/a2Commands.ts | 71 +++++++++++++++++-------- packages/cli/src/timeline/a2Shared.ts | 17 +++--- 2 files changed, 58 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index 73ad798860..0e57f1755d 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -17,7 +17,7 @@ import { isRecord, mutationConflict, positional, - refusal, + refuse, rowAt, type MutationContext, type MutationVerb, @@ -113,23 +113,26 @@ function applyPlanEdit( return { ok: true, file: row.file, after: decision.after }; } -export async function runApply(args: Record): Promise { - const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); - const json = args.json === true; - const plan = args.plan === true; - const file = typeof args.file === "string" ? args.file : positional(args)[1]; - if (!file) return refusal("an edit plan is required", "pass an edits.json path or -", json); +type EditPlan = + | { ok: true; edits: unknown[] } + | { ok: false; reason: string; fix: string }; + +function readEditPlan(file: string): EditPlan { const raw = file === "-" ? readFileSync(0, "utf-8") : readFileSync(file, "utf-8"); - let edits: unknown; try { - edits = JSON.parse(raw); + const edits: unknown = JSON.parse(raw); + return Array.isArray(edits) + ? { ok: true, edits } + : { ok: false, reason: "edit plan must be a JSON array", fix: "pass a JSON array of edits" }; } catch { - return refusal("edit plan is not valid JSON", "pass a JSON array of edits", json); + return { ok: false, reason: "edit plan is not valid JSON", fix: "pass a JSON array of edits" }; } - if (!Array.isArray(edits)) - return refusal("edit plan must be a JSON array", "pass a JSON array of edits", json); - ensureDOMParser(); - const timeline = await describeProject(project.indexPath); +} + +function readPlanSources( + timeline: ProjectTimeline, + project: ReturnType, +): { sourceByFile: Map; beforeByFile: Map } { const sourceByFile = new Map(); const beforeByFile = new Map(); for (const fileName of new Set(allRows(timeline).map((row) => row.file))) { @@ -137,11 +140,36 @@ export async function runApply(args: Record): Promise { sourceByFile.set(fileName, source); beforeByFile.set(fileName, source); } + return { sourceByFile, beforeByFile }; +} + +function applyPlanEdits( + edits: unknown[], + timeline: ProjectTimeline, + project: ReturnType, + sourceByFile: Map, +): { ok: true } | { ok: false; reason: string; fix: string } { for (const edit of edits) { const result = applyPlanEdit(edit, timeline, project, sourceByFile); - if (!result.ok) return refusal(result.reason, result.fix, json); + if (!result.ok) return result; sourceByFile.set(result.file, result.after); } + return { ok: true }; +} + +export async function runApply(args: Record): Promise { + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const json = args.json === true; + const plan = args.plan === true; + const file = typeof args.file === "string" ? args.file : positional(args)[1]; + if (!file) return refuse("timeline apply", { reason: "an edit plan is required", fix: "pass an edits.json path or -" }, json); + const planInput = readEditPlan(file); + if (!planInput.ok) return refuse("timeline apply", planInput, json); + ensureDOMParser(); + const timeline = await describeProject(project.indexPath); + const { sourceByFile, beforeByFile } = readPlanSources(timeline, project); + const editsResult = applyPlanEdits(planInput.edits, timeline, project, sourceByFile); + if (!editsResult.ok) return refuse("timeline apply", editsResult, json); const inputs = [...sourceByFile].flatMap(([fileName, after]) => { const before = beforeByFile.get(fileName)!; return after === before @@ -186,7 +214,7 @@ export async function runUndo(args: Record): Promise { const json = args.json === true; const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1]; if (!input) - return refusal("an undo receipt is required", "pass the receipt JSON or its file", json); + return refuse("timeline undo", { reason: "an undo receipt is required", fix: "pass the receipt JSON or its file" }, json); let raw: string; try { raw = readFileSync(input, "utf-8"); @@ -197,7 +225,7 @@ export async function runUndo(args: Record): Promise { try { parsed = JSON.parse(raw); } catch { - return refusal("undo receipt is not valid JSON", "pass the applied JSON receipt", json); + return refuse("timeline undo", { reason: "undo receipt is not valid JSON", fix: "pass the applied JSON receipt" }, json); } const value = isRecord(parsed) && isRecord(parsed.receipt) ? parsed.receipt : parsed; if ( @@ -206,11 +234,10 @@ export async function runUndo(args: Record): Promise { typeof value.version !== "string" || typeof value.backupPath !== "string" ) { - return refusal( - "undo receipt is missing file, version, or backupPath", - "pass an applied timeline receipt", - json, - ); + return refuse("timeline undo", { + reason: "undo receipt is missing file, version, or backupPath", + fix: "pass an applied timeline receipt", + }, json); } const backup = join(project.dir, value.backupPath); const target = join(project.dir, value.file); diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index d1ce221227..a404fff41a 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -1,24 +1,17 @@ import { - applyFileMutations, duplicateElementInHtml, - fileContentVersion, patchElementInHtml, removeElementFromHtml, splitElementInHtml, } from "@hyperframes/studio-server"; -import type { AppliedFileMutation, PatchOperation } from "@hyperframes/studio-server"; +import type { PatchOperation } from "@hyperframes/studio-server"; import { fpsToNumber, parseFpsWithDefault } from "@hyperframes/core"; import { readCompositionFps } from "../utils/compositionFps.js"; import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js"; -import { formatTimeline } from "./formatTimeline.js"; import { resolveRef } from "./resolveRef.js"; import { parseTimeExpression } from "./timeExpr.js"; -import { ensureDOMParser } from "../utils/dom.js"; import { setCommandExitCode } from "../utils/commandResult.js"; -import { resolveProject } from "../utils/project.js"; -import { withMeta } from "../utils/updateCheck.js"; import { parseSetAssignments, type SetAssignment } from "./a2Mutations.js"; export type MutationVerb = "move" | "trim" | "split" | "delete" | "set" | "duplicate"; @@ -49,6 +42,14 @@ export function refusal(reason: string, fix: string, json: boolean): void { console.error(json ? JSON.stringify(payload, null, 2) : `${reason}; ${fix}.`); } +export function refuse( + kind: string, + detail: { reason: string; fix: string }, + json: boolean, +): void { + refusal(`${kind}: ${detail.reason}`, detail.fix, json); +} + export function fpsFor(indexPath: string): number { const parsed = parseFpsWithDefault( readCompositionFps(readFileSync(indexPath, "utf-8")) ?? undefined, From 3528bfeab7b1f267a95f7138a6efad72f417dbf1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:50:23 -0400 Subject: [PATCH 26/40] style(cli): format A2 refusal helpers --- packages/cli/src/timeline/a2Commands.ts | 35 ++++++++++++++++++------- packages/cli/src/timeline/a2Shared.ts | 6 +---- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index 0e57f1755d..f0b208c8ec 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -113,9 +113,7 @@ function applyPlanEdit( return { ok: true, file: row.file, after: decision.after }; } -type EditPlan = - | { ok: true; edits: unknown[] } - | { ok: false; reason: string; fix: string }; +type EditPlan = { ok: true; edits: unknown[] } | { ok: false; reason: string; fix: string }; function readEditPlan(file: string): EditPlan { const raw = file === "-" ? readFileSync(0, "utf-8") : readFileSync(file, "utf-8"); @@ -162,7 +160,12 @@ export async function runApply(args: Record): Promise { const json = args.json === true; const plan = args.plan === true; const file = typeof args.file === "string" ? args.file : positional(args)[1]; - if (!file) return refuse("timeline apply", { reason: "an edit plan is required", fix: "pass an edits.json path or -" }, json); + if (!file) + return refuse( + "timeline apply", + { reason: "an edit plan is required", fix: "pass an edits.json path or -" }, + json, + ); const planInput = readEditPlan(file); if (!planInput.ok) return refuse("timeline apply", planInput, json); ensureDOMParser(); @@ -214,7 +217,11 @@ export async function runUndo(args: Record): Promise { const json = args.json === true; const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1]; if (!input) - return refuse("timeline undo", { reason: "an undo receipt is required", fix: "pass the receipt JSON or its file" }, json); + return refuse( + "timeline undo", + { reason: "an undo receipt is required", fix: "pass the receipt JSON or its file" }, + json, + ); let raw: string; try { raw = readFileSync(input, "utf-8"); @@ -225,7 +232,11 @@ export async function runUndo(args: Record): Promise { try { parsed = JSON.parse(raw); } catch { - return refuse("timeline undo", { reason: "undo receipt is not valid JSON", fix: "pass the applied JSON receipt" }, json); + return refuse( + "timeline undo", + { reason: "undo receipt is not valid JSON", fix: "pass the applied JSON receipt" }, + json, + ); } const value = isRecord(parsed) && isRecord(parsed.receipt) ? parsed.receipt : parsed; if ( @@ -234,10 +245,14 @@ export async function runUndo(args: Record): Promise { typeof value.version !== "string" || typeof value.backupPath !== "string" ) { - return refuse("timeline undo", { - reason: "undo receipt is missing file, version, or backupPath", - fix: "pass an applied timeline receipt", - }, json); + return refuse( + "timeline undo", + { + reason: "undo receipt is missing file, version, or backupPath", + fix: "pass an applied timeline receipt", + }, + json, + ); } const backup = join(project.dir, value.backupPath); const target = join(project.dir, value.file); diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index a404fff41a..5011b3dca4 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -42,11 +42,7 @@ export function refusal(reason: string, fix: string, json: boolean): void { console.error(json ? JSON.stringify(payload, null, 2) : `${reason}; ${fix}.`); } -export function refuse( - kind: string, - detail: { reason: string; fix: string }, - json: boolean, -): void { +export function refuse(kind: string, detail: { reason: string; fix: string }, json: boolean): void { refusal(`${kind}: ${detail.reason}`, detail.fix, json); } From 737ef033abe401298950375e60e1c998b3dea291 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:57:28 -0400 Subject: [PATCH 27/40] fix(cli): clean shared A2 imports --- packages/cli/src/commands/timeline.ts | 1 - packages/cli/src/timeline/a2MutationCommand.ts | 2 -- packages/cli/src/timeline/a2Shared.ts | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 1e06d78b5b..0432268d08 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -5,7 +5,6 @@ import { formatTimeline } from "../timeline/formatTimeline.js"; import { ensureDOMParser } from "../utils/dom.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; -import { runApply, runIds, runUndo } from "../timeline/a2Commands.js"; import { runMutation } from "../timeline/a2MutationCommand.js"; import type { MutationVerb } from "../timeline/a2Shared.js"; diff --git a/packages/cli/src/timeline/a2MutationCommand.ts b/packages/cli/src/timeline/a2MutationCommand.ts index 98b55a966e..d09ea27803 100644 --- a/packages/cli/src/timeline/a2MutationCommand.ts +++ b/packages/cli/src/timeline/a2MutationCommand.ts @@ -15,9 +15,7 @@ import { declaredFps, diff, fpsFor, - isRecord, mutationConflict, - positional, refusal, rowAt, type MutationContext, diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index 5011b3dca4..9e77e3ddc7 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -8,7 +8,7 @@ import type { PatchOperation } from "@hyperframes/studio-server"; import { fpsToNumber, parseFpsWithDefault } from "@hyperframes/core"; import { readCompositionFps } from "../utils/compositionFps.js"; import { readFileSync } from "node:fs"; -import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js"; +import type { ProjectTimeline, TimelineRow } from "./describeProject.js"; import { resolveRef } from "./resolveRef.js"; import { parseTimeExpression } from "./timeExpr.js"; import { setCommandExitCode } from "../utils/commandResult.js"; From 78614c788f5bbc8993a389615da46701165e751d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:00:06 -0400 Subject: [PATCH 28/40] fix(cli): make A2 runner imports explicit --- packages/cli/src/commands/timeline.ts | 7 ++++--- packages/cli/src/timeline/a2Shared.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 0432268d08..3b8b126a41 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -5,6 +5,7 @@ import { formatTimeline } from "../timeline/formatTimeline.js"; import { ensureDOMParser } from "../utils/dom.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; +import { runApply, runIds, runUndo } from "../timeline/a2Commands.js"; import { runMutation } from "../timeline/a2MutationCommand.js"; import type { MutationVerb } from "../timeline/a2Shared.js"; @@ -54,7 +55,7 @@ export default defineCommand({ meta: { name: "ids", description: "Stamp stable ids on timeline clips" }, args: { dir: { type: "string" }, json: { type: "boolean", default: false } }, async run({ args }) { - await (await import("../timeline/a2Commands.js")).runIds(args); + await runIds(args); }, }), apply: () => @@ -67,7 +68,7 @@ export default defineCommand({ plan: { type: "boolean", default: false }, }, async run({ args }) { - await (await import("../timeline/a2Commands.js")).runApply(args); + await runApply(args); }, }), undo: () => @@ -79,7 +80,7 @@ export default defineCommand({ json: { type: "boolean", default: false }, }, async run({ args }) { - await (await import("../timeline/a2Commands.js")).runUndo(args); + await runUndo(args); }, }), }, diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index 9e77e3ddc7..b89168ae22 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -123,7 +123,7 @@ export function diff(before: string, after: string): string { return output.length <= 8_000 ? output : `${output.slice(0, 7_997)}...`; } -export function overlap( +function overlap( row: TimelineRow, timeline: ProjectTimeline, start: number, From 9449fb1f4381b9d9906ef2bc8d5f01a9c6669a9c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:11:03 -0400 Subject: [PATCH 29/40] refactor(cli): isolate apply plan target validation --- packages/cli/src/timeline/a2Commands.ts | 54 ++++++++++++++++--------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index f0b208c8ec..0a14a9751f 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -54,36 +54,50 @@ type ApplyEditResult = | { ok: true; file: string; after: string } | { ok: false; reason: string; fix: string }; -function applyPlanEdit( +const PLAN_VERBS = new Set(["move", "trim", "split", "delete", "set", "duplicate"]); + +type PreparedPlanEdit = { + edit: Record; + row: TimelineRow; + before: string; + resolved: Extract, { ok: true }>; +}; + +function preparePlanEdit( edit: unknown, timeline: ProjectTimeline, - project: ReturnType, sourceByFile: Map, -): ApplyEditResult { +): PreparedPlanEdit | { ok: false; reason: string; fix: string } { if (!isRecord(edit) || typeof edit.verb !== "string" || typeof edit.ref !== "string") { - return { - ok: false, - reason: "each edit needs a verb and ref", - fix: "pass {verb, ref, ...} objects", - }; + return { ok: false, reason: "each edit needs a verb and ref", fix: "pass {verb, ref, ...} objects" }; } - const verb = edit.verb; - const supported = ["move", "trim", "split", "delete", "set", "duplicate"]; - if (!supported.includes(verb)) { + if (!PLAN_VERBS.has(edit.verb as MutationVerb)) { return { ok: false, - reason: `unsupported edit verb ${verb}`, + reason: `unsupported edit verb ${edit.verb}`, fix: "use move, trim, split, delete, set, or duplicate", }; } const resolved = resolveRef(timeline, edit.ref); - if (!resolved.ok) return { ok: false, reason: resolved.reason, fix: resolved.fix }; - const row = resolved.row; - const before = sourceByFile.get(row.file); - if (before === undefined) - return { ok: false, reason: `${row.file} was not found`, fix: "choose an existing clip" }; + if (!resolved.ok) return resolved; + const before = sourceByFile.get(resolved.row.file); + return before === undefined + ? { ok: false, reason: `${resolved.row.file} was not found`, fix: "choose an existing clip" } + : { ok: true, edit, row: resolved.row, before, resolved }; +} + +function applyPlanEdit( + edit: unknown, + timeline: ProjectTimeline, + project: ReturnType, + sourceByFile: Map, +): ApplyEditResult { + const prepared = preparePlanEdit(edit, timeline, sourceByFile); + if (!prepared.ok) return prepared; + const { edit: planEdit, row, before, resolved } = prepared; + const verb = planEdit.verb as MutationVerb; const context: MutationContext = { - ref: edit.ref, + ref: planEdit.ref as string, row, before, resolved, @@ -99,11 +113,11 @@ function applyPlanEdit( }), duration: row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration, }; - const decision = decideMutation(verb as MutationVerb, context, { ...edit, _: [edit.ref] }); + const decision = decideMutation(verb, context, { ...planEdit, _: [planEdit.ref] }); if (!decision.ok) return { ok: false, reason: decision.reason, fix: decision.fix }; const conflict = mutationConflict( verb as MutationVerb, - edit.overwrite === true, + planEdit.overwrite === true, row, timeline, decision.nextStart, From a31058a5d6aa03f2db0dbe395007b0138d45a98c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:12:50 -0400 Subject: [PATCH 30/40] fix(cli): tag prepared plan edits --- packages/cli/src/timeline/a2Commands.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index 0a14a9751f..ce87ce4bbf 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -2,7 +2,7 @@ import { applyFileMutations, fileContentVersion } from "@hyperframes/studio-serv import type { AppliedFileMutation } from "@hyperframes/studio-server"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { describeProject, type ProjectTimeline } from "./describeProject.js"; +import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js"; import { resolveRef } from "./resolveRef.js"; import { parseTimeExpression } from "./timeExpr.js"; import { ensureDOMParser } from "../utils/dom.js"; @@ -57,6 +57,7 @@ type ApplyEditResult = const PLAN_VERBS = new Set(["move", "trim", "split", "delete", "set", "duplicate"]); type PreparedPlanEdit = { + ok: true; edit: Record; row: TimelineRow; before: string; From 2ac43fc76517568019d6d6e0322768bcac80d593 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:15:21 -0400 Subject: [PATCH 31/40] style(cli): format plan target helper --- packages/cli/src/timeline/a2Commands.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index ce87ce4bbf..5f58523563 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -70,7 +70,11 @@ function preparePlanEdit( sourceByFile: Map, ): PreparedPlanEdit | { ok: false; reason: string; fix: string } { if (!isRecord(edit) || typeof edit.verb !== "string" || typeof edit.ref !== "string") { - return { ok: false, reason: "each edit needs a verb and ref", fix: "pass {verb, ref, ...} objects" }; + return { + ok: false, + reason: "each edit needs a verb and ref", + fix: "pass {verb, ref, ...} objects", + }; } if (!PLAN_VERBS.has(edit.verb as MutationVerb)) { return { From c3bbf655aaf2f66e05d405809e9e6b6405c42430 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 13:44:12 -0400 Subject: [PATCH 32/40] fix(cli): validate sequential plans and duplicate ids --- packages/cli/src/timeline/a2Commands.ts | 7 +++--- .../cli/src/timeline/timeline.e2e.test.ts | 25 +++++++++++++++++++ .../src/helpers/duplicateElement.ts | 3 ++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index 5f58523563..40a041c41b 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -160,16 +160,17 @@ function readPlanSources( return { sourceByFile, beforeByFile }; } -function applyPlanEdits( +async function applyPlanEdits( edits: unknown[], timeline: ProjectTimeline, project: ReturnType, sourceByFile: Map, -): { ok: true } | { ok: false; reason: string; fix: string } { +): Promise<{ ok: true } | { ok: false; reason: string; fix: string }> { for (const edit of edits) { const result = applyPlanEdit(edit, timeline, project, sourceByFile); if (!result.ok) return result; sourceByFile.set(result.file, result.after); + timeline = await describeProject(project.indexPath, undefined, sourceByFile); } return { ok: true }; } @@ -190,7 +191,7 @@ export async function runApply(args: Record): Promise { ensureDOMParser(); const timeline = await describeProject(project.indexPath); const { sourceByFile, beforeByFile } = readPlanSources(timeline, project); - const editsResult = applyPlanEdits(planInput.edits, timeline, project, sourceByFile); + const editsResult = await applyPlanEdits(planInput.edits, timeline, project, sourceByFile); if (!editsResult.ok) return refuse("timeline apply", editsResult, json); const inputs = [...sourceByFile].flatMap(([fileName, after]) => { const before = beforeByFile.get(fileName)!; diff --git a/packages/cli/src/timeline/timeline.e2e.test.ts b/packages/cli/src/timeline/timeline.e2e.test.ts index 80b4a96f24..a3fb66ed10 100644 --- a/packages/cli/src/timeline/timeline.e2e.test.ts +++ b/packages/cli/src/timeline/timeline.e2e.test.ts @@ -250,12 +250,37 @@ describe("timeline edit command", () => { expect(result.status, result.stderr).toBe(0); const html = readFileSync(join(dir, "index.html"), "utf8"); expect(html).toContain('id="clip-copy"'); + expect(html.match(/data-hf-id=/g)).toHaveLength(3); expect(html).toContain('id="neighbour" data-hf-id="neighbour" data-start="7"'); } finally { rmSync(dir, { recursive: true, force: true }); } }); + it("revalidates each apply edit against the previous edit's source", () => { + const dir = project(); + try { + const indexPath = join(dir, "index.html"); + writeFileSync( + indexPath, + readFileSync(indexPath, "utf8").replace('data-start="5"', 'data-start="7"'), + ); + const planPath = join(dir, "edits.json"); + writeFileSync( + planPath, + JSON.stringify([ + { verb: "move", ref: "#clip", time: "+1" }, + { verb: "move", ref: "#clip", time: "+2" }, + ]), + ); + const result = run(dir, "apply", planPath); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(indexPath, "utf8")).toContain('data-start="4"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("applies a JSON plan atomically and undoes its receipt", () => { const dir = project(); try { diff --git a/packages/studio-server/src/helpers/duplicateElement.ts b/packages/studio-server/src/helpers/duplicateElement.ts index 0d562eea42..a49192f25d 100644 --- a/packages/studio-server/src/helpers/duplicateElement.ts +++ b/packages/studio-server/src/helpers/duplicateElement.ts @@ -1,4 +1,5 @@ import { parseHTML } from "linkedom"; +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import type { SourceMutationTarget } from "./sourceMutation.js"; export interface DuplicateElementResult { @@ -41,7 +42,7 @@ export function duplicateElementInHtml( } clone.setAttribute("data-start", String(at)); element.parentElement.insertBefore(clone, element.nextSibling); - return { html: document.toString(), matched: true, newId: uniqueId }; + return { html: ensureHfIds(document.toString()), matched: true, newId: uniqueId }; } function numericAttribute(element: Element, name: string): number | null { From 91343d0751978e71638b9bc62468a51a36427eba Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 14:03:55 -0400 Subject: [PATCH 33/40] test(cli): account for stamped composition id --- packages/cli/src/timeline/timeline.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/timeline/timeline.e2e.test.ts b/packages/cli/src/timeline/timeline.e2e.test.ts index a3fb66ed10..b2159603b5 100644 --- a/packages/cli/src/timeline/timeline.e2e.test.ts +++ b/packages/cli/src/timeline/timeline.e2e.test.ts @@ -250,7 +250,7 @@ describe("timeline edit command", () => { expect(result.status, result.stderr).toBe(0); const html = readFileSync(join(dir, "index.html"), "utf8"); expect(html).toContain('id="clip-copy"'); - expect(html.match(/data-hf-id=/g)).toHaveLength(3); + expect(html.match(/data-hf-id=/g)).toHaveLength(4); expect(html).toContain('id="neighbour" data-hf-id="neighbour" data-start="7"'); } finally { rmSync(dir, { recursive: true, force: true }); From 42d858f093dcfaf569371ddfecbe7978832cc793 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 14:51:03 -0400 Subject: [PATCH 34/40] fix(studio): stamp ids on patched html --- .../studio-server/src/helpers/sourceMutation.test.ts | 11 +++++++++++ packages/studio-server/src/helpers/sourceMutation.ts | 6 ++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/studio-server/src/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts index 2115fbcc6b..8532537598 100644 --- a/packages/studio-server/src/helpers/sourceMutation.test.ts +++ b/packages/studio-server/src/helpers/sourceMutation.test.ts @@ -106,6 +106,17 @@ describe("patchElementInHtml", () => { expect(result).toContain('id="hero"'); }); + it("stamps the composition root before returning patched bytes", () => { + const source = '
Hello
'; + const { html: result, matched } = patchElementInHtml(source, { id: "hero" }, [ + { type: "text-content", property: "textContent", value: "Updated" }, + ]); + + expect(matched).toBe(true); + expect(result).toContain("Updated"); + expect(result).toMatch(/
{ const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "inline-style", property: "clip-path", value: "inset(10px 20px 30px 40px)" }, diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 5760cd3918..7bea5c2406 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -266,10 +266,8 @@ export function patchElementInHtml( } } - return { - html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), - matched: true, - }; + const html = wrappedFragment ? document.body.innerHTML || "" : document.toString(); + return { html: ensureHfIds(html), matched: true }; } export function probeElementInSource(source: string, target: SourceMutationTarget): boolean { From 35f2a3b31c8e22aded7fe80455ec688e0ef03a9f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 15:23:01 -0400 Subject: [PATCH 35/40] fix(studio): preserve no-op patch bytes --- packages/studio-server/src/helpers/sourceMutation.test.ts | 2 +- packages/studio-server/src/helpers/sourceMutation.ts | 1 + packages/studio-server/src/routes/files.test.ts | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/studio-server/src/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts index 8532537598..f0aae027f9 100644 --- a/packages/studio-server/src/helpers/sourceMutation.test.ts +++ b/packages/studio-server/src/helpers/sourceMutation.test.ts @@ -114,7 +114,7 @@ describe("patchElementInHtml", () => { expect(matched).toBe(true); expect(result).toContain("Updated"); - expect(result).toMatch(/
{ diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 7bea5c2406..7d14cb537d 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -267,6 +267,7 @@ export function patchElementInHtml( } const html = wrappedFragment ? document.body.innerHTML || "" : document.toString(); + if (html === source) return { html: source, matched: true }; return { html: ensureHfIds(html), matched: true }; } diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 306efb3f44..492706d6e0 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -1766,7 +1766,7 @@ gsap.set("#box", { rotation: 45 }); expect(response.status).toBe(200); expect(payload.changed).toBe(true); // The SECOND `.sub` (selectorIndex 1) is the one restacked, not the first. - expect(payload.content).toContain('
A
'); + expect(payload.content).toMatch(/
]*style="z-index: 1"[^>]*>A<\/div>/); expect(payload.content).toContain("z-index: 0"); }); @@ -1798,7 +1798,9 @@ gsap.set("#box", { rotation: 45 }); expect(response.status).toBe(200); expect(payload.changed).toBe(true); // First "main" untouched; second one restacked. - expect(payload.content).toContain('
first
'); + expect(payload.content).toMatch( + /
]*style="z-index: 5"[^>]*>first<\/div>/, + ); expect(payload.content).toContain("z-index: 0"); }); From 0fff963ed7a83b6fb01239af3d92e65a89fad2b1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 15:37:22 -0400 Subject: [PATCH 36/40] test(studio): expect stamped patch markup --- packages/studio-server/src/routes/files.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 492706d6e0..266b4e44b0 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -1766,7 +1766,9 @@ gsap.set("#box", { rotation: 45 }); expect(response.status).toBe(200); expect(payload.changed).toBe(true); // The SECOND `.sub` (selectorIndex 1) is the one restacked, not the first. - expect(payload.content).toMatch(/
]*style="z-index: 1"[^>]*>A<\/div>/); + expect(payload.content).toMatch( + /
A<\/div>/, + ); expect(payload.content).toContain("z-index: 0"); }); @@ -1799,7 +1801,7 @@ gsap.set("#box", { rotation: 45 }); expect(payload.changed).toBe(true); // First "main" untouched; second one restacked. expect(payload.content).toMatch( - /
]*style="z-index: 5"[^>]*>first<\/div>/, + /
first<\/div>/, ); expect(payload.content).toContain("z-index: 0"); }); From 19745e7e1e34c0976be2f3437e3bc358ecf91ee5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:04:39 -0400 Subject: [PATCH 37/40] fix(studio-server): duplicate elements in nested templates --- .../src/helpers/duplicateElement.test.ts | 19 ++++++++++ .../src/helpers/duplicateElement.ts | 36 ++++++------------- .../src/helpers/sourceMutation.ts | 10 ++++-- packages/studio-server/src/index.ts | 2 ++ 4 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 packages/studio-server/src/helpers/duplicateElement.test.ts diff --git a/packages/studio-server/src/helpers/duplicateElement.test.ts b/packages/studio-server/src/helpers/duplicateElement.test.ts new file mode 100644 index 0000000000..e344bb74a7 --- /dev/null +++ b/packages/studio-server/src/helpers/duplicateElement.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { duplicateElementInHtml } from "./duplicateElement.js"; + +describe("duplicateElementInHtml", () => { + it("duplicates a target inside a nested template", () => { + const source = ` + + `; + + const result = duplicateElementInHtml(source, { hfId: "hf-deep" }, "clip-copy", 1); + + expect(result.matched).toBe(true); + expect(result.newId).toBe("clip-copy"); + expect(result.html).toContain('id="clip-copy"'); + expect(result.html).toContain('data-start="1"'); + }); +}); diff --git a/packages/studio-server/src/helpers/duplicateElement.ts b/packages/studio-server/src/helpers/duplicateElement.ts index a49192f25d..94957dc549 100644 --- a/packages/studio-server/src/helpers/duplicateElement.ts +++ b/packages/studio-server/src/helpers/duplicateElement.ts @@ -1,6 +1,10 @@ -import { parseHTML } from "linkedom"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; -import type { SourceMutationTarget } from "./sourceMutation.js"; +import { + findTargetElement, + isHTMLElement, + parseSourceDocument, + type SourceMutationTarget, +} from "./sourceMutation.js"; export interface DuplicateElementResult { html: string; @@ -14,8 +18,8 @@ export function duplicateElementInHtml( newId: string, at: number, ): DuplicateElementResult { - const document = parseHTML(source).document; - const element = findTarget(document, target); + const { document, wrappedFragment } = parseSourceDocument(source); + const element = findTargetElement(document, target); if (!element || !element.parentElement) return { html: source, matched: false, newId: null }; const duration = numericAttribute(element, "data-duration"); const track = numericAttribute(element, "data-track-index") ?? 0; @@ -34,7 +38,7 @@ export function duplicateElementInHtml( candidate.setAttribute("data-start", String(start + duration)); } const clone = element.cloneNode(true); - if (!isElementNode(clone)) return { html: source, matched: false, newId: null }; + if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null }; clone.setAttribute("id", uniqueId); clone.removeAttribute("data-hf-id"); for (const child of Array.from(clone.querySelectorAll("[data-hf-id]"))) { @@ -42,29 +46,11 @@ export function duplicateElementInHtml( } clone.setAttribute("data-start", String(at)); element.parentElement.insertBefore(clone, element.nextSibling); - return { html: ensureHfIds(document.toString()), matched: true, newId: uniqueId }; + const html = wrappedFragment ? document.body.innerHTML || "" : document.toString(); + return { html: ensureHfIds(html), matched: true, newId: uniqueId }; } function numericAttribute(element: Element, name: string): number | null { const value = Number(element.getAttribute(name)); return Number.isFinite(value) ? value : null; } - -function findTarget(document: Document, target: SourceMutationTarget): Element | null { - if (target.hfId) { - const element = Array.from(document.querySelectorAll("[data-hf-id]")).find( - (candidate) => candidate.getAttribute("data-hf-id") === target.hfId, - ); - if (element) return element; - } - if (target.id) { - const element = document.getElementById(target.id); - if (element) return element; - } - if (!target.selector) return null; - return document.querySelectorAll(target.selector)[target.selectorIndex ?? 0] ?? null; -} - -function isElementNode(node: Node): node is Element { - return node.nodeType === 1 && "setAttribute" in node && "querySelectorAll" in node; -} diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 7d14cb537d..2a3391df08 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -20,7 +20,10 @@ export interface SourceMutationTarget { selectorIndex?: number; } -function parseSourceDocument(source: string): { document: Document; wrappedFragment: boolean } { +export function parseSourceDocument(source: string): { + document: Document; + wrappedFragment: boolean; +} { const hasDocumentShell = /]/i.test(source); if (hasDocumentShell) { return { document: parseHTML(source).document, wrappedFragment: false }; @@ -108,7 +111,10 @@ function findByHfId(document: Document, hfId: string): Element | null { } } -function findTargetElement(document: Document, target: SourceMutationTarget): Element | null { +export function findTargetElement( + document: Document, + target: SourceMutationTarget, +): Element | null { if (target.hfId) { const el = findByHfId(document, target.hfId); if (el) return el; diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index f4ba3e73df..e1893a9aab 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -15,6 +15,8 @@ export { patchElementInHtml, splitElementInHtml, removeElementFromHtml, + findTargetElement, + parseSourceDocument, type PatchOperation, type SourceMutationTarget, } from "./helpers/sourceMutation.js"; From 46fcf17fcb4d1a80ec8ca3feec8072a1ee77732a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:04:45 -0400 Subject: [PATCH 38/40] refactor(cli): share timeline mutation receipts --- packages/cli/src/timeline/a2Commands.ts | 16 +++------------- packages/cli/src/timeline/a2MutationCommand.ts | 11 ++--------- packages/cli/src/timeline/a2Mutations.ts | 6 ------ packages/cli/src/timeline/a2Shared.ts | 12 +++++++++++- 4 files changed, 16 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index 40a041c41b..324dbe45e6 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -1,5 +1,5 @@ import { applyFileMutations, fileContentVersion } from "@hyperframes/studio-server"; -import type { AppliedFileMutation } from "@hyperframes/studio-server"; +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js"; @@ -8,7 +8,6 @@ import { parseTimeExpression } from "./timeExpr.js"; import { ensureDOMParser } from "../utils/dom.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; -import { stampHfIds } from "./a2Mutations.js"; import { allRows, decideMutation, @@ -17,6 +16,7 @@ import { isRecord, mutationConflict, positional, + publicReceipt, refuse, rowAt, type MutationContext, @@ -31,7 +31,7 @@ export async function runIds(args: Record): Promise { const files = [...new Set(["index.html", ...allRows(beforeTimeline).map((row) => row.file)])]; const inputs = files.flatMap((file) => { const before = readFileSync(join(project.dir, file), "utf-8"); - const after = stampHfIds(before); + const after = ensureHfIds(before); return after === before ? [] : [{ sourceFile: file, absPath: join(project.dir, file), before, after }]; @@ -289,13 +289,3 @@ export async function runUndo(args: Record): Promise { if (json) console.log(JSON.stringify(withMeta(result), null, 2)); else console.log(`undid ${value.file}`); } - -function publicReceipt(receipt: AppliedFileMutation) { - return { - file: receipt.sourceFile, - version: receipt.version, - writeToken: receipt.writeToken, - changed: receipt.changed, - backupPath: receipt.backupPath, - }; -} diff --git a/packages/cli/src/timeline/a2MutationCommand.ts b/packages/cli/src/timeline/a2MutationCommand.ts index d09ea27803..25b945c219 100644 --- a/packages/cli/src/timeline/a2MutationCommand.ts +++ b/packages/cli/src/timeline/a2MutationCommand.ts @@ -16,6 +16,7 @@ import { diff, fpsFor, mutationConflict, + publicReceipt, refusal, rowAt, type MutationContext, @@ -190,15 +191,7 @@ async function applyAndPrint(args: { return refusal("mutation produced no receipt", "re-run hyperframes timeline", args.json); } args.result.after = rowsForFile(await args.describeSource(args.after), args.row.file); - args.result.receipt = receipt - ? { - file: receipt.sourceFile, - version: receipt.version, - writeToken: receipt.writeToken, - changed: receipt.changed, - backupPath: receipt.backupPath, - } - : null; + args.result.receipt = receipt ? publicReceipt(receipt) : null; if (args.json) { console.log(JSON.stringify(withMeta(args.result), null, 2)); return; diff --git a/packages/cli/src/timeline/a2Mutations.ts b/packages/cli/src/timeline/a2Mutations.ts index a3a1303bee..104320be77 100644 --- a/packages/cli/src/timeline/a2Mutations.ts +++ b/packages/cli/src/timeline/a2Mutations.ts @@ -1,5 +1,3 @@ -import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; - export type SetField = "volume" | "rate" | "track"; export interface SetAssignment { @@ -7,10 +5,6 @@ export interface SetAssignment { value: string; } -export function stampHfIds(source: string): string { - return ensureHfIds(source); -} - export function parseSetAssignments( values: readonly string[], ): { ok: true; assignments: SetAssignment[] } | { ok: false; reason: string; fix: string } { diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index b89168ae22..70fd6ba756 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -4,7 +4,7 @@ import { removeElementFromHtml, splitElementInHtml, } from "@hyperframes/studio-server"; -import type { PatchOperation } from "@hyperframes/studio-server"; +import type { AppliedFileMutation, PatchOperation } from "@hyperframes/studio-server"; import { fpsToNumber, parseFpsWithDefault } from "@hyperframes/core"; import { readCompositionFps } from "../utils/compositionFps.js"; import { readFileSync } from "node:fs"; @@ -46,6 +46,16 @@ export function refuse(kind: string, detail: { reason: string; fix: string }, js refusal(`${kind}: ${detail.reason}`, detail.fix, json); } +export function publicReceipt(receipt: AppliedFileMutation) { + return { + file: receipt.sourceFile, + version: receipt.version, + writeToken: receipt.writeToken, + changed: receipt.changed, + backupPath: receipt.backupPath, + }; +} + export function fpsFor(indexPath: string): number { const parsed = parseFpsWithDefault( readCompositionFps(readFileSync(indexPath, "utf-8")) ?? undefined, From 95480b11517568c00dfa0b5a8a1dd215b0549281 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:10:08 -0400 Subject: [PATCH 39/40] fix(studio-server): harden duplicate and no-op mutations --- packages/cli/src/timeline/a2Shared.ts | 14 ++++++++ .../cli/src/timeline/timeline.e2e.test.ts | 17 ++++++++++ .../src/helpers/duplicateElement.test.ts | 10 ++++++ .../src/helpers/duplicateElement.ts | 2 ++ .../src/helpers/sourceMutation.test.ts | 11 +++++++ .../src/helpers/sourceMutation.ts | 32 +++++++++++-------- packages/studio-server/src/index.ts | 1 + 7 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index 70fd6ba756..a8779726bc 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -405,6 +405,20 @@ export function mutationConflict( nextStart: number, nextDuration: number, ): { reason: string; fix: string } | null { + if (verb === "duplicate") { + const conflict = allRows(timeline).find( + (candidate) => + candidate.file === row.file && + candidate.trackIndex === row.trackIndex && + candidate.start < nextStart && + nextStart < candidate.end, + ); + if (!conflict) return null; + return { + reason: `duplicate insertion at ${nextStart} falls inside ${conflict.ref}`, + fix: "choose a clip boundary or split the spanning clip first", + }; + } if ((verb !== "move" && verb !== "trim") || overwrite) return null; const conflict = overlap(row, timeline, nextStart, nextStart + nextDuration); if (!conflict) return null; diff --git a/packages/cli/src/timeline/timeline.e2e.test.ts b/packages/cli/src/timeline/timeline.e2e.test.ts index b2159603b5..80463ca1ae 100644 --- a/packages/cli/src/timeline/timeline.e2e.test.ts +++ b/packages/cli/src/timeline/timeline.e2e.test.ts @@ -257,6 +257,23 @@ describe("timeline edit command", () => { } }); + it("refuses duplicate insertion inside a spanning clip", () => { + const dir = project(); + try { + const indexPath = join(dir, "index.html"); + writeFileSync( + indexPath, + readFileSync(indexPath, "utf8").replace('data-duration="2"', 'data-duration="4"'), + ); + const result = run(dir, "duplicate", "#clip", "--at", "3"); + expect(result.status).toBe(2); + expect(result.stderr).toContain("split the spanning clip first"); + expect(readFileSync(indexPath, "utf8")).not.toContain('id="clip-copy"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("revalidates each apply edit against the previous edit's source", () => { const dir = project(); try { diff --git a/packages/studio-server/src/helpers/duplicateElement.test.ts b/packages/studio-server/src/helpers/duplicateElement.test.ts index e344bb74a7..9e445d1ace 100644 --- a/packages/studio-server/src/helpers/duplicateElement.test.ts +++ b/packages/studio-server/src/helpers/duplicateElement.test.ts @@ -16,4 +16,14 @@ describe("duplicateElementInHtml", () => { expect(result.html).toContain('id="clip-copy"'); expect(result.html).toContain('data-start="1"'); }); + + it("deduplicates a composition id on the clone", () => { + const source = `
clip
`; + + const result = duplicateElementInHtml(source, { id: "scene" }, "scene-copy", 1); + + expect(result.matched).toBe(true); + expect(result.html).toContain('id="scene-copy" data-composition-id="scene-split"'); + expect(result.html.match(/data-composition-id="scene"/g)).toHaveLength(1); + }); }); diff --git a/packages/studio-server/src/helpers/duplicateElement.ts b/packages/studio-server/src/helpers/duplicateElement.ts index 94957dc549..e691e791aa 100644 --- a/packages/studio-server/src/helpers/duplicateElement.ts +++ b/packages/studio-server/src/helpers/duplicateElement.ts @@ -1,6 +1,7 @@ import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { findTargetElement, + dedupeClonedCompositionId, isHTMLElement, parseSourceDocument, type SourceMutationTarget, @@ -40,6 +41,7 @@ export function duplicateElementInHtml( const clone = element.cloneNode(true); if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null }; clone.setAttribute("id", uniqueId); + dedupeClonedCompositionId(document, clone); clone.removeAttribute("data-hf-id"); for (const child of Array.from(clone.querySelectorAll("[data-hf-id]"))) { child.removeAttribute("data-hf-id"); diff --git a/packages/studio-server/src/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts index f0aae027f9..930f7d02ef 100644 --- a/packages/studio-server/src/helpers/sourceMutation.test.ts +++ b/packages/studio-server/src/helpers/sourceMutation.test.ts @@ -106,6 +106,17 @@ describe("patchElementInHtml", () => { expect(result).toContain('id="hero"'); }); + it("keeps a lowercase doctype byte-identical for a no-op patch", () => { + const source = '
'; + + const { html, matched } = patchElementInHtml(source, { id: "hero" }, [ + { type: "attribute", property: "start", value: "0" }, + ]); + + expect(matched).toBe(true); + expect(html).toBe(source); + }); + it("stamps the composition root before returning patched bytes", () => { const source = '
Hello
'; const { html: result, matched } = patchElementInHtml(source, { id: "hero" }, [ diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 2a3391df08..0e8c80688e 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -148,6 +148,21 @@ export function isHTMLElement(el: Node): el is HTMLElement { return HTMLEl ? el instanceof HTMLEl : el.nodeType === 1 && "style" in el; } +export function dedupeClonedCompositionId(document: Document, clone: Element): void { + const compositionId = clone.getAttribute("data-composition-id"); + if (!compositionId) return; + const usedCompositionIds = new Set( + querySelectorAllWithTemplates(document, "[data-composition-id]").map((node) => + node.getAttribute("data-composition-id"), + ), + ); + const base = `${compositionId}-split`; + let nextCompositionId = base; + let suffix = 2; + while (usedCompositionIds.has(nextCompositionId)) nextCompositionId = `${base}-${suffix++}`; + clone.setAttribute("data-composition-id", nextCompositionId); +} + export interface PatchOperation { type: "inline-style" | "attribute" | "html-attribute" | "text-content" | "rich-text"; property: string; @@ -211,6 +226,7 @@ export function patchElementInHtml( const el = findTargetElement(document, target); if (!el || !isHTMLElement(el)) return { html: source, matched: false }; const htmlEl = el; + const originalHtml = wrappedFragment ? document.body.innerHTML || "" : document.toString(); const resolved: ResolvedPatchOperation[] = []; for (const op of operations) { @@ -273,7 +289,7 @@ export function patchElementInHtml( } const html = wrappedFragment ? document.body.innerHTML || "" : document.toString(); - if (html === source) return { html: source, matched: true }; + if (html === originalHtml) return { html: source, matched: true }; return { html: ensureHfIds(html), matched: true }; } @@ -362,19 +378,7 @@ export function splitElementInHtml( const clone = el.cloneNode(true); if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null }; clone.setAttribute("id", newId); - const compositionId = clone.getAttribute("data-composition-id"); - if (compositionId) { - const usedCompositionIds = new Set( - Array.from(document.querySelectorAll("[data-composition-id]"), (node) => - node.getAttribute("data-composition-id"), - ), - ); - const base = `${compositionId}-split`; - let nextCompositionId = base; - let suffix = 2; - while (usedCompositionIds.has(nextCompositionId)) nextCompositionId = `${base}-${suffix++}`; - clone.setAttribute("data-composition-id", nextCompositionId); - } + dedupeClonedCompositionId(document, clone); clone.removeAttribute("data-hf-id"); // Descendants carry their own data-hf-id; leaving them duplicates the id of // every nested node (e.g. an inner ), so strip them on the clone too. diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index e1893a9aab..4424cc191a 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -17,6 +17,7 @@ export { removeElementFromHtml, findTargetElement, parseSourceDocument, + dedupeClonedCompositionId, type PatchOperation, type SourceMutationTarget, } from "./helpers/sourceMutation.js"; From a715614d5c3699d2c4694a7ad3c29cd962a4d603 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:29:00 -0400 Subject: [PATCH 40/40] fix(studio-server): reduce duplicate helper complexity --- .../src/helpers/duplicateElement.ts | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/studio-server/src/helpers/duplicateElement.ts b/packages/studio-server/src/helpers/duplicateElement.ts index e691e791aa..19ce307723 100644 --- a/packages/studio-server/src/helpers/duplicateElement.ts +++ b/packages/studio-server/src/helpers/duplicateElement.ts @@ -27,29 +27,49 @@ export function duplicateElementInHtml( if (duration === null || numericAttribute(element, "data-start") === null) { return { html: source, matched: false, newId: null }; } + const uniqueId = nextUniqueId(document, newId); + rippleElements(document, element, at, duration, track); + const clone = element.cloneNode(true); + if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null }; + clone.setAttribute("id", uniqueId); + dedupeClonedCompositionId(document, clone); + stripStableIds(clone); + clone.setAttribute("data-start", String(at)); + element.parentElement.insertBefore(clone, element.nextSibling); + const html = wrappedFragment ? document.body.innerHTML || "" : document.toString(); + return { html: ensureHfIds(html), matched: true, newId: uniqueId }; +} + +function nextUniqueId(document: Document, newId: string): string { let uniqueId = newId; let suffix = 2; while (document.getElementById(uniqueId)) uniqueId = `${newId}-${suffix++}`; + return uniqueId; +} + +function rippleElements( + document: Document, + element: Element, + at: number, + duration: number, + track: number, +): void { for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) { if (candidate === element || candidate.getAttribute("data-track-index") !== String(track)) { continue; } const start = numericAttribute(candidate, "data-start"); - if (start !== null && start >= at) + if (start !== null && start >= at) { candidate.setAttribute("data-start", String(start + duration)); + } } - const clone = element.cloneNode(true); - if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null }; - clone.setAttribute("id", uniqueId); - dedupeClonedCompositionId(document, clone); - clone.removeAttribute("data-hf-id"); - for (const child of Array.from(clone.querySelectorAll("[data-hf-id]"))) { +} + +function stripStableIds(element: Element): void { + element.removeAttribute("data-hf-id"); + for (const child of Array.from(element.querySelectorAll("[data-hf-id]"))) { child.removeAttribute("data-hf-id"); } - clone.setAttribute("data-start", String(at)); - element.parentElement.insertBefore(clone, element.nextSibling); - const html = wrappedFragment ? document.body.innerHTML || "" : document.toString(); - return { html: ensureHfIds(html), matched: true, newId: uniqueId }; } function numericAttribute(element: Element, name: string): number | null {