feat(cli): add agent timeline workflows (#4241)

* feat(cli): add timeline agent mutation commands

* fix(cli): type timeline a2 mutations

* style(cli): format timeline a2 commands

* test(cli): cover timeline a2 verbs

* test(cli): assert ids keep named refs

* refactor: centralize timeline duplicate mutation

* fix(cli): narrow set assignment field

* style(cli): format timeline mutation exports

* refactor(cli): split apply plan decisions

* fix(cli): tag apply plan refusals

* refactor(cli): move A2 runners beside timeline helpers

* fix(cli): remove stale A2 imports

* style(cli): format A2 command module

* fix(cli): avoid A2 command import cycle

* refactor(cli): isolate shared timeline mutation logic

* fix(cli): restore timeline mutation command factory

* fix(cli): remove stale command-surface remnants

* fix(cli): complete shared timeline support extraction

* fix(cli): close shared conflict helper

* fix(cli): import shared timeline formatter

* style(cli): format shared mutation runner

* refactor(cli): split mutation command orchestration

* fix(cli): keep shared mutation primitives in support module

* fix(cli): use shared mutation support helpers

* refactor(cli): centralize A2 refusals

* style(cli): format A2 refusal helpers

* fix(cli): clean shared A2 imports

* fix(cli): make A2 runner imports explicit

* refactor(cli): isolate apply plan target validation

* fix(cli): tag prepared plan edits

* style(cli): format plan target helper

* fix(cli): validate sequential plans and duplicate ids

* test(cli): account for stamped composition id

* fix(studio): stamp ids on patched html

* fix(studio): preserve no-op patch bytes

* test(studio): expect stamped patch markup

* fix(studio-server): duplicate elements in nested templates

* refactor(cli): share timeline mutation receipts

* fix(studio-server): harden duplicate and no-op mutations

* fix(studio-server): reduce duplicate helper complexity
This commit is contained in:
Miguel Ángel
2026-09-21 17:14:47 -04:00
committed by GitHub
parent 97420736d5
commit 4e9059ad59
12 changed files with 1355 additions and 600 deletions
+40 -579
View File
@@ -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<ReturnType<typeof resolveRef>, { ok: true }>;
parseTime: (expression: string) => ReturnType<typeof parseTimeExpression>;
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>,
): 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<string, unknown>) {
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<typeof trimEnd>,
duration: ReturnType<typeof trimDuration>,
): 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<string, unknown>,
): 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<string, unknown>): Promise<void> {
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<typeof resolveProject>;
ref: string;
json: boolean;
plan: boolean;
overwrite: boolean;
timeline: ProjectTimeline;
indexSource: string;
row: TimelineRow;
resolved: Extract<ReturnType<typeof resolveRef>, { 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<string, unknown>): Promise<MutationSetupResult> {
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<MutationDecision, { ok: true }>,
): Promise<void> {
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<ProjectTimeline> =>
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<typeof mutationResult>;
describeSource: (source: string) => Promise<ProjectTimeline>;
}): Promise<void> {
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<typeof mutationResult>,
json: boolean,
timeline: ProjectTimeline,
describeSource: (source: string) => Promise<ProjectTimeline>,
after: string,
before: string,
): Promise<void> {
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;
+291
View File
@@ -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<string, unknown>): Promise<void> {
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<MutationVerb>(["move", "trim", "split", "delete", "set", "duplicate"]);
type PreparedPlanEdit = {
ok: true;
edit: Record<string, unknown>;
row: TimelineRow;
before: string;
resolved: Extract<ReturnType<typeof resolveRef>, { ok: true }>;
};
function preparePlanEdit(
edit: unknown,
timeline: ProjectTimeline,
sourceByFile: Map<string, string>,
): 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<typeof resolveProject>,
sourceByFile: Map<string, string>,
): 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<typeof resolveProject>,
): { sourceByFile: Map<string, string>; beforeByFile: Map<string, string> } {
const sourceByFile = new Map<string, string>();
const beforeByFile = new Map<string, string>();
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<typeof resolveProject>,
sourceByFile: Map<string, 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 };
}
export async function runApply(args: Record<string, unknown>): Promise<void> {
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<string, unknown>): Promise<void> {
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}`);
}
@@ -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<string, unknown>,
): Promise<void> {
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<typeof resolveProject>;
ref: string;
json: boolean;
plan: boolean;
overwrite: boolean;
timeline: ProjectTimeline;
indexSource: string;
row: TimelineRow;
resolved: Extract<ReturnType<typeof resolveRef>, { 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<string, unknown>): Promise<MutationSetupResult> {
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<MutationDecision, { ok: true }>,
): Promise<void> {
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<ProjectTimeline> =>
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<typeof mutationResult>;
describeSource: (source: string) => Promise<ProjectTimeline>;
}): Promise<void> {
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<typeof mutationResult>,
json: boolean,
timeline: ProjectTimeline,
describeSource: (source: string) => Promise<ProjectTimeline>,
after: string,
before: string,
): Promise<void> {
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" };
}
+54
View File
@@ -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=<number>, rate=<number>, or track=<number>",
};
}
const fieldValue = match[1];
if (!fieldValue) {
return {
ok: false,
reason: `unsupported set assignment ${value}`,
fix: "use volume=<number>, rate=<number>, or track=<number>",
};
}
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}=<number>`,
};
}
if (field === "rate" && number <= 0) {
return { ok: false, reason: "rate must be positive", fix: "pass rate=<positive number>" };
}
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}`);
}
+429
View File
@@ -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<ReturnType<typeof resolveRef>, { ok: true }>;
parseTime: (expression: string) => ReturnType<typeof parseTimeExpression>;
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>,
): 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<string, unknown>) {
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<typeof trimEnd>,
duration: ReturnType<typeof trimDuration>,
): 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<string, unknown>): 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<string, unknown>,
): 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<string, unknown>,
): 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, unknown>): string[] {
return Array.isArray(args._)
? args._.filter((value): value is string => typeof value === "string")
: [];
}
export function isRecord(value: unknown): value is Record<string, unknown> {
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",
};
}
@@ -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<Record<string, unknown>> };
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 });
}
});
});
@@ -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 = `<!doctype html><html><body>
<template data-composition-id="outer"><template data-composition-id="inner">
<div data-hf-id="hf-deep" data-start="0" data-duration="1" data-track-index="0">clip</div>
</template></template>
</body></html>`;
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 = `<div data-composition-id="main"><div id="scene" data-composition-id="scene" data-start="0" data-duration="1">clip</div></div>`;
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);
});
});
@@ -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;
}
@@ -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 = '<!doctype html><html><body><div id="hero" data-start="0"></div></body></html>';
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 = '<div data-composition-id="main"><div id="hero">Hello</div></div>';
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(/<div data-hf-id="hf-[a-z0-9]+" data-composition-id="main"/);
});
it("patches a 4-side clip-path inset inline style", () => {
const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "clip-path", value: "inset(10px 20px 30px 40px)" },
@@ -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 = /<!doctype|<html[\s>]/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 <span>), so strip them on the clone too.
+4
View File
@@ -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,
@@ -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('<div class="sub" style="z-index: 1">A</div>');
expect(payload.content).toMatch(
/<div data-hf-id="hf-[a-z0-9]+" class="sub" style="z-index: 1">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('<div class="root" id="main" style="z-index: 5">first</div>');
expect(payload.content).toMatch(
/<div data-hf-id="hf-[a-z0-9]+" class="root" id="main" style="z-index: 5">first<\/div>/,
);
expect(payload.content).toContain("z-index: 0");
});