diff --git a/packages/sdk/index.html b/packages/sdk/index.html
index c9d5c3a2..02dcd652 100644
--- a/packages/sdk/index.html
+++ b/packages/sdk/index.html
@@ -450,6 +450,50 @@
Image Reference (Style Transfer)
+
+
+
Files API (Upload & Reuse Refs)
+
+ Select a file to upload:
+
+
+
+ TTL:
+
+ Default (server-side, 24h)
+ Persistent (never expires)
+ Custom (seconds)
+
+
+
+ TTL seconds (60 – 2,592,000):
+
+
+
+ Upload File
+
+
+ Status: Ready
+
+
+ File Ref ID (auto-filled after upload, or paste an existing id):
+
+
+
+
id: -
+
filename: -
+
mime_type: -
+
size_bytes: -
+
created_at: -
+
expires_at: -
+
+
+ Get
+ Delete
+ Use as Realtime Image Ref
+
+
+
Video File Processing
@@ -562,7 +606,26 @@ Console Logs
processStatusText: document.getElementById('process-status-text'),
processedContainer: document.getElementById('processed-container'),
originalVideo: document.getElementById('original-video'),
- processedVideo: document.getElementById('processed-video')
+ processedVideo: document.getElementById('processed-video'),
+ // Files API elements
+ uploadFile: document.getElementById('upload-file'),
+ ttlMode: document.getElementById('ttl-mode'),
+ ttlCustomGroup: document.getElementById('ttl-custom-group'),
+ ttlCustom: document.getElementById('ttl-custom'),
+ uploadFileBtn: document.getElementById('upload-file-btn'),
+ uploadStatus: document.getElementById('upload-status'),
+ uploadStatusText: document.getElementById('upload-status-text'),
+ fileRefId: document.getElementById('file-ref-id'),
+ fileRefInfo: document.getElementById('file-ref-info'),
+ refIdText: document.getElementById('ref-id-text'),
+ refFilenameText: document.getElementById('ref-filename-text'),
+ refMimeText: document.getElementById('ref-mime-text'),
+ refSizeText: document.getElementById('ref-size-text'),
+ refCreatedText: document.getElementById('ref-created-text'),
+ refExpiresText: document.getElementById('ref-expires-text'),
+ getFileBtn: document.getElementById('get-file-btn'),
+ deleteFileBtn: document.getElementById('delete-file-btn'),
+ useRefAsImageBtn: document.getElementById('use-ref-as-image-btn'),
};
// Pre-populate API key from environment variable if available
@@ -869,6 +932,7 @@ Console Logs
addLog('Successfully connected to Decart!', 'success');
addLog(`Session ID: ${currentSessionId || '(pending)'}`, 'success');
+ updateUseRefAsImageBtnState();
} else {
throw new Error('Connection established but not in connected state');
}
@@ -1022,6 +1086,7 @@ Console Logs
elements.referenceImage.value = '';
elements.imageStatus.style.display = 'none';
elements.imagePreview.style.display = 'none';
+ updateUseRefAsImageBtnState();
addLog('Disconnected successfully', 'info');
});
@@ -1287,6 +1352,161 @@ Console Logs
}
});
+ // ---------- Files API (Upload & Reuse Refs) ----------
+
+ function ensureDecartClient() {
+ if (decartClient) return decartClient;
+ const apiKey = elements.apiKey.value.trim();
+ if (!apiKey) {
+ addLog('Please enter an API key', 'error');
+ return null;
+ }
+ const realtimeBaseUrl = elements.realtimeBaseUrl.value.trim();
+ decartClient = createDecartClient({
+ apiKey,
+ ...(realtimeBaseUrl && { realtimeBaseUrl }),
+ logger: createDemoLogger("debug"),
+ });
+ addLog('Initialized Decart client (for files API)', 'info');
+ return decartClient;
+ }
+
+ function updateUseRefAsImageBtnState() {
+ const hasRef = elements.fileRefId.value.trim().length > 0;
+ const canSetImage = isConnected && activeConnectionMode === 'publisher';
+ elements.useRefAsImageBtn.disabled = !hasRef || !canSetImage;
+ }
+
+ function getTtlOption() {
+ const mode = elements.ttlMode.value;
+ if (mode === 'default') return undefined;
+ if (mode === 'persistent') return 'persistent';
+ const n = Number.parseInt(elements.ttlCustom.value, 10);
+ return Number.isFinite(n) && n > 0 ? n : undefined;
+ }
+
+ function setRefInfo(ref) {
+ elements.fileRefId.value = ref.id;
+ elements.refIdText.textContent = ref.id;
+ elements.refFilenameText.textContent = ref.filename ?? '(null)';
+ elements.refMimeText.textContent = ref.mime_type;
+ elements.refSizeText.textContent = `${ref.size_bytes} bytes`;
+ elements.refCreatedText.textContent = ref.created_at;
+ elements.refExpiresText.textContent = ref.expires_at ?? 'persistent (never expires)';
+ elements.fileRefInfo.style.display = 'block';
+ updateUseRefAsImageBtnState();
+ }
+
+ elements.ttlMode.addEventListener('change', () => {
+ elements.ttlCustomGroup.style.display = elements.ttlMode.value === 'custom' ? 'block' : 'none';
+ });
+
+ elements.uploadFile.addEventListener('change', (e) => {
+ elements.uploadFileBtn.disabled = !e.target.files[0];
+ });
+
+ elements.fileRefId.addEventListener('input', updateUseRefAsImageBtnState);
+
+ elements.uploadFileBtn.addEventListener('click', async () => {
+ const file = elements.uploadFile.files[0];
+ if (!file) {
+ addLog('Please select a file first', 'error');
+ return;
+ }
+ const client = ensureDecartClient();
+ if (!client) return;
+
+ const ttlSeconds = getTtlOption();
+
+ try {
+ elements.uploadStatus.style.display = 'block';
+ elements.uploadStatusText.textContent = '⏳ Uploading...';
+ elements.uploadStatusText.style.color = '#ff9800';
+ elements.uploadFileBtn.disabled = true;
+
+ addLog(`Uploading ${file.name} (${file.size} bytes), ttlSeconds=${ttlSeconds ?? 'default'}`, 'info');
+ const ref = await client.files.upload(
+ file,
+ ttlSeconds !== undefined ? { ttlSeconds } : undefined,
+ );
+
+ setRefInfo(ref);
+ elements.uploadStatusText.textContent = `✅ Uploaded: ${ref.id}`;
+ elements.uploadStatusText.style.color = '#4CAF50';
+ addLog(`File uploaded: ${ref.id} (expires ${ref.expires_at ?? 'never'})`, 'success');
+ } catch (error) {
+ elements.uploadStatusText.textContent = `❌ Failed: ${error.message ?? error}`;
+ elements.uploadStatusText.style.color = '#f44336';
+ addLog(`Upload failed: ${error.message ?? error}`, 'error');
+ } finally {
+ elements.uploadFileBtn.disabled = false;
+ }
+ });
+
+ elements.getFileBtn.addEventListener('click', async () => {
+ const id = elements.fileRefId.value.trim();
+ if (!id) {
+ addLog('Please enter or upload a file ref id first', 'error');
+ return;
+ }
+ const client = ensureDecartClient();
+ if (!client) return;
+
+ try {
+ addLog(`Fetching file ${id}...`, 'info');
+ const ref = await client.files.get(id);
+ setRefInfo(ref);
+ addLog(`Got file ${id}`, 'success');
+ } catch (error) {
+ addLog(`Get failed: ${error.message ?? error}`, 'error');
+ }
+ });
+
+ elements.deleteFileBtn.addEventListener('click', async () => {
+ const id = elements.fileRefId.value.trim();
+ if (!id) {
+ addLog('Please enter or upload a file ref id first', 'error');
+ return;
+ }
+ const client = ensureDecartClient();
+ if (!client) return;
+
+ if (!confirm(`Delete file ${id}? This cannot be undone.`)) return;
+
+ try {
+ addLog(`Deleting file ${id}...`, 'info');
+ await client.files.delete(id);
+ addLog(`Deleted file ${id}`, 'success');
+ elements.fileRefInfo.style.display = 'none';
+ elements.fileRefId.value = '';
+ updateUseRefAsImageBtnState();
+ } catch (error) {
+ addLog(`Delete failed: ${error.message ?? error}`, 'error');
+ }
+ });
+
+ elements.useRefAsImageBtn.addEventListener('click', async () => {
+ const id = elements.fileRefId.value.trim();
+ if (!id) {
+ addLog('No file ref id to send', 'error');
+ return;
+ }
+ if (!decartRealtime || !isConnected || activeConnectionMode !== 'publisher') {
+ addLog('Not connected to Decart as a publisher', 'error');
+ return;
+ }
+ try {
+ elements.useRefAsImageBtn.disabled = true;
+ addLog(`Setting realtime image to ref ${id}...`, 'info');
+ await decartRealtime.setImage(id);
+ addLog(`Realtime image set to ref ${id}`, 'success');
+ } catch (error) {
+ addLog(`Failed to set image ref: ${error.message ?? error}`, 'error');
+ } finally {
+ updateUseRefAsImageBtnState();
+ }
+ });
+
// Initialize
addLog('Decart SDK Test Page loaded', 'info');
addLog('Click "Start Camera" for real-time or select a video file for processing', 'info');
diff --git a/packages/sdk/src/files/client.ts b/packages/sdk/src/files/client.ts
new file mode 100644
index 00000000..6070e56c
--- /dev/null
+++ b/packages/sdk/src/files/client.ts
@@ -0,0 +1,108 @@
+import { z } from "zod";
+import { buildAuthHeaders } from "../shared/request";
+import { createInvalidInputError, createSDKError } from "../utils/errors";
+import type { FileReference, FileUploadInput } from "./types";
+
+const MAX_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
+
+const ttlSecondsSchema = z.union([z.number().int().min(60).max(MAX_TTL_SECONDS), z.literal("persistent")]);
+
+export type FilesClientOptions = {
+ baseUrl: string;
+ apiKey: string;
+ integration?: string;
+};
+
+export type UploadFileOptions = {
+ signal?: AbortSignal;
+ /**
+ * Expiration:
+ * - omit → platform default (24 h)
+ * - a positive integer → TTL in seconds (60 .. 2_592_000)
+ * - `"persistent"` → never expires
+ */
+ ttlSeconds?: number | "persistent";
+};
+
+export type FilesClient = {
+ /**
+ * Upload a file once and get a reusable reference. Pass `ref.id` to
+ * realtime `set({ image })` to reuse the same asset across generations
+ * without re-uploading.
+ *
+ * @example
+ * ```ts
+ * const ref = await client.files.upload(blob);
+ * await rt.set({ image: ref.id, prompt: "make it cinematic" });
+ * await rt.set({ image: ref.id, prompt: "now in noir" }); // reused, no re-upload
+ * ```
+ */
+ upload: (file: FileUploadInput, options?: UploadFileOptions) => Promise;
+ get: (fileId: string) => Promise;
+ delete: (fileId: string) => Promise;
+};
+
+export const createFilesClient = (opts: FilesClientOptions): FilesClient => {
+ const { baseUrl, apiKey, integration } = opts;
+
+ const upload = async (file: FileUploadInput, options?: UploadFileOptions): Promise => {
+ if (options?.ttlSeconds !== undefined) {
+ const parsed = ttlSecondsSchema.safeParse(options.ttlSeconds);
+ if (!parsed.success) {
+ throw createInvalidInputError(
+ `ttlSeconds must be an integer in [60, ${MAX_TTL_SECONDS}] or the literal "persistent"`,
+ );
+ }
+ }
+
+ const formData = new FormData();
+ formData.append("file", file as Blob);
+ if (options?.ttlSeconds !== undefined) formData.append("ttl_seconds", String(options.ttlSeconds));
+
+ const response = await fetch(`${baseUrl}/v1/files`, {
+ method: "POST",
+ headers: buildAuthHeaders({ apiKey, integration }),
+ body: formData,
+ signal: options?.signal,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "Unknown error");
+ throw createSDKError("FILES_UPLOAD_ERROR", `Failed to upload file: ${response.status} - ${errorText}`, {
+ status: response.status,
+ });
+ }
+ return response.json();
+ };
+
+ const get = async (fileId: string): Promise => {
+ const response = await fetch(`${baseUrl}/v1/files/${encodeURIComponent(fileId)}`, {
+ method: "GET",
+ headers: buildAuthHeaders({ apiKey, integration }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "Unknown error");
+ throw createSDKError("FILES_GET_ERROR", `Failed to get file: ${response.status} - ${errorText}`, {
+ status: response.status,
+ });
+ }
+ return response.json();
+ };
+
+ const deleteFile = async (fileId: string): Promise => {
+ const response = await fetch(`${baseUrl}/v1/files/${encodeURIComponent(fileId)}`, {
+ method: "DELETE",
+ headers: buildAuthHeaders({ apiKey, integration }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "Unknown error");
+ throw createSDKError("FILES_DELETE_ERROR", `Failed to delete file: ${response.status} - ${errorText}`, {
+ status: response.status,
+ });
+ }
+ };
+
+ return { upload, get, delete: deleteFile };
+};
diff --git a/packages/sdk/src/files/types.ts b/packages/sdk/src/files/types.ts
new file mode 100644
index 00000000..c3f7d210
--- /dev/null
+++ b/packages/sdk/src/files/types.ts
@@ -0,0 +1,26 @@
+import type { ReactNativeFile } from "../process/types";
+
+/** Prefix on every uploaded-file id; disambiguates a ref string from base64. */
+export const FILE_REF_PREFIX = "file_";
+
+/** True if `value` is a `"file_..."` reference id from `client.files.upload(...)`. */
+export const isFileRefId = (value: unknown): value is string =>
+ typeof value === "string" && value.startsWith(FILE_REF_PREFIX);
+
+/**
+ * Metadata for a previously-uploaded file. Returned by `client.files.upload(...)`.
+ * Pass `ref.id` to `realtime.set({ image })` / `setImage(...)` to reuse it.
+ *
+ * Files expire after a server-configured TTL (default 24 h). `expires_at` is
+ * `null` when the upload was created with `persistent: true`.
+ */
+export interface FileReference {
+ id: string;
+ filename: string | null;
+ mime_type: string;
+ size_bytes: number;
+ created_at: string;
+ expires_at: string | null;
+}
+
+export type FileUploadInput = File | Blob | ReactNativeFile;
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index b2444264..81b70535 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { createFilesClient } from "./files/client";
import { createProcessClient } from "./process/client";
import { createQueueClient } from "./queue/client";
import { createRealTimeClient } from "./realtime/client";
@@ -8,6 +9,8 @@ import { readEnv } from "./utils/env";
import { createInvalidApiKeyError, createInvalidBaseUrlError } from "./utils/errors";
import { createConsoleLogger, type Logger } from "./utils/logger";
+export type { FilesClient, UploadFileOptions } from "./files/client";
+export type { FileReference, FileUploadInput } from "./files/types";
export type { ProcessClient } from "./process/client";
export type { FileInput, ProcessOptions, ReactNativeFile } from "./process/types";
export type { QueueClient } from "./queue/client";
@@ -226,6 +229,12 @@ export const createDecartClient = (options: DecartClientOptions = {}) => {
integration,
});
+ const files = createFilesClient({
+ baseUrl,
+ apiKey: apiKey || "",
+ integration,
+ });
+
return {
realtime: {
connect: realtimePublish.connect,
@@ -305,5 +314,16 @@ export const createDecartClient = (options: DecartClientOptions = {}) => {
* ```
*/
tokens,
+ /**
+ * Upload files once and reuse them across generations.
+ *
+ * @example
+ * ```ts
+ * const ref = await client.files.upload(blob);
+ * await rt.set({ image: ref.id, prompt: "make it cinematic" });
+ * await rt.set({ image: ref.id, prompt: "now in noir" });
+ * ```
+ */
+ files,
};
};
diff --git a/packages/sdk/src/realtime/client.ts b/packages/sdk/src/realtime/client.ts
index 73be6b3c..a19396dc 100644
--- a/packages/sdk/src/realtime/client.ts
+++ b/packages/sdk/src/realtime/client.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { isFileRefId } from "../files/types";
import {
type CustomModelDefinition,
type ModelDefinition,
@@ -78,6 +79,12 @@ export type RealTimeClient = {
sessionId: string | null;
subscribeToken: string | null;
getSubscribeToken: () => string | null;
+ /**
+ * Set the reference image for the session.
+ * - `Blob`/`File`/data URL/http(s) URL/base64 string: bytes traverse the wire as base64.
+ * - `"file_..."` id (from `client.files.upload(...).id`): sent as a server-side reference.
+ * - `null`: clear the current image.
+ */
setImage: (image: Blob | File | string | null, options?: ImageSetOptions) => Promise;
};
@@ -117,7 +124,9 @@ export const createRealTimeClient = (opts: RealTimeClientOptions) => {
let observability: RealtimeObservability | undefined;
try {
- const initialImage = initialState?.image ? await imageToBase64(initialState.image) : undefined;
+ const initialImageRef = isFileRefId(initialState?.image) ? initialState.image : undefined;
+ const initialImage =
+ initialImageRef === undefined && initialState?.image ? await imageToBase64(initialState.image) : undefined;
const initialPrompt = initialState?.prompt
? { text: initialState.prompt.text, enhance: initialState.prompt.enhance }
: undefined;
@@ -151,6 +160,7 @@ export const createRealTimeClient = (opts: RealTimeClientOptions) => {
observability,
localStream: inputStream,
initialImage,
+ initialImageRef,
initialPrompt,
logger,
videoCodec: safariCodec,
@@ -210,9 +220,12 @@ export const createRealTimeClient = (opts: RealTimeClientOptions) => {
},
getSubscribeToken: () => subscribeToken,
setImage: async (image: Blob | File | string | null, imgOptions?: ImageSetOptions) => {
- if (image === null) return activeSession.setImage(null, imgOptions);
+ if (isFileRefId(image)) {
+ return activeSession.setImage({ kind: "ref", ref: image }, imgOptions);
+ }
+ if (image === null) return activeSession.setImage({ kind: "data", data: null }, imgOptions);
const base64 = await imageToBase64(image);
- return activeSession.setImage(base64, imgOptions);
+ return activeSession.setImage({ kind: "data", data: base64 }, imgOptions);
},
};
diff --git a/packages/sdk/src/realtime/methods.ts b/packages/sdk/src/realtime/methods.ts
index 8dd29db7..56a418f1 100644
--- a/packages/sdk/src/realtime/methods.ts
+++ b/packages/sdk/src/realtime/methods.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { isFileRefId } from "../files/types";
import { REALTIME_CONFIG } from "./config-realtime";
import type { StreamSession } from "./stream-session";
import type { PromptSendOptions } from "./types";
@@ -7,6 +8,10 @@ const setInputSchema = z
.object({
prompt: z.string().min(1).optional(),
enhance: z.boolean().optional().default(true),
+ /**
+ * - `Blob`/`File`/data:/http(s):/base64 string: bytes traverse the wire as base64.
+ * - `"file_..."` id (from `client.files.upload(...).id`): sent as a server-side reference.
+ */
image: z.union([z.instanceof(Blob), z.instanceof(File), z.string(), z.null()]).optional(),
})
.refine((data) => data.prompt !== undefined || data.image !== undefined, {
@@ -29,9 +34,15 @@ export const realtimeMethods = (
if (!parsed.success) throw parsed.error;
const { prompt, enhance, image } = parsed.data;
- const imageBase64 = image !== undefined && image !== null ? await imageToBase64(image) : null;
+ const options = { prompt, enhance, timeout: REALTIME_CONFIG.methods.updateTimeoutMs };
+
+ if (isFileRefId(image)) {
+ await session.setImage({ kind: "ref", ref: image }, options);
+ return;
+ }
- await session.setImage(imageBase64, { prompt, enhance, timeout: REALTIME_CONFIG.methods.updateTimeoutMs });
+ const imageBase64 = image !== undefined && image !== null ? await imageToBase64(image) : null;
+ await session.setImage({ kind: "data", data: imageBase64 }, options);
};
const setPrompt = async (prompt: string, { enhance }: PromptSendOptions = {}): Promise => {
diff --git a/packages/sdk/src/realtime/signaling-channel.ts b/packages/sdk/src/realtime/signaling-channel.ts
index 82216fe0..6b873e4a 100644
--- a/packages/sdk/src/realtime/signaling-channel.ts
+++ b/packages/sdk/src/realtime/signaling-channel.ts
@@ -17,6 +17,7 @@ import type {
QueuePosition,
ServerError,
SetImageAckMessage,
+ SetImagePayload,
} from "./types";
export type RoomInfo = {
@@ -164,8 +165,11 @@ export class SignalingChannel {
if (!ack.success) throw new Error(ack.error ?? "Failed to send prompt");
}
- async setImage(image: string | null, opts: ImageSetOptions = {}): Promise {
- const message: OutgoingRealtimeMessage = { type: "set_image", image_data: image };
+ async setImage(payload: SetImagePayload, opts: ImageSetOptions = {}): Promise {
+ const message: OutgoingRealtimeMessage =
+ payload.kind === "ref"
+ ? { type: "set_image", image_ref: payload.ref }
+ : { type: "set_image", image_data: payload.data };
if (opts.prompt !== undefined) message.prompt = opts.prompt;
if (opts.enhance !== undefined) message.enhance_prompt = opts.enhance;
@@ -275,11 +279,19 @@ export class SignalingChannel {
private async sendInitialState(initialState?: InitialState): Promise {
if (!initialState) return;
+ if (initialState.imageRef !== undefined) {
+ await this.setImage(
+ { kind: "ref", ref: initialState.imageRef },
+ { prompt: initialState.prompt, enhance: initialState.enhance },
+ );
+ return;
+ }
+
if (initialState.image !== undefined) {
- await this.setImage(initialState.image, {
- prompt: initialState.prompt,
- enhance: initialState.enhance,
- });
+ await this.setImage(
+ { kind: "data", data: initialState.image },
+ { prompt: initialState.prompt, enhance: initialState.enhance },
+ );
return;
}
diff --git a/packages/sdk/src/realtime/stream-session.ts b/packages/sdk/src/realtime/stream-session.ts
index b9249c99..014baee9 100644
--- a/packages/sdk/src/realtime/stream-session.ts
+++ b/packages/sdk/src/realtime/stream-session.ts
@@ -18,6 +18,7 @@ import type {
PromptSendOptions,
QueuePosition,
SessionStarted,
+ SetImagePayload,
} from "./types";
type RetryAttemptError = Error & {
@@ -56,6 +57,7 @@ interface StreamSessionConfig {
observability?: RealtimeObservability;
localStream: MediaStream | null;
initialImage?: string;
+ initialImageRef?: string;
initialPrompt?: InitialPrompt;
logger?: Logger;
videoCodec?: VideoCodec;
@@ -123,9 +125,9 @@ export class StreamSession {
return this.signaling.sendPrompt(text, opts);
}
- async setImage(image: string | null, opts?: ImageSetOptions): Promise {
+ async setImage(payload: SetImagePayload, opts?: ImageSetOptions): Promise {
this.assertConnected();
- return this.signaling.setImage(image, opts);
+ return this.signaling.setImage(payload, opts);
}
disconnect(): void {
@@ -218,6 +220,14 @@ export class StreamSession {
}
private getInitialState(): InitialState | undefined {
+ if (this.config.initialImageRef !== undefined) {
+ return {
+ imageRef: this.config.initialImageRef,
+ prompt: this.config.initialPrompt?.text,
+ enhance: this.config.initialPrompt?.enhance,
+ };
+ }
+
if (this.config.initialImage !== undefined) {
return {
image: this.config.initialImage,
diff --git a/packages/sdk/src/realtime/types.ts b/packages/sdk/src/realtime/types.ts
index d5f7776e..961800a6 100644
--- a/packages/sdk/src/realtime/types.ts
+++ b/packages/sdk/src/realtime/types.ts
@@ -16,13 +16,17 @@ export type ErrorMessage = {
error: string;
};
+/** Wire shape: one of `image_data` or `image_ref` is set, not both. */
export type SetImageMessage = {
type: "set_image";
- image_data: string | null;
+ image_data?: string | null;
+ image_ref?: string;
prompt?: string | null;
enhance_prompt?: boolean;
};
+export type SetImagePayload = { kind: "data"; data: string | null } | { kind: "ref"; ref: string };
+
export type SetImageAckMessage = {
type: "set_image_ack";
success: boolean;
@@ -87,7 +91,10 @@ export type SessionStarted = {
};
export type InitialState = {
+ /** Pre-encoded base64 image; one of image/imageRef. */
image?: string | null;
+ /** Server file reference id; one of image/imageRef. */
+ imageRef?: string;
prompt?: string | null;
enhance?: boolean;
};
diff --git a/packages/sdk/src/shared/types.ts b/packages/sdk/src/shared/types.ts
index 37f7d0a0..2ae8d7f4 100644
--- a/packages/sdk/src/shared/types.ts
+++ b/packages/sdk/src/shared/types.ts
@@ -7,6 +7,12 @@ export const modelStateSchema = z.object({
enhance: z.boolean().optional().default(true),
})
.optional(),
+ /**
+ * Initial image for the session. Pass either bytes (`Blob`/`File`/data URL/
+ * http(s) URL/base64 string) or a `"file_..."` id returned by
+ * `client.files.upload(...)`.id — the SDK detects the prefix and sends a
+ * server-side reference instead of base64.
+ */
image: z.union([z.instanceof(Blob), z.instanceof(File), z.string()]).optional(),
});
export type ModelState = z.infer;
diff --git a/packages/sdk/src/utils/errors.ts b/packages/sdk/src/utils/errors.ts
index a8cecd05..c41c2870 100644
--- a/packages/sdk/src/utils/errors.ts
+++ b/packages/sdk/src/utils/errors.ts
@@ -17,6 +17,9 @@ export const ERROR_CODES = {
QUEUE_RESULT_ERROR: "QUEUE_RESULT_ERROR",
JOB_NOT_COMPLETED: "JOB_NOT_COMPLETED",
TOKEN_CREATE_ERROR: "TOKEN_CREATE_ERROR",
+ FILES_UPLOAD_ERROR: "FILES_UPLOAD_ERROR",
+ FILES_GET_ERROR: "FILES_GET_ERROR",
+ FILES_DELETE_ERROR: "FILES_DELETE_ERROR",
// WebRTC-specific error codes
WEBRTC_WEBSOCKET_ERROR: "WEBRTC_WEBSOCKET_ERROR",
WEBRTC_ICE_ERROR: "WEBRTC_ICE_ERROR",
diff --git a/packages/sdk/tests/e2e-realtime.test.ts b/packages/sdk/tests/e2e-realtime.test.ts
index a93737d0..5aaa074c 100644
--- a/packages/sdk/tests/e2e-realtime.test.ts
+++ b/packages/sdk/tests/e2e-realtime.test.ts
@@ -118,4 +118,85 @@ describe.concurrent("Realtime E2E Tests", { timeout: TIMEOUT, retry: 2 }, () =>
expect(realtimeClient?.getConnectionState()).toBe("disconnected");
});
}
+
+ // POST /v1/files + image_ref via realtime. Uses lucy-2.1 to actually exercise
+ // upstream forwarding through the bouncer's image_ref prologue.
+ describe("Files API + image_ref", () => {
+ async function pngBlob(width: number, height: number, color = "#3070C0"): Promise {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) throw new Error("2D canvas context unavailable");
+ ctx.fillStyle = color;
+ ctx.fillRect(0, 0, width, height);
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), "image/png");
+ });
+ }
+
+ it("upload → get → delete round-trip", async () => {
+ const ref = await client.files.upload(await pngBlob(96, 96));
+ expect(ref.id).toMatch(/^file_/);
+ expect(ref.mime_type).toBe("image/png");
+
+ const fetched = await client.files.get(ref.id);
+ expect(fetched.id).toBe(ref.id);
+
+ await client.files.delete(ref.id);
+ await expect(client.files.get(ref.id)).rejects.toThrow(/Failed to get file/);
+ });
+
+ it("connects with initialState.image = ref.id", async () => {
+ const model = models.realtime("lucy-2.1");
+ const ref = await client.files.upload(await pngBlob(model.width, model.height));
+ const syntheticStream = createSyntheticStream(model.width, model.height);
+
+ let remoteStreamReceived = false;
+ let realtimeClient: Awaited> | undefined;
+ const errors: DecartSDKError[] = [];
+
+ try {
+ realtimeClient = await client.realtime.connect(syntheticStream.stream, {
+ model,
+ initialState: { image: ref.id, prompt: { text: "make it cinematic", enhance: false } },
+ onRemoteStream: () => {
+ remoteStreamReceived = true;
+ },
+ });
+ realtimeClient.on("error", (err) => errors.push(err));
+
+ expect(["connected", "generating"]).toContain(realtimeClient.getConnectionState());
+ await waitUntil(() => remoteStreamReceived, "Timed out waiting for remote stream via image_ref");
+ expect(errors).toEqual([]);
+ } finally {
+ realtimeClient?.disconnect();
+ syntheticStream.stop();
+ await client.files.delete(ref.id).catch(() => {});
+ }
+ });
+
+ it("rt.set({ image: ref.id }) swaps the reference image mid-session", async () => {
+ const model = models.realtime("lucy-2.1");
+ const [refA, refB] = await Promise.all([
+ client.files.upload(await pngBlob(model.width, model.height, "#3070C0")),
+ client.files.upload(await pngBlob(model.width, model.height, "#C03070")),
+ ]);
+ const syntheticStream = createSyntheticStream(model.width, model.height);
+ let realtimeClient: Awaited> | undefined;
+
+ try {
+ realtimeClient = await client.realtime.connect(syntheticStream.stream, {
+ model,
+ initialState: { image: refA.id, prompt: { text: "anime style", enhance: false } },
+ onRemoteStream: () => {},
+ });
+ await realtimeClient.set({ image: refB.id, prompt: "noir" });
+ } finally {
+ realtimeClient?.disconnect();
+ syntheticStream.stop();
+ await Promise.allSettled([client.files.delete(refA.id), client.files.delete(refB.id)]);
+ }
+ });
+ });
});
diff --git a/packages/sdk/tests/realtime.unit.test.ts b/packages/sdk/tests/realtime.unit.test.ts
index deee25c1..270d1f44 100644
--- a/packages/sdk/tests/realtime.unit.test.ts
+++ b/packages/sdk/tests/realtime.unit.test.ts
@@ -207,20 +207,26 @@ describe("set()", () => {
it("sends only prompt when no image provided", async () => {
await methods.set({ prompt: "a cat" });
- expect(mockSession.setImage).toHaveBeenCalledWith(null, {
- prompt: "a cat",
- enhance: true,
- timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
- });
+ expect(mockSession.setImage).toHaveBeenCalledWith(
+ { kind: "data", data: null },
+ {
+ prompt: "a cat",
+ enhance: true,
+ timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
+ },
+ );
});
it("sends prompt with enhance flag", async () => {
await methods.set({ prompt: "a cat", enhance: true });
- expect(mockSession.setImage).toHaveBeenCalledWith(null, {
- prompt: "a cat",
- enhance: true,
- timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
- });
+ expect(mockSession.setImage).toHaveBeenCalledWith(
+ { kind: "data", data: null },
+ {
+ prompt: "a cat",
+ enhance: true,
+ timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
+ },
+ );
});
it("sends only image when no prompt provided", async () => {
@@ -228,22 +234,28 @@ describe("set()", () => {
await methods.set({ image: "rawbase64data" });
expect(mockImageToBase64).toHaveBeenCalledWith("rawbase64data");
- expect(mockSession.setImage).toHaveBeenCalledWith("convertedbase64", {
- prompt: undefined,
- enhance: true,
- timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
- });
+ expect(mockSession.setImage).toHaveBeenCalledWith(
+ { kind: "data", data: "convertedbase64" },
+ {
+ prompt: undefined,
+ enhance: true,
+ timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
+ },
+ );
});
it("sends prompt and image together", async () => {
mockImageToBase64.mockResolvedValue("convertedbase64");
await methods.set({ prompt: "a cat", enhance: false, image: "rawbase64" });
- expect(mockSession.setImage).toHaveBeenCalledWith("convertedbase64", {
- prompt: "a cat",
- enhance: false,
- timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
- });
+ expect(mockSession.setImage).toHaveBeenCalledWith(
+ { kind: "data", data: "convertedbase64" },
+ {
+ prompt: "a cat",
+ enhance: false,
+ timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
+ },
+ );
});
it("converts Blob image to base64", async () => {
@@ -252,11 +264,39 @@ describe("set()", () => {
await methods.set({ image: testBlob });
expect(mockImageToBase64).toHaveBeenCalledWith(testBlob);
- expect(mockSession.setImage).toHaveBeenCalledWith("blobbase64", {
- prompt: undefined,
- enhance: true,
- timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
- });
+ expect(mockSession.setImage).toHaveBeenCalledWith(
+ { kind: "data", data: "blobbase64" },
+ {
+ prompt: undefined,
+ enhance: true,
+ timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
+ },
+ );
+ });
+
+ it("treats a 'file_*' string as a server-side reference id, no base64 encoding", async () => {
+ await methods.set({ image: "file_abc123", prompt: "make it cinematic" });
+
+ expect(mockImageToBase64).not.toHaveBeenCalled();
+ expect(mockSession.setImage).toHaveBeenCalledWith(
+ { kind: "ref", ref: "file_abc123" },
+ {
+ prompt: "make it cinematic",
+ enhance: true,
+ timeout: REALTIME_CONFIG.methods.updateTimeoutMs,
+ },
+ );
+ });
+
+ it("still treats non-'file_' strings as base64/URL inputs (encoded via imageToBase64)", async () => {
+ mockImageToBase64.mockResolvedValue("convertedbase64");
+ await methods.set({ image: "rawbase64data" });
+
+ expect(mockImageToBase64).toHaveBeenCalledWith("rawbase64data");
+ expect(mockSession.setImage).toHaveBeenCalledWith(
+ { kind: "data", data: "convertedbase64" },
+ expect.objectContaining({ timeout: REALTIME_CONFIG.methods.updateTimeoutMs }),
+ );
});
});
diff --git a/packages/sdk/tests/unit.test.ts b/packages/sdk/tests/unit.test.ts
index afa6c8bb..095a6477 100644
--- a/packages/sdk/tests/unit.test.ts
+++ b/packages/sdk/tests/unit.test.ts
@@ -1131,6 +1131,182 @@ describe("Tokens API", () => {
});
});
+describe("Files API", () => {
+ let decart: ReturnType;
+ let lastRequest: Request | null = null;
+ const server = setupServer();
+
+ beforeAll(() => {
+ server.listen({ onUnhandledRequest: "error" });
+ });
+
+ afterAll(() => {
+ server.close();
+ });
+
+ beforeEach(() => {
+ lastRequest = null;
+ decart = createDecartClient({
+ apiKey: "test-api-key",
+ baseUrl: "http://localhost",
+ });
+ });
+
+ afterEach(() => {
+ server.resetHandlers();
+ });
+
+ describe("upload", () => {
+ it("uploads a Blob and returns a FileReference", async () => {
+ server.use(
+ http.post("http://localhost/v1/files", async ({ request }) => {
+ lastRequest = request;
+ return HttpResponse.json({
+ id: "file_abc123",
+ filename: "blob",
+ mime_type: "image/png",
+ size_bytes: 4,
+ created_at: "2026-01-01T00:00:00Z",
+ expires_at: "2026-01-02T00:00:00Z",
+ });
+ }),
+ );
+
+ const blob = new Blob([new Uint8Array([0, 1, 2, 3])], { type: "image/png" });
+ const ref = await decart.files.upload(blob);
+
+ expect(ref.id).toBe("file_abc123");
+ expect(ref.mime_type).toBe("image/png");
+ expect(lastRequest?.headers.get("x-api-key")).toBe("test-api-key");
+ // Content-Type starts with multipart/form-data when uploading a Blob
+ expect(lastRequest?.headers.get("content-type") ?? "").toContain("multipart/form-data");
+ });
+
+ it("throws on non-2xx upload", async () => {
+ server.use(
+ http.post("http://localhost/v1/files", () => {
+ return HttpResponse.json({ detail: "Too big" }, { status: 413 });
+ }),
+ );
+
+ await expect(decart.files.upload(new Blob(["x"]))).rejects.toThrow("Failed to upload file");
+ });
+
+ it("rejects bad ttlSeconds locally without hitting the network", async () => {
+ let hit = false;
+ server.use(
+ http.post("http://localhost/v1/files", () => {
+ hit = true;
+ return HttpResponse.json({}, { status: 200 });
+ }),
+ );
+
+ await expect(
+ // @ts-expect-error – intentionally invalid value
+ decart.files.upload(new Blob(["x"]), { ttlSeconds: "forever" }),
+ ).rejects.toMatchObject({ code: "INVALID_INPUT" });
+
+ await expect(decart.files.upload(new Blob(["x"]), { ttlSeconds: 30 })).rejects.toMatchObject({
+ code: "INVALID_INPUT",
+ });
+
+ expect(hit).toBe(false);
+ });
+
+ it("forwards a numeric ttlSeconds", async () => {
+ let receivedForm: FormData | null = null;
+ server.use(
+ http.post("http://localhost/v1/files", async ({ request }) => {
+ receivedForm = await request.formData();
+ return HttpResponse.json({
+ id: "file_abc",
+ filename: null,
+ mime_type: "image/png",
+ size_bytes: 1,
+ created_at: "2026-01-01T00:00:00Z",
+ expires_at: "2026-01-01T01:00:00Z",
+ });
+ }),
+ );
+ await decart.files.upload(new Blob(["x"]), { ttlSeconds: 3600 });
+ expect(receivedForm?.get("ttl_seconds")).toBe("3600");
+ });
+
+ it('forwards ttlSeconds="persistent" verbatim', async () => {
+ let receivedForm: FormData | null = null;
+ server.use(
+ http.post("http://localhost/v1/files", async ({ request }) => {
+ receivedForm = await request.formData();
+ return HttpResponse.json({
+ id: "file_abc",
+ filename: null,
+ mime_type: "image/png",
+ size_bytes: 1,
+ created_at: "2026-01-01T00:00:00Z",
+ expires_at: null,
+ });
+ }),
+ );
+ const ref = await decart.files.upload(new Blob(["x"]), { ttlSeconds: "persistent" });
+ expect(receivedForm?.get("ttl_seconds")).toBe("persistent");
+ expect(ref.expires_at).toBeNull();
+ });
+ });
+
+ describe("get", () => {
+ it("fetches metadata for a file id", async () => {
+ server.use(
+ http.get("http://localhost/v1/files/file_abc123", () => {
+ return HttpResponse.json({
+ id: "file_abc123",
+ filename: "portrait.png",
+ mime_type: "image/png",
+ size_bytes: 1234,
+ created_at: "2026-01-01T00:00:00Z",
+ expires_at: "2026-01-02T00:00:00Z",
+ });
+ }),
+ );
+
+ const ref = await decart.files.get("file_abc123");
+ expect(ref.id).toBe("file_abc123");
+ expect(ref.filename).toBe("portrait.png");
+ });
+
+ it("throws on 404", async () => {
+ server.use(
+ http.get("http://localhost/v1/files/file_missing", () => {
+ return HttpResponse.json({ detail: "File not found" }, { status: 404 });
+ }),
+ );
+
+ await expect(decart.files.get("file_missing")).rejects.toThrow("Failed to get file");
+ });
+ });
+
+ describe("delete", () => {
+ it("resolves on 204", async () => {
+ server.use(
+ http.delete("http://localhost/v1/files/file_abc123", () => {
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ await expect(decart.files.delete("file_abc123")).resolves.toBeUndefined();
+ });
+
+ it("throws on non-2xx", async () => {
+ server.use(
+ http.delete("http://localhost/v1/files/file_missing", () => {
+ return HttpResponse.json({ detail: "File not found" }, { status: 404 });
+ }),
+ );
+
+ await expect(decart.files.delete("file_missing")).rejects.toThrow("Failed to delete file");
+ });
+ });
+});
+
describe("Logger", () => {
it("noopLogger does nothing", async () => {
const { noopLogger } = await import("../src/utils/logger.js");