diff --git a/engine/include/oakengine/timeline.h b/engine/include/oakengine/timeline.h index e7948c1da..563f78b45 100644 --- a/engine/include/oakengine/timeline.h +++ b/engine/include/oakengine/timeline.h @@ -278,6 +278,71 @@ OAKENGINE_API int oakengine_clip_get_range(const OakEngineClip *self, int64_t *in, int64_t *out, int64_t *media_in); +/* ---- Editing primitives, round 2: split / ripple delete / trim / move ---- + * + * All four are undoable like the other editing primitives and report + * failures through oakengine_sequence_last_error(). Clips are addressed by + * (track_type, track_index, clip_index) exactly like + * oakengine_sequence_clip_at() (gap blocks are skipped). All times are + * frame timestamps in the sequence's frame-rate timebase. + */ + +/** + * @brief Split the addressed clip in two at timeline `time` (undoable; + * olive::BlockSplitCommand). + * + * `time` must lie strictly inside the clip's range. The left part keeps the + * clip's in-point, the right part starts at `time` with its media in-point + * advanced accordingly (the engine's split semantics). Returns OAKENGINE_OK + * or a negative code (OAKENGINE_E_NOT_FOUND for a missing clip, + * OAKENGINE_E_INVALID for a time outside the clip). + */ +OAKENGINE_API int oakengine_sequence_split_clip(OakEngineSequence *seq, + int track_type, + int track_index, + int clip_index, int64_t time); + +/** + * @brief Delete the addressed clip and shift all following clips on the + * track left by its length (undoable; + * olive::TrackRippleRemoveAreaCommand). + */ +OAKENGINE_API int oakengine_sequence_ripple_delete_clip(OakEngineSequence *seq, + int track_type, + int track_index, + int clip_index); + +/** + * @brief Change the clip's timeline range (undoable; olive::BlockTrimCommand, + * the application's trim command). + * + * Pass the current value for the end that should stay unchanged; changing + * both ends is applied as an in-trim followed by an out-trim in one + * undoable command. Requires new_out > new_in and new_in >= 0. When the + * in-point moves, the clip's media in-point moves with it (the engine's + * set_length_and_media_in() alignment); adjacent gaps absorb the difference + * (the engine's trim semantics, adjacent clips are not rolled). The clip + * handle must still be on a track. + */ +OAKENGINE_API int oakengine_clip_trim(OakEngineClip *clip, int64_t new_in, + int64_t new_out); + +/** + * @brief Move the addressed clip to start at `new_in` on the same track + * (undoable). + * + * Length and media in-point are preserved; the old spot is filled with a + * gap (olive::TrackReplaceBlockWithGapCommand) and the clip is placed at + * the destination (olive::TrackPlaceBlockCommand, which ripples whatever + * was there). Moving across tracks is a later milestone. `new_in` must be + * >= 0. + */ +OAKENGINE_API int oakengine_sequence_move_clip(OakEngineSequence *seq, + int track_type, + int track_index, + int clip_index, + int64_t new_in); + #ifdef __cplusplus } #endif diff --git a/engine/src/capi/timeline.cpp b/engine/src/capi/timeline.cpp index 8e9b87bef..1f6a34e4c 100644 --- a/engine/src/capi/timeline.cpp +++ b/engine/src/capi/timeline.cpp @@ -35,6 +35,8 @@ #include "timeline/timelinemarker.h" #include "timeline/timelineundogeneral.h" #include "timeline/timelineundopointer.h" +#include "timeline/timelineundoripple.h" +#include "timeline/timelineundosplit.h" #include "timeline/timelineworkarea.h" #include "undo/undocommand.h" #include "undo/undostack.h" @@ -137,6 +139,39 @@ OakEngineClip *wrap_clip(olive::ClipBlock *c) return reinterpret_cast(c); } +// Push an undoable command onto the global undo stack when the engine is +// initialized, otherwise execute it directly (same degradation as the +// round-1 primitives). +void push_or_run(olive::UndoCommand *command, const QString &name) +{ + if (olive::EngineCore::instance()) { + olive::EngineCore::instance()->undo_stack()->push(command, name); + } else { + command->redo_now(); + delete command; + } +} + +// The clip at (track_index, clip_index) within the given track list, +// skipping gap blocks; nullptr when out of range. +olive::ClipBlock *clip_at_index(olive::TrackList *list, int track_index, + int clip_index) +{ + if (track_index < 0 || track_index >= list->get_track_count()) { + return nullptr; + } + int seen = 0; + for (olive::Block *b : list->get_track_at(track_index)->blocks()) { + if (olive::ClipBlock *clip = dynamic_cast(b)) { + if (seen == clip_index) { + return clip; + } + seen++; + } + } + return nullptr; +} + } // namespace extern "C" @@ -552,20 +587,7 @@ OakEngineClip *oakengine_sequence_clip_at(OakEngineSequence *self, } olive::TrackList *list = impl(self)->track_list(to_track_type(track_type)); - if (track_index < 0 || track_index >= list->get_track_count()) { - return nullptr; - } - // Skip gap blocks: indexes address clips only. - int seen = 0; - for (olive::Block *b : list->get_track_at(track_index)->blocks()) { - if (olive::ClipBlock *clip = dynamic_cast(b)) { - if (seen == clip_index) { - return wrap_clip(clip); - } - seen++; - } - } - return nullptr; + return wrap_clip(clip_at_index(list, track_index, clip_index)); } int oakengine_clip_get_range(const OakEngineClip *self, int64_t *in, @@ -599,4 +621,163 @@ int oakengine_clip_get_range(const OakEngineClip *self, int64_t *in, return OAKENGINE_OK; } +/* ---- Editing primitives, round 2 ----------------------------------------- */ + +int oakengine_sequence_split_clip(OakEngineSequence *seq, int track_type, + int track_index, int clip_index, + int64_t time) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + set_seq_error(QStringLiteral("invalid sequence or track type")); + return OAKENGINE_E_INVALID; + } + olive::TrackList *list = + sequence->track_list(to_track_type(track_type)); + olive::ClipBlock *clip = clip_at_index(list, track_index, clip_index); + if (!clip) { + set_seq_error(QStringLiteral("no clip at track %1 index %2") + .arg(track_index) + .arg(clip_index)); + return OAKENGINE_E_NOT_FOUND; + } + 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(time, tb); + if (point <= clip->in() || point >= clip->out()) { + set_seq_error(QStringLiteral("split time %1 is not strictly inside " + "the clip [%2, %3]") + .arg(time) + .arg(time_to_ts(clip->in(), tb)) + .arg(time_to_ts(clip->out(), tb))); + return OAKENGINE_E_INVALID; + } + + push_or_run(new olive::BlockSplitCommand(clip, point), + QStringLiteral("Split Clip")); + return OAKENGINE_OK; +} + +int oakengine_sequence_ripple_delete_clip(OakEngineSequence *seq, + int track_type, int track_index, + int clip_index) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + set_seq_error(QStringLiteral("invalid sequence or track type")); + return OAKENGINE_E_INVALID; + } + olive::TrackList *list = + sequence->track_list(to_track_type(track_type)); + olive::ClipBlock *clip = clip_at_index(list, track_index, clip_index); + if (!clip || !clip->track()) { + set_seq_error(QStringLiteral("no clip at track %1 index %2") + .arg(track_index) + .arg(clip_index)); + return OAKENGINE_E_NOT_FOUND; + } + + push_or_run(new olive::TrackRippleRemoveAreaCommand( + clip->track(), olive::TimeRange(clip->in(), clip->out())), + QStringLiteral("Ripple Delete Clip")); + return OAKENGINE_OK; +} + +int oakengine_clip_trim(OakEngineClip *clip, int64_t new_in, int64_t new_out) +{ + set_seq_error(QString()); + olive::ClipBlock *block = reinterpret_cast(clip); + if (!block) { + set_seq_error(QStringLiteral("invalid clip handle")); + return OAKENGINE_E_INVALID; + } + olive::Track *track = block->track(); + if (!track) { + set_seq_error(QStringLiteral("clip is not on a track")); + return OAKENGINE_E_STATE; + } + const olive::Sequence *sequence = track->sequence(); + olive::Rational tb; + if (!sequence || !time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_STATE; + } + if (new_in < 0 || new_out <= new_in) { + set_seq_error(QStringLiteral("invalid trim range (need 0 <= new_in " + "< new_out)")); + return OAKENGINE_E_INVALID; + } + + const int64_t old_in = time_to_ts(block->in(), tb); + const int64_t old_out = time_to_ts(block->out(), tb); + if (new_in == old_in && new_out == old_out) { + return OAKENGINE_OK; + } + + // The application's trim command (BlockTrimCommand): one end at a time, + // adjacent gaps absorb the difference; both ends are applied as an + // in-trim followed by an out-trim within one undoable command. + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + if (new_in != old_in) { + command->add_child(new olive::BlockTrimCommand( + track, block, block->out() - + olive::core::Timecode::timestamp_to_time(new_in, tb), + olive::Timeline::k_trim_in)); + } + if (new_out != old_out) { + command->add_child(new olive::BlockTrimCommand( + track, block, + olive::core::Timecode::timestamp_to_time(new_out - new_in, tb), + olive::Timeline::k_trim_out)); + } + push_or_run(command, QStringLiteral("Trim Clip")); + return OAKENGINE_OK; +} + +int oakengine_sequence_move_clip(OakEngineSequence *seq, int track_type, + int track_index, int clip_index, + int64_t new_in) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE || new_in < 0) { + set_seq_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + olive::TrackList *list = + sequence->track_list(to_track_type(track_type)); + olive::ClipBlock *clip = clip_at_index(list, track_index, clip_index); + if (!clip || !clip->track()) { + set_seq_error(QStringLiteral("no clip at track %1 index %2") + .arg(track_index) + .arg(clip_index)); + return OAKENGINE_E_NOT_FOUND; + } + olive::Rational tb; + if (!time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_STATE; + } + + // Same assembly as the application's drag-move: gap the old spot, then + // place the clip at the destination (length and media in-point stay). + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + command->add_child( + new olive::TrackReplaceBlockWithGapCommand(clip->track(), clip)); + command->add_child(new olive::TrackPlaceBlockCommand( + list, track_index, clip, + olive::core::Timecode::timestamp_to_time(new_in, tb))); + push_or_run(command, QStringLiteral("Move Clip")); + return OAKENGINE_OK; +} + } // extern "C" diff --git a/engine/tests/oakengine_timeline_edit_test.cpp b/engine/tests/oakengine_timeline_edit_test.cpp index 18a4b6e92..1148f2e27 100644 --- a/engine/tests/oakengine_timeline_edit_test.cpp +++ b/engine/tests/oakengine_timeline_edit_test.cpp @@ -243,6 +243,207 @@ static void test_cross_project_rejected(const char *media_path) oakengine_project_free(b); } +// Round-2 primitives: split, trim, move, ripple delete (plus undo/redo), +// over a fresh project with two clips on one video track. +static void test_edit_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, "Round2"); + 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); + + // Two clips back to back: A [0,30] and B [30,60], both from media 0. + OakEngineClip *clip_a = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0); + assert(clip_a != NULL); + OakEngineClip *clip_b = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 30, 60, 0); + assert(clip_b != NULL); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + + int64_t in = -1, out = -1, media_in = -1; + char err[512]; + + // ---- split ------------------------------------------------------------- + // Bad split points: on the edges, outside, missing clip. + assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0, 0) == OAKENGINE_E_INVALID); + assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0, 30) == OAKENGINE_E_INVALID); + assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0, -5) == OAKENGINE_E_INVALID); + assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0, 99) == OAKENGINE_E_INVALID); + assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 99, 15) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_sequence_split_clip(NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0, 15) == OAKENGINE_E_INVALID); + + // Split A [0,30] at 15 -> [0,15] + [15,30] with aligned media in-points. + assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0, 15) == 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(left == clip_a); + assert(oakengine_clip_get_range(left, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 0 && out == 15 && media_in == 0); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 15 && out == 30 && media_in == 15); + + // Undo merges them back, redo splits again. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + assert(oakengine_clip_get_range(clip_a, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 0 && out == 30 && media_in == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 3); + right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 15 && out == 30 && media_in == 15); + + // ---- trim -------------------------------------------------------------- + // Bad trim ranges. + assert(oakengine_clip_trim(NULL, 0, 10) == OAKENGINE_E_INVALID); + assert(oakengine_clip_trim(right, 30, 20) == OAKENGINE_E_INVALID); + assert(oakengine_clip_trim(right, -1, 20) == OAKENGINE_E_INVALID); + + // Trim the right part's in-point 15 -> 20: media_in follows 15 -> 20. + assert(oakengine_clip_trim(right, 20, 30) == OAKENGINE_OK); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 20 && out == 30 && media_in == 20); + // The leading clip is untouched (a gap absorbs the difference). + assert(oakengine_clip_get_range(clip_a, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 0 && out == 15 && media_in == 0); + + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 15 && out == 30 && media_in == 15); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 20 && out == 30 && media_in == 20); + + // Trim the out-point 30 -> 25 (media in-point stays). + assert(oakengine_clip_trim(right, 20, 25) == OAKENGINE_OK); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 20 && out == 25 && media_in == 20); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 20 && out == 30 && media_in == 20); + + // No-op trim is accepted and does nothing. + assert(oakengine_clip_trim(right, 20, 30) == OAKENGINE_OK); + + // Both ends in one command: [20,30] -> [22,28], media_in follows. + assert(oakengine_clip_trim(right, 22, 28) == OAKENGINE_OK); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 22 && out == 28 && media_in == 22); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 20 && out == 30 && media_in == 20); + + // ---- move --------------------------------------------------------------- + assert(oakengine_sequence_move_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1, + -1) == OAKENGINE_E_INVALID); + assert(oakengine_sequence_move_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 99, 50) == OAKENGINE_E_NOT_FOUND); + + // Move the second clip [20,30] to 70 (past everything): position shifts, + // length and media in-point are preserved, the other clips stay put. + assert(oakengine_sequence_move_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1, + 70) == OAKENGINE_OK); + // After the move the moved clip is the LAST one on the track. + right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 2); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 70 && out == 80 && media_in == 20); + assert(oakengine_clip_get_range(clip_a, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 0 && out == 15 && media_in == 0); + assert(oakengine_clip_get_range(clip_b, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 30 && out == 60 && media_in == 0); + + assert(oakengine_project_undo(project) == OAKENGINE_OK); + right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 20 && out == 30 && media_in == 20); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 2); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 70 && out == 80 && media_in == 20); + + // ---- ripple delete ------------------------------------------------------- + assert(oakengine_sequence_ripple_delete_clip( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 99) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_sequence_ripple_delete_clip(NULL, + OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0) == OAKENGINE_E_INVALID); + + // Delete the first clip [0,15]: both following clips shift left by 15. + assert(oakengine_sequence_ripple_delete_clip( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 2); + right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 15 && out == 45 && media_in == 0); // clip_b was [30,60] + right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 55 && out == 65 && media_in == 20); // was [70,80] + + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 3); + right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 0 && out == 15 && media_in == 0); + right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 30 && out == 60 && media_in == 0); + right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 2); + assert(oakengine_clip_get_range(right, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 70 && out == 80 && media_in == 20); + + oakengine_footage_free(footage); // wrapper only; node stays + assert(oakengine_project_footage_count(project) == 1); +} + +// Footage imported into one project must not be placed into another. + int main(void) { make_tmpdir(); @@ -268,6 +469,7 @@ int main(void) test_add_track(project, seq); test_add_clip(project, seq, path); test_cross_project_rejected(path); + test_edit_round2(path); oakengine_project_free(project); assert(oakengine_shutdown() == OAKENGINE_OK);