From 9201bc01101ab2b6cfa16a12cd66d7d81b891c8f Mon Sep 17 00:00:00 2001 From: puke <1129090915@qq.com> Date: Tue, 4 Nov 2025 23:55:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81Docker=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E5=8C=96=E9=83=A8=E7=BD=B2;=20=E5=88=86=E7=A6=BB=E9=A2=84?= =?UTF-8?q?=E8=AE=BE=E8=B5=84=E6=BA=90=E5=92=8C=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E8=B5=84=E6=BA=90;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 69 ++++++++ Dockerfile | 54 ++++++ config.example.yaml | 1 + docker-compose.yml | 83 +++++++++ docker-start.sh | 68 ++++++++ pixelle_video/services/comfy_base_service.py | 38 +++-- pixelle_video/services/video.py | 43 +++-- pixelle_video/utils/os_util.py | 167 +++++++++++++++++++ pixelle_video/utils/template_util.py | 103 ++++++------ web/app.py | 33 ++-- 10 files changed, 569 insertions(+), 90 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 docker-start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ce5a180 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,69 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Git +.git/ +.gitignore +.gitattributes + +# Documentation +docs/ +*.md +!README.md + +# Plans and development files +plans/ +repositories/ +examples/ + +# Test files +test_*.py +tests/ +*.log + +# Output and temporary files +output/* +!output/.gitkeep +temp/ +*.tmp + +# User data (will be mounted) +data/users/* +!data/.gitkeep + +# Config (will be mounted) +config.yaml +config.yaml.bak +config.example.yaml + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Misc +*.bak +restart_web.sh +start_web.sh + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..785b1c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# Pixelle-Video Docker Image +# Based on Python 3.11 slim for smaller image size + +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +# - curl: for health checks and downloads +# - ffmpeg: for video/audio processing +# - chromium: for html2image rendering +# - fonts-noto-cjk: for CJK character support +RUN apt-get update && apt-get install -y \ + curl \ + ffmpeg \ + chromium \ + chromium-driver \ + fonts-noto-cjk \ + && rm -rf /var/lib/apt/lists/* + +# Install uv package manager +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.cargo/bin:$PATH" + +# Copy dependency files first for better layer caching +COPY pyproject.toml uv.lock ./ + +# Install Python dependencies using uv +RUN uv sync --frozen --no-dev + +# Copy application code +COPY pixelle_video ./pixelle_video +COPY api ./api +COPY web ./web +COPY bgm ./bgm +COPY templates ./templates +COPY workflows ./workflows + +# Create output and data directories +RUN mkdir -p /app/output /app/data + +# Set environment variables for html2image to use chromium +ENV BROWSER_EXECUTABLE_PATH=/usr/bin/chromium +ENV CHROME_BIN=/usr/bin/chromium + +# Expose ports +# 8000: API service +# 8501: Web UI service +EXPOSE 8000 8501 + +# Default command (can be overridden in docker-compose) +CMD ["uv", "run", "python", "api/app.py"] + diff --git a/config.example.yaml b/config.example.yaml index abdf013..1d12c89 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -21,6 +21,7 @@ llm: comfyui: # Global ComfyUI settings comfyui_url: http://127.0.0.1:8188 # ComfyUI server URL (required for selfhost workflows) + # Note for Docker users: Use host.docker.internal:8188 (Mac/Windows) or host IP address (Linux) runninghub_api_key: "" # RunningHub API key (required for runninghub workflows) # TTS-specific configuration diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..77e470a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +version: '3.8' + +services: + # API Service - FastAPI backend + api: + build: + context: . + dockerfile: Dockerfile + container_name: pixelle-video-api + command: uv run python api/app.py --host 0.0.0.0 --port 8000 + ports: + - "8000:8000" + volumes: + # Mount config file (required) + - ./config.yaml:/app/config.yaml:ro + # Mount data directories for persistence + # data/ contains: users/, bgm/, templates/, workflows/ (custom resources) + - ./data:/app/data + - ./output:/app/output + # Note: Default resources (bgm/, templates/, workflows/) are baked into the image + # Custom resources in data/* will override defaults + environment: + - TZ=Asia/Shanghai + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - pixelle-network + + # Web UI Service - Streamlit frontend + web: + build: + context: . + dockerfile: Dockerfile + container_name: pixelle-video-web + command: uv run streamlit run web/app.py --server.port 8501 --server.address 0.0.0.0 + ports: + - "8501:8501" + volumes: + # Mount config file (required) + - ./config.yaml:/app/config.yaml:ro + # Mount data directories for persistence + # data/ contains: users/, bgm/, templates/, workflows/ (custom resources) + - ./data:/app/data + - ./output:/app/output + # Note: Default resources (bgm/, templates/, workflows/) are baked into the image + # Custom resources in data/* will override defaults + environment: + - TZ=Asia/Shanghai + - STREAMLIT_SERVER_PORT=8501 + - STREAMLIT_SERVER_ADDRESS=0.0.0.0 + - STREAMLIT_BROWSER_GATHER_USAGE_STATS=false + restart: unless-stopped + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8501/_stcore/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - pixelle-network + +networks: + pixelle-network: + driver: bridge + diff --git a/docker-start.sh b/docker-start.sh new file mode 100755 index 0000000..b84e8e0 --- /dev/null +++ b/docker-start.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Pixelle-Video Docker Quick Start Script + +set -e + +echo "🐳 Pixelle-Video Docker Deployment" +echo "==================================" +echo "" + +# Check if config.yaml exists +if [ ! -f config.yaml ]; then + echo "❌ Error: config.yaml not found!" + echo "" + echo "Please create config.yaml before starting:" + echo " 1. Copy from config.example.yaml" + echo " 2. Fill in your API keys and ComfyUI URL" + echo "" + exit 1 +fi + +# Check if docker-compose is available +if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then + echo "❌ Error: docker-compose not found!" + echo "" + echo "Please install Docker Compose first:" + echo " https://docs.docker.com/compose/install/" + echo "" + exit 1 +fi + +# Use docker-compose or docker compose based on availability +if command -v docker-compose &> /dev/null; then + DOCKER_COMPOSE="docker-compose" +else + DOCKER_COMPOSE="docker compose" +fi + +echo "📦 Building Docker images..." +$DOCKER_COMPOSE build + +echo "" +echo "🚀 Starting services..." +$DOCKER_COMPOSE up -d + +echo "" +echo "⏳ Waiting for services to be ready..." +sleep 5 + +echo "" +echo "✅ Pixelle-Video is now running!" +echo "" +echo "Services:" +echo " 🌐 Web UI: http://localhost:8501" +echo " 🔌 API: http://localhost:8000" +echo " 📚 API Docs: http://localhost:8000/docs" +echo "" +echo "Custom Resources (optional):" +echo " 📁 data/bgm/ - Custom background music (overrides default)" +echo " 📁 data/templates/ - Custom HTML templates (overrides default)" +echo " 📁 data/workflows/ - Custom ComfyUI workflows (overrides default)" +echo "" +echo "Useful commands:" +echo " View logs: $DOCKER_COMPOSE logs -f" +echo " Stop: $DOCKER_COMPOSE down" +echo " Restart: $DOCKER_COMPOSE restart" +echo " Rebuild: $DOCKER_COMPOSE up -d --build" +echo "" + diff --git a/pixelle_video/services/comfy_base_service.py b/pixelle_video/services/comfy_base_service.py index 8d659c8..dc99f78 100644 --- a/pixelle_video/services/comfy_base_service.py +++ b/pixelle_video/services/comfy_base_service.py @@ -10,6 +10,12 @@ from typing import Optional, List, Dict, Any from comfykit import ComfyKit from loguru import logger +from pixelle_video.utils.os_util import ( + get_resource_path, + list_resource_files, + list_resource_dirs +) + class ComfyBaseService: """ @@ -47,7 +53,7 @@ class ComfyBaseService: def _scan_workflows(self) -> List[Dict[str, Any]]: """ - Scan workflows/source/*.json files from all source directories + Scan workflows/source/*.json files from all source directories (merged from workflows/ and data/workflows/) Returns: List of workflow info dicts @@ -70,28 +76,34 @@ class ComfyBaseService: ] """ workflows = [] - workflows_dir = Path(self.WORKFLOWS_DIR) - if not workflows_dir.exists(): - logger.warning(f"Workflows directory not found: {workflows_dir}") + # Get all workflow source directories (merged from workflows/ and data/workflows/) + source_dirs = list_resource_dirs("workflows") + + if not source_dirs: + logger.warning("No workflow source directories found") return workflows - # 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 + # Scan each source directory for workflow files + for source_name in source_dirs: + # Get all JSON files for this source (merged from both locations) + workflow_files = list_resource_files("workflows", source_name) - source_name = source_dir.name + # Filter to only files matching the prefix + matching_files = [ + f for f in workflow_files + if f.startswith(self.WORKFLOW_PREFIX) and f.endswith('.json') + ] - # Scan workflow files in this source directory - for file_path in source_dir.glob(f"{self.WORKFLOW_PREFIX}*.json"): + for filename in matching_files: try: + # Get actual file path (custom > default) + file_path = Path(get_resource_path("workflows", source_name, filename)) 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}") + logger.error(f"Failed to parse workflow {source_name}/{filename}: {e}") # Sort by key (source/name) return sorted(workflows, key=lambda w: w["key"]) diff --git a/pixelle_video/services/video.py b/pixelle_video/services/video.py index 9896458..d36518a 100644 --- a/pixelle_video/services/video.py +++ b/pixelle_video/services/video.py @@ -21,6 +21,12 @@ from typing import List, Literal, Optional import ffmpeg from loguru import logger +from pixelle_video.utils.os_util import ( + get_resource_path, + list_resource_files, + resource_exists +) + def check_ffmpeg() -> None: """ @@ -509,11 +515,16 @@ class VideoService: def _resolve_bgm_path(self, bgm_path: str) -> str: """ - Resolve BGM path (filename or custom path) + Resolve BGM path (filename or custom path) with custom override support + + Search priority: + 1. Direct path (absolute or relative) + 2. data/bgm/{filename} (custom) + 3. bgm/{filename} (default) Args: bgm_path: Can be: - - Filename with extension (e.g., "default.mp3", "happy.mp3"): auto-resolved from bgm/ directory + - Filename with extension (e.g., "default.mp3", "happy.mp3"): auto-resolved from bgm/ or data/bgm/ - Custom file path (absolute or relative) Returns: @@ -526,15 +537,14 @@ class VideoService: if os.path.exists(bgm_path): return os.path.abspath(bgm_path) - # Try as filename in bgm/ directory - preset_path = f"bgm/{bgm_path}" - if os.path.exists(preset_path): - return os.path.abspath(preset_path) + # Try as filename in resource directories (custom > default) + if resource_exists("bgm", bgm_path): + return get_resource_path("bgm", bgm_path) # Not found - provide helpful error message tried_paths = [ os.path.abspath(bgm_path), - os.path.abspath(preset_path) + f"data/bgm/{bgm_path} or bgm/{bgm_path}" ] # List available BGM files @@ -551,20 +561,19 @@ class VideoService: def _list_available_bgm(self) -> list[str]: """ - List available BGM files in bgm/ directory + List available BGM files (merged from bgm/ and data/bgm/) Returns: - List of filenames (with extensions) + List of filenames (with extensions), sorted """ - bgm_dir = "bgm" - if not os.path.exists(bgm_dir): - return [] - try: - files = os.listdir(bgm_dir) - # Return all audio files (mp3, wav, ogg, flac, etc.) + # Use resource API to get merged list + all_files = list_resource_files("bgm") + + # Filter to audio files only audio_extensions = ('.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac') - return [f for f in files if f.lower().endswith(audio_extensions)] - except Exception: + return sorted([f for f in all_files if f.lower().endswith(audio_extensions)]) + except Exception as e: + logger.warning(f"Failed to list BGM files: {e}") return [] diff --git a/pixelle_video/utils/os_util.py b/pixelle_video/utils/os_util.py index a4d6c0e..8c45d4d 100644 --- a/pixelle_video/utils/os_util.py +++ b/pixelle_video/utils/os_util.py @@ -283,3 +283,170 @@ def get_task_final_video_path(task_id: str) -> str: """ return get_task_path(task_id, "final.mp4") + +# ========== Resource Management (Templates/BGM/Workflows) ========== + +def get_resource_path(resource_type: Literal["bgm", "templates", "workflows"], *paths: str) -> str: + """ + Get resource file path with custom override support + + Search priority: + 1. data/{resource_type}/*paths (custom, higher priority) + 2. {resource_type}/*paths (default, fallback) + + Args: + resource_type: Resource type ("bgm", "templates", "workflows") + *paths: Path components relative to resource directory + + Returns: + Absolute path to resource file (custom if exists, otherwise default) + + Raises: + FileNotFoundError: If file not found in either location + + Examples: + >>> get_resource_path("bgm", "happy.mp3") + # Returns: "data/bgm/happy.mp3" (if exists) or "bgm/happy.mp3" + + >>> get_resource_path("templates", "1080x1920", "default.html") + # Returns: "data/templates/1080x1920/default.html" or "templates/1080x1920/default.html" + + >>> get_resource_path("workflows", "selfhost", "image_flux.json") + # Returns: "data/workflows/selfhost/image_flux.json" or "workflows/selfhost/image_flux.json" + """ + # Build custom path (data/*) + custom_path = get_data_path(resource_type, *paths) + + # Build default path (root/*) + default_path = get_root_path(resource_type, *paths) + + # Priority: custom > default + if os.path.exists(custom_path): + return custom_path + + if os.path.exists(default_path): + return default_path + + # Not found in either location + raise FileNotFoundError( + f"Resource not found: {os.path.join(resource_type, *paths)}\n" + f" Searched locations:\n" + f" 1. {custom_path} (custom)\n" + f" 2. {default_path} (default)" + ) + + +def list_resource_files( + resource_type: Literal["bgm", "templates", "workflows"], + subdir: str = "" +) -> list[str]: + """ + List resource files with custom override support + + Merges files from both default and custom locations: + - Files from data/{resource_type}/* (custom, higher priority) + - Files from {resource_type}/* (default) + - Duplicate names are deduplicated (custom takes precedence) + + Args: + resource_type: Resource type ("bgm", "templates", "workflows") + subdir: Optional subdirectory (e.g., "1080x1920" for templates) + + Returns: + Sorted list of filenames (deduplicated, custom overrides default) + + Examples: + >>> list_resource_files("bgm") + # Returns: ["custom.mp3", "default.mp3", "happy.mp3"] + # (merged from bgm/ and data/bgm/) + + >>> list_resource_files("templates", "1080x1920") + # Returns: ["custom.html", "default.html", "modern.html"] + # (merged from templates/1080x1920/ and data/templates/1080x1920/) + """ + files = {} # Use dict to track source priority: {filename: path} + + # Build directory paths + default_dir = Path(get_root_path(resource_type, subdir)) if subdir else Path(get_root_path(resource_type)) + custom_dir = Path(get_data_path(resource_type, subdir)) if subdir else Path(get_data_path(resource_type)) + + # Scan default directory first (lower priority) + if default_dir.exists() and default_dir.is_dir(): + for item in default_dir.iterdir(): + if item.is_file(): + files[item.name] = str(item) + + # Scan custom directory (higher priority, overwrites) + if custom_dir.exists() and custom_dir.is_dir(): + for item in custom_dir.iterdir(): + if item.is_file(): + files[item.name] = str(item) # Overwrite if exists + + return sorted(files.keys()) + + +def list_resource_dirs( + resource_type: Literal["bgm", "templates", "workflows"] +) -> list[str]: + """ + List subdirectories in resource directory + + Merges directories from both default and custom locations. + + Args: + resource_type: Resource type ("bgm", "templates", "workflows") + + Returns: + Sorted list of directory names (deduplicated) + + Examples: + >>> list_resource_dirs("templates") + # Returns: ["1080x1080", "1080x1920", "1920x1080"] + + >>> list_resource_dirs("workflows") + # Returns: ["runninghub", "selfhost"] + """ + dirs = set() + + # Build directory paths + default_dir = Path(get_root_path(resource_type)) + custom_dir = Path(get_data_path(resource_type)) + + # Scan default directory + if default_dir.exists() and default_dir.is_dir(): + for item in default_dir.iterdir(): + if item.is_dir(): + dirs.add(item.name) + + # Scan custom directory + if custom_dir.exists() and custom_dir.is_dir(): + for item in custom_dir.iterdir(): + if item.is_dir(): + dirs.add(item.name) + + return sorted(dirs) + + +def resource_exists(resource_type: Literal["bgm", "templates", "workflows"], *paths: str) -> bool: + """ + Check if resource file exists (in custom or default location) + + Args: + resource_type: Resource type ("bgm", "templates", "workflows") + *paths: Path components relative to resource directory + + Returns: + True if exists in either location, False otherwise + + Examples: + >>> resource_exists("bgm", "happy.mp3") + True + + >>> resource_exists("templates", "1080x1920", "default.html") + True + """ + custom_path = get_data_path(resource_type, *paths) + default_path = get_root_path(resource_type, *paths) + + return os.path.exists(custom_path) or os.path.exists(default_path) + diff --git a/pixelle_video/utils/template_util.py b/pixelle_video/utils/template_util.py index accea07..5076f64 100644 --- a/pixelle_video/utils/template_util.py +++ b/pixelle_video/utils/template_util.py @@ -7,6 +7,13 @@ from pathlib import Path from typing import List, Tuple, Optional, Literal from pydantic import BaseModel, Field +from pixelle_video.utils.os_util import ( + get_resource_path, + list_resource_files, + list_resource_dirs, + resource_exists +) + def parse_template_size(template_path: str) -> Tuple[int, int]: """ @@ -68,7 +75,7 @@ def parse_template_size(template_path: str) -> Tuple[int, int]: def list_available_sizes() -> List[str]: """ - List all available video sizes + List all available video sizes (merged from templates/ and data/templates/) Returns: List of size strings like ["1080x1920", "1920x1080", "1080x1080"] @@ -77,20 +84,18 @@ def list_available_sizes() -> List[str]: >>> list_available_sizes() ['1080x1920', '1920x1080', '1080x1080'] """ - templates_dir = Path("templates") - - if not templates_dir.exists(): - return [] + # Use new resource API to merge default and custom directories + all_dirs = list_resource_dirs("templates") + # Filter to only valid size formats (WIDTHxHEIGHT) sizes = [] - for item in templates_dir.iterdir(): - if item.is_dir() and 'x' in item.name: - # Validate it's a proper size format + for dir_name in all_dirs: + if 'x' in dir_name: try: - width, height = item.name.split('x') + width, height = dir_name.split('x') int(width) int(height) - sizes.append(item.name) + sizes.append(dir_name) except (ValueError, AttributeError): # Skip invalid directories continue @@ -100,7 +105,7 @@ def list_available_sizes() -> List[str]: def list_templates_for_size(size: str) -> List[str]: """ - List all templates available for a given size + List all templates available for a given size (merged from templates/ and data/templates/) Args: size: Size string like "1080x1920" @@ -112,47 +117,42 @@ def list_templates_for_size(size: str) -> List[str]: >>> list_templates_for_size("1080x1920") ['cartoon.html', 'default.html', 'elegant.html', 'modern.html', ...] """ - size_dir = Path("templates") / size + # Use new resource API to merge default and custom templates + all_files = list_resource_files("templates", size) - if not size_dir.exists() or not size_dir.is_dir(): - return [] - - templates = [] - for item in size_dir.iterdir(): - if item.is_file() and item.suffix == '.html': - templates.append(item.name) + # Filter to only HTML files + templates = [f for f in all_files if f.endswith('.html')] return sorted(templates) def get_template_full_path(size: str, template_name: str) -> str: """ - Get full template path from size and template name + Get full template path from size and template name (checks data/templates/ first, then templates/) Args: size: Size string like "1080x1920" template_name: Template filename like "default.html" Returns: - Full path like "templates/1080x1920/default.html" + Full path like "templates/1080x1920/default.html" or "data/templates/1080x1920/default.html" Raises: - FileNotFoundError: If template file doesn't exist + FileNotFoundError: If template file doesn't exist in either location Examples: >>> get_template_full_path("1080x1920", "default.html") 'templates/1080x1920/default.html' """ - template_path = Path("templates") / size / template_name - - if not template_path.exists(): + # Use new resource API to search custom first, then default + try: + return get_resource_path("templates", size, template_name) + except FileNotFoundError: available_templates = list_templates_for_size(size) raise FileNotFoundError( - f"Template not found: {template_path}\n" + f"Template not found: {size}/{template_name}\n" f"Available templates for size {size}: {available_templates}" ) - - return str(template_path) class TemplateDisplayInfo(BaseModel): @@ -300,20 +300,21 @@ def get_templates_grouped_by_size() -> dict: def resolve_template_path(template_input: Optional[str]) -> str: """ - Resolve template input to full path with validation + Resolve template input to full path with validation (checks data/templates/ first, then templates/) Args: template_input: Can be: - None: Use default "1080x1920/default.html" - "template.html": Use default size + this template - "1080x1920/template.html": Full relative path - - "templates/1080x1920/template.html": Absolute-ish path + - "templates/1080x1920/template.html": Absolute-ish path (legacy) + - "data/templates/1080x1920/template.html": Custom path (legacy) Returns: - Resolved full path like "templates/1080x1920/default.html" + Resolved full path (custom if exists, otherwise default) Raises: - FileNotFoundError: If template doesn't exist + FileNotFoundError: If template doesn't exist in either location Examples: >>> resolve_template_path(None) @@ -327,25 +328,33 @@ def resolve_template_path(template_input: Optional[str]) -> str: if template_input is None: template_input = "1080x1920/default.html" - # If already starts with "templates/", use as-is - if template_input.startswith("templates/"): - template_path = Path(template_input) - # If contains size directory (e.g., "1080x1920/default.html") - elif '/' in template_input and 'x' in template_input.split('/')[0]: - template_path = Path("templates") / template_input - # Just template name (e.g., "default.html") - else: - # Use default size - template_path = Path("templates") / "1080x1920" / template_input + # Parse input to extract size and template name + size = None + template_name = None - # Validate existence - if not template_path.exists(): + # Handle different input formats + if template_input.startswith("templates/") or template_input.startswith("data/templates/"): + # Legacy full path format - extract size and name + parts = Path(template_input).parts + if len(parts) >= 3: + size = parts[-2] + template_name = parts[-1] + elif '/' in template_input and 'x' in template_input.split('/')[0]: + # "1080x1920/template.html" format + size, template_name = template_input.split('/', 1) + else: + # Just template name - use default size + size = "1080x1920" + template_name = template_input + + # Use resource API to resolve path (custom > default) + try: + return get_resource_path("templates", size, template_name) + except FileNotFoundError: available_sizes = list_available_sizes() raise FileNotFoundError( - f"Template not found: {template_path}\n" + f"Template not found: {size}/{template_name}\n" f"Available sizes: {available_sizes}\n" f"Hint: Use format 'SIZExSIZE/template.html' (e.g., '1080x1920/default.html')" ) - - return str(template_path) diff --git a/web/app.py b/web/app.py index 3599173..f43229c 100644 --- a/web/app.py +++ b/web/app.py @@ -390,14 +390,17 @@ def main(): st.markdown(f"**{tr('help.how')}**") st.markdown(tr("bgm.how")) - # Dynamically scan bgm folder for music files (support common audio formats) - bgm_folder = Path("bgm") - bgm_files = [] - if bgm_folder.exists(): - audio_extensions = ["*.mp3", "*.wav", "*.flac", "*.m4a", "*.aac", "*.ogg"] - for ext in audio_extensions: - bgm_files.extend([f.name for f in bgm_folder.glob(ext)]) - bgm_files.sort() + # Dynamically scan bgm folder for music files (merged from bgm/ and data/bgm/) + from pixelle_video.utils.os_util import list_resource_files + + try: + all_files = list_resource_files("bgm") + # Filter to audio files only + audio_extensions = ('.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg') + bgm_files = sorted([f for f in all_files if f.lower().endswith(audio_extensions)]) + except Exception as e: + st.warning(f"Failed to load BGM files: {e}") + bgm_files = [] # Add special "None" option bgm_options = [tr("bgm.none")] + bgm_files @@ -417,11 +420,15 @@ def main(): # BGM preview button (only if BGM is not "None") if bgm_choice != tr("bgm.none"): if st.button(tr("bgm.preview"), key="preview_bgm", use_container_width=True): - bgm_file_path = f"bgm/{bgm_choice}" - if os.path.exists(bgm_file_path): - st.audio(bgm_file_path) - else: - st.error(tr("bgm.preview_failed", file=bgm_choice)) + from pixelle_video.utils.os_util import get_resource_path, resource_exists + try: + if resource_exists("bgm", bgm_choice): + bgm_file_path = get_resource_path("bgm", bgm_choice) + st.audio(bgm_file_path) + else: + st.error(tr("bgm.preview_failed", file=bgm_choice)) + except Exception as e: + st.error(f"{tr('bgm.preview_failed', file=bgm_choice)}: {e}") # Use full filename for bgm_path (including extension) bgm_path = None if bgm_choice == tr("bgm.none") else bgm_choice