Skip to content

Commit 6b5d5ff

Browse files
mustbesimo2claude
andcommitted
fix: hero moves as one coherent camera shot, not a pasted-on spinning centre
The previous render rotated only small circular patches at the centre over a frozen plate, so only the middle appeared to move and it read as composited onto a static background. Replaced it with a coherent floating-camera move: one closed sine/cosine cycle drives a whole-frame zoom-breathe + orbit + sway, so the entire mechanism and its cast shadow move together as a single live beauty shot. Seamless by construction (start==end), re-mastered at 2560x1440. A single 2-D plate still can't reveal new faces of the object — a true turntable needs a 3-D model or an image-to-video generation (documented in README). Motion tests inverted accordingly: the housing must now move WITH the frame (proving coherence) and the centre must not dwarf it (proving it isn't composited). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6ec426c commit 6b5d5ff

7 files changed

Lines changed: 96 additions & 90 deletions

File tree

examples/kern-calibration/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ An original cinematic example website for the `cinematic-scroll` skill.
1010
- Scroll-scrubbed CSS radial-gradient masking: five soft colour blooms open cumulatively over each mechanical system as the scan plane crosses it, then a full-frame wash floods the whole unit in colour.
1111
- Frame-by-frame drift correction using `requestVideoFrameCallback` and circular phase-aware playback-rate convergence—no repeated seeking at the loop boundary.
1212
- A genuinely full-viewport video stage using `object-fit: cover`; no framed media panel or transformed 3D ancestor.
13-
- Generated precision-mechanical hero imagery converted into a seamless 11.667-second, 30 fps, 1080p locked-camera loop. The outer housing stays pixel-stable while the inner carrier, escapement and counter-rotating pinions move at exact integer loop ratios; the coil and emitter pulse without moving the camera.
13+
- Generated precision-mechanical hero imagery converted into a seamless 11.667-second, 30 fps, 1440p loop with a coherent floating-camera move: the whole frame — mechanism and cast shadow together — drifts, breathes and sways as one integrated beauty shot, so no region ever reads as a pasted-on moving patch. (A single 2-D plate cannot reveal new faces of the object; a true turntable would need a 3-D model or an image-to-video generation.)
1414
- Velocity-reactive playback: scrolling accelerates the loop up to ~1.9×, easing back to 1× at rest; the loops pause off-screen and resume in sync.
1515
- Muted inline autoplay plus pointer, touch, wheel, scroll, visibility and page-show recovery hooks.
1616
- Five selectable mechanical systems with live, phase-locked macro video crops and matching readings.
@@ -22,12 +22,12 @@ An original cinematic example website for the `cinematic-scroll` skill.
2222

2323
| Asset | Role | Specification |
2424
|---|---|---|
25-
| `kern-mono.mp4` | Persistent base layer | 1920×1080, 30 fps, 11.667 s, H.264, locked-camera mechanical loop |
26-
| `kern-color.mp4` | Scroll-masked colour layer | 1920×1080, 30 fps, 11.667 s, H.264, locked-camera mechanical loop |
25+
| `kern-mono.mp4` | Persistent base layer | 2560×1440, 30 fps, 11.667 s, H.264, coherent floating-camera loop |
26+
| `kern-color.mp4` | Scroll-masked colour layer | 2560×1440, 30 fps, 11.667 s, H.264, coherent floating-camera loop |
2727
| `kern-calibration-unit.png` | Poster and macro source | Generated precision-mechanical plate |
28-
| `render-mechanism.py` | Deterministic renderer | Rotates isolated internal subassemblies and derives both frame-matched encodes |
28+
| `render-mechanism.py` | Deterministic renderer | Applies one closed sine/cosine camera cycle to the whole plate and derives both frame-matched encodes |
2929

30-
The colour loop is rendered once; the monochrome layer is a desaturated encode of that exact stream. Framing, duration, frame count and mechanism motion therefore match by construction.
30+
The colour loop is rendered once; the monochrome layer is a desaturated encode of that exact stream. Framing, duration, frame count and camera motion therefore match by construction.
3131

3232
## Originality boundary
3333

4.96 MB
Binary file not shown.
4.4 MB
Binary file not shown.
122 KB
Loading
Lines changed: 51 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,72 @@
11
#!/usr/bin/env python3
2-
"""Render the KERN hero as a locked-camera mechanical loop.
2+
"""Render the KERN hero as a coherent, seamless-loop camera move.
33
4-
The outer housing and background remain pixel-stable. Only circular internal
5-
subassemblies rotate/oscillate; the monochrome master is derived from the exact
6-
colour encode so both website layers remain frame-matched.
4+
The whole frame moves as ONE integrated shot — the entire mechanism and its cast
5+
shadow drift, breathe and sway together under a gently floating camera. There are
6+
no isolated composited "moving centre" patches over a frozen plate, so the motion
7+
never reads as pasted on: every pixel belongs to the same live beauty shot.
8+
9+
The camera path is built from a single sine/cosine cycle, so the last frame's
10+
position and velocity exactly match the first — the loop is seamless with no
11+
crossfade. The monochrome master is a desaturated grade of the exact colour
12+
encode, so both website layers stay frame-matched by construction.
13+
14+
Note: a single 2-D still cannot reveal new faces of the object. A true turntable
15+
(the mechanism itself rotating) needs either a 3-D model or an image-to-video
16+
generation — see README. This renderer delivers the honest best from one plate.
717
"""
818
from pathlib import Path
919
from subprocess import Popen, run, PIPE
10-
from math import sin, tau
11-
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
20+
from math import sin, cos, tau, radians
21+
from PIL import Image, ImageEnhance, ImageFilter
1222

1323
HERE = Path(__file__).resolve().parent
1424
SOURCE = HERE / "kern-calibration-unit.png"
1525
COLOR = HERE / "kern-color.mp4"
1626
MONO = HERE / "kern-mono.mp4"
17-
WIDTH, HEIGHT, FPS, FRAMES = 1920, 1080, 30, 350
27+
OW, OH, FPS, FRAMES = 2560, 1440, 30, 350
1828
DURATION = FRAMES / FPS
1929

20-
base = Image.open(SOURCE).convert("RGB").resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS)
21-
base = ImageEnhance.Contrast(base).enhance(1.035)
22-
base = ImageEnhance.Color(base).enhance(1.06)
30+
# Prepare the plate once: grade + sharpen so every resampled frame is crisp and consistent.
31+
src = Image.open(SOURCE).convert("RGB")
32+
src = ImageEnhance.Contrast(src).enhance(1.035)
33+
src = ImageEnhance.Color(src).enhance(1.05)
34+
src = src.filter(ImageFilter.UnsharpMask(radius=2.2, percent=90, threshold=2))
35+
SW, SH = src.size
36+
base_fit = max(OW / SW, OH / SH) # cover the output frame from the plate
37+
OCX, OCY = OW / 2.0, OH / 2.0
38+
SCX, SCY = SW / 2.0, SH / 2.0
2339

24-
# center-x, center-y and radius are normalized to the output frame.
25-
parts = [
26-
# Tourbillon-like inner carrier: one calm revolution per seamless loop.
27-
{"name": "carrier", "x": .505, "y": .515, "r": .112, "cycles": 1.0, "phase": 0},
28-
# Visible pinions counter-rotate at integer loop ratios.
29-
{"name": "upper-pinion", "x": .548, "y": .455, "r": .034, "cycles": -7.0, "phase": 8},
30-
{"name": "lower-pinion", "x": .458, "y": .555, "r": .031, "cycles": 10.0, "phase": -5},
31-
# Central gold escapement receives continuous rotation plus a tick oscillation.
32-
{"name": "escapement", "x": .505, "y": .515, "r": .052, "cycles": 4.0, "phase": 0, "tick": 14},
33-
]
34-
35-
prepared = []
36-
for part in parts:
37-
cx, cy = int(part["x"] * WIDTH), int(part["y"] * HEIGHT)
38-
radius = int(part["r"] * HEIGHT)
39-
pad = int(radius * 1.18)
40-
box = (cx - pad, cy - pad, cx + pad, cy + pad)
41-
patch = base.crop(box)
42-
mask = Image.new("L", patch.size, 0)
43-
d = ImageDraw.Draw(mask)
44-
feather = max(5, int(radius * .10))
45-
d.ellipse((pad-radius+feather, pad-radius+feather, pad+radius-feather, pad+radius-feather), fill=255)
46-
mask = mask.filter(ImageFilter.GaussianBlur(feather))
47-
prepared.append((part, box, patch, mask))
40+
# Floating-camera path — one closed sine cycle → seamless loop (start == end, matching velocity).
41+
ZOOM0, ZOOM_A = 1.09, 0.020 # breathe 1.07 <-> 1.11 (headroom hides translate/rotate)
42+
DX_A = 0.008 * OW # gentle horizontal orbit
43+
DY_A = 0.006 * OH # gentle vertical bob
44+
ROT_A = 1.2 # degrees of sway
4845

4946
cmd = [
5047
"ffmpeg", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
51-
"-s", f"{WIDTH}x{HEIGHT}", "-r", str(FPS), "-i", "-", "-an", "-c:v", "libx264",
52-
"-preset", "slow", "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(COLOR),
48+
"-s", f"{OW}x{OH}", "-r", str(FPS), "-i", "-", "-an",
49+
"-vf", "gradfun=1.0:16,format=yuv420p",
50+
"-c:v", "libx264", "-preset", "slow", "-crf", "19", "-movflags", "+faststart", str(COLOR),
5351
]
5452
encoder = Popen(cmd, stdin=PIPE)
5553
assert encoder.stdin is not None
5654
stream = encoder.stdin
5755

58-
for frame_index in range(FRAMES):
59-
progress = frame_index / FRAMES
60-
frame = base.copy()
61-
for part, box, patch, mask in prepared:
62-
angle = part["phase"] + 360 * part["cycles"] * progress
63-
if part.get("tick"):
64-
angle += 8.5 * sin(tau * part["tick"] * progress)
65-
rotated = patch.rotate(angle, resample=Image.Resampling.BICUBIC, expand=False)
66-
rotated_mask = mask.rotate(angle, resample=Image.Resampling.BICUBIC, expand=False)
67-
frame.paste(rotated, box[:2], rotated_mask)
68-
69-
# Energy is dynamic, not camera movement: coil and emitter breathe in place.
70-
glow = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
71-
gd = ImageDraw.Draw(glow)
72-
coil_pulse = .5 + .5 * sin(tau * 6 * progress)
73-
emit_pulse = .5 + .5 * sin(tau * 8 * progress + 1.2)
74-
for x, y, radius, color, strength in [
75-
(.36, .35, .052, (224, 148, 66), coil_pulse),
76-
(.69, .57, .046, (0, 230, 210), emit_pulse),
77-
]:
78-
cx, cy, rr = int(x*WIDTH), int(y*HEIGHT), int(radius*HEIGHT)
79-
gd.ellipse((cx-rr, cy-rr, cx+rr, cy+rr), fill=(*color, int(16 + strength*24)))
80-
glow = glow.filter(ImageFilter.GaussianBlur(26))
81-
frame = Image.alpha_composite(frame.convert("RGBA"), glow).convert("RGB")
56+
for i in range(FRAMES):
57+
a = tau * i / FRAMES
58+
theta = radians(ROT_A * sin(a))
59+
zoom = ZOOM0 + ZOOM_A * (-cos(a)) # min at loop start, max at half, back — smooth
60+
dx = DX_A * cos(a)
61+
dy = DY_A * sin(a)
62+
k = base_fit * zoom # total plate->frame scale
63+
ct, st = cos(theta) / k, sin(theta) / k
64+
# Inverse affine: output pixel (x,y) samples the plate at (a*x+b*y+c, d*x+e*y+f).
65+
ax, bx = ct, st
66+
dxc, ex = -st, ct
67+
c = SCX - (ax * (OCX + dx) + bx * (OCY + dy))
68+
f = SCY - (dxc * (OCX + dx) + ex * (OCY + dy))
69+
frame = src.transform((OW, OH), Image.AFFINE, (ax, bx, c, dxc, ex, f), resample=Image.BICUBIC)
8270
stream.write(frame.tobytes())
8371

8472
stream.close()
@@ -87,10 +75,10 @@
8775

8876
result = run([
8977
"ffmpeg", "-loglevel", "error", "-y", "-i", str(COLOR),
90-
"-vf", "hue=s=0,eq=contrast=1.12:brightness=-0.015,format=yuv420p",
91-
"-c:v", "libx264", "-preset", "slow", "-crf", "18", "-movflags", "+faststart", "-an", str(MONO),
78+
"-vf", "hue=s=0,eq=contrast=1.06:brightness=-0.012,format=yuv420p",
79+
"-c:v", "libx264", "-preset", "slow", "-crf", "19", "-movflags", "+faststart", "-an", str(MONO),
9280
])
9381
if result.returncode:
9482
raise SystemExit("monochrome encode failed")
9583

96-
print(f"rendered {FRAMES} frames / {DURATION:.3f}s -> {COLOR.name}, {MONO.name}")
84+
print(f"rendered {FRAMES} frames / {DURATION:.3f}s coherent camera move -> {COLOR.name}, {MONO.name}")
Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,49 @@
11
#!/usr/bin/env python3
2+
"""Assert the hero moves as ONE coherent shot, not an isolated moving centre.
3+
4+
The failure this guards against: a frozen plate with only a small central region
5+
composited-and-rotated on top, which reads as pasted on. So we require the outer
6+
housing/background annulus to move perceptibly too (the whole frame drifts under
7+
the camera), and we require the loop to close seamlessly.
8+
"""
29
from pathlib import Path
310
from subprocess import run
411
from tempfile import TemporaryDirectory
512
from PIL import Image, ImageChops, ImageStat, ImageDraw
613

714
VIDEO = Path(__file__).parent / "assets" / "kern-color.mp4"
815

16+
17+
def grab(video, n, tmp):
18+
target = Path(tmp) / f"frame-{n}.png"
19+
result = run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(video),
20+
"-vf", f"select='eq(n,{n})',scale=480:270", "-vsync", "0", "-frames:v", "1", str(target)])
21+
assert result.returncode == 0, "ffmpeg could not decode the hero video"
22+
return Image.open(target).convert("RGB")
23+
24+
925
with TemporaryDirectory() as tmp:
10-
frames = []
11-
for index, second in enumerate((0, 1)):
12-
target = Path(tmp) / f"frame-{index}.png"
13-
result = run(["ffmpeg", "-loglevel", "error", "-y", "-ss", str(second), "-i", str(VIDEO), "-frames:v", "1", "-vf", "scale=480:270", str(target)])
14-
assert result.returncode == 0, "ffmpeg could not decode the hero video"
15-
frames.append(Image.open(target).convert("RGB"))
16-
17-
diff = ImageChops.difference(*frames)
26+
f0, f30, f_last = grab(VIDEO, 0, tmp), grab(VIDEO, 30, tmp), grab(VIDEO, 349, tmp)
27+
28+
diff = ImageChops.difference(f0, f30)
1829
w, h = diff.size
19-
housing = Image.new("L", diff.size, 0)
20-
draw = ImageDraw.Draw(housing)
2130
cx, cy = w * 0.5, h * 0.51
22-
draw.ellipse((cx-h*.43, cy-h*.43, cx+h*.43, cy+h*.43), fill=255)
23-
draw.ellipse((cx-h*.20, cy-h*.20, cx+h*.20, cy+h*.20), fill=0)
24-
mechanism = Image.new("L", diff.size, 0)
25-
ImageDraw.Draw(mechanism).ellipse((cx-h*.18, cy-h*.18, cx+h*.18, cy+h*.18), fill=255)
26-
27-
housing_delta = sum(ImageStat.Stat(diff, housing).mean) / 3
28-
mechanism_delta = sum(ImageStat.Stat(diff, mechanism).mean) / 3
29-
assert housing_delta < 2.0, f"camera/housing is shaking: static annulus delta {housing_delta:.2f}"
30-
assert mechanism_delta > 3.0, f"mechanism is not visibly moving: center delta {mechanism_delta:.2f}"
31-
print(f"PASS stable housing {housing_delta:.2f}; dynamic mechanism {mechanism_delta:.2f}")
31+
outer = Image.new("L", diff.size, 0)
32+
draw = ImageDraw.Draw(outer)
33+
draw.ellipse((cx - h * .47, cy - h * .47, cx + h * .47, cy + h * .47), fill=255)
34+
draw.ellipse((cx - h * .30, cy - h * .30, cx + h * .30, cy + h * .30), fill=0)
35+
center = Image.new("L", diff.size, 0)
36+
ImageDraw.Draw(center).ellipse((cx - h * .16, cy - h * .16, cx + h * .16, cy + h * .16), fill=255)
37+
38+
outer_delta = sum(ImageStat.Stat(diff, outer).mean) / 3
39+
center_delta = sum(ImageStat.Stat(diff, center).mean) / 3
40+
seam_delta = sum(ImageStat.Stat(ImageChops.difference(f0, f_last)).mean) / 3
41+
42+
# The whole frame must move — the housing is NOT allowed to sit frozen while only the centre moves.
43+
assert outer_delta > 3.0, f"only the centre moves — housing/background is static (outer delta {outer_delta:.2f})"
44+
assert center_delta > 3.0, f"mechanism is not visibly moving: centre delta {center_delta:.2f}"
45+
# A pasted-on spinning centre shows up as centre motion wildly exceeding the coherent frame drift.
46+
assert center_delta < outer_delta * 6.0, f"centre motion ({center_delta:.2f}) dwarfs the frame ({outer_delta:.2f}) — looks composited"
47+
# One sine cycle → the last frame should land back on the first.
48+
assert seam_delta < 3.0, f"loop is not seamless: first/last frame delta {seam_delta:.2f}"
49+
print(f"PASS coherent camera move — outer {outer_delta:.2f}, centre {center_delta:.2f}, seam {seam_delta:.2f}")

examples/kern-calibration/test-video-motion.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@
1919

2020
difference = ImageChops.difference(*frames)
2121
mean_delta = sum(ImageStat.Stat(difference).mean) / 3
22-
assert mean_delta >= 0.8, f"mechanical loop has no perceptible frame change: mean delta {mean_delta:.2f} < 0.8"
23-
print(f"PASS perceptible localized video motion: mean frame delta {mean_delta:.2f}")
22+
assert mean_delta >= 2.0, f"camera move has no perceptible frame change: mean delta {mean_delta:.2f} < 2.0"
23+
print(f"PASS perceptible coherent camera motion: mean frame delta {mean_delta:.2f}")

0 commit comments

Comments
 (0)