ascii-shape-renderer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ export interface VideoAsciiRendererOptions {
2
+ cellWidth?: number;
3
+ cellHeight?: number;
4
+ globalContrastExponent?: number;
5
+ directionalContrastExponent?: number;
6
+ colorMode?: 'none' | 'fg' | 'bg' | 'both';
7
+ targetFps?: number;
8
+ maxWidth?: number;
9
+ characters?: string;
10
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,48 @@
1
+ declare const BASE_CW = 8, BASE_CH = 14, CIRCLE_R = 0.18;
2
+ declare const CHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
3
+ declare const INT_POS: {
4
+ x: number;
5
+ y: number;
6
+ }[];
7
+ declare const EXT_POS: {
8
+ x: number;
9
+ y: number;
10
+ }[];
11
+ declare const AFFECT: number[][];
12
+ declare let shapeVecs: {
13
+ ch: string;
14
+ v: number[];
15
+ }[];
16
+ declare let kdTree: any;
17
+ declare const cache: Map<string, string>;
18
+ declare function init(): void;
19
+ declare function sampleCircle(d: ImageData, cx: number, cy: number, r: number): number;
20
+ declare function buildKd(items: any[], depth?: number): any;
21
+ declare function findNear(n: any, t: number[], best?: {
22
+ dist: number;
23
+ ch: string;
24
+ }): {
25
+ dist: number;
26
+ ch: string;
27
+ };
28
+ declare function contrast(int: number[], ext: number[], gE: number, dE: number): number[];
29
+ declare function sampleImg(data: Uint8ClampedArray, width: number, height: number, cx: number, cy: number, r: number): number;
30
+ declare function sampleClr(data: Uint8ClampedArray, width: number, height: number, cx: number, cy: number, cw: number, ch: number): string;
31
+ declare function darken(h: string, a: number): string;
32
+ interface RenderMessage {
33
+ type: 'render';
34
+ id: number;
35
+ data: Uint8ClampedArray;
36
+ width: number;
37
+ height: number;
38
+ options: {
39
+ globalContrast?: number;
40
+ directionalContrast?: number;
41
+ colorMode?: string;
42
+ };
43
+ }
44
+ declare function renderFrame(data: Uint8ClampedArray, width: number, height: number, opts: any): {
45
+ html: string;
46
+ cols: number;
47
+ rows: number;
48
+ };
package/dist/worker.js ADDED
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ // ASCII Renderer Web Worker
3
+ // Handles heavy ASCII conversion off main thread
4
+ const BASE_CW = 8, BASE_CH = 14, CIRCLE_R = 0.18;
5
+ const CHARS = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
6
+ const INT_POS = [{ x: .25, y: .20 }, { x: .75, y: .13 }, { x: .25, y: .50 }, { x: .75, y: .50 }, { x: .25, y: .80 }, { x: .75, y: .87 }];
7
+ const EXT_POS = [{ x: .25, y: -.15 }, { x: .75, y: -.15 }, { x: -.15, y: .20 }, { x: 1.15, y: .13 }, { x: -.15, y: .50 }, { x: 1.15, y: .50 }, { x: -.15, y: .80 }, { x: 1.15, y: .87 }, { x: .25, y: 1.15 }, { x: .75, y: 1.15 }];
8
+ const AFFECT = [[0, 1, 2, 4], [0, 1, 3, 5], [2, 4, 6], [3, 5, 7], [4, 6, 8, 9], [5, 7, 8, 9]];
9
+ let shapeVecs = [];
10
+ let kdTree = null;
11
+ const cache = new Map();
12
+ // Initialize shape vectors
13
+ function init() {
14
+ if (kdTree)
15
+ return;
16
+ // Create offscreen canvas for character rendering
17
+ const c = new OffscreenCanvas(BASE_CW, BASE_CH);
18
+ const ctx = c.getContext('2d');
19
+ for (const ch of CHARS) {
20
+ ctx.fillStyle = '#000';
21
+ ctx.fillRect(0, 0, BASE_CW, BASE_CH);
22
+ ctx.fillStyle = '#fff';
23
+ ctx.font = `${BASE_CH}px monospace`;
24
+ ctx.textBaseline = 'top';
25
+ ctx.textAlign = 'center';
26
+ ctx.fillText(ch, BASE_CW / 2, 0);
27
+ const d = ctx.getImageData(0, 0, BASE_CW, BASE_CH);
28
+ const v = INT_POS.map(p => sampleCircle(d, p.x * BASE_CW, p.y * BASE_CH, CIRCLE_R * BASE_CH));
29
+ shapeVecs.push({ ch, v });
30
+ }
31
+ const max = [0, 0, 0, 0, 0, 0];
32
+ shapeVecs.forEach(({ v }) => v.forEach((x, i) => max[i] = Math.max(max[i], x)));
33
+ shapeVecs = shapeVecs.map(({ ch, v }) => ({ ch, v: v.map((x, i) => max[i] ? x / max[i] : 0) }));
34
+ kdTree = buildKd(shapeVecs);
35
+ }
36
+ function sampleCircle(d, cx, cy, r) {
37
+ let t = 0, c = 0;
38
+ for (let dy = -r; dy <= r; dy++) {
39
+ for (let dx = -r; dx <= r; dx++) {
40
+ if (dx * dx + dy * dy <= r * r) {
41
+ const x = ~~(cx + dx), y = ~~(cy + dy);
42
+ if (x >= 0 && x < d.width && y >= 0 && y < d.height) {
43
+ t += d.data[(y * d.width + x) * 4] / 255;
44
+ c++;
45
+ }
46
+ }
47
+ }
48
+ }
49
+ return c ? t / c : 0;
50
+ }
51
+ function buildKd(items, depth = 0) {
52
+ if (!items.length)
53
+ return null;
54
+ const a = depth % 6;
55
+ items.sort((x, y) => x.v[a] - y.v[a]);
56
+ const m = items.length >> 1;
57
+ return { item: items[m], left: buildKd(items.slice(0, m), depth + 1), right: buildKd(items.slice(m + 1), depth + 1), axis: a };
58
+ }
59
+ function findNear(n, t, best = { dist: Infinity, ch: ' ' }) {
60
+ if (!n)
61
+ return best;
62
+ const d = n.item.v.reduce((s, x, i) => s + (x - t[i]) ** 2, 0);
63
+ if (d < best.dist)
64
+ best = { dist: d, ch: n.item.ch };
65
+ const diff = t[n.axis] - n.item.v[n.axis];
66
+ best = findNear(diff < 0 ? n.left : n.right, t, best);
67
+ if (diff * diff < best.dist)
68
+ best = findNear(diff < 0 ? n.right : n.left, t, best);
69
+ return best;
70
+ }
71
+ function contrast(int, ext, gE, dE) {
72
+ let v = int.map((val, i) => {
73
+ if (dE <= 1)
74
+ return val;
75
+ let m = val;
76
+ for (const e of AFFECT[i])
77
+ m = Math.max(m, ext[e]);
78
+ return m ? Math.pow(val / m, dE) * m : val;
79
+ });
80
+ if (gE > 1) {
81
+ const m = Math.max(...v);
82
+ if (m)
83
+ v = v.map(x => Math.pow(x / m, gE) * m);
84
+ }
85
+ return v;
86
+ }
87
+ function sampleImg(data, width, height, cx, cy, r) {
88
+ let t = 0, c = 0;
89
+ for (let dy = -r; dy <= r; dy += 2) {
90
+ for (let dx = -r; dx <= r; dx += 2) {
91
+ if (dx * dx + dy * dy <= r * r) {
92
+ const x = ~~(cx + dx), y = ~~(cy + dy);
93
+ if (x >= 0 && x < width && y >= 0 && y < height) {
94
+ const i = (y * width + x) * 4;
95
+ t += .2126 * data[i] / 255 + .7152 * data[i + 1] / 255 + .0722 * data[i + 2] / 255;
96
+ c++;
97
+ }
98
+ }
99
+ }
100
+ }
101
+ return c ? t / c : 0;
102
+ }
103
+ function sampleClr(data, width, height, cx, cy, cw, ch) {
104
+ let r = 0, g = 0, b = 0, c = 0;
105
+ for (let dy = ch * .25; dy < ch * .75; dy += 2) {
106
+ for (let dx = cw * .25; dx < cw * .75; dx += 2) {
107
+ const x = ~~(cx + dx), y = ~~(cy + dy);
108
+ if (x >= 0 && x < width && y >= 0 && y < height) {
109
+ const i = (y * width + x) * 4;
110
+ r += data[i];
111
+ g += data[i + 1];
112
+ b += data[i + 2];
113
+ c++;
114
+ }
115
+ }
116
+ }
117
+ return c ? '#' + [r, g, b].map(v => (~~(v / c)).toString(16).padStart(2, '0')).join('') : '#000';
118
+ }
119
+ function darken(h, a) {
120
+ return '#' + [1, 3, 5].map(i => (~~(parseInt(h.slice(i, i + 2), 16) * (1 - a))).toString(16).padStart(2, '0')).join('');
121
+ }
122
+ function renderFrame(data, width, height, opts) {
123
+ const gE = opts.globalContrast ?? 2;
124
+ const dE = opts.directionalContrast ?? 3;
125
+ const colorMode = opts.colorMode ?? 'none';
126
+ const cols = Math.round(width / BASE_CW);
127
+ const rows = Math.round(height / BASE_CH);
128
+ const cW = width / cols, cH = height / rows;
129
+ const r = CIRCLE_R * cH;
130
+ cache.clear();
131
+ let html = '';
132
+ for (let row = 0; row < rows; row++) {
133
+ for (let col = 0; col < cols; col++) {
134
+ const cx = col * cW, cy = row * cH;
135
+ const int = INT_POS.map(p => sampleImg(data, width, height, cx + p.x * cW, cy + p.y * cH, r));
136
+ const ext = EXT_POS.map(p => sampleImg(data, width, height, cx + p.x * cW, cy + p.y * cH, r));
137
+ const enh = contrast(int, ext, gE, dE);
138
+ const key = enh.map(v => ~~(v * 31)).join(',');
139
+ if (!cache.has(key))
140
+ cache.set(key, findNear(kdTree, enh).ch);
141
+ let ch = cache.get(key);
142
+ if (ch === '<')
143
+ ch = '&lt;';
144
+ else if (ch === '>')
145
+ ch = '&gt;';
146
+ else if (ch === '&')
147
+ ch = '&amp;';
148
+ if (colorMode === 'none') {
149
+ html += ch;
150
+ }
151
+ else {
152
+ const clr = sampleClr(data, width, height, cx, cy, cW, cH);
153
+ const st = [];
154
+ if (colorMode === 'fg' || colorMode === 'both')
155
+ st.push(`color:${clr}`);
156
+ if (colorMode === 'bg' || colorMode === 'both')
157
+ st.push(`background:${darken(clr, .5)}`);
158
+ html += `<span style="${st.join(';')}">${ch}</span>`;
159
+ }
160
+ }
161
+ html += '\n';
162
+ }
163
+ return { html, cols, rows };
164
+ }
165
+ // Handle messages from main thread
166
+ self.onmessage = (e) => {
167
+ if (e.data.type === 'render') {
168
+ init();
169
+ const { id, data, width, height, options } = e.data;
170
+ const result = renderFrame(data, width, height, options);
171
+ self.postMessage({ id, ...result });
172
+ }
173
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "ascii-shape-renderer",
3
+ "version": "1.0.0",
4
+ "description": "Shape-aware ASCII renderer using 6D shape vectors and contrast enhancement",
5
+ "main": "dist/index.js",
6
+ "module": "dist/ascii-renderer.esm.js",
7
+ "browser": "dist/ascii-renderer.min.js",
8
+ "unpkg": "dist/ascii-renderer.min.js",
9
+ "jsdelivr": "dist/ascii-renderer.min.js",
10
+ "types": "dist/index.d.ts",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc && npm run bundle",
16
+ "bundle": "esbuild src/browser.ts --bundle --minify --format=iife --global-name=AsciiRenderer --outfile=dist/ascii-renderer.min.js && esbuild src/browser.ts --bundle --format=esm --outfile=dist/ascii-renderer.esm.js",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/jeeshofone/ascii-shape-renderer"
22
+ },
23
+ "keywords": [
24
+ "ascii",
25
+ "art",
26
+ "renderer",
27
+ "image",
28
+ "video",
29
+ "shape",
30
+ "terminal"
31
+ ],
32
+ "author": "",
33
+ "license": "MIT",
34
+ "devDependencies": {
35
+ "esbuild": "^0.27.2",
36
+ "html-minifier-terser": "^7.2.0",
37
+ "typescript": "^5.0.0"
38
+ },
39
+ "dependencies": {
40
+ "canvas": "^3.2.1",
41
+ "gifencoder": "^2.0.1"
42
+ }
43
+ }