asciify-engine 1.0.44 → 1.0.46
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/README.md +60 -35
- package/dist/index.cjs +90 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -33
- package/dist/index.d.ts +70 -33
- package/dist/index.js +90 -46
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,58 +77,41 @@ setInterval(() => {
|
|
|
77
77
|
}, 1000 / fps);
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
### Video
|
|
80
|
+
### Video
|
|
81
81
|
|
|
82
|
-
`
|
|
82
|
+
`asciifyVideo` streams video as live ASCII art in real time. Instant start, constant memory, unlimited duration.
|
|
83
83
|
|
|
84
|
-
> ⚠️ Never set the `<video>` element to `display: none
|
|
84
|
+
> ⚠️ Never set the backing `<video>` element to `display: none` — browsers skip GPU frame decoding. When given a URL string, `asciifyVideo` handles this automatically.
|
|
85
85
|
|
|
86
86
|
```ts
|
|
87
|
-
import {
|
|
87
|
+
import { asciifyVideo } from 'asciify-engine';
|
|
88
88
|
|
|
89
89
|
const canvas = document.getElementById('ascii') as HTMLCanvasElement;
|
|
90
90
|
|
|
91
|
-
//
|
|
92
|
-
const stop = await
|
|
91
|
+
// Minimal
|
|
92
|
+
const stop = await asciifyVideo('/clip.mp4', canvas);
|
|
93
93
|
|
|
94
|
-
//
|
|
95
|
-
const stop = await
|
|
96
|
-
|
|
97
|
-
fontSize: 6,
|
|
98
|
-
artStyle: 'matrix',
|
|
94
|
+
// Fit canvas to a container and re-size automatically on resize:
|
|
95
|
+
const stop = await asciifyVideo('/clip.mp4', canvas, {
|
|
96
|
+
fitTo: '#hero', // or an HTMLElement
|
|
99
97
|
});
|
|
100
98
|
|
|
101
|
-
//
|
|
102
|
-
const stop = await
|
|
99
|
+
// Lifecycle hooks — ready state, timers, etc.:
|
|
100
|
+
const stop = await asciifyVideo('/clip.mp4', canvas, {
|
|
101
|
+
fitTo: '#hero',
|
|
103
102
|
fontSize: 6,
|
|
104
|
-
onReady: (
|
|
105
|
-
|
|
106
|
-
// Resize the canvas to match the video here, update your UI, etc.
|
|
107
|
-
canvas.width = video.videoWidth;
|
|
108
|
-
canvas.height = video.videoHeight;
|
|
109
|
-
setReady(true);
|
|
110
|
-
},
|
|
111
|
-
onFrame: () => {
|
|
112
|
-
// Called after every rendered frame.
|
|
113
|
-
setElapsed(Math.floor(performance.now() / 1000));
|
|
114
|
-
},
|
|
103
|
+
onReady: () => setLoading(false),
|
|
104
|
+
onFrame: () => setElapsed(t => t + 1),
|
|
115
105
|
});
|
|
116
106
|
|
|
107
|
+
// Pre-extract all frames before playback (frame-perfect loops, short clips):
|
|
108
|
+
const stop = await asciifyVideo('/clip.mp4', canvas, { preExtract: true });
|
|
109
|
+
|
|
117
110
|
// Clean up:
|
|
118
111
|
stop();
|
|
119
112
|
```
|
|
120
113
|
|
|
121
|
-
### Video — pre-extracted frames
|
|
122
|
-
|
|
123
|
-
`asciifyVideo` seeks through the clip frame by frame and returns a frame sequence. Good for short clips where you want frame-perfect control, but requires up-front processing time.
|
|
124
|
-
|
|
125
|
-
```ts
|
|
126
|
-
import { asciifyVideo } from 'asciify-engine';
|
|
127
114
|
|
|
128
|
-
const canvas = document.getElementById('ascii') as HTMLCanvasElement;
|
|
129
|
-
const stop = await asciifyVideo('/clip.mp4', canvas, { fontSize: 8 });
|
|
130
|
-
stop();
|
|
131
|
-
```
|
|
132
115
|
|
|
133
116
|
---
|
|
134
117
|
|
|
@@ -148,6 +131,47 @@ All conversion and render functions accept an `AsciiOptions` object. Spread `DEF
|
|
|
148
131
|
| `hoverEffect` | `string` | `'none'` | Interactive effect driven by cursor position. See hover effects below. |
|
|
149
132
|
| `hoverStrength` | `number` | `0.8` | Effect intensity (0–1). |
|
|
150
133
|
| `hoverRadius` | `number` | `0.3` | Effect radius relative to canvas size (0–1). |
|
|
134
|
+
| `chromaKey` | `{r,g,b} \| string \| null` | `null` | Remove a background colour (green/blue screen). Keyed pixels become transparent spaces. Accepts `{r,g,b}`, any CSS colour string, or `null` to disable. |
|
|
135
|
+
| `chromaKeyTolerance` | `number` | `60` | Euclidean RGB distance threshold for chroma-key detection. `0` = exact match, higher = more pixels removed (max useful ~100). |
|
|
136
|
+
|
|
137
|
+
### Chroma Key (Green/Blue Screen)
|
|
138
|
+
|
|
139
|
+
Remove a solid background colour from any source — images, GIFs, or video — so the canvas background shows through keyed pixels.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { asciify, DEFAULT_OPTIONS } from 'asciify-engine';
|
|
143
|
+
|
|
144
|
+
// Green screen
|
|
145
|
+
asciify(img, canvas, {
|
|
146
|
+
options: {
|
|
147
|
+
...DEFAULT_OPTIONS,
|
|
148
|
+
chromaKey: '#00ff00', // CSS colour string — hex, rgb(), named all work
|
|
149
|
+
chromaKeyTolerance: 60, // tune to your footage (0 = exact, ~80 = loose)
|
|
150
|
+
colorMode: 'fullcolor',
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Blue screen
|
|
155
|
+
asciify(img, canvas, {
|
|
156
|
+
options: { ...DEFAULT_OPTIONS, chromaKey: 'blue', chromaKeyTolerance: 70 },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Custom RGB key
|
|
160
|
+
asciify(img, canvas, {
|
|
161
|
+
options: { ...DEFAULT_OPTIONS, chromaKey: { r: 0, g: 180, b: 90 }, chromaKeyTolerance: 50 },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Live video with green screen
|
|
165
|
+
asciifyVideo('/footage.mp4', canvas, {
|
|
166
|
+
fitTo: '#container',
|
|
167
|
+
options: { ...DEFAULT_OPTIONS, chromaKey: '#00b140', chromaKeyTolerance: 65, colorMode: 'fullcolor' },
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Tolerance guide:**
|
|
172
|
+
- `40–60` — tight key, natural green screen under good lighting
|
|
173
|
+
- `60–80` — broader key, wrinkled fabric or uneven lighting
|
|
174
|
+
- `80–120` — aggressive; expect some spill into the subject
|
|
151
175
|
|
|
152
176
|
### Color Modes
|
|
153
177
|
|
|
@@ -274,7 +298,6 @@ const animatedHtml = generateAnimatedEmbedCode(frames, options, fps);
|
|
|
274
298
|
| Function | Signature | Returns |
|
|
275
299
|
|---|---|---|
|
|
276
300
|
| `asciify` | `(source, canvas, options?)` | `Promise<void>` |
|
|
277
|
-
| `asciifyLiveVideo` | `(source, canvas, options?)` | `Promise<() => void>` |
|
|
278
301
|
| `asciifyVideo` | `(source, canvas, options?)` | `Promise<() => void>` |
|
|
279
302
|
| `asciifyGif` | `(source, canvas, options?)` | `Promise<() => void>` |
|
|
280
303
|
| `asciifyWebcam` | `(canvas, options?)` | `Promise<() => void>` |
|
|
@@ -286,6 +309,8 @@ const animatedHtml = generateAnimatedEmbedCode(frames, options, fps);
|
|
|
286
309
|
| `generateEmbedCode` | `(frame, options)` | `string` |
|
|
287
310
|
| `generateAnimatedEmbedCode` | `(frames, options, fps)` | `string` |
|
|
288
311
|
|
|
312
|
+
`asciifyVideo` options: `fitTo` (HTMLElement/selector — fits canvas to container + ResizeObserver), `preExtract` (pre-decode all frames, default false), `onReady(video)`, `onFrame()`
|
|
313
|
+
|
|
289
314
|
---
|
|
290
315
|
|
|
291
316
|
## License
|
package/dist/index.cjs
CHANGED
|
@@ -119,7 +119,9 @@ var DEFAULT_OPTIONS = {
|
|
|
119
119
|
hoverEffect: "spotlight",
|
|
120
120
|
hoverColor: "#ffffff",
|
|
121
121
|
artStyle: "classic",
|
|
122
|
-
customText: ""
|
|
122
|
+
customText: "",
|
|
123
|
+
chromaKey: null,
|
|
124
|
+
chromaKeyTolerance: 60
|
|
123
125
|
};
|
|
124
126
|
var HOVER_PRESETS = {
|
|
125
127
|
none: {
|
|
@@ -258,6 +260,17 @@ function getCellColorRGB(cell, colorMode, acR, acG, acB) {
|
|
|
258
260
|
}
|
|
259
261
|
return _colorRGB;
|
|
260
262
|
}
|
|
263
|
+
function parseChromaKeyColor(color) {
|
|
264
|
+
if (typeof color !== "string") return color;
|
|
265
|
+
const canvas = document.createElement("canvas");
|
|
266
|
+
canvas.width = 1;
|
|
267
|
+
canvas.height = 1;
|
|
268
|
+
const ctx = canvas.getContext("2d");
|
|
269
|
+
ctx.fillStyle = color;
|
|
270
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
271
|
+
const d = ctx.getImageData(0, 0, 1, 1).data;
|
|
272
|
+
return { r: d[0], g: d[1], b: d[2] };
|
|
273
|
+
}
|
|
261
274
|
|
|
262
275
|
// src/core/animation.ts
|
|
263
276
|
function smoothstep(t) {
|
|
@@ -635,6 +648,12 @@ function imageToAsciiFrame(source, options, targetWidth, targetHeight) {
|
|
|
635
648
|
normRange = hi > lo ? hi - lo : 255;
|
|
636
649
|
}
|
|
637
650
|
const frame = [];
|
|
651
|
+
let ckRGB = null;
|
|
652
|
+
let ckTolSq = 0;
|
|
653
|
+
if (options.chromaKey != null) {
|
|
654
|
+
ckRGB = parseChromaKeyColor(options.chromaKey);
|
|
655
|
+
ckTolSq = (options.chromaKeyTolerance ?? 60) ** 2;
|
|
656
|
+
}
|
|
638
657
|
for (let y = 0; y < rows; y++) {
|
|
639
658
|
const row = [];
|
|
640
659
|
for (let x = 0; x < cols; x++) {
|
|
@@ -643,6 +662,15 @@ function imageToAsciiFrame(source, options, targetWidth, targetHeight) {
|
|
|
643
662
|
const g = pixels[i + 1];
|
|
644
663
|
const b = pixels[i + 2];
|
|
645
664
|
const a = pixels[i + 3];
|
|
665
|
+
if (ckRGB !== null) {
|
|
666
|
+
const dr = r - ckRGB.r;
|
|
667
|
+
const dg = g - ckRGB.g;
|
|
668
|
+
const db = b - ckRGB.b;
|
|
669
|
+
if (dr * dr + dg * dg + db * db <= ckTolSq) {
|
|
670
|
+
row.push({ char: " ", r: 0, g: 0, b: 0, a: 0 });
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
646
674
|
const rawLum = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
647
675
|
const lum = options.normalize ? (rawLum - normMin) / normRange * 255 : rawLum;
|
|
648
676
|
const adjustedLum = adjustLuminance(lum, options.brightness, options.contrast);
|
|
@@ -985,6 +1013,17 @@ function renderFrameToCanvas(ctx, frame, options, canvasWidth, canvasHeight, tim
|
|
|
985
1013
|
}
|
|
986
1014
|
|
|
987
1015
|
// src/core/simple-api.ts
|
|
1016
|
+
function sizeCanvasToContainer(canvas, container, aspect) {
|
|
1017
|
+
const { width, height } = container.getBoundingClientRect();
|
|
1018
|
+
if (!width || !height) return;
|
|
1019
|
+
let w = width, h = w / aspect;
|
|
1020
|
+
if (h > height) {
|
|
1021
|
+
h = height;
|
|
1022
|
+
w = h * aspect;
|
|
1023
|
+
}
|
|
1024
|
+
canvas.width = Math.round(w);
|
|
1025
|
+
canvas.height = Math.round(h);
|
|
1026
|
+
}
|
|
988
1027
|
async function asciify(source, canvas, { fontSize = 10, artStyle = "classic", options = {} } = {}) {
|
|
989
1028
|
let el;
|
|
990
1029
|
if (typeof source === "string") {
|
|
@@ -1038,46 +1077,47 @@ async function asciifyGif(source, canvas, { fontSize = 10, artStyle = "classic",
|
|
|
1038
1077
|
cancelAnimationFrame(animId);
|
|
1039
1078
|
};
|
|
1040
1079
|
}
|
|
1041
|
-
async function asciifyVideo(source, canvas, { fontSize = 10, artStyle = "classic", options = {} } = {}) {
|
|
1042
|
-
let video;
|
|
1043
|
-
if (typeof source === "string") {
|
|
1044
|
-
video = document.createElement("video");
|
|
1045
|
-
video.crossOrigin = "anonymous";
|
|
1046
|
-
video.src = source;
|
|
1047
|
-
if (video.readyState < 2) {
|
|
1048
|
-
await new Promise((resolve, reject) => {
|
|
1049
|
-
video.onloadeddata = () => resolve();
|
|
1050
|
-
video.onerror = () => reject(new Error(`Failed to load video: ${source}`));
|
|
1051
|
-
});
|
|
1052
|
-
}
|
|
1053
|
-
} else {
|
|
1054
|
-
video = source;
|
|
1055
|
-
}
|
|
1080
|
+
async function asciifyVideo(source, canvas, { fontSize = 10, artStyle = "classic", options = {}, fitTo, preExtract = false, onReady, onFrame } = {}) {
|
|
1056
1081
|
const merged = { ...DEFAULT_OPTIONS, ...ART_STYLE_PRESETS[artStyle], ...options, fontSize };
|
|
1057
1082
|
const ctx = canvas.getContext("2d");
|
|
1058
|
-
if (!ctx) throw new Error("
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1083
|
+
if (!ctx) throw new Error("asciifyVideo: could not get 2d context from canvas.");
|
|
1084
|
+
const container = typeof fitTo === "string" ? document.querySelector(fitTo) : fitTo instanceof HTMLElement ? fitTo : null;
|
|
1085
|
+
if (preExtract) {
|
|
1086
|
+
let video2;
|
|
1087
|
+
if (typeof source === "string") {
|
|
1088
|
+
video2 = document.createElement("video");
|
|
1089
|
+
video2.crossOrigin = "anonymous";
|
|
1090
|
+
video2.src = source;
|
|
1091
|
+
if (video2.readyState < 2) {
|
|
1092
|
+
await new Promise((resolve, reject) => {
|
|
1093
|
+
video2.onloadeddata = () => resolve();
|
|
1094
|
+
video2.onerror = () => reject(new Error(`asciifyVideo: failed to load "${source}"`));
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
} else {
|
|
1098
|
+
video2 = source;
|
|
1099
|
+
}
|
|
1100
|
+
if (container) sizeCanvasToContainer(canvas, container, video2.videoWidth / video2.videoHeight);
|
|
1101
|
+
onReady?.(video2);
|
|
1102
|
+
const { frames, fps } = await videoToAsciiFrames(video2, merged, canvas.width, canvas.height);
|
|
1103
|
+
let cancelled2 = false, animId2, i = 0, last = performance.now();
|
|
1104
|
+
const interval = 1e3 / fps;
|
|
1105
|
+
const tick2 = (now) => {
|
|
1106
|
+
if (cancelled2) return;
|
|
1107
|
+
if (now - last >= interval) {
|
|
1108
|
+
renderFrameToCanvas(ctx, frames[i], merged, canvas.width, canvas.height);
|
|
1109
|
+
i = (i + 1) % frames.length;
|
|
1110
|
+
last = now;
|
|
1111
|
+
onFrame?.();
|
|
1112
|
+
}
|
|
1113
|
+
animId2 = requestAnimationFrame(tick2);
|
|
1114
|
+
};
|
|
1115
|
+
animId2 = requestAnimationFrame(tick2);
|
|
1116
|
+
return () => {
|
|
1117
|
+
cancelled2 = true;
|
|
1118
|
+
cancelAnimationFrame(animId2);
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1081
1121
|
let video;
|
|
1082
1122
|
let ownedVideo = false;
|
|
1083
1123
|
if (typeof source === "string") {
|
|
@@ -1101,7 +1141,7 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
|
|
|
1101
1141
|
ownedVideo = true;
|
|
1102
1142
|
await new Promise((resolve, reject) => {
|
|
1103
1143
|
video.onloadedmetadata = () => resolve();
|
|
1104
|
-
video.onerror = () => reject(new Error(`
|
|
1144
|
+
video.onerror = () => reject(new Error(`asciifyVideo: failed to load "${source}"`));
|
|
1105
1145
|
});
|
|
1106
1146
|
await video.play().catch(() => {
|
|
1107
1147
|
});
|
|
@@ -1110,14 +1150,14 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
|
|
|
1110
1150
|
if (video.paused) await video.play().catch(() => {
|
|
1111
1151
|
});
|
|
1112
1152
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1153
|
+
let ro = null;
|
|
1154
|
+
if (container) {
|
|
1155
|
+
const aspect = video.videoWidth / video.videoHeight;
|
|
1156
|
+
sizeCanvasToContainer(canvas, container, aspect);
|
|
1157
|
+
ro = new ResizeObserver(() => sizeCanvasToContainer(canvas, container, aspect));
|
|
1158
|
+
ro.observe(container);
|
|
1116
1159
|
}
|
|
1117
1160
|
onReady?.(video);
|
|
1118
|
-
const merged = { ...DEFAULT_OPTIONS, ...ART_STYLE_PRESETS[artStyle], ...options, fontSize };
|
|
1119
|
-
const ctx = canvas.getContext("2d");
|
|
1120
|
-
if (!ctx) throw new Error("asciifyLiveVideo: could not get 2d context from canvas.");
|
|
1121
1161
|
let cancelled = false;
|
|
1122
1162
|
let animId;
|
|
1123
1163
|
const tick = () => {
|
|
@@ -1134,6 +1174,7 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
|
|
|
1134
1174
|
return () => {
|
|
1135
1175
|
cancelled = true;
|
|
1136
1176
|
cancelAnimationFrame(animId);
|
|
1177
|
+
ro?.disconnect();
|
|
1137
1178
|
if (ownedVideo) {
|
|
1138
1179
|
video.pause();
|
|
1139
1180
|
video.src = "";
|
|
@@ -1141,6 +1182,9 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
|
|
|
1141
1182
|
}
|
|
1142
1183
|
};
|
|
1143
1184
|
}
|
|
1185
|
+
function asciifyLiveVideo(source, canvas, opts) {
|
|
1186
|
+
return asciifyVideo(source, canvas, opts);
|
|
1187
|
+
}
|
|
1144
1188
|
|
|
1145
1189
|
// src/backgrounds/rain.ts
|
|
1146
1190
|
function renderRainBackground(ctx, width, height, time, options = {}) {
|