diff --git a/docs/zh/plans/riir/M4-oaktimeline.md b/docs/zh/plans/riir/M4-oaktimeline.md index 51e505c59..9fe20b1f5 100644 --- a/docs/zh/plans/riir/M4-oaktimeline.md +++ b/docs/zh/plans/riir/M4-oaktimeline.md @@ -112,3 +112,27 @@ OAKTL_API int64_t oaktimeline_nearest_block_ts(OakNodeTrack *track, - edit:每个 `_command` 工厂 1 个"构造→入栈→undo 还原"用例 (track 增删、place/replace/trim/split/ripple/slide)。 - `oaktimeline_debug_alive_count()` 泄漏断言。 + +## 实施现状(2026-08-05) + +- 目录结构:`src/timeline/{src,c_api,tests,standalone}` + + `include/timeline/{error,marker,workarea,edit}.h`。 +- 构建测试:`cmake -S src/timeline/standalone -B build-oaktimeline && + cmake --build build-oaktimeline -j && ctest --test-dir + build-oaktimeline`——117/117(本模块 21 用例 + oaknode 回归 96)。 + 全量回归:oakcommon 193、oaknode 96、oakrender 42、oakcodec 18、 + oakaudio 36 全绿。 +- C API 与 §2 冻结表的差异:marker/workarea 的增删改统一为 + `_command` 工厂形态(返回 OakUndoCommand,调用方 redo/push), + workarea set_range_command 需要调用方提供旧值(facade 知情原则); + edit.h 的 `_command` 工厂按 §2.3 落地并补 insert_gaps/ + ripple_remove_area;`oaktimeline_marker_list_of/workarea_of` 经 + oaknode 新增的借用出口(oaknode_node_get_markers/get_work_area) + 实现。marker list 无 free(borrowed,M4 §2.1 既定)。 +- 与计划的差异:undo 命令类未走"语义函数 + 适配类",而是命令类整体 + 留在模块内、成员全部换成 oaknode C 句柄直调 C ABI(01 §0 铁律 6 + 的直接执行);UndoCommand 跨模块继承保留为例外(notes.md 有记录)。 +- oaknode 侧适配:transition/timeline/* 由 stub 改为桥接真身头; + viewer 的 workarea/markers 改 unique_ptr;4 个 serializer 的 marker + 构造点适配显式 add_marker。 +- 已知问题:无(de-Qt 断链的 block→track 长度通知已在本轮修复)。 diff --git a/docs/zh/plans/riir/notes.md b/docs/zh/plans/riir/notes.md index 26c63ec7b..dcc53427e 100644 --- a/docs/zh/plans/riir/notes.md +++ b/docs/zh/plans/riir/notes.md @@ -246,3 +246,42 @@ ColorManager)去Qt化过程中的删除与语义变化,迁移调用方时需 - displayinternal.h 分层违规(render→src/capi)并入 texturehandle.h。 - Renderer 线程亲和由 QObject::thread() 改显式 owner thread (`set_owner_thread_to_current()`/`called_on_owner_thread()`)。 + +## oaktimeline 去Qt化的删除与语义变更(2026-08-05) + +- TimelineMarker/TimelineWorkArea 的信号(time_changed/name_changed/ + color_changed/enabled_changed/range_changed/marker_added/removed/ + modified)全部删除,变更通知由 facade 层在命令后发出;list 的重排由 + TimelineMarker::set_time 直调 parent_->resort(this)。 +- TimelineMarker::draw()/get_marker_height()(QPainter/QFontMetrics) + 删除,归 app 层。 +- TimelineMarkerList 的 QObject childEvent 自动注册改为显式 + add_marker(std::unique_ptr)/remove_marker 所有权转移;marker 构造 + 不再带 parent 参数(oaknode 的 4 个 serializer 调用点已适配)。 +- ViewerOutput 的 workarea_/markers_ 由 QObject 父子管理改为 + std::unique_ptr 持有。 +- timeline 全部 undo 命令类改为持有 oaknode C 句柄 + (OakNodeTrack/OakNodeBlock/OakNodeSequence),图操作全部经 + oaknode C ABI(01 §0 铁律 6);为此 oaknode C API 新增: + copy_in_graph、clip_add_cache_passthrough_from、block_get_kind、 + block_as_node/from_node、track_as_node、nearest_block_before/after_or_at、 + command_create_remove_node/add_node/set_position_recursive、 + node_get_project、input_array_insert/remove、connect/disconnect_element、 + tracklist_get_sequence/track_input_id/array_append/array_remove_last/ + get_array_index_from_cache_index、sequence_as_node、node_get_markers/ + get_work_area(后两个为 oaktimeline 的借用出口)。 +- UndoCommand 跨模块继承(oaktimeline 命令类继承 oakundo 的 + olive::UndoCommand)保留——undo 架构的既有跨模块模式,记录为 + 01 §5 的明确例外(oaknode 的 nodeundo 同例)。 +- **修复 de-Qt 断链**:Block 长度变化不再经信号通知 Track,改为 + Block::InputValueChangedEvent 里对 track_->block_length_changed(this) + 直调(原 Qt 版 connect 链,wave-2 遗留的 facade 接线项提前兑现)。 +- 命令创建的 GapBlock 在插入 track 前必须先进项目图 + (block_add_to_graph/block_remove_from_graph helper,对齐原版 + setParent(graph)/setParent(memory) 语义);孤立句柄用 orphan 标志 + 跟踪,析构时 oaknode_block_free。 +- oaknode↔oaktimeline 运行期互相解析(add_default_nodes → + TimelineAddTrackCommand),双方 dylib dynamic_lookup,测试二进制 + 必须同时链两个库(各 standalone 驱动已接线)。 +- oakcommon xml C API 新增 get_native 借用访问器(C++ only), + oaktimeline 的 load/save 经它取回 XmlStreamReader/Writer。 diff --git a/include/common/xmlutils.h b/include/common/xmlutils.h index 8c8b38ca0..0ec1e08aa 100644 --- a/include/common/xmlutils.h +++ b/include/common/xmlutils.h @@ -64,6 +64,21 @@ typedef struct OakXmlWriter { */ OakXmlReader oakcommon_xml_reader_init(const char *data); +#ifdef __cplusplus +} /* extern "C" */ + +namespace olive { class XmlStreamReader; class XmlStreamWriter; } + +extern "C" { +#endif + +/** + * @brief Borrowed access to the underlying C++ reader/writer (C++ only, + * for adapter layers). Valid while the handle is held. NULL-safe. + */ +olive::XmlStreamReader *oakcommon_xml_reader_get_native(OakXmlReader reader); +olive::XmlStreamWriter *oakcommon_xml_writer_get_native(OakXmlWriter writer); + /** * @brief Release one reference to a reader. * diff --git a/include/node/block.h b/include/node/block.h index 1ffabfbd7..fa85a4de7 100644 --- a/include/node/block.h +++ b/include/node/block.h @@ -49,6 +49,13 @@ typedef struct OakNodeBlock OakNodeBlock; */ typedef struct OakNodeTrack OakNodeTrack; +/** + * @brief Opaque handle to a node (olive::Node), see node/node.h. + * + * Re-declared here so block.h is self-contained; the typedef is identical. + */ +typedef struct OakNodeNode OakNodeNode; + /** * @brief Concrete transition kinds for oaknode_block_transition_create(). */ @@ -57,6 +64,14 @@ enum OakNodeTransitionKind { OAKNODE_TRANSITION_DIP_TO_COLOR = 1 /**< DipToColorTransition. */ }; +/** + * @brief Input ids of a TransitionBlock's block connections + * (TransitionBlock::k_out_block_input / k_in_block_input). Pinned by + * test; pass to oaknode_node_connect()/oaknode_node_disconnect(). + */ +#define OAKNODE_TRANSITION_OUT_BLOCK_INPUT "out_block_in" +#define OAKNODE_TRANSITION_IN_BLOCK_INPUT "in_block_in" + /** * @brief Create a ClipBlock. * @@ -91,6 +106,32 @@ OakNodeBlock *oaknode_block_transition_create(int kind); */ void oaknode_block_free(OakNodeBlock *block); +enum OakNodeBlockKind { + OAKNODE_BLOCK_OTHER = 0, + OAKNODE_BLOCK_CLIP = 1, + OAKNODE_BLOCK_GAP = 2, + OAKNODE_BLOCK_TRANSITION = 3 +}; + +/** + * @brief Concrete kind of a block (dynamic_cast query). + */ +int oaknode_block_get_kind(OakNodeBlock *block, int *out_kind); + +/** + * @brief Borrowed cast from a block handle to its node handle. + * + * Every Block is a Node; the result must not be freed. NULL for NULL. + */ +OakNodeNode *oaknode_block_as_node(OakNodeBlock *block); + +/** + * @brief Borrowed cast from a node handle to a block handle. + * + * Returns NULL if the node is not a Block (or for NULL). + */ +OakNodeBlock *oaknode_block_from_node(OakNodeNode *node); + /** * @brief Rational getters/setters use numerator/denominator out pairs. * @@ -233,6 +274,14 @@ int oaknode_transition_get_connected_out_block(OakNodeBlock *transition, int oaknode_transition_get_connected_in_block(OakNodeBlock *transition, OakNodeBlock **out); +/** + * @brief Forward cache passthroughs from another clip + * (ClipBlock::add_cache_passthrough_from()). Used after splitting a + * clip so the new part shares the render caches. + */ +int oaknode_clip_add_cache_passthrough_from(OakNodeBlock *clip, + OakNodeBlock *other); + #ifdef __cplusplus } #endif diff --git a/include/node/node.h b/include/node/node.h index 232516a5a..7a64f8c65 100644 --- a/include/node/node.h +++ b/include/node/node.h @@ -99,6 +99,17 @@ typedef struct oaknode_value { */ typedef struct OakNodeNode OakNodeNode; +/* Re-declared here so node.h is self-contained; see node/project.h. */ +typedef struct OakNodeProject OakNodeProject; + +/** + * @brief Opaque borrowed handles to the timeline data owned by viewer + * nodes (TimelineMarkerList / TimelineWorkArea in oaktimeline). + * oaktimeline reinterprets these into its own handle types. + */ +typedef struct OakNodeMarkerList OakNodeMarkerList; +typedef struct OakNodeWorkArea OakNodeWorkArea; + /** * @brief Number of live owned objects created through this API * (nodes from oaknode_factory_create_from_id()/oaknode_node_create_copy(), @@ -469,6 +480,79 @@ int oaknode_node_remove_from_context(OakNodeNode *node, OakNodeNode *context); */ OakNodeNode *oaknode_node_create_copy(const OakNodeNode *node); +/** + * @brief Copy a node inside its graph (Node::copy_node_in_graph()), + * recording the reconnect operations in a new MultiUndoCommand. + * + * `*out_command` receives an owned undo command handle (free with + * oakundo_command_free()). The copy is inserted into the graph only when + * the returned command is redone; treat it as owned (oaknode_node_free()) + * until then. Returns NULL on failure. + */ +OakNodeNode *oaknode_node_copy_in_graph(OakNodeNode *node, + OakUndoCommand **out_command); + +/** + * @brief Get the project this node belongs to (borrowed). *out may be + * NULL if the node is orphaned. + */ +int oaknode_node_get_project(const OakNodeNode *node, + OakNodeProject **out); + +/** + * @brief Insert/remove an element in an input array (live, + * Node::input_array_insert/remove()). OAKNODE_E_NOT_FOUND for an + * unknown input id. + */ +int oaknode_node_input_array_insert(OakNodeNode *node, + const char *input_id, int index); +int oaknode_node_input_array_remove(OakNodeNode *node, + const char *input_id, int index); + +/** + * @brief Element-aware variants of oaknode_node_connect()/disconnect() + * (NodeInput element != -1, e.g. Sequence's track_in_N array inputs). + */ +int oaknode_node_connect_element(OakNodeNode *output_node, + OakNodeNode *input_node, + const char *input_id, int element); +int oaknode_node_disconnect_element(OakNodeNode *input_node, + const char *input_id, int element); + +/** + * @brief Create a command that adds a node to a project's graph + * (olive::NodeAddCommand). Owned; free with oakundo_command_free(). + */ +OakUndoCommand *oaknode_command_create_add_node(OakNodeProject *graph, + OakNodeNode *node); + +/** + * @brief Create a command that sets a node's position in a context and + * repositions its dependencies recursively + * (olive::NodeSetPositionAndDependenciesRecursivelyCommand). Owned. + */ +OakUndoCommand *oaknode_command_create_set_position_recursive( + OakNodeNode *node, OakNodeNode *context, double x, double y); + +/** + * @brief Borrowed marker list / work area of a viewer node. *out is NULL + * when the node is not a viewer (or for NULL input). + */ +int oaknode_node_get_markers(const OakNodeNode *node, + OakNodeMarkerList **out); +int oaknode_node_get_work_area(const OakNodeNode *node, + OakNodeWorkArea **out); + +/** + * @brief Create a command that removes a node from its graph together + * with its exclusive dependencies and disconnects its edges + * (NodeRemoveWithExclusiveDependenciesAndDisconnect). + * + * Owned command handle; free with oakundo_command_free(). Returns NULL + * on failure. + */ +OakUndoCommand *oaknode_command_create_remove_node(OakNodeNode *node); + /** * @brief Destroy an OWNED node immediately (C++ delete). NULL is a no-op. * diff --git a/include/node/sequence.h b/include/node/sequence.h index 4fa8ba738..fb7cf68ff 100644 --- a/include/node/sequence.h +++ b/include/node/sequence.h @@ -33,6 +33,18 @@ extern "C" { #endif +/** + * @brief Sequence texture/samples input ids (ViewerOutput::k_texture_input + * / k_samples_input) and the track input id format + * (Sequence::k_track_input_format). Pinned by test. + */ +#define OAKNODE_SEQUENCE_TEXTURE_INPUT "tex_in" +#define OAKNODE_SEQUENCE_SAMPLES_INPUT "samples_in" +#define OAKNODE_SEQUENCE_TRACK_INPUT_FORMAT "track_in_%1" + +/* Re-declared here so sequence.h is self-contained; see node/node.h. */ +typedef struct OakNodeNode OakNodeNode; + /** * @brief Opaque handle to a sequence (olive::Sequence). * @@ -71,6 +83,12 @@ void oaknode_sequence_free(OakNodeSequence *sequence); * @param type One of OAKNODE_TRACK_TYPE_VIDEO / _AUDIO / _SUBTITLE. * @return OAKNODE_OK, OAKNODE_E_INVALID or OAKNODE_E_NOT_FOUND (bad type). */ +/** + * @brief Borrowed cast from a sequence handle to its node handle. + * NULL for NULL. + */ +OakNodeNode *oaknode_sequence_as_node(OakNodeSequence *sequence); + int oaknode_sequence_get_track_list(OakNodeSequence *sequence, int type, OakNodeTrackList **out); diff --git a/include/node/track.h b/include/node/track.h index 856183d0a..f63611e16 100644 --- a/include/node/track.h +++ b/include/node/track.h @@ -67,6 +67,15 @@ enum OakNodeTrackType { OAKNODE_TRACK_TYPE_COUNT = 3 }; +/* Re-declared here so track.h is self-contained; see node/node.h. */ +typedef struct OakNodeNode OakNodeNode; + +/** + * @brief Borrowed cast from a track handle to its node handle. + * NULL for NULL. + */ +OakNodeNode *oaknode_track_as_node(OakNodeTrack *track); + /* ---------------------------------------------------------------- Track */ /** @@ -220,6 +229,42 @@ int oaknode_track_is_range_free(OakNodeTrack *track, int in_num, int in_den, /** * @brief Track list type (OakNodeTrackType values). */ +/** + * @brief Nearest block lookups (Track::nearest_block_before_or_at / + * nearest_block_after_or_at). *out is a borrowed handle or NULL. + */ +int oaknode_track_get_nearest_block_before_or_at(OakNodeTrack *track, + int numerator, int denominator, OakNodeBlock **out); +int oaknode_track_get_nearest_block_after_or_at(OakNodeTrack *track, + int numerator, int denominator, OakNodeBlock **out); + +/** + * @brief Borrowed sequence owning this track list. + */ +int oaknode_tracklist_get_sequence(OakNodeTrackList *list, + OakNodeSequence **out); + +/** + * @brief The list's track input id on the parent sequence + * (e.g. "track_in_0"). Two-stage string getter. + */ +int oaknode_tracklist_get_track_input_id(OakNodeTrackList *list, + char *buf, int buf_size); + +/** + * @brief Live input-array append/remove on the parent sequence for this + * list's track input (TrackList::array_append/array_remove_last()). + */ +int oaknode_tracklist_array_append(OakNodeTrackList *list); +int oaknode_tracklist_array_remove_last(OakNodeTrackList *list); + +/** + * @brief Map a cached track index to the input-array element index + * (TrackList::get_array_index_from_cache_index()). + */ +int oaknode_tracklist_get_array_index_from_cache_index( + OakNodeTrackList *list, int cache_index, int *out_index); + int oaknode_tracklist_get_type(OakNodeTrackList *list, int *type); /** diff --git a/include/timeline/edit.h b/include/timeline/edit.h new file mode 100644 index 000000000..fbe6ae0fe --- /dev/null +++ b/include/timeline/edit.h @@ -0,0 +1,115 @@ +/*** + + Oak Video Editor - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_EDITOR_TIMELINE_EDIT_H +#define OAK_EDITOR_TIMELINE_EDIT_H + +#include "node/block.h" +#include "node/sequence.h" +#include "node/track.h" +#include "timeline/error.h" +#include "undo/undocommand.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Timeline edit primitives (M4 §2.3). + * + * The timeline undo command classes stay inside oaktimeline (01 §5); + * consumers create commands through these factories, receiving base + * OakUndoCommand handles (owned; free with oakundo_command_free()). + * Redo a command directly or push it on an undo stack. + */ + +/** @brief olive::TimelineAddTrackCommand. */ +OakUndoCommand *oaktimeline_add_track_command(OakNodeTrackList *list); + +/** @brief olive::TimelineRemoveTrackCommand. */ +OakUndoCommand *oaktimeline_remove_track_command(OakNodeTrack *track); + +/** @brief olive::TrackPlaceBlockCommand. */ +OakUndoCommand *oaktimeline_place_block_command(OakNodeTrackList *list, + int track_index, + OakNodeBlock *block, + int64_t in_num, + int64_t in_den); + +/** @brief olive::TrackReplaceBlockWithGapCommand. */ +OakUndoCommand *oaktimeline_replace_block_with_gap_command( + OakNodeTrack *track, OakNodeBlock *block); + +/** + * @brief olive::BlockTrimCommand. `mode` is an OakTimelineMovementMode + * value (k_trim_in / k_trim_out). + */ +OakUndoCommand *oaktimeline_trim_command(OakNodeTrack *track, + OakNodeBlock *block, + int64_t new_length_num, + int64_t new_length_den, int mode); + +/** @brief olive::BlockSplitCommand on a set of blocks at one point. */ +OakUndoCommand *oaktimeline_split_command(OakNodeBlock *const *blocks, + int count, int64_t point_num, + int64_t point_den); + +/** @brief olive::BlockSplitPreservingLinksCommand. */ +OakUndoCommand *oaktimeline_split_preserving_links_command( + OakNodeBlock *const *blocks, int count, const int64_t *point_nums, + const int64_t *point_dens, int time_count); + +/** @brief olive::TimelineRippleDeleteGapsAtRegionsCommand. */ +OakUndoCommand *oaktimeline_ripple_delete_gaps_command( + OakNodeSequence *sequence, const int64_t *in_nums, + const int64_t *in_dens, const int64_t *out_nums, + const int64_t *out_dens, OakNodeTrack *const *tracks, int range_count); + +/** @brief olive::TrackSlideCommand. */ +OakUndoCommand *oaktimeline_slide_command( + OakNodeTrack *track, OakNodeBlock *const *blocks, int block_count, + OakNodeBlock *in_adjacent, OakNodeBlock *out_adjacent, + int64_t movement_num, int64_t movement_den); + +/** @brief olive::TrackRippleRemoveAreaCommand. */ +OakUndoCommand *oaktimeline_ripple_remove_area_command( + OakNodeTrack *track, int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den); + +/** @brief olive::TrackListInsertGaps. */ +OakUndoCommand *oaktimeline_insert_gaps_command(OakNodeTrackList *list, + int64_t point_num, + int64_t point_den, + int64_t length_num, + int64_t length_den); + +/** @brief Movement modes (olive::Timeline::MovementMode). */ +enum OakTimelineMovementMode { + OAKTIMELINE_MOVEMENT_NONE = 0, + OAKTIMELINE_MOVEMENT_MOVE = 1, + OAKTIMELINE_MOVEMENT_TRIM_IN = 2, + OAKTIMELINE_MOVEMENT_TRIM_OUT = 3 +}; + +#ifdef __cplusplus +} +#endif + +#endif //OAK_EDITOR_TIMELINE_EDIT_H diff --git a/include/timeline/error.h b/include/timeline/error.h new file mode 100644 index 000000000..3c56796d0 --- /dev/null +++ b/include/timeline/error.h @@ -0,0 +1,39 @@ +/*** + + Oak Video Editor - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_EDITOR_TIMELINE_ERROR_H +#define OAK_EDITOR_TIMELINE_ERROR_H + +/** + * @brief Status and error codes shared by all oaktimeline C API families. + * + * Return-code convention (mirrors engine/include/oakengine/init.h): + * 0 (OAKTIMELINE_OK) on success, a negative OAKTIMELINE_E_* error code on + * failure. String getters return the required buffer size in bytes + * (including the terminating NUL) as a non-negative value instead. + */ +#define OAKTIMELINE_OK 0 /**< Success. */ +#define OAKTIMELINE_E_INVALID (-1) /**< NULL handle or invalid argument. */ +#define OAKTIMELINE_E_STATE (-2) /**< Call not valid in the current state. */ +#define OAKTIMELINE_E_FAILED (-3) /**< The underlying operation failed. */ +#define OAKTIMELINE_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */ +#define OAKTIMELINE_E_NOMEM (-5) /**< Allocation failed. */ + +#endif //OAK_EDITOR_TIMELINE_ERROR_H diff --git a/include/timeline/marker.h b/include/timeline/marker.h new file mode 100644 index 000000000..81d35a73c --- /dev/null +++ b/include/timeline/marker.h @@ -0,0 +1,108 @@ +/*** + + Oak Video Editor - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_EDITOR_TIMELINE_MARKER_H +#define OAK_EDITOR_TIMELINE_MARKER_H + +#include "common/xmlutils.h" +#include "node/node.h" +#include "timeline/error.h" +#include "undo/undocommand.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Borrowed handle to a timeline marker list + * (olive::TimelineMarkerList), owned by a viewer node. + * + * Obtained via oaktimeline_marker_list_of(); never freed by the caller. + */ +typedef struct OakTimelineMarkerList OakTimelineMarkerList; + +/** + * @brief Borrowed marker list of a viewer node (sequence). NULL for NULL + * or when the node is not a viewer. + */ +OakTimelineMarkerList *oaktimeline_marker_list_of(OakNodeNode *owner); + +/** + * @brief Number of markers. Out-param convention; OAKTIMELINE_E_INVALID + * for NULL arguments. + */ +int oaktimeline_marker_count(const OakTimelineMarkerList *list, + int *out_count); + +/** + * @brief Marker at index: time as num/den pairs, color and name + * (two-stage string). OAKTIMELINE_E_NOT_FOUND when out of range. + */ +int oaktimeline_marker_at(const OakTimelineMarkerList *list, int index, + int *in_num, int *in_den, int *out_num, int *out_den, + int *color, char *name_buf, int buf_size); + +/** + * @brief Create a command that adds a marker (olive::MarkerAddCommand). + * + * Owned command; free with oakundo_command_free(). Redo it directly or + * push it on an undo stack. Returns NULL on failure. + */ +OakUndoCommand *oaktimeline_marker_add_command( + OakTimelineMarkerList *list, int in_num, int in_den, int out_num, + int out_den, const char *name, int color); + +/** + * @brief Create a command that removes the marker at `index`. + * OAKTIMELINE_E_NOT_FOUND (as NULL result documented by error) is + * reported by returning NULL. + */ +OakUndoCommand *oaktimeline_marker_remove_at_command( + OakTimelineMarkerList *list, int index); + +/** + * @brief Create a command that sets a marker's time range. + */ +OakUndoCommand *oaktimeline_marker_set_time_command( + OakTimelineMarkerList *list, int index, int in_num, int in_den, + int out_num, int out_den); + +/** + * @brief Create a command that sets a marker's color and/or name. + * `name` may be NULL to leave the name unchanged (color still applies + * when >= 0; both NULL-name and color < 0 is a no-op error). + */ +OakUndoCommand *oaktimeline_marker_set_props_command( + OakTimelineMarkerList *list, int index, int color, const char *name); + +/** + * @brief Load/save the list through oakcommon XML handles. The reader + * must be positioned on the wrapping element (e.g. "markers"). + */ +int oaktimeline_marker_list_load(OakTimelineMarkerList *list, + OakXmlReader reader); +int oaktimeline_marker_list_save(const OakTimelineMarkerList *list, + OakXmlWriter writer); + +#ifdef __cplusplus +} +#endif + +#endif //OAK_EDITOR_TIMELINE_MARKER_H diff --git a/include/timeline/workarea.h b/include/timeline/workarea.h new file mode 100644 index 000000000..9e5598f09 --- /dev/null +++ b/include/timeline/workarea.h @@ -0,0 +1,92 @@ +/*** + + Oak Video Editor - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_EDITOR_TIMELINE_WORKAREA_H +#define OAK_EDITOR_TIMELINE_WORKAREA_H + +#include "common/xmlutils.h" +#include "node/node.h" +#include "timeline/error.h" +#include "undo/undocommand.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Borrowed handle to a timeline work area + * (olive::TimelineWorkArea), owned by a viewer node. + */ +typedef struct OakTimelineWorkArea OakTimelineWorkArea; + +/** + * @brief Borrowed work area of a viewer node (sequence). NULL for NULL + * or when the node is not a viewer. + */ +OakTimelineWorkArea *oaktimeline_workarea_of(OakNodeNode *owner); + +/** + * @brief Read the work area state. Out params may individually be NULL. + */ +int oaktimeline_workarea_get(const OakTimelineWorkArea *w, int *in_num, + int *in_den, int *out_num, int *out_den, + int *enabled); + +/** + * @brief Set the range directly (live). + */ +int oaktimeline_workarea_set_range(OakTimelineWorkArea *w, int in_num, + int in_den, int out_num, int out_den); + +/** + * @brief Create a set-range command (olive::WorkareaSetRangeCommand). + * The old range must be supplied by the caller (facade knows what it + * changed from). Owned; free with oakundo_command_free(). + */ +OakUndoCommand *oaktimeline_workarea_set_range_command( + OakTimelineWorkArea *w, int in_num, int in_den, int out_num, + int out_den, int old_in_num, int old_in_den, int old_out_num, + int old_out_den); + +/** + * @brief Create a set-enabled command (olive::WorkareaSetEnabledCommand). + */ +OakUndoCommand *oaktimeline_workarea_set_enabled_command( + OakTimelineWorkArea *w, int enabled); + +/** + * @brief The reset sentinel range (TimelineWorkArea::k_reset_in/out). + */ +int oaktimeline_workarea_reset(int *in_num, int *in_den, int *out_num, + int *out_den); + +/** + * @brief Load/save through oakcommon XML handles. The reader must be + * positioned on the "workarea" element. + */ +int oaktimeline_workarea_load(OakTimelineWorkArea *w, OakXmlReader reader); +int oaktimeline_workarea_save(const OakTimelineWorkArea *w, + OakXmlWriter writer); + +#ifdef __cplusplus +} +#endif + +#endif //OAK_EDITOR_TIMELINE_WORKAREA_H diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ee1407ff5..53c3080f8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -4,3 +4,4 @@ add_subdirectory(node) add_subdirectory(render) add_subdirectory(codec) add_subdirectory(audio) +add_subdirectory(timeline) diff --git a/src/common/c_api/xmlutils.cpp b/src/common/c_api/xmlutils.cpp index 999e59aac..da85db5ff 100644 --- a/src/common/c_api/xmlutils.cpp +++ b/src/common/c_api/xmlutils.cpp @@ -316,3 +316,19 @@ int oakcommon_xml_writer_output(OakXmlWriter writer, char *buf, } } // extern "C" + +olive::XmlStreamReader *oakcommon_xml_reader_get_native(OakXmlReader reader) +{ + if (!reader.ctx) { + return nullptr; + } + return &oakcommon::handle_impl(reader.ctx)->reader; +} + +olive::XmlStreamWriter *oakcommon_xml_writer_get_native(OakXmlWriter writer) +{ + if (!writer.ctx) { + return nullptr; + } + return oakcommon::handle_impl(writer.ctx); +} diff --git a/src/node/c_api/block.cpp b/src/node/c_api/block.cpp index bd3a1d9fc..306b320df 100644 --- a/src/node/c_api/block.cpp +++ b/src/node/c_api/block.cpp @@ -475,3 +475,57 @@ int oaknode_transition_get_connected_in_block(OakNodeBlock *transition, *out = wrap(t->connected_in_block()); return OAKNODE_OK; } + +int oaknode_clip_add_cache_passthrough_from(OakNodeBlock *clip, + OakNodeBlock *other) +{ + if (!clip || !other) { + return OAKNODE_E_INVALID; + } + + olive::ClipBlock *c = clip_impl(clip); + olive::ClipBlock *o = clip_impl(other); + if (!c || !o) { + return OAKNODE_E_INVALID; + } + + try { + c->add_cache_passthrough_from(o); + return OAKNODE_OK; + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +OakNodeNode *oaknode_block_as_node(OakNodeBlock *block) +{ + return reinterpret_cast(block); +} + +OakNodeBlock *oaknode_block_from_node(OakNodeNode *node) +{ + if (!node) { + return NULL; + } + olive::Node *n = reinterpret_cast(node); + return wrap(dynamic_cast(n)); +} + +int oaknode_block_get_kind(OakNodeBlock *block, int *out_kind) +{ + if (!block || !out_kind) { + return OAKNODE_E_INVALID; + } + + olive::Block *b = impl(block); + if (dynamic_cast(b)) { + *out_kind = OAKNODE_BLOCK_TRANSITION; + } else if (dynamic_cast(b)) { + *out_kind = OAKNODE_BLOCK_CLIP; + } else if (dynamic_cast(b)) { + *out_kind = OAKNODE_BLOCK_GAP; + } else { + *out_kind = OAKNODE_BLOCK_OTHER; + } + return OAKNODE_OK; +} diff --git a/src/node/c_api/node.cpp b/src/node/c_api/node.cpp index 3af82b464..07e5f726e 100644 --- a/src/node/c_api/node.cpp +++ b/src/node/c_api/node.cpp @@ -25,6 +25,10 @@ #include "nodeundo.h" +#include "project.h" + +#include "output/viewer/viewer.h" + #include "valueconvert.h" namespace @@ -1028,3 +1032,201 @@ void oaknode_node_free(OakNodeNode *node) } catch (...) { } } + +OakNodeNode *oaknode_node_copy_in_graph(OakNodeNode *node, + OakUndoCommand **out_command) +{ + if (!node || !out_command) { + return NULL; + } + + try { + auto *command = new olive::MultiUndoCommand(); + olive::Node *copy = + olive::Node::copy_node_in_graph(to_node(node), command); + if (!copy) { + delete command; + return NULL; + } + *out_command = oaknode_c_api::wrap_command(command); + if (!*out_command) { + delete command; + return NULL; + } + oaknode_c_api::alive_inc(); + return from_node(copy); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaknode_command_create_remove_node(OakNodeNode *node) +{ + if (!node) { + return NULL; + } + + try { + return oaknode_c_api::wrap_command( + new olive::NodeRemoveWithExclusiveDependenciesAndDisconnect( + to_node(node))); + } catch (...) { + return NULL; + } +} + +int oaknode_node_get_project(const OakNodeNode *node, OakNodeProject **out) +{ + if (!node || !out) { + return OAKNODE_E_INVALID; + } + *out = reinterpret_cast(to_node(node)->project()); + return OAKNODE_OK; +} + +int oaknode_node_input_array_insert(OakNodeNode *node, const char *input_id, + int index) +{ + if (!node || !input_id) { + return OAKNODE_E_INVALID; + } + if (!has_input(to_node(node), input_id)) { + return OAKNODE_E_NOT_FOUND; + } + + try { + to_node(node)->input_array_insert(input_id, index); + return OAKNODE_OK; + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +int oaknode_node_input_array_remove(OakNodeNode *node, const char *input_id, + int index) +{ + if (!node || !input_id) { + return OAKNODE_E_INVALID; + } + if (!has_input(to_node(node), input_id)) { + return OAKNODE_E_NOT_FOUND; + } + + try { + to_node(node)->input_array_remove(input_id, index); + return OAKNODE_OK; + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +int oaknode_node_connect_element(OakNodeNode *output_node, + OakNodeNode *input_node, + const char *input_id, int element) +{ + if (!output_node || !input_node) { + return OAKNODE_E_INVALID; + } + if (!has_input(to_node(input_node), input_id)) { + return OAKNODE_E_NOT_FOUND; + } + + try { + olive::Node::connect_edge( + to_node(output_node), + olive::NodeInput(to_node(input_node), input_id, element)); + return OAKNODE_OK; + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +int oaknode_node_disconnect_element(OakNodeNode *input_node, + const char *input_id, int element) +{ + if (!input_node) { + return OAKNODE_E_INVALID; + } + if (!has_input(to_node(input_node), input_id)) { + return OAKNODE_E_NOT_FOUND; + } + + try { + olive::Node *output = to_node(input_node)->get_connected_output( + input_id, element); + if (!output) { + return OAKNODE_E_NOT_FOUND; + } + + olive::Node::disconnect_edge( + output, olive::NodeInput(to_node(input_node), input_id, element)); + return OAKNODE_OK; + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +OakUndoCommand *oaknode_command_create_add_node(OakNodeProject *graph, + OakNodeNode *node) +{ + if (!graph || !node) { + return NULL; + } + + try { + return oaknode_c_api::wrap_command( + new olive::NodeAddCommand( + reinterpret_cast(graph), to_node(node))); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaknode_command_create_set_position_recursive( + OakNodeNode *node, OakNodeNode *context, double x, double y) +{ + if (!node || !context) { + return NULL; + } + + try { + return oaknode_c_api::wrap_command( + new olive::NodeSetPositionAndDependenciesRecursivelyCommand( + to_node(node), to_node(context), + olive::Node::Position(olive::PointF(x, y)))); + } catch (...) { + return NULL; + } +} + +int oaknode_node_get_markers(const OakNodeNode *node, + OakNodeMarkerList **out) +{ + if (!node || !out) { + return OAKNODE_E_INVALID; + } + + const olive::Node *n = to_node(node); + if (auto *v = dynamic_cast(n)) { + *out = reinterpret_cast(v->get_markers()); + } else { + *out = NULL; + } + return OAKNODE_OK; +} + +int oaknode_node_get_work_area(const OakNodeNode *node, + OakNodeWorkArea **out) +{ + if (!node || !out) { + return OAKNODE_E_INVALID; + } + + const olive::Node *n = to_node(node); + if (auto *v = dynamic_cast(n)) { + *out = reinterpret_cast(v->get_work_area()); + } else { + *out = NULL; + } + return OAKNODE_OK; +} diff --git a/src/node/c_api/sequence.cpp b/src/node/c_api/sequence.cpp index 3204a5328..88f147863 100644 --- a/src/node/c_api/sequence.cpp +++ b/src/node/c_api/sequence.cpp @@ -318,3 +318,8 @@ int oaknode_sequence_set_audio_params(OakNodeSequence *sequence, int index, } return OAKNODE_OK; } + +OakNodeNode *oaknode_sequence_as_node(OakNodeSequence *sequence) +{ + return reinterpret_cast(sequence); +} diff --git a/src/node/c_api/track.cpp b/src/node/c_api/track.cpp index ab635b986..cc577144c 100644 --- a/src/node/c_api/track.cpp +++ b/src/node/c_api/track.cpp @@ -24,6 +24,8 @@ #include "alivecount.h" +#include "valueconvert.h" + #include "block/block.h" #include "output/track/track.h" #include "output/track/tracklist.h" @@ -572,3 +574,89 @@ int oaknode_tracklist_remove_track(OakNodeTrackList *list, OakNodeTrack *track) } return OAKNODE_OK; } + +int oaknode_track_get_nearest_block_before_or_at(OakNodeTrack *track, + int numerator, int denominator, OakNodeBlock **out) +{ + olive::Track *t = impl(track); + if (!t || !out) { + return OAKNODE_E_INVALID; + } + *out = wrap_block( + t->nearest_block_before_or_at(olive::core::Rational(numerator, denominator))); + return OAKNODE_OK; +} + +int oaknode_track_get_nearest_block_after_or_at(OakNodeTrack *track, + int numerator, int denominator, OakNodeBlock **out) +{ + olive::Track *t = impl(track); + if (!t || !out) { + return OAKNODE_E_INVALID; + } + *out = wrap_block( + t->nearest_block_after_or_at(olive::core::Rational(numerator, denominator))); + return OAKNODE_OK; +} + +int oaknode_tracklist_get_sequence(OakNodeTrackList *list, + OakNodeSequence **out) +{ + if (!list || !out) { + return OAKNODE_E_INVALID; + } + *out = reinterpret_cast(list_impl(list)->parent()); + return OAKNODE_OK; +} + +int oaknode_tracklist_get_track_input_id(OakNodeTrackList *list, char *buf, + int buf_size) +{ + if (!list) { + return OAKNODE_E_INVALID; + } + return oaknode_c_api::copy_string(list_impl(list)->track_input(), buf, + buf_size); +} + +int oaknode_tracklist_array_append(OakNodeTrackList *list) +{ + if (!list) { + return OAKNODE_E_INVALID; + } + try { + list_impl(list)->array_append(); + return OAKNODE_OK; + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +int oaknode_tracklist_array_remove_last(OakNodeTrackList *list) +{ + if (!list) { + return OAKNODE_E_INVALID; + } + try { + list_impl(list)->array_remove_last(); + return OAKNODE_OK; + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +int oaknode_tracklist_get_array_index_from_cache_index( + OakNodeTrackList *list, int cache_index, int *out_index) +{ + if (!list || !out_index) { + return OAKNODE_E_INVALID; + } + *out_index = + list_impl(list)->get_array_index_from_cache_index(cache_index); + return OAKNODE_OK; +} + +OakNodeNode *oaknode_track_as_node(OakNodeTrack *track) +{ + return reinterpret_cast(track); +} diff --git a/src/node/src/block/block.cpp b/src/node/src/block/block.cpp index efe0253b2..22b4ded61 100644 --- a/src/node/src/block/block.cpp +++ b/src/node/src/block/block.cpp @@ -24,6 +24,8 @@ #include #include "inputdragger.h" + +#include "output/track/track.h" #include "sliderdisplaytype.h" namespace olive @@ -95,6 +97,12 @@ void Block::set_enabled(bool e) void Block::InputValueChangedEvent(const std::string &input, int element) { super::InputValueChangedEvent(input, element); + + if (input == k_length_input && track_) { + // Formerly the length_changed signal connected by Track::insert; + // direct call after de-Qt (see DEQT.md §7) + track_->block_length_changed(this); + } } void Block::set_length_internal(const Rational &length) diff --git a/src/node/src/output/viewer/viewer.cpp b/src/node/src/output/viewer/viewer.cpp index b6735ce4a..f8be3cd0b 100644 --- a/src/node/src/output/viewer/viewer.cpp +++ b/src/node/src/output/viewer/viewer.cpp @@ -77,8 +77,8 @@ ViewerOutput::ViewerOutput(bool create_buffer_inputs, set_flag(k_dont_show_in_param_view); - workarea_ = new TimelineWorkArea(this); - markers_ = new TimelineMarkerList(this); + workarea_ = std::make_unique(); + markers_ = std::make_unique(); } std::string ViewerOutput::name() const diff --git a/src/node/src/output/viewer/viewer.h b/src/node/src/output/viewer/viewer.h index 3576e9b0e..a9905cc3e 100644 --- a/src/node/src/output/viewer/viewer.h +++ b/src/node/src/output/viewer/viewer.h @@ -176,11 +176,11 @@ public: TimelineWorkArea *get_work_area() const { - return workarea_; + return workarea_.get(); } TimelineMarkerList *get_markers() const { - return markers_; + return markers_.get(); } virtual TimeRange get_video_cache_range() const override @@ -275,8 +275,8 @@ private: AudioParams cached_audio_params_; - TimelineWorkArea *workarea_; - TimelineMarkerList *markers_; + std::unique_ptr workarea_; + std::unique_ptr markers_; bool autocache_input_video_; bool autocache_input_audio_; diff --git a/src/node/src/project/sequence/sequence.cpp b/src/node/src/project/sequence/sequence.cpp index 439f43746..3302b728d 100644 --- a/src/node/src/project/sequence/sequence.cpp +++ b/src/node/src/project/sequence/sequence.cpp @@ -84,10 +84,10 @@ Sequence::~Sequence() void Sequence::add_default_nodes(MultiUndoCommand *command) { // Create tracks and connect them to the viewer - UndoCommand *video_track_command = - new TimelineAddTrackCommand(track_list(Track::k_video)); - UndoCommand *audio_track_command = - new TimelineAddTrackCommand(track_list(Track::k_audio)); + UndoCommand *video_track_command = new TimelineAddTrackCommand( + reinterpret_cast(track_list(Track::k_video))); + UndoCommand *audio_track_command = new TimelineAddTrackCommand( + reinterpret_cast(track_list(Track::k_audio))); if (command) { command->add_child(video_track_command); diff --git a/src/node/src/project/serializer/serializer210528.cpp b/src/node/src/project/serializer/serializer210528.cpp index 0d5919f5b..779b642bd 100644 --- a/src/node/src/project/serializer/serializer210528.cpp +++ b/src/node/src/project/serializer/serializer210528.cpp @@ -838,8 +838,8 @@ void ProjectSerializer210528::load_marker_list(XmlStreamReader *reader, // MarkerColor resolves through configaccessor.h // (oakcommon_config_* C ABI) - new TimelineMarker(OAK_CONFIG("MarkerColor").toInt(), - TimeRange(in, out), name, markers); + markers->add_marker(std::make_unique( + OAK_CONFIG("MarkerColor").toInt(), TimeRange(in, out), name)); } reader->skip_current_element(); diff --git a/src/node/src/project/serializer/serializer210907.cpp b/src/node/src/project/serializer/serializer210907.cpp index 5ec9da544..da363e910 100644 --- a/src/node/src/project/serializer/serializer210907.cpp +++ b/src/node/src/project/serializer/serializer210907.cpp @@ -827,8 +827,8 @@ void ProjectSerializer210907::load_marker_list(XmlStreamReader *reader, // MarkerColor resolves through configaccessor.h // (oakcommon_config_* C ABI) - new TimelineMarker(OAK_CONFIG("MarkerColor").toInt(), - TimeRange(in, out), name, markers); + markers->add_marker(std::make_unique( + OAK_CONFIG("MarkerColor").toInt(), TimeRange(in, out), name)); } reader->skip_current_element(); diff --git a/src/node/src/project/serializer/serializer211228.cpp b/src/node/src/project/serializer/serializer211228.cpp index 27230175c..b1635b8f0 100644 --- a/src/node/src/project/serializer/serializer211228.cpp +++ b/src/node/src/project/serializer/serializer211228.cpp @@ -881,8 +881,8 @@ void ProjectSerializer211228::load_marker_list(XmlStreamReader *reader, // MarkerColor resolves through configaccessor.h // (oakcommon_config_* C ABI) - new TimelineMarker(OAK_CONFIG("MarkerColor").toInt(), - TimeRange(in, out), name, markers); + markers->add_marker(std::make_unique( + OAK_CONFIG("MarkerColor").toInt(), TimeRange(in, out), name)); } reader->skip_current_element(); diff --git a/src/node/src/project/serializer/serializer220403.cpp b/src/node/src/project/serializer/serializer220403.cpp index 82bda62b8..4784784ff 100644 --- a/src/node/src/project/serializer/serializer220403.cpp +++ b/src/node/src/project/serializer/serializer220403.cpp @@ -1165,8 +1165,9 @@ void ProjectSerializer220403::load_marker_list(XmlStreamReader *reader, { while (xml_read_next_start_element(reader)) { if (reader->name() == "marker") { - TimelineMarker *marker = new TimelineMarker(markers); - load_marker(reader, marker); + auto marker = std::make_unique(); + load_marker(reader, marker.get()); + markers->add_marker(std::move(marker)); } else { reader->skip_current_element(); } diff --git a/src/node/standalone/CMakeLists.txt b/src/node/standalone/CMakeLists.txt index ee93e915e..83494718f 100644 --- a/src/node/standalone/CMakeLists.txt +++ b/src/node/standalone/CMakeLists.txt @@ -71,6 +71,16 @@ set(BUILD_TESTS ON) enable_testing() add_subdirectory(${OAK_REPO_ROOT}/src/node ${CMAKE_BINARY_DIR}/node) +# M4 收尾:oaknode 的 Sequence::add_default_nodes() 依赖 oaktimeline 的 +# TimelineAddTrackCommand(src/node/transition/timeline/* 已桥接真身), +# 两个库在运行期互相解析(dynamic_lookup) +set(BUILD_TESTS OFF) +add_subdirectory(${OAK_REPO_ROOT}/src/timeline ${CMAKE_BINARY_DIR}/timeline) +set(BUILD_TESTS ON) +target_link_options(oaktimeline PRIVATE + "-undefined" "dynamic_lookup" +) + # The transition stub dir must precede ${OAK_REPO_ROOT}/include so that e.g. # "common/current.h" resolves to the transitional forwarder, not the stale # public mirror in include/common. @@ -138,7 +148,7 @@ target_link_libraries(oakrender PRIVATE "-framework QuartzCore" ) -target_link_libraries(oaknode-gtest PRIVATE oakrender) +target_link_libraries(oaknode-gtest PRIVATE oakrender oaktimeline) # liboakrender references the oakengine_ipc_* C ABI (worker IPC) via # dynamic_lookup; the real implementation (engine/src/capi/ipc.cpp) is still diff --git a/src/node/transition/timeline/timelinecommon.h b/src/node/transition/timeline/timelinecommon.h index baf2a6b57..dffea4e6f 100644 --- a/src/node/transition/timeline/timelinecommon.h +++ b/src/node/transition/timeline/timelinecommon.h @@ -1,22 +1,3 @@ #pragma once -#include "olive/core/util/rational.h" -namespace olive { -enum class TrackType { k_track_type_none, k_track_type_video, k_track_type_audio, k_track_type_subtitle, k_track_type_count }; -class Block; -class Track; -class Timeline { -public: - enum MovementMode { k_none, k_move, k_trim_in, k_trim_out }; - enum ThumbnailMode { k_thumbnail_off, k_thumbnail_in_out, k_thumbnail_on }; - enum WaveformMode { k_waveforms_disabled, k_waveforms_enabled }; - static bool is_a_trim_mode(MovementMode mode) - { - return mode == k_trim_in || mode == k_trim_out; - } - struct EditToInfo { - Track *track; - core::Rational nearest_time; - Block *nearest_block; - }; -}; -} +// M4 收尾:桥接到 oaktimeline 真身 +#include "../../../timeline/src/timelinecommon.h" diff --git a/src/node/transition/timeline/timelinemarker.h b/src/node/transition/timeline/timelinemarker.h index 242bf3554..47cff4105 100644 --- a/src/node/transition/timeline/timelinemarker.h +++ b/src/node/transition/timeline/timelinemarker.h @@ -1,25 +1,3 @@ #pragma once -#include -#include "xmlutils.h" -#include "olive/core/util/timerange.h" -namespace olive { -using core::TimeRange; -class TimelineMarkerList { -public: - template explicit TimelineMarkerList(T *) {} - bool load(XmlStreamReader *) { return true; } - void save(XmlStreamWriter *) const {} -}; -class TimelineMarker { -public: - TimelineMarker() {} - explicit TimelineMarker(TimelineMarkerList *) {} - TimelineMarker(int, const TimeRange &, const std::string &, - TimelineMarkerList *) {} - void set_name(const std::string &) {} - void set_color(int) {} - void set_time(const TimeRange &) {} - bool load(XmlStreamReader *) { return true; } - void save(XmlStreamWriter *) const {} -}; -} +// M4 收尾:桥接到 oaktimeline 真身 +#include "../../../timeline/src/timelinemarker.h" diff --git a/src/node/transition/timeline/timelineundogeneral.h b/src/node/transition/timeline/timelineundogeneral.h index 4176025f1..dc0436416 100644 --- a/src/node/transition/timeline/timelineundogeneral.h +++ b/src/node/transition/timeline/timelineundogeneral.h @@ -1,15 +1,3 @@ #pragma once -// Transitional stub for engine/timeline/timelineundogeneral.h (still -// Qt-based). Only the surface oaknode uses. M4/M7 replace this with the real -// oaktimeline boundary. -#include "undocommand.h" -namespace olive { -class TrackList; -class TimelineAddTrackCommand : public UndoCommand { -public: - explicit TimelineAddTrackCommand(TrackList *) {} -protected: - void redo() override {} - void undo() override {} -}; -} +// M4 收尾:桥接到 oaktimeline 真身 +#include "../../../timeline/src/timelineundogeneral.h" diff --git a/src/node/transition/timeline/timelineworkarea.h b/src/node/transition/timeline/timelineworkarea.h index 74b62c148..2f02eb677 100644 --- a/src/node/transition/timeline/timelineworkarea.h +++ b/src/node/transition/timeline/timelineworkarea.h @@ -1,21 +1,3 @@ #pragma once -#include "xmlutils.h" -#include "olive/core/util/rational.h" -#include "olive/core/util/timerange.h" -namespace olive { -using core::Rational; -using core::TimeRange; -class TimelineWorkArea { -public: - template explicit TimelineWorkArea(T *) {} - const Rational &in() const { return in_; } - const Rational &out() const { return out_; } - void set_enabled(bool) {} - void set_range(const TimeRange &) {} - TimeRange range() const { return TimeRange(); } - bool load(XmlStreamReader *) { return true; } - void save(XmlStreamWriter *) const {} -private: - Rational in_, out_; -}; -} +// M4 收尾:桥接到 oaktimeline 真身 +#include "../../../timeline/src/timelineworkarea.h" diff --git a/src/render/standalone/CMakeLists.txt b/src/render/standalone/CMakeLists.txt index 783694860..0656b7f40 100644 --- a/src/render/standalone/CMakeLists.txt +++ b/src/render/standalone/CMakeLists.txt @@ -59,6 +59,15 @@ set(BUILD_TESTS OFF) add_subdirectory(${OAK_REPO_ROOT}/src/undo ${CMAKE_BINARY_DIR}/undo) add_subdirectory(${OAK_REPO_ROOT}/src/common ${CMAKE_BINARY_DIR}/common) add_subdirectory(${OAK_REPO_ROOT}/src/node ${CMAKE_BINARY_DIR}/node) + +# M4 收尾:oaknode 运行期依赖 oaktimeline(add_default_nodes -> +# TimelineAddTrackCommand),测试二进制须链入 +set(BUILD_TESTS OFF) +add_subdirectory(${OAK_REPO_ROOT}/src/timeline ${CMAKE_BINARY_DIR}/timeline) +set(BUILD_TESTS ON) +target_link_options(oaktimeline PRIVATE + "-undefined" "dynamic_lookup" +) set(BUILD_TESTS ON) # oaknode needs its transition stubs too when built in this tree (see diff --git a/src/render/tests/CMakeLists.txt b/src/render/tests/CMakeLists.txt index eb71c3dd6..45dd9418f 100644 --- a/src/render/tests/CMakeLists.txt +++ b/src/render/tests/CMakeLists.txt @@ -24,6 +24,7 @@ add_executable(oakrender-gtest ) target_link_libraries(oakrender-gtest PRIVATE + oaktimeline oakrender oaknode oakcommon diff --git a/src/timeline/CMakeLists.txt b/src/timeline/CMakeLists.txt new file mode 100644 index 000000000..f41570121 --- /dev/null +++ b/src/timeline/CMakeLists.txt @@ -0,0 +1,6 @@ +add_subdirectory(src) +add_subdirectory(c_api) + +if(BUILD_TESTS) + add_subdirectory(tests) +endif() diff --git a/src/timeline/c_api/CMakeLists.txt b/src/timeline/c_api/CMakeLists.txt new file mode 100644 index 000000000..abfd9f957 --- /dev/null +++ b/src/timeline/c_api/CMakeLists.txt @@ -0,0 +1,5 @@ +target_sources(oaktimeline PRIVATE + marker.cpp + workarea.cpp + edit.cpp +) diff --git a/src/timeline/c_api/edit.cpp b/src/timeline/c_api/edit.cpp new file mode 100644 index 000000000..3e36dad6a --- /dev/null +++ b/src/timeline/c_api/edit.cpp @@ -0,0 +1,263 @@ +/*** + + Oak Video Editor - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "timeline/edit.h" + +#include +#include + +#include "../src/timelineundogeneral.h" +#include "../src/timelineundopointer.h" +#include "../src/timelineundoripple.h" +#include "../src/timelineundosplit.h" +#include "../../undo/c_api/commandhandle.h" + +namespace +{ + +OakUndoCommand *wrap_command(olive::UndoCommand *command) +{ + if (!command) { + return NULL; + } + OakUndoCommand *handle = new (std::nothrow) OakUndoCommand{command, true}; + if (!handle) { + delete command; + } + return handle; +} + +olive::core::Rational rat(int64_t n, int64_t d) +{ + return olive::core::Rational(int(n), int(d)); +} + +olive::Timeline::MovementMode to_mode(int mode) +{ + return static_cast(mode); +} + +} // namespace + +OakUndoCommand *oaktimeline_add_track_command(OakNodeTrackList *list) +{ + if (!list) { + return NULL; + } + + try { + return wrap_command(new olive::TimelineAddTrackCommand(list)); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_remove_track_command(OakNodeTrack *track) +{ + if (!track) { + return NULL; + } + + try { + return wrap_command(new olive::TimelineRemoveTrackCommand(track)); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_place_block_command(OakNodeTrackList *list, + int track_index, + OakNodeBlock *block, + int64_t in_num, + int64_t in_den) +{ + if (!list || !block) { + return NULL; + } + + try { + return wrap_command(new olive::TrackPlaceBlockCommand( + list, track_index, block, rat(in_num, in_den))); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_replace_block_with_gap_command( + OakNodeTrack *track, OakNodeBlock *block) +{ + if (!track || !block) { + return NULL; + } + + try { + return wrap_command( + new olive::TrackReplaceBlockWithGapCommand(track, block)); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_trim_command(OakNodeTrack *track, + OakNodeBlock *block, + int64_t new_length_num, + int64_t new_length_den, int mode) +{ + if (!track || !block || (mode != OAKTIMELINE_MOVEMENT_TRIM_IN && + mode != OAKTIMELINE_MOVEMENT_TRIM_OUT)) { + return NULL; + } + + try { + return wrap_command( + new olive::BlockTrimCommand(track, block, + rat(new_length_num, new_length_den), + to_mode(mode))); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_split_command(OakNodeBlock *const *blocks, + int count, int64_t point_num, + int64_t point_den) +{ + if (!blocks || count <= 0) { + return NULL; + } + + try { + auto *multi = new olive::MultiUndoCommand(); + for (int i = 0; i < count; i++) { + if (blocks[i]) { + multi->add_child(new olive::BlockSplitCommand( + blocks[i], rat(point_num, point_den))); + } + } + return wrap_command(multi); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_split_preserving_links_command( + OakNodeBlock *const *blocks, int count, const int64_t *point_nums, + const int64_t *point_dens, int time_count) +{ + if (!blocks || count <= 0 || !point_nums || !point_dens || + time_count <= 0) { + return NULL; + } + + try { + std::vector block_vec(blocks, blocks + count); + std::vector times; + times.reserve(time_count); + for (int i = 0; i < time_count; i++) { + times.push_back(rat(point_nums[i], point_dens[i])); + } + return wrap_command(new olive::BlockSplitPreservingLinksCommand( + block_vec, times)); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_ripple_delete_gaps_command( + OakNodeSequence *sequence, const int64_t *in_nums, + const int64_t *in_dens, const int64_t *out_nums, + const int64_t *out_dens, OakNodeTrack *const *tracks, int range_count) +{ + if (!sequence || !in_nums || !in_dens || !out_nums || !out_dens || + !tracks || range_count <= 0) { + return NULL; + } + + try { + olive::TimelineRippleDeleteGapsAtRegionsCommand::RangeList regions; + regions.reserve(range_count); + for (int i = 0; i < range_count; i++) { + regions.emplace_back( + tracks[i], + olive::core::TimeRange(rat(in_nums[i], in_dens[i]), + rat(out_nums[i], out_dens[i]))); + } + return wrap_command( + new olive::TimelineRippleDeleteGapsAtRegionsCommand(sequence, + regions)); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_slide_command( + OakNodeTrack *track, OakNodeBlock *const *blocks, int block_count, + OakNodeBlock *in_adjacent, OakNodeBlock *out_adjacent, + int64_t movement_num, int64_t movement_den) +{ + if (!track || !blocks || block_count <= 0) { + return NULL; + } + + try { + std::vector block_vec(blocks, blocks + block_count); + return wrap_command(new olive::TrackSlideCommand( + track, block_vec, in_adjacent, out_adjacent, + rat(movement_num, movement_den))); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_ripple_remove_area_command( + OakNodeTrack *track, int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den) +{ + if (!track) { + return NULL; + } + + try { + return wrap_command(new olive::TrackRippleRemoveAreaCommand( + track, olive::core::TimeRange(rat(in_num, in_den), + rat(out_num, out_den)))); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_insert_gaps_command(OakNodeTrackList *list, + int64_t point_num, + int64_t point_den, + int64_t length_num, + int64_t length_den) +{ + if (!list) { + return NULL; + } + + try { + return wrap_command( + new olive::TrackListInsertGaps(list, rat(point_num, point_den), + rat(length_num, length_den))); + } catch (...) { + return NULL; + } +} diff --git a/src/timeline/c_api/marker.cpp b/src/timeline/c_api/marker.cpp new file mode 100644 index 000000000..15b4f739f --- /dev/null +++ b/src/timeline/c_api/marker.cpp @@ -0,0 +1,247 @@ +/*** + + Oak Video Editor - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "timeline/marker.h" + +#include + +#include "../src/timelinemarker.h" +#include "../../undo/c_api/commandhandle.h" + +namespace +{ + +olive::TimelineMarkerList *impl(OakTimelineMarkerList *h) +{ + return reinterpret_cast(h); +} + +const olive::TimelineMarkerList *impl(const OakTimelineMarkerList *h) +{ + return reinterpret_cast(h); +} + +OakTimelineMarkerList *wrap(olive::TimelineMarkerList *l) +{ + return reinterpret_cast(l); +} + +OakUndoCommand *wrap_command(olive::UndoCommand *command) +{ + if (!command) { + return NULL; + } + OakUndoCommand *handle = new (std::nothrow) OakUndoCommand{command, true}; + if (!handle) { + delete command; + } + return handle; +} + +olive::core::Rational rat(int n, int d) +{ + return olive::core::Rational(n, d); +} + +olive::TimelineMarker *marker_at(OakTimelineMarkerList *list, int index) +{ + if (!list || index < 0 || index >= int(impl(list)->size())) { + return nullptr; + } + return impl(list)->at(index); +} + +} // namespace + +OakTimelineMarkerList *oaktimeline_marker_list_of(OakNodeNode *owner) +{ + if (!owner) { + return NULL; + } + + OakNodeMarkerList *markers = NULL; + if (oaknode_node_get_markers(owner, &markers) != OAKNODE_OK) { + return NULL; + } + return reinterpret_cast(markers); +} + +int oaktimeline_marker_count(const OakTimelineMarkerList *list, + int *out_count) +{ + if (!list || !out_count) { + return OAKTIMELINE_E_INVALID; + } + *out_count = int(impl(list)->size()); + return OAKTIMELINE_OK; +} + +int oaktimeline_marker_at(const OakTimelineMarkerList *list, int index, + int *in_num, int *in_den, int *out_num, int *out_den, + int *color, char *name_buf, int buf_size) +{ + if (!list) { + return OAKTIMELINE_E_INVALID; + } + if (index < 0 || index >= int(impl(list)->size())) { + return OAKTIMELINE_E_NOT_FOUND; + } + + const olive::TimelineMarker *m = impl(list)->at(index); + + if (in_num) { + *in_num = m->time().in().numerator(); + } + if (in_den) { + *in_den = m->time().in().denominator(); + } + if (out_num) { + *out_num = m->time().out().numerator(); + } + if (out_den) { + *out_den = m->time().out().denominator(); + } + if (color) { + *color = m->color(); + } + + const std::string &name = m->name(); + int needed = int(name.size()) + 1; + if (name_buf && buf_size >= needed) { + memcpy(name_buf, name.c_str(), needed); + } + return needed; +} + +OakUndoCommand *oaktimeline_marker_add_command( + OakTimelineMarkerList *list, int in_num, int in_den, int out_num, + int out_den, const char *name, int color) +{ + if (!list) { + return NULL; + } + + try { + return wrap_command(new olive::MarkerAddCommand( + impl(list), + olive::core::TimeRange(rat(int(in_num), int(in_den)), + rat(int(out_num), int(out_den))), + name ? name : "", color)); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_marker_remove_at_command( + OakTimelineMarkerList *list, int index) +{ + olive::TimelineMarker *m = marker_at(list, index); + if (!m) { + return NULL; + } + + try { + return wrap_command(new olive::MarkerRemoveCommand(m, impl(list))); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_marker_set_time_command( + OakTimelineMarkerList *list, int index, int in_num, int in_den, + int out_num, int out_den) +{ + olive::TimelineMarker *m = marker_at(list, index); + if (!m) { + return NULL; + } + + try { + return wrap_command(new olive::MarkerChangeTimeCommand( + m, olive::core::TimeRange(rat(int(in_num), int(in_den)), + rat(int(out_num), int(out_den))))); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_marker_set_props_command( + OakTimelineMarkerList *list, int index, int color, const char *name) +{ + olive::TimelineMarker *m = marker_at(list, index); + if (!m || (color < 0 && !name)) { + return NULL; + } + + try { + if (color >= 0 && name) { + auto *multi = new olive::MultiUndoCommand(); + multi->add_child(new olive::MarkerChangeColorCommand(m, color)); + multi->add_child(new olive::MarkerChangeNameCommand(m, name)); + return wrap_command(multi); + } + if (color >= 0) { + return wrap_command(new olive::MarkerChangeColorCommand(m, color)); + } + return wrap_command(new olive::MarkerChangeNameCommand(m, name)); + } catch (...) { + return NULL; + } +} + +int oaktimeline_marker_list_load(OakTimelineMarkerList *list, + OakXmlReader reader) +{ + if (!list || !reader.ctx) { + return OAKTIMELINE_E_INVALID; + } + + olive::XmlStreamReader *native = oakcommon_xml_reader_get_native(reader); + if (!native) { + return OAKTIMELINE_E_INVALID; + } + + try { + return impl(list)->load(native) ? OAKTIMELINE_OK + : OAKTIMELINE_E_FAILED; + } catch (...) { + return OAKTIMELINE_E_FAILED; + } +} + +int oaktimeline_marker_list_save(const OakTimelineMarkerList *list, + OakXmlWriter writer) +{ + if (!list || !writer.ctx) { + return OAKTIMELINE_E_INVALID; + } + + olive::XmlStreamWriter *native = oakcommon_xml_writer_get_native(writer); + if (!native) { + return OAKTIMELINE_E_INVALID; + } + + try { + impl(list)->save(native); + return OAKTIMELINE_OK; + } catch (...) { + return OAKTIMELINE_E_FAILED; + } +} diff --git a/src/timeline/c_api/workarea.cpp b/src/timeline/c_api/workarea.cpp new file mode 100644 index 000000000..0f5c7048b --- /dev/null +++ b/src/timeline/c_api/workarea.cpp @@ -0,0 +1,201 @@ +/*** + + Oak Video Editor - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "timeline/workarea.h" + +#include + +#include "../src/timelineundoworkarea.h" +#include "../src/timelineworkarea.h" +#include "../../undo/c_api/commandhandle.h" + +namespace +{ + +olive::TimelineWorkArea *impl(OakTimelineWorkArea *h) +{ + return reinterpret_cast(h); +} + +const olive::TimelineWorkArea *impl(const OakTimelineWorkArea *h) +{ + return reinterpret_cast(h); +} + +OakUndoCommand *wrap_command(olive::UndoCommand *command) +{ + if (!command) { + return NULL; + } + OakUndoCommand *handle = new (std::nothrow) OakUndoCommand{command, true}; + if (!handle) { + delete command; + } + return handle; +} + +olive::core::Rational rat(int64_t n, int64_t d) +{ + return olive::core::Rational(int(n), int(d)); +} + +} // namespace + +OakTimelineWorkArea *oaktimeline_workarea_of(OakNodeNode *owner) +{ + if (!owner) { + return NULL; + } + + OakNodeWorkArea *workarea = NULL; + if (oaknode_node_get_work_area(owner, &workarea) != OAKNODE_OK) { + return NULL; + } + return reinterpret_cast(workarea); +} + +int oaktimeline_workarea_get(const OakTimelineWorkArea *w, int *in_num, + int *in_den, int *out_num, int *out_den, + int *enabled) +{ + if (!w) { + return OAKTIMELINE_E_INVALID; + } + + if (in_num) { + *in_num = impl(w)->in().numerator(); + } + if (in_den) { + *in_den = impl(w)->in().denominator(); + } + if (out_num) { + *out_num = impl(w)->out().numerator(); + } + if (out_den) { + *out_den = impl(w)->out().denominator(); + } + if (enabled) { + *enabled = impl(w)->enabled() ? 1 : 0; + } + return OAKTIMELINE_OK; +} + +int oaktimeline_workarea_set_range(OakTimelineWorkArea *w, int in_num, + int in_den, int out_num, int out_den) +{ + if (!w) { + return OAKTIMELINE_E_INVALID; + } + + try { + impl(w)->set_range( + olive::core::TimeRange(rat(in_num, in_den), rat(out_num, out_den))); + return OAKTIMELINE_OK; + } catch (...) { + return OAKTIMELINE_E_FAILED; + } +} + +OakUndoCommand *oaktimeline_workarea_set_range_command( + OakTimelineWorkArea *w, int in_num, int in_den, int out_num, + int out_den, int old_in_num, int old_in_den, int old_out_num, + int old_out_den) +{ + if (!w) { + return NULL; + } + + try { + olive::core::TimeRange range(rat(in_num, in_den), rat(out_num, out_den)); + olive::core::TimeRange old_range(rat(old_in_num, old_in_den), + rat(old_out_num, old_out_den)); + return wrap_command( + new olive::WorkareaSetRangeCommand(impl(w), range, old_range)); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktimeline_workarea_set_enabled_command( + OakTimelineWorkArea *w, int enabled) +{ + if (!w) { + return NULL; + } + + try { + return wrap_command( + new olive::WorkareaSetEnabledCommand(impl(w), enabled != 0)); + } catch (...) { + return NULL; + } +} + +int oaktimeline_workarea_reset(int *in_num, int *in_den, int *out_num, + int *out_den) +{ + if (!in_num || !in_den || !out_num || !out_den) { + return OAKTIMELINE_E_INVALID; + } + + *in_num = olive::TimelineWorkArea::k_reset_in.numerator(); + *in_den = olive::TimelineWorkArea::k_reset_in.denominator(); + *out_num = olive::TimelineWorkArea::k_reset_out.numerator(); + *out_den = olive::TimelineWorkArea::k_reset_out.denominator(); + return OAKTIMELINE_OK; +} + +int oaktimeline_workarea_load(OakTimelineWorkArea *w, OakXmlReader reader) +{ + if (!w || !reader.ctx) { + return OAKTIMELINE_E_INVALID; + } + + olive::XmlStreamReader *native = oakcommon_xml_reader_get_native(reader); + if (!native) { + return OAKTIMELINE_E_INVALID; + } + + try { + return impl(w)->load(native) ? OAKTIMELINE_OK : OAKTIMELINE_E_FAILED; + } catch (...) { + return OAKTIMELINE_E_FAILED; + } +} + +int oaktimeline_workarea_save(const OakTimelineWorkArea *w, + OakXmlWriter writer) +{ + if (!w || !writer.ctx) { + return OAKTIMELINE_E_INVALID; + } + + olive::XmlStreamWriter *native = oakcommon_xml_writer_get_native(writer); + if (!native) { + return OAKTIMELINE_E_INVALID; + } + + try { + impl(w)->save(native); + return OAKTIMELINE_OK; + } catch (...) { + return OAKTIMELINE_E_FAILED; + } +} diff --git a/src/timeline/src/CMakeLists.txt b/src/timeline/src/CMakeLists.txt new file mode 100644 index 000000000..66d5963e6 --- /dev/null +++ b/src/timeline/src/CMakeLists.txt @@ -0,0 +1,35 @@ +# Olive - Non-Linear Video Editor +# Copyright (C) 2022 Olive Team +# Modifications Copyright (C) 2025 mikesolar +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +file(GLOB_RECURSE OAKTIMELINE_SOURCES CONFIGURE_DEPENDS *.cpp) + +add_library(oaktimeline SHARED ${OAKTIMELINE_SOURCES}) + +target_include_directories(oaktimeline PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${OAK_REPO_ROOT}/include + ${OAK_REPO_ROOT}/src/common/src + ${OAK_REPO_ROOT}/src/undo/src + ${OAK_REPO_ROOT}/core/include +) + +target_link_libraries(oaktimeline PUBLIC + oaknode + oakundo + oakcommon + olivecore +) diff --git a/src/timeline/src/timelinecommon.h b/src/timeline/src/timelinecommon.h new file mode 100644 index 000000000..a4fb36854 --- /dev/null +++ b/src/timeline/src/timelinecommon.h @@ -0,0 +1,60 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINECOMMON_H +#define OAK_TIMELINECOMMON_H + +#include + + +using namespace olive::core; + +namespace olive +{ + +class Block; +class Track; + +class Timeline { +public: + enum MovementMode { k_none, k_move, k_trim_in, k_trim_out }; + + enum ThumbnailMode { k_thumbnail_off, k_thumbnail_in_out, k_thumbnail_on }; + + enum WaveformMode { k_waveforms_disabled, k_waveforms_enabled }; + + static bool is_a_trim_mode(MovementMode mode) + { + return mode == k_trim_in || mode == k_trim_out; + } + + struct EditToInfo { + Track *track; + Rational nearest_time; + Block *nearest_block; + }; +}; + +#define PLAYHEAD_COLOR palette().highlight().color() + +} + +#endif // OAK_TIMELINECOMMON_H diff --git a/src/timeline/src/timelinemarker.cpp b/src/timeline/src/timelinemarker.cpp new file mode 100644 index 000000000..fb1d7bee7 --- /dev/null +++ b/src/timeline/src/timelinemarker.cpp @@ -0,0 +1,291 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "timelinemarker.h" + +#include + +#include "common/config.h" + +namespace olive +{ + +TimelineMarker::TimelineMarker() + : color_(oakcommon_config_get_int(NULL, "MarkerColor", 0)) + , parent_(nullptr) +{ +} + +TimelineMarker::TimelineMarker(int color, const TimeRange &time, + const std::string &name) + : time_(time) + , name_(name) + , color_(color) + , parent_(nullptr) +{ +} + +void TimelineMarker::set_time(const TimeRange &time) +{ + time_ = time; + + // Formerly the time_changed signal; the list must re-sort + if (parent_) { + parent_->resort(this); + } +} + +void TimelineMarker::set_time(const Rational &time) +{ + set_time(TimeRange(time, time + time_.length())); +} + +bool TimelineMarker::has_sibling_at_time(const Rational &t) const +{ + if (!parent_) { + return false; + } + + TimelineMarker *m = parent_->get_marker_at_time(t); + return m && m != this; +} + +void TimelineMarker::set_name(const std::string &name) +{ + name_ = name; +} + +void TimelineMarker::set_color(int c) +{ + color_ = c; +} + +bool TimelineMarker::load(XmlStreamReader *reader) +{ + Rational in, out; + + for (const XmlStreamAttribute &attr : reader->attributes()) { + if (attr.name == "name") { + this->set_name(attr.value); + } else if (attr.name == "in") { + in = Rational::from_string(attr.value); + } else if (attr.name == "out") { + out = Rational::from_string(attr.value); + } else if (attr.name == "color") { + this->set_color(atoi(attr.value.c_str())); + } + } + + this->set_time(TimeRange(in, out)); + + // This element has no inner text, so just skip it + reader->skip_current_element(); + + return true; +} + +void TimelineMarker::save(XmlStreamWriter *writer) const +{ + writer->write_attribute("name", this->name()); + writer->write_attribute("in", this->time().in().to_string()); + writer->write_attribute("out", this->time().out().to_string()); + writer->write_attribute("color", std::to_string(this->color())); +} + +bool TimelineMarkerList::load(XmlStreamReader *reader) +{ + while (xml_read_next_start_element(reader)) { + if (reader->name() == "marker") { + auto marker = std::make_unique(); + if (!marker->load(reader)) { + return false; + } + this->add_marker(std::move(marker)); + } else { + reader->skip_current_element(); + } + } + + return true; +} + +void TimelineMarkerList::save(XmlStreamWriter *writer) const +{ + for (const auto &marker : markers_) { + writer->write_start_element("marker"); + + marker->save(writer); + + writer->write_end_element(); // marker + } +} + +void TimelineMarkerList::add_marker(std::unique_ptr marker) +{ + marker->parent_ = this; + insert_into_list(std::move(marker)); +} + +std::unique_ptr +TimelineMarkerList::remove_marker(TimelineMarker *m) +{ + for (auto it = markers_.begin(); it != markers_.end(); it++) { + if (it->get() == m) { + std::unique_ptr removed = std::move(*it); + markers_.erase(it); + removed->parent_ = nullptr; + return removed; + } + } + + return nullptr; +} + +void TimelineMarkerList::resort(TimelineMarker *m) +{ + std::unique_ptr holder = remove_marker(m); + if (holder) { + insert_into_list(std::move(holder)); + } +} + +void TimelineMarkerList::insert_into_list( + std::unique_ptr marker) +{ + // Insertion sort by time to allow some loop optimizations + for (auto it = markers_.begin(); it != markers_.end(); it++) { + if ((*it)->time().in() > marker->time().in()) { + markers_.insert(it, std::move(marker)); + return; + } + } + + markers_.push_back(std::move(marker)); +} + +MarkerAddCommand::MarkerAddCommand(TimelineMarkerList *marker_list, + const TimeRange &range, + const std::string &name, int color) + : marker_list_(marker_list) + , added_marker_(nullptr) +{ + memory_manager_ = + std::make_unique(color, range, name); + added_marker_ = memory_manager_.get(); +} + +MarkerAddCommand::MarkerAddCommand( + TimelineMarkerList *marker_list, std::unique_ptr marker) + : marker_list_(marker_list) + , added_marker_(marker.get()) +{ + memory_manager_ = std::move(marker); +} + +void MarkerAddCommand::redo() +{ + if (!memory_manager_) { + // Marker is already in the list (redo after redo) + return; + } + marker_list_->add_marker(std::move(memory_manager_)); +} + +void MarkerAddCommand::undo() +{ + memory_manager_ = marker_list_->remove_marker(added_marker_); +} + +MarkerRemoveCommand::MarkerRemoveCommand(TimelineMarker *marker, + TimelineMarkerList *marker_list) + : marker_(marker) + , marker_list_(marker_list) +{ +} + +void MarkerRemoveCommand::redo() +{ + memory_manager_ = marker_list_->remove_marker(marker_); +} + +void MarkerRemoveCommand::undo() +{ + if (memory_manager_) { + marker_list_->add_marker(std::move(memory_manager_)); + } +} + +MarkerChangeColorCommand::MarkerChangeColorCommand(TimelineMarker *marker, + int new_color) + : marker_(marker) + , old_color_(marker->color()) + , new_color_(new_color) +{ +} + +void MarkerChangeColorCommand::redo() +{ + marker_->set_color(new_color_); +} + +void MarkerChangeColorCommand::undo() +{ + marker_->set_color(old_color_); +} + +MarkerChangeNameCommand::MarkerChangeNameCommand(TimelineMarker *marker, + std::string new_name) + : marker_(marker) + , old_name_(marker->name()) + , new_name_(std::move(new_name)) +{ +} + +void MarkerChangeNameCommand::redo() +{ + marker_->set_name(new_name_); +} + +void MarkerChangeNameCommand::undo() +{ + marker_->set_name(old_name_); +} + +MarkerChangeTimeCommand::MarkerChangeTimeCommand(TimelineMarker *marker, + const TimeRange &time, + const TimeRange &old_time) + : marker_(marker) + , old_time_(old_time) + , new_time_(time) +{ +} + +void MarkerChangeTimeCommand::redo() +{ + marker_->set_time(new_time_); +} + +void MarkerChangeTimeCommand::undo() +{ + marker_->set_time(old_time_); +} + +} diff --git a/src/timeline/src/timelinemarker.h b/src/timeline/src/timelinemarker.h new file mode 100644 index 000000000..00f6df394 --- /dev/null +++ b/src/timeline/src/timelinemarker.h @@ -0,0 +1,269 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEMARKER_H +#define OAK_TIMELINEMARKER_H + +#include +#include +#include + +#include + +#include "undocommand.h" +#include "xmlutils.h" + +using namespace olive::core; + +namespace olive +{ + +class TimelineMarkerList; + +/** + * @brief A marker on a timeline, with a time range, a name and a color + * + * De-Qt version: no QObject, no signals. Modification notifications are + * emitted by the caller's layer (facade). The draw() helper moved to the + * app layer. + */ +class TimelineMarker { +public: + TimelineMarker(); + TimelineMarker(int color, const TimeRange &time, + const std::string &name = std::string()); + + const TimeRange &time() const + { + return time_; + } + void set_time(const TimeRange &time); + void set_time(const Rational &time); + + bool has_sibling_at_time(const Rational &t) const; + + const std::string &name() const + { + return name_; + } + void set_name(const std::string &name); + + int color() const + { + return color_; + } + void set_color(int c); + + bool load(XmlStreamReader *reader); + void save(XmlStreamWriter *writer) const; + +private: + TimeRange time_; + + std::string name_; + + int color_; + + TimelineMarkerList *parent_; + + friend class TimelineMarkerList; +}; + +class TimelineMarkerList { +public: + TimelineMarkerList() = default; + + inline bool empty() const + { + return markers_.empty(); + } + inline size_t size() const + { + return markers_.size(); + } + inline TimelineMarker *at(size_t i) const + { + return markers_[i].get(); + } + inline TimelineMarker *back() const + { + return markers_.back().get(); + } + inline TimelineMarker *front() const + { + return markers_.front().get(); + } + + bool load(XmlStreamReader *reader); + void save(XmlStreamWriter *writer) const; + + /** + * @brief Insert a marker, keeping the list sorted by time + * + * Takes ownership. Replaces the old QObject childEvent + * auto-registration. + */ + void add_marker(std::unique_ptr marker); + + /** + * @brief Detach a marker from the list, returning ownership + * + * Returns nullptr if the marker is not in the list. + */ + std::unique_ptr remove_marker(TimelineMarker *m); + + TimelineMarker *get_marker_at_time(const Rational &t) const + { + for (const auto &m : markers_) { + if (m->time().in() == t) { + return m.get(); + } + } + + return nullptr; + } + + TimelineMarker *get_closest_marker_to_time(const Rational &t) const + { + TimelineMarker *closest = nullptr; + + for (const auto &m : markers_) { + Rational this_diff = rational_abs(m->time().in() - t); + + if (closest) { + Rational stored_diff = rational_abs(closest->time().in() - t); + + if (this_diff > stored_diff) { + // Since the list is organized by time, if the diff + // increases, assume we are only going to move further + // away from here and there's no need to check + break; + } + } + + closest = m.get(); + } + + return closest; + } + + /** + * @brief Re-sort a marker after its time changed + * + * Formerly triggered by the marker's time_changed signal; now called + * directly by TimelineMarker::set_time(). + */ + void resort(TimelineMarker *m); + +private: + static Rational rational_abs(const Rational &r) + { + return r < Rational(0) ? -r : r; + } + + void insert_into_list(std::unique_ptr marker); + + std::vector> markers_; +}; + +class MarkerAddCommand : public UndoCommand { +public: + MarkerAddCommand(TimelineMarkerList *marker_list, const TimeRange &range, + const std::string &name, int color); + MarkerAddCommand(TimelineMarkerList *marker_list, + std::unique_ptr marker); + +protected: + virtual void redo() override; + virtual void undo() override; + +private: + TimelineMarkerList *marker_list_; + + TimelineMarker *added_marker_; + std::unique_ptr memory_manager_; +}; + +class MarkerRemoveCommand : public UndoCommand { +public: + MarkerRemoveCommand(TimelineMarker *marker, TimelineMarkerList *marker_list); + +protected: + virtual void redo() override; + virtual void undo() override; + +private: + TimelineMarker *marker_; + TimelineMarkerList *marker_list_; + + std::unique_ptr memory_manager_; +}; + +class MarkerChangeColorCommand : public UndoCommand { +public: + MarkerChangeColorCommand(TimelineMarker *marker, int new_color); + +protected: + virtual void redo() override; + virtual void undo() override; + +private: + TimelineMarker *marker_; + int old_color_; + int new_color_; +}; + +class MarkerChangeNameCommand : public UndoCommand { +public: + MarkerChangeNameCommand(TimelineMarker *marker, std::string name); + +protected: + virtual void redo() override; + virtual void undo() override; + +private: + TimelineMarker *marker_; + std::string old_name_; + std::string new_name_; +}; + +class MarkerChangeTimeCommand : public UndoCommand { +public: + MarkerChangeTimeCommand(TimelineMarker *marker, const TimeRange &time, + const TimeRange &old_time); + MarkerChangeTimeCommand(TimelineMarker *marker, const TimeRange &time) + : MarkerChangeTimeCommand(marker, time, marker->time()) + { + } + +protected: + virtual void redo() override; + virtual void undo() override; + +private: + TimelineMarker *marker_; + TimeRange old_time_; + TimeRange new_time_; +}; + +} + +#endif // OAK_TIMELINEMARKER_H diff --git a/src/timeline/src/timelineundocommon.h b/src/timeline/src/timelineundocommon.h new file mode 100644 index 000000000..b527bff24 --- /dev/null +++ b/src/timeline/src/timelineundocommon.h @@ -0,0 +1,109 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEUNDOCOMMON_H +#define OAK_TIMELINEUNDOCOMMON_H + +#include "node/node.h" +#include "undo/undocommand.h" + +namespace olive +{ + +inline bool node_can_be_removed(OakNodeNode *n) +{ + int count = 0; + oaknode_node_output_connection_count(n, &count); + return count == 0; +} + +inline bool node_can_be_removed(OakNodeBlock *b) +{ + return node_can_be_removed(oaknode_block_as_node(b)); +} + +inline OakUndoCommand *create_remove_command(OakNodeNode *n) +{ + return oaknode_command_create_remove_node(n); +} + +inline OakUndoCommand *create_remove_command(OakNodeBlock *b) +{ + return oaknode_command_create_remove_node(oaknode_block_as_node(b)); +} + +inline OakUndoCommand *create_and_run_remove_command(OakNodeNode *n) +{ + OakUndoCommand *command = create_remove_command(n); + oakundo_command_redo_now(command); + return command; +} + +inline OakUndoCommand *create_and_run_remove_command(OakNodeBlock *b) +{ + return create_and_run_remove_command(oaknode_block_as_node(b)); +} + +inline void free_remove_command(OakUndoCommand *command) +{ + oakundo_command_free(command); +} + +/** + * @brief Wrap an oakundo C command handle as an olive::UndoCommand so it + * can live in this module's C++ command trees + */ +class CHandleCommandWrapper : public UndoCommand { +public: + CHandleCommandWrapper(OakUndoCommand *command) + : command_(command) + { + } + + virtual ~CHandleCommandWrapper() override + { + if (command_) { + oakundo_command_free(command_); + } + } + +protected: + virtual void redo() override + { + if (command_) { + oakundo_command_redo_now(command_); + } + } + + virtual void undo() override + { + if (command_) { + oakundo_command_undo_now(command_); + } + } + +private: + OakUndoCommand *command_; +}; + +} + +#endif // OAK_TIMELINEUNDOCOMMON_H diff --git a/src/timeline/src/timelineundogeneral.cpp b/src/timeline/src/timelineundogeneral.cpp new file mode 100644 index 000000000..e8542b581 --- /dev/null +++ b/src/timeline/src/timelineundogeneral.cpp @@ -0,0 +1,1055 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "timelineundogeneral.h" + +#include + +#include "common/config.h" +#include "node/factory.h" +#include "node/node.h" +#include "node/project.h" +#include "timelineundocommon.h" +#include "timelineundotrack.h" +#include "timelineutil.h" +#include "undo/undocommand.h" + +namespace olive +{ + +namespace +{ + +const char *k_merge_node_id = "org.olivevideoeditor.Olive.merge"; +const char *k_math_node_id = "org.olivevideoeditor.Olive.math"; +const char *k_merge_base_input = "base_in"; +const char *k_merge_blend_input = "blend_in"; +const char *k_math_param_a_input = "param_a_in"; +const char *k_math_param_b_input = "param_b_in"; + +void add_multi_child(OakUndoCommand *multi, OakUndoCommand *child) +{ + if (multi && child) { + oakundo_command_multi_add_child(multi, child); + } +} + +} // namespace + +// +// BlockResizeCommand +// +void BlockResizeCommand::redo() +{ + old_length_ = block_length(block_); + block_set_length_and_media_out(block_, new_length_); +} + +void BlockResizeCommand::undo() +{ + block_set_length_and_media_out(block_, old_length_); +} + +// +// BlockResizeWithMediaInCommand +// +void BlockResizeWithMediaInCommand::redo() +{ + old_length_ = block_length(block_); + block_set_length_and_media_in(block_, new_length_); +} + +void BlockResizeWithMediaInCommand::undo() +{ + block_set_length_and_media_in(block_, old_length_); +} + +// +// BlockSetMediaInCommand +// +void BlockSetMediaInCommand::redo() +{ + int n, d; + oaknode_clip_get_media_in(block_, &n, &d); + old_media_in_ = Rational(n, d); + rat_nd(new_media_in_, &n, &d); + oaknode_clip_set_media_in(block_, n, d); +} + +void BlockSetMediaInCommand::undo() +{ + int n, d; + rat_nd(old_media_in_, &n, &d); + oaknode_clip_set_media_in(block_, n, d); +} + +// +// TimelineAddTrackCommand +// +TimelineAddTrackCommand::TimelineAddTrackCommand(OakNodeTrackList *timeline) + : TimelineAddTrackCommand( + timeline, + oakcommon_config_get_bool(NULL, "AutoMergeTracks", 0) != 0) +{ +} + +TimelineAddTrackCommand::TimelineAddTrackCommand( + OakNodeTrackList *timeline, bool automerge_tracks) + : timeline_(timeline) + , track_(nullptr) + , merge_(nullptr) + , position_command_(nullptr) + , automerge_tracks_(automerge_tracks) + , track_orphaned_(false) + , merge_orphaned_(false) +{ + // Create new track + int type = OAKNODE_TRACK_TYPE_NONE; + oaknode_tracklist_get_type(timeline_, &type); + track_ = oaknode_track_create(type); + track_orphaned_ = true; + + // Determine what input to connect it to + if (type == OAKNODE_TRACK_TYPE_VIDEO) { + direct_input_ = OAKNODE_SEQUENCE_TEXTURE_INPUT; + } else if (type == OAKNODE_TRACK_TYPE_AUDIO) { + direct_input_ = OAKNODE_SEQUENCE_SAMPLES_INPUT; + } + + // If we have an input to connect to, check if something is already connected + if (!direct_input_.empty() && automerge_tracks_) { + OakNodeSequence *sequence = nullptr; + oaknode_tracklist_get_sequence(timeline_, &sequence); + + int connected = 0; + oaknode_node_input_is_connected(oaknode_sequence_as_node(sequence), + direct_input_.c_str(), &connected); + if (connected) { + if (type == OAKNODE_TRACK_TYPE_VIDEO) { + // Use merge for video + merge_ = oaknode_factory_create_from_id(k_merge_node_id); + base_input_ = k_merge_base_input; + blend_input_ = k_merge_blend_input; + } else if (type == OAKNODE_TRACK_TYPE_AUDIO) { + // Use math (add) for audio + merge_ = oaknode_factory_create_from_id(k_math_node_id); + base_input_ = k_math_param_a_input; + blend_input_ = k_math_param_b_input; + } + + if (merge_) { + merge_orphaned_ = true; + } + } + } +} + +TimelineAddTrackCommand::~TimelineAddTrackCommand() +{ + if (position_command_) { + oakundo_command_free(position_command_); + } + if (track_orphaned_ && track_) { + oaknode_track_free(track_); + } + if (merge_orphaned_ && merge_) { + oaknode_node_free(merge_); + } +} + +void TimelineAddTrackCommand::redo() +{ + // Get sequence + OakNodeSequence *sequence = nullptr; + oaknode_tracklist_get_sequence(timeline_, &sequence); + OakNodeNode *sequence_node = oaknode_sequence_as_node(sequence); + OakNodeNode *track_node = oaknode_track_as_node(track_); + + OakNodeProject *project = nullptr; + oaknode_node_get_project(sequence_node, &project); + + // Add track to sequence's graph + if (project) { + oaknode_project_add_node(project, track_node); + } + track_orphaned_ = false; + + int track_count = 0; + oaknode_tracklist_get_track_count(timeline_, &track_count); + if (track_count > 0) { + OakNodeTrack *last = nullptr; + oaknode_tracklist_get_track_at(timeline_, track_count - 1, &last); + double height = 0; + if (last && oaknode_track_get_height(last, &height) == OAKNODE_OK) { + oaknode_track_set_height(track_, height); + } + } + + char input_id[64]; + oaknode_tracklist_get_track_input_id(timeline_, input_id, + sizeof(input_id)); + + oaknode_tracklist_array_append(timeline_); + int array_size = 0; + oaknode_tracklist_get_array_size(timeline_, &array_size); + oaknode_node_connect_element(track_node, sequence_node, input_id, + array_size - 1); + + int type = OAKNODE_TRACK_TYPE_NONE; + oaknode_tracklist_get_type(timeline_, &type); + + double position_factor = 0.5; + if (type == OAKNODE_TRACK_TYPE_VIDEO) { + position_factor = -position_factor; + } + bool create_pos_command = + (!position_command_ && (type == OAKNODE_TRACK_TYPE_VIDEO || + type == OAKNODE_TRACK_TYPE_AUDIO)); + if (create_pos_command) { + position_command_ = oakundo_command_init_multi(); + } + + // Add merge if applicable + if (merge_) { + // Determine what was previously connected + OakNodeNode *previous_connection = nullptr; + oaknode_node_input_get_connected_node(sequence_node, + direct_input_.c_str(), + &previous_connection); + + // Add merge to graph + if (project) { + oaknode_project_add_node(project, merge_); + } + merge_orphaned_ = false; + + // Connect merge between what used to be here + oaknode_node_disconnect(sequence_node, direct_input_.c_str()); + oaknode_node_connect(merge_, sequence_node, direct_input_.c_str()); + if (previous_connection) { + oaknode_node_connect(previous_connection, merge_, + base_input_.c_str()); + } + oaknode_node_connect(track_node, merge_, blend_input_.c_str()); + + if (create_pos_command && position_command_) { + double sx = 0, sy = 0; + oaknode_node_get_context_position(sequence_node, sequence_node, + &sx, &sy, NULL); + + OakUndoCommand *child = nullptr; + if (oaknode_node_set_context_position_undoable( + track_node, sequence_node, sx - 1, + sy - position_factor, 0, + &child) == OAKNODE_OK) { + add_multi_child(position_command_, child); + } + child = nullptr; + if (oaknode_node_set_context_position_undoable( + merge_, sequence_node, sx, sy, 0, &child) == OAKNODE_OK) { + add_multi_child(position_command_, child); + } + + int count_for_pos = 0; + oaknode_tracklist_get_track_count(timeline_, &count_for_pos); + child = oaknode_command_create_set_position_recursive( + merge_, sequence_node, sx - 1, + sy + position_factor * count_for_pos); + add_multi_child(position_command_, child); + } + } else if (!direct_input_.empty()) { + int connected = 0; + oaknode_node_input_is_connected(sequence_node, direct_input_.c_str(), + &connected); + if (!connected) { + // If no merge, we have a direct connection, and nothing else is + // connected, connect this + oaknode_node_connect(track_node, sequence_node, + direct_input_.c_str()); + + if (create_pos_command && position_command_) { + // Just position directly next to the context node + double sx = 0, sy = 0; + oaknode_node_get_context_position(sequence_node, + sequence_node, &sx, &sy, + NULL); + + OakUndoCommand *child = nullptr; + if (oaknode_node_set_context_position_undoable( + track_node, sequence_node, sx - 1, + sy + position_factor, 0, + &child) == OAKNODE_OK) { + add_multi_child(position_command_, child); + } + } + } + } + + // Run position command if we created one + if (position_command_) { + oakundo_command_redo_now(position_command_); + } +} + +void TimelineAddTrackCommand::undo() +{ + if (position_command_) { + oakundo_command_undo_now(position_command_); + } + + OakNodeSequence *sequence = nullptr; + oaknode_tracklist_get_sequence(timeline_, &sequence); + OakNodeNode *sequence_node = oaknode_sequence_as_node(sequence); + OakNodeNode *track_node = oaknode_track_as_node(track_); + + OakNodeProject *project = nullptr; + oaknode_node_get_project(sequence_node, &project); + + // Remove merge if applicable + if (merge_) { + OakNodeNode *previous_connection = nullptr; + oaknode_node_input_get_connected_node(merge_, base_input_.c_str(), + &previous_connection); + + oaknode_node_disconnect(merge_, blend_input_.c_str()); + oaknode_node_disconnect(merge_, base_input_.c_str()); + oaknode_node_disconnect(sequence_node, direct_input_.c_str()); + if (previous_connection) { + oaknode_node_connect(previous_connection, sequence_node, + direct_input_.c_str()); + } + + if (project) { + oaknode_project_remove_node(project, merge_); + } + merge_orphaned_ = true; + } else if (!direct_input_.empty()) { + OakNodeNode *connected_output = nullptr; + oaknode_node_input_get_connected_node(sequence_node, + direct_input_.c_str(), + &connected_output); + if (connected_output == track_node) { + oaknode_node_disconnect(sequence_node, direct_input_.c_str()); + } + } + + // Remove track + char input_id[64]; + oaknode_tracklist_get_track_input_id(timeline_, input_id, + sizeof(input_id)); + int array_size = 0; + oaknode_tracklist_get_array_size(timeline_, &array_size); + oaknode_node_disconnect_element(sequence_node, input_id, array_size - 1); + oaknode_tracklist_array_remove_last(timeline_); + + if (project) { + oaknode_project_remove_node(project, track_node); + } + track_orphaned_ = true; +} + +// +// TransitionRemoveCommand +// +TransitionRemoveCommand::~TransitionRemoveCommand() +{ + if (remove_command_) { + free_remove_command(remove_command_); + } +} + +void TransitionRemoveCommand::redo() +{ + oaknode_block_get_track(block_, &track_); + oaknode_transition_get_connected_out_block(block_, &out_block_); + oaknode_transition_get_connected_in_block(block_, &in_block_); + + int n, d; + + if (in_block_) { + oaknode_transition_get_in_offset(block_, &n, &d); + block_set_length_and_media_in( + in_block_, block_length(in_block_) + Rational(n, d)); + } + + if (out_block_) { + oaknode_transition_get_out_offset(block_, &n, &d); + block_set_length_and_media_out( + out_block_, block_length(out_block_) + Rational(n, d)); + } + + if (in_block_) { + oaknode_node_disconnect(oaknode_block_as_node(block_), + OAKNODE_TRANSITION_IN_BLOCK_INPUT); + } + + if (out_block_) { + oaknode_node_disconnect(oaknode_block_as_node(block_), + OAKNODE_TRANSITION_OUT_BLOCK_INPUT); + } + + oaknode_track_ripple_remove_block(track_, block_); + + if (remove_from_graph_) { + if (!remove_command_) { + remove_command_ = create_remove_command(block_); + } + + oakundo_command_redo_now(remove_command_); + } +} + +void TransitionRemoveCommand::undo() +{ + if (remove_from_graph_) { + oakundo_command_undo_now(remove_command_); + } + + if (in_block_) { + oaknode_track_insert_block_before(track_, block_, in_block_); + } else { + oaknode_track_insert_block_after(track_, block_, out_block_); + } + + if (in_block_) { + oaknode_node_connect(oaknode_block_as_node(in_block_), + oaknode_block_as_node(block_), + OAKNODE_TRANSITION_IN_BLOCK_INPUT); + } + + if (out_block_) { + oaknode_node_connect(oaknode_block_as_node(out_block_), + oaknode_block_as_node(block_), + OAKNODE_TRANSITION_OUT_BLOCK_INPUT); + } + + int n, d; + + // These if statements must be separated because in_offset and out_offset report different things + // if only one block is connected vs two. So we have to connect the blocks first before we have + // an accurate return value from these offset functions. + if (in_block_) { + oaknode_transition_get_in_offset(block_, &n, &d); + block_set_length_and_media_in( + in_block_, block_length(in_block_) - Rational(n, d)); + } + + if (out_block_) { + oaknode_transition_get_out_offset(block_, &n, &d); + block_set_length_and_media_out( + out_block_, block_length(out_block_) - Rational(n, d)); + } +} + +// +// TrackListInsertGaps +// +TrackListInsertGaps::~TrackListInsertGaps() +{ + delete split_command_; + for (AddGap &add_gap : gaps_added_) { + if (add_gap.orphaned && add_gap.gap) { + oaknode_block_free(add_gap.gap); + } + } +} + +void TrackListInsertGaps::prepare() +{ + // Determine if all tracks will be affected, which will allow us to make some optimizations + int track_count = 0; + oaknode_tracklist_get_track_count(track_list_, &track_count); + for (int i = 0; i < track_count; i++) { + OakNodeTrack *track = nullptr; + oaknode_tracklist_get_track_at(track_list_, i, &track); + if (!track) { + continue; + } + + int locked = 0; + oaknode_track_get_locked(track, &locked); + if (locked) { + continue; + } + + working_tracks_.push_back(track); + } + + std::vector blocks_to_split; + std::vector blocks_to_append_gap_to; + std::vector tracks_to_append_gap_to; + + for (OakNodeTrack *track : working_tracks_) { + int block_count = 0; + oaknode_track_get_block_count(track, &block_count); + for (int i = 0; i < block_count; i++) { + OakNodeBlock *b = nullptr; + oaknode_track_get_block_at(track, i, &b); + if (!b) { + continue; + } + + int kind = OAKNODE_BLOCK_OTHER; + oaknode_block_get_kind(b, &kind); + + if (kind == OAKNODE_BLOCK_GAP && block_in(b) <= point_ && + block_out(b) >= point_) { + // Found a gap at the location + gaps_to_extend_.push_back(b); + break; + } else if (kind == OAKNODE_BLOCK_CLIP && block_out(b) >= point_) { + bool append_gap = true; + + if (block_in(b) == point_) { + // The only reason we should be here is if this block is at the start of the track, + // in which case no split needs to occur + b = nullptr; + } else if (block_out(b) > point_) { + // Block must be split as well as having a gap appended to it + blocks_to_split.push_back(b); + } else if (!block_next(b)) { + // At the end of a track, no gap needs to be added at all + append_gap = false; + } + + if (append_gap) { + tracks_to_append_gap_to.push_back(track); + blocks_to_append_gap_to.push_back(b); + } + break; + } + } + } + + if (!blocks_to_split.empty()) { + split_command_ = new BlockSplitPreservingLinksCommand(blocks_to_split, + { point_ }); + } + + for (size_t i = 0; i < blocks_to_append_gap_to.size(); i++) { + OakNodeBlock *gap = oaknode_block_gap_create(); + block_set_length_and_media_out(gap, length_); + gaps_added_.push_back({ gap, true, blocks_to_append_gap_to.at(i), + tracks_to_append_gap_to.at(i) }); + } +} + +void TrackListInsertGaps::redo() +{ + for (OakNodeBlock *gap : gaps_to_extend_) { + block_set_length_and_media_out(gap, block_length(gap) + length_); + } + + if (split_command_) { + split_command_->redo_now(); + } + + for (AddGap &add_gap : gaps_added_) { + block_add_to_graph(add_gap.gap, add_gap.track); + oaknode_track_insert_block_after(add_gap.track, add_gap.gap, + add_gap.before); + add_gap.orphaned = false; + } +} + +void TrackListInsertGaps::undo() +{ + // Remove added gaps + for (AddGap &add_gap : gaps_added_) { + OakNodeTrack *t = block_track(add_gap.gap); + if (t) { + oaknode_track_ripple_remove_block(t, add_gap.gap); + block_remove_from_graph(add_gap.gap, t); + } + add_gap.orphaned = true; + } + + // Un-split blocks + if (split_command_) { + split_command_->undo_now(); + } + + // Restore original length of gaps + for (OakNodeBlock *gap : gaps_to_extend_) { + block_set_length_and_media_out(gap, block_length(gap) - length_); + } +} + +// +// TrackReplaceBlockWithGapCommand +// +TrackReplaceBlockWithGapCommand::~TrackReplaceBlockWithGapCommand() +{ + for (TransitionRemoveCommand *c : transition_remove_commands_) { + delete c; + } + if (our_gap_ && our_gap_orphaned_) { + oaknode_block_free(our_gap_); + } + if (existing_merged_gap_ && merged_gap_orphaned_) { + oaknode_block_free(existing_merged_gap_); + } +} + +void TrackReplaceBlockWithGapCommand::redo() +{ + // Determine if this block is connected to any transitions that should also be removed by this operation + if (handle_transitions_ && transition_remove_commands_.empty()) { + create_remove_transition_command_if_necessary(false); + create_remove_transition_command_if_necessary(true); + } + for (TransitionRemoveCommand *c : transition_remove_commands_) { + c->redo_now(); + } + + if (block_next(block_)) { + // Block has a next, which means it's NOT at the end of the sequence and thus requires a gap + Rational new_gap_length = block_length(block_); + + OakNodeBlock *previous = block_previous(block_); + OakNodeBlock *next = block_next(block_); + + int prev_kind = OAKNODE_BLOCK_OTHER; + int next_kind = OAKNODE_BLOCK_OTHER; + if (previous) { + oaknode_block_get_kind(previous, &prev_kind); + } + if (next) { + oaknode_block_get_kind(next, &next_kind); + } + bool previous_is_a_gap = (prev_kind == OAKNODE_BLOCK_GAP); + bool next_is_a_gap = (next_kind == OAKNODE_BLOCK_GAP); + + if (previous_is_a_gap && next_is_a_gap) { + // Clip is preceded and followed by a gap, so we'll merge the two + existing_gap_ = previous; + + existing_merged_gap_ = next; + new_gap_length += block_length(existing_merged_gap_); + oaknode_track_ripple_remove_block(track_, existing_merged_gap_); + block_remove_from_graph(existing_merged_gap_, track_); + merged_gap_orphaned_ = true; + } else if (previous_is_a_gap) { + // Extend this gap to fill space left by block + existing_gap_ = previous; + } else if (next_is_a_gap) { + // Extend this gap to fill space left by block + existing_gap_ = next; + } + + if (existing_gap_) { + // Extend an existing gap + new_gap_length += block_length(existing_gap_); + block_set_length_and_media_out(existing_gap_, new_gap_length); + oaknode_track_ripple_remove_block(track_, block_); + + existing_gap_precedes_ = (existing_gap_ == previous); + } else { + // No gap exists to fill this space, create a new one and swap it in + if (!our_gap_) { + our_gap_ = oaknode_block_gap_create(); + block_set_length_and_media_out(our_gap_, new_gap_length); + our_gap_orphaned_ = true; + } + + block_add_to_graph(our_gap_, track_); + oaknode_track_replace_block(track_, block_, our_gap_); + our_gap_orphaned_ = false; + } + + } else { + // Block is at the end of the track, simply remove it + OakNodeBlock *preceding = block_previous(block_); + oaknode_track_ripple_remove_block(track_, block_); + + // Determine if it's preceded by a gap, and remove that gap if so + int kind = OAKNODE_BLOCK_OTHER; + if (preceding) { + oaknode_block_get_kind(preceding, &kind); + } + if (kind == OAKNODE_BLOCK_GAP) { + oaknode_track_ripple_remove_block(track_, preceding); + block_remove_from_graph(preceding, track_); + + existing_merged_gap_ = preceding; + merged_gap_orphaned_ = true; + } + } +} + +void TrackReplaceBlockWithGapCommand::undo() +{ + if (our_gap_ || existing_gap_) { + if (our_gap_) { + // We made this gap, simply swap our gap back + oaknode_track_replace_block(track_, our_gap_, block_); + block_remove_from_graph(our_gap_, track_); + our_gap_orphaned_ = true; + + } else { + // If we're here, assume that we extended an existing gap + Rational original_gap_length = + block_length(existing_gap_) - block_length(block_); + + // If we merged two gaps together, restore the second one now + if (existing_merged_gap_) { + original_gap_length -= block_length(existing_merged_gap_); + block_add_to_graph(existing_merged_gap_, track_); + oaknode_track_insert_block_after(track_, existing_merged_gap_, + existing_gap_); + merged_gap_orphaned_ = false; + existing_merged_gap_ = nullptr; + } + + // Restore original block + if (existing_gap_precedes_) { + oaknode_track_insert_block_after(track_, block_, + existing_gap_); + } else { + oaknode_track_insert_block_before(track_, block_, + existing_gap_); + } + + // Restore gap's original length + block_set_length_and_media_out(existing_gap_, + original_gap_length); + + existing_gap_ = nullptr; + } + + } else { + // Our gap and existing gap were both null, our block must have been at the end and thus + // required no gap extension/replacement + + // However, we may have removed an unnecessary gap that preceded it + if (existing_merged_gap_) { + block_add_to_graph(existing_merged_gap_, track_); + oaknode_track_append_block(track_, existing_merged_gap_); + merged_gap_orphaned_ = false; + existing_merged_gap_ = nullptr; + } + + // Restore block + oaknode_track_append_block(track_, block_); + } + + for (auto it = transition_remove_commands_.rbegin(); + it != transition_remove_commands_.rend(); it++) { + (*it)->undo_now(); + } +} + +void TrackReplaceBlockWithGapCommand::create_remove_transition_command_if_necessary( + bool next) +{ + OakNodeBlock *relevant_block = + next ? block_next(block_) : block_previous(block_); + + int kind = OAKNODE_BLOCK_OTHER; + if (relevant_block) { + oaknode_block_get_kind(relevant_block, &kind); + } + if (kind != OAKNODE_BLOCK_TRANSITION) { + return; + } + + OakNodeBlock *connected_out = nullptr; + OakNodeBlock *connected_in = nullptr; + oaknode_transition_get_connected_out_block(relevant_block, + &connected_out); + oaknode_transition_get_connected_in_block(relevant_block, &connected_in); + + if ((next && connected_out == block_ && !connected_in) || + (!next && connected_in == block_ && !connected_out)) { + transition_remove_commands_.push_back( + new TransitionRemoveCommand(relevant_block, true)); + } +} + +// +// TimelineRemoveTrackCommand +// +TimelineRemoveTrackCommand::~TimelineRemoveTrackCommand() +{ + if (remove_command_) { + free_remove_command(remove_command_); + } +} + +void TimelineRemoveTrackCommand::prepare() +{ + OakNodeSequence *sequence = nullptr; + oaknode_track_get_sequence(track_, &sequence); + + int type = OAKNODE_TRACK_TYPE_NONE; + oaknode_track_get_type(track_, &type); + oaknode_sequence_get_track_list(sequence, type, &list_); + + int cache_index = 0; + oaknode_track_get_index(track_, &cache_index); + oaknode_tracklist_get_array_index_from_cache_index(list_, cache_index, + &index_); + + remove_command_ = create_remove_command(oaknode_track_as_node(track_)); +} + +void TimelineRemoveTrackCommand::redo() +{ + oakundo_command_redo_now(remove_command_); + + OakNodeSequence *sequence = nullptr; + oaknode_tracklist_get_sequence(list_, &sequence); + + char input_id[64]; + oaknode_tracklist_get_track_input_id(list_, input_id, sizeof(input_id)); + oaknode_node_input_array_remove(oaknode_sequence_as_node(sequence), + input_id, index_); +} + +void TimelineRemoveTrackCommand::undo() +{ + OakNodeSequence *sequence = nullptr; + oaknode_tracklist_get_sequence(list_, &sequence); + + char input_id[64]; + oaknode_tracklist_get_track_input_id(list_, input_id, sizeof(input_id)); + oaknode_node_input_array_insert(oaknode_sequence_as_node(sequence), + input_id, index_); + + oakundo_command_undo_now(remove_command_); +} + +// +// TimelineAddDefaultTransitionCommand +// +namespace +{ + +std::string config_get_string(const char *key) +{ + int needed = oakcommon_config_get(NULL, key, NULL, 0); + if (needed <= 0) { + return std::string(); + } + std::vector buf(needed); + if (oakcommon_config_get(NULL, key, buf.data(), needed) < 0) { + return std::string(); + } + return std::string(buf.data()); +} + +} // namespace + +void TimelineAddDefaultTransitionCommand::prepare() +{ + for (OakNodeBlock *c : clips_) { + OakNodeBlock *previous = block_previous(c); + OakNodeBlock *next = block_next(c); + + auto is_clip_in_selection = [this](OakNodeBlock *b) { + if (!b) { + return false; + } + for (OakNodeBlock *clip : clips_) { + if (clip == b) { + return true; + } + } + return false; + }; + + int prev_kind = OAKNODE_BLOCK_OTHER; + int next_kind = OAKNODE_BLOCK_OTHER; + if (previous) { + oaknode_block_get_kind(previous, &prev_kind); + } + if (next) { + oaknode_block_get_kind(next, &next_kind); + } + + // Handle in transition + if (is_clip_in_selection(previous)) { + // Do nothing, assume this will be handled by a dual transition from that clip + } else if (prev_kind == OAKNODE_BLOCK_GAP || !previous) { + // Create in transition + add_transition(c, k_in); + } + + // Handle out transition + if (is_clip_in_selection(next)) { + add_transition(c, k_out_dual); + } else if (next_kind == OAKNODE_BLOCK_GAP || !next) { + // Create out transition + add_transition(c, k_out); + } + } +} + +void TimelineAddDefaultTransitionCommand::add_transition( + OakNodeBlock *c, CreateTransitionMode mode) +{ + OakNodeTrack *t = block_track(c); + if (!t) { + return; + } + + int type = OAKNODE_TRACK_TYPE_NONE; + oaknode_track_get_type(t, &type); + + OakNodeNode *p = nullptr; + if (type == OAKNODE_TRACK_TYPE_VIDEO) { + std::string id = config_get_string("DefaultVideoTransition"); + if (!id.empty()) { + p = oaknode_factory_create_from_id(id.c_str()); + } + } else if (type == OAKNODE_TRACK_TYPE_AUDIO) { + std::string id = config_get_string("DefaultAudioTransition"); + if (!id.empty()) { + p = oaknode_factory_create_from_id(id.c_str()); + } + } + + std::string len_str = config_get_string("DefaultTransitionLength"); + Rational transition_length = + len_str.empty() ? Rational() : Rational::from_string(len_str); + + // Resize original clip + switch (mode) { + case k_in: + validate_transition_length(c, transition_length); + + if (transition_length > 0) { + adjust_clip_length(c, transition_length, false); + } + break; + case k_out: + validate_transition_length(c, transition_length); + + if (transition_length > 0) { + adjust_clip_length(c, transition_length, true); + } + break; + case k_out_dual: { + Rational half_length = transition_length / 2; + + validate_transition_length(block_next(c), half_length); + validate_transition_length(c, half_length); + + transition_length = half_length * 2; + + if (transition_length > 0) { + adjust_clip_length(block_next(c), half_length, false); + adjust_clip_length(c, half_length, true); + } + break; + } + } + + if (transition_length <= 0) { + if (p) { + oaknode_node_free(p); + } + return; + } + + OakNodeBlock *transition = oaknode_block_from_node(p); + int kind = OAKNODE_BLOCK_OTHER; + if (transition) { + oaknode_block_get_kind(transition, &kind); + } + if (kind != OAKNODE_BLOCK_TRANSITION) { + if (p) { + oaknode_node_free(p); + } + return; + } + + block_set_length_and_media_out(transition, transition_length); + + // Add transition + OakNodeProject *project = nullptr; + oaknode_node_get_project(oaknode_block_as_node(c), &project); + commands_.push_back( + new CHandleCommandWrapper(oaknode_command_create_add_node(project, p))); + + // Insert block + OakNodeBlock *insert_after = (mode == k_in) ? block_previous(c) : c; + commands_.push_back( + new TrackInsertBlockAfterCommand(t, transition, insert_after)); + + // Connect + OakUndoCommand *edge_command = nullptr; + switch (mode) { + case k_in: + if (oaknode_node_connect_undoable( + oaknode_block_as_node(c), p, + OAKNODE_TRANSITION_IN_BLOCK_INPUT, + &edge_command) == OAKNODE_OK) { + commands_.push_back(new CHandleCommandWrapper(edge_command)); + } + break; + case k_out_dual: + if (oaknode_node_connect_undoable( + oaknode_block_as_node(block_next(c)), p, + OAKNODE_TRANSITION_IN_BLOCK_INPUT, + &edge_command) == OAKNODE_OK) { + commands_.push_back(new CHandleCommandWrapper(edge_command)); + } + /* fall through */ + case k_out: + edge_command = nullptr; + if (oaknode_node_connect_undoable( + oaknode_block_as_node(c), p, + OAKNODE_TRANSITION_OUT_BLOCK_INPUT, + &edge_command) == OAKNODE_OK) { + commands_.push_back(new CHandleCommandWrapper(edge_command)); + } + break; + } +} + +void TimelineAddDefaultTransitionCommand::adjust_clip_length( + OakNodeBlock *c, const Rational &transition_length, bool out) +{ + Rational cur_len = lengths_.count(c) ? lengths_[c] : block_length(c); + Rational new_len = cur_len - transition_length; + if (out) { + commands_.push_back(new BlockResizeCommand(c, new_len)); + } else { + commands_.push_back(new BlockResizeWithMediaInCommand(c, new_len)); + } + lengths_[c] = new_len; +} + +void TimelineAddDefaultTransitionCommand::validate_transition_length( + OakNodeBlock *c, Rational &transition_length) +{ + if (!c) { + return; + } + Rational cur_len = lengths_.count(c) ? lengths_[c] : block_length(c); + Rational half_cur_len = cur_len / 2; + if (transition_length >= half_cur_len) { + transition_length = half_cur_len - timebase_; + } +} + +} diff --git a/src/timeline/src/timelineundogeneral.h b/src/timeline/src/timelineundogeneral.h new file mode 100644 index 000000000..5a2f0ef97 --- /dev/null +++ b/src/timeline/src/timelineundogeneral.h @@ -0,0 +1,373 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEUNDOGENERAL_H +#define OAK_TIMELINEUNDOGENERAL_H + +#include +#include +#include + +#include + +#include "node/block.h" +#include "node/sequence.h" +#include "node/track.h" +#include "timelineundosplit.h" +#include "undocommand.h" + +using namespace olive::core; + +namespace olive +{ + +class BlockResizeCommand : public UndoCommand { +public: + BlockResizeCommand(OakNodeBlock *block, Rational new_length) + : block_(block) + , new_length_(new_length) + { + } + +protected: + virtual void redo() override; + virtual void undo() override; + +private: + OakNodeBlock *block_; + Rational old_length_; + Rational new_length_; +}; + +class BlockResizeWithMediaInCommand : public UndoCommand { +public: + BlockResizeWithMediaInCommand(OakNodeBlock *block, Rational new_length) + : block_(block) + , new_length_(new_length) + { + } + +protected: + virtual void redo(); + virtual void undo(); + +private: + OakNodeBlock *block_; + Rational old_length_; + Rational new_length_; +}; + +class BlockSetMediaInCommand : public UndoCommand { +public: + BlockSetMediaInCommand(OakNodeBlock *block, Rational new_media_in) + : block_(block) + , new_media_in_(new_media_in) + { + } + +protected: + virtual void redo(); + virtual void undo(); + +private: + OakNodeBlock *block_; + Rational old_media_in_; + Rational new_media_in_; +}; + +class TimelineAddTrackCommand : public UndoCommand { +public: + TimelineAddTrackCommand(OakNodeTrackList *timeline); + TimelineAddTrackCommand(OakNodeTrackList *timeline, bool automerge_tracks); + + virtual ~TimelineAddTrackCommand() override; + + static OakNodeTrack *run_immediately(OakNodeTrackList *timeline) + { + TimelineAddTrackCommand c(timeline); + c.redo(); + return c.track(); + } + + static OakNodeTrack *run_immediately(OakNodeTrackList *timeline, + bool automerge) + { + TimelineAddTrackCommand c(timeline, automerge); + c.redo(); + return c.track(); + } + + OakNodeTrack *track() const + { + return track_; + } + +protected: + virtual void redo() override; + + virtual void undo() override; + +private: + OakNodeTrackList *timeline_; + + OakNodeTrack *track_; + OakNodeNode *merge_; + std::string base_input_; + std::string blend_input_; + + std::string direct_input_; + + OakUndoCommand *position_command_; + + bool automerge_tracks_; + + // Ownership: true while the node is detached from the graph and this + // command must free it on destruction + bool track_orphaned_; + bool merge_orphaned_; +}; + +class TimelineRemoveTrackCommand : public UndoCommand { +public: + TimelineRemoveTrackCommand(OakNodeTrack *track) + : track_(track) + , list_(nullptr) + , index_(0) + , remove_command_(nullptr) + { + } + + virtual ~TimelineRemoveTrackCommand() override; + +protected: + virtual void prepare() override; + + virtual void redo() override; + + virtual void undo() override; + +private: + OakNodeTrack *track_; + + OakNodeTrackList *list_; + + int index_; + + OakUndoCommand *remove_command_; +}; + +class TransitionRemoveCommand : public UndoCommand { +public: + TransitionRemoveCommand(OakNodeBlock *block, bool remove_from_graph) + : block_(block) + , track_(nullptr) + , out_block_(nullptr) + , in_block_(nullptr) + , remove_from_graph_(remove_from_graph) + , remove_command_(nullptr) + { + } + + virtual ~TransitionRemoveCommand() override; + +protected: + virtual void redo() override; + + virtual void undo() override; + +private: + OakNodeBlock *block_; + + OakNodeTrack *track_; + + OakNodeBlock *out_block_; + OakNodeBlock *in_block_; + + bool remove_from_graph_; + OakUndoCommand *remove_command_; +}; + +class TrackReplaceBlockWithGapCommand : public UndoCommand { +public: + TrackReplaceBlockWithGapCommand(OakNodeTrack *track, OakNodeBlock *block, + bool handle_transitions = true) + : track_(track) + , block_(block) + , existing_gap_(nullptr) + , existing_merged_gap_(nullptr) + , existing_gap_precedes_(false) + , our_gap_(nullptr) + , handle_transitions_(handle_transitions) + , our_gap_orphaned_(false) + , merged_gap_orphaned_(false) + { + } + + virtual ~TrackReplaceBlockWithGapCommand() override; + +protected: + virtual void redo() override; + + virtual void undo() override; + +private: + void create_remove_transition_command_if_necessary(bool next); + + OakNodeTrack *track_; + OakNodeBlock *block_; + + OakNodeBlock *existing_gap_; + OakNodeBlock *existing_merged_gap_; + bool existing_gap_precedes_; + OakNodeBlock *our_gap_; + + bool handle_transitions_; + + bool our_gap_orphaned_; + bool merged_gap_orphaned_; + + std::vector transition_remove_commands_; +}; + +class BlockEnableDisableCommand : public UndoCommand { +public: + BlockEnableDisableCommand(OakNodeBlock *block, bool enabled) + : block_(block) + , new_enabled_(enabled) + { + old_enabled_ = false; + oaknode_block_get_enabled(block_, &old_enabled_); + } + +protected: + virtual void redo() override + { + oaknode_block_set_enabled(block_, new_enabled_); + } + + virtual void undo() override + { + oaknode_block_set_enabled(block_, old_enabled_); + } + +private: + OakNodeBlock *block_; + + int old_enabled_; + + int new_enabled_; +}; + +class TrackListInsertGaps : public UndoCommand { +public: + TrackListInsertGaps(OakNodeTrackList *track_list, const Rational &point, + const Rational &length) + : track_list_(track_list) + , point_(point) + , length_(length) + , split_command_(nullptr) + { + } + + virtual ~TrackListInsertGaps() override; + +protected: + virtual void prepare() override; + + virtual void redo() override; + + virtual void undo() override; + +private: + OakNodeTrackList *track_list_; + + Rational point_; + + Rational length_; + + std::vector working_tracks_; + + std::vector gaps_to_extend_; + + struct AddGap { + OakNodeBlock *gap; + bool orphaned; + OakNodeBlock *before; + OakNodeTrack *track; + }; + + std::vector gaps_added_; + + BlockSplitPreservingLinksCommand *split_command_; +}; + +class TimelineAddDefaultTransitionCommand : public UndoCommand { +public: + TimelineAddDefaultTransitionCommand( + const std::vector &clips, const Rational &timebase) + : clips_(clips) + , timebase_(timebase) + { + } + + virtual ~TimelineAddDefaultTransitionCommand() override + { + for (UndoCommand *c : commands_) { + delete c; + } + } + +protected: + virtual void prepare() override; + + virtual void redo() override + { + for (UndoCommand *c : commands_) { + c->redo_now(); + } + } + + virtual void undo() override + { + for (auto it = commands_.rbegin(); it != commands_.rend(); it++) { + (*it)->undo_now(); + } + } + +private: + enum CreateTransitionMode { k_in, k_out, k_out_dual }; + + void add_transition(OakNodeBlock *c, CreateTransitionMode mode); + void adjust_clip_length(OakNodeBlock *c, const Rational &transition_length, + bool out); + void validate_transition_length(OakNodeBlock *c, + Rational &transition_length); + + std::vector clips_; + Rational timebase_; + std::vector commands_; + + std::map lengths_; +}; + +} + +#endif // OAK_TIMELINEUNDOGENERAL_H diff --git a/src/timeline/src/timelineundopointer.cpp b/src/timeline/src/timelineundopointer.cpp new file mode 100644 index 000000000..a80b3cc70 --- /dev/null +++ b/src/timeline/src/timelineundopointer.cpp @@ -0,0 +1,423 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "timelineundopointer.h" + +#include "timelineundocommon.h" +#include "timelineutil.h" +#include "undo/undocommand.h" + +namespace olive +{ + +// +// BlockTrimCommand +// +BlockTrimCommand::~BlockTrimCommand() +{ + if (deleted_adjacent_command_) { + free_remove_command(deleted_adjacent_command_); + } + if (adjacent_orphaned_ && adjacent_) { + oaknode_block_free(adjacent_); + } +} + +void BlockTrimCommand::redo() +{ + if (doing_nothing_) { + return; + } + + if (mode_ == Timeline::k_trim_in) { + block_set_length_and_media_in(block_, new_length_); + } else { + block_set_length_and_media_out(block_, new_length_); + } + + if (needs_adjacent_) { + if (we_created_adjacent_) { + // Add adjacent and insert it + block_add_to_graph(adjacent_, track_); + if (mode_ == Timeline::k_trim_in) { + oaknode_track_insert_block_before(track_, adjacent_, block_); + } else { + oaknode_track_insert_block_after(track_, adjacent_, block_); + } + adjacent_orphaned_ = false; + } else if (we_removed_adjacent_) { + oaknode_track_ripple_remove_block(track_, adjacent_); + + // It no longer inputs/outputs anything, remove it + if (remove_block_from_graph_ && node_can_be_removed(adjacent_)) { + if (!deleted_adjacent_command_) { + deleted_adjacent_command_ = + create_and_run_remove_command(adjacent_); + } else { + oakundo_command_redo_now(deleted_adjacent_command_); + } + } + } else { + Rational adjacent_length = block_length(adjacent_) + trim_diff_; + + if (mode_ == Timeline::k_trim_in) { + block_set_length_and_media_out(adjacent_, adjacent_length); + } else { + block_set_length_and_media_in(adjacent_, adjacent_length); + } + } + } +} + +void BlockTrimCommand::undo() +{ + if (doing_nothing_) { + return; + } + + // Will be POSITIVE if trimming shorter and NEGATIVE if trimming longer + if (needs_adjacent_) { + if (we_created_adjacent_) { + // Adjacent is ours, just delete it + oaknode_track_ripple_remove_block(track_, adjacent_); + block_remove_from_graph(adjacent_, track_); + adjacent_orphaned_ = true; + } else { + if (we_removed_adjacent_) { + if (deleted_adjacent_command_) { + // We deleted adjacent, restore it now + oakundo_command_undo_now(deleted_adjacent_command_); + } + + if (mode_ == Timeline::k_trim_in) { + oaknode_track_insert_block_before(track_, adjacent_, + block_); + } else { + oaknode_track_insert_block_after(track_, adjacent_, + block_); + } + } else { + Rational adjacent_length = + block_length(adjacent_) - trim_diff_; + + if (mode_ == Timeline::k_trim_in) { + block_set_length_and_media_out(adjacent_, + adjacent_length); + } else { + block_set_length_and_media_in(adjacent_, adjacent_length); + } + } + } + } + + if (mode_ == Timeline::k_trim_in) { + block_set_length_and_media_in(block_, old_length_); + } else { + block_set_length_and_media_out(block_, old_length_); + } +} + +void BlockTrimCommand::prepare() +{ + // Store old length + old_length_ = block_length(block_); + + // Determine if the length isn't changing, in which case we set a flag to do nothing + if ((doing_nothing_ = (old_length_ == new_length_))) { + return; + } + + // Will be POSITIVE if trimming shorter and NEGATIVE if trimming longer + trim_diff_ = old_length_ - new_length_; + + // Retrieve our adjacent block (or nullptr if none) + if (mode_ == Timeline::k_trim_in) { + adjacent_ = block_previous(block_); + } else { + adjacent_ = block_next(block_); + } + + // Ignore when trimming the out with no adjacent, because the user must have trimmed the end + // of the last block in the track, so we don't need to do anything elses + needs_adjacent_ = (mode_ == Timeline::k_trim_in || adjacent_); + + if (needs_adjacent_) { + // If we're trimming shorter, we need an adjacent, so check if we have a viable one. + int adjacent_kind = OAKNODE_BLOCK_OTHER; + if (adjacent_) { + oaknode_block_get_kind(adjacent_, &adjacent_kind); + } + we_created_adjacent_ = + (trim_diff_ > 0 && (!adjacent_ || (adjacent_kind != + OAKNODE_BLOCK_GAP && + !trim_is_a_roll_edit_))); + + if (we_created_adjacent_) { + // We shortened but don't have a viable adjacent to lengthen, so we create one + adjacent_ = oaknode_block_gap_create(); + block_set_length_and_media_out(adjacent_, trim_diff_); + adjacent_orphaned_ = true; + } else { + // Determine if we're removing the adjacent + Rational adjacent_length = block_length(adjacent_) + trim_diff_; + we_removed_adjacent_ = adjacent_length.isNull(); + } + } +} + +// +// TrackSlideCommand +// +TrackSlideCommand::~TrackSlideCommand() +{ + if (in_adjacent_remove_command_) { + free_remove_command(in_adjacent_remove_command_); + } + if (out_adjacent_remove_command_) { + free_remove_command(out_adjacent_remove_command_); + } + if (in_adjacent_orphaned_ && in_adjacent_) { + oaknode_block_free(in_adjacent_); + } + if (out_adjacent_orphaned_ && out_adjacent_) { + oaknode_block_free(out_adjacent_); + } +} + +void TrackSlideCommand::redo() +{ + // We will always have an in adjacent if there was a valid slide + if (we_created_in_adjacent_) { + // We created in adjacent, so all we have to do is insert it + block_add_to_graph(in_adjacent_, track_); + oaknode_track_insert_block_before(track_, in_adjacent_, + blocks_.front()); + in_adjacent_orphaned_ = false; + } else if (-movement_ == block_length(in_adjacent_)) { + // Movement will remove in adjacent + oaknode_track_ripple_remove_block(track_, in_adjacent_); + + if (node_can_be_removed(in_adjacent_)) { + if (!in_adjacent_remove_command_) { + in_adjacent_remove_command_ = + create_remove_command(in_adjacent_); + } + + oakundo_command_redo_now(in_adjacent_remove_command_); + } + + we_removed_in_adjacent_ = true; + } else { + // Simply resize adjacent + block_set_length_and_media_out( + in_adjacent_, block_length(in_adjacent_) + movement_); + } + + // We may not have an out adjacent if the slide was at the end of the track + if (out_adjacent_) { + if (we_created_out_adjacent_) { + // We created out adjacent, so we just have to insert it + block_add_to_graph(out_adjacent_, track_); + oaknode_track_insert_block_after(track_, out_adjacent_, + blocks_.back()); + out_adjacent_orphaned_ = false; + } else if (movement_ == block_length(out_adjacent_)) { + // Movement will remove out adjacent + oaknode_track_ripple_remove_block(track_, out_adjacent_); + + if (node_can_be_removed(out_adjacent_)) { + if (!out_adjacent_remove_command_) { + out_adjacent_remove_command_ = + create_remove_command(out_adjacent_); + } + + oakundo_command_redo_now(out_adjacent_remove_command_); + } + + we_removed_out_adjacent_ = true; + } else { + // Simply resize adjacent + block_set_length_and_media_in( + out_adjacent_, + block_length(out_adjacent_) - movement_); + } + } +} + +void TrackSlideCommand::undo() +{ + if (we_created_in_adjacent_) { + // We created this, so we can remove it now + oaknode_track_ripple_remove_block(track_, in_adjacent_); + block_remove_from_graph(in_adjacent_, track_); + in_adjacent_orphaned_ = true; + } else if (we_removed_in_adjacent_) { + if (in_adjacent_remove_command_) { + // We removed this, so we can restore it now + oakundo_command_undo_now(in_adjacent_remove_command_); + } + + oaknode_track_insert_block_before(track_, in_adjacent_, + blocks_.front()); + } else { + // Simply resize adjacent + block_set_length_and_media_out( + in_adjacent_, block_length(in_adjacent_) - movement_); + } + + if (out_adjacent_) { + if (we_created_out_adjacent_) { + // We created this, so we can remove it now + oaknode_track_ripple_remove_block(track_, out_adjacent_); + block_remove_from_graph(out_adjacent_, track_); + out_adjacent_orphaned_ = true; + } else if (we_removed_out_adjacent_) { + if (out_adjacent_remove_command_) { + oakundo_command_undo_now(out_adjacent_remove_command_); + } + + oaknode_track_insert_block_after(track_, out_adjacent_, + blocks_.back()); + } else { + // Simply resize adjacent + block_set_length_and_media_in( + out_adjacent_, + block_length(out_adjacent_) + movement_); + } + } +} + +void TrackSlideCommand::prepare() +{ + if (!in_adjacent_) { + in_adjacent_ = oaknode_block_gap_create(); + block_set_length_and_media_out(in_adjacent_, movement_); + in_adjacent_orphaned_ = true; + we_created_in_adjacent_ = true; + } else { + we_created_in_adjacent_ = false; + } + + if (!out_adjacent_ && block_next(blocks_.back())) { + out_adjacent_ = oaknode_block_gap_create(); + block_set_length_and_media_out(out_adjacent_, -movement_); + out_adjacent_orphaned_ = true; + we_created_out_adjacent_ = true; + } else { + we_created_out_adjacent_ = false; + } +} + +// +// TrackPlaceBlockCommand +// +TrackPlaceBlockCommand::~TrackPlaceBlockCommand() +{ + delete ripple_remove_command_; + for (TimelineAddTrackCommand *c : add_track_commands_) { + delete c; + } + if (gap_orphaned_ && gap_) { + oaknode_block_free(gap_); + } +} + +void TrackPlaceBlockCommand::redo() +{ + // Determine if we need to add tracks + int track_count = 0; + oaknode_tracklist_get_track_count(timeline_, &track_count); + + if (track_index_ >= track_count) { + if (add_track_commands_.empty()) { + // First redo, create tracks now + add_track_commands_.resize(track_index_ - track_count + 1); + + for (size_t i = 0; i < add_track_commands_.size(); i++) { + add_track_commands_[i] = + new TimelineAddTrackCommand(timeline_); + } + } + + for (TimelineAddTrackCommand *c : add_track_commands_) { + c->redo_now(); + } + } + + OakNodeTrack *track = nullptr; + oaknode_tracklist_get_track_at(timeline_, track_index_, &track); + + bool append = (in_ >= track_length(track)); + + // Check if the placement location is past the end of the timeline + if (append) { + if (in_ > track_length(track)) { + // If so, insert a gap here + if (!gap_) { + gap_ = oaknode_block_gap_create(); + block_set_length_and_media_out(gap_, + in_ - track_length(track)); + } + block_add_to_graph(gap_, track); + oaknode_track_append_block(track, gap_); + gap_orphaned_ = false; + } + + oaknode_track_append_block(track, insert_); + } else { + // Place the Block at this point + if (!ripple_remove_command_) { + ripple_remove_command_ = new TrackRippleRemoveAreaCommand( + track, TimeRange(in_, in_ + block_length(insert_))); + ripple_remove_command_->set_allow_splitting_gaps(true); + } + + ripple_remove_command_->redo_now(); + oaknode_track_insert_block_after( + track, insert_, ripple_remove_command_->get_insertion_index()); + } +} + +void TrackPlaceBlockCommand::undo() +{ + OakNodeTrack *t = nullptr; + oaknode_tracklist_get_track_at(timeline_, track_index_, &t); + + // Firstly, remove our insert + oaknode_track_ripple_remove_block(t, insert_); + + if (ripple_remove_command_) { + // If we ripple removed, just undo that + ripple_remove_command_->undo_now(); + } else if (gap_) { + oaknode_track_ripple_remove_block(t, gap_); + block_remove_from_graph(gap_, t); + gap_orphaned_ = true; + } + + // Remove tracks if we added them + for (auto it = add_track_commands_.rbegin(); + it != add_track_commands_.rend(); it++) { + (*it)->undo_now(); + } +} + +} diff --git a/src/timeline/src/timelineundopointer.h b/src/timeline/src/timelineundopointer.h new file mode 100644 index 000000000..a723c1751 --- /dev/null +++ b/src/timeline/src/timelineundopointer.h @@ -0,0 +1,210 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEUNDOPOINTER_H +#define OAK_TIMELINEUNDOPOINTER_H + +#include + +#include + +#include "node/block.h" +#include "node/track.h" +#include "timelinecommon.h" +#include "timelineundogeneral.h" +#include "timelineundoripple.h" +#include "undocommand.h" + +using namespace olive::core; + +namespace olive +{ + +/** + * @brief Performs a trim in the timeline that only affects the block and the block adjacent + * + * Changes the length of one block while also changing the length of the block directly adjacent + * to compensate so that the rest of the track is unaffected. + * + * By default, this will only affect the length of gaps. If the adjacent needs to increase its + * length and is not a gap, a gap will be created and inserted to fill that time. This command can + * be set to always trim even if the adjacent clip isn't a gap with set_trim_is_a_roll_edit() + */ +class BlockTrimCommand : public UndoCommand { +public: + BlockTrimCommand(OakNodeTrack *track, OakNodeBlock *block, + Rational new_length, Timeline::MovementMode mode) + : track_(track) + , block_(block) + , new_length_(new_length) + , mode_(mode) + , deleted_adjacent_command_(nullptr) + , trim_is_a_roll_edit_(false) + , remove_block_from_graph_(true) + , doing_nothing_(false) + , adjacent_(nullptr) + , needs_adjacent_(false) + , we_created_adjacent_(false) + , we_removed_adjacent_(false) + , adjacent_orphaned_(false) + { + } + + virtual ~BlockTrimCommand() override; + + /** + * @brief Set this if the trim should always affect the adjacent clip and not create a gap + */ + void set_trim_is_a_roll_edit(bool e) + { + trim_is_a_roll_edit_ = e; + } + + /** + * @brief Set whether adjacent blocks set to zero length should be removed from the whole graph + * + * If an adjacent block's length is set to 0, it's automatically removed from the track. By + * default it also gets removed from the whole graph. Set this to FALSE to disable that + * functionality. + */ + void set_remove_zero_length_from_graph(bool e) + { + remove_block_from_graph_ = e; + } + +protected: + virtual void prepare() override; + virtual void redo() override; + virtual void undo() override; + +private: + bool doing_nothing_; + Rational trim_diff_; + + OakNodeTrack *track_; + OakNodeBlock *block_; + Rational old_length_; + Rational new_length_; + Timeline::MovementMode mode_; + + OakNodeBlock *adjacent_; + bool needs_adjacent_; + bool we_created_adjacent_; + bool we_removed_adjacent_; + OakUndoCommand *deleted_adjacent_command_; + + bool trim_is_a_roll_edit_; + bool remove_block_from_graph_; + + // Owned while the created adjacent gap is detached from the graph + bool adjacent_orphaned_; +}; + +class TrackSlideCommand : public UndoCommand { +public: + TrackSlideCommand(OakNodeTrack *track, + const std::vector &moving_blocks, + OakNodeBlock *in_adjacent, OakNodeBlock *out_adjacent, + const Rational &movement) + : track_(track) + , blocks_(moving_blocks) + , movement_(movement) + , we_created_in_adjacent_(false) + , we_removed_in_adjacent_(false) + , in_adjacent_(in_adjacent) + , in_adjacent_remove_command_(nullptr) + , in_adjacent_orphaned_(false) + , we_created_out_adjacent_(false) + , we_removed_out_adjacent_(false) + , out_adjacent_(out_adjacent) + , out_adjacent_remove_command_(nullptr) + , out_adjacent_orphaned_(false) + { + } + + virtual ~TrackSlideCommand() override; + +protected: + virtual void prepare() override; + + virtual void redo() override; + + virtual void undo() override; + +private: + OakNodeTrack *track_; + std::vector blocks_; + Rational movement_; + + bool we_created_in_adjacent_; + bool we_removed_in_adjacent_; + OakNodeBlock *in_adjacent_; + OakUndoCommand *in_adjacent_remove_command_; + bool in_adjacent_orphaned_; + bool we_created_out_adjacent_; + bool we_removed_out_adjacent_; + OakNodeBlock *out_adjacent_; + OakUndoCommand *out_adjacent_remove_command_; + bool out_adjacent_orphaned_; +}; + +/** + * @brief Destructively places `block` at the in point `start` + * + * The Block is guaranteed to be placed at the starting point specified. If there are Blocks in this area, they are + * either trimmed or removed to make space for this Block. Additionally, if the Block is placed beyond the end of + * the Sequence, a GapBlock is inserted to compensate. + */ +class TrackPlaceBlockCommand : public UndoCommand { +public: + TrackPlaceBlockCommand(OakNodeTrackList *timeline, int track, + OakNodeBlock *block, Rational in) + : timeline_(timeline) + , track_index_(track) + , in_(in) + , gap_(nullptr) + , gap_orphaned_(false) + , insert_(block) + , ripple_remove_command_(nullptr) + { + } + + virtual ~TrackPlaceBlockCommand() override; + +protected: + virtual void redo() override; + + virtual void undo() override; + +private: + OakNodeTrackList *timeline_; + int track_index_; + Rational in_; + OakNodeBlock *gap_; + bool gap_orphaned_; + OakNodeBlock *insert_; + std::vector add_track_commands_; + TrackRippleRemoveAreaCommand *ripple_remove_command_; +}; + +} + +#endif // OAK_TIMELINEUNDOPOINTER_H diff --git a/src/timeline/src/timelineundoripple.cpp b/src/timeline/src/timelineundoripple.cpp new file mode 100644 index 000000000..081eac00e --- /dev/null +++ b/src/timeline/src/timelineundoripple.cpp @@ -0,0 +1,623 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "timelineundoripple.h" + +#include +#include + +#include "timelineundocommon.h" +#include "timelineutil.h" +#include "undo/undocommand.h" + +namespace olive +{ + +// +// TrackRippleRemoveAreaCommand +// +TrackRippleRemoveAreaCommand::TrackRippleRemoveAreaCommand( + OakNodeTrack *track, const TimeRange &range) + : track_(track) + , range_(range) + , insert_previous_(nullptr) + , allow_splitting_gaps_(false) + , splice_split_command_(nullptr) +{ + trim_out_.block = nullptr; + trim_in_.block = nullptr; +} + +TrackRippleRemoveAreaCommand::~TrackRippleRemoveAreaCommand() +{ + delete splice_split_command_; + for (OakUndoCommand *c : remove_block_commands_) { + free_remove_command(c); + } +} + +void TrackRippleRemoveAreaCommand::prepare() +{ + int n, d; + rat_nd(range_.in(), &n, &d); + + // Determine precisely what will be happening to these tracks + OakNodeBlock *first_block = nullptr; + oaknode_track_get_nearest_block_before_or_at(track_, n, d, &first_block); + + if (!first_block) { + // No blocks at this time, nothing to be done on this track + return; + } + + // Determine if this first block is getting trimmed or removed + bool first_block_is_out_trimmed = block_in(first_block) < range_.in(); + bool first_block_is_in_trimmed = block_out(first_block) > range_.out(); + + // Set's the block that any insert command should insert AFTER. If the first block is not + // getting out-trimmed, that means first block is either getting removed or in-trimmed, which + // means any insert should happen before it + insert_previous_ = + first_block_is_out_trimmed ? first_block : block_previous(first_block); + + // If it's getting trimmed, determine if it's actually getting spliced + if (first_block_is_out_trimmed && first_block_is_in_trimmed) { + int kind = OAKNODE_BLOCK_OTHER; + oaknode_block_get_kind(first_block, &kind); + if (!allow_splitting_gaps_ && kind == OAKNODE_BLOCK_GAP) { + // As a rule, we don't split gaps, so we just treat it as a trim of the range requested + trim_out_ = { first_block, block_length(first_block), + block_length(first_block) - range_.length() }; + } else { + // This block is getting spliced, so we'll handle that later + splice_split_command_ = + new BlockSplitCommand(first_block, range_.in()); + } + } else { + // It's just getting trimmed or removed, so we'll append that operation + if (first_block_is_out_trimmed) { + trim_out_ = { first_block, block_length(first_block), + block_length(first_block) - + (block_out(first_block) - range_.in()) }; + } else if (first_block_is_in_trimmed) { + // Block is getting in trimmed + trim_in_ = { first_block, block_length(first_block), + block_length(first_block) - + (range_.out() - block_in(first_block)) }; + } else { + // We know for sure this block is within the range so it will be removed + removals_.push_back( + RemoveOperation({ first_block, block_previous(first_block) })); + } + + // If the first block is getting in trimmed, we're already at the end of our range + if (!first_block_is_in_trimmed) { + // Loop through the rest of the blocks and determine what to do with those + for (OakNodeBlock *next = block_next(first_block); next; + next = block_next(next)) { + bool trimming = (block_out(next) > range_.out()); + + if (trimming) { + trim_in_ = { next, block_length(next), + block_length(next) - + (range_.out() - block_in(next)) }; + break; + } else { + removals_.push_back( + RemoveOperation({ next, block_previous(next) })); + + if (block_out(next) == range_.out()) { + break; + } + } + } + } + } +} + +void TrackRippleRemoveAreaCommand::redo() +{ + if (splice_split_command_) { + // We're just splicing + splice_split_command_->redo_now(); + + // Trim the in of the split + OakNodeBlock *split = splice_split_command_->new_block(); + block_set_length_and_media_in( + split, block_length(split) - (range_.out() - block_in(split))); + } else { + if (trim_out_.block) { + block_set_length_and_media_out(trim_out_.block, + trim_out_.new_length); + } + + if (trim_in_.block) { + block_set_length_and_media_in(trim_in_.block, trim_in_.new_length); + } + + // Perform removals + if (!removals_.empty()) { + for (const RemoveOperation &op : removals_) { + // Ripple remove them all first + oaknode_track_ripple_remove_block(track_, op.block); + } + + // Create undo commands for node removals where possible + if (remove_block_commands_.empty()) { + for (const RemoveOperation &op : removals_) { + if (node_can_be_removed(op.block)) { + remove_block_commands_.push_back( + create_remove_command(op.block)); + } + } + } + + for (OakUndoCommand *c : remove_block_commands_) { + oakundo_command_redo_now(c); + } + } + } +} + +void TrackRippleRemoveAreaCommand::undo() +{ + if (splice_split_command_) { + splice_split_command_->undo_now(); + } else { + if (trim_out_.block) { + block_set_length_and_media_out(trim_out_.block, + trim_out_.old_length); + } + + if (trim_in_.block) { + block_set_length_and_media_in(trim_in_.block, trim_in_.old_length); + } + + // Un-remove any blocks + for (auto it = remove_block_commands_.rbegin(); + it != remove_block_commands_.rend(); it++) { + oakundo_command_undo_now(*it); + } + + for (const RemoveOperation &op : removals_) { + oaknode_track_insert_block_after(track_, op.block, op.before); + } + } +} + +// +// TrackListRippleRemoveAreaCommand +// +void TrackListRippleRemoveAreaCommand::prepare() +{ + int count = 0; + oaknode_tracklist_get_track_count(list_, &count); + + for (int i = 0; i < count; i++) { + OakNodeTrack *track = nullptr; + oaknode_tracklist_get_track_at(list_, i, &track); + if (!track) { + continue; + } + + int locked = 0; + oaknode_track_get_locked(track, &locked); + if (locked) { + continue; + } + + TrackRippleRemoveAreaCommand *c = + new TrackRippleRemoveAreaCommand(track, range_); + commands_.push_back(c); + working_tracks_.push_back(track); + } +} + +void TrackListRippleRemoveAreaCommand::redo() +{ + for (TrackRippleRemoveAreaCommand *c : commands_) { + c->redo_now(); + } +} + +void TrackListRippleRemoveAreaCommand::undo() +{ + for (TrackRippleRemoveAreaCommand *c : commands_) { + c->undo_now(); + } +} + +// +// TimelineRippleRemoveAreaCommand +// +TimelineRippleRemoveAreaCommand::TimelineRippleRemoveAreaCommand( + OakNodeSequence *timeline, Rational in, Rational out) +{ + for (int i = 0; i < OAKNODE_TRACK_TYPE_COUNT; i++) { + OakNodeTrackList *list = nullptr; + oaknode_sequence_get_track_list(timeline, i, &list); + if (list) { + add_child(new TrackListRippleRemoveAreaCommand(list, in, out)); + } + } +} + +// +// TrackListRippleToolCommand +// +TrackListRippleToolCommand::TrackListRippleToolCommand( + OakNodeTrackList *track_list, + const std::map &info, + const Rational &ripple_movement, + const Timeline::MovementMode &movement_mode) + : track_list_(track_list) + , info_(info) + , ripple_movement_(ripple_movement) + , movement_mode_(movement_mode) +{ +} + +TrackListRippleToolCommand::~TrackListRippleToolCommand() +{ + // Free any gaps still owned by this command (detached from the graph) + for (auto &pair : working_data_) { + WorkingData &wd = pair.second; + if (wd.created_gap && wd.created_gap_orphaned) { + oaknode_block_free(wd.created_gap); + } + if (wd.removed_gap && wd.removed_gap_orphaned) { + oaknode_block_free(wd.removed_gap); + } + } +} + +void TrackListRippleToolCommand::ripple(bool redo) +{ + if (info_.empty()) { + return; + } + + // The following variables are used to determine how much of the cache to invalidate + + // If we can shift, we will shift from the latest out before the ripple to the latest out after, + // since those sections will be unchanged by this ripple + Rational pre_latest_out = RATIONAL_MIN; + Rational post_latest_out = RATIONAL_MIN; + + // Make timeline changes + for (const auto &pair : info_) { + OakNodeTrack *track = pair.first; + const RippleInfo &info = pair.second; + WorkingData working_data = working_data_[track]; + OakNodeBlock *b = info.block; + + // Generate block length + Rational new_block_length; + Rational operation_movement = ripple_movement_; + + if (movement_mode_ == Timeline::k_trim_in) { + operation_movement = -operation_movement; + } + + if (!redo) { + operation_movement = -operation_movement; + } + + if (b) { + new_block_length = block_length(b) + operation_movement; + } + + Rational pre_shift; + Rational post_shift; + + if (info.append_gap) { + // Rather than rippling the referenced block, we'll insert a gap and ripple with that + OakNodeBlock *gap = working_data.created_gap; + + if (redo) { + if (!gap) { + gap = oaknode_block_gap_create(); + block_set_length_and_media_out( + gap, ripple_movement_ < Rational(0) + ? -ripple_movement_ + : ripple_movement_); + working_data.created_gap = gap; + working_data.created_gap_orphaned = true; + } + + block_add_to_graph(gap, track); + oaknode_track_insert_block_before(track, gap, b); + working_data.created_gap_orphaned = false; + + // As an insertion, we will shift from the gap's in to the gap's out + pre_shift = block_in(gap); + post_shift = block_out(gap); + working_data.earliest_point_of_change = block_in(gap); + } else { + // As a removal, we will shift from the gap's out to the gap's in + pre_shift = block_out(gap); + post_shift = block_in(gap); + + oaknode_track_ripple_remove_block(track, gap); + block_remove_from_graph(gap, track); + working_data.created_gap_orphaned = true; + } + + } else if ((redo && new_block_length.isNull()) || + (!redo && !block_track(b))) { + // The ripple is the length of this block. We assume that for this to happen, it must have + // been a gap that we will now remove. + + if (redo) { + // The earliest point changes will happen is at the start of this block + working_data.earliest_point_of_change = block_in(b); + + // As a removal, we will be shifting from the out point to the in point + pre_shift = block_out(b); + post_shift = block_in(b); + + // Remove gap from track and from graph + working_data.removed_gap = b; + working_data.removed_gap_after = block_previous(b); + oaknode_track_ripple_remove_block(track, b); + block_remove_from_graph(b, track); + working_data.removed_gap_orphaned = true; + } else { + // Restore gap to graph and track + block_add_to_graph(b, track); + oaknode_track_insert_block_after( + track, b, working_data.removed_gap_after); + working_data.removed_gap_orphaned = false; + + // The earliest point changes will happen is at the start of this block + working_data.earliest_point_of_change = block_in(b); + + // As an insert, we will be shifting from the block's in point to its out point + pre_shift = block_in(b); + post_shift = block_out(b); + } + + } else { + // Store old length + working_data.old_length = block_length(b); + + if (movement_mode_ == Timeline::k_trim_in) { + // The earliest point changes will occur is in point of this bloc + working_data.earliest_point_of_change = block_in(b); + + // Undo the trim in inversion we do above, this will still be inverted accurately for + // undoing where appropriate + Rational inverted = -operation_movement; + if (inverted > 0) { + pre_shift = block_in(b) + inverted; + post_shift = block_in(b); + } else { + pre_shift = block_in(b); + post_shift = block_in(b) - inverted; + } + + // Update length + block_set_length_and_media_in(b, new_block_length); + } else { + // The earliest point changes will occur is the out point if trimming out or the in point + // if trimming in + working_data.earliest_point_of_change = block_out(b); + + // The latest out before the ripple is this block's current out point + pre_shift = block_out(b); + + // Update length + block_set_length_and_media_out(b, new_block_length); + + // The latest out after the ripple is this block's out point after the length change + post_shift = block_out(b); + } + } + + working_data_[track] = working_data; + + pre_latest_out = std::max(pre_latest_out, pre_shift); + post_latest_out = std::max(post_latest_out, post_shift); + } +} + +// +// TimelineRippleDeleteGapsAtRegionsCommand +// +namespace +{ + +bool is_gap(OakNodeBlock *b) +{ + if (!b) { + return false; + } + int kind = OAKNODE_BLOCK_OTHER; + oaknode_block_get_kind(b, &kind); + return kind == OAKNODE_BLOCK_GAP; +} + +} // namespace + +void TimelineRippleDeleteGapsAtRegionsCommand::prepare() +{ + size_t max_gaps = 0; + std::map> requested_gaps; + + // Convert regions to gaps + for (const auto ®ion : regions_) { + OakNodeTrack *track = region.first; + const TimeRange &range = region.second; + + int n, d; + rat_nd(range.in(), &n, &d); + OakNodeBlock *block = nullptr; + oaknode_track_get_nearest_block_before_or_at(track, n, d, &block); + + if (is_gap(block)) { + std::vector &gaps_on_track = + requested_gaps[track]; + + RemovalRequest this_req = { block, range }; + + // Insertion sort + bool inserted = false; + for (size_t i = 0; i < gaps_on_track.size(); i++) { + if (gaps_on_track.at(i).range.in() < range.in()) { + gaps_on_track.insert(gaps_on_track.begin() + i, this_req); + inserted = true; + break; + } + } + if (!inserted) { + gaps_on_track.push_back(this_req); + } + + max_gaps = std::max(max_gaps, gaps_on_track.size()); + } else { + fprintf(stderr, "Failed to find corresponding gap to region\n"); + } + } + + // For each gap on each track, find a corresponding gap on every other track (which may include + // a requested gap) to ripple in order to keep everything synchronized + std::map gap_lengths; + for (size_t gap_index = 0; gap_index < max_gaps; gap_index++) { + Rational earliest_point = RATIONAL_MAX; + Rational ripple_length = RATIONAL_MAX; + Rational latest_point = RATIONAL_MIN; + + for (const auto &pair : requested_gaps) { + const std::vector &gaps_on_track = pair.second; + if (gap_index < gaps_on_track.size()) { + const RemovalRequest &gap = gaps_on_track.at(gap_index); + earliest_point = std::min(earliest_point, gap.range.in()); + ripple_length = std::min(ripple_length, gap.range.length()); + latest_point = std::max(latest_point, gap.range.out()); + } + } + + // Determine which gaps will be involved in this operation + std::vector gaps; + + int track_count = 0; + oaknode_sequence_get_all_track_count(timeline_, &track_count); + for (int ti = 0; ti < track_count; ti++) { + OakNodeTrack *track = nullptr; + oaknode_sequence_get_all_track_at(timeline_, ti, &track); + if (!track) { + continue; + } + + int locked = 0; + oaknode_track_get_locked(track, &locked); + if (locked) { + continue; + } + + auto req_it = requested_gaps.find(track); + const std::vector empty_list; + const std::vector &requested_gaps_on_track = + req_it != requested_gaps.end() ? req_it->second : empty_list; + + OakNodeBlock *gap = nullptr; + if (gap_index < requested_gaps_on_track.size()) { + // A requested gap was at this index, use it + gap = requested_gaps_on_track.at(gap_index).gap; + } else { + // No requested gap was at this index, find one + int n, d; + rat_nd(earliest_point, &n, &d); + OakNodeBlock *block = nullptr; + oaknode_track_get_nearest_block_after_or_at(track, n, d, + &block); + + if (block) { + // Found a block, test if it's a gap + if (is_gap(block)) { + gap = block; + } else { + if (block_in(block) == earliest_point) { + OakNodeBlock *next = block_next(block); + if (is_gap(next)) { + gap = next; + } else { + ripple_length = 0; + } + } else { + OakNodeBlock *prev = block_previous(block); + if (is_gap(prev)) { + gap = prev; + } else { + ripple_length = 0; + } + } + } + } else { + // Assume track finishes here and track won't be affected by this operation + } + } + + if (gap) { + gaps.push_back(gap); + + if (!gap_lengths.count(gap)) { + gap_lengths[gap] = block_length(gap); + } + + ripple_length = std::min(ripple_length, gap_lengths[gap]); + } + + if (ripple_length == 0) { + break; + } + } + + if (ripple_length > 0) { + for (OakNodeBlock *gap : gaps) { + if (gap_lengths[gap] == ripple_length) { + commands_.push_back(new TrackRippleRemoveBlockCommand( + block_track(gap), gap)); + } else { + gap_lengths[gap] -= ripple_length; + commands_.push_back( + new BlockResizeCommand(gap, gap_lengths[gap])); + } + } + } + } +} + +void TimelineRippleDeleteGapsAtRegionsCommand::redo() +{ + for (UndoCommand *c : commands_) { + c->redo_now(); + } +} + +void TimelineRippleDeleteGapsAtRegionsCommand::undo() +{ + for (auto it = commands_.rbegin(); it != commands_.rend(); it++) { + (*it)->undo_now(); + } +} + +} diff --git a/src/timeline/src/timelineundoripple.h b/src/timeline/src/timelineundoripple.h new file mode 100644 index 000000000..e6eda82b1 --- /dev/null +++ b/src/timeline/src/timelineundoripple.h @@ -0,0 +1,243 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEUNDORIPPLE_H +#define OAK_TIMELINEUNDORIPPLE_H + +#include +#include +#include + +#include + +#include "node/block.h" +#include "node/sequence.h" +#include "node/track.h" +#include "timelinecommon.h" +#include "timelineundogeneral.h" +#include "timelineundosplit.h" +#include "timelineundotrack.h" +#include "undocommand.h" + +using namespace olive::core; + +namespace olive +{ + +/** + * @brief Clears the area between in and out + * + * The area between `in` and `out` is guaranteed to be freed. Blocks are trimmed and removed to free this space. + * By default, nothing takes this area meaning all subsequent clips are pushed backward, however you can specify + * a block to insert at the `in` point. No checking is done to ensure `insert` is the same length as `in` to `out`. + */ +class TrackRippleRemoveAreaCommand : public UndoCommand { +public: + TrackRippleRemoveAreaCommand(OakNodeTrack *track, const TimeRange &range); + + virtual ~TrackRippleRemoveAreaCommand() override; + + /** + * @brief Block to insert after if you want to insert something between this ripple + */ + OakNodeBlock *get_insertion_index() const + { + return insert_previous_; + } + + OakNodeBlock *get_spliced_block() const + { + if (splice_split_command_) { + return splice_split_command_->new_block(); + } + + return nullptr; + } + + void set_allow_splitting_gaps(bool e) + { + allow_splitting_gaps_ = e; + } + +protected: + virtual void prepare() override; + + virtual void redo() override; + + virtual void undo() override; + +private: + struct TrimOperation { + OakNodeBlock *block; + Rational old_length; + Rational new_length; + }; + + struct RemoveOperation { + OakNodeBlock *block; + OakNodeBlock *before; + }; + + OakNodeTrack *track_; + TimeRange range_; + + TrimOperation trim_out_; + std::vector removals_; + TrimOperation trim_in_; + OakNodeBlock *insert_previous_; + bool allow_splitting_gaps_; + + BlockSplitCommand *splice_split_command_; + std::vector remove_block_commands_; +}; + +class TrackListRippleRemoveAreaCommand : public UndoCommand { +public: + TrackListRippleRemoveAreaCommand(OakNodeTrackList *list, Rational in, + Rational out) + : list_(list) + , range_(in, out) + { + } + + virtual ~TrackListRippleRemoveAreaCommand() override + { + for (TrackRippleRemoveAreaCommand *c : commands_) { + delete c; + } + } + +protected: + virtual void prepare() override; + + virtual void redo() override; + + virtual void undo() override; + +private: + OakNodeTrackList *list_; + + std::vector working_tracks_; + + TimeRange range_; + + std::vector commands_; +}; + +class TimelineRippleRemoveAreaCommand : public MultiUndoCommand { +public: + TimelineRippleRemoveAreaCommand(OakNodeSequence *timeline, Rational in, + Rational out); +}; + +class TrackListRippleToolCommand : public UndoCommand { +public: + struct RippleInfo { + OakNodeBlock *block; + bool append_gap; + }; + + TrackListRippleToolCommand( + OakNodeTrackList *track_list, + const std::map &info, + const Rational &ripple_movement, + const Timeline::MovementMode &movement_mode); + + virtual ~TrackListRippleToolCommand() override; + +protected: + virtual void redo() override + { + ripple(true); + } + + virtual void undo() override + { + ripple(false); + } + +private: + void ripple(bool redo); + + OakNodeTrackList *track_list_; + + std::map info_; + Rational ripple_movement_; + Timeline::MovementMode movement_mode_; + + struct WorkingData { + OakNodeBlock *created_gap = nullptr; + bool created_gap_orphaned = false; + OakNodeBlock *removed_gap = nullptr; + bool removed_gap_orphaned = false; + OakNodeBlock *removed_gap_after = nullptr; + Rational old_length; + Rational earliest_point_of_change; + }; + + std::map working_data_; +}; + +class TimelineRippleDeleteGapsAtRegionsCommand : public UndoCommand { +public: + using RangeList = std::vector>; + + TimelineRippleDeleteGapsAtRegionsCommand(OakNodeSequence *vo, + const RangeList ®ions) + : timeline_(vo) + , regions_(regions) + { + } + + virtual ~TimelineRippleDeleteGapsAtRegionsCommand() override + { + for (UndoCommand *c : commands_) { + delete c; + } + } + + bool has_commands() const + { + return !commands_.empty(); + } + +protected: + virtual void prepare() override; + + virtual void redo() override; + + virtual void undo() override; + +private: + OakNodeSequence *timeline_; + RangeList regions_; + + std::vector commands_; + + struct RemovalRequest { + OakNodeBlock *gap; + TimeRange range; + }; +}; + +} + +#endif // OAK_TIMELINEUNDORIPPLE_H diff --git a/src/timeline/src/timelineundosplit.cpp b/src/timeline/src/timelineundosplit.cpp new file mode 100644 index 000000000..f5e8e9914 --- /dev/null +++ b/src/timeline/src/timelineundosplit.cpp @@ -0,0 +1,308 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "timelineundosplit.h" + +#include + +#include "node/node.h" +#include "undo/undostack.h" + +namespace olive +{ + +namespace +{ + +void rat_nd(const Rational &r, int *n, int *d) +{ + *n = r.numerator(); + *d = r.denominator(); +} + +/** + * @brief oaknode-block-link undo command (replaces NodeLinkCommand from + * oaknode's nodeundo, which does not cross the module boundary) + */ +class BlockLinkUndoCommand : public UndoCommand { +public: + BlockLinkUndoCommand(OakNodeBlock *a, OakNodeBlock *b, bool link) + : a_(a) + , b_(b) + , link_(link) + { + } + +protected: + virtual void redo() override + { + if (link_) { + oaknode_block_link(a_, b_); + } else { + oaknode_block_unlink(a_, b_); + } + } + + virtual void undo() override + { + if (link_) { + oaknode_block_unlink(a_, b_); + } else { + oaknode_block_link(a_, b_); + } + } + +private: + OakNodeBlock *a_; + OakNodeBlock *b_; + bool link_; +}; + +} // namespace + +// +// BlockSplitCommand +// +BlockSplitCommand::~BlockSplitCommand() +{ + if (reconnect_tree_command_) { + oakundo_command_free(reconnect_tree_command_); + } +} + +void BlockSplitCommand::prepare() +{ + OakNodeNode *copy = oaknode_node_copy_in_graph( + oaknode_block_as_node(block_), &reconnect_tree_command_); + new_block_ = oaknode_block_from_node(copy); +} + +void BlockSplitCommand::redo() +{ + int n, d; + + oaknode_block_get_length(block_, &n, &d); + old_length_ = Rational(n, d); + + int block_in_n, block_in_d, block_out_n, block_out_d; + oaknode_block_get_in(block_, &block_in_n, &block_in_d); + oaknode_block_get_out(block_, &block_out_n, &block_out_d); + Rational block_in(block_in_n, block_in_d); + Rational block_out(block_out_n, block_out_d); + + assert(point_ > block_in && point_ < block_out); + + if (reconnect_tree_command_) { + oakundo_command_redo_now(reconnect_tree_command_); + } + + // Determine our new lengths + Rational new_length = point_ - block_in; + Rational new_part_length = block_out - point_; + + // Begin an operation + OakNodeTrack *track = nullptr; + oaknode_block_get_track(block_, &track); + + // Set lengths + rat_nd(new_length, &n, &d); + oaknode_block_set_length_and_media_out(block_, n, d); + rat_nd(new_part_length, &n, &d); + oaknode_block_set_length_and_media_in(new_block_, n, d); + + // Insert new block + oaknode_track_insert_block_after(track, new_block_, block_); + + oaknode_clip_add_cache_passthrough_from(new_block_, block_); + + // If the block had an out transition, we move it to the new block + moved_transition_ = nullptr; + moved_transition_input_.clear(); + + OakNodeBlock *next = nullptr; + oaknode_block_get_next(new_block_, &next); + int next_kind = OAKNODE_BLOCK_OTHER; + if (next) { + oaknode_block_get_kind(next, &next_kind); + } + if (next_kind == OAKNODE_BLOCK_TRANSITION) { + // A transition is a node connected to the old block's output + int conn_count = 0; + oaknode_node_output_connection_count( + oaknode_block_as_node(block_), &conn_count); + for (int i = 0; i < conn_count; i++) { + OakNodeNode *conn_node = nullptr; + oaknode_node_output_connection_node_at( + oaknode_block_as_node(block_), i, &conn_node); + if (oaknode_block_from_node(conn_node) != next) { + continue; + } + + char id_buf[64]; + if (oaknode_node_output_connection_input_id_at( + oaknode_block_as_node(block_), i, id_buf, + sizeof(id_buf)) < 0) { + continue; + } + + moved_transition_ = next; + moved_transition_input_ = id_buf; + oaknode_node_disconnect(oaknode_block_as_node(next), id_buf); + oaknode_node_connect(oaknode_block_as_node(new_block_), + oaknode_block_as_node(next), + id_buf); + break; + } + } +} + +void BlockSplitCommand::undo() +{ + OakNodeTrack *track = nullptr; + oaknode_block_get_track(block_, &track); + + if (moved_transition_) { + oaknode_node_disconnect(oaknode_block_as_node(moved_transition_), + moved_transition_input_.c_str()); + oaknode_node_connect(oaknode_block_as_node(block_), + oaknode_block_as_node(moved_transition_), + moved_transition_input_.c_str()); + } + + int n, d; + rat_nd(old_length_, &n, &d); + oaknode_block_set_length_and_media_out(block_, n, d); + oaknode_track_ripple_remove_block(track, new_block_); + + // If we ran a reconnect command, disconnect now + if (reconnect_tree_command_) { + oakundo_command_undo_now(reconnect_tree_command_); + } +} + +// +// BlockSplitPreservingLinksCommand +// +BlockSplitPreservingLinksCommand::~BlockSplitPreservingLinksCommand() +{ + for (UndoCommand *c : commands_) { + delete c; + } +} + +OakNodeBlock * +BlockSplitPreservingLinksCommand::get_split(OakNodeBlock *original, + int time_index) const +{ + if (time_index >= 0 && time_index < int(times_.size())) { + for (size_t i = 0; i < blocks_.size(); i++) { + if (blocks_[i] == original) { + return splits_.at(time_index).at(i); + } + } + } + + return nullptr; +} + +void BlockSplitPreservingLinksCommand::prepare() +{ + splits_.resize(times_.size()); + + for (size_t i = 0; i < times_.size(); i++) { + const Rational &time = times_.at(i); + + // FIXME: I realize this isn't going to work if the times aren't ordered. I'm lazy so rather + // than writing in a sorting algorithm here, I'll just put an assert as a reminder + // if this ever becomes an issue. + assert(i == 0 || time > times_.at(i - 1)); + + std::vector splits(blocks_.size(), nullptr); + + for (size_t j = 0; j < blocks_.size(); j++) { + OakNodeBlock *b = blocks_.at(j); + + int in_n, in_d, out_n, out_d; + oaknode_block_get_in(b, &in_n, &in_d); + oaknode_block_get_out(b, &out_n, &out_d); + Rational b_in(in_n, in_d); + Rational b_out(out_n, out_d); + + if (b_in < time && b_out > time) { + BlockSplitCommand *split_command = + new BlockSplitCommand(b, time); + split_command->redo_now(); + splits[j] = split_command->new_block(); + commands_.push_back(split_command); + } + } + + splits_[i] = splits; + } + + // Now that we've determined all the splits, we can relink everything + for (size_t i = 0; i < blocks_.size(); i++) { + OakNodeBlock *a = blocks_.at(i); + + for (size_t j = 0; j < blocks_.size(); j++) { + if (i == j) { + continue; + } + + OakNodeBlock *b = blocks_.at(j); + + int linked = 0; + oaknode_block_are_linked(a, b, &linked); + if (linked) { + // These blocks are linked, ensure all the splits are linked too + for (const std::vector &split_list : + splits_) { + if (!split_list.at(i) || !split_list.at(j)) { + continue; + } + BlockLinkUndoCommand *blc = new BlockLinkUndoCommand( + split_list.at(i), split_list.at(j), true); + blc->redo_now(); + commands_.push_back(blc); + } + } + } + } +} + +// +// TrackSplitAtTimeCommand +// +void TrackSplitAtTimeCommand::prepare() +{ + // Find Block that contains this time + int n, d; + rat_nd(point_, &n, &d); + + OakNodeBlock *b = nullptr; + oaknode_track_get_block_containing_time(track_, n, d, &b); + + if (b) { + command_ = new BlockSplitCommand(b, point_); + } +} + +} diff --git a/src/timeline/src/timelineundosplit.h b/src/timeline/src/timelineundosplit.h new file mode 100644 index 000000000..ff3f89f3e --- /dev/null +++ b/src/timeline/src/timelineundosplit.h @@ -0,0 +1,168 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEUNDOSPLIT_H +#define OAK_TIMELINEUNDOSPLIT_H + +#include +#include + +#include + +#include "node/block.h" +#include "node/track.h" +#include "undo/undocommand.h" +#include "undocommand.h" + +using namespace olive::core; + +namespace olive +{ + +/** + * @brief Undo commands for splitting blocks, over oaknode C handles + * + * All graph operations go through the oaknode C ABI (01 §0 rule 6). + */ +class BlockSplitCommand : public UndoCommand { +public: + BlockSplitCommand(OakNodeBlock *block, Rational point) + : block_(block) + , new_block_(nullptr) + , point_(point) + , reconnect_tree_command_(nullptr) + , moved_transition_(nullptr) + { + } + + virtual ~BlockSplitCommand() override; + + /** + * @brief Access the second block created as a result. Only valid after redo(). + */ + OakNodeBlock *new_block() + { + return new_block_; + } + +protected: + virtual void prepare() override; + + virtual void redo() override; + + virtual void undo() override; + +private: + OakNodeBlock *block_; + OakNodeBlock *new_block_; + + Rational old_length_; + Rational point_; + + OakUndoCommand *reconnect_tree_command_; + + OakNodeBlock *moved_transition_; + std::string moved_transition_input_; +}; + +class BlockSplitPreservingLinksCommand : public UndoCommand { +public: + BlockSplitPreservingLinksCommand( + const std::vector &blocks, + const std::vector ×) + : blocks_(blocks) + , times_(times) + { + } + + virtual ~BlockSplitPreservingLinksCommand() override; + + OakNodeBlock *get_split(OakNodeBlock *original, int time_index) const; + +protected: + virtual void prepare() override; + + virtual void redo() override + { + for (UndoCommand *c : commands_) { + c->redo_now(); + } + } + + virtual void undo() override + { + for (auto it = commands_.rbegin(); it != commands_.rend(); it++) { + (*it)->undo_now(); + } + } + +private: + std::vector blocks_; + + std::vector times_; + + std::vector commands_; + + std::vector> splits_; +}; + +class TrackSplitAtTimeCommand : public UndoCommand { +public: + TrackSplitAtTimeCommand(OakNodeTrack *track, Rational point) + : track_(track) + , point_(point) + , command_(nullptr) + { + } + + virtual ~TrackSplitAtTimeCommand() override + { + delete command_; + } + +protected: + virtual void prepare() override; + + virtual void redo() override + { + if (command_) { + command_->redo_now(); + } + } + + virtual void undo() override + { + if (command_) { + command_->undo_now(); + } + } + +private: + OakNodeTrack *track_; + + Rational point_; + + UndoCommand *command_; +}; + +} + +#endif // OAK_TIMELINEUNDOSPLIT_H diff --git a/src/timeline/src/timelineundotrack.h b/src/timeline/src/timelineundotrack.h new file mode 100644 index 000000000..8aee94829 --- /dev/null +++ b/src/timeline/src/timelineundotrack.h @@ -0,0 +1,154 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEUNDOTRACK_H +#define OAK_TIMELINEUNDOTRACK_H + +#include "node/block.h" +#include "node/track.h" +#include "undocommand.h" + +namespace olive +{ + +/** + * @brief Undo commands over oaknode track/block handles + * + * All graph operations go through the oaknode C ABI (01 §0 rule 6: no + * cross-module C++ member calls). + */ +class TrackRippleRemoveBlockCommand : public UndoCommand { +public: + TrackRippleRemoveBlockCommand(OakNodeTrack *track, OakNodeBlock *block) + : track_(track) + , block_(block) + , before_(nullptr) + { + } + +protected: + virtual void redo() override + { + oaknode_block_get_previous(block_, &before_); + oaknode_track_ripple_remove_block(track_, block_); + } + + virtual void undo() override + { + oaknode_track_insert_block_after(track_, block_, before_); + } + +private: + OakNodeTrack *track_; + + OakNodeBlock *block_; + + OakNodeBlock *before_; +}; + +class TrackPrependBlockCommand : public UndoCommand { +public: + TrackPrependBlockCommand(OakNodeTrack *track, OakNodeBlock *block) + : track_(track) + , block_(block) + { + } + +protected: + virtual void redo() override + { + oaknode_track_prepend_block(track_, block_); + } + + virtual void undo() override + { + oaknode_track_ripple_remove_block(track_, block_); + } + +private: + OakNodeTrack *track_; + OakNodeBlock *block_; +}; + +class TrackInsertBlockAfterCommand : public UndoCommand { +public: + TrackInsertBlockAfterCommand(OakNodeTrack *track, OakNodeBlock *block, + OakNodeBlock *before) + : track_(track) + , block_(block) + , before_(before) + { + } + +protected: + virtual void redo() override + { + oaknode_track_insert_block_after(track_, block_, before_); + } + + virtual void undo() override + { + oaknode_track_ripple_remove_block(track_, block_); + } + +private: + OakNodeTrack *track_; + + OakNodeBlock *block_; + + OakNodeBlock *before_; +}; + +/** + * @brief Replaces Block `old` with Block `replace` + * + * Both blocks must have equal lengths. + */ +class TrackReplaceBlockCommand : public UndoCommand { +public: + TrackReplaceBlockCommand(OakNodeTrack *track, OakNodeBlock *old_block, + OakNodeBlock *replace) + : track_(track) + , old_(old_block) + , replace_(replace) + { + } + +protected: + virtual void redo() override + { + oaknode_track_replace_block(track_, old_, replace_); + } + + virtual void undo() override + { + oaknode_track_replace_block(track_, replace_, old_); + } + +private: + OakNodeTrack *track_; + OakNodeBlock *old_; + OakNodeBlock *replace_; +}; + +} + +#endif // OAK_TIMELINEUNDOTRACK_H diff --git a/src/timeline/src/timelineundoworkarea.h b/src/timeline/src/timelineundoworkarea.h new file mode 100644 index 000000000..1952cac9c --- /dev/null +++ b/src/timeline/src/timelineundoworkarea.h @@ -0,0 +1,95 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEUNDOWORKAREA_H +#define OAK_TIMELINEUNDOWORKAREA_H + +#include "timelineworkarea.h" +#include "undocommand.h" + +namespace olive +{ + +class WorkareaSetEnabledCommand : public UndoCommand { +public: + WorkareaSetEnabledCommand(TimelineWorkArea *points, bool enabled) + : points_(points) + , old_enabled_(points_->enabled()) + , new_enabled_(enabled) + { + } + +protected: + virtual void redo() override + { + points_->set_enabled(new_enabled_); + } + + virtual void undo() override + { + points_->set_enabled(old_enabled_); + } + +private: + TimelineWorkArea *points_; + + bool old_enabled_; + + bool new_enabled_; +}; + +class WorkareaSetRangeCommand : public UndoCommand { +public: + WorkareaSetRangeCommand(TimelineWorkArea *workarea, const TimeRange &range, + const TimeRange &old_range) + : workarea_(workarea) + , old_range_(old_range) + , new_range_(range) + { + } + + WorkareaSetRangeCommand(TimelineWorkArea *workarea, const TimeRange &range) + : WorkareaSetRangeCommand(workarea, range, workarea->range()) + { + } + +protected: + virtual void redo() override + { + workarea_->set_range(new_range_); + } + + virtual void undo() override + { + workarea_->set_range(old_range_); + } + +private: + TimelineWorkArea *workarea_; + + TimeRange old_range_; + + TimeRange new_range_; +}; + +} + +#endif // OAK_TIMELINEUNDOWORKAREA_H diff --git a/src/timeline/src/timelineutil.h b/src/timeline/src/timelineutil.h new file mode 100644 index 000000000..46a347f98 --- /dev/null +++ b/src/timeline/src/timelineutil.h @@ -0,0 +1,144 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEUTIL_H +#define OAK_TIMELINEUTIL_H + +#include + +#include "node/block.h" +#include "node/project.h" +#include "node/sequence.h" +#include "node/track.h" + +namespace olive +{ + +/** + * @brief Small helpers translating between olive::core::Rational and the + * oaknode C ABI's numerator/denominator pairs + */ +inline void rat_nd(const olive::core::Rational &r, int *n, int *d) +{ + *n = r.numerator(); + *d = r.denominator(); +} + +inline olive::core::Rational block_in(OakNodeBlock *b) +{ + int n, d; + oaknode_block_get_in(b, &n, &d); + return olive::core::Rational(n, d); +} + +inline olive::core::Rational block_out(OakNodeBlock *b) +{ + int n, d; + oaknode_block_get_out(b, &n, &d); + return olive::core::Rational(n, d); +} + +inline olive::core::Rational block_length(OakNodeBlock *b) +{ + int n, d; + oaknode_block_get_length(b, &n, &d); + return olive::core::Rational(n, d); +} + +inline void block_set_length_and_media_out(OakNodeBlock *b, + const olive::core::Rational &len) +{ + int n, d; + rat_nd(len, &n, &d); + oaknode_block_set_length_and_media_out(b, n, d); +} + +inline void block_set_length_and_media_in(OakNodeBlock *b, + const olive::core::Rational &len) +{ + int n, d; + rat_nd(len, &n, &d); + oaknode_block_set_length_and_media_in(b, n, d); +} + +inline olive::core::Rational track_length(OakNodeTrack *t) +{ + int n, d; + oaknode_track_get_length(t, &n, &d); + return olive::core::Rational(n, d); +} + +inline OakNodeBlock *block_previous(OakNodeBlock *b) +{ + OakNodeBlock *out = nullptr; + oaknode_block_get_previous(b, &out); + return out; +} + +inline OakNodeBlock *block_next(OakNodeBlock *b) +{ + OakNodeBlock *out = nullptr; + oaknode_block_get_next(b, &out); + return out; +} + +inline OakNodeTrack *block_track(OakNodeBlock *b) +{ + OakNodeTrack *out = nullptr; + oaknode_block_get_track(b, &out); + return out; +} + +/** + * @brief Attach/detach a block to/from the project graph that owns a + * track (replaces the original setParent(graph) / setParent(memory)) + */ +inline OakNodeProject *track_project(OakNodeTrack *track) +{ + OakNodeSequence *sequence = nullptr; + if (oaknode_track_get_sequence(track, &sequence) != OAKNODE_OK || + !sequence) { + return nullptr; + } + OakNodeProject *project = nullptr; + oaknode_node_get_project(oaknode_sequence_as_node(sequence), &project); + return project; +} + +inline void block_add_to_graph(OakNodeBlock *b, OakNodeTrack *track) +{ + OakNodeProject *project = track_project(track); + if (project) { + oaknode_project_add_node(project, oaknode_block_as_node(b)); + } +} + +inline void block_remove_from_graph(OakNodeBlock *b, OakNodeTrack *track) +{ + OakNodeProject *project = track_project(track); + if (project) { + oaknode_project_remove_node(project, oaknode_block_as_node(b)); + } +} + +} + +#endif // OAK_TIMELINEUTIL_H diff --git a/src/timeline/src/timelineworkarea.cpp b/src/timeline/src/timelineworkarea.cpp new file mode 100644 index 000000000..9bdee1f40 --- /dev/null +++ b/src/timeline/src/timelineworkarea.cpp @@ -0,0 +1,112 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "timelineworkarea.h" + +#include + +namespace olive +{ + +const Rational TimelineWorkArea::k_reset_in = 0; +const Rational TimelineWorkArea::k_reset_out = RATIONAL_MAX; + +TimelineWorkArea::TimelineWorkArea() + : workarea_enabled_(false) +{ +} + +bool TimelineWorkArea::enabled() const +{ + return workarea_enabled_; +} + +void TimelineWorkArea::set_enabled(bool e) +{ + workarea_enabled_ = e; +} + +const TimeRange &TimelineWorkArea::range() const +{ + return workarea_range_; +} + +void TimelineWorkArea::set_range(const TimeRange &range) +{ + workarea_range_ = range; +} + +bool TimelineWorkArea::load(XmlStreamReader *reader) +{ + Rational range_in = this->in(); + Rational range_out = this->out(); + + for (const XmlStreamAttribute &attr : reader->attributes()) { + // version is currently unused + (void)attr; + } + + while (xml_read_next_start_element(reader)) { + if (reader->name() == "enabled") { + this->set_enabled(reader->read_element_text() != "0"); + } else if (reader->name() == "in") { + range_in = Rational::from_string(reader->read_element_text()); + } else if (reader->name() == "out") { + range_out = Rational::from_string(reader->read_element_text()); + } else { + reader->skip_current_element(); + } + } + + TimeRange loaded_workarea(range_in, range_out); + + if (loaded_workarea != this->range()) { + this->set_range(loaded_workarea); + } + + return true; +} + +void TimelineWorkArea::save(XmlStreamWriter *writer) const +{ + writer->write_attribute("version", "1"); + + writer->write_text_element("enabled", std::to_string(this->enabled())); + writer->write_text_element("in", this->in().to_string()); + writer->write_text_element("out", this->out().to_string()); +} + +Rational TimelineWorkArea::in() const +{ + return workarea_range_.in(); +} + +Rational TimelineWorkArea::out() const +{ + return workarea_range_.out(); +} + +Rational TimelineWorkArea::length() const +{ + return workarea_range_.length(); +} + +} diff --git a/src/timeline/src/timelineworkarea.h b/src/timeline/src/timelineworkarea.h new file mode 100644 index 000000000..2ba77b892 --- /dev/null +++ b/src/timeline/src/timelineworkarea.h @@ -0,0 +1,67 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_TIMELINEWORKAREA_H +#define OAK_TIMELINEWORKAREA_H + +#include + +#include "xmlutils.h" + +namespace olive +{ + +using namespace core; + +/** + * @brief A work area (in/out range) on a timeline + * + * De-Qt version: no QObject, no signals. Change notifications are + * emitted by the caller's layer (facade). + */ +class TimelineWorkArea { +public: + TimelineWorkArea(); + + bool enabled() const; + void set_enabled(bool e); + + Rational in() const; + Rational out() const; + Rational length() const; + const TimeRange &range() const; + void set_range(const TimeRange &range); + + bool load(XmlStreamReader *reader); + void save(XmlStreamWriter *writer) const; + + static const Rational k_reset_in; + static const Rational k_reset_out; + +private: + bool workarea_enabled_; + + TimeRange workarea_range_; +}; + +} + +#endif // OAK_TIMELINEWORKAREA_H diff --git a/src/timeline/standalone/CMakeLists.txt b/src/timeline/standalone/CMakeLists.txt new file mode 100644 index 000000000..d09f2bc07 --- /dev/null +++ b/src/timeline/standalone/CMakeLists.txt @@ -0,0 +1,158 @@ +# Olive - Non-Linear Video Editor +# Copyright (C) 2022 Olive Team +# Modifications Copyright (C) 2025 mikesolar +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +cmake_minimum_required(VERSION 3.16) +project(oaktimeline-standalone CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# oaktimeline manipulates node graphs through the oaknode C ABI; the +# not-yet-split engine modules' symbols are allowed to dangle, resolved +# by transitional stubs in src/{node,render}/transition. +set(OAK_REPO_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../..) +list(APPEND CMAKE_MODULE_PATH "${OAK_REPO_ROOT}/cmake") + +set(BUILD_TESTS ON CACHE BOOL "" FORCE) +set(OLIVECORE_BUILD_TESTS OFF CACHE BOOL "" FORCE) +# The /opt/otio OpenTimelineIO dylibs use @loader_path install names that +# break when linked from a build tree; oaktimeline does not need OTIO. +set(CMAKE_DISABLE_FIND_PACKAGE_OpenTimelineIO ON) + +find_package(EXPAT REQUIRED) +find_package(OpenColorIO CONFIG REQUIRED) +find_package(OpenImageIO CONFIG REQUIRED) +set(OCIO_LIBRARIES OpenColorIO::OpenColorIO) +set(OCIO_INCLUDE_DIRS ${OpenColorIO_INCLUDE_DIRS}) +set(OIIO_LIBRARIES OpenImageIO::OpenImageIO) +set(OIIO_INCLUDE_DIRS ${OpenImageIO_INCLUDE_DIRS}) + +add_subdirectory(${OAK_REPO_ROOT}/core ${CMAKE_BINARY_DIR}/core) +target_include_directories(olivecore PUBLIC + ${OAK_REPO_ROOT}/third_party/openfx/include +) +add_subdirectory(${OAK_REPO_ROOT}/ffmpeg_bridge ${CMAKE_BINARY_DIR}/ffmpeg_bridge) + +set(BUILD_TESTS OFF) +add_subdirectory(${OAK_REPO_ROOT}/src/undo ${CMAKE_BINARY_DIR}/undo) +add_subdirectory(${OAK_REPO_ROOT}/src/common ${CMAKE_BINARY_DIR}/common) +add_subdirectory(${OAK_REPO_ROOT}/src/render/src ${CMAKE_BINARY_DIR}/render) +set(BUILD_TESTS ON) + +enable_testing() +add_subdirectory(${OAK_REPO_ROOT}/src/node ${CMAKE_BINARY_DIR}/node) +add_subdirectory(${OAK_REPO_ROOT}/src/timeline ${CMAKE_BINARY_DIR}/timeline) + +# oaknode needs its transition stubs and the oakrender real headers +# (src/node/transition/render/* bridge there); src/render/transition +# must precede src/node/transition. +target_include_directories(oaknode BEFORE PUBLIC + ${OAK_REPO_ROOT}/src/render/transition + ${OAK_REPO_ROOT}/src/node/transition + ${OAK_REPO_ROOT}/src/render/src +) +target_include_directories(oaknode PUBLIC + ${OAK_REPO_ROOT}/third_party/openfx/HostSupport/include + /opt/homebrew/include + /opt/homebrew/include/Imath +) +target_link_options(oaknode PRIVATE + "-undefined" "dynamic_lookup" +) + +target_include_directories(oakrender BEFORE PUBLIC + ${OAK_REPO_ROOT}/src/render/transition + ${OAK_REPO_ROOT}/src/node/transition +) +target_include_directories(oakrender PUBLIC + ${OAK_REPO_ROOT}/engine/include + ${OAK_REPO_ROOT}/third_party/openfx/HostSupport/include + /opt/homebrew/include + /opt/homebrew/include/Imath + /opt/homebrew/include/OpenEXR +) +foreach(t oakrender oakgl oakgl2 oakvulkan) + if(TARGET ${t}) + target_link_options(${t} PRIVATE + "-undefined" "dynamic_lookup" + ) + endif() +endforeach() + +target_include_directories(oaktimeline PUBLIC + ${OAK_REPO_ROOT}/src/render/transition + ${OAK_REPO_ROOT}/src/node/transition + ${OAK_REPO_ROOT}/src/render/src + ${OAK_REPO_ROOT}/src/node/src +) +target_link_options(oaktimeline PRIVATE + "-undefined" "dynamic_lookup" +) + +target_link_libraries(oakrender PRIVATE + oaknode + oakcommon + oakundo + olivecore + ffmpeg_bridge + ${OCIO_LIBRARIES} + ${OIIO_LIBRARIES} + "-framework OpenGL" + "-framework CoreVideo" + "-framework Metal" + "-framework QuartzCore" +) + +target_link_libraries(oaknode-gtest PRIVATE oakrender) + +# liboakrender references the oakengine_ipc_* C ABI (worker IPC) via +# dynamic_lookup; the real implementation (engine/src/capi/ipc.cpp) is still +# Qt-based, so test binaries link an inert shim instead. +target_sources(oaknode-gtest PRIVATE + ${OAK_REPO_ROOT}/src/node/standalone/oakengine_ipc_shim.cpp +) +target_include_directories(oaknode-gtest PRIVATE + ${OAK_REPO_ROOT}/engine/include +) + +target_link_libraries(oaktimeline-gtest PRIVATE oakrender) +target_sources(oaktimeline-gtest PRIVATE + ${OAK_REPO_ROOT}/src/node/standalone/oakengine_ipc_shim.cpp +) +target_include_directories(oaktimeline-gtest PRIVATE + ${OAK_REPO_ROOT}/engine/include +) + +# OFX typeinfo is referenced via dynamic_lookup; force-load the host +# support archive so RTTI resolves (same as oaknode's test wiring). +find_library(OAKNODE_OFX_HOST_ARCHIVE NAMES OfxHost + PATHS ${OAK_REPO_ROOT}/build/third_party/openfx/HostSupport + NO_DEFAULT_PATH) +if(NOT OAKNODE_OFX_HOST_ARCHIVE) + message(FATAL_ERROR + "libOfxHost.a not found; run the full-tree build once or set " + "OAKNODE_OFX_HOST_ARCHIVE") +endif() +target_link_options(oaktimeline-gtest PRIVATE + "-Wl,-force_load,${OAKNODE_OFX_HOST_ARCHIVE}") +target_link_options(oaknode-gtest PRIVATE + "-Wl,-force_load,${OAKNODE_OFX_HOST_ARCHIVE}") + +# oaknode's Sequence::add_default_nodes() constructs +# TimelineAddTrackCommand, which lives in liboaktimeline; the two +# libraries resolve each other at runtime (dynamic_lookup). +target_link_libraries(oaknode-gtest PRIVATE oaktimeline) diff --git a/src/timeline/tests/CMakeLists.txt b/src/timeline/tests/CMakeLists.txt new file mode 100644 index 000000000..b7f9d3906 --- /dev/null +++ b/src/timeline/tests/CMakeLists.txt @@ -0,0 +1,19 @@ +find_package(GTest REQUIRED) +include(GoogleTest) + +add_executable(oaktimeline-gtest + timelinemarker_test.cpp + timeline_test.cpp +) + +target_link_libraries(oaktimeline-gtest PRIVATE + oaktimeline + GTest::gtest + GTest::gtest_main +) + +gtest_discover_tests(oaktimeline-gtest + DISCOVERY_MODE PRE_TEST + PROPERTIES ENVIRONMENT + "OCIO=${OAK_REPO_ROOT}/engine/render/ocioconf/config.ocio" +) diff --git a/src/timeline/tests/timeline_test.cpp b/src/timeline/tests/timeline_test.cpp new file mode 100644 index 000000000..30c1849c0 --- /dev/null +++ b/src/timeline/tests/timeline_test.cpp @@ -0,0 +1,602 @@ +/*** + + Oak Video Editor - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include + +#include "common/xmlutils.h" +#include "node/project.h" +#include "node/sequence.h" +#include "timeline/edit.h" +#include "timeline/marker.h" +#include "timeline/workarea.h" +#include "undo/undocommand.h" + +namespace +{ + +class TimelineSequenceFixture : public ::testing::Test { +protected: + void SetUp() override + { + project_ = oaknode_project_init(); + ASSERT_NE(project_, nullptr); + sequence_ = oaknode_sequence_create(); + ASSERT_NE(sequence_, nullptr); + node_ = oaknode_sequence_as_node(sequence_); + OakNodeProject *owner = nullptr; + ASSERT_EQ(oaknode_node_get_project(node_, &owner), OAKNODE_OK); + if (!owner) { + ASSERT_EQ(oaknode_project_add_node(project_, node_), + OAKNODE_OK); + } + } + + void TearDown() override + { + // The project owns the sequence and everything in its graph + oaknode_project_free(project_); + } + + OakNodeProject *project_ = nullptr; + OakNodeSequence *sequence_ = nullptr; + OakNodeNode *node_ = nullptr; +}; + +// ---- marker --------------------------------------------------------------- + +TEST_F(TimelineSequenceFixture, MarkerListOfReturnsList) +{ + EXPECT_NE(oaktimeline_marker_list_of(node_), nullptr); + EXPECT_EQ(oaktimeline_marker_list_of(nullptr), nullptr); +} + +TEST_F(TimelineSequenceFixture, MarkerAddCountAtRemove) +{ + OakTimelineMarkerList *list = oaktimeline_marker_list_of(node_); + ASSERT_NE(list, nullptr); + + int count = -1; + EXPECT_EQ(oaktimeline_marker_count(list, &count), OAKTIMELINE_OK); + EXPECT_EQ(count, 0); + EXPECT_EQ(oaktimeline_marker_count(nullptr, &count), + OAKTIMELINE_E_INVALID); + EXPECT_EQ(oaktimeline_marker_count(list, nullptr), + OAKTIMELINE_E_INVALID); + + OakUndoCommand *cmd = + oaktimeline_marker_add_command(list, 1, 2, 3, 4, "mark", 5); + ASSERT_NE(cmd, nullptr); + oakundo_command_redo_now(cmd); + oakundo_command_free(cmd); + + EXPECT_EQ(oaktimeline_marker_count(list, &count), OAKTIMELINE_OK); + ASSERT_EQ(count, 1); + + int in_n = 0, in_d = 0, out_n = 0, out_d = 0, color = 0; + char name[32]; + int needed = oaktimeline_marker_at(list, 0, &in_n, &in_d, &out_n, + &out_d, &color, name, sizeof(name)); + ASSERT_GT(needed, 0); + EXPECT_EQ(in_n, 1); + EXPECT_EQ(in_d, 2); + EXPECT_EQ(out_n, 3); + EXPECT_EQ(out_d, 4); + EXPECT_EQ(color, 5); + EXPECT_STREQ(name, "mark"); + + EXPECT_EQ(oaktimeline_marker_at(list, 9, &in_n, &in_d, &out_n, + &out_d, &color, name, sizeof(name)), + OAKTIMELINE_E_NOT_FOUND); + EXPECT_EQ(oaktimeline_marker_at(nullptr, 0, &in_n, &in_d, &out_n, + &out_d, &color, name, sizeof(name)), + OAKTIMELINE_E_INVALID); + + OakUndoCommand *rm = oaktimeline_marker_remove_at_command(list, 0); + ASSERT_NE(rm, nullptr); + oakundo_command_redo_now(rm); + oakundo_command_free(rm); + + EXPECT_EQ(oaktimeline_marker_count(list, &count), OAKTIMELINE_OK); + EXPECT_EQ(count, 0); + + EXPECT_EQ(oaktimeline_marker_remove_at_command(list, 4), nullptr); +} + +TEST_F(TimelineSequenceFixture, MarkerSetTimeAndPropsUndo) +{ + OakTimelineMarkerList *list = oaktimeline_marker_list_of(node_); + ASSERT_NE(list, nullptr); + + OakUndoCommand *add = + oaktimeline_marker_add_command(list, 0, 1, 1, 1, "a", 1); + ASSERT_NE(add, nullptr); + oakundo_command_redo_now(add); + oakundo_command_free(add); + + OakUndoCommand *set_time = + oaktimeline_marker_set_time_command(list, 0, 10, 1, 20, 1); + ASSERT_NE(set_time, nullptr); + oakundo_command_redo_now(set_time); + + int in_n = 0; + EXPECT_GT(oaktimeline_marker_at(list, 0, &in_n, NULL, NULL, NULL, + NULL, NULL, 0), + 0); + EXPECT_EQ(in_n, 10); + + oakundo_command_undo_now(set_time); + EXPECT_GT(oaktimeline_marker_at(list, 0, &in_n, NULL, NULL, NULL, + NULL, NULL, 0), + 0); + EXPECT_EQ(in_n, 0); + oakundo_command_free(set_time); + + OakUndoCommand *props = + oaktimeline_marker_set_props_command(list, 0, 7, "renamed"); + ASSERT_NE(props, nullptr); + oakundo_command_redo_now(props); + oakundo_command_free(props); + + int color = 0; + char name[32]; + EXPECT_GT(oaktimeline_marker_at(list, 0, NULL, NULL, NULL, NULL, + &color, name, sizeof(name)), + 0); + EXPECT_EQ(color, 7); + EXPECT_STREQ(name, "renamed"); + + EXPECT_EQ(oaktimeline_marker_set_props_command(list, 0, -1, NULL), + nullptr); + EXPECT_EQ(oaktimeline_marker_set_time_command(list, 9, 0, 1, 1, 1), + nullptr); +} + +TEST_F(TimelineSequenceFixture, MarkerListXmlRoundTrip) +{ + OakTimelineMarkerList *list = oaktimeline_marker_list_of(node_); + ASSERT_NE(list, nullptr); + + OakUndoCommand *add = + oaktimeline_marker_add_command(list, 1, 3, 2, 3, "xml", 4); + ASSERT_NE(add, nullptr); + oakundo_command_redo_now(add); + oakundo_command_free(add); + + OakXmlWriter writer = oakcommon_xml_writer_init(); + ASSERT_NE(writer.ctx, nullptr); + EXPECT_EQ(oakcommon_xml_writer_write_start_element(writer, "markers"), + OAKCOMMON_OK); + EXPECT_EQ(oaktimeline_marker_list_save(list, writer), OAKTIMELINE_OK); + EXPECT_EQ(oakcommon_xml_writer_write_end_element(writer), + OAKCOMMON_OK); + + char xml[1024]; + int needed = oakcommon_xml_writer_output(writer, xml, sizeof(xml)); + ASSERT_GT(needed, 0); + ASSERT_LT(needed, int(sizeof(xml))); + oakcommon_xml_writer_free(&writer); + + // Load into a fresh sequence's marker list + OakNodeSequence *seq2 = oaknode_sequence_create(); + ASSERT_NE(seq2, nullptr); + ASSERT_EQ(oaknode_project_add_node(project_, + oaknode_sequence_as_node(seq2)), + OAKNODE_OK); + OakTimelineMarkerList *list2 = + oaktimeline_marker_list_of(oaknode_sequence_as_node(seq2)); + ASSERT_NE(list2, nullptr); + + OakXmlReader reader = oakcommon_xml_reader_init(xml); + ASSERT_NE(reader.ctx, nullptr); + int start = 0; + ASSERT_EQ(oakcommon_xml_reader_read_next_start_element(reader, &start), + OAKCOMMON_OK); + ASSERT_NE(start, 0); + EXPECT_EQ(oaktimeline_marker_list_load(list2, reader), OAKTIMELINE_OK); + oakcommon_xml_reader_free(&reader); + + int count = 0; + EXPECT_EQ(oaktimeline_marker_count(list2, &count), OAKTIMELINE_OK); + ASSERT_EQ(count, 1); + + int color = 0; + char name[32]; + EXPECT_GT(oaktimeline_marker_at(list2, 0, NULL, NULL, NULL, NULL, + &color, name, sizeof(name)), + 0); + EXPECT_EQ(color, 4); + EXPECT_STREQ(name, "xml"); + + EXPECT_EQ(oaktimeline_marker_list_load(nullptr, reader), + OAKTIMELINE_E_INVALID); + +} + +// ---- workarea ------------------------------------------------------------- + +TEST_F(TimelineSequenceFixture, WorkareaGetSetLive) +{ + OakTimelineWorkArea *w = oaktimeline_workarea_of(node_); + ASSERT_NE(w, nullptr); + EXPECT_EQ(oaktimeline_workarea_of(nullptr), nullptr); + + int in_n = 0, in_d = 0, out_n = 0, out_d = 0, enabled = -1; + EXPECT_EQ(oaktimeline_workarea_get(w, &in_n, &in_d, &out_n, &out_d, + &enabled), + OAKTIMELINE_OK); + EXPECT_EQ(enabled, 0); + + EXPECT_EQ(oaktimeline_workarea_set_range(w, 1, 4, 3, 4), + OAKTIMELINE_OK); + EXPECT_EQ(oaktimeline_workarea_get(w, &in_n, &in_d, &out_n, &out_d, + &enabled), + OAKTIMELINE_OK); + EXPECT_EQ(in_n, 1); + EXPECT_EQ(in_d, 4); + EXPECT_EQ(out_n, 3); + EXPECT_EQ(out_d, 4); + + EXPECT_EQ(oaktimeline_workarea_set_range(nullptr, 0, 1, 1, 1), + OAKTIMELINE_E_INVALID); + EXPECT_EQ(oaktimeline_workarea_get(nullptr, &in_n, &in_d, &out_n, + &out_d, &enabled), + OAKTIMELINE_E_INVALID); +} + +TEST_F(TimelineSequenceFixture, WorkareaUndoCommands) +{ + OakTimelineWorkArea *w = oaktimeline_workarea_of(node_); + ASSERT_NE(w, nullptr); + + OakUndoCommand *range_cmd = oaktimeline_workarea_set_range_command( + w, 1, 2, 1, 1, 0, 1, 1, 1); + ASSERT_NE(range_cmd, nullptr); + oakundo_command_redo_now(range_cmd); + + int in_n = 0, out_n = 0; + EXPECT_EQ(oaktimeline_workarea_get(w, &in_n, NULL, &out_n, NULL, NULL), + OAKTIMELINE_OK); + EXPECT_EQ(in_n, 1); + EXPECT_EQ(out_n, 1); + + oakundo_command_undo_now(range_cmd); + EXPECT_EQ(oaktimeline_workarea_get(w, &in_n, NULL, &out_n, NULL, NULL), + OAKTIMELINE_OK); + EXPECT_EQ(in_n, 0); + oakundo_command_free(range_cmd); + + OakUndoCommand *enable_cmd = + oaktimeline_workarea_set_enabled_command(w, 1); + ASSERT_NE(enable_cmd, nullptr); + oakundo_command_redo_now(enable_cmd); + + int enabled = 0; + EXPECT_EQ(oaktimeline_workarea_get(w, NULL, NULL, NULL, NULL, + &enabled), + OAKTIMELINE_OK); + EXPECT_EQ(enabled, 1); + + oakundo_command_undo_now(enable_cmd); + EXPECT_EQ(oaktimeline_workarea_get(w, NULL, NULL, NULL, NULL, + &enabled), + OAKTIMELINE_OK); + EXPECT_EQ(enabled, 0); + oakundo_command_free(enable_cmd); + + EXPECT_EQ(oaktimeline_workarea_set_range_command(nullptr, 0, 1, 1, 1, + 0, 1, 1, 1), + nullptr); + EXPECT_EQ(oaktimeline_workarea_set_enabled_command(nullptr, 1), + nullptr); +} + +TEST_F(TimelineSequenceFixture, WorkareaResetSentinels) +{ + int in_n = 0, in_d = 0, out_n = 0, out_d = 0; + EXPECT_EQ(oaktimeline_workarea_reset(&in_n, &in_d, &out_n, &out_d), + OAKTIMELINE_OK); + EXPECT_EQ(in_n, 0); + EXPECT_EQ(out_n, INT32_MAX); + + EXPECT_EQ(oaktimeline_workarea_reset(nullptr, &in_d, &out_n, &out_d), + OAKTIMELINE_E_INVALID); +} + +TEST_F(TimelineSequenceFixture, WorkareaXmlRoundTrip) +{ + OakTimelineWorkArea *w = oaktimeline_workarea_of(node_); + ASSERT_NE(w, nullptr); + EXPECT_EQ(oaktimeline_workarea_set_range(w, 1, 3, 2, 3), + OAKTIMELINE_OK); + + OakXmlWriter writer = oakcommon_xml_writer_init(); + ASSERT_NE(writer.ctx, nullptr); + EXPECT_EQ(oakcommon_xml_writer_write_start_element(writer, "workarea"), + OAKCOMMON_OK); + EXPECT_EQ(oaktimeline_workarea_save(w, writer), OAKTIMELINE_OK); + EXPECT_EQ(oakcommon_xml_writer_write_end_element(writer), + OAKCOMMON_OK); + + char xml[1024]; + int needed = oakcommon_xml_writer_output(writer, xml, sizeof(xml)); + ASSERT_GT(needed, 0); + ASSERT_LT(needed, int(sizeof(xml))); + oakcommon_xml_writer_free(&writer); + + OakNodeSequence *seq2 = oaknode_sequence_create(); + ASSERT_NE(seq2, nullptr); + ASSERT_EQ(oaknode_project_add_node(project_, + oaknode_sequence_as_node(seq2)), + OAKNODE_OK); + OakTimelineWorkArea *w2 = + oaktimeline_workarea_of(oaknode_sequence_as_node(seq2)); + ASSERT_NE(w2, nullptr); + + OakXmlReader reader = oakcommon_xml_reader_init(xml); + ASSERT_NE(reader.ctx, nullptr); + int start = 0; + ASSERT_EQ(oakcommon_xml_reader_read_next_start_element(reader, &start), + OAKCOMMON_OK); + ASSERT_NE(start, 0); + EXPECT_EQ(oaktimeline_workarea_load(w2, reader), OAKTIMELINE_OK); + oakcommon_xml_reader_free(&reader); + + int in_n = 0, out_n = 0; + EXPECT_EQ(oaktimeline_workarea_get(w2, &in_n, NULL, &out_n, NULL, + NULL), + OAKTIMELINE_OK); + EXPECT_EQ(in_n, 1); + EXPECT_EQ(out_n, 2); + +} + +// ---- edit ----------------------------------------------------------------- + +TEST_F(TimelineSequenceFixture, AddAndRemoveTrackCommands) +{ + OakNodeTrackList *list = nullptr; + ASSERT_EQ(oaknode_sequence_get_track_list( + sequence_, OAKNODE_TRACK_TYPE_VIDEO, &list), + OAKNODE_OK); + ASSERT_NE(list, nullptr); + + int count = -1; + EXPECT_EQ(oaknode_tracklist_get_track_count(list, &count), OAKNODE_OK); + const int before = count; + + OakUndoCommand *add = oaktimeline_add_track_command(list); + ASSERT_NE(add, nullptr); + oakundo_command_redo_now(add); + + EXPECT_EQ(oaknode_tracklist_get_track_count(list, &count), OAKNODE_OK); + EXPECT_EQ(count, before + 1); + + OakNodeTrack *track = nullptr; + EXPECT_EQ(oaknode_tracklist_get_track_at(list, before, &track), + OAKNODE_OK); + ASSERT_NE(track, nullptr); + + OakUndoCommand *rm = oaktimeline_remove_track_command(track); + ASSERT_NE(rm, nullptr); + oakundo_command_redo_now(rm); + + EXPECT_EQ(oaknode_tracklist_get_track_count(list, &count), OAKNODE_OK); + EXPECT_EQ(count, before); + + oakundo_command_undo_now(rm); + EXPECT_EQ(oaknode_tracklist_get_track_count(list, &count), OAKNODE_OK); + EXPECT_EQ(count, before + 1); + + oakundo_command_undo_now(add); + EXPECT_EQ(oaknode_tracklist_get_track_count(list, &count), OAKNODE_OK); + EXPECT_EQ(count, before); + + oakundo_command_free(add); + oakundo_command_free(rm); + + EXPECT_EQ(oaktimeline_add_track_command(nullptr), nullptr); + EXPECT_EQ(oaktimeline_remove_track_command(nullptr), nullptr); +} + +TEST_F(TimelineSequenceFixture, PlaceTrimSplitRemoveAreaCommands) +{ + OakNodeTrackList *list = nullptr; + ASSERT_EQ(oaknode_sequence_get_track_list( + sequence_, OAKNODE_TRACK_TYPE_VIDEO, &list), + OAKNODE_OK); + + OakNodeTrack *track = oaknode_track_create(OAKNODE_TRACK_TYPE_VIDEO); + ASSERT_NE(track, nullptr); + ASSERT_EQ(oaknode_project_add_node(project_, + oaknode_track_as_node(track)), + OAKNODE_OK); + ASSERT_EQ(oaknode_tracklist_add_track(list, track), OAKNODE_OK); + + OakNodeBlock *clip = oaknode_block_clip_create(); + ASSERT_NE(clip, nullptr); + EXPECT_EQ(oaknode_block_set_length_and_media_out(clip, 10, 1), + OAKNODE_OK); + ASSERT_EQ(oaknode_project_add_node(project_, + oaknode_block_as_node(clip)), + OAKNODE_OK); + + OakUndoCommand *place = + oaktimeline_place_block_command(list, 0, clip, 0, 1); + ASSERT_NE(place, nullptr); + oakundo_command_redo_now(place); + + int block_count = 0; + EXPECT_EQ(oaknode_track_get_block_count(track, &block_count), + OAKNODE_OK); + ASSERT_EQ(block_count, 1); + + // Trim out to length 4 + OakUndoCommand *trim = oaktimeline_trim_command( + track, clip, 4, 1, OAKTIMELINE_MOVEMENT_TRIM_OUT); + ASSERT_NE(trim, nullptr); + oakundo_command_redo_now(trim); + + int n = 0, d = 0; + EXPECT_EQ(oaknode_block_get_length(clip, &n, &d), OAKNODE_OK); + EXPECT_EQ(n, 4); + + oakundo_command_undo_now(trim); + EXPECT_EQ(oaknode_block_get_length(clip, &n, &d), OAKNODE_OK); + EXPECT_EQ(n, 10); + oakundo_command_free(trim); + + // Split at 5: two blocks of 5 + OakNodeBlock *blocks[] = { clip }; + OakUndoCommand *split = + oaktimeline_split_command(blocks, 1, 5, 1); + ASSERT_NE(split, nullptr); + oakundo_command_redo_now(split); + + EXPECT_EQ(oaknode_track_get_block_count(track, &block_count), + OAKNODE_OK); + ASSERT_EQ(block_count, 2); + + oakundo_command_undo_now(split); + EXPECT_EQ(oaknode_track_get_block_count(track, &block_count), + OAKNODE_OK); + ASSERT_EQ(block_count, 1); + oakundo_command_free(split); + + // Ripple remove [3..5] then undo + OakUndoCommand *remove_area = + oaktimeline_ripple_remove_area_command(track, 3, 1, 5, 1); + ASSERT_NE(remove_area, nullptr); + oakundo_command_redo_now(remove_area); + + // Removing [3..5] from [0..10] splices it into [0..3] + [5..10] + EXPECT_EQ(oaknode_track_get_block_count(track, &block_count), + OAKNODE_OK); + ASSERT_EQ(block_count, 2); + EXPECT_EQ(oaknode_block_get_length(clip, &n, &d), OAKNODE_OK); + EXPECT_EQ(n, 3); + + OakNodeBlock *second = nullptr; + EXPECT_EQ(oaknode_track_get_block_at(track, 1, &second), OAKNODE_OK); + ASSERT_NE(second, nullptr); + EXPECT_EQ(oaknode_block_get_length(second, &n, &d), OAKNODE_OK); + EXPECT_EQ(n, 5); + + oakundo_command_undo_now(remove_area); + EXPECT_EQ(oaknode_block_get_length(clip, &n, &d), OAKNODE_OK); + EXPECT_EQ(n, 10); + oakundo_command_free(remove_area); + + oakundo_command_undo_now(place); + EXPECT_EQ(oaknode_track_get_block_count(track, &block_count), + OAKNODE_OK); + EXPECT_EQ(block_count, 0); + oakundo_command_free(place); + + EXPECT_EQ(oaktimeline_place_block_command(nullptr, 0, clip, 0, 1), + nullptr); + EXPECT_EQ(oaktimeline_trim_command(track, clip, 1, 1, 99), nullptr); + EXPECT_EQ(oaktimeline_split_command(blocks, 0, 1, 1), nullptr); + EXPECT_EQ(oaktimeline_ripple_remove_area_command(nullptr, 0, 1, 1, 1), + nullptr); +} + +TEST_F(TimelineSequenceFixture, ReplaceWithGapCommand) +{ + OakNodeTrackList *list = nullptr; + ASSERT_EQ(oaknode_sequence_get_track_list( + sequence_, OAKNODE_TRACK_TYPE_VIDEO, &list), + OAKNODE_OK); + + OakNodeTrack *track = oaknode_track_create(OAKNODE_TRACK_TYPE_VIDEO); + ASSERT_NE(track, nullptr); + ASSERT_EQ(oaknode_project_add_node(project_, + oaknode_track_as_node(track)), + OAKNODE_OK); + ASSERT_EQ(oaknode_tracklist_add_track(list, track), OAKNODE_OK); + + OakNodeBlock *clip = oaknode_block_clip_create(); + ASSERT_NE(clip, nullptr); + EXPECT_EQ(oaknode_block_set_length_and_media_out(clip, 6, 1), + OAKNODE_OK); + ASSERT_EQ(oaknode_project_add_node(project_, + oaknode_block_as_node(clip)), + OAKNODE_OK); + EXPECT_EQ(oaknode_track_append_block(track, clip), OAKNODE_OK); + + // Second clip so the first has a next (gap required) + OakNodeBlock *clip2 = oaknode_block_clip_create(); + ASSERT_NE(clip2, nullptr); + EXPECT_EQ(oaknode_block_set_length_and_media_out(clip2, 6, 1), + OAKNODE_OK); + ASSERT_EQ(oaknode_project_add_node(project_, + oaknode_block_as_node(clip2)), + OAKNODE_OK); + EXPECT_EQ(oaknode_track_append_block(track, clip2), OAKNODE_OK); + + OakUndoCommand *replace = + oaktimeline_replace_block_with_gap_command(track, clip); + ASSERT_NE(replace, nullptr); + oakundo_command_redo_now(replace); + + int block_count = 0; + EXPECT_EQ(oaknode_track_get_block_count(track, &block_count), + OAKNODE_OK); + ASSERT_EQ(block_count, 2); + + OakNodeBlock *first = nullptr; + EXPECT_EQ(oaknode_track_get_block_at(track, 0, &first), OAKNODE_OK); + int kind = OAKNODE_BLOCK_OTHER; + EXPECT_EQ(oaknode_block_get_kind(first, &kind), OAKNODE_OK); + EXPECT_EQ(kind, OAKNODE_BLOCK_GAP); + + oakundo_command_undo_now(replace); + EXPECT_EQ(oaknode_track_get_block_at(track, 0, &first), OAKNODE_OK); + EXPECT_EQ(first, clip); + oakundo_command_free(replace); + + EXPECT_EQ(oaktimeline_replace_block_with_gap_command(nullptr, clip), + nullptr); + + EXPECT_EQ(oaknode_track_ripple_remove_block(track, clip), OAKNODE_OK); + EXPECT_EQ(oaknode_track_ripple_remove_block(track, clip2), OAKNODE_OK); +} + +TEST_F(TimelineSequenceFixture, SlideAndInsertGapsAndRippleDeleteGapsFactories) +{ + OakNodeBlock *clip = oaknode_block_clip_create(); + EXPECT_EQ(oaktimeline_slide_command(nullptr, &clip, 1, nullptr, + nullptr, 1, 1), + nullptr); + + OakNodeTrackList *list = nullptr; + ASSERT_EQ(oaknode_sequence_get_track_list( + sequence_, OAKNODE_TRACK_TYPE_VIDEO, &list), + OAKNODE_OK); + EXPECT_EQ(oaktimeline_insert_gaps_command(nullptr, 0, 1, 1, 1), + nullptr); + + EXPECT_EQ(oaktimeline_ripple_delete_gaps_command( + sequence_, nullptr, nullptr, nullptr, nullptr, nullptr, 0), + nullptr); + + oaknode_block_free(clip); +} + +} // namespace diff --git a/src/timeline/tests/timelinemarker_test.cpp b/src/timeline/tests/timelinemarker_test.cpp new file mode 100644 index 000000000..fe187d354 --- /dev/null +++ b/src/timeline/tests/timelinemarker_test.cpp @@ -0,0 +1,169 @@ +/*** + + Oak Video Editor - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include + +#include "timelinemarker.h" + +using namespace olive; + +TEST(TimelineMarker, AddKeepsSortedByTime) +{ + TimelineMarkerList list; + + list.add_marker( + std::make_unique(0, TimeRange(Rational(10), Rational(20)))); + list.add_marker( + std::make_unique(1, TimeRange(Rational(0), Rational(5)))); + + ASSERT_EQ(list.size(), 2); + EXPECT_EQ(list.at(0)->time().in(), Rational(0)); + EXPECT_EQ(list.at(1)->time().in(), Rational(10)); +} + +TEST(TimelineMarker, SetTimeResorts) +{ + TimelineMarkerList list; + + auto *late = new TimelineMarker(0, TimeRange(Rational(10), Rational(20))); + list.add_marker(std::unique_ptr(late)); + list.add_marker( + std::make_unique(1, TimeRange(Rational(5), Rational(6)))); + + late->set_time(TimeRange(Rational(1), Rational(2))); + + EXPECT_EQ(list.at(0)->time().in(), Rational(1)); + EXPECT_EQ(list.at(1)->time().in(), Rational(5)); +} + +TEST(TimelineMarker, RemoveMarkerReturnsOwnership) +{ + TimelineMarkerList list; + + auto *raw = new TimelineMarker(0, TimeRange(Rational(0), Rational(5))); + list.add_marker(std::unique_ptr(raw)); + + auto removed = list.remove_marker(raw); + ASSERT_TRUE(removed != nullptr); + EXPECT_EQ(removed.get(), raw); + EXPECT_EQ(list.size(), 0); +} + +TEST(TimelineMarker, RemoveMissingMarkerReturnsNull) +{ + TimelineMarkerList list; + TimelineMarker not_in_list; + + EXPECT_EQ(list.remove_marker(¬_in_list), nullptr); +} + +TEST(TimelineMarker, GetMarkerAtTime) +{ + TimelineMarkerList list; + + list.add_marker( + std::make_unique(0, TimeRange(Rational(10), Rational(20)))); + + EXPECT_NE(list.get_marker_at_time(Rational(10)), nullptr); + EXPECT_EQ(list.get_marker_at_time(Rational(11)), nullptr); +} + +TEST(TimelineMarker, MarkerAddCommandUndoRedo) +{ + TimelineMarkerList list; + + MarkerAddCommand cmd(&list, TimeRange(Rational(0), Rational(5)), "m", 2); + + cmd.redo_now(); + ASSERT_EQ(list.size(), 1); + EXPECT_EQ(list.at(0)->name(), "m"); + EXPECT_EQ(list.at(0)->color(), 2); + + cmd.undo_now(); + EXPECT_EQ(list.size(), 0); + + cmd.redo_now(); + EXPECT_EQ(list.size(), 1); +} + +TEST(TimelineMarker, MarkerRemoveCommandUndoRedo) +{ + TimelineMarkerList list; + + auto *raw = new TimelineMarker(0, TimeRange(Rational(0), Rational(5))); + list.add_marker(std::unique_ptr(raw)); + + MarkerRemoveCommand cmd(raw, &list); + + cmd.redo_now(); + EXPECT_EQ(list.size(), 0); + + cmd.undo_now(); + ASSERT_EQ(list.size(), 1); + EXPECT_EQ(list.at(0), raw); +} + +TEST(TimelineMarker, ChangeCommandsUndoRedo) +{ + TimelineMarker marker(1, TimeRange(Rational(0), Rational(5)), "old"); + + MarkerChangeColorCommand color_cmd(&marker, 3); + color_cmd.redo_now(); + EXPECT_EQ(marker.color(), 3); + color_cmd.undo_now(); + EXPECT_EQ(marker.color(), 1); + + MarkerChangeNameCommand name_cmd(&marker, "new"); + name_cmd.redo_now(); + EXPECT_EQ(marker.name(), "new"); + name_cmd.undo_now(); + EXPECT_EQ(marker.name(), "old"); + + MarkerChangeTimeCommand time_cmd(&marker, TimeRange(Rational(7), Rational(9))); + time_cmd.redo_now(); + EXPECT_EQ(marker.time().in(), Rational(7)); + time_cmd.undo_now(); + EXPECT_EQ(marker.time().in(), Rational(0)); +} + +TEST(TimelineMarker, XmlRoundTrip) +{ + TimelineMarkerList list; + list.add_marker(std::make_unique( + 3, TimeRange(Rational(1, 2), Rational(3, 4)), "hello")); + + XmlStreamWriter writer; + writer.write_start_element("markers"); + list.save(&writer); + writer.write_end_element(); + + std::string xml = writer.output(); + + XmlStreamReader reader(xml); + TimelineMarkerList loaded; + ASSERT_TRUE(xml_read_next_start_element(&reader)); + ASSERT_TRUE(loaded.load(&reader)); + + ASSERT_EQ(loaded.size(), 1); + EXPECT_EQ(loaded.at(0)->name(), "hello"); + EXPECT_EQ(loaded.at(0)->color(), 3); + EXPECT_EQ(loaded.at(0)->time().in(), Rational(1, 2)); + EXPECT_EQ(loaded.at(0)->time().out(), Rational(3, 4)); +}