AI story writer enhancements, text to video and voice generation, subscription management, and more.
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from uuid import uuid4
|
||||
|
||||
from .media_utils import load_story_image_bytes
|
||||
|
||||
|
||||
def generate_hd_video_payload(request: Any, user_id: str) -> Dict[str, Any]:
|
||||
"""Handles synchronous HD video generation."""
|
||||
@@ -57,8 +55,8 @@ def generate_hd_video_payload(request: Any, user_id: str) -> Dict[str, Any]:
|
||||
|
||||
def generate_hd_video_scene_payload(request: Any, user_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Handles per-scene HD video generation including prompt enhancement,
|
||||
subscription validation, and optional image conditioning.
|
||||
Handles per-scene HD video generation including prompt enhancement
|
||||
and subscription validation.
|
||||
"""
|
||||
from services.database import get_db as get_db_validation
|
||||
from services.onboarding.api_key_manager import APIKeyManager
|
||||
@@ -71,7 +69,6 @@ def generate_hd_video_scene_payload(request: Any, user_id: str) -> Dict[str, Any
|
||||
scene_number = request.scene_number
|
||||
logger.info(f"[StoryWriter] Generating HD video for scene {scene_number} for user {user_id}")
|
||||
|
||||
# Step 1: Validate API key
|
||||
hf_token = APIKeyManager().get_api_key("hf_token")
|
||||
if not hf_token:
|
||||
logger.error("[StoryWriter] Pre-flight: HF token not configured - blocking video generation")
|
||||
@@ -83,7 +80,6 @@ def generate_hd_video_scene_payload(request: Any, user_id: str) -> Dict[str, Any
|
||||
},
|
||||
)
|
||||
|
||||
# Step 2: Subscription limits
|
||||
db_validation = next(get_db_validation())
|
||||
try:
|
||||
pricing_service = PricingService(db_validation)
|
||||
@@ -93,7 +89,6 @@ def generate_hd_video_scene_payload(request: Any, user_id: str) -> Dict[str, Any
|
||||
finally:
|
||||
db_validation.close()
|
||||
|
||||
# Stage 1: Prompt enhancement
|
||||
enhanced_prompt = enhance_scene_prompt_for_video(
|
||||
current_scene=request.scene_data,
|
||||
story_context=request.story_context,
|
||||
@@ -102,15 +97,6 @@ def generate_hd_video_scene_payload(request: Any, user_id: str) -> Dict[str, Any
|
||||
)
|
||||
logger.info(f"[StoryWriter] Generated enhanced prompt ({len(enhanced_prompt)} chars) for scene {scene_number}")
|
||||
|
||||
# Stage 2: Optional image reference
|
||||
scene_image_bytes: Optional[bytes] = None
|
||||
if getattr(request, "scene_image_url", None):
|
||||
scene_image_bytes = load_story_image_bytes(request.scene_image_url)
|
||||
if scene_image_bytes:
|
||||
logger.info(f"[StoryWriter] Using scene image reference for scene {scene_number}")
|
||||
else:
|
||||
logger.warning(f"[StoryWriter] Scene image could not be loaded for scene {scene_number}, falling back to text-only video")
|
||||
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if getattr(request, "model", None):
|
||||
kwargs["model"] = request.model
|
||||
@@ -129,7 +115,6 @@ def generate_hd_video_scene_payload(request: Any, user_id: str) -> Dict[str, Any
|
||||
prompt=enhanced_prompt,
|
||||
provider=getattr(request, "provider", None) or "huggingface",
|
||||
user_id=user_id,
|
||||
input_image_bytes=scene_image_bytes,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -151,4 +136,3 @@ def generate_hd_video_scene_payload(request: Any, user_id: str) -> Dict[str, Any
|
||||
"model": getattr(request, "model", None) or "tencent/HunyuanVideo",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from loguru import logger
|
||||
BASE_DIR = Path(__file__).resolve().parents[3] # backend/
|
||||
STORY_IMAGES_DIR = (BASE_DIR / "story_images").resolve()
|
||||
STORY_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STORY_AUDIO_DIR = (BASE_DIR / "story_audio").resolve()
|
||||
STORY_AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_story_image_bytes(image_url: str) -> Optional[bytes]:
|
||||
@@ -48,6 +50,41 @@ def load_story_image_bytes(image_url: str) -> Optional[bytes]:
|
||||
return None
|
||||
|
||||
|
||||
def load_story_audio_bytes(audio_url: str) -> 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
|
||||
|
||||
try:
|
||||
parsed = urlparse(audio_url)
|
||||
path = parsed.path if parsed.scheme else audio_url
|
||||
prefix = "/api/story/audio/"
|
||||
if prefix not in path:
|
||||
logger.warning(f"[StoryWriter] Unsupported audio URL for video reference: {audio_url}")
|
||||
return None
|
||||
|
||||
filename = path.split(prefix, 1)[1].split("?", 1)[0].strip()
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
file_path = (STORY_AUDIO_DIR / filename).resolve()
|
||||
if not str(file_path).startswith(str(STORY_AUDIO_DIR)):
|
||||
logger.error(f"[StoryWriter] Attempted path traversal when resolving audio: {audio_url}")
|
||||
return None
|
||||
|
||||
if not file_path.exists():
|
||||
logger.warning(f"[StoryWriter] Referenced scene audio not found on disk: {file_path}")
|
||||
return None
|
||||
|
||||
return file_path.read_bytes()
|
||||
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.
|
||||
@@ -62,8 +99,50 @@ def resolve_media_file(base_dir: Path, filename: str) -> Path:
|
||||
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:
|
||||
return None
|
||||
|
||||
stem = Path(filename).stem
|
||||
suffix = Path(filename).suffix
|
||||
|
||||
if not suffix or "_" not in stem:
|
||||
return None
|
||||
|
||||
prefix = stem.rsplit("_", 1)[0]
|
||||
pattern = f"{prefix}_*{suffix}"
|
||||
|
||||
try:
|
||||
candidates = sorted(
|
||||
(p for p in base_dir.glob(pattern) if p.is_file()),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(f"[StoryWriter] Failed to search alternate media files for {filename}: {exc}")
|
||||
return None
|
||||
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user