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/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
+ }
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ }
@@ -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,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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;