Compare commits

...

4 Commits

Author SHA1 Message Date
Your Name
03e0bcae6f chore: gitignore Syncthing artifacts
Adds .stignore, .stfolder*, .stversions, *.sync-conflict-* so future
syncs don't pollute the working tree. Deleted existing conflict files
and stale .stfolder.removed-* (current files won).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:24:23 +00:00
dev
9a4e501be9 fix: jp2a requires --key=value arg format
jp2a 1.3.x does not accept space-separated long option args.
Added _argFormat: 'equals' to jp2a schema so buildArgs emits
--key=value for value flags. Also mirrors chafa's width/height →
--size=WxH combining in runJp2a.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:31:37 +00:00
dev
a95c184be7 fix: Fastify on 0.0.0.0, remove vueDevTools, nginx API proxy config
- Fastify listens on 0.0.0.0:3050 (was 127.0.0.1, blocked nginx proxy)
- Remove vite-plugin-vue-devtools (was causing silent page reloads)
- Add asciinator.conf with working nginx config (API proxy strips /api/ prefix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:26:18 +00:00
dev
e904cdb3cb feat: full GUI, test API, auto-convert, aspect-ratio sliders
- Auto-convert on image load (0ms) and flag change (400ms debounce)
- SizeControl: linked width/height sliders with aspect-ratio lock and 0.5 font correction factor
- Full flag exposure for all 4 tools (chafa, jp2a, ascii-image-converter, img2txt)
- ChafaOptions: symbols/fill dropdowns, dither controls, work/threshold/font-ratio sliders, format select, toggles
- Jp2aOptions: color-depth, RGB weight sliders, edge controls, 8 toggles
- ImgTxtOptions: dither select with valid libcaca values, gamma slider
- OutputDisplay: ansi-to-html rendering for colored chafa output
- ShellBridge: abort-previous pattern, conversion-start/end lifecycle events
- Test API (ENABLE_TEST_API=true): /test/health, /test/convert, /test/flags/:tool, /test/imagemagick
- buildArgs: space-separated args (not = format); full schemas in SCHEMAS export
- runChafa: width/height destructured and combined into --size WxH
- Port changed to 3050; Vite on 0.0.0.0 with allowedHosts for production domain
- 98 unit tests passing across 12 test files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 10:29:45 +00:00
26 changed files with 1417 additions and 245 deletions

6
.gitignore vendored
View File

@ -40,3 +40,9 @@ __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: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) - 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:3001 // Listens on localhost:3050
// 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:3001 # Fastify API: http://localhost:3050
``` ```
### 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 :3001 │ │ │ Fastify :3050 │ │
│ ▼ │ │ ▼ │
│ 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:3001 | | Fastify API | http://localhost:3050 |
--- ---
@ -235,7 +235,7 @@ npm run test:e2e -- --debug # Debug mode
**curl example:** **curl example:**
```bash ```bash
curl -s -X POST http://localhost:3001/convert \ curl -s -X POST http://localhost:3050/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: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** **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.

49
asciinator.conf Normal file
View File

@ -0,0 +1,49 @@
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,6 +10,7 @@
"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"
@ -2730,6 +2731,30 @@
"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,6 +19,7 @@
"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,25 +23,98 @@ 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', () => {
const args = buildArgs('chafa', { size: 80 }) expect(buildArgs('chafa', { size: 80 })).toEqual(['--size', '80'])
expect(args).toEqual(['--size', '80'])
}) })
it('handles font-ratio hyphenated key', () => { it('handles hyphenated keys', () => {
const args = buildArgs('chafa', { 'font-ratio': '0.5' }) expect(buildArgs('chafa', { 'font-ratio': '0.5' })).toEqual(['--font-ratio', '0.5'])
expect(args).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')
}) })
}) })

143
server/__tests__/testApi.spec.js Executable file
View File

@ -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/)
})
})

View File

@ -2,10 +2,17 @@ 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, { origin: true }) await fastify.register(cors, {
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 },
@ -13,9 +20,14 @@ 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: 3001, host: '127.0.0.1' }) await fastify.listen({ port: 3050, host: '0.0.0.0' })
console.log('ASCIInator API → http://127.0.0.1:3001') console.log('ASCIInator API → http://0.0.0.0:3050')
} catch (err) { } catch (err) {
fastify.log.error(err) fastify.log.error(err)
process.exit(1) process.exit(1)

View File

@ -2,20 +2,45 @@ import { execa } from 'execa'
const TIMEOUT = 30_000 const TIMEOUT = 30_000
const SCHEMAS = { export 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'] },
'color-space': { type: 'value', valid: ['rgb', 'din99d'] },
symbols: { type: 'value' }, symbols: { type: 'value' },
fill: { type: 'value' },
dither: { type: 'value', valid: ['none', 'ordered', 'diffusion', 'noise'] }, dither: { type: 'value', valid: ['none', 'ordered', 'diffusion', 'noise'] },
'dither-grain': { type: 'value', valid: ['1x1', '2x2', '4x4', '8x8'] },
'dither-intensity':{ type: 'value' },
threshold: { type: 'value' }, threshold: { type: 'value' },
'font-ratio': { 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',
size: { type: 'value' },
width: { type: 'value' }, width: { type: 'value' },
height: { type: 'value' }, height: { type: 'value' },
chars: { type: 'value' }, chars: { type: 'value' },
background: { type: 'value', valid: ['light', 'dark'] }, 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' },
@ -28,7 +53,7 @@ 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' }, dither: { type: 'value', valid: ['none', 'ordered2', 'ordered4', 'ordered8', 'random', 'fstein'] },
gamma: { type: 'value' }, gamma: { type: 'value' },
}, },
} }
@ -37,6 +62,7 @@ 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]
@ -46,6 +72,8 @@ 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))
} }
@ -64,7 +92,12 @@ export async function runChafa(imagePath, flags) {
} }
export async function runJp2a(imagePath, flags) { export async function runJp2a(imagePath, flags) {
const args = buildArgs('jp2a', flags) const { width, height, ...rest } = 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 })
} }

192
server/routes/testApi.js Executable file
View File

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

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { reactive, ref } from 'vue' import { reactive, ref, watch } 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,6 +8,7 @@ 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: [],
@ -15,6 +16,7 @@ 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({
@ -24,20 +26,27 @@ function addError(message, source = 'error') {
}) })
} }
async function runConvert() { function scheduleConvert(delay = 400) {
if (!state.image) { if (!state.image) return
addError('No image selected.', 'warn') clearTimeout(debounceTimer)
return debounceTimer = setTimeout(() => bridge.value?.convert(), delay)
}
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
} }
@ -49,13 +58,18 @@ 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" style="border-color: var(--ascii-border)"> <header class="px-6 py-4 border-b flex items-center gap-4" 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">
@ -63,32 +77,24 @@ 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="state.image = $event" /> <ImageInput @image-ready="onImageReady" />
</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 @tool-config="state.toolConfig = $event" /> <ToolSelector :image-dims="state.imageDims" @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,5 +1,6 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref, computed } from 'vue'
import AnsiToHtml from 'ansi-to-html'
const props = defineProps({ const props = defineProps({
result: { type: String, default: '' }, result: { type: String, default: '' },
@ -8,14 +9,26 @@ 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() {
await navigator.clipboard.writeText(props.result) // Copy plain text strip ANSI codes for clipboard
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 blob = new Blob([props.result], { type: 'text/plain' }) const plain = props.result.replace(/\x1b\[[0-9;]*m/g, '')
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
@ -32,6 +45,7 @@ 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
@ -42,7 +56,21 @@ 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,11 +4,26 @@ const props = defineProps({
toolConfig: { type: Object, default: null }, toolConfig: { type: Object, default: null },
}) })
const emit = defineEmits(['conversion-result', 'conversion-error']) const emit = defineEmits(['conversion-result', 'conversion-error', 'conversion-start', 'conversion-end'])
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 controller = new AbortController()
activeController = controller
const timeoutId = setTimeout(() => controller.abort(), 30_000)
emit('conversion-start')
try {
const blob = await fetch(props.image.dataUrl).then((r) => r.blob()) const blob = await fetch(props.image.dataUrl).then((r) => r.blob())
const fd = new FormData() const fd = new FormData()
@ -16,10 +31,6 @@ async function convert() {
fd.append('tool', props.toolConfig.tool) fd.append('tool', props.toolConfig.tool)
fd.append('flags', JSON.stringify(props.toolConfig.flags)) fd.append('flags', JSON.stringify(props.toolConfig.flags))
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30_000)
try {
const res = await fetch('/api/convert', { const res = await fetch('/api/convert', {
method: 'POST', method: 'POST',
body: fd, body: fd,
@ -32,9 +43,17 @@ async function convert() {
emit('conversion-result', text) emit('conversion-result', text)
} }
} catch (err) { } catch (err) {
emit('conversion-error', err.name === 'AbortError' ? 'Conversion timed out (30s)' : err.message) if (err.name === 'AbortError') {
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')
} }
} }

73
src/components/SizeControl.vue Executable file
View File

@ -0,0 +1,73 @@
<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,6 +5,7 @@ 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 = [
@ -24,8 +25,22 @@ const OPTIONS_MAP = {
const selectedTool = ref('chafa') const selectedTool = ref('chafa')
const toolFlags = reactive({ const toolFlags = reactive({
chafa: { width: '', height: '', colors: '', symbols: '', dither: '', threshold: '', 'font-ratio': '' }, chafa: {
jp2a: { width: '', height: '', chars: '', background: '' }, 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: '' }, 'ascii-image-converter': { width: '', height: '', color: false, braille: false, threshold: '' },
img2txt: { width: '', height: '', format: '', dither: '', gamma: '' }, img2txt: { width: '', height: '', format: '', dither: '', gamma: '' },
}) })
@ -52,6 +67,7 @@ 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)',
@ -67,6 +83,7 @@ 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,39 +3,98 @@ 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: '', colors: '', symbols: '', width: '', height: '',
dither: '', threshold: '', 'font-ratio': '', 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',
} }
// 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 update:flags with updated value on input change', async () => { it('emits numeric width via SizeControl width slider', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } }) const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS, imageDims: null } })
const widthInput = wrapper.find('input[type="number"]') await wrapper.findAll('input[type="range"]')[0].setValue('120')
await widthInput.setValue('120') expect(wrapper.emitted('update:flags')[0][0].width).toBe(120)
const emitted = wrapper.emitted('update:flags')
expect(emitted).toBeTruthy()
expect(emitted[0][0].width).toBe('120')
}) })
it('preserves other flag values when one changes', async () => { it('preserves other flag values when width changes', async () => {
const flags = { ...EMPTY_FLAGS, colors: '256' } const wrapper = mount(ChafaOptions, { props: { flags: { ...EMPTY_FLAGS, colors: '256' }, imageDims: null } })
const wrapper = mount(ChafaOptions, { props: { flags } }) await wrapper.findAll('input[type="range"]')[0].setValue('80')
const widthInput = wrapper.find('input[type="number"]') expect(wrapper.emitted('update:flags')[0][0].colors).toBe('256')
await widthInput.setValue('80')
const emitted = wrapper.emitted('update:flags')
expect(emitted[0][0].colors).toBe('256')
}) })
it('emits update:flags with selected value on select change', async () => { it('emits selected color depth', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } }) const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
const select = wrapper.find('select') await wrapper.find('select').setValue('256')
await select.setValue('256') expect(wrapper.emitted('update:flags')[0][0].colors).toBe('256')
const emitted = wrapper.emitted('update:flags') })
expect(emitted[0][0].colors).toBe('256')
it('emits selected symbols preset', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
// symbols is the 4th select (after colors, extractor, color-space)
const selects = wrapper.findAll('select')
await selects[3].setValue('braille')
expect(wrapper.emitted('update:flags')[0][0].symbols).toBe('braille')
})
it('emits selected fill preset', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
// fill is the 5th select
const selects = wrapper.findAll('select')
await selects[4].setValue('block')
expect(wrapper.emitted('update:flags')[0][0].fill).toBe('block')
})
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

@ -0,0 +1,90 @@
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(blobResponse, convertResponse) { function mockFetch(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,20 +26,14 @@ describe('ShellBridge', () => {
}) })
it('emits conversion-result with stdout on 200', async () => { it('emits conversion-result with stdout on 200', async () => {
vi.stubGlobal( vi.stubGlobal('fetch', mockFetch({ ok: true, text: vi.fn().mockResolvedValue('ascii art') }))
'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( vi.stubGlobal('fetch', mockFetch({ ok: false, text: vi.fn().mockResolvedValue('tool failed') }))
'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')
@ -56,4 +50,25 @@ 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

@ -0,0 +1,71 @@
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('button').length).toBe(4) expect(wrapper.findAll('[data-testid="tool-btn"]').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('button') const buttons = wrapper.findAll('[data-testid="tool-btn"]')
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,18 +33,17 @@ 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('button') const [chafaBtn, jp2aBtn] = wrapper.findAll('[data-testid="tool-btn"]')
// Set width on chafa via ChafaOptions input // Set width via SizeControl width range slider (first range input)
const widthInput = wrapper.find('input[type="number"]') const widthSlider = wrapper.find('input[type="range"]')
await widthInput.setValue('120') await widthSlider.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,40 +1,56 @@
<script setup> <script setup>
const props = defineProps({ flags: { type: Object, required: true } }) import SizeControl from '../SizeControl.vue'
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="grid grid-cols-2 gap-3"> <div class="flex flex-col gap-5">
<!-- Size -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Size</p>
<SizeControl
:width="flags.width" :height="flags.height" :image-dims="imageDims"
@update:width="setNum('width', $event)"
@update:height="setNum('height', $event)"
/>
</div>
<!-- Threshold -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Threshold</p>
<label class="ascii-label"> <label class="ascii-label">
Width Brightness threshold {{ flags.threshold !== '' && flags.threshold !== undefined ? flags.threshold : '128' }}
<input class="ascii-input" type="number" min="1" :value="flags.width" <input type="range" min="0" max="255" step="1"
@input="set('width', $event.target.value)" placeholder="auto" /> :value="flags.threshold ?? 128"
</label> @input="setNum('threshold', $event.target.value)"
<label class="ascii-label"> class="w-full" style="accent-color: var(--ascii-green)" />
Height
<input class="ascii-input" type="number" min="1" :value="flags.height"
@input="set('height', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Threshold (0255)
<input class="ascii-input" type="number" min="0" max="255" :value="flags.threshold"
@input="set('threshold', $event.target.value)" placeholder="128" />
</label>
<div class="ascii-label justify-center gap-3 flex-row items-center col-span-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" :checked="flags.color"
@change="set('color', $event.target.checked)"
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,24 +1,36 @@
<script setup> <script setup>
const props = defineProps({ flags: { type: Object, required: true } }) import SizeControl from '../SizeControl.vue'
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">
<!-- Size -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Size</p>
<SizeControl
:width="flags.width" :height="flags.height" :image-dims="imageDims"
@update:width="setNum('width', $event)"
@update:height="setNum('height', $event)"
/>
</div>
<!-- Color -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Color</p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<label class="ascii-label"> <label class="ascii-label">
Width Depth
<input class="ascii-input" type="number" min="1" :value="flags.width"
@input="set('width', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Height
<input class="ascii-input" type="number" min="1" :value="flags.height"
@input="set('height', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Colors
<select class="ascii-input" :value="flags.colors" @change="set('colors', $event.target.value)"> <select class="ascii-input" :value="flags.colors" @change="set('colors', $event.target.value)">
<option value="">default</option> <option value="">default</option>
<option>none</option><option>2</option><option>8</option> <option>none</option><option>2</option><option>8</option>
@ -27,26 +39,151 @@ const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
</select> </select>
</label> </label>
<label class="ascii-label"> <label class="ascii-label">
Dither Extractor
<select class="ascii-input" :value="flags['color-extractor']" @change="set('color-extractor', $event.target.value)">
<option value="">default</option>
<option>average</option><option>median</option><option>mode</option>
</select>
</label>
<label class="ascii-label">
Color space
<select class="ascii-input" :value="flags['color-space']" @change="set('color-space', $event.target.value)">
<option value="">default</option>
<option>rgb</option><option>din99d</option>
</select>
</label>
</div>
</div>
<!-- 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)"> <select class="ascii-input" :value="flags.dither" @change="set('dither', $event.target.value)">
<option value="">default</option> <option value="">default</option>
<option>none</option><option>ordered</option><option>diffusion</option><option>noise</option> <option>none</option><option>ordered</option><option>diffusion</option><option>noise</option>
</select> </select>
</label> </label>
<label class="ascii-label"> <label class="ascii-label">
Symbols Grain
<input class="ascii-input" type="text" :value="flags.symbols" <select class="ascii-input" :value="flags['dither-grain']" @change="set('dither-grain', $event.target.value)">
@input="set('symbols', $event.target.value)" placeholder="e.g. block+border" /> <option value="">default</option>
</label> <option>1x1</option><option>2x2</option><option>4x4</option><option>8x8</option>
<label class="ascii-label"> </select>
Threshold (01)
<input class="ascii-input" type="number" min="0" max="1" step="0.05" :value="flags.threshold"
@input="set('threshold', $event.target.value)" placeholder="0.5" />
</label> </label>
<label class="ascii-label col-span-2"> <label class="ascii-label col-span-2">
Font ratio Intensity {{ flags['dither-intensity'] || '1.0' }}
<input class="ascii-input" type="number" min="0.1" step="0.05" :value="flags['font-ratio']" <input type="range" min="0" max="5" step="0.1"
@input="set('font-ratio', $event.target.value)" placeholder="auto" /> :value="flags['dither-intensity'] || 1"
@input="set('dither-intensity', $event.target.value)"
class="w-full" style="accent-color: var(--ascii-green)" />
</label> </label>
</div> </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>
</template> </template>

View File

@ -1,39 +1,62 @@
<script setup> <script setup>
const props = defineProps({ flags: { type: Object, required: true } }) import SizeControl from '../SizeControl.vue'
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">
<!-- Size -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Size</p>
<SizeControl
:width="flags.width" :height="flags.height" :image-dims="imageDims"
@update:width="setNum('width', $event)"
@update:height="setNum('height', $event)"
/>
</div>
<!-- Format & Dither -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Output</p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
Width
<input class="ascii-input" type="number" min="1" :value="flags.width"
@input="set('width', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Height
<input class="ascii-input" type="number" min="1" :value="flags.height"
@input="set('height', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label"> <label class="ascii-label">
Format Format
<select class="ascii-input" :value="flags.format" <select class="ascii-input" :value="flags.format" @change="set('format', $event.target.value)">
@change="set('format', $event.target.value)">
<option value="">default</option> <option value="">default</option>
<option>ansi</option><option>utf8</option><option>html</option> <option>ansi</option><option>utf8</option><option>html</option>
</select> </select>
</label> </label>
<label class="ascii-label"> <label class="ascii-label">
Dither Dither
<input class="ascii-input" type="text" :value="flags.dither" <select class="ascii-input" :value="flags.dither" @change="set('dither', $event.target.value)">
@input="set('dither', $event.target.value)" placeholder="default" /> <option value="">default</option>
</label> <option>none</option><option>ordered2</option><option>ordered4</option>
<label class="ascii-label col-span-2"> <option>ordered8</option><option>random</option><option>fstein</option>
Gamma </select>
<input class="ascii-input" type="number" min="0.1" step="0.1" :value="flags.gamma"
@input="set('gamma', $event.target.value)" placeholder="1.0" />
</label> </label>
</div> </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>
</template> </template>

View File

@ -1,34 +1,120 @@
<script setup> <script setup>
const props = defineProps({ flags: { type: Object, required: true } }) import SizeControl from '../SizeControl.vue'
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">
<!-- Size -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Size</p>
<SizeControl
:width="flags.width" :height="flags.height" :image-dims="imageDims"
@update:width="setNum('width', $event)"
@update:height="setNum('height', $event)"
/>
</div>
<!-- Color -->
<div>
<p class="text-xs uppercase tracking-wider mb-2" style="color: rgba(224,224,224,.35)">Color</p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<label class="ascii-label"> <label class="ascii-label">
Width Color depth
<input class="ascii-input" type="number" min="1" :value="flags.width" <select class="ascii-input" :value="flags['color-depth']" @change="set('color-depth', $event.target.value)">
@input="set('width', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Height
<input class="ascii-input" type="number" min="1" :value="flags.height"
@input="set('height', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Background
<select class="ascii-input" :value="flags.background"
@change="set('background', $event.target.value)">
<option value="">default</option> <option value="">default</option>
<option>light</option><option>dark</option> <option value="4">4 ANSI</option>
<option value="8">8 256 palette</option>
<option value="24">24 truecolor</option>
</select> </select>
</label> </label>
<label class="ascii-label"> <label class="ascii-label">
Chars 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 class="ascii-input" type="text" :value="flags.chars"
@input="set('chars', $event.target.value)" placeholder="e.g. .:-=+*#%@" /> @input="set('chars', $event.target.value)" placeholder="e.g. .:-=+*#%@" />
</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 ['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>
</template> </template>

View File

@ -2,13 +2,10 @@ 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: {
@ -16,9 +13,11 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: '0.0.0.0',
allowedHosts: ['asciinator.waynehayesdevelopment.com'],
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3001', target: 'http://localhost:3050',
rewrite: (path) => path.replace(/^\/api/, ''), rewrite: (path) => path.replace(/^\/api/, ''),
}, },
}, },