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:
2026-08-16 16:06:19 +08:00
parent 23e6fa7a5b
commit 92daff1a83
12 changed files with 1205 additions and 23 deletions
+70 -1
View File
@@ -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(),
]),
),
+8
View File
@@ -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", "项目"),
+46
View File
@@ -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>);
+65
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
}