diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index c4665847d..836c7073e 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -724,6 +724,13 @@ pub trait AppEngine: ) { } + /// The footage's length in sequence frames (for the timeline's drop + /// ghost): the probed duration times the frame rate. `None` when the + /// entry is not footage or has no probed duration. + fn footage_length_frames(&self, _id: u64) -> Option { + None + } + /// Starts an export of the current sequence in `format` to `path` and /// returns a session the host polls for progress and can cancel. /// diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index 02006818b..9967c03ce 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -1609,8 +1609,7 @@ impl AppEngine for MockEngine { // The footage's media type, inferred from its entry name (the mock // never probes media). Entries the explorer does not list are // rejected. - let Some(name) = self.footage_entry_name(id) else { - println!("[mock engine] drop footage: entry {id} not in the project"); + let Some(name) = self.footage_entry_name(id) else { println!("[mock engine] drop footage: entry {id} not in the project"); cx.notify(); return; }; @@ -1679,6 +1678,14 @@ impl AppEngine for MockEngine { cx.notify(); } + /// The mock's drop ghost extent: the 10-second demo clip length used + /// by `drop_footage`. + fn footage_length_frames(&self, id: u64) -> Option { + self.footage_entry_name(id)?; + let fps = self.frame_rate(); + Some((10.0 * fps.num as f64 / fps.den.max(1) as f64).round().max(1.0) as i64) + } + fn export_project_path(&mut self, _path: PathBuf, cx: &mut Context) -> Result<(), String> { println!("[mock engine] export: no persistence in mock mode"); cx.notify(); diff --git a/src/oakui/real.rs b/src/oakui/real.rs index be9378b8d..04819485a 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -4033,6 +4033,21 @@ impl AppEngine for RealEngine { self.apply_edit(result, "drop footage", cx); } + /// The footage's length in sequence frames (the drop ghost's extent): + /// the probed duration times the frame rate; `None` when the entry is + /// not footage or was never probed. + fn footage_length_frames(&self, id: u64) -> Option { + let project = self.project_ref()?; + let node = graphops::id_of(id)?; + let seconds = { + let guard = graphops::lock(project); + graphops::footage_duration_seconds(&guard.graph, node) + }?; + let fps = self.frame_rate(); + let fps_f = fps.num as f64 / fps.den.max(1) as f64; + Some((seconds * fps_f).round().max(1.0) as i64) + } + // --- project library (M13 D4) -------------------------------------- fn storage_bound(&self) -> bool { diff --git a/src/panels/timeline.rs b/src/panels/timeline.rs index f5f2fe34e..e674d5592 100644 --- a/src/panels/timeline.rs +++ b/src/panels/timeline.rs @@ -118,6 +118,8 @@ struct FootageDropTarget { track_index: usize, /// The start frame at the pointer. time: Frame, + /// The footage's length in frames (the ghost's extent). + length: i64, } impl TimelinePanel { @@ -365,14 +367,18 @@ impl TimelinePanel { // The clip area starts below the ruler and right of the track // headers column. if f32::from(now.y) < RULER_HEIGHT { - self.footage_drop = None; + if self.footage_drop.take().is_some() { + cx.notify(); + } return; } let clip_x = f32::from(now.x - px(HEADER_WIDTH)).max(0.0); let clip_y = now.y - px(RULER_HEIGHT); let state = self.timeline.read(cx).state.clone(); - let seq_len = self.engine.read(cx).sequence_length(); - let time = state.frame_at_point(px(clip_x)).clamp(Frame::ZERO, seq_len); + // No upper clamp: dropping past the sequence end is how the + // timeline extends (the old clamp to the sequence length squashed + // every drop onto an empty/near-empty timeline to frame zero). + let time = state.frame_at_point(px(clip_x)).max(Frame::ZERO); // Walk the display rows top-down, clamping each to the minimum row // height exactly like the timeline's own `track_at_y`. let (track_kind, track_index) = { @@ -400,7 +406,13 @@ impl TimelinePanel { track_kind, track_index, time, + length: event + .dragged_item() + .downcast_ref::() + .and_then(|drag| self.engine.read(cx).footage_length_frames(drag.0)) + .unwrap_or(1), }); + cx.notify(); } /// Applies a finished footage drop: routes the payload's footage id with @@ -414,10 +426,12 @@ impl TimelinePanel { track_kind, track_index, time, + .. } = target; self.engine.update(cx, |engine, cx| { engine.drop_footage(drag.0, track_kind, track_index, time, cx); }); + cx.notify(); } /// Routes a transport command to the engine's program monitor (the @@ -922,7 +936,49 @@ impl Render for TimelinePanel { } this.finish_footage_drop(drag, cx); })) - .child(self.timeline.clone()), + .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 { + 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)); + let engine = self.engine.read(cx); + let mut y = px(RULER_HEIGHT); + let mut row_h = px(64.0); + for index in 0..=target.track_index { + let Some(track) = engine.track(index) else { + break; + }; + let h = track.height().max(px(MIN_TRACK_HEIGHT)); + if index == target.track_index { + row_h = h; + break; + } + y += h; + } + inner = inner.child( + div() + .absolute() + .left(x) + .top(y) + .w(width) + .h(row_h) + .rounded_sm() + .border_1() + .border_color(colors.selected) + .bg(gpui::Rgba { + a: 0.35, + ..colors.selected + }) + .into_any_element(), + ); + } + inner + }), ) .child(right_controls), )