engine: add media management (relink/proxy) to the footage facade
- relink through Footage::set_filename with the engine's natural clear+reprobe cascade; find_offline_footage recovers offline media by exact filename match under a search directory - synchronous proxy generate (event-loop driven like the export family), state query, enable/disable, path, and delete, mirroring the app's proxy dialog assembly - documented as non-undoable, matching the app's existing semantics
This commit is contained in:
@@ -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`/<file name> 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
|
||||
|
||||
@@ -20,13 +20,20 @@
|
||||
|
||||
#include "oakengine/footage.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QElapsedTimer>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QString>
|
||||
#include <QThread>
|
||||
|
||||
#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<olive::Project *>(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<olive::Footage *>(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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user