Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ jobs:
env:
# Used by cmake/LiveKitSDK.cmake to resolve & download the LiveKit release.
GITHUB_TOKEN: ${{ github.token }}
# CI pin: upstream's "latest" LiveKit release currently has no assets; unpin when fixed upstream.
DECART_LIVEKIT_VERSION: "1.11.0"
steps:
- uses: actions/checkout@v4

Expand All @@ -35,7 +37,8 @@ jobs:
if: runner.os != 'Windows'
run: |
cmake -B build -S . -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DDECART_BUILD_EXAMPLES=ON -DDECART_BUILD_TESTS=ON
-DDECART_BUILD_EXAMPLES=ON -DDECART_BUILD_TESTS=ON \
-DDECART_LIVEKIT_VERSION="$DECART_LIVEKIT_VERSION"
cmake --build build

- name: Configure & build (Windows, vcpkg)
Expand All @@ -45,7 +48,8 @@ jobs:
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
-DDECART_BUILD_EXAMPLES=ON -DDECART_BUILD_TESTS=ON `
-DDECART_LIVEKIT_VERSION="$env:DECART_LIVEKIT_VERSION"
cmake --build build --config Release

- name: Test
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,32 @@ session->unmute(); // generation (and billing)
See [`examples/realtime_warmup.cpp`](examples/realtime_warmup.cpp) for a complete,
runnable example.

### Connect options: resolution and speed

`ConnectOptions` carries two optional per-session hints. Both are omitted from the
request when unset, which selects the server defaults.

- `resolution` (`std::optional<std::string>`): output resolution hint, `"720p"` or
`"1080p"`.
- `speed` (`std::optional<decart::Speed>`): compute tier. Fast mode
(`decart::Speed::Fast`, sent as `speed=fast`) serves the session from a
higher-compute tier for lower latency and higher throughput; output quality is
unchanged. It is currently available for `lucy-2.5` / `lucy-latest` and
`lucy-vton-3.5` / `lucy-vton-latest`, in the US region only, and is billed at 2x
the standard realtime rate for those models. Other models ignore the option (the
SDK logs a warning). Omit it (the default) for standard mode.
`ModelDefinition::supportedSpeeds` lists the tiers a model advertises.

```cpp
decart::ConnectOptions options;
options.model = decart::models::realtime("lucy-2.5");
options.resolution = "1080p"; // optional output resolution hint
options.speed = decart::Speed::Fast; // optional fast mode (2x rate, US only)
```

See the [Decart platform docs](https://docs.platform.decart.ai) for current model
availability and pricing.

### Authentication (client tokens)

Create a short-lived token server-side to hand to an untrusted client:
Expand Down
6 changes: 6 additions & 0 deletions examples/realtime_warmup.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
//
// DECART_API_KEY=sk-... ./realtime_warmup [model]
//
// Set DECART_SPEED=fast to request fast mode (lucy-2.5 / lucy-vton-3.5 families
// only; US region; billed at 2x the standard realtime rate).
//
#include <decart/decart.h>
#include <livekit/livekit.h>

Expand Down Expand Up @@ -36,6 +39,9 @@ int main(int argc, char** argv) {
options.model = model;
options.initialState.prompt = decart::Prompt{"A watercolor painting", true};
options.startMuted = true; // warm the connection without transmitting (no billing)
if (const char* speed = std::getenv("DECART_SPEED"); speed != nullptr && std::string(speed) == "fast") {
options.speed = decart::Speed::Fast; // higher-compute tier: lower latency, 2x rate, US only
}
options.onConnectionState = [](decart::ConnectionState state) {
// Stays "connected" while warmed; flips to "generating" once frames flow.
std::cout << "[state] " << decart::toString(state) << "\n";
Expand Down
6 changes: 6 additions & 0 deletions include/decart/models.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#include <string_view>
#include <vector>

#include "decart/realtime/types.h"

namespace decart {

/// A realtime model definition: everything the SDK needs to open a session and
Expand All @@ -21,6 +23,10 @@ struct ModelDefinition {
int width = 0;
/// Recommended capture height in pixels.
int height = 0;
/// Compute tiers this model accepts via `ConnectOptions::speed` (empty when the
/// model only offers the standard tier). Passing an unlisted speed is not an
/// error: the server ignores it and serves the standard tier.
std::vector<Speed> supportedSpeeds;
};

namespace models {
Expand Down
12 changes: 11 additions & 1 deletion include/decart/realtime/realtime.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ struct ConnectOptions {
/// Optional prompt/image to apply during the handshake.
InitialState initialState;

/// Optional output resolution hint ("720p" or "1080p").
/// Optional output resolution hint ("720p" or "1080p"). Omitted from the
/// request when unset.
std::optional<std::string> resolution;

/// Publish the input track muted, so no frames reach the model until you call
Expand All @@ -73,6 +74,15 @@ struct ConnectOptions {
/// Bound on the signaling handshake (socket open + room join). The LiveKit
/// media connect manages its own timeout. Default 60s.
std::chrono::milliseconds connectTimeout{60000};

/// Optional compute tier. `Speed::Fast` serves the session from a
/// higher-compute tier for lower latency and higher throughput; output quality
/// is unchanged. Currently available for `lucy-2.5` / `lucy-latest` and
/// `lucy-vton-3.5` / `lucy-vton-latest` (see
/// `ModelDefinition::supportedSpeeds`), in the US region only, and billed at
/// 2x the standard realtime rate for those models. Other models ignore the
/// option (the SDK logs a warning). Leave unset (the default) for standard mode.
std::optional<Speed> speed;
};

/// Entry point for realtime video transformation. Obtained from `Client::realtime()`.
Expand Down
14 changes: 14 additions & 0 deletions include/decart/realtime/types.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ enum class ConnectionState {
/// Human-readable name for a ConnectionState (e.g. "generating").
const char* toString(ConnectionState state) noexcept;

/// Compute tier for a realtime session, requested via `ConnectOptions::speed`.
/// Matches the `speed` option across the Decart SDKs.
enum class Speed {
/// Fast mode: serves the session from a higher-compute tier for lower latency
/// and higher throughput; output quality is unchanged. Currently available
/// for `lucy-2.5` / `lucy-latest` and `lucy-vton-3.5` / `lucy-vton-latest`,
/// in the US region only, and billed at 2x the standard realtime rate for
/// those models. Other models ignore it.
Fast,
};

/// Wire value for a Speed (e.g. "fast"), as sent in the `speed` query parameter.
const char* toString(Speed speed) noexcept;

/// A text prompt plus whether the server should enhance it.
struct Prompt {
std::string text;
Expand Down
46 changes: 46 additions & 0 deletions src/detail/stream_url.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright 2026 Decart. SPDX-License-Identifier: MIT
#pragma once

#include <algorithm>
#include <optional>
#include <string>

#include "decart/models.h"
#include "decart/realtime/types.h"
#include "detail/log.h"
#include "detail/url.h"

namespace decart::detail {

/// Build the realtime signaling URL for a session: `<base><model.urlPath>` plus
/// the `api_key`, `model`, and optional `resolution` / `speed` query parameters,
/// in that order. Optional parameters are omitted entirely when unset. The
/// `user_agent` parameter is appended later by SignalingChannel. Pure, so it can
/// be unit-tested without LiveKit.
inline std::string buildStreamUrl(const std::string& base, const ModelDefinition& model,
const std::string& apiKey, const std::optional<std::string>& resolution,
std::optional<Speed> speed) {
std::string url = base + model.urlPath;
url = appendQuery(url, "api_key", apiKey);
url = appendQuery(url, "model", model.name);
if (resolution.has_value()) url = appendQuery(url, "resolution", *resolution);
if (speed.has_value()) url = appendQuery(url, "speed", toString(*speed));
return url;
}

/// True when `model` advertises `speed` in `ModelDefinition::supportedSpeeds`.
inline bool supportsSpeed(const ModelDefinition& model, Speed speed) noexcept {
return std::find(model.supportedSpeeds.begin(), model.supportedSpeeds.end(), speed) !=
model.supportedSpeeds.end();
}

/// Log a warning when `speed` is requested for a model that does not advertise
/// it. Not an error: the parameter is still sent and the server ignores it,
/// serving (and billing) the standard tier.
inline void warnIfSpeedUnsupported(const ModelDefinition& model, std::optional<Speed> speed) {
if (!speed.has_value() || supportsSpeed(model, *speed)) return;
logWarn("speed=" + std::string(toString(*speed)) + " is not supported by model '" + model.name +
"'; the server will ignore it and serve the standard tier");
}

} // namespace decart::detail
15 changes: 9 additions & 6 deletions src/models.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,22 @@ struct Entry {
int fps;
int width;
int height;
bool canonical; // false for "latest"/deprecated aliases
bool canonical; // false for "latest"/deprecated aliases
bool fastSpeed = false; // accepts `speed=fast` (see ModelDefinition::supportedSpeeds)
};

// Realtime model registry. Kept in sync with the shared model list across the
// Decart SDKs. All realtime models stream over `/v1/stream`.
constexpr std::array<Entry, 7> kRealtime = {{
// Canonical
{"lucy-2.1", 30, 1088, 624, true},
{"lucy-2.5", 30, 1280, 720, true},
{"lucy-vton-3.5", 30, 1280, 720, true},
{"lucy-2.5", 30, 1280, 720, true, /*fastSpeed=*/true},
{"lucy-vton-3.5", 30, 1280, 720, true, /*fastSpeed=*/true},
{"lucy-restyle-2", 30, 1280, 704, true},
// Server-resolved "latest" aliases
{"lucy-latest", 30, 1088, 624, false},
{"lucy-latest", 30, 1088, 624, false, /*fastSpeed=*/true},
// Resolves server-side to lucy-vton-3.5.
{"lucy-vton-latest", 30, 1280, 720, false},
{"lucy-vton-latest", 30, 1280, 720, false, /*fastSpeed=*/true},
{"lucy-restyle-latest", 30, 1280, 704, false},
}};

Expand All @@ -42,7 +43,9 @@ const Entry* find(std::string_view name) {
}

ModelDefinition toDefinition(const Entry& e) {
return ModelDefinition{e.name, kStreamPath, e.fps, e.width, e.height};
ModelDefinition def{e.name, kStreamPath, e.fps, e.width, e.height, /*supportedSpeeds=*/{}};
if (e.fastSpeed) def.supportedSpeeds.push_back(Speed::Fast);
return def;
}

} // namespace
Expand Down
12 changes: 7 additions & 5 deletions src/realtime/session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
#include "detail/livekit_runtime.h"
#include "detail/log.h"
#include "detail/signaling_channel.h"
#include "detail/url.h"
#include "detail/stream_url.h"
#include "detail/user_agent.h"
#include "realtime/messages.h"
#include "realtime/session_internal.h"
Expand Down Expand Up @@ -448,10 +448,12 @@ std::unique_ptr<RealtimeSession> connectRealtime(std::shared_ptr<ClientConfig> c
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);
// Signaling is single-shot: this URL is built once per connect() and never
// rebuilt (no signaling reconnect), so per-session options such as `speed`
// only need to be applied here.
warnIfSpeedUnsupported(options.model, options.speed);
const std::string url = buildStreamUrl(config->realtimeBaseUrl, options.model, config->apiKey,
options.resolution, options.speed);

impl->signaling = std::make_unique<SignalingChannel>(url, buildUserAgent(config->integration));

Expand Down
8 changes: 8 additions & 0 deletions src/realtime/types.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,12 @@ const char* toString(ConnectionState state) noexcept {
return "unknown";
}

const char* toString(Speed speed) noexcept {
switch (speed) {
case Speed::Fast:
return "fast";
}
return "unknown";
}

} // namespace decart
6 changes: 4 additions & 2 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ set(DECART_TEST_SOURCES
test_main.cpp
test_models.cpp
test_base64.cpp
test_enums.cpp)
test_enums.cpp
test_stream_url.cpp
test_connect_options.cpp)

# The signaling message codec only exists in realtime builds.
if(DECART_BUILD_REALTIME)
Expand All @@ -27,7 +29,7 @@ endif()
add_executable(decart_tests ${DECART_TEST_SOURCES})

target_include_directories(decart_tests PRIVATE
${CMAKE_SOURCE_DIR}/src) # internal headers under test (messages, base64)
${CMAKE_SOURCE_DIR}/src) # internal headers under test (messages, base64, stream_url)

target_link_libraries(decart_tests PRIVATE
decart::decart
Expand Down
44 changes: 44 additions & 0 deletions tests/test_connect_options.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2026 Decart. SPDX-License-Identifier: MIT
#include <doctest/doctest.h>

#include <chrono>
#include <optional>
#include <string>

#include "decart/models.h"
#include "decart/realtime/realtime.h"

using namespace decart;

// Compile-time guard: the field order of ConnectOptions up to `connectTimeout`
// is part of the source-compatibility contract. A positional aggregate
// initializer written against the pre-`speed` shape must still compile and
// bind every field to the same member; new options are appended after it.
TEST_CASE("ConnectOptions keeps its pre-speed positional initializer shape") {
const ConnectOptions options{
models::realtime("lucy-2.5"), // model
nullptr, // onRemoteFrame
nullptr, // onConnectionState
nullptr, // onError
nullptr, // onQueuePosition
nullptr, // onGenerationTick
nullptr, // onGenerationEnded
InitialState{}, // initialState
std::string("1080p"), // resolution
true, // startMuted
std::chrono::milliseconds{5000},
};
CHECK(options.model.name == "lucy-2.5");
CHECK(options.resolution == std::optional<std::string>("1080p"));
CHECK(options.startMuted == true);
CHECK(options.connectTimeout == std::chrono::milliseconds{5000});
CHECK_FALSE(options.speed.has_value()); // trailing, defaulted
}

TEST_CASE("ConnectOptions::speed defaults to unset and accepts Speed::Fast") {
ConnectOptions options;
CHECK_FALSE(options.speed.has_value());
options.speed = Speed::Fast;
REQUIRE(options.speed.has_value());
CHECK(std::string(toString(*options.speed)) == "fast");
}
2 changes: 2 additions & 0 deletions tests/test_enums.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ TEST_CASE("ConnectionState names match the other SDKs") {
CHECK(std::string(toString(ConnectionState::Reconnecting)) == "reconnecting");
}

TEST_CASE("Speed wire values match the other SDKs") { CHECK(std::string(toString(Speed::Fast)) == "fast"); }

TEST_CASE("ImageInput factory helpers set the right field") {
auto p = ImageInput::fromPath("/tmp/a.png");
CHECK(p.image.has_value());
Expand Down
34 changes: 34 additions & 0 deletions tests/test_models.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Copyright 2026 Decart. SPDX-License-Identifier: MIT
#include <doctest/doctest.h>

#include <set>
#include <string>
#include <vector>

#include "decart/errors.h"
#include "decart/models.h"

Expand Down Expand Up @@ -48,6 +52,36 @@ TEST_CASE("realtime() throws ModelNotFound for unknown names") {
}
}

TEST_CASE("supportedSpeeds advertises fast mode on exactly the lucy-2.5 and lucy-vton-3.5 families") {
// Compare via integer form: doctest stringifies enums through our ADL
// toString() (which returns const char*) and cannot concatenate that.
const auto fastOnly = std::vector<int>{static_cast<int>(Speed::Fast)};
auto speeds = [](const ModelDefinition& m) {
std::vector<int> out;
for (auto s : m.supportedSpeeds) out.push_back(static_cast<int>(s));
return out;
};

CHECK(speeds(models::realtime("lucy-2.5")) == fastOnly);
CHECK(speeds(models::realtime("lucy-latest")) == fastOnly);
CHECK(speeds(models::realtime("lucy-vton-3.5")) == fastOnly);
CHECK(speeds(models::realtime("lucy-vton-latest")) == fastOnly);

const std::set<std::string> withFast = {"lucy-2.5", "lucy-latest", "lucy-vton-3.5", "lucy-vton-latest"};
for (const auto& m : models::listRealtime(/*canonicalOnly=*/false)) {
INFO("model " << m.name);
if (withFast.count(m.name)) {
CHECK(speeds(m) == fastOnly);
} else {
CHECK(m.supportedSpeeds.empty());
}
}
// Every other realtime model (lucy-2.1, lucy-restyle-2, lucy-restyle-latest) is standard-only.
CHECK(models::realtime("lucy-2.1").supportedSpeeds.empty());
CHECK(models::realtime("lucy-restyle-2").supportedSpeeds.empty());
CHECK(models::realtime("lucy-restyle-latest").supportedSpeeds.empty());
}

TEST_CASE("listRealtime() honors canonicalOnly") {
auto all = models::listRealtime(/*canonicalOnly=*/false);
auto canonical = models::listRealtime(/*canonicalOnly=*/true);
Expand Down
Loading
Loading