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,499 @@
1
+ "use strict";
2
+ /**
3
+ * ASCII Shape Renderer - Browser Bundle
4
+ * Simple drop-in library for rendering images/video as ASCII art
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.image = image;
8
+ exports.video = video;
9
+ exports.webcam = webcam;
10
+ // Core algorithm (inlined for single-file distribution)
11
+ const BASE_CW = 8, BASE_CH = 14, CIRCLE_R = 0.18;
12
+ const CHARS = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
13
+ 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 }];
14
+ 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 }];
15
+ const AFFECT = [[0, 1, 2, 4], [0, 1, 3, 5], [2, 4, 6], [3, 5, 7], [4, 6, 8, 9], [5, 7, 8, 9]];
16
+ let shapeVecs = [];
17
+ let kdTree = null;
18
+ let initialized = false;
19
+ // Worker pool for offloading rendering
20
+ let workerPool = [];
21
+ let workerIndex = 0;
22
+ let pendingCallbacks = new Map();
23
+ let messageId = 0;
24
+ function getWorker() {
25
+ if (typeof Worker === 'undefined')
26
+ return null;
27
+ if (workerPool.length === 0) {
28
+ // Create inline worker from blob
29
+ const workerCode = `${WORKER_CODE}`;
30
+ try {
31
+ const blob = new Blob([workerCode], { type: 'application/javascript' });
32
+ const url = URL.createObjectURL(blob);
33
+ const numWorkers = Math.min(navigator.hardwareConcurrency || 4, 4);
34
+ for (let i = 0; i < numWorkers; i++) {
35
+ const w = new Worker(url);
36
+ w.onmessage = (e) => {
37
+ const cb = pendingCallbacks.get(e.data.id);
38
+ if (cb) {
39
+ pendingCallbacks.delete(e.data.id);
40
+ cb(e.data);
41
+ }
42
+ };
43
+ workerPool.push(w);
44
+ }
45
+ console.log(`[AsciiRenderer] Using ${numWorkers} Web Workers for rendering`);
46
+ }
47
+ catch (e) {
48
+ console.log('[AsciiRenderer] Web Workers unavailable, using main thread');
49
+ return null;
50
+ }
51
+ }
52
+ const worker = workerPool[workerIndex];
53
+ workerIndex = (workerIndex + 1) % workerPool.length;
54
+ return worker;
55
+ }
56
+ function renderInWorker(data, width, height, options) {
57
+ return new Promise((resolve) => {
58
+ const worker = getWorker();
59
+ if (!worker) {
60
+ // Fallback to main thread
61
+ init();
62
+ resolve(renderFrame({ data, width, height }, options));
63
+ return;
64
+ }
65
+ const id = messageId++;
66
+ pendingCallbacks.set(id, resolve);
67
+ worker.postMessage({ type: 'render', id, data, width, height, options }, [data.buffer]);
68
+ });
69
+ }
70
+ // Inline worker code as string
71
+ const WORKER_CODE = `
72
+ const BASE_CW=8,BASE_CH=14,CIRCLE_R=.18,CHARS=' !"#$%&\\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^` + "`" + `_abcdefghijklmnopqrstuvwxyz{|}~',INT_POS=[{x:.25,y:.2},{x:.75,y:.13},{x:.25,y:.5},{x:.75,y:.5},{x:.25,y:.8},{x:.75,y:.87}],EXT_POS=[{x:.25,y:-.15},{x:.75,y:-.15},{x:-.15,y:.2},{x:1.15,y:.13},{x:-.15,y:.5},{x:1.15,y:.5},{x:-.15,y:.8},{x:1.15,y:.87},{x:.25,y:1.15},{x:.75,y:1.15}],AFFECT=[[0,1,2,4],[0,1,3,5],[2,4,6],[3,5,7],[4,6,8,9],[5,7,8,9]];let shapeVecs=[],kdTree=null;const cache=new Map;function init(){if(kdTree)return;const c=new OffscreenCanvas(BASE_CW,BASE_CH),ctx=c.getContext("2d",{willReadFrequently:true});for(const ch of CHARS){ctx.fillStyle="#000",ctx.fillRect(0,0,BASE_CW,BASE_CH),ctx.fillStyle="#fff",ctx.font=BASE_CH+"px monospace",ctx.textBaseline="top",ctx.textAlign="center",ctx.fillText(ch,BASE_CW/2,0);const d=ctx.getImageData(0,0,BASE_CW,BASE_CH),v=INT_POS.map(p=>sampleC(d,p.x*BASE_CW,p.y*BASE_CH,CIRCLE_R*BASE_CH));shapeVecs.push({ch,v})}const max=[0,0,0,0,0,0];shapeVecs.forEach(({v})=>v.forEach((x,i)=>max[i]=Math.max(max[i],x))),shapeVecs=shapeVecs.map(({ch,v})=>({ch,v:v.map((x,i)=>max[i]?x/max[i]:0)})),kdTree=buildKd(shapeVecs)}function sampleC(d,cx,cy,r){let t=0,c=0;for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++)dx*dx+dy*dy<=r*r&&(x=~~(cx+dx),y=~~(cy+dy),x>=0&&x<d.width&&y>=0&&y<d.height&&(t+=d.data[(y*d.width+x)*4]/255,c++));return c?t/c:0}function buildKd(items,depth=0){if(!items.length)return null;const a=depth%6;items.sort((x,y)=>x.v[a]-y.v[a]);const m=items.length>>1;return{item:items[m],left:buildKd(items.slice(0,m),depth+1),right:buildKd(items.slice(m+1),depth+1),axis:a}}function findNear(n,t,best={dist:1/0,ch:" "}){if(!n)return best;const d=n.item.v.reduce((s,x,i)=>s+(x-t[i])**2,0);d<best.dist&&(best={dist:d,ch:n.item.ch});const diff=t[n.axis]-n.item.v[n.axis];return best=findNear(diff<0?n.left:n.right,t,best),diff*diff<best.dist&&(best=findNear(diff<0?n.right:n.left,t,best)),best}function contrast(int,ext,gE,dE){let v=int.map((val,i)=>{if(dE<=1)return val;let m=val;for(const e of AFFECT[i])m=Math.max(m,ext[e]);return m?Math.pow(val/m,dE)*m:val});if(gE>1){const m=Math.max(...v);m&&(v=v.map(x=>Math.pow(x/m,gE)*m))}return v}function sampleImg(data,w,h,cx,cy,r){let t=0,c=0;for(let dy=-r;dy<=r;dy+=2)for(let dx=-r;dx<=r;dx+=2)dx*dx+dy*dy<=r*r&&(x=~~(cx+dx),y=~~(cy+dy),x>=0&&x<w&&y>=0&&y<h&&(i=(y*w+x)*4,t+=.2126*data[i]/255+.7152*data[i+1]/255+.0722*data[i+2]/255,c++));return c?t/c:0}function sampleClr(data,w,h,cx,cy,cw,ch){let r=0,g=0,b=0,c=0;for(let dy=ch*.25;dy<ch*.75;dy+=2)for(let dx=cw*.25;dx<cw*.75;dx+=2){const x=~~(cx+dx),y=~~(cy+dy);x>=0&&x<w&&y>=0&&y<h&&(i=(y*w+x)*4,r+=data[i],g+=data[i+1],b+=data[i+2],c++)}return c?"#"+[r,g,b].map(v=>(~~(v/c)).toString(16).padStart(2,"0")).join(""):"#000"}function darken(h,a){return"#"+[1,3,5].map(i=>(~~(parseInt(h.slice(i,i+2),16)*(1-a))).toString(16).padStart(2,"0")).join("")}function renderFrame(data,w,h,opts){const gE=opts.globalContrast??2,dE=opts.directionalContrast??3,colorMode=opts.colorMode??"none",cols=Math.round(w/BASE_CW),rows=Math.round(h/BASE_CH),cW=w/cols,cH=h/rows,r=CIRCLE_R*cH;cache.clear();let html="";for(let row=0;row<rows;row++){for(let col=0;col<cols;col++){const cx=col*cW,cy=row*cH,int=INT_POS.map(p=>sampleImg(data,w,h,cx+p.x*cW,cy+p.y*cH,r)),ext=EXT_POS.map(p=>sampleImg(data,w,h,cx+p.x*cW,cy+p.y*cH,r)),enh=contrast(int,ext,gE,dE),key=enh.map(v=>~~(31*v)).join(",");cache.has(key)||cache.set(key,findNear(kdTree,enh).ch);let ch=cache.get(key);"<"===ch?ch="&lt;":">"===ch?ch="&gt;":"&"===ch&&(ch="&amp;"),"none"===colorMode?html+=ch:(clr=sampleClr(data,w,h,cx,cy,cW,cH),st=[],"fg"!==colorMode&&"both"!==colorMode||st.push("color:"+clr),"bg"!==colorMode&&"both"!==colorMode||st.push("background:"+darken(clr,.5)),html+='<span style="'+st.join(";")+'">'+ch+"</span>")}html+="\\n"}return{html,cols,rows}}self.onmessage=e=>{if("render"===e.data.type){init();const{id,data,width,height,options}=e.data,result=renderFrame(data,width,height,options);self.postMessage({id,...result})}};
73
+ `;
74
+ function init() {
75
+ if (initialized)
76
+ return;
77
+ const c = document.createElement('canvas');
78
+ c.width = BASE_CW;
79
+ c.height = BASE_CH;
80
+ const ctx = c.getContext('2d', { willReadFrequently: true });
81
+ for (const ch of CHARS) {
82
+ ctx.fillStyle = '#000';
83
+ ctx.fillRect(0, 0, BASE_CW, BASE_CH);
84
+ ctx.fillStyle = '#fff';
85
+ ctx.font = `${BASE_CH}px monospace`;
86
+ ctx.textBaseline = 'top';
87
+ ctx.textAlign = 'center';
88
+ ctx.fillText(ch, BASE_CW / 2, 0);
89
+ const d = ctx.getImageData(0, 0, BASE_CW, BASE_CH);
90
+ const v = INT_POS.map(p => sampleCircle(d, p.x * BASE_CW, p.y * BASE_CH, CIRCLE_R * BASE_CH));
91
+ shapeVecs.push({ ch, v });
92
+ }
93
+ const max = [0, 0, 0, 0, 0, 0];
94
+ shapeVecs.forEach(({ v }) => v.forEach((x, i) => max[i] = Math.max(max[i], x)));
95
+ shapeVecs = shapeVecs.map(({ ch, v }) => ({ ch, v: v.map((x, i) => max[i] ? x / max[i] : 0) }));
96
+ kdTree = buildKd(shapeVecs);
97
+ initialized = true;
98
+ }
99
+ function sampleCircle(d, cx, cy, r) {
100
+ let t = 0, c = 0;
101
+ for (let dy = -r; dy <= r; dy++)
102
+ for (let dx = -r; dx <= r; dx++)
103
+ if (dx * dx + dy * dy <= r * r) {
104
+ const x = ~~(cx + dx), y = ~~(cy + dy);
105
+ if (x >= 0 && x < d.width && y >= 0 && y < d.height) {
106
+ t += d.data[(y * d.width + x) * 4] / 255;
107
+ c++;
108
+ }
109
+ }
110
+ return c ? t / c : 0;
111
+ }
112
+ function buildKd(items, depth = 0) {
113
+ if (!items.length)
114
+ return null;
115
+ const a = depth % 6;
116
+ items.sort((x, y) => x.v[a] - y.v[a]);
117
+ const m = items.length >> 1;
118
+ return { item: items[m], left: buildKd(items.slice(0, m), depth + 1), right: buildKd(items.slice(m + 1), depth + 1), axis: a };
119
+ }
120
+ function findNear(n, t, best = { dist: Infinity, ch: ' ' }) {
121
+ if (!n)
122
+ return best;
123
+ const d = n.item.v.reduce((s, x, i) => s + (x - t[i]) ** 2, 0);
124
+ if (d < best.dist)
125
+ best = { dist: d, ch: n.item.ch };
126
+ const diff = t[n.axis] - n.item.v[n.axis];
127
+ best = findNear(diff < 0 ? n.left : n.right, t, best);
128
+ if (diff * diff < best.dist)
129
+ best = findNear(diff < 0 ? n.right : n.left, t, best);
130
+ return best;
131
+ }
132
+ function contrast(int, ext, gE, dE) {
133
+ let v = int.map((val, i) => {
134
+ if (dE <= 1)
135
+ return val;
136
+ let m = val;
137
+ for (const e of AFFECT[i])
138
+ m = Math.max(m, ext[e]);
139
+ return m ? Math.pow(val / m, dE) * m : val;
140
+ });
141
+ if (gE > 1) {
142
+ const m = Math.max(...v);
143
+ if (m)
144
+ v = v.map(x => Math.pow(x / m, gE) * m);
145
+ }
146
+ return v;
147
+ }
148
+ function sampleImg(d, cx, cy, r) {
149
+ let t = 0, c = 0;
150
+ for (let dy = -r; dy <= r; dy += 2)
151
+ for (let dx = -r; dx <= r; dx += 2)
152
+ if (dx * dx + dy * dy <= r * r) {
153
+ const x = ~~(cx + dx), y = ~~(cy + dy);
154
+ if (x >= 0 && x < d.width && y >= 0 && y < d.height) {
155
+ const i = (y * d.width + x) * 4;
156
+ t += .2126 * d.data[i] / 255 + .7152 * d.data[i + 1] / 255 + .0722 * d.data[i + 2] / 255;
157
+ c++;
158
+ }
159
+ }
160
+ return c ? t / c : 0;
161
+ }
162
+ function sampleClr(d, cx, cy, cw, ch) {
163
+ let r = 0, g = 0, b = 0, c = 0;
164
+ for (let dy = ch * .25; dy < ch * .75; dy += 2)
165
+ for (let dx = cw * .25; dx < cw * .75; dx += 2) {
166
+ const x = ~~(cx + dx), y = ~~(cy + dy);
167
+ if (x >= 0 && x < d.width && y >= 0 && y < d.height) {
168
+ const i = (y * d.width + x) * 4;
169
+ r += d.data[i];
170
+ g += d.data[i + 1];
171
+ b += d.data[i + 2];
172
+ c++;
173
+ }
174
+ }
175
+ return c ? '#' + [r, g, b].map(v => (~~(v / c)).toString(16).padStart(2, '0')).join('') : '#000';
176
+ }
177
+ function darken(h, a) {
178
+ return '#' + [1, 3, 5].map(i => (~~(parseInt(h.slice(i, i + 2), 16) * (1 - a))).toString(16).padStart(2, '0')).join('');
179
+ }
180
+ const cache = new Map();
181
+ function renderFrame(imgData, opts) {
182
+ const gE = opts.globalContrast ?? 2;
183
+ const dE = opts.directionalContrast ?? 3;
184
+ const colorMode = opts.colorMode ?? 'none';
185
+ const cols = Math.round(imgData.width / BASE_CW);
186
+ const rows = Math.round(imgData.height / BASE_CH);
187
+ const cW = imgData.width / cols, cH = imgData.height / rows;
188
+ const r = CIRCLE_R * cH;
189
+ cache.clear();
190
+ let html = '';
191
+ for (let row = 0; row < rows; row++) {
192
+ for (let col = 0; col < cols; col++) {
193
+ const cx = col * cW, cy = row * cH;
194
+ const int = INT_POS.map(p => sampleImg(imgData, cx + p.x * cW, cy + p.y * cH, r));
195
+ const ext = EXT_POS.map(p => sampleImg(imgData, cx + p.x * cW, cy + p.y * cH, r));
196
+ const enh = contrast(int, ext, gE, dE);
197
+ const key = enh.map(v => ~~(v * 31)).join(',');
198
+ if (!cache.has(key))
199
+ cache.set(key, findNear(kdTree, enh).ch);
200
+ let ch = cache.get(key);
201
+ if (ch === '<')
202
+ ch = '&lt;';
203
+ else if (ch === '>')
204
+ ch = '&gt;';
205
+ else if (ch === '&')
206
+ ch = '&amp;';
207
+ if (colorMode === 'none') {
208
+ html += ch;
209
+ }
210
+ else {
211
+ const clr = sampleClr(imgData, cx, cy, cW, cH);
212
+ const st = [];
213
+ if (colorMode === 'fg' || colorMode === 'both')
214
+ st.push(`color:${clr}`);
215
+ if (colorMode === 'bg' || colorMode === 'both')
216
+ st.push(`background:${darken(clr, .5)}`);
217
+ html += `<span style="${st.join(';')}">${ch}</span>`;
218
+ }
219
+ }
220
+ html += '\n';
221
+ }
222
+ return html;
223
+ }
224
+ // Get element from selector or element
225
+ function getEl(el) {
226
+ return typeof el === 'string' ? document.querySelector(el) : el;
227
+ }
228
+ // Create output pre element
229
+ function createOutput(container, colorMode) {
230
+ const pre = document.createElement('pre');
231
+ pre.style.cssText = 'margin:0;line-height:1;font-family:monospace;white-space:pre;';
232
+ if (colorMode === 'none')
233
+ pre.style.color = '#0f0';
234
+ pre.style.background = '#000';
235
+ container.innerHTML = '';
236
+ container.appendChild(pre);
237
+ return pre;
238
+ }
239
+ /**
240
+ * Render an image as ASCII art
241
+ */
242
+ function image(source, container, options = {}) {
243
+ init();
244
+ const containerEl = getEl(container);
245
+ const output = createOutput(containerEl, options.colorMode ?? 'none');
246
+ const canvas = document.createElement('canvas');
247
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
248
+ let img;
249
+ let currentOpts = { ...options };
250
+ function render() {
251
+ const maxW = currentOpts.maxWidth ?? 800;
252
+ const scale = Math.min(1, maxW / img.width);
253
+ canvas.width = ~~(img.width * scale);
254
+ canvas.height = ~~(img.height * scale);
255
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
256
+ const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
257
+ const html = renderFrame(imgData, currentOpts);
258
+ // Calculate cols for font sizing
259
+ const cols = Math.round(canvas.width / BASE_CW);
260
+ const containerWidth = containerEl.clientWidth - 16; // padding
261
+ const fontSize = containerWidth / (cols * 0.6);
262
+ output.style.fontSize = fontSize + 'px';
263
+ output.innerHTML = html;
264
+ if (currentOpts.colorMode === 'none')
265
+ output.style.color = '#0f0';
266
+ else
267
+ output.style.color = '';
268
+ }
269
+ function load(src) {
270
+ if (typeof src === 'string') {
271
+ img = new Image();
272
+ img.crossOrigin = 'anonymous';
273
+ img.onload = render;
274
+ img.src = src;
275
+ }
276
+ else {
277
+ img = src;
278
+ render();
279
+ }
280
+ }
281
+ load(source);
282
+ return {
283
+ update(opts) {
284
+ if (opts)
285
+ currentOpts = { ...currentOpts, ...opts };
286
+ if (img)
287
+ render();
288
+ },
289
+ destroy() {
290
+ containerEl.innerHTML = '';
291
+ }
292
+ };
293
+ }
294
+ /**
295
+ * Render a video as ASCII art
296
+ */
297
+ function video(source, container, options = {}) {
298
+ init();
299
+ const containerEl = getEl(container);
300
+ const output = createOutput(containerEl, options.colorMode ?? 'none');
301
+ const canvas = document.createElement('canvas');
302
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
303
+ const vid = document.createElement('video');
304
+ vid.playsInline = true;
305
+ vid.muted = true; // Must be muted for autoplay to work
306
+ vid.loop = true;
307
+ let currentOpts = { ...options };
308
+ let playing = false;
309
+ let animId = null;
310
+ let lastRender = 0;
311
+ const minFrameTime = 1000 / (options.fps ?? 30); // Throttle to fps
312
+ // Pre-calculate canvas size once
313
+ let canvasReady = false;
314
+ function setupCanvas() {
315
+ if (canvasReady || !vid.videoWidth)
316
+ return;
317
+ const maxW = currentOpts.maxWidth ?? 640;
318
+ const scale = Math.min(1, maxW / vid.videoWidth);
319
+ canvas.width = ~~(vid.videoWidth * scale);
320
+ canvas.height = ~~(vid.videoHeight * scale);
321
+ canvasReady = true;
322
+ }
323
+ let rendering = false;
324
+ let cachedFontSize = 0;
325
+ async function render() {
326
+ if (!vid.videoWidth || rendering)
327
+ return;
328
+ setupCanvas();
329
+ rendering = true;
330
+ ctx.drawImage(vid, 0, 0, canvas.width, canvas.height);
331
+ const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
332
+ // Use worker for heavy lifting
333
+ const result = await renderInWorker(new Uint8ClampedArray(imgData.data), canvas.width, canvas.height, currentOpts);
334
+ // Calculate font size once
335
+ if (!cachedFontSize) {
336
+ const containerWidth = containerEl.clientWidth - 16;
337
+ cachedFontSize = containerWidth / (result.cols * 0.6);
338
+ output.style.fontSize = cachedFontSize + 'px';
339
+ }
340
+ output.innerHTML = result.html;
341
+ if (currentOpts.colorMode === 'none')
342
+ output.style.color = '#0f0';
343
+ else
344
+ output.style.color = '';
345
+ rendering = false;
346
+ }
347
+ function loop() {
348
+ if (!playing)
349
+ return;
350
+ const now = performance.now();
351
+ if (now - lastRender >= minFrameTime) {
352
+ lastRender = now;
353
+ render();
354
+ }
355
+ animId = requestAnimationFrame(loop);
356
+ }
357
+ // Load video
358
+ if (source instanceof File) {
359
+ vid.src = URL.createObjectURL(source);
360
+ }
361
+ else if (typeof source === 'string') {
362
+ vid.src = source;
363
+ }
364
+ else {
365
+ vid.src = source.src;
366
+ }
367
+ vid.onloadedmetadata = () => {
368
+ render();
369
+ if (options.autoplay) {
370
+ playing = true;
371
+ vid.play();
372
+ loop();
373
+ }
374
+ };
375
+ return {
376
+ play() {
377
+ if (playing || !vid.readyState)
378
+ return;
379
+ playing = true;
380
+ vid.play();
381
+ loop();
382
+ },
383
+ pause() {
384
+ playing = false;
385
+ vid.pause();
386
+ if (animId)
387
+ cancelAnimationFrame(animId);
388
+ },
389
+ seek(time) {
390
+ vid.currentTime = time;
391
+ if (!playing)
392
+ render();
393
+ },
394
+ update(opts) {
395
+ if (opts)
396
+ currentOpts = { ...currentOpts, ...opts };
397
+ if (!playing)
398
+ render();
399
+ },
400
+ destroy() {
401
+ playing = false;
402
+ if (animId)
403
+ cancelAnimationFrame(animId);
404
+ if (vid.src.startsWith('blob:'))
405
+ URL.revokeObjectURL(vid.src);
406
+ containerEl.innerHTML = '';
407
+ },
408
+ get isPlaying() { return playing; },
409
+ get currentTime() { return vid.currentTime; },
410
+ get duration() { return vid.duration; }
411
+ };
412
+ }
413
+ // Default export for ES modules
414
+ exports.default = { image, video, webcam };
415
+ /**
416
+ * Render webcam as ASCII art
417
+ */
418
+ function webcam(container, options = {}) {
419
+ init();
420
+ const containerEl = getEl(container);
421
+ const output = createOutput(containerEl, options.colorMode ?? 'none');
422
+ const canvas = document.createElement('canvas');
423
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
424
+ let currentOpts = { ...options };
425
+ let animId = null;
426
+ let stream = null;
427
+ let lastRender = 0;
428
+ const minFrameTime = 1000 / (options.fps ?? 30);
429
+ const vid = document.createElement('video');
430
+ vid.playsInline = true;
431
+ vid.muted = true;
432
+ let canvasReady = false;
433
+ function setupCanvas() {
434
+ if (canvasReady || !vid.videoWidth)
435
+ return;
436
+ const maxW = currentOpts.maxWidth ?? 640;
437
+ const scale = Math.min(1, maxW / vid.videoWidth);
438
+ canvas.width = ~~(vid.videoWidth * scale);
439
+ canvas.height = ~~(vid.videoHeight * scale);
440
+ canvasReady = true;
441
+ }
442
+ let rendering = false;
443
+ let cachedFontSize = 0;
444
+ async function render() {
445
+ if (!vid.videoWidth || rendering)
446
+ return;
447
+ setupCanvas();
448
+ rendering = true;
449
+ ctx.drawImage(vid, 0, 0, canvas.width, canvas.height);
450
+ const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
451
+ const result = await renderInWorker(new Uint8ClampedArray(imgData.data), canvas.width, canvas.height, currentOpts);
452
+ if (!cachedFontSize) {
453
+ const containerWidth = containerEl.clientWidth - 16;
454
+ cachedFontSize = containerWidth / (result.cols * 0.6);
455
+ output.style.fontSize = cachedFontSize + 'px';
456
+ }
457
+ output.innerHTML = result.html;
458
+ if (currentOpts.colorMode === 'none')
459
+ output.style.color = '#0f0';
460
+ else
461
+ output.style.color = '';
462
+ rendering = false;
463
+ }
464
+ function loop() {
465
+ const now = performance.now();
466
+ if (now - lastRender >= minFrameTime) {
467
+ lastRender = now;
468
+ render();
469
+ }
470
+ animId = requestAnimationFrame(loop);
471
+ }
472
+ const videoConstraints = {
473
+ video: {
474
+ width: { ideal: 1280 },
475
+ height: { ideal: 720 }
476
+ }
477
+ };
478
+ return navigator.mediaDevices.getUserMedia(videoConstraints).then(s => {
479
+ stream = s;
480
+ vid.srcObject = stream;
481
+ vid.play();
482
+ vid.onplaying = () => {
483
+ setupCanvas();
484
+ loop();
485
+ };
486
+ return {
487
+ stop() {
488
+ if (animId)
489
+ cancelAnimationFrame(animId);
490
+ stream?.getTracks().forEach(t => t.stop());
491
+ containerEl.innerHTML = '';
492
+ },
493
+ update(opts) {
494
+ if (opts)
495
+ currentOpts = { ...currentOpts, ...opts };
496
+ }
497
+ };
498
+ });
499
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Apply global contrast enhancement to a sampling vector.
3
+ * Normalizes to [0,1], applies exponent, denormalizes.
4
+ */
5
+ export declare function applyGlobalContrast(vector: number[], exponent: number): number[];
6
+ /**
7
+ * Apply directional contrast enhancement using external sampling vector.
8
+ * Each internal component is enhanced using the max of its affecting external values.
9
+ */
10
+ export declare function applyDirectionalContrast(internal: number[], external: number[], exponent: number): number[];
11
+ /**
12
+ * Apply both contrast enhancements in sequence
13
+ */
14
+ export declare function applyContrastEnhancement(internal: number[], external: number[], globalExponent: number, directionalExponent: number): number[];
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyGlobalContrast = applyGlobalContrast;
4
+ exports.applyDirectionalContrast = applyDirectionalContrast;
5
+ exports.applyContrastEnhancement = applyContrastEnhancement;
6
+ const shape_vectors_1 = require("./shape-vectors");
7
+ /**
8
+ * Apply global contrast enhancement to a sampling vector.
9
+ * Normalizes to [0,1], applies exponent, denormalizes.
10
+ */
11
+ function applyGlobalContrast(vector, exponent) {
12
+ if (exponent <= 1)
13
+ return vector;
14
+ const maxValue = Math.max(...vector);
15
+ if (maxValue === 0)
16
+ return vector;
17
+ return vector.map(v => {
18
+ const normalized = v / maxValue;
19
+ const enhanced = Math.pow(normalized, exponent);
20
+ return enhanced * maxValue;
21
+ });
22
+ }
23
+ /**
24
+ * Apply directional contrast enhancement using external sampling vector.
25
+ * Each internal component is enhanced using the max of its affecting external values.
26
+ */
27
+ function applyDirectionalContrast(internal, external, exponent) {
28
+ if (exponent <= 1)
29
+ return internal;
30
+ return internal.map((value, i) => {
31
+ // Find max value among affecting external circles
32
+ let maxValue = value;
33
+ for (const extIdx of shape_vectors_1.AFFECTING_EXTERNAL_INDICES[i]) {
34
+ maxValue = Math.max(maxValue, external[extIdx]);
35
+ }
36
+ if (maxValue === 0)
37
+ return value;
38
+ const normalized = value / maxValue;
39
+ const enhanced = Math.pow(normalized, exponent);
40
+ return enhanced * maxValue;
41
+ });
42
+ }
43
+ /**
44
+ * Apply both contrast enhancements in sequence
45
+ */
46
+ function applyContrastEnhancement(internal, external, globalExponent, directionalExponent) {
47
+ let result = applyDirectionalContrast(internal, external, directionalExponent);
48
+ result = applyGlobalContrast(result, globalExponent);
49
+ return result;
50
+ }
@@ -0,0 +1,7 @@
1
+ export { AsciiRenderer, ColoredChar } from './renderer';
2
+ export { VideoAsciiRenderer } from './video-renderer';
3
+ export { AsciiRendererOptions, ShapeVector } from './types';
4
+ export { VideoAsciiRendererOptions } from './video-types';
5
+ export { generateShapeVectors } from './shape-vectors';
6
+ export { KdTree, LookupCache } from './lookup';
7
+ export { applyGlobalContrast, applyDirectionalContrast, applyContrastEnhancement } from './contrast';
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyContrastEnhancement = exports.applyDirectionalContrast = exports.applyGlobalContrast = exports.LookupCache = exports.KdTree = exports.generateShapeVectors = exports.VideoAsciiRenderer = exports.AsciiRenderer = void 0;
4
+ var renderer_1 = require("./renderer");
5
+ Object.defineProperty(exports, "AsciiRenderer", { enumerable: true, get: function () { return renderer_1.AsciiRenderer; } });
6
+ var video_renderer_1 = require("./video-renderer");
7
+ Object.defineProperty(exports, "VideoAsciiRenderer", { enumerable: true, get: function () { return video_renderer_1.VideoAsciiRenderer; } });
8
+ var shape_vectors_1 = require("./shape-vectors");
9
+ Object.defineProperty(exports, "generateShapeVectors", { enumerable: true, get: function () { return shape_vectors_1.generateShapeVectors; } });
10
+ var lookup_1 = require("./lookup");
11
+ Object.defineProperty(exports, "KdTree", { enumerable: true, get: function () { return lookup_1.KdTree; } });
12
+ Object.defineProperty(exports, "LookupCache", { enumerable: true, get: function () { return lookup_1.LookupCache; } });
13
+ var contrast_1 = require("./contrast");
14
+ Object.defineProperty(exports, "applyGlobalContrast", { enumerable: true, get: function () { return contrast_1.applyGlobalContrast; } });
15
+ Object.defineProperty(exports, "applyDirectionalContrast", { enumerable: true, get: function () { return contrast_1.applyDirectionalContrast; } });
16
+ Object.defineProperty(exports, "applyContrastEnhancement", { enumerable: true, get: function () { return contrast_1.applyContrastEnhancement; } });
@@ -0,0 +1,24 @@
1
+ import { ShapeVector } from './types';
2
+ /**
3
+ * k-d tree for fast nearest-neighbor lookups in 6D space
4
+ */
5
+ export declare class KdTree {
6
+ private root;
7
+ private dimensions;
8
+ constructor(vectors: ShapeVector[]);
9
+ private buildTree;
10
+ findNearest(target: number[]): ShapeVector;
11
+ private squaredDistance;
12
+ }
13
+ /**
14
+ * Cache for character lookups using quantized 30-bit keys
15
+ */
16
+ export declare class LookupCache {
17
+ private cache;
18
+ private static BITS;
19
+ private static RANGE;
20
+ generateKey(vector: number[]): number;
21
+ get(key: number): string | undefined;
22
+ set(key: number, char: string): void;
23
+ has(key: number): boolean;
24
+ }