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