Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Generate images via OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream, and Replicate APIs with batch support.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/providers/replicate.ts
1import path from "node:path";2import { readFile } from "node:fs/promises";3import type { CliArgs } from "../types";45const DEFAULT_MODEL = "google/nano-banana-2";6const SYNC_WAIT_SECONDS = 60;7const POLL_INTERVAL_MS = 2000;8const MAX_POLL_MS = 300_000;9const DOCUMENTED_REPLICATE_ASPECT_RATIOS = new Set([10"1:1",11"2:3",12"3:2",13"3:4",14"4:3",15"5:4",16"4:5",17"9:16",18"16:9",19"21:9",20]);2122export type ReplicateModelFamily =23| "nano-banana"24| "seedream45"25| "seedream5lite"26| "wan27image"27| "wan27imagepro"28| "unknown";2930type PixelSize = {31width: number;32height: number;33};3435type Seedream45Size = "2K" | "4K" | { width: number; height: number };3637export function getDefaultModel(): string {38return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;39}4041function getApiToken(): string | null {42return process.env.REPLICATE_API_TOKEN || null;43}4445function getBaseUrl(): string {46const base = process.env.REPLICATE_BASE_URL || "https://api.replicate.com";47return base.replace(/\/+$/g, "");48}4950function normalizeModelId(model: string): string {51return model.trim().toLowerCase().split(":")[0]!;52}5354export function getModelFamily(model: string): ReplicateModelFamily {55const normalized = normalizeModelId(model);5657if (58normalized === "google/nano-banana" ||59normalized === "google/nano-banana-pro" ||60normalized === "google/nano-banana-2"61) {62return "nano-banana";63}6465if (normalized === "bytedance/seedream-4.5") {66return "seedream45";67}6869if (normalized === "bytedance/seedream-5-lite") {70return "seedream5lite";71}7273if (normalized === "wan-video/wan-2.7-image") {74return "wan27image";75}7677if (normalized === "wan-video/wan-2.7-image-pro") {78return "wan27imagepro";79}8081return "unknown";82}8384export function parseModelId(model: string): { owner: string; name: string; version: string | null } {85const [ownerName, version] = model.split(":");86const parts = ownerName!.split("/");87if (parts.length !== 2 || !parts[0] || !parts[1]) {88throw new Error(89`Invalid Replicate model format: "${model}". Expected "owner/name" or "owner/name:version".`90);91}92return { owner: parts[0], name: parts[1], version: version || null };93}9495function parsePixelSize(value: string): PixelSize | null {96const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);97if (!match) return null;9899const width = parseInt(match[1]!, 10);100const height = parseInt(match[2]!, 10);101if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {102return null;103}104105return { width, height };106}107108function parseAspectRatio(value: string): PixelSize | null {109const match = value.trim().match(/^(\d+)\s*:\s*(\d+)$/);110if (!match) return null;111112const width = parseInt(match[1]!, 10);113const height = parseInt(match[2]!, 10);114if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {115return null;116}117118return { width, height };119}120121function gcd(a: number, b: number): number {122let x = Math.abs(a);123let y = Math.abs(b);124125while (y !== 0) {126const next = x % y;127x = y;128y = next;129}130131return x || 1;132}133134function inferAspectRatioFromSize(size: string): string | null {135const parsed = parsePixelSize(size);136if (!parsed) return null;137138const divisor = gcd(parsed.width, parsed.height);139const normalized = `${parsed.width / divisor}:${parsed.height / divisor}`;140if (!DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(normalized)) {141return null;142}143144return normalized;145}146147function getQualityPreset(args: CliArgs): "normal" | "2k" {148return args.quality === "normal" ? "normal" : "2k";149}150151function validateDocumentedAspectRatio(model: string, aspectRatio: string): void {152if (aspectRatio === "match_input_image") {153return;154}155156if (DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(aspectRatio)) {157return;158}159160throw new Error(161`Replicate model ${model} does not support aspect ratio ${aspectRatio}. Supported values: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`162);163}164165function getRequestedAspectRatio(model: string, args: CliArgs): string | null {166if (args.aspectRatio) {167validateDocumentedAspectRatio(model, args.aspectRatio);168return args.aspectRatio;169}170171if (!args.size) return null;172173const inferred = inferAspectRatioFromSize(args.size);174if (!inferred) {175throw new Error(176`Replicate model ${model} cannot derive a supported aspect ratio from --size ${args.size}. Use one of: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`177);178}179180return inferred;181}182183function getNanoBananaResolution(args: CliArgs): "1K" | "2K" {184if (args.size) {185const parsed = parsePixelSize(args.size);186if (!parsed) {187throw new Error("Replicate nano-banana --size must be in WxH format, for example 1536x1024.");188}189190const longestEdge = Math.max(parsed.width, parsed.height);191if (longestEdge <= 1024) return "1K";192if (longestEdge <= 2048) return "2K";193throw new Error("Replicate nano-banana only supports sizes that map to 1K or 2K output.");194}195196return getQualityPreset(args) === "normal" ? "1K" : "2K";197}198199function resolveSeedream45Size(args: CliArgs): Seedream45Size {200if (args.size) {201const upper = args.size.trim().toUpperCase();202if (upper === "2K" || upper === "4K") {203return upper;204}205206const parsed = parsePixelSize(args.size);207if (!parsed) {208throw new Error("Replicate Seedream 4.5 --size must be 2K, 4K, or an explicit WxH size.");209}210if (parsed.width < 1024 || parsed.width > 4096 || parsed.height < 1024 || parsed.height > 4096) {211throw new Error("Replicate Seedream 4.5 custom --size must keep width and height between 1024 and 4096.");212}213return parsed;214}215216return getQualityPreset(args) === "normal" ? "2K" : "4K";217}218219function resolveSeedream5LiteSize(args: CliArgs): "2K" | "3K" {220if (args.size) {221const upper = args.size.trim().toUpperCase();222if (upper === "2K" || upper === "3K") {223return upper;224}225226throw new Error("Replicate Seedream 5 Lite currently supports 2K or 3K output in this tool.");227}228229return getQualityPreset(args) === "normal" ? "2K" : "3K";230}231232function formatCustomWanSize(size: PixelSize): string {233return `${size.width}*${size.height}`;234}235236function resolveWanSizeFromAspectRatio(237aspectRatio: string,238maxDimension: number,239): string {240const parsedRatio = parseAspectRatio(aspectRatio);241if (!parsedRatio) {242throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${aspectRatio}.`);243}244245const scale = Math.min(maxDimension / parsedRatio.width, maxDimension / parsedRatio.height);246const width = Math.max(1, Math.floor(parsedRatio.width * scale));247const height = Math.max(1, Math.floor(parsedRatio.height * scale));248return formatCustomWanSize({ width, height });249}250251function resolveWanSize(family: "wan27image" | "wan27imagepro", args: CliArgs): "1K" | "2K" | "4K" | string {252const referenceMode = args.referenceImages.length > 0;253const maxDimension = family === "wan27imagepro" && !referenceMode ? 4096 : 2048;254255if (args.size) {256const upper = args.size.trim().toUpperCase();257if (upper === "1K" || upper === "2K" || upper === "4K") {258if (upper === "4K" && family !== "wan27imagepro") {259throw new Error("Replicate Wan 2.7 Image only supports 1K, 2K, or custom sizes up to 2048px.");260}261if (upper === "4K" && referenceMode) {262throw new Error("Replicate Wan 2.7 Image Pro only supports 4K text-to-image. Remove --ref or lower the size.");263}264return upper;265}266267const parsed = parsePixelSize(args.size);268if (!parsed) {269throw new Error("Replicate Wan --size must be 1K, 2K, 4K, or an explicit WxH size.");270}271if (parsed.width > maxDimension || parsed.height > maxDimension) {272throw new Error(273`Replicate ${family === "wan27imagepro" ? "Wan 2.7 Image Pro" : "Wan 2.7 Image"} custom --size must keep width and height at or below ${maxDimension}px in the current mode.`274);275}276return formatCustomWanSize(parsed);277}278279if (args.aspectRatio) {280return resolveWanSizeFromAspectRatio(281args.aspectRatio,282getQualityPreset(args) === "normal" ? 1024 : 2048,283);284}285286return getQualityPreset(args) === "normal" ? "1K" : "2K";287}288289function buildNanoBananaInput(290prompt: string,291model: string,292args: CliArgs,293referenceImages: string[],294): Record<string, unknown> {295const input: Record<string, unknown> = {296prompt,297resolution: getNanoBananaResolution(args),298output_format: "png",299};300301const aspectRatio = getRequestedAspectRatio(model, args);302if (aspectRatio) {303input.aspect_ratio = aspectRatio;304} else if (referenceImages.length > 0) {305input.aspect_ratio = "match_input_image";306}307308if (referenceImages.length > 0) {309input.image_input = referenceImages;310}311312return input;313}314315function buildSeedreamInput(316family: "seedream45" | "seedream5lite",317prompt: string,318model: string,319args: CliArgs,320referenceImages: string[],321): Record<string, unknown> {322const size = family === "seedream45" ? resolveSeedream45Size(args) : resolveSeedream5LiteSize(args);323const input: Record<string, unknown> = {324prompt,325};326327if (family === "seedream45" && typeof size === "object") {328input.size = "custom";329input.width = size.width;330input.height = size.height;331} else {332input.size = size;333}334335if (referenceImages.length > 0) {336input.image_input = referenceImages;337}338339if (args.aspectRatio) {340validateDocumentedAspectRatio(model, args.aspectRatio);341input.aspect_ratio = args.aspectRatio;342} else if (referenceImages.length > 0 && family === "seedream45") {343input.aspect_ratio = "match_input_image";344}345346return input;347}348349function buildWanInput(350family: "wan27image" | "wan27imagepro",351prompt: string,352args: CliArgs,353referenceImages: string[],354): Record<string, unknown> {355const input: Record<string, unknown> = {356prompt,357size: resolveWanSize(family, args),358};359360if (referenceImages.length > 0) {361input.images = referenceImages;362}363364return input;365}366367export function validateArgs(model: string, args: CliArgs): void {368parseModelId(model);369370if (args.n !== 1) {371throw new Error("Replicate integration currently supports exactly one output image per request. Remove --n or use --n 1.");372}373374if (args.imageSize && args.imageSizeSource !== "config") {375throw new Error("Replicate models in baoyu-image-gen do not use --imageSize. Use --quality, --ar, or --size instead.");376}377378const family = getModelFamily(model);379380if (family === "nano-banana") {381if (args.referenceImages.length > 14) {382throw new Error("Replicate nano-banana supports at most 14 reference images.");383}384if (args.aspectRatio) {385validateDocumentedAspectRatio(model, args.aspectRatio);386}387if (args.size) {388getRequestedAspectRatio(model, args);389getNanoBananaResolution(args);390}391return;392}393394if (family === "seedream45") {395if (args.referenceImages.length > 14) {396throw new Error("Replicate Seedream 4.5 supports at most 14 reference images.");397}398if (args.aspectRatio) {399validateDocumentedAspectRatio(model, args.aspectRatio);400}401resolveSeedream45Size(args);402return;403}404405if (family === "seedream5lite") {406if (args.referenceImages.length > 14) {407throw new Error("Replicate Seedream 5 Lite supports at most 14 reference images.");408}409if (args.aspectRatio) {410validateDocumentedAspectRatio(model, args.aspectRatio);411}412resolveSeedream5LiteSize(args);413return;414}415416if (family === "wan27image" || family === "wan27imagepro") {417if (args.referenceImages.length > 9) {418throw new Error("Replicate Wan 2.7 image models support at most 9 reference images.");419}420if (args.aspectRatio) {421const parsed = parseAspectRatio(args.aspectRatio);422if (!parsed) {423throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${args.aspectRatio}.`);424}425}426resolveWanSize(family, args);427return;428}429430const hasExplicitAspectRatio = !!args.aspectRatio && args.aspectRatioSource !== "config";431432if (args.referenceImages.length > 0 || hasExplicitAspectRatio || args.size) {433throw new Error(434`Replicate model ${model} is not in the baoyu-image-gen compatibility list. Supported families: google/nano-banana*, bytedance/seedream-4.5, bytedance/seedream-5-lite, wan-video/wan-2.7-image, wan-video/wan-2.7-image-pro.`435);436}437}438439export function getDefaultOutputExtension(model: string): ".png" {440const _family = getModelFamily(model);441return ".png";442}443444export function buildInput(445model: string,446prompt: string,447args: CliArgs,448referenceImages: string[],449): Record<string, unknown> {450const family = getModelFamily(model);451452if (family === "nano-banana") {453return buildNanoBananaInput(prompt, model, args, referenceImages);454}455456if (family === "seedream45" || family === "seedream5lite") {457return buildSeedreamInput(family, prompt, model, args, referenceImages);458}459460if (family === "wan27image" || family === "wan27imagepro") {461return buildWanInput(family, prompt, args, referenceImages);462}463464return { prompt };465}466467async function readImageAsDataUrl(p: string): Promise<string> {468const buf = await readFile(p);469const ext = path.extname(p).toLowerCase();470let mimeType = "image/png";471if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg";472else if (ext === ".gif") mimeType = "image/gif";473else if (ext === ".webp") mimeType = "image/webp";474return `data:${mimeType};base64,${buf.toString("base64")}`;475}476477type PredictionResponse = {478id: string;479status: string;480output: unknown;481error: string | null;482urls?: { get?: string };483};484485async function createPrediction(486apiToken: string,487model: { owner: string; name: string; version: string | null },488input: Record<string, unknown>,489sync: boolean490): Promise<PredictionResponse> {491const baseUrl = getBaseUrl();492493let url: string;494const body: Record<string, unknown> = { input };495496if (model.version) {497url = `${baseUrl}/v1/predictions`;498body.version = model.version;499} else {500url = `${baseUrl}/v1/models/${model.owner}/${model.name}/predictions`;501}502503const headers: Record<string, string> = {504Authorization: `Bearer ${apiToken}`,505"Content-Type": "application/json",506};507508if (sync) {509headers["Prefer"] = `wait=${SYNC_WAIT_SECONDS}`;510}511512const res = await fetch(url, {513method: "POST",514headers,515body: JSON.stringify(body),516});517518if (!res.ok) {519const err = await res.text();520throw new Error(`Replicate API error (${res.status}): ${err}`);521}522523return (await res.json()) as PredictionResponse;524}525526async function pollPrediction(apiToken: string, getUrl: string): Promise<PredictionResponse> {527const start = Date.now();528529while (Date.now() - start < MAX_POLL_MS) {530const res = await fetch(getUrl, {531headers: { Authorization: `Bearer ${apiToken}` },532});533534if (!res.ok) {535const err = await res.text();536throw new Error(`Replicate poll error (${res.status}): ${err}`);537}538539const prediction = (await res.json()) as PredictionResponse;540541if (prediction.status === "succeeded") return prediction;542if (prediction.status === "failed" || prediction.status === "canceled") {543throw new Error(`Replicate prediction ${prediction.status}: ${prediction.error || "unknown error"}`);544}545546await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));547}548549throw new Error(`Replicate prediction timed out after ${MAX_POLL_MS / 1000}s`);550}551552export function extractOutputUrl(prediction: PredictionResponse): string {553const output = prediction.output;554555if (typeof output === "string") return output;556557if (Array.isArray(output)) {558if (output.length !== 1) {559throw new Error(560`Replicate returned ${output.length} outputs, but baoyu-image-gen currently supports saving exactly one image per request.`561);562}563const first = output[0];564if (typeof first === "string") return first;565}566567if (output && typeof output === "object" && "url" in output) {568const url = (output as Record<string, unknown>).url;569if (typeof url === "string") return url;570}571572throw new Error(`Unexpected Replicate output format: ${JSON.stringify(output)}`);573}574575async function downloadImage(url: string): Promise<Uint8Array> {576const res = await fetch(url);577if (!res.ok) throw new Error(`Failed to download image from Replicate: ${res.status}`);578const buf = await res.arrayBuffer();579return new Uint8Array(buf);580}581582export async function generateImage(583prompt: string,584model: string,585args: CliArgs586): Promise<Uint8Array> {587const apiToken = getApiToken();588if (!apiToken) throw new Error("REPLICATE_API_TOKEN is required. Get one at https://replicate.com/account/api-tokens");589590const parsedModel = parseModelId(model);591validateArgs(model, args);592593const refDataUrls: string[] = [];594for (const refPath of args.referenceImages) {595refDataUrls.push(await readImageAsDataUrl(refPath));596}597598const input = buildInput(model, prompt, args, refDataUrls);599600console.log(`Generating image with Replicate (${model})...`);601602let prediction = await createPrediction(apiToken, parsedModel, input, true);603604if (prediction.status !== "succeeded") {605if (!prediction.urls?.get) {606throw new Error("Replicate prediction did not return a poll URL");607}608console.log("Waiting for prediction to complete...");609prediction = await pollPrediction(apiToken, prediction.urls.get);610}611612console.log("Generation completed.");613614const outputUrl = extractOutputUrl(prediction);615return downloadImage(outputUrl);616}617