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)

+
+ + +
+
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + + +
+
+

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");