engine: timeline edit primitives and oak-cli transcode
- oakengine_sequence_add_track and add_footage_clip are the facade's first editing primitives: undoable track creation and clip placement with full range validation, clip enumeration, and gap filtering - oak-cli transcode closes the loop: media file -> import -> clip -> render, producing scaled PPM frames and a WAV from just the C ABI - engine fix uncovered by transcode: the render worker used an invalid empty AudioParams for IPC render frames, crashing any sequence that contains audio; it now derives them from the rendered node itself - sequence_new hardens its defaults against missing audio config keys
This commit is contained in:
@@ -90,6 +90,15 @@ if (BUILD_TESTS)
|
|||||||
set_tests_properties(oak_cli_probe PROPERTIES
|
set_tests_properties(oak_cli_probe PROPERTIES
|
||||||
PASS_REGULAR_EXPRESSION "1920.*48000"
|
PASS_REGULAR_EXPRESSION "1920.*48000"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# transcode smoke test: full "media in, renders out" round trip at a
|
||||||
|
# reduced width; like render it exits 2 without a GL backend (skip).
|
||||||
|
add_test(NAME oak_cli_transcode
|
||||||
|
COMMAND oak-cli transcode ${CMAKE_SOURCE_DIR}/tests/demo.mp4 ${CMAKE_CURRENT_BINARY_DIR}/oak_cli_transcode_out 960
|
||||||
|
)
|
||||||
|
set_tests_properties(oak_cli_transcode PROPERTIES
|
||||||
|
SKIP_RETURN_CODE 2
|
||||||
|
)
|
||||||
# Rendering needs the render worker and the dynamic backend plugins.
|
# Rendering needs the render worker and the dynamic backend plugins.
|
||||||
if (TARGET olive-render-worker)
|
if (TARGET olive-render-worker)
|
||||||
add_dependencies(oak-cli olive-render-worker)
|
add_dependencies(oak-cli olive-render-worker)
|
||||||
|
|||||||
+243
-41
@@ -80,6 +80,12 @@ void print_usage(FILE *out)
|
|||||||
" oak-cli probe <mediafile>\n"
|
" oak-cli probe <mediafile>\n"
|
||||||
" Probe a media file: decoder, duration, video and audio streams.\n"
|
" Probe a media file: decoder, duration, video and audio streams.\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
" oak-cli transcode <input_media> <out_dir> [width]\n"
|
||||||
|
" Transcode a media file end to end: import it into a temporary\n"
|
||||||
|
" project, place it as clips, and render the whole duration to\n"
|
||||||
|
" PPM frames + a WAV. [width] defaults to the source width; the\n"
|
||||||
|
" height follows the source aspect ratio.\n"
|
||||||
|
"\n"
|
||||||
" oak-cli --help\n"
|
" oak-cli --help\n"
|
||||||
" Show this text.\n"
|
" Show this text.\n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -307,6 +313,64 @@ int renderer_fail(OakEngineRenderer *renderer, const char *what)
|
|||||||
return k_exit_render_unavailable;
|
return k_exit_render_unavailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render frames [start_ts, end_ts) to PPM files in out_dir, with per-frame
|
||||||
|
// progress on stderr. Shared by the render and transcode commands.
|
||||||
|
int render_frames_to_ppm(OakEngineRenderer *renderer, const char *out_dir,
|
||||||
|
int64_t start_ts, int64_t end_ts)
|
||||||
|
{
|
||||||
|
const int64_t frame_count = end_ts - start_ts;
|
||||||
|
int rc = k_exit_ok;
|
||||||
|
try {
|
||||||
|
for (int64_t ts = start_ts; ts < end_ts; ts++) {
|
||||||
|
OakEngineFrame *frame =
|
||||||
|
oakengine_renderer_render_frame(renderer, ts);
|
||||||
|
if (!frame) {
|
||||||
|
rc = renderer_fail(renderer, "render_frame");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fprintf(stderr, "frame %lld/%lld (ts=%lld)\n",
|
||||||
|
(long long)(ts - start_ts + 1), (long long)frame_count,
|
||||||
|
(long long)ts);
|
||||||
|
|
||||||
|
char name[64];
|
||||||
|
snprintf(name, sizeof(name), "frame-%04lld.ppm",
|
||||||
|
(long long)(ts - start_ts));
|
||||||
|
const std::filesystem::path ppm_path =
|
||||||
|
std::filesystem::path(out_dir) / name;
|
||||||
|
write_ppm(frame, ppm_path.string());
|
||||||
|
oakengine_frame_free(frame);
|
||||||
|
}
|
||||||
|
} catch (const std::string &e) {
|
||||||
|
fprintf(stderr, "error: %s\n", e.c_str());
|
||||||
|
if (rc == k_exit_ok) {
|
||||||
|
rc = k_exit_error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render audio [start_ts, start_ts+length_ts) to audio.wav in out_dir.
|
||||||
|
// Shared by the render and transcode commands.
|
||||||
|
int render_audio_to_wav(OakEngineRenderer *renderer, const char *out_dir,
|
||||||
|
int64_t start_ts, int64_t length_ts)
|
||||||
|
{
|
||||||
|
OakEngineAudioBuffer *audio =
|
||||||
|
oakengine_renderer_render_audio(renderer, start_ts, length_ts);
|
||||||
|
if (!audio) {
|
||||||
|
return renderer_fail(renderer, "render_audio");
|
||||||
|
}
|
||||||
|
int rc = k_exit_ok;
|
||||||
|
try {
|
||||||
|
write_wav(audio,
|
||||||
|
(std::filesystem::path(out_dir) / "audio.wav").string());
|
||||||
|
} catch (const std::string &e) {
|
||||||
|
fprintf(stderr, "error: %s\n", e.c_str());
|
||||||
|
rc = k_exit_error;
|
||||||
|
}
|
||||||
|
oakengine_audio_free(audio);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
int cmd_render(const char *path, const char *start_str, const char *end_str,
|
int cmd_render(const char *path, const char *start_str, const char *end_str,
|
||||||
const char *out_dir)
|
const char *out_dir)
|
||||||
{
|
{
|
||||||
@@ -407,49 +471,10 @@ int cmd_render(const char *path, const char *start_str, const char *end_str,
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
rc = render_frames_to_ppm(renderer, out_dir, start_ts, end_ts);
|
||||||
for (int64_t ts = start_ts; ts < end_ts; ts++) {
|
|
||||||
OakEngineFrame *frame =
|
|
||||||
oakengine_renderer_render_frame(renderer, ts);
|
|
||||||
if (!frame) {
|
|
||||||
rc = renderer_fail(renderer, "render_frame");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
fprintf(stderr, "frame %lld/%lld (ts=%lld)\n",
|
|
||||||
(long long)(ts - start_ts + 1), (long long)frame_count,
|
|
||||||
(long long)ts);
|
|
||||||
|
|
||||||
char name[64];
|
|
||||||
snprintf(name, sizeof(name), "frame-%04lld.ppm",
|
|
||||||
(long long)(ts - start_ts));
|
|
||||||
const std::filesystem::path ppm_path =
|
|
||||||
std::filesystem::path(out_dir) / name;
|
|
||||||
write_ppm(frame, ppm_path.string());
|
|
||||||
oakengine_frame_free(frame);
|
|
||||||
}
|
|
||||||
} catch (const std::string &e) {
|
|
||||||
fprintf(stderr, "error: %s\n", e.c_str());
|
|
||||||
if (rc == k_exit_ok) {
|
|
||||||
rc = k_exit_error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rc == k_exit_ok) {
|
if (rc == k_exit_ok) {
|
||||||
OakEngineAudioBuffer *audio = oakengine_renderer_render_audio(
|
rc = render_audio_to_wav(renderer, out_dir, start_ts, frame_count);
|
||||||
renderer, start_ts, frame_count);
|
|
||||||
if (!audio) {
|
|
||||||
rc = renderer_fail(renderer, "render_audio");
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
write_wav(audio,
|
|
||||||
(std::filesystem::path(out_dir) / "audio.wav")
|
|
||||||
.string());
|
|
||||||
} catch (const std::string &e) {
|
|
||||||
fprintf(stderr, "error: %s\n", e.c_str());
|
|
||||||
rc = k_exit_error;
|
|
||||||
}
|
|
||||||
oakengine_audio_free(audio);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rc == k_exit_ok) {
|
if (rc == k_exit_ok) {
|
||||||
@@ -537,6 +562,176 @@ int cmd_probe(const char *path)
|
|||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Media in, renders out" round trip: probe the source, build a temporary
|
||||||
|
// project with the whole media placed as clips, and render it out to PPM
|
||||||
|
// frames + a WAV of the full duration.
|
||||||
|
int cmd_transcode(const char *input, const char *out_dir,
|
||||||
|
const char *width_str)
|
||||||
|
{
|
||||||
|
int width = 0;
|
||||||
|
if (width_str) {
|
||||||
|
char *end = nullptr;
|
||||||
|
const long parsed = std::strtol(width_str, &end, 10);
|
||||||
|
if (end == width_str || *end != '\0' || parsed <= 0) {
|
||||||
|
fprintf(stderr, "error: invalid width \"%s\"\n", width_str);
|
||||||
|
return k_exit_usage;
|
||||||
|
}
|
||||||
|
width = int(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) !=
|
||||||
|
OAKENGINE_OK) {
|
||||||
|
fprintf(stderr, "error: failed to initialize the engine\n");
|
||||||
|
return k_exit_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
int rc = k_exit_ok;
|
||||||
|
OakEngineProject *project = nullptr;
|
||||||
|
OakEngineFootage *footage = nullptr;
|
||||||
|
OakEngineRenderer *renderer = nullptr;
|
||||||
|
do {
|
||||||
|
// Probe the source for its native geometry, frame rate and duration.
|
||||||
|
OakEngineFootage *probed = oakengine_footage_probe(input);
|
||||||
|
if (!probed) {
|
||||||
|
char err[1024];
|
||||||
|
fprintf(stderr, "error: %s\n",
|
||||||
|
oakengine_footage_last_error(err, sizeof(err)) > 0 ?
|
||||||
|
err :
|
||||||
|
"probe failed");
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (oakengine_footage_get_video_stream_count(probed) < 1) {
|
||||||
|
fprintf(stderr, "error: \"%s\" has no video stream\n", input);
|
||||||
|
oakengine_footage_free(probed);
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
oak_footage_video_info vi;
|
||||||
|
if (oakengine_footage_get_video_stream_info(probed, 0, &vi) !=
|
||||||
|
OAKENGINE_OK ||
|
||||||
|
vi.frame_rate_num <= 0 || vi.frame_rate_den <= 0) {
|
||||||
|
fprintf(stderr, "error: \"%s\" has no valid video stream\n",
|
||||||
|
input);
|
||||||
|
oakengine_footage_free(probed);
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const double fps = double(vi.frame_rate_num) / vi.frame_rate_den;
|
||||||
|
double seconds = 0.0;
|
||||||
|
oakengine_footage_get_duration(probed, &seconds);
|
||||||
|
const int64_t total_ts = std::llround(seconds * fps);
|
||||||
|
if (width <= 0) {
|
||||||
|
width = vi.width;
|
||||||
|
}
|
||||||
|
const int height = std::max(2l, std::lround(
|
||||||
|
double(width) * vi.height / vi.width));
|
||||||
|
oakengine_footage_free(probed);
|
||||||
|
|
||||||
|
// Build the temporary project: import, sequence, one video + one
|
||||||
|
// audio track, and clips spanning the whole media.
|
||||||
|
project = oakengine_project_create();
|
||||||
|
if (oakengine_project_new(project) != OAKENGINE_OK) {
|
||||||
|
fprintf(stderr, "error: failed to create project\n");
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::error_code ec;
|
||||||
|
const std::string abs_input =
|
||||||
|
std::filesystem::absolute(std::filesystem::path(input), ec)
|
||||||
|
.string();
|
||||||
|
footage = oakengine_project_import_footage(
|
||||||
|
project, ec ? input : abs_input.c_str());
|
||||||
|
if (!footage) {
|
||||||
|
char err[1024];
|
||||||
|
fprintf(stderr, "error: %s\n",
|
||||||
|
oakengine_footage_last_error(err, sizeof(err)) > 0 ?
|
||||||
|
err :
|
||||||
|
"import failed");
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
OakEngineSequence *seq = oakengine_sequence_new(project, "Transcode");
|
||||||
|
if (!seq) {
|
||||||
|
fprintf(stderr, "error: failed to create sequence\n");
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Clip ranges are timestamps in the SEQUENCE's frame-rate timebase
|
||||||
|
// (which may differ from the source frame rate); the render loop
|
||||||
|
// below runs at the source frame rate.
|
||||||
|
int seq_fr_num = 0, seq_fr_den = 0;
|
||||||
|
if (oakengine_sequence_get_frame_rate(seq, &seq_fr_num,
|
||||||
|
&seq_fr_den) != OAKENGINE_OK ||
|
||||||
|
seq_fr_num <= 0 || seq_fr_den <= 0) {
|
||||||
|
fprintf(stderr, "error: sequence has no valid frame rate\n");
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const double seq_fps = double(seq_fr_num) / seq_fr_den;
|
||||||
|
const int64_t clip_end_ts = std::llround(seconds * seq_fps);
|
||||||
|
char edit_err[512];
|
||||||
|
if (oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) < 0 ||
|
||||||
|
oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) < 0) {
|
||||||
|
fprintf(stderr, "error: failed to add tracks: %s\n",
|
||||||
|
oakengine_sequence_last_error(edit_err,
|
||||||
|
sizeof(edit_err)) > 0 ?
|
||||||
|
edit_err :
|
||||||
|
"(no error)");
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!oakengine_sequence_add_footage_clip(
|
||||||
|
seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, clip_end_ts,
|
||||||
|
0) ||
|
||||||
|
!oakengine_sequence_add_footage_clip(
|
||||||
|
seq, footage, OAKENGINE_TRACK_TYPE_AUDIO, 0, 0, clip_end_ts,
|
||||||
|
0)) {
|
||||||
|
fprintf(stderr, "error: failed to place clips: %s\n",
|
||||||
|
oakengine_sequence_last_error(edit_err,
|
||||||
|
sizeof(edit_err)) > 0 ?
|
||||||
|
edit_err :
|
||||||
|
"(no error)");
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::create_directories(out_dir, ec);
|
||||||
|
if (ec) {
|
||||||
|
fprintf(stderr, "error: cannot create output directory \"%s\": %s\n",
|
||||||
|
out_dir, ec.message().c_str());
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer = oakengine_renderer_create(seq, width, height,
|
||||||
|
k_pixel_format_f32,
|
||||||
|
vi.frame_rate_num,
|
||||||
|
vi.frame_rate_den, nullptr);
|
||||||
|
if (!renderer) {
|
||||||
|
fprintf(stderr, "error: failed to create renderer\n");
|
||||||
|
rc = k_exit_error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = render_frames_to_ppm(renderer, out_dir, 0, total_ts);
|
||||||
|
if (rc == k_exit_ok) {
|
||||||
|
rc = render_audio_to_wav(renderer, out_dir, 0, total_ts);
|
||||||
|
}
|
||||||
|
if (rc == k_exit_ok) {
|
||||||
|
printf("wrote %lld PPM frame(s) (%dx%d) and audio.wav to \"%s\"\n",
|
||||||
|
(long long)total_ts, width, height, out_dir);
|
||||||
|
}
|
||||||
|
} while (false);
|
||||||
|
|
||||||
|
oakengine_renderer_free(renderer);
|
||||||
|
oakengine_footage_free(footage);
|
||||||
|
oakengine_project_free(project);
|
||||||
|
oakengine_shutdown();
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
@@ -572,6 +767,13 @@ int main(int argc, char *argv[])
|
|||||||
}
|
}
|
||||||
return cmd_probe(argv[2]);
|
return cmd_probe(argv[2]);
|
||||||
}
|
}
|
||||||
|
if (command == "transcode") {
|
||||||
|
if (argc != 4 && argc != 5) {
|
||||||
|
print_usage(stderr);
|
||||||
|
return k_exit_usage;
|
||||||
|
}
|
||||||
|
return cmd_transcode(argv[2], argv[3], argc == 5 ? argv[4] : nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
fprintf(stderr, "error: unknown command \"%s\"\n", argv[1]);
|
fprintf(stderr, "error: unknown command \"%s\"\n", argv[1]);
|
||||||
print_usage(stderr);
|
print_usage(stderr);
|
||||||
|
|||||||
@@ -269,4 +269,9 @@ if (BUILD_TESTS)
|
|||||||
target_compile_definitions(oakengine_footage_test PRIVATE
|
target_compile_definitions(oakengine_footage_test PRIVATE
|
||||||
OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
make_oakengine_test(oakengine_timeline_edit_test)
|
||||||
|
target_compile_definitions(oakengine_timeline_edit_test PRIVATE
|
||||||
|
OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||||
|
)
|
||||||
endif ()
|
endif ()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
#include "export.h"
|
#include "export.h"
|
||||||
|
#include "footage.h"
|
||||||
#include "init.h"
|
#include "init.h"
|
||||||
#include "project.h"
|
#include "project.h"
|
||||||
|
|
||||||
@@ -186,6 +187,97 @@ OAKENGINE_API int oakengine_sequence_marker_at(const OakEngineSequence *self,
|
|||||||
int index, int64_t *time,
|
int index, int64_t *time,
|
||||||
char *name, int name_size);
|
char *name, int name_size);
|
||||||
|
|
||||||
|
/* ---- Timeline editing primitives ---------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Track types, matching olive::Track::Type.
|
||||||
|
*/
|
||||||
|
#define OAKENGINE_TRACK_TYPE_VIDEO 0
|
||||||
|
#define OAKENGINE_TRACK_TYPE_AUDIO 1
|
||||||
|
#define OAKENGINE_TRACK_TYPE_SUBTITLE 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Opaque clip handle (a ClipBlock on a track).
|
||||||
|
*
|
||||||
|
* Handles are borrowed from their owning project (QObject parent chain) and
|
||||||
|
* become invalid when the project is freed or the clip is removed (e.g. by
|
||||||
|
* undoing the add). There is no oakengine_clip_free().
|
||||||
|
*/
|
||||||
|
typedef struct OakEngineClip OakEngineClip;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Human-readable reason for the last failed editing call on this
|
||||||
|
* thread (buf/size convention). Editing calls return NULL or a negative
|
||||||
|
* OAKENGINE_E_* code; the text explains why.
|
||||||
|
*/
|
||||||
|
OAKENGINE_API int oakengine_sequence_last_error(char *buf, int buf_size);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Append a track of `track_type` (OAKENGINE_TRACK_TYPE_*) to the
|
||||||
|
* sequence and return its index in that type's track list.
|
||||||
|
*
|
||||||
|
* Uses the engine's TimelineAddTrackCommand without auto-merge: the first
|
||||||
|
* video/audio track is connected straight to the sequence's texture/samples
|
||||||
|
* input (tracks beyond the first stay unconnected until a merge node is
|
||||||
|
* added -- multi-track compositing is a later milestone). The add is
|
||||||
|
* undoable like the other editing primitives. Returns the new track index
|
||||||
|
* (>= 0) or a negative OAKENGINE_E_* code.
|
||||||
|
*/
|
||||||
|
OAKENGINE_API int oakengine_sequence_add_track(OakEngineSequence *self,
|
||||||
|
int track_type);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Place a clip of `footage` on a track (undoable).
|
||||||
|
*
|
||||||
|
* Creates an olive::ClipBlock whose buffer input is fed by the footage node
|
||||||
|
* and places it on the track at `track_index` (within the track list of
|
||||||
|
* `track_type`, OAKENGINE_TRACK_TYPE_VIDEO or _AUDIO; subtitle clips are
|
||||||
|
* rejected with OAKENGINE_E_INVALID). The footage handle must be a borrowed
|
||||||
|
* import handle belonging to the same project as the sequence (probed
|
||||||
|
* handles carry no node and are rejected).
|
||||||
|
*
|
||||||
|
* `in`/`out` are the clip's timeline range and `media_in` the source in-
|
||||||
|
* point, all as frame timestamps in the sequence's frame-rate timebase
|
||||||
|
* (same convention as the rest of this family); `out` must be greater than
|
||||||
|
* `in` and `media_in` must be >= 0. No track is created implicitly: an
|
||||||
|
* out-of-range `track_index` fails with OAKENGINE_E_NOT_FOUND.
|
||||||
|
*
|
||||||
|
* The add mirrors the application's drop-import chain reduced to its
|
||||||
|
* editing core (NodeAddCommand + NodeEdgeAddCommand onto
|
||||||
|
* ClipBlock::k_buffer_in + TrackPlaceBlockCommand, pushed as one undoable
|
||||||
|
* MultiUndoCommand). Returns a borrowed clip handle, or NULL on failure
|
||||||
|
* (see oakengine_sequence_last_error()).
|
||||||
|
*/
|
||||||
|
OAKENGINE_API OakEngineClip *oakengine_sequence_add_footage_clip(
|
||||||
|
OakEngineSequence *seq, OakEngineFootage *footage, int track_type,
|
||||||
|
int track_index, int64_t in, int64_t out, int64_t media_in);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Number of clips on the track at `track_index` (within the
|
||||||
|
* `track_type` list). Gap blocks are not clips and are not counted.
|
||||||
|
* Returns the count (>= 0) or a negative OAKENGINE_E_* code
|
||||||
|
* (OAKENGINE_E_NOT_FOUND when the track does not exist).
|
||||||
|
*/
|
||||||
|
OAKENGINE_API int oakengine_sequence_clip_count(OakEngineSequence *self,
|
||||||
|
int track_type,
|
||||||
|
int track_index);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Borrowed handle of the clip at `clip_index` on the track (gap
|
||||||
|
* blocks are skipped), or NULL when out of range.
|
||||||
|
*/
|
||||||
|
OAKENGINE_API OakEngineClip *oakengine_sequence_clip_at(
|
||||||
|
OakEngineSequence *self, int track_type, int track_index, int clip_index);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The clip's timeline range (`in`/`out`) and source in-point
|
||||||
|
* (`media_in`) as frame timestamps in the sequence's frame-rate timebase.
|
||||||
|
* Any pointer may be NULL.
|
||||||
|
*/
|
||||||
|
OAKENGINE_API int oakengine_clip_get_range(const OakEngineClip *self,
|
||||||
|
int64_t *in, int64_t *out,
|
||||||
|
int64_t *media_in);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -126,6 +126,19 @@ QString filename_of(const OakEngineFootageState *s)
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
// Internal cross-family accessor (not part of the public C ABI): returns
|
||||||
|
// the borrowed project node of an import handle, or nullptr for probe
|
||||||
|
// handles and NULL. Used by the timeline editing primitives.
|
||||||
|
extern "C" __attribute__((visibility("hidden"))) void *
|
||||||
|
oakengine_capi_footage_node(OakEngineFootage *h)
|
||||||
|
{
|
||||||
|
if (!h) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const OakEngineFootageState *s = impl(h);
|
||||||
|
return s->node;
|
||||||
|
}
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,23 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
#include "coreengine.h"
|
#include "coreengine.h"
|
||||||
|
#include "node/block/clip/clip.h"
|
||||||
#include "node/nodeundo.h"
|
#include "node/nodeundo.h"
|
||||||
#include "node/project.h"
|
#include "node/project.h"
|
||||||
#include "node/project/folder/folder.h"
|
#include "node/project/folder/folder.h"
|
||||||
#include "node/project/sequence/sequence.h"
|
#include "node/project/sequence/sequence.h"
|
||||||
#include "timeline/timelinemarker.h"
|
#include "timeline/timelinemarker.h"
|
||||||
|
#include "timeline/timelineundogeneral.h"
|
||||||
|
#include "timeline/timelineundopointer.h"
|
||||||
#include "timeline/timelineworkarea.h"
|
#include "timeline/timelineworkarea.h"
|
||||||
#include "undo/undocommand.h"
|
#include "undo/undocommand.h"
|
||||||
#include "undo/undostack.h"
|
#include "undo/undostack.h"
|
||||||
|
|
||||||
|
// Internal cross-family accessor (not part of the public C ABI), defined in
|
||||||
|
// footage.cpp: borrowed project node of an import handle, nullptr otherwise.
|
||||||
|
extern "C" __attribute__((visibility("hidden"))) void *
|
||||||
|
oakengine_capi_footage_node(OakEngineFootage *h);
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -102,6 +110,33 @@ int64_t time_to_ts(const olive::Rational &time, const olive::Rational &tb)
|
|||||||
time, tb, olive::core::Timecode::k_round);
|
time, tb, olive::core::Timecode::k_round);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Editing primitive helpers -------------------------------------------
|
||||||
|
|
||||||
|
// Last editing error per thread (editing calls return NULL/negative codes).
|
||||||
|
thread_local QString g_seq_last_error;
|
||||||
|
|
||||||
|
void set_seq_error(const QString &error)
|
||||||
|
{
|
||||||
|
g_seq_last_error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::Track::Type to_track_type(int track_type)
|
||||||
|
{
|
||||||
|
switch (track_type) {
|
||||||
|
case OAKENGINE_TRACK_TYPE_VIDEO:
|
||||||
|
return olive::Track::k_video;
|
||||||
|
case OAKENGINE_TRACK_TYPE_AUDIO:
|
||||||
|
return olive::Track::k_audio;
|
||||||
|
default:
|
||||||
|
return olive::Track::k_subtitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OakEngineClip *wrap_clip(olive::ClipBlock *c)
|
||||||
|
{
|
||||||
|
return reinterpret_cast<OakEngineClip *>(c);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
@@ -119,6 +154,30 @@ OakEngineSequence *oakengine_sequence_new(OakEngineProject *project,
|
|||||||
sequence->set_default_parameters();
|
sequence->set_default_parameters();
|
||||||
sequence->set_label(QString::fromUtf8(name ? name : ""));
|
sequence->set_label(QString::fromUtf8(name ? name : ""));
|
||||||
|
|
||||||
|
// set_default_parameters() reads the sequence defaults from the user
|
||||||
|
// config; a config that lacks those keys yields invalid parameters
|
||||||
|
// (e.g. sample rate 0), which later aborts the render worker. Backfill
|
||||||
|
// hard defaults for anything invalid.
|
||||||
|
if (sequence->get_audio_params().sample_rate() <= 0) {
|
||||||
|
sequence->set_audio_params(
|
||||||
|
olive::AudioParams(48000, olive::core::k_channel_layout_stereo,
|
||||||
|
olive::core::SampleFormat::f32_p));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const olive::VideoParams vp = sequence->get_video_params();
|
||||||
|
const olive::Rational frame_rate = vp.frame_rate();
|
||||||
|
if (vp.width() <= 0 || vp.height() <= 0 || frame_rate.isNull() ||
|
||||||
|
frame_rate.isNaN()) {
|
||||||
|
const olive::PixelFormat::Format format =
|
||||||
|
vp.format() == olive::PixelFormat::invalid ?
|
||||||
|
olive::PixelFormat::f32 :
|
||||||
|
static_cast<olive::PixelFormat::Format>(vp.format());
|
||||||
|
sequence->set_video_params(olive::VideoParams(
|
||||||
|
1920, 1080, olive::Rational(1001, 30000), format,
|
||||||
|
olive::VideoParams::k_internal_channel_count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Same undoable creation as the application's "Create New Sequence"
|
// Same undoable creation as the application's "Create New Sequence"
|
||||||
// action (app/core.cpp), minus opening a viewer. Without an EngineCore
|
// action (app/core.cpp), minus opening a viewer. Without an EngineCore
|
||||||
// (library not initialized) the command is executed non-undoably.
|
// (library not initialized) the command is executed non-undoably.
|
||||||
@@ -347,4 +406,197 @@ int oakengine_sequence_marker_at(const OakEngineSequence *self, int index,
|
|||||||
return OAKENGINE_OK;
|
return OAKENGINE_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Timeline editing primitives ---------------------------------------- */
|
||||||
|
|
||||||
|
int oakengine_sequence_last_error(char *buf, int buf_size)
|
||||||
|
{
|
||||||
|
return string_to_buf(g_seq_last_error, buf, buf_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
int oakengine_sequence_add_track(OakEngineSequence *self, int track_type)
|
||||||
|
{
|
||||||
|
set_seq_error(QString());
|
||||||
|
if (!self || track_type < OAKENGINE_TRACK_TYPE_VIDEO ||
|
||||||
|
track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) {
|
||||||
|
set_seq_error(QStringLiteral("invalid sequence or track type"));
|
||||||
|
return OAKENGINE_E_INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::TrackList *list =
|
||||||
|
impl(self)->track_list(to_track_type(track_type));
|
||||||
|
// TimelineAddTrackCommand without auto-merge: the first video/audio
|
||||||
|
// track connects straight to the sequence output; further tracks stay
|
||||||
|
// unconnected (compositing is a later milestone).
|
||||||
|
auto *command = new olive::TimelineAddTrackCommand(list, false);
|
||||||
|
if (olive::EngineCore::instance()) {
|
||||||
|
olive::EngineCore::instance()->undo_stack()->push(
|
||||||
|
command, QStringLiteral("Add Track"));
|
||||||
|
} else {
|
||||||
|
command->redo_now();
|
||||||
|
delete command;
|
||||||
|
}
|
||||||
|
return list->get_track_count() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
OakEngineClip *oakengine_sequence_add_footage_clip(
|
||||||
|
OakEngineSequence *seq, OakEngineFootage *footage, int track_type,
|
||||||
|
int track_index, int64_t in, int64_t out, int64_t media_in)
|
||||||
|
{
|
||||||
|
set_seq_error(QString());
|
||||||
|
olive::Sequence *sequence = reinterpret_cast<olive::Sequence *>(seq);
|
||||||
|
auto *footage_node =
|
||||||
|
static_cast<olive::Footage *>(oakengine_capi_footage_node(footage));
|
||||||
|
|
||||||
|
if (!sequence || !footage) {
|
||||||
|
set_seq_error(QStringLiteral("invalid sequence or footage handle"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (!footage_node) {
|
||||||
|
set_seq_error(QStringLiteral(
|
||||||
|
"footage must be imported into the project first "
|
||||||
|
"(oakengine_project_import_footage)"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (track_type != OAKENGINE_TRACK_TYPE_VIDEO &&
|
||||||
|
track_type != OAKENGINE_TRACK_TYPE_AUDIO) {
|
||||||
|
set_seq_error(QStringLiteral(
|
||||||
|
"clips are only supported on video and audio tracks"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
olive::Project *project =
|
||||||
|
olive::Project::get_project_from_object(sequence);
|
||||||
|
if (!project ||
|
||||||
|
olive::Project::get_project_from_object(footage_node) != project) {
|
||||||
|
set_seq_error(QStringLiteral(
|
||||||
|
"footage and sequence belong to different projects"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (in < 0 || out <= in || media_in < 0) {
|
||||||
|
set_seq_error(QStringLiteral("invalid clip range (need 0 <= in < out "
|
||||||
|
"and media_in >= 0)"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
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("track index %1 out of range (%2 tracks)")
|
||||||
|
.arg(track_index)
|
||||||
|
.arg(list->get_track_count()));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
olive::Rational tb;
|
||||||
|
if (!time_base_of(sequence, &tb)) {
|
||||||
|
set_seq_error(QStringLiteral("sequence has no valid frame rate"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const olive::Rational in_time =
|
||||||
|
olive::core::Timecode::timestamp_to_time(in, tb);
|
||||||
|
const olive::Rational out_time =
|
||||||
|
olive::core::Timecode::timestamp_to_time(out, tb);
|
||||||
|
const olive::Rational media_in_time =
|
||||||
|
olive::core::Timecode::timestamp_to_time(media_in, tb);
|
||||||
|
|
||||||
|
// The application's drop-import chain reduced to its editing core (see
|
||||||
|
// ImportTool::place_at()): clip with media in-point and length, footage
|
||||||
|
// onto the buffer input, placed on the track -- all undoable.
|
||||||
|
auto *clip = new olive::ClipBlock();
|
||||||
|
clip->set_media_in(media_in_time);
|
||||||
|
clip->set_length_and_media_out(out_time - in_time);
|
||||||
|
|
||||||
|
olive::MultiUndoCommand *command = new olive::MultiUndoCommand();
|
||||||
|
command->add_child(new olive::NodeAddCommand(project, clip));
|
||||||
|
command->add_child(new olive::NodeEdgeAddCommand(
|
||||||
|
footage_node, olive::NodeInput(clip, olive::ClipBlock::k_buffer_in)));
|
||||||
|
command->add_child(new olive::TrackPlaceBlockCommand(list, track_index,
|
||||||
|
clip, in_time));
|
||||||
|
|
||||||
|
if (olive::EngineCore::instance()) {
|
||||||
|
olive::EngineCore::instance()->undo_stack()->push(
|
||||||
|
command, QStringLiteral("Add Clip"));
|
||||||
|
} else {
|
||||||
|
command->redo_now();
|
||||||
|
delete command;
|
||||||
|
}
|
||||||
|
return wrap_clip(clip);
|
||||||
|
}
|
||||||
|
|
||||||
|
int oakengine_sequence_clip_count(OakEngineSequence *self, int track_type,
|
||||||
|
int track_index)
|
||||||
|
{
|
||||||
|
if (!self || track_type < OAKENGINE_TRACK_TYPE_VIDEO ||
|
||||||
|
track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) {
|
||||||
|
return OAKENGINE_E_INVALID;
|
||||||
|
}
|
||||||
|
olive::TrackList *list =
|
||||||
|
impl(self)->track_list(to_track_type(track_type));
|
||||||
|
if (track_index < 0 || track_index >= list->get_track_count()) {
|
||||||
|
return OAKENGINE_E_NOT_FOUND;
|
||||||
|
}
|
||||||
|
// Only real clips count; gap blocks on the track are skipped.
|
||||||
|
int count = 0;
|
||||||
|
for (const olive::Block *b : list->get_track_at(track_index)->blocks()) {
|
||||||
|
if (dynamic_cast<const olive::ClipBlock *>(b)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
OakEngineClip *oakengine_sequence_clip_at(OakEngineSequence *self,
|
||||||
|
int track_type, int track_index,
|
||||||
|
int clip_index)
|
||||||
|
{
|
||||||
|
if (!self || track_type < OAKENGINE_TRACK_TYPE_VIDEO ||
|
||||||
|
track_type > OAKENGINE_TRACK_TYPE_SUBTITLE || clip_index < 0) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
olive::TrackList *list =
|
||||||
|
impl(self)->track_list(to_track_type(track_type));
|
||||||
|
if (track_index < 0 || track_index >= list->get_track_count()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
// Skip gap blocks: indexes address clips only.
|
||||||
|
int seen = 0;
|
||||||
|
for (olive::Block *b : list->get_track_at(track_index)->blocks()) {
|
||||||
|
if (olive::ClipBlock *clip = dynamic_cast<olive::ClipBlock *>(b)) {
|
||||||
|
if (seen == clip_index) {
|
||||||
|
return wrap_clip(clip);
|
||||||
|
}
|
||||||
|
seen++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int oakengine_clip_get_range(const OakEngineClip *self, int64_t *in,
|
||||||
|
int64_t *out, int64_t *media_in)
|
||||||
|
{
|
||||||
|
const olive::ClipBlock *clip =
|
||||||
|
reinterpret_cast<const olive::ClipBlock *>(self);
|
||||||
|
if (!clip) {
|
||||||
|
return OAKENGINE_E_INVALID;
|
||||||
|
}
|
||||||
|
// The clip's sequence (clip -> track -> sequence) provides the timebase
|
||||||
|
// for the timestamp conversion.
|
||||||
|
const olive::Sequence *sequence =
|
||||||
|
clip->track() ? clip->track()->sequence() : nullptr;
|
||||||
|
if (!sequence) {
|
||||||
|
return OAKENGINE_E_STATE;
|
||||||
|
}
|
||||||
|
olive::Rational tb;
|
||||||
|
if (!time_base_of(sequence, &tb)) {
|
||||||
|
return OAKENGINE_E_STATE;
|
||||||
|
}
|
||||||
|
if (in) {
|
||||||
|
*in = time_to_ts(clip->in(), tb);
|
||||||
|
}
|
||||||
|
if (out) {
|
||||||
|
*out = time_to_ts(clip->out(), tb);
|
||||||
|
}
|
||||||
|
if (media_in) {
|
||||||
|
*media_in = time_to_ts(clip->media_in(), tb);
|
||||||
|
}
|
||||||
|
return OAKENGINE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/***
|
||||||
|
|
||||||
|
Oak - 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
***/
|
||||||
|
|
||||||
|
// Pure C ABI test for the liboakengine timeline editing primitives:
|
||||||
|
// add_track, add_footage_clip and the clip accessors, including their
|
||||||
|
// undo/redo behavior and failure paths. Uses the real media file
|
||||||
|
// tests/demo.mp4. No GL required.
|
||||||
|
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#include <direct.h>
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "oakengine/footage.h"
|
||||||
|
#include "oakengine/init.h"
|
||||||
|
#include "oakengine/project.h"
|
||||||
|
#include "oakengine/timeline.h"
|
||||||
|
|
||||||
|
#ifndef OAK_TEST_SOURCE_DIR
|
||||||
|
#define OAK_TEST_SOURCE_DIR "."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static char g_tmpdir[4096];
|
||||||
|
|
||||||
|
static void make_tmpdir(void)
|
||||||
|
{
|
||||||
|
#if defined(_WIN32)
|
||||||
|
char base[MAX_PATH];
|
||||||
|
const DWORD len = GetTempPathA(MAX_PATH, base);
|
||||||
|
assert(len > 0 && len < MAX_PATH);
|
||||||
|
snprintf(g_tmpdir, sizeof(g_tmpdir), "%soakengine_tl_edit_test_%lu", base,
|
||||||
|
(unsigned long)GetCurrentProcessId());
|
||||||
|
assert(_mkdir(g_tmpdir) == 0);
|
||||||
|
#else
|
||||||
|
strcpy(g_tmpdir, "/tmp/oakengine_tl_edit_test_XXXXXX");
|
||||||
|
assert(mkdtemp(g_tmpdir) != NULL);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static void demo_path(char *dst, size_t cap)
|
||||||
|
{
|
||||||
|
const int n = snprintf(dst, cap, "%s/tests/demo.mp4", OAK_TEST_SOURCE_DIR);
|
||||||
|
assert(n > 0 && (size_t)n < cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_add_track(OakEngineProject *project, OakEngineSequence *seq)
|
||||||
|
{
|
||||||
|
int video = -1, audio = -1, subtitle = -1;
|
||||||
|
|
||||||
|
assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) ==
|
||||||
|
OAKENGINE_OK);
|
||||||
|
assert(video == 0 && audio == 0 && subtitle == 0);
|
||||||
|
|
||||||
|
assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == 0);
|
||||||
|
assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == 0);
|
||||||
|
assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_SUBTITLE) ==
|
||||||
|
0);
|
||||||
|
assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == 1);
|
||||||
|
|
||||||
|
assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) ==
|
||||||
|
OAKENGINE_OK);
|
||||||
|
assert(video == 2 && audio == 1 && subtitle == 1);
|
||||||
|
|
||||||
|
// Invalid track types are rejected.
|
||||||
|
assert(oakengine_sequence_add_track(seq, -1) == OAKENGINE_E_INVALID);
|
||||||
|
assert(oakengine_sequence_add_track(seq, 3) == OAKENGINE_E_INVALID);
|
||||||
|
assert(oakengine_sequence_add_track(NULL, OAKENGINE_TRACK_TYPE_VIDEO) ==
|
||||||
|
OAKENGINE_E_INVALID);
|
||||||
|
|
||||||
|
// Track adds are undoable: undo the second video track, then redo it.
|
||||||
|
assert(oakengine_project_undo(project) == OAKENGINE_OK);
|
||||||
|
video = audio = subtitle = -1;
|
||||||
|
assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) ==
|
||||||
|
OAKENGINE_OK);
|
||||||
|
assert(video == 1 && audio == 1 && subtitle == 1);
|
||||||
|
assert(oakengine_project_redo(project) == OAKENGINE_OK);
|
||||||
|
video = audio = subtitle = -1;
|
||||||
|
assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) ==
|
||||||
|
OAKENGINE_OK);
|
||||||
|
assert(video == 2 && audio == 1 && subtitle == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_add_clip(OakEngineProject *project, OakEngineSequence *seq,
|
||||||
|
const char *media_path)
|
||||||
|
{
|
||||||
|
char err[512];
|
||||||
|
|
||||||
|
// Probe handles carry no project node and cannot be placed.
|
||||||
|
OakEngineFootage *probed = oakengine_footage_probe(media_path);
|
||||||
|
assert(probed != NULL);
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq, probed,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, 0,
|
||||||
|
0, 30, 0) == NULL);
|
||||||
|
assert(oakengine_sequence_last_error(err, sizeof(err)) > 0);
|
||||||
|
oakengine_footage_free(probed);
|
||||||
|
|
||||||
|
// Import the media into the project.
|
||||||
|
OakEngineFootage *footage =
|
||||||
|
oakengine_project_import_footage(project, media_path);
|
||||||
|
assert(footage != NULL);
|
||||||
|
|
||||||
|
// No subtitle clips.
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq, footage,
|
||||||
|
OAKENGINE_TRACK_TYPE_SUBTITLE,
|
||||||
|
0, 0, 30, 0) == NULL);
|
||||||
|
|
||||||
|
// Out-of-range track indexes are rejected without creating tracks.
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq, footage,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, 5,
|
||||||
|
0, 30, 0) == NULL);
|
||||||
|
assert(oakengine_sequence_last_error(err, sizeof(err)) > 0);
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq, footage,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, -1,
|
||||||
|
0, 30, 0) == NULL);
|
||||||
|
|
||||||
|
// Bad ranges: out <= in, negative in / media_in.
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq, footage,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, 0,
|
||||||
|
30, 30, 0) == NULL);
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq, footage,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, 0,
|
||||||
|
30, 10, 0) == NULL);
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq, footage,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, 0,
|
||||||
|
-1, 30, 0) == NULL);
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq, footage,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, 0,
|
||||||
|
0, 30, -1) == NULL);
|
||||||
|
|
||||||
|
// NULL safety.
|
||||||
|
assert(oakengine_sequence_add_footage_clip(NULL, footage,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, 0,
|
||||||
|
0, 30, 0) == NULL);
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq, NULL,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, 0,
|
||||||
|
0, 30, 0) == NULL);
|
||||||
|
|
||||||
|
// clip_count reports OAKENGINE_E_NOT_FOUND for a missing track.
|
||||||
|
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
|
||||||
|
99) == OAKENGINE_E_NOT_FOUND);
|
||||||
|
assert(oakengine_sequence_clip_count(NULL, OAKENGINE_TRACK_TYPE_VIDEO,
|
||||||
|
0) == OAKENGINE_E_INVALID);
|
||||||
|
|
||||||
|
// Place a video clip on track 0: frames 10..40, media in-point 5.
|
||||||
|
OakEngineClip *clip = oakengine_sequence_add_footage_clip(
|
||||||
|
seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 10, 40, 5);
|
||||||
|
assert(clip != NULL);
|
||||||
|
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
|
||||||
|
0) == 1);
|
||||||
|
OakEngineClip *at = oakengine_sequence_clip_at(
|
||||||
|
seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0);
|
||||||
|
assert(at == clip);
|
||||||
|
assert(oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0,
|
||||||
|
1) == NULL);
|
||||||
|
assert(oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 9,
|
||||||
|
0) == NULL);
|
||||||
|
|
||||||
|
int64_t in = -1, out = -1, media_in = -1;
|
||||||
|
assert(oakengine_clip_get_range(clip, &in, &out, &media_in) ==
|
||||||
|
OAKENGINE_OK);
|
||||||
|
assert(in == 10 && out == 40 && media_in == 5);
|
||||||
|
assert(oakengine_clip_get_range(NULL, &in, &out, &media_in) ==
|
||||||
|
OAKENGINE_E_INVALID);
|
||||||
|
|
||||||
|
// And an audio clip on audio track 0 (same footage, whole range).
|
||||||
|
OakEngineClip *aclip = oakengine_sequence_add_footage_clip(
|
||||||
|
seq, footage, OAKENGINE_TRACK_TYPE_AUDIO, 0, 0, 30, 0);
|
||||||
|
assert(aclip != NULL);
|
||||||
|
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_AUDIO,
|
||||||
|
0) == 1);
|
||||||
|
|
||||||
|
// Undo/redo both clip adds (the audio clip is on top of the undo stack,
|
||||||
|
// the video clip right below it).
|
||||||
|
assert(oakengine_project_undo(project) == OAKENGINE_OK);
|
||||||
|
assert(oakengine_project_undo(project) == OAKENGINE_OK);
|
||||||
|
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_AUDIO,
|
||||||
|
0) == 0);
|
||||||
|
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
|
||||||
|
0) == 0);
|
||||||
|
assert(oakengine_project_redo(project) == OAKENGINE_OK);
|
||||||
|
assert(oakengine_project_redo(project) == OAKENGINE_OK);
|
||||||
|
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO,
|
||||||
|
0) == 1);
|
||||||
|
assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_AUDIO,
|
||||||
|
0) == 1);
|
||||||
|
|
||||||
|
oakengine_footage_free(footage); // wrapper only; node stays
|
||||||
|
assert(oakengine_project_footage_count(project) == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footage imported into one project must not be placed into another.
|
||||||
|
static void test_cross_project_rejected(const char *media_path)
|
||||||
|
{
|
||||||
|
OakEngineProject *a = oakengine_project_create();
|
||||||
|
OakEngineProject *b = oakengine_project_create();
|
||||||
|
assert(a != NULL && b != NULL);
|
||||||
|
assert(oakengine_project_new(a) == OAKENGINE_OK);
|
||||||
|
assert(oakengine_project_new(b) == OAKENGINE_OK);
|
||||||
|
|
||||||
|
OakEngineFootage *footage =
|
||||||
|
oakengine_project_import_footage(a, media_path);
|
||||||
|
assert(footage != NULL);
|
||||||
|
|
||||||
|
OakEngineSequence *seq_b = oakengine_sequence_new(b, "B");
|
||||||
|
assert(seq_b != NULL);
|
||||||
|
assert(oakengine_sequence_add_track(seq_b, OAKENGINE_TRACK_TYPE_VIDEO) ==
|
||||||
|
0);
|
||||||
|
|
||||||
|
char err[512];
|
||||||
|
assert(oakengine_sequence_add_footage_clip(seq_b, footage,
|
||||||
|
OAKENGINE_TRACK_TYPE_VIDEO, 0,
|
||||||
|
0, 30, 0) == NULL);
|
||||||
|
assert(oakengine_sequence_last_error(err, sizeof(err)) > 0);
|
||||||
|
assert(oakengine_sequence_clip_count(seq_b, OAKENGINE_TRACK_TYPE_VIDEO,
|
||||||
|
0) == 0);
|
||||||
|
|
||||||
|
oakengine_footage_free(footage);
|
||||||
|
oakengine_project_free(a);
|
||||||
|
oakengine_project_free(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
make_tmpdir();
|
||||||
|
|
||||||
|
// Sandbox the config/cache/data locations (see oakengine_init_test).
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
assert(setenv("XDG_CONFIG_HOME", g_tmpdir, 1) == 0);
|
||||||
|
assert(setenv("XDG_CACHE_HOME", g_tmpdir, 1) == 0);
|
||||||
|
assert(setenv("XDG_DATA_HOME", g_tmpdir, 1) == 0);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK);
|
||||||
|
|
||||||
|
OakEngineProject *project = oakengine_project_create();
|
||||||
|
assert(project != NULL);
|
||||||
|
assert(oakengine_project_new(project) == OAKENGINE_OK);
|
||||||
|
OakEngineSequence *seq = oakengine_sequence_new(project, "Edit");
|
||||||
|
assert(seq != NULL);
|
||||||
|
|
||||||
|
char path[4096];
|
||||||
|
demo_path(path, sizeof(path));
|
||||||
|
|
||||||
|
test_add_track(project, seq);
|
||||||
|
test_add_clip(project, seq, path);
|
||||||
|
test_cross_project_rejected(path);
|
||||||
|
|
||||||
|
oakengine_project_free(project);
|
||||||
|
assert(oakengine_shutdown() == OAKENGINE_OK);
|
||||||
|
|
||||||
|
printf("oakengine_timeline_edit_test: all assertions passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
+12
-2
@@ -504,8 +504,18 @@ private:
|
|||||||
QVariant::fromValue(color_output));
|
QVariant::fromValue(color_output));
|
||||||
}
|
}
|
||||||
ticket->setProperty("vparam", QVariant::fromValue(vparams));
|
ticket->setProperty("vparam", QVariant::fromValue(vparams));
|
||||||
ticket->setProperty("aparam",
|
// The IPC render_frame message carries no audio parameters, but
|
||||||
QVariant::fromValue(olive::AudioParams()));
|
// rendering a sequence that has audio content evaluates audio
|
||||||
|
// tracks with globals.aparams -- an empty AudioParams aborts
|
||||||
|
// (AudioParams::time_to_samples asserts is_valid). Use the render
|
||||||
|
// node's own audio parameters, mirroring the in-process render
|
||||||
|
// path (PreviewAutoCacher uses context->get_audio_params()).
|
||||||
|
olive::AudioParams aparam;
|
||||||
|
if (olive::ViewerOutput *viewer =
|
||||||
|
dynamic_cast<olive::ViewerOutput *>(node)) {
|
||||||
|
aparam = viewer->get_audio_params();
|
||||||
|
}
|
||||||
|
ticket->setProperty("aparam", QVariant::fromValue(aparam));
|
||||||
ticket->setProperty("return", olive::RenderManager::k_frame);
|
ticket->setProperty("return", olive::RenderManager::k_frame);
|
||||||
ticket->setProperty("cache", QString());
|
ticket->setProperty("cache", QString());
|
||||||
ticket->setProperty("cachetimebase",
|
ticket->setProperty("cachetimebase",
|
||||||
|
|||||||
Reference in New Issue
Block a user