feat(engine,app): timeline markers, work area, cross-track move (M12 P4)
- facade: oakengine_sequence_set_workarea_undoable (enable+range as one undo record); marker add/remove/list already existed and are now covered by it_timeline (10 new tests: markers, workarea, cross-track) - fixes: stubs workarea_get honored NULL out-params (is_enabled always failed); export tasks honor custom ranges (export_params_pod dropped them; oaktask EncodingParams carries has_custom_range) - app: markers on the ruler (gpui diamond markers), menu sequence-add/remove marker, set/clear work area (selected-clip bounds or playhead+1), ruler drag previews live and commits one undoable record (C++ ruler semantics); export uses the work area when enabled - cross-track clip moves were already wired; now covered end to end
This commit is contained in:
+70
-1
@@ -41,7 +41,9 @@ use std::time::Duration;
|
||||
use gpui::dock::{
|
||||
DockArea, DockLayout, DropTarget, DropZone, NodePath, PanelHandle, PanelRegistry,
|
||||
};
|
||||
use gpui::timeline::{ClipId, Frame, TimelineEvent, TimelineView};
|
||||
use gpui::timeline::{
|
||||
ClipData, ClipId, Frame, FrameRange, TimelineEvent, TimelineView, TrackData,
|
||||
};
|
||||
use gpui::{
|
||||
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, PathPromptOptions,
|
||||
Render, Window, WindowBounds, WindowOptions,
|
||||
@@ -100,6 +102,10 @@ mod menu_ids {
|
||||
pub const ADD_AUDIO_TRACK: usize = 502;
|
||||
pub const REMOVE_TRACK: usize = 503;
|
||||
pub const SPLIT_AT_PLAYHEAD: usize = 504;
|
||||
pub const ADD_MARKER: usize = 505;
|
||||
pub const REMOVE_MARKER: usize = 506;
|
||||
pub const SET_WORKAREA: usize = 507;
|
||||
pub const CLEAR_WORKAREA: usize = 508;
|
||||
|
||||
pub const FOCUS_PROJECT: usize = 601;
|
||||
pub const FOCUS_SOURCE_VIEWER: usize = 602;
|
||||
@@ -511,6 +517,12 @@ impl<E: AppEngine> OakApp<E> {
|
||||
let frame = self.program_clock.read(cx).current_frame();
|
||||
self.timeline
|
||||
.update(cx, |timeline, cx| timeline.seek(frame, cx));
|
||||
// Mirror the engine's work area into the ruler's view state (M12 P4).
|
||||
// Read every tick so undo/redo and the ruler-drag commit land on the
|
||||
// band promptly; the read is a cheap facade getter.
|
||||
let work_area = self.engine.read(cx).workarea();
|
||||
self.timeline
|
||||
.update(cx, |timeline, _| timeline.state.work_area = work_area.map(|(s, e)| FrameRange::new(s, e)));
|
||||
self.meter.update(cx, |meter, cx| meter.update(cx));
|
||||
self.poll_export(cx);
|
||||
cx.notify();
|
||||
@@ -598,6 +610,16 @@ impl<E: AppEngine> OakApp<E> {
|
||||
SPLIT_AT_PLAYHEAD => self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.split_at_playhead(cx)),
|
||||
ADD_MARKER => self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.add_marker_at_playhead(cx)),
|
||||
REMOVE_MARKER => self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.remove_marker_at_playhead(cx)),
|
||||
SET_WORKAREA => self.set_workarea_from_selection(cx),
|
||||
CLEAR_WORKAREA => self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.clear_workarea(cx)),
|
||||
// --- Window ----------------------------------------------------
|
||||
FOCUS_PROJECT => self.focus_panel(PROJECT, cx),
|
||||
FOCUS_SOURCE_VIEWER => self.focus_panel(SOURCE_VIEWER, cx),
|
||||
@@ -649,6 +671,49 @@ impl<E: AppEngine> OakApp<E> {
|
||||
.update(cx, |engine, cx| engine.remove_track(index, cx));
|
||||
}
|
||||
|
||||
/// 序列 → 设置工作区: sets the work area to the bounding range of the
|
||||
/// selected clips, or one frame at the program playhead when nothing is
|
||||
/// selected. Committed as ONE undoable entry whose old side is the
|
||||
/// engine's current work area (mirrors the ruler drag's commit).
|
||||
fn set_workarea_from_selection(&mut self, cx: &mut Context<Self>) {
|
||||
let (old_start, old_end) = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.workarea()
|
||||
.unwrap_or((Frame::ZERO, Frame::ZERO));
|
||||
let (start, end) = self.selection_workarea_range(cx);
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
engine.commit_workarea(old_start, old_end, start, end, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// The bounding range of the timeline's selected clips; `[playhead,
|
||||
/// playhead + 1)` when nothing is selected (the menu's fallback).
|
||||
fn selection_workarea_range(&self, cx: &App) -> (Frame, Frame) {
|
||||
let ids: Vec<ClipId> = self.timeline.read(cx).selection().iter().copied().collect();
|
||||
let engine = self.engine.read(cx);
|
||||
let mut start: Option<i64> = None;
|
||||
let mut end: i64 = 0;
|
||||
for index in 0..engine.track_count() {
|
||||
if let Some(track) = engine.track(index) {
|
||||
for clip in track.clips() {
|
||||
if ids.contains(&clip.id()) {
|
||||
let range = clip.range();
|
||||
start = Some(start.map_or(range.start.0, |s| s.min(range.start.0)));
|
||||
end = end.max(range.end.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match start {
|
||||
Some(s) if end > s => (Frame(s), Frame(end)),
|
||||
_ => {
|
||||
let playhead = self.program_clock.read(cx).current_frame();
|
||||
(playhead, Frame(playhead.0 + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Focuses a dock panel (used by the 窗口 menu).
|
||||
fn focus_panel(&self, id: gpui::dock::PanelId, cx: &mut Context<Self>) {
|
||||
if let Some(handle) = cx.windows().first() {
|
||||
@@ -1398,6 +1463,10 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
MenuItem::new(ADD_AUDIO_TRACK, tr("menu.sequence.add_audio_track")),
|
||||
MenuItem::new(REMOVE_TRACK, tr("menu.sequence.remove_track")).separated(),
|
||||
MenuItem::new(SPLIT_AT_PLAYHEAD, tr("menu.sequence.split_at_playhead")),
|
||||
MenuItem::new(ADD_MARKER, tr("menu.sequence.add_marker")).with_shortcut("M"),
|
||||
MenuItem::new(REMOVE_MARKER, tr("menu.sequence.remove_marker")).separated(),
|
||||
MenuItem::new(SET_WORKAREA, tr("menu.sequence.set_workarea")),
|
||||
MenuItem::new(CLEAR_WORKAREA, tr("menu.sequence.clear_workarea")),
|
||||
MenuItem::new(704, tr("menu.sequence.settings")).disabled(),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -214,6 +214,10 @@ const EN: &[(&str, &str)] = &[
|
||||
("menu.sequence.add_audio_track", "Add Audio Track"),
|
||||
("menu.sequence.remove_track", "Remove Selected Track"),
|
||||
("menu.sequence.split_at_playhead", "Split Clips at Playhead"),
|
||||
("menu.sequence.add_marker", "Add Marker"),
|
||||
("menu.sequence.remove_marker", "Remove Marker"),
|
||||
("menu.sequence.set_workarea", "Set Work Area"),
|
||||
("menu.sequence.clear_workarea", "Clear Work Area"),
|
||||
("menu.sequence.settings", "Sequence Settings…"),
|
||||
// --- Window ---
|
||||
("menu.window.project", "Project"),
|
||||
@@ -386,6 +390,10 @@ const ZH: &[(&str, &str)] = &[
|
||||
("menu.sequence.add_audio_track", "添加音频轨道"),
|
||||
("menu.sequence.remove_track", "删除所选轨道"),
|
||||
("menu.sequence.split_at_playhead", "在播放头处分割片段"),
|
||||
("menu.sequence.add_marker", "添加标记"),
|
||||
("menu.sequence.remove_marker", "清除标记"),
|
||||
("menu.sequence.set_workarea", "设置工作区"),
|
||||
("menu.sequence.clear_workarea", "清除工作区"),
|
||||
("menu.sequence.settings", "序列设置…"),
|
||||
// --- Window ---
|
||||
("menu.window.project", "项目"),
|
||||
|
||||
@@ -282,6 +282,52 @@ pub trait AppEngine:
|
||||
/// tool's menu action).
|
||||
fn split_at_playhead(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Sequence markers & work area (M12 P4): the facade surfaces are
|
||||
// undoable, mirroring Olive (MarkerAdd/MarkerRemove/WorkareaSet*).
|
||||
// Defaults: no-op / none, so mock-less engines degrade gracefully.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// The sequence work area (render/export in/out range) when enabled, in
|
||||
/// sequence frames. `None` when disabled or no sequence is open.
|
||||
fn workarea(&self) -> Option<(Frame, Frame)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Adds a marker at the program playhead (undoable).
|
||||
fn add_marker_at_playhead(&mut self, cx: &mut Context<Self>) {
|
||||
let _ = cx;
|
||||
}
|
||||
|
||||
/// Removes the marker at the program playhead, if any (undoable).
|
||||
fn remove_marker_at_playhead(&mut self, cx: &mut Context<Self>) {
|
||||
let _ = cx;
|
||||
}
|
||||
|
||||
/// Applies a work-area range **live** (not undoable) — the ruler drag
|
||||
/// preview path (Olive's `set_range` during drag).
|
||||
fn set_workarea_preview(&mut self, start: Frame, end: Frame, cx: &mut Context<Self>) {
|
||||
let _ = (start, end, cx);
|
||||
}
|
||||
|
||||
/// Commits a work-area range as ONE undoable entry. `old_start` /
|
||||
/// `old_end` are the range before the change (the ruler drag start).
|
||||
fn commit_workarea(
|
||||
&mut self,
|
||||
old_start: Frame,
|
||||
old_end: Frame,
|
||||
start: Frame,
|
||||
end: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let _ = (old_start, old_end, start, end, cx);
|
||||
}
|
||||
|
||||
/// Clears (disables) the work area (undoable).
|
||||
fn clear_workarea(&mut self, cx: &mut Context<Self>) {
|
||||
let _ = cx;
|
||||
}
|
||||
|
||||
/// Deletes the clip with `clip` id, rippling following content left when
|
||||
/// `ripple` is set.
|
||||
fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context<Self>);
|
||||
|
||||
@@ -268,6 +268,16 @@ unsafe extern "C" {
|
||||
num: c_int,
|
||||
den: c_int,
|
||||
);
|
||||
/// `oakengine_encoding_params_set_custom_range` — in/out export range as
|
||||
/// seconds rationals (the work-area export; the task renders exactly
|
||||
/// `[in, out)`).
|
||||
pub fn oakengine_encoding_params_set_custom_range(
|
||||
params: *mut OakEngineEncodingParams,
|
||||
in_num: i64,
|
||||
in_den: i64,
|
||||
out_num: i64,
|
||||
out_den: i64,
|
||||
);
|
||||
|
||||
// -- oakengine::common (config) --
|
||||
|
||||
@@ -697,6 +707,61 @@ unsafe extern "C" {
|
||||
height: f64,
|
||||
) -> c_int;
|
||||
|
||||
/// `oakengine_sequence_marker_count` — number of timeline markers
|
||||
/// (0 for NULL/invalid).
|
||||
pub fn oakengine_sequence_marker_count(self_: *const OakEngineSequence) -> c_int;
|
||||
/// `oakengine_sequence_marker_at` — marker at `index`: time as a frame
|
||||
/// timestamp (the sequence's timebase), name via the buf/size
|
||||
/// convention, and the color index.
|
||||
pub fn oakengine_sequence_marker_at(
|
||||
self_: *const OakEngineSequence,
|
||||
index: c_int,
|
||||
time: *mut i64,
|
||||
name: *mut c_char,
|
||||
name_size: c_int,
|
||||
color: *mut c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_marker_add` — undoable marker at `time_ts`.
|
||||
pub fn oakengine_sequence_marker_add(
|
||||
seq: *mut OakEngineSequence,
|
||||
time_ts: i64,
|
||||
name: *const c_char,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_marker_remove` — undoable removal of the marker
|
||||
/// at `time_ts`.
|
||||
pub fn oakengine_sequence_marker_remove(seq: *mut OakEngineSequence, time_ts: i64) -> c_int;
|
||||
|
||||
/// `oakengine_sequence_workarea_is_enabled` — 1 when the work area is
|
||||
/// enabled.
|
||||
pub fn oakengine_sequence_workarea_is_enabled(self_: *const OakEngineSequence) -> c_int;
|
||||
/// `oakengine_sequence_get_workarea` — work-area in/out as frame
|
||||
/// timestamps (the reset sentinel out when never set).
|
||||
pub fn oakengine_sequence_get_workarea(
|
||||
self_: *const OakEngineSequence,
|
||||
in_: *mut i64,
|
||||
out: *mut i64,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_set_workarea` — set the enabled flag + range
|
||||
/// live (NOT undoable; the ruler-drag preview path).
|
||||
pub fn oakengine_sequence_set_workarea(
|
||||
self_: *mut OakEngineSequence,
|
||||
enabled: c_int,
|
||||
in_: i64,
|
||||
out: i64,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_set_workarea_undoable` — set the enabled flag +
|
||||
/// range as ONE undoable entry ("Set Workarea"). `old_in`/`old_out` are
|
||||
/// the range before the change (the caller captured it, e.g. the
|
||||
/// drag-start range); the old enabled flag is captured by the engine.
|
||||
pub fn oakengine_sequence_set_workarea_undoable(
|
||||
self_: *mut OakEngineSequence,
|
||||
enabled: c_int,
|
||||
in_: i64,
|
||||
out: i64,
|
||||
old_in: i64,
|
||||
old_out: i64,
|
||||
) -> c_int;
|
||||
|
||||
/// `oakengine_track_height_internal_to_pixels`.
|
||||
pub fn oakengine_track_height_internal_to_pixels(height: f64) -> c_int;
|
||||
/// `oakengine_track_height_pixels_to_internal`.
|
||||
|
||||
+152
-4
@@ -50,8 +50,8 @@ use gpui::node_graph::{
|
||||
PortDataType, PortId, PortKind,
|
||||
};
|
||||
use gpui::timeline::{
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData,
|
||||
TrackKind, TrimEdge,
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, Marker, TimelineDataSource, TimelineEvent,
|
||||
TrackData, TrackKind, TrimEdge,
|
||||
};
|
||||
use gpui::{
|
||||
hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, Point, RenderImage,
|
||||
@@ -479,8 +479,7 @@ pub struct MockEngine {
|
||||
imported_footage: Vec<PathBuf>,
|
||||
/// 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.
|
||||
/// flow (list / open / create / rename / duplicate / delete / import / /// export) is testable without a database.
|
||||
library: Vec<LibraryProject>,
|
||||
/// Id allocator for library rows created at runtime.
|
||||
next_library_id: u64,
|
||||
@@ -490,6 +489,12 @@ pub struct MockEngine {
|
||||
/// (uuid, path) pairs handed to [`AppEngine::library_export_project`]
|
||||
/// (test observability).
|
||||
library_exported: Vec<(String, PathBuf)>,
|
||||
/// The demo sequence markers (M12 P4): shown on the timeline ruler and
|
||||
/// driven by the 序列 → 添加/清除标记 menu actions.
|
||||
markers: Vec<Marker>,
|
||||
/// The enabled work area (render/export in/out range) of the demo
|
||||
/// sequence, in sequence frames (M12 P4). `None` = disabled.
|
||||
workarea: Option<(Frame, Frame)>,
|
||||
}
|
||||
|
||||
impl MockEngine {
|
||||
@@ -737,6 +742,8 @@ impl MockEngine {
|
||||
next_library_id: 100,
|
||||
library_opened: Vec::new(),
|
||||
library_exported: Vec::new(),
|
||||
markers: Vec::new(),
|
||||
workarea: None,
|
||||
};
|
||||
// The demo graph is born connected: derive every port's `connected`
|
||||
// flag from the edge list.
|
||||
@@ -1229,6 +1236,17 @@ impl AppEngine for MockEngine {
|
||||
| TimelineEvent::TrackSelected { .. }
|
||||
| TimelineEvent::TransitionChanged { .. }
|
||||
| TimelineEvent::ZoomChanged(_) => {}
|
||||
TimelineEvent::WorkAreaPreview { start, end } => {
|
||||
self.set_workarea_preview(*start, *end, cx);
|
||||
}
|
||||
TimelineEvent::WorkAreaCommitted {
|
||||
start,
|
||||
end,
|
||||
old_start,
|
||||
old_end,
|
||||
} => {
|
||||
self.commit_workarea(*old_start, *old_end, *start, *end, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,6 +1320,50 @@ impl AppEngine for MockEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn workarea(&self) -> Option<(Frame, Frame)> {
|
||||
self.workarea
|
||||
}
|
||||
|
||||
fn add_marker_at_playhead(&mut self, cx: &mut Context<Self>) {
|
||||
let frame = self.clock_frame(Monitor::Program, cx);
|
||||
if !self.markers.iter().any(|m| m.frame == frame) {
|
||||
self.markers.push(Marker {
|
||||
frame,
|
||||
label: SharedString::new_static(""),
|
||||
color: None,
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn remove_marker_at_playhead(&mut self, cx: &mut Context<Self>) {
|
||||
let frame = self.clock_frame(Monitor::Program, cx);
|
||||
self.markers.retain(|m| m.frame != frame);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_workarea_preview(&mut self, start: Frame, end: Frame, cx: &mut Context<Self>) {
|
||||
self.workarea = Some((start, end));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn commit_workarea(
|
||||
&mut self,
|
||||
_old_start: Frame,
|
||||
_old_end: Frame,
|
||||
start: Frame,
|
||||
end: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.workarea = Some((start, end));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn clear_workarea(&mut self, cx: &mut Context<Self>) {
|
||||
self.workarea = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn new_project(&mut self, cx: &mut Context<Self>) {
|
||||
println!("[mock engine] new project: demo data stays (mock mode)");
|
||||
cx.notify();
|
||||
@@ -1481,6 +1543,10 @@ impl TimelineDataSource for MockEngine {
|
||||
fn track(&self, index: usize) -> Option<Self::Track> {
|
||||
self.tracks.get(index).cloned()
|
||||
}
|
||||
|
||||
fn markers(&self) -> Vec<Marker> {
|
||||
self.markers.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectStackDataSource for MockEngine {
|
||||
@@ -2034,4 +2100,86 @@ mod tests {
|
||||
assert!(bytes.chunks_exact(4).all(|px| px[3] == 255), "opaque alpha");
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sequence markers & work area (M12 P4): the mock state the 序列 menu
|
||||
// actions and the timeline ruler drag drive.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[gpui::test]
|
||||
async fn marker_menu_actions_add_and_remove_at_playhead(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
// Park the playhead at frame 40 (the program monitor's clock).
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.request_frame(Monitor::Program, Frame(40), cx);
|
||||
});
|
||||
|
||||
// 序列 → 添加标记: a marker appears at the playhead.
|
||||
engine.update(app, |engine, cx| engine.add_marker_at_playhead(cx));
|
||||
let markers = engine.read(app).markers();
|
||||
assert_eq!(markers.len(), 1);
|
||||
assert_eq!(markers[0].frame, Frame(40));
|
||||
|
||||
// Adding again at the same frame is idempotent for the menu.
|
||||
engine.update(app, |engine, cx| engine.add_marker_at_playhead(cx));
|
||||
assert_eq!(engine.read(app).markers().len(), 1);
|
||||
|
||||
// 序列 → 清除标记 removes it.
|
||||
engine.update(app, |engine, cx| engine.remove_marker_at_playhead(cx));
|
||||
assert!(engine.read(app).markers().is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn workarea_menu_actions_set_and_clear(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
assert_eq!(engine.read(app).workarea(), None);
|
||||
|
||||
// The menu's commit path (with an explicit old range).
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.commit_workarea(Frame(0), Frame(100), Frame(20), Frame(80), cx);
|
||||
});
|
||||
assert_eq!(engine.read(app).workarea(), Some((Frame(20), Frame(80))));
|
||||
|
||||
// The ruler-drag preview path (live, non-undoable).
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.set_workarea_preview(Frame(25), Frame(75), cx);
|
||||
});
|
||||
assert_eq!(engine.read(app).workarea(), Some((Frame(25), Frame(75))));
|
||||
|
||||
// 序列 → 清除工作区 disables it.
|
||||
engine.update(app, |engine, cx| engine.clear_workarea(cx));
|
||||
assert_eq!(engine.read(app).workarea(), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn timeline_workarea_events_drive_the_mock_engine(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
// The timeline widget's events land on the engine through
|
||||
// apply_timeline_event (the app shell's subscription).
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::WorkAreaPreview {
|
||||
start: Frame(30),
|
||||
end: Frame(90),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::WorkAreaCommitted {
|
||||
start: Frame(30),
|
||||
end: Frame(90),
|
||||
old_start: Frame::ZERO,
|
||||
old_end: Frame(100),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
assert_eq!(engine.read(app).workarea(), Some((Frame(30), Frame(90))));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+187
-9
@@ -63,9 +63,10 @@
|
||||
//! facade commands (drag previews never persist).
|
||||
//! * Audio meter still feeds silent data (the meter's facade surface is
|
||||
//! not bound in this increment).
|
||||
//! * Clip moves go through `oakengine_sequence_move_clip` (same-track only;
|
||||
//! the facade's capi signature has no target-track parameter, so a
|
||||
//! cross-track drag reports "not supported" instead of applying).
|
||||
//! * Clip moves go through the facade's move exports: same-track moves use
|
||||
//! `oakengine_sequence_move_clip`, cross-track moves
|
||||
//! `oakengine_sequence_move_clip_to_track` (M12 P4) — each one undoable
|
||||
//! entry, with the source spot becoming a gap.
|
||||
//!
|
||||
//! # Threading note
|
||||
//!
|
||||
@@ -90,8 +91,8 @@ use gpui::node_graph::{
|
||||
PortKind,
|
||||
};
|
||||
use gpui::timeline::{
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData,
|
||||
TrackKind, TrimEdge,
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, Marker, TimelineDataSource, TimelineEvent,
|
||||
TrackData, TrackKind, TrimEdge,
|
||||
};
|
||||
use gpui::{
|
||||
hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString,
|
||||
@@ -517,6 +518,20 @@ fn clip_color(index: u64) -> Hsla {
|
||||
}
|
||||
}
|
||||
|
||||
/// A marker color for a marker color index (the facade marker color
|
||||
/// contract): a small palette around the amber accent, so adjacent markers
|
||||
/// stay distinguishable.
|
||||
fn marker_color(index: c_int) -> Hsla {
|
||||
let hues = [0.10f32, 0.0, 0.55, 0.30, 0.78];
|
||||
let h = hues[(index.max(0) as usize) % hues.len()];
|
||||
Hsla {
|
||||
h,
|
||||
s: 0.75,
|
||||
l: 0.55,
|
||||
a: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A node in the real node graph (M12 P2: built from the current
|
||||
/// sequence's graph by [`crate::oakui::nodegraph`]).
|
||||
pub use crate::oakui::nodegraph::{RealEdge, RealNode, RealPort};
|
||||
@@ -1524,6 +1539,48 @@ impl TimelineDataSource for RealEngine {
|
||||
fn track(&self, index: usize) -> Option<Self::Track> {
|
||||
self.tracks.get(index).cloned()
|
||||
}
|
||||
|
||||
fn markers(&self) -> Vec<Marker> {
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let count = unsafe { oakengine_sequence_marker_count(seq) };
|
||||
if count <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::with_capacity(count as usize);
|
||||
for index in 0..count {
|
||||
let mut time: i64 = 0;
|
||||
let mut name_buf = [0 as c_char; 128];
|
||||
let mut color: c_int = 0;
|
||||
let rc = unsafe {
|
||||
oakengine_sequence_marker_at(
|
||||
seq,
|
||||
index,
|
||||
&mut time,
|
||||
name_buf.as_mut_ptr(),
|
||||
name_buf.len() as c_int,
|
||||
&mut color,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
continue;
|
||||
}
|
||||
let len = name_buf.iter().position(|&c| c == 0).unwrap_or(name_buf.len());
|
||||
let name: SharedString =
|
||||
String::from_utf8_lossy(unsafe {
|
||||
std::slice::from_raw_parts(name_buf.as_ptr() as *const u8, len)
|
||||
})
|
||||
.into_owned()
|
||||
.into();
|
||||
out.push(Marker {
|
||||
frame: Frame(time),
|
||||
label: name,
|
||||
color: Some(marker_color(color)),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectStackDataSource for RealEngine {
|
||||
@@ -2018,6 +2075,17 @@ impl AppEngine for RealEngine {
|
||||
| TimelineEvent::TrackSelected { .. }
|
||||
| TimelineEvent::TransitionChanged { .. }
|
||||
| TimelineEvent::ZoomChanged(_) => {}
|
||||
TimelineEvent::WorkAreaPreview { start, end } => {
|
||||
self.set_workarea_preview(*start, *end, cx);
|
||||
}
|
||||
TimelineEvent::WorkAreaCommitted {
|
||||
start,
|
||||
end,
|
||||
old_start,
|
||||
old_end,
|
||||
} => {
|
||||
self.commit_workarea(*old_start, *old_end, *start, *end, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2073,6 +2141,95 @@ impl AppEngine for RealEngine {
|
||||
self.apply_edit(rc, "split at playhead", cx);
|
||||
}
|
||||
|
||||
fn workarea(&self) -> Option<(Frame, Frame)> {
|
||||
let seq = self.seq_ptr()?;
|
||||
if unsafe { oakengine_sequence_workarea_is_enabled(seq) } == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut in_ts: i64 = 0;
|
||||
let mut out_ts: i64 = 0;
|
||||
if unsafe { oakengine_sequence_get_workarea(seq, &mut in_ts, &mut out_ts) } != 0 {
|
||||
return None;
|
||||
}
|
||||
Some((Frame(in_ts), Frame(out_ts)))
|
||||
}
|
||||
|
||||
fn add_marker_at_playhead(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return;
|
||||
};
|
||||
let frame = self.clock_frame(Monitor::Program, cx);
|
||||
let rc = unsafe { oakengine_sequence_marker_add(seq, frame.0, c"".as_ptr()) };
|
||||
self.apply_edit(rc, "add marker", cx);
|
||||
}
|
||||
|
||||
fn remove_marker_at_playhead(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return;
|
||||
};
|
||||
let frame = self.clock_frame(Monitor::Program, cx);
|
||||
let rc = unsafe { oakengine_sequence_marker_remove(seq, frame.0) };
|
||||
// Removing a marker that is not there is a benign no-op for the menu
|
||||
// action (the facade reports NOT_FOUND); only rebuild on success.
|
||||
if rc != 0 {
|
||||
return;
|
||||
}
|
||||
self.apply_edit(rc, "remove marker", cx);
|
||||
}
|
||||
|
||||
fn set_workarea_preview(&mut self, start: Frame, end: Frame, cx: &mut Context<Self>) {
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return;
|
||||
};
|
||||
// Live, non-undoable: the engine workarea tracks the drag so other
|
||||
// reads (export, snap) stay current; no timeline rebuild needed — the
|
||||
// band itself is widget-local state.
|
||||
unsafe { oakengine_sequence_set_workarea(seq, 1, start.0, end.0) };
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn commit_workarea(
|
||||
&mut self,
|
||||
old_start: Frame,
|
||||
old_end: Frame,
|
||||
start: Frame,
|
||||
end: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return;
|
||||
};
|
||||
let rc = unsafe {
|
||||
oakengine_sequence_set_workarea_undoable(
|
||||
seq,
|
||||
1,
|
||||
start.0,
|
||||
end.0,
|
||||
old_start.0,
|
||||
old_end.0,
|
||||
)
|
||||
};
|
||||
self.apply_edit(rc, "set workarea", cx);
|
||||
}
|
||||
|
||||
fn clear_workarea(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return;
|
||||
};
|
||||
let (old_start, old_end) = self.workarea().unwrap_or((Frame::ZERO, Frame::ZERO));
|
||||
let rc = unsafe {
|
||||
oakengine_sequence_set_workarea_undoable(
|
||||
seq,
|
||||
0,
|
||||
old_start.0,
|
||||
old_end.0,
|
||||
old_start.0,
|
||||
old_end.0,
|
||||
)
|
||||
};
|
||||
self.apply_edit(rc, "clear workarea", cx);
|
||||
}
|
||||
|
||||
fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context<Self>) {
|
||||
let Some((track_type, track_index, clip_index)) = self.clip_coords(clip) else {
|
||||
return;
|
||||
@@ -2426,11 +2583,32 @@ impl AppEngine for RealEngine {
|
||||
unsafe { oakengine_encoding_params_destroy(params) };
|
||||
return Err(format!("failed to enable audio (error {rc})"));
|
||||
}
|
||||
// Export the whole sequence.
|
||||
let length = self.sequence_length();
|
||||
if length.0 > 0 {
|
||||
// Export range: the work area when enabled (M12 P4), otherwise the
|
||||
// whole sequence. Frames → seconds rationals in the sequence's
|
||||
// frame-rate timebase (frame duration = rate_den / rate_num).
|
||||
if let Some((in_ts, out_ts)) = self.workarea().filter(|(s, e)| e.0 > s.0) {
|
||||
let tb_num = i64::from(rate_den.max(1));
|
||||
let tb_den = i64::from(rate_num.max(1));
|
||||
unsafe {
|
||||
oakengine_encoding_params_set_export_length(params, length.0 as c_int, 1);
|
||||
oakengine_encoding_params_set_custom_range(
|
||||
params,
|
||||
in_ts.0 * tb_num,
|
||||
tb_den,
|
||||
out_ts.0 * tb_num,
|
||||
tb_den,
|
||||
);
|
||||
oakengine_encoding_params_set_export_length(
|
||||
params,
|
||||
((out_ts.0 - in_ts.0) * tb_num) as c_int,
|
||||
rate_num.max(1),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let length = self.sequence_length();
|
||||
if length.0 > 0 {
|
||||
unsafe {
|
||||
oakengine_encoding_params_set_export_length(params, length.0 as c_int, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user