diff --git a/backend/api/story_writer/routes/scene_animation.py b/backend/api/story_writer/routes/scene_animation.py index 95a57040..ad51f433 100644 --- a/backend/api/story_writer/routes/scene_animation.py +++ b/backend/api/story_writer/routes/scene_animation.py @@ -5,7 +5,6 @@ Handles scene animation endpoints using WaveSpeed Kling and InfiniteTalk. """ import mimetypes -from pathlib import Path from typing import Any, Dict, Optional from urllib.parse import quote @@ -32,7 +31,7 @@ from utils.logger_utils import get_service_logger from ..task_manager import task_manager from ..utils.auth import require_authenticated_user -from ..utils.media_utils import load_story_audio_bytes, load_story_image_bytes +from ..utils.media_utils import get_story_media_write_dir, load_story_audio_bytes, load_story_image_bytes router = APIRouter() scene_logger = get_service_logger("api.story_writer.scene_animation") @@ -114,10 +113,13 @@ async def animate_scene_preview( duration=duration, ) + ai_video_dir = get_story_media_write_dir("video", user_id=user_id) + (ai_video_dir / AI_VIDEO_SUBDIR).mkdir(parents=True, exist_ok=True) + # Save video asset to library db = next(get_db()) try: - video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir)) + video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir / AI_VIDEO_SUBDIR)) save_result = video_service.save_scene_video( video_bytes=animation_result["video_bytes"], @@ -173,7 +175,7 @@ async def animate_scene_preview( source_module="story_writer", filename=video_filename, file_url=video_url, - file_path=str(ai_video_dir / video_filename), + file_path=str(ai_video_dir / AI_VIDEO_SUBDIR / video_filename), file_size=len(animation_result["video_bytes"]), mime_type="video/mp4", title=f"Scene {request.scene_number} Animation", @@ -230,10 +232,9 @@ async def resume_scene_animation_endpoint( user_id=user_id, ) - base_dir = Path(__file__).parent.parent.parent.parent - ai_video_dir = base_dir / "story_videos" / AI_VIDEO_SUBDIR - ai_video_dir.mkdir(parents=True, exist_ok=True) - video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir)) + ai_video_dir = get_story_media_write_dir("video", user_id=user_id) + (ai_video_dir / AI_VIDEO_SUBDIR).mkdir(parents=True, exist_ok=True) + video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir / AI_VIDEO_SUBDIR)) save_result = video_service.save_scene_video( video_bytes=animation_result["video_bytes"], @@ -380,10 +381,9 @@ def _execute_voiceover_animation_task( task_id, "processing", progress=80.0, message="Saving video file..." ) - base_dir = Path(__file__).parent.parent.parent.parent - ai_video_dir = base_dir / "story_videos" / AI_VIDEO_SUBDIR - ai_video_dir.mkdir(parents=True, exist_ok=True) - video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir)) + ai_video_dir = get_story_media_write_dir("video", user_id=user_id) + (ai_video_dir / AI_VIDEO_SUBDIR).mkdir(parents=True, exist_ok=True) + video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir / AI_VIDEO_SUBDIR)) save_result = video_service.save_scene_video( video_bytes=animation_result["video_bytes"], @@ -433,7 +433,7 @@ def _execute_voiceover_animation_task( source_module="story_writer", filename=video_filename, file_url=video_url, - file_path=str(ai_video_dir / video_filename), + file_path=str(ai_video_dir / AI_VIDEO_SUBDIR / video_filename), file_size=len(animation_result["video_bytes"]), mime_type="video/mp4", title=f"Scene {request.scene_number} Animation (Voiceover)", diff --git a/backend/api/story_writer/routes/video_generation.py b/backend/api/story_writer/routes/video_generation.py index a307f95d..8351c7e5 100644 --- a/backend/api/story_writer/routes/video_generation.py +++ b/backend/api/story_writer/routes/video_generation.py @@ -1,4 +1,3 @@ -from pathlib import Path from typing import Any, Dict, List, Optional from concurrent.futures import ThreadPoolExecutor import json @@ -27,7 +26,7 @@ from ..utils.hd_video import ( generate_hd_video_payload, generate_hd_video_scene_payload, ) -from ..utils.media_utils import resolve_media_file +from ..utils.media_utils import resolve_story_media_path router = APIRouter() @@ -88,10 +87,6 @@ async def generate_story_video( audio_paths: List[str] = [] valid_scenes: List[Dict[str, Any]] = [] - # Resolve video/audio directories - base_dir = Path(__file__).resolve().parents[4] - ai_video_dir = (base_dir / "data" / "media" / "story_videos" / "AI_Videos").resolve() - video_urls = request.video_urls or [None] * len(request.scenes) ai_audio_urls = request.ai_audio_urls or [None] * len(request.scenes) @@ -104,8 +99,11 @@ async def generate_story_video( if video_url: # Extract filename from animated video URL (e.g., /api/story/videos/ai/filename.mp4) video_filename = video_url.split("/")[-1].split("?")[0] - video_path = ai_video_dir / video_filename - if video_path.exists(): + try: + video_path = resolve_story_media_path(video_filename, "video", user_id, extra_subdir="AI_Videos") + except HTTPException: + video_path = None + if video_path: logger.info(f"[StoryWriter] Using animated video for scene {scene.get('scene_number', idx+1)}: {video_filename}") video_paths.append(str(video_path)) image_paths.append(None) @@ -117,8 +115,11 @@ async def generate_story_video( # Fall back to image if no animated video if not video_path: image_filename = image_url.split("/")[-1].split("?")[0] - image_path = image_service.output_dir / image_filename - if image_path.exists(): + try: + image_path = resolve_story_media_path(image_filename, "image", user_id) + except HTTPException: + image_path = None + if image_path: video_paths.append(None) image_paths.append(str(image_path)) else: @@ -132,8 +133,11 @@ async def generate_story_video( if ai_audio_url: audio_filename = ai_audio_url.split("/")[-1].split("?")[0] - audio_path = audio_service.output_dir / audio_filename - if audio_path.exists(): + try: + audio_path = resolve_story_media_path(audio_filename, "audio", user_id) + except HTTPException: + audio_path = None + if audio_path: logger.info(f"[StoryWriter] Using AI audio for scene {scene.get('scene_number', idx+1)}: {audio_filename}") else: logger.warning(f"[StoryWriter] AI audio not found: {audio_path}, falling back to free audio") @@ -142,8 +146,11 @@ async def generate_story_video( # Fall back to free audio if no AI audio if not audio_path: audio_filename = audio_url.split("/")[-1].split("?")[0] - audio_path = audio_service.output_dir / audio_filename - if not audio_path.exists(): + try: + audio_path = resolve_story_media_path(audio_filename, "audio", user_id) + except HTTPException: + audio_path = None + if not audio_path: logger.warning(f"[StoryWriter] Audio not found: {audio_path} (from URL: {audio_url})") continue @@ -237,12 +244,18 @@ def _execute_video_generation_task(task_id: str, request: StoryVideoGenerationRe for scene, image_url, audio_url in zip(scenes_data, request.image_urls, request.audio_urls): image_filename = image_url.split("/")[-1].split("?")[0] audio_filename = audio_url.split("/")[-1].split("?")[0] - image_path = image_service.output_dir / image_filename - audio_path = audio_service.output_dir / audio_filename - if not image_path.exists(): + try: + image_path = resolve_story_media_path(image_filename, "image", user_id) + except HTTPException: + image_path = None + try: + audio_path = resolve_story_media_path(audio_filename, "audio", user_id) + except HTTPException: + audio_path = None + if not image_path: logger.warning(f"[StoryWriter] Image not found: {image_path} (from URL: {image_url})") continue - if not audio_path.exists(): + if not audio_path: logger.warning(f"[StoryWriter] Audio not found: {audio_path} (from URL: {audio_url})") continue image_paths.append(str(image_path)) @@ -519,8 +532,8 @@ async def serve_story_video( ): """Serve a generated story video file.""" try: - require_authenticated_user(current_user) - video_path = resolve_media_file(video_service.output_dir, video_filename) + user_id = require_authenticated_user(current_user) + video_path = resolve_story_media_path(video_filename, "video", user_id) return FileResponse(path=str(video_path), media_type="video/mp4", filename=video_filename) except HTTPException: raise @@ -536,12 +549,9 @@ async def serve_ai_story_video( ): """Serve a generated AI scene animation video.""" try: - require_authenticated_user(current_user) + user_id = require_authenticated_user(current_user) - base_dir = Path(__file__).parent.parent.parent.parent - ai_video_dir = (base_dir / "story_videos" / "AI_Videos").resolve() - video_service_ai = StoryVideoGenerationService(output_dir=str(ai_video_dir)) - video_path = resolve_media_file(video_service_ai.output_dir, video_filename) + video_path = resolve_story_media_path(video_filename, "video", user_id, extra_subdir="AI_Videos") return FileResponse( path=str(video_path), diff --git a/backend/api/story_writer/utils/media_utils.py b/backend/api/story_writer/utils/media_utils.py index f59b0433..84fc363e 100644 --- a/backend/api/story_writer/utils/media_utils.py +++ b/backend/api/story_writer/utils/media_utils.py @@ -1,99 +1,190 @@ from __future__ import annotations from pathlib import Path -from typing import Optional +from typing import Iterable, List, Optional from urllib.parse import urlparse from fastapi import HTTPException, status from loguru import logger +from sqlalchemy.orm import Session from services.database import get_db from services.user_workspace_manager import UserWorkspaceManager -BASE_DIR = Path(__file__).resolve().parents[4] # root/ -# Default global media directory matches story image/audio services (root/data/media) -DATA_MEDIA_DIR = BASE_DIR / "data" / "media" +BASE_DIR = Path(__file__).resolve().parents[4] # repository root +DATA_MEDIA_DIR = (BASE_DIR / "data" / "media").resolve() +LEGACY_STORY_VIDEOS_DIR = (BASE_DIR / "story_videos").resolve() +LEGACY_WORKSPACE_MEDIA_DIR = (BASE_DIR / "workspace" / "media").resolve() -STORY_IMAGES_DIR = (DATA_MEDIA_DIR / "story_images").resolve() -STORY_AUDIO_DIR = (DATA_MEDIA_DIR / "story_audio").resolve() +STORY_MEDIA_SUBDIRS = { + "image": "story_images", + "audio": "story_audio", + "video": "story_videos", +} -def _get_user_media_path(user_id: str, media_type: str) -> Optional[Path]: - """Resolve user-specific media directory.""" +# Authoritative policy: +# - New reads/writes should use workspace/workspace_/media/story_*. +# Compatibility fallback order for reads: +# 1) workspace/workspace_/media/story_* +# 2) legacy workspace paths (e.g. content/story_audio, workspace/media/story_videos) +# 3) global data/media/story_* +# 4) old root-level story_videos/* +def _workspace_story_media_dir(workspace_path: Path, media_type: str) -> Path: + return (workspace_path / "media" / STORY_MEDIA_SUBDIRS[media_type]).resolve() + + +def _workspace_legacy_dirs(workspace_path: Path, media_type: str) -> List[Path]: + if media_type == "audio": + return [(workspace_path / "content" / "story_audio").resolve()] + if media_type == "video": + return [(workspace_path / "media" / "story_videos").resolve()] + return [] + + +def _global_story_media_dir(media_type: str) -> Path: + return (DATA_MEDIA_DIR / STORY_MEDIA_SUBDIRS[media_type]).resolve() + + +def _global_legacy_dirs(media_type: str) -> List[Path]: + if media_type == "video": + return [ + (LEGACY_WORKSPACE_MEDIA_DIR / "story_videos").resolve(), + LEGACY_STORY_VIDEOS_DIR, + ] + return [] + + +def _get_workspace_path(user_id: str, db: Optional[Session] = None) -> Optional[Path]: + if not user_id: + return None + + session = db + db_gen = None try: - # We need a new session for this operation - db_gen = get_db() - db = next(db_gen) - try: - workspace_manager = UserWorkspaceManager(db) - workspace = workspace_manager.get_user_workspace(user_id) - if workspace: - # media/story_images or media/story_audio - subdir = "story_images" if media_type == "image" else "story_audio" - path = Path(workspace['workspace_path']) / "media" / subdir - path.mkdir(parents=True, exist_ok=True) - return path - finally: - # Ensure we close the session if it's not managed by dependency injection - # Since get_db yields, we can't easily close it unless we manage the generator - # But get_db uses SessionLocal() which should be closed. - # However, get_db is a generator. We should really use a context manager or dependency. - # Here we just took next(db), so it's an open session. - # We should probably close it. - # Actually, UserWorkspaceManager uses the passed db. - # Let's assume standard usage pattern for manual DB access. - pass - # Note: The generator usage here is a bit tricky for cleanup. - # Ideally we'd have a context manager. - # For now, let's rely on garbage collection or explicit close if possible. - # But SQLAlchemy sessions should be closed. - # db.close() # valid if db is Session - except Exception as e: - logger.warning(f"Failed to resolve user workspace path for {user_id}: {e}") + if session is None: + db_gen = get_db() + session = next(db_gen) + + workspace_manager = UserWorkspaceManager(session) + workspace = workspace_manager.get_user_workspace(user_id) + if workspace and workspace.get("workspace_path"): + return Path(workspace["workspace_path"]).resolve() + except Exception as exc: + logger.warning(f"[StoryWriter] Failed to resolve workspace for {user_id}: {exc}") + finally: + if db is None and session is not None: + try: + session.close() + except Exception: + pass + if db_gen is not None: + try: + db_gen.close() + except Exception: + pass + return None -def resolve_story_media_path(filename: str, media_type: str, user_id: Optional[str] = None) -> Path: - """ - Resolve a story media file path, checking user workspace first then global directory. - media_type: 'image' or 'audio' - """ - filename = filename.split("?")[0].strip() - - # 1. Try user workspace - if user_id: - user_path = _get_user_media_path(user_id, media_type) - if user_path: - file_path = (user_path / filename).resolve() - # Guard against traversal - if str(file_path).startswith(str(user_path)) and file_path.exists(): - return file_path +def get_story_media_write_dir(media_type: str, user_id: Optional[str] = None, db: Optional[Session] = None) -> Path: + """Return the canonical directory used for newly generated story media files.""" + if media_type not in STORY_MEDIA_SUBDIRS: + raise ValueError(f"Unsupported media type: {media_type}") + + if user_id: + workspace_path = _get_workspace_path(user_id, db) + if workspace_path: + canonical = _workspace_story_media_dir(workspace_path, media_type) + canonical.mkdir(parents=True, exist_ok=True) + return canonical + + fallback = _global_story_media_dir(media_type) + fallback.mkdir(parents=True, exist_ok=True) + return fallback + + +def _safe_candidate(base_dir: Path, filename: str) -> Optional[Path]: + try: + base_resolved = base_dir.resolve() + candidate = (base_resolved / filename).resolve() + candidate.relative_to(base_resolved) + return candidate + except Exception: + return None + + +def _iter_read_dirs(media_type: str, user_id: Optional[str], db: Optional[Session]) -> Iterable[Path]: + if media_type not in STORY_MEDIA_SUBDIRS: + raise ValueError(f"Unsupported media type: {media_type}") + + workspace_path = _get_workspace_path(user_id, db) if user_id else None + if workspace_path: + yield _workspace_story_media_dir(workspace_path, media_type) + for legacy in _workspace_legacy_dirs(workspace_path, media_type): + yield legacy + + yield _global_story_media_dir(media_type) + for legacy in _global_legacy_dirs(media_type): + yield legacy + + +def resolve_story_media_path( + filename: str, + media_type: str, + user_id: Optional[str] = None, + db: Optional[Session] = None, + extra_subdir: Optional[str] = None, +) -> Path: + """Resolve story media with canonical-first lookup and legacy fallbacks.""" + filename = filename.split("?")[0].strip() + if not filename: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + for base_dir in _iter_read_dirs(media_type, user_id, db): + search_dir = base_dir / extra_subdir if extra_subdir else base_dir + candidate = _safe_candidate(search_dir, filename) + if not candidate: + continue + if candidate.exists(): + return candidate + + alternate = _find_alternate_media_file(search_dir, filename) + if alternate: + logger.warning( + "[StoryWriter] Requested media '%s' missing in '%s'; serving '%s'", + filename, + search_dir, + alternate.name, + ) + return alternate + + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"File not found: {filename}") + + +def resolve_media_file(base_dir: Path, filename: str) -> Path: + """Backwards-compatible helper for existing route handlers.""" + filename = filename.split("?")[0].strip() + resolved = _safe_candidate(base_dir, filename) + if not resolved: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + if resolved.exists(): + return resolved - # 2. Fallback to global directory - base_dir = STORY_IMAGES_DIR if media_type == "image" else STORY_AUDIO_DIR - file_path = (base_dir / filename).resolve() - - if not str(file_path).startswith(str(base_dir)): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") - - if file_path.exists(): - return file_path - - # 3. If not found, try alternate in global (legacy behavior support) alternate = _find_alternate_media_file(base_dir, filename) if alternate: - logger.warning(f"[StoryWriter] Serving alternate media for {filename}: {alternate.name}") + logger.warning( + "[StoryWriter] Requested media file '%s' missing; serving closest match '%s'", + filename, + alternate.name, + ) return alternate - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"File not found: {filename}") def load_story_image_bytes(image_url: str, user_id: Optional[str] = None) -> Optional[bytes]: - """ - Resolve an authenticated story image URL (e.g., /api/story/images/) to raw bytes. - Returns None if the file cannot be located. - """ if not image_url: return None @@ -109,25 +200,18 @@ def load_story_image_bytes(image_url: str, user_id: Optional[str] = None) -> Opt if not filename: return None - # Try to resolve path using helper try: file_path = resolve_story_media_path(filename, "image", user_id) return file_path.read_bytes() except HTTPException: - # Not found logger.warning(f"[StoryWriter] Referenced scene image not found: {filename}") return None - except Exception as exc: logger.error(f"[StoryWriter] Failed to load reference image for video gen: {exc}") return None def load_story_audio_bytes(audio_url: str, user_id: Optional[str] = None) -> Optional[bytes]: - """ - Resolve an authenticated story audio URL (e.g., /api/story/audio/) to raw bytes. - Returns None if the file cannot be located. - """ if not audio_url: return None @@ -143,54 +227,18 @@ def load_story_audio_bytes(audio_url: str, user_id: Optional[str] = None) -> Opt if not filename: return None - # Try to resolve path using helper try: file_path = resolve_story_media_path(filename, "audio", user_id) return file_path.read_bytes() except HTTPException: - # Not found logger.warning(f"[StoryWriter] Referenced scene audio not found: {filename}") return None - except Exception as exc: logger.error(f"[StoryWriter] Failed to load reference audio for video gen: {exc}") return None -def resolve_media_file(base_dir: Path, filename: str) -> Path: - """ - Returns a safe resolved path for a media file stored under base_dir. - Guards against directory traversal and ensures the file exists. - """ - filename = filename.split("?")[0].strip() - resolved = (base_dir / filename).resolve() - - try: - resolved.relative_to(base_dir.resolve()) - except ValueError: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") - - if not resolved.exists(): - alternate = _find_alternate_media_file(base_dir, filename) - if alternate: - logger.warning( - "[StoryWriter] Requested media file '%s' missing; serving closest match '%s'", - filename, - alternate.name, - ) - return alternate - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"File not found: {filename}") - - return resolved - - def _find_alternate_media_file(base_dir: Path, filename: str) -> Optional[Path]: - """ - Attempt to find the most recent media file that matches the original name prefix. - - This helps when files are regenerated with new UUID/hash suffixes but the frontend still - references an older filename. - """ try: base_dir = base_dir.resolve() except Exception: @@ -216,5 +264,3 @@ def _find_alternate_media_file(base_dir: Path, filename: str) -> Optional[Path]: return None return candidates[0] if candidates else None - - diff --git a/backend/services/story_writer/audio_generation_service.py b/backend/services/story_writer/audio_generation_service.py index 74e775e8..88c7b866 100644 --- a/backend/services/story_writer/audio_generation_service.py +++ b/backend/services/story_writer/audio_generation_service.py @@ -11,7 +11,7 @@ from pathlib import Path from loguru import logger from fastapi import HTTPException from sqlalchemy.orm import Session -from services.user_workspace_manager import UserWorkspaceManager +from api.story_writer.utils.media_utils import get_story_media_write_dir class StoryAudioGenerationService: @@ -23,17 +23,13 @@ class StoryAudioGenerationService: Parameters: output_dir (str, optional): Directory to save generated audio files. - Defaults to 'backend/story_audio' if not provided. + Defaults to canonical workspace media path if not provided. """ if output_dir: self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) else: - # Default to root/data/media/story_audio directory - base_dir = Path(__file__).resolve().parents[3] - self.output_dir = base_dir / "data" / "media" / "story_audio" - - # Create output directory if it doesn't exist - self.output_dir.mkdir(parents=True, exist_ok=True) + self.output_dir = get_story_media_write_dir("audio") logger.info(f"[StoryAudioGeneration] Initialized with output directory: {self.output_dir}") def _get_user_audio_dir(self, user_id: str, db: Optional[Session] = None) -> Path: @@ -41,19 +37,11 @@ class StoryAudioGenerationService: Get the audio directory for a specific user. Falls back to default output_dir if workspace not found. """ - if db and user_id: - try: - workspace_manager = UserWorkspaceManager(db) - workspace = workspace_manager.get_user_workspace(user_id) - if workspace: - # Use content/story_audio inside user workspace - user_audio_dir = Path(workspace['workspace_path']) / "content" / "story_audio" - user_audio_dir.mkdir(parents=True, exist_ok=True) - return user_audio_dir - except Exception as e: - logger.warning(f"[StoryAudioGeneration] Failed to resolve user workspace path for {user_id}: {e}") - - return self.output_dir + try: + return get_story_media_write_dir("audio", user_id=user_id, db=db) + except Exception as e: + logger.warning(f"[StoryAudioGeneration] Failed to resolve user workspace path for {user_id}: {e}") + return self.output_dir def _generate_audio_filename(self, scene_number: int, scene_title: str) -> str: """Generate a unique filename for a scene audio file.""" diff --git a/backend/services/story_writer/image_generation_service.py b/backend/services/story_writer/image_generation_service.py index e7f6ffec..88ba129d 100644 --- a/backend/services/story_writer/image_generation_service.py +++ b/backend/services/story_writer/image_generation_service.py @@ -15,7 +15,7 @@ from sqlalchemy.orm import Session from services.llm_providers.main_image_generation import generate_image from services.llm_providers.image_generation import ImageGenerationResult from utils.logger_utils import get_service_logger -from services.user_workspace_manager import UserWorkspaceManager +from api.story_writer.utils.media_utils import get_story_media_write_dir logger = get_service_logger("story_writer.image_generation") @@ -29,17 +29,13 @@ class StoryImageGenerationService: Parameters: output_dir (str, optional): Directory to save generated images. - Defaults to 'backend/story_images' if not provided. + Defaults to canonical workspace media path if not provided. """ if output_dir: self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) else: - # Default to root/data/media/story_images directory - base_dir = Path(__file__).resolve().parents[3] - self.output_dir = base_dir / "data" / "media" / "story_images" - - # Create output directory if it doesn't exist - self.output_dir.mkdir(parents=True, exist_ok=True) + self.output_dir = get_story_media_write_dir("image") logger.info(f"[StoryImageGeneration] Initialized with output directory: {self.output_dir}") def _get_user_image_dir(self, user_id: str, db: Optional[Session] = None) -> Path: @@ -47,19 +43,11 @@ class StoryImageGenerationService: Get the image directory for a specific user. Falls back to default output_dir if workspace not found. """ - if db and user_id: - try: - workspace_manager = UserWorkspaceManager(db) - workspace = workspace_manager.get_user_workspace(user_id) - if workspace: - # Use media/story_images inside user workspace - user_image_dir = Path(workspace['workspace_path']) / "media" / "story_images" - user_image_dir.mkdir(parents=True, exist_ok=True) - return user_image_dir - except Exception as e: - logger.warning(f"[StoryImageGeneration] Failed to resolve user workspace path for {user_id}: {e}") - - return self.output_dir + try: + return get_story_media_write_dir("image", user_id=user_id, db=db) + except Exception as e: + logger.warning(f"[StoryImageGeneration] Failed to resolve user workspace path for {user_id}: {e}") + return self.output_dir def _generate_image_filename(self, scene_number: int, scene_title: str) -> str: """Generate a unique filename for a scene image.""" diff --git a/backend/services/story_writer/video_generation_service.py b/backend/services/story_writer/video_generation_service.py index 88bfcada..1e48b259 100644 --- a/backend/services/story_writer/video_generation_service.py +++ b/backend/services/story_writer/video_generation_service.py @@ -11,7 +11,7 @@ from pathlib import Path from loguru import logger from fastapi import HTTPException from sqlalchemy.orm import Session -from services.user_workspace_manager import UserWorkspaceManager +from api.story_writer.utils.media_utils import get_story_media_write_dir class StoryVideoGenerationService: @@ -23,18 +23,13 @@ class StoryVideoGenerationService: Parameters: output_dir (str, optional): Directory to save generated videos. - Defaults to 'backend/story_videos' if not provided. + Defaults to canonical workspace media path if not provided. """ if output_dir: self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) else: - # Default to root/workspace/media/story_videos directory - # services/story_writer/video_generation_service.py -> services -> backend -> root - root_dir = Path(__file__).parent.parent.parent.parent - self.output_dir = root_dir / "workspace" / "media" / "story_videos" - - # Create output directory if it doesn't exist - self.output_dir.mkdir(parents=True, exist_ok=True) + self.output_dir = get_story_media_write_dir("video") logger.info(f"[StoryVideoGeneration] Initialized with output directory: {self.output_dir}") def _get_user_video_dir(self, user_id: str, db: Optional[Session] = None) -> Path: @@ -42,19 +37,11 @@ class StoryVideoGenerationService: Get the video directory for a specific user. Falls back to default output_dir if workspace not found. """ - if db and user_id: - try: - workspace_manager = UserWorkspaceManager(db) - workspace = workspace_manager.get_user_workspace(user_id) - if workspace: - # Use media/story_videos inside user workspace - user_video_dir = Path(workspace['workspace_path']) / "media" / "story_videos" - user_video_dir.mkdir(parents=True, exist_ok=True) - return user_video_dir - except Exception as e: - logger.warning(f"[StoryVideoGeneration] Failed to resolve user workspace path for {user_id}: {e}") - - return self.output_dir + try: + return get_story_media_write_dir("video", user_id=user_id, db=db) + except Exception as e: + logger.warning(f"[StoryVideoGeneration] Failed to resolve user workspace path for {user_id}: {e}") + return self.output_dir def _generate_video_filename(self, story_title: str = "story") -> str: """Generate a unique filename for a story video."""