- Auto-convert on image load (0ms) and flag change (400ms debounce) - SizeControl: linked width/height sliders with aspect-ratio lock and 0.5 font correction factor - Full flag exposure for all 4 tools (chafa, jp2a, ascii-image-converter, img2txt) - ChafaOptions: symbols/fill dropdowns, dither controls, work/threshold/font-ratio sliders, format select, toggles - Jp2aOptions: color-depth, RGB weight sliders, edge controls, 8 toggles - ImgTxtOptions: dither select with valid libcaca values, gamma slider - OutputDisplay: ansi-to-html rendering for colored chafa output - ShellBridge: abort-previous pattern, conversion-start/end lifecycle events - Test API (ENABLE_TEST_API=true): /test/health, /test/convert, /test/flags/:tool, /test/imagemagick - buildArgs: space-separated args (not = format); full schemas in SCHEMAS export - runChafa: width/height destructured and combined into --size WxH - Port changed to 3050; Vite on 0.0.0.0 with allowedHosts for production domain - 98 unit tests passing across 12 test files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
7.0 KiB
JavaScript
Executable File
193 lines
7.0 KiB
JavaScript
Executable File
import { randomUUID } from 'node:crypto'
|
|
import { writeFile, unlink, stat } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { execa } from 'execa'
|
|
import { preprocess } from '../lib/imagemagick.js'
|
|
import { SCHEMAS, buildArgs, runChafa, runJp2a, runAsciiImageConverter, runImgToTxt } from '../lib/converters.js'
|
|
|
|
const RUNNERS = {
|
|
chafa: runChafa,
|
|
jp2a: runJp2a,
|
|
'ascii-image-converter': runAsciiImageConverter,
|
|
img2txt: runImgToTxt,
|
|
}
|
|
|
|
const MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024
|
|
|
|
const BINARIES = [
|
|
{ key: 'chafa', cmd: 'chafa', args: ['--version'], src: 'stdout' },
|
|
{ key: 'jp2a', cmd: 'jp2a', args: ['--version'], src: 'both' },
|
|
{ key: 'ascii-image-converter', cmd: 'ascii-image-converter', args: ['--version'], src: 'stdout' },
|
|
{ key: 'img2txt', cmd: 'img2txt', args: ['--help'], src: 'both' },
|
|
{ key: 'imagemagick', cmd: 'magick', args: ['--version'], src: 'stdout' },
|
|
]
|
|
|
|
function validateUrl(raw) {
|
|
let url
|
|
try { url = new URL(raw) } catch { throw new Error('Invalid URL') }
|
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only http/https URLs are allowed')
|
|
return url.href
|
|
}
|
|
|
|
async function downloadToFile(url, destPath) {
|
|
const res = await fetch(url, {
|
|
signal: AbortSignal.timeout(15_000),
|
|
headers: { 'User-Agent': 'ASCIInator/1.0 (test API; +https://github.com/asciinator)' },
|
|
})
|
|
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`)
|
|
const buf = await res.arrayBuffer()
|
|
if (buf.byteLength > MAX_DOWNLOAD_BYTES) throw new Error('Image exceeds 10 MB limit')
|
|
await writeFile(destPath, Buffer.from(buf))
|
|
}
|
|
|
|
function parseVersion(stdout, stderr) {
|
|
const text = (stdout + '\n' + stderr).trim()
|
|
// Match semantic version: optional 'v' prefix, e.g. 1.2.3, v1.2.3, 0.99.19
|
|
const match = text.match(/v?(\d+\.\d+[\d.]*)/)
|
|
return match ? match[1] : null
|
|
}
|
|
|
|
async function probeImage(filePath) {
|
|
const result = await execa('magick', ['identify', '-format', '%w %h %m', filePath], { reject: false })
|
|
const [w, h, fmt] = result.stdout.trim().split(' ')
|
|
const { size } = await stat(filePath)
|
|
return { width: parseInt(w) || null, height: parseInt(h) || null, format: fmt || null, size_bytes: size }
|
|
}
|
|
|
|
function schemaToFlagList(tool) {
|
|
const schema = SCHEMAS[tool]
|
|
if (!schema) return null
|
|
|
|
const entries = Object.entries(schema)
|
|
|
|
// Chafa uses --size WxH internally but the API accepts width + height separately
|
|
const normalized = tool === 'chafa'
|
|
? entries.flatMap(([name, def]) =>
|
|
name === 'size'
|
|
? [['width', { type: 'value' }], ['height', { type: 'value' }]]
|
|
: [[name, def]]
|
|
)
|
|
: entries
|
|
|
|
return normalized.map(([name, def]) => {
|
|
let type = 'string'
|
|
if (def.type === 'bool') type = 'boolean'
|
|
else if (def.valid) type = 'enum'
|
|
else if (['width', 'height', 'work'].includes(name)) type = 'integer'
|
|
else if (['threshold', 'font-ratio', 'gamma', 'red', 'green', 'blue', 'dither-intensity', 'edge-threshold'].includes(name)) type = 'float'
|
|
const entry = { name, type, cliFlag: `--${name}` }
|
|
if (def.valid) entry.options = def.valid
|
|
return entry
|
|
})
|
|
}
|
|
|
|
export async function testRoutes(fastify) {
|
|
// GET /test/health
|
|
fastify.get('/health', async (_req, reply) => {
|
|
const binaries = {}
|
|
await Promise.all(BINARIES.map(async ({ key, cmd, args }) => {
|
|
try {
|
|
const result = await execa(cmd, args, { reject: false, timeout: 5000 })
|
|
const version = parseVersion(result.stdout, result.stderr)
|
|
binaries[key] = { available: true, version }
|
|
} catch {
|
|
binaries[key] = { available: false, version: null }
|
|
}
|
|
}))
|
|
return reply.send({ status: 'ok', binaries, server: 'fastify', timestamp: new Date().toISOString() })
|
|
})
|
|
|
|
// POST /test/convert
|
|
fastify.post('/convert', async (req, reply) => {
|
|
const { imageUrl, tool, flags = {} } = req.body ?? {}
|
|
if (!imageUrl) return reply.code(400).send({ error: 'imageUrl required' })
|
|
if (!tool) return reply.code(400).send({ error: 'tool required' })
|
|
if (!RUNNERS[tool]) return reply.code(400).send({ error: `Unknown tool: ${tool}` })
|
|
|
|
let validUrl
|
|
try { validUrl = validateUrl(imageUrl) } catch (e) { return reply.code(400).send({ error: e.message }) }
|
|
|
|
const ext = path.extname(new URL(validUrl).pathname).toLowerCase() || '.jpg'
|
|
const tempInput = `/tmp/asciinator-test-${randomUUID()}${ext}`
|
|
const format = tool === 'jp2a' ? 'jpg' : 'png'
|
|
let tempProcessed = null
|
|
const t0 = Date.now()
|
|
|
|
try {
|
|
await downloadToFile(validUrl, tempInput)
|
|
const t1 = Date.now()
|
|
|
|
tempProcessed = await preprocess(tempInput, format)
|
|
const t2 = Date.now()
|
|
|
|
const result = await RUNNERS[tool](tempProcessed, flags)
|
|
const t3 = Date.now()
|
|
|
|
return reply.send({
|
|
success: true, tool, flags,
|
|
output: result.stdout,
|
|
stderr: result.stderr,
|
|
exitCode: 0,
|
|
timings: { download_ms: t1 - t0, imagemagick_ms: t2 - t1, conversion_ms: t3 - t2, total_ms: t3 - t0 },
|
|
})
|
|
} catch (err) {
|
|
return reply.code(422).send({
|
|
success: false, tool, flags,
|
|
output: null,
|
|
stderr: err.stderr || err.message,
|
|
exitCode: err.exitCode ?? 1,
|
|
timings: { total_ms: Date.now() - t0 },
|
|
})
|
|
} finally {
|
|
await unlink(tempInput).catch(() => {})
|
|
if (tempProcessed) await unlink(tempProcessed).catch(() => {})
|
|
}
|
|
})
|
|
|
|
// GET /test/flags/:tool
|
|
fastify.get('/flags/:tool', async (req, reply) => {
|
|
const { tool } = req.params
|
|
const flags = schemaToFlagList(tool)
|
|
if (!flags) return reply.code(404).send({ error: `Unknown tool: ${tool}` })
|
|
return reply.send({ tool, flags })
|
|
})
|
|
|
|
// POST /test/imagemagick
|
|
fastify.post('/imagemagick', async (req, reply) => {
|
|
const { imageUrl } = req.body ?? {}
|
|
if (!imageUrl) return reply.code(400).send({ error: 'imageUrl required' })
|
|
|
|
let validUrl
|
|
try { validUrl = validateUrl(imageUrl) } catch (e) { return reply.code(400).send({ error: e.message }) }
|
|
|
|
const ext = path.extname(new URL(validUrl).pathname).toLowerCase() || '.jpg'
|
|
const tempInput = `/tmp/asciinator-test-${randomUUID()}${ext}`
|
|
let tempProcessed = null
|
|
const t0 = Date.now()
|
|
|
|
try {
|
|
await downloadToFile(validUrl, tempInput)
|
|
const inputMeta = await probeImage(tempInput)
|
|
|
|
const t1 = Date.now()
|
|
tempProcessed = await preprocess(tempInput, 'png')
|
|
const t2 = Date.now()
|
|
|
|
const outputMeta = await probeImage(tempProcessed)
|
|
|
|
return reply.send({
|
|
success: true,
|
|
input: inputMeta,
|
|
output: outputMeta,
|
|
commands_run: [`magick input${ext} -strip -auto-orient -resize 2000x2000> output.png`],
|
|
duration_ms: t2 - t1,
|
|
})
|
|
} catch (err) {
|
|
return reply.code(500).send({ success: false, error: err.message })
|
|
} finally {
|
|
await unlink(tempInput).catch(() => {})
|
|
if (tempProcessed) await unlink(tempProcessed).catch(() => {})
|
|
}
|
|
})
|
|
}
|