import type { ImageProvider, ImageEditRequest, ProviderSubmitResult, ProviderPollResult, } from "./provider.js"; /** * Adapter RunComfy — Model API (on-demand, bez deploymentu). * Submit: POST {base}/v1/models/{modelId} z płaskim body wg schematu modelu. * Poll: GET {base}/v1/requests/{id}/status → GET .../result * Obrazy referencyjne jako data: URI (akceptowane przez API). * Błąd transportu NIE jest równy zwiększeniu attempts — patrz domain/jobs. */ // Pola wejscia i koszt zaleza od rodziny modelu (schematy RunComfy Model API). const MODEL_PROFILES: Array<{ match: (id: string) => boolean; unitCostUsd: number; buildInputs: (p: ImageEditRequest) => Record; }> = [ { // google/nano-banana/{2,pro}/edit — image_urls, resolution 1K/2K/4K, bez quality match: (id) => id.includes("nano-banana"), unitCostUsd: 0.13, buildInputs: (p) => ({ prompt: p.prompt, image_urls: p.images, aspect_ratio: p.aspectRatio ?? "4:3", resolution: "2K", }), }, { // openai/gpt-image-2.5/sunburst/edit — images, quality, resolution "1k" match: () => true, unitCostUsd: 0.007, buildInputs: (p) => ({ prompt: p.prompt, images: p.images, aspect_ratio: p.aspectRatio ?? "4:3", output_format: "jpeg", quality: p.quality ?? "medium", resolution: "1k", }), }, ]; export function createRunComfyProvider(opts: { apiKey: string; modelId: string; baseUrl?: string; }): ImageProvider { const { apiKey, modelId } = opts; const base = (opts.baseUrl || "https://model-api.runcomfy.net").replace(/\/$/, ""); const profile = MODEL_PROFILES.find((m) => m.match(modelId))!; return { name: "runcomfy", capabilities: { imageEdit: true, imageToVideo: false, maxReferenceImages: 16 }, unitCostUsd: profile.unitCostUsd, async submitImageEdit(p: ImageEditRequest): Promise { const res = await fetch(`${base}/v1/models/${modelId}`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify(profile.buildInputs(p)), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`RunComfy HTTP ${res.status}: ${text.slice(0, 400)}`); } const data = (await res.json()) as { request_id?: string }; if (!data.request_id) throw new Error("RunComfy: brak request_id w odpowiedzi"); return { requestId: data.request_id }; }, async poll(requestId: string): Promise { const res = await fetch(`${base}/v1/requests/${encodeURIComponent(requestId)}/status`, { headers: { Authorization: `Bearer ${apiKey}` }, }); if (!res.ok) throw new Error(`RunComfy status HTTP ${res.status}`); const data = (await res.json()) as { status?: string; queue_position?: number | null; error?: unknown }; switch (data.status) { case "completed": case "succeeded": break; case "failed": case "cancelled": case "canceled": return { status: "failed", providerStatus: data.status, error: `provider status: ${data.status}${data.error ? ` — ${String(typeof data.error === "string" ? data.error : JSON.stringify(data.error)).slice(0, 200)}` : ""}`, }; default: return { status: "pending", providerStatus: data.status ?? "unknown", queuePosition: data.queue_position ?? null }; } const rr = await fetch(`${base}/v1/requests/${encodeURIComponent(requestId)}/result`, { headers: { Authorization: `Bearer ${apiKey}` }, }); if (!rr.ok) throw new Error(`RunComfy result HTTP ${rr.status}`); const rj = (await rr.json()) as { output?: { image?: string; images?: string[] }; output_urls?: string[]; }; const urls = rj.output?.images ?? (rj.output?.image ? [rj.output.image] : rj.output_urls) ?? []; if (urls.length === 0) return { status: "failed", error: "provider zwrócił 0 wyników" }; return { status: "done", outputUrls: urls }; }, }; }