export: dialog execution path now goes through the facade

- oakengine_export_render_ex covers the dialog's entire option surface
  (formats, codecs, pix fmts, audio params, ranges incl. still frame,
  subtitles, scaling, threads, custom OCIO color transform names,
  per-codec key/value options) - zero feature reduction
- the dialog's Start now creates a FacadeExportTask that drives
  oakengine_export_render_ex instead of constructing ExportTask in the
  UI; progress flows through the facade callback and cancel through
  oakengine_export_cancel (OAKENGINE_E_CANCELLED preserves the
  keep-dialog-open semantics)
- two real fixes: audio sample format 0 no longer means an
  AAC-unsupported u8_p (default is f32_p), and image-sequence exports
  use the engine's real [#####] placeholder instead of a made-up -%04d
This commit is contained in:
2026-07-20 11:06:46 +08:00
parent 1ea6527b80
commit 62f18125cb
5 changed files with 821 additions and 65 deletions
+156
View File
@@ -100,6 +100,10 @@ typedef struct oak_export_options {
* The call blocks until the export finishes. Progress is reported through
* the callback set with oakengine_export_set_progress_callback().
*
* Image sequences: `path` is the filename template; the engine's frame
* placeholder ("_[#####]") is inserted before the extension when absent
* (same bracketed-hash form the application uses).
*
* @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
@@ -144,6 +148,158 @@ OAKENGINE_API void
oakengine_export_set_progress_callback(oakengine_export_progress_fn fn,
void *userdata);
/**
* @brief Cancel the currently running oakengine_export_render()/_ex()
* call, if any (from another thread).
*
* The export aborts at the next opportunity and the blocked render call
* returns OAKENGINE_E_CANCELLED. A no-op when no export is running.
*/
OAKENGINE_API void oakengine_export_cancel(void);
/**
* @brief Cancellation return code of oakengine_export_render()/_ex().
*/
#define OAKENGINE_E_CANCELLED (-5)
/* ---- Extended export (oakengine_export_render_ex) -------------------------
*
* The extended entry point covers every field of the engine's
* EncodingParams (engine/codec/encoder.h) through the POD below, so
* full-featured consumers (like the application's export dialog) can drive
* the same export path without engine headers. Enum int fields carry the
* engine's own enum values (olive::ExportFormat::Format,
* olive::ExportCodec::Codec, olive::VideoParams::Interlacing/ColorRange,
* olive::core::SampleFormat::Format) unless documented otherwise; <= 0
* (or -1 where noted) selects the documented default.
*/
/** @brief range_mode values: what part of the sequence to export. */
#define OAKENGINE_EXPORT_RANGE_ENTIRE 0 /**< Whole sequence. */
#define OAKENGINE_EXPORT_RANGE_CUSTOM 1 /**< [range_in_ts, range_out_ts). */
#define OAKENGINE_EXPORT_RANGE_STILL 2 /**< Single frame at still_time_ts. */
/** @brief color_transform values: output color transform. */
#define OAKENGINE_EXPORT_COLOR_SRGB_OETF 0 /**< sRGB OETF (dialog default). */
#define OAKENGINE_EXPORT_COLOR_REC709_OETF 1 /**< Rec.709 OETF. */
#define OAKENGINE_EXPORT_COLOR_REFERENCE 2 /**< Reference space, no transform. */
#define OAKENGINE_EXPORT_COLOR_BT1886_EOTF 3 /**< BT.1886 EOTF. */
#define OAKENGINE_EXPORT_COLOR_CUSTOM (-1) /**< Use color_transform_name. */
/**
* @brief Full export parameters (POD; covers EncodingParams).
*/
typedef struct oak_export_options_ex {
/** OAKENGINE_EXPORT_RANGE_*; default ENTIRE. */
int range_mode;
/** range_mode == CUSTOM: range [in, out) as frame timestamps in the
* sequence's frame-rate timebase. */
int64_t range_in_ts;
int64_t range_out_ts;
/** range_mode == STILL: the frame to export as a timestamp. */
int64_t still_time_ts;
/** Container format as olive::ExportFormat::Format
* (0 = MP4, 5 = PNG sequence, others per engine/codec/exportformat.h). */
int format;
int video_enabled; /**< 0/1; default 1. */
/** Video codec as olive::ExportCodec::Codec (1 = H.264, 3 = H.265,
* 5 = PNG, others per engine/codec/exportcodec.h). */
int video_codec;
int audio_enabled; /**< 0/1; default 1. */
/** Audio codec as olive::ExportCodec::Codec (11 = AAC, 12 = PCM,
* others per engine/codec/exportcodec.h). */
int audio_codec;
int subtitles_enabled; /**< 0/1; default 0. */
int subtitles_sidecar; /**< 0 = embedded, 1 = sidecar file; default 0. */
/** Sidecar container as olive::ExportFormat::Format (13 = SRT). */
int subtitles_format;
/** Subtitle codec as olive::ExportCodec::Codec (16 = SRT). */
int subtitles_codec;
/** Video bit rate in bit/s; <= 0 lets the encoder choose. */
int64_t video_bit_rate;
/** Audio bit rate in bit/s; <= 0 lets the encoder choose. */
int64_t audio_bit_rate;
/** Encoded pixel format as an index into
* FFmpegEncoder::get_pixel_formats_for_codec(video_codec)
* (0 = the codec's preferred format, e.g. yuv420p for H.264;
* -1 = same as 0). Out-of-range indexes fail with
* OAKENGINE_E_INVALID. */
int video_pix_fmt;
/** Audio sample rate in Hz; <= 0 uses the sequence's rate. */
int audio_sample_rate;
/** Audio channel layout mask (0 = the sequence's layout). */
uint64_t audio_channel_layout;
/** Audio sample format: <= 0 selects the engine's float planar default
* (f32_p); >= 1 is an explicit olive::core::SampleFormat::Format value
* in engine units (s16_p = 1, s32_p = 2, s64_p = 3, f32_p = 4,
* f64_p = 5, u8 = 6, s16 = 7, s32 = 8, s64 = 9, f32 = 10, f64 = 11).
* u8_p (raw 0) overlaps the default and is not expressible. */
int audio_sample_format;
/** OAKENGINE_EXPORT_COLOR_*; default SRGB_OETF. */
int color_transform;
/** OCIO color space name, used when color_transform ==
* OAKENGINE_EXPORT_COLOR_CUSTOM. Empty otherwise. */
char color_transform_name[64];
/** Output dimensions; <= 0 uses the sequence's dimensions. */
int video_width;
int video_height;
/** Output frame rate; <= 0 uses the sequence's frame rate. */
int frame_rate_num;
int frame_rate_den;
/** Pixel aspect ratio; <= 0 uses the sequence's ratio. */
int pixel_aspect_num;
int pixel_aspect_den;
/** olive::VideoParams::Interlacing; -1 = the sequence's mode. */
int interlacing;
/** Render pixel format as olive::core::PixelFormat::Format;
* -1 = the OfflinePixelFormat config default. */
int pixel_format;
/** Scaling method (EncodingParams::VideoScalingMethod):
* -1 = fit (default), 0 = fit, 1 = stretch, 2 = crop. */
int scaling_method;
/** olive::VideoParams::ColorRange; -1 = limited (default). */
int color_range;
/** Encoder thread count; <= 0 = auto. */
int video_threads;
/** 1 = write an image sequence (still-image codecs); default 0. */
int is_image_sequence;
} oak_export_options_ex;
/**
* @brief Extended synchronous export with full EncodingParams coverage.
*
* Assembles the engine's EncodingParams from `opts` and runs the same
* synchronous ExportTask path as oakengine_export_render() (progress
* callback, conform prewarm, event-loop drive). PNG sequences accept a
* filename template ("-%04d" is inserted before the extension when
* absent), same as the simple entry point.
*
* @return OAKENGINE_OK on success; OAKENGINE_E_INVALID for bad options;
* OAKENGINE_E_STATE without OAKENGINE_INIT_RENDER; OAKENGINE_E_FAILED for
* render/encode failures; OAKENGINE_E_CANCELLED after
* oakengine_export_cancel().
*/
OAKENGINE_API int oakengine_export_render_ex(OakEngineSequence *seq,
const char *path,
const oak_export_options_ex *opts);
/**
* @brief Set an encoder-specific video option (key/value strings, e.g.
* "crf" = "18") applied by subsequent oakengine_export_render()/_ex()
* calls on this thread (mirrors EncodingParams::set_video_option()).
* Repeated calls accumulate; NULL value clears all options.
*/
OAKENGINE_API void oakengine_export_set_video_option(const char *key,
const char *value);
#ifdef __cplusplus
}
#endif
+362 -62
View File
@@ -51,6 +51,14 @@ thread_local QString g_last_error;
thread_local oakengine_export_progress_fn g_progress_fn = nullptr;
thread_local void *g_progress_userdata = nullptr;
// Encoder-specific video options (per thread), applied to the next
// assembled EncodingParams.
thread_local QHash<QString, QString> g_video_options;
// The export currently driven by oakengine_export_render()/_ex(), for
// cross-thread cancellation (one at a time per process).
std::atomic<olive::ExportTask *> g_current_export{ nullptr };
void set_error(const QString &error)
{
g_last_error = error;
@@ -117,16 +125,17 @@ bool layout_for_channels(int channels, uint64_t *layout)
}
}
// For PNG sequences: make sure the filename carries a frame placeholder
// ("-%04d"), inserting one before the extension when absent.
// For image sequences: make sure the filename carries the engine's frame
// placeholder ("[#####]", the same bracketed-hash form the application
// uses), 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());
return fi.dir().filePath(fi.completeBaseName() +
QStringLiteral("_[#####].") + fi.suffix());
}
// Pre-generate audio conforms for every footage with audio streams in the
@@ -195,6 +204,287 @@ bool prewarm_conforms(olive::Project *project,
return true;
}
// Drive a fully-assembled EncodingParams through the ExportTask on a
// worker thread while this thread pumps events (see the comment in the
// body). Shared by oakengine_export_render() and _render_ex().
int render_internal(olive::Sequence *sequence, olive::Project *project,
olive::EncodingParams &params, bool prewarm_audio,
const olive::AudioParams &prewarm_params)
{
try {
// Pre-generate the audio conforms the render is about to need (the
// headless equivalent of the application's preview-warmed cache).
// A timeout here is not fatal: the render's own conform wait is the
// fallback path.
if (prewarm_audio) {
QString prewarm_error;
prewarm_conforms(project, prewarm_params, &prewarm_error);
}
olive::ExportTask task(sequence, project->color_manager(), params);
g_current_export.store(&task);
// 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();
g_current_export.store(nullptr);
if (task.is_cancelled()) {
set_error(QStringLiteral("export cancelled"));
return OAKENGINE_E_CANCELLED;
}
if (!result) {
set_error(task.get_error().isEmpty() ?
QStringLiteral("export failed") :
task.get_error());
return OAKENGINE_E_FAILED;
}
} catch (const std::exception &e) {
g_current_export.store(nullptr);
set_error(QStringLiteral("export failed: %1").arg(e.what()));
return OAKENGINE_E_FAILED;
}
return OAKENGINE_OK;
}
// Assemble EncodingParams from the extended POD. Returns an error string
// on invalid options.
QString params_from_ex(const oak_export_options_ex &o,
olive::Sequence *sequence, const char *path,
olive::EncodingParams *params)
{
if (o.format < 0 || o.format >= olive::ExportFormat::k_format_count) {
return QStringLiteral("unknown export format %1").arg(o.format);
}
if (o.video_codec < 0 || o.video_codec >= olive::ExportCodec::k_codec_count) {
return QStringLiteral("unknown video codec %1").arg(o.video_codec);
}
if (o.audio_codec < 0 || o.audio_codec >= olive::ExportCodec::k_codec_count) {
return QStringLiteral("unknown audio codec %1").arg(o.audio_codec);
}
if (o.subtitles_enabled &&
(o.subtitles_codec < 0 ||
o.subtitles_codec >= olive::ExportCodec::k_codec_count)) {
return QStringLiteral("unknown subtitle codec %1")
.arg(o.subtitles_codec);
}
const olive::ExportCodec::Codec vcodec =
static_cast<olive::ExportCodec::Codec>(o.video_codec);
const olive::ExportCodec::Codec acodec =
static_cast<olive::ExportCodec::Codec>(o.audio_codec);
const olive::ExportFormat::Format format =
static_cast<olive::ExportFormat::Format>(o.format);
// Video params: sequence's own, with overrides applied.
olive::VideoParams vp = sequence->get_video_params();
if (o.video_width > 0) {
vp.set_width(o.video_width);
}
if (o.video_height > 0) {
vp.set_height(o.video_height);
}
if (o.frame_rate_num > 0 && o.frame_rate_den > 0) {
vp.set_time_base(olive::Rational(o.frame_rate_den, o.frame_rate_num));
}
if (o.pixel_aspect_num > 0 && o.pixel_aspect_den > 0) {
vp.set_pixel_aspect_ratio(
olive::Rational(o.pixel_aspect_num, o.pixel_aspect_den));
}
if (o.interlacing >= 0) {
vp.set_interlacing(
static_cast<olive::VideoParams::Interlacing>(o.interlacing));
}
if (o.color_range >= 0) {
vp.set_color_range(
static_cast<olive::VideoParams::ColorRange>(o.color_range));
}
if (o.pixel_format >= 0) {
vp.set_format(
static_cast<olive::PixelFormat::Format>(o.pixel_format));
}
if (vp.frame_rate().isNull() || vp.frame_rate().isNaN()) {
return QStringLiteral("sequence has no valid frame rate");
}
if (vp.width() <= 0 || vp.height() <= 0) {
return QStringLiteral("sequence has no valid video dimensions");
}
params->set_format(format);
QString filename = QString::fromUtf8(path);
if (o.is_image_sequence) {
params->set_video_is_image_sequence(true);
filename = image_sequence_filename(filename);
}
params->set_filename(filename);
if (o.video_enabled) {
params->enable_video(vp, vcodec);
if (o.video_bit_rate > 0) {
params->set_video_bit_rate(o.video_bit_rate);
}
if (o.video_threads > 0) {
params->set_video_threads(o.video_threads);
}
// Encoded pixel format by index into the codec's supported list.
if (o.video_pix_fmt >= 0) {
olive::FFmpegEncoder probe{ olive::EncodingParams() };
const QStringList pix_fmts =
probe.get_pixel_formats_for_codec(vcodec);
if (o.video_pix_fmt >= pix_fmts.size()) {
return QStringLiteral(
"pixel format index %1 out of range for codec %2")
.arg(o.video_pix_fmt)
.arg(o.video_codec);
}
if (!pix_fmts.isEmpty()) {
params->set_video_pix_fmt(
pix_fmts.at(qMax(0, o.video_pix_fmt)));
}
}
}
if (o.audio_enabled) {
olive::AudioParams ap = sequence->get_audio_params();
const int sample_rate =
o.audio_sample_rate > 0 ? o.audio_sample_rate : ap.sample_rate();
const uint64_t layout = o.audio_channel_layout != 0 ?
o.audio_channel_layout :
ap.channel_layout();
const olive::core::SampleFormat::Format sample_format =
o.audio_sample_format > 0 ?
static_cast<olive::core::SampleFormat::Format>(
o.audio_sample_format) :
olive::core::SampleFormat::f32_p;
if (sample_rate <= 0) {
return QStringLiteral("sequence has no valid audio sample rate");
}
ap = olive::AudioParams(sample_rate, layout, sample_format);
params->enable_audio(ap, acodec);
if (o.audio_bit_rate > 0) {
params->set_audio_bit_rate(o.audio_bit_rate);
}
}
if (o.subtitles_enabled) {
if (o.subtitles_sidecar) {
params->enable_sidecar_subtitles(
static_cast<olive::ExportFormat::Format>(o.subtitles_format),
static_cast<olive::ExportCodec::Codec>(o.subtitles_codec));
} else {
params->enable_subtitles(
static_cast<olive::ExportCodec::Codec>(o.subtitles_codec));
}
}
// Range.
const olive::Rational tb = vp.frame_rate().flipped();
switch (o.range_mode) {
case OAKENGINE_EXPORT_RANGE_CUSTOM: {
if (o.range_in_ts < 0 || o.range_out_ts <= o.range_in_ts) {
return QStringLiteral("invalid custom range");
}
const olive::Rational in_time =
olive::core::Timecode::timestamp_to_time(o.range_in_ts, tb);
const olive::Rational out_time =
olive::core::Timecode::timestamp_to_time(o.range_out_ts, tb);
params->set_custom_range(olive::TimeRange(in_time, out_time));
params->set_export_length(out_time - in_time);
break;
}
case OAKENGINE_EXPORT_RANGE_STILL: {
if (o.still_time_ts < 0) {
return QStringLiteral("invalid still time");
}
const olive::Rational t =
olive::core::Timecode::timestamp_to_time(o.still_time_ts, tb);
params->set_custom_range(olive::TimeRange(t, t + tb));
params->set_export_length(tb);
break;
}
default:
params->set_export_length(sequence->get_length());
break;
}
// Scaling and color.
if (o.scaling_method >= 0) {
params->set_video_scaling_method(
static_cast<olive::EncodingParams::VideoScalingMethod>(
o.scaling_method));
}
switch (o.color_transform) {
case OAKENGINE_EXPORT_COLOR_REC709_OETF:
params->set_color_transform(
olive::ColorTransform(QStringLiteral("Rec.709 OETF")));
break;
case OAKENGINE_EXPORT_COLOR_REFERENCE:
params->set_color_transform(olive::ColorTransform());
break;
case OAKENGINE_EXPORT_COLOR_BT1886_EOTF:
params->set_color_transform(
olive::ColorTransform(QStringLiteral("BT.1886 EOTF")));
break;
case OAKENGINE_EXPORT_COLOR_CUSTOM:
if (o.color_transform_name[0] == '\0') {
return QStringLiteral(
"color_transform is CUSTOM but color_transform_name is empty");
}
params->set_color_transform(olive::ColorTransform(
QString::fromUtf8(o.color_transform_name)));
break;
default:
// Same default output transform as the application's export dialog.
params->set_color_transform(
olive::ColorTransform(QStringLiteral("sRGB OETF")));
break;
}
// Encoder-specific video options accumulated for this thread.
for (auto it = g_video_options.cbegin(); it != g_video_options.cend();
++it) {
params->set_video_option(it.key(), it.value());
}
return QString();
}
} // namespace
extern "C"
@@ -333,68 +623,11 @@ int oakengine_export_render(OakEngineSequence *seq, const char *path,
olive::ColorTransform(QStringLiteral("sRGB OETF")));
try {
// Pre-generate the audio conforms the render is about to need (the
// headless equivalent of the application's preview-warmed cache).
// A timeout here is not fatal: the render's own conform wait is the
// fallback path.
if (audio_enabled) {
QString prewarm_error;
prewarm_conforms(project, ap, &prewarm_error);
}
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;
}
return render_internal(sequence, project, params, audio_enabled, ap);
} 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)
@@ -402,6 +635,73 @@ int oakengine_export_last_error(char *buf, int buf_size)
return string_to_buf(g_last_error, buf, buf_size);
}
int oakengine_export_render_ex(OakEngineSequence *seq, const char *path,
const oak_export_options_ex *opts)
{
set_error(QString());
olive::Sequence *sequence = reinterpret_cast<olive::Sequence *>(seq);
if (!sequence || !path || !opts) {
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;
}
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;
}
oak_export_options_ex o = *opts;
if (o.video_enabled == 0 && o.audio_enabled == 0 &&
o.subtitles_enabled == 0) {
// Preserve the simple entry point's defaults: video+audio enabled.
o.video_enabled = 1;
o.audio_enabled = 1;
}
olive::EncodingParams params;
const QString error = params_from_ex(o, sequence, path, &params);
if (!error.isEmpty()) {
set_error(error);
return OAKENGINE_E_INVALID;
}
try {
const bool prewarm_audio = o.audio_enabled != 0;
return render_internal(sequence, project, params, prewarm_audio,
params.audio_enabled() ?
params.audio_params() :
sequence->get_audio_params());
} catch (const std::exception &e) {
set_error(QStringLiteral("export failed: %1").arg(e.what()));
return OAKENGINE_E_FAILED;
}
}
void oakengine_export_cancel(void)
{
if (olive::ExportTask *task = g_current_export.load()) {
task->cancel();
}
}
void oakengine_export_set_video_option(const char *key, const char *value)
{
if (!key) {
return;
}
if (value) {
g_video_options.insert(QString::fromUtf8(key),
QString::fromUtf8(value));
} else {
g_video_options.clear();
}
}
int oakengine_export_has_video_codec(int codec)
{
if (codec == OAKENGINE_EXPORT_VIDEO_PNG_SEQUENCE) {
+140
View File
@@ -39,6 +39,9 @@
#include <unistd.h>
#endif
#include <chrono>
#include <thread>
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
@@ -365,6 +368,143 @@ int main(void)
oakengine_footage_free(tone);
}
// ---- render_ex option validation (no GL needed) -------------------------
{
oak_export_options_ex bad;
memset(&bad, 0, sizeof(bad));
bad.format = 2; // mp4
bad.video_enabled = 1;
bad.video_codec = 1; // h264
bad.audio_enabled = 0;
char out_bad[4096];
snprintf(out_bad, sizeof(out_bad), "%s/bad.mp4", g_tmpdir);
bad.format = -1;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.format = 99;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.format = 2;
bad.video_codec = 99;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.video_codec = 1;
bad.range_mode = OAKENGINE_EXPORT_RANGE_CUSTOM;
bad.range_in_ts = 30;
bad.range_out_ts = 30;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.range_mode = OAKENGINE_EXPORT_RANGE_STILL;
bad.still_time_ts = -1;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.range_mode = OAKENGINE_EXPORT_RANGE_ENTIRE;
bad.color_transform = OAKENGINE_EXPORT_COLOR_CUSTOM;
bad.color_transform_name[0] = '\0';
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.color_transform = OAKENGINE_EXPORT_COLOR_SRGB_OETF;
bad.video_pix_fmt = 99;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render_ex(NULL, out_bad, &bad) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render_ex(seq, NULL, &bad) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render_ex(seq, out_bad, NULL) ==
OAKENGINE_E_INVALID);
}
// ---- render_ex: real exports ---------------------------------------------
{
// H.264 + AAC over a custom 20-frame range of the same sequence.
char out3[4096];
snprintf(out3, sizeof(out3), "%s/ex_custom.mp4", g_tmpdir);
oak_export_options_ex o3;
memset(&o3, 0, sizeof(o3));
o3.range_mode = OAKENGINE_EXPORT_RANGE_CUSTOM;
o3.range_in_ts = 5;
o3.range_out_ts = 25;
o3.format = 2; // ExportFormat::k_format_mpe_g4_video
o3.video_enabled = 1;
o3.video_codec = 1; // ExportCodec::k_codec_h264
o3.audio_enabled = 1;
o3.audio_codec = 11; // ExportCodec::k_codec_aac
o3.audio_sample_rate = 48000;
o3.audio_channel_layout = 0x3;
o3.video_width = 320;
o3.video_height = 180;
rc = oakengine_export_render_ex(seq, out3, &o3);
if (rc != OAKENGINE_OK) {
fprintf(stderr, "ex custom export failed (%d): %s\n", rc,
oakengine_export_last_error(err, sizeof(err)) > 0 ?
err :
"(no error)");
}
assert(rc == OAKENGINE_OK);
snprintf(cmd, sizeof(cmd),
"ffprobe -v error -show_entries stream=codec_type,duration "
"-of csv=p=0 \"%s\"",
out3);
FILE *p3 = popen(cmd, "r");
assert(p3 != NULL);
char p3_out[512] = { 0 };
const size_t p3_len = fread(p3_out, 1, sizeof(p3_out) - 1, p3);
(void)p3_len;
assert(pclose(p3) == 0);
assert(strstr(p3_out, "video") != NULL);
assert(strstr(p3_out, "audio") != NULL);
// 20 frames at 30000/1001 ~= 0.667 s.
assert(strstr(p3_out, "0.6") != NULL);
// PNG sequence over 5 frames (image-sequence flag auto-fills the
// frame placeholder).
char out4[4096];
snprintf(out4, sizeof(out4), "%s/seqout.png", g_tmpdir);
oak_export_options_ex o4 = o3;
o4.range_in_ts = 0;
o4.range_out_ts = 5;
o4.format = 5; // ExportFormat::k_format_png
o4.video_codec = 5; // ExportCodec::k_codec_png
o4.audio_enabled = 0;
o4.is_image_sequence = 1;
rc = oakengine_export_render_ex(seq, out4, &o4);
if (rc != OAKENGINE_OK) {
fprintf(stderr, "ex png export failed (%d): %s\n", rc,
oakengine_export_last_error(err, sizeof(err)) > 0 ?
err :
"(no error)");
}
assert(rc == OAKENGINE_OK);
char png0[4096];
snprintf(png0, sizeof(png0), "%s/seqout_00000.png", g_tmpdir);
assert(access(png0, F_OK) == 0);
snprintf(cmd, sizeof(cmd), "file \"%s\"", png0);
assert_probe_matches(cmd, "PNG image data");
// Cancellation from another thread: either the export finishes
// first (idempotent) or reports E_CANCELLED; the exporter must stay
// usable afterwards.
oak_export_options_ex o5 = o3;
snprintf(out3, sizeof(out3), "%s/ex_cancel.mp4", g_tmpdir);
std::thread canceller([]() {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
oakengine_export_cancel();
});
const int cancel_rc = oakengine_export_render_ex(seq, out3, &o5);
canceller.join();
assert(cancel_rc == OAKENGINE_OK ||
cancel_rc == OAKENGINE_E_CANCELLED);
snprintf(out3, sizeof(out3), "%s/ex_after.mp4", g_tmpdir);
assert(oakengine_export_render_ex(seq, out3, &o5) == OAKENGINE_OK);
}
oakengine_project_free(project);
assert(oakengine_shutdown() == OAKENGINE_OK);