use crate::manifest::{LoadedManifest, PassKind};
use crate::ops::rgb_png_bytes;
use crate::project::{build_native_project, ensure_source_files_exist};
use anyhow::{Context, Result, bail};
use axum::extract::{
Query, State,
ws::{Message, WebSocket, WebSocketUpgrade},
};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use futures_util::StreamExt;
use notify::{RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
use shadertoy::{HeadlessContext, Runtime};
use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::net::{IpAddr, TcpListener};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use tokio::sync::{broadcast, oneshot};
mod renderer;
mod state;
mod web;
use renderer::render_loop;
use state::{relevant_watch_path, reload_event_kind};
use web::{frame_png, index, status, validate_remote_auth, websocket};
#[derive(Debug, Clone)]
pub struct PreviewConfig {
pub project: PathBuf,
pub host: String,
pub port: u16,
pub open: bool,
pub no_open: bool,
pub token: Option<String>,
pub preserve_reload_state: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct PreviewStatus {
pub project: String,
pub sequence: u64,
pub frame: i32,
pub time: f32,
pub paused: bool,
pub time_scale: f32,
pub width: u32,
pub height: u32,
pub fps: f32,
pub view: String,
pub final_pass: String,
pub passes: Vec<String>,
pub error: Option<String>,
}
impl Default for PreviewStatus {
fn default() -> Self {
Self {
project: String::new(),
sequence: 0,
frame: 0,
time: 0.0,
paused: false,
time_scale: 0.0,
width: 1280,
height: 720,
fps: 60.0,
view: "image".into(),
final_pass: "image".into(),
passes: Vec::new(),
error: None,
}
}
}
#[derive(Clone)]
struct Shared {
frame_png: Arc<RwLock<Vec<u8>>>,
status: Arc<RwLock<PreviewStatus>>,
controls: mpsc::Sender<Control>,
updates: broadcast::Sender<String>,
clients: Arc<AtomicUsize>,
token: Option<Arc<String>>,
}
#[derive(Debug)]
enum Control {
Reload,
Pause,
Resume,
Reset,
Step,
View(String),
Resolution(u32, u32),
TimeScale(f32),
Mouse {
x: f32,
y: f32,
down: bool,
clicked: bool,
},
Key {
code: u8,
down: bool,
pressed: bool,
},
Shutdown,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum BrowserControl {
Pause,
Resume,
Reset,
Step,
View {
pass: String,
},
Resolution {
width: u32,
height: u32,
},
TimeScale {
value: f32,
},
Mouse {
x: f32,
y: f32,
down: bool,
#[serde(default)]
clicked: bool,
},
Key {
code: u8,
down: bool,
#[serde(default)]
pressed: bool,
},
}
#[derive(Debug, Deserialize, Default)]
struct AuthQuery {
token: Option<String>,
}
struct SavedRuntimeState {
time: f32,
frame: i32,
fps: f32,
buffers: BTreeMap<String, Vec<f32>>,
}
const MAX_PREVIEW_DIMENSION: u32 = 4096;
pub fn run(config: PreviewConfig, json_mode: bool) -> Result<()> {
if config.open && config.no_open {
bail!("--open and --no-open are mutually exclusive");
}
validate_remote_auth(&config)?;
let loaded = LoadedManifest::load(&config.project)?;
validate_preview_dimensions(&loaded)?;
let root = loaded.root.clone();
let initial_status = PreviewStatus {
project: loaded.manifest.project.name.clone(),
width: loaded.manifest.render.width,
height: loaded.manifest.render.height,
fps: loaded.manifest.render.fps,
final_pass: loaded.manifest.final_pass().name.clone(),
view: loaded.manifest.final_pass().name.clone(),
passes: loaded
.manifest
.passes
.iter()
.map(|pass| pass.name.clone())
.collect(),
..PreviewStatus::default()
};
let (control_tx, control_rx) = mpsc::channel();
let (update_tx, _) = broadcast::channel(128);
let shared = Shared {
frame_png: Arc::new(RwLock::new(Vec::new())),
status: Arc::new(RwLock::new(initial_status)),
controls: control_tx.clone(),
updates: update_tx.clone(),
clients: Arc::new(AtomicUsize::new(0)),
token: config.token.clone().map(Arc::new),
};
let watcher_tx = control_tx.clone();
let watch_root = root.clone();
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
let Ok(event) = event else {
return;
};
if reload_event_kind(&event.kind)
&& event
.paths
.iter()
.any(|path| relevant_watch_path(&watch_root, path))
{
let _ = watcher_tx.send(Control::Reload);
}
})
.context("failed to create project file watcher")?;
watcher
.watch(&root, RecursiveMode::Recursive)
.with_context(|| format!("failed to watch {}", root.display()))?;
let listener = TcpListener::bind((config.host.as_str(), config.port)).with_context(|| {
format!(
"failed to bind preview server on {}:{}",
config.host, config.port
)
})?;
listener
.set_nonblocking(true)
.context("failed to configure preview server socket")?;
let address = listener.local_addr()?;
let context = HeadlessContext::new(64, 64)
.context("failed to create headless OpenGL context for preview")?;
let mut runtime =
Runtime::new(&context).context("failed to create native ShaderToy preview runtime")?;
let app = Router::new()
.route("/", get(index))
.route("/frame.png", get(frame_png))
.route("/api/status", get(status))
.route("/ws", get(websocket))
.with_state(shared.clone());
let url = preview_url(&config.host, address.port(), config.token.as_deref());
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel();
let server_controls = control_tx.clone();
let server_thread = thread::Builder::new()
.name("shadertoy-preview-http".into())
.spawn(move || {
let result = (|| -> Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("failed to create preview HTTP runtime")?;
runtime.block_on(async move {
let listener = tokio::net::TcpListener::from_std(listener)
.context("failed to adopt preview server socket")?;
axum::serve(listener, app)
.with_graceful_shutdown(async move {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = server_shutdown_rx => {}
}
})
.await
.context("preview server failed")
})
})();
let _ = server_controls.send(Control::Shutdown);
result
})
.context("failed to start preview HTTP thread")?;
if json_mode {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"ok": true,
"action": "preview",
"url": url,
"host": config.host,
"port": address.port(),
"project": root,
}))?
);
} else {
println!("ShaderToy preview: {url}");
println!("Watching {}", root.display());
}
let should_open = config.open
|| (!config.no_open && std::io::stdout().is_terminal() && config.host != "0.0.0.0");
if should_open && let Err(error) = open::that(&url) {
eprintln!("warning: could not open browser: {error}");
}
render_loop(
root,
shared,
control_rx,
config.preserve_reload_state,
&mut runtime,
);
let _ = server_shutdown_tx.send(());
drop(watcher);
match server_thread.join() {
Ok(result) => result,
Err(_) => bail!("preview HTTP thread panicked"),
}
}
fn validate_preview_dimensions(loaded: &LoadedManifest) -> Result<()> {
let width = loaded.manifest.render.width;
let height = loaded.manifest.render.height;
if width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
bail!(
"preview resolution must be between 1x1 and {0}x{0}; manifest requests {width}x{height}",
MAX_PREVIEW_DIMENSION
);
}
Ok(())
}
fn preview_url(host: &str, port: u16, token: Option<&str>) -> String {
let display_host = match host {
"0.0.0.0" => "127.0.0.1",
"::" => "::1",
other => other,
};
let authority_host = match display_host.parse::<IpAddr>() {
Ok(IpAddr::V6(_)) => format!("[{display_host}]"),
_ => display_host.to_string(),
};
let query = token
.map(|token| format!("?token={}", percent_encode_query(token)))
.unwrap_or_default();
format!("http://{authority_host}:{port}{query}")
}
fn percent_encode_query(value: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX[(byte >> 4) as usize]));
encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_url_brackets_ipv6_and_encodes_token() {
assert_eq!(
preview_url("::1", 4321, Some("a b&c")),
"http://[::1]:4321?token=a%20b%26c"
);
assert_eq!(preview_url("::", 4321, None), "http://[::1]:4321");
}
}