From 32c584920d5fc62d8d29c2f79d23aef0b8481cc7 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 7 Jul 2026 11:14:29 -0400 Subject: [PATCH] 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",