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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/ascii-renderer.esm.js +447 -0
- package/dist/ascii-renderer.min.js +4 -0
- package/dist/browser.d.ts +48 -0
- package/dist/browser.js +499 -0
- package/dist/contrast.d.ts +14 -0
- package/dist/contrast.js +50 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +16 -0
- package/dist/lookup.d.ts +24 -0
- package/dist/lookup.js +86 -0
- package/dist/renderer.d.ts +40 -0
- package/dist/renderer.js +170 -0
- package/dist/shape-vectors.d.ts +15 -0
- package/dist/shape-vectors.js +125 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.js +2 -0
- package/dist/video-renderer.d.ts +30 -0
- package/dist/video-renderer.js +198 -0
- package/dist/video-types.d.ts +10 -0
- package/dist/video-types.js +2 -0
- package/dist/worker.d.ts +48 -0
- package/dist/worker.js +173 -0
- package/package.json +43 -0
package/dist/lookup.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LookupCache = exports.KdTree = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* k-d tree for fast nearest-neighbor lookups in 6D space
|
|
6
|
+
*/
|
|
7
|
+
class KdTree {
|
|
8
|
+
constructor(vectors) {
|
|
9
|
+
this.root = null;
|
|
10
|
+
this.dimensions = vectors[0]?.vector.length ?? 6;
|
|
11
|
+
this.root = this.buildTree(vectors, 0);
|
|
12
|
+
}
|
|
13
|
+
buildTree(vectors, depth) {
|
|
14
|
+
if (vectors.length === 0)
|
|
15
|
+
return null;
|
|
16
|
+
const axis = depth % this.dimensions;
|
|
17
|
+
vectors.sort((a, b) => a.vector[axis] - b.vector[axis]);
|
|
18
|
+
const mid = Math.floor(vectors.length / 2);
|
|
19
|
+
return {
|
|
20
|
+
point: vectors[mid],
|
|
21
|
+
left: this.buildTree(vectors.slice(0, mid), depth + 1),
|
|
22
|
+
right: this.buildTree(vectors.slice(mid + 1), depth + 1),
|
|
23
|
+
axis,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
findNearest(target) {
|
|
27
|
+
let best = this.root.point;
|
|
28
|
+
let bestDist = this.squaredDistance(target, best.vector);
|
|
29
|
+
const search = (node, depth) => {
|
|
30
|
+
if (!node)
|
|
31
|
+
return;
|
|
32
|
+
const dist = this.squaredDistance(target, node.point.vector);
|
|
33
|
+
if (dist < bestDist) {
|
|
34
|
+
bestDist = dist;
|
|
35
|
+
best = node.point;
|
|
36
|
+
}
|
|
37
|
+
const axis = node.axis;
|
|
38
|
+
const diff = target[axis] - node.point.vector[axis];
|
|
39
|
+
const first = diff < 0 ? node.left : node.right;
|
|
40
|
+
const second = diff < 0 ? node.right : node.left;
|
|
41
|
+
search(first, depth + 1);
|
|
42
|
+
if (diff * diff < bestDist) {
|
|
43
|
+
search(second, depth + 1);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
search(this.root, 0);
|
|
47
|
+
return best;
|
|
48
|
+
}
|
|
49
|
+
squaredDistance(a, b) {
|
|
50
|
+
let sum = 0;
|
|
51
|
+
for (let i = 0; i < a.length; i++) {
|
|
52
|
+
const d = a[i] - b[i];
|
|
53
|
+
sum += d * d;
|
|
54
|
+
}
|
|
55
|
+
return sum;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.KdTree = KdTree;
|
|
59
|
+
/**
|
|
60
|
+
* Cache for character lookups using quantized 30-bit keys
|
|
61
|
+
*/
|
|
62
|
+
class LookupCache {
|
|
63
|
+
constructor() {
|
|
64
|
+
this.cache = new Map();
|
|
65
|
+
}
|
|
66
|
+
generateKey(vector) {
|
|
67
|
+
let key = 0;
|
|
68
|
+
for (const v of vector) {
|
|
69
|
+
const quantized = Math.min(LookupCache.RANGE - 1, Math.floor(v * LookupCache.RANGE));
|
|
70
|
+
key = (key << LookupCache.BITS) | quantized;
|
|
71
|
+
}
|
|
72
|
+
return key;
|
|
73
|
+
}
|
|
74
|
+
get(key) {
|
|
75
|
+
return this.cache.get(key);
|
|
76
|
+
}
|
|
77
|
+
set(key, char) {
|
|
78
|
+
this.cache.set(key, char);
|
|
79
|
+
}
|
|
80
|
+
has(key) {
|
|
81
|
+
return this.cache.has(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.LookupCache = LookupCache;
|
|
85
|
+
LookupCache.BITS = 5;
|
|
86
|
+
LookupCache.RANGE = 2 ** LookupCache.BITS;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AsciiRendererOptions } from './types';
|
|
2
|
+
export interface ColoredChar {
|
|
3
|
+
char: string;
|
|
4
|
+
fg?: string;
|
|
5
|
+
bg?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class AsciiRenderer {
|
|
8
|
+
private options;
|
|
9
|
+
private shapeVectors;
|
|
10
|
+
private kdTree;
|
|
11
|
+
private cache;
|
|
12
|
+
constructor(options?: AsciiRendererOptions);
|
|
13
|
+
/**
|
|
14
|
+
* Convert image data to ASCII string (no color)
|
|
15
|
+
*/
|
|
16
|
+
render(imageData: ImageData): string;
|
|
17
|
+
/**
|
|
18
|
+
* Convert image data to colored ASCII grid
|
|
19
|
+
*/
|
|
20
|
+
renderColored(imageData: ImageData): ColoredChar[][];
|
|
21
|
+
/**
|
|
22
|
+
* Render to ANSI escape codes for terminal output
|
|
23
|
+
*/
|
|
24
|
+
renderAnsi(imageData: ImageData): string;
|
|
25
|
+
/**
|
|
26
|
+
* Render to HTML with inline styles
|
|
27
|
+
*/
|
|
28
|
+
renderHtml(imageData: ImageData): string;
|
|
29
|
+
private renderCell;
|
|
30
|
+
private sampleVector;
|
|
31
|
+
private sampleCircleLightness;
|
|
32
|
+
private sampleCellColor;
|
|
33
|
+
private darken;
|
|
34
|
+
private hexToRgb;
|
|
35
|
+
private lookupCharacter;
|
|
36
|
+
getGridSize(width: number, height: number): {
|
|
37
|
+
cols: number;
|
|
38
|
+
rows: number;
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AsciiRenderer = void 0;
|
|
4
|
+
const shape_vectors_1 = require("./shape-vectors");
|
|
5
|
+
const lookup_1 = require("./lookup");
|
|
6
|
+
const contrast_1 = require("./contrast");
|
|
7
|
+
const DEFAULT_OPTIONS = {
|
|
8
|
+
cellWidth: 10,
|
|
9
|
+
cellHeight: 16,
|
|
10
|
+
samplingQuality: 3,
|
|
11
|
+
globalContrastExponent: 2,
|
|
12
|
+
directionalContrastExponent: 3,
|
|
13
|
+
characters: ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~',
|
|
14
|
+
colorMode: 'none',
|
|
15
|
+
};
|
|
16
|
+
class AsciiRenderer {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
19
|
+
this.shapeVectors = (0, shape_vectors_1.generateShapeVectors)(this.options.characters, this.options.cellWidth, this.options.cellHeight);
|
|
20
|
+
this.kdTree = new lookup_1.KdTree(this.shapeVectors);
|
|
21
|
+
this.cache = new lookup_1.LookupCache();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Convert image data to ASCII string (no color)
|
|
25
|
+
*/
|
|
26
|
+
render(imageData) {
|
|
27
|
+
return this.renderColored(imageData).map(row => row.map(c => c.char).join('')).join('\n');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Convert image data to colored ASCII grid
|
|
31
|
+
*/
|
|
32
|
+
renderColored(imageData) {
|
|
33
|
+
const { cellWidth, cellHeight, colorMode } = this.options;
|
|
34
|
+
const cols = Math.floor(imageData.width / cellWidth);
|
|
35
|
+
const rows = Math.floor(imageData.height / cellHeight);
|
|
36
|
+
const result = [];
|
|
37
|
+
for (let row = 0; row < rows; row++) {
|
|
38
|
+
const line = [];
|
|
39
|
+
for (let col = 0; col < cols; col++) {
|
|
40
|
+
const cellX = col * cellWidth;
|
|
41
|
+
const cellY = row * cellHeight;
|
|
42
|
+
const char = this.renderCell(imageData, col, row, cols, rows);
|
|
43
|
+
const colored = { char };
|
|
44
|
+
if (colorMode !== 'none') {
|
|
45
|
+
const color = this.sampleCellColor(imageData, cellX, cellY);
|
|
46
|
+
if (colorMode === 'fg' || colorMode === 'both')
|
|
47
|
+
colored.fg = color;
|
|
48
|
+
if (colorMode === 'bg' || colorMode === 'both')
|
|
49
|
+
colored.bg = this.darken(color, 0.3);
|
|
50
|
+
}
|
|
51
|
+
line.push(colored);
|
|
52
|
+
}
|
|
53
|
+
result.push(line);
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Render to ANSI escape codes for terminal output
|
|
59
|
+
*/
|
|
60
|
+
renderAnsi(imageData) {
|
|
61
|
+
const grid = this.renderColored(imageData);
|
|
62
|
+
return grid.map(row => row.map(c => {
|
|
63
|
+
let seq = '';
|
|
64
|
+
if (c.fg)
|
|
65
|
+
seq += `\x1b[38;2;${this.hexToRgb(c.fg)}m`;
|
|
66
|
+
if (c.bg)
|
|
67
|
+
seq += `\x1b[48;2;${this.hexToRgb(c.bg)}m`;
|
|
68
|
+
return seq + c.char + (c.fg || c.bg ? '\x1b[0m' : '');
|
|
69
|
+
}).join('')).join('\n');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Render to HTML with inline styles
|
|
73
|
+
*/
|
|
74
|
+
renderHtml(imageData) {
|
|
75
|
+
const grid = this.renderColored(imageData);
|
|
76
|
+
const lines = grid.map(row => row.map(c => {
|
|
77
|
+
const styles = [];
|
|
78
|
+
if (c.fg)
|
|
79
|
+
styles.push(`color:${c.fg}`);
|
|
80
|
+
if (c.bg)
|
|
81
|
+
styles.push(`background:${c.bg}`);
|
|
82
|
+
const escaped = c.char.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
83
|
+
return styles.length ? `<span style="${styles.join(';')}">${escaped}</span>` : escaped;
|
|
84
|
+
}).join(''));
|
|
85
|
+
return `<pre style="font-family:monospace;line-height:1">${lines.join('\n')}</pre>`;
|
|
86
|
+
}
|
|
87
|
+
renderCell(imageData, col, row, totalCols, totalRows) {
|
|
88
|
+
const { cellWidth, cellHeight, globalContrastExponent, directionalContrastExponent } = this.options;
|
|
89
|
+
const cellX = col * cellWidth;
|
|
90
|
+
const cellY = row * cellHeight;
|
|
91
|
+
const internal = this.sampleVector(imageData, cellX, cellY, shape_vectors_1.INTERNAL_CIRCLE_POSITIONS);
|
|
92
|
+
const external = this.sampleVector(imageData, cellX, cellY, shape_vectors_1.EXTERNAL_CIRCLE_POSITIONS);
|
|
93
|
+
const enhanced = (0, contrast_1.applyContrastEnhancement)(internal, external, globalContrastExponent, directionalContrastExponent);
|
|
94
|
+
return this.lookupCharacter(enhanced);
|
|
95
|
+
}
|
|
96
|
+
sampleVector(imageData, cellX, cellY, positions) {
|
|
97
|
+
const { cellWidth, cellHeight, samplingQuality } = this.options;
|
|
98
|
+
const radius = shape_vectors_1.CIRCLE_RADIUS * Math.min(cellWidth, cellHeight);
|
|
99
|
+
return positions.map(pos => {
|
|
100
|
+
const cx = cellX + pos.x * cellWidth;
|
|
101
|
+
const cy = cellY + pos.y * cellHeight;
|
|
102
|
+
return this.sampleCircleLightness(imageData, cx, cy, radius, samplingQuality);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
sampleCircleLightness(imageData, cx, cy, radius, quality) {
|
|
106
|
+
let total = 0, count = 0;
|
|
107
|
+
for (let i = 0; i < quality; i++) {
|
|
108
|
+
for (let j = 0; j < quality; j++) {
|
|
109
|
+
const dx = (i / (quality - 1 || 1) - 0.5) * 2 * radius;
|
|
110
|
+
const dy = (j / (quality - 1 || 1) - 0.5) * 2 * radius;
|
|
111
|
+
if (dx * dx + dy * dy <= radius * radius) {
|
|
112
|
+
const x = Math.floor(cx + dx), y = Math.floor(cy + dy);
|
|
113
|
+
if (x >= 0 && x < imageData.width && y >= 0 && y < imageData.height) {
|
|
114
|
+
const idx = (y * imageData.width + x) * 4;
|
|
115
|
+
const r = imageData.data[idx] / 255;
|
|
116
|
+
const g = imageData.data[idx + 1] / 255;
|
|
117
|
+
const b = imageData.data[idx + 2] / 255;
|
|
118
|
+
total += 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
119
|
+
count++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return count > 0 ? total / count : 0;
|
|
125
|
+
}
|
|
126
|
+
sampleCellColor(imageData, cellX, cellY) {
|
|
127
|
+
const { cellWidth, cellHeight } = this.options;
|
|
128
|
+
let r = 0, g = 0, b = 0, count = 0;
|
|
129
|
+
// Sample center region of cell
|
|
130
|
+
for (let dy = cellHeight * 0.25; dy < cellHeight * 0.75; dy += 2) {
|
|
131
|
+
for (let dx = cellWidth * 0.25; dx < cellWidth * 0.75; dx += 2) {
|
|
132
|
+
const x = Math.floor(cellX + dx), y = Math.floor(cellY + dy);
|
|
133
|
+
if (x >= 0 && x < imageData.width && y >= 0 && y < imageData.height) {
|
|
134
|
+
const idx = (y * imageData.width + x) * 4;
|
|
135
|
+
r += imageData.data[idx];
|
|
136
|
+
g += imageData.data[idx + 1];
|
|
137
|
+
b += imageData.data[idx + 2];
|
|
138
|
+
count++;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (count === 0)
|
|
143
|
+
return '#000000';
|
|
144
|
+
return `#${[r, g, b].map(c => Math.round(c / count).toString(16).padStart(2, '0')).join('')}`;
|
|
145
|
+
}
|
|
146
|
+
darken(hex, amount) {
|
|
147
|
+
const r = parseInt(hex.slice(1, 3), 16) * (1 - amount);
|
|
148
|
+
const g = parseInt(hex.slice(3, 5), 16) * (1 - amount);
|
|
149
|
+
const b = parseInt(hex.slice(5, 7), 16) * (1 - amount);
|
|
150
|
+
return `#${[r, g, b].map(c => Math.round(c).toString(16).padStart(2, '0')).join('')}`;
|
|
151
|
+
}
|
|
152
|
+
hexToRgb(hex) {
|
|
153
|
+
return `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`;
|
|
154
|
+
}
|
|
155
|
+
lookupCharacter(vector) {
|
|
156
|
+
const key = this.cache.generateKey(vector);
|
|
157
|
+
if (this.cache.has(key))
|
|
158
|
+
return this.cache.get(key);
|
|
159
|
+
const result = this.kdTree.findNearest(vector);
|
|
160
|
+
this.cache.set(key, result.character);
|
|
161
|
+
return result.character;
|
|
162
|
+
}
|
|
163
|
+
getGridSize(width, height) {
|
|
164
|
+
return {
|
|
165
|
+
cols: Math.floor(width / this.options.cellWidth),
|
|
166
|
+
rows: Math.floor(height / this.options.cellHeight),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
exports.AsciiRenderer = AsciiRenderer;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ShapeVector } from './types';
|
|
2
|
+
export declare const INTERNAL_CIRCLE_POSITIONS: {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
}[];
|
|
6
|
+
export declare const EXTERNAL_CIRCLE_POSITIONS: {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
}[];
|
|
10
|
+
export declare const AFFECTING_EXTERNAL_INDICES: number[][];
|
|
11
|
+
export declare const CIRCLE_RADIUS = 0.18;
|
|
12
|
+
/**
|
|
13
|
+
* Generate shape vectors for all ASCII characters by rendering them to canvas
|
|
14
|
+
*/
|
|
15
|
+
export declare function generateShapeVectors(characters?: string, cellWidth?: number, cellHeight?: number, font?: string): ShapeVector[];
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CIRCLE_RADIUS = exports.AFFECTING_EXTERNAL_INDICES = exports.EXTERNAL_CIRCLE_POSITIONS = exports.INTERNAL_CIRCLE_POSITIONS = void 0;
|
|
4
|
+
exports.generateShapeVectors = generateShapeVectors;
|
|
5
|
+
// Default printable ASCII characters
|
|
6
|
+
const DEFAULT_CHARS = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
|
|
7
|
+
// 6 sampling circle positions (staggered 2x3 grid, normalized 0-1)
|
|
8
|
+
// Left column slightly lower, right column slightly higher for better coverage
|
|
9
|
+
exports.INTERNAL_CIRCLE_POSITIONS = [
|
|
10
|
+
{ x: 0.25, y: 0.20 }, // top-left
|
|
11
|
+
{ x: 0.75, y: 0.13 }, // top-right
|
|
12
|
+
{ x: 0.25, y: 0.50 }, // mid-left
|
|
13
|
+
{ x: 0.75, y: 0.50 }, // mid-right
|
|
14
|
+
{ x: 0.25, y: 0.80 }, // bot-left
|
|
15
|
+
{ x: 0.75, y: 0.87 }, // bot-right
|
|
16
|
+
];
|
|
17
|
+
// 10 external sampling circles for directional contrast
|
|
18
|
+
exports.EXTERNAL_CIRCLE_POSITIONS = [
|
|
19
|
+
{ x: 0.25, y: -0.15 }, // 0: above top-left
|
|
20
|
+
{ x: 0.75, y: -0.15 }, // 1: above top-right
|
|
21
|
+
{ x: -0.15, y: 0.20 }, // 2: left of top-left
|
|
22
|
+
{ x: 1.15, y: 0.13 }, // 3: right of top-right
|
|
23
|
+
{ x: -0.15, y: 0.50 }, // 4: left of mid-left
|
|
24
|
+
{ x: 1.15, y: 0.50 }, // 5: right of mid-right
|
|
25
|
+
{ x: -0.15, y: 0.80 }, // 6: left of bot-left
|
|
26
|
+
{ x: 1.15, y: 0.87 }, // 7: right of bot-right
|
|
27
|
+
{ x: 0.25, y: 1.15 }, // 8: below bot-left
|
|
28
|
+
{ x: 0.75, y: 1.15 }, // 9: below bot-right
|
|
29
|
+
];
|
|
30
|
+
// Which external circles affect each internal circle (for directional contrast)
|
|
31
|
+
exports.AFFECTING_EXTERNAL_INDICES = [
|
|
32
|
+
[0, 1, 2, 4], // top-left affected by: above-left, above-right, left-top, left-mid
|
|
33
|
+
[0, 1, 3, 5], // top-right affected by: above-left, above-right, right-top, right-mid
|
|
34
|
+
[2, 4, 6], // mid-left affected by: left-top, left-mid, left-bot
|
|
35
|
+
[3, 5, 7], // mid-right affected by: right-top, right-mid, right-bot
|
|
36
|
+
[4, 6, 8, 9], // bot-left affected by: left-mid, left-bot, below-left, below-right
|
|
37
|
+
[5, 7, 8, 9], // bot-right affected by: right-mid, right-bot, below-left, below-right
|
|
38
|
+
];
|
|
39
|
+
exports.CIRCLE_RADIUS = 0.18; // relative to cell dimensions
|
|
40
|
+
/**
|
|
41
|
+
* Generate shape vectors for all ASCII characters by rendering them to canvas
|
|
42
|
+
*/
|
|
43
|
+
function generateShapeVectors(characters = DEFAULT_CHARS, cellWidth = 10, cellHeight = 16, font = 'monospace') {
|
|
44
|
+
const canvas = createCanvas(cellWidth, cellHeight);
|
|
45
|
+
const ctx = canvas.getContext('2d');
|
|
46
|
+
const vectors = [];
|
|
47
|
+
for (const char of characters) {
|
|
48
|
+
// Clear and render character
|
|
49
|
+
ctx.fillStyle = 'black';
|
|
50
|
+
ctx.fillRect(0, 0, cellWidth, cellHeight);
|
|
51
|
+
ctx.fillStyle = 'white';
|
|
52
|
+
ctx.font = `${cellHeight}px ${font}`;
|
|
53
|
+
ctx.textBaseline = 'top';
|
|
54
|
+
ctx.textAlign = 'center';
|
|
55
|
+
ctx.fillText(char, cellWidth / 2, 0);
|
|
56
|
+
const imageData = ctx.getImageData(0, 0, cellWidth, cellHeight);
|
|
57
|
+
const vector = sampleShapeVector(imageData, cellWidth, cellHeight);
|
|
58
|
+
vectors.push({ character: char, vector });
|
|
59
|
+
}
|
|
60
|
+
return normalizeShapeVectors(vectors);
|
|
61
|
+
}
|
|
62
|
+
function sampleShapeVector(imageData, cellWidth, cellHeight) {
|
|
63
|
+
const vector = [];
|
|
64
|
+
for (const pos of exports.INTERNAL_CIRCLE_POSITIONS) {
|
|
65
|
+
const overlap = sampleCircleOverlap(imageData, pos.x * cellWidth, pos.y * cellHeight, exports.CIRCLE_RADIUS * Math.min(cellWidth, cellHeight), cellWidth);
|
|
66
|
+
vector.push(overlap);
|
|
67
|
+
}
|
|
68
|
+
return vector;
|
|
69
|
+
}
|
|
70
|
+
function sampleCircleOverlap(imageData, cx, cy, radius, width) {
|
|
71
|
+
let total = 0;
|
|
72
|
+
let count = 0;
|
|
73
|
+
const r2 = radius * radius;
|
|
74
|
+
// Sample pixels within the circle
|
|
75
|
+
for (let dy = -radius; dy <= radius; dy++) {
|
|
76
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
77
|
+
if (dx * dx + dy * dy <= r2) {
|
|
78
|
+
const x = Math.floor(cx + dx);
|
|
79
|
+
const y = Math.floor(cy + dy);
|
|
80
|
+
if (x >= 0 && x < width && y >= 0 && y < imageData.height) {
|
|
81
|
+
const idx = (y * width + x) * 4;
|
|
82
|
+
total += imageData.data[idx] / 255; // Red channel as lightness
|
|
83
|
+
count++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return count > 0 ? total / count : 0;
|
|
89
|
+
}
|
|
90
|
+
function normalizeShapeVectors(vectors) {
|
|
91
|
+
const dims = vectors[0].vector.length;
|
|
92
|
+
const maxPerDim = new Array(dims).fill(0);
|
|
93
|
+
// Find max value for each dimension
|
|
94
|
+
for (const { vector } of vectors) {
|
|
95
|
+
for (let i = 0; i < dims; i++) {
|
|
96
|
+
maxPerDim[i] = Math.max(maxPerDim[i], vector[i]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Normalize
|
|
100
|
+
return vectors.map(({ character, vector }) => ({
|
|
101
|
+
character,
|
|
102
|
+
vector: vector.map((v, i) => maxPerDim[i] > 0 ? v / maxPerDim[i] : 0),
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
function createCanvas(width, height) {
|
|
106
|
+
// Node.js detection
|
|
107
|
+
const isNode = typeof process !== 'undefined' && process.versions?.node;
|
|
108
|
+
if (isNode) {
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
110
|
+
const canvas = eval('require')('canvas');
|
|
111
|
+
return canvas.createCanvas(width, height);
|
|
112
|
+
}
|
|
113
|
+
// Browser OffscreenCanvas
|
|
114
|
+
if (typeof OffscreenCanvas !== 'undefined') {
|
|
115
|
+
return new OffscreenCanvas(width, height);
|
|
116
|
+
}
|
|
117
|
+
// Browser DOM canvas
|
|
118
|
+
if (typeof document !== 'undefined') {
|
|
119
|
+
const canvas = document.createElement('canvas');
|
|
120
|
+
canvas.width = width;
|
|
121
|
+
canvas.height = height;
|
|
122
|
+
return canvas;
|
|
123
|
+
}
|
|
124
|
+
throw new Error('No canvas implementation available');
|
|
125
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface AsciiRendererOptions {
|
|
2
|
+
cellWidth?: number;
|
|
3
|
+
cellHeight?: number;
|
|
4
|
+
samplingQuality?: number;
|
|
5
|
+
globalContrastExponent?: number;
|
|
6
|
+
directionalContrastExponent?: number;
|
|
7
|
+
characters?: string;
|
|
8
|
+
colorMode?: 'none' | 'fg' | 'bg' | 'both';
|
|
9
|
+
}
|
|
10
|
+
export interface ShapeVector {
|
|
11
|
+
character: string;
|
|
12
|
+
vector: number[];
|
|
13
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { VideoAsciiRendererOptions } from './video-types';
|
|
2
|
+
export declare class VideoAsciiRenderer {
|
|
3
|
+
private options;
|
|
4
|
+
private renderer;
|
|
5
|
+
private video;
|
|
6
|
+
private canvas;
|
|
7
|
+
private ctx;
|
|
8
|
+
private output;
|
|
9
|
+
private container;
|
|
10
|
+
private animationId;
|
|
11
|
+
private lastFrameTime;
|
|
12
|
+
private frameInterval;
|
|
13
|
+
private _isPlaying;
|
|
14
|
+
constructor(options?: VideoAsciiRendererOptions);
|
|
15
|
+
load(source: File | string): Promise<void>;
|
|
16
|
+
mount(container: HTMLElement): void;
|
|
17
|
+
play(): void;
|
|
18
|
+
pause(): void;
|
|
19
|
+
seek(time: number): void;
|
|
20
|
+
setSpeed(rate: number): void;
|
|
21
|
+
get duration(): number;
|
|
22
|
+
get currentTime(): number;
|
|
23
|
+
get isPlaying(): boolean;
|
|
24
|
+
get videoWidth(): number;
|
|
25
|
+
get videoHeight(): number;
|
|
26
|
+
private renderLoop;
|
|
27
|
+
private renderFrame;
|
|
28
|
+
export(format?: 'webm' | 'mp4', onProgress?: (progress: number) => void): Promise<Blob>;
|
|
29
|
+
destroy(): void;
|
|
30
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VideoAsciiRenderer = void 0;
|
|
4
|
+
const renderer_1 = require("./renderer");
|
|
5
|
+
const DEFAULT_OPTIONS = {
|
|
6
|
+
cellWidth: 8,
|
|
7
|
+
cellHeight: 14,
|
|
8
|
+
globalContrastExponent: 2,
|
|
9
|
+
directionalContrastExponent: 3,
|
|
10
|
+
colorMode: 'none',
|
|
11
|
+
targetFps: 24,
|
|
12
|
+
maxWidth: 640,
|
|
13
|
+
characters: ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~',
|
|
14
|
+
};
|
|
15
|
+
class VideoAsciiRenderer {
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.output = null;
|
|
18
|
+
this.container = null;
|
|
19
|
+
this.animationId = null;
|
|
20
|
+
this.lastFrameTime = 0;
|
|
21
|
+
this._isPlaying = false;
|
|
22
|
+
this.renderLoop = () => {
|
|
23
|
+
if (!this._isPlaying)
|
|
24
|
+
return;
|
|
25
|
+
const now = performance.now();
|
|
26
|
+
const elapsed = now - this.lastFrameTime;
|
|
27
|
+
if (elapsed >= this.frameInterval) {
|
|
28
|
+
this.lastFrameTime = now - (elapsed % this.frameInterval);
|
|
29
|
+
this.renderFrame();
|
|
30
|
+
}
|
|
31
|
+
if (!this.video.ended) {
|
|
32
|
+
this.animationId = requestAnimationFrame(this.renderLoop);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
this._isPlaying = false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
39
|
+
this.frameInterval = 1000 / this.options.targetFps;
|
|
40
|
+
this.renderer = new renderer_1.AsciiRenderer({
|
|
41
|
+
cellWidth: this.options.cellWidth,
|
|
42
|
+
cellHeight: this.options.cellHeight,
|
|
43
|
+
globalContrastExponent: this.options.globalContrastExponent,
|
|
44
|
+
directionalContrastExponent: this.options.directionalContrastExponent,
|
|
45
|
+
colorMode: this.options.colorMode,
|
|
46
|
+
characters: this.options.characters,
|
|
47
|
+
});
|
|
48
|
+
this.video = document.createElement('video');
|
|
49
|
+
this.video.playsInline = true;
|
|
50
|
+
this.video.muted = false;
|
|
51
|
+
this.canvas = document.createElement('canvas');
|
|
52
|
+
this.ctx = this.canvas.getContext('2d', { willReadFrequently: true });
|
|
53
|
+
}
|
|
54
|
+
async load(source) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
this.video.onloadedmetadata = () => {
|
|
57
|
+
const scale = Math.min(1, this.options.maxWidth / this.video.videoWidth);
|
|
58
|
+
this.canvas.width = Math.floor(this.video.videoWidth * scale);
|
|
59
|
+
this.canvas.height = Math.floor(this.video.videoHeight * scale);
|
|
60
|
+
resolve();
|
|
61
|
+
};
|
|
62
|
+
this.video.onerror = () => reject(new Error('Failed to load video'));
|
|
63
|
+
if (source instanceof File) {
|
|
64
|
+
this.video.src = URL.createObjectURL(source);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
this.video.src = source;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
mount(container) {
|
|
72
|
+
this.container = container;
|
|
73
|
+
container.innerHTML = '';
|
|
74
|
+
// Create output element
|
|
75
|
+
this.output = document.createElement('pre');
|
|
76
|
+
this.output.style.cssText = 'margin:0;line-height:1;font-family:monospace;background:#000;overflow:hidden;';
|
|
77
|
+
if (this.options.colorMode === 'none') {
|
|
78
|
+
this.output.style.color = '#0f0';
|
|
79
|
+
}
|
|
80
|
+
container.appendChild(this.output);
|
|
81
|
+
}
|
|
82
|
+
play() {
|
|
83
|
+
if (this._isPlaying)
|
|
84
|
+
return;
|
|
85
|
+
this._isPlaying = true;
|
|
86
|
+
this.video.play();
|
|
87
|
+
this.lastFrameTime = performance.now();
|
|
88
|
+
this.renderLoop();
|
|
89
|
+
}
|
|
90
|
+
pause() {
|
|
91
|
+
this._isPlaying = false;
|
|
92
|
+
this.video.pause();
|
|
93
|
+
if (this.animationId) {
|
|
94
|
+
cancelAnimationFrame(this.animationId);
|
|
95
|
+
this.animationId = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
seek(time) {
|
|
99
|
+
this.video.currentTime = time;
|
|
100
|
+
if (!this._isPlaying) {
|
|
101
|
+
this.renderFrame();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
setSpeed(rate) {
|
|
105
|
+
this.video.playbackRate = rate;
|
|
106
|
+
}
|
|
107
|
+
get duration() {
|
|
108
|
+
return this.video.duration;
|
|
109
|
+
}
|
|
110
|
+
get currentTime() {
|
|
111
|
+
return this.video.currentTime;
|
|
112
|
+
}
|
|
113
|
+
get isPlaying() {
|
|
114
|
+
return this._isPlaying;
|
|
115
|
+
}
|
|
116
|
+
get videoWidth() {
|
|
117
|
+
return this.canvas.width;
|
|
118
|
+
}
|
|
119
|
+
get videoHeight() {
|
|
120
|
+
return this.canvas.height;
|
|
121
|
+
}
|
|
122
|
+
renderFrame() {
|
|
123
|
+
if (!this.output)
|
|
124
|
+
return;
|
|
125
|
+
this.ctx.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
|
|
126
|
+
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
|
127
|
+
if (this.options.colorMode === 'none') {
|
|
128
|
+
this.output.textContent = this.renderer.render(imageData);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
this.output.innerHTML = this.renderer.renderHtml(imageData).replace(/<\/?pre[^>]*>/g, '');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async export(format = 'webm', onProgress) {
|
|
135
|
+
const renderCanvas = document.createElement('canvas');
|
|
136
|
+
const renderCtx = renderCanvas.getContext('2d');
|
|
137
|
+
// Calculate output dimensions based on ASCII grid
|
|
138
|
+
const cols = Math.floor(this.canvas.width / this.options.cellWidth);
|
|
139
|
+
const rows = Math.floor(this.canvas.height / this.options.cellHeight);
|
|
140
|
+
const fontSize = 10;
|
|
141
|
+
const charWidth = fontSize * 0.6;
|
|
142
|
+
renderCanvas.width = Math.ceil(cols * charWidth);
|
|
143
|
+
renderCanvas.height = rows * fontSize;
|
|
144
|
+
const stream = renderCanvas.captureStream(this.options.targetFps);
|
|
145
|
+
// Add audio track if available
|
|
146
|
+
const audioCtx = new AudioContext();
|
|
147
|
+
const source = audioCtx.createMediaElementSource(this.video);
|
|
148
|
+
const dest = audioCtx.createMediaStreamDestination();
|
|
149
|
+
source.connect(dest);
|
|
150
|
+
source.connect(audioCtx.destination);
|
|
151
|
+
dest.stream.getAudioTracks().forEach(track => stream.addTrack(track));
|
|
152
|
+
const mimeType = format === 'mp4' ? 'video/mp4' : 'video/webm';
|
|
153
|
+
const recorder = new MediaRecorder(stream, {
|
|
154
|
+
mimeType: MediaRecorder.isTypeSupported(mimeType) ? mimeType : 'video/webm'
|
|
155
|
+
});
|
|
156
|
+
const chunks = [];
|
|
157
|
+
recorder.ondataavailable = (e) => chunks.push(e.data);
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
recorder.onstop = () => {
|
|
160
|
+
audioCtx.close();
|
|
161
|
+
resolve(new Blob(chunks, { type: mimeType }));
|
|
162
|
+
};
|
|
163
|
+
this.video.currentTime = 0;
|
|
164
|
+
recorder.start();
|
|
165
|
+
const exportFrame = () => {
|
|
166
|
+
if (this.video.ended || this.video.currentTime >= this.video.duration) {
|
|
167
|
+
recorder.stop();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Draw ASCII to canvas
|
|
171
|
+
this.ctx.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
|
|
172
|
+
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
|
173
|
+
const ascii = this.renderer.render(imageData);
|
|
174
|
+
renderCtx.fillStyle = '#000';
|
|
175
|
+
renderCtx.fillRect(0, 0, renderCanvas.width, renderCanvas.height);
|
|
176
|
+
renderCtx.fillStyle = this.options.colorMode === 'none' ? '#0f0' : '#fff';
|
|
177
|
+
renderCtx.font = `${fontSize}px monospace`;
|
|
178
|
+
renderCtx.textBaseline = 'top';
|
|
179
|
+
const lines = ascii.split('\n');
|
|
180
|
+
lines.forEach((line, i) => {
|
|
181
|
+
renderCtx.fillText(line, 0, i * fontSize);
|
|
182
|
+
});
|
|
183
|
+
onProgress?.(this.video.currentTime / this.video.duration);
|
|
184
|
+
this.video.requestVideoFrameCallback(exportFrame);
|
|
185
|
+
};
|
|
186
|
+
this.video.play();
|
|
187
|
+
this.video.requestVideoFrameCallback(exportFrame);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
destroy() {
|
|
191
|
+
this.pause();
|
|
192
|
+
if (this.video.src.startsWith('blob:')) {
|
|
193
|
+
URL.revokeObjectURL(this.video.src);
|
|
194
|
+
}
|
|
195
|
+
this.container?.replaceChildren();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
exports.VideoAsciiRenderer = VideoAsciiRenderer;
|