diff --git a/README.MD b/README.MD index 8478730..1e1bcfb 100644 --- a/README.MD +++ b/README.MD @@ -303,6 +303,18 @@ Frames are sampled at the target `--fps`, converted through the braille pipeline Per-frame rendering is configurable: `--mode glyph` uses character matching instead of braille, and `--matrix` (with `--matrix-color`, `--matrix-seed`, `--matrix-gamma`, `--matrix-mask`) renders each frame as matrix glyphs driven by that frame — the subject moves through the rain. Seeds advance per frame, so seeded runs are reproducible. +### Webcam (live ASCII mirror) + +Point the video command at a camera device and it streams to your terminal live until Ctrl-C — mirrored like a real mirror (`--no-mirror` to disable), with all the same mode/matrix/caption knobs: + +```bash +ascii-magic video "" # live mirror +ascii-magic video "" --matrix --matrix-color green # you, in the Matrix +ascii-magic video "" selfie.gif --seconds 5 # record 5s to an ASCII GIF +``` + +Camera recordings are silent (`.mp4` output works but has no audio path). + The web GUI has a matching **Video** tab: pick a file, set width/fps/frame cap and frame mode, press Render (video renders take a few seconds — no live auto-render), preview the animated result, and download `.gif` or `.frames`. The Matrix panel applies to video too. ## Login greeting (`ascii-magic-greet`) diff --git a/src/asciimagic/video.py b/src/asciimagic/video.py index af870d0..28ba749 100644 --- a/src/asciimagic/video.py +++ b/src/asciimagic/video.py @@ -15,7 +15,9 @@ import dataclasses import io import random +import re import sys +import time from typing import List, Optional, Tuple import numpy as np @@ -38,6 +40,24 @@ # Braille blank: no ink to draw _BLANKS = (" ", "⠀") +# ffmpeg camera device syntax, e.g. (first webcam) +CAMERA_RE = re.compile(r"^$") + +ESC_CLEAR = "\x1b[2J\x1b[H" +ESC_HIDE = "\x1b[?25l" +ESC_SHOW = "\x1b[?25h" + + +def is_camera(source: str) -> bool: + return bool(CAMERA_RE.match(source)) + + +def _arr_to_image(arr) -> Image.Image: + arr = np.asarray(arr) + if arr.ndim == 2: + return Image.fromarray(arr, mode="L").convert("RGB") + return Image.fromarray(arr[:, :, :3], mode="RGB") + def _require_imageio(): try: @@ -93,12 +113,7 @@ def read_video_frames( continue if len(frames) >= max_frames: break - arr = np.asarray(arr) - if arr.ndim == 2: - img = Image.fromarray(arr, mode="L").convert("RGB") - else: - img = Image.fromarray(arr[:, :, :3], mode="RGB") - frames.append(img) + frames.append(_arr_to_image(arr)) finally: reader.close() @@ -293,48 +308,165 @@ def video_to_ascii( caption=None, # colorize_ascii.CaptionOptions ) -> AsciiVideo: frames, out_fps = read_video_frames(path, sample_fps=sample_fps, max_frames=max_frames) + converted = _convert_frames( + frames, cols=cols, mode=mode, quality=quality, dither=dither, + threshold=threshold, gamma=gamma, autocontrast=autocontrast, invert=invert, + ) + cap_render = _resolve_video_caption(caption, converted, matrix) + return AsciiVideo(converted, out_fps, matrix=matrix, caption=cap_render) + + +def _convert_frame(img: Image.Image, *, cols, mode, quality, charset, + dither, threshold, gamma, autocontrast, invert) -> List[str]: + if mode == "glyph": + art = image_to_text_glyph_from_image( + img=img, cols=cols, cell_w=8, cell_h=16, charset=charset, + quality=quality, font_path=None, font_size=None, + autocontrast=autocontrast, gamma=gamma, invert=invert, topk=24, + ) + else: + art = image_to_braille_from_image( + img, cols=cols, autocontrast=autocontrast, gamma=gamma, + invert=invert, threshold=threshold, dither=dither, + ) + return art.splitlines() + + +def _convert_frames(frames, *, cols, mode, quality, dither, threshold, + gamma, autocontrast, invert): + charset = make_charset(unicode_mode="off", ascii_preset="dense") if mode == "glyph" else None + return [ + ( + _convert_frame( + img, cols=cols, mode=mode, quality=quality, charset=charset, + dither=dither, threshold=threshold, gamma=gamma, + autocontrast=autocontrast, invert=invert, + ), + img, + ) + for img in frames + ] + + +def _resolve_video_caption(caption, converted, matrix): + """Image-colored captions resolve against the first frame; otherwise a + neutral light default (or the matrix tint when matrix is on).""" + if caption is None or not caption.text or not converted: + return None + from .animate import _resolve_caption + + first_lines, first_img = converted[0] + width = max(len(ln) for ln in first_lines) + tint = matrix.tint if (matrix and matrix.enabled) else (224, 224, 224) + return _resolve_caption(caption, first_img, width, tint) + + +def live_view( + source: str, + cols: int = 100, + mode: str = "braille", + quality: str = "balanced", + dither: bool = True, + threshold: float = 0.5, + gamma: float = 1.0, + autocontrast: bool = False, + invert: bool = False, + matrix: Optional[MatrixOptions] = None, + caption=None, + mirror: bool = True, + out=None, + max_frames: Optional[int] = None, +) -> int: + """Stream a camera (or any source) to the terminal as live ASCII until + Ctrl-C. Returns the number of frames shown. `max_frames`/`out` exist for + testing; interactive use leaves them unset.""" + iio = _require_imageio() + out = out or sys.stdout + matrix = matrix if (matrix and matrix.enabled) else None charset = make_charset(unicode_mode="off", ascii_preset="dense") if mode == "glyph" else None - converted = [] - for img in frames: - if mode == "glyph": - art = image_to_text_glyph_from_image( - img=img, - cols=cols, - cell_w=8, - cell_h=16, - charset=charset, - quality=quality, - font_path=None, - font_size=None, - autocontrast=autocontrast, - gamma=gamma, - invert=invert, - topk=24, - ) - else: - art = image_to_braille_from_image( - img, - cols=cols, - autocontrast=autocontrast, - gamma=gamma, - invert=invert, - threshold=threshold, - dither=dither, - ) - converted.append((art.splitlines(), img)) + reader = iio.get_reader(source) + shown = 0 + cap_rows = None cap_render = None - if caption is not None and caption.text and converted: - from .animate import _resolve_caption + try: + out.write(f"{ESC_CLEAR}{ESC_HIDE}") + for arr in reader: + img = _arr_to_image(arr) + if mirror: + img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + lines = _convert_frame( + img, cols=cols, mode=mode, quality=quality, charset=charset, + dither=dither, threshold=threshold, gamma=gamma, + autocontrast=autocontrast, invert=invert, + ) + if caption is not None and cap_render is None and caption.text: + cap_render = _resolve_video_caption(caption, [(lines, img)], matrix) + if cap_render is not None: + from .animate import caption_rows_ansi + + cap_rows = caption_rows_ansi(cap_render) + if matrix: + mi = dataclasses.replace( + matrix, seed=(matrix.seed + shown) if matrix.seed is not None else None + ) + rows = matrix_lines_ansi(lines, img, mi) + else: + rows = colorize_lines_ansi(lines, img, color_spaces=False) + if cap_rows is not None: + from .animate import with_caption_rows + + rows = with_caption_rows(list(rows), cap_render, cap_rows) + out.write("\x1b[H" + "\n".join(rows) + "\x1b[0m") + out.flush() + shown += 1 + if max_frames is not None and shown >= max_frames: + break + except KeyboardInterrupt: + pass + finally: + reader.close() + try: + out.write(f"\x1b[0m{ESC_SHOW}\n") + out.flush() + except BrokenPipeError: + pass + return shown + + +def record_camera( + source: str, + seconds: float = 5.0, + mirror: bool = True, + **convert_kwargs, +) -> AsciiVideo: + """Capture a camera for `seconds` of wall time, then convert like a file.""" + iio = _require_imageio() + matrix = convert_kwargs.pop("matrix", None) + caption = convert_kwargs.pop("caption", None) - first_lines, first_img = converted[0] - width = max(len(ln) for ln in first_lines) - # Image-colored captions resolve against the first frame; otherwise a - # neutral light default (or the matrix tint when matrix is on). - tint = matrix.tint if (matrix and matrix.enabled) else (224, 224, 224) - cap_render = _resolve_caption(caption, first_img, width, tint) + reader = iio.get_reader(source) + frames: List[Image.Image] = [] + t0 = time.monotonic() + try: + for arr in reader: + img = _arr_to_image(arr) + if mirror: + img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + frames.append(img) + # wall-clock bound, plus a hard cap for absurdly fast sources + if time.monotonic() - t0 >= seconds or len(frames) >= 1800: + break + finally: + reader.close() + if not frames: + raise RuntimeError(f"No frames could be read from {source}") - return AsciiVideo(converted, out_fps, matrix=matrix, caption=cap_render) + elapsed = max(time.monotonic() - t0, 1e-3) + fps = max(1.0, len(frames) / elapsed) + converted = _convert_frames(frames, **convert_kwargs) + cap_render = _resolve_video_caption(caption, converted, matrix) + return AsciiVideo(converted, fps, matrix=matrix, caption=cap_render) def build_arg_parser() -> argparse.ArgumentParser: @@ -343,10 +475,10 @@ def build_arg_parser() -> argparse.ArgumentParser: description="Convert a video into a colorized ASCII animation " "(any format ffmpeg reads: mp4, webm, mov, mkv, avi, gif, ...).", ) - ap.add_argument("input", help="Video file") + ap.add_argument("input", help="Video file, or a camera like '' for a live mirror") ap.add_argument("out", nargs="?", default=None, help="Output: .gif, .mp4 (keeps the source audio), or .frames " - "(omit to play in the terminal)") + "(omit to play in the terminal; with a camera, omit to mirror live)") ap.add_argument("-c", "--cols", type=int, default=100, help="Width in characters") ap.add_argument("--fps", type=float, default=10.0, help="Target sample/playback fps (default: 10)") @@ -367,6 +499,10 @@ def build_arg_parser() -> argparse.ArgumentParser: help="Bias matrix glyphs toward inked ASCII cells") ap.add_argument("--no-audio", action="store_true", help=".mp4 output: skip muxing the source audio") + ap.add_argument("--seconds", type=float, default=5.0, metavar="F", + help="Camera sources with an output file: how long to record (default: 5)") + ap.add_argument("--mirror", action=argparse.BooleanOptionalAction, default=True, + help="Camera sources: flip horizontally like a mirror (default: on)") ap.add_argument("--caption", default=None, metavar="TEXT", help="Render TEXT as ASCII and stitch it onto every frame") ap.add_argument("--caption-pos", choices=["top", "bottom"], default="bottom") @@ -421,21 +557,39 @@ def main(argv: Optional[List[str]] = None) -> int: ) try: - video = video_to_ascii( - args.input, - cols=args.cols, - sample_fps=args.fps, - max_frames=args.max_frames, - dither=not args.no_dither, - threshold=args.threshold, - gamma=args.gamma, - autocontrast=args.autocontrast, - invert=args.invert, - mode=args.mode, - quality=args.quality, - matrix=matrix, - caption=caption, - ) + if is_camera(args.input): + if args.out is None: + live_view( + args.input, cols=args.cols, mode=args.mode, quality=args.quality, + dither=not args.no_dither, threshold=args.threshold, + gamma=args.gamma, autocontrast=args.autocontrast, + invert=args.invert, matrix=matrix, caption=caption, + mirror=args.mirror, + ) + return 0 + video = record_camera( + args.input, seconds=args.seconds, mirror=args.mirror, + cols=args.cols, mode=args.mode, quality=args.quality, + dither=not args.no_dither, threshold=args.threshold, + gamma=args.gamma, autocontrast=args.autocontrast, + invert=args.invert, matrix=matrix, caption=caption, + ) + else: + video = video_to_ascii( + args.input, + cols=args.cols, + sample_fps=args.fps, + max_frames=args.max_frames, + dither=not args.no_dither, + threshold=args.threshold, + gamma=args.gamma, + autocontrast=args.autocontrast, + invert=args.invert, + mode=args.mode, + quality=args.quality, + matrix=matrix, + caption=caption, + ) except RuntimeError as e: raise SystemExit(str(e)) @@ -448,7 +602,8 @@ def main(argv: Optional[List[str]] = None) -> int: elif args.out.lower().endswith(".mp4"): with_audio = video.write_mp4( args.out, - audio_source=None if args.no_audio else args.input, + # cameras have no muxable audio path; record silent + audio_source=None if (args.no_audio or is_camera(args.input)) else args.input, font_size=args.font_size, ) note = "with source audio" if with_audio else "silent (no usable audio in source)" diff --git a/tests/test_video.py b/tests/test_video.py index 41770f2..a97d093 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -136,6 +136,86 @@ def test_video_mp4_output(clip, tmp_path): assert b"ftyp" in data[:64] # mp4 container signature +class _FakeReader: + """Stands in for imageio's camera reader: yields moving-dot frames.""" + + def __init__(self, n=40): + self.n = n + + def __iter__(self): + import numpy as np + + for i in range(self.n): + f = np.full((32, 48, 3), 15, dtype=np.uint8) + f[10:20, (i * 4) % 40:(i * 4) % 40 + 8] = (240, 160, 60) + yield f + + def get_meta_data(self): + return {"fps": 30} + + def close(self): + pass + + +@pytest.fixture +def fake_camera(monkeypatch): + class _IIO: + @staticmethod + def get_reader(source): + return _FakeReader() + + monkeypatch.setattr(video_mod, "_require_imageio", lambda: _IIO) + + +def test_is_camera(): + assert video_mod.is_camera("") + assert video_mod.is_camera("") + assert not video_mod.is_camera("clip.mp4") + assert not video_mod.is_camera("") + + +def test_live_view_streams_and_restores(fake_camera): + import io as _io + + buf = _io.StringIO() + shown = video_mod.live_view("", cols=16, out=buf, max_frames=4) + assert shown == 4 + out = buf.getvalue() + assert out.startswith(video_mod.ESC_CLEAR + video_mod.ESC_HIDE) + assert out.count("\x1b[H") >= 4 # cursor-home per frame + assert out.endswith(video_mod.ESC_SHOW + "\n") + assert "\x1b[38;2;" in out # colorized + + +def test_live_view_mirror_flips(fake_camera): + import io as _io + import re + + strip = lambda s: re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", s) + a = _io.StringIO() + b = _io.StringIO() + video_mod.live_view("", cols=16, out=a, max_frames=1, mirror=False) + video_mod.live_view("", cols=16, out=b, max_frames=1, mirror=True) + assert strip(a.getvalue()) != strip(b.getvalue()) + + +def test_record_camera(fake_camera): + v = video_mod.record_camera( + "", seconds=0.05, cols=16, mode="braille", quality="balanced", + dither=False, threshold=0.5, gamma=1.0, autocontrast=False, invert=False, + ) + assert len(v.frames) >= 1 + assert v.fps >= 1.0 + assert v.to_gif_bytes()[:4] == b"GIF8" + + +def test_camera_cli_records_to_gif(fake_camera, tmp_path): + out = tmp_path / "cam.gif" + rc = video_mod.main(["", str(out), "-c", "16", "--seconds", "0.05"]) + assert rc == 0 + assert out.read_bytes()[:4] == b"GIF8" + + def test_video_matrix_cli_flags(clip, tmp_path): out = tmp_path / "m.frames" rc = video_mod.main([