asciify-engine 1.0.43 → 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,39 +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
- const stop = await asciifyLiveVideo('/clip.mp4', canvas);
91
90
 
92
- // With options:
93
- const stop = await asciifyLiveVideo('/clip.mp4', canvas, {
91
+ // Minimal
92
+ const stop = await asciifyVideo('/clip.mp4', canvas);
93
+
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
97
+ });
98
+
99
+ // Lifecycle hooks — ready state, timers, etc.:
100
+ const stop = await asciifyVideo('/clip.mp4', canvas, {
101
+ fitTo: '#hero',
94
102
  fontSize: 6,
95
- artStyle: 'matrix',
103
+ onReady: () => setLoading(false),
104
+ onFrame: () => setElapsed(t => t + 1),
96
105
  });
97
106
 
107
+ // Pre-extract all frames before playback (frame-perfect loops, short clips):
108
+ const stop = await asciifyVideo('/clip.mp4', canvas, { preExtract: true });
109
+
98
110
  // Clean up:
99
111
  stop();
100
112
  ```
101
113
 
102
- ### Video — pre-extracted frames
103
114
 
104
- `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.
105
-
106
- ```ts
107
- import { asciifyVideo } from 'asciify-engine';
108
-
109
- const canvas = document.getElementById('ascii') as HTMLCanvasElement;
110
- const stop = await asciifyVideo('/clip.mp4', canvas, { fontSize: 8 });
111
- stop();
112
- ```
113
115
 
114
116
  ---
115
117
 
@@ -255,7 +257,6 @@ const animatedHtml = generateAnimatedEmbedCode(frames, options, fps);
255
257
  | Function | Signature | Returns |
256
258
  |---|---|---|
257
259
  | `asciify` | `(source, canvas, options?)` | `Promise<void>` |
258
- | `asciifyLiveVideo` | `(source, canvas, options?)` | `Promise<() => void>` |
259
260
  | `asciifyVideo` | `(source, canvas, options?)` | `Promise<() => void>` |
260
261
  | `asciifyGif` | `(source, canvas, options?)` | `Promise<() => void>` |
261
262
  | `asciifyWebcam` | `(canvas, options?)` | `Promise<() => void>` |
@@ -267,6 +268,8 @@ const animatedHtml = generateAnimatedEmbedCode(frames, options, fps);
267
268
  | `generateEmbedCode` | `(frame, options)` | `string` |
268
269
  | `generateAnimatedEmbedCode` | `(frames, options, fps)` | `string` |
269
270
 
271
+ `asciifyVideo` options: `fitTo` (HTMLElement/selector — fits canvas to container + ResizeObserver), `preExtract` (pre-decode all frames, default false), `onReady(video)`, `onFrame()`
272
+
270
273
  ---
271
274
 
272
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 = {}, 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,20 +1113,23 @@ 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
  });
1108
- onReady?.(video);
1109
1120
  } else {
1110
1121
  video = source;
1111
1122
  if (video.paused) await video.play().catch(() => {
1112
1123
  });
1113
- onReady?.(video);
1114
1124
  }
1115
- const merged = { ...DEFAULT_OPTIONS, ...ART_STYLE_PRESETS[artStyle], ...options, fontSize };
1116
- const ctx = canvas.getContext("2d");
1117
- if (!ctx) throw new Error("asciifyLiveVideo: could not get 2d context from canvas.");
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);
1131
+ }
1132
+ onReady?.(video);
1118
1133
  let cancelled = false;
1119
1134
  let animId;
1120
1135
  const tick = () => {
@@ -1131,6 +1146,7 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
1131
1146
  return () => {
1132
1147
  cancelled = true;
1133
1148
  cancelAnimationFrame(animId);
1149
+ ro?.disconnect();
1134
1150
  if (ownedVideo) {
1135
1151
  video.pause();
1136
1152
  video.src = "";
@@ -1138,6 +1154,9 @@ async function asciifyLiveVideo(source, canvas, { fontSize = 10, artStyle = "cla
1138
1154
  }
1139
1155
  };
1140
1156
  }
1157
+ function asciifyLiveVideo(source, canvas, opts) {
1158
+ return asciifyVideo(source, canvas, opts);
1159
+ }
1141
1160
 
1142
1161
  // src/backgrounds/rain.ts
1143
1162
  function renderRainBackground(ctx, width, height, time, options = {}) {