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
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)",

View File

@@ -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),

View File

@@ -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_<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:
# 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/<file>) 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/<file>) 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