Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/sdk/src/process/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const createProcessClient = (opts: ProcessClientOptions): ProcessClient =
const processedInputs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(parsedInputs.data as Record<string, unknown>)) {
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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/queue/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const createQueueClient = (opts: QueueClientOptions): QueueClient => {
const processedInputs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(parsedInputs.data as Record<string, unknown>)) {
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;
}
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/shared/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ export type ModelDefinition<T extends Model = Model> = {
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;
};

Expand All @@ -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(),
});

Expand Down Expand Up @@ -364,6 +370,7 @@ const _models = {
fps: 22,
width: 1280,
height: 704,
maxFileSize: 100 * 1024 * 1024,
inputSchema: modelInputSchemas["lucy-restyle-v2v"],
},
},
Expand Down
41 changes: 28 additions & 13 deletions packages/sdk/src/shared/request.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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<Blob | ReactNativeFile> {
// React Native file object - pass through as-is
export async function fileInputToBlob(
input: FileInput,
fieldName?: string,
maxFileSize?: number,
): Promise<Blob | ReactNativeFile> {
// 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://")) {
Expand All @@ -45,10 +52,18 @@ export async function fileInputToBlob(input: FileInput): Promise<Blob | ReactNat
if (!response.ok) {
throw createInvalidInputError(`Failed to fetch file from URL: ${response.statusText}`);
}
return response.blob();
blob = await response.blob();
} else {
throw createInvalidInputError("Invalid file input type");
}

// Validate file size
const limit = maxFileSize ?? MAX_FILE_SIZE;
if (blob.size > limit) {
throw createFileTooLargeError(blob.size, limit, fieldName);
}

throw createInvalidInputError("Invalid file input type");
return blob;
}

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/sdk/src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 },
);
}
85 changes: 85 additions & 0 deletions packages/sdk/tests/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading