asciify-engine 1.0.31 → 1.0.34

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/dist/index.d.cts CHANGED
@@ -711,66 +711,108 @@ declare function renderTextBackground(ctx: CanvasRenderingContext2D, width: numb
711
711
  intensity?: number;
712
712
  } | null): void;
713
713
 
714
- /**
715
- * record() — capture a rolling frame buffer from a running ASCII canvas
716
- * and export it as a downloadable animated GIF or WebP data URL.
717
- *
718
- * Usage:
719
- * ```ts
720
- * const recorder = createRecorder(canvasEl, { fps: 15, maxFrames: 120 });
721
- * recorder.start();
722
- * // ... after a few seconds ...
723
- * const dataUrl = await recorder.stop(); // 'data:image/gif;base64,...'
724
- * ```
725
- */
726
- interface RecorderOptions {
727
- /** Target capture frame rate (default: 15) */
728
- fps?: number;
729
- /** Maximum number of frames to buffer (default: 120 → 8 s at 15 fps) */
730
- maxFrames?: number;
714
+ interface SnapshotOptions {
731
715
  /**
732
- * Output format.
733
- * - 'gif' — animated GIF via gif.js worker (requires gif.worker.js in public/)
734
- * - 'webp' — animated WebP via MediaRecorder API (Chrome/Edge only)
735
- * - 'png-sequence' — returns a JSON array of PNG data URLs (universal)
736
- * Default: 'gif'
716
+ * Image format.
717
+ * - 'png' — lossless (default)
718
+ * - 'jpeg' — smaller, no transparency
719
+ * - 'webp' — best compression with transparency
737
720
  */
738
- format?: 'gif' | 'webp' | 'png-sequence';
739
- /** GIF quality 1 (best) – 30 (smallest) — only used for format:'gif' (default: 10) */
721
+ format?: 'png' | 'jpeg' | 'webp';
722
+ /** 0–1 quality for jpeg/webp. Default: 0.92 */
740
723
  quality?: number;
741
724
  /**
742
- * Scale factor applied to the canvas before capture (default: 1).
743
- * Use 0.5 to halve dimensions and reduce file size.
725
+ * Scale factor applied before capture. Default: 1.
726
+ * Use 2 for a high-resolution export at the canvas size.
744
727
  */
745
728
  scale?: number;
746
729
  }
747
- interface Recorder {
748
- /** Start capturing frames from the canvas. */
749
- start(): void;
750
- /**
751
- * Stop capturing and encode.
752
- * Resolves with a data URL (gif/webp) or JSON string (png-sequence).
753
- */
754
- stop(): Promise<string>;
755
- /** True while recording. */
756
- readonly isRecording: boolean;
757
- /** Number of frames captured so far. */
758
- readonly frameCount: number;
759
- }
760
730
  /**
761
- * Create a Recorder bound to a canvas element.
731
+ * Capture a single frame from a canvas as a Blob.
732
+ *
733
+ * @example
734
+ * ```ts
735
+ * const blob = await captureSnapshot(canvas);
736
+ * const url = URL.createObjectURL(blob);
737
+ * ```
762
738
  */
763
- declare function createRecorder(canvas: HTMLCanvasElement, options?: RecorderOptions): Recorder;
739
+ declare function captureSnapshot(canvas: HTMLCanvasElement, { format, quality, scale }?: SnapshotOptions): Promise<Blob>;
764
740
  /**
765
- * Convenience: record for a fixed duration and auto-download.
741
+ * Capture a single frame and immediately trigger a browser download.
766
742
  *
767
743
  * @example
768
744
  * ```ts
769
- * await recordAndDownload(canvasEl, 3000, { fps: 15, format: 'gif' });
745
+ * await snapshotAndDownload(canvas);
746
+ * await snapshotAndDownload(canvas, { format: 'jpeg', filename: 'my-art' });
770
747
  * ```
771
748
  */
772
- declare function recordAndDownload(canvas: HTMLCanvasElement, durationMs: number, options?: RecorderOptions & {
749
+ declare function snapshotAndDownload(canvas: HTMLCanvasElement, options?: SnapshotOptions & {
773
750
  filename?: string;
774
751
  }): Promise<void>;
775
752
 
776
- export { ART_STYLE_PRESETS, type AnimationStyle, type ArtStyle, type AsciiBackgroundOptions, type AsciiCell, type AsciiFrame, type AsciiOptions, type AsciiResult, type AsciifySimpleOptions, type AuroraBackgroundOptions, CHARSETS, type CharsetKey, type CircuitBackgroundOptions, type ColorMode, DEFAULT_OPTIONS, type DnaBackgroundOptions, type FireBackgroundOptions, type GridBackgroundOptions, HOVER_PRESETS, type HoverEffect, type HoverPreset, type MorphBackgroundOptions, type MountWaveOptions, type NoiseBackgroundOptions, PALETTE_THEMES, type PaletteTheme, type PulseBackgroundOptions, type RainBackgroundOptions, type Recorder, type RecorderOptions, type RenderMode, type SilkBackgroundOptions, type SourceType, type StarsBackgroundOptions, type TerrainBackgroundOptions, type TextBackgroundOptions, type VoidBackgroundOptions, type WaveBackgroundOptions, asciiBackground, asciiText, asciiTextAnsi, asciify, asciifyGif, asciifyVideo, buildTextFrame, createRecorder, gifToAsciiFrames, imageToAsciiFrame, mountWaveBackground, recordAndDownload, renderAuroraBackground, renderCircuitBackground, renderDnaBackground, renderFireBackground, renderFrameToCanvas, renderGridBackground, renderMorphBackground, renderNoiseBackground, renderPulseBackground, renderRainBackground, renderSilkBackground, renderStarsBackground, renderTerrainBackground, renderTextBackground, renderVoidBackground, renderWaveBackground, videoToAsciiFrames };
753
+ /**
754
+ * asciifyWebcam — live webcam → ASCII art on canvas.
755
+ *
756
+ * Requests camera access, attaches the stream to a hidden video element, and
757
+ * runs a rAF loop that converts each frame to ASCII and renders it onto a
758
+ * supplied canvas.
759
+ *
760
+ * @example
761
+ * const stop = await asciifyWebcam(canvas);
762
+ * // later: stop();
763
+ *
764
+ * @example
765
+ * const stop = await asciifyWebcam(canvas, {
766
+ * fontSize: 8,
767
+ * style: 'terminal',
768
+ * mirror: true, // horizontal flip (selfie mode)
769
+ * constraints: { facingMode: 'user' },
770
+ * });
771
+ */
772
+
773
+ interface WebcamOptions {
774
+ /** Character size in pixels. Default: 10 */
775
+ fontSize?: number;
776
+ /** Art style preset. Default: 'classic' */
777
+ style?: ArtStyle;
778
+ /** Extra AsciiOptions merged on top of the preset */
779
+ options?: Partial<AsciiOptions>;
780
+ /**
781
+ * Called every frame to get the latest options. Takes priority over `options`.
782
+ * Use this to keep the rendering in sync with live UI controls without
783
+ * restarting the camera.
784
+ *
785
+ * @example
786
+ * const optionsRef = useRef(currentOptions);
787
+ * optionsRef.current = currentOptions;
788
+ * asciifyWebcam(canvas, { liveOptions: () => optionsRef.current });
789
+ */
790
+ liveOptions?: () => Partial<AsciiOptions>;
791
+ /**
792
+ * Flip the output horizontally so it reads like a mirror / selfie camera.
793
+ * Default: true
794
+ */
795
+ mirror?: boolean;
796
+ /**
797
+ * Passed directly to `getUserMedia({ video: constraints })`.
798
+ * Defaults to `{ facingMode: 'user' }`.
799
+ */
800
+ constraints?: MediaTrackConstraints;
801
+ /**
802
+ * Device pixel ratio used to compute logical render dimensions from the
803
+ * canvas's physical pixel size. Defaults to `window.devicePixelRatio ?? 1`.
804
+ * Set this when you size the canvas at physical resolution (e.g. width × dpr)
805
+ * so that ASCII column/row counts are based on CSS pixels, not physical ones.
806
+ */
807
+ dpr?: number;
808
+ }
809
+ /**
810
+ * Start a live webcam ASCII-art loop and render it onto `canvas`.
811
+ * Returns a `stop()` function that cancels the loop and releases the camera.
812
+ *
813
+ * Throws if the browser doesn't support `getUserMedia` or the user denies
814
+ * camera permission.
815
+ */
816
+ declare function asciifyWebcam(canvas: HTMLCanvasElement, { fontSize, style, options, liveOptions, mirror, constraints, dpr: dprOverride, }?: WebcamOptions): Promise<() => void>;
817
+
818
+ export { ART_STYLE_PRESETS, type AnimationStyle, type ArtStyle, type AsciiBackgroundOptions, type AsciiCell, type AsciiFrame, type AsciiOptions, type AsciiResult, type AsciifySimpleOptions, type AuroraBackgroundOptions, CHARSETS, type CharsetKey, type CircuitBackgroundOptions, type ColorMode, DEFAULT_OPTIONS, type DnaBackgroundOptions, type FireBackgroundOptions, type GridBackgroundOptions, HOVER_PRESETS, type HoverEffect, type HoverPreset, type MorphBackgroundOptions, type MountWaveOptions, type NoiseBackgroundOptions, PALETTE_THEMES, type PaletteTheme, type PulseBackgroundOptions, type RainBackgroundOptions, type RenderMode, type SilkBackgroundOptions, type SnapshotOptions, type SourceType, type StarsBackgroundOptions, type TerrainBackgroundOptions, type TextBackgroundOptions, type VoidBackgroundOptions, type WaveBackgroundOptions, type WebcamOptions, asciiBackground, asciiText, asciiTextAnsi, asciify, asciifyGif, asciifyVideo, asciifyWebcam, buildTextFrame, captureSnapshot, gifToAsciiFrames, imageToAsciiFrame, mountWaveBackground, renderAuroraBackground, renderCircuitBackground, renderDnaBackground, renderFireBackground, renderFrameToCanvas, renderGridBackground, renderMorphBackground, renderNoiseBackground, renderPulseBackground, renderRainBackground, renderSilkBackground, renderStarsBackground, renderTerrainBackground, renderTextBackground, renderVoidBackground, renderWaveBackground, snapshotAndDownload, videoToAsciiFrames };
package/dist/index.d.ts CHANGED
@@ -711,66 +711,108 @@ declare function renderTextBackground(ctx: CanvasRenderingContext2D, width: numb
711
711
  intensity?: number;
712
712
  } | null): void;
713
713
 
714
- /**
715
- * record() — capture a rolling frame buffer from a running ASCII canvas
716
- * and export it as a downloadable animated GIF or WebP data URL.
717
- *
718
- * Usage:
719
- * ```ts
720
- * const recorder = createRecorder(canvasEl, { fps: 15, maxFrames: 120 });
721
- * recorder.start();
722
- * // ... after a few seconds ...
723
- * const dataUrl = await recorder.stop(); // 'data:image/gif;base64,...'
724
- * ```
725
- */
726
- interface RecorderOptions {
727
- /** Target capture frame rate (default: 15) */
728
- fps?: number;
729
- /** Maximum number of frames to buffer (default: 120 → 8 s at 15 fps) */
730
- maxFrames?: number;
714
+ interface SnapshotOptions {
731
715
  /**
732
- * Output format.
733
- * - 'gif' — animated GIF via gif.js worker (requires gif.worker.js in public/)
734
- * - 'webp' — animated WebP via MediaRecorder API (Chrome/Edge only)
735
- * - 'png-sequence' — returns a JSON array of PNG data URLs (universal)
736
- * Default: 'gif'
716
+ * Image format.
717
+ * - 'png' — lossless (default)
718
+ * - 'jpeg' — smaller, no transparency
719
+ * - 'webp' — best compression with transparency
737
720
  */
738
- format?: 'gif' | 'webp' | 'png-sequence';
739
- /** GIF quality 1 (best) – 30 (smallest) — only used for format:'gif' (default: 10) */
721
+ format?: 'png' | 'jpeg' | 'webp';
722
+ /** 0–1 quality for jpeg/webp. Default: 0.92 */
740
723
  quality?: number;
741
724
  /**
742
- * Scale factor applied to the canvas before capture (default: 1).
743
- * Use 0.5 to halve dimensions and reduce file size.
725
+ * Scale factor applied before capture. Default: 1.
726
+ * Use 2 for a high-resolution export at the canvas size.
744
727
  */
745
728
  scale?: number;
746
729
  }
747
- interface Recorder {
748
- /** Start capturing frames from the canvas. */
749
- start(): void;
750
- /**
751
- * Stop capturing and encode.
752
- * Resolves with a data URL (gif/webp) or JSON string (png-sequence).
753
- */
754
- stop(): Promise<string>;
755
- /** True while recording. */
756
- readonly isRecording: boolean;
757
- /** Number of frames captured so far. */
758
- readonly frameCount: number;
759
- }
760
730
  /**
761
- * Create a Recorder bound to a canvas element.
731
+ * Capture a single frame from a canvas as a Blob.
732
+ *
733
+ * @example
734
+ * ```ts
735
+ * const blob = await captureSnapshot(canvas);
736
+ * const url = URL.createObjectURL(blob);
737
+ * ```
762
738
  */
763
- declare function createRecorder(canvas: HTMLCanvasElement, options?: RecorderOptions): Recorder;
739
+ declare function captureSnapshot(canvas: HTMLCanvasElement, { format, quality, scale }?: SnapshotOptions): Promise<Blob>;
764
740
  /**
765
- * Convenience: record for a fixed duration and auto-download.
741
+ * Capture a single frame and immediately trigger a browser download.
766
742
  *
767
743
  * @example
768
744
  * ```ts
769
- * await recordAndDownload(canvasEl, 3000, { fps: 15, format: 'gif' });
745
+ * await snapshotAndDownload(canvas);
746
+ * await snapshotAndDownload(canvas, { format: 'jpeg', filename: 'my-art' });
770
747
  * ```
771
748
  */
772
- declare function recordAndDownload(canvas: HTMLCanvasElement, durationMs: number, options?: RecorderOptions & {
749
+ declare function snapshotAndDownload(canvas: HTMLCanvasElement, options?: SnapshotOptions & {
773
750
  filename?: string;
774
751
  }): Promise<void>;
775
752
 
776
- export { ART_STYLE_PRESETS, type AnimationStyle, type ArtStyle, type AsciiBackgroundOptions, type AsciiCell, type AsciiFrame, type AsciiOptions, type AsciiResult, type AsciifySimpleOptions, type AuroraBackgroundOptions, CHARSETS, type CharsetKey, type CircuitBackgroundOptions, type ColorMode, DEFAULT_OPTIONS, type DnaBackgroundOptions, type FireBackgroundOptions, type GridBackgroundOptions, HOVER_PRESETS, type HoverEffect, type HoverPreset, type MorphBackgroundOptions, type MountWaveOptions, type NoiseBackgroundOptions, PALETTE_THEMES, type PaletteTheme, type PulseBackgroundOptions, type RainBackgroundOptions, type Recorder, type RecorderOptions, type RenderMode, type SilkBackgroundOptions, type SourceType, type StarsBackgroundOptions, type TerrainBackgroundOptions, type TextBackgroundOptions, type VoidBackgroundOptions, type WaveBackgroundOptions, asciiBackground, asciiText, asciiTextAnsi, asciify, asciifyGif, asciifyVideo, buildTextFrame, createRecorder, gifToAsciiFrames, imageToAsciiFrame, mountWaveBackground, recordAndDownload, renderAuroraBackground, renderCircuitBackground, renderDnaBackground, renderFireBackground, renderFrameToCanvas, renderGridBackground, renderMorphBackground, renderNoiseBackground, renderPulseBackground, renderRainBackground, renderSilkBackground, renderStarsBackground, renderTerrainBackground, renderTextBackground, renderVoidBackground, renderWaveBackground, videoToAsciiFrames };
753
+ /**
754
+ * asciifyWebcam — live webcam → ASCII art on canvas.
755
+ *
756
+ * Requests camera access, attaches the stream to a hidden video element, and
757
+ * runs a rAF loop that converts each frame to ASCII and renders it onto a
758
+ * supplied canvas.
759
+ *
760
+ * @example
761
+ * const stop = await asciifyWebcam(canvas);
762
+ * // later: stop();
763
+ *
764
+ * @example
765
+ * const stop = await asciifyWebcam(canvas, {
766
+ * fontSize: 8,
767
+ * style: 'terminal',
768
+ * mirror: true, // horizontal flip (selfie mode)
769
+ * constraints: { facingMode: 'user' },
770
+ * });
771
+ */
772
+
773
+ interface WebcamOptions {
774
+ /** Character size in pixels. Default: 10 */
775
+ fontSize?: number;
776
+ /** Art style preset. Default: 'classic' */
777
+ style?: ArtStyle;
778
+ /** Extra AsciiOptions merged on top of the preset */
779
+ options?: Partial<AsciiOptions>;
780
+ /**
781
+ * Called every frame to get the latest options. Takes priority over `options`.
782
+ * Use this to keep the rendering in sync with live UI controls without
783
+ * restarting the camera.
784
+ *
785
+ * @example
786
+ * const optionsRef = useRef(currentOptions);
787
+ * optionsRef.current = currentOptions;
788
+ * asciifyWebcam(canvas, { liveOptions: () => optionsRef.current });
789
+ */
790
+ liveOptions?: () => Partial<AsciiOptions>;
791
+ /**
792
+ * Flip the output horizontally so it reads like a mirror / selfie camera.
793
+ * Default: true
794
+ */
795
+ mirror?: boolean;
796
+ /**
797
+ * Passed directly to `getUserMedia({ video: constraints })`.
798
+ * Defaults to `{ facingMode: 'user' }`.
799
+ */
800
+ constraints?: MediaTrackConstraints;
801
+ /**
802
+ * Device pixel ratio used to compute logical render dimensions from the
803
+ * canvas's physical pixel size. Defaults to `window.devicePixelRatio ?? 1`.
804
+ * Set this when you size the canvas at physical resolution (e.g. width × dpr)
805
+ * so that ASCII column/row counts are based on CSS pixels, not physical ones.
806
+ */
807
+ dpr?: number;
808
+ }
809
+ /**
810
+ * Start a live webcam ASCII-art loop and render it onto `canvas`.
811
+ * Returns a `stop()` function that cancels the loop and releases the camera.
812
+ *
813
+ * Throws if the browser doesn't support `getUserMedia` or the user denies
814
+ * camera permission.
815
+ */
816
+ declare function asciifyWebcam(canvas: HTMLCanvasElement, { fontSize, style, options, liveOptions, mirror, constraints, dpr: dprOverride, }?: WebcamOptions): Promise<() => void>;
817
+
818
+ export { ART_STYLE_PRESETS, type AnimationStyle, type ArtStyle, type AsciiBackgroundOptions, type AsciiCell, type AsciiFrame, type AsciiOptions, type AsciiResult, type AsciifySimpleOptions, type AuroraBackgroundOptions, CHARSETS, type CharsetKey, type CircuitBackgroundOptions, type ColorMode, DEFAULT_OPTIONS, type DnaBackgroundOptions, type FireBackgroundOptions, type GridBackgroundOptions, HOVER_PRESETS, type HoverEffect, type HoverPreset, type MorphBackgroundOptions, type MountWaveOptions, type NoiseBackgroundOptions, PALETTE_THEMES, type PaletteTheme, type PulseBackgroundOptions, type RainBackgroundOptions, type RenderMode, type SilkBackgroundOptions, type SnapshotOptions, type SourceType, type StarsBackgroundOptions, type TerrainBackgroundOptions, type TextBackgroundOptions, type VoidBackgroundOptions, type WaveBackgroundOptions, type WebcamOptions, asciiBackground, asciiText, asciiTextAnsi, asciify, asciifyGif, asciifyVideo, asciifyWebcam, buildTextFrame, captureSnapshot, gifToAsciiFrames, imageToAsciiFrame, mountWaveBackground, renderAuroraBackground, renderCircuitBackground, renderDnaBackground, renderFireBackground, renderFrameToCanvas, renderGridBackground, renderMorphBackground, renderNoiseBackground, renderPulseBackground, renderRainBackground, renderSilkBackground, renderStarsBackground, renderTerrainBackground, renderTextBackground, renderVoidBackground, renderWaveBackground, snapshotAndDownload, videoToAsciiFrames };
package/dist/index.js CHANGED
@@ -2283,146 +2283,145 @@ function renderTextBackground(ctx, width, height, text, options = {}, hoverPos)
2283
2283
  }
2284
2284
 
2285
2285
  // src/core/record.ts
2286
- function createRecorder(canvas, options = {}) {
2287
- const {
2288
- fps = 15,
2289
- maxFrames = 120,
2290
- format = "gif",
2291
- quality = 10,
2292
- scale = 1
2293
- } = options;
2294
- const interval = 1e3 / fps;
2295
- let recording = false;
2296
- let timerId = -1;
2297
- const blobs = [];
2298
- const captureFrame = () => {
2299
- if (!recording || blobs.length >= maxFrames) return;
2286
+ function captureSnapshot(canvas, { format = "png", quality = 0.92, scale = 1 } = {}) {
2287
+ return new Promise((resolve, reject) => {
2300
2288
  let src = canvas;
2301
2289
  if (scale !== 1) {
2302
2290
  const off = document.createElement("canvas");
2303
2291
  off.width = Math.round(canvas.width * scale);
2304
2292
  off.height = Math.round(canvas.height * scale);
2305
2293
  const offCtx = off.getContext("2d");
2294
+ if (!offCtx) {
2295
+ reject(new Error("captureSnapshot: could not get 2d context"));
2296
+ return;
2297
+ }
2306
2298
  offCtx.drawImage(canvas, 0, 0, off.width, off.height);
2307
2299
  src = off;
2308
2300
  }
2309
- blobs.push(src.toDataURL("image/png"));
2301
+ src.toBlob(
2302
+ (blob) => blob ? resolve(blob) : reject(new Error("captureSnapshot: toBlob returned null")),
2303
+ `image/${format}`,
2304
+ quality
2305
+ );
2306
+ });
2307
+ }
2308
+ async function snapshotAndDownload(canvas, options = {}) {
2309
+ const { filename = "asciify-snapshot", format = "png", ...snapOpts } = options;
2310
+ const blob = await captureSnapshot(canvas, { format, ...snapOpts });
2311
+ const ext = format === "jpeg" ? "jpg" : format;
2312
+ const a = document.createElement("a");
2313
+ a.href = URL.createObjectURL(blob);
2314
+ a.download = `${filename}.${ext}`;
2315
+ a.click();
2316
+ setTimeout(() => URL.revokeObjectURL(a.href), 1e4);
2317
+ }
2318
+
2319
+ // src/core/webcam.ts
2320
+ async function asciifyWebcam(canvas, {
2321
+ fontSize = 10,
2322
+ style = "classic",
2323
+ options = {},
2324
+ liveOptions,
2325
+ mirror = true,
2326
+ constraints = { facingMode: "user" },
2327
+ dpr: dprOverride
2328
+ } = {}) {
2329
+ if (!navigator.mediaDevices?.getUserMedia) {
2330
+ throw new Error("asciifyWebcam: getUserMedia is not supported in this browser.");
2331
+ }
2332
+ const stream = await navigator.mediaDevices.getUserMedia({ video: constraints });
2333
+ const video = document.createElement("video");
2334
+ video.srcObject = stream;
2335
+ video.muted = true;
2336
+ video.playsInline = true;
2337
+ await new Promise((resolve, reject) => {
2338
+ video.onloadedmetadata = () => resolve();
2339
+ video.onerror = () => reject(new Error("asciifyWebcam: video stream failed to load."));
2340
+ video.play().catch(reject);
2341
+ });
2342
+ const merged = {
2343
+ ...DEFAULT_OPTIONS,
2344
+ ...ART_STYLE_PRESETS[style],
2345
+ ...options,
2346
+ fontSize
2310
2347
  };
2311
- const encodeGif = async (frames) => {
2312
- return new Promise((resolve, reject) => {
2313
- if (typeof GIF === "undefined") {
2314
- reject(new Error('[asciify recorder] gif.js not found. Add <script src="/gif.worker.js"> to your page.'));
2315
- return;
2316
- }
2317
- const gif = new GIF({
2318
- workers: 2,
2319
- quality,
2320
- workerScript: "/gif.worker.js"
2321
- });
2322
- let loaded = 0;
2323
- const total = frames.length;
2324
- frames.forEach((dataUrl) => {
2325
- const img = new Image();
2326
- img.onload = () => {
2327
- gif.addFrame(img, { delay: interval, copy: true });
2328
- loaded++;
2329
- if (loaded === total) gif.render();
2330
- };
2331
- img.src = dataUrl;
2332
- });
2333
- gif.on("finished", (blob) => {
2334
- const reader = new FileReader();
2335
- reader.onload = () => resolve(reader.result);
2336
- reader.readAsDataURL(blob);
2337
- });
2338
- gif.on("error", reject);
2339
- });
2348
+ const ctx = canvas.getContext("2d");
2349
+ if (!ctx) throw new Error("asciifyWebcam: could not get 2d context from canvas.");
2350
+ const deviceRatio = dprOverride ?? (typeof window !== "undefined" ? window.devicePixelRatio : 1) ?? 1;
2351
+ if (deviceRatio !== 1) {
2352
+ ctx.scale(deviceRatio, deviceRatio);
2353
+ }
2354
+ let hoverPos = null;
2355
+ const smoothHover = { x: 0.5, y: 0.5, intensity: 0 };
2356
+ const onMouseMove = (e) => {
2357
+ const rect = canvas.getBoundingClientRect();
2358
+ hoverPos = {
2359
+ x: (e.clientX - rect.left) / rect.width,
2360
+ y: (e.clientY - rect.top) / rect.height
2361
+ };
2340
2362
  };
2341
- const encodeWebP = async (frames, _fps) => {
2342
- if (typeof MediaRecorder === "undefined") {
2343
- throw new Error("[asciify recorder] MediaRecorder not available in this browser.");
2344
- }
2345
- const off = document.createElement("canvas");
2346
- if (frames.length === 0) return "";
2347
- const probe = new Image();
2348
- await new Promise((res) => {
2349
- probe.onload = () => res();
2350
- probe.src = frames[0];
2351
- });
2352
- off.width = probe.naturalWidth;
2353
- off.height = probe.naturalHeight;
2354
- const offCtx = off.getContext("2d");
2355
- const stream = off.captureStream(_fps);
2356
- const recorder = new MediaRecorder(stream, { mimeType: "video/webm;codecs=vp9" });
2357
- const chunks = [];
2358
- recorder.ondataavailable = (e) => chunks.push(e.data);
2359
- return new Promise((resolve, reject) => {
2360
- recorder.onstop = () => {
2361
- const blob = new Blob(chunks, { type: "video/webm" });
2362
- const reader = new FileReader();
2363
- reader.onload = () => resolve(reader.result);
2364
- reader.readAsDataURL(blob);
2365
- };
2366
- recorder.onerror = reject;
2367
- recorder.start();
2368
- let idx = 0;
2369
- const drawNext = () => {
2370
- if (idx >= frames.length) {
2371
- recorder.stop();
2372
- return;
2373
- }
2374
- const img = new Image();
2375
- img.onload = () => {
2376
- offCtx.drawImage(img, 0, 0);
2377
- idx++;
2378
- setTimeout(drawNext, interval);
2379
- };
2380
- img.src = frames[idx];
2381
- };
2382
- drawNext();
2383
- });
2363
+ const onMouseLeave = () => {
2364
+ hoverPos = null;
2384
2365
  };
2385
- return {
2386
- get isRecording() {
2387
- return recording;
2388
- },
2389
- get frameCount() {
2390
- return blobs.length;
2391
- },
2392
- start() {
2393
- if (recording) return;
2394
- blobs.length = 0;
2395
- recording = true;
2396
- timerId = window.setInterval(captureFrame, interval);
2397
- },
2398
- async stop() {
2399
- if (!recording) return "";
2400
- recording = false;
2401
- clearInterval(timerId);
2402
- const frames = blobs.slice();
2403
- if (format === "png-sequence") {
2404
- return JSON.stringify(frames);
2366
+ if (merged.hoverStrength > 0) {
2367
+ canvas.addEventListener("mousemove", onMouseMove);
2368
+ canvas.addEventListener("mouseleave", onMouseLeave);
2369
+ }
2370
+ let cancelled = false;
2371
+ let animId;
2372
+ const startTime = performance.now();
2373
+ const tick = (timestamp) => {
2374
+ if (cancelled) return;
2375
+ if (video.readyState >= video.HAVE_CURRENT_DATA) {
2376
+ const displayW = canvas.width / deviceRatio;
2377
+ const displayH = canvas.height / deviceRatio;
2378
+ const elapsed = (timestamp - startTime) / 1e3;
2379
+ const frameOptions = liveOptions ? { ...merged, ...liveOptions() } : merged;
2380
+ const wantsHover = frameOptions.hoverStrength > 0;
2381
+ if (wantsHover) {
2382
+ canvas.addEventListener("mousemove", onMouseMove);
2383
+ canvas.addEventListener("mouseleave", onMouseLeave);
2384
+ } else {
2385
+ canvas.removeEventListener("mousemove", onMouseMove);
2386
+ canvas.removeEventListener("mouseleave", onMouseLeave);
2387
+ }
2388
+ const { frame } = imageToAsciiFrame(video, frameOptions, displayW, displayH);
2389
+ if (hoverPos) {
2390
+ const dx = hoverPos.x - smoothHover.x;
2391
+ const dy = hoverPos.y - smoothHover.y;
2392
+ const dist = Math.sqrt(dx * dx + dy * dy);
2393
+ const speed = Math.min(0.25, 0.06 + dist * 0.8);
2394
+ smoothHover.x += dx * speed;
2395
+ smoothHover.y += dy * speed;
2396
+ smoothHover.intensity += (1 - smoothHover.intensity) * 0.12;
2397
+ } else {
2398
+ smoothHover.intensity *= 0.965;
2399
+ if (smoothHover.intensity < 3e-3) smoothHover.intensity = 0;
2405
2400
  }
2406
- if (format === "webp") {
2407
- return encodeWebP(frames, fps);
2401
+ const hoverArg = smoothHover.intensity > 3e-3 ? { x: smoothHover.x, y: smoothHover.y, intensity: smoothHover.intensity } : null;
2402
+ if (mirror) {
2403
+ ctx.save();
2404
+ ctx.scale(-1, 1);
2405
+ ctx.translate(-displayW, 0);
2406
+ renderFrameToCanvas(ctx, frame, frameOptions, displayW, displayH, elapsed, hoverArg);
2407
+ ctx.restore();
2408
+ } else {
2409
+ renderFrameToCanvas(ctx, frame, frameOptions, displayW, displayH, elapsed, hoverArg);
2408
2410
  }
2409
- return encodeGif(frames);
2410
2411
  }
2412
+ animId = requestAnimationFrame(tick);
2413
+ };
2414
+ animId = requestAnimationFrame(tick);
2415
+ return () => {
2416
+ cancelled = true;
2417
+ cancelAnimationFrame(animId);
2418
+ canvas.removeEventListener("mousemove", onMouseMove);
2419
+ canvas.removeEventListener("mouseleave", onMouseLeave);
2420
+ stream.getTracks().forEach((t) => t.stop());
2421
+ video.srcObject = null;
2411
2422
  };
2412
- }
2413
- async function recordAndDownload(canvas, durationMs, options = {}) {
2414
- const { filename = "asciify-recording", ...recOpts } = options;
2415
- const recorder = createRecorder(canvas, recOpts);
2416
- recorder.start();
2417
- await new Promise((res) => setTimeout(res, durationMs));
2418
- const dataUrl = await recorder.stop();
2419
- const ext = options.format === "webp" ? "webm" : options.format === "png-sequence" ? "json" : "gif";
2420
- const a = document.createElement("a");
2421
- a.href = dataUrl;
2422
- a.download = `${filename}.${ext}`;
2423
- a.click();
2424
2423
  }
2425
2424
 
2426
- export { ART_STYLE_PRESETS, CHARSETS, DEFAULT_OPTIONS, HOVER_PRESETS, PALETTE_THEMES, asciiBackground, asciiText, asciiTextAnsi, asciify, asciifyGif, asciifyVideo, buildTextFrame, createRecorder, gifToAsciiFrames, imageToAsciiFrame, mountWaveBackground, recordAndDownload, renderAuroraBackground, renderCircuitBackground, renderDnaBackground, renderFireBackground, renderFrameToCanvas, renderGridBackground, renderMorphBackground, renderNoiseBackground, renderPulseBackground, renderRainBackground, renderSilkBackground, renderStarsBackground, renderTerrainBackground, renderTextBackground, renderVoidBackground, renderWaveBackground, videoToAsciiFrames };
2425
+ export { ART_STYLE_PRESETS, CHARSETS, DEFAULT_OPTIONS, HOVER_PRESETS, PALETTE_THEMES, asciiBackground, asciiText, asciiTextAnsi, asciify, asciifyGif, asciifyVideo, asciifyWebcam, buildTextFrame, captureSnapshot, gifToAsciiFrames, imageToAsciiFrame, mountWaveBackground, renderAuroraBackground, renderCircuitBackground, renderDnaBackground, renderFireBackground, renderFrameToCanvas, renderGridBackground, renderMorphBackground, renderNoiseBackground, renderPulseBackground, renderRainBackground, renderSilkBackground, renderStarsBackground, renderTerrainBackground, renderTextBackground, renderVoidBackground, renderWaveBackground, snapshotAndDownload, videoToAsciiFrames };
2427
2426
  //# sourceMappingURL=index.js.map
2428
2427
  //# sourceMappingURL=index.js.map