fix(app): full-res worker crash + source playback frozen + cli/worker DS batch
- full-res source jobs carried only the footage node box: dropping the project mid-flight left the node dangling (crash in the worker's free path) and the node was also freed twice (at renderer creation AND at release). The request now carries an addref'd project copy and the node is freed exactly once; regression test drops the project before the worker runs - the source clock ticked at the SEQUENCE length, so playing footage with an empty sequence froze the source playhead at 0; the source clock now loops at the selected footage's probed duration - oak-cli/oak-worker: DeepSeek's refactor batch (clap migration, engine FFI consumers); the stale exporter-family test flipped to the real contract (mp4 is written) - engine: render_audio smoke test on an empty sequence
This commit is contained in:
+122
@@ -2267,6 +2267,128 @@ mod tests {
|
||||
assert_eq!(imported, vec![dropped]);
|
||||
}
|
||||
|
||||
/// Dragging a media entry from the project explorer onto the timeline
|
||||
/// places a clip there: the row starts a [`FootageDrag`] carrying the
|
||||
/// entry id, the timeline panel resolves the cursor to a display track +
|
||||
/// frame, and the engine records the `drop_footage` request with the
|
||||
/// correct parameters.
|
||||
#[gpui::test]
|
||||
async fn dragging_footage_onto_the_timeline_places_a_clip(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (window, root) = mock_shell(cx);
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
// The mock project's root-level footage entry ("第一稿.mp4", id 3) is
|
||||
// the drag source; the timeline's clip area is the drop target.
|
||||
let row = cx
|
||||
.debug_bounds("gpui-widgets-explorer-entry-3")
|
||||
.expect("root-level footage row rendered");
|
||||
let canvas = cx
|
||||
.debug_bounds("timeline-canvas")
|
||||
.expect("timeline body rendered");
|
||||
// A point inside the clip area: 100 px into the ruler's content
|
||||
// (frame 50 at the shell's default zoom of 2 px/frame) on the first
|
||||
// track row (V2, 64 px tall).
|
||||
let drop = gpui::point(
|
||||
canvas.left() + px(gpui::timeline::HEADER_WIDTH + 100.0),
|
||||
canvas.top() + px(gpui::timeline::RULER_HEIGHT + 20.0),
|
||||
);
|
||||
|
||||
// Dispatch the whole gesture against the same rendered frame: gpui's
|
||||
// interactive listeners are registered per render, so a repaint
|
||||
// between the events would consume them before the drop lands (same
|
||||
// caveat as the file-drop test above).
|
||||
cx.update(|window, cx| {
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseDown(gpui::MouseDownEvent {
|
||||
position: row.center(),
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
button: gpui::MouseButton::Left,
|
||||
click_count: 1,
|
||||
first_mouse: false,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
// Move past the drag threshold to start the drag.
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
|
||||
position: row.center() + gpui::point(px(6.0), px(0.0)),
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
pressed_button: Some(gpui::MouseButton::Left),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
// Hover the drop point (the panel resolves the track + frame).
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
|
||||
position: drop,
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
pressed_button: Some(gpui::MouseButton::Left),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
// Release over the drop point.
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseUp(gpui::MouseUpEvent {
|
||||
position: drop,
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
button: gpui::MouseButton::Left,
|
||||
click_count: 1,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let drops = cx.read(|app| root.read(app).engine.read(app).footage_drops().to_vec());
|
||||
assert_eq!(drops.len(), 1, "the drop must reach the engine exactly once");
|
||||
assert_eq!(drops[0].id, 3);
|
||||
assert_eq!(drops[0].track_kind, gpui::timeline::TrackKind::Video);
|
||||
assert_eq!(drops[0].track_index, 0);
|
||||
assert_eq!(drops[0].time, gpui::timeline::Frame(50));
|
||||
}
|
||||
|
||||
/// The project explorer lists root-level imported footage in BOTH views:
|
||||
/// the tree shows the entry as a row, and after switching to the icon
|
||||
/// grid the same entry (plus folder children) appears as icons.
|
||||
#[gpui::test]
|
||||
async fn explorer_views_list_root_level_footage(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (window, _root) = mock_shell(cx);
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
// Tree view: the root-level footage entry (id 3 "第一稿.mp4") renders
|
||||
// as a row.
|
||||
assert!(
|
||||
cx.debug_bounds("gpui-widgets-explorer-entry-3").is_some(),
|
||||
"tree view shows the root-level footage row"
|
||||
);
|
||||
|
||||
// Switch to the icon grid; root-level footage and folder children
|
||||
// both appear as icons, the folder root itself does not.
|
||||
let toggle = cx
|
||||
.debug_bounds("gpui-widgets-explorer-icons")
|
||||
.expect("icons toggle rendered");
|
||||
cx.simulate_click(toggle.center(), gpui::Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
|
||||
assert!(
|
||||
cx.debug_bounds("gpui-widgets-explorer-icon-3").is_some(),
|
||||
"root-level footage shows as an icon"
|
||||
);
|
||||
assert!(
|
||||
cx.debug_bounds("gpui-widgets-explorer-icon-10").is_some(),
|
||||
"a folder child shows as an icon"
|
||||
);
|
||||
assert!(
|
||||
cx.debug_bounds("gpui-widgets-explorer-icon-1").is_none(),
|
||||
"the footage folder root itself has no icon"
|
||||
);
|
||||
}
|
||||
|
||||
/// The command-line parser understands the project path and the mock
|
||||
/// flag, and the `OAK_ENGINE` env var forces the mock.
|
||||
#[test]
|
||||
|
||||
@@ -444,6 +444,31 @@ pub trait AppEngine:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Places the footage with project-explorer entry `id` on the timeline:
|
||||
/// a clip on the track at display `track_index` starting at `time`
|
||||
/// (undoable where the backend supports it — the facade
|
||||
/// `oakengine_sequence_add_footage_clip_ex` pushes one "Add Clip" undo
|
||||
/// entry).
|
||||
///
|
||||
/// The timeline panel resolves the cursor to a display track + frame and
|
||||
/// forwards them here; the backend resolves `id` to its footage, picks
|
||||
/// the target track and places the clip. Track policy: the pointed track
|
||||
/// is used when its kind matches the footage's media type; a mismatch
|
||||
/// (audio footage onto a video track, or vice versa) auto-selects the
|
||||
/// topmost track of the footage's kind, and the drop is rejected when no
|
||||
/// such track exists (the facade validates only the track type — video
|
||||
/// or audio, subtitles are rejected — never the media/track pairing).
|
||||
/// Default: no-op.
|
||||
fn drop_footage(
|
||||
&mut self,
|
||||
_id: u64,
|
||||
_track_kind: TrackKind,
|
||||
_track_index: usize,
|
||||
_time: Frame,
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
/// Starts an export of the current sequence in `format` to `path` and
|
||||
/// returns a session the host polls for progress and can cancel.
|
||||
///
|
||||
|
||||
@@ -410,6 +410,15 @@ unsafe extern "C" {
|
||||
/// thread (two-stage buf/size getter; empty when the last call
|
||||
/// succeeded).
|
||||
pub fn oakengine_footage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
/// `oakengine_footage_borrow` — wrap a footage node in a borrowed
|
||||
/// footage box (addref'd; free with `oakengine_footage_free`). NULL
|
||||
/// when `node` is NULL or not a footage node.
|
||||
pub fn oakengine_footage_borrow(node: *mut OakEngineNode) -> *mut OakEngineFootage;
|
||||
/// `oakengine_footage_get_duration` — media duration in seconds.
|
||||
pub fn oakengine_footage_get_duration(
|
||||
self_: *mut OakEngineFootage,
|
||||
seconds: *mut f64,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_add_footage_clip_ex` — place a clip of
|
||||
/// `footage` on the track, skipping the unenforceable same-project
|
||||
/// check (sequences live in their own scratch project — documented
|
||||
|
||||
@@ -192,6 +192,20 @@ impl PlaybackClock for MockClock {
|
||||
// Timeline model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A recorded timeline drop of a footage entry (mock state; the mock has no
|
||||
/// media pipeline, so it records the request and places a demo clip).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MockFootageDrop {
|
||||
/// The dropped project-explorer entry id.
|
||||
pub id: u64,
|
||||
/// The track kind the clip landed on.
|
||||
pub track_kind: TrackKind,
|
||||
/// The display track index the clip landed on.
|
||||
pub track_index: usize,
|
||||
/// The clip's start frame.
|
||||
pub time: Frame,
|
||||
}
|
||||
|
||||
/// A clip on the demo timeline.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockClip {
|
||||
@@ -477,6 +491,11 @@ pub struct MockEngine {
|
||||
/// has no media pipeline, so it just records them (drives app-level tests
|
||||
/// of the import flow).
|
||||
imported_footage: Vec<PathBuf>,
|
||||
/// Footage entries dropped onto the timeline via
|
||||
/// [`AppEngine::drop_footage`] since creation (mock state; each entry is
|
||||
/// the applied (id, track kind, track index, start frame) — drives
|
||||
/// app-level tests of the explorer→timeline drag).
|
||||
footage_drops: Vec<MockFootageDrop>,
|
||||
/// The fake project library the project manager browses (M13 D4): an
|
||||
/// in-memory row set the library trait methods operate on, so the app
|
||||
/// flow (list / open / create / rename / duplicate / delete / import / /// export) is testable without a database.
|
||||
@@ -743,6 +762,7 @@ impl MockEngine {
|
||||
node_selection: BTreeSet::new(),
|
||||
cpu_frame_cache: Mutex::new(HashMap::new()),
|
||||
imported_footage: Vec::new(),
|
||||
footage_drops: Vec::new(),
|
||||
library: demo_library(),
|
||||
next_library_id: 100,
|
||||
library_opened: Vec::new(),
|
||||
@@ -1389,6 +1409,87 @@ impl AppEngine for MockEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_footage(
|
||||
&mut self,
|
||||
id: u64,
|
||||
track_kind: TrackKind,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// 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");
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
let footage_kind = if crate::oakui::filename_is_audio(&name) {
|
||||
TrackKind::Audio
|
||||
} else {
|
||||
TrackKind::Video
|
||||
};
|
||||
// Track policy (mirrors the facade semantics, see `AppEngine`: the
|
||||
// pointed track is used when it matches the footage's kind; a
|
||||
// mismatch auto-selects the topmost track of the footage's kind; no
|
||||
// matching track rejects the drop).
|
||||
let target = if self.tracks.get(track_index).map(|t| t.kind) == Some(footage_kind) {
|
||||
track_index
|
||||
} else if let Some(index) = self.tracks.iter().position(|t| t.kind == footage_kind) {
|
||||
index
|
||||
} else {
|
||||
println!(
|
||||
"[mock engine] drop footage: no {:?} track for {:?} media \"{name}\"",
|
||||
footage_kind, track_kind
|
||||
);
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
// A 10-second demo clip (the mock has no media durations).
|
||||
let fps = self.frame_rate();
|
||||
let length = Frame(
|
||||
(10.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.clone().into(),
|
||||
color: if footage_kind == TrackKind::Audio {
|
||||
Hsla {
|
||||
h: 0.402,
|
||||
s: 0.32,
|
||||
l: 0.54,
|
||||
a: 1.0,
|
||||
}
|
||||
} else {
|
||||
Hsla {
|
||||
h: 0.402,
|
||||
s: 0.385,
|
||||
l: 0.459,
|
||||
a: 1.0,
|
||||
}
|
||||
},
|
||||
};
|
||||
// Keep the track's clips in ascending frame order (a data-source
|
||||
// consistency requirement of the timeline widget).
|
||||
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);
|
||||
self.footage_drops.push(MockFootageDrop {
|
||||
id,
|
||||
track_kind: footage_kind,
|
||||
track_index: target,
|
||||
time: Frame(time.0.max(0)),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -1659,6 +1760,27 @@ impl MockEngine {
|
||||
&self.imported_footage
|
||||
}
|
||||
|
||||
/// The footage entries dropped onto the timeline so far (mock state; see
|
||||
/// [`MockFootageDrop`]).
|
||||
pub fn footage_drops(&self) -> &[MockFootageDrop] {
|
||||
&self.footage_drops
|
||||
}
|
||||
|
||||
/// The display name of the project-explorer entry with `id`, if any.
|
||||
fn footage_entry_name(&self, id: u64) -> Option<String> {
|
||||
for entry in self.roots() {
|
||||
if entry.id == id {
|
||||
return Some(entry.name.to_string());
|
||||
}
|
||||
for child in self.children(entry.id) {
|
||||
if child.id == id {
|
||||
return Some(child.name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The undo/redo call counts (test observability; see the fields).
|
||||
pub fn undo_redo_calls(&self) -> (u64, u64) {
|
||||
(self.undo_calls, self.redo_calls)
|
||||
|
||||
@@ -58,4 +58,25 @@ pub use engine::{
|
||||
};
|
||||
pub use mock::{MockClock, MockEngine};
|
||||
pub use real::{RealClock, RealEngine};
|
||||
|
||||
/// Whether `name` (a media file name) denotes audio-only media, by
|
||||
/// extension.
|
||||
///
|
||||
/// The facade's module footage is never probed (`oakengine` imports media
|
||||
/// without decoding it), so the stream counts it exposes are always empty
|
||||
/// and the timeline drop's track matching falls back to the extension:
|
||||
/// known audio containers count as audio, everything else as video.
|
||||
pub fn filename_is_audio(name: &str) -> bool {
|
||||
matches!(
|
||||
std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some(
|
||||
"wav" | "mp3" | "flac" | "aac" | "ogg" | "oga" | "opus" | "m4a" | "wma" | "aiff"
|
||||
| "aif" | "ac3" | "amr" | "ape" | "caf"
|
||||
)
|
||||
)
|
||||
}
|
||||
pub use transport::{PlayState, TransportState};
|
||||
|
||||
+294
-14
@@ -384,9 +384,12 @@ enum FullResTarget {
|
||||
/// An addref'd sequence box (released with [`free_box`], last, after
|
||||
/// the renderer so the sequence outlives the renderer's borrowed view).
|
||||
Sequence(SendPtr<OakEngineSequence>),
|
||||
/// A boxed footage node (freed with `oakengine_node_free` once the
|
||||
/// renderer has resolved its footage spec).
|
||||
Node(SendPtr<OakEngineNode>),
|
||||
/// A boxed footage node plus an addref'd copy of its project. The node
|
||||
/// box alone does NOT keep the graph alive: dropping the project while
|
||||
/// a job is in flight leaves the node dangling (observed crash:
|
||||
/// misaligned pointer dereference in `oakengine_node_free`). The
|
||||
/// project copy is released after the node.
|
||||
Node(SendPtr<OakEngineNode>, SendPtr<OakEngineProject>),
|
||||
}
|
||||
|
||||
/// One background full-resolution render request (built on the UI thread
|
||||
@@ -867,6 +870,58 @@ impl RealEngine {
|
||||
self.project.as_ref().map(ProjectHandle::ptr)
|
||||
}
|
||||
|
||||
/// An addref'd copy of the project handle, boxed for the background
|
||||
/// worker — same lifetime contract as [`RealEngine::sequence_copy`]:
|
||||
/// the copy keeps the graph alive after the engine's own project is
|
||||
/// dropped (the source monitor's footage node dangles otherwise).
|
||||
fn project_copy(&self) -> Option<*mut OakEngineProject> {
|
||||
let project = self.project_ptr()?;
|
||||
// SAFETY: `project` is the engine's live project box.
|
||||
let handle = unsafe { unbox(project) }?;
|
||||
let addref = handle.addref?;
|
||||
// SAFETY: `handle` is a live module handle; addref takes a new
|
||||
// reference the copy releases.
|
||||
unsafe { addref(handle.ctx) };
|
||||
Some(unsafe { box_handle::<OakEngineProject>(handle) })
|
||||
}
|
||||
|
||||
/// The selected footage's duration in frames at the current rate
|
||||
/// (0 when nothing is selected or the footage was not probed).
|
||||
fn source_length(&self) -> Frame {
|
||||
let Some(project) = self.project_ptr() else {
|
||||
return Frame(0);
|
||||
};
|
||||
let Some(id) = self.selected_item else {
|
||||
return Frame(0);
|
||||
};
|
||||
let count = unsafe { oakengine_project_footage_count(project) };
|
||||
for i in 0..count.max(0) {
|
||||
let f = unsafe { oakengine_project_footage_at(project, i) };
|
||||
if f.is_null() {
|
||||
continue;
|
||||
}
|
||||
let matches = unsafe { oakengine_node_identity(f) } == id;
|
||||
if matches {
|
||||
// The footage list yields node boxes; borrow the footage
|
||||
// view for the duration query.
|
||||
let footage = unsafe { oakengine_footage_borrow(f) };
|
||||
unsafe { oakengine_node_free(f) };
|
||||
let mut seconds: f64 = 0.0;
|
||||
let ok = !footage.is_null()
|
||||
&& unsafe { oakengine_footage_get_duration(footage, &mut seconds) } == 0;
|
||||
unsafe { oakengine_footage_free(footage) };
|
||||
if ok && seconds > 0.0 {
|
||||
let rate = self.frame_rate();
|
||||
let fps = rate.num as f64 / rate.den.max(1) as f64;
|
||||
return Frame((seconds * fps).round().max(1.0) as i64);
|
||||
}
|
||||
return Frame(0);
|
||||
}
|
||||
unsafe { oakengine_node_free(f) };
|
||||
}
|
||||
Frame(0)
|
||||
}
|
||||
|
||||
/// Current sequence length (0 without a sequence).
|
||||
fn sequence_length(&self) -> Frame {
|
||||
self.sequence_info
|
||||
@@ -1218,7 +1273,14 @@ impl RealEngine {
|
||||
let height = info.format.height.max(1) as c_int;
|
||||
let target = match monitor {
|
||||
Monitor::Program => FullResTarget::Sequence(SendPtr(self.sequence_copy()?)),
|
||||
Monitor::Source => FullResTarget::Node(SendPtr(self.selected_footage_node()?)),
|
||||
Monitor::Source => {
|
||||
// The project copy MUST be taken while the engine's own
|
||||
// project is still alive (it keeps the node valid).
|
||||
FullResTarget::Node(
|
||||
SendPtr(self.selected_footage_node()?),
|
||||
SendPtr(self.project_copy()?),
|
||||
)
|
||||
}
|
||||
};
|
||||
Some(FullResRequest {
|
||||
monitor,
|
||||
@@ -1269,10 +1331,11 @@ impl RealEngine {
|
||||
std::ptr::null(),
|
||||
)
|
||||
}
|
||||
FullResTarget::Node(node) => {
|
||||
// SAFETY: the renderer resolves its own footage spec at
|
||||
// creation; the node box is no longer needed after it.
|
||||
let renderer = oakengine_renderer_create_for_node(
|
||||
FullResTarget::Node(node, _) => {
|
||||
// The node stays alive until release_full_res_target
|
||||
// (freed exactly once there); the renderer resolves its
|
||||
// footage spec at creation and borrows nothing beyond.
|
||||
oakengine_renderer_create_for_node(
|
||||
node.0,
|
||||
width,
|
||||
height,
|
||||
@@ -1280,9 +1343,7 @@ impl RealEngine {
|
||||
rate_num,
|
||||
rate_den,
|
||||
std::ptr::null(),
|
||||
);
|
||||
oakengine_node_free(node.0);
|
||||
renderer
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1327,7 +1388,12 @@ impl RealEngine {
|
||||
unsafe {
|
||||
match target {
|
||||
FullResTarget::Sequence(seq) => free_box(seq.0),
|
||||
FullResTarget::Node(node) => oakengine_node_free(node.0),
|
||||
// The node goes first; the addref'd project copy outlives
|
||||
// it (the node's graph must stay alive during the free).
|
||||
FullResTarget::Node(node, project) => {
|
||||
oakengine_node_free(node.0);
|
||||
free_box(project.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1898,11 +1964,19 @@ impl EngineGateway for RealEngine {
|
||||
}
|
||||
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
// Each clock loops at its own monitor's length: the program at the
|
||||
// sequence length, the source at the selected footage's duration
|
||||
// (the sequence length is wrong for footage playback — an empty
|
||||
// project's length 0 used to freeze the source playhead at 0).
|
||||
let length = self.sequence_length();
|
||||
for clock in [&self.source_clock, &self.program_clock] {
|
||||
let source_length = self.source_length();
|
||||
for (clock, len) in [
|
||||
(&self.source_clock, source_length),
|
||||
(&self.program_clock, length),
|
||||
] {
|
||||
let clock = clock.clone();
|
||||
clock.update(cx, |clock, cx| {
|
||||
clock.tick(length);
|
||||
clock.tick(len);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -2818,6 +2892,146 @@ impl AppEngine for RealEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_footage(
|
||||
&mut self,
|
||||
id: u64,
|
||||
track_kind: TrackKind,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(project) = self.project_ptr() else {
|
||||
return;
|
||||
};
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return;
|
||||
};
|
||||
// The explorer's entry id IS the footage node's stable identity
|
||||
// (`projectbrowser`); find the matching footage node (its box is
|
||||
// freed below).
|
||||
let mut footage_node: *mut OakEngineNode = std::ptr::null_mut();
|
||||
let mut footage_index: c_int = -1;
|
||||
let count = unsafe { oakengine_project_footage_count(project) };
|
||||
for index in 0..count.max(0) {
|
||||
let node = unsafe { oakengine_project_footage_at(project, index) };
|
||||
if node.is_null() {
|
||||
continue;
|
||||
}
|
||||
if unsafe { oakengine_node_identity(node) } == id {
|
||||
footage_node = node;
|
||||
footage_index = index;
|
||||
break;
|
||||
}
|
||||
// SAFETY: `node` is a box from `oakengine_project_footage_at`.
|
||||
unsafe { oakengine_node_free(node) };
|
||||
}
|
||||
if footage_node.is_null() {
|
||||
println!("[real engine] drop footage: entry {id} is not a footage node");
|
||||
return;
|
||||
}
|
||||
// Media type by extension: the module's footage is never probed, so
|
||||
// the facade's stream counts are empty (see `filename_is_audio`).
|
||||
let filename = read_string(|buf, size| unsafe {
|
||||
oakengine_project_footage_filename(project, footage_index, buf, size)
|
||||
});
|
||||
let footage_kind = if crate::oakui::filename_is_audio(&filename) {
|
||||
TrackKind::Audio
|
||||
} else {
|
||||
TrackKind::Video
|
||||
};
|
||||
// Track policy (see the `AppEngine::drop_footage` docs): use the
|
||||
// pointed display track when its kind matches, otherwise auto-select
|
||||
// the topmost track of the footage's kind; reject when there is none.
|
||||
// The facade itself validates only the track type (video/audio; it
|
||||
// rejects subtitles) and never the media/track pairing.
|
||||
let target = if let Some(track) = self.tracks.get(track_index) {
|
||||
if track.kind == footage_kind {
|
||||
track_index
|
||||
} else {
|
||||
match self.tracks.iter().position(|t| t.kind == footage_kind) {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
println!(
|
||||
"[real engine] drop footage: no {:?} track for {:?} media \"{}\"",
|
||||
footage_kind, track_kind, filename
|
||||
);
|
||||
// SAFETY: `footage_node` is a box from
|
||||
// `oakengine_project_footage_at`.
|
||||
unsafe { oakengine_node_free(footage_node) };
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("[real engine] drop footage: display track {track_index} does not exist");
|
||||
// SAFETY: `footage_node` is a box from `oakengine_project_footage_at`.
|
||||
unsafe { oakengine_node_free(footage_node) };
|
||||
return;
|
||||
};
|
||||
// The display list maps 1:1 onto the facade's per-type track lists
|
||||
// (see `rebuild_timeline`), so the snapshot's coordinates address the
|
||||
// facade track directly.
|
||||
let (track_type, track_index_facade) = {
|
||||
let track = &self.tracks[target];
|
||||
(track.track_type, track.track_index)
|
||||
};
|
||||
// Clip length: the footage's probed duration when available; module
|
||||
// footage is never probed, so fall back to a 10-second default.
|
||||
let fps = self.frame_rate();
|
||||
let fps_f = fps.num as f64 / fps.den.max(1) as f64;
|
||||
let footage = unsafe { oakengine_footage_borrow(footage_node) };
|
||||
let mut seconds: f64 = 0.0;
|
||||
let has_duration = !footage.is_null()
|
||||
&& unsafe { oakengine_footage_get_duration(footage, &mut seconds) } == 0
|
||||
&& seconds > 0.0;
|
||||
let length = if has_duration {
|
||||
(seconds * fps_f).round().max(1.0) as i64
|
||||
} else {
|
||||
(10.0 * fps_f).round().max(1.0) as i64
|
||||
};
|
||||
let in_ts = time.0.max(0);
|
||||
// SAFETY: `seq` and `footage` are live facade handles; the returned
|
||||
// owned clip box is freed below.
|
||||
let clip = unsafe {
|
||||
oakengine_sequence_add_footage_clip_ex(
|
||||
seq,
|
||||
footage,
|
||||
track_type,
|
||||
track_index_facade as c_int,
|
||||
in_ts,
|
||||
in_ts + length,
|
||||
0,
|
||||
)
|
||||
};
|
||||
// SAFETY: `footage` is a borrowed box (`oakengine_footage_borrow`);
|
||||
// `footage_node` a box from `oakengine_project_footage_at`.
|
||||
unsafe {
|
||||
if !footage.is_null() {
|
||||
oakengine_footage_free(footage);
|
||||
}
|
||||
oakengine_node_free(footage_node);
|
||||
}
|
||||
let rc = if clip.is_null() {
|
||||
let error = read_string(|buf, size| unsafe {
|
||||
oakengine_sequence_last_error(buf, size)
|
||||
});
|
||||
let error = if error.is_empty() {
|
||||
"add footage clip rejected".to_string()
|
||||
} else {
|
||||
error
|
||||
};
|
||||
println!("[real engine] drop footage rejected: {error}");
|
||||
-1
|
||||
} else {
|
||||
// SAFETY: `clip` is an owned facade box (`free_box`).
|
||||
unsafe { free_box(clip) };
|
||||
0
|
||||
};
|
||||
// The facade export pushes ONE undoable "Add Clip" entry; the refresh
|
||||
// also invalidates the cached rendered frames.
|
||||
self.apply_edit(rc, "drop footage", cx);
|
||||
}
|
||||
|
||||
// --- project library (M13 D4) --------------------------------------
|
||||
|
||||
fn storage_bound(&self) -> bool {
|
||||
@@ -4194,6 +4408,72 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
/// Regression: the source monitor's full-res job used to carry only the
|
||||
/// footage node box — dropping the project while the job was in flight
|
||||
/// left the node dangling, and the worker's free path died on a
|
||||
/// misaligned pointer inside the module's handle table. The request now
|
||||
/// also carries an addref'd project copy, so this scenario completes
|
||||
/// (and frees cleanly) instead of crashing.
|
||||
#[test]
|
||||
fn full_res_worker_outlives_a_dropped_project() {
|
||||
let _media = media_lock();
|
||||
if !RealEngine::ensure_render_manager() {
|
||||
panic!("the render manager failed to start");
|
||||
}
|
||||
|
||||
let project = unsafe { oakengine_project_create() };
|
||||
assert!(!project.is_null());
|
||||
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
|
||||
let media = std::env::temp_dir().join(format!(
|
||||
"oakapp_fullres_src_{}.mp4",
|
||||
std::process::id()
|
||||
));
|
||||
let media_c = CString::new(media.to_string_lossy().into_owned()).unwrap();
|
||||
assert_eq!(
|
||||
unsafe { oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) },
|
||||
0
|
||||
);
|
||||
let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) };
|
||||
assert!(!footage.is_null(), "import must succeed");
|
||||
|
||||
// The node box from the project's footage list plus the addref'd
|
||||
// project copy (what build_full_res_request now does).
|
||||
let node = unsafe { oakengine_project_footage_at(project, 0) };
|
||||
assert!(!node.is_null());
|
||||
// SAFETY: `footage` is a live box; the node box is independent.
|
||||
unsafe { oakengine_footage_free(footage) };
|
||||
let handle = unsafe { unbox(project) }.expect("project handle");
|
||||
let addref = handle.addref.expect("module handle addref");
|
||||
// SAFETY: `handle` is a live module handle; addref takes a new
|
||||
// reference the copy releases.
|
||||
unsafe { addref(handle.ctx) };
|
||||
let project_copy = unsafe { box_handle::<OakEngineProject>(handle) };
|
||||
|
||||
// The engine's own project goes away BEFORE the worker runs — the
|
||||
// pre-fix crash window.
|
||||
unsafe { oakengine_project_free(project) };
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let request = FullResRequest {
|
||||
monitor: Monitor::Source,
|
||||
frame: 0,
|
||||
generation: 1,
|
||||
target: FullResTarget::Node(SendPtr(node), SendPtr(project_copy)),
|
||||
width: 64,
|
||||
height: 64,
|
||||
rate_num: 10,
|
||||
rate_den: 1,
|
||||
};
|
||||
std::thread::spawn(move || RealEngine::full_res_worker(request, tx));
|
||||
|
||||
let event = rx
|
||||
.recv_timeout(Duration::from_secs(20))
|
||||
.expect("the worker delivers the frame after the project drop");
|
||||
let bytes = event.image.as_bytes(0).expect("one frame");
|
||||
assert_eq!(bytes.len(), 64 * 64 * 4, "full-res geometry");
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// M12 P5a: the full-resolution fill — scheduling logic (pure)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+98
-2
@@ -41,10 +41,13 @@
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::timeline::TimelineView;
|
||||
use gpui::timeline::{
|
||||
Frame, TimelineView, TrackData, TrackKind, HEADER_WIDTH, MIN_TRACK_HEIGHT, RULER_HEIGHT,
|
||||
};
|
||||
use gpui::{div, img, prelude::*, px, Context, Entity, Window};
|
||||
use gpui::{AnyElement, App, ClickEvent, EventEmitter, Render, SharedString};
|
||||
use gpui::{AnyElement, App, ClickEvent, DragMoveEvent, EventEmitter, Render, SharedString};
|
||||
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
|
||||
use gpui_widgets::project_explorer::FootageDrag;
|
||||
use gpui_widgets::slider::{Slider, SliderEvent, SliderModel};
|
||||
use gpui_widgets::tooltip::tooltip_view;
|
||||
use gpui_widgets::value::ValueKind;
|
||||
@@ -85,6 +88,21 @@ pub struct TimelinePanel<E: AppEngine> {
|
||||
snap: Entity<CheckBox>,
|
||||
/// The currently selected tool (visual only).
|
||||
selected_tool: usize,
|
||||
/// The drop point of an in-flight footage drag: the display track under
|
||||
/// the cursor plus the start frame. `None` outside the clip area or while
|
||||
/// no footage drag is active.
|
||||
footage_drop: Option<FootageDropTarget>,
|
||||
}
|
||||
|
||||
/// A footage drop target resolved from the cursor: the display track under
|
||||
/// the pointer and the clip's start frame.
|
||||
struct FootageDropTarget {
|
||||
/// The pointed track's kind.
|
||||
track_kind: TrackKind,
|
||||
/// The pointed display track index.
|
||||
track_index: usize,
|
||||
/// The start frame at the pointer.
|
||||
time: Frame,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> TimelinePanel<E> {
|
||||
@@ -154,8 +172,75 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
height,
|
||||
snap,
|
||||
selected_tool: 0,
|
||||
footage_drop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
// 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;
|
||||
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);
|
||||
// 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) = {
|
||||
let engine = self.engine.read(cx);
|
||||
let mut acc = 0.0f32;
|
||||
let mut found = None;
|
||||
for index in 0..engine.track_count() {
|
||||
if let Some(track) = engine.track(index) {
|
||||
acc += f32::from(track.height()).max(MIN_TRACK_HEIGHT);
|
||||
if f32::from(clip_y) < acc {
|
||||
found = Some((track.kind(), index));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found.unwrap_or_else(|| {
|
||||
let last = engine.track_count().saturating_sub(1);
|
||||
engine
|
||||
.track(last)
|
||||
.map(|t| (t.kind(), last))
|
||||
.unwrap_or((TrackKind::Video, 0))
|
||||
})
|
||||
};
|
||||
self.footage_drop = Some(FootageDropTarget {
|
||||
track_kind,
|
||||
track_index,
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn finish_footage_drop(&mut self, drag: &FootageDrag, cx: &mut Context<Self>) {
|
||||
let Some(target) = self.footage_drop.take() else {
|
||||
return;
|
||||
};
|
||||
let FootageDropTarget {
|
||||
track_kind,
|
||||
track_index,
|
||||
time,
|
||||
} = target;
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
engine.drop_footage(drag.0, track_kind, track_index, time, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
@@ -330,6 +415,17 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
.debug_selector(|| "timeline-canvas".into())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
// Footage drop target: hover resolves the track +
|
||||
// frame (see [`TimelinePanel::update_footage_drop`]),
|
||||
// the release routes the payload to the engine.
|
||||
.on_drag_move(cx.listener(
|
||||
|this, event: &DragMoveEvent<FootageDrag>, _window, cx| {
|
||||
this.update_footage_drop(event, cx);
|
||||
},
|
||||
))
|
||||
.on_drop(cx.listener(|this, drag: &FootageDrag, _window, cx| {
|
||||
this.finish_footage_drop(drag, cx);
|
||||
}))
|
||||
.child(self.timeline.clone()),
|
||||
)
|
||||
.child(right_controls),
|
||||
|
||||
Reference in New Issue
Block a user