Add web animation skills (iart-ai/web-animation-skills)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeokZtSmqg42ZGWsLXX2C
This commit is contained in:
42 files changed
+4170
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,179 @@
|
||||
---
|
||||
name: ascii-animation
|
||||
description: This skill should be used when the user asks to "make an ASCII animation", "build a terminal/CLI intro or loader", "convert an image or video to ASCII art", "add an ASCII shader/post-effect to a canvas or Three.js scene", "create retro/hacker text-character motion", or "animate text characters with a brightness ramp". Covers generative ASCII fields, image/video/3D-to-ASCII, and animated character art for both web and terminal.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# ASCII Animation
|
||||
|
||||
Render motion entirely with text characters. ASCII output is extremely lightweight, distinctive, and works in browsers (`<pre>`/canvas), terminals (ANSI), and over any 3D scene (Three.js `AsciiEffect`).
|
||||
|
||||
## When to use
|
||||
|
||||
- Build terminal/CLI intros, loaders, banners, or a retro/hacker aesthetic.
|
||||
- Convert an image, video frame, or 3D scene into animated ASCII.
|
||||
- Add an ASCII post-effect over an existing canvas/WebGL render.
|
||||
- Create generative text fields (plasma, sine waves, noise, tunnels).
|
||||
|
||||
## Core concept: the brightness ramp
|
||||
|
||||
Map luminance (0..1) to a character whose ink density matches. Order characters dark-to-light. Pick the index with `Math.round(lum * (ramp.length - 1))`.
|
||||
|
||||
Common ramps (dark to light):
|
||||
- Short (10): ` .:-=+*#%@`
|
||||
- Medium (16): ` .'\`^",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$` truncated — see table below.
|
||||
- Standard 70-level (Paul Bourke), best for photos:
|
||||
`` $@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`'. `` (reverse for dark-on-light).
|
||||
|
||||
Compute relative luminance from sRGB (perceptual):
|
||||
```js
|
||||
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1
|
||||
```
|
||||
|
||||
Invert when drawing dark text on a light background: `lum = 1 - lum`.
|
||||
|
||||
## Character cell aspect correction (the #1 gotcha)
|
||||
|
||||
Monospace character cells are taller than wide — roughly **0.5 width:height**. Sampling a square pixel grid produces a vertically stretched image. Correct by sampling **fewer rows than columns**: for a target of `cols` characters wide, use `rows = Math.round(cols * (imgH / imgW) * fontAspect)` where `fontAspect ≈ 0.5`. Equivalently, when drawing to an offscreen canvas, set its height to `cols * aspect * 0.5`.
|
||||
|
||||
## Web rendering: `<pre>` vs canvas
|
||||
|
||||
- **`<pre>` + textContent**: simplest. One string with `\n` per row. Fine up to ~120×60 chars at 30fps. Set `white-space: pre; font-family: monospace; line-height: 1;`.
|
||||
- **Canvas `fillText`**: needed for per-character color, larger grids, or 60fps. Draw each char at `x * cellW, y * cellH`. Faster than thousands of DOM nodes.
|
||||
|
||||
Generative `<pre>` field (plasma):
|
||||
```js
|
||||
const pre = document.querySelector('pre');
|
||||
const COLS = 100, ROWS = 50, ramp = ' .:-=+*#%@';
|
||||
function frame(t) {
|
||||
let out = '';
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const v = Math.sin(x * 0.2 + t * 0.001)
|
||||
+ Math.sin(y * 0.3 + t * 0.0013)
|
||||
+ Math.sin((x + y) * 0.15 + t * 0.0007);
|
||||
const lum = (v + 3) / 6; // normalize -3..3 to 0..1
|
||||
out += ramp[Math.round(lum * (ramp.length - 1))];
|
||||
}
|
||||
out += '\n';
|
||||
}
|
||||
pre.textContent = out;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
```
|
||||
|
||||
## Image / video to ASCII
|
||||
|
||||
Draw the source to a small offscreen canvas at `cols × rows`, read pixels with `getImageContext().getImageData`, then map each pixel to a character. For video, repeat per frame from a `<video>` element.
|
||||
|
||||
```js
|
||||
function videoToAscii(video, cols = 120) {
|
||||
const aspect = video.videoHeight / video.videoWidth;
|
||||
const rows = Math.round(cols * aspect * 0.5); // cell aspect correction
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cols; cv.height = rows;
|
||||
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
||||
const ramp = ' .:-=+*#%@', pre = document.querySelector('pre');
|
||||
(function tick() {
|
||||
ctx.drawImage(video, 0, 0, cols, rows);
|
||||
const { data } = ctx.getImageData(0, 0, cols, rows);
|
||||
let out = '';
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const lum = (0.2126*data[i] + 0.7152*data[i+1] + 0.0722*data[i+2]) / 255;
|
||||
out += ramp[Math.round(lum * (ramp.length - 1))];
|
||||
if (((i / 4) + 1) % cols === 0) out += '\n';
|
||||
}
|
||||
pre.textContent = out;
|
||||
requestAnimationFrame(tick);
|
||||
})();
|
||||
}
|
||||
```
|
||||
|
||||
For Node image→ASCII, see `scripts/img-to-ascii.mjs`.
|
||||
|
||||
## 3D-to-ASCII with Three.js
|
||||
|
||||
`AsciiEffect` wraps a renderer and renders any scene as ASCII into a DOM element. Render through the effect, not the renderer.
|
||||
|
||||
```js
|
||||
import { AsciiEffect } from 'three/addons/effects/AsciiEffect.js';
|
||||
const effect = new AsciiEffect(renderer, ' .:-=+*#%@', { invert: true });
|
||||
effect.setSize(innerWidth, innerHeight);
|
||||
effect.domElement.style.color = '#0f0';
|
||||
effect.domElement.style.backgroundColor = 'black';
|
||||
document.body.appendChild(effect.domElement);
|
||||
// in loop: effect.render(scene, camera); // NOT renderer.render
|
||||
```
|
||||
|
||||
## Terminal / CLI animation
|
||||
|
||||
Loop with ANSI escape codes: hide the cursor, move to home, print the frame, throttle to 12–24 fps. Show the cursor again on exit.
|
||||
|
||||
```js
|
||||
const ESC = '\x1b[';
|
||||
process.stdout.write(ESC + '?25l'); // hide cursor
|
||||
function frame(t) {
|
||||
process.stdout.write(ESC + 'H'); // cursor to top-left
|
||||
// build and write rows...
|
||||
}
|
||||
const id = setInterval(() => frame(Date.now()), 1000 / 20);
|
||||
process.on('SIGINT', () => {
|
||||
clearInterval(id);
|
||||
process.stdout.write(ESC + '?25h' + ESC + '2J'); // show cursor, clear
|
||||
process.exit();
|
||||
});
|
||||
```
|
||||
Use `ESC + '2J'` to clear the whole screen, `ESC + 'H'` for home (cheaper per frame than clearing). Color with `\x1b[38;2;R;G;Bm` (truecolor) and reset with `\x1b[0m`.
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
For a self-contained web ASCII piece (generative field, image/video→ASCII, `AsciiEffect` scene) the deliverable is **one HTML file that opens directly in a browser** — the `<pre>`/canvas, the ramp, and the rAF loop inline (Three.js from CDN if used). No build step. One file is the right tier; don't reach for a bundler. (Terminal/CLI pieces verify differently — capture stdout or a screenshot of the terminal.)
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: the render target, the brightness ramp, and the `requestAnimationFrame` loop in one inline `<script>`.
|
||||
- Drive frames from an **injectable time** (not `Date.now()`/`performance.now()` directly) and seed any randomness, so a frame is reproducible.
|
||||
|
||||
**Seek harness — freeze the rAF loop on a deterministic frame.** `?t=N` renders exactly one frame at simulated time `N` instead of looping, so a screenshot is reproducible. Feed `N` where the loop reads time, and fix the seed:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const t = new URLSearchParams(location.search).get("t");
|
||||
// your frame(time){…} reads `time`, not Date.now(); RNG uses a fixed seed
|
||||
if (t !== null) { frame(parseFloat(t)); } // render ONE frame, no rAF
|
||||
else { (function loop(now){ frame(now); requestAnimationFrame(loop); })(0); }
|
||||
window.__ready = true;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → freeze → screenshot → check:** render at a few simulated times (`?t=0`, `?t=1000`, `?t=2000`), screenshot each, and check **fidelity** (ramp reads dark→light correctly, motion evolves) plus **artifacts** (vertical stretch from missing cell-aspect correction, wrong invert on a light bg, clipped grid, FOUC before the monospace font loads — fonts settle the cell metrics, so wait). Any headless tool works:
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/ascii.html?t=1000" frame-mid.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone — no console errors, CDN (Three.js, if used) loads, monospace font applied.
|
||||
2. `?t=N` renders one deterministic frame (injected time + fixed seed), no live loop.
|
||||
3. Screenshotted at 3 simulated times — matches the brief, no vertical stretch or wrong-invert.
|
||||
4. `prefers-reduced-motion` honored — stop/slow the rAF loop, show a static frame.
|
||||
5. Easing is intentional — frame rate throttled on purpose (12–24fps for retro feel), ramp ordering deliberate.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Approach |
|
||||
|------|----------|
|
||||
| Simple generative field | `<pre>` + `textContent`, sine/noise → ramp |
|
||||
| Photo fidelity | 70-level Bourke ramp, luminance from sRGB weights |
|
||||
| Per-char color / 60fps | Canvas `fillText` per cell |
|
||||
| Video | `<video>` → offscreen canvas → `getImageData` per frame |
|
||||
| 3D scene | Three.js `AsciiEffect.render(scene, camera)` |
|
||||
| Terminal | ANSI `\x1b[H` home + throttle 12–24fps, hide cursor |
|
||||
| Aspect fix | `rows = cols * imgAspect * 0.5` |
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/rendering.md` — Full brightness ramp tables (10/70/extended), pixel sampling math and cell aspect correction, `<pre>` vs canvas tradeoffs with code, ANSI terminal frame loop with truecolor, and complete `AsciiEffect` wiring.
|
||||
- `scripts/img-to-ascii.mjs` — Runnable Node script that converts a PNG/JPG file to ASCII text, with `--cols`, `--invert`, and `--ramp` flags.
|
||||
@@ -0,0 +1,194 @@
|
||||
# ASCII Rendering Reference
|
||||
|
||||
Detailed tables, sampling math, and complete renderers for ASCII animation.
|
||||
|
||||
## Brightness ramp tables
|
||||
|
||||
Order is **dark → light**. Index into the ramp with `Math.round(lum * (ramp.length - 1))` where `lum` is 0..1.
|
||||
|
||||
### 10-level (compact, generative)
|
||||
```
|
||||
.:-=+*#%@
|
||||
```
|
||||
Good for low-resolution generative fields and terminals. Distinct steps, low banding artifacts.
|
||||
|
||||
### 70-level Paul Bourke ramp (photo fidelity)
|
||||
```
|
||||
$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`'.
|
||||
```
|
||||
This is ordered **light → dark** (densest first). For luminance mapping, either reverse the string or use `index = Math.round((1 - lum) * (len - 1))`. Best results for grayscale photos; whitespace at the end represents black/empty.
|
||||
|
||||
### 16-level extended (balance)
|
||||
```
|
||||
.'`,^:";~-_+<>i!lI?/\|()1{}[]rcvunxzjftLCJUYXZO0Qoahkbdpqwm*WMB8&%$#@
|
||||
```
|
||||
Truncate or downsample to taste. For UI/loaders a hand-picked 8–12 char ramp usually looks cleaner than a 70-char ramp because the steps are visually distinct.
|
||||
|
||||
### Block / shade characters (Unicode, smoother gradient)
|
||||
```
|
||||
░▒▓█
|
||||
```
|
||||
Or with the 1/8 block fills for vertical bars: `▁▂▃▄▅▆▇█`. Renders as a near-continuous gradient in monospace fonts; great for waveforms, meters, and smooth fades. Requires a font with box-drawing glyphs (most monospace fonts have them).
|
||||
|
||||
## Luminance computation
|
||||
|
||||
Perceptual (sRGB-weighted, recommended):
|
||||
```js
|
||||
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1
|
||||
```
|
||||
Fast average (acceptable for stylized output):
|
||||
```js
|
||||
const lum = (r + g + b) / (3 * 255);
|
||||
```
|
||||
Invert for dark-on-light surfaces: `lum = 1 - lum`. Apply gamma to spread midtones: `lum = Math.pow(lum, 0.8)` (lower exponent brightens).
|
||||
|
||||
## Pixel sampling and cell aspect correction
|
||||
|
||||
Monospace cells are about twice as tall as wide. Real cell aspect depends on the font: measure once.
|
||||
|
||||
```js
|
||||
function measureCellAspect(fontFamily = 'monospace', fontPx = 16) {
|
||||
const cv = document.createElement('canvas');
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.font = `${fontPx}px ${fontFamily}`;
|
||||
const w = ctx.measureText('M').width; // glyph advance width
|
||||
const h = fontPx * 1.0; // with line-height: 1
|
||||
return w / h; // ~0.5 for most fonts
|
||||
}
|
||||
```
|
||||
|
||||
Compute the character grid for a source image, correcting aspect so the picture is not vertically stretched:
|
||||
```js
|
||||
function gridSize(imgW, imgH, cols, cellAspect = 0.5) {
|
||||
const rows = Math.round(cols * (imgH / imgW) * cellAspect);
|
||||
return { cols, rows };
|
||||
}
|
||||
```
|
||||
Draw the source into an offscreen canvas at exactly `cols × rows`, then read all pixels once with `getImageData(0, 0, cols, rows)`. One `getImageData` call per frame is far cheaper than per-pixel reads.
|
||||
|
||||
## `<pre>` vs canvas
|
||||
|
||||
| Aspect | `<pre>` + textContent | Canvas `fillText` |
|
||||
|--------|----------------------|-------------------|
|
||||
| Setup | trivial, one string | per-cell draw loop |
|
||||
| Color | single color (or per-span, slow) | per-char color cheap |
|
||||
| Max size | ~120×60 @ 30fps | thousands of cells @ 60fps |
|
||||
| Selectable text | yes | no |
|
||||
| Best for | generative fields, loaders | video, per-char color, large grids |
|
||||
|
||||
`<pre>` styling that avoids gaps and stretching:
|
||||
```css
|
||||
pre { font-family: ui-monospace, monospace; line-height: 1; letter-spacing: 0;
|
||||
margin: 0; white-space: pre; }
|
||||
```
|
||||
|
||||
Canvas per-char color renderer:
|
||||
```js
|
||||
function drawAsciiCanvas(ctx, chars, colors, cols, rows, cellW, cellH) {
|
||||
ctx.clearRect(0, 0, cols * cellW, rows * cellH);
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = `${cellH}px monospace`;
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const i = y * cols + x;
|
||||
ctx.fillStyle = colors[i]; // e.g. `rgb(r,g,b)`
|
||||
ctx.fillText(chars[i], x * cellW, y * cellH);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ANSI terminal frame loop (Node)
|
||||
|
||||
```js
|
||||
const ESC = '\x1b[';
|
||||
const out = process.stdout;
|
||||
|
||||
function start(render, fps = 20) {
|
||||
out.write(ESC + '?25l'); // hide cursor
|
||||
out.write(ESC + '2J'); // clear screen once
|
||||
const interval = 1000 / fps;
|
||||
let start = Date.now();
|
||||
const id = setInterval(() => {
|
||||
const t = Date.now() - start;
|
||||
out.write(ESC + 'H'); // home (cheap, no full clear)
|
||||
out.write(render(t, out.columns || 80, out.rows || 24));
|
||||
}, interval);
|
||||
const stop = () => {
|
||||
clearInterval(id);
|
||||
out.write(ESC + '0m' + ESC + '?25h' + ESC + '2J' + ESC + 'H');
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', stop);
|
||||
return stop;
|
||||
}
|
||||
|
||||
// Truecolor per character: foreground `\x1b[38;2;R;G;Bm`, reset `\x1b[0m`.
|
||||
function colored(ch, r, g, b) {
|
||||
return `${ESC}38;2;${r};${g};${b}m${ch}${ESC}0m`;
|
||||
}
|
||||
|
||||
// Example: plasma using full terminal size
|
||||
start((t, cols, rows) => {
|
||||
const ramp = ' .:-=+*#%@';
|
||||
let s = '';
|
||||
for (let y = 0; y < rows - 1; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const v = Math.sin(x * 0.15 + t * 0.001)
|
||||
+ Math.sin(y * 0.25 + t * 0.0015)
|
||||
+ Math.sin((x + y) * 0.1 + t * 0.0008);
|
||||
const lum = (v + 3) / 6;
|
||||
s += ramp[Math.round(lum * (ramp.length - 1))];
|
||||
}
|
||||
s += '\n';
|
||||
}
|
||||
return s;
|
||||
});
|
||||
```
|
||||
Notes: writing `ESC + 'H'` (home) and overwriting is much smoother than clearing the screen every frame, which causes flicker. Avoid printing a trailing newline on the last row or the terminal scrolls. `out.columns`/`out.rows` give the live terminal size; recompute the grid on `process.stdout.on('resize', ...)`.
|
||||
|
||||
## Three.js AsciiEffect — complete wiring
|
||||
|
||||
```js
|
||||
import * as THREE from 'three';
|
||||
import { AsciiEffect } from 'three/addons/effects/AsciiEffect.js';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
|
||||
const effect = new AsciiEffect(renderer, ' .:-=+*#%@', { invert: true, resolution: 0.2 });
|
||||
effect.setSize(innerWidth, innerHeight);
|
||||
effect.domElement.style.color = 'white';
|
||||
effect.domElement.style.backgroundColor = 'black';
|
||||
document.body.appendChild(effect.domElement); // append the EFFECT's DOM, not renderer.domElement
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 1, 1000);
|
||||
camera.position.z = 4;
|
||||
scene.add(new THREE.PointLight(0xffffff, 3).position.set(5, 5, 5));
|
||||
scene.add(new THREE.AmbientLight(0x808080));
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.TorusKnotGeometry(1, 0.3, 100, 16),
|
||||
new THREE.MeshStandardMaterial({ flatShading: true })
|
||||
);
|
||||
scene.add(mesh);
|
||||
|
||||
const controls = new OrbitControls(camera, effect.domElement); // attach to effect DOM
|
||||
|
||||
function animate(t) {
|
||||
mesh.rotation.y = t * 0.0005;
|
||||
effect.render(scene, camera); // render through the effect
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||||
effect.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
```
|
||||
Key gotchas: append `effect.domElement` (not the renderer's canvas) and call `effect.render`, not `renderer.render`. `resolution` (default ~0.15) controls character density; lower = fewer, larger characters. Lighting matters — ASCII reads luminance, so use strong directional contrast and `flatShading` for crisp edges.
|
||||
|
||||
---
|
||||
Map brightness to the right glyph ramp and the characters come alive. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=ascii-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
// img-to-ascii.mjs — Convert a PNG/JPG image to ASCII text.
|
||||
//
|
||||
// Usage:
|
||||
// node img-to-ascii.mjs <image> [--cols 120] [--invert] [--ramp " .:-=+*#%@"]
|
||||
//
|
||||
// Dependency: sharp (npm i sharp). Sharp decodes PNG/JPG/WebP and resizes.
|
||||
//
|
||||
// Notes:
|
||||
// - Applies monospace cell-aspect correction (cells are ~2x taller than wide),
|
||||
// so rows = cols * (imgH/imgW) * 0.5.
|
||||
// - Luminance uses sRGB perceptual weights (0.2126/0.7152/0.0722).
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { cols: 120, invert: false, ramp: ' .:-=+*#%@', file: null };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--cols') args.cols = parseInt(argv[++i], 10);
|
||||
else if (a === '--invert') args.invert = true;
|
||||
else if (a === '--ramp') args.ramp = argv[++i];
|
||||
else if (!a.startsWith('--')) args.file = a;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { cols, invert, ramp, file } = parseArgs(process.argv);
|
||||
if (!file) {
|
||||
console.error('Usage: node img-to-ascii.mjs <image> [--cols N] [--invert] [--ramp "..."]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const CELL_ASPECT = 0.5; // monospace width:height
|
||||
const meta = await sharp(file).metadata();
|
||||
const rows = Math.max(1, Math.round(cols * (meta.height / meta.width) * CELL_ASPECT));
|
||||
|
||||
// Resize to the character grid, drop alpha, get raw RGB bytes.
|
||||
const { data } = await sharp(file)
|
||||
.resize(cols, rows, { fit: 'fill' })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const last = ramp.length - 1;
|
||||
const lines = [];
|
||||
for (let y = 0; y < rows; y++) {
|
||||
let line = '';
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const i = (y * cols + x) * 3;
|
||||
let lum = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255;
|
||||
if (invert) lum = 1 - lum;
|
||||
line += ramp[Math.round(lum * last)];
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user