#!/usr/bin/env python3
"""Generate the ambient-loop recreation as a self-contained animated SVG.
Geometry + motion recreated from motiscope's analysis of ambient_loop.mov:
grid 5x3 tiles of 267px at offset (18,9) -> normalised to 200px tiles
beat 0.75s (measured: burst peaks 0.317 / 1.067 / 1.817 on three tiles)
loop 4 beats = 3.0s
easing strong ease-in-out per tick (velocity bursts, not constant velocity)
stagger ~41ms top-to-bottom (motiscope stagger hint)
Palette is deliberately NOT the source palette; shapes and motion are.
"""
import math, sys
T = 200 # tile size
BEAT = 0.75
LOOP = 4 * BEAT # 3.0s
ROT = 2 * BEAT # 1.5s (a full eased turn / two conveyor ticks)
# --- new palette (source colour -> ours) --------------------------------------
PAPER = "#FBF8F1" # #FCFCFC white
TEAL = "#17A2A2" # #295EEF blue
PLUM = "#37244F" # #2F386F navy
SAGE = "#BFDCC6" # #E6C0E2 lilac
SAND = "#FBE3B8" # #FBEDAE cream
AMBER = "#F5B335" # #FBD346 yellow
CORAL = "#F2584B" # #F946A8 pink
def pt(cx, cy, r, a):
return (cx + r * math.cos(math.radians(a)), cy - r * math.sin(math.radians(a)))
def sector(cx, cy, r, a0, a1):
"""Pie sector, CCW from a0 to a1 (degrees, 0 = east)."""
da = (a1 - a0) % 360
large = 1 if da > 180 else 0
x0, y0 = pt(cx, cy, r, a0)
x1, y1 = pt(cx, cy, r, a1)
return (f"M{cx:.1f},{cy:.1f} L{x0:.1f},{y0:.1f} "
f"A{r},{r} 0 {large} 0 {x1:.1f},{y1:.1f} Z")
def tri(pts, fill, cls="", style=""):
p = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
return f''
def A(cls, origin=None, delay=None):
"""class + transform-origin (absolute user units) + own delay as --d.
The delay is a custom property, not `animation-delay`, so the freeze harness
can offset every element by a shared --t without destroying per-element phase.
"""
s = f' class="{cls}"'
st = []
if origin:
st.append(f"transform-origin:{origin[0]:.1f}px {origin[1]:.1f}px")
if delay:
st.append(f"--d:{delay}")
if st:
s += f' style="{";".join(st)}"'
return s
tiles = []
def tile(r, c, body):
ox, oy = c * T, r * T
stagger = f"{r * 0.041:.3f}s"
tiles.append(f'''
{body(ox, oy)}
''')
# ---------------------------------------------------------------- row 0
def t00(ox, oy): # target: rocking square + ripple
h = T / 2
rings = ""
cols = [PLUM, SAND, PLUM, SAND]
for i, col in enumerate(cols): # d0 = backmost = most elapsed
d = -(3 - i) * BEAT
rings += (f'\n ')
return f'''
{rings}
'''
def t01(ox, oy): # rotating half-disc in a circle
return f'''
{tri([(ox,oy),(ox+88,oy),(ox,oy+88)], AMBER)}
{tri([(ox,oy+T),(ox+88,oy+T),(ox,oy+T-88)], AMBER)}
{tri([(ox+T,oy+T),(ox+T-88,oy+T),(ox+T,oy+T-88)], AMBER)}
'''
def t02(ox, oy): # L collapses, square blooms
return f'''
'''
def t03(ox, oy): # rotating pac-man
return f'''
'''
def t04(ox, oy): # triangle conveyor (scrolls up)
body = ""
for k in range(-2, 4):
y = oy + k * 100
col = AMBER if k % 2 == 0 else CORAL
body += "\n " + tri([(ox, y+100), (ox+T, y+100), (ox+100, y)], col)
return f'''
{body}
'''
# ---------------------------------------------------------------- row 1
def t10(ox, oy): # rotating half-plane
return f'''
'''
def t11(ox, oy): # rocking square + circle
return f'''
'''
def t12(ox, oy): # dome conveyor (scrolls down)
body = ""
for k in range(-2, 4):
y = oy + k * 100
col = PLUM if k % 2 == 0 else TEAL
body += f'\n '
return f'''
{body}
'''
def t13(ox, oy): # rotating pie wedge + corners
return f'''
'''
def t14(ox, oy): # scaling quarter-disc
return f'''
'''
# ---------------------------------------------------------------- row 2
def t20(ox, oy): # circle + rotating half + ring
return f'''
{tri([(ox,oy+T),(ox+90,oy+T),(ox,oy+T-90)], TEAL)}
'''
def t21(ox, oy): # four pulsing triangles
return f'''
{tri([(ox,oy),(ox+T,oy),(ox+100,oy+100)], SAND, A("pulse-b", (ox+100, oy)))}
{tri([(ox,oy+T),(ox+T,oy+T),(ox+100,oy+100)], CORAL, A("pulse-b", (ox+100, oy+T)))}
{tri([(ox,oy),(ox,oy+T),(ox+100,oy+100)], TEAL, A("pulse-a", (ox, oy+100)))}
{tri([(ox+T,oy),(ox+T,oy+T),(ox+100,oy+100)], PLUM, A("pulse-a", (ox+T, oy+100)))}'''
def t22(ox, oy): # expanding ripple rings
cols = [TEAL, PAPER, AMBER, PLUM]
rings = ""
for i, col in enumerate(cols):
d = -(3 - i) * BEAT
rings += (f'\n ')
return f''' {rings}'''
def t23(ox, oy): # four corner discs -> star
return f'''
'''
def t24(ox, oy): # rocking square
return f'''
'''
for (r, c, fn) in [(0,0,t00),(0,1,t01),(0,2,t02),(0,3,t03),(0,4,t04),
(1,0,t10),(1,1,t11),(1,2,t12),(1,3,t13),(1,4,t14),
(2,0,t20),(2,1,t21),(2,2,t22),(2,3,t23),(2,4,t24)]:
tile(r, c, fn)
clips = "\n".join(
f' '
for r in range(3) for c in range(5))
EASE = "cubic-bezier(.83,0,.17,1)" # peak/mean angular speed ~3.6x -> a sharp snap
SOFT = "cubic-bezier(.45,0,.55,1)"
CSS = f"""
* {{ transform-box: view-box; }}
.spin-cw {{ animation: spin {ROT}s {EASE} infinite; }}
.spin-cw-half {{ animation: spinh {LOOP}s {EASE} infinite; }}
.spin-ccw-half {{ animation: spinhr {LOOP}s {EASE} infinite; }}
.tick-cw {{ animation: tick {LOOP}s {EASE} infinite; }}
.conv-up {{ animation: convup {ROT}s {EASE} infinite; }}
.conv-down {{ animation: convdn {ROT}s {EASE} infinite; }}
.ring {{ animation: grow {LOOP}s {EASE} infinite; }}
.bg4 {{ animation: bg4 {LOOP}s step-end infinite; }}
.bg2 {{ animation: bg2 {LOOP}s step-end infinite; }}
.pulse-a {{ animation: pulsea {ROT}s {EASE} infinite; }}
.pulse-b {{ animation: pulseb {ROT}s {EASE} infinite; }}
.shrinkY {{ animation: shrinky {LOOP}s {EASE} infinite; }}
.shrinkX {{ animation: shrinkx {LOOP}s {EASE} infinite; }}
.bloom {{ animation: bloom {LOOP}s {EASE} infinite; }}
.rock {{ animation: rock {LOOP}s {SOFT} infinite; }}
.rock-b {{ animation: rockb {LOOP}s {SOFT} infinite; }}
.rock-c {{ animation: rockc {LOOP}s {SOFT} infinite; }}
/* every animated element: own phase + measured ~41ms row stagger - freeze offset.
fill-mode: both is load-bearing -- a positive stagger delay would otherwise show
the element un-transformed (a ripple ring at full radius) until its turn came. */
[class] {{ animation-delay: calc(var(--d, 0s) + var(--stag, 0s) - var(--t, 0s));
animation-fill-mode: both; }}
@keyframes spin {{ to {{ transform: rotate(-360deg); }} }}
@keyframes spinh {{ 0%{{transform:rotate(0)}} 50%{{transform:rotate(-180deg)}}
100%{{transform:rotate(-360deg)}} }}
@keyframes spinhr {{ 0%{{transform:rotate(0)}} 50%{{transform:rotate(180deg)}}
100%{{transform:rotate(360deg)}} }}
@keyframes tick {{ 0%{{transform:rotate(0)}} 25%{{transform:rotate(-90deg)}}
50%{{transform:rotate(-180deg)}} 75%{{transform:rotate(-270deg)}}
100%{{transform:rotate(-360deg)}} }}
@keyframes convup {{ 0%{{transform:translateY(0)}} 50%{{transform:translateY(-100px)}}
100%{{transform:translateY(-200px)}} }}
@keyframes convdn {{ 0%{{transform:translateY(0)}} 50%{{transform:translateY(100px)}}
100%{{transform:translateY(200px)}} }}
@keyframes grow {{ 0%{{transform:scale(0)}} 25%{{transform:scale(.25)}}
50%{{transform:scale(.5)}} 75%{{transform:scale(.75)}}
100%{{transform:scale(1)}} }}
@keyframes bg4 {{ 0%{{fill:{PLUM}}} 25%{{fill:{TEAL}}} 50%{{fill:{PAPER}}} 75%{{fill:{AMBER}}} }}
@keyframes bg2 {{ 0%{{fill:{SAND}}} 25%{{fill:{PLUM}}} 50%{{fill:{SAND}}} 75%{{fill:{PLUM}}} }}
@keyframes pulsea {{ 0%{{transform:scale(1)}} 50%{{transform:scale(.62)}} 100%{{transform:scale(1)}} }}
@keyframes pulseb {{ 0%{{transform:scale(.62)}} 50%{{transform:scale(1)}} 100%{{transform:scale(.62)}} }}
@keyframes shrinky{{ 0%,10%{{transform:scaleY(1)}} 22%,58%{{transform:scaleY(0)}}
70%,100%{{transform:scaleY(1)}} }}
@keyframes shrinkx{{ 0%,10%{{transform:scaleX(1)}} 22%,58%{{transform:scaleX(0)}}
70%,100%{{transform:scaleX(1)}} }}
@keyframes bloom {{ 0%,10%{{transform:scale(0)}} 22%,58%{{transform:scale(1)}}
70%,100%{{transform:scale(0)}} }}
@keyframes rock {{ 0%{{transform:rotate(-9deg)}} 50%{{transform:rotate(9deg)}}
100%{{transform:rotate(-9deg)}} }}
@keyframes rockb {{ 0%{{transform:rotate(8deg)}} 50%{{transform:rotate(-8deg)}}
100%{{transform:rotate(8deg)}} }}
@keyframes rockc {{ 0%{{transform:rotate(-14deg)}} 50%{{transform:rotate(6deg)}}
100%{{transform:rotate(-14deg)}} }}
/* Reduced motion: freeze on a real frame of the loop, don't disable the animations.
`animation: none` would drop every transform, blowing each ripple ring up to its
full radius -- the tiles would render as flat discs, not concentric rings. */
@media (prefers-reduced-motion: reduce) {{
svg {{ --t: 0.35s; }}
[class] {{ animation-play-state: paused !important; }}
}}
"""
# A frozen render at time t (for verification): pause everything at a chosen offset.
freeze = ""
if len(sys.argv) > 2 and sys.argv[1] == "--freeze":
t = float(sys.argv[2])
freeze = f"""
svg {{ --t: {t}s; }}
[class] {{ animation-play-state: paused !important; }}
"""
svg = f'''
'''
sys.stdout.write(svg)