Skip to content
Merged
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
12 changes: 8 additions & 4 deletions src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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)
};
}

Expand Down
31 changes: 31 additions & 0 deletions tests/edit-clip-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading