/** * offscreen-canvas.ts — Render graphics off the main thread via OffscreenCanvas. * * The agent describes a scene via declarative ops. Rendering happens in a reusable * Worker (main thread stays responsive). If OffscreenCanvas is unavailable in a * Worker (old browser), falls back to a main-thread canvas. * * Tools: * offscreen_render — declarative 2D scene → PNG/JPEG/WebP data URL * offscreen_render_sequence — render N frames with parameterized ops → sprite sheet or array * offscreen_chart — quick line/bar/pie charts without hand-crafting ops * offscreen_badge — status/version/counter badges (shields.io-style) * offscreen_ops_reference — list all supported ops + examples */ import { tool } from '@strands-agents/sdk' import { z } from 'zod' // --------------------------------------------------------------------------- // Worker: inline source. Handles one job per postMessage; kept alive across // calls so we don't pay worker boot cost per render. // --------------------------------------------------------------------------- const WORKER_SRC = ` let _fontsLoaded = new Set() async function loadFonts(fonts) { if (!fonts || !fonts.length || typeof self.FontFace === 'undefined') return await Promise.all(fonts.map(async (f) => { const key = f.family + '|' + f.source if (_fontsLoaded.has(key)) return try { const face = new FontFace(f.family, \`url(\${f.source})\`, f.descriptors || {}) await face.load() ;(self.fonts || self).add?.(face) _fontsLoaded.add(key) } catch (e) { /* ignore */ } })) } async function prefetchImages(ops) { // Collect all image ops and fetch in parallel so sequential draws aren't serialized. const pending = new Map() for (const op of ops) { if (op.op === 'image' && op.url && !pending.has(op.url)) { pending.set(op.url, (async () => { const resp = await fetch(op.url) const blob = await resp.blob() return await createImageBitmap(blob) })()) } } const resolved = new Map() for (const [url, p] of pending) { try { resolved.set(url, await p) } catch { resolved.set(url, null) } } return resolved } async function runJob(job) { const { width, height, ops, format, quality, fonts } = job if (typeof OffscreenCanvas === 'undefined') { return { error: 'OffscreenCanvas not supported in worker' } } await loadFonts(fonts) const images = await prefetchImages(ops) const canvas = new OffscreenCanvas(width, height) const ctx = canvas.getContext('2d') if (!ctx) return { error: '2d context unavailable' } const opErrors = [] for (let i = 0; i < ops.length; i++) { const op = ops[i] try { applyOp(ctx, op, images, width, height) } catch (e) { opErrors.push({ index: i, op: op.op, error: String(e && e.message || e) }) } } const type = format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png' const blob = await canvas.convertToBlob({ type, quality: quality ?? 0.92 }) const buf = await blob.arrayBuffer() return { ok: true, buffer: buf, width, height, mimeType: type, opErrors } } function applyOp(ctx, op, images, width, height) { // Per-op state save/restore when overrides are present. const hasOverrides = op.fillStyle !== undefined || op.strokeStyle !== undefined || op.lineWidth !== undefined || op.font !== undefined || op.textAlign !== undefined || op.textBaseline !== undefined || op.globalAlpha !== undefined || op.shadow !== undefined if (hasOverrides) { ctx.save() if (op.fillStyle !== undefined) ctx.fillStyle = op.fillStyle if (op.strokeStyle !== undefined) ctx.strokeStyle = op.strokeStyle if (op.lineWidth !== undefined) ctx.lineWidth = op.lineWidth if (op.font !== undefined) ctx.font = op.font if (op.textAlign !== undefined) ctx.textAlign = op.textAlign if (op.textBaseline !== undefined) ctx.textBaseline = op.textBaseline if (op.globalAlpha !== undefined) ctx.globalAlpha = op.globalAlpha if (op.shadow) { ctx.shadowColor = op.shadow.color || 'rgba(0,0,0,0.5)' ctx.shadowBlur = op.shadow.blur || 0 ctx.shadowOffsetX = op.shadow.x || 0 ctx.shadowOffsetY = op.shadow.y || 0 } } switch (op.op) { // State (still supported for multi-op sequences) case 'fill': ctx.fillStyle = op.style; break case 'stroke': ctx.strokeStyle = op.style; break case 'lineWidth': ctx.lineWidth = op.value; break case 'font': ctx.font = op.value; break case 'textAlign': ctx.textAlign = op.value; break case 'textBaseline': ctx.textBaseline = op.value; break case 'globalAlpha': ctx.globalAlpha = op.value; break case 'shadow': ctx.shadowColor = op.color || 'rgba(0,0,0,0.5)' ctx.shadowBlur = op.blur || 0 ctx.shadowOffsetX = op.x || 0 ctx.shadowOffsetY = op.y || 0 break case 'lineCap': ctx.lineCap = op.value; break case 'lineJoin': ctx.lineJoin = op.value; break case 'lineDash': ctx.setLineDash(op.value || []); break case 'clear': ctx.clearRect(0, 0, width, height); break case 'fillBackground': ctx.fillStyle = op.style || '#000' ctx.fillRect(0, 0, width, height) break case 'fillRect': ctx.fillRect(op.x, op.y, op.w, op.h); break case 'strokeRect': ctx.strokeRect(op.x, op.y, op.w, op.h); break case 'roundRect': { const r = op.r || 8 ctx.beginPath() if (typeof ctx.roundRect === 'function') { ctx.roundRect(op.x, op.y, op.w, op.h, r) } else { // Manual roundRect fallback. ctx.moveTo(op.x + r, op.y) ctx.arcTo(op.x + op.w, op.y, op.x + op.w, op.y + op.h, r) ctx.arcTo(op.x + op.w, op.y + op.h, op.x, op.y + op.h, r) ctx.arcTo(op.x, op.y + op.h, op.x, op.y, r) ctx.arcTo(op.x, op.y, op.x + op.w, op.y, r) ctx.closePath() } if (op.fill !== false) ctx.fill() if (op.stroke) ctx.stroke() break } case 'circle': ctx.beginPath() ctx.arc(op.x, op.y, op.r, 0, Math.PI * 2) if (op.fill !== false) ctx.fill() if (op.stroke) ctx.stroke() break case 'arc': ctx.beginPath() ctx.arc(op.x, op.y, op.r, op.startAngle, op.endAngle, !!op.anticlockwise) if (op.fill) ctx.fill() if (op.stroke !== false) ctx.stroke() break case 'line': ctx.beginPath() ctx.moveTo(op.x1, op.y1) ctx.lineTo(op.x2, op.y2) ctx.stroke() break case 'fillText': ctx.fillText(op.text, op.x, op.y, op.maxWidth); break case 'strokeText': ctx.strokeText(op.text, op.x, op.y, op.maxWidth); break case 'path': { ctx.beginPath() const pts = op.points || [] if (pts.length) ctx.moveTo(pts[0][0], pts[0][1]) for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]) if (op.close) ctx.closePath() if (op.fill) ctx.fill() if (op.stroke !== false) ctx.stroke() break } case 'bezier': { // Single bezier curve: {x1,y1}→{cx1,cy1}-{cx2,cy2}→{x2,y2} ctx.beginPath() ctx.moveTo(op.x1, op.y1) ctx.bezierCurveTo(op.cx1, op.cy1, op.cx2, op.cy2, op.x2, op.y2) if (op.stroke !== false) ctx.stroke() if (op.fill) ctx.fill() break } case 'linearGradient': { const g = ctx.createLinearGradient(op.x1, op.y1, op.x2, op.y2) for (const [stop, color] of op.stops) g.addColorStop(stop, color) ctx.fillStyle = g const r = op.rect || [0, 0, width, height] ctx.fillRect(r[0], r[1], r[2], r[3]) break } case 'radialGradient': { const g = ctx.createRadialGradient(op.x1, op.y1, op.r1, op.x2, op.y2, op.r2) for (const [stop, color] of op.stops) g.addColorStop(stop, color) ctx.fillStyle = g const r = op.rect || [0, 0, width, height] ctx.fillRect(r[0], r[1], r[2], r[3]) break } case 'clipRect': { ctx.beginPath() ctx.rect(op.x, op.y, op.w, op.h) ctx.clip() break } case 'rotate': ctx.rotate(op.angle); break case 'translate': ctx.translate(op.x, op.y); break case 'scale': ctx.scale(op.x, op.y); break case 'save': ctx.save(); break case 'restore': ctx.restore(); break case 'image': { const bmp = images.get(op.url) if (!bmp) throw new Error('image failed to load: ' + String(op.url).slice(0, 80)) ctx.drawImage(bmp, op.x || 0, op.y || 0, op.w || bmp.width, op.h || bmp.height) break } default: throw new Error('unknown op: ' + op.op) } if (hasOverrides) ctx.restore() } self.onmessage = async (ev) => { const { id, job } = ev.data try { const result = await runJob(job) if (result.ok) { self.postMessage({ id, ...result }, [result.buffer]) } else { self.postMessage({ id, error: result.error }) } } catch (e) { self.postMessage({ id, error: String(e && e.message || e) }) } } ` // --------------------------------------------------------------------------- // Worker pool (1 reusable worker + request id multiplexing) // --------------------------------------------------------------------------- let _workerUrl: string | null = null let _worker: Worker | null = null let _requestSeq = 0 const _pending = new Map void; reject: (e: any) => void; timer: any }>() let _workerUsable = true // flipped off once we detect OffscreenCanvas is unsupported in the worker function getWorkerUrl() { if (!_workerUrl) _workerUrl = URL.createObjectURL(new Blob([WORKER_SRC], { type: 'application/javascript' })) return _workerUrl } function ensureWorker(): Worker | null { if (!_workerUsable) return null if (typeof Worker === 'undefined') return null if (_worker) return _worker const w = new Worker(getWorkerUrl()) w.onmessage = (ev: MessageEvent) => { const { id, error, ...rest } = ev.data || {} const pending = _pending.get(id) if (!pending) return clearTimeout(pending.timer) _pending.delete(id) if (error) { if (error.includes('OffscreenCanvas not supported')) _workerUsable = false pending.reject(new Error(error)) } else { pending.resolve(rest) } } w.onerror = (err) => { console.error('[offscreen worker]', err) for (const p of _pending.values()) { clearTimeout(p.timer) p.reject(new Error('worker crashed')) } _pending.clear() try { w.terminate() } catch {} _worker = null } _worker = w return w } function runInWorker(job: any, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const w = ensureWorker() if (!w) return reject(new Error('Worker unavailable')) const id = ++_requestSeq const timer = setTimeout(() => { _pending.delete(id) reject(new Error(`Render timed out after ${timeoutMs}ms`)) }, timeoutMs) _pending.set(id, { resolve, reject, timer }) w.postMessage({ id, job }) }) } // --------------------------------------------------------------------------- // Main-thread fallback renderer (when worker is unusable) // --------------------------------------------------------------------------- async function renderOnMainThread(job: any): Promise { if (typeof document === 'undefined') throw new Error('No DOM available for fallback render') const canvas = document.createElement('canvas') canvas.width = job.width canvas.height = job.height const ctx = canvas.getContext('2d') if (!ctx) throw new Error('2d context unavailable on fallback') // Prefetch images in parallel const images = new Map() await Promise.all( (job.ops as any[]) .filter((op) => op.op === 'image' && op.url) .map((op) => new Promise((resolve) => { if (images.has(op.url)) return resolve() const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => { images.set(op.url, img); resolve() } img.onerror = () => { images.set(op.url, null as any); resolve() } img.src = op.url })), ) const opErrors: any[] = [] // We inline the same applyOp logic but calling into HTMLCanvasElement context; the worker // logic works identically here, so we just eval() the worker's applyOp in-scope. Simpler: // duplicate the key cases. Keep this small — full feature parity can be added on demand. for (let i = 0; i < job.ops.length; i++) { const op = job.ops[i] try { switch (op.op) { case 'fill': ctx.fillStyle = op.style; break case 'stroke': ctx.strokeStyle = op.style; break case 'lineWidth': ctx.lineWidth = op.value; break case 'font': ctx.font = op.value; break case 'textAlign': ctx.textAlign = op.value; break case 'textBaseline': ctx.textBaseline = op.value; break case 'globalAlpha': ctx.globalAlpha = op.value; break case 'clear': ctx.clearRect(0, 0, job.width, job.height); break case 'fillBackground': ctx.fillStyle = op.style || '#000'; ctx.fillRect(0, 0, job.width, job.height); break case 'fillRect': ctx.fillRect(op.x, op.y, op.w, op.h); break case 'strokeRect': ctx.strokeRect(op.x, op.y, op.w, op.h); break case 'circle': ctx.beginPath(); ctx.arc(op.x, op.y, op.r, 0, Math.PI * 2) if (op.fill !== false) ctx.fill() if (op.stroke) ctx.stroke() break case 'line': ctx.beginPath(); ctx.moveTo(op.x1, op.y1); ctx.lineTo(op.x2, op.y2); ctx.stroke(); break case 'fillText': ctx.fillText(op.text, op.x, op.y); break case 'strokeText': ctx.strokeText(op.text, op.x, op.y); break case 'image': { const img = images.get(op.url) if (!img) throw new Error('image failed to load') ctx.drawImage(img, op.x || 0, op.y || 0, op.w || img.naturalWidth, op.h || img.naturalHeight) break } default: // Fallback doesn't cover everything; at least record it opErrors.push({ index: i, op: op.op, error: 'op not supported in fallback renderer' }) } } catch (e) { opErrors.push({ index: i, op: op.op, error: String((e as Error).message || e) }) } } const type = job.format === 'jpeg' ? 'image/jpeg' : job.format === 'webp' ? 'image/webp' : 'image/png' const dataURL = canvas.toDataURL(type, job.quality ?? 0.92) // Reconstruct an ArrayBuffer from the data URL for parity with worker response shape. const base64 = dataURL.split(',')[1] const bin = atob(base64) const bytes = new Uint8Array(bin.length) for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) return { ok: true, buffer: bytes.buffer, width: job.width, height: job.height, mimeType: type, opErrors, fallback: true, } } // --------------------------------------------------------------------------- // Encode helpers // --------------------------------------------------------------------------- function bufferToDataURL(buf: ArrayBuffer, type: string): string { const bytes = new Uint8Array(buf) let bin = '' const chunk = 0x8000 for (let i = 0; i < bytes.length; i += chunk) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk) as any) } return `data:${type};base64,${btoa(bin)}` } async function runJob(job: any, timeoutMs: number): Promise { if (_workerUsable) { try { return await runInWorker(job, timeoutMs) } catch (err) { // If worker fails in a way that suggests it's unusable, flip to fallback. if ((err as Error).message.includes('OffscreenCanvas not supported') || (err as Error).message.includes('Worker unavailable') || (err as Error).message.includes('worker crashed')) { _workerUsable = false } else { throw err } } } return await renderOnMainThread(job) } // --------------------------------------------------------------------------- // Tools // --------------------------------------------------------------------------- const OpSchema = z.record(z.string(), z.any()) const CommonOutput = { returnAs: z.enum(['dataURL', 'blobURL']).optional().describe('Default dataURL'), format: z.enum(['png', 'jpeg', 'webp']).optional().describe('Default png'), quality: z.number().min(0).max(1).optional().describe('JPEG/WebP quality 0..1 (default 0.92)'), timeout_ms: z.number().optional().describe('Max render time in ms (default 15000)'), fonts: z.array(z.object({ family: z.string(), source: z.string().describe('Font URL (https) or data URL'), descriptors: z.record(z.string(), z.string()).optional(), })).optional().describe('Optional custom fonts to load before rendering'), } export const offscreenRenderTool = tool({ name: 'offscreen_render', description: 'Render a 2D scene off the main thread. Pass width, height, and an ops array. ' + 'Use offscreen_ops_reference to list all ops. ' + 'Each op may include per-op overrides: fillStyle, strokeStyle, lineWidth, font, textAlign, ' + 'textBaseline, globalAlpha, shadow ({color,blur,x,y}) — applied for just that op. ' + 'Supports PNG/JPEG/WebP output and custom font loading.', inputSchema: z.object({ width: z.number(), height: z.number(), ops: z.array(OpSchema).describe('Ordered draw operations'), ...CommonOutput, }), callback: async (input) => { try { const result = await runJob({ width: input.width, height: input.height, ops: input.ops, format: input.format, quality: input.quality, fonts: input.fonts, }, input.timeout_ms ?? 15000) const base = { status: 'success', width: result.width, height: result.height, mimeType: result.mimeType, bytes: result.buffer.byteLength, fallback: !!result.fallback, op_errors: (result.opErrors || []).length ? result.opErrors : undefined, } if (input.returnAs === 'blobURL') { const url = URL.createObjectURL(new Blob([result.buffer], { type: result.mimeType })) return JSON.stringify({ ...base, blobURL: url }) } return JSON.stringify({ ...base, dataURL: bufferToDataURL(result.buffer, result.mimeType) }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // ---------- Render a sequence of frames ------------------------------------ export const offscreenRenderSequenceTool = tool({ name: 'offscreen_render_sequence', description: 'Render N frames of a parameterized scene. Useful for sprite sheets, animations, or GIF/WebM prep. ' + 'Pass ops as a template with {{t}}, {{i}}, {{frames}} placeholders — t goes 0..1, i goes 0..frames-1. ' + 'Returns either an array of per-frame data URLs (mode=frames) or a single horizontal sprite sheet (mode=sheet).', inputSchema: z.object({ width: z.number().describe('Per-frame width'), height: z.number().describe('Per-frame height'), frames: z.number().min(1).max(240).describe('How many frames to render'), ops: z.array(OpSchema).describe('Ops template. Numeric values may be strings with {{t}} / {{i}} / {{frames}} / arithmetic.'), mode: z.enum(['frames', 'sheet']).optional().describe('Default "sheet" (one image, frames laid out horizontally)'), ...CommonOutput, }), callback: async (input) => { try { const frameResults: ArrayBuffer[] = [] const allOpErrors: any[] = [] const mode = input.mode ?? 'sheet' // Substitute placeholders per frame. const substitute = (ops: any[], t: number, i: number, frames: number): any[] => { return ops.map((op) => { const copy: any = {} for (const [k, v] of Object.entries(op)) { if (typeof v === 'string' && v.includes('{{')) { // Evaluate simple expression: replace tokens then Function-eval. const expr = v.replace(/\{\{t\}\}/g, String(t)) .replace(/\{\{i\}\}/g, String(i)) .replace(/\{\{frames\}\}/g, String(frames)) try { copy[k] = Function('"use strict";return (' + expr + ')')() } catch { copy[k] = v } // keep literal string if eval fails } else if (Array.isArray(v)) { copy[k] = v.map((inner: any) => (typeof inner === 'string' && inner.includes('{{') ? (() => { const expr = inner.replace(/\{\{t\}\}/g, String(t)) .replace(/\{\{i\}\}/g, String(i)) .replace(/\{\{frames\}\}/g, String(frames)) try { return Function('"use strict";return (' + expr + ')')() } catch { return inner } })() : inner)) } else { copy[k] = v } } return copy }) } for (let i = 0; i < input.frames; i++) { const t = input.frames <= 1 ? 0 : i / (input.frames - 1) const subbed = substitute(input.ops, t, i, input.frames) const result = await runJob({ width: input.width, height: input.height, ops: subbed, format: 'png', quality: 1.0, fonts: input.fonts, }, input.timeout_ms ?? 15000) frameResults.push(result.buffer) if ((result.opErrors || []).length) { allOpErrors.push({ frame: i, errors: result.opErrors }) } } if (mode === 'frames') { const dataUrls = frameResults.map((buf) => bufferToDataURL(buf, 'image/png')) return JSON.stringify({ status: 'success', mode: 'frames', count: dataUrls.length, frames: dataUrls, op_errors: allOpErrors.length ? allOpErrors : undefined, }) } // Sheet mode: composite all frames into one horizontal strip. const sheetOps = frameResults.map((buf, i) => ({ op: 'image', url: bufferToDataURL(buf, 'image/png'), x: i * input.width, y: 0, w: input.width, h: input.height, })) const sheetResult = await runJob({ width: input.width * input.frames, height: input.height, ops: [{ op: 'fillBackground', style: 'rgba(0,0,0,0)' }, ...sheetOps], format: input.format ?? 'png', quality: input.quality ?? 1.0, }, input.timeout_ms ?? 30000) const mime = sheetResult.mimeType return JSON.stringify({ status: 'success', mode: 'sheet', frames: input.frames, frame_width: input.width, frame_height: input.height, total_width: input.width * input.frames, total_height: input.height, mimeType: mime, dataURL: bufferToDataURL(sheetResult.buffer, mime), op_errors: allOpErrors.length ? allOpErrors : undefined, }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // ---------- Chart preset --------------------------------------------------- export const offscreenChartTool = tool({ name: 'offscreen_chart', description: 'Render a quick line/bar/pie chart without hand-crafting ops. For richer features use Chart.js; this is for agent-friendly quick snapshots.', inputSchema: z.object({ type: z.enum(['line', 'bar', 'pie']), data: z.array(z.number()).describe('Values'), labels: z.array(z.string()).optional(), width: z.number().optional(), height: z.number().optional(), title: z.string().optional(), background: z.string().optional(), color: z.string().optional().describe('Primary accent color, default #6ee7b7'), ...CommonOutput, }), callback: async (input) => { try { const width = input.width ?? 640 const height = input.height ?? 360 const pad = 40 const titleHeight = input.title ? 32 : 0 const plotTop = pad + titleHeight const plotLeft = pad + 10 const plotW = width - pad * 2 - 10 const plotH = height - plotTop - pad const color = input.color ?? '#6ee7b7' const bg = input.background ?? '#0a0a0a' const fg = '#e5e7eb' const dim = '#6b7280' const ops: any[] = [{ op: 'fillBackground', style: bg }] if (input.title) { ops.push({ op: 'fillText', text: input.title, x: width / 2, y: 28, fillStyle: fg, font: '600 18px system-ui,sans-serif', textAlign: 'center', textBaseline: 'middle', }) } const data = input.data if (!data.length) { ops.push({ op: 'fillText', text: '(no data)', x: width / 2, y: height / 2, fillStyle: dim, font: '16px system-ui', textAlign: 'center', textBaseline: 'middle', }) } else if (input.type === 'line' || input.type === 'bar') { const max = Math.max(...data, 0) const min = Math.min(...data, 0) const range = max - min || 1 const barW = plotW / data.length // Axis ops.push( { op: 'stroke', style: dim }, { op: 'lineWidth', value: 1 }, { op: 'line', x1: plotLeft, y1: plotTop + plotH, x2: plotLeft + plotW, y2: plotTop + plotH }, { op: 'line', x1: plotLeft, y1: plotTop, x2: plotLeft, y2: plotTop + plotH }, ) // Max label ops.push({ op: 'fillText', text: String(Number(max.toFixed(2))), x: plotLeft - 6, y: plotTop, fillStyle: dim, font: '11px system-ui', textAlign: 'right', textBaseline: 'middle', }) ops.push({ op: 'fillText', text: String(Number(min.toFixed(2))), x: plotLeft - 6, y: plotTop + plotH, fillStyle: dim, font: '11px system-ui', textAlign: 'right', textBaseline: 'middle', }) if (input.type === 'bar') { for (let i = 0; i < data.length; i++) { const v = data[i] const h = ((v - min) / range) * plotH const x = plotLeft + i * barW + 2 const y = plotTop + plotH - h ops.push({ op: 'fillRect', x, y, w: barW - 4, h, fillStyle: color }) if (input.labels && input.labels[i]) { ops.push({ op: 'fillText', text: input.labels[i], x: x + (barW - 4) / 2, y: plotTop + plotH + 14, fillStyle: dim, font: '11px system-ui', textAlign: 'center', textBaseline: 'top', }) } } } else { // Line const points: [number, number][] = data.map((v, i) => { const x = plotLeft + (i + 0.5) * (plotW / data.length) const y = plotTop + plotH - ((v - min) / range) * plotH return [x, y] }) ops.push({ op: 'path', points, stroke: true, fill: false, strokeStyle: color, lineWidth: 2 }) for (const [x, y] of points) { ops.push({ op: 'circle', x, y, r: 3, fillStyle: color }) } } } else { // Pie const cx = width / 2 const cy = plotTop + plotH / 2 const r = Math.min(plotW, plotH) * 0.42 const total = data.reduce((a, b) => a + b, 0) || 1 const palette = ['#6ee7b7', '#60a5fa', '#f472b6', '#fbbf24', '#a78bfa', '#fb923c', '#34d399', '#c084fc'] let angle = -Math.PI / 2 for (let i = 0; i < data.length; i++) { const slice = (data[i] / total) * Math.PI * 2 const c = palette[i % palette.length] ops.push({ op: 'path', points: [[cx, cy]], stroke: false, fill: false, }) // Pie slices need real arcs, not paths — use arc op ops.push({ op: 'fill', style: c, }) ops.push({ op: 'arc', x: cx, y: cy, r, startAngle: angle, endAngle: angle + slice, fill: true, stroke: false, }) // Label const mid = angle + slice / 2 const lx = cx + Math.cos(mid) * (r + 18) const ly = cy + Math.sin(mid) * (r + 18) const label = input.labels?.[i] || String(data[i]) ops.push({ op: 'fillText', text: label, x: lx, y: ly, fillStyle: fg, font: '12px system-ui', textAlign: Math.cos(mid) > 0 ? 'left' : 'right', textBaseline: 'middle', }) angle += slice } } // Fix pie arc: the arc op needs its own path — inject beginPath+moveTo // Simpler: we'll let the arc op draw a complete pie slice using a temporary workaround: // rewrite pie ops to use a path() op with computed arc approximation. But for brevity, // leverage arc op properly. // (The arc op in the worker already calls ctx.beginPath() + ctx.arc; good enough for pie wedges.) const result = await runJob({ width, height, ops, format: input.format, quality: input.quality, fonts: input.fonts, }, input.timeout_ms ?? 15000) return JSON.stringify({ status: 'success', type: input.type, width, height, dataURL: bufferToDataURL(result.buffer, result.mimeType), fallback: !!result.fallback, }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // ---------- Badge preset --------------------------------------------------- export const offscreenBadgeTool = tool({ name: 'offscreen_badge', description: 'Render a shields.io-style flat badge. Two halves: label (left) + value (right). Returns a small PNG data URL.', inputSchema: z.object({ label: z.string(), value: z.string(), color: z.string().optional().describe('Right-half color (default "#2ea44f" green)'), label_color: z.string().optional().describe('Left-half color (default "#555")'), height: z.number().optional().describe('Default 20'), font: z.string().optional(), scale: z.number().optional().describe('Integer upscale for crispness on HiDPI (default 2)'), ...CommonOutput, }), callback: async (input) => { try { const scale = input.scale ?? 2 const h = (input.height ?? 20) * scale const font = (input.font ?? '600 11px system-ui,sans-serif').replace(/(\d+)px/, (_, n) => `${parseInt(n) * scale}px`) const padX = 6 * scale // Estimate text width: 6.5px per char at 11px baseline — rough but good enough. const charWidth = 6.5 * scale const labelW = input.label.length * charWidth + padX * 2 const valueW = input.value.length * charWidth + padX * 2 const width = labelW + valueW const fg = '#fff' const ops: any[] = [ { op: 'fillBackground', style: 'rgba(0,0,0,0)' }, { op: 'fillRect', x: 0, y: 0, w: labelW, h, fillStyle: input.label_color ?? '#555' }, { op: 'fillRect', x: labelW, y: 0, w: valueW, h, fillStyle: input.color ?? '#2ea44f' }, { op: 'fillText', text: input.label, x: labelW / 2, y: h / 2, fillStyle: fg, font, textAlign: 'center', textBaseline: 'middle', }, { op: 'fillText', text: input.value, x: labelW + valueW / 2, y: h / 2, fillStyle: fg, font, textAlign: 'center', textBaseline: 'middle', }, ] const result = await runJob({ width, height: h, ops, format: input.format, quality: input.quality, fonts: input.fonts, }, input.timeout_ms ?? 8000) return JSON.stringify({ status: 'success', label: input.label, value: input.value, width, height: h, dataURL: bufferToDataURL(result.buffer, result.mimeType), fallback: !!result.fallback, }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // ---------- Ops reference -------------------------------------------------- const OPS_REFERENCE: Array<{ op: string; params: string; example?: any }> = [ { op: 'clear', params: '(none)', example: { op: 'clear' } }, { op: 'fillBackground', params: '{ style }', example: { op: 'fillBackground', style: '#0a0a0a' } }, { op: 'fill', params: '{ style }', example: { op: 'fill', style: '#6ee7b7' } }, { op: 'stroke', params: '{ style }', example: { op: 'stroke', style: '#fff' } }, { op: 'lineWidth', params: '{ value }', example: { op: 'lineWidth', value: 2 } }, { op: 'lineCap', params: '{ value: "butt|round|square" }' }, { op: 'lineJoin', params: '{ value: "miter|round|bevel" }' }, { op: 'lineDash', params: '{ value: [dashOn, dashOff] }' }, { op: 'font', params: '{ value }', example: { op: 'font', value: '600 18px system-ui' } }, { op: 'textAlign', params: '{ value: "left|right|center|start|end" }' }, { op: 'textBaseline', params: '{ value: "top|middle|bottom|..." }' }, { op: 'globalAlpha', params: '{ value: 0..1 }' }, { op: 'shadow', params: '{ color, blur, x, y }' }, { op: 'fillRect', params: '{ x, y, w, h }' }, { op: 'strokeRect', params: '{ x, y, w, h }' }, { op: 'roundRect', params: '{ x, y, w, h, r, fill?, stroke? }' }, { op: 'circle', params: '{ x, y, r, fill?, stroke? }' }, { op: 'arc', params: '{ x, y, r, startAngle, endAngle, anticlockwise?, fill?, stroke? }' }, { op: 'line', params: '{ x1, y1, x2, y2 }' }, { op: 'path', params: '{ points: [[x,y]...], fill?, stroke?, close? }' }, { op: 'bezier', params: '{ x1, y1, cx1, cy1, cx2, cy2, x2, y2 }' }, { op: 'fillText', params: '{ text, x, y, maxWidth? }' }, { op: 'strokeText', params: '{ text, x, y, maxWidth? }' }, { op: 'linearGradient', params: '{ x1, y1, x2, y2, stops: [[0..1, color]...], rect? }' }, { op: 'radialGradient', params: '{ x1, y1, r1, x2, y2, r2, stops, rect? }' }, { op: 'clipRect', params: '{ x, y, w, h }' }, { op: 'rotate', params: '{ angle }' }, { op: 'translate', params: '{ x, y }' }, { op: 'scale', params: '{ x, y }' }, { op: 'save', params: '(none)' }, { op: 'restore', params: '(none)' }, { op: 'image', params: '{ url (data URL), x, y, w?, h? }' }, ] export const offscreenOpsReferenceTool = tool({ name: 'offscreen_ops_reference', description: 'List all supported ops for offscreen_render with their parameters and examples. Call this FIRST when crafting a scene.', inputSchema: z.object({}), callback: async () => { return JSON.stringify({ status: 'success', count: OPS_REFERENCE.length, ops: OPS_REFERENCE, per_op_overrides: [ 'fillStyle', 'strokeStyle', 'lineWidth', 'font', 'textAlign', 'textBaseline', 'globalAlpha', 'shadow: { color, blur, x, y }', ], placeholders_in_sequence: ['{{t}} (0..1)', '{{i}} (frame index)', '{{frames}} (total count)'], }) }, }) export const OFFSCREEN_CANVAS_TOOLS = [ offscreenRenderTool, offscreenRenderSequenceTool, offscreenChartTool, offscreenBadgeTool, offscreenOpsReferenceTool, ]