feat: Add comprehensive timeline editor with frame editing and regeneration capabilities

This commit is contained in:
empty
2026-01-05 14:48:43 +08:00
parent 7d78dcd078
commit ca018a9b1f
68 changed files with 14904 additions and 57 deletions

View File

@@ -27,19 +27,41 @@ from loguru import logger
from pixelle_video.models.progress import ProgressEvent
from pixelle_video.models.storyboard import Storyboard, StoryboardFrame, StoryboardConfig
from pixelle_video.services.quality import (
QualityGate,
QualityConfig,
RetryManager,
RetryConfig,
QualityError,
)
class FrameProcessor:
"""Frame processor"""
def __init__(self, pixelle_video_core):
def __init__(
self,
pixelle_video_core,
quality_config: Optional[QualityConfig] = None,
retry_config: Optional[RetryConfig] = None,
enable_quality_check: bool = True,
):
"""
Initialize
Args:
pixelle_video_core: PixelleVideoCore instance
quality_config: Quality evaluation configuration
retry_config: Retry behavior configuration
enable_quality_check: Whether to enable quality checking
"""
self.core = pixelle_video_core
self.enable_quality_check = enable_quality_check
self.quality_gate = QualityGate(
llm_service=pixelle_video_core.llm if hasattr(pixelle_video_core, 'llm') else None,
config=quality_config or QualityConfig()
)
self.retry_manager = RetryManager(config=retry_config or RetryConfig())
async def __call__(
self,
@@ -199,11 +221,14 @@ class FrameProcessor:
frame: StoryboardFrame,
config: StoryboardConfig
):
"""Step 2: Generate media (image or video) using ComfyKit"""
"""
Step 2: Generate media (image or video) using ComfyKit
Enhanced with quality evaluation and retry logic.
"""
logger.debug(f" 2/4: Generating media for frame {frame.index}...")
# Determine media type based on workflow
# video_ prefix in workflow name indicates video generation
workflow_name = config.media_workflow or ""
is_video_workflow = "video_" in workflow_name.lower()
media_type = "video" if is_video_workflow else "image"
@@ -213,57 +238,87 @@ class FrameProcessor:
# Build media generation parameters
media_params = {
"prompt": frame.image_prompt,
"workflow": config.media_workflow, # Pass workflow from config (None = use default)
"workflow": config.media_workflow,
"media_type": media_type,
"width": config.media_width,
"height": config.media_height,
"index": frame.index + 1, # 1-based index for workflow
"index": frame.index + 1,
}
# For video workflows: pass audio duration as target video duration
# This ensures video length matches audio length from the source
if is_video_workflow and frame.duration:
media_params["duration"] = frame.duration
logger.info(f" → Generating video with target duration: {frame.duration:.2f}s (from TTS audio)")
logger.info(f" → Generating video with target duration: {frame.duration:.2f}s")
# Call Media generation
media_result = await self.core.media(**media_params)
# Define generation operation
async def generate_and_download():
media_result = await self.core.media(**media_params)
local_path = await self._download_media(
media_result.url,
frame.index,
config.task_id,
media_type=media_result.media_type
)
return (media_result, local_path)
# Store media type
# Define quality evaluator
async def evaluate_quality(result):
media_result, local_path = result
if media_result.is_video:
return await self.quality_gate.evaluate_video(
local_path, frame.image_prompt, frame.narration
)
else:
return await self.quality_gate.evaluate_image(
local_path, frame.image_prompt, frame.narration
)
# Execute with retry and quality check
if self.enable_quality_check:
try:
retry_result = await self.retry_manager.execute_with_retry(
operation=generate_and_download,
quality_evaluator=evaluate_quality,
operation_name=f"frame_{frame.index}_media",
)
media_result, local_path = retry_result.result
# Store quality metrics on frame
if retry_result.quality_score:
frame.quality_score = retry_result.quality_score.overall_score
frame.quality_issues = retry_result.quality_score.issues
frame.retry_count = retry_result.attempts - 1 # first attempt is not a retry
except QualityError as e:
logger.warning(f" ⚠ Quality check failed after retries: {e}")
# Still try to use the last result if available
media_result, local_path = await generate_and_download()
frame.quality_issues = [str(e)]
else:
# Quality check disabled - just generate
media_result, local_path = await generate_and_download()
# Store results on frame
frame.media_type = media_result.media_type
if media_result.is_image:
# Download image to local (pass task_id)
local_path = await self._download_media(
media_result.url,
frame.index,
config.task_id,
media_type="image"
)
frame.image_path = local_path
logger.debug(f" ✓ Image generated: {local_path}")
elif media_result.is_video:
# Download video to local (pass task_id)
local_path = await self._download_media(
media_result.url,
frame.index,
config.task_id,
media_type="video"
)
frame.video_path = local_path
# Update duration from video if available
if media_result.duration:
frame.duration = media_result.duration
logger.debug(f" ✓ Video generated: {local_path} (duration: {frame.duration:.2f}s)")
else:
# Get video duration from file
frame.duration = await self._get_video_duration(local_path)
logger.debug(f" ✓ Video generated: {local_path} (duration: {frame.duration:.2f}s)")
logger.debug(f" ✓ Video generated: {local_path} (duration: {frame.duration:.2f}s)")
else:
raise ValueError(f"Unknown media type: {media_result.media_type}")
# Log quality result
if frame.quality_score is not None:
logger.info(
f" 📊 Quality: {frame.quality_score:.2f} "
f"(retries: {frame.retry_count}, issues: {len(frame.quality_issues or [])})"
)
async def _step_compose_frame(
self,