Compare commits

..

No commits in common. "03e0bcae6f8391875dc9fa0018f154e3c68cb465" and "c1cf06ed67c73d22ba6431866f66045a9502cb1f" have entirely different histories.

26 changed files with 244 additions and 1416 deletions

6
.gitignore vendored
View File

@ -40,9 +40,3 @@ __screenshots__/
test-results/ test-results/
playwright-report/ playwright-report/
# Syncthing
.stignore
.stfolder*
.stversions
*.sync-conflict-*

View File

@ -89,7 +89,7 @@ asciinator/
- Props: `image` (`{ dataUrl, mimeType, filename }`), `toolConfig` (`{ tool, flags }`) - Props: `image` (`{ dataUrl, mimeType, filename }`), `toolConfig` (`{ tool, flags }`)
- Exposes: `convert()` — called via template ref from App.vue on button click - Exposes: `convert()` — called via template ref from App.vue on button click
- Converts `dataUrl` back to Blob via `fetch()`, sends as `multipart/form-data` - Converts `dataUrl` back to Blob via `fetch()`, sends as `multipart/form-data`
- POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3050/convert`) - POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3001/convert`)
- Emits: `conversion-result` (stdout string) | `conversion-error` (stderr string) - Emits: `conversion-result` (stdout string) | `conversion-error` (stderr string)
- Handles: 30s timeout via `AbortController`, network errors, non-2xx responses - Handles: 30s timeout via `AbortController`, network errors, non-2xx responses
@ -113,7 +113,7 @@ asciinator/
### Entry: `server/index.js` ### Entry: `server/index.js`
```js ```js
// Fastify + @fastify/multipart // Fastify + @fastify/multipart
// Listens on localhost:3050 // Listens on localhost:3001
// Single route: POST /convert // Single route: POST /convert
// CORS: origin: true (reflects requester — permissive for local dev) // CORS: origin: true (reflects requester — permissive for local dev)
// Logger: warn level only (startup logged via console.log) // Logger: warn level only (startup logged via console.log)
@ -268,7 +268,7 @@ npm run dev
# Both should be running simultaneously during development # Both should be running simultaneously during development
# Vue: http://localhost:5173 # Vue: http://localhost:5173
# Fastify API: http://localhost:3050 # Fastify API: http://localhost:3001
``` ```
### npm scripts (`package.json`) ### npm scripts (`package.json`)

View File

@ -69,7 +69,7 @@ All tools must be installed on your system. ASCIInator does not install them for
└──────────────────┼──────────────────────┘ └──────────────────┼──────────────────────┘
┌──────────────────┼──────────────────────┐ ┌──────────────────┼──────────────────────┐
│ Fastify :3050 │ │ │ Fastify :3001 │ │
│ ▼ │ │ ▼ │
│ ImageMagick preprocessing │ │ ImageMagick preprocessing │
│ ↓ │ │ ↓ │
@ -155,7 +155,7 @@ npm run dev
| Service | URL | | Service | URL |
|---------|-----| |---------|-----|
| Vue app | http://localhost:5173 | | Vue app | http://localhost:5173 |
| Fastify API | http://localhost:3050 | | Fastify API | http://localhost:3001 |
--- ---
@ -235,7 +235,7 @@ npm run test:e2e -- --debug # Debug mode
**curl example:** **curl example:**
```bash ```bash
curl -s -X POST http://localhost:3050/convert \ curl -s -X POST http://localhost:3001/convert \
-F "image=@/path/to/photo.jpg" \ -F "image=@/path/to/photo.jpg" \
-F "tool=chafa" \ -F "tool=chafa" \
-F 'flags={"width":"80","colors":"256"}' -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. ImageMagick isn't installed. It's required for all conversions regardless of tool.
**CORS error in the browser console** **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:3050`. 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`.
**422 from the server** **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. The binary returned a non-zero exit code. The full stderr is in the response body and shown in the ErrorLog panel.

View File

@ -1,49 +0,0 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name asciinator.waynehayesdevelopment.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name asciinator.waynehayesdevelopment.com;
client_max_body_size 25m;
ssl_certificate /etc/letsencrypt/live/asciinator.waynehayesdevelopment.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/asciinator.waynehayesdevelopment.com/privkey.pem;
# API → Fastify (trailing slash strips /api/ prefix before forwarding)
location /api/ {
proxy_pass http://172.23.0.3:3050/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend → Vite dev server
location / {
proxy_pass http://172.23.0.3:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

25
package-lock.json generated
View File

@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"ansi-to-html": "^0.7.2",
"execa": "^9.6.1", "execa": "^9.6.1",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"vue": "^3.5.32" "vue": "^3.5.32"
@ -2731,30 +2730,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/ansis": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",

View File

@ -19,7 +19,6 @@
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"ansi-to-html": "^0.7.2",
"execa": "^9.6.1", "execa": "^9.6.1",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"vue": "^3.5.32" "vue": "^3.5.32"

View File

@ -23,98 +23,25 @@ describe('buildArgs', () => {
expect(() => buildArgs('chafa', { colors: 'invalid' })).toThrow('Invalid value') 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', () => { it('throws on unknown tool', () => {
expect(() => buildArgs('malicious-tool', {})).toThrow('Unknown tool') expect(() => buildArgs('malicious-tool', {})).toThrow('Unknown tool')
}) })
it('stringifies numeric values', () => { it('stringifies numeric values', () => {
expect(buildArgs('chafa', { size: 80 })).toEqual(['--size', '80']) const args = buildArgs('chafa', { size: 80 })
expect(args).toEqual(['--size', '80'])
}) })
it('handles hyphenated keys', () => { it('handles font-ratio hyphenated key', () => {
expect(buildArgs('chafa', { 'font-ratio': '0.5' })).toEqual(['--font-ratio', '0.5']) const args = buildArgs('chafa', { 'font-ratio': '0.5' })
expect(buildArgs('chafa', { 'dither-grain': '4x4' })).toEqual(['--dither-grain', '4x4']) expect(args).toEqual(['--font-ratio', '0.5'])
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')
}) })
}) })

View File

@ -1,143 +0,0 @@
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/)
})
})

View File

@ -2,17 +2,10 @@ import Fastify from 'fastify'
import multipart from '@fastify/multipart' import multipart from '@fastify/multipart'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import { convertRoute } from './routes/convert.js' import { convertRoute } from './routes/convert.js'
import { testRoutes } from './routes/testApi.js'
const fastify = Fastify({ logger: { level: 'warn' } }) const fastify = Fastify({ logger: { level: 'warn' } })
await fastify.register(cors, { await fastify.register(cors, { origin: true })
origin: [
'http://localhost:5173',
'http://localhost:4173',
'https://asciinator.waynehayesdevelopment.com',
],
})
await fastify.register(multipart, { await fastify.register(multipart, {
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: 20 * 1024 * 1024 },
@ -20,14 +13,9 @@ await fastify.register(multipart, {
await fastify.register(convertRoute) 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 { try {
await fastify.listen({ port: 3050, host: '0.0.0.0' }) await fastify.listen({ port: 3001, host: '127.0.0.1' })
console.log('ASCIInator API → http://0.0.0.0:3050') console.log('ASCIInator API → http://127.0.0.1:3001')
} catch (err) { } catch (err) {
fastify.log.error(err) fastify.log.error(err)
process.exit(1) process.exit(1)

View File

@ -2,45 +2,20 @@ import { execa } from 'execa'
const TIMEOUT = 30_000 const TIMEOUT = 30_000
export const SCHEMAS = { const SCHEMAS = {
chafa: { chafa: {
size: { type: 'value' }, size: { type: 'value' },
colors: { type: 'value', valid: ['none', '2', '8', '16/8', '16', '240', '256', 'full'] }, colors: { type: 'value', valid: ['none', '2', '8', '16/8', '16', '240', '256', 'full'] },
'color-extractor': { type: 'value', valid: ['average', 'median', 'mode'] }, symbols: { type: 'value' },
'color-space': { type: 'value', valid: ['rgb', 'din99d'] }, dither: { type: 'value', valid: ['none', 'ordered', 'diffusion', 'noise'] },
symbols: { type: 'value' }, threshold: { type: 'value' },
fill: { type: 'value' }, 'font-ratio': { 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: { jp2a: {
_argFormat: 'equals', width: { type: 'value' },
size: { type: 'value' }, height: { type: 'value' },
width: { type: 'value' }, chars: { type: 'value' },
height: { type: 'value' }, background: { type: 'value', valid: ['light', 'dark'] },
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': { 'ascii-image-converter': {
width: { type: 'value' }, width: { type: 'value' },
@ -53,7 +28,7 @@ export const SCHEMAS = {
width: { type: 'value' }, width: { type: 'value' },
height: { type: 'value' }, height: { type: 'value' },
format: { type: 'value', valid: ['ansi', 'utf8', 'html'] }, format: { type: 'value', valid: ['ansi', 'utf8', 'html'] },
dither: { type: 'value', valid: ['none', 'ordered2', 'ordered4', 'ordered8', 'random', 'fstein'] }, dither: { type: 'value' },
gamma: { type: 'value' }, gamma: { type: 'value' },
}, },
} }
@ -62,7 +37,6 @@ export function buildArgs(tool, flags) {
const schema = SCHEMAS[tool] const schema = SCHEMAS[tool]
if (!schema) throw new Error(`Unknown tool: ${tool}`) if (!schema) throw new Error(`Unknown tool: ${tool}`)
const equals = schema._argFormat === 'equals'
const args = [] const args = []
for (const [key, value] of Object.entries(flags)) { for (const [key, value] of Object.entries(flags)) {
const def = schema[key] const def = schema[key]
@ -72,8 +46,6 @@ export function buildArgs(tool, flags) {
} }
if (def.type === 'bool') { if (def.type === 'bool') {
if (value) args.push(`--${key}`) if (value) args.push(`--${key}`)
} else if (equals) {
args.push(`--${key}=${String(value)}`)
} else { } else {
args.push(`--${key}`, String(value)) args.push(`--${key}`, String(value))
} }
@ -92,12 +64,7 @@ export async function runChafa(imagePath, flags) {
} }
export async function runJp2a(imagePath, flags) { export async function runJp2a(imagePath, flags) {
const { width, height, ...rest } = flags const args = buildArgs('jp2a', flags)
const processed = { ...rest }
if (width && height) processed.size = `${width}x${height}`
else if (width) processed.size = `${width}x`
else if (height) processed.size = `x${height}`
const args = buildArgs('jp2a', processed)
return execa('jp2a', [...args, imagePath], { timeout: TIMEOUT }) return execa('jp2a', [...args, imagePath], { timeout: TIMEOUT })
} }

View File

@ -1,192 +0,0 @@
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(() => {})
}
})
}

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { reactive, ref, watch } from 'vue' import { reactive, ref } from 'vue'
import ImageInput from './components/ImageInput.vue' import ImageInput from './components/ImageInput.vue'
import ToolSelector from './components/ToolSelector.vue' import ToolSelector from './components/ToolSelector.vue'
import ShellBridge from './components/ShellBridge.vue' import ShellBridge from './components/ShellBridge.vue'
@ -8,7 +8,6 @@ import ErrorLog from './components/ErrorLog.vue'
const state = reactive({ const state = reactive({
image: null, image: null,
imageDims: null,
toolConfig: { tool: 'chafa', flags: {} }, toolConfig: { tool: 'chafa', flags: {} },
result: '', result: '',
errors: [], errors: [],
@ -16,7 +15,6 @@ const state = reactive({
}) })
const bridge = ref(null) const bridge = ref(null)
let debounceTimer = null
function addError(message, source = 'error') { function addError(message, source = 'error') {
state.errors.push({ state.errors.push({
@ -26,27 +24,20 @@ function addError(message, source = 'error') {
}) })
} }
function scheduleConvert(delay = 400) { async function runConvert() {
if (!state.image) return if (!state.image) {
clearTimeout(debounceTimer) addError('No image selected.', 'warn')
debounceTimer = setTimeout(() => bridge.value?.convert(), delay) return
}
state.isConverting = true
state.result = ''
try {
await bridge.value.convert()
} finally {
state.isConverting = false
}
} }
function onImageReady(img) {
state.image = img
const el = new Image()
el.onload = () => { state.imageDims = { w: el.naturalWidth, h: el.naturalHeight } }
el.src = img.dataUrl
}
// New image convert immediately
watch(() => state.image, (img) => {
if (img) scheduleConvert(0)
})
// Flag / tool changes debounce for slider drag
watch(() => state.toolConfig, () => scheduleConvert(400), { deep: true })
function onResult(text) { function onResult(text) {
state.result = text state.result = text
} }
@ -58,18 +49,13 @@ function onError(message) {
<template> <template>
<div class="min-h-screen pb-20" style="background-color: var(--ascii-bg)"> <div class="min-h-screen pb-20" style="background-color: var(--ascii-bg)">
<header class="px-6 py-4 border-b flex items-center gap-4" style="border-color: var(--ascii-border)"> <header class="px-6 py-4 border-b" style="border-color: var(--ascii-border)">
<h1 <h1
class="text-lg font-semibold tracking-widest font-mono" class="text-lg font-semibold tracking-widest font-mono"
style="color: var(--ascii-green)" style="color: var(--ascii-green)"
> >
ASCIInator ASCIInator
</h1> </h1>
<span
v-if="state.isConverting"
class="text-xs font-mono animate-pulse"
style="color: rgba(57,255,20,.5)"
>converting</span>
</header> </header>
<main class="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8"> <main class="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
@ -77,24 +63,32 @@ function onError(message) {
<section> <section>
<h2 class="text-xs font-medium uppercase tracking-wider mb-3" <h2 class="text-xs font-medium uppercase tracking-wider mb-3"
style="color: rgba(224,224,224,.35)">Image</h2> style="color: rgba(224,224,224,.35)">Image</h2>
<ImageInput @image-ready="onImageReady" /> <ImageInput @image-ready="state.image = $event" />
</section> </section>
<section> <section>
<h2 class="text-xs font-medium uppercase tracking-wider mb-3" <h2 class="text-xs font-medium uppercase tracking-wider mb-3"
style="color: rgba(224,224,224,.35)">Tool &amp; Options</h2> style="color: rgba(224,224,224,.35)">Tool &amp; Options</h2>
<ToolSelector :image-dims="state.imageDims" @tool-config="state.toolConfig = $event" /> <ToolSelector @tool-config="state.toolConfig = $event" />
</section> </section>
</div> </div>
<div class="flex justify-center">
<button
class="ascii-btn-primary"
:disabled="state.isConverting || !state.image"
@click="runConvert"
>
{{ state.isConverting ? 'Converting...' : 'Convert' }}
</button>
</div>
<ShellBridge <ShellBridge
ref="bridge" ref="bridge"
:image="state.image" :image="state.image"
:tool-config="state.toolConfig" :tool-config="state.toolConfig"
@conversion-result="onResult" @conversion-result="onResult"
@conversion-error="onError" @conversion-error="onError"
@conversion-start="state.isConverting = true"
@conversion-end="state.isConverting = false"
/> />
<OutputDisplay :result="state.result" /> <OutputDisplay :result="state.result" />

View File

@ -1,6 +1,5 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref } from 'vue'
import AnsiToHtml from 'ansi-to-html'
const props = defineProps({ const props = defineProps({
result: { type: String, default: '' }, result: { type: String, default: '' },
@ -9,26 +8,14 @@ const props = defineProps({
const fontSize = ref(12) const fontSize = ref(12)
const copied = ref(false) const copied = ref(false)
const ansiConverter = new AnsiToHtml({ escapeXML: true, bg: '#1a1a1a', fg: '#e0e0e0' })
const hasAnsi = computed(() => props.result.includes('\x1b['))
const renderedHtml = computed(() => {
if (!hasAnsi.value) return null
return ansiConverter.convert(props.result)
})
async function copyToClipboard() { async function copyToClipboard() {
// Copy plain text strip ANSI codes for clipboard await navigator.clipboard.writeText(props.result)
const plain = props.result.replace(/\x1b\[[0-9;]*m/g, '')
await navigator.clipboard.writeText(plain)
copied.value = true copied.value = true
setTimeout(() => { copied.value = false }, 2000) setTimeout(() => { copied.value = false }, 2000)
} }
function downloadTxt() { function downloadTxt() {
const plain = props.result.replace(/\x1b\[[0-9;]*m/g, '') const blob = new Blob([props.result], { type: 'text/plain' })
const blob = new Blob([plain], { type: 'text/plain' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
@ -45,7 +32,6 @@ function downloadTxt() {
{{ copied ? 'Copied!' : 'Copy' }} {{ copied ? 'Copied!' : 'Copy' }}
</button> </button>
<button class="ascii-btn" @click="downloadTxt">Download .txt</button> <button class="ascii-btn" @click="downloadTxt">Download .txt</button>
<span v-if="hasAnsi" class="text-xs" style="color: rgba(57,255,20,.5)">ANSI</span>
<label class="flex items-center gap-2 text-xs ml-auto" style="color: rgba(224,224,224,.5)"> <label class="flex items-center gap-2 text-xs ml-auto" style="color: rgba(224,224,224,.5)">
{{ fontSize }}px {{ fontSize }}px
<input <input
@ -56,21 +42,7 @@ function downloadTxt() {
/> />
</label> </label>
</div> </div>
<!-- ANSI output: rendered as coloured HTML -->
<pre <pre
v-if="hasAnsi"
class="rounded p-4 overflow-auto leading-tight whitespace-pre"
:style="{
fontSize: fontSize + 'px',
fontFamily: '\'JetBrains Mono\', \'Fira Code\', monospace',
backgroundColor: 'var(--ascii-surface)',
border: '1px solid var(--ascii-border)',
}"
v-html="renderedHtml"
/>
<!-- Plain text output -->
<pre
v-else
class="rounded p-4 overflow-auto leading-tight whitespace-pre" class="rounded p-4 overflow-auto leading-tight whitespace-pre"
:style="{ :style="{
fontSize: fontSize + 'px', fontSize: fontSize + 'px',

View File

@ -4,33 +4,22 @@ const props = defineProps({
toolConfig: { type: Object, default: null }, toolConfig: { type: Object, default: null },
}) })
const emit = defineEmits(['conversion-result', 'conversion-error', 'conversion-start', 'conversion-end']) const emit = defineEmits(['conversion-result', 'conversion-error'])
let activeController = null
async function convert() { async function convert() {
// Cancel any in-flight request
if (activeController) {
activeController.abort()
activeController = null
}
if (!props.image || !props.toolConfig) return if (!props.image || !props.toolConfig) return
const blob = await fetch(props.image.dataUrl).then((r) => r.blob())
const fd = new FormData()
fd.append('image', blob, props.image.filename)
fd.append('tool', props.toolConfig.tool)
fd.append('flags', JSON.stringify(props.toolConfig.flags))
const controller = new AbortController() const controller = new AbortController()
activeController = controller
const timeoutId = setTimeout(() => controller.abort(), 30_000) const timeoutId = setTimeout(() => controller.abort(), 30_000)
emit('conversion-start')
try { try {
const blob = await fetch(props.image.dataUrl).then((r) => r.blob())
const fd = new FormData()
fd.append('image', blob, props.image.filename)
fd.append('tool', props.toolConfig.tool)
fd.append('flags', JSON.stringify(props.toolConfig.flags))
const res = await fetch('/api/convert', { const res = await fetch('/api/convert', {
method: 'POST', method: 'POST',
body: fd, body: fd,
@ -43,17 +32,9 @@ async function convert() {
emit('conversion-result', text) emit('conversion-result', text)
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') { emit('conversion-error', err.name === 'AbortError' ? 'Conversion timed out (30s)' : err.message)
if (activeController === controller) {
emit('conversion-error', 'Conversion timed out (30s)')
}
} else {
emit('conversion-error', err.message)
}
} finally { } finally {
clearTimeout(timeoutId) clearTimeout(timeoutId)
if (activeController === controller) activeController = null
emit('conversion-end')
} }
} }

View File

@ -1,73 +0,0 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
width: { type: [Number, String], default: '' },
height: { type: [Number, String], default: '' },
imageDims: { type: Object, default: null }, // { w, h } in pixels
})
const emit = defineEmits(['update:width', 'update:height'])
const locked = ref(true)
// Terminal chars are ~2x taller than wide, so multiply by 0.5 to get char-space aspect.
function charHeight(w) {
if (!props.imageDims) return null
return Math.max(1, Math.round(w * (props.imageDims.h / props.imageDims.w) * 0.5))
}
function charWidth(h) {
if (!props.imageDims) return null
return Math.max(1, Math.round(h * (props.imageDims.w / props.imageDims.h) * 2))
}
function onWidth(val) {
const w = val === '' ? '' : Number(val)
emit('update:width', w)
if (locked.value && w !== '') {
const h = charHeight(w)
if (h !== null) emit('update:height', h)
}
}
function onHeight(val) {
const h = val === '' ? '' : Number(val)
emit('update:height', h)
if (locked.value && h !== '') {
const w = charWidth(h)
if (w !== null) emit('update:width', w)
}
}
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-xs w-16 shrink-0" style="color: rgba(224,224,224,.5)">
W {{ width || 'auto' }}
</span>
<input type="range" min="10" max="400" step="1"
:value="width || 80"
@input="onWidth($event.target.value)"
class="flex-1" style="accent-color: var(--ascii-green)" />
<button
class="text-xs px-2 py-0.5 rounded border shrink-0 transition-colors"
:style="{
borderColor: locked ? 'var(--ascii-green)' : 'var(--ascii-border)',
color: locked ? 'var(--ascii-green)' : 'rgba(224,224,224,.3)',
}"
@click="locked = !locked"
>{{ locked ? 'lock' : 'free' }}</button>
</div>
<div class="flex items-center gap-2">
<span class="text-xs w-16 shrink-0" style="color: rgba(224,224,224,.5)">
H {{ height || 'auto' }}
</span>
<input type="range" min="5" max="200" step="1"
:value="height || 40"
@input="onHeight($event.target.value)"
class="flex-1" style="accent-color: var(--ascii-green)" />
<div class="w-10 shrink-0" />
</div>
</div>
</template>

View File

@ -5,7 +5,6 @@ import Jp2aOptions from './options/Jp2aOptions.vue'
import AsciiOptions from './options/AsciiOptions.vue' import AsciiOptions from './options/AsciiOptions.vue'
import ImgTxtOptions from './options/ImgTxtOptions.vue' import ImgTxtOptions from './options/ImgTxtOptions.vue'
defineProps({ imageDims: { type: Object, default: null } })
const emit = defineEmits(['tool-config']) const emit = defineEmits(['tool-config'])
const TOOLS = [ const TOOLS = [
@ -25,22 +24,8 @@ const OPTIONS_MAP = {
const selectedTool = ref('chafa') const selectedTool = ref('chafa')
const toolFlags = reactive({ const toolFlags = reactive({
chafa: { chafa: { width: '', height: '', colors: '', symbols: '', dither: '', threshold: '', 'font-ratio': '' },
width: '', height: '', jp2a: { width: '', height: '', chars: '', background: '' },
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: '' }, 'ascii-image-converter': { width: '', height: '', color: false, braille: false, threshold: '' },
img2txt: { width: '', height: '', format: '', dither: '', gamma: '' }, img2txt: { width: '', height: '', format: '', dither: '', gamma: '' },
}) })
@ -67,7 +52,6 @@ watch(
<button <button
v-for="t in TOOLS" v-for="t in TOOLS"
:key="t.id" :key="t.id"
data-testid="tool-btn"
class="px-3 py-1 text-xs font-mono rounded border transition-colors" class="px-3 py-1 text-xs font-mono rounded border transition-colors"
:style="{ :style="{
borderColor: selectedTool === t.id ? 'var(--ascii-green)' : 'var(--ascii-border)', borderColor: selectedTool === t.id ? 'var(--ascii-green)' : 'var(--ascii-border)',
@ -83,7 +67,6 @@ watch(
<component <component
:is="OPTIONS_MAP[selectedTool]" :is="OPTIONS_MAP[selectedTool]"
:flags="toolFlags[selectedTool]" :flags="toolFlags[selectedTool]"
:image-dims="imageDims"
@update:flags="onFlagsUpdate" @update:flags="onFlagsUpdate"
/> />
</div> </div>

View File

@ -3,98 +3,39 @@ import { mount } from '@vue/test-utils'
import ChafaOptions from '../options/ChafaOptions.vue' import ChafaOptions from '../options/ChafaOptions.vue'
const EMPTY_FLAGS = { const EMPTY_FLAGS = {
width: '', height: '', width: '', height: '', colors: '', symbols: '',
colors: '', 'color-extractor': '', 'color-space': '', dither: '', threshold: '', 'font-ratio': '',
symbols: '', fill: '',
dither: '', 'dither-grain': '', 'dither-intensity': '',
threshold: '', 'font-ratio': '', work: '',
zoom: false, stretch: false, 'fg-only': false,
format: 'symbols',
} }
// Range slider layout in ChafaOptions (SizeControl adds first two):
// 0: width (SizeControl), 1: height (SizeControl),
// 2: dither-intensity, 3: threshold, 4: font-ratio, 5: work
describe('ChafaOptions', () => { describe('ChafaOptions', () => {
it('renders without error', () => { it('renders without error', () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } }) const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
expect(wrapper.exists()).toBe(true) expect(wrapper.exists()).toBe(true)
}) })
it('emits numeric width via SizeControl width slider', async () => { it('emits update:flags with updated value on input change', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS, imageDims: null } })
await wrapper.findAll('input[type="range"]')[0].setValue('120')
expect(wrapper.emitted('update:flags')[0][0].width).toBe(120)
})
it('preserves other flag values when width changes', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: { ...EMPTY_FLAGS, colors: '256' }, imageDims: null } })
await wrapper.findAll('input[type="range"]')[0].setValue('80')
expect(wrapper.emitted('update:flags')[0][0].colors).toBe('256')
})
it('emits selected color depth', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } }) const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.find('select').setValue('256') const widthInput = wrapper.find('input[type="number"]')
expect(wrapper.emitted('update:flags')[0][0].colors).toBe('256') await widthInput.setValue('120')
const emitted = wrapper.emitted('update:flags')
expect(emitted).toBeTruthy()
expect(emitted[0][0].width).toBe('120')
}) })
it('emits selected symbols preset', async () => { it('preserves other flag values when one changes', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } }) const flags = { ...EMPTY_FLAGS, colors: '256' }
// symbols is the 4th select (after colors, extractor, color-space) const wrapper = mount(ChafaOptions, { props: { flags } })
const selects = wrapper.findAll('select') const widthInput = wrapper.find('input[type="number"]')
await selects[3].setValue('braille') await widthInput.setValue('80')
expect(wrapper.emitted('update:flags')[0][0].symbols).toBe('braille') const emitted = wrapper.emitted('update:flags')
expect(emitted[0][0].colors).toBe('256')
}) })
it('emits selected fill preset', async () => { it('emits update:flags with selected value on select change', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } }) const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
// fill is the 5th select const select = wrapper.find('select')
const selects = wrapper.findAll('select') await select.setValue('256')
await selects[4].setValue('block') const emitted = wrapper.emitted('update:flags')
expect(wrapper.emitted('update:flags')[0][0].fill).toBe('block') expect(emitted[0][0].colors).toBe('256')
})
it('emits dither-intensity from its range slider', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.findAll('input[type="range"]')[2].setValue('2.5')
expect(wrapper.emitted('update:flags')[0][0]['dither-intensity']).toBe('2.5')
})
it('emits threshold from its range slider', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.findAll('input[type="range"]')[3].setValue('0.75')
expect(wrapper.emitted('update:flags')[0][0].threshold).toBe('0.75')
})
it('emits work quality from its range slider', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.findAll('input[type="range"]')[5].setValue('9')
expect(wrapper.emitted('update:flags')[0][0].work).toBe(9)
})
it('emits true for zoom toggle when checked', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.find('input[type="checkbox"]').setChecked(true)
expect(wrapper.emitted('update:flags')[0][0].zoom).toBe(true)
})
it('renders all three toggle checkboxes', () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
expect(wrapper.findAll('input[type="checkbox"]').length).toBe(3)
})
it('renders 6 range sliders total', () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
expect(wrapper.findAll('input[type="range"]').length).toBe(6)
})
it('emits format change', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
// format select is the last select
const selects = wrapper.findAll('select')
await selects[selects.length - 1].setValue('utf8')
expect(wrapper.emitted('update:flags')[0][0].format).toBe('utf8')
}) })
}) })

View File

@ -1,90 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Jp2aOptions from '../options/Jp2aOptions.vue'
const EMPTY_FLAGS = {
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,
}
// Range slider layout:
// 0: width (SizeControl), 1: height (SizeControl),
// 2: red, 3: green, 4: blue, 5: edge-threshold
describe('Jp2aOptions', () => {
it('renders without error', () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
expect(wrapper.exists()).toBe(true)
})
it('emits numeric width via SizeControl width slider', async () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS, imageDims: null } })
await wrapper.findAll('input[type="range"]')[0].setValue('100')
expect(wrapper.emitted('update:flags')[0][0].width).toBe(100)
})
it('emits background select value', async () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
const selects = wrapper.findAll('select')
const bgSelect = selects.find(s => s.findAll('option').some(o => o.text() === 'dark'))
await bgSelect.setValue('dark')
expect(wrapper.emitted('update:flags')[0][0].background).toBe('dark')
})
it('emits color-depth select value', async () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.find('select').setValue('8')
expect(wrapper.emitted('update:flags')[0][0]['color-depth']).toBe('8')
})
it('emits red slider value', async () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.findAll('input[type="range"]')[2].setValue('0.5')
expect(wrapper.emitted('update:flags')[0][0].red).toBe('0.5')
})
it('emits green slider value', async () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.findAll('input[type="range"]')[3].setValue('0.4')
expect(wrapper.emitted('update:flags')[0][0].green).toBe('0.4')
})
it('emits blue slider value', async () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.findAll('input[type="range"]')[4].setValue('0.2')
expect(wrapper.emitted('update:flags')[0][0].blue).toBe('0.2')
})
it('emits edge-threshold slider value', async () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
await wrapper.findAll('input[type="range"]')[5].setValue('0.4')
expect(wrapper.emitted('update:flags')[0][0]['edge-threshold']).toBe('0.4')
})
it('emits invert toggle', async () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
// invert is the 4th checkbox (colors, fill, grayscale, invert...)
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[3].setChecked(true)
expect(wrapper.emitted('update:flags')[0][0].invert).toBe(true)
})
it('renders 8 toggle checkboxes', () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
expect(wrapper.findAll('input[type="checkbox"]').length).toBe(8)
})
it('renders 6 range sliders (2 size + 3 RGB + 1 edge)', () => {
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
expect(wrapper.findAll('input[type="range"]').length).toBe(6)
})
it('preserves other flags when one changes', async () => {
const wrapper = mount(Jp2aOptions, { props: { flags: { ...EMPTY_FLAGS, chars: '.:#' }, imageDims: null } })
await wrapper.findAll('input[type="range"]')[0].setValue('80')
expect(wrapper.emitted('update:flags')[0][0].chars).toBe('.:#')
})
})

View File

@ -9,7 +9,7 @@ const IMAGE = {
} }
const TOOL_CONFIG = { tool: 'chafa', flags: { width: '80' } } const TOOL_CONFIG = { tool: 'chafa', flags: { width: '80' } }
function mockFetch(convertResponse) { function mockFetch(blobResponse, convertResponse) {
return vi.fn() return vi.fn()
.mockResolvedValueOnce({ blob: vi.fn().mockResolvedValue(new Blob([''])) }) .mockResolvedValueOnce({ blob: vi.fn().mockResolvedValue(new Blob([''])) })
.mockResolvedValueOnce(convertResponse) .mockResolvedValueOnce(convertResponse)
@ -26,14 +26,20 @@ describe('ShellBridge', () => {
}) })
it('emits conversion-result with stdout on 200', async () => { it('emits conversion-result with stdout on 200', async () => {
vi.stubGlobal('fetch', mockFetch({ ok: true, text: vi.fn().mockResolvedValue('ascii art') })) vi.stubGlobal(
'fetch',
mockFetch(null, { ok: true, text: vi.fn().mockResolvedValue('ascii art') }),
)
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } }) const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
await wrapper.vm.convert() await wrapper.vm.convert()
expect(wrapper.emitted('conversion-result')?.[0][0]).toBe('ascii art') expect(wrapper.emitted('conversion-result')?.[0][0]).toBe('ascii art')
}) })
it('emits conversion-error with body on non-2xx', async () => { it('emits conversion-error with body on non-2xx', async () => {
vi.stubGlobal('fetch', mockFetch({ ok: false, text: vi.fn().mockResolvedValue('tool failed') })) vi.stubGlobal(
'fetch',
mockFetch(null, { ok: false, text: vi.fn().mockResolvedValue('tool failed') }),
)
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } }) const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
await wrapper.vm.convert() await wrapper.vm.convert()
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('tool failed') expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('tool failed')
@ -50,25 +56,4 @@ describe('ShellBridge', () => {
await wrapper.vm.convert() await wrapper.vm.convert()
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('Network error') expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('Network error')
}) })
it('emits conversion-start before fetching', async () => {
vi.stubGlobal('fetch', mockFetch({ ok: true, text: vi.fn().mockResolvedValue('ok') }))
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
await wrapper.vm.convert()
expect(wrapper.emitted('conversion-start')).toBeTruthy()
})
it('emits conversion-end after success', async () => {
vi.stubGlobal('fetch', mockFetch({ ok: true, text: vi.fn().mockResolvedValue('ok') }))
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
await wrapper.vm.convert()
expect(wrapper.emitted('conversion-end')).toBeTruthy()
})
it('emits conversion-end even after error', async () => {
vi.stubGlobal('fetch', mockFetch({ ok: false, text: vi.fn().mockResolvedValue('err') }))
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
await wrapper.vm.convert()
expect(wrapper.emitted('conversion-end')).toBeTruthy()
})
}) })

View File

@ -1,71 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SizeControl from '../SizeControl.vue'
const DIMS = { w: 1920, h: 1080 } // 16:9
describe('SizeControl', () => {
it('renders two range sliders', () => {
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
expect(wrapper.findAll('input[type="range"]').length).toBe(2)
})
it('emits update:width when width slider moves', async () => {
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
await wrapper.findAll('input[type="range"]')[0].setValue('120')
expect(wrapper.emitted('update:width')?.[0][0]).toBe(120)
})
it('emits update:height when height slider moves', async () => {
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
await wrapper.findAll('input[type="range"]')[1].setValue('30')
expect(wrapper.emitted('update:height')?.[0][0]).toBe(30)
})
it('auto-computes height from width when locked and imageDims set', async () => {
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
await wrapper.findAll('input[type="range"]')[0].setValue('160')
const heights = wrapper.emitted('update:height')
expect(heights).toBeTruthy()
// 160 * (1080/1920) * 0.5 = 45
expect(heights[0][0]).toBe(45)
})
it('auto-computes width from height when locked and imageDims set', async () => {
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
await wrapper.findAll('input[type="range"]')[1].setValue('45')
const widths = wrapper.emitted('update:width')
expect(widths).toBeTruthy()
// 45 * (1920/1080) * 2 = 160
expect(widths[0][0]).toBe(160)
})
it('does not auto-compute when unlocked', async () => {
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
await wrapper.find('button').trigger('click') // unlock
await wrapper.findAll('input[type="range"]')[0].setValue('120')
// Should emit width but NOT height
expect(wrapper.emitted('update:width')).toBeTruthy()
expect(wrapper.emitted('update:height')).toBeFalsy()
})
it('does not auto-compute when imageDims is null', async () => {
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: null } })
await wrapper.findAll('input[type="range"]')[0].setValue('120')
expect(wrapper.emitted('update:height')).toBeFalsy()
})
it('shows lock button toggling between lock and free', async () => {
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
const btn = wrapper.find('button')
expect(btn.text()).toBe('lock')
await btn.trigger('click')
expect(btn.text()).toBe('free')
})
it('displays current width and height values', () => {
const wrapper = mount(SizeControl, { props: { width: 80, height: 40, imageDims: DIMS } })
expect(wrapper.text()).toContain('80')
expect(wrapper.text()).toContain('40')
})
})

View File

@ -5,7 +5,7 @@ import ToolSelector from '../ToolSelector.vue'
describe('ToolSelector', () => { describe('ToolSelector', () => {
it('renders four tool buttons', () => { it('renders four tool buttons', () => {
const wrapper = mount(ToolSelector) const wrapper = mount(ToolSelector)
expect(wrapper.findAll('[data-testid="tool-btn"]').length).toBe(4) expect(wrapper.findAll('button').length).toBe(4)
}) })
it('emits tool-config immediately with chafa as default', () => { it('emits tool-config immediately with chafa as default', () => {
@ -24,7 +24,7 @@ describe('ToolSelector', () => {
it('switches active tool and emits updated tool-config', async () => { it('switches active tool and emits updated tool-config', async () => {
const wrapper = mount(ToolSelector) const wrapper = mount(ToolSelector)
const buttons = wrapper.findAll('[data-testid="tool-btn"]') const buttons = wrapper.findAll('button')
await buttons[1].trigger('click') // jp2a await buttons[1].trigger('click') // jp2a
const emitted = wrapper.emitted('tool-config') const emitted = wrapper.emitted('tool-config')
const latest = emitted[emitted.length - 1][0] const latest = emitted[emitted.length - 1][0]
@ -33,17 +33,18 @@ describe('ToolSelector', () => {
it('preserves flags for a tool when switching away and back', async () => { it('preserves flags for a tool when switching away and back', async () => {
const wrapper = mount(ToolSelector) const wrapper = mount(ToolSelector)
const [chafaBtn, jp2aBtn] = wrapper.findAll('[data-testid="tool-btn"]') const [chafaBtn, jp2aBtn] = wrapper.findAll('button')
// Set width via SizeControl width range slider (first range input) // Set width on chafa via ChafaOptions input
const widthSlider = wrapper.find('input[type="range"]') const widthInput = wrapper.find('input[type="number"]')
await widthSlider.setValue('120') await widthInput.setValue('120')
// Switch to jp2a and back
await jp2aBtn.trigger('click') await jp2aBtn.trigger('click')
await chafaBtn.trigger('click') await chafaBtn.trigger('click')
const emitted = wrapper.emitted('tool-config') const emitted = wrapper.emitted('tool-config')
const latest = emitted[emitted.length - 1][0] const latest = emitted[emitted.length - 1][0]
expect(latest.flags.width).toBe(120) expect(latest.flags.width).toBe('120')
}) })
}) })

View File

@ -1,56 +1,40 @@
<script setup> <script setup>
import SizeControl from '../SizeControl.vue' const props = defineProps({ flags: { type: Object, required: true } })
const props = defineProps({
flags: { type: Object, required: true },
imageDims: { type: Object, default: null },
})
const emit = defineEmits(['update:flags']) const emit = defineEmits(['update:flags'])
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val }) const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
</script> </script>
<template> <template>
<div class="flex flex-col gap-5"> <div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
<!-- Size --> Width
<div> <input class="ascii-input" type="number" min="1" :value="flags.width"
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Size</p> @input="set('width', $event.target.value)" placeholder="auto" />
<SizeControl </label>
:width="flags.width" :height="flags.height" :image-dims="imageDims" <label class="ascii-label">
@update:width="setNum('width', $event)" Height
@update:height="setNum('height', $event)" <input class="ascii-input" type="number" min="1" :value="flags.height"
/> @input="set('height', $event.target.value)" placeholder="auto" />
</div> </label>
<label class="ascii-label">
<!-- Threshold --> Threshold (0255)
<div> <input class="ascii-input" type="number" min="0" max="255" :value="flags.threshold"
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Threshold</p> @input="set('threshold', $event.target.value)" placeholder="128" />
<label class="ascii-label"> </label>
Brightness threshold {{ flags.threshold !== '' && flags.threshold !== undefined ? flags.threshold : '128' }} <div class="ascii-label justify-center gap-3 flex-row items-center col-span-2">
<input type="range" min="0" max="255" step="1" <label class="flex items-center gap-2 cursor-pointer">
:value="flags.threshold ?? 128" <input type="checkbox" :checked="flags.color"
@input="setNum('threshold', $event.target.value)" @change="set('color', $event.target.checked)"
class="w-full" style="accent-color: var(--ascii-green)" /> style="accent-color: var(--ascii-green)" />
Color
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" :checked="flags.braille"
@change="set('braille', $event.target.checked)"
style="accent-color: var(--ascii-green)" />
Braille
</label> </label>
</div> </div>
<!-- Toggles -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Options</p>
<div class="flex flex-wrap gap-3">
<label v-for="opt in ['color', 'braille']" :key="opt"
class="flex items-center gap-2 cursor-pointer select-none text-sm"
style="color: var(--ascii-text)">
<input type="checkbox"
:checked="!!flags[opt]"
@change="set(opt, $event.target.checked)"
class="w-4 h-4" style="accent-color: var(--ascii-green)" />
{{ opt }}
</label>
</div>
</div>
</div> </div>
</template> </template>

View File

@ -1,189 +1,52 @@
<script setup> <script setup>
import SizeControl from '../SizeControl.vue' const props = defineProps({ flags: { type: Object, required: true } })
const props = defineProps({
flags: { type: Object, required: true },
imageDims: { type: Object, default: null },
})
const emit = defineEmits(['update:flags']) const emit = defineEmits(['update:flags'])
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val }) const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
const setBool = (key, val) => set(key, val)
</script> </script>
<template> <template>
<div class="flex flex-col gap-5"> <div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
<!-- Size --> Width
<div> <input class="ascii-input" type="number" min="1" :value="flags.width"
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Size</p> @input="set('width', $event.target.value)" placeholder="auto" />
<SizeControl </label>
:width="flags.width" :height="flags.height" :image-dims="imageDims" <label class="ascii-label">
@update:width="setNum('width', $event)" Height
@update:height="setNum('height', $event)" <input class="ascii-input" type="number" min="1" :value="flags.height"
/> @input="set('height', $event.target.value)" placeholder="auto" />
</div> </label>
<label class="ascii-label">
<!-- Color --> Colors
<div> <select class="ascii-input" :value="flags.colors" @change="set('colors', $event.target.value)">
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Color</p> <option value="">default</option>
<div class="grid grid-cols-2 gap-3"> <option>none</option><option>2</option><option>8</option>
<label class="ascii-label"> <option value="16/8">16/8</option><option>16</option>
Depth <option>240</option><option>256</option><option>full</option>
<select class="ascii-input" :value="flags.colors" @change="set('colors', $event.target.value)"> </select>
<option value="">default</option> </label>
<option>none</option><option>2</option><option>8</option> <label class="ascii-label">
<option value="16/8">16/8</option><option>16</option> Dither
<option>240</option><option>256</option><option>full</option> <select class="ascii-input" :value="flags.dither" @change="set('dither', $event.target.value)">
</select> <option value="">default</option>
</label> <option>none</option><option>ordered</option><option>diffusion</option><option>noise</option>
<label class="ascii-label"> </select>
Extractor </label>
<select class="ascii-input" :value="flags['color-extractor']" @change="set('color-extractor', $event.target.value)"> <label class="ascii-label">
<option value="">default</option> Symbols
<option>average</option><option>median</option><option>mode</option> <input class="ascii-input" type="text" :value="flags.symbols"
</select> @input="set('symbols', $event.target.value)" placeholder="e.g. block+border" />
</label> </label>
<label class="ascii-label"> <label class="ascii-label">
Color space Threshold (01)
<select class="ascii-input" :value="flags['color-space']" @change="set('color-space', $event.target.value)"> <input class="ascii-input" type="number" min="0" max="1" step="0.05" :value="flags.threshold"
<option value="">default</option> @input="set('threshold', $event.target.value)" placeholder="0.5" />
<option>rgb</option><option>din99d</option> </label>
</select> <label class="ascii-label col-span-2">
</label> Font ratio
</div> <input class="ascii-input" type="number" min="0.1" step="0.05" :value="flags['font-ratio']"
</div> @input="set('font-ratio', $event.target.value)" placeholder="auto" />
</label>
<!-- Symbols -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Symbols</p>
<div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
Symbols
<select class="ascii-input" :value="flags.symbols" @change="set('symbols', $event.target.value)">
<option value="">default</option>
<option>ascii</option>
<option value="block">block</option>
<option value="block+border">block+border</option>
<option value="block+border+extra">block+border+extra</option>
<option value="block+border+diagonal">block+border+diagonal</option>
<option>braille</option>
<option>half</option>
<option value="half+border">half+border</option>
<option>sextant</option>
<option>wedge</option>
<option>dot</option>
<option>stipple</option>
<option value="all-wide">all-wide</option>
<option>all</option>
<option>none</option>
</select>
</label>
<label class="ascii-label">
Fill
<select class="ascii-input" :value="flags.fill" @change="set('fill', $event.target.value)">
<option value="">default</option>
<option>none</option>
<option>block</option>
<option>half</option>
<option>dot</option>
<option>stipple</option>
<option>space</option>
<option>braille</option>
</select>
</label>
</div>
</div>
<!-- Dither -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Dither</p>
<div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
Mode
<select class="ascii-input" :value="flags.dither" @change="set('dither', $event.target.value)">
<option value="">default</option>
<option>none</option><option>ordered</option><option>diffusion</option><option>noise</option>
</select>
</label>
<label class="ascii-label">
Grain
<select class="ascii-input" :value="flags['dither-grain']" @change="set('dither-grain', $event.target.value)">
<option value="">default</option>
<option>1x1</option><option>2x2</option><option>4x4</option><option>8x8</option>
</select>
</label>
<label class="ascii-label col-span-2">
Intensity {{ flags['dither-intensity'] || '1.0' }}
<input type="range" min="0" max="5" step="0.1"
:value="flags['dither-intensity'] || 1"
@input="set('dither-intensity', $event.target.value)"
class="w-full" style="accent-color: var(--ascii-green)" />
</label>
</div>
</div>
<!-- Threshold & Font ratio -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Threshold &amp; Geometry</p>
<div class="flex flex-col gap-3">
<label class="ascii-label">
Threshold {{ flags.threshold !== undefined && flags.threshold !== '' ? Number(flags.threshold).toFixed(2) : '0.50' }}
<input type="range" min="0" max="1" step="0.01"
:value="flags.threshold ?? 0.5"
@input="set('threshold', $event.target.value)"
class="w-full" style="accent-color: var(--ascii-green)" />
</label>
<label class="ascii-label">
Font ratio {{ flags['font-ratio'] || 'auto' }}
<input type="range" min="0.1" max="2" step="0.05"
:value="flags['font-ratio'] || 0.5"
@input="set('font-ratio', $event.target.value)"
class="w-full" style="accent-color: var(--ascii-green)" />
</label>
</div>
</div>
<!-- Quality -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Quality</p>
<label class="ascii-label">
Work {{ flags.work || '5' }}
<input type="range" min="1" max="9" step="1"
:value="flags.work || 5"
@input="setNum('work', $event.target.value)"
class="w-full" style="accent-color: var(--ascii-green)" />
</label>
</div>
<!-- Output format -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Output</p>
<label class="ascii-label">
Format
<select class="ascii-input" :value="flags.format" @change="set('format', $event.target.value)">
<option value="symbols">symbols plain text, no color codes</option>
<option value="utf8">utf8 ANSI color codes</option>
</select>
</label>
</div>
<!-- Toggles -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Options</p>
<div class="flex flex-wrap gap-3">
<label v-for="opt in ['zoom', 'stretch', 'fg-only']" :key="opt"
class="flex items-center gap-2 cursor-pointer select-none text-sm"
style="color: var(--ascii-text)">
<input type="checkbox"
:checked="!!flags[opt]"
@change="setBool(opt, $event.target.checked)"
class="accent-green-400 w-4 h-4" />
{{ opt }}
</label>
</div>
</div>
</div> </div>
</template> </template>

View File

@ -1,62 +1,39 @@
<script setup> <script setup>
import SizeControl from '../SizeControl.vue' const props = defineProps({ flags: { type: Object, required: true } })
const props = defineProps({
flags: { type: Object, required: true },
imageDims: { type: Object, default: null },
})
const emit = defineEmits(['update:flags']) const emit = defineEmits(['update:flags'])
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val }) const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
</script> </script>
<template> <template>
<div class="flex flex-col gap-5"> <div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
<!-- Size --> Width
<div> <input class="ascii-input" type="number" min="1" :value="flags.width"
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Size</p> @input="set('width', $event.target.value)" placeholder="auto" />
<SizeControl </label>
:width="flags.width" :height="flags.height" :image-dims="imageDims" <label class="ascii-label">
@update:width="setNum('width', $event)" Height
@update:height="setNum('height', $event)" <input class="ascii-input" type="number" min="1" :value="flags.height"
/> @input="set('height', $event.target.value)" placeholder="auto" />
</div> </label>
<label class="ascii-label">
<!-- Format & Dither --> Format
<div> <select class="ascii-input" :value="flags.format"
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Output</p> @change="set('format', $event.target.value)">
<div class="grid grid-cols-2 gap-3"> <option value="">default</option>
<label class="ascii-label"> <option>ansi</option><option>utf8</option><option>html</option>
Format </select>
<select class="ascii-input" :value="flags.format" @change="set('format', $event.target.value)"> </label>
<option value="">default</option> <label class="ascii-label">
<option>ansi</option><option>utf8</option><option>html</option> Dither
</select> <input class="ascii-input" type="text" :value="flags.dither"
</label> @input="set('dither', $event.target.value)" placeholder="default" />
<label class="ascii-label"> </label>
Dither <label class="ascii-label col-span-2">
<select class="ascii-input" :value="flags.dither" @change="set('dither', $event.target.value)"> Gamma
<option value="">default</option> <input class="ascii-input" type="number" min="0.1" step="0.1" :value="flags.gamma"
<option>none</option><option>ordered2</option><option>ordered4</option> @input="set('gamma', $event.target.value)" placeholder="1.0" />
<option>ordered8</option><option>random</option><option>fstein</option> </label>
</select>
</label>
</div>
</div>
<!-- Gamma -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Gamma</p>
<label class="ascii-label">
Gamma {{ flags.gamma !== '' && flags.gamma !== undefined ? Number(flags.gamma).toFixed(1) : '1.0' }}
<input type="range" min="0.1" max="3.0" step="0.1"
:value="flags.gamma ?? 1.0"
@input="set('gamma', $event.target.value)"
class="w-full" style="accent-color: var(--ascii-green)" />
</label>
</div>
</div> </div>
</template> </template>

View File

@ -1,120 +1,34 @@
<script setup> <script setup>
import SizeControl from '../SizeControl.vue' const props = defineProps({ flags: { type: Object, required: true } })
const props = defineProps({
flags: { type: Object, required: true },
imageDims: { type: Object, default: null },
})
const emit = defineEmits(['update:flags']) const emit = defineEmits(['update:flags'])
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val }) const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
const setBool = (key, val) => set(key, val)
</script> </script>
<template> <template>
<div class="flex flex-col gap-5"> <div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
<!-- Size --> Width
<div> <input class="ascii-input" type="number" min="1" :value="flags.width"
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Size</p> @input="set('width', $event.target.value)" placeholder="auto" />
<SizeControl </label>
:width="flags.width" :height="flags.height" :image-dims="imageDims" <label class="ascii-label">
@update:width="setNum('width', $event)" Height
@update:height="setNum('height', $event)" <input class="ascii-input" type="number" min="1" :value="flags.height"
/> @input="set('height', $event.target.value)" placeholder="auto" />
</div> </label>
<label class="ascii-label">
<!-- Color --> Background
<div> <select class="ascii-input" :value="flags.background"
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Color</p> @change="set('background', $event.target.value)">
<div class="grid grid-cols-2 gap-3"> <option value="">default</option>
<label class="ascii-label"> <option>light</option><option>dark</option>
Color depth </select>
<select class="ascii-input" :value="flags['color-depth']" @change="set('color-depth', $event.target.value)"> </label>
<option value="">default</option> <label class="ascii-label">
<option value="4">4 ANSI</option> Chars
<option value="8">8 256 palette</option> <input class="ascii-input" type="text" :value="flags.chars"
<option value="24">24 truecolor</option> @input="set('chars', $event.target.value)" placeholder="e.g. .:-=+*#%@" />
</select> </label>
</label>
<label class="ascii-label">
Background
<select class="ascii-input" :value="flags.background" @change="set('background', $event.target.value)">
<option value="">default</option>
<option>light</option>
<option>dark</option>
</select>
</label>
</div>
</div>
<!-- RGB channel weights -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">RGB weights</p>
<div class="flex flex-col gap-3">
<label class="ascii-label">
Red {{ flags.red !== '' && flags.red !== undefined ? Number(flags.red).toFixed(3) : '0.299' }}
<input type="range" min="0" max="1" step="0.001"
:value="flags.red ?? 0.2989"
@input="set('red', $event.target.value)"
class="w-full" style="accent-color: #ff4444" />
</label>
<label class="ascii-label">
Green {{ flags.green !== '' && flags.green !== undefined ? Number(flags.green).toFixed(3) : '0.587' }}
<input type="range" min="0" max="1" step="0.001"
:value="flags.green ?? 0.5866"
@input="set('green', $event.target.value)"
class="w-full" style="accent-color: #39ff14" />
</label>
<label class="ascii-label">
Blue {{ flags.blue !== '' && flags.blue !== undefined ? Number(flags.blue).toFixed(3) : '0.114' }}
<input type="range" min="0" max="1" step="0.001"
:value="flags.blue ?? 0.1145"
@input="set('blue', $event.target.value)"
class="w-full" style="accent-color: #4488ff" />
</label>
</div>
</div>
<!-- Edge detection -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Edge detection</p>
<label class="ascii-label">
Edge threshold {{ flags['edge-threshold'] !== '' && flags['edge-threshold'] !== undefined ? Number(flags['edge-threshold']).toFixed(2) : 'off' }}
<input type="range" min="0" max="1" step="0.01"
:value="flags['edge-threshold'] ?? 0"
@input="set('edge-threshold', $event.target.value)"
class="w-full" style="accent-color: var(--ascii-green)" />
</label>
</div>
<!-- Characters -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Characters</p>
<label class="ascii-label">
Char palette
<input class="ascii-input" type="text" :value="flags.chars"
@input="set('chars', $event.target.value)" placeholder="e.g. .:-=+*#%@" />
</label>
</div>
<!-- Toggles -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Options</p>
<div class="flex flex-wrap gap-3">
<label v-for="opt in ['colors', 'fill', 'grayscale', 'invert', 'border', 'flipx', 'flipy', 'edges-only']"
:key="opt"
class="flex items-center gap-2 cursor-pointer select-none text-sm"
style="color: var(--ascii-text)">
<input type="checkbox"
:checked="!!flags[opt]"
@change="setBool(opt, $event.target.checked)"
class="w-4 h-4" style="accent-color: var(--ascii-green)" />
{{ opt }}
</label>
</div>
</div>
</div> </div>
</template> </template>

View File

@ -2,10 +2,13 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
vueDevTools(),
], ],
resolve: { resolve: {
alias: { alias: {
@ -13,11 +16,9 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: '0.0.0.0',
allowedHosts: ['asciinator.waynehayesdevelopment.com'],
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3050', target: 'http://localhost:3001',
rewrite: (path) => path.replace(/^\/api/, ''), rewrite: (path) => path.replace(/^\/api/, ''),
}, },
}, },