shadertoy-cli 2.0.1

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

pub fn render_project(options: &RenderOptions) -> Result<Output> {
    let loaded = LoadedManifest::load(&options.project)?;
    ensure_source_files_exist(&loaded)?;
    let state = options.state.as_ref().map(StateFile::load).transpose()?;

    if let Some(state) = &state
        && state.header.project != loaded.manifest.project.name
    {
        bail!(
            "state belongs to project '{}' but current project is '{}'",
            state.header.project,
            loaded.manifest.project.name
        );
    }

    let (width, height) =
        resolve_dimensions(&loaded, state.as_ref(), options.width, options.height)?;
    let fps = resolve_fps(&loaded, state.as_ref(), options.fps)?;
    let target_frame =
        resolve_target_frame(&loaded, state.as_ref(), options.frame, options.time, fps)?;

    let output = options
        .output
        .clone()
        .unwrap_or_else(|| loaded.root.join("target/render.png"));
    if let Some(parent) = output.parent() {
        fs::create_dir_all(parent)?;
    }

    let overrides = load_overrides(&options.set_buffers, width, height)?;

    let context = HeadlessContext::new(64, 64)
        .context("failed to create headless OpenGL context for rendering")?;
    let mut runtime = Runtime::new(&context)?;
    let project = build_native_project(&loaded)?;
    runtime.load_project(&project)?;

    let final_image = if let Some(state) = &state {
        restore_state(&mut runtime, state)?;
        render_from_restored_state(
            &mut runtime,
            state.header.frame,
            target_frame,
            fps,
            width,
            height,
            &overrides,
        )?
    } else {
        render_from_zero(&mut runtime, target_frame, fps, width, height, &overrides)?
    };

    let requested_pass = options.pass.as_deref();
    let image = match requested_pass {
        None => final_image.context("render did not produce a final image")?,
        Some(name) if name == loaded.manifest.final_pass().name => {
            final_image.context("render did not produce a final image")?
        }
        Some(name) => runtime.snapshot_pass_rgb(name, width, height)?,
    };
    save_rgb_png(&image, &output)?;

    Ok(Output {
        human: format!(
            "Rendered {}x{} frame {} ({:.3}s){} -> {}",
            width,
            height,
            runtime.frame(),
            runtime.time(),
            requested_pass
                .map(|name| format!(" pass '{name}'"))
                .unwrap_or_default(),
            output.display()
        ),
        json: json!({
            "ok": true,
            "project": loaded.manifest.project.name,
            "output": output,
            "width": width,
            "height": height,
            "fps": fps,
            "frame": runtime.frame(),
            "time": runtime.time(),
            "pass": requested_pass.unwrap_or(&loaded.manifest.final_pass().name),
            "state": options.state,
        }),
    })
}

pub(super) fn restore_state(runtime: &mut Runtime<'_>, state: &StateFile) -> Result<()> {
    runtime.set_fixed_state(state.header.time, state.header.frame, state.header.fps)?;
    for name in &state.header.buffers {
        let values = state
            .buffers
            .get(name)
            .with_context(|| format!("state is missing buffer '{name}'"))?;
        runtime.restore_pass_rgba32f(name, state.header.width, state.header.height, values)?;
    }
    Ok(())
}

pub(super) fn render_from_zero(
    runtime: &mut Runtime<'_>,
    target_frame: i32,
    fps: f32,
    width: u32,
    height: u32,
    overrides: &[(String, Vec<u8>)],
) -> Result<Option<RgbImage>> {
    if target_frame < 0 {
        bail!("target frame must be non-negative");
    }

    if target_frame == 0 {
        apply_overrides(runtime, overrides, width, height)?;
        return Ok(Some(runtime.render(width, height)?));
    }

    // Establish deterministic frame-0 buffer contents before advancing.
    let _ = runtime.render(width, height)?;
    for _frame in 1..target_frame {
        runtime.tick_fixed(1.0 / fps, fps)?;
        let _ = runtime.render(width, height)?;
    }
    apply_overrides(runtime, overrides, width, height)?;
    runtime.tick_fixed(1.0 / fps, fps)?;
    Ok(Some(runtime.render(width, height)?))
}

fn render_from_restored_state(
    runtime: &mut Runtime<'_>,
    state_frame: i32,
    target_frame: i32,
    fps: f32,
    width: u32,
    height: u32,
    overrides: &[(String, Vec<u8>)],
) -> Result<Option<RgbImage>> {
    if target_frame < state_frame {
        bail!("requested frame {target_frame} precedes restored state frame {state_frame}");
    }
    if target_frame == state_frame {
        if !overrides.is_empty() {
            apply_overrides(runtime, overrides, width, height)?;
        }
        return Ok(None);
    }

    let mut image = None;
    for frame in (state_frame + 1)..=target_frame {
        if frame == target_frame {
            apply_overrides(runtime, overrides, width, height)?;
        }
        runtime.tick_fixed(1.0 / fps, fps)?;
        image = Some(runtime.render(width, height)?);
    }
    Ok(image)
}

fn apply_overrides(
    runtime: &mut Runtime<'_>,
    overrides: &[(String, Vec<u8>)],
    width: u32,
    height: u32,
) -> Result<()> {
    for (name, rgba) in overrides {
        runtime.override_pass_rgba8(name, width, height, rgba)?;
    }
    Ok(())
}

fn resolve_dimensions(
    loaded: &LoadedManifest,
    state: Option<&StateFile>,
    width: Option<u32>,
    height: Option<u32>,
) -> Result<(u32, u32)> {
    if let Some(state) = state {
        if let Some(width) = width
            && width != state.header.width
        {
            bail!(
                "resumable state is {} pixels wide; rendering it at {} would discard buffer state",
                state.header.width,
                width
            );
        }
        if let Some(height) = height
            && height != state.header.height
        {
            bail!(
                "resumable state is {} pixels high; rendering it at {} would discard buffer state",
                state.header.height,
                height
            );
        }
        return Ok((state.header.width, state.header.height));
    }

    let width = width.unwrap_or(loaded.manifest.render.width);
    let height = height.unwrap_or(loaded.manifest.render.height);
    validate_dimensions(width, height)?;
    Ok((width, height))
}

fn resolve_fps(
    loaded: &LoadedManifest,
    state: Option<&StateFile>,
    fps: Option<f32>,
) -> Result<f32> {
    let fps = fps.unwrap_or_else(|| {
        state
            .map(|state| state.header.fps)
            .unwrap_or(loaded.manifest.render.fps)
    });
    validate_fps(fps)?;
    Ok(fps)
}

pub(super) fn resolve_target_frame(
    loaded: &LoadedManifest,
    state: Option<&StateFile>,
    frame: Option<i32>,
    time: Option<f32>,
    fps: f32,
) -> Result<i32> {
    if frame.is_some() && time.is_some() {
        bail!("use either --frame or --time, not both");
    }
    if let Some(frame) = frame {
        if frame < 0 {
            bail!("--frame must be non-negative");
        }
        return Ok(frame);
    }
    if let Some(time) = time {
        if !time.is_finite() || time < 0.0 {
            bail!("--time must be a finite non-negative number");
        }
        return frame_for_time(time, fps, "--time");
    }
    if let Some(state) = state {
        return state
            .header
            .frame
            .checked_add(1)
            .context("state frame counter overflow");
    }
    frame_for_time(
        loaded.manifest.render.preview_time,
        fps,
        "render.preview_time",
    )
}

pub(super) fn validate_dimensions(width: u32, height: u32) -> Result<()> {
    if width == 0 || height == 0 {
        bail!("render dimensions must be positive");
    }
    if width > crate::manifest::MAX_RENDER_DIMENSION
        || height > crate::manifest::MAX_RENDER_DIMENSION
    {
        bail!(
            "render dimensions exceed the {} pixel safety limit",
            crate::manifest::MAX_RENDER_DIMENSION
        );
    }
    Ok(())
}

pub(super) fn validate_fps(fps: f32) -> Result<()> {
    if !fps.is_finite() || fps <= 0.0 || fps > crate::manifest::MAX_RENDER_FPS {
        bail!(
            "fps must be finite and in the range (0, {}]",
            crate::manifest::MAX_RENDER_FPS
        );
    }
    Ok(())
}

fn frame_for_time(time: f32, fps: f32, source: &str) -> Result<i32> {
    let frame = f64::from(time) * f64::from(fps);
    if !frame.is_finite() || frame.round() > f64::from(i32::MAX) {
        bail!("{source} resolves to a frame outside the supported i32 range");
    }
    Ok(frame.round() as i32)
}

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

    #[test]
    fn frame_for_time_rejects_i32_overflow() {
        assert!(frame_for_time(f32::MAX, 1000.0, "--time").is_err());
    }
}