fix(studio): harden composition timeline reliability (#2615)

* fix(studio): preserve composition playback continuity

* feat(studio): drag compositions into the timeline

* fix(studio): collapse expanded composition move aliases

* fix(studio): make timeline cuts atomic

* fix(studio): group inspector gesture history

* test(studio): cover masked text selection

* fix(studio): harden composition timeline reliability

* fix(studio): satisfy CI source gates

* fix(studio): harden composition mutation requests
This commit is contained in:
Miguel Ángel
2026-07-17 14:15:30 -04:00
committed by GitHub
parent 2be8a62c00
commit 2b65b4efce
93 changed files with 3925 additions and 758 deletions
@@ -0,0 +1,175 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../player";
import { buildAtomicCutIntents, runAtomicCutTransaction } from "./razorSplitTransaction";
const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
id: "clip",
domId: "clip",
tag: "div",
start: 0,
duration: 4,
track: 0,
timingSource: "authored",
sourceFile: "index.html",
...over,
});
afterEach(() => vi.unstubAllGlobals());
describe("buildAtomicCutIntents", () => {
it("deduplicates runtime aliases but keeps repeated authored hosts distinct", () => {
const intents = buildAtomicCutIntents(
[
element({ id: "runtime-a", domId: "host-a", hfId: "stable-a" }),
element({ id: "alias-a", domId: "host-a", hfId: "stable-a" }),
element({ id: "runtime-b", domId: "host-b", hfId: "stable-b" }),
],
2,
"index.html",
);
expect(intents).toHaveLength(1);
expect(intents[0].targets).toHaveLength(2);
expect(intents[0].targets.map((target) => target.originalId)).toEqual(["host-a", "host-b"]);
});
it("rebases each nested target into its own source-file coordinates", () => {
const intents = buildAtomicCutIntents(
[element({ start: 8, duration: 4, expandedParentStart: 6, sourceFile: "scene.html" })],
10,
"index.html",
);
expect(intents[0].targets[0]).toMatchObject({ splitTime: 4, elementStart: 2 });
});
});
function installCutServer(options: { status?: number } = {}) {
const requests: Array<{ url: string; body?: unknown }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
requests.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
if (url.includes("/files/")) {
return new Response(JSON.stringify({ content: "before", version: '"v0"' }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (options.status) {
return new Response(JSON.stringify({ error: "stale base" }), {
status: options.status,
headers: { "Content-Type": "application/json" },
});
}
return new Response(
JSON.stringify({
ok: true,
outcome: "committed",
files: [
{
path: "index.html",
before: "before",
after: "after",
version: '"v1"',
writeToken: "cut-1",
splitCount: 1,
skippedSelectors: [],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}),
);
return requests;
}
describe("runAtomicCutTransaction", () => {
it("records canonical snapshots once and performs no client forward write", async () => {
const requests = installCutServer();
const writeProjectFile = vi.fn();
const recordEdit = vi.fn().mockResolvedValue(undefined);
const observe = vi.fn();
const synchronize = vi.fn();
const result = await runAtomicCutTransaction({
projectId: "launch/demo",
intents: buildAtomicCutIntents([element()], 2, "index.html"),
label: "Split timeline clip",
writeProjectFile,
recordEdit,
observeProjectFileVersion: observe,
synchronize,
});
expect(requests.filter((request) => request.url.includes("split-batch"))).toHaveLength(1);
expect(requests.map((request) => request.url)).toEqual([
"/api/projects/launch%2Fdemo/files/index.html",
"/api/projects/launch%2Fdemo/file-mutations/split-batch",
]);
expect(writeProjectFile).not.toHaveBeenCalled();
expect(recordEdit).toHaveBeenCalledWith({
label: "Split timeline clip",
kind: "timeline",
files: { "index.html": { before: "before", after: "after" } },
});
expect(observe).toHaveBeenCalledWith("index.html", '"v1"');
expect(synchronize).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({ splitCount: 1, syncFailed: false });
});
it("CAS-restores durable bytes when history registration fails", async () => {
installCutServer();
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
await expect(
runAtomicCutTransaction({
projectId: "p1",
intents: buildAtomicCutIntents([element()], 2, "index.html"),
label: "Split timeline clip",
writeProjectFile,
recordEdit: vi.fn().mockRejectedValue(new Error("history unavailable")),
synchronize: vi.fn(),
}),
).rejects.toThrow("history unavailable");
expect(writeProjectFile).toHaveBeenCalledTimes(1);
expect(writeProjectFile).toHaveBeenCalledWith("index.html", "before", "after");
});
it("reports an initial version conflict with no history or client write", async () => {
installCutServer({ status: 409 });
const writeProjectFile = vi.fn();
const recordEdit = vi.fn();
await expect(
runAtomicCutTransaction({
projectId: "p1",
intents: buildAtomicCutIntents([element()], 2, "index.html"),
label: "Split timeline clip",
writeProjectFile,
recordEdit,
synchronize: vi.fn(),
}),
).rejects.toThrow("Cut conflict");
expect(writeProjectFile).not.toHaveBeenCalled();
expect(recordEdit).not.toHaveBeenCalled();
});
it("keeps a durable recorded cut when synchronization fails", async () => {
installCutServer();
const result = await runAtomicCutTransaction({
projectId: "p1",
intents: buildAtomicCutIntents([element()], 2, "index.html"),
label: "Split timeline clip",
writeProjectFile: vi.fn(),
recordEdit: vi.fn().mockResolvedValue(undefined),
synchronize: () => {
throw new Error("preview unavailable");
},
});
expect(result.syncFailed).toBe(true);
});
});
@@ -0,0 +1,200 @@
import type { TimelineElement } from "../player";
import type { RecordEditInput } from "../hooks/timelineEditingHelpers";
import { buildPatchTarget } from "./timelineElementSplit";
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
import { buildProjectApiPath } from "./projectRouting";
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
interface CutTarget {
target: NonNullable<ReturnType<typeof buildPatchTarget>>;
originalId?: string;
splitTime: number;
elementStart: number;
elementDuration: number;
playbackStart?: number;
playbackRate?: number;
isComposition?: boolean;
}
interface CutFileIntent {
path: string;
targets: CutTarget[];
}
interface CutFileResult {
path: string;
before: string;
after: string;
version: string;
writeToken: string;
splitCount: number;
skippedSelectors: string[];
}
interface CutBatchResponse {
ok: true;
outcome: "committed";
files: CutFileResult[];
}
export interface AtomicCutResult {
splitCount: number;
skippedSelectors: string[];
syncFailed: boolean;
}
function targetIdentity(
path: string,
target: NonNullable<ReturnType<typeof buildPatchTarget>>,
): string {
if (target.hfId) return `${path}|hf:${target.hfId}`;
if (target.id) return `${path}|id:${target.id}`;
return `${path}|selector:${target.selector ?? ""}:${target.selectorIndex ?? 0}`;
}
function buildCutTarget(
element: TimelineElement,
target: CutTarget["target"],
splitTime: number,
): CutTarget {
const basis = element.expandedParentStart;
return {
target,
...(element.domId ? { originalId: element.domId } : {}),
splitTime: basis === undefined ? splitTime : Math.max(0, splitTime - basis),
elementStart: basis === undefined ? element.start : element.start - basis,
elementDuration: element.duration,
...(element.playbackStart != null ? { playbackStart: element.playbackStart } : {}),
...(element.playbackRate != null ? { playbackRate: element.playbackRate } : {}),
...(element.kind === "composition" ? { isComposition: true } : {}),
};
}
/** Group one immutable cut time by file and collapse runtime aliases once. */
export function buildAtomicCutIntents(
elements: readonly TimelineElement[],
splitTime: number,
activeCompPath: string | null,
): CutFileIntent[] {
const byPath = new Map<string, CutFileIntent>();
const seen = new Set<string>();
for (const element of elements) {
const target = buildPatchTarget(element);
if (!target) throw new Error("Clip is missing a patchable target.");
const path = element.sourceFile || activeCompPath || "index.html";
const identity = targetIdentity(path, target);
if (seen.has(identity)) continue;
seen.add(identity);
const intent = byPath.get(path) ?? { path, targets: [] };
intent.targets.push(buildCutTarget(element, target, splitTime));
byPath.set(path, intent);
}
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
}
async function readFileVersion(projectId: string, path: string): Promise<string> {
const response = await fetch(
buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
);
if (!response.ok) throw new Error(`Failed to read ${path} before cut (${response.status})`);
const body = (await response.json()) as { version?: string };
const version = body.version ?? response.headers.get("etag") ?? undefined;
if (!version) throw new Error(`Missing content version for ${path}`);
return version;
}
async function requestAtomicCut(
projectId: string,
intents: CutFileIntent[],
): Promise<CutBatchResponse> {
const files = [];
for (const intent of intents) {
files.push({
...intent,
expectedVersion: await readFileVersion(projectId, intent.path),
});
}
const transactionToken = `cut:${crypto.randomUUID()}`;
const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hyperframes-Write-Token": transactionToken,
},
body: JSON.stringify({ files, transactionToken }),
});
const body = (await response.json().catch(() => null)) as
| (Partial<CutBatchResponse> & { error?: string; outcome?: string })
| null;
if (!response.ok || body?.ok !== true || !Array.isArray(body.files)) {
const prefix = response.status === 409 ? "Cut conflict" : "Cut failed";
throw new Error(`${prefix}: ${body?.error ?? `server returned ${response.status}`}`);
}
return body as CutBatchResponse;
}
async function rollbackUnrecordedCut(
files: readonly CutFileResult[],
writeProjectFile: ProjectFileWriter,
): Promise<void> {
const failures: unknown[] = [];
for (const file of [...files].reverse()) {
try {
await writeProjectFile(file.path, file.before, file.after);
} catch (error) {
failures.push(error);
}
}
if (failures.length > 0) {
throw new AggregateError(
failures,
"Cut history failed and externally changed files could not be safely restored",
);
}
}
interface RunAtomicCutInput {
projectId: string;
intents: CutFileIntent[];
label: string;
writeProjectFile: ProjectFileWriter;
recordEdit: (input: RecordEditInput) => Promise<void>;
observeProjectFileVersion?: (path: string, version: string | null) => void;
synchronize: () => void;
}
/** One coordinator owns request, history registration, safe rollback, and resync. */
export function runAtomicCutTransaction(input: RunAtomicCutInput): Promise<AtomicCutResult> {
const paths = input.intents.map((intent) => intent.path);
return serializeStudioFileMutations(input.writeProjectFile, paths, async () => {
const result = await requestAtomicCut(input.projectId, input.intents);
const snapshots = Object.fromEntries(
result.files.map((file) => [file.path, { before: file.before, after: file.after }]),
);
try {
await input.recordEdit({ label: input.label, kind: "timeline", files: snapshots });
} catch (error) {
try {
await rollbackUnrecordedCut(result.files, input.writeProjectFile);
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], "Cut aborted with rollback conflicts");
}
throw error;
}
for (const file of result.files) input.observeProjectFileVersion?.(file.path, file.version);
let syncFailed = false;
try {
input.synchronize();
} catch {
syncFailed = true;
}
return {
splitCount: result.files.reduce((count, file) => count + file.splitCount, 0),
skippedSelectors: [...new Set(result.files.flatMap((file) => file.skippedSelectors))],
syncFailed,
};
});
}
@@ -114,6 +114,33 @@ describe("pauseStudioPreviewPlayback", () => {
});
describe("getPreviewTargetFromPointer", () => {
it("chooses the deepest headline through a transparent overflow mask", () => {
const { iframe, doc } = createPreviewIframe();
doc.body.innerHTML = `
<template id="source-template"></template>
<main data-composition-id="scene">
<section class="hl-block">
<div class="hl-mask" style="overflow: hidden; background: transparent">
<h1 class="hl-text">Launch title</h1>
</div>
</section>
</main>
`;
const scene = doc.querySelector<HTMLElement>("main")!;
const block = doc.querySelector<HTMLElement>(".hl-block")!;
const mask = doc.querySelector<HTMLElement>(".hl-mask")!;
const headline = doc.querySelector<HTMLElement>(".hl-text")!;
stubRect(iframe, domRect(0, 0, 400, 300));
stubRect(scene, domRect(0, 0, 400, 300));
stubRect(block, domRect(30, 30, 300, 100));
stubRect(mask, domRect(40, 40, 260, 64));
stubRect(headline, domRect(44, 44, 220, 48));
doc.elementsFromPoint = () => [headline, mask, block, scene];
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
iframe.remove();
});
it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
const { iframe, doc } = createPreviewIframe();
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { parseTimelineCompositionPayload } from "./timelineCompositionDrop";
describe("timeline composition drop", () => {
it("parses valid composition payloads and rejects malformed ones", () => {
expect(parseTimelineCompositionPayload('{"sourcePath":"scene.html"}')).toEqual({
sourcePath: "scene.html",
});
expect(parseTimelineCompositionPayload('{"path":"scene.html"}')).toBeNull();
expect(parseTimelineCompositionPayload("nope")).toBeNull();
});
});
@@ -0,0 +1,16 @@
export const TIMELINE_COMPOSITION_MIME = "application/x-hyperframes-composition";
export interface TimelineCompositionPayload {
sourcePath: string;
}
export function parseTimelineCompositionPayload(raw: string): TimelineCompositionPayload | null {
try {
const value: unknown = JSON.parse(raw);
if (typeof value !== "object" || value === null || !("sourcePath" in value)) return null;
const sourcePath = value.sourcePath;
return typeof sourcePath === "string" && sourcePath.trim() ? { sourcePath } : null;
} catch {
return null;
}
}
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { commitTimelineCompositionInsertion } from "./timelineCompositionInsert";
afterEach(() => vi.unstubAllGlobals());
function response(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("commitTimelineCompositionInsertion", () => {
it("records one history entry, then selects and refreshes once", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(response({ content: "before", version: "v1" }))
.mockResolvedValueOnce(
response({
path: "index.html",
hostId: "headline",
before: "before",
after: "after",
version: "v2",
}),
);
vi.stubGlobal("fetch", fetchMock);
const writeFile = vi.fn();
const recordEdit = vi.fn();
const observeVersion = vi.fn();
const selectHost = vi.fn();
const resync = vi.fn();
const refresh = vi.fn();
await commitTimelineCompositionInsertion({
projectId: "launch/demo",
targetPath: "index.html",
sourcePath: "headline.html",
start: 4,
track: 2,
writeFile,
recordEdit,
observeVersion,
selectHost,
resync,
refresh,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"/api/projects/launch%2Fdemo/files/index.html",
"/api/projects/launch%2Fdemo/file-mutations/insert-composition/index.html",
]);
expect(recordEdit).toHaveBeenCalledOnce();
expect(writeFile).not.toHaveBeenCalled();
expect(observeVersion).toHaveBeenCalledWith("index.html", "v2");
expect(selectHost).toHaveBeenCalledWith("index.html#headline");
expect(resync).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
});
it("CAS-restores the server write when history registration fails", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response({ content: "before", version: "v1" }))
.mockResolvedValueOnce(
response({
path: "index.html",
hostId: "headline",
before: "before",
after: "after",
version: "v2",
}),
),
);
const writeFile = vi.fn();
const refresh = vi.fn();
await expect(
commitTimelineCompositionInsertion({
projectId: "demo",
targetPath: "index.html",
sourcePath: "headline.html",
start: 4,
track: 2,
writeFile,
recordEdit: vi.fn().mockRejectedValue(new Error("history failed")),
selectHost: vi.fn(),
refresh,
}),
).rejects.toThrow("history failed");
expect(writeFile).toHaveBeenCalledWith("index.html", "before", "after");
expect(refresh).not.toHaveBeenCalled();
});
it("keeps a durable insertion successful and refreshes when resync fails", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response({ content: "before", version: "v1" }))
.mockResolvedValueOnce(
response({
path: "index.html",
hostId: "headline",
before: "before",
after: "after",
version: "v2",
}),
),
);
const refresh = vi.fn();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
commitTimelineCompositionInsertion({
projectId: "demo",
targetPath: "index.html",
sourcePath: "headline.html",
start: 4,
track: 2,
writeFile: vi.fn(),
recordEdit: vi.fn(),
selectHost: vi.fn(),
resync: () => {
throw new Error("resync failed");
},
refresh,
}),
).resolves.toBeUndefined();
expect(refresh).toHaveBeenCalledOnce();
expect(consoleError).toHaveBeenCalledWith(
"[Studio] Composition insertion committed but preview resync failed",
expect.any(Error),
);
consoleError.mockRestore();
});
});
@@ -0,0 +1,90 @@
import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
import type { RecordEditInput } from "./studioFileHistory";
import { buildProjectApiPath } from "./projectRouting";
interface TimelineCompositionInsertionResult {
path: string;
hostId: string;
before: string;
after: string;
version: string;
}
async function insertTimelineComposition(input: {
projectId: string;
targetPath: string;
sourcePath: string;
start: number;
track: number;
}): Promise<TimelineCompositionInsertionResult> {
const current = await fetch(
buildProjectApiPath(input.projectId, `/files/${encodeURIComponent(input.targetPath)}`),
);
if (!current.ok) {
throw await createStudioSaveHttpError(current, `Failed to read ${input.targetPath}`);
}
const snapshot = (await current.json()) as { version?: string };
if (typeof snapshot.version !== "string") throw new Error("Missing composition file version");
const response = await fetch(
buildProjectApiPath(
input.projectId,
`/file-mutations/insert-composition/${encodeURIComponent(input.targetPath)}`,
),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourcePath: input.sourcePath,
start: input.start,
track: input.track,
expectedVersion: snapshot.version,
}),
},
);
if (!response.ok) {
throw await createStudioSaveHttpError(response, "Failed to add composition to timeline");
}
return (await response.json()) as TimelineCompositionInsertionResult;
}
export async function commitTimelineCompositionInsertion(input: {
projectId: string;
targetPath: string;
sourcePath: string;
start: number;
track: number;
writeFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
recordEdit: (entry: RecordEditInput) => Promise<void>;
observeVersion?: (path: string, version: string | null) => void;
selectHost: (key: string) => void;
resync?: () => void;
refresh: () => void;
}): Promise<void> {
await serializeStudioFileMutation(input.writeFile, input.targetPath, async () => {
const result = await insertTimelineComposition(input);
input.observeVersion?.(input.targetPath, result.version);
try {
await input.recordEdit({
label: "Add composition to timeline",
kind: "timeline",
files: { [input.targetPath]: { before: result.before, after: result.after } },
});
} catch (error) {
await input.writeFile(input.targetPath, result.before, result.after);
throw error;
}
input.selectHost(`${input.targetPath}#${result.hostId}`);
try {
input.resync?.();
} catch (error) {
console.error("[Studio] Composition insertion committed but preview resync failed", error);
}
try {
input.refresh();
} catch (error) {
console.error("[Studio] Composition insertion committed but refresh failed", error);
}
});
}
@@ -14,6 +14,7 @@ function element(overrides: Partial<TimelineElement> = {}): TimelineElement {
start: 1,
duration: 4,
track: 0,
domId: "el-1",
...overrides,
};
}
@@ -77,10 +78,21 @@ describe("canSplitElementAt", () => {
).toBe(false);
});
it("rejects locked, implicit and sub-composition elements", () => {
it("rejects locked and implicit elements while allowing identified compositions", () => {
expect(canSplitElementAt(element({ timelineLocked: true }), 3)).toBe(false);
expect(canSplitElementAt(element({ timingSource: "implicit" }), 3)).toBe(false);
expect(canSplitElementAt(element({ compositionSrc: "child.html" }), 3)).toBe(false);
expect(
canSplitElementAt(
element({ kind: "composition", compositionSrc: "child.html", playbackRate: 2 }),
3,
),
).toBe(true);
});
it("rejects missing identity and invalid playback rates", () => {
expect(canSplitElementAt(element({ domId: undefined }), 3)).toBe(false);
expect(canSplitElementAt(element({ playbackRate: 0 }), 3)).toBe(false);
expect(canSplitElementAt(element({ playbackRate: Number.NaN }), 3)).toBe(false);
});
});
@@ -22,10 +22,14 @@ export function isSplitTimeWithinBounds(
}
export function canSplitElement(el: TimelineElement): boolean {
const hasStableIdentity = Boolean(el.hfId || el.domId || el.selector);
const hasValidRate =
el.playbackRate == null || (Number.isFinite(el.playbackRate) && el.playbackRate > 0);
return (
!el.timelineLocked &&
el.timingSource !== "implicit" &&
!el.compositionSrc &&
hasStableIdentity &&
hasValidRate &&
!!el.duration &&
Number.isFinite(el.duration)
);