diff --git a/engine/src/capi/export.cpp b/engine/src/capi/export.cpp index 37aa2fdcb..160f05778 100644 --- a/engine/src/capi/export.cpp +++ b/engine/src/capi/export.cpp @@ -25,15 +25,18 @@ #include #include +#include #include #include #include +#include "codec/conformmanager.h" #include "codec/encoder.h" #include "codec/ffmpeg/ffmpegencoder.h" #include "coreengine.h" #include "node/color/colormanager/colormanager.h" #include "node/project.h" +#include "node/project/footage/footage.h" #include "node/project/sequence/sequence.h" #include "render/rendermanager.h" #include "task/export/export.h" @@ -126,6 +129,72 @@ QString image_sequence_filename(const QString &path) fi.suffix()); } +// Pre-generate audio conforms for every footage with audio streams in the +// project, using exactly the AudioParams the export render will request. +// This mirrors what the application gets for free from preview playback +// (conforms are usually already cached by export time there) and makes +// first-time exports of freshly imported media both faster and +// deterministic. Delivery to the TaskManager thread and the completion +// signal both go through this thread's event queue, so the wait pumps +// events like the render wait does. Returns false on timeout; the caller +// proceeds anyway since the render's own conform wait is the fallback. +bool prewarm_conforms(olive::Project *project, + const olive::AudioParams ¶ms, QString *error) +{ + if (!olive::ConformManager::instance() || params.sample_rate() <= 0) { + return true; // nothing to prewarm (or nothing to prewarm with) + } + const QString cache_path = project->cache_path(); + + struct Pending { + QString decoder_id; + olive::Decoder::CodecStream stream; + }; + QVector pending; + for (olive::Node *n : project->nodes()) { + olive::Footage *footage = dynamic_cast(n); + if (!footage || !footage->is_valid()) { + continue; + } + const QString decoder_id = footage->decoder().isEmpty() ? + QStringLiteral("ffmpeg") : + footage->decoder(); + for (int i = 0; i < footage->get_audio_stream_count(); i++) { + const olive::AudioParams stream_params = + footage->get_audio_params(i); + olive::Decoder::CodecStream stream(footage->filename(), + stream_params.stream_index(), + nullptr); + // Trigger (or adopt) the conform task. + olive::ConformManager::instance()->get_conform_state( + decoder_id, cache_path, stream, params, false); + pending.append({ decoder_id, stream }); + } + } + + constexpr qint64 k_prewarm_timeout_ms = 120000; + QElapsedTimer timer; + timer.start(); + for (const Pending &p : pending) { + while (true) { + const olive::ConformManager::Conform conform = + olive::ConformManager::instance()->get_conform_state( + p.decoder_id, cache_path, p.stream, params, false); + if (conform.state == olive::ConformManager::k_conform_exists) { + break; + } + if (timer.hasExpired(k_prewarm_timeout_ms)) { + *error = QStringLiteral("conform prewarm timed out for %1") + .arg(p.stream.filename()); + return false; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(5); + } + } + return true; +} + } // namespace extern "C" @@ -264,6 +333,15 @@ 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 diff --git a/engine/tests/oakengine_export_test.cpp b/engine/tests/oakengine_export_test.cpp index e70e4a08e..1b94e0963 100644 --- a/engine/tests/oakengine_export_test.cpp +++ b/engine/tests/oakengine_export_test.cpp @@ -57,6 +57,7 @@ #endif #include "oakengine/exporter.h" +#include "oakengine/footage.h" #include "oakengine/init.h" #include "oakengine/project.h" #include "oakengine/timeline.h" @@ -173,6 +174,35 @@ static void test_codecs_and_validation(OakEngineSequence *seq) assert(oakengine_export_last_error(err, sizeof(err)) > 0); } +// Generate a loud test tone (440 Hz stereo sine, 2 s) with the ffmpeg +// CLI -- the demo file's own audio track is essentially silent, so a real +// tone is needed to exercise "audio content present" assertions. +static void make_tone(const char *path) +{ + char cmd[4608]; + snprintf(cmd, sizeof(cmd), + "ffmpeg -v error -y -f lavfi -i " + "\"sine=frequency=440:duration=2\" -ar 48000 -ac 2 \"%s\"", + path); + assert(system(cmd) == 0); + FILE *f = fopen(path, "rb"); + assert(f != NULL); + fclose(f); +} + +// Probe a media file with ffprobe/ffmpeg and assert the output contains a +// string (used for codec/dimension and loudness checks). +static void assert_probe_matches(const char *cmd, const char *needle) +{ + FILE *probe = popen(cmd, "r"); + assert(probe != NULL); + char out[2048] = { 0 }; + const size_t len = fread(out, 1, sizeof(out) - 1, probe); + (void)len; + assert(pclose(probe) == 0); + assert(strstr(out, needle) != NULL); +} + int main(void) { make_tmpdir(); @@ -269,6 +299,72 @@ int main(void) const double duration = atof(comma + 1); assert(fabs(duration - 1.001) < 0.15); + // ---- First-conform audio export --------------------------------------- + // The sandboxed cache guarantees no conform exists yet: this is the + // exact first-time-export path that used to come out silent. Lay a loud + // tone clip on an audio track and export with AAC audio; the exported + // audio must actually contain the tone. + { + char tone_path[4096]; + snprintf(tone_path, sizeof(tone_path), "%s/tone.wav", g_tmpdir); + make_tone(tone_path); + + // Import through the facade and place through the timeline editing + // primitives. + OakEngineFootage *tone = + oakengine_project_import_footage(project, tone_path); + assert(tone != NULL); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == + 0); + assert(oakengine_sequence_add_footage_clip( + seq, tone, OAKENGINE_TRACK_TYPE_AUDIO, 0, 0, 30, 0) != + NULL); + + char out2[4096]; + snprintf(out2, sizeof(out2), "%s/export_audio.mp4", g_tmpdir); + oak_export_options opts2; + memset(&opts2, 0, sizeof(opts2)); + opts2.video_codec = OAKENGINE_EXPORT_VIDEO_H264; + opts2.audio_codec = OAKENGINE_EXPORT_AUDIO_AAC; + opts2.audio_sample_rate = 48000; + opts2.audio_channel_count = 2; + rc = oakengine_export_render(seq, out2, 0, 30, 320, 180, &opts2); + if (rc != OAKENGINE_OK) { + fprintf(stderr, "audio export failed (%d): %s\n", rc, + oakengine_export_last_error(err, sizeof(err)) > 0 ? + err : + "(no error)"); + } + assert(rc == OAKENGINE_OK); + assert(access(out2, F_OK) == 0); + + // The MP4 carries an AAC audio stream... + snprintf(cmd, sizeof(cmd), + "ffprobe -v error -select_streams a:0 -show_entries " + "stream=codec_name -of csv=p=0 \"%s\"", + out2); + assert_probe_matches(cmd, "aac"); + + // ...and the tone is really in there (sine is ~-24 dB; silence + // would read ~-91 dB). + snprintf(cmd, sizeof(cmd), + "ffmpeg -v info -i \"%s\" -map a:0 -af volumedetect -f " + "null - 2>&1 | grep mean_volume", + out2); + FILE *vol = popen(cmd, "r"); + assert(vol != NULL); + char vol_out[512] = { 0 }; + const size_t vol_len = fread(vol_out, 1, sizeof(vol_out) - 1, vol); + (void)vol_len; + assert(pclose(vol) == 0); + const char *db = strstr(vol_out, "mean_volume:"); + assert(db != NULL); + const double mean_db = atof(db + strlen("mean_volume:")); + assert(mean_db > -60.0); + + oakengine_footage_free(tone); + } + oakengine_project_free(project); assert(oakengine_shutdown() == OAKENGINE_OK);