mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user