# ============================================================
# Mandelbrot zoom — Google Colab (one-cell version)
# ============================================================
# Paste into a fresh Colab cell and run.
# Output: mandelbrot_zoom.mp4 + inline preview + download link.
# ============================================================

import math, time, subprocess as sp, os, shutil, sys, importlib
from pathlib import Path

# ---- 1. Dependencies ---------------------------------------
for pkg in ('numba', 'numpy', 'matplotlib', 'tqdm'):
    if importlib.util.find_spec(pkg) is None:
        sp.check_call([sys.executable, '-m', 'pip', 'install', '-q', pkg])

if shutil.which('ffmpeg') is None:
    sp.check_call([sys.executable, '-m', 'pip', 'install', '-q', 'imageio-ffmpeg'])
    import imageio_ffmpeg
    FFMPEG_BIN = imageio_ffmpeg.get_ffmpeg_exe()
else:
    FFMPEG_BIN = shutil.which('ffmpeg')

import numpy as np
import numba as nb
import matplotlib.pyplot as plt
from tqdm.auto import tqdm
from IPython.display import Video, FileLink, display

print('numba', nb.__version__, '| numpy', np.__version__)
print('ffmpeg ->', FFMPEG_BIN)

# ---- 2. Parameters (edit me) -------------------------------
WIDTH      = 1280
HEIGHT     = 720
FPS        = 30
N_FRAMES   = 150                       # 5 s @ 30 fps

CENTER_X   = -0.7436438870371587       # Seahorse Valley
CENTER_Y   =  0.13182590420531197
ZOOM_START =  1.0
ZOOM_END   =  1e-4                     # 10 000x zoom-in

CRF        = 20
PRESET     = 'fast'
OUTPUT_FILE = 'mandelbrot_zoom.mp4'

print(f'Plan: {N_FRAMES} frames @ {WIDTH}x{HEIGHT}, {FPS} fps '
      f'-> {N_FRAMES / FPS:.1f} s of video, zoom {ZOOM_START}->{ZOOM_END}')

# ---- 3. Palette LUT (IQ cosine, precomputed) ---------------
PALETTE_SIZE = 1024

def build_palette(n):
    t = np.linspace(0.0, 1.0, n, dtype=np.float64) ** 0.4
    two_pi = 2.0 * math.pi
    r = 0.5 + 0.5 * np.cos(two_pi * (t + 0.00))
    g = 0.5 + 0.5 * np.cos(two_pi * (t + 0.33))
    b = 0.5 + 0.5 * np.cos(two_pi * (t + 0.67))
    pal = np.empty((n, 3), dtype=np.uint8)
    pal[:, 0] = np.clip(r * 255.0, 0, 255).astype(np.uint8)
    pal[:, 1] = np.clip(g * 255.0, 0, 255).astype(np.uint8)
    pal[:, 2] = np.clip(b * 255.0, 0, 255).astype(np.uint8)
    return pal

PALETTE = build_palette(PALETTE_SIZE)    # global, read-only in kernel

# ---- 4. Mandelbrot kernel (Numba, parallel, LUT colors) ----
@nb.njit(parallel=True, fastmath=True, cache=True)
def mandelbrot_kernel(width, height, cx, cy, zoom, aspect, max_iter,
                      palette):
    img = np.empty((height, width, 3), dtype=np.uint8)
    pal_n = palette.shape[0]
    inv_w = 1.0 / width
    inv_h = 1.0 / height
    inv_max = 1.0 / float(max_iter)
    for j in nb.prange(height):
        v_uv_y = 2.0 * (j + 0.5) * inv_h - 1.0
        for i in range(width):
            u_uv_x = 2.0 * (i + 0.5) * inv_w - 1.0
            px = (u_uv_x * aspect) * zoom + cx
            py = v_uv_y * zoom + cy

            zx = 0.0; zy = 0.0
            zx2 = 0.0; zy2 = 0.0
            k = 0
            while k < max_iter and (zx2 + zy2) <= 4.0:
                zy = 2.0 * zx * zy + py
                zx = zx2 - zy2 + px
                zx2 = zx * zx
                zy2 = zy * zy
                k += 1

            if k == max_iter:
                img[j, i, 0] = 13
                img[j, i, 1] = 0
                img[j, i, 2] = 26
            else:
                # map k/max_iter -> LUT index (palette already has t**0.4 baked in)
                idx = int((k * inv_max) * (pal_n - 1))
                if idx < 0: idx = 0
                elif idx >= pal_n: idx = pal_n - 1
                img[j, i, 0] = palette[idx, 0]
                img[j, i, 1] = palette[idx, 1]
                img[j, i, 2] = palette[idx, 2]
    return img


def dynamic_max_iter(zoom, cx, cy):
    log_scale = math.log2(max(1.0 / max(zoom, 1e-6), 1.0))
    pos_term  = 0.5 * math.hypot(cx + 0.75, cy)
    mi = int(64.0 + 32.0 * min(max(log_scale + pos_term, 0.0), 12.0))
    return min(mi, 512)


def render_frame(zoom):
    aspect = WIDTH / HEIGHT
    mi = dynamic_max_iter(zoom, CENTER_X, CENTER_Y)
    return mandelbrot_kernel(WIDTH, HEIGHT, CENTER_X, CENTER_Y,
                             zoom, aspect, mi, PALETTE)

# ---- 5. JIT warmup + preview start frame --------------------
t0 = time.perf_counter()
_ = mandelbrot_kernel(64, 32, -0.75, 0.0, 1.0, 2.0, 64, PALETTE)
print(f'JIT compiled in {time.perf_counter()-t0:.1f} s')

frame0 = render_frame(ZOOM_START)
plt.figure(figsize=(10, 5.625))
plt.imshow(frame0)
plt.axis('off')
plt.title(f'Start frame  zoom={ZOOM_START}  '
          f'maxIter={dynamic_max_iter(ZOOM_START, CENTER_X, CENTER_Y)}')
plt.show()

# ---- 6. Render & encode MP4 --------------------------------
cmd = [
    FFMPEG_BIN, '-y',
    '-f', 'rawvideo', '-pixel_format', 'rgb24',
    '-video_size', f'{WIDTH}x{HEIGHT}',
    '-framerate', str(FPS),
    '-i', '-',
    '-c:v', 'libx264',
    '-preset', PRESET,
    '-crf', str(CRF),
    '-pix_fmt', 'yuv420p',
    '-movflags', '+faststart',
    OUTPUT_FILE,
]
# stderr -> file to avoid the classic two-pipe deadlock
ffmpeg_log = open('ffmpeg.log', 'wb')
proc = sp.Popen(cmd, stdin=sp.PIPE, stderr=ffmpeg_log)

log_start, log_end = math.log(ZOOM_START), math.log(ZOOM_END)
t_render_start = time.perf_counter()

for i in tqdm(range(N_FRAMES), unit='frame', desc='render'):
    t = i / max(N_FRAMES - 1, 1)
    zoom = math.exp(log_start + t * (log_end - log_start))
    proc.stdin.write(render_frame(zoom).tobytes())

proc.stdin.close()
proc.wait()
ffmpeg_log.close()

t_total = time.perf_counter() - t_render_start
size_kb = os.path.getsize(OUTPUT_FILE) / 1024
print(f'\nDone in {t_total:.1f} s  ({N_FRAMES / t_total:.1f} fps avg, '
      f'{size_kb:.0f} KB)  -> {OUTPUT_FILE}')

if proc.returncode != 0:
    with open('ffmpeg.log', 'rb') as f:
        print('FFmpeg stderr (tail):\n', f.read()[-1500:].decode(errors='ignore'))

# ---- 7. Preview & download --------------------------------
display(Video(OUTPUT_FILE, html_attributes='controls loop', embed=False,
              width=720))
display(FileLink(OUTPUT_FILE, result_html_prefix='Download: '))

# ---- 8. Bonus: single still at arbitrary zoom -------------
still_zoom = 1e-3
still = render_frame(still_zoom)
plt.figure(figsize=(10, 5.625))
plt.imshow(still)
plt.axis('off')
plt.title(f'zoom={still_zoom}  '
          f'maxIter={dynamic_max_iter(still_zoom, CENTER_X, CENTER_Y)}')
plt.show()