添加taskid逻辑

This commit is contained in:
puke
2025-10-28 21:15:46 +08:00
parent dd98e1b254
commit d9d501d849
7 changed files with 241 additions and 51 deletions

View File

@@ -58,7 +58,6 @@ async def generate_video_sync(
title=request_body.title,
n_scenes=request_body.n_scenes,
voice_id=request_body.voice_id,
use_uuid_filename=True, # API mode: use UUID filename
min_narration_words=request_body.min_narration_words,
max_narration_words=request_body.max_narration_words,
min_image_prompt_words=request_body.min_image_prompt_words,
@@ -133,7 +132,6 @@ async def generate_video_async(
title=request_body.title,
n_scenes=request_body.n_scenes,
voice_id=request_body.voice_id,
use_uuid_filename=True, # API mode: use UUID filename
min_narration_words=request_body.min_narration_words,
max_narration_words=request_body.max_narration_words,
min_image_prompt_words=request_body.min_image_prompt_words,

View File

@@ -10,6 +10,10 @@ from typing import List, Optional
@dataclass
class StoryboardConfig:
"""Storyboard configuration parameters"""
# Task isolation
task_id: Optional[str] = None # Task ID for file isolation (auto-generated if None)
n_storyboard: int = 5 # Number of storyboard frames
min_narration_words: int = 5 # Min narration word count
max_narration_words: int = 20 # Max narration word count

View File

@@ -198,7 +198,8 @@ class HTMLFrameGenerator:
image: str,
ext: Optional[Dict[str, Any]] = None,
width: int = 1080,
height: int = 1920
height: int = 1920,
output_path: Optional[str] = None
) -> str:
"""
Generate frame from HTML template
@@ -210,6 +211,7 @@ class HTMLFrameGenerator:
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:
Path to generated frame image
@@ -248,10 +250,21 @@ class HTMLFrameGenerator:
placeholder = f"{{{{{key}}}}}"
html = html.replace(placeholder, str(value) if value is not None else "")
# Generate unique output path
from reelforge.utils.os_util import get_output_path
output_filename = f"frame_{uuid.uuid4().hex[:16]}.png"
output_path = get_output_path(output_filename)
# Use provided output path or auto-generate
if output_path is None:
# Fallback: auto-generate (for backward compatibility)
from reelforge.utils.os_util import get_output_path
output_filename = f"frame_{uuid.uuid4().hex[:16]}.png"
output_path = get_output_path(output_filename)
else:
# Ensure parent directory exists
import os
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Extract filename from output_path for html2image
import os
output_filename = os.path.basename(output_path)
output_dir = os.path.dirname(output_path)
# Ensure Html2Image is initialized
self._ensure_hti(width, height)
@@ -265,11 +278,11 @@ class HTMLFrameGenerator:
size=(width, height)
)
# html2image saves to current directory by default, move to output
import os
# html2image saves to current directory by default, move to target directory
import shutil
if os.path.exists(output_filename):
shutil.move(output_filename, output_path)
temp_file = os.path.join(os.getcwd(), output_filename)
if os.path.exists(temp_file) and temp_file != output_path:
shutil.move(temp_file, output_path)
logger.info(f"✅ Frame generated: {output_path}")
return output_path

View File

@@ -11,7 +11,6 @@ from loguru import logger
from reelforge.models.progress import ProgressEvent
from reelforge.models.storyboard import Storyboard, StoryboardFrame, StoryboardConfig
from reelforge.utils.os_util import get_temp_path
class FrameProcessor:
@@ -121,11 +120,16 @@ class FrameProcessor:
"""Step 1: Generate audio using TTS"""
logger.debug(f" 1/4: Generating audio for frame {frame.index}...")
# Call TTS
# Generate output path using task_id
from reelforge.utils.os_util import get_task_frame_path
output_path = get_task_frame_path(config.task_id, frame.index, "audio")
# Call TTS with specific output path
audio_path = await self.core.tts(
text=frame.narration,
voice=config.voice_id,
rate="+20%",
output_path=output_path,
)
frame.audio_path = audio_path
@@ -151,8 +155,8 @@ class FrameProcessor:
height=config.image_height
)
# Download image to local
local_path = await self._download_image(image_url, frame.index)
# Download image to local (pass task_id)
local_path = await self._download_image(image_url, frame.index, config.task_id)
frame.image_path = local_path
logger.debug(f" ✓ Image generated: {local_path}")
@@ -166,7 +170,9 @@ class FrameProcessor:
"""Step 3: Compose frame with subtitle using HTML template"""
logger.debug(f" 3/4: Composing frame {frame.index}...")
output_path = get_temp_path(f"frame_{frame.index}_composed.png")
# Generate output path using task_id
from reelforge.utils.os_util import get_task_frame_path
output_path = get_task_frame_path(config.task_id, frame.index, "composed")
# Use HTML template to compose frame
composed_path = await self._compose_frame_html(frame, storyboard, config, output_path)
@@ -219,7 +225,8 @@ class FrameProcessor:
image=frame.image_path,
ext=ext,
width=config.video_width,
height=config.video_height
height=config.video_height,
output_path=output_path
)
return composed_path
@@ -232,7 +239,9 @@ class FrameProcessor:
"""Step 4: Create video segment from image + audio"""
logger.debug(f" 4/4: Creating video segment for frame {frame.index}...")
output_path = get_temp_path(f"frame_{frame.index}_segment.mp4")
# Generate output path using task_id
from reelforge.utils.os_util import get_task_frame_path
output_path = get_task_frame_path(config.task_id, frame.index, "segment")
# Call video compositor to create video from image + audio
from reelforge.services.video import VideoService
@@ -266,9 +275,10 @@ class FrameProcessor:
estimated_duration = file_size / 2000
return max(1.0, estimated_duration) # At least 1 second
async def _download_image(self, url: str, frame_index: int) -> str:
async def _download_image(self, url: str, frame_index: int, task_id: str) -> str:
"""Download image from URL to local file"""
output_path = get_temp_path(f"frame_{frame_index}_image.png")
from reelforge.utils.os_util import get_task_frame_path
output_path = get_task_frame_path(task_id, frame_index, "image")
async with httpx.AsyncClient() as client:
response = await client.get(url)

View File

@@ -90,6 +90,8 @@ class TTSService(ComfyBaseService):
rate: Optional[str] = None,
volume: Optional[str] = None,
pitch: Optional[str] = None,
# Output path
output_path: Optional[str] = None,
**params
) -> str:
"""
@@ -107,6 +109,7 @@ class TTSService(ComfyBaseService):
rate: Speech rate (e.g., "+0%", "+50%", "-20%")
volume: Speech volume (e.g., "+0%")
pitch: Speech pitch (e.g., "+0Hz")
output_path: Custom output path (auto-generated if None)
**params: Additional parameters
Returns:
@@ -148,6 +151,7 @@ class TTSService(ComfyBaseService):
rate=rate,
volume=volume,
pitch=pitch,
output_path=output_path,
**params
)
else:
@@ -161,6 +165,7 @@ class TTSService(ComfyBaseService):
rate=rate,
volume=volume,
pitch=pitch,
output_path=output_path,
**params
)
@@ -171,6 +176,7 @@ class TTSService(ComfyBaseService):
rate: Optional[str] = None,
volume: Optional[str] = None,
pitch: Optional[str] = None,
output_path: Optional[str] = None,
**params
) -> str:
"""
@@ -182,6 +188,7 @@ class TTSService(ComfyBaseService):
rate: Speech rate (default: +0%)
volume: Speech volume (default: +0%)
pitch: Speech pitch (default: +0Hz)
output_path: Custom output path (auto-generated if None)
**params: Additional parameters (e.g., retry_count, retry_delay)
Returns:
@@ -191,8 +198,13 @@ class TTSService(ComfyBaseService):
logger.info(f"🎙️ Using edge-tts (local SDK)")
# Generate temp file path
output_path = get_temp_path(f"{uuid.uuid4().hex}.mp3")
# Generate output path (use provided path or auto-generate)
if output_path is None:
output_path = get_temp_path(f"{uuid.uuid4().hex}.mp3")
else:
# Ensure parent directory exists
import os
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Call edge-tts with output_path to save directly
try:
@@ -223,6 +235,7 @@ class TTSService(ComfyBaseService):
rate: Optional[str] = None,
volume: Optional[str] = None,
pitch: Optional[str] = None,
output_path: Optional[str] = None,
**params
) -> str:
"""
@@ -237,10 +250,11 @@ class TTSService(ComfyBaseService):
rate: Speech rate (workflow-specific)
volume: Speech volume (workflow-specific)
pitch: Speech pitch (workflow-specific)
output_path: Custom output path (downloads if URL returned)
**params: Additional workflow parameters
Returns:
Generated audio file path/URL
Generated audio file path (local if output_path provided, otherwise URL)
"""
logger.info(f"🎙️ Using ComfyUI workflow: {workflow_path}")
@@ -303,6 +317,25 @@ class TTSService(ComfyBaseService):
logger.error("No audio file generated")
raise Exception("No audio file generated by workflow")
# If output_path provided and audio_path is URL, download to local
if output_path and audio_path.startswith(('http://', 'https://')):
import httpx
import os
# Ensure parent directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
logger.info(f"Downloading audio from {audio_path} to {output_path}")
async with httpx.AsyncClient() as client:
response = await client.get(audio_path)
response.raise_for_status()
with open(output_path, 'wb') as f:
f.write(response.content)
logger.info(f"✅ Generated audio (ComfyUI): {output_path}")
return output_path
logger.info(f"✅ Generated audio (ComfyUI): {audio_path}")
return audio_path

View File

@@ -56,7 +56,6 @@ class VideoGeneratorService:
n_scenes: int = 5, # Only used in generate mode; ignored in fixed mode
voice_id: str = "zh-CN-YunjianNeural",
output_path: Optional[str] = None,
use_uuid_filename: bool = False, # Use UUID instead of timestamp for filename
# === LLM Parameters ===
min_narration_words: int = 5,
@@ -186,25 +185,31 @@ class VideoGeneratorService:
final_title = await self.core.title_generator(text, strategy="llm")
logger.info(f" Title: '{final_title}' (LLM-generated)")
# Auto-generate output path if not provided
if output_path is None:
if use_uuid_filename:
# API mode: use UUID for filename
import uuid
filename = str(uuid.uuid4()).replace('-', '')
output_path = f"output/{filename}.mp4"
else:
# Default mode: use timestamp + title
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# Use first 10 chars of final_title for filename
safe_name = final_title[:10].replace('/', '_').replace(' ', '_')
output_path = f"output/{timestamp}_{safe_name}.mp4"
# ========== Step 0.5: Create isolated task directory ==========
from reelforge.utils.os_util import (
create_task_output_dir,
get_task_final_video_path
)
# Ensure output directory exists
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
# Create isolated task directory for this video generation
task_dir, task_id = create_task_output_dir()
logger.info(f"📁 Task directory created: {task_dir}")
logger.info(f" Task ID: {task_id}")
# Determine final video path
user_specified_output = None
if output_path is None:
# Use standardized path: output/{task_id}/final.mp4
output_path = get_task_final_video_path(task_id)
else:
# User specified custom path: save it and use task path for generation
user_specified_output = output_path
output_path = get_task_final_video_path(task_id)
logger.info(f" Will copy final video to: {user_specified_output}")
# Create storyboard config
config = StoryboardConfig(
task_id=task_id, # Pass task_id for file isolation
n_storyboard=n_scenes,
min_narration_words=min_narration_words,
max_narration_words=max_narration_words,
@@ -354,6 +359,16 @@ class VideoGeneratorService:
storyboard.final_video_path = final_video_path
storyboard.completed_at = datetime.now()
# Copy to user-specified path if provided
if user_specified_output:
import shutil
Path(user_specified_output).parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(final_video_path, user_specified_output)
logger.info(f"📹 Final video copied to: {user_specified_output}")
# Use user-specified path in result
final_video_path = user_specified_output
storyboard.final_video_path = user_specified_output
logger.success(f"🎬 Video generation completed: {final_video_path}")
# Step 6: Create result

View File

@@ -6,7 +6,10 @@ Inspired by Pixelle-MCP's os_util.py.
"""
import os
import random
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple, Literal
def get_reelforge_root_path() -> str:
@@ -23,24 +26,12 @@ def ensure_reelforge_root_path() -> str:
"""
Ensure ReelForge root path exists and return the path
Creates necessary directory structure if needed:
- temp/: for temporary files (audio, video, etc.)
- data/: for persistent data
- output/: for final output files
Returns:
Root path as string
"""
root_path = get_reelforge_root_path()
root_path_obj = Path(root_path)
# Create directory structure if needed
temp_dir = root_path_obj / 'temp'
data_dir = root_path_obj / 'data'
output_dir = root_path_obj / 'output'
temp_dir.mkdir(parents=True, exist_ok=True)
data_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents=True, exist_ok=True)
return root_path
@@ -165,3 +156,129 @@ def ensure_dir(path: str) -> str:
os.makedirs(path, exist_ok=True)
return os.path.abspath(path)
# ========== Task Directory Management ==========
def create_task_id() -> str:
"""
Create unique task ID with timestamp + random suffix
Format: {timestamp}_{random_hex}
Example: "20251028_143052_ab3d"
Collision probability: < 0.0001% (65536 combinations per second)
Returns:
Task ID string
"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
random_suffix = f"{random.randint(0, 0xFFFF):04x}" # 4-digit hex (0000-ffff)
return f"{timestamp}_{random_suffix}"
def create_task_output_dir(task_id: Optional[str] = None) -> Tuple[str, str]:
"""
Create isolated output directory for single video generation task
Directory structure:
output/{task_id}/
├── final.mp4 # Final video output
├── frames/ # All frame-related files
│ ├── 0_audio.mp3
│ ├── 0_image.png
│ ├── 0_composed.png
│ ├── 0_segment.mp4
│ └── ...
└── metadata.json # Optional: task metadata
Args:
task_id: Optional task ID (auto-generated if None)
Returns:
(task_dir, task_id) tuple
Example:
>>> task_dir, task_id = create_task_output_dir()
>>> # task_dir = "/path/to/project/output/20251028_143052_ab3d"
>>> # task_id = "20251028_143052_ab3d"
"""
if task_id is None:
task_id = create_task_id()
task_dir = get_output_path(task_id)
frames_dir = os.path.join(task_dir, "frames")
# Create directories
os.makedirs(frames_dir, exist_ok=True)
return task_dir, task_id
def get_task_path(task_id: str, *paths: str) -> str:
"""
Get path within task directory
Args:
task_id: Task ID
*paths: Path components to join
Returns:
Absolute path within task directory
Example:
>>> get_task_path("20251028_143052_ab3d", "final.mp4")
>>> # Returns: "/path/to/project/output/20251028_143052_ab3d/final.mp4"
"""
task_dir = get_output_path(task_id)
if paths:
return os.path.join(task_dir, *paths)
return task_dir
def get_task_frame_path(
task_id: str,
frame_index: int,
file_type: Literal["audio", "image", "composed", "segment"]
) -> str:
"""
Get frame file path within task directory
Args:
task_id: Task ID
frame_index: Frame index (0-based)
file_type: File type (audio/image/composed/segment)
Returns:
Absolute path to frame file
Example:
>>> get_task_frame_path("20251028_143052_ab3d", 0, "audio")
>>> # Returns: ".../output/20251028_143052_ab3d/frames/0_audio.mp3"
"""
ext_map = {
"audio": "mp3",
"image": "png",
"composed": "png",
"segment": "mp4"
}
filename = f"{frame_index}_{file_type}.{ext_map[file_type]}"
return get_task_path(task_id, "frames", filename)
def get_task_final_video_path(task_id: str) -> str:
"""
Get final video path within task directory
Args:
task_id: Task ID
Returns:
Absolute path to final video
Example:
>>> get_task_final_video_path("20251028_143052_ab3d")
>>> # Returns: ".../output/20251028_143052_ab3d/final.mp4"
"""
return get_task_path(task_id, "final.mp4")