Merge branch 'pr-413'

This commit is contained in:
ajaysi
2026-03-12 15:46:43 +05:30
6 changed files with 235 additions and 216 deletions

View File

@@ -5,7 +5,6 @@ Handles scene animation endpoints using WaveSpeed Kling and InfiniteTalk.
""" """
import mimetypes import mimetypes
from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from urllib.parse import quote from urllib.parse import quote
@@ -32,7 +31,7 @@ from utils.logger_utils import get_service_logger
from ..task_manager import task_manager from ..task_manager import task_manager
from ..utils.auth import require_authenticated_user 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() router = APIRouter()
scene_logger = get_service_logger("api.story_writer.scene_animation") scene_logger = get_service_logger("api.story_writer.scene_animation")
@@ -114,10 +113,13 @@ async def animate_scene_preview(
duration=duration, 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 # Save video asset to library
db = next(get_db()) db = next(get_db())
try: 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( save_result = video_service.save_scene_video(
video_bytes=animation_result["video_bytes"], video_bytes=animation_result["video_bytes"],
@@ -173,7 +175,7 @@ async def animate_scene_preview(
source_module="story_writer", source_module="story_writer",
filename=video_filename, filename=video_filename,
file_url=video_url, 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"]), file_size=len(animation_result["video_bytes"]),
mime_type="video/mp4", mime_type="video/mp4",
title=f"Scene {request.scene_number} Animation", title=f"Scene {request.scene_number} Animation",
@@ -230,10 +232,9 @@ async def resume_scene_animation_endpoint(
user_id=user_id, user_id=user_id,
) )
base_dir = Path(__file__).parent.parent.parent.parent ai_video_dir = get_story_media_write_dir("video", user_id=user_id)
ai_video_dir = base_dir / "story_videos" / AI_VIDEO_SUBDIR (ai_video_dir / AI_VIDEO_SUBDIR).mkdir(parents=True, exist_ok=True)
ai_video_dir.mkdir(parents=True, exist_ok=True) video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir / AI_VIDEO_SUBDIR))
video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir))
save_result = video_service.save_scene_video( save_result = video_service.save_scene_video(
video_bytes=animation_result["video_bytes"], 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..." task_id, "processing", progress=80.0, message="Saving video file..."
) )
base_dir = Path(__file__).parent.parent.parent.parent ai_video_dir = get_story_media_write_dir("video", user_id=user_id)
ai_video_dir = base_dir / "story_videos" / AI_VIDEO_SUBDIR (ai_video_dir / AI_VIDEO_SUBDIR).mkdir(parents=True, exist_ok=True)
ai_video_dir.mkdir(parents=True, exist_ok=True) video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir / AI_VIDEO_SUBDIR))
video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir))
save_result = video_service.save_scene_video( save_result = video_service.save_scene_video(
video_bytes=animation_result["video_bytes"], video_bytes=animation_result["video_bytes"],
@@ -433,7 +433,7 @@ def _execute_voiceover_animation_task(
source_module="story_writer", source_module="story_writer",
filename=video_filename, filename=video_filename,
file_url=video_url, 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"]), file_size=len(animation_result["video_bytes"]),
mime_type="video/mp4", mime_type="video/mp4",
title=f"Scene {request.scene_number} Animation (Voiceover)", title=f"Scene {request.scene_number} Animation (Voiceover)",

View File

@@ -1,4 +1,3 @@
from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import json import json
@@ -27,7 +26,7 @@ from ..utils.hd_video import (
generate_hd_video_payload, generate_hd_video_payload,
generate_hd_video_scene_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() router = APIRouter()
@@ -88,10 +87,6 @@ async def generate_story_video(
audio_paths: List[str] = [] audio_paths: List[str] = []
valid_scenes: List[Dict[str, Any]] = [] 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) video_urls = request.video_urls or [None] * len(request.scenes)
ai_audio_urls = request.ai_audio_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: if video_url:
# Extract filename from animated video URL (e.g., /api/story/videos/ai/filename.mp4) # Extract filename from animated video URL (e.g., /api/story/videos/ai/filename.mp4)
video_filename = video_url.split("/")[-1].split("?")[0] video_filename = video_url.split("/")[-1].split("?")[0]
video_path = ai_video_dir / video_filename try:
if video_path.exists(): 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}") logger.info(f"[StoryWriter] Using animated video for scene {scene.get('scene_number', idx+1)}: {video_filename}")
video_paths.append(str(video_path)) video_paths.append(str(video_path))
image_paths.append(None) image_paths.append(None)
@@ -117,8 +115,11 @@ async def generate_story_video(
# Fall back to image if no animated video # Fall back to image if no animated video
if not video_path: if not video_path:
image_filename = image_url.split("/")[-1].split("?")[0] image_filename = image_url.split("/")[-1].split("?")[0]
image_path = image_service.output_dir / image_filename try:
if image_path.exists(): image_path = resolve_story_media_path(image_filename, "image", user_id)
except HTTPException:
image_path = None
if image_path:
video_paths.append(None) video_paths.append(None)
image_paths.append(str(image_path)) image_paths.append(str(image_path))
else: else:
@@ -132,8 +133,11 @@ async def generate_story_video(
if ai_audio_url: if ai_audio_url:
audio_filename = ai_audio_url.split("/")[-1].split("?")[0] audio_filename = ai_audio_url.split("/")[-1].split("?")[0]
audio_path = audio_service.output_dir / audio_filename try:
if audio_path.exists(): 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}") logger.info(f"[StoryWriter] Using AI audio for scene {scene.get('scene_number', idx+1)}: {audio_filename}")
else: else:
logger.warning(f"[StoryWriter] AI audio not found: {audio_path}, falling back to free audio") 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 # Fall back to free audio if no AI audio
if not audio_path: if not audio_path:
audio_filename = audio_url.split("/")[-1].split("?")[0] audio_filename = audio_url.split("/")[-1].split("?")[0]
audio_path = audio_service.output_dir / audio_filename try:
if not audio_path.exists(): 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})") logger.warning(f"[StoryWriter] Audio not found: {audio_path} (from URL: {audio_url})")
continue 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): for scene, image_url, audio_url in zip(scenes_data, request.image_urls, request.audio_urls):
image_filename = image_url.split("/")[-1].split("?")[0] image_filename = image_url.split("/")[-1].split("?")[0]
audio_filename = audio_url.split("/")[-1].split("?")[0] audio_filename = audio_url.split("/")[-1].split("?")[0]
image_path = image_service.output_dir / image_filename try:
audio_path = audio_service.output_dir / audio_filename image_path = resolve_story_media_path(image_filename, "image", user_id)
if not image_path.exists(): 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})") logger.warning(f"[StoryWriter] Image not found: {image_path} (from URL: {image_url})")
continue continue
if not audio_path.exists(): if not audio_path:
logger.warning(f"[StoryWriter] Audio not found: {audio_path} (from URL: {audio_url})") logger.warning(f"[StoryWriter] Audio not found: {audio_path} (from URL: {audio_url})")
continue continue
image_paths.append(str(image_path)) image_paths.append(str(image_path))
@@ -519,8 +532,8 @@ async def serve_story_video(
): ):
"""Serve a generated story video file.""" """Serve a generated story video file."""
try: try:
require_authenticated_user(current_user) user_id = require_authenticated_user(current_user)
video_path = resolve_media_file(video_service.output_dir, video_filename) video_path = resolve_story_media_path(video_filename, "video", user_id)
return FileResponse(path=str(video_path), media_type="video/mp4", filename=video_filename) return FileResponse(path=str(video_path), media_type="video/mp4", filename=video_filename)
except HTTPException: except HTTPException:
raise raise
@@ -536,12 +549,9 @@ async def serve_ai_story_video(
): ):
"""Serve a generated AI scene animation video.""" """Serve a generated AI scene animation video."""
try: try:
require_authenticated_user(current_user) user_id = require_authenticated_user(current_user)
base_dir = Path(__file__).parent.parent.parent.parent video_path = resolve_story_media_path(video_filename, "video", user_id, extra_subdir="AI_Videos")
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)
return FileResponse( return FileResponse(
path=str(video_path), path=str(video_path),

View File

@@ -1,99 +1,190 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Iterable, List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import HTTPException, status from fastapi import HTTPException, status
from loguru import logger from loguru import logger
from sqlalchemy.orm import Session
from services.database import get_db from services.database import get_db
from services.user_workspace_manager import UserWorkspaceManager from services.user_workspace_manager import UserWorkspaceManager
BASE_DIR = Path(__file__).resolve().parents[4] # root/ BASE_DIR = Path(__file__).resolve().parents[4] # repository root
# Default global media directory matches story image/audio services (root/data/media) DATA_MEDIA_DIR = (BASE_DIR / "data" / "media").resolve()
DATA_MEDIA_DIR = BASE_DIR / "data" / "media" 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_MEDIA_SUBDIRS = {
STORY_AUDIO_DIR = (DATA_MEDIA_DIR / "story_audio").resolve() "image": "story_images",
"audio": "story_audio",
"video": "story_videos",
}
def _get_user_media_path(user_id: str, media_type: str) -> Optional[Path]: # Authoritative policy:
"""Resolve user-specific media directory.""" # - New reads/writes should use workspace/workspace_<id>/media/story_*.
# Compatibility fallback order for reads:
# 1) workspace/workspace_<id>/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: try:
# We need a new session for this operation if session is None:
db_gen = get_db() db_gen = get_db()
db = next(db_gen) session = next(db_gen)
try:
workspace_manager = UserWorkspaceManager(db) workspace_manager = UserWorkspaceManager(session)
workspace = workspace_manager.get_user_workspace(user_id) workspace = workspace_manager.get_user_workspace(user_id)
if workspace: if workspace and workspace.get("workspace_path"):
# media/story_images or media/story_audio return Path(workspace["workspace_path"]).resolve()
subdir = "story_images" if media_type == "image" else "story_audio" except Exception as exc:
path = Path(workspace['workspace_path']) / "media" / subdir logger.warning(f"[StoryWriter] Failed to resolve workspace for {user_id}: {exc}")
path.mkdir(parents=True, exist_ok=True) finally:
return path if db is None and session is not None:
finally: try:
# Ensure we close the session if it's not managed by dependency injection session.close()
# Since get_db yields, we can't easily close it unless we manage the generator except Exception:
# But get_db uses SessionLocal() which should be closed. pass
# However, get_db is a generator. We should really use a context manager or dependency. if db_gen is not None:
# Here we just took next(db), so it's an open session. try:
# We should probably close it. db_gen.close()
# Actually, UserWorkspaceManager uses the passed db. except Exception:
# Let's assume standard usage pattern for manual DB access. pass
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}")
return None return None
def resolve_story_media_path(filename: str, media_type: str, user_id: Optional[str] = None) -> 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."""
Resolve a story media file path, checking user workspace first then global directory. if media_type not in STORY_MEDIA_SUBDIRS:
media_type: 'image' or 'audio' raise ValueError(f"Unsupported media type: {media_type}")
"""
filename = filename.split("?")[0].strip()
# 1. Try user workspace
if user_id: if user_id:
user_path = _get_user_media_path(user_id, media_type) workspace_path = _get_workspace_path(user_id, db)
if user_path: if workspace_path:
file_path = (user_path / filename).resolve() canonical = _workspace_story_media_dir(workspace_path, media_type)
# Guard against traversal canonical.mkdir(parents=True, exist_ok=True)
if str(file_path).startswith(str(user_path)) and file_path.exists(): return canonical
return file_path
# 2. Fallback to global directory fallback = _global_story_media_dir(media_type)
base_dir = STORY_IMAGES_DIR if media_type == "image" else STORY_AUDIO_DIR fallback.mkdir(parents=True, exist_ok=True)
file_path = (base_dir / filename).resolve() return fallback
if not str(file_path).startswith(str(base_dir)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
if file_path.exists(): def _safe_candidate(base_dir: Path, filename: str) -> Optional[Path]:
return file_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
# 3. If not found, try alternate in global (legacy behavior support)
alternate = _find_alternate_media_file(base_dir, filename) alternate = _find_alternate_media_file(base_dir, filename)
if alternate: 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 return alternate
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"File not found: {filename}") 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]: 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/<file>) to raw bytes.
Returns None if the file cannot be located.
"""
if not image_url: if not image_url:
return None return None
@@ -109,25 +200,18 @@ def load_story_image_bytes(image_url: str, user_id: Optional[str] = None) -> Opt
if not filename: if not filename:
return None return None
# Try to resolve path using helper
try: try:
file_path = resolve_story_media_path(filename, "image", user_id) file_path = resolve_story_media_path(filename, "image", user_id)
return file_path.read_bytes() return file_path.read_bytes()
except HTTPException: except HTTPException:
# Not found
logger.warning(f"[StoryWriter] Referenced scene image not found: {filename}") logger.warning(f"[StoryWriter] Referenced scene image not found: {filename}")
return None return None
except Exception as exc: except Exception as exc:
logger.error(f"[StoryWriter] Failed to load reference image for video gen: {exc}") logger.error(f"[StoryWriter] Failed to load reference image for video gen: {exc}")
return None return None
def load_story_audio_bytes(audio_url: str, user_id: Optional[str] = None) -> Optional[bytes]: 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/<file>) to raw bytes.
Returns None if the file cannot be located.
"""
if not audio_url: if not audio_url:
return None return None
@@ -143,54 +227,18 @@ def load_story_audio_bytes(audio_url: str, user_id: Optional[str] = None) -> Opt
if not filename: if not filename:
return None return None
# Try to resolve path using helper
try: try:
file_path = resolve_story_media_path(filename, "audio", user_id) file_path = resolve_story_media_path(filename, "audio", user_id)
return file_path.read_bytes() return file_path.read_bytes()
except HTTPException: except HTTPException:
# Not found
logger.warning(f"[StoryWriter] Referenced scene audio not found: {filename}") logger.warning(f"[StoryWriter] Referenced scene audio not found: {filename}")
return None return None
except Exception as exc: except Exception as exc:
logger.error(f"[StoryWriter] Failed to load reference audio for video gen: {exc}") logger.error(f"[StoryWriter] Failed to load reference audio for video gen: {exc}")
return None 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]: 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: try:
base_dir = base_dir.resolve() base_dir = base_dir.resolve()
except Exception: except Exception:
@@ -216,5 +264,3 @@ def _find_alternate_media_file(base_dir: Path, filename: str) -> Optional[Path]:
return None return None
return candidates[0] if candidates else None return candidates[0] if candidates else None

View File

@@ -11,7 +11,7 @@ from pathlib import Path
from loguru import logger from loguru import logger
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session 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: class StoryAudioGenerationService:
@@ -23,17 +23,13 @@ class StoryAudioGenerationService:
Parameters: Parameters:
output_dir (str, optional): Directory to save generated audio files. 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: if output_dir:
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
else: else:
# Default to root/data/media/story_audio directory self.output_dir = get_story_media_write_dir("audio")
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)
logger.info(f"[StoryAudioGeneration] Initialized with output directory: {self.output_dir}") 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: 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. Get the audio directory for a specific user.
Falls back to default output_dir if workspace not found. Falls back to default output_dir if workspace not found.
""" """
if db and user_id: try:
try: return get_story_media_write_dir("audio", user_id=user_id, db=db)
workspace_manager = UserWorkspaceManager(db) except Exception as e:
workspace = workspace_manager.get_user_workspace(user_id) logger.warning(f"[StoryAudioGeneration] Failed to resolve user workspace path for {user_id}: {e}")
if workspace: return self.output_dir
# 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
def _generate_audio_filename(self, scene_number: int, scene_title: str) -> str: def _generate_audio_filename(self, scene_number: int, scene_title: str) -> str:
"""Generate a unique filename for a scene audio file.""" """Generate a unique filename for a scene audio file."""

View File

@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
from services.llm_providers.main_image_generation import generate_image from services.llm_providers.main_image_generation import generate_image
from services.llm_providers.image_generation import ImageGenerationResult from services.llm_providers.image_generation import ImageGenerationResult
from utils.logger_utils import get_service_logger 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") logger = get_service_logger("story_writer.image_generation")
@@ -29,17 +29,13 @@ class StoryImageGenerationService:
Parameters: Parameters:
output_dir (str, optional): Directory to save generated images. 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: if output_dir:
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
else: else:
# Default to root/data/media/story_images directory self.output_dir = get_story_media_write_dir("image")
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)
logger.info(f"[StoryImageGeneration] Initialized with output directory: {self.output_dir}") 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: 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. Get the image directory for a specific user.
Falls back to default output_dir if workspace not found. Falls back to default output_dir if workspace not found.
""" """
if db and user_id: try:
try: return get_story_media_write_dir("image", user_id=user_id, db=db)
workspace_manager = UserWorkspaceManager(db) except Exception as e:
workspace = workspace_manager.get_user_workspace(user_id) logger.warning(f"[StoryImageGeneration] Failed to resolve user workspace path for {user_id}: {e}")
if workspace: return self.output_dir
# 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
def _generate_image_filename(self, scene_number: int, scene_title: str) -> str: def _generate_image_filename(self, scene_number: int, scene_title: str) -> str:
"""Generate a unique filename for a scene image.""" """Generate a unique filename for a scene image."""

View File

@@ -11,7 +11,7 @@ from pathlib import Path
from loguru import logger from loguru import logger
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session 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: class StoryVideoGenerationService:
@@ -23,18 +23,13 @@ class StoryVideoGenerationService:
Parameters: Parameters:
output_dir (str, optional): Directory to save generated videos. 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: if output_dir:
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
else: else:
# Default to root/workspace/media/story_videos directory self.output_dir = get_story_media_write_dir("video")
# 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)
logger.info(f"[StoryVideoGeneration] Initialized with output directory: {self.output_dir}") 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: 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. Get the video directory for a specific user.
Falls back to default output_dir if workspace not found. Falls back to default output_dir if workspace not found.
""" """
if db and user_id: try:
try: return get_story_media_write_dir("video", user_id=user_id, db=db)
workspace_manager = UserWorkspaceManager(db) except Exception as e:
workspace = workspace_manager.get_user_workspace(user_id) logger.warning(f"[StoryVideoGeneration] Failed to resolve user workspace path for {user_id}: {e}")
if workspace: return self.output_dir
# 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
def _generate_video_filename(self, story_title: str = "story") -> str: def _generate_video_filename(self, story_title: str = "story") -> str:
"""Generate a unique filename for a story video.""" """Generate a unique filename for a story video."""