use super::*;
pub fn new_project(path: &Path, template: Template) -> Result<Output> {
let name = path
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.context("project path must end in a valid UTF-8 directory name")?;
create_project(path, name, template, false)?;
Ok(Output {
human: format!("Created ShaderToy project {}", path.display()),
json: json!({
"ok": true,
"action": "new",
"project": path,
"name": name,
"template": template_name(template),
}),
})
}
pub fn init_project(path: &Path, template: Template) -> Result<Output> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let name = absolute
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.context("project directory must have a valid UTF-8 name")?;
create_project(&absolute, name, template, true)?;
Ok(Output {
human: format!("Initialized ShaderToy project {}", absolute.display()),
json: json!({
"ok": true,
"action": "init",
"project": absolute,
"name": name,
"template": template_name(template),
}),
})
}
pub fn check_project(options: &CheckOptions) -> Result<Output> {
let loaded = LoadedManifest::load_with_preset(&options.project, options.preset.as_deref())?;
ensure_source_files_exist(&loaded)?;
let diagnostics = super::graph::analyze_graph(&loaded, options.pedantic)?;
let errors = diagnostics
.iter()
.filter(|diagnostic| diagnostic.level == "error")
.count();
let warnings = diagnostics
.iter()
.filter(|diagnostic| diagnostic.level == "warning")
.count();
if errors != 0 {
let mut human = format!(
"FAILED: {} ({} graph error{})",
loaded.manifest.project.name,
errors,
if errors == 1 { "" } else { "s" }
);
for diagnostic in &diagnostics {
human.push_str(&format!(
"\n {} [{}] {}",
diagnostic.level.to_ascii_uppercase(),
diagnostic.code,
diagnostic.message
));
}
return Ok(Output {
human,
json: json!({
"ok": false,
"project": loaded.manifest.project.name,
"root": loaded.root,
"preset": options.preset,
"compiled": false,
"diagnostics": diagnostics,
"errors": errors,
"warnings": warnings,
}),
});
}
let context = HeadlessContext::new(64, 64)
.context("failed to create headless OpenGL context for shader compilation")?;
let mut runtime = Runtime::new(&context)?;
let project = build_native_project(&loaded)?;
runtime.load_project(&project)?;
crate::uniforms::apply_to_runtime(
&mut runtime,
&crate::uniforms::defaults(&loaded.manifest.uniforms),
)?;
let sound_passes = super::sound::check_sound_passes(&context, &loaded)?;
let pedantic_failed = options.pedantic && warnings != 0;
let mut human = format!(
"{}: {} ({} passes, {} assets",
if pedantic_failed { "FAILED" } else { "OK" },
loaded.manifest.project.name,
loaded.manifest.passes.len(),
loaded.manifest.assets.len()
);
if warnings != 0 {
human.push_str(&format!(
", {warnings} warning{}",
if warnings == 1 { "" } else { "s" }
));
}
human.push(')');
for diagnostic in &diagnostics {
human.push_str(&format!(
"\n {} [{}] {}",
diagnostic.level.to_ascii_uppercase(),
diagnostic.code,
diagnostic.message
));
}
Ok(Output {
human,
json: json!({
"ok": !pedantic_failed,
"project": loaded.manifest.project.name,
"root": loaded.root,
"preset": options.preset,
"passes": loaded.manifest.passes.len(),
"assets": loaded.manifest.assets.len(),
"compiled": true,
"sound_passes": sound_passes,
"pedantic": options.pedantic,
"diagnostics": diagnostics,
"errors": errors,
"warnings": warnings,
}),
})
}
pub fn build_project(path: &Path, output: Option<&Path>, preset: Option<&str>) -> Result<Output> {
let loaded = LoadedManifest::load_with_preset(path, preset)?;
ensure_source_files_exist(&loaded)?;
if loaded
.manifest
.passes
.iter()
.any(|pass| pass.kind == PassKind::Sound)
{
bail!(
"STTF build does not encode Sound passes; use render-audio or keep the directory project"
);
}
if loaded
.manifest
.assets
.iter()
.any(|asset| asset.kind == crate::manifest::AssetKind::Video)
|| crate::media::manifest_uses_webcam(&loaded)
{
bail!(
"STTF build does not encode dynamic video/webcam playback; keep the directory project for media inputs"
);
}
let output = output.map(PathBuf::from).unwrap_or_else(|| {
loaded.root.join("target").join(format!(
"{}.sttf",
file_safe_name(&loaded.manifest.project.name)
))
});
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let context = HeadlessContext::new(64, 64)
.context("failed to create headless OpenGL context for shader compilation")?;
let mut runtime = Runtime::new(&context)?;
let project = build_native_project(&loaded)?;
runtime.load_project(&project)?;
crate::uniforms::apply_to_runtime(
&mut runtime,
&crate::uniforms::defaults(&loaded.manifest.uniforms),
)?;
runtime.save_sttf(&output)?;
Ok(Output {
human: format!("Built {}", output.display()),
json: json!({
"ok": true,
"project": loaded.manifest.project.name,
"artifact": output,
"format": "sttf",
}),
})
}
fn template_name(template: Template) -> &'static str {
match template {
Template::Minimal => "minimal",
Template::Multipass => "multipass",
}
}
fn file_safe_name(value: &str) -> String {
let mut result = String::new();
for character in value.chars() {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
result.push(character);
} else if !result.ends_with('-') {
result.push('-');
}
}
let result = result.trim_matches('-');
if result.is_empty() {
"project".to_string()
} else {
result.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_safe_name_has_nonempty_fallback() {
assert_eq!(file_safe_name("☃"), "project");
}
}