Compare commits
No commits in common. "03e0bcae6f8391875dc9fa0018f154e3c68cb465" and "c1cf06ed67c73d22ba6431866f66045a9502cb1f" have entirely different histories.
03e0bcae6f
...
c1cf06ed67
6
.gitignore
vendored
6
.gitignore
vendored
@ -40,9 +40,3 @@ __screenshots__/
|
|||||||
|
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|
||||||
# Syncthing
|
|
||||||
.stignore
|
|
||||||
.stfolder*
|
|
||||||
.stversions
|
|
||||||
*.sync-conflict-*
|
|
||||||
|
|||||||
@ -89,7 +89,7 @@ asciinator/
|
|||||||
- Props: `image` (`{ dataUrl, mimeType, filename }`), `toolConfig` (`{ tool, flags }`)
|
- Props: `image` (`{ dataUrl, mimeType, filename }`), `toolConfig` (`{ tool, flags }`)
|
||||||
- Exposes: `convert()` — called via template ref from App.vue on button click
|
- Exposes: `convert()` — called via template ref from App.vue on button click
|
||||||
- Converts `dataUrl` back to Blob via `fetch()`, sends as `multipart/form-data`
|
- Converts `dataUrl` back to Blob via `fetch()`, sends as `multipart/form-data`
|
||||||
- POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3050/convert`)
|
- POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3001/convert`)
|
||||||
- Emits: `conversion-result` (stdout string) | `conversion-error` (stderr string)
|
- Emits: `conversion-result` (stdout string) | `conversion-error` (stderr string)
|
||||||
- Handles: 30s timeout via `AbortController`, network errors, non-2xx responses
|
- Handles: 30s timeout via `AbortController`, network errors, non-2xx responses
|
||||||
|
|
||||||
@ -113,7 +113,7 @@ asciinator/
|
|||||||
### Entry: `server/index.js`
|
### Entry: `server/index.js`
|
||||||
```js
|
```js
|
||||||
// Fastify + @fastify/multipart
|
// Fastify + @fastify/multipart
|
||||||
// Listens on localhost:3050
|
// Listens on localhost:3001
|
||||||
// Single route: POST /convert
|
// Single route: POST /convert
|
||||||
// CORS: origin: true (reflects requester — permissive for local dev)
|
// CORS: origin: true (reflects requester — permissive for local dev)
|
||||||
// Logger: warn level only (startup logged via console.log)
|
// Logger: warn level only (startup logged via console.log)
|
||||||
@ -268,7 +268,7 @@ npm run dev
|
|||||||
|
|
||||||
# Both should be running simultaneously during development
|
# Both should be running simultaneously during development
|
||||||
# Vue: http://localhost:5173
|
# Vue: http://localhost:5173
|
||||||
# Fastify API: http://localhost:3050
|
# Fastify API: http://localhost:3001
|
||||||
```
|
```
|
||||||
|
|
||||||
### npm scripts (`package.json`)
|
### npm scripts (`package.json`)
|
||||||
|
|||||||
@ -69,7 +69,7 @@ All tools must be installed on your system. ASCIInator does not install them for
|
|||||||
└──────────────────┼──────────────────────┘
|
└──────────────────┼──────────────────────┘
|
||||||
│
|
│
|
||||||
┌──────────────────┼──────────────────────┐
|
┌──────────────────┼──────────────────────┐
|
||||||
│ Fastify :3050 │ │
|
│ Fastify :3001 │ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ImageMagick preprocessing │
|
│ ImageMagick preprocessing │
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
@ -155,7 +155,7 @@ npm run dev
|
|||||||
| Service | URL |
|
| Service | URL |
|
||||||
|---------|-----|
|
|---------|-----|
|
||||||
| Vue app | http://localhost:5173 |
|
| Vue app | http://localhost:5173 |
|
||||||
| Fastify API | http://localhost:3050 |
|
| Fastify API | http://localhost:3001 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -235,7 +235,7 @@ npm run test:e2e -- --debug # Debug mode
|
|||||||
|
|
||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:3050/convert \
|
curl -s -X POST http://localhost:3001/convert \
|
||||||
-F "image=@/path/to/photo.jpg" \
|
-F "image=@/path/to/photo.jpg" \
|
||||||
-F "tool=chafa" \
|
-F "tool=chafa" \
|
||||||
-F 'flags={"width":"80","colors":"256"}'
|
-F 'flags={"width":"80","colors":"256"}'
|
||||||
@ -299,7 +299,7 @@ The binary isn't on PATH. Install it via your package manager — see Prerequisi
|
|||||||
ImageMagick isn't installed. It's required for all conversions regardless of tool.
|
ImageMagick isn't installed. It's required for all conversions regardless of tool.
|
||||||
|
|
||||||
**CORS error in the browser console**
|
**CORS error in the browser console**
|
||||||
The Fastify server isn't running, or it's on a different port. Start it with `npm run server` and confirm it logs `Server listening at http://127.0.0.1:3050`.
|
The Fastify server isn't running, or it's on a different port. Start it with `npm run server` and confirm it logs `Server listening at http://127.0.0.1:3001`.
|
||||||
|
|
||||||
**422 from the server**
|
**422 from the server**
|
||||||
The binary returned a non-zero exit code. The full stderr is in the response body and shown in the ErrorLog panel.
|
The binary returned a non-zero exit code. The full stderr is in the response body and shown in the ErrorLog panel.
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
map $http_upgrade $connection_upgrade {
|
|
||||||
default upgrade;
|
|
||||||
'' close;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name asciinator.waynehayesdevelopment.com;
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/certbot;
|
|
||||||
}
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
listen [::]:443 ssl http2;
|
|
||||||
server_name asciinator.waynehayesdevelopment.com;
|
|
||||||
client_max_body_size 25m;
|
|
||||||
ssl_certificate /etc/letsencrypt/live/asciinator.waynehayesdevelopment.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/asciinator.waynehayesdevelopment.com/privkey.pem;
|
|
||||||
|
|
||||||
# API → Fastify (trailing slash strips /api/ prefix before forwarding)
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://172.23.0.3:3050/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Frontend → Vite dev server
|
|
||||||
location / {
|
|
||||||
proxy_pass http://172.23.0.3:5173;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
package-lock.json
generated
25
package-lock.json
generated
@ -10,7 +10,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"ansi-to-html": "^0.7.2",
|
|
||||||
"execa": "^9.6.1",
|
"execa": "^9.6.1",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"vue": "^3.5.32"
|
"vue": "^3.5.32"
|
||||||
@ -2731,30 +2730,6 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ansi-to-html": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"entities": "^2.2.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"ansi-to-html": "bin/ansi-to-html"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansi-to-html/node_modules/entities": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansis": {
|
"node_modules/ansis": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"ansi-to-html": "^0.7.2",
|
|
||||||
"execa": "^9.6.1",
|
"execa": "^9.6.1",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"vue": "^3.5.32"
|
"vue": "^3.5.32"
|
||||||
|
|||||||
@ -23,98 +23,25 @@ describe('buildArgs', () => {
|
|||||||
expect(() => buildArgs('chafa', { colors: 'invalid' })).toThrow('Invalid value')
|
expect(() => buildArgs('chafa', { colors: 'invalid' })).toThrow('Invalid value')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts valid enum values', () => {
|
||||||
|
expect(() => buildArgs('chafa', { colors: '256' })).not.toThrow()
|
||||||
|
expect(() => buildArgs('chafa', { colors: '16/8' })).not.toThrow()
|
||||||
|
expect(() => buildArgs('chafa', { dither: 'noise' })).not.toThrow()
|
||||||
|
expect(() => buildArgs('jp2a', { background: 'dark' })).not.toThrow()
|
||||||
|
expect(() => buildArgs('img2txt', { format: 'ansi' })).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
it('throws on unknown tool', () => {
|
it('throws on unknown tool', () => {
|
||||||
expect(() => buildArgs('malicious-tool', {})).toThrow('Unknown tool')
|
expect(() => buildArgs('malicious-tool', {})).toThrow('Unknown tool')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stringifies numeric values', () => {
|
it('stringifies numeric values', () => {
|
||||||
expect(buildArgs('chafa', { size: 80 })).toEqual(['--size', '80'])
|
const args = buildArgs('chafa', { size: 80 })
|
||||||
|
expect(args).toEqual(['--size', '80'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles hyphenated keys', () => {
|
it('handles font-ratio hyphenated key', () => {
|
||||||
expect(buildArgs('chafa', { 'font-ratio': '0.5' })).toEqual(['--font-ratio', '0.5'])
|
const args = buildArgs('chafa', { 'font-ratio': '0.5' })
|
||||||
expect(buildArgs('chafa', { 'dither-grain': '4x4' })).toEqual(['--dither-grain', '4x4'])
|
expect(args).toEqual(['--font-ratio', '0.5'])
|
||||||
expect(buildArgs('chafa', { 'dither-intensity': '1.5' })).toEqual(['--dither-intensity', '1.5'])
|
|
||||||
expect(buildArgs('chafa', { 'color-extractor': 'median' })).toEqual(['--color-extractor', 'median'])
|
|
||||||
expect(buildArgs('chafa', { 'color-space': 'din99d' })).toEqual(['--color-space', 'din99d'])
|
|
||||||
expect(buildArgs('chafa', { 'fg-only': true })).toContain('--fg-only')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts all valid chafa color depths', () => {
|
|
||||||
for (const v of ['none', '2', '8', '16/8', '16', '240', '256', 'full']) {
|
|
||||||
expect(() => buildArgs('chafa', { colors: v })).not.toThrow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts all valid chafa dither modes', () => {
|
|
||||||
for (const v of ['none', 'ordered', 'diffusion', 'noise']) {
|
|
||||||
expect(() => buildArgs('chafa', { dither: v })).not.toThrow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts all valid chafa dither grains', () => {
|
|
||||||
for (const v of ['1x1', '2x2', '4x4', '8x8']) {
|
|
||||||
expect(() => buildArgs('chafa', { 'dither-grain': v })).not.toThrow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts chafa bool flags', () => {
|
|
||||||
expect(buildArgs('chafa', { zoom: true })).toContain('--zoom')
|
|
||||||
expect(buildArgs('chafa', { stretch: true })).toContain('--stretch')
|
|
||||||
expect(buildArgs('chafa', { 'fg-only': true })).toContain('--fg-only')
|
|
||||||
expect(buildArgs('chafa', { zoom: false })).not.toContain('--zoom')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts chafa work quality value', () => {
|
|
||||||
expect(buildArgs('chafa', { work: 9 })).toEqual(['--work', '9'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts chafa format values', () => {
|
|
||||||
expect(buildArgs('chafa', { format: 'symbols' })).toEqual(['--format', 'symbols'])
|
|
||||||
expect(buildArgs('chafa', { format: 'utf8' })).toEqual(['--format', 'utf8'])
|
|
||||||
expect(() => buildArgs('chafa', { format: 'sixel' })).toThrow('Invalid value')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts valid jp2a background values', () => {
|
|
||||||
expect(() => buildArgs('jp2a', { background: 'dark' })).not.toThrow()
|
|
||||||
expect(() => buildArgs('jp2a', { background: 'light' })).not.toThrow()
|
|
||||||
expect(() => buildArgs('jp2a', { background: 'invalid' })).toThrow('Invalid value')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts valid jp2a color-depth values', () => {
|
|
||||||
for (const v of ['4', '8', '24']) {
|
|
||||||
expect(() => buildArgs('jp2a', { 'color-depth': v })).not.toThrow()
|
|
||||||
}
|
|
||||||
expect(() => buildArgs('jp2a', { 'color-depth': '16' })).toThrow('Invalid value')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts jp2a bool flags', () => {
|
|
||||||
for (const flag of ['colors', 'fill', 'grayscale', 'invert', 'border', 'flipx', 'flipy', 'edges-only']) {
|
|
||||||
expect(buildArgs('jp2a', { [flag]: true })).toContain(`--${flag}`)
|
|
||||||
expect(buildArgs('jp2a', { [flag]: false })).not.toContain(`--${flag}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts jp2a RGB weight values', () => {
|
|
||||||
expect(buildArgs('jp2a', { red: '0.5', green: '0.3', blue: '0.2' })).toEqual([
|
|
||||||
'--red=0.5', '--green=0.3', '--blue=0.2',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts jp2a edge-threshold', () => {
|
|
||||||
expect(buildArgs('jp2a', { 'edge-threshold': '0.4' })).toEqual(['--edge-threshold=0.4'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts valid img2txt format values', () => {
|
|
||||||
for (const v of ['ansi', 'utf8', 'html']) {
|
|
||||||
expect(() => buildArgs('img2txt', { format: v })).not.toThrow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts valid img2txt dither values', () => {
|
|
||||||
for (const v of ['none', 'ordered2', 'ordered4', 'ordered8', 'random', 'fstein']) {
|
|
||||||
expect(() => buildArgs('img2txt', { dither: v })).not.toThrow()
|
|
||||||
}
|
|
||||||
expect(() => buildArgs('img2txt', { dither: 'invalid' })).toThrow('Invalid value')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,143 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import Fastify from 'fastify'
|
|
||||||
import { testRoutes } from '../routes/testApi.js'
|
|
||||||
|
|
||||||
async function buildApp() {
|
|
||||||
const app = Fastify()
|
|
||||||
await app.register(testRoutes, { prefix: '/test' })
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GET /test/health', () => {
|
|
||||||
it('returns ok status with binary availability map', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/test/health' })
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
const body = res.json()
|
|
||||||
expect(body.status).toBe('ok')
|
|
||||||
expect(body.binaries).toHaveProperty('chafa')
|
|
||||||
expect(body.binaries).toHaveProperty('jp2a')
|
|
||||||
expect(body.binaries).toHaveProperty('imagemagick')
|
|
||||||
expect(typeof body.binaries.chafa.available).toBe('boolean')
|
|
||||||
expect(body.timestamp).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GET /test/flags/:tool', () => {
|
|
||||||
it('returns flag list for chafa with width/height not size', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/test/flags/chafa' })
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
const body = res.json()
|
|
||||||
expect(body.tool).toBe('chafa')
|
|
||||||
expect(Array.isArray(body.flags)).toBe(true)
|
|
||||||
const names = body.flags.map(f => f.name)
|
|
||||||
expect(names).toContain('width')
|
|
||||||
expect(names).toContain('height')
|
|
||||||
expect(names).not.toContain('size')
|
|
||||||
expect(names).toContain('colors')
|
|
||||||
expect(names).toContain('threshold')
|
|
||||||
expect(names).toContain('format')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns flag list for jp2a', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/test/flags/jp2a' })
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
const body = res.json()
|
|
||||||
expect(body.tool).toBe('jp2a')
|
|
||||||
const names = body.flags.map(f => f.name)
|
|
||||||
expect(names).toContain('width')
|
|
||||||
expect(names).toContain('background')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 404 for unknown tool', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/test/flags/unknown-tool' })
|
|
||||||
expect(res.statusCode).toBe(404)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes enum options for enum-type flags', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/test/flags/chafa' })
|
|
||||||
const body = res.json()
|
|
||||||
const colorsFlag = body.flags.find(f => f.name === 'colors')
|
|
||||||
expect(colorsFlag.type).toBe('enum')
|
|
||||||
expect(Array.isArray(colorsFlag.options)).toBe(true)
|
|
||||||
expect(colorsFlag.options).toContain('256')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('marks bool flags as boolean type', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/test/flags/chafa' })
|
|
||||||
const body = res.json()
|
|
||||||
const zoomFlag = body.flags.find(f => f.name === 'zoom')
|
|
||||||
expect(zoomFlag.type).toBe('boolean')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /test/convert — URL validation', () => {
|
|
||||||
it('rejects missing imageUrl', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/test/convert',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
payload: { tool: 'chafa', flags: {} },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects missing tool', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/test/convert',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
payload: { imageUrl: 'https://example.com/img.png' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects non-http URL schemes', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/test/convert',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
payload: { imageUrl: 'file:///etc/passwd', tool: 'chafa', flags: {} },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(400)
|
|
||||||
expect(res.json().error).toMatch(/http/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects unknown tool', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/test/convert',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
payload: { imageUrl: 'https://example.com/img.png', tool: 'evil-tool', flags: {} },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(400)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /test/imagemagick — URL validation', () => {
|
|
||||||
it('rejects missing imageUrl', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/test/imagemagick',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
payload: {},
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects file:// scheme', async () => {
|
|
||||||
const app = await buildApp()
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/test/imagemagick',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
payload: { imageUrl: 'file:///tmp/img.png' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(400)
|
|
||||||
expect(res.json().error).toMatch(/http/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -2,17 +2,10 @@ import Fastify from 'fastify'
|
|||||||
import multipart from '@fastify/multipart'
|
import multipart from '@fastify/multipart'
|
||||||
import cors from '@fastify/cors'
|
import cors from '@fastify/cors'
|
||||||
import { convertRoute } from './routes/convert.js'
|
import { convertRoute } from './routes/convert.js'
|
||||||
import { testRoutes } from './routes/testApi.js'
|
|
||||||
|
|
||||||
const fastify = Fastify({ logger: { level: 'warn' } })
|
const fastify = Fastify({ logger: { level: 'warn' } })
|
||||||
|
|
||||||
await fastify.register(cors, {
|
await fastify.register(cors, { origin: true })
|
||||||
origin: [
|
|
||||||
'http://localhost:5173',
|
|
||||||
'http://localhost:4173',
|
|
||||||
'https://asciinator.waynehayesdevelopment.com',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await fastify.register(multipart, {
|
await fastify.register(multipart, {
|
||||||
limits: { fileSize: 20 * 1024 * 1024 },
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
@ -20,14 +13,9 @@ await fastify.register(multipart, {
|
|||||||
|
|
||||||
await fastify.register(convertRoute)
|
await fastify.register(convertRoute)
|
||||||
|
|
||||||
if (process.env.ENABLE_TEST_API === 'true') {
|
|
||||||
await fastify.register(testRoutes, { prefix: '/test' })
|
|
||||||
console.log('Test API enabled → /test/{health,convert,flags,imagemagick}')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fastify.listen({ port: 3050, host: '0.0.0.0' })
|
await fastify.listen({ port: 3001, host: '127.0.0.1' })
|
||||||
console.log('ASCIInator API → http://0.0.0.0:3050')
|
console.log('ASCIInator API → http://127.0.0.1:3001')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err)
|
fastify.log.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
@ -2,45 +2,20 @@ import { execa } from 'execa'
|
|||||||
|
|
||||||
const TIMEOUT = 30_000
|
const TIMEOUT = 30_000
|
||||||
|
|
||||||
export const SCHEMAS = {
|
const SCHEMAS = {
|
||||||
chafa: {
|
chafa: {
|
||||||
size: { type: 'value' },
|
size: { type: 'value' },
|
||||||
colors: { type: 'value', valid: ['none', '2', '8', '16/8', '16', '240', '256', 'full'] },
|
colors: { type: 'value', valid: ['none', '2', '8', '16/8', '16', '240', '256', 'full'] },
|
||||||
'color-extractor': { type: 'value', valid: ['average', 'median', 'mode'] },
|
|
||||||
'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' },
|
||||||
@ -53,7 +28,7 @@ export const SCHEMAS = {
|
|||||||
width: { type: 'value' },
|
width: { type: 'value' },
|
||||||
height: { type: 'value' },
|
height: { type: 'value' },
|
||||||
format: { type: 'value', valid: ['ansi', 'utf8', 'html'] },
|
format: { type: 'value', valid: ['ansi', 'utf8', 'html'] },
|
||||||
dither: { type: 'value', valid: ['none', 'ordered2', 'ordered4', 'ordered8', 'random', 'fstein'] },
|
dither: { type: 'value' },
|
||||||
gamma: { type: 'value' },
|
gamma: { type: 'value' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -62,7 +37,6 @@ export function buildArgs(tool, flags) {
|
|||||||
const schema = SCHEMAS[tool]
|
const schema = SCHEMAS[tool]
|
||||||
if (!schema) throw new Error(`Unknown tool: ${tool}`)
|
if (!schema) throw new Error(`Unknown tool: ${tool}`)
|
||||||
|
|
||||||
const equals = schema._argFormat === 'equals'
|
|
||||||
const args = []
|
const args = []
|
||||||
for (const [key, value] of Object.entries(flags)) {
|
for (const [key, value] of Object.entries(flags)) {
|
||||||
const def = schema[key]
|
const def = schema[key]
|
||||||
@ -72,8 +46,6 @@ export function buildArgs(tool, flags) {
|
|||||||
}
|
}
|
||||||
if (def.type === 'bool') {
|
if (def.type === 'bool') {
|
||||||
if (value) args.push(`--${key}`)
|
if (value) args.push(`--${key}`)
|
||||||
} else if (equals) {
|
|
||||||
args.push(`--${key}=${String(value)}`)
|
|
||||||
} else {
|
} else {
|
||||||
args.push(`--${key}`, String(value))
|
args.push(`--${key}`, String(value))
|
||||||
}
|
}
|
||||||
@ -92,12 +64,7 @@ export async function runChafa(imagePath, flags) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runJp2a(imagePath, flags) {
|
export async function runJp2a(imagePath, flags) {
|
||||||
const { width, height, ...rest } = flags
|
const args = buildArgs('jp2a', flags)
|
||||||
const processed = { ...rest }
|
|
||||||
if (width && height) processed.size = `${width}x${height}`
|
|
||||||
else if (width) processed.size = `${width}x`
|
|
||||||
else if (height) processed.size = `x${height}`
|
|
||||||
const args = buildArgs('jp2a', processed)
|
|
||||||
return execa('jp2a', [...args, imagePath], { timeout: TIMEOUT })
|
return execa('jp2a', [...args, imagePath], { timeout: TIMEOUT })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,192 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
|
||||||
import { writeFile, unlink, stat } from 'node:fs/promises'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { execa } from 'execa'
|
|
||||||
import { preprocess } from '../lib/imagemagick.js'
|
|
||||||
import { SCHEMAS, buildArgs, runChafa, runJp2a, runAsciiImageConverter, runImgToTxt } from '../lib/converters.js'
|
|
||||||
|
|
||||||
const RUNNERS = {
|
|
||||||
chafa: runChafa,
|
|
||||||
jp2a: runJp2a,
|
|
||||||
'ascii-image-converter': runAsciiImageConverter,
|
|
||||||
img2txt: runImgToTxt,
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024
|
|
||||||
|
|
||||||
const BINARIES = [
|
|
||||||
{ key: 'chafa', cmd: 'chafa', args: ['--version'], src: 'stdout' },
|
|
||||||
{ key: 'jp2a', cmd: 'jp2a', args: ['--version'], src: 'both' },
|
|
||||||
{ key: 'ascii-image-converter', cmd: 'ascii-image-converter', args: ['--version'], src: 'stdout' },
|
|
||||||
{ key: 'img2txt', cmd: 'img2txt', args: ['--help'], src: 'both' },
|
|
||||||
{ key: 'imagemagick', cmd: 'magick', args: ['--version'], src: 'stdout' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function validateUrl(raw) {
|
|
||||||
let url
|
|
||||||
try { url = new URL(raw) } catch { throw new Error('Invalid URL') }
|
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only http/https URLs are allowed')
|
|
||||||
return url.href
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadToFile(url, destPath) {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
signal: AbortSignal.timeout(15_000),
|
|
||||||
headers: { 'User-Agent': 'ASCIInator/1.0 (test API; +https://github.com/asciinator)' },
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`)
|
|
||||||
const buf = await res.arrayBuffer()
|
|
||||||
if (buf.byteLength > MAX_DOWNLOAD_BYTES) throw new Error('Image exceeds 10 MB limit')
|
|
||||||
await writeFile(destPath, Buffer.from(buf))
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVersion(stdout, stderr) {
|
|
||||||
const text = (stdout + '\n' + stderr).trim()
|
|
||||||
// Match semantic version: optional 'v' prefix, e.g. 1.2.3, v1.2.3, 0.99.19
|
|
||||||
const match = text.match(/v?(\d+\.\d+[\d.]*)/)
|
|
||||||
return match ? match[1] : null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probeImage(filePath) {
|
|
||||||
const result = await execa('magick', ['identify', '-format', '%w %h %m', filePath], { reject: false })
|
|
||||||
const [w, h, fmt] = result.stdout.trim().split(' ')
|
|
||||||
const { size } = await stat(filePath)
|
|
||||||
return { width: parseInt(w) || null, height: parseInt(h) || null, format: fmt || null, size_bytes: size }
|
|
||||||
}
|
|
||||||
|
|
||||||
function schemaToFlagList(tool) {
|
|
||||||
const schema = SCHEMAS[tool]
|
|
||||||
if (!schema) return null
|
|
||||||
|
|
||||||
const entries = Object.entries(schema)
|
|
||||||
|
|
||||||
// Chafa uses --size WxH internally but the API accepts width + height separately
|
|
||||||
const normalized = tool === 'chafa'
|
|
||||||
? entries.flatMap(([name, def]) =>
|
|
||||||
name === 'size'
|
|
||||||
? [['width', { type: 'value' }], ['height', { type: 'value' }]]
|
|
||||||
: [[name, def]]
|
|
||||||
)
|
|
||||||
: entries
|
|
||||||
|
|
||||||
return normalized.map(([name, def]) => {
|
|
||||||
let type = 'string'
|
|
||||||
if (def.type === 'bool') type = 'boolean'
|
|
||||||
else if (def.valid) type = 'enum'
|
|
||||||
else if (['width', 'height', 'work'].includes(name)) type = 'integer'
|
|
||||||
else if (['threshold', 'font-ratio', 'gamma', 'red', 'green', 'blue', 'dither-intensity', 'edge-threshold'].includes(name)) type = 'float'
|
|
||||||
const entry = { name, type, cliFlag: `--${name}` }
|
|
||||||
if (def.valid) entry.options = def.valid
|
|
||||||
return entry
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testRoutes(fastify) {
|
|
||||||
// GET /test/health
|
|
||||||
fastify.get('/health', async (_req, reply) => {
|
|
||||||
const binaries = {}
|
|
||||||
await Promise.all(BINARIES.map(async ({ key, cmd, args }) => {
|
|
||||||
try {
|
|
||||||
const result = await execa(cmd, args, { reject: false, timeout: 5000 })
|
|
||||||
const version = parseVersion(result.stdout, result.stderr)
|
|
||||||
binaries[key] = { available: true, version }
|
|
||||||
} catch {
|
|
||||||
binaries[key] = { available: false, version: null }
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
return reply.send({ status: 'ok', binaries, server: 'fastify', timestamp: new Date().toISOString() })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /test/convert
|
|
||||||
fastify.post('/convert', async (req, reply) => {
|
|
||||||
const { imageUrl, tool, flags = {} } = req.body ?? {}
|
|
||||||
if (!imageUrl) return reply.code(400).send({ error: 'imageUrl required' })
|
|
||||||
if (!tool) return reply.code(400).send({ error: 'tool required' })
|
|
||||||
if (!RUNNERS[tool]) return reply.code(400).send({ error: `Unknown tool: ${tool}` })
|
|
||||||
|
|
||||||
let validUrl
|
|
||||||
try { validUrl = validateUrl(imageUrl) } catch (e) { return reply.code(400).send({ error: e.message }) }
|
|
||||||
|
|
||||||
const ext = path.extname(new URL(validUrl).pathname).toLowerCase() || '.jpg'
|
|
||||||
const tempInput = `/tmp/asciinator-test-${randomUUID()}${ext}`
|
|
||||||
const format = tool === 'jp2a' ? 'jpg' : 'png'
|
|
||||||
let tempProcessed = null
|
|
||||||
const t0 = Date.now()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadToFile(validUrl, tempInput)
|
|
||||||
const t1 = Date.now()
|
|
||||||
|
|
||||||
tempProcessed = await preprocess(tempInput, format)
|
|
||||||
const t2 = Date.now()
|
|
||||||
|
|
||||||
const result = await RUNNERS[tool](tempProcessed, flags)
|
|
||||||
const t3 = Date.now()
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
success: true, tool, flags,
|
|
||||||
output: result.stdout,
|
|
||||||
stderr: result.stderr,
|
|
||||||
exitCode: 0,
|
|
||||||
timings: { download_ms: t1 - t0, imagemagick_ms: t2 - t1, conversion_ms: t3 - t2, total_ms: t3 - t0 },
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
return reply.code(422).send({
|
|
||||||
success: false, tool, flags,
|
|
||||||
output: null,
|
|
||||||
stderr: err.stderr || err.message,
|
|
||||||
exitCode: err.exitCode ?? 1,
|
|
||||||
timings: { total_ms: Date.now() - t0 },
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await unlink(tempInput).catch(() => {})
|
|
||||||
if (tempProcessed) await unlink(tempProcessed).catch(() => {})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /test/flags/:tool
|
|
||||||
fastify.get('/flags/:tool', async (req, reply) => {
|
|
||||||
const { tool } = req.params
|
|
||||||
const flags = schemaToFlagList(tool)
|
|
||||||
if (!flags) return reply.code(404).send({ error: `Unknown tool: ${tool}` })
|
|
||||||
return reply.send({ tool, flags })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /test/imagemagick
|
|
||||||
fastify.post('/imagemagick', async (req, reply) => {
|
|
||||||
const { imageUrl } = req.body ?? {}
|
|
||||||
if (!imageUrl) return reply.code(400).send({ error: 'imageUrl required' })
|
|
||||||
|
|
||||||
let validUrl
|
|
||||||
try { validUrl = validateUrl(imageUrl) } catch (e) { return reply.code(400).send({ error: e.message }) }
|
|
||||||
|
|
||||||
const ext = path.extname(new URL(validUrl).pathname).toLowerCase() || '.jpg'
|
|
||||||
const tempInput = `/tmp/asciinator-test-${randomUUID()}${ext}`
|
|
||||||
let tempProcessed = null
|
|
||||||
const t0 = Date.now()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadToFile(validUrl, tempInput)
|
|
||||||
const inputMeta = await probeImage(tempInput)
|
|
||||||
|
|
||||||
const t1 = Date.now()
|
|
||||||
tempProcessed = await preprocess(tempInput, 'png')
|
|
||||||
const t2 = Date.now()
|
|
||||||
|
|
||||||
const outputMeta = await probeImage(tempProcessed)
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
success: true,
|
|
||||||
input: inputMeta,
|
|
||||||
output: outputMeta,
|
|
||||||
commands_run: [`magick input${ext} -strip -auto-orient -resize 2000x2000> output.png`],
|
|
||||||
duration_ms: t2 - t1,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
return reply.code(500).send({ success: false, error: err.message })
|
|
||||||
} finally {
|
|
||||||
await unlink(tempInput).catch(() => {})
|
|
||||||
if (tempProcessed) await unlink(tempProcessed).catch(() => {})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
56
src/App.vue
56
src/App.vue
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import ImageInput from './components/ImageInput.vue'
|
import ImageInput from './components/ImageInput.vue'
|
||||||
import ToolSelector from './components/ToolSelector.vue'
|
import ToolSelector from './components/ToolSelector.vue'
|
||||||
import ShellBridge from './components/ShellBridge.vue'
|
import ShellBridge from './components/ShellBridge.vue'
|
||||||
@ -8,7 +8,6 @@ import ErrorLog from './components/ErrorLog.vue'
|
|||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
image: null,
|
image: null,
|
||||||
imageDims: null,
|
|
||||||
toolConfig: { tool: 'chafa', flags: {} },
|
toolConfig: { tool: 'chafa', flags: {} },
|
||||||
result: '',
|
result: '',
|
||||||
errors: [],
|
errors: [],
|
||||||
@ -16,7 +15,6 @@ const state = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const bridge = ref(null)
|
const bridge = ref(null)
|
||||||
let debounceTimer = null
|
|
||||||
|
|
||||||
function addError(message, source = 'error') {
|
function addError(message, source = 'error') {
|
||||||
state.errors.push({
|
state.errors.push({
|
||||||
@ -26,26 +24,19 @@ function addError(message, source = 'error') {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleConvert(delay = 400) {
|
async function runConvert() {
|
||||||
if (!state.image) return
|
if (!state.image) {
|
||||||
clearTimeout(debounceTimer)
|
addError('No image selected.', 'warn')
|
||||||
debounceTimer = setTimeout(() => bridge.value?.convert(), delay)
|
return
|
||||||
|
}
|
||||||
|
state.isConverting = true
|
||||||
|
state.result = ''
|
||||||
|
try {
|
||||||
|
await bridge.value.convert()
|
||||||
|
} finally {
|
||||||
|
state.isConverting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImageReady(img) {
|
|
||||||
state.image = img
|
|
||||||
const el = new Image()
|
|
||||||
el.onload = () => { state.imageDims = { w: el.naturalWidth, h: el.naturalHeight } }
|
|
||||||
el.src = img.dataUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New image — convert immediately
|
|
||||||
watch(() => state.image, (img) => {
|
|
||||||
if (img) scheduleConvert(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Flag / tool changes — debounce for slider drag
|
|
||||||
watch(() => state.toolConfig, () => scheduleConvert(400), { deep: true })
|
|
||||||
|
|
||||||
function onResult(text) {
|
function onResult(text) {
|
||||||
state.result = text
|
state.result = text
|
||||||
@ -58,18 +49,13 @@ function onError(message) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen pb-20" style="background-color: var(--ascii-bg)">
|
<div class="min-h-screen pb-20" style="background-color: var(--ascii-bg)">
|
||||||
<header class="px-6 py-4 border-b flex items-center gap-4" style="border-color: var(--ascii-border)">
|
<header class="px-6 py-4 border-b" style="border-color: var(--ascii-border)">
|
||||||
<h1
|
<h1
|
||||||
class="text-lg font-semibold tracking-widest font-mono"
|
class="text-lg font-semibold tracking-widest font-mono"
|
||||||
style="color: var(--ascii-green)"
|
style="color: var(--ascii-green)"
|
||||||
>
|
>
|
||||||
ASCIInator
|
ASCIInator
|
||||||
</h1>
|
</h1>
|
||||||
<span
|
|
||||||
v-if="state.isConverting"
|
|
||||||
class="text-xs font-mono animate-pulse"
|
|
||||||
style="color: rgba(57,255,20,.5)"
|
|
||||||
>converting…</span>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
|
<main class="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
|
||||||
@ -77,24 +63,32 @@ function onError(message) {
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
|
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
|
||||||
style="color: rgba(224,224,224,.35)">Image</h2>
|
style="color: rgba(224,224,224,.35)">Image</h2>
|
||||||
<ImageInput @image-ready="onImageReady" />
|
<ImageInput @image-ready="state.image = $event" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
|
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
|
||||||
style="color: rgba(224,224,224,.35)">Tool & Options</h2>
|
style="color: rgba(224,224,224,.35)">Tool & Options</h2>
|
||||||
<ToolSelector :image-dims="state.imageDims" @tool-config="state.toolConfig = $event" />
|
<ToolSelector @tool-config="state.toolConfig = $event" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
|
class="ascii-btn-primary"
|
||||||
|
:disabled="state.isConverting || !state.image"
|
||||||
|
@click="runConvert"
|
||||||
|
>
|
||||||
|
{{ state.isConverting ? 'Converting...' : 'Convert' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ShellBridge
|
<ShellBridge
|
||||||
ref="bridge"
|
ref="bridge"
|
||||||
:image="state.image"
|
:image="state.image"
|
||||||
:tool-config="state.toolConfig"
|
:tool-config="state.toolConfig"
|
||||||
@conversion-result="onResult"
|
@conversion-result="onResult"
|
||||||
@conversion-error="onError"
|
@conversion-error="onError"
|
||||||
@conversion-start="state.isConverting = true"
|
|
||||||
@conversion-end="state.isConverting = false"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OutputDisplay :result="state.result" />
|
<OutputDisplay :result="state.result" />
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import AnsiToHtml from 'ansi-to-html'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
result: { type: String, default: '' },
|
result: { type: String, default: '' },
|
||||||
@ -9,26 +8,14 @@ const props = defineProps({
|
|||||||
const fontSize = ref(12)
|
const fontSize = ref(12)
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
|
||||||
const ansiConverter = new AnsiToHtml({ escapeXML: true, bg: '#1a1a1a', fg: '#e0e0e0' })
|
|
||||||
|
|
||||||
const hasAnsi = computed(() => props.result.includes('\x1b['))
|
|
||||||
|
|
||||||
const renderedHtml = computed(() => {
|
|
||||||
if (!hasAnsi.value) return null
|
|
||||||
return ansiConverter.convert(props.result)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function copyToClipboard() {
|
async function copyToClipboard() {
|
||||||
// Copy plain text — strip ANSI codes for clipboard
|
await navigator.clipboard.writeText(props.result)
|
||||||
const plain = props.result.replace(/\x1b\[[0-9;]*m/g, '')
|
|
||||||
await navigator.clipboard.writeText(plain)
|
|
||||||
copied.value = true
|
copied.value = true
|
||||||
setTimeout(() => { copied.value = false }, 2000)
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadTxt() {
|
function downloadTxt() {
|
||||||
const plain = props.result.replace(/\x1b\[[0-9;]*m/g, '')
|
const blob = new Blob([props.result], { type: 'text/plain' })
|
||||||
const blob = new Blob([plain], { type: 'text/plain' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
@ -45,7 +32,6 @@ function downloadTxt() {
|
|||||||
{{ copied ? 'Copied!' : 'Copy' }}
|
{{ copied ? 'Copied!' : 'Copy' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ascii-btn" @click="downloadTxt">Download .txt</button>
|
<button class="ascii-btn" @click="downloadTxt">Download .txt</button>
|
||||||
<span v-if="hasAnsi" class="text-xs" style="color: rgba(57,255,20,.5)">ANSI</span>
|
|
||||||
<label class="flex items-center gap-2 text-xs ml-auto" style="color: rgba(224,224,224,.5)">
|
<label class="flex items-center gap-2 text-xs ml-auto" style="color: rgba(224,224,224,.5)">
|
||||||
{{ fontSize }}px
|
{{ fontSize }}px
|
||||||
<input
|
<input
|
||||||
@ -56,21 +42,7 @@ function downloadTxt() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<!-- ANSI output: rendered as coloured HTML -->
|
|
||||||
<pre
|
<pre
|
||||||
v-if="hasAnsi"
|
|
||||||
class="rounded p-4 overflow-auto leading-tight whitespace-pre"
|
|
||||||
:style="{
|
|
||||||
fontSize: fontSize + 'px',
|
|
||||||
fontFamily: '\'JetBrains Mono\', \'Fira Code\', monospace',
|
|
||||||
backgroundColor: 'var(--ascii-surface)',
|
|
||||||
border: '1px solid var(--ascii-border)',
|
|
||||||
}"
|
|
||||||
v-html="renderedHtml"
|
|
||||||
/>
|
|
||||||
<!-- Plain text output -->
|
|
||||||
<pre
|
|
||||||
v-else
|
|
||||||
class="rounded p-4 overflow-auto leading-tight whitespace-pre"
|
class="rounded p-4 overflow-auto leading-tight whitespace-pre"
|
||||||
:style="{
|
:style="{
|
||||||
fontSize: fontSize + 'px',
|
fontSize: fontSize + 'px',
|
||||||
|
|||||||
@ -4,26 +4,11 @@ const props = defineProps({
|
|||||||
toolConfig: { type: Object, default: null },
|
toolConfig: { type: Object, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['conversion-result', 'conversion-error', 'conversion-start', 'conversion-end'])
|
const emit = defineEmits(['conversion-result', 'conversion-error'])
|
||||||
|
|
||||||
let activeController = null
|
|
||||||
|
|
||||||
async function convert() {
|
async function convert() {
|
||||||
// Cancel any in-flight request
|
|
||||||
if (activeController) {
|
|
||||||
activeController.abort()
|
|
||||||
activeController = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.image || !props.toolConfig) return
|
if (!props.image || !props.toolConfig) return
|
||||||
|
|
||||||
const 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()
|
||||||
@ -31,6 +16,10 @@ 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,
|
||||||
@ -43,17 +32,9 @@ async function convert() {
|
|||||||
emit('conversion-result', text)
|
emit('conversion-result', text)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') {
|
emit('conversion-error', err.name === 'AbortError' ? 'Conversion timed out (30s)' : err.message)
|
||||||
if (activeController === controller) {
|
|
||||||
emit('conversion-error', 'Conversion timed out (30s)')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
emit('conversion-error', err.message)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
if (activeController === controller) activeController = null
|
|
||||||
emit('conversion-end')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
width: { type: [Number, String], default: '' },
|
|
||||||
height: { type: [Number, String], default: '' },
|
|
||||||
imageDims: { type: Object, default: null }, // { w, h } in pixels
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:width', 'update:height'])
|
|
||||||
|
|
||||||
const locked = ref(true)
|
|
||||||
|
|
||||||
// Terminal chars are ~2x taller than wide, so multiply by 0.5 to get char-space aspect.
|
|
||||||
function charHeight(w) {
|
|
||||||
if (!props.imageDims) return null
|
|
||||||
return Math.max(1, Math.round(w * (props.imageDims.h / props.imageDims.w) * 0.5))
|
|
||||||
}
|
|
||||||
function charWidth(h) {
|
|
||||||
if (!props.imageDims) return null
|
|
||||||
return Math.max(1, Math.round(h * (props.imageDims.w / props.imageDims.h) * 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onWidth(val) {
|
|
||||||
const w = val === '' ? '' : Number(val)
|
|
||||||
emit('update:width', w)
|
|
||||||
if (locked.value && w !== '') {
|
|
||||||
const h = charHeight(w)
|
|
||||||
if (h !== null) emit('update:height', h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onHeight(val) {
|
|
||||||
const h = val === '' ? '' : Number(val)
|
|
||||||
emit('update:height', h)
|
|
||||||
if (locked.value && h !== '') {
|
|
||||||
const w = charWidth(h)
|
|
||||||
if (w !== null) emit('update:width', w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-xs w-16 shrink-0" style="color: rgba(224,224,224,.5)">
|
|
||||||
W — {{ width || 'auto' }}
|
|
||||||
</span>
|
|
||||||
<input type="range" min="10" max="400" step="1"
|
|
||||||
:value="width || 80"
|
|
||||||
@input="onWidth($event.target.value)"
|
|
||||||
class="flex-1" style="accent-color: var(--ascii-green)" />
|
|
||||||
<button
|
|
||||||
class="text-xs px-2 py-0.5 rounded border shrink-0 transition-colors"
|
|
||||||
:style="{
|
|
||||||
borderColor: locked ? 'var(--ascii-green)' : 'var(--ascii-border)',
|
|
||||||
color: locked ? 'var(--ascii-green)' : 'rgba(224,224,224,.3)',
|
|
||||||
}"
|
|
||||||
@click="locked = !locked"
|
|
||||||
>{{ locked ? 'lock' : 'free' }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-xs w-16 shrink-0" style="color: rgba(224,224,224,.5)">
|
|
||||||
H — {{ height || 'auto' }}
|
|
||||||
</span>
|
|
||||||
<input type="range" min="5" max="200" step="1"
|
|
||||||
:value="height || 40"
|
|
||||||
@input="onHeight($event.target.value)"
|
|
||||||
class="flex-1" style="accent-color: var(--ascii-green)" />
|
|
||||||
<div class="w-10 shrink-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -5,7 +5,6 @@ import Jp2aOptions from './options/Jp2aOptions.vue'
|
|||||||
import AsciiOptions from './options/AsciiOptions.vue'
|
import AsciiOptions from './options/AsciiOptions.vue'
|
||||||
import ImgTxtOptions from './options/ImgTxtOptions.vue'
|
import ImgTxtOptions from './options/ImgTxtOptions.vue'
|
||||||
|
|
||||||
defineProps({ imageDims: { type: Object, default: null } })
|
|
||||||
const emit = defineEmits(['tool-config'])
|
const emit = defineEmits(['tool-config'])
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
@ -25,22 +24,8 @@ const OPTIONS_MAP = {
|
|||||||
const selectedTool = ref('chafa')
|
const selectedTool = ref('chafa')
|
||||||
|
|
||||||
const toolFlags = reactive({
|
const toolFlags = reactive({
|
||||||
chafa: {
|
chafa: { width: '', height: '', colors: '', symbols: '', dither: '', threshold: '', 'font-ratio': '' },
|
||||||
width: '', height: '',
|
jp2a: { width: '', height: '', chars: '', background: '' },
|
||||||
colors: '', 'color-extractor': '', 'color-space': '',
|
|
||||||
symbols: '', fill: '',
|
|
||||||
dither: '', 'dither-grain': '', 'dither-intensity': '',
|
|
||||||
threshold: '', 'font-ratio': '', work: '',
|
|
||||||
zoom: false, stretch: false, 'fg-only': false,
|
|
||||||
format: 'symbols',
|
|
||||||
},
|
|
||||||
jp2a: {
|
|
||||||
width: '', height: '', chars: '', background: '',
|
|
||||||
colors: false, 'color-depth': '', fill: false, grayscale: false,
|
|
||||||
invert: false, border: false, flipx: false, flipy: false,
|
|
||||||
red: '', green: '', blue: '',
|
|
||||||
'edge-threshold': '', 'edges-only': false,
|
|
||||||
},
|
|
||||||
'ascii-image-converter': { width: '', height: '', color: false, braille: false, threshold: '' },
|
'ascii-image-converter': { width: '', height: '', color: false, braille: false, threshold: '' },
|
||||||
img2txt: { width: '', height: '', format: '', dither: '', gamma: '' },
|
img2txt: { width: '', height: '', format: '', dither: '', gamma: '' },
|
||||||
})
|
})
|
||||||
@ -67,7 +52,6 @@ watch(
|
|||||||
<button
|
<button
|
||||||
v-for="t in TOOLS"
|
v-for="t in TOOLS"
|
||||||
:key="t.id"
|
:key="t.id"
|
||||||
data-testid="tool-btn"
|
|
||||||
class="px-3 py-1 text-xs font-mono rounded border transition-colors"
|
class="px-3 py-1 text-xs font-mono rounded border transition-colors"
|
||||||
:style="{
|
:style="{
|
||||||
borderColor: selectedTool === t.id ? 'var(--ascii-green)' : 'var(--ascii-border)',
|
borderColor: selectedTool === t.id ? 'var(--ascii-green)' : 'var(--ascii-border)',
|
||||||
@ -83,7 +67,6 @@ watch(
|
|||||||
<component
|
<component
|
||||||
:is="OPTIONS_MAP[selectedTool]"
|
:is="OPTIONS_MAP[selectedTool]"
|
||||||
:flags="toolFlags[selectedTool]"
|
:flags="toolFlags[selectedTool]"
|
||||||
:image-dims="imageDims"
|
|
||||||
@update:flags="onFlagsUpdate"
|
@update:flags="onFlagsUpdate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,98 +3,39 @@ import { mount } from '@vue/test-utils'
|
|||||||
import ChafaOptions from '../options/ChafaOptions.vue'
|
import ChafaOptions from '../options/ChafaOptions.vue'
|
||||||
|
|
||||||
const EMPTY_FLAGS = {
|
const EMPTY_FLAGS = {
|
||||||
width: '', height: '',
|
width: '', height: '', colors: '', symbols: '',
|
||||||
colors: '', 'color-extractor': '', 'color-space': '',
|
dither: '', threshold: '', 'font-ratio': '',
|
||||||
symbols: '', fill: '',
|
|
||||||
dither: '', 'dither-grain': '', 'dither-intensity': '',
|
|
||||||
threshold: '', 'font-ratio': '', work: '',
|
|
||||||
zoom: false, stretch: false, 'fg-only': false,
|
|
||||||
format: 'symbols',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Range slider layout in ChafaOptions (SizeControl adds first two):
|
|
||||||
// 0: width (SizeControl), 1: height (SizeControl),
|
|
||||||
// 2: dither-intensity, 3: threshold, 4: font-ratio, 5: work
|
|
||||||
|
|
||||||
describe('ChafaOptions', () => {
|
describe('ChafaOptions', () => {
|
||||||
it('renders without error', () => {
|
it('renders without error', () => {
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
||||||
expect(wrapper.exists()).toBe(true)
|
expect(wrapper.exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits numeric width via SizeControl width slider', async () => {
|
it('emits update:flags with updated value on input change', async () => {
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS, imageDims: null } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[0].setValue('120')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].width).toBe(120)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves other flag values when width changes', async () => {
|
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: { ...EMPTY_FLAGS, colors: '256' }, imageDims: null } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[0].setValue('80')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].colors).toBe('256')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits selected color depth', async () => {
|
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
||||||
await wrapper.find('select').setValue('256')
|
const widthInput = wrapper.find('input[type="number"]')
|
||||||
expect(wrapper.emitted('update:flags')[0][0].colors).toBe('256')
|
await widthInput.setValue('120')
|
||||||
|
const emitted = wrapper.emitted('update:flags')
|
||||||
|
expect(emitted).toBeTruthy()
|
||||||
|
expect(emitted[0][0].width).toBe('120')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits selected symbols preset', async () => {
|
it('preserves other flag values when one changes', async () => {
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
const flags = { ...EMPTY_FLAGS, colors: '256' }
|
||||||
// symbols is the 4th select (after colors, extractor, color-space)
|
const wrapper = mount(ChafaOptions, { props: { flags } })
|
||||||
const selects = wrapper.findAll('select')
|
const widthInput = wrapper.find('input[type="number"]')
|
||||||
await selects[3].setValue('braille')
|
await widthInput.setValue('80')
|
||||||
expect(wrapper.emitted('update:flags')[0][0].symbols).toBe('braille')
|
const emitted = wrapper.emitted('update:flags')
|
||||||
|
expect(emitted[0][0].colors).toBe('256')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits selected fill preset', async () => {
|
it('emits update:flags with selected value on select change', async () => {
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
||||||
// fill is the 5th select
|
const select = wrapper.find('select')
|
||||||
const selects = wrapper.findAll('select')
|
await select.setValue('256')
|
||||||
await selects[4].setValue('block')
|
const emitted = wrapper.emitted('update:flags')
|
||||||
expect(wrapper.emitted('update:flags')[0][0].fill).toBe('block')
|
expect(emitted[0][0].colors).toBe('256')
|
||||||
})
|
|
||||||
|
|
||||||
it('emits dither-intensity from its range slider', async () => {
|
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[2].setValue('2.5')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0]['dither-intensity']).toBe('2.5')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits threshold from its range slider', async () => {
|
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[3].setValue('0.75')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].threshold).toBe('0.75')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits work quality from its range slider', async () => {
|
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[5].setValue('9')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].work).toBe(9)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits true for zoom toggle when checked', async () => {
|
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
await wrapper.find('input[type="checkbox"]').setChecked(true)
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].zoom).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders all three toggle checkboxes', () => {
|
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
expect(wrapper.findAll('input[type="checkbox"]').length).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders 6 range sliders total', () => {
|
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
expect(wrapper.findAll('input[type="range"]').length).toBe(6)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits format change', async () => {
|
|
||||||
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
// format select is the last select
|
|
||||||
const selects = wrapper.findAll('select')
|
|
||||||
await selects[selects.length - 1].setValue('utf8')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].format).toBe('utf8')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import Jp2aOptions from '../options/Jp2aOptions.vue'
|
|
||||||
|
|
||||||
const EMPTY_FLAGS = {
|
|
||||||
width: '', height: '', chars: '', background: '',
|
|
||||||
colors: false, 'color-depth': '', fill: false, grayscale: false,
|
|
||||||
invert: false, border: false, flipx: false, flipy: false,
|
|
||||||
red: '', green: '', blue: '',
|
|
||||||
'edge-threshold': '', 'edges-only': false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Range slider layout:
|
|
||||||
// 0: width (SizeControl), 1: height (SizeControl),
|
|
||||||
// 2: red, 3: green, 4: blue, 5: edge-threshold
|
|
||||||
|
|
||||||
describe('Jp2aOptions', () => {
|
|
||||||
it('renders without error', () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
expect(wrapper.exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits numeric width via SizeControl width slider', async () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS, imageDims: null } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[0].setValue('100')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].width).toBe(100)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits background select value', async () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
const selects = wrapper.findAll('select')
|
|
||||||
const bgSelect = selects.find(s => s.findAll('option').some(o => o.text() === 'dark'))
|
|
||||||
await bgSelect.setValue('dark')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].background).toBe('dark')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits color-depth select value', async () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
await wrapper.find('select').setValue('8')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0]['color-depth']).toBe('8')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits red slider value', async () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[2].setValue('0.5')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].red).toBe('0.5')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits green slider value', async () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[3].setValue('0.4')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].green).toBe('0.4')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits blue slider value', async () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[4].setValue('0.2')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].blue).toBe('0.2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits edge-threshold slider value', async () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[5].setValue('0.4')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0]['edge-threshold']).toBe('0.4')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits invert toggle', async () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
// invert is the 4th checkbox (colors, fill, grayscale, invert...)
|
|
||||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
|
||||||
await checkboxes[3].setChecked(true)
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].invert).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders 8 toggle checkboxes', () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
expect(wrapper.findAll('input[type="checkbox"]').length).toBe(8)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders 6 range sliders (2 size + 3 RGB + 1 edge)', () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: EMPTY_FLAGS } })
|
|
||||||
expect(wrapper.findAll('input[type="range"]').length).toBe(6)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves other flags when one changes', async () => {
|
|
||||||
const wrapper = mount(Jp2aOptions, { props: { flags: { ...EMPTY_FLAGS, chars: '.:#' }, imageDims: null } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[0].setValue('80')
|
|
||||||
expect(wrapper.emitted('update:flags')[0][0].chars).toBe('.:#')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -9,7 +9,7 @@ const IMAGE = {
|
|||||||
}
|
}
|
||||||
const TOOL_CONFIG = { tool: 'chafa', flags: { width: '80' } }
|
const TOOL_CONFIG = { tool: 'chafa', flags: { width: '80' } }
|
||||||
|
|
||||||
function mockFetch(convertResponse) {
|
function mockFetch(blobResponse, convertResponse) {
|
||||||
return vi.fn()
|
return vi.fn()
|
||||||
.mockResolvedValueOnce({ blob: vi.fn().mockResolvedValue(new Blob([''])) })
|
.mockResolvedValueOnce({ blob: vi.fn().mockResolvedValue(new Blob([''])) })
|
||||||
.mockResolvedValueOnce(convertResponse)
|
.mockResolvedValueOnce(convertResponse)
|
||||||
@ -26,14 +26,20 @@ describe('ShellBridge', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('emits conversion-result with stdout on 200', async () => {
|
it('emits conversion-result with stdout on 200', async () => {
|
||||||
vi.stubGlobal('fetch', mockFetch({ ok: true, text: vi.fn().mockResolvedValue('ascii art') }))
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
mockFetch(null, { ok: true, text: vi.fn().mockResolvedValue('ascii art') }),
|
||||||
|
)
|
||||||
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
||||||
await wrapper.vm.convert()
|
await wrapper.vm.convert()
|
||||||
expect(wrapper.emitted('conversion-result')?.[0][0]).toBe('ascii art')
|
expect(wrapper.emitted('conversion-result')?.[0][0]).toBe('ascii art')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits conversion-error with body on non-2xx', async () => {
|
it('emits conversion-error with body on non-2xx', async () => {
|
||||||
vi.stubGlobal('fetch', mockFetch({ ok: false, text: vi.fn().mockResolvedValue('tool failed') }))
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
mockFetch(null, { ok: false, text: vi.fn().mockResolvedValue('tool failed') }),
|
||||||
|
)
|
||||||
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
||||||
await wrapper.vm.convert()
|
await wrapper.vm.convert()
|
||||||
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('tool failed')
|
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('tool failed')
|
||||||
@ -50,25 +56,4 @@ describe('ShellBridge', () => {
|
|||||||
await wrapper.vm.convert()
|
await wrapper.vm.convert()
|
||||||
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('Network error')
|
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('Network error')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits conversion-start before fetching', async () => {
|
|
||||||
vi.stubGlobal('fetch', mockFetch({ ok: true, text: vi.fn().mockResolvedValue('ok') }))
|
|
||||||
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
|
||||||
await wrapper.vm.convert()
|
|
||||||
expect(wrapper.emitted('conversion-start')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits conversion-end after success', async () => {
|
|
||||||
vi.stubGlobal('fetch', mockFetch({ ok: true, text: vi.fn().mockResolvedValue('ok') }))
|
|
||||||
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
|
||||||
await wrapper.vm.convert()
|
|
||||||
expect(wrapper.emitted('conversion-end')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits conversion-end even after error', async () => {
|
|
||||||
vi.stubGlobal('fetch', mockFetch({ ok: false, text: vi.fn().mockResolvedValue('err') }))
|
|
||||||
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
|
||||||
await wrapper.vm.convert()
|
|
||||||
expect(wrapper.emitted('conversion-end')).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import SizeControl from '../SizeControl.vue'
|
|
||||||
|
|
||||||
const DIMS = { w: 1920, h: 1080 } // 16:9
|
|
||||||
|
|
||||||
describe('SizeControl', () => {
|
|
||||||
it('renders two range sliders', () => {
|
|
||||||
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
|
|
||||||
expect(wrapper.findAll('input[type="range"]').length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits update:width when width slider moves', async () => {
|
|
||||||
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[0].setValue('120')
|
|
||||||
expect(wrapper.emitted('update:width')?.[0][0]).toBe(120)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits update:height when height slider moves', async () => {
|
|
||||||
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[1].setValue('30')
|
|
||||||
expect(wrapper.emitted('update:height')?.[0][0]).toBe(30)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-computes height from width when locked and imageDims set', async () => {
|
|
||||||
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[0].setValue('160')
|
|
||||||
const heights = wrapper.emitted('update:height')
|
|
||||||
expect(heights).toBeTruthy()
|
|
||||||
// 160 * (1080/1920) * 0.5 = 45
|
|
||||||
expect(heights[0][0]).toBe(45)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-computes width from height when locked and imageDims set', async () => {
|
|
||||||
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[1].setValue('45')
|
|
||||||
const widths = wrapper.emitted('update:width')
|
|
||||||
expect(widths).toBeTruthy()
|
|
||||||
// 45 * (1920/1080) * 2 = 160
|
|
||||||
expect(widths[0][0]).toBe(160)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not auto-compute when unlocked', async () => {
|
|
||||||
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
|
|
||||||
await wrapper.find('button').trigger('click') // unlock
|
|
||||||
await wrapper.findAll('input[type="range"]')[0].setValue('120')
|
|
||||||
// Should emit width but NOT height
|
|
||||||
expect(wrapper.emitted('update:width')).toBeTruthy()
|
|
||||||
expect(wrapper.emitted('update:height')).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not auto-compute when imageDims is null', async () => {
|
|
||||||
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: null } })
|
|
||||||
await wrapper.findAll('input[type="range"]')[0].setValue('120')
|
|
||||||
expect(wrapper.emitted('update:height')).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows lock button toggling between lock and free', async () => {
|
|
||||||
const wrapper = mount(SizeControl, { props: { width: '', height: '', imageDims: DIMS } })
|
|
||||||
const btn = wrapper.find('button')
|
|
||||||
expect(btn.text()).toBe('lock')
|
|
||||||
await btn.trigger('click')
|
|
||||||
expect(btn.text()).toBe('free')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays current width and height values', () => {
|
|
||||||
const wrapper = mount(SizeControl, { props: { width: 80, height: 40, imageDims: DIMS } })
|
|
||||||
expect(wrapper.text()).toContain('80')
|
|
||||||
expect(wrapper.text()).toContain('40')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -5,7 +5,7 @@ import ToolSelector from '../ToolSelector.vue'
|
|||||||
describe('ToolSelector', () => {
|
describe('ToolSelector', () => {
|
||||||
it('renders four tool buttons', () => {
|
it('renders four tool buttons', () => {
|
||||||
const wrapper = mount(ToolSelector)
|
const wrapper = mount(ToolSelector)
|
||||||
expect(wrapper.findAll('[data-testid="tool-btn"]').length).toBe(4)
|
expect(wrapper.findAll('button').length).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits tool-config immediately with chafa as default', () => {
|
it('emits tool-config immediately with chafa as default', () => {
|
||||||
@ -24,7 +24,7 @@ describe('ToolSelector', () => {
|
|||||||
|
|
||||||
it('switches active tool and emits updated tool-config', async () => {
|
it('switches active tool and emits updated tool-config', async () => {
|
||||||
const wrapper = mount(ToolSelector)
|
const wrapper = mount(ToolSelector)
|
||||||
const buttons = wrapper.findAll('[data-testid="tool-btn"]')
|
const buttons = wrapper.findAll('button')
|
||||||
await buttons[1].trigger('click') // jp2a
|
await buttons[1].trigger('click') // jp2a
|
||||||
const emitted = wrapper.emitted('tool-config')
|
const emitted = wrapper.emitted('tool-config')
|
||||||
const latest = emitted[emitted.length - 1][0]
|
const latest = emitted[emitted.length - 1][0]
|
||||||
@ -33,17 +33,18 @@ describe('ToolSelector', () => {
|
|||||||
|
|
||||||
it('preserves flags for a tool when switching away and back', async () => {
|
it('preserves flags for a tool when switching away and back', async () => {
|
||||||
const wrapper = mount(ToolSelector)
|
const wrapper = mount(ToolSelector)
|
||||||
const [chafaBtn, jp2aBtn] = wrapper.findAll('[data-testid="tool-btn"]')
|
const [chafaBtn, jp2aBtn] = wrapper.findAll('button')
|
||||||
|
|
||||||
// Set width via SizeControl width range slider (first range input)
|
// Set width on chafa via ChafaOptions input
|
||||||
const widthSlider = wrapper.find('input[type="range"]')
|
const widthInput = wrapper.find('input[type="number"]')
|
||||||
await widthSlider.setValue('120')
|
await widthInput.setValue('120')
|
||||||
|
|
||||||
|
// Switch to jp2a and back
|
||||||
await jp2aBtn.trigger('click')
|
await jp2aBtn.trigger('click')
|
||||||
await chafaBtn.trigger('click')
|
await chafaBtn.trigger('click')
|
||||||
|
|
||||||
const emitted = wrapper.emitted('tool-config')
|
const emitted = wrapper.emitted('tool-config')
|
||||||
const latest = emitted[emitted.length - 1][0]
|
const latest = emitted[emitted.length - 1][0]
|
||||||
expect(latest.flags.width).toBe(120)
|
expect(latest.flags.width).toBe('120')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,56 +1,40 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import SizeControl from '../SizeControl.vue'
|
const props = defineProps({ flags: { type: Object, required: true } })
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
flags: { type: Object, required: true },
|
|
||||||
imageDims: { type: Object, default: null },
|
|
||||||
})
|
|
||||||
const emit = defineEmits(['update:flags'])
|
const emit = defineEmits(['update:flags'])
|
||||||
|
|
||||||
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||||
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-5">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
|
||||||
<!-- 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">
|
||||||
Brightness threshold — {{ flags.threshold !== '' && flags.threshold !== undefined ? flags.threshold : '128' }}
|
Width
|
||||||
<input type="range" min="0" max="255" step="1"
|
<input class="ascii-input" type="number" min="1" :value="flags.width"
|
||||||
:value="flags.threshold ?? 128"
|
@input="set('width', $event.target.value)" placeholder="auto" />
|
||||||
@input="setNum('threshold', $event.target.value)"
|
</label>
|
||||||
class="w-full" style="accent-color: var(--ascii-green)" />
|
<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
|
||||||
</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>
|
||||||
|
|||||||
@ -1,36 +1,24 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import SizeControl from '../SizeControl.vue'
|
const props = defineProps({ flags: { type: Object, required: true } })
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
flags: { type: Object, required: true },
|
|
||||||
imageDims: { type: Object, default: null },
|
|
||||||
})
|
|
||||||
const emit = defineEmits(['update:flags'])
|
const emit = defineEmits(['update:flags'])
|
||||||
|
|
||||||
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||||
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
|
|
||||||
const setBool = (key, val) => set(key, val)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
|
|
||||||
<!-- 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">
|
||||||
Depth
|
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
|
||||||
<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>
|
||||||
@ -39,151 +27,26 @@ const setBool = (key, val) => set(key, val)
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="ascii-label">
|
<label class="ascii-label">
|
||||||
Extractor
|
Dither
|
||||||
<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">
|
||||||
Grain
|
Symbols
|
||||||
<select class="ascii-input" :value="flags['dither-grain']" @change="set('dither-grain', $event.target.value)">
|
<input class="ascii-input" type="text" :value="flags.symbols"
|
||||||
<option value="">default</option>
|
@input="set('symbols', $event.target.value)" placeholder="e.g. block+border" />
|
||||||
<option>1x1</option><option>2x2</option><option>4x4</option><option>8x8</option>
|
</label>
|
||||||
</select>
|
<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" />
|
||||||
</label>
|
</label>
|
||||||
<label class="ascii-label col-span-2">
|
<label class="ascii-label col-span-2">
|
||||||
Intensity — {{ flags['dither-intensity'] || '1.0' }}
|
Font ratio
|
||||||
<input type="range" min="0" max="5" step="0.1"
|
<input class="ascii-input" type="number" min="0.1" step="0.05" :value="flags['font-ratio']"
|
||||||
:value="flags['dither-intensity'] || 1"
|
@input="set('font-ratio', $event.target.value)" placeholder="auto" />
|
||||||
@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 & 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>
|
||||||
|
|||||||
@ -1,62 +1,39 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import SizeControl from '../SizeControl.vue'
|
const props = defineProps({ flags: { type: Object, required: true } })
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
flags: { type: Object, required: true },
|
|
||||||
imageDims: { type: Object, default: null },
|
|
||||||
})
|
|
||||||
const emit = defineEmits(['update:flags'])
|
const emit = defineEmits(['update:flags'])
|
||||||
|
|
||||||
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||||
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
|
|
||||||
<!-- 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" @change="set('format', $event.target.value)">
|
<select class="ascii-input" :value="flags.format"
|
||||||
|
@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
|
||||||
<select class="ascii-input" :value="flags.dither" @change="set('dither', $event.target.value)">
|
<input class="ascii-input" type="text" :value="flags.dither"
|
||||||
<option value="">default</option>
|
@input="set('dither', $event.target.value)" placeholder="default" />
|
||||||
<option>none</option><option>ordered2</option><option>ordered4</option>
|
|
||||||
<option>ordered8</option><option>random</option><option>fstein</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<label class="ascii-label col-span-2">
|
||||||
</div>
|
Gamma
|
||||||
|
<input class="ascii-input" type="number" min="0.1" step="0.1" :value="flags.gamma"
|
||||||
<!-- Gamma -->
|
@input="set('gamma', $event.target.value)" placeholder="1.0" />
|
||||||
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,120 +1,34 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import SizeControl from '../SizeControl.vue'
|
const props = defineProps({ flags: { type: Object, required: true } })
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
flags: { type: Object, required: true },
|
|
||||||
imageDims: { type: Object, default: null },
|
|
||||||
})
|
|
||||||
const emit = defineEmits(['update:flags'])
|
const emit = defineEmits(['update:flags'])
|
||||||
|
|
||||||
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||||
const setNum = (key, val) => set(key, val === '' ? '' : Number(val))
|
|
||||||
const setBool = (key, val) => set(key, val)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
|
|
||||||
<!-- 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">
|
||||||
Color depth
|
Width
|
||||||
<select class="ascii-input" :value="flags['color-depth']" @change="set('color-depth', $event.target.value)">
|
<input class="ascii-input" type="number" min="1" :value="flags.width"
|
||||||
<option value="">default</option>
|
@input="set('width', $event.target.value)" placeholder="auto" />
|
||||||
<option value="4">4 — ANSI</option>
|
</label>
|
||||||
<option value="8">8 — 256 palette</option>
|
<label class="ascii-label">
|
||||||
<option value="24">24 — truecolor</option>
|
Height
|
||||||
</select>
|
<input class="ascii-input" type="number" min="1" :value="flags.height"
|
||||||
|
@input="set('height', $event.target.value)" placeholder="auto" />
|
||||||
</label>
|
</label>
|
||||||
<label class="ascii-label">
|
<label class="ascii-label">
|
||||||
Background
|
Background
|
||||||
<select class="ascii-input" :value="flags.background" @change="set('background', $event.target.value)">
|
<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>light</option><option>dark</option>
|
||||||
<option>dark</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="ascii-label">
|
||||||
Red — {{ flags.red !== '' && flags.red !== undefined ? Number(flags.red).toFixed(3) : '0.299' }}
|
Chars
|
||||||
<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>
|
||||||
|
|||||||
@ -2,10 +2,13 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@ -13,11 +16,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
|
||||||
allowedHosts: ['asciinator.waynehayesdevelopment.com'],
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3050',
|
target: 'http://localhost:3001',
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user