Rootfinder’s Fractals

17 minute read

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()