From 853e7250b30dd90f4d829be77fd6a5705023abf9 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Thu, 18 Jun 2026 14:01:50 +0300 Subject: [PATCH 1/2] feat: initial Decart C++ SDK (realtime + auth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimal, professional C++17 SDK for Decart's realtime models, built on the official LiveKit C++ SDK for media transport. - decart::Client with API-key/base-URL config (DECART_API_KEY fallback) - Realtime: client.realtime().connect(videoSource, opts) — publish frames, receive transformed frames, steer with setPrompt/set/setImage; connection-state, queue-position, generation, and error callbacks - Auth: client.tokens().create() — short-lived client tokens - Model registry: decart::models::realtime(...) - Transport: WebSocket signaling (IXWebSocket) + LiveKit/WebRTC media - Optional auth-only build (DECART_BUILD_REALTIME=OFF) with no LiveKit dependency - CMake packaging (vcpkg manifest + FetchContent fallback + prebuilt LiveKit download), examples, doctest unit tests, and Linux/macOS/Windows CI --- .clang-format | 8 + .clang-tidy | 17 ++ .github/workflows/ci.yml | 62 ++++ .github/workflows/release.yml | 33 ++ .gitignore | 25 ++ AGENTS.md | 51 ++++ CMakeLists.txt | 224 ++++++++++++++ CMakePresets.json | 51 ++++ LICENSE | 21 ++ README.md | 174 +++++++++++ cmake/LiveKitSDK.cmake | 266 ++++++++++++++++ cmake/decart-config.cmake.in | 15 + examples/CMakeLists.txt | 18 ++ examples/create_token.cpp | 34 +++ examples/realtime_synthetic.cpp | 104 +++++++ examples/realtime_video.cpp | 138 +++++++++ include/decart/client.h | 74 +++++ include/decart/decart.h | 21 ++ include/decart/errors.h | 65 ++++ include/decart/logging.h | 25 ++ include/decart/models.h | 46 +++ include/decart/realtime/realtime.h | 91 ++++++ include/decart/realtime/session.h | 81 +++++ include/decart/realtime/types.h | 71 +++++ include/decart/tokens.h | 53 ++++ include/decart/version.h | 20 ++ scripts/format.sh | 14 + src/client.cpp | 86 ++++++ src/detail/base64.cpp | 48 +++ src/detail/base64.h | 15 + src/detail/client_config.h | 17 ++ src/detail/http_client.cpp | 41 +++ src/detail/http_client.h | 21 ++ src/detail/livekit_runtime.h | 11 + src/detail/log.h | 18 ++ src/detail/signaling_channel.cpp | 262 ++++++++++++++++ src/detail/signaling_channel.h | 123 ++++++++ src/detail/url.h | 33 ++ src/detail/user_agent.cpp | 46 +++ src/detail/user_agent.h | 12 + src/errors.cpp | 36 +++ src/logging.cpp | 69 +++++ src/models.cpp | 70 +++++ src/realtime/messages.cpp | 101 +++++++ src/realtime/messages.h | 78 +++++ src/realtime/realtime.cpp | 16 + src/realtime/session.cpp | 467 +++++++++++++++++++++++++++++ src/realtime/session_internal.h | 21 ++ src/realtime/types.cpp | 22 ++ src/tokens.cpp | 57 ++++ tests/CMakeLists.txt | 39 +++ tests/test_base64.cpp | 28 ++ tests/test_enums.cpp | 33 ++ tests/test_main.cpp | 3 + tests/test_messages.cpp | 102 +++++++ tests/test_models.cpp | 46 +++ vcpkg.json | 22 ++ 57 files changed, 3745 insertions(+) create mode 100644 .clang-format create mode 100644 .clang-tidy create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmake/LiveKitSDK.cmake create mode 100644 cmake/decart-config.cmake.in create mode 100644 examples/CMakeLists.txt create mode 100644 examples/create_token.cpp create mode 100644 examples/realtime_synthetic.cpp create mode 100644 examples/realtime_video.cpp create mode 100644 include/decart/client.h create mode 100644 include/decart/decart.h create mode 100644 include/decart/errors.h create mode 100644 include/decart/logging.h create mode 100644 include/decart/models.h create mode 100644 include/decart/realtime/realtime.h create mode 100644 include/decart/realtime/session.h create mode 100644 include/decart/realtime/types.h create mode 100644 include/decart/tokens.h create mode 100644 include/decart/version.h create mode 100755 scripts/format.sh create mode 100644 src/client.cpp create mode 100644 src/detail/base64.cpp create mode 100644 src/detail/base64.h create mode 100644 src/detail/client_config.h create mode 100644 src/detail/http_client.cpp create mode 100644 src/detail/http_client.h create mode 100644 src/detail/livekit_runtime.h create mode 100644 src/detail/log.h create mode 100644 src/detail/signaling_channel.cpp create mode 100644 src/detail/signaling_channel.h create mode 100644 src/detail/url.h create mode 100644 src/detail/user_agent.cpp create mode 100644 src/detail/user_agent.h create mode 100644 src/errors.cpp create mode 100644 src/logging.cpp create mode 100644 src/models.cpp create mode 100644 src/realtime/messages.cpp create mode 100644 src/realtime/messages.h create mode 100644 src/realtime/realtime.cpp create mode 100644 src/realtime/session.cpp create mode 100644 src/realtime/session_internal.h create mode 100644 src/realtime/types.cpp create mode 100644 src/tokens.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test_base64.cpp create mode 100644 tests/test_enums.cpp create mode 100644 tests/test_main.cpp create mode 100644 tests/test_messages.cpp create mode 100644 tests/test_models.cpp create mode 100644 vcpkg.json diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..22cf750 --- /dev/null +++ b/.clang-format @@ -0,0 +1,8 @@ +--- +BasedOnStyle: Google +Language: Cpp +Standard: c++17 + +ColumnLimit: 110 +SpacesBeforeTrailingComments: 1 +AccessModifierOffset: -2 diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..9371a07 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,17 @@ +--- +# Pragmatic checks for the SDK. Third-party headers (LiveKit, IXWebSocket, +# nlohmann) are excluded via HeaderFilterRegex. +Checks: > + -*, + bugprone-*, + performance-*, + modernize-use-override, + modernize-use-nullptr, + modernize-use-using, + readability-redundant-*, + -bugprone-easily-swappable-parameters, + -bugprone-exception-escape + +WarningsAsErrors: '' +HeaderFilterRegex: '.*/(include|src)/decart/.*|.*/src/detail/.*|.*/src/realtime/.*' +FormatStyle: file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..326527d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: build & test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + env: + # Used by cmake/LiveKitSDK.cmake to resolve & download the LiveKit release. + GITHUB_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + + - name: Install build tools (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y ninja-build libssl-dev zlib1g-dev + + - name: Install build tools (macOS) + if: runner.os == 'macOS' + run: brew install ninja + + - name: Configure & build (Unix) + if: runner.os != 'Windows' + run: | + cmake -B build -S . -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DDECART_BUILD_EXAMPLES=ON -DDECART_BUILD_TESTS=ON + cmake --build build + + - name: Configure & build (Windows, vcpkg) + if: runner.os == 'Windows' + shell: pwsh + run: | + cmake -B build -S . ` + -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT\scripts\buildsystems\vcpkg.cmake" ` + -DVCPKG_MANIFEST_FEATURES=tests ` + -DDECART_BUILD_EXAMPLES=ON -DDECART_BUILD_TESTS=ON + cmake --build build --config Release + + - name: Test + run: ctest --test-dir build --output-on-failure -C Release + + format: + name: clang-format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check formatting + run: | + sudo apt-get update && sudo apt-get install -y clang-format + ./scripts/format.sh --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5ba7d98 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Create GitHub release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify tag matches CMake project version + run: | + tag="${GITHUB_REF_NAME#v}" + proj="$(sed -n 's/^[[:space:]]*VERSION[[:space:]]*\([0-9][0-9.]*\).*/\1/p' CMakeLists.txt | head -1)" + echo "tag=$tag project=$proj" + if [ "$tag" != "$proj" ]; then + echo "::error::Tag v$tag does not match CMake project version $proj" + exit 1 + fi + + - name: Create release with autogenerated notes + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --generate-notes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0648a4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Build output +/build/ +build-*/ +cmake-build-*/ +out/ + +# CMake +CMakeCache.txt +CMakeFiles/ +CMakeUserPresets.json +_deps/ +*.cmake.user + +# vcpkg +vcpkg_installed/ + +# Editor / OS +.vscode/ +.idea/ +.cache/ +compile_commands.json +.DS_Store + +# Workspace +.context/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..74ae372 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# Decart C++ SDK — Coding Agent Guidelines + +## Build / test commands + +- `cmake --preset release` — configure (downloads LiveKit, fetches IXWebSocket/json) +- `cmake --build --preset release` — build library, examples, tests +- `ctest --preset release` — run unit tests (or run `build/release/tests/decart_tests`) +- `./scripts/format.sh` / `clang-format -i` — format (`.clang-format`, 110 cols, Google base) +- Auth/tokens-only build (no LiveKit): `cmake -B build -S . -DDECART_BUILD_REALTIME=OFF` + (compiles out `Client::realtime()` via the `DECART_NO_REALTIME` interface define) + +## Architecture + +A realtime session = **WebSocket signaling** (control) + **LiveKit/WebRTC** (media). + +- `include/decart/` — public API. `client.h` (Client/ClientOptions), `models.h` + (model registry), `tokens.h` (auth), `errors.h`, `logging.h`, + `realtime/{realtime,session,types}.h`. +- `src/` — implementation. + - `client.cpp`, `models.cpp`, `tokens.cpp`, `logging.cpp`, `errors.cpp` + - `detail/` — internal helpers: `http_client` (IXWebSocket HTTP), `signaling_channel` + (WebSocket + ack/event dispatch), `base64`, `url`, `user_agent`, `client_config`, `log`. + - `realtime/` — `messages` (pure JSON codec, unit-tested), `realtime` (RealtimeClient + forwarders), `session` (the orchestration + LiveKit `RoomDelegate` + frame pump). +- `tests/` — doctest unit tests for the dependency-light core (models, message codec, + base64, enums). Keep new pure logic testable here. + +## Conventions + +- **C++17**. PascalCase types, camelCase methods, `decart::` namespace, internal + helpers under `decart::detail`. This matches the LiveKit C++ SDK ergonomics, since + callers mix `decart::` and `livekit::` types in the same code. +- Public headers stay light: forward-declare `livekit::VideoSource`/`VideoFrame` + rather than including LiveKit; never expose `nlohmann::json` in the public API. +- Errors: throw `decart::Exception{Error{code, message}}` for synchronous failures; + deliver async session errors via the `onError` callback. Codes mirror the other SDKs. +- The signaling protocol's wire shapes live in `src/realtime/messages.*` — keep them + byte-compatible with the other SDKs (`type`, `prompt`, `enhance_prompt`, + `image_data`/`image_ref`, `livekit_join`, `livekit_room_info`, acks, etc.). + +## Gotchas + +- The released LiveKit `RoomOptions` is narrower than `main` — validate API usage + against the **release** headers (downloaded to `build/*/_deps/livekit-sdk/...`), + not the upstream `main` branch. +- `VideoCodec` is forward-declared only in LiveKit's public headers, so the publish + codec cannot be set from consumer code — leave it at the default. +- Never call `room.disconnect()` (or `session->disconnect()`) from inside a + delegate/session callback — it deadlocks the LiveKit event thread. +- The LiveKit FFI shared library must sit next to the executable; use + `decart_copy_livekit_runtime(target)` for any binary that links `decart`. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..96d6387 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,224 @@ +# Copyright 2026 Decart. SPDX-License-Identifier: MIT +cmake_minimum_required(VERSION 3.20) + +project(decart + VERSION 0.1.0 + DESCRIPTION "Decart C++ SDK — realtime video transformation and auth" + HOMEPAGE_URL "https://github.com/DecartAI/decart-cpp" + LANGUAGES CXX) + +# --- Standard / output --------------------------------------------------------- +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +# Treat this as the top-level project only when not added via add_subdirectory. +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(DECART_IS_TOP_LEVEL ON) +else() + set(DECART_IS_TOP_LEVEL OFF) +endif() + +# --- Options ------------------------------------------------------------------- +option(DECART_BUILD_EXAMPLES "Build example applications" ${DECART_IS_TOP_LEVEL}) +option(DECART_BUILD_TESTS "Build unit tests" ${DECART_IS_TOP_LEVEL}) +# When OFF, builds an auth/tokens-only library with no LiveKit dependency (no Rust +# toolchain, no FFI download) — useful for server-side token minting. The realtime +# API (Client::realtime()) is then compiled out. +option(DECART_BUILD_REALTIME "Build the realtime (LiveKit-backed) module" ON) +set(DECART_LIVEKIT_VERSION "latest" CACHE STRING "LiveKit C++ SDK release to download (e.g. 0.2.0 or latest)") +set(DECART_LIVEKIT_LOCAL_DIR "" CACHE PATH "Path to a local LiveKit SDK install prefix (skips download)") + +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +include(FetchContent) + +# Tracks whether any dependency was pulled via FetchContent (rather than found as +# an installed package). A relocatable install/export is only valid against found +# packages, so this gates the install rules below. +set(_decart_fetched_deps OFF) + +# --- Dependencies: nlohmann_json (vcpkg/system, else fetch) -------------------- +find_package(nlohmann_json CONFIG QUIET) +if(NOT TARGET nlohmann_json::nlohmann_json) + message(STATUS "decart: fetching nlohmann_json") + FetchContent_Declare(nlohmann_json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz) + FetchContent_MakeAvailable(nlohmann_json) + set(_decart_fetched_deps ON) +endif() + +# --- Dependencies: IXWebSocket (WebSocket signaling + HTTP) --------------------- +find_package(ixwebsocket CONFIG QUIET) +if(NOT TARGET ixwebsocket::ixwebsocket AND NOT TARGET ixwebsocket) + message(STATUS "decart: fetching IXWebSocket") + set(USE_TLS ON CACHE BOOL "" FORCE) + FetchContent_Declare(ixwebsocket + GIT_REPOSITORY https://github.com/machinezone/IXWebSocket.git + GIT_TAG v11.4.6) + FetchContent_MakeAvailable(ixwebsocket) + set(_decart_fetched_deps ON) +endif() +if(TARGET ixwebsocket::ixwebsocket) + set(DECART_IXWEBSOCKET ixwebsocket::ixwebsocket) +else() + set(DECART_IXWEBSOCKET ixwebsocket) +endif() + +# --- Dependencies: LiveKit C++ SDK (realtime media transport) ------------------- +if(DECART_BUILD_REALTIME) + if(DECART_LIVEKIT_LOCAL_DIR) + list(PREPEND CMAKE_PREFIX_PATH "${DECART_LIVEKIT_LOCAL_DIR}") + else() + include(LiveKitSDK) + livekit_sdk_setup( + VERSION "${DECART_LIVEKIT_VERSION}" + SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk" + GITHUB_TOKEN "$ENV{GITHUB_TOKEN}") + endif() + find_package(LiveKit CONFIG REQUIRED) + if(TARGET LiveKit::livekit) + set(DECART_LIVEKIT LiveKit::livekit) + elseif(TARGET livekit) + set(DECART_LIVEKIT livekit) + else() + message(FATAL_ERROR "decart: LiveKit core target not found (LiveKit::livekit / livekit)") + endif() +endif() + +# --- Library ------------------------------------------------------------------- +# Always built: client, auth/tokens, models, and HTTP/JSON plumbing. +set(DECART_SOURCES + src/client.cpp + src/errors.cpp + src/logging.cpp + src/models.cpp + src/tokens.cpp + src/detail/base64.cpp + src/detail/http_client.cpp + src/detail/user_agent.cpp + src/realtime/types.cpp) + +# Realtime (LiveKit-backed) sources, only when enabled. +if(DECART_BUILD_REALTIME) + list(APPEND DECART_SOURCES + src/detail/signaling_channel.cpp + src/realtime/messages.cpp + src/realtime/realtime.cpp + src/realtime/session.cpp) +endif() + +add_library(decart ${DECART_SOURCES}) +add_library(decart::decart ALIAS decart) + +target_include_directories(decart + PUBLIC + $ + $ + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src) + +target_compile_features(decart PUBLIC cxx_std_17) +# nlohmann_json is header-only and used only in .cpp files: needed to build, never +# part of decart's link/usage interface. +target_link_libraries(decart PRIVATE $) +# IXWebSocket powers both the tokens HTTP client and realtime signaling. +target_link_libraries(decart PRIVATE ${DECART_IXWEBSOCKET}) + +if(DECART_BUILD_REALTIME) + # LiveKit is PUBLIC: consumers create livekit::VideoSource / receive livekit::VideoFrame. + target_link_libraries(decart PUBLIC ${DECART_LIVEKIT}) +else() + # Compiled out of the public headers and consumers (propagated via INTERFACE). + target_compile_definitions(decart PUBLIC DECART_NO_REALTIME) +endif() + +if(MSVC) + target_compile_options(decart PRIVATE /W4) +else() + target_compile_options(decart PRIVATE -Wall -Wextra -Wpedantic) +endif() + +# Copy the LiveKit FFI runtime library next to a target's executable. +function(decart_copy_livekit_runtime target) + if(NOT DEFINED LiveKit_DIR) + return() + endif() + get_filename_component(_cmake_dir "${LiveKit_DIR}" DIRECTORY) # /lib/cmake + get_filename_component(_lib_dir "${_cmake_dir}" DIRECTORY) # /lib + get_filename_component(_root "${_lib_dir}" DIRECTORY) # + if(WIN32) + foreach(_dll livekit.dll livekit_ffi.dll) + if(EXISTS "${_root}/bin/${_dll}") + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_root}/bin/${_dll}" "$") + endif() + endforeach() + else() + file(GLOB _ffi_libs "${_root}/lib/*livekit_ffi*") + foreach(_ffi ${_ffi_libs}) + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_ffi}" "$") + endforeach() + endif() +endfunction() + +# --- Subdirectories ------------------------------------------------------------ +if(DECART_BUILD_EXAMPLES) + add_subdirectory(examples) +endif() + +if(DECART_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +# --- Install / export ---------------------------------------------------------- +# A relocatable package can only be exported when decart's dependencies are +# themselves installed packages (found via find_package), not FetchContent build +# targets. Default the install rules accordingly; users can override. +if(_decart_fetched_deps) + set(_decart_install_default OFF) +else() + set(_decart_install_default ${DECART_IS_TOP_LEVEL}) +endif() +option(DECART_INSTALL "Generate install/export rules for decart" ${_decart_install_default}) + +if(DECART_INSTALL) +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +install(TARGETS decart + EXPORT decartTargets + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(EXPORT decartTargets + FILE decartTargets.cmake + NAMESPACE decart:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/decart) + +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/decart-config.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/decart-config.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/decart) + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/decart-config-version.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) + +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/decart-config.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/decart-config-version.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/decart) +endif() # DECART_INSTALL diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..9089382 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,51 @@ +{ + "version": 3, + "cmakeMinimumRequired": { "major": 3, "minor": 20, "patch": 0 }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "binaryDir": "${sourceDir}/build/${presetName}", + "generator": "Ninja", + "cacheVariables": { + "DECART_BUILD_EXAMPLES": "ON", + "DECART_BUILD_TESTS": "ON" + } + }, + { + "name": "vcpkg", + "hidden": true, + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" + } + }, + { + "name": "debug", + "inherits": "base", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "release", + "inherits": "base", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } + }, + { + "name": "debug-vcpkg", + "inherits": ["debug", "vcpkg"] + }, + { + "name": "release-vcpkg", + "inherits": ["release", "vcpkg"] + } + ], + "buildPresets": [ + { "name": "debug", "configurePreset": "debug" }, + { "name": "release", "configurePreset": "release" }, + { "name": "debug-vcpkg", "configurePreset": "debug-vcpkg" }, + { "name": "release-vcpkg", "configurePreset": "release-vcpkg" } + ], + "testPresets": [ + { "name": "debug", "configurePreset": "debug", "output": { "outputOnFailure": true } }, + { "name": "release", "configurePreset": "release", "output": { "outputOnFailure": true } } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51e77a4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Decart AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7edc1ec --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# Decart C++ SDK + +A C++ SDK for [Decart](https://decart.ai)'s realtime models. It speaks the same +realtime + auth protocol as the [JavaScript](https://github.com/DecartAI/sdk), +[Python](https://github.com/DecartAI/decart-python), iOS, and Android SDKs, and +is built on the official [LiveKit C++ SDK](https://github.com/livekit/client-sdk-cpp) +for media transport. + +> **Status:** v0.1 — focused on realtime video transformation and authentication. + +## Features + +- **Realtime** — publish a video stream, get back the model-transformed stream, + and steer it live with prompts and reference images. +- **Auth** — mint short-lived client tokens from a server-side API key. +- Native, header-light public API; LiveKit types only where you actually touch media. + +## Requirements + +- **C++17** and **CMake ≥ 3.20** +- Platforms (from the LiveKit C++ SDK): **Linux** (x64/arm64), **macOS** 12.3+ + (Apple Silicon & Intel), **Windows** (x64) +- Dependencies (resolved automatically — see below): + [LiveKit C++ SDK](https://github.com/livekit/client-sdk-cpp), + [IXWebSocket](https://github.com/machinezone/IXWebSocket), + [nlohmann/json](https://github.com/nlohmann/json) + +## Installation + +### CMake `FetchContent` (simplest) + +```cmake +include(FetchContent) +FetchContent_Declare(decart + GIT_REPOSITORY https://github.com/DecartAI/decart-cpp.git + GIT_TAG v0.1.0) +FetchContent_MakeAvailable(decart) + +target_link_libraries(your_app PRIVATE decart::decart) +``` + +The first configure downloads a prebuilt LiveKit release and fetches IXWebSocket / +nlohmann/json. Set `DECART_LIVEKIT_VERSION` to pin a LiveKit release, or +`DECART_LIVEKIT_LOCAL_DIR` to point at a local install. The LiveKit FFI runtime +library is copied next to your executable automatically via +`decart_copy_livekit_runtime(your_app)`. + +### vcpkg + +`nlohmann-json` and `ixwebsocket` are declared in `vcpkg.json`. Configure with the +vcpkg toolchain (LiveKit is still fetched as a prebuilt release): + +```bash +cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake +cmake --build build +``` + +### `add_subdirectory` + +```cmake +add_subdirectory(third_party/decart-cpp) +target_link_libraries(your_app PRIVATE decart::decart) +``` + +## Quick start + +### Realtime video transformation + +You drive a `livekit::VideoSource` (push your camera/render frames into it); the +SDK publishes it and hands you the transformed frames. + +```cpp +#include +#include + +decart::Client client; // reads DECART_API_KEY +auto model = decart::models::realtime("lucy-restyle-2"); +auto source = std::make_shared(model.width, model.height); + +decart::ConnectOptions options; +options.model = model; +options.initialState.prompt = decart::Prompt{"A watercolor painting", /*enhance=*/true}; +options.onConnectionState = [](decart::ConnectionState s) { + std::printf("state: %s\n", decart::toString(s)); +}; +options.onRemoteFrame = [](const livekit::VideoFrame& frame, std::int64_t /*ts_us*/) { + // Render `frame` (RGBA). Runs on a reader thread — keep it quick. +}; + +auto session = client.realtime().connect(source, options); + +// Push frames into `source` from your capture/render loop: +// source->captureFrame(myFrame); + +// Steer the model live: +session->setPrompt("Cyberpunk city"); + +session->disconnect(); +``` + +See [`examples/realtime_synthetic.cpp`](examples/realtime_synthetic.cpp) for a +complete, runnable program that publishes synthetic frames, and +[`examples/realtime_video.cpp`](examples/realtime_video.cpp) to stream a real +video file through a model and save the transformed result (uses `ffmpeg` to +decode/encode): + +```bash +DECART_API_KEY=... ./realtime_video input.mp4 output.mp4 "put them all in space" lucy-2.1 +``` + +### Authentication (client tokens) + +Create a short-lived token server-side to hand to an untrusted client: + +```cpp +decart::Client client; // server-side API key +decart::CreateTokenOptions opts; +opts.expiresIn = 300; // seconds +opts.allowedModels = {"lucy-restyle-2"}; +auto token = client.tokens().create(opts); +// token.apiKey -> "ek_...", token.expiresAt -> ISO-8601 +``` + +## Configuration + +```cpp +decart::ClientOptions options; +options.apiKey = "sk-..."; // or set DECART_API_KEY +options.baseUrl = "https://api.decart.ai"; // HTTP API (tokens) +options.realtimeBaseUrl = "wss://api3.decart.ai"; // signaling +options.integration = "my-app"; // included in the User-Agent +decart::Client client(options); +``` + +Errors surface as `decart::Exception` (synchronous failures) carrying a structured +`decart::Error{code, message}`; asynchronous session errors are delivered to +`ConnectOptions::onError`. + +## How it works + +A realtime session is two channels: a **WebSocket signaling** connection to Decart +that performs the room handshake and carries prompt/image control messages, and a +**LiveKit (WebRTC)** room that carries the media. The SDK opens the socket, joins, +publishes your `VideoSource`, subscribes to the inference server's output track, and +pumps decoded frames to your `onRemoteFrame` callback. + +## Building from source + +```bash +cmake --preset release # configure (downloads deps) +cmake --build --preset release # build library, examples, tests +ctest --preset release # run unit tests +``` + +Useful options: `DECART_BUILD_EXAMPLES`, `DECART_BUILD_TESTS`, +`DECART_LIVEKIT_VERSION` (pin a LiveKit release), `DECART_LIVEKIT_LOCAL_DIR` +(use a local LiveKit install instead of downloading), and +`DECART_BUILD_REALTIME=OFF` for an **auth/tokens-only build with no LiveKit +dependency** (no Rust toolchain, no FFI download) — `Client::realtime()` is then +compiled out, leaving `Client::tokens()`. + +## v0.1 scope & notes + +- **Input frames** are provided by you via `livekit::VideoSource` (full control + over your capture pipeline). Audio is not published in v0.1. +- **Reference images** accept a local file path, a `data:` URL, or raw base64. +- **Reconnection**: LiveKit auto-recovers the media transport; the signaling + socket is single-shot in v0.1 (a drop after connect is surfaced via `onError`). +- Not yet included (planned): `checkConnectivity` preflight, audio, queue/image + generation APIs, proxy mode. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/cmake/LiveKitSDK.cmake b/cmake/LiveKitSDK.cmake new file mode 100644 index 0000000..02d36fa --- /dev/null +++ b/cmake/LiveKitSDK.cmake @@ -0,0 +1,266 @@ +# LiveKitSDK.cmake +# +# Helper for example repos: +# - Downloads the appropriate prebuilt LiveKit C++ SDK release asset for the host OS/arch +# - Extracts it into a local directory (default: /_deps/livekit-sdk) +# - Prepends the extracted prefix to CMAKE_PREFIX_PATH so: +# find_package(LiveKit CONFIG REQUIRED) +# works out of the box. +# +# Usage: +# list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +# include(LiveKitSDK) +# livekit_sdk_setup(VERSION "latest" SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk") +# +# Optional: +# livekit_sdk_setup(VERSION "latest" REPO "livekit/client-sdk-cpp" GITHUB_TOKEN "$ENV{GITHUB_TOKEN}") + +include_guard(GLOBAL) + +# -------------------- Host detection -------------------- +function(_lk_detect_host out_os out_arch) + if(WIN32) + set(_os "windows") + elseif(APPLE) + set(_os "macos") + elseif(UNIX) + set(_os "linux") + else() + message(FATAL_ERROR "LiveKitSDK: unsupported host OS") + endif() + + # Use host processor; normalize common variants (case-insensitive) + set(_proc "${CMAKE_HOST_SYSTEM_PROCESSOR}") + string(TOLOWER "${_proc}" _proc_l) + + if(_proc_l MATCHES "^(x86_64|amd64)$") + set(_arch "x64") + elseif(_proc_l MATCHES "^(arm64|aarch64)$") + set(_arch "arm64") + else() + message(FATAL_ERROR "LiveKitSDK: unsupported host arch: ${_proc}") + endif() + + set(${out_os} "${_os}" PARENT_SCOPE) + set(${out_arch} "${_arch}" PARENT_SCOPE) +endfunction() + +function(_lk_default_triple out_triple) + _lk_detect_host(_os _arch) + set(${out_triple} "${_os}-${_arch}" PARENT_SCOPE) +endfunction() + +function(_lk_archive_ext out_ext) + _lk_detect_host(_os _arch) + if(_os STREQUAL "windows") + set(${out_ext} "zip" PARENT_SCOPE) + else() + set(${out_ext} "tar.gz" PARENT_SCOPE) + endif() +endfunction() + +# -------------------- GitHub API helpers -------------------- +# Resolve VERSION="latest" via GitHub API, returning version without leading "v". +function(_lk_resolve_latest_version out_version repo download_dir github_token) + if(NOT download_dir) + set(download_dir "${CMAKE_BINARY_DIR}/_downloads") + endif() + file(MAKE_DIRECTORY "${download_dir}") + + set(_api "https://api.github.com/repos/${repo}/releases/latest") + + # Sanitize repo for filename + string(REPLACE "/" "_" _repo_sanitized "${repo}") + set(_json "${download_dir}/livekit_latest_release_${_repo_sanitized}.json") + + # Build headers as a proper LIST (each element is one full header line) + set(_headers + "User-Agent: cmake-livekit-sdk/1.0" + "Accept: application/vnd.github+json" + "X-GitHub-Api-Version: 2022-11-28" + ) + + # Strip token (defensive: avoids accidental newline causing header splitting) + if(NOT "${github_token}" STREQUAL "") + string(STRIP "${github_token}" github_token) + endif() + + # Use Authorization only if token is non-empty + if(NOT "${github_token}" STREQUAL "") + # "token" is broadly compatible + list(APPEND _headers "Authorization: Bearer ${github_token}") + else() + message(STATUS "LiveKitSDK: no GITHUB_TOKEN provided; GitHub API may rate-limit.") + endif() + + # Capture LOG for actionable failure output + set(_dl_args + TLS_VERIFY ON + STATUS _st + LOG _log + ) + foreach(_h IN LISTS _headers) + list(APPEND _dl_args HTTPHEADER "${_h}") + endforeach() + file(DOWNLOAD "${_api}" "${_json}" ${_dl_args}) + + list(GET _st 0 _code) + list(GET _st 1 _msg) + if(NOT _code EQUAL 0) + message(STATUS "LiveKitSDK: GitHub API download log:\n${_log}") + if(EXISTS "${_json}") + file(READ "${_json}" _body) + message(STATUS "LiveKitSDK: GitHub API response body:\n${_body}") + endif() + message(FATAL_ERROR + "LiveKitSDK: failed to query latest release from GitHub API\n" + "API: ${_api}\n" + "Status: ${_code}\n" + "Message: ${_msg}\n" + "Tip: set GITHUB_TOKEN to avoid rate limits, or use VERSION=." + ) + endif() + + file(READ "${_json}" _content) + + # CMake >= 3.19 supports string(JSON ...) + string(JSON _tag GET "${_content}" tag_name) + if(_tag STREQUAL "") + message(FATAL_ERROR "LiveKitSDK: GitHub API response missing tag_name") + endif() + + # Strip leading "v" if present + string(REGEX REPLACE "^v" "" _ver "${_tag}") + set(${out_version} "${_ver}" PARENT_SCOPE) +endfunction() + +# -------------------- Public entrypoint -------------------- +# livekit_sdk_setup( +# VERSION +# SDK_DIR +# [REPO ] default: livekit/client-sdk-cpp +# [SHA256 ] optional: verify download (only works for fixed VERSION) +# [TRIPLE ] optional override +# [DOWNLOAD_DIR ] default: /_downloads +# [GITHUB_TOKEN ] optional: auth for GitHub API when VERSION=latest +# [NO_DOWNLOAD] error if not already present +# ) +function(livekit_sdk_setup) + set(options NO_DOWNLOAD) + set(oneValueArgs VERSION SDK_DIR REPO SHA256 TRIPLE DOWNLOAD_DIR GITHUB_TOKEN) + cmake_parse_arguments(LK "${options}" "${oneValueArgs}" "" ${ARGN}) + + if(NOT LK_VERSION) + message(FATAL_ERROR "livekit_sdk_setup: VERSION is required (use \"latest\" if desired)") + endif() + if(NOT LK_SDK_DIR) + message(FATAL_ERROR "livekit_sdk_setup: SDK_DIR is required") + endif() + + if(NOT LK_REPO) + set(LK_REPO "livekit/client-sdk-cpp") + endif() + if(NOT LK_DOWNLOAD_DIR) + set(LK_DOWNLOAD_DIR "${CMAKE_BINARY_DIR}/_downloads") + endif() + if(NOT LK_TRIPLE) + _lk_default_triple(LK_TRIPLE) + endif() + + # Resolve latest tag if requested + set(_resolved_version "${LK_VERSION}") + if(LK_VERSION STREQUAL "latest") + if(LK_SHA256) + message(WARNING "LiveKitSDK: SHA256 was provided but VERSION=latest; ignoring SHA256.") + set(LK_SHA256 "") + endif() + + if(NOT LK_GITHUB_TOKEN) + # Common in CI if you set env: GITHUB_TOKEN: ${{ github.token }} + set(LK_GITHUB_TOKEN "$ENV{GITHUB_TOKEN}") + endif() + + _lk_resolve_latest_version(_resolved_version "${LK_REPO}" "${LK_DOWNLOAD_DIR}" "${LK_GITHUB_TOKEN}") + message(STATUS "LiveKitSDK: resolved latest version = ${_resolved_version}") + endif() + + _lk_archive_ext(_ext) + set(_archive "livekit-sdk-${LK_TRIPLE}-${_resolved_version}.${_ext}") + set(_url "https://github.com/${LK_REPO}/releases/download/v${_resolved_version}/${_archive}") + + set(_archive_path "${LK_DOWNLOAD_DIR}/${_archive}") + + # The archive is expected to contain a top-level folder named: + # livekit-sdk--/ + set(_extracted_root "${LK_SDK_DIR}/livekit-sdk-${LK_TRIPLE}-${_resolved_version}") + + file(MAKE_DIRECTORY "${LK_DOWNLOAD_DIR}") + file(MAKE_DIRECTORY "${LK_SDK_DIR}") + + if(NOT EXISTS "${_extracted_root}") + if(LK_NO_DOWNLOAD) + message(FATAL_ERROR + "LiveKitSDK: SDK not found at:\n ${_extracted_root}\n" + "and NO_DOWNLOAD was set." + ) + endif() + + message(STATUS "LiveKitSDK: downloading ${_url}") + + if(LK_SHA256) + file(DOWNLOAD "${_url}" "${_archive_path}" + SHOW_PROGRESS + TLS_VERIFY ON + EXPECTED_HASH "SHA256=${LK_SHA256}" + STATUS _st + LOG _log + ) + else() + file(DOWNLOAD "${_url}" "${_archive_path}" + SHOW_PROGRESS + TLS_VERIFY ON + STATUS _st + LOG _log + ) + endif() + + list(GET _st 0 _code) + list(GET _st 1 _msg) + if(NOT _code EQUAL 0) + message(STATUS "LiveKitSDK: download log:\n${_log}") + message(FATAL_ERROR "LiveKitSDK: download failed\nURL: ${_url}\nStatus: ${_code}\nMessage: ${_msg}") + endif() + + # Remove any previous partial extraction + file(REMOVE_RECURSE "${_extracted_root}") + + message(STATUS "LiveKitSDK: extracting ${_archive_path}") + file(ARCHIVE_EXTRACT + INPUT "${_archive_path}" + DESTINATION "${LK_SDK_DIR}" + ) + endif() + + if(NOT EXISTS "${_extracted_root}/lib/cmake") + message(FATAL_ERROR + "LiveKitSDK: extracted SDK does not look valid (missing lib/cmake)\n" + "Expected: ${_extracted_root}\n" + "If your archive root folder name differs, adjust _extracted_root logic." + ) + endif() + + # Make find_package(LiveKit CONFIG REQUIRED) work. + list(PREPEND CMAKE_PREFIX_PATH "${_extracted_root}") + set(CMAKE_PREFIX_PATH "${CMAKE_PREFIX_PATH}" PARENT_SCOPE) + + # Direct hint to the package config dir + set(LiveKit_DIR "${_extracted_root}/lib/cmake/LiveKit" PARENT_SCOPE) + + # Export a few useful variables for callers (optional). + set(LIVEKIT_SDK_EXTRACTED_ROOT "${_extracted_root}" CACHE PATH "LiveKit SDK extracted root" FORCE) + set(LIVEKIT_SDK_URL_USED "${_url}" CACHE STRING "LiveKit SDK URL used" FORCE) + set(LIVEKIT_SDK_VERSION_RESOLVED "${_resolved_version}" CACHE STRING "LiveKit SDK resolved version" FORCE) + set(LIVEKIT_SDK_TRIPLE_USED "${LK_TRIPLE}" CACHE STRING "LiveKit SDK triple used" FORCE) + + message(STATUS "LiveKitSDK: using SDK at ${_extracted_root}") +endfunction() diff --git a/cmake/decart-config.cmake.in b/cmake/decart-config.cmake.in new file mode 100644 index 0000000..70721f5 --- /dev/null +++ b/cmake/decart-config.cmake.in @@ -0,0 +1,15 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) + +# decart links these as a static library; consumers of the installed package need +# them discoverable on CMAKE_PREFIX_PATH (e.g. via vcpkg or the LiveKit release +# prefix). nlohmann_json is header-only / build-time only, so it is not required. +find_dependency(ixwebsocket) +if("@DECART_BUILD_REALTIME@") + find_dependency(LiveKit) +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/decartTargets.cmake") + +check_required_components(decart) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..7515160 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,18 @@ +# Copyright 2026 Decart. SPDX-License-Identifier: MIT + +# Auth/tokens example builds in every configuration. +add_executable(create_token create_token.cpp) +target_link_libraries(create_token PRIVATE decart::decart) +decart_copy_livekit_runtime(create_token) + +# Realtime examples require the LiveKit-backed module. +if(DECART_BUILD_REALTIME) + add_executable(realtime_synthetic realtime_synthetic.cpp) + target_link_libraries(realtime_synthetic PRIVATE decart::decart) + decart_copy_livekit_runtime(realtime_synthetic) + + # Streams a video file through a model and saves the result (requires ffmpeg). + add_executable(realtime_video realtime_video.cpp) + target_link_libraries(realtime_video PRIVATE decart::decart) + decart_copy_livekit_runtime(realtime_video) +endif() diff --git a/examples/create_token.cpp b/examples/create_token.cpp new file mode 100644 index 0000000..5cf9e1b --- /dev/null +++ b/examples/create_token.cpp @@ -0,0 +1,34 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +// +// Auth example: mint a short-lived client token from a server-side API key. +// +// DECART_API_KEY=sk-... ./create_token +// +#include + +#include +#include + +int main() { + if (std::getenv("DECART_API_KEY") == nullptr) { + std::cerr << "Set DECART_API_KEY to run this example.\n"; + return 1; + } + + try { + decart::Client client; + + decart::CreateTokenOptions options; + options.expiresIn = 300; // 5 minutes + options.allowedModels = {"lucy-restyle-2"}; + options.metadata = {{"role", "viewer"}}; + + const decart::CreateTokenResponse token = client.tokens().create(options); + std::cout << "apiKey: " << token.apiKey << "\n"; + std::cout << "expiresAt: " << token.expiresAt << "\n"; + return 0; + } catch (const decart::Exception& e) { + std::cerr << "Decart error [" << decart::toString(e.code()) << "]: " << e.what() << "\n"; + return 1; + } +} diff --git a/examples/realtime_synthetic.cpp b/examples/realtime_synthetic.cpp new file mode 100644 index 0000000..c057706 --- /dev/null +++ b/examples/realtime_synthetic.cpp @@ -0,0 +1,104 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +// +// Realtime example: publish synthetic color-cycling frames to a Decart model and +// receive the transformed frames. Mirrors the python `realtime_synthetic.py`. +// +// DECART_API_KEY=sk-... ./realtime_synthetic [model] +// +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::atomic g_running{true}; +std::atomic g_remoteFrames{0}; + +// Fill an RGBA frame with a flat color that cycles every ~25 frames. +livekit::VideoFrame makeFrame(int width, int height, int counter) { + static const std::uint8_t colors[4][3] = {{255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {255, 255, 0}}; + const auto& c = colors[(counter / 25) % 4]; + + livekit::VideoFrame frame = livekit::VideoFrame::create(width, height, livekit::VideoBufferType::RGBA); + std::uint8_t* data = frame.data(); + for (std::size_t i = 0; i + 4 <= frame.dataSize(); i += 4) { + data[i] = c[0]; + data[i + 1] = c[1]; + data[i + 2] = c[2]; + data[i + 3] = 255; + } + return frame; +} + +} // namespace + +int main(int argc, char** argv) { + const char* apiKey = std::getenv("DECART_API_KEY"); + if (apiKey == nullptr) { + std::cerr << "Set DECART_API_KEY to run this example.\n"; + return 1; + } + const std::string modelName = argc > 1 ? argv[1] : "lucy-restyle-2"; + + try { + decart::Client client; // reads DECART_API_KEY + const decart::ModelDefinition model = decart::models::realtime(modelName); + std::cout << "Model: " << model.name << " (" << model.width << "x" << model.height << " @ " << model.fps + << "fps)\n"; + + auto source = std::make_shared(model.width, model.height); + + decart::ConnectOptions options; + options.model = model; + options.initialState.prompt = decart::Prompt{"A watercolor painting", true}; + options.onConnectionState = [](decart::ConnectionState state) { + std::cout << "[state] " << decart::toString(state) << "\n"; + }; + options.onError = [](const decart::Error& error) { + std::cerr << "[error] " << decart::toString(error.code) << ": " << error.message << "\n"; + }; + options.onRemoteFrame = [](const livekit::VideoFrame& frame, std::int64_t) { + const auto n = ++g_remoteFrames; + if (n % 25 == 0) { + std::cout << "[recv] " << n << " frames (" << frame.width() << "x" << frame.height() << ")\n"; + } + }; + + std::cout << "Connecting...\n"; + auto session = client.realtime().connect(source, options); + std::cout << "Connected. session=" << session->sessionId().value_or("?") << "\n"; + + // Push synthetic frames on a background thread. + std::thread producer([&] { + const auto interval = std::chrono::milliseconds(1000 / std::max(1, model.fps)); + int counter = 0; + while (g_running.load()) { + source->captureFrame(makeFrame(model.width, model.height, counter++)); + std::this_thread::sleep_for(interval); + } + }); + + std::this_thread::sleep_for(std::chrono::seconds(5)); + std::cout << "Changing prompt to 'Cyberpunk city'...\n"; + session->setPrompt("Cyberpunk city"); + + std::this_thread::sleep_for(std::chrono::seconds(5)); + + g_running.store(false); + producer.join(); + session->disconnect(); + std::cout << "Done. Received " << g_remoteFrames.load() << " transformed frames.\n"; + return 0; + } catch (const decart::Exception& e) { + std::cerr << "Decart error [" << decart::toString(e.code()) << "]: " << e.what() << "\n"; + return 1; + } +} diff --git a/examples/realtime_video.cpp b/examples/realtime_video.cpp new file mode 100644 index 0000000..1f8e5b8 --- /dev/null +++ b/examples/realtime_video.cpp @@ -0,0 +1,138 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +// +// Stream a video FILE through a Decart realtime model and save the transformed +// result. Uses ffmpeg (must be on PATH) to decode the input to raw RGBA frames +// and to encode the returned frames back into an .mp4. +// +// DECART_API_KEY=... ./realtime_video ["prompt"] [model] +// +// Set DECART_DEBUG=1 for verbose SDK logging. +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +FILE* openDecodePipe(const std::string& input, int w, int h, int fps) { + // Scale to the model resolution, preserving aspect ratio with black padding. + std::string cmd = "ffmpeg -hide_banner -loglevel error -i \"" + input + "\"" + + " -vf \"scale=" + std::to_string(w) + ":" + std::to_string(h) + + ":force_original_aspect_ratio=decrease,pad=" + std::to_string(w) + ":" + + std::to_string(h) + ":(ow-iw)/2:(oh-ih)/2,fps=" + std::to_string(fps) + + "\" -f rawvideo -pix_fmt rgba -"; + return popen(cmd.c_str(), "r"); +} + +FILE* openEncodePipe(const std::string& output, int w, int h, int fps) { + std::string cmd = "ffmpeg -hide_banner -loglevel error -y -f rawvideo -pix_fmt rgba -s " + + std::to_string(w) + "x" + std::to_string(h) + " -r " + std::to_string(fps) + + " -i - -c:v libx264 -pix_fmt yuv420p \"" + output + "\""; + return popen(cmd.c_str(), "w"); +} + +} // namespace + +int main(int argc, char** argv) { + if (std::getenv("DECART_API_KEY") == nullptr) { + std::cerr << "Set DECART_API_KEY to run this example.\n"; + return 1; + } + if (argc < 3) { + std::cerr << "Usage: " << argv[0] << " [\"prompt\"] [model]\n"; + return 1; + } + const std::string input = argv[1]; + const std::string output = argv[2]; + const std::string prompt = argc > 3 ? argv[3] : "put them all in space"; + const std::string modelName = argc > 4 ? argv[4] : "lucy-2.1"; + + if (std::getenv("DECART_DEBUG") != nullptr) decart::setLogLevel(decart::LogLevel::Debug); + + try { + decart::Client client; + const decart::ModelDefinition model = decart::models::realtime(modelName); + const int w = model.width, h = model.height, fps = model.fps; + std::cout << "Model " << model.name << " " << w << "x" << h << "@" << fps << " prompt=\"" << prompt + << "\"\n"; + + auto source = std::make_shared(w, h); + + // Output encoder is opened lazily once we know the returned frame size. + std::mutex encMutex; + FILE* encodePipe = nullptr; + int outW = 0, outH = 0; + std::atomic recvFrames{0}; + + decart::ConnectOptions options; + options.model = model; + options.initialState.prompt = decart::Prompt{prompt, /*enhance=*/true}; + options.onConnectionState = [](decart::ConnectionState s) { + std::cout << "[state] " << decart::toString(s) << "\n"; + }; + options.onError = [](const decart::Error& e) { + std::cerr << "[error] " << decart::toString(e.code) << ": " << e.message << "\n"; + }; + options.onRemoteFrame = [&](const livekit::VideoFrame& frame, std::int64_t) { + std::lock_guard lk(encMutex); + if (encodePipe == nullptr) { + outW = frame.width(); + outH = frame.height(); + encodePipe = openEncodePipe(output, outW, outH, fps); + std::cout << "[recv] first frame " << outW << "x" << outH << " -> encoding to " << output << "\n"; + } + if (encodePipe && frame.width() == outW && frame.height() == outH) { + std::fwrite(frame.data(), 1, frame.dataSize(), encodePipe); + if (++recvFrames % 30 == 0) std::cout << "[recv] " << recvFrames << " frames\n"; + } + }; + + std::cout << "Connecting...\n"; + auto session = client.realtime().connect(source, options); + std::cout << "Connected. session=" << session->sessionId().value_or("?") << "\n"; + + FILE* decodePipe = openDecodePipe(input, w, h, fps); + if (decodePipe == nullptr) { + std::cerr << "Failed to start ffmpeg decode for " << input << "\n"; + return 1; + } + + const std::size_t frameBytes = static_cast(w) * h * 4; + std::vector buf(frameBytes); + const auto interval = std::chrono::milliseconds(1000 / std::max(1, fps)); + std::uint64_t fed = 0; + auto next = std::chrono::steady_clock::now(); + while (std::fread(buf.data(), 1, frameBytes, decodePipe) == frameBytes) { + source->captureFrame(livekit::VideoFrame(w, h, livekit::VideoBufferType::RGBA, buf)); + if (++fed % 30 == 0) std::cout << "[send] " << fed << " frames\n"; + next += interval; + std::this_thread::sleep_until(next); + } + pclose(decodePipe); + std::cout << "Fed " << fed << " frames; draining output...\n"; + + // Let trailing transformed frames arrive before tearing down. + std::this_thread::sleep_for(std::chrono::seconds(3)); + session->disconnect(); + + { + std::lock_guard lk(encMutex); + if (encodePipe) pclose(encodePipe); + } + std::cout << "Done. Sent " << fed << ", received " << recvFrames.load() << " frames -> " << output + << "\n"; + return recvFrames.load() > 0 ? 0 : 2; + } catch (const decart::Exception& e) { + std::cerr << "Decart error [" << decart::toString(e.code()) << "]: " << e.what() << "\n"; + return 1; + } +} diff --git a/include/decart/client.h b/include/decart/client.h new file mode 100644 index 0000000..8479eee --- /dev/null +++ b/include/decart/client.h @@ -0,0 +1,74 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include + +#include "decart/tokens.h" +#ifndef DECART_NO_REALTIME +#include "decart/realtime/realtime.h" +#endif + +namespace decart { + +/// Configuration for a `Client`. +struct ClientOptions { + /// API key for authentication. If empty, the `DECART_API_KEY` environment + /// variable is used. Realtime requires a key. + std::string apiKey; + /// Override the HTTP API base URL (default "https://api.decart.ai"). + std::string baseUrl; + /// Override the realtime signaling base URL (default "wss://api3.decart.ai"). + std::string realtimeBaseUrl; + /// Optional integration identifier, included in the User-Agent. + std::string integration; +}; + +/// Top-level Decart client. Construct once and reuse; sub-clients +/// (`realtime()`, `tokens()`) share its configuration. +/// +/// @example +/// @code +/// decart::Client client; // reads DECART_API_KEY +/// auto token = client.tokens().create(); +/// auto model = decart::models::realtime("lucy-restyle-2"); +/// auto source = std::make_shared(model.width, model.height); +/// decart::ConnectOptions opts; +/// opts.model = model; +/// opts.onRemoteFrame = [](const livekit::VideoFrame&, std::int64_t) {}; +/// auto session = client.realtime().connect(source, opts); +/// @endcode +class Client { +public: + /// Construct a client. @throws decart::Exception(InvalidApiKey) when no key is + /// available, or (InvalidBaseUrl) when a provided URL is malformed. + explicit Client(ClientOptions options = {}); + ~Client(); + + Client(const Client&) = delete; + Client& operator=(const Client&) = delete; + Client(Client&&) noexcept; + Client& operator=(Client&&) noexcept; + +#ifndef DECART_NO_REALTIME + /// Realtime video transformation client. Available unless the SDK was built + /// with `DECART_BUILD_REALTIME=OFF` (auth/tokens-only). + RealtimeClient& realtime(); +#endif + + /// Client-token (auth) client. + TokensClient& tokens(); + + /// The resolved API key in use. + const std::string& apiKey() const noexcept; + /// The resolved HTTP API base URL. + const std::string& baseUrl() const noexcept; + /// The resolved realtime signaling base URL. + const std::string& realtimeBaseUrl() const noexcept; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace decart diff --git a/include/decart/decart.h b/include/decart/decart.h new file mode 100644 index 0000000..580f67b --- /dev/null +++ b/include/decart/decart.h @@ -0,0 +1,21 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +// +// Decart C++ SDK — umbrella header. +// +// Realtime video transformation and authentication for Decart's models, built +// on the official LiveKit C++ SDK. See https://docs.platform.decart.ai +#pragma once + +#include "decart/client.h" +#include "decart/errors.h" +#include "decart/logging.h" +#include "decart/models.h" +#include "decart/tokens.h" +#include "decart/version.h" + +// Realtime API — present unless built with DECART_BUILD_REALTIME=OFF. +#ifndef DECART_NO_REALTIME +#include "decart/realtime/realtime.h" +#include "decart/realtime/session.h" +#include "decart/realtime/types.h" +#endif diff --git a/include/decart/errors.h b/include/decart/errors.h new file mode 100644 index 0000000..a77954b --- /dev/null +++ b/include/decart/errors.h @@ -0,0 +1,65 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include + +namespace decart { + +/// Stable error codes surfaced by the SDK. These mirror the codes used by the +/// other Decart SDKs so behaviour can be reasoned about across languages. +enum class ErrorCode { + /// No API key was provided and `DECART_API_KEY` was not set. + InvalidApiKey, + /// A configured base URL was malformed. + InvalidBaseUrl, + /// A caller-provided argument was invalid. + InvalidInput, + /// The requested model is not known to the SDK registry. + ModelNotFound, + /// Creating a client token failed. + TokenCreateError, + /// The realtime signaling WebSocket failed. + WebsocketError, + /// A request to the server timed out. + TimeoutError, + /// The server returned an explicit error message. + ServerError, + /// A realtime signaling-level error occurred. + SignalingError, + /// The realtime media (LiveKit/WebRTC) transport failed. + MediaError, + /// An operation requires an active connection that is not present. + NotConnected, + /// An HTTP request failed (network or non-2xx response). + HttpError, +}; + +/// Human-readable name for an ErrorCode (e.g. "INVALID_API_KEY"). +const char* toString(ErrorCode code) noexcept; + +/// Structured description of an error. Carried by `Exception` and passed to the +/// `onError` realtime callback. +struct Error { + ErrorCode code; + std::string message; + /// Optional HTTP status code when `code == HttpError`/`TokenCreateError`. + int status = 0; +}; + +/// Exception type thrown by the SDK for synchronous failures (e.g. a failed +/// connect, a rejected prompt, or a token creation error). +class Exception : public std::runtime_error { +public: + explicit Exception(Error error) : std::runtime_error(error.message), error_(std::move(error)) {} + + /// The structured error that caused this exception. + const Error& error() const noexcept { return error_; } + /// Convenience accessor for the error code. + ErrorCode code() const noexcept { return error_.code; } + +private: + Error error_; +}; + +} // namespace decart diff --git a/include/decart/logging.h b/include/decart/logging.h new file mode 100644 index 0000000..c7304f2 --- /dev/null +++ b/include/decart/logging.h @@ -0,0 +1,25 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include + +namespace decart { + +/// SDK log severity. +enum class LogLevel { Trace = 0, Debug, Info, Warn, Error, Off }; + +/// Sink for SDK log messages. +using LogHandler = std::function; + +/// Set the minimum level emitted by the SDK (default: Info). +void setLogLevel(LogLevel level); + +/// Current minimum log level. +LogLevel logLevel() noexcept; + +/// Install a custom log sink. By default messages go to stderr. Pass `nullptr` +/// to restore the default sink. +void setLogHandler(LogHandler handler); + +} // namespace decart diff --git a/include/decart/models.h b/include/decart/models.h new file mode 100644 index 0000000..d65d604 --- /dev/null +++ b/include/decart/models.h @@ -0,0 +1,46 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include +#include + +namespace decart { + +/// A realtime model definition: everything the SDK needs to open a session and +/// size the input video. Mirrors the `models.realtime(...)` registry shared by +/// the other Decart SDKs. +struct ModelDefinition { + /// Canonical model name sent to the server (e.g. "lucy-restyle-2"). + std::string name; + /// Signaling URL path for the model (all realtime models use "/v1/stream"). + std::string urlPath; + /// Recommended capture frame rate. + int fps = 30; + /// Recommended capture width in pixels. + int width = 0; + /// Recommended capture height in pixels. + int height = 0; +}; + +namespace models { + +/// Resolve a realtime model by name. +/// +/// Accepts canonical names ("lucy-2.1", "lucy-2.1-vton", "lucy-vton-2", +/// "lucy-vton-3", "lucy-restyle-2"), server-resolved aliases ("lucy-latest", +/// "lucy-vton-latest", "lucy-restyle-latest"), and deprecated names (which emit +/// no error but resolve to the same stream endpoint). +/// +/// @throws Exception(ErrorCode::ModelNotFound) if the name is unknown. +ModelDefinition realtime(std::string_view name); + +/// Returns true if `name` is a known realtime model name. +bool isRealtimeModel(std::string_view name) noexcept; + +/// List all realtime model definitions known to the SDK. +/// @param canonicalOnly when true, excludes deprecated and "latest" aliases. +std::vector listRealtime(bool canonicalOnly = false); + +} // namespace models +} // namespace decart diff --git a/include/decart/realtime/realtime.h b/include/decart/realtime/realtime.h new file mode 100644 index 0000000..04fe8ec --- /dev/null +++ b/include/decart/realtime/realtime.h @@ -0,0 +1,91 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include +#include +#include +#include + +#include "decart/errors.h" +#include "decart/models.h" +#include "decart/realtime/session.h" +#include "decart/realtime/types.h" + +// The realtime API is built on the official LiveKit C++ SDK. Consumers create +// and drive a `livekit::VideoSource`; the SDK publishes it and delivers the +// transformed frames back through `onRemoteFrame`. Forward-declared here so that +// translation units which only use auth/tokens need not include LiveKit headers. +namespace livekit { +class VideoSource; +class VideoFrame; +} // namespace livekit + +namespace decart { + +namespace detail { +struct ClientConfig; +struct SessionInternals; +} // namespace detail + +/// Callback delivering each transformed frame from the model. Invoked on a +/// dedicated reader thread; keep it fast and do not call session methods from it. +using RemoteFrameCallback = std::function; + +/// Options for `RealtimeClient::connect`. +struct ConnectOptions { + /// Model to run. Use `decart::models::realtime(...)`. + ModelDefinition model; + + /// Receives transformed frames from the model. Required to see output. + RemoteFrameCallback onRemoteFrame; + + /// Notified on every connection-state transition. + std::function onConnectionState; + + /// Notified on asynchronous, non-fatal errors during the session. + std::function onError; + + /// Notified while waiting in the inference queue. + std::function onQueuePosition; + + /// Notified periodically while the model generates. + std::function onGenerationTick; + + /// Notified when a generation run ends. + std::function onGenerationEnded; + + /// Optional prompt/image to apply during the handshake. + InitialState initialState; + + /// Optional output resolution hint ("720p" or "1080p"). + std::optional resolution; + + /// Bound on the signaling handshake (socket open + room join). The LiveKit + /// media connect manages its own timeout. Default 60s. + std::chrono::milliseconds connectTimeout{60000}; +}; + +/// Entry point for realtime video transformation. Obtained from `Client::realtime()`. +class RealtimeClient { +public: + /// Connect a realtime session that publishes frames from `source` and + /// delivers transformed frames via `options.onRemoteFrame`. + /// + /// The caller owns `source` and pushes frames into it (via + /// `livekit::VideoSource::captureFrame`) for the lifetime of the session. + /// `source` is required (must be non-null). + /// + /// Blocks until connected. @throws decart::Exception on failure (including + /// `InvalidInput` if `source` is null). + std::unique_ptr connect(std::shared_ptr source, + ConnectOptions options); + + /// @internal Constructed by `Client`; not intended for direct use. + explicit RealtimeClient(std::shared_ptr config); + +private: + std::shared_ptr config_; +}; + +} // namespace decart diff --git a/include/decart/realtime/session.h b/include/decart/realtime/session.h new file mode 100644 index 0000000..7a70815 --- /dev/null +++ b/include/decart/realtime/session.h @@ -0,0 +1,81 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include +#include +#include + +#include "decart/realtime/types.h" + +namespace decart { + +namespace detail { +struct SessionInternals; // grants the realtime orchestrator access to Impl +} // namespace detail + +/// Combined update for `RealtimeSession::set` — change the prompt, the reference +/// image, or both in a single call. At least one of `prompt`/`image` must be set. +struct SetInput { + std::optional prompt; + bool enhance = true; + std::optional image; +}; + +/// Per-call options for image/prompt updates. +struct UpdateOptions { + /// Optional prompt to apply alongside an image update. + std::optional prompt; + bool enhance = true; + /// How long to wait for the server acknowledgement before timing out. + std::chrono::milliseconds timeout{30000}; +}; + +/// A live realtime session. Created by `RealtimeClient::connect`. +/// +/// Methods that talk to the server (`setPrompt`, `set`, `setImage`) block until +/// the server acknowledges and throw `decart::Exception` on failure or timeout. +/// The object is move-only; destroying it disconnects. +/// +/// Thread-safety: control methods may be called from any thread. They must not +/// be called from inside a `ConnectOptions` callback. +class RealtimeSession { +public: + RealtimeSession(); + ~RealtimeSession(); + + RealtimeSession(const RealtimeSession&) = delete; + RealtimeSession& operator=(const RealtimeSession&) = delete; + RealtimeSession(RealtimeSession&&) noexcept; + RealtimeSession& operator=(RealtimeSession&&) noexcept; + + /// Set the active text prompt. Blocks until acknowledged. + void setPrompt(const std::string& prompt, bool enhance = true); + + /// Apply a prompt and/or reference image in one call. Blocks until acknowledged. + void set(const SetInput& input); + + /// Set (or clear) the reference image. Pass `std::nullopt` to clear. + /// Blocks until acknowledged. + void setImage(const std::optional& image, const UpdateOptions& options = {}); + + /// True while the session is connected (state `Connected` or `Generating`). + bool isConnected() const noexcept; + + /// Current connection state. + ConnectionState connectionState() const noexcept; + + /// Server session id, available once the session has started. + std::optional sessionId() const; + + /// Tear down the session. Idempotent. Safe to call from any thread except + /// from inside a session callback. + void disconnect(); + +private: + friend struct detail::SessionInternals; + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace decart diff --git a/include/decart/realtime/types.h b/include/decart/realtime/types.h new file mode 100644 index 0000000..baa31a9 --- /dev/null +++ b/include/decart/realtime/types.h @@ -0,0 +1,71 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include + +namespace decart { + +/// Lifecycle of a realtime session, reported via `ConnectOptions::onConnectionState`. +/// Matches the `connectionChange` states across the Decart SDKs. +enum class ConnectionState { + /// Establishing the signaling/media connection. + Connecting, + /// Connected; media is flowing but the model has not started generating yet. + Connected, + /// The model is actively generating transformed frames. + Generating, + /// Not connected (initial state, or after disconnect/failure). + Disconnected, + /// A transient drop occurred and the SDK is attempting to recover. + Reconnecting, +}; + +/// Human-readable name for a ConnectionState (e.g. "generating"). +const char* toString(ConnectionState state) noexcept; + +/// A text prompt plus whether the server should enhance it. +struct Prompt { + std::string text; + bool enhance = true; +}; + +/// An image input. Provide exactly one of `image` / `ref`. +/// +/// `image` accepts a local file path, a `data:` URL, or a raw base64 string +/// (remote http(s) URLs are not fetched — download them yourself). `ref` is a +/// server-side file id and is sent by reference instead of inlining bytes. +struct ImageInput { + std::optional image; + std::optional ref; + + static ImageInput fromPath(std::string path) { return {std::move(path), std::nullopt}; } + static ImageInput fromBase64(std::string base64) { return {std::move(base64), std::nullopt}; } + static ImageInput fromRef(std::string id) { return {std::nullopt, std::move(id)}; } +}; + +/// Initial state applied during the connection handshake, before the first +/// frame is generated. +struct InitialState { + std::optional prompt; + std::optional image; +}; + +/// Reported while waiting in the inference queue before a session starts. +struct QueuePosition { + int position = 0; + int queueSize = 0; +}; + +/// Periodic progress signal emitted while the model generates. +struct GenerationTick { + double seconds = 0.0; +}; + +/// Emitted when a generation run ends. +struct GenerationEnded { + double seconds = 0.0; + std::string reason; +}; + +} // namespace decart diff --git a/include/decart/tokens.h b/include/decart/tokens.h new file mode 100644 index 0000000..3f4b76b --- /dev/null +++ b/include/decart/tokens.h @@ -0,0 +1,53 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include +#include +#include +#include + +namespace decart { + +namespace detail { +struct ClientConfig; +} // namespace detail + +/// Options for creating a short-lived client token. +struct CreateTokenOptions { + /// Seconds until the token expires (1-3600, server default 60). + std::optional expiresIn; + /// Custom key/value pairs attached to the token. + std::map metadata; + /// Restrict which models the token may access (max 20). + std::vector allowedModels; + /// Restrict which web origins the token may be used from (max 20). + std::vector allowedOrigins; + /// Optional cap on realtime session duration, in seconds. + std::optional maxRealtimeSessionDuration; +}; + +/// Result of creating a client token. +struct CreateTokenResponse { + /// Short-lived API key (prefixed "ek_"), safe for client-side use. + std::string apiKey; + /// ISO-8601 expiry timestamp. + std::string expiresAt; + /// Raw JSON response body, for fields not surfaced as typed members. + std::string rawJson; +}; + +/// Client for creating short-lived client tokens. Obtained from `Client::tokens()`. +class TokensClient { +public: + /// Create a client token. @throws decart::Exception on failure. + CreateTokenResponse create(const CreateTokenOptions& options = {}); + + /// @internal Constructed by `Client`; not intended for direct use. + explicit TokensClient(std::shared_ptr config); + +private: + std::shared_ptr config_; +}; + +} // namespace decart diff --git a/include/decart/version.h b/include/decart/version.h new file mode 100644 index 0000000..9810738 --- /dev/null +++ b/include/decart/version.h @@ -0,0 +1,20 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include + +/// Major version of the Decart C++ SDK. +#define DECART_SDK_VERSION_MAJOR 0 +/// Minor version of the Decart C++ SDK. +#define DECART_SDK_VERSION_MINOR 1 +/// Patch version of the Decart C++ SDK. +#define DECART_SDK_VERSION_PATCH 0 +/// Full version string of the Decart C++ SDK. +#define DECART_SDK_VERSION "0.1.0" + +namespace decart { + +/// Returns the SDK version string (e.g. "0.1.0"). +inline std::string version() { return DECART_SDK_VERSION; } + +} // namespace decart diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..88b194c --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Format all SDK sources in place with clang-format. +set -euo pipefail +cd "$(dirname "$0")/.." + +mode="-i" +if [[ "${1:-}" == "--check" ]]; then + mode="--dry-run --Werror" +fi + +find include src examples tests \ + \( -name '*.h' -o -name '*.cpp' \) \ + -print0 | xargs -0 clang-format $mode +echo "clang-format done (${mode})" diff --git a/src/client.cpp b/src/client.cpp new file mode 100644 index 0000000..0d794ce --- /dev/null +++ b/src/client.cpp @@ -0,0 +1,86 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "decart/client.h" + +#include + +#include "decart/errors.h" +#include "detail/client_config.h" +#ifndef DECART_NO_REALTIME +#include "detail/livekit_runtime.h" +#endif + +namespace decart { +namespace { + +std::string readEnv(const char* name) { + const char* value = std::getenv(name); + return value != nullptr ? std::string(value) : std::string(); +} + +std::string stripTrailingSlash(std::string url) { + while (!url.empty() && url.back() == '/') url.pop_back(); + return url; +} + +bool startsWith(const std::string& s, const char* prefix) { return s.rfind(prefix, 0) == 0; } + +} // namespace + +struct Client::Impl { + std::shared_ptr config; +#ifndef DECART_NO_REALTIME + std::unique_ptr realtime; +#endif + std::unique_ptr tokens; +}; + +Client::Client(ClientOptions options) : impl_(std::make_unique()) { + auto config = std::make_shared(); + + config->apiKey = !options.apiKey.empty() ? options.apiKey : readEnv("DECART_API_KEY"); + if (config->apiKey.empty()) { + throw Exception(Error{ErrorCode::InvalidApiKey, + "Missing API key. Pass ClientOptions::apiKey or set the " + "DECART_API_KEY environment variable."}); + } + + std::string baseUrl = options.baseUrl.empty() ? "https://api.decart.ai" : options.baseUrl; + if (!startsWith(baseUrl, "http://") && !startsWith(baseUrl, "https://")) { + throw Exception(Error{ErrorCode::InvalidBaseUrl, "Invalid base URL: " + baseUrl}); + } + + std::string realtimeBaseUrl = + options.realtimeBaseUrl.empty() ? "wss://api3.decart.ai" : options.realtimeBaseUrl; + if (!startsWith(realtimeBaseUrl, "ws://") && !startsWith(realtimeBaseUrl, "wss://")) { + throw Exception(Error{ErrorCode::InvalidBaseUrl, + "Invalid realtime base URL (must be ws:// or wss://): " + realtimeBaseUrl}); + } + + config->baseUrl = stripTrailingSlash(std::move(baseUrl)); + config->realtimeBaseUrl = stripTrailingSlash(std::move(realtimeBaseUrl)); + config->integration = options.integration; + + impl_->config = config; + impl_->tokens = std::make_unique(config); +#ifndef DECART_NO_REALTIME + impl_->realtime = std::make_unique(config); + // Initialize the LiveKit runtime up front so callers can construct a + // livekit::VideoSource before connect(). Idempotent and safe to repeat. + detail::ensureLiveKitInit(); +#endif +} + +Client::~Client() = default; +Client::Client(Client&&) noexcept = default; +Client& Client::operator=(Client&&) noexcept = default; + +#ifndef DECART_NO_REALTIME +RealtimeClient& Client::realtime() { return *impl_->realtime; } +#endif +TokensClient& Client::tokens() { return *impl_->tokens; } + +const std::string& Client::apiKey() const noexcept { return impl_->config->apiKey; } +const std::string& Client::baseUrl() const noexcept { return impl_->config->baseUrl; } +const std::string& Client::realtimeBaseUrl() const noexcept { return impl_->config->realtimeBaseUrl; } + +} // namespace decart diff --git a/src/detail/base64.cpp b/src/detail/base64.cpp new file mode 100644 index 0000000..e0fc4c1 --- /dev/null +++ b/src/detail/base64.cpp @@ -0,0 +1,48 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "detail/base64.h" + +namespace decart::detail { +namespace { +constexpr char kAlphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +} + +std::string base64Encode(const std::uint8_t* data, std::size_t size) { + std::string out; + out.reserve(((size + 2) / 3) * 4); + + std::size_t i = 0; + for (; i + 3 <= size; i += 3) { + const std::uint32_t n = + (std::uint32_t(data[i]) << 16) | (std::uint32_t(data[i + 1]) << 8) | std::uint32_t(data[i + 2]); + out.push_back(kAlphabet[(n >> 18) & 0x3F]); + out.push_back(kAlphabet[(n >> 12) & 0x3F]); + out.push_back(kAlphabet[(n >> 6) & 0x3F]); + out.push_back(kAlphabet[n & 0x3F]); + } + + const std::size_t rem = size - i; + if (rem == 1) { + const std::uint32_t n = std::uint32_t(data[i]) << 16; + out.push_back(kAlphabet[(n >> 18) & 0x3F]); + out.push_back(kAlphabet[(n >> 12) & 0x3F]); + out.push_back('='); + out.push_back('='); + } else if (rem == 2) { + const std::uint32_t n = (std::uint32_t(data[i]) << 16) | (std::uint32_t(data[i + 1]) << 8); + out.push_back(kAlphabet[(n >> 18) & 0x3F]); + out.push_back(kAlphabet[(n >> 12) & 0x3F]); + out.push_back(kAlphabet[(n >> 6) & 0x3F]); + out.push_back('='); + } + return out; +} + +std::string base64Encode(const std::vector& data) { + return base64Encode(data.data(), data.size()); +} + +std::string base64Encode(const std::string& data) { + return base64Encode(reinterpret_cast(data.data()), data.size()); +} + +} // namespace decart::detail diff --git a/src/detail/base64.h b/src/detail/base64.h new file mode 100644 index 0000000..5a0d5d3 --- /dev/null +++ b/src/detail/base64.h @@ -0,0 +1,15 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include +#include + +namespace decart::detail { + +/// Standard base64 encode (RFC 4648, with padding). +std::string base64Encode(const std::uint8_t* data, std::size_t size); +std::string base64Encode(const std::vector& data); +std::string base64Encode(const std::string& data); + +} // namespace decart::detail diff --git a/src/detail/client_config.h b/src/detail/client_config.h new file mode 100644 index 0000000..68c33f2 --- /dev/null +++ b/src/detail/client_config.h @@ -0,0 +1,17 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include + +namespace decart::detail { + +/// Resolved client configuration shared by the top-level `Client` and its +/// sub-clients (realtime, tokens). +struct ClientConfig { + std::string apiKey; + std::string baseUrl; // e.g. "https://api.decart.ai" + std::string realtimeBaseUrl; // e.g. "wss://api3.decart.ai" + std::string integration; +}; + +} // namespace decart::detail diff --git a/src/detail/http_client.cpp b/src/detail/http_client.cpp new file mode 100644 index 0000000..dca334a --- /dev/null +++ b/src/detail/http_client.cpp @@ -0,0 +1,41 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "detail/http_client.h" + +#include +#include + +namespace decart::detail { +namespace { + +// IXWebSocket needs WSAStartup on Windows; harmless and idempotent elsewhere. +struct NetSystemInit { + NetSystemInit() { ix::initNetSystem(); } +}; +void ensureNetSystem() { static NetSystemInit once; } + +} // namespace + +HttpResponse httpPostJson(const std::string& url, const std::string& body, + const std::map& headers) { + ensureNetSystem(); + + ix::HttpClient client(/*async=*/false); + ix::HttpRequestArgsPtr args = client.createRequest(url, ix::HttpClient::kPost); + for (const auto& [key, value] : headers) { + args->extraHeaders[key] = value; + } + args->connectTimeout = 30; + args->transferTimeout = 60; + + ix::HttpResponsePtr resp = client.post(url, body, args); + + HttpResponse out; + out.status = resp->statusCode; + out.body = resp->body; + if (resp->errorCode != ix::HttpErrorCode::Ok) { + out.transportError = resp->errorMsg.empty() ? "HTTP transport error" : resp->errorMsg; + } + return out; +} + +} // namespace decart::detail diff --git a/src/detail/http_client.h b/src/detail/http_client.h new file mode 100644 index 0000000..71e2242 --- /dev/null +++ b/src/detail/http_client.h @@ -0,0 +1,21 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include + +namespace decart::detail { + +struct HttpResponse { + int status = 0; + std::string body; + /// Transport-level error (empty when the request reached the server). + std::string transportError; +}; + +/// Minimal JSON HTTP POST used by the auth/tokens API. Thin wrapper over +/// IXWebSocket's HttpClient so the rest of the SDK stays transport-agnostic. +HttpResponse httpPostJson(const std::string& url, const std::string& body, + const std::map& headers); + +} // namespace decart::detail diff --git a/src/detail/livekit_runtime.h b/src/detail/livekit_runtime.h new file mode 100644 index 0000000..3523208 --- /dev/null +++ b/src/detail/livekit_runtime.h @@ -0,0 +1,11 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +namespace decart::detail { + +/// Initialize the LiveKit runtime exactly once (idempotent). Must run before any +/// `livekit::` object is constructed — including the `VideoSource` the caller +/// builds before `connect()` — so the `Client` constructor calls it eagerly. +void ensureLiveKitInit(); + +} // namespace decart::detail diff --git a/src/detail/log.h b/src/detail/log.h new file mode 100644 index 0000000..917a654 --- /dev/null +++ b/src/detail/log.h @@ -0,0 +1,18 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include + +#include "decart/logging.h" + +namespace decart::detail { + +/// Emit an SDK log line (respects the configured level and sink). +void log(LogLevel level, const std::string& message); + +inline void logInfo(const std::string& m) { log(LogLevel::Info, m); } +inline void logWarn(const std::string& m) { log(LogLevel::Warn, m); } +inline void logError(const std::string& m) { log(LogLevel::Error, m); } +inline void logDebug(const std::string& m) { log(LogLevel::Debug, m); } + +} // namespace decart::detail diff --git a/src/detail/signaling_channel.cpp b/src/detail/signaling_channel.cpp new file mode 100644 index 0000000..b035901 --- /dev/null +++ b/src/detail/signaling_channel.cpp @@ -0,0 +1,262 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "detail/signaling_channel.h" + +#include +#include + +#include + +#include "decart/errors.h" +#include "detail/url.h" + +namespace decart::detail { +namespace { + +struct NetSystemInit { + NetSystemInit() { ix::initNetSystem(); } +}; +void ensureNetSystem() { static NetSystemInit once; } + +} // namespace + +SignalingChannel::SignalingChannel(std::string url, std::string userAgent) + : url_(std::move(url)), userAgent_(std::move(userAgent)) { + ensureNetSystem(); +} + +SignalingChannel::~SignalingChannel() { close(); } + +void SignalingChannel::setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } + +bool SignalingChannel::isOpen() const { return open_; } + +RoomInfo SignalingChannel::openAndJoin(std::chrono::milliseconds connectTimeout) { + // Single deadline shared across socket-open and room-join phases. + const auto deadline = std::chrono::steady_clock::now() + connectTimeout; + const std::string finalUrl = appendQuery(url_, "user_agent", userAgent_); + + ws_ = std::make_unique(); + ws_->setUrl(finalUrl); + ws_->disableAutomaticReconnection(); + ws_->setOnMessageCallback([this](const ix::WebSocketMessagePtr& msg) { + switch (msg->type) { + case ix::WebSocketMessageType::Open: { + std::lock_guard lk(mutex_); + open_ = true; + cv_.notify_all(); + break; + } + case ix::WebSocketMessageType::Message: + onMessage(msg->str); + break; + case ix::WebSocketMessageType::Close: + onClosed(msg->closeInfo.code, msg->closeInfo.reason); + break; + case ix::WebSocketMessageType::Error: + onSocketError(msg->errorInfo.reason); + break; + default: + break; + } + }); + ws_->start(); + + // 1. Wait for the socket to open (against the shared deadline). + { + std::unique_lock lk(mutex_); + if (!cv_.wait_until(lk, deadline, [&] { return open_ || fatalError_.has_value(); })) { + throw Exception(Error{ErrorCode::WebsocketError, "WebSocket open timeout"}); + } + if (fatalError_) throw Exception(Error{ErrorCode::WebsocketError, *fatalError_}); + } + + // 2. Arm a waiter for room_info, then send the join request. + auto waiter = std::make_shared(); + waiter->match = [](const IncomingMessage& m) { return m.type == IncomingType::LiveKitRoomInfo; }; + { + std::lock_guard lk(mutex_); + waiters_.push_back(waiter); + // Continue against the same overall deadline; queue_position extends it by a + // fresh connectTimeout so queue waiting does not count against the bound. + handshakeWindow_ = connectTimeout; + handshakeDeadline_ = deadline; + } + if (!sendText(makeJoinMsg(std::nullopt).dump())) { + std::lock_guard lk(mutex_); + waiters_.erase(std::remove(waiters_.begin(), waiters_.end(), waiter), waiters_.end()); + throw Exception(Error{ErrorCode::WebsocketError, "Failed to send join"}); + } + + // 3. Wait for room_info; queue_position pushes the deadline out. + { + std::unique_lock lk(mutex_); + while (!waiter->ready && !fatalError_) { + if (cv_.wait_until(lk, handshakeDeadline_) == std::cv_status::timeout && + std::chrono::steady_clock::now() >= handshakeDeadline_) { + break; + } + } + waiters_.erase(std::remove(waiters_.begin(), waiters_.end(), waiter), waiters_.end()); + if (fatalError_) throw Exception(Error{ErrorCode::ServerError, *fatalError_}); + if (!waiter->ready) throw Exception(Error{ErrorCode::TimeoutError, "livekit_room_info timeout"}); + connected_ = true; + return RoomInfo{waiter->result.livekitUrl, waiter->result.token, waiter->result.roomName, + waiter->result.sessionId}; + } +} + +IncomingMessage SignalingChannel::request(const nlohmann::json& message, + std::function match, + std::chrono::milliseconds timeout, const char* label) { + // One ack-bearing request in flight at a time (see requestMutex_). + std::lock_guard serialize(requestMutex_); + + auto waiter = std::make_shared(); + waiter->match = std::move(match); + { + std::lock_guard lk(mutex_); + if (fatalError_) throw Exception(Error{ErrorCode::SignalingError, *fatalError_}); + if (!open_) throw Exception(Error{ErrorCode::NotConnected, "Signaling channel not open"}); + waiters_.push_back(waiter); + } + + if (!sendText(message.dump())) { + std::lock_guard lk(mutex_); + waiters_.erase(std::remove(waiters_.begin(), waiters_.end(), waiter), waiters_.end()); + throw Exception(Error{ErrorCode::WebsocketError, "WebSocket is not open"}); + } + + const auto deadline = std::chrono::steady_clock::now() + timeout; + std::unique_lock lk(mutex_); + cv_.wait_until(lk, deadline, [&] { return waiter->ready || fatalError_.has_value(); }); + waiters_.erase(std::remove(waiters_.begin(), waiters_.end(), waiter), waiters_.end()); + + if (waiter->ready) { + if (!waiter->result.success) { + throw Exception( + Error{ErrorCode::ServerError, waiter->result.error.value_or(std::string(label) + " failed")}); + } + return waiter->result; + } + if (fatalError_) throw Exception(Error{ErrorCode::SignalingError, *fatalError_}); + throw Exception(Error{ErrorCode::TimeoutError, std::string(label) + " timed out"}); +} + +void SignalingChannel::sendPrompt(const std::string& text, bool enhance, std::chrono::milliseconds timeout) { + request( + makePromptMsg(text, enhance), + [text](const IncomingMessage& m) { return m.type == IncomingType::PromptAck && m.prompt == text; }, + timeout, "Prompt send"); +} + +void SignalingChannel::setImageData(const std::optional& base64, + const std::optional& prompt, + const std::optional& enhance, std::chrono::milliseconds timeout) { + request( + makeSetImageDataMsg(base64, prompt, enhance), + [](const IncomingMessage& m) { return m.type == IncomingType::SetImageAck; }, timeout, "Image send"); +} + +void SignalingChannel::setImageRef(const std::string& ref, const std::optional& prompt, + const std::optional& enhance, std::chrono::milliseconds timeout) { + request( + makeSetImageRefMsg(ref, prompt, enhance), + [](const IncomingMessage& m) { return m.type == IncomingType::SetImageAck; }, timeout, "Image send"); +} + +void SignalingChannel::onMessage(const std::string& text) { + const IncomingMessage m = parseIncoming(text); + if (m.type == IncomingType::Unknown) return; + + // Acks and room_info are consumed by their waiters; everything else is an event. + { + std::lock_guard lk(mutex_); + for (auto& w : waiters_) { + if (!w->ready && w->match(m)) { + w->result = m; + w->ready = true; + cv_.notify_all(); + return; + } + } + } + + switch (m.type) { + case IncomingType::QueuePosition: { + { + std::lock_guard lk(mutex_); + if (!connected_) { + handshakeDeadline_ = std::chrono::steady_clock::now() + handshakeWindow_; + cv_.notify_all(); + } + } + if (callbacks_.onQueuePosition) callbacks_.onQueuePosition(m.position, m.queueSize); + break; + } + case IncomingType::GenerationStarted: + if (callbacks_.onGenerationStarted) callbacks_.onGenerationStarted(); + break; + case IncomingType::GenerationTick: + if (callbacks_.onGenerationTick) callbacks_.onGenerationTick(m.seconds); + break; + case IncomingType::GenerationEnded: + if (callbacks_.onGenerationEnded) callbacks_.onGenerationEnded(m.seconds, m.reason); + break; + case IncomingType::ServerError: { + { + std::lock_guard lk(mutex_); + if (!fatalError_) fatalError_ = m.errorMessage; + cv_.notify_all(); + } + if (callbacks_.onServerError) callbacks_.onServerError(m.errorMessage); + break; + } + default: + break; + } +} + +void SignalingChannel::onClosed(int code, const std::string& reason) { + bool report = false; + { + std::lock_guard lk(mutex_); + open_ = false; + const bool wasConnected = connected_; + if (!fatalError_) { + fatalError_ = "WebSocket closed: code=" + std::to_string(code) + (reason.empty() ? "" : " " + reason); + } + cv_.notify_all(); + report = wasConnected && !closing_; + closing_ = true; // suppress duplicate reports from a trailing Error/Close + } + if (report && callbacks_.onClosed) callbacks_.onClosed(code, reason); +} + +void SignalingChannel::onSocketError(const std::string& reason) { + std::lock_guard lk(mutex_); + if (!fatalError_) fatalError_ = reason.empty() ? "WebSocket error" : reason; + cv_.notify_all(); +} + +bool SignalingChannel::sendText(const std::string& text) { + if (!ws_) return false; + return ws_->sendText(text).success; +} + +void SignalingChannel::close() { + { + std::lock_guard lk(mutex_); + if (closing_ && !ws_) return; + closing_ = true; + } + // stop() joins the WS thread; must not hold the lock (the Close callback locks). + if (ws_) ws_->stop(); + { + std::lock_guard lk(mutex_); + open_ = false; + if (!fatalError_) fatalError_ = "Signaling channel closed"; + cv_.notify_all(); + } +} + +} // namespace decart::detail diff --git a/src/detail/signaling_channel.h b/src/detail/signaling_channel.h new file mode 100644 index 0000000..f7ce8de --- /dev/null +++ b/src/detail/signaling_channel.h @@ -0,0 +1,123 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "realtime/messages.h" + +namespace ix { +class WebSocket; +} + +namespace decart::detail { + +/// LiveKit room credentials returned by the signaling handshake. +struct RoomInfo { + std::string livekitUrl; + std::string token; + std::string roomName; + std::string sessionId; +}; + +/// The Decart realtime signaling channel: a WebSocket to `wss://api3.decart.ai` +/// that performs the join handshake, carries prompt/image control messages +/// (request/ack), and forwards server events. Media flows separately over +/// LiveKit. See [[decart-realtime-wire-protocol]]. +/// +/// Thread-safety: `sendPrompt`/`setImage*` may be called from any thread. +/// Server events are delivered on the WebSocket's internal thread. +class SignalingChannel { +public: + /// Server-pushed event callbacks. All optional; invoked on the WS thread. + struct Callbacks { + std::function onQueuePosition; + std::function onGenerationStarted; + std::function onGenerationTick; + std::function onGenerationEnded; + std::function onServerError; + /// Unexpected close after a successful connect (code, reason). + std::function onClosed; + }; + + /// @param url Full signaling URL including query params. + /// @param userAgent Value appended as the `user_agent` query param. + SignalingChannel(std::string url, std::string userAgent); + ~SignalingChannel(); + + SignalingChannel(const SignalingChannel&) = delete; + SignalingChannel& operator=(const SignalingChannel&) = delete; + + void setCallbacks(Callbacks callbacks); + + /// Open the socket and send `livekit_join`, then wait for `livekit_room_info`. + /// `connectTimeout` is a single bound covering both phases (socket open + room + /// join). `queue_position` messages refresh the deadline, so time spent waiting + /// in the inference queue does not count against it. @throws on failure. + RoomInfo openAndJoin(std::chrono::milliseconds connectTimeout); + + /// Send a prompt and block until acknowledged. @throws on failure/timeout. + void sendPrompt(const std::string& text, bool enhance, std::chrono::milliseconds timeout); + + /// Send a `set_image` (data or null) and block until acknowledged. + void setImageData(const std::optional& base64, const std::optional& prompt, + const std::optional& enhance, std::chrono::milliseconds timeout); + + /// Send a `set_image` by server file reference and block until acknowledged. + void setImageRef(const std::string& ref, const std::optional& prompt, + const std::optional& enhance, std::chrono::milliseconds timeout); + + /// Close the socket. Idempotent. + void close(); + + bool isOpen() const; + +private: + struct Waiter { + std::function match; + IncomingMessage result; + bool ready = false; + }; + + void onMessage(const std::string& text); + void onClosed(int code, const std::string& reason); + void onSocketError(const std::string& reason); + + // Register `waiter`, send `message`, and block until matched or `timeout`. + IncomingMessage request(const nlohmann::json& message, std::function match, + std::chrono::milliseconds timeout, const char* label); + + bool sendText(const std::string& text); + + std::string url_; + std::string userAgent_; + std::unique_ptr ws_; + Callbacks callbacks_; + + // Serializes request()/ack round-trips. set_image_ack carries no correlation + // id, so only one ack-bearing request may be outstanding at a time; concurrent + // callers queue here rather than racing to claim each other's acks. + std::mutex requestMutex_; + + std::mutex mutex_; + std::condition_variable cv_; + std::vector> waiters_; + + bool open_ = false; + bool connected_ = false; // room_info received (handshake complete) + bool closing_ = false; + std::optional fatalError_; + + // Handshake deadline, extendable when queue_position arrives. + std::chrono::steady_clock::time_point handshakeDeadline_{}; + std::chrono::milliseconds handshakeWindow_{}; +}; + +} // namespace decart::detail diff --git a/src/detail/url.h b/src/detail/url.h new file mode 100644 index 0000000..e7c3a28 --- /dev/null +++ b/src/detail/url.h @@ -0,0 +1,33 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include +#include + +namespace decart::detail { + +/// Percent-encode a string for use as a URL query-component value (RFC 3986 +/// unreserved characters pass through). +inline std::string urlEncode(const std::string& value) { + static const char hex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(value.size() * 3); + for (unsigned char c : value) { + if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + out.push_back(static_cast(c)); + } else { + out.push_back('%'); + out.push_back(hex[(c >> 4) & 0xF]); + out.push_back(hex[c & 0xF]); + } + } + return out; +} + +/// Append `key=urlEncode(value)` to `url`, choosing `?` or `&`. +inline std::string appendQuery(const std::string& url, const std::string& key, const std::string& value) { + const char sep = url.find('?') == std::string::npos ? '?' : '&'; + return url + sep + key + "=" + urlEncode(value); +} + +} // namespace decart::detail diff --git a/src/detail/user_agent.cpp b/src/detail/user_agent.cpp new file mode 100644 index 0000000..a1deded --- /dev/null +++ b/src/detail/user_agent.cpp @@ -0,0 +1,46 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "detail/user_agent.h" + +#include "decart/version.h" + +namespace decart::detail { +namespace { + +constexpr const char* osName() { +#if defined(_WIN32) + return "windows"; +#elif defined(__APPLE__) + return "macos"; +#elif defined(__linux__) + return "linux"; +#else + return "unknown"; +#endif +} + +constexpr const char* archName() { +#if defined(__aarch64__) || defined(_M_ARM64) + return "arm64"; +#elif defined(__x86_64__) || defined(_M_X64) + return "x64"; +#else + return "unknown"; +#endif +} + +} // namespace + +std::string buildUserAgent(const std::string& integration) { + std::string ua = "decart-sdk-cpp/" DECART_SDK_VERSION " ("; + ua += osName(); + ua += "; "; + ua += archName(); + ua += ")"; + if (!integration.empty()) { + ua += " integration/"; + ua += integration; + } + return ua; +} + +} // namespace decart::detail diff --git a/src/detail/user_agent.h b/src/detail/user_agent.h new file mode 100644 index 0000000..35b6ece --- /dev/null +++ b/src/detail/user_agent.h @@ -0,0 +1,12 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include + +namespace decart::detail { + +/// Build the SDK User-Agent string, e.g. +/// "decart-sdk-cpp/0.1.0 (macos; arm64) integration/my-app". +std::string buildUserAgent(const std::string& integration = {}); + +} // namespace decart::detail diff --git a/src/errors.cpp b/src/errors.cpp new file mode 100644 index 0000000..3c63ff2 --- /dev/null +++ b/src/errors.cpp @@ -0,0 +1,36 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "decart/errors.h" + +namespace decart { + +const char* toString(ErrorCode code) noexcept { + switch (code) { + case ErrorCode::InvalidApiKey: + return "INVALID_API_KEY"; + case ErrorCode::InvalidBaseUrl: + return "INVALID_BASE_URL"; + case ErrorCode::InvalidInput: + return "INVALID_INPUT"; + case ErrorCode::ModelNotFound: + return "MODEL_NOT_FOUND"; + case ErrorCode::TokenCreateError: + return "TOKEN_CREATE_ERROR"; + case ErrorCode::WebsocketError: + return "WEBRTC_WEBSOCKET_ERROR"; + case ErrorCode::TimeoutError: + return "WEBRTC_TIMEOUT_ERROR"; + case ErrorCode::ServerError: + return "WEBRTC_SERVER_ERROR"; + case ErrorCode::SignalingError: + return "WEBRTC_SIGNALING_ERROR"; + case ErrorCode::MediaError: + return "WEBRTC_MEDIA_ERROR"; + case ErrorCode::NotConnected: + return "NOT_CONNECTED"; + case ErrorCode::HttpError: + return "HTTP_ERROR"; + } + return "UNKNOWN"; +} + +} // namespace decart diff --git a/src/logging.cpp b/src/logging.cpp new file mode 100644 index 0000000..617c125 --- /dev/null +++ b/src/logging.cpp @@ -0,0 +1,69 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "decart/logging.h" + +#include +#include +#include + +#include "detail/log.h" + +namespace decart { +namespace { + +std::mutex g_mutex; +LogLevel g_level = LogLevel::Info; +LogHandler g_handler; // empty => default stderr sink + +const char* levelName(LogLevel level) { + switch (level) { + case LogLevel::Trace: + return "TRACE"; + case LogLevel::Debug: + return "DEBUG"; + case LogLevel::Info: + return "INFO"; + case LogLevel::Warn: + return "WARN"; + case LogLevel::Error: + return "ERROR"; + case LogLevel::Off: + return "OFF"; + } + return "?"; +} + +} // namespace + +void setLogLevel(LogLevel level) { + std::lock_guard lk(g_mutex); + g_level = level; +} + +LogLevel logLevel() noexcept { + std::lock_guard lk(g_mutex); + return g_level; +} + +void setLogHandler(LogHandler handler) { + std::lock_guard lk(g_mutex); + g_handler = std::move(handler); +} + +namespace detail { + +void log(LogLevel level, const std::string& message) { + LogHandler handler; + { + std::lock_guard lk(g_mutex); + if (static_cast(level) < static_cast(g_level) || g_level == LogLevel::Off) return; + handler = g_handler; + } + if (handler) { + handler(level, message); + } else { + std::cerr << "[decart] " << levelName(level) << " " << message << "\n"; + } +} + +} // namespace detail +} // namespace decart diff --git a/src/models.cpp b/src/models.cpp new file mode 100644 index 0000000..ff4cd8f --- /dev/null +++ b/src/models.cpp @@ -0,0 +1,70 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "decart/models.h" + +#include + +#include "decart/errors.h" + +namespace decart { +namespace models { +namespace { + +constexpr const char* kStreamPath = "/v1/stream"; + +struct Entry { + const char* name; + int width; + int height; + bool canonical; // false for "latest"/deprecated aliases +}; + +// Realtime model registry. Kept in sync with the shared model list across the +// Decart SDKs. All realtime models stream over `/v1/stream` at 30 fps. +constexpr std::array kRealtime = {{ + // Canonical + {"lucy-2.1", 1088, 624, true}, + {"lucy-2.1-vton", 1088, 624, true}, + {"lucy-vton-2", 1088, 624, true}, + {"lucy-vton-3", 1088, 624, true}, + {"lucy-restyle-2", 1280, 704, true}, + // Server-resolved "latest" aliases + {"lucy-latest", 1088, 624, false}, + {"lucy-vton-latest", 1088, 624, false}, + {"lucy-restyle-latest", 1280, 704, false}, + // Deprecated aliases (still accepted) + {"mirage_v2", 1280, 704, false}, + {"lucy-vton", 1088, 624, false}, + {"lucy-2.1-vton-2", 1088, 624, false}, +}}; + +const Entry* find(std::string_view name) { + for (const auto& e : kRealtime) { + if (name == e.name) return &e; + } + return nullptr; +} + +ModelDefinition toDefinition(const Entry& e) { + return ModelDefinition{e.name, kStreamPath, 30, e.width, e.height}; +} + +} // namespace + +ModelDefinition realtime(std::string_view name) { + if (const Entry* e = find(name)) return toDefinition(*e); + throw Exception(Error{ErrorCode::ModelNotFound, "Realtime model not found: " + std::string(name)}); +} + +bool isRealtimeModel(std::string_view name) noexcept { return find(name) != nullptr; } + +std::vector listRealtime(bool canonicalOnly) { + std::vector out; + for (const auto& e : kRealtime) { + if (canonicalOnly && !e.canonical) continue; + out.push_back(toDefinition(e)); + } + return out; +} + +} // namespace models +} // namespace decart diff --git a/src/realtime/messages.cpp b/src/realtime/messages.cpp new file mode 100644 index 0000000..960b6a6 --- /dev/null +++ b/src/realtime/messages.cpp @@ -0,0 +1,101 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "realtime/messages.h" + +namespace decart::detail { +namespace { + +using nlohmann::json; + +void applyPromptFields(json& msg, const std::optional& prompt, + const std::optional& enhance) { + if (prompt.has_value()) msg["prompt"] = *prompt; + if (enhance.has_value()) msg["enhance_prompt"] = *enhance; +} + +// nlohmann throws on type mismatch; value() with a default tolerates absent / +// null / wrong-typed fields, which keeps parsing resilient to server changes. +template +T get(const json& j, const char* key, T fallback) { + auto it = j.find(key); + if (it == j.end() || it->is_null()) return fallback; + try { + return it->get(); + } catch (const json::exception&) { + return fallback; + } +} + +} // namespace + +nlohmann::json makePromptMsg(const std::string& text, bool enhance) { + return json{{"type", "prompt"}, {"prompt", text}, {"enhance_prompt", enhance}}; +} + +nlohmann::json makeSetImageDataMsg(const std::optional& base64, + const std::optional& prompt, + const std::optional& enhance) { + json msg{{"type", "set_image"}}; + if (base64.has_value()) { + msg["image_data"] = *base64; + } else { + msg["image_data"] = nullptr; + } + applyPromptFields(msg, prompt, enhance); + return msg; +} + +nlohmann::json makeSetImageRefMsg(const std::string& ref, const std::optional& prompt, + const std::optional& enhance) { + json msg{{"type", "set_image"}, {"image_ref", ref}}; + applyPromptFields(msg, prompt, enhance); + return msg; +} + +nlohmann::json makeJoinMsg(const std::optional& initialState) { + json msg{{"type", "livekit_join"}}; + msg["initial_state"] = initialState.has_value() ? *initialState : json(nullptr); + return msg; +} + +IncomingMessage parseIncoming(const std::string& text) { + IncomingMessage out; + json j = json::parse(text, /*cb=*/nullptr, /*allow_exceptions=*/false); + if (j.is_discarded() || !j.is_object()) return out; + + const std::string type = get(j, "type", ""); + if (type == "livekit_room_info") { + out.type = IncomingType::LiveKitRoomInfo; + out.livekitUrl = get(j, "livekit_url", ""); + out.token = get(j, "token", ""); + out.roomName = get(j, "room_name", ""); + out.sessionId = get(j, "session_id", out.roomName); + } else if (type == "queue_position") { + out.type = IncomingType::QueuePosition; + out.position = get(j, "position", 0); + out.queueSize = get(j, "queue_size", 0); + } else if (type == "generation_started") { + out.type = IncomingType::GenerationStarted; + } else if (type == "generation_tick") { + out.type = IncomingType::GenerationTick; + out.seconds = get(j, "seconds", 0.0); + } else if (type == "generation_ended") { + out.type = IncomingType::GenerationEnded; + out.seconds = get(j, "seconds", 0.0); + out.reason = get(j, "reason", ""); + } else if (type == "prompt_ack") { + out.type = IncomingType::PromptAck; + out.prompt = get(j, "prompt", ""); + out.success = get(j, "success", false); + if (auto it = j.find("error"); it != j.end() && it->is_string()) out.error = it->get(); + } else if (type == "set_image_ack") { + out.type = IncomingType::SetImageAck; + out.success = get(j, "success", false); + if (auto it = j.find("error"); it != j.end() && it->is_string()) out.error = it->get(); + } else if (type == "error") { + out.type = IncomingType::ServerError; + out.errorMessage = get(j, "error", ""); + } + return out; +} + +} // namespace decart::detail diff --git a/src/realtime/messages.h b/src/realtime/messages.h new file mode 100644 index 0000000..cb0c53f --- /dev/null +++ b/src/realtime/messages.h @@ -0,0 +1,78 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +// +// Pure (de)serialization of the Decart realtime signaling protocol. No +// transport, so this is unit-testable on its own. See the wire protocol in the +// other SDKs (e.g. python `realtime/messages.py`, js `realtime/types.ts`). +#pragma once + +#include +#include +#include + +namespace decart::detail { + +// ---- Outgoing message builders ------------------------------------------------ + +/// `{"type":"prompt","prompt":,"enhance_prompt":}` +nlohmann::json makePromptMsg(const std::string& text, bool enhance); + +/// `{"type":"set_image","image_data":[,"prompt":...][,"enhance_prompt":...]}` +/// A nullopt `base64` serializes `image_data: null` (clears the image). +nlohmann::json makeSetImageDataMsg(const std::optional& base64, + const std::optional& prompt, + const std::optional& enhance); + +/// `{"type":"set_image","image_ref":[,"prompt":...][,"enhance_prompt":...]}` +nlohmann::json makeSetImageRefMsg(const std::string& ref, const std::optional& prompt, + const std::optional& enhance); + +/// `{"type":"livekit_join","initial_state":}` +nlohmann::json makeJoinMsg(const std::optional& initialState); + +// ---- Incoming message parsing ------------------------------------------------- + +enum class IncomingType { + Unknown, + LiveKitRoomInfo, + QueuePosition, + GenerationStarted, + GenerationTick, + GenerationEnded, + PromptAck, + SetImageAck, + ServerError, +}; + +/// A parsed incoming signaling message. Only the fields relevant to `type` are +/// populated. +struct IncomingMessage { + IncomingType type = IncomingType::Unknown; + + // LiveKitRoomInfo + std::string livekitUrl; + std::string token; + std::string roomName; + std::string sessionId; + + // QueuePosition + int position = 0; + int queueSize = 0; + + // GenerationTick / GenerationEnded + double seconds = 0.0; + std::string reason; + + // PromptAck / SetImageAck + std::string prompt; // echoed prompt (PromptAck) + bool success = false; + std::optional error; + + // ServerError + std::string errorMessage; +}; + +/// Parse one incoming JSON text frame. Returns `type == Unknown` for malformed +/// input or unrecognized message types (callers should ignore those). +IncomingMessage parseIncoming(const std::string& text); + +} // namespace decart::detail diff --git a/src/realtime/realtime.cpp b/src/realtime/realtime.cpp new file mode 100644 index 0000000..330f046 --- /dev/null +++ b/src/realtime/realtime.cpp @@ -0,0 +1,16 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "decart/realtime/realtime.h" + +#include "detail/client_config.h" +#include "realtime/session_internal.h" + +namespace decart { + +RealtimeClient::RealtimeClient(std::shared_ptr config) : config_(std::move(config)) {} + +std::unique_ptr RealtimeClient::connect(std::shared_ptr source, + ConnectOptions options) { + return detail::connectRealtime(config_, std::move(source), std::move(options)); +} + +} // namespace decart diff --git a/src/realtime/session.cpp b/src/realtime/session.cpp new file mode 100644 index 0000000..9a5ee49 --- /dev/null +++ b/src/realtime/session.cpp @@ -0,0 +1,467 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +// +// Realtime session orchestration: the Decart signaling handshake (control) plus +// the LiveKit room (media). See the protocol notes in [[decart-realtime-wire-protocol]]. +#include "decart/realtime/session.h" + +#include + +#include +#include +#include +#include + +#include "decart/errors.h" +#include "decart/logging.h" +#include "decart/realtime/realtime.h" +#include "detail/base64.h" +#include "detail/client_config.h" +#include "detail/livekit_runtime.h" +#include "detail/log.h" +#include "detail/signaling_channel.h" +#include "detail/url.h" +#include "detail/user_agent.h" +#include "realtime/messages.h" +#include "realtime/session_internal.h" + +namespace decart { +namespace detail { + +void ensureLiveKitInit() { + static std::once_flag flag; + std::call_once(flag, [] { + livekit::LogLevel lk = livekit::LogLevel::Warn; + switch (decart::logLevel()) { + case LogLevel::Trace: + lk = livekit::LogLevel::Trace; + break; + case LogLevel::Debug: + lk = livekit::LogLevel::Debug; + break; + case LogLevel::Info: + lk = livekit::LogLevel::Info; + break; + case LogLevel::Warn: + lk = livekit::LogLevel::Warn; + break; + case LogLevel::Error: + lk = livekit::LogLevel::Error; + break; + case LogLevel::Off: + lk = livekit::LogLevel::Off; + break; + } + livekit::initialize(lk); + }); +} + +namespace { + +constexpr const char* kInferencePrefix = "inference-server-"; +constexpr auto kPromptTimeout = std::chrono::milliseconds(15000); +constexpr auto kUpdateTimeout = std::chrono::milliseconds(30000); + +// Resolve an image input to base64. Supports `data:` URLs, local file paths, and +// raw base64 strings. Remote http(s) URLs are not fetched in v0.1. +std::string resolveImageBase64(const std::string& image) { + if (image.rfind("data:", 0) == 0) { + const auto comma = image.find(','); + return comma == std::string::npos ? image : image.substr(comma + 1); + } + if (image.rfind("http://", 0) == 0 || image.rfind("https://", 0) == 0) { + throw Exception(Error{ErrorCode::InvalidInput, + "Remote image URLs are not supported; pass a local file path, a data: " + "URL, or base64 bytes."}); + } + std::ifstream file(image, std::ios::binary); + if (file) { + std::string bytes((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + return base64Encode(bytes); + } + // Not a file — treat as raw base64 (mirrors the js/python SDK fallback). + return image; +} + +} // namespace + +/// Shared media half of a session: owns the LiveKit room, maps its events to the +/// SDK connection-state model, and pumps the inference-server video track to the +/// user's frame callback. Used by both publishing and subscribing sessions. +class MediaSession : public livekit::RoomDelegate { +public: + RemoteFrameCallback onRemoteFrame; + std::function onConnectionState; + std::function onError; + + ~MediaSession() override { disconnect(); } + + void markIntentional() { intentional_.store(true); } + + ConnectionState state() const { + std::lock_guard lk(stateMutex_); + return state_; + } + + bool isConnected() const { + const auto s = state(); + return s == ConnectionState::Connected || s == ConnectionState::Generating; + } + + void setState(ConnectionState next) { + std::function cb; + { + std::lock_guard lk(stateMutex_); + if (state_ == next) return; + state_ = next; + cb = onConnectionState; + } + if (cb) cb(next); + } + + // Transition only when currently in `from` (used for the connected<->generating + // sub-states so external Disconnected/Reconnecting transitions are not clobbered). + void transitionIf(ConnectionState from, ConnectionState to) { + std::function cb; + { + std::lock_guard lk(stateMutex_); + if (state_ != from) return; + state_ = to; + cb = onConnectionState; + } + if (cb) cb(to); + } + + void markGenerating() { transitionIf(ConnectionState::Connected, ConnectionState::Generating); } + + // Generation finished; fall back to Connected (a new run re-enters Generating). + void markGenerationEnded() { transitionIf(ConnectionState::Generating, ConnectionState::Connected); } + + void connectRoom(const RoomInfo& info, const std::shared_ptr& source, + int publishFps) { + room_ = std::make_unique(); + room_->setDelegate(this); + + livekit::RoomOptions options; + options.auto_subscribe = true; // required to receive the inference output track + options.dynacast = false; + // Use dual peer connections (separate publish/subscribe), matching the + // browser/JS SDK. Single-PC mode dropped the inference output subscription. + options.single_peer_connection = false; + + if (!room_->connect(info.livekitUrl, info.token, options)) { + throw Exception(Error{ErrorCode::MediaError, "Failed to connect to the LiveKit room"}); + } + + if (source) { + auto local = room_->localParticipant().lock(); + if (!local) throw Exception(Error{ErrorCode::MediaError, "Local participant unavailable"}); + + auto track = livekit::LocalVideoTrack::createLocalVideoTrack("decart-input", source); + livekit::TrackPublishOptions publish; + publish.source = livekit::TrackSource::SOURCE_CAMERA; + publish.simulcast = false; + livekit::VideoEncodingOptions encoding; + encoding.max_bitrate = 3'500'000; + encoding.max_framerate = static_cast(publishFps); + publish.video_encoding = encoding; + local->publishTrack(track, publish); + publishedTrack_ = std::move(track); + } + } + + void disconnect() { + intentional_.store(true); + + if (room_) { + { + std::lock_guard lk(mediaMutex_); + if (videoCallbackSet_) { + room_->clearOnVideoFrameCallback(remoteIdentity_, remoteTrackName_); + videoCallbackSet_ = false; + } + } + room_->setDelegate(nullptr); + room_->disconnect(); + room_.reset(); + } + publishedTrack_.reset(); + setState(ConnectionState::Disconnected); + } + + // --- RoomDelegate --- + + void onTrackSubscribed(livekit::Room& room, const livekit::TrackSubscribedEvent& ev) override { + const std::string identity = ev.participant ? ev.participant->identity() : ""; + const bool isVideo = ev.track && ev.track->kind() == livekit::TrackKind::KIND_VIDEO; + log(LogLevel::Debug, "trackSubscribed: identity=" + identity + " kind=" + (isVideo ? "video" : "other") + + " name=" + (ev.track ? ev.track->name() : std::string(""))); + if (ev.participant == nullptr || !ev.track || !isVideo) return; + if (identity.rfind(kInferencePrefix, 0) != 0) return; + + // Register a dispatcher-managed frame callback keyed by (identity, trackName). + // The inference output track is re-published as generation (re)starts; the + // dispatcher re-attaches a reader on each re-subscribe, where a one-shot + // VideoStream bound to a single track handle would go silent. + std::lock_guard lk(mediaMutex_); + if (videoCallbackSet_) return; + videoCallbackSet_ = true; + remoteIdentity_ = identity; + remoteTrackName_ = ev.track->name(); + auto cb = onRemoteFrame; + livekit::VideoStream::Options opts; + opts.format = livekit::VideoBufferType::RGBA; + room.setOnVideoFrameCallback( + remoteIdentity_, remoteTrackName_, + [cb](const livekit::VideoFrame& frame, std::int64_t ts) { + if (cb) cb(frame, ts); + }, + opts); + log(LogLevel::Debug, "registered video frame callback for " + identity); + } + + // Media-layer reconnects must not resurrect a session whose control channel + // already died (signaling close sets Disconnected, which is terminal in v0.1), + // so only move between the live connected<->reconnecting sub-states. + void onReconnecting(livekit::Room&, const livekit::ReconnectingEvent&) override { + transitionIf(ConnectionState::Connected, ConnectionState::Reconnecting); + transitionIf(ConnectionState::Generating, ConnectionState::Reconnecting); + } + + void onReconnected(livekit::Room&, const livekit::ReconnectedEvent&) override { + transitionIf(ConnectionState::Reconnecting, ConnectionState::Connected); + } + + void onDisconnected(livekit::Room&, const livekit::DisconnectedEvent&) override { + if (intentional_.load()) return; + setState(ConnectionState::Disconnected); + if (onError) onError(Error{ErrorCode::MediaError, "LiveKit room disconnected"}); + } + + void onTrackSubscriptionFailed(livekit::Room&, const livekit::TrackSubscriptionFailedEvent& ev) override { + if (onError) onError(Error{ErrorCode::MediaError, "Track subscription failed: " + ev.error}); + } + +private: + std::unique_ptr room_; + std::shared_ptr publishedTrack_; + + mutable std::mutex stateMutex_; + ConnectionState state_ = ConnectionState::Disconnected; + std::atomic intentional_{false}; + + std::mutex mediaMutex_; + bool videoCallbackSet_ = false; + std::string remoteIdentity_; + std::string remoteTrackName_; +}; + +} // namespace detail + +// --------------------------------------------------------------------------- +// RealtimeSession +// --------------------------------------------------------------------------- + +struct RealtimeSession::Impl { + std::shared_ptr config; + std::shared_ptr source; + detail::MediaSession media; + // Declared last so it is destroyed first: closing the socket stops callbacks + // before `media` is torn down. + std::unique_ptr signaling; + + mutable std::mutex infoMutex; + std::optional sessionId; + + void setSessionId(std::string sid) { + std::lock_guard lk(infoMutex); + sessionId = std::move(sid); + } + + void assertConnected() const { + if (!media.isConnected()) { + throw Exception(Error{ErrorCode::NotConnected, "Realtime session is not connected"}); + } + } + + void disconnect() { + media.markIntentional(); + if (signaling) signaling->close(); + media.disconnect(); + } +}; + +RealtimeSession::RealtimeSession() : impl_(std::make_unique()) {} +RealtimeSession::~RealtimeSession() { + if (impl_) impl_->disconnect(); +} +RealtimeSession::RealtimeSession(RealtimeSession&&) noexcept = default; +RealtimeSession& RealtimeSession::operator=(RealtimeSession&&) noexcept = default; + +void RealtimeSession::setPrompt(const std::string& prompt, bool enhance) { + if (prompt.empty()) throw Exception(Error{ErrorCode::InvalidInput, "Prompt cannot be empty"}); + impl_->assertConnected(); + impl_->signaling->sendPrompt(prompt, enhance, detail::kPromptTimeout); +} + +void RealtimeSession::set(const SetInput& input) { + if (!input.prompt.has_value() && !input.image.has_value()) { + throw Exception(Error{ErrorCode::InvalidInput, "At least one of 'prompt' or 'image' must be provided"}); + } + impl_->assertConnected(); + + // Prompt-only update: send a prompt message so the active reference image is + // preserved (a set_image with image_data:null would clear it). Use setImage() + // to change or clear the image explicitly. + if (!input.image.has_value()) { + impl_->signaling->sendPrompt(*input.prompt, input.enhance, detail::kPromptTimeout); + return; + } + + const std::optional prompt = input.prompt; + const std::optional enhance = + input.prompt.has_value() ? std::optional(input.enhance) : std::nullopt; + + if (input.image->ref.has_value()) { + impl_->signaling->setImageRef(*input.image->ref, prompt, enhance, detail::kUpdateTimeout); + return; + } + std::optional base64; + if (input.image->image.has_value()) { + base64 = detail::resolveImageBase64(*input.image->image); + } + impl_->signaling->setImageData(base64, prompt, enhance, detail::kUpdateTimeout); +} + +void RealtimeSession::setImage(const std::optional& image, const UpdateOptions& options) { + impl_->assertConnected(); + const std::optional prompt = options.prompt; + const std::optional enhance = + options.prompt.has_value() ? std::optional(options.enhance) : std::nullopt; + + if (image.has_value() && image->ref.has_value()) { + impl_->signaling->setImageRef(*image->ref, prompt, enhance, options.timeout); + return; + } + std::optional base64; + if (image.has_value() && image->image.has_value()) { + base64 = detail::resolveImageBase64(*image->image); + } + impl_->signaling->setImageData(base64, prompt, enhance, options.timeout); +} + +bool RealtimeSession::isConnected() const noexcept { return impl_->media.isConnected(); } +ConnectionState RealtimeSession::connectionState() const noexcept { return impl_->media.state(); } + +std::optional RealtimeSession::sessionId() const { + std::lock_guard lk(impl_->infoMutex); + return impl_->sessionId; +} + +void RealtimeSession::disconnect() { impl_->disconnect(); } + +// --------------------------------------------------------------------------- +// Orchestration (declared in session_internal.h) +// --------------------------------------------------------------------------- + +namespace detail { + +// Friend of RealtimeSession; lets the orchestrator reach Impl. +struct SessionInternals { + static RealtimeSession::Impl* impl(RealtimeSession& session) { return session.impl_.get(); } +}; + +std::unique_ptr connectRealtime(std::shared_ptr config, + std::shared_ptr source, + ConnectOptions options) { + if (!source) { + throw Exception(Error{ErrorCode::InvalidInput, + "connect() requires a video source to publish; pass a livekit::VideoSource"}); + } + ensureLiveKitInit(); + + auto session = std::unique_ptr(new RealtimeSession()); + auto* impl = SessionInternals::impl(*session); + impl->config = config; + impl->source = source; + impl->media.onRemoteFrame = options.onRemoteFrame; + impl->media.onConnectionState = options.onConnectionState; + impl->media.onError = options.onError; + impl->media.setState(ConnectionState::Connecting); + + std::string url = config->realtimeBaseUrl + options.model.urlPath; + url = appendQuery(url, "api_key", config->apiKey); + url = appendQuery(url, "model", options.model.name); + if (options.resolution.has_value()) url = appendQuery(url, "resolution", *options.resolution); + + impl->signaling = std::make_unique(url, buildUserAgent(config->integration)); + + SignalingChannel::Callbacks callbacks; + callbacks.onQueuePosition = [cb = options.onQueuePosition](int position, int size) { + if (cb) cb(QueuePosition{position, size}); + }; + callbacks.onGenerationStarted = [impl] { impl->media.markGenerating(); }; + callbacks.onGenerationTick = [impl, cb = options.onGenerationTick](double seconds) { + impl->media.markGenerating(); + if (cb) cb(GenerationTick{seconds}); + }; + callbacks.onGenerationEnded = [impl, cb = options.onGenerationEnded](double seconds, + const std::string& reason) { + impl->media.markGenerationEnded(); + if (cb) cb(GenerationEnded{seconds, reason}); + }; + callbacks.onServerError = [cb = options.onError](const std::string& message) { + if (cb) cb(Error{ErrorCode::ServerError, message}); + }; + // An unexpected signaling drop after connect means control is dead: reflect it + // in the connection state so isConnected() stops returning true, then notify. + callbacks.onClosed = [impl, cb = options.onError](int code, const std::string& reason) { + impl->media.setState(ConnectionState::Disconnected); + if (cb) + cb(Error{ErrorCode::WebsocketError, + "Signaling channel closed: " + std::to_string(code) + " " + reason}); + }; + impl->signaling->setCallbacks(std::move(callbacks)); + + try { + const RoomInfo info = impl->signaling->openAndJoin(options.connectTimeout); + + // Initial reference image / prompt rides ahead of the media connect so the + // model has it before the first frame (mirrors the python SDK ordering). + bool hasInitialState = false; + const auto& initial = options.initialState; + const std::optional initialPrompt = + initial.prompt.has_value() ? std::optional(initial.prompt->text) : std::nullopt; + const std::optional initialEnhance = + initial.prompt.has_value() ? std::optional(initial.prompt->enhance) : std::nullopt; + + if (initial.image.has_value()) { + hasInitialState = true; + if (initial.image->ref.has_value()) { + impl->signaling->setImageRef(*initial.image->ref, initialPrompt, initialEnhance, kUpdateTimeout); + } else if (initial.image->image.has_value()) { + impl->signaling->setImageData(resolveImageBase64(*initial.image->image), initialPrompt, + initialEnhance, kUpdateTimeout); + } + } else if (initial.prompt.has_value()) { + hasInitialState = true; + impl->signaling->sendPrompt(initial.prompt->text, initial.prompt->enhance, kPromptTimeout); + } + + impl->media.connectRoom(info, source, options.model.fps); + + // With no explicit initial state, a passthrough set_image kicks off generation. + if (!hasInitialState) { + impl->signaling->setImageData(std::nullopt, std::nullopt, std::nullopt, kUpdateTimeout); + } + + impl->setSessionId(info.sessionId); + impl->media.setState(ConnectionState::Connected); + return session; + } catch (...) { + impl->disconnect(); + throw; + } +} + +} // namespace detail +} // namespace decart diff --git a/src/realtime/session_internal.h b/src/realtime/session_internal.h new file mode 100644 index 0000000..a07c945 --- /dev/null +++ b/src/realtime/session_internal.h @@ -0,0 +1,21 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#pragma once + +#include + +#include "decart/realtime/realtime.h" +#include "detail/client_config.h" + +namespace livekit { +class VideoSource; +} + +namespace decart::detail { + +/// Orchestrates a publishing realtime session: signaling handshake + LiveKit +/// room connect + publish + remote-frame pump. Implemented in session.cpp. +std::unique_ptr connectRealtime(std::shared_ptr config, + std::shared_ptr source, + ConnectOptions options); + +} // namespace decart::detail diff --git a/src/realtime/types.cpp b/src/realtime/types.cpp new file mode 100644 index 0000000..5b983f9 --- /dev/null +++ b/src/realtime/types.cpp @@ -0,0 +1,22 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "decart/realtime/types.h" + +namespace decart { + +const char* toString(ConnectionState state) noexcept { + switch (state) { + case ConnectionState::Connecting: + return "connecting"; + case ConnectionState::Connected: + return "connected"; + case ConnectionState::Generating: + return "generating"; + case ConnectionState::Disconnected: + return "disconnected"; + case ConnectionState::Reconnecting: + return "reconnecting"; + } + return "unknown"; +} + +} // namespace decart diff --git a/src/tokens.cpp b/src/tokens.cpp new file mode 100644 index 0000000..03b465e --- /dev/null +++ b/src/tokens.cpp @@ -0,0 +1,57 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include "decart/tokens.h" + +#include + +#include "decart/errors.h" +#include "detail/client_config.h" +#include "detail/http_client.h" +#include "detail/user_agent.h" + +namespace decart { + +TokensClient::TokensClient(std::shared_ptr config) : config_(std::move(config)) {} + +CreateTokenResponse TokensClient::create(const CreateTokenOptions& options) { + using nlohmann::json; + + json body = json::object(); + if (options.expiresIn.has_value()) body["expiresIn"] = *options.expiresIn; + if (!options.metadata.empty()) body["metadata"] = options.metadata; + if (!options.allowedModels.empty()) body["allowedModels"] = options.allowedModels; + if (!options.allowedOrigins.empty()) body["allowedOrigins"] = options.allowedOrigins; + if (options.maxRealtimeSessionDuration.has_value()) { + body["constraints"] = {{"realtime", {{"maxSessionDuration", *options.maxRealtimeSessionDuration}}}}; + } + + const std::string url = config_->baseUrl + "/v1/client/tokens"; + const std::map headers{ + {"X-API-KEY", config_->apiKey}, + {"Content-Type", "application/json"}, + {"User-Agent", detail::buildUserAgent(config_->integration)}, + }; + + const detail::HttpResponse resp = detail::httpPostJson(url, body.dump(), headers); + + if (!resp.transportError.empty()) { + throw Exception(Error{ErrorCode::HttpError, "Token request failed: " + resp.transportError}); + } + if (resp.status < 200 || resp.status >= 300) { + throw Exception(Error{ErrorCode::TokenCreateError, + "Failed to create token: " + std::to_string(resp.status) + " - " + resp.body, + resp.status}); + } + + json parsed = json::parse(resp.body, nullptr, /*allow_exceptions=*/false); + if (parsed.is_discarded() || !parsed.is_object()) { + throw Exception(Error{ErrorCode::TokenCreateError, "Malformed token response", resp.status}); + } + + CreateTokenResponse out; + out.apiKey = parsed.value("apiKey", std::string()); + out.expiresAt = parsed.value("expiresAt", std::string()); + out.rawJson = resp.body; + return out; +} + +} // namespace decart diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..d804642 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,39 @@ +# Copyright 2026 Decart. SPDX-License-Identifier: MIT + +# doctest: from vcpkg/system, else fetched. +find_package(doctest CONFIG QUIET) +if(NOT TARGET doctest::doctest) + message(STATUS "decart: fetching doctest") + # doctest's CMakeLists predates CMake 4's 3.5 policy floor; allow it. + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) + FetchContent_Declare(doctest + GIT_REPOSITORY https://github.com/doctest/doctest.git + GIT_TAG v2.4.11) + FetchContent_MakeAvailable(doctest) + unset(CMAKE_POLICY_VERSION_MINIMUM) +endif() + +set(DECART_TEST_SOURCES + test_main.cpp + test_models.cpp + test_base64.cpp + test_enums.cpp) + +# The signaling message codec only exists in realtime builds. +if(DECART_BUILD_REALTIME) + list(APPEND DECART_TEST_SOURCES test_messages.cpp) +endif() + +add_executable(decart_tests ${DECART_TEST_SOURCES}) + +target_include_directories(decart_tests PRIVATE + ${CMAKE_SOURCE_DIR}/src) # internal headers under test (messages, base64) + +target_link_libraries(decart_tests PRIVATE + decart::decart + doctest::doctest + nlohmann_json::nlohmann_json) + +decart_copy_livekit_runtime(decart_tests) + +add_test(NAME decart_tests COMMAND decart_tests) diff --git a/tests/test_base64.cpp b/tests/test_base64.cpp new file mode 100644 index 0000000..a7a314e --- /dev/null +++ b/tests/test_base64.cpp @@ -0,0 +1,28 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include + +#include +#include + +#include "detail/base64.h" + +using decart::detail::base64Encode; + +static std::string enc(const std::string& s) { + return base64Encode(reinterpret_cast(s.data()), s.size()); +} + +TEST_CASE("base64Encode matches RFC 4648 test vectors") { + CHECK(enc("") == ""); + CHECK(enc("f") == "Zg=="); + CHECK(enc("fo") == "Zm8="); + CHECK(enc("foo") == "Zm9v"); + CHECK(enc("foob") == "Zm9vYg=="); + CHECK(enc("fooba") == "Zm9vYmE="); + CHECK(enc("foobar") == "Zm9vYmFy"); +} + +TEST_CASE("base64Encode handles binary bytes") { + std::vector bytes{0x00, 0xFF, 0x10, 0x80, 0x7F}; + CHECK(base64Encode(bytes) == "AP8QgH8="); +} diff --git a/tests/test_enums.cpp b/tests/test_enums.cpp new file mode 100644 index 0000000..24e1a9a --- /dev/null +++ b/tests/test_enums.cpp @@ -0,0 +1,33 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include + +#include + +#include "decart/errors.h" +#include "decart/realtime/types.h" + +using namespace decart; + +TEST_CASE("ErrorCode names are stable and cross-SDK aligned") { + CHECK(std::string(toString(ErrorCode::InvalidApiKey)) == "INVALID_API_KEY"); + CHECK(std::string(toString(ErrorCode::ModelNotFound)) == "MODEL_NOT_FOUND"); + CHECK(std::string(toString(ErrorCode::ServerError)) == "WEBRTC_SERVER_ERROR"); +} + +TEST_CASE("ConnectionState names match the other SDKs") { + CHECK(std::string(toString(ConnectionState::Connecting)) == "connecting"); + CHECK(std::string(toString(ConnectionState::Connected)) == "connected"); + CHECK(std::string(toString(ConnectionState::Generating)) == "generating"); + CHECK(std::string(toString(ConnectionState::Disconnected)) == "disconnected"); + CHECK(std::string(toString(ConnectionState::Reconnecting)) == "reconnecting"); +} + +TEST_CASE("ImageInput factory helpers set the right field") { + auto p = ImageInput::fromPath("/tmp/a.png"); + CHECK(p.image.has_value()); + CHECK_FALSE(p.ref.has_value()); + + auto r = ImageInput::fromRef("file_1"); + CHECK(r.ref.has_value()); + CHECK_FALSE(r.image.has_value()); +} diff --git a/tests/test_main.cpp b/tests/test_main.cpp new file mode 100644 index 0000000..3fbf136 --- /dev/null +++ b/tests/test_main.cpp @@ -0,0 +1,3 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include diff --git a/tests/test_messages.cpp b/tests/test_messages.cpp new file mode 100644 index 0000000..1aae3c7 --- /dev/null +++ b/tests/test_messages.cpp @@ -0,0 +1,102 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include + +#include "realtime/messages.h" + +using namespace decart::detail; + +TEST_CASE("makePromptMsg produces the wire shape") { + auto j = makePromptMsg("Anime", true); + CHECK(j["type"] == "prompt"); + CHECK(j["prompt"] == "Anime"); + CHECK(j["enhance_prompt"] == true); +} + +TEST_CASE("makeSetImageDataMsg encodes data and null clears") { + auto with = makeSetImageDataMsg("aGVsbG8=", std::string("noir"), true); + CHECK(with["type"] == "set_image"); + CHECK(with["image_data"] == "aGVsbG8="); + CHECK(with["prompt"] == "noir"); + CHECK(with["enhance_prompt"] == true); + CHECK_FALSE(with.contains("image_ref")); + + auto cleared = makeSetImageDataMsg(std::nullopt, std::nullopt, std::nullopt); + CHECK(cleared["image_data"].is_null()); + CHECK_FALSE(cleared.contains("prompt")); + CHECK_FALSE(cleared.contains("enhance_prompt")); +} + +TEST_CASE("makeSetImageRefMsg references a server file id") { + auto j = makeSetImageRefMsg("file_123", std::nullopt, std::nullopt); + CHECK(j["image_ref"] == "file_123"); + CHECK_FALSE(j.contains("image_data")); +} + +TEST_CASE("makeJoinMsg embeds initial state or null") { + auto empty = makeJoinMsg(std::nullopt); + CHECK(empty["type"] == "livekit_join"); + CHECK(empty["initial_state"].is_null()); + + auto withState = makeJoinMsg(makePromptMsg("hi", false)); + CHECK(withState["initial_state"]["type"] == "prompt"); + CHECK(withState["initial_state"]["prompt"] == "hi"); +} + +TEST_CASE("parseIncoming handles livekit_room_info") { + auto m = parseIncoming( + R"({"type":"livekit_room_info","livekit_url":"wss://lk","token":"t","room_name":"r","session_id":"s"})"); + CHECK(m.type == IncomingType::LiveKitRoomInfo); + CHECK(m.livekitUrl == "wss://lk"); + CHECK(m.token == "t"); + CHECK(m.roomName == "r"); + CHECK(m.sessionId == "s"); +} + +TEST_CASE("parseIncoming defaults session_id to room_name when absent") { + auto m = + parseIncoming(R"({"type":"livekit_room_info","livekit_url":"u","token":"t","room_name":"room42"})"); + CHECK(m.sessionId == "room42"); +} + +TEST_CASE("parseIncoming handles queue, generation and acks") { + auto q = parseIncoming(R"({"type":"queue_position","position":3,"queue_size":10})"); + CHECK(q.type == IncomingType::QueuePosition); + CHECK(q.position == 3); + CHECK(q.queueSize == 10); + + auto started = parseIncoming(R"({"type":"generation_started"})"); + CHECK(started.type == IncomingType::GenerationStarted); + + auto tick = parseIncoming(R"({"type":"generation_tick","seconds":1.5})"); + CHECK(tick.type == IncomingType::GenerationTick); + CHECK(tick.seconds == doctest::Approx(1.5)); + + auto ended = parseIncoming(R"({"type":"generation_ended","seconds":4,"reason":"done"})"); + CHECK(ended.type == IncomingType::GenerationEnded); + CHECK(ended.reason == "done"); + + auto pa = parseIncoming(R"({"type":"prompt_ack","prompt":"hi","success":true,"error":null})"); + CHECK(pa.type == IncomingType::PromptAck); + CHECK(pa.prompt == "hi"); + CHECK(pa.success == true); + CHECK_FALSE(pa.error.has_value()); + + auto ia = parseIncoming(R"({"type":"set_image_ack","success":false,"error":"too big"})"); + CHECK(ia.type == IncomingType::SetImageAck); + CHECK(ia.success == false); + REQUIRE(ia.error.has_value()); + CHECK(*ia.error == "too big"); +} + +TEST_CASE("parseIncoming surfaces server errors") { + auto e = parseIncoming(R"({"type":"error","error":"unauthorized"})"); + CHECK(e.type == IncomingType::ServerError); + CHECK(e.errorMessage == "unauthorized"); +} + +TEST_CASE("parseIncoming is resilient to malformed and unknown input") { + CHECK(parseIncoming("not json").type == IncomingType::Unknown); + CHECK(parseIncoming("[]").type == IncomingType::Unknown); + CHECK(parseIncoming(R"({"type":"future_thing"})").type == IncomingType::Unknown); + CHECK(parseIncoming(R"({"no":"type"})").type == IncomingType::Unknown); +} diff --git a/tests/test_models.cpp b/tests/test_models.cpp new file mode 100644 index 0000000..e9f8935 --- /dev/null +++ b/tests/test_models.cpp @@ -0,0 +1,46 @@ +// Copyright 2026 Decart. SPDX-License-Identifier: MIT +#include + +#include "decart/errors.h" +#include "decart/models.h" + +using namespace decart; + +TEST_CASE("realtime() resolves canonical models with correct geometry") { + auto m = models::realtime("lucy-restyle-2"); + CHECK(m.name == "lucy-restyle-2"); + CHECK(m.urlPath == "/v1/stream"); + CHECK(m.fps == 30); + CHECK(m.width == 1280); + CHECK(m.height == 704); + + auto vton = models::realtime("lucy-vton-3"); + CHECK(vton.width == 1088); + CHECK(vton.height == 624); +} + +TEST_CASE("realtime() accepts latest and deprecated aliases") { + CHECK(models::realtime("lucy-latest").urlPath == "/v1/stream"); + CHECK(models::realtime("lucy-vton-latest").name == "lucy-vton-latest"); + CHECK(models::realtime("mirage_v2").width == 1280); // deprecated -> restyle geometry + CHECK(models::isRealtimeModel("lucy-vton")); +} + +TEST_CASE("realtime() throws ModelNotFound for unknown names") { + CHECK_FALSE(models::isRealtimeModel("not-a-model")); + CHECK_THROWS_AS(models::realtime("not-a-model"), Exception); + try { + models::realtime("nope"); + } catch (const Exception& e) { + // Compare via integer form: doctest stringifies enums through our ADL + // toString() (which returns const char*) and cannot concatenate that. + CHECK(static_cast(e.code()) == static_cast(ErrorCode::ModelNotFound)); + } +} + +TEST_CASE("listRealtime() honors canonicalOnly") { + auto all = models::listRealtime(/*canonicalOnly=*/false); + auto canonical = models::listRealtime(/*canonicalOnly=*/true); + CHECK(canonical.size() == 5); + CHECK(all.size() > canonical.size()); +} diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..51c73ce --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", + "name": "decart-cpp", + "version": "0.1.0", + "description": "Decart C++ SDK — realtime video transformation and auth", + "homepage": "https://github.com/DecartAI/decart-cpp", + "license": "MIT", + "supports": "windows | linux | osx", + "dependencies": [ + "nlohmann-json", + { + "name": "ixwebsocket", + "features": ["ssl"] + } + ], + "features": { + "tests": { + "description": "Build unit tests", + "dependencies": ["doctest"] + } + } +} From f7d8626b2a2e66e5cf63d5c84bb6113d8c24e9f5 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Thu, 18 Jun 2026 14:09:19 +0300 Subject: [PATCH 2/2] fix(windows): link Bcrypt for IXWebSocket TLS and use _popen/_pclose - Windows build failed to link the examples: IXWebSocket's mbedtls TLS backend references BCryptGenRandom, so link Bcrypt.lib (PUBLIC) on Windows. - realtime_video used POSIX popen/pclose, which MSVC names _popen/_pclose; add a _WIN32 shim. - Silence MSVC C4996 getenv warnings via _CRT_SECURE_NO_WARNINGS. --- CMakeLists.txt | 7 +++++++ examples/CMakeLists.txt | 4 ++++ examples/realtime_video.cpp | 6 ++++++ 3 files changed, 17 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 96d6387..e467b28 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -129,6 +129,12 @@ target_link_libraries(decart PRIVATE $ #include +// MSVC spells the POSIX pipe functions with a leading underscore. +#ifdef _WIN32 +#define popen _popen +#define pclose _pclose +#endif + namespace { FILE* openDecodePipe(const std::string& input, int w, int h, int fps) {