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