diff --git a/config.example.yaml b/config.example.yaml index dfa560e..ad581db 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -23,7 +23,16 @@ tts: # ==================== Image Generation Configuration ==================== image: - default: image_default.json + # Required: Default workflow to use (no fallback) + # Options: runninghub/image_default.json (recommended, no local setup) + # selfhost/image_default.json (requires local ComfyUI) + default_workflow: runninghub/image_default.json + + # Local ComfyUI configuration (required if using selfhost workflows) comfyui_url: http://127.0.0.1:8188 + + # RunningHub cloud configuration (required if using runninghub workflows) runninghub_api_key: "" + + # Image prompt prefix (optional) prompt_prefix: "Pure white background, minimalist illustration, matchstick figure style, black and white line drawing, simple clean lines" diff --git a/reelforge/config/manager.py b/reelforge/config/manager.py index 48528e9..ccb1928 100644 --- a/reelforge/config/manager.py +++ b/reelforge/config/manager.py @@ -96,7 +96,7 @@ class ConfigManager: def get_image_config(self) -> dict: """Get image configuration as dict""" return { - "default": self.config.image.default, + "default_workflow": self.config.image.default_workflow, "comfyui_url": self.config.image.comfyui_url, "runninghub_api_key": self.config.image.runninghub_api_key, "prompt_prefix": self.config.image.prompt_prefix, diff --git a/reelforge/config/schema.py b/reelforge/config/schema.py index 537570f..bedeceb 100644 --- a/reelforge/config/schema.py +++ b/reelforge/config/schema.py @@ -15,12 +15,16 @@ class LLMConfig(BaseModel): class TTSConfig(BaseModel): """TTS configuration""" - default: str = Field(default="edge", description="Default TTS workflow") + model_config = {"populate_by_name": True} # Allow both field name and alias + + default_workflow: str = Field(default="edge", description="Default TTS workflow", alias="default") class ImageConfig(BaseModel): """Image generation configuration""" - default: str = Field(default="image_default.json", description="Default image workflow") + model_config = {"populate_by_name": True} # Allow both field name and alias + + default_workflow: str = Field(default=None, description="Default image workflow (required, no fallback)", alias="default") comfyui_url: str = Field(default="http://127.0.0.1:8188", description="ComfyUI Server URL") runninghub_api_key: str = Field(default="", description="RunningHub API Key (optional)") prompt_prefix: str = Field( diff --git a/reelforge/services/comfy_base_service.py b/reelforge/services/comfy_base_service.py index 7fa3aa8..cbdf58e 100644 --- a/reelforge/services/comfy_base_service.py +++ b/reelforge/services/comfy_base_service.py @@ -2,6 +2,7 @@ ComfyUI Base Service - Common logic for ComfyUI-based services """ +import json import os from pathlib import Path from typing import Optional, List, Dict, Any @@ -38,13 +39,29 @@ class ComfyBaseService: self.service_name = service_name self._workflows_cache: Optional[List[str]] = None - def _scan_workflows(self) -> List[str]: + def _scan_workflows(self) -> List[Dict[str, Any]]: """ - Scan workflows/{prefix}*.json files + Scan workflows/source/*.json files from all source directories Returns: - List of workflow filenames - Example: ["image_default.json", "image_flux.json"] + List of workflow info dicts + Example: [ + { + "name": "image_default.json", + "display_name": "image_default.json - Selfhost", + "source": "selfhost", + "path": "workflows/selfhost/image_default.json", + "key": "selfhost/image_default.json" + }, + { + "name": "image_default.json", + "display_name": "image_default.json - Runninghub", + "source": "runninghub", + "path": "workflows/runninghub/image_default.json", + "key": "runninghub/image_default.json", + "workflow_id": "123456" + } + ] """ workflows = [] workflows_dir = Path(self.WORKFLOWS_DIR) @@ -53,67 +70,130 @@ class ComfyBaseService: logger.warning(f"Workflows directory not found: {workflows_dir}") return workflows - # Scan for {prefix}_*.json files - for file in workflows_dir.glob(f"{self.WORKFLOW_PREFIX}*.json"): - workflows.append(file.name) - logger.debug(f"Found {self.service_name} workflow: {file.name}") + # Scan subdirectories (selfhost, runninghub, etc.) + for source_dir in workflows_dir.iterdir(): + if not source_dir.is_dir(): + logger.debug(f"Skipping non-directory: {source_dir}") + continue + + source_name = source_dir.name + + # Scan workflow files in this source directory + for file_path in source_dir.glob(f"{self.WORKFLOW_PREFIX}*.json"): + try: + workflow_info = self._parse_workflow_file(file_path, source_name) + workflows.append(workflow_info) + logger.debug(f"Found workflow: {workflow_info['key']}") + except Exception as e: + logger.error(f"Failed to parse workflow {file_path}: {e}") - return sorted(workflows) + # Sort by key (source/name) + return sorted(workflows, key=lambda w: w["key"]) + + def _parse_workflow_file(self, file_path: Path, source: str) -> Dict[str, Any]: + """ + Parse workflow file and extract metadata + + Args: + file_path: Path to workflow JSON file + source: Source directory name (e.g., "selfhost", "runninghub") + + Returns: + Workflow info dict with structure: + { + "name": "image_default.json", + "display_name": "image_default.json - Runninghub", + "source": "runninghub", + "path": "workflows/runninghub/image_default.json", + "key": "runninghub/image_default.json", + "workflow_id": "123456" # Only for RunningHub + } + """ + with open(file_path, 'r', encoding='utf-8') as f: + content = json.load(f) + + # Build base info + workflow_info = { + "name": file_path.name, + "display_name": f"{file_path.name} - {source.title()}", + "source": source, + "path": str(file_path), + "key": f"{source}/{file_path.name}" + } + + # Check if it's a wrapper format (RunningHub, etc.) + if "source" in content: + # Wrapper format: {"source": "runninghub", "workflow_id": "xxx", ...} + if "workflow_id" in content: + workflow_info["workflow_id"] = content["workflow_id"] + if "metadata" in content: + workflow_info["metadata"] = content["metadata"] + + return workflow_info def _get_default_workflow(self) -> str: """ - Get default workflow name from config or use DEFAULT_WORKFLOW + Get default workflow from config (required, no fallback) Returns: - Default workflow filename + Default workflow key (e.g., "runninghub/image_default.json") + + Raises: + ValueError: If default_workflow not configured """ - return self.config.get("default_workflow", self.DEFAULT_WORKFLOW) + default_workflow = self.config.get("default_workflow") + + if not default_workflow: + raise ValueError( + f"No default workflow configured for {self.service_name}. " + f"Please set 'default_workflow' in config.yaml under '{self.service_name}' section. " + f"Available workflows: {', '.join(self.available)}" + ) + + return default_workflow - def _resolve_workflow(self, workflow: Optional[str] = None) -> str: + def _resolve_workflow(self, workflow: Optional[str] = None) -> Dict[str, Any]: """ - Resolve workflow to actual workflow path + Resolve workflow key to workflow info Args: - workflow: Workflow filename (e.g., "image_default.json") - Can also be: - - Absolute path: "/path/to/workflow.json" - - Relative path: "custom/workflow.json" - - URL: "http://..." - - RunningHub ID: "12345" + workflow: Workflow key (e.g., "runninghub/image_default.json") + If None, uses default from config Returns: - Workflow file path or identifier + Workflow info dict with structure: + { + "name": "image_default.json", + "display_name": "image_default.json - Runninghub", + "source": "runninghub", + "path": "workflows/runninghub/image_default.json", + "key": "runninghub/image_default.json", + "workflow_id": "123456" # Only for RunningHub + } Raises: ValueError: If workflow not found """ - # 1. If not specified, use default + # 1. If not specified, use default from config if workflow is None: workflow = self._get_default_workflow() - # 2. If it's an absolute path, URL, or looks like RunningHub ID, use as-is - if (workflow.startswith("/") or - workflow.startswith("http://") or - workflow.startswith("https://") or - workflow.isdigit()): - logger.debug(f"Using workflow identifier: {workflow}") - return workflow + # 2. Scan available workflows + available_workflows = self._scan_workflows() - # 3. If it's just a filename, look in workflows/ directory - workflow_path = Path(self.WORKFLOWS_DIR) / workflow + # 3. Find matching workflow by key + for wf_info in available_workflows: + if wf_info["key"] == workflow: + logger.info(f"🎬 Using {self.service_name} workflow: {workflow}") + return wf_info - if not workflow_path.exists(): - # List available workflows for error message - available = self._scan_workflows() - available_str = ", ".join(available) if available else "none" - raise ValueError( - f"Workflow '{workflow}' not found at {workflow_path}. " - f"Available workflows: {available_str}\n" - f"Please create: {workflow_path}" - ) - - logger.info(f"🎬 Using {self.service_name} workflow: {workflow}") - return str(workflow_path) + # 4. Not found - generate error message + available_keys = [wf["key"] for wf in available_workflows] + available_str = ", ".join(available_keys) if available_keys else "none" + raise ValueError( + f"Workflow '{workflow}' not found. " + f"Available workflows: {available_str}" + ) def _prepare_comfykit_config( self, @@ -153,31 +233,42 @@ class ComfyBaseService: logger.debug(f"ComfyKit config: {kit_config}") return kit_config - def list_workflows(self) -> List[str]: + def list_workflows(self) -> List[Dict[str, Any]]: """ - List all available workflows + List all available workflows with full metadata Returns: - List of workflow filenames (sorted alphabetically) + List of workflow info dicts (sorted by key) Example: workflows = service.list_workflows() - # ['image_default.json', 'image_flux.json'] + # [ + # { + # "name": "image_default.json", + # "display_name": "image_default.json - Runninghub", + # "source": "runninghub", + # "path": "workflows/runninghub/image_default.json", + # "key": "runninghub/image_default.json", + # "workflow_id": "123456" + # }, + # ... + # ] """ return self._scan_workflows() @property def available(self) -> List[str]: """ - List available workflows + List available workflow keys Returns: - List of available workflow filenames + List of available workflow keys (e.g., ["runninghub/image_default.json", ...]) Example: print(f"Available workflows: {service.available}") """ - return self.list_workflows() + workflows = self.list_workflows() + return [wf["key"] for wf in workflows] def __repr__(self) -> str: """String representation""" diff --git a/reelforge/services/image.py b/reelforge/services/image.py index 41cc2aa..086f6c4 100644 --- a/reelforge/services/image.py +++ b/reelforge/services/image.py @@ -31,7 +31,7 @@ class ImageService(ComfyBaseService): """ WORKFLOW_PREFIX = "image_" - DEFAULT_WORKFLOW = "image_default.json" + DEFAULT_WORKFLOW = None # No hardcoded default, must be configured WORKFLOWS_DIR = "workflows" def __init__(self, config: dict): @@ -112,10 +112,10 @@ class ImageService(ComfyBaseService): comfyui_url="http://192.168.1.100:8188" ) """ - # 1. Resolve workflow path - workflow_path = self._resolve_workflow(workflow=workflow) + # 1. Resolve workflow (returns structured info) + workflow_info = self._resolve_workflow(workflow=workflow) - # 2. Prepare ComfyKit config + # 2. Prepare ComfyKit config (supports both selfhost and runninghub) kit_config = self._prepare_comfykit_config( comfyui_url=comfyui_url, runninghub_api_key=runninghub_api_key @@ -145,12 +145,21 @@ class ImageService(ComfyBaseService): logger.debug(f"Workflow parameters: {workflow_params}") - # 4. Execute workflow + # 4. Execute workflow (ComfyKit auto-detects based on input type) try: kit = ComfyKit(**kit_config) - logger.info(f"Executing workflow: {workflow_path}") - result = await kit.execute(workflow_path, workflow_params) + # Determine what to pass to ComfyKit based on source + if workflow_info["source"] == "runninghub" and "workflow_id" in workflow_info: + # RunningHub: pass workflow_id (ComfyKit will use runninghub backend) + workflow_input = workflow_info["workflow_id"] + logger.info(f"Executing RunningHub workflow: {workflow_input}") + else: + # Selfhost: pass file path (ComfyKit will use local ComfyUI) + workflow_input = workflow_info["path"] + logger.info(f"Executing selfhost workflow: {workflow_input}") + + result = await kit.execute(workflow_input, workflow_params) # 5. Handle result if result.status != "completed": diff --git a/reelforge/services/tts_service.py b/reelforge/services/tts_service.py index 5f39c25..6cf42a0 100644 --- a/reelforge/services/tts_service.py +++ b/reelforge/services/tts_service.py @@ -139,11 +139,8 @@ class TTSService(ComfyBaseService): workflow="/path/to/custom_tts.json" ) """ - # 1. Resolve workflow path or provider - workflow_or_provider = self._resolve_workflow(workflow=workflow) - - # 2. Determine execution path - if workflow_or_provider in self.BUILTIN_PROVIDERS: + # 1. Check if it's a builtin provider (edge-tts) + if workflow in self.BUILTIN_PROVIDERS or workflow is None and self._get_default_workflow() in self.BUILTIN_PROVIDERS: # Use edge-tts return await self._call_edge_tts( text=text, @@ -154,20 +151,22 @@ class TTSService(ComfyBaseService): output_path=output_path, **params ) - else: - # Use ComfyUI workflow - return await self._call_comfyui_workflow( - workflow_path=workflow_or_provider, - text=text, - comfyui_url=comfyui_url, - runninghub_api_key=runninghub_api_key, - voice=voice, - rate=rate, - volume=volume, - pitch=pitch, - output_path=output_path, - **params - ) + + # 2. Use ComfyUI workflow - resolve to structured info + workflow_info = self._resolve_workflow(workflow=workflow) + + return await self._call_comfyui_workflow( + workflow_info=workflow_info, + text=text, + comfyui_url=comfyui_url, + runninghub_api_key=runninghub_api_key, + voice=voice, + rate=rate, + volume=volume, + pitch=pitch, + output_path=output_path, + **params + ) async def _call_edge_tts( self, @@ -227,7 +226,7 @@ class TTSService(ComfyBaseService): async def _call_comfyui_workflow( self, - workflow_path: str, + workflow_info: dict, text: str, comfyui_url: Optional[str] = None, runninghub_api_key: Optional[str] = None, @@ -242,7 +241,7 @@ class TTSService(ComfyBaseService): Generate speech using ComfyUI workflow Args: - workflow_path: Path to workflow file + workflow_info: Workflow info dict from _resolve_workflow() text: Text to convert to speech comfyui_url: ComfyUI URL runninghub_api_key: RunningHub API key @@ -256,9 +255,9 @@ class TTSService(ComfyBaseService): Returns: Generated audio file path (local if output_path provided, otherwise URL) """ - logger.info(f"🎙️ Using ComfyUI workflow: {workflow_path}") + logger.info(f"🎙️ Using workflow: {workflow_info['key']}") - # 1. Prepare ComfyKit config + # 1. Prepare ComfyKit config (supports both selfhost and runninghub) kit_config = self._prepare_comfykit_config( comfyui_url=comfyui_url, runninghub_api_key=runninghub_api_key @@ -282,12 +281,21 @@ class TTSService(ComfyBaseService): logger.debug(f"Workflow parameters: {workflow_params}") - # 3. Execute workflow + # 3. Execute workflow (ComfyKit auto-detects based on input type) try: kit = ComfyKit(**kit_config) - logger.info(f"Executing TTS workflow: {workflow_path}") - result = await kit.execute(workflow_path, workflow_params) + # Determine what to pass to ComfyKit based on source + if workflow_info["source"] == "runninghub" and "workflow_id" in workflow_info: + # RunningHub: pass workflow_id + workflow_input = workflow_info["workflow_id"] + logger.info(f"Executing RunningHub TTS workflow: {workflow_input}") + else: + # Selfhost: pass file path + workflow_input = workflow_info["path"] + logger.info(f"Executing selfhost TTS workflow: {workflow_input}") + + result = await kit.execute(workflow_input, workflow_params) # 4. Handle result if result.status != "completed": diff --git a/web/app.py b/web/app.py index 4f2c318..24db05b 100644 --- a/web/app.py +++ b/web/app.py @@ -13,7 +13,7 @@ import streamlit as st from loguru import logger # Import i18n and config manager -from reelforge.i18n import load_locales, set_language, tr, get_available_languages +from web.i18n import load_locales, set_language, tr, get_available_languages from reelforge.config import config_manager from reelforge.models.progress import ProgressEvent @@ -63,38 +63,6 @@ def init_i18n(): set_language(st.session_state.language) -# ============================================================================ -# Preview Cache Functions -# ============================================================================ - -def generate_style_preview_cached(prompt_prefix: str): - """ - Generate and cache visual style preview - - Args: - prompt_prefix: Prompt prefix to test - - Returns: - Generated image path - """ - from reelforge.utils.prompt_helper import build_image_prompt - - reelforge = get_reelforge() - - # Build final prompt with prefix - test_prompt = "A peaceful mountain landscape" - final_prompt = build_image_prompt(test_prompt, prompt_prefix) - - # Generate preview image (small size for speed) - preview_image_path = run_async(reelforge.image( - prompt=final_prompt, - width=512, - height=512 - )) - - return preview_image_path - - # ============================================================================ # Initialize ReelForge # ============================================================================ @@ -512,25 +480,39 @@ def main(): st.caption(tr("style.workflow")) st.caption(tr("style.workflow_help")) - # Dynamically scan workflows folder for image_*.json files - workflows_folder = Path("workflows") - workflow_files = [] - if workflows_folder.exists(): - workflow_files = sorted([f.name for f in workflows_folder.glob("image_*.json")]) + # Get available workflows from reelforge (with source info) + workflows = reelforge.image.list_workflows() - # Default to "image_default.json" if exists, otherwise first option + # Build options for selectbox + # Display: "image_default.json - Runninghub" + # Value: "runninghub/image_default.json" + workflow_options = [wf["display_name"] for wf in workflows] + workflow_keys = [wf["key"] for wf in workflows] + + # Default to first option (should be runninghub by sorting) default_workflow_index = 0 - if "image_default.json" in workflow_files: - default_workflow_index = workflow_files.index("image_default.json") - workflow_filename = st.selectbox( + # If user has a saved preference in config, try to match it + image_config = config_manager.get_image_config() + saved_workflow = image_config.get("default_workflow") + if saved_workflow and saved_workflow in workflow_keys: + default_workflow_index = workflow_keys.index(saved_workflow) + + workflow_display = st.selectbox( "Workflow", - workflow_files if workflow_files else ["image_default.json"], + workflow_options if workflow_options else ["No workflows found"], index=default_workflow_index, label_visibility="collapsed", key="image_workflow_select" ) + # Get the actual workflow key (e.g., "runninghub/image_default.json") + if workflow_options: + workflow_selected_index = workflow_options.index(workflow_display) + workflow_key = workflow_keys[workflow_selected_index] + else: + workflow_key = "runninghub/image_default.json" # fallback + # 2. Prompt prefix input st.caption(tr("style.prompt_prefix")) @@ -549,38 +531,59 @@ def main(): help=tr("style.prompt_prefix_help") ) - # Visual style preview button - if st.button(tr("style.preview"), key="preview_style", use_container_width=True): - with st.spinner(tr("style.previewing")): - try: - # Generate preview using cached function - preview_image_path = generate_style_preview_cached(prompt_prefix) - - # Display preview (support both URL and local path) - if preview_image_path: - # Read and encode image - if preview_image_path.startswith('http'): - # URL - use directly - img_html = f'