shadertoy-cli 2.4.2

Agent-friendly ShaderToy project, rendering, debugging, and live-preview CLI
#![cfg(target_os = "linux")]

use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

struct TempRoot(PathBuf);

impl TempRoot {
    fn new(label: &str) -> Self {
        static NEXT: AtomicU64 = AtomicU64::new(0);
        let path = std::env::temp_dir().join(format!(
            "shadertoy-cli-agent-workflows-{label}-{}-{}",
            std::process::id(),
            NEXT.fetch_add(1, Ordering::Relaxed)
        ));
        let _ = std::fs::remove_dir_all(&path);
        std::fs::create_dir_all(&path).expect("create temporary test directory");
        Self(path)
    }
}

impl Drop for TempRoot {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

fn shadertoy(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_shadertoy"))
        .args(args)
        .output()
        .expect("run shadertoy")
}

fn write_color_project(root: &Path, name: &str, gain: f32, with_unused: bool) {
    std::fs::create_dir_all(root.join("shaders")).expect("create shaders");
    let unused = if with_unused {
        r#"
[[pass]]
name = "unused"
kind = "buffer"
source = "shaders/unused.frag"
"#
    } else {
        ""
    };
    std::fs::write(
        root.join("ShaderToy.toml"),
        format!(
            r#"format = 1

[project]
name = "{name}"

[render]
width = 8
height = 8
fps = 60.0
preview_time = 0.0

[[uniform]]
name = "gain"
type = "float"
default = {gain}
min = 0.0
max = 1.0

[[pass]]
name = "buffer"
kind = "buffer"
source = "shaders/buffer.frag"
width = 8
height = 8

[[pass]]
name = "image"
kind = "image"
source = "shaders/image.frag"

[[pass.input]]
channel = 0
source = "buffer"
kind = "pass"
frame = "current"
filter = "nearest"
wrap = "clamp"
{unused}
"#
        ),
    )
    .expect("write manifest");
    std::fs::write(
        root.join("shaders/buffer.frag"),
        "void mainImage(out vec4 c, in vec2 p) { c = vec4(gain, 0.0, 0.0, 1.0); }\n",
    )
    .expect("write buffer");
    std::fs::write(
        root.join("shaders/image.frag"),
        "void mainImage(out vec4 c, in vec2 p) { c = texture(iChannel0, p / iResolution.xy); }\n",
    )
    .expect("write image");
    if with_unused {
        std::fs::write(
            root.join("shaders/unused.frag"),
            "void mainImage(out vec4 c, in vec2 p) { c = vec4(0.0); }\n",
        )
        .expect("write unused");
    }
}

#[test]
fn graph_exports_dot_and_pedantic_check_surfaces_unreachable_pass() {
    let temp = TempRoot::new("graph");
    let project = temp.0.join("project");
    write_color_project(&project, "graph-test", 0.25, true);
    let project_arg = project.to_string_lossy().into_owned();
    let dot = temp.0.join("graph.dot");
    let dot_arg = dot.to_string_lossy().into_owned();

    let graph = shadertoy(&[
        "--json",
        "graph",
        "--project",
        &project_arg,
        "--dot",
        &dot_arg,
    ]);
    assert!(graph.status.success(), "{graph:?}");
    let report: serde_json::Value =
        serde_json::from_slice(&graph.stdout).expect("parse graph json");
    assert!(
        report["diagnostics"]
            .as_array()
            .unwrap()
            .iter()
            .any(|diagnostic| diagnostic["code"] == "unreachable-pass")
    );
    assert!(
        std::fs::read_to_string(dot)
            .unwrap()
            .contains("digraph ShaderToy")
    );

    let checked = shadertoy(&["--json", "check", "--project", &project_arg, "--pedantic"]);
    assert_eq!(checked.status.code(), Some(1), "{checked:?}");
    let report: serde_json::Value =
        serde_json::from_slice(&checked.stdout).expect("parse check json");
    assert_eq!(report["compiled"], true);
    assert!(report["warnings"].as_u64().unwrap() >= 1);
}

#[test]
fn experiment_compares_arbitrary_projects_with_metrics() {
    let temp = TempRoot::new("experiment");
    let baseline = temp.0.join("baseline");
    let candidate = temp.0.join("candidate");
    write_color_project(&baseline, "baseline", 0.2, false);
    write_color_project(&candidate, "candidate", 0.8, false);

    let baseline_source = format!("project:{}", baseline.display());
    let candidate_source = format!("project:{}", candidate.display());
    let output = temp.0.join("experiment");
    let output_arg = output.to_string_lossy().into_owned();
    let run = shadertoy(&[
        "--json",
        "experiment",
        "--baseline",
        &baseline_source,
        "--candidate",
        &candidate_source,
        "--frames",
        "0",
        "--profile-samples",
        "0",
        "--output-dir",
        &output_arg,
    ]);
    assert!(run.status.success(), "{run:?}");
    let report: serde_json::Value =
        serde_json::from_slice(&run.stdout).expect("parse experiment json");
    assert_eq!(report["variant_count"], 2);
    let comparison = &report["comparisons"][0]["aggregate"];
    assert!(comparison["rmse_mean"].as_f64().unwrap() > 0.1);
    assert!(comparison["ssim_mean"].as_f64().unwrap() < 1.0);
    assert!(output.join("contact-sheet.png").is_file());
    assert!(output.join("experiment-report.json").is_file());
}

#[test]
fn trace_capture_inspect_and_replay_are_self_contained() {
    let temp = TempRoot::new("trace");
    let project = temp.0.join("project");
    write_color_project(&project, "trace-test", 0.375, false);
    let project_arg = project.to_string_lossy().into_owned();
    let trace = temp.0.join("capture.sttrace");
    let trace_arg = trace.to_string_lossy().into_owned();

    let capture = shadertoy(&[
        "--json",
        "trace",
        "capture",
        "--project",
        &project_arg,
        "--frame",
        "2",
        "--set",
        "gain=0.75",
        "--include-intermediates",
        "-o",
        &trace_arg,
    ]);
    assert!(capture.status.success(), "{capture:?}");
    assert!(trace.join("trace.json").is_file());
    assert!(trace.join("project.sttf").is_file());
    assert!(trace.join("state.ststate").is_file());

    let inspect = shadertoy(&["--json", "trace", "inspect", &trace_arg]);
    assert!(inspect.status.success(), "{inspect:?}");
    let inspected: serde_json::Value =
        serde_json::from_slice(&inspect.stdout).expect("parse trace inspect json");
    assert_eq!(inspected["verified"], true);

    let replay = shadertoy(&["--json", "trace", "replay", &trace_arg]);
    assert!(replay.status.success(), "{replay:?}");
    let replayed: serde_json::Value =
        serde_json::from_slice(&replay.stdout).expect("parse trace replay json");
    assert_eq!(replayed["exact"], true);
    assert_eq!(replayed["state_valid"], true);
}

#[test]
fn test_supports_uniform_comparisons_gpu_budgets_and_state_roundtrip() {
    let temp = TempRoot::new("test-framework");
    let project = temp.0.join("project");
    write_color_project(&project, "test-framework", 0.25, false);
    let manifest = project.join("ShaderToy.toml");
    let mut text = std::fs::read_to_string(&manifest).unwrap();
    text.push_str(
        r#"

[[test]]
name = "variant-diff"
pass = "buffer"
frame = 0
uniforms = { gain = 0.8 }
reference_uniforms = { gain = 0.2 }
min_rmse = 0.1
max_gpu_ms = 1000.0
max_pass_gpu_ms = { buffer = 1000.0, image = 1000.0 }
assert_state_roundtrip = true
assert_deterministic = true
"#,
    );
    std::fs::write(&manifest, text).unwrap();
    let project_arg = project.to_string_lossy().into_owned();

    let tested = shadertoy(&["--json", "test", "--project", &project_arg, "--ci"]);
    assert!(tested.status.success(), "{tested:?}");
    let report: serde_json::Value =
        serde_json::from_slice(&tested.stdout).expect("parse test json");
    assert_eq!(report["passed"], 1);
    assert!(report["cases"][0]["rmse"].as_f64().unwrap() > 0.1);
    assert_eq!(report["cases"][0]["state_roundtrip"], true);
    assert!(report["cases"][0]["gpu_total_ms"].as_f64().unwrap() >= 0.0);
}

#[test]
fn test_supports_exact_ssbo_fixtures() {
    let temp = TempRoot::new("test-storage-fixture");
    let project = temp.0.join("project");
    std::fs::create_dir_all(project.join("shaders")).unwrap();
    std::fs::create_dir_all(project.join("tests")).unwrap();
    std::fs::write(
        project.join("ShaderToy.toml"),
        r#"format = 1

[project]
name = "storage-fixture"

[render]
width = 1
height = 1
fps = 60.0
preview_time = 0.0

[[pass]]
name = "writer"
kind = "buffer"
source = "shaders/writer.frag"
width = 1
height = 1

[[pass.storage]]
binding = 0
name = "shared-data"
size = 16

[[pass]]
name = "image"
kind = "image"
source = "shaders/image.frag"

[[pass.input]]
channel = 0
source = "writer"
kind = "pass"
frame = "current"
filter = "nearest"
wrap = "clamp"

[[test]]
name = "ssbo-fixture"
pass = "writer"
frame = 0
assert_state_roundtrip = true

[[test.storage]]
name = "shared-data"
reference = "tests/shared-data.bin"
"#,
    )
    .unwrap();
    std::fs::write(
        project.join("shaders/writer.frag"),
        r#"layout(std430, binding = 0) buffer SharedData { vec4 value; };
void mainImage(out vec4 c, in vec2 p) {
    value = vec4(0.125, 0.25, 0.5, 1.0);
    c = value;
}
"#,
    )
    .unwrap();
    std::fs::write(
        project.join("shaders/image.frag"),
        "void mainImage(out vec4 c, in vec2 p) { c = texture(iChannel0, vec2(0.5)); }\n",
    )
    .unwrap();
    let mut fixture = Vec::new();
    for value in [0.125f32, 0.25, 0.5, 1.0] {
        fixture.extend_from_slice(&value.to_le_bytes());
    }
    std::fs::write(project.join("tests/shared-data.bin"), fixture).unwrap();

    let project_arg = project.to_string_lossy().into_owned();
    let tested = shadertoy(&["--json", "test", "--project", &project_arg, "--ci"]);
    assert!(tested.status.success(), "{tested:?}");
    let report: serde_json::Value =
        serde_json::from_slice(&tested.stdout).expect("parse storage fixture test json");
    assert_eq!(report["passed"], 1);
    assert_eq!(report["cases"][0]["storage_assertions"], 1);
    assert_eq!(report["cases"][0]["state_roundtrip"], true);
}