shadertoy-cli 2.2.9

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

#[allow(clippy::too_many_arguments)]
pub(super) fn reload(
    root: &Path,
    runtime: &mut Runtime<'_>,
    loaded: &mut Option<LoadedManifest>,
    sources: &mut Option<SourceGraph>,
    width: &mut u32,
    height: &mut u32,
    fps: &mut f32,
    view: &mut String,
    uniform_values: &mut BTreeMap<String, crate::uniforms::UniformValue>,
    preserve_state: bool,
) -> Result<()> {
    let previous_uniform_values = preserve_state.then(|| uniform_values.clone());
    let saved = if preserve_state {
        loaded
            .as_ref()
            .map(|current| save_runtime_state(runtime, current, *width, *height, *fps))
            .transpose()?
    } else {
        None
    };

    let candidate = LoadedManifest::load(root)?;
    ensure_source_files_exist(&candidate)?;
    let (project, candidate_sources) = build_native_project_with_sources(&candidate)?;
    runtime.load_project(&project)?;
    let mut next_uniform_values = crate::uniforms::defaults(&candidate.manifest.uniforms);
    if let Some(previous) = previous_uniform_values {
        for (name, value) in previous {
            if let Some(definition) = candidate
                .manifest
                .uniforms
                .iter()
                .find(|definition| definition.name() == name)
                && definition.validate_value(&value).is_ok()
            {
                next_uniform_values.insert(name, value);
            }
        }
    }
    crate::uniforms::apply_to_runtime(runtime, &next_uniform_values)?;
    *uniform_values = next_uniform_values;

    if loaded.is_none() {
        *width = candidate.manifest.render.width;
        *height = candidate.manifest.render.height;
        *fps = candidate.manifest.render.fps;
    }

    if let Some(saved) = saved {
        runtime.set_fixed_state(saved.time, saved.frame, saved.fps)?;
        for (name, (saved_dimensions, data)) in saved.buffers {
            if let Some(pass) = candidate.manifest.passes.iter().find(|pass| {
                pass.name == name && matches!(pass.kind, PassKind::Buffer | PassKind::Compute)
            }) {
                let (pass_width, pass_height) =
                    candidate.manifest.pass_dimensions(pass, *width, *height);
                if (pass_width, pass_height) == (saved_dimensions.width, saved_dimensions.height) {
                    let _ = runtime.restore_pass_rgba32f(&name, pass_width, pass_height, &data);
                }
            }
        }
    }

    let final_pass = candidate.manifest.final_pass().name.clone();
    if !candidate
        .manifest
        .passes
        .iter()
        .any(|pass| pass.name == *view)
    {
        *view = final_pass;
    }
    *loaded = Some(candidate);
    *sources = Some(candidate_sources);
    Ok(())
}

pub(super) fn reload_changed_sources(
    root: &Path,
    runtime: &mut Runtime<'_>,
    loaded: &LoadedManifest,
    sources: &mut SourceGraph,
    changed: &BTreeSet<PathBuf>,
) -> Result<bool> {
    let manifest_path = root.join(crate::manifest::MANIFEST_NAME);
    if changed.iter().any(|path| same_path(path, &manifest_path)) {
        return Ok(false);
    }

    let mut impacted = Vec::new();
    for pass in &loaded.manifest.passes {
        let Some(current) = sources.get(&pass.name) else {
            return Ok(false);
        };
        if changed.iter().any(|path| {
            current
                .dependencies
                .iter()
                .any(|dependency| same_path(path, dependency))
        }) {
            impacted.push(pass);
        }
    }

    if impacted.is_empty() {
        let source_like = changed.iter().all(|path| {
            matches!(
                path.extension().and_then(|extension| extension.to_str()),
                Some("glsl" | "frag" | "vert" | "comp")
            )
        });
        return Ok(source_like);
    }

    let mut replacements = Vec::with_capacity(impacted.len());
    for pass in impacted {
        replacements.push((pass.name.clone(), expand_pass(loaded, pass)?));
    }

    let mut applied: Vec<String> = Vec::new();
    for (name, replacement) in &replacements {
        if let Err(error) = runtime.reload_pass_source(name, &replacement.text) {
            for previous_name in applied.iter().rev() {
                if let Some(previous) = sources.get(previous_name) {
                    let _ = runtime.reload_pass_source(previous_name, &previous.text);
                }
            }
            return Err(error.into());
        }
        applied.push(name.clone());
    }
    for (name, replacement) in replacements {
        sources.insert(name, replacement);
    }
    Ok(true)
}

fn same_path(left: &Path, right: &Path) -> bool {
    let normalize = |path: &Path| fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
    normalize(left) == normalize(right)
}

fn save_runtime_state(
    runtime: &mut Runtime<'_>,
    loaded: &LoadedManifest,
    width: u32,
    height: u32,
    fps: f32,
) -> Result<SavedRuntimeState> {
    let mut buffers = BTreeMap::new();
    for pass in &loaded.manifest.passes {
        if !matches!(pass.kind, PassKind::Buffer | PassKind::Compute) {
            continue;
        }
        let (pass_width, pass_height) = loaded.manifest.pass_dimensions(pass, width, height);
        if let Ok(data) = runtime.snapshot_pass_rgba32f(&pass.name, pass_width, pass_height) {
            buffers.insert(
                pass.name.clone(),
                (
                    crate::state::BufferDimensions {
                        width: pass_width,
                        height: pass_height,
                    },
                    data,
                ),
            );
        }
    }
    Ok(SavedRuntimeState {
        time: runtime.time(),
        frame: runtime.frame(),
        fps,
        buffers,
    })
}

#[allow(clippy::too_many_arguments)]
pub(super) fn update_status(
    shared: &Shared,
    loaded: Option<&LoadedManifest>,
    runtime: &Runtime<'_>,
    width: u32,
    height: u32,
    fps: f32,
    paused: bool,
    view: &str,
    uniform_values: &BTreeMap<String, crate::uniforms::UniformValue>,
    error: Option<String>,
    increment_sequence: bool,
) {
    let mut status = shared.status.write().expect("preview status lock poisoned");
    if let Some(loaded) = loaded {
        status.project = loaded.manifest.project.name.clone();
        status.final_pass = loaded.manifest.final_pass().name.clone();
        status.passes = loaded
            .manifest
            .passes
            .iter()
            .filter(|pass| !matches!(pass.kind, PassKind::Cubemap | PassKind::Sound))
            .map(|pass| pass.name.clone())
            .collect();
        status.uniforms = loaded
            .manifest
            .uniforms
            .iter()
            .map(|definition| PreviewUniformStatus {
                name: definition.name().to_string(),
                kind: definition.kind_name().to_string(),
                value: uniform_values
                    .get(definition.name())
                    .cloned()
                    .unwrap_or_else(|| definition.default_value()),
                min: definition.preview_min(),
                max: definition.preview_max(),
                step: definition.preview_step(),
            })
            .collect();
        status.webcam = crate::media::manifest_uses_webcam(loaded);
    }
    status.frame = runtime.frame();
    status.time = runtime.time();
    status.paused = paused;
    status.time_scale = runtime.time_scale();
    status.width = width;
    status.height = height;
    status.fps = fps;
    status.view = view.to_string();
    if error.is_some() {
        status.error = error;
    } else if increment_sequence {
        status.error = None;
    }
    if increment_sequence {
        status.sequence = status.sequence.wrapping_add(1);
    }
    broadcast_status(shared, &status);
}

pub(super) fn set_error(shared: &Shared, message: String) {
    let mut status = shared.status.write().expect("preview status lock poisoned");
    status.error = Some(message);
    broadcast_status(shared, &status);
}

pub(super) fn clear_error(shared: &Shared) {
    let mut status = shared.status.write().expect("preview status lock poisoned");
    status.error = None;
    broadcast_status(shared, &status);
}

fn broadcast_status(shared: &Shared, status: &PreviewStatus) {
    if let Ok(message) = serde_json::to_string(status) {
        let _ = shared.updates.send(message);
    }
}

pub(super) fn reload_event_kind(kind: &notify::EventKind) -> bool {
    matches!(
        kind,
        notify::EventKind::Any
            | notify::EventKind::Create(_)
            | notify::EventKind::Modify(_)
            | notify::EventKind::Remove(_)
    )
}

pub(super) fn relevant_watch_path(root: &Path, path: &Path) -> bool {
    let Ok(relative) = path.strip_prefix(root) else {
        return false;
    };
    let Some(first) = relative.components().next() else {
        return false;
    };
    let first = first.as_os_str().to_string_lossy();
    if matches!(first.as_ref(), "target" | ".git" | ".shadertoy") {
        return false;
    }
    true
}