From 49e2131715333f3afa5bf48c634b11bc215150e3 Mon Sep 17 00:00:00 2001 From: ajaysi Date: Fri, 28 Nov 2025 14:33:52 +0530 Subject: [PATCH] AI Image Studio, AI podcast Maker, AI product Marketing --- backend/api/blog_writer/router.py | 208 +- backend/api/blog_writer/task_manager.py | 4 + backend/api/content_assets/router.py | 2 +- .../routers/facebook_router.py | 305 ++- backend/api/images.py | 214 +- backend/api/story_writer/router.py | 1775 +---------------- backend/api/story_writer/routes/__init__.py | 22 +- .../story_writer/routes/media_generation.py | 133 +- .../story_writer/routes/scene_animation.py | 484 +++++ .../api/story_writer/routes/story_content.py | 47 +- .../story_writer/routes/video_generation.py | 27 + backend/app.py | 2 + backend/docs/ASSET_TRACKING_IMPLEMENTATION.md | 264 +++ .../TEXT_ASSET_TRACKING_IMPLEMENTATION.md | 143 ++ backend/models/content_asset_models.py | 78 +- backend/models/product_asset_models.py | 155 ++ backend/models/product_marketing_models.py | 162 ++ backend/routers/image_studio.py | 251 ++- backend/routers/linkedin.py | 254 ++- backend/routers/product_marketing.py | 640 ++++++ .../scripts/create_product_asset_tables.py | 88 + .../create_product_marketing_tables.py | 71 + backend/services/content_asset_service.py | 12 +- backend/services/database.py | 8 +- backend/services/image_studio/__init__.py | 8 + .../image_studio/infinitetalk_adapter.py | 155 ++ .../services/image_studio/studio_manager.py | 37 + .../image_studio/transform_service.py | 379 ++++ .../services/image_studio/wan25_service.py | 295 +++ .../services/product_marketing/__init__.py | 20 + .../services/product_marketing/asset_audit.py | 205 ++ .../product_marketing/brand_dna_sync.py | 176 ++ .../product_marketing/campaign_storage.py | 222 +++ .../product_marketing/channel_pack.py | 180 ++ .../product_marketing/orchestrator.py | 469 +++++ .../product_image_service.py | 634 ++++++ .../product_marketing/prompt_builder.py | 304 +++ backend/utils/asset_tracker.py | 6 +- backend/utils/file_storage.py | 246 +++ backend/utils/text_asset_tracker.py | 133 ++ .../features/image-studio/api-reference.md | 940 +++++++++ .../features/image-studio/asset-library.md | 323 +++ .../features/image-studio/control-studio.md | 375 ++++ .../docs/features/image-studio/cost-guide.md | 285 +++ .../features/image-studio/create-studio.md | 385 ++++ .../docs/features/image-studio/edit-studio.md | 404 ++++ .../image-studio/implementation-overview.md | 517 +++++ .../docs/features/image-studio/modules.md | 432 ++++ .../docs/features/image-studio/overview.md | 225 +++ .../docs/features/image-studio/providers.md | 360 ++++ .../features/image-studio/social-optimizer.md | 283 +++ .../docs/features/image-studio/templates.md | 334 ++++ .../features/image-studio/transform-studio.md | 388 ++++ .../features/image-studio/upscale-studio.md | 284 +++ .../features/image-studio/workflow-guide.md | 370 ++++ docs-site/mkdocs.yml | 16 + docs/AI_PODCAST_BACKEND_REFERENCE.md | 148 ++ .../ENHANCED_GROUNDING_UI_IMPLEMENTATION.md | 0 .../RESEARCH_AI_HYPERPERSONALIZATION.md | 0 .../RESEARCH_COMPONENT_INTEGRATION.md | 0 .../RESEARCH_IMPROVEMENTS_SUMMARY.md | 0 .../RESEARCH_WIZARD_IMPLEMENTATION.md | 0 ...NAVIGATION_COPILOTKIT_MITIGATION_REVIEW.md | 200 -- .../expected_calendar_output_structure.md | 0 .../COMPETITOR_SITEMAP_ANALYSIS_PLAN.md | 0 docs/{ => SEO}/PRIMARY_SEO_TOOLS_ANALYSIS.md | 0 .../SEO_Dashboard_Design_Document.md | 0 .../SITEMAP_ANALYSIS_ENHANCEMENT_PLAN.md | 0 docs/SESSION_ID_CLEANUP_SUMMARY.md | 308 --- docs/SESSION_SUMMARY_USER_ISOLATION_FIX.md | 275 --- docs/STYLE_DETECTION_404_ANALYSIS.md | 134 -- docs/STYLE_DETECTION_FIX_SUMMARY.md | 332 --- docs/USER_ISOLATION_COMPLETE_FIX.md | 310 --- docs/USER_ISOLATION_FIX_COMPLETE.md | 351 ---- .../AI_IMAGE_STUDIO_COMPREHENSIVE_PLAN.md | 0 .../AI_IMAGE_STUDIO_EXECUTIVE_SUMMARY.md | 0 ..._STUDIO_FRONTEND_IMPLEMENTATION_SUMMARY.md | 0 .../AI_IMAGE_STUDIO_QUICK_START.md | 0 .../IMAGE_STUDIO_MASKING_ANALYSIS.md | 0 ...O_PHASE1_MODULE1_IMPLEMENTATION_SUMMARY.md | 0 .../IMAGE_STUDIO_PROGRESS_REVIEW.md | 0 .../IMAGE_STUDIO_QUICK_INTEGRATION_GUIDE.md | 0 .../AI_PRODUCT_MARKETING_SUITE.md | 875 ++++++++ .../PRODUCT_MARKETING_FIXES.md | 50 + .../PRODUCT_MARKETING_NEXT_STEPS.md | 400 ++++ .../PRODUCT_MARKETING_PHASE1_FRONTEND.md | 162 ++ ...PRODUCT_MARKETING_PHASE1_IMPLEMENTATION.md | 155 ++ .../PRODUCT_MARKETING_SUITE_PLAN.md | 653 ++++++ .../PRODUCT_MARKETING_VS_CAMPAIGN_CREATOR.md | 318 +++ .../STORY_GENERATION_CODE_ADAPTATION_GUIDE.md | 0 .../STORY_GENERATION_IMPLEMENTATION_PLAN.md | 0 ...ORY_WRITER_FRONTEND_FOUNDATION_COMPLETE.md | 0 .../STORY_WRITER_IMPLEMENTATION_REVIEW.md | 0 .../STORY_WRITER_VIDEO_ENHANCEMENT.md | 0 frontend/src/App.tsx | 9 +- .../components/ImageStudio/AssetLibrary.tsx | 271 ++- .../ImageStudio/ImageStudioLayout.tsx | 117 +- .../ImageStudio/TransformStudio.tsx | 730 +++++++ .../ImageStudio/dashboard/modules.tsx | 7 +- frontend/src/components/ImageStudio/index.ts | 1 + .../PodcastMaker/PodcastDashboard.tsx | 922 +++++++++ frontend/src/components/PodcastMaker/types.ts | 115 ++ .../ProductMarketing/AssetAuditPanel.tsx | 351 ++++ .../CampaignFlowIndicator.tsx | 74 + .../ProductMarketing/CampaignWizard.tsx | 508 +++++ .../ProductMarketing/ChannelPackBuilder.tsx | 236 +++ .../PreflightValidationAlert.tsx | 150 ++ .../ProductMarketingDashboard.tsx | 473 +++++ .../ProductAssetsGallery.tsx | 248 +++ .../ProductImagePreview.tsx | 251 +++ .../ProductInfoForm.tsx | 67 + .../ProductPhotoshootStudio.tsx | 327 +++ .../ProductVariations.tsx | 163 ++ .../ProductPhotoshootStudio/StyleSelector.tsx | 188 ++ .../ProductPhotoshootStudio/index.ts | 7 + .../ProductMarketing/ProposalReview.tsx | 451 +++++ .../src/components/ProductMarketing/index.ts | 8 + frontend/src/hooks/useContentAssets.ts | 4 +- frontend/src/hooks/useProductMarketing.ts | 496 +++++ frontend/src/hooks/useTransformStudio.ts | 153 ++ frontend/src/services/podcastApi.ts | 415 ++++ preflight-check-cost-estimation.plan.md | 490 ----- 122 files changed, 22311 insertions(+), 4331 deletions(-) create mode 100644 backend/api/story_writer/routes/scene_animation.py create mode 100644 backend/docs/ASSET_TRACKING_IMPLEMENTATION.md create mode 100644 backend/docs/TEXT_ASSET_TRACKING_IMPLEMENTATION.md create mode 100644 backend/models/product_asset_models.py create mode 100644 backend/models/product_marketing_models.py create mode 100644 backend/routers/product_marketing.py create mode 100644 backend/scripts/create_product_asset_tables.py create mode 100644 backend/scripts/create_product_marketing_tables.py create mode 100644 backend/services/image_studio/infinitetalk_adapter.py create mode 100644 backend/services/image_studio/transform_service.py create mode 100644 backend/services/image_studio/wan25_service.py create mode 100644 backend/services/product_marketing/__init__.py create mode 100644 backend/services/product_marketing/asset_audit.py create mode 100644 backend/services/product_marketing/brand_dna_sync.py create mode 100644 backend/services/product_marketing/campaign_storage.py create mode 100644 backend/services/product_marketing/channel_pack.py create mode 100644 backend/services/product_marketing/orchestrator.py create mode 100644 backend/services/product_marketing/product_image_service.py create mode 100644 backend/services/product_marketing/prompt_builder.py create mode 100644 backend/utils/file_storage.py create mode 100644 backend/utils/text_asset_tracker.py create mode 100644 docs-site/docs/features/image-studio/api-reference.md create mode 100644 docs-site/docs/features/image-studio/asset-library.md create mode 100644 docs-site/docs/features/image-studio/control-studio.md create mode 100644 docs-site/docs/features/image-studio/cost-guide.md create mode 100644 docs-site/docs/features/image-studio/create-studio.md create mode 100644 docs-site/docs/features/image-studio/edit-studio.md create mode 100644 docs-site/docs/features/image-studio/implementation-overview.md create mode 100644 docs-site/docs/features/image-studio/modules.md create mode 100644 docs-site/docs/features/image-studio/overview.md create mode 100644 docs-site/docs/features/image-studio/providers.md create mode 100644 docs-site/docs/features/image-studio/social-optimizer.md create mode 100644 docs-site/docs/features/image-studio/templates.md create mode 100644 docs-site/docs/features/image-studio/transform-studio.md create mode 100644 docs-site/docs/features/image-studio/upscale-studio.md create mode 100644 docs-site/docs/features/image-studio/workflow-guide.md create mode 100644 docs/AI_PODCAST_BACKEND_REFERENCE.md rename docs/{ => ALwrity Researcher}/ENHANCED_GROUNDING_UI_IMPLEMENTATION.md (100%) rename docs/{ => ALwrity Researcher}/RESEARCH_AI_HYPERPERSONALIZATION.md (100%) rename docs/{ => ALwrity Researcher}/RESEARCH_COMPONENT_INTEGRATION.md (100%) rename docs/{ => ALwrity Researcher}/RESEARCH_IMPROVEMENTS_SUMMARY.md (100%) rename docs/{ => ALwrity Researcher}/RESEARCH_WIZARD_IMPLEMENTATION.md (100%) delete mode 100644 docs/CLICKABLE_PHASE_NAVIGATION_COPILOTKIT_MITIGATION_REVIEW.md rename docs/{ => Content Calender}/expected_calendar_output_structure.md (100%) rename docs/{ => SEO}/COMPETITOR_SITEMAP_ANALYSIS_PLAN.md (100%) rename docs/{ => SEO}/PRIMARY_SEO_TOOLS_ANALYSIS.md (100%) rename docs/{ => SEO}/SEO_Dashboard_Design_Document.md (100%) rename docs/{ => SEO}/SITEMAP_ANALYSIS_ENHANCEMENT_PLAN.md (100%) delete mode 100644 docs/SESSION_ID_CLEANUP_SUMMARY.md delete mode 100644 docs/SESSION_SUMMARY_USER_ISOLATION_FIX.md delete mode 100644 docs/STYLE_DETECTION_404_ANALYSIS.md delete mode 100644 docs/STYLE_DETECTION_FIX_SUMMARY.md delete mode 100644 docs/USER_ISOLATION_COMPLETE_FIX.md delete mode 100644 docs/USER_ISOLATION_FIX_COMPLETE.md rename docs/{ => image studio}/AI_IMAGE_STUDIO_COMPREHENSIVE_PLAN.md (100%) rename docs/{ => image studio}/AI_IMAGE_STUDIO_EXECUTIVE_SUMMARY.md (100%) rename docs/{ => image studio}/AI_IMAGE_STUDIO_FRONTEND_IMPLEMENTATION_SUMMARY.md (100%) rename docs/{ => image studio}/AI_IMAGE_STUDIO_QUICK_START.md (100%) rename docs/{ => image studio}/IMAGE_STUDIO_MASKING_ANALYSIS.md (100%) rename docs/{ => image studio}/IMAGE_STUDIO_PHASE1_MODULE1_IMPLEMENTATION_SUMMARY.md (100%) rename docs/{ => image studio}/IMAGE_STUDIO_PROGRESS_REVIEW.md (100%) rename docs/{ => image studio}/IMAGE_STUDIO_QUICK_INTEGRATION_GUIDE.md (100%) create mode 100644 docs/product marketing/AI_PRODUCT_MARKETING_SUITE.md create mode 100644 docs/product marketing/PRODUCT_MARKETING_FIXES.md create mode 100644 docs/product marketing/PRODUCT_MARKETING_NEXT_STEPS.md create mode 100644 docs/product marketing/PRODUCT_MARKETING_PHASE1_FRONTEND.md create mode 100644 docs/product marketing/PRODUCT_MARKETING_PHASE1_IMPLEMENTATION.md create mode 100644 docs/product marketing/PRODUCT_MARKETING_SUITE_PLAN.md create mode 100644 docs/product marketing/PRODUCT_MARKETING_VS_CAMPAIGN_CREATOR.md rename docs/{ => story writer}/STORY_GENERATION_CODE_ADAPTATION_GUIDE.md (100%) rename docs/{ => story writer}/STORY_GENERATION_IMPLEMENTATION_PLAN.md (100%) rename docs/{ => story writer}/STORY_WRITER_FRONTEND_FOUNDATION_COMPLETE.md (100%) rename docs/{ => story writer}/STORY_WRITER_IMPLEMENTATION_REVIEW.md (100%) rename docs/{ => story writer}/STORY_WRITER_VIDEO_ENHANCEMENT.md (100%) create mode 100644 frontend/src/components/ImageStudio/TransformStudio.tsx create mode 100644 frontend/src/components/PodcastMaker/PodcastDashboard.tsx create mode 100644 frontend/src/components/PodcastMaker/types.ts create mode 100644 frontend/src/components/ProductMarketing/AssetAuditPanel.tsx create mode 100644 frontend/src/components/ProductMarketing/CampaignFlowIndicator.tsx create mode 100644 frontend/src/components/ProductMarketing/CampaignWizard.tsx create mode 100644 frontend/src/components/ProductMarketing/ChannelPackBuilder.tsx create mode 100644 frontend/src/components/ProductMarketing/PreflightValidationAlert.tsx create mode 100644 frontend/src/components/ProductMarketing/ProductMarketingDashboard.tsx create mode 100644 frontend/src/components/ProductMarketing/ProductPhotoshootStudio/ProductAssetsGallery.tsx create mode 100644 frontend/src/components/ProductMarketing/ProductPhotoshootStudio/ProductImagePreview.tsx create mode 100644 frontend/src/components/ProductMarketing/ProductPhotoshootStudio/ProductInfoForm.tsx create mode 100644 frontend/src/components/ProductMarketing/ProductPhotoshootStudio/ProductPhotoshootStudio.tsx create mode 100644 frontend/src/components/ProductMarketing/ProductPhotoshootStudio/ProductVariations.tsx create mode 100644 frontend/src/components/ProductMarketing/ProductPhotoshootStudio/StyleSelector.tsx create mode 100644 frontend/src/components/ProductMarketing/ProductPhotoshootStudio/index.ts create mode 100644 frontend/src/components/ProductMarketing/ProposalReview.tsx create mode 100644 frontend/src/components/ProductMarketing/index.ts create mode 100644 frontend/src/hooks/useProductMarketing.ts create mode 100644 frontend/src/hooks/useTransformStudio.ts create mode 100644 frontend/src/services/podcastApi.ts delete mode 100644 preflight-check-cost-estimation.plan.md diff --git a/backend/api/blog_writer/router.py b/backend/api/blog_writer/router.py index 2fe8998d..99e7a8e1 100644 --- a/backend/api/blog_writer/router.py +++ b/backend/api/blog_writer/router.py @@ -10,6 +10,9 @@ from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field from loguru import logger from middleware.auth_middleware import get_current_user +from sqlalchemy.orm import Session +from services.database import get_db as get_db_dependency +from utils.text_asset_tracker import save_and_track_text_content from models.blog_models import ( BlogResearchRequest, @@ -41,6 +44,10 @@ router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"]) service = BlogWriterService() recommendation_applier = BlogSEORecommendationApplier() + + +# Use the proper database dependency from services.database +get_db = get_db_dependency # --------------------------- # SEO Recommendation Endpoints # --------------------------- @@ -272,10 +279,41 @@ async def rebalance_outline(outline_data: Dict[str, Any], target_words: int = 15 # Content Generation Endpoints @router.post("/section/generate", response_model=BlogSectionResponse) -async def generate_section(request: BlogSectionRequest) -> BlogSectionResponse: +async def generate_section( + request: BlogSectionRequest, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db) +) -> BlogSectionResponse: """Generate content for a specific section.""" try: - return await service.generate_section(request) + response = await service.generate_section(request) + + # Save and track text content (non-blocking) + if response.markdown: + try: + user_id = str(current_user.get('id', '')) if current_user else None + if user_id: + section_heading = getattr(request, 'section_heading', getattr(request, 'heading', 'Section')) + save_and_track_text_content( + db=db, + user_id=user_id, + content=response.markdown, + source_module="blog_writer", + title=f"Blog Section: {section_heading[:60]}", + description=f"Blog section content", + prompt=f"Section: {section_heading}\nKeywords: {getattr(request, 'keywords', [])}", + tags=["blog", "section", "content"], + asset_metadata={ + "section_id": getattr(request, 'section_id', None), + "word_count": len(response.markdown.split()), + }, + subdirectory="sections", + file_extension=".md" + ) + except Exception as track_error: + logger.warning(f"Failed to track blog section asset: {track_error}") + + return response except Exception as e: logger.error(f"Failed to generate section: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -321,13 +359,48 @@ async def start_content_generation( @router.get("/content/status/{task_id}") -async def content_generation_status(task_id: str) -> Dict[str, Any]: +async def content_generation_status( + task_id: str, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +) -> Dict[str, Any]: """Poll status for content generation task.""" try: status = await task_manager.get_task_status(task_id) if status is None: raise HTTPException(status_code=404, detail="Task not found") + # Track blog content when task completes (non-blocking) + if status.get('status') == 'completed' and status.get('result'): + try: + result = status.get('result', {}) + if result.get('sections') and len(result.get('sections', [])) > 0: + user_id = str(current_user.get('id', '')) if current_user else None + if user_id: + # Combine all sections into full blog content + blog_content = f"# {result.get('title', 'Untitled Blog')}\n\n" + for section in result.get('sections', []): + blog_content += f"\n## {section.get('heading', 'Section')}\n\n{section.get('content', '')}\n\n" + + save_and_track_text_content( + db=db, + user_id=user_id, + content=blog_content, + source_module="blog_writer", + title=f"Blog: {result.get('title', 'Untitled Blog')[:60]}", + description=f"Complete blog post with {len(result.get('sections', []))} sections", + prompt=f"Title: {result.get('title', 'Untitled')}\nSections: {len(result.get('sections', []))}", + tags=["blog", "complete", "content"], + asset_metadata={ + "section_count": len(result.get('sections', [])), + "model": result.get('model'), + }, + subdirectory="complete", + file_extension=".md" + ) + except Exception as track_error: + logger.warning(f"Failed to track blog content asset: {track_error}") + # If task failed with subscription error, return HTTP error so frontend interceptor can catch it if status.get('status') == 'failed' and status.get('error_status') in [429, 402]: error_data = status.get('error_data', {}) or {} @@ -420,10 +493,40 @@ async def analyze_flow_advanced(request: Dict[str, Any]) -> Dict[str, Any]: @router.post("/section/optimize", response_model=BlogOptimizeResponse) -async def optimize_section(request: BlogOptimizeRequest) -> BlogOptimizeResponse: +async def optimize_section( + request: BlogOptimizeRequest, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db) +) -> BlogOptimizeResponse: """Optimize a specific section for better quality and engagement.""" try: - return await service.optimize_section(request) + response = await service.optimize_section(request) + + # Save and track text content (non-blocking) + if response.optimized: + try: + user_id = str(current_user.get('id', '')) if current_user else None + if user_id: + save_and_track_text_content( + db=db, + user_id=user_id, + content=response.optimized, + source_module="blog_writer", + title=f"Optimized Blog Section", + description=f"Optimized blog section content", + prompt=f"Original Content: {request.content[:200]}\nGoals: {request.goals}", + tags=["blog", "section", "optimized"], + asset_metadata={ + "optimization_goals": request.goals, + "word_count": len(response.optimized.split()), + }, + subdirectory="sections/optimized", + file_extension=".md" + ) + except Exception as track_error: + logger.warning(f"Failed to track optimized blog section asset: {track_error}") + + return response except Exception as e: logger.error(f"Failed to optimize section: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -591,13 +694,49 @@ async def start_medium_generation( @router.get("/generate/medium/status/{task_id}") -async def medium_generation_status(task_id: str): +async def medium_generation_status( + task_id: str, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +): """Poll status for medium blog generation task.""" try: status = await task_manager.get_task_status(task_id) if status is None: raise HTTPException(status_code=404, detail="Task not found") + # Track blog content when task completes (non-blocking) + if status.get('status') == 'completed' and status.get('result'): + try: + result = status.get('result', {}) + if result.get('sections') and len(result.get('sections', [])) > 0: + user_id = str(current_user.get('id', '')) if current_user else None + if user_id: + # Combine all sections into full blog content + blog_content = f"# {result.get('title', 'Untitled Blog')}\n\n" + for section in result.get('sections', []): + blog_content += f"\n## {section.get('heading', 'Section')}\n\n{section.get('content', '')}\n\n" + + save_and_track_text_content( + db=db, + user_id=user_id, + content=blog_content, + source_module="blog_writer", + title=f"Medium Blog: {result.get('title', 'Untitled Blog')[:60]}", + description=f"Medium-length blog post with {len(result.get('sections', []))} sections", + prompt=f"Title: {result.get('title', 'Untitled')}\nSections: {len(result.get('sections', []))}", + tags=["blog", "medium", "complete"], + asset_metadata={ + "section_count": len(result.get('sections', [])), + "model": result.get('model'), + "generation_time_ms": result.get('generation_time_ms'), + }, + subdirectory="medium", + file_extension=".md" + ) + except Exception as track_error: + logger.warning(f"Failed to track medium blog asset: {track_error}") + # If task failed with subscription error, return HTTP error so frontend interceptor can catch it if status.get('status') == 'failed' and status.get('error_status') in [429, 402]: error_data = status.get('error_data', {}) or {} @@ -677,7 +816,8 @@ async def rewrite_status(task_id: str): @router.post("/titles/generate-seo") async def generate_seo_titles( request: Dict[str, Any], - current_user: Dict[str, Any] = Depends(get_current_user) + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db) ) -> Dict[str, Any]: """Generate 5 SEO-optimized blog titles using research and outline data.""" try: @@ -722,6 +862,30 @@ async def generate_seo_titles( user_id=user_id ) + # Save and track titles (non-blocking) + if titles and len(titles) > 0: + try: + titles_content = "# SEO Blog Titles\n\n" + "\n".join([f"{i+1}. {title}" for i, title in enumerate(titles)]) + save_and_track_text_content( + db=db, + user_id=user_id, + content=titles_content, + source_module="blog_writer", + title=f"SEO Blog Titles: {primary_keywords[0] if primary_keywords else 'Blog'}", + description=f"SEO-optimized blog title suggestions", + prompt=f"Primary Keywords: {primary_keywords}\nSearch Intent: {search_intent}\nWord Count: {word_count}", + tags=["blog", "titles", "seo"], + asset_metadata={ + "title_count": len(titles), + "primary_keywords": primary_keywords, + "search_intent": search_intent, + }, + subdirectory="titles", + file_extension=".md" + ) + except Exception as track_error: + logger.warning(f"Failed to track SEO titles asset: {track_error}") + return { "success": True, "titles": titles @@ -736,7 +900,8 @@ async def generate_seo_titles( @router.post("/introductions/generate") async def generate_introductions( request: Dict[str, Any], - current_user: Dict[str, Any] = Depends(get_current_user) + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db) ) -> Dict[str, Any]: """Generate 3 varied blog introductions using research, outline, and content.""" try: @@ -781,6 +946,33 @@ async def generate_introductions( user_id=user_id ) + # Save and track introductions (non-blocking) + if introductions and len(introductions) > 0: + try: + intro_content = f"# Blog Introductions for: {blog_title}\n\n" + for i, intro in enumerate(introductions, 1): + intro_content += f"## Introduction {i}\n\n{intro}\n\n" + + save_and_track_text_content( + db=db, + user_id=user_id, + content=intro_content, + source_module="blog_writer", + title=f"Blog Introductions: {blog_title[:60]}", + description=f"Blog introduction variations", + prompt=f"Blog Title: {blog_title}\nPrimary Keywords: {primary_keywords}\nSearch Intent: {search_intent}", + tags=["blog", "introductions"], + asset_metadata={ + "introduction_count": len(introductions), + "blog_title": blog_title, + "search_intent": search_intent, + }, + subdirectory="introductions", + file_extension=".md" + ) + except Exception as track_error: + logger.warning(f"Failed to track blog introductions asset: {track_error}") + return { "success": True, "introductions": introductions diff --git a/backend/api/blog_writer/task_manager.py b/backend/api/blog_writer/task_manager.py index 59952a18..0cb42585 100644 --- a/backend/api/blog_writer/task_manager.py +++ b/backend/api/blog_writer/task_manager.py @@ -21,6 +21,7 @@ from models.blog_models import ( ) from services.blog_writer.blog_service import BlogWriterService from services.blog_writer.database_task_manager import DatabaseTaskManager +from utils.text_asset_tracker import save_and_track_text_content class TaskManager: @@ -281,6 +282,9 @@ class TaskManager: self.task_storage[task_id]["status"] = "completed" self.task_storage[task_id]["result"] = result.dict() await self.update_progress(task_id, f"✅ Generated {len(result.sections)} sections successfully.") + + # Note: Blog content tracking is handled in the status endpoint + # to ensure we have proper database session and user context except HTTPException as http_error: # Handle HTTPException (e.g., 429 subscription limit) - preserve error details for frontend diff --git a/backend/api/content_assets/router.py b/backend/api/content_assets/router.py index 4b16caa7..69dc60ba 100644 --- a/backend/api/content_assets/router.py +++ b/backend/api/content_assets/router.py @@ -32,7 +32,7 @@ class AssetResponse(BaseModel): description: Optional[str] = None prompt: Optional[str] = None tags: List[str] = [] - metadata: Dict[str, Any] = {} + asset_metadata: Dict[str, Any] = {} provider: Optional[str] = None model: Optional[str] = None cost: float = 0.0 diff --git a/backend/api/facebook_writer/routers/facebook_router.py b/backend/api/facebook_writer/routers/facebook_router.py index adf1ef57..2cfb607e 100644 --- a/backend/api/facebook_writer/routers/facebook_router.py +++ b/backend/api/facebook_writer/routers/facebook_router.py @@ -1,11 +1,15 @@ """FastAPI router for Facebook Writer endpoints.""" from fastapi import APIRouter, HTTPException, Depends -from typing import Dict, Any +from typing import Dict, Any, Optional import logging +from sqlalchemy.orm import Session from ..models import * from ..services import * +from middleware.auth_middleware import get_current_user +from services.database import get_db as get_db_dependency +from utils.text_asset_tracker import save_and_track_text_content # Configure logging logger = logging.getLogger(__name__) @@ -115,9 +119,17 @@ async def get_available_tools(): return {"tools": tools, "total_count": len(tools)} +# Use the proper database dependency from services.database +get_db = get_db_dependency + + # Content Creation Endpoints @router.post("/post/generate", response_model=FacebookPostResponse) -async def generate_facebook_post(request: FacebookPostRequest): +async def generate_facebook_post( + request: FacebookPostRequest, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +): """Generate a Facebook post with engagement optimization.""" try: logger.info(f"Generating Facebook post for business: {request.business_type}") @@ -126,6 +138,37 @@ async def generate_facebook_post(request: FacebookPostRequest): if not response.success: raise HTTPException(status_code=400, detail=response.error) + # Save and track text content (non-blocking) + if response.content: + try: + user_id = None + if current_user: + user_id = str(current_user.get('id', '') or current_user.get('sub', '')) + + if user_id: + text_content = response.content + if response.analytics: + text_content += f"\n\n## Analytics\nExpected Reach: {response.analytics.expected_reach}\nExpected Engagement: {response.analytics.expected_engagement}\nBest Time to Post: {response.analytics.best_time_to_post}" + + save_and_track_text_content( + db=db, + user_id=user_id, + content=text_content, + source_module="facebook_writer", + title=f"Facebook Post: {request.business_type[:60]}", + description=f"Facebook post for {request.business_type}", + prompt=f"Business Type: {request.business_type}\nTarget Audience: {request.target_audience}\nGoal: {request.post_goal.value if hasattr(request.post_goal, 'value') else request.post_goal}\nTone: {request.post_tone.value if hasattr(request.post_tone, 'value') else request.post_tone}", + tags=["facebook", "post", request.business_type.lower().replace(' ', '_')], + asset_metadata={ + "post_goal": request.post_goal.value if hasattr(request.post_goal, 'value') else str(request.post_goal), + "post_tone": request.post_tone.value if hasattr(request.post_tone, 'value') else str(request.post_tone), + "media_type": request.media_type.value if hasattr(request.media_type, 'value') else str(request.media_type) + }, + subdirectory="posts" + ) + except Exception as track_error: + logger.warning(f"Failed to track Facebook post asset: {track_error}") + return response except Exception as e: @@ -134,7 +177,11 @@ async def generate_facebook_post(request: FacebookPostRequest): @router.post("/story/generate", response_model=FacebookStoryResponse) -async def generate_facebook_story(request: FacebookStoryRequest): +async def generate_facebook_story( + request: FacebookStoryRequest, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +): """Generate a Facebook story with visual suggestions.""" try: logger.info(f"Generating Facebook story for business: {request.business_type}") @@ -143,6 +190,31 @@ async def generate_facebook_story(request: FacebookStoryRequest): if not response.success: raise HTTPException(status_code=400, detail=response.error) + # Save and track text content (non-blocking) + if response.content: + try: + user_id = None + if current_user: + user_id = str(current_user.get('id', '') or current_user.get('sub', '')) + + if user_id: + save_and_track_text_content( + db=db, + user_id=user_id, + content=response.content, + source_module="facebook_writer", + title=f"Facebook Story: {request.business_type[:60]}", + description=f"Facebook story for {request.business_type}", + prompt=f"Business Type: {request.business_type}\nStory Type: {request.story_type.value if hasattr(request.story_type, 'value') else request.story_type}", + tags=["facebook", "story", request.business_type.lower().replace(' ', '_')], + asset_metadata={ + "story_type": request.story_type.value if hasattr(request.story_type, 'value') else str(request.story_type) + }, + subdirectory="stories" + ) + except Exception as track_error: + logger.warning(f"Failed to track Facebook story asset: {track_error}") + return response except Exception as e: @@ -151,7 +223,11 @@ async def generate_facebook_story(request: FacebookStoryRequest): @router.post("/reel/generate", response_model=FacebookReelResponse) -async def generate_facebook_reel(request: FacebookReelRequest): +async def generate_facebook_reel( + request: FacebookReelRequest, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +): """Generate a Facebook reel script with music suggestions.""" try: logger.info(f"Generating Facebook reel for business: {request.business_type}") @@ -160,6 +236,42 @@ async def generate_facebook_reel(request: FacebookReelRequest): if not response.success: raise HTTPException(status_code=400, detail=response.error) + # Save and track text content (non-blocking) + if response.script: + try: + user_id = None + if current_user: + user_id = str(current_user.get('id', '') or current_user.get('sub', '')) + + if user_id: + text_content = f"# Facebook Reel Script\n\n## Script\n{response.script}\n" + if response.scene_breakdown: + text_content += f"\n## Scene Breakdown\n" + "\n".join([f"{i+1}. {scene}" for i, scene in enumerate(response.scene_breakdown)]) + "\n" + if response.music_suggestions: + text_content += f"\n## Music Suggestions\n" + "\n".join(response.music_suggestions) + "\n" + if response.hashtag_suggestions: + text_content += f"\n## Hashtag Suggestions\n" + " ".join([f"#{tag}" for tag in response.hashtag_suggestions]) + "\n" + + save_and_track_text_content( + db=db, + user_id=user_id, + content=text_content, + source_module="facebook_writer", + title=f"Facebook Reel: {request.topic[:60]}", + description=f"Facebook reel script for {request.business_type}", + prompt=f"Business Type: {request.business_type}\nTopic: {request.topic}\nReel Type: {request.reel_type.value if hasattr(request.reel_type, 'value') else request.reel_type}\nLength: {request.reel_length.value if hasattr(request.reel_length, 'value') else request.reel_length}", + tags=["facebook", "reel", request.business_type.lower().replace(' ', '_')], + asset_metadata={ + "reel_type": request.reel_type.value if hasattr(request.reel_type, 'value') else str(request.reel_type), + "reel_length": request.reel_length.value if hasattr(request.reel_length, 'value') else str(request.reel_length), + "reel_style": request.reel_style.value if hasattr(request.reel_style, 'value') else str(request.reel_style) + }, + subdirectory="reels", + file_extension=".md" + ) + except Exception as track_error: + logger.warning(f"Failed to track Facebook reel asset: {track_error}") + return response except Exception as e: @@ -168,7 +280,11 @@ async def generate_facebook_reel(request: FacebookReelRequest): @router.post("/carousel/generate", response_model=FacebookCarouselResponse) -async def generate_facebook_carousel(request: FacebookCarouselRequest): +async def generate_facebook_carousel( + request: FacebookCarouselRequest, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +): """Generate a Facebook carousel post with multiple slides.""" try: logger.info(f"Generating Facebook carousel for business: {request.business_type}") @@ -177,6 +293,44 @@ async def generate_facebook_carousel(request: FacebookCarouselRequest): if not response.success: raise HTTPException(status_code=400, detail=response.error) + # Save and track text content (non-blocking) + if response.main_caption and response.slides: + try: + user_id = None + if current_user: + user_id = str(current_user.get('id', '') or current_user.get('sub', '')) + + if user_id: + text_content = f"# Facebook Carousel\n\n## Main Caption\n{response.main_caption}\n\n" + text_content += "## Slides\n" + for i, slide in enumerate(response.slides, 1): + text_content += f"\n### Slide {i}: {slide.title}\n{slide.content}\n" + if slide.image_description: + text_content += f"Image Description: {slide.image_description}\n" + + if response.hashtag_suggestions: + text_content += f"\n## Hashtag Suggestions\n" + " ".join([f"#{tag}" for tag in response.hashtag_suggestions]) + "\n" + + save_and_track_text_content( + db=db, + user_id=user_id, + content=text_content, + source_module="facebook_writer", + title=f"Facebook Carousel: {request.topic[:60]}", + description=f"Facebook carousel for {request.business_type}", + prompt=f"Business Type: {request.business_type}\nTopic: {request.topic}\nCarousel Type: {request.carousel_type.value if hasattr(request.carousel_type, 'value') else request.carousel_type}\nSlides: {request.num_slides}", + tags=["facebook", "carousel", request.business_type.lower().replace(' ', '_')], + asset_metadata={ + "carousel_type": request.carousel_type.value if hasattr(request.carousel_type, 'value') else str(request.carousel_type), + "num_slides": request.num_slides, + "has_cta": request.include_cta + }, + subdirectory="carousels", + file_extension=".md" + ) + except Exception as track_error: + logger.warning(f"Failed to track Facebook carousel asset: {track_error}") + return response except Exception as e: @@ -186,7 +340,11 @@ async def generate_facebook_carousel(request: FacebookCarouselRequest): # Business Tools Endpoints @router.post("/event/generate", response_model=FacebookEventResponse) -async def generate_facebook_event(request: FacebookEventRequest): +async def generate_facebook_event( + request: FacebookEventRequest, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +): """Generate a Facebook event description.""" try: logger.info(f"Generating Facebook event: {request.event_name}") @@ -195,6 +353,36 @@ async def generate_facebook_event(request: FacebookEventRequest): if not response.success: raise HTTPException(status_code=400, detail=response.error) + # Save and track text content (non-blocking) + if response.description: + try: + user_id = None + if current_user: + user_id = str(current_user.get('id', '') or current_user.get('sub', '')) + + if user_id: + text_content = f"# Facebook Event: {request.event_name}\n\n## Description\n{response.description}\n" + if hasattr(response, 'details') and response.details: + text_content += f"\n## Details\n{response.details}\n" + + save_and_track_text_content( + db=db, + user_id=user_id, + content=text_content, + source_module="facebook_writer", + title=f"Facebook Event: {request.event_name[:60]}", + description=f"Facebook event description for {request.event_name}", + prompt=f"Event Name: {request.event_name}\nEvent Type: {getattr(request, 'event_type', 'N/A')}\nDate: {getattr(request, 'event_date', 'N/A')}", + tags=["facebook", "event", request.event_name.lower().replace(' ', '_')[:20]], + asset_metadata={ + "event_name": request.event_name, + "event_type": getattr(request, 'event_type', None) + }, + subdirectory="events" + ) + except Exception as track_error: + logger.warning(f"Failed to track Facebook event asset: {track_error}") + return response except Exception as e: @@ -203,7 +391,11 @@ async def generate_facebook_event(request: FacebookEventRequest): @router.post("/group-post/generate", response_model=FacebookGroupPostResponse) -async def generate_facebook_group_post(request: FacebookGroupPostRequest): +async def generate_facebook_group_post( + request: FacebookGroupPostRequest, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +): """Generate a Facebook group post following community guidelines.""" try: logger.info(f"Generating Facebook group post for: {request.group_name}") @@ -212,6 +404,32 @@ async def generate_facebook_group_post(request: FacebookGroupPostRequest): if not response.success: raise HTTPException(status_code=400, detail=response.error) + # Save and track text content (non-blocking) + if response.content: + try: + user_id = None + if current_user: + user_id = str(current_user.get('id', '') or current_user.get('sub', '')) + + if user_id: + save_and_track_text_content( + db=db, + user_id=user_id, + content=response.content, + source_module="facebook_writer", + title=f"Facebook Group Post: {request.group_name[:60]}", + description=f"Facebook group post for {request.group_name}", + prompt=f"Group Name: {request.group_name}\nTopic: {getattr(request, 'topic', 'N/A')}", + tags=["facebook", "group_post", request.group_name.lower().replace(' ', '_')[:20]], + asset_metadata={ + "group_name": request.group_name, + "group_type": getattr(request, 'group_type', None) + }, + subdirectory="group_posts" + ) + except Exception as track_error: + logger.warning(f"Failed to track Facebook group post asset: {track_error}") + return response except Exception as e: @@ -220,7 +438,11 @@ async def generate_facebook_group_post(request: FacebookGroupPostRequest): @router.post("/page-about/generate", response_model=FacebookPageAboutResponse) -async def generate_facebook_page_about(request: FacebookPageAboutRequest): +async def generate_facebook_page_about( + request: FacebookPageAboutRequest, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +): """Generate a Facebook page about section.""" try: logger.info(f"Generating Facebook page about for: {request.business_name}") @@ -229,6 +451,32 @@ async def generate_facebook_page_about(request: FacebookPageAboutRequest): if not response.success: raise HTTPException(status_code=400, detail=response.error) + # Save and track text content (non-blocking) + if response.about_section: + try: + user_id = None + if current_user: + user_id = str(current_user.get('id', '') or current_user.get('sub', '')) + + if user_id: + save_and_track_text_content( + db=db, + user_id=user_id, + content=response.about_section, + source_module="facebook_writer", + title=f"Facebook Page About: {request.business_name[:60]}", + description=f"Facebook page about section for {request.business_name}", + prompt=f"Business Name: {request.business_name}\nBusiness Type: {getattr(request, 'business_type', 'N/A')}", + tags=["facebook", "page_about", request.business_name.lower().replace(' ', '_')[:20]], + asset_metadata={ + "business_name": request.business_name, + "business_type": getattr(request, 'business_type', None) + }, + subdirectory="page_about" + ) + except Exception as track_error: + logger.warning(f"Failed to track Facebook page about asset: {track_error}") + return response except Exception as e: @@ -238,7 +486,11 @@ async def generate_facebook_page_about(request: FacebookPageAboutRequest): # Marketing Tools Endpoints @router.post("/ad-copy/generate", response_model=FacebookAdCopyResponse) -async def generate_facebook_ad_copy(request: FacebookAdCopyRequest): +async def generate_facebook_ad_copy( + request: FacebookAdCopyRequest, + current_user: Optional[Dict[str, Any]] = Depends(get_current_user), + db: Session = Depends(get_db) +): """Generate Facebook ad copy with targeting suggestions.""" try: logger.info(f"Generating Facebook ad copy for: {request.business_type}") @@ -247,6 +499,41 @@ async def generate_facebook_ad_copy(request: FacebookAdCopyRequest): if not response.success: raise HTTPException(status_code=400, detail=response.error) + # Save and track text content (non-blocking) + if response.ad_copy: + try: + user_id = None + if current_user: + user_id = str(current_user.get('id', '') or current_user.get('sub', '')) + + if user_id: + text_content = f"# Facebook Ad Copy\n\n## Ad Copy\n{response.ad_copy}\n" + if hasattr(response, 'headline') and response.headline: + text_content += f"\n## Headline\n{response.headline}\n" + if hasattr(response, 'description') and response.description: + text_content += f"\n## Description\n{response.description}\n" + if hasattr(response, 'targeting_suggestions') and response.targeting_suggestions: + text_content += f"\n## Targeting Suggestions\n" + "\n".join(response.targeting_suggestions) + "\n" + + save_and_track_text_content( + db=db, + user_id=user_id, + content=text_content, + source_module="facebook_writer", + title=f"Facebook Ad Copy: {request.business_type[:60]}", + description=f"Facebook ad copy for {request.business_type}", + prompt=f"Business Type: {request.business_type}\nAd Objective: {getattr(request, 'ad_objective', 'N/A')}\nTarget Audience: {getattr(request, 'target_audience', 'N/A')}", + tags=["facebook", "ad_copy", request.business_type.lower().replace(' ', '_')], + asset_metadata={ + "ad_objective": getattr(request, 'ad_objective', None), + "budget": getattr(request, 'budget', None) + }, + subdirectory="ad_copy", + file_extension=".md" + ) + except Exception as track_error: + logger.warning(f"Failed to track Facebook ad copy asset: {track_error}") + return response except Exception as e: diff --git a/backend/api/images.py b/backend/api/images.py index a6ec462a..d0e093bd 100644 --- a/backend/api/images.py +++ b/backend/api/images.py @@ -2,10 +2,14 @@ from __future__ import annotations import base64 import os +import uuid from typing import Optional, Dict, Any from datetime import datetime +from pathlib import Path +from sqlalchemy.orm import Session -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi.responses import FileResponse from pydantic import BaseModel, Field from services.llm_providers.main_image_generation import generate_image @@ -16,6 +20,8 @@ from middleware.auth_middleware import get_current_user from services.database import get_db from services.subscription import UsageTrackingService, PricingService from models.subscription_models import APIProvider, UsageSummary +from utils.asset_tracker import save_asset_to_library +from utils.file_storage import save_file_safely, generate_unique_filename, sanitize_filename router = APIRouter(prefix="/api/images", tags=["images"]) @@ -37,6 +43,7 @@ class ImageGenerateRequest(BaseModel): class ImageGenerateResponse(BaseModel): success: bool = True image_base64: str + image_url: Optional[str] = None # URL to saved image file width: int height: int provider: str @@ -47,7 +54,8 @@ class ImageGenerateResponse(BaseModel): @router.post("/generate", response_model=ImageGenerateResponse) def generate( req: ImageGenerateRequest, - current_user: Dict[str, Any] = Depends(get_current_user) + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db) ) -> ImageGenerateResponse: """Generate image with subscription checking.""" try: @@ -80,6 +88,78 @@ def generate( ) image_b64 = base64.b64encode(result.image_bytes).decode("utf-8") + # Save image to disk and track in asset library + image_url = None + image_filename = None + image_path = None + + try: + # Create output directory for image studio images + base_dir = Path(__file__).parent.parent + output_dir = base_dir / "image_studio_images" + + # Generate safe filename from prompt + clean_prompt = sanitize_filename(req.prompt[:50], max_length=50) + image_filename = generate_unique_filename( + prefix=f"img_{clean_prompt}", + extension=".png", + include_uuid=True + ) + + # Save file safely + image_path, save_error = save_file_safely( + content=result.image_bytes, + directory=output_dir, + filename=image_filename, + max_file_size=50 * 1024 * 1024 # 50MB for images + ) + + if image_path and not save_error: + # Generate file URL (will be served via API endpoint) + image_url = f"/api/images/image-studio/images/{image_path.name}" + + logger.info(f"[images.generate] Saved image to: {image_path} ({len(result.image_bytes)} bytes)") + + # Save to asset library (non-blocking) + try: + asset_id = save_asset_to_library( + db=db, + user_id=user_id, + asset_type="image", + source_module="image_studio", + filename=image_path.name, + file_url=image_url, + file_path=str(image_path), + file_size=len(result.image_bytes), + mime_type="image/png", + title=req.prompt[:100] if len(req.prompt) <= 100 else req.prompt[:97] + "...", + description=f"Generated image: {req.prompt[:200]}" if len(req.prompt) > 200 else req.prompt, + prompt=req.prompt, + tags=["image_studio", "generated", result.provider] if result.provider else ["image_studio", "generated"], + provider=result.provider, + model=result.model, + asset_metadata={ + "width": result.width, + "height": result.height, + "seed": result.seed, + "status": "completed", + "negative_prompt": req.negative_prompt + } + ) + if asset_id: + logger.info(f"[images.generate] ✅ Asset saved to library: ID={asset_id}, filename={image_path.name}") + else: + logger.warning(f"[images.generate] Asset tracking returned None (may have failed silently)") + except Exception as asset_error: + logger.error(f"[images.generate] Failed to save asset to library: {asset_error}", exc_info=True) + # Don't fail the request if asset tracking fails + else: + logger.warning(f"[images.generate] Failed to save image to disk: {save_error}") + # Continue without failing the request - base64 is still available + except Exception as save_error: + logger.error(f"[images.generate] Unexpected error saving image: {save_error}", exc_info=True) + # Continue without failing the request + # TRACK USAGE after successful image generation if result: logger.info(f"[images.generate] ✅ Image generation successful, tracking usage for user {user_id}") @@ -168,6 +248,7 @@ def generate( return ImageGenerateResponse( image_base64=image_b64, + image_url=image_url, width=result.width, height=result.height, provider=result.provider, @@ -226,6 +307,7 @@ class ImageEditRequest(BaseModel): class ImageEditResponse(BaseModel): success: bool = True image_base64: str + image_url: Optional[str] = None # URL to saved edited image file width: int height: int provider: str @@ -358,7 +440,8 @@ def suggest_prompts( @router.post("/edit", response_model=ImageEditResponse) def edit( req: ImageEditRequest, - current_user: Dict[str, Any] = Depends(get_current_user) + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db) ) -> ImageEditResponse: """Edit image with subscription checking.""" try: @@ -391,6 +474,78 @@ def edit( ) edited_image_b64 = base64.b64encode(result.image_bytes).decode("utf-8") + # Save edited image to disk and track in asset library + image_url = None + image_filename = None + image_path = None + + try: + # Create output directory for image studio edited images + base_dir = Path(__file__).parent.parent + output_dir = base_dir / "image_studio_images" / "edited" + + # Generate safe filename from prompt + clean_prompt = sanitize_filename(req.prompt[:50], max_length=50) + image_filename = generate_unique_filename( + prefix=f"edited_{clean_prompt}", + extension=".png", + include_uuid=True + ) + + # Save file safely + image_path, save_error = save_file_safely( + content=result.image_bytes, + directory=output_dir, + filename=image_filename, + max_file_size=50 * 1024 * 1024 # 50MB for images + ) + + if image_path and not save_error: + # Generate file URL + image_url = f"/api/images/image-studio/images/edited/{image_path.name}" + + logger.info(f"[images.edit] Saved edited image to: {image_path} ({len(result.image_bytes)} bytes)") + + # Save to asset library (non-blocking) + try: + asset_id = save_asset_to_library( + db=db, + user_id=user_id, + asset_type="image", + source_module="image_studio", + filename=image_path.name, + file_url=image_url, + file_path=str(image_path), + file_size=len(result.image_bytes), + mime_type="image/png", + title=f"Edited: {req.prompt[:100]}" if len(req.prompt) <= 100 else f"Edited: {req.prompt[:97]}...", + description=f"Edited image with prompt: {req.prompt[:200]}" if len(req.prompt) > 200 else f"Edited image with prompt: {req.prompt}", + prompt=req.prompt, + tags=["image_studio", "edited", result.provider] if result.provider else ["image_studio", "edited"], + provider=result.provider, + model=result.model, + asset_metadata={ + "width": result.width, + "height": result.height, + "seed": result.seed, + "status": "completed", + "operation": "edit" + } + ) + if asset_id: + logger.info(f"[images.edit] ✅ Asset saved to library: ID={asset_id}, filename={image_path.name}") + else: + logger.warning(f"[images.edit] Asset tracking returned None (may have failed silently)") + except Exception as asset_error: + logger.error(f"[images.edit] Failed to save asset to library: {asset_error}", exc_info=True) + # Don't fail the request if asset tracking fails + else: + logger.warning(f"[images.edit] Failed to save edited image to disk: {save_error}") + # Continue without failing the request - base64 is still available + except Exception as save_error: + logger.error(f"[images.edit] Unexpected error saving edited image: {save_error}", exc_info=True) + # Continue without failing the request + # TRACK USAGE after successful image editing if result: logger.info(f"[images.edit] ✅ Image editing successful, tracking usage for user {user_id}") @@ -478,6 +633,7 @@ def edit( return ImageEditResponse( image_base64=edited_image_b64, + image_url=image_url, width=result.width, height=result.height, provider=result.provider, @@ -494,3 +650,55 @@ def edit( detail="Image editing service is temporarily unavailable or the connection was reset. Please try again." ) + +# --------------------------- +# Image Serving Endpoints +# --------------------------- + +@router.get("/image-studio/images/{image_filename:path}") +async def serve_image_studio_image( + image_filename: str, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Serve a generated or edited image from Image Studio.""" + try: + if not current_user: + raise HTTPException(status_code=401, detail="Authentication required") + + # Determine if it's an edited image or regular image + base_dir = Path(__file__).parent.parent + image_studio_dir = (base_dir / "image_studio_images").resolve() + + if image_filename.startswith("edited/"): + # Remove "edited/" prefix and serve from edited directory + actual_filename = image_filename.replace("edited/", "", 1) + image_path = (image_studio_dir / "edited" / actual_filename).resolve() + base_subdir = (image_studio_dir / "edited").resolve() + else: + image_path = (image_studio_dir / image_filename).resolve() + base_subdir = image_studio_dir + + # Security: Prevent directory traversal attacks + # Ensure the resolved path is within the intended directory + try: + image_path.relative_to(base_subdir) + except ValueError: + raise HTTPException( + status_code=403, + detail="Access denied: Invalid image path" + ) + + if not image_path.exists(): + raise HTTPException(status_code=404, detail="Image not found") + + return FileResponse( + path=str(image_path), + media_type="image/png", + filename=image_path.name + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"[images] Failed to serve image: {e}") + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/backend/api/story_writer/router.py b/backend/api/story_writer/router.py index 74da0a67..05ba88fb 100644 --- a/backend/api/story_writer/router.py +++ b/backend/api/story_writer/router.py @@ -1,66 +1,23 @@ """ Story Writer API Router -Main router for story generation operations including premise, outline, -content generation, and full story creation. +Main router for story generation operations. This file serves as the entry point +and includes modular sub-routers for different functionality areas. """ -import mimetypes -from pathlib import Path -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request -from loguru import logger -from middleware.auth_middleware import get_current_user, get_current_user_with_query_token +from fastapi import APIRouter -from models.story_models import ( - AnimateSceneRequest, - AnimateSceneVoiceoverRequest, - AnimateSceneResponse, - ResumeSceneAnimationRequest, - StoryGenerationRequest, - StorySetupGenerationRequest, - StorySetupGenerationResponse, - StorySetupOption, - StoryStartRequest, - StoryPremiseResponse, - StoryOutlineResponse, - StoryScene, - StoryContentResponse, - StoryFullGenerationResponse, - StoryContinueRequest, - StoryContinueResponse, - StoryImageGenerationRequest, - StoryImageGenerationResponse, - StoryImageResult, - StoryAudioGenerationRequest, - StoryAudioGenerationResponse, - StoryAudioResult, - StoryVideoGenerationRequest, - StoryVideoGenerationResponse, - StoryVideoResult, - TaskStatus, +from .routes import ( + cache_routes, + media_generation, + scene_animation, + story_content, + story_setup, + story_tasks, + video_generation, ) -from pydantic import BaseModel, Field -from services.database import get_db -from services.llm_providers.main_video_generation import track_video_usage -from services.story_writer.story_service import StoryWriterService -from services.story_writer.video_generation_service import StoryVideoGenerationService -from services.subscription import PricingService -from services.subscription.preflight_validator import validate_scene_animation_operation -from services.wavespeed.kling_animation import animate_scene_image, resume_scene_animation -from services.wavespeed.infinitetalk import animate_scene_with_voiceover -from uuid import uuid4 -from utils.logger_utils import get_service_logger - -from .cache_manager import cache_manager -from .routes import cache_routes, media_generation, story_content, story_setup, story_tasks, video_generation -from .task_manager import task_manager -from .utils.auth import require_authenticated_user -from .utils.hd_video import generate_hd_video_payload, generate_hd_video_scene_payload -from .utils.media_utils import load_story_image_bytes, load_story_audio_bytes, resolve_media_file -from urllib.parse import quote - router = APIRouter(prefix="/api/story", tags=["Story Writer"]) @@ -69,1718 +26,12 @@ router.include_router(story_setup.router) router.include_router(story_content.router) router.include_router(story_tasks.router) router.include_router(media_generation.router) +router.include_router(scene_animation.router) router.include_router(video_generation.router) router.include_router(cache_routes.router) -service = StoryWriterService() -scene_logger = get_service_logger("api.story_writer.scene_animation") -AI_VIDEO_SUBDIR = Path("AI_Videos") - - -def _build_authenticated_media_url(request: Request, path: str) -> str: - """Append the caller's auth token to a media URL so