diff --git a/engine/include/oakengine/footage.h b/engine/include/oakengine/footage.h index 978c6f100..bfe040b0f 100644 --- a/engine/include/oakengine/footage.h +++ b/engine/include/oakengine/footage.h @@ -200,6 +200,88 @@ OAKENGINE_API int oakengine_footage_get_source_start_time( OAKENGINE_API OakEngineFootage *oakengine_project_import_footage( OakEngineProject *project, const char *path); +/* ---- Media management: relink and proxies ----------------------------------- + * + * These functions operate on BORROWED import handles (footage nodes living + * in a project). Probe handles carry no node and are rejected with + * OAKENGINE_E_INVALID everywhere in this section. Relinking and proxy + * state changes are not undoable in the engine (the application's relink + * and proxy dialogs call the same setters directly). + */ + +/** + * @brief Point the footage at a different media file (relink). + * + * Calls Footage::set_filename(), which triggers the engine's full reprobe + * cascade (Footage::clear(): streams, decoder link and the proxy state are + * reset before re-probing). Fails with OAKENGINE_E_NOT_FOUND when + * `new_path` does not exist, and with OAKENGINE_E_FAILED when the new file + * cannot be probed as media. Not undoable (same as the application's + * relink dialog). + */ +OAKENGINE_API int oakengine_footage_relink(OakEngineFootage *footage, + const char *new_path); + +/** + * @brief Try to bring every offline footage item in the project back + * online by looking up its file name under `search_dir`. + * + * Simplified version of the application's relink dialog matching: each + * footage whose stored filename does not exist is relinked to + * `search_dir`/ when that file exists (exact file-name match, + * no recursion). Returns the number of relinked items (>= 0) or a negative + * code. + */ +OAKENGINE_API int +oakengine_project_find_offline_footage(OakEngineProject *project, + const char *search_dir); + +/** + * @brief Proxy state (olive::ProxyManager::ProxyState): 0 = missing, + * 1 = generating, 2 = ready, 3 = failed. + */ +OAKENGINE_API int oakengine_footage_proxy_get_state(OakEngineFootage *self); + +/** + * @brief Generate the proxy synchronously + * (ProxyManager::get_or_start_proxy() + wait). + * + * Drives the proxy task with an event loop like the export family (the + * engine hands proxy tasks to the TaskManager thread via queued calls, so + * the calling thread must process events while waiting; up to 120 s). A + * ready proxy is a success; a failed generation returns + * OAKENGINE_E_FAILED with the reason in oakengine_footage_last_error(). + * Not undoable. + */ +OAKENGINE_API int oakengine_footage_proxy_generate(OakEngineFootage *self); + +/** + * @brief Delete the proxy file (and any in-progress working file) and + * reset the footage's proxy state (mirrors the application's proxy + * dialog). Not undoable. + */ +OAKENGINE_API int oakengine_footage_proxy_delete(OakEngineFootage *self); + +/** + * @brief 1 if proxy usage is enabled for this footage + * (Footage::proxy_enabled()). + */ +OAKENGINE_API int oakengine_footage_proxy_is_enabled(OakEngineFootage *self); + +/** + * @brief Enable or disable proxy usage (Footage::set_proxy_enabled(); not + * undoable). + */ +OAKENGINE_API int oakengine_footage_proxy_set_enabled(OakEngineFootage *self, + int enabled); + +/** + * @brief Path of the proxy file (Footage::proxy_path(); empty when the + * footage has no proxy). buf/size convention. + */ +OAKENGINE_API int oakengine_footage_proxy_get_path(OakEngineFootage *self, + char *buf, int buf_size); + #ifdef __cplusplus } #endif diff --git a/engine/src/capi/footage.cpp b/engine/src/capi/footage.cpp index 37581be03..e6de06519 100644 --- a/engine/src/capi/footage.cpp +++ b/engine/src/capi/footage.cpp @@ -20,13 +20,20 @@ #include "oakengine/footage.h" +#include #include #include +#include +#include +#include +#include #include #include +#include #include "codec/decoder.h" +#include "codec/proxymanager.h" #include "coreengine.h" #include "node/nodeundo.h" #include "node/project.h" @@ -124,6 +131,24 @@ QString filename_of(const OakEngineFootageState *s) return s->node ? s->node->filename() : s->filename; } +// The footage node of a borrowed handle, or nullptr (probe handles have no +// node). Shared validation for the media-management section; sets the +// error string. +olive::Footage *borrowed_node(OakEngineFootage *self) +{ + if (!self) { + set_error(QStringLiteral("invalid footage handle")); + return nullptr; + } + olive::Footage *node = impl(self)->node; + if (!node) { + set_error(QStringLiteral( + "probe handles carry no project node; import the media first")); + return nullptr; + } + return node; +} + } // namespace // Internal cross-family accessor (not part of the public C ABI): returns @@ -358,4 +383,196 @@ OakEngineFootage *oakengine_project_import_footage(OakEngineProject *project, return wrap(state); } +int oakengine_footage_relink(OakEngineFootage *footage, const char *new_path) +{ + set_error(QString()); + if (!new_path) { + set_error(QStringLiteral("invalid path")); + return OAKENGINE_E_INVALID; + } + olive::Footage *node = borrowed_node(footage); + if (!node) { + return OAKENGINE_E_INVALID; + } + const QString path = QString::fromUtf8(new_path); + if (!QFileInfo::exists(path)) { + set_error(QStringLiteral("file does not exist: %1").arg(path)); + return OAKENGINE_E_NOT_FOUND; + } + + // Same as the application's relink dialog: set_filename() triggers + // clear() (streams, decoder and proxy state reset) and a reprobe. + node->set_filename(path); + node->set_label(QFileInfo(path).fileName()); + if (!node->is_valid()) { + set_error(QStringLiteral("failed to probe \"%1\" as media") + .arg(path)); + return OAKENGINE_E_FAILED; + } + return OAKENGINE_OK; +} + +int oakengine_project_find_offline_footage(OakEngineProject *project, + const char *search_dir) +{ + set_error(QString()); + olive::Project *p = reinterpret_cast(project); + if (!p || !search_dir) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const QDir dir(QString::fromUtf8(search_dir)); + if (!dir.exists()) { + set_error(QStringLiteral("directory does not exist: %1") + .arg(search_dir)); + return OAKENGINE_E_INVALID; + } + + int relinked = 0; + for (olive::Node *n : p->nodes()) { + olive::Footage *footage = dynamic_cast(n); + if (!footage) { + continue; + } + const QString filename = footage->filename(); + if (filename.isEmpty() || QFileInfo::exists(filename)) { + continue; + } + // Exact file-name match in the search directory (no recursion). + const QString candidate = + dir.filePath(QFileInfo(filename).fileName()); + if (QFileInfo::exists(candidate)) { + footage->set_filename(candidate); + footage->set_label(QFileInfo(candidate).fileName()); + if (footage->is_valid()) { + relinked++; + } + } + } + return relinked; +} + +int oakengine_footage_proxy_get_state(OakEngineFootage *self) +{ + if (!self || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + return int(impl(self)->node->proxy_state()); +} + +int oakengine_footage_proxy_generate(OakEngineFootage *self) +{ + set_error(QString()); + olive::Footage *node = borrowed_node(self); + if (!node) { + return OAKENGINE_E_INVALID; + } + if (!olive::ProxyManager::instance()) { + set_error(QStringLiteral("engine not initialized")); + return OAKENGINE_E_STATE; + } + olive::Project *project = node->project(); + if (!project) { + set_error(QStringLiteral("footage is not part of a project")); + return OAKENGINE_E_INVALID; + } + const QString filename = node->filename(); + if (!QFileInfo::exists(filename)) { + set_error(QStringLiteral("source file does not exist: %1") + .arg(filename)); + return OAKENGINE_E_NOT_FOUND; + } + if (node->get_video_stream_count() < 1) { + set_error(QStringLiteral("footage has no video stream to proxy")); + return OAKENGINE_E_INVALID; + } + + const int stream_index = node->get_video_params(0).stream_index(); + const olive::ProxyManager::ProxyParams params = + olive::ProxyManager::proxy_params_from_config(); + + // Same assembly as the application's proxy dialog. + olive::ProxyManager::Proxy proxy = + olive::ProxyManager::instance()->get_or_start_proxy( + project->cache_path(), filename, stream_index, params); + node->set_proxy(proxy.filename, proxy.state, stream_index, + params.version, true); + node->invalidate_all(olive::Footage::k_filename_input); + + if (proxy.state == olive::ProxyManager::k_proxy_ready) { + return OAKENGINE_OK; + } + + // Wait for completion with an event loop, like the export family: the + // proxy task finishes on the TaskManager thread and its completion + // signal is queued back to this thread (up to 120 s). + QElapsedTimer timer; + timer.start(); + while (node->proxy_state() == olive::ProxyManager::k_proxy_generating && + !timer.hasExpired(120000)) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(5); + } + + if (node->proxy_state() == olive::ProxyManager::k_proxy_ready) { + return OAKENGINE_OK; + } + if (node->proxy_state() == olive::ProxyManager::k_proxy_failed) { + set_error(QStringLiteral("proxy generation failed for \"%1\"") + .arg(filename)); + return OAKENGINE_E_FAILED; + } + set_error(QStringLiteral("proxy generation timed out for \"%1\"") + .arg(filename)); + return OAKENGINE_E_FAILED; +} + +int oakengine_footage_proxy_delete(OakEngineFootage *self) +{ + set_error(QString()); + olive::Footage *node = borrowed_node(self); + if (!node) { + return OAKENGINE_E_INVALID; + } + // Mirrors the application's proxy dialog: remove the finished and the + // in-progress working file, then reset the proxy state. + if (!node->proxy_path().isEmpty()) { + QFile::remove(node->proxy_path()); + QFile::remove( + olive::ProxyManager::get_working_proxy_filename( + node->proxy_path())); + } + node->clear_proxy(); + node->invalidate_all(olive::Footage::k_filename_input); + return OAKENGINE_OK; +} + +int oakengine_footage_proxy_is_enabled(OakEngineFootage *self) +{ + if (!self || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + return impl(self)->node->proxy_enabled() ? 1 : 0; +} + +int oakengine_footage_proxy_set_enabled(OakEngineFootage *self, int enabled) +{ + set_error(QString()); + olive::Footage *node = borrowed_node(self); + if (!node) { + return OAKENGINE_E_INVALID; + } + node->set_proxy_enabled(enabled != 0); + return OAKENGINE_OK; +} + +int oakengine_footage_proxy_get_path(OakEngineFootage *self, char *buf, + int buf_size) +{ + if (!self || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->node->proxy_path(), buf, buf_size); +} + } // extern "C" diff --git a/engine/tests/oakengine_footage_test.cpp b/engine/tests/oakengine_footage_test.cpp index 4f18535be..9b6520fc7 100644 --- a/engine/tests/oakengine_footage_test.cpp +++ b/engine/tests/oakengine_footage_test.cpp @@ -238,6 +238,177 @@ static void test_failures(void) OAKENGINE_E_INVALID); } +// Copy a file in C (used to stage a deletable media copy). +static void copy_file(const char *src, const char *dst) +{ + FILE *in = fopen(src, "rb"); + assert(in != NULL); + FILE *out = fopen(dst, "wb"); + assert(out != NULL); + char chunk[65536]; + size_t n; + while ((n = fread(chunk, 1, sizeof(chunk), in)) > 0) { + assert(fwrite(chunk, 1, n, out) == n); + } + fclose(out); + fclose(in); +} + +static void test_relink(void) +{ + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + char path[4096], img[4096], missing[4096], txt[4096]; + demo_path(path, sizeof(path)); + snprintf(img, sizeof(img), "%s/tests/img.png", OAK_TEST_SOURCE_DIR); + snprintf(missing, sizeof(missing), "%s/tests/not-there.mp4", + OAK_TEST_SOURCE_DIR); + snprintf(txt, sizeof(txt), "%s/not-media.txt", g_tmpdir); + { + FILE *f = fopen(txt, "w"); + assert(f != NULL); + fputs("not media\n", f); + fclose(f); + } + + OakEngineFootage *f = oakengine_project_import_footage(project, path); + assert(f != NULL); + + // Missing target, non-media target, probe handles, NULL. + assert(oakengine_footage_relink(f, missing) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_footage_relink(f, txt) == OAKENGINE_E_FAILED); + assert(oakengine_footage_relink(NULL, path) == OAKENGINE_E_INVALID); + OakEngineFootage *probed = oakengine_footage_probe(path); + assert(probed != NULL); + assert(oakengine_footage_relink(probed, img) == OAKENGINE_E_INVALID); + oakengine_footage_free(probed); + + // Relink to the same file: still valid, still online. + assert(oakengine_footage_relink(f, path) == OAKENGINE_OK); + assert(oakengine_footage_is_online(f) == 1); + assert(oakengine_footage_get_video_stream_count(f) == 1); + + // Relink to a still image: decoder/filename update. + assert(oakengine_footage_relink(f, img) == OAKENGINE_OK); + char filename[4096]; + assert(oakengine_project_footage_filename(project, 0, filename, + sizeof(filename)) > 0); + assert(strcmp(filename, img) == 0); + char decoder[64]; + assert(oakengine_footage_get_decoder_name(f, decoder, + sizeof(decoder)) > 0); + assert(strlen(decoder) > 0); + assert(oakengine_footage_get_video_stream_count(f) >= 1); + assert(oakengine_footage_is_online(f) == 1); + + // And back to the demo file. + assert(oakengine_footage_relink(f, path) == OAKENGINE_OK); + assert(oakengine_project_footage_filename(project, 0, filename, + sizeof(filename)) > 0); + assert(strcmp(filename, path) == 0); + + oakengine_footage_free(f); + oakengine_project_free(project); +} + +static void test_find_offline(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + // Import a staged copy, then delete it so the footage goes offline. + char staged[4096], tests_dir[4096]; + snprintf(staged, sizeof(staged), "%s/demo.mp4", g_tmpdir); + snprintf(tests_dir, sizeof(tests_dir), "%s/tests", OAK_TEST_SOURCE_DIR); + char path[4096]; + demo_path(path, sizeof(path)); + copy_file(path, staged); + + OakEngineFootage *f = oakengine_project_import_footage(project, staged); + assert(f != NULL); + assert(oakengine_project_footage_is_online(project, 0) == 1); + assert(remove(staged) == 0); + assert(oakengine_project_footage_is_online(project, 0) == 0); + + // The search directory has a file with the same name: relinked. + assert(oakengine_project_find_offline_footage(project, tests_dir) == 1); + assert(oakengine_project_footage_is_online(project, 0) == 1); + char filename[4096]; + assert(oakengine_project_footage_filename(project, 0, filename, + sizeof(filename)) > 0); + assert(strcmp(filename, path) == 0); + + // Nothing left to do; a missing search directory is an error. + assert(oakengine_project_find_offline_footage(project, tests_dir) == 0); + assert(oakengine_project_find_offline_footage(project, + "/definitely/not/here") == + OAKENGINE_E_INVALID); + assert(oakengine_project_find_offline_footage(NULL, tests_dir) == + OAKENGINE_E_INVALID); + + oakengine_footage_free(f); + oakengine_project_free(project); +} + +static void test_proxy(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + char path[4096]; + demo_path(path, sizeof(path)); + OakEngineFootage *f = oakengine_project_import_footage(project, path); + assert(f != NULL); + + assert(oakengine_footage_proxy_get_state(f) == 0); // missing + assert(oakengine_footage_proxy_get_state(NULL) == OAKENGINE_E_INVALID); + + // Generate synchronously (CPU transcode of the 17 s demo file). + assert(oakengine_footage_proxy_generate(f) == OAKENGINE_OK); + assert(oakengine_footage_proxy_get_state(f) == 2); // ready + char proxy_path[4096]; + assert(oakengine_footage_proxy_get_path(f, proxy_path, + sizeof(proxy_path)) > 0); + FILE *pf = fopen(proxy_path, "rb"); + assert(pf != NULL); + fclose(pf); + assert(oakengine_footage_proxy_is_enabled(f) == 1); + + // Enable/disable round-trip. + assert(oakengine_footage_proxy_set_enabled(f, 0) == OAKENGINE_OK); + assert(oakengine_footage_proxy_is_enabled(f) == 0); + assert(oakengine_footage_proxy_set_enabled(f, 1) == OAKENGINE_OK); + assert(oakengine_footage_proxy_is_enabled(f) == 1); + + // Delete: state and file are gone. + assert(oakengine_footage_proxy_delete(f) == OAKENGINE_OK); + assert(oakengine_footage_proxy_get_state(f) == 0); + assert(fopen(proxy_path, "rb") == NULL); + + // Error paths. + assert(oakengine_footage_proxy_generate(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_footage_proxy_delete(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_footage_proxy_set_enabled(NULL, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_footage_proxy_get_path(NULL, proxy_path, + sizeof(proxy_path)) == + OAKENGINE_E_INVALID); + assert(oakengine_footage_proxy_is_enabled(NULL) == OAKENGINE_E_INVALID); + OakEngineFootage *probed = oakengine_footage_probe(path); + assert(probed != NULL); + assert(oakengine_footage_proxy_generate(probed) == OAKENGINE_E_INVALID); + oakengine_footage_free(probed); + + oakengine_footage_free(f); + oakengine_project_free(project); +} + int main(void) { make_tmpdir(); @@ -255,6 +426,9 @@ int main(void) test_probe(); test_import(); test_failures(); + test_relink(); + test_find_offline(); + test_proxy(); assert(oakengine_shutdown() == OAKENGINE_OK);