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
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:
18
templates/image-pipeline/worker/providers/index.ts
Normal file
18
templates/image-pipeline/worker/providers/index.ts
Normal 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
|
||||
}
|
||||
235
templates/image-pipeline/worker/providers/replicate.ts
Normal file
235
templates/image-pipeline/worker/providers/replicate.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
95
templates/image-pipeline/worker/providers/types.ts
Normal file
95
templates/image-pipeline/worker/providers/types.ts
Normal 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(''))
|
||||
}
|
||||
103
templates/image-pipeline/worker/routes/generate.ts
Normal file
103
templates/image-pipeline/worker/routes/generate.ts
Normal 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
|
||||
}
|
||||
116
templates/image-pipeline/worker/routes/generateText.ts
Normal file
116
templates/image-pipeline/worker/routes/generateText.ts
Normal 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.`,
|
||||
}
|
||||
}
|
||||
68
templates/image-pipeline/worker/routes/images.ts
Normal file
68
templates/image-pipeline/worker/routes/images.ts
Normal 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
|
||||
}
|
||||
101
templates/image-pipeline/worker/routes/ipAdapter.ts
Normal file
101
templates/image-pipeline/worker/routes/ipAdapter.ts
Normal 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)}` }
|
||||
}
|
||||
119
templates/image-pipeline/worker/routes/styleTransfer.ts
Normal file
119
templates/image-pipeline/worker/routes/styleTransfer.ts
Normal 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)}` }
|
||||
}
|
||||
57
templates/image-pipeline/worker/routes/upscale.ts
Normal file
57
templates/image-pipeline/worker/routes/upscale.ts
Normal 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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
38
templates/image-pipeline/worker/worker.ts
Normal file
38
templates/image-pipeline/worker/worker.ts
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user