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