From 6bc63496553efba2fe42847ca4b3e5c751e7ea1a Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 3 Jun 2026 12:20:09 +0100 Subject: [PATCH 1/3] React export rework --- app/components/ascii-art-generator.tsx | 4 + app/components/ascii-preview.tsx | 60 ++++- app/components/asset-export.tsx | 66 ++++++ app/lib/core/text-renderer.ts | 128 +++++++++++ app/lib/react-export.ts | 295 +++++++++++++++++++++++++ sandbox/index.html | 158 +++++++++++++ vercel.json | 3 +- vite.config.ts | 28 ++- 8 files changed, 728 insertions(+), 14 deletions(-) create mode 100644 app/lib/core/text-renderer.ts create mode 100644 app/lib/react-export.ts create mode 100644 sandbox/index.html diff --git a/app/components/ascii-art-generator.tsx b/app/components/ascii-art-generator.tsx index 6ffed67..de08c4b 100644 --- a/app/components/ascii-art-generator.tsx +++ b/app/components/ascii-art-generator.tsx @@ -805,6 +805,10 @@ export function AsciiArtGenerator() { }} disabled={!program} exportSettings={settings.export} + sourceCode={settings.source.code} + characterSet={settings.output.characterSet} + frameRate={settings.animation.frameRate} + esbuildService={esbuildService} />
diff --git a/app/components/ascii-preview.tsx b/app/components/ascii-preview.tsx index 1852f27..b719bfb 100644 --- a/app/components/ascii-preview.tsx +++ b/app/components/ascii-preview.tsx @@ -145,19 +145,58 @@ export function AsciiPreview({ const [autoFit, setAutoFit] = useState(true) const prevDimensionsRef = useRef(dimensions) - const containerSize = useSize(container) + // Mirror zoom/position in refs so rapid wheel events accumulate from the + // latest values rather than a stale render closure. + const zoomRef = useRef(zoomLevel) + const positionRef = useRef(position) + useEffect(() => { + zoomRef.current = zoomLevel + }, [zoomLevel]) + useEffect(() => { + positionRef.current = position + }, [position]) - const handleWheel = (e: React.WheelEvent) => { - setAutoFit(false) + const containerSize = useSize(container) - const zoomFactor = 0.035 * (e.deltaY > 0 ? 1 : 1.1) + // Zoom toward the cursor (Figma-style) on scroll wheel. Attached as a + // non-passive native listener so preventDefault stops the container from + // also scrolling. + useEffect(() => { + if (!container) return + + const onWheel = (e: WheelEvent) => { + e.preventDefault() + setAutoFit(false) + + const rect = container.getBoundingClientRect() + // Cursor position relative to the container center (the transform origin). + const mouseX = e.clientX - rect.left - rect.width / 2 + const mouseY = e.clientY - rect.top - rect.height / 2 + + const currentZoom = zoomRef.current + const currentPos = positionRef.current + + const zoomFactor = 0.035 * (e.deltaY > 0 ? 1 : 1.1) + const newZoom = + e.deltaY < 0 + ? Math.min(currentZoom * (1 + zoomFactor), 3) + : Math.max(currentZoom / (1 + zoomFactor), 0.5) + + const ratio = newZoom / currentZoom + const newPos = { + x: mouseX * (1 - ratio) + ratio * currentPos.x, + y: mouseY * (1 - ratio) + ratio * currentPos.y, + } - if (e.deltaY < 0) { - setZoomLevel((prev) => Math.min(prev * (1 + zoomFactor), 3)) - } else { - setZoomLevel((prev) => Math.max(prev / (1 + zoomFactor), 0.5)) + zoomRef.current = newZoom + positionRef.current = newPos + setZoomLevel(newZoom) + setPosition(newPos) } - } + + container.addEventListener('wheel', onWheel, { passive: false }) + return () => container.removeEventListener('wheel', onWheel) + }, [container]) const handleMouseDown = (e: React.MouseEvent) => { setIsDragging(true) @@ -314,7 +353,6 @@ export function AsciiPreview({ {/* ASCII preview container */}
)}
( animationLength > 1 ? 'frames' : 'png', @@ -667,6 +678,52 @@ export function AssetExport({ toast.success('Export complete!', { id: 'video-export' }) } + const buildReactComponentSource = async (): Promise => { + if (!program || !esbuildService) return null + return generateReactComponentSource({ + esbuildService, + code: sourceCode, + characterSet, + columns: dimensions.width, + rows: dimensions.height, + animationLength, + fps: frameRate, + settings: exportSettings, + }) + } + + const exportReactComponent = async (mode: 'download' | 'copy') => { + if (!program) return + if (!esbuildService) { + toast('esbuild is still initializing — try again in a moment') + return + } + try { + setIsExporting(true) + toast.loading('Bundling React component…', { id: 'react-export' }) + const source = await buildReactComponentSource() + if (!source) { + toast('Could not generate React component', { id: 'react-export' }) + return + } + if (mode === 'download') { + const blob = new Blob([source], { type: 'text/typescript;charset=utf-8' }) + saveAs(blob, 'ascii-art.tsx') + toast.success('React component downloaded', { id: 'react-export' }) + } else { + await navigator.clipboard.writeText(source) + toast.success('React component copied to clipboard', { id: 'react-export' }) + } + } catch (error) { + console.error('Error exporting React component:', error) + toast.error('Failed to export React component', { id: 'react-export' }) + } finally { + setIsExporting(false) + } + } + + const reactExportDisabled = isExporting || disabled || !esbuildService + // Copy with cmd+c useHotkeys('meta+c', () => copyText(), { preventDefault: true }, []) @@ -821,6 +878,15 @@ export function AssetExport({ Copy SVG
+ + exportReactComponent('download')} + disabled={reactExportDisabled} + > + Download React +
) diff --git a/app/lib/core/text-renderer.ts b/app/lib/core/text-renderer.ts new file mode 100644 index 0000000..1cac221 --- /dev/null +++ b/app/lib/core/text-renderer.ts @@ -0,0 +1,128 @@ +/* + * This Source Code Form is subject to the terms of the Apache License, + * v. 2.0. If a copy of the license was not distributed with this file, you can + * obtain one at https://github.com/ertdfgcvb/play.core/blob/master/LICENSE. + * + * Modified from https://github.com/ertdfgcvb/play.core + * Copyright ertdfgcvb (Andreas Gysin) + */ +import { invariant, type Cell, type Context } from '../animation' + +export default function createRenderer() { + const backBuffer: Cell[] = [] + let cols: number, rows: number + + function render(context: Context, buffer: Cell[]): void { + const element = context.settings.element + + invariant(!!element, 'Element is required') + + // Detect resize and validate dimensions + if (context.rows !== rows || context.cols !== cols) { + // Validate dimensions + if ( + context.rows <= 0 || + context.cols <= 0 || + !isFinite(context.rows) || + !isFinite(context.cols) + ) { + console.error(`Invalid dimensions: ${context.cols} x ${context.rows}`) + return + } + + cols = context.cols + rows = context.rows + backBuffer.length = 0 + } + + // DOM rows update: expand lines if necessary + while (element.childElementCount < rows) { + const span = document.createElement('span') + span.style.display = 'block' + element.appendChild(span) + } + + // DOM rows update: shorten lines if necessary + while (element.childElementCount > rows) { + const lastChild = element.lastChild + if (lastChild) element.removeChild(lastChild) + } + + // A bit of a cumbersome render-loop… + // A few notes: the fastest way I found to render the image + // is by manually write the markup into the parent node via .innerHTML; + // creating a node via .createElement and then popluate it resulted + // remarkably slower (even if more elegant for the CSS handling below). + for (let j = 0; j < rows; j++) { + const offs = j * cols + + // This check is faster than to force update the DOM. + // Buffer can be manually modified in pre, main and after + // with semi-arbitrary values… + // It is necessary to keep track of the previous state + // and specifically check if a change in style + // or char happened on the whole row. + let rowNeedsUpdate = false + for (let i = 0; i < cols; i++) { + const idx = i + offs + if (idx >= buffer.length) { + continue + } + + const newCell = buffer[idx] + const oldCell = backBuffer[idx] + if (!isSameCell(newCell, oldCell)) { + rowNeedsUpdate = true + backBuffer[idx] = { ...newCell } + } + } + + // Skip row if update is not necessary + if (rowNeedsUpdate === false) continue + + let html = '' // Accumulates the markup + let openColor: string | null = null // colour of the currently open , or null + + for (let i = 0; i < cols; i++) { + const idx = i + offs + if (idx >= buffer.length) continue + + const currCell = buffer[idx] + const color = currCell.color || null + + // Open / close colour spans only when the colour changes, so a run of + // same-coloured cells shares a single span. Uncoloured cells fall back + // to the stock text colour set on the container. + if (color !== openColor) { + if (openColor !== null) html += '' + if (color !== null) html += `` + openColor = color + } + + html += currCell.char || ' ' + } + if (openColor !== null) { + html += '' + } + + // Write the row + if (j < element.childElementCount) { + const childNode = element.childNodes[j] as HTMLSpanElement + childNode.innerHTML = html + } + } + } + + // Move helper functions inside closure to access backBuffer + function isSameCell(cellA: Cell | undefined, cellB: Cell | undefined): boolean { + if (typeof cellA !== 'object') return false + if (typeof cellB !== 'object') return false + if (cellA?.char !== cellB?.char) return false + if (cellA?.color !== cellB?.color) return false + return true + } + + return { + render, + } +} diff --git a/app/lib/react-export.ts b/app/lib/react-export.ts new file mode 100644 index 0000000..b706466 --- /dev/null +++ b/app/lib/react-export.ts @@ -0,0 +1,295 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { BuildResult, Plugin } from 'esbuild-wasm' + +// The runtime is pulled in as source (Vite `?raw`) and handed to esbuild-wasm so +// the generated component bundles the *program*, not pre-rendered frames. Keeping +// these as raw imports means the export always tracks the real runtime files. +import animationSource from '~/lib/animation.ts?raw' +import fpsSource from '~/lib/core/fps.ts?raw' +import textRendererSource from '~/lib/core/text-renderer.ts?raw' +import * as localUtils from '~/lib/localUtils' + +import { CHAR_WIDTH } from '~/components/dimension-utils' +import type { EsbuildService } from '~/hooks/use-esbuild' + +/** Matches the canvas/SVG export + preview. */ +const ASCII_FONT_SIZE_PX = 12 +const ASCII_LINE_HEIGHT_RATIO = 1.2 + +export interface ReactExportVisualSettings { + textColor: string + backgroundColor: string + /** Cell padding (same units as Mitos export options). */ + padding: number +} + +export interface GenerateReactComponentSourceParams { + esbuildService: EsbuildService + /** The user's program source (settings.source.code). */ + code: string + /** Character set exposed to the program via `@/settings`. */ + characterSet: string + columns: number + rows: number + /** Number of frames the animation loops over; <= 1 renders a single frame. */ + animationLength: number + fps: number + settings: ReactExportVisualSettings + /** PascalCase component name; invalid values fall back to AsciiArtEmbed. */ + componentName?: string +} + +function sanitizeComponentName(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_]/g, '') + if (!cleaned || /^[0-9]/.test(cleaned)) { + return 'AsciiArtEmbed' + } + return cleaned +} + +function isUrl(path: string): boolean { + return path.startsWith('https://') || path.startsWith('http://') +} + +// Virtual source files the runtime bundle is assembled from. The keys are the +// canonical module names the resolver maps every import specifier onto. +function buildVirtualModules(params: { + code: string + characterSet: string + textColor: string + backgroundColor: string +}): Record { + // Reuse the in-app convention: stringify each util fn so the bundle behaves + // exactly like the live preview's `@/utils`. + const utilsExports = Object.entries(localUtils) + .filter(([, value]) => typeof value === 'function') + .map(([key, fn]) => `export const ${key} = ${(fn as () => unknown).toString()};`) + .join('\n') + + const program = `${params.code} + +const _main = typeof main !== 'undefined' ? main : undefined; +const _boot = typeof boot !== 'undefined' ? boot : undefined; +const _pre = typeof pre !== 'undefined' ? pre : undefined; +const _post = typeof post !== 'undefined' ? post : undefined; +export { _main as main, _boot as boot, _pre as pre, _post as post };` + + const entry = `import { createAnimation } from 'mitos:animation' +import * as program from 'mitos:program' + +export function mount(element, options) { + return createAnimation(program, { element, ...options }) +}` + + return { + 'mitos:entry': entry, + 'mitos:animation': animationSource, + // animation.ts renders through ./core/canvas-renderer; swap in the DOM/text + // renderer so the exported component draws into a
 instead of a canvas.
+    'mitos:renderer': textRendererSource,
+    'mitos:fps': fpsSource,
+    'mitos:program': program,
+    'mitos:utils': `// Pattern generation utilities\n${utilsExports}`,
+    'mitos:settings': `export const characterSet = ${JSON.stringify(params.characterSet)};
+export const textColor = ${JSON.stringify(params.textColor)};
+export const backgroundColor = ${JSON.stringify(params.backgroundColor)};
+export const settings = {};`,
+    'mitos:imageData': `export const imageData = {};\nexport const frames = null;`,
+  }
+}
+
+// Maps every import specifier seen during the build onto one of the virtual
+// module names above, or returns null for things that should come from unpkg.
+function resolveVirtualName(specifier: string): string | null {
+  if (specifier === 'mitos:entry') return 'mitos:entry'
+  if (specifier === 'mitos:program') return 'mitos:program'
+  if (specifier === 'mitos:animation' || /(^|\/)animation(\.ts)?$/.test(specifier))
+    return 'mitos:animation'
+  if (/canvas-renderer(\.ts)?$/.test(specifier)) return 'mitos:renderer'
+  if (/(^|\/)fps(\.ts)?$/.test(specifier)) return 'mitos:fps'
+  if (specifier === '@/utils') return 'mitos:utils'
+  if (specifier === '@/settings') return 'mitos:settings'
+  if (specifier === '@/imageData') return 'mitos:imageData'
+  return null
+}
+
+function createBundlePlugin(modules: Record): Plugin {
+  const moduleCache = new Map()
+
+  return {
+    name: 'mitos-react-export',
+    setup(build) {
+      // Internal virtual modules (runtime, program, injected globals).
+      build.onResolve({ filter: /.*/ }, (args) => {
+        const name = resolveVirtualName(args.path)
+        if (name) return { path: name, namespace: 'mitos' }
+
+        // Everything else is an npm/url import: resolve through unpkg and fetch.
+        if (isUrl(args.path)) return { path: args.path, namespace: 'http' }
+        return { path: `https://unpkg.com/${args.path}?module`, namespace: 'http' }
+      })
+
+      build.onLoad({ filter: /.*/, namespace: 'mitos' }, (args) => ({
+        loader: 'ts',
+        contents: modules[args.path] ?? '',
+      }))
+
+      // Resolve relative imports inside fetched packages against their URL.
+      build.onResolve({ filter: /.*/, namespace: 'http' }, (args) => {
+        if (isUrl(args.path)) return { path: args.path, namespace: 'http' }
+        return {
+          path: new URL(args.path, 'https://unpkg.com' + args.resolveDir + '/').href,
+          namespace: 'http',
+        }
+      })
+
+      build.onLoad({ filter: /.*/, namespace: 'http' }, async (args) => {
+        const cached = moduleCache.get(args.path)
+        if (cached) {
+          return {
+            loader: 'ts',
+            contents: cached,
+            resolveDir: new URL('./', args.path).pathname,
+          }
+        }
+        const response = await fetch(args.path)
+        if (!response.ok) {
+          throw new Error(`Failed to load ${args.path}: HTTP ${response.status}`)
+        }
+        const contents = await response.text()
+        moduleCache.set(args.path, contents)
+        return {
+          loader: 'ts',
+          contents,
+          resolveDir: new URL('./', response.url).pathname,
+        }
+      })
+    },
+  }
+}
+
+async function bundleRuntime(
+  esbuildService: EsbuildService,
+  modules: Record,
+): Promise {
+  const result: BuildResult = await esbuildService.build({
+    entryPoints: ['mitos:entry'],
+    bundle: true,
+    minify: true,
+    format: 'iife',
+    globalName: '__mitosRuntime',
+    platform: 'browser',
+    target: 'es2020',
+    write: false,
+    treeShaking: true,
+    plugins: [createBundlePlugin(modules)],
+    define: {
+      global: 'globalThis',
+      'process.env.NODE_ENV': '"production"',
+    },
+  })
+
+  if (!result.outputFiles || result.outputFiles.length === 0) {
+    throw new Error('esbuild produced no output')
+  }
+  return result.outputFiles[0].text.trim()
+}
+
+/**
+ * Produce a self-contained .tsx source string that runs the user's program live
+ * (via the bundled Mitos runtime + text renderer) inside a 
. The runtime is
+ * a small fixed cost; nothing is fetched at runtime and no frames are baked.
+ */
+export async function generateReactComponentSource(
+  params: GenerateReactComponentSourceParams,
+): Promise {
+  const componentName = sanitizeComponentName(params.componentName ?? 'AsciiArtEmbed')
+  const animated = params.animationLength > 1
+
+  const runtimeBundle = await bundleRuntime(
+    params.esbuildService,
+    buildVirtualModules({
+      code: params.code,
+      characterSet: params.characterSet,
+      textColor: params.settings.textColor,
+      backgroundColor: params.settings.backgroundColor,
+    }),
+  )
+
+  const paddingPx = params.settings.padding * CHAR_WIDTH
+  const lineHeightPx = ASCII_FONT_SIZE_PX * ASCII_LINE_HEIGHT_RATIO
+
+  const style = [
+    '{',
+    `    margin: 0,`,
+    `    color: ${JSON.stringify(params.settings.textColor)},`,
+    `    backgroundColor: ${JSON.stringify(params.settings.backgroundColor)},`,
+    `    padding: ${paddingPx},`,
+    `    fontFamily: ${JSON.stringify('GT America Mono, ui-monospace, monospace')},`,
+    `    fontSize: ${ASCII_FONT_SIZE_PX},`,
+    `    lineHeight: '${lineHeightPx}px',`,
+    `    whiteSpace: 'pre' as const,`,
+    `  }`,
+  ].join('\n')
+
+  return `/*
+ * Generated by Mitos (https://github.com/oxidecomputer/mitos).
+ * Self-contained: the ASCII program and its runtime are bundled below — nothing
+ * is fetched at runtime. Requires React 18+ and "jsx": "react-jsx".
+ * GT America Mono may need a separate font license; ui-monospace/monospace are fallbacks.
+ */
+/* eslint-disable */
+// @ts-nocheck
+// prettier-ignore
+import { useEffect, useRef } from 'react'
+
+// --- Mitos runtime + program (generated, do not edit) ----------------------
+${runtimeBundle}
+// ---------------------------------------------------------------------------
+
+const COLS = ${params.columns}
+const ROWS = ${params.rows}
+const FPS = ${Math.round(params.fps)}
+const FRAMES = ${animated ? params.animationLength : 1}
+
+export interface ${componentName}Props {
+  className?: string
+}
+
+export function ${componentName}({ className }: ${componentName}Props) {
+  const ref = useRef(null)
+
+  useEffect(() => {
+    const element = ref.current
+    if (!element) return
+
+    const controller = __mitosRuntime.mount(element, {
+      cols: COLS,
+      rows: ROWS,
+      fps: FPS,
+      maxFrames: FRAMES,
+    })
+
+    ${animated ? 'controller.togglePlay(true)' : 'controller.setFrame(0)'}
+    return () => controller.cleanup()
+  }, [])
+
+  return (
+    
+  )
+}
+
+export default ${componentName}
+`
+}
diff --git a/sandbox/index.html b/sandbox/index.html
new file mode 100644
index 0000000..c98ee43
--- /dev/null
+++ b/sandbox/index.html
@@ -0,0 +1,158 @@
+
+
+  
+    
+    
+    Mitos React Export Sandbox
+
+    
+    
+
+    
+  
+  
+    
+ +
+ + + + diff --git a/vercel.json b/vercel.json index 6dbaf1c..d95d62c 100644 --- a/vercel.json +++ b/vercel.json @@ -4,6 +4,7 @@ "source": "/js/viewscript.js", "destination": "https://trck.oxide.computer/js/plausible.js" }, - { "source": "/api/event", "destination": "https://trck.oxide.computer/api/event" } + { "source": "/api/event", "destination": "https://trck.oxide.computer/api/event" }, + { "source": "/sandbox", "destination": "/sandbox/index.html" } ] } diff --git a/vite.config.ts b/vite.config.ts index 8bb2bfb..9ca0517 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,11 +7,35 @@ */ import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import { resolve } from 'node:path' +import { defineConfig, type Plugin } from 'vite' import tsconfigPaths from 'vite-tsconfig-paths' +// Serve the /sandbox page (no trailing slash) in dev. Vite's static handling +// only resolves the directory index for `/sandbox/`, so rewrite the bare path. +// The deployed equivalent lives in vercel.json. +function sandboxCleanUrl(): Plugin { + return { + name: 'sandbox-clean-url', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + if (req.url === '/sandbox') req.url = '/sandbox/' + next() + }) + }, + } +} + // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tsconfigPaths()], + plugins: [react(), tsconfigPaths(), sandboxCleanUrl()], server: { port: 3000 }, + build: { + rollupOptions: { + input: { + main: resolve(import.meta.dirname, 'index.html'), + sandbox: resolve(import.meta.dirname, 'sandbox/index.html'), + }, + }, + }, }) From 40713ec7a9554a851fd21706e37428e8d3945d69 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 3 Jun 2026 13:11:10 +0100 Subject: [PATCH 2/3] Image support in React export --- app/components/ascii-art-generator.tsx | 2 + app/components/asset-export.tsx | 8 +++ app/lib/react-export.ts | 94 +++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/app/components/ascii-art-generator.tsx b/app/components/ascii-art-generator.tsx index de08c4b..4976328 100644 --- a/app/components/ascii-art-generator.tsx +++ b/app/components/ascii-art-generator.tsx @@ -809,6 +809,8 @@ export function AsciiArtGenerator() { characterSet={settings.output.characterSet} frameRate={settings.animation.frameRate} esbuildService={esbuildService} + imageData={currentImageData} + frames={currentFrames} />
diff --git a/app/components/asset-export.tsx b/app/components/asset-export.tsx index b047d2c..469cb7e 100644 --- a/app/components/asset-export.tsx +++ b/app/components/asset-export.tsx @@ -15,6 +15,7 @@ import { toast } from 'sonner' import type { EsbuildService } from '~/hooks/use-esbuild' import type { Cell, Program } from '~/lib/animation' +import type { AsciiImageData } from '~/lib/types' import { getColoredRows, getContent } from '~/lib/buffer-text' import { generateReactComponentSource } from '~/lib/react-export' import { glyphRunToPathData, loadAsciiFont, type Font } from '~/lib/svg-font' @@ -55,6 +56,9 @@ interface AssetExportProps { characterSet: string frameRate: number esbuildService: EsbuildService | null + // Processed 0–1 value grid(s) for image/GIF sources, baked into the export. + imageData: AsciiImageData | null + frames: AsciiImageData[] | null } export function AssetExport({ @@ -70,6 +74,8 @@ export function AssetExport({ characterSet, frameRate, esbuildService, + imageData, + frames, }: AssetExportProps) { const [exportFormat, setExportFormat] = useState( animationLength > 1 ? 'frames' : 'png', @@ -689,6 +695,8 @@ export function AssetExport({ animationLength, fps: frameRate, settings: exportSettings, + imageData: imageData ?? undefined, + frames, }) } diff --git a/app/lib/react-export.ts b/app/lib/react-export.ts index b706466..8b92069 100644 --- a/app/lib/react-export.ts +++ b/app/lib/react-export.ts @@ -17,6 +17,16 @@ import * as localUtils from '~/lib/localUtils' import { CHAR_WIDTH } from '~/components/dimension-utils' import type { EsbuildService } from '~/hooks/use-esbuild' +import type { AsciiImageData } from '~/lib/types' + +// Image grids are baked as one byte per cell (a printable char), not JSON +// numbers — this strips all the array punctuation and decimals, which dominates +// the size for multi-frame (GIF) sources. Each cell is quantised to one of +// IMAGE_LEVELS brightness steps. The char range [IMAGE_CHAR_BASE, +LEVELS) stays +// inside printable ASCII and avoids `"`(34) and `\`(92), so the packed strings +// never need JSON escaping (guaranteed exactly one byte per cell). +const IMAGE_CHAR_BASE = 35 +const IMAGE_LEVELS = 57 /** Matches the canvas/SVG export + preview. */ const ASCII_FONT_SIZE_PX = 12 @@ -43,6 +53,71 @@ export interface GenerateReactComponentSourceParams { settings: ReactExportVisualSettings /** PascalCase component name; invalid values fall back to AsciiArtEmbed. */ componentName?: string + /** + * Processed per-cell 0–1 values for image sources, exposed to the program via + * `@/imageData`. We bake this derived grid — not the source image — so image + * exports stay small. Omit for purely generative programs. + */ + imageData?: AsciiImageData + /** One value grid per frame for animated (GIF) image sources. */ + frames?: AsciiImageData[] | null +} + +// Pack the sparse `{ [x]: { [y]: number } }` grid into a flat string, one byte +// per cell, column-major (so index = x * rows + y). Decoded back to a dense +// `number[][]` at runtime (see imageDataModule), which is what getImageValue() +// reads via `data[x][y]`. +function encodeGrid( + data: AsciiImageData | undefined, + columns: number, + rows: number, +): string { + const max = IMAGE_LEVELS - 1 + let out = '' + for (let x = 0; x < columns; x++) { + const source = data?.[x] + for (let y = 0; y < rows; y++) { + const value = source?.[y] + const clamped = value === undefined ? 0 : value < 0 ? 0 : value > 1 ? 1 : value + out += String.fromCharCode(IMAGE_CHAR_BASE + Math.round(clamped * max)) + } + } + return out +} + +// The `@/imageData` module source: packed strings plus a decoder that rebuilds +// the 0–1 `number[][]` grids the program expects. Empty for generative sources. +function imageDataModule(params: { + columns: number + rows: number + imageData?: AsciiImageData + frames?: AsciiImageData[] | null +}): string { + if (!params.imageData) { + return `export const imageData = {};\nexport const frames = null;` + } + + const img = JSON.stringify(encodeGrid(params.imageData, params.columns, params.rows)) + const frames = + params.frames && params.frames.length > 0 + ? `[${params.frames.map((f) => JSON.stringify(encodeGrid(f, params.columns, params.rows))).join(',')}]` + : 'null' + + return `const __COLS = ${params.columns}, __ROWS = ${params.rows} +const __BASE = ${IMAGE_CHAR_BASE}, __MAX = ${IMAGE_LEVELS - 1} +function __decode(s) { + const grid = [] + let i = 0 + for (let x = 0; x < __COLS; x++) { + const col = new Array(__ROWS) + for (let y = 0; y < __ROWS; y++) col[y] = (s.charCodeAt(i++) - __BASE) / __MAX + grid.push(col) + } + return grid +} +const __frames = ${frames} +export const imageData = __decode(${img}) +export const frames = __frames === null ? null : __frames.map(__decode)` } function sanitizeComponentName(name: string): string { @@ -64,6 +139,10 @@ function buildVirtualModules(params: { characterSet: string textColor: string backgroundColor: string + columns: number + rows: number + imageData?: AsciiImageData + frames?: AsciiImageData[] | null }): Record { // Reuse the in-app convention: stringify each util fn so the bundle behaves // exactly like the live preview's `@/utils`. @@ -100,7 +179,12 @@ export function mount(element, options) { export const textColor = ${JSON.stringify(params.textColor)}; export const backgroundColor = ${JSON.stringify(params.backgroundColor)}; export const settings = {};`, - 'mitos:imageData': `export const imageData = {};\nexport const frames = null;`, + 'mitos:imageData': imageDataModule({ + columns: params.columns, + rows: params.rows, + imageData: params.imageData, + frames: params.frames, + }), } } @@ -204,7 +288,9 @@ async function bundleRuntime( /** * Produce a self-contained .tsx source string that runs the user's program live * (via the bundled Mitos runtime + text renderer) inside a
. The runtime is
- * a small fixed cost; nothing is fetched at runtime and no frames are baked.
+ * a small fixed cost and nothing is fetched at runtime. For image sources the
+ * derived 0–1 value grid is baked in (not the source image), so the program
+ * runs live against it just like the in-app preview.
  */
 export async function generateReactComponentSource(
   params: GenerateReactComponentSourceParams,
@@ -219,6 +305,10 @@ export async function generateReactComponentSource(
       characterSet: params.characterSet,
       textColor: params.settings.textColor,
       backgroundColor: params.settings.backgroundColor,
+      columns: params.columns,
+      rows: params.rows,
+      imageData: params.imageData,
+      frames: params.frames,
     }),
   )
 

From cf21535ef5dc27b95390e48b139fb68e3b8075f6 Mon Sep 17 00:00:00 2001
From: benjaminleonard 
Date: Wed, 3 Jun 2026 13:11:31 +0100
Subject: [PATCH 3/3] Fmt

---
 app/components/asset-export.tsx | 2 +-
 app/lib/react-export.ts         | 5 ++---
 vite.config.ts                  | 2 +-
 3 files changed, 4 insertions(+), 5 deletions(-)

diff --git a/app/components/asset-export.tsx b/app/components/asset-export.tsx
index 469cb7e..0337944 100644
--- a/app/components/asset-export.tsx
+++ b/app/components/asset-export.tsx
@@ -15,10 +15,10 @@ import { toast } from 'sonner'
 
 import type { EsbuildService } from '~/hooks/use-esbuild'
 import type { Cell, Program } from '~/lib/animation'
-import type { AsciiImageData } from '~/lib/types'
 import { getColoredRows, getContent } from '~/lib/buffer-text'
 import { generateReactComponentSource } from '~/lib/react-export'
 import { glyphRunToPathData, loadAsciiFont, type Font } from '~/lib/svg-font'
+import type { AsciiImageData } from '~/lib/types'
 import { InputButton, InputNumber, InputSwitch } from '~/lib/ui/src'
 import { InputSelect } from '~/lib/ui/src/components/InputSelect/InputSelect'
 
diff --git a/app/lib/react-export.ts b/app/lib/react-export.ts
index 8b92069..d82ba6d 100644
--- a/app/lib/react-export.ts
+++ b/app/lib/react-export.ts
@@ -7,6 +7,8 @@
  */
 import type { BuildResult, Plugin } from 'esbuild-wasm'
 
+import { CHAR_WIDTH } from '~/components/dimension-utils'
+import type { EsbuildService } from '~/hooks/use-esbuild'
 // The runtime is pulled in as source (Vite `?raw`) and handed to esbuild-wasm so
 // the generated component bundles the *program*, not pre-rendered frames. Keeping
 // these as raw imports means the export always tracks the real runtime files.
@@ -14,9 +16,6 @@ import animationSource from '~/lib/animation.ts?raw'
 import fpsSource from '~/lib/core/fps.ts?raw'
 import textRendererSource from '~/lib/core/text-renderer.ts?raw'
 import * as localUtils from '~/lib/localUtils'
-
-import { CHAR_WIDTH } from '~/components/dimension-utils'
-import type { EsbuildService } from '~/hooks/use-esbuild'
 import type { AsciiImageData } from '~/lib/types'
 
 // Image grids are baked as one byte per cell (a printable char), not JSON
diff --git a/vite.config.ts b/vite.config.ts
index 9ca0517..056406b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,8 +6,8 @@
  * Copyright Oxide Computer Company
  */
 
-import react from '@vitejs/plugin-react'
 import { resolve } from 'node:path'
+import react from '@vitejs/plugin-react'
 import { defineConfig, type Plugin } from 'vite'
 import tsconfigPaths from 'vite-tsconfig-paths'