From b9b5ad09f7279ce6a1081eec91105574b2962d0c Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Mon, 20 Jul 2026 07:34:21 +0800 Subject: [PATCH] engine: add the export family to the C ABI facade; mp4 transcode - oakengine_export_render drives ExportTask synchronously (offline render + encode) with a progress callback, codec probing, and a thread-local error channel; exporter.h keeps clear of the visibility macro header - oak-cli transcode now defaults to mp4 (H.264/AAC) with --format ppm keeping the raw output path - two real concurrency bugs found by the facade's own test: ExportTask deadlocks when start()ed synchronously (queued conform handshake needs an event loop), and the progress callback must be captured by value because it fires on the task thread --- cli/CMakeLists.txt | 21 +- cli/main.cpp | 155 ++++++++--- cli/verify_transcode_mp4.cmake | 44 +++ engine/CMakeLists.txt | 27 ++ engine/include/oakengine/exporter.h | 151 +++++++++++ engine/src/capi/CMakeLists.txt | 2 + engine/src/capi/export.cpp | 359 +++++++++++++++++++++++++ engine/tests/oakengine_export_test.cpp | 277 +++++++++++++++++++ 8 files changed, 994 insertions(+), 42 deletions(-) create mode 100644 cli/verify_transcode_mp4.cmake create mode 100644 engine/include/oakengine/exporter.h create mode 100644 engine/src/capi/export.cpp create mode 100644 engine/tests/oakengine_export_test.cpp diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt index 91e317f98..866d6900c 100644 --- a/cli/CMakeLists.txt +++ b/cli/CMakeLists.txt @@ -91,14 +91,29 @@ if (BUILD_TESTS) 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). + # transcode smoke test: full "media in, renders out" round trip to a + # single H.264 MP4 (the default format); like render it exits 2 without + # a GL backend (skip). The verify step then checks the file with ffprobe. 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 + COMMAND oak-cli transcode ${CMAKE_SOURCE_DIR}/tests/demo.mp4 ${CMAKE_CURRENT_BINARY_DIR}/oak_cli_transcode.mp4 960 ) set_tests_properties(oak_cli_transcode PROPERTIES SKIP_RETURN_CODE 2 ) + add_test(NAME oak_cli_transcode_verify + COMMAND ${CMAKE_COMMAND} -DOUT=${CMAKE_CURRENT_BINARY_DIR}/oak_cli_transcode.mp4 -P ${CMAKE_CURRENT_SOURCE_DIR}/verify_transcode_mp4.cmake + ) + set_tests_properties(oak_cli_transcode_verify PROPERTIES + DEPENDS oak_cli_transcode + ) + + # Same round trip through the PPM+WAV path. + add_test(NAME oak_cli_transcode_ppm + COMMAND oak-cli transcode ${CMAKE_SOURCE_DIR}/tests/demo.mp4 ${CMAKE_CURRENT_BINARY_DIR}/oak_cli_transcode_ppm_out 960 --format ppm + ) + set_tests_properties(oak_cli_transcode_ppm 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 e295ef124..4384b0f0d 100644 --- a/cli/main.cpp +++ b/cli/main.cpp @@ -48,6 +48,7 @@ #include #include +#include "oakengine/exporter.h" #include "oakengine/footage.h" #include "oakengine/init.h" #include "oakengine/project.h" @@ -80,11 +81,14 @@ 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" + " oak-cli transcode [width] [--format ppm|mp4]\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" + " project, place it as clips, and render the whole duration.\n" + " Default output is a single H.264/AAC MP4 file (encoder default\n" + " bit rate); --format ppm renders PPM frames + a WAV instead.\n" + " [width] defaults to the source width; the height follows the\n" + " source aspect ratio. is the MP4 file path, or the\n" + " output directory with --format ppm.\n" "\n" " oak-cli --help\n" " Show this text.\n" @@ -562,21 +566,51 @@ int cmd_probe(const char *path) return rc; } +// Progress printer for mp4 transcodes: percentage steps of 5% on stderr. +static int g_transcode_progress_shown = -1; + +void transcode_progress(double fraction, void *userdata) +{ + (void)userdata; + const int percent = int(fraction * 100.0); + if (percent / 5 > g_transcode_progress_shown) { + g_transcode_progress_shown = percent / 5; + fprintf(stderr, "export progress: %d%%\n", percent); + } +} + // "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) +// project with the whole media placed as clips, and render it out -- as a +// single encoded MP4 by default, or as PPM frames + WAV with --format ppm. +int cmd_transcode(const char *input, const char *out, int argc, + char *argv[], int first_opt) { 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); + std::string format = "mp4"; + for (int i = first_opt; i < argc; i++) { + if (std::strcmp(argv[i], "--format") == 0) { + if (i + 1 >= argc) { + fprintf(stderr, "error: --format needs a value (ppm|mp4)\n"); + return k_exit_usage; + } + format = argv[++i]; + if (format != "ppm" && format != "mp4") { + fprintf(stderr, "error: unknown --format \"%s\" (ppm|mp4)\n", + format.c_str()); + return k_exit_usage; + } + } else if (width == 0) { + char *end = nullptr; + const long parsed = std::strtol(argv[i], &end, 10); + if (end == argv[i] || *end != '\0' || parsed <= 0) { + fprintf(stderr, "error: invalid width \"%s\"\n", argv[i]); + return k_exit_usage; + } + width = int(parsed); + } else { + fprintf(stderr, "error: unexpected argument \"%s\"\n", argv[i]); return k_exit_usage; } - width = int(parsed); } if (oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) != @@ -697,31 +731,74 @@ int cmd_transcode(const char *input, const char *out_dir, 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; - } + if (format == "ppm") { + std::filesystem::create_directories(out, ec); + if (ec) { + fprintf(stderr, + "error: cannot create output directory \"%s\": %s\n", + out, 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; - } + 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); + rc = render_frames_to_ppm(renderer, out, 0, total_ts); + if (rc == k_exit_ok) { + rc = render_audio_to_wav(renderer, out, 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); + } + } else { + // mp4: synchronous render + encode through the export facade. + // Bit rate is left to the encoder default (bit_rate 0), H.264 + // video + AAC audio at 48 kHz stereo. + const std::filesystem::path out_parent = + std::filesystem::path(out).parent_path(); + if (!out_parent.empty()) { + std::filesystem::create_directories(out_parent, ec); + } + if (ec) { + fprintf(stderr, + "error: cannot create output directory for \"%s\": %s\n", + out, ec.message().c_str()); + rc = k_exit_error; + break; + } + + oak_export_options opts; + memset(&opts, 0, sizeof(opts)); + opts.video_codec = OAKENGINE_EXPORT_VIDEO_H264; + opts.audio_codec = OAKENGINE_EXPORT_AUDIO_AAC; + opts.audio_sample_rate = 48000; + opts.audio_channel_count = 2; + oakengine_export_set_progress_callback(transcode_progress, NULL); + const int export_rc = oakengine_export_render( + seq, out, 0, clip_end_ts, width, height, &opts); + oakengine_export_set_progress_callback(NULL, NULL); + if (export_rc != OAKENGINE_OK) { + char err[1024]; + fprintf(stderr, "error: export failed: %s\n", + oakengine_export_last_error(err, sizeof(err)) > 0 ? + err : + "(no error)"); + rc = k_exit_render_unavailable; + break; + } + fprintf(stderr, "\n"); + printf("wrote %dx%d H.264/AAC MP4 to \"%s\"\n", width, height, + out); } } while (false); @@ -768,11 +845,11 @@ int main(int argc, char *argv[]) return cmd_probe(argv[2]); } if (command == "transcode") { - if (argc != 4 && argc != 5) { + if (argc < 4 || argc > 7) { print_usage(stderr); return k_exit_usage; } - return cmd_transcode(argv[2], argv[3], argc == 5 ? argv[4] : nullptr); + return cmd_transcode(argv[2], argv[3], argc, argv, 4); } fprintf(stderr, "error: unknown command \"%s\"\n", argv[1]); diff --git a/cli/verify_transcode_mp4.cmake b/cli/verify_transcode_mp4.cmake new file mode 100644 index 000000000..ef4e4ac7f --- /dev/null +++ b/cli/verify_transcode_mp4.cmake @@ -0,0 +1,44 @@ +# 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 . + +# Post-check for the oak_cli_transcode test: the MP4 exists and ffprobe +# reports an h264 video stream (invoked with -DOUT=). + +if (NOT EXISTS "${OUT}") + message(FATAL_ERROR "transcode output missing: ${OUT}") +endif () + +execute_process( + COMMAND ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height -of csv=p=0 "${OUT}" + OUTPUT_VARIABLE probe_out + RESULT_VARIABLE probe_rc + OUTPUT_STRIP_TRAILING_WHITESPACE +) +if (NOT probe_rc EQUAL 0) + message(FATAL_ERROR "ffprobe failed on ${OUT}") +endif () + +string(FIND "${probe_out}" "h264" h264_pos) +if (h264_pos EQUAL -1) + message(FATAL_ERROR "no h264 stream in ${OUT} (ffprobe: ${probe_out})") +endif () + +string(FIND "${probe_out}" "960,540" size_pos) +if (size_pos EQUAL -1) + message(FATAL_ERROR "unexpected dimensions in ${OUT} (ffprobe: ${probe_out})") +endif () + +message(STATUS "transcode mp4 verified: ${OUT} (${probe_out})") diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 4fa928541..17ec9dcde 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -274,4 +274,31 @@ if (BUILD_TESTS) target_compile_definitions(oakengine_timeline_edit_test PRIVATE OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) + + make_oakengine_test(oakengine_export_test) + # The export test builds sequence content through the engine C++ API and + # probes the dynamic render backend like oakengine_renderer_test does. + target_include_directories(oakengine_export_test PRIVATE + ${CMAKE_SOURCE_DIR}/third_party/openfx/include + ${CMAKE_SOURCE_DIR}/third_party/openfx/HostSupport/include + ${OLIVE_INCLUDE_DIRS} + ) + target_compile_definitions(oakengine_export_test PRIVATE + ${OLIVE_DEFINITIONS} + OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + ) + target_compile_options(oakengine_export_test PRIVATE + ${OLIVE_COMPILE_OPTIONS} + ) + if (OAK_ENABLE_DYNAMIC_RENDER_BACKEND) + target_compile_definitions(oakengine_export_test PRIVATE + OAK_ENABLE_DYNAMIC_RENDER_BACKEND) + add_dependencies(oakengine_export_test oakgl) + if (TARGET oakvulkan) + add_dependencies(oakengine_export_test oakvulkan) + endif () + endif () + if (TARGET olive-render-worker) + add_dependencies(oakengine_export_test olive-render-worker) + endif () endif () diff --git a/engine/include/oakengine/exporter.h b/engine/include/oakengine/exporter.h new file mode 100644 index 000000000..51bb4f7b5 --- /dev/null +++ b/engine/include/oakengine/exporter.h @@ -0,0 +1,151 @@ +/*** + + 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 . + +***/ + +#ifndef OAKENGINE_EXPORTER_H +#define OAKENGINE_EXPORTER_H + +#include + +#include "export.h" +#include "init.h" +#include "timeline.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file exporter.h + * @brief C ABI for synchronous offline export (render + encode) + * + * oakengine_export_render() renders a sequence range offline + * (RenderMode::k_online) and encodes it straight to a file, driving the + * engine's own export path (ExportTask over EncodingParams + the + * FFmpeg/OIIO encoders, engine/task/export/export.cpp) synchronously on the + * calling thread. The engine's task machinery itself has no UI dependency; + * the export dialog stays out of the picture by design. (Named exporter.h + * because oakengine/export.h already holds the symbol visibility macros.) + * + * Rendering requires OAKENGINE_INIT_RENDER (video frames go through the + * render worker pool and may need GL); codec probing + * (oakengine_export_has_video_codec()/_has_audio_codec()) does not. + * + * Conventions match the other facade families: 0 (OAKENGINE_OK) / negative + * OAKENGINE_E_* codes, buf/size strings, NULL handles are no-ops. Failures + * record a human-readable reason in the thread-local last-error string + * (oakengine_export_last_error()). + */ + +/** @brief Video codecs for oak_export_options::video_codec. */ +#define OAKENGINE_EXPORT_VIDEO_H264 0 /**< H.264 in an MP4 container. */ +#define OAKENGINE_EXPORT_VIDEO_H265 1 /**< H.265/HEVC in an MP4 container. */ +#define OAKENGINE_EXPORT_VIDEO_PNG_SEQUENCE 2 /**< PNG still-image sequence. */ + +/** @brief Audio codecs for oak_export_options::audio_codec. */ +#define OAKENGINE_EXPORT_AUDIO_AAC 0 +#define OAKENGINE_EXPORT_AUDIO_PCM 1 +/** @brief Disable the audio track entirely (not a codec). */ +#define OAKENGINE_EXPORT_AUDIO_NONE (-1) + +/** + * @brief POD export parameters. 0 (or negative) fields select the default + * documented per field. + */ +typedef struct oak_export_options { + /** OAKENGINE_EXPORT_VIDEO_* value; default H264. */ + int video_codec; + /** OAKENGINE_EXPORT_AUDIO_* value; default AAC; AUDIO_NONE disables. */ + int audio_codec; + /** Video bit rate in bit/s; <= 0 lets the encoder choose (FFmpeg + * defaults). */ + int64_t video_bit_rate; + /** Audio sample rate in Hz; <= 0 uses the sequence's rate. */ + int audio_sample_rate; + /** Audio channel count (1 = mono, 2 = stereo); <= 0 uses the + * sequence's layout. */ + int audio_channel_count; +} oak_export_options; + +/** + * @brief Render `seq`'s [in_ts, out_ts) range offline and encode it to + * `path`. + * + * `in_ts`/`out_ts` are frame timestamps in the sequence's frame-rate + * timebase (the export frame rate is the sequence frame rate). `width` and + * `height` <= 0 fall back to the sequence's video dimensions; when they + * differ, the frames are scaled to fit (EncodingParams::k_fit). Video is + * encoded with the options' codec (PNG sequence: `path` is the filename + * template -- a "-%04d" frame placeholder is inserted before the extension + * when absent), audio with the options' codec at the requested rate/layout, + * and color is transformed from the project's reference space to sRGB OETF + * (the application export dialog's default output). + * + * The call blocks until the export finishes. Progress is reported through + * the callback set with oakengine_export_set_progress_callback(). + * + * @return OAKENGINE_OK on success; OAKENGINE_E_INVALID for bad arguments; + * OAKENGINE_E_STATE when the engine lacks OAKENGINE_INIT_RENDER; + * OAKENGINE_E_FAILED for render/encode failures (see + * oakengine_export_last_error()). + */ +OAKENGINE_API int oakengine_export_render(OakEngineSequence *seq, + const char *path, int64_t in_ts, + int64_t out_ts, int width, + int height, + const oak_export_options *opts); + +/** + * @brief Human-readable reason for the last failed export on this thread + * (buf/size convention). + */ +OAKENGINE_API int oakengine_export_last_error(char *buf, int buf_size); + +/** + * @brief 1 if the OAKENGINE_EXPORT_VIDEO_* codec is encodable here, 0 + * otherwise (unknown codec ids included). + */ +OAKENGINE_API int oakengine_export_has_video_codec(int codec); + +/** + * @brief 1 if the OAKENGINE_EXPORT_AUDIO_* codec is encodable here, 0 + * otherwise (AUDIO_NONE and unknown ids included). + */ +OAKENGINE_API int oakengine_export_has_audio_codec(int codec); + +/** + * @brief Progress callback signature: `fraction` in [0, 1], monotonically + * non-decreasing during one export. + */ +typedef void (*oakengine_export_progress_fn)(double fraction, + void *userdata); + +/** + * @brief Install the progress callback used by subsequent + * oakengine_export_render() calls on this thread (NULL disables). + */ +OAKENGINE_API void +oakengine_export_set_progress_callback(oakengine_export_progress_fn fn, + void *userdata); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_EXPORTER_H */ diff --git a/engine/src/capi/CMakeLists.txt b/engine/src/capi/CMakeLists.txt index 6db761d91..d802d0a26 100644 --- a/engine/src/capi/CMakeLists.txt +++ b/engine/src/capi/CMakeLists.txt @@ -27,10 +27,12 @@ set(OLIVE_SOURCES include/oakengine/timeline.h include/oakengine/renderer.h include/oakengine/footage.h + include/oakengine/exporter.h src/capi/init.cpp src/capi/project.cpp src/capi/timeline.cpp src/capi/renderer.cpp src/capi/footage.cpp + src/capi/export.cpp PARENT_SCOPE ) diff --git a/engine/src/capi/export.cpp b/engine/src/capi/export.cpp new file mode 100644 index 000000000..37aa2fdcb --- /dev/null +++ b/engine/src/capi/export.cpp @@ -0,0 +1,359 @@ +/*** + + 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 . + +***/ + +#include "oakengine/exporter.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include "codec/encoder.h" +#include "codec/ffmpeg/ffmpegencoder.h" +#include "coreengine.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" +#include "render/rendermanager.h" +#include "task/export/export.h" + +namespace +{ + +// Last export error per thread. +thread_local QString g_last_error; + +// Installed progress callback (per thread), NULL when unset. +thread_local oakengine_export_progress_fn g_progress_fn = nullptr; +thread_local void *g_progress_userdata = nullptr; + +void set_error(const QString &error) +{ + g_last_error = error; +} + +// buf/size convention: returns the would-be length excluding the NUL. +int string_to_buf(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf = s.toUtf8(); + if (buf && buf_size > 0) { + snprintf(buf, size_t(buf_size), "%s", utf.constData()); + } + return int(utf.size()); +} + +// facade video codec -> (ExportCodec, container format); false when invalid. +bool map_video_codec(int codec, olive::ExportCodec::Codec *out_codec, + olive::ExportFormat::Format *out_format) +{ + switch (codec) { + case OAKENGINE_EXPORT_VIDEO_H264: + *out_codec = olive::ExportCodec::k_codec_h264; + *out_format = olive::ExportFormat::k_format_mpe_g4_video; + return true; + case OAKENGINE_EXPORT_VIDEO_H265: + *out_codec = olive::ExportCodec::k_codec_h265; + *out_format = olive::ExportFormat::k_format_mpe_g4_video; + return true; + case OAKENGINE_EXPORT_VIDEO_PNG_SEQUENCE: + *out_codec = olive::ExportCodec::k_codec_png; + *out_format = olive::ExportFormat::k_format_png; + return true; + default: + return false; + } +} + +bool map_audio_codec(int codec, olive::ExportCodec::Codec *out) +{ + switch (codec) { + case OAKENGINE_EXPORT_AUDIO_AAC: + *out = olive::ExportCodec::k_codec_aac; + return true; + case OAKENGINE_EXPORT_AUDIO_PCM: + *out = olive::ExportCodec::k_codec_pcm; + return true; + default: + return false; + } +} + +// Channel count -> ffmpeg-style layout mask (mono/stereo only). +bool layout_for_channels(int channels, uint64_t *layout) +{ + switch (channels) { + case 1: + *layout = 0x4; // AV_CH_LAYOUT_MONO + return true; + case 2: + *layout = 0x3; // AV_CH_LAYOUT_STEREO + return true; + default: + return false; + } +} + +// For PNG sequences: make sure the filename carries a frame placeholder +// ("-%04d"), inserting one before the extension when absent. +QString image_sequence_filename(const QString &path) +{ + if (olive::Encoder::filename_contains_digit_placeholder(path)) { + return path; + } + const QFileInfo fi(path); + return fi.dir().filePath(fi.completeBaseName() + QStringLiteral("-%04d.") + + fi.suffix()); +} + +} // namespace + +extern "C" +{ + +int oakengine_export_render(OakEngineSequence *seq, const char *path, + int64_t in_ts, int64_t out_ts, int width, + int height, const oak_export_options *opts) +{ + set_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + if (!sequence || !path || in_ts < 0 || out_ts <= in_ts) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + if (!olive::RenderManager::instance()) { + set_error(QStringLiteral("engine not initialized with " + "OAKENGINE_INIT_RENDER")); + return OAKENGINE_E_STATE; + } + + oak_export_options o = {}; + if (opts) { + o = *opts; + } + if (o.video_codec == 0 && o.audio_codec == 0 && o.video_bit_rate == 0 && + o.audio_sample_rate == 0 && o.audio_channel_count == 0 && !opts) { + // All defaults. + } + if (o.video_codec < 0) { + o.video_codec = OAKENGINE_EXPORT_VIDEO_H264; + } + if (o.audio_codec < 0 && o.audio_codec != OAKENGINE_EXPORT_AUDIO_NONE) { + o.audio_codec = OAKENGINE_EXPORT_AUDIO_AAC; + } + + olive::ExportCodec::Codec vcodec, acodec; + olive::ExportFormat::Format format; + if (!map_video_codec(o.video_codec, &vcodec, &format)) { + set_error(QStringLiteral("unknown video codec %1") + .arg(o.video_codec)); + return OAKENGINE_E_INVALID; + } + const bool audio_enabled = o.audio_codec != OAKENGINE_EXPORT_AUDIO_NONE; + if (audio_enabled && !map_audio_codec(o.audio_codec, &acodec)) { + set_error(QStringLiteral("unknown audio codec %1") + .arg(o.audio_codec)); + return OAKENGINE_E_INVALID; + } + + // Video parameters: the sequence's own, with dimensions/frame geometry + // overridden as requested. + olive::VideoParams vp = sequence->get_video_params(); + if (vp.frame_rate().isNull() || vp.frame_rate().isNaN()) { + set_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_INVALID; + } + if (width <= 0) { + width = vp.width(); + } + if (height <= 0) { + height = vp.height(); + } + if (width <= 0 || height <= 0) { + set_error(QStringLiteral("sequence has no valid video dimensions")); + return OAKENGINE_E_INVALID; + } + vp.set_width(width); + vp.set_height(height); + + // Audio parameters: the sequence's own, with rate/layout overridden. + olive::AudioParams ap = sequence->get_audio_params(); + if (audio_enabled) { + int sample_rate = o.audio_sample_rate > 0 ? o.audio_sample_rate : + ap.sample_rate(); + uint64_t layout = ap.channel_layout(); + if (o.audio_channel_count > 0) { + if (!layout_for_channels(o.audio_channel_count, &layout)) { + set_error(QStringLiteral("unsupported audio channel count %1 " + "(1 = mono, 2 = stereo)") + .arg(o.audio_channel_count)); + return OAKENGINE_E_INVALID; + } + } + if (sample_rate <= 0) { + set_error(QStringLiteral("sequence has no valid audio sample " + "rate")); + return OAKENGINE_E_INVALID; + } + ap = olive::AudioParams(sample_rate, layout, ap.format()); + } + + olive::Project *project = olive::Project::get_project_from_object(sequence); + if (!project) { + set_error(QStringLiteral("sequence is not part of a project")); + return OAKENGINE_E_INVALID; + } + + // Assemble the encoding parameters for the engine's export path. + const olive::Rational tb = vp.frame_rate().flipped(); + const olive::Rational in_time = + olive::core::Timecode::timestamp_to_time(in_ts, tb); + const olive::Rational out_time = + olive::core::Timecode::timestamp_to_time(out_ts, tb); + + olive::EncodingParams params; + params.set_format(format); + QString filename = QString::fromUtf8(path); + if (o.video_codec == OAKENGINE_EXPORT_VIDEO_PNG_SEQUENCE) { + params.set_video_is_image_sequence(true); + filename = image_sequence_filename(filename); + } + params.set_filename(filename); + params.enable_video(vp, vcodec); + if (audio_enabled) { + params.enable_audio(ap, acodec); + } + // The FFmpeg bridge rejects an empty pixel format ("Invalid video pixel + // format: -1"); like the export dialog, default to the codec's + // preferred pixel format (e.g. yuv420p for H.264). + if (format == olive::ExportFormat::k_format_mpe_g4_video) { + olive::FFmpegEncoder probe{ olive::EncodingParams() }; + const QStringList pix_fmts = probe.get_pixel_formats_for_codec(vcodec); + if (!pix_fmts.isEmpty()) { + params.set_video_pix_fmt(pix_fmts.first()); + } + } + if (o.video_bit_rate > 0) { + params.set_video_bit_rate(o.video_bit_rate); + } + params.set_custom_range(olive::TimeRange(in_time, out_time)); + params.set_export_length(out_time - in_time); + params.set_video_scaling_method(olive::EncodingParams::k_fit); + // Same default output transform as the application's export dialog. + params.set_color_transform( + olive::ColorTransform(QStringLiteral("sRGB OETF"))); + + try { + olive::ExportTask task(sequence, project->color_manager(), params); + // The progress signal is emitted on the task thread, so the callback + // (installed for the calling thread) is captured by value -- reading + // the thread_local on the task thread would see NULL. + const oakengine_export_progress_fn progress_fn = g_progress_fn; + void *const progress_userdata = g_progress_userdata; + if (progress_fn) { + QObject::connect(&task, &olive::ExportTask::progress_changed, + [progress_fn, progress_userdata](double fraction) { + progress_fn(fraction, progress_userdata); + }); + } + + // Drive the task the way the application does: the task runs on a + // worker thread while the calling thread keeps its event loop + // spinning. A bare synchronous start() deadlocks on audio exports: + // audio conforms are delivered to TaskManager via queued calls and + // ConformManager::conform_task_finished is queued back to THIS + // thread, which must therefore process events while waiting. + std::atomic done{ false }; + bool result = false; + QObject::connect(&task, &olive::ExportTask::finished, + [&done, &result](olive::Task *, bool r) { + result = r; + done.store(true); + }); + + QThread task_thread; + task.moveToThread(&task_thread); + task_thread.start(); + QMetaObject::invokeMethod(&task, "start", Qt::QueuedConnection); + while (!done.load()) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(5); + } + // Move the task back before tearing the thread down (QObjects must + // not be destroyed while owned by a dead thread). + task.moveToThread(QCoreApplication::instance()->thread()); + task_thread.quit(); + task_thread.wait(); + + if (!result) { + set_error(task.get_error().isEmpty() ? + QStringLiteral("export failed") : + task.get_error()); + return OAKENGINE_E_FAILED; + } + } catch (const std::exception &e) { + set_error(QStringLiteral("export failed: %1").arg(e.what())); + return OAKENGINE_E_FAILED; + } + + return OAKENGINE_OK; +} + +int oakengine_export_last_error(char *buf, int buf_size) +{ + return string_to_buf(g_last_error, buf, buf_size); +} + +int oakengine_export_has_video_codec(int codec) +{ + if (codec == OAKENGINE_EXPORT_VIDEO_PNG_SEQUENCE) { + // PNG sequences go through the statically linked OIIO encoder. + return 1; + } + olive::ExportCodec::Codec mapped; + olive::ExportFormat::Format format; + if (!map_video_codec(codec, &mapped, &format)) { + return 0; + } + olive::FFmpegEncoder encoder{ olive::EncodingParams() }; + return !encoder.get_pixel_formats_for_codec(mapped).isEmpty() ? 1 : 0; +} + +int oakengine_export_has_audio_codec(int codec) +{ + olive::ExportCodec::Codec mapped; + if (!map_audio_codec(codec, &mapped)) { + return 0; + } + olive::FFmpegEncoder encoder{ olive::EncodingParams() }; + return !encoder.get_sample_formats_for_codec(mapped).empty() ? 1 : 0; +} + +void oakengine_export_set_progress_callback(oakengine_export_progress_fn fn, + void *userdata) +{ + g_progress_fn = fn; + g_progress_userdata = userdata; +} + +} // extern "C" diff --git a/engine/tests/oakengine_export_test.cpp b/engine/tests/oakengine_export_test.cpp new file mode 100644 index 000000000..e70e4a08e --- /dev/null +++ b/engine/tests/oakengine_export_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 export facade. Codec probing and the +// argument/error paths require no GL; the actual export is GL-gated the same +// way as oakengine_renderer_test (dynamic backend probe + worker binary, +// SKIP with exit 0 when unavailable). The GL part builds a solid-color +// sequence through the engine C++ API (engine-internal test), exports one +// second of H.264 MP4 and validates it with ffprobe, and checks the +// progress callback. + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif + +#include +#include +#include +#include + +#include "config/config.h" +#include "node/generator/solid/solid.h" +#include "node/node.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" + +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND +#include "render/backend/dynamicrenderer.h" +#include "render/backend/renderbackend_c.h" +#endif + +#include "oakengine/exporter.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_export_test_%lu", base, + (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_export_test_XXXXXX"); + assert(mkdtemp(g_tmpdir) != NULL); +#endif +} + +// Same probe as tests/gtest/render_worker_footage_test.cpp. +static bool is_render_backend_available(const QString &backend) +{ +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND + olive::DynamicRenderer renderer(backend); + if (!renderer.load()) { + return false; + } + + OakRenderBackendInfo info = {}; + if (!renderer.get_backend_info(&info)) { + return false; + } + + if (backend == QStringLiteral("opengl") && + info.kind != oak_render_backend_opengl) { + return false; + } + + return renderer.init(); +#else + Q_UNUSED(backend) + return false; +#endif +} + +static bool worker_binary_exists() +{ + QDir dir(QCoreApplication::applicationDirPath()); + dir.cd(QStringLiteral("../worker")); +#if defined(_WIN32) + return QFileInfo::exists(dir.filePath(QStringLiteral("oak-render-worker.exe"))); +#else + return QFileInfo::exists(dir.filePath(QStringLiteral("oak-render-worker"))); +#endif +} + +// ---- Progress callback state ---------------------------------------------- +static int g_progress_calls = 0; +static double g_progress_last = -1.0; +static int g_progress_monotonic = 1; + +static void progress_cb(double fraction, void *userdata) +{ + (void)userdata; + g_progress_calls++; + if (fraction < g_progress_last - 1e-9) { + g_progress_monotonic = 0; + } + g_progress_last = fraction; +} + +// ---- No-GL part ------------------------------------------------------------- +static void test_codecs_and_validation(OakEngineSequence *seq) +{ + // Codec probing needs no RENDER bit and no GL. + assert(oakengine_export_has_video_codec(OAKENGINE_EXPORT_VIDEO_H264) == + 1); + assert(oakengine_export_has_video_codec(OAKENGINE_EXPORT_VIDEO_H265) == + 1); + assert(oakengine_export_has_video_codec( + OAKENGINE_EXPORT_VIDEO_PNG_SEQUENCE) == 1); + assert(oakengine_export_has_video_codec(-2) == 0); + assert(oakengine_export_has_video_codec(99) == 0); + assert(oakengine_export_has_audio_codec(OAKENGINE_EXPORT_AUDIO_AAC) == 1); + assert(oakengine_export_has_audio_codec(OAKENGINE_EXPORT_AUDIO_PCM) == 1); + assert(oakengine_export_has_audio_codec( + OAKENGINE_EXPORT_AUDIO_NONE) == 0); + assert(oakengine_export_has_audio_codec(99) == 0); + + // Argument validation (engine has no RENDER bit yet either). + char path[4096]; + snprintf(path, sizeof(path), "%s/out.mp4", g_tmpdir); + assert(oakengine_export_render(NULL, path, 0, 30, 320, 180, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_export_render(seq, NULL, 0, 30, 320, 180, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_export_render(seq, path, -1, 30, 320, 180, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_export_render(seq, path, 30, 30, 320, 180, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_export_render(seq, path, 40, 30, 320, 180, NULL) == + OAKENGINE_E_INVALID); + + // Without OAKENGINE_INIT_RENDER the export is refused with E_STATE. + assert(oakengine_export_render(seq, path, 0, 30, 320, 180, NULL) == + OAKENGINE_E_STATE); + char err[256]; + assert(oakengine_export_last_error(err, sizeof(err)) > 0); +} + +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, "Export"); + assert(seq != NULL); + + test_codecs_and_validation(seq); + + // ---- GL-gated part ------------------------------------------------------ + if (!is_render_backend_available(QStringLiteral("opengl"))) { + printf("oakengine_export_test: SKIP: OpenGL render backend not " + "available, export assertions skipped\n"); + oakengine_project_free(project); + oakengine_shutdown(); + return 0; + } + if (!worker_binary_exists()) { + printf("oakengine_export_test: SKIP: oak-render-worker binary not " + "found, export assertions skipped\n"); + oakengine_project_free(project); + oakengine_shutdown(); + return 0; + } + + olive::Config::current()[QStringLiteral("GraphicsBackend")] = + QStringLiteral("opengl"); + assert(oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) == + OAKENGINE_OK); + + // Solid red generator -> texture input (engine C++ API, internal test). + auto *proj = reinterpret_cast(project); + auto *sequence = reinterpret_cast(seq); + auto *solid = new olive::SolidGenerator(); + solid->setParent(proj); + olive::Node::connect_edge( + solid, olive::NodeInput(sequence, olive::ViewerOutput::k_texture_input)); + + // Export one second (30 frames at the default 30000/1001) of H.264. + char out[4096]; + snprintf(out, sizeof(out), "%s/export.mp4", g_tmpdir); + oak_export_options opts; + memset(&opts, 0, sizeof(opts)); + opts.video_codec = OAKENGINE_EXPORT_VIDEO_H264; + opts.audio_codec = OAKENGINE_EXPORT_AUDIO_NONE; // no audio content here + + oakengine_export_set_progress_callback(progress_cb, NULL); + char err[512]; + int rc = oakengine_export_render(seq, out, 0, 30, 320, 180, &opts); + if (rc != OAKENGINE_OK) { + fprintf(stderr, "export failed (%d): %s\n", rc, + oakengine_export_last_error(err, sizeof(err)) > 0 ? + err : + "(no error)"); + } + assert(rc == OAKENGINE_OK); + oakengine_export_set_progress_callback(NULL, NULL); + + // Progress was reported, monotonically, and completed. + assert(g_progress_calls > 0); + assert(g_progress_monotonic == 1); + assert(fabs(g_progress_last - 1.0) < 1e-6); + + // Validate the MP4 with ffprobe: h264 video, 320x180, ~1 second. + assert(access(out, F_OK) == 0); + char cmd[4608]; + snprintf(cmd, sizeof(cmd), + "ffprobe -v error -select_streams v:0 -show_entries " + "stream=codec_name,width,height,duration -of csv=p=0 \"%s\"", + out); + FILE *probe = popen(cmd, "r"); + assert(probe != NULL); + char probe_out[512] = { 0 }; + const size_t probe_len = fread(probe_out, 1, sizeof(probe_out) - 1, probe); + (void)probe_len; + assert(pclose(probe) == 0); + assert(strstr(probe_out, "h264") != NULL); + assert(strstr(probe_out, "320,180") != NULL); + // Duration is the last csv field; 30 frames at 30000/1001 ~= 1.001 s. + const char *comma = strrchr(probe_out, ','); + assert(comma != NULL); + const double duration = atof(comma + 1); + assert(fabs(duration - 1.001) < 0.15); + + oakengine_project_free(project); + assert(oakengine_shutdown() == OAKENGINE_OK); + + printf("oakengine_export_test: all assertions passed\n"); + return 0; +}