diff --git a/.gitignore b/.gitignore index a469996..a37ffca 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,9 @@ dist-ssr claude.md package-lock.json + +# Playwright (baselines under e2e/**-snapshots are committed; artifacts are not) +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..55bcbdd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Agent Guidelines for Mitos + +## Commands + +- **Build**: `bun run build` (tsc + vite) +- **Lint**: `bun run lint` (eslint app) +- **Format**: `bun run fmt` (write) or `bun run fmt:check` (check) +- **Test**: `bun test` (unit, in `tests/`) or `bun test ` (single test) +- **E2E**: `bun run test:e2e` (Playwright render snapshots in `e2e/`; needs + `bunx playwright install chromium` once). Regenerate baselines per-platform with + `bun run test:e2e:update`. +- **Dev**: `bun run dev` +- **CI**: `bun run ci` (runs fmt:check, tsc, lint, test) + +## Code Style + +- **Formatting**: Prettier with 92 char width, no semicolons, single quotes, trailing commas +- **Imports**: Sort order: third-party → `~/*` (app alias) → relative. Use `~/` for app/ + imports. +- **TypeScript**: Strict mode enabled. Prefix unused vars/params with `_`. Use explicit + types. +- **Naming**: camelCase for vars/functions, PascalCase for components/types +- **ESLint rules**: Use `===`, no param reassignment, no return assignment +- **Error handling**: Use toast (sonner) for user-facing errors +- **License**: Add MPL 2.0 header to all new source files +- **Tests**: Use Bun test with describe/test/expect. See tests/ for examples. diff --git a/app/components/ascii-animation.tsx b/app/components/ascii-animation.tsx index 73b871b..d1e54da 100644 --- a/app/components/ascii-animation.tsx +++ b/app/components/ascii-animation.tsx @@ -19,6 +19,7 @@ export default function AsciiAnimation({ setAnimationController, textColor, backgroundColor, + canvasBackgroundColor = backgroundColor, padding, children, }: { @@ -29,10 +30,14 @@ export default function AsciiAnimation({ setAnimationController: (controller: AnimationController) => void textColor: string backgroundColor: string + // Background used to fill the canvas itself. Defaults to `backgroundColor`, + // but can be set to 'transparent' so the underlying image (rendered behind + // the canvas but in front of the container background) shows through. + canvasBackgroundColor?: string padding: number children: ReactNode }) { - const asciiEl = useRef(null) + const asciiEl = useRef(null) const controllerRef = useRef(animationController) const containerRef = useRef(null) @@ -48,8 +53,8 @@ export default function AsciiAnimation({ const element = asciiEl.current if (!container || !element) return - const width = Math.floor(element.offsetWidth + padding * 2) - const height = Math.floor(element.offsetHeight + padding * 2) + const width = Math.floor(element.offsetWidth) + const height = Math.floor(element.offsetHeight) container.style.width = `${width}px` container.style.height = `${height}px` @@ -94,6 +99,9 @@ export default function AsciiAnimation({ element: asciiEl.current, onFrameUpdate: onFrameUpdate ? onFrameUpdate : undefined, maxFrames, + textColor, + backgroundColor: canvasBackgroundColor, + padding, }) animController.togglePlay(wasPlaying) @@ -103,26 +111,34 @@ export default function AsciiAnimation({ } catch (error) { console.error('Error creating animation controller:', error) } - }, [program, maxFrames, onFrameUpdate, setAnimationController]) + }, [ + program, + maxFrames, + onFrameUpdate, + setAnimationController, + textColor, + canvasBackgroundColor, + padding, + ]) return (
-
       {children}
diff --git a/app/components/ascii-art-generator.tsx b/app/components/ascii-art-generator.tsx
index 0085523..6ffed67 100644
--- a/app/components/ascii-art-generator.tsx
+++ b/app/components/ascii-art-generator.tsx
@@ -717,8 +717,9 @@ export function AsciiArtGenerator() {
       setTemplateType('custom')
 
       toast(`${projectData.name} has been loaded successfully.`)
-    } catch (_error) {
+    } catch (error) {
       toast('The selected file is not a valid project file')
+      console.error(error)
     }
   }
 
diff --git a/app/components/ascii-preview.tsx b/app/components/ascii-preview.tsx
index 33ab591..1852f27 100644
--- a/app/components/ascii-preview.tsx
+++ b/app/components/ascii-preview.tsx
@@ -142,7 +142,7 @@ export function AsciiPreview({
   const [position, setPosition] = useState({ x: 0, y: 0 })
   const [isDragging, setIsDragging] = useState(false)
   const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
-  const [autoFit, setAutoFit] = useState(false)
+  const [autoFit, setAutoFit] = useState(true)
   const prevDimensionsRef = useRef(dimensions)
 
   const containerSize = useSize(container)
@@ -153,7 +153,7 @@ export function AsciiPreview({
     const zoomFactor = 0.035 * (e.deltaY > 0 ? 1 : 1.1)
 
     if (e.deltaY < 0) {
-      setZoomLevel((prev) => Math.min(prev * (1 + zoomFactor), 5))
+      setZoomLevel((prev) => Math.min(prev * (1 + zoomFactor), 3))
     } else {
       setZoomLevel((prev) => Math.max(prev / (1 + zoomFactor), 0.5))
     }
@@ -331,7 +331,7 @@ export function AsciiPreview({
           
)}
{/* Show underlying image if enabled */} @@ -390,34 +395,6 @@ export function AsciiPreview({ ) } -export const getContent = (dimensions: { width: number; height: number }) => { - const asciiElement = document.querySelector('.ascii-animation pre') - - if (asciiElement) { - const rawContent = asciiElement.textContent || '' - const { width, height } = dimensions - - // Process the raw content into properly formatted lines - const formattedLines = [] - - for (let i = 0; i < height; i++) { - // Extract exactly width characters for each line - const lineStart = i * width - const lineEnd = lineStart + width - - // Ensure we don't go out of bounds - if (lineStart < rawContent.length) { - const line = rawContent.substring(lineStart, Math.min(lineEnd, rawContent.length)) - // Add the line without right trimming to preserve spaces - formattedLines.push(line) - } - } - - // Join the lines with newlines - return formattedLines.join('\n') - } -} - function FrameSlider({ frame, totalFrames, diff --git a/app/components/asset-export.tsx b/app/components/asset-export.tsx index 60d4631..1322bf5 100644 --- a/app/components/asset-export.tsx +++ b/app/components/asset-export.tsx @@ -5,20 +5,21 @@ * * Copyright Oxide Computer Company */ -import { FFmpeg } from '@ffmpeg/ffmpeg' -import { fetchFile, toBlobURL } from '@ffmpeg/util' +import { Recorder } from 'canvas-record' import { saveAs } from 'file-saver' -import html2canvas from 'html2canvas-pro' import JSZip from 'jszip' -import { useEffect, useRef, useState } from 'react' +import { AVC } from 'media-codecs' +import { useEffect, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { toast } from 'sonner' -import type { Program } from '~/lib/animation' +import type { Cell, Program } from '~/lib/animation' +import { getColoredRows, getContent } from '~/lib/buffer-text' +import { glyphRunToPathData, loadAsciiFont, type Font } from '~/lib/svg-font' import { InputButton, InputNumber, InputSwitch } from '~/lib/ui/src' import { InputSelect } from '~/lib/ui/src/components/InputSelect/InputSelect' -import { getContent, type AnimationController } from './ascii-preview' +import { type AnimationController } from './ascii-preview' import { Container } from './container' import { calculateAspectRatio, @@ -27,7 +28,7 @@ import { CHAR_WIDTH, } from './dimension-utils' -export type ExportFormat = 'frames' | 'png' | 'svg' | 'mp4' | 'gif' +export type ExportFormat = 'frames' | 'png' | 'svg' | 'mp4' interface ExportDimensions { width: number @@ -49,53 +50,6 @@ interface AssetExportProps { } } -// Walk the rendered ASCII DOM and group each row into runs of same-coloured -// text, so SVG export can reproduce the per-cell colours scripts emit (the live -// preview wraps coloured runs in ). Cells without an -// explicit colour fall back to the stock text colour. -function extractColoredRows( - preEl: Element, - width: number, - height: number, - defaultColor: string, -): { text: string; color: string }[][] { - const rows: { text: string; color: string }[][] = [] - const rowEls = preEl.children - - for (let r = 0; r < height; r++) { - const segments: { text: string; color: string }[] = [] - const rowEl = rowEls[r] - let col = 0 - - const pushText = (text: string, color: string) => { - if (col >= width || !text) return - const slice = text.slice(0, width - col) - if (!slice) return - col += slice.length - const last = segments[segments.length - 1] - if (last && last.color === color) last.text += slice - else segments.push({ text: slice, color }) - } - - if (rowEl) { - rowEl.childNodes.forEach((node) => { - if (node.nodeType === Node.TEXT_NODE) { - pushText(node.textContent || '', defaultColor) - } else if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as HTMLElement - pushText(el.textContent || '', el.style?.color || defaultColor) - } - }) - } - - // Pad short rows so vertical positioning stays aligned. - if (col < width) pushText(' '.repeat(width - col), defaultColor) - rows.push(segments) - } - - return rows -} - export function AssetExport({ program, animationController, @@ -118,8 +72,12 @@ export function AssetExport({ const [trimX, setTrimX] = useState(0) const [trimY, setTrimY] = useState(0) - const [ffmpegLoaded, setFfmpegLoaded] = useState(false) - const ffmpegRef = useRef(null) + // When on, SVG export outlines glyphs to data + const [flattenSvg, setFlattenSvg] = useState(false) + + // When off, the export has a transparent background (where the format allows + // it — MP4 can't, so it always keeps the background colour). + const [includeBackground, setIncludeBackground] = useState(false) // Set export height based on character dimensions including padding useEffect(() => { @@ -134,67 +92,6 @@ export function AssetExport({ })) }, [dimensions, exportSettings.padding]) - useEffect(() => { - const loadFFmpeg = async () => { - try { - if (!ffmpegRef.current) { - toast.loading('Loading video processing library...', { id: 'ffmpeg-load' }) - - const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.10/dist/esm' - const ffmpeg = new FFmpeg() - - ffmpeg.on('log', ({ message }) => { - if (message && message.includes('frame=')) { - toast.message(message, { id: 'ffmpeg-load' }) - } - }) - - ffmpeg.on('progress', ({ progress }) => { - toast.loading(`Encoding: ${Math.round(progress * 100)}%`, { - id: 'video-export', - }) - }) - - const coreURL = await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript') - const wasmURL = await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm') - - // Load with a timeout to detect hanging - const loadPromise = ffmpeg.load({ - coreURL, - wasmURL, - }) - - // Create a timeout promise - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Loading ffmpeg timed out')), 30000) - }) - - // Race the loading against the timeout - await Promise.race([loadPromise, timeoutPromise]) - - ffmpegRef.current = ffmpeg - setFfmpegLoaded(true) - toast.success('Video processing library loaded!', { id: 'ffmpeg-load' }) - } - } catch (error) { - console.error('Error loading ffmpeg:', error) - toast.error( - `Failed to load video processing: ${error instanceof Error ? error.message : 'Unknown error'}`, - { - id: 'ffmpeg-load', - duration: 5000, - }, - ) - // Reset loading state so user can try again - setFfmpegLoaded(false) - } - } - - if ((exportFormat === 'mp4' || exportFormat === 'gif') && !ffmpegLoaded) { - loadFFmpeg() - } - }, [exportFormat, ffmpegLoaded]) - useEffect(() => { const isAnimated = animationLength > 1 @@ -251,10 +148,49 @@ export function AssetExport({ } const exportAsPng = async () => { - const canvas = await captureFrame() - if (!canvas) return + if (!animationController) { + toast.error('Animation controller not available') + return + } + + // Apply trim adjustments + const finalWidth = trimEnabled ? exportDimensions.width + trimX : exportDimensions.width + const finalHeight = trimEnabled + ? exportDimensions.height + trimY + : exportDimensions.height - canvas.toBlob( + const exportCanvas = document.createElement('canvas') + exportCanvas.width = finalWidth + exportCanvas.height = finalHeight + + const buffer = animationController.getBuffer() + const metrics = animationController.getMetrics() + if (!metrics) { + toast.error('Animation metrics not available') + return + } + + // Calculate padding for export using calculateContentDimensions + const { pixelHeight, paddingPixels } = calculateContentDimensions( + dimensions, + exportSettings.padding, + ) + const previewTotalHeight = pixelHeight + paddingPixels * 2 + const scale = finalHeight / previewTotalHeight + const exportPadding = paddingPixels * scale + + renderBufferToCanvas( + exportCanvas, + buffer, + dimensions, + exportSettings, + metrics.fontSize, + metrics.lineHeight, + exportPadding, + includeBackground, + ) + + exportCanvas.toBlob( (blob) => { if (blob) saveAs(blob, 'ascii-art.png') }, @@ -265,23 +201,31 @@ export function AssetExport({ toast('Frame has been exported as PNG') } - const generateSvgContent = () => { - const asciiElement = document.querySelector('.ascii-animation pre') - - if (!asciiElement) { + const generateSvgContent = async () => { + if (!animationController) { toast('Could not find ASCII content') return null } try { const { width, height } = dimensions - const coloredRows = extractColoredRows( - asciiElement, - width, - height, + const coloredRows = getColoredRows( + dimensions, + animationController, exportSettings.textColor, ) + // Outlining is opt-in and needs the parsed font + let font: Font | null = null + if (flattenSvg) { + try { + font = await loadAsciiFont() + } catch (error) { + console.error('Could not load font for flattening:', error) + toast('Could not load font to flatten — exporting as text') + } + } + const padding = exportSettings.padding * CHAR_WIDTH const fontSize = 12 @@ -309,7 +253,9 @@ export function AssetExport({ svgContent += ' .grid-line { stroke: #666666; stroke-width: 0.5; stroke-opacity: 0.5; }\n' svgContent += ' \n' - svgContent += ` \n` + if (includeBackground) { + svgContent += ` \n` + } const gridElement = document.querySelector('.grid-overlay') const gridType = gridElement?.getAttribute('data-grid-type') || 'none' @@ -335,28 +281,57 @@ export function AssetExport({ svgContent += ' \n' } - svgContent += ` \n` + if (font) { + // Flattened: emit one of outlined glyphs per colour run. Glyphs + // sit on a fixed monospace grid (measuredCellWidth) so they line up + // with the cells exactly + svgContent += ' \n' + + coloredRows.forEach((segments, index) => { + const baselineY = padding + fontSize + index * cellHeight + let col = 0 + + segments.forEach((seg) => { + const startX = padding + col * measuredCellWidth + col += seg.text.length + const d = glyphRunToPathData( + font, + seg.text, + startX, + baselineY, + fontSize, + measuredCellWidth, + ) + if (d) svgContent += ` \n` + }) + }) + + svgContent += ' \n' + } else { + svgContent += ` \n` - coloredRows.forEach((segments, index) => { - if (segments.length === 0) { - // Keep the (blank) line so following rows stay vertically aligned. - svgContent += ` \n` - return - } + coloredRows.forEach((segments, index) => { + if (segments.length === 0) { + // Keep the (blank) line so following rows stay vertically aligned. + svgContent += ` \n` + return + } - // Each row is one or more tspans flowing left-to-right. Only the first - // tspan of a row sets x (left margin) and advances dy to the next line; - // the rest inherit the position so colour runs stay contiguous. - segments.forEach((seg, segIndex) => { - const processed = seg.text.replace(/ /g, '\u00A0') // preserve spacing - const isFirst = segIndex === 0 - const xAttr = isFirst ? ` x="${padding}"` : '' - const dyAttr = ` dy="${isFirst && index !== 0 ? cellHeight : 0}"` - svgContent += ` ${escapeXml(processed)}\n` + // Each row is one or more tspans flowing left-to-right. Only the first + // tspan of a row sets x (left margin) and advances dy to the next line; + // the rest inherit the position so colour runs stay contiguous. + segments.forEach((seg, segIndex) => { + const processed = seg.text.replace(/ /g, '\u00A0') // preserve spacing + const isFirst = segIndex === 0 + const xAttr = isFirst ? ` x="${padding}"` : '' + const dyAttr = ` dy="${isFirst && index !== 0 ? cellHeight : 0}"` + svgContent += ` ${escapeXml(processed)}\n` + }) }) - }) - svgContent += ' \n' + svgContent += ' \n' + } + svgContent += '' return svgContent @@ -368,7 +343,7 @@ export function AssetExport({ } const exportAsSvg = async () => { - const svgContent = generateSvgContent() + const svgContent = await generateSvgContent() if (!svgContent) return const blob = new Blob([svgContent], { type: 'image/svg+xml' }) @@ -383,10 +358,7 @@ export function AssetExport({ try { setIsExporting(true) - // Allow DOM to update - await new Promise((resolve) => setTimeout(resolve, 50)) - - const svgContent = generateSvgContent() + const svgContent = await generateSvgContent() if (!svgContent) return // Copy SVG content to clipboard @@ -407,9 +379,11 @@ export function AssetExport({ if (!program) return try { - navigator.clipboard.writeText(getContent(dimensions) || '').then(() => { - toast('ASCII art has been copied to your clipboard') - }) + navigator.clipboard + .writeText(getContent(dimensions, animationController) || '') + .then(() => { + toast('ASCII art has been copied to your clipboard') + }) } catch (error) { console.error('Error copying to clipboard:', error) toast('Could not copy to clipboard') @@ -435,79 +409,166 @@ export function AssetExport({ }) } - const exportAsVideo = async (frames: Blob[]) => { - if (!ffmpegLoaded || !ffmpegRef.current) { - toast.error('Video processing library not loaded') + const renderBufferToCanvas = ( + canvas: HTMLCanvasElement, + buffer: Cell[], + dimensions: { width: number; height: number }, + settings: { textColor: string; backgroundColor: string }, + baseFontSize: number, + baseLineHeight: number, + padding: number = 0, + includeBackground: boolean = true, + ) => { + const ctx = canvas.getContext('2d') + if (!ctx) return + + // Calculate cell dimensions accounting for padding + const contentWidth = canvas.width - padding * 2 + const contentHeight = canvas.height - padding * 2 + const cellWidth = contentWidth / dimensions.width + const lineHeight = contentHeight / dimensions.height + + // Calculate font size based on the scale ratio between export and preview + // Scale = (export lineHeight) / (base lineHeight from preview) + const scale = lineHeight / baseLineHeight + const fontSize = Math.round(baseFontSize * scale) + + // Wipe the canvas before painting. fillRect alone composites source-over, + // so a transparent background would leave the previous frame's pixels + // behind (the canvas is reused across frames) — clearRect resets to + // transparent first. + ctx.clearRect(0, 0, canvas.width, canvas.height) + if (includeBackground) { + ctx.fillStyle = settings.backgroundColor + ctx.fillRect(0, 0, canvas.width, canvas.height) + } + ctx.fillStyle = settings.textColor + ctx.font = `${fontSize}px "GT America Mono", monospace` + ctx.textBaseline = 'top' + + // Only touch fillStyle when a cell's colour differs from the previous one; + // cells without an explicit colour fall back to the stock text colour. + let currentColor = settings.textColor + + for (let i = 0; i < buffer.length; i++) { + const col = i % dimensions.width + const row = Math.floor(i / dimensions.width) + const x = col * cellWidth + padding + const y = row * lineHeight + padding + + const color = buffer[i]?.color || settings.textColor + if (color !== currentColor) { + ctx.fillStyle = color + currentColor = color + } + + ctx.fillText(buffer[i]?.char || ' ', x, y) + } + } + + const exportAsVideoWithCanvasRecord = async (totalFrames: number) => { + if (!animationController) { + toast.error('Animation controller not available') return } try { - const ffmpeg = ffmpegRef.current - toast.loading('Processing video...', { id: 'video-export' }) - - // Write each frame to the virtual file system - for (let i = 0; i < frames.length; i++) { - const frameName = `frame_${String(i).padStart(4, '0')}.png` - const frameData = await fetchFile(frames[i]) - await ffmpeg.writeFile(frameName, frameData) - - if (i % 10 === 0 || i === frames.length - 1) { - toast.loading( - `Preparing frames: ${Math.round(((i + 1) / frames.length) * 100)}%`, - { - id: 'video-export', - }, - ) - } + toast.loading('Initializing video encoder...', { id: 'video-export' }) + + // Apply trim adjustments and ensure even dimensions for H264 + let finalWidth = trimEnabled ? exportDimensions.width + trimX : exportDimensions.width + let finalHeight = trimEnabled + ? exportDimensions.height + trimY + : exportDimensions.height + + // H264 requires even dimensions - round up to nearest even number + finalWidth = Math.ceil(finalWidth / 2) * 2 + finalHeight = Math.ceil(finalHeight / 2) * 2 + + const exportCanvas = document.createElement('canvas') + exportCanvas.width = finalWidth + exportCanvas.height = finalHeight + + const ctx = exportCanvas.getContext('2d') + if (!ctx) { + toast.error('Could not get canvas context') + return } - // Generate the video based on selected format - const fps = Math.min(30, Math.max(10, animationController?.getState().fps || 24)) - const outputFilename = `output.${exportFormat}` - - toast.loading('Encoding video...', { id: 'video-export' }) - - if (exportFormat === 'gif') { - await ffmpeg.exec([ - '-framerate', - `${fps}`, - '-pattern_type', - 'glob', - '-i', - 'frame_*.png', - '-vf', - 'split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse', - '-f', - 'gif', - outputFilename, - ]) - } else { - await ffmpeg.exec([ - '-framerate', - `${fps}`, - '-pattern_type', - 'glob', - '-i', - 'frame_*.png', - '-vf', - 'format=yuv420p', - '-c:v', - 'libx264', - outputFilename, - ]) + const recorder = new Recorder(ctx, { + name: 'ascii-animation', + encoderOptions: { + codec: AVC.getCodec({ profile: 'Main', level: '5.2' }), + }, + }) + + const wasPlaying = animationController.getState().playing + const currentFrame = animationController.getState().frame + + animationController.togglePlay(false) + + const metrics = animationController.getMetrics() + if (!metrics) { + toast.error('Animation metrics not available', { id: 'video-export' }) + return } - // Read the output file - const data = await ffmpeg.readFile(outputFilename) - const blob = new Blob( - [data instanceof Uint8Array ? (data.buffer as ArrayBuffer) : data], - { - type: exportFormat === 'mp4' ? 'video/mp4' : 'image/gif', - }, + // Calculate padding for export using calculateContentDimensions + const { pixelHeight, paddingPixels } = calculateContentDimensions( + dimensions, + exportSettings.padding, ) + const previewTotalHeight = pixelHeight + paddingPixels * 2 + const scale = finalHeight / previewTotalHeight + const exportPadding = paddingPixels * scale + + // Render frame 0 before starting (start() captures the canvas as frame 0) + animationController.renderFrame(0) + const firstBuffer = animationController.getBuffer() + renderBufferToCanvas( + exportCanvas, + firstBuffer, + dimensions, + exportSettings, + metrics.fontSize, + metrics.lineHeight, + exportPadding, + ) + + // Start recording (this encodes the current canvas state as frame 0) + await recorder.start() + + // Render and record remaining frames + for (let i = 1; i < totalFrames; i++) { + animationController.renderFrame(i) + + const buffer = animationController.getBuffer() + renderBufferToCanvas( + exportCanvas, + buffer, + dimensions, + exportSettings, + metrics.fontSize, + metrics.lineHeight, + exportPadding, + ) + + await recorder.step() + + if (i % 5 === 0 || i === totalFrames - 1) { + toast.loading(`Encoding: ${Math.round(((i + 1) / totalFrames) * 100)}%`, { + id: 'video-export', + }) + } + } + + // Automatically saves + recorder.stop() - // Save the video - saveAs(blob, `ascii-animation.${exportFormat}`) + animationController.setFrame(currentFrame) + if (wasPlaying) { + animationController.togglePlay(true) + } toast.success('Video export complete!', { id: 'video-export' }) } catch (error) { console.error('Error encoding video:', error) @@ -529,22 +590,57 @@ export function AssetExport({ currentFrame: number, wasPlaying: boolean, ) => { + if (exportFormat === 'mp4') { + await exportAsVideoWithCanvasRecord(totalFrames) + return + } + + if (!animationController) return + const frames: Blob[] = [] - for (let i = 0; i < totalFrames; i++) { - if (animationController) { - animationController.setFrame(i) - } + // Apply trim adjustments + const finalWidth = trimEnabled ? exportDimensions.width + trimX : exportDimensions.width + const finalHeight = trimEnabled + ? exportDimensions.height + trimY + : exportDimensions.height + + const exportCanvas = document.createElement('canvas') + exportCanvas.width = finalWidth + exportCanvas.height = finalHeight + + const metrics = animationController.getMetrics() + if (!metrics) { + toast.error('Animation metrics not available') + return + } - // Allow DOM to update - await waitForPaint() + // Calculate padding for export using calculateContentDimensions + const { pixelHeight, paddingPixels } = calculateContentDimensions( + dimensions, + exportSettings.padding, + ) + const previewTotalHeight = pixelHeight + paddingPixels * 2 + const scale = finalHeight / previewTotalHeight + const exportPadding = paddingPixels * scale - const canvas = await captureFrame() - if (!canvas) continue + for (let i = 0; i < totalFrames; i++) { + animationController.renderFrame(i) + + const buffer = animationController.getBuffer() + renderBufferToCanvas( + exportCanvas, + buffer, + dimensions, + exportSettings, + metrics.fontSize, + metrics.lineHeight, + exportPadding, + includeBackground, + ) - // Convert canvas to blob and add to zip const blob = await new Promise((resolve) => - canvas.toBlob((b) => resolve(b as Blob), 'image/png', 1.0), + exportCanvas.toBlob((b) => resolve(b as Blob), 'image/png', 1.0), ) frames.push(blob) @@ -556,87 +652,19 @@ export function AssetExport({ } } - // Restore animation state - if (animationController) { - animationController.setFrame(currentFrame) - if (wasPlaying) { - animationController.togglePlay(true) - } - } - - // Either export as video or as frame zip - if (exportFormat === 'mp4' || exportFormat === 'gif') { - await exportAsVideo(frames) - } else { - // Original frames export code - const zip = new JSZip() - frames.forEach((blob, i) => { - zip.file(`frame_${String(i).padStart(4, '0')}.png`, blob) - }) - - const zipBlob = await zip.generateAsync({ type: 'blob' }) - saveAs(zipBlob, 'ascii-animation-frames.zip') - toast.success('Export complete!', { id: 'export-progress' }) + animationController.setFrame(currentFrame) + if (wasPlaying) { + animationController.togglePlay(true) } - } - // The ASCII is HTML so we need some way to turn it into an image - const captureFrame = async () => { - const asciiParent = document.querySelector('.ascii-animation')?.parentElement - if (!asciiParent) return null - - // Contains both the ASCII and grid overlay - const containerElement = asciiParent - - // Calculate scale to achieve target dimensions using character dimensions - const { totalWidth: totalActualWidth, totalHeight: totalActualHeight } = - calculateContentDimensions(dimensions, exportSettings.padding) - - // Apply trim adjustments to final export dimensions - const finalExportWidth = trimEnabled - ? exportDimensions.width + trimX - : exportDimensions.width - const finalExportHeight = trimEnabled - ? exportDimensions.height + trimY - : exportDimensions.height - - // Calculate scale factors based on original export dimensions (not trimmed) - const scaleX = exportDimensions.width / totalActualWidth - const scaleY = exportDimensions.height / totalActualHeight - - // Calculate offset for centering content with any trim values - const offsetX = trimEnabled ? -trimX / 2 : 0 - const offsetY = trimEnabled ? -trimY / 2 : 0 - - return html2canvas(containerElement as HTMLElement, { - backgroundColor: exportSettings.backgroundColor, - logging: false, - allowTaint: true, - useCORS: true, - removeContainer: false, - width: finalExportWidth, - height: finalExportHeight, - x: offsetX, - y: offsetY, - scale: 1, - onclone: (document, element) => { - // Apply transform to scale the content to fill the export dimensions - const clonedElement = element as HTMLElement - clonedElement.style.transform = `scale(${scaleX}, ${scaleY})` - clonedElement.style.transformOrigin = 'top left' - - // Find elements with CSS color functions and simplify them - const elements = document.querySelectorAll('*') - elements.forEach((el) => { - const style = window.getComputedStyle(el) - const color = style.color - if (color.includes('color(')) { - // Set to a basic color that html2canvas can handle - ;(el as HTMLElement).style.color = 'currentColor' - } - }) - }, + const zip = new JSZip() + frames.forEach((blob, i) => { + zip.file(`frame_${String(i).padStart(4, '0')}.png`, blob) }) + + const zipBlob = await zip.generateAsync({ type: 'blob' }) + saveAs(zipBlob, 'ascii-animation-frames.zip') + toast.success('Export complete!', { id: 'video-export' }) } // Copy with cmd+c @@ -654,7 +682,7 @@ export function AssetExport({ }} options={ animationLength > 1 - ? (['mp4', 'gif', 'frames'] as ExportFormat[]) + ? (['mp4', 'frames'] as ExportFormat[]) : (['svg', 'png'] as ExportFormat[]) } labelize={(format) => { @@ -662,7 +690,6 @@ export function AssetExport({ case 'frames': return 'PNGs' case 'mp4': - case 'gif': return format.toUpperCase() default: return format.toUpperCase() @@ -673,10 +700,7 @@ export function AssetExport({ Format - {(exportFormat === 'png' || - exportFormat === 'frames' || - exportFormat === 'mp4' || - exportFormat === 'gif') && ( + {(exportFormat === 'png' || exportFormat === 'frames' || exportFormat === 'mp4') && (
@@ -746,6 +770,22 @@ export function AssetExport({
)} + + + Include background + + + + Flatten SVG +
)} @@ -755,14 +795,10 @@ export function AssetExport({ variant="secondary" className="mt-2 w-full" onClick={exportContent} - disabled={ - isExporting || - disabled || - ((exportFormat === 'mp4' || exportFormat === 'gif') && !ffmpegLoaded) - } + disabled={isExporting || disabled} > - {exportFormat === 'mp4' || exportFormat === 'gif' - ? `Export as ${exportFormat.toUpperCase()}` + {exportFormat === 'mp4' + ? 'Export as MP4' : animationLength > 1 ? `Export ${exportFormat === 'frames' ? 'Frames' : 'Frame'}` : 'Export Image'} diff --git a/app/lib/animation.ts b/app/lib/animation.ts index 14eafb7..9981a2b 100644 --- a/app/lib/animation.ts +++ b/app/lib/animation.ts @@ -6,8 +6,8 @@ * Modified from https://github.com/ertdfgcvb/play.core * Copyright ertdfgcvb (Andreas Gysin) */ +import createRenderer from './core/canvas-renderer' import FPS, { type FPSType } from './core/fps' -import createRenderer from './core/text-renderer' // Default settings for the program runner. // They can be overwritten by the parameters of the runner @@ -34,6 +34,9 @@ export interface Settings { fontWeight?: string onFrameUpdate?: (frame: number) => void maxFrames?: number + textColor?: string + backgroundColor?: string + padding?: number } interface State { @@ -64,6 +67,15 @@ interface Cursor { } } +// A neutral cursor used when there is no pointer interaction +// (e.g. during automatic frame exports). +const EMPTY_CURSOR: Cursor = { + x: 0, + y: 0, + pressed: false, + p: { x: 0, y: 0, pressed: false }, +} + export interface Cell { char: string color?: string @@ -262,12 +274,6 @@ export function createAnimation( // Timing update timeSample = t - (delta % interval) // adjust timeSample state.time = t + timeOffset // increment time + initial offs - if (!settings.maxFrames || state.frame < settings.maxFrames) { - state.frame++ // increment frame counter - } else { - state.frame = 0 - } - settings.onFrameUpdate && settings.onFrameUpdate(state.frame) // Cursor update const cursor = { @@ -289,39 +295,87 @@ export function createAnimation( // 1. -------------------------------------------------------------- // In case of resize / init normalize the buffer - if (cols !== context.cols || rows !== context.rows) { - cols = context.cols - rows = context.rows - - // Add validation to ensure valid array length - const newLength = context.cols * context.rows - if (newLength > 0 && newLength < 10000000 && isFinite(newLength)) { - // Set a reasonable upper limit - buffer.length = newLength - for (let i = 0; i < buffer.length; i++) { - buffer[i] = { char: EMPTY_CELL } - } + normalizeBuffer(context) + + // 2. -------------------------------------------------------------- + // Run pre()/main()/post() and render to the canvas + renderProgram(context, cursor) + + // 6. -------------------------------------------------------------- + // Queued events + while (eventQueue.length > 0) { + const type = eventQueue.shift() + if (type && typeof program[type] === 'function') { + program[type](context, cursor, buffer) + } + } + + // 7. -------------------------------------------------------------- + // Increment frame counter AFTER rendering (so we start at frame 0). + // Only advance while playing — when paused the loop is kicked by + // setFrame() to render a specific frame, and must stay on that frame + // (otherwise scrubbing or re-initializing on a settings change would + // bump the frame by one). + if (state.playing) { + if (!settings.maxFrames || state.frame < settings.maxFrames - 1) { + state.frame++ // increment frame counter } else { - console.error(`Invalid buffer dimensions: ${context.cols} x ${context.rows}`) - // Use a safe fallback - cols = cols || 1 - rows = rows || 1 - const safeLength = cols * rows - buffer.length = safeLength - for (let i = 0; i < buffer.length; i++) { - buffer[i] = { char: EMPTY_CELL } - } + state.frame = 0 } } + settings.onFrameUpdate && settings.onFrameUpdate(state.frame) - // 2. -------------------------------------------------------------- - // Call pre(), if defined + // 8. -------------------------------------------------------------- + // Loop (eventually) + if (state.playing) requestAnimationFrame(loop) + } + + function togglePlay(playing: boolean) { + state.playing = playing + if (playing) { + requestAnimationFrame(loop) + } + } + + function setFrame(frame: number) { + state.frame = frame + requestAnimationFrame(loop) + } + + // In case of resize / init, (re)allocate the cell buffer to fit the grid. + function normalizeBuffer(context: Context) { + if (cols === context.cols && rows === context.rows && buffer.length > 0) return + + cols = context.cols + rows = context.rows + + // Add validation to ensure valid array length + const newLength = context.cols * context.rows + if (newLength > 0 && newLength < 10000000 && isFinite(newLength)) { + // Set a reasonable upper limit + buffer.length = newLength + for (let i = 0; i < buffer.length; i++) { + buffer[i] = { char: EMPTY_CELL } + } + } else { + console.error(`Invalid buffer dimensions: ${context.cols} x ${context.rows}`) + // Use a safe fallback + cols = cols || 1 + rows = rows || 1 + const safeLength = cols * rows + buffer.length = safeLength + for (let i = 0; i < buffer.length; i++) { + buffer[i] = { char: EMPTY_CELL } + } + } + } + + // Run pre()/main()/post() over the buffer and render it to the canvas. + function renderProgram(context: Context, cursor: Cursor) { if (typeof program.pre === 'function') { program.pre(context, cursor, buffer, userDataRef) } - // 3. -------------------------------------------------------------- - // Call main(), if defined if (typeof program.main === 'function') { for (let j = 0; j < context.rows; j++) { const offs = j * context.cols @@ -347,39 +401,24 @@ export function createAnimation( } } - // 4. -------------------------------------------------------------- - // Call post(), if defined if (typeof program.post === 'function') { program.post(context, cursor, buffer, userDataRef) } - // 5. -------------------------------------------------------------- renderer.render(context, buffer) - - // 6. -------------------------------------------------------------- - // Queued events - while (eventQueue.length > 0) { - const type = eventQueue.shift() - if (type && typeof program[type] === 'function') { - program[type](context, cursor, buffer) - } - } - - // 7. -------------------------------------------------------------- - // Loop (eventually) - if (state.playing) requestAnimationFrame(loop) } - function togglePlay(playing: boolean) { - state.playing = playing - if (playing) { - requestAnimationFrame(loop) - } - } + // Synchronous render for exports. Unlike setFrame(), this runs the program + // immediately (no requestAnimationFrame) so the buffer can be read right + // after. No cursor is supplied as exporting is an automatic process. + function renderFrame(frame: number) { + if (!metrics) return - function setFrame(frame: number) { state.frame = frame - requestAnimationFrame(loop) + const context = getContext(state, settings, metrics, fps) + + normalizeBuffer(context) + renderProgram(context, EMPTY_CURSOR) } function cleanup() { @@ -416,8 +455,12 @@ export function createAnimation( togglePlay, cleanup, setFrame, + renderFrame, updateSettings, getState, + getBuffer: () => buffer, + getMetrics: () => metrics, + isReady: () => !!metrics, } } @@ -454,25 +497,39 @@ function getContext(state: State, settings: Settings, metrics: Metrics, fps: any export function calcMetrics(el: HTMLElement): Metrics { const style = getComputedStyle(el) - // Extract info from the style + // Extract info from the style: in case of a canvas element + // the style and font family should be set anyways. const fontFamily = style.getPropertyValue('font-family') const lineHeightStyle = style.getPropertyValue('line-height') const fontSize = Number.parseFloat(style.getPropertyValue('font-size')) - let cellWidth, cellHeight - - // cellWidth is computed - const span = document.createElement('span') - el.appendChild(span) - span.innerHTML = ''.padEnd(50, 'X') - cellWidth = span.getBoundingClientRect().width / 50 - cellHeight = span.getBoundingClientRect().height - el.removeChild(span) + const lineHeight = + lineHeightStyle === 'normal' ? fontSize * 1.2 : Number.parseFloat(lineHeightStyle) + let cellWidth + + // If the output element is a canvas 'measureText()' is used + // else cellWidth is computed 'by hand' (should be the same, in any case) + if (el.nodeName === 'CANVAS') { + const canvas = el as HTMLCanvasElement + const ctx = canvas.getContext('2d') + + if (ctx) { + ctx.font = fontSize + 'px ' + fontFamily + cellWidth = ctx.measureText(''.padEnd(50, 'X')).width / 50 + } else { + cellWidth = fontSize * 0.6 // fallback + } + } else { + const span = document.createElement('span') + el.appendChild(span) + span.innerHTML = ''.padEnd(50, 'X') + cellWidth = span.getBoundingClientRect().width / 50 + el.removeChild(span) + } const metrics = { - aspect: cellWidth / cellHeight, + aspect: cellWidth / lineHeight, cellWidth, - lineHeight: - lineHeightStyle === 'normal' ? fontSize * 1.2 : Number.parseFloat(lineHeightStyle), + lineHeight, fontFamily, fontSize, } diff --git a/app/lib/buffer-text.ts b/app/lib/buffer-text.ts new file mode 100644 index 0000000..aed83b8 --- /dev/null +++ b/app/lib/buffer-text.ts @@ -0,0 +1,86 @@ +/* + * 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 { Cell } from './animation' + +/** + * Anything that can hand back the current cell buffer. Kept structural (rather + * than importing the full AnimationController) so this module stays free of DOM + * dependencies and can be unit tested in isolation. + */ +export interface BufferSource { + getBuffer: () => Cell[] +} + +/** + * Format the flat cell buffer into newline-separated text laid out as a + * `width` × `height` grid. Empty cells render as a single space, so every line + * is exactly `width` characters wide (including trailing spaces). + */ +export const getContent = ( + dimensions: { width: number; height: number }, + source: BufferSource | null, +): string => { + if (!source) return '' + + const buffer = source.getBuffer() + const { width, height } = dimensions + const formattedLines = [] + + for (let i = 0; i < height; i++) { + const lineStart = i * width + const lineEnd = lineStart + width + const lineCells = buffer.slice(lineStart, lineEnd) + const line = lineCells.map((cell) => cell.char || ' ').join('') + formattedLines.push(line) + } + + return formattedLines.join('\n') +} + +/** A run of same-coloured characters within a single row. */ +export interface ColoredSegment { + text: string + color: string +} + +/** + * Group the flat cell buffer into per-row runs of same-coloured text, so SVG + * export can reproduce the per-cell colours scripts emit. Cells without an + * explicit colour fall back to `defaultColor`. Each returned row is exactly + * `width` characters wide (short rows are padded with spaces) so vertical + * positioning stays aligned, matching `getContent`. + */ +export const getColoredRows = ( + dimensions: { width: number; height: number }, + source: BufferSource | null, + defaultColor: string, +): ColoredSegment[][] => { + if (!source) return [] + + const buffer = source.getBuffer() + const { width, height } = dimensions + const rows: ColoredSegment[][] = [] + + for (let r = 0; r < height; r++) { + const segments: ColoredSegment[] = [] + + for (let c = 0; c < width; c++) { + const cell = buffer[r * width + c] + const char = cell?.char || ' ' + const color = cell?.color || defaultColor + + const last = segments[segments.length - 1] + if (last && last.color === color) last.text += char + else segments.push({ text: char, color }) + } + + rows.push(segments) + } + + return rows +} diff --git a/app/lib/core/canvas-renderer.ts b/app/lib/core/canvas-renderer.ts new file mode 100644 index 0000000..f931818 --- /dev/null +++ b/app/lib/core/canvas-renderer.ts @@ -0,0 +1,104 @@ +/* + * 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 { invariant, type Cell, type Context } from '../animation' + +// Supersample the backing bitmap above the device pixel ratio so the canvas +// stays crisp when the preview is zoomed in (zoom goes up to 5x). Text is +// vector, so the extra resolution costs only memory/fill, not fidelity. +const SUPERSAMPLE = 3 + +export default function createRenderer() { + function render(context: Context, buffer: Cell[]): void { + const canvas = context.settings.element as HTMLCanvasElement + + invariant(!!canvas, 'Canvas element is required') + + const ctx = canvas.getContext('2d') + if (!ctx) { + console.error('Could not get canvas context') + return + } + + // 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 + } + + const scale = devicePixelRatio * SUPERSAMPLE + const m = context.metrics + + // Validate metrics + if (!m.cellWidth || !m.lineHeight || m.cellWidth <= 0 || m.lineHeight <= 0) { + console.error('Invalid metrics:', m) + return + } + + // Get padding from settings (default to 0) + const padding = context.settings.padding || 0 + + // Calculate canvas dimensions based on character grid + padding + const canvasWidth = context.cols * m.cellWidth + padding * 2 + const canvasHeight = context.rows * m.lineHeight + padding * 2 + + // Set canvas size + canvas.width = canvasWidth * scale + canvas.height = canvasHeight * scale + canvas.style.width = canvasWidth + 'px' + canvas.style.height = canvasHeight + 'px' + + // Get colors from settings + const backgroundColor = context.settings.backgroundColor || 'white' + const textColor = context.settings.textColor || 'black' + + // Fill background + ctx.fillStyle = backgroundColor + ctx.fillRect(0, 0, canvas.width, canvas.height) + + // Setup text rendering + ctx.save() + ctx.scale(scale, scale) + ctx.fillStyle = textColor + ctx.font = `${m.fontSize}px ${m.fontFamily}` + ctx.textBaseline = 'top' + + // Track the active fill colour so we only touch fillStyle when a cell's + // colour differs from the previous one (matching the span-run approach of + // the DOM renderer). Cells without an explicit colour fall back to the + // stock text colour. + let currentColor = textColor + + // Render cells with padding offset + for (let j = 0; j < context.rows; j++) { + for (let i = 0; i < context.cols; i++) { + const cell = buffer[j * context.cols + i] + const x = i * m.cellWidth + padding + const y = j * m.lineHeight + padding + + const color = cell?.color || textColor + if (color !== currentColor) { + ctx.fillStyle = color + currentColor = color + } + + ctx.fillText(cell?.char || ' ', x, y) + } + } + + ctx.restore() + } + + return { + render, + } +} diff --git a/app/lib/core/text-renderer.ts b/app/lib/core/text-renderer.ts deleted file mode 100644 index 1cac221..0000000 --- a/app/lib/core/text-renderer.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * 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/svg-font.ts b/app/lib/svg-font.ts new file mode 100644 index 0000000..3828fb9 --- /dev/null +++ b/app/lib/svg-font.ts @@ -0,0 +1,63 @@ +/* + * 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 { parse, type Font } from 'opentype.js' + +export type { Font } + +// This is used to outline glyphs for SVG export +const FONT_URL = 'https://oxide.computer/fonts/GT-America-Mono-Regular-OCC.woff' + +let fontPromise: Promise | null = null + +/** + * Fetch and parse the ASCII grid font, returning a parsed opentype Font that + * can outline glyphs to SVG paths. The result is cached for the session; a + * failed load is not cached so a later attempt can retry. Rejects if the font + * cannot be fetched (e.g. CORS) or parsed. + */ +export function loadAsciiFont(): Promise { + if (fontPromise) return fontPromise + + fontPromise = (async () => { + const response = await fetch(FONT_URL) + if (!response.ok) { + throw new Error(`Failed to fetch font (${response.status})`) + } + const buffer = await response.arrayBuffer() + return parse(buffer) + })().catch((error) => { + fontPromise = null // let the next caller retry + throw error + }) + + return fontPromise +} + +/** + * Outline a run of text into a single SVG path `d` string, placing each glyph + * on a fixed monospace grid so the outlines line up exactly with the cell + * layout (rather than trusting the font's own advance width). `baselineY` is + * the text baseline; `startX` is the left edge of the first cell. + */ +export function glyphRunToPathData( + font: Font, + text: string, + startX: number, + baselineY: number, + fontSize: number, + cellWidth: number, +): string { + let d = '' + for (let i = 0; i < text.length; i++) { + const char = text[i] + if (char === ' ') continue // spaces have no outline + const x = startX + i * cellWidth + d += font.getPath(char, x, baselineY, fontSize).toPathData(2) + } + return d +} diff --git a/bun.lock b/bun.lock index 1ccdca5..892b611 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "mitos", @@ -9,13 +10,12 @@ "@codemirror/language": "^6.11.0", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.36.4", - "@ffmpeg/ffmpeg": "^0.12.15", - "@ffmpeg/util": "^0.12.2", "@ianvs/prettier-plugin-sort-imports": "^4.4.1", "@oxide/design-system": "^2.5.0", "@radix-ui/react-accordion": "^1.2.11", "@react-hook/resize-observer": "^2.0.2", "@uiw/react-codemirror": "^4.23.10", + "canvas-record": "^5.5.0", "clsx": "^2.1.1", "esbuild-wasm": "^0.25.9", "eslint-config-prettier": "^10.1.1", @@ -24,9 +24,10 @@ "eslint-plugin-react": "^7.37.4", "file-saver": "^2.0.5", "gifuct-js": "^2.1.2", - "html2canvas-pro": "^1.5.8", "jszip": "^3.10.1", + "media-codecs": "^2.0.2", "motion": "^12.16.0", + "opentype.js": "^2.0.0", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", "react": "^19.0.0", @@ -44,8 +45,10 @@ "devDependencies": { "@eslint/compat": "^1.2.8", "@eslint/js": "^9.21.0", + "@playwright/test": "^1.60.0", "@types/bun": "^1.2.19", "@types/file-saver": "^2.0.7", + "@types/opentype.js": "^1.3.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", @@ -202,6 +205,10 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.1", "", { "dependencies": { "@eslint/core": "^0.14.0", "levn": "^0.4.1" } }, "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w=="], + "@ffmpeg/core": ["@ffmpeg/core@0.12.10", "", {}, "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA=="], + + "@ffmpeg/core-mt": ["@ffmpeg/core-mt@0.12.10", "", {}, "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ=="], + "@ffmpeg/ffmpeg": ["@ffmpeg/ffmpeg@0.12.15", "", { "dependencies": { "@ffmpeg/types": "^0.12.4" } }, "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw=="], "@ffmpeg/types": ["@ffmpeg/types@0.12.4", "", {}, "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A=="], @@ -284,6 +291,8 @@ "@pkgr/core": ["@pkgr/core@0.2.7", "", {}, "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A=="], @@ -598,6 +607,10 @@ "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/file-saver": ["@types/file-saver@2.0.7", "", {}, "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A=="], @@ -612,6 +625,8 @@ "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], + "@types/opentype.js": ["@types/opentype.js@1.3.10", "", {}, "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ=="], + "@types/react": ["@types/react@19.1.6", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q=="], "@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="], @@ -686,8 +701,6 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], - "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], @@ -710,6 +723,12 @@ "caniuse-lite": ["caniuse-lite@1.0.30001721", "", {}, "sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ=="], + "canvas-context": ["canvas-context@3.3.1", "", {}, "sha512-JGCW75ua3Q4B7pYTTDEGUCt1c9QGcl4k37fH9T5qhzKrtVoUOasqXC0bgXpYe5oqxmbCXsdcnhX7ETxavERzRw=="], + + "canvas-record": ["canvas-record@5.5.0", "", { "dependencies": { "@ffmpeg/ffmpeg": "^0.12.7", "@ffmpeg/util": "^0.12.1", "canvas-context": "^3.3.1", "canvas-screenshot": "^4.2.2", "gifenc": "^1.0.3", "h264-mp4-encoder": "^1.0.12", "media-codecs": "^2.0.2", "mediabunny": "^1.24.2" }, "optionalDependencies": { "@ffmpeg/core": "^0.12.3", "@ffmpeg/core-mt": "^0.12.3" } }, "sha512-7Qgd+U3K0xDx3nNGGL+Rrb3s7N0Nzn3SYI8RtA4eywY1BZy9ptv6Ue5F5mnYDVYB1K95VbaSIdi6P+LVmJGQOg=="], + + "canvas-screenshot": ["canvas-screenshot@4.2.2", "", { "dependencies": { "file-extension": "^4.0.5" } }, "sha512-2bcsLb9oPjmbJV+8/gAF9UrfMpDTefQZx1b3vFU9PdNiId1Fo4tDn5qW2jZLjO4fsv9aXIZ75lu9Yv6NJ1j/YQ=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -744,8 +763,6 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], - "cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -868,6 +885,8 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-extension": ["file-extension@4.0.5", "", {}, "sha512-l0rOL3aKkoi6ea7MNZe6OHgqYYpn48Qfflr8Pe9G9JPPTx5A+sfboK91ZufzIs59/lPqh351l0eb6iKU9J5oGg=="], + "file-saver": ["file-saver@2.0.5", "", {}, "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -902,6 +921,8 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "gifenc": ["gifenc@1.0.3", "", {}, "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw=="], + "gifuct-js": ["gifuct-js@2.1.2", "", { "dependencies": { "js-binary-schema-parser": "^2.0.3" } }, "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg=="], "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": "dist/esm/bin.mjs" }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], @@ -918,6 +939,8 @@ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "h264-mp4-encoder": ["h264-mp4-encoder@1.0.12", "", {}, "sha512-xih3J+Go0o1RqGjhOt6TwXLWWGqLONRPyS8yoMu/RoS/S8WyEv4HuHp1KBsDDl8srZQ3gw9f95JYkCSjCuZbHQ=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -942,8 +965,6 @@ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - "html2canvas-pro": ["html2canvas-pro@1.5.11", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w=="], - "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -1072,6 +1093,10 @@ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], + "media-codecs": ["media-codecs@2.0.2", "", {}, "sha512-D7ygdW7j5yqkDJ9kX5H4UU2iC/fsreU2Vy49GxbN6OUeYso6U2QG6QgcwSn75eFUoM6ttVuTLAOt4qxQvLWdFw=="], + + "mediabunny": ["mediabunny@1.25.3", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-+LDXv/kybsElRQCmMlrdbKmPNvegeIyouGeOUzW5DJ8+M56H4G6wyEL2AJ1A45UcOB+U62qJMV7tuFO9S7yA7g=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], @@ -1132,6 +1157,8 @@ "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], + "opentype.js": ["opentype.js@2.0.0", "", { "bin": { "ot": "bin/ot" } }, "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -1162,6 +1189,10 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.4", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w=="], @@ -1332,8 +1363,6 @@ "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], - "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -1396,8 +1425,6 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], @@ -1480,6 +1507,8 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "rollup/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..457408d --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +[test] +# Playwright specs live in e2e/ and are run via `bun run test:e2e`. +# Scope Bun's own test runner to tests/ so a bare `bun test` doesn't try to +# execute (and choke on) the Playwright e2e specs. +root = "tests" diff --git a/e2e/render-snapshot.spec.ts b/e2e/render-snapshot.spec.ts new file mode 100644 index 0000000..5cd2612 --- /dev/null +++ b/e2e/render-snapshot.spec.ts @@ -0,0 +1,408 @@ +/* + * 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 { expect, test, type Download, type Locator, type Page } from '@playwright/test' +import JSZip from 'jszip' + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +const COIN_GREEN = '#238A5E' +const COIN_BG = '#2D3335' + +/** + * Wait until the ASCII canvas has actually painted. The renderer sets the + * canvas `width`/`height` attributes on its first render, so a non-zero width + * is a reliable signal that the compiled program has run and drawn a frame. + */ +async function waitForRender(page: Page): Promise { + const canvas = page.locator('#ascii-canvas') + await expect(canvas).toBeVisible() + // Webfont drives the canvas text metrics; wait for it so cell sizing is stable. + await page.evaluate(() => document.fonts.ready) + await expect + .poll(() => canvas.evaluate((c) => (c as HTMLCanvasElement).width), { + timeout: 15_000, + }) + .toBeGreaterThan(0) + // Let any font-swap repaint settle before capturing. + await page.waitForTimeout(300) + return canvas +} + +/** + * Read the canvas's own bitmap as a PNG buffer. Going through `toDataURL` + * (rather than a page screenshot) captures only the rendered pixels, with no + * surrounding/overlapping UI compositing into the frame. + */ +async function canvasToPngBuffer(canvas: Locator): Promise { + const dataUrl = await canvas.evaluate((c) => + (c as HTMLCanvasElement).toDataURL('image/png'), + ) + return Buffer.from(dataUrl.replace(/^data:image\/png;base64,/, ''), 'base64') +} + +/** Collect a Playwright download into a single Buffer. */ +async function downloadToBuffer(download: Download): Promise { + const stream = await download.createReadStream() + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(chunk as Buffer) + return Buffer.concat(chunks) +} + +/** Pull a single named entry out of a downloaded ZIP archive. */ +async function extractZipEntry(zipBuffer: Buffer, name: string): Promise { + const zip = await JSZip.loadAsync(zipBuffer) + const entry = zip.file(name) + if (!entry) throw new Error(`Zip is missing expected entry: ${name}`) + return entry.async('nodebuffer') +} + +/** + * Flip the "Include background" switch to the desired state. Its underlying + * checkbox is `pointer-events: none`, so the click target is the track div. + */ +async function setIncludeBackground(page: Page, on: boolean): Promise { + const row = page.locator('.ui-switch', { hasText: 'Include background' }) + const input = row.locator('input[type="checkbox"]') + if ((await input.isChecked()) !== on) { + await row.locator('.ui-switch__track').click() + } + await expect(input).toBeChecked({ checked: on }) +} + +/** + * Decode a PNG inside the browser and report whether any pixel is fully + * transparent (alpha 0). A backgroundless export leaves the padding/empty + * cells transparent; an export with the background filled has none. + */ +async function pngHasTransparentPixels(page: Page, pngBuffer: Buffer): Promise { + return page.evaluate(async (base64) => { + const img = new Image() + img.src = `data:image/png;base64,${base64}` + await img.decode() + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('no 2d context') + ctx.drawImage(img, 0, 0) + const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height) + for (let i = 3; i < data.length; i += 4) { + if (data[i] === 0) return true + } + return false + }, pngBuffer.toString('base64')) +} + +test.describe('ascii render snapshots', () => { + test('static template renders deterministically to canvas', async ({ page }) => { + // `numbers` has animationLength 1 — fully static, no time/frame dependence. + await page.goto('/?template=numbers') + const canvas = await waitForRender(page) + + const buffer = await canvasToPngBuffer(canvas) + expect(buffer.subarray(0, 8)).toEqual(PNG_MAGIC) + expect(buffer).toMatchSnapshot('numbers-canvas.png') + }) + + test('PNG export matches snapshot', async ({ page }) => { + await page.goto('/?template=numbers') + await waitForRender(page) + + // The baseline was captured with the background filled, so enable it (the + // toggle defaults to off / transparent). + await setIncludeBackground(page, true) + + // Static templates default the export format to PNG and label the button + // "Export Image". + const exportButton = page.getByRole('button', { name: 'Export Image' }) + await expect(exportButton).toBeEnabled() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + exportButton.click(), + ]) + + expect(download.suggestedFilename()).toBe('ascii-art.png') + + const buffer = await downloadToBuffer(download) + // Sanity: it's a real, non-trivial PNG before we pixel-diff it. + expect(buffer.subarray(0, 8)).toEqual(PNG_MAGIC) + expect(buffer.length).toBeGreaterThan(1000) + + // Pixel-diff the exported image against the committed baseline. + expect(buffer).toMatchSnapshot('numbers-export.png') + }) + + test('MP4 export produces a valid video file', async ({ page }) => { + // Video bytes are not deterministic across encoder versions, so we assert a + // well-formed, non-empty MP4 is produced rather than pixel-diffing frames. + // `sin` is animated (animationLength > 1), which enables the MP4 format. + await page.goto('/?template=sin') + await waitForRender(page) + + await page + .locator('.ui-select', { hasText: 'Format' }) + .locator('select') + .selectOption('mp4') + + const exportButton = page.getByRole('button', { name: 'Export as MP4' }) + await expect(exportButton).toBeEnabled() + + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 120_000 }), + exportButton.click(), + ]) + + expect(download.suggestedFilename()).toMatch(/\.mp4$/) + + const buffer = await downloadToBuffer(download) + expect(buffer.length).toBeGreaterThan(1000) + // MP4/ISO-BMFF files carry an 'ftyp' box marker near the start. + expect(buffer.subarray(0, 16).includes(Buffer.from('ftyp'))).toBe(true) + }) + + test('SVG export produces valid vector text', async ({ page }) => { + // `numbers` is static, so the format select offers svg/png. SVG bytes embed + // float metrics from live font measurement (not pixel-stable across runs), + // so we assert structure rather than pixel-diffing. + await page.goto('/?template=numbers') + await waitForRender(page) + + await page + .locator('.ui-select', { hasText: 'Format' }) + .locator('select') + .selectOption('svg') + + const exportButton = page.getByRole('button', { name: 'Export Image' }) + await expect(exportButton).toBeEnabled() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + exportButton.click(), + ]) + + expect(download.suggestedFilename()).toBe('ascii-art.svg') + + const svg = (await downloadToBuffer(download)).toString('utf-8') + expect(svg).toContain('') + // Default (non-flattened) export emits selectable / glyphs... + expect(svg).toContain(' { + // `coins` returns { char, color } per cell, so its SVG carries multiple + // colour runs. The SVG format isn't offered for animated templates, but the + // "Copy SVG" action works regardless and exercises the same generator. + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto('/?template=coins') + await waitForRender(page) + + await page.getByRole('button', { name: 'Copy SVG' }).click() + + // The copy runs through an async font/measurement step, so wait for the SVG + // to actually land on the clipboard before reading it back. + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText()), { + timeout: 15_000, + }) + .toContain(' navigator.clipboard.readText()) + + // Both coin colours present ⇒ per-cell colour runs survived into the SVG. + expect(svg).toContain(`fill="${COIN_GREEN}"`) + expect(svg).toContain(`fill="${COIN_BG}"`) + }) + + test('multi-colour frame export matches snapshot', async ({ page }) => { + // Visual-diff the multi-colour raster path: `coins` exports a ZIP of PNG + // frames, each rendered deterministically from the frame index (no time or + // randomness), so frame 0 is reproducible. Pixel-diffing it guards the + // per-cell colour rendering in renderBufferToCanvas. + test.setTimeout(120_000) + await page.goto('/?template=coins') + await waitForRender(page) + + // The baseline was captured with the background filled, so enable it (the + // toggle defaults to off / transparent). + await setIncludeBackground(page, true) + + // Animated templates default the format to "PNGs" (frames) labelled + // "Export Frames". + const exportButton = page.getByRole('button', { name: 'Export Frames' }) + await expect(exportButton).toBeEnabled() + + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 120_000 }), + exportButton.click(), + ]) + + expect(download.suggestedFilename()).toBe('ascii-animation-frames.zip') + + const zip = await downloadToBuffer(download) + const frame = await extractZipEntry(zip, 'frame_0000.png') + expect(frame.subarray(0, 8)).toEqual(PNG_MAGIC) + expect(frame.length).toBeGreaterThan(1000) + expect(frame).toMatchSnapshot('coins-frame-export.png') + }) + + // Pixel-diff the default (transparent) export for each deterministic example + // template. `clock` renders the live wall-clock time and `unpkgDemo` seeds + // simplex noise from Math.random, so neither is reproducible; `custom` is an + // empty project. The rest render purely from the frame index, so frame 0 is + // stable. + const TRANSPARENT_EXAMPLES: { template: string; animated: boolean }[] = [ + { template: 'numbers', animated: false }, + { template: 'localPattern', animated: false }, + { template: 'sin', animated: true }, + { template: 'coins', animated: true }, + { template: 'imageCode', animated: true }, + ] + + for (const { template, animated } of TRANSPARENT_EXAMPLES) { + test(`${template} exports a transparent PNG by default`, async ({ page }) => { + // Animated templates capture every frame into a zip, which is slow. + test.setTimeout(120_000) + await page.goto(`/?template=${template}`) + await waitForRender(page) + + // "Include background" defaults to off, so the export leaves the + // padding/empty cells transparent rather than filling the canvas. + const includeBackground = page + .locator('.ui-switch', { hasText: 'Include background' }) + .locator('input[type="checkbox"]') + await expect(includeBackground).not.toBeChecked() + + const exportButton = page.getByRole('button', { + name: animated ? 'Export Frames' : 'Export Image', + }) + await expect(exportButton).toBeEnabled() + + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 120_000 }), + exportButton.click(), + ]) + + // Static templates download a PNG directly; animated ones download a ZIP + // whose first frame is the deterministic frame 0. + const downloaded = await downloadToBuffer(download) + const png = animated + ? await extractZipEntry(downloaded, 'frame_0000.png') + : downloaded + + expect(png.subarray(0, 8)).toEqual(PNG_MAGIC) + expect(await pngHasTransparentPixels(page, png)).toBe(true) + expect(png).toMatchSnapshot(`${template}-transparent.png`) + }) + } + + test('PNG export is opaque when background is on', async ({ page }) => { + await page.goto('/?template=numbers') + await waitForRender(page) + + await setIncludeBackground(page, true) + + const exportButton = page.getByRole('button', { name: 'Export Image' }) + await expect(exportButton).toBeEnabled() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + exportButton.click(), + ]) + + const buffer = await downloadToBuffer(download) + expect(buffer.subarray(0, 8)).toEqual(PNG_MAGIC) + // The background fill covers every pixel, so nothing is left transparent. + expect(await pngHasTransparentPixels(page, buffer)).toBe(false) + }) + + test('SVG export omits the background rect when background is off', async ({ page }) => { + await page.goto('/?template=numbers') + await waitForRender(page) + + await page + .locator('.ui-select', { hasText: 'Format' }) + .locator('select') + .selectOption('svg') + + const exportButton = page.getByRole('button', { name: 'Export Image' }) + await expect(exportButton).toBeEnabled() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + exportButton.click(), + ]) + + const svg = (await downloadToBuffer(download)).toString('utf-8') + expect(svg).toContain(' { + // The "Include background" toggle only renders for the raster formats, so + // set it while PNG is selected, then switch to SVG (the flag persists). + await page.goto('/?template=numbers') + await waitForRender(page) + + await setIncludeBackground(page, true) + + await page + .locator('.ui-select', { hasText: 'Format' }) + .locator('select') + .selectOption('svg') + + const exportButton = page.getByRole('button', { name: 'Export Image' }) + await expect(exportButton).toBeEnabled() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + exportButton.click(), + ]) + + const svg = (await downloadToBuffer(download)).toString('utf-8') + expect(svg).toContain(' { + // Transparency isn't possible for video, so the "Include background" toggle + // is disabled for MP4 and the format always renders an opaque frame. + test.setTimeout(120_000) + await page.goto('/?template=sin') + await waitForRender(page) + + await page + .locator('.ui-select', { hasText: 'Format' }) + .locator('select') + .selectOption('mp4') + + const includeBackground = page + .locator('.ui-switch', { hasText: 'Include background' }) + .locator('input[type="checkbox"]') + await expect(includeBackground).not.toBeChecked() + await expect(includeBackground).toBeDisabled() + + const exportButton = page.getByRole('button', { name: 'Export as MP4' }) + await expect(exportButton).toBeEnabled() + + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 120_000 }), + exportButton.click(), + ]) + + const buffer = await downloadToBuffer(download) + expect(buffer.length).toBeGreaterThan(1000) + expect(buffer.subarray(0, 16).includes(Buffer.from('ftyp'))).toBe(true) + }) +}) diff --git a/e2e/render-snapshot.spec.ts-snapshots/coins-frame-export-chromium-darwin.png b/e2e/render-snapshot.spec.ts-snapshots/coins-frame-export-chromium-darwin.png new file mode 100644 index 0000000..899786a Binary files /dev/null and b/e2e/render-snapshot.spec.ts-snapshots/coins-frame-export-chromium-darwin.png differ diff --git a/e2e/render-snapshot.spec.ts-snapshots/coins-transparent-chromium-darwin.png b/e2e/render-snapshot.spec.ts-snapshots/coins-transparent-chromium-darwin.png new file mode 100644 index 0000000..8b2b5b3 Binary files /dev/null and b/e2e/render-snapshot.spec.ts-snapshots/coins-transparent-chromium-darwin.png differ diff --git a/e2e/render-snapshot.spec.ts-snapshots/imageCode-transparent-chromium-darwin.png b/e2e/render-snapshot.spec.ts-snapshots/imageCode-transparent-chromium-darwin.png new file mode 100644 index 0000000..7d4985e Binary files /dev/null and b/e2e/render-snapshot.spec.ts-snapshots/imageCode-transparent-chromium-darwin.png differ diff --git a/e2e/render-snapshot.spec.ts-snapshots/localPattern-transparent-chromium-darwin.png b/e2e/render-snapshot.spec.ts-snapshots/localPattern-transparent-chromium-darwin.png new file mode 100644 index 0000000..3ecfcda Binary files /dev/null and b/e2e/render-snapshot.spec.ts-snapshots/localPattern-transparent-chromium-darwin.png differ diff --git a/e2e/render-snapshot.spec.ts-snapshots/numbers-canvas-chromium-darwin.png b/e2e/render-snapshot.spec.ts-snapshots/numbers-canvas-chromium-darwin.png new file mode 100644 index 0000000..a5a76e9 Binary files /dev/null and b/e2e/render-snapshot.spec.ts-snapshots/numbers-canvas-chromium-darwin.png differ diff --git a/e2e/render-snapshot.spec.ts-snapshots/numbers-export-chromium-darwin.png b/e2e/render-snapshot.spec.ts-snapshots/numbers-export-chromium-darwin.png new file mode 100644 index 0000000..f19f59a Binary files /dev/null and b/e2e/render-snapshot.spec.ts-snapshots/numbers-export-chromium-darwin.png differ diff --git a/e2e/render-snapshot.spec.ts-snapshots/numbers-transparent-chromium-darwin.png b/e2e/render-snapshot.spec.ts-snapshots/numbers-transparent-chromium-darwin.png new file mode 100644 index 0000000..a781cbf Binary files /dev/null and b/e2e/render-snapshot.spec.ts-snapshots/numbers-transparent-chromium-darwin.png differ diff --git a/e2e/render-snapshot.spec.ts-snapshots/sin-transparent-chromium-darwin.png b/e2e/render-snapshot.spec.ts-snapshots/sin-transparent-chromium-darwin.png new file mode 100644 index 0000000..35bc6be Binary files /dev/null and b/e2e/render-snapshot.spec.ts-snapshots/sin-transparent-chromium-darwin.png differ diff --git a/package.json b/package.json index 0fb22b7..8c5ba2a 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,11 @@ "preview": "vite preview", "tsc": "tsc", "lint": "eslint app", - "test": "bun test", - "test:watch": "bun test --watch", - "test:coverage": "bun test --coverage", + "test": "bun test tests/", + "test:watch": "bun test --watch tests/", + "test:coverage": "bun test --coverage tests/", + "test:e2e": "playwright test", + "test:e2e:update": "playwright test --update-snapshots", "ci": "bun run fmt:check && bun run tsc && bun run lint && bun run test" }, "dependencies": { @@ -22,13 +24,12 @@ "@codemirror/language": "^6.11.0", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.36.4", - "@ffmpeg/ffmpeg": "^0.12.15", - "@ffmpeg/util": "^0.12.2", "@ianvs/prettier-plugin-sort-imports": "^4.4.1", "@oxide/design-system": "^2.5.0", "@radix-ui/react-accordion": "^1.2.11", "@react-hook/resize-observer": "^2.0.2", "@uiw/react-codemirror": "^4.23.10", + "canvas-record": "^5.5.0", "clsx": "^2.1.1", "esbuild-wasm": "^0.25.9", "eslint-config-prettier": "^10.1.1", @@ -37,9 +38,10 @@ "eslint-plugin-react": "^7.37.4", "file-saver": "^2.0.5", "gifuct-js": "^2.1.2", - "html2canvas-pro": "^1.5.8", "jszip": "^3.10.1", + "media-codecs": "^2.0.2", "motion": "^12.16.0", + "opentype.js": "^2.0.0", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", "react": "^19.0.0", @@ -57,8 +59,10 @@ "devDependencies": { "@eslint/compat": "^1.2.8", "@eslint/js": "^9.21.0", + "@playwright/test": "^1.60.0", "@types/bun": "^1.2.19", "@types/file-saver": "^2.0.7", + "@types/opentype.js": "^1.3.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..76dbba2 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,64 @@ +/* + * 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 { defineConfig, devices } from '@playwright/test' + +const PORT = 3000 +const baseURL = `http://localhost:${PORT}` + +/** + * Playwright drives the real app in a browser to take render snapshots of the + * ASCII canvas and exported assets. These tests live in `e2e/` and are run with + * `bun run test:e2e` — separately from the `bun test` unit suite in `tests/`. + * + * Snapshot baselines are platform-specific (font rasterization differs across + * OSes), so they are stored with an OS suffix. Regenerate with + * `bun run test:e2e:update` on the same platform that runs CI. + */ +export default defineConfig({ + testDir: './e2e', + // Snapshots are inherently sensitive to timing/animation; never silently retry + // into a green by chance. Fail fast and let the author inspect the diff. + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'list', + + expect: { + toHaveScreenshot: { + // Allow a little slack for subpixel font rendering while still catching + // real layout/content regressions. + maxDiffPixelRatio: 0.01, + // Anti-aliasing tolerance per pixel. + threshold: 0.2, + }, + }, + + use: { + baseURL, + // Pin the device scale factor so the supersampled canvas screenshots are + // reproducible regardless of the host display. + deviceScaleFactor: 1, + viewport: { width: 1280, height: 800 }, + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], deviceScaleFactor: 1 }, + }, + ], + + webServer: { + command: 'bun run dev', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) diff --git a/tests/buffer-text.test.ts b/tests/buffer-text.test.ts new file mode 100644 index 0000000..e9f6bad --- /dev/null +++ b/tests/buffer-text.test.ts @@ -0,0 +1,155 @@ +/* + * 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 { describe, expect, test } from 'bun:test' + +import type { Cell } from '~/lib/animation' +import { getColoredRows, getContent, type BufferSource } from '~/lib/buffer-text' + +/** Build a BufferSource from a flat list of characters. */ +const source = (chars: string[]): BufferSource => ({ + getBuffer: () => chars.map((char): Cell => ({ char })), +}) + +/** Build a BufferSource from rows of text (each string is one grid row). */ +const grid = (rows: string[]): BufferSource => source(rows.flatMap((row) => row.split(''))) + +describe('getContent', () => { + test('formats a flat buffer into a width x height grid', () => { + const result = getContent({ width: 3, height: 2 }, grid(['abc', 'def'])) + expect(result).toBe('abc\ndef') + }) + + test('produces exactly `height` lines', () => { + const result = getContent({ width: 2, height: 3 }, grid(['ab', 'cd', 'ef'])) + expect(result.split('\n')).toHaveLength(3) + }) + + test('each line is exactly `width` characters wide', () => { + const result = getContent({ width: 4, height: 2 }, grid(['ab x', 'wxyz'])) + for (const line of result.split('\n')) { + expect(line).toHaveLength(4) + } + }) + + test('renders empty-string cells as a single space', () => { + // Cells produced by the animation buffer can carry an empty char. + const result = getContent({ width: 3, height: 1 }, source(['a', '', 'c'])) + expect(result).toBe('a c') + }) + + test('preserves significant whitespace within a row', () => { + const result = getContent({ width: 5, height: 1 }, source(['a', ' ', ' ', 'b', ' '])) + expect(result).toBe('a b ') + }) + + test('preserves trailing whitespace (does not right-trim lines)', () => { + const result = getContent({ width: 3, height: 2 }, grid(['a ', 'b '])) + expect(result.split('\n')).toEqual(['a ', 'b ']) + }) + + test('reads the buffer in row-major order', () => { + // 2x2 grid: indices 0,1 are the top row; 2,3 the bottom row. + const result = getContent({ width: 2, height: 2 }, source(['1', '2', '3', '4'])) + expect(result).toBe('12\n34') + }) + + test('ignores buffer cells beyond width * height', () => { + // Buffer has an extra trailing cell that should not appear in the output. + const result = getContent({ width: 2, height: 1 }, source(['a', 'b', 'EXTRA'])) + expect(result).toBe('ab') + }) + + test('returns empty string when the controller is null', () => { + expect(getContent({ width: 4, height: 4 }, null)).toBe('') + }) + + test('handles a single cell', () => { + expect(getContent({ width: 1, height: 1 }, source(['x']))).toBe('x') + }) + + test('produces empty lines when the buffer is shorter than the grid', () => { + // Only the first row has data; the remaining requested rows are empty. + const result = getContent({ width: 3, height: 3 }, source(['a', 'b', 'c'])) + expect(result).toBe('abc\n\n') + expect(result.split('\n')).toHaveLength(3) + }) + + test('round-trips multi-line ascii art', () => { + const art = [' /\\ ', ' / \\ ', '/____\\'] + const result = getContent({ width: 6, height: 3 }, grid(art)) + expect(result).toBe(art.join('\n')) + }) +}) + +/** Build a BufferSource from cells with optional per-cell colour. */ +const colored = (cells: { char: string; color?: string }[]): BufferSource => ({ + getBuffer: () => cells.map((c): Cell => ({ char: c.char, color: c.color })), +}) + +describe('getColoredRows', () => { + test('returns one segment array per row', () => { + const rows = getColoredRows({ width: 2, height: 3 }, grid(['ab', 'cd', 'ef']), '#000') + expect(rows).toHaveLength(3) + }) + + test('falls back to the default colour for uncoloured cells', () => { + const rows = getColoredRows({ width: 3, height: 1 }, grid(['abc']), '#000') + expect(rows[0]).toEqual([{ text: 'abc', color: '#000' }]) + }) + + test('merges a run of same-coloured cells into one segment', () => { + const rows = getColoredRows( + { width: 3, height: 1 }, + colored([ + { char: 'a', color: 'red' }, + { char: 'b', color: 'red' }, + { char: 'c', color: 'red' }, + ]), + '#000', + ) + expect(rows[0]).toEqual([{ text: 'abc', color: 'red' }]) + }) + + test('splits into separate segments when the colour changes', () => { + const rows = getColoredRows( + { width: 4, height: 1 }, + colored([ + { char: 'a', color: 'red' }, + { char: 'b', color: 'red' }, + { char: 'c', color: 'blue' }, + { char: 'd' }, + ]), + '#000', + ) + expect(rows[0]).toEqual([ + { text: 'ab', color: 'red' }, + { text: 'c', color: 'blue' }, + { text: 'd', color: '#000' }, + ]) + }) + + test('pads short rows with default-coloured spaces to full width', () => { + const rows = getColoredRows({ width: 4, height: 1 }, source(['a']), '#000') + const text = rows[0].map((s) => s.text).join('') + expect(text).toBe('a ') + expect(text).toHaveLength(4) + }) + + test('reads the buffer in row-major order', () => { + const rows = getColoredRows( + { width: 2, height: 2 }, + source(['1', '2', '3', '4']), + '#000', + ) + expect(rows.map((r) => r.map((s) => s.text).join(''))).toEqual(['12', '34']) + }) + + test('returns an empty array when the controller is null', () => { + expect(getColoredRows({ width: 4, height: 4 }, null, '#000')).toEqual([]) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 863bcdd..8bb2bfb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,4 @@ import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ plugins: [react(), tsconfigPaths()], server: { port: 3000 }, - optimizeDeps: { - exclude: ['@ffmpeg/ffmpeg'], - }, })