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:
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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user