diff --git a/CLAUDE.md b/CLAUDE.md index 6af6b06..b74fd71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ asciinator/ - Props: `image` (`{ dataUrl, mimeType, filename }`), `toolConfig` (`{ tool, flags }`) - Exposes: `convert()` — called via template ref from App.vue on button click - Converts `dataUrl` back to Blob via `fetch()`, sends as `multipart/form-data` -- POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3001/convert`) +- POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3050/convert`) - Emits: `conversion-result` (stdout string) | `conversion-error` (stderr string) - Handles: 30s timeout via `AbortController`, network errors, non-2xx responses @@ -113,7 +113,7 @@ asciinator/ ### Entry: `server/index.js` ```js // Fastify + @fastify/multipart -// Listens on localhost:3001 +// Listens on localhost:3050 // Single route: POST /convert // CORS: origin: true (reflects requester — permissive for local dev) // Logger: warn level only (startup logged via console.log) @@ -268,7 +268,7 @@ npm run dev # Both should be running simultaneously during development # Vue: http://localhost:5173 -# Fastify API: http://localhost:3001 +# Fastify API: http://localhost:3050 ``` ### npm scripts (`package.json`) diff --git a/README.md b/README.md index 033b89c..a4c7ed1 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ All tools must be installed on your system. ASCIInator does not install them for └──────────────────┼──────────────────────┘ │ ┌──────────────────┼──────────────────────┐ -│ Fastify :3001 │ │ +│ Fastify :3050 │ │ │ ▼ │ │ ImageMagick preprocessing │ │ ↓ │ @@ -155,7 +155,7 @@ npm run dev | Service | URL | |---------|-----| | Vue app | http://localhost:5173 | -| Fastify API | http://localhost:3001 | +| Fastify API | http://localhost:3050 | --- @@ -235,7 +235,7 @@ npm run test:e2e -- --debug # Debug mode **curl example:** ```bash -curl -s -X POST http://localhost:3001/convert \ +curl -s -X POST http://localhost:3050/convert \ -F "image=@/path/to/photo.jpg" \ -F "tool=chafa" \ -F 'flags={"width":"80","colors":"256"}' @@ -299,7 +299,7 @@ The binary isn't on PATH. Install it via your package manager — see Prerequisi ImageMagick isn't installed. It's required for all conversions regardless of tool. **CORS error in the browser console** -The Fastify server isn't running, or it's on a different port. Start it with `npm run server` and confirm it logs `Server listening at http://127.0.0.1:3001`. +The Fastify server isn't running, or it's on a different port. Start it with `npm run server` and confirm it logs `Server listening at http://127.0.0.1:3050`. **422 from the server** The binary returned a non-zero exit code. The full stderr is in the response body and shown in the ErrorLog panel. diff --git a/package-lock.json b/package-lock.json index 9d0c4bf..0cc10bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/cors": "^11.2.0", "@fastify/multipart": "^10.0.0", + "ansi-to-html": "^0.7.2", "execa": "^9.6.1", "fastify": "^5.8.5", "vue": "^3.5.32" @@ -2730,6 +2731,30 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansi-to-html": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz", + "integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==", + "license": "MIT", + "dependencies": { + "entities": "^2.2.0" + }, + "bin": { + "ansi-to-html": "bin/ansi-to-html" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ansi-to-html/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/ansis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", diff --git a/package.json b/package.json index 1396e72..69d8955 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@fastify/cors": "^11.2.0", "@fastify/multipart": "^10.0.0", + "ansi-to-html": "^0.7.2", "execa": "^9.6.1", "fastify": "^5.8.5", "vue": "^3.5.32" diff --git a/server/__tests__/converters.spec.js b/server/__tests__/converters.spec.js index 873466f..6d6578f 100644 --- a/server/__tests__/converters.spec.js +++ b/server/__tests__/converters.spec.js @@ -23,25 +23,98 @@ describe('buildArgs', () => { expect(() => buildArgs('chafa', { colors: 'invalid' })).toThrow('Invalid value') }) - it('accepts valid enum values', () => { - expect(() => buildArgs('chafa', { colors: '256' })).not.toThrow() - expect(() => buildArgs('chafa', { colors: '16/8' })).not.toThrow() - expect(() => buildArgs('chafa', { dither: 'noise' })).not.toThrow() - expect(() => buildArgs('jp2a', { background: 'dark' })).not.toThrow() - expect(() => buildArgs('img2txt', { format: 'ansi' })).not.toThrow() - }) - it('throws on unknown tool', () => { expect(() => buildArgs('malicious-tool', {})).toThrow('Unknown tool') }) it('stringifies numeric values', () => { - const args = buildArgs('chafa', { size: 80 }) - expect(args).toEqual(['--size', '80']) + expect(buildArgs('chafa', { size: 80 })).toEqual(['--size', '80']) }) - it('handles font-ratio hyphenated key', () => { - const args = buildArgs('chafa', { 'font-ratio': '0.5' }) - expect(args).toEqual(['--font-ratio', '0.5']) + it('handles hyphenated keys', () => { + expect(buildArgs('chafa', { 'font-ratio': '0.5' })).toEqual(['--font-ratio', '0.5']) + expect(buildArgs('chafa', { 'dither-grain': '4x4' })).toEqual(['--dither-grain', '4x4']) + expect(buildArgs('chafa', { 'dither-intensity': '1.5' })).toEqual(['--dither-intensity', '1.5']) + expect(buildArgs('chafa', { 'color-extractor': 'median' })).toEqual(['--color-extractor', 'median']) + expect(buildArgs('chafa', { 'color-space': 'din99d' })).toEqual(['--color-space', 'din99d']) + expect(buildArgs('chafa', { 'fg-only': true })).toContain('--fg-only') + }) + + it('accepts all valid chafa color depths', () => { + for (const v of ['none', '2', '8', '16/8', '16', '240', '256', 'full']) { + expect(() => buildArgs('chafa', { colors: v })).not.toThrow() + } + }) + + it('accepts all valid chafa dither modes', () => { + for (const v of ['none', 'ordered', 'diffusion', 'noise']) { + expect(() => buildArgs('chafa', { dither: v })).not.toThrow() + } + }) + + it('accepts all valid chafa dither grains', () => { + for (const v of ['1x1', '2x2', '4x4', '8x8']) { + expect(() => buildArgs('chafa', { 'dither-grain': v })).not.toThrow() + } + }) + + it('accepts chafa bool flags', () => { + expect(buildArgs('chafa', { zoom: true })).toContain('--zoom') + expect(buildArgs('chafa', { stretch: true })).toContain('--stretch') + expect(buildArgs('chafa', { 'fg-only': true })).toContain('--fg-only') + expect(buildArgs('chafa', { zoom: false })).not.toContain('--zoom') + }) + + it('accepts chafa work quality value', () => { + expect(buildArgs('chafa', { work: 9 })).toEqual(['--work', '9']) + }) + + it('accepts chafa format values', () => { + expect(buildArgs('chafa', { format: 'symbols' })).toEqual(['--format', 'symbols']) + expect(buildArgs('chafa', { format: 'utf8' })).toEqual(['--format', 'utf8']) + expect(() => buildArgs('chafa', { format: 'sixel' })).toThrow('Invalid value') + }) + + it('accepts valid jp2a background values', () => { + expect(() => buildArgs('jp2a', { background: 'dark' })).not.toThrow() + expect(() => buildArgs('jp2a', { background: 'light' })).not.toThrow() + expect(() => buildArgs('jp2a', { background: 'invalid' })).toThrow('Invalid value') + }) + + it('accepts valid jp2a color-depth values', () => { + for (const v of ['4', '8', '24']) { + expect(() => buildArgs('jp2a', { 'color-depth': v })).not.toThrow() + } + expect(() => buildArgs('jp2a', { 'color-depth': '16' })).toThrow('Invalid value') + }) + + it('accepts jp2a bool flags', () => { + for (const flag of ['colors', 'fill', 'grayscale', 'invert', 'border', 'flipx', 'flipy', 'edges-only']) { + expect(buildArgs('jp2a', { [flag]: true })).toContain(`--${flag}`) + expect(buildArgs('jp2a', { [flag]: false })).not.toContain(`--${flag}`) + } + }) + + it('accepts jp2a RGB weight values', () => { + expect(buildArgs('jp2a', { red: '0.5', green: '0.3', blue: '0.2' })).toEqual([ + '--red', '0.5', '--green', '0.3', '--blue', '0.2', + ]) + }) + + it('accepts jp2a edge-threshold', () => { + expect(buildArgs('jp2a', { 'edge-threshold': '0.4' })).toEqual(['--edge-threshold', '0.4']) + }) + + it('accepts valid img2txt format values', () => { + for (const v of ['ansi', 'utf8', 'html']) { + expect(() => buildArgs('img2txt', { format: v })).not.toThrow() + } + }) + + it('accepts valid img2txt dither values', () => { + for (const v of ['none', 'ordered2', 'ordered4', 'ordered8', 'random', 'fstein']) { + expect(() => buildArgs('img2txt', { dither: v })).not.toThrow() + } + expect(() => buildArgs('img2txt', { dither: 'invalid' })).toThrow('Invalid value') }) }) diff --git a/server/__tests__/testApi.spec.js b/server/__tests__/testApi.spec.js new file mode 100755 index 0000000..156b281 --- /dev/null +++ b/server/__tests__/testApi.spec.js @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify from 'fastify' +import { testRoutes } from '../routes/testApi.js' + +async function buildApp() { + const app = Fastify() + await app.register(testRoutes, { prefix: '/test' }) + return app +} + +describe('GET /test/health', () => { + it('returns ok status with binary availability map', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/test/health' }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.status).toBe('ok') + expect(body.binaries).toHaveProperty('chafa') + expect(body.binaries).toHaveProperty('jp2a') + expect(body.binaries).toHaveProperty('imagemagick') + expect(typeof body.binaries.chafa.available).toBe('boolean') + expect(body.timestamp).toBeTruthy() + }) +}) + +describe('GET /test/flags/:tool', () => { + it('returns flag list for chafa with width/height not size', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/test/flags/chafa' }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.tool).toBe('chafa') + expect(Array.isArray(body.flags)).toBe(true) + const names = body.flags.map(f => f.name) + expect(names).toContain('width') + expect(names).toContain('height') + expect(names).not.toContain('size') + expect(names).toContain('colors') + expect(names).toContain('threshold') + expect(names).toContain('format') + }) + + it('returns flag list for jp2a', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/test/flags/jp2a' }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.tool).toBe('jp2a') + const names = body.flags.map(f => f.name) + expect(names).toContain('width') + expect(names).toContain('background') + }) + + it('returns 404 for unknown tool', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/test/flags/unknown-tool' }) + expect(res.statusCode).toBe(404) + }) + + it('includes enum options for enum-type flags', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/test/flags/chafa' }) + const body = res.json() + const colorsFlag = body.flags.find(f => f.name === 'colors') + expect(colorsFlag.type).toBe('enum') + expect(Array.isArray(colorsFlag.options)).toBe(true) + expect(colorsFlag.options).toContain('256') + }) + + it('marks bool flags as boolean type', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/test/flags/chafa' }) + const body = res.json() + const zoomFlag = body.flags.find(f => f.name === 'zoom') + expect(zoomFlag.type).toBe('boolean') + }) +}) + +describe('POST /test/convert — URL validation', () => { + it('rejects missing imageUrl', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'POST', url: '/test/convert', + headers: { 'content-type': 'application/json' }, + payload: { tool: 'chafa', flags: {} }, + }) + expect(res.statusCode).toBe(400) + }) + + it('rejects missing tool', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'POST', url: '/test/convert', + headers: { 'content-type': 'application/json' }, + payload: { imageUrl: 'https://example.com/img.png' }, + }) + expect(res.statusCode).toBe(400) + }) + + it('rejects non-http URL schemes', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'POST', url: '/test/convert', + headers: { 'content-type': 'application/json' }, + payload: { imageUrl: 'file:///etc/passwd', tool: 'chafa', flags: {} }, + }) + expect(res.statusCode).toBe(400) + expect(res.json().error).toMatch(/http/) + }) + + it('rejects unknown tool', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'POST', url: '/test/convert', + headers: { 'content-type': 'application/json' }, + payload: { imageUrl: 'https://example.com/img.png', tool: 'evil-tool', flags: {} }, + }) + expect(res.statusCode).toBe(400) + }) +}) + +describe('POST /test/imagemagick — URL validation', () => { + it('rejects missing imageUrl', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'POST', url: '/test/imagemagick', + headers: { 'content-type': 'application/json' }, + payload: {}, + }) + expect(res.statusCode).toBe(400) + }) + + it('rejects file:// scheme', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'POST', url: '/test/imagemagick', + headers: { 'content-type': 'application/json' }, + payload: { imageUrl: 'file:///tmp/img.png' }, + }) + expect(res.statusCode).toBe(400) + expect(res.json().error).toMatch(/http/) + }) +}) diff --git a/server/index.js b/server/index.js index 7641fe1..b803dc5 100644 --- a/server/index.js +++ b/server/index.js @@ -2,10 +2,17 @@ import Fastify from 'fastify' import multipart from '@fastify/multipart' import cors from '@fastify/cors' import { convertRoute } from './routes/convert.js' +import { testRoutes } from './routes/testApi.js' const fastify = Fastify({ logger: { level: 'warn' } }) -await fastify.register(cors, { origin: true }) +await fastify.register(cors, { + origin: [ + 'http://localhost:5173', + 'http://localhost:4173', + 'https://asciinator.waynehayesdevelopment.com', + ], +}) await fastify.register(multipart, { limits: { fileSize: 20 * 1024 * 1024 }, @@ -13,9 +20,14 @@ await fastify.register(multipart, { await fastify.register(convertRoute) +if (process.env.ENABLE_TEST_API === 'true') { + await fastify.register(testRoutes, { prefix: '/test' }) + console.log('Test API enabled → /test/{health,convert,flags,imagemagick}') +} + try { - await fastify.listen({ port: 3001, host: '127.0.0.1' }) - console.log('ASCIInator API → http://127.0.0.1:3001') + await fastify.listen({ port: 3050, host: '127.0.0.1' }) + console.log('ASCIInator API → http://127.0.0.1:3050') } catch (err) { fastify.log.error(err) process.exit(1) diff --git a/server/lib/converters.js b/server/lib/converters.js index 2e3faab..f5b5654 100644 --- a/server/lib/converters.js +++ b/server/lib/converters.js @@ -2,20 +2,43 @@ import { execa } from 'execa' const TIMEOUT = 30_000 -const SCHEMAS = { +export const SCHEMAS = { chafa: { - size: { type: 'value' }, - colors: { type: 'value', valid: ['none', '2', '8', '16/8', '16', '240', '256', 'full'] }, - symbols: { type: 'value' }, - dither: { type: 'value', valid: ['none', 'ordered', 'diffusion', 'noise'] }, - threshold: { type: 'value' }, - 'font-ratio': { type: 'value' }, + size: { type: 'value' }, + colors: { type: 'value', valid: ['none', '2', '8', '16/8', '16', '240', '256', 'full'] }, + 'color-extractor': { type: 'value', valid: ['average', 'median', 'mode'] }, + 'color-space': { type: 'value', valid: ['rgb', 'din99d'] }, + symbols: { type: 'value' }, + fill: { type: 'value' }, + dither: { type: 'value', valid: ['none', 'ordered', 'diffusion', 'noise'] }, + 'dither-grain': { type: 'value', valid: ['1x1', '2x2', '4x4', '8x8'] }, + 'dither-intensity':{ type: 'value' }, + threshold: { type: 'value' }, + 'font-ratio': { type: 'value' }, + work: { type: 'value' }, + zoom: { type: 'bool' }, + stretch: { type: 'bool' }, + 'fg-only': { type: 'bool' }, + format: { type: 'value', valid: ['symbols', 'utf8'] }, }, jp2a: { - width: { type: 'value' }, - height: { type: 'value' }, - chars: { type: 'value' }, - background: { type: 'value', valid: ['light', 'dark'] }, + width: { type: 'value' }, + height: { type: 'value' }, + chars: { type: 'value' }, + background: { type: 'value', valid: ['light', 'dark'] }, + colors: { type: 'bool' }, + 'color-depth': { type: 'value', valid: ['4', '8', '24'] }, + fill: { type: 'bool' }, + grayscale: { type: 'bool' }, + invert: { type: 'bool' }, + border: { type: 'bool' }, + flipx: { type: 'bool' }, + flipy: { type: 'bool' }, + red: { type: 'value' }, + green: { type: 'value' }, + blue: { type: 'value' }, + 'edge-threshold': { type: 'value' }, + 'edges-only': { type: 'bool' }, }, 'ascii-image-converter': { width: { type: 'value' }, @@ -28,7 +51,7 @@ const SCHEMAS = { width: { type: 'value' }, height: { type: 'value' }, format: { type: 'value', valid: ['ansi', 'utf8', 'html'] }, - dither: { type: 'value' }, + dither: { type: 'value', valid: ['none', 'ordered2', 'ordered4', 'ordered8', 'random', 'fstein'] }, gamma: { type: 'value' }, }, } diff --git a/server/routes/testApi.js b/server/routes/testApi.js new file mode 100755 index 0000000..392e879 --- /dev/null +++ b/server/routes/testApi.js @@ -0,0 +1,192 @@ +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(() => {}) + } + }) +} diff --git a/src/App.vue b/src/App.vue index c0aac1d..61ae638 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ + + diff --git a/src/components/ToolSelector.vue b/src/components/ToolSelector.vue index 447aacd..c3d9796 100644 --- a/src/components/ToolSelector.vue +++ b/src/components/ToolSelector.vue @@ -5,6 +5,7 @@ import Jp2aOptions from './options/Jp2aOptions.vue' import AsciiOptions from './options/AsciiOptions.vue' import ImgTxtOptions from './options/ImgTxtOptions.vue' +defineProps({ imageDims: { type: Object, default: null } }) const emit = defineEmits(['tool-config']) const TOOLS = [ @@ -24,8 +25,22 @@ const OPTIONS_MAP = { const selectedTool = ref('chafa') const toolFlags = reactive({ - chafa: { width: '', height: '', colors: '', symbols: '', dither: '', threshold: '', 'font-ratio': '' }, - jp2a: { width: '', height: '', chars: '', background: '' }, + chafa: { + width: '', height: '', + colors: '', 'color-extractor': '', 'color-space': '', + symbols: '', fill: '', + dither: '', 'dither-grain': '', 'dither-intensity': '', + threshold: '', 'font-ratio': '', work: '', + zoom: false, stretch: false, 'fg-only': false, + format: 'symbols', + }, + jp2a: { + width: '', height: '', chars: '', background: '', + colors: false, 'color-depth': '', fill: false, grayscale: false, + invert: false, border: false, flipx: false, flipy: false, + red: '', green: '', blue: '', + 'edge-threshold': '', 'edges-only': false, + }, 'ascii-image-converter': { width: '', height: '', color: false, braille: false, threshold: '' }, img2txt: { width: '', height: '', format: '', dither: '', gamma: '' }, }) @@ -52,6 +67,7 @@ watch(