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/fluent-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@
"prettier": {
"trailingComma": "all"
}
}
}
10 changes: 10 additions & 0 deletions packages/fluent-ai/src/builder/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export class ImageBuilder<TProvider extends string = string> {
return this;
}

edit(images: string[]) {
this.input.edit = images;
return this;
}

upload(options: ImageJob["input"]["upload"]) {
this.input.upload = options;
return this;
}

build() {
return {
type: "image" as const,
Expand Down
27 changes: 21 additions & 6 deletions packages/fluent-ai/src/job/fal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { ImageJob } from "~/src/job/schema";
import { createHTTPJob, downloadImages } from "~/src/job/http";
import {
createHTTPJob,
downloadImages,
uploadLocalImages,
} from "~/src/job/http";

// TODO: switch to fal queue api
const BASE_URL = "https://fal.run";
Expand All @@ -8,17 +12,28 @@ export const runner = {
image: async (input: ImageJob["input"], options?: ImageJob["options"]) => {
const apiKey = options?.apiKey || process.env.FAL_API_KEY;

let editImages = input.edit;
if (editImages && input.upload) {
editImages = await uploadLocalImages(editImages, input.upload);
}

const body: any = {
prompt: input.prompt,
image_size: input.size,
num_images: input.n,
};

if (editImages && editImages.length > 0) {
body.image_urls = editImages;
}

const request = new Request(`${BASE_URL}/${input.model}`, {
method: "POST",
headers: {
Authorization: `Key ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: input.prompt,
image_size: input.size,
num_images: input.n,
}),
body: JSON.stringify(body),
});

return createHTTPJob(request, async (response: Response) => {
Expand Down
60 changes: 55 additions & 5 deletions packages/fluent-ai/src/job/http.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,54 @@
import * as fs from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { ImageJob } from "./schema";
import type { ImageJob } from "~/src/job/schema";

const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
};

function isRemoteUrl(url: string) {
return url.startsWith("http://") || url.startsWith("https://");
}

async function convertToBase64(filePath: string) {
try {
const fileBuffer = await fs.readFile(filePath);
const base64Data = fileBuffer.toString("base64");

const ext = path.extname(filePath).toLowerCase();
const mimeType = MIME_TYPES[ext] || "image/png";

return `data:${mimeType};base64,${base64Data}`;
} catch (error) {
console.error(`Failed to read local file ${filePath}:`, error);
throw new Error(`Failed to upload local image: ${filePath}`);
}
}

export async function uploadLocalImages(
images: string[],
uploadOption: ImageJob["input"]["upload"],
) {
if (!uploadOption) {
return images;
}

return Promise.all(
images.map(async (img) => {
if (isRemoteUrl(img)) return img;

if (uploadOption === "base64") {
return await convertToBase64(img);
}

return img;
}),
);
}

export async function createHTTPJob<T>(
request: RequestInfo | URL,
Expand All @@ -24,8 +72,10 @@ export async function downloadImages(
): Promise<Array<{ url: string; downloadPath?: string; [key: string]: any }>> {
const localDir = options!.local;

if (!fs.existsSync(localDir)) {
fs.mkdirSync(localDir, { recursive: true });
try {
await fs.access(localDir);
} catch {
await fs.mkdir(localDir, { recursive: true });
}

const downloadedImages = await Promise.all(
Expand All @@ -47,7 +97,7 @@ export async function downloadImages(
const filePath = path.join(localDir, filename);

const buffer = await response.arrayBuffer();
fs.writeFileSync(filePath, Buffer.from(buffer));
await fs.writeFile(filePath, Buffer.from(buffer));

return {
...img,
Expand Down
3 changes: 3 additions & 0 deletions packages/fluent-ai/src/job/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const embeddingOutputSchema = z.object({
});

const downloadOptionsSchema = z.union([z.object({ local: z.string() })]);
const uploadOptionsSchema = z.union([z.literal("base64")]);

const imageSizeSchema = z.object({
width: z.number(),
Expand All @@ -69,6 +70,8 @@ const imageInputSchema = z.object({
outputFormat: z.string().optional(),
guidanceScale: z.number().optional(),
download: downloadOptionsSchema.optional(),
edit: z.array(z.string()).optional(),
upload: uploadOptionsSchema.optional(),
});

const imageOutputSchema = z.object({
Expand Down
37 changes: 37 additions & 0 deletions packages/fluent-ai/test/job.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type Job,
user,
voyage,
type ImageJob,
} from "~/src/index";
import { Runner } from "~/src/job/runner";

Expand Down Expand Up @@ -55,6 +56,42 @@ test("image job", () => {
);
});

test("image edit job", () => {
const job: ImageJob = {
provider: "fal",
type: "image",
input: {
model: "fal-ai/bytedance/seedream/v4/edit",
prompt:
"Dress the model in the clothes and hat. Add a cat to the scene and change the background to a Victorian era building.",
edit: [
"./input1.png",
"https://storage.googleapis.com/falserverless/example_inputs/seedream4_edit_input_2.png",
],
size: {
width: 3840,
height: 2160,
},
upload: "base64",
},
};

expect(job).toEqual(
fal()
.image("fal-ai/bytedance/seedream/v4/edit")
.prompt(
"Dress the model in the clothes and hat. Add a cat to the scene and change the background to a Victorian era building.",
)
.edit([
"./input1.png",
"https://storage.googleapis.com/falserverless/example_inputs/seedream4_edit_input_2.png",
])
.size({ width: 3840, height: 2160 })
.upload("base64")
.build(),
);
});

test("embedding job", () => {
const job: Job = {
provider: "voyage",
Expand Down
Loading