diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt index 4db1d0634..91e317f98 100644 --- a/cli/CMakeLists.txt +++ b/cli/CMakeLists.txt @@ -90,6 +90,15 @@ if (BUILD_TESTS) set_tests_properties(oak_cli_probe PROPERTIES 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. if (TARGET olive-render-worker) add_dependencies(oak-cli olive-render-worker) diff --git a/cli/main.cpp b/cli/main.cpp index 1451d68cb..e295ef124 100644 --- a/cli/main.cpp +++ b/cli/main.cpp @@ -80,6 +80,12 @@ void print_usage(FILE *out) " oak-cli probe \n" " Probe a media file: decoder, duration, video and audio streams.\n" "\n" + " oak-cli transcode [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" " Show this text.\n" "\n" @@ -307,6 +313,64 @@ int renderer_fail(OakEngineRenderer *renderer, const char *what) 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, const char *out_dir) { @@ -407,49 +471,10 @@ int cmd_render(const char *path, const char *start_str, const char *end_str, break; } - 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; - } - } + rc = render_frames_to_ppm(renderer, out_dir, start_ts, end_ts); if (rc == k_exit_ok) { - OakEngineAudioBuffer *audio = oakengine_renderer_render_audio( - 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); - } + rc = render_audio_to_wav(renderer, out_dir, start_ts, frame_count); } if (rc == k_exit_ok) { @@ -537,6 +562,176 @@ int cmd_probe(const char *path) 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 int main(int argc, char *argv[]) @@ -572,6 +767,13 @@ int main(int argc, char *argv[]) } 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]); print_usage(stderr); diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 0b8b73d6e..4fa928541 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -269,4 +269,9 @@ if (BUILD_TESTS) target_compile_definitions(oakengine_footage_test PRIVATE 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 () diff --git a/engine/include/oakengine/timeline.h b/engine/include/oakengine/timeline.h index 9dfc0b8ae..e7948c1da 100644 --- a/engine/include/oakengine/timeline.h +++ b/engine/include/oakengine/timeline.h @@ -24,6 +24,7 @@ #include #include "export.h" +#include "footage.h" #include "init.h" #include "project.h" @@ -186,6 +187,97 @@ OAKENGINE_API int oakengine_sequence_marker_at(const OakEngineSequence *self, int index, int64_t *time, 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 } #endif diff --git a/engine/src/capi/footage.cpp b/engine/src/capi/footage.cpp index 1ef46e197..37581be03 100644 --- a/engine/src/capi/footage.cpp +++ b/engine/src/capi/footage.cpp @@ -126,6 +126,19 @@ QString filename_of(const OakEngineFootageState *s) } // 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" { diff --git a/engine/src/capi/timeline.cpp b/engine/src/capi/timeline.cpp index c24016293..8e9b87bef 100644 --- a/engine/src/capi/timeline.cpp +++ b/engine/src/capi/timeline.cpp @@ -27,15 +27,23 @@ #include #include "coreengine.h" +#include "node/block/clip/clip.h" #include "node/nodeundo.h" #include "node/project.h" #include "node/project/folder/folder.h" #include "node/project/sequence/sequence.h" #include "timeline/timelinemarker.h" +#include "timeline/timelineundogeneral.h" +#include "timeline/timelineundopointer.h" #include "timeline/timelineworkarea.h" #include "undo/undocommand.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 { @@ -102,6 +110,33 @@ int64_t time_to_ts(const olive::Rational &time, const olive::Rational &tb) 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(c); +} + } // namespace extern "C" @@ -119,6 +154,30 @@ OakEngineSequence *oakengine_sequence_new(OakEngineProject *project, sequence->set_default_parameters(); 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(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" // action (app/core.cpp), minus opening a viewer. Without an EngineCore // (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; } +/* ---- 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(seq); + auto *footage_node = + static_cast(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(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(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(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" diff --git a/engine/tests/oakengine_timeline_edit_test.cpp b/engine/tests/oakengine_timeline_edit_test.cpp new file mode 100644 index 000000000..18a4b6e92 --- /dev/null +++ b/engine/tests/oakengine_timeline_edit_test.cpp @@ -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 . + +***/ + +// 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 +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#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; +} diff --git a/worker/workermain.cpp b/worker/workermain.cpp index adf61bd08..fa1e74876 100644 --- a/worker/workermain.cpp +++ b/worker/workermain.cpp @@ -504,8 +504,18 @@ private: QVariant::fromValue(color_output)); } ticket->setProperty("vparam", QVariant::fromValue(vparams)); - ticket->setProperty("aparam", - QVariant::fromValue(olive::AudioParams())); + // The IPC render_frame message carries no audio parameters, but + // 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(node)) { + aparam = viewer->get_audio_params(); + } + ticket->setProperty("aparam", QVariant::fromValue(aparam)); ticket->setProperty("return", olive::RenderManager::k_frame); ticket->setProperty("cache", QString()); ticket->setProperty("cachetimebase",