Compare commits
4 Commits
c1cf06ed67
...
03e0bcae6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e0bcae6f | ||
|
|
9a4e501be9 | ||
|
|
a95c184be7 | ||
|
|
e904cdb3cb |
6
.gitignore
vendored
6
.gitignore
vendored
@ -40,3 +40,9 @@ __screenshots__/
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Syncthing
|
||||
.stignore
|
||||
.stfolder*
|
||||
.stversions
|
||||
*.sync-conflict-*
|
||||
|
||||
@ -89,7 +89,7 @@ asciinator/
|
||||
- Props: `image` (`{ dataUrl, mimeType, filename }`), `toolConfig` (`{ tool, flags }`)
|
||||
- Exposes: `convert()` — called via template ref from App.vue on button click
|
||||
- Converts `dataUrl` back to Blob via `fetch()`, sends as `multipart/form-data`
|
||||
- POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3001/convert`)
|
||||
- POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3050/convert`)
|
||||
- Emits: `conversion-result` (stdout string) | `conversion-error` (stderr string)
|
||||
- Handles: 30s timeout via `AbortController`, network errors, non-2xx responses
|
||||
|
||||
@ -113,7 +113,7 @@ asciinator/
|
||||
### Entry: `server/index.js`
|
||||
```js
|
||||
// Fastify + @fastify/multipart
|
||||
// Listens on localhost:3001
|
||||
// Listens on localhost:3050
|
||||
// Single route: POST /convert
|
||||
// CORS: origin: true (reflects requester — permissive for local dev)
|
||||
// Logger: warn level only (startup logged via console.log)
|
||||
@ -268,7 +268,7 @@ npm run dev
|
||||
|
||||
# Both should be running simultaneously during development
|
||||
# Vue: http://localhost:5173
|
||||
# Fastify API: http://localhost:3001
|
||||
# Fastify API: http://localhost:3050
|
||||
```
|
||||
|
||||
### npm scripts (`package.json`)
|
||||
|
||||
@ -69,7 +69,7 @@ All tools must be installed on your system. ASCIInator does not install them for
|
||||
└──────────────────┼──────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────────┐
|
||||
│ Fastify :3001 │ │
|
||||
│ Fastify :3050 │ │
|
||||
│ ▼ │
|
||||
│ ImageMagick preprocessing │
|
||||
│ ↓ │
|
||||
@ -155,7 +155,7 @@ npm run dev
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Vue app | http://localhost:5173 |
|
||||
| Fastify API | http://localhost:3001 |
|
||||
| Fastify API | http://localhost:3050 |
|
||||
|
||||
---
|
||||
|
||||
@ -235,7 +235,7 @@ npm run test:e2e -- --debug # Debug mode
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3001/convert \
|
||||
curl -s -X POST http://localhost:3050/convert \
|
||||
-F "image=@/path/to/photo.jpg" \
|
||||
-F "tool=chafa" \
|
||||
-F 'flags={"width":"80","colors":"256"}'
|
||||
@ -299,7 +299,7 @@ The binary isn't on PATH. Install it via your package manager — see Prerequisi
|
||||
ImageMagick isn't installed. It's required for all conversions regardless of tool.
|
||||
|
||||
**CORS error in the browser console**
|
||||
The Fastify server isn't running, or it's on a different port. Start it with `npm run server` and confirm it logs `Server listening at http://127.0.0.1:3001`.
|
||||
The Fastify server isn't running, or it's on a different port. Start it with `npm run server` and confirm it logs `Server listening at http://127.0.0.1:3050`.
|
||||
|
||||
**422 from the server**
|
||||
The binary returned a non-zero exit code. The full stderr is in the response body and shown in the ErrorLog panel.
|
||||
|
||||
49
asciinator.conf
Normal file
49
asciinator.conf
Normal 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
25
package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"execa": "^9.6.1",
|
||||
"fastify": "^5.8.5",
|
||||
"vue": "^3.5.32"
|
||||
@ -2730,6 +2731,30 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-to-html": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
|
||||
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^2.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"ansi-to-html": "bin/ansi-to-html"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-to-html/node_modules/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"execa": "^9.6.1",
|
||||
"fastify": "^5.8.5",
|
||||
"vue": "^3.5.32"
|
||||
|
||||
@ -23,25 +23,98 @@ describe('buildArgs', () => {
|
||||
expect(() => buildArgs('chafa', { colors: 'invalid' })).toThrow('Invalid value')
|
||||
})
|
||||
|
||||
it('accepts valid enum values', () => {
|
||||
expect(() => buildArgs('chafa', { colors: '256' })).not.toThrow()
|
||||
expect(() => buildArgs('chafa', { colors: '16/8' })).not.toThrow()
|
||||
expect(() => buildArgs('chafa', { dither: 'noise' })).not.toThrow()
|
||||
expect(() => buildArgs('jp2a', { background: 'dark' })).not.toThrow()
|
||||
expect(() => buildArgs('img2txt', { format: 'ansi' })).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws on unknown tool', () => {
|
||||
expect(() => buildArgs('malicious-tool', {})).toThrow('Unknown tool')
|
||||
})
|
||||
|
||||
it('stringifies numeric values', () => {
|
||||
const args = buildArgs('chafa', { size: 80 })
|
||||
expect(args).toEqual(['--size', '80'])
|
||||
expect(buildArgs('chafa', { size: 80 })).toEqual(['--size', '80'])
|
||||
})
|
||||
|
||||
it('handles font-ratio hyphenated key', () => {
|
||||
const args = buildArgs('chafa', { 'font-ratio': '0.5' })
|
||||
expect(args).toEqual(['--font-ratio', '0.5'])
|
||||
it('handles hyphenated keys', () => {
|
||||
expect(buildArgs('chafa', { 'font-ratio': '0.5' })).toEqual(['--font-ratio', '0.5'])
|
||||
expect(buildArgs('chafa', { 'dither-grain': '4x4' })).toEqual(['--dither-grain', '4x4'])
|
||||
expect(buildArgs('chafa', { 'dither-intensity': '1.5' })).toEqual(['--dither-intensity', '1.5'])
|
||||
expect(buildArgs('chafa', { 'color-extractor': 'median' })).toEqual(['--color-extractor', 'median'])
|
||||
expect(buildArgs('chafa', { 'color-space': 'din99d' })).toEqual(['--color-space', 'din99d'])
|
||||
expect(buildArgs('chafa', { 'fg-only': true })).toContain('--fg-only')
|
||||
})
|
||||
|
||||
it('accepts all valid chafa color depths', () => {
|
||||
for (const v of ['none', '2', '8', '16/8', '16', '240', '256', 'full']) {
|
||||
expect(() => buildArgs('chafa', { colors: v })).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts all valid chafa dither modes', () => {
|
||||
for (const v of ['none', 'ordered', 'diffusion', 'noise']) {
|
||||
expect(() => buildArgs('chafa', { dither: v })).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts all valid chafa dither grains', () => {
|
||||
for (const v of ['1x1', '2x2', '4x4', '8x8']) {
|
||||
expect(() => buildArgs('chafa', { 'dither-grain': v })).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts chafa bool flags', () => {
|
||||
expect(buildArgs('chafa', { zoom: true })).toContain('--zoom')
|
||||
expect(buildArgs('chafa', { stretch: true })).toContain('--stretch')
|
||||
expect(buildArgs('chafa', { 'fg-only': true })).toContain('--fg-only')
|
||||
expect(buildArgs('chafa', { zoom: false })).not.toContain('--zoom')
|
||||
})
|
||||
|
||||
it('accepts chafa work quality value', () => {
|
||||
expect(buildArgs('chafa', { work: 9 })).toEqual(['--work', '9'])
|
||||
})
|
||||
|
||||
it('accepts chafa format values', () => {
|
||||
expect(buildArgs('chafa', { format: 'symbols' })).toEqual(['--format', 'symbols'])
|
||||
expect(buildArgs('chafa', { format: 'utf8' })).toEqual(['--format', 'utf8'])
|
||||
expect(() => buildArgs('chafa', { format: 'sixel' })).toThrow('Invalid value')
|
||||
})
|
||||
|
||||
it('accepts valid jp2a background values', () => {
|
||||
expect(() => buildArgs('jp2a', { background: 'dark' })).not.toThrow()
|
||||
expect(() => buildArgs('jp2a', { background: 'light' })).not.toThrow()
|
||||
expect(() => buildArgs('jp2a', { background: 'invalid' })).toThrow('Invalid value')
|
||||
})
|
||||
|
||||
it('accepts valid jp2a color-depth values', () => {
|
||||
for (const v of ['4', '8', '24']) {
|
||||
expect(() => buildArgs('jp2a', { 'color-depth': v })).not.toThrow()
|
||||
}
|
||||
expect(() => buildArgs('jp2a', { 'color-depth': '16' })).toThrow('Invalid value')
|
||||
})
|
||||
|
||||
it('accepts jp2a bool flags', () => {
|
||||
for (const flag of ['colors', 'fill', 'grayscale', 'invert', 'border', 'flipx', 'flipy', 'edges-only']) {
|
||||
expect(buildArgs('jp2a', { [flag]: true })).toContain(`--${flag}`)
|
||||
expect(buildArgs('jp2a', { [flag]: false })).not.toContain(`--${flag}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts jp2a RGB weight values', () => {
|
||||
expect(buildArgs('jp2a', { red: '0.5', green: '0.3', blue: '0.2' })).toEqual([
|
||||
'--red=0.5', '--green=0.3', '--blue=0.2',
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts jp2a edge-threshold', () => {
|
||||
expect(buildArgs('jp2a', { 'edge-threshold': '0.4' })).toEqual(['--edge-threshold=0.4'])
|
||||
})
|
||||
|
||||
it('accepts valid img2txt format values', () => {
|
||||
for (const v of ['ansi', 'utf8', 'html']) {
|
||||
expect(() => buildArgs('img2txt', { format: v })).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid img2txt dither values', () => {
|
||||
for (const v of ['none', 'ordered2', 'ordered4', 'ordered8', 'random', 'fstein']) {
|
||||
expect(() => buildArgs('img2txt', { dither: v })).not.toThrow()
|
||||
}
|
||||
expect(() => buildArgs('img2txt', { dither: 'invalid' })).toThrow('Invalid value')
|
||||
})
|
||||
})
|
||||
|
||||
143
server/__tests__/testApi.spec.js
Executable file
143
server/__tests__/testApi.spec.js
Executable 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/)
|
||||
})
|
||||
})
|
||||
@ -2,10 +2,17 @@ import Fastify from 'fastify'
|
||||
import multipart from '@fastify/multipart'
|
||||
import cors from '@fastify/cors'
|
||||
import { convertRoute } from './routes/convert.js'
|
||||
import { testRoutes } from './routes/testApi.js'
|
||||
|
||||
const fastify = Fastify({ logger: { level: 'warn' } })
|
||||
|
||||
await fastify.register(cors, { origin: true })
|
||||
await fastify.register(cors, {
|
||||
origin: [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:4173',
|
||||
'https://asciinator.waynehayesdevelopment.com',
|
||||
],
|
||||
})
|
||||
|
||||
await fastify.register(multipart, {
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
@ -13,9 +20,14 @@ await fastify.register(multipart, {
|
||||
|
||||
await fastify.register(convertRoute)
|
||||
|
||||
if (process.env.ENABLE_TEST_API === 'true') {
|
||||
await fastify.register(testRoutes, { prefix: '/test' })
|
||||
console.log('Test API enabled → /test/{health,convert,flags,imagemagick}')
|
||||
}
|
||||
|
||||
try {
|
||||
await fastify.listen({ port: 3001, host: '127.0.0.1' })
|
||||
console.log('ASCIInator API → http://127.0.0.1:3001')
|
||||
await fastify.listen({ port: 3050, host: '0.0.0.0' })
|
||||
console.log('ASCIInator API → http://0.0.0.0:3050')
|
||||
} catch (err) {
|
||||
fastify.log.error(err)
|
||||
process.exit(1)
|
||||
|
||||
@ -2,20 +2,45 @@ import { execa } from 'execa'
|
||||
|
||||
const TIMEOUT = 30_000
|
||||
|
||||
const SCHEMAS = {
|
||||
export const SCHEMAS = {
|
||||
chafa: {
|
||||
size: { type: 'value' },
|
||||
colors: { type: 'value', valid: ['none', '2', '8', '16/8', '16', '240', '256', 'full'] },
|
||||
'color-extractor': { type: 'value', valid: ['average', 'median', 'mode'] },
|
||||
'color-space': { type: 'value', valid: ['rgb', 'din99d'] },
|
||||
symbols: { type: 'value' },
|
||||
fill: { type: 'value' },
|
||||
dither: { type: 'value', valid: ['none', 'ordered', 'diffusion', 'noise'] },
|
||||
'dither-grain': { type: 'value', valid: ['1x1', '2x2', '4x4', '8x8'] },
|
||||
'dither-intensity':{ type: 'value' },
|
||||
threshold: { type: 'value' },
|
||||
'font-ratio': { type: 'value' },
|
||||
work: { type: 'value' },
|
||||
zoom: { type: 'bool' },
|
||||
stretch: { type: 'bool' },
|
||||
'fg-only': { type: 'bool' },
|
||||
format: { type: 'value', valid: ['symbols', 'utf8'] },
|
||||
},
|
||||
jp2a: {
|
||||
_argFormat: 'equals',
|
||||
size: { type: 'value' },
|
||||
width: { type: 'value' },
|
||||
height: { type: 'value' },
|
||||
chars: { type: 'value' },
|
||||
background: { type: 'value', valid: ['light', 'dark'] },
|
||||
colors: { type: 'bool' },
|
||||
'color-depth': { type: 'value', valid: ['4', '8', '24'] },
|
||||
fill: { type: 'bool' },
|
||||
grayscale: { type: 'bool' },
|
||||
invert: { type: 'bool' },
|
||||
border: { type: 'bool' },
|
||||
flipx: { type: 'bool' },
|
||||
flipy: { type: 'bool' },
|
||||
red: { type: 'value' },
|
||||
green: { type: 'value' },
|
||||
blue: { type: 'value' },
|
||||
'edge-threshold': { type: 'value' },
|
||||
'edges-only': { type: 'bool' },
|
||||
},
|
||||
'ascii-image-converter': {
|
||||
width: { type: 'value' },
|
||||
@ -28,7 +53,7 @@ const SCHEMAS = {
|
||||
width: { type: 'value' },
|
||||
height: { type: 'value' },
|
||||
format: { type: 'value', valid: ['ansi', 'utf8', 'html'] },
|
||||
dither: { type: 'value' },
|
||||
dither: { type: 'value', valid: ['none', 'ordered2', 'ordered4', 'ordered8', 'random', 'fstein'] },
|
||||
gamma: { type: 'value' },
|
||||
},
|
||||
}
|
||||
@ -37,6 +62,7 @@ export function buildArgs(tool, flags) {
|
||||
const schema = SCHEMAS[tool]
|
||||
if (!schema) throw new Error(`Unknown tool: ${tool}`)
|
||||
|
||||
const equals = schema._argFormat === 'equals'
|
||||
const args = []
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
const def = schema[key]
|
||||
@ -46,6 +72,8 @@ export function buildArgs(tool, flags) {
|
||||
}
|
||||
if (def.type === 'bool') {
|
||||
if (value) args.push(`--${key}`)
|
||||
} else if (equals) {
|
||||
args.push(`--${key}=${String(value)}`)
|
||||
} else {
|
||||
args.push(`--${key}`, String(value))
|
||||
}
|
||||
@ -64,7 +92,12 @@ export async function runChafa(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 })
|
||||
}
|
||||
|
||||
|
||||
192
server/routes/testApi.js
Executable file
192
server/routes/testApi.js
Executable 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(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
58
src/App.vue
58
src/App.vue
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import ImageInput from './components/ImageInput.vue'
|
||||
import ToolSelector from './components/ToolSelector.vue'
|
||||
import ShellBridge from './components/ShellBridge.vue'
|
||||
@ -8,6 +8,7 @@ import ErrorLog from './components/ErrorLog.vue'
|
||||
|
||||
const state = reactive({
|
||||
image: null,
|
||||
imageDims: null,
|
||||
toolConfig: { tool: 'chafa', flags: {} },
|
||||
result: '',
|
||||
errors: [],
|
||||
@ -15,6 +16,7 @@ const state = reactive({
|
||||
})
|
||||
|
||||
const bridge = ref(null)
|
||||
let debounceTimer = null
|
||||
|
||||
function addError(message, source = 'error') {
|
||||
state.errors.push({
|
||||
@ -24,20 +26,27 @@ function addError(message, source = 'error') {
|
||||
})
|
||||
}
|
||||
|
||||
async function runConvert() {
|
||||
if (!state.image) {
|
||||
addError('No image selected.', 'warn')
|
||||
return
|
||||
}
|
||||
state.isConverting = true
|
||||
state.result = ''
|
||||
try {
|
||||
await bridge.value.convert()
|
||||
} finally {
|
||||
state.isConverting = false
|
||||
}
|
||||
function scheduleConvert(delay = 400) {
|
||||
if (!state.image) return
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => bridge.value?.convert(), delay)
|
||||
}
|
||||
|
||||
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) {
|
||||
state.result = text
|
||||
}
|
||||
@ -49,13 +58,18 @@ function onError(message) {
|
||||
|
||||
<template>
|
||||
<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
|
||||
class="text-lg font-semibold tracking-widest font-mono"
|
||||
style="color: var(--ascii-green)"
|
||||
>
|
||||
ASCIInator
|
||||
</h1>
|
||||
<span
|
||||
v-if="state.isConverting"
|
||||
class="text-xs font-mono animate-pulse"
|
||||
style="color: rgba(57,255,20,.5)"
|
||||
>converting…</span>
|
||||
</header>
|
||||
|
||||
<main class="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
|
||||
@ -63,32 +77,24 @@ function onError(message) {
|
||||
<section>
|
||||
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
|
||||
style="color: rgba(224,224,224,.35)">Image</h2>
|
||||
<ImageInput @image-ready="state.image = $event" />
|
||||
<ImageInput @image-ready="onImageReady" />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
|
||||
style="color: rgba(224,224,224,.35)">Tool & Options</h2>
|
||||
<ToolSelector @tool-config="state.toolConfig = $event" />
|
||||
<ToolSelector :image-dims="state.imageDims" @tool-config="state.toolConfig = $event" />
|
||||
</section>
|
||||
</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
|
||||
ref="bridge"
|
||||
:image="state.image"
|
||||
:tool-config="state.toolConfig"
|
||||
@conversion-result="onResult"
|
||||
@conversion-error="onError"
|
||||
@conversion-start="state.isConverting = true"
|
||||
@conversion-end="state.isConverting = false"
|
||||
/>
|
||||
|
||||
<OutputDisplay :result="state.result" />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import AnsiToHtml from 'ansi-to-html'
|
||||
|
||||
const props = defineProps({
|
||||
result: { type: String, default: '' },
|
||||
@ -8,14 +9,26 @@ const props = defineProps({
|
||||
const fontSize = ref(12)
|
||||
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() {
|
||||
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
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
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 a = document.createElement('a')
|
||||
a.href = url
|
||||
@ -32,6 +45,7 @@ function downloadTxt() {
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
</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)">
|
||||
{{ fontSize }}px
|
||||
<input
|
||||
@ -42,7 +56,21 @@ function downloadTxt() {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<!-- ANSI output: rendered as coloured HTML -->
|
||||
<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"
|
||||
:style="{
|
||||
fontSize: fontSize + 'px',
|
||||
|
||||
@ -4,11 +4,26 @@ const props = defineProps({
|
||||
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() {
|
||||
// Cancel any in-flight request
|
||||
if (activeController) {
|
||||
activeController.abort()
|
||||
activeController = null
|
||||
}
|
||||
|
||||
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 fd = new FormData()
|
||||
@ -16,10 +31,6 @@ async function convert() {
|
||||
fd.append('tool', props.toolConfig.tool)
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
@ -32,9 +43,17 @@ async function convert() {
|
||||
emit('conversion-result', text)
|
||||
}
|
||||
} 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 {
|
||||
clearTimeout(timeoutId)
|
||||
if (activeController === controller) activeController = null
|
||||
emit('conversion-end')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
73
src/components/SizeControl.vue
Executable file
73
src/components/SizeControl.vue
Executable 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>
|
||||
@ -5,6 +5,7 @@ import Jp2aOptions from './options/Jp2aOptions.vue'
|
||||
import AsciiOptions from './options/AsciiOptions.vue'
|
||||
import ImgTxtOptions from './options/ImgTxtOptions.vue'
|
||||
|
||||
defineProps({ imageDims: { type: Object, default: null } })
|
||||
const emit = defineEmits(['tool-config'])
|
||||
|
||||
const TOOLS = [
|
||||
@ -24,8 +25,22 @@ const OPTIONS_MAP = {
|
||||
const selectedTool = ref('chafa')
|
||||
|
||||
const toolFlags = reactive({
|
||||
chafa: { width: '', height: '', colors: '', symbols: '', dither: '', threshold: '', 'font-ratio': '' },
|
||||
jp2a: { width: '', height: '', chars: '', background: '' },
|
||||
chafa: {
|
||||
width: '', height: '',
|
||||
colors: '', 'color-extractor': '', 'color-space': '',
|
||||
symbols: '', fill: '',
|
||||
dither: '', 'dither-grain': '', 'dither-intensity': '',
|
||||
threshold: '', 'font-ratio': '', work: '',
|
||||
zoom: false, stretch: false, 'fg-only': false,
|
||||
format: 'symbols',
|
||||
},
|
||||
jp2a: {
|
||||
width: '', height: '', chars: '', background: '',
|
||||
colors: false, 'color-depth': '', fill: false, grayscale: false,
|
||||
invert: false, border: false, flipx: false, flipy: false,
|
||||
red: '', green: '', blue: '',
|
||||
'edge-threshold': '', 'edges-only': false,
|
||||
},
|
||||
'ascii-image-converter': { width: '', height: '', color: false, braille: false, threshold: '' },
|
||||
img2txt: { width: '', height: '', format: '', dither: '', gamma: '' },
|
||||
})
|
||||
@ -52,6 +67,7 @@ watch(
|
||||
<button
|
||||
v-for="t in TOOLS"
|
||||
:key="t.id"
|
||||
data-testid="tool-btn"
|
||||
class="px-3 py-1 text-xs font-mono rounded border transition-colors"
|
||||
:style="{
|
||||
borderColor: selectedTool === t.id ? 'var(--ascii-green)' : 'var(--ascii-border)',
|
||||
@ -67,6 +83,7 @@ watch(
|
||||
<component
|
||||
:is="OPTIONS_MAP[selectedTool]"
|
||||
:flags="toolFlags[selectedTool]"
|
||||
:image-dims="imageDims"
|
||||
@update:flags="onFlagsUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -3,39 +3,98 @@ import { mount } from '@vue/test-utils'
|
||||
import ChafaOptions from '../options/ChafaOptions.vue'
|
||||
|
||||
const EMPTY_FLAGS = {
|
||||
width: '', height: '', colors: '', symbols: '',
|
||||
dither: '', threshold: '', 'font-ratio': '',
|
||||
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',
|
||||
}
|
||||
|
||||
// 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', () => {
|
||||
it('renders without error', () => {
|
||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits update:flags with updated value on input change', async () => {
|
||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
||||
const widthInput = wrapper.find('input[type="number"]')
|
||||
await widthInput.setValue('120')
|
||||
const emitted = wrapper.emitted('update:flags')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted[0][0].width).toBe('120')
|
||||
it('emits numeric width via SizeControl width slider', 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 one changes', async () => {
|
||||
const flags = { ...EMPTY_FLAGS, colors: '256' }
|
||||
const wrapper = mount(ChafaOptions, { props: { flags } })
|
||||
const widthInput = wrapper.find('input[type="number"]')
|
||||
await widthInput.setValue('80')
|
||||
const emitted = wrapper.emitted('update:flags')
|
||||
expect(emitted[0][0].colors).toBe('256')
|
||||
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 update:flags with selected value on select change', async () => {
|
||||
it('emits selected color depth', async () => {
|
||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
||||
const select = wrapper.find('select')
|
||||
await select.setValue('256')
|
||||
const emitted = wrapper.emitted('update:flags')
|
||||
expect(emitted[0][0].colors).toBe('256')
|
||||
await wrapper.find('select').setValue('256')
|
||||
expect(wrapper.emitted('update:flags')[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')
|
||||
})
|
||||
})
|
||||
|
||||
90
src/components/__tests__/Jp2aOptions.spec.js
Executable file
90
src/components/__tests__/Jp2aOptions.spec.js
Executable 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('.:#')
|
||||
})
|
||||
})
|
||||
@ -9,7 +9,7 @@ const IMAGE = {
|
||||
}
|
||||
const TOOL_CONFIG = { tool: 'chafa', flags: { width: '80' } }
|
||||
|
||||
function mockFetch(blobResponse, convertResponse) {
|
||||
function mockFetch(convertResponse) {
|
||||
return vi.fn()
|
||||
.mockResolvedValueOnce({ blob: vi.fn().mockResolvedValue(new Blob([''])) })
|
||||
.mockResolvedValueOnce(convertResponse)
|
||||
@ -26,20 +26,14 @@ describe('ShellBridge', () => {
|
||||
})
|
||||
|
||||
it('emits conversion-result with stdout on 200', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
mockFetch(null, { ok: true, text: vi.fn().mockResolvedValue('ascii art') }),
|
||||
)
|
||||
vi.stubGlobal('fetch', mockFetch({ ok: true, text: vi.fn().mockResolvedValue('ascii art') }))
|
||||
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
||||
await wrapper.vm.convert()
|
||||
expect(wrapper.emitted('conversion-result')?.[0][0]).toBe('ascii art')
|
||||
})
|
||||
|
||||
it('emits conversion-error with body on non-2xx', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
mockFetch(null, { ok: false, text: vi.fn().mockResolvedValue('tool failed') }),
|
||||
)
|
||||
vi.stubGlobal('fetch', mockFetch({ ok: false, text: vi.fn().mockResolvedValue('tool failed') }))
|
||||
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
||||
await wrapper.vm.convert()
|
||||
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('tool failed')
|
||||
@ -56,4 +50,25 @@ describe('ShellBridge', () => {
|
||||
await wrapper.vm.convert()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
71
src/components/__tests__/SizeControl.spec.js
Executable file
71
src/components/__tests__/SizeControl.spec.js
Executable 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')
|
||||
})
|
||||
})
|
||||
@ -5,7 +5,7 @@ import ToolSelector from '../ToolSelector.vue'
|
||||
describe('ToolSelector', () => {
|
||||
it('renders four tool buttons', () => {
|
||||
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', () => {
|
||||
@ -24,7 +24,7 @@ describe('ToolSelector', () => {
|
||||
|
||||
it('switches active tool and emits updated tool-config', async () => {
|
||||
const wrapper = mount(ToolSelector)
|
||||
const buttons = wrapper.findAll('button')
|
||||
const buttons = wrapper.findAll('[data-testid="tool-btn"]')
|
||||
await buttons[1].trigger('click') // jp2a
|
||||
const emitted = wrapper.emitted('tool-config')
|
||||
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 () => {
|
||||
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
|
||||
const widthInput = wrapper.find('input[type="number"]')
|
||||
await widthInput.setValue('120')
|
||||
// Set width via SizeControl width range slider (first range input)
|
||||
const widthSlider = wrapper.find('input[type="range"]')
|
||||
await widthSlider.setValue('120')
|
||||
|
||||
// Switch to jp2a and back
|
||||
await jp2aBtn.trigger('click')
|
||||
await chafaBtn.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('tool-config')
|
||||
const latest = emitted[emitted.length - 1][0]
|
||||
expect(latest.flags.width).toBe('120')
|
||||
expect(latest.flags.width).toBe(120)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,40 +1,56 @@
|
||||
<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 set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
|
||||
</script>
|
||||
|
||||
<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">
|
||||
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">
|
||||
Threshold (0–255)
|
||||
<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
|
||||
Brightness threshold — {{ flags.threshold !== '' && flags.threshold !== undefined ? flags.threshold : '128' }}
|
||||
<input type="range" min="0" max="255" step="1"
|
||||
:value="flags.threshold ?? 128"
|
||||
@input="setNum('threshold', $event.target.value)"
|
||||
class="w-full" style="accent-color: var(--ascii-green)" />
|
||||
</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 ['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>
|
||||
</template>
|
||||
|
||||
@ -1,24 +1,36 @@
|
||||
<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 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>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
Colors
|
||||
Depth
|
||||
<select class="ascii-input" :value="flags.colors" @change="set('colors', $event.target.value)">
|
||||
<option value="">default</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>
|
||||
</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)">
|
||||
<option value="">default</option>
|
||||
<option>none</option><option>ordered</option><option>diffusion</option><option>noise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="ascii-label">
|
||||
Symbols
|
||||
<input class="ascii-input" type="text" :value="flags.symbols"
|
||||
@input="set('symbols', $event.target.value)" placeholder="e.g. block+border" />
|
||||
</label>
|
||||
<label class="ascii-label">
|
||||
Threshold (0–1)
|
||||
<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" />
|
||||
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">
|
||||
Font ratio
|
||||
<input class="ascii-input" type="number" min="0.1" step="0.05" :value="flags['font-ratio']"
|
||||
@input="set('font-ratio', $event.target.value)" placeholder="auto" />
|
||||
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 & 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>
|
||||
|
||||
@ -1,39 +1,62 @@
|
||||
<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 set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
Format
|
||||
<select class="ascii-input" :value="flags.format"
|
||||
@change="set('format', $event.target.value)">
|
||||
<select class="ascii-input" :value="flags.format" @change="set('format', $event.target.value)">
|
||||
<option value="">default</option>
|
||||
<option>ansi</option><option>utf8</option><option>html</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="ascii-label">
|
||||
Dither
|
||||
<input class="ascii-input" type="text" :value="flags.dither"
|
||||
@input="set('dither', $event.target.value)" placeholder="default" />
|
||||
</label>
|
||||
<label class="ascii-label col-span-2">
|
||||
Gamma
|
||||
<input class="ascii-input" type="number" min="0.1" step="0.1" :value="flags.gamma"
|
||||
@input="set('gamma', $event.target.value)" placeholder="1.0" />
|
||||
<select class="ascii-input" :value="flags.dither" @change="set('dither', $event.target.value)">
|
||||
<option value="">default</option>
|
||||
<option>none</option><option>ordered2</option><option>ordered4</option>
|
||||
<option>ordered8</option><option>random</option><option>fstein</option>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -1,34 +1,120 @@
|
||||
<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 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>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
Background
|
||||
<select class="ascii-input" :value="flags.background"
|
||||
@change="set('background', $event.target.value)">
|
||||
Color depth
|
||||
<select class="ascii-input" :value="flags['color-depth']" @change="set('color-depth', $event.target.value)">
|
||||
<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>
|
||||
</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="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>
|
||||
</template>
|
||||
|
||||
@ -2,13 +2,10 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
@ -16,9 +13,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['asciinator.waynehayesdevelopment.com'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
target: 'http://localhost:3050',
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user