engine: timeline panel core edit commands migrate to the facade (part 1)

- new batch primitives: split_clips (link-preserving, single undo
  command), delete_clips (gap replace + optional ripple with explicit
  region support), ripple_delete_range, marker_add_ex with color
- razor/split-at-playhead, clip delete, ripple-to-point, track delete,
  and the non-dialog marker path now issue facade commands instead of
  the app's own undo command classes
- batch operations deliberately produce one undo command per user
  action (deleting twenty clips is one entry, not twenty); selection
  and transition removal stay UI-side as documented leftovers
This commit is contained in:
2026-07-20 13:17:07 +08:00
parent 0a25d43218
commit 2aa7eec016
8 changed files with 528 additions and 51 deletions
+21 -2
View File
@@ -30,6 +30,7 @@
#include "common/current.h"
#include "dialog/markerproperties/markerpropertiesdialog.h"
#include "node/project/sequence/sequence.h"
#include "oakengine/timeline.h"
#include "timeline/timelineundoworkarea.h"
#include "widget/timeruler/timeruler.h"
@@ -751,17 +752,35 @@ void TimeBasedWidget::set_marker()
color, TimeRange(get_connected_node()->get_playhead(),
get_connected_node()->get_playhead()));
bool edited_in_dialog = false;
if (OAK_CONFIG("SetNameWithMarker").toBool()) {
MarkerPropertiesDialog mpd({ marker }, timebase(), this);
if (mpd.exec() != QDialog::Accepted) {
delete marker;
marker = nullptr;
} else {
edited_in_dialog = true;
}
}
if (marker) {
Core::instance()->undo_stack()->push(
new MarkerAddCommand(markers, marker), tr("Added Marker"));
if (edited_in_dialog) {
// The dialog pushed undo commands referencing this exact
// marker object, so it must be the one added to the list.
Core::instance()->undo_stack()->push(
new MarkerAddCommand(markers, marker), tr("Added Marker"));
} else {
// Pristine marker: add through the liboakengine C ABI
// facade (one undoable command) and drop the temporary.
oakengine_sequence_marker_add_ex(
reinterpret_cast<OakEngineSequence *>(
get_connected_node()),
Timecode::time_to_timestamp(marker->time().in(),
timebase(),
Timecode::k_round),
"", marker->color());
delete marker;
}
}
}
}
+57 -27
View File
@@ -47,6 +47,7 @@
#include "node/nodeundo.h"
#include "node/project/footage/footage.h"
#include "node/project/serializer/serializer.h"
#include "oakengine/timeline.h"
#include "render/audiowaveformcache.h"
#include "task/project/import/import.h"
#include "timeline/timelineundogeneral.h"
@@ -556,10 +557,20 @@ void TimelineWidget::split_at_playhead()
}
if (!blocks_to_split.isEmpty()) {
Core::instance()->undo_stack()->push(
new BlockSplitPreservingLinksCommand(blocks_to_split,
{ playhead_time }),
tr("Split Clips At Playhead"));
// Split through the liboakengine C ABI facade: one undoable,
// link-preserving command with the same semantics as the old
// app-side BlockSplitPreservingLinksCommand push.
QVector<OakEngineClip *> clips;
clips.reserve(blocks_to_split.size());
foreach (Block *b, blocks_to_split) {
clips.append(reinterpret_cast<OakEngineClip *>(
static_cast<ClipBlock *>(b)));
}
oakengine_sequence_split_clips(
reinterpret_cast<OakEngineSequence *>(sequence()), clips.data(),
clips.size(),
Timecode::time_to_timestamp(playhead_time, timebase(),
Timecode::k_round));
}
}
@@ -639,32 +650,47 @@ void TimelineWidget::DeleteSelected(bool ripple)
command->add_child(trc);
}
// Replace clips with gaps (effectively deleting them)
replace_blocks_with_gaps(clips_to_delete, true, command, false);
// Selection clearing and transition removal stay app-side (selection
// state and transition commands have no facade equivalent); the clip
// deletion core below goes through the facade and lands as one undoable
// command right after this one, keeping the undo order intact.
Core::instance()->undo_stack()->push(command, tr("Deleted Clips"));
// Insert ripple command now that it's all cleaned up gaps
TimelineRippleDeleteGapsAtRegionsCommand *ripple_command = nullptr;
Rational new_playhead = RATIONAL_MAX;
if (ripple) {
TimelineRippleDeleteGapsAtRegionsCommand::RangeList range_list;
foreach (Block *b, selected_list) {
range_list.append({ b->track(), b->range() });
new_playhead = qMin(new_playhead, b->in());
}
ripple_command = new TimelineRippleDeleteGapsAtRegionsCommand(
sequence(), range_list);
command->add_child(ripple_command);
// Delete the clips through the liboakengine C ABI facade (gap
// replacement + graph removal, optionally rippling the selected ranges
// closed), same semantics as the old in-command children.
QVector<OakEngineClip *> facade_clips;
facade_clips.reserve(clips_to_delete.size());
foreach (Block *b, clips_to_delete) {
facade_clips.append(
reinterpret_cast<OakEngineClip *>(static_cast<ClipBlock *>(b)));
}
Core::instance()->undo_stack()->push(command, tr("Deleted Clips"));
Rational new_playhead = RATIONAL_MAX;
QVector<int64_t> ripple_ranges;
if (ripple) {
foreach (Block *b, selected_list) {
ripple_ranges.append(int64_t(b->track()->type()));
ripple_ranges.append(b->track()->index());
ripple_ranges.append(Timecode::time_to_timestamp(
b->in(), timebase(), Timecode::k_round));
ripple_ranges.append(Timecode::time_to_timestamp(
b->out(), timebase(), Timecode::k_round));
new_playhead = qMin(new_playhead, b->in());
}
}
int rippled = 0;
oakengine_sequence_delete_clips(
reinterpret_cast<OakEngineSequence *>(sequence()),
facade_clips.data(), facade_clips.size(), ripple ? 1 : 0,
ripple ? ripple_ranges.constData() : nullptr,
ripple ? ripple_ranges.size() / 4 : 0, &rippled);
// Ensures any current drag operations are cancelled
clear_ghosts();
if (ripple_command && ripple_command->has_commands() &&
new_playhead != RATIONAL_MAX) {
if (ripple && rippled && new_playhead != RATIONAL_MAX) {
get_connected_node()->set_playhead(new_playhead);
}
}
@@ -2535,10 +2561,14 @@ void TimelineWidget::ripple_to(Timeline::MovementMode mode)
Rational in_ripple = qMin(closest_point_to_playhead, playhead_time);
Rational out_ripple = qMax(closest_point_to_playhead, playhead_time);
TimelineRippleRemoveAreaCommand *c =
new TimelineRippleRemoveAreaCommand(sequence(), in_ripple, out_ripple);
Core::instance()->undo_stack()->push(c, tr("Rippled Clip(s) To Point"));
// Ripple the region out through the liboakengine C ABI facade (one
// undoable all-tracks ripple, same as the old
// TimelineRippleRemoveAreaCommand push).
oakengine_sequence_ripple_delete_range(
reinterpret_cast<OakEngineSequence *>(sequence()),
Timecode::time_to_timestamp(in_ripple, timebase(), Timecode::k_round),
Timecode::time_to_timestamp(out_ripple, timebase(),
Timecode::k_round));
// If we rippled, ump to where new cut is if applicable
if (mode == Timeline::k_trim_in) {
+16 -6
View File
@@ -21,8 +21,7 @@
#include "razor.h"
#include "node/nodeundo.h"
#include "timeline/timelineundosplit.h"
#include "oakengine/timeline.h"
#include "widget/timelinewidget/timelinewidget.h"
namespace olive
@@ -94,10 +93,21 @@ void RazorTool::mouse_release(TimelineViewMouseEvent *event)
split_tracks_.clear();
if (!blocks_to_split.isEmpty()) {
Core::instance()->undo_stack()->push(
new BlockSplitPreservingLinksCommand(blocks_to_split,
{ split_time }),
qApp->translate("RazorTool", "Split Clips"));
// Split through the liboakengine C ABI facade: one undoable,
// link-preserving command with the same semantics as the old
// app-side BlockSplitPreservingLinksCommand push.
QVector<OakEngineClip *> clips;
clips.reserve(blocks_to_split.size());
foreach (Block *b, blocks_to_split) {
if (ClipBlock *clip = dynamic_cast<ClipBlock *>(b)) {
clips.append(reinterpret_cast<OakEngineClip *>(clip));
}
}
oakengine_sequence_split_clips(
reinterpret_cast<OakEngineSequence *>(parent()->sequence()),
clips.data(), clips.size(),
Timecode::time_to_timestamp(split_time, parent()->timebase(),
Timecode::k_round));
}
dragging_ = false;
@@ -29,6 +29,7 @@
#include <QtMath>
#include "core.h"
#include "oakengine/timeline.h"
#include "timeline/timelineundogeneral.h"
#include "ui/icons/icons.h"
#include "widget/menu/menu.h"
@@ -159,9 +160,11 @@ void TrackViewItem::show_context_menu(const QPoint &p)
void TrackViewItem::delete_track()
{
emit about_to_delete_track(track_);
Core::instance()->undo_stack()->push(
new TimelineRemoveTrackCommand(track_),
tr("Deleted Track \"%1\"").arg(track_->get_label_or_name()));
// Through the liboakengine C ABI facade (one undoable command, same as
// the old TimelineRemoveTrackCommand push).
oakengine_sequence_remove_track(
reinterpret_cast<OakEngineSequence *>(track_->sequence()),
int(track_->type()), track_->index());
}
void TrackViewItem::delete_all_empty_tracks()
+71 -2
View File
@@ -274,12 +274,14 @@ oakengine_sequence_marker_count(const OakEngineSequence *self);
/**
* @brief Marker at `index`: `time` receives its in-point as a timestamp in
* timebase units (may be NULL), `name` its label using the buf/size
* truncation convention (may be NULL to only fetch the time). Returns
* truncation convention (may be NULL to only fetch the time), `color` its
* color index (may be NULL). Returns
* OAKENGINE_OK on success, OAKENGINE_E_NOT_FOUND for an out-of-range index.
*/
OAKENGINE_API int oakengine_sequence_marker_at(const OakEngineSequence *self,
int index, int64_t *time,
char *name, int name_size);
char *name, int name_size,
int *color);
/* ---- Timeline editing primitives ---------------------------------------- */
@@ -437,6 +439,61 @@ OAKENGINE_API int oakengine_sequence_move_clip(OakEngineSequence *seq,
int clip_index,
int64_t new_in);
/* ---- Batch editing (timeline panel) ------------------------------------------
*
* Higher-level operations mirroring the application's timeline panel
* (app/widget/timelinewidget), each undoable as ONE command like the
* panel's own undo entries. Clip arrays hold borrowed handles
* (oakengine_sequence_clip_at(); the handle is the engine ClipBlock
* pointer in this family, so the application can pass its own clips
* directly). All times are frame timestamps in the sequence's frame-rate
* timebase.
*/
/**
* @brief Split every given clip at timeline `time_ts`, preserving links
* (undoable; olive::BlockSplitPreservingLinksCommand -- the application's
* razor tool / split-at-playhead command).
*
* Clips not spanning `time_ts` are skipped (same as the engine command);
* when none of the clips spans it, the call fails with
* OAKENGINE_E_NOT_FOUND and nothing is pushed. The halves of linked clips
* come out linked, like the application's split.
*/
OAKENGINE_API int oakengine_sequence_split_clips(
OakEngineSequence *seq, OakEngineClip **clips, int clip_count,
int64_t time_ts);
/**
* @brief Delete clips leaving gaps, optionally rippling regions closed
* (undoable; the clip-deletion core of the application's
* TimelineWidget::DeleteSelected).
*
* Each clip is replaced with a gap (olive::TrackReplaceBlockWithGapCommand,
* transitions left to the caller like the application) and removed from
* the graph with its exclusive dependencies
* (olive::NodeRemoveWithExclusiveDependenciesAndDisconnect).
*
* When `ripple` != 0, a olive::TimelineRippleDeleteGapsAtRegionsCommand
* follows over `ripple_ranges_ts` -- 4 int64 per range: track_type,
* track_index, in_ts, out_ts (NULL with `ripple_range_count` 0 ripples the
* deleted clips' own ranges instead). `rippled` (may be NULL) receives 1
* when the ripple actually produced commands. Everything lands as one
* undoable command.
*/
OAKENGINE_API int oakengine_sequence_delete_clips(
OakEngineSequence *seq, OakEngineClip **clips, int clip_count, int ripple,
const int64_t *ripple_ranges_ts, int ripple_range_count, int *rippled);
/**
* @brief Remove the area [in_ts, out_ts) on every track and shift the
* following content left (undoable;
* olive::TimelineRippleRemoveAreaCommand -- the application's
* "ripple to playhead"). `in_ts` must be >= 0 and `out_ts` > `in_ts`.
*/
OAKENGINE_API int oakengine_sequence_ripple_delete_range(
OakEngineSequence *seq, int64_t in_ts, int64_t out_ts);
/* ---- Track structure and markers ------------------------------------------
*
* Track structure edits are undoable like the other editing primitives.
@@ -524,6 +581,18 @@ OAKENGINE_API int oakengine_sequence_marker_add(OakEngineSequence *seq,
int64_t time_ts,
const char *name);
/**
* @brief Add a timeline marker with an explicit color index (undoable).
*
* Same as oakengine_sequence_marker_add() (which passes color 0) but the
* caller picks the marker color, like the application's "set marker"
* action (color of the closest marker, or the configured default).
*/
OAKENGINE_API int oakengine_sequence_marker_add_ex(OakEngineSequence *seq,
int64_t time_ts,
const char *name,
int color);
/**
* @brief Remove the (first) marker at `time_ts` (undoable;
* olive::MarkerRemoveCommand). OAKENGINE_E_NOT_FOUND when no marker exists
+195 -2
View File
@@ -714,7 +714,8 @@ int oakengine_sequence_marker_count(const OakEngineSequence *self)
}
int oakengine_sequence_marker_at(const OakEngineSequence *self, int index,
int64_t *time, char *name, int name_size)
int64_t *time, char *name, int name_size,
int *color)
{
if (!self || index < 0) {
return OAKENGINE_E_INVALID;
@@ -734,6 +735,9 @@ int oakengine_sequence_marker_at(const OakEngineSequence *self, int index,
if (name && name_size > 0) {
copy_to_buf(marker->name(), name, size_t(name_size));
}
if (color) {
*color = marker->color();
}
return OAKENGINE_OK;
}
@@ -1076,6 +1080,189 @@ int oakengine_sequence_move_clip(OakEngineSequence *seq, int track_type,
return OAKENGINE_OK;
}
/* ---- Batch editing (timeline panel) ------------------------------------------ */
int oakengine_sequence_split_clips(OakEngineSequence *seq,
OakEngineClip **clips, int clip_count,
int64_t time_ts)
{
set_seq_error(QString());
olive::Sequence *sequence = reinterpret_cast<olive::Sequence *>(seq);
if (!sequence || !clips || clip_count <= 0) {
set_seq_error(QStringLiteral("invalid arguments"));
return OAKENGINE_E_INVALID;
}
olive::Rational tb;
if (!time_base_of(sequence, &tb)) {
set_seq_error(QStringLiteral("sequence has no valid frame rate"));
return OAKENGINE_E_STATE;
}
const olive::Rational time =
olive::core::Timecode::timestamp_to_time(time_ts, tb);
QVector<olive::Block *> blocks;
blocks.reserve(clip_count);
bool any_spanning = false;
for (int i = 0; i < clip_count; i++) {
olive::ClipBlock *clip =
reinterpret_cast<olive::ClipBlock *>(clips[i]);
if (!clip) {
set_seq_error(QStringLiteral("invalid clip at index %1").arg(i));
return OAKENGINE_E_INVALID;
}
if (blocks.contains(clip)) {
continue;
}
blocks.append(clip);
if (clip->in() < time && clip->out() > time) {
any_spanning = true;
}
}
if (!any_spanning) {
set_seq_error(QStringLiteral("no clip spans time %1").arg(time_ts));
return OAKENGINE_E_NOT_FOUND;
}
// Same as the application's razor tool / split-at-playhead
// (BlockSplitPreservingLinksCommand): split every block spanning the
// time and link the halves of linked blocks, one undoable command.
push_or_run(new olive::BlockSplitPreservingLinksCommand(blocks, { time }),
QStringLiteral("Split Clips"));
return OAKENGINE_OK;
}
int oakengine_sequence_delete_clips(OakEngineSequence *seq,
OakEngineClip **clips, int clip_count,
int ripple, const int64_t *ripple_ranges_ts,
int ripple_range_count, int *rippled)
{
set_seq_error(QString());
if (rippled) {
*rippled = 0;
}
olive::Sequence *sequence = reinterpret_cast<olive::Sequence *>(seq);
if (!sequence || clip_count < 0 || (clip_count > 0 && !clips) ||
ripple_range_count < 0 ||
(ripple_range_count > 0 && !ripple_ranges_ts)) {
set_seq_error(QStringLiteral("invalid arguments"));
return OAKENGINE_E_INVALID;
}
if (clip_count == 0 && (!ripple || ripple_range_count == 0)) {
// Nothing to delete and nothing to ripple.
return OAKENGINE_OK;
}
olive::Rational tb;
if (!time_base_of(sequence, &tb)) {
set_seq_error(QStringLiteral("sequence has no valid frame rate"));
return OAKENGINE_E_STATE;
}
olive::MultiUndoCommand *command = new olive::MultiUndoCommand();
olive::TimelineRippleDeleteGapsAtRegionsCommand::RangeList clip_ranges;
for (int i = 0; i < clip_count; i++) {
olive::ClipBlock *clip =
reinterpret_cast<olive::ClipBlock *>(clips[i]);
if (!clip || !clip->track()) {
set_seq_error(QStringLiteral("invalid clip at index %1").arg(i));
delete command;
return OAKENGINE_E_INVALID;
}
// Same as the application's delete-selection core
// (TimelineWidget::DeleteSelected): replace the clip with a gap
// (transitions are the caller's job) and remove it from the graph
// with its exclusive dependencies.
command->add_child(new olive::TrackReplaceBlockWithGapCommand(
clip->track(), clip, false));
command->add_child(
new olive::NodeRemoveWithExclusiveDependenciesAndDisconnect(clip));
clip_ranges.append({ clip->track(), clip->range() });
}
olive::TimelineRippleDeleteGapsAtRegionsCommand *ripple_command = nullptr;
if (ripple) {
olive::TimelineRippleDeleteGapsAtRegionsCommand::RangeList ranges;
if (ripple_ranges_ts && ripple_range_count > 0) {
for (int i = 0; i < ripple_range_count; i++) {
const int64_t *range = ripple_ranges_ts + i * 4;
const int track_type = int(range[0]);
const int track_index = int(range[1]);
if (track_type < OAKENGINE_TRACK_TYPE_VIDEO ||
track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) {
set_seq_error(QStringLiteral("invalid track type in "
"ripple range %1")
.arg(i));
delete command;
return OAKENGINE_E_INVALID;
}
olive::TrackList *list =
sequence->track_list(to_track_type(track_type));
if (track_index < 0 ||
track_index >= list->get_track_count()) {
set_seq_error(QStringLiteral("no track at index %1 in "
"ripple range %2")
.arg(track_index)
.arg(i));
delete command;
return OAKENGINE_E_NOT_FOUND;
}
ranges.append({ list->get_track_at(track_index),
olive::TimeRange(
olive::core::Timecode::timestamp_to_time(
range[2], tb),
olive::core::Timecode::timestamp_to_time(
range[3], tb)) });
}
} else {
ranges = clip_ranges;
}
if (!ranges.isEmpty()) {
ripple_command =
new olive::TimelineRippleDeleteGapsAtRegionsCommand(sequence,
ranges);
command->add_child(ripple_command);
}
}
push_or_run(command, QStringLiteral("Delete Clips"));
if (rippled) {
// has_commands() is valid after the stack prepared the command;
// without an engine the command ran directly and a non-null ripple
// command means regions were queued.
*rippled = (ripple_command && (!olive::EngineCore::instance() ||
ripple_command->has_commands())) ?
1 :
0;
}
return OAKENGINE_OK;
}
int oakengine_sequence_ripple_delete_range(OakEngineSequence *seq,
int64_t in_ts, int64_t out_ts)
{
set_seq_error(QString());
olive::Sequence *sequence = reinterpret_cast<olive::Sequence *>(seq);
if (!sequence || in_ts < 0 || out_ts <= in_ts) {
set_seq_error(QStringLiteral("invalid range [%1, %2)")
.arg(in_ts)
.arg(out_ts));
return OAKENGINE_E_INVALID;
}
olive::Rational tb;
if (!time_base_of(sequence, &tb)) {
set_seq_error(QStringLiteral("sequence has no valid frame rate"));
return OAKENGINE_E_STATE;
}
// Same as the application's "ripple to playhead" (TimelineWidget::
// ripple_to): remove the area on every track and shift the following
// content left, one undoable command.
push_or_run(new olive::TimelineRippleRemoveAreaCommand(
sequence,
olive::core::Timecode::timestamp_to_time(in_ts, tb),
olive::core::Timecode::timestamp_to_time(out_ts, tb)),
QStringLiteral("Ripple Delete Range"));
return OAKENGINE_OK;
}
/* ---- Track structure and markers ------------------------------------------ */
int oakengine_sequence_remove_track(OakEngineSequence *seq, int track_type,
@@ -1278,6 +1465,12 @@ int oakengine_track_set_locked(OakEngineSequence *seq, int track_type,
int oakengine_sequence_marker_add(OakEngineSequence *seq, int64_t time_ts,
const char *name)
{
return oakengine_sequence_marker_add_ex(seq, time_ts, name, 0);
}
int oakengine_sequence_marker_add_ex(OakEngineSequence *seq, int64_t time_ts,
const char *name, int color)
{
set_seq_error(QString());
olive::Sequence *sequence = reinterpret_cast<olive::Sequence *>(seq);
@@ -1301,7 +1494,7 @@ int oakengine_sequence_marker_add(OakEngineSequence *seq, int64_t time_ts,
}
push_or_run(new olive::MarkerAddCommand(
markers, olive::TimeRange(time, time),
QString::fromUtf8(name ? name : ""), 0),
QString::fromUtf8(name ? name : ""), color),
QStringLiteral("Add Marker"));
return OAKENGINE_OK;
}
+2 -2
View File
@@ -220,7 +220,7 @@ static void test_sequence_and_save_load(void)
// No markers on a fresh sequence.
assert(oakengine_sequence_marker_count(seq) == 0);
assert(oakengine_sequence_marker_at(seq, 0, NULL, NULL, 0) ==
assert(oakengine_sequence_marker_at(seq, 0, NULL, NULL, 0, NULL) ==
OAKENGINE_E_NOT_FOUND);
// Save; the modified flag clears and the filename is adopted.
@@ -384,7 +384,7 @@ static void test_null_safety(void)
assert(oakengine_sequence_set_workarea(NULL, 0, 0, 0) ==
OAKENGINE_E_INVALID);
assert(oakengine_sequence_marker_count(NULL) == 0);
assert(oakengine_sequence_marker_at(NULL, 0, NULL, NULL, 0) ==
assert(oakengine_sequence_marker_at(NULL, 0, NULL, NULL, 0, NULL) ==
OAKENGINE_E_INVALID);
}
+160 -7
View File
@@ -584,7 +584,7 @@ static void test_markers(void)
assert(oakengine_sequence_marker_add(seq, 30, "Chapter 1") ==
OAKENGINE_OK);
assert(oakengine_sequence_marker_count(seq) == 1);
assert(oakengine_sequence_marker_at(seq, 0, &ts, name, sizeof(name)) ==
assert(oakengine_sequence_marker_at(seq, 0, &ts, name, sizeof(name), NULL) ==
OAKENGINE_OK);
assert(ts == 30 && strcmp(name, "Chapter 1") == 0);
@@ -595,29 +595,29 @@ static void test_markers(void)
// Earlier marker sorts in front.
assert(oakengine_sequence_marker_add(seq, 10, "Intro") == OAKENGINE_OK);
assert(oakengine_sequence_marker_count(seq) == 2);
assert(oakengine_sequence_marker_at(seq, 0, &ts, name, sizeof(name)) ==
assert(oakengine_sequence_marker_at(seq, 0, &ts, name, sizeof(name), NULL) ==
OAKENGINE_OK);
assert(ts == 10 && strcmp(name, "Intro") == 0);
assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name)) ==
assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name), NULL) ==
OAKENGINE_OK);
assert(ts == 30 && strcmp(name, "Chapter 1") == 0);
assert(oakengine_sequence_marker_at(seq, 2, &ts, name, sizeof(name)) ==
assert(oakengine_sequence_marker_at(seq, 2, &ts, name, sizeof(name), NULL) ==
OAKENGINE_E_NOT_FOUND);
// Rename with undo/redo.
assert(oakengine_sequence_marker_rename(seq, 30, "Chapter 2") ==
OAKENGINE_OK);
assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name)) ==
assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name), NULL) ==
OAKENGINE_OK);
assert(strcmp(name, "Chapter 2") == 0);
assert(oakengine_sequence_marker_rename(seq, 77, "nope") ==
OAKENGINE_E_NOT_FOUND);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name)) ==
assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name), NULL) ==
OAKENGINE_OK);
assert(strcmp(name, "Chapter 1") == 0);
assert(oakengine_project_redo(project) == OAKENGINE_OK);
assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name)) ==
assert(oakengine_sequence_marker_at(seq, 1, &ts, name, sizeof(name), NULL) ==
OAKENGINE_OK);
assert(strcmp(name, "Chapter 2") == 0);
@@ -810,6 +810,158 @@ static void test_sequence_params(void)
oakengine_project_free(p2);
}
static void test_batch_editing(const char *media_path)
{
OakEngineProject *project = oakengine_project_create();
assert(project != NULL);
assert(oakengine_project_new(project) == OAKENGINE_OK);
OakEngineSequence *seq = oakengine_sequence_new(project, "Batch");
assert(seq != NULL);
assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) ==
0);
OakEngineFootage *footage =
oakengine_project_import_footage(project, media_path);
assert(footage != NULL);
int64_t in = -1, out = -1;
// Two clips on the video track: [0, 100) and [100, 160).
OakEngineClip *c0 = oakengine_sequence_add_footage_clip(
seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 100, 0);
OakEngineClip *c1 = oakengine_sequence_add_footage_clip(
seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 100, 160, 0);
assert(c0 != NULL && c1 != NULL);
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 2);
// split_clips at 50: the first clip splits in two.
{
OakEngineClip *arr[1] = { c0 };
assert(oakengine_sequence_split_clips(seq, arr, 1, 50) ==
OAKENGINE_OK);
}
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 3);
OakEngineClip *left = oakengine_sequence_clip_at(
seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0);
OakEngineClip *right = oakengine_sequence_clip_at(
seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1);
assert(oakengine_clip_get_range(left, &in, &out, NULL) == OAKENGINE_OK);
assert(in == 0 && out == 50);
assert(oakengine_clip_get_range(right, &in, &out, NULL) == OAKENGINE_OK);
assert(in == 50 && out == 100);
// Nothing spans 200; invalid arrays are rejected.
{
OakEngineClip *arr[2] = { left, right };
assert(oakengine_sequence_split_clips(seq, arr, 2, 200) ==
OAKENGINE_E_NOT_FOUND);
assert(oakengine_sequence_split_clips(seq, NULL, 1, 50) ==
OAKENGINE_E_INVALID);
assert(oakengine_sequence_split_clips(seq, arr, 0, 50) ==
OAKENGINE_E_INVALID);
assert(oakengine_sequence_split_clips(NULL, arr, 1, 50) ==
OAKENGINE_E_INVALID);
}
// Undo the split.
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 2);
// delete_clips without ripple: a gap is left, the second clip stays.
int rippled = -1;
{
OakEngineClip *arr[1] = { c0 };
assert(oakengine_sequence_delete_clips(seq, arr, 1, 0, NULL, 0,
&rippled) == OAKENGINE_OK);
}
assert(rippled == 0);
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 1);
assert(oakengine_clip_get_range(c1, &in, &out, NULL) == OAKENGINE_OK);
assert(in == 100 && out == 160);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 2);
// delete_clips with ripple (auto ranges): the second clip shifts left.
rippled = -1;
{
OakEngineClip *arr[1] = { c0 };
assert(oakengine_sequence_delete_clips(seq, arr, 1, 1, NULL, 0,
&rippled) == OAKENGINE_OK);
}
assert(rippled == 1);
assert(oakengine_clip_get_range(c1, &in, &out, NULL) == OAKENGINE_OK);
assert(in == 0 && out == 60);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_clip_get_range(c1, &in, &out, NULL) == OAKENGINE_OK);
assert(in == 100 && out == 160);
// delete_clips with an explicit ripple range: same shift.
rippled = -1;
{
OakEngineClip *arr[1] = { c0 };
const int64_t ranges[4] = { OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 100 };
assert(oakengine_sequence_delete_clips(seq, arr, 1, 1, ranges, 1,
&rippled) == OAKENGINE_OK);
}
assert(rippled == 1);
assert(oakengine_clip_get_range(c1, &in, &out, NULL) == OAKENGINE_OK);
assert(in == 0 && out == 60);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 2);
// A bad explicit range coordinate is rejected without side effects.
{
OakEngineClip *arr[1] = { c0 };
const int64_t bad[4] = { OAKENGINE_TRACK_TYPE_VIDEO, 9, 0, 100 };
assert(oakengine_sequence_delete_clips(seq, arr, 1, 1, bad, 1,
NULL) == OAKENGINE_E_NOT_FOUND);
}
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 2);
assert(oakengine_sequence_delete_clips(NULL, NULL, 0, 0, NULL, 0,
NULL) == OAKENGINE_E_INVALID);
// ripple_delete_range over [0, 100): the area is removed from every
// track and the following content shifts left.
assert(oakengine_sequence_ripple_delete_range(seq, 0, 100) ==
OAKENGINE_OK);
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 1);
OakEngineClip *remaining = oakengine_sequence_clip_at(
seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0);
assert(oakengine_clip_get_range(remaining, &in, &out, NULL) ==
OAKENGINE_OK);
assert(in == 0 && out == 60);
assert(oakengine_sequence_ripple_delete_range(seq, 100, 100) ==
OAKENGINE_E_INVALID);
assert(oakengine_sequence_ripple_delete_range(seq, -1, 5) ==
OAKENGINE_E_INVALID);
assert(oakengine_sequence_ripple_delete_range(NULL, 0, 1) ==
OAKENGINE_E_INVALID);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 2);
// Marker with an explicit color.
assert(oakengine_sequence_marker_add_ex(seq, 10, "colored", 5) ==
OAKENGINE_OK);
int color = -1;
int64_t ts = -1;
assert(oakengine_sequence_marker_at(seq, 0, &ts, NULL, 0, &color) ==
OAKENGINE_OK);
assert(ts == 10 && color == 5);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_sequence_marker_count(seq) == 0);
oakengine_footage_free(footage);
oakengine_project_free(project);
}
int main(void)
{
make_tmpdir();
@@ -839,6 +991,7 @@ int main(void)
test_track_structure(path);
test_markers();
test_sequence_params();
test_batch_editing(path);
oakengine_project_free(project);
assert(oakengine_shutdown() == OAKENGINE_OK);