440 lines
16 KiB
Rust
440 lines
16 KiB
Rust
mod support;
|
|
|
|
use makepad_ai_hub::client::{ArtifactBytes, ContentProvider};
|
|
use makepad_ai_hub::error::AssetAiError;
|
|
use makepad_ai_hub::protocol::{
|
|
ArtifactRefJson, GenerateRequestJson, HealthJson, JobStatusJson, ModelInfoJson,
|
|
JOB_STATE_CANCELLED, JOB_STATE_DONE, JOB_STATE_RUNNING,
|
|
};
|
|
use makepad_ai_hub::registry::Domain;
|
|
use makepad_flow::engine::executors::gen::GenSeam;
|
|
use makepad_flow::engine::{RunEvent, RunState, Seams};
|
|
use makepad_flow::graph::{evaluate, is_canonical, write};
|
|
use makepad_flow::{Literal, PortType, Value};
|
|
use std::collections::{BTreeMap, HashSet};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use support::{FakeChat, FakeHttp};
|
|
|
|
#[test]
|
|
fn every_recipe_template_evaluates_and_round_trips() {
|
|
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("recipes/templates");
|
|
let mut templates: Vec<_> = fs::read_dir(&template_dir)
|
|
.unwrap_or_else(|error| panic!("cannot read {}: {error}", template_dir.display()))
|
|
.map(|entry| entry.expect("cannot read template directory entry").path())
|
|
.filter(|path| path.extension().is_some_and(|extension| extension == "splash"))
|
|
.collect();
|
|
templates.sort();
|
|
assert!(!templates.is_empty(), "no recipe templates found");
|
|
|
|
for path in templates {
|
|
let name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.expect("template file name is not UTF-8");
|
|
println!("checking recipe template: {name}");
|
|
let source = fs::read_to_string(&path)
|
|
.unwrap_or_else(|error| panic!("{name}: cannot read template: {error}"));
|
|
let graph = evaluate(&source, name)
|
|
.unwrap_or_else(|error| panic!("{name}: evaluation failed: {error}"));
|
|
|
|
if source.contains("let prompt = Input") {
|
|
assert!(
|
|
graph
|
|
.nodes
|
|
.iter()
|
|
.any(|node| node.id == "prompt" && node.kind == "input"),
|
|
"{name}: expected an Input node named `prompt`"
|
|
);
|
|
}
|
|
|
|
let mut reachable: HashSet<&str> = graph
|
|
.nodes
|
|
.iter()
|
|
.filter(|node| node.kind == "input")
|
|
.map(|node| node.id.as_str())
|
|
.collect();
|
|
loop {
|
|
let before = reachable.len();
|
|
for edge in &graph.edges {
|
|
if reachable.contains(edge.from_node.as_str()) {
|
|
reachable.insert(edge.to_node.as_str());
|
|
}
|
|
}
|
|
if reachable.len() == before {
|
|
break;
|
|
}
|
|
}
|
|
let outputs: Vec<_> = graph
|
|
.nodes
|
|
.iter()
|
|
.filter(|node| matches!(node.kind.as_str(), "output" | "publish"))
|
|
.collect();
|
|
assert!(!outputs.is_empty(), "{name}: template has no terminal node");
|
|
for output in outputs {
|
|
assert!(
|
|
reachable.contains(output.id.as_str()),
|
|
"{name}: Output `{}` is not reachable from an Input",
|
|
output.id
|
|
);
|
|
}
|
|
|
|
let written = write(&graph);
|
|
let rewritten = write(
|
|
&evaluate(&written, name)
|
|
.unwrap_or_else(|error| panic!("{name}: written form failed: {error}")),
|
|
);
|
|
assert_eq!(rewritten, written, "{name}: writer did not round-trip");
|
|
assert!(is_canonical(&written), "{name}: written form is not canonical");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shipped_templates_are_warning_free_and_keep_typed_multi_input_edges() {
|
|
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("recipes/templates");
|
|
for entry in fs::read_dir(&template_dir).unwrap() {
|
|
let path = entry.unwrap().path();
|
|
if path.extension().is_none_or(|extension| extension != "splash") {
|
|
continue;
|
|
}
|
|
let name = path.file_name().unwrap().to_str().unwrap();
|
|
let source = fs::read_to_string(&path).unwrap();
|
|
let graph = evaluate(&source, name).unwrap();
|
|
assert!(graph.warnings.is_empty(), "{name}: {:?}", graph.warnings);
|
|
}
|
|
|
|
let dream = evaluate(include_str!("../recipes/templates/dream.splash"), "dream.splash").unwrap();
|
|
let keyframes: HashSet<_> = dream
|
|
.edges
|
|
.iter()
|
|
.filter(|edge| edge.from_node == "image" && edge.to_node == "video")
|
|
.map(|edge| (edge.from_port.as_str(), edge.to_port.as_str()))
|
|
.collect();
|
|
assert_eq!(keyframes, HashSet::from([("image", "image"), ("image", "last_frame")]));
|
|
|
|
let music = evaluate(include_str!("../recipes/templates/music.splash"), "music.splash").unwrap();
|
|
assert!(music.edges.iter().any(|edge| {
|
|
edge.from_node == "lyrics"
|
|
&& edge.from_port == "text"
|
|
&& edge.to_node == "music"
|
|
&& edge.to_port == "lyrics"
|
|
}));
|
|
|
|
let inpaint = evaluate(include_str!("../recipes/templates/inpaint.splash"), "inpaint.splash")
|
|
.unwrap();
|
|
let image_edges: HashSet<_> = inpaint
|
|
.edges
|
|
.iter()
|
|
.filter(|edge| edge.to_node == "inpaint" && matches!(edge.to_port.as_str(), "image" | "mask"))
|
|
.map(|edge| (edge.from_node.as_str(), edge.to_port.as_str()))
|
|
.collect();
|
|
assert_eq!(image_edges, HashSet::from([("image", "image"), ("mask", "mask")]));
|
|
let node = inpaint.nodes.iter().find(|node| node.id == "inpaint").unwrap();
|
|
assert!(node
|
|
.inputs
|
|
.iter()
|
|
.filter(|input| matches!(input.port.as_str(), "image" | "mask"))
|
|
.all(|input| input.ty == makepad_flow::PortType::Image));
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct DomainFake {
|
|
requests: Arc<Mutex<Vec<(Domain, GenerateRequestJson)>>>,
|
|
}
|
|
|
|
impl GenSeam for DomainFake {
|
|
fn pick(&self, domain: &str) -> Result<Box<dyn ContentProvider>, String> {
|
|
let domain = Domain::parse(domain)
|
|
.ok_or_else(|| format!("template requested unknown domain `{domain}`"))?;
|
|
Ok(Box::new(DomainProvider {
|
|
domain,
|
|
polls: AtomicUsize::new(0),
|
|
cancelled: AtomicBool::new(false),
|
|
requests: self.requests.clone(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
struct DomainProvider {
|
|
domain: Domain,
|
|
polls: AtomicUsize,
|
|
cancelled: AtomicBool,
|
|
requests: Arc<Mutex<Vec<(Domain, GenerateRequestJson)>>>,
|
|
}
|
|
|
|
impl ContentProvider for DomainProvider {
|
|
fn health(&self) -> Result<HealthJson, AssetAiError> {
|
|
Err(AssetAiError::Unavailable("not used by template test".to_string()))
|
|
}
|
|
|
|
fn list_models(&self) -> Result<Vec<ModelInfoJson>, AssetAiError> {
|
|
Err(AssetAiError::Unavailable("not used by template test".to_string()))
|
|
}
|
|
|
|
fn request(
|
|
&self,
|
|
domain: Domain,
|
|
request: &GenerateRequestJson,
|
|
) -> Result<String, AssetAiError> {
|
|
assert_eq!(domain, self.domain);
|
|
self.requests.lock().unwrap().push((domain, request.clone()));
|
|
Ok(format!("{}-job", domain.as_str()))
|
|
}
|
|
|
|
fn poll(&self, job_id: &str) -> Result<JobStatusJson, AssetAiError> {
|
|
let running = self.polls.fetch_add(1, Ordering::Relaxed) == 0;
|
|
Ok(domain_status(self.domain, job_id, running))
|
|
}
|
|
|
|
fn fetch_artifact(&self, artifact_id: &str) -> Result<ArtifactBytes, AssetAiError> {
|
|
let index = artifact_id
|
|
.rsplit('-')
|
|
.next()
|
|
.and_then(|index| index.parse::<usize>().ok())
|
|
.unwrap_or(0);
|
|
let (content_type, bytes) = domain_artifact(self.domain, index);
|
|
Ok(ArtifactBytes {
|
|
content_type: content_type.to_string(),
|
|
bytes,
|
|
})
|
|
}
|
|
|
|
fn cancel(&self, job_id: &str) -> Result<JobStatusJson, AssetAiError> {
|
|
self.cancelled.store(true, Ordering::Relaxed);
|
|
let mut status = domain_status(self.domain, job_id, false);
|
|
status.state = JOB_STATE_CANCELLED.to_string();
|
|
Ok(status)
|
|
}
|
|
}
|
|
|
|
fn domain_status(domain: Domain, job_id: &str, running: bool) -> JobStatusJson {
|
|
let artifacts = if running {
|
|
Vec::new()
|
|
} else {
|
|
(0..4)
|
|
.map(|index| {
|
|
let (content_type, bytes) = domain_artifact(domain, index);
|
|
ArtifactRefJson {
|
|
id: format!("{}-{index}", domain.as_str()),
|
|
url: format!("/artifact/{}-{index}", domain.as_str()),
|
|
content_type: content_type.to_string(),
|
|
sha256: Some(makepad_ai_hub::sha256::sha256_hex(&bytes)),
|
|
byte_len: Some(bytes.len() as u64),
|
|
}
|
|
})
|
|
.collect()
|
|
};
|
|
JobStatusJson {
|
|
job_id: job_id.to_string(),
|
|
state: if running {
|
|
JOB_STATE_RUNNING.to_string()
|
|
} else {
|
|
JOB_STATE_DONE.to_string()
|
|
},
|
|
stage: running.then(|| "testpattern".to_string()),
|
|
progress: running.then_some(0.5),
|
|
artifacts,
|
|
error: None,
|
|
model: Some("domain-aware-fake".to_string()),
|
|
queued_ms: None,
|
|
started_ms: None,
|
|
finished_ms: None,
|
|
log: None,
|
|
partial_text: None,
|
|
live: None,
|
|
serving: None,
|
|
text: (!running).then(|| r#"{"caption":"test pattern","tags":[]}"#.to_string()),
|
|
}
|
|
}
|
|
|
|
fn domain_artifact(domain: Domain, index: usize) -> (&'static str, Vec<u8>) {
|
|
match domain {
|
|
Domain::Video | Domain::Enhance => ("video/mp4", b"fake-mp4".to_vec()),
|
|
Domain::Audio | Domain::Music | Domain::Speech => {
|
|
("audio/wav", b"RIFFfakeWAVE".to_vec())
|
|
}
|
|
Domain::Mesh
|
|
| Domain::Paint
|
|
| Domain::Rig
|
|
| Domain::Motion
|
|
| Domain::Splat
|
|
| Domain::World => ("model/gltf-binary", b"glTFfake".to_vec()),
|
|
Domain::Stems => ("application/zip", b"fake-stems".to_vec()),
|
|
Domain::Notes if index > 0 => ("audio/midi", b"MThdfake".to_vec()),
|
|
_ => {
|
|
let pixels = vec![96_u8; 8 * 8 * 4];
|
|
(
|
|
"image/png",
|
|
makepad_ai_hub::testpattern::encode_png_rgba(&pixels, 8, 8).unwrap(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn supplied_value(ty: PortType) -> Value {
|
|
match ty {
|
|
PortType::Text => Value::text("test pattern prompt"),
|
|
PortType::Json => Value::json("{}"),
|
|
PortType::List => Value::list("[]"),
|
|
PortType::Image => {
|
|
let pixels = vec![160_u8; 8 * 8 * 4];
|
|
Value::media(
|
|
ty,
|
|
"image/png",
|
|
makepad_ai_hub::testpattern::encode_png_rgba(&pixels, 8, 8).unwrap(),
|
|
)
|
|
}
|
|
PortType::Audio => Value::media(ty, "audio/wav", b"RIFFinputWAVE".to_vec()),
|
|
PortType::Video => Value::media(ty, "video/mp4", b"input-mp4".to_vec()),
|
|
PortType::Mesh => Value::media(ty, "model/gltf-binary", b"glTFinput".to_vec()),
|
|
PortType::Bytes => Value::media(ty, "application/octet-stream", b"input".to_vec()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_recipe_template_runs_through_the_engine() {
|
|
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("recipes/templates");
|
|
let mut templates: Vec<_> = fs::read_dir(template_dir)
|
|
.unwrap()
|
|
.map(|entry| entry.unwrap().path())
|
|
.filter(|path| path.extension().is_some_and(|extension| extension == "splash"))
|
|
.collect();
|
|
templates.sort();
|
|
|
|
for path in templates {
|
|
let name = path.file_name().unwrap().to_str().unwrap();
|
|
let source = fs::read_to_string(&path).unwrap();
|
|
let mut graph = evaluate(&source, name).unwrap();
|
|
assert!(!graph.label.trim().is_empty(), "{name}: missing label");
|
|
assert!(!graph.brief.trim().is_empty(), "{name}: missing brief");
|
|
|
|
let terminal_ids: Vec<_> = graph
|
|
.nodes
|
|
.iter()
|
|
.filter(|node| matches!(node.kind.as_str(), "output" | "publish"))
|
|
.map(|node| node.id.clone())
|
|
.collect();
|
|
assert!(!terminal_ids.is_empty(), "{name}: missing terminal output");
|
|
|
|
let inputs: BTreeMap<_, _> = graph
|
|
.nodes
|
|
.iter()
|
|
.filter(|node| node.kind == "input")
|
|
.map(|node| {
|
|
let output = node.outputs.first().expect("Input has an output");
|
|
(
|
|
node.id.clone(),
|
|
BTreeMap::from([(output.name.clone(), supplied_value(output.ty))]),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
// Publishing itself has its own route/executor tests. Project it to an
|
|
// in-memory terminal here so this exhaustive wiring test has no server.
|
|
for node in &mut graph.nodes {
|
|
if node.kind == "publish" {
|
|
let ty = node
|
|
.inputs
|
|
.iter()
|
|
.find(|input| input.port == "value")
|
|
.expect("Publish has a value input")
|
|
.ty;
|
|
node.kind = "output".to_string();
|
|
node.type_name = "Output".to_string();
|
|
node.params = vec![("type".to_string(), Literal::Id(ty.as_str().to_string()))];
|
|
}
|
|
}
|
|
let projected_source = write(&graph);
|
|
graph = evaluate(&projected_source, name)
|
|
.unwrap_or_else(|error| panic!("{name}: publish projection failed: {error}"));
|
|
|
|
let gen = DomainFake::default();
|
|
let requests = gen.requests.clone();
|
|
let events = support::run_graph(
|
|
&projected_source,
|
|
graph,
|
|
Seams {
|
|
chat: Arc::new(FakeChat::done(
|
|
r#"{"caption":"test pattern","tags":[]}"#,
|
|
)),
|
|
gen: Arc::new(gen),
|
|
http: Arc::new(FakeHttp::json(200, "{}")),
|
|
},
|
|
None,
|
|
inputs,
|
|
);
|
|
let Some(RunEvent::RunFinished { state, outputs, .. }) = events.last() else {
|
|
panic!("{name}: no finished event: {events:#?}");
|
|
};
|
|
assert_eq!(*state, RunState::Done, "{name}: {events:#?}");
|
|
assert_eq!(outputs.len(), terminal_ids.len(), "{name}: {outputs:#?}");
|
|
for terminal in &terminal_ids {
|
|
assert!(
|
|
outputs.iter().any(|(node, _)| node == terminal),
|
|
"{name}: terminal `{terminal}` produced no value"
|
|
);
|
|
}
|
|
for (domain, request) in requests.lock().unwrap().iter() {
|
|
if *domain == Domain::Inpaint {
|
|
let names: HashSet<_> = request
|
|
.inputs
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|input| input.name.as_str())
|
|
.collect();
|
|
assert_eq!(names, HashSet::from(["image", "mask"]), "{name}");
|
|
}
|
|
if *domain == Domain::Paint {
|
|
let names: HashSet<_> = request
|
|
.inputs
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|input| input.name.as_str())
|
|
.collect();
|
|
assert_eq!(
|
|
names,
|
|
HashSet::from(["mesh", "reference_image"]),
|
|
"{name}"
|
|
);
|
|
}
|
|
if matches!(
|
|
domain,
|
|
Domain::Edit
|
|
| Domain::Inpaint
|
|
| Domain::Control
|
|
| Domain::Upscale
|
|
| Domain::Matte
|
|
| Domain::Depth
|
|
| Domain::Body
|
|
| Domain::Segment
|
|
| Domain::Enhance
|
|
| Domain::Rig
|
|
| Domain::Motion
|
|
| Domain::Stt
|
|
| Domain::Beats
|
|
| Domain::Stems
|
|
| Domain::Notes
|
|
| Domain::Mesh
|
|
| Domain::Paint
|
|
| Domain::Splat
|
|
| Domain::Vision
|
|
) {
|
|
assert!(
|
|
request.input_b64.is_some() || request.inputs.as_ref().is_some_and(|v| !v.is_empty()),
|
|
"{name}: {} request lost its media input",
|
|
domain.as_str()
|
|
);
|
|
}
|
|
if name == "image-to-video.splash" && *domain == Domain::Video {
|
|
assert!(
|
|
request.input_b64.is_some(),
|
|
"{name}: image input was not primary"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|