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
This commit is contained in:
2026-07-20 07:34:21 +08:00
parent c1ee784600
commit b9b5ad09f7
8 changed files with 994 additions and 42 deletions
+18 -3
View File
@@ -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)
+98 -21
View File
@@ -48,6 +48,7 @@
#include <string>
#include <vector>
#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 <mediafile>\n"
" Probe a media file: decoder, duration, video and audio streams.\n"
"\n"
" oak-cli transcode <input_media> <out_dir> [width]\n"
" oak-cli transcode <input_media> <out> [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. <out> 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) {
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(width_str, &end, 10);
if (end == width_str || *end != '\0' || parsed <= 0) {
fprintf(stderr, "error: invalid width \"%s\"\n", width_str);
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;
}
}
if (oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) !=
@@ -697,10 +731,12 @@ int cmd_transcode(const char *input, const char *out_dir,
break;
}
std::filesystem::create_directories(out_dir, ec);
if (format == "ppm") {
std::filesystem::create_directories(out, ec);
if (ec) {
fprintf(stderr, "error: cannot create output directory \"%s\": %s\n",
out_dir, ec.message().c_str());
fprintf(stderr,
"error: cannot create output directory \"%s\": %s\n",
out, ec.message().c_str());
rc = k_exit_error;
break;
}
@@ -715,13 +751,54 @@ int cmd_transcode(const char *input, const char *out_dir,
break;
}
rc = render_frames_to_ppm(renderer, out_dir, 0, total_ts);
rc = render_frames_to_ppm(renderer, out, 0, total_ts);
if (rc == k_exit_ok) {
rc = render_audio_to_wav(renderer, out_dir, 0, total_ts);
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_dir);
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]);
+44
View File
@@ -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 <http://www.gnu.org/licenses/>.
# Post-check for the oak_cli_transcode test: the MP4 exists and ffprobe
# reports an h264 video stream (invoked with -DOUT=<file>).
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})")
+27
View File
@@ -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 ()
+151
View File
@@ -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 <http://www.gnu.org/licenses/>.
***/
#ifndef OAKENGINE_EXPORTER_H
#define OAKENGINE_EXPORTER_H
#include <stdint.h>
#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 */
+2
View File
@@ -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
)
+359
View File
@@ -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 <http://www.gnu.org/licenses/>.
***/
#include "oakengine/exporter.h"
#include <atomic>
#include <cstdio>
#include <QByteArray>
#include <QCoreApplication>
#include <QFileInfo>
#include <QString>
#include <QThread>
#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<olive::Sequence *>(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<bool> 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"
+277
View File
@@ -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 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 <assert.h>
#include <math.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 <QCoreApplication>
#include <QDir>
#include <QFileInfo>
#include <QString>
#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<olive::Project *>(project);
auto *sequence = reinterpret_cast<olive::Sequence *>(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;
}