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/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 @@