diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0012724..ebae3b3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,14 +10,17 @@ on: - 'scripts/generate_supported_formats_doc.py' - 'scripts/generate_tool_registry_doc.py' - 'scripts/generate_spec_v2_reference.py' + - 'scripts/generate_upscayl_models_doc.py' - 'schemas/renderflow-v2.schema.json' - 'examples/renderflow-v2.yaml' - 'crates/renderflow-core/data/tool-registry.yaml' + - 'crates/renderflow-core/data/upscayl-models.yaml' - 'crates/renderflow-core/src/audio/format.rs' - 'crates/renderflow-core/src/graph/format.rs' - 'crates/renderflow-core/src/image/format.rs' - 'crates/renderflow-core/src/input_format.rs' - 'crates/renderflow-core/src/spec.rs' + - 'crates/renderflow-core/src/super_resolution.rs' - 'crates/renderflow-core/src/commands/spec.rs' - 'crates/renderflow-core/src/toolchain.rs' push: @@ -33,14 +36,17 @@ on: - 'scripts/generate_supported_formats_doc.py' - 'scripts/generate_tool_registry_doc.py' - 'scripts/generate_spec_v2_reference.py' + - 'scripts/generate_upscayl_models_doc.py' - 'schemas/renderflow-v2.schema.json' - 'examples/renderflow-v2.yaml' - 'crates/renderflow-core/data/tool-registry.yaml' + - 'crates/renderflow-core/data/upscayl-models.yaml' - 'crates/renderflow-core/src/audio/format.rs' - 'crates/renderflow-core/src/graph/format.rs' - 'crates/renderflow-core/src/image/format.rs' - 'crates/renderflow-core/src/input_format.rs' - 'crates/renderflow-core/src/spec.rs' + - 'crates/renderflow-core/src/super_resolution.rs' - 'crates/renderflow-core/src/commands/spec.rs' - 'crates/renderflow-core/src/toolchain.rs' workflow_dispatch: @@ -82,6 +88,7 @@ jobs: run: | python scripts/generate_supported_formats_doc.py python scripts/generate_tool_registry_doc.py + python scripts/generate_upscayl_models_doc.py cargo run --quiet -p renderflow-cli -- spec schema --format json --output schemas/renderflow-v2.schema.json python scripts/generate_spec_v2_reference.py @@ -90,6 +97,7 @@ jobs: git diff --exit-code -- \ docs/user-guide/supported-formats.md \ docs/user-guide/tool-registry.md \ + docs/user-guide/upscayl-models.md \ docs/user-guide/spec-v2-reference.md \ schemas/renderflow-v2.schema.json @@ -132,6 +140,7 @@ jobs: run: | python scripts/generate_supported_formats_doc.py python scripts/generate_tool_registry_doc.py + python scripts/generate_upscayl_models_doc.py cargo run --quiet -p renderflow-cli -- spec schema --format json --output schemas/renderflow-v2.schema.json python scripts/generate_spec_v2_reference.py diff --git a/crates/renderflow-core/data/tool-registry.yaml b/crates/renderflow-core/data/tool-registry.yaml index 110bc54..2a3e7b4 100644 --- a/crates/renderflow-core/data/tool-registry.yaml +++ b/crates/renderflow-core/data/tool-registry.yaml @@ -159,3 +159,22 @@ tools: support_tier: experimental license_notes: "Ghostscript is available under AGPL/commercial licensing; review distribution obligations." distribution_notes: "Used by press-oriented TIFF/PDF command adapters when configured." + + + - id: tool.upscayl-ncnn + name: Upscayl NCNN + discovery: + kind: executable + candidates: [upscayl-ncnn, upscayl-bin] + version_args: [-h] + operating_systems: [linux, macos, windows] + capabilities: + - image.super_resolution + input_media_types: [image/*] + output_media_types: [image/*] + determinism: configuration_dependent + locality: local + fidelity: lossy + support_tier: experimental + license_notes: "The Upscayl NCNN backend is AGPL-3.0; model licensing is separate and recorded per model variant." + distribution_notes: "Requires compatible Vulkan/GPU runtime and NCNN .param/.bin model pairs; packaged desktop distributions may expose the executable as upscayl-bin." diff --git a/crates/renderflow-core/data/upscayl-models.yaml b/crates/renderflow-core/data/upscayl-models.yaml new file mode 100644 index 0000000..8643b90 --- /dev/null +++ b/crates/renderflow-core/data/upscayl-models.yaml @@ -0,0 +1,58 @@ +schema: renderflow.upscayl-models/v1 + +models: + - variant_id: variant.upscayl-ncnn.upscayl-standard-4x + model_name: upscayl-standard-4x + display_name: Standard + native_scale: 4 + commercial_use: unknown + description: General-purpose Upscayl super-resolution model. + license_notes: "Model-specific usage terms are not asserted by Renderflow; verify the model license before commercial publication." + + - variant_id: variant.upscayl-ncnn.upscayl-lite-4x + model_name: upscayl-lite-4x + display_name: Lite + native_scale: 4 + commercial_use: unknown + description: Lighter-weight general-purpose Upscayl super-resolution model. + license_notes: "Model-specific usage terms are not asserted by Renderflow; verify the model license before commercial publication." + + - variant_id: variant.upscayl-ncnn.high-fidelity-4x + model_name: high-fidelity-4x + display_name: High Fidelity + native_scale: 4 + commercial_use: unknown + description: Upscayl model oriented toward preserving source appearance and detail. + license_notes: "Model-specific usage terms are not asserted by Renderflow; verify the model license before commercial publication." + + - variant_id: variant.upscayl-ncnn.remacri-4x + model_name: remacri-4x + display_name: Remacri + native_scale: 4 + commercial_use: prohibited + description: Remacri model distributed in the Upscayl default model set. + license_notes: "Upscayl currently labels this model Non-Commercial. Do not select it for commercial publication without separately establishing permission." + + - variant_id: variant.upscayl-ncnn.ultramix-balanced-4x + model_name: ultramix-balanced-4x + display_name: Ultramix Balanced + native_scale: 4 + commercial_use: prohibited + description: Balanced Ultramix model distributed in the Upscayl default model set. + license_notes: "Upscayl currently labels this model Non-Commercial. Do not select it for commercial publication without separately establishing permission." + + - variant_id: variant.upscayl-ncnn.ultrasharp-4x + model_name: ultrasharp-4x + display_name: Ultrasharp + native_scale: 4 + commercial_use: prohibited + description: Sharpness-oriented model distributed in the Upscayl default model set. + license_notes: "Upscayl currently labels this model Non-Commercial. Do not select it for commercial publication without separately establishing permission." + + - variant_id: variant.upscayl-ncnn.digital-art-4x + model_name: digital-art-4x + display_name: Digital Art + native_scale: 4 + commercial_use: unknown + description: Upscayl model oriented toward illustrations and digital artwork. + license_notes: "Model-specific usage terms are not asserted by Renderflow; verify the model license before commercial publication." diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 9263c40..553bc9f 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -137,6 +137,11 @@ pub fn run_cli(cli: Cli) -> Result<()> { format, transforms, } => commands::tools::run_inspect(&id, transforms.as_deref(), &format)?, + ToolCommands::Variants { + id, + models_dir, + format, + } => commands::tools::run_variants(&id, models_dir.as_deref(), &format)?, }, Some(Commands::Capabilities { format, transforms }) => { commands::tools::run_capabilities(transforms.as_deref(), &format)? diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index 90699ad..e4fba9f 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -319,6 +319,18 @@ pub enum ToolCommands { #[arg(long, value_name = "FILE")] transforms: Option, }, + + /// List stable provider variants/models and optional runtime material evidence. + Variants { + /// Stable provider/tool identifier, for example `tool.upscayl-ncnn`. + id: String, + /// Optional provider model/material directory used for runtime discovery. + #[arg(long, value_name = "DIR")] + models_dir: Option, + /// Output format: text (default), json, or yaml. + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, } /// Subcommands for `renderflow ai`. diff --git a/crates/renderflow-core/src/commands/tools.rs b/crates/renderflow-core/src/commands/tools.rs index c7bd510..c76701f 100644 --- a/crates/renderflow-core/src/commands/tools.rs +++ b/crates/renderflow-core/src/commands/tools.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use anyhow::{Context, Result}; use serde::Serialize; +use crate::super_resolution::{UpscaylModelCatalog, UPSCAYL_TOOL_ID}; use crate::toolchain::{ToolAvailability, ToolDescriptor, ToolRegistry}; use crate::transforms::yaml_loader::load_tool_registry_from_yaml; @@ -141,6 +142,48 @@ pub fn run_inspect(id: &str, transforms: Option<&str>, format: &str) -> Result<( Ok(()) } +pub fn run_variants(id: &str, models_dir: Option<&str>, format: &str) -> Result<()> { + if id != UPSCAYL_TOOL_ID { + anyhow::bail!("tool provider '{id}' does not expose a registered variant catalog"); + } + let catalog = match models_dir { + Some(path) => UpscaylModelCatalog::discover(path) + .with_context(|| format!("failed to discover Upscayl models from '{path}'"))?, + None => UpscaylModelCatalog::builtins(), + }; + let format = StructuredFormat::parse(format)?; + if format != StructuredFormat::Text { + return emit_serialized(&catalog, format); + } + + println!("Tool variants: {id}"); + println!("==============================="); + for model in &catalog.models { + let digest = model.model_digest.as_deref().unwrap_or("-"); + let scale = model + .native_scale + .map(|value| format!("x{value}")) + .unwrap_or_else(|| "unknown".to_string()); + println!( + "{:<52} {:<24} {:<8} {:<12} {:<10} {}", + model.variant_id, + model.model_name, + scale, + model.commercial_use.as_str(), + if model.is_materialized() { + "ready" + } else { + "catalog" + }, + digest + ); + } + for diagnostic in &catalog.diagnostics { + println!("diagnostic [{}]: {}", diagnostic.code, diagnostic.message); + } + Ok(()) +} + pub fn run_capabilities(transforms: Option<&str>, format: &str) -> Result<()> { let registry = load_registry(transforms)?; let capabilities: BTreeMap> = registry.capabilities(); diff --git a/crates/renderflow-core/src/graph/execution_plan.rs b/crates/renderflow-core/src/graph/execution_plan.rs index 8938306..d62dec7 100644 --- a/crates/renderflow-core/src/graph/execution_plan.rs +++ b/crates/renderflow-core/src/graph/execution_plan.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use super::{Format, InputKind, MultiTargetDag, TransformEdge}; use crate::optimization::OptimizationMode; @@ -67,6 +67,12 @@ pub struct PlanEdge { /// Stable capability identifier implemented by the selected provider. #[serde(default, skip_serializing_if = "Option::is_none")] pub capability_id: Option, + /// Stable provider-specific transform/model variant identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant_id: Option, + /// Variant-specific reproducibility evidence. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub evidence: BTreeMap, } impl PlanEdge { @@ -91,6 +97,8 @@ impl PlanEdge { edge_type, provider_id: e.provider_id.clone(), capability_id: e.capability_id.clone(), + variant_id: e.variant_id.clone(), + evidence: e.evidence.clone(), } } } @@ -644,6 +652,43 @@ mod tests { assert_eq!(plan.metadata.execution_depth, 1); } + #[test] + fn test_plan_preserves_provider_variant_evidence() { + let mut graph = TransformGraph::new(); + graph.add_transform( + TransformEdge::new(Format::Png, Format::Webp, 1.0, 0.9) + .with_provider("tool.upscayl-ncnn", "image.super_resolution") + .with_variant("variant.upscayl-ncnn.digital-art-4x") + .with_evidence("model_digest", "sha256:model"), + ); + let dag = graph + .build_multi_target_dag(Format::Png, &[Format::Webp]) + .unwrap(); + let plan = ExecutionPlan::from_dag( + &dag, + Format::Png, + &[Format::Webp], + OptimizationMode::Balanced, + ); + + assert_eq!(plan.edges.len(), 1); + let edge = &plan.edges[0]; + assert_eq!(edge.provider_id.as_deref(), Some("tool.upscayl-ncnn")); + assert_eq!( + edge.capability_id.as_deref(), + Some("image.super_resolution") + ); + assert_eq!( + edge.variant_id.as_deref(), + Some("variant.upscayl-ncnn.digital-art-4x") + ); + assert_eq!( + edge.evidence.get("model_digest").map(String::as_str), + Some("sha256:model") + ); + assert_eq!(plan.waves[0].edges[0].variant_id, edge.variant_id); + } + #[test] fn test_plan_serialization_roundtrip() { let plan = build_plan(); diff --git a/crates/renderflow-core/src/graph/transform_edge.rs b/crates/renderflow-core/src/graph/transform_edge.rs index fe77946..574d43c 100644 --- a/crates/renderflow-core/src/graph/transform_edge.rs +++ b/crates/renderflow-core/src/graph/transform_edge.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use super::{Format, InputKind}; /// Metadata attached to a directed edge in the [`TransformGraph`](super::TransformGraph). @@ -26,6 +28,10 @@ pub struct TransformEdge { pub provider_id: Option, /// Stable machine-readable capability identifier for this edge, when known. pub capability_id: Option, + /// Stable provider-specific transform/model variant identity, when known. + pub variant_id: Option, + /// Deterministically ordered evidence that affects this variant's reproducibility. + pub evidence: BTreeMap, } impl TransformEdge { @@ -47,6 +53,8 @@ impl TransformEdge { input_kind: InputKind::Single, provider_id: None, capability_id: None, + variant_id: None, + evidence: BTreeMap::new(), } } @@ -76,6 +84,8 @@ impl TransformEdge { input_kind, provider_id: None, capability_id: None, + variant_id: None, + evidence: BTreeMap::new(), } } @@ -89,6 +99,21 @@ impl TransformEdge { self.capability_id = Some(capability_id.into()); self } + + pub fn with_variant(mut self, variant_id: impl Into) -> Self { + self.variant_id = Some(variant_id.into()); + self + } + + pub fn with_evidence(mut self, key: impl Into, value: impl Into) -> Self { + self.evidence.insert(key.into(), value.into()); + self + } + + pub fn with_evidence_map(mut self, evidence: BTreeMap) -> Self { + self.evidence = evidence; + self + } } #[cfg(test)] diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index 0c36296..ba8be7e 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -28,6 +28,7 @@ pub mod process; mod sdk; pub mod spec; pub mod strategies; +pub mod super_resolution; mod template; pub mod toolchain; pub mod transforms; diff --git a/crates/renderflow-core/src/spec.rs b/crates/renderflow-core/src/spec.rs index 7ab2557..e622069 100644 --- a/crates/renderflow-core/src/spec.rs +++ b/crates/renderflow-core/src/spec.rs @@ -73,6 +73,8 @@ pub struct SelectorSet { pub transforms: Vec, #[serde(default)] pub profiles: Vec, + #[serde(default)] + pub variants: Vec, } impl SelectorSet { @@ -82,6 +84,7 @@ impl SelectorSet { && self.capabilities.is_empty() && self.transforms.is_empty() && self.profiles.is_empty() + && self.variants.is_empty() } } @@ -101,6 +104,8 @@ pub struct TargetSpec { #[serde(default)] pub transform: Option, #[serde(default)] + pub variant: Option, + #[serde(default)] pub preset: Option, #[serde(default)] pub template: Option, @@ -496,6 +501,8 @@ impl SpecV2 { } validate_targets(&self.targets.exact, "$.targets.exact", &mut diagnostics); + validate_selector_variants(&self.targets.include, "$.targets.include", &mut diagnostics); + validate_selector_variants(&self.targets.exclude, "$.targets.exclude", &mut diagnostics); for (index, profile_name) in self.targets.profiles.iter().enumerate() { if !self.profiles.contains_key(profile_name) { @@ -528,6 +535,16 @@ impl SpecV2 { &format!("{base}.targets"), &mut diagnostics, ); + validate_selector_variants( + &profile.include, + &format!("{base}.include"), + &mut diagnostics, + ); + validate_selector_variants( + &profile.exclude, + &format!("{base}.exclude"), + &mut diagnostics, + ); } if self.execution.max_parallel == 0 { @@ -608,6 +625,15 @@ fn validate_targets(targets: &[TargetSpec], base: &str, diagnostics: &mut Vec, +) { + for (index, variant) in selector.variants.iter().enumerate() { + if !is_stable_id(variant) { + diagnostics.push(SpecDiagnostic::new( + format!("{base}.variants[{index}]"), + "selector.variant.invalid", + "variant id must use only ASCII letters, digits, '.', '_', or '-'", + )); + } + } +} + fn validate_optional_positive( value: Option, path: &str, @@ -851,6 +893,7 @@ pub(crate) fn migrate_v1_config(config: &Config) -> SpecV2 { family: None, capability: None, transform: None, + variant: None, preset: output.profile.clone(), template: output.template.clone(), }) @@ -949,7 +992,8 @@ pub fn json_schema() -> Value { "families": {"type": "array", "items": {"type": "string"}, "default": []}, "capabilities": {"type": "array", "items": {"type": "string"}, "default": []}, "transforms": {"type": "array", "items": {"type": "string"}, "default": []}, - "profiles": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []} + "profiles": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []}, + "variants": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []} } }, "target": { @@ -962,6 +1006,7 @@ pub fn json_schema() -> Value { "family": {"type": ["string", "null"]}, "capability": {"type": ["string", "null"]}, "transform": {"type": ["string", "null"]}, + "variant": {"anyOf": [{"$ref": "#/$defs/stableId"}, {"type": "null"}]}, "preset": {"type": ["string", "null"]}, "template": {"type": ["string", "null"]} }, diff --git a/crates/renderflow-core/src/super_resolution.rs b/crates/renderflow-core/src/super_resolution.rs new file mode 100644 index 0000000..d60cefe --- /dev/null +++ b/crates/renderflow-core/src/super_resolution.rs @@ -0,0 +1,1362 @@ +//! Local AI image super-resolution providers and reproducibility evidence. +//! +//! The Upscayl integration deliberately separates four concerns: +//! +//! - canonical model identity and publication-relevant metadata, +//! - runtime model material discovery/checksums, +//! - spec-v2 variant expansion intent, +//! - bounded execution through [`crate::process::ProcessExecutor`]. +//! +//! The canonical planner introduced by Renderflow #354 can consume the pure +//! variant-selection APIs here without creating a second image-only planner. + +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::process::{ + ProcessExecutor, ProcessExpectedOutput, ProcessNetworkPolicy, ProcessRequest, ToolProbeStatus, +}; +use crate::spec::{AiPolicy, SelectorSet, SpecV2, TargetSpec}; +use crate::toolchain::{ + SelectedToolVariantEvidence, ToolAvailabilityStatus, ToolInventory, ToolProbe, ToolRegistry, + ToolRuntimeContext, +}; + +pub const UPSCAYL_MODEL_CATALOG_SCHEMA: &str = "renderflow.upscayl-models/v1"; +pub const UPSCAYL_TOOL_ID: &str = "tool.upscayl-ncnn"; +pub const SUPER_RESOLUTION_CAPABILITY_ID: &str = "image.super_resolution"; +pub const DEFAULT_UPSCAYL_MODEL: &str = "upscayl-standard-4x"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UpscaylModelOrigin { + BuiltIn, + Custom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommercialUsePolicy { + Permitted, + Prohibited, + Unknown, +} + +impl CommercialUsePolicy { + pub fn as_str(self) -> &'static str { + match self { + Self::Permitted => "permitted", + Self::Prohibited => "prohibited", + Self::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpscaylModelDescriptor { + pub variant_id: String, + pub model_name: String, + pub display_name: String, + pub native_scale: Option, + pub origin: UpscaylModelOrigin, + pub commercial_use: CommercialUsePolicy, + pub description: String, + pub license_notes: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub param_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bin_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub param_sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bin_sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_digest: Option, +} + +impl UpscaylModelDescriptor { + pub fn is_materialized(&self) -> bool { + self.param_path.is_some() + && self.bin_path.is_some() + && self.param_sha256.is_some() + && self.bin_sha256.is_some() + && self.model_digest.is_some() + } + + pub fn toolchain_variant_evidence(&self) -> SelectedToolVariantEvidence { + let mut attributes = BTreeMap::new(); + attributes.insert("model_name".to_string(), self.model_name.clone()); + attributes.insert( + "origin".to_string(), + match self.origin { + UpscaylModelOrigin::BuiltIn => "built_in", + UpscaylModelOrigin::Custom => "custom", + } + .to_string(), + ); + attributes.insert( + "commercial_use".to_string(), + self.commercial_use.as_str().to_string(), + ); + if let Some(scale) = self.native_scale { + attributes.insert("native_scale".to_string(), scale.to_string()); + } + if let Some(digest) = &self.model_digest { + attributes.insert("model_digest".to_string(), digest.clone()); + } + SelectedToolVariantEvidence { + id: self.variant_id.clone(), + attributes, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpscaylDiagnostic { + pub code: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +impl UpscaylDiagnostic { + fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + variant_id: None, + path: None, + } + } + + fn for_variant(mut self, variant_id: impl Into) -> Self { + self.variant_id = Some(variant_id.into()); + self + } + + fn at_path(mut self, path: impl Into) -> Self { + self.path = Some(path.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpscaylModelCatalog { + pub schema: String, + pub models: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Deserialize)] +struct UpscaylModelCatalogDocument { + schema: String, + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct UpscaylModelDefinition { + variant_id: String, + model_name: String, + display_name: String, + native_scale: u32, + commercial_use: CommercialUsePolicy, + description: String, + license_notes: String, +} + +impl UpscaylModelCatalog { + pub fn builtins() -> Self { + Self::from_yaml(include_str!("../data/upscayl-models.yaml")) + .expect("embedded Upscayl model catalog must be valid") + } + + pub fn from_yaml(yaml: &str) -> Result { + let document: UpscaylModelCatalogDocument = + serde_yaml_ng::from_str(yaml).context("failed to parse Upscayl model catalog YAML")?; + if document.schema != UPSCAYL_MODEL_CATALOG_SCHEMA { + anyhow::bail!( + "unsupported Upscayl model catalog schema '{}'; expected '{}'", + document.schema, + UPSCAYL_MODEL_CATALOG_SCHEMA + ); + } + + let mut seen_variants = BTreeSet::new(); + let mut seen_names = BTreeSet::new(); + let mut models = Vec::with_capacity(document.models.len()); + for definition in document.models { + validate_variant_id(&definition.variant_id)?; + if !seen_variants.insert(definition.variant_id.clone()) { + anyhow::bail!("duplicate Upscayl variant id '{}'", definition.variant_id); + } + if !seen_names.insert(definition.model_name.clone()) { + anyhow::bail!("duplicate Upscayl model name '{}'", definition.model_name); + } + if definition.native_scale == 0 { + anyhow::bail!( + "Upscayl model '{}' has invalid native scale 0", + definition.model_name + ); + } + models.push(UpscaylModelDescriptor { + variant_id: definition.variant_id, + model_name: definition.model_name, + display_name: definition.display_name, + native_scale: Some(definition.native_scale), + origin: UpscaylModelOrigin::BuiltIn, + commercial_use: definition.commercial_use, + description: definition.description, + license_notes: definition.license_notes, + param_path: None, + bin_path: None, + param_sha256: None, + bin_sha256: None, + model_digest: None, + }); + } + models.sort_by(|left, right| left.variant_id.cmp(&right.variant_id)); + Ok(Self { + schema: document.schema, + models, + diagnostics: Vec::new(), + }) + } + + /// Discover model material from an Upscayl-compatible model directory. + /// + /// Every `.param` file must have a sibling `.bin`. Known built-in model + /// names materialize their canonical descriptors. Unknown pairs are exposed + /// as custom variants without mutating the built-in catalog. + pub fn discover(models_dir: impl AsRef) -> Result { + let models_dir = models_dir.as_ref(); + let mut catalog = Self::builtins(); + if !models_dir.is_dir() { + catalog.diagnostics.push( + UpscaylDiagnostic::new( + "model_directory.missing", + format!( + "Upscayl model directory '{}' does not exist or is not a directory", + models_dir.display() + ), + ) + .at_path(models_dir), + ); + return Ok(catalog); + } + + let mut params = Vec::new(); + for entry in fs::read_dir(models_dir) + .with_context(|| format!("failed to read model directory '{}'", models_dir.display()))? + { + let path = entry?.path(); + if path.extension().and_then(|value| value.to_str()) == Some("param") { + params.push(path); + } + } + params.sort(); + + for param_path in params { + let Some(model_name) = param_path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + let model_name = model_name.to_string(); + let bin_path = param_path.with_extension("bin"); + if !bin_path.is_file() { + let variant_id = canonical_variant_id(&model_name); + catalog.diagnostics.push( + UpscaylDiagnostic::new( + "model_files.incomplete", + format!( + "model '{}' has '{}' but is missing sibling '{}'", + model_name, + param_path.display(), + bin_path.display() + ), + ) + .for_variant(variant_id) + .at_path(¶m_path), + ); + continue; + } + + let param_sha256 = sha256_file(¶m_path)?; + let bin_sha256 = sha256_file(&bin_path)?; + let model_digest = combined_model_digest(¶m_path, &bin_path)?; + + if let Some(existing) = catalog + .models + .iter_mut() + .find(|model| model.model_name == model_name) + { + existing.param_path = Some(param_path); + existing.bin_path = Some(bin_path); + existing.param_sha256 = Some(param_sha256); + existing.bin_sha256 = Some(bin_sha256); + existing.model_digest = Some(model_digest); + continue; + } + + let variant_id = canonical_variant_id(&model_name); + let native_scale = infer_native_scale(&model_name); + if native_scale.is_none() { + catalog.diagnostics.push( + UpscaylDiagnostic::new( + "model_scale.unknown", + format!( + "custom model '{}' does not advertise a native x2/x3/x4 scale in its name", + model_name + ), + ) + .for_variant(variant_id.clone()) + .at_path(¶m_path), + ); + } + catalog.models.push(UpscaylModelDescriptor { + variant_id, + model_name: model_name.clone(), + display_name: model_name, + native_scale, + origin: UpscaylModelOrigin::Custom, + commercial_use: CommercialUsePolicy::Unknown, + description: "Custom Upscayl/NCNN model discovered at runtime.".to_string(), + license_notes: "Custom model licensing is supplied by the user; Renderflow does not infer commercial-use permission.".to_string(), + param_path: Some(param_path), + bin_path: Some(bin_path), + param_sha256: Some(param_sha256), + bin_sha256: Some(bin_sha256), + model_digest: Some(model_digest), + }); + } + + catalog + .models + .sort_by(|left, right| left.variant_id.cmp(&right.variant_id)); + Ok(catalog) + } + + pub fn get(&self, id_or_name: &str) -> Option<&UpscaylModelDescriptor> { + self.models + .iter() + .find(|model| model.variant_id == id_or_name || model.model_name == id_or_name) + } + + pub fn default_model(&self) -> Option<&UpscaylModelDescriptor> { + self.get(DEFAULT_UPSCAYL_MODEL) + } + + pub fn materialized_models(&self) -> impl Iterator { + self.models.iter().filter(|model| model.is_materialized()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpscaylVariantSelectionReport { + pub provider_id: String, + pub capability_id: String, + pub variants: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl UpscaylVariantSelectionReport { + pub fn is_allowed(&self) -> bool { + !self.variants.is_empty() + && !self + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code.starts_with("policy.")) + } +} + +/// Resolve the Upscayl variant intent expressed by a v2 specification. +/// +/// This is intentionally pure planning input. It does not mutate the graph or +/// execute a transform; #354's canonical planner can project the returned +/// variants into the unified execution plan. +pub fn select_upscayl_variants( + spec: &SpecV2, + catalog: &UpscaylModelCatalog, +) -> UpscaylVariantSelectionReport { + let mut requested = BTreeSet::new(); + let mut diagnostics = Vec::new(); + let mut relevant_request = false; + + for target in &spec.targets.exact { + relevant_request |= + collect_target_request(target, catalog, &mut requested, &mut diagnostics); + } + + for profile_name in &spec.targets.profiles { + if let Some(profile) = spec.profiles.get(profile_name) { + for target in &profile.targets { + relevant_request |= + collect_target_request(target, catalog, &mut requested, &mut diagnostics); + } + relevant_request |= collect_selector_request( + &profile.include, + catalog, + &mut requested, + &mut diagnostics, + true, + ); + apply_variant_exclusions(&profile.exclude, &mut requested); + } + } + + if spec.targets.all_reachable + && !spec + .targets + .exclude + .capabilities + .iter() + .any(|capability| capability == SUPER_RESOLUTION_CAPABILITY_ID) + { + let include = &spec.targets.include; + let included = include.capabilities.is_empty() + || include + .capabilities + .iter() + .any(|capability| capability == SUPER_RESOLUTION_CAPABILITY_ID) + || !include.variants.is_empty(); + if included { + relevant_request = true; + if include.variants.is_empty() { + for model in &catalog.models { + requested.insert(model.variant_id.clone()); + } + } else { + for variant in &include.variants { + add_variant_request(variant, catalog, &mut requested, &mut diagnostics); + } + } + } + } + apply_variant_exclusions(&spec.targets.exclude, &mut requested); + + if relevant_request { + apply_execution_policy(spec, &mut requested, &mut diagnostics); + } + + let variants = requested + .into_iter() + .filter_map(|variant| catalog.get(&variant).cloned()) + .collect(); + + UpscaylVariantSelectionReport { + provider_id: UPSCAYL_TOOL_ID.to_string(), + capability_id: SUPER_RESOLUTION_CAPABILITY_ID.to_string(), + variants, + diagnostics, + } +} + +fn collect_target_request( + target: &TargetSpec, + catalog: &UpscaylModelCatalog, + requested: &mut BTreeSet, + diagnostics: &mut Vec, +) -> bool { + if target.capability.as_deref() != Some(SUPER_RESOLUTION_CAPABILITY_ID) { + return false; + } + if let Some(variant) = &target.variant { + add_variant_request(variant, catalog, requested, diagnostics); + } else if let Some(default_model) = catalog.default_model() { + requested.insert(default_model.variant_id.clone()); + } + true +} + +fn collect_selector_request( + selector: &SelectorSet, + catalog: &UpscaylModelCatalog, + requested: &mut BTreeSet, + diagnostics: &mut Vec, + allow_capability_expansion: bool, +) -> bool { + let capability_selected = selector + .capabilities + .iter() + .any(|capability| capability == SUPER_RESOLUTION_CAPABILITY_ID); + if !capability_selected && selector.variants.is_empty() { + return false; + } + if selector.variants.is_empty() && allow_capability_expansion { + for model in &catalog.models { + requested.insert(model.variant_id.clone()); + } + } else { + for variant in &selector.variants { + add_variant_request(variant, catalog, requested, diagnostics); + } + } + true +} + +fn add_variant_request( + variant: &str, + catalog: &UpscaylModelCatalog, + requested: &mut BTreeSet, + diagnostics: &mut Vec, +) { + if let Some(model) = catalog.get(variant) { + requested.insert(model.variant_id.clone()); + } else { + diagnostics.push( + UpscaylDiagnostic::new( + "variant.unknown", + format!("Upscayl variant/model '{variant}' is not registered"), + ) + .for_variant(variant), + ); + } +} + +fn apply_variant_exclusions(selector: &SelectorSet, requested: &mut BTreeSet) { + for variant in &selector.variants { + requested.remove(variant); + if !variant.starts_with("variant.") { + requested.remove(&canonical_variant_id(variant)); + } + } +} + +fn apply_execution_policy( + spec: &SpecV2, + requested: &mut BTreeSet, + diagnostics: &mut Vec, +) { + if spec.execution.ai == AiPolicy::Deny { + requested.clear(); + diagnostics.push(UpscaylDiagnostic::new( + "policy.ai.denied", + "Upscayl is an AI super-resolution transform; set execution.ai to local_only or allow to opt in", + )); + return; + } + if spec.execution.requirements.deterministic { + requested.clear(); + diagnostics.push(UpscaylDiagnostic::new( + "policy.determinism.unsatisfied", + "Upscayl output may vary across Vulkan/GPU implementations and cannot satisfy deterministic: true", + )); + return; + } + if spec + .execution + .tools + .deny + .iter() + .any(|tool| tool == UPSCAYL_TOOL_ID) + { + requested.clear(); + diagnostics.push(UpscaylDiagnostic::new( + "policy.tool.denied", + format!("provider '{UPSCAYL_TOOL_ID}' is denied by execution.tools"), + )); + return; + } + if !spec.execution.tools.allow.is_empty() + && !spec + .execution + .tools + .allow + .iter() + .any(|tool| tool == UPSCAYL_TOOL_ID) + { + requested.clear(); + diagnostics.push(UpscaylDiagnostic::new( + "policy.tool.not_allowed", + format!("provider '{UPSCAYL_TOOL_ID}' is not present in execution.tools.allow"), + )); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UpscaylReadinessStatus { + Ready, + MissingExecutable, + MissingModelFiles, + MissingVulkanRuntime, + RuntimeUnverified, + ProviderUnavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpscaylReadiness { + pub status: UpscaylReadinessStatus, + pub provider_executable: Option, + pub materialized_models: usize, + pub diagnostics: Vec, +} + +pub fn assess_upscayl_readiness_with( + catalog: &UpscaylModelCatalog, + probe: &dyn ToolProbe, + context: &ToolRuntimeContext, +) -> UpscaylReadiness { + let registry = ToolRegistry::builtins(); + let inventory = registry.assess_ids_with([UPSCAYL_TOOL_ID], probe, context); + let availability = inventory + .get(UPSCAYL_TOOL_ID) + .expect("built-in Upscayl provider must be represented in inventory"); + let materialized_models = catalog.materialized_models().count(); + let mut diagnostics = catalog.diagnostics.clone(); + + if !availability.is_available() { + let status = if availability.status == ToolAvailabilityStatus::MissingExecutable { + UpscaylReadinessStatus::MissingExecutable + } else { + UpscaylReadinessStatus::ProviderUnavailable + }; + diagnostics.push(UpscaylDiagnostic::new( + "provider.unavailable", + availability.summary(), + )); + return UpscaylReadiness { + status, + provider_executable: availability.selected_executable.clone(), + materialized_models, + diagnostics, + }; + } + + if materialized_models == 0 { + diagnostics.push(UpscaylDiagnostic::new( + "model_files.missing", + "no complete Upscayl .param/.bin model pairs were discovered", + )); + return UpscaylReadiness { + status: UpscaylReadinessStatus::MissingModelFiles, + provider_executable: availability.selected_executable.clone(), + materialized_models, + diagnostics, + }; + } + + if context.has_runtime_service("vulkan") { + return UpscaylReadiness { + status: UpscaylReadinessStatus::Ready, + provider_executable: availability.selected_executable.clone(), + materialized_models, + diagnostics, + }; + } + + let vulkan_probe = probe.probe("vulkaninfo", &["--summary".to_string()]); + let status = match vulkan_probe.status { + ToolProbeStatus::Available => UpscaylReadinessStatus::Ready, + ToolProbeStatus::Missing => { + diagnostics.push(UpscaylDiagnostic::new( + "vulkan.unverified", + "vulkaninfo is not available, so Vulkan/GPU readiness cannot be verified before execution", + )); + UpscaylReadinessStatus::RuntimeUnverified + } + ToolProbeStatus::Failed | ToolProbeStatus::TimedOut => { + diagnostics.push(UpscaylDiagnostic::new( + "vulkan.unavailable", + vulkan_probe + .diagnostic + .unwrap_or_else(|| "Vulkan probe failed".to_string()), + )); + UpscaylReadinessStatus::MissingVulkanRuntime + } + }; + + UpscaylReadiness { + status, + provider_executable: availability.selected_executable.clone(), + materialized_models, + diagnostics, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum UpscaylOutputFormat { + #[default] + Png, + Jpg, + Webp, +} + +impl UpscaylOutputFormat { + pub fn as_cli_str(self) -> &'static str { + match self { + Self::Png => "png", + Self::Jpg => "jpg", + Self::Webp => "webp", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpscaylOptions { + #[serde(default)] + pub requested_scale: Option, + #[serde(default)] + pub gpu_id: Option, + #[serde(default)] + pub tile_size: Option, + #[serde(default)] + pub tta: bool, + #[serde(default)] + pub output_format: UpscaylOutputFormat, + #[serde(default)] + pub compression: u8, + #[serde(default = "default_upscayl_timeout_seconds")] + pub timeout_seconds: u64, +} + +fn default_upscayl_timeout_seconds() -> u64 { + 30 * 60 +} + +impl Default for UpscaylOptions { + fn default() -> Self { + Self { + requested_scale: None, + gpu_id: None, + tile_size: None, + tta: false, + output_format: UpscaylOutputFormat::Png, + compression: 0, + timeout_seconds: default_upscayl_timeout_seconds(), + } + } +} + +impl UpscaylOptions { + pub fn validate(&self) -> Result<()> { + if matches!(self.requested_scale, Some(0 | 5..)) { + anyhow::bail!("requested Upscayl scale must be between 1 and 4"); + } + if self.compression > 100 { + anyhow::bail!("Upscayl compression must be between 0 and 100"); + } + if self.timeout_seconds == 0 { + anyhow::bail!("Upscayl timeout_seconds must be greater than zero"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpscaylExecutionRequest { + pub input: PathBuf, + pub output: PathBuf, + pub models_dir: PathBuf, + pub model: UpscaylModelDescriptor, + pub options: UpscaylOptions, +} + +impl UpscaylExecutionRequest { + pub fn validate(&self) -> Result<()> { + self.options.validate()?; + if !self.input.is_file() { + anyhow::bail!("Upscayl input '{}' is not a file", self.input.display()); + } + if !self.models_dir.is_dir() { + anyhow::bail!( + "Upscayl models directory '{}' is not a directory", + self.models_dir.display() + ); + } + if !self.model.is_materialized() { + anyhow::bail!( + "Upscayl model '{}' is not materialized with a complete .param/.bin pair", + self.model.variant_id + ); + } + Ok(()) + } + + pub fn command_arguments(&self) -> Result> { + self.options.validate()?; + let mut arguments = vec![ + "-i".to_string(), + self.input.to_string_lossy().into_owned(), + "-o".to_string(), + self.output.to_string_lossy().into_owned(), + "-m".to_string(), + self.models_dir.to_string_lossy().into_owned(), + "-n".to_string(), + self.model.model_name.clone(), + ]; + if let Some(requested_scale) = self.options.requested_scale { + if self.model.native_scale != Some(requested_scale) { + arguments.push("-s".to_string()); + arguments.push(requested_scale.to_string()); + } + } + if let Some(gpu_id) = &self.options.gpu_id { + arguments.push("-g".to_string()); + arguments.push(gpu_id.clone()); + } + arguments.push("-f".to_string()); + arguments.push(self.options.output_format.as_cli_str().to_string()); + arguments.push("-c".to_string()); + arguments.push(self.options.compression.to_string()); + if let Some(tile_size) = self.options.tile_size { + arguments.push("-t".to_string()); + arguments.push(tile_size.to_string()); + } + if self.options.tta { + arguments.push("-x".to_string()); + } + Ok(arguments) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpscaylExecutionEvidence { + pub provider_id: String, + pub capability_id: String, + pub variant_id: String, + pub model_name: String, + pub model_digest: String, + pub executable: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub executable_sha256: Option, + pub native_scale: Option, + pub requested_scale: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub post_scale: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gpu_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_backend: Option, + pub output_format: UpscaylOutputFormat, + pub command_configuration: BTreeMap, +} + +impl UpscaylExecutionEvidence { + pub fn toolchain_variant_evidence(&self) -> SelectedToolVariantEvidence { + let mut attributes = self.command_configuration.clone(); + attributes.insert("model_name".to_string(), self.model_name.clone()); + attributes.insert("model_digest".to_string(), self.model_digest.clone()); + if let Some(scale) = self.native_scale { + attributes.insert("native_scale".to_string(), scale.to_string()); + } + if let Some(scale) = self.requested_scale { + attributes.insert("requested_scale".to_string(), scale.to_string()); + } + if let Some(scale) = self.post_scale { + attributes.insert("post_scale".to_string(), scale.to_string()); + } + if let Some(gpu) = &self.gpu_id { + attributes.insert("gpu_id".to_string(), gpu.clone()); + } + if let Some(backend) = &self.runtime_backend { + attributes.insert("runtime_backend".to_string(), backend.clone()); + } + if let Some(binary_digest) = &self.executable_sha256 { + attributes.insert("executable_sha256".to_string(), binary_digest.clone()); + } + SelectedToolVariantEvidence { + id: self.variant_id.clone(), + attributes, + } + } +} + +#[derive(Clone)] +pub struct UpscaylAdapter { + executable: String, + executor: ProcessExecutor, +} + +impl UpscaylAdapter { + pub fn new(executable: impl Into) -> Self { + Self { + executable: executable.into(), + executor: ProcessExecutor::new(), + } + } + + pub fn from_inventory(inventory: &ToolInventory) -> Result { + let availability = inventory + .get(UPSCAYL_TOOL_ID) + .ok_or_else(|| anyhow::anyhow!("Upscayl provider was not assessed"))?; + if !availability.is_available() { + anyhow::bail!( + "Upscayl provider is unavailable: {}", + availability.summary() + ); + } + let executable = availability.selected_executable.clone().ok_or_else(|| { + anyhow::anyhow!("Upscayl provider did not resolve an executable candidate") + })?; + Ok(Self::new(executable)) + } + + pub fn execute(&self, request: &UpscaylExecutionRequest) -> Result { + request.validate()?; + let arguments = request.command_arguments()?; + let process_request = ProcessRequest::direct(&self.executable) + .args(arguments) + .network_policy(ProcessNetworkPolicy::Deny) + .timeout(Duration::from_secs(request.options.timeout_seconds)) + .expect_output(ProcessExpectedOutput::file(&request.output).require_non_empty()); + let result = self + .executor + .execute_checked(process_request) + .context("Upscayl execution failed")?; + + let runtime_backend = parse_runtime_backend(result.stdout().redacted_text()) + .or_else(|| parse_runtime_backend(result.stderr().redacted_text())); + let executable_sha256 = + resolve_executable_path(&self.executable).and_then(|path| sha256_file(&path).ok()); + let model_digest = request + .model + .model_digest + .clone() + .ok_or_else(|| anyhow::anyhow!("materialized model is missing model digest"))?; + + let mut command_configuration = BTreeMap::new(); + command_configuration.insert( + "output_format".to_string(), + request.options.output_format.as_cli_str().to_string(), + ); + command_configuration.insert( + "compression".to_string(), + request.options.compression.to_string(), + ); + command_configuration.insert("tta".to_string(), request.options.tta.to_string()); + if let Some(tile_size) = request.options.tile_size { + command_configuration.insert("tile_size".to_string(), tile_size.to_string()); + } + + let requested_scale = request.options.requested_scale; + let post_scale = requested_scale.filter(|scale| Some(*scale) != request.model.native_scale); + Ok(UpscaylExecutionEvidence { + provider_id: UPSCAYL_TOOL_ID.to_string(), + capability_id: SUPER_RESOLUTION_CAPABILITY_ID.to_string(), + variant_id: request.model.variant_id.clone(), + model_name: request.model.model_name.clone(), + model_digest, + executable: self.executable.clone(), + executable_sha256, + native_scale: request.model.native_scale, + requested_scale, + post_scale, + gpu_id: request.options.gpu_id.clone(), + runtime_backend, + output_format: request.options.output_format, + command_configuration, + }) + } +} + +fn validate_variant_id(value: &str) -> Result<()> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + anyhow::bail!( + "variant id '{}' may contain only ASCII letters, digits, '.', '_' and '-'", + value + ); + } + Ok(()) +} + +fn canonical_variant_id(model_name: &str) -> String { + let mut slug = String::new(); + let mut separator = false; + for character in model_name.chars() { + let character = character.to_ascii_lowercase(); + if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') { + slug.push(character); + separator = false; + } else if !separator { + slug.push('-'); + separator = true; + } + } + format!("variant.upscayl-ncnn.{}", slug.trim_matches('-')) +} + +fn infer_native_scale(model_name: &str) -> Option { + let lower = model_name.to_ascii_lowercase(); + for scale in [2_u32, 3, 4] { + if lower.contains(&format!("x{scale}")) || lower.contains(&format!("{scale}x")) { + return Some(scale); + } + } + None +} + +fn sha256_file(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("failed to read '{}'", path.display()))?; + let digest = Sha256::digest(bytes); + Ok(format!("sha256:{digest:x}")) +} + +fn combined_model_digest(param_path: &Path, bin_path: &Path) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"upscayl-model-v1\0param\0"); + hasher.update( + fs::read(param_path) + .with_context(|| format!("failed to read '{}'", param_path.display()))?, + ); + hasher.update(b"\0bin\0"); + hasher.update( + fs::read(bin_path).with_context(|| format!("failed to read '{}'", bin_path.display()))?, + ); + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +fn resolve_executable_path(executable: &str) -> Option { + let candidate = Path::new(executable); + if candidate.components().count() > 1 && candidate.is_file() { + return Some(candidate.to_path_buf()); + } + let path = env::var_os("PATH")?; + for directory in env::split_paths(&path) { + let candidate = directory.join(executable); + if candidate.is_file() { + return Some(candidate); + } + #[cfg(windows)] + { + let candidate = directory.join(format!("{executable}.exe")); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +fn parse_runtime_backend(output: &str) -> Option { + output.lines().find_map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.contains(']') { + Some(trimmed.to_string()) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::process::{ProcessPlatform, ProcessTreeTermination, ToolProbeEvidence}; + use crate::spec::{ExecutionPolicy, SourceKind, SourceSpec, TargetSelection, SPEC_V2_ID}; + + #[derive(Default)] + struct FakeProbe { + responses: BTreeMap, + } + + impl FakeProbe { + fn with(mut self, executable: &str, status: ToolProbeStatus) -> Self { + self.responses.insert( + executable.to_string(), + ToolProbeEvidence { + executable: executable.to_string(), + status, + version_line: Some("upscayl-ncnn help".to_string()), + duration_ms: 1, + platform: ProcessPlatform { + os: "linux", + arch: "x86_64", + tree_termination: ProcessTreeTermination::UnixProcessGroup, + }, + diagnostic: None, + }, + ); + self + } + } + + impl ToolProbe for FakeProbe { + fn probe(&self, executable: &str, _version_args: &[String]) -> ToolProbeEvidence { + self.responses + .get(executable) + .cloned() + .unwrap_or(ToolProbeEvidence { + executable: executable.to_string(), + status: ToolProbeStatus::Missing, + version_line: None, + duration_ms: 1, + platform: ProcessPlatform { + os: "linux", + arch: "x86_64", + tree_termination: ProcessTreeTermination::UnixProcessGroup, + }, + diagnostic: Some("missing".to_string()), + }) + } + } + + fn minimal_spec() -> SpecV2 { + SpecV2 { + schema: SPEC_V2_ID.to_string(), + sources: vec![SourceSpec { + id: "source.image".to_string(), + role: None, + kind: SourceKind::Artifact, + path: Some("image.png".to_string()), + uri: None, + members: Vec::new(), + media_type: Some("image/png".to_string()), + format: Some("png".to_string()), + detect: false, + immutable: true, + }], + profiles: BTreeMap::new(), + targets: TargetSelection::default(), + execution: ExecutionPolicy { + ai: AiPolicy::LocalOnly, + ..ExecutionPolicy::default() + }, + output: Default::default(), + variables: BTreeMap::new(), + transforms: None, + } + } + + #[test] + fn built_in_catalog_has_stable_seven_model_set() { + let catalog = UpscaylModelCatalog::builtins(); + assert_eq!(catalog.models.len(), 7); + let ids: Vec<&str> = catalog + .models + .iter() + .map(|model| model.variant_id.as_str()) + .collect(); + let mut sorted = ids.clone(); + sorted.sort(); + assert_eq!(ids, sorted); + assert!(catalog.get(DEFAULT_UPSCAYL_MODEL).is_some()); + } + + #[test] + fn non_commercial_models_are_explicit_metadata() { + let catalog = UpscaylModelCatalog::builtins(); + for name in ["remacri-4x", "ultramix-balanced-4x", "ultrasharp-4x"] { + assert_eq!( + catalog.get(name).unwrap().commercial_use, + CommercialUsePolicy::Prohibited + ); + } + } + + #[test] + fn custom_model_discovery_is_deterministic_and_checksum_sensitive() { + let directory = tempfile::tempdir().unwrap(); + let param = directory.path().join("comic-x2.param"); + let bin = directory.path().join("comic-x2.bin"); + fs::write(¶m, b"param-v1").unwrap(); + fs::write(&bin, b"weights-v1").unwrap(); + let first = UpscaylModelCatalog::discover(directory.path()).unwrap(); + let first_model = first.get("comic-x2").unwrap(); + assert_eq!(first_model.origin, UpscaylModelOrigin::Custom); + assert_eq!(first_model.native_scale, Some(2)); + let first_digest = first_model.model_digest.clone().unwrap(); + + fs::write(&bin, b"weights-v2").unwrap(); + let second = UpscaylModelCatalog::discover(directory.path()).unwrap(); + let second_digest = second + .get("comic-x2") + .unwrap() + .model_digest + .clone() + .unwrap(); + assert_ne!(first_digest, second_digest); + } + + #[test] + fn all_reachable_expands_every_model_when_ai_is_opted_in() { + let mut spec = minimal_spec(); + spec.targets.all_reachable = true; + let report = select_upscayl_variants(&spec, &UpscaylModelCatalog::builtins()); + assert_eq!(report.variants.len(), 7); + assert!(report.diagnostics.is_empty()); + } + + #[test] + fn exact_variant_selection_resolves_one_model() { + let mut spec = minimal_spec(); + spec.targets.exact.push(TargetSpec { + id: Some("target.upscale".to_string()), + role: None, + format: None, + family: None, + capability: Some(SUPER_RESOLUTION_CAPABILITY_ID.to_string()), + transform: None, + variant: Some("variant.upscayl-ncnn.digital-art-4x".to_string()), + preset: None, + template: None, + }); + let report = select_upscayl_variants(&spec, &UpscaylModelCatalog::builtins()); + assert_eq!(report.variants.len(), 1); + assert_eq!(report.variants[0].model_name, "digital-art-4x"); + } + + #[test] + fn ai_deny_blocks_super_resolution_expansion() { + let mut spec = minimal_spec(); + spec.execution.ai = AiPolicy::Deny; + spec.targets.all_reachable = true; + let report = select_upscayl_variants(&spec, &UpscaylModelCatalog::builtins()); + assert!(report.variants.is_empty()); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "policy.ai.denied")); + } + + #[test] + fn command_arguments_distinguish_native_and_post_scale() { + let mut model = UpscaylModelCatalog::builtins() + .get(DEFAULT_UPSCAYL_MODEL) + .unwrap() + .clone(); + model.param_path = Some(PathBuf::from("models/upscayl-standard-4x.param")); + model.bin_path = Some(PathBuf::from("models/upscayl-standard-4x.bin")); + model.param_sha256 = Some("sha256:param".to_string()); + model.bin_sha256 = Some("sha256:bin".to_string()); + model.model_digest = Some("sha256:model".to_string()); + let request = UpscaylExecutionRequest { + input: PathBuf::from("input.png"), + output: PathBuf::from("output.png"), + models_dir: PathBuf::from("models"), + model, + options: UpscaylOptions { + requested_scale: Some(2), + ..UpscaylOptions::default() + }, + }; + let args = request.command_arguments().unwrap(); + let scale_index = args.iter().position(|arg| arg == "-s").unwrap(); + assert_eq!(args[scale_index + 1], "2"); + } + + #[test] + fn all_reachable_can_select_variant_subset() { + let mut spec = minimal_spec(); + spec.targets.all_reachable = true; + spec.targets + .include + .capabilities + .push(SUPER_RESOLUTION_CAPABILITY_ID.to_string()); + spec.targets.include.variants = vec![ + "variant.upscayl-ncnn.digital-art-4x".to_string(), + "variant.upscayl-ncnn.high-fidelity-4x".to_string(), + ]; + + let report = select_upscayl_variants(&spec, &UpscaylModelCatalog::builtins()); + let names: Vec<&str> = report + .variants + .iter() + .map(|model| model.model_name.as_str()) + .collect(); + assert_eq!(names, vec!["digital-art-4x", "high-fidelity-4x"]); + } + + #[test] + fn all_reachable_can_exclude_named_variant() { + let mut spec = minimal_spec(); + spec.targets.all_reachable = true; + spec.targets.exclude.variants = vec!["variant.upscayl-ncnn.ultrasharp-4x".to_string()]; + + let report = select_upscayl_variants(&spec, &UpscaylModelCatalog::builtins()); + assert_eq!(report.variants.len(), 6); + assert!(!report + .variants + .iter() + .any(|model| model.model_name == "ultrasharp-4x")); + } + + #[test] + fn readiness_reports_missing_executable_without_gpu() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("comic-x2.param"), b"param").unwrap(); + fs::write(directory.path().join("comic-x2.bin"), b"bin").unwrap(); + let catalog = UpscaylModelCatalog::discover(directory.path()).unwrap(); + let context = ToolRuntimeContext::for_platform("linux", "x86_64"); + + let readiness = assess_upscayl_readiness_with(&catalog, &FakeProbe::default(), &context); + assert_eq!(readiness.status, UpscaylReadinessStatus::MissingExecutable); + } + + #[test] + fn readiness_reports_missing_model_files() { + let catalog = UpscaylModelCatalog::builtins(); + let probe = FakeProbe::default().with("upscayl-ncnn", ToolProbeStatus::Available); + let context = ToolRuntimeContext::for_platform("linux", "x86_64"); + + let readiness = assess_upscayl_readiness_with(&catalog, &probe, &context); + assert_eq!(readiness.status, UpscaylReadinessStatus::MissingModelFiles); + } + + #[test] + fn compression_out_of_range_is_rejected() { + let options = UpscaylOptions { + compression: 101, + ..UpscaylOptions::default() + }; + assert!(options.validate().is_err()); + } + + #[test] + #[ignore = "requires an Upscayl executable, Vulkan GPU, model files, and explicit input path"] + fn upscayl_real_provider_smoke() { + let input = env::var("RENDERFLOW_UPSCAYL_SMOKE_INPUT") + .expect("set RENDERFLOW_UPSCAYL_SMOKE_INPUT for the ignored smoke test"); + let models_dir = env::var("RENDERFLOW_UPSCAYL_MODELS_DIR") + .expect("set RENDERFLOW_UPSCAYL_MODELS_DIR for the ignored smoke test"); + let executable = + env::var("RENDERFLOW_UPSCAYL_EXECUTABLE").unwrap_or_else(|_| "upscayl-bin".to_string()); + let catalog = UpscaylModelCatalog::discover(&models_dir).unwrap(); + let model = catalog + .get(DEFAULT_UPSCAYL_MODEL) + .expect("default Upscayl model must be present") + .clone(); + assert!(model.is_materialized(), "default model files are missing"); + let output_dir = tempfile::tempdir().unwrap(); + let output = output_dir.path().join("upscayl-smoke.png"); + let evidence = UpscaylAdapter::new(executable) + .execute(&UpscaylExecutionRequest { + input: PathBuf::from(input), + output: output.clone(), + models_dir: PathBuf::from(models_dir), + model, + options: UpscaylOptions::default(), + }) + .unwrap(); + assert!(output.is_file()); + assert_eq!(evidence.provider_id, UPSCAYL_TOOL_ID); + } + + #[test] + fn readiness_can_be_tested_without_gpu_hardware() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("comic-x2.param"), b"param").unwrap(); + fs::write(directory.path().join("comic-x2.bin"), b"bin").unwrap(); + let catalog = UpscaylModelCatalog::discover(directory.path()).unwrap(); + let probe = FakeProbe::default() + .with("upscayl-ncnn", ToolProbeStatus::Available) + .with("vulkaninfo", ToolProbeStatus::Available); + let context = ToolRuntimeContext::for_platform("linux", "x86_64"); + let readiness = assess_upscayl_readiness_with(&catalog, &probe, &context); + assert_eq!(readiness.status, UpscaylReadinessStatus::Ready); + } +} diff --git a/crates/renderflow-core/src/toolchain.rs b/crates/renderflow-core/src/toolchain.rs index 563f2e8..8485d5d 100644 --- a/crates/renderflow-core/src/toolchain.rs +++ b/crates/renderflow-core/src/toolchain.rs @@ -408,6 +408,10 @@ impl ToolRuntimeContext { self.runtime_services.insert(service.into()); self } + + pub fn has_runtime_service(&self, service: &str) -> bool { + self.runtime_services.contains(service) + } } /// Probe seam used by tests and embedders to avoid arbitrary host-state dependencies. @@ -520,6 +524,14 @@ impl ToolInventory { } } +/// Evidence for one selected provider variant/model included in a toolchain fingerprint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SelectedToolVariantEvidence { + pub id: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub attributes: BTreeMap, +} + /// Evidence for a selected provider included in a toolchain fingerprint. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SelectedToolEvidence { @@ -529,6 +541,8 @@ pub struct SelectedToolEvidence { pub capabilities: Vec, pub determinism: ToolDeterminism, pub locality: ToolLocality, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub variants: Vec, } /// Reproducibility evidence derived only from providers selected by a plan/run. @@ -762,6 +776,20 @@ impl ToolRegistry { ids: I, context: &ToolRuntimeContext, ) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.fingerprint_selected_with_variants(inventory, ids, &BTreeMap::new(), context) + } + + pub fn fingerprint_selected_with_variants( + &self, + inventory: &ToolInventory, + ids: I, + variants: &BTreeMap>, + context: &ToolRuntimeContext, + ) -> Result where I: IntoIterator, S: AsRef, @@ -782,6 +810,13 @@ impl ToolRegistry { let descriptor = self .get(&id) .ok_or_else(|| anyhow::anyhow!("tool '{}' is not registered", id))?; + let mut selected_variants = variants.get(&id).cloned().unwrap_or_default(); + selected_variants.sort_by(|left, right| { + left.id + .cmp(&right.id) + .then_with(|| left.attributes.cmp(&right.attributes)) + }); + selected_variants.dedup(); selected_tools.push(SelectedToolEvidence { id: descriptor.id.clone(), executable: availability.selected_executable.clone(), @@ -792,6 +827,7 @@ impl ToolRegistry { capabilities: descriptor.capabilities.clone(), determinism: descriptor.determinism, locality: descriptor.locality, + variants: selected_variants, }); } selected_tools.sort_by(|left, right| left.id.cmp(&right.id)); @@ -820,11 +856,25 @@ impl ToolRegistry { dag: &MultiTargetDag, context: &ToolRuntimeContext, ) -> Result { - self.fingerprint_selected( + let mut variants: BTreeMap> = BTreeMap::new(); + for edge in dag.all_edges() { + if let (Some(provider_id), Some(variant_id)) = + (edge.provider_id.as_deref(), edge.variant_id.as_deref()) + { + variants.entry(provider_id.to_string()).or_default().push( + SelectedToolVariantEvidence { + id: variant_id.to_string(), + attributes: edge.evidence.clone(), + }, + ); + } + } + self.fingerprint_selected_with_variants( inventory, dag.all_edges() .iter() .filter_map(|edge| edge.provider_id.as_deref()), + &variants, context, ) } @@ -1077,6 +1127,7 @@ pub fn canonical_tool_id_for_hint(hint: &str) -> ToolId { "zip" => "tool.zip".to_string(), "img2pdf" => "tool.img2pdf".to_string(), "gs" | "ghostscript" => "tool.ghostscript".to_string(), + "upscayl-ncnn" | "upscayl-bin" => "tool.upscayl-ncnn".to_string(), value => format!("tool.command.{}", slug(value)), }; ToolId::new(canonical).expect("canonicalized tool id is valid") @@ -1342,6 +1393,39 @@ mod tests { assert_ne!(first.fingerprint, only_a.fingerprint); } + #[test] + fn selected_variant_material_changes_toolchain_fingerprint() { + let mut registry = ToolRegistry::new(); + registry.register(test_descriptor("tool.a", "a")).unwrap(); + let probe = FakeProbe::default().with("a", ProcessProbeStatus::Available, Some("a 1.2.3")); + let context = ToolRuntimeContext::for_platform("linux", "x86_64"); + let inventory = registry.assess_all_with(&probe, &context); + + let mut first_variants = BTreeMap::new(); + first_variants.insert( + "tool.a".to_string(), + vec![SelectedToolVariantEvidence { + id: "variant.a".to_string(), + attributes: BTreeMap::from([( + "model_digest".to_string(), + "sha256:first".to_string(), + )]), + }], + ); + let mut second_variants = first_variants.clone(); + second_variants.get_mut("tool.a").unwrap()[0] + .attributes + .insert("model_digest".to_string(), "sha256:second".to_string()); + + let first = registry + .fingerprint_selected_with_variants(&inventory, ["tool.a"], &first_variants, &context) + .unwrap(); + let second = registry + .fingerprint_selected_with_variants(&inventory, ["tool.a"], &second_variants, &context) + .unwrap(); + assert_ne!(first.fingerprint, second.fingerprint); + } + #[test] fn dynamic_command_provider_can_be_augmented_with_transform_capability() { let mut registry = ToolRegistry::builtins(); diff --git a/docs/cli-reference/tools.md b/docs/cli-reference/tools.md index 15ecbbd..f71de1b 100644 --- a/docs/cli-reference/tools.md +++ b/docs/cli-reference/tools.md @@ -21,6 +21,16 @@ renderflow tools inspect tool.pandoc --format json Inspection includes discovery strategy, live availability state, selected executable, installed version evidence, determinism/locality/fidelity metadata, capability IDs, fallbacks, and diagnostics. +## List provider variants + +```bash +renderflow tools variants tool.upscayl-ncnn +renderflow tools variants tool.upscayl-ncnn --models-dir /path/to/models +renderflow tools variants tool.upscayl-ncnn --models-dir /path/to/models --format json +``` + +The generic variant surface exposes stable provider-specific model/variant identities. When runtime material is supplied, structured output can include checksums and discovery diagnostics without changing the canonical built-in catalog. + ## List capabilities ```bash @@ -32,6 +42,6 @@ Capability IDs and provider IDs are stable machine-readable identifiers. Human-r ## Toolchain fingerprints -Graph planning fingerprints only providers selected by the final DAG. The fingerprint includes the selected provider IDs, compatible installed versions, relevant executable identity, provider capability metadata, and the target OS/architecture. It does **not** hash the entire host environment. +Graph planning fingerprints only providers selected by the final DAG. The fingerprint includes the selected provider IDs, compatible installed versions, relevant executable identity, provider capability metadata, selected variant/model evidence when present, and the target OS/architecture. It does **not** hash the entire host environment. Graph execution uses that fingerprint in artifact-cache compatibility and writes the selected toolchain evidence into Renderflow state for reproducibility/provenance consumers. diff --git a/docs/user-guide/super-resolution.md b/docs/user-guide/super-resolution.md new file mode 100644 index 0000000..87a37aa --- /dev/null +++ b/docs/user-guide/super-resolution.md @@ -0,0 +1,137 @@ +# Image super-resolution + +Renderflow models AI image super-resolution as an explicit local transform capability rather than a hidden image-export option. + +The first built-in provider is Upscayl's NCNN backend: + +- provider: `tool.upscayl-ncnn` +- capability: `image.super_resolution` +- executable candidates: `upscayl-ncnn`, `upscayl-bin` + +## Why model variants are first-class + +Super-resolution models make different trade-offs. Renderflow therefore keeps the selected model as stable transform identity and reproducibility evidence instead of treating `-n ` as an opaque command flag. + +A maximal v2 request can enumerate every enabled model candidate for later comparison. An exact request can name one variant. The unified planner work in #354 consumes this selection API so exact and exhaustive requests use the same execution architecture. + +```yaml +schema: renderflow/v2 + +sources: + - id: source.page + path: page.png + media_type: image/png + +targets: + exact: + - id: target.upscale + capability: image.super_resolution + variant: variant.upscayl-ncnn.digital-art-4x + +execution: + ai: local_only + network: deny +``` + +For exhaustive evaluation: + +```yaml +targets: + all_reachable: true + include: + capabilities: [image.super_resolution] + +execution: + ai: local_only +``` + +Use `include.variants` or `exclude.variants` to constrain an exhaustive run without writing duplicate transform definitions. + +## Runtime requirements + +Upscayl-NCNN runs locally and does not require network access during transformation, but it does require: + +1. an Upscayl NCNN executable (`upscayl-ncnn` or packaged `upscayl-bin`), +2. a compatible Vulkan-capable runtime/GPU backend, +3. complete NCNN model pairs (`.param` and `.bin`). + +`renderflow tools list` reports executable availability. Model material and GPU readiness are separate evidence because a binary can exist on a host that cannot actually execute the selected model. + +When `vulkaninfo --summary` is available Renderflow can proactively verify Vulkan readiness. If `vulkaninfo` is absent, readiness is reported as unverified rather than falsely declaring every non-Linux or MoltenVK setup broken. + +## Inspect variants + +```bash +renderflow tools inspect tool.upscayl-ncnn +renderflow tools variants tool.upscayl-ncnn +renderflow tools variants tool.upscayl-ncnn --models-dir /path/to/models +renderflow tools variants tool.upscayl-ncnn --models-dir /path/to/models --format json +``` + +Without `--models-dir`, the command lists the canonical built-in model identities and policy metadata. With a model directory, it also records materialization state and SHA-256 evidence for `.param`/`.bin` pairs and discovers custom models. + +## Custom models + +Custom model pairs are discovered without being promoted into the canonical built-in catalog. Their variant ID is derived deterministically from the model filename, and their model digest is derived from the actual `.param` and `.bin` bytes. + +For example: + +```text +my-comic-x2.param +my-comic-x2.bin +``` + +becomes a runtime variant similar to: + +```text +variant.upscayl-ncnn.my-comic-x2 +``` + +Renderflow infers native scale from conventional `x2`/`2x`, `x3`/`3x`, or `x4`/`4x` naming. When the native scale cannot be inferred, the model remains usable as explicit runtime material but carries an actionable `model_scale.unknown` diagnostic. + +## Native scale versus requested scale + +The canonical default Upscayl models are native x4 models. When a requested scale differs, the distinction is retained in evidence as native model scale versus post-processing/requested scale. Renderflow does not present an emulated x2/x3 output as though a native x2/x3 model produced it. + +## Reproducibility evidence + +Selected super-resolution variants can contribute the following material to the toolchain/cache fingerprint and artifact provenance: + +- provider and capability IDs, +- stable variant/model ID, +- model `.param` and `.bin` SHA-256 material digest, +- Upscayl executable identity and executable SHA-256 when resolvable, +- native scale and requested/post scale, +- output format, compression, tile size, and TTA configuration, +- selected GPU ID and runtime backend line when reported by Upscayl. + +GPU/Vulkan implementation identity is evidence because AI super-resolution should not be assumed bit-for-bit portable across hardware backends. + +## Publication licensing + +Model licensing is independent of the Upscayl application/backend license. The canonical model catalog intentionally records commercial-use policy instead of assuming every bundled model is interchangeable for publication. + +See [Upscayl model reference](upscayl-models.md) for the current catalog. A model marked `unknown` requires its own license check before commercial publication. A model marked `prohibited` is currently labeled Non-Commercial by Upscayl and should not be selected for commercial output without separately establishing permission. + +## CI and real-GPU smoke testing + +Ordinary CI must not assume GitHub-hosted runners expose a supported Vulkan GPU. Renderflow therefore tests catalog discovery, checksums, policy filtering, variant expansion, toolchain evidence, and command construction using fixtures/fake probes. + +For a real host, first verify the provider and model material: + +```bash +renderflow tools inspect tool.upscayl-ncnn +renderflow tools variants tool.upscayl-ncnn --models-dir /path/to/models +vulkaninfo --summary +``` + +Then run the ignored real-provider smoke test with explicit paths: + +```bash +RENDERFLOW_UPSCAYL_SMOKE_INPUT=/path/to/input.png \ +RENDERFLOW_UPSCAYL_MODELS_DIR=/path/to/models \ +RENDERFLOW_UPSCAYL_EXECUTABLE=upscayl-bin \ +cargo test -p renderflow upscayl_real_provider_smoke -- --ignored --nocapture +``` + +This same command can move to a GPU-enabled self-hosted CI runner later without creating a second test path. diff --git a/docs/user-guide/tool-registry.md b/docs/user-guide/tool-registry.md index d349219..5053b9b 100644 --- a/docs/user-guide/tool-registry.md +++ b/docs/user-guide/tool-registry.md @@ -22,6 +22,7 @@ without editing this built-in catalog. | `tool.img2pdf` | img2pdf | executable: `img2pdf` | experimental | deterministic | local | | `tool.pandoc` | Pandoc | executable: `pandoc` | required | configuration_dependent | local | | `tool.tectonic` | Tectonic | executable: `tectonic` | optional | configuration_dependent | network_optional | +| `tool.upscayl-ncnn` | Upscayl NCNN | executable: `upscayl-ncnn`, `upscayl-bin` | experimental | configuration_dependent | local | | `tool.wkhtmltopdf` | wkhtmltopdf | executable: `wkhtmltopdf` | experimental | configuration_dependent | local | | `tool.zip` | Info-ZIP compatible zip | executable: `zip` | experimental | configuration_dependent | local | @@ -40,6 +41,7 @@ without editing this built-in catalog. | `document.generate` | `tool.pandoc` | | `latex.compile` | `tool.tectonic` | | `pdf.typeset` | `tool.tectonic` | +| `image.super_resolution` | `tool.upscayl-ncnn` | | `html.render.pdf` | `tool.wkhtmltopdf` | | `archive.zip.create` | `tool.zip` | | `comic.cbz.create` | `tool.zip` | diff --git a/docs/user-guide/upscayl-models.md b/docs/user-guide/upscayl-models.md new file mode 100644 index 0000000..a880d7f --- /dev/null +++ b/docs/user-guide/upscayl-models.md @@ -0,0 +1,23 @@ + +# Upscayl model reference + +This page is generated from the canonical model catalog at +`crates/renderflow-core/data/upscayl-models.yaml`. Do not edit it by hand. + +Catalog schema: `renderflow.upscayl-models/v1` + +| Variant ID | CLI model | Native scale | Commercial use | Notes | +| --- | --- | ---: | --- | --- | +| `variant.upscayl-ncnn.digital-art-4x` | `digital-art-4x` | x4 | unknown | Model-specific usage terms are not asserted by Renderflow; verify the model license before commercial publication. | +| `variant.upscayl-ncnn.high-fidelity-4x` | `high-fidelity-4x` | x4 | unknown | Model-specific usage terms are not asserted by Renderflow; verify the model license before commercial publication. | +| `variant.upscayl-ncnn.remacri-4x` | `remacri-4x` | x4 | prohibited | Upscayl currently labels this model Non-Commercial. Do not select it for commercial publication without separately establishing permission. | +| `variant.upscayl-ncnn.ultramix-balanced-4x` | `ultramix-balanced-4x` | x4 | prohibited | Upscayl currently labels this model Non-Commercial. Do not select it for commercial publication without separately establishing permission. | +| `variant.upscayl-ncnn.ultrasharp-4x` | `ultrasharp-4x` | x4 | prohibited | Upscayl currently labels this model Non-Commercial. Do not select it for commercial publication without separately establishing permission. | +| `variant.upscayl-ncnn.upscayl-lite-4x` | `upscayl-lite-4x` | x4 | unknown | Model-specific usage terms are not asserted by Renderflow; verify the model license before commercial publication. | +| `variant.upscayl-ncnn.upscayl-standard-4x` | `upscayl-standard-4x` | x4 | unknown | Model-specific usage terms are not asserted by Renderflow; verify the model license before commercial publication. | + +!!! warning + `unknown` means Renderflow does not assert commercial-use permission. + Verify the model's own terms before commercial publication. Models marked + `prohibited` are currently labeled Non-Commercial by Upscayl and should not + be selected for commercial output without separately establishing permission. diff --git a/mkdocs.yml b/mkdocs.yml index bc2f0d1..1d91dab 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,8 @@ nav: - Spec v2 Reference: user-guide/spec-v2-reference.md - Supported Formats: user-guide/supported-formats.md - Tool Registry: user-guide/tool-registry.md + - Super Resolution: user-guide/super-resolution.md + - Upscayl Models: user-guide/upscayl-models.md - Pipelines: user-guide/pipelines.md - Transforms: user-guide/transforms.md - Optimization: user-guide/optimization.md diff --git a/schemas/renderflow-v2.schema.json b/schemas/renderflow-v2.schema.json index 38cadf8..fa288bd 100644 --- a/schemas/renderflow-v2.schema.json +++ b/schemas/renderflow-v2.schema.json @@ -240,6 +240,13 @@ "type": "string" }, "type": "array" + }, + "variants": { + "default": [], + "items": { + "$ref": "#/$defs/stableId" + }, + "type": "array" } }, "type": "object" @@ -389,6 +396,16 @@ "string", "null" ] + }, + "variant": { + "anyOf": [ + { + "$ref": "#/$defs/stableId" + }, + { + "type": "null" + } + ] } }, "type": "object" diff --git a/scripts/generate_upscayl_models_doc.py b/scripts/generate_upscayl_models_doc.py new file mode 100644 index 0000000..4931157 --- /dev/null +++ b/scripts/generate_upscayl_models_doc.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Generate the Upscayl model reference from the canonical model catalog.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CATALOG_PATH = ROOT / "crates" / "renderflow-core" / "data" / "upscayl-models.yaml" +OUTPUT_PATH = ROOT / "docs" / "user-guide" / "upscayl-models.md" + + +def escape(value: object) -> str: + """Escape Markdown table separators in generated values.""" + return str(value).replace("|", "\\|").replace("\n", " ") + + +def main() -> None: + """Generate the checked-in model reference page.""" + catalog = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) + models = sorted(catalog.get("models", []), key=lambda model: model["variant_id"]) + + lines = [ + "", + "# Upscayl model reference", + "", + "This page is generated from the canonical model catalog at", + "`crates/renderflow-core/data/upscayl-models.yaml`. Do not edit it by hand.", + "", + f"Catalog schema: `{catalog['schema']}`", + "", + "| Variant ID | CLI model | Native scale | Commercial use | Notes |", + "| --- | --- | ---: | --- | --- |", + ] + for model in models: + lines.append( + "| " + + " | ".join( + [ + f"`{escape(model['variant_id'])}`", + f"`{escape(model['model_name'])}`", + f"x{escape(model['native_scale'])}", + escape(model["commercial_use"]), + escape(model["license_notes"]), + ] + ) + + " |" + ) + + lines.extend( + [ + "", + "!!! warning", + " `unknown` means Renderflow does not assert commercial-use permission.", + " Verify the model's own terms before commercial publication. Models marked", + " `prohibited` are currently labeled Non-Commercial by Upscayl and should not", + " be selected for commercial output without separately establishing permission.", + "", + ] + ) + OUTPUT_PATH.write_text("\n".join(lines), encoding="utf-8") + print(f"Generated {OUTPUT_PATH.relative_to(ROOT)}") + + +if __name__ == "__main__": + main()