From 32c584920d5fc62d8d29c2f79d23aef0b8481cc7 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 7 Jul 2026 11:14:29 -0400 Subject: [PATCH 1/2] feat: mp4 download in the web GUI (encode on demand) New POST /api/render/mp4 re-runs the video conversion and streams back an h264 mp4 with the source's audio muxed in - called only when the user clicks the new .mp4 download button, so the preview loop stays fast. The upload/convert core is shared with /api/render via _video_from_upload (which keeps the temp source alive for audio muxing). Co-Authored-By: Claude Fable 5 --- src/asciimagic/static/app.js | 23 +++++++++++++ src/asciimagic/static/index.html | 1 + src/asciimagic/webapp.py | 57 ++++++++++++++++++++++++++++++-- tests/test_webapp.py | 18 ++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/asciimagic/static/app.js b/src/asciimagic/static/app.js index ce4bb0c..8efe751 100644 --- a/src/asciimagic/static/app.js +++ b/src/asciimagic/static/app.js @@ -146,6 +146,7 @@ async function render() { for (const b of ["dl-ans", "dl-html", "dl-txt"]) $(b).disabled = false; $("dl-gif").disabled = !body.gif_b64; $("dl-frames").disabled = !body.frames_text; + $("dl-mp4").disabled = !(state.tab === "video" && body.video); let msg; if (body.video) { @@ -196,6 +197,28 @@ $("dl-gif").addEventListener("click", () => { $("dl-frames").addEventListener("click", () => download(`${state.fileStem}.frames`, state.result.frames_text, "text/plain")); +$("dl-mp4").addEventListener("click", async () => { + if (!state.videoFile) return; + $("dl-mp4").disabled = true; + setStatus("Encoding mp4 with audio… this takes a few seconds", "busy"); + try { + const form = new FormData(); + form.append("image", state.videoFile); + form.append("options", JSON.stringify(collectOptions())); + const res = await fetch("/api/render/mp4", { method: "POST", body: form }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.detail || `HTTP ${res.status}`); + } + download(`${state.fileStem}.mp4`, await res.blob(), "video/mp4"); + setStatus("mp4 downloaded", ""); + } catch (err) { + setStatus(`Error: ${err.message}`, "error"); + } finally { + $("dl-mp4").disabled = false; + } +}); + // ---------- sizing ---------- function autoSize() { diff --git a/src/asciimagic/static/index.html b/src/asciimagic/static/index.html index 0d40972..676360b 100644 --- a/src/asciimagic/static/index.html +++ b/src/asciimagic/static/index.html @@ -25,6 +25,7 @@

ASCIIMagic

+ diff --git a/src/asciimagic/webapp.py b/src/asciimagic/webapp.py index c7dabae..d0f7ff4 100644 --- a/src/asciimagic/webapp.py +++ b/src/asciimagic/webapp.py @@ -135,8 +135,10 @@ def health() -> dict[str, str]: return {"status": "ok", "version": __version__} -def _render_video(upload: Optional[UploadFile], o: dict[str, Any], t0: float) -> dict[str, Any]: - import base64 as b64mod +def _video_from_upload(upload: Optional[UploadFile], o: dict[str, Any]): + """Validate + spool the upload, convert to AsciiVideo. Returns + (video, tmp_path); the caller must unlink tmp_path (the mp4 sink needs + it alive for audio muxing).""" import os as os_mod import tempfile @@ -191,8 +193,18 @@ def _render_video(upload: Optional[UploadFile], o: dict[str, Any], t0: float) -> ) except (RuntimeError, ValueError, OSError) as e: raise HTTPException(status_code=400, detail=f"Could not read the video: {e}") - finally: + except Exception: os_mod.unlink(tmp.name) + raise + return v, tmp.name + + +def _render_video(upload: Optional[UploadFile], o: dict[str, Any], t0: float) -> dict[str, Any]: + import base64 as b64mod + import os as os_mod + + v, tmp_path = _video_from_upload(upload, o) + os_mod.unlink(tmp_path) # the JSON response doesn't need the source again from .greet import FRAME_SEP @@ -220,6 +232,45 @@ def _render_video(upload: Optional[UploadFile], o: dict[str, Any], t0: float) -> } +@app.post("/api/render/mp4") +def render_mp4( + image: Optional[UploadFile] = File(None), + options: str = Form("{}"), +): + """Encode-on-demand mp4 (with the source's audio). The GUI calls this + only when the user clicks the .mp4 download, so previews stay fast.""" + import os as os_mod + import tempfile + + from fastapi.responses import Response + + try: + o: dict[str, Any] = json.loads(options) + if not isinstance(o, dict): + raise ValueError("options must be a JSON object") + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Bad options JSON: {e}") + + v, src_path = _video_from_upload(image, o) + out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) + out.close() + try: + try: + v.write_mp4(out.name, audio_source=src_path) + except (RuntimeError, OSError) as e: + raise HTTPException(status_code=400, detail=f"Could not encode mp4: {e}") + data = open(out.name, "rb").read() + finally: + os_mod.unlink(src_path) + os_mod.unlink(out.name) + + return Response( + content=data, + media_type="video/mp4", + headers={"Content-Disposition": 'attachment; filename="ascii-video.mp4"'}, + ) + + @app.post("/api/render") def render( # Plain def: Starlette runs sync handlers in a threadpool, so a slow diff --git a/tests/test_webapp.py b/tests/test_webapp.py index 600dd6a..019a9ca 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -289,6 +289,24 @@ def test_render_video_missing_file_400(): assert r.status_code == 400 +def test_render_mp4_on_demand(): + pytest.importorskip("imageio") + pytest.importorskip("imageio_ffmpeg") + r = client.post( + "/api/render/mp4", + files={"image": ("clip.gif", _gif_clip_bytes(), "image/gif")}, + data={"options": json.dumps({"source": "video", "cols": 20, "video_max_frames": 3})}, + ) + assert r.status_code == 200 + assert r.headers["content-type"] == "video/mp4" + assert b"ftyp" in r.content[:64] + + +def test_render_mp4_missing_file_400(): + r = client.post("/api/render/mp4", data={"options": "{}"}) + assert r.status_code == 400 + + def test_render_video_bad_suffix_400(): r = client.post( "/api/render", From 2396b3a679b51c62395f1b8d0dcb9fc73df9988b Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 7 Jul 2026 11:17:38 -0400 Subject: [PATCH 2/2] polish: close out the #11 review's deferred items (closes #23) - HTML player drop heads brightness-track like the ANSI/GIF sinks: 16 .hN classes computed with the same _cell_rgb blend, replacing the fixed full-bright head color. - Floyd-Steinberg dither inner loop runs on Python lists instead of per-element NumPy indexing: 3.5x faster at cols~400 (0.69s -> 0.20s on 1200x800), float64 accumulation (slightly more accurate on borderline pixels). - README documents the loop-seam behavior: drop positions loop seamlessly, glyph identities re-roll at the wrap. Co-Authored-By: Claude Fable 5 --- README.MD | 2 +- src/asciimagic/animate.py | 17 +++++++++++--- src/asciimagic/image_to_ascii.py | 40 ++++++++++++++++++++------------ tests/test_matrix_color.py | 5 +++- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/README.MD b/README.MD index 1e1bcfb..7051eac 100644 --- a/README.MD +++ b/README.MD @@ -216,7 +216,7 @@ colorize-ascii photo.png art.txt --animate --loops 3 # play in - `--loops N` : Terminal playback repeats; `0` plays until Ctrl-C (default: 3) - `--reveal` : The rain uncovers the colorized image, which persists beneath it — the loop ends (and holds) on the fully revealed picture -All `--matrix-*` knobs (seed, gamma, intensity ranges, mask biasing, `--matrix-color` themes) apply to the rain too, and `--caption*` works in every animation sink — the caption stays static (matching the rain tint by default, or any `--caption-color` including `image`) while the rain falls. The web GUI exposes the same controls under *Matrix mode → Animate*, previews the animation live, and adds a `.gif` download. Programmatic use: `asciimagic.pipeline.animate(ctx, ...)` returns an object with `frames_ansi()`, `play()`, `to_gif_bytes()`, and `to_html()`. +All `--matrix-*` knobs (seed, gamma, intensity ranges, mask biasing, `--matrix-color` themes) apply to the rain too. Drop positions loop seamlessly; glyph identities re-roll at the wrap point, which reads as the same flicker the rain has everywhere else. `--caption*` works in every animation sink — the caption stays static (matching the rain tint by default, or any `--caption-color` including `image`) while the rain falls. The web GUI exposes the same controls under *Matrix mode → Animate*, previews the animation live, and adds a `.gif` download. Programmatic use: `asciimagic.pipeline.animate(ctx, ...)` returns an object with `frames_ansi()`, `play()`, `to_gif_bytes()`, and `to_html()`. ### Text to ASCII (`text_to_ascii.py`) diff --git a/src/asciimagic/animate.py b/src/asciimagic/animate.py index 2655f48..c956e23 100644 --- a/src/asciimagic/animate.py +++ b/src/asciimagic/animate.py @@ -407,7 +407,7 @@ def flush(): key = ((r_ >> 5) << 5, (g_ >> 5) << 5, (b_ >> 5) << 5) ch = html_mod.escape(ach) else: - key = "h" if head[y, x] else f"c{green[y, x] >> 4}" + key = f"h{green[y, x] >> 4}" if head[y, x] else f"c{green[y, x] >> 4}" ch = html_mod.escape(self.chars[i]) if key != prev: flush() @@ -424,7 +424,18 @@ def flush(): for i in range(16) for (r, g, b) in [tint_rgb(min(255, i * 17), self.tint)] ) - hr, hg, hb = (c + (255 - c) * 216 // 255 for c in self.tint) + # Head classes brightness-track the drop like the ANSI/GIF sinks + # (_cell_rgb): base tint scaled by intensity, blended 80% toward it. + def _head_rgb(level: int): + g = min(255, level * 17) + base = tint_rgb(g, self.tint) + return tuple(c + (g - c) * 4 // 5 for c in base) + + css_heads = "\n".join( + " .h{i} {{ color: rgb({r},{g},{b}); }}".format(i=i, r=r, g=g, b=b) + for i in range(16) + for (r, g, b) in [_head_rgb(i)] + ) cap_top = cap_bottom = "" if self.caption: @@ -448,7 +459,7 @@ def flush(): f" font-size: {font_size_px}px;\n line-height: {font_size_px}px;\n" " }\n" f"{css_levels}\n" - f" .h {{ color: rgb({hr},{hg},{hb}); }}\n" + f"{css_heads}\n" " \n\n\n" f"{cap_top}" '
\n'
diff --git a/src/asciimagic/image_to_ascii.py b/src/asciimagic/image_to_ascii.py
index 4e696bd..6345c56 100755
--- a/src/asciimagic/image_to_ascii.py
+++ b/src/asciimagic/image_to_ascii.py
@@ -400,26 +400,36 @@ def image_to_text_glyph_from_image(
 
 def _floyd_steinberg_dots(ink: np.ndarray, threshold: float) -> np.ndarray:
     """Error-diffusion dither: returns a boolean dot map preserving gradients
-    that a hard threshold would flatten."""
-    arr = ink.astype(np.float32).copy()
-    h, w = arr.shape
+    that a hard threshold would flatten.
+
+    Error diffusion is inherently serial per pixel, so the inner loop runs
+    on plain Python lists — roughly an order of magnitude faster than
+    per-element NumPy indexing at large sizes.
+    """
+    h, w = ink.shape
+    rows = ink.astype(np.float64).tolist()
     out = np.zeros((h, w), dtype=bool)
+    last = w - 1
     for y in range(h):
-        row = arr[y]
-        below = arr[y + 1] if y + 1 < h else None
+        row = rows[y]
+        below = rows[y + 1] if y + 1 < h else None
+        bits = bytearray(w)
         for x in range(w):
             old = row[x]
-            new = old >= threshold
-            out[y, x] = new
-            err = old - (1.0 if new else 0.0)
-            if x + 1 < w:
-                row[x + 1] += err * (7 / 16)
+            if old >= threshold:
+                bits[x] = 1
+                err = old - 1.0
+            else:
+                err = old
+            if x < last:
+                row[x + 1] += err * 0.4375       # 7/16
             if below is not None:
-                if x > 0:
-                    below[x - 1] += err * (3 / 16)
-                below[x] += err * (5 / 16)
-                if x + 1 < w:
-                    below[x + 1] += err * (1 / 16)
+                if x:
+                    below[x - 1] += err * 0.1875  # 3/16
+                below[x] += err * 0.3125          # 5/16
+                if x < last:
+                    below[x + 1] += err * 0.0625  # 1/16
+        out[y] = np.frombuffer(bytes(bits), dtype=np.uint8).astype(bool)
     return out
 
 
diff --git a/tests/test_matrix_color.py b/tests/test_matrix_color.py
index adb473f..ec36789 100644
--- a/tests/test_matrix_color.py
+++ b/tests/test_matrix_color.py
@@ -94,4 +94,7 @@ def test_animation_default_tint_is_green():
     m = MatrixOptions(enabled=True, seed=5)
     anim = generate(ASCII, _img(), m=m, a=AnimationOptions(frames=2))
     html = anim.to_html()
-    assert ".h { color: rgb(216,255,216); }" in html
+    # Head classes brightness-track the drop; full-intensity green head is
+    # (0,255,0) blended 80% toward its own intensity -> (204,255,204).
+    assert ".h15 { color: rgb(204,255,204); }" in html
+    assert ".h0 { color: rgb(0,0,0); }" in html