支持Docker容器化部署; 分离预设资源和自定义资源;
This commit is contained in:
69
.dockerignore
Normal file
69
.dockerignore
Normal file
@@ -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
|
||||
|
||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
83
docker-compose.yml
Normal file
83
docker-compose.yml
Normal file
@@ -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
|
||||
|
||||
68
docker-start.sh
Executable file
68
docker-start.sh
Executable file
@@ -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 ""
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
33
web/app.py
33
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
|
||||
|
||||
Reference in New Issue
Block a user