From 8c306986ab2056b7b830546e361f69edb0129020 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 27 Aug 2026 10:13:11 +0800 Subject: [PATCH] app: stamp clips with a stable creation-time color and footage-name label --- crates/oak-app/src/oakui/graphops.rs | 153 +++++++++++++++++++++------ crates/oak-app/src/oakui/real.rs | 19 +++- 2 files changed, 140 insertions(+), 32 deletions(-) diff --git a/crates/oak-app/src/oakui/graphops.rs b/crates/oak-app/src/oakui/graphops.rs index 082728cb1..b195988f6 100644 --- a/crates/oak-app/src/oakui/graphops.rs +++ b/crates/oak-app/src/oakui/graphops.rs @@ -1206,6 +1206,59 @@ pub fn set_track_height(p: &ProjectRef, track: NodeId, height: f64) { } } +/// The stable per-clip color index, fixed when the clip is created: the +/// node identity is unique per creation and never shifts when clips are +/// added or removed elsewhere, so a clip keeps its color for its whole +/// life (persisted through [`crate::oakui::real`]'s `override_color`). +fn clip_color_index(id: NodeId) -> i32 { + (id.identity() % 8) as i32 +} + +/// The display name of `footage` for clip labels: its node label (the +/// imported basename), falling back to the file basename. +fn footage_display_name(g: &Graph, footage: NodeId) -> String { + if let Some(e) = g.get(footage) { + if !e.core.label.is_empty() { + return e.core.label.clone(); + } + } + footage_behavior(g, footage) + .and_then(|f| { + Path::new(&f.filename) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + }) + .unwrap_or_default() +} + +/// Create a footage clip block in `g`: the shared span/state setup plus +/// the per-clip color and footage-name label, both fixed at creation +/// time (C++ `oaknode_clip_set_media_in` + +/// `oaknode_block_set_length_and_media_in`). +fn create_footage_clip( + g: &mut Graph, + footage: NodeId, + media_in: Rational, + length: Rational, +) -> NodeId { + let label = footage_display_name(g, footage); + let (core, behavior) = oak_node::block::clip_create(); + let id = g.add_node(core, behavior); + if let Some(entry) = g.get_mut(id) { + entry.core.override_color = clip_color_index(id); + entry.core.label = label; + if let Some(c) = entry + .behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + { + c.core.media_in = media_in; + c.core.set_length_and_media_in(length); + } + } + id +} + /// Place a clip of `footage` on track `track_index` of the sequence's /// `kind` list (undoable "Add Clip", one row): the clip block is created /// in the project, placed by the module's `TrackPlaceBlockCommand`, and @@ -1261,22 +1314,11 @@ pub fn place_footage_clip( let media_r = ts_to_rational(media_in_ts, tb); let length = out_r - in_r; - // The clip block, positioned by media-in + length (the facade's - // `oaknode_clip_set_media_in` + `oaknode_block_set_length_and_media_in`). + // The clip block, positioned by media-in + length and stamped with + // its creation-time color + footage-name label. let clip = { let mut g = lock(p); - let (core, behavior) = oak_node::block::clip_create(); - let id = g.graph.add_node(core, behavior); - if let Some(c) = g - .graph - .get_mut(id) - .and_then(|e| e.behavior.as_any_mut()) - .and_then(|a| a.downcast_mut::()) - { - c.core.media_in = media_r; - c.core.set_length_and_media_in(length); - } - id + create_footage_clip(&mut g.graph, footage, media_r, length) }; let place = oak_timeline::undopointer::TrackPlaceBlockCommand::new( @@ -1324,21 +1366,12 @@ pub fn place_footage_clips_linked( let length = out_r - in_r; // Create all clips first (graph writes are not undoable; the placement - // commands below are). + // commands below are). Each gets its creation-time color + footage + // name label. let mut clips = Vec::with_capacity(placements.len()); for _ in placements { let mut g = lock(p); - let (core, behavior) = oak_node::block::clip_create(); - let id = g.graph.add_node(core, behavior); - if let Some(c) = g - .graph - .get_mut(id) - .and_then(|e| e.behavior.as_any_mut()) - .and_then(|a| a.downcast_mut::()) - { - c.core.media_in = media_r; - c.core.set_length_and_media_in(length); - } + let id = create_footage_clip(&mut g.graph, footage, media_r, length); clips.push(id); } @@ -2048,17 +2081,14 @@ pub fn paste_clips( let clip = { let mut g = lock(p); - let (core, behavior) = oak_node::block::clip_create(); - let id = g.graph.add_node(core, behavior); + let id = create_footage_clip(&mut g.graph, item.footage, media_r, length_r); if let Some(c) = g .graph .get_mut(id) .and_then(|e| e.behavior.as_any_mut()) .and_then(|a| a.downcast_mut::()) { - c.core.media_in = media_r; c.core.speed = item.speed; - c.core.set_length_and_media_in(length_r); } id }; @@ -2253,6 +2283,69 @@ mod tests { assert!(set_track_locked(&project, seq, true).is_err()); oak_undo::global::clear().unwrap(); } + + /// Every clip is stamped at creation with the footage's name as its + /// label and a color index fixed for its whole life — adding or + /// removing other clips must not reshuffle it (the old + /// track-position-derived color did). + #[test] + fn clips_get_a_creation_time_label_and_stable_color() { + let _g = test_lock(); + oak_undo::global::clear().unwrap(); + let project = create_project(); + let seq = create_sequence(&project, "Clip Color"); + let media = std::env::temp_dir().join(format!("oak_clip_color_{}.mp4", std::process::id())); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); + let footage = import_footage(&project, &media).expect("import"); + let name = media.file_name().unwrap().to_string_lossy().into_owned(); + + let color_of = |p: &ProjectRef, id: NodeId| -> i32 { + let g = lock(p); + g.graph.get(id).expect("the clip node exists").core.override_color + }; + + // Label = the footage name; the color is locked in at creation. + let first = place_footage_clip(&project, seq, footage, TrackType::Video, 0, 0, 10, 0) + .expect("place first"); + let color1 = color_of(&project, first); + assert_ne!(color1, -1, "the clip color is locked in at creation"); + { + let g = lock(&project); + assert_eq!( + g.graph.get(first).unwrap().core.label, + name, + "the clip is named after its footage" + ); + } + + // Adding another clip must not reshuffle the first one's color. + let _second = place_footage_clip(&project, seq, footage, TrackType::Video, 0, 10, 20, 0) + .expect("place second"); + assert_eq!( + color_of(&project, first), + color1, + "the color survives later edits" + ); + + // The linked A/V drop names both clips after the footage too. + let linked = place_footage_clips_linked( + &project, + seq, + footage, + &[(TrackType::Video, 0), (TrackType::Audio, 0)], + 20, + 30, + 0, + ) + .expect("linked placement"); + for clip in linked { + let g = lock(&project); + assert_eq!(g.graph.get(clip).unwrap().core.label, name); + } + + oak_undo::global::clear().unwrap(); + let _ = std::fs::remove_file(&media); + } } #[cfg(test)] diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 6bd2b2865..c18df3778 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -2891,12 +2891,27 @@ impl RealEngine { .filter_map(|(clip_index, &block)| { let (in_r, out_r, media_r) = graphops::clip_range(graph, block)?; let to_ts = |r: oak_core::Rational| tb.map(|tb| graphops::rational_to_ts(r, tb)).unwrap_or(0); + // The clip's color is locked in at creation time (its + // `override_color`); clips without one (older projects) + // fall back to the track-relative palette so their color + // still varies. + let entry = graph.get(block)?; + let color = if entry.core.override_color >= 0 { + clip_color(entry.core.override_color as u64) + } else { + clip_color(clip_index as u64) + }; + let label = if entry.core.label.is_empty() { + format!("Clip {}", clip_index + 1) + } else { + entry.core.label.clone() + }; Some(RealClip { id: ClipId(block.identity()), range: FrameRange::new(Frame(to_ts(in_r)), Frame(to_ts(out_r))), media_in: Frame(to_ts(media_r)), - label: format!("Clip {}", clip_index + 1).into(), - color: clip_color(clip_index as u64), + label: label.into(), + color, block, }) })