shadertoy-cli 2.2.2

Agent-friendly ShaderToy project, rendering, debugging, and live-preview CLI
use super::*;

pub fn inspect_project(path: &Path, mode: InspectMode) -> Result<Output> {
    let loaded = LoadedManifest::load(path)?;
    let manifest = &loaded.manifest;

    match mode {
        InspectMode::Summary => {
            let human = format!(
                "Project: {}\nRoot: {}\nPasses: {}\nAssets: {}\nFinal output: {}\nRender: {}x{} @ {} fps",
                manifest.project.name,
                loaded.root.display(),
                manifest.passes.len(),
                manifest.assets.len(),
                manifest.final_pass().name,
                manifest.render.width,
                manifest.render.height,
                manifest.render.fps,
            );
            let passes = manifest
                .passes
                .iter()
                .map(|pass| {
                    json!({
                        "name": pass.name,
                        "kind": format!("{:?}", pass.kind).to_lowercase(),
                        "source": pass.source,
                        "inputs": pass.inputs.len(),
                    })
                })
                .collect::<Vec<_>>();
            Ok(Output {
                human,
                json: json!({
                    "ok": true,
                    "project": manifest.project.name,
                    "root": loaded.root,
                    "final_output": manifest.final_pass().name,
                    "render": manifest.render,
                    "passes": passes,
                    "assets": manifest.assets,
                }),
            })
        }
        InspectMode::Graph => {
            let mut human = format!("Project graph: {}\n", manifest.project.name);
            let mut edges = Vec::new();
            for pass in &manifest.passes {
                human.push_str(&format!("  {} ({:?})\n", pass.name, pass.kind));
                for input in &pass.inputs {
                    let kind = manifest.infer_input_kind(input)?;
                    human.push_str(&format!(
                        "    iChannel{} <- {} ({:?}, {:?}, {:?}, {:?})\n",
                        input.channel, input.source, kind, input.frame, input.filter, input.wrap
                    ));
                    edges.push(json!({
                        "from": input.source,
                        "to": pass.name,
                        "channel": input.channel,
                        "kind": format!("{kind:?}").to_lowercase(),
                        "frame": format!("{:?}", input.frame).to_lowercase(),
                        "filter": format!("{:?}", input.filter).to_lowercase(),
                        "wrap": format!("{:?}", input.wrap).to_lowercase(),
                    }));
                }
            }
            Ok(Output {
                human,
                json: json!({
                    "ok": true,
                    "project": manifest.project.name,
                    "passes": manifest.passes,
                    "edges": edges,
                }),
            })
        }
        InspectMode::Pass(name) => {
            let pass = manifest
                .passes
                .iter()
                .find(|pass| pass.name == name)
                .with_context(|| format!("unknown pass '{name}'"))?;
            let human = format!(
                "Pass: {}\nKind: {:?}\nSource: {}\nInputs: {}",
                pass.name,
                pass.kind,
                pass.source,
                pass.inputs.len()
            );
            Ok(Output {
                human,
                json: json!({
                    "ok": true,
                    "project": manifest.project.name,
                    "pass": pass,
                    "is_final": pass.name == manifest.final_pass().name,
                }),
            })
        }
        InspectMode::Channels(name) => {
            let pass = manifest
                .passes
                .iter()
                .find(|pass| pass.name == name)
                .with_context(|| format!("unknown pass '{name}'"))?;
            let mut human = format!("Channels for {}:\n", pass.name);
            let mut channels = Vec::new();
            if pass.inputs.is_empty() {
                human.push_str("  (none)");
            }
            for input in &pass.inputs {
                let kind = manifest.infer_input_kind(input)?;
                human.push_str(&format!(
                    "  iChannel{} <- {} ({:?}, {:?}, {:?}, {:?})\n",
                    input.channel, input.source, kind, input.frame, input.filter, input.wrap
                ));
                channels.push(json!({
                    "channel": input.channel,
                    "source": input.source,
                    "kind": format!("{kind:?}").to_lowercase(),
                    "frame": format!("{:?}", input.frame).to_lowercase(),
                    "filter": format!("{:?}", input.filter).to_lowercase(),
                    "wrap": format!("{:?}", input.wrap).to_lowercase(),
                }));
            }
            Ok(Output {
                human,
                json: json!({
                    "ok": true,
                    "project": manifest.project.name,
                    "pass": pass.name,
                    "channels": channels,
                }),
            })
        }
    }
}

pub fn inspect_buffer(options: &InspectBufferOptions) -> Result<Output> {
    let loaded = LoadedManifest::load(&options.project)?;
    ensure_source_files_exist(&loaded)?;
    let pass = loaded
        .manifest
        .passes
        .iter()
        .find(|pass| pass.name == options.pass)
        .with_context(|| format!("unknown pass '{}'", options.pass))?;
    if !matches!(pass.kind, PassKind::Buffer | PassKind::Compute) {
        bail!("runtime buffer inspection requires a 2D buffer/compute pass");
    }

    let (width, height) =
        super::render::resolve_dimensions(&loaded, None, options.width, options.height)?;
    let fps = super::render::resolve_fps(&loaded, None, options.fps)?;
    let frame = resolve_target_frame(&loaded, None, options.frame, options.time, fps)?;
    let (pass_width, pass_height) = loaded.manifest.pass_dimensions(pass, width, height);

    if let Some((x, y)) = options.pixel
        && (x >= pass_width || y >= pass_height)
    {
        bail!(
            "--pixel {x},{y} is outside pass '{}' dimensions {}x{}",
            pass.name,
            pass_width,
            pass_height
        );
    }

    let context = HeadlessContext::new(64, 64)
        .context("failed to create headless OpenGL context for buffer inspection")?;
    let mut runtime = Runtime::new(&context)?;
    let project = build_native_project(&loaded)?;
    runtime.load_project(&project)?;
    let _ = render_from_zero(&mut runtime, frame, fps, width, height, &[])?;
    let values = runtime.snapshot_pass_rgba32f(&pass.name, pass_width, pass_height)?;

    let stats = BufferStats::from_rgba(&values);
    let pixel = options.pixel.map(|(x, y)| {
        let offset = ((y as usize * pass_width as usize) + x as usize) * 4;
        [
            values[offset],
            values[offset + 1],
            values[offset + 2],
            values[offset + 3],
        ]
    });

    if let Some(path) = &options.output {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        let image = visualize_buffer(
            pass_width,
            pass_height,
            &values,
            options.visualization,
            &stats,
        );
        save_rgb_png(&image, path)?;
    }

    let mut human = format!(
        "Buffer: {}\nFormat: RGBA32F\nResolution: {}x{}\nFrame: {} ({:.3}s)\nNaN: {}  Inf: {}\n",
        pass.name,
        pass_width,
        pass_height,
        runtime.frame(),
        runtime.time(),
        stats.nan_count,
        stats.inf_count,
    );
    for channel in 0..4 {
        human.push_str(&format!(
            "{}: min={:.6} max={:.6} mean={:.6}\n",
            ["R", "G", "B", "A"][channel],
            stats.min[channel],
            stats.max[channel],
            stats.mean[channel],
        ));
    }
    if let (Some((x, y)), Some(value)) = (options.pixel, pixel) {
        human.push_str(&format!(
            "Pixel {x},{y}: [{:.6}, {:.6}, {:.6}, {:.6}]\n",
            value[0], value[1], value[2], value[3]
        ));
    }
    if let Some(path) = &options.output {
        human.push_str(&format!("Visualization: {}\n", path.display()));
    }

    Ok(Output {
        human: human.trim_end().to_string(),
        json: json!({
            "ok": true,
            "project": loaded.manifest.project.name,
            "pass": pass.name,
            "format": format!("{:?}", pass.format).to_lowercase(),
            "width": pass_width,
            "height": pass_height,
            "frame": runtime.frame(),
            "time": runtime.time(),
            "stats": {
                "min": stats.min,
                "max": stats.max,
                "mean": stats.mean,
                "nan": stats.nan_count,
                "inf": stats.inf_count,
            },
            "pixel": options.pixel.map(|coordinates| json!({
                "x": coordinates.0,
                "y": coordinates.1,
                "rgba": pixel.expect("pixel value exists with pixel coordinates"),
            })),
            "output": options.output,
        }),
    })
}

struct BufferStats {
    min: [f32; 4],
    max: [f32; 4],
    mean: [f64; 4],
    finite_count: [u64; 4],
    nan_count: u64,
    inf_count: u64,
}

impl BufferStats {
    fn from_rgba(values: &[f32]) -> Self {
        let mut result = Self {
            min: [f32::INFINITY; 4],
            max: [f32::NEG_INFINITY; 4],
            mean: [0.0; 4],
            finite_count: [0; 4],
            nan_count: 0,
            inf_count: 0,
        };
        for pixel in values.chunks_exact(4) {
            for (channel, value) in pixel.iter().copied().enumerate() {
                if value.is_nan() {
                    result.nan_count += 1;
                } else if value.is_infinite() {
                    result.inf_count += 1;
                } else {
                    result.min[channel] = result.min[channel].min(value);
                    result.max[channel] = result.max[channel].max(value);
                    result.mean[channel] += f64::from(value);
                    result.finite_count[channel] += 1;
                }
            }
        }
        for channel in 0..4 {
            if result.finite_count[channel] == 0 {
                result.min[channel] = f32::NAN;
                result.max[channel] = f32::NAN;
                result.mean[channel] = f64::NAN;
            } else {
                result.mean[channel] /= result.finite_count[channel] as f64;
            }
        }
        result
    }
}

fn visualize_buffer(
    width: u32,
    height: u32,
    values: &[f32],
    requested: InspectVisualization,
    stats: &BufferStats,
) -> RgbImage {
    let visualization = match requested {
        InspectVisualization::Auto
            if stats.min[..3].iter().any(|value| *value < 0.0)
                || stats.max[..3].iter().any(|value| *value > 1.0) =>
        {
            InspectVisualization::Signed
        }
        InspectVisualization::Auto => InspectVisualization::Rgb,
        other => other,
    };

    let signed_scale = stats.min[..3]
        .iter()
        .chain(&stats.max[..3])
        .filter(|value| value.is_finite())
        .map(|value| value.abs())
        .fold(0.0f32, f32::max)
        .max(f32::EPSILON);
    let magnitude_scale = values
        .chunks_exact(4)
        .map(|pixel| {
            let rgb = [
                if pixel[0].is_finite() { pixel[0] } else { 0.0 },
                if pixel[1].is_finite() { pixel[1] } else { 0.0 },
                if pixel[2].is_finite() { pixel[2] } else { 0.0 },
            ];
            (rgb[0] * rgb[0] + rgb[1] * rgb[1] + rgb[2] * rgb[2]).sqrt()
        })
        .fold(0.0f32, f32::max)
        .max(f32::EPSILON);

    let mut pixels = Vec::with_capacity(width as usize * height as usize * 3);
    let to_byte = |value: f32| -> u8 {
        if value.is_nan() {
            return 255;
        }
        (value.clamp(0.0, 1.0) * 255.0).round() as u8
    };
    for pixel in values.chunks_exact(4) {
        let rgb = match visualization {
            InspectVisualization::Auto | InspectVisualization::Rgb => {
                [pixel[0], pixel[1], pixel[2]]
            }
            InspectVisualization::Signed => [
                0.5 + 0.5 * pixel[0] / signed_scale,
                0.5 + 0.5 * pixel[1] / signed_scale,
                0.5 + 0.5 * pixel[2] / signed_scale,
            ],
            InspectVisualization::Magnitude => {
                let magnitude = (pixel[0] * pixel[0] + pixel[1] * pixel[1] + pixel[2] * pixel[2])
                    .sqrt()
                    / magnitude_scale;
                [magnitude; 3]
            }
        };
        pixels.extend(rgb.map(to_byte));
    }
    RgbImage::new(width, height, pixels)
}