mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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:
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user