From 92daff1a83b5ec96654693c18035e3c1a2aeb945 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 16 Aug 2026 16:06:19 +0800 Subject: [PATCH] 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 --- crates/oakengine/src/stubs.rs | 27 +- crates/oakengine/src/task.rs | 24 + .../oakengine/src/test_support/it_timeline.rs | 532 ++++++++++++++++++ crates/oakengine/src/test_support/mod.rs | 1 + crates/oakengine/src/timeline.rs | 69 +++ crates/oaktask/src/export.rs | 33 +- src/app.rs | 71 ++- src/i18n.rs | 8 + src/oakui/engine.rs | 46 ++ src/oakui/ffi.rs | 65 +++ src/oakui/mock.rs | 156 ++++- src/oakui/real.rs | 196 ++++++- 12 files changed, 1205 insertions(+), 23 deletions(-) create mode 100644 crates/oakengine/src/test_support/it_timeline.rs diff --git a/crates/oakengine/src/stubs.rs b/crates/oakengine/src/stubs.rs index 7ad3531a5..79436f6d1 100644 --- a/crates/oakengine/src/stubs.rs +++ b/crates/oakengine/src/stubs.rs @@ -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::(&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); diff --git a/crates/oakengine/src/task.rs b/crates/oakengine/src/task.rs index 81ed5c68f..c572e6575 100644 --- a/crates/oakengine/src/task.rs +++ b/crates/oakengine/src/task.rs @@ -709,6 +709,30 @@ fn export_params_pod(params: *const OakEngineEncodingParams) -> Result::uninit(); diff --git a/crates/oakengine/src/test_support/it_timeline.rs b/crates/oakengine/src/test_support/it_timeline.rs new file mode 100644 index 000000000..3119b9a30 --- /dev/null +++ b/crates/oakengine/src/test_support/it_timeline.rs @@ -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 . + +//! 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::(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); + } +} diff --git a/crates/oakengine/src/test_support/mod.rs b/crates/oakengine/src/test_support/mod.rs index da42b101e..7bd216244 100644 --- a/crates/oakengine/src/test_support/mod.rs +++ b/crates/oakengine/src/test_support/mod.rs @@ -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; diff --git a/crates/oakengine/src/timeline.rs b/crates/oakengine/src/timeline.rs index ba4bb86d2..4519d0193 100644 --- a/crates/oakengine/src/timeline.rs +++ b/crates/oakengine/src/timeline.rs @@ -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 { diff --git a/crates/oaktask/src/export.rs b/crates/oaktask/src/export.rs index 672c716f7..c6ec70b23 100644 --- a/crates/oaktask/src/export.rs +++ b/crates/oaktask/src/export.rs @@ -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) } diff --git a/src/app.rs b/src/app.rs index c09148509..3ca13ef47 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 OakApp { 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 OakApp { 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 OakApp { .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) { + 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 = self.timeline.read(cx).selection().iter().copied().collect(); + let engine = self.engine.read(cx); + let mut start: Option = 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) { if let Some(handle) = cx.windows().first() { @@ -1398,6 +1463,10 @@ fn make_menus(dark: bool) -> Vec { 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(), ]), ), diff --git a/src/i18n.rs b/src/i18n.rs index 46762142d..b61713282 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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", "项目"), diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 3d274f2c3..56633669d 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -282,6 +282,52 @@ pub trait AppEngine: /// tool's menu action). fn split_at_playhead(&mut self, cx: &mut Context); + // ------------------------------------------------------------------- + // 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) { + let _ = cx; + } + + /// Removes the marker at the program playhead, if any (undoable). + fn remove_marker_at_playhead(&mut self, cx: &mut Context) { + 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) { + 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, + ) { + let _ = (old_start, old_end, start, end, cx); + } + + /// Clears (disables) the work area (undoable). + fn clear_workarea(&mut self, cx: &mut Context) { + 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); diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs index b5510d503..9855e458c 100644 --- a/src/oakui/ffi.rs +++ b/src/oakui/ffi.rs @@ -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`. diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index c7e5bf456..261e2f365 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -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, /// 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, /// 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, + /// 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) { + 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) { + 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.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.workarea = Some((start, end)); + cx.notify(); + } + + fn clear_workarea(&mut self, cx: &mut Context) { + self.workarea = None; + cx.notify(); + } + fn new_project(&mut self, cx: &mut Context) { 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.tracks.get(index).cloned() } + + fn markers(&self) -> Vec { + 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)))); + }); + } } diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 1081a9483..b3004e3c6 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -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.tracks.get(index).cloned() } + + fn markers(&self) -> Vec { + 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) { + 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) { + 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) { + 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, + ) { + 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) { + 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) { 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); + } } }