Merge branch 'pr-413'
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user