shadertoy-cli 2.2.5

Agent-friendly ShaderToy project, rendering, debugging, and live-preview CLI
use super::*;
use std::io::Write;
use std::process::{Command, Stdio};

const DEFAULT_VIDEO_DURATION_SECONDS: f32 = 5.0;
const MAX_VIDEO_FRAMES: u32 = 1_000_000;

pub fn render_video_project(options: &RenderVideoOptions) -> Result<Output> {
    let loaded = LoadedManifest::load(&options.project)?;
    ensure_source_files_exist(&loaded)?;
    let media = crate::media::MediaInputs::new_headless(&loaded)?;

    let (width, height) = render::resolve_dimensions(&loaded, None, options.width, options.height)?;
    let fps = render::resolve_fps(&loaded, None, options.fps)?;
    if options.start_frame < 0 {
        bail!("--start-frame must be non-negative");
    }

    let frame_count = resolve_frame_count(options.frames, options.duration, fps)?;
    let end_frame = options
        .start_frame
        .checked_add(i32::try_from(frame_count - 1).context("video frame count exceeds i32")?)
        .context("video end frame overflow")?;

    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!("render-video only supports the final image and 2D buffer/compute passes");
    }
    let (selected_width, selected_height) = loaded.manifest.pass_dimensions(pass, width, height);

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

    let mut ffmpeg = ffmpeg_command(
        &output,
        selected_width,
        selected_height,
        fps,
        options.codec.as_deref(),
    )?;
    let mut child = ffmpeg
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .spawn()
        .with_context(|| {
            "failed to launch ffmpeg; install ffmpeg or set SHADERTOY_FFMPEG to its executable"
                .to_string()
        })?;
    let mut stdin = child.stdin.take().context("failed to open ffmpeg stdin")?;

    let context = HeadlessContext::new(64, 64)
        .context("failed to create headless OpenGL context for video rendering")?;
    let mut runtime = Runtime::new(&context)?;
    let project = build_native_project(&loaded)?;
    runtime.load_project(&project)?;
    let uniform_values =
        crate::uniforms::parse_assignments(&loaded.manifest.uniforms, &options.set_uniforms)?;
    crate::uniforms::apply_to_runtime(&mut runtime, &uniform_values)?;

    let final_pass = loaded.manifest.final_pass().name.as_str();
    for frame in 0..=end_frame {
        if frame > 0 {
            runtime.tick_fixed(1.0 / fps, fps)?;
        }
        let media_time = runtime.time();
        media.update(&mut runtime, media_time)?;
        let final_image = runtime.render(width, height)?;
        if frame < options.start_frame {
            continue;
        }

        let image = if selected_pass == final_pass {
            final_image
        } else {
            runtime.snapshot_pass_rgb(selected_pass, selected_width, selected_height)?
        };
        let mut pixels = image.pixels;
        super::images::flip_rgb_rows(&mut pixels, image.width, image.height);
        stdin
            .write_all(&pixels)
            .with_context(|| format!("ffmpeg stopped while encoding frame {frame}"))?;
    }
    drop(stdin);

    let result = child
        .wait_with_output()
        .context("failed while waiting for ffmpeg")?;
    if !result.status.success() {
        let stderr = String::from_utf8_lossy(&result.stderr);
        bail!("ffmpeg failed with {}: {}", result.status, stderr.trim());
    }

    Ok(Output {
        human: format!(
            "Rendered {} deterministic frames ({}..={}) at {}x{} @ {} fps{} -> {}",
            frame_count,
            options.start_frame,
            end_frame,
            selected_width,
            selected_height,
            fps,
            options
                .pass
                .as_ref()
                .map(|name| format!(" pass '{name}'"))
                .unwrap_or_default(),
            output.display()
        ),
        json: json!({
            "ok": true,
            "project": loaded.manifest.project.name,
            "output": output,
            "pass": selected_pass,
            "width": selected_width,
            "height": selected_height,
            "fps": fps,
            "start_frame": options.start_frame,
            "end_frame": end_frame,
            "frames": frame_count,
        }),
    })
}

fn resolve_frame_count(frames: Option<u32>, duration: Option<f32>, fps: f32) -> Result<u32> {
    if frames.is_some() && duration.is_some() {
        bail!("use either --frames or --duration, not both");
    }
    if let Some(frames) = frames {
        if frames == 0 || frames > MAX_VIDEO_FRAMES {
            bail!("--frames must be in 1..={MAX_VIDEO_FRAMES}");
        }
        return Ok(frames);
    }

    let duration = duration.unwrap_or(DEFAULT_VIDEO_DURATION_SECONDS);
    if !duration.is_finite() || duration <= 0.0 {
        bail!("--duration must be a finite positive number");
    }
    let frames = (f64::from(duration) * f64::from(fps)).round();
    if frames < 1.0 || frames > f64::from(MAX_VIDEO_FRAMES) {
        bail!(
            "--duration resolves to an unsupported frame count; keep it within 1..={MAX_VIDEO_FRAMES}"
        );
    }
    Ok(frames as u32)
}

fn ffmpeg_command(
    output: &Path,
    width: u32,
    height: u32,
    fps: f32,
    codec_override: Option<&str>,
) -> Result<Command> {
    let executable = std::env::var_os("SHADERTOY_FFMPEG").unwrap_or_else(|| "ffmpeg".into());
    let mut command = Command::new(executable);
    command.args([
        "-hide_banner",
        "-loglevel",
        "error",
        "-y",
        "-f",
        "rawvideo",
        "-pix_fmt",
        "rgb24",
        "-s:v",
        &format!("{width}x{height}"),
        "-r",
        &format!("{fps:.6}"),
        "-i",
        "pipe:0",
        "-an",
    ]);

    if let Some(codec) = codec_override {
        command.args(["-c:v", codec]);
    } else {
        match output
            .extension()
            .and_then(|extension| extension.to_str())
            .map(str::to_ascii_lowercase)
            .as_deref()
        {
            Some("mp4" | "m4v" | "mov") => {
                command.args(["-c:v", "libx264", "-crf", "18", "-preset", "medium"]);
                command.args([
                    "-vf",
                    "pad=ceil(iw/2)*2:ceil(ih/2)*2",
                    "-pix_fmt",
                    "yuv420p",
                ]);
            }
            Some("webm") => {
                command.args(["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0"]);
                command.args([
                    "-vf",
                    "pad=ceil(iw/2)*2:ceil(ih/2)*2",
                    "-pix_fmt",
                    "yuv420p",
                ]);
            }
            Some("gif") => {}
            Some(_) | None => {}
        }
    }
    command.arg(output);
    Ok(command)
}

pub(super) fn parse_frame_range(value: &str) -> Result<Vec<i32>> {
    let parts = value.split(':').collect::<Vec<_>>();
    if !(2..=3).contains(&parts.len()) {
        bail!("frame range must be START:END or START:END:STEP");
    }
    let start = parts[0]
        .parse::<i32>()
        .context("frame range START must be an integer")?;
    let end = parts[1]
        .parse::<i32>()
        .context("frame range END must be an integer")?;
    let step = if parts.len() == 3 {
        parts[2]
            .parse::<i32>()
            .context("frame range STEP must be an integer")?
    } else {
        1
    };
    if start < 0 || end < 0 {
        bail!("frame range values must be non-negative");
    }
    if end < start {
        bail!("frame range END must be greater than or equal to START");
    }
    if step <= 0 {
        bail!("frame range STEP must be positive");
    }

    let mut frames = Vec::new();
    let mut frame = start;
    while frame <= end {
        if frames.len() >= 1024 {
            bail!("frame range expands to more than 1024 entries");
        }
        frames.push(frame);
        match frame.checked_add(step) {
            Some(next) if next > frame => frame = next,
            _ => break,
        }
    }
    Ok(frames)
}

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

    #[test]
    fn parses_inclusive_frame_ranges() {
        assert_eq!(parse_frame_range("0:4").unwrap(), vec![0, 1, 2, 3, 4]);
        assert_eq!(parse_frame_range("0:120:60").unwrap(), vec![0, 60, 120]);
        assert!(parse_frame_range("4:0").is_err());
        assert!(parse_frame_range("0:4:0").is_err());
    }

    #[test]
    fn duration_and_frame_count_are_mutually_exclusive() {
        assert!(resolve_frame_count(Some(10), Some(1.0), 60.0).is_err());
        assert_eq!(resolve_frame_count(None, Some(0.5), 60.0).unwrap(), 30);
    }
}