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 CHANGED
@@ -77,58 +77,41 @@ setInterval(() => {
77
77
  }, 1000 / fps);
78
78
  ```
79
79
 
80
- ### Video — live (recommended)
80
+ ### Video
81
81
 
82
- `asciifyLiveVideo` streams a video as ASCII art in real time. Pass a URL and a canvas — it handles everything else.
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`. Browsers skip GPU frame decoding for hidden elements — you get a blank canvas. `asciifyLiveVideo` handles this automatically.
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 { asciifyLiveVideo } from 'asciify-engine';
87
+ import { asciifyVideo } from 'asciify-engine';
88
88
 
89
89
  const canvas = document.getElementById('ascii') as HTMLCanvasElement;
90
90
 
91
- // Sizes canvas to the video's native dimensions automatically:
92
- const stop = await asciifyLiveVideo('/clip.mp4', canvas, { autoSize: true });
91
+ // Minimal
92
+ const stop = await asciifyVideo('/clip.mp4', canvas);
93
93
 
94
- // With art style:
95
- const stop = await asciifyLiveVideo('/clip.mp4', canvas, {
96
- autoSize: true,
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
- // With lifecycle hooks — useful for sizing the canvas, loading indicators, timers:
102
- const stop = await asciifyLiveVideo('/clip.mp4', canvas, {
99
+ // Lifecycle hooks — ready state, timers, etc.:
100
+ const stop = await asciifyVideo('/clip.mp4', canvas, {
101
+ fitTo: '#hero',
103
102
  fontSize: 6,
104
- onReady: (video) => {
105
- // Called once when metadata is loaded and playback has started.
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("Could not get 2d context from canvas");
1059
- const { frames, fps } = await videoToAsciiFrames(video, merged, canvas.width, canvas.height);
1060
- let cancelled = false;
1061
- let animId;
1062
- let i = 0;
1063
- let last = performance.now();
1064
- const interval = 1e3 / fps;
1065
- const tick = (now) => {
1066
- if (cancelled) return;
1067
- if (now - last >= interval) {
1068
- renderFrameToCanvas(ctx, frames[i], merged, canvas.width, canvas.height);
1069
- i = (i + 1) % frames.length;
1070
- last = now;
1071
- }
1072
- animId = requestAnimationFrame(tick);
1073
- };
1074
- animId = requestAnimationFrame(tick);
1075
- return () => {
1076
- cancelled = true;
1077
- cancelAnimationFrame(animId);
1078
- };
1079
- }
1080
- async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "classic", options = {}, autoSize = false, onReady, onFrame } = {}) {
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(`asciifyLiveVideo: failed to load "${source}"`));
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
- if (autoSize) {
1114
- canvas.width = video.videoWidth;
1115
- canvas.height = video.videoHeight;
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 = {}) {