Merge branch 'pr-413'
This commit is contained in:
@@ -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)",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
if user_id:
|
||||||
|
workspace_path = _get_workspace_path(user_id, db)
|
||||||
# 1. Try user workspace
|
if workspace_path:
|
||||||
if user_id:
|
canonical = _workspace_story_media_dir(workspace_path, media_type)
|
||||||
user_path = _get_user_media_path(user_id, media_type)
|
canonical.mkdir(parents=True, exist_ok=True)
|
||||||
if user_path:
|
return canonical
|
||||||
file_path = (user_path / filename).resolve()
|
|
||||||
# Guard against traversal
|
fallback = _global_story_media_dir(media_type)
|
||||||
if str(file_path).startswith(str(user_path)) and file_path.exists():
|
fallback.mkdir(parents=True, exist_ok=True)
|
||||||
return file_path
|
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)
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user