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
8 changes: 7 additions & 1 deletion src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1240,7 +1240,13 @@ export class Edit {
// Validate the final state before committing to history.
// Live updates (updateClipInDocument) skip validation for performance,
// so this is the gate that catches corrupt data from drag/slider interactions.
ResolvedClipSchema.parse(finalConfig);
// Skip invalid commits (e.g. media asset with empty src) instead of throwing —
// resize/fit gestures must not surface unhandled ZodErrors to the host app.
const parsed = ResolvedClipSchema.safeParse(finalConfig);
if (!parsed.success) {
console.warn(`commitClipUpdate: skipping invalid clip ${clipId}`, parsed.error);
return;
}

const command = new SetUpdatedClipCommand(initialConfig, structuredClone(finalConfig), {
trackIndex: location.trackIndex,
Expand Down
66 changes: 66 additions & 0 deletions tests/edit-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1893,3 +1893,69 @@ describe("Keyboard movement keyframe safety", () => {
expect(setUpdatedClip).not.toHaveBeenCalled();
});
});


describe("commitClipUpdate validation", () => {
let edit: Edit;
let warnSpy: jest.SpyInstance;

beforeEach(async () => {
edit = new Edit({
timeline: {
tracks: [
{
clips: [{ asset: { type: "image", src: "https://example.com/image.jpg" }, start: 0, length: 1 }]
}
]
},
output: { size: { width: 1920, height: 1080 }, format: "mp4" }
});
await edit.load();
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
});

afterEach(() => {
warnSpy.mockRestore();
edit.dispose();
jest.clearAllMocks();
});

it("skips history commit when media asset src is empty (no ZodError)", () => {
const resolved = edit.getResolvedClip(0, 0);
expect(resolved).toBeTruthy();
const clipId = resolved!.id;
const initial = structuredClone(resolved!) as typeof resolved & { id: string };
const finalConfig = structuredClone(resolved!) as typeof resolved & {
id: string;
fit?: string;
asset: { type: string; src: string };
};
finalConfig.asset.src = "";
finalConfig.fit = "contain";

expect(() => edit.commitClipUpdate(clipId, initial!, finalConfig!)).not.toThrow();

const { history, index } = getCommandState(edit);
expect(history).toHaveLength(0);
expect(index).toBe(-1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("skipping invalid clip"),
expect.anything()
);
});

it("commits valid fit updates to history", () => {
const resolved = edit.getResolvedClip(0, 0);
expect(resolved).toBeTruthy();
const clipId = resolved!.id;
const initial = structuredClone(resolved!);
const finalConfig = structuredClone(resolved!) as typeof resolved & { fit?: string };
finalConfig!.fit = "contain";

expect(() => edit.commitClipUpdate(clipId, initial!, finalConfig!)).not.toThrow();

const { history, index } = getCommandState(edit);
expect(history).toHaveLength(1);
expect(index).toBe(0);
});
});
Loading