Images
Video files, streams or the webcam, converted every frame.
Frames stay in your browser. Nothing is uploaded.
Controls
pnpm dlx shadcn@latest add @asciicn/ascii-videoPulls in the engine at lib/ascii on first install. The @asciicn namespace needs one line in components.json, see Installation.
Install the engine first with npx shadcn@latest add @asciicn/ascii-core, or copy lib/ascii from the repository.
Copy this into components/ascii/ascii-video.tsx:
"use client"
import { useEffect, useEffectEvent, useState } from "react"
import { AsciiCanvas, type AsciiCanvasProps } from "@/lib/ascii"
import { cn } from "@/lib/utils"
export type AsciiVideoProps = Omit<AsciiCanvasProps, "source" | "mirror"> & {
/** A video URL, a MediaStream, or "webcam" to ask for the user's camera. */
src: string | MediaStream
/** Mirror horizontally. Defaults to true for the webcam. */
mirror?: boolean
onError?: (error: unknown) => void
}
export function AsciiVideo({ src, mirror, onError, className, ...props }: AsciiVideoProps) {
const [video, setVideo] = useState<HTMLVideoElement | null>(null)
const [aspect, setAspect] = useState(16 / 9)
const fail = useEffectEvent((error: unknown) => onError?.(error))
useEffect(() => {
const el = document.createElement("video")
el.muted = true
el.loop = true
el.playsInline = true
el.crossOrigin = "anonymous"
let stream: MediaStream | null = null
let cancelled = false
el.onloadeddata = () => {
setAspect(el.videoWidth / el.videoHeight)
setVideo(el)
el.play().catch(fail)
}
if (src === "webcam") {
navigator.mediaDevices
.getUserMedia({ video: { facingMode: "user" } })
.then((s) => {
if (cancelled) return s.getTracks().forEach((t) => t.stop())
stream = s
el.srcObject = s
})
.catch(fail)
} else if (typeof src === "string") {
el.src = src
} else {
el.srcObject = src
}
return () => {
cancelled = true
el.pause()
el.onloadeddata = null
stream?.getTracks().forEach((t) => t.stop())
setVideo(null)
}
}, [src])
return (
<div className={cn("relative w-full overflow-hidden", className)} style={{ aspectRatio: aspect }}>
<AsciiCanvas source={video} mirror={mirror ?? src === "webcam"} className="absolute inset-0" {...props} />
</div>
)
}
import { AsciiVideo } from "@/components/ascii/ascii-video"
<AsciiVideo src="/clips/surf.mp4" dither="bayer" />
<AsciiVideo src="webcam" aria-label="Your camera" />| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | MediaStream | "webcam" | — | A URL, a stream, or the user's camera. |
| mirror | boolean | webcam only | Flip horizontally. |
| onError | (error) => void | — | Called when playback or camera access fails. |
| Prop | Type | Default | Description |
|---|---|---|---|
| charset | CharsetName | string | "standard" | Named ramp or your own string, empty to dense. "braille" packs 2×4 dots per cell, "quadrants" 2×2 blocks. |
| fontSize | number | 12 | Cell size in CSS pixels. Smaller means more detail. |
| dither | "none" | "bayer" | "floyd" | "none" | Quantisation. Floyd–Steinberg for photos, Bayer for a retro look. |
| color | "ink" | "source" | string[] | "ink" | Ink uses the CSS colour (theme with text-*). Source keeps pixel colours. An array maps tone onto gradient stops, e.g. palettes.thermal. |
| edges | number | 0 | Outline contours with | / - \ where the tone gradient beats this strength. Try 0.3. Ramps only. |
| opacity | boolean | false | Fade each glyph by its tone. |
| brightness | number | 0 | Added after contrast, -1 to 1. |
| contrast | number | 1 | Multiplier around mid grey. |
| gamma | number | 1 | Tone curve exponent. |
| invert | boolean | false | Swap ink and blank. |
| animate | boolean | auto | On for fields, frames and video. Off under reduced motion, except video. |
| fps | number | 30 | Frame cap. |
| ref | Ref<AsciiHandle> | — | Exposes text(), svg(), png() and the canvas. |
src changes.aria-label to say what the video shows. The underlying <video> is muted and never rendered.prefers-reduced-motion, since the user chose to start it. Drawing pauses offscreen.