diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 5e50c5d08..e47f6908e 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -42,7 +42,7 @@ jobs: eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" echo "$(/home/linuxbrew/.linuxbrew/bin/brew --prefix)/bin" >> "$GITHUB_PATH" brew install bats-core fish zsh - pip install pytest pytest-cov pyyaml jsonschema + pip install pytest pytest-cov pyyaml jsonschema numpy - name: Install just uses: taiki-e/install-action@b6a061addd82b81aaeca51bdf2c53cd707996e5a # just @@ -79,6 +79,9 @@ jobs: tests/test_skill_docs.py \ -v + - name: Run pytest (Hidamari movie generator) + run: python3 -m pytest tests/test_render_hidamari_movie.py -v + - name: Upload coverage report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() diff --git a/Justfile b/Justfile index b47741312..046dffb79 100644 --- a/Justfile +++ b/Justfile @@ -5,7 +5,7 @@ just := just_executable() # reason; tests/test_suite_registration.bats enforces that. # Run unit tests (pytest for hooks.py, bats for shell scripts) test: - python3 -m pytest tests/test_hooks.py tests/test_check_oci_refs.py tests/test_curated_config.py tests/test_skill_docs.py tests/test_chairlift_config.py tests/test_renovate_config.py -v --cov=tests --cov-report=term-missing + python3 -m pytest tests/test_hooks.py tests/test_check_oci_refs.py tests/test_curated_config.py tests/test_skill_docs.py tests/test_chairlift_config.py tests/test_renovate_config.py tests/test_render_hidamari_movie.py -v --cov=tests --cov-report=term-missing bats tests/test_libsetup.bats bats tests/test_setup_scripts.bats bats tests/test_privileged_setup.bats diff --git a/docs/SKILL.md b/docs/SKILL.md index 56535bd74..9667a7c1b 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -60,6 +60,7 @@ repository's local catalog with `docs/skills/hive.md` as the preflight guard. | Track PAPPL scanning API status or scanner ownership boundary | [`pappl-scanning-boundary.md`](skills/pappl-scanning-boundary.md) | | Work with OEM first-boot hooks | [`oem-hardware-hooks/SKILL.md`](skills/oem-hardware-hooks/SKILL.md) | | Understand MIME defaults | [`mime-defaults.md`](skills/mime-defaults.md) | +| Regenerate the Hidamari video-wallpaper asset | [`hidamari-wallpaper.md`](skills/hidamari-wallpaper.md) | | Understand why skill-drift was retired | [`skill-drift.md`](skills/skill-drift.md) | | Decide whether / how to update a skill | [`skill-improvement.md`](skills/skill-improvement.md) | | Author a new skill | [`write-a-skill.md`](skills/write-a-skill.md) | diff --git a/docs/skills/hidamari-wallpaper.md b/docs/skills/hidamari-wallpaper.md new file mode 100644 index 000000000..e6303d32a --- /dev/null +++ b/docs/skills/hidamari-wallpaper.md @@ -0,0 +1,84 @@ +--- +name: hidamari-wallpaper +version: "1.0" +last_updated: "2026-09-24" +id: hidamari-wallpaper +one_line_purpose: Regenerate and validate the Hidamari looping video-wallpaper asset. +entry_point: docs/skills/hidamari-wallpaper.md +category: test-authoring +mcp_compliance_level: partial +optimization_status: draft +status: active +dependencies: [numpy, ffmpeg-libvpx-vp9] +tags: [hidamari, wallpaper, video, vp9, webm, backgrounds, system_files] +description: >- + Regenerate, validate, or replace the Bluefin Hidamari video-wallpaper asset + (bluefin-hidamari.webm) with scripts/render-hidamari-movie.py. Covers the + seamless-loop invariants, size budget, and licensing constraint that the + animation must stay original. +metadata: + type: reference +--- + +# Hidamari video wallpaper — projectbluefin/common + +## What ships + +`system_files/bluefin/usr/share/backgrounds/bluefin/bluefin-hidamari.webm` +is a 1920x1080, 12 s, 24 fps VP9 (yuv420p, no audio) loop for the Hidamari +video-wallpaper engine (`io.github.jeffshee.Hidamari`). It is an original +render — layered ocean-gradient waves with rising glow specks in the Bluefin +palette — not a re-encode of third-party art. + +## Licensing constraint (do not skip) + +The Wallpaper Engine workshop item named in the tracking issue carries **no +redistribution license**. Never vendor, re-encode, or derive the shipped +movie from workshop content. The asset must stay reproducible from +`scripts/render-hidamari-movie.py`, which generates original frames only. If +a licensed render is ever donated upstream, replace this constraint in the +same PR that changes the source. + +## Regenerating the asset + +```bash +# needs python3 + numpy, and an ffmpeg with libvpx-vp9 on PATH +# (or FFMPEG=/path/to/ffmpeg; a static build works fine) +python3 scripts/render-hidamari-movie.py # ~3 min, 1080p +python3 scripts/render-hidamari-movie.py --preview-frame 0.5 # one PNG frame +``` + +Tunables: `--width/--height/--fps/--seconds/--crf/--dither`. Defaults are the +shipped values (1080p, 24 fps, 12 s, crf 24, dither 1.4). Deterministic: the +same script and ffmpeg/libvpx version produces a byte-identical render. + +## Invariants the tests enforce + +`tests/test_render_hidamari_movie.py` (run via `just test` and the +unit-tests workflow) pins: + +- **Seamless loop** — `luminance(t=0) == luminance(t=1)`; every temporal term + uses integer harmonic counts. With `--dither 0` frames 0 and N are + byte-identical; with grain on, the wrap may differ only by <= 4/255. +- **Determinism** — palette LUT and bubble seed table derive from + `Config.seed`; changing the seed forks the animation. +- **Size budget** — shipped WebM stays under 4 MiB (OCI layer cost). +- **Format** — EBML magic and size budget. + +When editing the generator, keep every temporal term an integer number of +cycles per loop or the wrap seam will drift and `test_loop_wrap_is_seamless` +will fail. + +## Verify a rebuilt asset by hand + +```bash +ffprobe -show_entries format=duration,size -of default=nw=1 bluefin-hidamari.webm +ffmpeg -v error -i bluefin-hidamari.webm -f null - # must decode clean +``` + +## Related + +- Epic: Wallpaper Enhancements (Damask, Hidamari, ChairLift bundles) +- ChairLift bundle for the engine lands via `video-wallpaper.Brewfile` + (separate sub-issue; see `docs/skills/brew-lifecycle/` for the bundle-file + conventions). diff --git a/docs/skills/index.json b/docs/skills/index.json index f90b08e3b..f4ec61e37 100644 --- a/docs/skills/index.json +++ b/docs/skills/index.json @@ -297,6 +297,27 @@ "last_updated": "2026-09-25", "doc_type": "runbook" }, + { + "id": "hidamari-wallpaper", + "name": "hidamari-wallpaper", + "one_line_purpose": "Regenerate and validate the Hidamari looping video-wallpaper asset.", + "entry_point": "docs/skills/hidamari-wallpaper.md", + "category": "test-authoring", + "status": "active", + "tags": [ + "hidamari", + "wallpaper", + "video", + "vp9", + "webm", + "backgrounds", + "system_files" + ], + "description": "Regenerate, validate, or replace the Bluefin Hidamari video-wallpaper asset (bluefin-hidamari.webm) with scripts/render-hidamari-movie.py. Covers the seamless-loop invariants, size budget, and licensing constraint that the animation must stay original.", + "version": "1.0", + "last_updated": "2026-09-24", + "doc_type": "reference" + }, { "id": "hive", "name": "hive", diff --git a/docs/skills/index.md b/docs/skills/index.md index aab633bde..a8077d069 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -3,7 +3,7 @@ This file is a human-readable mirror of `index.json`. Both are generated by `scripts/generate_skill_index.py` — do not hand-edit either file. -Generated: 2026-09-26 · schema 1.0 · 44 skills +Generated: 2026-09-26 · schema 1.0 · 45 skills | id | category | status | one-line purpose | |---|---|---|---| @@ -24,6 +24,7 @@ Generated: 2026-09-26 · schema 1.0 · 44 skills | [governance](governance.md) | meta | active | Manage CODEOWNERS, triager roles, and governance sync. | | [gpu-toolkit-interface](gpu-toolkit-interface.md) | meta | active | Define the shared GPU vendor toolkit interface that all GPU support implementations must satisfy. | | [hardware-testing](hardware-testing.md) | test-authoring | active | File hardware test reports and apply promotion policy. | +| [hidamari-wallpaper](hidamari-wallpaper.md) | test-authoring | active | Regenerate and validate the Hidamari looping video-wallpaper asset. | | [hive](hive.md) | ci-ops | active | Route factory work through Hive coordination and labels. | | [hive-automerge](hive-automerge.md) | ci-ops | active | Queue reviewed PRs for Hive's auto-merge-on-green sweep. | | [hive-review](hive-review.md) | ci-ops | active | Review live Hive and GitHub state for actionable work. | diff --git a/scripts/render-hidamari-movie.py b/scripts/render-hidamari-movie.py new file mode 100644 index 000000000..87a739921 --- /dev/null +++ b/scripts/render-hidamari-movie.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Render the Bluefin Hidamari looping wallpaper movie. + +Generates the original, seamless-loop WebM (VP9) video shipped for the +Hidamari video-wallpaper engine (io.github.jeffshee.Hidamari): + + system_files/bluefin/usr/share/backgrounds/bluefin/bluefin-hidamari.webm + +The animation is fully original — layered deep-ocean gradient waves with +rising glow specks in Bluefin's blue/teal palette — so no third-party +wallpaper licensing applies (the Wallpaper Engine workshop item named in +the tracking issue carries no redistribution license). + +Every temporal component is periodic over t in [0, 1) using integer +harmonic counts, so frame(t = 1.0) == frame(t = 0.0) and the loop is +seamless. Only per-frame dither grain differs; it reads as film grain. + +Usage: + python3 scripts/render-hidamari-movie.py --output \ + [--width 1920] [--height 1080] [--fps 24] [--seconds 12] \ + [--crf 24] [--dither 1.4] + +Requires: python3 with numpy, and an ffmpeg binary with libvpx-vp9 on +PATH (or pointed at via the FFMPEG env var). Pillow is used to write a +preview PNG when --preview-frame is given. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass, replace + +import numpy as np + +DEFAULT_OUTPUT = ( + "system_files/bluefin/usr/share/backgrounds/bluefin/bluefin-hidamari.webm" +) + +# Color stops for the palette LUT: (position, RGB). Deep-ocean navy rising +# through Bluefin teal to a soft cyan highlight. +PALETTE_STOPS = [ + (0.00, (4, 12, 26)), + (0.35, (10, 34, 58)), + (0.65, (18, 74, 92)), + (0.88, (64, 160, 158)), + (1.00, (198, 236, 233)), +] + +# Bubbles rising through the frame: (x0, y0, sigma_px, amplitude, drift phase). +BUBBLE_COUNT = 56 + + +@dataclass(frozen=True) +class Config: + """Render configuration. All fields are deterministic.""" + + width: int = 1920 + height: int = 1080 + fps: int = 24 + seconds: int = 12 + crf: int = 24 + dither: float = 1.4 + seed: int = 1158 # tracking issue number + + +def make_bubbles(cfg: Config, count: int = BUBBLE_COUNT) -> np.ndarray: + """Deterministic bubble seed table, shape (count, 5).""" + rng = np.random.default_rng(cfg.seed) + return np.stack( + [ + rng.uniform(0.0, 1.0, count), # x0 (fraction of width) + rng.uniform(0.0, 1.0, count), # y0 (fraction of height) + rng.uniform(1.6, 4.2, count), # gaussian sigma in pixels + rng.uniform(0.10, 0.34, count), # peak amplitude + rng.uniform(0.0, 1.0, count), # horizontal drift phase + ], + axis=1, + ) + + +def bubble_alpha(bubbles: np.ndarray, t: float) -> np.ndarray: + """Per-bubble brightness envelope at time t (fraction of the loop). + + Each bubble rises exactly one frame height per loop, fading in from + the bottom and out at the top, so the wrap is invisible. + """ + p = np.mod(bubbles[:, 1] - t, 1.0) + return np.sin(np.pi * p) ** 0.7 * bubbles[:, 3] + + +def luminance(cfg: Config, bubbles: np.ndarray, t: float) -> np.ndarray: + """Render the normalized luminance field for one frame, t in [0, 1).""" + w, h = cfg.width, cfg.height + two_pi = 2.0 * np.pi + aspect = w / h + + # Normalized spatial coordinates, pre-scaled by 2*pi so spatial + # frequencies below are plain cycle counts. + y = np.linspace(0.0, two_pi, h, dtype=np.float32)[:, None] + x = np.linspace(0.0, two_pi * aspect, w, dtype=np.float32)[None, :] + + # Layered slow-drifting waves; temporal phases run 1..2 cycles per + # loop so the field is exactly periodic. + waves = ( + 0.45 * np.sin(0.9 * x + 0.7 * y + two_pi * 1.0 * t) + + 0.30 * np.sin(1.6 * x - 1.1 * y + two_pi * 2.0 * t + 1.3) + + 0.20 * np.sin(2.6 * x + 2.0 * y + two_pi * 1.0 * t + 4.1) + ) + + # Vertical base gradient: brighter surface light above, abyss below. + lum = 0.40 - 0.22 * (y / two_pi) + + # Wave modulation. + lum = lum + 0.15 * waves + + # Faint slanting light rays from the surface. + rays = np.maximum(np.sin(1.3 * x + two_pi * 1.0 * t + 0.6), 0.0) + lum = lum + 0.16 * rays * (1.0 - y / two_pi) ** 2 + + # Rising glow specks. Each rises exactly one height per loop. + px = (x / two_pi) * w # pixel x per column + py = (y / two_pi) * h # pixel y per row + glow = np.zeros((h, w), dtype=np.float32) + alpha = bubble_alpha(bubbles, t) + for i in range(bubbles.shape[0]): + x0, _, sigma, _amp, phase = bubbles[i] + cx = (x0 + 0.012 * np.sin(two_pi * (t + phase))) * w + cy = np.mod(bubbles[i, 1] - t, 1.0) * h + # Wrap vertically so bubbles crossing an edge stay whole. + dy = np.abs(py - cy) + dy = np.minimum(dy, h - dy) + dx = np.abs(px - cx) + dx = np.minimum(dx, w - dx) + r2 = dx * dx + dy * dy + glow += alpha[i] * np.exp(-r2 / (2.0 * sigma * sigma)) + lum = lum + glow + + # Slow global "breathing", one cycle per loop. + lum = lum * (1.0 + 0.04 * np.sin(two_pi * t)) + + return np.clip(lum, 0.0, 1.0) + + +def palette_lut() -> np.ndarray: + """256-entry RGB palette built from PALETTE_STOPS.""" + pos = np.array([s[0] for s in PALETTE_STOPS]) + stops = np.array([s[1] for s in PALETTE_STOPS], dtype=np.float32) + xs = np.linspace(0.0, 1.0, 256) + lut = np.stack( + [np.interp(xs, pos, stops[:, c]) for c in range(3)], axis=1 + ) + return lut.astype(np.float32) + + +def vignette(cfg: Config) -> np.ndarray: + """Center-weighted falloff mask, shape (h, w), values in (0, 1].""" + h, w = cfg.height, cfg.width + yy = np.linspace(-1.0, 1.0, h, dtype=np.float32)[:, None] + xx = np.linspace(-1.0, 1.0, w, dtype=np.float32)[None, :] + r2 = (xx * xx + yy * yy) / 2.0 + return 1.0 - 0.32 * np.clip(r2, 0.0, 1.0) + + +def frame_rgb( + cfg: Config, + bubbles: np.ndarray, + t: float, + lut: np.ndarray, + vig: np.ndarray, + frame_index: int = 0, +) -> np.ndarray: + """Render one frame as a uint8 (h, w, 3) RGB array.""" + lum = luminance(cfg, bubbles, t) * vig + idx = np.clip((lum * 255.0).astype(np.int32), 0, 255) + rgb = lut[idx] + if cfg.dither > 0.0: + rng = np.random.default_rng(cfg.seed + 7919 + frame_index) + noise = (rng.random(rgb.shape, dtype=np.float32) - 0.5) * ( + 2.0 * cfg.dither + ) + rgb = rgb + noise + return np.clip(rgb, 0, 255).astype(np.uint8) + + +def loop_frames(cfg: Config) -> int: + return cfg.fps * cfg.seconds + + +def ffmpeg_path() -> str: + return os.environ.get("FFMPEG", "ffmpeg") + + +def render_movie(cfg: Config, out_path: str) -> None: + """Pipe every frame into ffmpeg and encode a seamless VP9 WebM.""" + ff = shutil.which(ffmpeg_path()) + if ff is None: + sys.exit( + f"error: ffmpeg not found (set FFMPEG=/path/to/ffmpeg); " + f"needs libvpx-vp9" + ) + + bubbles = make_bubbles(cfg) + lut = palette_lut() + vig = vignette(cfg) + n = loop_frames(cfg) + + cmd = [ + ff, + "-y", + "-f", "rawvideo", + "-pix_fmt", "rgb24", + "-s", f"{cfg.width}x{cfg.height}", + "-framerate", str(cfg.fps), + "-i", "-", + "-an", + "-c:v", "libvpx-vp9", + "-b:v", "0", + "-crf", str(cfg.crf), + "-deadline", "good", + "-cpu-used", "4", + "-row-mt", "1", + "-tile-columns", "2", + "-pix_fmt", "yuv420p", + out_path, + ] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) + assert proc.stdin is not None + try: + for f in range(n): + t = f / n + frame = frame_rgb(cfg, bubbles, t, lut, vig, frame_index=f) + proc.stdin.write(frame.tobytes()) + finally: + proc.stdin.close() + if proc.wait() != 0: + sys.exit(f"error: ffmpeg failed with exit code {proc.returncode}") + + +def preview_frame(cfg: Config, t: float, out_path: str) -> None: + """Write a single frame as PNG (requires Pillow).""" + try: + from PIL import Image + except ImportError: + sys.exit("error: Pillow required for --preview-frame") + bubbles = make_bubbles(cfg) + lut = palette_lut() + vig = vignette(cfg) + frame = frame_rgb(cfg, bubbles, t, lut, vig) + Image.fromarray(frame).save(out_path) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--output", default=DEFAULT_OUTPUT) + ap.add_argument("--width", type=int, default=Config.width) + ap.add_argument("--height", type=int, default=Config.height) + ap.add_argument("--fps", type=int, default=Config.fps) + ap.add_argument("--seconds", type=int, default=Config.seconds) + ap.add_argument("--crf", type=int, default=Config.crf) + ap.add_argument("--dither", type=float, default=Config.dither) + ap.add_argument( + "--preview-frame", + type=float, + default=None, + metavar="T", + help="render one frame at loop fraction T to .png and exit", + ) + return ap.parse_args(argv) + + +def main(argv: list[str]) -> None: + args = parse_args(argv) + cfg = Config( + width=args.width, + height=args.height, + fps=args.fps, + seconds=args.seconds, + crf=args.crf, + dither=args.dither, + ) + if args.preview_frame is not None: + preview_frame(cfg, args.preview_frame, args.output + ".png") + print(f"preview written: {args.output}.png") + return + render_movie(cfg, args.output) + size = os.path.getsize(args.output) + print(f"wrote {args.output}: {size / 1_048_576:.2f} MiB, " + f"{loop_frames(cfg)} frames @ {cfg.fps} fps") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/system_files/bluefin/usr/share/backgrounds/bluefin/bluefin-hidamari.webm b/system_files/bluefin/usr/share/backgrounds/bluefin/bluefin-hidamari.webm new file mode 100644 index 000000000..d2fc97fbf Binary files /dev/null and b/system_files/bluefin/usr/share/backgrounds/bluefin/bluefin-hidamari.webm differ diff --git a/tests/test_render_hidamari_movie.py b/tests/test_render_hidamari_movie.py new file mode 100644 index 000000000..936689bc9 --- /dev/null +++ b/tests/test_render_hidamari_movie.py @@ -0,0 +1,241 @@ +"""Tests for scripts/render-hidamari-movie.py + +The generator for the Hidamari video-wallpaper asset must stay deterministic +and seamlessly looping: the shipped WebM is rebuilt from this script, so any +change that would alter the palette, break the loop wrap, or fork the bubble +seed table must fail here. Rendering is exercised on a small Config so no +test needs a real 1080p encode. +""" + +import importlib.util +import math +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +SCRIPT_PATH = Path(__file__).parent.parent / "scripts/render-hidamari-movie.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("render_hidamari_movie", SCRIPT_PATH) + mod = importlib.util.module_from_spec(spec) + sys.modules["render_hidamari_movie"] = mod + spec.loader.exec_module(mod) + return mod + + +mod = _load_module() + +SMALL_CFG = mod.Config(width=96, height=54, fps=8, seconds=2) +ASSET_PATH = ( + Path(__file__).parent.parent + / "system_files/bluefin/usr/share/backgrounds/bluefin/bluefin-hidamari.webm" +) + + +@pytest.fixture(scope="module") +def small_render_ctx(): + bubbles = mod.make_bubbles(SMALL_CFG) + lut = mod.palette_lut() + vig = mod.vignette(SMALL_CFG) + return bubbles, lut, vig + + +# --------------------------------------------------------------------------- +# Palette +# --------------------------------------------------------------------------- + + +class TestPalette: + def test_lut_shape_and_range(self): + lut = mod.palette_lut() + assert lut.shape == (256, 3) + assert (lut >= 0).all() and (lut <= 255).all() + + def test_lut_is_deterministic(self): + np.testing.assert_array_equal(mod.palette_lut(), mod.palette_lut()) + + def test_deep_to_bright_gradient(self): + # Palette must run from deep navy to a bright highlight so the + # luminance ramp maps onto the Bluefin deep-ocean look. + lut = mod.palette_lut() + assert lut[0].sum() < lut[-1].sum() / 3 + + def test_blue_dominant(self): + # Bluefin palette: blue channel never dips below red. + lut = mod.palette_lut() + assert (lut[:, 2] >= lut[:, 0]).all() + + +# --------------------------------------------------------------------------- +# Determinism and loop wrap +# --------------------------------------------------------------------------- + + +class TestDeterminism: + def test_bubble_table_is_seeded(self): + cfg = mod.Config(seed=1158) + a = mod.make_bubbles(cfg) + b = mod.make_bubbles(mod.Config(seed=1158)) + np.testing.assert_array_equal(a, b) + assert a.shape == (mod.BUBBLE_COUNT, 5) + + def test_seed_change_forks_bubbles(self): + assert not np.array_equal( + mod.make_bubbles(mod.Config(seed=1)), mod.make_bubbles(mod.Config(seed=2)) + ) + + def test_frame_render_is_deterministic(self, small_render_ctx): + bubbles, lut, vig = small_render_ctx + a = mod.frame_rgb(SMALL_CFG, bubbles, 0.25, lut, vig, frame_index=3) + b = mod.frame_rgb(SMALL_CFG, bubbles, 0.25, lut, vig, frame_index=3) + np.testing.assert_array_equal(a, b) + + def test_loop_wrap_is_seamless(self, small_render_ctx): + # frame(0.0) and frame(1.0) may differ only by the per-frame dither + # grain; the underlying field must be periodic. + bubbles, lut, vig = small_render_ctx + lum0 = mod.luminance(SMALL_CFG, bubbles, 0.0) + lum1 = mod.luminance(SMALL_CFG, bubbles, 1.0) + np.testing.assert_allclose(lum0, lum1, atol=1e-5) + + def test_dither_grain_stays_subtle(self, small_render_ctx): + # The only allowed first-vs-last difference is grain; keep it small + # so the wrap reads as film grain, not a cut. + bubbles, lut, vig = small_render_ctx + a = mod.frame_rgb(SMALL_CFG, bubbles, 0.0, lut, vig, frame_index=0) + b = mod.frame_rgb(SMALL_CFG, bubbles, 1.0, lut, vig, frame_index=1) + assert np.abs(a.astype(int) - b.astype(int)).max() <= 4 + + def test_zero_dither_makes_exact_wrap(self, small_render_ctx): + bubbles, lut, vig = small_render_ctx + cfg = mod.Config(width=96, height=54, fps=8, seconds=2, dither=0.0) + a = mod.frame_rgb(cfg, bubbles, 0.0, lut, vig, frame_index=0) + b = mod.frame_rgb(cfg, bubbles, 1.0, lut, vig, frame_index=1) + np.testing.assert_array_equal(a, b) + + def test_mid_loop_differs_from_start(self, small_render_ctx): + bubbles, lut, vig = small_render_ctx + a = mod.frame_rgb(SMALL_CFG, bubbles, 0.0, lut, vig, frame_index=0) + c = mod.frame_rgb(SMALL_CFG, bubbles, 0.5, lut, vig, frame_index=4) + assert np.abs(a.astype(int) - c.astype(int)).mean() > 1.0 + + def test_loop_frames_count(self): + assert mod.loop_frames(SMALL_CFG) == 16 + + +# --------------------------------------------------------------------------- +# Field properties +# --------------------------------------------------------------------------- + + +class TestField: + def test_luminance_bounded(self, small_render_ctx): + bubbles, _, _ = small_render_ctx + for t in (0.0, 0.1, 0.5, 0.9): + lum = mod.luminance(SMALL_CFG, bubbles, t) + assert lum.shape == (SMALL_CFG.height, SMALL_CFG.width) + assert (lum >= 0.0).all() and (lum <= 1.0).all() + + def test_surface_light_at_top(self, small_render_ctx): + # Rays brighten the top of the frame; the upper band should not be + # darker on average than the lower band across the loop. + bubbles, _, _ = small_render_ctx + top_means, bottom_means = [], [] + for f in range(mod.loop_frames(SMALL_CFG)): + lum = mod.luminance(SMALL_CFG, bubbles, f / mod.loop_frames(SMALL_CFG)) + top_means.append(lum[: SMALL_CFG.height // 4].mean()) + bottom_means.append(lum[-SMALL_CFG.height // 4 :].mean()) + assert np.mean(top_means) > np.mean(bottom_means) + + def test_vignette_darkens_corners(self): + vig = mod.vignette(SMALL_CFG) + assert vig[0, 0] < vig[SMALL_CFG.height // 2, SMALL_CFG.width // 2] + assert (vig > 0).all() and (vig <= 1.0).all() + + def test_bubble_envelope_periodic(self): + bubbles = mod.make_bubbles(SMALL_CFG) + np.testing.assert_allclose( + mod.bubble_alpha(bubbles, 0.0), mod.bubble_alpha(bubbles, 1.0), atol=1e-9 + ) + + def test_bubble_envelope_peaks_mid_rise(self): + bubbles = mod.make_bubbles(SMALL_CFG) + alpha = mod.bubble_alpha(bubbles, bubbles[0, 1] - 0.5) + assert alpha[0] == pytest.approx(bubbles[0, 3], abs=1e-6) + + def test_frame_rgb_dtype_and_shape(self, small_render_ctx): + bubbles, lut, vig = small_render_ctx + frame = mod.frame_rgb(SMALL_CFG, bubbles, 0.3, lut, vig, frame_index=7) + assert frame.dtype == np.uint8 + assert frame.shape == (SMALL_CFG.height, SMALL_CFG.width, 3) + + def test_default_config_values(self): + # Defaults produce a 1080p 12 s loop at 24 fps; asset consumers + # (Hidamari guidance in the docs) rely on these. + cfg = mod.Config() + assert (cfg.width, cfg.height) == (1920, 1080) + assert cfg.fps == 24 + assert cfg.seconds == 12 + assert mod.loop_frames(cfg) == 288 + + +# --------------------------------------------------------------------------- +# CLI / ffmpeg wiring +# --------------------------------------------------------------------------- + + +class TestCli: + def test_missing_ffmpeg_exits_with_error(self, monkeypatch, tmp_path): + monkeypatch.setenv("FFMPEG", str(tmp_path / "no-such-ffmpeg")) + with pytest.raises(SystemExit, match="ffmpeg"): + mod.render_movie(SMALL_CFG, str(tmp_path / "out.webm")) + + def test_renders_real_webm(self, tmp_path, small_render_ctx, monkeypatch): + pytest.importorskip("numpy") + from shutil import which + + ff = which("ffmpeg") or which(str(Path.home() / "bin" / "ffmpeg")) + if ff is None: + pytest.skip("ffmpeg with libvpx-vp9 not available") + monkeypatch.setenv("FFMPEG", ff) + out = tmp_path / "out.webm" + mod.render_movie(SMALL_CFG, str(out)) + assert out.exists() and out.stat().st_size > 0 + # ffprobe not required: decode with ffmpeg and count frames. + frames = subprocess.run( + [ff, "-v", "error", "-i", str(out), "-f", "null", "-"], + capture_output=True, + ) + assert frames.returncode == 0 + + def test_preview_frame_requires_pillow(self, monkeypatch, tmp_path): + monkeypatch.setitem(sys.modules, "PIL", None) + with pytest.raises(SystemExit, match="Pillow"): + mod.preview_frame(SMALL_CFG, 0.0, str(tmp_path / "p.png")) + + def test_preview_frame_writes_png(self, tmp_path, small_render_ctx, monkeypatch): + PIL = pytest.importorskip("PIL") + monkeypatch.setitem(sys.modules, "PIL", PIL) + out = tmp_path / "p.png" + mod.preview_frame(SMALL_CFG, 0.25, str(out)) + assert out.exists() and out.stat().st_size > 0 + + +# --------------------------------------------------------------------------- +# Shipped asset +# --------------------------------------------------------------------------- + + +class TestShippedAsset: + def test_shipped_asset_exists_and_is_small(self): + # The rebuilt WebM must be checked in. Keep it under 4 MiB: it ships + # in the OCI layer consumed by every Bluefin variant. + assert ASSET_PATH.exists(), "bluefin-hidamari.webm is missing from git" + assert ASSET_PATH.stat().st_size < 4 * 1024 * 1024 + + def test_shipped_asset_is_webm(self): + assert ASSET_PATH.read_bytes()[:4] == b"\x1a\x45\xdf\xa3"