Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@/lib/ai-edition/document/timeline";
import { type AxcutClip, documentSchema } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { saveTimelineMutation } from "@/lib/ai-edition/store/timelineSave";
import {
useAssetTranscriptions,
useAutoTranscription,
Expand Down Expand Up @@ -358,6 +359,7 @@ export function NewEditorShell() {
// stale-closure bugs.
const known = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 60;
const state = useProjectStore.getState();
const persist = state.saveDocument;
setSourceDuration(known);
const doc = state.document;
if (!doc || doc.assets.length === 0) return;
Expand All @@ -380,7 +382,7 @@ export function NewEditorShell() {
[{ startSec: 0, endSec: known }],
"Auto-created full-duration clip",
);
void state.saveDocument(next);
void saveTimelineMutation(persist, next);
return;
}
// Hand the probed duration to the pure document layer: it patches only the
Expand All @@ -391,7 +393,7 @@ export function NewEditorShell() {
// nothing is waiting, so there is nothing to guard here.
const next = applyProbedDuration(doc, assetId, known);
if (next !== doc) {
void state.saveDocument(next);
void saveTimelineMutation(persist, next);
}
},
[setSourceDuration],
Expand Down
1 change: 1 addition & 0 deletions src/components/ai-edition/v4/V4Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,7 @@ export function V4Timeline({
return;
}
const added = await tl.addZoomsBulk(suggestions);
if (added === 0) return;
toast.success(
t(added === 1 ? "toolbar.addedAutoZoom" : "toolbar.addedAutoZoomPlural", { count: added }),
);
Expand Down
24 changes: 24 additions & 0 deletions src/lib/ai-edition/store/timelineSave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { toast } from "sonner";
import type { AxcutDocument } from "../schema";

type SaveDocument = (document: AxcutDocument) => Promise<unknown>;

/**
* Persist a user-initiated timeline mutation without letting a detached caller
* turn a write failure into an unhandled rejection.
*/
export async function saveTimelineMutation(
saveDocument: SaveDocument,
document: AxcutDocument,
): Promise<boolean> {
try {
await saveDocument(document);
return true;
} catch (error) {
console.error("[timeline] failed to save mutation:", error);
toast.error("Save failed", {
description: error instanceof Error ? error.message : String(error),
});
return false;
}
}
28 changes: 16 additions & 12 deletions src/lib/ai-edition/store/useSequentialTimelineOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema
import { useProjectStore } from "./projectStore";
import { useSequentialTimelineOps } from "./useSequentialTimelineOps";

const toastErrorMock = vi.hoisted(() => vi.fn());

vi.mock("sonner", () => ({ toast: { error: toastErrorMock } }));

function makeDocWithAsset(): AxcutDocument {
const base = createEmptyDocument({ projectId: "proj_seq", title: "seq" });
return {
Expand Down Expand Up @@ -55,6 +59,7 @@ function makeDocWithAsset(): AxcutDocument {

beforeEach(() => {
useProjectStore.getState().clear();
toastErrorMock.mockReset();
});

afterEach(() => {
Expand Down Expand Up @@ -112,7 +117,7 @@ describe("useSequentialTimelineOps", () => {
expect(doc2.timeline.trimRanges.map((t) => t.startSec).sort()).toEqual([1, 5]);
});

it("swallows save errors so the next call can still proceed", async () => {
it("surfaces save errors without rejecting or poisoning the queue", async () => {
const seed = makeDocWithAsset();
useProjectStore.setState({ document: seed });

Expand Down Expand Up @@ -140,24 +145,23 @@ describe("useSequentialTimelineOps", () => {
reason: "second",
};

let firstSettled = false;
let secondSettled = false;
let firstResult: AxcutDocument | null | undefined;
let secondResult: AxcutDocument | null | undefined;
await act(async () => {
const p1 = result.current.apply(op1);
const p2 = result.current.apply(op2);
await p1.catch((err: unknown) => {
firstSettled = true;
expect((err as Error).message).toBe("save failed");
});
await p2.then(() => {
secondSettled = true;
});
firstResult = await p1;
secondResult = await p2;
});

expect(firstSettled).toBe(true);
expect(secondSettled).toBe(true);
expect(firstResult).toBeNull();
expect(secondResult).not.toBeNull();
expect(toastErrorMock).toHaveBeenCalledWith("Save failed", {
description: "save failed",
});
Comment thread
arhxam marked this conversation as resolved.
// The queue survived the first failure — both saves were attempted.
expect(saveDocument).toHaveBeenCalledTimes(2);
expect(saveDocument).toHaveBeenNthCalledWith(2, secondResult);
});

it("returns null when the store has no document and no fallback is supplied", async () => {
Expand Down
22 changes: 10 additions & 12 deletions src/lib/ai-edition/store/useSequentialTimelineOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
// read the doc INSIDE the chain, after awaiting the previous save, so
// every call sees the doc state the previous call committed.
//
// Errors are swallowed when advancing the queue ref so a failed save
// doesn't poison the queue (the next call still has a resolved promise
// to chain off). The original promise returned to the caller is NOT
// swallowed — the caller can await it and observe the rejection.
// Save failures are surfaced once by the shared mutation boundary and resolve
// to null. This keeps detached UI calls from emitting unhandled rejections and
// also leaves the queue healthy for the next edit.

import { useCallback, useRef } from "react";
import type { AxcutTimelineOperation } from "@/lib/ai-edition/document/operations";
import type { AxcutDocument } from "@/lib/ai-edition/schema";
import { useProjectStore } from "./projectStore";
import { saveTimelineMutation } from "./timelineSave";

export interface SequentialTimelineOps {
/**
Expand All @@ -25,7 +25,7 @@ export interface SequentialTimelineOps {
* saved. Calls are serialised — op N+1 reads the doc op N wrote.
*
* Returns the saved document, or `null` if no project document is
* loaded (store empty AND no fallback supplied).
* loaded (store empty AND no fallback supplied) or the save fails.
*/
apply: (op: AxcutTimelineOperation) => Promise<AxcutDocument | null>;
}
Expand All @@ -43,21 +43,19 @@ export function useSequentialTimelineOps(options: {
(op: AxcutTimelineOperation): Promise<AxcutDocument | null> => {
const queued = saveQueueRef.current
.then(() => import("@/lib/ai-edition/document/operations"))
.then(({ applyTimelineOperation }) => {
.then(async ({ applyTimelineOperation }) => {
// Read the doc inside the chain. The store holds the
// latest committed state because the previous call's
// save has already resolved by the time this .then
// runs — see the file header for the race this fixes.
const doc = useProjectStore.getState().document ?? fallbackDocument;
if (!doc) return null;
const applied = applyTimelineOperation(doc, op);
return saveDocument(applied.document).then(() => applied.document);
const saved = await saveTimelineMutation(saveDocument, applied.document);
return saved ? applied.document : null;
});
// Swallow rejection when advancing the queue so a failed save
// doesn't poison the queue — the next call still has a
// resolved promise to chain off. The original `queued` is
// returned to the caller, who can await it and observe the
// rejection.
// Keep operation/import errors from poisoning the queue. Save
// failures already resolve to null after showing user feedback.
saveQueueRef.current = queued.then(
() => undefined,
() => undefined,
Expand Down
61 changes: 61 additions & 0 deletions src/lib/ai-edition/store/useTimeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const probeVideoDurationMock = vi.hoisted(() => vi.fn());
const probeVideoDimensionsMock = vi.hoisted(() =>
vi.fn().mockResolvedValue({ width: 1920, height: 1080 }),
);
const toastErrorMock = vi.hoisted(() => vi.fn());

vi.mock("sonner", () => ({ toast: { error: toastErrorMock } }));

vi.mock("../timeline/duration", async (importOriginal) => {
const actual = await importOriginal<typeof import("../timeline/duration")>();
Expand Down Expand Up @@ -546,6 +549,34 @@ describe("useTimeline zoom modifiers (rotation + focus mode)", () => {
focusMode: "auto",
});
});

it("rolls a live focus edit back when its commit cannot be saved", async () => {
bridgeMocks.save.mockResolvedValueOnce({ success: false, error: "project file locked" });
const { result } = renderTimeline();

act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 }));
expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({
cx: 0.8,
cy: 0.2,
});

await act(async () => {
await result.current.commitZoomFocus();
});

expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({
cx: 0.5,
cy: 0.5,
});
expect(useProjectStore.getState().dirty).toBe(false);
// The live edit advanced revision once; restoring a different document
// advances it again so async work cannot mistake the rollback for the
// optimistic document it replaced.
expect(useProjectStore.getState().revision).toBe(3);
expect(toastErrorMock).toHaveBeenCalledWith("Save failed", {
description: "project file locked",
});
});
});

// Regression guard for the playhead-stutter fix. `currentTimeSec` is rewritten on
Expand Down Expand Up @@ -669,3 +700,33 @@ describe("useTimeline selection", () => {
expect(result.current.clipSelection).toBeNull();
});
});

describe("useTimeline save failures", () => {
beforeEach(() => {
useProjectStore.getState().clear();
for (const mock of Object.values(bridgeMocks)) mock.mockReset();
toastErrorMock.mockReset();
bridgeMocks.save.mockResolvedValue({ success: false, error: "disk full" });
useProjectStore.setState({
projectId: "proj_test",
document: sampleDoc,
revision: 1,
status: "ready",
error: null,
});
});

it("surfaces a rejected mutation without applying it or leaking the rejection", async () => {
const { result } = renderTimeline();

await act(async () => {
await expect(result.current.removeClip("clip_a")).resolves.toBeUndefined();
});

expect(toastErrorMock).toHaveBeenCalledWith("Save failed", {
description: "disk full",
});
expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(1);
expect(useProjectStore.getState().document?.timeline.clips[0]?.id).toBe("clip_a");
});
});
Loading
Loading