From 71b1243ad83b358ccc8731c579d9b439d58c81cd Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Mon, 20 Jul 2026 08:42:03 +0800 Subject: [PATCH] engine: add sequence structure to the timeline facade - undoable track removal (content restored on undo) and true-move track reordering assembled from the edge commands (the engine has no move-track API) - track height/mute/lock getters and setters, documented as non-undoable to match the engine's current semantics - undoable marker add/remove/rename; duplicate timestamps are rejected with E_STATE instead of hitting the engine's debug assert --- engine/include/oakengine/timeline.h | 104 +++++++ engine/src/capi/timeline.cpp | 284 ++++++++++++++++++ engine/tests/oakengine_timeline_edit_test.cpp | 199 ++++++++++++ 3 files changed, 587 insertions(+) diff --git a/engine/include/oakengine/timeline.h b/engine/include/oakengine/timeline.h index 563f78b45..ef853da42 100644 --- a/engine/include/oakengine/timeline.h +++ b/engine/include/oakengine/timeline.h @@ -343,6 +343,110 @@ OAKENGINE_API int oakengine_sequence_move_clip(OakEngineSequence *seq, int clip_index, int64_t new_in); +/* ---- Track structure and markers ------------------------------------------ + * + * Track structure edits are undoable like the other editing primitives. + * Track height, mute and lock are NOT undoable in the engine (the + * application wires those buttons straight to the setters, see + * app/widget/timelinewidget/trackview/trackviewitem.cpp), and this family + * follows that behavior. Marker edits are undoable. + * All marker times are frame timestamps in the sequence's frame-rate + * timebase, like the rest of the family. + */ + +/** + * @brief Remove a track and its content (undoable; + * olive::TimelineRemoveTrackCommand). OAKENGINE_E_NOT_FOUND when the track + * does not exist. + */ +OAKENGINE_API int oakengine_sequence_remove_track(OakEngineSequence *seq, + int track_type, + int track_index); + +/** + * @brief Move the track at `from_index` to `to_index` within the track + * list (undoable). + * + * Implemented as a true move (take out and re-insert, the tracks in + * between shift), assembled from the engine's edge commands. `from_index` + * == `to_index` is a no-op success; an out-of-range index fails with + * OAKENGINE_E_NOT_FOUND. + */ +OAKENGINE_API int oakengine_sequence_move_track(OakEngineSequence *seq, + int track_type, + int from_index, int to_index); + +/** + * @brief Track height in the engine's internal units + * (Track::get_track_height(); NOT undoable, see the section comment). + */ +OAKENGINE_API int oakengine_track_get_height(const OakEngineSequence *seq, + int track_type, int track_index, + double *height); + +/** + * @brief Set the track height in internal units + * (Track::set_track_height(); NOT undoable). `height` must be > 0. + */ +OAKENGINE_API int oakengine_track_set_height(OakEngineSequence *seq, + int track_type, int track_index, + double height); + +/** + * @brief 1 if the track is muted (Track::is_muted()). + */ +OAKENGINE_API int oakengine_track_is_muted(const OakEngineSequence *seq, + int track_type, int track_index); + +/** + * @brief Mute or unmute the track (Track::set_muted(); NOT undoable). + */ +OAKENGINE_API int oakengine_track_set_muted(OakEngineSequence *seq, + int track_type, int track_index, + int muted); + +/** + * @brief 1 if the track is locked (Track::is_locked()). + */ +OAKENGINE_API int oakengine_track_is_locked(const OakEngineSequence *seq, + int track_type, int track_index); + +/** + * @brief Lock or unlock the track (Track::set_locked(); NOT undoable). + */ +OAKENGINE_API int oakengine_track_set_locked(OakEngineSequence *seq, + int track_type, int track_index, + int locked); + +/** + * @brief Add a timeline marker at `time_ts` named `name` (undoable; + * olive::MarkerAddCommand). + * + * The engine does not allow two markers at the exact same time (its + * insertion asserts on it), so adding one fails with OAKENGINE_E_STATE. + * `name` may be NULL for an empty name. + */ +OAKENGINE_API int oakengine_sequence_marker_add(OakEngineSequence *seq, + int64_t time_ts, + const char *name); + +/** + * @brief Remove the (first) marker at `time_ts` (undoable; + * olive::MarkerRemoveCommand). OAKENGINE_E_NOT_FOUND when no marker exists + * at that exact time. + */ +OAKENGINE_API int oakengine_sequence_marker_remove(OakEngineSequence *seq, + int64_t time_ts); + +/** + * @brief Rename the (first) marker at `time_ts` (undoable; + * olive::MarkerChangeNameCommand). OAKENGINE_E_NOT_FOUND when no marker + * exists at that exact time. + */ +OAKENGINE_API int oakengine_sequence_marker_rename(OakEngineSequence *seq, + int64_t time_ts, + const char *name); + #ifdef __cplusplus } #endif diff --git a/engine/src/capi/timeline.cpp b/engine/src/capi/timeline.cpp index 1f6a34e4c..c062ba191 100644 --- a/engine/src/capi/timeline.cpp +++ b/engine/src/capi/timeline.cpp @@ -780,4 +780,288 @@ int oakengine_sequence_move_clip(OakEngineSequence *seq, int track_type, return OAKENGINE_OK; } +/* ---- Track structure and markers ------------------------------------------ */ + +int oakengine_sequence_remove_track(OakEngineSequence *seq, int track_type, + int track_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)); + if (track_index < 0 || track_index >= list->get_track_count()) { + set_seq_error(QStringLiteral("no track at index %1") + .arg(track_index)); + return OAKENGINE_E_NOT_FOUND; + } + push_or_run(new olive::TimelineRemoveTrackCommand( + list->get_track_at(track_index)), + QStringLiteral("Remove Track")); + return OAKENGINE_OK; +} + +int oakengine_sequence_move_track(OakEngineSequence *seq, int track_type, + int from_index, int to_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)); + const int count = list->get_track_count(); + if (from_index < 0 || from_index >= count || to_index < 0 || + to_index >= count) { + set_seq_error(QStringLiteral("track index out of range (%1 tracks)") + .arg(count)); + return OAKENGINE_E_NOT_FOUND; + } + if (from_index == to_index) { + return OAKENGINE_OK; + } + + // True move: compute the reordered track list, then rewire every track's + // array-element connection into that order with the engine's edge + // commands (one undoable command, so undo restores the old order). + QVector order = list->get_tracks(); + olive::Track *moved = order.at(from_index); + order.removeAt(from_index); + order.insert(to_index, moved); + + // Array elements stay put (connections just move between them); collect + // them in slot order. + QVector elements; + for (int i = 0; i < count; i++) { + elements.append(list->get_array_index_from_cache_index(i)); + } + + const QString input_id = list->track_input(); + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + for (int i = 0; i < count; i++) { + command->add_child(new olive::NodeEdgeRemoveCommand( + list->get_tracks().at(i), + olive::NodeInput(sequence, input_id, elements.at(i)))); + } + for (int i = 0; i < count; i++) { + command->add_child(new olive::NodeEdgeAddCommand( + order.at(i), + olive::NodeInput(sequence, input_id, elements.at(i)))); + } + push_or_run(command, QStringLiteral("Move Track")); + return OAKENGINE_OK; +} + +int oakengine_track_get_height(const OakEngineSequence *seq, int track_type, + int track_index, double *height) +{ + set_seq_error(QString()); + const olive::Sequence *sequence = + reinterpret_cast(seq); + if (!sequence || !height || track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + set_seq_error(QStringLiteral("invalid arguments")); + 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") + .arg(track_index)); + return OAKENGINE_E_NOT_FOUND; + } + *height = list->get_track_at(track_index)->get_track_height(); + return OAKENGINE_OK; +} + +int oakengine_track_set_height(OakEngineSequence *seq, int track_type, + int track_index, double height) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || height <= 0.0 || + track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + set_seq_error(QStringLiteral("invalid arguments")); + 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") + .arg(track_index)); + return OAKENGINE_E_NOT_FOUND; + } + // Straight setter like the application (not undoable). + list->get_track_at(track_index)->set_track_height(height); + return OAKENGINE_OK; +} + +int oakengine_track_is_muted(const OakEngineSequence *seq, int track_type, + int track_index) +{ + const olive::Sequence *sequence = + reinterpret_cast(seq); + if (!sequence || track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + return 0; + } + olive::TrackList *list = + sequence->track_list(to_track_type(track_type)); + if (track_index < 0 || track_index >= list->get_track_count()) { + return 0; + } + return list->get_track_at(track_index)->is_muted() ? 1 : 0; +} + +int oakengine_track_set_muted(OakEngineSequence *seq, int track_type, + int track_index, int muted) +{ + 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 arguments")); + 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") + .arg(track_index)); + return OAKENGINE_E_NOT_FOUND; + } + list->get_track_at(track_index)->set_muted(muted != 0); + return OAKENGINE_OK; +} + +int oakengine_track_is_locked(const OakEngineSequence *seq, int track_type, + int track_index) +{ + const olive::Sequence *sequence = + reinterpret_cast(seq); + if (!sequence || track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + return 0; + } + olive::TrackList *list = + sequence->track_list(to_track_type(track_type)); + if (track_index < 0 || track_index >= list->get_track_count()) { + return 0; + } + return list->get_track_at(track_index)->is_locked() ? 1 : 0; +} + +int oakengine_track_set_locked(OakEngineSequence *seq, int track_type, + int track_index, int locked) +{ + 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 arguments")); + 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") + .arg(track_index)); + return OAKENGINE_E_NOT_FOUND; + } + list->get_track_at(track_index)->set_locked(locked != 0); + return OAKENGINE_OK; +} + +int oakengine_sequence_marker_add(OakEngineSequence *seq, int64_t time_ts, + const char *name) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence) { + set_seq_error(QStringLiteral("invalid sequence")); + 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); + olive::TimelineMarkerList *markers = sequence->get_markers(); + if (markers->get_marker_at_time(time)) { + // The engine's marker insertion asserts on duplicate times. + set_seq_error(QStringLiteral("a marker already exists at time %1") + .arg(time_ts)); + return OAKENGINE_E_STATE; + } + push_or_run(new olive::MarkerAddCommand( + markers, olive::TimeRange(time, time), + QString::fromUtf8(name ? name : ""), 0), + QStringLiteral("Add Marker")); + return OAKENGINE_OK; +} + +int oakengine_sequence_marker_remove(OakEngineSequence *seq, int64_t time_ts) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence) { + set_seq_error(QStringLiteral("invalid sequence")); + 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); + olive::TimelineMarker *marker = + sequence->get_markers()->get_marker_at_time(time); + if (!marker) { + set_seq_error(QStringLiteral("no marker at time %1").arg(time_ts)); + return OAKENGINE_E_NOT_FOUND; + } + push_or_run(new olive::MarkerRemoveCommand(marker), + QStringLiteral("Remove Marker")); + return OAKENGINE_OK; +} + +int oakengine_sequence_marker_rename(OakEngineSequence *seq, int64_t time_ts, + const char *name) +{ + set_seq_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence) { + set_seq_error(QStringLiteral("invalid sequence")); + 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); + olive::TimelineMarker *marker = + sequence->get_markers()->get_marker_at_time(time); + if (!marker) { + set_seq_error(QStringLiteral("no marker at time %1").arg(time_ts)); + return OAKENGINE_E_NOT_FOUND; + } + push_or_run(new olive::MarkerChangeNameCommand( + marker, QString::fromUtf8(name ? name : "")), + QStringLiteral("Rename Marker")); + return OAKENGINE_OK; +} + } // extern "C" diff --git a/engine/tests/oakengine_timeline_edit_test.cpp b/engine/tests/oakengine_timeline_edit_test.cpp index 1148f2e27..f0c0db72c 100644 --- a/engine/tests/oakengine_timeline_edit_test.cpp +++ b/engine/tests/oakengine_timeline_edit_test.cpp @@ -444,6 +444,203 @@ static void test_edit_round2(const char *media_path) // Footage imported into one project must not be placed into another. +// Track structure: move/height/mute/lock/remove over two video tracks, +// plus undo/redo of the undoable ones. +static void test_track_structure(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, "Tracks"); + assert(seq != NULL); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == + 0); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == + 1); + + OakEngineFootage *footage = + oakengine_project_import_footage(project, media_path); + assert(footage != NULL); + // Distinctive clips per track so track order is observable. + OakEngineClip *clip_a = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 10, 0); + OakEngineClip *clip_b = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 1, 20, 30, 0); + assert(clip_a != NULL && clip_b != NULL); + + int64_t in = -1, out = -1, media_in = -1; + double height = 0.0; + + // move_track swaps their positions; the clips move with the tracks. + assert(oakengine_sequence_move_track(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 1) == OAKENGINE_OK); + OakEngineClip *now = oakengine_sequence_clip_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + assert(now == clip_b); + assert(oakengine_clip_get_range(now, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 20 && out == 30); + now = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 1, 0); + assert(now == clip_a); + assert(oakengine_clip_get_range(now, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 0 && out == 10); + + // Undo/redo restore the order both ways. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + now = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + assert(now == clip_a); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + now = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + assert(now == clip_b); + + // No-op move and out-of-range indexes. + assert(oakengine_sequence_move_track(seq, OAKENGINE_TRACK_TYPE_VIDEO, 1, + 1) == OAKENGINE_OK); + assert(oakengine_sequence_move_track(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 5) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_sequence_move_track(seq, OAKENGINE_TRACK_TYPE_VIDEO, 5, + 0) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_sequence_move_track(NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 1) == OAKENGINE_E_INVALID); + + // Height: default is positive, round-trip, direct (not undoable). + assert(oakengine_track_get_height(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + &height) == OAKENGINE_OK); + const double default_height = height; + assert(default_height > 0.0); + assert(oakengine_track_set_height(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 3.0) == OAKENGINE_OK); + assert(oakengine_track_get_height(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + &height) == OAKENGINE_OK); + assert(height == 3.0); + assert(oakengine_track_set_height(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0.0) == OAKENGINE_E_INVALID); + assert(oakengine_track_get_height(seq, OAKENGINE_TRACK_TYPE_VIDEO, 99, + &height) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_track_get_height(NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, + &height) == OAKENGINE_E_INVALID); + + // Mute and lock toggles, direct (not undoable). + assert(oakengine_track_is_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 0); + assert(oakengine_track_set_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 1) == OAKENGINE_OK); + assert(oakengine_track_is_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 1); + assert(oakengine_track_set_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0) == OAKENGINE_OK); + assert(oakengine_track_is_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 0); + assert(oakengine_track_set_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 99, + 1) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_track_is_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == + 0); + assert(oakengine_track_set_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 1) == OAKENGINE_OK); + assert(oakengine_track_is_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == + 1); + assert(oakengine_track_set_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0) == OAKENGINE_OK); + assert(oakengine_track_set_locked(NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 1) == OAKENGINE_E_INVALID); + + // remove_track drops the track and its content; undo brings both back. + int video = -1, audio = -1, subtitle = -1; + assert(oakengine_sequence_remove_track(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 1) == OAKENGINE_OK); + assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) == + OAKENGINE_OK); + assert(video == 1); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 1); + assert(oakengine_sequence_remove_track(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 9) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) == + OAKENGINE_OK); + assert(video == 2); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 1) == 1); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) == + OAKENGINE_OK); + assert(video == 1); + + oakengine_footage_free(footage); + oakengine_project_free(project); +} + +static void test_markers(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "Markers"); + assert(seq != NULL); + + int64_t ts = -1; + char name[64]; + + assert(oakengine_sequence_marker_count(seq) == 0); + 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)) == + OAKENGINE_OK); + assert(ts == 30 && strcmp(name, "Chapter 1") == 0); + + // Duplicate time: the engine forbids two markers at one time. + assert(oakengine_sequence_marker_add(seq, 30, "dup") == + OAKENGINE_E_STATE); + + // 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)) == + OAKENGINE_OK); + assert(ts == 10 && strcmp(name, "Intro") == 0); + assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name)) == + OAKENGINE_OK); + assert(ts == 30 && strcmp(name, "Chapter 1") == 0); + assert(oakengine_sequence_marker_at(seq, 2, &ts, name, sizeof(name)) == + 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)) == + 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)) == + 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)) == + OAKENGINE_OK); + assert(strcmp(name, "Chapter 2") == 0); + + // Remove with undo; removing again reports not found. + assert(oakengine_sequence_marker_remove(seq, 10) == OAKENGINE_OK); + assert(oakengine_sequence_marker_count(seq) == 1); + assert(oakengine_sequence_marker_remove(seq, 10) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_marker_count(seq) == 2); + + // NULL safety. + assert(oakengine_sequence_marker_add(NULL, 0, "x") == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_marker_remove(NULL, 0) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_marker_rename(NULL, 0, "x") == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_remove_track(NULL, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == OAKENGINE_E_INVALID); + + oakengine_project_free(project); +} + int main(void) { make_tmpdir(); @@ -470,6 +667,8 @@ int main(void) test_add_clip(project, seq, path); test_cross_project_rejected(path); test_edit_round2(path); + test_track_structure(path); + test_markers(); oakengine_project_free(project); assert(oakengine_shutdown() == OAKENGINE_OK);