diff --git a/api/routers/video.py b/api/routers/video.py index b67348c..de47937 100644 --- a/api/routers/video.py +++ b/api/routers/video.py @@ -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, diff --git a/reelforge/models/storyboard.py b/reelforge/models/storyboard.py index 9f237ef..0420683 100644 --- a/reelforge/models/storyboard.py +++ b/reelforge/models/storyboard.py @@ -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 diff --git a/reelforge/services/frame_html.py b/reelforge/services/frame_html.py index 92980ec..e1d5b84 100644 --- a/reelforge/services/frame_html.py +++ b/reelforge/services/frame_html.py @@ -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 diff --git a/reelforge/services/frame_processor.py b/reelforge/services/frame_processor.py index f298b9d..c61786e 100644 --- a/reelforge/services/frame_processor.py +++ b/reelforge/services/frame_processor.py @@ -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) diff --git a/reelforge/services/tts_service.py b/reelforge/services/tts_service.py index aec93e2..5f39c25 100644 --- a/reelforge/services/tts_service.py +++ b/reelforge/services/tts_service.py @@ -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 diff --git a/reelforge/services/video_generator.py b/reelforge/services/video_generator.py index bd80c9e..f017b30 100644 --- a/reelforge/services/video_generator.py +++ b/reelforge/services/video_generator.py @@ -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 diff --git a/reelforge/utils/os_util.py b/reelforge/utils/os_util.py index b3842f8..bd4c36e 100644 --- a/reelforge/utils/os_util.py +++ b/reelforge/utils/os_util.py @@ -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") +