更改视频尺寸的定义方式
This commit is contained in:
@@ -65,8 +65,6 @@ async def generate_video_sync(
|
||||
image_width=request_body.image_width,
|
||||
image_height=request_body.image_height,
|
||||
image_workflow=request_body.image_workflow,
|
||||
video_width=request_body.video_width,
|
||||
video_height=request_body.video_height,
|
||||
video_fps=request_body.video_fps,
|
||||
frame_template=request_body.frame_template,
|
||||
prompt_prefix=request_body.prompt_prefix,
|
||||
|
||||
@@ -37,12 +37,13 @@ class VideoGenerateRequest(BaseModel):
|
||||
image_workflow: Optional[str] = Field(None, description="Custom image workflow")
|
||||
|
||||
# === Video Parameters ===
|
||||
video_width: int = Field(1080, description="Video width")
|
||||
video_height: int = Field(1920, description="Video height")
|
||||
video_fps: int = Field(30, ge=15, le=60, description="Video FPS")
|
||||
|
||||
# === Frame Template ===
|
||||
frame_template: Optional[str] = Field(None, description="HTML template name (e.g., 'default.html')")
|
||||
# === Frame Template (determines video size) ===
|
||||
frame_template: Optional[str] = Field(
|
||||
None,
|
||||
description="HTML template path with size (e.g., '1080x1920/default.html'). Video size is auto-determined from template."
|
||||
)
|
||||
|
||||
# === Image Style ===
|
||||
prompt_prefix: Optional[str] = Field(None, description="Image style prefix")
|
||||
|
||||
@@ -20,9 +20,7 @@ class StoryboardConfig:
|
||||
min_image_prompt_words: int = 30 # Min image prompt word count
|
||||
max_image_prompt_words: int = 60 # Max image prompt word count
|
||||
|
||||
# Video parameters
|
||||
video_width: int = 1080 # Video width
|
||||
video_height: int = 1920 # Video height (9:16 portrait)
|
||||
# Video parameters (fps only, size is determined by frame template)
|
||||
video_fps: int = 30 # Frame rate
|
||||
|
||||
# Audio parameters
|
||||
@@ -36,8 +34,8 @@ class StoryboardConfig:
|
||||
image_height: int = 1024
|
||||
image_workflow: Optional[str] = None # Image workflow filename (None = use default)
|
||||
|
||||
# Frame template
|
||||
frame_template: str = "default.html" # HTML template name or path (e.g., "default.html", "modern.html")
|
||||
# Frame template (includes size information in path)
|
||||
frame_template: str = "1080x1920/default.html" # Template path with size (e.g., "1080x1920/default.html")
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -18,6 +18,8 @@ from pathlib import Path
|
||||
from html2image import Html2Image
|
||||
from loguru import logger
|
||||
|
||||
from pixelle_video.utils.template_util import parse_template_size
|
||||
|
||||
|
||||
class HTMLFrameGenerator:
|
||||
"""
|
||||
@@ -41,13 +43,17 @@ class HTMLFrameGenerator:
|
||||
Initialize HTML frame generator
|
||||
|
||||
Args:
|
||||
template_path: Path to HTML template file
|
||||
template_path: Path to HTML template file (e.g., "templates/1080x1920/default.html")
|
||||
"""
|
||||
self.template_path = template_path
|
||||
self.template = self._load_template(template_path)
|
||||
|
||||
# Parse video size from template path
|
||||
self.width, self.height = parse_template_size(template_path)
|
||||
|
||||
self.hti = None # Lazy init to avoid overhead
|
||||
self._check_linux_dependencies()
|
||||
logger.debug(f"Loaded HTML template: {template_path}")
|
||||
logger.debug(f"Loaded HTML template: {template_path} (size: {self.width}x{self.height})")
|
||||
|
||||
def _check_linux_dependencies(self):
|
||||
"""Check Linux system dependencies and warn if missing"""
|
||||
@@ -197,20 +203,18 @@ class HTMLFrameGenerator:
|
||||
text: str,
|
||||
image: str,
|
||||
ext: Optional[Dict[str, Any]] = None,
|
||||
width: int = 1080,
|
||||
height: int = 1920,
|
||||
output_path: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Generate frame from HTML template
|
||||
|
||||
Video size is automatically determined from template path during initialization.
|
||||
|
||||
Args:
|
||||
title: Video title
|
||||
text: Narration text for this frame
|
||||
image: Path to AI-generated image (supports relative path, absolute path, or HTTP URL)
|
||||
ext: Additional data (content_title, content_author, etc.)
|
||||
width: Frame width in pixels
|
||||
height: Frame height in pixels
|
||||
output_path: Custom output path (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
@@ -266,11 +270,11 @@ class HTMLFrameGenerator:
|
||||
output_filename = os.path.basename(output_path)
|
||||
output_dir = os.path.dirname(output_path)
|
||||
|
||||
# Ensure Html2Image is initialized
|
||||
self._ensure_hti(width, height)
|
||||
# Ensure Html2Image is initialized with template's size
|
||||
self._ensure_hti(self.width, self.height)
|
||||
|
||||
# Render HTML to image
|
||||
logger.debug(f"Rendering HTML template to {output_path}")
|
||||
logger.debug(f"Rendering HTML template to {output_path} (size: {self.width}x{self.height})")
|
||||
try:
|
||||
self.hti.screenshot(
|
||||
html_str=html,
|
||||
|
||||
@@ -197,21 +197,10 @@ class FrameProcessor:
|
||||
) -> str:
|
||||
"""Compose frame using HTML template"""
|
||||
from pixelle_video.services.frame_html import HTMLFrameGenerator
|
||||
from pathlib import Path
|
||||
from pixelle_video.utils.template_util import resolve_template_path
|
||||
|
||||
# Resolve template path
|
||||
template_filename = config.frame_template
|
||||
|
||||
# Try templates/ directory first
|
||||
template_path = Path(f"templates/{template_filename}")
|
||||
if not template_path.exists():
|
||||
# Try as absolute/relative path
|
||||
template_path = Path(template_filename)
|
||||
if not template_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Template not found: {template_filename}. "
|
||||
f"Built-in templates: default.html, modern.html, neon.html"
|
||||
)
|
||||
# Resolve template path (handles various input formats)
|
||||
template_path = resolve_template_path(config.frame_template)
|
||||
|
||||
# Get content metadata from storyboard
|
||||
content_metadata = storyboard.content_metadata if storyboard else None
|
||||
@@ -224,15 +213,13 @@ class FrameProcessor:
|
||||
ext["content_subtitle"] = content_metadata.subtitle or ""
|
||||
ext["content_genre"] = content_metadata.genre or ""
|
||||
|
||||
# Generate frame using HTML
|
||||
generator = HTMLFrameGenerator(str(template_path))
|
||||
# Generate frame using HTML (size is auto-parsed from template path)
|
||||
generator = HTMLFrameGenerator(template_path)
|
||||
composed_path = await generator.generate_frame(
|
||||
title=storyboard.title,
|
||||
text=frame.narration,
|
||||
image=frame.image_path,
|
||||
ext=ext,
|
||||
width=config.video_width,
|
||||
height=config.video_height,
|
||||
output_path=output_path
|
||||
)
|
||||
|
||||
|
||||
@@ -72,11 +72,9 @@ class VideoGeneratorService:
|
||||
image_workflow: Optional[str] = None,
|
||||
|
||||
# === Video Parameters ===
|
||||
video_width: int = 1080,
|
||||
video_height: int = 1920,
|
||||
video_fps: int = 30,
|
||||
|
||||
# === Frame Template ===
|
||||
# === Frame Template (determines video size) ===
|
||||
frame_template: Optional[str] = None,
|
||||
|
||||
# === Image Style ===
|
||||
@@ -128,12 +126,11 @@ class VideoGeneratorService:
|
||||
image_height: Generated image height (default 1024)
|
||||
image_workflow: Image workflow filename (e.g., "image_flux.json", None = use default)
|
||||
|
||||
video_width: Final video width (default 1080)
|
||||
video_height: Final video height (default 1920)
|
||||
video_fps: Video frame rate (default 30)
|
||||
|
||||
frame_template: HTML template filename or path (None = use default template)
|
||||
e.g., "default.html", "modern.html", "neon.html", or custom path
|
||||
frame_template: HTML template path with size (None = use default "1080x1920/default.html")
|
||||
Format: "SIZExSIZE/template.html" (e.g., "1080x1920/default.html", "1920x1080/modern.html")
|
||||
Video size is automatically determined from template path
|
||||
|
||||
prompt_prefix: Image prompt prefix (overrides config.yaml if provided)
|
||||
e.g., "anime style, vibrant colors" or "" for no prefix
|
||||
@@ -220,8 +217,6 @@ class VideoGeneratorService:
|
||||
max_narration_words=max_narration_words,
|
||||
min_image_prompt_words=min_image_prompt_words,
|
||||
max_image_prompt_words=max_image_prompt_words,
|
||||
video_width=video_width,
|
||||
video_height=video_height,
|
||||
video_fps=video_fps,
|
||||
voice_id=voice_id,
|
||||
tts_workflow=tts_workflow,
|
||||
@@ -230,7 +225,7 @@ class VideoGeneratorService:
|
||||
image_width=image_width,
|
||||
image_height=image_height,
|
||||
image_workflow=image_workflow,
|
||||
frame_template=frame_template or "default.html"
|
||||
frame_template=frame_template or "1080x1920/default.html"
|
||||
)
|
||||
|
||||
# Create storyboard
|
||||
|
||||
207
pixelle_video/utils/template_util.py
Normal file
207
pixelle_video/utils/template_util.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Template utility functions for size parsing and template management
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
|
||||
def parse_template_size(template_path: str) -> Tuple[int, int]:
|
||||
"""
|
||||
Parse video size from template path
|
||||
|
||||
Args:
|
||||
template_path: Template path like "templates/1080x1920/default.html"
|
||||
or "1080x1920/default.html"
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height) in pixels
|
||||
|
||||
Raises:
|
||||
ValueError: If template path format is invalid
|
||||
|
||||
Examples:
|
||||
>>> parse_template_size("templates/1080x1920/default.html")
|
||||
(1080, 1920)
|
||||
>>> parse_template_size("1920x1080/modern.html")
|
||||
(1920, 1080)
|
||||
"""
|
||||
path = Path(template_path)
|
||||
|
||||
# Get parent directory name (should be like "1080x1920")
|
||||
dir_name = path.parent.name
|
||||
|
||||
# Special case: if parent is "templates", go up one more level
|
||||
if dir_name == "templates":
|
||||
# This shouldn't happen in new structure, but handle it
|
||||
raise ValueError(
|
||||
f"Invalid template path format: {template_path}. "
|
||||
f"Expected format: 'WIDTHxHEIGHT/template.html' or 'templates/WIDTHxHEIGHT/template.html'"
|
||||
)
|
||||
|
||||
# Parse size from directory name
|
||||
if 'x' not in dir_name:
|
||||
raise ValueError(
|
||||
f"Invalid size format in path: {template_path}. "
|
||||
f"Directory name should be 'WIDTHxHEIGHT' (e.g., '1080x1920')"
|
||||
)
|
||||
|
||||
try:
|
||||
width_str, height_str = dir_name.split('x')
|
||||
width = int(width_str)
|
||||
height = int(height_str)
|
||||
|
||||
# Sanity check
|
||||
if width < 100 or height < 100 or width > 10000 or height > 10000:
|
||||
raise ValueError(f"Invalid size dimensions: {width}x{height}")
|
||||
|
||||
return (width, height)
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Failed to parse size from path: {template_path}. "
|
||||
f"Expected format: 'WIDTHxHEIGHT/template.html' (e.g., '1080x1920/default.html'). "
|
||||
f"Error: {e}"
|
||||
)
|
||||
|
||||
|
||||
def list_available_sizes() -> List[str]:
|
||||
"""
|
||||
List all available video sizes
|
||||
|
||||
Returns:
|
||||
List of size strings like ["1080x1920", "1920x1080", "1080x1080"]
|
||||
|
||||
Examples:
|
||||
>>> list_available_sizes()
|
||||
['1080x1920', '1920x1080', '1080x1080']
|
||||
"""
|
||||
templates_dir = Path("templates")
|
||||
|
||||
if not templates_dir.exists():
|
||||
return []
|
||||
|
||||
sizes = []
|
||||
for item in templates_dir.iterdir():
|
||||
if item.is_dir() and 'x' in item.name:
|
||||
# Validate it's a proper size format
|
||||
try:
|
||||
width, height = item.name.split('x')
|
||||
int(width)
|
||||
int(height)
|
||||
sizes.append(item.name)
|
||||
except (ValueError, AttributeError):
|
||||
# Skip invalid directories
|
||||
continue
|
||||
|
||||
return sorted(sizes)
|
||||
|
||||
|
||||
def list_templates_for_size(size: str) -> List[str]:
|
||||
"""
|
||||
List all templates available for a given size
|
||||
|
||||
Args:
|
||||
size: Size string like "1080x1920"
|
||||
|
||||
Returns:
|
||||
List of template filenames (without path) like ["default.html", "modern.html"]
|
||||
|
||||
Examples:
|
||||
>>> list_templates_for_size("1080x1920")
|
||||
['cartoon.html', 'default.html', 'elegant.html', 'modern.html', ...]
|
||||
"""
|
||||
size_dir = Path("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)
|
||||
|
||||
return sorted(templates)
|
||||
|
||||
|
||||
def get_template_full_path(size: str, template_name: str) -> str:
|
||||
"""
|
||||
Get full template path from size and template name
|
||||
|
||||
Args:
|
||||
size: Size string like "1080x1920"
|
||||
template_name: Template filename like "default.html"
|
||||
|
||||
Returns:
|
||||
Full path like "templates/1080x1920/default.html"
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If template file doesn't exist
|
||||
|
||||
Examples:
|
||||
>>> get_template_full_path("1080x1920", "default.html")
|
||||
'templates/1080x1920/default.html'
|
||||
"""
|
||||
template_path = Path("templates") / size / template_name
|
||||
|
||||
if not template_path.exists():
|
||||
available_templates = list_templates_for_size(size)
|
||||
raise FileNotFoundError(
|
||||
f"Template not found: {template_path}\n"
|
||||
f"Available templates for size {size}: {available_templates}"
|
||||
)
|
||||
|
||||
return str(template_path)
|
||||
|
||||
|
||||
def resolve_template_path(template_input: Optional[str]) -> str:
|
||||
"""
|
||||
Resolve template input to full path with validation
|
||||
|
||||
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
|
||||
|
||||
Returns:
|
||||
Resolved full path like "templates/1080x1920/default.html"
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If template doesn't exist
|
||||
|
||||
Examples:
|
||||
>>> resolve_template_path(None)
|
||||
'templates/1080x1920/default.html'
|
||||
>>> resolve_template_path("modern.html")
|
||||
'templates/1080x1920/modern.html'
|
||||
>>> resolve_template_path("1920x1080/default.html")
|
||||
'templates/1920x1080/default.html'
|
||||
"""
|
||||
# Default case
|
||||
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
|
||||
|
||||
# Validate existence
|
||||
if not template_path.exists():
|
||||
available_sizes = list_available_sizes()
|
||||
raise FileNotFoundError(
|
||||
f"Template not found: {template_path}\n"
|
||||
f"Available sizes: {available_sizes}\n"
|
||||
f"Hint: Use format 'SIZExSIZE/template.html' (e.g., '1080x1920/default.html')"
|
||||
)
|
||||
|
||||
return str(template_path)
|
||||
|
||||
79
web/app.py
79
web/app.py
@@ -666,24 +666,49 @@ def main():
|
||||
st.markdown(f"**{tr('help.how')}**")
|
||||
st.markdown(tr("template.how"))
|
||||
|
||||
# Dynamically scan templates folder for HTML files
|
||||
templates_folder = Path("templates")
|
||||
template_files = []
|
||||
if templates_folder.exists():
|
||||
template_files = sorted([f.name for f in templates_folder.glob("*.html")])
|
||||
# Import template utilities
|
||||
from pixelle_video.utils.template_util import list_available_sizes, list_templates_for_size
|
||||
|
||||
# Step 1: Select video size
|
||||
VIDEO_SIZE_OPTIONS = {
|
||||
"📱 竖屏视频 (1080×1920)": "1080x1920",
|
||||
"🖥 横屏视频 (1920×1080)": "1920x1080",
|
||||
"⬜ 方形视频 (1080×1080)": "1080x1080",
|
||||
}
|
||||
|
||||
# Filter available sizes (only show sizes that exist)
|
||||
available_sizes = list_available_sizes()
|
||||
available_size_options = {k: v for k, v in VIDEO_SIZE_OPTIONS.items() if v in available_sizes}
|
||||
|
||||
if not available_size_options:
|
||||
st.error("No template sizes found. Please ensure templates are in correct directory structure.")
|
||||
st.stop()
|
||||
|
||||
selected_size_label = st.selectbox(
|
||||
tr("template.video_size"),
|
||||
list(available_size_options.keys()),
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
selected_size = available_size_options[selected_size_label]
|
||||
|
||||
# Step 2: Select template for the chosen size
|
||||
template_files = list_templates_for_size(selected_size)
|
||||
|
||||
# Default to default.html if exists, otherwise first option
|
||||
default_template_index = 0
|
||||
if "default.html" in template_files:
|
||||
default_template_index = template_files.index("default.html")
|
||||
|
||||
frame_template = st.selectbox(
|
||||
"Template",
|
||||
template_name = st.selectbox(
|
||||
tr("template.style"),
|
||||
template_files if template_files else ["default.html"],
|
||||
index=default_template_index,
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
|
||||
# Combine size and template name to get full path
|
||||
frame_template = f"{selected_size}/{template_name}"
|
||||
|
||||
# Template preview expander
|
||||
with st.expander(tr("template.preview_title"), expanded=False):
|
||||
col1, col2 = st.columns(2)
|
||||
@@ -709,26 +734,10 @@ def main():
|
||||
key="preview_text"
|
||||
)
|
||||
|
||||
# Size settings in a compact row
|
||||
col3, col4 = st.columns(2)
|
||||
with col3:
|
||||
preview_width = st.number_input(
|
||||
tr("template.preview_param_width"),
|
||||
value=1080,
|
||||
min_value=100,
|
||||
max_value=4096,
|
||||
step=10,
|
||||
key="preview_width"
|
||||
)
|
||||
with col4:
|
||||
preview_height = st.number_input(
|
||||
tr("template.preview_param_height"),
|
||||
value=1920,
|
||||
min_value=100,
|
||||
max_value=4096,
|
||||
step=10,
|
||||
key="preview_height"
|
||||
)
|
||||
# Info: Size is auto-determined from template
|
||||
from pixelle_video.utils.template_util import parse_template_size
|
||||
template_width, template_height = parse_template_size(f"templates/{frame_template}")
|
||||
st.info(f"📐 {tr('template.size_info')}: {template_width} × {template_height}")
|
||||
|
||||
# Preview button
|
||||
if st.button(tr("template.preview_button"), key="btn_preview_template", use_container_width=True):
|
||||
@@ -736,17 +745,15 @@ def main():
|
||||
try:
|
||||
from pixelle_video.services.frame_html import HTMLFrameGenerator
|
||||
|
||||
# Use the currently selected template
|
||||
# Use the currently selected template (size is auto-parsed)
|
||||
template_path = f"templates/{frame_template}"
|
||||
generator = HTMLFrameGenerator(template_path)
|
||||
|
||||
# Generate preview
|
||||
# Generate preview (size is auto-determined from template)
|
||||
preview_path = run_async(generator.generate_frame(
|
||||
title=preview_title,
|
||||
text=preview_text,
|
||||
image=preview_image,
|
||||
width=preview_width,
|
||||
height=preview_height
|
||||
image=preview_image
|
||||
))
|
||||
|
||||
# Display preview
|
||||
@@ -857,11 +864,17 @@ def main():
|
||||
|
||||
# Video information (compact display)
|
||||
file_size_mb = result.file_size / (1024 * 1024)
|
||||
|
||||
# Parse video size from template path
|
||||
from pixelle_video.utils.template_util import parse_template_size, resolve_template_path
|
||||
template_path = resolve_template_path(result.storyboard.config.frame_template)
|
||||
video_width, video_height = parse_template_size(template_path)
|
||||
|
||||
info_text = (
|
||||
f"⏱️ {result.duration:.1f}s "
|
||||
f"📦 {file_size_mb:.2f}MB "
|
||||
f"🎬 {len(result.storyboard.frames)}{tr('info.scenes_unit')} "
|
||||
f"📐 {result.storyboard.config.video_width}x{result.storyboard.config.video_height}"
|
||||
f"📐 {video_width}x{video_height}"
|
||||
)
|
||||
st.caption(info_text)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user