diff --git a/packages/sdk/src/process/client.ts b/packages/sdk/src/process/client.ts index 09877f5..7bafc26 100644 --- a/packages/sdk/src/process/client.ts +++ b/packages/sdk/src/process/client.ts @@ -30,7 +30,7 @@ export const createProcessClient = (opts: ProcessClientOptions): ProcessClient = const processedInputs: Record = {}; for (const [key, value] of Object.entries(parsedInputs.data as Record)) { if (key === "data" || key === "start" || key === "end") { - processedInputs[key] = await fileInputToBlob(value as FileInput); + processedInputs[key] = await fileInputToBlob(value as FileInput, key, model.maxFileSize); } else { processedInputs[key] = value; } diff --git a/packages/sdk/src/queue/client.ts b/packages/sdk/src/queue/client.ts index aeb92ed..deff3cd 100644 --- a/packages/sdk/src/queue/client.ts +++ b/packages/sdk/src/queue/client.ts @@ -101,7 +101,7 @@ export const createQueueClient = (opts: QueueClientOptions): QueueClient => { const processedInputs: Record = {}; for (const [key, value] of Object.entries(parsedInputs.data as Record)) { if (key === "data" || key === "start" || key === "end" || key === "reference_image") { - processedInputs[key] = await fileInputToBlob(value as FileInput); + processedInputs[key] = await fileInputToBlob(value as FileInput, key, model.maxFileSize); } else { processedInputs[key] = value; } diff --git a/packages/sdk/src/shared/model.ts b/packages/sdk/src/shared/model.ts index b3dcef0..79b736f 100644 --- a/packages/sdk/src/shared/model.ts +++ b/packages/sdk/src/shared/model.ts @@ -205,6 +205,11 @@ export type ModelDefinition = { fps: number; width: number; height: number; + /** + * Optional per-model file size limit in bytes. + * Falls back to the global MAX_FILE_SIZE (20MB) if not set. + */ + maxFileSize?: number; inputSchema: T extends keyof ModelInputSchemas ? ModelInputSchemas[T] : z.ZodTypeAny; }; @@ -227,6 +232,7 @@ export const modelDefinitionSchema = z.object({ fps: z.number().min(1), width: z.number().min(1), height: z.number().min(1), + maxFileSize: z.number().min(1).optional(), inputSchema: z.any(), }); @@ -364,6 +370,7 @@ const _models = { fps: 22, width: 1280, height: 704, + maxFileSize: 100 * 1024 * 1024, inputSchema: modelInputSchemas["lucy-restyle-v2v"], }, }, diff --git a/packages/sdk/src/shared/request.ts b/packages/sdk/src/shared/request.ts index 6b25ca0..e7d373b 100644 --- a/packages/sdk/src/shared/request.ts +++ b/packages/sdk/src/shared/request.ts @@ -1,7 +1,12 @@ import type { FileInput, ReactNativeFile } from "../process/types"; -import { createInvalidInputError } from "../utils/errors"; +import { createFileTooLargeError, createInvalidInputError } from "../utils/errors"; import { buildUserAgent } from "../utils/user-agent"; +/** + * Maximum file size allowed for uploads (20MB). + */ +export const MAX_FILE_SIZE = 20 * 1024 * 1024; + /** * Type guard to check if a value is a React Native file object. */ @@ -19,22 +24,24 @@ function isReactNativeFile(value: unknown): value is ReactNativeFile { * Convert various file input types to a Blob or React Native file object. * React Native file objects are passed through as-is for proper FormData handling. */ -export async function fileInputToBlob(input: FileInput): Promise { - // React Native file object - pass through as-is +export async function fileInputToBlob( + input: FileInput, + fieldName?: string, + maxFileSize?: number, +): Promise { + // React Native file object - pass through as-is (cannot check size) if (isReactNativeFile(input)) { return input; } - if (input instanceof Blob || input instanceof File) { - return input; - } + let blob: Blob; - if (input instanceof ReadableStream) { + if (input instanceof Blob || input instanceof File) { + blob = input; + } else if (input instanceof ReadableStream) { const response = new Response(input); - return response.blob(); - } - - if (typeof input === "string" || input instanceof URL) { + blob = await response.blob(); + } else if (typeof input === "string" || input instanceof URL) { const url = typeof input === "string" ? input : input.toString(); if (!url.startsWith("http://") && !url.startsWith("https://")) { @@ -45,10 +52,18 @@ export async function fileInputToBlob(input: FileInput): Promise limit) { + throw createFileTooLargeError(blob.size, limit, fieldName); } - throw createInvalidInputError("Invalid file input type"); + return blob; } /** diff --git a/packages/sdk/src/utils/errors.ts b/packages/sdk/src/utils/errors.ts index 3171657..f507154 100644 --- a/packages/sdk/src/utils/errors.ts +++ b/packages/sdk/src/utils/errors.ts @@ -18,6 +18,7 @@ export const ERROR_CODES = { QUEUE_RESULT_ERROR: "QUEUE_RESULT_ERROR", JOB_NOT_COMPLETED: "JOB_NOT_COMPLETED", TOKEN_CREATE_ERROR: "TOKEN_CREATE_ERROR", + FILE_TOO_LARGE: "FILE_TOO_LARGE", } as const; export function createSDKError( @@ -73,3 +74,14 @@ export function createJobNotCompletedError(jobId: string, currentStatus: string) { jobId, currentStatus }, ); } + +export function createFileTooLargeError(fileSize: number, maxSize: number, fieldName?: string): DecartSDKError { + const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(1); + const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(0); + const field = fieldName ? ` for field '${fieldName}'` : ""; + return createSDKError( + ERROR_CODES.FILE_TOO_LARGE, + `File size${field} (${fileSizeMB}MB) exceeds the maximum allowed size of ${maxSizeMB}MB. Please reduce the file size or resolution before uploading.`, + { fileSize, maxSize, fieldName }, + ); +} diff --git a/packages/sdk/tests/unit.test.ts b/packages/sdk/tests/unit.test.ts index bcf4623..a864e4b 100644 --- a/packages/sdk/tests/unit.test.ts +++ b/packages/sdk/tests/unit.test.ts @@ -233,6 +233,48 @@ describe("Decart SDK", () => { }); }); + describe("File Size Validation", () => { + it("rejects file exceeding 20MB limit for process API", async () => { + const largeBlob = new Blob([new Uint8Array(21 * 1024 * 1024)], { type: "image/png" }); + + await expect( + decart.process({ + model: models.image("lucy-pro-i2i"), + prompt: "test", + data: largeBlob, + }), + ).rejects.toThrow("exceeds the maximum allowed size of 20MB"); + }); + + it("accepts file at exactly 20MB limit", async () => { + server.use(createMockHandler("/v1/generate/lucy-pro-i2i")); + + const exactBlob = new Blob([new Uint8Array(20 * 1024 * 1024)], { type: "image/png" }); + + const result = await decart.process({ + model: models.image("lucy-pro-i2i"), + prompt: "test", + data: exactBlob, + }); + + expect(result).toBeInstanceOf(Blob); + }); + + it("accepts file under 20MB limit", async () => { + server.use(createMockHandler("/v1/generate/lucy-pro-i2i")); + + const smallBlob = new Blob([new Uint8Array(1024)], { type: "image/png" }); + + const result = await decart.process({ + model: models.image("lucy-pro-i2i"), + prompt: "test", + data: smallBlob, + }); + + expect(result).toBeInstanceOf(Blob); + }); + }); + describe("Error Handling", () => { it("handles API errors", async () => { server.use( @@ -618,6 +660,49 @@ describe("Queue API", () => { }), ).rejects.toThrow("Failed to submit job"); }); + + it("rejects file exceeding 20MB limit for queue API", async () => { + const largeBlob = new Blob([new Uint8Array(21 * 1024 * 1024)], { type: "video/mp4" }); + + await expect( + decart.queue.submit({ + model: models.video("lucy-pro-v2v"), + prompt: "test", + data: largeBlob, + }), + ).rejects.toThrow("exceeds the maximum allowed size of 20MB"); + }); + + it("accepts file over 20MB for lucy-restyle-v2v (100MB limit)", async () => { + server.use( + http.post("http://localhost/v1/jobs/lucy-restyle-v2v", async ({ request }) => { + lastFormData = await request.formData(); + return HttpResponse.json({ job_id: "job_restyle_large", status: "pending" }); + }), + ); + + const blob50MB = new Blob([new Uint8Array(50 * 1024 * 1024)], { type: "video/mp4" }); + + const result = await decart.queue.submit({ + model: models.video("lucy-restyle-v2v"), + prompt: "Restyle this", + data: blob50MB, + }); + + expect(result.job_id).toBe("job_restyle_large"); + }); + + it("rejects file exceeding 100MB for lucy-restyle-v2v", async () => { + const blob101MB = new Blob([new Uint8Array(101 * 1024 * 1024)], { type: "video/mp4" }); + + await expect( + decart.queue.submit({ + model: models.video("lucy-restyle-v2v"), + prompt: "Restyle this", + data: blob101MB, + }), + ).rejects.toThrow("exceeds the maximum allowed size of 100MB"); + }); }); describe("status", () => {