diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 60a209b6f..13e5c1c47 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1203,22 +1203,17 @@ export function NewEditorShell() { return; } - // An effect region goes with its definition (`RegionSnapshot`), and with the span of the - // whole pill: one cut by a clip boundary is several fragments. + // The pill on screen, not the fragment clicked: a region cut by a clip boundary is several. + const { wholePillOf } = await import("@/lib/ai-edition/timeline/timelineMap"); + + // An effect region goes with its definition (`RegionSnapshot`). if (sel.kind === "shaderEffect") { - const { coalesceRegionsForRuler } = await import("@/lib/ai-edition/timeline/timelineMap"); - const pill = coalesceRegionsForRuler(tl.shaderEffectRanges).find((p) => - p.ids.includes(sel.id), - ); - const definition = pill && tl.shaderEffects.find((e) => e.id === pill.member.effectId); - if (!pill || !definition) return; + const region = wholePillOf(tl.shaderEffectRanges, sel.id); + const definition = region && tl.shaderEffects.find((e) => e.id === region.effectId); + if (!region || !definition) return; copyRegion({ kind: "shaderEffect", - region: { - ...pill.member, - startMs: Math.round(pill.start * 1000), - endMs: Math.round(pill.end * 1000), - } as unknown as Record, + region: region as unknown as Record, definition, }); setCopiedClipId(null); @@ -1233,7 +1228,10 @@ export function NewEditorShell() { : sel.kind === "speed" ? tl.speedRegions : tl.cameraFullscreenRegions; - const region = (source as Array<{ id: string }>).find((r) => r.id === sel.id); + const region = wholePillOf( + source as Array<{ id: string; startMs: number; endMs: number }>, + sel.id, + ); if (!region) return; copyRegion({ kind: sel.kind, region: region as unknown as Record }); // One clipboard wins at a time: a copied pill retires the copied clip. diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts index 11aa30c50..d613614bb 100644 --- a/src/lib/ai-edition/timeline/timelineMap.test.ts +++ b/src/lib/ai-edition/timeline/timelineMap.test.ts @@ -18,6 +18,7 @@ import { resolveNativePosition, resolvePillIds, resolveRegionIds, + wholePillOf, } from "./timelineMap"; function clip(overrides: Partial & Pick): AxcutClip { @@ -787,6 +788,18 @@ describe("pills wired to the universal rules", () => { expect(pills[0].ids).toEqual(["a", "b"]); }); + it("wholePillOf gives the pill a fragment belongs to, over its whole span", () => { + // One zoom cut by the clip boundary at 25.557 s: two fragments, one pill. + const regions = [ + { id: "a", startMs: 20000, endMs: 25557, depth: 3 }, + { id: "b", startMs: 25557, endMs: 30000, depth: 3 }, + { id: "c", startMs: 30000, endMs: 31000, depth: 5 }, + ]; + expect(wholePillOf(regions, "b")).toEqual({ id: "a", startMs: 20000, endMs: 30000, depth: 3 }); + expect(wholePillOf(regions, "c")).toEqual(regions[2]); + expect(wholePillOf(regions, "gone")).toBeNull(); + }); + it("keeps adjacent regions with different properties as separate pills", () => { const pills = coalesceRegionsForRuler([ { id: "a", startMs: 2000, endMs: 5000, speed: 3 }, diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts index 451987ce9..80f77ed4d 100644 --- a/src/lib/ai-edition/timeline/timelineMap.ts +++ b/src/lib/ai-edition/timeline/timelineMap.ts @@ -304,6 +304,24 @@ export function coalesceRegionsForRuler( + regions: T[], + id: string, +): T | null { + const pill = coalesceRegionsForRuler(regions).find((p) => p.ids.includes(id)); + if (!pill) return null; + return { + ...pill.member, + startMs: Math.round(pill.start * 1000), + endMs: Math.round(pill.end * 1000), + }; +} + /** * The regions that render as the SAME pill as `id`. Recomputed from the merge rule * rather than stored: nothing records which regions belong together, because equal