diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 6ec6448e01..3b8b126a41 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -1,29 +1,13 @@ -import { - applyFileMutations, - fileContentVersion, - patchElementInHtml, - removeElementFromHtml, - splitElementInHtml, -} from "@hyperframes/studio-server"; -import type { AppliedFileMutation } 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 } 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 { runApply, runIds, runUndo } from "../timeline/a2Commands.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"], @@ -31,571 +15,13 @@ export const examples: Example[] = [ ["Delete a clip and return a receipt", "hyperframes timeline delete '#hero' --json"], ]; -type MutationVerb = "move" | "trim" | "split" | "delete"; - -type MutationDecision = - | { ok: true; after: string; nextStart: number; nextDuration: number } - | { ok: false; reason: string; fix: string }; - -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 }; - -const allRows = (timeline: ProjectTimeline): TimelineRow[] => - timeline.tracks.flatMap((track) => track.rows); - -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 { - 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 }; -} - -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 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); - } -} - -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); -} - -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; - } -} - -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" }; -} - -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" }, @@ -622,6 +48,41 @@ 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/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts new file mode 100644 index 0000000000..324dbe45e6 --- /dev/null +++ b/packages/cli/src/timeline/a2Commands.ts @@ -0,0 +1,291 @@ +import { applyFileMutations, fileContentVersion } 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"; +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, + decideMutation, + diff, + fpsFor, + isRecord, + mutationConflict, + positional, + publicReceipt, + refuse, + rowAt, + type MutationContext, + type MutationVerb, +} from "./a2Shared.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 = ensureHfIds(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 }; + +const PLAN_VERBS = new Set(["move", "trim", "split", "delete", "set", "duplicate"]); + +type PreparedPlanEdit = { + ok: true; + edit: Record; + row: TimelineRow; + before: string; + resolved: Extract, { ok: true }>; +}; + +function preparePlanEdit( + edit: unknown, + timeline: ProjectTimeline, + 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", + }; + } + if (!PLAN_VERBS.has(edit.verb as MutationVerb)) { + return { + ok: false, + 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 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: planEdit.ref as string, + 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, context, { ...planEdit, _: [planEdit.ref] }); + if (!decision.ok) return { ok: false, reason: decision.reason, fix: decision.fix }; + const conflict = mutationConflict( + verb as MutationVerb, + planEdit.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 }; +} + +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"); + try { + 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 { ok: false, reason: "edit plan is not valid JSON", fix: "pass a JSON array of edits" }; + } +} + +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))) { + const source = readFileSync(join(project.dir, fileName), "utf-8"); + sourceByFile.set(fileName, source); + beforeByFile.set(fileName, source); + } + return { sourceByFile, beforeByFile }; +} + +async function applyPlanEdits( + edits: unknown[], + timeline: ProjectTimeline, + project: ReturnType, + sourceByFile: Map, +): 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 }; +} + +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 = 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)!; + 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 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"); + } catch { + raw = input; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + 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 ( + !isRecord(value) || + typeof value.file !== "string" || + 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, + ); + } + 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}`); +} diff --git a/packages/cli/src/timeline/a2MutationCommand.ts b/packages/cli/src/timeline/a2MutationCommand.ts new file mode 100644 index 0000000000..25b945c219 --- /dev/null +++ b/packages/cli/src/timeline/a2MutationCommand.ts @@ -0,0 +1,267 @@ +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 { 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, + decideMutation, + declaredFps, + diff, + fpsFor, + mutationConflict, + publicReceipt, + refusal, + rowAt, + type MutationContext, + type MutationDecision, + 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 ? publicReceipt(receipt) : 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); +} + +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; + } +} + +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" }; +} diff --git a/packages/cli/src/timeline/a2Mutations.ts b/packages/cli/src/timeline/a2Mutations.ts new file mode 100644 index 0000000000..104320be77 --- /dev/null +++ b/packages/cli/src/timeline/a2Mutations.ts @@ -0,0 +1,54 @@ +export type SetField = "volume" | "rate" | "track"; + +export interface SetAssignment { + field: SetField; + value: 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); + if (!match) { + return { + ok: false, + reason: `unsupported set assignment ${value}`, + fix: "use volume=, rate=, or track=", + }; + } + 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 { + 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 }; +} + +function parseSetField(value: string): SetField { + switch (value) { + case "volume": + case "rate": + case "track": + return value; + } + throw new Error(`unsupported set field ${value}`); +} diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts new file mode 100644 index 0000000000..a8779726bc --- /dev/null +++ b/packages/cli/src/timeline/a2Shared.ts @@ -0,0 +1,429 @@ +import { + duplicateElementInHtml, + 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 type { ProjectTimeline, TimelineRow } from "./describeProject.js"; +import { resolveRef } from "./resolveRef.js"; +import { parseTimeExpression } from "./timeExpr.js"; +import { setCommandExitCode } from "../utils/commandResult.js"; +import { parseSetAssignments, type SetAssignment } from "./a2Mutations.js"; + +export type MutationVerb = "move" | "trim" | "split" | "delete" | "set" | "duplicate"; + +export 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 refuse(kind: string, detail: { reason: string; fix: string }, json: boolean): void { + 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, + ); + return fpsToNumber(parsed.ok ? parsed.value : { num: 30, den: 1 }); +} + +export 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); + } +} + +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 === "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; + 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/timeline.e2e.test.ts b/packages/cli/src/timeline/timeline.e2e.test.ts index e64ca7e071..80463ca1ae 100644 --- a/packages/cli/src/timeline/timeline.e2e.test.ts +++ b/packages/cli/src/timeline/timeline.e2e.test.ts @@ -213,4 +213,111 @@ 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 === "#clip")).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.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 }); + } + }); + + 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 { + 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 { + 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 }); + } + }); }); 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..9e445d1ace --- /dev/null +++ b/packages/studio-server/src/helpers/duplicateElement.test.ts @@ -0,0 +1,29 @@ +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"'); + }); + + 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 new file mode 100644 index 0000000000..19ce307723 --- /dev/null +++ b/packages/studio-server/src/helpers/duplicateElement.ts @@ -0,0 +1,78 @@ +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; +import { + findTargetElement, + dedupeClonedCompositionId, + isHTMLElement, + parseSourceDocument, + 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, 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; + 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) { + candidate.setAttribute("data-start", String(start + duration)); + } + } +} + +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"); + } +} + +function numericAttribute(element: Element, name: string): number | null { + const value = Number(element.getAttribute(name)); + return Number.isFinite(value) ? value : null; +} diff --git a/packages/studio-server/src/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts index 2115fbcc6b..930f7d02ef 100644 --- a/packages/studio-server/src/helpers/sourceMutation.test.ts +++ b/packages/studio-server/src/helpers/sourceMutation.test.ts @@ -106,6 +106,28 @@ 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" }, [ + { 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..0e8c80688e 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; @@ -142,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; @@ -205,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) { @@ -266,10 +288,9 @@ export function patchElementInHtml( } } - return { - html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), - matched: true, - }; + const html = wrappedFragment ? document.body.innerHTML || "" : document.toString(); + if (html === originalHtml) return { html: source, matched: true }; + return { html: ensureHfIds(html), matched: true }; } export function probeElementInSource(source: string, target: SourceMutationTarget): boolean { @@ -357,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 665e4f96a3..4424cc191a 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -15,9 +15,13 @@ export { patchElementInHtml, splitElementInHtml, removeElementFromHtml, + findTargetElement, + parseSourceDocument, + dedupeClonedCompositionId, type PatchOperation, type SourceMutationTarget, } from "./helpers/sourceMutation.js"; +export { duplicateElementInHtml, type DuplicateElementResult } from "./helpers/duplicateElement.js"; export { applyFileMutations, type AppliedFileMutation, diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 306efb3f44..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).toContain('
A
'); + expect(payload.content).toMatch( + /
A<\/div>/, + ); expect(payload.content).toContain("z-index: 0"); }); @@ -1798,7 +1800,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( + /
first<\/div>/, + ); expect(payload.content).toContain("z-index: 0"); });