snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import { replicate } from './replicate'
import type { ImageProvider } from './types'
export type {
GenerateParams,
GenerateResult,
ImageProvider,
UpscaleParams,
UpscaleResult,
} from './types'
export function getProvider(_name: string): ImageProvider {
return replicate
}
export function getUpscaleProvider(_method: string): ImageProvider {
return replicate
}

View File

@@ -0,0 +1,235 @@
import { resolveImage } from './types'
import type {
GenerateParams,
GenerateResult,
ImageProvider,
UpscaleParams,
UpscaleResult,
} from './types'
/** Maps ControlNet mode IDs to Flux ControlNet model identifiers. */
const CONTROLNET_MODELS: Record<string, string> = {
canny: 'black-forest-labs/flux-canny-dev',
depth: 'black-forest-labs/flux-depth-dev',
// No Flux-native pose/segmentation models — fall back to canny
pose: 'black-forest-labs/flux-canny-dev',
segmentation: 'black-forest-labs/flux-depth-dev',
}
export const replicate: ImageProvider = {
name: 'replicate',
async generate(params: GenerateParams, env: Env): Promise<GenerateResult> {
const apiToken = env.REPLICATE_API_TOKEN
if (!apiToken) {
throw new Error('REPLICATE_API_TOKEN is not configured')
}
// Use a ControlNet model when ControlNet params are present
if (params.controlNetMode && params.referenceImageUrl) {
return generateWithControlNet(params, apiToken, env)
}
// Google models (Nano Banana, Imagen)
if (
params.modelId === 'nano-banana-pro' ||
params.modelId === 'nano-banana' ||
params.modelId === 'imagen-4-fast'
) {
return generateWithGoogle(params, apiToken)
}
return generateWithFlux(params, apiToken)
},
async upscale(params: UpscaleParams, env: Env): Promise<UpscaleResult> {
const apiToken = env.REPLICATE_API_TOKEN
if (!apiToken) {
throw new Error('REPLICATE_API_TOKEN is not configured')
}
const response = await fetch(
'https://api.replicate.com/v1/models/nightmareai/real-esrgan/predictions',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
Prefer: 'wait',
},
body: JSON.stringify({
input: {
image: params.imageUrl,
scale: params.scale,
},
}),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(`Replicate upscale error ${response.status}: ${text}`)
}
const data = (await response.json()) as { output: string }
return { imageUrl: data.output }
},
}
async function generateWithFlux(params: GenerateParams, apiToken: string): Promise<GenerateResult> {
const replicateModel =
params.modelId === 'flux-schnell'
? 'black-forest-labs/flux-schnell'
: params.modelId === 'flux-pro'
? 'black-forest-labs/flux-1.1-pro'
: 'black-forest-labs/flux-dev'
const response = await fetch(
`https://api.replicate.com/v1/models/${replicateModel}/predictions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
Prefer: 'wait',
},
body: JSON.stringify({
input: {
prompt: params.prompt,
num_inference_steps: params.steps ?? 20,
guidance: params.cfgScale ?? 7,
seed: params.seed ?? null,
aspect_ratio: '1:1',
// Flux Pro uses safety_tolerance (1=strictest), others use disable_safety_checker
...(params.modelId === 'flux-pro'
? { safety_tolerance: 1 }
: { disable_safety_checker: false }),
...(params.referenceImageUrl ? { image: params.referenceImageUrl } : {}),
},
}),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(`Replicate error ${response.status}: ${text}`)
}
const data = (await response.json()) as {
output: string | string[]
seed?: number
}
const imageUrl = Array.isArray(data.output) ? data.output[0] : data.output
return {
imageUrl,
seed: data.seed ?? params.seed ?? 0,
}
}
/**
* Use a Google model on Replicate (Nano Banana, Imagen).
*/
async function generateWithGoogle(
params: GenerateParams,
apiToken: string
): Promise<GenerateResult> {
const modelMap: Record<string, string> = {
'nano-banana-pro': 'google/nano-banana-pro',
'nano-banana': 'google/nano-banana',
'imagen-4-fast': 'google/imagen-4-fast',
}
const replicateModel = modelMap[params.modelId] ?? 'google/nano-banana-pro'
const response = await fetch(
`https://api.replicate.com/v1/models/${replicateModel}/predictions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
Prefer: 'wait',
},
body: JSON.stringify({
input: {
prompt: params.prompt,
aspect_ratio: '1:1',
...(params.referenceImageUrl ? { image_input: [params.referenceImageUrl] } : {}),
},
}),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(`Replicate Google model error ${response.status}: ${text}`)
}
const data = (await response.json()) as {
output: string | string[]
seed?: number
}
const imageUrl = Array.isArray(data.output) ? data.output[0] : data.output
return {
imageUrl,
seed: data.seed ?? params.seed ?? 0,
}
}
/**
* Use a Flux ControlNet model on Replicate.
* The reference image is passed as the control image, and the mode
* selects the appropriate variant (canny or depth).
*/
async function generateWithControlNet(
params: GenerateParams,
apiToken: string,
env: Env
): Promise<GenerateResult> {
const model = CONTROLNET_MODELS[params.controlNetMode!] ?? CONTROLNET_MODELS.canny
// Replicate accepts https URLs and data URIs directly.
// Only resolve R2 paths that Replicate can't access.
let imageInput = params.referenceImageUrl!
if (imageInput.startsWith('/api/images/')) {
const { dataUrl } = await resolveImage(imageInput, env)
imageInput = dataUrl
}
const response = await fetch(`https://api.replicate.com/v1/models/${model}/predictions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
Prefer: 'wait',
},
body: JSON.stringify({
input: {
control_image: imageInput,
prompt: params.prompt,
num_inference_steps: params.steps ?? 28,
guidance: params.cfgScale ?? 30,
...(params.seed != null ? { seed: params.seed } : {}),
output_format: 'png',
disable_safety_checker: false,
},
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(`Replicate ControlNet error ${response.status}: ${text}`)
}
const data = (await response.json()) as {
output: string | string[]
seed?: number
}
const imageUrl = Array.isArray(data.output) ? data.output[0] : data.output
return {
imageUrl,
seed: data.seed ?? params.seed ?? 0,
}
}

View File

@@ -0,0 +1,95 @@
export interface GenerateParams {
modelId: string
prompt: string
negativePrompt?: string
steps: number
cfgScale: number
seed: number | null
controlNetMode?: string
controlNetStrength?: number
referenceImageUrl?: string
}
export interface GenerateResult {
imageUrl: string
seed: number
}
export interface UpscaleParams {
imageUrl: string
scale: number
method: string
}
export interface UpscaleResult {
imageUrl: string
}
export interface ImageProvider {
name: string
generate(params: GenerateParams, env: Env): Promise<GenerateResult>
upscale?(params: UpscaleParams, env: Env): Promise<UpscaleResult>
}
/**
* Resolve an image URL (data URL, R2 path, or external URL) into a Blob
* and a data URL that external APIs can consume.
*/
export async function resolveImage(
url: string,
env: Env
): Promise<{ blob: Blob; dataUrl: string }> {
if (url.startsWith('data:')) {
const [header, data] = url.split(',')
const mimeMatch = header.match(/data:([^;]+)/)
const mime = mimeMatch?.[1] ?? 'image/png'
if (header.includes('base64')) {
const binary = atob(data)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return { blob: new Blob([bytes], { type: mime }), dataUrl: url }
}
const decoded = decodeURIComponent(data)
const bytes = new TextEncoder().encode(decoded)
const b64 = arrayBufferToBase64(bytes.buffer as ArrayBuffer)
return {
blob: new Blob([bytes], { type: mime }),
dataUrl: `data:${mime};base64,${b64}`,
}
}
// R2 path — read directly from the bucket
if (url.startsWith('/api/images/') && env.IMAGE_BUCKET) {
const imageId = url.slice('/api/images/'.length)
const object = await env.IMAGE_BUCKET.get(imageId)
if (!object) throw new Error(`Image not found in R2: ${imageId}`)
const buf = await object.arrayBuffer()
const mime = object.httpMetadata?.contentType ?? 'image/png'
return {
blob: new Blob([buf], { type: mime }),
dataUrl: `data:${mime};base64,${arrayBufferToBase64(buf)}`,
}
}
// External URL
const res = await fetch(url)
if (!res.ok) throw new Error(`Failed to fetch image: ${res.status}`)
const buf = await res.arrayBuffer()
const mime = res.headers.get('content-type') ?? 'image/png'
return {
blob: new Blob([buf], { type: mime }),
dataUrl: `data:${mime};base64,${arrayBufferToBase64(buf)}`,
}
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const chunks: string[] = []
const chunkSize = 8192
for (let i = 0; i < bytes.length; i += chunkSize) {
chunks.push(String.fromCharCode(...bytes.subarray(i, i + chunkSize)))
}
return btoa(chunks.join(''))
}

View File

@@ -0,0 +1,103 @@
import { IRequest } from 'itty-router'
import { getProvider } from '../providers'
import type { GenerateParams } from '../providers'
/**
* Request body for the /api/generate endpoint.
*/
interface GenerateRequest {
/** The provider:model string, e.g. "stable-diffusion:sdxl" */
model: string
/** The text prompt describing the desired image */
prompt: string
/** Optional negative prompt */
negativePrompt?: string
/** Number of inference steps (default: 20) */
steps?: number
/** Classifier-free guidance scale (default: 7) */
cfgScale?: number
/** Seed for reproducibility (default: random) */
seed?: number
/** ControlNet mode if applicable */
controlNetMode?: string
/** ControlNet strength (0-100) */
controlNetStrength?: number
/** Reference image URL for ControlNet */
referenceImageUrl?: string
}
/**
* POST /api/generate
*
* Calls an AI image generation provider and returns the generated image.
* Supports multiple providers via the model string format "provider:model".
*
* Returns: { imageUrl: string, seed: number }
*/
export async function handleGenerate(request: IRequest, env: Env) {
const body = (await request.json()) as GenerateRequest
if (!body.prompt) {
return new Response(JSON.stringify({ error: 'prompt is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const [providerName, modelId] = (body.model ?? 'flux:flux-dev').split(':')
try {
const provider = getProvider(providerName)
const params: GenerateParams = {
modelId: modelId ?? '',
prompt: body.prompt,
negativePrompt: body.negativePrompt,
steps: body.steps ?? 20,
cfgScale: body.cfgScale ?? 7,
seed: body.seed ?? null,
controlNetMode: body.controlNetMode,
controlNetStrength: body.controlNetStrength,
referenceImageUrl: body.referenceImageUrl,
}
let result = await provider.generate(params, env)
// Optionally persist the image to R2.
if (env.IMAGE_BUCKET && result.imageUrl?.startsWith('data:')) {
const imageId = `gen_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const blob = dataUrlToBlob(result.imageUrl)
await env.IMAGE_BUCKET.put(imageId, blob, {
httpMetadata: { contentType: 'image/png' },
})
result = { ...result, imageUrl: `/api/images/${imageId}` }
}
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
})
} catch (e: any) {
console.error('Generate error:', e)
return new Response(JSON.stringify({ error: e.message ?? 'Generation failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function dataUrlToBlob(dataUrl: string): ArrayBuffer {
const [header, base64] = dataUrl.split(',')
if (header.includes('base64')) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer as ArrayBuffer
}
// For SVG data URLs, just encode as UTF-8
return new TextEncoder().encode(decodeURIComponent(base64)).buffer as ArrayBuffer
}

View File

@@ -0,0 +1,116 @@
import { IRequest } from 'itty-router'
import { resolveImage } from '../providers/types'
interface GenerateTextRequest {
input?: string
prompt: string
}
/**
* POST /api/generate-text
*
* Takes an optional input (image or text) and a prompt, then calls
* google/gemini-3.5-flash on Replicate to generate text.
* Falls back to a placeholder if no API token is configured.
*/
export async function handleGenerateText(request: IRequest, env: Env) {
const body = (await request.json()) as GenerateTextRequest
if (!body.prompt) {
return new Response(JSON.stringify({ error: 'prompt is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const apiToken = env.REPLICATE_API_TOKEN
if (!apiToken) {
return new Response(JSON.stringify(generateTextPlaceholder(body)), {
headers: { 'Content-Type': 'application/json' },
})
}
// Coerce input to string so downstream .startsWith() never crashes
const inputStr = body.input != null ? String(body.input) : null
// Detect whether the input is an image or text
const isImage =
inputStr != null &&
(inputStr.startsWith('data:image/') ||
inputStr.startsWith('/api/images/') ||
inputStr.startsWith('https://') ||
inputStr.startsWith('http://'))
try {
// Build the prompt — if input is text, prepend it to the prompt
let prompt = body.prompt
if (inputStr && !isImage) {
prompt = `Context:\n${inputStr}\n\n${body.prompt}`
}
// Build the images array if we have an image input
const images: string[] = []
if (inputStr && isImage) {
const { dataUrl } = await resolveImage(inputStr, env)
images.push(dataUrl)
}
const input: Record<string, unknown> = {
prompt,
max_output_tokens: 1024,
}
if (images.length > 0) {
input.images = images
}
const response = await fetch(
'https://api.replicate.com/v1/models/google/gemini-3.5-flash/predictions',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
Prefer: 'wait',
},
body: JSON.stringify({ input }),
}
)
if (!response.ok) {
const err = await response.text()
throw new Error(`Replicate error: ${response.status} ${err}`)
}
const result = (await response.json()) as { output?: string | string[] }
const output = Array.isArray(result.output) ? result.output.join('') : result.output
if (!output) throw new Error('No output from text generation')
return new Response(JSON.stringify({ text: output }), {
headers: { 'Content-Type': 'application/json' },
})
} catch (e: any) {
console.error('Generate text error:', e)
return new Response(JSON.stringify({ error: e.message ?? 'Text generation failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
function generateTextPlaceholder(body: GenerateTextRequest): { text: string } {
const inputStr = body.input != null ? String(body.input) : null
const isImage =
inputStr != null &&
(inputStr.startsWith('data:image/') ||
inputStr.startsWith('/api/images/') ||
inputStr.startsWith('https://') ||
inputStr.startsWith('http://'))
const inputDesc = inputStr
? isImage
? '[image provided]'
: `[text: "${inputStr.slice(0, 40)}${inputStr.length > 40 ? '...' : ''}"]`
: '[no input]'
return {
text: `[Placeholder] Prompt: "${body.prompt.slice(0, 60)}${body.prompt.length > 60 ? '...' : ''}" | Input: ${inputDesc} — Set REPLICATE_API_TOKEN for real text generation.`,
}
}

View File

@@ -0,0 +1,68 @@
import { IRequest } from 'itty-router'
/**
* POST /api/images/:imageId
*
* Upload a generated image to R2 for persistence.
*/
export async function handleImageUpload(request: IRequest, env: Env) {
const { imageId } = request.params
const contentType = request.headers.get('content-type') ?? 'image/png'
if (!contentType.startsWith('image/')) {
return new Response(JSON.stringify({ error: 'Invalid content type' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
// Don't overwrite existing images.
if (await env.IMAGE_BUCKET.head(imageId)) {
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
})
}
await env.IMAGE_BUCKET.put(imageId, request.body, {
httpMetadata: { contentType },
})
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
})
}
/**
* GET /api/images/:imageId
*
* Download a generated image from R2 with caching.
*/
export async function handleImageDownload(request: IRequest, env: Env, ctx: ExecutionContext) {
const { imageId } = request.params
// Check edge cache first.
// Use caches.open() to avoid DOM/Workers CacheStorage type conflict.
const cache = await caches.open('images')
const cacheKey = new Request(request.url, { headers: request.headers })
const cachedResponse = await cache.match(cacheKey)
if (cachedResponse) {
return cachedResponse
}
const object = await env.IMAGE_BUCKET.get(imageId)
if (!object) {
return new Response('Not found', { status: 404 })
}
const headers = new Headers()
headers.set('content-type', object.httpMetadata?.contentType ?? 'image/png')
// Generated images are immutable — cache them aggressively.
headers.set('cache-control', 'public, max-age=31536000, immutable')
const response = new Response(object.body, { headers })
// Cache in the edge for future requests.
ctx.waitUntil(cache.put(cacheKey, response.clone()))
return response
}

View File

@@ -0,0 +1,101 @@
import { IRequest } from 'itty-router'
import { resolveImage } from '../providers/types'
interface IPAdapterRequest {
imageUrl: string
prompt: string
scale: number
steps: number
}
/**
* POST /api/ip-adapter
*
* Generates an image guided by a reference image and text prompt using
* IP-Adapter SDXL on Replicate. Falls back to a placeholder if no API key.
*/
export async function handleIPAdapter(request: IRequest, env: Env) {
const body = (await request.json()) as IPAdapterRequest
if (!body.imageUrl) {
return new Response(JSON.stringify({ error: 'imageUrl is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const apiKey = env.REPLICATE_API_TOKEN
if (!apiKey) {
return new Response(JSON.stringify(ipAdapterPlaceholder(body)), {
headers: { 'Content-Type': 'application/json' },
})
}
try {
const { dataUrl } = await resolveImage(body.imageUrl, env)
const prediction = await fetch('https://api.replicate.com/v1/predictions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Prefer: 'wait',
},
body: JSON.stringify({
version: '904dc004af1dba5c9b13fc9e41635aeb2f9a177896a396ab3393f3f6493dbdd4',
input: {
image: dataUrl,
prompt: body.prompt || 'best quality, high quality',
scale: body.scale ?? 0.6,
num_inference_steps: body.steps ?? 30,
},
}),
})
if (!prediction.ok) {
const err = await prediction.text()
throw new Error(`Replicate error: ${prediction.status} ${err}`)
}
const result = (await prediction.json()) as { output?: string[] | string }
const outputUrl = Array.isArray(result.output) ? result.output[0] : result.output
if (!outputUrl) throw new Error('No output from IP-Adapter')
// Persist to R2 if available
let imageUrl = outputUrl
if (env.IMAGE_BUCKET) {
const imageRes = await fetch(outputUrl)
const imageData = await imageRes.arrayBuffer()
const id = crypto.randomUUID()
await env.IMAGE_BUCKET.put(id, imageData, {
httpMetadata: { contentType: imageRes.headers.get('content-type') ?? 'image/png' },
})
imageUrl = `/api/images/${id}`
}
return new Response(JSON.stringify({ imageUrl }), {
headers: { 'Content-Type': 'application/json' },
})
} catch (e: any) {
console.error('IP-Adapter error:', e)
return new Response(JSON.stringify({ error: e.message ?? 'IP-Adapter failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
function ipAdapterPlaceholder(params: IPAdapterRequest) {
const hue = Math.floor(Math.random() * 360)
const prompt = (params.prompt || 'IP-Adapter').slice(0, 30)
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024">
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="hsl(${hue},50%,40%)"/>
<stop offset="100%" stop-color="hsl(${(hue + 80) % 360},45%,55%)"/>
</linearGradient></defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<text x="512" y="490" text-anchor="middle" fill="rgba(255,255,255,0.7)" font-family="sans-serif" font-size="22">${prompt}</text>
<text x="512" y="530" text-anchor="middle" fill="rgba(255,255,255,0.4)" font-family="sans-serif" font-size="14">IP-Adapter · scale ${params.scale} · placeholder</text>
</svg>`
return { imageUrl: `data:image/svg+xml,${encodeURIComponent(svg)}` }
}

View File

@@ -0,0 +1,119 @@
import { IRequest } from 'itty-router'
import { resolveImage } from '../providers/types'
interface StyleTransferRequest {
styleImageUrl: string
contentImageUrl?: string
prompt?: string
model: string
strength: number
}
/**
* POST /api/style-transfer
*
* Transfers the style of one image onto another (or generates a new image
* in that style) using fofr/style-transfer on Replicate.
* Falls back to a placeholder if no API key.
*/
export async function handleStyleTransfer(request: IRequest, env: Env) {
const body = (await request.json()) as StyleTransferRequest
if (!body.styleImageUrl) {
return new Response(JSON.stringify({ error: 'styleImageUrl is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const apiKey = env.REPLICATE_API_TOKEN
if (!apiKey) {
return new Response(JSON.stringify(styleTransferPlaceholder(body)), {
headers: { 'Content-Type': 'application/json' },
})
}
try {
const { dataUrl: styleDataUrl } = await resolveImage(body.styleImageUrl, env)
const input: Record<string, unknown> = {
style_image: styleDataUrl,
prompt: body.prompt || '',
style_strength: body.strength ?? 0.5,
}
if (body.contentImageUrl) {
const { dataUrl: contentDataUrl } = await resolveImage(body.contentImageUrl, env)
input.structure_image = contentDataUrl
}
// Map model variant to the Replicate model parameter
const modelMap: Record<string, string> = {
fast: 'fast',
'high-quality': 'high-quality',
realistic: 'realistic',
cinematic: 'cinematic',
animated: 'animated',
}
input.model = modelMap[body.model] ?? 'fast'
const prediction = await fetch('https://api.replicate.com/v1/predictions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Prefer: 'wait',
},
body: JSON.stringify({
version: 'f1023890703bc0a5a3a2c21b5e498833be5f6ef6e70e9daf6b9b3a4fd8309cf0',
input,
}),
})
if (!prediction.ok) {
const err = await prediction.text()
throw new Error(`Replicate error: ${prediction.status} ${err}`)
}
const result = (await prediction.json()) as { output?: string[] | string }
const outputUrl = Array.isArray(result.output) ? result.output[0] : result.output
if (!outputUrl) throw new Error('No output from style transfer')
// Persist to R2 if available
let imageUrl = outputUrl
if (env.IMAGE_BUCKET) {
const imageRes = await fetch(outputUrl)
const imageData = await imageRes.arrayBuffer()
const id = crypto.randomUUID()
await env.IMAGE_BUCKET.put(id, imageData, {
httpMetadata: { contentType: imageRes.headers.get('content-type') ?? 'image/png' },
})
imageUrl = `/api/images/${id}`
}
return new Response(JSON.stringify({ imageUrl }), {
headers: { 'Content-Type': 'application/json' },
})
} catch (e: any) {
console.error('Style transfer error:', e)
return new Response(JSON.stringify({ error: e.message ?? 'Style transfer failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
function styleTransferPlaceholder(params: StyleTransferRequest) {
const hue = Math.floor(Math.random() * 360)
const model = params.model || 'fast'
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024">
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="hsl(${hue},55%,35%)"/>
<stop offset="100%" stop-color="hsl(${(hue + 120) % 360},50%,50%)"/>
</linearGradient></defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<text x="512" y="490" text-anchor="middle" fill="rgba(255,255,255,0.7)" font-family="sans-serif" font-size="22">Style Transfer</text>
<text x="512" y="530" text-anchor="middle" fill="rgba(255,255,255,0.4)" font-family="sans-serif" font-size="14">${model} · strength ${params.strength} · placeholder</text>
</svg>`
return { imageUrl: `data:image/svg+xml,${encodeURIComponent(svg)}` }
}

View File

@@ -0,0 +1,57 @@
import { IRequest } from 'itty-router'
import { getUpscaleProvider } from '../providers'
import type { UpscaleParams } from '../providers'
interface UpscaleRequest {
/** URL of the image to upscale */
imageUrl: string
/** Scale factor (2 or 4) */
scale: number
/** Upscale method */
method: string
}
/**
* POST /api/upscale
*
* Upscales an image using an AI upscaler. Falls back to a placeholder
* if no API key is configured.
*/
export async function handleUpscale(request: IRequest, env: Env) {
const body = (await request.json()) as UpscaleRequest
if (!body.imageUrl) {
return new Response(JSON.stringify({ error: 'imageUrl is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
try {
const provider = getUpscaleProvider(body.method)
const params: UpscaleParams = {
imageUrl: body.imageUrl,
scale: body.scale,
method: body.method,
}
if (!provider.upscale) {
return new Response(
JSON.stringify({ error: `Provider "${provider.name}" does not support upscaling` }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
const result = await provider.upscale(params, env)
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
})
} catch (e: any) {
console.error('Upscale error:', e)
return new Response(JSON.stringify({ error: e.message ?? 'Upscale failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}

View File

@@ -0,0 +1,38 @@
import { AutoRouter, error, IRequest } from 'itty-router'
import { handleGenerate } from './routes/generate'
import { handleGenerateText } from './routes/generateText'
import { handleImageDownload, handleImageUpload } from './routes/images'
import { handleIPAdapter } from './routes/ipAdapter'
import { handleStyleTransfer } from './routes/styleTransfer'
import { handleUpscale } from './routes/upscale'
const router = AutoRouter<IRequest, [env: Env, ctx: ExecutionContext]>({
catch: (e) => {
console.error(e)
return error(e)
},
})
// Image generation endpoint — accepts prompt, model, and parameters
.post('/api/generate', handleGenerate)
// Upscale endpoint — accepts an image URL and scale factor
.post('/api/upscale', handleUpscale)
// IP-Adapter endpoint — reference image + prompt guided generation
.post('/api/ip-adapter', handleIPAdapter)
// Style transfer endpoint — transfers style between images
.post('/api/style-transfer', handleStyleTransfer)
// Text generation endpoint — multimodal AI text generation
.post('/api/generate-text', handleGenerateText)
// Upload/download generated images to/from R2 bucket
.post('/api/images/:imageId', handleImageUpload)
.get('/api/images/:imageId', handleImageDownload)
.all('*', () => new Response('Not found', { status: 404 }))
export default {
fetch: router.fetch,
}