Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements
This commit is contained in:
73
backend/api/story_writer/models_projects.py
Normal file
73
backend/api/story_writer/models_projects.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Story Project API Models
|
||||
|
||||
Pydantic models for Story Studio project endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class StoryProjectResponse(BaseModel):
|
||||
id: int
|
||||
project_id: str
|
||||
user_id: str
|
||||
title: Optional[str] = None
|
||||
story_mode: Optional[str] = None
|
||||
story_template: Optional[str] = None
|
||||
setup: Optional[Dict[str, Any]] = None
|
||||
outline: Optional[Dict[str, Any]] = None
|
||||
scenes: Optional[List[Dict[str, Any]]] = None
|
||||
story_content: Optional[Dict[str, Any]] = None
|
||||
anime_bible: Optional[Dict[str, Any]] = None
|
||||
media_state: Optional[Dict[str, Any]] = None
|
||||
current_phase: Optional[str] = None
|
||||
status: str = "draft"
|
||||
is_favorite: bool = False
|
||||
is_complete: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StoryProjectListResponse(BaseModel):
|
||||
projects: List[StoryProjectResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class CreateStoryProjectRequest(BaseModel):
|
||||
project_id: str = Field(..., description="Unique story project ID")
|
||||
title: Optional[str] = Field(None, description="Optional story project title or idea")
|
||||
story_mode: Optional[str] = Field(
|
||||
None, description="Story mode (marketing or pure) if provided by the UI"
|
||||
)
|
||||
story_template: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional story template identifier (e.g. product_story, anime_fiction)",
|
||||
)
|
||||
setup: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Initial story setup payload to persist with the project",
|
||||
)
|
||||
|
||||
|
||||
class UpdateStoryProjectRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
story_mode: Optional[str] = None
|
||||
story_template: Optional[str] = None
|
||||
setup: Optional[Dict[str, Any]] = None
|
||||
outline: Optional[Dict[str, Any]] = None
|
||||
scenes: Optional[List[Dict[str, Any]]] = None
|
||||
story_content: Optional[Dict[str, Any]] = None
|
||||
anime_bible: Optional[Dict[str, Any]] = None
|
||||
media_state: Optional[Dict[str, Any]] = None
|
||||
current_phase: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
is_complete: Optional[bool] = None
|
||||
|
||||
@@ -14,6 +14,7 @@ from .routes import (
|
||||
media_generation,
|
||||
scene_animation,
|
||||
story_content,
|
||||
story_projects,
|
||||
story_setup,
|
||||
story_tasks,
|
||||
video_generation,
|
||||
@@ -24,6 +25,7 @@ router = APIRouter(prefix="/api/story", tags=["Story Writer"])
|
||||
# Include modular routers (order preserved roughly by workflow)
|
||||
router.include_router(story_setup.router)
|
||||
router.include_router(story_content.router)
|
||||
router.include_router(story_projects.router)
|
||||
router.include_router(story_tasks.router)
|
||||
router.include_router(media_generation.router)
|
||||
router.include_router(scene_animation.router)
|
||||
|
||||
@@ -65,7 +65,7 @@ async def generate_scene_images(
|
||||
scene_number=result.get("scene_number", 0),
|
||||
scene_title=result.get("scene_title", "Untitled"),
|
||||
image_filename=result.get("image_filename", ""),
|
||||
image_url=result.get("image_url", ""),
|
||||
image_url=result.get("image_url") or "",
|
||||
width=result.get("width", 1024),
|
||||
height=result.get("height", 1024),
|
||||
provider=result.get("provider", "unknown"),
|
||||
@@ -148,7 +148,7 @@ async def regenerate_scene_image(
|
||||
scene_number=result.get("scene_number", request.scene_number),
|
||||
scene_title=result.get("scene_title", request.scene_title),
|
||||
image_filename=result.get("image_filename", ""),
|
||||
image_url=result.get("image_url", ""),
|
||||
image_url=result.get("image_url") or "",
|
||||
width=result.get("width", request.width or 1024),
|
||||
height=result.get("height", request.height or 1024),
|
||||
provider=result.get("provider", "unknown"),
|
||||
|
||||
@@ -12,6 +12,10 @@ from models.story_models import (
|
||||
StoryScene,
|
||||
StoryContinueRequest,
|
||||
StoryContinueResponse,
|
||||
AnimeSceneTextRequest,
|
||||
AnimeSceneTextResponse,
|
||||
AnimeSceneGenerateRequest,
|
||||
AnimeSceneGenerateResponse,
|
||||
)
|
||||
from services.story_writer.story_service import StoryWriterService
|
||||
|
||||
@@ -107,6 +111,7 @@ async def generate_story_start(
|
||||
content_rating=request.content_rating,
|
||||
ending_preference=request.ending_preference,
|
||||
story_length=story_length,
|
||||
anime_bible=getattr(request, "anime_bible", None),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@@ -211,6 +216,7 @@ async def continue_story(
|
||||
audience_age_group=request.audience_age_group,
|
||||
content_rating=request.content_rating,
|
||||
ending_preference=request.ending_preference,
|
||||
anime_bible=getattr(request, "anime_bible", None),
|
||||
story_length=story_length,
|
||||
user_id=user_id,
|
||||
)
|
||||
@@ -245,6 +251,105 @@ async def continue_story(
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/anime/scene-text", response_model=AnimeSceneTextResponse)
|
||||
async def refine_anime_scene_text(
|
||||
request: AnimeSceneTextRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> AnimeSceneTextResponse:
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
scene_dict = request.scene.dict()
|
||||
if not scene_dict.get("title") and not scene_dict.get("description"):
|
||||
raise HTTPException(status_code=400, detail="Scene title or description is required")
|
||||
|
||||
refined = story_service.refine_anime_scene_text(
|
||||
scene=scene_dict,
|
||||
persona=request.persona,
|
||||
story_setting=request.story_setting,
|
||||
character_input=request.character_input,
|
||||
plot_elements=request.plot_elements,
|
||||
writing_style=request.writing_style,
|
||||
story_tone=request.story_tone,
|
||||
narrative_pov=request.narrative_pov,
|
||||
audience_age_group=request.audience_age_group,
|
||||
content_rating=request.content_rating,
|
||||
anime_bible=request.anime_bible,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
refined_scene = StoryScene(
|
||||
scene_number=refined.get("scene_number", request.scene.scene_number),
|
||||
title=refined.get("title", request.scene.title),
|
||||
description=refined.get("description", request.scene.description),
|
||||
image_prompt=refined.get("image_prompt", request.scene.image_prompt),
|
||||
audio_narration=refined.get("audio_narration", request.scene.audio_narration),
|
||||
character_descriptions=refined.get(
|
||||
"character_descriptions", request.scene.character_descriptions
|
||||
),
|
||||
key_events=refined.get("key_events", request.scene.key_events),
|
||||
)
|
||||
|
||||
return AnimeSceneTextResponse(scene=refined_scene, success=True)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"[StoryWriter] Failed to refine anime scene text: {exc}")
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/anime/scene-generate", response_model=AnimeSceneGenerateResponse)
|
||||
async def generate_anime_scene_from_bible(
|
||||
request: AnimeSceneGenerateRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> AnimeSceneGenerateResponse:
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not request.anime_bible:
|
||||
raise HTTPException(status_code=400, detail="Anime story bible is required")
|
||||
|
||||
previous_scenes_payload: Optional[List[Dict[str, Any]]] = None
|
||||
if request.previous_scenes:
|
||||
previous_scenes_payload = [scene.dict() for scene in request.previous_scenes]
|
||||
|
||||
generated = story_service.generate_anime_scene_from_bible(
|
||||
premise=request.premise,
|
||||
persona=request.persona,
|
||||
story_setting=request.story_setting,
|
||||
character_input=request.character_input,
|
||||
plot_elements=request.plot_elements,
|
||||
writing_style=request.writing_style,
|
||||
story_tone=request.story_tone,
|
||||
narrative_pov=request.narrative_pov,
|
||||
audience_age_group=request.audience_age_group,
|
||||
content_rating=request.content_rating,
|
||||
anime_bible=request.anime_bible,
|
||||
previous_scenes=previous_scenes_payload,
|
||||
target_scene_number=request.target_scene_number,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
scene = StoryScene(
|
||||
scene_number=generated.get("scene_number"),
|
||||
title=generated.get("title", ""),
|
||||
description=generated.get("description", ""),
|
||||
image_prompt=generated.get("image_prompt", ""),
|
||||
audio_narration=generated.get("audio_narration", ""),
|
||||
character_descriptions=generated.get("character_descriptions") or [],
|
||||
key_events=generated.get("key_events") or [],
|
||||
)
|
||||
|
||||
return AnimeSceneGenerateResponse(scene=scene, success=True)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"[StoryWriter] Failed to generate anime scene from bible: {exc}")
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
class SceneApprovalRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
scene_id: str = Field(..., min_length=1)
|
||||
|
||||
189
backend/api/story_writer/routes/story_projects.py
Normal file
189
backend/api/story_writer/routes/story_projects.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.database import get_db
|
||||
from services.story_writer.story_project_service import StoryProjectService
|
||||
from ..models_projects import (
|
||||
CreateStoryProjectRequest,
|
||||
StoryProjectListResponse,
|
||||
StoryProjectResponse,
|
||||
UpdateStoryProjectRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/projects", response_model=StoryProjectResponse, status_code=201)
|
||||
async def create_story_project(
|
||||
request: CreateStoryProjectRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> StoryProjectResponse:
|
||||
try:
|
||||
user_id = current_user.get("user_id") or current_user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found")
|
||||
|
||||
service = StoryProjectService(db)
|
||||
|
||||
existing = service.get_project(user_id, request.project_id)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Project ID already exists")
|
||||
|
||||
project = service.create_project(
|
||||
user_id=user_id,
|
||||
project_id=request.project_id,
|
||||
title=request.title,
|
||||
story_mode=request.story_mode,
|
||||
story_template=request.story_template,
|
||||
setup=request.setup,
|
||||
)
|
||||
|
||||
return StoryProjectResponse.model_validate(project)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error creating story project: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=StoryProjectResponse)
|
||||
async def get_story_project(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> StoryProjectResponse:
|
||||
try:
|
||||
user_id = current_user.get("user_id") or current_user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found")
|
||||
|
||||
service = StoryProjectService(db)
|
||||
project = service.get_project(user_id, project_id)
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
return StoryProjectResponse.model_validate(project)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error fetching story project: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}", response_model=StoryProjectResponse)
|
||||
async def update_story_project(
|
||||
project_id: str,
|
||||
request: UpdateStoryProjectRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> StoryProjectResponse:
|
||||
try:
|
||||
user_id = current_user.get("user_id") or current_user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found")
|
||||
|
||||
service = StoryProjectService(db)
|
||||
|
||||
updates = request.model_dump(exclude_unset=True)
|
||||
|
||||
project = service.update_project(user_id, project_id, **updates)
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
return StoryProjectResponse.model_validate(project)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error updating story project: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/projects", response_model=StoryProjectListResponse)
|
||||
async def list_story_projects(
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
favorites_only: bool = Query(False, description="Only favorites"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
order_by: str = Query("updated_at", description="Order by: updated_at or created_at"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> StoryProjectListResponse:
|
||||
try:
|
||||
user_id = current_user.get("user_id") or current_user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found")
|
||||
|
||||
if order_by not in ["updated_at", "created_at"]:
|
||||
raise HTTPException(status_code=400, detail="order_by must be 'updated_at' or 'created_at'")
|
||||
|
||||
service = StoryProjectService(db)
|
||||
projects, total = service.list_projects(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
favorites_only=favorites_only,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
order_by=order_by,
|
||||
)
|
||||
|
||||
return StoryProjectListResponse(
|
||||
projects=[StoryProjectResponse.model_validate(p) for p in projects],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error listing story projects: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}", status_code=204)
|
||||
async def delete_story_project(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> None:
|
||||
try:
|
||||
user_id = current_user.get("user_id") or current_user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found")
|
||||
|
||||
service = StoryProjectService(db)
|
||||
deleted = service.delete_project(user_id, project_id)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error deleting story project: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/favorite", response_model=StoryProjectResponse)
|
||||
async def toggle_story_project_favorite(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> StoryProjectResponse:
|
||||
try:
|
||||
user_id = current_user.get("user_id") or current_user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found")
|
||||
|
||||
service = StoryProjectService(db)
|
||||
project = service.toggle_favorite(user_id, project_id)
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
return StoryProjectResponse.model_validate(project)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error toggling story project favorite: {str(e)}")
|
||||
|
||||
@@ -2,6 +2,8 @@ from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from models.story_models import (
|
||||
@@ -13,8 +15,14 @@ from models.story_models import (
|
||||
StoryScene,
|
||||
StoryStartRequest,
|
||||
StoryPremiseResponse,
|
||||
StoryIdeaEnhanceRequest,
|
||||
StoryIdeaEnhanceResponse,
|
||||
StoryIdeaEnhanceSuggestion,
|
||||
)
|
||||
from services.story_writer.story_service import StoryWriterService
|
||||
from api.onboarding_utils.onboarding_summary_service import OnboardingSummaryService
|
||||
from services.database import get_session_for_user
|
||||
from models.content_asset_models import ContentAsset, AssetType, AssetSource
|
||||
|
||||
from ..utils.auth import require_authenticated_user
|
||||
|
||||
@@ -39,6 +47,9 @@ async def generate_story_setup(
|
||||
|
||||
options = story_service.generate_story_setup_options(
|
||||
story_idea=request.story_idea,
|
||||
story_mode=request.story_mode,
|
||||
story_template=request.story_template,
|
||||
brand_context=request.brand_context,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@@ -52,6 +63,152 @@ async def generate_story_setup(
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/enhance-idea", response_model=StoryIdeaEnhanceResponse)
|
||||
async def enhance_story_idea(
|
||||
request: StoryIdeaEnhanceRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> StoryIdeaEnhanceResponse:
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not request.story_idea or not request.story_idea.strip():
|
||||
raise HTTPException(status_code=400, detail="Story idea is required")
|
||||
|
||||
logger.info(f"[StoryWriter] Enhancing story idea for user {user_id}")
|
||||
|
||||
suggestions = story_service.enhance_story_idea(
|
||||
story_idea=request.story_idea,
|
||||
story_mode=request.story_mode,
|
||||
story_template=request.story_template,
|
||||
brand_context=request.brand_context,
|
||||
user_id=user_id,
|
||||
fiction_variant=request.fiction_variant,
|
||||
narrative_energy=request.narrative_energy,
|
||||
)
|
||||
|
||||
return StoryIdeaEnhanceResponse(
|
||||
suggestions=[StoryIdeaEnhanceSuggestion(**s) for s in suggestions],
|
||||
success=True,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"[StoryWriter] Failed to enhance story idea: {exc}")
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/context")
|
||||
async def get_story_context(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""Return onboarding-based story context for the current user."""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
summary_service = OnboardingSummaryService(user_id)
|
||||
summary = await summary_service.get_onboarding_summary()
|
||||
|
||||
canonical_profile = summary.get("canonical_profile") or {}
|
||||
persona_readiness = summary.get("persona_readiness") or {}
|
||||
capabilities = summary.get("capabilities") or {}
|
||||
|
||||
website_url = summary.get("website_url")
|
||||
style_analysis = summary.get("style_analysis") or {}
|
||||
research_preferences = summary.get("research_preferences") or {}
|
||||
|
||||
brand_name = None
|
||||
if isinstance(style_analysis, dict):
|
||||
brand_name = style_analysis.get("brand_name") or style_analysis.get("site_title")
|
||||
|
||||
writing_tone = canonical_profile.get("writing_tone")
|
||||
target_audience = canonical_profile.get("target_audience")
|
||||
|
||||
brand_context = {
|
||||
"brand_name": brand_name,
|
||||
"writing_tone": writing_tone,
|
||||
"target_audience": target_audience,
|
||||
}
|
||||
|
||||
avatar_url = None
|
||||
voice_preview_url = None
|
||||
custom_voice_id = None
|
||||
|
||||
db: Session | None = get_session_for_user(user_id)
|
||||
if db:
|
||||
try:
|
||||
avatar_asset = (
|
||||
db.query(ContentAsset)
|
||||
.filter(
|
||||
ContentAsset.user_id == user_id,
|
||||
ContentAsset.asset_type == AssetType.IMAGE,
|
||||
ContentAsset.source_module.in_(
|
||||
[AssetSource.BRAND_AVATAR_GENERATOR, AssetSource.STORY_WRITER]
|
||||
),
|
||||
)
|
||||
.order_by(desc(ContentAsset.created_at))
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
|
||||
selected_avatar = None
|
||||
for candidate in avatar_asset:
|
||||
if candidate.source_module == AssetSource.BRAND_AVATAR_GENERATOR:
|
||||
selected_avatar = candidate
|
||||
break
|
||||
meta = candidate.asset_metadata or {}
|
||||
if meta.get("category") == "brand_avatar":
|
||||
selected_avatar = candidate
|
||||
break
|
||||
|
||||
if selected_avatar:
|
||||
avatar_url = selected_avatar.file_url
|
||||
|
||||
voice_asset = (
|
||||
db.query(ContentAsset)
|
||||
.filter(
|
||||
ContentAsset.user_id == user_id,
|
||||
ContentAsset.asset_type == AssetType.AUDIO,
|
||||
ContentAsset.source_module == AssetSource.VOICE_CLONER,
|
||||
)
|
||||
.order_by(desc(ContentAsset.created_at))
|
||||
.first()
|
||||
)
|
||||
|
||||
if voice_asset:
|
||||
meta = voice_asset.asset_metadata or {}
|
||||
voice_preview_url = meta.get("preview_url") or voice_asset.file_url
|
||||
custom_voice_id = meta.get("custom_voice_id")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
persona_enabled = bool(persona_readiness.get("ready")) and bool(
|
||||
capabilities.get("persona_generation")
|
||||
)
|
||||
has_persona_context = persona_enabled and bool(
|
||||
brand_name or writing_tone or target_audience or avatar_url or voice_preview_url
|
||||
)
|
||||
|
||||
return {
|
||||
"canonical_profile": canonical_profile,
|
||||
"website_url": website_url,
|
||||
"research_preferences": research_preferences,
|
||||
"brand_context": brand_context,
|
||||
"brand_assets": {
|
||||
"avatar_url": avatar_url,
|
||||
"voice_preview_url": voice_preview_url,
|
||||
"custom_voice_id": custom_voice_id,
|
||||
},
|
||||
"persona_enabled": persona_enabled,
|
||||
"has_persona_context": has_persona_context,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"[StoryWriter] Failed to get story context: {exc}")
|
||||
raise HTTPException(status_code=500, detail="Failed to load story context")
|
||||
|
||||
|
||||
@router.post("/generate-premise", response_model=StoryPremiseResponse)
|
||||
async def generate_premise(
|
||||
request: StoryGenerationRequest,
|
||||
@@ -108,6 +265,9 @@ async def generate_outline(
|
||||
request.story_tone,
|
||||
)
|
||||
|
||||
# For now, treat all outlines as potentially anime-aware. The downstream
|
||||
# generation logic will decide whether to actually create a bible based
|
||||
# on how the prompt is interpreted (e.g., anime templates in persona).
|
||||
outline = story_service.generate_outline(
|
||||
premise=request.premise,
|
||||
persona=request.persona,
|
||||
@@ -122,15 +282,37 @@ async def generate_outline(
|
||||
ending_preference=request.ending_preference,
|
||||
user_id=user_id,
|
||||
use_structured_output=use_structured,
|
||||
include_anime_bible=True,
|
||||
)
|
||||
|
||||
if isinstance(outline, list):
|
||||
scenes: List[StoryScene] = [
|
||||
StoryScene(**scene) if isinstance(scene, dict) else scene for scene in outline
|
||||
]
|
||||
return StoryOutlineResponse(outline=scenes, success=True, is_structured=True)
|
||||
anime_bible: Dict[str, Any] | None = None
|
||||
outline_payload: Any = outline
|
||||
|
||||
return StoryOutlineResponse(outline=str(outline), success=True, is_structured=False)
|
||||
if isinstance(outline, dict):
|
||||
if "anime_bible" in outline:
|
||||
anime_bible = outline.get("anime_bible")
|
||||
if "scenes" in outline:
|
||||
outline_payload = outline.get("scenes")
|
||||
elif "outline" in outline:
|
||||
outline_payload = outline.get("outline")
|
||||
|
||||
if isinstance(outline_payload, list):
|
||||
scenes: List[StoryScene] = [
|
||||
StoryScene(**scene) if isinstance(scene, dict) else scene for scene in outline_payload
|
||||
]
|
||||
return StoryOutlineResponse(
|
||||
outline=scenes,
|
||||
success=True,
|
||||
is_structured=True,
|
||||
anime_bible=anime_bible,
|
||||
)
|
||||
|
||||
return StoryOutlineResponse(
|
||||
outline=str(outline_payload),
|
||||
success=True,
|
||||
is_structured=False,
|
||||
anime_bible=anime_bible,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -350,9 +351,21 @@ def execute_complete_video_generation(
|
||||
Runs in a background task and performs blocking operations.
|
||||
"""
|
||||
try:
|
||||
task_manager.update_task_status(task_id, "processing", progress=5.0, message="Starting complete video generation...")
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"processing",
|
||||
progress=5.0,
|
||||
message="Starting complete video generation...",
|
||||
)
|
||||
|
||||
task_manager.update_task_status(task_id, "processing", progress=10.0, message="Generating story premise...")
|
||||
anime_bible = request_data.get("anime_bible")
|
||||
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"processing",
|
||||
progress=10.0,
|
||||
message="Generating story premise...",
|
||||
)
|
||||
premise = story_service.generate_premise(
|
||||
persona=request_data["persona"],
|
||||
story_setting=request_data["story_setting"],
|
||||
@@ -367,7 +380,12 @@ def execute_complete_video_generation(
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
task_manager.update_task_status(task_id, "processing", progress=20.0, message="Generating structured outline with scenes...")
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"processing",
|
||||
progress=20.0,
|
||||
message="Generating structured outline with scenes...",
|
||||
)
|
||||
outline_scenes = story_service.generate_outline(
|
||||
premise=premise,
|
||||
persona=request_data["persona"],
|
||||
@@ -401,6 +419,7 @@ def execute_complete_video_generation(
|
||||
height=request_data.get("image_height", 1024),
|
||||
model=request_data.get("image_model"),
|
||||
progress_callback=image_progress_callback,
|
||||
anime_bible=anime_bible,
|
||||
)
|
||||
|
||||
task_manager.update_task_status(task_id, "processing", progress=50.0, message="Generating audio narration for scenes...")
|
||||
|
||||
@@ -140,7 +140,7 @@ class TaskManager:
|
||||
audience_age_group=request_data["audience_age_group"],
|
||||
content_rating=request_data["content_rating"],
|
||||
ending_preference=request_data["ending_preference"],
|
||||
user_id=user_id
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Step 2: Generate outline
|
||||
@@ -157,7 +157,7 @@ class TaskManager:
|
||||
audience_age_group=request_data["audience_age_group"],
|
||||
content_rating=request_data["content_rating"],
|
||||
ending_preference=request_data["ending_preference"],
|
||||
user_id=user_id
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Step 3: Generate story start
|
||||
@@ -175,7 +175,8 @@ class TaskManager:
|
||||
audience_age_group=request_data["audience_age_group"],
|
||||
content_rating=request_data["content_rating"],
|
||||
ending_preference=request_data["ending_preference"],
|
||||
user_id=user_id
|
||||
anime_bible=request_data.get("anime_bible"),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Step 4: Continue story
|
||||
@@ -208,7 +209,8 @@ class TaskManager:
|
||||
audience_age_group=request_data["audience_age_group"],
|
||||
content_rating=request_data["content_rating"],
|
||||
ending_preference=request_data["ending_preference"],
|
||||
user_id=user_id
|
||||
anime_bible=request_data.get("anime_bible"),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if continuation:
|
||||
|
||||
@@ -8,7 +8,7 @@ def require_authenticated_user(current_user: Dict[str, Any] | None) -> str:
|
||||
Validates the current user dictionary provided by Clerk middleware and
|
||||
returns the normalized user_id. Raises HTTP 401 if authentication fails.
|
||||
"""
|
||||
if not current_user:
|
||||
if not current_user or not isinstance(current_user, dict):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get("id", "")).strip()
|
||||
|
||||
@@ -12,13 +12,11 @@ from services.user_workspace_manager import UserWorkspaceManager
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[4] # root/
|
||||
DATA_MEDIA_DIR = BASE_DIR / "workspace" / "media"
|
||||
# Default global media directory matches story image/audio services (root/data/media)
|
||||
DATA_MEDIA_DIR = BASE_DIR / "data" / "media"
|
||||
|
||||
STORY_IMAGES_DIR = (DATA_MEDIA_DIR / "story_images").resolve()
|
||||
# STORY_IMAGES_DIR.mkdir(parents=True, exist_ok=True) # Disabled global creation
|
||||
|
||||
STORY_AUDIO_DIR = (DATA_MEDIA_DIR / "story_audio").resolve()
|
||||
# STORY_AUDIO_DIR.mkdir(parents=True, exist_ok=True) # Disabled global creation
|
||||
|
||||
|
||||
def _get_user_media_path(user_id: str, media_type: str) -> Optional[Path]:
|
||||
|
||||
Reference in New Issue
Block a user