Skip to main content

tellers_timeline_core/stack_methods/
mod.rs

1use crate::metadata::{item_link_group_id, item_tellers_group_id, resolve_tellers_group_id};
2use crate::{
3    Clip, Gap, IdMetadataExt, InsertPolicy, Item, OverlapPolicy, Seconds, Stack, Track, TrackKind,
4    TrackInsertResult,
5};
6use std::collections::{HashMap, HashSet};
7
8mod stack_insert_propagate;
9mod stack_item_delete;
10mod stack_item_get;
11mod stack_item_insert;
12mod stack_item_link;
13mod stack_item_move;
14mod stack_item_replace;
15mod stack_item_split;
16mod stack_partner_tracks;
17#[cfg(test)]
18mod stack_partner_move_tests;
19mod stack_ripple;
20mod stack_snap;
21mod stack_track;
22
23pub use stack_snap::{SnapEdge, SnapMatch, SnapTarget};
24
25use stack_item_split::SyncSplitIdPolicy;
26
27const EPS: Seconds = 1e-9;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct SyncedInsertResult {
31    pub primary_clip_id: String,
32    pub audio_clips: Vec<(String, usize)>,
33    pub synced_video_clip_id: Option<String>,
34    pub sync_clips_id: Option<i64>,
35    pub created_track_indices: Vec<usize>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum InsertItemAtTimeResult {
40    ItemId(String),
41    Synced(SyncedInsertResult),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SyncTrackInfo {
46    pub track_indices: Vec<usize>,
47    pub track_ids: Vec<Option<String>>,
48}
49
50#[derive(Debug, Clone)]
51struct SyncedInputs {
52    audio: Vec<Item>,
53    video: Option<Item>,
54}
55
56#[derive(Debug, Clone)]
57struct SyncedMoveItem {
58    track_index: usize,
59    item_index: usize,
60    track_kind: TrackKind,
61    item: Item,
62    is_selected: bool,
63}
64
65#[derive(Debug, Clone)]
66struct SyncedClipState {
67    track_index: usize,
68    start: Seconds,
69    duration: Seconds,
70    source_start: Seconds,
71    sync_clips_id: i64,
72}
73
74#[derive(Debug, Clone)]
75struct FlattenedBoundary {
76    track_indices: Vec<usize>,
77}
78
79fn shift_track_index_after_insert(track_index: &mut usize, inserted_track_index: usize) {
80    if inserted_track_index <= *track_index {
81        *track_index += 1;
82    }
83}
84
85fn shift_track_indices_after_insert(track_indices: &mut [usize], inserted_track_index: usize) {
86    for track_index in track_indices {
87        shift_track_index_after_insert(track_index, inserted_track_index);
88    }
89}
90
91fn shift_move_placements_after_insert(
92    placements: &mut [(usize, Item, bool)],
93    inserted_track_index: usize,
94) {
95    for (track_index, _, _) in placements {
96        shift_track_index_after_insert(track_index, inserted_track_index);
97    }
98}
99
100fn clamp_insertion_index(len: usize, index: isize) -> usize {
101    if index < 0 {
102        let pos = len as isize + index;
103        if pos <= 0 {
104            0
105        } else if pos >= len as isize {
106            len
107        } else {
108            pos as usize
109        }
110    } else {
111        let idx = index as usize;
112        if idx > len {
113            len
114        } else {
115            idx
116        }
117    }
118}
119
120impl Stack {
121    fn collect_timeline_ids(&self) -> HashSet<String> {
122        let mut ids = HashSet::new();
123        for track in &self.children {
124            if let Some(id) = track.get_id() {
125                if !id.is_empty() {
126                    ids.insert(id);
127                }
128            }
129            for item in &track.items {
130                if let Some(id) = item.get_id() {
131                    if !id.is_empty() {
132                        ids.insert(id);
133                    }
134                }
135            }
136        }
137        ids
138    }
139
140    fn ensure_unique_item_id(item: &mut Item, used_ids: &mut HashSet<String>) -> String {
141        let current = item.get_id().filter(|id| !id.is_empty());
142        if let Some(id) = current {
143            if used_ids.insert(id.clone()) {
144                return id;
145            }
146        }
147
148        loop {
149            let id = crate::types::gen_hex_id_12();
150            if used_ids.insert(id.clone()) {
151                item.set_id(Some(id.clone()));
152                return id;
153            }
154        }
155    }
156
157    fn next_sync_clips_id(&self) -> i64 {
158        self.children
159            .iter()
160            .flat_map(|track| track.items.iter())
161            .filter_map(|item| match item {
162                Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata),
163                Item::Gap(_) => None,
164            })
165            .max()
166            .unwrap_or(0)
167            + 1
168    }
169
170    fn next_tellers_group_id(&self) -> i64 {
171        self.children
172            .iter()
173            .flat_map(|track| track.items.iter())
174            .filter_map(|item| match item {
175                Item::Clip(clip) => resolve_tellers_group_id(&clip.metadata),
176                Item::Gap(_) => None,
177            })
178            .max()
179            .unwrap_or(0)
180            + 1
181    }
182
183    fn insert_gap_only(&mut self, track_index: usize, dest_time: Seconds, item: Item) -> bool {
184        if track_index >= self.children.len() || item.duration() <= EPS {
185            return false;
186        }
187
188        let duration = item.duration().max(0.0);
189        let mut start = dest_time;
190        let total = self.children[track_index].total_duration();
191        if start < 0.0 {
192            start = total - start;
193        }
194        if start < 0.0 {
195            return false;
196        }
197        let end = start + duration;
198
199        if start >= total - EPS {
200            self.children[track_index].insert_at_time(
201                start,
202                item,
203                OverlapPolicy::Override,
204                InsertPolicy::InsertBefore,
205            );
206            return true;
207        }
208
209        if !range_is_gap_backed(&self.children[track_index], start, end) {
210            return false;
211        }
212
213        let track = &mut self.children[track_index];
214        split_gap_boundary(track, end);
215        split_gap_boundary(track, start);
216
217        self.children[track_index].insert_at_time(
218            start,
219            item,
220            OverlapPolicy::Override,
221            InsertPolicy::InsertBefore,
222        );
223        true
224    }
225
226    fn apply_sync_splits_for_column_insert(
227        &mut self,
228        start: Seconds,
229        duration: Seconds,
230        insert_policy: InsertPolicy,
231        overlap_policy: OverlapPolicy,
232        id_policy: SyncSplitIdPolicy,
233        scope_track_indices: Option<&[usize]>,
234    ) -> bool {
235        let mut split_times = Vec::new();
236        if matches!(insert_policy, InsertPolicy::SplitAndInsert) {
237            split_times.push(start);
238        }
239        if overlap_policy == OverlapPolicy::Override && duration > EPS {
240            if !split_times
241                .iter()
242                .any(|time| (*time - start).abs() <= EPS)
243            {
244                split_times.push(start);
245            }
246            if matches!(id_policy, SyncSplitIdPolicy::KeepShared) {
247                split_times.push(start + duration);
248            }
249        }
250        split_times.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
251        split_times.dedup_by(|left, right| (*left - *right).abs() <= EPS);
252        for split_time in split_times {
253            if !self.split_sync_clips_at_time(split_time, id_policy, scope_track_indices) {
254                return false;
255            }
256        }
257        true
258    }
259
260    fn insert_at_time_with_sync_splits(
261        &mut self,
262        track_index: usize,
263        insert_time: Seconds,
264        item: Item,
265        overlap_policy: OverlapPolicy,
266        insert_policy: InsertPolicy,
267    ) {
268        if overlap_policy == OverlapPolicy::Push {
269            let _ = self.children[track_index].insert_at_time(
270                insert_time,
271                item,
272                overlap_policy,
273                insert_policy,
274            );
275            return;
276        }
277
278        let prefer_cluster_with_video = self.children.get(track_index).is_some_and(|track| {
279            track.kind == TrackKind::Video
280        });
281        let cluster = self.preferred_cluster_indices(track_index, prefer_cluster_with_video);
282        let start = insertion_start_or_end_for_policy(
283            &self.children[track_index],
284            insert_time,
285            insert_policy,
286        )
287        .unwrap_or(insert_time);
288        let duration = item.duration().max(0.0);
289
290        let primary_result = self.children[track_index].insert_at_time(
291            insert_time,
292            item,
293            overlap_policy,
294            insert_policy,
295        );
296        if primary_result.success {
297            let updates = [(track_index, &primary_result)];
298            let _ = self.propagate_insert_to_cluster(
299                start,
300                duration,
301                overlap_policy,
302                &updates,
303                &cluster,
304                None,
305            );
306        }
307    }
308
309    /// Video tracks other than `dest_track_index` free over `[start, end]`,
310    /// nearest first, tracks nobody lists as a partner first.
311    fn nearest_free_video_tracks(
312        &self,
313        dest_track_index: usize,
314        start: Seconds,
315        end: Seconds,
316        exclude: &[usize],
317    ) -> Vec<usize> {
318        self.nearest_free_tracks_of_kind(TrackKind::Video, dest_track_index, start, end, exclude)
319    }
320
321    pub(super) fn nearest_free_tracks_of_kind(
322        &self,
323        kind: TrackKind,
324        dest_track_index: usize,
325        start: Seconds,
326        end: Seconds,
327        exclude: &[usize],
328    ) -> Vec<usize> {
329        let mut candidates: Vec<usize> = self
330            .children
331            .iter()
332            .enumerate()
333            .filter(|(track_index, track)| {
334                *track_index != dest_track_index
335                    && track.kind == kind
336                    && !exclude.contains(track_index)
337                    && (track_is_empty_boundary(track) || range_is_gap_backed(track, start, end))
338            })
339            .map(|(track_index, _)| track_index)
340            .collect();
341        candidates.sort_by_key(|&track_index| {
342            (
343                !self.track_is_free(track_index),
344                track_index.abs_diff(dest_track_index),
345                track_index,
346            )
347        });
348        candidates
349    }
350
351    /// Whether `[start, end]` on `track` holds nothing (an empty or gap-only
352    /// track, or a gap-backed range).
353    pub(crate) fn track_range_is_free(track: &Track, start: Seconds, end: Seconds) -> bool {
354        track_is_empty_boundary(track) || range_is_gap_backed(track, start, end)
355    }
356
357    pub(super) fn try_reuse_video_track_for_audio_move(
358        &self,
359        track_index: usize,
360        audio_track_index: usize,
361        dest_time: Seconds,
362        end_time: Seconds,
363        sync_clips_id: Option<i64>,
364        use_sync_backed_track: bool,
365        overlap_policy: OverlapPolicy,
366        insert_policy: InsertPolicy,
367    ) -> Option<usize> {
368        if self.children.get(track_index)?.kind != TrackKind::Video {
369            return None;
370        }
371        if range_is_gap_backed(&self.children[track_index], dest_time, end_time) {
372            return Some(track_index);
373        }
374        let has_blocking_clip = range_has_blocking_clip(
375            &self.children[track_index],
376            dest_time,
377            end_time,
378            sync_clips_id,
379        );
380        let can_push_existing_boundary = overlap_policy == OverlapPolicy::Push
381            && self.track_matches_primary_sync_boundary(audio_track_index, track_index);
382        // Override + SplitAndInsert splits destination clips at the insert
383        // point before the column lands, so an occupied cluster video track
384        // is still the correct target (e.g. cut into link group 7 on Video 2).
385        let will_split_for_override = overlap_policy == OverlapPolicy::Override
386            && matches!(insert_policy, InsertPolicy::SplitAndInsert);
387        if !has_blocking_clip || can_push_existing_boundary || will_split_for_override {
388            return use_sync_backed_track.then_some(track_index);
389        }
390        None
391    }
392
393    pub(super) fn find_or_create_video_track_for_audio(
394        &mut self,
395        audio_track_index: usize,
396        dest_time: Seconds,
397        duration: Seconds,
398        created_track_indices: &mut Vec<usize>,
399        sync_clips_id: Option<i64>,
400        use_sync_backed_track: bool,
401        overlap_policy: OverlapPolicy,
402        insert_policy: InsertPolicy,
403    ) -> Option<usize> {
404        let end_time = dest_time + duration;
405
406        // Prefer the destination boundary cluster's own video track before a
407        // physically adjacent video from a neighboring cluster (e.g. reuse
408        // Video 2 when inserting on A9, not Main Video sitting above A9).
409        let cluster = self.boundary_group_indices(audio_track_index);
410        let mut cluster_video_tracks: Vec<usize> = cluster
411            .iter()
412            .copied()
413            .filter(|&track_index| {
414                self.children
415                    .get(track_index)
416                    .is_some_and(|track| track.kind == TrackKind::Video)
417            })
418            .collect();
419        // Prefer the cluster video below the destination audio (Resolve
420        // audio-over-video layout) before any video above the group.
421        cluster_video_tracks.sort_by_key(|track_index| {
422            if *track_index > audio_track_index {
423                0
424            } else {
425                1
426            }
427        });
428        for track_index in cluster_video_tracks {
429            if let Some(track_index) = self.try_reuse_video_track_for_audio_move(
430                track_index,
431                audio_track_index,
432                dest_time,
433                end_time,
434                sync_clips_id,
435                use_sync_backed_track,
436                overlap_policy,
437                insert_policy,
438            ) {
439                return Some(track_index);
440            }
441        }
442
443        // No cluster video can take the partner; any other video track that is
444        // free over the column is still better than a brand-new one.
445        if let Some(track_index) = self
446            .nearest_free_video_tracks(audio_track_index, dest_time, end_time, &[])
447            .into_iter()
448            .next()
449        {
450            return Some(track_index);
451        }
452
453        let mut audio_end = audio_track_index + 1;
454        while audio_end < self.children.len() && self.children[audio_end].kind == TrackKind::Audio {
455            audio_end += 1;
456        }
457
458        // Create the new video track directly below the moving audio group.
459        let insert_at = audio_end;
460        let track = self.new_numbered_track(TrackKind::Video);
461        self.children.insert(insert_at, track);
462        created_track_indices.push(insert_at);
463        Some(insert_at)
464    }
465
466    fn item_occupies_column(
467        &self,
468        track_index: usize,
469        item_index: usize,
470        column_start: Seconds,
471        column_duration: Seconds,
472    ) -> bool {
473        let Some(track) = self.children.get(track_index) else {
474            return false;
475        };
476        if item_index >= track.items.len() {
477            return false;
478        }
479        (track.start_time_of_item(item_index) - column_start).abs() <= EPS
480            && (track.items[item_index].duration() - column_duration).abs() <= EPS
481    }
482
483    fn delete_clips_at_indices(
484        &mut self,
485        mut targets: Vec<(usize, usize)>,
486        replace_with_gap: bool,
487    ) -> Vec<(usize, Item)> {
488        targets.sort_by(|a, b| b.cmp(a));
489        targets.dedup();
490        let mut removed = Vec::new();
491        let mut used_ids = replace_with_gap.then(|| self.collect_timeline_ids());
492        for (ti, ii) in targets {
493            if ti >= self.children.len() {
494                continue;
495            }
496            let track = &mut self.children[ti];
497            if ii >= track.items.len() {
498                continue;
499            }
500            if matches!(track.items[ii], Item::Gap(_)) && replace_with_gap {
501                continue;
502            }
503            let start = track.start_time_of_item(ii);
504            let end = start + track.items[ii].duration().max(0.0);
505            let mut removed_items = track.delete_range(start, end, replace_with_gap);
506            let Some(removed_item) = removed_items.pop() else {
507                continue;
508            };
509            if replace_with_gap {
510                if let (Some(used_ids), Some(item)) = (
511                    &mut used_ids,
512                    track
513                        .get_item_at_time(start)
514                        .and_then(|gap_index| track.items.get_mut(gap_index)),
515                ) {
516                    if matches!(item, Item::Gap(_)) {
517                        Self::ensure_unique_item_id(item, used_ids);
518                    }
519                }
520            }
521            removed.push((ti, removed_item));
522        }
523        removed.reverse();
524        removed
525    }
526
527    fn delete_item_targets(&self, item_id: &str) -> Option<Vec<(usize, usize)>> {
528        let (track_index, item_index, item) = self.get_item(item_id)?;
529        if matches!(item, Item::Gap(_)) {
530            return None;
531        }
532        let sync_clips_id = match &item {
533            Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata),
534            Item::Gap(_) => None,
535        };
536        if let Some(sync_id) = sync_clips_id {
537            Some(self.synced_clips_targets(sync_id))
538        } else {
539            Some(vec![(track_index, item_index)])
540        }
541    }
542
543    /// Like [`delete_item_targets`], but expands to the whole Tellers group when
544    /// the item belongs to one (including each member's sync partners). Used only
545    /// by the public delete entry points; the internal synced-move path keeps
546    /// using the sync-only [`delete_item_targets`].
547    /// With `with_sync_clips` false, sync partners are left out: only the item
548    /// (and its group members) is targeted.
549    fn delete_item_group_targets(
550        &self,
551        item_id: &str,
552        with_sync_clips: bool,
553    ) -> Option<Vec<(usize, usize)>> {
554        let mut targets = if with_sync_clips {
555            self.delete_item_targets(item_id)?
556        } else {
557            vec![self.clip_target(item_id)?]
558        };
559        let (_, _, item) = self.get_item(item_id)?;
560        if let Item::Clip(clip) = item {
561            if let Some(group_id) = resolve_tellers_group_id(&clip.metadata) {
562                for member in self.tellers_group_targets(group_id) {
563                    targets.push(member);
564                    if !with_sync_clips {
565                        continue;
566                    }
567                    if let Some(Item::Clip(member_clip)) = self
568                        .children
569                        .get(member.0)
570                        .and_then(|track| track.items.get(member.1))
571                    {
572                        if let Some(sync_id) = resolve_sync_clips_id(&member_clip.metadata) {
573                            targets.extend(self.synced_clips_targets(sync_id));
574                        }
575                    }
576                }
577            }
578        }
579        Some(targets)
580    }
581
582    fn delete_item_replace_with_gap(
583        &mut self,
584        item_id: &str,
585        with_sync_clips: bool,
586    ) -> Vec<(usize, Item)> {
587        let Some(targets) = self.delete_item_group_targets(item_id, with_sync_clips) else {
588            return Vec::new();
589        };
590        let removed = self.delete_clips_at_indices(targets, true);
591        if !removed.is_empty() {
592            self.sanitize();
593        }
594        removed
595    }
596
597    fn sync_groups_behind_clips(&self, clips_to_delete: &[(usize, usize)]) -> HashSet<i64> {
598        let mut sync_groups = HashSet::new();
599        for &(ti, ii) in clips_to_delete {
600            let Some(track) = self.children.get(ti) else {
601                continue;
602            };
603            for item in track.items.iter().skip(ii + 1) {
604                if let Item::Clip(clip) = item {
605                    if let Some(sync_id) = resolve_sync_clips_id(&clip.metadata) {
606                        sync_groups.insert(sync_id);
607                    }
608                }
609            }
610        }
611        sync_groups
612    }
613
614    fn collapse_mutation_tracks(
615        &self,
616        clips_to_delete: &[(usize, usize)],
617        sync_groups_behind: &HashSet<i64>,
618    ) -> HashSet<usize> {
619        let mut tracks: HashSet<usize> = clips_to_delete.iter().map(|(ti, _)| *ti).collect();
620        for sync_id in sync_groups_behind {
621            for (ti, _) in self.synced_clips_targets(*sync_id) {
622                tracks.insert(ti);
623            }
624        }
625        tracks
626    }
627
628    fn delete_item_collapse(&mut self, item_id: &str) -> Vec<(usize, Item)> {
629        let Some((track_index, item_index, item)) = self.get_item(item_id) else {
630            return Vec::new();
631        };
632
633        if matches!(item, Item::Gap(_)) {
634            let removed = self.delete_clips_at_indices(vec![(track_index, item_index)], false);
635            if !removed.is_empty() {
636                self.sanitize();
637            }
638            return removed;
639        }
640
641        let Some(clips_to_delete) = self.delete_item_group_targets(item_id, true) else {
642            return Vec::new();
643        };
644        if clips_to_delete.is_empty() {
645            return Vec::new();
646        }
647
648        let column_start = self.children[track_index].start_time_of_item(item_index);
649        let column_end = column_start + item.duration().max(0.0);
650        let sync_groups_behind = self.sync_groups_behind_clips(&clips_to_delete);
651        let tracks_to_mutate =
652            self.collapse_mutation_tracks(&clips_to_delete, &sync_groups_behind);
653
654        let mut removed = self.delete_clips_at_indices(clips_to_delete, false);
655        let mutated_tracks: HashSet<usize> = removed.iter().map(|(ti, _)| *ti).collect();
656
657        let mut pending_range_tracks: Vec<_> = tracks_to_mutate
658            .into_iter()
659            .filter(|ti| !mutated_tracks.contains(ti))
660            .collect();
661        pending_range_tracks.sort_unstable();
662        pending_range_tracks.dedup();
663
664        for ti in pending_range_tracks {
665            let Some(track) = self.children.get_mut(ti) else {
666                continue;
667            };
668            for removed_item in track.delete_range(column_start, column_end, false) {
669                removed.push((ti, removed_item));
670            }
671        }
672
673        if !removed.is_empty() {
674            self.sanitize();
675        }
676        removed
677    }
678
679    fn delete_sync_clips(
680        &mut self,
681        sync_clips_id: i64,
682        replace_with_gap: bool,
683    ) -> Vec<(usize, Item)> {
684        self.delete_clips_at_indices(self.synced_clips_targets(sync_clips_id), replace_with_gap)
685    }
686
687    fn clip_target(&self, item_id: &str) -> Option<(usize, usize)> {
688        let (track_index, item_index, item) = self.get_item(item_id)?;
689        matches!(item, Item::Clip(_)).then_some((track_index, item_index))
690    }
691
692    fn synced_clips_targets(&self, sync_clips_id: i64) -> Vec<(usize, usize)> {
693        let mut targets = Vec::new();
694        for (ti, track) in self.children.iter().enumerate() {
695            for (ii, item) in track.items.iter().enumerate() {
696                if let Item::Clip(clip) = item {
697                    if resolve_sync_clips_id(&clip.metadata) == Some(sync_clips_id) {
698                        targets.push((ti, ii));
699                    }
700                }
701            }
702        }
703        targets
704    }
705
706    fn tellers_group_targets(&self, group_id: i64) -> Vec<(usize, usize)> {
707        let mut targets = Vec::new();
708        for (ti, track) in self.children.iter().enumerate() {
709            for (ii, item) in track.items.iter().enumerate() {
710                if let Item::Clip(clip) = item {
711                    if resolve_tellers_group_id(&clip.metadata) == Some(group_id) {
712                        targets.push((ti, ii));
713                    }
714                }
715            }
716        }
717        targets
718    }
719
720    /// Build the list of moves for a grouped move of `item_id` to
721    /// `dest_track_id` / `dest_time`. Returns `None` when the selected clip is
722    /// not part of a Tellers group.
723    ///
724    /// Each entry is `(representative_item_id, dest_track_id, dest_time)`. A
725    /// sub-unit is one aligned sync column or a single clip, including offset
726    /// linked partners. Only one representative per aligned column is emitted. The selected
727    /// clip's sub-unit moves to the requested destination track; every other
728    /// sub-unit shifts by the same delta on its own track.
729    ///
730    /// The transaction lifts all sources before ordering destination insertions.
731    fn tellers_group_move_plan(
732        &self,
733        item_id: &str,
734        dest_track_id: &str,
735        dest_time: Seconds,
736    ) -> Option<Vec<(String, String, Seconds)>> {
737        let (selected_track_index, selected_item_index, selected_item) = self.get_item(item_id)?;
738        item_tellers_group_id(selected_item)?;
739        let selected_start =
740            self.children[selected_track_index].start_time_of_item(selected_item_index);
741        let delta = dest_time - selected_start;
742
743        let column_ids = |id: &str| -> Vec<String> {
744            self.synced_move_items(id)
745                .map(|items| items.into_iter().filter_map(|m| m.item.get_id()).collect())
746                .unwrap_or_else(|| vec![id.to_string()])
747        };
748
749        // (rep_id, dest_track_id, dest_time). The selected sub-unit
750        // goes to the requested destination track; the rest stay on their own.
751        let mut moves = vec![(
752            item_id.to_string(),
753            dest_track_id.to_string(),
754            dest_time,
755        )];
756        let mut seen_ids: HashSet<String> = column_ids(item_id).into_iter().collect();
757        for (track_index, item_index) in self.delete_item_group_targets(item_id, true)? {
758            let Some(Item::Clip(clip)) = self
759                .children
760                .get(track_index)
761                .and_then(|track| track.items.get(item_index))
762            else {
763                continue;
764            };
765            let Some(rep_id) = clip.get_id() else {
766                continue;
767            };
768            if seen_ids.contains(&rep_id) {
769                continue;
770            }
771            seen_ids.extend(column_ids(&rep_id));
772            let Some(track_id) = self.children[track_index].get_id() else {
773                continue;
774            };
775            let rep_start = self.children[track_index].start_time_of_item(item_index);
776            moves.push((rep_id, track_id, rep_start + delta));
777        }
778
779        Some(moves)
780    }
781
782    fn sync_column_targets_at(
783        &self,
784        sync_clips_id: i64,
785        column_start: Seconds,
786        column_duration: Seconds,
787    ) -> Vec<(usize, usize)> {
788        self.synced_clips_targets(sync_clips_id)
789            .into_iter()
790            .filter(|(track_index, item_index)| {
791                self.item_occupies_column(*track_index, *item_index, column_start, column_duration)
792            })
793            .collect()
794    }
795
796    fn is_independent_sync_column(
797        &self,
798        sync_clips_id: i64,
799        column_start: Seconds,
800        column_duration: Seconds,
801    ) -> bool {
802        self.sync_column_targets_at(sync_clips_id, column_start, column_duration)
803            .len()
804            > 1
805    }
806
807    fn item_is_unsynced(item: &Item) -> bool {
808        match item {
809            Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata).is_none(),
810            Item::Gap(_) => true,
811        }
812    }
813
814    /// Every track in the cluster has an unsynced item at `column_start` with `duration`.
815    /// This is the column shape produced by `insert_synced_item_at_time` padding.
816    fn is_cluster_column_at(
817        &self,
818        cluster: &[usize],
819        column_start: Seconds,
820        duration: Seconds,
821    ) -> bool {
822        cluster.len() > 1
823            && cluster.iter().all(|&track_index| {
824                let Some(track) = self.children.get(track_index) else {
825                    return false;
826                };
827                let Some(item_index) = track.get_item_at_time(column_start) else {
828                    return false;
829                };
830                (track.start_time_of_item(item_index) - column_start).abs() <= EPS
831                    && Self::item_is_unsynced(&track.items[item_index])
832                    && (track.items[item_index].duration() - duration).abs() <= EPS
833            })
834    }
835
836    /// Resize an insert-style cluster column: unsynced items at the same start on
837    /// every bound track, then realign synced assets across the cluster.
838    fn apply_cluster_column_duration_change(
839        &mut self,
840        item_id: &str,
841        cluster: &[usize],
842        column_start: Seconds,
843        old_duration: Seconds,
844        new_duration: Seconds,
845        source_start_time: Seconds,
846        clamp_to_media: bool,
847    ) -> bool {
848        for &track_index in cluster {
849            let Some(track) = self.children.get_mut(track_index) else {
850                return false;
851            };
852            let Some(item_index) = track.get_item_at_time(column_start) else {
853                return false;
854            };
855            if (track.start_time_of_item(item_index) - column_start).abs() > EPS {
856                return false;
857            }
858            let item = &mut track.items[item_index];
859            if !Self::item_is_unsynced(item) {
860                return false;
861            }
862            if (item.duration() - old_duration).abs() > EPS {
863                return false;
864            }
865            if item.get_id().as_deref() == Some(item_id) {
866                if let Item::Clip(clip) = item {
867                    let _ = clip;
868                    set_item_source_start(item, source_start_time);
869                }
870            }
871            item.set_duration(new_duration);
872            if clamp_to_media {
873                item.clamp_to_active_available_range();
874            }
875            track.sanitize_preserving_all_gap_track();
876        }
877
878        true
879    }
880
881    fn synced_clip_states(&self) -> HashMap<String, SyncedClipState> {
882        let mut states = HashMap::new();
883        for (track_index, track) in self.children.iter().enumerate() {
884            for (item_index, item) in track.items.iter().enumerate() {
885                let Item::Clip(clip) = item else {
886                    continue;
887                };
888                let Some(sync_clips_id) = resolve_sync_clips_id(&clip.metadata) else {
889                    continue;
890                };
891                let Some(id) = item.get_id() else {
892                    continue;
893                };
894                states.insert(
895                    id,
896                    SyncedClipState {
897                        track_index,
898                        start: track.start_time_of_item(item_index),
899                        duration: item.duration().max(0.0),
900                        source_start: item_source_start(item),
901                        sync_clips_id,
902                    },
903                );
904            }
905        }
906        states
907    }
908
909    fn cleanup_singleton_sync_clips(&mut self, sync_clips_ids: &[i64]) -> usize {
910        let mut count = 0;
911        let mut seen = HashSet::new();
912        for sync_clips_id in sync_clips_ids {
913            if !seen.insert(*sync_clips_id) {
914                continue;
915            }
916            let targets = self.synced_clips_targets(*sync_clips_id);
917            if targets.len() > 1 {
918                continue;
919            }
920            for (track_index, item_index) in targets {
921                let Some(Item::Clip(clip)) = self
922                    .children
923                    .get_mut(track_index)
924                    .and_then(|track| track.items.get_mut(item_index))
925                else {
926                    continue;
927                };
928                if remove_resolve_sync_clips_id(&mut clip.metadata) {
929                    count += 1;
930                }
931            }
932        }
933        count
934    }
935
936    fn set_item_sync_clips(item: &mut Item, sync_clips_id: Option<i64>) {
937        let Item::Clip(clip) = item else {
938            return;
939        };
940        if let Some(sync_clips_id) = sync_clips_id {
941            set_resolve_sync_clips_id(&mut clip.metadata, sync_clips_id);
942        } else {
943            remove_resolve_sync_clips_id(&mut clip.metadata);
944        }
945    }
946
947    pub(super) fn new_numbered_track(&self, kind: TrackKind) -> Track {
948        let prefix = match kind {
949            TrackKind::Audio => "A",
950            TrackKind::Video => "V",
951            TrackKind::Other => "T",
952        };
953        let used: HashSet<_> = self
954            .children
955            .iter()
956            .flat_map(|track| [track.get_id(), track.name.clone()])
957            .flatten()
958            .collect();
959        let id = (1..=99)
960            .map(|index| format!("{prefix}{index}"))
961            .find(|candidate| !used.contains(candidate))
962            .unwrap_or_else(crate::types::gen_hex_id_12);
963        let mut track = Track::new(kind, Some(id.clone()));
964        track.name = Some(id);
965        track.set_auto_created(true);
966        track
967    }
968
969    fn prepare_synced_item(
970        item: Item,
971        duration: Seconds,
972        sync_clips_id: Option<i64>,
973        used_ids: &mut HashSet<String>,
974    ) -> Option<(Item, String)> {
975        let Item::Clip(mut clip) = item else {
976            return None;
977        };
978        clip.source_range.duration.set_from_seconds(duration);
979        clamp_clip_to_active_available_range(&mut clip);
980        let clamped_duration = clip.source_range.duration.to_seconds();
981        if clamped_duration + EPS < duration || clamped_duration <= EPS
982        {
983            return None;
984        }
985
986        let mut item = Item::Clip(clip);
987        Self::set_item_sync_clips(&mut item, sync_clips_id);
988        let id = Self::ensure_unique_item_id(&mut item, used_ids);
989        Some((item, id))
990    }
991
992    fn prepare_synced_item_preserve_duration(
993        item: Item,
994        sync_clips_id: Option<i64>,
995        used_ids: &mut HashSet<String>,
996    ) -> Option<(Item, String)> {
997        let Item::Clip(mut clip) = item else {
998            return None;
999        };
1000        clamp_clip_to_active_available_range(&mut clip);
1001        if clip.source_range.duration.to_seconds() <= EPS {
1002            return None;
1003        }
1004
1005        let mut item = Item::Clip(clip);
1006        Self::set_item_sync_clips(&mut item, sync_clips_id);
1007        let id = Self::ensure_unique_item_id(&mut item, used_ids);
1008        Some((item, id))
1009    }
1010
1011    fn synced_insert_column_span(primary_duration: Seconds, inputs: &SyncedInputs) -> Seconds {
1012        let mut span = primary_duration;
1013        for item in &inputs.audio {
1014            if let Some(duration) = Self::sanitized_clip_duration(item) {
1015                span = span.max(duration);
1016            }
1017        }
1018        if let Some(video_item) = &inputs.video {
1019            if let Some(duration) = Self::sanitized_clip_duration(video_item) {
1020                span = span.max(duration);
1021            }
1022        }
1023        span
1024    }
1025
1026    fn sanitized_clip_duration(item: &Item) -> Option<Seconds> {
1027        let Item::Clip(mut clip) = item.clone() else {
1028            return None;
1029        };
1030        clamp_clip_to_active_available_range(&mut clip);
1031        let duration = clip.source_range.duration.to_seconds().max(0.0);
1032        (duration > EPS).then_some(duration)
1033    }
1034
1035    fn synced_inputs_match_duration(duration: Seconds, inputs: &SyncedInputs) -> bool {
1036        for audio_item in &inputs.audio {
1037            let Some(audio_duration) = Self::sanitized_clip_duration(audio_item) else {
1038                return false;
1039            };
1040            if (audio_duration - duration).abs() > EPS {
1041                return false;
1042            }
1043        }
1044        if let Some(video_item) = &inputs.video {
1045            let Some(video_duration) = Self::sanitized_clip_duration(video_item) else {
1046                return false;
1047            };
1048            if (video_duration - duration).abs() > EPS {
1049                return false;
1050            }
1051        }
1052
1053        true
1054    }
1055
1056    fn normalize_synced_inputs(
1057        synced_audio_clips: Option<Vec<Item>>,
1058        synced_video_clip: Option<Item>,
1059    ) -> SyncedInputs {
1060        let mut inputs = SyncedInputs {
1061            audio: synced_audio_clips.unwrap_or_default(),
1062            video: synced_video_clip,
1063        };
1064        for item in &mut inputs.audio {
1065            item.clamp_to_active_available_range();
1066        }
1067        if let Some(video) = &mut inputs.video {
1068            video.clamp_to_active_available_range();
1069        }
1070        inputs
1071    }
1072
1073    pub(super) fn shift_insert_track_indices_after_create(
1074        inserted_at: usize,
1075        dest_track_index: &mut usize,
1076        cluster: &mut [usize],
1077        audio_slots: &mut [usize],
1078        created_track_indices: &mut [usize],
1079    ) {
1080        if inserted_at <= *dest_track_index {
1081            *dest_track_index += 1;
1082        }
1083        for index in cluster.iter_mut() {
1084            if *index >= inserted_at {
1085                *index += 1;
1086            }
1087        }
1088        for index in audio_slots.iter_mut() {
1089            if *index >= inserted_at {
1090                *index += 1;
1091            }
1092        }
1093        for index in created_track_indices.iter_mut() {
1094            if *index >= inserted_at {
1095                *index += 1;
1096            }
1097        }
1098    }
1099
1100    /// Sync ids of the clips on `track_index` overlapping `[start, end]`.
1101    pub(super) fn sync_clips_ids_in_range(
1102        &self,
1103        track_index: usize,
1104        start: Seconds,
1105        end: Seconds,
1106    ) -> HashSet<i64> {
1107        let mut ids = HashSet::new();
1108        let Some(track) = self.children.get(track_index) else {
1109            return ids;
1110        };
1111        let mut pos: Seconds = 0.0;
1112        for item in &track.items {
1113            let item_start = pos;
1114            let item_end = pos + item.duration().max(0.0);
1115            pos = item_end;
1116            if item_end <= start + EPS || item_start >= end - EPS {
1117                continue;
1118            }
1119            if let Item::Clip(clip) = item {
1120                if let Some(sync_clips_id) = resolve_sync_clips_id(&clip.metadata) {
1121                    ids.insert(sync_clips_id);
1122                }
1123            }
1124        }
1125        ids
1126    }
1127
1128    /// True when every clip on `track_index` overlapping `[start, end]` belongs
1129    /// to one of `sync_clips_ids` (gaps never block).
1130    pub(super) fn range_only_holds_sync_clips(
1131        &self,
1132        track_index: usize,
1133        start: Seconds,
1134        end: Seconds,
1135        sync_clips_ids: &HashSet<i64>,
1136    ) -> bool {
1137        let Some(track) = self.children.get(track_index) else {
1138            return false;
1139        };
1140        let mut pos: Seconds = 0.0;
1141        for item in &track.items {
1142            let item_start = pos;
1143            let item_end = pos + item.duration().max(0.0);
1144            pos = item_end;
1145            if item_end <= start + EPS || item_start >= end - EPS {
1146                continue;
1147            }
1148            if let Item::Clip(clip) = item {
1149                if !resolve_sync_clips_id(&clip.metadata)
1150                    .is_some_and(|sync_clips_id| sync_clips_ids.contains(&sync_clips_id))
1151                {
1152                    return false;
1153                }
1154            }
1155        }
1156        true
1157    }
1158
1159    pub(super) fn track_sync_clips_ids(&self, track_index: usize) -> HashSet<i64> {
1160        let Some(track) = self.children.get(track_index) else {
1161            return HashSet::new();
1162        };
1163        track
1164            .items
1165            .iter()
1166            .filter_map(|item| match item {
1167                Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata),
1168                Item::Gap(_) => None,
1169            })
1170            .collect()
1171    }
1172
1173    pub(super) fn tracks_share_sync_clips(
1174        &self,
1175        left_track_index: usize,
1176        right_track_index: usize,
1177    ) -> bool {
1178        if left_track_index == right_track_index {
1179            return true;
1180        }
1181        let left_groups = self.track_sync_clips_ids(left_track_index);
1182        !left_groups.is_empty()
1183            && self
1184                .track_sync_clips_ids(right_track_index)
1185                .iter()
1186                .any(|sync_clips_id| left_groups.contains(sync_clips_id))
1187    }
1188
1189    fn flatten_boundary_for_sync_clips(
1190        &self,
1191        anchor_track_index: usize,
1192        sync_clips: &[i64],
1193    ) -> FlattenedBoundary {
1194        if anchor_track_index >= self.children.len() {
1195            return FlattenedBoundary {
1196                track_indices: Vec::new(),
1197            };
1198        }
1199
1200        let mut track_indices = Vec::new();
1201        match self.children[anchor_track_index].kind {
1202            TrackKind::Video => {
1203                for track_index in (0..anchor_track_index).rev() {
1204                    if self.children[track_index].kind != TrackKind::Audio {
1205                        break;
1206                    }
1207                    if track_is_empty_boundary(&self.children[track_index]) {
1208                        track_indices.push(track_index);
1209                        break;
1210                    }
1211                    if !self
1212                        .track_matches_primary_sync_boundary(anchor_track_index, track_index)
1213                    {
1214                        break;
1215                    }
1216                    track_indices.push(track_index);
1217                }
1218                track_indices.reverse();
1219                track_indices.push(anchor_track_index);
1220                if !sync_clips.is_empty() {
1221                    for track_index in (anchor_track_index + 1)..self.children.len() {
1222                        if self.children[track_index].kind != TrackKind::Audio {
1223                            break;
1224                        }
1225                        if track_is_empty_boundary(&self.children[track_index]) {
1226                            track_indices.push(track_index);
1227                            break;
1228                        }
1229                        if !self
1230                            .track_matches_primary_sync_boundary(anchor_track_index, track_index)
1231                        {
1232                            break;
1233                        }
1234                        track_indices.push(track_index);
1235                    }
1236                }
1237            }
1238            TrackKind::Audio => {
1239                let mut audio_start = anchor_track_index;
1240                while audio_start > 0 && self.children[audio_start - 1].kind == TrackKind::Audio {
1241                    let previous = audio_start - 1;
1242                    if track_is_empty_boundary(&self.children[previous])
1243                        || !self.track_matches_primary_sync_boundary(anchor_track_index, previous)
1244                    {
1245                        break;
1246                    }
1247                    audio_start = previous;
1248                }
1249                if !sync_clips.is_empty() {
1250                    let previous_video_index = audio_start.checked_sub(1);
1251                    if let Some(video_index) = previous_video_index {
1252                        if self.children[video_index].kind == TrackKind::Video
1253                            && self
1254                                .track_matches_primary_sync_boundary(anchor_track_index, video_index)
1255                        {
1256                            track_indices.push(video_index);
1257                        }
1258                    }
1259                }
1260                for track_index in audio_start..self.children.len() {
1261                    if self.children[track_index].kind != TrackKind::Audio {
1262                        break;
1263                    }
1264                    if track_index != anchor_track_index
1265                        && !track_is_empty_boundary(&self.children[track_index])
1266                        && !self.track_matches_primary_sync_boundary(
1267                            anchor_track_index,
1268                            track_index,
1269                        )
1270                    {
1271                        break;
1272                    }
1273                    track_indices.push(track_index);
1274                    if track_index != anchor_track_index
1275                        && track_is_empty_boundary(&self.children[track_index])
1276                    {
1277                        break;
1278                    }
1279                }
1280                let video_index = track_indices.last().copied().unwrap_or(anchor_track_index) + 1;
1281                if video_index < self.children.len()
1282                    && self.children[video_index].kind == TrackKind::Video
1283                    && self.track_matches_primary_sync_boundary(anchor_track_index, video_index)
1284                {
1285                    track_indices.push(video_index);
1286                }
1287            }
1288            TrackKind::Other => track_indices.push(anchor_track_index),
1289        }
1290
1291        FlattenedBoundary { track_indices }
1292    }
1293
1294    fn track_matches_primary_sync_boundary(
1295        &self,
1296        primary_track_index: usize,
1297        candidate_track_index: usize,
1298    ) -> bool {
1299        self.tracks_share_sync_clips(primary_track_index, candidate_track_index)
1300    }
1301
1302    fn boundary_track_indices_for_anchors(
1303        &self,
1304        sync_clips: &[i64],
1305        anchor_track_indices: &[usize],
1306        excluded_track_indices: &[usize],
1307    ) -> Vec<usize> {
1308        let mut track_indices = Vec::new();
1309        for anchor_track_index in anchor_track_indices {
1310            for track_index in &self
1311                .flatten_boundary_for_sync_clips(*anchor_track_index, sync_clips)
1312                .track_indices
1313            {
1314                if excluded_track_indices.contains(track_index) {
1315                    continue;
1316                }
1317                if !track_indices.contains(track_index) {
1318                    track_indices.push(*track_index);
1319                }
1320            }
1321        }
1322        track_indices
1323    }
1324
1325    /// After a resize on `modified_track_indices`, make every sync column that
1326    /// changed there carry the same change on its partner tracks. The change
1327    /// is read from the clip itself (or its surviving fragment), never from
1328    /// the overlap policy, so partners mirror what actually happened:
1329    ///
1330    /// - a clip that moved by `start_delta` with its length kept (it was
1331    ///   pushed, or pulled back) ripples its partner tracks by the same delta
1332    ///   at its position, once per track and position, so unrelated content
1333    ///   after it on the partner track moves along as it did on the primary;
1334    /// - a clip that got shorter inside its old span (trimmed at either end)
1335    ///   is trimmed in place on the partners, gaps filling what it left and
1336    ///   the media offset following a head trim;
1337    /// - a clip that grew past its old end overrides what follows on the
1338    ///   partners, as it did on the primary.
1339    fn sync_changed_groups_after_resize(
1340        &mut self,
1341        before_states: &HashMap<String, SyncedClipState>,
1342        modified_track_indices: &[usize],
1343        excluded_ids: &HashSet<String>,
1344        _overlap_policy: OverlapPolicy,
1345    ) -> bool {
1346        struct GroupChange {
1347            sync_clips_id: i64,
1348            primary_start: Seconds,
1349            start_delta: Seconds,
1350            old_duration: Seconds,
1351            new_duration: Seconds,
1352            source_delta: Seconds,
1353        }
1354        let mut seen = HashSet::new();
1355        let mut changes: Vec<GroupChange> = Vec::new();
1356        let mut removed_groups: Vec<i64> = Vec::new();
1357        for (id, before) in before_states {
1358            if excluded_ids.contains(id) || !modified_track_indices.contains(&before.track_index) {
1359                continue;
1360            }
1361            let Some((track_index, item_index, item)) = self
1362                .get_item(id)
1363                .or_else(|| self.surviving_sync_fragment(before))
1364            else {
1365                // The clip was wiped out entirely: its column goes with it.
1366                if seen.insert(before.sync_clips_id) {
1367                    removed_groups.push(before.sync_clips_id);
1368                }
1369                continue;
1370            };
1371            let start = self.children[track_index].start_time_of_item(item_index);
1372            let duration = item.duration().max(0.0);
1373            let start_delta = start - before.start;
1374            if (start_delta.abs() > EPS || (duration - before.duration).abs() > EPS)
1375                && seen.insert(before.sync_clips_id)
1376            {
1377                changes.push(GroupChange {
1378                    sync_clips_id: before.sync_clips_id,
1379                    primary_start: before.start,
1380                    start_delta,
1381                    old_duration: before.duration,
1382                    new_duration: duration,
1383                    source_delta: item_source_start(item) - before.source_start,
1384                });
1385            }
1386        }
1387        changes.sort_by(|a, b| a.primary_start.total_cmp(&b.primary_start));
1388
1389        // Partners of a removed column leave a gap where they were, as the
1390        // overridden clip did on the primary track.
1391        for sync_clips_id in removed_groups {
1392            let partner_ids: Vec<String> = before_states
1393                .iter()
1394                .filter(|(_, state)| {
1395                    state.sync_clips_id == sync_clips_id
1396                        && !modified_track_indices.contains(&state.track_index)
1397                })
1398                .map(|(id, _)| id.clone())
1399                .collect();
1400            for partner_id in partner_ids {
1401                let Some((track_index, item_index, _)) = self.get_item(&partner_id) else {
1402                    continue;
1403                };
1404                self.children[track_index].delete_clip(item_index, true);
1405                self.children[track_index].sanitize_preserving_all_gap_track();
1406            }
1407        }
1408
1409        // Ripple already applied on a partner track: (from time, delta).
1410        let mut applied: HashMap<usize, Vec<(Seconds, Seconds)>> = HashMap::new();
1411        for change in changes {
1412            let partners: Vec<(String, SyncedClipState)> = before_states
1413                .iter()
1414                .filter(|(_, state)| {
1415                    state.sync_clips_id == change.sync_clips_id
1416                        && !modified_track_indices.contains(&state.track_index)
1417                })
1418                .map(|(id, state)| (id.clone(), state.clone()))
1419                .collect();
1420            for (partner_id, before) in partners {
1421                let Some((track_index, _, _)) = self.get_item(&partner_id) else {
1422                    continue;
1423                };
1424                let old_start = before.start
1425                    + applied
1426                        .get(&track_index)
1427                        .map(|gaps| {
1428                            gaps.iter()
1429                                .filter(|(from, _)| *from <= before.start + EPS)
1430                                .map(|(_, delta)| *delta)
1431                                .sum::<Seconds>()
1432                        })
1433                        .unwrap_or(0.0);
1434                let old_end = old_start + before.duration;
1435                let new_end_rel = change.start_delta + change.new_duration;
1436                let trimmed_in_place = change.start_delta >= -EPS
1437                    && new_end_rel <= change.old_duration + EPS
1438                    && change.new_duration < change.old_duration - EPS;
1439
1440                if trimmed_in_place {
1441                    let Some((_, item_index, _)) = self.get_item(&partner_id) else {
1442                        continue;
1443                    };
1444                    if change.start_delta > EPS {
1445                        self.resize_synced_clips_with_leading_gap(
1446                            vec![(track_index, item_index)],
1447                            change.start_delta,
1448                            change.new_duration,
1449                        );
1450                        // The leading gap moved the clip one slot to the right.
1451                        let trailing = change.old_duration - new_end_rel;
1452                        if trailing > EPS {
1453                            self.resize_synced_clips_with_trailing_gap(
1454                                vec![(track_index, item_index + 1)],
1455                                change.new_duration,
1456                            );
1457                            // resize_synced_clips_with_trailing_gap adds the gap
1458                            // for whatever the clip exceeds `duration`; the clip
1459                            // is already at `new_duration`, so add it directly.
1460                            self.children[track_index]
1461                                .items
1462                                .insert(item_index + 2, Item::Gap(Gap::make_gap(trailing)));
1463                        }
1464                    } else {
1465                        self.resize_synced_clips_with_trailing_gap(
1466                            vec![(track_index, item_index)],
1467                            change.new_duration,
1468                        );
1469                    }
1470                    if change.source_delta.abs() > EPS && change.start_delta <= EPS {
1471                        let Some((t, i, _)) = self.get_item(&partner_id) else {
1472                            continue;
1473                        };
1474                        let shifted = item_source_start(&self.children[t].items[i]) + change.source_delta;
1475                        set_item_source_start(&mut self.children[t].items[i], shifted);
1476                    }
1477                    self.children[track_index].sanitize_preserving_all_gap_track();
1478                    continue;
1479                }
1480
1481                // Ripple: the whole column moved (pushed or pulled); unrelated
1482                // content after it on the partner track follows, once per track.
1483                if change.start_delta.abs() > EPS {
1484                    let delta = change.start_delta;
1485                    let track = &mut self.children[track_index];
1486                    if delta > 0.0 {
1487                        track.insert_at_time(
1488                            old_start,
1489                            Item::Gap(Gap::make_gap(delta)),
1490                            OverlapPolicy::Push,
1491                            InsertPolicy::SplitAndInsert,
1492                        );
1493                    } else {
1494                        let from = (old_start + delta).max(0.0);
1495                        track.delete_range(from, old_start, false);
1496                    }
1497                    applied
1498                        .entry(track_index)
1499                        .or_default()
1500                        .push((before.start, delta));
1501                }
1502
1503                // Length: shorter fills with a gap in place, longer overrides what follows.
1504                let Some((t, i, item)) = self.get_item(&partner_id) else {
1505                    continue;
1506                };
1507                let current_start = self.children[t].start_time_of_item(i);
1508                if change.new_duration < change.old_duration - EPS {
1509                    self.resize_synced_clips_with_trailing_gap(vec![(t, i)], change.new_duration);
1510                } else if change.new_duration > change.old_duration + EPS {
1511                    let mut item = item.clone();
1512                    item.set_duration(change.new_duration);
1513                    replace_track_range_with_item(
1514                        &mut self.children[t],
1515                        current_start,
1516                        current_start + change.new_duration,
1517                        item,
1518                    );
1519                }
1520                if change.source_delta.abs() > EPS {
1521                    let Some((t, i, _)) = self.get_item(&partner_id) else {
1522                        continue;
1523                    };
1524                    let shifted = item_source_start(&self.children[t].items[i]) + change.source_delta;
1525                    set_item_source_start(&mut self.children[t].items[i], shifted);
1526                }
1527                let _ = old_end;
1528                self.children[track_index].sanitize_preserving_all_gap_track();
1529            }
1530        }
1531        true
1532    }
1533
1534    /// The fragment that survived an edit of a synced clip whose id is gone:
1535    /// a clip on the same track with the same sync id overlapping the clip's
1536    /// former range.
1537    fn surviving_sync_fragment(&self, before: &SyncedClipState) -> Option<(usize, usize, &Item)> {
1538        let track = self.children.get(before.track_index)?;
1539        let old_end = before.start + before.duration;
1540        let mut position: Seconds = 0.0;
1541        for (item_index, item) in track.items.iter().enumerate() {
1542            let item_start = position;
1543            let item_end = position + item.duration().max(0.0);
1544            position = item_end;
1545            let Item::Clip(clip) = item else {
1546                continue;
1547            };
1548            if resolve_sync_clips_id(&clip.metadata) != Some(before.sync_clips_id) {
1549                continue;
1550            }
1551            if item_end > before.start + EPS && item_start < old_end - EPS {
1552                return Some((before.track_index, item_index, item));
1553            }
1554        }
1555        None
1556    }
1557
1558    fn insert_synced_item_at_time(
1559        &mut self,
1560        dest_track_index: usize,
1561        dest_time: Seconds,
1562        dest_index: Option<usize>,
1563        item: Item,
1564        overlap_policy: OverlapPolicy,
1565        insert_policy: InsertPolicy,
1566        synced_audio_clips: Option<Vec<Item>>,
1567        synced_video_clip: Option<Item>,
1568        column_video_placed_separately: bool,
1569        preferred_video_track_id: Option<&str>,
1570        preferred_audio_track_indices: Option<&[usize]>,
1571        move_source_track_indices: Option<&[usize]>,
1572    ) -> Option<InsertItemAtTimeResult> {
1573        let mut primary_item = item;
1574        primary_item.clamp_to_active_available_range();
1575        let synced_inputs =
1576            Self::normalize_synced_inputs(synced_audio_clips, synced_video_clip);
1577        let has_synced_clips =
1578            !synced_inputs.audio.is_empty() || synced_inputs.video.is_some();
1579        if has_synced_clips && !matches!(primary_item, Item::Clip(_)) {
1580            return None;
1581        }
1582        if matches!(synced_inputs.video, Some(Item::Gap(_))) {
1583            return None;
1584        }
1585        if !self.column_may_land_on(
1586            dest_track_index,
1587            matches!(primary_item, Item::Gap(_)),
1588            synced_inputs.video.is_some() || column_video_placed_separately,
1589        ) {
1590            return None;
1591        }
1592
1593        let modified_duration = primary_item.duration().max(0.0);
1594        if modified_duration <= EPS {
1595            return None;
1596        }
1597        let column_span = Self::synced_insert_column_span(modified_duration, &synced_inputs);
1598
1599        // Resolved start on the primary track; every column member lands here.
1600        let start = if let Some(dest_index) = dest_index {
1601            self.children[dest_track_index].start_time_of_item(dest_index)
1602        } else {
1603            insertion_start_or_end_for_policy(
1604                &self.children[dest_track_index],
1605                dest_time,
1606                insert_policy,
1607            )?
1608        };
1609
1610        let backup = self.clone();
1611        let mut used_ids = self.collect_timeline_ids();
1612        let sync_clips_id = has_synced_clips.then(|| self.next_sync_clips_id());
1613        let mut created_track_indices = Vec::new();
1614
1615        // Pick the best sync cluster for the destination: prefer video when inserting
1616        // on a video track or supplying a synced video clip, otherwise prefer most tracks.
1617        let prefer_cluster_with_video = self.children.get(dest_track_index).is_some_and(|track| {
1618            track.kind == TrackKind::Video
1619        }) || synced_inputs.video.is_some();
1620        let mut dest_track_index = dest_track_index;
1621        let mut cluster = self.resolve_insert_cluster(
1622            dest_track_index,
1623            prefer_cluster_with_video,
1624            if synced_inputs.video.is_some() {
1625                preferred_video_track_id
1626            } else {
1627                None
1628            },
1629        );
1630
1631        // The video partner first: it owns the column, so the audio slots come
1632        // from its association even when an audio clip is the primary (a moved
1633        // audio partner).
1634        let mut audio_slots: Vec<usize> = Vec::new();
1635        let mut synced_video_clip_id = None;
1636        let mut column_video = None;
1637        // An audio-only column on a partner track of an audio group is recorded
1638        // on the group's owner; on a video track or a free track the
1639        // destination itself owns the column.
1640        let mut owner_track_index = self.group_owner_of(dest_track_index);
1641        if let Some(video_item) = synced_inputs.video {
1642            let video_span = Self::sanitized_clip_duration(&video_item).unwrap_or(column_span);
1643            let track_count_before = self.children.len();
1644            let video_track_index = self.pick_video_partner_track(
1645                dest_track_index,
1646                start,
1647                start + video_span,
1648                preferred_video_track_id,
1649                &[],
1650                sync_clips_id,
1651                overlap_policy,
1652                insert_policy,
1653                &mut created_track_indices,
1654            );
1655            let Some(video_track_index) = video_track_index else {
1656                *self = backup;
1657                return None;
1658            };
1659            if self.children.len() > track_count_before {
1660                Self::shift_insert_track_indices_after_create(
1661                    video_track_index,
1662                    &mut dest_track_index,
1663                    &mut cluster,
1664                    &mut audio_slots,
1665                    &mut [],
1666                );
1667            }
1668            let Some((video_item, video_id)) = Self::prepare_synced_item_preserve_duration(
1669                video_item,
1670                sync_clips_id,
1671                &mut used_ids,
1672            ) else {
1673                *self = backup;
1674                return None;
1675            };
1676            synced_video_clip_id = Some(video_id);
1677            column_video = Some((video_track_index, video_item));
1678            owner_track_index = video_track_index;
1679        }
1680
1681        // The owner's associated tracks join the working cluster so insert
1682        // propagation applies to them even while they are empty.
1683        if has_synced_clips {
1684            for track_index in self
1685                .associated_track_indices(dest_track_index)
1686                .into_iter()
1687                .chain(self.associated_track_indices(owner_track_index))
1688                .chain(std::iter::once(owner_track_index))
1689            {
1690                if !cluster.contains(&track_index) {
1691                    cluster.push(track_index);
1692                }
1693            }
1694            cluster.sort_unstable();
1695        }
1696
1697        // Audio slots: the owner's associated tracks, then created tracks (see
1698        // `stack_partner_tracks`). Override replaces whatever sits under the
1699        // column on the destination track together with the partners of those
1700        // clips; an associated track holding anything else there (unrelated
1701        // music, another video track's audio) must not lose it, so it is not a
1702        // slot for this column.
1703        let audio_durations: Vec<Seconds> = synced_inputs
1704            .audio
1705            .iter()
1706            .map(|item| Self::sanitized_clip_duration(item).unwrap_or(modified_duration))
1707            .collect();
1708        let column_end = start + column_span;
1709        let overridden_sync_ids: HashSet<i64> = if overlap_policy == OverlapPolicy::Override {
1710            self.sync_clips_ids_in_range(dest_track_index, start, column_end)
1711        } else {
1712            HashSet::new()
1713        };
1714        audio_slots = self.assign_audio_partner_slots(
1715            &mut dest_track_index,
1716            &mut owner_track_index,
1717            &mut cluster,
1718            &mut created_track_indices,
1719            start,
1720            &audio_durations,
1721            preferred_audio_track_indices,
1722            move_source_track_indices.unwrap_or(&[]),
1723            overlap_policy,
1724            &overridden_sync_ids,
1725        );
1726        if let Some((video_track_index, _)) = column_video.as_mut() {
1727            // Audio slot creation may have shifted the video track.
1728            *video_track_index = owner_track_index;
1729        }
1730
1731        // Primary clip/gap on the destination track.
1732        let (primary_item, primary_id) = match primary_item {
1733            Item::Clip(_) => Self::prepare_synced_item(
1734                primary_item,
1735                modified_duration,
1736                sync_clips_id,
1737                &mut used_ids,
1738            )?,
1739            Item::Gap(mut gap) => {
1740                gap.source_range.duration.set_from_seconds(modified_duration);
1741                let mut item = Item::Gap(gap);
1742                let id = Self::ensure_unique_item_id(&mut item, &mut used_ids);
1743                (item, id)
1744            }
1745        };
1746        let mut column = Vec::new();
1747
1748        // Audio clips onto their slots, in partner order.
1749        let mut audio_clips = Vec::new();
1750        for (audio_item, &audio_track_index) in synced_inputs.audio.into_iter().zip(&audio_slots) {
1751            let Some((audio_item, audio_id)) = Self::prepare_synced_item_preserve_duration(
1752                audio_item,
1753                sync_clips_id,
1754                &mut used_ids,
1755            ) else {
1756                *self = backup;
1757                return None;
1758            };
1759            column.push((audio_track_index, audio_item));
1760            audio_clips.push((audio_id, audio_track_index));
1761        }
1762        if let Some(video_placement) = column_video {
1763            column.push(video_placement);
1764        }
1765        column.sort_by_key(|(track_index, _)| *track_index);
1766
1767        let mut full_column = vec![(dest_track_index, primary_item)];
1768        full_column.extend(column);
1769        if overlap_policy == OverlapPolicy::Push {
1770            full_column.sort_by_key(|(track_index, _)| *track_index);
1771        } else {
1772            let (mut dest_items, mut partners): (Vec<_>, Vec<_>) = full_column
1773                .into_iter()
1774                .partition(|(track_index, _)| *track_index == dest_track_index);
1775            partners.sort_by_key(|(track_index, _)| *track_index);
1776            dest_items.extend(partners);
1777            full_column = dest_items;
1778        }
1779
1780        let mut insert_updates: Vec<(usize, TrackInsertResult)> = Vec::new();
1781        for (track_index, item) in full_column {
1782            let result = self.children[track_index].insert_at_time(
1783                start,
1784                item,
1785                overlap_policy,
1786                InsertPolicy::SplitAndInsert,
1787            );
1788            if !result.success {
1789                *self = backup;
1790                return None;
1791            }
1792            insert_updates.push((track_index, result));
1793        }
1794
1795        let update_refs: Vec<_> = insert_updates
1796            .iter()
1797            .map(|(track_index, result)| (*track_index, result))
1798            .collect();
1799        if !self.propagate_insert_to_cluster(
1800            start,
1801            modified_duration,
1802            overlap_policy,
1803            &update_refs,
1804            &cluster,
1805            sync_clips_id,
1806        ) {
1807            *self = backup;
1808            return None;
1809        }
1810        if overlap_policy == OverlapPolicy::Push
1811            && !self.propagate_push_to_cluster(start, modified_duration, &update_refs)
1812        {
1813            *self = backup;
1814            return None;
1815        }
1816
1817        self.sanitize_preserving_all_gap_tracks();
1818
1819        if sync_clips_id.is_some()
1820            && matches!(self.get_item(&primary_id), Some((_, _, Item::Clip(_))))
1821        {
1822            Some(InsertItemAtTimeResult::Synced(SyncedInsertResult {
1823                primary_clip_id: primary_id,
1824                audio_clips,
1825                synced_video_clip_id,
1826                sync_clips_id,
1827                created_track_indices,
1828            }))
1829        } else {
1830            Some(InsertItemAtTimeResult::ItemId(primary_id))
1831        }
1832    }
1833
1834    pub fn resize_item(
1835        &mut self,
1836        item_id: &str,
1837        new_start_time: Seconds,
1838        new_duration: Seconds,
1839        overlap_policy: OverlapPolicy,
1840        clamp_to_media: bool,
1841    ) -> bool {
1842        let Some((selected_track_index, selected_item_index, selected_item)) =
1843            self.get_item(item_id)
1844        else {
1845            return false;
1846        };
1847        let before_states = self.synced_clip_states();
1848        let target_ids: Vec<String> = match selected_item {
1849            Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata)
1850                .map(|sync_clips_id| {
1851                    self.synced_clips_targets(sync_clips_id)
1852                        .into_iter()
1853                        .filter_map(|(track_index, item_index)| {
1854                            self.children
1855                                .get(track_index)
1856                                .and_then(|track| track.items.get(item_index))
1857                                .and_then(Item::get_id)
1858                        })
1859                        .collect()
1860                })
1861                .filter(|ids: &Vec<String>| ids.len() > 1)
1862                .unwrap_or_else(|| vec![item_id.to_string()]),
1863            Item::Gap(_) => vec![item_id.to_string()],
1864        };
1865        if target_ids.is_empty() {
1866            return false;
1867        }
1868        if matches!(selected_item, Item::Gap(_)) {
1869            let backup = self.clone();
1870            let Some(track) = self.children.get_mut(selected_track_index) else {
1871                return false;
1872            };
1873            let Some(item) = track.items.get_mut(selected_item_index) else {
1874                return false;
1875            };
1876            item.set_duration(new_duration.max(0.0));
1877            track.sanitize_preserving_all_gap_track();
1878            if !self.sync_changed_groups_after_resize(
1879                &before_states,
1880                &[selected_track_index],
1881                &HashSet::new(),
1882                overlap_policy,
1883            ) {
1884                *self = backup;
1885                return false;
1886            }
1887            self.sanitize_preserving_all_gap_tracks();
1888            return true;
1889        }
1890        let excluded_ids: HashSet<_> = target_ids.iter().cloned().collect();
1891        let selected_start =
1892            self.children[selected_track_index].start_time_of_item(selected_item_index);
1893        let start_delta = new_start_time - selected_start;
1894
1895        let effective_duration = target_ids
1896            .iter()
1897            .filter_map(|id| {
1898                self.get_item(id).map(|(_, _, item)| {
1899                    resize_effective_duration(item, new_duration, clamp_to_media)
1900                })
1901            })
1902            .fold(new_duration.max(0.0), Seconds::min);
1903
1904        let backup = self.clone();
1905        if start_delta.abs() <= EPS {
1906            if overlap_policy != OverlapPolicy::Push {
1907                let mut resized_items = Vec::new();
1908                let mut modified_track_indices = Vec::new();
1909
1910                for id in &target_ids {
1911                    let Some((track_index, item_index, item)) = self.get_item(id) else {
1912                        *self = backup;
1913                        return false;
1914                    };
1915                    if matches!(item, Item::Gap(_)) {
1916                        resized_items.clear();
1917                        modified_track_indices.clear();
1918                        break;
1919                    }
1920
1921                    let old_start = self.children[track_index].start_time_of_item(item_index);
1922                    let old_duration = item.duration().max(0.0);
1923                    if effective_duration <= old_duration + EPS {
1924                        resized_items.clear();
1925                        modified_track_indices.clear();
1926                        break;
1927                    }
1928
1929                    let mut item = item.clone();
1930                    item.set_duration(effective_duration);
1931                    if clamp_to_media {
1932                        item.clamp_to_active_available_range();
1933                    }
1934                    resized_items.push((
1935                        track_index,
1936                        old_start,
1937                        old_start + effective_duration,
1938                        item,
1939                    ));
1940                    modified_track_indices.push(track_index);
1941                }
1942
1943                if !resized_items.is_empty() {
1944                    for (track_index, range_start, range_end, item) in resized_items {
1945                        let Some(track) = self.children.get_mut(track_index) else {
1946                            *self = backup;
1947                            return false;
1948                        };
1949                        replace_track_range_with_item(track, range_start, range_end, item);
1950                    }
1951                    modified_track_indices.sort_unstable();
1952                    modified_track_indices.dedup();
1953                    if !self.sync_changed_groups_after_resize(
1954                        &before_states,
1955                        &modified_track_indices,
1956                        &excluded_ids,
1957                        overlap_policy,
1958                    ) {
1959                        *self = backup;
1960                        return false;
1961                    }
1962                    self.sanitize_preserving_all_gap_tracks();
1963                    return true;
1964                }
1965            }
1966
1967            let mut modified_track_indices = Vec::new();
1968            for id in &target_ids {
1969                let Some((track_index, item_index, _)) = self.get_item(id) else {
1970                    *self = backup;
1971                    return false;
1972                };
1973                let Some(track) = self.children.get_mut(track_index) else {
1974                    *self = backup;
1975                    return false;
1976                };
1977                let Some(item) = track.items.get_mut(item_index) else {
1978                    *self = backup;
1979                    return false;
1980                };
1981                item.set_duration(effective_duration);
1982                if clamp_to_media {
1983                    item.clamp_to_active_available_range();
1984                }
1985                track.sanitize_preserving_all_gap_track();
1986                modified_track_indices.push(track_index);
1987            }
1988            modified_track_indices.sort_unstable();
1989            modified_track_indices.dedup();
1990            if !self.sync_changed_groups_after_resize(
1991                &before_states,
1992                &modified_track_indices,
1993                &excluded_ids,
1994                overlap_policy,
1995            ) {
1996                *self = backup;
1997                return false;
1998            }
1999            self.sanitize_preserving_all_gap_tracks();
2000            return true;
2001        }
2002
2003        if start_delta < -EPS {
2004            let mut resized_items = Vec::new();
2005            let mut modified_track_indices = Vec::new();
2006
2007            for id in &target_ids {
2008                let Some((track_index, item_index, item)) = self.get_item(id) else {
2009                    *self = backup;
2010                    return false;
2011                };
2012                let old_start = self.children[track_index].start_time_of_item(item_index);
2013                let target_start = old_start + start_delta;
2014                let old_duration = item.duration().max(0.0);
2015                let old_end = old_start + old_duration;
2016
2017                modified_track_indices.push(track_index);
2018                let mut item = item.clone();
2019                item.set_duration(effective_duration);
2020                if clamp_to_media {
2021                    item.clamp_to_active_available_range();
2022                }
2023                resized_items.push((
2024                    track_index,
2025                    item_index,
2026                    target_start,
2027                    old_duration,
2028                    old_end,
2029                    item,
2030                ));
2031            }
2032
2033            let mut removals = resized_items
2034                .iter()
2035                .map(|(track_index, item_index, _, old_duration, _, _)| {
2036                    (*track_index, *item_index, *old_duration)
2037                })
2038                .collect::<Vec<_>>();
2039            removals.sort_unstable_by(|a, b| (b.0, b.1).cmp(&(a.0, a.1)));
2040
2041            for (track_index, item_index, old_duration) in removals {
2042                let Some(track) = self.children.get_mut(track_index) else {
2043                    *self = backup;
2044                    return false;
2045                };
2046                if item_index >= track.items.len() {
2047                    *self = backup;
2048                    return false;
2049                }
2050                track.items.remove(item_index);
2051                track
2052                    .items
2053                    .insert(item_index, Item::Gap(Gap::make_gap(old_duration)));
2054            }
2055
2056            for (track_index, _, target_start, _, old_end, item) in resized_items {
2057                let Some(track) = self.children.get_mut(track_index) else {
2058                    *self = backup;
2059                    return false;
2060                };
2061                replace_track_range_with_item(track, target_start, old_end, item);
2062            }
2063            modified_track_indices.sort_unstable();
2064            modified_track_indices.dedup();
2065            if !self.sync_changed_groups_after_resize(
2066                &before_states,
2067                &modified_track_indices,
2068                &excluded_ids,
2069                overlap_policy,
2070            ) {
2071                *self = backup;
2072                return false;
2073            }
2074            self.sanitize_preserving_all_gap_tracks();
2075            return true;
2076        }
2077
2078        // start_delta > 0: the clip starts later. Within its old span (a trim
2079        // from the left) it is replaced in place: a gap, the shorter clip, and
2080        // a gap for whatever is left at the end; nothing else moves. Past its
2081        // old end it is lifted (a gap keeps the positions) and lands with the
2082        // overlap policy: Override cuts into what follows, Push moves it.
2083        let mut resized_items = Vec::new();
2084        let mut modified_track_indices = Vec::new();
2085        for id in &target_ids {
2086            let Some((track_index, item_index, item)) = self.get_item(id) else {
2087                *self = backup;
2088                return false;
2089            };
2090            modified_track_indices.push(track_index);
2091            let old_start = self.children[track_index].start_time_of_item(item_index);
2092            let old_end = old_start + item.duration().max(0.0);
2093            let target_start = old_start + start_delta;
2094            let mut item = item.clone();
2095            item.set_duration(effective_duration);
2096            if clamp_to_media {
2097                item.clamp_to_active_available_range();
2098            }
2099            resized_items.push((track_index, old_start, old_end, target_start, item));
2100        }
2101
2102        for (track_index, old_start, old_end, target_start, item) in resized_items {
2103            let Some(id) = item.get_id() else {
2104                *self = backup;
2105                return false;
2106            };
2107            let Some((_, item_index, _)) = self.get_item(&id) else {
2108                *self = backup;
2109                return false;
2110            };
2111            let new_end = target_start + item.duration().max(0.0);
2112            let Some(track) = self.children.get_mut(track_index) else {
2113                *self = backup;
2114                return false;
2115            };
2116            if new_end <= old_end + EPS {
2117                track.items.remove(item_index);
2118                let trailing = old_end - new_end;
2119                if trailing > EPS {
2120                    track
2121                        .items
2122                        .insert(item_index, Item::Gap(Gap::make_gap(trailing)));
2123                }
2124                track.items.insert(item_index, item);
2125                track
2126                    .items
2127                    .insert(item_index, Item::Gap(Gap::make_gap(start_delta)));
2128                track.sanitize_preserving_all_gap_track();
2129            } else if overlap_policy == OverlapPolicy::Override {
2130                track.items.remove(item_index);
2131                track
2132                    .items
2133                    .insert(item_index, Item::Gap(Gap::make_gap(old_end - old_start)));
2134                replace_track_range_with_item(track, target_start, new_end, item);
2135            } else {
2136                track.items.remove(item_index);
2137                track.sanitize_preserving_all_gap_track();
2138                track.insert_at_time(
2139                    target_start,
2140                    item,
2141                    overlap_policy,
2142                    InsertPolicy::SplitAndInsert,
2143                );
2144            }
2145        }
2146        modified_track_indices.sort_unstable();
2147        modified_track_indices.dedup();
2148        if !self.sync_changed_groups_after_resize(
2149            &before_states,
2150            &modified_track_indices,
2151            &excluded_ids,
2152            overlap_policy,
2153        ) {
2154            *self = backup;
2155            return false;
2156        }
2157        self.sanitize_preserving_all_gap_tracks();
2158        true
2159    }
2160
2161    pub fn modify_item(
2162        &mut self,
2163        item_id: &str,
2164        source_start_time: Seconds,
2165        duration: Seconds,
2166        clamp_to_media: bool,
2167        resize_from_start: bool,
2168        push_following: bool,
2169    ) -> bool {
2170        let Some((track_index, item_index, _)) = self.get_item(item_id) else {
2171            return false;
2172        };
2173
2174        let mut effective_source_start = source_start_time;
2175        let mut effective_duration = duration;
2176        if effective_source_start < 0.0 {
2177            effective_duration += effective_source_start;
2178            effective_source_start = 0.0;
2179        }
2180
2181        if effective_duration < 0.0 {
2182            let replace_with_gap =
2183                !matches!(self.children[track_index].items[item_index], Item::Gap(_));
2184            self.delete_item(item_id, replace_with_gap, true);
2185            self.sanitize_preserving_all_gap_tracks();
2186            return true;
2187        }
2188
2189        let old_timeline_start = self.children[track_index].start_time_of_item(item_index);
2190        let old_source_start = match &self.children[track_index].items[item_index] {
2191            Item::Clip(clip) => clip.source_range.start_time.to_seconds(),
2192            Item::Gap(_) => 0.0,
2193        };
2194        let old_duration = self.children[track_index].items[item_index].duration();
2195        let new_timeline_start =
2196            (old_timeline_start + effective_source_start - old_source_start).max(0.0);
2197        let is_gap = matches!(self.children[track_index].items[item_index], Item::Gap(_));
2198        let is_clip = matches!(self.children[track_index].items[item_index], Item::Clip(_));
2199        let mut source_delta = effective_source_start - old_source_start;
2200        if is_clip && resize_from_start && !push_following {
2201            let unclamped_timeline_start = old_timeline_start + source_delta;
2202            if unclamped_timeline_start < 0.0 {
2203                source_delta = -old_timeline_start;
2204                effective_source_start = old_source_start + source_delta;
2205                effective_duration = old_duration - source_delta;
2206            }
2207        }
2208        let effective_push_following =
2209            push_following || (is_gap && effective_duration < old_duration);
2210
2211        let cluster = self.boundary_group_indices(track_index);
2212        let is_cluster_column = self.is_cluster_column_at(
2213            &cluster,
2214            old_timeline_start,
2215            old_duration,
2216        );
2217        if is_cluster_column
2218            && (new_timeline_start - old_timeline_start).abs() <= EPS
2219            && (effective_duration - old_duration).abs() > EPS
2220        {
2221            let backup = self.clone();
2222            let before_states = self.synced_clip_states();
2223            if !self.apply_cluster_column_duration_change(
2224                item_id,
2225                &cluster,
2226                old_timeline_start,
2227                old_duration,
2228                effective_duration,
2229                effective_source_start,
2230                clamp_to_media,
2231            ) {
2232                return false;
2233            }
2234            self.sanitize_preserving_all_gap_tracks();
2235            let overlap_policy = if effective_push_following {
2236                OverlapPolicy::Push
2237            } else {
2238                OverlapPolicy::Override
2239            };
2240            if !self.sync_changed_groups_after_resize(
2241                &before_states,
2242                &cluster,
2243                &HashSet::new(),
2244                overlap_policy,
2245            ) {
2246                *self = backup;
2247                return false;
2248            }
2249            return true;
2250        }
2251
2252        if is_gap && effective_duration < old_duration {
2253            let backup = self.clone();
2254            let before_states = self.synced_clip_states();
2255            self.children[track_index].items[item_index].set_duration(effective_duration);
2256            self.sanitize_preserving_all_gap_tracks();
2257            if !self.sync_changed_groups_after_resize(
2258                &before_states,
2259                &[track_index],
2260                &HashSet::new(),
2261                OverlapPolicy::Push,
2262            ) {
2263                *self = backup;
2264                return false;
2265            }
2266            return true;
2267        }
2268
2269        if is_clip && !effective_push_following {
2270            if resize_from_start && source_delta > 0.0 && effective_duration < old_duration {
2271                let targets = self.synced_clip_targets_for_item(item_id);
2272                self.resize_synced_clips_with_leading_gap(
2273                    targets,
2274                    source_delta,
2275                    effective_duration,
2276                );
2277                self.sanitize_preserving_all_gap_tracks();
2278                return true;
2279            }
2280
2281            let duration_delta = old_duration - effective_duration;
2282            if !resize_from_start && duration_delta > 0.0 {
2283                let targets = self.synced_clip_targets_for_item(item_id);
2284                self.resize_synced_clips_with_trailing_gap(targets, effective_duration);
2285                self.sanitize_preserving_all_gap_tracks();
2286                return true;
2287            }
2288        }
2289
2290        let resize_timeline_start = if is_clip && effective_push_following && resize_from_start {
2291            old_timeline_start
2292        } else {
2293            new_timeline_start
2294        };
2295
2296        let resized = self.resize_item_with_source_start(
2297            item_id,
2298            resize_timeline_start,
2299            effective_source_start,
2300            effective_duration,
2301            if effective_push_following {
2302                OverlapPolicy::Push
2303            } else {
2304                OverlapPolicy::Override
2305            },
2306            clamp_to_media,
2307        );
2308        self.sanitize_preserving_all_gap_tracks();
2309        resized
2310    }
2311
2312    pub fn resize_item_with_source_start(
2313        &mut self,
2314        item_id: &str,
2315        new_start_time: Seconds,
2316        source_start_time: Seconds,
2317        new_duration: Seconds,
2318        overlap_policy: OverlapPolicy,
2319        clamp_to_media: bool,
2320    ) -> bool {
2321        let Some((_, _, item)) = self.get_item(item_id) else {
2322            return false;
2323        };
2324        let source_delta = match item {
2325            Item::Clip(clip) => source_start_time - clip.source_range.start_time.to_seconds(),
2326            Item::Gap(_) => 0.0,
2327        };
2328        if source_delta.abs() > EPS {
2329            self.offset_synced_clip_source_starts(item_id, source_delta);
2330        }
2331        self.resize_item(
2332            item_id,
2333            new_start_time,
2334            new_duration,
2335            overlap_policy,
2336            clamp_to_media,
2337        )
2338    }
2339
2340    fn resize_synced_clips_with_leading_gap(
2341        &mut self,
2342        mut targets: Vec<(usize, usize)>,
2343        source_delta: Seconds,
2344        duration: Seconds,
2345    ) {
2346        targets.sort_unstable_by(|a, b| b.cmp(a));
2347        for (track_index, item_index) in targets {
2348            let Some(track) = self.children.get_mut(track_index) else {
2349                continue;
2350            };
2351            if item_index >= track.items.len() {
2352                continue;
2353            }
2354            let source_start_time = item_source_start(&track.items[item_index]) + source_delta;
2355            set_item_source_start(&mut track.items[item_index], source_start_time);
2356            track.items[item_index].set_duration(duration);
2357            track
2358                .items
2359                .insert(item_index, Item::Gap(Gap::make_gap(source_delta)));
2360        }
2361    }
2362
2363    fn resize_synced_clips_with_trailing_gap(
2364        &mut self,
2365        mut targets: Vec<(usize, usize)>,
2366        duration: Seconds,
2367    ) {
2368        targets.sort_unstable_by(|a, b| b.cmp(a));
2369        for (track_index, item_index) in targets {
2370            let Some(track) = self.children.get_mut(track_index) else {
2371                continue;
2372            };
2373            if item_index >= track.items.len() {
2374                continue;
2375            }
2376            let gap_duration = (track.items[item_index].duration() - duration).max(0.0);
2377            track.items[item_index].set_duration(duration);
2378            if gap_duration > 0.0 {
2379                track
2380                    .items
2381                    .insert(item_index + 1, Item::Gap(Gap::make_gap(gap_duration)));
2382            }
2383        }
2384    }
2385
2386    fn synced_clip_targets_for_item(&self, item_id: &str) -> Vec<(usize, usize)> {
2387        let Some((selected_track_index, selected_item_index, selected_item)) =
2388            self.get_item(item_id)
2389        else {
2390            return Vec::new();
2391        };
2392        let Some(sync_clips_id) = (match selected_item {
2393            Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata),
2394            Item::Gap(_) => None,
2395        }) else {
2396            return vec![(selected_track_index, selected_item_index)];
2397        };
2398
2399        let targets = self.synced_clips_targets(sync_clips_id);
2400        if targets.len() > 1 {
2401            targets
2402        } else {
2403            vec![(selected_track_index, selected_item_index)]
2404        }
2405    }
2406
2407    fn offset_synced_clip_source_starts(&mut self, item_id: &str, source_delta: Seconds) {
2408        for (track_index, item_index) in self.synced_clip_targets_for_item(item_id) {
2409            let Some(item) = self
2410                .children
2411                .get_mut(track_index)
2412                .and_then(|track| track.items.get_mut(item_index))
2413            else {
2414                continue;
2415            };
2416            let source_start_time = (item_source_start(item) + source_delta).max(0.0);
2417            set_item_source_start(item, source_start_time);
2418        }
2419    }
2420
2421    fn delete_one_item(&mut self, item_id: &str, replace_with_gap: bool) -> Option<(usize, Item)> {
2422        for ti in 0..self.children.len() {
2423            if let Some((ii, item)) = self.children[ti].get_item_by_id(item_id) {
2424                // A ripple delete of an unsynced clip pulls every track whose
2425                // sync columns shift with it, by the clip's duration.
2426                if !replace_with_gap
2427                    && matches!(item, Item::Clip(clip) if clip.sync_clips_id().is_none())
2428                {
2429                    let (mut removed, _) = self.ripple_remove_items(&[(ti, ii)])?;
2430                    return removed.pop();
2431                }
2432                let backup = self.clone();
2433                let before_states = self.synced_clip_states();
2434                // Use the track API for deletion and optional gap insertion/merge behavior
2435                let Some(removed) = self.children[ti].delete_clip(ii, replace_with_gap) else {
2436                    return None;
2437                };
2438                if !replace_with_gap {
2439                    let excluded_ids = removed.get_id().into_iter().collect();
2440                    if !self.sync_changed_groups_after_resize(
2441                        &before_states,
2442                        &[ti],
2443                        &excluded_ids,
2444                        OverlapPolicy::Override,
2445                    ) {
2446                        *self = backup;
2447                        return None;
2448                    }
2449                }
2450                return Some((ti, removed));
2451            }
2452        }
2453        None
2454    }
2455
2456    fn remove_item_at_for_move(
2457        &mut self,
2458        track_index: usize,
2459        item_index: usize,
2460        replace_with_gap: bool,
2461        used_ids: &mut HashSet<String>,
2462    ) -> bool {
2463        let Some(track) = self.children.get_mut(track_index) else {
2464            return false;
2465        };
2466        if item_index >= track.items.len() {
2467            return false;
2468        }
2469        let removed = track.items.remove(item_index);
2470        let duration = removed.duration().max(0.0);
2471        if replace_with_gap && duration > EPS {
2472            let mut gap = Item::Gap(crate::Gap::make_gap(duration));
2473            Self::ensure_unique_item_id(&mut gap, used_ids);
2474            track.items.insert(item_index.min(track.items.len()), gap);
2475            track.merge_adjacent_gaps();
2476        }
2477        true
2478    }
2479
2480    fn synced_move_items(&self, item_id: &str) -> Option<Vec<SyncedMoveItem>> {
2481        let (selected_track_index, selected_item_index, selected_item) = self.get_item(item_id)?;
2482        let sync_clips_id = match selected_item {
2483            Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata),
2484            Item::Gap(_) => None,
2485        }?;
2486        let selected_start = self.children[selected_track_index]
2487            .start_time_of_item(selected_item_index);
2488        let selected_duration = selected_item.duration().max(0.0);
2489        if selected_duration <= EPS {
2490            return None;
2491        }
2492        let targets =
2493            self.sync_column_targets_at(sync_clips_id, selected_start, selected_duration);
2494        if targets.len() <= 1 {
2495            return None;
2496        }
2497
2498        // Resolve exports often misalign video/audio columns. Defer to the
2499        // linked-move path so each partner keeps its own duration.
2500        let has_misaligned_video = self
2501            .synced_clips_targets(sync_clips_id)
2502            .into_iter()
2503            .any(|(track_index, item_index)| {
2504                self.children
2505                    .get(track_index)
2506                    .is_some_and(|track| track.kind == TrackKind::Video)
2507                    && !targets
2508                        .iter()
2509                        .any(|(ti, ii)| ti == &track_index && ii == &item_index)
2510            });
2511        if has_misaligned_video {
2512            return None;
2513        }
2514
2515        let mut items = Vec::new();
2516        for (track_index, item_index) in targets {
2517            let track_kind = self.children.get(track_index)?.kind.clone();
2518            let item = self
2519                .children
2520                .get(track_index)?
2521                .items
2522                .get(item_index)?
2523                .clone();
2524            let is_selected =
2525                track_index == selected_track_index && item_index == selected_item_index;
2526            items.push(SyncedMoveItem {
2527                track_index,
2528                item_index,
2529                track_kind,
2530                item,
2531                is_selected,
2532            });
2533        }
2534
2535        Some(items)
2536    }
2537
2538    /// Whether the selected member of a column may be moved onto
2539    /// `dest_track_index` over `[start, end]`: a video clip only onto a video
2540    /// track; the audio partner of a video column only within its own group,
2541    /// onto a sibling track whose range is empty or holds clips of the same
2542    /// column (a swap), or with Push onto any sibling track (the columns there
2543    /// are pushed whole, never overwritten); a member of an audio-only column
2544    /// onto its own group or another audio group, never a video group.
2545    fn partner_move_target_allowed(
2546        &self,
2547        selected: &SyncedMoveItem,
2548        column_has_video: bool,
2549        dest_track_index: usize,
2550        start: Seconds,
2551        end: Seconds,
2552        overlap_policy: OverlapPolicy,
2553    ) -> bool {
2554        let Some(dest) = self.children.get(dest_track_index) else {
2555            return false;
2556        };
2557        if selected.track_kind == TrackKind::Video {
2558            return dest.kind == TrackKind::Video;
2559        }
2560        if selected.track_kind != TrackKind::Audio || dest.kind != TrackKind::Audio {
2561            return selected.track_kind == dest.kind;
2562        }
2563        if dest_track_index == selected.track_index {
2564            return true;
2565        }
2566        let sync_clips_id = match &selected.item {
2567            Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata),
2568            Item::Gap(_) => None,
2569        };
2570        let source_owner = self.group_owner_of(selected.track_index);
2571        let dest_owner = self.group_owner_of(dest_track_index);
2572        if source_owner == dest_owner {
2573            if overlap_policy == OverlapPolicy::Push {
2574                return true;
2575            }
2576            let allowed: HashSet<i64> = sync_clips_id.into_iter().collect();
2577            return self.range_only_holds_sync_clips(dest_track_index, start, end, &allowed);
2578        }
2579        if column_has_video {
2580            // The audio partner of a video column stays in its video's group:
2581            // it moves in time, or swaps with a sibling, never to another track.
2582            return false;
2583        }
2584        // An audio-only column never joins a video group.
2585        !self.track_in_video_group(dest_track_index)
2586    }
2587
2588    fn is_intra_cluster_sync_move(
2589        &self,
2590        dest_track_index: usize,
2591        items_to_move: &[SyncedMoveItem],
2592    ) -> bool {
2593        let cluster: HashSet<usize> = self
2594            .boundary_group_indices(dest_track_index)
2595            .into_iter()
2596            .collect();
2597        cluster.contains(&dest_track_index)
2598            && items_to_move
2599                .iter()
2600                .all(|item| cluster.contains(&item.track_index))
2601    }
2602
2603    /// Whether `[start, start + duration]` on the track holds no clip other
2604    /// than `moving_ids` (the items being moved, still in place).
2605    fn move_landing_is_free(
2606        &self,
2607        track_index: usize,
2608        start: Seconds,
2609        duration: Seconds,
2610        moving_ids: &[String],
2611    ) -> bool {
2612        let Some(track) = self.children.get(track_index) else {
2613            return true;
2614        };
2615        let end = start + duration.max(0.0);
2616        let mut cursor = 0.0;
2617        track.items.iter().all(|item| {
2618            let (item_start, item_end) = (cursor, cursor + item.duration());
2619            cursor = item_end;
2620            !matches!(item, Item::Clip(_))
2621                || item_end <= start + EPS
2622                || item_start >= end - EPS
2623                || item.get_id().is_some_and(|id| moving_ids.contains(&id))
2624        })
2625    }
2626
2627    fn stack_item_start_time(&self, item_id: &str) -> Option<Seconds> {
2628        let (track_index, item_index, _) = self.get_item(item_id)?;
2629        Some(self.children[track_index].start_time_of_item(item_index))
2630    }
2631
2632    /// Where `insert_policy` puts a drop at `time` relative to the clip (other
2633    /// than `moving_ids`) that strictly contains it on the track, or `None`
2634    /// when no such clip exists or the policy splits it.
2635    fn move_snap_to_clip_at(
2636        &self,
2637        track_index: usize,
2638        time: Seconds,
2639        insert_policy: InsertPolicy,
2640        moving_ids: &[String],
2641    ) -> Option<Seconds> {
2642        let track = self.children.get(track_index)?;
2643        let mut cursor = 0.0;
2644        track.items.iter().find_map(|item| {
2645            let (start, end) = (cursor, cursor + item.duration());
2646            cursor = end;
2647            let contains = matches!(item, Item::Clip(_))
2648                && start + EPS < time
2649                && time < end - EPS
2650                && !item.get_id().is_some_and(|id| moving_ids.contains(&id));
2651            if !contains {
2652                return None;
2653            }
2654            match insert_policy {
2655                InsertPolicy::SplitAndInsert => None,
2656                InsertPolicy::InsertBefore => Some(start),
2657                InsertPolicy::InsertAfter => Some(end),
2658                InsertPolicy::InsertBeforeOrAfter => {
2659                    Some(if time - start <= end - time { start } else { end })
2660                }
2661            }
2662        })
2663    }
2664
2665    /// A move that lands on free space (only gaps once the moved items are
2666    /// lifted) goes exactly where it was dropped. Snapping to an item boundary
2667    /// would snap to the edge of the gap the lifted items leave behind.
2668    fn effective_move_insert_policy(insert_policy: InsertPolicy, landing_is_free: bool) -> InsertPolicy {
2669        if landing_is_free {
2670            InsertPolicy::SplitAndInsert
2671        } else {
2672            insert_policy
2673        }
2674    }
2675
2676    /// A move that lands on free space has nothing to push: Push would shove
2677    /// unrelated tail content on that track rightward, so use Override for the
2678    /// insert itself. When it lands on another clip, keep Push: Override would
2679    /// delete that clip instead of rippling it.
2680    fn effective_move_overlap_policy(
2681        overlap_policy: OverlapPolicy,
2682        previous_start: Option<Seconds>,
2683        dest_time: Seconds,
2684        landing_is_free: bool,
2685    ) -> OverlapPolicy {
2686        if overlap_policy == OverlapPolicy::Push
2687            && landing_is_free
2688            && previous_start.is_some_and(|previous| (dest_time - previous).abs() > EPS)
2689        {
2690            OverlapPolicy::Override
2691        } else {
2692            overlap_policy
2693        }
2694    }
2695
2696    fn linked_item_ids_for_move(&self, item_timeline_id: &str, item_to_move: &Item) -> Vec<String> {
2697        let Some((primary_track_index, primary_item_index, _)) = self.get_item(item_timeline_id)
2698        else {
2699            return Vec::new();
2700        };
2701        let primary_start = self.children[primary_track_index]
2702            .start_time_of_item(primary_item_index);
2703        let primary_duration = item_to_move.duration().max(0.0);
2704        let primary_sync_id = match item_to_move {
2705            Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata),
2706            Item::Gap(_) => None,
2707        };
2708
2709        let mut selected_ids = HashSet::from([item_timeline_id.to_string()]);
2710        let mut selected_link_group_ids = HashSet::<i64>::new();
2711        if let Some(link_group_id) = item_link_group_id(item_to_move) {
2712            selected_link_group_ids.insert(link_group_id);
2713        }
2714        if selected_link_group_ids.is_empty() {
2715            return Vec::new();
2716        }
2717
2718        loop {
2719            let mut changed = false;
2720            for (track_index, track) in self.children.iter().enumerate() {
2721                let mut pos = 0.0;
2722                for (item_index, item) in track.items.iter().enumerate() {
2723                    if !matches!(item, Item::Clip(_)) {
2724                        pos += item.duration().max(0.0);
2725                        continue;
2726                    }
2727                    let linked = item_link_group_id(item)
2728                        .is_some_and(|id| selected_link_group_ids.contains(&id));
2729                    if !linked {
2730                        pos += item.duration().max(0.0);
2731                        continue;
2732                    }
2733                    if let Some(sync_id) = primary_sync_id {
2734                        if resolve_sync_clips_id(match item {
2735                            Item::Clip(clip) => &clip.metadata,
2736                            Item::Gap(_) => {
2737                                pos += item.duration().max(0.0);
2738                                continue;
2739                            }
2740                        }) == Some(sync_id)
2741                            && !self.item_occupies_column(
2742                                track_index,
2743                                item_index,
2744                                primary_start,
2745                                primary_duration,
2746                            )
2747                            && self.is_independent_sync_column(
2748                                sync_id,
2749                                pos,
2750                                item.duration().max(0.0),
2751                            )
2752                        {
2753                            pos += item.duration().max(0.0);
2754                            continue;
2755                        }
2756                    }
2757                    let Some(item_id) = item.get_id() else {
2758                        pos += item.duration().max(0.0);
2759                        continue;
2760                    };
2761                    if selected_ids.insert(item_id) {
2762                        changed = true;
2763                    }
2764                    if let Some(link_group_id) = item_link_group_id(item) {
2765                        changed |= selected_link_group_ids.insert(link_group_id);
2766                    }
2767                    pos += item.duration().max(0.0);
2768                }
2769            }
2770            if !changed {
2771                break;
2772            }
2773        }
2774
2775        let mut linked_ids = Vec::new();
2776        for track in &self.children {
2777            for item in &track.items {
2778                let Some(item_id) = item.get_id() else {
2779                    continue;
2780                };
2781                if item_id != item_timeline_id && selected_ids.contains(&item_id) {
2782                    linked_ids.push(item_id);
2783                }
2784            }
2785        }
2786        linked_ids
2787    }
2788
2789    /// Move a primary clip and its linked / grouped partners while preserving
2790    /// relative timeline offsets. Uses delete + insert so cluster column padding
2791    /// applies for unsynced inserts inside a sync cluster.
2792    fn sync_clips_id_from_insert_result(
2793        &self,
2794        result: &InsertItemAtTimeResult,
2795    ) -> Option<i64> {
2796        match result {
2797            InsertItemAtTimeResult::Synced(synced) => synced.sync_clips_id,
2798            InsertItemAtTimeResult::ItemId(id) => self
2799                .get_item(id)
2800                .and_then(|(_, _, item)| item_link_group_id(item)),
2801        }
2802    }
2803
2804    fn place_link_group_video_partner(
2805        &mut self,
2806        dest_audio_track_index: usize,
2807        dest_time: Seconds,
2808        video_item: Item,
2809        sync_clips_id: Option<i64>,
2810        preferred_cluster_video_id: Option<&str>,
2811        insert_policy: InsertPolicy,
2812        overlap_policy: OverlapPolicy,
2813    ) -> bool {
2814        let video_duration = video_item.duration().max(0.0);
2815        if video_duration <= EPS {
2816            return false;
2817        }
2818        let Some(start) = insertion_start_or_end_for_policy(
2819            &self.children[dest_audio_track_index],
2820            dest_time,
2821            insert_policy,
2822        ) else {
2823            return false;
2824        };
2825        let end_time = start + video_duration;
2826
2827        let mut created_track_indices = Vec::new();
2828        let Some(video_track_index) = self.pick_video_partner_track(
2829            dest_audio_track_index,
2830            start,
2831            end_time,
2832            preferred_cluster_video_id,
2833            &[],
2834            sync_clips_id,
2835            overlap_policy,
2836            insert_policy,
2837            &mut created_track_indices,
2838        ) else {
2839            return false;
2840        };
2841
2842        let mut used_ids = self.collect_timeline_ids();
2843        let mut video_item = video_item;
2844        Self::set_item_sync_clips(&mut video_item, sync_clips_id);
2845        Self::ensure_unique_item_id(&mut video_item, &mut used_ids);
2846
2847        self.children[video_track_index].insert_at_time(
2848            dest_time,
2849            video_item,
2850            overlap_policy,
2851            insert_policy,
2852        );
2853        true
2854    }
2855
2856    fn move_linked_items_at_time(
2857        &mut self,
2858        item_id: &str,
2859        dest_track_id: &str,
2860        dest_time: Seconds,
2861        replace_with_gap: bool,
2862        insert_policy: InsertPolicy,
2863        overlap_policy: OverlapPolicy,
2864    ) -> bool {
2865        let item_to_move = match self.get_item(item_id) {
2866            Some((_ti, _ii, item)) => item.clone(),
2867            None => return false,
2868        };
2869        let dest_track_index = match self.get_track_by_id(dest_track_id) {
2870            Some((index, _)) => index,
2871            None => return false,
2872        };
2873        let backup = self.clone();
2874        let primary_start_time = backup
2875            .stack_item_start_time(item_id)
2876            .unwrap_or(dest_time);
2877        let landing_is_free = backup.move_landing_is_free(
2878            dest_track_index,
2879            dest_time,
2880            item_to_move.duration(),
2881            &[item_id.to_string()],
2882        );
2883        // A ripple move closes the source slot, pulling the rest of the track
2884        // left, so the drop's free space is gone: Override would cut whatever
2885        // slid under the drop. Push reopens the slot instead.
2886        let overlap_policy = if replace_with_gap {
2887            Self::effective_move_overlap_policy(
2888                overlap_policy,
2889                Some(primary_start_time),
2890                dest_time,
2891                landing_is_free,
2892            )
2893        } else {
2894            overlap_policy
2895        };
2896        let insert_policy = Self::effective_move_insert_policy(insert_policy, landing_is_free);
2897        let preferred_cluster_video_id = backup
2898            .get_track_by_id(dest_track_id)
2899            .map(|(dest_track_index, _)| dest_track_index)
2900            .and_then(|dest_track_index| {
2901                backup
2902                    .boundary_group_indices(dest_track_index)
2903                    .into_iter()
2904                    .find_map(|track_index| {
2905                        let track = backup.children.get(track_index)?;
2906                        (track.kind == TrackKind::Video)
2907                            .then(|| track.get_id().clone())
2908                            .flatten()
2909                    })
2910            });
2911        let linked_item_ids = backup.linked_item_ids_for_move(item_id, &item_to_move);
2912        let mut ids_to_remove: Vec<String> = std::iter::once(item_id.to_string())
2913            .chain(linked_item_ids.into_iter())
2914            .collect();
2915        ids_to_remove.sort_by(|left, right| {
2916            let left_pos = backup.get_item(left).map(|(ti, ii, _)| (ti, ii));
2917            let right_pos = backup.get_item(right).map(|(ti, ii, _)| (ti, ii));
2918            right_pos.cmp(&left_pos)
2919        });
2920        ids_to_remove.dedup();
2921
2922        let mut removed_items = Vec::new();
2923        for remove_id in ids_to_remove {
2924            if let Some(removed) = self.delete_one_item(&remove_id, replace_with_gap) {
2925                removed_items.push(removed);
2926            }
2927        }
2928        if removed_items.is_empty() {
2929            return false;
2930        }
2931
2932        let mut linked_audio_items = Vec::new();
2933        let mut preferred_audio_track_indices = Vec::new();
2934        let mut linked_video_item = None;
2935        let dest_is_audio = self.children.get(dest_track_index).is_some_and(|track| {
2936            track.kind == TrackKind::Audio
2937        });
2938        for (source_track_index, item) in removed_items {
2939            if item
2940                .get_id()
2941                .as_deref()
2942                .is_some_and(|id| id == item_id)
2943            {
2944                continue;
2945            }
2946            let item_destination_time = item
2947                .get_id()
2948                .as_deref()
2949                .and_then(|id| backup.stack_item_start_time(id))
2950                .map(|start_time| dest_time + start_time - primary_start_time)
2951                .unwrap_or(dest_time);
2952            let source_track_kind = backup
2953                .children
2954                .get(source_track_index)
2955                .map(|track| track.kind.clone());
2956            let is_same_time = (item_destination_time - dest_time).abs() <= EPS;
2957            let is_same_time_audio_clip = is_same_time
2958                && matches!(item, Item::Clip(_))
2959                && matches!(source_track_kind, Some(TrackKind::Audio));
2960            if is_same_time_audio_clip {
2961                if let Item::Clip(clip) = item {
2962                    linked_audio_items.push(Item::Clip(clip));
2963                    preferred_audio_track_indices.push(source_track_index);
2964                }
2965            } else if dest_is_audio
2966                && matches!(source_track_kind, Some(TrackKind::Video))
2967                && matches!(item, Item::Clip(_))
2968            {
2969                // Resolve link groups often misalign video/audio columns; when
2970                // moving onto an audio destination, keep the video partner for
2971                // the synced insert instead of leaving it on the source track.
2972                // The audio partner of a video column stays in its video's
2973                // group, so the destination must belong to that video.
2974                if linked_video_item.is_some()
2975                    || backup.group_owner_of(dest_track_index) != source_track_index
2976                {
2977                    *self = backup;
2978                    return false;
2979                }
2980                linked_video_item = Some(item);
2981            } else if is_same_time && matches!(item, Item::Gap(_)) {
2982                // Drop same-time gaps left behind by sync deletion.
2983            } else if let Some(source_track_id) = backup
2984                .children
2985                .get(source_track_index)
2986                .and_then(|track| track.get_id())
2987            {
2988                let Some((target_track_index, _)) = self.get_track_by_id(&source_track_id) else {
2989                    *self = backup;
2990                    return false;
2991                };
2992                self.insert_at_time_with_sync_splits(
2993                    target_track_index,
2994                    item_destination_time,
2995                    item,
2996                    overlap_policy,
2997                    insert_policy,
2998                );
2999            }
3000        }
3001
3002        let has_synced_partners =
3003            !linked_audio_items.is_empty() || linked_video_item.is_some();
3004        let insert_result = if !has_synced_partners {
3005            self.insert_synced_item_at_time(
3006                dest_track_index,
3007                dest_time,
3008                None,
3009                item_to_move,
3010                overlap_policy,
3011                insert_policy,
3012                None,
3013                None,
3014                false,
3015                None::<&str>,
3016                None::<&[usize]>,
3017                None::<&[usize]>,
3018            )
3019        } else {
3020            self.insert_synced_item_at_time(
3021                dest_track_index,
3022                dest_time,
3023                None,
3024                item_to_move,
3025                overlap_policy,
3026                insert_policy,
3027                (!linked_audio_items.is_empty()).then_some(linked_audio_items),
3028                None,
3029                linked_video_item.is_some(),
3030                preferred_cluster_video_id.as_deref(),
3031                (!preferred_audio_track_indices.is_empty())
3032                    .then_some(preferred_audio_track_indices.as_slice()),
3033                None::<&[usize]>,
3034            )
3035        };
3036        if has_synced_partners {
3037            let Some(insert_result) = insert_result else {
3038                *self = backup;
3039                return false;
3040            };
3041            if let Some(video_item) = linked_video_item {
3042                let sync_clips_id = self.sync_clips_id_from_insert_result(&insert_result);
3043                let dest_audio_track_index = self
3044                    .get_track_by_id(dest_track_id)
3045                    .map(|(index, _)| index)
3046                    .unwrap_or(dest_track_index);
3047                if !self.place_link_group_video_partner(
3048                    dest_audio_track_index,
3049                    dest_time,
3050                    video_item,
3051                    sync_clips_id,
3052                    preferred_cluster_video_id.as_deref(),
3053                    insert_policy,
3054                    overlap_policy,
3055                ) {
3056                    *self = backup;
3057                    return false;
3058                }
3059            }
3060        } else if insert_result.is_none() {
3061            *self = backup;
3062            return false;
3063        }
3064
3065        self.sanitize();
3066        true
3067    }
3068
3069    fn collect_placeholder_gap_ids(&self, targets: &[(usize, usize)]) -> Vec<String> {
3070        let mut gap_ids = Vec::new();
3071        for (track_index, item_index) in targets {
3072            let Some(track) = self.children.get(*track_index) else {
3073                continue;
3074            };
3075            let Some(item) = track.items.get(*item_index) else {
3076                continue;
3077            };
3078            if matches!(item, Item::Gap(_)) {
3079                if let Some(id) = item.get_id() {
3080                    gap_ids.push(id);
3081                }
3082            }
3083        }
3084        gap_ids
3085    }
3086
3087    fn remove_gaps_by_id(&mut self, gap_ids: &[String]) {
3088        let mut gap_ids: Vec<_> = gap_ids.to_vec();
3089        gap_ids.sort();
3090        gap_ids.dedup();
3091        for gap_id in gap_ids {
3092            let Some((track_index, item_index, item)) = self.get_item(&gap_id) else {
3093                continue;
3094            };
3095            if !matches!(item, Item::Gap(_)) {
3096                continue;
3097            }
3098            let Some(track) = self.children.get_mut(track_index) else {
3099                continue;
3100            };
3101            if item_index < track.items.len() {
3102                track.items.remove(item_index);
3103                track.merge_adjacent_gaps();
3104            }
3105        }
3106    }
3107
3108    fn replace_synced_item_via_insert(
3109        &mut self,
3110        items: Vec<SyncedMoveItem>,
3111        dest_track_index: usize,
3112        dest_time: Seconds,
3113        item: Item,
3114        synced_audio_clips: Option<Vec<Item>>,
3115    ) -> bool {
3116        let backup = self.clone();
3117        if dest_track_index >= self.children.len() {
3118            return false;
3119        }
3120
3121        let Some(selected) = items.iter().find(|item| item.is_selected) else {
3122            return false;
3123        };
3124        let Some(selected_id) = selected.item.get_id() else {
3125            return false;
3126        };
3127
3128        let synced_audio_input_provided = synced_audio_clips.is_some();
3129        let synced_inputs = Self::normalize_synced_inputs(synced_audio_clips, None);
3130        if !matches!(item, Item::Clip(_)) {
3131            return false;
3132        }
3133        let mut primary_item = item;
3134        primary_item.clamp_to_active_available_range();
3135        primary_item.set_id(Some(selected_id.clone()));
3136        let replacement_duration = primary_item.duration().max(0.0);
3137        if replacement_duration <= EPS {
3138            return false;
3139        }
3140        if synced_audio_input_provided
3141            && !Self::synced_inputs_match_duration(replacement_duration, &synced_inputs)
3142        {
3143            return false;
3144        }
3145
3146        let mut synced_audio = Vec::new();
3147        let mut preferred_audio_track_indices = Vec::new();
3148        let mut gap_only_audio_tracks = Vec::new();
3149        let mut synced_video = None;
3150        let mut audio_partners: Vec<_> = items
3151            .iter()
3152            .filter(|item| !item.is_selected && item.track_kind == TrackKind::Audio)
3153            .collect();
3154        let member_tracks: Vec<usize> = items.iter().map(|item| item.track_index).collect();
3155        let owner = self
3156            .column_owner_track_index(&member_tracks)
3157            .unwrap_or(selected.track_index);
3158        audio_partners.sort_by_key(|item| self.partner_slot_order(owner, item.track_index));
3159
3160        for item in &items {
3161            if item.is_selected {
3162                continue;
3163            }
3164            if item.track_kind == TrackKind::Video {
3165                let mut video_item = item.item.clone();
3166                video_item.set_duration(replacement_duration);
3167                synced_video = Some(video_item);
3168            }
3169        }
3170
3171        if synced_audio_input_provided {
3172            let mut synced_audio_inputs = synced_inputs.audio.into_iter();
3173            for partner in audio_partners {
3174                if let Some(mut next_audio) = synced_audio_inputs.next() {
3175                    if let Some(audio_id) = partner.item.get_id() {
3176                        next_audio.set_id(Some(audio_id));
3177                    }
3178                    next_audio.set_duration(replacement_duration);
3179                    synced_audio.push(next_audio);
3180                    preferred_audio_track_indices.push(partner.track_index);
3181                } else {
3182                    gap_only_audio_tracks.push(partner.track_index);
3183                }
3184            }
3185            for audio_item in synced_audio_inputs {
3186                synced_audio.push(audio_item);
3187            }
3188        } else {
3189            for item in &items {
3190                if item.is_selected || item.track_kind != TrackKind::Audio {
3191                    continue;
3192                }
3193                let mut audio_item = item.item.clone();
3194                audio_item.set_duration(replacement_duration);
3195                synced_audio.push(audio_item);
3196                preferred_audio_track_indices.push(item.track_index);
3197            }
3198        }
3199
3200        let Some(targets) = self.delete_item_targets(&selected_id) else {
3201            return false;
3202        };
3203        let removed = self.delete_clips_at_indices(targets.clone(), true);
3204        if removed.is_empty() {
3205            return false;
3206        }
3207        let placeholder_gap_ids = self.collect_placeholder_gap_ids(&targets);
3208
3209        let synced_audio_clips = (!synced_audio.is_empty()).then_some(synced_audio);
3210        let gap_only_exclude = (!gap_only_audio_tracks.is_empty())
3211            .then_some(gap_only_audio_tracks.as_slice());
3212        if self
3213            .insert_synced_item_at_time(
3214                dest_track_index,
3215                dest_time,
3216                None,
3217                primary_item,
3218                OverlapPolicy::Override,
3219                InsertPolicy::SplitAndInsert,
3220                synced_audio_clips,
3221                synced_video,
3222                false,
3223                None,
3224                (!preferred_audio_track_indices.is_empty())
3225                    .then_some(preferred_audio_track_indices.as_slice()),
3226                gap_only_exclude,
3227            )
3228            .is_some()
3229        {
3230            self.remove_gaps_by_id(&placeholder_gap_ids);
3231            let mut used_ids = self.collect_timeline_ids();
3232            for &track_index in &gap_only_audio_tracks {
3233                let start = dest_time;
3234                let end = dest_time + replacement_duration;
3235                let Some(track) = self.children.get_mut(track_index) else {
3236                    *self = backup;
3237                    return false;
3238                };
3239                track.delete_range(start, end, false);
3240                let mut gap = Item::Gap(Gap::make_gap(replacement_duration));
3241                Self::ensure_unique_item_id(&mut gap, &mut used_ids);
3242                let result = track.insert_at_time(
3243                    start,
3244                    gap,
3245                    OverlapPolicy::Override,
3246                    InsertPolicy::InsertBefore,
3247                );
3248                if !result.success {
3249                    *self = backup;
3250                    return false;
3251                }
3252            }
3253            self.sanitize_preserving_all_gap_tracks();
3254            true
3255        } else {
3256            *self = backup;
3257            false
3258        }
3259    }
3260
3261    fn move_synced_items_at_time_via_insert(
3262        &mut self,
3263        items_to_move: Vec<SyncedMoveItem>,
3264        dest_track_index: usize,
3265        dest_time: Seconds,
3266        replace_with_gap: bool,
3267        insert_policy: InsertPolicy,
3268        overlap_policy: OverlapPolicy,
3269    ) -> bool {
3270        let backup = self.clone();
3271        if dest_track_index >= self.children.len() {
3272            return false;
3273        }
3274
3275        let Some(selected) = items_to_move.iter().find(|item| item.is_selected) else {
3276            return false;
3277        };
3278        let Some(item_id) = selected.item.get_id() else {
3279            return false;
3280        };
3281        let primary_item = selected.item.clone();
3282        let moved_end = dest_time + primary_item.duration().max(0.0);
3283        let column_has_video = items_to_move
3284            .iter()
3285            .any(|item| item.track_kind == TrackKind::Video);
3286        if !self.partner_move_target_allowed(
3287            selected,
3288            column_has_video,
3289            dest_track_index,
3290            dest_time,
3291            moved_end,
3292            overlap_policy,
3293        ) {
3294            return false;
3295        }
3296
3297        // Deleting the last column erases the sync-cluster evidence. Keep the
3298        // original video identity before deleting it: a moved audio partner
3299        // keeps its video on the video's own track, whichever audio track it
3300        // goes to.
3301        let preferred_video_track_id = items_to_move
3302            .iter()
3303            .find(|item| item.track_kind == TrackKind::Video)
3304            .and_then(|item| self.children[item.track_index].get_id());
3305
3306        let mut synced_audio = Vec::new();
3307        let mut preferred_audio_track_indices = Vec::new();
3308        let mut synced_video = None;
3309        let mut audio_partners: Vec<_> = items_to_move
3310            .iter()
3311            .filter(|item| !item.is_selected && item.track_kind == TrackKind::Audio)
3312            .collect();
3313        let member_tracks: Vec<usize> = items_to_move.iter().map(|item| item.track_index).collect();
3314        let owner = self
3315            .column_owner_track_index(&member_tracks)
3316            .unwrap_or(selected.track_index);
3317        audio_partners.sort_by_key(|item| self.partner_slot_order(owner, item.track_index));
3318        for item in &items_to_move {
3319            if item.is_selected {
3320                continue;
3321            }
3322            match item.track_kind {
3323                TrackKind::Audio => continue,
3324                TrackKind::Video => {
3325                    if synced_video.is_some() {
3326                        return false;
3327                    }
3328                    synced_video = Some(item.item.clone());
3329                }
3330                TrackKind::Other => return false,
3331            }
3332        }
3333        for item in audio_partners {
3334            synced_audio.push(item.item.clone());
3335            // A swap: the channel sitting on the destination takes the moved
3336            // channel's old track.
3337            preferred_audio_track_indices.push(if item.track_index == dest_track_index {
3338                selected.track_index
3339            } else {
3340                item.track_index
3341            });
3342        }
3343        let move_previous_start = backup.children[selected.track_index]
3344            .start_time_of_item(selected.item_index);
3345        let moving_ids: Vec<String> = items_to_move
3346            .iter()
3347            .filter_map(|item| item.item.get_id())
3348            .collect();
3349        // The whole column must land on free space: the selected item on the
3350        // destination, its partners on their own tracks. A video clip moved to
3351        // another video track takes its audio to that track's partner tracks,
3352        // so its own partner tracks say nothing about the landing (snapping on
3353        // them would drop the column mid-clip on the destination).
3354        let partners_change_tracks =
3355            selected.track_kind == TrackKind::Video && dest_track_index != selected.track_index;
3356        let column_tracks: Vec<usize> = std::iter::once(dest_track_index)
3357            .chain(
3358                items_to_move
3359                    .iter()
3360                    .filter(|item| !item.is_selected && !partners_change_tracks)
3361                    .map(|item| item.track_index),
3362            )
3363            .collect();
3364        let landing_is_free = column_tracks.iter().all(|&track_index| {
3365                backup.move_landing_is_free(
3366                    track_index,
3367                    dest_time,
3368                    selected.item.duration(),
3369                    &moving_ids,
3370                )
3371            });
3372        let requested_overlap_policy = overlap_policy;
3373        let mut overlap_policy = Self::effective_move_overlap_policy(
3374            overlap_policy,
3375            Some(move_previous_start),
3376            dest_time,
3377            landing_is_free,
3378        );
3379        // Snap to the clip the column lands on, on whichever column track holds
3380        // it. The lifted source leaves only a gap on its own track, so snapping
3381        // there would jump to that gap's edge and strand the partners.
3382        let dest_time = if landing_is_free {
3383            dest_time
3384        } else {
3385            column_tracks
3386                .iter()
3387                .find_map(|&track_index| {
3388                    backup.move_snap_to_clip_at(track_index, dest_time, insert_policy, &moving_ids)
3389                })
3390                .unwrap_or(dest_time)
3391        };
3392        if dest_track_index == selected.track_index && (dest_time - move_previous_start).abs() <= EPS {
3393            // Snapped back onto its own slot: nothing moves.
3394            return true;
3395        }
3396        let insert_policy = if landing_is_free || insert_policy != InsertPolicy::SplitAndInsert {
3397            InsertPolicy::SplitAndInsert
3398        } else {
3399            insert_policy
3400        };
3401        let move_source_track_indices: Vec<usize> =
3402            items_to_move.iter().map(|item| item.track_index).collect();
3403        let partner_ids: Vec<String> = items_to_move
3404            .iter()
3405            .filter(|item| !item.is_selected)
3406            .filter_map(|item| item.item.get_id())
3407            .collect();
3408
3409        let Some(targets) = self.delete_item_targets(&item_id) else {
3410            return false;
3411        };
3412        // A ripple move along the column's own tracks closes the hole first:
3413        // every column track shifts by the same amount, so the drop time
3414        // (given on the timeline before the move) just shifts with it. Holding
3415        // placeholder gaps through the insert instead lost them whenever the
3416        // insert merged or split them, and the column came out misaligned.
3417        let ripple_in_place =
3418            !replace_with_gap && move_source_track_indices.contains(&dest_track_index);
3419        let original_dest_time = dest_time;
3420        let mut dest_time = dest_time;
3421        if ripple_in_place {
3422            let moved_duration = selected.item.duration().max(0.0);
3423            if dest_time >= move_previous_start + moved_duration - EPS {
3424                dest_time -= moved_duration;
3425            } else if dest_time > move_previous_start {
3426                dest_time = move_previous_start;
3427            }
3428        }
3429        // A ripple move closes the hole on every track the column's removal
3430        // shifts, linked or not, and drops at the same content it was aimed at.
3431        let mut rippled = false;
3432        if !replace_with_gap {
3433            let Some((_, spans)) = self.ripple_remove_items(&targets) else {
3434                *self = backup;
3435                return false;
3436            };
3437            dest_time = spans.map_time(dest_track_index, original_dest_time);
3438            rippled = true;
3439            // Closing the hole may have pulled a clip under the drop: the
3440            // landing is judged again on the stack as it is now.
3441            let landing_is_free = column_tracks.iter().all(|&track_index| {
3442                self.move_landing_is_free(track_index, dest_time, selected.item.duration(), &[])
3443            });
3444            overlap_policy = Self::effective_move_overlap_policy(
3445                requested_overlap_policy,
3446                Some(move_previous_start),
3447                dest_time,
3448                landing_is_free,
3449            );
3450        }
3451        if !rippled {
3452            let removed = self.delete_clips_at_indices(targets.clone(), !ripple_in_place);
3453            if removed.is_empty() {
3454                return false;
3455            }
3456        }
3457        let placeholder_gap_ids = if ripple_in_place || rippled {
3458            Vec::new()
3459        } else {
3460            self.collect_placeholder_gap_ids(&targets)
3461        };
3462
3463        let synced_audio_clips = (!synced_audio.is_empty()).then_some(synced_audio);
3464        if self
3465            .insert_synced_item_at_time(
3466                dest_track_index,
3467                dest_time,
3468                None,
3469                primary_item,
3470                overlap_policy,
3471                insert_policy,
3472                synced_audio_clips,
3473                synced_video,
3474                false,
3475                preferred_video_track_id.as_deref(),
3476                (!preferred_audio_track_indices.is_empty())
3477                    .then_some(preferred_audio_track_indices.as_slice()),
3478                Some(move_source_track_indices.as_slice()),
3479            )
3480            .is_some()
3481        {
3482            if !replace_with_gap && !ripple_in_place && !rippled {
3483                self.remove_gaps_by_id(&placeholder_gap_ids);
3484                self.realign_moved_partners(&item_id, &partner_ids);
3485            }
3486            self.sanitize();
3487            true
3488        } else {
3489            *self = backup;
3490            false
3491        }
3492    }
3493
3494    /// Ripple-closing the source holes shifts everything after them on the
3495    /// source tracks, including partners that were just re-inserted there,
3496    /// while a primary that landed on another track stays put. Pad (or trim
3497    /// the gap before) each partner so the column stays aligned on the primary.
3498    fn realign_moved_partners(&mut self, primary_id: &str, partner_ids: &[String]) {
3499        let Some(primary_start) = self.stack_item_start_time(primary_id) else {
3500            return;
3501        };
3502        let mut used_ids = self.collect_timeline_ids();
3503        for partner_id in partner_ids {
3504            let Some((track_index, item_index, _)) = self.get_item(partner_id) else {
3505                continue;
3506            };
3507            let partner_start = self.children[track_index].start_time_of_item(item_index);
3508            let delta = primary_start - partner_start;
3509            if delta > EPS {
3510                let mut gap = Item::Gap(Gap::make_gap(delta));
3511                Self::ensure_unique_item_id(&mut gap, &mut used_ids);
3512                self.children[track_index].items.insert(item_index, gap);
3513            } else if delta < -EPS {
3514                let Some(gap_index) = item_index.checked_sub(1) else {
3515                    continue;
3516                };
3517                let Some(Item::Gap(gap)) = self.children[track_index].items.get_mut(gap_index)
3518                else {
3519                    continue;
3520                };
3521                let available = gap.source_range.duration.to_seconds();
3522                let remaining = available - (-delta).min(available);
3523                if remaining <= EPS {
3524                    self.children[track_index].items.remove(gap_index);
3525                } else {
3526                    gap.source_range.duration.set_from_seconds(remaining);
3527                }
3528            }
3529        }
3530    }
3531
3532    fn move_synced_items(
3533        &mut self,
3534        items_to_move: Vec<SyncedMoveItem>,
3535        mut dest_track_index: usize,
3536        replace_with_gap: bool,
3537        overlap_policy: OverlapPolicy,
3538        dest_index: usize,
3539    ) -> bool {
3540        let backup = self.clone();
3541        if dest_track_index >= self.children.len() {
3542            return false;
3543        }
3544
3545        let Some(selected_position) = items_to_move.iter().position(|item| item.is_selected) else {
3546            return false;
3547        };
3548        let selected_item = &items_to_move[selected_position];
3549        let selected_source_track_index = selected_item.track_index;
3550        let Some(selected_id) = selected_item.item.get_id() else {
3551            return false;
3552        };
3553        let Some(sync_clips_id) = (match &selected_item.item {
3554            Item::Clip(clip) => resolve_sync_clips_id(&clip.metadata),
3555            Item::Gap(_) => None,
3556        }) else {
3557            return false;
3558        };
3559        let moved_duration = selected_item.item.duration().max(0.0);
3560        if moved_duration <= EPS
3561            || items_to_move
3562                .iter()
3563                .any(|item| (item.item.duration().max(0.0) - moved_duration).abs() > EPS)
3564        {
3565            return false;
3566        }
3567        // `dest_index` refers to the destination as it is now. Lifting a column
3568        // member that sits before it shifts the indices (its slot goes, or its
3569        // gap merges with a neighbour), and the column's sync splits shift them
3570        // again, so the drop is anchored on the start time `items[dest_index]`
3571        // has once the column is lifted. The landing check below still runs on
3572        // the track as it is now, so it takes the boundary as it is now.
3573        let current_start = self.children[dest_track_index].start_time_of_item(dest_index);
3574        {
3575            let column_has_video = items_to_move
3576                .iter()
3577                .any(|item| item.track_kind == TrackKind::Video);
3578            if !self.partner_move_target_allowed(
3579                selected_item,
3580                column_has_video,
3581                dest_track_index,
3582                current_start,
3583                current_start + moved_duration,
3584                overlap_policy,
3585            ) {
3586                return false;
3587            }
3588        }
3589
3590        let mut used_ids = self.collect_timeline_ids();
3591        let mut targets: Vec<_> = items_to_move
3592            .iter()
3593            .map(|item| (item.track_index, item.item_index))
3594            .collect();
3595        let planned_start = if replace_with_gap {
3596            targets.sort_by(|a, b| b.cmp(a));
3597            for (track_index, item_index) in targets {
3598                if !self.remove_item_at_for_move(
3599                    track_index,
3600                    item_index,
3601                    replace_with_gap,
3602                    &mut used_ids,
3603                ) {
3604                    *self = backup;
3605                    return false;
3606                }
3607            }
3608            current_start
3609        } else {
3610            // Closing the hole pulls every track the column's removal shifts,
3611            // linked or not; the drop follows the content it was aimed at.
3612            let Some((_, spans)) = self.ripple_remove_items(&targets) else {
3613                *self = backup;
3614                return false;
3615            };
3616            spans.map_time(dest_track_index, current_start)
3617        };
3618
3619        let insert_policy_for_video = InsertPolicy::SplitAndInsert;
3620        let moved_start = planned_start;
3621
3622        let mut placements = vec![(
3623            dest_track_index,
3624            items_to_move[selected_position].item.clone(),
3625            true,
3626        )];
3627        let mut created_track_indices = Vec::new();
3628        let mut used_audio_track_indices = Vec::new();
3629        let mut used_video_track_indices = Vec::new();
3630        match self.children[dest_track_index].kind {
3631            TrackKind::Audio => used_audio_track_indices.push(dest_track_index),
3632            TrackKind::Video => used_video_track_indices.push(dest_track_index),
3633            TrackKind::Other => {}
3634        }
3635
3636        let intra_cluster_move =
3637            backup.is_intra_cluster_sync_move(dest_track_index, &items_to_move);
3638        let source_member_tracks: Vec<usize> =
3639            items_to_move.iter().map(|item| item.track_index).collect();
3640
3641        let mut sync_track_items: Vec<_> = items_to_move
3642            .into_iter()
3643            .enumerate()
3644            .filter(|(position, _)| *position != selected_position)
3645            .map(|(_, move_item)| move_item)
3646            .collect();
3647        // Video first (it owns the column), then audio in the owner's slot order.
3648        let source_owner = backup
3649            .column_owner_track_index(&source_member_tracks)
3650            .unwrap_or(selected_source_track_index);
3651        sync_track_items.sort_by_key(|move_item| {
3652            (
3653                move_item.track_kind != TrackKind::Video,
3654                backup.partner_slot_order(source_owner, move_item.track_index),
3655            )
3656        });
3657        let mut owner_track_index = dest_track_index;
3658        let moved_end = moved_start + moved_duration;
3659        let horizontal = selected_source_track_index == dest_track_index;
3660        let mut source_track_indices: Vec<usize> = std::iter::once(selected_source_track_index)
3661            .chain(
3662                sync_track_items
3663                    .iter()
3664                    .map(|move_item| move_item.track_index),
3665            )
3666            .collect();
3667
3668        for position in 0..sync_track_items.len() {
3669            let move_item = sync_track_items[position].clone();
3670            if intra_cluster_move {
3671                // A swap: the channel sitting on the destination takes the
3672                // moved channel's old track, so a track never holds two clips
3673                // of the column.
3674                let track_index = if move_item.track_index == dest_track_index {
3675                    selected_source_track_index
3676                } else {
3677                    move_item.track_index
3678                };
3679                match move_item.track_kind {
3680                    TrackKind::Audio => used_audio_track_indices.push(track_index),
3681                    TrackKind::Video => used_video_track_indices.push(track_index),
3682                    TrackKind::Other => {
3683                        *self = backup;
3684                        return false;
3685                    }
3686                }
3687                placements.push((track_index, move_item.item, false));
3688                continue;
3689            }
3690
3691            let track_count_before = self.children.len();
3692            let track_index = match move_item.track_kind {
3693                TrackKind::Audio => {
3694                    let overridden_sync_ids: HashSet<i64> =
3695                        if overlap_policy == OverlapPolicy::Override {
3696                            self.sync_clips_ids_in_range(dest_track_index, moved_start, moved_end)
3697                        } else {
3698                            HashSet::new()
3699                        };
3700                    let primary = dest_track_index;
3701                    let owner = owner_track_index;
3702                    let usable = |stack: &Stack, index: usize| {
3703                        stack.partner_track_usable(
3704                            primary,
3705                            index,
3706                            moved_start,
3707                            moved_end,
3708                            overlap_policy,
3709                            &overridden_sync_ids,
3710                        ) || (owner != primary
3711                            && stack.partner_track_usable(
3712                                owner,
3713                                index,
3714                                moved_start,
3715                                moved_end,
3716                                overlap_policy,
3717                                &overridden_sync_ids,
3718                            ))
3719                    };
3720                    let track_index = self.pick_partner_track(
3721                        &stack_partner_tracks::PartnerSlotRequest {
3722                            primary_track_index: dest_track_index,
3723                            owner_track_index,
3724                            kind: TrackKind::Audio,
3725                            start: moved_start,
3726                            end: moved_end,
3727                            used: &used_audio_track_indices,
3728                            // A swap: the channel sitting on the destination
3729                            // takes the moved channel's old track.
3730                            preferred: Some(if move_item.track_index == dest_track_index {
3731                                selected_source_track_index
3732                            } else {
3733                                move_item.track_index
3734                            }),
3735                            horizontal,
3736                            exclude_fallback: &source_track_indices,
3737                        },
3738                        &usable,
3739                        &mut created_track_indices,
3740                    );
3741                    track_index
3742                }
3743                TrackKind::Video => {
3744                    let Some(mut track_index) = self.pick_video_partner_track(
3745                        dest_track_index,
3746                        moved_start,
3747                        moved_end,
3748                        None,
3749                        &used_video_track_indices,
3750                        Some(sync_clips_id),
3751                        overlap_policy,
3752                        insert_policy_for_video,
3753                        &mut created_track_indices,
3754                    ) else {
3755                        *self = backup;
3756                        return false;
3757                    };
3758                    if used_video_track_indices.contains(&track_index) {
3759                        let insert_at = track_index;
3760                        self.children
3761                            .insert(insert_at, self.new_numbered_track(TrackKind::Video));
3762                        created_track_indices.push(insert_at);
3763                        track_index = insert_at;
3764                    }
3765                    owner_track_index = track_index;
3766                    track_index
3767                }
3768                TrackKind::Other => {
3769                    *self = backup;
3770                    return false;
3771                }
3772            };
3773            if self.children.len() > track_count_before {
3774                // One track was inserted at `track_index`: every index this move
3775                // still holds that pointed at or past it moved up by one.
3776                shift_track_index_after_insert(&mut dest_track_index, track_index);
3777                shift_move_placements_after_insert(&mut placements, track_index);
3778                shift_track_indices_after_insert(&mut used_audio_track_indices, track_index);
3779                shift_track_indices_after_insert(&mut used_video_track_indices, track_index);
3780                shift_track_indices_after_insert(&mut source_track_indices, track_index);
3781                if owner_track_index != track_index {
3782                    shift_track_index_after_insert(&mut owner_track_index, track_index);
3783                }
3784                for pending in sync_track_items[position + 1..].iter_mut() {
3785                    shift_track_index_after_insert(&mut pending.track_index, track_index);
3786                }
3787            }
3788            match move_item.track_kind {
3789                TrackKind::Audio => used_audio_track_indices.push(track_index),
3790                _ => used_video_track_indices.push(track_index),
3791            }
3792            placements.push((track_index, move_item.item, false));
3793        }
3794
3795        let mut boundary_track_indices =
3796            self.boundary_track_indices_for_anchors(&[sync_clips_id], &[dest_track_index], &[]);
3797        for (track_index, _, _) in &placements {
3798            if !boundary_track_indices.contains(track_index) {
3799                boundary_track_indices.push(*track_index);
3800            }
3801        }
3802        boundary_track_indices.sort_unstable();
3803        boundary_track_indices.dedup();
3804
3805        for track_index in boundary_track_indices {
3806            if placements
3807                .iter()
3808                .any(|(placement_track_index, _, _)| *placement_track_index == track_index)
3809            {
3810                continue;
3811            }
3812            let mut gap = Item::Gap(crate::Gap::make_gap(moved_duration));
3813            Self::ensure_unique_item_id(&mut gap, &mut used_ids);
3814            placements.push((track_index, gap, false));
3815        }
3816        placements.sort_by_key(|(track_index, _, _)| *track_index);
3817
3818        let insert_policy = InsertPolicy::SplitAndInsert;
3819        let cluster = self.boundary_group_indices(dest_track_index);
3820        let placed_ids: HashSet<String> = placements
3821            .iter()
3822            .filter_map(|(_, item, _)| item.get_id())
3823            .collect();
3824        if !self.apply_sync_splits_for_column_insert(
3825            moved_start,
3826            moved_duration,
3827            insert_policy,
3828            overlap_policy,
3829            SyncSplitIdPolicy::KeepShared,
3830            Some(&cluster),
3831        ) {
3832            *self = backup;
3833            return false;
3834        }
3835
3836        let starts_before = self.clip_starts_from(moved_start);
3837        for (track_index, item, is_selected) in placements {
3838            // The selected item goes in at the boundary it was dropped on. When
3839            // the column stays in its own cluster and pushes, its other tracks
3840            // do the same, so whatever follows shifts by the same amount on
3841            // every track of the cluster (filling a gap on one while pushing on
3842            // another breaks the sync of the columns after it). A column moved
3843            // to another cluster leaves its old tracks' content in place.
3844            let at_boundary = is_selected
3845                || (intra_cluster_move
3846                    && overlap_policy == OverlapPolicy::Push
3847                    && moved_start <= self.children[track_index].total_duration() + EPS);
3848            if at_boundary {
3849                let track = &mut self.children[track_index];
3850                track.split_at_time(moved_start);
3851                let index = (0..=track.items.len())
3852                    .find(|&index| track.start_time_of_item(index) >= moved_start - EPS)
3853                    .unwrap_or(track.items.len());
3854                let _ = track.insert_at_index(index, item, overlap_policy);
3855            } else {
3856                let moved_end = moved_start + item.duration().max(0.0);
3857                if range_is_gap_backed(&self.children[track_index], moved_start, moved_end) {
3858                    if !self.insert_gap_only(track_index, moved_start, item) {
3859                        *self = backup;
3860                        return false;
3861                    }
3862                } else {
3863                    self.children[track_index].insert_at_time(
3864                        moved_start,
3865                        item,
3866                        overlap_policy,
3867                        InsertPolicy::SplitAndInsert,
3868                    );
3869                }
3870            }
3871        }
3872        if self.get_item(&selected_id).is_none() {
3873            *self = backup;
3874            return false;
3875        }
3876        // Whatever the placement pushed carries its sync partners along, on
3877        // every track holding them.
3878        if overlap_policy == OverlapPolicy::Push
3879            && !self.push_partners_of_shifted_clips(
3880                &starts_before,
3881                moved_start,
3882                moved_duration,
3883                &placed_ids,
3884                Some(sync_clips_id),
3885            )
3886        {
3887            *self = backup;
3888            return false;
3889        }
3890
3891        self.sanitize_preserving_all_gap_tracks();
3892        true
3893    }
3894}
3895
3896pub(super) fn range_is_gap_backed(track: &Track, start: Seconds, end: Seconds) -> bool {
3897    if start < -EPS || end < start - EPS {
3898        return false;
3899    }
3900    if end <= start + EPS {
3901        return true;
3902    }
3903
3904    let total = track.total_duration();
3905    if start >= total - EPS {
3906        return true;
3907    }
3908
3909    let mut pos: Seconds = 0.0;
3910    for item in &track.items {
3911        let item_start = pos;
3912        let item_end = pos + item.duration().max(0.0);
3913        if item_end > start + EPS && item_start < end - EPS {
3914            if !matches!(item, Item::Gap(_)) {
3915                return false;
3916            }
3917        }
3918        pos = item_end;
3919    }
3920    true
3921}
3922
3923pub(super) fn track_is_empty_boundary(track: &Track) -> bool {
3924    track.items.iter().all(|item| matches!(item, Item::Gap(_)))
3925}
3926
3927fn range_has_blocking_clip(
3928    track: &Track,
3929    start: Seconds,
3930    end: Seconds,
3931    sync_clips_id: Option<i64>,
3932) -> bool {
3933    if start < -EPS || end < start - EPS {
3934        return true;
3935    }
3936    if end <= start + EPS {
3937        return false;
3938    }
3939
3940    let total = track.total_duration();
3941    if start >= total - EPS {
3942        return false;
3943    }
3944
3945    let mut pos: Seconds = 0.0;
3946    for item in &track.items {
3947        let item_start = pos;
3948        let item_end = pos + item.duration().max(0.0);
3949        if item_end > start + EPS && item_start < end - EPS {
3950            match item {
3951                Item::Gap(_) => {}
3952                Item::Clip(clip)
3953                    if sync_clips_id.is_some()
3954                        && resolve_sync_clips_id(&clip.metadata) == sync_clips_id => {}
3955                Item::Clip(_) => return true,
3956            }
3957        }
3958        pos = item_end;
3959    }
3960    false
3961}
3962
3963pub(super) fn split_gap_boundary(track: &mut Track, time: Seconds) {
3964    let Some(index) = track.get_item_at_time(time) else {
3965        return;
3966    };
3967    let item_start = track.start_time_of_item(index);
3968    let local = time - item_start;
3969    if local <= EPS {
3970        return;
3971    }
3972
3973    let mut gap = match track.items.remove(index) {
3974        Item::Gap(gap) => gap,
3975        other => {
3976            track.items.insert(index, other);
3977            return;
3978        }
3979    };
3980    let total = gap.source_range.duration.to_seconds().max(0.0);
3981    if local >= total - EPS {
3982        track.items.insert(index, Item::Gap(gap));
3983        return;
3984    }
3985
3986    let mut left = gap.clone();
3987    left.source_range.duration.set_from_seconds(local.max(0.0));
3988    gap.source_range
3989        .duration
3990        .set_from_seconds((total - local).max(0.0));
3991    gap.set_id(Some(crate::types::gen_hex_id_12()));
3992    track.items.insert(index, Item::Gap(left));
3993    track.items.insert(index + 1, Item::Gap(gap));
3994}
3995
3996fn clamp_clip_to_active_available_range(clip: &mut Clip) {
3997    clip.clamp_to_active_available_range();
3998}
3999
4000fn resize_effective_duration(
4001    item: &Item,
4002    requested_duration: Seconds,
4003    clamp_to_media: bool,
4004) -> Seconds {
4005    let mut item = item.clone();
4006    item.set_duration(requested_duration.max(0.0));
4007    if clamp_to_media {
4008        if let Item::Clip(clip) = &mut item {
4009            clamp_clip_to_active_available_range(clip);
4010        }
4011    }
4012    item.duration().max(0.0)
4013}
4014
4015fn item_source_start(item: &Item) -> Seconds {
4016    match item {
4017        Item::Clip(clip) => clip.source_range.start_time.to_seconds(),
4018        Item::Gap(gap) => gap.source_range.start_time.to_seconds(),
4019    }
4020}
4021
4022fn set_item_source_start(item: &mut Item, source_start_time: Seconds) {
4023    match item {
4024        Item::Clip(clip) => {
4025            clip.source_range
4026                .start_time
4027                .set_from_seconds(source_start_time);
4028        }
4029        Item::Gap(gap) => {
4030            gap.source_range
4031                .start_time
4032                .set_from_seconds(source_start_time);
4033        }
4034    }
4035}
4036
4037fn replace_track_range_with_item(
4038    track: &mut Track,
4039    range_start: Seconds,
4040    range_end: Seconds,
4041    item: Item,
4042) {
4043    let start = range_start.max(0.0);
4044    let end = range_end.max(start);
4045
4046    track.split_at_time(start);
4047    let end_split = track.split_at_time(end);
4048
4049    let start_index = track.get_item_at_time(start).unwrap_or(track.items.len());
4050    let end_index = track.get_item_at_time(end).unwrap_or(track.items.len());
4051
4052    if end_index > start_index {
4053        track.items.drain(start_index..end_index);
4054    }
4055    track.items.insert(start_index, item);
4056    // A clip whose head was cut by the end boundary keeps its id on the tail.
4057    if let Some(mut split) = end_split {
4058        track.restore_trimmed_head_id(&mut split);
4059    }
4060    track.sanitize_preserving_all_gap_track();
4061}
4062
4063fn insertion_start_or_end_for_policy(
4064    track: &Track,
4065    insert_time: Seconds,
4066    insert_policy: InsertPolicy,
4067) -> Option<Seconds> {
4068    let total = track.total_duration();
4069    let mut effective_time = insert_time;
4070    if effective_time < 0.0 {
4071        effective_time = total - effective_time;
4072    }
4073    if effective_time < 0.0 {
4074        return None;
4075    }
4076    if effective_time >= total - EPS {
4077        return Some(effective_time);
4078    }
4079
4080    let Some(item_index) = track.get_item_at_time(effective_time) else {
4081        return Some(effective_time);
4082    };
4083    let item_start = track.start_time_of_item(item_index);
4084    let item_end = item_start + track.items[item_index].duration().max(0.0);
4085    let start = match insert_policy {
4086        InsertPolicy::SplitAndInsert => effective_time,
4087        InsertPolicy::InsertBefore => item_start,
4088        InsertPolicy::InsertAfter => item_end,
4089        InsertPolicy::InsertBeforeOrAfter => {
4090            let d_start = (effective_time - item_start).abs();
4091            let d_end = (item_end - effective_time).abs();
4092            if d_start <= d_end {
4093                item_start
4094            } else {
4095                item_end
4096            }
4097        }
4098    };
4099    Some(start)
4100}
4101
4102fn resolve_sync_clips_id(metadata: &serde_json::Value) -> Option<i64> {
4103    Clip::resolve_otio_i64(metadata, "Link Group ID")
4104}
4105
4106pub(super) fn set_resolve_sync_clips_id(metadata: &mut serde_json::Value, sync_clips_id: i64) {
4107    if metadata.as_object().is_none() {
4108        *metadata = serde_json::Value::Object(serde_json::Map::new());
4109    }
4110    let map = metadata.as_object_mut().unwrap();
4111    let resolve = map
4112        .entry("Resolve_OTIO".to_string())
4113        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
4114    if resolve.as_object().is_none() {
4115        *resolve = serde_json::Value::Object(serde_json::Map::new());
4116    }
4117    resolve.as_object_mut().unwrap().insert(
4118        "Link Group ID".to_string(),
4119        serde_json::Value::Number(serde_json::Number::from(sync_clips_id)),
4120    );
4121}
4122
4123fn remove_resolve_sync_clips_id(metadata: &mut serde_json::Value) -> bool {
4124    let Some(resolve) = metadata
4125        .get_mut("Resolve_OTIO")
4126        .and_then(|value| value.as_object_mut())
4127    else {
4128        return false;
4129    };
4130    resolve.remove("Link Group ID").is_some()
4131}