* M3: Add shared custom colors and complete marker editing
* #1435: refine native colors and range endpoint flags
Use the macOS Colors panel for custom label colors and reuse the established egui picker on other hosts. Preserve draft confirmation, exact sRGB values, native text shortcuts and project ownership. Compact the swatches and center the add control, split range glyphs across their endpoints, localize the new controls, protect newer marker edits from stale dialogs, and cover the behavior with rendered UI and hit-test regressions.
Co-authored-by: Codex <noreply@openai.com>
* #1435: preserve imported palettes and marker double-click ownership
Union saved OTIO palettes across timelines with deduplication, bounded capacity,
first-name preservation and explicit overflow warnings. Keep null authors
consistent with omitted fields, and consume marker double-clicks before the
work-area gesture so opening the editor does not alter project history.
Correct the CSV URL column and schema documentation. Add six regressions for
palette retention/capacity, author handling, and physical overlapping gestures.
Validation: full cargo xtask ci (3666 passed, 45 skipped), 42 rendered UI tests,
and strict workspace/all-target Clippy on Rust 1.99.1.
Co-authored-by: Codex <noreply@openai.com>
* #1435: retain OTIO swatch names and generator labels
Ignore orphan name entries when retaining saved palette names so input ordering
does not hide valid names after the first 64 entries. Keep names bounded by the
retained palette and preserve existing valid names.
Include generator preset labels alongside custom label colors in OTIO metadata.
Add regressions for orphan-prefixed name maps and three generator kinds with
preset/custom colors through actual export/import round trips.
Validation: both regressions failed before fixes; all 72 interchange tests and
full cargo xtask ci pass (3668 tests, 45 skipped), plus strict workspace/all-target
Clippy on Rust 1.99.1.
Co-authored-by: Codex <noreply@openai.com>
* #1435: preserve distinct OTIO generator item identity
Use exported FilmCraft item IDs for generator media identity so identical
content can retain distinct source names, labels and custom colors. Repeated
references to one source item still share a single imported media entry.
Preserve content-based fallback for missing or invalid item IDs. Cover black
video, bars and tone, and color mattes with distinct items/shared references,
plus legacy and malformed identity metadata.
Validation: distinct-generator regression failed before fix; legacy compatibility
passed. All 74 interchange tests and final full cargo xtask ci pass (3670 tests,
45 skipped); strict workspace/all-target Clippy also passes on Rust 1.99.1.
Co-authored-by: Codex <noreply@openai.com>
* #1435: align Edit Marker layout and add safe marker navigation
Give the editor a wide aligned form, larger Notes area, side action column,
and grouped palette with vertical marker types. Expand the Web Link field
with the form and keep actions accessible in small windows.
Previous/Next saves valid edits as one Undo step and stays with the original
sequence/source owner, including coincident markers. Invalid drafts stay open;
unchanged navigation and Done add no history. Keep native/shared color pickers,
Author, Notes, URL validation and immediate marker creation.
Validation: all 45 rendered sequence/marker UI tests pass, including five new
navigation/owner/validation/layout regressions. Native macOS layout, picker
Cancel and exact single-step Undo restoration verified. Full standard cargo
xtask ci passes 3675 tests (45 skipped), doctests and all remaining gates;
strict Rust 1.99.1 workspace/all-target Clippy also passes.
Co-authored-by: Codex <noreply@openai.com>
* #1435: refine Edit Marker proportions and action alignment
Narrow the dialog to 680 logical pixels and keep the core order Name,
Time/Duration, Notes, Color and marker type. Place Author after the type-specific
options as secondary metadata. Preserve our palette, custom picker and range controls.
Use consistently sized, centered rounded action buttons with a primary Done action.
Shorten Delete Marker to Delete and remove the navigation helper and unused translations.
Keep the existing navigation save/validation/Undo semantics and automation IDs.
Validation: all 45 rendered marker/sequence UI tests pass, including small-window
action accessibility. Actual macOS layout and no-op Previous/Next/Cancel verified.
Full standard cargo xtask ci passes 3675 tests (45 skipped), doctests, formatting,
Clippy, translations, layers, assets and 49 WASM crates. Strict Rust 1.99.1
workspace/all-target Clippy passes separately.
Co-authored-by: Codex <noreply@openai.com>
* #1435: tighten marker layout and validate text editing
Use a narrower aligned form with Author below Name, bounded scrolling Notes,
left-aligned colors, centered actions and an inline Web Link URL/open icon.
Remove the legacy Flash Cue choice while preserving imported marker types.
Constrain timecode typing/paste to complete numeric colon-separated fields,
validate drop-frame labels and frame bounds, and disable Done for invalid timing.
Preserve high-frame-rate timecodes and point-marker semantics when Duration is
unchecked. Route native/menu clipboard and text Undo commands to focused fields;
explicit engine commands retain project Undo and selection semantics.
Validation: standard cargo xtask ci passes 3679 tests (45 standard skips),
doctests, fmt, strict workspace/all-target Clippy, i18n, layers, 69 assets and
49 WASM crates. Also passed 48 rendered marker/sequence tests and strict Rust
1.99.1 Clippy. Actual macOS clipboard, bounded Notes, point save and exact Undo
were checked. Windows/Linux desktop interaction remains pending.
Co-authored-by: Codex <noreply@openai.com>
* M0: Wait for frame-worker recovery before asserting
Do not treat a nonempty request queue as proof of worker death in the
panic-recovery regression. Keep waiting for all injected faults and enough
post-fault completions, then drain the queue without adding more seeks.
Retain the exact fault-count, successful-recovery and empty-queue assertions
and bound the wait with a two-minute deadline.
The inherited test failed twice in independent fork CI. It did not reproduce
locally: baseline passed 20 normal and 10 delayed-report runs; the repair
passed 50 normal and 10 delayed-report runs. No product or marker UI changes.
Validation: standard cargo xtask ci passed all 4485 tests (52 standard skips),
doctests, formatting, strict workspace/all-target Clippy, i18n, layers,
69 assets and 49 WASM crates.
Assisted-by: OpenAI Codex
---------
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Codex <codex@openai.com>
Problem: slip checked frame hold before media length, so a graphic stretched left (source_in below 0) and then frame-held got the frame-hold floor of 0: slipping it 1 frame jumped it to 0.
Solution: slip checks media with no length first, as head() does, so a frame-held graphic has no floor either.
Tested with a_slip_moves_a_frame_held_stretched_graphic_by_what_was_asked, which fails without the change.
Problem: a graphic stretched left has a source_in below 0. slip still floored source_in at 0, so slipping it 1 frame jumped it back to 0. head() checked frame hold before the no-length rule, so a frame-held graphic with a negative source_in turned into a positive limit and a roll moved the cut the wrong way.
Solution: head() checks media with no length first, then frame hold. slip has no floor for media with no length, frame holds and real media keep theirs.
Tested with a_slip_moves_a_stretched_graphic_by_what_was_asked and a_roll_extends_a_frame_held_graphic, which fail without the change.
Problem: head() treated graphics, stills and adjustment layers like video. Their source_in starts at 0, so head() answered 0 and trim, roll and slide could not extend them earlier from their left edge.
Solution: media with no length now has no head limit, like its tail. Its source_in may go below 0, so keyframes keep their timeline positions and extending then shortening round-trips.
Tested with a_graphic_extends_from_its_left_edge, a_graphic_ripple_extends_from_its_left_edge, a_roll_extends_a_graphic_from_its_left_edge and a_slide_extends_a_graphic_from_its_left_edge, which fail without the change.
Fixes#3367
* engine: grouped clips select together
clip.group stored a group id on each item, but nothing read it: clicking a
grouped clip selected only that clip (and its linked partners), so groups
never moved, trimmed or deleted together.
timeline.select now expands the selection with with_groups_and_links: group
members and linked partners, repeated until nothing more joins, so clicking
the audio partner of a grouped video clip also brings the group along.
Alt-click (linked: false) still picks a single member.
Adds grouped_clips_select_together.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjftakwWmCfps6q4KGGnDe
* engine: copies of a group get a group of their own
Paste, Option-drag copy (edit::copy_items), Make Subsequence and placing
a sequence's clips gave each copy a fresh clip and link id but kept the
original's group id. The copies then joined the originals' group, so
clicking either one selected both: the next Option-drag copied (or a
plain drag moved) originals and copies together, and Clear deleted both.
Each of these now remaps group ids the way it remaps link ids: one new
group per copied group.
Adds copies_of_a_group_do_not_join_the_original_group (drag and paste).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjftakwWmCfps6q4KGGnDe
* engine: toggling a group flips it as a whole
timeline.select with toggle decided add or remove per expanded clip, so
toggling a partly selected group (or one whose linked partner was
selected) left it half flipped. Decide once from the requested clips'
selection state and apply that to every grouped and linked clip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjftajchQQEwwLgiUDrtcn
* engine: dedupe unlinked timeline.select replacement
With linked:false, a plain timeline.select assigned the raw clips list,
so repeated ids put the same clip in the selection more than once. Keep
the first of each, in order; add/toggle and linked expansion unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjftajchQQEwwLgiUDrtcn
* engine: dedupe timeline.select replacement with a HashSet
Replace the O(n^2) Vec::contains scan with a seen-set, keeping
first-seen order, so a long hostile id list stays linear.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjftajchQQEwwLgiUDrtcn
* engine: document paste and make_subsequence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjftajchQQEwwLgiUDrtcn
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: User <user@Mac.local>
#3236 and #3115 each added the same clamp of a transition to its two clips. Both now use keep_within_clips.
#3115's roll_reach repeated #3155's commands::roll_delta without its check that every linked cut rolls by the same amount, so the drag could show a roll that timeline.roll then refuses. The drag uses roll_delta.
A Regular trim of the second clip's Out edge, or of the first clip's In
edge, could leave a transition between the two clips reaching past
them, and so could a Speed/Duration change without ripple that made
one of them shorter. Only fades to or from nothing followed the trimmed
edge. Such a transition is now shortened when the clips are shorter
than it and moved back inside them, and its stored alignment follows
where that leaves it on the cut.
transitions_follow_cuts kept a transition within its two clips only when its cut moved. A ripple trim of the second clip's Out edge leaves the cut in place, so the transition could end past the clip. The check now runs for every transition between two clips.
* Timeline: Option-drag duplicates captions
Option-dragging a caption block leaves the original in place and drops a copy where it is
released; the cursor turns into a copy cursor and the original stays drawn under the copy while
dragging. A drag now carries the whole caption selection, not only the grabbed block.
New command `captions.duplicate` {captions, delta|deltaFrames}: copies on their own tracks,
never before 0, selected afterwards, one undo step. Captions never overlap, so
`edit::captions::overwrite_captions` places copies over what is there: captions under a copy are
trimmed, split around it or removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* captions.duplicate: refuse an empty caption list and copy each caption once; move its UI test into the shared ui-egui test binary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Found while testing crates/edit/src/through.rs:21 (is_through_edit) and :61-102 (join_through_edits_where)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
In a selected transition's own timeline in Effect Controls, the cut line
over the transition can now be dragged. While it is dragged, the clips
and the transition are drawn where the rolling edit would put them, with
the same limits as `timeline.roll`. On release one rolling edit moves
the cut, so the clips' In and Out points change and the sequence keeps
its length. The ends of the transition still resize it, and the rest of
it still slides it.
A rolling edit now stops where the transition on its cut would leave
one of its two clips. When a ripple trim, a slide or a ripple speed
change moves a cut far enough that the transition on it would leave its
two clips, the transition is moved back within them, shortened when the
clips are now shorter than it, and its stored alignment is updated.
Reuse caption range extraction for actual closed spans inside the atomic edit. Cover imported captions, multiple spans, locked tracks, rollback and undo/redo.
AI assistance: Antigravity implementation and independent Codex review and validation.
Reject repeated source IDs before moving items, and normalize selection-derived plans so nudges and drags edit each selected identity once. Keep new-track intent per copy entry so repeated sources retain their requested destinations.
Cover failed-move history preservation, corrected retry, repeated-copy destinations, and Frame Hold followed by dragging.
Signed-off-by: Earl Co <hello@tekiz.ai>
A regular trim that opened a gap at a crossfade's cut (or a razor through
the outgoing clip, or an insert on the cut) left the transition floating
over the gap: remove_orphan_transitions only checked that its clips
existed, not that they still met.
remove_orphan_transitions now also drops a two-clip transition whose
outgoing clip no longer ends where the incoming one starts; a one-sided
fade still needs only its clip.
Session::edit (and edit_merged), which every command goes through, runs
it on each sequence the edit changed, next to the existing self-nesting
check. Sequences an edit did not touch are skipped (Arc::ptr_eq), so a
project saved with stale transitions keeps them until edited. The five
per-command sweeps this replaces (Clear, Nest, Make Subsequence, paste,
Simplify Sequence) are removed.
Dragging a clip kept its transitions on their old spots, so they jumped to the
dropped position on release instead of moving with the clip. The drag preview now
draws each transition where the drop will leave it, applying the same rule the
edit does: a fade travels with its clip, and a cut transition travels only when
every clip it sits on moves to one track by one offset. A cut transition whose
one side stays behind is dropped on release; the preview keeps it on its cut
while dragging, and Option-drag copies show a ghost transition beside the
copies, duplicating only the transitions whose every clip is copied.
- edit: carried_transitions keeps only all-clips-move transitions; move_items
and copy_items drop or skip the rest
- ui: move_transition_preview draws carried transitions where they land
- tests: timeline_transition_move_ui (fade, both-clips cut, XYZ partial drop,
cross-track drop, Option-drag ghost); transitions_travel_with_moved_clips
updated to the drop-on-partial rule
rate_stretch_items skips clips on locked tracks and refuses the edit when
every clip is locked, which is what the command loop on main did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Non-ripple clip.speedDuration shortened the clip but left one-sided
out-edge transitions at the old timeline range. Reposition them with
the same edge-follow used for a regular trim.
A neighbour on only one track used to clamp that partner alone, so picture
and sound stayed linked at different rates. The tightest room now limits
the whole group.
Trim, roll, slip, slide, rate stretch and Speed/Duration now fail with
"track is locked" instead of changing clips on a locked track, matching
same-track move. Linked partners on locked tracks are left alone.
Holding Option (Alt) while dragging timeline clips now leaves the
originals in place and drops copies where the pointer lets go, as in
Premiere. The copies are selected; copies of linked clips are linked to
each other, and transitions that would travel with a move are copied.
- edit: copy_items (overwrite / insert, new ids, links and transitions)
- engine: timeline.move takes `copy: true`, returns `copied`
- ui: the drag remembers Option, shows the copy under the pointer while
the originals stay, copy cursor
- tests: engine copy/link/locked-track test, headless Option-drag UI test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: dragging a clip's edge with the Selection tool (a regular trim)
moved the edge but left a fade in from nothing / fade out to nothing where
it was; only Ripple trims moved transitions along with the cut.
Fix: after a regular trim, the trimmed clip's one-sided transitions are put
back on its edges, and shortened when the clip gets shorter than them.
Transitions between two clips are untouched (a regular trim cannot move
their cut).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: moving a clip dropped the transitions at its edges (#227, point 8
of #374). `edit::move_items` deletes the moved clips before placing them
again, and that deletion removes every transition that refers to them, so
a fade on a clip's edge, or the cut between two clips moved together,
vanished.
Fix: before the move, collect the transitions whose clips all move onto one
track by one offset, and put them back on that track shifted by the offset
once the clips have landed (overwrite and insert moves). A crossfade whose
partner stays behind is still dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: timeline.slideLeft/Right(5) slid every expanded selection member by
the raw requested delta, so when a neighbour limited only one linked partner
(e.g. a short sound-only follower), picture and sound ended up at different
starts. timeline.slide already clamped to a common delta.
Fix: add edit::slide_items, which clamps the delta by the most restrictive
item and slides all items by it, and use it from both the keyboard Slide
commands and timeline.slide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing a gap on a locked track skipped that track but still moved the
sync-locked ones, pulling them out of sync with it. It is now refused
with the usual "locked" error.
While an edge was dragged, the Timeline drew the clip at its new length
from the untrimmed item, so draw_waveform spread the clip's whole source
range over the shorter (or longer) box: the waveform squeezed and the
sound under the pointer moved, which made it hard to trim to where the
music starts (#374, item 13). Trim and roll previews now draw from the
item the edit will leave, so every timeline time still covered shows the
same media and the waveform is cut off at the moving edge. Video
thumbnails show the new first frame the same way. Rate Stretch, which
really fits the same media into a new length, still squeezes.
The item update is factored out of trim and ripple_trim_group into
filmcraft_edit::move_edge, which the preview uses too, so preview and
edit agree for speed-changed, reversed and frame-held clips.
Rendered mid-drag against the released result, the music clip's
waveform matched in 15% of pixel columns before and 100% after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>