diff --git a/CMakeLists.txt b/CMakeLists.txt
index f07183bfd..779f8e0ec 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -309,6 +309,7 @@ add_subdirectory(third_party/KDDockWidgets EXCLUDE_FROM_ALL)
add_subdirectory(third_party/openfx/HostSupport)
add_subdirectory(engine)
+add_subdirectory(cli)
# Everything above the engine library links against it
list(APPEND OLIVE_LIBRARIES oakengine)
diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt
new file mode 100644
index 000000000..fcbe53947
--- /dev/null
+++ b/cli/CMakeLists.txt
@@ -0,0 +1,94 @@
+# 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 .
+
+# oak-cli: headless command-line consumer of the liboakengine C ABI facade.
+# It is the first real consumer of the facade and therefore links ONLY
+# against liboakengine (which publicly re-exports liboakcore); the source
+# includes only the public oakengine/*.h C headers -- no engine C++ headers,
+# no Qt headers.
+
+add_executable(oak-cli
+ main.cpp
+)
+
+# oakengine leaves olive::k_app_version to the final executable; the version
+# object library provides it (same as the engine C ABI tests and worker).
+target_sources(oak-cli PRIVATE $)
+
+target_link_libraries(oak-cli PRIVATE oakengine)
+target_include_directories(oak-cli PRIVATE
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+set_target_properties(oak-cli PROPERTIES
+ OUTPUT_NAME oak-cli
+)
+
+if (APPLE)
+ # Distribute inside Oak.app/Contents/MacOS next to the other helper
+ # binaries (mirrors the oak-render-worker copies in app/CMakeLists.txt).
+ add_custom_command(TARGET oak-cli POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E make_directory $/Contents/MacOS
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $/Contents/MacOS/
+ COMMENT "Copying oak-cli into Oak.app"
+ )
+elseif (UNIX)
+ install(TARGETS oak-cli RUNTIME DESTINATION bin)
+ # liboakengine installs into the platform lib dir (see
+ # engine/CMakeLists.txt), resolve it relative to the binary.
+ set_target_properties(oak-cli PROPERTIES INSTALL_RPATH "$ORIGIN/../lib")
+endif ()
+
+if (WIN32)
+ # Windows has no RPATH: shared libraries must sit next to the executable
+ # (mirrors tests/gtest/CMakeLists.txt).
+ add_custom_command(TARGET oak-cli POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $
+ )
+endif ()
+
+if (BUILD_TESTS)
+ enable_testing()
+
+ # info always runs (headless); the output must mention the fixture's
+ # sequence name and footage filename.
+ add_test(NAME oak_cli_info
+ COMMAND oak-cli info ${CMAKE_SOURCE_DIR}/tests/project_with_footage.ove
+ )
+ set_tests_properties(oak_cli_info PROPERTIES
+ PASS_REGULAR_EXPRESSION "Fixture Sequence.*demo\\.mp4"
+ )
+
+ # Render smoke test: without a GL render backend oak-cli reports the
+ # failure and exits 2, which ctest treats as a skip.
+ add_test(NAME oak_cli_render
+ COMMAND oak-cli render ${CMAKE_SOURCE_DIR}/tests/project_with_footage.ove 0 1 ${CMAKE_CURRENT_BINARY_DIR}/oak_cli_render_out
+ )
+ set_tests_properties(oak_cli_render PROPERTIES
+ SKIP_RETURN_CODE 2
+ )
+ # Rendering needs the render worker and the dynamic backend plugins.
+ if (TARGET olive-render-worker)
+ add_dependencies(oak-cli olive-render-worker)
+ endif ()
+ if (TARGET oakgl)
+ add_dependencies(oak-cli oakgl)
+ endif ()
+ if (TARGET oakvulkan)
+ add_dependencies(oak-cli oakvulkan)
+ endif ()
+endif ()
diff --git a/cli/main.cpp b/cli/main.cpp
new file mode 100644
index 000000000..1d10757f8
--- /dev/null
+++ b/cli/main.cpp
@@ -0,0 +1,491 @@
+/***
+
+ 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 .
+
+***/
+
+// oak-cli: headless command-line consumer of the liboakengine C ABI facade.
+//
+// This binary is a pure C ABI consumer: it includes ONLY the public
+// oakengine/*.h C headers (no engine C++ headers, no Qt headers) and links
+// only against liboakengine. Everything it does goes through the facade:
+//
+// oak-cli info
+// Print the project name, its sequences (name, length, frame rate,
+// track counts, playhead) and its footage (filename, online/offline).
+//
+// oak-cli render
+// Render the first sequence frame by frame at the sequence frame rate
+// to PPM (P6) images plus the whole audio range to a PCM s16 WAV.
+//
+// Exit codes:
+// 0 success
+// 1 general error (bad project file, no sequence, I/O failure)
+// 2 rendering unavailable or failed (e.g. no GL render backend); ctest
+// treats this as SKIP via SKIP_RETURN_CODE
+// 64 usage error (wrong arguments)
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "oakengine/init.h"
+#include "oakengine/project.h"
+#include "oakengine/renderer.h"
+#include "oakengine/timeline.h"
+
+namespace
+{
+
+constexpr int k_exit_ok = 0;
+constexpr int k_exit_error = 1;
+constexpr int k_exit_render_unavailable = 2;
+constexpr int k_exit_usage = 64;
+
+// The facade does not expose sequence dimensions yet, so rendering uses the
+// application's default sequence size (also the native size of the demo
+// assets).
+constexpr int k_render_width = 1920;
+constexpr int k_render_height = 1080;
+constexpr int k_pixel_format_f32 = 4; // olive::core::PixelFormat::f32
+
+void print_usage(FILE *out)
+{
+ fprintf(out,
+ "oak-cli - headless consumer of the liboakengine C ABI\n"
+ "\n"
+ "Usage:\n"
+ " oak-cli info \n"
+ " Print project name, sequences and footage.\n"
+ "\n"
+ " oak-cli render \n"
+ " Render the first sequence to PPM frames (P6, 8-bit RGB) and the\n"
+ " audio range to a PCM s16 WAV file in .\n"
+ "\n"
+ " oak-cli --help\n"
+ " Show this text.\n"
+ "\n"
+ "Exit codes:\n"
+ " 0 success\n"
+ " 1 general error (bad project file, no sequence, I/O failure)\n"
+ " 2 rendering unavailable or failed (e.g. no GL render backend)\n"
+ " 64 usage error\n");
+}
+
+// Read a facade string (buf/size convention) into a std::string.
+std::string facade_string(int (*getter)(const void *, char *, int),
+ const void *handle)
+{
+ const int size = getter(handle, nullptr, 0);
+ if (size < 0) {
+ return std::string();
+ }
+ std::string s(size_t(size) + 1, '\0');
+ getter(handle, s.data(), size + 1);
+ s.resize(size_t(size));
+ return s;
+}
+
+int project_name_adapter(const void *handle, char *buf, int size)
+{
+ return oakengine_project_name(
+ reinterpret_cast(handle), buf, size);
+}
+
+int sequence_name_adapter(const void *handle, char *buf, int size)
+{
+ return oakengine_sequence_name(
+ reinterpret_cast(handle), buf, size);
+}
+
+void print_sequence(OakEngineSequence *seq, int index)
+{
+ const std::string name =
+ facade_string(sequence_name_adapter, reinterpret_cast(seq));
+
+ double seconds = 0.0;
+ oakengine_sequence_get_length(seq, &seconds);
+ int len_num = 0, len_den = 0;
+ oakengine_sequence_get_length_rational(seq, &len_num, &len_den);
+ int fr_num = 0, fr_den = 0;
+ oakengine_sequence_get_frame_rate(seq, &fr_num, &fr_den);
+ int video = 0, audio = 0, subtitle = 0;
+ oakengine_sequence_track_count(seq, &video, &audio, &subtitle);
+ int64_t playhead = 0;
+ double playhead_seconds = 0.0;
+ oakengine_sequence_get_playhead(seq, &playhead);
+ oakengine_sequence_get_playhead_seconds(seq, &playhead_seconds);
+
+ printf(" [%d] \"%s\"\n", index, name.c_str());
+ printf(" length: %.6f s (%d/%d)\n", seconds, len_num, len_den);
+ printf(" frame rate: %d/%d (%.3f fps)\n", fr_num, fr_den,
+ fr_den ? double(fr_num) / double(fr_den) : 0.0);
+ printf(" tracks: video=%d audio=%d subtitle=%d\n", video, audio,
+ subtitle);
+ printf(" playhead: %lld (%.6f s)\n", (long long)playhead,
+ playhead_seconds);
+}
+
+int cmd_info(const char *path)
+{
+ if (oakengine_init(OAKENGINE_INIT_HEADLESS) != OAKENGINE_OK) {
+ fprintf(stderr, "error: failed to initialize the engine\n");
+ return k_exit_error;
+ }
+
+ int rc = k_exit_ok;
+ OakEngineProject *project = oakengine_project_create();
+ char err[1024];
+ if (oakengine_project_load(project, path, err, sizeof(err)) !=
+ OAKENGINE_OK) {
+ fprintf(stderr, "error: failed to load \"%s\": %s\n", path, err);
+ rc = k_exit_error;
+ } else {
+ const std::string name =
+ facade_string(project_name_adapter, reinterpret_cast(project));
+ char filename[4096];
+ oakengine_project_filename(project, filename, sizeof(filename));
+
+ printf("Project: %s\n", name.c_str());
+ printf("File: %s\n", filename);
+ printf("Modified: %s\n",
+ oakengine_project_is_modified(project) ? "yes" : "no");
+
+ const int sequences = oakengine_project_sequence_count(project);
+ printf("Sequences: %d\n", sequences);
+ for (int i = 0; i < sequences; i++) {
+ print_sequence(oakengine_project_sequence_at(project, i), i);
+ }
+
+ const int footage = oakengine_project_footage_count(project);
+ printf("Footage: %d\n", footage);
+ for (int i = 0; i < footage; i++) {
+ const int size =
+ oakengine_project_footage_filename(project, i, nullptr, 0);
+ std::string fn(size_t(size > 0 ? size : 0), '\0');
+ if (size > 0) {
+ oakengine_project_footage_filename(project, i, fn.data(),
+ size + 1);
+ }
+ const int online =
+ oakengine_project_footage_is_online(project, i);
+ printf(" [%d] \"%s\" %s\n", i, fn.c_str(),
+ online == 1 ? "online" : "offline");
+ }
+ }
+
+ oakengine_project_free(project);
+ oakengine_shutdown();
+ return rc;
+}
+
+// f32 RGBA -> 8-bit RGB triple, clamped.
+void write_ppm(const OakEngineFrame *frame, const std::string &path)
+{
+ const int width = oakengine_frame_width(frame);
+ const int height = oakengine_frame_height(frame);
+ const int format = oakengine_frame_format(frame);
+ const int channels = oakengine_frame_channel_count(frame);
+ const int linesize = oakengine_frame_linesize_bytes(frame);
+ const char *data =
+ reinterpret_cast(oakengine_frame_data(frame));
+
+ FILE *f = fopen(path.c_str(), "wb");
+ if (!f) {
+ throw std::string("cannot open \"" + path + "\" for writing");
+ }
+ fprintf(f, "P6\n%d %d\n255\n", width, height);
+
+ std::vector row(size_t(width) * 3);
+ for (int y = 0; y < height; y++) {
+ const char *line = data + ptrdiff_t(y) * linesize;
+ for (int x = 0; x < width; x++) {
+ for (int c = 0; c < 3; c++) {
+ unsigned char v = 0;
+ if (format == k_pixel_format_f32) {
+ // f32: 4 bytes per channel
+ const float *px = reinterpret_cast(line) +
+ ptrdiff_t(x) * channels;
+ const float clamped = px[c] < 0.0f ? 0.0f :
+ px[c] > 1.0f ? 1.0f :
+ px[c];
+ v = static_cast(clamped * 255.0f + 0.5f);
+ } else if (format == 0) {
+ // u8: 1 byte per channel
+ v = static_cast(
+ line[ptrdiff_t(x) * channels + c]);
+ } else {
+ fclose(f);
+ throw std::string("unsupported frame pixel format " +
+ std::to_string(format));
+ }
+ row[size_t(x) * 3 + c] = v;
+ }
+ }
+ fwrite(row.data(), 1, row.size(), f);
+ }
+ fclose(f);
+}
+
+void write_u16_le(FILE *f, uint16_t v)
+{
+ fputc(v & 0xFF, f);
+ fputc((v >> 8) & 0xFF, f);
+}
+
+void write_u32_le(FILE *f, uint32_t v)
+{
+ write_u16_le(f, uint16_t(v & 0xFFFF));
+ write_u16_le(f, uint16_t(v >> 16));
+}
+
+// Planar float samples -> interleaved PCM s16 WAV.
+void write_wav(const OakEngineAudioBuffer *audio, const std::string &path)
+{
+ const int rate = oakengine_audio_sample_rate(audio);
+ const int channels = oakengine_audio_channel_count(audio);
+ const int64_t samples = oakengine_audio_sample_count(audio);
+
+ FILE *f = fopen(path.c_str(), "wb");
+ if (!f) {
+ throw std::string("cannot open \"" + path + "\" for writing");
+ }
+
+ const uint32_t data_size = uint32_t(samples) * uint32_t(channels) * 2;
+ fwrite("RIFF", 1, 4, f);
+ write_u32_le(f, 36 + data_size);
+ fwrite("WAVE", 1, 4, f);
+ fwrite("fmt ", 1, 4, f);
+ write_u32_le(f, 16); // fmt chunk size
+ write_u16_le(f, 1); // PCM
+ write_u16_le(f, uint16_t(channels));
+ write_u32_le(f, uint32_t(rate));
+ write_u32_le(f, uint32_t(rate * channels * 2)); // byte rate
+ write_u16_le(f, uint16_t(channels * 2)); // block align
+ write_u16_le(f, 16); // bits per sample
+ fwrite("data", 1, 4, f);
+ write_u32_le(f, data_size);
+
+ for (int64_t i = 0; i < samples; i++) {
+ for (int ch = 0; ch < channels; ch++) {
+ const float *channel_data = oakengine_audio_data(audio, ch);
+ const float v = channel_data[i];
+ const float clamped = v < -1.0f ? -1.0f : v > 1.0f ? 1.0f : v;
+ const int16_t s = static_cast(clamped * 32767.0f);
+ write_u16_le(f, uint16_t(s));
+ }
+ }
+ fclose(f);
+}
+
+int renderer_fail(OakEngineRenderer *renderer, const char *what)
+{
+ char err[1024];
+ if (oakengine_renderer_last_error(renderer, err, sizeof(err)) > 0) {
+ fprintf(stderr, "error: %s failed: %s\n", what, err);
+ } else {
+ fprintf(stderr, "error: %s failed\n", what);
+ }
+ return k_exit_render_unavailable;
+}
+
+int cmd_render(const char *path, const char *start_str, const char *end_str,
+ const char *out_dir)
+{
+ char *end = nullptr;
+ const double start_seconds = std::strtod(start_str, &end);
+ if (end == start_str || *end != '\0') {
+ fprintf(stderr, "error: invalid start seconds \"%s\"\n", start_str);
+ return k_exit_usage;
+ }
+ const double end_seconds = std::strtod(end_str, &end);
+ if (end == end_str || *end != '\0' || end_seconds <= start_seconds) {
+ fprintf(stderr, "error: invalid end seconds \"%s\"\n", end_str);
+ return k_exit_usage;
+ }
+
+ if (oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) !=
+ OAKENGINE_OK) {
+ fprintf(stderr, "error: failed to initialize the engine\n");
+ return k_exit_error;
+ }
+
+ int rc = k_exit_ok;
+ OakEngineProject *project = nullptr;
+ OakEngineRenderer *renderer = nullptr;
+ do {
+ // Footage paths are stored relative to the project file, and the
+ // engine probes/decodes them against the process cwd (both when the
+ // project loads and when audio renders in-process), so resolve from
+ // the project's directory -- the same rule as
+ // oakengine_project_footage_is_online(). The project path itself is
+ // made absolute first so it survives the chdir.
+ std::error_code ec;
+ const std::filesystem::path abs_project =
+ std::filesystem::absolute(std::filesystem::path(path), ec);
+ if (ec) {
+ fprintf(stderr, "error: cannot resolve \"%s\": %s\n", path,
+ ec.message().c_str());
+ rc = k_exit_error;
+ break;
+ }
+ const std::filesystem::path project_dir = abs_project.parent_path();
+ if (!project_dir.empty()) {
+ std::filesystem::current_path(project_dir, ec);
+ }
+
+ project = oakengine_project_create();
+ char err[1024];
+ if (oakengine_project_load(project, abs_project.string().c_str(), err,
+ sizeof(err)) != OAKENGINE_OK) {
+ fprintf(stderr, "error: failed to load \"%s\": %s\n", path, err);
+ rc = k_exit_error;
+ break;
+ }
+
+ if (oakengine_project_sequence_count(project) < 1) {
+ fprintf(stderr, "error: project has no sequence to render\n");
+ rc = k_exit_error;
+ break;
+ }
+ OakEngineSequence *seq = oakengine_project_sequence_at(project, 0);
+
+ int fr_num = 0, fr_den = 0;
+ if (oakengine_sequence_get_frame_rate(seq, &fr_num, &fr_den) !=
+ OAKENGINE_OK ||
+ fr_num <= 0 || fr_den <= 0) {
+ fprintf(stderr, "error: sequence has no valid frame rate\n");
+ rc = k_exit_error;
+ break;
+ }
+ const double fps = double(fr_num) / double(fr_den);
+ const int64_t start_ts = std::llround(start_seconds * fps);
+ const int64_t end_ts = std::llround(end_seconds * fps);
+ const int64_t frame_count = end_ts - start_ts;
+
+ std::filesystem::create_directories(out_dir, ec);
+ if (ec) {
+ fprintf(stderr, "error: cannot create output directory \"%s\": %s\n",
+ out_dir, ec.message().c_str());
+ rc = k_exit_error;
+ break;
+ }
+
+ renderer = oakengine_renderer_create(seq, k_render_width,
+ k_render_height, k_pixel_format_f32,
+ fr_num, fr_den, nullptr);
+ if (!renderer) {
+ fprintf(stderr, "error: failed to create renderer\n");
+ rc = k_exit_error;
+ break;
+ }
+
+ try {
+ for (int64_t ts = start_ts; ts < end_ts; ts++) {
+ OakEngineFrame *frame =
+ oakengine_renderer_render_frame(renderer, ts);
+ if (!frame) {
+ rc = renderer_fail(renderer, "render_frame");
+ break;
+ }
+ fprintf(stderr, "frame %lld/%lld (ts=%lld)\n",
+ (long long)(ts - start_ts + 1), (long long)frame_count,
+ (long long)ts);
+
+ char name[64];
+ snprintf(name, sizeof(name), "frame-%04lld.ppm",
+ (long long)(ts - start_ts));
+ const std::filesystem::path ppm_path =
+ std::filesystem::path(out_dir) / name;
+ write_ppm(frame, ppm_path.string());
+ oakengine_frame_free(frame);
+ }
+ } catch (const std::string &e) {
+ fprintf(stderr, "error: %s\n", e.c_str());
+ if (rc == k_exit_ok) {
+ rc = k_exit_error;
+ }
+ }
+
+ if (rc == k_exit_ok) {
+ OakEngineAudioBuffer *audio = oakengine_renderer_render_audio(
+ renderer, start_ts, frame_count);
+ if (!audio) {
+ rc = renderer_fail(renderer, "render_audio");
+ } else {
+ try {
+ write_wav(audio,
+ (std::filesystem::path(out_dir) / "audio.wav")
+ .string());
+ } catch (const std::string &e) {
+ fprintf(stderr, "error: %s\n", e.c_str());
+ rc = k_exit_error;
+ }
+ oakengine_audio_free(audio);
+ }
+ }
+
+ if (rc == k_exit_ok) {
+ printf("wrote %lld PPM frame(s) and audio.wav to \"%s\"\n",
+ (long long)frame_count, out_dir);
+ }
+ } while (false);
+
+ oakengine_renderer_free(renderer);
+ oakengine_project_free(project);
+ oakengine_shutdown();
+ return rc;
+}
+
+} // namespace
+
+int main(int argc, char *argv[])
+{
+ if (argc < 2) {
+ print_usage(stderr);
+ return k_exit_usage;
+ }
+
+ const std::string command = argv[1];
+ if (command == "--help" || command == "-h") {
+ print_usage(stdout);
+ return k_exit_ok;
+ }
+ if (command == "info") {
+ if (argc != 3) {
+ print_usage(stderr);
+ return k_exit_usage;
+ }
+ return cmd_info(argv[2]);
+ }
+ if (command == "render") {
+ if (argc != 6) {
+ print_usage(stderr);
+ return k_exit_usage;
+ }
+ return cmd_render(argv[2], argv[3], argv[4], argv[5]);
+ }
+
+ fprintf(stderr, "error: unknown command \"%s\"\n", argv[1]);
+ print_usage(stderr);
+ return k_exit_usage;
+}
diff --git a/engine/src/capi/project.cpp b/engine/src/capi/project.cpp
index e818bd305..3e28b3929 100644
--- a/engine/src/capi/project.cpp
+++ b/engine/src/capi/project.cpp
@@ -193,7 +193,12 @@ int oakengine_project_load(OakEngineProject *self, const char *path,
return OAKENGINE_E_STATE;
}
- const QString filename = QString::fromUtf8(path);
+ // Normalize to an absolute path so the stored filename matches the
+ // file's saved_url; otherwise the footage validator treats the project
+ // as moved (absolute saved_url vs. relative filename) and rewrites
+ // relative footage paths against the caller's cwd.
+ const QString filename =
+ QFileInfo(QString::fromUtf8(path)).absoluteFilePath();
project->set_filename(filename);
olive::ProjectSerializer::Result result = olive::ProjectSerializer::load(
diff --git a/tests/project_with_footage.ove b/tests/project_with_footage.ove
index 70f50796a..1284fc928 100644
--- a/tests/project_with_footage.ove
+++ b/tests/project_with_footage.ove
@@ -2,9 +2,9 @@
- {b9147285-72ca-4b6b-b980-ce1dfbec408f}
+ {9f610139-af81-4c3f-a689-caeac18ffa5e}
-
+
@@ -35,21 +35,21 @@
-
+
-
+
-
-
- {3f699391-032f-4b27-9ab9-297faa2781ba}
- {a11aa028-d950-460f-a7a6-d71df445ce68}
+
+
+ {a97d0036-322c-4e52-800b-4d97d49ccaa9}
+ {fa87a9bb-5f9d-451d-be93-5ba6ee738f33}
-