diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 2564c62f..98121644 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -21,6 +21,18 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install ExifTool and FFmpeg (includes ffprobe) + run: | + sudo apt-get update + sudo apt-get install -y libimage-exiftool-perl ffmpeg + + - name: Verify ExifTool and ffprobe + run: | + echo "ExifTool version:" + exiftool -ver || true + echo "ffprobe version:" + ffprobe -version || true + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/backend/features/audio/metadata.py b/backend/features/audio/metadata.py index ff686558..0c8baec4 100644 --- a/backend/features/audio/metadata.py +++ b/backend/features/audio/metadata.py @@ -13,6 +13,7 @@ from ..metadata.parsing_utils import ( looks_like_comfyui_workflow, parse_auto1111_params, parse_json_value, + try_parse_json_text, ) from ..metadata.extractors import extract_rating_tags_from_exif @@ -40,6 +41,71 @@ def _extract_json_fields(container: Any) -> Tuple[Optional[Dict[str, Any]], Opti return (workflow, prompt) +def _unwrap_workflow_prompt_container(container: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + """ + Handle wrapper payloads embedded in a single text field/tag, e.g: + {"workflow": {...}, "prompt": "{...json...}"} + """ + if not isinstance(container, dict): + return (None, None) + + wf = container.get("workflow") or container.get("Workflow") or None + pr = container.get("prompt") or container.get("Prompt") or None + + wf_out: Optional[Dict[str, Any]] = None + pr_out: Optional[Dict[str, Any]] = None + + if isinstance(wf, dict) and looks_like_comfyui_workflow(wf): + wf_out = wf + + if isinstance(pr, dict) and looks_like_comfyui_prompt_graph(pr): + pr_out = pr + elif isinstance(pr, str): + parsed = try_parse_json_text(pr) + if isinstance(parsed, dict) and looks_like_comfyui_prompt_graph(parsed): + pr_out = parsed + + return (wf_out, pr_out) + + +def _scan_for_workflow_prompt(*containers: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + """ + Robust scan across one or multiple metadata containers. + """ + workflow: Optional[Dict[str, Any]] = None + prompt: Optional[Dict[str, Any]] = None + + for container in containers: + if not isinstance(container, dict): + continue + + # 1) Fast key-based parse first + wf, pr = _extract_json_fields(container) + if workflow is None: + workflow = wf + if prompt is None: + prompt = pr + + # 2) Wrapper scan for JSON values under comments/descriptions + if workflow is None or prompt is None: + for _, value in container.items(): + parsed = parse_json_value(value) + if not isinstance(parsed, dict): + continue + wf2, pr2 = _unwrap_workflow_prompt_container(parsed) + if workflow is None and wf2 is not None: + workflow = wf2 + if prompt is None and pr2 is not None: + prompt = pr2 + if workflow is not None and prompt is not None: + break + + if workflow is not None and prompt is not None: + break + + return (workflow, prompt) + + def extract_audio_metadata( file_path: str, exif_data: Optional[Dict[str, Any]] = None, @@ -84,11 +150,17 @@ def extract_audio_metadata( if metadata["bitrate"] is None: metadata["bitrate"] = fmt.get("bit_rate") - workflow, prompt = _extract_json_fields(exif_data) - if isinstance(fmt, dict): - wf2, pr2 = _extract_json_fields(fmt.get("tags")) - workflow = workflow or wf2 - prompt = prompt or pr2 + format_tags = fmt.get("tags") if isinstance(fmt, dict) and isinstance(fmt.get("tags"), dict) else {} + stream_tag_dicts = [] + if isinstance(ffprobe_data, dict) and isinstance(ffprobe_data.get("streams"), list): + for stream in ffprobe_data.get("streams") or []: + if not isinstance(stream, dict): + continue + tags = stream.get("tags") + if isinstance(tags, dict): + stream_tag_dicts.append(tags) + + workflow, prompt = _scan_for_workflow_prompt(exif_data, format_tags, *stream_tag_dicts) if workflow is not None: metadata["workflow"] = workflow diff --git a/backend/features/geninfo/parser.py b/backend/features/geninfo/parser.py index f157ce18..5e7e757f 100644 --- a/backend/features/geninfo/parser.py +++ b/backend/features/geninfo/parser.py @@ -1880,6 +1880,193 @@ def _normalize_graph_input(prompt_graph: Any, workflow: Any) -> Optional[Dict[st return nodes_by_id if nodes_by_id else None def _extract_geninfo(nodes_by_id: Dict[str, Any], sinks: List[str], workflow_meta: Optional[Dict]) -> Result: + def _extract_tts_geninfo_fallback() -> Optional[Dict[str, Any]]: + text_node_id = None + text_node = None + engine_node_id = None + engine_node = None + + for nid, node in nodes_by_id.items(): + if not isinstance(node, dict): + continue + ct = _lower(_node_type(node)) + if text_node is None and ("unifiedttstextnode" in ct or "tts_text_node" in ct or ("tts" in ct and "text" in ct)): + text_node_id = nid + text_node = node + if engine_node is None and ( + "ttsengine" in ct + or ("qwen" in ct and "tts" in ct and "engine" in ct) + or "engine_node" in ct and "tts" in ct + ): + engine_node_id = nid + engine_node = node + if text_node is not None and engine_node is not None: + break + + if not text_node and not engine_node: + return None + + out: Dict[str, Any] = { + "engine": { + "parser_version": "geninfo-tts-v1", + "type": "tts", + "sink": "audio", + } + } + if workflow_meta: + out["metadata"] = workflow_meta + + if text_node: + tins = _inputs(text_node) + source = f"{_node_type(text_node)}:{text_node_id}" + # In TTS workflows, text node is the generation driver ("sampler"). + out["sampler"] = {"name": str(_node_type(text_node) or "TTS"), "confidence": "high", "source": source} + text_value = tins.get("text") + if isinstance(text_value, str) and text_value.strip(): + out["positive"] = {"value": text_value.strip(), "confidence": "high", "source": source} + seed_value = _scalar(tins.get("seed")) or _scalar(tins.get("noise_seed")) + if seed_value is not None: + out["seed"] = {"value": seed_value, "confidence": "high", "source": source} + narrator = _scalar(tins.get("narrator_voice")) + if narrator is not None and str(narrator).strip(): + out["voice"] = {"name": str(narrator).strip(), "confidence": "high", "source": source} + for key in ( + "enable_chunking", + "max_chars_per_chunk", + "chunk_combination_method", + "silence_between_chunks_ms", + "enable_audio_cache", + "batch_size", + "control_after_generate", + ): + value = _scalar(tins.get(key)) + if value is not None: + out[key] = {"value": value, "confidence": "high", "source": source} + # Workflow-export fallback: infer key fields from widgets_values. + widgets = text_node.get("widgets_values") + if isinstance(widgets, list): + if "positive" not in out: + for v in widgets: + if isinstance(v, str) and len(v.strip()) > 20: + out["positive"] = {"value": v.strip(), "confidence": "medium", "source": source} + break + if "seed" not in out: + for v in widgets: + sv = _scalar(v) + if isinstance(sv, int) and sv >= 0: + out["seed"] = {"value": sv, "confidence": "medium", "source": source} + break + if "voice" not in out and len(widgets) >= 2 and isinstance(widgets[1], str) and widgets[1].strip(): + out["voice"] = {"name": widgets[1].strip(), "confidence": "medium", "source": source} + # If narrator comes from CharacterVoices via opt_narrator link, resolve voice_name. + try: + narrator_link = tins.get("opt_narrator") + narrator_id = _walk_passthrough(nodes_by_id, narrator_link) if _is_link(narrator_link) else None + narrator_node = nodes_by_id.get(narrator_id) if narrator_id else None + if isinstance(narrator_node, dict): + nins = _inputs(narrator_node) + voice_name = _scalar(nins.get("voice_name")) + if voice_name is not None and str(voice_name).strip(): + out["voice"] = { + "name": str(voice_name).strip(), + "confidence": "high", + "source": f"{_node_type(narrator_node)}:{narrator_id}", + } + except Exception: + pass + + if engine_node: + eins = _inputs(engine_node) + esource = f"{_node_type(engine_node)}:{engine_node_id}" + model_name = _clean_model_id(eins.get("model_size") or eins.get("model") or eins.get("checkpoint") or eins.get("model_name")) + if model_name: + out["checkpoint"] = {"name": model_name, "confidence": "high", "source": esource} + out["models"] = {"checkpoint": out["checkpoint"]} + device = _scalar(eins.get("device")) + if device is not None and str(device).strip(): + out["device"] = {"value": str(device).strip(), "confidence": "high", "source": esource} + voice_preset = _scalar(eins.get("voice_preset")) + if voice_preset is not None and str(voice_preset).strip(): + out["voice_preset"] = {"value": str(voice_preset).strip(), "confidence": "high", "source": esource} + instruct = _scalar(eins.get("instruct")) + if instruct is not None and str(instruct).strip(): + out["instruct"] = {"value": str(instruct).strip(), "confidence": "high", "source": esource} + language = _scalar(eins.get("language")) + if language is not None and str(language).strip(): + out["language"] = {"value": str(language).strip(), "confidence": "high", "source": esource} + for key in ( + "temperature", + "top_p", + "top_k", + "repetition_penalty", + "max_new_tokens", + "dtype", + "attn_implementation", + "x_vector_only_mode", + "use_torch_compile", + "use_cuda_graphs", + "compile_mode", + ): + value = _scalar(eins.get(key)) + if value is not None: + out[key] = {"value": value, "confidence": "high", "source": esource} + # Workflow-export fallback for engine widgets. + ewidgets = engine_node.get("widgets_values") + ect = _lower(_node_type(engine_node)) + if isinstance(ewidgets, list): + if "checkpoint" not in out and ewidgets: + guess_model = _clean_model_id(ewidgets[0]) + if guess_model: + out["checkpoint"] = {"name": guess_model, "confidence": "medium", "source": esource} + out["models"] = {"checkpoint": out["checkpoint"]} + if "language" not in out: + guess_lang = None + if "qwen3ttsengine" in ect and len(ewidgets) > 3: + guess_lang = _scalar(ewidgets[3]) + if guess_lang is None: + for v in ewidgets: + if isinstance(v, str) and v.strip() and v.strip().lower() not in ("auto", "default"): + guess_lang = v + break + if guess_lang is not None: + out["language"] = {"value": str(guess_lang).strip(), "confidence": "medium", "source": esource} + if "device" not in out and "qwen3ttsengine" in ect and len(ewidgets) > 1: + v = _scalar(ewidgets[1]) + if v is not None and str(v).strip(): + out["device"] = {"value": str(v).strip(), "confidence": "medium", "source": esource} + if "voice_preset" not in out and "qwen3ttsengine" in ect and len(ewidgets) > 2: + v = _scalar(ewidgets[2]) + if v is not None and str(v).strip(): + out["voice_preset"] = {"value": str(v).strip(), "confidence": "medium", "source": esource} + if "top_k" not in out and "qwen3ttsengine" in ect and len(ewidgets) > 5: + v = _scalar(ewidgets[5]) + if v is not None: + out["top_k"] = {"value": v, "confidence": "medium", "source": esource} + if "top_p" not in out and "qwen3ttsengine" in ect and len(ewidgets) > 6: + v = _scalar(ewidgets[6]) + if v is not None: + out["top_p"] = {"value": v, "confidence": "medium", "source": esource} + if "temperature" not in out and "qwen3ttsengine" in ect and len(ewidgets) > 7: + v = _scalar(ewidgets[7]) + if v is not None: + out["temperature"] = {"value": v, "confidence": "medium", "source": esource} + if "repetition_penalty" not in out and "qwen3ttsengine" in ect and len(ewidgets) > 8: + v = _scalar(ewidgets[8]) + if v is not None: + out["repetition_penalty"] = {"value": v, "confidence": "medium", "source": esource} + if "max_new_tokens" not in out and "qwen3ttsengine" in ect and len(ewidgets) > 9: + v = _scalar(ewidgets[9]) + if v is not None: + out["max_new_tokens"] = {"value": v, "confidence": "medium", "source": esource} + + input_files = _extract_input_files(nodes_by_id) + if input_files: + out["inputs"] = input_files + + if len(out.keys()) <= 1: + return None + return out + sink_id = sinks[0] sampler_id, sampler_conf = _select_primary_sampler(nodes_by_id, sink_id) sampler_mode = "primary" @@ -1904,6 +2091,10 @@ def _extract_geninfo(nodes_by_id: Dict[str, Any], sinks: List[str], workflow_met break if not sampler_id: + tts_fallback = _extract_tts_geninfo_fallback() + if tts_fallback: + return Result.Ok(tts_fallback) + out_fallback: Dict[str, Any] = {} if workflow_meta: out_fallback["metadata"] = workflow_meta diff --git a/js/api/endpoints.js b/js/api/endpoints.js index 99726102..87fb1c92 100644 --- a/js/api/endpoints.js +++ b/js/api/endpoints.js @@ -11,6 +11,7 @@ export const ENDPOINTS = { // Health & Config HEALTH: "/mjr/am/health", HEALTH_COUNTERS: "/mjr/am/health/counters", + HEALTH_DB: "/mjr/am/health/db", CONFIG: "/mjr/am/config", VERSION: "/majoor/version", diff --git a/js/components/sidebar/SidebarView.js b/js/components/sidebar/SidebarView.js index 421aeb0c..af3f353e 100644 --- a/js/components/sidebar/SidebarView.js +++ b/js/components/sidebar/SidebarView.js @@ -127,19 +127,29 @@ export async function showAssetInSidebar(sidebar, asset, onUpdate) { const hasMeaningfulMetadataRaw = (value) => { if (value == null) return false; + let obj = value; if (typeof value === "string") { const trimmed = value.trim(); - if (!trimmed) return false; - return trimmed !== "{}" && trimmed !== "null"; - } - if (typeof value === "object") { + if (!trimmed || trimmed === "{}" || trimmed === "null") return false; try { - return Object.keys(value).length > 0; + obj = JSON.parse(trimmed); } catch { return false; } } - return true; + if (typeof obj === "object") { + try { + // Require generation-specific keys (not just any metadata payload). + if (obj.geninfo || obj.prompt || obj.workflow) return true; + if (obj.metadata_raw && typeof obj.metadata_raw === "object") { + return Boolean(obj.metadata_raw.geninfo || obj.metadata_raw.prompt || obj.metadata_raw.workflow); + } + return false; + } catch { + return false; + } + } + return false; }; const hasGenerationLikeData = (obj) => { diff --git a/js/components/sidebar/parsers/geninfoParser.js b/js/components/sidebar/parsers/geninfoParser.js index 5f4507f8..41245a43 100644 --- a/js/components/sidebar/parsers/geninfoParser.js +++ b/js/components/sidebar/parsers/geninfoParser.js @@ -40,6 +40,53 @@ export function normalizeGenerationMetadata(raw) { if (cfg !== null && cfg !== undefined) mapped.cfg = cfg; const seed = geninfo.seed?.value ?? geninfo.seed ?? null; if (seed !== null && seed !== undefined) mapped.seed = seed; + const voice = geninfo.voice?.name ?? geninfo.voice?.value ?? geninfo.voice ?? null; + if (voice !== null && voice !== undefined && String(voice).trim()) mapped.voice = String(voice).trim(); + const language = geninfo.language?.value ?? geninfo.language ?? null; + if (language !== null && language !== undefined && String(language).trim()) mapped.language = String(language).trim(); + const temperature = geninfo.temperature?.value ?? geninfo.temperature ?? null; + if (temperature !== null && temperature !== undefined) mapped.temperature = temperature; + const topK = geninfo.top_k?.value ?? geninfo.top_k ?? null; + if (topK !== null && topK !== undefined) mapped.top_k = topK; + const topP = geninfo.top_p?.value ?? geninfo.top_p ?? null; + if (topP !== null && topP !== undefined) mapped.top_p = topP; + const repetitionPenalty = geninfo.repetition_penalty?.value ?? geninfo.repetition_penalty ?? null; + if (repetitionPenalty !== null && repetitionPenalty !== undefined) mapped.repetition_penalty = repetitionPenalty; + const maxNewTokens = geninfo.max_new_tokens?.value ?? geninfo.max_new_tokens ?? null; + if (maxNewTokens !== null && maxNewTokens !== undefined) mapped.max_new_tokens = maxNewTokens; + const device = geninfo.device?.value ?? geninfo.device ?? null; + if (device !== null && device !== undefined && String(device).trim()) mapped.device = String(device).trim(); + const voicePreset = geninfo.voice_preset?.value ?? geninfo.voice_preset ?? null; + if (voicePreset !== null && voicePreset !== undefined && String(voicePreset).trim()) mapped.voice_preset = String(voicePreset).trim(); + const instruct = geninfo.instruct?.value ?? geninfo.instruct ?? null; + if (instruct !== null && instruct !== undefined && String(instruct).trim()) mapped.instruct = String(instruct).trim(); + const dtype = geninfo.dtype?.value ?? geninfo.dtype ?? null; + if (dtype !== null && dtype !== undefined && String(dtype).trim()) mapped.dtype = String(dtype).trim(); + const attnImplementation = geninfo.attn_implementation?.value ?? geninfo.attn_implementation ?? null; + if (attnImplementation !== null && attnImplementation !== undefined && String(attnImplementation).trim()) + mapped.attn_implementation = String(attnImplementation).trim(); + const xVectorOnlyMode = geninfo.x_vector_only_mode?.value ?? geninfo.x_vector_only_mode ?? null; + if (xVectorOnlyMode !== null && xVectorOnlyMode !== undefined) mapped.x_vector_only_mode = xVectorOnlyMode; + const useTorchCompile = geninfo.use_torch_compile?.value ?? geninfo.use_torch_compile ?? null; + if (useTorchCompile !== null && useTorchCompile !== undefined) mapped.use_torch_compile = useTorchCompile; + const useCudaGraphs = geninfo.use_cuda_graphs?.value ?? geninfo.use_cuda_graphs ?? null; + if (useCudaGraphs !== null && useCudaGraphs !== undefined) mapped.use_cuda_graphs = useCudaGraphs; + const compileMode = geninfo.compile_mode?.value ?? geninfo.compile_mode ?? null; + if (compileMode !== null && compileMode !== undefined && String(compileMode).trim()) mapped.compile_mode = String(compileMode).trim(); + const enableChunking = geninfo.enable_chunking?.value ?? geninfo.enable_chunking ?? null; + if (enableChunking !== null && enableChunking !== undefined) mapped.enable_chunking = enableChunking; + const maxCharsPerChunk = geninfo.max_chars_per_chunk?.value ?? geninfo.max_chars_per_chunk ?? null; + if (maxCharsPerChunk !== null && maxCharsPerChunk !== undefined) mapped.max_chars_per_chunk = maxCharsPerChunk; + const chunkMethod = geninfo.chunk_combination_method?.value ?? geninfo.chunk_combination_method ?? null; + if (chunkMethod !== null && chunkMethod !== undefined && String(chunkMethod).trim()) + mapped.chunk_combination_method = String(chunkMethod).trim(); + const silenceBetweenChunks = geninfo.silence_between_chunks_ms?.value ?? geninfo.silence_between_chunks_ms ?? null; + if (silenceBetweenChunks !== null && silenceBetweenChunks !== undefined) + mapped.silence_between_chunks_ms = silenceBetweenChunks; + const enableAudioCache = geninfo.enable_audio_cache?.value ?? geninfo.enable_audio_cache ?? null; + if (enableAudioCache !== null && enableAudioCache !== undefined) mapped.enable_audio_cache = enableAudioCache; + const batchSize = geninfo.batch_size?.value ?? geninfo.batch_size ?? null; + if (batchSize !== null && batchSize !== undefined) mapped.batch_size = batchSize; const denoise = geninfo.denoise?.value ?? geninfo.denoise ?? null; if (denoise !== null && denoise !== undefined) mapped.denoise = denoise; const clipSkip = geninfo.clip_skip?.value ?? geninfo.clip_skip ?? geninfo.clipSkip ?? null; diff --git a/js/components/sidebar/sections/GenerationSection.js b/js/components/sidebar/sections/GenerationSection.js index ed21aa4d..1d467d0a 100644 --- a/js/components/sidebar/sections/GenerationSection.js +++ b/js/components/sidebar/sections/GenerationSection.js @@ -34,6 +34,10 @@ export function createGenerationSection(asset) { if (obj.models || obj.model || obj.checkpoint || obj.loras) return true; if (obj.sampler || obj.sampler_name || obj.steps || obj.cfg || obj.cfg_scale || obj.scheduler) return true; if (obj.seed || obj.width || obj.height || obj.denoise || obj.denoising || obj.clip_skip) return true; + if (obj.voice || obj.language || obj.temperature || obj.top_k || obj.top_p || obj.repetition_penalty || obj.max_new_tokens) return true; + if (obj.device || obj.voice_preset || obj.instruct || obj.dtype || obj.attn_implementation) return true; + if (obj.enable_chunking !== undefined || obj.max_chars_per_chunk || obj.chunk_combination_method || obj.silence_between_chunks_ms) return true; + if (obj.enable_audio_cache !== undefined || obj.batch_size !== undefined || obj.use_torch_compile !== undefined || obj.use_cuda_graphs !== undefined || obj.compile_mode) return true; if (typeof obj.lyrics === "string" && obj.lyrics.trim()) return true; return false; } catch { @@ -269,6 +273,64 @@ export function createGenerationSection(asset) { if (samplingData.length > 0) { container.appendChild(createParametersBox("Sampling", samplingData, "#FF9800", { emphasis: true })); } + + const isTTS = String(metadata?.engine?.type || "").toLowerCase() === "tts"; + const ttsData = []; + if (metadata.voice) ttsData.push({ label: "Narrator Voice", value: metadata.voice }); + if (metadata.language) ttsData.push({ label: "Language", value: metadata.language }); + if (metadata.top_k !== undefined && metadata.top_k !== null) ttsData.push({ label: "Top-k", value: metadata.top_k }); + if (metadata.top_p !== undefined && metadata.top_p !== null) ttsData.push({ label: "Top-p", value: metadata.top_p }); + if (metadata.temperature !== undefined && metadata.temperature !== null) ttsData.push({ label: "Temperature", value: metadata.temperature }); + if (metadata.repetition_penalty !== undefined && metadata.repetition_penalty !== null) + ttsData.push({ label: "Repetition Penalty", value: metadata.repetition_penalty }); + if (metadata.max_new_tokens !== undefined && metadata.max_new_tokens !== null) + ttsData.push({ label: "Max New Tokens", value: metadata.max_new_tokens }); + if (ttsData.length > 0 || isTTS) { + container.appendChild(createParametersBox("TTS", ttsData, "#26A69A", { emphasis: true })); + } + + const ttsEngineData = []; + if (metadata.device) ttsEngineData.push({ label: "Device", value: metadata.device }); + if (metadata.voice_preset) ttsEngineData.push({ label: "Voice Preset", value: metadata.voice_preset }); + if (metadata.dtype) ttsEngineData.push({ label: "Dtype", value: metadata.dtype }); + if (metadata.attn_implementation) ttsEngineData.push({ label: "Attention", value: metadata.attn_implementation }); + if (metadata.compile_mode) ttsEngineData.push({ label: "Compile Mode", value: metadata.compile_mode }); + if (metadata.use_torch_compile !== undefined && metadata.use_torch_compile !== null) + ttsEngineData.push({ label: "Torch Compile", value: metadata.use_torch_compile ? "on" : "off" }); + if (metadata.use_cuda_graphs !== undefined && metadata.use_cuda_graphs !== null) + ttsEngineData.push({ label: "CUDA Graphs", value: metadata.use_cuda_graphs ? "on" : "off" }); + if (metadata.x_vector_only_mode !== undefined && metadata.x_vector_only_mode !== null) + ttsEngineData.push({ label: "X-Vector Only", value: metadata.x_vector_only_mode ? "on" : "off" }); + if (ttsEngineData.length > 0) { + container.appendChild(createParametersBox("TTS Engine", ttsEngineData, "#00897B")); + } + + if (typeof metadata.instruct === "string" && metadata.instruct.trim()) { + container.appendChild( + createInfoBox("TTS Instruction", metadata.instruct, "#26A69A", { + showCopyButton: false, + copyOnContentClick: true, + }) + ); + } + + const ttsChunkingData = []; + if (metadata.enable_chunking !== undefined && metadata.enable_chunking !== null) + ttsChunkingData.push({ label: "Chunking", value: metadata.enable_chunking ? "on" : "off" }); + if (metadata.max_chars_per_chunk !== undefined && metadata.max_chars_per_chunk !== null) + ttsChunkingData.push({ label: "Max Chars/Chunk", value: metadata.max_chars_per_chunk }); + if (metadata.chunk_combination_method) + ttsChunkingData.push({ label: "Chunk Method", value: metadata.chunk_combination_method }); + if (metadata.silence_between_chunks_ms !== undefined && metadata.silence_between_chunks_ms !== null) + ttsChunkingData.push({ label: "Silence Between Chunks (ms)", value: metadata.silence_between_chunks_ms }); + if (metadata.enable_audio_cache !== undefined && metadata.enable_audio_cache !== null) + ttsChunkingData.push({ label: "Audio Cache", value: metadata.enable_audio_cache ? "on" : "off" }); + if (metadata.batch_size !== undefined && metadata.batch_size !== null) + ttsChunkingData.push({ label: "Batch Size", value: metadata.batch_size }); + if (ttsChunkingData.length > 0) { + container.appendChild(createParametersBox("TTS Runtime", ttsChunkingData, "#00796B")); + } + if (metadata.lyrics_strength !== undefined && metadata.lyrics_strength !== null) { container.appendChild(createParametersBox("Audio", [{ label: "Lyrics Strength", value: metadata.lyrics_strength }], "#00BCD4")); } diff --git a/js/features/status/StatusDot.js b/js/features/status/StatusDot.js index 6474a540..dec7690b 100644 --- a/js/features/status/StatusDot.js +++ b/js/features/status/StatusDot.js @@ -636,6 +636,42 @@ function setBodyCollapsed(section, collapsed) { indicator.textContent = "▾"; } } +function buildDbHealthLine(healthData, dbDiagData) { + const dbAvailable = Boolean(healthData?.database?.available); + const diagnostics = dbDiagData?.diagnostics || {}; + const malformed = Boolean(diagnostics?.malformed); + const locked = Boolean(diagnostics?.locked); + + if (!dbAvailable || malformed) { + return t("status.dbHealthError", "DB health: error"); + } + if (locked) { + return t("status.dbHealthLocked", "DB health: locked"); + } + return t("status.dbHealthOk", "DB health: ok"); +} + +function buildIndexHealthLine(counters, desiredScope) { + const totalAssets = Number(counters?.total_assets || 0); + const lastIndexEnd = counters?.last_index_end; + const scopeLabel = + desiredScope === "all" + ? t("scope.all", "Inputs + Outputs") + : desiredScope === "input" + ? t("scope.input", "Inputs") + : desiredScope === "custom" + ? t("scope.custom", "Custom") + : t("scope.output", "Outputs"); + + if (!Number.isFinite(totalAssets) || totalAssets <= 0) { + return `${t("status.indexHealthEmpty", "Index health: empty")} (${scopeLabel})`; + } + if (!lastIndexEnd) { + return `${t("status.indexHealthPartial", "Index health: partial")} (${scopeLabel})`; + } + return `${t("status.indexHealthOk", "Index health: ok")} (${scopeLabel})`; +} + export async function updateStatus(statusDot, statusText, capabilitiesSection = null, scanTarget = null, meta = null, options = {}) { const section = statusText?.closest?.("#mjr-status-body")?.parentElement || statusText?.closest?.("div"); const signal = options?.signal || null; @@ -658,7 +694,11 @@ export async function updateStatus(statusDot, statusText, capabilitiesSection = desiredScope === "custom" ? `${ENDPOINTS.HEALTH_COUNTERS}?scope=custom&custom_root_id=${encodeURIComponent(String(desiredCustomRootId || ""))}` : `${ENDPOINTS.HEALTH_COUNTERS}?scope=${encodeURIComponent(desiredScope || "output")}`; - const result = await get(url, signal ? { signal } : undefined); + const [result, healthResult, dbDiagResult] = await Promise.all([ + get(url, signal ? { signal } : undefined), + get(ENDPOINTS.HEALTH, signal ? { signal } : undefined), + get(ENDPOINTS.HEALTH_DB, signal ? { signal } : undefined), + ]); let toolStatusData = null; try { const toolsResult = await getToolsStatus(signal ? { signal } : undefined); @@ -690,6 +730,22 @@ export async function updateStatus(statusDot, statusText, capabilitiesSection = const dbSizeLine = t("status.dbSize", "Database size: {size}", { size: formatBytes(counters.db_size_bytes || 0), }); + const dbHealthLine = buildDbHealthLine(healthResult?.data || null, dbDiagResult?.data || null); + const indexHealthLine = buildIndexHealthLine(counters, desiredScope); + const dbAvailable = Boolean(healthResult?.ok && healthResult?.data?.database?.available); + const dbDiagnostics = dbDiagResult?.ok ? (dbDiagResult?.data?.diagnostics || {}) : {}; + const dbMalformed = Boolean(dbDiagnostics?.malformed); + const dbLocked = Boolean(dbDiagnostics?.locked); + const hasIndexedAssets = Number(totalAssets) > 0; + const hasIndexTimestamp = Boolean(counters?.last_index_end); + const indexHealthy = hasIndexedAssets && hasIndexTimestamp; + + let healthTone = "success"; + if (!dbAvailable || dbMalformed) { + healthTone = "error"; + } else if (dbLocked || !indexHealthy) { + healthTone = "warning"; + } let watcherInfo = counters.watcher; try { if (!watcherInfo || typeof watcherInfo.enabled !== "boolean") { @@ -717,25 +773,38 @@ export async function updateStatus(statusDot, statusText, capabilitiesSection = ? t("scope.custom", "Custom") : t("scope.output", "Outputs"); + if (healthTone === "error") { + statusDot.style.background = "var(--mjr-status-error, #f44336)"; + applyStatusHighlight(section, "error"); + setStatusWithHint( + statusText, + t("status.dbCorrupted", "Database appears corrupted or unavailable."), + [dbHealthLine, indexHealthLine, dbSizeLine, watcherLine].filter(Boolean).join(" | ") + ); + return counters; + } + if (totalAssets === 0) { - // No assets indexed - yellow - statusDot.style.background = "var(--mjr-status-warning, #FFA726)"; - applyStatusHighlight(section, "warning"); + statusDot.style.background = + healthTone === "warning" ? "var(--mjr-status-warning, #FFA726)" : "var(--mjr-status-success, #4CAF50)"; + applyStatusHighlight(section, healthTone); setStatusWithHint( statusText, t("status.noAssets", `No assets indexed yet (${scopeLabel})`, { scope: scopeLabel }), - [t("status.clickToScan", "Click the dot to start a scan"), dbSizeLine, watcherLine].filter(Boolean).join(" | ") + [t("status.clickToScan", "Click the dot to start a scan"), dbHealthLine, indexHealthLine, dbSizeLine, watcherLine].filter(Boolean).join(" | ") ); } else { - // Assets indexed - green - statusDot.style.background = "var(--mjr-status-success, #4CAF50)"; - applyStatusHighlight(section, "success"); + statusDot.style.background = + healthTone === "warning" ? "var(--mjr-status-warning, #FFA726)" : "var(--mjr-status-success, #4CAF50)"; + applyStatusHighlight(section, healthTone); setStatusLines( statusText, [ t("status.assetsIndexed", `${totalAssets.toLocaleString()} assets indexed (${scopeLabel})`, { count: totalAssets.toLocaleString(), scope: scopeLabel }), t("status.imagesVideos", `Images: ${counters.images || 0} - Videos: ${counters.videos || 0}`, { images: counters.images || 0, videos: counters.videos || 0 }), t("status.withWorkflows", `With workflows: ${withWorkflows} - Generation data: ${withGenerationData}`, { workflows: withWorkflows, gendata: withGenerationData }), + dbHealthLine, + indexHealthLine, dbSizeLine, watcherLine, ], diff --git a/tests/core/test_mvp.py b/tests/core/test_mvp.py index c65c016c..30f4b1d0 100644 --- a/tests/core/test_mvp.py +++ b/tests/core/test_mvp.py @@ -1,14 +1,10 @@ -import pytest -""" -MVP test to validate architecture. -Tests: Result pattern, logging with emojis, ExifTool, FFProbe, SQLite. -""" +"""MVP tests focused on Result pattern, adapters and SQLite behavior.""" +import json import sys from pathlib import Path +from unittest.mock import MagicMock -# Add project to path -project_root = Path(__file__).parent -sys.path.insert(0, str(project_root)) +import pytest from shared import Result, get_logger, log_success, ErrorCode from backend.adapters.tools import ExifTool, FFProbe @@ -84,56 +80,55 @@ async def test_result_pattern(): log_success(logger, "Result pattern works!") @pytest.mark.asyncio -async def test_exiftool(): - """Test ExifTool adapter.""" - logger.info("Testing ExifTool adapter...") +async def test_exiftool_with_mocks(tmp_path, monkeypatch): + """ExifTool adapter test with mocked OS/process responses.""" + logger.info("Testing ExifTool adapter with mocks...") + + test_file = tmp_path / "sample.png" + test_file.write_bytes(b"\x89PNG\r\n\x1a\n") + + monkeypatch.setattr("backend.adapters.tools.exiftool.shutil.which", lambda _: "/usr/bin/exiftool") + + fake_payload = [{"SourceFile": str(test_file), "XMP-xmp:Rating": 5, "XMP-dc:Subject": ["tag1"]}] + fake_proc = MagicMock( + returncode=0, + stdout=json.dumps(fake_payload), + stderr="", + ) + monkeypatch.setattr("backend.adapters.tools.exiftool.subprocess.run", lambda *args, **kwargs: fake_proc) exiftool = ExifTool() - - if not exiftool.is_available(): - logger.warning("ExifTool not available, skipping test") - return - - # Test with sample PNG file - test_file = Path("C:/Users/ewald/Pictures/ComfyUI_TOTEST/ComfyUI/custom_nodes/parser/00095-1926696019.png") - if not test_file.exists(): - logger.warning(f"Test file not found: {test_file}") - return + assert exiftool.is_available() result = exiftool.read(str(test_file)) - - if result.ok: - logger.info(f"ExifTool found {len(result.data)} metadata fields") - logger.info(f"Sample fields: {list(result.data.keys())[:5]}") - log_success(logger, "ExifTool works!") - else: - logger.error(f"ExifTool failed: {result.error}") + assert result.ok + assert result.data["XMP-xmp:Rating"] == 5 + assert "XMP-dc:Subject" in result.data @pytest.mark.asyncio -async def test_ffprobe(): - """Test FFProbe adapter.""" - logger.info("Testing FFProbe adapter...") +async def test_ffprobe_with_mocks(tmp_path, monkeypatch): + """FFProbe adapter test with mocked OS/process responses.""" + logger.info("Testing FFProbe adapter with mocks...") + + test_file = tmp_path / "sample.mp4" + test_file.write_bytes(b"\x00\x00\x00\x18ftypmp42") + + monkeypatch.setattr("backend.adapters.tools.ffprobe.shutil.which", lambda _: "/usr/bin/ffprobe") + + ffprobe_json = { + "format": {"format_name": "mov,mp4,m4a,3gp,3g2,mj2", "duration": "2.5"}, + "streams": [{"codec_type": "video", "codec_name": "h264", "width": 1280, "height": 720}], + } + fake_proc = MagicMock(returncode=0, stdout=json.dumps(ffprobe_json), stderr="") + monkeypatch.setattr("backend.adapters.tools.ffprobe.subprocess.run", lambda *args, **kwargs: fake_proc) ffprobe = FFProbe() - - if not ffprobe.is_available(): - logger.warning("ffprobe not available, skipping test") - return - - # Test with sample MP4 file - test_file = Path("C:/Users/ewald/Pictures/ComfyUI_TOTEST/ComfyUI/custom_nodes/parser/ComfyUI_00002_.mp4") - if not test_file.exists(): - logger.warning(f"Test file not found: {test_file}") - return + assert ffprobe.is_available() result = ffprobe.read(str(test_file)) - - if result.ok: - logger.info(f"ffprobe format: {result.data.get('format', {}).get('format_name')}") - logger.info(f"Video stream: {result.data.get('video_stream', {}).get('codec_name')}") - log_success(logger, "FFProbe works!") - else: - logger.error(f"ffprobe failed: {result.error}") + assert result.ok + assert result.data["video_stream"]["codec_name"] == "h264" + assert result.data["format"]["duration"] == "2.5" @pytest.mark.asyncio async def test_sqlite(tmp_path): @@ -150,11 +145,7 @@ async def main(): logger.info("=" * 60) try: - test_result_pattern() - print() - await test_exiftool() - print() - await test_ffprobe() + await test_result_pattern() print() # For manual runs, keep artifacts out of the repo root. import tempfile diff --git a/tests/features/test_geninfo_integration.py b/tests/features/test_geninfo_integration.py index f5603520..03e7b614 100644 --- a/tests/features/test_geninfo_integration.py +++ b/tests/features/test_geninfo_integration.py @@ -100,3 +100,56 @@ def test_wan_video_integration_payload(): ckpt = data["models"].get("checkpoint") or data["models"].get("diffusion") assert ckpt["name"] == "Wan2.1-T2V-14B" assert data["steps"]["value"] == 30 + + +def test_tts_audio_integration_payload(): + """ + Simulate a TTS audio workflow (no diffusion sampler) and verify fallback geninfo. + """ + graph = { + "1": { + "class_type": "UnifiedTTSTextNode", + "inputs": { + "text": "Bonjour tout le monde", + "seed": 616267440, + "narrator_voice": "none", + "TTS_engine": ["4", 0], + }, + }, + "2": {"class_type": "SaveAudioMP3", "inputs": {"audio": ["1", 0]}}, + "4": { + "class_type": "Qwen3TTSEngineNode", + "inputs": { + "model_size": "1.7B", + "device": "auto", + "voice_preset": "None (Zero-shot / Custom)", + "language": "French", + "temperature": 0.7, + "top_p": 0.9, + "top_k": 50, + "repetition_penalty": 1.1, + "max_new_tokens": 2048, + "dtype": "auto", + "attn_implementation": "auto", + "use_torch_compile": False, + "use_cuda_graphs": False, + "compile_mode": "default", + }, + }, + } + + result = parse_geninfo_from_prompt(graph) + assert result.ok + data = result.data + assert data["engine"]["type"] == "tts" + assert data["positive"]["value"] == "Bonjour tout le monde" + assert data["seed"]["value"] == 616267440 + assert data["models"]["checkpoint"]["name"] == "1.7B" + assert data["language"]["value"] == "French" + assert data["device"]["value"] == "auto" + assert data["voice_preset"]["value"] == "None (Zero-shot / Custom)" + assert data["top_k"]["value"] == 50 + assert data["top_p"]["value"] == 0.9 + assert data["temperature"]["value"] == 0.7 + assert data["repetition_penalty"]["value"] == 1.1 + assert data["max_new_tokens"]["value"] == 2048 diff --git a/tests/metadata/test_audio_metadata_extraction.py b/tests/metadata/test_audio_metadata_extraction.py index befa12e6..acfe4747 100644 --- a/tests/metadata/test_audio_metadata_extraction.py +++ b/tests/metadata/test_audio_metadata_extraction.py @@ -37,3 +37,45 @@ async def test_audio_extractor_reads_generation_json_from_tags(tmp_path) -> None assert isinstance(res.data.get("prompt"), dict) assert res.data.get("audio_codec") == "aac" assert str(res.data.get("duration")) == "3.2" + + +@pytest.mark.asyncio +async def test_audio_extractor_parses_wrapped_comment_payload_for_mp3_tts(tmp_path) -> None: + audio = tmp_path / "tts_track.mp3" + audio.write_bytes(b"") + + workflow = {"nodes": [{"id": 1, "type": "SaveAudio"}], "links": []} + prompt = { + "1": {"class_type": "TTSLoader", "inputs": {}}, + "2": {"class_type": "SaveAudio", "inputs": {"audio": ["1", 0]}}, + } + wrapped = {"workflow": workflow, "prompt": json.dumps(prompt)} + exif = {"ID3:Comment": json.dumps(wrapped)} + ffprobe = {"audio_stream": {"codec_name": "mp3"}} + + res = extract_audio_metadata(str(audio), exif_data=exif, ffprobe_data=ffprobe) + assert res.ok + assert isinstance(res.data.get("workflow"), dict) + assert isinstance(res.data.get("prompt"), dict) + assert res.data.get("quality") == "full" + + +@pytest.mark.asyncio +async def test_audio_extractor_reads_prompt_from_ffprobe_stream_tags(tmp_path) -> None: + audio = tmp_path / "tts_stream_tags.mp3" + audio.write_bytes(b"") + + prompt = { + "10": {"class_type": "CLIPTextEncode", "inputs": {"text": "hello voice"}}, + "11": {"class_type": "SaveAudio", "inputs": {"audio": ["10", 0]}}, + } + ffprobe = { + "audio_stream": {"codec_name": "mp3"}, + "streams": [{"codec_type": "audio", "tags": {"Prompt": json.dumps(prompt)}}], + "format": {"duration": "1.2"}, + } + + res = extract_audio_metadata(str(audio), exif_data={}, ffprobe_data=ffprobe) + assert res.ok + assert isinstance(res.data.get("prompt"), dict) + assert res.data["prompt"]["10"]["class_type"] == "CLIPTextEncode"