From 561ef166ae1140f9c810281398d1fbe566b9648d Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 07:58:34 +0200 Subject: [PATCH 1/2] fix(export): no Full Camera trail at a cut whose chunk start rounds up projectRegionsToSource rounded each piece's source bounds to the ms, while the clip keeps its exact start. A piece cut by a trim is gated in f32 by the compositor (continues_before: t < start), and the clip's first frame lands on the clip's own start. Where that start rounded up (10.292957 -> 10.293, 46.34986635 -> 46.350) the first frame fell before the piece: camera back in its PiP corner, motion blur trailing it there. A start that rounded down was clean, and the same held mirrored for a piece's end before a cut. Leave a bound at a cut unrounded, so it sits exactly on the segment's bound. A region's own bounds keep their rounding: unrounding those too moved a caption and two Full Camera ramps by a frame in the same export. Every consumer divides the bounds by 1000 into an f64 startSec, so nothing downstream needs an integer. Refs #1300 --- .../ai-edition/timeline/timelineMap.test.ts | 34 +++++++++++++++++++ src/lib/ai-edition/timeline/timelineMap.ts | 7 ++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts index d613614bb..26e20b498 100644 --- a/src/lib/ai-edition/timeline/timelineMap.test.ts +++ b/src/lib/ai-edition/timeline/timelineMap.test.ts @@ -344,6 +344,40 @@ describe("projectRegionsToSource", () => { const out = projectRegionsToSource([partial], [c], [c], () => "x"); expect(out).toEqual([{ ...partial, startMs: 3000, endMs: 5000, clipIndex: 0 }]); }); + + // A piece at a cut is gated in f32 by the compositor (`continues_before` → `t < start`, + // `continues_after` → `t > end`), and a clip's first and last frames land on the clip's own + // bounds. A bound rounded to the ms past them left that frame out of the Full Camera piece: + // camera back in its PiP corner, motion blur trailing it there (issue #1300, cuts of the + // v2.1.0-rc.5 passes). The cuts that rounded the other way were clean: the controls. + const pieceAcross = (trimStartSec: number, trimEndSec: number) => { + const c = clip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 60, + timelineEndSec: 60, + }); + const anchored = { ...region("r", 0, 60), clipId: "c1", sourceStartSec: 0, sourceEndSec: 60 }; + const segments = resolvePlaybackSegments([c], [trim("a", trimStartSec, trimEndSec)]); + return projectRegionsToSource([anchored], segments, [c], () => "r2"); + }; + + it.each([ + 10.292957, 18.361809, 46.34986635, 6.284394, + ])("keeps the first frame after a cut at %s in the Full Camera piece", (cut) => { + const [, after] = pieceAcross(cut - 2, cut); + expect(after.continuesBefore).toBe(true); + expect(Math.fround(cut)).toBeGreaterThanOrEqual(Math.fround(after.startMs / 1000)); + }); + + it.each([ + 6.284394, 14.340314, 10.292957, + ])("keeps the last frame before a cut at %s in the Full Camera piece", (cut) => { + const [before] = pieceAcross(cut, cut + 2); + expect(before.continuesAfter).toBe(true); + expect(Math.fround(cut)).toBeLessThanOrEqual(Math.fround(before.endMs / 1000)); + }); }); describe("resolveNativePosition", () => { diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts index 80f77ed4d..15c9907ba 100644 --- a/src/lib/ai-edition/timeline/timelineMap.ts +++ b/src/lib/ai-edition/timeline/timelineMap.ts @@ -689,8 +689,11 @@ export function projectRegionsToSource< out.push({ ...region, id: emitted === 0 ? region.id : makeId(), - startMs: Math.round(srcStartSec * 1000), - endMs: Math.round(srcEndSec * 1000), + // A bound at a cut is not rounded to the ms: it must sit exactly on the segment's + // bound, where native puts the clip's first or last frame. Rounded past it, that + // frame fell out of the piece and Full Camera trailed to its PiP corner (#1300). + startMs: flags.continuesBefore ? srcStartSec * 1000 : Math.round(srcStartSec * 1000), + endMs: flags.continuesAfter ? srcEndSec * 1000 : Math.round(srcEndSec * 1000), clipIndex, // Omitted rather than sent as `false`: every payload without a trim under a // modifier stays byte-for-byte what it was. From 39eb2e57a94200b4977c66daaeef1485d85b1f9b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 08:18:48 +0200 Subject: [PATCH 2/2] test(timeline): assert a cut-edge piece bound equals the cut in f32 --- src/lib/ai-edition/timeline/timelineMap.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts index 26e20b498..dbaebf9db 100644 --- a/src/lib/ai-edition/timeline/timelineMap.test.ts +++ b/src/lib/ai-edition/timeline/timelineMap.test.ts @@ -368,7 +368,7 @@ describe("projectRegionsToSource", () => { ])("keeps the first frame after a cut at %s in the Full Camera piece", (cut) => { const [, after] = pieceAcross(cut - 2, cut); expect(after.continuesBefore).toBe(true); - expect(Math.fround(cut)).toBeGreaterThanOrEqual(Math.fround(after.startMs / 1000)); + expect(Math.fround(after.startMs / 1000)).toBe(Math.fround(cut)); }); it.each([ @@ -376,7 +376,7 @@ describe("projectRegionsToSource", () => { ])("keeps the last frame before a cut at %s in the Full Camera piece", (cut) => { const [before] = pieceAcross(cut, cut + 2); expect(before.continuesAfter).toBe(true); - expect(Math.fround(cut)).toBeLessThanOrEqual(Math.fround(before.endMs / 1000)); + expect(Math.fround(before.endMs / 1000)).toBe(Math.fround(cut)); }); });