timeline: drag generators onto the timeline, drop transitions at clip edges

Generator effects (bars, checkerboard) can be dragged from the library
onto the timeline, where they land as a standalone five-second clip
built from the node factory; the inspector shows the generator's
parameters as the clip's own chain.

Transitions are no longer junction-only. The render planner accepts a
transition with at least one wired neighbor and blends the missing
side against transparent black, so head transitions fade in from black
and tail transitions fade out to black. add_transition_at_edge creates
those single-sided blocks (wired to just the IN or OUT block), the
default-transition command covers both ends of a lone clip, and an
effect drag dropped near a clip edge routes to the nearest seam or
edge within a one-second window.
This commit is contained in:
2026-09-11 08:49:08 +08:00
parent 16364d414a
commit 2817ac286c
7 changed files with 983 additions and 49 deletions
+32
View File
@@ -1094,6 +1094,38 @@ pub trait AppEngine:
Err("no project open".to_string())
}
/// Drops a GENERATOR effect (checkerboard / color bars / shape / …)
/// from the effect library onto the timeline at `(track_index, time)`:
/// creates the generator node and places a 5-second generator clip fed
/// by it — a standalone clip, not an effect on another clip (the
/// inspector just shows the generator's params). Default: unsupported.
fn drop_generator_clip(
&mut self,
type_id: &str,
track_index: usize,
time: Frame,
cx: &mut Context<Self>,
) -> Result<(), String> {
let _ = (type_id, track_index, time, cx);
Err("no project open".to_string())
}
/// Drops a TRANSITION effect onto the timeline near `(track_index,
/// time)`: snaps to the nearest clip edge on that track and creates a
/// transition there — a junction transition when the edge is a
/// contiguous cut, otherwise a single-sided head/tail transition
/// (PR-style edge drops). Default: unsupported.
fn drop_transition_at(
&mut self,
type_id: &str,
track_index: usize,
time: Frame,
cx: &mut Context<Self>,
) -> Result<(), String> {
let _ = (type_id, track_index, time, cx);
Err("no project open".to_string())
}
/// Applies new name/format/interlaced parameters to the sequence `id`.
fn update_sequence_parameters(
&mut self,
+293 -26
View File
@@ -1851,19 +1851,119 @@ pub fn add_transition_at_seam(
Ok(block)
}
/// The undo commands of [`add_transition_at_edge`], unpushed (the batch
/// paths build their own combined rows).
fn edge_transition_commands(
p: &ProjectRef,
clip: NodeId,
start_edge: bool,
length: Rational,
) -> Result<(NodeId, Vec<oak_undo::undocommand::UndoCommand>), String> {
if length <= Rational::new(0, 1) {
return Err("transition: the length must be positive".to_string());
}
let (track, anchor, edge_time) = {
let g = lock(p);
if clip_behavior(&g.graph, clip).is_none() {
return Err("transition: the block is not a clip".to_string());
}
let Some(core) = block_core_of(&g.graph, clip) else {
return Err("transition: the clip has no block core".to_string());
};
let Some(track) = core.track else {
return Err("transition: the clip is not on a track".to_string());
};
let Some(blocks) = track_behavior(&g.graph, track).map(|t| t.blocks.clone()) else {
return Err("transition: the track is not in the project".to_string());
};
let Some(index) = blocks.iter().position(|&b| b == clip) else {
return Err("transition: the clip is not on the track".to_string());
};
if transition_of_clip(&g.graph, clip, start_edge).is_some() {
return Err("transition: the edge already carries a transition".to_string());
}
// The head transition inserts before the clip (after whatever block
// precedes it); the tail inserts after the clip.
let anchor = if start_edge {
index.checked_sub(1).and_then(|i| blocks.get(i)).copied()
} else {
Some(clip)
};
let edge_time = if start_edge { core.in_() } else { core.out() };
(track, anchor, edge_time)
};
let block = {
let mut g = lock(p);
let (core, behavior) = oak_node::block::transition_create();
let id = g.graph.add_node(core, behavior);
let Some(t) = g
.graph
.get_mut(id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::block::TransitionBlockBehavior>())
else {
return Err("transition: could not create the block".to_string());
};
if start_edge {
// Head: no outgoing side — the whole span eats into the clip.
t.core.range = TimeRange::new(edge_time, edge_time + length);
t.in_offset = Rational::new(0, 1);
t.out_offset = length;
} else {
t.core.range = TimeRange::new(edge_time - length, edge_time);
t.in_offset = length;
t.out_offset = Rational::new(0, 1);
}
id
};
let input = if start_edge {
oak_node::block::transition_input::IN_BLOCK
} else {
oak_node::block::transition_input::OUT_BLOCK
};
let insert = oak_timeline::undotrack::TrackInsertBlockAfterCommand::new(
node_ref(p, track),
node_ref(p, block),
anchor.map(|b| node_ref(p, b)),
)
.to_command();
let edge = connect_command(p, clip, block, input)?;
Ok((block, vec![insert, edge]))
}
/// Create a SINGLE-SIDED transition on one edge of `clip` (PR-style: a
/// head fade-in when nothing precedes the clip, a tail fade-out when
/// nothing follows it) and push it as one "Add Transition" undo row.
/// Only the clip itself is wired (`in_block_in` for the head,
/// `out_block_in` for the tail); the renderer blends the open side
/// against black. An edge that already carries a transition is an error.
pub fn add_transition_at_edge(
p: &ProjectRef,
clip: NodeId,
start_edge: bool,
length: Rational,
) -> Result<NodeId, String> {
let (block, commands) = edge_transition_commands(p, clip, start_edge, length)?;
push_multi(commands, "Add Transition")?;
Ok(block)
}
/// Add a default transition at every contiguous seam around the selected
/// clips (the Ctrl+Shift+D action / the timeline clip-menu item).
/// clips, plus BOTH ends of any clip with no junction at all (the
/// Ctrl+Shift+D action / the timeline clip-menu item — PR's
/// apply-to-edit-points semantics, extended to lone clips).
///
/// `half` is half of the default transition length: the transition spans
/// `half` on either side of its seam. Every selected clip contributes the
/// seam before it and the seam after it; a neighbor only counts when it is
/// a clip block (not a gap, a transition or an adjustment layer) that
/// touches the clip exactly, and a seam shared by two selected clips is
/// built once. Seams that cannot take a transition (an existing
/// transition, a non-contiguous neighbor) are skipped. The whole batch is
/// one "Add Transition" undo row; a batch that builds nothing is an error,
/// so the action never leaves an empty row. Returns the number of
/// transitions created.
/// `half` is half of the default transition length: a junction transition
/// spans `half` on either side of its seam, a head/tail (single-sided)
/// transition spans `2*half` into its own clip. Every selected clip
/// contributes the seam before it and the seam after it (a neighbor
/// counts only when it is a clip block that touches the clip exactly,
/// shared seams built once), plus a head transition when nothing
/// precedes it and a tail transition when nothing follows it. Seams and
/// edges that cannot take a transition (an existing one) are skipped.
/// The whole batch is one "Add Transition" undo row; a batch that builds
/// nothing is an error, so the action never leaves an empty row. Returns
/// the number of transitions created.
///
/// `seq` scopes the action: a selected block whose track list belongs to
/// another sequence is ignored.
@@ -1882,12 +1982,12 @@ pub fn add_default_transition(
};
let mut seams: Vec<(NodeId, NodeId)> = Vec::new();
// Standalone ends, applied ONLY when the clip has no junction at all
// (PR's both-ends-on-a-lone-clip default): `(clip, start_edge)`.
let mut edges: Vec<(NodeId, bool)> = Vec::new();
for &clip in clip_blocks {
let candidates: Vec<(NodeId, NodeId)> = {
let (candidates, touches_prev, touches_next) = {
let g = lock(p);
if clip_behavior(&g.graph, clip).is_none() {
continue;
}
let Some(track) = clip_track(&g.graph, clip) else {
continue;
};
@@ -1903,34 +2003,60 @@ pub fn add_default_transition(
let Some(index) = blocks.iter().position(|&b| b == clip) else {
continue;
};
if clip_behavior(&g.graph, clip).is_none() {
continue;
}
let range = block_core_of(&g.graph, clip).map(|c| (c.in_(), c.out()));
let mut sides = Vec::new();
if let Some(&prev) = index.checked_sub(1).and_then(|i| blocks.get(i)) {
let prev = index.checked_sub(1).and_then(|i| blocks.get(i)).copied();
if let Some(prev) = prev {
if clip_behavior(&g.graph, prev).is_some()
&& block_core_of(&g.graph, prev).map(|c| c.out()) == range.map(|r| r.0)
{
sides.push((prev, clip));
}
}
if let Some(&next) = blocks.get(index + 1) {
let next = blocks.get(index + 1).copied();
if let Some(next) = next {
if clip_behavior(&g.graph, next).is_some()
&& block_core_of(&g.graph, next).map(|c| c.in_()) == range.map(|r| r.1)
{
sides.push((clip, next));
}
}
sides
let (touches_prev, touches_next) = (
sides.iter().any(|(_, next)| *next == clip),
sides.iter().any(|(prev, _)| *prev == clip),
);
(sides, touches_prev, touches_next)
};
let seam_count = candidates.len();
for seam in candidates {
if !seams.contains(&seam) {
seams.push(seam);
}
}
}
if seams.is_empty() {
return Err("transition: no selected clip borders another clip".to_string());
// A clip with no junction at all and no transition wired to it
// takes the free ends (a leading gap is "nothing" — a head
// fade-in still applies); a clip with any junction or an existing
// transition keeps just what it has (the action is idempotent: a
// second run builds nothing).
if seam_count == 0
&& transition_of_clip(&lock(p).graph, clip, true).is_none()
&& transition_of_clip(&lock(p).graph, clip, false).is_none()
{
if !touches_prev {
edges.push((clip, true));
}
if !touches_next {
edges.push((clip, false));
}
}
}
// One undo row covers the junctions AND the standalone ends; a batch
// that builds nothing is an error, so the action never leaves an empty
// row.
let mut commands = Vec::new();
let mut created = 0usize;
for (prev, next) in seams {
@@ -1939,8 +2065,15 @@ pub fn add_default_transition(
created += 1;
}
}
let edge_length = half + half;
for (clip, start_edge) in edges {
if let Ok((_, children)) = edge_transition_commands(p, clip, start_edge, edge_length) {
commands.extend(children);
created += 1;
}
}
if created == 0 {
return Err("transition: no contiguous seam accepts a transition".to_string());
return Err("transition: no seam or clip edge accepts a transition".to_string());
}
push_multi(commands, "Add Transition")?;
Ok(created)
@@ -2154,20 +2287,43 @@ pub fn place_text_clip(
track_index: usize,
in_ts: i64,
out_ts: i64,
) -> Result<NodeId, String> {
{
let g = lock(p);
if node_type_id(&g.graph, text) != TEXT_FOOTAGE_TYPE_ID {
return Err("the text entry is not in the project".to_string());
}
}
place_generator_clip(p, host_seq, text, track_index, in_ts, out_ts)
}
/// Place a clip fed by a GENERATOR node (checkerboard / color bars /
/// text / shape / solid…) at `(track_index, [in_ts, out_ts))` — the
/// effect-library drag-to-timeline counterpart of [`place_text_clip`]:
/// one undo row covers the placement and the generator→`tex_in` edge.
/// The generator must already be in the project; the clip is labelled
/// after it. Returns the clip's node id.
pub fn place_generator_clip(
p: &ProjectRef,
host_seq: NodeId,
generator: NodeId,
track_index: usize,
in_ts: i64,
out_ts: i64,
) -> Result<NodeId, String> {
if in_ts < 0 || out_ts <= in_ts {
return Err("invalid clip range (need 0 <= in < out)".to_string());
}
let (tb, list, label) = {
let g = lock(p);
if node_type_id(&g.graph, text) != TEXT_FOOTAGE_TYPE_ID {
return Err("the text entry is not in the project".to_string());
if g.graph.get(generator).is_none() {
return Err("the generator node is not in the project".to_string());
}
let tb = sequence_time_base(&g.graph, host_seq)
.ok_or_else(|| "host sequence has no valid frame rate".to_string())?;
let list = track_list_of(&g.graph, host_seq, TrackType::Video)
.ok_or_else(|| "host sequence has no video track list".to_string())?;
let label = node_label(&g.graph, text);
let label = node_label(&g.graph, generator);
(tb, list, label)
};
let track_count = {
@@ -2214,7 +2370,7 @@ pub fn place_text_clip(
in_r,
)
.to_command();
let edge = connect_command(p, text, clip, oak_node::block::clip_input::TEXTURE_INPUT)?;
let edge = connect_command(p, generator, clip, oak_node::block::clip_input::TEXTURE_INPUT)?;
push_multi(vec![place, edge], "Add Clip")?;
Ok(clip)
}
@@ -4420,6 +4576,117 @@ mod undo_cycle_ops_tests {
(project, seq, track, a, b, tb, half)
}
/// A LONE clip (no junction on either side) takes the default
/// transition on BOTH ends (PR's apply-to-a-lone-clip semantics): a
/// head fade-in wired `in_block_in` only and a tail fade-out wired
/// `out_block_in` only, each spanning `2*half` into the clip, as ONE
/// undo row — and the action is idempotent (a second run has nothing
/// left to build and leaves no empty row).
#[test]
fn default_transition_covers_both_ends_of_a_lone_clip() {
let _g = test_lock();
oak_undo::global::clear().unwrap();
let media = std::env::temp_dir()
.join(format!("oak_lone_transition_{}.mp4", std::process::id()));
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate");
let project = create_project();
let seq = create_sequence(&project, "Lone Transition");
let footage = import_footage(&project, &media).expect("import");
let clip = place_footage_clip(&project, seq, footage, TrackType::Video, 0, 10, 30, 0)
.expect("place the clip");
let (tb, track) = {
let g = lock(&project);
(
sequence_time_base(&g.graph, seq).expect("time base"),
track_ids(&g.graph, seq, TrackType::Video)[0],
)
};
let half = ts_to_rational(3, tb);
let before = oak_undo::global::count().unwrap();
let created = add_default_transition(&project, seq, &[clip], half)
.expect("add default transition");
assert_eq!(created, 2, "a lone clip takes both ends");
assert_eq!(oak_undo::global::count().unwrap(), before + 1, "one undo row");
let edge = half + half;
{
let g = lock(&project);
let blocks = track_behavior(&g.graph, track)
.map(|t| t.blocks.clone())
.unwrap_or_default();
let index = blocks.iter().position(|&b| b == clip).expect("clip on track");
// (A leading gap when the clip starts past zero,) the head
// transition, the clip, the tail transition.
let (head, tail) = (blocks[index - 1], blocks[index + 1]);
let head_b = g
.graph
.get(head)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<oak_node::block::TransitionBlockBehavior>())
.expect("head transition");
assert_eq!(
(head_b.in_offset, head_b.out_offset),
(Rational::new(0, 1), edge),
"the head transition eats only into the clip"
);
assert_eq!(
(head_b.core.in_(), head_b.core.out()),
(ts_to_rational(10, tb), ts_to_rational(10, tb) + edge),
"the head transition spans the clip's head"
);
assert_eq!(
g.graph.connected_output(head, oak_node::block::transition_input::IN_BLOCK, -1),
Some(clip),
"the head transition wires only the clip in"
);
assert_eq!(
g.graph.connected_output(head, oak_node::block::transition_input::OUT_BLOCK, -1),
None,
"the head transition has no outgoing side"
);
let tail_b = g
.graph
.get(tail)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<oak_node::block::TransitionBlockBehavior>())
.expect("tail transition");
assert_eq!(
(tail_b.in_offset, tail_b.out_offset),
(edge, Rational::new(0, 1)),
"the tail transition eats only into the clip"
);
assert_eq!(
(tail_b.core.in_(), tail_b.core.out()),
(ts_to_rational(30, tb) - edge, ts_to_rational(30, tb)),
"the tail transition spans the clip's tail"
);
assert_eq!(
g.graph.connected_output(tail, oak_node::block::transition_input::OUT_BLOCK, -1),
Some(clip),
"the tail transition wires only the clip out"
);
assert_eq!(
g.graph.connected_output(tail, oak_node::block::transition_input::IN_BLOCK, -1),
None,
"the tail transition has no incoming side"
);
// The wedges: a head transition draws on the clip's start edge, a
// tail transition on its end edge.
assert_eq!(transition_of_clip(&g.graph, clip, true), Some(head));
assert_eq!(transition_of_clip(&g.graph, clip, false), Some(tail));
}
// Idempotent: every edge already carries a transition, so a second
// run builds nothing and leaves no empty row.
let rows = oak_undo::global::count().unwrap();
assert!(add_default_transition(&project, seq, &[clip], half).is_err());
assert_eq!(oak_undo::global::count().unwrap(), rows);
oak_undo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
/// 编辑 → 设为默认转场: one transition per contiguous seam around the
/// selection — the single seam here, built once even though both clips
/// are selected — wired onto both clips as ONE undo row.
+66
View File
@@ -2015,6 +2015,72 @@ impl AppEngine for MockEngine {
Ok(id)
}
fn drop_generator_clip(
&mut self,
type_id: &str,
track_index: usize,
time: Frame,
cx: &mut Context<Self>,
) -> Result<(), String> {
// A 5-second canned clip labelled after the generator, on the
// pointed (or first) video track — the mock's footage-drop shape.
let name = crate::oakui::effectchain::addable_effects()
.into_iter()
.find(|entry| entry.type_id == type_id)
.map(|entry| entry.name)
.unwrap_or_else(|| type_id.to_string());
let target = if self
.tracks
.get(track_index)
.is_some_and(|t| t.kind == TrackKind::Video)
{
track_index
} else if let Some(index) = self.tracks.iter().position(|t| t.kind == TrackKind::Video)
{
index
} else {
return Err("no video track".to_string());
};
let fps = self.frame_rate();
let length = Frame(
(5.0 * fps.num as f64 / fps.den.max(1) as f64)
.round()
.max(1.0) as i64,
);
let clip = MockClip {
id: ClipId(self.next_mock_clip_id()),
range: FrameRange::new(Frame(time.0.max(0)), Frame(time.0.max(0) + length.0)),
media_in: Frame::ZERO,
label: name.into(),
color: Hsla {
h: 0.402,
s: 0.385,
l: 0.459,
a: 1.0,
},
};
let track = &mut self.tracks[target];
let position = track
.clips
.iter()
.position(|c| c.range.start.0 > time.0)
.unwrap_or(track.clips.len());
track.clips.insert(position, clip);
cx.notify();
Ok(())
}
fn drop_transition_at(
&mut self,
_type_id: &str,
_track_index: usize,
_time: Frame,
_cx: &mut Context<Self>,
) -> Result<(), String> {
// The mock models no transition blocks; the route is accepted.
Ok(())
}
fn drop_footage(
&mut self,
id: u64,
+245 -1
View File
@@ -5864,6 +5864,159 @@ impl AppEngine for RealEngine {
Ok(id.identity())
}
fn drop_generator_clip(
&mut self,
type_id: &str,
track_index: usize,
time: Frame,
cx: &mut Context<Self>,
) -> Result<(), String> {
let Some(project) = self.project.clone() else {
return Err("no project open".to_string());
};
let Some(host_seq) = self.sequence else {
return Err("no sequence open".to_string());
};
// The effect-library generator becomes a node in the project, then
// a generator clip feeds from it (the text-clip drop's shape).
let (core, behavior) = oak_node::factory::Factory::global()
.create_any(type_id)
.ok_or_else(|| format!("unknown effect \"{type_id}\""))?;
if !behavior.categories().contains(&oak_node::node::Category::Generator) {
return Err(format!("effect \"{type_id}\" is not a generator"));
}
let generator = {
let mut guard = graphops::lock(&project);
guard.graph.add_node(core, behavior)
};
let video_target = if self
.tracks
.get(track_index)
.is_some_and(|t| t.kind == TrackKind::Video)
{
track_index
} else if let Some(index) = self.tracks.iter().position(|t| t.kind == TrackKind::Video)
{
index
} else {
return Err("no video track".to_string());
};
let video_index = self.tracks[video_target].track_index;
// Five seconds of the host sequence's frames (the drop's default
// extent — a generator clip has no media length to take one from).
let (in_ts, length_ts) = {
let guard = graphops::lock(&project);
let tb = graphops::sequence_time_base(&guard.graph, host_seq)
.ok_or_else(|| "host sequence has no frame rate".to_string())?;
let fps_f = tb.1.max(1) as f64 / tb.0.max(1) as f64;
((time.0.max(0)), ((5.0 * fps_f).round() as i64).max(1))
};
let placed = graphops::place_generator_clip(
&project,
host_seq,
generator,
video_index,
in_ts,
in_ts + length_ts,
);
let result = placed.map(|_| ());
self.apply_edit(result.clone(), "drop generator clip", cx);
result
}
fn drop_transition_at(
&mut self,
_type_id: &str,
track_index: usize,
time: Frame,
cx: &mut Context<Self>,
) -> Result<(), String> {
let Some(project) = self.project.clone() else {
return Err("no project open".to_string());
};
let Some(host_seq) = self.sequence else {
return Err("no sequence open".to_string());
};
let video_target = if self
.tracks
.get(track_index)
.is_some_and(|t| t.kind == TrackKind::Video)
{
track_index
} else if let Some(index) = self.tracks.iter().position(|t| t.kind == TrackKind::Video)
{
index
} else {
return Err("no video track".to_string());
};
let video_index = self.tracks[video_target].track_index;
let placed = {
let guard = graphops::lock(&project);
let Some(track) = graphops::track_ids(&guard.graph, host_seq, TrackType::Video)
.get(video_index)
.copied()
else {
return Err("the target track is not in the sequence".to_string());
};
let Some(tb) = graphops::sequence_time_base(&guard.graph, track) else {
return Err("the track has no frame rate".to_string());
};
// Snap to the nearest clip edge on the target track (within one
// second): a junction when the edge is a contiguous cut,
// otherwise a single-sided head/tail transition.
let time_r = graphops::ts_to_rational(time.0.max(0), tb);
let mut best: Option<(oak_core::Rational, NodeId, bool)> = None;
for clip in graphops::clip_ids(&guard.graph, track) {
let Some((in_r, out_r, _)) = graphops::clip_range(&guard.graph, clip) else {
continue;
};
for (edge, start_edge) in [(in_r, true), (out_r, false)] {
let dist = if edge > time_r { edge - time_r } else { time_r - edge };
if best.as_ref().is_none_or(|(d, ..)| dist < *d) {
best = Some((dist, clip, start_edge));
}
}
}
let Some((dist, clip, start_edge)) = best else {
return Err("the target track has no clips".to_string());
};
if dist > oak_core::Rational::new(1, 1) {
return Err("no clip edge within one second of the drop point".to_string());
}
let Some((in_r, out_r, _)) = graphops::clip_range(&guard.graph, clip) else {
return Err("the edge clip has no range".to_string());
};
let blocks = graphops::track_behavior(&guard.graph, track)
.map(|t| t.blocks.clone())
.unwrap_or_default();
let index = blocks.iter().position(|&b| b == clip);
let prev = index.and_then(|i| i.checked_sub(1)).and_then(|i| blocks.get(i)).copied();
let next = index.and_then(|i| blocks.get(i + 1)).copied();
let prev_touch = prev.is_some_and(|b| {
graphops::clip_behavior(&guard.graph, b).is_some()
&& graphops::block_core_of(&guard.graph, b).map(|c| c.out()) == Some(in_r)
});
let next_touch = next.is_some_and(|b| {
graphops::clip_behavior(&guard.graph, b).is_some()
&& graphops::block_core_of(&guard.graph, b).map(|c| c.in_()) == Some(out_r)
});
// The default transition's half-length, one frame at least.
let fps = tb.1 as f64 / tb.0.max(1) as f64;
let half_frames = (0.5 * fps).round().max(1.0) as i64;
let half = graphops::ts_to_rational(half_frames, tb);
if start_edge && prev_touch {
graphops::add_transition_at_seam(&project, prev.unwrap(), clip, half)
} else if !start_edge && next_touch {
graphops::add_transition_at_seam(&project, clip, next.unwrap(), half)
} else {
graphops::add_transition_at_edge(&project, clip, start_edge, half + half)
}
};
let result = placed.map(|_| ());
self.apply_edit(result.clone(), "drop transition", cx);
result
}
fn update_sequence_parameters(
&mut self,
id: u64,
@@ -10622,7 +10775,98 @@ mod tests {
oak_undo::global::clear().unwrap();
}
/// 调整图层 through the engine facade: `add_adjustment_layer` places a
/// An effect-library generator (Color Bars) drops onto the timeline as
/// a STANDALONE generator clip: one undo row, the clip labelled after
/// the generator, fed through `tex_in`, spanning five seconds from the
/// drop frame — and undo removes it.
#[gpui::test]
async fn engine_drops_a_generator_effect_as_a_clip(cx: &mut gpui::TestAppContext) {
let _media = media_lock();
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
let seq = cx.update(|app| {
engine.update(app, |engine, cx| {
engine
.create_sequence_with_params(
"Generator Drop".to_string(),
VideoFormat {
width: 1920,
height: 1080,
rate: FrameRate::new(30000, 1001),
},
false,
cx,
)
.expect("create sequence")
})
});
let project = cx.read(|app| engine.read(app).project.clone().expect("project"));
cx.update(|app| {
engine.update(app, |engine, cx| {
engine
.drop_generator_clip(
"org.olivevideoeditor.Olive.colorbars",
0,
Frame(25),
cx,
)
.expect("drop generator clip")
})
});
let seq_node = graphops::id_of(seq).expect("sequence node");
let target_row = cx.read(|app| engine.read(app).tracks[0].track_index);
let (tb, tracks) = {
let guard = graphops::lock(&project);
(
graphops::sequence_time_base(&guard.graph, seq_node).expect("time base"),
graphops::track_ids(&guard.graph, seq_node, TrackType::Video),
)
};
let track = tracks[target_row];
let (clip, generator) = {
let guard = graphops::lock(&project);
let clips = graphops::clip_ids(&guard.graph, track);
assert_eq!(clips.len(), 1, "the drop lands exactly one clip: {clips:?}");
let clip = clips[0];
let generator = guard
.graph
.connected_output(clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("the clip reads the generator through tex_in");
assert_eq!(
graphops::node_type_id(&guard.graph, generator),
"org.olivevideoeditor.Olive.colorbars"
);
let (in_r, out_r, _) = graphops::clip_range(&guard.graph, clip).expect("clip range");
let fps = tb.1 as f64 / tb.0 as f64;
let length = (5.0 * fps).round().max(1.0) as i64;
assert_eq!(in_r, graphops::ts_to_rational(25, tb));
assert_eq!(
out_r,
graphops::ts_to_rational(25 + length, tb),
"the clip spans five seconds from the drop point"
);
assert_eq!(
graphops::node_label(&guard.graph, clip),
graphops::node_label(&guard.graph, generator),
"the clip is labelled after its generator"
);
(clip, generator)
};
let _ = (clip, generator);
oak_undo::global::undo().unwrap();
{
let guard = graphops::lock(&project);
assert!(
graphops::clip_ids(&guard.graph, track).is_empty(),
"the undo removes the dropped clip"
);
}
oak_undo::global::clear().unwrap();
}
/// five-second empty block at the clicked frame on a video track (an
/// `AdjustmentBlockBehavior` with no footage and no texture wiring),
/// refuses audio tracks, resolves through the timeline selection, and is
+129 -13
View File
@@ -97,6 +97,9 @@ pub struct TimelinePanel<E: AppEngine> {
/// the cursor plus the start frame. `None` outside the clip area or while
/// no footage drag is active.
footage_drop: Option<FootageDropTarget>,
/// The drop point of an in-flight effect-library drag (generator
/// effects becoming standalone clips). Same shape as `footage_drop`.
effect_drop: Option<FootageDropTarget>,
/// The right-click context menu (opened from
/// [`TimelineEvent::ContextMenuRequested`]).
context_menu: ContextMenuHandle,
@@ -201,6 +204,7 @@ impl<E: AppEngine> TimelinePanel<E> {
height,
snap,
footage_drop: None,
effect_drop: None,
context_menu,
context_track: None,
context_empty: None,
@@ -367,21 +371,21 @@ impl<E: AppEngine> TimelinePanel<E> {
}
}
/// Resolves the footage-drop target under the cursor: converts the
/// pointer (relative to the timeline body) into a display track + start
/// frame using the timeline view's zoom/scroll state and the engine's
/// track heights — the same affine mapping the timeline itself uses (see
/// [`TimelineState::frame_at_point`] and the view's track-row walk).
/// Hovering outside the clip area (above the ruler) clears the target.
fn update_footage_drop(&mut self, event: &DragMoveEvent<FootageDrag>, cx: &mut Context<Self>) {
let now = event.event.position - event.bounds.origin;
/// Resolves a drag pointer (relative to the timeline body) into a
/// display track + start frame using the timeline view's zoom/scroll
/// state and the engine's track heights — the same affine mapping the
/// timeline itself uses (see [`TimelineState::frame_at_point`] and the
/// view's track-row walk). `None` above the ruler (outside the clip
/// area).
fn resolve_drop_point(
&self,
now: Point<Pixels>,
cx: &App,
) -> Option<(TrackKind, usize, Frame)> {
// The clip area starts below the ruler and right of the track
// headers column.
if f32::from(now.y) < RULER_HEIGHT {
if self.footage_drop.take().is_some() {
cx.notify();
}
return;
return None;
}
let clip_x = f32::from(now.x - px(HEADER_WIDTH)).max(0.0);
let clip_y = now.y - px(RULER_HEIGHT);
@@ -413,6 +417,20 @@ impl<E: AppEngine> TimelinePanel<E> {
.unwrap_or((TrackKind::Video, 0))
})
};
Some((track_kind, track_index, time))
}
/// Resolves the footage-drop target under the cursor (see
/// [`Self::resolve_drop_point`]). Hovering outside the clip area
/// clears the target.
fn update_footage_drop(&mut self, event: &DragMoveEvent<FootageDrag>, cx: &mut Context<Self>) {
let now = event.event.position - event.bounds.origin;
let Some((track_kind, track_index, time)) = self.resolve_drop_point(now, cx) else {
if self.footage_drop.take().is_some() {
cx.notify();
}
return;
};
self.footage_drop = Some(FootageDropTarget {
track_kind,
track_index,
@@ -426,6 +444,86 @@ impl<E: AppEngine> TimelinePanel<E> {
cx.notify();
}
/// Resolves the effect-library drop target under the cursor (same
/// mapping as the footage drop; a generator clip's 5-second default
/// length in sequence frames).
fn update_effect_drop(
&mut self,
event: &DragMoveEvent<gpui::effect_stack::LibraryEffectDrag>,
cx: &mut Context<Self>,
) {
let now = event.event.position - event.bounds.origin;
let Some((track_kind, track_index, time)) = self.resolve_drop_point(now, cx) else {
if self.effect_drop.take().is_some() {
cx.notify();
}
return;
};
let length = {
let engine = self.engine.read(cx);
let fps = engine.frame_rate();
((5.0 * fps.num as f64 / fps.den.max(1) as f64).round() as i64).max(1)
};
self.effect_drop = Some(FootageDropTarget {
track_kind,
track_index,
time,
length,
});
cx.notify();
}
/// Applies a finished effect-library drop: generator-category effects
/// become a standalone generator clip at the hovered track + frame
/// (undoable, one row); transition effects snap to the nearest clip
/// edge as a junction or single-sided transition. Everything else is
/// ignored.
fn finish_effect_drop(
&mut self,
drag: &gpui::effect_stack::LibraryEffectDrag,
cx: &mut Context<Self>,
) {
let Some(target) = self.effect_drop.take() else {
return;
};
let type_id = drag.type_id.as_str();
let engine = self.engine.clone();
if matches!(
type_id,
"org.olivevideoeditor.Olive.transition" | "org.olivevideoeditor.Olive.transitionfx"
) {
engine.update(cx, |engine, cx| {
if let Err(err) =
engine.drop_transition_at(type_id, target.track_index, target.time, cx)
{
println!("[timeline] transition drop: {err}");
}
});
cx.notify();
return;
}
let is_generator = oak_node::factory::Factory::global()
.create_any(type_id)
.map(|(_, behavior)| {
behavior
.categories()
.contains(&oak_node::node::Category::Generator)
})
.unwrap_or(false);
if !is_generator {
println!("[timeline] effect drop: \"{type_id}\" is not a generator");
return;
}
engine.update(cx, |engine, cx| {
if let Err(err) =
engine.drop_generator_clip(type_id, target.track_index, target.time, cx)
{
println!("[timeline] generator clip drop failed: {err}");
}
});
cx.notify();
}
/// Applies a finished footage drop: routes the payload's footage id with
/// the last hovered track + frame to the engine, which resolves the
/// footage, validates the track and places the clip (undoable).
@@ -1020,13 +1118,31 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
}
this.finish_footage_drop(drag, cx);
}))
// Effect-library drop target (generator effects →
// standalone generator clips).
.on_drag_move(cx.listener(
|this,
event: &DragMoveEvent<gpui::effect_stack::LibraryEffectDrag>,
_window,
cx| {
this.update_effect_drop(event, cx);
},
))
.on_drop(cx.listener(
|this,
drag: &gpui::effect_stack::LibraryEffectDrag,
_window,
cx| {
this.finish_effect_drop(drag, cx);
},
))
.child({
let mut inner =
div().relative().size_full().child(self.timeline.clone());
// The drop ghost: a translucent block at the
// resolved track + frame, spanning the footage's
// length, so the user sees where the clip lands.
if let Some(target) = &self.footage_drop {
if let Some(target) = self.footage_drop.as_ref().or(self.effect_drop.as_ref()) {
let state = self.timeline.read(cx).state.clone();
let x = px(HEADER_WIDTH) + state.point_at_frame(target.time);
let width = px(target.length as f32 * state.zoom).max(px(4.0));
+40 -9
View File
@@ -1437,8 +1437,12 @@ enum TrackRenderStep {
/// selects.
Transition {
block: oak_node::id::NodeId,
out_block: oak_node::id::NodeId,
in_block: oak_node::id::NodeId,
/// The outgoing (previous) block — `None` for a head transition
/// (no previous clip; fades in from black).
out_block: Option<oak_node::id::NodeId>,
/// The incoming (next) block — `None` for a tail transition (no
/// next clip; fades out to black).
in_block: Option<oak_node::id::NodeId>,
progress: f64,
shader: &'static str,
},
@@ -1510,8 +1514,8 @@ fn blend_transition(
graph: &oak_node::graph::Graph,
traverser: &mut oak_node::traverser::Traverser,
hooks: &mut RenderEvalHooks,
out_block: oak_node::id::NodeId,
in_block: oak_node::id::NodeId,
out_block: Option<oak_node::id::NodeId>,
in_block: Option<oak_node::id::NodeId>,
time: Rational,
progress: f64,
shader: &str,
@@ -1519,8 +1523,30 @@ fn blend_transition(
use oak_node::nodes::transitions;
use oak_node::value::NodeValueRow;
let from = evaluate_block_frame(graph, traverser, hooks, out_block, time)?;
let to = evaluate_block_frame(graph, traverser, hooks, in_block, time)?;
let from = match out_block {
Some(block) => evaluate_block_frame(graph, traverser, hooks, block, time)?,
None => None,
};
let to = match in_block {
Some(block) => evaluate_block_frame(graph, traverser, hooks, block, time)?,
None => None,
};
if from.is_none() && to.is_none() {
return Ok(None);
}
// A single-sided transition blends against transparent black (a head
// fade-in from nothing, a tail fade-out to nothing) — the same
// shaders, with one side generated empty.
let size = from
.as_ref()
.or(to.as_ref())
.map(|f| (f.width, f.height))
.or(hooks.frame_size)
.unwrap_or((1, 1));
let black = |size: (i32, i32)| generate_frame(time, size, PixelFormat::F32).ok();
let from = from.or_else(|| black(size));
let to = to.or_else(|| black(size));
// Both sides present: blend them. The side frames are boxed as CPU
// textures (the shader-job path uploads those into scratch textures
@@ -1803,7 +1829,11 @@ pub fn render_graph_frame(
{
continue;
}
let (Some(out_block), Some(in_block)) = (
// A transition needs at least one neighbor: a
// junction block wires both, a head/tail
// (single-sided) transition wires only its own
// clip and fades from/to black.
let (out_block, in_block) = (
graph.connected_output(
*block_id,
oak_node::block::transition_input::OUT_BLOCK,
@@ -1814,9 +1844,10 @@ pub fn render_graph_frame(
oak_node::block::transition_input::IN_BLOCK,
-1,
),
) else {
);
if out_block.is_none() && in_block.is_none() {
continue;
};
}
let style = block
.core
.value_at_time(
@@ -395,3 +395,181 @@ fn wipe_style_splits_the_frame_at_the_boundary() {
let _ = std::fs::remove_file(&red);
let _ = std::fs::remove_file(&blue);
}
/// Single-sided transitions (PR-style edge transitions): a head
/// transition wired only `in_block_in` fades the clip in from black, a
/// tail transition wired only `out_block_in` fades it out to black — the
/// same shader with the open side generated transparent.
#[test]
fn single_sided_transitions_fade_from_and_to_black() {
if oak_core::backend::GpuContext::shared().is_none() {
eprintln!("skipping single_sided_transitions_fade_from_and_to_black: no GPU adapter");
return;
}
let red = clip_path("edge_red");
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
.expect("red clip generation");
let red_path = red.to_string_lossy().to_string();
let xs: Vec<usize> = (8..56).collect();
let ys: Vec<usize> = (8..56).collect();
let build = |start_edge: bool| {
pin_legacy_working_space();
let project = Project::new();
let seq;
{
let mut p = project.lock().unwrap();
let (score, sbehavior) = SequenceBehavior::create();
seq = p.graph.add_node(score, sbehavior);
let (tcore, tbehavior) = TrackListBehavior::create();
let tl = p.graph.add_node(tcore, tbehavior);
let (tcore, tbehavior) = TrackBehavior::create();
let v1 = p.graph.add_node(tcore, tbehavior);
let mut footage = FootageBehavior::new(&red_path);
footage.probe().expect("probe the generated clip");
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
let (ccore, cbehavior) = oak_node::block::clip_create();
let clip = p.graph.add_node(ccore, cbehavior);
p.graph
.connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("connect footage to clip");
p.graph
.get_mut(clip)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<ClipBlockBehavior>()
.expect("clip block")
.core
.range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 1));
let (tcore, tbehavior) = oak_node::block::transition_create();
let block = p.graph.add_node(tcore, tbehavior);
{
let t = p
.graph
.get_mut(block)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TransitionBlockBehavior>()
.expect("transition block");
if start_edge {
t.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 2));
t.in_offset = Rational::new(0, 1);
t.out_offset = Rational::new(1, 2);
} else {
t.core.range = TimeRange::new(Rational::new(1, 2), Rational::new(1, 1));
t.in_offset = Rational::new(1, 2);
t.out_offset = Rational::new(0, 1);
}
}
p.graph
.connect(
clip,
block,
if start_edge {
oak_node::block::transition_input::IN_BLOCK
} else {
oak_node::block::transition_input::OUT_BLOCK
},
-1,
)
.expect("wire the clip to the transition");
{
let track = p
.graph
.get_mut(v1)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.expect("video track");
if start_edge {
track.append_block(block);
track.append_block(clip);
} else {
track.append_block(clip);
track.append_block(block);
}
}
p.graph
.get_mut(tl)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.expect("video track list")
.tracks
.push(v1);
p.graph
.get_mut(seq)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<SequenceBehavior>()
.expect("sequence")
.track_lists
.push(tl);
}
(project, seq)
};
// Head (fade-in): progress 0 = black, 0.5 = the half blend, 1 = the
// clip (and the plain clip beyond the span).
let (project, seq) = build(true);
let full = render_frame(&project, seq, Rational::new(3, 4));
assert!(
channel_mean(&full, 0, &xs, &ys) > 0.5,
"past the span the plain clip shows"
);
let start = render_frame(&project, seq, Rational::new(0, 1));
assert!(
channel_mean(&start, 0, &xs, &ys) < 0.02,
"the fade-in starts at black, got {}",
channel_mean(&start, 0, &xs, &ys)
);
let mid = render_frame(&project, seq, Rational::new(1, 4));
// The pipeline's composite convention (the same one the opacity
// stack carries): the half blend carries half alpha, and the
// alpha-over composite applies that alpha once more, so the
// midpoint reads a quarter of the clip's channels.
let expected = 0.25 * channel_mean(&full, 0, &xs, &ys);
let got = channel_mean(&mid, 0, &xs, &ys);
assert!(
(got - expected).abs() < 0.02,
"fade-in midpoint must be the half blend composited: expected {expected}, got {got}"
);
// Tail (fade-out): progress 0 = the clip, 0.5 = the half blend, 1 =
// black.
let (project, seq) = build(false);
let full = render_frame(&project, seq, Rational::new(1, 4));
assert!(
channel_mean(&full, 0, &xs, &ys) > 0.5,
"before the span the plain clip shows"
);
let mid = render_frame(&project, seq, Rational::new(3, 4));
// Same composite convention as the fade-in: half blend, alpha
// applied again, a quarter of the clip at the midpoint.
let expected = 0.25 * channel_mean(&full, 0, &xs, &ys);
let got = channel_mean(&mid, 0, &xs, &ys);
assert!(
(got - expected).abs() < 0.02,
"fade-out midpoint must be the half blend composited: expected {expected}, got {got}"
);
let end = render_frame(&project, seq, Rational::new(1, 1));
assert!(
channel_mean(&end, 0, &xs, &ys) < 0.02,
"the fade-out ends at black, got {}",
channel_mean(&end, 0, &xs, &ys)
);
let _ = std::fs::remove_file(&red);
}