engine: add the footage family to the C ABI facade; oak-cli probe

- oakengine_footage_probe inspects media without a project (decoder
  probe): stream counts, per-stream video info (dimensions, rate,
  duration, color tags, interlacing), audio info, duration, decoder
  name, source start time; thread_local last_error for the NULL-handle
  failure paths
- oakengine_project_import_footage adds probed footage to a project
  through the same undoable command path as the app
- oak-cli probe prints decoder/duration/per-stream details and runs as
  a ctest everywhere (no GL)
- dual ownership documented: probe handles are owned, imported footage
  is borrowed from its project
This commit is contained in:
2026-07-20 06:03:16 +08:00
parent 6118260e01
commit 1a7029fd8f
7 changed files with 919 additions and 1 deletions
+2
View File
@@ -26,9 +26,11 @@ set(OLIVE_SOURCES
include/oakengine/project.h
include/oakengine/timeline.h
include/oakengine/renderer.h
include/oakengine/footage.h
src/capi/init.cpp
src/capi/project.cpp
src/capi/timeline.cpp
src/capi/renderer.cpp
src/capi/footage.cpp
PARENT_SCOPE
)
+348
View File
@@ -0,0 +1,348 @@
/***
Oak - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#include "oakengine/footage.h"
#include <cstdio>
#include <QByteArray>
#include <QFileInfo>
#include <QString>
#include "codec/decoder.h"
#include "coreengine.h"
#include "node/nodeundo.h"
#include "node/project.h"
#include "node/project/folder/folder.h"
#include "node/project/footage/footage.h"
#include "undo/undocommand.h"
#include "undo/undostack.h"
namespace
{
// Handle payload: a probe result (owned description) or a project footage
// node (borrowed). See oakengine/footage.h for the two models.
struct OakEngineFootageState {
// true: `node` is owned by a project and must not be deleted here.
bool borrowed = false;
olive::FootageDescription description;
olive::Footage *node = nullptr;
QString filename; // media path (probe model; the node has its own)
};
// Last probe/import error per thread (handles are NULL on failure, so the
// reason cannot hang off them like in the renderer family).
thread_local QString g_last_error;
void set_error(const QString &error)
{
g_last_error = error;
}
OakEngineFootageState *impl(OakEngineFootage *h)
{
return reinterpret_cast<OakEngineFootageState *>(h);
}
const OakEngineFootageState *impl(const OakEngineFootage *h)
{
return reinterpret_cast<const OakEngineFootageState *>(h);
}
OakEngineFootage *wrap(OakEngineFootageState *s)
{
return reinterpret_cast<OakEngineFootage *>(s);
}
// buf/size convention: returns the would-be length excluding the NUL.
int string_to_buf(const QString &s, char *buf, int buf_size)
{
const QByteArray utf = s.toUtf8();
if (buf && buf_size > 0) {
snprintf(buf, size_t(buf_size), "%s", utf.constData());
}
return int(utf.size());
}
// Unified stream access across the two handle models.
int video_stream_count(const OakEngineFootageState *s)
{
return s->node ? s->node->get_video_stream_count() :
s->description.get_video_streams().size();
}
int audio_stream_count(const OakEngineFootageState *s)
{
return s->node ? s->node->get_audio_stream_count() :
s->description.get_audio_streams().size();
}
int subtitle_stream_count(const OakEngineFootageState *s)
{
return s->node ? s->node->get_subtitle_stream_count() :
s->description.get_subtitle_streams().size();
}
olive::VideoParams video_stream_at(const OakEngineFootageState *s, int index)
{
if (index < 0 || index >= video_stream_count(s)) {
return olive::VideoParams();
}
return s->node ? s->node->get_video_params(index) :
s->description.get_video_streams().at(index);
}
olive::AudioParams audio_stream_at(const OakEngineFootageState *s, int index)
{
if (index < 0 || index >= audio_stream_count(s)) {
return olive::AudioParams();
}
return s->node ? s->node->get_audio_params(index) :
s->description.get_audio_streams().at(index);
}
QString filename_of(const OakEngineFootageState *s)
{
return s->node ? s->node->filename() : s->filename;
}
} // namespace
extern "C"
{
OakEngineFootage *oakengine_footage_probe(const char *path)
{
set_error(QString());
if (!path || !QFileInfo::exists(QString::fromUtf8(path))) {
set_error(QStringLiteral("file does not exist: %1")
.arg(path ? path : "(null)"));
return nullptr;
}
olive::DecoderPtr decoder =
olive::Decoder::create_from_id(QStringLiteral("ffmpeg"));
if (!decoder) {
set_error(QStringLiteral("ffmpeg decoder is not available"));
return nullptr;
}
olive::FootageDescription description =
decoder->probe(QString::fromUtf8(path), nullptr);
if (!description.is_valid()) {
set_error(QStringLiteral("failed to probe \"%1\": unsupported or "
"unreadable media file")
.arg(path));
return nullptr;
}
auto *state = new OakEngineFootageState();
state->description = description;
state->filename = QString::fromUtf8(path);
return wrap(state);
}
void oakengine_footage_free(OakEngineFootage *self)
{
// Only the wrapper is deleted; a borrowed node stays with its project.
delete impl(self);
}
int oakengine_footage_last_error(char *buf, int buf_size)
{
return string_to_buf(g_last_error, buf, buf_size);
}
int oakengine_footage_get_decoder_name(OakEngineFootage *self, char *buf,
int buf_size)
{
if (!self) {
return OAKENGINE_E_INVALID;
}
const OakEngineFootageState *s = impl(self);
return string_to_buf(s->node ? s->node->decoder() :
s->description.decoder(),
buf, buf_size);
}
int oakengine_footage_get_video_stream_count(const OakEngineFootage *self)
{
return self ? video_stream_count(impl(self)) : 0;
}
int oakengine_footage_get_audio_stream_count(const OakEngineFootage *self)
{
return self ? audio_stream_count(impl(self)) : 0;
}
int oakengine_footage_get_subtitle_stream_count(const OakEngineFootage *self)
{
return self ? subtitle_stream_count(impl(self)) : 0;
}
int oakengine_footage_get_video_stream_info(OakEngineFootage *self, int index,
oak_footage_video_info *out)
{
if (!self || !out) {
return OAKENGINE_E_INVALID;
}
const olive::VideoParams vp = video_stream_at(impl(self), index);
if (!vp.is_valid()) {
return OAKENGINE_E_NOT_FOUND;
}
const olive::Rational frame_rate = vp.frame_rate();
const olive::Rational time_base = vp.time_base();
out->stream_index = vp.stream_index();
out->width = vp.width();
out->height = vp.height();
out->frame_rate_num = frame_rate.numerator();
out->frame_rate_den = frame_rate.denominator();
out->duration_ts = vp.duration();
out->time_base_num = time_base.numerator();
out->time_base_den = time_base.denominator();
out->color_primaries = vp.color_primaries();
out->color_trc = vp.color_transfer();
out->interlaced =
vp.interlacing() != olive::VideoParams::k_interlace_none ? 1 : 0;
return OAKENGINE_OK;
}
int oakengine_footage_get_audio_stream_info(OakEngineFootage *self, int index,
oak_footage_audio_info *out)
{
if (!self || !out) {
return OAKENGINE_E_INVALID;
}
const olive::AudioParams ap = audio_stream_at(impl(self), index);
if (ap.sample_rate() <= 0) {
return OAKENGINE_E_NOT_FOUND;
}
const olive::Rational time_base = ap.time_base();
out->stream_index = ap.stream_index();
out->sample_rate = ap.sample_rate();
out->channel_layout = ap.channel_layout();
out->channel_count = ap.channel_count();
out->duration_ts = ap.duration();
out->time_base_num = time_base.numerator();
out->time_base_den = time_base.denominator();
return OAKENGINE_OK;
}
int oakengine_footage_get_duration(OakEngineFootage *self, double *seconds)
{
if (!self || !seconds) {
return OAKENGINE_E_INVALID;
}
const OakEngineFootageState *s = impl(self);
double longest = 0.0;
for (int i = 0; i < video_stream_count(s); i++) {
const olive::VideoParams vp = video_stream_at(s, i);
longest = qMax(longest, vp.duration() * vp.time_base().to_double());
}
for (int i = 0; i < audio_stream_count(s); i++) {
const olive::AudioParams ap = audio_stream_at(s, i);
longest = qMax(longest, ap.duration() * ap.time_base().to_double());
}
*seconds = longest;
return OAKENGINE_OK;
}
int oakengine_footage_is_online(OakEngineFootage *self)
{
if (!self) {
return OAKENGINE_E_INVALID;
}
return QFileInfo::exists(filename_of(impl(self))) ? 1 : 0;
}
int oakengine_footage_get_source_start_time(OakEngineFootage *self, int *num,
int *den)
{
if (!self) {
return OAKENGINE_E_INVALID;
}
const OakEngineFootageState *s = impl(self);
const bool has = s->node ? s->node->has_source_start_time() :
s->description.has_source_start_time();
if (!has) {
return 0;
}
const olive::Rational t = s->node ? s->node->source_start_time() :
s->description.source_start_time();
if (num) {
*num = t.numerator();
}
if (den) {
*den = t.denominator();
}
return 1;
}
OakEngineFootage *oakengine_project_import_footage(OakEngineProject *project,
const char *path)
{
set_error(QString());
olive::Project *p = reinterpret_cast<olive::Project *>(project);
if (!p || !p->root() || !path) {
set_error(QStringLiteral("invalid project or path"));
return nullptr;
}
const QFileInfo file_info(QString::fromUtf8(path));
if (!file_info.exists()) {
set_error(QStringLiteral("file does not exist: %1").arg(path));
return nullptr;
}
// Non-UI core of ProjectImportTask: assigning the filename probes the
// media (Footage::reprobe()); invalid media is rejected. Numbered stills
// are imported as single frames -- there is no image-sequence
// confirmation handler behind this facade.
auto *footage = new olive::Footage();
footage->set_filename(file_info.absoluteFilePath());
footage->set_label(file_info.fileName());
if (!footage->is_valid()) {
set_error(QStringLiteral("failed to probe \"%1\": unsupported or "
"unreadable media file")
.arg(path));
delete footage;
return nullptr;
}
olive::MultiUndoCommand *command = new olive::MultiUndoCommand();
command->add_child(new olive::NodeAddCommand(p, footage));
command->add_child(new olive::FolderAddChild(p->root(), footage));
if (olive::EngineCore::instance()) {
olive::EngineCore::instance()->undo_stack()->push(
command, QStringLiteral("Import Footage"));
} else {
command->redo_now();
delete command;
}
auto *state = new OakEngineFootageState();
state->borrowed = true;
state->node = footage;
return wrap(state);
}
} // extern "C"