shadertoy-cli 2.2.5

Agent-friendly ShaderToy project, rendering, debugging, and live-preview CLI
use super::*;
use crate::uniforms::UniformDefinition;
use std::collections::HashSet;

const MAX_SWEEP_VARIANTS: usize = 256;

#[derive(Debug, Clone)]
struct SweepDimension {
    name: String,
    values: Vec<String>,
}

pub fn sweep_project(options: &SweepOptions) -> Result<Output> {
    let loaded = LoadedManifest::load(&options.project)?;
    ensure_source_files_exist(&loaded)?;
    let media = crate::media::MediaInputs::new_headless(&loaded)?;
    let dimensions = parse_sweep_dimensions(&loaded.manifest.uniforms, &options.sweep_uniforms)?;
    let variants = expand_variants(&dimensions)?;
    let (width, height) = render::resolve_dimensions(&loaded, None, options.width, options.height)?;
    let fps = render::resolve_fps(&loaded, None, options.fps)?;
    let target_frame = resolve_target_frame(&loaded, None, options.frame, options.time, fps)?;

    let selected_pass = options
        .pass
        .as_deref()
        .unwrap_or(&loaded.manifest.final_pass().name);
    let pass = loaded
        .manifest
        .passes
        .iter()
        .find(|pass| pass.name == selected_pass)
        .with_context(|| format!("unknown render pass '{selected_pass}'"))?;
    if matches!(pass.kind, PassKind::Cubemap | PassKind::Sound) {
        bail!("sweep only supports the final image and 2D buffer/compute passes");
    }
    let (selected_width, selected_height) = loaded.manifest.pass_dimensions(pass, width, height);

    let output_dir = options
        .output_dir
        .clone()
        .unwrap_or_else(|| loaded.root.join("target/sweep"));
    fs::create_dir_all(&output_dir)
        .with_context(|| format!("failed to create {}", output_dir.display()))?;

    let contact_path = if options.no_contact_sheet {
        None
    } else {
        Some(
            options
                .contact_sheet
                .clone()
                .unwrap_or_else(|| output_dir.join("contact-sheet.png")),
        )
    };
    let mut contact_sheet = contact_path
        .as_ref()
        .map(|path| {
            render::prepare_contact_sheet(
                path,
                variants.len(),
                options.columns,
                selected_width,
                selected_height,
            )
        })
        .transpose()?;

    let context = HeadlessContext::new(64, 64)
        .context("failed to create headless OpenGL context for parameter sweep")?;
    let project = build_native_project(&loaded)?;
    let final_pass = loaded.manifest.final_pass().name.as_str();
    let mut outputs = Vec::with_capacity(variants.len());

    for (index, assignments) in variants.iter().enumerate() {
        let mut runtime = Runtime::new(&context)?;
        runtime.load_project(&project)?;
        let values = crate::uniforms::parse_assignments(&loaded.manifest.uniforms, assignments)?;
        crate::uniforms::apply_to_runtime(&mut runtime, &values)?;
        let final_image =
            render_from_zero(&mut runtime, target_frame, fps, width, height, &[], &media)?
                .context("sweep render did not produce a final image")?;
        let image = if selected_pass == final_pass {
            final_image
        } else {
            runtime.snapshot_pass_rgb(selected_pass, selected_width, selected_height)?
        };
        let path = output_dir.join(format!("variant-{index:03}.png"));
        save_rgb_png(&image, &path)?;
        if let Some(sheet) = &mut contact_sheet {
            sheet.blit(index, &image)?;
        }
        outputs.push(json!({
            "index": index,
            "output": path,
            "set": assignments,
        }));
    }

    let contact_sheet_output = if let Some(sheet) = contact_sheet {
        Some(sheet.save()?)
    } else {
        None
    };
    let names = dimensions
        .iter()
        .map(|dimension| dimension.name.as_str())
        .collect::<Vec<_>>()
        .join(", ");

    Ok(Output {
        human: format!(
            "Rendered {} sweep variants for [{}] at frame {}{} -> {}{}",
            variants.len(),
            names,
            target_frame,
            options
                .pass
                .as_ref()
                .map(|name| format!(" pass '{name}'"))
                .unwrap_or_default(),
            output_dir.display(),
            contact_sheet_output
                .as_ref()
                .map(|path| format!("; contact sheet {}", path.display()))
                .unwrap_or_default(),
        ),
        json: json!({
            "ok": true,
            "project": loaded.manifest.project.name,
            "output_dir": output_dir,
            "contact_sheet": contact_sheet_output,
            "width": selected_width,
            "height": selected_height,
            "fps": fps,
            "frame": target_frame,
            "pass": selected_pass,
            "variant_count": variants.len(),
            "variants": outputs,
        }),
    })
}

fn parse_sweep_dimensions(
    definitions: &[UniformDefinition],
    specs: &[String],
) -> Result<Vec<SweepDimension>> {
    if specs.is_empty() {
        bail!("sweep requires at least one --set NAME=VALUE1,VALUE2,... assignment");
    }
    let mut seen = HashSet::new();
    let mut result = Vec::with_capacity(specs.len());
    for spec in specs {
        let (name, raw) = spec
            .split_once('=')
            .with_context(|| format!("sweep assignment '{spec}' must use NAME=VALUES"))?;
        let name = name.trim();
        if !seen.insert(name.to_string()) {
            bail!("sweep uniform '{name}' was specified more than once");
        }
        let definition = definitions
            .iter()
            .find(|definition| definition.name() == name)
            .with_context(|| format!("unknown custom uniform '{name}'"))?;
        let values = split_values(definition, raw)?;
        result.push(SweepDimension {
            name: name.to_string(),
            values,
        });
    }
    Ok(result)
}

fn split_values(definition: &UniformDefinition, raw: &str) -> Result<Vec<String>> {
    let parts = match definition {
        UniformDefinition::Vec2 { .. }
        | UniformDefinition::Vec3 { .. }
        | UniformDefinition::Vec4 { .. } => raw.split(';').collect::<Vec<_>>(),
        _ => raw.split(',').collect::<Vec<_>>(),
    };
    let mut values = Vec::with_capacity(parts.len());
    for value in parts {
        let value = value.trim();
        if value.is_empty() {
            bail!(
                "sweep uniform '{}' contains an empty value",
                definition.name()
            );
        }
        definition.parse_value(value).map_err(|error| {
            anyhow::anyhow!(
                "invalid sweep value for uniform '{}': {error:#}",
                definition.name()
            )
        })?;
        values.push(value.to_string());
    }
    Ok(values)
}

fn expand_variants(dimensions: &[SweepDimension]) -> Result<Vec<Vec<String>>> {
    let count = dimensions.iter().try_fold(1usize, |count, dimension| {
        count
            .checked_mul(dimension.values.len())
            .context("sweep variant count overflow")
    })?;
    if count > MAX_SWEEP_VARIANTS {
        bail!(
            "sweep expands to {count} variants; reduce values or dimensions (limit {MAX_SWEEP_VARIANTS})"
        );
    }

    let mut variants = vec![Vec::with_capacity(dimensions.len())];
    for dimension in dimensions {
        let mut next = Vec::with_capacity(variants.len() * dimension.values.len());
        for variant in &variants {
            for value in &dimension.values {
                let mut expanded = variant.clone();
                expanded.push(format!("{}={value}", dimension.name));
                next.push(expanded);
            }
        }
        variants = next;
    }
    Ok(variants)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn expands_cartesian_product_and_supports_vector_semicolons() {
        let definitions = vec![
            UniformDefinition::Float {
                name: "gain".into(),
                default: 1.0,
                min: None,
                max: None,
                step: None,
            },
            UniformDefinition::Vec2 {
                name: "wind".into(),
                default: [1.0, 0.0],
                min: None,
                max: None,
                step: None,
            },
        ];
        let dimensions = parse_sweep_dimensions(
            &definitions,
            &["gain=0.5,1.0".into(), "wind=1,0;0,1".into()],
        )
        .unwrap();
        let variants = expand_variants(&dimensions).unwrap();
        assert_eq!(variants.len(), 4);
        assert_eq!(variants[0], ["gain=0.5", "wind=1,0"]);
        assert_eq!(variants[3], ["gain=1.0", "wind=0,1"]);
    }
}