diff --git a/app/widget/timelinewidget/timelinewidget.cpp b/app/widget/timelinewidget/timelinewidget.cpp index 8b9efd01a..1cc1a98f5 100644 --- a/app/widget/timelinewidget/timelinewidget.cpp +++ b/app/widget/timelinewidget/timelinewidget.cpp @@ -53,7 +53,6 @@ #include "timeline/timelineundogeneral.h" #include "timeline/timelineundopointer.h" #include "timeline/timelineundoripple.h" -#include "timeline/timelineundoworkarea.h" #include "tool/add.h" #include "tool/beam.h" #include "tool/edit.h" @@ -873,42 +872,20 @@ void TimelineWidget::delete_in_to_out(bool ripple) return; } - MultiUndoCommand *command = new MultiUndoCommand(); + // Compound through the liboakengine C ABI facade (ripple removal or + // per-track gap fill + workarea disable, one undoable command with the + // same semantics as the old app-side assembly). + const Rational wa_in = get_connected_node()->get_work_area()->in(); + const Rational wa_out = get_connected_node()->get_work_area()->out(); + oakengine_sequence_ripple_delete_in_to_out( + reinterpret_cast(sequence()), ripple ? 1 : 0, + Timecode::time_to_timestamp(wa_in, timebase(), Timecode::k_round), + Timecode::time_to_timestamp(wa_out, timebase(), Timecode::k_round)); + // Playhead move is not undoable and stays here (same as before). if (ripple) { - command->add_child(new TimelineRippleRemoveAreaCommand( - sequence(), get_connected_node()->get_work_area()->in(), - get_connected_node()->get_work_area()->out())); - - } else { - QVector unlocked_tracks = sequence()->get_unlocked_tracks(); - - foreach (Track *track, unlocked_tracks) { - GapBlock *gap = new GapBlock(); - - gap->set_length_and_media_out( - get_connected_node()->get_work_area()->length()); - - command->add_child(new NodeAddCommand( - static_cast(track->parent()), gap)); - - command->add_child(new TrackPlaceBlockCommand( - sequence()->track_list(track->type()), track->index(), gap, - get_connected_node()->get_work_area()->in())); - } + get_connected_node()->set_playhead(wa_in); } - - // Clear workarea after this - command->add_child(new WorkareaSetEnabledCommand( - get_connected_node()->project(), get_connected_node()->get_work_area(), - false)); - - if (ripple) { - get_connected_node()->set_playhead( - get_connected_node()->get_work_area()->in()); - } - - Core::instance()->undo_stack()->push(command, tr("Deleted In To Out")); } void TimelineWidget::toggle_selected_enabled() @@ -2583,34 +2560,14 @@ void TimelineWidget::edit_to(Timeline::MovementMode mode) { const Rational playhead_time = get_connected_node()->get_playhead(); - // Get list of unlocked tracks - QVector tracks = get_edit_to_info(playhead_time, mode); - - if (tracks.isEmpty()) { - return; - } - - MultiUndoCommand *command = new MultiUndoCommand(); - - foreach (const Timeline::EditToInfo &info, tracks) { - if (info.nearest_block && - !dynamic_cast(info.nearest_block) && - info.nearest_time != playhead_time) { - Rational new_len; - - if (mode == Timeline::k_trim_in) { - new_len = playhead_time - info.nearest_time; - } else { - new_len = info.nearest_time - playhead_time; - } - new_len = info.nearest_block->length() - new_len; - - command->add_child(new BlockTrimCommand( - info.track, info.nearest_block, new_len, mode)); - } - } - - Core::instance()->undo_stack()->push(command, tr("Cut Clip(s) To Point")); + // Batch trim through the liboakengine C ABI facade (one undoable + // command; the per-track nearest-block semantics of the old app-side + // assembly live behind the facade now). + oakengine_sequence_trim_clips_to( + reinterpret_cast(sequence()), + (mode == Timeline::k_trim_in) ? 0 : 1, + Timecode::time_to_timestamp(playhead_time, timebase(), + Timecode::k_round)); } void TimelineWidget::update_viewports(const Track::Type &type) diff --git a/app/widget/timelinewidget/trackview/trackviewitem.cpp b/app/widget/timelinewidget/trackview/trackviewitem.cpp index ccb48490a..be2151903 100644 --- a/app/widget/timelinewidget/trackview/trackviewitem.cpp +++ b/app/widget/timelinewidget/trackview/trackviewitem.cpp @@ -28,9 +28,8 @@ #include #include -#include "core.h" +#include "node/project/sequence/sequence.h" #include "oakengine/timeline.h" -#include "timeline/timelineundogeneral.h" #include "ui/icons/icons.h" #include "widget/menu/menu.h" @@ -189,12 +188,10 @@ void TrackViewItem::delete_all_empty_tracks() tr("This will delete the following tracks:\n\n%1\n\nDo you wish to continue?") .arg(track_names_to_remove.join('\n')), QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Ok) { - MultiUndoCommand *command = new MultiUndoCommand(); - foreach (Track *track, tracks_to_remove) { - command->add_child(new TimelineRemoveTrackCommand(track)); - } - Core::instance()->undo_stack()->push( - command, tr("Deleted All Empty Tracks")); + // Batch removal through the liboakengine C ABI facade (one + // undoable command, same as the old per-track children). + oakengine_sequence_delete_empty_tracks( + reinterpret_cast(sequence), -1); } } } diff --git a/app/widget/timeruler/seekablewidget.cpp b/app/widget/timeruler/seekablewidget.cpp index 631a4d8d2..770542c8c 100644 --- a/app/widget/timeruler/seekablewidget.cpp +++ b/app/widget/timeruler/seekablewidget.cpp @@ -30,7 +30,9 @@ #include "common/range.h" #include "core.h" #include "dialog/markerproperties/markerpropertiesdialog.h" +#include "node/project/sequence/sequence.h" #include "node/project/serializer/serializer.h" +#include "oakengine/timeline.h" #include "timeline/timelineundoworkarea.h" #include "widget/colorlabelmenu/colorlabelmenu.h" #include "widget/menu/menushared.h" @@ -122,16 +124,38 @@ void SeekableWidget::set_work_area(TimelineWorkArea *workarea) void SeekableWidget::delete_selected() { if (!selection_manager_.is_dragging()) { + const auto &selected = selection_manager_.get_selected_objects(); + if (selected.empty()) { + return; + } + + if (Sequence *sequence = + dynamic_cast(get_viewer_node())) { + // Batch removal through the liboakengine C ABI facade (one + // undoable command). The facade family only wraps sequences; + // markers of other viewer nodes (e.g. footage viewers) keep + // the old per-marker command path below. + QVector times; + times.reserve(int(selected.size())); + for (TimelineMarker *marker : selected) { + times.append(Timecode::time_to_timestamp( + marker->time().in(), timebase(), Timecode::k_round)); + } + oakengine_sequence_marker_remove_many( + reinterpret_cast(sequence), + times.constData(), times.size()); + return; + } + MultiUndoCommand *command = new MultiUndoCommand(); - foreach (TimelineMarker *marker, - selection_manager_.get_selected_objects()) { + foreach (TimelineMarker *marker, selected) { command->add_child(new MarkerRemoveCommand(marker)); } Core::instance()->undo_stack()->push( - command, tr("Deleted %1 Marker(s)") - .arg(selection_manager_.get_selected_objects().size())); + command, + tr("Deleted %1 Marker(s)").arg(selected.size())); } } diff --git a/engine/include/oakengine/timeline.h b/engine/include/oakengine/timeline.h index 843b1b3cc..79967cd42 100644 --- a/engine/include/oakengine/timeline.h +++ b/engine/include/oakengine/timeline.h @@ -494,6 +494,62 @@ OAKENGINE_API int oakengine_sequence_delete_clips( OAKENGINE_API int oakengine_sequence_ripple_delete_range( OakEngineSequence *seq, int64_t in_ts, int64_t out_ts); +/** + * @brief Delete the workarea range on every track (undoable, ONE command; + * the application's TimelineWidget::delete_in_to_out). + * + * `ripple` != 0: the area is removed and following content shifts left + * (olive::TimelineRippleRemoveAreaCommand). `ripple` == 0: a gap of the + * range's length is placed at `in_ts` on every unlocked track (the + * application's gap-insert path). Either way the workarea is disabled + * afterwards (olive::WorkareaSetEnabledCommand), all as one undoable + * command. Requires an enabled workarea on the sequence + * (OAKENGINE_E_STATE otherwise) and 0 <= in_ts < out_ts. The application's + * playhead move after a ripple stays with the caller (not undoable). + */ +OAKENGINE_API int oakengine_sequence_ripple_delete_in_to_out( + OakEngineSequence *seq, int ripple, int64_t in_ts, int64_t out_ts); + +/** + * @brief Trim the nearest clip of every unlocked track to `point_ts` + * (undoable, ONE command; the application's TimelineWidget::edit_to). + * + * `edge` 0 (in): per track the nearest block starting before or at the + * point gets its in-point trimmed to the point; `edge` 1 (out): the + * nearest block before the point gets its out-point trimmed to it + * (olive::BlockTrimCommand per affected track). Gap blocks and blocks + * already at the point are skipped, like the application. Returns the + * number of trimmed clips (>= 0; 0 when nothing qualified and no command + * is pushed) or a negative code. + */ +OAKENGINE_API int oakengine_sequence_trim_clips_to(OakEngineSequence *seq, + int edge, int64_t point_ts); + +/** + * @brief Remove every empty track (undoable, ONE command; the + * application's "delete all empty tracks"). + * + * `track_type` is an OAKENGINE_TRACK_TYPE_* value to only purge that + * type, or -1 for all types (the application's behavior). Returns the + * number of removed tracks (>= 0; 0 when no track was empty and no + * command is pushed) or a negative code. + */ +OAKENGINE_API int oakengine_sequence_delete_empty_tracks(OakEngineSequence *seq, + int track_type); + +/** + * @brief Remove the markers at the given times (undoable, ONE command; + * the marker-list equivalent of the application's marker + * SeekableWidget::delete_selected). + * + * `times_ts` holds `count` marker in-point timestamps; each must name an + * existing marker (markers are unique per time in the engine) or the + * whole call fails with OAKENGINE_E_NOT_FOUND and nothing is pushed. + * Returns the number of removed markers (>= 0) or a negative code. + */ +OAKENGINE_API int oakengine_sequence_marker_remove_many( + OakEngineSequence *seq, const int64_t *times_ts, int count); + /* ---- Track structure and markers ------------------------------------------ * * Track structure edits are undoable like the other editing primitives. diff --git a/engine/src/capi/timeline.cpp b/engine/src/capi/timeline.cpp index b2e85f525..245ac719c 100644 --- a/engine/src/capi/timeline.cpp +++ b/engine/src/capi/timeline.cpp @@ -37,6 +37,7 @@ #include "timeline/timelineundopointer.h" #include "timeline/timelineundoripple.h" #include "timeline/timelineundosplit.h" +#include "timeline/timelineundoworkarea.h" #include "timeline/timelineworkarea.h" #include "undo/undocommand.h" #include "undo/undostack.h" @@ -1263,6 +1264,182 @@ int oakengine_sequence_ripple_delete_range(OakEngineSequence *seq, return OAKENGINE_OK; } +int oakengine_sequence_ripple_delete_in_to_out(OakEngineSequence *seq, + int ripple, 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::TimelineWorkArea *workarea = sequence->get_work_area(); + if (!workarea || !workarea->enabled()) { + set_seq_error(QStringLiteral("sequence workarea is not enabled")); + return OAKENGINE_E_STATE; + } + 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 in_time = + olive::core::Timecode::timestamp_to_time(in_ts, tb); + const olive::Rational out_time = + olive::core::Timecode::timestamp_to_time(out_ts, tb); + + // The application's delete_in_to_out, one undoable command: ripple the + // area out (or fill it with a fresh gap per unlocked track), then + // disable the workarea. + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + if (ripple) { + command->add_child(new olive::TimelineRippleRemoveAreaCommand( + sequence, in_time, out_time)); + } else { + const QVector unlocked_tracks = + sequence->get_unlocked_tracks(); + for (olive::Track *track : unlocked_tracks) { + auto *gap = new olive::GapBlock(); + gap->set_length_and_media_out(out_time - in_time); + command->add_child(new olive::NodeAddCommand( + static_cast(track->parent()), gap)); + command->add_child(new olive::TrackPlaceBlockCommand( + sequence->track_list(track->type()), track->index(), gap, + in_time)); + } + } + command->add_child(new olive::WorkareaSetEnabledCommand( + sequence->project(), workarea, false)); + push_or_run(command, QStringLiteral("Delete In To Out")); + return OAKENGINE_OK; +} + +int oakengine_sequence_trim_clips_to(OakEngineSequence *seq, int edge, + int64_t point_ts) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || point_ts < 0 || edge < 0 || edge > 1) { + 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 point = + olive::core::Timecode::timestamp_to_time(point_ts, tb); + const olive::Timeline::MovementMode mode = + (edge == 0) ? olive::Timeline::k_trim_in : olive::Timeline::k_trim_out; + + // The application's edit_to: per unlocked track, trim the nearest + // block's edge to the point (gaps and blocks already at the point are + // skipped), all as one undoable command. + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + int trimmed = 0; + const QVector tracks = sequence->get_unlocked_tracks(); + for (olive::Track *track : tracks) { + olive::Block *block = + (mode == olive::Timeline::k_trim_in) ? + track->nearest_block_before_or_at(point) : + track->nearest_block_before(point); + if (!block || dynamic_cast(block)) { + continue; + } + const olive::Rational nearest_time = + (mode == olive::Timeline::k_trim_in) ? block->in() : block->out(); + if (nearest_time == point) { + continue; + } + olive::Rational new_length = + (mode == olive::Timeline::k_trim_in) ? point - nearest_time : + nearest_time - point; + new_length = block->length() - new_length; + command->add_child( + new olive::BlockTrimCommand(track, block, new_length, mode)); + trimmed++; + } + if (trimmed == 0) { + delete command; + return 0; + } + push_or_run(command, QStringLiteral("Trim Clips To Point")); + return trimmed; +} + +int oakengine_sequence_delete_empty_tracks(OakEngineSequence *seq, + int track_type) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || track_type < -1 || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + set_seq_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + + // The application's "delete all empty tracks", one undoable command. + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + int removed = 0; + for (olive::Track *track : sequence->get_tracks()) { + if (track_type >= 0 && track->type() != to_track_type(track_type)) { + continue; + } + if (track->blocks().isEmpty()) { + command->add_child(new olive::TimelineRemoveTrackCommand(track)); + removed++; + } + } + if (removed == 0) { + delete command; + return 0; + } + push_or_run(command, QStringLiteral("Delete Empty Tracks")); + return removed; +} + +int oakengine_sequence_marker_remove_many(OakEngineSequence *seq, + const int64_t *times_ts, int count) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || count < 0 || (count > 0 && !times_ts)) { + set_seq_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + if (count == 0) { + return 0; + } + olive::Rational tb; + if (!time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_STATE; + } + olive::TimelineMarkerList *markers = sequence->get_markers(); + + // Resolve all markers first so a bad time fails without side effects + // (markers are unique per time in the engine). + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + for (int i = 0; i < count; i++) { + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(times_ts[i], tb); + olive::TimelineMarker *marker = markers->get_marker_at_time(time); + if (!marker) { + set_seq_error(QStringLiteral("no marker at time %1") + .arg(times_ts[i])); + delete command; + return OAKENGINE_E_NOT_FOUND; + } + command->add_child(new olive::MarkerRemoveCommand(marker)); + } + push_or_run(command, QStringLiteral("Remove Markers")); + return count; +} + /* ---- Track structure and markers ------------------------------------------ */ int oakengine_sequence_remove_track(OakEngineSequence *seq, int track_type, diff --git a/engine/tests/oakengine_timeline_edit_test.cpp b/engine/tests/oakengine_timeline_edit_test.cpp index 65b4454ba..6277a1d9e 100644 --- a/engine/tests/oakengine_timeline_edit_test.cpp +++ b/engine/tests/oakengine_timeline_edit_test.cpp @@ -962,6 +962,172 @@ static void test_batch_editing(const char *media_path) oakengine_project_free(project); } +static void test_batch_editing_round2(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, "Batch2"); + 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; + + OakEngineClip *c0 = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 100, 0); + assert(c0 != NULL); + + // trim_clips_to: out-edge to 60, then in-edge to 40 (one clip each). + assert(oakengine_sequence_trim_clips_to(seq, 1, 60) == 1); + assert(oakengine_clip_get_range(c0, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 0 && out == 60); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_clip_get_range(c0, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 0 && out == 100); + assert(oakengine_sequence_trim_clips_to(seq, 0, 40) == 1); + assert(oakengine_clip_get_range(c0, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 40 && out == 100); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + + // Already at the point: nothing qualifies, 0 and no command. + assert(oakengine_sequence_trim_clips_to(seq, 1, 100) == 0); + // Past the content there is no block under the point either (the + // application's edit_to only shortens blocks containing the point). + assert(oakengine_sequence_trim_clips_to(seq, 1, 200) == 0); + assert(oakengine_sequence_trim_clips_to(seq, 0, 200) == 0); + // Invalid arguments. + assert(oakengine_sequence_trim_clips_to(seq, 2, 60) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_trim_clips_to(seq, 1, -1) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_trim_clips_to(NULL, 1, 60) == + OAKENGINE_E_INVALID); + assert(oakengine_clip_get_range(c0, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 0 && out == 100); + + // ripple_delete_in_to_out (ripple): area removed, workarea disabled, + // one undo entry restores everything including the workarea. + assert(oakengine_sequence_set_workarea(seq, 1, 20, 80) == OAKENGINE_OK); + assert(oakengine_sequence_workarea_is_enabled(seq) == 1); + assert(oakengine_sequence_ripple_delete_in_to_out(seq, 1, 20, 80) == + OAKENGINE_OK); + assert(oakengine_sequence_workarea_is_enabled(seq) == 0); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + OakEngineClip *piece0 = oakengine_sequence_clip_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + OakEngineClip *piece1 = oakengine_sequence_clip_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); + assert(oakengine_clip_get_range(piece0, &in, &out, NULL) == + OAKENGINE_OK); + assert(in == 0 && out == 20); + assert(oakengine_clip_get_range(piece1, &in, &out, NULL) == + OAKENGINE_OK); + assert(in == 20 && out == 40); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_workarea_is_enabled(seq) == 1); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 1); + assert(oakengine_clip_get_range(c0, &in, &out, NULL) == OAKENGINE_OK); + assert(in == 0 && out == 100); + + // ripple_delete_in_to_out (no ripple): a gap fills the area, later + // content shifts right by the range length. + assert(oakengine_sequence_ripple_delete_in_to_out(seq, 0, 20, 80) == + OAKENGINE_OK); + assert(oakengine_sequence_workarea_is_enabled(seq) == 0); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + piece0 = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0); + piece1 = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 1); + assert(oakengine_clip_get_range(piece0, &in, &out, NULL) == + OAKENGINE_OK); + assert(in == 0 && out == 20); + assert(oakengine_clip_get_range(piece1, &in, &out, NULL) == + OAKENGINE_OK); + assert(in == 80 && out == 100); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 1); + + // Workarea disabled: rejected; bad range: invalid (range is validated + // before the workarea state). + assert(oakengine_sequence_set_workarea(seq, 0, 20, 80) == OAKENGINE_OK); + assert(oakengine_sequence_ripple_delete_in_to_out(seq, 1, 20, 80) == + OAKENGINE_E_STATE); + assert(oakengine_sequence_ripple_delete_in_to_out(seq, 1, 80, 20) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_ripple_delete_in_to_out(NULL, 1, 20, 80) == + OAKENGINE_E_INVALID); + + // delete_empty_tracks: add an empty audio and an empty subtitle track. + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == + 0); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_SUBTITLE) == + 0); + int v = 0, a = 0, s = 0; + assert(oakengine_sequence_track_count(seq, &v, &a, &s) == OAKENGINE_OK); + assert(v == 1 && a == 1 && s == 1); + // Type-filtered purge first: only the audio track goes. + assert(oakengine_sequence_delete_empty_tracks( + seq, OAKENGINE_TRACK_TYPE_AUDIO) == 1); + assert(oakengine_sequence_track_count(seq, &v, &a, &s) == OAKENGINE_OK); + assert(v == 1 && a == 0 && s == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_track_count(seq, &v, &a, &s) == OAKENGINE_OK); + assert(v == 1 && a == 1 && s == 1); + // All types at once (the application's behavior). + assert(oakengine_sequence_delete_empty_tracks(seq, -1) == 2); + assert(oakengine_sequence_track_count(seq, &v, &a, &s) == OAKENGINE_OK); + assert(v == 1 && a == 0 && s == 0); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_track_count(seq, &v, &a, &s) == OAKENGINE_OK); + assert(v == 1 && a == 1 && s == 1); + // Nothing empty: 0, no command; invalid type rejected. + assert(oakengine_sequence_delete_empty_tracks( + seq, OAKENGINE_TRACK_TYPE_VIDEO) == 0); + assert(oakengine_sequence_delete_empty_tracks(seq, 3) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_delete_empty_tracks(NULL, -1) == + OAKENGINE_E_INVALID); + + // marker_remove_many: batch delete by time, one undo entry. + assert(oakengine_sequence_marker_add_ex(seq, 10, "m1", 0) == + OAKENGINE_OK); + assert(oakengine_sequence_marker_add_ex(seq, 20, "m2", 0) == + OAKENGINE_OK); + assert(oakengine_sequence_marker_add_ex(seq, 30, "m3", 0) == + OAKENGINE_OK); + assert(oakengine_sequence_marker_count(seq) == 3); + { + const int64_t times[2] = { 10, 30 }; + assert(oakengine_sequence_marker_remove_many(seq, times, 2) == 2); + } + assert(oakengine_sequence_marker_count(seq) == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_marker_count(seq) == 3); + { + const int64_t bad[1] = { 99 }; + assert(oakengine_sequence_marker_remove_many(seq, bad, 1) == + OAKENGINE_E_NOT_FOUND); + } + assert(oakengine_sequence_marker_count(seq) == 3); + assert(oakengine_sequence_marker_remove_many(seq, NULL, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_marker_remove_many(seq, NULL, 0) == 0); + assert(oakengine_sequence_marker_remove_many(NULL, NULL, 0) == + OAKENGINE_E_INVALID); + + oakengine_footage_free(footage); + oakengine_project_free(project); +} + int main(void) { make_tmpdir(); @@ -992,6 +1158,7 @@ int main(void) test_markers(); test_sequence_params(); test_batch_editing(path); + test_batch_editing_round2(path); oakengine_project_free(project); assert(oakengine_shutdown() == OAKENGINE_OK);