Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ templates/direction-approved.md the gate file the user signs off on
scripts/verify-deck.py Playwright dual-viewport verifier
scripts/check_launch_motion.py normal/reduced-motion launch-page gate
scripts/render_readme_hero.py rebuilds the hero film above
scripts/render_site_filmstrip.py rebuilds the launch-page filmstrip from that film
scripts/render_site_previews.py stages the rail/pinned screenshots on the dark set
scripts/render_site_social_card.py rebuilds the 1280x640 share card
examples/ the dogfooded deck + its three previews
```

Expand Down
Binary file added assets/site-previews/deck-05.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/site-previews/dir-01.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/site-previews/dir-02.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/site-previews/dir-03.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions docs/hero-film-storyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,24 @@ The verifier screenshots the film is cut from are generated, not committed; the
script runs `scripts/verify-deck.py` first when they are missing. The stage is
deterministic, so a fresh clone rebuilds the same film without random particle
or grain flicker.

## Launch-page assets derived from the master film

The live launch page (`site/`) reuses this film's full arc instead of keeping
its own cut:

```bash
# 72-frame WebP sprite for scroll scrubbing + first/last fallback JPEGs
# (requires ffmpeg; samples evenly across all five shots, so the site can
# silently drift to a two-clip recut no more)
python3 scripts/render_site_filmstrip.py

# rail + pinned screenshots, floated as lit screens on the matching dark set
python3 scripts/render_site_previews.py

# 1280x640 OG/Twitter card with the launch headline and the chosen direction
python3 scripts/render_site_social_card.py # macOS (system SF Pro)
```

Preview sources live in `assets/site-previews/`; all three outputs land in
`site/assets/`, are committed, and are never rebuilt in CI.
87 changes: 87 additions & 0 deletions scripts/render_site_filmstrip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Rebuild the launch-page hero filmstrip from the README master film.

The website hero is scroll-scrubbed from a tiled sprite (see
site/index.html). The source of truth is the full five-shot README film
assets/hero-concept-gate.mp4 (storyboard: docs/hero-film-storyboard.md);
this script samples FRAMES evenly across every shot, so the website can no
longer silently drift back to a two-clip recut.

Outputs (committed; CI never rebuilds them):
site/assets/hero-filmstrip.webp COLS x ROWS tiled sprite
site/assets/hero_first.jpg still shown before the sprite decodes
site/assets/hero_last.jpg sprite decode / no-canvas fallback

Requirements: ffmpeg on PATH, Pillow.
"""
from __future__ import annotations

import subprocess
import tempfile
from pathlib import Path

from PIL import Image

ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "assets" / "hero-concept-gate.mp4"
SITE_ASSETS = ROOT / "site" / "assets"

FRAME_W, FRAME_H = 1280, 720
FRAMES = 72
COLS = 6
WEBP_QUALITY = 72
JPEG_QUALITY = 86


def ffprobe_frame_count() -> int:
out = subprocess.run(
["ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0",
"-show_entries", "stream=nb_read_frames", "-of",
"csv=p=0", str(SOURCE)],
check=True, capture_output=True, text=True,
).stdout.strip()
return int(out)


def sample(total: int) -> list[int]:
return [round(i * (total - 1) / (FRAMES - 1)) for i in range(FRAMES)]


def main() -> None:
if not SOURCE.exists():
raise SystemExit(f"missing master film: {SOURCE}")
total = ffprobe_frame_count()
indices = sample(total)
expression = "+".join(f"eq(n\\,{i})" for i in indices)

with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-i", str(SOURCE),
"-vf", f"select='{expression}'", "-vsync", "0",
str(tmp_path / "f%03d.png")],
check=True,
)
frames = sorted(tmp_path.glob("f*.png"))
if len(frames) != FRAMES:
raise SystemExit(f"expected {FRAMES} frames, got {len(frames)}")
images = [Image.open(f).convert("RGB") for f in frames]

rows = (FRAMES + COLS - 1) // COLS
strip = Image.new("RGB", (COLS * FRAME_W, rows * FRAME_H), (0, 0, 0))
for i, im in enumerate(images):
strip.paste(im, ((i % COLS) * FRAME_W, (i // COLS) * FRAME_H))
strip.save(SITE_ASSETS / "hero-filmstrip.webp",
quality=WEBP_QUALITY, method=6)

images[0].save(SITE_ASSETS / "hero_first.jpg",
quality=JPEG_QUALITY, optimize=True)
images[-1].save(SITE_ASSETS / "hero_last.jpg",
quality=JPEG_QUALITY, optimize=True)

print(f"wrote {FRAMES} sampled frames from {total} "
f"({COLS}x{rows}) to site/assets/")


if __name__ == "__main__":
main()
61 changes: 61 additions & 0 deletions scripts/render_site_previews.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Stage raw slide screenshots for the launch page.

The rail + pinned screenshots used to be raw slide exports dropped straight
into a dark page: near-blank white canvases and one dark red blob, three
different worlds. The previews now keep every slide pixel intact (a preview
must stay the real slide) but float as lit screens on the cinematic plate
shared with the hero — see sitestage.py.

Source slides (committed, provenance = the concept-gate + built deck):
assets/site-previews/{dir-01,dir-02,dir-03,deck-05}.png
Outputs (overwritten every run, idempotent):
site/assets/{dir-01,dir-02,dir-03,deck-05}.png

Pure Pillow, deterministic, no network. Run: python3 scripts/render_site_previews.py
"""
from __future__ import annotations

from pathlib import Path

from PIL import Image

from sitestage import add_glow, AMBER, CINNABAR, COOL, plate, stage_screen

ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = ROOT / "assets" / "site-previews"
OUT_DIR = ROOT / "site" / "assets"
W, H = 1280, 720

CARD_W = 1056
CARD_H = round(CARD_W * 720 / 1280) # 594
CARD_X = (W - CARD_W) // 2
CARD_Y = 66

JOBS = [
("dir-01.png", {"chosen": False, "dark_slide": False}),
("dir-02.png", {"chosen": False, "dark_slide": True}),
("dir-03.png", {"chosen": True, "dark_slide": False}),
("deck-05.png", {"chosen": False, "dark_slide": False}),
]


def build(slide: Image.Image, *, chosen: bool, dark_slide: bool) -> Image.Image:
base = plate(W, H)
add_glow(base, AMBER, 0.16, 0.0, 0.42, 0.12)
add_glow(base, CINNABAR, 0.92, -0.08, 0.46, 0.24 if chosen else 0.12)
add_glow(base, COOL, 0.5, 1.14, 0.52, 0.10)
stage_screen(base, slide, CARD_X, CARD_Y, CARD_W, CARD_H,
chosen=chosen, dark_slide=dark_slide)
return base.convert("RGB")


def main() -> None:
for name, flags in JOBS:
slide = Image.open(SRC_DIR / name)
build(slide, **flags).save(OUT_DIR / name, optimize=True)
print("staged", name)


if __name__ == "__main__":
main()
95 changes: 95 additions & 0 deletions scripts/render_site_social_card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Rebuild the 1280x640 OG/Twitter card on the launch-page stage.

The previous card was a light contact sheet that belonged to the old pale
site. This keeps the exact launch-page copy ("Clear ideas / before pixels.")
and shows the chosen direction floating on the same cinematic plate, so a
shared link already feels like the page behind it.

Source: assets/site-previews/dir-03.png (the chosen concept-gate direction).
Output: site/assets/social-card.png (idempotent overwrite).

Pure Pillow using macOS system SF Pro; run on macOS.
"""
from __future__ import annotations

from pathlib import Path

from PIL import Image, ImageDraw, ImageFont

from sitestage import add_glow, AMBER, CINNABAR, COOL, INK, MUTE, plate, stage_screen

ROOT = Path(__file__).resolve().parents[1]
SF = "/System/Library/Fonts/SFNS.ttf"
SFMONO = "/System/Library/Fonts/SFNSMono.ttf"
W, H = 1280, 640


def sf(size: int, weight: str) -> ImageFont.FreeTypeFont:
f = ImageFont.truetype(SF, size=size)
try:
f.set_variation_by_name(weight)
except OSError:
pass
return f


def tracked(draw: ImageDraw.ImageDraw, xy, text, font, fill, tracking=0):
x, y = xy
for ch in text:
draw.text((x, y), ch, font=font, fill=fill)
x += draw.textlength(ch, font=font) + tracking


def wrap(draw, text, font, width):
words, lines, cur = text.split(), [], ""
for word in words:
trial = (cur + " " + word).strip()
if draw.textlength(trial, font=font) <= width:
cur = trial
else:
lines.append(cur)
cur = word
if cur:
lines.append(cur)
return lines


def main() -> None:
base = plate(W, H)
add_glow(base, AMBER, 0.10, -0.12, 0.46, 0.13)
add_glow(base, CINNABAR, 0.92, -0.10, 0.5, 0.16)
add_glow(base, COOL, 0.42, 1.18, 0.55, 0.09)

# chosen direction as the floating screen (right two fifths)
slide = Image.open(ROOT / "assets" / "site-previews" / "dir-03.png")
card_w = 596
card_h = round(card_w * 720 / 1280)
stage_screen(base, slide, W - card_w - 84, (H - card_h) // 2 - 6,
card_w, card_h, chosen=True, reflection=False)

d = ImageDraw.Draw(base)
x = 84
tracked(d, (x, 132), "PREFLIGHT DECKS", ImageFont.truetype(SFMONO, 18),
AMBER + (255,), tracking=3)

title = sf(74, "Bold")
d.text((x - 3, 178), "Clear ideas", font=title, fill=INK + (255,))
d.text((x - 3, 262), "before pixels.", font=title, fill=CINNABAR + (255,))

sub = sf(25, "Regular")
lines = wrap(d,
"Three directions, chosen on pixels — then verified on a "
"projector and a phone before delivery.", sub, 500)
y = 372
for line in lines:
d.text((x, y), line, font=sub, fill=MUTE + (255,))
y += 36

base.convert("RGB").save(ROOT / "site" / "assets" / "social-card.png",
optimize=True)
print("staged social-card.png")


if __name__ == "__main__":
main()
99 changes: 99 additions & 0 deletions scripts/sitestage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Shared cinematic-stage compositing for launch-page raster assets.

Used by render_site_previews.py and render_site_social_card.py so every
raster on the launch page speaks one lighting language: near-black gradient
plate, amber/cinnabar/cool ambient glows, floating rounded screens with
hairline bezels, floor shadow and reflection. Pure Pillow, deterministic.
"""
from __future__ import annotations

from PIL import Image, ImageChops, ImageDraw, ImageFilter

CINNABAR = (232, 72, 44)
AMBER = (226, 178, 120)
COOL = (226, 238, 255)
INK = (236, 238, 244)
MUTE = (150, 156, 168)


def plate(w: int, h: int, *, top=(11, 14, 22), bottom=(4, 5, 9)) -> Image.Image:
im = Image.new("RGBA", (w, h), top + (255,))
d = ImageDraw.Draw(im)
for y in range(h):
t = y / (h - 1)
row = tuple(round(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
d.line((0, y, w, y), fill=row + (255,))
return im


def add_glow(base: Image.Image, color: tuple[int, int, int], fx: float, fy: float,
fr: float, peak: float) -> None:
w, h = base.size
diameter = int(fr * 2 * w)
glow = Image.new("RGBA", (diameter, diameter), color + (0,))
mask = Image.new("L", (diameter, diameter), 0)
md = ImageDraw.Draw(mask)
for i in range(30, 0, -1):
t = i / 30
a = int(255 * peak * (1 - t) ** 1.5)
r = diameter / 2 * t
md.ellipse((diameter / 2 - r, diameter / 2 - r,
diameter / 2 + r, diameter / 2 + r), fill=a)
mask = mask.filter(ImageFilter.GaussianBlur(diameter / 12))
glow.putalpha(mask)
base.alpha_composite(glow, (int(fx * w - diameter / 2),
int(fy * h - diameter / 2)))


def rounded_mask(w: int, h: int, radius: int) -> Image.Image:
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, w - 1, h - 1),
radius=radius, fill=255)
return mask


def stage_screen(base: Image.Image, slide: Image.Image, x: int, y: int,
card_w: int, card_h: int, *, radius: int = 12,
chosen: bool = False, dark_slide: bool = False,
reflection: bool = True) -> None:
"""Composite a slide screenshot as a lit floating screen in place."""
w, h = base.size

if chosen:
halo = Image.new("RGBA", (w, h), (0, 0, 0, 0))
ImageDraw.Draw(halo).rounded_rectangle(
(x - 8, y - 8, x + card_w + 8, y + card_h + 8),
radius=radius + 8, fill=CINNABAR + (95,))
base.alpha_composite(halo.filter(ImageFilter.GaussianBlur(36)))

shadow = Image.new("RGBA", (w, h), (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle(
(x + 8, y + 34, x + card_w - 8, y + card_h + 58),
radius=radius, fill=(0, 0, 0, 150))
base.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(40)))

card = slide.convert("RGBA").resize((card_w, card_h), Image.LANCZOS)
card.putalpha(rounded_mask(card_w, card_h, radius))

if reflection:
refl_h = min(116, card_h // 5)
refl = card.crop((0, card_h - refl_h, card_w, card_h)).transpose(
Image.Transpose.FLIP_TOP_BOTTOM)
fade = Image.new("L", (card_w, refl_h), 0)
fp = fade.load()
for yy in range(refl_h):
a = int(255 * (1 - yy / refl_h) ** 1.6)
for xx in range(card_w):
fp[xx, yy] = a
refl.putalpha(ImageChops.multiply(refl.getchannel("A"), fade))
base.alpha_composite(refl, (x, y + card_h - 2))

base.alpha_composite(card, (x, y))

edge = ImageDraw.Draw(base)
if chosen:
outline, line_w = CINNABAR + (235,), 2
else:
outline, line_w = (255, 255, 255, 78 if dark_slide else 120), 1
edge.rounded_rectangle((x, y, x + card_w - 1, y + card_h - 1),
radius=radius, outline=outline, width=line_w)
Binary file modified site/assets/deck-05.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/assets/dir-01.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/assets/dir-02.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/assets/dir-03.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed site/assets/hero-filmstrip.jpg
Binary file not shown.
Binary file added site/assets/hero-filmstrip.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/assets/hero_first.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/assets/hero_last.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/assets/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,10 @@ <h2>Ship decks that survive the room.</h2>
const FRAME_W = 1280;
const FRAME_H = 720;
const COLS = 6;
const FRAMES = 36;
const FRAMES = 72;
let lastFrame = -1;
const filmstrip = new Image();
filmstrip.src = 'assets/hero-filmstrip.jpg';
filmstrip.src = 'assets/hero-filmstrip.webp';
const drawFrame = (progress) => {
const index = Math.min(FRAMES - 1, Math.floor(progress * FRAMES));
if (index === lastFrame || !filmstrip.complete || !filmstrip.naturalWidth) return;
Expand Down
Loading