From 2aa7eec016216197a27dca3d2f7a599624731be9 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Mon, 20 Jul 2026 13:17:07 +0800 Subject: [PATCH] engine: timeline panel core edit commands migrate to the facade (part 1) - new batch primitives: split_clips (link-preserving, single undo command), delete_clips (gap replace + optional ripple with explicit region support), ripple_delete_range, marker_add_ex with color - razor/split-at-playhead, clip delete, ripple-to-point, track delete, and the non-dialog marker path now issue facade commands instead of the app's own undo command classes - batch operations deliberately produce one undo command per user action (deleting twenty clips is one entry, not twenty); selection and transition removal stay UI-side as documented leftovers --- app/widget/timebased/timebasedwidget.cpp | 23 +- app/widget/timelinewidget/timelinewidget.cpp | 84 +++++--- app/widget/timelinewidget/tool/razor.cpp | 22 +- .../trackview/trackviewitem.cpp | 9 +- engine/include/oakengine/timeline.h | 73 ++++++- engine/src/capi/timeline.cpp | 197 +++++++++++++++++- engine/tests/oakengine_init_test.cpp | 4 +- engine/tests/oakengine_timeline_edit_test.cpp | 167 ++++++++++++++- 8 files changed, 528 insertions(+), 51 deletions(-) diff --git a/app/widget/timebased/timebasedwidget.cpp b/app/widget/timebased/timebasedwidget.cpp index 5c672ecc3..adaa015d3 100644 --- a/app/widget/timebased/timebasedwidget.cpp +++ b/app/widget/timebased/timebasedwidget.cpp @@ -30,6 +30,7 @@ #include "common/current.h" #include "dialog/markerproperties/markerpropertiesdialog.h" #include "node/project/sequence/sequence.h" +#include "oakengine/timeline.h" #include "timeline/timelineundoworkarea.h" #include "widget/timeruler/timeruler.h" @@ -751,17 +752,35 @@ void TimeBasedWidget::set_marker() color, TimeRange(get_connected_node()->get_playhead(), get_connected_node()->get_playhead())); + bool edited_in_dialog = false; if (OAK_CONFIG("SetNameWithMarker").toBool()) { MarkerPropertiesDialog mpd({ marker }, timebase(), this); if (mpd.exec() != QDialog::Accepted) { delete marker; marker = nullptr; + } else { + edited_in_dialog = true; } } if (marker) { - Core::instance()->undo_stack()->push( - new MarkerAddCommand(markers, marker), tr("Added Marker")); + if (edited_in_dialog) { + // The dialog pushed undo commands referencing this exact + // marker object, so it must be the one added to the list. + Core::instance()->undo_stack()->push( + new MarkerAddCommand(markers, marker), tr("Added Marker")); + } else { + // Pristine marker: add through the liboakengine C ABI + // facade (one undoable command) and drop the temporary. + oakengine_sequence_marker_add_ex( + reinterpret_cast( + get_connected_node()), + Timecode::time_to_timestamp(marker->time().in(), + timebase(), + Timecode::k_round), + "", marker->color()); + delete marker; + } } } } diff --git a/app/widget/timelinewidget/timelinewidget.cpp b/app/widget/timelinewidget/timelinewidget.cpp index 4c9fb32d6..8b9efd01a 100644 --- a/app/widget/timelinewidget/timelinewidget.cpp +++ b/app/widget/timelinewidget/timelinewidget.cpp @@ -47,6 +47,7 @@ #include "node/nodeundo.h" #include "node/project/footage/footage.h" #include "node/project/serializer/serializer.h" +#include "oakengine/timeline.h" #include "render/audiowaveformcache.h" #include "task/project/import/import.h" #include "timeline/timelineundogeneral.h" @@ -556,10 +557,20 @@ void TimelineWidget::split_at_playhead() } if (!blocks_to_split.isEmpty()) { - Core::instance()->undo_stack()->push( - new BlockSplitPreservingLinksCommand(blocks_to_split, - { playhead_time }), - tr("Split Clips At Playhead")); + // Split through the liboakengine C ABI facade: one undoable, + // link-preserving command with the same semantics as the old + // app-side BlockSplitPreservingLinksCommand push. + QVector clips; + clips.reserve(blocks_to_split.size()); + foreach (Block *b, blocks_to_split) { + clips.append(reinterpret_cast( + static_cast(b))); + } + oakengine_sequence_split_clips( + reinterpret_cast(sequence()), clips.data(), + clips.size(), + Timecode::time_to_timestamp(playhead_time, timebase(), + Timecode::k_round)); } } @@ -639,32 +650,47 @@ void TimelineWidget::DeleteSelected(bool ripple) command->add_child(trc); } - // Replace clips with gaps (effectively deleting them) - replace_blocks_with_gaps(clips_to_delete, true, command, false); + // Selection clearing and transition removal stay app-side (selection + // state and transition commands have no facade equivalent); the clip + // deletion core below goes through the facade and lands as one undoable + // command right after this one, keeping the undo order intact. + Core::instance()->undo_stack()->push(command, tr("Deleted Clips")); - // Insert ripple command now that it's all cleaned up gaps - TimelineRippleDeleteGapsAtRegionsCommand *ripple_command = nullptr; - Rational new_playhead = RATIONAL_MAX; - if (ripple) { - TimelineRippleDeleteGapsAtRegionsCommand::RangeList range_list; - - foreach (Block *b, selected_list) { - range_list.append({ b->track(), b->range() }); - new_playhead = qMin(new_playhead, b->in()); - } - - ripple_command = new TimelineRippleDeleteGapsAtRegionsCommand( - sequence(), range_list); - command->add_child(ripple_command); + // Delete the clips through the liboakengine C ABI facade (gap + // replacement + graph removal, optionally rippling the selected ranges + // closed), same semantics as the old in-command children. + QVector facade_clips; + facade_clips.reserve(clips_to_delete.size()); + foreach (Block *b, clips_to_delete) { + facade_clips.append( + reinterpret_cast(static_cast(b))); } - Core::instance()->undo_stack()->push(command, tr("Deleted Clips")); + Rational new_playhead = RATIONAL_MAX; + QVector ripple_ranges; + if (ripple) { + foreach (Block *b, selected_list) { + ripple_ranges.append(int64_t(b->track()->type())); + ripple_ranges.append(b->track()->index()); + ripple_ranges.append(Timecode::time_to_timestamp( + b->in(), timebase(), Timecode::k_round)); + ripple_ranges.append(Timecode::time_to_timestamp( + b->out(), timebase(), Timecode::k_round)); + new_playhead = qMin(new_playhead, b->in()); + } + } + + int rippled = 0; + oakengine_sequence_delete_clips( + reinterpret_cast(sequence()), + facade_clips.data(), facade_clips.size(), ripple ? 1 : 0, + ripple ? ripple_ranges.constData() : nullptr, + ripple ? ripple_ranges.size() / 4 : 0, &rippled); // Ensures any current drag operations are cancelled clear_ghosts(); - if (ripple_command && ripple_command->has_commands() && - new_playhead != RATIONAL_MAX) { + if (ripple && rippled && new_playhead != RATIONAL_MAX) { get_connected_node()->set_playhead(new_playhead); } } @@ -2535,10 +2561,14 @@ void TimelineWidget::ripple_to(Timeline::MovementMode mode) Rational in_ripple = qMin(closest_point_to_playhead, playhead_time); Rational out_ripple = qMax(closest_point_to_playhead, playhead_time); - TimelineRippleRemoveAreaCommand *c = - new TimelineRippleRemoveAreaCommand(sequence(), in_ripple, out_ripple); - - Core::instance()->undo_stack()->push(c, tr("Rippled Clip(s) To Point")); + // Ripple the region out through the liboakengine C ABI facade (one + // undoable all-tracks ripple, same as the old + // TimelineRippleRemoveAreaCommand push). + oakengine_sequence_ripple_delete_range( + reinterpret_cast(sequence()), + Timecode::time_to_timestamp(in_ripple, timebase(), Timecode::k_round), + Timecode::time_to_timestamp(out_ripple, timebase(), + Timecode::k_round)); // If we rippled, ump to where new cut is if applicable if (mode == Timeline::k_trim_in) { diff --git a/app/widget/timelinewidget/tool/razor.cpp b/app/widget/timelinewidget/tool/razor.cpp index 234d50bc0..874277039 100644 --- a/app/widget/timelinewidget/tool/razor.cpp +++ b/app/widget/timelinewidget/tool/razor.cpp @@ -21,8 +21,7 @@ #include "razor.h" -#include "node/nodeundo.h" -#include "timeline/timelineundosplit.h" +#include "oakengine/timeline.h" #include "widget/timelinewidget/timelinewidget.h" namespace olive @@ -94,10 +93,21 @@ void RazorTool::mouse_release(TimelineViewMouseEvent *event) split_tracks_.clear(); if (!blocks_to_split.isEmpty()) { - Core::instance()->undo_stack()->push( - new BlockSplitPreservingLinksCommand(blocks_to_split, - { split_time }), - qApp->translate("RazorTool", "Split Clips")); + // Split through the liboakengine C ABI facade: one undoable, + // link-preserving command with the same semantics as the old + // app-side BlockSplitPreservingLinksCommand push. + QVector clips; + clips.reserve(blocks_to_split.size()); + foreach (Block *b, blocks_to_split) { + if (ClipBlock *clip = dynamic_cast(b)) { + clips.append(reinterpret_cast(clip)); + } + } + oakengine_sequence_split_clips( + reinterpret_cast(parent()->sequence()), + clips.data(), clips.size(), + Timecode::time_to_timestamp(split_time, parent()->timebase(), + Timecode::k_round)); } dragging_ = false; diff --git a/app/widget/timelinewidget/trackview/trackviewitem.cpp b/app/widget/timelinewidget/trackview/trackviewitem.cpp index d1cf2943a..ccb48490a 100644 --- a/app/widget/timelinewidget/trackview/trackviewitem.cpp +++ b/app/widget/timelinewidget/trackview/trackviewitem.cpp @@ -29,6 +29,7 @@ #include #include "core.h" +#include "oakengine/timeline.h" #include "timeline/timelineundogeneral.h" #include "ui/icons/icons.h" #include "widget/menu/menu.h" @@ -159,9 +160,11 @@ void TrackViewItem::show_context_menu(const QPoint &p) void TrackViewItem::delete_track() { emit about_to_delete_track(track_); - Core::instance()->undo_stack()->push( - new TimelineRemoveTrackCommand(track_), - tr("Deleted Track \"%1\"").arg(track_->get_label_or_name())); + // Through the liboakengine C ABI facade (one undoable command, same as + // the old TimelineRemoveTrackCommand push). + oakengine_sequence_remove_track( + reinterpret_cast(track_->sequence()), + int(track_->type()), track_->index()); } void TrackViewItem::delete_all_empty_tracks() diff --git a/engine/include/oakengine/timeline.h b/engine/include/oakengine/timeline.h index 39ba9e1f8..843b1b3cc 100644 --- a/engine/include/oakengine/timeline.h +++ b/engine/include/oakengine/timeline.h @@ -274,12 +274,14 @@ oakengine_sequence_marker_count(const OakEngineSequence *self); /** * @brief Marker at `index`: `time` receives its in-point as a timestamp in * timebase units (may be NULL), `name` its label using the buf/size - * truncation convention (may be NULL to only fetch the time). Returns + * truncation convention (may be NULL to only fetch the time), `color` its + * color index (may be NULL). Returns * OAKENGINE_OK on success, OAKENGINE_E_NOT_FOUND for an out-of-range index. */ OAKENGINE_API int oakengine_sequence_marker_at(const OakEngineSequence *self, int index, int64_t *time, - char *name, int name_size); + char *name, int name_size, + int *color); /* ---- Timeline editing primitives ---------------------------------------- */ @@ -437,6 +439,61 @@ OAKENGINE_API int oakengine_sequence_move_clip(OakEngineSequence *seq, int clip_index, int64_t new_in); +/* ---- Batch editing (timeline panel) ------------------------------------------ + * + * Higher-level operations mirroring the application's timeline panel + * (app/widget/timelinewidget), each undoable as ONE command like the + * panel's own undo entries. Clip arrays hold borrowed handles + * (oakengine_sequence_clip_at(); the handle is the engine ClipBlock + * pointer in this family, so the application can pass its own clips + * directly). All times are frame timestamps in the sequence's frame-rate + * timebase. + */ + +/** + * @brief Split every given clip at timeline `time_ts`, preserving links + * (undoable; olive::BlockSplitPreservingLinksCommand -- the application's + * razor tool / split-at-playhead command). + * + * Clips not spanning `time_ts` are skipped (same as the engine command); + * when none of the clips spans it, the call fails with + * OAKENGINE_E_NOT_FOUND and nothing is pushed. The halves of linked clips + * come out linked, like the application's split. + */ +OAKENGINE_API int oakengine_sequence_split_clips( + OakEngineSequence *seq, OakEngineClip **clips, int clip_count, + int64_t time_ts); + +/** + * @brief Delete clips leaving gaps, optionally rippling regions closed + * (undoable; the clip-deletion core of the application's + * TimelineWidget::DeleteSelected). + * + * Each clip is replaced with a gap (olive::TrackReplaceBlockWithGapCommand, + * transitions left to the caller like the application) and removed from + * the graph with its exclusive dependencies + * (olive::NodeRemoveWithExclusiveDependenciesAndDisconnect). + * + * When `ripple` != 0, a olive::TimelineRippleDeleteGapsAtRegionsCommand + * follows over `ripple_ranges_ts` -- 4 int64 per range: track_type, + * track_index, in_ts, out_ts (NULL with `ripple_range_count` 0 ripples the + * deleted clips' own ranges instead). `rippled` (may be NULL) receives 1 + * when the ripple actually produced commands. Everything lands as one + * undoable command. + */ +OAKENGINE_API int oakengine_sequence_delete_clips( + OakEngineSequence *seq, OakEngineClip **clips, int clip_count, int ripple, + const int64_t *ripple_ranges_ts, int ripple_range_count, int *rippled); + +/** + * @brief Remove the area [in_ts, out_ts) on every track and shift the + * following content left (undoable; + * olive::TimelineRippleRemoveAreaCommand -- the application's + * "ripple to playhead"). `in_ts` must be >= 0 and `out_ts` > `in_ts`. + */ +OAKENGINE_API int oakengine_sequence_ripple_delete_range( + OakEngineSequence *seq, int64_t in_ts, int64_t out_ts); + /* ---- Track structure and markers ------------------------------------------ * * Track structure edits are undoable like the other editing primitives. @@ -524,6 +581,18 @@ OAKENGINE_API int oakengine_sequence_marker_add(OakEngineSequence *seq, int64_t time_ts, const char *name); +/** + * @brief Add a timeline marker with an explicit color index (undoable). + * + * Same as oakengine_sequence_marker_add() (which passes color 0) but the + * caller picks the marker color, like the application's "set marker" + * action (color of the closest marker, or the configured default). + */ +OAKENGINE_API int oakengine_sequence_marker_add_ex(OakEngineSequence *seq, + int64_t time_ts, + const char *name, + int color); + /** * @brief Remove the (first) marker at `time_ts` (undoable; * olive::MarkerRemoveCommand). OAKENGINE_E_NOT_FOUND when no marker exists diff --git a/engine/src/capi/timeline.cpp b/engine/src/capi/timeline.cpp index caa3abca7..b2e85f525 100644 --- a/engine/src/capi/timeline.cpp +++ b/engine/src/capi/timeline.cpp @@ -714,7 +714,8 @@ int oakengine_sequence_marker_count(const OakEngineSequence *self) } int oakengine_sequence_marker_at(const OakEngineSequence *self, int index, - int64_t *time, char *name, int name_size) + int64_t *time, char *name, int name_size, + int *color) { if (!self || index < 0) { return OAKENGINE_E_INVALID; @@ -734,6 +735,9 @@ int oakengine_sequence_marker_at(const OakEngineSequence *self, int index, if (name && name_size > 0) { copy_to_buf(marker->name(), name, size_t(name_size)); } + if (color) { + *color = marker->color(); + } return OAKENGINE_OK; } @@ -1076,6 +1080,189 @@ int oakengine_sequence_move_clip(OakEngineSequence *seq, int track_type, return OAKENGINE_OK; } +/* ---- Batch editing (timeline panel) ------------------------------------------ */ + +int oakengine_sequence_split_clips(OakEngineSequence *seq, + OakEngineClip **clips, int clip_count, + int64_t time_ts) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || !clips || clip_count <= 0) { + set_seq_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + olive::Rational tb; + if (!time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_STATE; + } + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(time_ts, tb); + + QVector blocks; + blocks.reserve(clip_count); + bool any_spanning = false; + for (int i = 0; i < clip_count; i++) { + olive::ClipBlock *clip = + reinterpret_cast(clips[i]); + if (!clip) { + set_seq_error(QStringLiteral("invalid clip at index %1").arg(i)); + return OAKENGINE_E_INVALID; + } + if (blocks.contains(clip)) { + continue; + } + blocks.append(clip); + if (clip->in() < time && clip->out() > time) { + any_spanning = true; + } + } + if (!any_spanning) { + set_seq_error(QStringLiteral("no clip spans time %1").arg(time_ts)); + return OAKENGINE_E_NOT_FOUND; + } + + // Same as the application's razor tool / split-at-playhead + // (BlockSplitPreservingLinksCommand): split every block spanning the + // time and link the halves of linked blocks, one undoable command. + push_or_run(new olive::BlockSplitPreservingLinksCommand(blocks, { time }), + QStringLiteral("Split Clips")); + return OAKENGINE_OK; +} + +int oakengine_sequence_delete_clips(OakEngineSequence *seq, + OakEngineClip **clips, int clip_count, + int ripple, const int64_t *ripple_ranges_ts, + int ripple_range_count, int *rippled) +{ + set_seq_error(QString()); + if (rippled) { + *rippled = 0; + } + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || clip_count < 0 || (clip_count > 0 && !clips) || + ripple_range_count < 0 || + (ripple_range_count > 0 && !ripple_ranges_ts)) { + set_seq_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + if (clip_count == 0 && (!ripple || ripple_range_count == 0)) { + // Nothing to delete and nothing to ripple. + return OAKENGINE_OK; + } + olive::Rational tb; + if (!time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_STATE; + } + + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + olive::TimelineRippleDeleteGapsAtRegionsCommand::RangeList clip_ranges; + for (int i = 0; i < clip_count; i++) { + olive::ClipBlock *clip = + reinterpret_cast(clips[i]); + if (!clip || !clip->track()) { + set_seq_error(QStringLiteral("invalid clip at index %1").arg(i)); + delete command; + return OAKENGINE_E_INVALID; + } + // Same as the application's delete-selection core + // (TimelineWidget::DeleteSelected): replace the clip with a gap + // (transitions are the caller's job) and remove it from the graph + // with its exclusive dependencies. + command->add_child(new olive::TrackReplaceBlockWithGapCommand( + clip->track(), clip, false)); + command->add_child( + new olive::NodeRemoveWithExclusiveDependenciesAndDisconnect(clip)); + clip_ranges.append({ clip->track(), clip->range() }); + } + + olive::TimelineRippleDeleteGapsAtRegionsCommand *ripple_command = nullptr; + if (ripple) { + olive::TimelineRippleDeleteGapsAtRegionsCommand::RangeList ranges; + if (ripple_ranges_ts && ripple_range_count > 0) { + for (int i = 0; i < ripple_range_count; i++) { + const int64_t *range = ripple_ranges_ts + i * 4; + const int track_type = int(range[0]); + const int track_index = int(range[1]); + if (track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + set_seq_error(QStringLiteral("invalid track type in " + "ripple range %1") + .arg(i)); + delete command; + return OAKENGINE_E_INVALID; + } + olive::TrackList *list = + sequence->track_list(to_track_type(track_type)); + if (track_index < 0 || + track_index >= list->get_track_count()) { + set_seq_error(QStringLiteral("no track at index %1 in " + "ripple range %2") + .arg(track_index) + .arg(i)); + delete command; + return OAKENGINE_E_NOT_FOUND; + } + ranges.append({ list->get_track_at(track_index), + olive::TimeRange( + olive::core::Timecode::timestamp_to_time( + range[2], tb), + olive::core::Timecode::timestamp_to_time( + range[3], tb)) }); + } + } else { + ranges = clip_ranges; + } + if (!ranges.isEmpty()) { + ripple_command = + new olive::TimelineRippleDeleteGapsAtRegionsCommand(sequence, + ranges); + command->add_child(ripple_command); + } + } + + push_or_run(command, QStringLiteral("Delete Clips")); + if (rippled) { + // has_commands() is valid after the stack prepared the command; + // without an engine the command ran directly and a non-null ripple + // command means regions were queued. + *rippled = (ripple_command && (!olive::EngineCore::instance() || + ripple_command->has_commands())) ? + 1 : + 0; + } + return OAKENGINE_OK; +} + +int oakengine_sequence_ripple_delete_range(OakEngineSequence *seq, + int64_t in_ts, int64_t out_ts) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || in_ts < 0 || out_ts <= in_ts) { + set_seq_error(QStringLiteral("invalid range [%1, %2)") + .arg(in_ts) + .arg(out_ts)); + return OAKENGINE_E_INVALID; + } + olive::Rational tb; + if (!time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_STATE; + } + // Same as the application's "ripple to playhead" (TimelineWidget:: + // ripple_to): remove the area on every track and shift the following + // content left, one undoable command. + push_or_run(new olive::TimelineRippleRemoveAreaCommand( + sequence, + olive::core::Timecode::timestamp_to_time(in_ts, tb), + olive::core::Timecode::timestamp_to_time(out_ts, tb)), + QStringLiteral("Ripple Delete Range")); + return OAKENGINE_OK; +} + /* ---- Track structure and markers ------------------------------------------ */ int oakengine_sequence_remove_track(OakEngineSequence *seq, int track_type, @@ -1278,6 +1465,12 @@ int oakengine_track_set_locked(OakEngineSequence *seq, int track_type, int oakengine_sequence_marker_add(OakEngineSequence *seq, int64_t time_ts, const char *name) +{ + return oakengine_sequence_marker_add_ex(seq, time_ts, name, 0); +} + +int oakengine_sequence_marker_add_ex(OakEngineSequence *seq, int64_t time_ts, + const char *name, int color) { set_seq_error(QString()); olive::Sequence *sequence = reinterpret_cast(seq); @@ -1301,7 +1494,7 @@ int oakengine_sequence_marker_add(OakEngineSequence *seq, int64_t time_ts, } push_or_run(new olive::MarkerAddCommand( markers, olive::TimeRange(time, time), - QString::fromUtf8(name ? name : ""), 0), + QString::fromUtf8(name ? name : ""), color), QStringLiteral("Add Marker")); return OAKENGINE_OK; } diff --git a/engine/tests/oakengine_init_test.cpp b/engine/tests/oakengine_init_test.cpp index b35cefae5..bebcc2b55 100644 --- a/engine/tests/oakengine_init_test.cpp +++ b/engine/tests/oakengine_init_test.cpp @@ -220,7 +220,7 @@ static void test_sequence_and_save_load(void) // No markers on a fresh sequence. assert(oakengine_sequence_marker_count(seq) == 0); - assert(oakengine_sequence_marker_at(seq, 0, NULL, NULL, 0) == + assert(oakengine_sequence_marker_at(seq, 0, NULL, NULL, 0, NULL) == OAKENGINE_E_NOT_FOUND); // Save; the modified flag clears and the filename is adopted. @@ -384,7 +384,7 @@ static void test_null_safety(void) assert(oakengine_sequence_set_workarea(NULL, 0, 0, 0) == OAKENGINE_E_INVALID); assert(oakengine_sequence_marker_count(NULL) == 0); - assert(oakengine_sequence_marker_at(NULL, 0, NULL, NULL, 0) == + assert(oakengine_sequence_marker_at(NULL, 0, NULL, NULL, 0, NULL) == OAKENGINE_E_INVALID); } diff --git a/engine/tests/oakengine_timeline_edit_test.cpp b/engine/tests/oakengine_timeline_edit_test.cpp index a4a0f1519..65b4454ba 100644 --- a/engine/tests/oakengine_timeline_edit_test.cpp +++ b/engine/tests/oakengine_timeline_edit_test.cpp @@ -584,7 +584,7 @@ static void test_markers(void) assert(oakengine_sequence_marker_add(seq, 30, "Chapter 1") == OAKENGINE_OK); assert(oakengine_sequence_marker_count(seq) == 1); - assert(oakengine_sequence_marker_at(seq, 0, &ts, name, sizeof(name)) == + assert(oakengine_sequence_marker_at(seq, 0, &ts, name, sizeof(name), NULL) == OAKENGINE_OK); assert(ts == 30 && strcmp(name, "Chapter 1") == 0); @@ -595,29 +595,29 @@ static void test_markers(void) // Earlier marker sorts in front. assert(oakengine_sequence_marker_add(seq, 10, "Intro") == OAKENGINE_OK); assert(oakengine_sequence_marker_count(seq) == 2); - assert(oakengine_sequence_marker_at(seq, 0, &ts, name, sizeof(name)) == + assert(oakengine_sequence_marker_at(seq, 0, &ts, name, sizeof(name), NULL) == OAKENGINE_OK); assert(ts == 10 && strcmp(name, "Intro") == 0); - assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name)) == + assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name), NULL) == OAKENGINE_OK); assert(ts == 30 && strcmp(name, "Chapter 1") == 0); - assert(oakengine_sequence_marker_at(seq, 2, &ts, name, sizeof(name)) == + assert(oakengine_sequence_marker_at(seq, 2, &ts, name, sizeof(name), NULL) == OAKENGINE_E_NOT_FOUND); // Rename with undo/redo. assert(oakengine_sequence_marker_rename(seq, 30, "Chapter 2") == OAKENGINE_OK); - assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name)) == + assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name), NULL) == OAKENGINE_OK); assert(strcmp(name, "Chapter 2") == 0); assert(oakengine_sequence_marker_rename(seq, 77, "nope") == OAKENGINE_E_NOT_FOUND); assert(oakengine_project_undo(project) == OAKENGINE_OK); - assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name)) == + assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name), NULL) == OAKENGINE_OK); assert(strcmp(name, "Chapter 1") == 0); assert(oakengine_project_redo(project) == OAKENGINE_OK); - assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name)) == + assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name), NULL) == OAKENGINE_OK); assert(strcmp(name, "Chapter 2") == 0); @@ -810,6 +810,158 @@ static void test_sequence_params(void) oakengine_project_free(p2); } +static void test_batch_editing(const char *media_path) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "Batch"); + assert(seq != NULL); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == + 0); + OakEngineFootage *footage = + oakengine_project_import_footage(project, media_path); + assert(footage != NULL); + + int64_t in = -1, out = -1; + + // Two clips on the video track: [0, 100) and [100, 160). + OakEngineClip *c0 = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 100, 0); + OakEngineClip *c1 = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 100, 160, 0); + assert(c0 != NULL && c1 != NULL); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + + // split_clips at 50: the first clip splits in two. + { + OakEngineClip *arr[1] = { c0 }; + assert(oakengine_sequence_split_clips(seq, arr, 1, 50) == + OAKENGINE_OK); + } + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 3); + OakEngineClip *left = oakengine_sequence_clip_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + OakEngineClip *right = oakengine_sequence_clip_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); + assert(oakengine_clip_get_range(left, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 0 && out == 50); + assert(oakengine_clip_get_range(right, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 50 && out == 100); + + // Nothing spans 200; invalid arrays are rejected. + { + OakEngineClip *arr[2] = { left, right }; + assert(oakengine_sequence_split_clips(seq, arr, 2, 200) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_sequence_split_clips(seq, NULL, 1, 50) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_split_clips(seq, arr, 0, 50) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_split_clips(NULL, arr, 1, 50) == + OAKENGINE_E_INVALID); + } + + // Undo the split. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + + // delete_clips without ripple: a gap is left, the second clip stays. + int rippled = -1; + { + OakEngineClip *arr[1] = { c0 }; + assert(oakengine_sequence_delete_clips(seq, arr, 1, 0, NULL, 0, + &rippled) == OAKENGINE_OK); + } + assert(rippled == 0); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 1); + assert(oakengine_clip_get_range(c1, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 100 && out == 160); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + + // delete_clips with ripple (auto ranges): the second clip shifts left. + rippled = -1; + { + OakEngineClip *arr[1] = { c0 }; + assert(oakengine_sequence_delete_clips(seq, arr, 1, 1, NULL, 0, + &rippled) == OAKENGINE_OK); + } + assert(rippled == 1); + assert(oakengine_clip_get_range(c1, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 0 && out == 60); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_clip_get_range(c1, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 100 && out == 160); + + // delete_clips with an explicit ripple range: same shift. + rippled = -1; + { + OakEngineClip *arr[1] = { c0 }; + const int64_t ranges[4] = { OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 100 }; + assert(oakengine_sequence_delete_clips(seq, arr, 1, 1, ranges, 1, + &rippled) == OAKENGINE_OK); + } + assert(rippled == 1); + assert(oakengine_clip_get_range(c1, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 0 && out == 60); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + + // A bad explicit range coordinate is rejected without side effects. + { + OakEngineClip *arr[1] = { c0 }; + const int64_t bad[4] = { OAKENGINE_TRACK_TYPE_VIDEO, 9, 0, 100 }; + assert(oakengine_sequence_delete_clips(seq, arr, 1, 1, bad, 1, + NULL) == OAKENGINE_E_NOT_FOUND); + } + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + assert(oakengine_sequence_delete_clips(NULL, NULL, 0, 0, NULL, 0, + NULL) == OAKENGINE_E_INVALID); + + // ripple_delete_range over [0, 100): the area is removed from every + // track and the following content shifts left. + assert(oakengine_sequence_ripple_delete_range(seq, 0, 100) == + OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 1); + OakEngineClip *remaining = oakengine_sequence_clip_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + assert(oakengine_clip_get_range(remaining, &in, &out, NULL) == + OAKENGINE_OK); + assert(in == 0 && out == 60); + assert(oakengine_sequence_ripple_delete_range(seq, 100, 100) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_ripple_delete_range(seq, -1, 5) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_ripple_delete_range(NULL, 0, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + + // Marker with an explicit color. + assert(oakengine_sequence_marker_add_ex(seq, 10, "colored", 5) == + OAKENGINE_OK); + int color = -1; + int64_t ts = -1; + assert(oakengine_sequence_marker_at(seq, 0, &ts, NULL, 0, &color) == + OAKENGINE_OK); + assert(ts == 10 && color == 5); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_marker_count(seq) == 0); + + oakengine_footage_free(footage); + oakengine_project_free(project); +} + int main(void) { make_tmpdir(); @@ -839,6 +991,7 @@ int main(void) test_track_structure(path); test_markers(); test_sequence_params(); + test_batch_editing(path); oakengine_project_free(project); assert(oakengine_shutdown() == OAKENGINE_OK);