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
+19 -8
View File
@@ -10870,21 +10870,27 @@ pub mod timeline {
out_den: *mut c_int,
enabled: *mut c_int,
) -> c_int {
if in_num.is_null() || in_den.is_null() || out_num.is_null() || out_den.is_null() {
return oaktimeline::error::OAKTIMELINE_E_INVALID;
}
// SAFETY: work-area handles box TimelineWorkArea.
let wa = match unsafe { oaktimeline::handle::get::<oaktimeline::workarea::TimelineWorkArea>(&w) } {
Some(wa) => wa,
None => return oaktimeline::error::OAKTIMELINE_E_INVALID,
};
let range = wa.range();
// SAFETY: valid out pointers.
// Out params may individually be NULL (the header contract); write
// each only when the caller supplied a target.
unsafe {
*in_num = range.in_().numerator() as c_int;
*in_den = range.in_().denominator() as c_int;
*out_num = range.out().numerator() as c_int;
*out_den = range.out().denominator() as c_int;
if !in_num.is_null() {
*in_num = range.in_().numerator() as c_int;
}
if !in_den.is_null() {
*in_den = range.in_().denominator() as c_int;
}
if !out_num.is_null() {
*out_num = range.out().numerator() as c_int;
}
if !out_den.is_null() {
*out_den = range.out().denominator() as c_int;
}
if !enabled.is_null() {
*enabled = if wa.enabled() { 1 } else { 0 };
}
@@ -13699,6 +13705,11 @@ pub mod task {
subtitles_enabled: pod.subtitles_enabled != 0,
export_length_num: pod.export_length_num,
export_length_den: pod.export_length_den,
has_custom_range: pod.has_custom_range != 0,
custom_range_in_num: pod.custom_range_in_num as i32,
custom_range_in_den: pod.custom_range_in_den as i32,
custom_range_out_num: pod.custom_range_out_num as i32,
custom_range_out_den: pod.custom_range_out_den as i32,
};
let _ = color_manager; // the domain ExportTask dropped the manager slot
let inner = oaktask::export::ExportTask::new(viewer_ref, encoding);
+24
View File
@@ -709,6 +709,30 @@ fn export_params_pod(params: *const OakEngineEncodingParams) -> Result<EncodingP
&mut pod.export_length_den,
);
}
// Custom in/out range (work-area export): copied so the export task
// renders exactly [in, out) instead of the whole viewer length.
if unsafe { crate::codec::oakengine_encoding_params_has_custom_range(params) } != 0 {
let mut in_num: i64 = 0;
let mut in_den: i64 = 0;
let mut out_num: i64 = 0;
let mut out_den: i64 = 0;
let rc = unsafe {
crate::codec::oakengine_encoding_params_get_custom_range(
params,
&mut in_num,
&mut in_den,
&mut out_num,
&mut out_den,
)
};
if rc == 0 {
pod.has_custom_range = 1;
pod.custom_range_in_num = in_num;
pod.custom_range_in_den = in_den;
pod.custom_range_out_num = out_num;
pod.custom_range_out_den = out_den;
}
}
if pod.video_enabled != 0 {
let mut video = std::mem::MaybeUninit::<OakVideoParamsPod>::uninit();
@@ -0,0 +1,532 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Integration tests for the **timeline editing family** (M12 P4): the
//! cross-track move export `oakengine_sequence_move_clip_to_track`, the
//! sequence marker surface (add / remove / list, all undoable), and the
//! sequence work-area surface (set / get / enable / clear, live and
//! undoable). Coverage rules (see the family test charter):
//!
//! 1. no mocks — every call goes through the real facade into the real
//! module crates; the only stubs are the host-provided `oakcore_*`
//! symbols in `tests/common` (no media is decoded, so no FFmpeg);
//! 2. every export under test is exercised on a legal path with the
//! result asserted;
//! 3. illegal inputs (NULL seq, bad track types, out-of-range indices,
//! negative times) always yield a negative error code — never a crash;
//! 4. the undoable exports round-trip through `oakengine_project_undo` /
//! `oakengine_project_redo`.
//!
//! ## Serialization
//!
//! The tests assemble projects and push undo commands on the facade's
//! process-wide undo stack, so every test takes the shared stack lock (the
//! same pattern as `it_export`/`it_undo`).
use super::common;
use std::ffi::{c_char, c_int};
use std::sync::Mutex;
use crate::handle::{OakEngineFootage, OakEngineProject, OakEngineSequence, free_box};
use crate::node::{
oakengine_footage_free, oakengine_project_create, oakengine_project_free,
oakengine_project_import_footage, oakengine_project_new, oakengine_project_redo,
oakengine_project_undo,
};
use crate::timeline::{
oakengine_clip_get_range, oakengine_sequence_add_footage_clip_ex,
oakengine_sequence_add_track, oakengine_sequence_clip_at, oakengine_sequence_clip_count,
oakengine_sequence_get_workarea, oakengine_sequence_marker_add, oakengine_sequence_marker_at,
oakengine_sequence_marker_count, oakengine_sequence_marker_remove,
oakengine_sequence_move_clip_to_track, oakengine_sequence_new, oakengine_sequence_name,
oakengine_sequence_set_video_params, oakengine_sequence_set_workarea,
oakengine_sequence_set_workarea_undoable, oakengine_sequence_workarea_is_enabled,
};
use crate::undo::oakengine_undo_clear;
/// `OAKENGINE_TRACK_TYPE_*` (timeline.h).
const TRACK_VIDEO: c_int = 0;
const TRACK_AUDIO: c_int = 1;
/// Serializes every test here: the facade's global undo stack is shared
/// with the it_undo / it_export / it_storage tests.
static SERIAL: Mutex<()> = Mutex::new(());
/// Both lock guards held by [`serial`].
struct SerialGuard {
/// The [`SERIAL`] lock.
_task: std::sync::MutexGuard<'static, ()>,
/// The facade-wide undo-stack lock.
_stack: std::sync::MutexGuard<'static, ()>,
}
/// Take the [`SERIAL`] lock AND the global undo-stack lock, recovering
/// from any poisoning.
fn serial() -> SerialGuard {
let _task = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let _stack = super::it_undo::GLOBAL_STACK_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
SerialGuard { _task, _stack }
}
/// Reads a NUL-terminated string from a facade two-stage buffer.
unsafe fn read_str(buf: *const c_char) -> String {
if buf.is_null() {
return String::new();
}
let len = (0..4096).find(|&i| unsafe { *buf.add(i) } == 0).unwrap_or(0);
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf as *const u8, len) })
.into_owned()
}
/// A temp file path (per-process so parallel test binaries never collide).
fn temp_path(kind: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("oakengine-it-timeline-{kind}-{}.bin", std::process::id()))
}
/// Build a project + sequence with `video_tracks` video tracks, each
/// carrying one clip spanning `0..100` frames (media-in 0), at 25 fps.
/// The source file is a plain byte blob — the module does not probe media.
///
/// Returns `(project, sequence, footage)` — the caller releases the
/// footage with `oakengine_footage_free`, the sequence box with `free_box`,
/// and the project with `oakengine_project_free`.
///
/// # Safety
/// The returned handles must be released by the caller exactly once.
unsafe fn assemble_timeline_sequence(
video_tracks: c_int,
) -> (*mut OakEngineProject, *mut OakEngineSequence, *mut OakEngineFootage) {
unsafe {
// The caller holds the storage-config lock + storage_off_guard for
// the whole body (assembly AND the subsequent command pushes), so no
// lock is taken here (re-locking the same std mutex would deadlock).
let media = temp_path("media");
std::fs::write(&media, b"oak timeline test footage").expect("write the source blob");
let media_c = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
let project = oakengine_project_create();
assert!(!project.is_null());
assert_eq!(oakengine_project_new(project), 0);
let footage = oakengine_project_import_footage(project, media_c.as_ptr());
assert!(!footage.is_null(), "import the source blob");
let seq = oakengine_sequence_new(project, c"Timeline Test".as_ptr());
assert!(!seq.is_null());
assert_eq!(
oakengine_sequence_set_video_params(seq, 640, 480, 25, 1, 1, 1, 0, 4, 0),
0,
"set the sequence frame rate"
);
for _ in 0..video_tracks {
let track = oakengine_sequence_add_track(seq, TRACK_VIDEO);
assert!(track >= 0, "add the video track");
// Only the first track carries a clip: the move tests need an
// empty destination track (and `oakengine_sequence_add_track`
// returns its index, which is 0 for the first call).
if track == 0 {
let clip = oakengine_sequence_add_footage_clip_ex(seq, footage, TRACK_VIDEO, track, 0, 100, 0);
assert!(!clip.is_null(), "place the clip");
free_box(clip);
}
}
(project, seq, footage)
}
}
/// Release the assembly returned by [`assemble_timeline_sequence`].
///
/// # Safety
/// The handles must be the ones returned by [`assemble_timeline_sequence`].
unsafe fn drop_timeline_sequence(
project: *mut OakEngineProject,
seq: *mut OakEngineSequence,
footage: *mut OakEngineFootage,
) {
unsafe {
if !footage.is_null() {
oakengine_footage_free(footage);
}
free_box::<OakEngineSequence>(seq);
oakengine_project_free(project);
}
}
/// The (in, out) frame range of the clip at `(track, index)`, read back
/// through the facade. `None` when the track has no such clip.
unsafe fn clip_range_of(seq: *mut OakEngineSequence, track: c_int, index: c_int) -> Option<(i64, i64)> {
unsafe {
let clip = oakengine_sequence_clip_at(seq, TRACK_VIDEO, track, index);
if clip.is_null() {
return None;
}
let mut in_ts: i64 = 0;
let mut out_ts: i64 = 0;
let mut media_in: i64 = 0;
assert_eq!(oakengine_clip_get_range(clip, &mut in_ts, &mut out_ts, &mut media_in), 0);
free_box(clip);
Some((in_ts, out_ts))
}
}
// ---------------------------------------------------------------------------
// Cross-track move (oakengine_sequence_move_clip_to_track)
// ---------------------------------------------------------------------------
/// A cross-track move lands the clip on the destination track at the new
/// in point, and the source spot becomes a gap (the source track's clip
/// count drops to zero). One undoable entry: undo restores the clip to its
/// original track/position, redo re-applies the move.
#[test]
fn move_clip_to_track_cross_track_roundtrip() {
let _g = serial();
common::force_link();
unsafe {
// Storage off for the whole body: the assembly and the move both
// push commands (the assembly helper's guard is dropped on return).
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(2);
assert_eq!(oakengine_undo_clear(), 0);
assert_eq!(oakengine_sequence_clip_count(seq, TRACK_VIDEO, 0), 1);
assert_eq!(oakengine_sequence_clip_count(seq, TRACK_VIDEO, 1), 0);
assert_eq!(clip_range_of(seq, 0, 0), Some((0, 100)));
// Track 0 clip 0 → track 1 at frame 30.
let rc = oakengine_sequence_move_clip_to_track(seq, TRACK_VIDEO, 0, 0, 1, 30);
assert_eq!(rc, 0, "cross-track move succeeds");
assert_eq!(
oakengine_sequence_clip_count(seq, TRACK_VIDEO, 0),
0,
"source spot becomes a gap"
);
assert_eq!(
oakengine_sequence_clip_count(seq, TRACK_VIDEO, 1),
1,
"the clip lands on the destination track"
);
assert_eq!(clip_range_of(seq, 1, 0), Some((30, 130)));
// One undo restores the original layout.
assert_eq!(oakengine_project_undo(project), 0);
assert_eq!(oakengine_sequence_clip_count(seq, TRACK_VIDEO, 0), 1);
assert_eq!(oakengine_sequence_clip_count(seq, TRACK_VIDEO, 1), 0);
assert_eq!(clip_range_of(seq, 0, 0), Some((0, 100)));
// Redo re-applies the move.
assert_eq!(oakengine_project_redo(project), 0);
assert_eq!(oakengine_sequence_clip_count(seq, TRACK_VIDEO, 0), 0);
assert_eq!(oakengine_sequence_clip_count(seq, TRACK_VIDEO, 1), 1);
assert_eq!(clip_range_of(seq, 1, 0), Some((30, 130)));
drop_timeline_sequence(project, seq, footage);
}
}
/// Moving to the same track (destination index == source index) is a
/// time-only move, equivalent to `oakengine_sequence_move_clip`.
#[test]
fn move_clip_to_track_same_track_is_time_only() {
let _g = serial();
common::force_link();
unsafe {
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(2);
assert_eq!(oakengine_undo_clear(), 0);
let rc = oakengine_sequence_move_clip_to_track(seq, TRACK_VIDEO, 0, 0, 0, 40);
assert_eq!(rc, 0, "same-track move succeeds");
assert_eq!(oakengine_sequence_clip_count(seq, TRACK_VIDEO, 0), 1);
assert_eq!(oakengine_sequence_clip_count(seq, TRACK_VIDEO, 1), 0);
assert_eq!(clip_range_of(seq, 0, 0), Some((40, 140)));
assert_eq!(oakengine_project_undo(project), 0);
assert_eq!(clip_range_of(seq, 0, 0), Some((0, 100)));
drop_timeline_sequence(project, seq, footage);
}
}
/// Illegal inputs never crash: NULL sequence, unknown track types,
/// negative in points, missing clips and out-of-range destination tracks
/// all yield a negative error code.
#[test]
fn move_clip_to_track_rejects_illegal_inputs() {
let _g = serial();
common::force_link();
unsafe {
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(1);
assert_eq!(oakengine_undo_clear(), 0);
// NULL sequence.
assert!(oakengine_sequence_move_clip_to_track(
std::ptr::null_mut(), TRACK_VIDEO, 0, 0, 1, 10
) < 0);
// Unknown track types.
assert!(oakengine_sequence_move_clip_to_track(seq, 3, 0, 0, 1, 10) < 0);
assert!(oakengine_sequence_move_clip_to_track(seq, -1, 0, 0, 1, 10) < 0);
// Negative destination in point.
assert!(oakengine_sequence_move_clip_to_track(seq, TRACK_VIDEO, 0, 0, 1, -5) < 0);
// Missing clip (index 5 on a one-clip track).
assert!(oakengine_sequence_move_clip_to_track(seq, TRACK_VIDEO, 0, 5, 1, 10) < 0);
assert!(oakengine_sequence_move_clip_to_track(seq, TRACK_VIDEO, 2, 0, 1, 10) < 0);
// Destination track out of range.
assert!(oakengine_sequence_move_clip_to_track(seq, TRACK_VIDEO, 0, 0, 9, 10) < 0);
// The rejections left the sequence untouched.
assert_eq!(oakengine_sequence_clip_count(seq, TRACK_VIDEO, 0), 1);
assert_eq!(clip_range_of(seq, 0, 0), Some((0, 100)));
drop_timeline_sequence(project, seq, footage);
}
}
// ---------------------------------------------------------------------------
// Sequence markers (oakengine_sequence_marker_*)
// ---------------------------------------------------------------------------
/// `oakengine_sequence_marker_add` inserts an undoable marker; the list
/// exposes it through count/at (time in the sequence's frame timebase,
/// name and color round-trip). Undo removes it, redo re-adds it.
#[test]
fn markers_add_list_and_undo_roundtrip() {
let _g = serial();
common::force_link();
unsafe {
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(0);
assert_eq!(oakengine_undo_clear(), 0);
assert_eq!(oakengine_sequence_marker_count(seq), 0);
let rc = oakengine_sequence_marker_add(seq, 60, c"scene 1".as_ptr());
assert_eq!(rc, 0, "add a marker at frame 60");
assert_eq!(oakengine_sequence_marker_count(seq), 1);
let mut time: i64 = -1;
let mut color: c_int = -1;
let mut name_buf = [0 as c_char; 64];
assert_eq!(
oakengine_sequence_marker_at(
seq, 0, &mut time, name_buf.as_mut_ptr(), 64, &mut color
),
0
);
assert_eq!(time, 60);
assert_eq!(color, 0);
assert_eq!(read_str(name_buf.as_ptr()), "scene 1");
// Undo removes the marker, redo re-adds it.
assert_eq!(oakengine_project_undo(project), 0);
assert_eq!(oakengine_sequence_marker_count(seq), 0);
assert_eq!(oakengine_project_redo(project), 0);
assert_eq!(oakengine_sequence_marker_count(seq), 1);
time = -1;
assert_eq!(oakengine_sequence_marker_at(seq, 0, &mut time, std::ptr::null_mut(), 0, std::ptr::null_mut()), 0);
assert_eq!(time, 60);
drop_timeline_sequence(project, seq, footage);
}
}
/// `oakengine_sequence_marker_remove` removes the marker at a time
/// (undoable); removing from an empty spot is an error that leaves the list
/// intact.
#[test]
fn markers_remove_and_reject_duplicates() {
let _g = serial();
common::force_link();
unsafe {
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(0);
assert_eq!(oakengine_undo_clear(), 0);
assert_eq!(oakengine_sequence_marker_add(seq, 30, std::ptr::null()), 0);
assert_eq!(oakengine_sequence_marker_add(seq, 90, c"end".as_ptr()), 0);
assert_eq!(oakengine_sequence_marker_count(seq), 2);
// A second marker at the same time is rejected (module asserts on
// duplicate in points).
assert!(oakengine_sequence_marker_add(seq, 30, c"dup".as_ptr()) < 0);
assert_eq!(oakengine_sequence_marker_count(seq), 2);
// Removing the marker at frame 30 (undoable).
assert_eq!(oakengine_sequence_marker_remove(seq, 30), 0);
assert_eq!(oakengine_sequence_marker_count(seq), 1);
let mut time: i64 = -1;
assert_eq!(oakengine_sequence_marker_at(seq, 0, &mut time, std::ptr::null_mut(), 0, std::ptr::null_mut()), 0);
assert_eq!(time, 90, "the surviving marker is the one at 90");
// Removing from an empty time is an error.
assert!(oakengine_sequence_marker_remove(seq, 30) < 0);
assert_eq!(oakengine_sequence_marker_count(seq), 1);
// Undo restores the removed marker.
assert_eq!(oakengine_project_undo(project), 0);
assert_eq!(oakengine_sequence_marker_count(seq), 2);
drop_timeline_sequence(project, seq, footage);
}
}
/// Marker inputs: NULL sequence and out-of-range indices are errors, never
/// crashes.
#[test]
fn markers_reject_illegal_inputs() {
let _g = serial();
common::force_link();
unsafe {
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(0);
assert_eq!(oakengine_undo_clear(), 0);
assert!(oakengine_sequence_marker_add(std::ptr::null_mut(), 10, std::ptr::null()) < 0);
assert!(oakengine_sequence_marker_remove(std::ptr::null_mut(), 10) < 0);
assert_eq!(oakengine_sequence_marker_count(std::ptr::null_mut()), 0);
assert!(oakengine_sequence_marker_remove(seq, 10) < 0, "no marker at frame 10");
let mut time: i64 = 0;
assert!(
oakengine_sequence_marker_at(seq, 3, &mut time, std::ptr::null_mut(), 0, std::ptr::null_mut()) < 0,
"index out of range"
);
drop_timeline_sequence(project, seq, footage);
}
}
// ---------------------------------------------------------------------------
// Sequence work area (oakengine_sequence_workarea_*)
// ---------------------------------------------------------------------------
/// `oakengine_sequence_set_workarea` (live) round-trips through
/// `oakengine_sequence_get_workarea` / `oakengine_sequence_workarea_is_enabled`;
/// disabling clears the enabled flag.
#[test]
fn workarea_set_get_clear_roundtrip() {
let _g = serial();
common::force_link();
unsafe {
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(0);
assert_eq!(oakengine_sequence_workarea_is_enabled(seq), 0);
let mut in_ts: i64 = -1;
let mut out_ts: i64 = -1;
assert_eq!(oakengine_sequence_get_workarea(seq, &mut in_ts, &mut out_ts), 0);
// Default range: the reset sentinel, 0..RATIONAL_MAX in the frame
// timebase (a huge positive out point).
assert_eq!(in_ts, 0);
assert!(out_ts > 0, "default out is the reset sentinel, got {out_ts}");
// Set an enabled range and read it back.
assert_eq!(oakengine_sequence_set_workarea(seq, 1, 100, 200), 0);
assert_eq!(oakengine_sequence_workarea_is_enabled(seq), 1);
assert_eq!(oakengine_sequence_get_workarea(seq, &mut in_ts, &mut out_ts), 0);
assert_eq!((in_ts, out_ts), (100, 200));
// Disable ("clear") keeps the range but flips the flag.
assert_eq!(oakengine_sequence_set_workarea(seq, 0, 100, 200), 0);
assert_eq!(oakengine_sequence_workarea_is_enabled(seq), 0);
assert_eq!(oakengine_sequence_get_workarea(seq, &mut in_ts, &mut out_ts), 0);
assert_eq!((in_ts, out_ts), (100, 200));
drop_timeline_sequence(project, seq, footage);
}
}
/// `oakengine_sequence_set_workarea_undoable` changes enabled + range as
/// ONE undoable entry: undo restores the pre-change range (the caller
/// supplied old in/out), redo re-applies the new one.
#[test]
fn workarea_undoable_set_roundtrips() {
let _g = serial();
common::force_link();
unsafe {
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(0);
assert_eq!(oakengine_undo_clear(), 0);
// Start from a live-set range so the undo has something to restore.
assert_eq!(oakengine_sequence_set_workarea(seq, 1, 100, 200), 0);
let rc = oakengine_sequence_set_workarea_undoable(seq, 1, 300, 400, 100, 200);
assert_eq!(rc, 0, "undoable workarea set succeeds");
let mut in_ts: i64 = 0;
let mut out_ts: i64 = 0;
assert_eq!(oakengine_sequence_get_workarea(seq, &mut in_ts, &mut out_ts), 0);
assert_eq!((in_ts, out_ts), (300, 400));
assert_eq!(oakengine_sequence_workarea_is_enabled(seq), 1);
// One undo entry: enabled + range restored together.
assert_eq!(oakengine_project_undo(project), 0);
assert_eq!(oakengine_sequence_workarea_is_enabled(seq), 1);
assert_eq!(oakengine_sequence_get_workarea(seq, &mut in_ts, &mut out_ts), 0);
assert_eq!((in_ts, out_ts), (100, 200));
// Redo re-applies.
assert_eq!(oakengine_project_redo(project), 0);
assert_eq!(oakengine_sequence_get_workarea(seq, &mut in_ts, &mut out_ts), 0);
assert_eq!((in_ts, out_ts), (300, 400));
drop_timeline_sequence(project, seq, footage);
}
}
/// Work-area inputs: NULL sequence and negative ranges are errors.
#[test]
fn workarea_rejects_illegal_inputs() {
let _g = serial();
common::force_link();
unsafe {
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(0);
assert_eq!(oakengine_sequence_workarea_is_enabled(std::ptr::null_mut()), 0);
assert!(oakengine_sequence_get_workarea(std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) < 0);
assert!(oakengine_sequence_set_workarea(std::ptr::null_mut(), 1, 0, 10) < 0);
assert!(oakengine_sequence_set_workarea_undoable(std::ptr::null_mut(), 1, 0, 10, 0, 0) < 0);
// Negative range values are rejected by the undoable export (never a
// crash). The live setter is a plain setter (C++ parity — it stores
// what it is given), so only the command path validates.
assert!(oakengine_sequence_set_workarea_undoable(seq, 1, 0, 10, -1, 0) < 0);
assert!(oakengine_sequence_set_workarea_undoable(seq, 1, -1, 10, 0, 0) < 0);
drop_timeline_sequence(project, seq, footage);
}
}
/// The sequence name getter used by the app's `refresh_sequence_info`
/// (two-stage buf/size) is exercised here as the assembly smoke check.
#[test]
fn assembled_sequence_has_expected_name() {
let _g = serial();
common::force_link();
unsafe {
let _storage = common::storage_off_guard();
let (project, seq, footage) = assemble_timeline_sequence(1);
let mut buf = [0 as c_char; 64];
assert!(oakengine_sequence_name(seq, buf.as_mut_ptr(), 64) > 0);
assert_eq!(read_str(buf.as_ptr()), "Timeline Test");
drop_timeline_sequence(project, seq, footage);
}
}
+1
View File
@@ -45,6 +45,7 @@ mod it_library;
mod it_plugin;
mod it_storage;
mod it_task;
mod it_timeline;
mod it_undo;
mod linkage;
mod node;
+69
View File
@@ -1577,6 +1577,75 @@ pub unsafe extern "C" fn oakengine_sequence_set_workarea(
})
}
/// `oakengine_sequence_set_workarea_undoable` — set the workarea's enabled
/// flag and in/out range as ONE undoable entry ("Set Workarea").
///
/// The old range must be supplied by the caller (the same convention as
/// `oakengine_workarea_set_range_undoable`): pass the range read before the
/// change — e.g. the drag-start range of a ruler work-area drag. The enabled
/// flag's previous value is captured by the module command itself.
#[no_mangle]
pub unsafe extern "C" fn oakengine_sequence_set_workarea_undoable(
self_: *mut OakEngineSequence,
enabled: c_int,
in_: i64,
out: i64,
old_in: i64,
old_out: i64,
) -> c_int {
guard(|| unsafe {
set_seq_error("");
let sequence = match unbox(self_) {
Ok(h) => h,
Err(_) => {
set_seq_error("invalid sequence");
return Err(Error::Invalid);
}
};
let tb = match seq_time_base(sequence) {
Ok(tb) => tb,
Err(_) => {
set_seq_error("sequence has no valid frame rate");
return Err(Error::State);
}
};
if in_ < 0 || out < 0 || old_in < 0 || old_out < 0 {
set_seq_error("invalid workarea range");
return Err(Error::Invalid);
}
let wa = seq_workarea(sequence)?;
let (new_in_num, new_in_den) = ts_to_rational(in_, tb);
let (new_out_num, new_out_den) = ts_to_rational(out, tb);
let (old_in_num, old_in_den) = ts_to_rational(old_in, tb);
let (old_out_num, old_out_den) = ts_to_rational(old_out, tb);
let enabled_cmd = tl::oaktimeline_workarea_set_enabled_command(wa, enabled);
if enabled_cmd.is_null() {
release_handle(wa);
set_seq_error("workarea enabled command failed");
return Err(Error::Failed("workarea enabled command failed".into()));
}
let range_cmd = tl::oaktimeline_workarea_set_range_command(
wa,
new_in_num as c_int,
new_in_den as c_int,
new_out_num as c_int,
new_out_den as c_int,
old_in_num as c_int,
old_in_den as c_int,
old_out_num as c_int,
old_out_den as c_int,
);
release_handle(wa);
if range_cmd.is_null() {
set_seq_error("workarea range command failed");
return Err(Error::Failed("workarea range command failed".into()));
}
// The commands hold borrowed clones of `wa` (no addref); the workarea
// lives with the sequence, so it outlives the undo entry.
push_multi_commands(&[enabled_cmd, range_cmd], "Set Workarea")
})
}
/// `oakengine_sequence_marker_count` — number of timeline markers.
#[no_mangle]
pub unsafe extern "C" fn oakengine_sequence_marker_count(self_: *const OakEngineSequence) -> c_int {
+32 -1
View File
@@ -101,6 +101,18 @@ pub struct EncodingParams {
pub export_length_num: i32,
/// Export length denominator.
pub export_length_den: i32,
/// Whether a custom in/out export range is set (work-area export). When
/// set, [`ExportTask::export_range`] renders exactly `[in, out)` instead
/// of the whole viewer length.
pub has_custom_range: bool,
/// Custom range in point numerator (seconds rational).
pub custom_range_in_num: i32,
/// Custom range in point denominator.
pub custom_range_in_den: i32,
/// Custom range out point numerator (seconds rational).
pub custom_range_out_num: i32,
/// Custom range out point denominator.
pub custom_range_out_den: i32,
}
impl ExportTask {
@@ -157,8 +169,27 @@ impl ExportTask {
/// Resolve the export range: the custom range when set, otherwise the
/// whole viewer length (direct `oaknode` domain query; the deleted
/// `oaknode_sequence_get_length` stub is gone).
/// `oaknode_sequence_get_length` stub is gone). The custom range is the
/// work-area / in-out export: `export_params_pod` copies it from the
/// facade params handle, so `oakengine_export_render_with_params` and the
/// app's work-area export render exactly `[in, out)`.
fn export_range(&self) -> TimeRange {
if self.encoding_params.has_custom_range
&& self.encoding_params.custom_range_in_den != 0
&& self.encoding_params.custom_range_out_den != 0
{
let in_ = Rational::new(
i64::from(self.encoding_params.custom_range_in_num),
i64::from(self.encoding_params.custom_range_in_den),
);
let out = Rational::new(
i64::from(self.encoding_params.custom_range_out_num),
i64::from(self.encoding_params.custom_range_out_den),
);
if out > in_ {
return TimeRange::new(in_, out);
}
}
let length = nodeops::node_length(&self.viewer_node.0, self.viewer_node.1);
TimeRange::new(Rational::new(0, 1), length)
}
+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);
}
}
}