#!/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''' Ambient loop — recreated with motiscope {clips} {chr(10).join(tiles)} ''' sys.stdout.write(svg)