// Audio-reactive living cyber-organic sigil.
//
// A fixed cyber skeleton with organic overgrowth that grows / branches /
// withers / restructures with the music, drawn as noise-warped curves in
// audio-driven OKLCH colour, through a feedback-trail + bloom post stack with
// transient chromatic aberration. See src/viz/*.
//
//   cargo run --release --bin sigil -- [--mode sigil|scope|breakcore] [<index>|monitor|loopback|<file>]
//   cargo run --release --bin sigil -- --render [--mode breakcore] [out.mp4] <file>
//
// --render decodes the whole file and renders every frame at a fixed fps with
// no frame budget, *streaming* each raw frame straight into one long-lived
// ffmpeg over stdin (no PNG sequence / temp dir). Output res/quality is
// cfg-tunable (out_scale / crf / x264_preset). Live mode plays the file/feed
// and reacts in real time. --mode picks the visualiser (default sigil);
// `breakcore` is a wgpu SDF raymarcher that bypasses the Draw/Post path.
//
// Keys: R reseed · M cycle mode · P save PNG · F fullscreen · H HUD · G glow
//       B feedback · C write cfg · 1/2 low-scale · 3/4 warp · 5/6 trail-decay
//       7/8 bloom · 9/0 chroma-aberration · -/= curve quality · Esc quit

use std::io::Write;
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};

use audio_visualizer::audio::{self, AudioHandle, Bands, Source, Timeline};
use audio_visualizer::viz::breakcore::Breakcore;
use audio_visualizer::viz::palette::Palette;
use audio_visualizer::viz::post::{Post, ADDITIVE};
use audio_visualizer::viz::scope::Scope;
use audio_visualizer::viz::sigil::Sigil;
use nannou::prelude::*;

const W: f32 = 1080.0;
const H: f32 = 1080.0;
const RENDER_FPS: f32 = 30.0;
const SEED: u64 = 0x5C1_6E1_5EED;

// x264 speed/quality preset. Copy so `Gains` stays Copy.
#[derive(Clone, Copy, PartialEq)]
enum Preset {
    Ultrafast,
    Veryfast,
    Faster,
    Fast,
    Medium,
    Slow,
    Slower,
    Veryslow,
}

impl Preset {
    fn as_str(self) -> &'static str {
        match self {
            Preset::Ultrafast => "ultrafast",
            Preset::Veryfast => "veryfast",
            Preset::Faster => "faster",
            Preset::Fast => "fast",
            Preset::Medium => "medium",
            Preset::Slow => "slow",
            Preset::Slower => "slower",
            Preset::Veryslow => "veryslow",
        }
    }
    fn parse(s: &str) -> Option<Self> {
        Some(match s {
            "ultrafast" => Preset::Ultrafast,
            "veryfast" => Preset::Veryfast,
            "faster" => Preset::Faster,
            "fast" => Preset::Fast,
            "medium" => Preset::Medium,
            "slow" => Preset::Slow,
            "slower" => Preset::Slower,
            "veryslow" => Preset::Veryslow,
            _ => return None,
        })
    }
}

// Runtime-tunable, persisted to <project>/sigil.cfg.
#[derive(Clone, Copy)]
struct Gains {
    low: f32,         // low level -> breathing scale
    warp: f32,        // organic noise-warp amplitude multiplier
    fade: f32,        // feedback decay per frame (0 endless .. 1 none)
    zoom: f32,        // feedback bloom expansion (~1.006)
    ca: f32,          // chromatic aberration px at full broadband flux
    drive: f32,       // breakcore expressive-effect master (heat/glitch/swirl)
    seg: usize,       // Catmull-Rom samples per control segment (quality)
    glow: bool,       // faux-glow halo passes
    feedback: bool,   // feedback/bloom post (vs. direct draw)
    out_scale: u32,   // --render: output square px (0 = native RES, no rescale)
    crf: u32,         // --render: x264 crf (lower = bigger/better; ~16..28)
    x264: Preset,     // --render: x264 speed/quality preset
}

impl Default for Gains {
    fn default() -> Self {
        Gains {
            low: 0.85,
            warp: 1.0,
            fade: 0.11,
            zoom: 1.006,
            ca: 7.0,
            drive: 1.0,
            seg: 9,
            glow: true,
            feedback: true,
            out_scale: 1080,
            crf: 18,
            x264: Preset::Slow,
        }
    }
}

impl Gains {
    fn load(path: &PathBuf) -> Self {
        let mut g = Gains::default();
        let Ok(txt) = std::fs::read_to_string(path) else {
            return g;
        };
        for line in txt.lines() {
            let Some((k, v)) = line.split_once('=') else {
                continue;
            };
            let (k, v) = (k.trim(), v.trim());
            match k {
                "low" => g.low = v.parse().unwrap_or(g.low),
                "warp" => g.warp = v.parse().unwrap_or(g.warp),
                "fade" => g.fade = v.parse().unwrap_or(g.fade),
                "zoom" => g.zoom = v.parse().unwrap_or(g.zoom),
                "ca" => g.ca = v.parse().unwrap_or(g.ca),
                "drive" => g.drive = v.parse().unwrap_or(g.drive),
                "seg" => g.seg = v.parse().unwrap_or(g.seg),
                "glow" => g.glow = v.parse().unwrap_or(g.glow),
                "feedback" => g.feedback = v.parse().unwrap_or(g.feedback),
                "out_scale" => g.out_scale = v.parse().unwrap_or(g.out_scale),
                "crf" => g.crf = v.parse().unwrap_or(g.crf),
                "x264_preset" => g.x264 = Preset::parse(v).unwrap_or(g.x264),
                _ => {}
            }
        }
        g.seg = g.seg.clamp(2, 24);
        g.drive = g.drive.clamp(0.0, 3.0);
        g.crf = g.crf.clamp(0, 51);
        if g.out_scale != 0 {
            g.out_scale &= !1; // x264 yuv420p needs even dimensions
        }
        g
    }
    fn save(&self, path: &PathBuf) {
        let s = format!(
            "low={}\nwarp={}\nfade={}\nzoom={}\nca={}\ndrive={}\nseg={}\nglow={}\nfeedback={}\n\
             out_scale={}\ncrf={}\nx264_preset={}\n",
            self.low,
            self.warp,
            self.fade,
            self.zoom,
            self.ca,
            self.drive,
            self.seg,
            self.glow,
            self.feedback,
            self.out_scale,
            self.crf,
            self.x264.as_str(),
        );
        if std::fs::write(path, s).is_ok() {
            println!("wrote {}", path.display());
        }
    }
}

enum Mode {
    Live(AudioHandle),
    Render {
        tl: Timeline,
        frame: u64,
        total: u64,
        ff: Child,                  // long-lived ffmpeg encoder
        stdin: Option<ChildStdin>,  // raw-frame pipe (None once closed)
        out: PathBuf,
    },
}

/// The active visualiser. `Sigil`/`Scope` are `Draw`-based and share the draw
/// signature so the chromatic-aberration channel passes dispatch uniformly;
/// `Breakcore` owns its own wgpu raymarch target instead (see `is_gpu`).
#[allow(clippy::large_enum_variant)] // Sigil/Scope are inline; Breakcore is boxed
enum Visual {
    Sigil(Sigil),
    Scope(Scope),
    Breakcore(Box<Breakcore>), // owns its wgpu pipeline -> boxed
}

impl Visual {
    fn update(&mut self, b: &Bands, dt: f32) {
        match self {
            Visual::Sigil(s) => s.update(b, dt),
            Visual::Scope(s) => s.update(b, dt),
            Visual::Breakcore(s) => s.update(b, dt),
        }
    }
    fn reseed(&mut self, seed: u64) {
        match self {
            Visual::Sigil(s) => s.reseed(seed),
            Visual::Scope(s) => s.reseed(seed),
            Visual::Breakcore(s) => s.reseed(seed),
        }
    }
    fn seed(&self) -> u64 {
        match self {
            Visual::Sigil(s) => s.seed,
            Visual::Scope(s) => s.seed,
            Visual::Breakcore(s) => s.seed,
        }
    }
    fn count(&self) -> usize {
        match self {
            Visual::Sigil(s) => s.tendril_count(),
            Visual::Scope(s) => s.point_count(),
            Visual::Breakcore(s) => s.point_count(),
        }
    }
    fn name(&self) -> &'static str {
        match self {
            Visual::Sigil(_) => "sigil",
            Visual::Scope(_) => "scope",
            Visual::Breakcore(_) => "breakcore",
        }
    }
    /// `Breakcore` renders through its own wgpu pipeline, not the shared
    /// `Draw` → `Post` path.
    fn is_gpu(&self) -> bool {
        matches!(self, Visual::Breakcore(_))
    }
    /// Cycle visualisers, keeping the current seed (live only). `Breakcore`
    /// needs the device to (re)build its pipeline.
    fn cycle(&mut self, device: &nannou::wgpu::Device) {
        let s = self.seed();
        *self = match self {
            Visual::Sigil(_) => Visual::Scope(Scope::new(s)),
            Visual::Scope(_) => Visual::Breakcore(Box::new(Breakcore::new(s, device))),
            Visual::Breakcore(_) => Visual::Sigil(Sigil::new(s)),
        };
    }
    #[allow(clippy::too_many_arguments)]
    fn draw(
        &self,
        d: &Draw,
        pal: &Palette,
        fit: f32,
        scale: f32,
        warp: f32,
        glow: bool,
        seg: usize,
        tint: [f32; 3],
    ) {
        match self {
            Visual::Sigil(s) => s.draw(d, pal, fit, scale, warp, glow, seg, tint),
            Visual::Scope(s) => s.draw(d, pal, fit, scale, warp, glow, seg, tint),
            Visual::Breakcore(_) => {} // rendered via its own pipeline
        }
    }

    // --- gpu path (Breakcore only); the other arms never run these ---------
    #[allow(clippy::too_many_arguments)]
    fn render_gpu<'a>(
        &'a mut self,
        device: &nannou::wgpu::Device,
        queue: &nannou::wgpu::Queue,
        pal: &Palette,
        scale: f32,
        warp: f32,
        feedback: bool,
        fade: f32,
        ca_px: f32,
        drive: f32,
    ) -> &'a nannou::wgpu::Texture {
        match self {
            Visual::Breakcore(s) => {
                s.render(device, queue, pal, scale, warp, feedback, fade, ca_px, drive)
            }
            _ => unreachable!("render_gpu on a Draw-based visual"),
        }
    }
    fn current_tex(&self) -> &nannou::wgpu::Texture {
        match self {
            Visual::Breakcore(s) => s.current(),
            _ => unreachable!("current_tex on a Draw-based visual"),
        }
    }
    fn capture_raw(
        &self,
        device: &nannou::wgpu::Device,
        queue: &nannou::wgpu::Queue,
    ) -> anyhow::Result<Vec<u8>> {
        match self {
            Visual::Breakcore(s) => s.capture_raw(device, queue),
            _ => unreachable!("capture_raw on a Draw-based visual"),
        }
    }
}

struct Model {
    visual: Visual,
    post: Post,
    mode: Mode,
    g: Gains,
    cfg: PathBuf,
    hud: bool,
    fullscreen: bool,
    ca_env: f32,
    last: Bands, // for the HUD
}

fn main() {
    // Headless diagnostic: decode + analyse a file and print timeline stats.
    // No window/GPU/audio device. Useful for validating the analysis path.
    //   sigil --analyze <file>
    let args: Vec<String> = std::env::args().collect();
    if let Some(i) = args.iter().position(|a| a == "--analyze") {
        let f = args
            .get(i + 1)
            .map(PathBuf::from)
            .unwrap_or_else(|| die("--analyze needs a file path"));
        match audio::analyze_file(&f) {
            Ok(tl) => {
                let mut peak = Bands::default();
                for b in &tl.frames {
                    peak.low = peak.low.max(b.low);
                    peak.loud = peak.loud.max(b.loud);
                    peak.flux = peak.flux.max(b.flux);
                    peak.centroid = peak.centroid.max(b.centroid);
                }
                println!(
                    "ok: {} frames, {:.2}s, {} Hz, {:.1} fps\n  peak  low {:.2}  loud {:.2}  flux {:.2}  centroid {:.2}",
                    tl.frames.len(),
                    tl.duration(),
                    tl.sample_rate as u32,
                    tl.rate_hz,
                    peak.low,
                    peak.loud,
                    peak.flux,
                    peak.centroid,
                );
            }
            Err(e) => die(format!("analyze: {e}")),
        }
        return;
    }

    nannou::app(model).update(update).exit(on_exit).run();
}

fn die(msg: impl std::fmt::Display) -> ! {
    eprintln!("error: {msg}");
    std::process::exit(1);
}

/// FNV-1a 64 — stable string hash to fold a file name into the visual seed.
fn fnv1a(s: &str) -> u64 {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for b in s.bytes() {
        h ^= b as u64;
        h = h.wrapping_mul(0x0000_0100_0000_01B3);
    }
    h
}

fn model(app: &App) -> Model {
    if let Err(e) = app
        .new_window()
        .size(W as u32, H as u32)
        .title("living sigil")
        .view(view)
        .key_pressed(key_pressed)
        .build()
    {
        die(format!("window: {e}"));
    }

    // Parse args:
    //   [--render [out.mp4]] [--mode sigil|scope] [<index>|monitor|loopback|<file>]
    let args: Vec<String> = std::env::args().skip(1).collect();
    let render = args.iter().any(|a| a == "--render");
    let mode_sel = args
        .iter()
        .position(|a| a == "--mode")
        .and_then(|i| args.get(i + 1))
        .cloned();
    // Drop the flags (and --mode's value) so what remains is [out.mp4?] <src>.
    let rest: Vec<&String> = {
        let mut skip = false;
        args.iter()
            .filter(|a| {
                if skip {
                    skip = false;
                    return false;
                }
                match a.as_str() {
                    "--render" => false,
                    "--mode" => {
                        skip = true;
                        false
                    }
                    _ => true,
                }
            })
            .collect()
    };

    // Config (and thus the render encode settings) must load before we spawn
    // ffmpeg for a render.
    let cfg = app
        .project_path()
        .map(|p| p.join("sigil.cfg"))
        .unwrap_or_else(|_| PathBuf::from("sigil.cfg"));
    let g = Gains::load(&cfg);

    let mode = if render {
        // rest = [out.mp4?] <file>. Last arg must be the audio file.
        let file = rest
            .last()
            .map(|s| PathBuf::from(s.as_str()))
            .filter(|p| p.exists())
            .unwrap_or_else(|| die("--render needs an existing audio file as last arg"));
        let out = if rest.len() >= 2 {
            PathBuf::from(rest[0].as_str())
        } else {
            file.with_extension("mp4")
        };
        eprintln!("analysing {} ...", file.display());
        let tl = audio::analyze_file(&file).unwrap_or_else(|e| die(format!("analyze: {e}")));
        let total = (tl.duration() * RENDER_FPS).ceil() as u64;
        let res = Post::res() as u32;
        let scaled = g.out_scale != 0 && g.out_scale != res;
        eprintln!(
            "render: {:.1}s, {} frames @ {} fps -> {} ({}p, crf {}, {})",
            tl.duration(),
            total,
            RENDER_FPS as u32,
            out.display(),
            if scaled { g.out_scale } else { res },
            g.crf,
            g.x264.as_str(),
        );

        // One long-lived ffmpeg: raw RGBA frames in over stdin, original audio
        // muxed from the file. No PNG sequence / temp dir.
        let mut cmd = Command::new("ffmpeg");
        cmd.args([
            "-y",
            "-f",
            "rawvideo",
            "-pix_fmt",
            "rgba",
            "-s",
            &format!("{res}x{res}"),
            "-r",
            &format!("{}", RENDER_FPS as u32),
            "-i",
            "-",
        ]);
        cmd.arg("-i").arg(&file);
        cmd.args(["-map", "0:v:0", "-map", "1:a:0"]);
        if scaled {
            cmd.args(["-vf", &format!("scale={s}:{s}", s = g.out_scale)]);
        }
        cmd.args([
            "-c:v",
            "libx264",
            "-preset",
            g.x264.as_str(),
            "-crf",
            &g.crf.to_string(),
            "-pix_fmt",
            "yuv420p",
            "-c:a",
            "aac",
            "-b:a",
            "320k",
            "-shortest",
        ]);
        cmd.arg(&out).stdin(Stdio::piped());
        let mut ff = cmd
            .spawn()
            .unwrap_or_else(|e| die(format!("ffmpeg spawn failed ({e}); is it on PATH?")));
        let stdin = ff.stdin.take();
        Mode::Render {
            tl,
            frame: 0,
            total,
            ff,
            stdin,
            out,
        }
    } else {
        let src = match rest.first().map(|s| s.as_str()) {
            None => Source::Capture(None),
            Some("monitor") => Source::CaptureNamed("monitor".into()),
            Some("loopback") => Source::CaptureNamed("loopback".into()),
            Some(a) => match a.parse::<usize>() {
                Ok(i) => Source::Capture(Some(i)),
                Err(_) => Source::File(a.into()),
            },
        };
        Mode::Live(audio::start(src).unwrap_or_else(|e| die(format!("audio: {e}"))))
    };

    // Mix the audio-file name into the seed so each track looks distinct by
    // default (R still reseeds; capture sources keep the constant seed).
    let src_name: Option<&str> = if render {
        rest.last().map(|s| s.as_str())
    } else {
        match rest.first().map(|s| s.as_str()) {
            Some("monitor") | Some("loopback") | None => None,
            Some(a) if a.parse::<usize>().is_ok() => None,
            Some(a) => Some(a),
        }
    };
    let seed = SEED ^ src_name.map(fnv1a).unwrap_or(0);

    let visual = match mode_sel.as_deref() {
        Some("scope") => Visual::Scope(Scope::new(seed)),
        Some("breakcore") => {
            Visual::Breakcore(Box::new(Breakcore::new(seed, app.main_window().device())))
        }
        Some("sigil") | None => Visual::Sigil(Sigil::new(seed)),
        Some(other) => die(format!("unknown --mode {other:?} (sigil|scope|breakcore)")),
    };

    let post = Post::new(app.main_window().device());

    Model {
        visual,
        post,
        mode,
        g,
        cfg,
        hud: true,
        fullscreen: false,
        ca_env: 0.0,
        last: Bands::default(),
    }
}

fn key_pressed(app: &App, m: &mut Model, key: Key) {
    let g = &mut m.g;
    match key {
        Key::R => {
            let s = (app.duration.since_start.as_nanos() as u64) ^ 0x9E37_79B9_7F4A_7C15;
            m.visual.reseed(s);
            println!("seed = {:#018x}", s);
        }
        Key::M => {
            // Mode switch only makes sense live; a render is a fixed pass.
            if matches!(m.mode, Mode::Live(_)) {
                m.visual.cycle(app.main_window().device());
                println!("mode = {}", m.visual.name());
            }
        }
        Key::P => {
            let path = app
                .project_path()
                .map(|p| p.join(format!("{}_{:016x}.png", m.visual.name(), m.visual.seed())))
                .unwrap_or_else(|_| PathBuf::from("sigil.png"));
            let window = app.main_window();
            let (device, queue) = (window.device(), window.queue());
            // Breakcore bypasses Post — grab its own raymarch target, not the
            // (never-written) Post accumulator.
            let res = Post::res() as u32;
            let saved = if m.visual.is_gpu() {
                m.visual.capture_raw(device, queue).and_then(|px| {
                    nannou::image::RgbaImage::from_raw(res, res, px)
                        .ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))?
                        .save(&path)
                        .map_err(Into::into)
                })
            } else {
                m.post.capture_png(device, queue, &path)
            };
            match saved {
                Ok(()) => println!("saved {}", path.display()),
                Err(e) => eprintln!("save failed: {e}"),
            }
        }
        Key::F => {
            m.fullscreen = !m.fullscreen;
            app.main_window().set_fullscreen(m.fullscreen);
        }
        Key::H => m.hud = !m.hud,
        Key::G => g.glow = !g.glow,
        Key::B => g.feedback = !g.feedback,
        Key::C => g.save(&m.cfg),
        Key::Key1 => g.low = (g.low - 0.1).max(0.0),
        Key::Key2 => g.low += 0.1,
        Key::Key3 => g.warp = (g.warp - 0.1).max(0.0),
        Key::Key4 => g.warp += 0.1,
        Key::Key5 => g.fade = (g.fade - 0.02).max(0.0),
        Key::Key6 => g.fade = (g.fade + 0.02).min(1.0),
        Key::Key7 => g.zoom = (g.zoom - 0.002).max(1.0),
        Key::Key8 => g.zoom += 0.002,
        Key::Key9 => g.ca = (g.ca - 1.0).max(0.0),
        Key::Key0 => g.ca += 1.0,
        Key::Minus => g.seg = g.seg.saturating_sub(1).max(2),
        Key::Equals => g.seg = (g.seg + 1).min(24),
        Key::LBracket => g.drive = (g.drive - 0.1).max(0.0),
        Key::RBracket => g.drive = (g.drive + 0.1).min(3.0),
        _ => {}
    }
}

fn update(app: &App, m: &mut Model, upd: Update) {
    // Pull this frame's analysis + the simulation time step.
    let (b, dt) = match &mut m.mode {
        Mode::Live(h) => (h.bands(), upd.since_last.as_secs_f32().clamp(0.0, 0.05)),
        Mode::Render {
            tl, frame, total, ..
        } => {
            if *frame >= *total {
                // All frames piped — quit; on_exit closes the pipe so ffmpeg
                // flushes its trailer and we wait on it there.
                app.quit();
                return;
            }
            let t = *frame as f32 / RENDER_FPS;
            *frame += 1;
            (tl.at(t), 1.0 / RENDER_FPS)
        }
    };
    m.last = b;

    // Audio-driven motion. The visual's own update grows/morphs/restructures.
    m.visual.update(&b, dt);
    m.ca_env += (b.flux - m.ca_env) * if b.flux > m.ca_env { 0.55 } else { 0.10 };

    let pal = Palette::from_audio(&b);
    let fit = Post::res() / W;
    let scale = 1.0 + (b.low * m.g.low).min(0.9) + b.low_on * 0.4;
    let warp = m.g.warp * (5.0 + 24.0 * b.mid + 13.0 * b.low);
    let ca_px = (m.g.ca * m.ca_env).min(28.0);

    let window = app.main_window();
    let device = window.device();
    let queue = window.queue();

    if m.visual.is_gpu() {
        // Breakcore renders through its own raymarch pipeline; no Draw/Post.
        m.visual.render_gpu(
            device, queue, &pal, scale, warp, m.g.feedback, m.g.fade, ca_px, m.g.drive,
        );
    } else {
        // Build the scene off-screen, then push it through the feedback chain.
        let scene = Draw::new();
        if ca_px > 0.4 {
            // Per-channel offset passes, summed additively -> RGB split fringe.
            let add = scene.color_blend(ADDITIVE);
            for (tint, dx) in [
                ([1.0, 0.0, 0.0], -ca_px),
                ([0.0, 1.0, 0.0], 0.0),
                ([0.0, 0.0, 1.0], ca_px),
            ] {
                let d = add.xy(vec2(dx, 0.0));
                m.visual
                    .draw(&d, &pal, fit, scale, warp, m.g.glow, m.g.seg, tint);
            }
        } else {
            m.visual
                .draw(&scene, &pal, fit, scale, warp, m.g.glow, m.g.seg, [1.0; 3]);
        }
        if m.g.feedback {
            m.post
                .render(device, queue, &scene, pal.bg(), m.g.fade, m.g.zoom);
        } else {
            m.post.render_direct(device, queue, &scene);
        }
    }

    // Offline: read this frame back synchronously and stream it into ffmpeg.
    if matches!(m.mode, Mode::Render { .. }) {
        let cap = if m.visual.is_gpu() {
            m.visual.capture_raw(device, queue)
        } else {
            m.post.capture_raw(device, queue)
        };
        match cap {
            Ok(buf) => {
                if let Mode::Render { stdin, .. } = &mut m.mode {
                    if let Some(si) = stdin.as_mut() {
                        if let Err(e) = si.write_all(&buf) {
                            eprintln!("ffmpeg pipe broke ({e}); finalising early");
                            *stdin = None;
                            app.quit();
                        }
                    }
                }
            }
            Err(e) => eprintln!("frame capture failed: {e}"),
        }
    }
}

fn view(app: &App, m: &Model, frame: Frame) {
    let win = app.window_rect();
    let bg = match &m.mode {
        Mode::Render { tl, frame: f, .. } => Palette::from_audio(&tl.at(*f as f32 / RENDER_FPS)),
        _ => Palette::from_audio(&m.last),
    }
    .bg();

    let d = app.draw();
    d.background().color(srgb(bg[0], bg[1], bg[2]));
    // Present the accumulator over the field, downsampled to the window
    // (cheap AA). It is a bounded convex composite, so a plain over-draw is
    // correct; transparent regions (direct mode) fall through to bg.
    // Breakcore presents its own raymarch target instead of the Post chain.
    let tex = if m.visual.is_gpu() {
        m.visual.current_tex()
    } else {
        m.post.current()
    };
    d.texture(tex).w_h(win.w(), win.h());

    // Subtle vignette: concentric edge-stroked rects darkening outward.
    for i in 0..6 {
        let inset = i as f32 * 26.0;
        let a = (i as f32 / 6.0).powi(2) * 0.5;
        d.rect()
            .x_y(0.0, 0.0)
            .w_h(win.w() - inset, win.h() - inset)
            .no_fill()
            .stroke_weight(28.0)
            .stroke(srgba(bg[0], bg[1], bg[2], a));
    }

    if m.hud {
        let g = m.g;
        let extra = match &m.mode {
            Mode::Render { frame: f, total, .. } => {
                format!("RENDER {}/{}", f, total)
            }
            Mode::Live(_) => format!("fps {:.0}", app.fps()),
        };
        let txt = format!(
            "{} · seed {:#x} · n {}\nlow {:.2} warp {:.2} fade {:.2} bloom {:.3} ca {:.0} drive {:.1} seg {}\nglow {} feedback {}  {}",
            m.visual.name(),
            m.visual.seed(),
            m.visual.count(),
            g.low,
            g.warp,
            g.fade,
            g.zoom,
            g.ca,
            g.drive,
            g.seg,
            g.glow,
            g.feedback,
            extra,
        );
        d.text(&txt)
            .font_size(13)
            .left_justify()
            .x_y(win.left() + 220.0, win.top() - 40.0)
            .color(srgba(0.65, 0.74, 0.82, 0.8));
    }

    // (Render frames are captured + piped to ffmpeg in `update`, not here.)
    d.to_frame(app, &frame).unwrap();
}

// Finalise a render: close the frame pipe so ffmpeg flushes its trailer,
// then wait for it. (For Live this is a no-op.) Reached both on normal
// completion and on an early quit, so a partial render still produces a
// playable file.
fn on_exit(_app: &App, m: Model) {
    let Mode::Render {
        mut ff, stdin, out, ..
    } = m.mode
    else {
        return;
    };
    drop(stdin); // EOF on ffmpeg's stdin -> it encodes the tail and exits
    eprintln!("finalising {} ...", out.display());
    match ff.wait() {
        Ok(s) if s.success() => eprintln!("done: {}", out.display()),
        Ok(s) => eprintln!("ffmpeg exited {s}; {} may be incomplete", out.display()),
        Err(e) => eprintln!("waiting on ffmpeg failed: {e}"),
    }
}
