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
+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) {