diff --git a/app/dialog/export/export.cpp b/app/dialog/export/export.cpp index 323577392..84c332460 100644 --- a/app/dialog/export/export.cpp +++ b/app/dialog/export/export.cpp @@ -33,11 +33,13 @@ #include "common/digit.h" #include "common/qtutils.h" +#include "codec/ffmpeg/ffmpegencoder.h" #include "dialog/msgbox.h" #include "dialog/task/task.h" #include "exportsavepresetdialog.h" #include "node/project.h" #include "node/project/sequence/sequence.h" +#include "oakengine/exporter.h" #include "task/taskmanager.h" #include "ui/icons/icons.h" #include "widget/timeruler/timeruler.h" @@ -47,6 +49,164 @@ namespace olive #define super QDialog +namespace +{ + +// pix_fmt string (e.g. "yuv420p") to its index in the codec's supported +// list; 0 (the codec's preferred format) when absent. +int pix_fmt_index(ExportCodec::Codec codec, const QString &pix_fmt) +{ + if (pix_fmt.isEmpty()) { + return 0; + } + FFmpegEncoder probe{ EncodingParams() }; + const int index = probe.get_pixel_formats_for_codec(codec).indexOf(pix_fmt); + return index >= 0 ? index : 0; +} + +// EncodingParams (assembled by the dialog) -> facade POD. One-to-one with +// oak_export_options_ex; see oakengine/exporter.h for the field docs. +oak_export_options_ex params_to_ex(const EncodingParams &p) +{ + oak_export_options_ex o = {}; + + const VideoParams &vp = p.video_params(); + const Rational tb = vp.frame_rate().flipped(); + + if (p.has_custom_range()) { + o.range_mode = OAKENGINE_EXPORT_RANGE_CUSTOM; + o.range_in_ts = Timecode::time_to_timestamp(p.custom_range().in(), tb); + o.range_out_ts = + Timecode::time_to_timestamp(p.custom_range().out(), tb); + } else { + o.range_mode = OAKENGINE_EXPORT_RANGE_ENTIRE; + } + + o.format = int(p.format()); + o.video_enabled = p.video_enabled() ? 1 : 0; + o.video_codec = int(p.video_codec()); + o.audio_enabled = p.audio_enabled() ? 1 : 0; + o.audio_codec = int(p.audio_codec()); + o.subtitles_enabled = p.subtitles_enabled() ? 1 : 0; + o.subtitles_sidecar = p.subtitles_are_sidecar() ? 1 : 0; + o.subtitles_format = + p.subtitles_are_sidecar() ? int(p.subtitle_sidecar_fmt()) : 0; + o.subtitles_codec = p.subtitles_enabled() ? int(p.subtitles_codec()) : 0; + + o.video_bit_rate = p.video_bit_rate(); + o.audio_bit_rate = p.audio_bit_rate(); + o.video_pix_fmt = pix_fmt_index(p.video_codec(), p.video_pix_fmt()); + + o.audio_sample_rate = p.audio_params().sample_rate(); + o.audio_channel_layout = p.audio_params().channel_layout(); + o.audio_sample_format = int(p.audio_params().format()); + + const QString ct = p.color_transform().output(); + if (ct.isEmpty()) { + o.color_transform = OAKENGINE_EXPORT_COLOR_REFERENCE; + } else if (ct == QStringLiteral("sRGB OETF")) { + o.color_transform = OAKENGINE_EXPORT_COLOR_SRGB_OETF; + } else if (ct == QStringLiteral("Rec.709 OETF")) { + o.color_transform = OAKENGINE_EXPORT_COLOR_REC709_OETF; + } else if (ct == QStringLiteral("BT.1886 EOTF")) { + o.color_transform = OAKENGINE_EXPORT_COLOR_BT1886_EOTF; + } else { + o.color_transform = OAKENGINE_EXPORT_COLOR_CUSTOM; + const QByteArray utf = ct.toUtf8(); + snprintf(o.color_transform_name, sizeof(o.color_transform_name), + "%s", utf.constData()); + } + + o.video_width = vp.width(); + o.video_height = vp.height(); + o.frame_rate_num = vp.frame_rate().numerator(); + o.frame_rate_den = vp.frame_rate().denominator(); + o.pixel_aspect_num = vp.pixel_aspect_ratio().numerator(); + o.pixel_aspect_den = vp.pixel_aspect_ratio().denominator(); + o.interlacing = int(vp.interlacing()); + o.pixel_format = int(vp.format()); + o.scaling_method = int(p.video_scaling_method()); + o.color_range = int(vp.color_range()); + o.video_threads = p.video_threads(); + o.is_image_sequence = p.video_is_image_sequence() ? 1 : 0; + + return o; +} + +} // namespace + +/** + * @brief ExportTask replacement driven by the liboakengine C ABI facade + * + * Same Task contract as the engine's ExportTask (progress via + * progress_changed, cancel via CancelEvent), but the actual + * render+encode goes through oakengine_export_render_ex(): the facade + * owns the ExportTask instance, its event-loop drive and the conform + * prewarm. Cancellation is forwarded to the facade + * (oakengine_export_cancel()), which reports OAKENGINE_E_CANCELLED back. + */ +class FacadeExportTask : public Task { +public: + FacadeExportTask(ViewerOutput *viewer_node, const EncodingParams ¶ms) + : sequence_(reinterpret_cast(viewer_node)) + , params_(params) + { + set_title(tr("Exporting \"%1\"").arg(viewer_node->get_label())); + } + +protected: + virtual bool run() override + { + oak_export_options_ex o = params_to_ex(params_); + // Pass the codec section's encoder-specific options through. + for (auto it = params_.video_opts().cbegin(); + it != params_.video_opts().cend(); ++it) { + oakengine_export_set_video_option(it.key().toUtf8().constData(), + it.value().toUtf8().constData()); + } + oakengine_export_set_progress_callback( + &FacadeExportTask::forward_progress, this); + const int rc = oakengine_export_render_ex( + sequence_, params_.filename().toUtf8().constData(), &o); + oakengine_export_set_progress_callback(nullptr, nullptr); + oakengine_export_set_video_option("", nullptr); + + if (rc == OAKENGINE_E_CANCELLED) { + // Mirror the engine task's cancelled state for TaskDialog. + cancel(); + return false; + } + if (rc != OAKENGINE_OK) { + char err[1024]; + err[0] = '\0'; + oakengine_export_last_error(err, sizeof(err)); + set_error(err[0] ? QString::fromUtf8(err) : + QStringLiteral("Export failed")); + return false; + } + return true; + } + + virtual void CancelEvent() override + { + oakengine_export_cancel(); + } + +private: + static void forward_progress(double fraction, void *userdata) + { + static_cast(userdata)->emit_progress(fraction); + } + + void emit_progress(double fraction) + { + emit progress_changed(fraction); + } + + OakEngineSequence *sequence_; + EncodingParams params_; +}; + ExportDialog::ExportDialog(ViewerOutput *viewer_node, bool stills_only_mode, QWidget *parent) : super(parent) @@ -398,8 +558,8 @@ void ExportDialog::start_export() return; } - ExportTask *task = - new ExportTask(viewer_node_, color_manager_, generate_params()); + FacadeExportTask *task = + new FacadeExportTask(viewer_node_, generate_params()); if (export_bkg_box_->isChecked()) { // Send to TaskManager to export in background diff --git a/app/dialog/export/export.h b/app/dialog/export/export.h index 912a8d004..f3a8cfc97 100644 --- a/app/dialog/export/export.h +++ b/app/dialog/export/export.h @@ -28,13 +28,13 @@ #include #include +#include "codec/encoder.h" #include "codec/exportcodec.h" #include "codec/exportformat.h" #include "dialog/export/exportformatcombobox.h" #include "exportaudiotab.h" #include "exportsubtitlestab.h" #include "exportvideotab.h" -#include "task/export/export.h" #include "widget/nodeparamview/nodeparamviewwidgetbridge.h" #include "widget/viewer/viewer.h" diff --git a/engine/include/oakengine/exporter.h b/engine/include/oakengine/exporter.h index 51bb4f7b5..c6d1c7f81 100644 --- a/engine/include/oakengine/exporter.h +++ b/engine/include/oakengine/exporter.h @@ -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 diff --git a/engine/src/capi/export.cpp b/engine/src/capi/export.cpp index 160f05778..4bbf56935 100644 --- a/engine/src/capi/export.cpp +++ b/engine/src/capi/export.cpp @@ -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 g_video_options; + +// The export currently driven by oakengine_export_render()/_ex(), for +// cross-thread cancellation (one at a time per process). +std::atomic 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 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(o.video_codec); + const olive::ExportCodec::Codec acodec = + static_cast(o.audio_codec); + const olive::ExportFormat::Format format = + static_cast(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(o.interlacing)); + } + if (o.color_range >= 0) { + vp.set_color_range( + static_cast(o.color_range)); + } + if (o.pixel_format >= 0) { + vp.set_format( + static_cast(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( + 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(o.subtitles_format), + static_cast(o.subtitles_codec)); + } else { + params->enable_subtitles( + static_cast(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( + 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 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(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) { diff --git a/engine/tests/oakengine_export_test.cpp b/engine/tests/oakengine_export_test.cpp index 1b94e0963..ebe5dfc82 100644 --- a/engine/tests/oakengine_export_test.cpp +++ b/engine/tests/oakengine_export_test.cpp @@ -39,6 +39,9 @@ #include #endif +#include +#include + #include #include #include @@ -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);