diff --git a/README.md b/README.md index 1b9d199..f5f0efe 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,31 @@ # Git Jet One to three pixel jets launch from a carrier, bank over your real commit calendar, -and destroy each active day's cell. Transparent background, waves, projectiles, +and destroy each active day's cell. Transparent background, rolling ocean swells, +bow foam and a flowing stern wake, projectiles, pixel explosions and a spinning radar. Runs with GitHub Actions. **Lincoln — default carrier** - - A pixel jet launches from a Lincoln-inspired carrier with an angled runway, detailed island and spinning radar. + + A pixel jet launches from a Lincoln-inspired carrier through rolling ocean water, with an angled runway, detailed island and spinning radar. **Classic — original carrier** - - The original ivory-island carrier with a spinning radar launches a pixel jet to clear the commit calendar. + + The original ivory-island carrier with a spinning radar and flowing ocean wake launches a pixel jet to clear the commit calendar. Choose `carrier: 'lincoln'` (default) or `carrier: 'classic'` in your workflow. For the CLI, use `--carrier classic`. Both designs have the spinning radar. +Choose `water: 'ocean'` (default) for the flowing ocean surface and wake, or +`water: 'original'` for the original small wave patches. This works with either +carrier. For the CLI, use `--water ocean` or `--water original`. + Both demos use **56 synthetic commits across 14 active days**. Live mode uses your actual data. **One attack clears one day cell**, regardless of that day's count. The jet returns to land, the calendar rebuilds, and the animation loops. diff --git a/action.yml b/action.yml index 1d842b8..9f49bf4 100644 --- a/action.yml +++ b/action.yml @@ -29,6 +29,9 @@ inputs: carrier: description: Carrier artwork, lincoln (default) or classic default: lincoln + water: + description: Water artwork, ocean (default) or original + default: ocean auto-compress: description: Automatically lower scale then FPS to fit GIF limits without skipping day cells default: 'true' @@ -68,6 +71,7 @@ runs: JET_FPS: ${{ inputs.fps }} JET_JETS: ${{ inputs.jets }} JET_CARRIER: ${{ inputs.carrier }} + JET_WATER: ${{ inputs.water }} JET_AUTO_COMPRESS: ${{ inputs.auto-compress }} JET_DEMO: ${{ inputs.demo }} JET_SEED: ${{ inputs.seed }} diff --git a/artwork/APPROVAL.md b/artwork/APPROVAL.md index 49ee562..e8023fa 100644 --- a/artwork/APPROVAL.md +++ b/artwork/APPROVAL.md @@ -4,6 +4,22 @@ Both carrier designs and the base artwork are approved. Production artwork lives `src/git_jet/art.py` (Classic, jets and effects) and `src/git_jet/lincoln.py` (Lincoln). Draft sources and review images retain the approved drawings for regression checks. +The owner requested a complete water redesign on 2026-11-02. The sea now lives in +`src/git_jet/water.py`, beneath both carriers: crossing travelling swells, drifting +bow foam, waterline turbulence and spreading stern eddies. The original carrier +studies remain archived; regression checks preserve their hull, deck and island +pixels while allowing the original detached water strips to be removed. + +The refined bow animation and the selectable `ocean` / `original` setting were +approved by the owner on 2026-11-02. The bow foam starts at the submerged stem and +peels aft along the hull. Pressure crests expand away from the stem, foam flows +out to both sides, and short spray droplets rise and fall into the wake. +Pressure crests dissolve into the sea before their animation age wraps, fixing +the visible reset reported by the owner on 2026-11-02. +The `original` setting reproduces the archived water pixels for both carriers. +Run `python scripts/preview_water.py` to generate the ocean review loop, or add +`--water original` to inspect the preserved effect. + | Asset | Status | Preview | | --- | --- | --- | | Angled aircraft carrier, parked aircraft and waves | Revision 2 approved by project owner, 2026-11-01 | drafts/output/carrier-approval.png | @@ -12,6 +28,7 @@ Draft sources and review images retain the approved drawings for regression chec | Projectiles, explosion and smoke | Approved by project owner, 2026-11-01 | drafts/output/effects-approval.png | | Mast antenna rotating through a full turn | Approved by project owner, 2026-11-02; detached aerial pixels removed as requested | [Animation](drafts/output/radar-spinner.gif) · [Eight angles](drafts/output/radar-spinner-sheet.png) | | Lincoln carrier traced from the owner's silhouette/runway guides, including island details and radar cleanup | Approved by project owner, 2026-11-02 | [Approved pixels](drafts/output/lincoln-island-detail-transparent.png) · [Island comparison](drafts/output/lincoln-island-detail-comparison.png) · [Animation](drafts/output/lincoln-island-detail-motion.gif) | +| Ocean water with expanding bow pressure crests, outward foam and spray; original water remains selectable | Approved by project owner, 2026-11-02 | [Lincoln demo](../examples/demo/git-jet.gif) · [Classic demo](../examples/demo-classic/git-jet.gif) | Lincoln is the default; Classic is selectable. Classic's original hull, deck and island remain unchanged. Its detached aerial was removed alongside the same cleanup diff --git a/examples/demo-classic/git-jet.gif b/examples/demo-classic/git-jet.gif index ad9f051..379784b 100644 Binary files a/examples/demo-classic/git-jet.gif and b/examples/demo-classic/git-jet.gif differ diff --git a/examples/demo-classic/git-jet.png b/examples/demo-classic/git-jet.png index 8f32f75..7d8e3e8 100644 Binary files a/examples/demo-classic/git-jet.png and b/examples/demo-classic/git-jet.png differ diff --git a/examples/demo-classic/report.json b/examples/demo-classic/report.json index ffe448b..0bf45b9 100644 --- a/examples/demo-classic/report.json +++ b/examples/demo-classic/report.json @@ -11,6 +11,7 @@ "attack_mode": "day-cell", "jets": 1, "carrier": "classic", + "water": "ocean", "seed": "git-jet-loop", "flights": [ { @@ -40,7 +41,7 @@ "duration_seconds": 28.0, "transparent": true, "decoded_frames_match": true, - "gif_bytes": 8407801, + "gif_bytes": 14334512, "targets": [ { "date": "2026-10-18", diff --git a/examples/demo/git-jet.gif b/examples/demo/git-jet.gif index 0f65ee0..98534dd 100644 Binary files a/examples/demo/git-jet.gif and b/examples/demo/git-jet.gif differ diff --git a/examples/demo/git-jet.png b/examples/demo/git-jet.png index e27a7a1..84fbeac 100644 Binary files a/examples/demo/git-jet.png and b/examples/demo/git-jet.png differ diff --git a/examples/demo/report.json b/examples/demo/report.json index ff2289e..de400ac 100644 --- a/examples/demo/report.json +++ b/examples/demo/report.json @@ -11,6 +11,7 @@ "attack_mode": "day-cell", "jets": 1, "carrier": "lincoln", + "water": "ocean", "seed": "git-jet-loop", "flights": [ { @@ -40,7 +41,7 @@ "duration_seconds": 24.0, "transparent": true, "decoded_frames_match": true, - "gif_bytes": 7371234, + "gif_bytes": 13041689, "targets": [ { "date": "2026-10-18", diff --git a/examples/profile-workflow.yml b/examples/profile-workflow.yml index 211ed6f..367dcce 100644 --- a/examples/profile-workflow.yml +++ b/examples/profile-workflow.yml @@ -23,6 +23,7 @@ jobs: username: ${{ github.repository_owner }} days: '365' carrier: lincoln # Choose lincoln (default) or classic. + water: ocean # Choose ocean (default) or original for the old water. jets: '1' # Choose 1, 2 or 3; more jets shorten busy calendars. - name: Publish README images env: diff --git a/scripts/preview_water.py b/scripts/preview_water.py new file mode 100644 index 0000000..3cd2f2a --- /dev/null +++ b/scripts/preview_water.py @@ -0,0 +1,63 @@ +"""Render a four-second water study and matching light/dark review images.""" + +import argparse +from pathlib import Path + +from PIL import Image + +from git_jet.art import CARRIERS, DEFAULT_CARRIER, carrier_sprite +from git_jet.export import indexed, palette +from git_jet.water import DEFAULT_WATER, POSITION, WATER_STYLES, water_sprite + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--carrier", choices=CARRIERS, default=DEFAULT_CARRIER) + parser.add_argument("--water", choices=WATER_STYLES, default=DEFAULT_WATER) + args = parser.parse_args() + output = Path(__file__).resolve().parents[1] / "out/water-preview" + output.mkdir(parents=True, exist_ok=True) + mapping, rgb = palette() + frames = [] + bow_frames = [] + for index in range(80): + phase = index / 80 + scene = Image.new("RGBA", (480, 320)) + scene.alpha_composite(water_sprite(phase, water=args.water, carrier=args.carrier), POSITION) + scene.alpha_composite(carrier_sprite(phase, carrier=args.carrier), (65, 12)) + frame = indexed(scene, mapping, rgb, 2).crop((0, 0, 960, 440)) + frames.append(frame) + bow = frame.crop((45, 220, 425, 415)).resize((760, 390), Image.Resampling.NEAREST) + bow.putpalette(rgb) + bow_frames.append(bow) + frames[0].save( + output / f"{args.water}-{args.carrier}-motion-v4.gif", + save_all=True, + append_images=frames[1:], + duration=50, + loop=0, + transparency=0, + disposal=2, + optimize=False, + ) + review = Image.new("RGB", (960, 880)) + for row, color in enumerate(("#0d1117", "#ffffff")): + background = Image.new("RGBA", (960, 440), color) + background.alpha_composite(frames[0].convert("RGBA")) + review.paste(background.convert("RGB"), (0, row * 440)) + review.save(output / f"{args.water}-{args.carrier}-backgrounds-v4.png") + bow_frames[0].save( + output / f"{args.water}-{args.carrier}-bow-v4.gif", + save_all=True, + append_images=bow_frames[1:], + duration=50, + loop=0, + transparency=0, + disposal=2, + optimize=False, + ) + print(output) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_action.py b/scripts/run_action.py index cd02893..ce56d6f 100644 --- a/scripts/run_action.py +++ b/scripts/run_action.py @@ -34,6 +34,8 @@ def run(): os.environ.get("JET_JETS", "1"), "--carrier", os.environ.get("JET_CARRIER", "lincoln"), + "--water", + os.environ.get("JET_WATER", "ocean"), ] if demo == "true": args += ["--demo", "--days", os.environ.get("JET_DAYS", "365")] diff --git a/src/git_jet/art.py b/src/git_jet/art.py index 4890918..e5f849a 100644 --- a/src/git_jet/art.py +++ b/src/git_jet/art.py @@ -153,26 +153,6 @@ def block(u, v, w, depth, z, height): CREAM, ) - # Detached stepped foam patches, rather than an opaque ocean rectangle. - angle = math.tau * (t % 1) - for index, u in enumerate((-7, 30, 70, 112, 158, 202, 241)): - x, y = project(u, 58, -2) - x += round(2 * math.sin(angle + index)) - y += round(math.sin(angle + index * 0.7)) - width = (22, 31, 24, 35, 23, 30, 21)[index] - draw.polygon( - [ - (x - 7, y), - (x + width - 5, y - 10), - (x + width, y - 9), - (x + 1, y + 3), - (x - 10, y + 3), - ], - fill=DEEP, - ) - draw.line((x - 4, y, x + width - 7, y - 9), fill=WAVE, width=2) - draw.line((x, y - 1, x + width - 11, y - 8), fill=FOAM) - # Deep sidewalls, red waterline, and angular bow face in the reference view. poly([(-18, 20, 24), (-18, 36, 24), (8, 52, 24), (8, 52, 0), (-18, 36, 0), (-18, 20, 7)], INK) poly( diff --git a/src/git_jet/cli.py b/src/git_jet/cli.py index 2bf070c..f933090 100644 --- a/src/git_jet/cli.py +++ b/src/git_jet/cli.py @@ -12,6 +12,7 @@ from .demo import demo_calendar from .export import Options, render_assets from .github import GitHubClient, GitHubError +from .water import DEFAULT_WATER, WATER_STYLES def main(argv=None): @@ -37,6 +38,12 @@ def main(argv=None): default=DEFAULT_CARRIER, help="carrier artwork (default: lincoln)", ) + render.add_argument( + "--water", + choices=WATER_STYLES, + default=DEFAULT_WATER, + help="water artwork: ocean or original (default: ocean)", + ) render.add_argument( "--jets", type=int, choices=(1, 2, 3), default=1, help="simultaneous jets (default: 1)" ) @@ -78,6 +85,7 @@ def main(argv=None): options, jets=args.jets, carrier=args.carrier, + water=args.water, seed=args.seed, source=source_name, auto_compress=args.auto_compress, diff --git a/src/git_jet/export.py b/src/git_jet/export.py index eab580d..4d8155c 100644 --- a/src/git_jet/export.py +++ b/src/git_jet/export.py @@ -10,13 +10,15 @@ from dataclasses import dataclass from pathlib import Path -from PIL import GifImagePlugin, Image, ImageChops +from PIL import GifImagePlugin, Image, ImageChops, ImageColor from . import art from .calendar import Calendar from .flight import HEIGHT, WIDTH, route_seed from .limits import FrameLimitError, GifSizeLimitError from .render import Renderer +from .water import COLORS as WATER_COLORS +from .water import DEFAULT_WATER ASSETS = ("git-jet.gif", "git-jet.png", "report.json", "calendar.json") MAX_BYTES = 25_000_000 @@ -41,7 +43,12 @@ def palette(): *(art.explosion_sprite(i / 10) for i in range(10)), *(art.jet_sprite(0, thrust, bank) for thrust in range(3) for bank in (-1, 0, 1)), ] - colors = sorted({color for sample in samples for _, color in sample.getcolors(256) if color[3]}) + colors = sorted( + { + *(color for sample in samples for _, color in sample.getcolors(256) if color[3]), + *(ImageColor.getcolor(color, "RGBA") for color in WATER_COLORS), + } + ) if len(colors) > 255 or any(color[3] != 255 for color in colors): raise ValueError("artwork must fit 255 opaque colors plus transparency") entries = [(0, 0, 0, 0), *colors] @@ -131,6 +138,7 @@ def export( "attack_mode": "day-cell", "jets": renderer.jet_count, "carrier": renderer.carrier, + "water": renderer.water, "seed": renderer.flight.seed, "flights": [ { @@ -181,6 +189,7 @@ def render_assets( *, jets=1, carrier=art.DEFAULT_CARRIER, + water=DEFAULT_WATER, seed=None, source="live", auto_compress=True, @@ -193,7 +202,9 @@ def render_assets( attempt = 0 for fps in rates: try: - renderer = Renderer(calendar, fps=fps, seed=seed, jets=jets, carrier=carrier) + renderer = Renderer( + calendar, fps=fps, seed=seed, jets=jets, carrier=carrier, water=water + ) scales = ( range(options.scale, 0, -1) if auto_compress and fps == options.fps diff --git a/src/git_jet/lincoln.py b/src/git_jet/lincoln.py index 49028a5..28e99a6 100644 --- a/src/git_jet/lincoln.py +++ b/src/git_jet/lincoln.py @@ -4,7 +4,7 @@ from PIL import Image, ImageDraw -from git_jet.art import DEEP, FOAM, GOLD, INK, LIGHT, WAVE, project, radar_sprite +from git_jet.art import FOAM, GOLD, INK, LIGHT, project, radar_sprite HULL = "#546570" LIT = "#71838d" @@ -94,41 +94,6 @@ def box(u, v, width, depth, z, height, top=GREY): top, ) - phase = math.tau * (t % 1) - for index, (u, v) in enumerate( - ((-3, 27), (35, 34), (75, 40), (116, 57), (158, 57), (200, 57), (242, 56), (268, 47)) - ): - x, y = project(u, v, 0) - x += round(2 * math.sin(phase + index)) - y += round(math.sin(phase + index * 0.8)) - reach = (15, 21, 20, 25, 22, 24, 18, 14)[index] - draw.polygon( - [ - (x - 8, y + 2), - (x + reach - 4, y - 7), - (x + reach + 4, y - 6), - (x + 2, y + 5), - (x - 10, y + 5), - ], - fill=DEEP, - ) - draw.line((x - 5, y + 1, x + reach - 4, y - 6), fill=WAVE, width=2) - draw.line((x - 1, y, x + reach - 9, y - 4), fill=FOAM) - x, y = project(-12, 10, 2) - draw.polygon( - [ - (x - 10, y + 1), - (x - 4, y - 4), - (x + 2, y - 2), - (x + 9, y), - (x + 15, y + 3), - (x + 3, y + 6), - (x - 7, y + 5), - ], - fill=WAVE, - ) - draw.line((x - 7, y, x - 2, y - 2, x + 4, y + 1, x + 10, y + 2), fill=FOAM, width=2) - # A fine raked stem underneath the thin, straight bow deck edge. stem, keel = (-23, 11, 24), (-10, 11, 8) poly([stem, (-3, -2, 24), (23, 6, 24), (27, 28, 24), (18, 24, 4), keel], INK) diff --git a/src/git_jet/render.py b/src/git_jet/render.py index 6cca4c9..9e7cba9 100644 --- a/src/git_jet/render.py +++ b/src/git_jet/render.py @@ -26,14 +26,25 @@ Squadron, sprite_heading, ) +from .water import DEFAULT_WATER, validate_water, water_sprite +from .water import POSITION as WATER_POSITION class Renderer: - def __init__(self, calendar: Calendar, fps=20, seed=None, jets=1, carrier=DEFAULT_CARRIER): + def __init__( + self, + calendar: Calendar, + fps=20, + seed=None, + jets=1, + carrier=DEFAULT_CARRIER, + water=DEFAULT_WATER, + ): if type(jets) is not int or not 1 <= jets <= 3: raise ValueError("jets must be an integer in 1..3") self.jet_count = jets self.carrier = validate_carrier(carrier) + self.water = validate_water(water) self.calendar = calendar self.cells = calendar.cells() self.flight = ( @@ -77,6 +88,9 @@ def frame(self, frame_number: int): frame = frame_number % self.frame_count phase = frame / (self.flight.fps * 4) image = Image.new("RGBA", (WIDTH, HEIGHT)) + image.alpha_composite( + water_sprite(phase, water=self.water, carrier=self.carrier), WATER_POSITION + ) image.alpha_composite(carrier_sprite(phase, carrier=self.carrier), (65, 12)) image.alpha_composite(self.grid_frame(frame)) draw = ImageDraw.Draw(image) diff --git a/src/git_jet/water.py b/src/git_jet/water.py new file mode 100644 index 0000000..785fed9 --- /dev/null +++ b/src/git_jet/water.py @@ -0,0 +1,354 @@ +"""Directional ocean swells and an advecting carrier wake, with binary alpha.""" + +import math +from functools import lru_cache + +from PIL import Image, ImageColor, ImageDraw, ImageFilter + +from .art import DEEP as ORIGINAL_DEEP +from .art import DEFAULT_CARRIER, project, validate_carrier +from .art import FOAM as ORIGINAL_FOAM +from .art import WAVE as ORIGINAL_WAVE + +DEFAULT_WATER = "ocean" +WATER_STYLES = ("ocean", "original") +SIZE = (450, 210) +POSITION = (25, 12) +SHADOW = "#123747" +DEEP = "#19475b" +MID = "#20576d" +SHALLOW = "#2b687e" +SWELL = "#397e92" +GLINT = "#5b9eaf" +SPRAY = "#a5cdd2" +FOAM = "#e8eee6" +COLORS = ( + SHADOW, + DEEP, + MID, + SHALLOW, + SWELL, + GLINT, + SPRAY, + FOAM, + ORIGINAL_DEEP, + ORIGINAL_WAVE, + ORIGINAL_FOAM, +) +RGB = tuple(ImageColor.getcolor(color, "RGBA") for color in COLORS) + + +def point(u, v, z=0): + x, y = project(u, v, z) + return x + 40, y + + +def validate_water(water): + if not isinstance(water, str) or water not in WATER_STYLES: + raise ValueError("water must be ocean or original") + return water + + +def _smoothstep(value): + value = min(1, max(0, value)) + return value * value * (3 - 2 * value) + + +def _fade(layer, opacity): + """Dissolve a layer without adding partial alpha or new GIF colors.""" + if opacity >= 1: + return layer + thresholds = ((0, 8, 2, 10), (12, 4, 14, 6), (3, 11, 1, 9), (15, 7, 13, 5)) + bounds = layer.getbbox() + if bounds: + pixels = layer.load() + for y in range(bounds[1], bounds[3]): + for x in range(bounds[0], bounds[2]): + if opacity <= (thresholds[y % 4][x % 4] + 0.5) / 16: + pixels[x, y] = (0, 0, 0, 0) + return layer + + +def _pressure_crest(age, carrier=DEFAULT_CARRIER): + layer = Image.new("RGBA", SIZE) + # Leave the end of the lifetime clear, including the last sample at every + # supported FPS. Wrapping the age can then never teleport a visible ridge. + if age <= 0 or age >= 0.88: + return layer + bow_u, bow_v, bow_z = (-12, 11, 6) if carrier == "lincoln" else (-19, 27, 5) + ridge = [] + for step in range(25): + angle = -math.pi / 2 + math.pi * step / 24 + u = bow_u + 4 + age * 12 - (3 + age * 14) * math.cos(angle) + v = bow_v + (5 + age * 28) * math.sin(angle) + z = bow_z * (1 - age) + 2 * math.sin(math.pi * age) + ridge.append(point(u, v, z)) + draw = ImageDraw.Draw(layer) + draw.line(ridge, fill=SHALLOW, width=4, joint="curve") + draw.line(ridge, fill=SWELL, width=2, joint="curve") + highlights = Image.new("RGBA", SIZE) + bright = ImageDraw.Draw(highlights) + for start in (0, 4, 9, 14, 19, 22): + draw.line(ridge[start : start + 2], fill=GLINT) + bright.line(ridge[start : start + 2], fill=SPRAY) + layer.alpha_composite(_fade(highlights, 1 - _smoothstep((age - 0.25) / 0.15))) + opacity = _smoothstep(age / 0.10) * (1 - _smoothstep((age - 0.45) / 0.43)) + return _fade(layer, opacity) + + +def water_sprite(t=0, *, water=DEFAULT_WATER, carrier=DEFAULT_CARRIER): + """Sample a four-second ocean cycle independently of export frame rate.""" + validate_water(water) + validate_carrier(carrier) + if water == "original": + return original_water_sprite(t, carrier=carrier) + # All supported rates divide 400, so motion advances in equal time steps + # at 5, 10, 20 and 25 FPS while a complete cycle fits in the frame cache. + return _water_frame(round((t % 1) * 400) % 400, carrier) + + +@lru_cache(maxsize=100) +def _water_frame(index, carrier): + time = index / 400 + phase = math.tau * time + bow_u, bow_v, bow_z = (-12, 11, 6) if carrier == "lincoln" else (-19, 27, 5) + mask = Image.new("L", SIZE) + outline = ( + (-75, 10), + (-63, -16), + (-33, -34), + (24, -41), + (102, -42), + (183, -39), + (266, -31), + (325, -15), + (355, 14), + (337, 55), + (305, 95), + (244, 104), + (169, 109), + (83, 116), + (7, 114), + (-51, 99), + (-79, 69), + (-86, 38), + ) + ImageDraw.Draw(mask).polygon( + [point(u, v + 2 * math.sin(phase + n * 1.3)) for n, (u, v) in enumerate(outline)], fill=255 + ) + edge = mask.filter(ImageFilter.GaussianBlur(2.5)).load() + + # Three travelling wave trains cross at different wavelengths and speeds. + # Integer temporal frequencies close the loop without reversing the flow. + image = Image.new("RGBA", SIZE) + pixels = image.load() + for y in range(29, 204): + for x in range(5, 445): + coverage = edge[x, y] + grain = ((x // 3 * 73) ^ (y * 151) ^ (x // 9 * y * 7)) % 256 + if coverage == 0 or (coverage < 255 and coverage <= grain): + continue + dx, dy = x - 75, y - 140 + u = (0.30 * dx - 0.76 * dy) / 0.5024 + v = (0.29 * dx + 0.94 * dy) / 0.5024 + long = 0.12 * u + 0.26 * v - phase + cross = 0.21 * u - 0.39 * v - phase * 2 + chop = 0.47 * u + 0.61 * v - phase * 3 + face = ( + 0.62 * math.sin(long + 0.5 * math.sin(cross)) + + 0.27 * math.sin(cross) + + 0.11 * math.sin(chop) + ) + # The bow compresses the approaching swells into travelling rings. + # Their energy falls away with distance from the submerged stem. + if bow_u - 45 < u < bow_u + 65 and abs(v - bow_v) < 50: + distance = math.hypot((u - bow_u) / 1.25, v - bow_v) + pressure = max(0, 1 - distance / 55) + face += 0.35 * pressure * math.sin(distance * 0.48 - phase * 4) + shade = ( + 0 + if face < -0.69 + else 1 + if face < -0.29 + else 2 + if face < 0.20 + else 3 + if face < 0.60 + else 4 + if face < 0.91 + else 5 + ) + pixels[x, y] = RGB[shade] + + draw = ImageDraw.Draw(image) + + # Successive pressure crests grow outward from the stem, then flatten into + # the sea. Their ends flare aft, carrying the displaced water around the bow. + for crest in range(3): + age = (time * 2 + crest / 3) % 1 + image.alpha_composite(_pressure_crest(age, carrier)) + + # Bright foam follows curved paths away from the contact point. A second, + # smaller set of particles rises above the crest and falls back into it. + for side in (-1, 1): + + def foam_point(q, side=side): + u = bow_u - 5 * math.sin(math.pi * q) + 62 * q * q + v = bow_v + side * (3 + 35 * q + 9 * math.sin(math.pi * q)) + return point(u, v, bow_z * (1 - q)) + + for fleck in range(24): + age = (time * 2 + fleck / 24) % 1 + color = FOAM if age < 0.45 else SPRAY if age < 0.75 else GLINT + draw.line( + (foam_point(max(0, age - 0.035)), foam_point(age)), + fill=color, + width=1 + (age < 0.25), + ) + for droplet in range(12): + age = (time * 3 + droplet / 12 + side * 0.07) % 1 + u = bow_u - 5 * age + 18 * age * age + v = bow_v + side * (4 + 20 * age) + z = bow_z + 8 * math.sin(math.pi * age) - 3 * age + x, y = point(u, v, z) + if age < 0.82: + draw.line((x, y, x, y + 1), fill=FOAM if age < 0.45 else SPRAY) + + # Narrow shoulder waves carry the pressure crest into the longer hull wake. + + def shoulder(q, side): + spread = (42 if carrier == "lincoln" else 34) if side == 1 else 22 + u = bow_u + 168 * q + v = bow_v + side * (10 * math.sqrt(q) + spread * q) + v += 0.8 * math.sin(phase - q * 9) * math.sin(math.pi * q) + return point(u, v, bow_z * (1 - q)) + + draw.polygon( + [ + point(bow_u - 4, bow_v, bow_z - 1), + point(bow_u + 3, bow_v - 7, bow_z - 1), + point(bow_u + 18, bow_v - 8, bow_z - 1), + point(bow_u + 22, bow_v + 12, bow_z - 1), + point(bow_u + 4, bow_v + 8, bow_z - 1), + ], + fill=SHALLOW, + ) + for side in (-1, 1): + path = [shoulder(step / 32, side) for step in range(33)] + draw.line(path, fill=SHALLOW, width=3, joint="curve") + for start in range(0, 30, 4): + draw.line(path[start : start + 3], fill=SWELL, width=2) + for fleck in range(19): + q = (time * 3 / 19 + fleck / 19) % 1 + x, y = shoulder(q, side) + reach = max(1, round(3 * (1 - q))) + color = FOAM if q < 0.20 else SPRAY if q < 0.5 else GLINT + draw.line((x, y, x + reach, y - 1), fill=color) + + # Adjacent lanes churn at different phases along the visible waterline. + for lane in range(3): + for fleck in range(24): + q = (time / 6 + fleck / 24 + lane * 0.17) % 1 + u = -3 + q * 301 + v = 35 + 20 * min(1, max(0, u / 110)) + lane * 6 + v += 2.8 * math.sin(q * 27 - phase + lane) + x, y = point(u, v) + length = 6 + round(3 * math.sin(q * math.tau * 5 + lane)) + draw.line((x - 1, y + 1, x + length, y - 2), fill=SWELL, width=2) + color = SPRAY if lane == 0 and round(q * 24) % 3 else GLINT + draw.line((x, y, x + length - 2, y - 2), fill=color) + if lane == 0 and round(q * 24) % 4 == 0: + draw.point((x + 1, y - 1), fill=FOAM) + + # Widening stern eddies lose their bright foam as they drift aft. + for side in (-1, 1): + for eddy in range(12): + q = (time / 4 + eddy / 12 + side * 0.09) % 1 + u = 257 + q * 105 + v = 23 + side * (6 + q * 38) + v += 3 * math.sin(q * 13 - phase + side) + x, y = point(u, v) + radius = 3 + round(q * 7) + draw.arc( + (x - radius, y - 2, x + radius, y + 3), 185, 345, fill=GLINT if q > 0.5 else SPRAY + ) + if q < 0.35: + draw.line((x - 2, y - 1, x + 2, y - 2), fill=FOAM) + + # Wake marks share the dissolving, transparent outer edge of the sea. + for y in range(SIZE[1]): + for x in range(SIZE[0]): + if edge[x, y] < 100: + pixels[x, y] = (0, 0, 0, 0) + return image + + +def original_water_sprite(t=0, *, carrier=DEFAULT_CARRIER): + """Preserve the exact water pixels used by each original carrier drawing.""" + image = Image.new("RGBA", SIZE) + draw = ImageDraw.Draw(image) + phase = math.tau * (t % 1) + if carrier == "classic": + for index, u in enumerate((-7, 30, 70, 112, 158, 202, 241)): + x, y = point(u, 58, -2) + x += round(2 * math.sin(phase + index)) + y += round(math.sin(phase + index * 0.7)) + width = (22, 31, 24, 35, 23, 30, 21)[index] + draw.polygon( + [ + (x - 7, y), + (x + width - 5, y - 10), + (x + width, y - 9), + (x + 1, y + 3), + (x - 10, y + 3), + ], + fill=ORIGINAL_DEEP, + ) + draw.line((x - 4, y, x + width - 7, y - 9), fill=ORIGINAL_WAVE, width=2) + draw.line((x, y - 1, x + width - 11, y - 8), fill=ORIGINAL_FOAM) + else: + for index, (u, v) in enumerate( + ( + (-3, 27), + (35, 34), + (75, 40), + (116, 57), + (158, 57), + (200, 57), + (242, 56), + (268, 47), + ) + ): + x, y = point(u, v) + x += round(2 * math.sin(phase + index)) + y += round(math.sin(phase + index * 0.8)) + reach = (15, 21, 20, 25, 22, 24, 18, 14)[index] + draw.polygon( + [ + (x - 8, y + 2), + (x + reach - 4, y - 7), + (x + reach + 4, y - 6), + (x + 2, y + 5), + (x - 10, y + 5), + ], + fill=ORIGINAL_DEEP, + ) + draw.line((x - 5, y + 1, x + reach - 4, y - 6), fill=ORIGINAL_WAVE, width=2) + draw.line((x - 1, y, x + reach - 9, y - 4), fill=ORIGINAL_FOAM) + x, y = point(-12, 10, 2) + draw.polygon( + [ + (x - 10, y + 1), + (x - 4, y - 4), + (x + 2, y - 2), + (x + 9, y), + (x + 15, y + 3), + (x + 3, y + 6), + (x - 7, y + 5), + ], + fill=ORIGINAL_WAVE, + ) + draw.line( + (x - 7, y, x - 2, y - 2, x + 4, y + 1, x + 10, y + 2), fill=ORIGINAL_FOAM, width=2 + ) + return image diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..56e3fa7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +import pytest +from PIL import ImageColor + + +@pytest.fixture +def assert_carrier_body_preserved(): + """Only the detached blue water may disappear from the approved studies.""" + sea = {ImageColor.getcolor(color, "RGBA") for color in ("#39799c", "#70b3ce", "#f1f4e8")} + + def compare(current, reference): + assert current.size == reference.size + pixels, original = current.load(), reference.load() + for y in range(current.height): + for x in range(current.width): + assert pixels[x, y] == original[x, y] or ( + pixels[x, y] == (0, 0, 0, 0) and original[x, y] in sea + ), (x, y) + + return compare diff --git a/tests/test_action.py b/tests/test_action.py index c3ddf53..d12eecc 100644 --- a/tests/test_action.py +++ b/tests/test_action.py @@ -20,6 +20,7 @@ def test_action_forwards_inputs_and_writes_only_successful_output(monkeypatch, t monkeypatch.setenv("JET_DAYS", "30") monkeypatch.setenv("JET_JETS", "3") monkeypatch.setenv("JET_CARRIER", "classic") + monkeypatch.setenv("JET_WATER", "original") monkeypatch.setenv("JET_AUTO_COMPRESS", "false") monkeypatch.setenv("JET_OUTPUT_DIR", "generated") monkeypatch.setenv("JET_SEED", "reproducible-action-route") @@ -36,6 +37,7 @@ def collect(argv): assert args[args.index("--days") + 1] == "30" assert args[args.index("--jets") + 1] == "3" assert args[args.index("--carrier") + 1] == "classic" + assert args[args.index("--water") + 1] == "original" assert "--no-auto-compress" in args assert args[args.index("--seed") + 1] == "reproducible-action-route" outputs = dict(line.split("=", 1) for line in (tmp_path / "outputs").read_text().splitlines()) @@ -71,7 +73,8 @@ def test_action_does_not_publish_an_output_when_collection_fails(monkeypatch, tm @pytest.mark.parametrize("carrier", ("lincoln", "classic")) -def test_actual_action_script_generates_demo_assets_and_returns_paths(tmp_path, carrier): +@pytest.mark.parametrize("water", ("ocean", "original")) +def test_actual_action_script_generates_demo_assets_and_returns_paths(tmp_path, carrier, water): process = subprocess.run( [sys.executable, str(ROOT / "scripts/run_action.py")], env={ @@ -87,6 +90,7 @@ def test_actual_action_script_generates_demo_assets_and_returns_paths(tmp_path, "JET_SEED": "action-smoke", "JET_JETS": "3", "JET_CARRIER": carrier, + "JET_WATER": water, "JET_AUTO_COMPRESS": "true", "GITHUB_TOKEN": "never-save-this-test-token", }, @@ -99,6 +103,7 @@ def test_actual_action_script_generates_demo_assets_and_returns_paths(tmp_path, assert report["seed"] == "action-smoke" assert report["jets"] == 3 and len(report["flights"]) == 3 assert report["carrier"] == carrier + assert report["water"] == water assert sum(f["attacks"] for f in report["flights"]) == report["attacks"] assert report["decoded_frames_match"] outputs = dict(line.split("=", 1) for line in (tmp_path / "outputs").read_text().splitlines()) diff --git a/tests/test_auto_compress.py b/tests/test_auto_compress.py index a6adac1..3ca889d 100644 --- a/tests/test_auto_compress.py +++ b/tests/test_auto_compress.py @@ -23,7 +23,9 @@ def collect(*args, **kwargs): return calendar monkeypatch.setattr("git_jet.cli.GitHubClient.collect", collect) - monkeypatch.setattr(exporter, "MAX_BYTES", 5_000_000) + # Between the native and doubled-size demos, so this exercises scale-only + # fallback even with the richer animated ocean surface. + monkeypatch.setattr(exporter, "MAX_BYTES", 8_000_000) assert ( main( [ @@ -95,6 +97,7 @@ def fit(renderer, output, options, **kwargs): assert renderer.calendar is calendar assert renderer.flight.seed == "keep-this-route" and renderer.jet_count == 3 assert renderer.carrier == "classic" + assert renderer.water == "original" assert len(renderer.flight.strikes) == 14 assert sum(s.cell.day.commits for s in renderer.flight.strikes) == calendar.total if options.fps == 20: @@ -103,7 +106,9 @@ def fit(renderer, output, options, **kwargs): monkeypatch.setattr("git_jet.flight.secrets.token_hex", fresh_seed) monkeypatch.setattr(exporter, "export", fit) - assert render_assets(calendar, tmp_path, Options(), jets=3, carrier="classic") == {"fps": 10} + assert render_assets( + calendar, tmp_path, Options(), jets=3, carrier="classic", water="original" + ) == {"fps": 10} assert attempts == [Options(2, 20), Options(1, 20), Options(1, 10)] assert len(seeds) == 1 diff --git a/tests/test_carriers.py b/tests/test_carriers.py index 14139c0..63f6651 100644 --- a/tests/test_carriers.py +++ b/tests/test_carriers.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[1] -def test_lincoln_is_the_default_and_matches_the_approved_drawing(): +def test_lincoln_is_the_default_and_preserves_the_approved_body(assert_carrier_body_preserved): spec = importlib.util.spec_from_file_location( "approved_lincoln", ROOT / "artwork/drafts/lincoln_redline.py" ) @@ -27,11 +27,10 @@ def test_lincoln_is_the_default_and_matches_the_approved_drawing(): with Image.open( ROOT / "artwork/drafts/output/lincoln-island-detail-transparent.png" ) as reference: - assert art.carrier_sprite().tobytes() == reference.tobytes() + assert_carrier_body_preserved(art.carrier_sprite(), reference) for index in range(80): - assert ( - art.carrier_sprite(index / 80).tobytes() - == approved.carrier_sprite(index / 80).tobytes() + assert_carrier_body_preserved( + art.carrier_sprite(index / 80), approved.carrier_sprite(index / 80) ) assert art.carrier_sprite().tobytes() != art.carrier_sprite(carrier="classic").tobytes() diff --git a/tests/test_export.py b/tests/test_export.py index 77f1710..de377da 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -12,8 +12,9 @@ @pytest.mark.parametrize("jets", (1, 3)) @pytest.mark.parametrize("carrier", ("lincoln", "classic")) +@pytest.mark.parametrize("water", ("ocean", "original")) def test_real_cached_calendar_exports_exact_frames_without_network( - tmp_path, monkeypatch, capsys, jets, carrier + tmp_path, monkeypatch, capsys, jets, carrier, water ): def forbidden(*args, **kwargs): raise AssertionError("cached or demo rendering must not use the network") @@ -38,6 +39,8 @@ def forbidden(*args, **kwargs): str(jets), "--carrier", carrier, + "--water", + water, "--output-dir", str(output), ] @@ -51,9 +54,10 @@ def forbidden(*args, **kwargs): assert report["source"] == "cache" assert report["jets"] == jets and len(report["flights"]) == jets assert report["carrier"] == carrier + assert report["water"] == water assert sum(f["attacks"] for f in report["flights"]) == 2 assert {t["jet"] for t in report["targets"]}.issubset(set(range(1, jets + 1))) - renderer = Renderer(calendar, jets=jets, seed=report["seed"], carrier=carrier) + renderer = Renderer(calendar, jets=jets, seed=report["seed"], carrier=carrier, water=water) with Image.open(output / "git-jet.png") as png: assert png.tobytes() == renderer.frame(0).tobytes() assert set(png.getchannel("A").tobytes()) == {0, 255} diff --git a/tests/test_render.py b/tests/test_render.py index b2d15de..bb5f08d 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -62,7 +62,7 @@ def test_zero_commits_patrol_has_no_targets_or_fabricated_hits(): assert set(renderer.frame(frame).getchannel("A").tobytes()) == {0, 255} -def test_production_art_preserves_approved_studies_outside_the_rotating_radar(): +def test_production_art_preserves_approved_body_and_effects(assert_carrier_body_preserved): spec = importlib.util.spec_from_file_location( "approved_art", ROOT / "artwork/drafts/sprites.py" ) @@ -73,7 +73,7 @@ def test_production_art_preserves_approved_studies_outside_the_rotating_radar(): x, y = art.project(185, 10, 60) # The owner requested removal of this detached five-pixel aerial. reference.paste((0, 0, 0, 0), (x - 3, y - 26, x - 2, y - 21)) - assert art.carrier_sprite(carrier="classic").tobytes() == reference.tobytes() + assert_carrier_body_preserved(art.carrier_sprite(carrier="classic"), reference) for phase in (0, 0.12, 0.5, 0.95): current, original = ( art.carrier_sprite(phase, carrier="classic"), @@ -82,7 +82,7 @@ def test_production_art_preserves_approved_studies_outside_the_rotating_radar(): x, y = art.project(185, 10, 60) radar = (x - 7, y - 27, x + 9, y - 12) original.paste(current.crop(radar), radar[:2]) - assert current.tobytes() == original.tobytes() + assert_carrier_body_preserved(current, original) for heading in (i * 22.5 for i in range(16)): for bank in (-1, 0, 1): for thrust in (0, 1, 2): @@ -121,7 +121,7 @@ def test_airborne_speed_and_heading_are_continuous_through_shots_and_path_joins( def test_frame_rate_changes_sampling_not_cruise_speed(): for fps in (5, 10, 20, 25): - flight = Flight(demo_calendar(), fps=fps) + flight = Flight(demo_calendar(), fps=fps, seed="frame-rate-sampling") assert flight.frame_count % (fps * 4) == 0 assert 98 < flight.step * fps <= 100 a, b = flight.pose(flight.cruise_start + 1), flight.pose(flight.cruise_start + 2) diff --git a/tests/test_water.py b/tests/test_water.py new file mode 100644 index 0000000..3c7775a --- /dev/null +++ b/tests/test_water.py @@ -0,0 +1,96 @@ +import importlib.util +from pathlib import Path + +import pytest +from PIL import Image + +from git_jet import art +from git_jet.cli import main +from git_jet.demo import demo_calendar +from git_jet.export import indexed, palette +from git_jet.flight import HEIGHT, WIDTH +from git_jet.render import Renderer +from git_jet.water import POSITION, _pressure_crest, water_sprite + + +def test_water_moves_loops_and_keeps_clear_space_around_the_surface(): + first = water_sprite(0) + assert first.tobytes() == water_sprite(1).tobytes() == water_sprite(-1).tobytes() + assert len({water_sprite(i / 20).tobytes() for i in range(20)}) == 20 + for index in range(100): + surface = water_sprite(index / 100) + assert set(surface.getchannel("A").tobytes()) == {0, 255} + left, top, right, bottom = surface.getbbox() + assert 0 < left < right < surface.width + assert 0 < top < bottom < surface.height + assert bottom + POSITION[1] < 230 # the calendar begins at y=237 + assert surface.getchannel("A").histogram()[0] > surface.width * surface.height / 2 + + +def test_every_water_frame_survives_the_shared_gif_palette_exactly(): + mapping, rgb = palette() + for index in range(100): + frame = Image.new("RGBA", (WIDTH, HEIGHT)) + frame.alpha_composite(water_sprite(index / 100), POSITION) + assert indexed(frame, mapping, rgb, 1).convert("RGBA").tobytes() == frame.tobytes() + + +@pytest.mark.parametrize("carrier", ("lincoln", "classic")) +@pytest.mark.parametrize("fps", (5, 10, 20, 25)) +def test_pressure_crest_is_gone_before_its_age_wraps(carrier, fps): + # A crest lives for two seconds. Its last sampled frame must be clear, + # otherwise the next frame visibly jumps from the outer wave to the stem. + last_age = 1 - 1 / (fps * 2) + first, last = _pressure_crest(0, carrier), _pressure_crest(last_age, carrier) + assert first.getbbox() is None + assert last.tobytes() == first.tobytes() + assert _pressure_crest(0.4, carrier).getbbox() is not None + + +@pytest.mark.parametrize("carrier", ("lincoln", "classic")) +def test_pressure_crest_dissolves_gradually_before_the_reset(carrier): + visible = [ + _pressure_crest(age, carrier).getchannel("A").histogram()[255] + for age in (0.4, 0.6, 0.8, 0.9) + ] + assert visible[0] > visible[1] > visible[2] > visible[3] == 0 + assert set(_pressure_crest(0.8, carrier).getchannel("A").tobytes()) == {0, 255} + + +@pytest.mark.parametrize( + "carrier,study", + ( + ("classic", "sprites.py"), + ("lincoln", "lincoln_redline.py"), + ), +) +def test_original_water_recreates_the_approved_carrier_frames(carrier, study): + root = Path(__file__).resolve().parents[1] + spec = importlib.util.spec_from_file_location("original_study", root / "artwork/drafts" / study) + original = importlib.util.module_from_spec(spec) + spec.loader.exec_module(original) + for phase in (0, 0.13, 0.25, 0.5, 0.91): + current = water_sprite(phase, water="original", carrier=carrier).crop((40, 0, 390, 190)) + current.alpha_composite(art.carrier_sprite(phase, carrier=carrier)) + reference = original.carrier_sprite(phase) + if carrier == "classic": + x, y = art.project(*art.carrier_radar_origin(carrier)) + radar = (x - 7, y - 27, x + 9, y - 12) + reference.paste(current.crop(radar), radar[:2]) + assert current.tobytes() == reference.tobytes() + + +@pytest.mark.parametrize("water", ("unknown", None, [], 1)) +def test_invalid_water_is_rejected(water): + with pytest.raises(ValueError, match="water"): + Renderer(demo_calendar(), water=water) + + +def test_cli_rejects_unknown_water_before_collecting(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("invalid water must not start collection") + + monkeypatch.setattr("git_jet.cli.GitHubClient.collect", forbidden) + with pytest.raises(SystemExit) as error: + main(["render", "--username", "octocat", "--water", "unknown"]) + assert error.value.code == 2