diff --git a/ShowControl/FundingCAPTCHA/captcha-settings.json b/ShowControl/FundingCAPTCHA/captcha-settings.json index 99c67a4..cdcce8e 100644 --- a/ShowControl/FundingCAPTCHA/captcha-settings.json +++ b/ShowControl/FundingCAPTCHA/captcha-settings.json @@ -57,8 +57,9 @@ }, "shader_app": { - "_comment": "shader_app.py (ADR-0021). render_scale: fraction of display resolution the passes render at.", + "_comment": "shader_app.py (ADR-0021). render_scale: fraction of display resolution the passes render at. display: which monitor to go fullscreen on (0 = first; --display N overrides).", "render_scale": 0.5, + "display": 0, "fps": 30, "blobs": { "_comment": "Blob records for Setups that read the \"blobs\" source (ADR-0022). Pixels are Body pixels; frames are camera frames.", diff --git a/ShowControl/FundingCAPTCHA/playlist.json b/ShowControl/FundingCAPTCHA/playlist.json index d6296c1..6b31993 100644 --- a/ShowControl/FundingCAPTCHA/playlist.json +++ b/ShowControl/FundingCAPTCHA/playlist.json @@ -1,6 +1,6 @@ { "_comment": "playlist_app.py: one shuffle bag of shader Setups (shaders//) and videos (files in videos/, which is gitignored — copy them onto the show PC). Each Setup plays setup_duration_s, overriding its own duration_s; each video plays to the end of its file, silent and letterboxed. A missing or unreadable item is skipped.", "setup_duration_s": 300, - "setups": ["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop"], + "setups": ["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop", "watercolor_smudge"], "videos": [] } diff --git a/ShowControl/FundingCAPTCHA/playlist_app.py b/ShowControl/FundingCAPTCHA/playlist_app.py index bd25b00..f0bb4d7 100644 --- a/ShowControl/FundingCAPTCHA/playlist_app.py +++ b/ShowControl/FundingCAPTCHA/playlist_app.py @@ -32,8 +32,8 @@ from app import DIR, load_settings from arc import ShuffleBag -from shader_app import (SHADER_DIR, ShaderStage, add_mode_args, drain_camera, - open_display, start_camera) +from shader_app import (SHADER_DIR, SetupImages, ShaderStage, add_mode_args, display_index, + drain_camera, open_display, start_camera) from shadertoy import Renderer, load_setup log = logging.getLogger("playlist_app") @@ -233,8 +233,10 @@ def __init__(self, renderer: Renderer, items: list[Item], setup_duration_s: floa shader_dir: Path = SHADER_DIR, video_dir: Path = VIDEO_DIR, open_video: Callable[[Path], VideoPlayer] = VideoPlayer, clock: Callable[[], float] = time.monotonic, - on_load: Callable[[], None] | None = None) -> None: + on_load: Callable[[], None] | None = None, + image_max: tuple[int, int] = (4096, 4096), image_max_side: int = 16384) -> None: self.renderer = renderer + self._images = SetupImages(image_max, image_max_side) self.items = list(items) self.setup_duration_s = setup_duration_s self._bag = ShuffleBag(self.items) @@ -320,7 +322,9 @@ def _start(self, item: Item) -> bool: def _load_setup(self, name: str) -> bool: try: - self.renderer.load(load_setup(self._shaders / name)) + setup = load_setup(self._shaders / name) + image = self._images.next(setup) if setup.uses_image else None + self.renderer.load(setup, image=image) except Exception as exc: # team-authored content: never fatal log.error("Setup %s skipped: %s", name, exc) return False @@ -353,11 +357,13 @@ def main() -> None: if item.kind == "video" and not (VIDEO_DIR / item.name).is_file(): log.warning("Video %s not found in %s — it will be skipped", item.name, VIDEO_DIR) - ctx, win = open_display(args.windowed, "Playlist player", args.test_input) + ctx, win = open_display(args.windowed, "Playlist player", args.test_input, + display_index(args, settings)) stage = ShaderStage(ctx, win, settings, args.test_input) screen = VideoScreen(ctx, win, stage.renderer.vbo) playlist = Playlist(stage.renderer, playlist_cfg.items, playlist_cfg.setup_duration_s, - on_load=stage.blobs.reset) + on_load=stage.blobs.reset, + image_max=win, image_max_side=stage.max_texture_side) playlist.next() cam_q, stop = start_camera(args, settings) diff --git a/ShowControl/FundingCAPTCHA/shader_app.py b/ShowControl/FundingCAPTCHA/shader_app.py index 1ffa122..053c2f3 100644 --- a/ShowControl/FundingCAPTCHA/shader_app.py +++ b/ShowControl/FundingCAPTCHA/shader_app.py @@ -33,12 +33,14 @@ os.environ.setdefault("MESA_GLSL_VERSION_OVERRIDE", "330") import moderngl +import numpy as np import pygame from app import DIR, _camera_thread, load_settings from arc import ShuffleBag from body_blobs import BlobConfig, BodyBlobs -from shadertoy import BodyMouse, Renderer, VERT, js_keycode, load_setup, pack_body +from shadertoy import (BodyMouse, Renderer, ShaderError, VERT, decode_image, js_keycode, + load_setup, pack_body) try: from IIVision import MockCamera, OrbbecCamera @@ -84,6 +86,40 @@ def update(self, win: tuple[int, int], res: tuple[int, int]) -> tuple: return self.state +class SetupImages: + """The "image" source's pictures (ADR-0024): a shuffle bag per Setup, one image per load. + + Shared by Rotation and playlist_app.Playlist. Images keep enough pixels to + cover `cover` (the window), with no side beyond `max_side` (the GPU's limit). + """ + + def __init__(self, cover: tuple[int, int] = (4096, 4096), max_side: int = 16384) -> None: + self._cover = cover + self._max_side = max_side + self._bags: dict[str, tuple] = {} # name → (files, ShuffleBag, undecodable) + + def next(self, setup) -> np.ndarray | None: + """Decode the next image from this Setup's bag; None if none decode.""" + files, bag, bad = self._bags.get(setup.name, (None, None, set())) + if files != setup.images: # first load, or images/ changed (R) + files, bag = list(setup.images), ShuffleBag([p for p in setup.images if p not in bad]) + for _ in range(len(files)): + path = bag.next() + if path is None: + break + try: + image = decode_image(path, self._cover, self._max_side) + except ShaderError as exc: + log.error("%s: image dropped: %s", setup.name, exc) + bad.add(path) + bag = ShuffleBag([p for p in files if p not in bad]) + continue + self._bags[setup.name] = (files, bag, bad) + return image + self._bags[setup.name] = (files, bag, bad) + return None + + class Rotation: """Which Setup plays: shuffle-bag order, each for its duration_s. @@ -92,8 +128,10 @@ class Rotation: """ def __init__(self, renderer: Renderer, names: list[str], - shader_dir: Path = SHADER_DIR, clock=time.monotonic, on_load=None) -> None: + shader_dir: Path = SHADER_DIR, clock=time.monotonic, on_load=None, + image_max: tuple[int, int] = (4096, 4096), image_max_side: int = 16384) -> None: self.renderer = renderer + self._images = SetupImages(image_max, image_max_side) self._on_load = on_load # called after every successful load self.names = list(names) self._bag = ShuffleBag(self.names) @@ -135,7 +173,9 @@ def failed(self, exc: Exception) -> None: def _load(self, name: str) -> bool: try: - self.renderer.load(load_setup(self._dir / name)) + setup = load_setup(self._dir / name) + image = self._images.next(setup) if setup.uses_image else None + self.renderer.load(setup, image=image) except Exception as exc: # team-authored content: never fatal log.error("Setup %s skipped: %s", name, exc) return False @@ -145,6 +185,14 @@ def _load(self, name: str) -> bool: return True +def pick_display(sizes: list[tuple[int, int]], index: int) -> tuple[int, tuple[int, int]]: + """(display index, its desktop size). An index with no display falls back to the first.""" + if not 0 <= index < len(sizes): + log.warning("No display %d (%d connected) — using display 0", index, len(sizes)) + index = 0 + return index, tuple(sizes[index]) + + def _setup_names() -> list[str]: try: return json.loads((SHADER_DIR / "setups.json").read_text()) @@ -162,10 +210,22 @@ def add_mode_args(ap: argparse.ArgumentParser) -> None: mode.add_argument("--test-input", action="store_true", help="Mouse paints the Body with shaders/body_painter.glsl, as in Shadertoy") ap.add_argument("--windowed", action="store_true", help="1280×720 window instead of fullscreen") + ap.add_argument("--display", type=int, metavar="N", + help="Display to go fullscreen on (0 = first); overrides shader_app.display") + +def display_index(args, settings: dict) -> int: + """--display N, else shader_app.display, else the first display.""" + if args.display is not None: + return args.display + return int(settings.get("shader_app", {}).get("display", 0)) -def open_display(windowed: bool, caption: str, mouse_visible: bool): - """pygame window with a GL 3.3 core context → (moderngl ctx, window size).""" + +def open_display(windowed: bool, caption: str, mouse_visible: bool, display: int = 0): + """pygame window with a GL 3.3 core context → (moderngl ctx, window size). + + Fullscreen goes on `display` (0 = first); a missing one falls back to the first. + """ pygame.init() pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3) pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3) @@ -175,9 +235,11 @@ def open_display(windowed: bool, caption: str, mouse_visible: bool): win = (1280, 720) pygame.display.set_mode(win, pygame.OPENGL | pygame.DOUBLEBUF) else: - info = pygame.display.Info() - win = (info.current_w, info.current_h) - pygame.display.set_mode(win, pygame.OPENGL | pygame.DOUBLEBUF | pygame.FULLSCREEN) + sizes = pygame.display.get_desktop_sizes() + display, win = pick_display(sizes, display) + pygame.display.set_mode(win, pygame.OPENGL | pygame.DOUBLEBUF | pygame.FULLSCREEN, + display=display) + log.info("Fullscreen on display %d of %d", display, len(sizes)) pygame.display.set_caption(caption) pygame.mouse.set_visible(mouse_visible) ctx = moderngl.create_context() # also attaches to SDL's EGL context (kmsdrm) @@ -229,6 +291,7 @@ def __init__(self, ctx, win: tuple[int, int], settings: dict, test_input: bool) cam_cfg = settings.get("camera", {}) scale = float(cfg.get("render_scale", 0.5)) self.ctx, self.win = ctx, win + self.max_texture_side = int(ctx.info["GL_MAX_TEXTURE_SIZE"]) self.res = (max(1, int(win[0] * scale)), max(1, int(win[1] * scale))) self.slabs = settings.get("depth_slabs", [{"near_mm": 800, "far_mm": 2500}]) self.roi = settings.get("camera_roi", @@ -312,10 +375,12 @@ def main() -> None: if (args.camera or args.mock_camera) and not _CV_AVAILABLE: sys.exit("ERROR: CV libraries not available — pip install -r requirements.txt") - ctx, win = open_display(args.windowed, "Shader player", args.test_input) + ctx, win = open_display(args.windowed, "Shader player", args.test_input, + display_index(args, settings)) stage = ShaderStage(ctx, win, settings, args.test_input) rotation = Rotation(stage.renderer, [args.setup] if args.setup else _setup_names(), - on_load=stage.blobs.reset) + on_load=stage.blobs.reset, + image_max=win, image_max_side=stage.max_texture_side) rotation.next() cam_q, stop = start_camera(args, settings) diff --git a/ShowControl/FundingCAPTCHA/shaders/README.md b/ShowControl/FundingCAPTCHA/shaders/README.md index 470dae9..77b6d48 100644 --- a/ShowControl/FundingCAPTCHA/shaders/README.md +++ b/ShowControl/FundingCAPTCHA/shaders/README.md @@ -23,7 +23,7 @@ Mouse controls in Buffer A: **hold left button** = a person at the cursor · **h - Buffers are 16-bit float on the Pi (Shadertoy uses 32-bit). Keep stored values small: past ~2048, whole numbers start rounding. Store positions as UVs (0–1), not pixels. - `HW_PERFORMANCE` is 0 on the Pi — the low-quality branch. - `iMouse` works: on the Pi it's the centre of everyone in frame, "pressed" while anyone is there. -- No textures, videos, sound, cubemaps, or webcam — procedural + Body only. +- No videos, sound, cubemaps, or webcam. The only texture is the `"image"` source (see Images); everything else is procedural + Body. - Channel filter/wrap settings are ignored: always linear, clamp. - The Pi renders at half resolution by default. Keep it cheap; avoid long raymarch loops. @@ -51,6 +51,17 @@ When handing in, don't copy the blob painter tab. In `setup.json`, write `"blobs "channels": { "image": ["A", "blobs"] } ``` +## Images (optional) + +A Setup can paint over a picture instead of generating everything ([ADR-0024](../../../docs/adr/0024-shader-player-image-channel.md)). Put `.png` or `.jpg` files in `shaders//images/` and bind `"image"` in `setup.json`. Each time the Setup plays, the player shuffles to the next one. No repeats until all have shown. + +- In Shadertoy, use one of its built-in textures in that channel as a stand-in. +- Sample it with `texture()` and its `iChannelResolution` for aspect. Row 0 is the bottom, as with every channel. +- **No images in the folder** leaves the channel unbound: it reads 0 and `iChannelResolution[n].x == 0.0`. Test for that and fall back to something procedural, so the Setup still plays in a fresh clone. +- Keep images near display size; larger ones are downscaled on load and add a hitch. +- Images are committed publicly. Nothing private goes here. +- For smear-type Setups, fairly saturated images with clear colour regions smear more legibly than soft, low-contrast ones. + ## Hand it in Copy [`ripples/`](ripples/) to `shaders//` and replace: @@ -75,7 +86,7 @@ Copy [`ripples/`](ripples/) to `shaders//` and replace: } ``` -Sources: `"A"` (Body), `"B"`, `"C"`, `"D"`, `"keyboard"`, `"blobs"` (blob records, above), or `null`. List position = iChannel number. +Sources: `"A"` (Body), `"B"`, `"C"`, `"D"`, `"keyboard"`, `"blobs"` (blob records, above), `"image"` (images, above), or `null`. List position = iChannel number. Add the folder name to [`setups.json`](setups.json) to put it in the rotation. `make test` compiles every listed Setup. diff --git a/ShowControl/FundingCAPTCHA/shaders/setups.json b/ShowControl/FundingCAPTCHA/shaders/setups.json index c50bfc3..faabbfa 100644 --- a/ShowControl/FundingCAPTCHA/shaders/setups.json +++ b/ShowControl/FundingCAPTCHA/shaders/setups.json @@ -1 +1 @@ -["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop"] +["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop", "watercolor_smudge"] diff --git a/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_b.glsl new file mode 100644 index 0000000..ab2aa4a --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_b.glsl @@ -0,0 +1,18 @@ +// Buffer B — the original painting the canvas heals toward. +// iChannel0 = B (itself), iChannel1 = "image" (unbound → resolution 0 → procedural sunset). +// rgb = painting, a = smearable (1) vs. unpainted paper border (0). + +void mainImage(out vec4 o, in vec2 f) { + vec2 uv = f / iResolution.xy; + if (iChannelResolution[1].x > 0.0) { + o = vec4(texture(iChannel1, coverUV(uv, iResolution.xy, iChannelResolution[1].xy)).rgb, 1.0); + return; + } + // Paint once; repaint if the buffer was reallocated (a Shadertoy resize clears it). + vec3 ctr = texelFetch(iChannel0, ivec2(iResolution.xy * 0.5), 0).rgb; + if (iFrame == 0 || ctr == vec3(0.0)) { + o = vec4(paintSunset(uv, iResolution.xy), paintMask(uv, iResolution.xy)); + } else { + o = texelFetch(iChannel0, ivec2(f), 0); + } +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_c.glsl new file mode 100644 index 0000000..c7918ae --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_c.glsl @@ -0,0 +1,30 @@ +// Buffer C — motion. iChannel0 = A (Body), iChannel1 = C (itself, last frame). +// r = eased soft Body, g = last frame's eased Body, b = extremity weight (thin parts 1, torso 0) +// measured on last frame's eased Body, +// a at texel (0,0) = seconds the room has been empty (clamped at EMPTY_CLAMP_S), 0 elsewhere. + +// The Body averaged over a small disc: a soft edge about 2 × BODY_SOFT wide. +float softBody(vec2 uv, float A) { + float s = texture(iChannel0, uv).r; + for (int i = 0; i < 8; i++) { + float a = float(i) * 0.7853982; + vec2 d = vec2(cos(a), sin(a)) / vec2(A, 1.0) * BODY_SOFT; + s += texture(iChannel0, uv + d * 0.5).r + texture(iChannel0, uv + d).r; + } + return s / 17.0; +} + +void mainImage(out vec4 o, in vec2 f) { + vec2 uv = f / iResolution.xy; + float A = iResolution.x / iResolution.y; + float raw = softBody(uv, A); + vec4 prev = texelFetch(iChannel1, ivec2(f), 0); + float k = 1.0 - exp(-iTimeDelta / BODY_EASE_S); + float body = iFrame == 0 ? raw : mix(prev.r, raw, k); + float last = iFrame == 0 ? raw : prev.r; + float t = 0.0; + if (ivec2(f) == ivec2(0)) { + t = iMouse.z > 0.0 ? 0.0 : min(EMPTY_CLAMP_S, prev.a + iTimeDelta); + } + o = vec4(body, last, extremity(iChannel1, uv, A), t); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_d.glsl b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_d.glsl new file mode 100644 index 0000000..272a1c6 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_d.glsl @@ -0,0 +1,74 @@ +// Buffer D — the canvas: rgb = paint, a = wetness 0–1. +// iChannel0 = D (itself), iChannel1 = B (original, this frame), iChannel2 = C (motion, this frame). +// Thin, moving Body edges push paint the way they move (semi-Lagrangian advection), in finger +// streaks, mixing like pigment. Heals toward the original once the room has been empty a while. + +vec2 bodyPair(vec2 uv) { return texture(iChannel2, uv).rg; } + +// 0 when nearby Body change balances (something moved), 1 when it's one-sided (appeared, +// vanished, flickered). Sampled on the extremity rings, so it spans a limb and its travel. +float imbalance(vec2 uv, float A) { + vec4 m = texture(iChannel2, uv); + float net = m.r - m.g, total = abs(net); + for (int i = 0; i < 12; i++) { + float a = float(i) * 0.5235988; + vec2 d = vec2(cos(a), sin(a)) / vec2(A, 1.0); + vec4 m1 = texture(iChannel2, uv + d * RING_R1), m2 = texture(iChannel2, uv + d * RING_R2); + net += (m1.r - m1.g) + (m2.r - m2.g); + total += abs(m1.r - m1.g) + abs(m2.r - m2.g); + } + return abs(net) / (total + 1e-4); +} + +void mainImage(out vec4 o, in vec2 f) { + // Paint never goes below PAINT_FLOOR, so an exactly-zero centre means "buffer cleared" (a + // Shadertoy resize), never black paint. + vec3 ctr = texelFetch(iChannel0, ivec2(iResolution.xy * 0.5), 0).rgb; + if (iFrame == 0 || ctr == vec3(0.0)) { + o = vec4(max(texelFetch(iChannel1, ivec2(f), 0).rgb, vec3(PAINT_FLOOR)), 0.0); + return; + } + vec2 uv = f / iResolution.xy; + float A = iResolution.x / iResolution.y; + float dt = max(iTimeDelta, 1e-3); + + // Normal flow from this and last frame's eased Body, in screen heights per second. + vec2 ox = vec2(FLOW_TAP / A, 0.0), oy = vec2(0.0, FLOW_TAP); + vec2 px1 = bodyPair(uv + ox), px0 = bodyPair(uv - ox), py1 = bodyPair(uv + oy), py0 = bodyPair(uv - oy); + vec2 g = vec2((px1.x + px1.y) - (px0.x + px0.y), (py1.x + py1.y) - (py0.x + py0.y)) / (4.0 * FLOW_TAP); + vec4 m = texture(iChannel2, uv); + vec2 v = -(m.r - m.g) / dt * g / (dot(g, g) + FLOW_EPS); + float sp = length(v); + if (sp > FLOW_MAX) v *= FLOW_MAX / sp; + v *= smoothstep(MIN_FLOW, 2.0 * MIN_FLOW, sp); // dead zone: creep isn't movement + if (sp > 0.0) v *= 1.0 - smoothstep(BALANCE_LO, BALANCE_HI, imbalance(uv, A)); + + // Only thin parts smear, and never on or out of the paper border. + vec4 orig = texture(iChannel1, uv); + float upstream = texture(iChannel1, uv - v * 0.08 / vec2(A, 1.0)).a; + vec2 vel = v * m.b * orig.a * upstream; + float vs = length(vel); + vec2 perp = vs > 1e-4 ? vec2(-vel.y, vel.x) / vs : vec2(1.0, 0.0); + float streak = STREAK_BASE + STREAK_AMP * vn(vec2(dot(uv * vec2(A, 1.0), perp) * STREAK_FREQ, 3.0)); + vec2 src = uv - vel * streak * SMEAR_STRENGTH * dt / vec2(A, 1.0); + vec4 c = texture(iChannel0, src); + + // Pigment-like mixing: geometric mean of neighbours, where moving or still wet. + float mv = smoothstep(0.02, 0.3, vs); + vec2 mx = vec2(MIX_TAP / A, 0.0), my = vec2(0.0, MIX_TAP); + vec3 L = log(max(texture(iChannel0, src + mx).rgb, 0.02)) + log(max(texture(iChannel0, src - mx).rgb, 0.02)) + + log(max(texture(iChannel0, src + my).rgb, 0.02)) + log(max(texture(iChannel0, src - my).rgb, 0.02)); + c.rgb = mix(c.rgb, exp(L * 0.25), clamp(mv * MIX_MOVE + c.a * MIX_WET, 0.0, 0.6) * orig.a); + + float wet = max(c.a * exp(-dt * WET_DECAY), mv); + + float empty = texelFetch(iChannel2, ivec2(0), 0).a; + if (empty > HEAL_DELAY_S) { + vec3 d = orig.rgb - c.rgb; // exp ease, with a linear floor for 16-bit + vec3 step = max(abs(d) * (1.0 - exp(-dt * 3.0 / HEAL_TIME_S)), vec3(HEAL_FLOOR * dt)); + c.rgb += sign(d) * min(abs(d), step); + } else { + c.rgb = mix(c.rgb, orig.rgb, 1.0 - exp(-dt * PRESENT_HEAL)); + } + o = vec4(max(c.rgb, vec3(PAINT_FLOOR)), wet); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/common.glsl new file mode 100644 index 0000000..790d9cf --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/common.glsl @@ -0,0 +1,152 @@ +// Watercolor smudge — shared tunables and helpers. +// Spec: docs/superpowers/specs/2026-11-07-watercolor-smudge-design.md +// Approved sketch: docs/superpowers/specs/2026-11-07-watercolor-smudge-mockup.html +// +// Sizes are in screen heights: q = uv * vec2(aspect, 1). Times are seconds. +// Retune on site and press R to reload. + +// ── Smear ──────────────────────────────────────────────────────────────────── +const float SMEAR_STRENGTH = 1.5; // paint speed / edge speed (sketch was 0.63). Second to FLOW_EPS in effect +const float THIN = 0.5; // what counts as an extremity: 0 only the thinnest … 1 almost all +const float RING_R1 = 0.045; // extremity rings: how much of these is Body decides thin vs. broad +const float RING_R2 = 0.08; +const float STREAK_BASE = 0.7; // finger streaks across a stroke: base + amp × noise +const float STREAK_AMP = 0.6; +const float STREAK_FREQ = 45.0; // streaks per screen height +const float MIX_MOVE = 0.5; // pigment mixing where paint is moving +const float MIX_WET = 0.04; // ... and where it's still wet + +// ── Motion (normal flow from the eased Body changing between frames) ───────── +const float BODY_SOFT = 0.04; // softens the Body edge (screen heights). The camera's 10 Hz frames move a + // limb ~0.07 per frame; a soft edge wider than that makes the cross-fade + // between frames read as a sliding edge, which carries paint along +const float BODY_EASE_S = 0.12; // eases the 10 Hz Body so flow is smooth and 1-px flicker averages out +const float FLOW_TAP = 0.006; // gradient half-step, screen heights +const float FLOW_EPS = 0.25; // regularises v = −dB/dt · ∇B / (|∇B|² + eps). The biggest smear knob: 4.0 swamped + // |∇B|² and underestimated limb speed several-fold; lower = stronger smear +const float FLOW_MAX = 2.5; // screen heights per second +const float MIN_FLOW = 0.1; // edge motion slower than this (screen heights/s) is ignored: 1-camera-px jitter is ~0.05, limbs 0.5–1.5 +const float BALANCE_LO = 0.35; // movement gains Body ahead and loses it behind in equal measure; +const float BALANCE_HI = 0.7; // appearing, vanishing and flicker only gain or only lose. |gain−loss|/(gain+loss) + // above BALANCE_LO starts suppressing the smear, fully by BALANCE_HI. + // Known cost: an arm reaching along its own axis only gains, so it doesn't + // smear; raise BALANCE_LO/HI at the rig if reaching matters more than flicker +const float MIX_TAP = 0.0045; // pigment-mixing neighbourhood, screen heights + +// ── Wet look ───────────────────────────────────────────────────────────────── +const float WET_DECAY = 0.2; // per second; smears look wet for ~5 s +const float WET_DARKEN = 0.15; // wet paint reads this much darker + +// ── Heal ───────────────────────────────────────────────────────────────────── +const float HEAL_DELAY_S = 2.0; // room empty this long before healing starts +const float HEAL_TIME_S = 10.0; // then ~95 % healed after this long +const float PRESENT_HEAL = 0.004; // per second while anyone is present (keeps drift bounded) +const float HEAL_FLOOR = 0.05; // min heal speed (colour/s) while healing: 16-bit can't do the exp tail +const float EMPTY_CLAMP_S = 10.0; // empty-room timer clamp; 16-bit can't count 1/30 s steps near 99 + +// ── Display ────────────────────────────────────────────────────────────────── +const float DISTURB_LO = 0.01; // |canvas − original| below this shows the image untouched +const float DISTURB_HI = 0.04; // ... above this shows the canvas + +const float T_CLAMP = 99.0; // every "seconds since" counter clamps here +const float PAINT_FLOOR = 1.0 / 512.0; // canvas paint floor: keeps black paint distinct from a cleared buffer +const vec3 PAPER = vec3(0.965, 0.94, 0.885); + +// ── Noise ──────────────────────────────────────────────────────────────────── +float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } +float vn(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); +} +float fbm(vec2 p) { float s = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s; } +float lum(vec3 c) { return dot(c, vec3(0.3, 0.55, 0.15)); } +vec3 glaze(vec3 c, vec3 g, float k) { return c * mix(vec3(1.0), g, k); } + +float paperGrain(vec2 q) { + float fib = vn(q * vec2(420.0, 140.0)) * 0.5 + vn(q * vec2(150.0, 520.0)) * 0.5; + return 0.955 + 0.045 * fib; +} + +// How thin the Body is around uv: 1 for limbs and head, 0 for the torso. +// Coverage of two rings of Body samples; a thin part leaves most of each ring empty. +// Measured on the same eased, softened Body (.r) that drives the flow, so a broad part's +// lagging trailing edge still reads as broad. +float extremity(sampler2D body, vec2 uv, float A) { + float s = 0.0; + for (int i = 0; i < 12; i++) { + float a = float(i) * 0.5235988; + vec2 d = vec2(cos(a), sin(a)) / vec2(A, 1.0); + s += texture(body, uv + d * RING_R1).r + texture(body, uv + d * RING_R2).r; + } + float t = mix(0.18, 0.45, THIN); + return 1.0 - smoothstep(t - 0.08, t + 0.08, s / 24.0); +} + +// Cover-fit: uv 0–1 on screen → image uv, aspect kept, overflow cropped, centred. +vec2 coverUV(vec2 uv, vec2 screen, vec2 img) { + float sa = screen.x / screen.y, ia = img.x / img.y; + if (sa > ia) uv.y = (uv.y - 0.5) * ia / sa + 0.5; + else uv.x = (uv.x - 0.5) * sa / ia + 0.5; + return uv; +} + +// ── Procedural stand-in painting: a watercolor sunset ──────────────────────── +// Distance into the painted area from its ragged edge, in screen heights. +float paperEdge(vec2 uv, vec2 res) { + float A = res.x / res.y; vec2 q = uv * vec2(A, 1.0); + vec2 e = min(uv, 1.0 - uv) * vec2(A, 1.0); + return min(e.x, e.y) + 0.03 * (fbm(q * 7.0) - 0.5); +} + +// 1 where paint may smear, 0 on the unpainted border. +float paintMask(vec2 uv, vec2 res) { return smoothstep(0.06, 0.09, paperEdge(uv, res)); } + +vec3 sunsetSky(float t) { + vec3 c0 = vec3(1.0, 0.87, 0.55), c1 = vec3(0.99, 0.63, 0.36), c2 = vec3(0.93, 0.42, 0.48), + c3 = vec3(0.60, 0.38, 0.62), c4 = vec3(0.27, 0.31, 0.55); + vec3 c = mix(c0, c1, smoothstep(0.0, 0.2, t)); + c = mix(c, c2, smoothstep(0.15, 0.42, t)); + c = mix(c, c3, smoothstep(0.38, 0.7, t)); + return mix(c, c4, smoothstep(0.65, 1.0, t)); +} + +vec3 paintSunset(vec2 uv, vec2 res) { + float A = res.x / res.y; vec2 q = uv * vec2(A, 1.0); + float warp = fbm(q * vec2(1.5, 4.0) + 3.0) - 0.5; + float h = 0.36 + 0.012 * (fbm(vec2(q.x * 3.0, 1.0)) - 0.5); // horizon + vec2 sc = vec2(A * 0.58, h + 0.12); // sun centre + vec3 col; + if (uv.y > h) { + float t = (uv.y - h) / (1.0 - h) + 0.08 * warp; + col = sunsetSky(clamp(t, 0.0, 1.0)); + float fb = fract(t * 4.5 + 0.3 * warp); + col *= 1.0 - 0.08 * smoothstep(0.8, 1.0, fb); // pooled lower edge of each wash + float cl = fbm(vec2(q.x * 1.1, uv.y * 8.0) + vec2(warp * 2.5, 0.0)); + float cm = smoothstep(0.54, 0.68, cl) * smoothstep(h + 0.1, h + 0.22, uv.y) * smoothstep(0.97, 0.62, uv.y); + col = glaze(col, mix(vec3(0.86, 0.55, 0.72), vec3(0.62, 0.52, 0.80), clamp(t, 0.0, 1.0)), cm * 0.75); + col *= 1.0 - 0.10 * cm * smoothstep(0.62, 0.68, cl) * (1.0 - smoothstep(0.68, 0.74, cl)); + float d = length(q - sc) + 0.014 * (fbm(q * 11.0) - 0.5); + col = mix(col, vec3(1.0, 0.84, 0.55), smoothstep(0.34, 0.0, d) * 0.35); // glow + col = mix(col, vec3(1.0, 0.95, 0.74), smoothstep(0.088, 0.078, d) * 0.92); // lifted sun + col *= 1.0 - 0.13 * smoothstep(0.012, 0.0, abs(d - 0.083)); // pooled rim + float hill = h + 0.012 + 0.075 * (fbm(vec2(q.x * 1.6, 7.0)) - 0.25) * smoothstep(A * 0.9, A * 0.15, q.x) + + 0.006 * (vn(q * 40.0) - 0.5); + float hm = smoothstep(0.004, -0.004, uv.y - hill); + col = mix(col, glaze(col, vec3(0.52, 0.36, 0.55), 0.85) * (1.0 - 0.12 * smoothstep(0.014, 0.0, hill - uv.y)), hm); + } else { + float s = (h - uv.y) / h; + float st = fbm(vec2(q.x * 2.2, uv.y * 38.0)); + vec3 w = mix(vec3(0.86, 0.52, 0.52), vec3(0.29, 0.27, 0.50), smoothstep(-0.05, 1.0, s + 0.12 * warp)); + float refl = smoothstep(0.07 + 0.16 * s, 0.0, abs(q.x - sc.x) + 0.09 * (st - 0.5)) * smoothstep(0.42, 0.58, st); + w = mix(w, vec3(1.0, 0.83, 0.55), refl * 0.85); + w *= 1.0 - 0.09 * smoothstep(0.58, 0.72, st) * (1.0 - refl); + col = w; + } + float gran = vn(q * 170.0) * vn(q * 63.0 + 5.0); + col *= 1.0 - 0.14 * gran * (1.0 - lum(col)); // granulation in darker washes + float ed = paperEdge(uv, res); + col = mix(PAPER, col, smoothstep(0.025, 0.05, ed)); // ragged unpainted border + col *= 1.0 - 0.06 * smoothstep(0.04, 0.05, ed) * (1.0 - smoothstep(0.05, 0.065, ed)); // dried rim + return col; +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/image.glsl new file mode 100644 index 0000000..1d5f02b --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/image.glsl @@ -0,0 +1,21 @@ +// Image — the painting as seen. iChannel0 = D (canvas), iChannel1 = B (original), iChannel2 = "image". +// With an image bound, untouched areas sample it directly (no second resample through the canvas); +// smeared areas show the canvas with fresh paper grain and a wet sheen. + +void mainImage(out vec4 o, in vec2 f) { + vec2 uv = f / iResolution.xy; + vec2 q = uv * vec2(iResolution.x / iResolution.y, 1.0); + vec4 cv = texture(iChannel0, uv); + vec3 og = texture(iChannel1, uv).rgb; + vec3 d = abs(cv.rgb - og); + float disturbed = max(smoothstep(DISTURB_LO, DISTURB_HI, max(d.r, max(d.g, d.b))), cv.a); + vec3 smeared = cv.rgb * (1.0 - WET_DARKEN * cv.a) * paperGrain(q); + vec3 col; + if (iChannelResolution[2].x > 0.0) { + vec3 im = texture(iChannel2, coverUV(uv, iResolution.xy, iChannelResolution[2].xy)).rgb; + col = mix(im, smeared, disturbed); + } else { + col = smeared; // procedural: grain everywhere, as the sketch + } + o = vec4(col, 1.0); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/setup.json b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/setup.json new file mode 100644 index 0000000..d2ffe94 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/setup.json @@ -0,0 +1,10 @@ +{ + "_comment": "Watercolor smudge: a watercolor painting that visitors' moving hands, feet and head smear; heals once the room empties. Images: images/ (shuffled per load, ADR-0023); none = procedural sunset. Spec: docs/superpowers/specs/2026-11-07-watercolor-smudge-design.md", + "duration_s": 120, + "channels": { + "buffer_b": ["B", "image"], + "buffer_c": ["A", "C"], + "buffer_d": ["D", "B", "C"], + "image": ["D", "B", "image"] + } +} diff --git a/ShowControl/FundingCAPTCHA/shadertoy.py b/ShowControl/FundingCAPTCHA/shadertoy.py index 73c9546..ce8868f 100644 --- a/ShowControl/FundingCAPTCHA/shadertoy.py +++ b/ShowControl/FundingCAPTCHA/shadertoy.py @@ -23,7 +23,10 @@ A slot keeps its track for the track's whole life. In Shadertoy the source is shaders/blob_painter.glsl in a spare buffer. -Channel sources: "A" "B" "C" "D" "keyboard" "blobs" or null. Buffers are linear- +Source "image" is one image from /images/, chosen per load by +shader_app.SetupImages (ADR-0024); unbound (reads 0, resolution 0) without one. + +Channel sources: "A" "B" "C" "D" "keyboard" "blobs" "image" or null. Buffers are linear- filtered and clamped like Shadertoy's, but RGBA16F where Shadertoy's are 32-bit: the Pi's V3D can't linear-filter 32-bit float. Values above ~2048 lose integer precision. @@ -45,7 +48,8 @@ log = logging.getLogger("shader_app") BUFFERS = ("buffer_b", "buffer_c", "buffer_d") # render order, before image -SOURCES = {"A", "B", "C", "D", "keyboard", "blobs"} +SOURCES = {"A", "B", "C", "D", "keyboard", "blobs", "image"} +IMAGE_EXTS = (".png", ".jpg", ".jpeg") # /images/ files, any case _BUF_KEY = {"buffer_b": "B", "buffer_c": "C", "buffer_d": "D"} PAINTER_PATH = Path(__file__).parent / "shaders" / "body_painter.glsl" @@ -102,11 +106,16 @@ class Setup: common: str passes: dict[str, str] # pass → source, render order channels: dict[str, list[str | None]] = field(default_factory=dict) + images: list[Path] = field(default_factory=list) # /images/, sorted @property def uses_blobs(self) -> bool: return any("blobs" in chans for chans in self.channels.values()) + @property + def uses_image(self) -> bool: + return any("image" in chans for chans in self.channels.values()) + def load_setup(path: Path) -> Setup: path = Path(path) @@ -138,6 +147,11 @@ def load_setup(path: Path) -> Setup: raise ShaderError(f"{path.name}: {p} reads {missing} but no such buffer file") channels[p] = list(chans) + [None] * (4 - len(chans)) + img_dir = path / "images" + images = sorted((f for f in img_dir.iterdir() + if f.is_file() and f.suffix.lower() in IMAGE_EXTS), + key=lambda f: f.name.lower()) if img_dir.is_dir() else [] + common_path = path / "common.glsl" return Setup( name = path.name, @@ -145,9 +159,30 @@ def load_setup(path: Path) -> Setup: common = common_path.read_text() if common_path.exists() else "", passes = passes, channels = channels, + images = images, ) +def decode_image(path: Path, cover: tuple[int, int], max_side: int = 16384) -> np.ndarray: + """An image file as uint8 RGBA (h, w, 4), row 0 = bottom (GL), upright per its EXIF. + + Downscaled (aspect kept, never upscaled) to the smallest size that still covers + `cover`, since Setups cover-fit it to the screen; no side exceeds `max_side`. + """ + from PIL import Image, ImageOps + try: + with Image.open(path) as im: + im = ImageOps.exif_transpose(im).convert("RGBA") + w, h = im.size + scale = min(1.0, max(cover[0] / w, cover[1] / h), max_side / max(w, h)) + if scale < 1.0: + im = im.resize((max(1, round(w * scale)), max(1, round(h * scale))), + Image.Resampling.LANCZOS) + return np.ascontiguousarray(np.flipud(np.asarray(im, dtype=np.uint8))) + except Exception as exc: + raise ShaderError(f"{Path(path).name}: can't decode image: {exc}") from exc + + def wrap_source(common: str, body: str) -> str: """Shadertoy tab → GLSL 330 fragment shader; error line numbers match the tab.""" return f"{_HEADER}#line 1 1\n{common}\n#line 1 0\n{body}\n{_FOOTER}" @@ -306,6 +341,7 @@ def __init__(self, ctx, size: tuple[int, int], body_mode: str = "camera") -> Non self._painter = self._compile("", PAINTER_PATH.read_text(), "body_painter") self._painter_buf = _PingPong(ctx, size) + self._image_tex = None # this load's "image" source (ADR-0024) self._passes: dict[str, tuple] = {} # pass → (prog, vao) self._buffers: dict[str, _PingPong] = {} self.setup: Setup | None = None @@ -322,8 +358,12 @@ def _compile(self, common: str, body: str, label: str) -> tuple: vao = self.ctx.vertex_array(prog, [(self.vbo, "2f", "in_vert")]) return prog, vao - def load(self, setup: Setup) -> None: - """Compile every pass first so a broken Setup leaves the current one running.""" + def load(self, setup: Setup, image: np.ndarray | None = None) -> None: + """Compile every pass first so a broken Setup leaves the current one running. + + `image` (uint8 RGBA, row 0 = bottom, from decode_image) is bound to the + "image" source; without one that source reads 0 with resolution 0. + """ compiled = {} try: for p, src in setup.passes.items(): @@ -336,6 +376,11 @@ def load(self, setup: Setup) -> None: self._release_setup() self._passes = compiled self._buffers = {p: _PingPong(self.ctx, self.size) for p in setup.passes if p in BUFFERS} + if image is not None: + h, w = image.shape[:2] + self._image_tex = self.ctx.texture((w, h), 4, data=np.ascontiguousarray(image).tobytes()) + self._image_tex.filter = (self._mgl.LINEAR, self._mgl.LINEAR) + self._image_tex.repeat_x = self._image_tex.repeat_y = False self.setup = setup self.reset_clock() log.info("Setup loaded: %s (%s)", setup.name, ", ".join(setup.passes)) @@ -351,6 +396,14 @@ def _release_setup(self) -> None: for b in self._buffers.values(): b.release() self._passes, self._buffers = {}, {} + if self._image_tex is not None: + self._image_tex.release() + self._image_tex = None + + @property + def image_size(self) -> tuple[int, int] | None: + """(w, h) of the bound "image" source, or None.""" + return tuple(self._image_tex.size) if self._image_tex is not None else None def reset_clock(self) -> None: self.time = 0.0 @@ -391,6 +444,8 @@ def _source_tex(self, src: str | None): return self._kb_tex if src == "blobs": return self._blobs_tex + if src == "image": + return self._image_tex if src == "A": return self._painter_buf.read_tex if self._painter_buf else self._body_tex return self._buffers[f"buffer_{src.lower()}"].read_tex diff --git a/ShowControl/FundingCAPTCHA/test_playlist_app.py b/ShowControl/FundingCAPTCHA/test_playlist_app.py index 94f7a25..dad9a13 100644 --- a/ShowControl/FundingCAPTCHA/test_playlist_app.py +++ b/ShowControl/FundingCAPTCHA/test_playlist_app.py @@ -8,7 +8,7 @@ from playlist_app import Item, Playlist, VideoPlayer, fit_rect, load_playlist from shadertoy import Renderer -from test_shadertoy import SOLID, ctx # noqa: F401 (GL context fixture) +from test_shadertoy import SOLID, _image_setup, _rendered_colour, ctx # noqa: F401 (GL context fixture) # ── Playlist file ───────────────────────────────────────────────────────────── @@ -201,3 +201,16 @@ def test_single_setup_is_not_reloaded_on_expiry(ctx, tmp_path): pl.tick() assert r.setup is first and pl.started == 300.0 r.release() + + +def test_setup_gets_a_shuffled_image_per_load(ctx, tmp_path): + """The "image" source works under the playlist as under shader_app.Rotation (ADR-0024).""" + _image_setup(tmp_path, {"r.png": (255, 0, 0, 255), "g.png": (0, 255, 0, 255)}) + r = Renderer(ctx, (4, 4)) + pl = Playlist(r, [Item("setup", "s")], 300, shader_dir=tmp_path, video_dir=tmp_path) + pl.next() + seen = [_rendered_colour(r)] + pl.reload() + seen.append(_rendered_colour(r)) + assert set(seen) == {(255, 0, 0, 255), (0, 255, 0, 255)} + r.release() diff --git a/ShowControl/FundingCAPTCHA/test_shadertoy.py b/ShowControl/FundingCAPTCHA/test_shadertoy.py index e93b296..f2e3b27 100644 --- a/ShowControl/FundingCAPTCHA/test_shadertoy.py +++ b/ShowControl/FundingCAPTCHA/test_shadertoy.py @@ -13,7 +13,7 @@ from shadertoy import (BLOB_CONFIRMED, BLOB_COASTING, BLOB_EMPTY, BLOB_LOST, BLOB_PAINTER_PATH, BLOB_ROWS, BLOB_SLOTS, BLOB_TENTATIVE, BodyMouse, Keyboard, Renderer, - ShaderError, js_keycode, load_setup, pack_body) + ShaderError, decode_image, js_keycode, load_setup, pack_body) SHADER_DIR = Path(__file__).parent / "shaders" @@ -152,6 +152,68 @@ def test_js_keycode(): assert js_keycode(pygame.K_F12) is None +def _png(path: Path, size: tuple[int, int], colour: tuple, rows: list | None = None) -> Path: + """Solid PNG, or one colour per row band (top first) when `rows` is given.""" + from PIL import Image + im = Image.new("RGBA", size, colour) + if rows: + band = size[1] // len(rows) + for i, c in enumerate(rows): + for y in range(i * band, (i + 1) * band): + for x in range(size[0]): + im.putpixel((x, y), c) + im.save(path) + return path + + +def test_load_setup_lists_images_sorted_ignoring_others(tmp_path): + d = _write_setup(tmp_path, {"image.glsl": SOLID}, {"channels": {"image": ["image"]}}) + (d / "images").mkdir() + for n in ("b.JPG", "a.png", "c.jpeg", "notes.md", ".DS_Store"): + (d / "images" / n).write_bytes(b"x") + s = load_setup(d) + assert [p.name for p in s.images] == ["a.png", "b.JPG", "c.jpeg"] + assert s.uses_image + + +def test_load_setup_without_images_folder_has_no_images(tmp_path): + s = load_setup(_write_setup(tmp_path, {"image.glsl": SOLID}, {"channels": {"image": ["image"]}})) + assert s.images == [] and s.uses_image + + +def test_setup_without_image_source_does_not_use_image(tmp_path): + assert not load_setup(_write_setup(tmp_path, {"image.glsl": SOLID})).uses_image + + +def test_decode_image_puts_bottom_row_first(tmp_path): + p = _png(tmp_path / "t.png", (1, 2), (0, 0, 0, 255), rows=[(255, 0, 0, 255), (0, 0, 255, 255)]) + a = decode_image(p, (64, 64)) + assert a.shape == (2, 1, 4) and a.dtype == np.uint8 + assert tuple(a[0, 0]) == (0, 0, 255, 255) and tuple(a[1, 0]) == (255, 0, 0, 255) + + +def test_decode_image_downscales_only_while_still_covering(tmp_path): + p = _png(tmp_path / "w.png", (400, 200), (10, 20, 30, 255)) + assert decode_image(p, (100, 100)).shape == (100, 200, 4) # covers 100×100, aspect kept + assert decode_image(p, (1000, 1000)).shape == (200, 400, 4) # never upscaled + assert decode_image(p, (100, 100), max_side=150).shape == (75, 150, 4) + + +def test_decode_image_applies_exif_orientation(tmp_path): + from PIL import Image + im = Image.new("RGB", (40, 20), (200, 10, 10)) + exif = Image.Exif() + exif[0x0112] = 6 # "rotate 90° CW to display" + im.save(tmp_path / "phone.jpg", exif=exif) + assert decode_image(tmp_path / "phone.jpg", (1000, 1000)).shape == (40, 20, 4) + + +def test_decode_image_corrupt_raises(tmp_path): + (tmp_path / "bad.png").write_bytes(b"not a png") + with pytest.raises(ShaderError): + decode_image(tmp_path / "bad.png", (64, 64)) + + # ── GL ──────────────────────────────────────────────────────────────────────── @pytest.fixture(scope="module") @@ -463,3 +525,124 @@ def test_rotation_calls_on_load_for_every_load(ctx, tmp_path): rot.reload() # broken edit: nothing reloaded assert len(loads) == 2 r.release() + + +# ── Image source (ADR-0024) ─────────────────────────────────────────────────── + +SHOW_CH0 = ("void mainImage(out vec4 o, in vec2 f) {" + " o = vec4(texture(iChannel0, f / iResolution.xy).rgb," + " iChannelResolution[0].x > 0.0 ? 1.0 : 0.0); }") + + +def _image_setup(tmp_path: Path, images: dict[str, tuple | bytes] | None = None) -> Path: + """Setup "s" under tmp_path showing its image; images: name → solid RGBA or raw bytes.""" + d = _write_setup(tmp_path, {"image.glsl": SHOW_CH0}, {"channels": {"image": ["image"]}}) + if images is not None: + (d / "images").mkdir() + for name, v in images.items(): + if isinstance(v, bytes): + (d / "images" / name).write_bytes(v) + else: + _png(d / "images" / name, (4, 4), v) + return d + + +def test_image_channel_reaches_shader(ctx, tmp_path): + img = np.zeros((2, 1, 4), dtype=np.uint8) + img[0, 0] = (0, 0, 255, 255) # row 0 = bottom + img[1, 0] = (255, 0, 0, 255) + r = Renderer(ctx, (4, 4)) + r.load(load_setup(_image_setup(tmp_path)), image=img) + r.render(1 / 30, (0, 0, 0, 0)) + assert _pixel(r, 1, 3) == (255, 0, 0, 255) # GL y=3 is the top + assert _pixel(r, 1, 0) == (0, 0, 255, 255) + r.release() + + +def test_unbound_image_reads_zero_resolution(ctx, tmp_path): + r = Renderer(ctx, (4, 4)) + r.load(load_setup(_image_setup(tmp_path))) + r.render(1 / 30, (0, 0, 0, 0)) + assert _pixel(r, 1, 1) == (0, 0, 0, 0) + r.release() + + +def _rendered_colour(r: Renderer) -> tuple: + r.render(1 / 30, (0, 0, 0, 0)) + return _pixel(r, 1, 1) + + +def test_rotation_shuffles_images_per_load(ctx, tmp_path): + from shader_app import Rotation + _image_setup(tmp_path, {"r.png": (255, 0, 0, 255), "g.png": (0, 255, 0, 255), + "b.png": (0, 0, 255, 255)}) + r = Renderer(ctx, (4, 4)) + rot = Rotation(r, ["s"], shader_dir=tmp_path) + rot.next() + seen = [_rendered_colour(r)] + for _ in range(3): + rot.reload() + seen.append(_rendered_colour(r)) + assert len(set(seen[:3])) == 3 # no repeat until all have shown + assert seen[3] in seen[:3] + r.release() + + +def test_rotation_skips_corrupt_image(ctx, tmp_path): + from shader_app import Rotation + _image_setup(tmp_path, {"bad.png": b"not a png", "good.png": (0, 255, 0, 255)}) + r = Renderer(ctx, (4, 4)) + rot = Rotation(r, ["s"], shader_dir=tmp_path) + rot.next() + colours = [_rendered_colour(r)] + for _ in range(3): + rot.reload() + colours.append(_rendered_colour(r)) + assert colours == [(0, 255, 0, 255)] * 4 + r.release() + + +def test_rotation_with_only_corrupt_images_binds_nothing(ctx, tmp_path): + from shader_app import Rotation + _image_setup(tmp_path, {"bad.png": b"not a png"}) + r = Renderer(ctx, (4, 4)) + rot = Rotation(r, ["s"], shader_dir=tmp_path) + rot.next() + assert r.setup is not None and r.setup.name == "s" + assert _rendered_colour(r)[3] == 0 + r.release() + + +def test_rotation_scales_images_to_cover_image_max(ctx, tmp_path): + from shader_app import Rotation + d = _image_setup(tmp_path, {}) + _png(d / "images" / "big.png", (40, 20), (9, 9, 9, 255)) + r = Renderer(ctx, (4, 4)) + rot = Rotation(r, ["s"], shader_dir=tmp_path, image_max=(10, 10)) + rot.next() + assert r.image_size == (20, 10) # smallest size that still covers 10×10 + r.release() + + +# ── Display choice ──────────────────────────────────────────────────────────── + +def test_pick_display_uses_that_displays_own_size(): + from shader_app import pick_display + sizes = [(3072, 1920), (1600, 1200)] + assert pick_display(sizes, 1) == (1, (1600, 1200)) + assert pick_display(sizes, 0) == (0, (3072, 1920)) + + +def test_pick_display_out_of_range_falls_back_to_first(): + from shader_app import pick_display + assert pick_display([(1920, 1080)], 2) == (0, (1920, 1080)) + assert pick_display([(1920, 1080)], -1) == (0, (1920, 1080)) + + +def test_display_index_flag_beats_settings(): + from argparse import Namespace + from shader_app import display_index + settings = {"shader_app": {"display": 1}} + assert display_index(Namespace(display=None), settings) == 1 + assert display_index(Namespace(display=2), settings) == 2 + assert display_index(Namespace(display=None), {}) == 0 diff --git a/ShowControl/FundingCAPTCHA/test_watercolor_smudge.py b/ShowControl/FundingCAPTCHA/test_watercolor_smudge.py new file mode 100644 index 0000000..d8b08b5 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/test_watercolor_smudge.py @@ -0,0 +1,334 @@ +"""Behaviour tests for the watercolor_smudge Setup (shaders/watercolor_smudge/). + +Spec: docs/superpowers/specs/2026-11-07-watercolor-smudge-design.md. +Buffers: B original painting, C motion (eased Body, extremity, empty timer), +D canvas (paint + wetness). The Body is uploaded directly at render size. +GL tests need a headless context (EGL / llvmpipe) and skip without one. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import numpy as np +import pytest + +from shadertoy import Renderer, load_setup + +SETUP = Path(__file__).parent / "shaders" / "watercolor_smudge" +SIZE = (96, 54) +DT = 1 / 30 +ABSENT, PRESENT = (0.0, 0.0, -1.0, -1.0), (48.0, 27.0, 48.0, 27.0) + + +@pytest.fixture(scope="module") +def ctx(): + moderngl = pytest.importorskip("moderngl") + for kw in ({"backend": "egl"}, {}): + try: + c = moderngl.create_standalone_context(**kw) + break + except Exception: + continue + else: + if os.environ.get("CI"): + pytest.fail("no headless OpenGL context in CI — GL tests would silently skip") + pytest.skip("no headless OpenGL context") + yield c + c.release() + + +def _rig(ctx, image: np.ndarray | None = None) -> Renderer: + r = Renderer(ctx, SIZE, body_mode="camera") + r.load(load_setup(SETUP), image=image) + r.upload_body(np.zeros((SIZE[1], SIZE[0], 4), dtype=np.uint8)) + return r + + +def _buf(r: Renderer, name: str) -> np.ndarray: + tex = r._buffers[name].read_tex + data = np.frombuffer(tex.read(), dtype=np.float16) + return data.reshape(tex.size[1], tex.size[0], 4).astype(np.float32) + + +def _out(r: Renderer) -> np.ndarray: + data = np.frombuffer(r.output.read(components=4), dtype=np.uint8) + return data.reshape(SIZE[1], SIZE[0], 4) + + +def _frames(r: Renderer, n: int, mouse=ABSENT) -> None: + for _ in range(n): + r.render(DT, mouse) + + +def _halves(left: tuple, right: tuple) -> np.ndarray: + img = np.zeros((SIZE[1], SIZE[0], 4), dtype=np.uint8) + img[:, : SIZE[0] // 2] = left + img[:, SIZE[0] // 2:] = right + return img + + +# ── Task 2: original painting, canvas, display ──────────────────────────────── + +def test_without_image_paints_procedural_sunset(ctx): + r = _rig(ctx) + _frames(r, 3) + o = _out(r).astype(int) + top = o[48, 48, :3] # GL row 48 is near the top + assert top[2] > top[0] # sky top is blue-violet + assert o.std() > 10 # not flat + assert (o[1, 1, :3] > 200).all() # ragged paper border at the corner + r.release() + + +def test_canvas_starts_equal_to_original(ctx): + r = _rig(ctx) + _frames(r, 2) + diff = np.abs(_buf(r, "buffer_d")[..., :3] - _buf(r, "buffer_b")[..., :3]) + assert diff.max() < 1 / 255 + r.release() + + +def test_image_shows_unchanged_when_undisturbed(ctx): + r = _rig(ctx, image=_halves((255, 0, 0, 255), (0, 0, 255, 255))) + _frames(r, 3) + o = _out(r).astype(int) + assert np.abs(o[27, 20, :3] - (255, 0, 0)).max() <= 2 + assert np.abs(o[27, 76, :3] - (0, 0, 255)).max() <= 2 + r.release() + + +def test_image_covers_screen_keeping_aspect(ctx): + # Portrait 54×96, bands top→bottom green / red / blue (row 0 = bottom). + img = np.zeros((96, 54, 4), dtype=np.uint8) + img[64:] = (0, 255, 0, 255) + img[32:64] = (255, 0, 0, 255) + img[:32] = (0, 0, 255, 255) + r = _rig(ctx, image=img) + _frames(r, 3) + o = _out(r).astype(int) + assert (o[..., 0] > 200).all() # cover crops top and bottom: all red + assert (o[..., 1] < 30).all() and (o[..., 2] < 30).all() + r.release() + + +# ── Task 3: motion, extremity smear, heal ───────────────────────────────────── + +CAMERA = (384, 216) # a camera-like Body resolution, for flicker + + +def _bar(w_frac: float, x_frac: float, extra: np.ndarray | None = None, + size: tuple[int, int] = SIZE) -> np.ndarray: + """Body: a full-height vertical bar w_frac screen widths wide, centred at x_frac.""" + body = np.zeros((size[1], size[0], 4), dtype=np.uint8) + x = (np.arange(size[0]) + 0.5) / size[0] + body[:, np.abs(x - x_frac) < w_frac / 2, 0] = 255 + if extra is not None: + body[..., 0] = np.maximum(body[..., 0], extra) + return body + + +def _diff(r: Renderer) -> np.ndarray: + """|canvas − original| per texel, max over rgb.""" + return np.abs(_buf(r, "buffer_d")[..., :3] - _buf(r, "buffer_b")[..., :3]).max(axis=2) + + +def _sweep(r: Renderer, w_frac: float, x0: float, x1: float, seconds: float = 1.0) -> None: + """Move a bar from x0 to x1, Body at 10 Hz, render at 30 Hz, someone present.""" + n = int(round(seconds / DT)) + for i in range(n + 1): + if i % 3 == 0: + r.upload_body(_bar(w_frac, x0 + (x1 - x0) * i / n)) + r.render(DT, PRESENT) + + +def _cols(x0: float, x1: float) -> slice: + return slice(int(x0 * SIZE[0]), int(x1 * SIZE[0])) + + +def test_extremity_scores_thin_high_and_broad_low(ctx): + r = _rig(ctx) + body = _bar(0.02, 0.3) + body[..., 0] |= _bar(0.25, 0.7)[..., 0] + r.upload_body(body) + _frames(r, 3, PRESENT) + ext = _buf(r, "buffer_c")[..., 2] + assert ext[27, int(0.3 * SIZE[0])] > 0.8 + assert ext[27, int(0.7 * SIZE[0])] < 0.2 + r.release() + + +def test_moving_limb_smears(ctx): + r = _rig(ctx) + _frames(r, 2) + _sweep(r, 0.02, 0.2, 0.6) + assert _diff(r)[:, _cols(0.2, 0.6)].max() > 0.1 + r.release() + + +def _checker(cell: int = 6) -> np.ndarray: + yy, xx = np.mgrid[0:SIZE[1], 0:SIZE[0]] + ch = ((xx // cell + yy // cell) % 2).astype(np.uint8) * 255 + return np.stack([ch, 255 - ch, np.full_like(ch, 128), np.full_like(ch, 255)], -1) + + +def test_moving_limb_smears_most_of_its_path(ctx): + """A camera-hard Body edge still carries paint along, as the sketch's soft figure did.""" + r = _rig(ctx, image=_checker()) + _frames(r, 2) + _sweep(r, 0.02, 0.2, 0.6) + assert (_diff(r)[:, _cols(0.25, 0.55)] > 0.1).mean() > 0.3 + r.release() + + +def test_black_paint_at_centre_still_smears(ctx): + img = _checker() + img[20:34, 40:56, :3] = 0 # pure black patch over the centre + r = _rig(ctx, image=img) + _frames(r, 2) + _sweep(r, 0.02, 0.2, 0.6) + assert (_diff(r)[:, _cols(0.25, 0.55)] > 0.1).mean() > 0.3 + r.release() + + +def _arm(x0: float, x1: float, y: float = 0.5, h: float = 0.07) -> np.ndarray: + """Body: a horizontal arm from x0 to x1 (screen widths), h screen heights thick.""" + body = np.zeros((SIZE[1], SIZE[0], 4), dtype=np.uint8) + xs = (np.arange(SIZE[0]) + 0.5) / SIZE[0] + ys = (np.arange(SIZE[1]) + 0.5) / SIZE[1] + body[np.ix_(np.abs(ys - y) < h / 2, (xs >= x0) & (xs <= x1))] = (255, 0, 0, 0) + return body + + +def test_reaching_arm_along_its_axis_does_not_smear_yet(ctx): + """Known limitation (final-review ruling): an arm extending along its own axis only gains + Body at the tip, so the balance gate reads it as appearing and suppresses the smear. + Pinned so a gate change that fixes it is noticed; flip the assertion when it does.""" + r = _rig(ctx, image=_checker()) + r.upload_body(_arm(0.25, 0.3)) + _frames(r, 15, PRESENT) + n = 30 + for i in range(n + 1): + if i % 3 == 0: + r.upload_body(_arm(0.25, 0.3 + 0.5 * i / n)) + r.render(DT, PRESENT) + rows = slice(int(0.4 * SIZE[1]), int(0.6 * SIZE[1])) + assert (_diff(r)[rows, _cols(0.35, 0.75)] > 0.1).mean() < 0.02 + r.release() + + +def test_moving_torso_does_not_smear(ctx): + r = _rig(ctx) + _frames(r, 2) + _sweep(r, 0.3, 0.2, 0.6) + assert _diff(r).max() < 0.03 + r.release() + + +def test_still_flickering_person_leaves_paint_alone(ctx): + """A still limb whose 1-camera-px edge flickers at 10 Hz (rim pixels on/off, edge dropout).""" + rng = np.random.default_rng(7) + r = _rig(ctx) + _frames(r, 2) + base = _bar(0.02, 0.5, size=CAMERA) + cols = np.flatnonzero(base[0, :, 0]) + for i in range(90): # 3 s + if i % 3 == 0: + body = base.copy() + for side in (cols[0] - 1, cols[-1] + 1): # rim pixels switch on + body[rng.random(CAMERA[1]) < 0.5, side, 0] = 255 + for edge in (cols[0], cols[-1]): # edge pixels drop out + body[rng.random(CAMERA[1]) < 0.3, edge, 0] = 0 + r.upload_body(body) + r.render(DT, PRESENT) + assert _diff(r).max() < 0.02 + r.release() + + +def test_smear_persists_while_present(ctx): + r = _rig(ctx) + _frames(r, 2) + _sweep(r, 0.02, 0.2, 0.6) + before = _diff(r).sum() + _frames(r, 150, PRESENT) # 5 s, bar still at 0.6 + assert _diff(r).sum() > 0.9 * before + r.release() + + +def _smeared_then_empty(ctx) -> tuple[Renderer, float]: + r = _rig(ctx) + _frames(r, 2) + _sweep(r, 0.02, 0.2, 0.6) + _sweep(r, 0.02, 0.6, 0.6, seconds=0.5) # stand still so the paint settles + r.upload_body(np.zeros((SIZE[1], SIZE[0], 4), dtype=np.uint8)) + return r, _diff(r).sum() + + +def test_heal_waits_for_delay_then_heals_in_heal_time(ctx): + r, start = _smeared_then_empty(ctx) + _frames(r, 45) # 1.5 s empty + assert _diff(r).sum() > 0.9 * start + _frames(r, 315) # to 12 s: 2 s delay + 10 s heal + assert _diff(r).sum() < 0.05 * start + r.release() + + +def test_heal_pauses_when_someone_returns(ctx): + r, start = _smeared_then_empty(ctx) + _frames(r, 120) # 4 s empty: 2 s of healing + r.upload_body(_bar(0.02, 0.6)) + _frames(r, 15, PRESENT) # let the arrival settle + mid = _diff(r).sum() + _frames(r, 90, PRESENT) # 3 s present + assert _diff(r).sum() > 0.95 * mid + r.release() + + +def test_paper_border_never_smears(ctx): + r = _rig(ctx) + _frames(r, 2) + _sweep(r, 0.02, 0.0, 0.2) + border = _buf(r, "buffer_b")[..., 3] < 0.01 + assert border.any() + assert _diff(r)[border].max() < 1 / 255 + r.release() + + +def test_state_stays_bounded_when_idle(ctx): + r = _rig(ctx) + _frames(r, 4000) + c = _buf(r, "buffer_c") + assert c[0, 0, 3] == pytest.approx(10.0, abs=0.1) # EMPTY_CLAMP_S + for name in ("buffer_b", "buffer_c", "buffer_d"): + assert np.isfinite(_buf(r, name)).all() + assert _diff(r).max() < 1 / 255 + r.release() + + +# ── Task 3: display ─────────────────────────────────────────────────────────── + +def test_image_stays_sharp_outside_smear(ctx): + yy, xx = np.mgrid[0:SIZE[1], 0:SIZE[0]] + checker = ((xx // 4 + yy // 4) % 2).astype(np.uint8) * 255 + img = np.stack([checker, 255 - checker, np.full_like(checker, 128), np.full_like(checker, 255)], -1) + r = _rig(ctx, image=img) + _frames(r, 2) + _sweep(r, 0.02, 0.2, 0.4) + o = _out(r).astype(int) + far = _cols(0.75, 0.95) + assert np.abs(o[:, far, :3] - img[:, far, :3].astype(int)).max() <= 2 + assert np.abs(o[:, _cols(0.2, 0.4), :3] - img[:, _cols(0.2, 0.4), :3].astype(int)).max() > 25 + r.release() + + +def test_wet_smear_reads_darker_than_dry(ctx): + r = _rig(ctx) + _frames(r, 2) + _sweep(r, 0.02, 0.2, 0.6) + swept = (slice(None), _cols(0.25, 0.55)) + wet = _out(r)[swept][..., :3].astype(float).mean() + wetness = _buf(r, "buffer_d")[..., 3][swept].max() + _frames(r, 120, PRESENT) # 4 s, present: no heal + assert wetness > 0.5 + assert wet < _out(r)[swept][..., :3].astype(float).mean() + r.release() diff --git a/docs/FundingCAPTCHA.md b/docs/FundingCAPTCHA.md index 37e6707..7bdf3ab 100644 --- a/docs/FundingCAPTCHA.md +++ b/docs/FundingCAPTCHA.md @@ -84,7 +84,7 @@ Shader Setups and full-screen videos in one shuffle bag: every item plays once b A missing or unreadable item is logged and skipped. The camera runs throughout, so the background keeps learning across items. -Flags: `--camera` / `--mock-camera` / `--test-input` / `--windowed` as `shader_app.py`, and `--playlist PATH` to use another playlist file. Keys: `N` / `→` next item · `R` reload the current Setup from disk · Escape quit. +Flags: `--camera` / `--mock-camera` / `--test-input` / `--windowed` / `--display N` as `shader_app.py`, and `--playlist PATH` to use another playlist file. Keys: `N` / `→` next item · `R` reload the current Setup from disk · Escape quit. --- @@ -93,7 +93,7 @@ Flags: `--camera` / `--mock-camera` / `--test-input` / `--windowed` as `shader_a Per-machine overrides go in `captcha-settings.local.json` (loaded second, shallow-merged on top — not gitignored, but per-host). Top-level fields: -- `shader_app` — shader player: `render_scale` (fraction of display resolution, default 0.5), `fps` (default 30), `blobs` (blob-record tracking for the `blobs` source, ADR-0022: `min_blob_pixels`, `min_confirm_frames`, `max_miss_frames`, `max_match_dist_px`, `smoothing_alpha`, `lost_hold_s`, `ease_s`; Body pixels and camera frames) +- `shader_app` — shader player: `render_scale` (fraction of display resolution, default 0.5), `display` (which monitor to go fullscreen on, 0 = first; `--display N` overrides), `fps` (default 30), `blobs` (blob-record tracking for the `blobs` source, ADR-0022: `min_blob_pixels`, `min_confirm_frames`, `max_miss_frames`, `max_match_dist_px`, `smoothing_alpha`, `lost_hold_s`, `ease_s`; Body pixels and camera frames) - `camera` — Orbbec config (`serial`, `width`, `height`, `fps`, `pos_cm`, `rotation`) - `detection` — `depth_delta_mm`, `min_blob_pixels`, `min_depth_mm`, `max_depth_mm` - `depth_slabs[]` — `[{near_mm, far_mm, slab_id}, …]`; two slabs may share `slab_id` diff --git a/docs/adr/0021-fundingcaptcha-shader-player.md b/docs/adr/0021-fundingcaptcha-shader-player.md index f54c524..3ffedea 100644 --- a/docs/adr/0021-fundingcaptcha-shader-player.md +++ b/docs/adr/0021-fundingcaptcha-shader-player.md @@ -1,6 +1,6 @@ # FundingCAPTCHA shader player — Shadertoy Setups driven by the depth camera -**Amended by:** [ADR-0022](0022-shader-player-blob-records.md) (a `blobs` channel source: boxes, stable IDs, lifecycle), [ADR-0023](0023-fundingcaptcha-self-learning-depth-background.md) (no calibration wait at startup) +**Amended by:** [ADR-0022](0022-shader-player-blob-records.md) (a `blobs` channel source: boxes, stable IDs, lifecycle), [ADR-0023](0023-fundingcaptcha-self-learning-depth-background.md) (no calibration wait at startup), [ADR-0024](0024-shader-player-image-channel.md) (an `image` channel source: one shuffled image per load) ## Context diff --git a/docs/adr/0024-shader-player-image-channel.md b/docs/adr/0024-shader-player-image-channel.md new file mode 100644 index 0000000..ae8a40e --- /dev/null +++ b/docs/adr/0024-shader-player-image-channel.md @@ -0,0 +1,32 @@ +# ADR 0024 — Shader player image channel: one shuffled image per load + +**Status:** Accepted +**Amends:** [ADR-0021](0021-fundingcaptcha-shader-player.md) + +## Context + +ADR-0021 lets Setups use only procedural content and the Body: "Shadertoy can't take uploaded media." The Watercolor Smudge Setup ([spec](../superpowers/specs/2026-11-07-watercolor-smudge-design.md), issue #164) smears and heals a watercolor painting. Painted watercolors look far better than procedural watercolor-ification. They get wet edges, blooms and granulation for free, while a filter reads as a photo-app effect. The artist will supply a set of paintings, and each time the Setup plays it should show a different one. + +## Decision + +1. **A new channel source, `"image"`.** It's bound like any other source in `setup.json`. It binds one image from the Setup's own `images/` folder (`.png`, `.jpg`, `.jpeg`, any case; other files are ignored). The texture is RGBA8, linear-filtered and clamped like every channel, with no mipmaps. `iChannelResolution` reports its size in pixels. + +2. **One image per load, shuffled.** `shader_app.SetupImages` (used by both `shader_app.Rotation` and `playlist_app.Playlist`) keeps one `arc.ShuffleBag` of image paths per Setup for the whole run. It draws the next one on every real load, so no image repeats until all have shown. + - **R** (hot reload) is a real load and advances the bag. + - A Setup that's the only one playing (`--setup NAME`, or a one-entry `setups.json`) isn't reloaded when its `duration_s` runs out (ADR-0021), so it keeps its image. + - If the folder's file list changes, the bag is rebuilt on the next load. + +3. **Decoding.** Images are decoded on load with Pillow into RGBA with row 0 at the bottom (GL, matching the Body), turned upright per their EXIF orientation (phone photos). Before upload, an image is downscaled, keeping aspect and never upscaled, to the smallest size that still *covers* the window, since Setups cover-fit it. No side may exceed `GL_MAX_TEXTURE_SIZE`. + +4. **No images is not an error.** With an empty or missing `images/` folder, the source is unbound: it reads 0 and `iChannelResolution[n]` is `(0, 0, 0)`. A Setup can test `iChannelResolution[n].x == 0.0` and fall back to something procedural. CI and fresh clones play such Setups normally. + +5. **Broken files are skipped.** A file that won't decode is logged and dropped from that Setup's bag for the run, and the next is tried. If none decode, the Setup plays with the source unbound. + +6. **Prototyping.** Shadertoy can't take uploads, so prototype with one of its built-in textures in that channel. Hand-in writes `"image"` where Shadertoy has the texture. + +## Consequences + +- Image files live in the public repo next to the Setup that uses them. Nothing private may go there (see `docs/FundingCAPTCHA.md`). +- Pillow, already a dependency, is now used by `shader_app.py`. +- Decoding happens at load time, on the render thread. A large JPEG adds a visible hitch at the Setup change. Keep images near display size. +- Only one image per Setup per load. A Setup that wants several images at once would need another amendment. diff --git a/docs/superpowers/plans/2026-11-07-watercolor-smudge.md b/docs/superpowers/plans/2026-11-07-watercolor-smudge.md new file mode 100644 index 0000000..bf6ea20 --- /dev/null +++ b/docs/superpowers/plans/2026-11-07-watercolor-smudge.md @@ -0,0 +1,429 @@ +# Watercolor Smudge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Two pieces of work: + +- The shader player gains an `"image"` channel source that shuffles through a Setup's `images/` folder, one image per load. +- A new Setup, `watercolor_smudge`, shows a watercolor painting. Only the thin, moving parts of a visitor's silhouette smear the paint, and it heals about 10 s after the room empties. + +**Architecture:** + +- **Player.** `shadertoy.py` gains: + - an `"image"` source; + - `Setup.images`, the files in `/images/`; + - `decode_image()`, using Pillow, with row 0 at the bottom, fitted to a maximum size; + - an image texture that `Renderer.load(setup, image=...)` binds. When no image is bound, the source reads 0 and its `iChannelResolution` is 0. + + `shader_app.Rotation` keeps one `ShuffleBag` of image paths per Setup. It decodes the next image on every real load (including **R**) and skips files that won't decode. +- **Setup.** Four tabs, rendered in the player's order B → C → D → Image: + - **B, "original"**: the painting to heal toward. If an image is bound, B samples it to cover the screen every frame. Otherwise B paints the procedural sunset once and then copies itself. `.a` = the smearable mask. + - **C, "motion"**: `.r` = eased Body, `.g` = last frame's eased Body, `.b` = extremity weight (ring coverage of the raw Body). Texel (0,0) `.a` = seconds the room has been empty. + - **D, "canvas"**: rgb = paint, `.a` = wetness. Each frame: normal-flow motion from C's `.r`/`.g`, weighted by `.b`, advects the paint. Pigment mixing and wetness are applied, then the canvas heals toward B. + - **Image**: shows the bound image sampled directly where the canvas matches the original. Where it differs, it shows the canvas, with paper grain and wet darkening there. + +**Tech Stack:** Python 3, moderngl, numpy, Pillow (already in `requirements.txt`), pytest; GLSL in the Shadertoy dialect, wrapped by `shadertoy.py`. + +**Spec:** `docs/superpowers/specs/2026-11-07-watercolor-smudge-design.md`, plus the approved sketch `docs/superpowers/specs/2026-11-07-watercolor-smudge-mockup.html`. Read both before starting. The sketch's `PAINT`, `SIM` and `SHOW` shaders and its `extremity()` are the behaviour and look to port. Its slider defaults are the tunable defaults. Also read: + +- `ShowControl/FundingCAPTCHA/shaders/README.md` +- `docs/adr/0021-fundingcaptcha-shader-player.md` and `docs/adr/0022-shader-player-blob-records.md` (0022 is the model for the new ADR) +- `shaders/watering_can/common.glsl`, the model for tunable constants and Body easing + +## Global Constraints + +- **Target machine:** an Intel Core Ultra 9 Linux PC, not the Pi. The player is unchanged otherwise: RGBA16F buffers, `HW_PERFORMANCE` 0, `render_scale` (default 0.5). Don't add a full-resolution output pass. +- **Shader player rules** (`shaders/README.md`): + - Read the Body (Buffer A) with `texture()` and UVs. + - Buffer state is 0–1 colour, or a bounded "seconds since" counter clamped at `T_CLAMP = 99.0`. + - Use `iTimeDelta` for all timing; behaviour must not depend on render rate. +- **Tunables:** every strength, threshold, radius, rate and time is a named, commented constant at the top of `shaders/watercolor_smudge/common.glsl`. Defaults are copied from the spec's table: + + | Constant | Default | + |---|---| + | `SMEAR_STRENGTH` | 0.63 | + | `THIN` | 0.5 (coverage threshold `mix(.18,.45,THIN)` ± 0.08) | + | `RING_R1`, `RING_R2` | 0.045, 0.08 | + | `STREAK_BASE`, `STREAK_AMP`, `STREAK_FREQ` | 0.7, 0.6, 45.0 | + | `WET_DECAY` | 0.35 | + | `HEAL_DELAY_S` | 2.0 | + | `HEAL_TIME_S` | 10.0 (heal rate `3.0 / HEAL_TIME_S`) | + | `PRESENT_HEAL` | 0.004 | + + Builder-chosen constants also go there: `BODY_EASE_S`, `MIN_FLOW`, `FLOW_EPS`, `FLOW_MAX`, `MIX_MOVE`, `MIX_WET`. +- **Units:** sizes are in screen heights. `q = uv * vec2(iResolution.x / iResolution.y, 1.0)`, as in the sketch. +- **Presence:** "anyone present" is `iMouse.z > 0.0`. The player sets it while anyone is in the Body (ADR-0021), and the body painter sets it while the mouse is held. +- **Never draw the person.** No silhouette, outline or ghost. +- **Shadertoy:** every tab must paste unchanged. Buffer A runs `body_painter.glsl`. Where `setup.json` says `"image"`, Shadertoy binds a built-in texture; the user accepts this. +- **Repo rules:** + - Never write a show, artist or album name into any file. + - Never commit anything under `ShowControl/FundingCAPTCHA/images/private/`. + - Commits end with `Co-Authored-By: Claude Opus 5.5 `. +- **Branch:** `claude/watercolor-smudge` (the spec is committed there). +- **Tests:** + - Run from `ShowControl/FundingCAPTCHA/`. + - `make test` from the repo root must stay green, except `test_compile_error_reports_tab_line` on NVIDIA drivers, which already fails and is unrelated. + +## Review Focus + +1. **A still person whose silhouette edge flickers.** Depth-edge noise toggles a rim of pixels while the person doesn't move, and the Body arrives at 10 Hz against a 30 fps render. Expected: the painting stays untouched. Pinned by `test_still_flickering_person_leaves_paint_alone` (Task 3). +2. **The room empties briefly, then someone returns before the heal finishes.** Expected: healing pauses back to the trickle rate, and the smears left stay put while they play. Pinned by `test_heal_waits_for_delay_and_pauses_on_return` (Task 3). +3. **An image whose aspect differs from the screen** (portrait art on a 16:9 screen). Expected: cover-fit, centred, not stretched, with no clamped edge smear. Pinned by `test_image_covers_screen_keeping_aspect` (Task 2). +4. **Every file in `images/` is corrupt, or the folder holds non-image files** such as `.DS_Store` or `README.md`. Expected: non-images are ignored. If nothing decodes, the Setup plays the procedural sunset and nothing crashes. Pinned by `test_rotation_with_only_corrupt_images_binds_nothing` and `test_load_setup_lists_images_sorted_ignoring_others` (Task 1). +5. **Hours of running.** The empty timer clamps and every buffer value stays finite. Pinned by `test_state_stays_bounded_when_idle` (Task 3). + +--- + +### Task 1: Player — the `"image"` channel source + +**Files:** +- Modify: `ShowControl/FundingCAPTCHA/shadertoy.py`: + - `SOURCES` + - `Setup` + - `load_setup` + - new `IMAGE_EXTS` and `decode_image` + - `Renderer.load`, `_release_setup`, `_source_tex`, `release` + - the module docstring +- Modify: `ShowControl/FundingCAPTCHA/shader_app.py`: `Rotation.__init__`, `Rotation._load`, and `main()` (pass `image_max`) +- Create: `docs/adr/0023-shader-player-image-channel.md` +- Modify: `docs/adr/0021-fundingcaptcha-shader-player.md`: add 0023 to its "Amended by" line +- Modify: `ShowControl/FundingCAPTCHA/shaders/README.md` +- Test: `ShowControl/FundingCAPTCHA/test_shadertoy.py` + +**Interfaces:** +- Produces: + - `IMAGE_EXTS = (".png", ".jpg", ".jpeg")`, matched case-insensitively. + - `Setup.images: list[Path]`: a sorted list of the files in `/images/` with an `IMAGE_EXTS` suffix. It's `[]` if the folder is missing. + - `Setup.uses_image -> bool`: true when any pass binds `"image"`. + - `decode_image(path: Path, max_size: tuple[int, int]) -> np.ndarray`: uint8 `(h, w, 4)` RGBA, with row 0 at the image's bottom. + - Downscales (Pillow `LANCZOS`, keeping aspect) only if it's wider than `max_size[0]` or taller than `max_size[1]`. + - Raises `ShaderError` for anything that won't decode. + - `Renderer.load(setup: Setup, image: np.ndarray | None = None) -> None`: binds `image` as an RGBA8 texture, `LINEAR` filtered and clamped. `_release_setup` releases it. + - `_source_tex("image")` returns that texture, or `None` when there is none, so the channel reads 0 and its `iChannelResolution` is `(0,0,0)`. + - `Rotation(..., image_max: tuple[int, int] = (4096, 4096))`. + - `Rotation._load(name)` decodes the next image for Setups with `uses_image` and passes it to `renderer.load`. It keeps one `ShuffleBag` per Setup name, built from `setup.images` on first use. + - A path that raises `ShaderError` is logged and permanently dropped from that Setup's bag. The next one is tried, at most `len(images)` attempts per load. + - If nothing decodes, it loads with `image=None`. + - `main()` passes `image_max=(min(win[0], max_tex), min(win[1], max_tex))`, where `max_tex = ctx.info["GL_MAX_TEXTURE_SIZE"]`. + +- [ ] **Step 1: Write the failing pure tests** in `test_shadertoy.py`, in the "Setup loading" section. They use Pillow to write images, e.g. `Image.new("RGBA", (w, h), colour).save(path)`. + +```python +def test_load_setup_lists_images_sorted_ignoring_others(tmp_path): + d = _write_setup(tmp_path, {"image.glsl": SOLID}, {"channels": {"image": ["image"]}}) + (d / "images").mkdir() + for n in ("b.JPG", "a.png", "c.jpeg", "notes.md", ".DS_Store"): + (d / "images" / n).write_bytes(b"x") + s = load_setup(d) + assert [p.name for p in s.images] == ["a.png", "b.JPG", "c.jpeg"] + assert s.uses_image + +def test_load_setup_without_images_folder_has_no_images(tmp_path): + s = load_setup(_write_setup(tmp_path, {"image.glsl": SOLID}, {"channels": {"image": ["image"]}})) + assert s.images == [] and s.uses_image + +def test_decode_image_puts_bottom_row_first(tmp_path): + # 1×2 PNG: top pixel red, bottom pixel blue + ... + a = decode_image(p, (64, 64)) + assert a.shape == (2, 1, 4) and a.dtype == np.uint8 + assert tuple(a[0, 0]) == (0, 0, 255, 255) and tuple(a[1, 0]) == (255, 0, 0, 255) + +def test_decode_image_fits_inside_max_keeping_aspect(tmp_path): + # 400×200 image, max (100, 100) → 100×50 + assert decode_image(p, (100, 100)).shape == (50, 100, 4) + +def test_decode_image_corrupt_raises(tmp_path): + (tmp_path / "bad.png").write_bytes(b"not a png") + with pytest.raises(ShaderError): + decode_image(tmp_path / "bad.png", (64, 64)) +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `python3 -m pytest test_shadertoy.py -k "images or decode_image" -v` +Expected: FAIL. The import error for `decode_image` comes first, then "unknown source(s) ['image']". + +- [ ] **Step 3: Implement `IMAGE_EXTS`, `Setup.images`, `Setup.uses_image`, the `"image"` entry in `SOURCES`, the `load_setup` listing, and `decode_image`** in `shadertoy.py`, as in the Interfaces block. Import Pillow inside `decode_image`; it's already a dependency. Convert to `"RGBA"`, then flip with `np.flipud`. + +- [ ] **Step 4: Run them to verify they pass** + +Run: `python3 -m pytest test_shadertoy.py -k "images or decode_image" -v` +Expected: PASS. + +- [ ] **Step 5: Write the failing GL and rotation tests** in `test_shadertoy.py`. Add them to the GL section; the rotation tests go next to `test_single_setup_is_not_recompiled_on_expiry`. + +```python +SHOW_CH0 = ("void mainImage(out vec4 o, in vec2 f) {" + " o = vec4(texture(iChannel0, f / iResolution.xy).rgb," + " iChannelResolution[0].x > 0.0 ? 1.0 : 0.0); }") + +def test_image_channel_reaches_shader(ctx, tmp_path): + # Load with a 1×2 array (row 0 = bottom = blue, row 1 = red) bound to "image". + # The top half of the output is red, the bottom half blue, alpha 255. + +def test_unbound_image_reads_zero_resolution(ctx, tmp_path): + # Load the same Setup with image=None: the pixel is (0, 0, 0, 0). + +def test_rotation_shuffles_images_per_load(ctx, tmp_path): + # One Setup whose images/ holds 3 solid PNGs (red, green, blue), image.glsl = SHOW_CH0. + # rot.next(), then rot.reload() twice: the three rendered colours are all different. + # A 4th load (reload) gives one of the three again. + +def test_rotation_skips_corrupt_image(ctx, tmp_path): + # images/ = bad.png (garbage bytes) + good.png (green). Over 4 loads every frame is green; + # no exception escapes. + +def test_rotation_with_only_corrupt_images_binds_nothing(ctx, tmp_path): + # images/ = bad.png only. The Setup still loads; the pixel's alpha is 0 (no resolution). +``` + +The rotation tests build their Setup folder with `_write_setup` and pass `shader_dir=tmp_path`, `names=["s"]`. + +- [ ] **Step 6: Run them to verify they fail** + +Run: `python3 -m pytest test_shadertoy.py -k "image_channel or unbound_image or rotation_" -v` +Expected: the new tests FAIL (unexpected keyword `image`, or a blank channel). The existing `rotation_` tests still pass. + +- [ ] **Step 7: Implement the texture binding in `Renderer`, the per-Setup bags in `Rotation`, and `image_max` in `main()`**, as in the Interfaces block. Upload with `ctx.texture((w, h), 4, data=arr.tobytes())` and set `repeat_x`/`repeat_y` to `False`. Release it in `_release_setup` and `release`. + +- [ ] **Step 8: Run the whole file to verify it passes** + +Run: `python3 -m pytest test_shadertoy.py -v` +Expected: all PASS (apart from the known NVIDIA-only failure). + +- [ ] **Step 9: Write the docs** + - **ADR-0023**, following ADR-0022's shape (Status Accepted, Amends ADR-0021, Context/Decision/Consequences). It records: + - the `"image"` source, the `images/` folder and its extensions; + - one shuffle bag per Setup per run, with only real loads (including **R**) advancing it, and a single pinned Setup keeping its image; + - decoding: row 0 at the bottom, downscaled to fit the window or the GL max texture size, no mipmaps, linear and clamp; + - corrupt files dropped from the bag; no images meaning an unbound channel with `iChannelResolution` 0 (not an error); + - Shadertoy prototyping with a built-in texture. + - Add 0023 to ADR-0021's "Amended by" line. + - **`shaders/README.md`:** + - Replace "No textures…" with a rule saying the one exception is the `"image"` source. + - Add `"image"` to the "Sources:" list. + - Add a short "Images" section: the folder, the shuffle, the no-image fallback test `iChannelResolution[n].x == 0.0`, Shadertoy stand-in textures, and the advice "fairly saturated images with clear colour regions smear more legibly than soft, low-contrast ones". + - Update the `shadertoy.py` module docstring's "Channel sources:" line. + +- [ ] **Step 10: Commit** + +```bash +git add ShowControl/FundingCAPTCHA/shadertoy.py ShowControl/FundingCAPTCHA/shader_app.py \ + ShowControl/FundingCAPTCHA/test_shadertoy.py ShowControl/FundingCAPTCHA/shaders/README.md \ + docs/adr/0023-shader-player-image-channel.md docs/adr/0021-fundingcaptcha-shader-player.md +git commit -m "Shader player: \"image\" channel source, shuffled per load (ADR-0023, #164)" +``` + +--- + +### Task 2: Setup skeleton — original painting, canvas and display + +**Files:** +- Create in `ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/`: `setup.json`, `common.glsl`, `buffer_b.glsl`, `buffer_c.glsl` (pass-through stub for now), `buffer_d.glsl`, `image.glsl` +- Modify: `ShowControl/FundingCAPTCHA/shaders/setups.json` (append `"watercolor_smudge"`) +- Test: `ShowControl/FundingCAPTCHA/test_watercolor_smudge.py` + +**Interfaces:** +- Consumes: Task 1's `Renderer.load(setup, image=...)`, `decode_image`, and the `"image"` source. +- Produces: + - **`setup.json`:** + + ```json + {"_comment": "Watercolor smudge: a watercolor painting that visitors' moving hands, feet and head smear; heals once the room empties. Spec: docs/superpowers/specs/2026-11-07-watercolor-smudge-design.md", + "duration_s": 120, + "channels": {"buffer_b": ["B", "image"], "buffer_c": ["A", "C"], + "buffer_d": ["D", "B", "C"], "image": ["D", "B", "image"]}} + ``` + - **`common.glsl`:** + - all the Global Constraints tunables; + - `vec3 PAPER = vec3(.965,.94,.885)`; + - the sketch's `hash`, `vn`, `fbm`, `lum`, `glaze`; + - `vec3 paintSunset(vec2 uv, vec2 res)` and `float paintMask(vec2 uv, vec2 res)`, ported from the sketch's `PAINT`; + - `vec2 coverUV(vec2 uv, vec2 screen, vec2 img)`: centred cover-fit, so uv in 0–1 maps inside the image with the overflow cropped. + - **Buffer B, "original":** + - With `iChannelResolution[1].x > 0`: `rgb = texture(iChannel1, coverUV(...))` and `a = 1.0`, every frame. + - Otherwise: paint `paintSunset`/`paintMask` when `iFrame == 0`, or when its own centre texel is `vec3(0)` (a Shadertoy resize). Copy itself otherwise. + - **Buffer D, "canvas":** for now, copies B when `iFrame == 0` or its centre texel is `vec3(0)`, and otherwise holds itself. Task 3 adds motion and heal. + - **Image:** + - `disturbed = smoothstep(0.01, 0.04, max-channel |canvas − original|)`, combined with wetness. + - With an image bound: `mix(texture(image, coverUV), canvas, disturbed)`. Grain and wet darkening (sketch `SHOW`) are applied only through `disturbed`. + - Procedural: the canvas with grain everywhere and wet darkening, as in the sketch. + +- [ ] **Step 1: Write the failing tests** in a new `test_watercolor_smudge.py`. Copy the `ctx` fixture and `_pixel` helper pattern from `test_shadertoy.py` (import them is fine too). Add helpers: + - `_rig(ctx, size=(96, 54), image=None) -> Renderer`: `body_mode="camera"`, loads `shaders/watercolor_smudge`, uploads an empty Body. + - `_buf(r, name) -> np.ndarray`: float32 `(h, w, 4)`, read from `r._buffers[name].read_tex` as float16. + - `_out(r) -> np.ndarray`: uint8 `(h, w, 4)`. + +```python +def test_without_image_paints_procedural_sunset(ctx): + r = _rig(ctx); [r.render(1/30, (0,0,-1,-1)) for _ in range(3)] + o = _out(r).astype(int) + top, low = o[48, 48, :3], o[10, 48, :3] # GL rows: 48 near top, 10 in the water + assert top[2] > top[0] # sky top is blue-violet + assert o.std() > 10 # not flat + assert (o[1, 1, :3] > 200).all() # ragged paper border at the corner + +def test_canvas_starts_equal_to_original(ctx): + # After 2 frames, |buffer_d.rgb − buffer_b.rgb| max < 1/255. + +def test_image_shows_unchanged_when_undisturbed(ctx): + # A 96×54 image: left half red, right half blue. Output pixels at (20,27) and (76,27) match within 2/255. + +def test_image_covers_screen_keeping_aspect(ctx): + # A 54×96 portrait image (top third green, middle third red, bottom third blue) on a 96×54 render. + # Cover-fit crops top and bottom: the output's centre row is red, and no output pixel is green or blue. +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `python3 -m pytest test_watercolor_smudge.py -v` +Expected: FAIL (the Setup folder is missing). + +- [ ] **Step 3: Implement the Setup files, as in the Interfaces block, and append it to `setups.json`.** Port `PAINT` from the mockup HTML verbatim apart from the dialect: `mainImage`, `iResolution`, and returning the mask separately. Buffer C is a stub that writes `vec4(0)` until Task 3. + +- [ ] **Step 4: Run them to verify they pass, along with the listed-Setup compile test** + +Run: `python3 -m pytest test_watercolor_smudge.py "test_shadertoy.py::test_listed_setups_compile" -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ShowControl/FundingCAPTCHA/shaders/watercolor_smudge ShowControl/FundingCAPTCHA/shaders/setups.json \ + ShowControl/FundingCAPTCHA/test_watercolor_smudge.py +git commit -m "Watercolor smudge: Setup skeleton, procedural sunset and image cover (#164)" +``` + +--- + +### Task 3: Motion, extremity smear and heal + +**Files:** +- Modify: `shaders/watercolor_smudge/common.glsl`, `buffer_c.glsl`, `buffer_d.glsl` +- Test: `ShowControl/FundingCAPTCHA/test_watercolor_smudge.py` + +**Interfaces:** +- Consumes: Task 2's buffer roles and `setup.json` bindings. +- Produces: + - **Buffer C:** + - `.r` = the Body eased toward `texture(iChannel0).r` with rate `1 − exp(−dt / BODY_EASE_S)`; + - `.g` = last frame's `.r`; + - `.b` = `extremity(iChannel0, uv, aspect, THIN)` (sketch `extremity`, radii `RING_R1`/`RING_R2`, on the raw Body); + - texel `(0,0)` `.a` = `min(T_CLAMP, prev + dt)` while `iMouse.z <= 0`, and 0 while present. + - **Buffer D:** the sketch's `SIM`, minus the bleed and wet-edge darkening (already removed in the sketch's v2), reading flow from C's `.r`/`.g` instead of B/Bp: + - The flow is zeroed below `MIN_FLOW`, clamped to `FLOW_MAX`, and weighted by C's `.b` and B's `.a` at the pixel and upstream. + - Advection uses streaks, log-space mixing (`MIX_MOVE`, `MIX_WET`), and wetness decaying by `WET_DECAY`. + - Heal toward B: rate `3.0 / HEAL_TIME_S` once the timer exceeds `HEAL_DELAY_S`, and `PRESENT_HEAL` otherwise. + - **Test helper:** `_bar(w_frac, x_frac) -> np.ndarray`, a uint8 RGBA Body `(54, 96, 4)` with a full-height vertical bar of width `w_frac` (screen widths) centred at `x_frac`. The Body texture is uploaded at the render size in tests. + +- [ ] **Step 1: Write the failing tests** + +```python +def test_extremity_scores_thin_high_and_broad_low(ctx): + # Body = thin bar (0.02 wide, x 0.3) plus broad bar (0.25 wide, x 0.7). After 3 frames, C.b + # at the thin bar's centre is > 0.8, and at the broad bar's centre is < 0.2. + +def test_moving_limb_smears(ctx): + # A thin bar moves from x 0.2 to 0.6 over 1 s (Body uploaded every 3rd frame, 10 Hz), present. + # The max |canvas − original| over the swept region is > 0.1. + +def test_moving_torso_does_not_smear(ctx): + # A broad bar (0.3 wide) makes the same move. The max |canvas − original| at the bar's swept interior + # (excluding 0.05 at each side of the start and end positions) is < 0.03. + +def test_still_flickering_person_leaves_paint_alone(ctx): + # A thin bar at x 0.5, still. Every 3rd frame, a random 1-px rim is added or removed on either side + # (seeded rng). After 3 s present, the max |canvas − original| is < 0.02. + +def test_smear_persists_while_present(ctx): + # After test_moving_limb_smears' sweep, 5 s more with the bar still and present. + # The summed |canvas − original| is still > 90 % of what it was. + +def test_heal_waits_for_delay_and_pauses_on_return(ctx): + # Sweep, then empty Body and iMouse.z = −1. At 1.5 s, the diff is > 90 % of the start. + # At 2 s + 10 s, it's < 5 %. + # Variant: empty for 4 s, then present again (Body still), 3 s more → the diff drops by < 5 % in those 3 s. + +def test_paper_border_never_smears(ctx): + # Procedural; a thin bar sweeps from x 0.0 to 0.2. Texels whose B.a < 0.01 keep canvas == original + # (< 1/255). + +def test_state_stays_bounded_when_idle(ctx): + # 4000 frames empty (dt 1/30). C(0,0).a == 99.0 (T_CLAMP); every buffer is finite; and + # |canvas − original| < 1/255. +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `python3 -m pytest test_watercolor_smudge.py -v` +Expected: the Task 3 tests FAIL (C is a stub, D only holds). Task 2's tests still pass. + +- [ ] **Step 3: Implement Buffers C and D, and add the new tunables to `common.glsl`**, as in the Interfaces block. Choose `BODY_EASE_S` and `MIN_FLOW` so the flicker test passes while the moving-limb test still smears. Start with `BODY_EASE_S = 0.12` (as `watering_can`) and `MIN_FLOW = 0.05` screen heights/s. If easing alone isn't enough, add a hysteresis like `watering_can`'s `BODY_ON`/`BODY_OFF`. + +- [ ] **Step 4: Run them to verify they pass** + +Run: `python3 -m pytest test_watercolor_smudge.py -v` +Expected: PASS. + +- [ ] **Step 5: Write the failing display tests** + +```python +def test_image_stays_sharp_outside_smear(ctx): + # With an image bound (a 4-px checkerboard), sweep a thin bar through x 0.2–0.4. The output at x 0.8 + # matches the image exactly (within 2/255), and some output in the swept region differs by > 0.1. + +def test_wet_smear_reads_darker_than_dry(ctx): + # Procedural. Right after the sweep, the mean luminance of the swept region's output is lower than + # 4 s later (still present, so no heal). +``` + +- [ ] **Step 6: Run them; they should pass already if Task 2's Image pass is right** + +Run: `python3 -m pytest test_watercolor_smudge.py -k "sharp or wet" -v` +Expected: PASS. If one fails, fix the Image pass's `disturbed` blend. + +- [ ] **Step 7: Run the full suite** + +Run (repo root): `make test` +Expected: green, apart from the known NVIDIA-only failure. + +- [ ] **Step 8: Commit** + +```bash +git add ShowControl/FundingCAPTCHA/shaders/watercolor_smudge ShowControl/FundingCAPTCHA/test_watercolor_smudge.py +git commit -m "Watercolor smudge: extremity smear from Body motion, heal after the room empties (#164)" +``` + +--- + +### Task 4: Look check in the player, then hand-off for the rig test + +**Files:** +- Possibly modify: `shaders/watercolor_smudge/*.glsl` (look fixes only) + +- [ ] **Step 1: Run it with the painter Body** + +Run (from `ShowControl/FundingCAPTCHA/`): `python3 shader_app.py --test-input --setup watercolor_smudge --windowed` + +Expected: +- The procedural sunset matches the sketch. +- Dragging with the held mouse (the painter's person) smears where its edges move. +- Releasing for 2 s, then waiting about 10 s, heals it. +- **R** reloads it. + +Compare against the mockup open in a browser. Fix look differences in the GLSL, re-run `python3 -m pytest test_watercolor_smudge.py`, and commit if anything changed. + +- [ ] **Step 2: Try an image** + +Copy any PNG into `shaders/watercolor_smudge/images/`, re-run, and confirm it shows cover-fit and smears. **Delete it afterwards. Don't commit test art.** + +- [ ] **Step 3: Hand off to the user** for the rig test on the Ultra 9 with the Orbbec (`python3 shader_app.py --camera --setup watercolor_smudge`). Report back: + - a checklist: stand still for 30 s with no smear; walk through; wave; leave and watch the heal; note the `fps` log line; + - which `common.glsl` constants to turn for each symptom: + - too broad: lower `THIN` or the ring radii; + - too little smear: raise `SMEAR_STRENGTH`; + - a still person smearing: raise `BODY_EASE_S` or `MIN_FLOW`; + - heal too slow or too fast: `HEAL_TIME_S`. + + **R** reloads edits live. Tuning changes from that session land on this branch. The merge waits for the user's sign-off. diff --git a/docs/superpowers/specs/2026-11-07-watercolor-smudge-design.md b/docs/superpowers/specs/2026-11-07-watercolor-smudge-design.md new file mode 100644 index 0000000..563e84b --- /dev/null +++ b/docs/superpowers/specs/2026-11-07-watercolor-smudge-design.md @@ -0,0 +1,173 @@ +# Watercolor Smudge — shader player Setup + +A new FundingCAPTCHA shader player Setup (ADR-0021), from issue #164. The screen shows a watercolor painting. When visitors come into range, the thin moving parts of their silhouette (hands, arms, feet, head) smear the paint like fingers dragged through wet watercolor. Once the room empties, the painting slowly heals. + +The work has two parts: + +1. **A player change**: Setups can bind an image as a channel source, and the player shuffles images from the Setup's folder on each load. +2. **The Setup**: the smear and heal behaviour, with a procedurally painted watercolor sunset standing in until real images are committed. + +## Intent + +The piece is a nice painting you discover you can touch. Nothing on screen says it's interactive. People find out by walking past and seeing the paint move where their hands and feet go. The smear should feel physical: paint is pushed in the direction a limb moves, streaks, and mixes like wet pigment. Then it quietly mends itself once everyone has gone, so the next visitor finds a fresh painting. + +The rotation will eventually shuffle interactive Setups and baked videos together. That system isn't part of this work. This Setup only needs to be self-contained: choose its image when it loads and play for its `duration_s`. + +## Goals + +- **Empty room:** a calm, full-screen watercolor painting. +- **Only the extremities smear.** The thin parts of the silhouette smear the paint when they move: arms, hands, legs and feet, and the head. The broad torso passes through without smearing. Thin vs. broad is judged from the silhouette alone, since there's no skeleton. Concretely, it's how much of a ring around each point is Body (see the mockup's `extremity()`). +- **The smear follows the movement.** Paint is pushed in the direction the edge of the silhouette moves, at roughly the limb's speed times a strength. It breaks into finger-drag streaks across the stroke, and colours mix the way pigments do (multiplicatively, darkening a little) rather than averaging to grey. +- **Wet look:** freshly smeared paint reads slightly darker while wet and dries back over a few seconds. +- **Standing still does nothing.** A still person causes no smear and no bleed. +- **Heal:** once nobody has been in range for about **2 s**, the canvas eases back to the original painting over about **10 s**. While anyone is present, healing is negligible, so smears persist while people play. +- **Images:** the Setup paints whichever image the player bound. On each load the player picks the next image from a shuffle of the Setup's `images/` folder. If the folder is empty or missing, the Setup paints the procedural watercolor sunset instead. +- **Tunable:** smear strength, the thin/broad threshold, heal time and the heal delay are named constants at the top of `common.glsl`, with comments, matching how the other Setups expose tuning. Defaults come from the approved sketch. +- Runs in the shader player on the show's Intel Core Ultra 9 Linux machine, and can be prototyped in Shadertoy with the body painter. + +## Non-goals + +- No bleed or soak around a standing person. It was tried in the first sketch and dropped. +- No drawing of the person: no silhouette, outline or ghost. +- Nearness (`.g`) isn't used. +- No sound. +- No procedural "watercolor filter" over the images. The images are painted as watercolors already; see "Images" below. +- Playing baked videos and mixing them into the rotation are out of scope. + +## Anti-goals + +- **A still person smearing because of the camera.** Depth-edge flicker, dropout holes and grain on a stationary silhouette must not move paint. The smear must come from real movement. With the real camera this is the main risk of the frame-difference motion signal; see "Motion signal". +- **The torso bulldozing the painting.** If walking through drags a body-wide band of paint across the screen, the extremity idea is lost. The first sketch did this, and it was rejected as "too broad". +- **Grey mud.** Mixing must stay pigment-like and colourful, never average to grey. +- **Popping.** Healing is a smooth ease back, never a cut. +- **Hard, graphic stroke outlines.** An early v2 iteration had dark sawtooth edges around every stroke; they were removed. Smears should read as soft paint. + +## Reference mockup + +[`2026-11-07-watercolor-smudge-mockup.html`](2026-11-07-watercolor-smudge-mockup.html) is a runnable WebGL2 sketch (v2) of the approved interaction. A stick figure stands in for the depth camera's Body; drag to walk it and hold **W** to wave. It shows: + +- the procedural sunset, which is the look for the stand-in painting; +- the extremity weighting ("Show the Body": red smears, grey doesn't); +- normal-flow motion from frame-to-frame Body change; +- streaky, pigment-mixing smear, the wet darkening, and heal after the room empties; +- live sliders for smear strength, "what counts as thin" and heal time. + +The user approved this interaction as-is: *"this interaction paradigm is great as it is. Keep the tunability."* Treat its behaviour and default slider values as the target. It's a sketch for behaviour and look, not a code structure to copy. Cost is covered in "Target machine and cost". + +## Behaviour (decided) + +### Motion signal + +- **Per-pixel motion from the Body changing between frames** (normal flow), as in the sketch: `v = −(∂B/∂t) · ∇B / (|∇B|² + ε)`, using the current and previous Body. It is non-zero only in the soft band at a moving silhouette edge, and points the way that edge moves. Leading and trailing edges both point forward. +- The previous Body has to be kept by the Setup, for example copied into a buffer each frame. +- **Flicker rejection is required** (anti-goal). Ways to get there are left to the builder: easing the Body over a few frames as `watering_can` does (`PRESENCE_EASE_S`, hysteresis), a minimum-speed dead zone, or gating by the person's blob velocity when they're standing still. The acceptance test is that a person standing still in front of the real camera leaves the painting untouched. + +### Extremity weighting + +- `ext = 1 − smoothstep` of the Body coverage on two rings around the point (radii about 0.045 and 0.08 screen heights in the sketch). Thin parts score near 1, the torso near 0. The threshold is the "what counts as thin" tunable. +- The radii are in screen units. In the player the camera's square view is stretched to the screen, so people look wider than in the sketch, and the defaults may need retuning against the real camera. +- With real people, legs often sit close together and score as broad, so the feet and a waving arm do most of the smearing. That matched the sketch and was accepted. + +### Smear + +- Semi-Lagrangian advection of a persistent **canvas buffer** by `v × ext × streak × strength`. `streak` is noise across the stroke direction, so the smear breaks into finger streaks. +- Where paint is moving, it mixes with its neighbours by a geometric mean, i.e. averaged in log space. This is the pigment-like mixing. +- A wetness channel rises where paint moves and decays over about 3 s. The final image darkens slightly where it's wet. + +### Heal + +- "Empty" means no Body presence. `iMouse.z > 0` is "anyone present" in the player (ADR-0021), or presence can be derived from the Body. Once the room has been empty for the heal delay (default 2 s), the canvas eases exponentially toward the original, reaching about 95 % in the heal time (default 10 s). While anyone is present, there's a tiny heal rate, which the sketch uses so that nothing drifts forever. +- The "empty for" timer needs per-frame state. Keep it in a buffer texel, as the other Setups keep state. + +### The original painting + +- **With an image bound:** the image, scaled to cover the screen. Aspect is kept and the overflow cropped, centred. *(Cover vs. stretch is my call.)* +- **Without an image** (the channel's `iChannelResolution` is zero): the procedural watercolor sunset from the sketch's `PAINT` shader. That's graded sky washes with pooled stroke edges, glazed clouds, a pale lifted sun with a pooled rim, low hills, a reflection, granulation, and a ragged unpainted paper border. +- The procedural sunset is costly, so compute it once into a buffer rather than every frame. For example, repaint it only when `iFrame == 0` or the resolution changes. +- **Paper border:** the procedural painting's border never smears and paint can't be dragged out of it, as in the sketch (`P.a`). Images get no border by default. If an image has an alpha channel, its transparent areas could act as that mask; this is optional and left to the builder. + +### Display + +- **Keep images as sharp as the player allows.** Every pass, including `image`, renders at `render_scale` (default 0.5) and is upscaled to the window by `shader_app.py`. This work doesn't change that: no full-resolution final pass. Within that, avoid a second resample. The `image` pass samples the bound image directly wherever the canvas is undisturbed, and shows the canvas only where it differs from the original. Watercolors are soft, so half-resolution output should hold up. If it doesn't, raising `render_scale` is a separate, measured decision. *(My call; the blend details are the builder's.)* +- Add procedural paper grain only over disturbed areas, plus the wet darkening. That way a smear doesn't drag the image's own scanned paper texture around with it, and untouched areas show the image exactly as painted. The procedural sunset gets grain everywhere, as in the sketch. + +## Images + +- **Pre-watercolored images**, not procedural watercolor-ification of arbitrary photos. Real watercolor gets wet edges, blooms and granulation for free, while a filter tends to read as a photo-app effect and adds per-frame cost. The shader adds only what comes from people moving through the painting: smear, mixing, wetness and fresh grain. +- They're committed **publicly** in `shaders/watercolor_smudge/images/`. None exist yet; the procedural sunset ships as the stand-in. Adding images later needs no code change. +- Advice to the artist, for the README: fairly saturated images with clear colour regions smear more legibly than soft, low-contrast ones. + +## Player change: image channels (decided) + +The player currently allows no textures (ADR-0021 "Consequences"). This adds one image source. Record it as a **new ADR (0023) amending ADR-0021**, as ADR-0022 did for blobs. + +- **`setup.json`:** a new channel source, `"image"`. It binds this load's image from `/images/`. +- **Choosing the image:** on each load of the Setup, the player picks the next image from a shuffle bag of the folder's files, reusing `arc.ShuffleBag`, so you see a new image each time and none repeats until all have shown. *(One bag per Setup for the run is my call.)* Accepted formats: PNG and JPEG. +- **Only a real load advances the bag.** When this is the only Setup playing (`--setup NAME`, or a one-entry `setups.json`), `Rotation.next()` keeps the Setup loaded and only resets its timer, so the image stays the same. That's accepted; there's no mid-run image swap. The **R** hot-reload is a real load, so it advances the bag. *(My call: in the show, the rotation will have many Setups and videos.)* +- **Loading:** decode on load into an RGBA8 texture, flipped so row 0 is the bottom (GL convention, matching the Body). Use linear filtering and clamp, like every channel. Release it on unload. + - Downscale an image larger than the **window**, or than the GPU's max texture size, before upload, so detail survives if `render_scale` is ever raised. *(My call: no mipmaps.)* + - `iChannelResolution` reports the image size. +- **No images:** with an empty or missing folder, the channel is unbound, so it reads 0 and `iChannelResolution` is 0. The Setup uses that to fall back to procedural. This is *not* an error, so CI and fresh clones play the Setup fine. +- **A broken image file** (fails to decode): log it, drop it from the bag and try the next. If none decode, play with the channel unbound. +- **Shadertoy prototyping:** bind one of Shadertoy's built-in textures where the Setup uses `"image"`. The user confirmed a stand-in texture there is fine. Update the README's channel table and rules ("No textures…") to describe the new source. +- **Tests:** loading a Setup with an `"image"` source; the shuffle; the empty-folder fallback; a corrupt file. `make test` compiles every listed Setup, so the new Setup must compile with the folder empty. + +## Target machine and cost + +**The show machine for this Setup is an Intel Core Ultra 9 Linux PC, not the Pi 5.** The user decided to set the Pi aside, so render limits matter much less. The player itself is unchanged: it still uses RGBA16F buffers, `HW_PERFORMANCE` 0 and `render_scale`. On this machine, `render_scale` can be raised in that machine's settings if half resolution looks soft; this work doesn't change the default. Still, stay reasonable: + +- Cache the procedural painting; don't regenerate it per frame. +- The Body arrives at about 10 Hz but renders at about 30 fps, so raw frame-to-frame change is spiky: zero on most frames, then a jump. Easing the Body is needed for smooth flow as well as for flicker. +- The sketch's 24-tap extremity test per pixel is fine on this machine. Don't spend effort reducing it unless fps (logged every 10 s by `shader_app.py`) says so. + +16-bit float buffers: canvas colours are 0–1, so there's no precision problem with magnitude. Repeated advection does blur over time, and the heal and the "only show the canvas where disturbed" display both bound how much that shows. Watch for drift in a long busy session. + +## Testing and sign-off + +- **Automated** (`make test`), which must pass: + - the Setup compiles with an empty `images/` folder; + - the `"image"` source loads; + - the shuffle bag advances per load; + - the empty-folder fallback works; + - a corrupt file is skipped. +- **At the rig, by the user, before merge:** + - the standing-still test with the real depth camera (anti-goal 1); + - walking and waving through it; + - tuning the ring radii and thin threshold against real silhouettes; + - an fps reading on the Ultra 9. + + The plan ends with a hand-off listing what to try and which constants to adjust. Tuning changes from that session land on the same branch. + +## Tunables and defaults + +All are named constants at the top of `common.glsl` with comments. Defaults are the sketch's. + +| Constant | Default | Meaning | +|---|---|---| +| smear strength | 0.63 (sketch slider 45 × 1.4) | fraction of edge motion that moves paint | +| thin threshold | 0.5 (maps to coverage ≈ 0.315 ± 0.08) | what counts as an extremity | +| extremity ring radii | 0.045, 0.08 screen heights | scale of "thin" | +| streak noise | 0.7 + 0.6·noise, 45 /unit | finger-streak contrast and spacing | +| wet decay | ~0.35 /s | how long smears look wet | +| heal delay | 2 s | empty time before healing starts | +| heal time | 10 s | time to ~95 % healed | +| present heal rate | 0.004 /s | trickle heal while people are present | + +## Setup facts + +- **Folder:** `shaders/watercolor_smudge/`, added to `setups.json`. +- **`duration_s`:** 120. *(My call, to match the other Setups.)* +- **`setup.json` `_comment`:** names the spec, as the other Setups do. +- The buffer layout is the builder's, within the four buffers (A is the Body) and four channels per pass. One plausible layout: + - B: canvas (rgb paint, a wetness) + - C: previous Body, extremity and flow + - D: cached original and state texels + - image pass: B, D, `"image"`, A + +## Left to the builder + +- The buffer layout, how state is packed, and how the "empty for" timer is stored. +- The flicker-rejection method (it must pass the standing-still test). +- How the full-resolution and canvas blend are done. +- Whether image alpha acts as a no-smear mask. +- Exact tuning against the real camera, starting from the defaults above. diff --git a/docs/superpowers/specs/2026-11-07-watercolor-smudge-mockup.html b/docs/superpowers/specs/2026-11-07-watercolor-smudge-mockup.html new file mode 100644 index 0000000..cce8c6f --- /dev/null +++ b/docs/superpowers/specs/2026-11-07-watercolor-smudge-mockup.html @@ -0,0 +1,249 @@ + + +Watercolor Smudge prototype v2 + +

Watercolor Smudge v2: only the extremities smear

+

The figure's thin parts (arms, legs, head) push paint as they move; the torso passes through. No bleed when standing still. Heals in about 10 s once the room is empty.

+ +
+ +
+ + + + +
+
+ + + +
+

Drag to walk a figure through the painting (its legs and arms swing as it goes). Hold W while dragging to wave an arm. Let go to leave. The demo visitor walks in, stops, waves, and walks out.

+ + + diff --git a/requirements-dev.txt b/requirements-dev.txt index a21cf0e..25933e1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,3 +7,4 @@ httpx>=0.24 uvicorn pygame>=2.1 moderngl>=5.13 +Pillow>=9.0