diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 48772ea18..e2373e837 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -60,16 +60,26 @@ set_target_properties(oakengine PROPERTIES ) # Consumers resolve engine headers ("node/...", "render/...", "coreengine.h", -# "ui/icons/icons.h", "tool/tool.h") from the engine root +# "ui/icons/icons.h", "tool/tool.h") from the engine root, and the public C +# API ("oakengine/ipc.h") from include/. The library itself builds against its +# internal implementation headers under src/oliveimpl, included with an +# "oliveimpl/"-prefixed path resolved from src/ (mirrors the src/oliveimpl +# comment in core/CMakeLists.txt). A plain include-order trick like core's +# cannot work here: CMAKE_INCLUDE_CURRENT_DIR puts the engine root ahead of +# every target-level directory, so the prefixed paths are what keep internal +# sources from ever picking up a consumer wrapper header by accident. target_include_directories(oakengine + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${CMAKE_SOURCE_DIR}/third_party/openfx/include ${CMAKE_SOURCE_DIR}/third_party/openfx/HostSupport/include ${OLIVE_INCLUDE_DIRS} ) target_link_libraries(oakengine PUBLIC ${OLIVE_LIBRARIES} OfxHost) -target_compile_definitions(oakengine PRIVATE ${OLIVE_DEFINITIONS}) +# OAKENGINE_BUILD marks the library side of the C ABI export macros (dllexport) +target_compile_definitions(oakengine PRIVATE ${OLIVE_DEFINITIONS} OAKENGINE_BUILD) target_compile_options(oakengine PRIVATE ${OLIVE_COMPILE_OPTIONS}) # Install into the platform's standard library directory (/usr/lib, @@ -193,3 +203,26 @@ if (OAK_ENABLE_DYNAMIC_RENDER_BACKEND) target_compile_options(oakvulkan-cabi-check PRIVATE ${OLIVE_COMPILE_OPTIONS}) endif () endif () + +# Pure C ABI tests for the liboakengine public interface (mirrors the +# make_test pattern in core/CMakeLists.txt). They link only against oakengine +# and must not depend on OpenGL/Vulkan. +if (BUILD_TESTS) + enable_testing() + + function(make_oakengine_test name) + # oakengine leaves olive::k_app_version to the final executable; the + # version object library provides it (same as worker/CMakeLists.txt). + add_executable(${name} + tests/${name}.cpp + $ + ) + target_link_libraries(${name} PRIVATE oakengine) + target_include_directories(${name} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + ) + add_test(${name} ${name}) + endfunction() + + make_oakengine_test(oakengine_ipc_test) +endif () diff --git a/engine/include/oakengine/export.h b/engine/include/oakengine/export.h new file mode 100644 index 000000000..608840ce6 --- /dev/null +++ b/engine/include/oakengine/export.h @@ -0,0 +1,45 @@ +/*** + + 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 . + +***/ + +#ifndef OAKENGINE_EXPORT_H +#define OAKENGINE_EXPORT_H + +/** + * @file export.h + * @brief Symbol visibility macros for liboakengine + * + * liboakengine is transitioning to a pure C ABI: every public C function is + * declared with OAKENGINE_API. While the migration is in flight the library + * is still built with default symbol visibility, so the legacy C++ symbols + * remain exported alongside the C ABI. + */ +#if defined(_WIN32) || defined(__CYGWIN__) + #ifdef OAKENGINE_BUILD + #define OAKENGINE_API __declspec(dllexport) + #else + #define OAKENGINE_API __declspec(dllimport) + #endif +#elif defined(__GNUC__) || defined(__clang__) + #define OAKENGINE_API __attribute__((visibility("default"))) +#else + #define OAKENGINE_API +#endif + +#endif /* OAKENGINE_EXPORT_H */ diff --git a/engine/include/oakengine/ipc.h b/engine/include/oakengine/ipc.h new file mode 100644 index 000000000..df5ce987b --- /dev/null +++ b/engine/include/oakengine/ipc.h @@ -0,0 +1,431 @@ +/*** + + 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 . + +***/ + +#ifndef OAKENGINE_IPC_H +#define OAKENGINE_IPC_H + +#include +#include + +#include "export.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file ipc.h + * @brief C ABI for the render worker IPC subsystem + * + * Two channels are covered: + * + * - Bulk data: named shared-memory segments (OakSharedMemoryRegion) holding + * a fixed-size pool of frame slots (OakFrameSlotPool). The in-memory + * layout produced by oakengine_ipc_framepool_create() is the wire + * protocol (version 1) shared with the render worker binary and never + * changes. oak_frame_slot_meta is the POD metadata record that lives in + * that shared layout, so it is exposed here as a plain C struct. + * + * - Control plane: newline-delimited JSON messages exchanged over stdio + * pipes. No Qt types cross this boundary; each message type has a POD + * struct plus build/parse functions converting between the struct and a + * compact JSON string. + * + * Conventions: + * - Returned Oak* handles are owned by the caller and must be released + * with the matching _free(). NULL is accepted by every function and + * yields a no-op / zero result. + * - Booleans are int (1/0). Fallible parses return 1 on success, 0 on + * failure. + * - String output uses the buf/size convention: the return value is the + * number of characters that would have been written excluding the NUL, + * so buf == NULL or a short buffer queries the required size. The + * output is NUL-terminated whenever buf_size > 0. + * - POD message structs carry strings in fixed-capacity inline buffers so + * they stay trivially copyable; overlong input is truncated at the + * capacity. + */ + +/** @brief Capacity of oak_frame_slot_meta::colorspace, including the NUL. */ +#define OAK_IPC_COLORSPACE_CAP 128 +/** @brief Capacity of shared-memory key string fields, including the NUL. */ +#define OAK_IPC_SHM_KEY_CAP 128 +/** @brief Capacity of oak_ipc_render_frame::node_uuid, including the NUL. */ +#define OAK_IPC_NODE_UUID_CAP 64 +/** @brief Capacity of the color transform name fields, including the NUL. */ +#define OAK_IPC_COLOR_STR_CAP 128 +/** @brief Capacity of oak_ipc_load_graph::path, including the NUL. */ +#define OAK_IPC_PATH_CAP 1024 +/** @brief Maximum number of decoded input slots carried by one render_frame. */ +#define OAK_IPC_INPUT_SLOTS_CAP 64 +/** @brief Capacity of the message buffer filled by oakengine_ipc_error_parse. */ +#define OAK_IPC_ERROR_MESSAGE_CAP 512 + +/** + * @brief Message type strings on the control-plane wire format. + * + * Every control message is a compact JSON object on one line whose "type" + * field carries one of these values. graph_update is reserved (no payload + * struct is defined yet). + */ +#define OAKENGINE_IPC_MSGTYPE_HANDSHAKE "handshake" +#define OAKENGINE_IPC_MSGTYPE_LOAD_GRAPH "load_graph" +#define OAKENGINE_IPC_MSGTYPE_RENDER_FRAME "render_frame" +#define OAKENGINE_IPC_MSGTYPE_FRAME_READY "frame_ready" +#define OAKENGINE_IPC_MSGTYPE_CANCEL "cancel" +#define OAKENGINE_IPC_MSGTYPE_GRAPH_UPDATE "graph_update" +#define OAKENGINE_IPC_MSGTYPE_SHUTDOWN "shutdown" +#define OAKENGINE_IPC_MSGTYPE_ERROR "error" + +/** + * @brief Message type discriminator mirroring the msgtype strings. + * + * Same-named values for each wire message type; returned by + * oakengine_ipc_message_type() so a C consumer can dispatch an incoming JSON + * line without hard-coding the strings. + */ +typedef enum oak_ipc_msgtype { + OAK_IPC_MSGTYPE_UNKNOWN = -1, + OAK_IPC_MSGTYPE_HANDSHAKE = 0, + OAK_IPC_MSGTYPE_LOAD_GRAPH, + OAK_IPC_MSGTYPE_RENDER_FRAME, + OAK_IPC_MSGTYPE_FRAME_READY, + OAK_IPC_MSGTYPE_CANCEL, + OAK_IPC_MSGTYPE_GRAPH_UPDATE, + OAK_IPC_MSGTYPE_SHUTDOWN, + OAK_IPC_MSGTYPE_ERROR +} oak_ipc_msgtype; + +/** + * @brief Open mode for oakengine_ipc_shm_open(). + */ +typedef enum oak_ipc_shm_mode { + /** Create (and own) the segment. Fails if it exists; unlinks on close. */ + OAK_IPC_SHM_MODE_CREATE = 0, + /** Attach to a segment created by the peer. Does not unlink on close. */ + OAK_IPC_SHM_MODE_ATTACH = 1 +} oak_ipc_shm_mode; + +/** + * @brief Per-slot metadata describing the frame currently occupying a slot. + * + * Trivially-copyable POD that lives in shared memory alongside the pixel + * data, part of the version-1 wire protocol between the app and the render + * worker. Carries everything the consumer needs to reconstruct a frame + * without any out-of-band information. The timestamp is stored as an + * explicit numerator/denominator pair to stay POD. + */ +typedef struct oak_frame_slot_meta { + int64_t id; /**< Caller-defined tag (e.g. ticket id, or footage stream hash). */ + int64_t time_num; /**< Frame timestamp numerator. */ + int64_t time_den; /**< Frame timestamp denominator. */ + int32_t width; + int32_t height; + int32_t format; /**< PixelFormat::Format value. */ + int32_t channel_count; + int32_t linesize; /**< Bytes per scanline (stride). */ + int32_t data_size; /**< Valid bytes written into the slot's data block. */ + char colorspace[OAK_IPC_COLORSPACE_CAP]; /**< Input colorspace name. */ +} oak_frame_slot_meta; + +typedef struct OakSharedMemoryRegion OakSharedMemoryRegion; +typedef struct OakFrameSlotPool OakFrameSlotPool; + +/* ---- SharedMemoryRegion ------------------------------------------------- */ + +/** + * @brief Allocate an empty (invalid) region object. Owned by the caller. + */ +OAKENGINE_API OakSharedMemoryRegion *oakengine_ipc_shm_create(void); +OAKENGINE_API void oakengine_ipc_shm_free(OakSharedMemoryRegion *self); + +/** + * @brief Open the segment identified by `key` with the given `size` in bytes. + * + * `key` is a short identifier (no leading slash needed; the platform prefix + * is added internally). Returns 1 on success; on failure returns 0 and + * oakengine_ipc_shm_error() carries a human-readable reason. + */ +OAKENGINE_API int oakengine_ipc_shm_open(OakSharedMemoryRegion *self, + const char *key, size_t size, + oak_ipc_shm_mode mode); + +/** + * @brief Unmap and (if owner) unlink the segment. Also done by _free(). + */ +OAKENGINE_API void oakengine_ipc_shm_close(OakSharedMemoryRegion *self); + +OAKENGINE_API int oakengine_ipc_shm_is_valid(const OakSharedMemoryRegion *self); +OAKENGINE_API void *oakengine_ipc_shm_data(OakSharedMemoryRegion *self); +OAKENGINE_API size_t oakengine_ipc_shm_size(const OakSharedMemoryRegion *self); + +/** + * @brief The key the region was opened with (buf/size convention). + */ +OAKENGINE_API int oakengine_ipc_shm_key(const OakSharedMemoryRegion *self, + char *buf, int buf_size); + +/** + * @brief Human-readable reason for the last failed open (buf/size convention). + */ +OAKENGINE_API int oakengine_ipc_shm_error(const OakSharedMemoryRegion *self, + char *buf, int buf_size); + +/** + * @brief Build a unique segment key for a worker, e.g. "olive-rw--". + * + * Centralized so the owner and the spawned worker agree on the same name. + * Uses the buf/size convention. + */ +OAKENGINE_API int oakengine_ipc_shm_make_key(int64_t owner_pid, + int worker_index, char *buf, + int buf_size); + +/* ---- FrameSlotPool ------------------------------------------------------ */ + +/** + * @brief Total bytes a region must provide to back a pool of `slot_count` x + * `slot_data_bytes`. + */ +OAKENGINE_API size_t +oakengine_ipc_framepool_bytes_needed(uint32_t slot_count, + size_t slot_data_bytes); + +/** + * @brief Lay out and initialize a brand-new pool over `mem` (owner side, once). + * + * `mem` must provide at least oakengine_ipc_framepool_bytes_needed() bytes + * and must outlive the returned handle. The handle is owned by the caller; + * it does not own `mem`. + */ +OAKENGINE_API OakFrameSlotPool * +oakengine_ipc_framepool_create(void *mem, uint32_t slot_count, + size_t slot_data_bytes); + +/** + * @brief Map an existing, already-initialized pool (peer side). + * + * Reads the geometry from the in-memory header written by _create(); the + * handle reports is_valid() == 0 if the magic does not match. + */ +OAKENGINE_API OakFrameSlotPool *oakengine_ipc_framepool_attach(void *mem); + +/** + * @brief Copy the view (same shared memory, independent handle). Owned. + */ +OAKENGINE_API OakFrameSlotPool * +oakengine_ipc_framepool_copy(const OakFrameSlotPool *self); + +OAKENGINE_API void oakengine_ipc_framepool_free(OakFrameSlotPool *self); + +OAKENGINE_API int oakengine_ipc_framepool_is_valid(const OakFrameSlotPool *self); +OAKENGINE_API uint32_t +oakengine_ipc_framepool_slot_count(const OakFrameSlotPool *self); +OAKENGINE_API size_t +oakengine_ipc_framepool_slot_data_bytes(const OakFrameSlotPool *self); + +/* Filler side: acquire a free slot, write meta + pixels, publish it. */ + +/** + * @brief Take ownership of a free slot. Returns 0 (leaving *index untouched) + * if none is free. + */ +OAKENGINE_API int oakengine_ipc_framepool_acquire(OakFrameSlotPool *self, + uint32_t *index); + +/** + * @brief Pointer to a slot's pixel data block (slot_data_bytes available). + */ +OAKENGINE_API void *oakengine_ipc_framepool_slot_data(OakFrameSlotPool *self, + uint32_t index); +OAKENGINE_API const void * +oakengine_ipc_framepool_slot_data_const(const OakFrameSlotPool *self, + uint32_t index); + +/** + * @brief Mutable metadata for a slot. The filler writes this before publish. + * The returned pointer addresses shared memory; it is borrowed, not owned. + */ +OAKENGINE_API oak_frame_slot_meta * +oakengine_ipc_framepool_meta(OakFrameSlotPool *self, uint32_t index); +OAKENGINE_API const oak_frame_slot_meta * +oakengine_ipc_framepool_meta_const(const OakFrameSlotPool *self, + uint32_t index); + +/** + * @brief Publish a filled slot to the drainer. Must follow a successful + * acquire of `index`. + */ +OAKENGINE_API int oakengine_ipc_framepool_publish(OakFrameSlotPool *self, + uint32_t index); + +/* Drainer side: consume the next published slot, read it, release it. */ + +/** + * @brief Take the next published slot. Returns 0 if nothing is ready. + */ +OAKENGINE_API int oakengine_ipc_framepool_consume(OakFrameSlotPool *self, + uint32_t *index); + +/** + * @brief Return a consumed slot to the free pool. Must follow a consume of + * `index`. + */ +OAKENGINE_API int oakengine_ipc_framepool_release(OakFrameSlotPool *self, + uint32_t index); + +/* ---- Control-plane messages --------------------------------------------- */ + +/** + * @brief Negotiate protocol version and announce shared-memory key/geometry. + * + * Field-for-field equivalent of the C++ HandshakeMsg; strings are inline + * buffers with the documented capacities. + */ +typedef struct oak_ipc_handshake { + int32_t protocol_version; + char shm_key[OAK_IPC_SHM_KEY_CAP]; /**< Worker<-main output segment key. */ + char input_shm_key[OAK_IPC_SHM_KEY_CAP]; /**< Main->worker input key (optional). */ + int32_t input_slots; /**< Number of main->worker input frame slots. */ + int32_t output_slots; /**< Number of worker->main output frame slots. */ + int64_t slot_data_bytes; /**< Per-output-slot pixel block size. */ + int64_t input_slot_data_bytes; /**< Per-input-slot pixel block size. */ +} oak_ipc_handshake; + +/** + * @brief Request a frame render: node uuid, time, video params. + * + * Field-for-field equivalent of the C++ RenderFrameMsg. `input_slots` holds + * `input_slot_count` entries; the legacy scalar `input_slot` (-1 = none) is + * kept in sync by the parse fallback exactly like the Qt implementation. + */ +typedef struct oak_ipc_render_frame { + int64_t ticket_id; /**< Correlates with the eventual frame_ready. */ + char node_uuid[OAK_IPC_NODE_UUID_CAP]; /**< Viewer node stable uuid. */ + int64_t time_num; + int64_t time_den; + int32_t width; /**< Forced output size (0 = use graph default). */ + int32_t height; + int32_t format; /**< Forced PixelFormat::Format (-1 = default/INVALID). */ + int32_t channel_count; /**< 0 = default. */ + int32_t mode; /**< RenderMode::Mode. */ + int32_t input_slot; /**< Optional decoded input slot (-1 = none). */ + int32_t input_slots[OAK_IPC_INPUT_SLOTS_CAP]; /**< Ordered decoded input slots. */ + int32_t input_slot_count; /**< Number of valid input_slots entries. */ + /* Output color transform; ignored unless has_color_transform != 0. */ + int32_t has_color_transform; + int32_t color_is_display; + char color_output[OAK_IPC_COLOR_STR_CAP]; + char color_view[OAK_IPC_COLOR_STR_CAP]; + char color_look[OAK_IPC_COLOR_STR_CAP]; +} oak_ipc_render_frame; + +/** + * @brief A rendered frame is published; carries the output slot + ticket. + */ +typedef struct oak_ipc_frame_ready { + int64_t ticket_id; + int32_t output_slot; /**< Index into the worker->main output FrameSlotPool. */ +} oak_ipc_frame_ready; + +/** + * @brief Abandon an in-flight ticket by id. + */ +typedef struct oak_ipc_cancel { + int64_t ticket_id; +} oak_ipc_cancel; + +/** + * @brief Path to a temporary file holding the serialized node graph. + */ +typedef struct oak_ipc_load_graph { + char path[OAK_IPC_PATH_CAP]; +} oak_ipc_load_graph; + +/** + * @brief Identify the message type of one compact JSON line. + * + * Returns OAK_IPC_MSGTYPE_UNKNOWN for malformed JSON or an unrecognized + * "type" field. + */ +OAKENGINE_API oak_ipc_msgtype oakengine_ipc_message_type(const char *json); + +/** + * @brief Serialize to compact JSON (buf/size convention). Returns -1 if + * `self` is NULL. + */ +OAKENGINE_API int oakengine_ipc_handshake_to_json( + const oak_ipc_handshake *self, char *buf, int buf_size); +/** + * @brief Parse compact JSON. Returns 1 on success, 0 on type mismatch or + * malformed input. + */ +OAKENGINE_API int oakengine_ipc_handshake_parse(const char *json, + oak_ipc_handshake *out); + +OAKENGINE_API int oakengine_ipc_render_frame_to_json( + const oak_ipc_render_frame *self, char *buf, int buf_size); +OAKENGINE_API int oakengine_ipc_render_frame_parse(const char *json, + oak_ipc_render_frame *out); + +OAKENGINE_API int oakengine_ipc_frame_ready_to_json( + const oak_ipc_frame_ready *self, char *buf, int buf_size); +OAKENGINE_API int oakengine_ipc_frame_ready_parse(const char *json, + oak_ipc_frame_ready *out); + +OAKENGINE_API int oakengine_ipc_cancel_to_json(const oak_ipc_cancel *self, + char *buf, int buf_size); +OAKENGINE_API int oakengine_ipc_cancel_parse(const char *json, + oak_ipc_cancel *out); + +OAKENGINE_API int oakengine_ipc_load_graph_to_json( + const oak_ipc_load_graph *self, char *buf, int buf_size); +OAKENGINE_API int oakengine_ipc_load_graph_parse(const char *json, + oak_ipc_load_graph *out); + +/** + * @brief Build the payload-less shutdown message (buf/size convention). + */ +OAKENGINE_API int oakengine_ipc_shutdown_to_json(char *buf, int buf_size); +/** + * @brief Returns 1 if `json` is a shutdown message, 0 otherwise. + */ +OAKENGINE_API int oakengine_ipc_shutdown_parse(const char *json); + +/** + * @brief Build a worker-side error report with a human-readable message + * (buf/size convention). + */ +OAKENGINE_API int oakengine_ipc_error_to_json(const char *message, char *buf, + int buf_size); +/** + * @brief Parse an error message; the text is written into message_buf + * (truncated at OAK_IPC_ERROR_MESSAGE_CAP-style buf size). Returns 1 on + * success, 0 otherwise. + */ +OAKENGINE_API int oakengine_ipc_error_parse(const char *json, + char *message_buf, + int message_buf_size); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_IPC_H */ diff --git a/engine/render/ipc/spscringbuffer.h b/engine/include/oakengine/spscringbuffer.h similarity index 100% rename from engine/render/ipc/spscringbuffer.h rename to engine/include/oakengine/spscringbuffer.h diff --git a/engine/render/ipc/CMakeLists.txt b/engine/render/ipc/CMakeLists.txt index 1fa59d3f5..9096e09a9 100644 --- a/engine/render/ipc/CMakeLists.txt +++ b/engine/render/ipc/CMakeLists.txt @@ -14,14 +14,26 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# The IPC subsystem is migrating behind the liboakengine C ABI facade: +# - include/oakengine/*.h public C API (+ the inline SPSC header) +# - src/oliveimpl/render/ipc/*.h internal implementation headers +# - src/capi/ipc.cpp C ABI implementation +# - render/ipc/*.{h,cpp} implementation sources and the +# consumer-side C++ wrapper headers set(OLIVE_SOURCES ${OLIVE_SOURCES} + include/oakengine/export.h + include/oakengine/ipc.h + include/oakengine/spscringbuffer.h + src/capi/ipc.cpp + src/oliveimpl/render/ipc/frameslotpool.h + src/oliveimpl/render/ipc/ipcmessage.h + src/oliveimpl/render/ipc/sharedmemoryregion.h render/ipc/frameslotpool.cpp render/ipc/frameslotpool.h render/ipc/ipcmessage.cpp render/ipc/ipcmessage.h render/ipc/sharedmemoryregion.cpp render/ipc/sharedmemoryregion.h - render/ipc/spscringbuffer.h PARENT_SCOPE -) \ No newline at end of file +) diff --git a/engine/render/ipc/frameslotpool.cpp b/engine/render/ipc/frameslotpool.cpp index 9729d7f13..724ef3fc0 100644 --- a/engine/render/ipc/frameslotpool.cpp +++ b/engine/render/ipc/frameslotpool.cpp @@ -18,12 +18,16 @@ ***/ -#include "frameslotpool.h" +#include "oliveimpl/render/ipc/frameslotpool.h" #include namespace olive { +namespace engine +{ +namespace internal +{ namespace ipc { @@ -45,9 +49,9 @@ size_t FrameSlotPool::bytes_needed(uint32_t slot_count, size_t slot_data_bytes) const uint32_t ring_cap = ring_capacity(slot_count); size_t total = align_up(sizeof(Header), k_align); total += - align_up(SpscRingBuffer::bytes_needed(ring_cap), k_align); // free ring + align_up(olive::ipc::SpscRingBuffer::bytes_needed(ring_cap), k_align); // free ring total += - align_up(SpscRingBuffer::bytes_needed(ring_cap), k_align); // ready ring + align_up(olive::ipc::SpscRingBuffer::bytes_needed(ring_cap), k_align); // ready ring total += align_up(sizeof(FrameSlotMeta) * slot_count, k_align); // metadata array total += align_up(slot_data_bytes, k_align) * slot_count; // pixel data blocks @@ -67,10 +71,10 @@ FrameSlotPool FrameSlotPool::create(void *mem, uint32_t slot_count, offset += align_up(sizeof(Header), k_align); const size_t free_off = offset; - offset += align_up(SpscRingBuffer::bytes_needed(ring_cap), k_align); + offset += align_up(olive::ipc::SpscRingBuffer::bytes_needed(ring_cap), k_align); const size_t ready_off = offset; - offset += align_up(SpscRingBuffer::bytes_needed(ring_cap), k_align); + offset += align_up(olive::ipc::SpscRingBuffer::bytes_needed(ring_cap), k_align); const size_t meta_off = offset; offset += align_up(sizeof(FrameSlotMeta) * slot_count, k_align); @@ -86,8 +90,8 @@ FrameSlotPool FrameSlotPool::create(void *mem, uint32_t slot_count, pool.header_->meta_offset = meta_off; pool.header_->data_offset = data_off; - pool.free_ring_ = SpscRingBuffer::create(pool.base_ + free_off, ring_cap); - pool.ready_ring_ = SpscRingBuffer::create(pool.base_ + ready_off, ring_cap); + pool.free_ring_ = olive::ipc::SpscRingBuffer::create(pool.base_ + free_off, ring_cap); + pool.ready_ring_ = olive::ipc::SpscRingBuffer::create(pool.base_ + ready_off, ring_cap); pool.meta_ = reinterpret_cast(pool.base_ + meta_off); pool.data_ = pool.base_ + data_off; @@ -115,9 +119,9 @@ FrameSlotPool FrameSlotPool::attach(void *mem) } pool.free_ring_ = - SpscRingBuffer::attach(pool.base_ + pool.header_->free_ring_offset); + olive::ipc::SpscRingBuffer::attach(pool.base_ + pool.header_->free_ring_offset); pool.ready_ring_ = - SpscRingBuffer::attach(pool.base_ + pool.header_->ready_ring_offset); + olive::ipc::SpscRingBuffer::attach(pool.base_ + pool.header_->ready_ring_offset); pool.meta_ = reinterpret_cast(pool.base_ + pool.header_->meta_offset); pool.data_ = pool.base_ + pool.header_->data_offset; @@ -176,4 +180,6 @@ bool FrameSlotPool::release(uint32_t index) } } // namespace ipc +} // namespace internal +} // namespace engine } // namespace olive \ No newline at end of file diff --git a/engine/render/ipc/frameslotpool.h b/engine/render/ipc/frameslotpool.h index 27fb5c378..ff6b91a0c 100644 --- a/engine/render/ipc/frameslotpool.h +++ b/engine/render/ipc/frameslotpool.h @@ -24,7 +24,7 @@ #include #include -#include "spscringbuffer.h" +#include "oakengine/ipc.h" namespace olive { @@ -34,150 +34,196 @@ namespace ipc /** * @brief Per-slot metadata describing the frame currently occupying a slot. * - * Trivially-copyable POD that lives in shared memory alongside the pixel data. Carries everything - * the consumer needs to reconstruct an olive::Frame without any out-of-band information. We store - * the Rational timestamp as an explicit numerator/denominator pair to stay POD (olive::Rational is - * not guaranteed shared-memory-safe). + * Trivially-copyable POD that lives in shared memory alongside the pixel data, part of the + * version-1 wire protocol with the render worker. This is the C ABI oak_frame_slot_meta struct, + * aliased so the shared-memory layout is defined exactly once, in oakengine/ipc.h. */ -struct FrameSlotMeta { - int64_t id; ///< Caller-defined tag (e.g. ticket id, or footage stream hash). - int64_t time_num; ///< Frame timestamp numerator. - int64_t time_den; ///< Frame timestamp denominator. - int32_t width; - int32_t height; - int32_t format; ///< olive::PixelFormat::Format value. - int32_t channel_count; - int32_t linesize; ///< Bytes per scanline (stride). - int32_t data_size; ///< Valid bytes written into the slot's data block. - char colorspace[128]; ///< Input colorspace name for color-managed footage. -}; +typedef oak_frame_slot_meta FrameSlotMeta; /** * @brief A fixed-size pool of equal-sized frame slots in shared memory, with lock-free hand-off. * + * Consumer-side wrapper over the liboakengine C ABI: the object only holds an opaque + * OakFrameSlotPool handle and forwards every call across the C boundary. The public API is + * unchanged from the original implementation; see oakengine/ipc.h for the protocol description. + * * One pool models a single direction of frame flow (e.g. worker -> main for rendered output, or - * main -> worker for decoded input). Ownership of a slot is transferred via two SPSC ring buffers - * of slot indices, so no mutex is ever taken: - * - * - free_ring: indices of slots available to the FILLER. The drainer returns slots here. - * - ready_ring: indices of slots holding a published frame, produced by the FILLER for the - * DRAINER to consume. - * - * Lifecycle (filler = producer of frames, drainer = consumer of frames): - * filler: Acquire() -> pop a free index -> write meta + pixels -> Publish() -> push to ready - * drainer: Consume() -> pop a ready index -> read meta + pixels -> Release() -> push to free - * - * Because each ring has exactly one producer and one consumer (the filler owns free.Pop + - * ready.Push, the drainer owns ready.Pop + free.Push), the SPSC invariant holds and the whole - * exchange is lock-free. - * - * All slots are sized to `slot_data_bytes`, computed for the maximum supported frame (e.g. 8K RGBA - * half-float). Frames smaller than that simply use a prefix of the slot. - * - * The pool does NOT own the memory; it is constructed over a SharedMemoryRegion mapping. Use - * BytesNeeded() to size that region. + * main -> worker for decoded input). The pool does NOT own the memory; it is constructed over a + * SharedMemoryRegion mapping. Use bytes_needed() to size that region. */ class FrameSlotPool { public: + FrameSlotPool() = default; + + FrameSlotPool(const FrameSlotPool &rhs) + : handle_(oakengine_ipc_framepool_copy(rhs.handle_)) + { + } + + FrameSlotPool(FrameSlotPool &&rhs) noexcept + : handle_(rhs.handle_) + { + rhs.handle_ = nullptr; + } + + ~FrameSlotPool() + { + oakengine_ipc_framepool_free(handle_); + } + + FrameSlotPool &operator=(const FrameSlotPool &rhs) + { + if (this != &rhs) { + oakengine_ipc_framepool_free(handle_); + handle_ = oakengine_ipc_framepool_copy(rhs.handle_); + } + return *this; + } + + FrameSlotPool &operator=(FrameSlotPool &&rhs) noexcept + { + if (this != &rhs) { + oakengine_ipc_framepool_free(handle_); + handle_ = rhs.handle_; + rhs.handle_ = nullptr; + } + return *this; + } + /** * @brief Total bytes a region must provide to back a pool of `slot_count` x `slot_data_bytes`. */ - static size_t bytes_needed(uint32_t slot_count, size_t slot_data_bytes); + static size_t bytes_needed(uint32_t slot_count, size_t slot_data_bytes) + { + return oakengine_ipc_framepool_bytes_needed(slot_count, slot_data_bytes); + } /** * @brief Lay out and initialize a brand-new pool over `mem` (owner side, once). * * Initializes both rings, seeds the free ring with every slot index, and zeroes metadata. - * `mem` must provide at least BytesNeeded(slot_count, slot_data_bytes) bytes. + * `mem` must provide at least bytes_needed(slot_count, slot_data_bytes) bytes. */ static FrameSlotPool create(void *mem, uint32_t slot_count, - size_t slot_data_bytes); + size_t slot_data_bytes) + { + return from_handle(oakengine_ipc_framepool_create(mem, slot_count, + slot_data_bytes)); + } /** * @brief Map an existing, already-initialized pool (peer side). * - * Reads slot_count/slot_data_bytes from the in-memory header written by Create(). + * Reads slot_count/slot_data_bytes from the in-memory header written by create(). */ - static FrameSlotPool attach(void *mem); + static FrameSlotPool attach(void *mem) + { + return from_handle(oakengine_ipc_framepool_attach(mem)); + } bool is_valid() const { - return header_ != nullptr; + return oakengine_ipc_framepool_is_valid(handle_) != 0; } - uint32_t slot_count() const; - size_t slot_data_bytes() const; + uint32_t slot_count() const + { + return oakengine_ipc_framepool_slot_count(handle_); + } + + size_t slot_data_bytes() const + { + return oakengine_ipc_framepool_slot_data_bytes(handle_); + } // ---- Filler side ---- /** * @brief Take ownership of a free slot. Returns false (and leaves *index untouched) if none free. */ - bool acquire(uint32_t *index); + bool acquire(uint32_t *index) + { + return oakengine_ipc_framepool_acquire(handle_, index) != 0; + } /** * @brief Pointer to a slot's pixel data block (slot_data_bytes available). */ - void *slot_data(uint32_t index); + void *slot_data(uint32_t index) + { + return oakengine_ipc_framepool_slot_data(handle_, index); + } /** - * @brief Mutable metadata for a slot. Filler writes this before Publish(). + * @brief Mutable metadata for a slot. Filler writes this before publish(). */ - FrameSlotMeta *meta(uint32_t index); + FrameSlotMeta *meta(uint32_t index) + { + return oakengine_ipc_framepool_meta(handle_, index); + } /** - * @brief Publish a filled slot to the drainer. Must follow a successful Acquire() of `index`. + * @brief Publish a filled slot to the drainer. Must follow a successful acquire() of `index`. */ - bool publish(uint32_t index); + bool publish(uint32_t index) + { + return oakengine_ipc_framepool_publish(handle_, index) != 0; + } // ---- Drainer side ---- /** * @brief Take the next published slot. Returns false if nothing is ready. */ - bool consume(uint32_t *index); - - /** - * @brief Return a consumed slot to the free pool for reuse. Must follow Consume() of `index`. - */ - bool release(uint32_t index); - - const FrameSlotMeta *meta(uint32_t index) const; - const void *slot_data(uint32_t index) const; - -public: - FrameSlotPool() = default; - -private: - struct Header { - uint32_t magic; - uint32_t slot_count; - uint64_t slot_data_bytes; - // Byte offsets from the start of the segment to each sub-region. - uint64_t free_ring_offset; - uint64_t ready_ring_offset; - uint64_t meta_offset; - uint64_t data_offset; - }; - - static constexpr uint32_t k_magic = 0x4F4B5350; // 'OKSP' - - // Ring capacity must exceed slot_count by one because a ring can hold at most capacity-1 entries - // and we need to be able to enqueue every slot at once. - static uint32_t ring_capacity(uint32_t slot_count) + bool consume(uint32_t *index) { - return slot_count + 1; + return oakengine_ipc_framepool_consume(handle_, index) != 0; } - uint8_t *base_ = nullptr; - Header *header_ = nullptr; - SpscRingBuffer *free_ring_ = nullptr; - SpscRingBuffer *ready_ring_ = nullptr; - FrameSlotMeta *meta_ = nullptr; - uint8_t *data_ = nullptr; + /** + * @brief Return a consumed slot to the free pool for reuse. Must follow consume() of `index`. + */ + bool release(uint32_t index) + { + return oakengine_ipc_framepool_release(handle_, index) != 0; + } + + const FrameSlotMeta *meta(uint32_t index) const + { + return oakengine_ipc_framepool_meta_const(handle_, index); + } + + const void *slot_data(uint32_t index) const + { + return oakengine_ipc_framepool_slot_data_const(handle_, index); + } + + /** + * @brief The wrapped C handle, for cross-type wrappers and direct C API use + */ + OakFrameSlotPool *handle() const + { + return handle_; + } + + /** + * @brief Wraps an owned C handle (takes ownership) + */ + static FrameSlotPool from_handle(OakFrameSlotPool *handle) + { + return FrameSlotPool(handle); + } + +private: + explicit FrameSlotPool(OakFrameSlotPool *handle) + : handle_(handle) + { + } + + OakFrameSlotPool *handle_ = nullptr; }; } // namespace ipc } // namespace olive -#endif // OAK_IPC_FRAMESLOTPOOL_H \ No newline at end of file +#endif // OAK_IPC_FRAMESLOTPOOL_H diff --git a/engine/render/ipc/ipcmessage.cpp b/engine/render/ipc/ipcmessage.cpp index 0309ad2f3..de52bb72f 100644 --- a/engine/render/ipc/ipcmessage.cpp +++ b/engine/render/ipc/ipcmessage.cpp @@ -18,7 +18,7 @@ ***/ -#include "ipcmessage.h" +#include "oliveimpl/render/ipc/ipcmessage.h" #include #include @@ -26,6 +26,10 @@ namespace olive { +namespace engine +{ +namespace internal +{ namespace ipc { @@ -227,4 +231,6 @@ bool LoadGraphMsg::from_json(const QJsonObject &o, LoadGraphMsg *out) } } // namespace ipc +} // namespace internal +} // namespace engine } // namespace olive diff --git a/engine/render/ipc/ipcmessage.h b/engine/render/ipc/ipcmessage.h index 4cd40e912..ab2a0ac8e 100644 --- a/engine/render/ipc/ipcmessage.h +++ b/engine/render/ipc/ipcmessage.h @@ -21,12 +21,17 @@ #ifndef OAK_IPC_IPCMESSAGE_H #define OAK_IPC_IPCMESSAGE_H +#include #include +#include #include +#include #include #include #include +#include "oakengine/ipc.h" + class QIODevice; namespace olive @@ -43,6 +48,10 @@ namespace ipc * bulk pixel data travels through the shared-memory FrameSlotPool, and the (potentially large) * serialized node graph travels via a temporary file referenced by path. * + * Consumer-side wrapper over the liboakengine C ABI: the typed builders/parsers below convert + * through the oakengine_ipc_*_to_json/parse functions, and the QIODevice framing stays inline + * here. The public API is unchanged from the original implementation. + * * Every message object has a "type" string field. Directionality (M = main, W = worker): * "handshake" M<->W Negotiate protocol version and announce shared-memory key/geometry. * "load_graph" M ->W Path to a temporary file holding the serialized node graph. @@ -55,23 +64,58 @@ namespace ipc */ namespace msgtype { -constexpr const char *k_handshake = "handshake"; -constexpr const char *k_load_graph = "load_graph"; -constexpr const char *k_render_frame = "render_frame"; -constexpr const char *k_frame_ready = "frame_ready"; -constexpr const char *k_cancel = "cancel"; -constexpr const char *k_graph_update = "graph_update"; -constexpr const char *k_shutdown = "shutdown"; -constexpr const char *k_error = "error"; +constexpr const char *k_handshake = OAKENGINE_IPC_MSGTYPE_HANDSHAKE; +constexpr const char *k_load_graph = OAKENGINE_IPC_MSGTYPE_LOAD_GRAPH; +constexpr const char *k_render_frame = OAKENGINE_IPC_MSGTYPE_RENDER_FRAME; +constexpr const char *k_frame_ready = OAKENGINE_IPC_MSGTYPE_FRAME_READY; +constexpr const char *k_cancel = OAKENGINE_IPC_MSGTYPE_CANCEL; +constexpr const char *k_graph_update = OAKENGINE_IPC_MSGTYPE_GRAPH_UPDATE; +constexpr const char *k_shutdown = OAKENGINE_IPC_MSGTYPE_SHUTDOWN; +constexpr const char *k_error = OAKENGINE_IPC_MSGTYPE_ERROR; } // namespace msgtype +namespace detail +{ + +inline void copy_str(const QString &s, char *dst, size_t cap) +{ + const QByteArray utf = s.toUtf8(); + const size_t n = std::min(size_t(utf.size()), cap - 1); + memcpy(dst, utf.constData(), n); + dst[n] = '\0'; +} + +/** + * @brief Run a C to_json function (buf/size convention) and reparse the compact JSON text. + */ +template QJsonObject via_c_json(F &&to_json) +{ + const int size = to_json(nullptr, 0); + QByteArray buf(size + 1, '\0'); + to_json(buf.data(), size + 1); + buf.resize(size); + return QJsonDocument::fromJson(buf).object(); +} + +inline QByteArray compact_json(const QJsonObject &o) +{ + return QJsonDocument(o).toJson(QJsonDocument::Compact); +} + +} // namespace detail + /** * @brief Write one NDJSON message line to `device`. * * Serializes `obj` to compact JSON, appends '\n', and writes the whole line in one call. Returns * true only if the full line was written. */ -bool write_message(QIODevice *device, const QJsonObject &obj); +inline bool write_message(QIODevice *device, const QJsonObject &obj) +{ + QByteArray line = detail::compact_json(obj); + line.append('\n'); + return device->write(line) == line.size(); +} /** * @brief Pull one complete NDJSON line out of `buffer` and parse it. @@ -82,13 +126,46 @@ bool write_message(QIODevice *device, const QJsonObject &obj); * and continue rather than wedge. Supports the typical "append bytes as they arrive, then drain * complete lines" reader loop on a pipe. */ -bool read_message(QByteArray *buffer, QJsonObject *out, bool *ok = nullptr); +inline bool read_message(QByteArray *buffer, QJsonObject *out, bool *ok = nullptr) +{ + while (true) { + const int newline = buffer->indexOf('\n'); + if (newline < 0) { + // No complete line buffered yet. + return false; + } + + const QByteArray line = buffer->left(newline); + buffer->remove(0, newline + 1); + + // Skip blank lines silently (e.g. a stray newline) without flagging an error. + if (line.trimmed().isEmpty()) { + continue; + } + + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(line, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + if (ok) { + *ok = false; + } + return false; + } + + *out = doc.object(); + if (ok) { + *ok = true; + } + return true; + } +} // ---- Typed message builders / parsers ------------------------------------------------------- // -// Thin helpers that construct or read the QJsonObject for each message type, keeping field names in -// one place so main and worker agree. Fields use plain JSON numbers/strings; 64-bit ids are stored -// as JSON numbers (doubles exactly represent integers up to 2^53, ample for our counters). +// Thin wrappers that convert each struct to/from the C ABI POD form and let the library build or +// read the JSON, keeping field names in one place so main and worker agree. Fields use plain JSON +// numbers/strings; 64-bit ids are stored as JSON numbers (doubles exactly represent integers up +// to 2^53, ample for our counters). struct HandshakeMsg { int protocol_version = 0; @@ -100,8 +177,38 @@ struct HandshakeMsg { qint64 slot_data_bytes = 0; ///< Per-output-slot pixel block size. qint64 input_slot_data_bytes = 0; ///< Per-input-slot pixel block size. - QJsonObject to_json() const; - static bool from_json(const QJsonObject &o, HandshakeMsg *out); + QJsonObject to_json() const + { + oak_ipc_handshake c; + c.protocol_version = protocol_version; + detail::copy_str(shm_key, c.shm_key, sizeof(c.shm_key)); + detail::copy_str(input_shm_key, c.input_shm_key, + sizeof(c.input_shm_key)); + c.input_slots = input_slots; + c.output_slots = output_slots; + c.slot_data_bytes = slot_data_bytes; + c.input_slot_data_bytes = input_slot_data_bytes; + return detail::via_c_json([&](char *buf, int size) { + return oakengine_ipc_handshake_to_json(&c, buf, size); + }); + } + + static bool from_json(const QJsonObject &o, HandshakeMsg *out) + { + oak_ipc_handshake c; + if (!oakengine_ipc_handshake_parse(detail::compact_json(o).constData(), + &c)) { + return false; + } + out->protocol_version = c.protocol_version; + out->shm_key = QString::fromUtf8(c.shm_key); + out->input_shm_key = QString::fromUtf8(c.input_shm_key); + out->input_slots = c.input_slots; + out->output_slots = c.output_slots; + out->slot_data_bytes = c.slot_data_bytes; + out->input_slot_data_bytes = c.input_slot_data_bytes; + return true; + } }; struct RenderFrameMsg { @@ -129,30 +236,138 @@ struct RenderFrameMsg { QString color_view; QString color_look; - QJsonObject to_json() const; - static bool from_json(const QJsonObject &o, RenderFrameMsg *out); + QJsonObject to_json() const + { + oak_ipc_render_frame c; + c.ticket_id = ticket_id; + detail::copy_str(node_uuid, c.node_uuid, sizeof(c.node_uuid)); + c.time_num = time_num; + c.time_den = time_den; + c.width = width; + c.height = height; + c.format = format; + c.channel_count = channel_count; + c.mode = mode; + c.input_slot = input_slot; + c.input_slot_count = std::min(int(input_slots.size()), + OAK_IPC_INPUT_SLOTS_CAP); + for (int i = 0; i < c.input_slot_count; i++) { + c.input_slots[i] = input_slots.at(i); + } + c.has_color_transform = has_color_transform ? 1 : 0; + c.color_is_display = color_is_display ? 1 : 0; + detail::copy_str(color_output, c.color_output, + sizeof(c.color_output)); + detail::copy_str(color_view, c.color_view, sizeof(c.color_view)); + detail::copy_str(color_look, c.color_look, sizeof(c.color_look)); + return detail::via_c_json([&](char *buf, int size) { + return oakengine_ipc_render_frame_to_json(&c, buf, size); + }); + } + + static bool from_json(const QJsonObject &o, RenderFrameMsg *out) + { + oak_ipc_render_frame c; + if (!oakengine_ipc_render_frame_parse( + detail::compact_json(o).constData(), &c)) { + return false; + } + out->ticket_id = c.ticket_id; + out->node_uuid = QString::fromUtf8(c.node_uuid); + out->time_num = c.time_num; + out->time_den = c.time_den; + out->width = c.width; + out->height = c.height; + out->format = c.format; + out->channel_count = c.channel_count; + out->mode = c.mode; + out->input_slot = c.input_slot; + out->input_slots.clear(); + for (int i = 0; i < c.input_slot_count; i++) { + out->input_slots.append(c.input_slots[i]); + } + out->has_color_transform = c.has_color_transform != 0; + out->color_is_display = c.color_is_display != 0; + out->color_output = QString::fromUtf8(c.color_output); + out->color_view = QString::fromUtf8(c.color_view); + out->color_look = QString::fromUtf8(c.color_look); + return true; + } }; struct FrameReadyMsg { qint64 ticket_id = 0; int output_slot = 0; ///< Index into the worker->main output FrameSlotPool. - QJsonObject to_json() const; - static bool from_json(const QJsonObject &o, FrameReadyMsg *out); + QJsonObject to_json() const + { + oak_ipc_frame_ready c; + c.ticket_id = ticket_id; + c.output_slot = output_slot; + return detail::via_c_json([&](char *buf, int size) { + return oakengine_ipc_frame_ready_to_json(&c, buf, size); + }); + } + + static bool from_json(const QJsonObject &o, FrameReadyMsg *out) + { + oak_ipc_frame_ready c; + if (!oakengine_ipc_frame_ready_parse( + detail::compact_json(o).constData(), &c)) { + return false; + } + out->ticket_id = c.ticket_id; + out->output_slot = c.output_slot; + return true; + } }; struct CancelMsg { qint64 ticket_id = 0; - QJsonObject to_json() const; - static bool from_json(const QJsonObject &o, CancelMsg *out); + QJsonObject to_json() const + { + oak_ipc_cancel c; + c.ticket_id = ticket_id; + return detail::via_c_json([&](char *buf, int size) { + return oakengine_ipc_cancel_to_json(&c, buf, size); + }); + } + + static bool from_json(const QJsonObject &o, CancelMsg *out) + { + oak_ipc_cancel c; + if (!oakengine_ipc_cancel_parse(detail::compact_json(o).constData(), + &c)) { + return false; + } + out->ticket_id = c.ticket_id; + return true; + } }; struct LoadGraphMsg { QString path; ///< Temporary file holding the serialized node graph. - QJsonObject to_json() const; - static bool from_json(const QJsonObject &o, LoadGraphMsg *out); + QJsonObject to_json() const + { + oak_ipc_load_graph c; + detail::copy_str(path, c.path, sizeof(c.path)); + return detail::via_c_json([&](char *buf, int size) { + return oakengine_ipc_load_graph_to_json(&c, buf, size); + }); + } + + static bool from_json(const QJsonObject &o, LoadGraphMsg *out) + { + oak_ipc_load_graph c; + if (!oakengine_ipc_load_graph_parse(detail::compact_json(o).constData(), + &c)) { + return false; + } + out->path = QString::fromUtf8(c.path); + return true; + } }; } // namespace ipc diff --git a/engine/render/ipc/sharedmemoryregion.cpp b/engine/render/ipc/sharedmemoryregion.cpp index 860dc14ac..38a481478 100644 --- a/engine/render/ipc/sharedmemoryregion.cpp +++ b/engine/render/ipc/sharedmemoryregion.cpp @@ -18,7 +18,7 @@ ***/ -#include "sharedmemoryregion.h" +#include "oliveimpl/render/ipc/sharedmemoryregion.h" #include @@ -35,6 +35,10 @@ namespace olive { +namespace engine +{ +namespace internal +{ namespace ipc { @@ -228,4 +232,6 @@ void SharedMemoryRegion::close() #endif } // namespace ipc +} // namespace internal +} // namespace engine } // namespace olive \ No newline at end of file diff --git a/engine/render/ipc/sharedmemoryregion.h b/engine/render/ipc/sharedmemoryregion.h index e6af0ef66..d432ed007 100644 --- a/engine/render/ipc/sharedmemoryregion.h +++ b/engine/render/ipc/sharedmemoryregion.h @@ -24,6 +24,8 @@ #include #include +#include "oakengine/ipc.h" + namespace olive { namespace ipc @@ -32,27 +34,34 @@ namespace ipc /** * @brief A named, fixed-size shared memory segment mapped into the process address space. * - * One process Create()s the segment (owner); the peer process Attach()es to it by the same key. - * The mapping is a raw contiguous byte range accessible via data() — the IPC ring buffers and frame - * slot pools are laid out inside it. Nothing here is locked; synchronization is entirely the - * caller's responsibility via the lock-free structures placed in the mapping. + * Consumer-side wrapper over the liboakengine C ABI: the object only holds an opaque + * OakSharedMemoryRegion handle and forwards every call across the C boundary. The public API is + * unchanged from the original implementation. * - * We deliberately use the raw OS primitives (POSIX shm_open + mmap, Windows CreateFileMapping + - * MapViewOfFile) rather than QSharedMemory: QSharedMemory carries an implicit semaphore and a 1-byte - * header convention, attaches/detaches with reference counting we don't want, and historically has - * cross-platform lifetime quirks. For a render pipeline pushing large frames we want a plain mmap. + * One process open()s the segment with k_create (owner); the peer process open()s it by the same + * key with k_attach. The mapping is a raw contiguous byte range accessible via data() — the IPC + * ring buffers and frame slot pools are laid out inside it. Nothing here is locked; + * synchronization is entirely the caller's responsibility via the lock-free structures placed in + * the mapping. */ class SharedMemoryRegion { public: enum Mode { /// Create (and own) the segment. Fails if it already exists; unlinks on destruction. - k_create, + k_create = OAK_IPC_SHM_MODE_CREATE, /// Attach to a segment created by the peer. Does not unlink on destruction. - k_attach + k_attach = OAK_IPC_SHM_MODE_ATTACH }; - SharedMemoryRegion(); - ~SharedMemoryRegion(); + SharedMemoryRegion() + : handle_(oakengine_ipc_shm_create()) + { + } + + ~SharedMemoryRegion() + { + oakengine_ipc_shm_free(handle_); + } SharedMemoryRegion(const SharedMemoryRegion &) = delete; SharedMemoryRegion &operator=(const SharedMemoryRegion &) = delete; @@ -63,26 +72,36 @@ public: * `key` is a short identifier (no leading slash needed; the platform prefix is added internally). * Returns true on success. On failure, error() carries a human-readable reason. */ - bool open(const QString &key, size_t size, Mode mode); + bool open(const QString &key, size_t size, Mode mode) + { + const bool ok = oakengine_ipc_shm_open( + handle_, key.toUtf8().constData(), size, + static_cast(mode)) != 0; + refresh_caches(); + return ok; + } /** * @brief Unmap and (if owner) unlink the segment. Called automatically by the destructor. */ - void close(); + void close() + { + oakengine_ipc_shm_close(handle_); + } bool is_valid() const { - return data_ != nullptr; + return oakengine_ipc_shm_is_valid(handle_) != 0; } void *data() const { - return data_; + return oakengine_ipc_shm_data(handle_); } size_t size() const { - return size_; + return oakengine_ipc_shm_size(handle_); } const QString &key() const @@ -100,24 +119,50 @@ public: * * Centralized so the owner and the spawned worker agree on the same name. */ - static QString make_key(qint64 owner_pid, int worker_index); + static QString make_key(qint64 owner_pid, int worker_index) + { + const int size = oakengine_ipc_shm_make_key(owner_pid, worker_index, + nullptr, 0); + QByteArray buf(size + 1, '\0'); + oakengine_ipc_shm_make_key(owner_pid, worker_index, buf.data(), + size + 1); + return QString::fromUtf8(buf.constData()); + } + + /** + * @brief The wrapped C handle, for cross-type wrappers and direct C API use + */ + OakSharedMemoryRegion *handle() const + { + return handle_; + } private: - QString key_; - size_t size_; - void *data_; - Mode mode_; - QString error_; + static QString query_string(int (*query)(const OakSharedMemoryRegion *, + char *, int), + const OakSharedMemoryRegion *handle) + { + const int size = query(handle, nullptr, 0); + if (size <= 0) { + return QString(); + } + QByteArray buf(size + 1, '\0'); + query(handle, buf.data(), size + 1); + return QString::fromUtf8(buf.constData()); + } -#if defined(Q_OS_WIN) - void *handle_; // HANDLE from CreateFileMapping/OpenFileMapping -#else - int fd_; // file descriptor from shm_open - QString shm_name_; // the platform-prefixed name actually passed to shm_open -#endif + void refresh_caches() + { + key_ = query_string(oakengine_ipc_shm_key, handle_); + error_ = query_string(oakengine_ipc_shm_error, handle_); + } + + OakSharedMemoryRegion *handle_; + QString key_; + QString error_; }; } // namespace ipc } // namespace olive -#endif // OAK_IPC_SHAREDMEMORYREGION_H \ No newline at end of file +#endif // OAK_IPC_SHAREDMEMORYREGION_H diff --git a/engine/render/renderprocessor.cpp b/engine/render/renderprocessor.cpp index 5a7664765..6e246c7de 100644 --- a/engine/render/renderprocessor.cpp +++ b/engine/render/renderprocessor.cpp @@ -36,7 +36,7 @@ #include "render/plugin/pluginrenderer.h" #include "pluginSupport/oliveclip.h" #include "pluginSupport/olivehost.h" -#include "render/ipc/frameslotpool.h" +#include "oliveimpl/render/ipc/frameslotpool.h" namespace olive { @@ -456,7 +456,7 @@ void RenderProcessor::process_video_footage(TexturePtr destination, render_ctx_->flush(); }; - auto *input_pool = QtUtils::value_to_ptr( + auto *input_pool = QtUtils::value_to_ptr( ticket_->property("ipc_input_pool")); int input_slot = -1; const QVariantList input_slots = @@ -481,7 +481,7 @@ void RenderProcessor::process_video_footage(TexturePtr destination, return; } - const ipc::FrameSlotMeta *meta = input_pool->meta(uint32_t(input_slot)); + const engine::internal::ipc::FrameSlotMeta *meta = input_pool->meta(uint32_t(input_slot)); if (meta && meta->width > 0 && meta->height > 0 && meta->data_size > 0 && meta->data_size <= int(input_pool->slot_data_bytes())) { diff --git a/engine/render/renderworkerpool.cpp b/engine/render/renderworkerpool.cpp index 5c40ce969..5158f243c 100644 --- a/engine/render/renderworkerpool.cpp +++ b/engine/render/renderworkerpool.cpp @@ -46,6 +46,9 @@ #include "common/qtutils.h" #include "node/project/footage/footage.h" #include "node/traverser.h" +#include "oliveimpl/render/ipc/frameslotpool.h" +#include "oliveimpl/render/ipc/ipcmessage.h" +#include "oliveimpl/render/ipc/sharedmemoryregion.h" namespace olive { @@ -381,7 +384,7 @@ bool read_control_message(QProcess *process, QJsonObject *out, QString *error, *out = doc.object(); if (out->value(QStringLiteral("type")).toString() == - QLatin1String(ipc::msgtype::k_error)) { + QLatin1String(engine::internal::ipc::msgtype::k_error)) { if (error) { *error = out->value(QStringLiteral("message")).toString(); } @@ -398,6 +401,28 @@ bool read_control_message(QProcess *process, QJsonObject *out, QString *error, } // namespace +// Holds the persistent per-worker IPC state. Defined here rather than in the +// header because the member types are engine-internal (oliveimpl): the header +// is consumed outside the library and only forward-declares this struct. +struct RenderWorkerPool::PooledWorker { + QProcess *process = nullptr; + QString loaded_graph_path; + qint64 last_used_ms = 0; + int use_count = 0; + + // Persistent shared memory for this worker. Reusing regions across frames + // avoids the cost of creating/destroying large shm segments every render. + engine::internal::ipc::SharedMemoryRegion output_region; + engine::internal::ipc::FrameSlotPool output_pool; + size_t output_slot_bytes = 0; + QString output_shm_key; + + engine::internal::ipc::SharedMemoryRegion input_region; + engine::internal::ipc::FrameSlotPool input_pool; + size_t input_slot_bytes = 0; + QString input_shm_key; +}; + RenderWorkerPool::RenderWorkerPool(DecoderCache *decoder_cache, const QString &gpu_backend, QObject *parent) : QThread(parent) @@ -782,33 +807,33 @@ RenderWorkerPool::process_job_attempt(const Job &job, int worker_index, } } const size_t output_region_bytes = - ipc::FrameSlotPool::bytes_needed(k_output_slots, output_slot_bytes); + engine::internal::ipc::FrameSlotPool::bytes_needed(k_output_slots, output_slot_bytes); if (!worker->output_region.is_valid() || worker->output_slot_bytes < output_slot_bytes) { if (worker->output_region.is_valid()) { worker->output_region.close(); - worker->output_pool = ipc::FrameSlotPool(); + worker->output_pool = engine::internal::ipc::FrameSlotPool(); } if (worker->output_shm_key.isEmpty()) { worker->output_shm_key = - ipc::SharedMemoryRegion::make_key(worker_process_id, 0) + + engine::internal::ipc::SharedMemoryRegion::make_key(worker_process_id, 0) + QStringLiteral("-out"); } if (!worker->output_region.open(worker->output_shm_key, output_region_bytes, - ipc::SharedMemoryRegion::k_create)) { + engine::internal::ipc::SharedMemoryRegion::k_create)) { qWarning() << "RenderWorkerPool failed to create output shared memory" << worker->output_region.error(); return JobResult::k_fatal_failure; } - worker->output_pool = ipc::FrameSlotPool::create( + worker->output_pool = engine::internal::ipc::FrameSlotPool::create( worker->output_region.data(), k_output_slots, output_slot_bytes); worker->output_slot_bytes = output_slot_bytes; } const QString shm_key = worker->output_shm_key; - ipc::FrameSlotPool &output_pool = worker->output_pool; + engine::internal::ipc::FrameSlotPool &output_pool = worker->output_pool; const uint32_t input_slot_count = job.input_frames.isEmpty() ? 0 : uint32_t(job.input_frames.size()); @@ -818,31 +843,31 @@ RenderWorkerPool::process_job_attempt(const Job &job, int worker_index, worker->input_pool.slot_count() < input_slot_count) { if (worker->input_region.is_valid()) { worker->input_region.close(); - worker->input_pool = ipc::FrameSlotPool(); + worker->input_pool = engine::internal::ipc::FrameSlotPool(); } if (worker->input_shm_key.isEmpty()) { worker->input_shm_key = - ipc::SharedMemoryRegion::make_key(worker_process_id, 1) + + engine::internal::ipc::SharedMemoryRegion::make_key(worker_process_id, 1) + QStringLiteral("-in"); } - const size_t input_region_bytes = ipc::FrameSlotPool::bytes_needed( + const size_t input_region_bytes = engine::internal::ipc::FrameSlotPool::bytes_needed( input_slot_count, input_slot_bytes); if (!worker->input_region.open(worker->input_shm_key, input_region_bytes, - ipc::SharedMemoryRegion::k_create)) { + engine::internal::ipc::SharedMemoryRegion::k_create)) { qWarning() << "RenderWorkerPool failed to create input shared memory" << worker->input_region.error(); return JobResult::k_fatal_failure; } worker->input_pool = - ipc::FrameSlotPool::create(worker->input_region.data(), + engine::internal::ipc::FrameSlotPool::create(worker->input_region.data(), input_slot_count, input_slot_bytes); worker->input_slot_bytes = input_slot_bytes; } } const QString input_shm_key = worker->input_shm_key; - ipc::FrameSlotPool &input_pool = worker->input_pool; + engine::internal::ipc::FrameSlotPool &input_pool = worker->input_pool; QVector input_slots; if (input_slot_count > 0) { for (const FramePtr &frame : job.input_frames) { @@ -860,7 +885,7 @@ RenderWorkerPool::process_job_attempt(const Job &job, int worker_index, memcpy(input_pool.slot_data(slot), frame->const_data(), size_t(frame->allocated_size())); - ipc::FrameSlotMeta *meta = input_pool.meta(slot); + engine::internal::ipc::FrameSlotMeta *meta = input_pool.meta(slot); meta->id = qint64(input_slots.size()); meta->time_num = frame->timestamp().numerator(); meta->time_den = frame->timestamp().denominator(); @@ -896,14 +921,14 @@ RenderWorkerPool::process_job_attempt(const Job &job, int worker_index, set_active_worker(worker_index, job.ticket, worker->process, ticket_id); if (job.ticket->is_cancelled()) { - ipc::CancelMsg cancel; + engine::internal::ipc::CancelMsg cancel; cancel.ticket_id = ticket_id; try_write_control_message(worker->process, cancel.to_json()); clear_active_worker(worker_index, worker_process_id); return JobResult::k_cancelled; } - ipc::HandshakeMsg handshake; + engine::internal::ipc::HandshakeMsg handshake; handshake.protocol_version = k_protocol_version; handshake.shm_key = shm_key; handshake.input_shm_key = input_slots.isEmpty() ? QString() : input_shm_key; @@ -923,7 +948,7 @@ RenderWorkerPool::process_job_attempt(const Job &job, int worker_index, } if (worker->loaded_graph_path != job.graph_path) { - ipc::LoadGraphMsg load; + engine::internal::ipc::LoadGraphMsg load; load.path = job.graph_path; QString error; QJsonObject response; @@ -939,7 +964,7 @@ RenderWorkerPool::process_job_attempt(const Job &job, int worker_index, } worker->loaded_graph_path = job.graph_path; } - ipc::RenderFrameMsg render; + engine::internal::ipc::RenderFrameMsg render; render.ticket_id = ticket_id; render.node_uuid = job.node_token; render.time_num = job.params.time.numerator(); @@ -970,7 +995,7 @@ RenderWorkerPool::process_job_attempt(const Job &job, int worker_index, QString error; QJsonObject response; - ipc::FrameReadyMsg ready; + engine::internal::ipc::FrameReadyMsg ready; while (true) { if (!read_control_message(worker->process, &response, &error, 30000)) { if (!job.ticket->is_cancelled()) { @@ -982,7 +1007,7 @@ RenderWorkerPool::process_job_attempt(const Job &job, int worker_index, JobResult::k_retryable_failure; } - if (ipc::FrameReadyMsg::from_json(response, &ready)) { + if (engine::internal::ipc::FrameReadyMsg::from_json(response, &ready)) { break; } } @@ -1174,7 +1199,7 @@ void RenderWorkerPool::shutdown_worker(PooledWorker *worker) if (process->state() == QProcess::Running) { QJsonObject shutdown; - shutdown[QStringLiteral("type")] = ipc::msgtype::k_shutdown; + shutdown[QStringLiteral("type")] = engine::internal::ipc::msgtype::k_shutdown; try_write_control_message(process, shutdown); process->closeWriteChannel(); if (!process->waitForFinished(5000)) { @@ -1209,10 +1234,10 @@ void RenderWorkerPool::clear_graph_cache() } void RenderWorkerPool::finish_with_frame(RenderTicketPtr ticket, - const ipc::FrameSlotPool &pool, + const engine::internal::ipc::FrameSlotPool &pool, uint32_t slot) { - const ipc::FrameSlotMeta *meta = pool.meta(slot); + const engine::internal::ipc::FrameSlotMeta *meta = pool.meta(slot); if (!meta || meta->data_size <= 0 || meta->data_size > int(pool.slot_data_bytes())) { ticket->finish(); diff --git a/engine/render/renderworkerpool.h b/engine/render/renderworkerpool.h index f50815ce2..0ca87f53f 100644 --- a/engine/render/renderworkerpool.h +++ b/engine/render/renderworkerpool.h @@ -32,9 +32,6 @@ #include "codec/frame.h" #include "node/project/serializer/serializer.h" -#include "render/ipc/frameslotpool.h" -#include "render/ipc/ipcmessage.h" -#include "render/ipc/sharedmemoryregion.h" #include "render/rendermanager.h" class QProcess; @@ -42,6 +39,16 @@ class QProcess; namespace olive { +// The worker IPC implementation lives behind the liboakengine C ABI facade +// (src/oliveimpl/render/ipc). This header is consumed outside the engine +// library, so it can only forward-declare the internal types used by private +// method signatures; PooledWorker (which holds IPC objects by value) is +// defined in the .cpp for the same reason. +namespace engine::internal::ipc +{ +class FrameSlotPool; +} + class Project; class RenderWorkerPool : public QThread { @@ -90,24 +97,9 @@ private: qint64 ticket_id = 0; }; - struct PooledWorker { - QProcess *process = nullptr; - QString loaded_graph_path; - qint64 last_used_ms = 0; - int use_count = 0; - - // Persistent shared memory for this worker. Reusing regions across frames - // avoids the cost of creating/destroying large shm segments every render. - ipc::SharedMemoryRegion output_region; - ipc::FrameSlotPool output_pool; - size_t output_slot_bytes = 0; - QString output_shm_key; - - ipc::SharedMemoryRegion input_region; - ipc::FrameSlotPool input_pool; - size_t input_slot_bytes = 0; - QString input_shm_key; - }; + // Defined in the .cpp: holds the per-worker IPC shared-memory regions and + // frame slot pools (internal oliveimpl types) by value. + struct PooledWorker; struct CachedGraph { QString path; @@ -124,7 +116,8 @@ private: std::vector> *local_pool); JobResult process_job_attempt(const Job &job, int worker_index, int attempt_index, PooledWorker *worker); - void finish_with_frame(RenderTicketPtr ticket, const ipc::FrameSlotPool &pool, + void finish_with_frame(RenderTicketPtr ticket, + const engine::internal::ipc::FrameSlotPool &pool, uint32_t slot); void cleanup_graph_file(const QString &path); void add_graph_path_ref(const QString &path); diff --git a/engine/src/capi/ipc.cpp b/engine/src/capi/ipc.cpp new file mode 100644 index 000000000..96cd4dfef --- /dev/null +++ b/engine/src/capi/ipc.cpp @@ -0,0 +1,623 @@ +/*** + + 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 . + +***/ + +#include "oakengine/ipc.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include "oliveimpl/render/ipc/frameslotpool.h" +#include "oliveimpl/render/ipc/ipcmessage.h" +#include "oliveimpl/render/ipc/sharedmemoryregion.h" + +namespace +{ + +namespace internal_ipc = olive::engine::internal::ipc; + +olive::engine::internal::ipc::SharedMemoryRegion *impl(OakSharedMemoryRegion *h) +{ + return reinterpret_cast( + h); +} + +const olive::engine::internal::ipc::SharedMemoryRegion * +impl(const OakSharedMemoryRegion *h) +{ + return reinterpret_cast< + const olive::engine::internal::ipc::SharedMemoryRegion *>(h); +} + +OakSharedMemoryRegion * +wrap(olive::engine::internal::ipc::SharedMemoryRegion *r) +{ + return reinterpret_cast(r); +} + +olive::engine::internal::ipc::FrameSlotPool *impl(OakFrameSlotPool *h) +{ + return reinterpret_cast(h); +} + +const olive::engine::internal::ipc::FrameSlotPool * +impl(const OakFrameSlotPool *h) +{ + return reinterpret_cast( + h); +} + +OakFrameSlotPool *wrap(olive::engine::internal::ipc::FrameSlotPool *p) +{ + return reinterpret_cast(p); +} + +// Copy a QString into a fixed-capacity C buffer, always NUL-terminating and +// truncating what does not fit. +void copy_to_buf(const QString &s, char *dst, size_t cap) +{ + const QByteArray utf = s.toUtf8(); + const size_t n = qMin(size_t(utf.size()), cap - 1); + memcpy(dst, utf.constData(), n); + dst[n] = '\0'; +} + +// 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()); +} + +int object_to_buf(const QJsonObject &o, char *buf, int buf_size) +{ + const QByteArray json = QJsonDocument(o).toJson(QJsonDocument::Compact); + if (buf && buf_size > 0) { + snprintf(buf, size_t(buf_size), "%s", json.constData()); + } + return int(json.size()); +} + +bool parse_object(const char *json, QJsonObject *out) +{ + if (!json) { + return false; + } + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(json, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + return false; + } + *out = doc.object(); + return true; +} + +// ---- POD <-> impl message conversions ------------------------------------ + +void to_c(const internal_ipc::HandshakeMsg &in, oak_ipc_handshake *out) +{ + memset(out, 0, sizeof(*out)); + out->protocol_version = in.protocol_version; + copy_to_buf(in.shm_key, out->shm_key, sizeof(out->shm_key)); + copy_to_buf(in.input_shm_key, out->input_shm_key, + sizeof(out->input_shm_key)); + out->input_slots = in.input_slots; + out->output_slots = in.output_slots; + out->slot_data_bytes = in.slot_data_bytes; + out->input_slot_data_bytes = in.input_slot_data_bytes; +} + +void from_c(const oak_ipc_handshake *in, internal_ipc::HandshakeMsg *out) +{ + out->protocol_version = in->protocol_version; + out->shm_key = QString::fromUtf8(in->shm_key); + out->input_shm_key = QString::fromUtf8(in->input_shm_key); + out->input_slots = in->input_slots; + out->output_slots = in->output_slots; + out->slot_data_bytes = in->slot_data_bytes; + out->input_slot_data_bytes = in->input_slot_data_bytes; +} + +void to_c(const internal_ipc::RenderFrameMsg &in, oak_ipc_render_frame *out) +{ + memset(out, 0, sizeof(*out)); + out->ticket_id = in.ticket_id; + copy_to_buf(in.node_uuid, out->node_uuid, sizeof(out->node_uuid)); + out->time_num = in.time_num; + out->time_den = in.time_den; + out->width = in.width; + out->height = in.height; + out->format = in.format; + out->channel_count = in.channel_count; + out->mode = in.mode; + out->input_slot = in.input_slot; + const int count = qMin(int(in.input_slots.size()), OAK_IPC_INPUT_SLOTS_CAP); + out->input_slot_count = count; + for (int i = 0; i < count; i++) { + out->input_slots[i] = in.input_slots.at(i); + } + out->has_color_transform = in.has_color_transform ? 1 : 0; + out->color_is_display = in.color_is_display ? 1 : 0; + copy_to_buf(in.color_output, out->color_output, + sizeof(out->color_output)); + copy_to_buf(in.color_view, out->color_view, sizeof(out->color_view)); + copy_to_buf(in.color_look, out->color_look, sizeof(out->color_look)); +} + +void from_c(const oak_ipc_render_frame *in, internal_ipc::RenderFrameMsg *out) +{ + out->ticket_id = in->ticket_id; + out->node_uuid = QString::fromUtf8(in->node_uuid); + out->time_num = in->time_num; + out->time_den = in->time_den; + out->width = in->width; + out->height = in->height; + out->format = in->format; + out->channel_count = in->channel_count; + out->mode = in->mode; + out->input_slot = in->input_slot; + out->input_slots.clear(); + const int count = qMin(in->input_slot_count, OAK_IPC_INPUT_SLOTS_CAP); + for (int i = 0; i < count; i++) { + out->input_slots.append(in->input_slots[i]); + } + out->has_color_transform = in->has_color_transform != 0; + out->color_is_display = in->color_is_display != 0; + out->color_output = QString::fromUtf8(in->color_output); + out->color_view = QString::fromUtf8(in->color_view); + out->color_look = QString::fromUtf8(in->color_look); +} + +void to_c(const internal_ipc::FrameReadyMsg &in, oak_ipc_frame_ready *out) +{ + memset(out, 0, sizeof(*out)); + out->ticket_id = in.ticket_id; + out->output_slot = in.output_slot; +} + +void from_c(const oak_ipc_frame_ready *in, internal_ipc::FrameReadyMsg *out) +{ + out->ticket_id = in->ticket_id; + out->output_slot = in->output_slot; +} + +void to_c(const internal_ipc::CancelMsg &in, oak_ipc_cancel *out) +{ + memset(out, 0, sizeof(*out)); + out->ticket_id = in.ticket_id; +} + +void from_c(const oak_ipc_cancel *in, internal_ipc::CancelMsg *out) +{ + out->ticket_id = in->ticket_id; +} + +void to_c(const internal_ipc::LoadGraphMsg &in, oak_ipc_load_graph *out) +{ + memset(out, 0, sizeof(*out)); + copy_to_buf(in.path, out->path, sizeof(out->path)); +} + +void from_c(const oak_ipc_load_graph *in, internal_ipc::LoadGraphMsg *out) +{ + out->path = QString::fromUtf8(in->path); +} + +} // namespace + +extern "C" +{ + +/* ---- SharedMemoryRegion ------------------------------------------------- */ + +OakSharedMemoryRegion *oakengine_ipc_shm_create(void) +{ + return wrap(new internal_ipc::SharedMemoryRegion()); +} + +void oakengine_ipc_shm_free(OakSharedMemoryRegion *self) +{ + delete impl(self); +} + +int oakengine_ipc_shm_open(OakSharedMemoryRegion *self, const char *key, + size_t size, oak_ipc_shm_mode mode) +{ + if (!self || !key) { + return 0; + } + const internal_ipc::SharedMemoryRegion::Mode m = + mode == OAK_IPC_SHM_MODE_CREATE ? + internal_ipc::SharedMemoryRegion::k_create : + internal_ipc::SharedMemoryRegion::k_attach; + return impl(self)->open(QString::fromUtf8(key), size, m) ? 1 : 0; +} + +void oakengine_ipc_shm_close(OakSharedMemoryRegion *self) +{ + if (self) { + impl(self)->close(); + } +} + +int oakengine_ipc_shm_is_valid(const OakSharedMemoryRegion *self) +{ + return self && impl(self)->is_valid() ? 1 : 0; +} + +void *oakengine_ipc_shm_data(OakSharedMemoryRegion *self) +{ + return self ? impl(self)->data() : nullptr; +} + +size_t oakengine_ipc_shm_size(const OakSharedMemoryRegion *self) +{ + return self ? impl(self)->size() : 0; +} + +int oakengine_ipc_shm_key(const OakSharedMemoryRegion *self, char *buf, + int buf_size) +{ + return string_to_buf(self ? impl(self)->key() : QString(), buf, buf_size); +} + +int oakengine_ipc_shm_error(const OakSharedMemoryRegion *self, char *buf, + int buf_size) +{ + return string_to_buf(self ? impl(self)->error() : QString(), buf, buf_size); +} + +int oakengine_ipc_shm_make_key(int64_t owner_pid, int worker_index, char *buf, + int buf_size) +{ + return string_to_buf( + internal_ipc::SharedMemoryRegion::make_key(owner_pid, worker_index), + buf, buf_size); +} + +/* ---- FrameSlotPool ------------------------------------------------------ */ + +size_t oakengine_ipc_framepool_bytes_needed(uint32_t slot_count, + size_t slot_data_bytes) +{ + return internal_ipc::FrameSlotPool::bytes_needed(slot_count, + slot_data_bytes); +} + +OakFrameSlotPool *oakengine_ipc_framepool_create(void *mem, + uint32_t slot_count, + size_t slot_data_bytes) +{ + if (!mem) { + return nullptr; + } + return wrap(new internal_ipc::FrameSlotPool( + internal_ipc::FrameSlotPool::create(mem, slot_count, + slot_data_bytes))); +} + +OakFrameSlotPool *oakengine_ipc_framepool_attach(void *mem) +{ + if (!mem) { + return nullptr; + } + return wrap(new internal_ipc::FrameSlotPool( + internal_ipc::FrameSlotPool::attach(mem))); +} + +OakFrameSlotPool *oakengine_ipc_framepool_copy(const OakFrameSlotPool *self) +{ + if (!self) { + return nullptr; + } + return wrap(new internal_ipc::FrameSlotPool(*impl(self))); +} + +void oakengine_ipc_framepool_free(OakFrameSlotPool *self) +{ + delete impl(self); +} + +int oakengine_ipc_framepool_is_valid(const OakFrameSlotPool *self) +{ + return self && impl(self)->is_valid() ? 1 : 0; +} + +uint32_t oakengine_ipc_framepool_slot_count(const OakFrameSlotPool *self) +{ + return self ? impl(self)->slot_count() : 0; +} + +size_t oakengine_ipc_framepool_slot_data_bytes(const OakFrameSlotPool *self) +{ + return self ? impl(self)->slot_data_bytes() : 0; +} + +int oakengine_ipc_framepool_acquire(OakFrameSlotPool *self, uint32_t *index) +{ + return self && index && impl(self)->is_valid() && + impl(self)->acquire(index) ? + 1 : + 0; +} + +void *oakengine_ipc_framepool_slot_data(OakFrameSlotPool *self, uint32_t index) +{ + return self && impl(self)->is_valid() ? impl(self)->slot_data(index) : + nullptr; +} + +const void *oakengine_ipc_framepool_slot_data_const( + const OakFrameSlotPool *self, uint32_t index) +{ + return self && impl(self)->is_valid() ? impl(self)->slot_data(index) : + nullptr; +} + +oak_frame_slot_meta *oakengine_ipc_framepool_meta(OakFrameSlotPool *self, + uint32_t index) +{ + return self && impl(self)->is_valid() ? impl(self)->meta(index) : nullptr; +} + +const oak_frame_slot_meta *oakengine_ipc_framepool_meta_const( + const OakFrameSlotPool *self, uint32_t index) +{ + return self && impl(self)->is_valid() ? impl(self)->meta(index) : nullptr; +} + +int oakengine_ipc_framepool_publish(OakFrameSlotPool *self, uint32_t index) +{ + return self && impl(self)->is_valid() && impl(self)->publish(index) ? 1 : 0; +} + +int oakengine_ipc_framepool_consume(OakFrameSlotPool *self, uint32_t *index) +{ + return self && index && impl(self)->is_valid() && + impl(self)->consume(index) ? + 1 : + 0; +} + +int oakengine_ipc_framepool_release(OakFrameSlotPool *self, uint32_t index) +{ + return self && impl(self)->is_valid() && impl(self)->release(index) ? 1 : 0; +} + +/* ---- Control-plane messages --------------------------------------------- */ + +oak_ipc_msgtype oakengine_ipc_message_type(const char *json) +{ + QJsonObject o; + if (!parse_object(json, &o)) { + return OAK_IPC_MSGTYPE_UNKNOWN; + } + const QString type = o[QStringLiteral("type")].toString(); + if (type == QLatin1String(internal_ipc::msgtype::k_handshake)) { + return OAK_IPC_MSGTYPE_HANDSHAKE; + } + if (type == QLatin1String(internal_ipc::msgtype::k_load_graph)) { + return OAK_IPC_MSGTYPE_LOAD_GRAPH; + } + if (type == QLatin1String(internal_ipc::msgtype::k_render_frame)) { + return OAK_IPC_MSGTYPE_RENDER_FRAME; + } + if (type == QLatin1String(internal_ipc::msgtype::k_frame_ready)) { + return OAK_IPC_MSGTYPE_FRAME_READY; + } + if (type == QLatin1String(internal_ipc::msgtype::k_cancel)) { + return OAK_IPC_MSGTYPE_CANCEL; + } + if (type == QLatin1String(internal_ipc::msgtype::k_graph_update)) { + return OAK_IPC_MSGTYPE_GRAPH_UPDATE; + } + if (type == QLatin1String(internal_ipc::msgtype::k_shutdown)) { + return OAK_IPC_MSGTYPE_SHUTDOWN; + } + if (type == QLatin1String(internal_ipc::msgtype::k_error)) { + return OAK_IPC_MSGTYPE_ERROR; + } + return OAK_IPC_MSGTYPE_UNKNOWN; +} + +int oakengine_ipc_handshake_to_json(const oak_ipc_handshake *self, char *buf, + int buf_size) +{ + if (!self) { + return -1; + } + internal_ipc::HandshakeMsg in; + from_c(self, &in); + return object_to_buf(in.to_json(), buf, buf_size); +} + +int oakengine_ipc_handshake_parse(const char *json, oak_ipc_handshake *out) +{ + if (!out) { + return 0; + } + QJsonObject o; + internal_ipc::HandshakeMsg in; + if (!parse_object(json, &o) || + !internal_ipc::HandshakeMsg::from_json(o, &in)) { + return 0; + } + to_c(in, out); + return 1; +} + +int oakengine_ipc_render_frame_to_json(const oak_ipc_render_frame *self, + char *buf, int buf_size) +{ + if (!self) { + return -1; + } + internal_ipc::RenderFrameMsg in; + from_c(self, &in); + return object_to_buf(in.to_json(), buf, buf_size); +} + +int oakengine_ipc_render_frame_parse(const char *json, + oak_ipc_render_frame *out) +{ + if (!out) { + return 0; + } + QJsonObject o; + internal_ipc::RenderFrameMsg in; + if (!parse_object(json, &o) || + !internal_ipc::RenderFrameMsg::from_json(o, &in)) { + return 0; + } + to_c(in, out); + return 1; +} + +int oakengine_ipc_frame_ready_to_json(const oak_ipc_frame_ready *self, + char *buf, int buf_size) +{ + if (!self) { + return -1; + } + internal_ipc::FrameReadyMsg in; + from_c(self, &in); + return object_to_buf(in.to_json(), buf, buf_size); +} + +int oakengine_ipc_frame_ready_parse(const char *json, + oak_ipc_frame_ready *out) +{ + if (!out) { + return 0; + } + QJsonObject o; + internal_ipc::FrameReadyMsg in; + if (!parse_object(json, &o) || + !internal_ipc::FrameReadyMsg::from_json(o, &in)) { + return 0; + } + to_c(in, out); + return 1; +} + +int oakengine_ipc_cancel_to_json(const oak_ipc_cancel *self, char *buf, + int buf_size) +{ + if (!self) { + return -1; + } + internal_ipc::CancelMsg in; + from_c(self, &in); + return object_to_buf(in.to_json(), buf, buf_size); +} + +int oakengine_ipc_cancel_parse(const char *json, oak_ipc_cancel *out) +{ + if (!out) { + return 0; + } + QJsonObject o; + internal_ipc::CancelMsg in; + if (!parse_object(json, &o) || + !internal_ipc::CancelMsg::from_json(o, &in)) { + return 0; + } + to_c(in, out); + return 1; +} + +int oakengine_ipc_load_graph_to_json(const oak_ipc_load_graph *self, char *buf, + int buf_size) +{ + if (!self) { + return -1; + } + internal_ipc::LoadGraphMsg in; + from_c(self, &in); + return object_to_buf(in.to_json(), buf, buf_size); +} + +int oakengine_ipc_load_graph_parse(const char *json, oak_ipc_load_graph *out) +{ + if (!out) { + return 0; + } + QJsonObject o; + internal_ipc::LoadGraphMsg in; + if (!parse_object(json, &o) || + !internal_ipc::LoadGraphMsg::from_json(o, &in)) { + return 0; + } + to_c(in, out); + return 1; +} + +int oakengine_ipc_shutdown_to_json(char *buf, int buf_size) +{ + QJsonObject o; + o[QStringLiteral("type")] = internal_ipc::msgtype::k_shutdown; + return object_to_buf(o, buf, buf_size); +} + +int oakengine_ipc_shutdown_parse(const char *json) +{ + QJsonObject o; + if (!parse_object(json, &o)) { + return 0; + } + return o[QStringLiteral("type")].toString() == + QLatin1String(internal_ipc::msgtype::k_shutdown) ? + 1 : + 0; +} + +int oakengine_ipc_error_to_json(const char *message, char *buf, int buf_size) +{ + QJsonObject o; + o[QStringLiteral("type")] = internal_ipc::msgtype::k_error; + o[QStringLiteral("message")] = QString::fromUtf8(message ? message : ""); + return object_to_buf(o, buf, buf_size); +} + +int oakengine_ipc_error_parse(const char *json, char *message_buf, + int message_buf_size) +{ + QJsonObject o; + if (!parse_object(json, &o) || + o[QStringLiteral("type")].toString() != + QLatin1String(internal_ipc::msgtype::k_error)) { + return 0; + } + if (message_buf && message_buf_size > 0) { + copy_to_buf(o[QStringLiteral("message")].toString(), message_buf, + size_t(message_buf_size)); + } + return 1; +} + +} // extern "C" diff --git a/engine/src/oliveimpl/render/ipc/frameslotpool.h b/engine/src/oliveimpl/render/ipc/frameslotpool.h new file mode 100644 index 000000000..37223ce85 --- /dev/null +++ b/engine/src/oliveimpl/render/ipc/frameslotpool.h @@ -0,0 +1,182 @@ +/*** + + 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 . + +***/ + +#ifndef OAK_OLIVEIMPL_RENDER_IPC_FRAMESLOTPOOL_H +#define OAK_OLIVEIMPL_RENDER_IPC_FRAMESLOTPOOL_H + +#include +#include + +#include "oakengine/ipc.h" +#include "oakengine/spscringbuffer.h" + +namespace olive +{ +namespace engine +{ +namespace internal +{ +namespace ipc +{ + +/** + * @brief Per-slot metadata describing the frame currently occupying a slot. + * + * Trivially-copyable POD that lives in shared memory alongside the pixel data. Carries everything + * the consumer needs to reconstruct an olive::Frame without any out-of-band information. We store + * the Rational timestamp as an explicit numerator/denominator pair to stay POD (olive::Rational is + * not guaranteed shared-memory-safe). + * + * This is the C ABI oak_frame_slot_meta struct, aliased so the version-1 wire layout the app and + * the render worker agree on is defined exactly once, in oakengine/ipc.h. + */ +typedef oak_frame_slot_meta FrameSlotMeta; + +/** + * @brief A fixed-size pool of equal-sized frame slots in shared memory, with lock-free hand-off. + * + * One pool models a single direction of frame flow (e.g. worker -> main for rendered output, or + * main -> worker for decoded input). Ownership of a slot is transferred via two SPSC ring buffers + * of slot indices, so no mutex is ever taken: + * + * - free_ring: indices of slots available to the FILLER. The drainer returns slots here. + * - ready_ring: indices of slots holding a published frame, produced by the FILLER for the + * DRAINER to consume. + * + * Lifecycle (filler = producer of frames, drainer = consumer of frames): + * filler: Acquire() -> pop a free index -> write meta + pixels -> Publish() -> push to ready + * drainer: Consume() -> pop a ready index -> read meta + pixels -> Release() -> push to free + * + * Because each ring has exactly one producer and one consumer (the filler owns free.Pop + + * ready.Push, the drainer owns ready.Pop + free.Push), the SPSC invariant holds and the whole + * exchange is lock-free. + * + * All slots are sized to `slot_data_bytes`, computed for the maximum supported frame (e.g. 8K RGBA + * half-float). Frames smaller than that simply use a prefix of the slot. + * + * The pool does NOT own the memory; it is constructed over a SharedMemoryRegion mapping. Use + * BytesNeeded() to size that region. + */ +class FrameSlotPool { +public: + /** + * @brief Total bytes a region must provide to back a pool of `slot_count` x `slot_data_bytes`. + */ + static size_t bytes_needed(uint32_t slot_count, size_t slot_data_bytes); + + /** + * @brief Lay out and initialize a brand-new pool over `mem` (owner side, once). + * + * Initializes both rings, seeds the free ring with every slot index, and zeroes metadata. + * `mem` must provide at least BytesNeeded(slot_count, slot_data_bytes) bytes. + */ + static FrameSlotPool create(void *mem, uint32_t slot_count, + size_t slot_data_bytes); + + /** + * @brief Map an existing, already-initialized pool (peer side). + * + * Reads slot_count/slot_data_bytes from the in-memory header written by Create(). + */ + static FrameSlotPool attach(void *mem); + + bool is_valid() const + { + return header_ != nullptr; + } + + uint32_t slot_count() const; + size_t slot_data_bytes() const; + + // ---- Filler side ---- + + /** + * @brief Take ownership of a free slot. Returns false (and leaves *index untouched) if none free. + */ + bool acquire(uint32_t *index); + + /** + * @brief Pointer to a slot's pixel data block (slot_data_bytes available). + */ + void *slot_data(uint32_t index); + + /** + * @brief Mutable metadata for a slot. Filler writes this before Publish(). + */ + FrameSlotMeta *meta(uint32_t index); + + /** + * @brief Publish a filled slot to the drainer. Must follow a successful Acquire() of `index`. + */ + bool publish(uint32_t index); + + // ---- Drainer side ---- + + /** + * @brief Take the next published slot. Returns false if nothing is ready. + */ + bool consume(uint32_t *index); + + /** + * @brief Return a consumed slot to the free pool for reuse. Must follow Consume() of `index`. + */ + bool release(uint32_t index); + + const FrameSlotMeta *meta(uint32_t index) const; + const void *slot_data(uint32_t index) const; + +public: + FrameSlotPool() = default; + +private: + struct Header { + uint32_t magic; + uint32_t slot_count; + uint64_t slot_data_bytes; + // Byte offsets from the start of the segment to each sub-region. + uint64_t free_ring_offset; + uint64_t ready_ring_offset; + uint64_t meta_offset; + uint64_t data_offset; + }; + + static constexpr uint32_t k_magic = 0x4F4B5350; // 'OKSP' + + // Ring capacity must exceed slot_count by one because a ring can hold at most capacity-1 entries + // and we need to be able to enqueue every slot at once. + static uint32_t ring_capacity(uint32_t slot_count) + { + return slot_count + 1; + } + + uint8_t *base_ = nullptr; + Header *header_ = nullptr; + olive::ipc::SpscRingBuffer *free_ring_ = nullptr; + olive::ipc::SpscRingBuffer *ready_ring_ = nullptr; + FrameSlotMeta *meta_ = nullptr; + uint8_t *data_ = nullptr; +}; + +} // namespace ipc +} // namespace internal +} // namespace engine +} // namespace olive + +#endif // OAK_OLIVEIMPL_RENDER_IPC_FRAMESLOTPOOL_H diff --git a/engine/src/oliveimpl/render/ipc/ipcmessage.h b/engine/src/oliveimpl/render/ipc/ipcmessage.h new file mode 100644 index 000000000..a557cfbfe --- /dev/null +++ b/engine/src/oliveimpl/render/ipc/ipcmessage.h @@ -0,0 +1,167 @@ +/*** + + 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 . + +***/ + +#ifndef OAK_OLIVEIMPL_RENDER_IPC_IPCMESSAGE_H +#define OAK_OLIVEIMPL_RENDER_IPC_IPCMESSAGE_H + +#include +#include +#include +#include +#include + +class QIODevice; + +namespace olive +{ +namespace engine +{ +namespace internal +{ +namespace ipc +{ + +/** + * @brief Control-plane protocol exchanged over stdio between main and render worker. + * + * The wire format is NDJSON: one compact QJsonObject per line, terminated by '\n'. This is + * deliberately human-readable so the channel can be inspected live with `tee`/`cat` and test + * messages can be injected by hand. The stdio channel carries only low-frequency control traffic; + * bulk pixel data travels through the shared-memory FrameSlotPool, and the (potentially large) + * serialized node graph travels via a temporary file referenced by path. + * + * Every message object has a "type" string field. Directionality (M = main, W = worker): + * "handshake" M<->W Negotiate protocol version and announce shared-memory key/geometry. + * "load_graph" M ->W Path to a temporary file holding the serialized node graph. + * "render_frame" M ->W Request a frame: node uuid, time, video params. + * "frame_ready" W ->M A rendered frame is published; carries the output-slot index + ticket. + * "cancel" M ->W Abandon an in-flight ticket by id. + * "graph_update" M ->W (Reserved, Phase 6) Incremental graph mutation, mirrors ProjectCopier. + * "shutdown" M ->W Finish current work and exit cleanly. + * "error" W ->M Worker-side failure report (human-readable "message" field). + */ +namespace msgtype +{ +constexpr const char *k_handshake = "handshake"; +constexpr const char *k_load_graph = "load_graph"; +constexpr const char *k_render_frame = "render_frame"; +constexpr const char *k_frame_ready = "frame_ready"; +constexpr const char *k_cancel = "cancel"; +constexpr const char *k_graph_update = "graph_update"; +constexpr const char *k_shutdown = "shutdown"; +constexpr const char *k_error = "error"; +} // namespace msgtype + +/** + * @brief Write one NDJSON message line to `device`. + * + * Serializes `obj` to compact JSON, appends '\n', and writes the whole line in one call. Returns + * true only if the full line was written. + */ +bool write_message(QIODevice *device, const QJsonObject &obj); + +/** + * @brief Pull one complete NDJSON line out of `buffer` and parse it. + * + * If `buffer` contains at least one '\n', the leading line is removed, parsed as JSON, and returned + * via `out` (true). If no complete line is buffered yet, leaves `buffer` untouched and returns + * false. Malformed lines are skipped (removed) and reported via `*ok = false` so the reader can log + * and continue rather than wedge. Supports the typical "append bytes as they arrive, then drain + * complete lines" reader loop on a pipe. + */ +bool read_message(QByteArray *buffer, QJsonObject *out, bool *ok = nullptr); + +// ---- Typed message builders / parsers ------------------------------------------------------- +// +// Thin helpers that construct or read the QJsonObject for each message type, keeping field names in +// one place so main and worker agree. Fields use plain JSON numbers/strings; 64-bit ids are stored +// as JSON numbers (doubles exactly represent integers up to 2^53, ample for our counters). + +struct HandshakeMsg { + int protocol_version = 0; + QString shm_key; ///< Worker->main output shared-memory segment key. + QString + input_shm_key; ///< Main->worker input shared-memory segment key (optional). + int input_slots = 0; ///< Number of main->worker input frame slots. + int output_slots = 0; ///< Number of worker->main output frame slots. + qint64 slot_data_bytes = 0; ///< Per-output-slot pixel block size. + qint64 input_slot_data_bytes = 0; ///< Per-input-slot pixel block size. + + QJsonObject to_json() const; + static bool from_json(const QJsonObject &o, HandshakeMsg *out); +}; + +struct RenderFrameMsg { + qint64 ticket_id = + 0; ///< Correlates this request with the eventual frame_ready. + QString + node_uuid; ///< Output/viewer node to render, by stable uuid in the loaded graph. + qint64 time_num = 0; + qint64 time_den = 1; + int width = 0; ///< Forced output size (0 = use graph default). + int height = 0; + int format = -1; ///< Forced PixelFormat::Format (-1 = default/INVALID). + int channel_count = 0; ///< 0 = default. + int mode = 0; ///< RenderMode::Mode. + int input_slot = + -1; ///< Optional main->worker decoded input slot for footage nodes. + QVector + input_slots; ///< Optional ordered decoded input slots for footage nodes. + + // Output color transform to apply before returning the frame. When empty, + // the worker returns the image in the project's reference space. + bool has_color_transform = false; + bool color_is_display = false; + QString color_output; + QString color_view; + QString color_look; + + QJsonObject to_json() const; + static bool from_json(const QJsonObject &o, RenderFrameMsg *out); +}; + +struct FrameReadyMsg { + qint64 ticket_id = 0; + int output_slot = 0; ///< Index into the worker->main output FrameSlotPool. + + QJsonObject to_json() const; + static bool from_json(const QJsonObject &o, FrameReadyMsg *out); +}; + +struct CancelMsg { + qint64 ticket_id = 0; + + QJsonObject to_json() const; + static bool from_json(const QJsonObject &o, CancelMsg *out); +}; + +struct LoadGraphMsg { + QString path; ///< Temporary file holding the serialized node graph. + + QJsonObject to_json() const; + static bool from_json(const QJsonObject &o, LoadGraphMsg *out); +}; + +} // namespace ipc +} // namespace internal +} // namespace engine +} // namespace olive + +#endif // OAK_OLIVEIMPL_RENDER_IPC_IPCMESSAGE_H diff --git a/engine/src/oliveimpl/render/ipc/sharedmemoryregion.h b/engine/src/oliveimpl/render/ipc/sharedmemoryregion.h new file mode 100644 index 000000000..1a72887e2 --- /dev/null +++ b/engine/src/oliveimpl/render/ipc/sharedmemoryregion.h @@ -0,0 +1,129 @@ +/*** + + 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 . + +***/ + +#ifndef OAK_OLIVEIMPL_RENDER_IPC_SHAREDMEMORYREGION_H +#define OAK_OLIVEIMPL_RENDER_IPC_SHAREDMEMORYREGION_H + +#include +#include + +namespace olive +{ +namespace engine +{ +namespace internal +{ +namespace ipc +{ + +/** + * @brief A named, fixed-size shared memory segment mapped into the process address space. + * + * One process Create()s the segment (owner); the peer process Attach()es to it by the same key. + * The mapping is a raw contiguous byte range accessible via data() — the IPC ring buffers and frame + * slot pools are laid out inside it. Nothing here is locked; synchronization is entirely the + * caller's responsibility via the lock-free structures placed in the mapping. + * + * We deliberately use the raw OS primitives (POSIX shm_open + mmap, Windows CreateFileMapping + + * MapViewOfFile) rather than QSharedMemory: QSharedMemory carries an implicit semaphore and a 1-byte + * header convention, attaches/detaches with reference counting we don't want, and historically has + * cross-platform lifetime quirks. For a render pipeline pushing large frames we want a plain mmap. + */ +class SharedMemoryRegion { +public: + enum Mode { + /// Create (and own) the segment. Fails if it already exists; unlinks on destruction. + k_create, + /// Attach to a segment created by the peer. Does not unlink on destruction. + k_attach + }; + + SharedMemoryRegion(); + ~SharedMemoryRegion(); + + SharedMemoryRegion(const SharedMemoryRegion &) = delete; + SharedMemoryRegion &operator=(const SharedMemoryRegion &) = delete; + + /** + * @brief Open the segment identified by `key` with the given `size` in bytes. + * + * `key` is a short identifier (no leading slash needed; the platform prefix is added internally). + * Returns true on success. On failure, error() carries a human-readable reason. + */ + bool open(const QString &key, size_t size, Mode mode); + + /** + * @brief Unmap and (if owner) unlink the segment. Called automatically by the destructor. + */ + void close(); + + bool is_valid() const + { + return data_ != nullptr; + } + + void *data() const + { + return data_; + } + + size_t size() const + { + return size_; + } + + const QString &key() const + { + return key_; + } + + const QString &error() const + { + return error_; + } + + /** + * @brief Build a unique segment key for a worker, e.g. "olive-rw--". + * + * Centralized so the owner and the spawned worker agree on the same name. + */ + static QString make_key(qint64 owner_pid, int worker_index); + +private: + QString key_; + size_t size_; + void *data_; + Mode mode_; + QString error_; + +#if defined(Q_OS_WIN) + void *handle_; // HANDLE from CreateFileMapping/OpenFileMapping +#else + int fd_; // file descriptor from shm_open + QString shm_name_; // the platform-prefixed name actually passed to shm_open +#endif +}; + +} // namespace ipc +} // namespace internal +} // namespace engine +} // namespace olive + +#endif // OAK_OLIVEIMPL_RENDER_IPC_SHAREDMEMORYREGION_H diff --git a/engine/tests/oakengine_ipc_test.cpp b/engine/tests/oakengine_ipc_test.cpp new file mode 100644 index 000000000..1d04c0d41 --- /dev/null +++ b/engine/tests/oakengine_ipc_test.cpp @@ -0,0 +1,591 @@ +/*** + + 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 . + +***/ + +// Pure C ABI test for the liboakengine render/ipc facade. Exercises the +// shared-memory region, the frame slot pool (including its version-1 wire +// layout) and every control-plane message build/parse pair. No Qt, no GL. + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#define getpid _getpid +#else +#include +#endif + +#include "oakengine/ipc.h" + +// ---- Version-1 wire layout: oak_frame_slot_meta must never move ---------- +static_assert(sizeof(oak_frame_slot_meta) == 176, "FrameSlotMeta size"); +static_assert(offsetof(oak_frame_slot_meta, id) == 0, "id offset"); +static_assert(offsetof(oak_frame_slot_meta, time_num) == 8, "time_num offset"); +static_assert(offsetof(oak_frame_slot_meta, time_den) == 16, "time_den offset"); +static_assert(offsetof(oak_frame_slot_meta, width) == 24, "width offset"); +static_assert(offsetof(oak_frame_slot_meta, height) == 28, "height offset"); +static_assert(offsetof(oak_frame_slot_meta, format) == 32, "format offset"); +static_assert(offsetof(oak_frame_slot_meta, channel_count) == 36, + "channel_count offset"); +static_assert(offsetof(oak_frame_slot_meta, linesize) == 40, "linesize offset"); +static_assert(offsetof(oak_frame_slot_meta, data_size) == 44, + "data_size offset"); +static_assert(offsetof(oak_frame_slot_meta, colorspace) == 48, + "colorspace offset"); + +static void assert_json_eq(const char *produced, const char *expected) +{ + if (strcmp(produced, expected) != 0) { + fprintf(stderr, "JSON mismatch:\n produced: %s\n expected: %s\n", + produced, expected); + assert(0); + } +} + +static void test_shm(void) +{ + // make_key: buf/size convention and exact format + char key[OAK_IPC_SHM_KEY_CAP]; + const int64_t pid = getpid(); + int needed = oakengine_ipc_shm_make_key(pid, 0, NULL, 0); + char expected[64]; + snprintf(expected, sizeof(expected), "olive-rw-%lld-0", (long long)pid); + assert(needed == (int)strlen(expected)); + assert(oakengine_ipc_shm_make_key(pid, 0, key, sizeof(key)) == needed); + assert(strcmp(key, expected) == 0); + assert(oakengine_ipc_shm_make_key(pid, 0, key, 6) == needed); // truncated + assert(strcmp(key, "olive") == 0); + // A different worker index produces a different key. + char key1[OAK_IPC_SHM_KEY_CAP]; + assert(oakengine_ipc_shm_make_key(pid, 1, key1, sizeof(key1)) == needed); + assert(strcmp(key1, expected) != 0); + + const int key_len = oakengine_ipc_shm_make_key(pid, 0, key, sizeof(key)); + (void)key_len; + + // Attaching to a missing segment fails and reports an error. + OakSharedMemoryRegion *missing = oakengine_ipc_shm_create(); + assert(oakengine_ipc_shm_open(missing, key, 4096, + OAK_IPC_SHM_MODE_ATTACH) == 0); + assert(oakengine_ipc_shm_is_valid(missing) == 0); + char err[256]; + assert(oakengine_ipc_shm_error(missing, err, sizeof(err)) > 0); + assert(strlen(err) > 0); + oakengine_ipc_shm_free(missing); + + // Create the owner: valid, sized, zero-initialized. + OakSharedMemoryRegion *owner = oakengine_ipc_shm_create(); + assert(oakengine_ipc_shm_open(owner, key, 8192, OAK_IPC_SHM_MODE_CREATE) == + 1); + assert(oakengine_ipc_shm_is_valid(owner) == 1); + assert(oakengine_ipc_shm_size(owner) == 8192); + unsigned char *data = (unsigned char *)oakengine_ipc_shm_data(owner); + assert(data != NULL); + for (int i = 0; i < 8192; i++) { + assert(data[i] == 0); + } + + // key() round-trips the opened key. + char opened_key[OAK_IPC_SHM_KEY_CAP]; + assert(oakengine_ipc_shm_key(owner, opened_key, sizeof(opened_key)) == + key_len); + assert(strcmp(opened_key, key) == 0); + + // A peer attaches by the same key and sees the owner's bytes. + data[0] = 0xAB; + data[8191] = 0xCD; + OakSharedMemoryRegion *peer = oakengine_ipc_shm_create(); + assert(oakengine_ipc_shm_open(peer, key, 8192, OAK_IPC_SHM_MODE_ATTACH) == + 1); + assert(oakengine_ipc_shm_size(peer) == 8192); + const unsigned char *peer_data = + (const unsigned char *)oakengine_ipc_shm_data(peer); + assert(peer_data[0] == 0xAB && peer_data[8191] == 0xCD); + peer_data = NULL; + + // The peer closes without unlinking; the owner stays valid. + oakengine_ipc_shm_close(peer); + assert(oakengine_ipc_shm_is_valid(peer) == 0); + assert(oakengine_ipc_shm_is_valid(owner) == 1); + oakengine_ipc_shm_free(peer); + + // The owner unlinks on close: a late attach must fail. + oakengine_ipc_shm_close(owner); + assert(oakengine_ipc_shm_is_valid(owner) == 0); + OakSharedMemoryRegion *late = oakengine_ipc_shm_create(); + assert(oakengine_ipc_shm_open(late, key, 8192, OAK_IPC_SHM_MODE_ATTACH) == + 0); + oakengine_ipc_shm_free(late); + oakengine_ipc_shm_free(owner); + + // NULL safety. + oakengine_ipc_shm_close(NULL); + assert(oakengine_ipc_shm_is_valid(NULL) == 0); + assert(oakengine_ipc_shm_data(NULL) == NULL); + assert(oakengine_ipc_shm_size(NULL) == 0); + oakengine_ipc_shm_free(NULL); +} + +static void test_framepool(void) +{ + const uint32_t k_slots = 3; + const size_t k_slot_bytes = 256; + + // bytes_needed stays 64-aligned and grows with geometry. + assert(oakengine_ipc_framepool_bytes_needed(k_slots, k_slot_bytes) % 64 == + 0); + assert(oakengine_ipc_framepool_bytes_needed(2, 65) == + oakengine_ipc_framepool_bytes_needed(2, 128)); + assert(oakengine_ipc_framepool_bytes_needed(1, 64) < + oakengine_ipc_framepool_bytes_needed(2, 64)); + + const size_t bytes = + oakengine_ipc_framepool_bytes_needed(k_slots, k_slot_bytes); + void *mem = malloc(bytes); + assert(mem != NULL); + + OakFrameSlotPool *filler = + oakengine_ipc_framepool_create(mem, k_slots, k_slot_bytes); + assert(filler != NULL); + assert(oakengine_ipc_framepool_is_valid(filler) == 1); + assert(oakengine_ipc_framepool_slot_count(filler) == k_slots); + assert(oakengine_ipc_framepool_slot_data_bytes(filler) == k_slot_bytes); + + // Peer maps the same memory; a copy of the handle views it too. + OakFrameSlotPool *drainer = oakengine_ipc_framepool_attach(mem); + assert(oakengine_ipc_framepool_is_valid(drainer) == 1); + OakFrameSlotPool *drainer_copy = oakengine_ipc_framepool_copy(drainer); + assert(drainer_copy != NULL && drainer_copy != drainer); + assert(oakengine_ipc_framepool_slot_count(drainer_copy) == k_slots); + + // Free slots are issued FIFO; the pool then reports exhausted. + uint32_t held[3]; + for (uint32_t i = 0; i < k_slots; i++) { + assert(oakengine_ipc_framepool_acquire(filler, &held[i]) == 1); + assert(held[i] == i); + } + uint32_t overflow = 99; + assert(oakengine_ipc_framepool_acquire(filler, &overflow) == 0); + assert(overflow == 99); + + // Fill slot 0 with a pattern + full metadata and publish it. + unsigned char *data = + (unsigned char *)oakengine_ipc_framepool_slot_data(filler, held[0]); + assert(data != NULL); + for (size_t i = 0; i < k_slot_bytes; i++) { + data[i] = (unsigned char)(i & 0xFF); + } + oak_frame_slot_meta *meta = oakengine_ipc_framepool_meta(filler, held[0]); + assert(meta != NULL); + meta->id = -4242; + meta->time_num = 1001; + meta->time_den = 30000; + meta->width = 3840; + meta->height = 2160; + meta->format = 17; + meta->channel_count = 4; + meta->linesize = 3840 * 4 * 4; + meta->data_size = (int32_t)k_slot_bytes; + strncpy(meta->colorspace, "acescg", sizeof(meta->colorspace) - 1); + meta->colorspace[sizeof(meta->colorspace) - 1] = '\0'; + assert(oakengine_ipc_framepool_publish(filler, held[0]) == 1); + + // Publish slot 2 then slot 1: consume order follows publish order. + assert(oakengine_ipc_framepool_publish(filler, held[2]) == 1); + assert(oakengine_ipc_framepool_publish(filler, held[1]) == 1); + + uint32_t got = 99; + assert(oakengine_ipc_framepool_consume(drainer, &got) == 1); + assert(got == held[0]); + + // Metadata and pixels arrive intact on the drainer side. + const oak_frame_slot_meta *out = + oakengine_ipc_framepool_meta_const(drainer, got); + assert(out != NULL); + assert(out->id == -4242); + assert(out->time_num == 1001 && out->time_den == 30000); + assert(out->width == 3840 && out->height == 2160); + assert(out->format == 17 && out->channel_count == 4); + assert(out->linesize == 3840 * 4 * 4); + assert(out->data_size == (int32_t)k_slot_bytes); + assert(strcmp(out->colorspace, "acescg") == 0); + const unsigned char *out_data = + (const unsigned char *)oakengine_ipc_framepool_slot_data_const(drainer, + got); + assert(out_data != NULL); + for (size_t i = 0; i < k_slot_bytes; i++) { + assert(out_data[i] == (unsigned char)(i & 0xFF)); + } + assert(oakengine_ipc_framepool_release(drainer, got) == 1); + + // The copied handle consumes the remaining slots in publish order. + assert(oakengine_ipc_framepool_consume(drainer_copy, &got) == 1); + assert(got == held[2]); + assert(oakengine_ipc_framepool_release(drainer_copy, got) == 1); + assert(oakengine_ipc_framepool_consume(drainer_copy, &got) == 1); + assert(got == held[1]); + assert(oakengine_ipc_framepool_release(drainer_copy, got) == 1); + assert(oakengine_ipc_framepool_consume(drainer, &got) == 0); // drained + + // Released slots cycle back to the filler. + uint32_t again = 0; + assert(oakengine_ipc_framepool_acquire(filler, &again) == 1); + + oakengine_ipc_framepool_free(drainer_copy); + oakengine_ipc_framepool_free(drainer); + oakengine_ipc_framepool_free(filler); + free(mem); + + // A region without the pool magic attaches as invalid. + void *raw = calloc(1, oakengine_ipc_framepool_bytes_needed(2, 64)); + assert(raw != NULL); + OakFrameSlotPool *bad = oakengine_ipc_framepool_attach(raw); + assert(bad != NULL); + assert(oakengine_ipc_framepool_is_valid(bad) == 0); + assert(oakengine_ipc_framepool_slot_count(bad) == 0); + assert(oakengine_ipc_framepool_slot_data_bytes(bad) == 0); + oakengine_ipc_framepool_free(bad); + free(raw); + + // NULL safety. + assert(oakengine_ipc_framepool_is_valid(NULL) == 0); + assert(oakengine_ipc_framepool_slot_count(NULL) == 0); + assert(oakengine_ipc_framepool_slot_data(NULL, 0) == NULL); + assert(oakengine_ipc_framepool_meta(NULL, 0) == NULL); + assert(oakengine_ipc_framepool_acquire(NULL, &again) == 0); + oakengine_ipc_framepool_free(NULL); +} + +// Frame hand-off across two live shared-memory mappings, the app<->worker shape. +static void test_framepool_over_shm(void) +{ + char key[OAK_IPC_SHM_KEY_CAP]; + assert(oakengine_ipc_shm_make_key(getpid(), 7, key, sizeof(key)) > 0); + + const uint32_t k_slots = 2; + const size_t k_slot_bytes = 64; + const size_t bytes = + oakengine_ipc_framepool_bytes_needed(k_slots, k_slot_bytes); + + OakSharedMemoryRegion *owner = oakengine_ipc_shm_create(); + assert(oakengine_ipc_shm_open(owner, key, bytes, OAK_IPC_SHM_MODE_CREATE) == + 1); + OakSharedMemoryRegion *peer = oakengine_ipc_shm_create(); + assert(oakengine_ipc_shm_open(peer, key, bytes, OAK_IPC_SHM_MODE_ATTACH) == + 1); + + OakFrameSlotPool *filler = oakengine_ipc_framepool_create( + oakengine_ipc_shm_data(owner), k_slots, k_slot_bytes); + OakFrameSlotPool *drainer = oakengine_ipc_framepool_attach( + oakengine_ipc_shm_data(peer)); + assert(oakengine_ipc_framepool_is_valid(filler) == 1); + assert(oakengine_ipc_framepool_is_valid(drainer) == 1); + + uint32_t idx = 0; + assert(oakengine_ipc_framepool_acquire(filler, &idx) == 1); + oak_frame_slot_meta *meta = oakengine_ipc_framepool_meta(filler, idx); + meta->id = 77; + meta->data_size = (int32_t)k_slot_bytes; + memset(oakengine_ipc_framepool_slot_data(filler, idx), 0x5A, k_slot_bytes); + assert(oakengine_ipc_framepool_publish(filler, idx) == 1); + + uint32_t got = 0; + assert(oakengine_ipc_framepool_consume(drainer, &got) == 1); + assert(got == idx); + assert(oakengine_ipc_framepool_meta_const(drainer, got)->id == 77); + const unsigned char *d = (const unsigned char *) + oakengine_ipc_framepool_slot_data_const(drainer, got); + assert(d[0] == 0x5A && d[k_slot_bytes - 1] == 0x5A); + assert(oakengine_ipc_framepool_release(drainer, got) == 1); + + oakengine_ipc_framepool_free(drainer); + oakengine_ipc_framepool_free(filler); + oakengine_ipc_shm_free(peer); + oakengine_ipc_shm_free(owner); // owner frees last: unlinks the segment +} + +static void test_handshake(void) +{ + oak_ipc_handshake hs; + memset(&hs, 0, sizeof(hs)); + hs.protocol_version = 1; + strcpy(hs.shm_key, "olive-rw-1234-0-out"); + strcpy(hs.input_shm_key, "olive-rw-1234-1-in"); + hs.input_slots = 4; + hs.output_slots = 6; + hs.slot_data_bytes = 256ll * 1024 * 1024; + hs.input_slot_data_bytes = 128ll * 1024 * 1024; + + const int needed = oakengine_ipc_handshake_to_json(&hs, NULL, 0); + assert(needed > 0); + char *json = (char *)malloc(size_t(needed) + 1); + assert(json != NULL); + assert(oakengine_ipc_handshake_to_json(&hs, json, needed + 1) == needed); + assert((int)strlen(json) == needed); + + // buf/size convention: a short buffer truncates but reports the full size. + char tiny[8]; + assert(oakengine_ipc_handshake_to_json(&hs, tiny, sizeof(tiny)) == needed); + assert(strlen(tiny) == sizeof(tiny) - 1); + + assert(oakengine_ipc_message_type(json) == OAK_IPC_MSGTYPE_HANDSHAKE); + + oak_ipc_handshake back; + assert(oakengine_ipc_handshake_parse(json, &back) == 1); + assert(back.protocol_version == hs.protocol_version); + assert(strcmp(back.shm_key, hs.shm_key) == 0); + assert(strcmp(back.input_shm_key, hs.input_shm_key) == 0); + assert(back.input_slots == hs.input_slots); + assert(back.output_slots == hs.output_slots); + assert(back.slot_data_bytes == hs.slot_data_bytes); + assert(back.input_slot_data_bytes == hs.input_slot_data_bytes); + free(json); + + // Exact wire text: same field names and compact shape the Qt builder emits. + oak_ipc_handshake small; + memset(&small, 0, sizeof(small)); + small.protocol_version = 1; + strcpy(small.shm_key, "out"); + strcpy(small.input_shm_key, "in"); + small.input_slots = 4; + small.output_slots = 6; + small.slot_data_bytes = 256; + small.input_slot_data_bytes = 128; + const int n2 = oakengine_ipc_handshake_to_json(&small, NULL, 0); + char *json2 = (char *)malloc(size_t(n2) + 1); + assert(json2 != NULL); + assert(oakengine_ipc_handshake_to_json(&small, json2, n2 + 1) == n2); + assert_json_eq(json2, + "{\"input_shm_key\":\"in\",\"input_slot_data_bytes\":128," + "\"input_slots\":4,\"output_slots\":6,\"protocol_version\":1," + "\"shm_key\":\"out\",\"slot_data_bytes\":256," + "\"type\":\"handshake\"}"); + free(json2); + + assert(oakengine_ipc_handshake_to_json(NULL, NULL, 0) == -1); + assert(oakengine_ipc_handshake_parse("not json", &back) == 0); + assert(oakengine_ipc_handshake_parse(NULL, &back) == 0); +} + +static void test_render_frame(void) +{ + oak_ipc_render_frame rf; + memset(&rf, 0, sizeof(rf)); + rf.ticket_id = (int64_t(1) << 52) + 12345; // exact as a JSON double + strcpy(rf.node_uuid, "{abcd-1234}"); + rf.time_num = (int64_t)48000 * 123456789; + rf.time_den = int64_t(1) << 40; + rf.width = 1920; + rf.height = 1080; + rf.format = 3; + rf.channel_count = 4; + rf.mode = 1; + rf.input_slot = 2; + rf.input_slots[0] = 2; + rf.input_slots[1] = 3; + rf.input_slot_count = 2; + rf.has_color_transform = 1; + rf.color_is_display = 1; + strcpy(rf.color_output, "sRGB - Display"); + strcpy(rf.color_view, "ACES 1.0 SDR-video"); + strcpy(rf.color_look, "None"); + + const int needed = oakengine_ipc_render_frame_to_json(&rf, NULL, 0); + char *json = (char *)malloc(size_t(needed) + 1); + assert(json != NULL); + assert(oakengine_ipc_render_frame_to_json(&rf, json, needed + 1) == needed); + assert(oakengine_ipc_message_type(json) == OAK_IPC_MSGTYPE_RENDER_FRAME); + + oak_ipc_render_frame back; + assert(oakengine_ipc_render_frame_parse(json, &back) == 1); + assert(back.ticket_id == rf.ticket_id); + assert(strcmp(back.node_uuid, rf.node_uuid) == 0); + assert(back.time_num == rf.time_num); + assert(back.time_den == rf.time_den); + assert(back.width == rf.width && back.height == rf.height); + assert(back.format == rf.format && back.channel_count == rf.channel_count); + assert(back.mode == rf.mode); + assert(back.input_slot == rf.input_slot); + assert(back.input_slot_count == 2); + assert(back.input_slots[0] == 2 && back.input_slots[1] == 3); + assert(back.has_color_transform == 1 && back.color_is_display == 1); + assert(strcmp(back.color_output, rf.color_output) == 0); + assert(strcmp(back.color_view, rf.color_view) == 0); + assert(strcmp(back.color_look, rf.color_look) == 0); + free(json); + + // Without a color transform the color keys are omitted from the wire text. + oak_ipc_render_frame plain; + memset(&plain, 0, sizeof(plain)); + plain.ticket_id = 99; + strcpy(plain.node_uuid, "{abcd-1234}"); + plain.time_num = 1001; + plain.time_den = 30000; + plain.width = 1920; + plain.height = 1080; + plain.format = 3; + plain.channel_count = 4; + plain.mode = 1; + plain.input_slot = 2; + plain.input_slots[0] = 2; + plain.input_slots[1] = 3; + plain.input_slot_count = 2; + const int n2 = oakengine_ipc_render_frame_to_json(&plain, NULL, 0); + char *json2 = (char *)malloc(size_t(n2) + 1); + assert(json2 != NULL); + assert(oakengine_ipc_render_frame_to_json(&plain, json2, n2 + 1) == n2); + assert(strstr(json2, "has_color_transform") == NULL); + assert_json_eq(json2, + "{\"channels\":4,\"format\":3,\"height\":1080," + "\"input_slot\":2,\"input_slots\":[2,3],\"mode\":1," + "\"node\":\"{abcd-1234}\",\"ticket\":99,\"time_den\":30000," + "\"time_num\":1001,\"type\":\"render_frame\",\"width\":1920}"); + free(json2); + + // Defaults for a sparse message, mirroring the Qt from_json defaults. + oak_ipc_render_frame sparse; + assert(oakengine_ipc_render_frame_parse("{\"type\":\"render_frame\"}", + &sparse) == 1); + assert(sparse.ticket_id == 0); + assert(sparse.time_num == 0 && sparse.time_den == 1); + assert(sparse.width == 0 && sparse.height == 0); + assert(sparse.format == -1 && sparse.channel_count == 0 && sparse.mode == 0); + assert(sparse.input_slot == -1 && sparse.input_slot_count == 0); + assert(sparse.has_color_transform == 0); + + // Legacy scalar input_slot folds into the array when it is absent. + oak_ipc_render_frame legacy; + assert(oakengine_ipc_render_frame_parse( + "{\"type\":\"render_frame\",\"ticket\":5,\"input_slot\":3}", + &legacy) == 1); + assert(legacy.input_slot == 3); + assert(legacy.input_slot_count == 1 && legacy.input_slots[0] == 3); + + // Type mismatches are rejected. + oak_ipc_handshake hs; + memset(&hs, 0, sizeof(hs)); + const int hn = oakengine_ipc_handshake_to_json(&hs, NULL, 0); + char *hjson = (char *)malloc(size_t(hn) + 1); + assert(hjson != NULL); + assert(oakengine_ipc_handshake_to_json(&hs, hjson, hn + 1) == hn); + assert(oakengine_ipc_render_frame_parse(hjson, &back) == 0); + assert(oakengine_ipc_handshake_parse(hjson, &hs) == 1); + free(hjson); +} + +static void test_small_messages(void) +{ + // frame_ready + oak_ipc_frame_ready fr; + fr.ticket_id = 99; + fr.output_slot = 2; + const int fn = oakengine_ipc_frame_ready_to_json(&fr, NULL, 0); + char *fjson = (char *)malloc(size_t(fn) + 1); + assert(fjson != NULL); + assert(oakengine_ipc_frame_ready_to_json(&fr, fjson, fn + 1) == fn); + assert_json_eq(fjson, "{\"slot\":2,\"ticket\":99,\"type\":\"frame_ready\"}"); + assert(oakengine_ipc_message_type(fjson) == OAK_IPC_MSGTYPE_FRAME_READY); + oak_ipc_frame_ready fr_back; + assert(oakengine_ipc_frame_ready_parse(fjson, &fr_back) == 1); + assert(fr_back.ticket_id == 99 && fr_back.output_slot == 2); + free(fjson); + + // cancel + oak_ipc_cancel cancel; + cancel.ticket_id = 7; + const int cn = oakengine_ipc_cancel_to_json(&cancel, NULL, 0); + char *cjson = (char *)malloc(size_t(cn) + 1); + assert(cjson != NULL); + assert(oakengine_ipc_cancel_to_json(&cancel, cjson, cn + 1) == cn); + assert_json_eq(cjson, "{\"ticket\":7,\"type\":\"cancel\"}"); + assert(oakengine_ipc_message_type(cjson) == OAK_IPC_MSGTYPE_CANCEL); + oak_ipc_cancel cancel_back; + assert(oakengine_ipc_cancel_parse(cjson, &cancel_back) == 1); + assert(cancel_back.ticket_id == 7); + // cancel rejects a frame_ready payload. + assert(oakengine_ipc_cancel_parse("{\"slot\":2,\"ticket\":7,\"type\":" + "\"frame_ready\"}", + &cancel_back) == 0); + free(cjson); + + // load_graph + oak_ipc_load_graph load; + strcpy(load.path, "/tmp/oak-render-graph-abc123.ove"); + const int ln = oakengine_ipc_load_graph_to_json(&load, NULL, 0); + char *ljson = (char *)malloc(size_t(ln) + 1); + assert(ljson != NULL); + assert(oakengine_ipc_load_graph_to_json(&load, ljson, ln + 1) == ln); + assert_json_eq(ljson, + "{\"path\":\"/tmp/oak-render-graph-abc123.ove\",\"type\":" + "\"load_graph\"}"); + assert(oakengine_ipc_message_type(ljson) == OAK_IPC_MSGTYPE_LOAD_GRAPH); + oak_ipc_load_graph load_back; + assert(oakengine_ipc_load_graph_parse(ljson, &load_back) == 1); + assert(strcmp(load_back.path, load.path) == 0); + free(ljson); + + // shutdown + const int sn = oakengine_ipc_shutdown_to_json(NULL, 0); + char *sjson = (char *)malloc(size_t(sn) + 1); + assert(sjson != NULL); + assert(oakengine_ipc_shutdown_to_json(sjson, sn + 1) == sn); + assert_json_eq(sjson, "{\"type\":\"shutdown\"}"); + assert(oakengine_ipc_shutdown_parse(sjson) == 1); + assert(oakengine_ipc_shutdown_parse("{\"type\":\"cancel\"}") == 0); + assert(oakengine_ipc_message_type(sjson) == OAK_IPC_MSGTYPE_SHUTDOWN); + free(sjson); + + // error + const int en = oakengine_ipc_error_to_json("boom", NULL, 0); + char *ejson = (char *)malloc(size_t(en) + 1); + assert(ejson != NULL); + assert(oakengine_ipc_error_to_json("boom", ejson, en + 1) == en); + assert_json_eq(ejson, "{\"message\":\"boom\",\"type\":\"error\"}"); + assert(oakengine_ipc_message_type(ejson) == OAK_IPC_MSGTYPE_ERROR); + char msg[OAK_IPC_ERROR_MESSAGE_CAP]; + assert(oakengine_ipc_error_parse(ejson, msg, sizeof(msg)) == 1); + assert(strcmp(msg, "boom") == 0); + assert(oakengine_ipc_error_parse("{\"type\":\"shutdown\"}", msg, + sizeof(msg)) == 0); + free(ejson); + + // Unknown and malformed input. + assert(oakengine_ipc_message_type("{\"type\":\"nope\"}") == + OAK_IPC_MSGTYPE_UNKNOWN); + assert(oakengine_ipc_message_type("garbage") == OAK_IPC_MSGTYPE_UNKNOWN); + assert(oakengine_ipc_message_type(NULL) == OAK_IPC_MSGTYPE_UNKNOWN); + assert(OAK_IPC_MSGTYPE_GRAPH_UPDATE != OAK_IPC_MSGTYPE_UNKNOWN); +} + +int main() +{ + test_shm(); + test_framepool(); + test_framepool_over_shm(); + test_handshake(); + test_render_frame(); + test_small_messages(); + + printf("oakengine_ipc_test: all assertions passed\n"); + return 0; +} diff --git a/tests/gtest/render_ipc_test.cpp b/tests/gtest/render_ipc_test.cpp index 90e69d778..06d048a53 100644 --- a/tests/gtest/render_ipc_test.cpp +++ b/tests/gtest/render_ipc_test.cpp @@ -22,9 +22,9 @@ #include #include +#include "oakengine/spscringbuffer.h" #include "render/ipc/frameslotpool.h" #include "render/ipc/ipcmessage.h" -#include "render/ipc/spscringbuffer.h" using namespace olive::ipc; diff --git a/worker/workermain.cpp b/worker/workermain.cpp index 150a47721..adf61bd08 100644 --- a/worker/workermain.cpp +++ b/worker/workermain.cpp @@ -515,8 +515,12 @@ private: static_cast(nullptr))); ticket->setProperty( "ipc_input_pool", + // The engine reads this back as the internal implementation object + // (olive::engine::internal::ipc::FrameSlotPool), which is exactly + // what the C handle points at. olive::QtUtils::ptr_to_value(input_pool_ ? - static_cast(&*input_pool_) : + static_cast( + input_pool_->handle()) : static_cast(nullptr))); QVariantList input_slot_values; for (int slot : input_slots) {