diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index b880e68..10083f0 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -402,10 +402,12 @@ export class Edit { /** * Look up a clip by its stable ID. - * @returns The clip or null if no clip with that ID exists. + * @returns A copy of the clip, or null if no clip with that ID exists. Mutating the + * returned object has no effect on the edit — use `updateClipById` to make changes. */ public getClipById(clipId: string): Clip | null { - return this.document.getClipById(clipId)?.clip ?? null; + const clip = this.document.getClipById(clipId)?.clip; + return clip ? structuredClone(clip) : null; } /** @@ -720,7 +722,8 @@ export class Edit { // Cast to Clip since clipConfiguration is ResolvedClip internally but compatible at runtime const track = this.tracks[trackIdx]; if (!track || clipIdx < 0 || clipIdx >= track.length) return null; - return track[clipIdx].clipConfiguration as unknown as Clip; + // Copy so callers can't mutate (or freeze) live player state through the return value + return structuredClone(track[clipIdx].clipConfiguration) as unknown as Clip; } /** @@ -1027,7 +1030,8 @@ export class Edit { if (trackClips.length === 0) return null; return { - clips: trackClips.map((clip: Player) => clip.clipConfiguration as unknown as Clip) + // Copy so callers can't mutate (or freeze) live player state through the return value + clips: trackClips.map((clip: Player) => structuredClone(clip.clipConfiguration) as unknown as Clip) }; } diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index 7d926db..f93ae50 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -732,6 +732,37 @@ describe("Edit Clip Operations", () => { }); }); + describe("defensive copies from public getters", () => { + it("getClipById returns a copy — freezing it does not break in-place document updates", () => { + const clipId = edit.getClipId(0, 0); + expect(clipId).not.toBeNull(); + + // Hosts may store returned clips in state that deep-freezes (e.g. immer autoFreeze). + // updateClipInDocument mutates the stored clip in place (the canvas resize/drag path), + // so a leaked live reference would make this throw "object is not extensible". + Object.freeze(edit.getClipById(clipId!)); + + expect(() => edit.updateClipInDocument(clipId!, { width: 100, height: 100 })).not.toThrow(); + expect(edit.getClipById(clipId!)?.width).toBe(100); + }); + + it("getClip returns a copy detached from live player state", () => { + const copy = edit.getClip(0, 0); + expect(copy).not.toBeNull(); + (copy as { start?: number }).start = 999; + + expect(edit.getClip(0, 0)?.start).not.toBe(999); + }); + + it("getTrack returns copies of clip configurations", () => { + const track = edit.getTrack(0); + expect(track).not.toBeNull(); + (track!.clips[0] as { start?: number }).start = 999; + + expect(edit.getTrack(0)?.clips[0]?.start).not.toBe(999); + }); + }); + describe("clip operations undo integration", () => { it("addClip undo removes the added clip", async () => { await edit.addClip(0, createVideoClip(0, 5));