@@ -48,7 +48,10 @@ import {
4848 replacePillSpan ,
4949 resolvePillIds ,
5050} from "../../src/lib/ai-edition/timeline/timelineMap" ;
51- import { trimAppliesToClip } from "../../src/lib/ai-edition/timeline/trim-mapping" ;
51+ import {
52+ trimAppliesToClip ,
53+ trimToTimelineSpan ,
54+ } from "../../src/lib/ai-edition/timeline/trim-mapping" ;
5255// ponytail: relative, and it has to stay that way — `electron/` never resolves
5356// the `@/` alias (the main-process build does not declare it), which is why the
5457// scale table was moved out of `components/video-editor/types.ts` to be
@@ -57,6 +60,10 @@ import {
5760 effectiveZoomScale ,
5861 ZOOM_DEPTH_LEGEND ,
5962} from "../../src/lib/ai-edition/timeline/zoom-scale" ;
63+ import {
64+ transitionCutsMs ,
65+ zoomTransitions ,
66+ } from "../../src/lib/ai-edition/timeline/zoom-transitions" ;
6067import { SETTING_BOUNDS } from "../../src/lib/projectDefaults" ;
6168
6269export interface AgentToolExecution {
@@ -720,6 +727,56 @@ function roundSec(ms: number): number {
720727 return Math . round ( ms ) / 1000 ;
721728}
722729
730+ // Each zoom pill's camera moves in virtual ms, and how much of each a trim cuts off (#1028).
731+ // The pill shows the hold only: the move in runs before it, the move out after it, and a
732+ // trim over either makes the export jump at the cut.
733+ function zoomTransitionsForAgent ( document : AxcutDocument ) {
734+ const legacy = document . legacyEditor as Record < string , unknown > | null ;
735+ const speedRegions =
736+ ( legacy ?. speedRegions as
737+ | Array < { id : string ; startMs : number ; endMs : number ; speed : number } >
738+ | undefined ) ?? [ ] ;
739+ // A trim with no clipId cuts every clip of its asset in playback, while the ruler maps it
740+ // through one: measured per clip, so a zoom on any of them learns of the cut.
741+ const trims = document . timeline . trimRanges . flatMap ( ( trim ) => {
742+ const clips =
743+ trim . clipId !== undefined
744+ ? [ trim ]
745+ : document . timeline . clips
746+ . filter ( ( clip ) => clip . assetId === trim . assetId )
747+ . map ( ( clip ) => ( { ...trim , clipId : clip . id } ) ) ;
748+ return clips . flatMap ( ( anchored ) => {
749+ const span = trimToTimelineSpan ( anchored , document . timeline . clips ) ;
750+ return span ? [ { startMs : span . start * 1000 , endMs : span . end * 1000 } ] : [ ] ;
751+ } ) ;
752+ } ) ;
753+ return coalesceForAgent ( document . zoomRanges ) . map ( ( zoom ) => {
754+ const transitions = zoomTransitions ( { ...zoom , scale : effectiveZoomScale ( zoom ) } , speedRegions ) ;
755+ return { zoom, transitions, cut : transitionCutsMs ( zoom , transitions , trims ) } ;
756+ } ) ;
757+ }
758+
759+ /** The transitions this edit cut that were not cut, or not this way, before it: the warning a
760+ * zoom or trim write carries, like `cursorAnchor`: reported, never refused. */
761+ function cutTransitionsReport ( before : AxcutDocument , after : AxcutDocument ) {
762+ const cutsOf = ( document : AxcutDocument ) =>
763+ zoomTransitionsForAgent ( document ) . flatMap ( ( { zoom, cut } ) =>
764+ ( [ "in" , "out" ] as const )
765+ . map ( ( side ) => ( {
766+ zoomId : zoom . id ,
767+ side,
768+ cutSec : roundSec ( side === "in" ? cut . inMs : cut . outMs ) ,
769+ key : `${ zoom . id } :${ side } :${ zoom . startMs } :${ zoom . endMs } ` ,
770+ } ) )
771+ . filter ( ( c ) => c . cutSec > 0 ) ,
772+ ) ;
773+ const known = new Set ( cutsOf ( before ) . map ( ( c ) => `${ c . key } :${ c . cutSec } ` ) ) ;
774+ const cuts = cutsOf ( after )
775+ . filter ( ( c ) => ! known . has ( `${ c . key } :${ c . cutSec } ` ) )
776+ . map ( ( { key : _key , ...c } ) => c ) ;
777+ return cuts . length ? { cutTransitions : cuts } : { } ;
778+ }
779+
723780// Compact projection of the document for the model: everything it needs to
724781// reference ids and times, nothing it doesn't (no waveform paths, no history).
725782//
@@ -771,7 +828,10 @@ export function documentSnapshotForModel(
771828 zoomNote :
772829 `renderedScale is what the viewer sees (depth is an ordinal, not a factor: ${ ZOOM_DEPTH_LEGEND } ). ` +
773830 "When a zoom carries customScale it wins over depth and depthIsOverridden is true — " +
774- "a setZoom that only changes depth on such a zoom clears customScale so the depth takes effect." ,
831+ "a setZoom that only changes depth on such a zoom clears customScale so the depth takes effect. " +
832+ "startSec–endSec is where the zoom HOLDS: it animates in over zoomInFromSec–startSec and out over " +
833+ "endSec–zoomOutUntilSec (transitionSec of screen time each; a speed region scales that on the " +
834+ "timeline, longer above 1x and shorter below, so read the bounds rather than adding transitionSec). cutByTrim names the move a trim cuts into (in, out or both): the export jumps at that cut." ,
775835 project : { id : document . project . id , title : document . project . title } ,
776836 primaryAssetId : document . project . primaryAssetId ?? document . assets [ 0 ] ?. id ?? null ,
777837 autoFocusAll,
@@ -828,10 +888,16 @@ export function documentSnapshotForModel(
828888 endSec : s . endSec ,
829889 reason : s . reason ,
830890 } ) ) ,
831- zoomRanges : coalesceForAgent ( document . zoomRanges ) . map ( ( z ) => ( {
891+ zoomRanges : zoomTransitionsForAgent ( document ) . map ( ( { zoom : z , transitions , cut } ) => ( {
832892 id : z . id ,
833893 startSec : roundSec ( z . startMs ) ,
834894 endSec : roundSec ( z . endMs ) ,
895+ zoomInFromSec : roundSec ( transitions . inFromMs ) ,
896+ zoomOutUntilSec : roundSec ( transitions . outUntilMs ) ,
897+ transitionSec : roundSec ( transitions . durationMs ) ,
898+ ...( cut . inMs > 0 || cut . outMs > 0
899+ ? { cutByTrim : cut . inMs > 0 ? ( cut . outMs > 0 ? "both" : "in" ) : "out" }
900+ : { } ) ,
835901 depth : z . depth ,
836902 renderedScale : effectiveZoomScale ( z ) ,
837903 // Emitted only when set: an unconditional `customScale: null` on every
@@ -1658,7 +1724,12 @@ export function executeAgentTool(
16581724 return {
16591725 ok : true ,
16601726 document : next ,
1661- resultJson : JSON . stringify ( { trimRangeId : trim . id , startSec, endSec } ) ,
1727+ resultJson : JSON . stringify ( {
1728+ trimRangeId : trim . id ,
1729+ startSec,
1730+ endSec,
1731+ ...cutTransitionsReport ( document , next ) ,
1732+ } ) ,
16621733 summary : `added trim ${ formatSec ( startSec ) } – ${ formatSec ( endSec ) } ` ,
16631734 } ;
16641735 }
@@ -1705,7 +1776,12 @@ export function executeAgentTool(
17051776 return {
17061777 ok : true ,
17071778 document : next ,
1708- resultJson : JSON . stringify ( { trimRangeId, startSec, endSec } ) ,
1779+ resultJson : JSON . stringify ( {
1780+ trimRangeId,
1781+ startSec,
1782+ endSec,
1783+ ...cutTransitionsReport ( document , next ) ,
1784+ } ) ,
17091785 summary : `moved trim to ${ formatSec ( startSec ) } – ${ formatSec ( endSec ) } ` ,
17101786 } ;
17111787 }
@@ -1901,6 +1977,7 @@ export function executeAgentTool(
19011977 renderedScale : effectiveZoomScale ( zoom ) ,
19021978 ...landingReport ( landing , startMs / 1000 , endMs / 1000 ) ,
19031979 ...( anchor ? { cursorAnchor : anchor } : { } ) ,
1980+ ...cutTransitionsReport ( document , next ) ,
19041981 } ) ,
19051982 summary :
19061983 `added zoom ${ formatSec ( landing . startSec ) } – ${ formatSec ( landing . endSec ) } ` +
@@ -1985,6 +2062,7 @@ export function executeAgentTool(
19852062 ...( clearsCustomScale ? { clearedCustomScale : true } : { } ) ,
19862063 ...landingReport ( landing , startMs / 1000 , endMs / 1000 ) ,
19872064 ...( anchor ? { cursorAnchor : anchor } : { } ) ,
2065+ ...cutTransitionsReport ( document , next ) ,
19882066 } ) ,
19892067 summary :
19902068 `updated zoom ${ formatSec ( landing . startSec ) } – ${ formatSec ( landing . endSec ) } ` +
0 commit comments