Rootfinder’s Fractals
Published:
I found my roots.
import numpy as np
from numba import njit, prange
import os, subprocess, time, math
# ── Kernel ────────────────────────────────────────────────────────────────────
@njit(parallel=True, cache=True, fastmath=True)
def newton_frame(
cx,
cy,
scale,
re_c,
im_c,
n_c,
w_sin,
w_cos,
w_csh,
w_exp,
w_z4s,
alpha,
max_iter,
res,
):
fzr = np.empty((res, res), dtype=np.float64)
fzi = np.empty((res, res), dtype=np.float64)
iters = np.full((res, res), float(max_iter), dtype=np.float64)
for py in prange(res):
for px in range(res):
zr = cx + (px / res - 0.5) * 2.0 * scale
zi = cy + (py / res - 0.5) * 2.0 * scale
it = float(max_iter)
for i in range(max_iter):
# polynomial via Horner
fr = re_c[n_c - 1]
fi = im_c[n_c - 1]
for k in range(n_c - 2, -1, -1):
nr = fr * zr - fi * zi + re_c[k]
ni = fr * zi + fi * zr + im_c[k]
fr = nr
fi = ni
dr = (n_c - 1) * re_c[n_c - 1]
di = (n_c - 1) * im_c[n_c - 1]
for k in range(n_c - 2, 0, -1):
nr = dr * zr - di * zi + k * re_c[k]
ni = dr * zi + di * zr + k * im_c[k]
dr = nr
di = ni
zi_cl = max(-50.0, min(zi, 50.0))
zr_cl = max(-50.0, min(zr, 50.0))
if w_sin != 0.0 or w_cos != 0.0:
sa = math.sin(zr)
ca = math.cos(zr)
chb = math.cosh(zi_cl)
shb = math.sinh(zi_cl)
if w_sin != 0.0:
fr += w_sin * (sa * chb - 1.0)
fi += w_sin * (ca * shb)
dr += w_sin * (ca * chb)
di += w_sin * (-sa * shb)
if w_cos != 0.0:
fr += w_cos * (ca * chb - 1.0)
fi += w_cos * (-sa * shb)
dr += w_cos * (-sa * chb)
di += w_cos * (-ca * shb)
if w_csh != 0.0:
cha = math.cosh(zr_cl)
sha = math.sinh(zr_cl)
cb = math.cos(zi)
sb = math.sin(zi)
fr += w_csh * (cha * cb - 1.0)
fi += w_csh * (sha * sb)
dr += w_csh * (sha * cb)
di += w_csh * (cha * sb)
if w_exp != 0.0:
er = math.exp(max(-30.0, min(zr, 4.0)))
ecr = er * math.cos(zi)
eci = er * math.sin(zi)
fr += w_exp * (ecr - 2.0)
fi += w_exp * eci
dr += w_exp * ecr
di += w_exp * eci
if w_z4s != 0.0:
zr2 = zr * zr - zi * zi
zi2 = 2 * zr * zi
zr4 = zr2 * zr2 - zi2 * zi2
zi4 = 2 * zr2 * zi2
zr3 = zr2 * zr - zi2 * zi
zi3 = zr2 * zi + zi2 * zr
sa2 = math.sin(zr)
ca2 = math.cos(zr)
chb2 = math.cosh(zi_cl)
shb2 = math.sinh(zi_cl)
sr = sa2 * chb2
si_ = ca2 * shb2
cr2 = ca2 * chb2
ci_ = -sa2 * shb2
fr += w_z4s * (zr4 * sr - zi4 * si_)
fi += w_z4s * (zr4 * si_ + zi4 * sr)
dr += w_z4s * (4 * (zr3 * sr - zi3 * si_) + (zr4 * cr2 - zi4 * ci_))
di += w_z4s * (4 * (zr3 * si_ + zi3 * sr) + (zr4 * ci_ + zi4 * cr2))
denom = dr * dr + di * di
if denom < 1e-28:
break
sr = alpha * (fr * dr + fi * di) / denom
si = alpha * (fi * dr - fr * di) / denom
zr -= sr
zi -= si
step2 = sr * sr + si * si
if step2 < 1e-12:
it = i + 1.0 - math.log(step2 + 1e-30) / math.log(1e-12)
it = max(1.0, min(it, float(max_iter)))
break
fzr[py, px] = zr
fzi[py, px] = zi
iters[py, px] = it
return fzr, fzi, iters
# ── Palettes ──────────────────────────────────────────────────────────────────
def _hsv_to_rgb(h, s, v, n):
h = h % 1.0
i6 = (h * 6).astype(int) % 6
f = h * 6 - (h * 6).astype(int)
p = v * (1 - s)
q_ = v * (1 - f * s)
t_ = v * (1 - (1 - f) * s)
rgb = np.zeros((n, 3), dtype=np.float32)
for cond, r, g, b in [
(i6 == 0, v, t_, p),
(i6 == 1, q_, v, p),
(i6 == 2, p, v, t_),
(i6 == 3, p, q_, v),
(i6 == 4, t_, p, v),
(i6 == 5, v, p, q_),
]:
rgb[cond, 0] = r[cond]
rgb[cond, 1] = g[cond]
rgb[cond, 2] = b[cond]
return np.clip(rgb, 0, 1)
def _make_palettes(n=1024):
t = np.linspace(0, 1, n, endpoint=False)
pals = {}
# PSYCHEDELIC: full hue cycle, very high saturation, punchy value oscillation
h = t
s = np.ones(n)
v = 0.45 + 0.55 * np.abs(np.sin(t * np.pi * 8))
pals["psychedelic"] = _hsv_to_rgb(h, s, v, n)
# FIRE: red→orange→yellow, bright and saturated
h = t * 0.17
s = np.ones(n) * 0.97
v = 0.25 + 0.75 * t
# add a hot-white pulse
v += 0.15 * np.exp(-((t - 0.85) ** 2) / 0.005)
v = np.clip(v, 0, 1)
pals["fire"] = _hsv_to_rgb(h, s, v, n)
# NEON: electric cyan/magenta/green cycling, high brightness
h = (t * 2.5) % 1.0
s = np.ones(n)
v = 0.55 + 0.45 * np.sin(t * np.pi * 14)
v = np.clip(np.abs(v), 0.1, 1.0)
pals["neon"] = _hsv_to_rgb(h, s, v, n)
# COOL: deep blue→cyan→teal, smooth gradients
h = 0.50 + 0.18 * np.sin(t * 2 * np.pi)
s = 0.85 + 0.15 * np.sin(t * np.pi * 5)
v = 0.30 + 0.60 * (0.5 + 0.5 * np.cos(t * 2 * np.pi * 3))
pals["cool"] = _hsv_to_rgb(h, s, v, n)
# RAINBOW: full spectrum, smooth brightness oscillation (deep bands)
h = t
s = np.ones(n) * 0.95
v = 0.40 + 0.50 * (0.5 + 0.5 * np.cos(t * 2 * np.pi * 7))
pals["rainbow"] = _hsv_to_rgb(h, s, v, n)
return pals
_PALS = _make_palettes(1024)
PAL_NAMES = ["psychedelic", "fire", "neon", "cool", "rainbow"]
PAL_IDX = {n: i for i, n in enumerate(PAL_NAMES)}
# Stack into (5, 1024, 3) array for vectorised blending
PAL_STACK = np.stack([_PALS[n] for n in PAL_NAMES], axis=0) # (5,1024,3)
def colour_frame_angle(
fzr, fzi, iters, max_iter, band_freq, hue_rot, brightness, pal_weights
):
PAL = np.tensordot(pal_weights, PAL_STACK, axes=([0], [0]))
N = len(PAL)
converged = iters < max_iter
# hue from angle of attractor in complex plane — stable, no tracking
angle = ((np.arctan2(fzi, fzr) / (2 * np.pi)) + hue_rot) % 1.0
smooth_it = np.where(converged, iters / max_iter, 0.0).astype(np.float32)
band = (smooth_it * band_freq + angle.astype(np.float32)) % 1.0
idx = (band * N).astype(np.int32) % N
rgb = np.empty((*fzr.shape, 3), dtype=np.float32)
rgb[:, :, 0] = PAL[idx, 0]
rgb[:, :, 1] = PAL[idx, 1]
rgb[:, :, 2] = PAL[idx, 2]
rgb[~converged] = 0.0
bmod = brightness * (
0.30 + 0.70 * np.exp(-np.where(converged, iters, 0.0).astype(np.float32) / 20.0)
)
rgb *= bmod[:, :, np.newaxis]
return (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
# ── Root tracker ──────────────────────────────────────────────────────────────
class RootTracker:
def __init__(self):
self.roots = []
def assign(self, fzr, fzi, iters, max_iter):
Q = 80.0
fzc = np.nan_to_num(fzr, nan=1e9, posinf=1e9, neginf=-1e9)
fic = np.nan_to_num(fzi, nan=1e9, posinf=1e9, neginf=-1e9)
Zq = np.round(fzc * Q).astype(np.int64) * 1000003 + np.round(fic * Q).astype(
np.int64
)
Zq[iters >= max_iter] = np.iinfo(np.int64).min
uv = np.unique(Zq)
uv = uv[uv != np.iinfo(np.int64).min]
clusters = [(fzc[Zq == v].mean(), fic[Zq == v].mean(), v) for v in uv]
assignment = {}
used = set()
for cr, ci, v in clusters:
best_id = None
best_dist = 0.25
for sid, (tr, ti) in enumerate(self.roots):
d = (cr - tr) ** 2 + (ci - ti) ** 2
if d < best_dist and sid not in used:
best_dist = d
best_id = sid
if best_id is None:
best_id = len(self.roots)
self.roots.append((cr, ci))
else:
tr, ti = self.roots[best_id]
self.roots[best_id] = (tr * 0.9 + cr * 0.1, ti * 0.9 + ci * 0.1)
assignment[v] = best_id
used.add(best_id)
root_id = np.full(fzr.shape, -1, dtype=np.int32)
for v, sid in assignment.items():
root_id[Zq == v] = sid
return root_id, max(len(self.roots), 1)
# ── Keyframes ─────────────────────────────────────────────────────────────────
def K(
rc=None,
ic=None,
w_sin=0.0,
w_cos=0.0,
w_csh=0.0,
w_exp=0.0,
w_z4s=0.0,
alpha=1.0,
scale=1.6,
band_freq=8.0,
hue_rot=0.0,
bright=1.0,
pal_w=None,
):
rc = rc or [0.0]
ic = ic or ([0.0] * len(rc))
pal_w = pal_w or [0.2, 0.2, 0.2, 0.2, 0.2]
# normalise
pw = np.array(pal_w, dtype=np.float32)
pw = pw / pw.sum()
return dict(
rc=rc,
ic=ic,
w_sin=w_sin,
w_cos=w_cos,
w_csh=w_csh,
w_exp=w_exp,
w_z4s=w_z4s,
alpha=alpha,
scale=scale,
band_freq=band_freq,
hue_rot=hue_rot,
bright=bright,
pal_w=pw.tolist(),
)
KEYFRAMES = [
# ── SECTION 1: polynomial series, vivid palettes ──────────────────────
K(
[-1, 0, 0, 1],
scale=1.60,
band_freq=9,
hue_rot=0.00,
bright=1.0,
pal_w=[1.0, 0, 0, 0, 0],
), # 00 z³-1 psychedelic
K(
[-1, 0, 0, 0, 1],
scale=1.50,
band_freq=8,
hue_rot=0.05,
bright=1.0,
pal_w=[0.3, 0, 0, 0, 0.7],
), # 01 z⁴-1 rainbow
K(
[-1, 0, 0, 1, 0, 0, 1],
scale=1.55,
band_freq=9,
hue_rot=0.10,
bright=1.1,
pal_w=[0.8, 0, 0.2, 0, 0],
), # 02 z⁶+z³-1 psychedelic+neon
K(
[-1, 0, 0, 0, 0, 1],
scale=1.40,
band_freq=10,
hue_rot=0.18,
bright=1.0,
pal_w=[0, 0, 1.0, 0, 0],
), # 03 z⁵-1 neon
K(
[-1, 0, 0, 0, 0, 0, 1],
scale=1.30,
band_freq=8,
hue_rot=0.25,
bright=1.0,
pal_w=[0, 0, 0, 0, 1.0],
), # 04 z⁶-1 rainbow
K(
[2, -2, 0, 1],
scale=1.80,
band_freq=7,
hue_rot=0.32,
bright=1.0,
pal_w=[0, 0, 0, 1.0, 0],
), # 05 z³-2z+2 cool
K(
[-1, 0, 0, 1, 1],
scale=1.50,
band_freq=9,
hue_rot=0.40,
bright=1.0,
pal_w=[0, 0, 1.0, 0, 0],
), # 06 z⁴+z³-1 neon
K(
[1, -1, 0, 0, 0, 1],
scale=1.40,
band_freq=8,
hue_rot=0.48,
bright=1.0,
pal_w=[0, 0, 0.2, 0, 0.8],
), # 07 z⁵-z+1 rainbow
# ── complex coefficients ──────────────────────────────────────────────
K(
[-1, 0, 0, 1],
ic=[0, 1, 0, 0],
scale=1.60,
band_freq=9,
hue_rot=0.56,
bright=1.0,
pal_w=[1.0, 0, 0, 0, 0],
), # 08 z³+iz-1 psychedelic
K(
[1, 0, 0, 0, 1],
ic=[0, 0, -1, 0, 0],
scale=1.50,
band_freq=9,
hue_rot=0.62,
bright=1.0,
pal_w=[0.7, 0, 0.3, 0, 0],
), # 09 z⁴-iz²+1 psychedelic+neon
K(
[-1, 0, 0, 0, 0, 0, 1],
ic=[0, 0, 0, 0, 1, 0, 0],
scale=1.30,
band_freq=10,
hue_rot=0.70,
bright=1.0,
pal_w=[0, 0, 1.0, 0, 0],
), # 10 z⁶+iz⁴-1 neon
K(
[0, 0, 0, 0.5, 1],
ic=[0, 0, 0, 0.5, 0],
scale=1.50,
band_freq=9,
hue_rot=0.77,
bright=1.0,
pal_w=[0, 1.0, 0, 0, 0],
), # 11 z⁴+(½+½i)z³ fire
K(
[-(1), 0, 0, 1],
ic=[-1, 0, 0, 0],
scale=1.60,
band_freq=8,
hue_rot=0.83,
bright=1.0,
pal_w=[0, 0, 0, 1.0, 0],
), # 12 z³-(1+i) cool
# ── SECTION 2: relaxed Newton — extended (was 0:50-1:20 highlight) ───
K(
[-1, 0, 0, 1],
alpha=0.75,
scale=1.60,
band_freq=11,
hue_rot=0.00,
bright=1.15,
pal_w=[0, 1.0, 0, 0, 0],
), # 13 z³-1 α=0.75 fire
K(
[-1, 0, 0, 0, 1],
alpha=0.70,
scale=1.55,
band_freq=12,
hue_rot=0.08,
bright=1.1,
pal_w=[0, 0.5, 0.5, 0, 0],
), # 14 z⁴-1 α=0.70 fire+neon
K(
[-1, 0, 0, 0, 0, 1],
alpha=0.65,
scale=1.45,
band_freq=13,
hue_rot=0.16,
bright=1.2,
pal_w=[0, 0, 1.0, 0, 0],
), # 15 z⁵-1 α=0.65 neon
K(
[-1, 0, 0, 0, 0, 0, 1],
alpha=0.62,
scale=1.35,
band_freq=14,
hue_rot=0.25,
bright=1.2,
pal_w=[0.4, 0, 0.6, 0, 0],
), # 16 z⁶-1 α=0.62 psych+neon
K(
[-1, 0, 0, 1],
alpha=0.58,
scale=1.60,
band_freq=15,
hue_rot=0.33,
bright=1.3,
pal_w=[1.0, 0, 0, 0, 0],
), # 17 z³-1 α=0.58 psychedelic deep
K(
[-1, 0, 0, 0, 1],
alpha=0.55,
scale=1.50,
band_freq=16,
hue_rot=0.42,
bright=1.25,
pal_w=[0, 1.0, 0, 0, 0],
), # 18 z⁴-1 α=0.55 fire deep
K(
[-1, 0, 0, 1],
alpha=1.30,
scale=1.60,
band_freq=10,
hue_rot=0.50,
bright=1.0,
pal_w=[0, 0, 0.5, 0.5, 0],
), # 19 z³-1 α=1.30 over-relaxed
K(
[-1, 0, 0, 0, 1],
alpha=1.35,
scale=1.50,
band_freq=11,
hue_rot=0.58,
bright=1.0,
pal_w=[0, 0, 0, 0, 1.0],
), # 20 z⁴-1 α=1.35 rainbow
# ── SECTION 3: transcendentals ────────────────────────────────────────
K(
[-1, 0, 0, 1],
w_sin=0.4,
scale=3.50,
band_freq=8,
hue_rot=0.65,
bright=1.0,
pal_w=[0, 0, 0, 0, 1.0],
), # 21 poly→sin bridge rainbow
K(
[0.0],
w_sin=1.0,
scale=6.50,
band_freq=6,
hue_rot=0.72,
bright=1.0,
pal_w=[0, 0, 0, 0.3, 0.7],
), # 22 pure sin(z)-1 cool+rainbow
K(
[0.0],
w_cos=1.0,
scale=5.00,
band_freq=7,
hue_rot=0.78,
bright=1.0,
pal_w=[0, 0, 0, 1.0, 0],
), # 23 cos(z)-1 cool
K(
[0.0],
w_csh=1.0,
scale=5.00,
band_freq=8,
hue_rot=0.84,
bright=1.05,
pal_w=[0, 0, 0, 0.6, 0.4],
), # 24 cosh(z)-1 cool+rainbow
K(
[-1, 0, 0, 0, 1],
w_csh=0.9,
scale=3.00,
band_freq=9,
hue_rot=0.90,
bright=1.0,
pal_w=[0.5, 0, 0, 0.5, 0],
), # 25 poly+cosh psych+cool
K(
[0.0],
w_exp=1.0,
scale=4.00,
band_freq=7,
hue_rot=0.96,
bright=1.0,
pal_w=[0, 0, 0, 1.0, 0],
), # 26 exp(z)-2 cool
K(
[0.0],
w_z4s=1.0,
scale=3.00,
band_freq=9,
hue_rot=0.04,
bright=1.0,
pal_w=[0, 1.0, 0, 0, 0],
), # 27 z⁴·sin(z) fire
K(
[-1, 0, 0, 1],
w_sin=0.5,
w_csh=0.5,
scale=4.00,
band_freq=9,
hue_rot=0.12,
bright=1.1,
pal_w=[0.5, 0, 0.5, 0, 0],
), # 28 sin+cosh cocktail psych+neon
K(
[-1, 0, 0, 0, 1],
w_cos=0.4,
w_exp=0.3,
scale=3.50,
band_freq=8,
hue_rot=0.20,
bright=1.0,
pal_w=[0, 0, 0.4, 0.3, 0.3],
), # 29 cos+exp cocktail neon+cool+rain
K(
[-1, 0, 0, 1],
w_z4s=0.5,
w_sin=0.3,
scale=3.50,
band_freq=10,
hue_rot=0.28,
bright=1.1,
pal_w=[0, 0.6, 0.4, 0, 0],
), # 30 z4sin+sin blend fire+neon
K(
[-1, 0, 0, 1],
scale=1.60,
band_freq=9,
hue_rot=0.00,
bright=1.0,
pal_w=[1.0, 0, 0, 0, 0],
), # 31 z³-1 psychedelic (closes loop)
]
N_KEYS = len(KEYFRAMES)
MAX_DEG = max((len(k["rc"]) for k in KEYFRAMES), default=1)
MAX_DEG = max(MAX_DEG, 9)
def _pad(lst, n):
a = np.array(lst, dtype=np.float64)
return np.pad(a, (0, max(0, n - len(a))))
KRC = np.array([_pad(k["rc"], MAX_DEG) for k in KEYFRAMES])
KIC = np.array([_pad(k["ic"], MAX_DEG) for k in KEYFRAMES])
KPW = np.array([k["pal_w"] for k in KEYFRAMES], dtype=np.float32) # (N,5)
KPAR = np.array(
[
[
k["w_sin"],
k["w_cos"],
k["w_csh"],
k["w_exp"],
k["w_z4s"],
k["alpha"],
k["scale"],
k["band_freq"],
k["hue_rot"],
k["bright"],
]
for k in KEYFRAMES
]
)
def _cr(p0, p1, p2, p3, t):
return 0.5 * (
(2 * p1)
+ (-p0 + p2) * t
+ (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t
+ (-p0 + 3 * p1 - 3 * p2 + p3) * t * t * t
)
def get_params(phase):
fp = phase * (N_KEYS - 1)
seg = int(fp) % (N_KEYS - 1)
t = fp - int(fp)
i0 = (seg - 1) % (N_KEYS - 1)
i1 = seg
i2 = (seg + 1) % (N_KEYS - 1)
i3 = (seg + 2) % (N_KEYS - 1)
rc = _cr(KRC[i0], KRC[i1], KRC[i2], KRC[i3], t)
ic = _cr(KIC[i0], KIC[i1], KIC[i2], KIC[i3], t)
pw = _cr(KPW[i0], KPW[i1], KPW[i2], KPW[i3], t)
pars = _cr(KPAR[i0], KPAR[i1], KPAR[i2], KPAR[i3], t)
pw = np.clip(pw, 0, None).astype(np.float32)
pw /= pw.sum() + 1e-9
w_sin = float(np.clip(pars[0], 0, 3))
w_cos = float(np.clip(pars[1], 0, 3))
w_csh = float(np.clip(pars[2], 0, 3))
w_exp = float(np.clip(pars[3], 0, 3))
w_z4s = float(np.clip(pars[4], 0, 3))
alpha = float(np.clip(pars[5], 0.4, 1.6))
scale = float(np.clip(pars[6], 0.5, 12.0))
bfreq = float(np.clip(pars[7], 2.0, 30.0))
hrot = float(pars[8] % 1.0)
bright = float(np.clip(pars[9], 0.5, 1.8))
n = MAX_DEG
while n > 1 and abs(rc[n - 1]) < 1e-9 and abs(ic[n - 1]) < 1e-9:
n -= 1
n = max(n, 1)
return (
np.ascontiguousarray(rc[:n], dtype=np.float64),
np.ascontiguousarray(ic[:n], dtype=np.float64),
n,
w_sin,
w_cos,
w_csh,
w_exp,
w_z4s,
alpha,
scale,
bfreq,
hrot,
bright,
pw,
)
# ── Config ────────────────────────────────────────────────────────────────────
FPS = 30
DURATION = 150
TOTAL = FPS * DURATION
RES = 900
MAX_ITER = 64
OUT = "newton.mp4"
def main():
n_threads = int(os.environ.get("NUMBA_NUM_THREADS", "1"))
print("Newton Fractal Renderer — Definitive Edition")
print(f" {RES}×{RES} {FPS}fps {DURATION}s ({TOTAL} frames)")
print(
f" Threads: {n_threads}"
+ (" ← set NUMBA_NUM_THREADS=8 for M1 performance" if n_threads < 4 else " ✓")
)
print(f" Keyframes: {N_KEYS} ({DURATION/(N_KEYS-1):.1f}s per segment)")
section_times = {
"Polynomials (vivid palettes)": (0, 54),
"Relaxed Newton (highlight)": (54, 99),
"Transcendentals": (99, 150),
}
for label, (ts, te) in section_times.items():
print(f" {ts:3d}s–{te:3d}s {label}")
print()
print("Compiling JIT (once)...", flush=True)
t0 = time.time()
_r = np.array([-1.0, 0.0, 0.0, 1.0], dtype=np.float64)
_i = np.zeros(4, dtype=np.float64)
newton_frame(0.0, 0.0, 1.6, _r, _i, 4, 0.5, 0.5, 0.5, 0.5, 0.5, 1.0, 8, 64)
newton_frame(0.0, 0.0, 1.6, _r, _i, 4, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 8, 64)
print(f" Done in {time.time()-t0:.1f}s\n", flush=True)
cmd = [
"ffmpeg",
"-y",
"-f",
"rawvideo",
"-vcodec",
"rawvideo",
"-s",
f"{RES}x{RES}",
"-pix_fmt",
"rgb24",
"-r",
str(FPS),
"-i",
"pipe:0",
"-vcodec",
"libx264",
"-preset",
"slow",
"-crf",
"14",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
OUT,
]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)
# No root tracker needed — angle-based colouring is self-consistent
t_start = time.time()
last_prog = t_start
print(f"Rendering {TOTAL} frames...", flush=True)
for frame_idx in range(TOTAL):
phase = frame_idx / TOTAL
(
rc,
ic,
n,
w_sin,
w_cos,
w_csh,
w_exp,
w_z4s,
alpha,
scale,
bfreq,
hrot,
bright,
pw,
) = get_params(phase)
fzr, fzi, iters = newton_frame(
0.0,
0.0,
scale,
rc,
ic,
n,
w_sin,
w_cos,
w_csh,
w_exp,
w_z4s,
alpha,
MAX_ITER,
RES,
)
rgb = colour_frame_angle(fzr, fzi, iters, MAX_ITER, bfreq, hrot, bright, pw)
proc.stdin.write(rgb.tobytes())
now = time.time()
if now - last_prog > 15.0 or frame_idx == TOTAL - 1:
el = now - t_start
fps = (frame_idx + 1) / el
eta = (TOTAL - frame_idx - 1) / fps
pct = 100 * (frame_idx + 1) // TOTAL
seg = int((phase * (N_KEYS - 1))) % (N_KEYS - 1)
# identify section
t_vid = frame_idx / FPS
sec = next(
(l for l, (ts, te) in section_times.items() if ts <= t_vid < te),
"Cocktails",
)
pal_desc = PAL_NAMES[int(np.argmax(pw))]
print(
f" {frame_idx+1:5d}/{TOTAL} {pct:3d}% {fps:5.1f}fps "
f"ETA {eta/60:.1f}min "
f"[{t_vid:.0f}s key{seg} "
f"sin={w_sin:.2f} csh={w_csh:.2f} α={alpha:.2f} "
f"pal≈{pal_desc}]",
flush=True,
)
last_prog = now
proc.stdin.close()
proc.wait()
elapsed = time.time() - t_start
size_mb = os.path.getsize(OUT) / 1e6 if os.path.exists(OUT) else 0
print(f"\n{'─'*60}")
print(
f"Done. {TOTAL} frames in {elapsed/60:.1f}min "
f"({TOTAL/elapsed:.1f}fps render speed)"
)
print(f"Output: {OUT} ({size_mb:.0f} MB)")
if __name__ == "__main__":
main()

