app: stamp clips with a stable creation-time color and footage-name label

This commit is contained in:
2026-08-27 10:13:11 +08:00
parent c51a349070
commit 8c306986ab
2 changed files with 140 additions and 32 deletions
+123 -30
View File
@@ -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::<ClipBlockBehavior>())
{
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::<ClipBlockBehavior>())
{
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::<ClipBlockBehavior>())
{
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::<ClipBlockBehavior>())
{
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)]
+17 -2
View File
@@ -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,
})
})