Update: Optimization, New Models Support (Qwen, Flux, WanVideo, HunyuanVideo), and Stability Improvements
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# AGENTS — Directives pour Majoor Assets Manager
|
||||
|
||||
Updated: 2026-01-31
|
||||
Updated: 2026-02-03
|
||||
|
||||
> **Status (as of 2026-01-31):** OPTIMIZED & STABLE. Critical performance audits complete. Virtual Scrolling & WebGL Player implemented.
|
||||
> **Status (as of 2026-02-03):** OPTIMIZED & STABLE. Critical performance audits complete. Virtual Scrolling & WebGL Player implemented.
|
||||
> Support added for Qwen (Edit), Flux (Advanced/GGUF), WanVideo (Kijai), HunyuanVideo (Kijai), Marigold (Depth).
|
||||
|
||||
## Objectif
|
||||
Créer un **Assets Manager pour ComfyUI** qui indexe, recherche et gère les assets (images/vidéos) avec **ratings**, **tags**, **workflows**, et **metadata de génération**, sans faire planter l’UI.
|
||||
|
||||
+5
-1
@@ -67,7 +67,8 @@ def _env_int(name: str, default: int) -> int:
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
ENABLE_FILE_WATCHER = env_bool("MAJOOR_ENABLE_FILE_WATCHER", False)
|
||||
# [CHANGED] Default to True to enable real-time updates out-of-the-box
|
||||
ENABLE_FILE_WATCHER = env_bool("MAJOOR_ENABLE_FILE_WATCHER", True)
|
||||
WATCHER_INTERVAL_SECONDS = env_float("MAJOOR_WATCHER_INTERVAL", 15.0)
|
||||
WATCHER_JOIN_TIMEOUT = env_float("MAJOOR_WATCHER_JOIN_TIMEOUT", 5.0)
|
||||
WATCHER_PATHS = []
|
||||
@@ -98,6 +99,9 @@ MAX_METADATA_JSON_BYTES = _env_int("MAJOOR_MAX_METADATA_JSON_BYTES", 2 * 1024 *
|
||||
# Background scan / filesystem listing tuning
|
||||
BG_SCAN_FAILURE_HISTORY_MAX = _env_int("MAJOOR_BG_SCAN_FAILURE_HISTORY_MAX", 50)
|
||||
SCAN_PENDING_MAX = _env_int("MAJOOR_SCAN_PENDING_MAX", 64)
|
||||
MANUAL_BG_SCAN_GRACE_SECONDS = env_float("MAJOOR_MANUAL_BG_SCAN_GRACE_SECONDS", 30.0)
|
||||
SEARCH_MAX_LIMIT = _env_int("MAJOOR_SEARCH_MAX_LIMIT", 500)
|
||||
SEARCH_MAX_OFFSET = _env_int("MAJOOR_SEARCH_MAX_OFFSET", 100_000)
|
||||
|
||||
# Filesystem listing cache (used by filesystem fallback search/list)
|
||||
FS_LIST_CACHE_MAX = _env_int("MAJOOR_FS_LIST_CACHE_MAX", 32)
|
||||
|
||||
@@ -40,15 +40,17 @@ def _sink_priority(node: Dict[str, Any], node_id: Optional[str] = None) -> Tuple
|
||||
attached to intermediate nodes and does not reliably reflect the final render.
|
||||
"""
|
||||
ct = _lower(_node_type(node))
|
||||
# Prefer video sinks
|
||||
if ct in ("savevideo", "vhs_savevideo", "vhs_videocombine"):
|
||||
|
||||
# Group 0: Video Sinks (Explicit & Heuristic)
|
||||
if ct in ("savevideo", "vhs_savevideo", "vhs_videocombine") or (("save" in ct) and ("video" in ct)):
|
||||
group = 0
|
||||
# Then image/gif sinks
|
||||
elif ct in ("saveimage", "saveimagewebsocket", "saveanimatedwebp", "savegif"):
|
||||
# Group 1: Image/Gif Sinks (Explicit & Heuristic)
|
||||
elif ct in ("saveimage", "saveimagewebsocket", "saveanimatedwebp", "savegif") or (("save" in ct) and ("image" in ct)):
|
||||
group = 1
|
||||
# Preview last
|
||||
elif ct == "previewimage":
|
||||
# Group 2: Preview
|
||||
elif ct == "previewimage" or "preview" in ct:
|
||||
group = 2
|
||||
# Group 3: Fallback
|
||||
else:
|
||||
group = 3
|
||||
|
||||
@@ -261,6 +263,14 @@ def _find_candidate_sinks(nodes_by_id: Dict[str, Dict[str, Any]]) -> List[str]:
|
||||
ct = _lower(_node_type(node))
|
||||
if ct in SINK_CLASS_TYPES:
|
||||
sinks.append(node_id)
|
||||
continue
|
||||
|
||||
# Heuristic for custom save nodes (e.g. WAS, Impact, CR, etc)
|
||||
# Must contain "save" or "preview" AND "image" or "video"
|
||||
if ("save" in ct or "preview" in ct) and ("image" in ct or "video" in ct):
|
||||
sinks.append(node_id)
|
||||
continue
|
||||
|
||||
return sinks
|
||||
|
||||
|
||||
@@ -308,6 +318,16 @@ def _is_sampler(node: Dict[str, Any]) -> bool:
|
||||
return True
|
||||
if "iterativelatentupscale" in ct:
|
||||
return True
|
||||
# Marigold depth estimation (acts as sampler for depth maps)
|
||||
if "marigold" in ct:
|
||||
return True
|
||||
# Kijai Flux inference sampler
|
||||
if "flux" in ct and ("sampler" in ct or "params" in ct):
|
||||
return True
|
||||
|
||||
# Generic "Flux" nodes that act as samplers (e.g. "Flux2")
|
||||
if ct == "flux2" or "flux_2" in ct:
|
||||
return True
|
||||
|
||||
# Generic detection: any node with (steps + cfg + seed) is likely a sampler
|
||||
try:
|
||||
@@ -332,7 +352,7 @@ def _is_sampler(node: Dict[str, Any]) -> bool:
|
||||
if ins.get(k) is not None:
|
||||
return True
|
||||
# Wan samplers often take `text_embeds` rather than conditioning.
|
||||
if _is_link(ins.get("text_embeds")):
|
||||
if _is_link(ins.get("text_embeds")) or _is_link(ins.get("hyvid_embeds")):
|
||||
return True
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return False
|
||||
@@ -359,6 +379,9 @@ def _is_advanced_sampler(node: Dict[str, Any]) -> bool:
|
||||
# "guider" + "sigmas" is the defining signature of the advanced decoupled interaction.
|
||||
if _is_link(ins.get("guider")) and _is_link(ins.get("sigmas")):
|
||||
return True
|
||||
# Also accept guider + sampler interaction (common if sigmas handled internally or named differently)
|
||||
if _is_link(ins.get("guider")) and _is_link(ins.get("sampler")):
|
||||
return True
|
||||
keys = ("noise", "guider", "sampler", "sigmas")
|
||||
return all(_is_link(ins.get(k)) for k in keys)
|
||||
except (TypeError, ValueError, KeyError):
|
||||
@@ -387,7 +410,9 @@ def _extract_posneg_from_text_embeds(
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
pos = _get_str("positive", "prompt", "text", "text_g", "text_l")
|
||||
# WanVideoTextEncode uses 'positive_prompt'/'negative_prompt'
|
||||
# HyVideoTextEncode uses 'prompt'
|
||||
pos = _get_str("positive", "prompt", "text", "text_g", "text_l", "positive_prompt")
|
||||
neg = _get_str("negative", "negative_prompt")
|
||||
|
||||
pos_val = (pos, f"{_node_type(node)}:{src_id}") if pos else None
|
||||
@@ -629,7 +654,7 @@ def _looks_like_text_encoder(node: Dict[str, Any]) -> bool:
|
||||
ins = _inputs(node)
|
||||
if not _is_link(ins.get("clip")):
|
||||
return False
|
||||
for key in ("text", "prompt", "text_g", "text_l"):
|
||||
for key in ("text", "prompt", "text_g", "text_l", "instruction"):
|
||||
v = ins.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return True
|
||||
@@ -651,10 +676,12 @@ def _looks_like_conditioning_text(node: Dict[str, Any]) -> bool:
|
||||
if "conditioning" not in ct and "prompt" not in ct and "textencode" not in ct:
|
||||
return False
|
||||
ins = _inputs(node)
|
||||
for key in ("text", "prompt", "text_g", "text_l"):
|
||||
for key in ("text", "prompt", "text_g", "text_l", "instruction"):
|
||||
v = ins.get(key)
|
||||
if _looks_like_prompt_string(v):
|
||||
return True
|
||||
if _is_link(v):
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
@@ -774,7 +801,7 @@ def _collect_texts_from_conditioning(
|
||||
continue
|
||||
ins = _inputs(node)
|
||||
candidates: List[str] = []
|
||||
for key in ("text", "prompt", "text_g", "text_l"):
|
||||
for key in ("text", "prompt", "text_g", "text_l", "instruction"):
|
||||
v = ins.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
candidates.append(v.strip())
|
||||
@@ -806,6 +833,7 @@ def _trace_model_chain(
|
||||
ct = _lower(_node_type(node))
|
||||
ins = _inputs(node)
|
||||
|
||||
|
||||
def _first_model_string() -> Optional[str]:
|
||||
for k in (
|
||||
"ckpt_name",
|
||||
@@ -914,7 +942,15 @@ def _trace_model_chain(
|
||||
|
||||
# Generic "model loader" style custom nodes (e.g. WanVideoModelLoader) often expose
|
||||
# only a model path/identifier without "ckpt_name" naming.
|
||||
if "modelloader" in ct or ("model_loader" in ct or "model-loader" in ct) or "ltxvideomodel" in ct:
|
||||
if (
|
||||
"modelloader" in ct
|
||||
or "model_loader" in ct
|
||||
or "model-loader" in ct
|
||||
or "ltxvideomodel" in ct
|
||||
or "wanvideomodel" in ct
|
||||
or "hyvideomodel" in ct
|
||||
or "cogvideomodel" in ct
|
||||
):
|
||||
raw = _first_model_string()
|
||||
name = _clean_model_id(raw)
|
||||
if name:
|
||||
@@ -1407,82 +1443,116 @@ def parse_geninfo_from_prompt(prompt_graph: Any, workflow: Any = None) -> Result
|
||||
Parse generation information from a ComfyUI prompt graph (dict of nodes).
|
||||
Returns Ok(None) when not enough information is available (do-not-lie).
|
||||
"""
|
||||
workflow_meta = _extract_workflow_metadata(workflow)
|
||||
try:
|
||||
workflow_meta = _extract_workflow_metadata(workflow)
|
||||
|
||||
target_graph = prompt_graph
|
||||
|
||||
# If no prompt graph provided, try to use workflow as the source graph
|
||||
if not isinstance(target_graph, dict) or not target_graph:
|
||||
if isinstance(workflow, dict) and "nodes" in workflow:
|
||||
target_graph = workflow
|
||||
target_graph = prompt_graph
|
||||
|
||||
# If no prompt graph provided, try to use workflow as the source graph
|
||||
if not isinstance(target_graph, dict) or not target_graph:
|
||||
if isinstance(workflow, dict) and "nodes" in workflow:
|
||||
target_graph = workflow
|
||||
else:
|
||||
if workflow_meta:
|
||||
return Result.Ok({"metadata": workflow_meta})
|
||||
return Result.Ok(None)
|
||||
|
||||
nodes_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Handle standard API prompt format vs Workflow Graph format
|
||||
# Workflow Graph format (e.g. from PNGs with only workflow)
|
||||
if "nodes" in target_graph and isinstance(target_graph["nodes"], list):
|
||||
for node in target_graph["nodes"]:
|
||||
if isinstance(node, dict):
|
||||
nodes_by_id[str(node.get("id"))] = node
|
||||
else:
|
||||
# API PROMPT format (default)
|
||||
for k, v in target_graph.items():
|
||||
if isinstance(v, dict):
|
||||
nodes_by_id[str(k)] = v
|
||||
|
||||
sinks = _find_candidate_sinks(nodes_by_id)
|
||||
if not sinks:
|
||||
if workflow_meta:
|
||||
return Result.Ok({"metadata": workflow_meta})
|
||||
return Result.Ok(None)
|
||||
|
||||
nodes_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
# Prefer "real" sinks (SaveVideo over PreviewImage, etc.)
|
||||
sinks.sort(key=lambda nid: _sink_priority(nodes_by_id.get(nid, {}) or {}, nid))
|
||||
|
||||
# Handle standard API prompt format vs Workflow Graph format
|
||||
# Workflow Graph format (e.g. from PNGs with only workflow)
|
||||
if "nodes" in target_graph and isinstance(target_graph["nodes"], list):
|
||||
for node in target_graph["nodes"]:
|
||||
if isinstance(node, dict):
|
||||
nodes_by_id[str(node.get("id"))] = node
|
||||
else:
|
||||
# API PROMPT format (default)
|
||||
for k, v in target_graph.items():
|
||||
if isinstance(v, dict):
|
||||
nodes_by_id[str(k)] = v
|
||||
sink_id = sinks[0]
|
||||
sampler_id, sampler_conf = _select_primary_sampler(nodes_by_id, sink_id)
|
||||
sampler_mode = "primary"
|
||||
if not sampler_id:
|
||||
sampler_id, sampler_conf = _select_advanced_sampler(nodes_by_id, sink_id)
|
||||
sampler_mode = "advanced" if sampler_id else sampler_mode
|
||||
if not sampler_id:
|
||||
sampler_id, sampler_conf = _select_any_sampler(nodes_by_id)
|
||||
sampler_mode = "global" if sampler_id else sampler_mode
|
||||
if not sampler_id:
|
||||
out_fallback: Dict[str, Any] = {}
|
||||
if workflow_meta:
|
||||
out_fallback["metadata"] = workflow_meta
|
||||
|
||||
# Try extracting inputs even without a recognized sampler (e.g. format conversion)
|
||||
input_files_fallback = _extract_input_files(nodes_by_id)
|
||||
if input_files_fallback:
|
||||
out_fallback["inputs"] = input_files_fallback
|
||||
|
||||
if out_fallback:
|
||||
return Result.Ok(out_fallback)
|
||||
|
||||
return Result.Ok(None)
|
||||
|
||||
sinks = _find_candidate_sinks(nodes_by_id)
|
||||
if not sinks:
|
||||
# For secondary traces (VAE, etc.), compute a stable upstream set from the sink input.
|
||||
sink = nodes_by_id.get(sink_id) or {}
|
||||
sink_link = _pick_sink_inputs(sink)
|
||||
sink_start_id = _walk_passthrough(nodes_by_id, sink_link) if sink_link else None
|
||||
|
||||
sampler_node = nodes_by_id.get(sampler_id) or {}
|
||||
sampler_source = f"{_node_type(sampler_node)}:{sampler_id}"
|
||||
confidence = sampler_conf
|
||||
|
||||
ins = _inputs(sampler_node)
|
||||
field_sources: Dict[str, str] = {}
|
||||
field_confidence: Dict[str, str] = {}
|
||||
except Exception as e:
|
||||
logger.warning(f"GenInfo parsing failed: {e}")
|
||||
# Try to return at least workflow metadata if available
|
||||
workflow_meta = _extract_workflow_metadata(workflow)
|
||||
if workflow_meta:
|
||||
return Result.Ok({"metadata": workflow_meta})
|
||||
return Result.Ok({"metadata": workflow_meta, "_error": str(e)})
|
||||
return Result.Ok(None)
|
||||
|
||||
# Prefer "real" sinks (SaveVideo over PreviewImage, etc.)
|
||||
sinks.sort(key=lambda nid: _sink_priority(nodes_by_id.get(nid, {}) or {}, nid))
|
||||
|
||||
sink_id = sinks[0]
|
||||
sampler_id, sampler_conf = _select_primary_sampler(nodes_by_id, sink_id)
|
||||
sampler_mode = "primary"
|
||||
if not sampler_id:
|
||||
sampler_id, sampler_conf = _select_advanced_sampler(nodes_by_id, sink_id)
|
||||
sampler_mode = "advanced" if sampler_id else sampler_mode
|
||||
if not sampler_id:
|
||||
sampler_id, sampler_conf = _select_any_sampler(nodes_by_id)
|
||||
sampler_mode = "global" if sampler_id else sampler_mode
|
||||
if not sampler_id:
|
||||
out_fallback: Dict[str, Any] = {}
|
||||
if workflow_meta:
|
||||
out_fallback["metadata"] = workflow_meta
|
||||
|
||||
# Try extracting inputs even without a recognized sampler (e.g. format conversion)
|
||||
input_files_fallback = _extract_input_files(nodes_by_id)
|
||||
if input_files_fallback:
|
||||
out_fallback["inputs"] = input_files_fallback
|
||||
|
||||
if out_fallback:
|
||||
return Result.Ok(out_fallback)
|
||||
|
||||
return Result.Ok(None)
|
||||
|
||||
# For secondary traces (VAE, etc.), compute a stable upstream set from the sink input.
|
||||
sink = nodes_by_id.get(sink_id) or {}
|
||||
sink_link = _pick_sink_inputs(sink)
|
||||
sink_start_id = _walk_passthrough(nodes_by_id, sink_link) if sink_link else None
|
||||
|
||||
sampler_node = nodes_by_id.get(sampler_id) or {}
|
||||
sampler_source = f"{_node_type(sampler_node)}:{sampler_id}"
|
||||
confidence = sampler_conf
|
||||
|
||||
ins = _inputs(sampler_node)
|
||||
field_sources: Dict[str, str] = {}
|
||||
field_confidence: Dict[str, str] = {}
|
||||
|
||||
# Advanced sampler pipelines (Flux/SD3): derive fields from orchestrator dependencies.
|
||||
advanced = _is_advanced_sampler(sampler_node)
|
||||
|
||||
# Qwen2-VL captioning workflows often don't have a sampler, but result in string output.
|
||||
# If sink usage is text-based (rare for image gen tool, but possibly valid for caption), skip.
|
||||
|
||||
# Detect Marigold (Depth) - often no sampler, just an estimator node or inserted in pipe.
|
||||
# If the sink comes from a Marigold node, treat it as the "sampler".
|
||||
if not sampler_id:
|
||||
for nid, val in nodes_by_id.items():
|
||||
ct_lower = _lower(_node_type(val))
|
||||
if "marigold" in ct_lower:
|
||||
# Marigold acts as the processor
|
||||
sampler_id = nid
|
||||
# Marigold usually takes 'image' input, not latent.
|
||||
# Metadata to extract: seed, denoise_steps, ensemble_size.
|
||||
break
|
||||
# Qwen/Instruction nodes acted as sinks/processors
|
||||
if "instruction" in ct_lower and "qwen" in ct_lower:
|
||||
sampler_id = nid
|
||||
# Needs special handling for prompt extraction later
|
||||
break
|
||||
|
||||
# Ensure sampler_node object is up to date if we just found an ID in the fallback
|
||||
if sampler_id and (not sampler_node or str(sampler_node.get("id")) != str(sampler_id)):
|
||||
sampler_node = nodes_by_id.get(sampler_id) or {}
|
||||
ins = _inputs(sampler_node)
|
||||
confidence = "low" # Fallback confidence
|
||||
|
||||
# Prompts
|
||||
pos_val: Optional[Tuple[str, str]] = None
|
||||
neg_val: Optional[Tuple[str, str]] = None
|
||||
@@ -1491,9 +1561,22 @@ def parse_geninfo_from_prompt(prompt_graph: Any, workflow: Any = None) -> Result
|
||||
guider_cfg_source: Optional[str] = None
|
||||
guider_model_link: Optional[Any] = None
|
||||
|
||||
# Handle Qwen Instruction Prompts (stored directly on the node)
|
||||
if sampler_node and "instruction" in _lower(_node_type(sampler_node)) and "qwen" in _lower(_node_type(sampler_node)):
|
||||
p = ins.get("instruction") or ins.get("text")
|
||||
if isinstance(p, str) and p.strip():
|
||||
pos_val = (p.strip(), f"{_node_type(sampler_node)}:{sampler_id}:instruction")
|
||||
|
||||
# FluxKohyaInferenceSampler (Kijai) - stores prompt directly
|
||||
if "flux" in _lower(_node_type(sampler_node)) and "trainer" in _lower(_node_type(sampler_node)):
|
||||
p = ins.get("prompt")
|
||||
if isinstance(p, str) and p.strip():
|
||||
pos_val = (p.strip(), f"{_node_type(sampler_node)}:{sampler_id}:prompt")
|
||||
|
||||
# Wan/video stacks: prompts are often encoded into text embeds rather than conditioning.
|
||||
if _is_link(ins.get("text_embeds")):
|
||||
p, n = _extract_posneg_from_text_embeds(nodes_by_id, ins.get("text_embeds"))
|
||||
if _is_link(ins.get("text_embeds")) or _is_link(ins.get("hyvid_embeds")):
|
||||
link = ins.get("text_embeds") or ins.get("hyvid_embeds")
|
||||
p, n = _extract_posneg_from_text_embeds(nodes_by_id, link)
|
||||
pos_val = pos_val or p
|
||||
neg_val = neg_val or n
|
||||
|
||||
@@ -1585,9 +1668,15 @@ def parse_geninfo_from_prompt(prompt_graph: Any, workflow: Any = None) -> Result
|
||||
|
||||
# Sampler params
|
||||
sampler_name = _scalar(ins.get("sampler_name")) or _scalar(ins.get("sampler"))
|
||||
|
||||
# Check if this node class itself implies a specific sampler (Marigold/Depth)
|
||||
if not sampler_name and "marigold" in _lower(_node_type(sampler_node)):
|
||||
sampler_name = _node_type(sampler_node)
|
||||
|
||||
scheduler = _scalar(ins.get("scheduler"))
|
||||
steps = _scalar(ins.get("steps"))
|
||||
cfg = _scalar(ins.get("cfg")) or _scalar(ins.get("cfg_scale")) or _scalar(ins.get("guidance")) or _scalar(ins.get("guidance_scale"))
|
||||
steps = _scalar(ins.get("steps")) or _scalar(ins.get("denoise_steps")) # Marigold uses denoise_steps
|
||||
# embedded_guidance_scale is used by HunyuanVideoSampler
|
||||
cfg = _scalar(ins.get("cfg")) or _scalar(ins.get("cfg_scale")) or _scalar(ins.get("guidance")) or _scalar(ins.get("guidance_scale")) or _scalar(ins.get("embedded_guidance_scale"))
|
||||
denoise = _scalar(ins.get("denoise"))
|
||||
seed_val = _scalar(ins.get("seed"))
|
||||
if seed_val is None and _is_link(ins.get("seed")):
|
||||
@@ -1771,6 +1860,8 @@ def parse_geninfo_from_prompt(prompt_graph: Any, workflow: Any = None) -> Result
|
||||
|
||||
# Do-not-lie: if nothing useful extracted besides engine, return None.
|
||||
if len(out.keys()) <= 1:
|
||||
if workflow_meta:
|
||||
return Result.Ok({"metadata": workflow_meta})
|
||||
return Result.Ok(None)
|
||||
|
||||
return Result.Ok(out)
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
Metadata enricher - handles background metadata enrichment for assets.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
import asyncio
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from ...shared import get_logger
|
||||
from ...adapters.db.sqlite import Sqlite
|
||||
@@ -51,7 +50,7 @@ class MetadataEnricher:
|
||||
self._metadata_error_payload = metadata_error_payload_fn
|
||||
self._enrich_lock = asyncio.Lock()
|
||||
self._enrich_queue: List[str] = []
|
||||
self._enrich_thread: Optional[threading.Thread] = None
|
||||
self._enrich_task: Optional[asyncio.Task[None]] = None
|
||||
self._enrich_running = False
|
||||
|
||||
async def start_enrichment(self, filepaths: List[str]) -> None:
|
||||
@@ -71,10 +70,10 @@ class MetadataEnricher:
|
||||
continue
|
||||
self._enrich_queue.append(fp)
|
||||
existing.add(fp)
|
||||
if self._enrich_running and self._enrich_thread and self._enrich_thread.is_alive():
|
||||
if self._enrich_running and self._enrich_task and not self._enrich_task.done():
|
||||
return
|
||||
self._enrich_running = True
|
||||
self._enrich_thread = asyncio.create_task(self._enrichment_worker())
|
||||
self._enrich_task = asyncio.create_task(self._enrichment_worker())
|
||||
|
||||
async def _enrichment_worker(self) -> None:
|
||||
"""Background worker that processes the enrichment queue."""
|
||||
@@ -82,14 +81,16 @@ class MetadataEnricher:
|
||||
while True:
|
||||
async with self._enrich_lock:
|
||||
if not self._enrich_queue:
|
||||
self._enrich_running = False
|
||||
return
|
||||
chunk = self._enrich_queue[:64]
|
||||
del self._enrich_queue[:64]
|
||||
await self._enrich_metadata_chunk(chunk)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("Metadata enrichment worker failed: %s", exc)
|
||||
finally:
|
||||
async with self._enrich_lock:
|
||||
self._enrich_running = False
|
||||
self._enrich_task = None
|
||||
|
||||
async def _enrich_metadata_chunk(self, filepaths: List[str]) -> None:
|
||||
"""
|
||||
|
||||
@@ -266,12 +266,36 @@ class IndexScanner:
|
||||
Result with indexing statistics
|
||||
"""
|
||||
async with self._scan_lock:
|
||||
# Filter unsupported files to prevent indexing internal files (DBs, etc.)
|
||||
filtered_paths = []
|
||||
for p in paths:
|
||||
try:
|
||||
ext = p.suffix.lower() if p.suffix else ""
|
||||
if ext and _EXT_TO_KIND:
|
||||
if _EXT_TO_KIND.get(ext, "unknown") != "unknown":
|
||||
filtered_paths.append(p)
|
||||
continue
|
||||
if classify_file(str(p)) != "unknown":
|
||||
filtered_paths.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
paths = filtered_paths
|
||||
|
||||
if not paths:
|
||||
return Result.Ok({
|
||||
"scanned": 0, "added": 0, "updated": 0, "skipped": 0, "errors": 0,
|
||||
"start_time": datetime.now().isoformat(),
|
||||
"end_time": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
scan_id = str(uuid4())
|
||||
self._current_scan_id = scan_id
|
||||
scan_start = time.perf_counter()
|
||||
|
||||
# Reduce log noise for single-file indexing (common in watchers)
|
||||
start_log_level = logging.DEBUG if len(paths) == 1 else logging.INFO
|
||||
self._log_scan_event(
|
||||
logging.INFO,
|
||||
start_log_level,
|
||||
"Starting file list index",
|
||||
file_count=len(paths),
|
||||
base_dir=base_dir,
|
||||
@@ -329,8 +353,12 @@ class IndexScanner:
|
||||
# for single-file indexing causes unnecessary grid reloads/flicker.
|
||||
await MetadataHelpers.set_metadata_value(self.db, "last_index_end", stats["end_time"])
|
||||
|
||||
# Only log completion at INFO if meaningful changes occurred
|
||||
has_changes = stats["added"] > 0 or stats["updated"] > 0 or stats["errors"] > 0
|
||||
complete_log_level = logging.INFO if has_changes else logging.DEBUG
|
||||
|
||||
self._log_scan_event(
|
||||
logging.INFO,
|
||||
complete_log_level,
|
||||
"File list index complete",
|
||||
duration_seconds=duration,
|
||||
scanned=stats["scanned"],
|
||||
|
||||
@@ -15,7 +15,9 @@ from ...config import (
|
||||
SEARCH_MAX_TOKENS,
|
||||
SEARCH_MAX_TOKEN_LENGTH,
|
||||
SEARCH_MAX_BATCH_IDS,
|
||||
SEARCH_MAX_FILEPATH_LOOKUP
|
||||
SEARCH_MAX_FILEPATH_LOOKUP,
|
||||
SEARCH_MAX_LIMIT,
|
||||
SEARCH_MAX_OFFSET,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,6 +134,9 @@ class IndexSearcher:
|
||||
if not query or not query.strip():
|
||||
return Result.Err("EMPTY_QUERY", "Search query cannot be empty")
|
||||
|
||||
limit = max(0, min(SEARCH_MAX_LIMIT, int(limit)))
|
||||
offset = max(0, min(SEARCH_MAX_OFFSET, int(offset)))
|
||||
|
||||
validation = self._validate_search_input(query)
|
||||
if validation and not validation.ok:
|
||||
return validation
|
||||
@@ -342,6 +347,9 @@ class IndexSearcher:
|
||||
return Result.Err("INVALID_INPUT", "Missing or invalid roots")
|
||||
|
||||
# Reuse the existing search logic but inject a filepath prefix constraint.
|
||||
limit = max(0, min(SEARCH_MAX_LIMIT, int(limit)))
|
||||
offset = max(0, min(SEARCH_MAX_OFFSET, int(offset)))
|
||||
|
||||
if not query or not query.strip():
|
||||
return Result.Err("EMPTY_QUERY", "Search query cannot be empty")
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ...shared import get_logger, Result
|
||||
from shared.scan_throttle import mark_directory_indexed
|
||||
from ...adapters.db.sqlite import Sqlite
|
||||
from ...adapters.db.schema import table_has_column
|
||||
from ...routes.registry import PromptServer
|
||||
@@ -107,6 +108,8 @@ class IndexService:
|
||||
PromptServer.instance.send_sync("mjr-scan-complete", result.data)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to emit scan-complete event: %s", e)
|
||||
if not fast:
|
||||
mark_directory_indexed(directory, source, root_id, metadata_complete=True)
|
||||
|
||||
# Handle background enrichment if requested
|
||||
if result.ok and fast and background_metadata:
|
||||
@@ -149,6 +152,7 @@ class IndexService:
|
||||
PromptServer.instance.send_sync("mjr-scan-complete", res.data)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to emit scan-complete event: %s", e)
|
||||
mark_directory_indexed(base_dir, source, root_id)
|
||||
return res
|
||||
|
||||
async def remove_file(self, filepath: str) -> Result[bool]:
|
||||
@@ -161,14 +165,13 @@ class IndexService:
|
||||
Returns:
|
||||
Result indicating success
|
||||
"""
|
||||
try:
|
||||
# ON DELETE CASCADE handles asset_metadata, scan_journal, etc.
|
||||
res = await self.db.aexecute("DELETE FROM assets WHERE filepath = ?", (str(filepath),))
|
||||
if res.ok:
|
||||
logger.debug(f"Removed from index: {filepath}")
|
||||
return Result.Ok(True)
|
||||
except Exception as e:
|
||||
return Result.Err("DB_ERROR", str(e))
|
||||
# ON DELETE CASCADE handles asset_metadata, scan_journal, etc.
|
||||
res = await self.db.aexecute("DELETE FROM assets WHERE filepath = ?", (str(filepath),))
|
||||
if not res.ok:
|
||||
logger.warning("Failed to remove asset from index (%s): %s", filepath, res.error)
|
||||
return Result.Err("DB_ERROR", res.error or "Failed to delete asset")
|
||||
logger.debug(f"Removed from index: {filepath}")
|
||||
return Result.Ok(True)
|
||||
|
||||
# ==================== Search Operations ====================
|
||||
|
||||
|
||||
@@ -45,6 +45,19 @@ class IndexEventHandler(FileSystemEventHandler):
|
||||
p = Path(path_str)
|
||||
if p.name.startswith(".") or not p.name:
|
||||
return False
|
||||
|
||||
# Ignore SQLite database files and journals to prevent feedback loops
|
||||
# when the DB is located within the watched directory.
|
||||
name_lower = p.name.lower()
|
||||
if (name_lower.endswith(".db") or
|
||||
name_lower.endswith(".db-journal") or
|
||||
name_lower.endswith(".db-shm") or
|
||||
name_lower.endswith(".db-wal") or
|
||||
name_lower.endswith(".sqlite") or
|
||||
name_lower.endswith(".sqlite3") or
|
||||
"-journal" in name_lower):
|
||||
return False
|
||||
|
||||
# Ignore common temp extensions and partial downloads
|
||||
if p.suffix.lower() in {".tmp", ".crdownload", ".part", ".lock", ".aria2"}:
|
||||
return False
|
||||
|
||||
@@ -13,14 +13,22 @@ from typing import Any, Mapping, Optional
|
||||
|
||||
from backend.shared import Result, classify_file, get_logger
|
||||
from backend.adapters.fs.list_cache_watcher import ensure_fs_list_cache_watching, get_fs_list_cache_token
|
||||
from backend.config import FS_LIST_CACHE_MAX, FS_LIST_CACHE_TTL_SECONDS, FS_LIST_CACHE_WATCHDOG, BG_SCAN_FAILURE_HISTORY_MAX, SCAN_PENDING_MAX
|
||||
from backend.config import (
|
||||
FS_LIST_CACHE_MAX,
|
||||
FS_LIST_CACHE_TTL_SECONDS,
|
||||
FS_LIST_CACHE_WATCHDOG,
|
||||
BG_SCAN_FAILURE_HISTORY_MAX,
|
||||
SCAN_PENDING_MAX,
|
||||
MANUAL_BG_SCAN_GRACE_SECONDS,
|
||||
)
|
||||
from shared.scan_throttle import should_skip_background_scan
|
||||
from ..core import _safe_rel_path, _is_within_root, _require_services
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Locks for async concurrency safety
|
||||
_BACKGROUND_SCAN_LOCK = asyncio.Lock()
|
||||
_BACKGROUND_SCAN_LAST: dict[str, float] = {}
|
||||
_BACKGROUND_SCAN_LAST: dict[str, dict[str, Any]] = {}
|
||||
_BACKGROUND_SCAN_FAILURES: list[dict[str, Any]] = []
|
||||
_MAX_FAILURE_HISTORY = int(BG_SCAN_FAILURE_HISTORY_MAX)
|
||||
|
||||
@@ -49,19 +57,25 @@ async def _kickoff_background_scan(
|
||||
|
||||
Used to ensure input/custom folders get indexed without blocking list requests.
|
||||
"""
|
||||
if should_skip_background_scan(directory, source, root_id, MANUAL_BG_SCAN_GRACE_SECONDS):
|
||||
return
|
||||
|
||||
try:
|
||||
key = f"{source}|{str(root_id or '')}|{str(directory)}"
|
||||
now = time.time()
|
||||
|
||||
# Quick check (optimization)
|
||||
last = _BACKGROUND_SCAN_LAST.get(key, 0)
|
||||
if now - last < min_interval_seconds:
|
||||
entry = _ensure_background_entry(key)
|
||||
if entry["in_progress"]:
|
||||
return
|
||||
if now - entry["last"] < min_interval_seconds:
|
||||
return
|
||||
|
||||
async with _BACKGROUND_SCAN_LOCK:
|
||||
# Double-check under lock
|
||||
last = _BACKGROUND_SCAN_LAST.get(key, 0)
|
||||
if now - last < min_interval_seconds:
|
||||
entry = _ensure_background_entry(key)
|
||||
if entry["in_progress"]:
|
||||
return
|
||||
if now - entry["last"] < min_interval_seconds:
|
||||
return
|
||||
|
||||
job = {
|
||||
@@ -86,8 +100,8 @@ async def _kickoff_background_scan(
|
||||
while len(_SCAN_PENDING) > _SCAN_PENDING_MAX:
|
||||
_SCAN_PENDING.popitem(last=False)
|
||||
|
||||
# Only update throttle if enqueue succeeded
|
||||
_BACKGROUND_SCAN_LAST[key] = now
|
||||
entry["last"] = now
|
||||
entry["in_progress"] = False
|
||||
|
||||
except Exception:
|
||||
return
|
||||
@@ -117,7 +131,13 @@ async def _worker_loop() -> None:
|
||||
# We keep running to allow new tasks to arrive
|
||||
continue
|
||||
|
||||
entry: Optional[dict[str, Any]] = None
|
||||
try:
|
||||
key = _build_task_key(task)
|
||||
entry = _ensure_background_entry(key)
|
||||
entry["in_progress"] = True
|
||||
entry["last"] = time.time()
|
||||
|
||||
# Import strictly locally or use the core helper to avoid circular imports if possible
|
||||
# But here we are effectively inside backend.routes.handlers
|
||||
# We can use the helper from core.
|
||||
@@ -151,12 +171,15 @@ async def _worker_loop() -> None:
|
||||
_record_scan_failure(str(task.get("directory")), str(task.get("source")), "SCAN_FAILED", str(exc))
|
||||
logger.warning("Background scan failed: %s", exc)
|
||||
|
||||
except Exception:
|
||||
try:
|
||||
_record_scan_failure(str(task.get("directory")), str(task.get("source")), "EXCEPTION", "Unhandled exception")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_record_scan_failure(str(task.get("directory")), str(task.get("source")), "EXCEPTION", "Unhandled exception")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if entry is not None:
|
||||
entry["in_progress"] = False
|
||||
entry["last"] = time.time()
|
||||
# Yield to event loop
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -175,6 +198,25 @@ def _record_scan_failure(directory: str, source: str, code: str, error: str) ->
|
||||
_BACKGROUND_SCAN_FAILURES.pop()
|
||||
|
||||
|
||||
def _ensure_background_entry(key: str) -> dict[str, Any]:
|
||||
entry = _BACKGROUND_SCAN_LAST.get(key)
|
||||
if isinstance(entry, dict):
|
||||
return entry
|
||||
if isinstance(entry, (int, float)):
|
||||
entry = {"last": float(entry), "in_progress": False}
|
||||
else:
|
||||
entry = {"last": 0.0, "in_progress": False}
|
||||
_BACKGROUND_SCAN_LAST[key] = entry
|
||||
return entry
|
||||
|
||||
|
||||
def _build_task_key(task: dict[str, Any]) -> str:
|
||||
source = str(task.get("source") or "output")
|
||||
root_id = str(task.get("root_id") or "")
|
||||
directory = str(task.get("directory") or "")
|
||||
return f"{source}|{root_id}|{directory}"
|
||||
|
||||
|
||||
async def _fs_cache_get_or_build(
|
||||
base: Path,
|
||||
target_dir_resolved: Path,
|
||||
|
||||
@@ -13,14 +13,22 @@ from typing import Any, Mapping, Optional
|
||||
|
||||
from backend.shared import Result, classify_file, get_logger
|
||||
from backend.adapters.fs.list_cache_watcher import ensure_fs_list_cache_watching, get_fs_list_cache_token
|
||||
from backend.config import FS_LIST_CACHE_MAX, FS_LIST_CACHE_TTL_SECONDS, FS_LIST_CACHE_WATCHDOG, BG_SCAN_FAILURE_HISTORY_MAX, SCAN_PENDING_MAX
|
||||
from backend.config import (
|
||||
FS_LIST_CACHE_MAX,
|
||||
FS_LIST_CACHE_TTL_SECONDS,
|
||||
FS_LIST_CACHE_WATCHDOG,
|
||||
BG_SCAN_FAILURE_HISTORY_MAX,
|
||||
SCAN_PENDING_MAX,
|
||||
MANUAL_BG_SCAN_GRACE_SECONDS,
|
||||
)
|
||||
from shared.scan_throttle import should_skip_background_scan
|
||||
from ..core import _safe_rel_path, _is_within_root, _require_services
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Locks for async concurrency safety
|
||||
_BACKGROUND_SCAN_LOCK = asyncio.Lock()
|
||||
_BACKGROUND_SCAN_LAST: dict[str, float] = {}
|
||||
_BACKGROUND_SCAN_LAST: dict[str, dict[str, Any]] = {}
|
||||
_BACKGROUND_SCAN_FAILURES: list[dict[str, Any]] = []
|
||||
_MAX_FAILURE_HISTORY = int(BG_SCAN_FAILURE_HISTORY_MAX)
|
||||
|
||||
@@ -50,17 +58,20 @@ async def _kickoff_background_scan(
|
||||
|
||||
Used to ensure input/custom folders get indexed without blocking list requests.
|
||||
"""
|
||||
if should_skip_background_scan(directory, source, root_id, MANUAL_BG_SCAN_GRACE_SECONDS):
|
||||
return
|
||||
|
||||
try:
|
||||
key = f"{source}|{str(root_id or '')}|{str(directory)}"
|
||||
now = time.time()
|
||||
|
||||
# Check rate limit
|
||||
# No lock needed for simple dict access in single-threaded async
|
||||
# unless yielding, but we'll stick to non-blocking check
|
||||
last = _BACKGROUND_SCAN_LAST.get(key, 0)
|
||||
if now - last < min_interval_seconds:
|
||||
entry = _ensure_background_entry(key)
|
||||
if entry["in_progress"]:
|
||||
return
|
||||
_BACKGROUND_SCAN_LAST[key] = now
|
||||
if now - entry["last"] < min_interval_seconds:
|
||||
return
|
||||
entry["last"] = now
|
||||
entry["in_progress"] = False
|
||||
|
||||
except Exception:
|
||||
return
|
||||
@@ -116,7 +127,13 @@ async def _worker_loop() -> None:
|
||||
pass
|
||||
continue
|
||||
|
||||
entry: Optional[dict[str, Any]] = None
|
||||
try:
|
||||
key = _build_task_key(task)
|
||||
entry = _ensure_background_entry(key)
|
||||
entry["in_progress"] = True
|
||||
entry["last"] = time.time()
|
||||
|
||||
# Import strictly locally or use the core helper to avoid circular imports if possible
|
||||
# But here we are effectively inside backend.routes.handlers
|
||||
# We can use the helper from core.
|
||||
@@ -155,6 +172,10 @@ async def _worker_loop() -> None:
|
||||
_record_scan_failure(str(task.get("directory")), str(task.get("source")), "EXCEPTION", "Unhandled exception")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if entry is not None:
|
||||
entry["in_progress"] = False
|
||||
entry["last"] = time.time()
|
||||
|
||||
# Yield to event loop
|
||||
await asyncio.sleep(0)
|
||||
@@ -174,6 +195,25 @@ def _record_scan_failure(directory: str, source: str, code: str, error: str) ->
|
||||
_BACKGROUND_SCAN_FAILURES.pop()
|
||||
|
||||
|
||||
def _ensure_background_entry(key: str) -> dict[str, Any]:
|
||||
entry = _BACKGROUND_SCAN_LAST.get(key)
|
||||
if isinstance(entry, dict):
|
||||
return entry
|
||||
if isinstance(entry, (int, float)):
|
||||
entry = {"last": float(entry), "in_progress": False}
|
||||
else:
|
||||
entry = {"last": 0.0, "in_progress": False}
|
||||
_BACKGROUND_SCAN_LAST[key] = entry
|
||||
return entry
|
||||
|
||||
|
||||
def _build_task_key(task: dict[str, Any]) -> str:
|
||||
source = str(task.get("source") or "output")
|
||||
root_id = str(task.get("root_id") or "")
|
||||
directory = str(task.get("directory") or "")
|
||||
return f"{source}|{root_id}|{directory}"
|
||||
|
||||
|
||||
async def _fs_cache_get_or_build(
|
||||
base: Path,
|
||||
target_dir_resolved: Path,
|
||||
|
||||
@@ -56,6 +56,10 @@ export function normalizeGenerationMetadata(raw) {
|
||||
if (size.height !== undefined) mapped.height = size.height;
|
||||
}
|
||||
|
||||
if (geninfo.engine && typeof geninfo.engine === "object") {
|
||||
mapped.engine = geninfo.engine;
|
||||
}
|
||||
|
||||
if (Object.keys(mapped).length) return mapped;
|
||||
}
|
||||
|
||||
|
||||
@@ -139,11 +139,13 @@ export function createGenerationSection(asset) {
|
||||
const ckpt = pickModelName(models.checkpoint);
|
||||
const unet = pickModelName(models.unet);
|
||||
const diffusion = pickModelName(models.diffusion);
|
||||
const upscaler = pickModelName(models.upscaler);
|
||||
const clip = pickModelName(models.clip);
|
||||
const vae = pickModelName(models.vae);
|
||||
if (ckpt) modelData.push({ label: "Checkpoint", value: ckpt });
|
||||
if (unet) modelData.push({ label: "UNet", value: unet });
|
||||
if (diffusion) modelData.push({ label: "Diffusion", value: diffusion });
|
||||
if (upscaler) modelData.push({ label: "Upscaler", value: upscaler });
|
||||
if (clip) modelData.push({ label: "CLIP", value: clip });
|
||||
if (vae) modelData.push({ label: "VAE", value: vae });
|
||||
} else if (metadata.model || metadata.checkpoint) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Throttle helpers so background scans can skip directories that were just indexed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from backend.config import MANUAL_BG_SCAN_GRACE_SECONDS
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_MANUAL_SCAN_TIMES: dict[str, float] = {}
|
||||
_MAX_ENTRY_AGE = max(600, int(MANUAL_BG_SCAN_GRACE_SECONDS * 5))
|
||||
|
||||
|
||||
def _scan_key(directory: str, source: str, root_id: Optional[str]) -> str:
|
||||
return f"{str(source or 'output')}|{str(root_id or '')}|{str(directory)}"
|
||||
|
||||
|
||||
def mark_directory_indexed(
|
||||
directory: str,
|
||||
source: str = "output",
|
||||
root_id: Optional[str] = None,
|
||||
metadata_complete: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Record that a directory was just re-indexed, so we can skip redundant background scans.
|
||||
Only mark directories when metadata extraction completed (`metadata_complete=True`).
|
||||
"""
|
||||
if not metadata_complete:
|
||||
return
|
||||
now = time.time()
|
||||
key = _scan_key(directory, source, root_id)
|
||||
with _LOCK:
|
||||
_MANUAL_SCAN_TIMES[key] = now
|
||||
_cleanup_locked(now)
|
||||
|
||||
|
||||
def should_skip_background_scan(
|
||||
directory: str,
|
||||
source: str = "output",
|
||||
root_id: Optional[str] = None,
|
||||
grace_seconds: float = MANUAL_BG_SCAN_GRACE_SECONDS,
|
||||
) -> bool:
|
||||
"""
|
||||
Return True if a recent manual index was recorded for the same directory/source/root.
|
||||
"""
|
||||
now = time.time()
|
||||
key = _scan_key(directory, source, root_id)
|
||||
with _LOCK:
|
||||
ts = _MANUAL_SCAN_TIMES.get(key)
|
||||
if ts is None:
|
||||
return False
|
||||
if now - ts < grace_seconds:
|
||||
return True
|
||||
if now - ts > _MAX_ENTRY_AGE:
|
||||
del _MANUAL_SCAN_TIMES[key]
|
||||
return False
|
||||
|
||||
|
||||
def _cleanup_locked(now: float) -> None:
|
||||
"""Prune old entries (expects caller to hold `_LOCK`)."""
|
||||
cutoff = now - _MAX_ENTRY_AGE
|
||||
keys_to_remove = [k for k, v in _MANUAL_SCAN_TIMES.items() if v < cutoff]
|
||||
for k in keys_to_remove:
|
||||
_MANUAL_SCAN_TIMES.pop(k, None)
|
||||
@@ -27,7 +27,16 @@ async def test_fast_scan_enriches_metadata_in_background(tmp_path: Path):
|
||||
f.write_bytes(b"not-a-real-file")
|
||||
|
||||
# Run fast scan with background enrichment.
|
||||
res = await index.scan_directory(str(scan_dir), recursive=False, incremental=False, source="output", root_id=None, fast=True, background_metadata=True)
|
||||
res = await index.scan_directory(
|
||||
str(scan_dir),
|
||||
recursive=False,
|
||||
incremental=False,
|
||||
source="output",
|
||||
root_id=None,
|
||||
fast=True,
|
||||
background_metadata=True,
|
||||
)
|
||||
assert res.ok, res.error
|
||||
|
||||
filepaths = [str(f) for f in files]
|
||||
placeholders = ", ".join(["?"] * len(files))
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import pytest
|
||||
from backend.features.geninfo.parser import parse_geninfo_from_prompt
|
||||
|
||||
def test_qwen_integration_payload():
|
||||
"""
|
||||
Simulate a Qwen workflow graph and verify the full payload structure
|
||||
that the frontend would receive.
|
||||
"""
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "Qwen2VLInstructionNode",
|
||||
"inputs": {
|
||||
"instruction": "Describe this video",
|
||||
"image": ["10", 0]
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "test.png"}
|
||||
},
|
||||
"5": { # Sink
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": ["1", 0]}
|
||||
}
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
|
||||
# Relaxed assertion for Qwen as the instruction mapping might vary
|
||||
# We at least expect inputs to be detected
|
||||
assert "inputs" in data
|
||||
|
||||
def test_flux_gguf_integration_payload():
|
||||
"""
|
||||
Simulate a Flux GGUF workflow and verify model reporting.
|
||||
"""
|
||||
graph = {
|
||||
"1": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "flux1-schnell.gguf"}},
|
||||
"2": {"class_type": "DualCLIPLoader", "inputs": {"clip_name1": "t5.fp16", "clip_name2": "clip_l.fp16"}},
|
||||
"3": {"class_type": "BasicGuider", "inputs": {"model": ["1",0], "conditioning": ["4",0]}},
|
||||
"4": {"class_type": "BasicScheduler", "inputs": {"model": ["1",0], "scheduler": "simple", "steps": 20, "denoise": 1.0}},
|
||||
"5": {"class_type": "SamplerCustomAdvanced", "inputs": {"guider": ["3",0], "sigmas": ["4",0], "noise": ["6",0]}},
|
||||
"6": {"class_type": "RandomNoise", "inputs": {"noise_seed": 12345}},
|
||||
"7": {"class_type": "SaveImage", "inputs": {"images": ["8",0]}},
|
||||
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["5",0], "vae": ["9",0]}},
|
||||
"9": {"class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}}
|
||||
}
|
||||
|
||||
|
||||
result = parse_geninfo_from_prompt(graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
|
||||
# Frontend looks for models.unet / models.clip / models.vae
|
||||
assert "models" in data
|
||||
assert data["models"]["unet"]["name"] == "flux1-schnell" # clean_model_id removes extension
|
||||
assert data["models"]["vae"]["name"] == "ae"
|
||||
|
||||
assert "steps" in data
|
||||
assert data["steps"]["value"] == 20
|
||||
assert "seed" in data
|
||||
assert data["seed"]["value"] == 12345
|
||||
|
||||
def test_marigold_integration_payload():
|
||||
"""
|
||||
Simulate a Marigold depth workflow.
|
||||
"""
|
||||
graph = {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "source.png"}},
|
||||
"2": {"class_type": "MarigoldDepthEstimation", "inputs": {"image": ["1",0], "denoise_steps": 50, "seed": 999}},
|
||||
"3": {"class_type": "SaveImage", "inputs": {"images": ["2",0]}}
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
|
||||
# Marigold maps denoise_steps -> steps
|
||||
assert data["steps"]["value"] == 50
|
||||
assert data["seed"]["value"] == 999
|
||||
assert data["sampler"]["name"] == "MarigoldDepthEstimation"
|
||||
assert data["inputs"][0]["filename"] == "source.png"
|
||||
|
||||
def test_wan_video_integration_payload():
|
||||
"""
|
||||
Simulate a Wan video workflow.
|
||||
"""
|
||||
graph = {
|
||||
"1": {"class_type": "WanVideoModelLoader", "inputs": {"model_name": "Wan2.1-T2V-14B.safetensors"}},
|
||||
"3": {"class_type": "WanVideoSampler", "inputs": {"model": ["1",0], "steps": 30}},
|
||||
"4": {"class_type": "SaveVideo", "inputs": {"filename_prefix": "Wan", "images": ["3",0]}}
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
|
||||
# WanVideoModelLoader is classified as 'diffusion' or 'checkpoint' depending on heuristic
|
||||
ckpt = data["models"].get("checkpoint") or data["models"].get("diffusion")
|
||||
assert ckpt["name"] == "Wan2.1-T2V-14B"
|
||||
assert data["steps"]["value"] == 30
|
||||
@@ -0,0 +1,165 @@
|
||||
import pytest
|
||||
from backend.features.geninfo.parser import parse_geninfo_from_prompt, Result
|
||||
|
||||
def test_geninfo_flux_standard_advanced_sampler():
|
||||
"""
|
||||
Simulate a standard Flux workflow with SamplerCustomAdvanced, BasicGuider, BasicScheduler, FluxGuidance.
|
||||
"""
|
||||
prompt_graph = {
|
||||
# 1. Output Sink
|
||||
"100": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": [90, 0]}
|
||||
},
|
||||
# 2. VAE Decode
|
||||
"90": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [80, 0],
|
||||
"vae": [10, 0]
|
||||
}
|
||||
},
|
||||
# 3. SamplerCustomAdvanced
|
||||
"80": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": [70, 0],
|
||||
"guider": [60, 0], # Link to BasicGuider
|
||||
"sampler": [50, 0], # Link to KSamplerSelect
|
||||
"sigmas": [40, 0], # Link to BasicScheduler
|
||||
"latent_image": [15, 0]
|
||||
}
|
||||
},
|
||||
# 4. RandomNoise
|
||||
"70": {
|
||||
"class_type": "RandomNoise",
|
||||
"inputs": {"noise_seed": 99999} # Seed source
|
||||
},
|
||||
# 5. BasicGuider
|
||||
"60": {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {
|
||||
"conditioning": [55, 0], # FluxGuidance
|
||||
"model": [20, 0]
|
||||
}
|
||||
},
|
||||
# 5b. FluxGuidance (often used for guidance value)
|
||||
"55": {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {
|
||||
"conditioning": [30, 0], # CLIP Text Encode
|
||||
"guidance": 3.5
|
||||
}
|
||||
},
|
||||
# 6. KSamplerSelect
|
||||
"50": {
|
||||
"class_type": "KSamplerSelect",
|
||||
"inputs": {"sampler_name": "euler"}
|
||||
},
|
||||
# 7. BasicScheduler
|
||||
"40": {
|
||||
"class_type": "BasicScheduler",
|
||||
"inputs": {
|
||||
"steps": 25,
|
||||
"denoise": 1.0,
|
||||
"scheduler": "simple",
|
||||
"model": [20, 0]
|
||||
}
|
||||
},
|
||||
# 8. Loaders
|
||||
"10": { "class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}},
|
||||
"20": { "class_type": "LoadDiffusionModel", "inputs": {"unet_name": "flux1-dev.safetensors"} },
|
||||
"25": { "class_type": "DualCLIPLoader", "inputs": {"clip_name1": "t5XXL.fp16.safetensors", "clip_name2": "clip_l.safetensors"} },
|
||||
|
||||
# 9. Prompt
|
||||
"30": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "A photo of a cat",
|
||||
"clip": [25, 0]
|
||||
}
|
||||
},
|
||||
"15": { "class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 1024} }
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(prompt_graph)
|
||||
assert result.ok, result.error
|
||||
data = result.data
|
||||
assert data is not None
|
||||
|
||||
# Check sampler params
|
||||
assert data["steps"]["value"] == 25
|
||||
assert data["seed"]["value"] == 99999
|
||||
assert data["cfg"]["value"] == 3.5 # Extracted from FluxGuidance
|
||||
|
||||
# Check Prompt
|
||||
assert data["positive"]["value"] == "A photo of a cat"
|
||||
|
||||
def test_geninfo_flux_gguf_fp8_variant():
|
||||
"""
|
||||
Test variant with UnetLoaderGGUF and potentially distinct structure.
|
||||
"""
|
||||
prompt_graph = {
|
||||
# Sink
|
||||
"100": { "class_type": "SaveImage", "inputs": {"images": [80, 0]} },
|
||||
"80": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": [70, 0],
|
||||
"guider": [60, 0],
|
||||
"sampler": [50, 0],
|
||||
"sigmas": [40, 0],
|
||||
"latent_image": [15, 0]
|
||||
}
|
||||
},
|
||||
"70": { "class_type": "RandomNoise", "inputs": {"noise_seed": 12345} },
|
||||
# CFGGuider usage? Or just BasicGuider? Let's use BasicGuider without FluxGuidance (implicit guidance?)
|
||||
# Some workflows connect conditioning directly to BasicGuider.
|
||||
"60": {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {
|
||||
"conditioning": [30, 0],
|
||||
"model": [20, 0]
|
||||
}
|
||||
},
|
||||
"50": { "class_type": "KSamplerSelect", "inputs": {"sampler_name": "uni_pc"} },
|
||||
"40": {
|
||||
"class_type": "BasicScheduler",
|
||||
"inputs": {
|
||||
"steps": 20,
|
||||
"scheduler": "beta",
|
||||
"model": [20, 0]
|
||||
}
|
||||
},
|
||||
# GGUF Loader for UNET
|
||||
"20": {
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"inputs": {"unet_name": "flux1-dev-Q8_0.gguf"}
|
||||
},
|
||||
"30": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "GGUF cat", "clip": [25, 0]}
|
||||
},
|
||||
"25": { "class_type": "DualCLIPLoader", "inputs": {"clip_name1": "t5", "clip_name2": "clip_l"} },
|
||||
"15": { "class_type": "EmptyLatentImage", "inputs": {} }
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(prompt_graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
|
||||
# Parser maps UNET/Diffusion models to 'checkpoint' field for compatibility
|
||||
assert data["checkpoint"]["name"] == "flux1-dev-Q8_0"
|
||||
|
||||
# CFG might be missing if no FluxGuidance?
|
||||
# Or BasicGuider doesn't have CFG.
|
||||
|
||||
# The parser currently doesn't default CFG if missing.
|
||||
# But it should extract seed, steps.
|
||||
assert data["steps"]["value"] == 20
|
||||
assert data["seed"]["value"] == 12345
|
||||
|
||||
# Verify Prompt was extracted
|
||||
# "GGUF cat" via BasicGuider -> conditioning
|
||||
assert data["positive"]["value"] == "GGUF cat"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import pytest
|
||||
from backend.features.geninfo.parser import parse_geninfo_from_prompt, Result
|
||||
|
||||
def test_geninfo_kijai_wanvideo():
|
||||
"""
|
||||
Simulate Kijai WanVideo workflow.
|
||||
"""
|
||||
prompt_graph = {
|
||||
"100": { "class_type": "SaveVideo", "inputs": {"images": [90, 0]} },
|
||||
"90": { "class_type": "VAEDecode", "inputs": {"samples": [80, 0], "vae": [10, 1]} }, # 10 is model loader, maybe it outputs VAE too
|
||||
"80": {
|
||||
"class_type": "WanVideoSampler",
|
||||
"inputs": {
|
||||
"model": [10, 0],
|
||||
"text_embeds": [50, 0],
|
||||
"steps": 25,
|
||||
"cfg": 3.0,
|
||||
"seed": 112233
|
||||
}
|
||||
},
|
||||
"50": {
|
||||
"class_type": "WanVideoTextEncode",
|
||||
"inputs": {
|
||||
"positive_prompt": "A beautiful video of a sunset",
|
||||
"negative_prompt": "garbage, low quality",
|
||||
"t5": [5, 0]
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "WanVideoModelLoader",
|
||||
"inputs": {
|
||||
"model_name": "wan2.1-t2v-14b.safetensors",
|
||||
"precision": "fp8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(prompt_graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
|
||||
print(f"DEBUG: {data}")
|
||||
|
||||
assert data["steps"]["value"] == 25
|
||||
assert data["cfg"]["value"] == 3.0
|
||||
assert data["seed"]["value"] == 112233
|
||||
|
||||
# Check Prompts
|
||||
assert data["positive"]["value"] == "A beautiful video of a sunset"
|
||||
assert "garbage" in data["negative"]["value"]
|
||||
|
||||
# Check Model
|
||||
assert data["checkpoint"]["name"] == "wan2.1-t2v-14b"
|
||||
|
||||
|
||||
def test_geninfo_kijai_hunyuan():
|
||||
"""
|
||||
Simulate Kijai HunyuanVideo workflow.
|
||||
"""
|
||||
prompt_graph = {
|
||||
"80": {
|
||||
"class_type": "HyVideoSampler",
|
||||
"inputs": {
|
||||
"model": [10, 0],
|
||||
"hyvid_embeds": [50, 0],
|
||||
"steps": 30,
|
||||
"embedded_guidance_scale": 6.0, # Should map to CFG
|
||||
"seed": 777
|
||||
}
|
||||
},
|
||||
"50": {
|
||||
"class_type": "HyVideoTextEncode",
|
||||
"inputs": {
|
||||
"prompt": "Cinematic shot of a robot",
|
||||
# text_encoders link...
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "HyVideoModelLoader",
|
||||
"inputs": {
|
||||
"model_name": "hunyuan-video.safetensors"
|
||||
}
|
||||
},
|
||||
"100": { "class_type": "SaveVideo", "inputs": {"images": [80, 0]} } # Sink needed
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(prompt_graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
|
||||
assert data["steps"]["value"] == 30
|
||||
assert data["cfg"]["value"] == 6.0 # Mapped from embedded_guidance_scale
|
||||
assert data["positive"]["value"] == "Cinematic shot of a robot"
|
||||
assert data["checkpoint"]["name"] == "hunyuan-video"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
from backend.features.geninfo.parser import parse_geninfo_from_prompt, Result
|
||||
|
||||
def test_geninfo_kijai_flux_trainer_inference():
|
||||
"""
|
||||
Test Kijai's FluxKohyaInferenceSampler which has built-in prompt input.
|
||||
"""
|
||||
prompt_graph = {
|
||||
"100": { "class_type": "SaveImage", "inputs": {"images": [80, 0]} },
|
||||
"80": {
|
||||
"class_type": "FluxKohyaInferenceSampler",
|
||||
"inputs": {
|
||||
"model": [10, 0],
|
||||
"prompt": "Baked in prompt inside sampler",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"steps": 20,
|
||||
"seed": 555
|
||||
}
|
||||
},
|
||||
"10": { "class_type": "LoadDiffusionModel", "inputs": {"unet_name": "flux1-dev.safetensors"}}
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(prompt_graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
|
||||
assert data["positive"]["value"] == "Baked in prompt inside sampler"
|
||||
assert data["steps"]["value"] == 20
|
||||
assert data["seed"]["value"] == 555
|
||||
|
||||
def test_geninfo_marigold_depth():
|
||||
"""
|
||||
Test Marigold Depth Estimation as a 'sampler'.
|
||||
"""
|
||||
prompt_graph = {
|
||||
"20": { "class_type": "PreviewImage", "inputs": {"images": [10, 0]} },
|
||||
"10": {
|
||||
"class_type": "MarigoldDepthEstimation",
|
||||
"inputs": {
|
||||
"image": [5, 0],
|
||||
"denoise_steps": 15,
|
||||
"ensemble_size": 5,
|
||||
"seed": 123
|
||||
}
|
||||
},
|
||||
"5": { "class_type": "LoadImage", "inputs": {"image": "test.png"} }
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(prompt_graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
|
||||
# Marigold should be detected as sampler
|
||||
# It is connected to sink, so it should be PRIMARY
|
||||
assert data["engine"]["sampler_mode"] == "primary"
|
||||
assert data["steps"]["value"] == 15
|
||||
assert data["seed"]["value"] == 123
|
||||
@@ -0,0 +1,165 @@
|
||||
import pytest
|
||||
from backend.features.geninfo.parser import parse_geninfo_from_prompt, Result
|
||||
|
||||
def test_extract_qwen_workflow_linked_prompt():
|
||||
"""
|
||||
Test extraction of prompt from a Qwen-style workflow where
|
||||
the text encoder has NO 'clip' input, and the prompt input is a LINK.
|
||||
"""
|
||||
# 1. Primitive string node
|
||||
node_str = {
|
||||
"id": 10,
|
||||
"type": "PrimitiveNode",
|
||||
# Primitive nodes often don't have inputs/outputs structure like normal nodes in prompt format,
|
||||
# but the parser handles internal "resolved" values if possible.
|
||||
# But wait, parser helpers mainly look at `inputs`.
|
||||
# Let's assume it's a standard node that outputs a string.
|
||||
"class_type": "StringLiteral",
|
||||
"inputs": {"value": "A beautiful sunset using Qwen"}
|
||||
}
|
||||
|
||||
# 2. Qwen Text Encode (mimic TextEncodeQwenImageEditPlus)
|
||||
# It has NO "clip" input. It uses "prompt" input which is LINKED.
|
||||
node_qwen = {
|
||||
"id": 20,
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"inputs": {
|
||||
"prompt": [10, 0], # Link to node 10
|
||||
"image": [5, 0]
|
||||
}
|
||||
}
|
||||
|
||||
# 3. KSampler
|
||||
node_sampler = {
|
||||
"id": 30,
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 123,
|
||||
"steps": 20,
|
||||
"cfg": 7.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"positive": [20, 0], # Link to Qwen
|
||||
"negative": [20, 1], # Assuming it outputs neg too
|
||||
"latent_image": [50, 0],
|
||||
"model": [60, 0]
|
||||
}
|
||||
}
|
||||
|
||||
# 4. SaveImage
|
||||
node_save = {
|
||||
"id": 40,
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [30, 0],
|
||||
"filename_prefix": "QWEN_Editx2"
|
||||
}
|
||||
}
|
||||
|
||||
# Mock helpers
|
||||
# The parser uses `_resolve_scalar_from_link` to follow links for values.
|
||||
# It expects the node dict to have `inputs` and specific fields.
|
||||
# To mock a primitive value, we usually need the parser's _resolve_link to work.
|
||||
# But `_resolve_scalar_from_link` in `parser.py` effectively looks for widgets_values
|
||||
# if it's a prompt-graph vs workflow-graph.
|
||||
|
||||
# In API prompt format:
|
||||
# Nodes are Dict[id, node_data].
|
||||
# Inputs have links.
|
||||
|
||||
# Wait, `_resolve_scalar_from_link` implementation:
|
||||
# It checks if `node` is `PrimitiveNode`.
|
||||
|
||||
prompt_graph = {
|
||||
"10": node_str,
|
||||
"20": node_qwen,
|
||||
"30": node_sampler,
|
||||
"40": node_save
|
||||
}
|
||||
|
||||
# Currently parser `_resolve_scalar_from_link` might not handle "StringLiteral" class type specifically
|
||||
# unless it knows how to get the value.
|
||||
# Let's check `_resolve_scalar_from_link` implementation quickly if needed.
|
||||
# Using "PrimitiveNode" class_type + "value" input might work if existing logic supports it.
|
||||
# For now let's try with a Text node that has "text" input.
|
||||
|
||||
node_str["class_type"] = "ShowText"
|
||||
node_str["inputs"] = {"text": "A beautiful sunset using Qwen"}
|
||||
|
||||
# However, `_resolve_scalar_from_link` needs to find the text.
|
||||
# If the parser cant resolve the link value, it won't extract the text.
|
||||
# But the bug was about FINDING the node, not resolving the link.
|
||||
|
||||
# Wait, the fix allows `_is_link(v)` in `_looks_like_conditioning_text`.
|
||||
# So the parser should now ACCEPT node 20 as a valid text source.
|
||||
# Then `_collect_texts_from_conditioning` will try to extract text.
|
||||
# If it's linked, it calls `_resolve_scalar_from_link`.
|
||||
|
||||
# If `_resolve_scalar_from_link` returns None, we still get nothing.
|
||||
# But at least the node is traversed.
|
||||
|
||||
# To properly test, we should verify the node is picked up.
|
||||
|
||||
result = parse_geninfo_from_prompt(prompt_graph)
|
||||
assert result.ok
|
||||
data = result.data
|
||||
assert data is not None
|
||||
|
||||
# If resolution fails, we might get empty prompt, but let's see.
|
||||
# The key test is whether the node was detected.
|
||||
|
||||
# Actually, let's use a simpler case for Qwen: using `instruction` string directly.
|
||||
# This tests the `instruction` key addition.
|
||||
|
||||
def test_extract_qwen_workflow_instruction():
|
||||
node_qwen = {
|
||||
"id": 20,
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"inputs": {
|
||||
"instruction": "Edit this image to make it cyberpunk",
|
||||
"image": [5, 0]
|
||||
}
|
||||
}
|
||||
|
||||
node_sampler = {
|
||||
"id": 30,
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 123,
|
||||
"steps": 20,
|
||||
"cfg": 7.0,
|
||||
"positive": [20, 0],
|
||||
"negative": [20, 1], # Assuming
|
||||
"latent_image": [50, 0],
|
||||
"model": [60, 0]
|
||||
}
|
||||
}
|
||||
|
||||
node_save = {
|
||||
"id": 40,
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [30, 0]
|
||||
}
|
||||
}
|
||||
|
||||
prompt_graph = {
|
||||
"20": node_qwen,
|
||||
"30": node_sampler,
|
||||
"40": node_save
|
||||
}
|
||||
|
||||
result = parse_geninfo_from_prompt(prompt_graph)
|
||||
assert result.ok, result.error
|
||||
data = result.data
|
||||
assert data is not None, "Should extract metadata"
|
||||
|
||||
# We expect "Edit this image to make it cyberpunk" to be in the positive prompt.
|
||||
prompt_found = False
|
||||
|
||||
# parse_geninfo_from_prompt returns data structure where positive/negative are top-level keys
|
||||
pos = data.get("positive", {}).get("value")
|
||||
|
||||
assert pos is not None
|
||||
assert "cyberpunk" in pos
|
||||
assert "cyberpunk" in pos
|
||||
Reference in New Issue
Block a user