支持Docker容器化部署; 分离预设资源和自定义资源;

This commit is contained in:
puke
2025-11-04 23:55:20 +08:00
parent 641efb81c6
commit 9201bc0110
10 changed files with 569 additions and 90 deletions

View File

@@ -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"])

View File

@@ -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 []

View File

@@ -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)

View File

@@ -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)