asciify-engine 1.0.34 → 1.0.36

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
@@ -28,6 +28,133 @@
28
28
  npm install asciify-engine
29
29
  ```
30
30
 
31
+ ## Quick Start
32
+
33
+ ### Image → ASCII
34
+
35
+ ```ts
36
+ import { asciify } from 'asciify-engine';
37
+
38
+ const canvas = document.querySelector('canvas')!;
39
+
40
+ // Minimal — just a URL and a canvas, no img.onload boilerplate needed
41
+ await asciify('https://example.com/photo.jpg', canvas);
42
+
43
+ // With an art style preset
44
+ await asciify('photo.jpg', canvas, { artStyle: 'letters' });
45
+
46
+ // Full control
47
+ await asciify('photo.jpg', canvas, {
48
+ fontSize: 8,
49
+ artStyle: 'art',
50
+ options: {
51
+ colorMode: 'fullcolor',
52
+ invert: true,
53
+ hoverEffect: 'spotlight',
54
+ hoverStrength: 0.5,
55
+ },
56
+ });
57
+ ```
58
+
59
+ ### GIF Animation
60
+
61
+ ```ts
62
+ import { asciifyGif } from 'asciify-engine';
63
+
64
+ // Fetches, converts, and starts the animation loop — returns a stop() function
65
+ const stop = await asciifyGif('animation.gif', canvas);
66
+
67
+ // Clean up the animation loop when done
68
+ stop();
69
+ ```
70
+
71
+ ### Video
72
+
73
+ ```ts
74
+ import { asciifyVideo } from 'asciify-engine';
75
+
76
+ // Accepts a URL string or an existing HTMLVideoElement
77
+ const stop = await asciifyVideo('/my-video.mp4', canvas, { artStyle: 'terminal' });
78
+
79
+ // Cancel when unmounting / navigating away
80
+ stop();
81
+ ```
82
+
83
+ ### React
84
+
85
+ ```tsx
86
+ import { useEffect, useRef } from 'react';
87
+ import { asciify } from 'asciify-engine';
88
+
89
+ export function AsciiImage({ src }: { src: string }) {
90
+ const ref = useRef<HTMLCanvasElement>(null);
91
+
92
+ useEffect(() => {
93
+ if (ref.current) asciify(src, ref.current, { artStyle: 'art' });
94
+ }, [src]);
95
+
96
+ return <canvas ref={ref} width={800} height={600} />;
97
+ }
98
+ ```
99
+
100
+ ### Animated GIF or video in React
101
+
102
+ ```tsx
103
+ import { useEffect, useRef } from 'react';
104
+ import { asciifyGif } from 'asciify-engine';
105
+
106
+ export function AsciiGif({ src }: { src: string }) {
107
+ const ref = useRef<HTMLCanvasElement>(null);
108
+
109
+ useEffect(() => {
110
+ let stop: (() => void) | undefined;
111
+ asciifyGif(src, ref.current!).then(fn => { stop = fn; });
112
+ return () => stop?.(); // cancels the rAF loop on unmount
113
+ }, [src]);
114
+
115
+ return <canvas ref={ref} width={800} height={600} />;
116
+ }
117
+ ```
118
+
119
+ ### Vue
120
+
121
+ ```vue
122
+ <template>
123
+ <canvas ref="canvasRef" width="800" height="600" />
124
+ </template>
125
+
126
+ <script setup lang="ts">
127
+ import { useTemplateRef, onMounted } from 'vue';
128
+ import { asciify } from 'asciify-engine';
129
+
130
+ const props = defineProps<{ src: string; artStyle?: string }>();
131
+ const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef');
132
+
133
+ onMounted(() => asciify(props.src, canvasRef.value!, { artStyle: props.artStyle as any }));
134
+ </script>
135
+ ```
136
+
137
+ ### Angular
138
+
139
+ ```typescript
140
+ import { Component, ElementRef, ViewChild, AfterViewInit, Input } from '@angular/core';
141
+ import { asciify } from 'asciify-engine';
142
+
143
+ @Component({
144
+ selector: 'app-ascii',
145
+ template: `<canvas #canvas width="800" height="600"></canvas>`,
146
+ })
147
+ export class AsciiComponent implements AfterViewInit {
148
+ @ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
149
+ @Input() src = '';
150
+ @Input() artStyle = 'classic';
151
+
152
+ ngAfterViewInit() {
153
+ asciify(this.src, this.canvasRef.nativeElement, { artStyle: this.artStyle as any });
154
+ }
155
+ }
156
+ ```
157
+
31
158
  ## Animated Backgrounds
32
159
 
33
160
  Drop a live ASCII animation into any element with one call.
@@ -84,23 +211,12 @@ Each background type also accepts its own specific options — see the individua
84
211
  All renderers are also exported individually for direct canvas use:
85
212
 
86
213
  ```ts
87
- import {
88
- renderRainBackground,
89
- renderWaveBackground,
90
- renderStarsBackground,
91
- renderPulseBackground,
92
- renderNoiseBackground,
93
- renderGridBackground,
94
- renderAuroraBackground,
95
- renderSilkBackground,
96
- renderVoidBackground,
97
- renderMorphBackground,
98
- } from 'asciify-engine';
99
-
100
- // Example: drive the rain renderer yourself
214
+ import { renderRainBackground } from 'asciify-engine';
215
+
101
216
  const canvas = document.getElementById('canvas') as HTMLCanvasElement;
102
217
  const ctx = canvas.getContext('2d')!;
103
218
  let t = 0;
219
+
104
220
  function tick() {
105
221
  renderRainBackground(ctx, canvas.width, canvas.height, t, {
106
222
  speed: 1.2,
@@ -114,113 +230,39 @@ function tick() {
114
230
  tick();
115
231
  ```
116
232
 
117
- ## Quick Start
118
-
119
- ### Vanilla JS
120
-
121
- ```html
122
- <canvas id="ascii" width="800" height="600"></canvas>
123
- <script type="module">
124
- import {
125
- imageToAsciiFrame,
126
- renderFrameToCanvas,
127
- DEFAULT_OPTIONS,
128
- } from 'asciify-engine';
129
-
130
- const img = new Image();
131
- img.crossOrigin = 'anonymous';
132
- img.src = 'https://picsum.photos/600/400';
133
- img.onload = () => {
134
- const canvas = document.getElementById('ascii');
135
- const options = { ...DEFAULT_OPTIONS, fontSize: 10 };
136
- const { frame } = imageToAsciiFrame(img, options, canvas.width, canvas.height);
137
- renderFrameToCanvas(canvas.getContext('2d'), frame, options, canvas.width, canvas.height);
138
- };
139
- </script>
140
- ```
141
-
142
- ### React
143
-
144
- ```tsx
145
- import { useEffect, useRef } from 'react';
146
- import {
147
- imageToAsciiFrame,
148
- renderFrameToCanvas,
149
- DEFAULT_OPTIONS,
150
- } from 'asciify-engine';
151
-
152
- export function AsciiImage({ src }: { src: string }) {
153
- const ref = useRef<HTMLCanvasElement>(null);
154
-
155
- useEffect(() => {
156
- const img = new Image();
157
- img.crossOrigin = 'anonymous';
158
- img.src = src;
159
- img.onload = () => {
160
- const canvas = ref.current!;
161
- const opts = { ...DEFAULT_OPTIONS, fontSize: 10 };
162
- const { frame } = imageToAsciiFrame(img, opts, canvas.width, canvas.height);
163
- renderFrameToCanvas(canvas.getContext('2d')!, frame, opts, canvas.width, canvas.height);
164
- };
165
- }, [src]);
166
-
167
- return <canvas ref={ref} width={800} height={600} />;
168
- }
169
- ```
170
-
171
- ### Angular
172
-
173
- ```typescript
174
- import { Component, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
175
- import {
176
- imageToAsciiFrame,
177
- renderFrameToCanvas,
178
- DEFAULT_OPTIONS,
179
- } from 'asciify-engine';
180
-
181
- @Component({
182
- selector: 'app-ascii',
183
- template: `<canvas #canvas [width]="800" [height]="600"></canvas>`,
184
- })
185
- export class AsciiComponent implements AfterViewInit {
186
- @ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
233
+ ## Low-level API
187
234
 
188
- ngAfterViewInit() {
189
- const img = new Image();
190
- img.crossOrigin = 'anonymous';
191
- img.src = 'https://picsum.photos/600/400';
192
- img.onload = () => {
193
- const canvas = this.canvasRef.nativeElement;
194
- const opts = { ...DEFAULT_OPTIONS, fontSize: 10 };
195
- const { frame } = imageToAsciiFrame(img, opts, canvas.width, canvas.height);
196
- renderFrameToCanvas(canvas.getContext('2d')!, frame, opts, canvas.width, canvas.height);
197
- };
198
- }
199
- }
200
- ```
235
+ For cases where the one-call API is too limiting — direct frame manipulation, custom render loops, progress callbacks, etc.
201
236
 
202
- ### GIF Animation
237
+ ### GIF (low-level)
203
238
 
204
239
  ```ts
205
240
  import { gifToAsciiFrames, renderFrameToCanvas, DEFAULT_OPTIONS } from 'asciify-engine';
206
241
 
207
- const response = await fetch('https://media.giphy.com/media/ENagATV1Gr9eg/giphy.gif');
208
- const buffer = await response.arrayBuffer();
209
-
210
- const canvas = document.getElementById('ascii') as HTMLCanvasElement;
211
- const ctx = canvas.getContext('2d')!;
242
+ const buffer = await fetch('animation.gif').then(r => r.arrayBuffer());
212
243
  const options = { ...DEFAULT_OPTIONS, fontSize: 8 };
213
-
214
244
  const { frames, fps } = await gifToAsciiFrames(buffer, options, canvas.width, canvas.height);
215
245
 
246
+ const ctx = canvas.getContext('2d')!;
216
247
  let i = 0;
217
- setInterval(() => {
218
- renderFrameToCanvas(ctx, frames[i], options, canvas.width, canvas.height);
219
- i = (i + 1) % frames.length;
220
- }, 1000 / fps);
248
+ let last = performance.now();
249
+ const interval = 1000 / fps;
250
+
251
+ let animId: number;
252
+ const tick = (now: number) => {
253
+ if (now - last >= interval) {
254
+ renderFrameToCanvas(ctx, frames[i], options, canvas.width, canvas.height);
255
+ i = (i + 1) % frames.length;
256
+ last = now;
257
+ }
258
+ animId = requestAnimationFrame(tick);
259
+ };
260
+ animId = requestAnimationFrame(tick);
261
+
262
+ // Clean up → cancelAnimationFrame(animId);
221
263
  ```
222
264
 
223
- ### Video
265
+ ### Video (low-level)
224
266
 
225
267
  ```ts
226
268
  import { videoToAsciiFrames, renderFrameToCanvas, DEFAULT_OPTIONS } from 'asciify-engine';
@@ -228,18 +270,52 @@ import { videoToAsciiFrames, renderFrameToCanvas, DEFAULT_OPTIONS } from 'asciif
228
270
  const video = document.createElement('video');
229
271
  video.crossOrigin = 'anonymous';
230
272
  video.src = '/my-video.mp4';
231
- await new Promise((r) => (video.onloadeddata = r));
273
+ // Guard against cached video where onloadeddata already fired
274
+ if (video.readyState < 2) {
275
+ await new Promise<void>(r => { video.onloadeddata = () => r(); });
276
+ }
232
277
 
233
- const canvas = document.getElementById('ascii') as HTMLCanvasElement;
234
278
  const options = { ...DEFAULT_OPTIONS, fontSize: 8 };
235
-
236
279
  const { frames, fps } = await videoToAsciiFrames(video, options, canvas.width, canvas.height, 12, 10);
237
280
 
281
+ const ctx = canvas.getContext('2d')!;
238
282
  let i = 0;
239
- setInterval(() => {
240
- renderFrameToCanvas(canvas.getContext('2d')!, frames[i], options, canvas.width, canvas.height);
241
- i = (i + 1) % frames.length;
242
- }, 1000 / fps);
283
+ let last = performance.now();
284
+ const interval = 1000 / fps;
285
+
286
+ let animId: number;
287
+ const tick = (now: number) => {
288
+ if (now - last >= interval) {
289
+ renderFrameToCanvas(ctx, frames[i], options, canvas.width, canvas.height);
290
+ i = (i + 1) % frames.length;
291
+ last = now;
292
+ }
293
+ animId = requestAnimationFrame(tick);
294
+ };
295
+ animId = requestAnimationFrame(tick);
296
+
297
+ // Clean up → cancelAnimationFrame(animId);
298
+ ```
299
+
300
+ ### Vanilla JS (low-level image)
301
+
302
+ ```html
303
+ <canvas id="ascii" width="800" height="600"></canvas>
304
+ <script type="module">
305
+ import { imageToAsciiFrame, renderFrameToCanvas, DEFAULT_OPTIONS } from 'asciify-engine';
306
+
307
+ const canvas = document.getElementById('ascii');
308
+ const ctx = canvas.getContext('2d');
309
+ const options = { ...DEFAULT_OPTIONS, fontSize: 10 };
310
+
311
+ const img = new Image();
312
+ img.crossOrigin = 'anonymous';
313
+ img.src = 'https://picsum.photos/600/400';
314
+ img.onload = () => {
315
+ const { frame } = imageToAsciiFrame(img, options, canvas.width, canvas.height);
316
+ renderFrameToCanvas(ctx, frame, options, canvas.width, canvas.height);
317
+ };
318
+ </script>
243
319
  ```
244
320
 
245
321
  ## API Reference
@@ -248,14 +324,27 @@ setInterval(() => {
248
324
 
249
325
  | Function | Returns | Description |
250
326
  |---|---|---|
251
- | `imageToAsciiFrame(source, options, w?, h?)` | `{ frame, cols, rows }` | Convert an image, video frame, or canvas to ASCII |
252
- | `renderFrameToCanvas(ctx, frame, options, w, h, time?, hoverPos?)` | `void` | Render an ASCII frame to a 2D canvas context |
327
+ | `asciify(source, canvas, opts?)` | `Promise<void>` | Convert an image/video/canvas (or URL) to ASCII and render — handles loading automatically |
328
+ | `asciifyGif(source, canvas, opts?)` | `Promise<() => void>` | Fetch a GIF, convert, and start an animation loop — returns `stop()` |
329
+ | `asciifyVideo(source, canvas, opts?)` | `Promise<() => void>` | Convert a video and start an animation loop — returns `stop()` |
330
+ | `imageToAsciiFrame(source, options, w?, h?)` | `{ frame, cols, rows }` | Convert an image, video frame, or canvas to a raw ASCII frame |
331
+ | `renderFrameToCanvas(ctx, frame, options, w, h, time?, hoverPos?)` | `void` | Render a raw ASCII frame to a 2D canvas context |
253
332
  | `gifToAsciiFrames(buffer, options, w, h, onProgress?)` | `{ frames, cols, rows, fps }` | Parse an animated GIF `ArrayBuffer` into ASCII frames |
254
333
  | `videoToAsciiFrames(video, options, w, h, fps?, maxDuration?, onProgress?)` | `{ frames, cols, rows, fps }` | Extract and convert video frames to ASCII |
255
334
  | `asciiBackground(selector, options)` | `() => void` | Mount a live animated ASCII background; returns a cleanup function |
256
335
  | `generateEmbedCode(frame, options)` | `string` | Self-contained static HTML embed |
257
336
  | `generateAnimatedEmbedCode(frames, options, fps)` | `string` | Self-contained animated HTML embed |
258
337
 
338
+ ### Simple API Options (`AsciifySimpleOptions`)
339
+
340
+ Used by `asciify()`, `asciifyGif()`, and `asciifyVideo()`:
341
+
342
+ | Option | Type | Default | Description |
343
+ |---|---|---|---|
344
+ | `fontSize` | `number` | `10` | Character cell size in px |
345
+ | `artStyle` | `ArtStyle` | `'classic'` | Art style preset — sets charset, render mode, and color mode together |
346
+ | `options` | `Partial<AsciiOptions>` | `{}` | Fine-grained overrides applied on top of the preset |
347
+
259
348
  ### Key Options (`AsciiOptions`)
260
349
 
261
350
  | Option | Type | Default | Description |
@@ -263,13 +352,17 @@ setInterval(() => {
263
352
  | `fontSize` | `number` | `10` | Character cell size in px |
264
353
  | `colorMode` | `'grayscale' \| 'fullcolor' \| 'matrix' \| 'accent'` | `'grayscale'` | Color output mode |
265
354
  | `renderMode` | `'ascii' \| 'dots'` | `'ascii'` | Render as characters or dot particles |
266
- | `charset` | `string` | standard ramp | Custom character density ramp |
267
- | `brightness` | `number` | `0` | Brightness adjustment (-1 → 1) |
268
- | `contrast` | `number` | `1` | Contrast multiplier |
355
+ | `charset` | `string` | `' .:-=+*#%@'` | Custom character density ramp (light → dark) |
356
+ | `brightness` | `number` | `0` | Brightness adjustment (1 → 0 → 1) |
357
+ | `contrast` | `number` | `0` | Contrast boost (0 = unchanged, positive = more contrast) |
269
358
  | `invert` | `boolean` | `false` | Invert luminance mapping |
270
- | `hoverEffect` | `string` | `'none'` | Interactive effect name |
271
- | `hoverStrength` | `number` | `0.8` | Effect intensity |
272
- | `hoverRadius` | `number` | `0.3` | Effect radius (0–1 relative to canvas) |
359
+ | `animationStyle` | `AnimationStyle` | `'none'` | Per-character animation driven over time |
360
+ | `hoverEffect` | `HoverEffect` | `'spotlight'` | Cursor interaction style |
361
+ | `hoverStrength` | `number` | `0` | Effect intensity (0 = disabled) |
362
+ | `hoverRadius` | `number` | `0.2` | Effect radius as a fraction of canvas size |
363
+ | `artStyle` | `ArtStyle` | `'classic'` | Art style preset (see `ART_STYLE_PRESETS`) |
364
+ | `ditherStrength` | `number` | `0` | Floyd-Steinberg dither intensity (0–1) |
365
+ | `dotSizeRatio` | `number` | `0.8` | Dot size when `renderMode === 'dots'` (fraction of cell) |
273
366
 
274
367
  ### Background Options
275
368
 
package/dist/index.cjs CHANGED
@@ -970,7 +970,7 @@ function renderFrameToCanvas(ctx, frame, options, canvasWidth, canvasHeight, tim
970
970
  }
971
971
 
972
972
  // src/core/simple-api.ts
973
- async function asciify(source, canvas, { fontSize = 10, style = "classic", options = {} } = {}) {
973
+ async function asciify(source, canvas, { fontSize = 10, artStyle = "classic", options = {} } = {}) {
974
974
  let el;
975
975
  if (typeof source === "string") {
976
976
  const img = new Image();
@@ -990,16 +990,16 @@ async function asciify(source, canvas, { fontSize = 10, style = "classic", optio
990
990
  } else {
991
991
  el = source;
992
992
  }
993
- const preset = ART_STYLE_PRESETS[style];
993
+ const preset = ART_STYLE_PRESETS[artStyle];
994
994
  const merged = { ...DEFAULT_OPTIONS, ...preset, ...options, fontSize };
995
995
  const ctx = canvas.getContext("2d");
996
996
  if (!ctx) throw new Error("Could not get 2d context from canvas");
997
997
  const { frame } = imageToAsciiFrame(el, merged, canvas.width, canvas.height);
998
998
  renderFrameToCanvas(ctx, frame, merged, canvas.width, canvas.height);
999
999
  }
1000
- async function asciifyGif(source, canvas, { fontSize = 10, style = "classic", options = {} } = {}) {
1000
+ async function asciifyGif(source, canvas, { fontSize = 10, artStyle = "classic", options = {} } = {}) {
1001
1001
  const buffer = typeof source === "string" ? await fetch(source).then((r) => r.arrayBuffer()) : source;
1002
- const merged = { ...DEFAULT_OPTIONS, ...ART_STYLE_PRESETS[style], ...options, fontSize };
1002
+ const merged = { ...DEFAULT_OPTIONS, ...ART_STYLE_PRESETS[artStyle], ...options, fontSize };
1003
1003
  const ctx = canvas.getContext("2d");
1004
1004
  if (!ctx) throw new Error("Could not get 2d context from canvas");
1005
1005
  const { frames, fps } = await gifToAsciiFrames(buffer, merged, canvas.width, canvas.height);
@@ -1023,20 +1023,22 @@ async function asciifyGif(source, canvas, { fontSize = 10, style = "classic", op
1023
1023
  cancelAnimationFrame(animId);
1024
1024
  };
1025
1025
  }
1026
- async function asciifyVideo(source, canvas, { fontSize = 10, style = "classic", options = {} } = {}) {
1026
+ async function asciifyVideo(source, canvas, { fontSize = 10, artStyle = "classic", options = {} } = {}) {
1027
1027
  let video;
1028
1028
  if (typeof source === "string") {
1029
1029
  video = document.createElement("video");
1030
1030
  video.crossOrigin = "anonymous";
1031
1031
  video.src = source;
1032
- await new Promise((resolve, reject) => {
1033
- video.onloadeddata = () => resolve();
1034
- video.onerror = () => reject(new Error(`Failed to load video: ${source}`));
1035
- });
1032
+ if (video.readyState < 2) {
1033
+ await new Promise((resolve, reject) => {
1034
+ video.onloadeddata = () => resolve();
1035
+ video.onerror = () => reject(new Error(`Failed to load video: ${source}`));
1036
+ });
1037
+ }
1036
1038
  } else {
1037
1039
  video = source;
1038
1040
  }
1039
- const merged = { ...DEFAULT_OPTIONS, ...ART_STYLE_PRESETS[style], ...options, fontSize };
1041
+ const merged = { ...DEFAULT_OPTIONS, ...ART_STYLE_PRESETS[artStyle], ...options, fontSize };
1040
1042
  const ctx = canvas.getContext("2d");
1041
1043
  if (!ctx) throw new Error("Could not get 2d context from canvas");
1042
1044
  const { frames, fps } = await videoToAsciiFrames(video, merged, canvas.width, canvas.height);