terminal-pixel-animation 0.3.5

Render pixel images as Unicode characters (Braille / Half-block) in the terminal with True Color support
import { useState, useEffect, useRef, useCallback, createContext, useContext } from "react";
import type { ReactNode } from "react";
import init, {
  render_braille as wasmRenderBraille,
  render_half_block as wasmRenderHalfBlock,
} from "terminal-pixel-animation";

// ── Wasm Context ──────────────────────────────────────────────────────────────

interface WasmState {
  ready: boolean;
  error: Error | null;
}

const WasmContext = createContext<WasmState>({ ready: false, error: null });

export interface WasmProviderProps {
  children: ReactNode;
  /** Optional URL/path to the .wasm file. If omitted, uses default from import.meta.url. */
  wasmUrl?: string;
}

export function WasmProvider({ children, wasmUrl }: WasmProviderProps) {
  const [state, setState] = useState<WasmState>({ ready: false, error: null });

  useEffect(() => {
    let cancelled = false;
    init(wasmUrl)
      .then(() => {
        if (!cancelled) setState({ ready: true, error: null });
      })
      .catch((err) => {
        if (!cancelled)
          setState({ ready: false, error: err instanceof Error ? err : new Error(String(err)) });
      });
    return () => {
      cancelled = true;
    };
  }, [wasmUrl]);

  return <WasmContext.Provider value={state}>{children}</WasmContext.Provider>;
}

export function useWasm(): WasmState {
  return useContext(WasmContext);
}

// ── Braille ───────────────────────────────────────────────────────────────────

export interface BrailleCell {
  char: string;
  r: number;
  g: number;
  b: number;
}

export interface UseBrailleResult {
  cells: Uint8Array | null;
  decoded: BrailleCell[] | null;
  loading: boolean;
  error: Error | null;
}

export function useBraille(
  pixels: Uint8Array | null,
  inWidth: number,
  inHeight: number,
  cols: number,
  rows: number
): UseBrailleResult {
  const { ready, error: wasmError } = useWasm();
  const [result, setResult] = useState<UseBrailleResult>({
    cells: null,
    decoded: null,
    loading: false,
    error: null,
  });
  const prevPixelsRef = useRef<Uint8Array | null>(null);

  const render = useCallback(() => {
    if (!ready || !pixels || inWidth === 0 || inHeight === 0 || cols === 0 || rows === 0) {
      return;
    }

    if (pixels === prevPixelsRef.current) return;
    prevPixelsRef.current = pixels;

    try {
      setResult((prev) => ({ ...prev, loading: true, error: null }));
      const cells = wasmRenderBraille(pixels, inWidth, inHeight, cols, rows);
      const decoded = decodeBrailleCells(cells, cols, rows);
      setResult({ cells, decoded, loading: false, error: null });
    } catch (err) {
      setResult({
        cells: null,
        decoded: null,
        loading: false,
        error: err instanceof Error ? err : new Error(String(err)),
      });
    }
  }, [ready, pixels, inWidth, inHeight, cols, rows]);

  useEffect(() => {
    render();
  }, [render]);

  if (wasmError) {
    return { cells: null, decoded: null, loading: false, error: wasmError };
  }

  return result;
}

function decodeBrailleCells(cells: Uint8Array, cols: number, rows: number): BrailleCell[] {
  const result: BrailleCell[] = [];
  for (let row = 0; row < rows; row++) {
    for (let col = 0; col < cols; col++) {
      const idx = (row * cols + col) * 8;
      const cp = cells[idx] | (cells[idx + 1] << 8) | (cells[idx + 2] << 16) | (cells[idx + 3] << 24);
      result.push({
        char: String.fromCodePoint(cp),
        r: cells[idx + 4],
        g: cells[idx + 5],
        b: cells[idx + 6],
      });
    }
  }
  return result;
}

// ── Half-block ────────────────────────────────────────────────────────────────

export interface HalfBlockCell {
  rFg: number;
  gFg: number;
  bFg: number;
  rBg: number;
  gBg: number;
  bBg: number;
}

export interface UseHalfBlockResult {
  cells: Uint8Array | null;
  decoded: HalfBlockCell[] | null;
  loading: boolean;
  error: Error | null;
}

export function useHalfBlock(
  pixels: Uint8Array | null,
  inWidth: number,
  inHeight: number,
  cols: number,
  rows: number
): UseHalfBlockResult {
  const { ready, error: wasmError } = useWasm();
  const [result, setResult] = useState<UseHalfBlockResult>({
    cells: null,
    decoded: null,
    loading: false,
    error: null,
  });
  const prevPixelsRef = useRef<Uint8Array | null>(null);

  const render = useCallback(() => {
    if (!ready || !pixels || inWidth === 0 || inHeight === 0 || cols === 0 || rows === 0) {
      return;
    }

    if (pixels === prevPixelsRef.current) return;
    prevPixelsRef.current = pixels;

    try {
      setResult((prev) => ({ ...prev, loading: true, error: null }));
      const cells = wasmRenderHalfBlock(pixels, inWidth, inHeight, cols, rows);
      const decoded = decodeHalfBlockCells(cells, cols, rows);
      setResult({ cells, decoded, loading: false, error: null });
    } catch (err) {
      setResult({
        cells: null,
        decoded: null,
        loading: false,
        error: err instanceof Error ? err : new Error(String(err)),
      });
    }
  }, [ready, pixels, inWidth, inHeight, cols, rows]);

  useEffect(() => {
    render();
  }, [render]);

  if (wasmError) {
    return { cells: null, decoded: null, loading: false, error: wasmError };
  }

  return result;
}

function decodeHalfBlockCells(cells: Uint8Array, cols: number, rows: number): HalfBlockCell[] {
  const result: HalfBlockCell[] = [];
  for (let row = 0; row < rows; row++) {
    for (let col = 0; col < cols; col++) {
      const idx = (row * cols + col) * 6;
      result.push({
        rFg: cells[idx],
        gFg: cells[idx + 1],
        bFg: cells[idx + 2],
        rBg: cells[idx + 3],
        gBg: cells[idx + 4],
        bBg: cells[idx + 5],
      });
    }
  }
  return result;
}