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 @@
ASCII✦Magic
+
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",