ASCIInator/server/routes/testApi.js

193 lines
7.0 KiB
JavaScript
Raw Normal View History

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(() => {})
}
})
}