Skip to content

Commit 1b618f6

Browse files
hsyl20claude
andcommitted
Docs: animate the plinth wordmark on the front page
The letters drop onto the studio floor and settle, left to right, with the plinth-shaped I landing last and, being heavier, slower and without a bounce. The camera eases in to the framing of the existing still. plinth-anim.pov reuses plinth-common.inc untouched, so the light rig and the hand-calibrated coral albedo cannot drift from the wordmark and the favicons. The animation stops on a frame identical to the committed plinth-wordmark.png (verified below the render's own noise floor), which is what lets index.md offer the still to readers who have asked for reduced motion without showing them a different image. `make-anim.sh --check` tests that invariant. Two things measured rather than assumed, both written up as notes: - Animated WebP is wrong for this content. The frame is almost all smooth near-white backdrop, and WebP streaks across it; going from q88 to q99 tripled the file to 2.2M and improved the backdrop by 5%. VP9 is visibly cleaner at 288K. - The poster is the animation's first frame. Posting the finished mark meant arriving on the page, seeing the completed wordmark, and then watching it vanish and rebuild. An H.264 fallback covers browsers too old for VP9, so a first-frame poster cannot strand anyone on an empty studio. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ca460ab commit 1b618f6

9 files changed

Lines changed: 444 additions & 3 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Jekyll build output
22
_site/
33

4+
# Intermediates from _logo/make-anim.sh -- the encoded clip is committed, the
5+
# 80 rendered frames and the low-res timing preview are not
6+
_logo/frames/
7+
_logo/draft.webm
8+
49
# Local preview (bundler); GitHub Pages builds the site itself
510
.bundle/
611
vendor/

_logo/README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ copy any of it into the built site.
55

66
| File | What |
77
|---|---|
8-
| `plinth-common.inc` | palette, geometry, materials, light rig -- shared, so the two marks cannot drift apart |
8+
| `plinth-common.inc` | palette, geometry, materials, light rig -- shared, so the marks cannot drift apart |
99
| `plinth-logo.pov` | the PLINTH wordmark |
1010
| `plinth-icon.pov` | the plinth alone on a brand tile, for the favicons |
11+
| `plinth-anim.pov` | the wordmark assembling itself: the letters drop in and the plinth lands last |
12+
| `anim.ini` | frame count, clock range and render size for the animation |
1113
| `make-icons.py` | rounds the tile corners and cuts the favicon sizes |
14+
| `make-anim.sh` | renders the animation and encodes the clip, poster and fallback |
1215

1316
Rendering, from this directory:
1417

@@ -19,8 +22,31 @@ povray +Iplinth-logo.pov +Oplinth-logo.png +W1800 +H700 +A0.25 +AM2 +R3 +Q11
1922
# favicons -> assets/
2023
povray +Iplinth-icon.pov +Oplinth-icon.png +W1024 +H1024 +A0.2 +AM2 +R4 +Q11
2124
./make-icons.py plinth-icon.png
25+
26+
# animated wordmark -> assets/images/ (~5 min, 80 frames)
27+
./make-anim.sh
28+
./make-anim.sh --draft # quarter size, no AA, ~30s, for checking the timing
29+
./make-anim.sh --encode # re-encode existing frames, ~1s, for tuning the codec
30+
./make-anim.sh --check # verify the last frame really is the still
2231
```
2332

33+
That writes three files: `plinth-wordmark.webm` (VP9, what nearly everyone
34+
gets), `plinth-wordmark.mp4` (H.264, for browsers too old for VP9) and
35+
`plinth-wordmark-poster.webp`.
36+
37+
The animation plays once and stops, and its last frame is the committed still.
38+
That is what lets `index.md` swap the clip for `plinth-wordmark.png` when a
39+
reader has asked for reduced motion -- they end up looking at the same image
40+
everyone else does. Keep that property if you change the timing: `--check`
41+
tests it, and `Note [The last frame is the still]` in `plinth-anim.pov`
42+
explains it.
43+
44+
Two encoding decisions are already made and worth not re-litigating, both
45+
written up in `make-anim.sh`: `Note [VP9, not animated WebP]` (animated WebP
46+
streaks badly across this backdrop and raising its quality does not fix it) and
47+
`Note [The poster is the FIRST frame]` (posting the finished mark makes the
48+
page show the logo, then rebuild it).
49+
2450
`Declare=Theme=1` renders either scene white-on-charcoal instead of
2551
coral-on-white. The site is light-skinned, so only the light variants are
2652
committed.

_logo/anim.ini

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
; POV-Ray animation settings for plinth-anim.pov. Driven by make-anim.sh,
2+
; which overrides Width/Height/Final_Frame for its --draft mode.
3+
;
4+
; 80 frames at 30fps is 2.67s. Render size is 1.5x the delivered 1200px width,
5+
; as with the still: POV-Ray's antialiasing is good but downsampling is better,
6+
; and the letters have a lot of near-horizontal bevel edges to keep clean.
7+
8+
Input_File_Name = plinth-anim.pov
9+
Output_File_Name = frames/f.png
10+
Output_File_Type = N
11+
12+
Initial_Frame = 1
13+
Final_Frame = 80
14+
Initial_Clock = 0
15+
Final_Clock = 1
16+
Cyclic_Animation = off
17+
18+
Width = 1800
19+
Height = 700
20+
21+
Quality = 11
22+
Antialias = on
23+
Antialias_Threshold = 0.25
24+
Antialias_Depth = 3
25+
Sampling_Method = 2
26+
27+
Display = off
28+
Pause_When_Done = off
29+
Verbose = off

_logo/make-anim.sh

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env bash
2+
# Render the animated wordmark and assemble it for the site.
3+
#
4+
# ./make-anim.sh render 80 frames at 1800x700, encode the clip
5+
# ./make-anim.sh --draft quarter size, no antialiasing, ~30s, for timing
6+
# ./make-anim.sh --encode re-encode the frames already in frames/, no render
7+
# ./make-anim.sh --check verify the tail is still and matches the still PNG
8+
#
9+
# Output: ../assets/images/plinth-wordmark.webm, a VP9 clip that plays once and
10+
# stops on a frame identical to the committed plinth-wordmark.png. See
11+
# Note [The last frame is the still] in plinth-anim.pov for why that matters,
12+
# and index.md for how the two are served together.
13+
set -euo pipefail
14+
15+
cd "$(dirname "$0")"
16+
17+
FRAMES=frames
18+
STILL=../assets/images/plinth-wordmark.png
19+
OUT=../assets/images/plinth-wordmark.webm
20+
MP4=../assets/images/plinth-wordmark.mp4
21+
POSTER=../assets/images/plinth-wordmark-poster.webp
22+
WIDE=1200 # delivered width, same as the still
23+
FPS=30
24+
LAST=80 # keep in step with Final_Frame in anim.ini
25+
26+
# Note [Downsampling does double duty]
27+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
28+
# Rendering at 1800 and resampling to 1200 sharpens the bevel edges, as it does
29+
# for the still -- but in an animation it also halves the frame-to-frame noise
30+
# from the jittered area lights and the radiosity sampling, which is what would
31+
# otherwise show up as a faint shimmer on the floor. Measured on two frames that
32+
# should be identical: 0.13% mean difference before resampling, well under half
33+
# of that after. Not worth touching the light rig for (and Note [Hitting the
34+
# brand colour] in plinth-common.inc says not to).
35+
mode=${1:-}
36+
37+
case "$mode" in
38+
--check)
39+
# 1. Is the tail actually static? Consecutive frames near the end must
40+
# differ only by render noise, not by anything still in motion.
41+
# 2. Does the last frame match the committed still? Same tolerance: both
42+
# numbers are the noise floor, so a real mismatch stands out.
43+
[[ -f $FRAMES/f$LAST.png ]] || { echo "no frames: run ./make-anim.sh first" >&2; exit 1; }
44+
# The tolerance is the render's own noise floor. Two frames that are meant
45+
# to be identical still differ by about 0.4%, because the area lights are
46+
# jittered and the radiosity is sampled afresh each frame; anything actually
47+
# in motion is far above that. Note the "|| true" on every compare: it exits
48+
# 1 whenever the images differ at all, which under set -e would abort the
49+
# check rather than report it.
50+
tol=0.01
51+
for pair in "$((LAST - 4)) $((LAST - 2))" "$((LAST - 2)) $LAST"; do
52+
read -r a b <<<"$pair"
53+
d=$( { magick compare -metric RMSE "$FRAMES/f$a.png" "$FRAMES/f$b.png" null: 2>&1 || true; } |
54+
sed 's/.*(\(.*\))/\1/')
55+
echo "frame $a vs $b: $d"
56+
awk -v d="$d" -v t="$tol" 'BEGIN { exit !(d > t) }' &&
57+
{ echo " FAIL: still moving at frame $b" >&2; exit 1; }
58+
done
59+
# -resize before both filenames applies to both as they are read, so this
60+
# compares the 1800-wide frame against the 1200-wide still on equal terms.
61+
d=$( { magick compare -metric RMSE -resize ${WIDE}x "$FRAMES/f$LAST.png" "$STILL" null: 2>&1 || true; } |
62+
sed 's/.*(\(.*\))/\1/')
63+
echo "frame $LAST vs $(basename $STILL): $d"
64+
awk -v d="$d" -v t="$tol" 'BEGIN { exit !(d > t) }' &&
65+
{ echo " FAIL: last frame is not the committed still" >&2; exit 1; }
66+
echo "ok"
67+
exit 0
68+
;;
69+
--draft)
70+
render=(povray anim.ini +W450 +H175 -A +Q5)
71+
OUT=draft.webm
72+
WIDE=450
73+
;;
74+
--encode)
75+
# Retuning the encoder is the thing you do repeatedly; re-rendering 80
76+
# frames to do it is five minutes for nothing.
77+
[[ -f $FRAMES/f$LAST.png ]] || { echo "no frames: run ./make-anim.sh first" >&2; exit 1; }
78+
render=(true)
79+
;;
80+
"")
81+
render=(povray anim.ini)
82+
;;
83+
*)
84+
sed -n '2,9p' "$0" >&2
85+
exit 1
86+
;;
87+
esac
88+
89+
if [[ $mode != --encode ]]; then
90+
mkdir -p "$FRAMES"
91+
rm -f "$FRAMES"/f*.png
92+
fi
93+
"${render[@]}"
94+
95+
# Note [VP9, not animated WebP]
96+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
97+
# This clip is 90% smooth near-white backdrop, which is the worst case for
98+
# WebP: it lays visible horizontal streaks across the gradient, and the quality
99+
# knob does not buy them off. Measured on the backdrop alone, against the
100+
# unencoded frame -- q88 0.0080, q99 0.0076, for 848K and 2.2M respectively.
101+
# VP9 reaches the same number at crf 20 in 200K and is visibly cleaner by
102+
# crf 16, because it has a deblocking filter and rate control built for exactly
103+
# this. Lossless WebP would do it in 12M.
104+
#
105+
# Odd heights are fine for VP9, so the frame is 1200x467 -- the same size as
106+
# the still, which is the poster.
107+
#
108+
# No -loop: a <video> stops on its last frame, which is the whole design (see
109+
# Note [The last frame is the still] in plinth-anim.pov). That frame is then on
110+
# screen indefinitely, so -force_key_frames codes it as a keyframe rather than
111+
# the tail of a 79-frame delta chain. Costs about 20K.
112+
ffmpeg -nostdin -loglevel error -y \
113+
-framerate "$FPS" -start_number 1 -i "$FRAMES/f%02d.png" \
114+
-vf "scale=$WIDE:-1:flags=lanczos" \
115+
-c:v libvpx-vp9 -crf 16 -b:v 0 -pix_fmt yuv420p \
116+
-force_key_frames "expr:eq(n,$((LAST - 1)))" \
117+
-deadline good -cpu-used 1 -row-mt 1 -an \
118+
"$OUT"
119+
120+
printf '%s %s %s frames @ %sfps\n' \
121+
"$OUT" "$(du -h "$OUT" | cut -f1)" "$LAST" "$FPS"
122+
123+
if [[ $mode == --draft ]]; then
124+
exit 0
125+
fi
126+
127+
# Note [The poster is the FIRST frame]
128+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
129+
# Not the last one, which is the obvious choice and is wrong: the poster is what
130+
# the browser shows until playback starts, so posting the finished wordmark
131+
# means arriving on the page, seeing the completed mark, and then watching it
132+
# vanish and rebuild itself. Frame 1 makes the handover invisible.
133+
#
134+
# The cost is that a browser that cannot play either source is left on an empty
135+
# studio, which is why the H.264 fallback below exists -- with it, "no playable
136+
# source" stops being a case that happens. Cheap at 4K: frame 1 is nearly flat,
137+
# and the streaking that ruled WebP out for the animation (see Note [VP9, not
138+
# animated WebP]) does not arise on one still image at this bitrate.
139+
ffmpeg -nostdin -loglevel error -y -i "$FRAMES/f01.png" \
140+
-vf "scale=$WIDE:-1:flags=lanczos" \
141+
-c:v libwebp -lossless 0 -quality 90 -compression_level 6 \
142+
"$POSTER"
143+
144+
# H.264 for anything too old for VP9 -- pre-2021 Safari, mainly. Modern browsers
145+
# take the WebM listed first in index.md and never fetch this. 466 not 467:
146+
# H.264 requires even dimensions, and one pixel of letterbox is not visible.
147+
ffmpeg -nostdin -loglevel error -y \
148+
-framerate "$FPS" -start_number 1 -i "$FRAMES/f%02d.png" \
149+
-vf "scale=$WIDE:466:flags=lanczos" \
150+
-c:v libx264 -crf 19 -preset slow -pix_fmt yuv420p \
151+
-movflags +faststart -an \
152+
"$MP4"
153+
154+
printf '%s %s\n%s %s\n' \
155+
"$POSTER" "$(du -h "$POSTER" | cut -f1)" \
156+
"$MP4" "$(du -h "$MP4" | cut -f1)"

0 commit comments

Comments
 (0)