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**
-
-
+
+
**Classic — original carrier**
-
-
+
+
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