1use crate::{
5 catalog::{EntryProvenance, LICENSES, require},
6 generation::{FileRole, GenerationFile, GenerationKind, GenerationModel, Workflow},
7 sizing::ValidationError,
8};
9use serde::Deserialize;
10use serde_json::Value;
11
12pub const PUBLISHER: &str = "Comfy-Org";
13pub const MAX_METADATA_BYTES: usize = 4 * 1024 * 1024;
14pub const MAX_SAFETENSORS_HEADER_BYTES: u64 = 100 * 1024 * 1024;
15
16pub fn parse_listing(raw: &str) -> Result<Vec<String>, ValidationError> {
18 require(
19 raw.len() <= MAX_METADATA_BYTES,
20 "model listing exceeds 4 MiB",
21 )?;
22 #[derive(Deserialize)]
23 struct Item {
24 id: String,
25 }
26 let items: Vec<Item> =
27 serde_json::from_str(raw).map_err(|error| ValidationError(error.to_string()))?;
28 require(items.len() <= 1000, "model listing has too many entries")?;
29 Ok(items
30 .into_iter()
31 .map(|item| item.id)
32 .filter(|id| repository(id) && id.starts_with(&format!("{PUBLISHER}/")))
33 .collect())
34}
35
36fn repository(id: &str) -> bool {
37 let mut parts = id.split('/');
38 let valid = |part: &str| {
39 !part.is_empty()
40 && part.len() <= 96
41 && part.as_bytes()[0].is_ascii_alphanumeric()
42 && part
43 .bytes()
44 .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
45 };
46 matches!((parts.next(), parts.next(), parts.next()), (Some(owner), Some(name), None) if valid(owner) && valid(name))
47}
48
49#[derive(Debug, Clone, Deserialize)]
50pub struct Lfs {
51 pub sha256: String,
52 pub size: u64,
53}
54#[derive(Debug, Clone, Deserialize)]
55pub struct Sibling {
56 pub rfilename: String,
57 #[serde(default)]
58 pub lfs: Option<Lfs>,
59}
60#[derive(Debug, Clone, Deserialize)]
61pub struct HfModel {
62 pub id: String,
63 pub sha: String,
64 #[serde(default)]
65 pub tags: Vec<String>,
66 #[serde(default)]
67 pub gated: Value,
68 #[serde(default)]
69 pub private: bool,
70 #[serde(default)]
71 pub siblings: Vec<Sibling>,
72 #[serde(default)]
73 pub pipeline_tag: Option<String>,
74}
75impl HfModel {
76 pub fn parse(raw: &str) -> Result<Self, ValidationError> {
77 require(
78 raw.len() <= MAX_METADATA_BYTES,
79 "model metadata exceeds 4 MiB",
80 )?;
81 let model: Self =
82 serde_json::from_str(raw).map_err(|error| ValidationError(error.to_string()))?;
83 require(repository(&model.id), "invalid repository id")?;
84 require(
85 model.siblings.len() <= 10_000 && model.tags.len() <= 1_000,
86 "model metadata has too many entries",
87 )?;
88 Ok(model)
89 }
90 pub fn pinned(&self) -> bool {
92 self.sha.len() == 40
93 && self.sha.bytes().all(|byte| byte.is_ascii_hexdigit())
94 && !self.private
95 && matches!(self.gated, Value::Null | Value::Bool(false))
96 }
97 pub fn base_model(&self) -> Option<String> {
98 self.base_models().into_iter().next()
99 }
100 pub fn base_models(&self) -> Vec<String> {
102 self.tags
103 .iter()
104 .filter_map(|tag| tag.strip_prefix("base_model:"))
105 .filter(|value| repository(value))
106 .take(4)
107 .map(str::to_string)
108 .collect()
109 }
110}
111
112pub fn license_from_tags(tags: &[String]) -> Option<&'static str> {
113 let mut licenses = tags.iter().filter_map(|tag| tag.strip_prefix("license:"));
114 let license = licenses.next()?;
115 if licenses.next().is_some() {
116 return None;
117 }
118 LICENSES.iter().copied().find(|known| *known == license)
119}
120
121pub fn kind_from_pipeline(pipeline: Option<&str>) -> Option<GenerationKind> {
123 match pipeline? {
124 "text-to-image" => Some(GenerationKind::Image),
125 "text-to-video" => Some(GenerationKind::Video),
126 _ => None,
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum SafetensorsError {
132 Truncated(u64),
134 Invalid(&'static str),
135}
136
137pub fn safetensors_params(bytes: &[u8]) -> Result<u64, SafetensorsError> {
139 let length = u64::from_le_bytes(
140 bytes
141 .get(..8)
142 .ok_or(SafetensorsError::Truncated(8))?
143 .try_into()
144 .expect("eight bytes"),
145 );
146 if length == 0 || length > MAX_SAFETENSORS_HEADER_BYTES {
147 return Err(SafetensorsError::Invalid(
148 "implausible safetensors header length",
149 ));
150 }
151 let end = 8 + length as usize;
152 let header = bytes
153 .get(8..end)
154 .ok_or(SafetensorsError::Truncated(end as u64))?;
155 let table: serde_json::Map<String, Value> = serde_json::from_slice(header)
156 .map_err(|_| SafetensorsError::Invalid("safetensors header is not a JSON object"))?;
157 let mut total: u64 = 0;
158 for (name, tensor) in table {
159 if name == "__metadata__" {
160 continue;
161 }
162 let shape = tensor["shape"]
163 .as_array()
164 .ok_or(SafetensorsError::Invalid("tensor without shape"))?;
165 let mut elements: u64 = 1;
166 for dim in shape {
167 elements = elements
168 .checked_mul(
169 dim.as_u64()
170 .ok_or(SafetensorsError::Invalid("invalid tensor dimension"))?,
171 )
172 .ok_or(SafetensorsError::Invalid("tensor size overflow"))?;
173 }
174 total = total
175 .checked_add(elements)
176 .ok_or(SafetensorsError::Invalid("tensor size overflow"))?;
177 }
178 if total == 0 {
179 return Err(SafetensorsError::Invalid(
180 "safetensors header has no tensors",
181 ));
182 }
183 Ok(total)
184}
185
186pub struct FitOnlyInput<'a> {
187 pub model: &'a HfModel,
188 pub kind: GenerationKind,
189 pub license: &'static str,
190 pub params: &'a dyn Fn(&str) -> Option<u64>,
192 pub today: &'a str,
193}
194
195pub struct Recipe {
197 pub verified: bool,
199 pub repository: &'static str,
200 pub kind: GenerationKind,
201 pub workflow: Workflow,
202 pub source: &'static str,
203 pub diffusion: &'static [&'static str],
205 pub text_encoder: &'static str,
206 pub vae: &'static str,
207}
208
209pub const RECIPES: [Recipe; 3] = [
210 Recipe {
211 verified: true,
212 repository: "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
213 kind: GenerationKind::Video,
214 workflow: Workflow::Wan22Ti2v,
215 source: "https://comfyanonymous.github.io/ComfyUI_examples/wan22/",
216 diffusion: &["wan2.2_ti2v_5B_fp16.safetensors"],
217 text_encoder: "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
218 vae: "wan2.2_vae.safetensors",
219 },
220 Recipe {
221 verified: true,
222 repository: "Comfy-Org/Qwen-Image_ComfyUI",
223 kind: GenerationKind::Image,
224 workflow: Workflow::QwenImage,
225 source: "https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/",
226 diffusion: &[
227 "qwen_image_fp8_e4m3fn.safetensors",
228 "qwen_image_bf16.safetensors",
229 ],
230 text_encoder: "qwen_2.5_vl_7b_fp8_scaled.safetensors",
231 vae: "qwen_image_vae.safetensors",
232 },
233 Recipe {
234 verified: true,
235 repository: "Comfy-Org/Ming-Image",
236 kind: GenerationKind::Image,
237 workflow: Workflow::MingDesign,
238 source: "https://github.com/Comfy-Org/workflow_templates/blob/99e3d43745926b78466d99f937c0cd2bb622423a/templates/image_ming_image_01_design_t2i.json",
239 diffusion: &["ming_image_0.1_design_int8_convrot.safetensors"],
240 text_encoder: "ming_image_0.1_ling_mini_2.0_int8_convrot.safetensors",
241 vae: "ming_image_vae_bf16.safetensors",
242 },
243];
244
245pub fn recipe(repository: &str) -> Option<&'static Recipe> {
246 RECIPES
247 .iter()
248 .find(|recipe| recipe.verified && recipe.repository == repository)
249}
250
251pub fn family(repository: &str) -> String {
253 let mut name = repository
254 .rsplit('/')
255 .next()
256 .unwrap_or_default()
257 .to_ascii_lowercase();
258 loop {
259 let trimmed = ["_repackaged", "-repackaged", "_comfyui", "-comfyui"]
260 .iter()
261 .find_map(|suffix| name.strip_suffix(suffix).map(str::to_string));
262 match trimmed {
263 Some(shorter) if !shorter.is_empty() => name = shorter,
264 _ => return name,
265 }
266 }
267}
268
269fn folder(path: &str) -> Option<FileRole> {
270 let segments: Vec<&str> = path.split('/').collect();
271 let parent = segments.get(segments.len().checked_sub(2)?)?;
272 match *parent {
273 "diffusion_models" => Some(FileRole::Diffusion),
274 "text_encoders" => Some(FileRole::TextEncoder),
275 "vae" => Some(FileRole::Vae),
276 _ => None,
277 }
278}
279
280fn stem(path: &str) -> &str {
281 let name = path.rsplit('/').next().unwrap_or(path);
282 name.strip_suffix(".safetensors").unwrap_or(name)
283}
284
285fn text_prompted(path: &str) -> bool {
287 let name = stem(path).to_ascii_lowercase();
288 ![
290 "edit",
291 "lora",
292 "inpaint",
293 "controlnet",
294 "control_",
295 "fill",
296 "depth",
297 "canny",
298 "animate",
299 "fun_",
300 "_i2v",
301 "s2v",
302 "vace",
303 "camera",
304 ]
305 .iter()
306 .any(|marker| name.contains(marker))
307}
308
309fn generation_file(model: &HfModel, sibling: &Sibling, role: FileRole) -> Option<GenerationFile> {
310 let lfs = sibling.lfs.as_ref()?;
311 (lfs.sha256.len() == 64
312 && lfs.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
313 && lfs.size > 0)
314 .then(|| GenerationFile {
315 role,
316 folder: role.folder().into(),
317 repo: model.id.clone(),
318 revision: model.sha.clone(),
319 file: sibling.rfilename.clone(),
320 sha256: lfs.sha256.to_ascii_lowercase(),
321 bytes: lfs.size,
322 })
323}
324
325pub fn diffusion_files(model: &HfModel) -> Vec<&Sibling> {
327 model
328 .siblings
329 .iter()
330 .filter(|sibling| {
331 sibling.rfilename.ends_with(".safetensors")
332 && folder(&sibling.rfilename) == Some(FileRole::Diffusion)
333 && text_prompted(&sibling.rfilename)
334 && sibling.lfs.is_some()
335 })
336 .collect()
337}
338
339pub fn fit_only_entries(input: &FitOnlyInput) -> Result<Vec<GenerationModel>, ValidationError> {
342 let model = input.model;
343 require(model.pinned(), "repository is not a public pinned revision")?;
344 let family = family(&model.id);
345 let files: Vec<(&Sibling, FileRole)> = model
346 .siblings
347 .iter()
348 .filter(|sibling| sibling.rfilename.ends_with(".safetensors"))
349 .filter_map(|sibling| folder(&sibling.rfilename).map(|role| (sibling, role)))
350 .collect();
351 let smallest = |role: FileRole| {
352 files
353 .iter()
354 .filter(|(_, candidate)| *candidate == role)
355 .filter_map(|(sibling, _)| generation_file(model, sibling, role))
356 .min_by(|left, right| {
357 left.bytes
358 .cmp(&right.bytes)
359 .then_with(|| left.file.cmp(&right.file))
360 })
361 };
362 let companions: Vec<GenerationFile> = [FileRole::TextEncoder, FileRole::Vae]
363 .into_iter()
364 .filter_map(smallest)
365 .collect();
366 let mut entries = Vec::new();
367 for (sibling, role) in &files {
368 if *role != FileRole::Diffusion || !text_prompted(&sibling.rfilename) {
369 continue;
370 }
371 let Some(diffusion) = generation_file(model, sibling, FileRole::Diffusion) else {
372 continue;
373 };
374 let Some(params) = (input.params)(&sibling.rfilename) else {
375 continue;
376 };
377 let id = format!("{family}:{}", stem(&sibling.rfilename).to_ascii_lowercase());
378 let file_name = sibling.rfilename.rsplit('/').next().unwrap_or_default();
379 let exact = |role: FileRole, name: &str| {
380 files
381 .iter()
382 .find(|(candidate, candidate_role)| {
383 *candidate_role == role && candidate.rfilename.rsplit('/').next() == Some(name)
384 })
385 .and_then(|(candidate, _)| generation_file(model, candidate, role))
386 };
387 let runnable = recipe(&model.id)
389 .filter(|recipe| recipe.kind == input.kind && recipe.diffusion.contains(&file_name))
390 .and_then(|recipe| {
391 Some((
392 recipe,
393 exact(FileRole::TextEncoder, recipe.text_encoder)?,
394 exact(FileRole::Vae, recipe.vae)?,
395 ))
396 });
397 let mut entry_files = vec![diffusion];
398 let (workflow, workflow_source) = match runnable {
399 Some((recipe, encoder, vae)) => {
400 entry_files.extend([encoder, vae]);
401 (Some(recipe.workflow), Some(recipe.source.to_string()))
402 }
403 None => {
404 entry_files.extend(companions.iter().cloned());
405 (None, None)
406 }
407 };
408 entries.push(GenerationModel {
409 id,
410 family: family.clone(),
411 kind: input.kind,
412 params: crate::admission::parameter_label(params as f64),
413 license: input.license.into(),
414 open_weight: true,
415 release_date: None,
416 added_at: Some(input.today.into()),
417 provenance: EntryProvenance::Auto,
418 default: false,
419 source: format!("https://huggingface.co/{}", model.id),
420 workflow,
421 workflow_source,
422 files: entry_files,
423 });
424 }
425 entries.sort_by(|left, right| left.id.cmp(&right.id));
426 Ok(entries)
427}