feat(app): drop-at-cursor placement and a translucent drop ghost
- Footage drops no longer clamp to the sequence length — that clamp squashed every drop on an empty or short timeline to frame zero. Dropping past the end now extends the timeline, so the clip lands where it is released. - While dragging, a translucent ghost (35% opacity, accent border) previews the resolved track, start frame and footage length at the cursor; a new AppEngine::footage_length_frames (probed duration x frame rate) feeds its extent, with a mock implementation for demo mode.
This commit is contained in:
@@ -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<i64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Starts an export of the current sequence in `format` to `path` and
|
||||
/// returns a session the host polls for progress and can cancel.
|
||||
///
|
||||
|
||||
+9
-2
@@ -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<i64> {
|
||||
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<Self>) -> Result<(), String> {
|
||||
println!("[mock engine] export: no persistence in mock mode");
|
||||
cx.notify();
|
||||
|
||||
@@ -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<i64> {
|
||||
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 {
|
||||
|
||||
+60
-4
@@ -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<E: AppEngine> TimelinePanel<E> {
|
||||
@@ -365,14 +367,18 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
// 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<E: AppEngine> TimelinePanel<E> {
|
||||
track_kind,
|
||||
track_index,
|
||||
time,
|
||||
length: event
|
||||
.dragged_item()
|
||||
.downcast_ref::<FootageDrag>()
|
||||
.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<E: AppEngine> TimelinePanel<E> {
|
||||
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<E: AppEngine> Render for TimelinePanel<E> {
|
||||
}
|
||||
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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user