asciify-engine 1.0.44 → 1.0.45

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
114
 
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
-
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
 
@@ -274,7 +257,6 @@ const animatedHtml = generateAnimatedEmbedCode(frames, options, fps);
274
257
  | Function | Signature | Returns |
275
258
  |---|---|---|
276
259
  | `asciify` | `(source, canvas, options?)` | `Promise<void>` |
277
- | `asciifyLiveVideo` | `(source, canvas, options?)` | `Promise<() => void>` |
278
260
  | `asciifyVideo` | `(source, canvas, options?)` | `Promise<() => void>` |
279
261
  | `asciifyGif` | `(source, canvas, options?)` | `Promise<() => void>` |
280
262
  | `asciifyWebcam` | `(canvas, options?)` | `Promise<() => void>` |
@@ -286,6 +268,8 @@ const animatedHtml = generateAnimatedEmbedCode(frames, options, fps);
286
268
  | `generateEmbedCode` | `(frame, options)` | `string` |
287
269
  | `generateAnimatedEmbedCode` | `(frames, options, fps)` | `string` |
288
270
 
271
+ `asciifyVideo` options: `fitTo` (HTMLElement/selector — fits canvas to container + ResizeObserver), `preExtract` (pre-decode all frames, default false), `onReady(video)`, `onFrame()`
272
+
289
273
  ---
290
274
 
291
275
  ## License
package/dist/index.cjs CHANGED
@@ -985,6 +985,17 @@ function renderFrameToCanvas(ctx, frame, options, canvasWidth, canvasHeight, tim
985
985
  }
986
986
 
987
987
  // src/core/simple-api.ts
988
+ function sizeCanvasToContainer(canvas, container, aspect) {
989
+ const { width, height } = container.getBoundingClientRect();
990
+ if (!width || !height) return;
991
+ let w = width, h = w / aspect;
992
+ if (h > height) {
993
+ h = height;
994
+ w = h * aspect;
995
+ }
996
+ canvas.width = Math.round(w);
997
+ canvas.height = Math.round(h);
998
+ }
988
999
  async function asciify(source, canvas, { fontSize = 10, artStyle = "classic", options = {} } = {}) {
989
1000
  let el;
990
1001
  if (typeof source === "string") {
@@ -1038,46 +1049,47 @@ async function asciifyGif(source, canvas, { fontSize = 10, artStyle = "classic",
1038
1049
  cancelAnimationFrame(animId);
1039
1050
  };
1040
1051
  }
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
- }
1052
+ async function asciifyVideo(source, canvas, { fontSize = 10, artStyle = "classic", options = {}, fitTo, preExtract = false, onReady, onFrame } = {}) {
1056
1053
  const merged = { ...DEFAULT_OPTIONS, ...ART_STYLE_PRESETS[artStyle], ...options, fontSize };
1057
1054
  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 } = {}) {
1055
+ if (!ctx) throw new Error("asciifyVideo: could not get 2d context from canvas.");
1056
+ const container = typeof fitTo === "string" ? document.querySelector(fitTo) : fitTo instanceof HTMLElement ? fitTo : null;
1057
+ if (preExtract) {
1058
+ let video2;
1059
+ if (typeof source === "string") {
1060
+ video2 = document.createElement("video");
1061
+ video2.crossOrigin = "anonymous";
1062
+ video2.src = source;
1063
+ if (video2.readyState < 2) {
1064
+ await new Promise((resolve, reject) => {
1065
+ video2.onloadeddata = () => resolve();
1066
+ video2.onerror = () => reject(new Error(`asciifyVideo: failed to load "${source}"`));
1067
+ });
1068
+ }
1069
+ } else {
1070
+ video2 = source;
1071
+ }
1072
+ if (container) sizeCanvasToContainer(canvas, container, video2.videoWidth / video2.videoHeight);
1073
+ onReady?.(video2);
1074
+ const { frames, fps } = await videoToAsciiFrames(video2, merged, canvas.width, canvas.height);
1075
+ let cancelled2 = false, animId2, i = 0, last = performance.now();
1076
+ const interval = 1e3 / fps;
1077
+ const tick2 = (now) => {
1078
+ if (cancelled2) return;
1079
+ if (now - last >= interval) {
1080
+ renderFrameToCanvas(ctx, frames[i], merged, canvas.width, canvas.height);
1081
+ i = (i + 1) % frames.length;
1082
+ last = now;
1083
+ onFrame?.();
1084
+ }
1085
+ animId2 = requestAnimationFrame(tick2);
1086
+ };
1087
+ animId2 = requestAnimationFrame(tick2);
1088
+ return () => {
1089
+ cancelled2 = true;
1090
+ cancelAnimationFrame(animId2);
1091
+ };
1092
+ }
1081
1093
  let video;
1082
1094
  let ownedVideo = false;
1083
1095
  if (typeof source === "string") {
@@ -1101,7 +1113,7 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
1101
1113
  ownedVideo = true;
1102
1114
  await new Promise((resolve, reject) => {
1103
1115
  video.onloadedmetadata = () => resolve();
1104
- video.onerror = () => reject(new Error(`asciifyLiveVideo: failed to load "${source}"`));
1116
+ video.onerror = () => reject(new Error(`asciifyVideo: failed to load "${source}"`));
1105
1117
  });
1106
1118
  await video.play().catch(() => {
1107
1119
  });
@@ -1110,14 +1122,14 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
1110
1122
  if (video.paused) await video.play().catch(() => {
1111
1123
  });
1112
1124
  }
1113
- if (autoSize) {
1114
- canvas.width = video.videoWidth;
1115
- canvas.height = video.videoHeight;
1125
+ let ro = null;
1126
+ if (container) {
1127
+ const aspect = video.videoWidth / video.videoHeight;
1128
+ sizeCanvasToContainer(canvas, container, aspect);
1129
+ ro = new ResizeObserver(() => sizeCanvasToContainer(canvas, container, aspect));
1130
+ ro.observe(container);
1116
1131
  }
1117
1132
  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
1133
  let cancelled = false;
1122
1134
  let animId;
1123
1135
  const tick = () => {
@@ -1134,6 +1146,7 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
1134
1146
  return () => {
1135
1147
  cancelled = true;
1136
1148
  cancelAnimationFrame(animId);
1149
+ ro?.disconnect();
1137
1150
  if (ownedVideo) {
1138
1151
  video.pause();
1139
1152
  video.src = "";
@@ -1141,6 +1154,9 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
1141
1154
  }
1142
1155
  };
1143
1156
  }
1157
+ function asciifyLiveVideo(source, canvas, opts) {
1158
+ return asciifyVideo(source, canvas, opts);
1159
+ }
1144
1160
 
1145
1161
  // src/backgrounds/rain.ts
1146
1162
  function renderRainBackground(ctx, width, height, time, options = {}) {