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:
+362
-62
@@ -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 ¶ms, 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, ¶ms);
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user