Spaces:
Sleeping
Sleeping
Commit Β·
82a1419
1
Parent(s): 593851d
Enhance prompt validation and safety features
Browse files- Added new endpoints for prompt validation and safety checks in `prompt_validator.py` and `prompt_safety.py`.
- Implemented content validation to catch potential policy violations before prompt generation.
- Introduced automatic prompt sanitization to modify unsafe content while preserving intent.
- Updated the API to include streaming prompt generation using GPT-5.2 in `prompt_generation.py`.
- Removed the deprecated pricing endpoint in `pricing.py`.
- Added a new script `run-dev.sh` for easier development setup with auto-rebuild for the frontend.
- Updated `.gitignore` to include prompt cache directory and ensure proper asset serving in production.
- Enhanced error handling in `video_generation.py` to improve feedback on failures.
- .gitignore +2 -0
- api/image_service.py +35 -15
- api/pricing.py +0 -157
- api/prompt_generation.py +514 -131
- api/prompt_safety.py +205 -0
- api/prompt_validator.py +155 -0
- api/veo_error_handler.py +272 -0
- api/video_generation.py +5 -2
- frontend/FLOW.md +1 -1
- frontend/README.md +15 -3
- frontend/package.json +1 -0
- frontend/src/App.tsx +76 -2
- frontend/src/components/ErrorDisplay.tsx +25 -5
- frontend/src/components/GenerationComplete.tsx +41 -2
- frontend/src/components/GenerationForm.tsx +958 -326
- frontend/src/components/GenerationProgress.tsx +36 -1
- frontend/src/components/Icons.tsx +49 -0
- frontend/src/components/ProviderSelect.tsx +21 -24
- frontend/src/components/SavedPromptsLibrary.tsx +388 -0
- frontend/src/components/SegmentGenerationProgress.tsx +221 -0
- frontend/src/components/SegmentPromptsViewer.tsx +304 -0
- frontend/src/components/index.ts +3 -0
- frontend/src/context/GenerationContext.tsx +58 -19
- frontend/src/types/index.ts +4 -6
- frontend/src/utils/api.ts +352 -174
- frontend/tailwind.config.js +5 -0
- main.py +30 -5
- run-dev.sh +39 -0
- standalone_video_creator.py +2 -2
- storage/prompt_cache/.gitkeep +0 -0
- utils/prompt_cache.py +201 -0
- utils/prompt_generator.py +609 -25
.gitignore
CHANGED
|
@@ -34,8 +34,10 @@ ENV/
|
|
| 34 |
# Storage
|
| 35 |
storage/images/*
|
| 36 |
storage/videos/*
|
|
|
|
| 37 |
!storage/images/.gitkeep
|
| 38 |
!storage/videos/.gitkeep
|
|
|
|
| 39 |
|
| 40 |
# IDE
|
| 41 |
.vscode/
|
|
|
|
| 34 |
# Storage
|
| 35 |
storage/images/*
|
| 36 |
storage/videos/*
|
| 37 |
+
storage/prompt_cache/*
|
| 38 |
!storage/images/.gitkeep
|
| 39 |
!storage/videos/.gitkeep
|
| 40 |
+
!storage/prompt_cache/.gitkeep
|
| 41 |
|
| 42 |
# IDE
|
| 43 |
.vscode/
|
api/image_service.py
CHANGED
|
@@ -3,39 +3,59 @@ Image Service API endpoints
|
|
| 3 |
Handles image compression, storage, and serving
|
| 4 |
"""
|
| 5 |
|
| 6 |
-
from fastapi import APIRouter, HTTPException, Response, UploadFile, File
|
| 7 |
from fastapi.responses import JSONResponse
|
| 8 |
from utils.storage import temp_images
|
| 9 |
from utils.image_processor import compress_and_store_image
|
| 10 |
import os
|
|
|
|
| 11 |
|
| 12 |
router = APIRouter()
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
@router.post("/upload-image")
|
| 15 |
-
async def upload_image(
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
-
Upload and host an image, returns public URL
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
try:
|
| 20 |
-
# Read image bytes
|
| 21 |
image_bytes = await file.read()
|
| 22 |
-
|
| 23 |
-
# Convert to data URL for processing
|
| 24 |
import base64
|
| 25 |
encoded = base64.b64encode(image_bytes).decode('utf-8')
|
| 26 |
-
data_url = f"data:{file.content_type};base64,{encoded}"
|
| 27 |
-
|
| 28 |
-
# Get public URL from env or use default
|
| 29 |
public_url = os.getenv('VITE_API_BASE_URL', 'http://localhost:4000')
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return JSONResponse(content={
|
| 35 |
"url": hosted_url,
|
| 36 |
-
"filename": file.filename
|
| 37 |
})
|
| 38 |
-
|
| 39 |
except Exception as e:
|
| 40 |
raise HTTPException(status_code=500, detail=f"Image upload failed: {str(e)}")
|
| 41 |
|
|
|
|
| 3 |
Handles image compression, storage, and serving
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Response, UploadFile, File, Query
|
| 7 |
from fastapi.responses import JSONResponse
|
| 8 |
from utils.storage import temp_images
|
| 9 |
from utils.image_processor import compress_and_store_image
|
| 10 |
import os
|
| 11 |
+
import re
|
| 12 |
|
| 13 |
router = APIRouter()
|
| 14 |
|
| 15 |
+
# High-quality settings for reference/continuity frames (last frame of previous segment)
|
| 16 |
+
REFERENCE_FRAME_QUALITY = 92
|
| 17 |
+
REFERENCE_FRAME_MAX_WIDTH = 1920
|
| 18 |
+
REFERENCE_FRAME_MAX_HEIGHT = 1080
|
| 19 |
+
|
| 20 |
+
def _is_reference_frame_filename(filename: str) -> bool:
|
| 21 |
+
if not filename:
|
| 22 |
+
return False
|
| 23 |
+
name = filename.lower()
|
| 24 |
+
return bool(re.match(r"^(frame-|last-frame\.|whisper-frame-)", name) or "frame" in name and name.endswith((".jpg", ".jpeg", ".png")))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
@router.post("/upload-image")
|
| 28 |
+
async def upload_image(
|
| 29 |
+
file: UploadFile = File(...),
|
| 30 |
+
reference: bool = Query(False, description="High quality for last-frame/reference uploads"),
|
| 31 |
+
):
|
| 32 |
"""
|
| 33 |
+
Upload and host an image, returns public URL.
|
| 34 |
+
Use ?reference=true when uploading a continuity/reference frame (last frame of previous segment)
|
| 35 |
+
for higher quality and less downscaling.
|
| 36 |
"""
|
| 37 |
try:
|
|
|
|
| 38 |
image_bytes = await file.read()
|
|
|
|
|
|
|
| 39 |
import base64
|
| 40 |
encoded = base64.b64encode(image_bytes).decode('utf-8')
|
| 41 |
+
data_url = f"data:{file.content_type or 'image/jpeg'};base64,{encoded}"
|
|
|
|
|
|
|
| 42 |
public_url = os.getenv('VITE_API_BASE_URL', 'http://localhost:4000')
|
| 43 |
+
|
| 44 |
+
use_high_quality = reference or _is_reference_frame_filename(file.filename or "")
|
| 45 |
+
if use_high_quality:
|
| 46 |
+
hosted_url = await compress_and_store_image(
|
| 47 |
+
data_url, public_url,
|
| 48 |
+
max_width=REFERENCE_FRAME_MAX_WIDTH,
|
| 49 |
+
max_height=REFERENCE_FRAME_MAX_HEIGHT,
|
| 50 |
+
quality=REFERENCE_FRAME_QUALITY,
|
| 51 |
+
)
|
| 52 |
+
else:
|
| 53 |
+
hosted_url = await compress_and_store_image(data_url, public_url)
|
| 54 |
+
|
| 55 |
return JSONResponse(content={
|
| 56 |
"url": hosted_url,
|
| 57 |
+
"filename": file.filename,
|
| 58 |
})
|
|
|
|
| 59 |
except Exception as e:
|
| 60 |
raise HTTPException(status_code=500, detail=f"Image upload failed: {str(e)}")
|
| 61 |
|
api/pricing.py
DELETED
|
@@ -1,157 +0,0 @@
|
|
| 1 |
-
"""Pricing endpoints for the frontend estimator
|
| 2 |
-
|
| 3 |
-
Provides:
|
| 4 |
-
- GET /pricing -> list of supported providers (id, name, summary)
|
| 5 |
-
- POST /pricing/estimate -> estimated cost + breakdown + assumptions
|
| 6 |
-
|
| 7 |
-
"""
|
| 8 |
-
from fastapi import APIRouter, HTTPException
|
| 9 |
-
from pydantic import BaseModel
|
| 10 |
-
from typing import Optional, Dict, Any
|
| 11 |
-
|
| 12 |
-
router = APIRouter()
|
| 13 |
-
|
| 14 |
-
# Replicate `google/veo-3-fast` (with_audio): $0.15 / second
|
| 15 |
-
# - equivalently: ~$10 / 66 seconds (user-provided)
|
| 16 |
-
REPLICATE_VEO3_FAST_WITH_AUDIO = 0.15
|
| 17 |
-
|
| 18 |
-
# KIE Veo 3 Fast (with audio): user-provided evidence: $0.40 for an 8s video
|
| 19 |
-
# => $0.40 / 8s = $0.05 / second
|
| 20 |
-
KIE_VEO3_FAST_WITH_AUDIO = 0.05
|
| 21 |
-
|
| 22 |
-
# Provider catalogue for frontend list
|
| 23 |
-
PROVIDERS = [
|
| 24 |
-
{"id": "kie", "name": "Kie API", "summary": "Veo 3.1 Fast (assumed audio)"},
|
| 25 |
-
{"id": "replicate", "name": "Replicate API", "summary": "google/veo-3-fast (with audio)"},
|
| 26 |
-
{"id": "openai", "name": "OpenAI", "summary": "Text & multimodal pricing"},
|
| 27 |
-
]
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
class PricingEstimateRequest(BaseModel):
|
| 31 |
-
provider: str
|
| 32 |
-
# seconds for video (optional for token-based providers)
|
| 33 |
-
video_seconds: Optional[float] = None
|
| 34 |
-
# quality / tier (optional)
|
| 35 |
-
quality: Optional[str] = None
|
| 36 |
-
# for token-based providers
|
| 37 |
-
input_tokens: Optional[int] = None
|
| 38 |
-
output_tokens: Optional[int] = None
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
@router.get("/pricing")
|
| 42 |
-
async def list_pricing_providers():
|
| 43 |
-
# Enrich provider list with sample pricing for common durations so
|
| 44 |
-
# the frontend can display costs on the provider cards without
|
| 45 |
-
# making multiple estimate requests.
|
| 46 |
-
def make_sample(pr_id: str):
|
| 47 |
-
if pr_id == 'replicate':
|
| 48 |
-
per_second = REPLICATE_VEO3_FAST_WITH_AUDIO
|
| 49 |
-
evidence = "model variant is with_audio β $0.15/sec (user-provided)"
|
| 50 |
-
elif pr_id == 'kie':
|
| 51 |
-
per_second = KIE_VEO3_FAST_WITH_AUDIO
|
| 52 |
-
evidence = "Veo 3 Fast (8s, with audio) β $0.40 per 8s (user-provided)"
|
| 53 |
-
else:
|
| 54 |
-
per_second = None
|
| 55 |
-
evidence = None
|
| 56 |
-
|
| 57 |
-
if per_second is None:
|
| 58 |
-
return {"per_second": None, "samples": {}, "evidence": evidence}
|
| 59 |
-
|
| 60 |
-
samples = {
|
| 61 |
-
"1s": round(per_second, 4),
|
| 62 |
-
"8s": round(per_second * 8, 4),
|
| 63 |
-
"30s": round(per_second * 30, 4),
|
| 64 |
-
"60s": round(per_second * 60, 4),
|
| 65 |
-
}
|
| 66 |
-
return {"per_second": per_second, "samples": samples, "evidence": evidence}
|
| 67 |
-
|
| 68 |
-
providers_with_pricing = []
|
| 69 |
-
for p in PROVIDERS:
|
| 70 |
-
info = p.copy()
|
| 71 |
-
pricing = make_sample(p['id'])
|
| 72 |
-
info['pricing'] = pricing
|
| 73 |
-
providers_with_pricing.append(info)
|
| 74 |
-
|
| 75 |
-
return {"providers": providers_with_pricing}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
@router.post("/pricing/estimate")
|
| 79 |
-
async def estimate_pricing(req: PricingEstimateRequest) -> Dict[str, Any]:
|
| 80 |
-
"""Return an approximate USD estimate, breakdown and assumptions.
|
| 81 |
-
|
| 82 |
-
Important: all video estimates assume audio is generated (product decision).
|
| 83 |
-
"""
|
| 84 |
-
provider = (req.provider or "").lower()
|
| 85 |
-
|
| 86 |
-
if provider not in {"kie", "replicate", "openai"}:
|
| 87 |
-
raise HTTPException(status_code=400, detail="Unsupported provider")
|
| 88 |
-
|
| 89 |
-
# OpenAI (token-based) - pass through a simple token cost example
|
| 90 |
-
if provider == "openai":
|
| 91 |
-
input_t = req.input_tokens or 0
|
| 92 |
-
output_t = req.output_tokens or 0
|
| 93 |
-
# NOTE: these are illustrative values β frontend already marks assumptions
|
| 94 |
-
cost_per_input_1k = 3.00 / 1_000_000 # $3 / 1M input tokens -> per-token
|
| 95 |
-
cost_per_output_1k = 0.015 / 1_000 # $0.015 / 1k output tokens
|
| 96 |
-
estimated = input_t * cost_per_input_1k + output_t * cost_per_output_1k
|
| 97 |
-
return {
|
| 98 |
-
"estimated_cost_usd": round(estimated, 6),
|
| 99 |
-
"breakdown": {
|
| 100 |
-
"input_tokens": input_t,
|
| 101 |
-
"output_tokens": output_t,
|
| 102 |
-
"input_cost": round(input_t * cost_per_input_1k, 6),
|
| 103 |
-
"output_cost": round(output_t * cost_per_output_1k, 6),
|
| 104 |
-
},
|
| 105 |
-
"assumptions": {
|
| 106 |
-
"note": "Token pricing is illustrative β replace with your OpenAI contract rates",
|
| 107 |
-
}
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
# Video-based providers require `video_seconds`
|
| 111 |
-
if req.video_seconds is None:
|
| 112 |
-
raise HTTPException(status_code=400, detail="video_seconds is required for video providers")
|
| 113 |
-
|
| 114 |
-
seconds = float(req.video_seconds)
|
| 115 |
-
|
| 116 |
-
if provider == "replicate":
|
| 117 |
-
# Use published Veo 3 with_audio rate as conservative baseline
|
| 118 |
-
per_second = REPLICATE_VEO3_FAST_WITH_AUDIO
|
| 119 |
-
estimated = seconds * per_second
|
| 120 |
-
return {
|
| 121 |
-
"estimated_cost_usd": round(estimated, 6),
|
| 122 |
-
"breakdown": {
|
| 123 |
-
"per_second": per_second,
|
| 124 |
-
"seconds": seconds,
|
| 125 |
-
"line_item": round(seconds * per_second, 6)
|
| 126 |
-
},
|
| 127 |
-
"assumptions": {
|
| 128 |
-
"audio": True,
|
| 129 |
-
"model_variant": "with_audio",
|
| 130 |
-
"evidence_text": "model variant is with_audio β $0.15 per second of output video (or around 66 seconds for $10) β replicate google/veo-3-fast",
|
| 131 |
-
"source": "user-provided (project owner) β treated as authoritative for estimator",
|
| 132 |
-
"note": "google/veo-3-fast was provided as $0.15/sec (user). Replace if official published rate differs."
|
| 133 |
-
}
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
if provider == "kie":
|
| 137 |
-
# Use placeholder β must be replaced by an authoritative vendor rate
|
| 138 |
-
per_second = KIE_VEO3_FAST_WITH_AUDIO
|
| 139 |
-
estimated = seconds * per_second
|
| 140 |
-
return {
|
| 141 |
-
"estimated_cost_usd": round(estimated, 6),
|
| 142 |
-
"breakdown": {
|
| 143 |
-
"per_second": per_second,
|
| 144 |
-
"seconds": seconds,
|
| 145 |
-
"line_item": round(seconds * per_second, 6)
|
| 146 |
-
},
|
| 147 |
-
"assumptions": {
|
| 148 |
-
"audio": True,
|
| 149 |
-
"model_variant": "with_audio",
|
| 150 |
-
"evidence_text": "Veo 3 Fast (8 s, with audio) β Kie.ai $0.40 (β$0.05/sec). About $0.40 per 8-second video with audio on Kie.ai.",
|
| 151 |
-
"source": "user-provided (project owner)",
|
| 152 |
-
"note": "KIE rate was supplied by project owner and used for estimator. Replace with vendor contract if different."
|
| 153 |
-
}
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
# Fallback
|
| 157 |
-
raise HTTPException(status_code=500, detail="Failed to compute estimate")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/prompt_generation.py
CHANGED
|
@@ -1,44 +1,39 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
Structured, validated segment generation for video prompts
|
| 4 |
"""
|
| 5 |
|
| 6 |
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
| 7 |
-
from fastapi.responses import JSONResponse
|
| 8 |
-
from pydantic import BaseModel
|
| 9 |
from typing import Optional
|
| 10 |
import base64
|
|
|
|
| 11 |
|
| 12 |
from utils.prompt_generator import (
|
| 13 |
VeoInputs,
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
)
|
| 17 |
from openai import OpenAI
|
| 18 |
import os
|
| 19 |
import json
|
|
|
|
| 20 |
|
| 21 |
router = APIRouter()
|
| 22 |
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
script: str
|
| 27 |
-
style: str = "clean, lifestyle UGC"
|
| 28 |
-
jsonFormat: str = "standard"
|
| 29 |
-
continuationMode: bool = True
|
| 30 |
-
voiceType: Optional[str] = None
|
| 31 |
-
energyLevel: Optional[str] = None
|
| 32 |
-
settingMode: str = "single"
|
| 33 |
-
cameraStyle: Optional[str] = "handheld steadicam"
|
| 34 |
-
energyArc: Optional[str] = None
|
| 35 |
-
narrativeStyle: Optional[str] = "direct address"
|
| 36 |
-
accentRegion: Optional[str] = None
|
| 37 |
-
model: str = "gpt-4o"
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
@router.post("/generate-prompts")
|
| 41 |
-
async def generate_prompts_api(
|
| 42 |
script: str = Form(...),
|
| 43 |
style: str = Form("clean, lifestyle UGC"),
|
| 44 |
jsonFormat: str = Form("standard"),
|
|
@@ -50,82 +45,225 @@ async def generate_prompts_api(
|
|
| 50 |
energyArc: Optional[str] = Form(None),
|
| 51 |
narrativeStyle: Optional[str] = Form("direct address"),
|
| 52 |
accentRegion: Optional[str] = Form(None),
|
| 53 |
-
model: str = Form("gpt-
|
| 54 |
-
image: UploadFile = File(...)
|
|
|
|
|
|
|
|
|
|
| 55 |
):
|
| 56 |
"""
|
| 57 |
-
|
| 58 |
|
| 59 |
This endpoint:
|
| 60 |
-
1.
|
| 61 |
-
2.
|
| 62 |
-
3.
|
| 63 |
-
4.
|
| 64 |
-
|
| 65 |
-
Accepts multipart/form-data with:
|
| 66 |
-
- script: The video script text
|
| 67 |
-
- style: Visual style description
|
| 68 |
-
- image: Character reference image (required)
|
| 69 |
-
- Other optional parameters for fine-tuning
|
| 70 |
|
| 71 |
Returns:
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
try:
|
| 75 |
-
# Read image
|
| 76 |
image_bytes = await image.read()
|
| 77 |
-
print(f"π· Received reference image: {len(image_bytes)} bytes")
|
| 78 |
-
|
| 79 |
-
# Convert continuationMode string to boolean
|
| 80 |
-
continuation_mode = continuationMode.lower() == "true"
|
| 81 |
-
|
| 82 |
-
# Create inputs from form data
|
| 83 |
-
inputs = VeoInputs(
|
| 84 |
-
script=script,
|
| 85 |
-
style=style,
|
| 86 |
-
jsonFormat=jsonFormat,
|
| 87 |
-
continuationMode=continuation_mode,
|
| 88 |
-
voiceType=voiceType if voiceType else None,
|
| 89 |
-
energyLevel=energyLevel if energyLevel else None,
|
| 90 |
-
settingMode=settingMode,
|
| 91 |
-
cameraStyle=cameraStyle if cameraStyle else None,
|
| 92 |
-
energyArc=energyArc if energyArc else None,
|
| 93 |
-
narrativeStyle=narrativeStyle if narrativeStyle else None,
|
| 94 |
-
accentRegion=accentRegion if accentRegion else None
|
| 95 |
-
)
|
| 96 |
-
|
| 97 |
-
# Check environment mode
|
| 98 |
-
environment = os.getenv('ENVIRONMENT', 'dev').lower()
|
| 99 |
-
is_dev_mode = environment == 'dev' or environment == 'development'
|
| 100 |
-
|
| 101 |
-
# Generate payload
|
| 102 |
-
payload = generate_segments_payload(
|
| 103 |
-
inputs=inputs,
|
| 104 |
-
image_bytes=image_bytes,
|
| 105 |
-
model=model
|
| 106 |
-
)
|
| 107 |
-
|
| 108 |
-
# Add environment mode to response
|
| 109 |
-
payload['environment'] = environment
|
| 110 |
-
payload['is_dev_mode'] = is_dev_mode
|
| 111 |
-
payload['max_segments'] = 2 if is_dev_mode else None
|
| 112 |
-
|
| 113 |
-
# Validation warnings (if any) are logged to console but don't block
|
| 114 |
-
return JSONResponse(content=payload)
|
| 115 |
-
|
| 116 |
except Exception as e:
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
@router.post("/split-script")
|
| 125 |
async def split_script_api(
|
| 126 |
script: str = Form(...),
|
| 127 |
seconds_per_segment: int = Form(8),
|
| 128 |
-
words_per_second: float = Form(2.
|
| 129 |
):
|
| 130 |
"""
|
| 131 |
Split script into segments for preview
|
|
@@ -153,29 +291,36 @@ async def split_script_api(
|
|
| 153 |
|
| 154 |
|
| 155 |
@router.post("/validate-payload")
|
| 156 |
-
async def validate_payload_api(
|
| 157 |
"""
|
| 158 |
-
Validate a segments payload
|
| 159 |
|
| 160 |
-
|
|
|
|
|
|
|
| 161 |
"""
|
| 162 |
try:
|
| 163 |
-
from utils.prompt_generator import validate_segments_payload
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
expected_segments = len(payload.get("segments", []))
|
| 166 |
errors = validate_segments_payload(payload, expected_segments)
|
| 167 |
-
|
| 168 |
if errors:
|
| 169 |
-
return {
|
| 170 |
-
|
| 171 |
-
"errors": errors
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
return {
|
| 175 |
-
"valid": True,
|
| 176 |
-
"message": "Payload is valid"
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
except Exception as e:
|
| 180 |
raise HTTPException(
|
| 181 |
status_code=500,
|
|
@@ -186,7 +331,7 @@ async def validate_payload_api(payload: dict):
|
|
| 186 |
@router.get("/prompt-status")
|
| 187 |
async def prompt_status():
|
| 188 |
"""
|
| 189 |
-
Check if GPT-
|
| 190 |
"""
|
| 191 |
import os
|
| 192 |
|
|
@@ -194,7 +339,7 @@ async def prompt_status():
|
|
| 194 |
|
| 195 |
return {
|
| 196 |
"available": bool(openai_key),
|
| 197 |
-
"message": "GPT-
|
| 198 |
else "Add OPENAI_API_KEY to .env.local"
|
| 199 |
}
|
| 200 |
|
|
@@ -208,8 +353,8 @@ async def refine_prompt_for_continuity(
|
|
| 208 |
):
|
| 209 |
"""
|
| 210 |
Refine a segment prompt to match the actual visual AND audio from the previous segment.
|
| 211 |
-
|
| 212 |
-
This ensures perfect continuity by having GPT-
|
| 213 |
1. The last frame (visual consistency)
|
| 214 |
2. The transcribed dialogue (audio consistency - what was actually said)
|
| 215 |
"""
|
|
@@ -307,38 +452,56 @@ Return ONLY the updated JSON segment object with the same structure. No explanat
|
|
| 307 |
|
| 308 |
print(f"π Refining prompt for visual continuity...")
|
| 309 |
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
{
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
"type": "image_url",
|
| 323 |
-
"image_url": {
|
| 324 |
-
"url": f"data:image/jpeg;base64,{encoded_image}"
|
| 325 |
-
}
|
| 326 |
-
}
|
| 327 |
-
]
|
| 328 |
-
}
|
| 329 |
-
],
|
| 330 |
-
response_format={"type": "json_object"},
|
| 331 |
-
temperature=0.3, # Lower temperature for precise matching
|
| 332 |
-
)
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
|
|
|
|
| 339 |
return JSONResponse(content={
|
| 340 |
"refined_prompt": refined_prompt,
|
| 341 |
-
"original_prompt": segment_data
|
| 342 |
})
|
| 343 |
|
| 344 |
except Exception as e:
|
|
@@ -348,3 +511,223 @@ Return ONLY the updated JSON segment object with the same structure. No explanat
|
|
| 348 |
detail=f"Prompt refinement failed: {str(e)}"
|
| 349 |
)
|
| 350 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Prompt Generation API (streaming)
|
| 3 |
+
Structured, validated segment generation for video prompts via GPT-5.2
|
| 4 |
"""
|
| 5 |
|
| 6 |
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
| 7 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
|
|
| 8 |
from typing import Optional
|
| 9 |
import base64
|
| 10 |
+
import asyncio
|
| 11 |
|
| 12 |
from utils.prompt_generator import (
|
| 13 |
VeoInputs,
|
| 14 |
+
split_script_into_segments,
|
| 15 |
+
generate_single_segment,
|
| 16 |
+
generate_segment_plan_ai,
|
| 17 |
+
SEGMENT_DURATION_SECONDS,
|
| 18 |
+
)
|
| 19 |
+
from utils.prompt_cache import (
|
| 20 |
+
save_prompt,
|
| 21 |
+
get_prompt,
|
| 22 |
+
update_prompt,
|
| 23 |
+
list_prompts,
|
| 24 |
+
delete_prompt,
|
| 25 |
+
cleanup_old_prompts
|
| 26 |
)
|
| 27 |
from openai import OpenAI
|
| 28 |
import os
|
| 29 |
import json
|
| 30 |
+
import uuid
|
| 31 |
|
| 32 |
router = APIRouter()
|
| 33 |
|
| 34 |
|
| 35 |
+
@router.post("/generate-prompts-stream")
|
| 36 |
+
async def generate_prompts_stream(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
script: str = Form(...),
|
| 38 |
style: str = Form("clean, lifestyle UGC"),
|
| 39 |
jsonFormat: str = Form("standard"),
|
|
|
|
| 45 |
energyArc: Optional[str] = Form(None),
|
| 46 |
narrativeStyle: Optional[str] = Form("direct address"),
|
| 47 |
accentRegion: Optional[str] = Form(None),
|
| 48 |
+
model: str = Form("gpt-5.2"),
|
| 49 |
+
image: UploadFile = File(...),
|
| 50 |
+
segment_mode: str = Form("fixed"),
|
| 51 |
+
allowed_durations: Optional[str] = Form(None),
|
| 52 |
+
seconds_per_segment: int = Form(8),
|
| 53 |
):
|
| 54 |
"""
|
| 55 |
+
π STREAMING endpoint - Generate segments with real-time progress
|
| 56 |
|
| 57 |
This endpoint:
|
| 58 |
+
1. Streams each segment as it's generated (one-by-one)
|
| 59 |
+
2. Sends progress updates in real-time
|
| 60 |
+
3. Uses GPT-5.2 for individual segment generation
|
| 61 |
+
4. Provides immediate feedback to the frontend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
Returns:
|
| 64 |
+
Newline-delimited JSON (NDJSON) stream with events:
|
| 65 |
+
- "start": Initial event with total_segments
|
| 66 |
+
- "segment": Each generated segment with progress
|
| 67 |
+
- "complete": Final completion event
|
| 68 |
+
- "error": Error event if something fails
|
| 69 |
"""
|
| 70 |
+
|
| 71 |
+
# π§ FIX: Read image BEFORE entering async generator
|
| 72 |
+
# The file handle will be closed by FastAPI after this function returns,
|
| 73 |
+
# so we must read the bytes into memory first
|
| 74 |
try:
|
|
|
|
| 75 |
image_bytes = await image.read()
|
| 76 |
+
print(f"π· Received reference image for streaming: {len(image_bytes)} bytes")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
except Exception as e:
|
| 78 |
+
print(f"β Failed to read image: {str(e)}")
|
| 79 |
+
async def error_stream():
|
| 80 |
+
yield json.dumps({
|
| 81 |
+
"event": "error",
|
| 82 |
+
"message": f"Failed to read image: {str(e)}",
|
| 83 |
+
"error_type": "ImageReadError"
|
| 84 |
+
}) + "\n"
|
| 85 |
+
return StreamingResponse(error_stream(), media_type="application/x-ndjson")
|
| 86 |
+
|
| 87 |
+
async def stream_segments():
|
| 88 |
+
try:
|
| 89 |
+
|
| 90 |
+
# Convert continuationMode string to boolean
|
| 91 |
+
continuation_mode = continuationMode.lower() == "true"
|
| 92 |
+
|
| 93 |
+
# Create inputs from form data
|
| 94 |
+
inputs = VeoInputs(
|
| 95 |
+
script=script,
|
| 96 |
+
style=style,
|
| 97 |
+
jsonFormat=jsonFormat,
|
| 98 |
+
continuationMode=continuation_mode,
|
| 99 |
+
voiceType=voiceType if voiceType else None,
|
| 100 |
+
energyLevel=energyLevel if energyLevel else None,
|
| 101 |
+
settingMode=settingMode,
|
| 102 |
+
cameraStyle=cameraStyle if cameraStyle else None,
|
| 103 |
+
energyArc=energyArc if energyArc else None,
|
| 104 |
+
narrativeStyle=narrativeStyle if narrativeStyle else None,
|
| 105 |
+
accentRegion=accentRegion if accentRegion else None
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Resolve segment list: AI-driven plan or fixed split
|
| 109 |
+
use_ai_plan = (segment_mode or "").strip().lower() == "ai_driven"
|
| 110 |
+
if use_ai_plan:
|
| 111 |
+
allowed = None
|
| 112 |
+
if allowed_durations:
|
| 113 |
+
try:
|
| 114 |
+
allowed = [int(x.strip()) for x in allowed_durations.split(",") if x.strip()]
|
| 115 |
+
allowed = [d for d in allowed if d in SEGMENT_DURATION_SECONDS]
|
| 116 |
+
except (ValueError, AttributeError):
|
| 117 |
+
pass
|
| 118 |
+
plan = await generate_segment_plan_ai(
|
| 119 |
+
script=script,
|
| 120 |
+
style=style,
|
| 121 |
+
allowed_durations=allowed or [4, 6, 8],
|
| 122 |
+
model=model,
|
| 123 |
+
)
|
| 124 |
+
segment_texts = [p["dialogue"] for p in plan]
|
| 125 |
+
segment_durations = [p["duration_seconds"] for p in plan]
|
| 126 |
+
total_segments = len(plan)
|
| 127 |
+
print(f"π€ AI plan: {total_segments} segments with durations {segment_durations}")
|
| 128 |
+
else:
|
| 129 |
+
seg_sec = 8
|
| 130 |
+
if seconds_per_segment in (4, 6, 8):
|
| 131 |
+
seg_sec = seconds_per_segment
|
| 132 |
+
segment_texts = split_script_into_segments(script, seconds_per_segment=seg_sec)
|
| 133 |
+
segment_durations = [seg_sec] * len(segment_texts)
|
| 134 |
+
total_segments = len(segment_texts)
|
| 135 |
+
|
| 136 |
+
print(f"π Starting streaming generation of {total_segments} segments...")
|
| 137 |
+
|
| 138 |
+
# 1. Send start event
|
| 139 |
+
yield json.dumps({
|
| 140 |
+
"event": "start",
|
| 141 |
+
"total_segments": total_segments,
|
| 142 |
+
"model": model,
|
| 143 |
+
"segment_mode": "ai_driven" if use_ai_plan else "fixed",
|
| 144 |
+
}) + "\n"
|
| 145 |
+
|
| 146 |
+
# Store segments as we generate them
|
| 147 |
+
all_segments = []
|
| 148 |
+
reference_character = None
|
| 149 |
+
reference_scene = None
|
| 150 |
+
|
| 151 |
+
# 2. Generate each segment individually and stream it
|
| 152 |
+
for i, segment_text in enumerate(segment_texts):
|
| 153 |
+
duration_sec = segment_durations[i] if i < len(segment_durations) else 8
|
| 154 |
+
print(f"π Generating segment {i + 1}/{total_segments} ({duration_sec}s)...")
|
| 155 |
+
|
| 156 |
+
try:
|
| 157 |
+
# Generate THIS segment
|
| 158 |
+
segment = await generate_single_segment(
|
| 159 |
+
inputs=inputs,
|
| 160 |
+
segment_text=segment_text,
|
| 161 |
+
segment_index=i,
|
| 162 |
+
total_segments=total_segments,
|
| 163 |
+
image_bytes=image_bytes if i == 0 else None, # Only send image for first segment
|
| 164 |
+
reference_character=reference_character,
|
| 165 |
+
reference_scene=reference_scene,
|
| 166 |
+
segment_duration_seconds=duration_sec,
|
| 167 |
+
model=model
|
| 168 |
+
)
|
| 169 |
+
except Exception as seg_error:
|
| 170 |
+
print(f"β Error generating segment {i + 1}: {str(seg_error)}")
|
| 171 |
+
print(f" Error type: {type(seg_error).__name__}")
|
| 172 |
+
import traceback
|
| 173 |
+
traceback.print_exc()
|
| 174 |
+
raise # Re-raise to be caught by outer try-except
|
| 175 |
+
|
| 176 |
+
# Store for reference in subsequent segments
|
| 177 |
+
all_segments.append(segment)
|
| 178 |
+
|
| 179 |
+
# Extract reference data from first segment for consistency
|
| 180 |
+
if i == 0:
|
| 181 |
+
reference_character = segment.get("character_description", {})
|
| 182 |
+
reference_scene = {
|
| 183 |
+
"environment": segment.get("scene_continuity", {}).get("environment", ""),
|
| 184 |
+
"lighting_state": segment.get("scene_continuity", {}).get("lighting_state", ""),
|
| 185 |
+
"props_in_frame": segment.get("scene_continuity", {}).get("props_in_frame", ""),
|
| 186 |
+
"background_elements": segment.get("scene_continuity", {}).get("background_elements", "")
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
# Calculate progress
|
| 190 |
+
progress = ((i + 1) / total_segments) * 100
|
| 191 |
+
|
| 192 |
+
# 3. Stream this segment immediately
|
| 193 |
+
yield json.dumps({
|
| 194 |
+
"event": "segment",
|
| 195 |
+
"index": i,
|
| 196 |
+
"total": total_segments,
|
| 197 |
+
"segment": segment,
|
| 198 |
+
"progress": round(progress, 2)
|
| 199 |
+
}) + "\n"
|
| 200 |
+
|
| 201 |
+
print(f"β
Streamed segment {i + 1}/{total_segments} ({progress:.1f}% complete)")
|
| 202 |
+
|
| 203 |
+
# Small delay to ensure client receives the event
|
| 204 |
+
await asyncio.sleep(0.1)
|
| 205 |
+
|
| 206 |
+
# 4. Build complete payload
|
| 207 |
+
payload = {
|
| 208 |
+
"segments": all_segments
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
# Check environment mode
|
| 212 |
+
environment = os.getenv('ENVIRONMENT', 'dev').lower()
|
| 213 |
+
is_dev_mode = environment == 'dev' or environment == 'development'
|
| 214 |
+
|
| 215 |
+
payload['environment'] = environment
|
| 216 |
+
payload['is_dev_mode'] = is_dev_mode
|
| 217 |
+
|
| 218 |
+
# 5. Save to cache
|
| 219 |
+
prompt_id = str(uuid.uuid4())
|
| 220 |
+
save_prompt(
|
| 221 |
+
prompt_id=prompt_id,
|
| 222 |
+
payload=payload,
|
| 223 |
+
metadata={
|
| 224 |
+
"script": script,
|
| 225 |
+
"style": style,
|
| 226 |
+
"model": model,
|
| 227 |
+
"segments_count": total_segments,
|
| 228 |
+
"generation_type": "streamed"
|
| 229 |
+
}
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
payload['prompt_id'] = prompt_id
|
| 233 |
+
payload['cached'] = True
|
| 234 |
+
|
| 235 |
+
print(f"πΎ Saved streamed prompts to cache: {prompt_id}")
|
| 236 |
+
|
| 237 |
+
# 6. Send completion event
|
| 238 |
+
yield json.dumps({
|
| 239 |
+
"event": "complete",
|
| 240 |
+
"message": f"Successfully generated {total_segments} segments",
|
| 241 |
+
"prompt_id": prompt_id,
|
| 242 |
+
"payload": payload
|
| 243 |
+
}) + "\n"
|
| 244 |
+
|
| 245 |
+
print(f"β
Streaming complete - {total_segments} segments generated")
|
| 246 |
+
|
| 247 |
+
except Exception as e:
|
| 248 |
+
print(f"β Streaming error: {str(e)}")
|
| 249 |
+
# Send error event
|
| 250 |
+
yield json.dumps({
|
| 251 |
+
"event": "error",
|
| 252 |
+
"message": str(e),
|
| 253 |
+
"error_type": type(e).__name__
|
| 254 |
+
}) + "\n"
|
| 255 |
+
|
| 256 |
+
return StreamingResponse(
|
| 257 |
+
stream_segments(),
|
| 258 |
+
media_type="application/x-ndjson"
|
| 259 |
+
)
|
| 260 |
|
| 261 |
|
| 262 |
@router.post("/split-script")
|
| 263 |
async def split_script_api(
|
| 264 |
script: str = Form(...),
|
| 265 |
seconds_per_segment: int = Form(8),
|
| 266 |
+
words_per_second: float = Form(2.5)
|
| 267 |
):
|
| 268 |
"""
|
| 269 |
Split script into segments for preview
|
|
|
|
| 291 |
|
| 292 |
|
| 293 |
@router.post("/validate-payload")
|
| 294 |
+
async def validate_payload_api(body: dict):
|
| 295 |
"""
|
| 296 |
+
Validate a segments payload (schema rules, optional AI review).
|
| 297 |
|
| 298 |
+
Body can be:
|
| 299 |
+
- The payload itself: { "segments": [...] } β returns schema validation only.
|
| 300 |
+
- Or: { "payload": { "segments": [...] }, "use_ai": true, "script": "optional" } β schema + AI validation.
|
| 301 |
"""
|
| 302 |
try:
|
| 303 |
+
from utils.prompt_generator import validate_segments_payload, validate_segments_payload_with_ai
|
| 304 |
+
|
| 305 |
+
payload = body.get("payload") if "payload" in body else body
|
| 306 |
+
use_ai = body.get("use_ai", False)
|
| 307 |
+
script = body.get("script") or ""
|
| 308 |
+
|
| 309 |
+
if use_ai:
|
| 310 |
+
result = validate_segments_payload_with_ai(payload, script=script or None)
|
| 311 |
+
return {
|
| 312 |
+
"valid": result["valid"],
|
| 313 |
+
"schema_errors": result["schema_errors"],
|
| 314 |
+
"ai_checked": result["ai_checked"],
|
| 315 |
+
"ai_valid": result["ai_valid"],
|
| 316 |
+
"ai_warnings": result["ai_warnings"],
|
| 317 |
+
"ai_suggestions": result["ai_suggestions"],
|
| 318 |
+
}
|
| 319 |
expected_segments = len(payload.get("segments", []))
|
| 320 |
errors = validate_segments_payload(payload, expected_segments)
|
|
|
|
| 321 |
if errors:
|
| 322 |
+
return {"valid": False, "errors": errors, "schema_errors": errors}
|
| 323 |
+
return {"valid": True, "message": "Payload is valid"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
except Exception as e:
|
| 325 |
raise HTTPException(
|
| 326 |
status_code=500,
|
|
|
|
| 331 |
@router.get("/prompt-status")
|
| 332 |
async def prompt_status():
|
| 333 |
"""
|
| 334 |
+
Check if GPT-5.2 prompt generation is available
|
| 335 |
"""
|
| 336 |
import os
|
| 337 |
|
|
|
|
| 339 |
|
| 340 |
return {
|
| 341 |
"available": bool(openai_key),
|
| 342 |
+
"message": "GPT-5.2 is configured" if openai_key
|
| 343 |
else "Add OPENAI_API_KEY to .env.local"
|
| 344 |
}
|
| 345 |
|
|
|
|
| 353 |
):
|
| 354 |
"""
|
| 355 |
Refine a segment prompt to match the actual visual AND audio from the previous segment.
|
| 356 |
+
|
| 357 |
+
This ensures perfect continuity by having GPT-5.2 analyze:
|
| 358 |
1. The last frame (visual consistency)
|
| 359 |
2. The transcribed dialogue (audio consistency - what was actually said)
|
| 360 |
"""
|
|
|
|
| 452 |
|
| 453 |
print(f"π Refining prompt for visual continuity...")
|
| 454 |
|
| 455 |
+
messages = [
|
| 456 |
+
{
|
| 457 |
+
"role": "user",
|
| 458 |
+
"content": [
|
| 459 |
+
{"type": "text", "text": refinement_instructions},
|
| 460 |
+
{
|
| 461 |
+
"type": "image_url",
|
| 462 |
+
"image_url": {"url": f"data:image/jpeg;base64,{encoded_image}"}
|
| 463 |
+
}
|
| 464 |
+
]
|
| 465 |
+
}
|
| 466 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
|
| 468 |
+
content = None
|
| 469 |
+
for attempt in range(2): # initial + 1 retry
|
| 470 |
+
response = client.chat.completions.create(
|
| 471 |
+
model="gpt-4o",
|
| 472 |
+
messages=messages,
|
| 473 |
+
response_format={"type": "json_object"},
|
| 474 |
+
temperature=0.3,
|
| 475 |
+
)
|
| 476 |
+
content = response.choices[0].message.content
|
| 477 |
+
if content and content.strip():
|
| 478 |
+
break
|
| 479 |
+
print(f"β οΈ GPT-4o returned empty response (attempt {attempt + 1}/2)")
|
| 480 |
+
if attempt == 0:
|
| 481 |
+
await asyncio.sleep(1.0)
|
| 482 |
|
| 483 |
+
if not content or not content.strip():
|
| 484 |
+
print(f"β οΈ Using original segment after empty GPT response")
|
| 485 |
+
return JSONResponse(content={
|
| 486 |
+
"refined_prompt": segment_data,
|
| 487 |
+
"original_prompt": segment_data,
|
| 488 |
+
"refinement_skipped": True,
|
| 489 |
+
})
|
| 490 |
+
|
| 491 |
+
try:
|
| 492 |
+
refined_prompt = json.loads(content)
|
| 493 |
+
except json.JSONDecodeError as je:
|
| 494 |
+
print(f"β οΈ Failed to parse GPT response as JSON, using original: {str(je)}")
|
| 495 |
+
return JSONResponse(content={
|
| 496 |
+
"refined_prompt": segment_data,
|
| 497 |
+
"original_prompt": segment_data,
|
| 498 |
+
"refinement_skipped": True,
|
| 499 |
+
})
|
| 500 |
|
| 501 |
+
print(f"β
Prompt refined for visual continuity")
|
| 502 |
return JSONResponse(content={
|
| 503 |
"refined_prompt": refined_prompt,
|
| 504 |
+
"original_prompt": segment_data,
|
| 505 |
})
|
| 506 |
|
| 507 |
except Exception as e:
|
|
|
|
| 511 |
detail=f"Prompt refinement failed: {str(e)}"
|
| 512 |
)
|
| 513 |
|
| 514 |
+
|
| 515 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 516 |
+
# πΎ PROMPT CACHE MANAGEMENT ENDPOINTS
|
| 517 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 518 |
+
|
| 519 |
+
@router.get("/cached-prompts")
|
| 520 |
+
async def list_cached_prompts(limit: int = 50):
|
| 521 |
+
"""
|
| 522 |
+
List all cached prompts (most recent first)
|
| 523 |
+
|
| 524 |
+
Query params:
|
| 525 |
+
limit: Maximum number of prompts to return (default: 50)
|
| 526 |
+
|
| 527 |
+
Returns:
|
| 528 |
+
List of cached prompts with metadata
|
| 529 |
+
"""
|
| 530 |
+
try:
|
| 531 |
+
prompts = list_prompts(limit=limit)
|
| 532 |
+
|
| 533 |
+
# Return summary (without full payload for performance)
|
| 534 |
+
summaries = []
|
| 535 |
+
for p in prompts:
|
| 536 |
+
summaries.append({
|
| 537 |
+
"prompt_id": p["prompt_id"],
|
| 538 |
+
"created_at": p["created_at"],
|
| 539 |
+
"updated_at": p["updated_at"],
|
| 540 |
+
"metadata": p["metadata"],
|
| 541 |
+
"segments_count": len(p["payload"].get("segments", []))
|
| 542 |
+
})
|
| 543 |
+
|
| 544 |
+
return {
|
| 545 |
+
"prompts": summaries,
|
| 546 |
+
"count": len(summaries)
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
except Exception as e:
|
| 550 |
+
raise HTTPException(
|
| 551 |
+
status_code=500,
|
| 552 |
+
detail=f"Failed to list cached prompts: {str(e)}"
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
@router.get("/cached-prompts/{prompt_id}")
|
| 557 |
+
async def get_cached_prompt(prompt_id: str):
|
| 558 |
+
"""
|
| 559 |
+
Get a specific cached prompt with full payload
|
| 560 |
+
|
| 561 |
+
Args:
|
| 562 |
+
prompt_id: The prompt identifier
|
| 563 |
+
|
| 564 |
+
Returns:
|
| 565 |
+
Full cached prompt entry
|
| 566 |
+
"""
|
| 567 |
+
try:
|
| 568 |
+
cache_entry = get_prompt(prompt_id)
|
| 569 |
+
|
| 570 |
+
if not cache_entry:
|
| 571 |
+
raise HTTPException(
|
| 572 |
+
status_code=404,
|
| 573 |
+
detail=f"Prompt not found: {prompt_id}"
|
| 574 |
+
)
|
| 575 |
+
|
| 576 |
+
return cache_entry
|
| 577 |
+
|
| 578 |
+
except HTTPException:
|
| 579 |
+
raise
|
| 580 |
+
except Exception as e:
|
| 581 |
+
raise HTTPException(
|
| 582 |
+
status_code=500,
|
| 583 |
+
detail=f"Failed to retrieve cached prompt: {str(e)}"
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
@router.put("/cached-prompts/{prompt_id}")
|
| 588 |
+
async def update_cached_prompt(prompt_id: str, payload: dict):
|
| 589 |
+
"""
|
| 590 |
+
Update a cached prompt
|
| 591 |
+
|
| 592 |
+
Args:
|
| 593 |
+
prompt_id: The prompt identifier
|
| 594 |
+
payload: Updated segments payload
|
| 595 |
+
|
| 596 |
+
Returns:
|
| 597 |
+
Updated cache entry
|
| 598 |
+
"""
|
| 599 |
+
try:
|
| 600 |
+
updated_entry = update_prompt(
|
| 601 |
+
prompt_id=prompt_id,
|
| 602 |
+
payload=payload
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
if not updated_entry:
|
| 606 |
+
raise HTTPException(
|
| 607 |
+
status_code=404,
|
| 608 |
+
detail=f"Prompt not found: {prompt_id}"
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
return {
|
| 612 |
+
"success": True,
|
| 613 |
+
"message": "Prompt updated successfully",
|
| 614 |
+
"prompt_id": prompt_id,
|
| 615 |
+
"updated_at": updated_entry["updated_at"]
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
except HTTPException:
|
| 619 |
+
raise
|
| 620 |
+
except Exception as e:
|
| 621 |
+
raise HTTPException(
|
| 622 |
+
status_code=500,
|
| 623 |
+
detail=f"Failed to update cached prompt: {str(e)}"
|
| 624 |
+
)
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
@router.delete("/cached-prompts/{prompt_id}")
|
| 628 |
+
async def delete_cached_prompt(prompt_id: str):
|
| 629 |
+
"""
|
| 630 |
+
Delete a cached prompt
|
| 631 |
+
|
| 632 |
+
Args:
|
| 633 |
+
prompt_id: The prompt identifier
|
| 634 |
+
|
| 635 |
+
Returns:
|
| 636 |
+
Success message
|
| 637 |
+
"""
|
| 638 |
+
try:
|
| 639 |
+
deleted = delete_prompt(prompt_id)
|
| 640 |
+
|
| 641 |
+
if not deleted:
|
| 642 |
+
raise HTTPException(
|
| 643 |
+
status_code=404,
|
| 644 |
+
detail=f"Prompt not found: {prompt_id}"
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
return {
|
| 648 |
+
"success": True,
|
| 649 |
+
"message": "Prompt deleted successfully",
|
| 650 |
+
"prompt_id": prompt_id
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
except HTTPException:
|
| 654 |
+
raise
|
| 655 |
+
except Exception as e:
|
| 656 |
+
raise HTTPException(
|
| 657 |
+
status_code=500,
|
| 658 |
+
detail=f"Failed to delete cached prompt: {str(e)}"
|
| 659 |
+
)
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
@router.post("/cleanup-cache")
|
| 663 |
+
async def cleanup_prompt_cache(max_age_days: int = 7):
|
| 664 |
+
"""
|
| 665 |
+
Clean up old cached prompts
|
| 666 |
+
|
| 667 |
+
Query params:
|
| 668 |
+
max_age_days: Maximum age in days (default: 7)
|
| 669 |
+
|
| 670 |
+
Returns:
|
| 671 |
+
Cleanup summary
|
| 672 |
+
"""
|
| 673 |
+
try:
|
| 674 |
+
cleanup_old_prompts(max_age_days=max_age_days)
|
| 675 |
+
|
| 676 |
+
return {
|
| 677 |
+
"success": True,
|
| 678 |
+
"message": f"Cleaned up prompts older than {max_age_days} days"
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
except Exception as e:
|
| 682 |
+
raise HTTPException(
|
| 683 |
+
status_code=500,
|
| 684 |
+
detail=f"Cache cleanup failed: {str(e)}"
|
| 685 |
+
)
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
@router.post("/use-cached-prompt/{prompt_id}")
|
| 689 |
+
async def use_cached_prompt_for_generation(prompt_id: str):
|
| 690 |
+
"""
|
| 691 |
+
Retrieve a cached prompt for reuse in video generation
|
| 692 |
+
|
| 693 |
+
This endpoint allows users to:
|
| 694 |
+
1. Recover from failed generations
|
| 695 |
+
2. Reuse previously generated prompts
|
| 696 |
+
3. Edit and regenerate with modifications
|
| 697 |
+
|
| 698 |
+
Args:
|
| 699 |
+
prompt_id: The prompt identifier
|
| 700 |
+
|
| 701 |
+
Returns:
|
| 702 |
+
The full payload ready for video generation
|
| 703 |
+
"""
|
| 704 |
+
try:
|
| 705 |
+
cache_entry = get_prompt(prompt_id)
|
| 706 |
+
|
| 707 |
+
if not cache_entry:
|
| 708 |
+
raise HTTPException(
|
| 709 |
+
status_code=404,
|
| 710 |
+
detail=f"Prompt not found: {prompt_id}"
|
| 711 |
+
)
|
| 712 |
+
|
| 713 |
+
# Return the payload with cache metadata
|
| 714 |
+
response = {
|
| 715 |
+
"payload": cache_entry["payload"],
|
| 716 |
+
"metadata": cache_entry["metadata"],
|
| 717 |
+
"prompt_id": prompt_id,
|
| 718 |
+
"created_at": cache_entry["created_at"],
|
| 719 |
+
"from_cache": True
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
print(f"β»οΈ Reusing cached prompt: {prompt_id}")
|
| 723 |
+
|
| 724 |
+
return JSONResponse(content=response)
|
| 725 |
+
|
| 726 |
+
except HTTPException:
|
| 727 |
+
raise
|
| 728 |
+
except Exception as e:
|
| 729 |
+
raise HTTPException(
|
| 730 |
+
status_code=500,
|
| 731 |
+
detail=f"Failed to retrieve cached prompt: {str(e)}"
|
| 732 |
+
)
|
| 733 |
+
|
api/prompt_safety.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prompt Safety API - Auto-fix unsafe prompts
|
| 3 |
+
Detects and sanitizes prompts that trigger content moderation errors
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, HTTPException
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from typing import Any, Dict
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
from .veo_error_handler import (
|
| 12 |
+
handle_veo_error,
|
| 13 |
+
should_auto_fix,
|
| 14 |
+
is_safety_violation,
|
| 15 |
+
VeoErrorType
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
# Try importing OpenAI
|
| 21 |
+
try:
|
| 22 |
+
from openai import OpenAI
|
| 23 |
+
OPENAI_AVAILABLE = True
|
| 24 |
+
except ImportError:
|
| 25 |
+
OPENAI_AVAILABLE = False
|
| 26 |
+
print("β οΈ OpenAI package not available for prompt safety fixes")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class SafetyFixRequest(BaseModel):
|
| 30 |
+
segment: Dict[str, Any] # The VeoSegment that triggered safety error
|
| 31 |
+
error_message: str # The error message from the API
|
| 32 |
+
attempt_count: int = 0 # How many times we've tried to fix this
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class SafetyFixResponse(BaseModel):
|
| 36 |
+
success: bool
|
| 37 |
+
fixed_segment: Dict[str, Any] | None = None
|
| 38 |
+
changes_made: str | None = None # Description of what was changed
|
| 39 |
+
error: str | None = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_openai_client():
|
| 43 |
+
"""Get OpenAI client"""
|
| 44 |
+
if not OPENAI_AVAILABLE:
|
| 45 |
+
raise HTTPException(
|
| 46 |
+
status_code=500,
|
| 47 |
+
detail="OpenAI package not installed"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
api_key = os.getenv('OPENAI_API_KEY')
|
| 51 |
+
if not api_key:
|
| 52 |
+
raise HTTPException(
|
| 53 |
+
status_code=500,
|
| 54 |
+
detail="OPENAI_API_KEY not configured"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return OpenAI(api_key=api_key)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.post("/fix-unsafe-prompt", response_model=SafetyFixResponse)
|
| 61 |
+
async def fix_unsafe_prompt(request: SafetyFixRequest):
|
| 62 |
+
"""
|
| 63 |
+
Automatically fix a prompt that triggered content safety errors.
|
| 64 |
+
|
| 65 |
+
Uses GPT-4o to analyze the prompt and remove/modify content that
|
| 66 |
+
violates content policies while preserving the creative intent.
|
| 67 |
+
"""
|
| 68 |
+
try:
|
| 69 |
+
client = get_openai_client()
|
| 70 |
+
|
| 71 |
+
# Extract key parts of the segment
|
| 72 |
+
segment = request.segment
|
| 73 |
+
dialogue = segment.get('action_timeline', {}).get('dialogue', '')
|
| 74 |
+
character_state = segment.get('character_description', {}).get('current_state', '')
|
| 75 |
+
environment = segment.get('scene_continuity', {}).get('environment', '')
|
| 76 |
+
|
| 77 |
+
# Build safety fix prompt for GPT
|
| 78 |
+
system_prompt = """You are a content safety expert. Your job is to modify video generation prompts that have been flagged as unsafe to make them compliant with content policies.
|
| 79 |
+
|
| 80 |
+
Content policy violations typically include:
|
| 81 |
+
- Violence, gore, or harmful content
|
| 82 |
+
- Sexual or suggestive content
|
| 83 |
+
- Hate speech or discriminatory content
|
| 84 |
+
- Dangerous activities or illegal content
|
| 85 |
+
- Disturbing or shocking imagery
|
| 86 |
+
- References to prominent public figures, celebrities, or politicians (MOST COMMON!)
|
| 87 |
+
- Copyrighted characters or brand names
|
| 88 |
+
|
| 89 |
+
Based on official Veo 3.1 documentation, common safety violations include:
|
| 90 |
+
- PUBLIC_ERROR_MINOR: Generic internal error (wait and retry)
|
| 91 |
+
- Support Code errors: Specific safety categories (Child, Celebrity, Sexual, Violence, Dangerous)
|
| 92 |
+
- "Flagged for containing a prominent public figure" - Replace names with generic descriptions
|
| 93 |
+
|
| 94 |
+
Your task:
|
| 95 |
+
1. Identify what might have triggered the safety filter
|
| 96 |
+
2. Modify ONLY the problematic parts while preserving the overall scene intent
|
| 97 |
+
3. Keep the modifications minimal - don't change the entire scene
|
| 98 |
+
4. Maintain the emotional tone and storytelling intent
|
| 99 |
+
5. Keep dialogue natural and character-appropriate
|
| 100 |
+
6. Return the modified segment in the EXACT same JSON structure
|
| 101 |
+
|
| 102 |
+
IMPORTANT FIXES FOR PUBLIC FIGURES:
|
| 103 |
+
- Remove any names of real people, celebrities, politicians, or public figures
|
| 104 |
+
- Replace with generic descriptions (e.g., "a business professional" instead of specific names)
|
| 105 |
+
- Remove brand names or copyrighted characters
|
| 106 |
+
- Keep the scene's intent but make it generic
|
| 107 |
+
|
| 108 |
+
IMPORTANT: Make surgical changes - only fix what's unsafe, keep everything else."""
|
| 109 |
+
|
| 110 |
+
user_prompt = f"""A video generation prompt was flagged as unsafe with this error:
|
| 111 |
+
"{request.error_message}"
|
| 112 |
+
|
| 113 |
+
Original segment:
|
| 114 |
+
```json
|
| 115 |
+
{json.dumps(segment, indent=2)}
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
Please fix this segment by making MINIMAL changes to remove unsafe content while preserving the creative intent. Return the fixed segment in the exact same JSON structure.
|
| 119 |
+
|
| 120 |
+
Think about:
|
| 121 |
+
- What specific words or descriptions might have triggered the filter?
|
| 122 |
+
- Are there any names of real people, celebrities, politicians, or public figures? Replace with generic roles.
|
| 123 |
+
- Are there any brand names or copyrighted characters? Make them generic.
|
| 124 |
+
- Can you replace violent/sexual/disturbing language with safer alternatives?
|
| 125 |
+
- Can you soften intense emotional descriptions while keeping the mood?
|
| 126 |
+
- Can you make actions less explicit while maintaining the story?
|
| 127 |
+
|
| 128 |
+
Examples of fixes:
|
| 129 |
+
- "Donald Trump gives a speech" β "A business executive gives a presentation"
|
| 130 |
+
- "Like Elon Musk, he..." β "Like a tech entrepreneur, he..."
|
| 131 |
+
- "Taylor Swift performs" β "A singer performs"
|
| 132 |
+
- "Nike shoes" β "athletic shoes"
|
| 133 |
+
- "Spider-Man" β "a superhero"
|
| 134 |
+
|
| 135 |
+
Return ONLY the fixed JSON segment, no explanations."""
|
| 136 |
+
|
| 137 |
+
# Call GPT-4o to fix the prompt
|
| 138 |
+
print(f"π‘οΈ Attempting to auto-fix unsafe prompt (attempt {request.attempt_count + 1})...")
|
| 139 |
+
|
| 140 |
+
response = client.chat.completions.create(
|
| 141 |
+
model="gpt-4o",
|
| 142 |
+
messages=[
|
| 143 |
+
{"role": "system", "content": system_prompt},
|
| 144 |
+
{"role": "user", "content": user_prompt}
|
| 145 |
+
],
|
| 146 |
+
temperature=0.3, # Lower temperature for more conservative fixes
|
| 147 |
+
response_format={"type": "json_object"}
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# Parse the fixed segment
|
| 151 |
+
fixed_content = response.choices[0].message.content
|
| 152 |
+
if not fixed_content:
|
| 153 |
+
raise ValueError("Empty response from GPT")
|
| 154 |
+
|
| 155 |
+
fixed_segment = json.loads(fixed_content)
|
| 156 |
+
|
| 157 |
+
# Generate a summary of changes
|
| 158 |
+
changes = []
|
| 159 |
+
if fixed_segment.get('action_timeline', {}).get('dialogue') != dialogue:
|
| 160 |
+
changes.append("Modified dialogue for safety")
|
| 161 |
+
if fixed_segment.get('character_description', {}).get('current_state') != character_state:
|
| 162 |
+
changes.append("Adjusted character description")
|
| 163 |
+
if fixed_segment.get('scene_continuity', {}).get('environment') != environment:
|
| 164 |
+
changes.append("Softened scene environment")
|
| 165 |
+
|
| 166 |
+
changes_summary = ", ".join(changes) if changes else "Made minimal safety adjustments"
|
| 167 |
+
|
| 168 |
+
print(f"β
Auto-fix complete: {changes_summary}")
|
| 169 |
+
|
| 170 |
+
return SafetyFixResponse(
|
| 171 |
+
success=True,
|
| 172 |
+
fixed_segment=fixed_segment,
|
| 173 |
+
changes_made=changes_summary,
|
| 174 |
+
error=None
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
except json.JSONDecodeError as e:
|
| 178 |
+
error_msg = f"Failed to parse GPT response: {str(e)}"
|
| 179 |
+
print(f"β {error_msg}")
|
| 180 |
+
return SafetyFixResponse(
|
| 181 |
+
success=False,
|
| 182 |
+
fixed_segment=None,
|
| 183 |
+
changes_made=None,
|
| 184 |
+
error=error_msg
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
except Exception as e:
|
| 188 |
+
error_msg = f"Safety fix failed: {str(e)}"
|
| 189 |
+
print(f"β {error_msg}")
|
| 190 |
+
return SafetyFixResponse(
|
| 191 |
+
success=False,
|
| 192 |
+
fixed_segment=None,
|
| 193 |
+
changes_made=None,
|
| 194 |
+
error=error_msg
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@router.get("/safety/health")
|
| 199 |
+
async def safety_health():
|
| 200 |
+
"""Check if safety fix is available"""
|
| 201 |
+
return {
|
| 202 |
+
"available": OPENAI_AVAILABLE and bool(os.getenv('OPENAI_API_KEY')),
|
| 203 |
+
"message": "Safety fix ready" if (OPENAI_AVAILABLE and os.getenv('OPENAI_API_KEY'))
|
| 204 |
+
else "OpenAI not configured"
|
| 205 |
+
}
|
api/prompt_validator.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pre-submission prompt validation to catch common issues before generation
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from typing import Dict, List, Tuple
|
| 7 |
+
|
| 8 |
+
# Common public figure names (not exhaustive, just examples)
|
| 9 |
+
COMMON_PUBLIC_FIGURES = [
|
| 10 |
+
# Politicians
|
| 11 |
+
"donald trump", "joe biden", "barack obama", "kamala harris",
|
| 12 |
+
"vladimir putin", "xi jinping", "narendra modi", "boris johnson",
|
| 13 |
+
# Tech CEOs
|
| 14 |
+
"elon musk", "jeff bezos", "mark zuckerberg", "bill gates", "steve jobs",
|
| 15 |
+
"tim cook", "sundar pichai", "satya nadella",
|
| 16 |
+
# Celebrities
|
| 17 |
+
"taylor swift", "beyonce", "kim kardashian", "kanye west",
|
| 18 |
+
"dwayne johnson", "tom cruise", "leonardo dicaprio",
|
| 19 |
+
# Athletes
|
| 20 |
+
"lebron james", "cristiano ronaldo", "lionel messi", "serena williams",
|
| 21 |
+
"tiger woods", "michael jordan",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
# Copyrighted characters and brands
|
| 25 |
+
COPYRIGHTED_TERMS = [
|
| 26 |
+
# Characters
|
| 27 |
+
"spider-man", "spiderman", "batman", "superman", "iron man",
|
| 28 |
+
"mickey mouse", "harry potter", "darth vader",
|
| 29 |
+
# Brands
|
| 30 |
+
"nike", "adidas", "apple", "google", "microsoft", "coca-cola",
|
| 31 |
+
"pepsi", "mcdonalds", "starbucks", "amazon",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def validate_prompt_content(text: str) -> Tuple[bool, List[str]]:
|
| 36 |
+
"""
|
| 37 |
+
Validate prompt content for potential content policy violations.
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
Tuple of (is_valid, list_of_warnings)
|
| 41 |
+
"""
|
| 42 |
+
warnings = []
|
| 43 |
+
text_lower = text.lower()
|
| 44 |
+
|
| 45 |
+
# Check for public figures
|
| 46 |
+
found_figures = [name for name in COMMON_PUBLIC_FIGURES if name in text_lower]
|
| 47 |
+
if found_figures:
|
| 48 |
+
warnings.append(
|
| 49 |
+
f"β οΈ Detected public figure(s): {', '.join(found_figures)}. "
|
| 50 |
+
"Consider using generic descriptions instead (e.g., 'a business executive' instead of specific names)."
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Check for copyrighted terms
|
| 54 |
+
found_copyrighted = [term for term in COPYRIGHTED_TERMS if term in text_lower]
|
| 55 |
+
if found_copyrighted:
|
| 56 |
+
warnings.append(
|
| 57 |
+
f"β οΈ Detected copyrighted term(s): {', '.join(found_copyrighted)}. "
|
| 58 |
+
"Consider using generic alternatives to avoid content policy issues."
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Check for potentially sensitive content
|
| 62 |
+
sensitive_patterns = [
|
| 63 |
+
(r'\b(kill|murder|death|blood|violence)\b', "violent content"),
|
| 64 |
+
(r'\b(naked|nude|sex|sexual)\b', "explicit content"),
|
| 65 |
+
(r'\b(hate|racist|discriminat)\w*\b', "discriminatory language"),
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
for pattern, content_type in sensitive_patterns:
|
| 69 |
+
if re.search(pattern, text_lower):
|
| 70 |
+
warnings.append(f"β οΈ Potentially sensitive {content_type} detected. Review for content policy compliance.")
|
| 71 |
+
|
| 72 |
+
is_valid = len(warnings) == 0
|
| 73 |
+
return is_valid, warnings
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def sanitize_prompt_content(text: str) -> str:
|
| 77 |
+
"""
|
| 78 |
+
Automatically sanitize prompt content by replacing problematic terms.
|
| 79 |
+
This is a basic implementation - the AI-powered fix is more sophisticated.
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Sanitized text
|
| 83 |
+
"""
|
| 84 |
+
sanitized = text
|
| 85 |
+
|
| 86 |
+
# Replace common public figures with generic terms
|
| 87 |
+
replacements = {
|
| 88 |
+
# Politicians
|
| 89 |
+
"donald trump": "a business executive",
|
| 90 |
+
"joe biden": "a senior politician",
|
| 91 |
+
"elon musk": "a tech entrepreneur",
|
| 92 |
+
"jeff bezos": "a business mogul",
|
| 93 |
+
"mark zuckerberg": "a tech founder",
|
| 94 |
+
"bill gates": "a technology pioneer",
|
| 95 |
+
# Celebrities
|
| 96 |
+
"taylor swift": "a popular singer",
|
| 97 |
+
"beyonce": "a renowned performer",
|
| 98 |
+
"kim kardashian": "a media personality",
|
| 99 |
+
# Athletes
|
| 100 |
+
"lebron james": "a professional basketball player",
|
| 101 |
+
"cristiano ronaldo": "a soccer star",
|
| 102 |
+
"lionel messi": "a soccer champion",
|
| 103 |
+
# Characters
|
| 104 |
+
"spider-man": "a superhero",
|
| 105 |
+
"spiderman": "a superhero",
|
| 106 |
+
"batman": "a crime fighter",
|
| 107 |
+
"superman": "a hero with superpowers",
|
| 108 |
+
"harry potter": "a young wizard",
|
| 109 |
+
# Brands
|
| 110 |
+
"nike": "athletic",
|
| 111 |
+
"adidas": "sportswear",
|
| 112 |
+
"apple": "tech",
|
| 113 |
+
"google": "a search engine",
|
| 114 |
+
"starbucks": "a coffee shop",
|
| 115 |
+
"mcdonalds": "a restaurant",
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
for term, replacement in replacements.items():
|
| 119 |
+
# Case-insensitive replacement
|
| 120 |
+
pattern = re.compile(re.escape(term), re.IGNORECASE)
|
| 121 |
+
sanitized = pattern.sub(replacement, sanitized)
|
| 122 |
+
|
| 123 |
+
return sanitized
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def get_content_guidance() -> Dict[str, List[str]]:
|
| 127 |
+
"""
|
| 128 |
+
Get guidance on what to avoid in prompts.
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
Dictionary of content categories and examples
|
| 132 |
+
"""
|
| 133 |
+
return {
|
| 134 |
+
"avoid_public_figures": [
|
| 135 |
+
"Real politicians, celebrities, athletes, or public figures",
|
| 136 |
+
"Use generic roles instead: 'a business executive', 'a singer', 'an athlete'",
|
| 137 |
+
],
|
| 138 |
+
"avoid_copyrighted": [
|
| 139 |
+
"Trademarked characters (Spider-Man, Mickey Mouse, etc.)",
|
| 140 |
+
"Brand names (Nike, Apple, Starbucks, etc.)",
|
| 141 |
+
"Use generic alternatives: 'a superhero', 'athletic shoes', 'a coffee shop'",
|
| 142 |
+
],
|
| 143 |
+
"avoid_sensitive": [
|
| 144 |
+
"Violence, gore, or disturbing imagery",
|
| 145 |
+
"Explicit or sexual content",
|
| 146 |
+
"Hate speech or discriminatory language",
|
| 147 |
+
"Dangerous or illegal activities",
|
| 148 |
+
],
|
| 149 |
+
"best_practices": [
|
| 150 |
+
"Use generic, descriptive language",
|
| 151 |
+
"Focus on actions, emotions, and settings rather than specific identities",
|
| 152 |
+
"Keep content family-friendly and brand-safe",
|
| 153 |
+
"Test with shorter scripts first to validate content compliance",
|
| 154 |
+
],
|
| 155 |
+
}
|
api/veo_error_handler.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Veo 3.1 Official Error Handler
|
| 3 |
+
Based on official Veo 3.1 API documentation
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from enum import Enum
|
| 7 |
+
from typing import Dict, Tuple, Optional
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class VeoErrorType(Enum):
|
| 12 |
+
"""Official Veo 3.1 error types"""
|
| 13 |
+
RESOURCE_EXHAUSTED = "429"
|
| 14 |
+
FORBIDDEN = "403"
|
| 15 |
+
SAFETY_FILTER = "safety"
|
| 16 |
+
PUBLIC_ERROR_MINOR = "public_error_minor"
|
| 17 |
+
CELEBRITY = "celebrity" # Prominent public figure
|
| 18 |
+
CHILD = "child"
|
| 19 |
+
SEXUAL = "sexual"
|
| 20 |
+
VIOLENCE = "violence"
|
| 21 |
+
DANGEROUS = "dangerous"
|
| 22 |
+
UNKNOWN = "unknown"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Official Veo 3.1 Safety Filter Support Codes
|
| 26 |
+
# Source: Official API Documentation
|
| 27 |
+
SAFETY_CODES: Dict[str, str] = {
|
| 28 |
+
# Child Safety
|
| 29 |
+
"58061214": "Child",
|
| 30 |
+
"17301594": "Child",
|
| 31 |
+
|
| 32 |
+
# Celebrity / Public Figures
|
| 33 |
+
"29310472": "Celebrity",
|
| 34 |
+
"15236754": "Celebrity",
|
| 35 |
+
|
| 36 |
+
# Sexual Content
|
| 37 |
+
"90789179": "Sexual",
|
| 38 |
+
"43188360": "Sexual",
|
| 39 |
+
|
| 40 |
+
# Violence
|
| 41 |
+
"61493863": "Violence",
|
| 42 |
+
"56562880": "Violence",
|
| 43 |
+
|
| 44 |
+
# Dangerous Content
|
| 45 |
+
"62263041": "Dangerous",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def extract_support_code(error_message: str) -> Optional[str]:
|
| 50 |
+
"""
|
| 51 |
+
Extract 8-digit support code from error message
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
error_message: The error message from Veo API
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
Support code if found, None otherwise
|
| 58 |
+
"""
|
| 59 |
+
# Look for 8-digit numbers (support codes)
|
| 60 |
+
match = re.search(r'\b\d{8}\b', error_message)
|
| 61 |
+
return match.group(0) if match else None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_safety_category(support_code: str) -> str:
|
| 65 |
+
"""
|
| 66 |
+
Get safety category from support code
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
support_code: 8-digit support code
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Safety category name
|
| 73 |
+
"""
|
| 74 |
+
return SAFETY_CODES.get(support_code, f"Unknown ({support_code})")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def is_safety_violation(error_message: str) -> bool:
|
| 78 |
+
"""
|
| 79 |
+
Check if error is a safety filter violation
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
error_message: Error message from API
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
True if safety violation detected
|
| 86 |
+
"""
|
| 87 |
+
# Official keywords from documentation
|
| 88 |
+
safety_keywords = [
|
| 89 |
+
"safety filter",
|
| 90 |
+
"safety violation",
|
| 91 |
+
"content policy",
|
| 92 |
+
"inappropriate content",
|
| 93 |
+
"flagged for containing",
|
| 94 |
+
"prominent public figure",
|
| 95 |
+
"celebrity",
|
| 96 |
+
"public figure",
|
| 97 |
+
"politician",
|
| 98 |
+
"moderation",
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
error_lower = error_message.lower()
|
| 102 |
+
|
| 103 |
+
# Check for keywords
|
| 104 |
+
if any(keyword in error_lower for keyword in safety_keywords):
|
| 105 |
+
return True
|
| 106 |
+
|
| 107 |
+
# Check for support code
|
| 108 |
+
if extract_support_code(error_message):
|
| 109 |
+
return True
|
| 110 |
+
|
| 111 |
+
return False
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def is_public_error_minor(error_message: str) -> bool:
|
| 115 |
+
"""
|
| 116 |
+
Check if error is PUBLIC_ERROR_MINOR (generic internal error)
|
| 117 |
+
|
| 118 |
+
Args:
|
| 119 |
+
error_message: Error message from API
|
| 120 |
+
|
| 121 |
+
Returns:
|
| 122 |
+
True if PUBLIC_ERROR_MINOR detected
|
| 123 |
+
"""
|
| 124 |
+
return "PUBLIC_ERROR_MINOR" in error_message.upper()
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def handle_veo_error(error_response: Dict) -> Tuple[VeoErrorType, str, Optional[str]]:
|
| 128 |
+
"""
|
| 129 |
+
Handle Veo 3.1 errors according to official documentation
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
error_response: Error response from Veo API
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
Tuple of (error_type, error_message, support_code)
|
| 136 |
+
"""
|
| 137 |
+
error_code = str(error_response.get("code", ""))
|
| 138 |
+
error_message = error_response.get("message", "")
|
| 139 |
+
|
| 140 |
+
# Extract support code if present
|
| 141 |
+
support_code = extract_support_code(error_message)
|
| 142 |
+
|
| 143 |
+
# 429: Too many requests
|
| 144 |
+
if "429" in error_code:
|
| 145 |
+
return (
|
| 146 |
+
VeoErrorType.RESOURCE_EXHAUSTED,
|
| 147 |
+
"Too many requests. Wait 5-10 minutes and retry.",
|
| 148 |
+
None
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
# 403: Insufficient permissions
|
| 152 |
+
if "403" in error_code:
|
| 153 |
+
return (
|
| 154 |
+
VeoErrorType.FORBIDDEN,
|
| 155 |
+
"Insufficient permissions. Check API configuration.",
|
| 156 |
+
None
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# PUBLIC_ERROR_MINOR: Generic internal error
|
| 160 |
+
if is_public_error_minor(error_message):
|
| 161 |
+
return (
|
| 162 |
+
VeoErrorType.PUBLIC_ERROR_MINOR,
|
| 163 |
+
"Internal processing error. Wait 5-10 minutes or simplify request.",
|
| 164 |
+
None
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Safety filter with support code
|
| 168 |
+
if support_code:
|
| 169 |
+
category = get_safety_category(support_code)
|
| 170 |
+
return (
|
| 171 |
+
VeoErrorType.SAFETY_FILTER,
|
| 172 |
+
f"Safety filter triggered: {category}",
|
| 173 |
+
support_code
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Celebrity / Public Figure (no support code)
|
| 177 |
+
if any(keyword in error_message.lower() for keyword in [
|
| 178 |
+
"prominent public figure", "celebrity", "politician", "public figure"
|
| 179 |
+
]):
|
| 180 |
+
return (
|
| 181 |
+
VeoErrorType.CELEBRITY,
|
| 182 |
+
"Content contains prominent public figure or celebrity",
|
| 183 |
+
None
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# Generic safety violation
|
| 187 |
+
if is_safety_violation(error_message):
|
| 188 |
+
return (
|
| 189 |
+
VeoErrorType.SAFETY_FILTER,
|
| 190 |
+
"Content policy violation detected",
|
| 191 |
+
None
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
# Unknown error
|
| 195 |
+
return (
|
| 196 |
+
VeoErrorType.UNKNOWN,
|
| 197 |
+
error_message or "Unknown error occurred",
|
| 198 |
+
None
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def get_retry_strategy(error_type: VeoErrorType) -> Dict[str, any]:
|
| 203 |
+
"""
|
| 204 |
+
Get recommended retry strategy for error type
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
error_type: The type of error
|
| 208 |
+
|
| 209 |
+
Returns:
|
| 210 |
+
Dict with retry strategy
|
| 211 |
+
"""
|
| 212 |
+
strategies = {
|
| 213 |
+
VeoErrorType.RESOURCE_EXHAUSTED: {
|
| 214 |
+
"should_retry": True,
|
| 215 |
+
"wait_seconds": 600, # 10 minutes
|
| 216 |
+
"max_retries": 2,
|
| 217 |
+
"requires_fix": False,
|
| 218 |
+
},
|
| 219 |
+
VeoErrorType.PUBLIC_ERROR_MINOR: {
|
| 220 |
+
"should_retry": True,
|
| 221 |
+
"wait_seconds": 300, # 5 minutes
|
| 222 |
+
"max_retries": 2,
|
| 223 |
+
"requires_fix": False,
|
| 224 |
+
},
|
| 225 |
+
VeoErrorType.CELEBRITY: {
|
| 226 |
+
"should_retry": True,
|
| 227 |
+
"wait_seconds": 2,
|
| 228 |
+
"max_retries": 2,
|
| 229 |
+
"requires_fix": True, # Needs AI fix
|
| 230 |
+
},
|
| 231 |
+
VeoErrorType.SAFETY_FILTER: {
|
| 232 |
+
"should_retry": True,
|
| 233 |
+
"wait_seconds": 2,
|
| 234 |
+
"max_retries": 2,
|
| 235 |
+
"requires_fix": True, # Needs AI fix
|
| 236 |
+
},
|
| 237 |
+
VeoErrorType.FORBIDDEN: {
|
| 238 |
+
"should_retry": False,
|
| 239 |
+
"wait_seconds": 0,
|
| 240 |
+
"max_retries": 0,
|
| 241 |
+
"requires_fix": False,
|
| 242 |
+
},
|
| 243 |
+
VeoErrorType.UNKNOWN: {
|
| 244 |
+
"should_retry": True,
|
| 245 |
+
"wait_seconds": 5,
|
| 246 |
+
"max_retries": 1,
|
| 247 |
+
"requires_fix": False,
|
| 248 |
+
},
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
return strategies.get(error_type, strategies[VeoErrorType.UNKNOWN])
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def should_auto_fix(error_type: VeoErrorType) -> bool:
|
| 255 |
+
"""
|
| 256 |
+
Determine if error should trigger AI auto-fix
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
error_type: The type of error
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
True if auto-fix should be attempted
|
| 263 |
+
"""
|
| 264 |
+
fixable_types = [
|
| 265 |
+
VeoErrorType.CELEBRITY,
|
| 266 |
+
VeoErrorType.SAFETY_FILTER,
|
| 267 |
+
VeoErrorType.CHILD,
|
| 268 |
+
VeoErrorType.SEXUAL,
|
| 269 |
+
VeoErrorType.VIOLENCE,
|
| 270 |
+
VeoErrorType.DANGEROUS,
|
| 271 |
+
]
|
| 272 |
+
return error_type in fixable_types
|
api/video_generation.py
CHANGED
|
@@ -282,10 +282,13 @@ async def veo_callback(callback_data: CallbackData):
|
|
| 282 |
'fallbackFlag': fallback_flag
|
| 283 |
})
|
| 284 |
else:
|
|
|
|
|
|
|
| 285 |
await send_sse_event(task_id, {
|
| 286 |
'status': 'failed',
|
| 287 |
-
'error': callback_data.msg,
|
| 288 |
-
'
|
|
|
|
| 289 |
})
|
| 290 |
|
| 291 |
# Clean up old results
|
|
|
|
| 282 |
'fallbackFlag': fallback_flag
|
| 283 |
})
|
| 284 |
else:
|
| 285 |
+
# Include both code and message for proper error handling
|
| 286 |
+
# This format matches what veo_error_handler.py expects
|
| 287 |
await send_sse_event(task_id, {
|
| 288 |
'status': 'failed',
|
| 289 |
+
'error': callback_data.msg, # Legacy field
|
| 290 |
+
'message': callback_data.msg, # For error handler
|
| 291 |
+
'code': callback_data.code # HTTP or API error code
|
| 292 |
})
|
| 293 |
|
| 294 |
# Clean up old results
|
frontend/FLOW.md
CHANGED
|
@@ -378,7 +378,7 @@ USER INPUT BACKEND EXTERNAL APIs
|
|
| 378 |
|
| 379 |
| Endpoint | Method | Description |
|
| 380 |
|----------|--------|-------------|
|
| 381 |
-
| `/api/generate-prompts` | POST |
|
| 382 |
| `/api/upload-image` | POST | Upload character reference image |
|
| 383 |
| `/api/veo/generate` | POST | Start video generation |
|
| 384 |
| `/api/veo/extend` | POST | Extend existing video |
|
|
|
|
| 378 |
|
| 379 |
| Endpoint | Method | Description |
|
| 380 |
|----------|--------|-------------|
|
| 381 |
+
| `/api/generate-prompts-stream` | POST | Streaming prompt generation (GPT-5.2) |
|
| 382 |
| `/api/upload-image` | POST | Upload character reference image |
|
| 383 |
| `/api/veo/generate` | POST | Start video generation |
|
| 384 |
| `/api/veo/extend` | POST | Extend existing video |
|
frontend/README.md
CHANGED
|
@@ -26,11 +26,23 @@ cd frontend
|
|
| 26 |
# Install dependencies
|
| 27 |
npm install
|
| 28 |
|
| 29 |
-
# Start development server
|
| 30 |
npm run dev
|
| 31 |
```
|
| 32 |
|
| 33 |
-
The frontend will be available at `http://localhost:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
### Environment Variables
|
| 36 |
|
|
@@ -119,7 +131,7 @@ npm run preview
|
|
| 119 |
|
| 120 |
The frontend communicates with the Python backend at `/api/*`:
|
| 121 |
|
| 122 |
-
- `POST /api/generate-prompts` - Generate video prompts
|
| 123 |
- `POST /api/veo/generate` - Start Kling video generation
|
| 124 |
- `GET /api/veo/events/:taskId` - SSE for generation progress
|
| 125 |
- `POST /api/replicate/generate` - Start Replicate generation
|
|
|
|
| 26 |
# Install dependencies
|
| 27 |
npm install
|
| 28 |
|
| 29 |
+
# Start development server (Vite dev server with HMR)
|
| 30 |
npm run dev
|
| 31 |
```
|
| 32 |
|
| 33 |
+
The frontend will be available at `http://localhost:5173` (Vite default).
|
| 34 |
+
|
| 35 |
+
### Serving from Python backend (port 4000)
|
| 36 |
+
|
| 37 |
+
If you want to use the app at `http://localhost:4000` (same origin as the API):
|
| 38 |
+
|
| 39 |
+
- **One-time build:** `npm run build` then start the backend with `python main.py`.
|
| 40 |
+
- **Auto-rebuild on save:** run from project root:
|
| 41 |
+
```bash
|
| 42 |
+
bash run-dev.sh
|
| 43 |
+
```
|
| 44 |
+
This starts the backend and runs `npm run build:watch` so any change in `frontend/src` triggers a rebuild; refresh the browser to see updates.
|
| 45 |
+
- **Or in two terminals:** Terminal 1: `cd frontend && npm run build:watch`; Terminal 2: `python main.py`.
|
| 46 |
|
| 47 |
### Environment Variables
|
| 48 |
|
|
|
|
| 131 |
|
| 132 |
The frontend communicates with the Python backend at `/api/*`:
|
| 133 |
|
| 134 |
+
- `POST /api/generate-prompts-stream` - Generate video prompts (streaming, GPT-5.2)
|
| 135 |
- `POST /api/veo/generate` - Start Kling video generation
|
| 136 |
- `GET /api/veo/events/:taskId` - SSE for generation progress
|
| 137 |
- `POST /api/replicate/generate` - Start Replicate generation
|
frontend/package.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "tsc && vite build",
|
|
|
|
| 9 |
"preview": "vite preview"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "tsc && vite build",
|
| 9 |
+
"build:watch": "tsc && vite build --watch",
|
| 10 |
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
frontend/src/App.tsx
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
| 9 |
GenerationComplete,
|
| 10 |
ErrorDisplay,
|
| 11 |
Login,
|
| 12 |
-
LogoIcon
|
|
|
|
| 13 |
} from '@/components';
|
| 14 |
import { checkHealth } from '@/utils/api';
|
| 15 |
import type { HealthStatus } from '@/types';
|
|
@@ -17,9 +18,10 @@ import type { HealthStatus } from '@/types';
|
|
| 17 |
// Main App Content (uses context)
|
| 18 |
function AppContent() {
|
| 19 |
const { isAuthenticated, loading: authLoading, logout } = useAuth();
|
| 20 |
-
const { state, selectProvider, reset } = useGeneration();
|
| 21 |
const [healthStatus, setHealthStatus] = useState<HealthStatus | null>(null);
|
| 22 |
const [healthError, setHealthError] = useState<string | null>(null);
|
|
|
|
| 23 |
|
| 24 |
// Check backend health on mount (must be called before any conditional returns)
|
| 25 |
useEffect(() => {
|
|
@@ -138,6 +140,17 @@ function AppContent() {
|
|
| 138 |
</span>
|
| 139 |
</div>
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
{/* Logout Button */}
|
| 142 |
<button
|
| 143 |
onClick={logout}
|
|
@@ -174,6 +187,67 @@ function AppContent() {
|
|
| 174 |
<span className="text-coral-400">AdGenesis</span>
|
| 175 |
</p>
|
| 176 |
</footer>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</div>
|
| 178 |
);
|
| 179 |
}
|
|
|
|
| 9 |
GenerationComplete,
|
| 10 |
ErrorDisplay,
|
| 11 |
Login,
|
| 12 |
+
LogoIcon,
|
| 13 |
+
SavedPromptsLibrary
|
| 14 |
} from '@/components';
|
| 15 |
import { checkHealth } from '@/utils/api';
|
| 16 |
import type { HealthStatus } from '@/types';
|
|
|
|
| 18 |
// Main App Content (uses context)
|
| 19 |
function AppContent() {
|
| 20 |
const { isAuthenticated, loading: authLoading, logout } = useAuth();
|
| 21 |
+
const { state, selectProvider, reset, updateSegments } = useGeneration();
|
| 22 |
const [healthStatus, setHealthStatus] = useState<HealthStatus | null>(null);
|
| 23 |
const [healthError, setHealthError] = useState<string | null>(null);
|
| 24 |
+
const [showSavedPrompts, setShowSavedPrompts] = useState(false);
|
| 25 |
|
| 26 |
// Check backend health on mount (must be called before any conditional returns)
|
| 27 |
useEffect(() => {
|
|
|
|
| 140 |
</span>
|
| 141 |
</div>
|
| 142 |
|
| 143 |
+
{/* Saved Prompts Button */}
|
| 144 |
+
<button
|
| 145 |
+
onClick={() => setShowSavedPrompts(true)}
|
| 146 |
+
className="px-3 py-1.5 text-xs font-medium text-void-400 hover:text-void-200
|
| 147 |
+
hover:bg-void-800 rounded-lg transition-colors flex items-center gap-1.5"
|
| 148 |
+
title="My Saved Prompts"
|
| 149 |
+
>
|
| 150 |
+
<span>πΎ</span>
|
| 151 |
+
<span>Saved Prompts</span>
|
| 152 |
+
</button>
|
| 153 |
+
|
| 154 |
{/* Logout Button */}
|
| 155 |
<button
|
| 156 |
onClick={logout}
|
|
|
|
| 187 |
<span className="text-coral-400">AdGenesis</span>
|
| 188 |
</p>
|
| 189 |
</footer>
|
| 190 |
+
|
| 191 |
+
{/* Saved Prompts Modal */}
|
| 192 |
+
{showSavedPrompts && (
|
| 193 |
+
<SavedPromptsLibrary
|
| 194 |
+
onClose={() => setShowSavedPrompts(false)}
|
| 195 |
+
onReuse={(data) => {
|
| 196 |
+
// Handle reusing a saved prompt - skip prompt generation, go directly to video generation
|
| 197 |
+
console.log('β»οΈ Reusing cached prompt:', data);
|
| 198 |
+
setShowSavedPrompts(false);
|
| 199 |
+
|
| 200 |
+
// Extract segments - check multiple possible locations for robustness
|
| 201 |
+
let segments = data.payload?.segments || data.segments || [];
|
| 202 |
+
|
| 203 |
+
// If segments is still empty, try to find it in the data structure
|
| 204 |
+
if (segments.length === 0 && data.payload) {
|
| 205 |
+
// Sometimes the entire payload is the segments object
|
| 206 |
+
const payload = data.payload;
|
| 207 |
+
if (Array.isArray(payload)) {
|
| 208 |
+
segments = payload;
|
| 209 |
+
} else if (typeof payload === 'object') {
|
| 210 |
+
// Check if any property contains an array of segments
|
| 211 |
+
for (const key of Object.keys(payload)) {
|
| 212 |
+
if (Array.isArray(payload[key]) && payload[key].length > 0) {
|
| 213 |
+
// Verify it looks like segments (has segment_info or character_description)
|
| 214 |
+
const firstItem = payload[key][0];
|
| 215 |
+
if (firstItem && (firstItem.segment_info || firstItem.character_description)) {
|
| 216 |
+
segments = payload[key];
|
| 217 |
+
break;
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
console.log('π Extracted segments:', segments);
|
| 225 |
+
console.log('π Segments count:', segments.length);
|
| 226 |
+
|
| 227 |
+
if (segments.length === 0) {
|
| 228 |
+
console.error('β No segments found in data structure:', data);
|
| 229 |
+
alert('No segments found in cached prompt. Please check the console for details.');
|
| 230 |
+
return;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// Determine provider from metadata or default to kling
|
| 234 |
+
const provider = data.metadata?.provider || 'kling';
|
| 235 |
+
|
| 236 |
+
console.log(`π Loading ${segments.length} cached segments for ${provider}`);
|
| 237 |
+
|
| 238 |
+
// IMPORTANT: Load segments FIRST, then set provider
|
| 239 |
+
// This ensures segments are available when the form renders
|
| 240 |
+
updateSegments(segments);
|
| 241 |
+
|
| 242 |
+
// Small delay to ensure state update has propagated
|
| 243 |
+
setTimeout(() => {
|
| 244 |
+
selectProvider(provider);
|
| 245 |
+
}, 50);
|
| 246 |
+
|
| 247 |
+
// The form will detect the pre-loaded segments and skip prompt generation
|
| 248 |
+
}}
|
| 249 |
+
/>
|
| 250 |
+
)}
|
| 251 |
</div>
|
| 252 |
);
|
| 253 |
}
|
frontend/src/components/ErrorDisplay.tsx
CHANGED
|
@@ -4,7 +4,10 @@ import { XIcon, RefreshIcon, ArrowLeftIcon } from './Icons';
|
|
| 4 |
|
| 5 |
export const ErrorDisplay: React.FC = () => {
|
| 6 |
const { state, reset, setStep } = useGeneration();
|
| 7 |
-
const { error } = state;
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
return (
|
| 10 |
<motion.div
|
|
@@ -23,17 +26,34 @@ export const ErrorDisplay: React.FC = () => {
|
|
| 23 |
<XIcon size={40} className="text-red-400" />
|
| 24 |
</motion.div>
|
| 25 |
|
| 26 |
-
{/*
|
| 27 |
<h1 className="text-3xl font-display font-bold text-void-100 mb-4">
|
| 28 |
-
Generation Failed
|
| 29 |
</h1>
|
| 30 |
-
|
| 31 |
-
<div className="card bg-red-500/10 border-red-500/30 mb-
|
| 32 |
<p className="text-red-300 text-sm">
|
| 33 |
{error || 'An unexpected error occurred during video generation.'}
|
| 34 |
</p>
|
| 35 |
</div>
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
{/* Actions */}
|
| 38 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
| 39 |
<button
|
|
|
|
| 4 |
|
| 5 |
export const ErrorDisplay: React.FC = () => {
|
| 6 |
const { state, reset, setStep } = useGeneration();
|
| 7 |
+
const { error, generatedVideos, segments } = state;
|
| 8 |
+
const isCancelled = error?.toLowerCase().includes('cancelled') ?? false;
|
| 9 |
+
const hasPartialVideos = generatedVideos.length > 0;
|
| 10 |
+
const hasPartialPrompts = segments.length > 0 && !hasPartialVideos;
|
| 11 |
|
| 12 |
return (
|
| 13 |
<motion.div
|
|
|
|
| 26 |
<XIcon size={40} className="text-red-400" />
|
| 27 |
</motion.div>
|
| 28 |
|
| 29 |
+
{/* Title: Cancelled vs Failed */}
|
| 30 |
<h1 className="text-3xl font-display font-bold text-void-100 mb-4">
|
| 31 |
+
{isCancelled ? 'Generation Cancelled' : 'Generation Failed'}
|
| 32 |
</h1>
|
| 33 |
+
|
| 34 |
+
<div className="card bg-red-500/10 border-red-500/30 mb-4">
|
| 35 |
<p className="text-red-300 text-sm">
|
| 36 |
{error || 'An unexpected error occurred during video generation.'}
|
| 37 |
</p>
|
| 38 |
</div>
|
| 39 |
|
| 40 |
+
{/* Partial results when user cancelled */}
|
| 41 |
+
{(isCancelled && (hasPartialVideos || hasPartialPrompts)) && (
|
| 42 |
+
<div className="card bg-void-900/80 border-void-600 mb-8 text-left">
|
| 43 |
+
<h3 className="text-sm font-semibold text-void-200 mb-2">Stopped with partial results</h3>
|
| 44 |
+
{hasPartialVideos && (
|
| 45 |
+
<p className="text-void-300 text-sm">
|
| 46 |
+
<span className="font-medium text-void-100">{generatedVideos.length}</span> video segment{generatedVideos.length === 1 ? '' : 's'} generated.
|
| 47 |
+
</p>
|
| 48 |
+
)}
|
| 49 |
+
{hasPartialPrompts && (
|
| 50 |
+
<p className="text-void-300 text-sm mt-1">
|
| 51 |
+
<span className="font-medium text-void-100">{segments.length}</span> segment prompt{segments.length === 1 ? '' : 's'} generated (no videos yet).
|
| 52 |
+
</p>
|
| 53 |
+
)}
|
| 54 |
+
</div>
|
| 55 |
+
)}
|
| 56 |
+
|
| 57 |
{/* Actions */}
|
| 58 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
| 59 |
<button
|
frontend/src/components/GenerationComplete.tsx
CHANGED
|
@@ -2,11 +2,12 @@ import { useState } from 'react';
|
|
| 2 |
import { motion } from 'framer-motion';
|
| 3 |
import { useGeneration } from '@/context/GenerationContext';
|
| 4 |
import { CheckIcon, DownloadIcon, PlayIcon, RefreshIcon, VideoIcon } from './Icons';
|
|
|
|
| 5 |
import { mergeVideos, ClipMetadata } from '@/utils/api';
|
| 6 |
|
| 7 |
export const GenerationComplete: React.FC = () => {
|
| 8 |
const { state, reset } = useGeneration();
|
| 9 |
-
const { generatedVideos, provider } = state;
|
| 10 |
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
| 11 |
const [isMerging, setIsMerging] = useState(false);
|
| 12 |
const [mergeError, setMergeError] = useState<string | null>(null);
|
|
@@ -116,6 +117,30 @@ export const GenerationComplete: React.FC = () => {
|
|
| 116 |
className="min-h-[60vh] flex flex-col items-center justify-center p-8"
|
| 117 |
>
|
| 118 |
<div className="max-w-4xl w-full">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
{/* Success Header */}
|
| 120 |
<div className="text-center mb-12">
|
| 121 |
<motion.div
|
|
@@ -140,7 +165,7 @@ export const GenerationComplete: React.FC = () => {
|
|
| 140 |
className="text-4xl font-display font-bold mb-4"
|
| 141 |
>
|
| 142 |
<span className={accentColor === 'coral' ? 'gradient-text' : 'gradient-text-electric'}>
|
| 143 |
-
Generation Complete!
|
| 144 |
</span>
|
| 145 |
</motion.h1>
|
| 146 |
|
|
@@ -219,6 +244,20 @@ export const GenerationComplete: React.FC = () => {
|
|
| 219 |
))}
|
| 220 |
</motion.div>
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
{/* Merged Video Preview */}
|
| 223 |
{mergedVideoUrl && (
|
| 224 |
<motion.div
|
|
|
|
| 2 |
import { motion } from 'framer-motion';
|
| 3 |
import { useGeneration } from '@/context/GenerationContext';
|
| 4 |
import { CheckIcon, DownloadIcon, PlayIcon, RefreshIcon, VideoIcon } from './Icons';
|
| 5 |
+
import { SegmentPromptsViewer } from './SegmentPromptsViewer';
|
| 6 |
import { mergeVideos, ClipMetadata } from '@/utils/api';
|
| 7 |
|
| 8 |
export const GenerationComplete: React.FC = () => {
|
| 9 |
const { state, reset } = useGeneration();
|
| 10 |
+
const { generatedVideos, provider, segments, partialCompletionError } = state;
|
| 11 |
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
| 12 |
const [isMerging, setIsMerging] = useState(false);
|
| 13 |
const [mergeError, setMergeError] = useState<string | null>(null);
|
|
|
|
| 117 |
className="min-h-[60vh] flex flex-col items-center justify-center p-8"
|
| 118 |
>
|
| 119 |
<div className="max-w-4xl w-full">
|
| 120 |
+
{/* Partial Completion Warning */}
|
| 121 |
+
{partialCompletionError && (
|
| 122 |
+
<motion.div
|
| 123 |
+
initial={{ opacity: 0, y: -10 }}
|
| 124 |
+
animate={{ opacity: 1, y: 0 }}
|
| 125 |
+
className="mb-8 p-6 bg-gradient-to-r from-amber-500/10 to-red-500/10 border-2 border-amber-500/30 rounded-xl"
|
| 126 |
+
>
|
| 127 |
+
<div className="flex items-start gap-4">
|
| 128 |
+
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-amber-500/20 flex items-center justify-center">
|
| 129 |
+
<span className="text-2xl">β οΈ</span>
|
| 130 |
+
</div>
|
| 131 |
+
<div className="flex-1">
|
| 132 |
+
<h3 className="font-bold text-amber-300 mb-2 text-lg">Partial Generation</h3>
|
| 133 |
+
<p className="text-void-200 text-sm mb-3">
|
| 134 |
+
{partialCompletionError}
|
| 135 |
+
</p>
|
| 136 |
+
<p className="text-void-400 text-xs">
|
| 137 |
+
The successfully generated videos are displayed below. You can still export and use them.
|
| 138 |
+
</p>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</motion.div>
|
| 142 |
+
)}
|
| 143 |
+
|
| 144 |
{/* Success Header */}
|
| 145 |
<div className="text-center mb-12">
|
| 146 |
<motion.div
|
|
|
|
| 165 |
className="text-4xl font-display font-bold mb-4"
|
| 166 |
>
|
| 167 |
<span className={accentColor === 'coral' ? 'gradient-text' : 'gradient-text-electric'}>
|
| 168 |
+
{partialCompletionError ? 'Partial Generation Complete' : 'Generation Complete!'}
|
| 169 |
</span>
|
| 170 |
</motion.h1>
|
| 171 |
|
|
|
|
| 244 |
))}
|
| 245 |
</motion.div>
|
| 246 |
|
| 247 |
+
{/* Segment Prompts Viewer */}
|
| 248 |
+
{segments.length > 0 && (
|
| 249 |
+
<motion.div
|
| 250 |
+
initial={{ opacity: 0, y: 20 }}
|
| 251 |
+
animate={{ opacity: 1, y: 0 }}
|
| 252 |
+
transition={{ delay: 0.5 }}
|
| 253 |
+
>
|
| 254 |
+
<SegmentPromptsViewer
|
| 255 |
+
segments={segments}
|
| 256 |
+
accentColor={accentColor}
|
| 257 |
+
/>
|
| 258 |
+
</motion.div>
|
| 259 |
+
)}
|
| 260 |
+
|
| 261 |
{/* Merged Video Preview */}
|
| 262 |
{mergedVideoUrl && (
|
| 263 |
<motion.div
|
frontend/src/components/GenerationForm.tsx
CHANGED
|
@@ -8,18 +8,28 @@ import {
|
|
| 8 |
ImageIcon
|
| 9 |
} from './Icons';
|
| 10 |
import {
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
uploadImage,
|
| 13 |
klingGenerate,
|
| 14 |
klingExtend,
|
| 15 |
waitForKlingVideo,
|
| 16 |
generateVideoWithRetry,
|
|
|
|
|
|
|
| 17 |
downloadVideo,
|
| 18 |
getVideoDuration,
|
| 19 |
generateThumbnails,
|
| 20 |
replicateGenerate,
|
| 21 |
waitForReplicateVideo,
|
| 22 |
-
whisperAnalyzeAndExtract
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
} from '@/utils/api';
|
| 24 |
|
| 25 |
interface GenerationFormProps {
|
|
@@ -37,8 +47,11 @@ const aspectRatios = ['9:16', '16:9', '1:1'];
|
|
| 37 |
type GenerationMode = 'extend' | 'frame-continuity';
|
| 38 |
|
| 39 |
export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack }) => {
|
| 40 |
-
const { startGeneration, updateProgress, addVideo, setStep, setError,
|
| 41 |
-
const {
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
// Draft storage key
|
| 44 |
const draftKey = `video-gen-draft-${provider}`;
|
|
@@ -78,73 +91,15 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 78 |
const [imagePreview, setImagePreview] = useState<string | null>(draft?.imagePreview || null);
|
| 79 |
const [isDragging, setIsDragging] = useState(false);
|
| 80 |
const [isGenerating, setIsGenerating] = useState(false);
|
|
|
|
|
|
|
| 81 |
|
| 82 |
// Generation mode selection
|
| 83 |
const [generationMode, setGenerationMode] = useState<GenerationMode>(draft?.generationMode || 'frame-continuity');
|
| 84 |
-
|
| 85 |
-
// Retry editing state
|
| 86 |
-
const [retryDialogue, setRetryDialogue] = useState('');
|
| 87 |
-
const [retryEnvironment, setRetryEnvironment] = useState('');
|
| 88 |
-
const [retryAction, setRetryAction] = useState('');
|
| 89 |
-
|
| 90 |
-
// Initialize retry fields when error occurs
|
| 91 |
-
useEffect(() => {
|
| 92 |
-
if (retryState && segments[retryState.failedSegmentIndex]) {
|
| 93 |
-
const seg = segments[retryState.failedSegmentIndex];
|
| 94 |
-
setRetryDialogue(seg.action_timeline?.dialogue || '');
|
| 95 |
-
setRetryEnvironment(seg.scene_continuity?.environment || '');
|
| 96 |
-
setRetryAction(seg.character_description?.current_state || '');
|
| 97 |
-
}
|
| 98 |
-
}, [retryState, segments]);
|
| 99 |
-
|
| 100 |
-
const handleRetrySubmit = () => {
|
| 101 |
-
if (!retryState) return;
|
| 102 |
-
|
| 103 |
-
const idx = retryState.failedSegmentIndex;
|
| 104 |
-
const updatedSegments = [...segments];
|
| 105 |
-
|
| 106 |
-
// Update the segment with edited values
|
| 107 |
-
if (updatedSegments[idx]) {
|
| 108 |
-
updatedSegments[idx] = {
|
| 109 |
-
...updatedSegments[idx],
|
| 110 |
-
action_timeline: {
|
| 111 |
-
...updatedSegments[idx].action_timeline,
|
| 112 |
-
dialogue: retryDialogue
|
| 113 |
-
},
|
| 114 |
-
scene_continuity: {
|
| 115 |
-
...updatedSegments[idx].scene_continuity,
|
| 116 |
-
environment: retryEnvironment
|
| 117 |
-
},
|
| 118 |
-
character_description: {
|
| 119 |
-
...updatedSegments[idx].character_description,
|
| 120 |
-
current_state: retryAction
|
| 121 |
-
}
|
| 122 |
-
};
|
| 123 |
-
|
| 124 |
-
updateSegments(updatedSegments);
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
// Clear error and resume
|
| 128 |
-
setRetryState(null);
|
| 129 |
-
setStep('generating_video');
|
| 130 |
-
setIsGenerating(true);
|
| 131 |
-
|
| 132 |
-
// Resume generation based on provider
|
| 133 |
-
if (provider === 'kling') {
|
| 134 |
-
if (generationMode === 'frame-continuity') {
|
| 135 |
-
handleKlingFrameContinuityFlow();
|
| 136 |
-
} else {
|
| 137 |
-
handleKlingExtendFlow();
|
| 138 |
-
}
|
| 139 |
-
} else {
|
| 140 |
-
handleReplicateGeneration();
|
| 141 |
-
}
|
| 142 |
-
};
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
};
|
| 148 |
|
| 149 |
// Show notification if draft was restored
|
| 150 |
useEffect(() => {
|
|
@@ -168,15 +123,32 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 168 |
try {
|
| 169 |
const draft = {
|
| 170 |
formState,
|
| 171 |
-
imagePreview,
|
| 172 |
generationMode,
|
|
|
|
|
|
|
| 173 |
savedAt: new Date().toISOString(),
|
| 174 |
};
|
| 175 |
localStorage.setItem(draftKey, JSON.stringify(draft));
|
| 176 |
} catch (error) {
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}
|
| 179 |
-
}, [formState, imagePreview, generationMode, draftKey]);
|
| 180 |
|
| 181 |
// Clear draft function
|
| 182 |
const clearDraft = useCallback(() => {
|
|
@@ -189,10 +161,44 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 189 |
}
|
| 190 |
}, [draftKey]);
|
| 191 |
|
|
|
|
|
|
|
|
|
|
| 192 |
// Calculate estimated segments
|
| 193 |
const wordCount = formState.script.trim().split(/\s+/).filter(w => w).length;
|
| 194 |
const estimatedSegments = wordCount > 0 ? Math.max(1, Math.min(Math.ceil(wordCount / 17), 10)) : 0;
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
// Handle input changes
|
| 197 |
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
| 198 |
const { name, value } = e.target;
|
|
@@ -283,56 +289,131 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 283 |
// 3. Use that frame as reference for next segment
|
| 284 |
// 4. Repeat for all segments
|
| 285 |
|
| 286 |
-
const handleKlingFrameContinuityFlow = async () => {
|
| 287 |
-
if (!imageFile
|
|
|
|
| 288 |
|
| 289 |
setIsGenerating(true);
|
| 290 |
setError(null);
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
| 292 |
try {
|
| 293 |
-
// Step 1: Generate prompts using GPT-4o
|
| 294 |
-
updateProgress('Analyzing script with GPT-4o...');
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
// Track current reference image (starts with original)
|
| 319 |
let currentImageFile = imageFile;
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
// Step 2: Generate videos segment by segment with frame continuity
|
| 323 |
-
for (let i =
|
| 324 |
-
const segment =
|
| 325 |
-
const isLastSegment = i ===
|
| 326 |
|
| 327 |
updateProgress(
|
| 328 |
-
`Generating video ${i + 1} of ${
|
| 329 |
i,
|
| 330 |
-
|
| 331 |
);
|
| 332 |
|
| 333 |
// Upload current reference image
|
| 334 |
updateProgress(`Uploading reference image for segment ${i + 1}...`);
|
| 335 |
-
const uploadResult = await uploadImage(currentImageFile);
|
| 336 |
const hostedImageUrl = uploadResult.url;
|
| 337 |
|
| 338 |
console.log(`πΌοΈ Segment ${i + 1} using image: ${i === 0 ? 'original' : 'last frame from previous'}`);
|
|
@@ -381,16 +462,19 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 381 |
});
|
| 382 |
|
| 383 |
if (whisperResult.success && whisperResult.frame_base64) {
|
| 384 |
-
// Convert base64 frame to File for next segment
|
| 385 |
-
const
|
|
|
|
|
|
|
|
|
|
| 386 |
const byteCharacters = atob(base64Data);
|
| 387 |
const byteNumbers = new Array(byteCharacters.length);
|
| 388 |
for (let j = 0; j < byteCharacters.length; j++) {
|
| 389 |
byteNumbers[j] = byteCharacters.charCodeAt(j);
|
| 390 |
}
|
| 391 |
const byteArray = new Uint8Array(byteNumbers);
|
| 392 |
-
const frameBlob = new Blob([byteArray], { type:
|
| 393 |
-
currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.
|
| 394 |
|
| 395 |
// Store trim point for later merge
|
| 396 |
if (whisperResult.trim_point) {
|
|
@@ -406,7 +490,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 406 |
console.log(`β
Whisper: Last word at ${whisperResult.last_word_timestamp?.toFixed(2)}s, frame at ${whisperResult.frame_timestamp?.toFixed(2)}s, trim at ${trimPoint.toFixed(2)}s`);
|
| 407 |
|
| 408 |
// REFINE NEXT SEGMENT PROMPT with frame + transcription
|
| 409 |
-
const nextSegment =
|
| 410 |
if (nextSegment && currentImageFile) {
|
| 411 |
updateProgress(`Refining segment ${i + 2} prompt with visual and audio context...`);
|
| 412 |
try {
|
|
@@ -418,7 +502,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 418 |
dialogue
|
| 419 |
);
|
| 420 |
// Update the next segment with refined prompt
|
| 421 |
-
|
| 422 |
console.log(`β
Refined segment ${i + 2} prompt for consistency`);
|
| 423 |
} catch (refineError) {
|
| 424 |
console.warn(`β οΈ Prompt refinement failed, using original:`, refineError);
|
|
@@ -451,10 +535,9 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 451 |
thumbnails,
|
| 452 |
trimPoint, // Store trim point for merge
|
| 453 |
};
|
| 454 |
-
generatedVideos.push(generatedVideo);
|
| 455 |
addVideo(generatedVideo);
|
| 456 |
|
| 457 |
-
updateProgress(`Completed video ${i + 1} of ${
|
| 458 |
}
|
| 459 |
|
| 460 |
// All done!
|
|
@@ -466,13 +549,320 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 466 |
} catch (err) {
|
| 467 |
console.error('Generation error:', err);
|
| 468 |
const errorMessage = err instanceof Error ? err.message : 'Generation failed';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
});
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
|
| 477 |
} finally {
|
| 478 |
setIsGenerating(false);
|
|
@@ -484,42 +874,81 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 484 |
// ============================================
|
| 485 |
// Original flow using KIE's extend API
|
| 486 |
|
| 487 |
-
const handleKlingExtendFlow = async () => {
|
| 488 |
if (!imageFile || !formState.script.trim()) return;
|
| 489 |
|
| 490 |
setIsGenerating(true);
|
| 491 |
setError(null);
|
|
|
|
|
|
|
| 492 |
|
| 493 |
try {
|
| 494 |
-
// Step 1:
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
formData.append('script', formState.script);
|
| 499 |
-
formData.append('style', formState.style || 'clean, lifestyle UGC');
|
| 500 |
-
formData.append('jsonFormat', 'standard');
|
| 501 |
-
formData.append('continuationMode', 'true');
|
| 502 |
-
formData.append('voiceType', formState.voiceType || '');
|
| 503 |
-
formData.append('energyLevel', formState.energyLevel || '');
|
| 504 |
-
formData.append('settingMode', 'single');
|
| 505 |
-
formData.append('cameraStyle', formState.cameraStyle || '');
|
| 506 |
-
formData.append('narrativeStyle', formState.narrativeStyle || '');
|
| 507 |
-
formData.append('image', imageFile);
|
| 508 |
-
|
| 509 |
-
// Use existing segments if retrying, otherwise generate new ones
|
| 510 |
-
let payload: { segments: VeoSegment[] };
|
| 511 |
-
if (retryState && segments.length > 0) {
|
| 512 |
-
// Retry mode: use existing segments (they may have been edited)
|
| 513 |
payload = { segments };
|
| 514 |
-
|
|
|
|
| 515 |
} else {
|
| 516 |
-
//
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
}
|
| 521 |
-
updateProgress(`Generated ${payload.segments.length} segments. Starting video generation...`);
|
| 522 |
-
startGeneration(payload.segments);
|
| 523 |
}
|
| 524 |
|
| 525 |
// Step 2: Upload reference image once
|
|
@@ -538,7 +967,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 538 |
try {
|
| 539 |
const lastVideoBlob = await fetch(generatedVideos[startIndex - 1].blobUrl!).then(r => r.blob());
|
| 540 |
const lastFrameFile = await extractLastFrame(lastVideoBlob);
|
| 541 |
-
const frameUploadResult = await uploadImage(lastFrameFile);
|
| 542 |
currentImageUrl = frameUploadResult.url;
|
| 543 |
updateProgress(`Using frame from segment ${startIndex} for segment ${startIndex + 1}...`);
|
| 544 |
} catch (frameError) {
|
|
@@ -587,7 +1016,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 587 |
return extendResult;
|
| 588 |
}
|
| 589 |
}, 300000, (attempt) => {
|
| 590 |
-
updateProgress(`Retrying video ${i + 1}... (attempt ${attempt}/
|
| 591 |
});
|
| 592 |
|
| 593 |
// Download and save
|
|
@@ -623,20 +1052,21 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 623 |
} catch (err) {
|
| 624 |
console.error('Generation error:', err);
|
| 625 |
const errorMessage = err instanceof Error ? err.message : 'Generation failed';
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
|
|
|
|
|
|
| 638 |
}
|
| 639 |
-
|
| 640 |
} finally {
|
| 641 |
setIsGenerating(false);
|
| 642 |
// Clean up any remaining task IDs
|
|
@@ -648,67 +1078,112 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 648 |
// REPLICATE GENERATION - FRAME CONTINUITY FLOW
|
| 649 |
// ============================================
|
| 650 |
// This mirrors the approach from standalone_video_creator.py:
|
| 651 |
-
// 1. Generate prompts using GPT-
|
| 652 |
// 2. For each segment, generate video with current reference image
|
| 653 |
// 3. Extract last frame from generated video
|
| 654 |
// 4. Use that frame as reference for next segment
|
| 655 |
// 5. Result: Perfect visual continuity across all segments
|
| 656 |
|
| 657 |
-
const handleReplicateGeneration = async () => {
|
| 658 |
if (!formState.script.trim()) return;
|
| 659 |
|
| 660 |
setIsGenerating(true);
|
| 661 |
setError(null);
|
|
|
|
|
|
|
| 662 |
|
| 663 |
try {
|
| 664 |
-
// Step 1:
|
| 665 |
-
// Note: Replicate can work without an image, but for consistency we encourage one
|
| 666 |
-
updateProgress('Analyzing script with GPT-4o...');
|
| 667 |
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
formData.append('energyLevel', formState.energyLevel || '');
|
| 675 |
-
formData.append('settingMode', 'single');
|
| 676 |
-
formData.append('cameraStyle', formState.cameraStyle || '');
|
| 677 |
-
formData.append('narrativeStyle', formState.narrativeStyle || '');
|
| 678 |
-
|
| 679 |
-
// If image provided, include it for GPT-4o analysis
|
| 680 |
-
if (imageFile) {
|
| 681 |
-
formData.append('image', imageFile);
|
| 682 |
} else {
|
| 683 |
-
//
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
|
| 689 |
-
|
|
|
|
|
|
|
| 690 |
|
| 691 |
-
|
| 692 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
}
|
| 694 |
|
| 695 |
-
const segments = payload.segments;
|
| 696 |
-
updateProgress(`Generated ${segments.length} segments. Starting Replicate generation...`);
|
| 697 |
-
startGeneration(segments);
|
| 698 |
-
|
| 699 |
// Track current reference image (starts with original if provided)
|
| 700 |
let currentImageFile = imageFile;
|
| 701 |
-
const generatedVideos: GeneratedVideo[] = [];
|
| 702 |
|
| 703 |
// Step 2: Generate videos segment by segment with frame continuity
|
| 704 |
-
for (let i = 0; i <
|
| 705 |
-
const segment =
|
| 706 |
-
const isLastSegment = i ===
|
| 707 |
|
| 708 |
updateProgress(
|
| 709 |
-
`Generating video ${i + 1} of ${
|
| 710 |
i,
|
| 711 |
-
|
| 712 |
);
|
| 713 |
|
| 714 |
// Convert structured segment to text prompt for Replicate
|
|
@@ -721,7 +1196,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 721 |
let imageUrl: string | undefined;
|
| 722 |
if (currentImageFile) {
|
| 723 |
updateProgress(`Uploading reference image for segment ${i + 1}...`);
|
| 724 |
-
const uploadResult = await uploadImage(currentImageFile);
|
| 725 |
imageUrl = uploadResult.url;
|
| 726 |
console.log(`πΌοΈ Segment ${i + 1} using image: ${i === 0 ? 'original' : 'last frame from previous'}`);
|
| 727 |
}
|
|
@@ -768,16 +1243,19 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 768 |
});
|
| 769 |
|
| 770 |
if (whisperResult.success && whisperResult.frame_base64) {
|
| 771 |
-
// Convert base64 frame to File for next segment
|
| 772 |
-
const
|
|
|
|
|
|
|
|
|
|
| 773 |
const byteCharacters = atob(base64Data);
|
| 774 |
const byteNumbers = new Array(byteCharacters.length);
|
| 775 |
for (let j = 0; j < byteCharacters.length; j++) {
|
| 776 |
byteNumbers[j] = byteCharacters.charCodeAt(j);
|
| 777 |
}
|
| 778 |
const byteArray = new Uint8Array(byteNumbers);
|
| 779 |
-
const frameBlob = new Blob([byteArray], { type:
|
| 780 |
-
currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.
|
| 781 |
|
| 782 |
// Store trim point for later merge
|
| 783 |
if (whisperResult.trim_point) {
|
|
@@ -793,7 +1271,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 793 |
console.log(`β
Whisper: Last word at ${whisperResult.last_word_timestamp?.toFixed(2)}s, frame at ${whisperResult.frame_timestamp?.toFixed(2)}s, trim at ${trimPoint.toFixed(2)}s`);
|
| 794 |
|
| 795 |
// REFINE NEXT SEGMENT PROMPT with frame + transcription
|
| 796 |
-
const nextSegment =
|
| 797 |
if (nextSegment && currentImageFile) {
|
| 798 |
updateProgress(`Refining segment ${i + 2} prompt with visual and audio context...`);
|
| 799 |
try {
|
|
@@ -805,7 +1283,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 805 |
dialogue
|
| 806 |
);
|
| 807 |
// Update the next segment with refined prompt
|
| 808 |
-
|
| 809 |
console.log(`β
Refined segment ${i + 2} prompt for consistency`);
|
| 810 |
} catch (refineError) {
|
| 811 |
console.warn(`β οΈ Prompt refinement failed, using original:`, refineError);
|
|
@@ -838,10 +1316,9 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 838 |
thumbnails,
|
| 839 |
trimPoint, // Store trim point for merge
|
| 840 |
};
|
| 841 |
-
generatedVideos.push(generatedVideo);
|
| 842 |
addVideo(generatedVideo);
|
| 843 |
|
| 844 |
-
updateProgress(`Completed video ${i + 1} of ${
|
| 845 |
}
|
| 846 |
|
| 847 |
// All done!
|
|
@@ -851,14 +1328,21 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 851 |
} catch (err) {
|
| 852 |
console.error('Replicate generation error:', err);
|
| 853 |
const errorMessage = err instanceof Error ? err.message : 'Replicate generation failed';
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 859 |
});
|
| 860 |
-
|
| 861 |
-
|
|
|
|
|
|
|
|
|
|
| 862 |
} finally {
|
| 863 |
setIsGenerating(false);
|
| 864 |
}
|
|
@@ -929,9 +1413,42 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 929 |
}
|
| 930 |
};
|
| 931 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
const isValid = provider === 'kling'
|
| 933 |
-
? !!imageFile && formState.script.trim().length > 0
|
| 934 |
-
: formState.script.trim().length > 0;
|
| 935 |
|
| 936 |
return (
|
| 937 |
<motion.div
|
|
@@ -957,85 +1474,114 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 957 |
<span className="text-void-200"> Video Generation</span>
|
| 958 |
</h1>
|
| 959 |
<p className="text-void-400 mt-2">
|
| 960 |
-
{
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 964 |
</p>
|
| 965 |
</div>
|
| 966 |
</div>
|
| 967 |
|
| 968 |
-
{/*
|
| 969 |
-
{
|
| 970 |
-
<div
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
>
|
| 976 |
-
<
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
<
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
<p className="text-void-300 mb-6 p-4 bg-void-800 rounded-lg border border-void-700">
|
| 984 |
-
Error at segment {retryState.failedSegmentIndex + 1}: <span className="text-red-300">{retryState.error}</span>
|
| 985 |
-
</p>
|
| 986 |
-
|
| 987 |
-
<div className="space-y-4 mb-8">
|
| 988 |
-
<h4 className="font-semibold text-void-200">Edit Segment {retryState.failedSegmentIndex + 1} to fix the issue:</h4>
|
| 989 |
-
|
| 990 |
-
<div>
|
| 991 |
-
<label className="block text-sm font-medium text-void-400 mb-1">Dialogue</label>
|
| 992 |
-
<textarea
|
| 993 |
-
value={retryDialogue}
|
| 994 |
-
onChange={(e) => setRetryDialogue(e.target.value)}
|
| 995 |
-
className="w-full bg-void-950 border border-void-700 rounded-lg p-3 text-void-100 h-24 focus:border-coral-500 focus:outline-none"
|
| 996 |
-
placeholder="Adjust dialogue..."
|
| 997 |
-
/>
|
| 998 |
-
</div>
|
| 999 |
-
|
| 1000 |
-
<div>
|
| 1001 |
-
<label className="block text-sm font-medium text-void-400 mb-1">Action / Character State</label>
|
| 1002 |
-
<textarea
|
| 1003 |
-
value={retryAction}
|
| 1004 |
-
onChange={(e) => setRetryAction(e.target.value)}
|
| 1005 |
-
className="w-full bg-void-950 border border-void-700 rounded-lg p-3 text-void-100 h-24 focus:border-coral-500 focus:outline-none"
|
| 1006 |
-
placeholder="Adjust action description..."
|
| 1007 |
-
/>
|
| 1008 |
-
</div>
|
| 1009 |
-
|
| 1010 |
-
<div>
|
| 1011 |
-
<label className="block text-sm font-medium text-void-400 mb-1">Environment</label>
|
| 1012 |
-
<textarea
|
| 1013 |
-
value={retryEnvironment}
|
| 1014 |
-
onChange={(e) => setRetryEnvironment(e.target.value)}
|
| 1015 |
-
className="w-full bg-void-950 border border-void-700 rounded-lg p-3 text-void-100 h-24 focus:border-coral-500 focus:outline-none"
|
| 1016 |
-
placeholder="Adjust environment description..."
|
| 1017 |
-
/>
|
| 1018 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1019 |
</div>
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
</div>
|
| 1035 |
-
|
| 1036 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
)}
|
| 1038 |
|
|
|
|
| 1039 |
{/* Draft Restored Notification */}
|
| 1040 |
{draftRestored && (
|
| 1041 |
<motion.div
|
|
@@ -1062,55 +1608,131 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
|
|
| 1062 |
</motion.div>
|
| 1063 |
)}
|
| 1064 |
|
| 1065 |
-
<form onSubmit={handleSubmit}>
|
| 1066 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 1067 |
{/* Left Column - Script & Style */}
|
| 1068 |
<div className="space-y-6">
|
| 1069 |
-
{/* Script Input */}
|
| 1070 |
-
|
| 1071 |
-
<
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
|
|
|
| 1081 |
|
| 1082 |
The AI will automatically analyze and segment your script into optimal video chunks, typically 8 seconds each."
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1088 |
</p>
|
| 1089 |
-
{wordCount > 0 && (
|
| 1090 |
-
<div className="flex items-center gap-4 text-xs">
|
| 1091 |
-
<span className="text-void-400">{wordCount} words</span>
|
| 1092 |
-
<span className={`font-semibold ${provider === 'kling' ? 'text-coral-400' : 'text-electric-400'}`}>
|
| 1093 |
-
~{estimatedSegments} segments
|
| 1094 |
-
</span>
|
| 1095 |
-
</div>
|
| 1096 |
-
)}
|
| 1097 |
</div>
|
| 1098 |
-
|
| 1099 |
|
| 1100 |
-
{/* Style Input */}
|
| 1101 |
-
|
| 1102 |
-
<
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1114 |
|
| 1115 |
{/* Generation Mode Selection (Kling only) */}
|
| 1116 |
{provider === 'kling' && (
|
|
@@ -1164,8 +1786,8 @@ The AI will automatically analyze and segment your script into optimal video chu
|
|
| 1164 |
</div>
|
| 1165 |
)}
|
| 1166 |
|
| 1167 |
-
{/* Generation Preview */}
|
| 1168 |
-
{estimatedSegments > 0 && (
|
| 1169 |
<motion.div
|
| 1170 |
initial={{ opacity: 0, scale: 0.95 }}
|
| 1171 |
animate={{ opacity: 1, scale: 1 }}
|
|
@@ -1354,7 +1976,10 @@ The AI will automatically analyze and segment your script into optimal video chu
|
|
| 1354 |
className={`
|
| 1355 |
w-full py-4 font-semibold rounded-xl transition-all duration-300
|
| 1356 |
flex items-center justify-center gap-3
|
| 1357 |
-
${
|
|
|
|
|
|
|
|
|
|
| 1358 |
disabled:opacity-50 disabled:cursor-not-allowed disabled:scale-100
|
| 1359 |
`}
|
| 1360 |
>
|
|
@@ -1363,6 +1988,13 @@ The AI will automatically analyze and segment your script into optimal video chu
|
|
| 1363 |
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 1364 |
<span>Generating...</span>
|
| 1365 |
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1366 |
) : (
|
| 1367 |
<>
|
| 1368 |
<SparklesIcon size={20} />
|
|
|
|
| 8 |
ImageIcon
|
| 9 |
} from './Icons';
|
| 10 |
import {
|
| 11 |
+
generatePromptsStreaming,
|
| 12 |
+
type StreamEvent,
|
| 13 |
+
type StreamSegmentEvent,
|
| 14 |
uploadImage,
|
| 15 |
klingGenerate,
|
| 16 |
klingExtend,
|
| 17 |
waitForKlingVideo,
|
| 18 |
generateVideoWithRetry,
|
| 19 |
+
handleFlowRetry,
|
| 20 |
+
type FlowRetryOutcome,
|
| 21 |
downloadVideo,
|
| 22 |
getVideoDuration,
|
| 23 |
generateThumbnails,
|
| 24 |
replicateGenerate,
|
| 25 |
waitForReplicateVideo,
|
| 26 |
+
whisperAnalyzeAndExtract,
|
| 27 |
+
fixUnsafePrompt,
|
| 28 |
+
isUnsafeSegmentError,
|
| 29 |
+
validateContent,
|
| 30 |
+
AUTO_FIX_MAX_ATTEMPTS,
|
| 31 |
+
MAX_VIDEO_ATTEMPTS,
|
| 32 |
+
type ContentValidationResponse
|
| 33 |
} from '@/utils/api';
|
| 34 |
|
| 35 |
interface GenerationFormProps {
|
|
|
|
| 47 |
type GenerationMode = 'extend' | 'frame-continuity';
|
| 48 |
|
| 49 |
export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack }) => {
|
| 50 |
+
const { startGeneration, updateProgress, addVideo, setStep, setError, updateSegments, addTaskId, removeTaskId, setPartialCompletionError, registerPromptAbortController, state } = useGeneration();
|
| 51 |
+
const { generatedVideos, segments, isCancelling } = state;
|
| 52 |
+
|
| 53 |
+
// Check if we're in "reuse mode" (segments pre-loaded from cache)
|
| 54 |
+
const isReuseMode = segments.length > 0;
|
| 55 |
|
| 56 |
// Draft storage key
|
| 57 |
const draftKey = `video-gen-draft-${provider}`;
|
|
|
|
| 91 |
const [imagePreview, setImagePreview] = useState<string | null>(draft?.imagePreview || null);
|
| 92 |
const [isDragging, setIsDragging] = useState(false);
|
| 93 |
const [isGenerating, setIsGenerating] = useState(false);
|
| 94 |
+
const [contentWarnings, setContentWarnings] = useState<ContentValidationResponse | null>(null);
|
| 95 |
+
const [showWarnings, setShowWarnings] = useState(false);
|
| 96 |
|
| 97 |
// Generation mode selection
|
| 98 |
const [generationMode, setGenerationMode] = useState<GenerationMode>(draft?.generationMode || 'frame-continuity');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
+
// Segment mode: fixed duration (4/6/8s) or AI decides per segment
|
| 101 |
+
const [segmentMode, setSegmentMode] = useState<'fixed' | 'ai_driven'>(draft?.segmentMode ?? 'fixed');
|
| 102 |
+
const [secondsPerSegment, setSecondsPerSegment] = useState<4 | 6 | 8>(draft?.secondsPerSegment ?? 8);
|
|
|
|
| 103 |
|
| 104 |
// Show notification if draft was restored
|
| 105 |
useEffect(() => {
|
|
|
|
| 123 |
try {
|
| 124 |
const draft = {
|
| 125 |
formState,
|
|
|
|
| 126 |
generationMode,
|
| 127 |
+
segmentMode,
|
| 128 |
+
secondsPerSegment,
|
| 129 |
savedAt: new Date().toISOString(),
|
| 130 |
};
|
| 131 |
localStorage.setItem(draftKey, JSON.stringify(draft));
|
| 132 |
} catch (error) {
|
| 133 |
+
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
| 134 |
+
try {
|
| 135 |
+
localStorage.removeItem(draftKey);
|
| 136 |
+
const fallback = {
|
| 137 |
+
formState: { ...formState, script: formState.script.slice(0, 5000) },
|
| 138 |
+
generationMode,
|
| 139 |
+
segmentMode,
|
| 140 |
+
secondsPerSegment,
|
| 141 |
+
savedAt: new Date().toISOString(),
|
| 142 |
+
};
|
| 143 |
+
localStorage.setItem(draftKey, JSON.stringify(fallback));
|
| 144 |
+
} catch {
|
| 145 |
+
console.warn('Failed to save draft: quota exceeded');
|
| 146 |
+
}
|
| 147 |
+
} else {
|
| 148 |
+
console.warn('Failed to save draft:', error);
|
| 149 |
+
}
|
| 150 |
}
|
| 151 |
+
}, [formState, imagePreview, generationMode, segmentMode, secondsPerSegment, draftKey]);
|
| 152 |
|
| 153 |
// Clear draft function
|
| 154 |
const clearDraft = useCallback(() => {
|
|
|
|
| 161 |
}
|
| 162 |
}, [draftKey]);
|
| 163 |
|
| 164 |
+
// Accumulate streamed segments so we can show them live and pass to context
|
| 165 |
+
const streamedSegmentsRef = useRef<VeoSegment[]>([]);
|
| 166 |
+
|
| 167 |
// Calculate estimated segments
|
| 168 |
const wordCount = formState.script.trim().split(/\s+/).filter(w => w).length;
|
| 169 |
const estimatedSegments = wordCount > 0 ? Math.max(1, Math.min(Math.ceil(wordCount / 17), 10)) : 0;
|
| 170 |
|
| 171 |
+
// Validate content when script changes (debounced)
|
| 172 |
+
const validateScriptContent = useCallback(async (script: string) => {
|
| 173 |
+
if (!script.trim() || isReuseMode) {
|
| 174 |
+
setContentWarnings(null);
|
| 175 |
+
return;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
try {
|
| 179 |
+
const validation = await validateContent(script);
|
| 180 |
+
if (!validation.is_valid) {
|
| 181 |
+
setContentWarnings(validation);
|
| 182 |
+
setShowWarnings(true);
|
| 183 |
+
} else {
|
| 184 |
+
setContentWarnings(null);
|
| 185 |
+
setShowWarnings(false);
|
| 186 |
+
}
|
| 187 |
+
} catch (error) {
|
| 188 |
+
console.error('Content validation error:', error);
|
| 189 |
+
// Silently fail - don't block user
|
| 190 |
+
}
|
| 191 |
+
}, [isReuseMode]);
|
| 192 |
+
|
| 193 |
+
// Debounce content validation
|
| 194 |
+
useEffect(() => {
|
| 195 |
+
const timer = setTimeout(() => {
|
| 196 |
+
validateScriptContent(formState.script);
|
| 197 |
+
}, 1000); // Validate 1 second after user stops typing
|
| 198 |
+
|
| 199 |
+
return () => clearTimeout(timer);
|
| 200 |
+
}, [formState.script, validateScriptContent]);
|
| 201 |
+
|
| 202 |
// Handle input changes
|
| 203 |
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
| 204 |
const { name, value } = e.target;
|
|
|
|
| 289 |
// 3. Use that frame as reference for next segment
|
| 290 |
// 4. Repeat for all segments
|
| 291 |
|
| 292 |
+
const handleKlingFrameContinuityFlow = async (attemptCount: number = 0) => {
|
| 293 |
+
if (!imageFile) return;
|
| 294 |
+
if (!isReuseMode && !formState.script.trim()) return;
|
| 295 |
|
| 296 |
setIsGenerating(true);
|
| 297 |
setError(null);
|
| 298 |
+
|
| 299 |
+
let segmentsToUse: VeoSegment[] = [];
|
| 300 |
+
let currentImageFile = imageFile; // Declare at function scope for catch block access
|
| 301 |
+
|
| 302 |
try {
|
|
|
|
|
|
|
| 303 |
|
| 304 |
+
// Step 1: Get segments - either from cache (reuse mode), existing segments (retry mode), or generate new ones
|
| 305 |
+
if ((isReuseMode || attemptCount > 0) && segments.length > 0) {
|
| 306 |
+
// REUSE MODE or RETRY MODE: Skip prompt generation, use existing segments
|
| 307 |
+
const mode = isReuseMode ? 'REUSE' : 'RETRY';
|
| 308 |
+
console.log(`β»οΈ ${mode} MODE: Using ${segments.length} existing segments (attempt ${attemptCount})`);
|
| 309 |
+
segmentsToUse = segments;
|
| 310 |
+
setStep('generating_video');
|
| 311 |
+
updateProgress(`Starting video generation with ${segments.length} ${mode === 'RETRY' ? 'cached' : 'pre-loaded'} segments...`, 0, segments.length);
|
| 312 |
+
} else {
|
| 313 |
+
// NORMAL MODE: Generate prompts using GPT-5.2 with STREAMING
|
| 314 |
+
setStep('generating_prompts');
|
| 315 |
+
updateProgress('π Streaming prompt generation with GPT-5.2...', 0, estimatedSegments);
|
| 316 |
+
|
| 317 |
+
const formData = new FormData();
|
| 318 |
+
formData.append('script', formState.script);
|
| 319 |
+
formData.append('style', formState.style || 'clean, lifestyle UGC');
|
| 320 |
+
formData.append('jsonFormat', 'standard');
|
| 321 |
+
formData.append('continuationMode', 'true');
|
| 322 |
+
formData.append('voiceType', formState.voiceType || '');
|
| 323 |
+
formData.append('energyLevel', formState.energyLevel || '');
|
| 324 |
+
formData.append('settingMode', 'single');
|
| 325 |
+
formData.append('cameraStyle', formState.cameraStyle || '');
|
| 326 |
+
formData.append('narrativeStyle', formState.narrativeStyle || '');
|
| 327 |
+
formData.append('image', imageFile);
|
| 328 |
+
formData.append('segment_mode', segmentMode);
|
| 329 |
+
formData.append('seconds_per_segment', String(secondsPerSegment));
|
| 330 |
+
if (segmentMode === 'ai_driven') formData.append('allowed_durations', '4,6,8');
|
| 331 |
|
| 332 |
+
streamedSegmentsRef.current = [];
|
| 333 |
+
const promptAbort = new AbortController();
|
| 334 |
+
registerPromptAbortController(promptAbort);
|
| 335 |
+
try {
|
| 336 |
+
const payload = await generatePromptsStreaming(
|
| 337 |
+
formData,
|
| 338 |
+
(event: StreamEvent) => {
|
| 339 |
+
switch (event.event) {
|
| 340 |
+
case 'start':
|
| 341 |
+
streamedSegmentsRef.current = [];
|
| 342 |
+
updateSegments([]);
|
| 343 |
+
console.log(`π Starting streaming generation of ${event.total_segments} segments`);
|
| 344 |
+
updateProgress(`Streaming ${event.total_segments} segments from GPT-5.2...`, 0, event.total_segments);
|
| 345 |
+
break;
|
| 346 |
+
|
| 347 |
+
case 'segment': {
|
| 348 |
+
const seg = (event as StreamSegmentEvent).segment;
|
| 349 |
+
if (seg) {
|
| 350 |
+
streamedSegmentsRef.current = [...streamedSegmentsRef.current, seg];
|
| 351 |
+
updateSegments(streamedSegmentsRef.current);
|
| 352 |
+
}
|
| 353 |
+
console.log(`β
Segment ${event.index + 1} complete (${event.progress.toFixed(1)}%)`);
|
| 354 |
+
updateProgress(`Generated segment ${event.index + 1}... (${event.progress.toFixed(1)}%)`);
|
| 355 |
+
break;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
case 'complete':
|
| 359 |
+
console.log(`π All ${event.payload.segments.length} segments generated!`);
|
| 360 |
+
updateProgress('All segments generated! Starting video generation...');
|
| 361 |
+
break;
|
| 362 |
+
|
| 363 |
+
case 'error':
|
| 364 |
+
console.error(`β Streaming error: ${event.message}`);
|
| 365 |
+
throw new Error(event.message);
|
| 366 |
+
}
|
| 367 |
+
},
|
| 368 |
+
{ signal: promptAbort.signal }
|
| 369 |
+
);
|
| 370 |
+
|
| 371 |
+
if (!payload?.segments?.length) {
|
| 372 |
+
throw new Error('No segments generated from script');
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
segmentsToUse = payload.segments;
|
| 376 |
+
updateProgress(`Generated ${segmentsToUse.length} segments. Starting video generation...`);
|
| 377 |
+
startGeneration(segmentsToUse);
|
| 378 |
+
} finally {
|
| 379 |
+
registerPromptAbortController(null);
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
|
| 383 |
// Track current reference image (starts with original)
|
| 384 |
let currentImageFile = imageFile;
|
| 385 |
+
|
| 386 |
+
// RESUME SUPPORT: Start from where we left off if retrying
|
| 387 |
+
const startIndex = attemptCount > 0 ? generatedVideos.length : 0;
|
| 388 |
+
|
| 389 |
+
// If resuming, extract last frame from previous video for continuity
|
| 390 |
+
if (startIndex > 0 && generatedVideos[startIndex - 1]?.blobUrl) {
|
| 391 |
+
updateProgress(`Resuming from segment ${startIndex + 1}...`);
|
| 392 |
+
console.log(`π Resuming generation from segment ${startIndex + 1} (${generatedVideos.length} videos already generated)`);
|
| 393 |
+
try {
|
| 394 |
+
const lastVideoBlob = await fetch(generatedVideos[startIndex - 1].blobUrl!).then(r => r.blob());
|
| 395 |
+
const lastFrameFile = await extractLastFrame(lastVideoBlob);
|
| 396 |
+
currentImageFile = lastFrameFile;
|
| 397 |
+
console.log(`β
Using frame from segment ${startIndex} for continuity`);
|
| 398 |
+
} catch (frameError) {
|
| 399 |
+
console.warn('β οΈ Failed to extract frame for resume, using original image:', frameError);
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
|
| 403 |
// Step 2: Generate videos segment by segment with frame continuity
|
| 404 |
+
for (let i = startIndex; i < segmentsToUse.length; i++) {
|
| 405 |
+
const segment = segmentsToUse[i];
|
| 406 |
+
const isLastSegment = i === segmentsToUse.length - 1;
|
| 407 |
|
| 408 |
updateProgress(
|
| 409 |
+
`Generating video ${i + 1} of ${segmentsToUse.length}...${i > 0 ? ' (using last frame from previous)' : ''}`,
|
| 410 |
i,
|
| 411 |
+
segmentsToUse.length
|
| 412 |
);
|
| 413 |
|
| 414 |
// Upload current reference image
|
| 415 |
updateProgress(`Uploading reference image for segment ${i + 1}...`);
|
| 416 |
+
const uploadResult = await uploadImage(currentImageFile, { reference: true });
|
| 417 |
const hostedImageUrl = uploadResult.url;
|
| 418 |
|
| 419 |
console.log(`πΌοΈ Segment ${i + 1} using image: ${i === 0 ? 'original' : 'last frame from previous'}`);
|
|
|
|
| 462 |
});
|
| 463 |
|
| 464 |
if (whisperResult.success && whisperResult.frame_base64) {
|
| 465 |
+
// Convert base64 frame to File for next segment (backend may return PNG or JPEG)
|
| 466 |
+
const dataUrl = whisperResult.frame_base64;
|
| 467 |
+
const mime = dataUrl.startsWith('data:image/png') ? 'image/png' : 'image/jpeg';
|
| 468 |
+
const ext = mime === 'image/png' ? 'png' : 'jpg';
|
| 469 |
+
const base64Data = dataUrl.split(',')[1] || dataUrl;
|
| 470 |
const byteCharacters = atob(base64Data);
|
| 471 |
const byteNumbers = new Array(byteCharacters.length);
|
| 472 |
for (let j = 0; j < byteCharacters.length; j++) {
|
| 473 |
byteNumbers[j] = byteCharacters.charCodeAt(j);
|
| 474 |
}
|
| 475 |
const byteArray = new Uint8Array(byteNumbers);
|
| 476 |
+
const frameBlob = new Blob([byteArray], { type: mime });
|
| 477 |
+
currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.${ext}`, { type: mime });
|
| 478 |
|
| 479 |
// Store trim point for later merge
|
| 480 |
if (whisperResult.trim_point) {
|
|
|
|
| 490 |
console.log(`β
Whisper: Last word at ${whisperResult.last_word_timestamp?.toFixed(2)}s, frame at ${whisperResult.frame_timestamp?.toFixed(2)}s, trim at ${trimPoint.toFixed(2)}s`);
|
| 491 |
|
| 492 |
// REFINE NEXT SEGMENT PROMPT with frame + transcription
|
| 493 |
+
const nextSegment = segmentsToUse[i + 1];
|
| 494 |
if (nextSegment && currentImageFile) {
|
| 495 |
updateProgress(`Refining segment ${i + 2} prompt with visual and audio context...`);
|
| 496 |
try {
|
|
|
|
| 502 |
dialogue
|
| 503 |
);
|
| 504 |
// Update the next segment with refined prompt
|
| 505 |
+
segmentsToUse[i + 1] = refined.refined_prompt as typeof nextSegment;
|
| 506 |
console.log(`β
Refined segment ${i + 2} prompt for consistency`);
|
| 507 |
} catch (refineError) {
|
| 508 |
console.warn(`β οΈ Prompt refinement failed, using original:`, refineError);
|
|
|
|
| 535 |
thumbnails,
|
| 536 |
trimPoint, // Store trim point for merge
|
| 537 |
};
|
|
|
|
| 538 |
addVideo(generatedVideo);
|
| 539 |
|
| 540 |
+
updateProgress(`Completed video ${i + 1} of ${segmentsToUse.length}`, i + 1, segmentsToUse.length);
|
| 541 |
}
|
| 542 |
|
| 543 |
// All done!
|
|
|
|
| 549 |
} catch (err) {
|
| 550 |
console.error('Generation error:', err);
|
| 551 |
const errorMessage = err instanceof Error ? err.message : 'Generation failed';
|
| 552 |
+
const isUserCancel = (err as Error & { name?: string })?.name === 'AbortError' || errorMessage.includes('cancelled') || isCancelling;
|
| 553 |
+
|
| 554 |
+
// If user cancelled, context already set error with segment/video count; don't overwrite
|
| 555 |
+
if (isUserCancel) {
|
| 556 |
+
setStep('error');
|
| 557 |
+
setIsGenerating(false);
|
| 558 |
+
return;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
{
|
| 562 |
+
// Check if this is a safety error that can be auto-fixed
|
| 563 |
+
if (isUnsafeSegmentError(errorMessage) && attemptCount < AUTO_FIX_MAX_ATTEMPTS) {
|
| 564 |
+
const segmentIndex = generatedVideos.length;
|
| 565 |
+
console.log(`π‘οΈ Safety error detected for segment ${segmentIndex + 1}, attempting auto-fix...`);
|
| 566 |
+
updateProgress(`Detected safety issue in segment ${segmentIndex + 1}, auto-fixing...`);
|
| 567 |
+
|
| 568 |
+
try {
|
| 569 |
+
// Call AI to fix the unsafe segment
|
| 570 |
+
const fixResult = await fixUnsafePrompt({
|
| 571 |
+
segment: segmentsToUse[segmentIndex],
|
| 572 |
+
error_message: errorMessage,
|
| 573 |
+
attempt_count: attemptCount
|
| 574 |
+
});
|
| 575 |
+
|
| 576 |
+
if (fixResult.success && fixResult.fixed_segment) {
|
| 577 |
+
console.log(`β
Auto-fix successful: ${fixResult.changes_made}`);
|
| 578 |
+
updateProgress(`Fixed segment ${segmentIndex + 1}: ${fixResult.changes_made}`);
|
| 579 |
+
|
| 580 |
+
// Update the segment with the fixed version IN THE LOCAL ARRAY
|
| 581 |
+
segmentsToUse[segmentIndex] = fixResult.fixed_segment;
|
| 582 |
+
|
| 583 |
+
// Update context state (async, but we don't wait for it)
|
| 584 |
+
updateSegments(segmentsToUse);
|
| 585 |
+
|
| 586 |
+
// IMPORTANT: Continue generating from current position with fixed segment
|
| 587 |
+
// Don't restart the whole function - just continue from current index
|
| 588 |
+
console.log(`π Retrying segment ${segmentIndex + 1} with fixed prompt...`);
|
| 589 |
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
| 590 |
+
|
| 591 |
+
// Continue the loop from current segment (i stays the same, so it will retry)
|
| 592 |
+
// We do this by NOT incrementing i and continuing
|
| 593 |
+
// Actually, we're in the catch block, so we need to resume the generation
|
| 594 |
+
// The best approach is to just retry the current segment inline
|
| 595 |
+
|
| 596 |
+
// Reset to retry current segment
|
| 597 |
+
updateProgress(`Retrying segment ${segmentIndex + 1} with fixed content...`, segmentIndex, segmentsToUse.length);
|
| 598 |
+
|
| 599 |
+
// Re-run the segment generation with fixed prompt
|
| 600 |
+
const segment = segmentsToUse[segmentIndex];
|
| 601 |
+
const isLastSegment = segmentIndex === segmentsToUse.length - 1;
|
| 602 |
+
|
| 603 |
+
// Upload current reference image (should still be set from before)
|
| 604 |
+
updateProgress(`Uploading reference image for segment ${segmentIndex + 1}...`);
|
| 605 |
+
const uploadResult = await uploadImage(currentImageFile, { reference: true });
|
| 606 |
+
const hostedImageUrl = uploadResult.url;
|
| 607 |
+
|
| 608 |
+
// Generate video with fixed prompt
|
| 609 |
+
updateProgress(`Submitting FIXED segment ${segmentIndex + 1} to KIE Veo 3.1...`);
|
| 610 |
+
const generateResult = await klingGenerate({
|
| 611 |
+
prompt: segment,
|
| 612 |
+
imageUrls: [hostedImageUrl],
|
| 613 |
+
model: 'veo3_fast',
|
| 614 |
+
aspectRatio: formState.aspectRatio,
|
| 615 |
+
generationType: 'FIRST_AND_LAST_FRAMES_2_VIDEO',
|
| 616 |
+
seeds: formState.seedValue,
|
| 617 |
+
voiceType: formState.voiceType,
|
| 618 |
+
});
|
| 619 |
+
|
| 620 |
+
// Wait for completion
|
| 621 |
+
updateProgress(`Processing FIXED video ${segmentIndex + 1}... (this may take 1-2 minutes)`);
|
| 622 |
+
const videoUrl = await waitForKlingVideo(generateResult.taskId);
|
| 623 |
+
|
| 624 |
+
// Download video
|
| 625 |
+
updateProgress(`Downloading video ${segmentIndex + 1}...`);
|
| 626 |
+
const videoBlob = await downloadVideo(videoUrl);
|
| 627 |
+
const blobUrl = URL.createObjectURL(videoBlob);
|
| 628 |
+
|
| 629 |
+
// Get video duration
|
| 630 |
+
const videoFile = new File([videoBlob], `segment-${segmentIndex + 1}.mp4`, { type: 'video/mp4' });
|
| 631 |
+
const duration = await getVideoDuration(videoFile);
|
| 632 |
+
const thumbnails = await generateThumbnails(videoFile);
|
| 633 |
+
|
| 634 |
+
// Extract frame for next segment if not last
|
| 635 |
+
let trimPoint = duration;
|
| 636 |
+
if (!isLastSegment) {
|
| 637 |
+
updateProgress(`Analyzing video ${segmentIndex + 1} with Whisper...`);
|
| 638 |
+
try {
|
| 639 |
+
const dialogue = segment.action_timeline?.dialogue || '';
|
| 640 |
+
const whisperResult = await whisperAnalyzeAndExtract({
|
| 641 |
+
video_url: videoUrl,
|
| 642 |
+
dialogue: dialogue,
|
| 643 |
+
buffer_time: 0.3,
|
| 644 |
+
model_size: 'base'
|
| 645 |
+
});
|
| 646 |
+
|
| 647 |
+
if (whisperResult.success && whisperResult.frame_base64) {
|
| 648 |
+
const dataUrl = whisperResult.frame_base64;
|
| 649 |
+
const mime = dataUrl.startsWith('data:image/png') ? 'image/png' : 'image/jpeg';
|
| 650 |
+
const ext = mime === 'image/png' ? 'png' : 'jpg';
|
| 651 |
+
const base64Data = dataUrl.split(',')[1] || dataUrl;
|
| 652 |
+
const byteCharacters = atob(base64Data);
|
| 653 |
+
const byteNumbers = new Array(byteCharacters.length);
|
| 654 |
+
for (let j = 0; j < byteCharacters.length; j++) {
|
| 655 |
+
byteNumbers[j] = byteCharacters.charCodeAt(j);
|
| 656 |
+
}
|
| 657 |
+
const byteArray = new Uint8Array(byteNumbers);
|
| 658 |
+
const frameBlob = new Blob([byteArray], { type: mime });
|
| 659 |
+
currentImageFile = new File([frameBlob], `whisper-frame-${segmentIndex + 1}.${ext}`, { type: mime });
|
| 660 |
+
|
| 661 |
+
if (whisperResult.trim_point) {
|
| 662 |
+
trimPoint = whisperResult.trim_point;
|
| 663 |
+
}
|
| 664 |
+
const transcribedText = whisperResult.transcribed_text || '';
|
| 665 |
+
const nextSegment = segmentsToUse[segmentIndex + 1];
|
| 666 |
+
if (nextSegment && currentImageFile && transcribedText) {
|
| 667 |
+
updateProgress(`Refining segment ${segmentIndex + 2} prompt with visual and audio context...`);
|
| 668 |
+
try {
|
| 669 |
+
const { refinePromptWithContext } = await import('@/utils/api');
|
| 670 |
+
const refined = await refinePromptWithContext(
|
| 671 |
+
nextSegment,
|
| 672 |
+
currentImageFile,
|
| 673 |
+
transcribedText,
|
| 674 |
+
dialogue
|
| 675 |
+
);
|
| 676 |
+
segmentsToUse[segmentIndex + 1] = refined.refined_prompt as typeof nextSegment;
|
| 677 |
+
console.log(`β
Refined segment ${segmentIndex + 2} prompt for consistency`);
|
| 678 |
+
} catch (refineError) {
|
| 679 |
+
console.warn(`β οΈ Prompt refinement failed, using original:`, refineError);
|
| 680 |
+
}
|
| 681 |
+
}
|
| 682 |
+
}
|
| 683 |
+
} catch {
|
| 684 |
+
const lastFrameFile = await extractLastFrame(videoBlob);
|
| 685 |
+
currentImageFile = lastFrameFile;
|
| 686 |
+
}
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
// Add to generated videos
|
| 690 |
+
const generatedVideo: GeneratedVideo = {
|
| 691 |
+
id: `video-${Date.now()}-${segmentIndex}`,
|
| 692 |
+
url: videoUrl,
|
| 693 |
+
blobUrl,
|
| 694 |
+
segment,
|
| 695 |
+
duration,
|
| 696 |
+
thumbnails,
|
| 697 |
+
trimPoint,
|
| 698 |
+
};
|
| 699 |
+
addVideo(generatedVideo);
|
| 700 |
+
|
| 701 |
+
updateProgress(`Completed FIXED video ${segmentIndex + 1} of ${segmentsToUse.length}`, segmentIndex + 1, segmentsToUse.length);
|
| 702 |
+
|
| 703 |
+
// Continue with remaining segments (DON'T restart the whole function!)
|
| 704 |
+
for (let i = segmentIndex + 1; i < segmentsToUse.length; i++) {
|
| 705 |
+
const segment = segmentsToUse[i];
|
| 706 |
+
const isLastSegment = i === segmentsToUse.length - 1;
|
| 707 |
+
|
| 708 |
+
updateProgress(
|
| 709 |
+
`Generating video ${i + 1} of ${segmentsToUse.length}...${i > 0 ? ' (using last frame from previous)' : ''}`,
|
| 710 |
+
i,
|
| 711 |
+
segmentsToUse.length
|
| 712 |
+
);
|
| 713 |
+
|
| 714 |
+
// Upload current reference image
|
| 715 |
+
updateProgress(`Uploading reference image for segment ${i + 1}...`);
|
| 716 |
+
const uploadResult = await uploadImage(currentImageFile, { reference: true });
|
| 717 |
+
const hostedImageUrl = uploadResult.url;
|
| 718 |
+
|
| 719 |
+
console.log(`πΌοΈ Segment ${i + 1} using image: ${i === 0 ? 'original' : 'last frame from previous'}`);
|
| 720 |
+
|
| 721 |
+
// Generate video with current reference image
|
| 722 |
+
updateProgress(`Submitting segment ${i + 1} to KIE Veo 3.1...`);
|
| 723 |
+
const generateResult = await klingGenerate({
|
| 724 |
+
prompt: segment,
|
| 725 |
+
imageUrls: [hostedImageUrl],
|
| 726 |
+
model: 'veo3_fast',
|
| 727 |
+
aspectRatio: formState.aspectRatio,
|
| 728 |
+
generationType: 'FIRST_AND_LAST_FRAMES_2_VIDEO',
|
| 729 |
+
seeds: formState.seedValue,
|
| 730 |
+
voiceType: formState.voiceType,
|
| 731 |
+
});
|
| 732 |
+
|
| 733 |
+
// Wait for completion
|
| 734 |
+
updateProgress(`Processing video ${i + 1}... (this may take 1-2 minutes)`);
|
| 735 |
+
const videoUrl = await waitForKlingVideo(generateResult.taskId);
|
| 736 |
+
|
| 737 |
+
// Download video
|
| 738 |
+
updateProgress(`Downloading video ${i + 1}...`);
|
| 739 |
+
const videoBlob = await downloadVideo(videoUrl);
|
| 740 |
+
const blobUrl = URL.createObjectURL(videoBlob);
|
| 741 |
+
|
| 742 |
+
// Get video duration
|
| 743 |
+
const videoFile = new File([videoBlob], `segment-${i + 1}.mp4`, { type: 'video/mp4' });
|
| 744 |
+
const duration = await getVideoDuration(videoFile);
|
| 745 |
+
const thumbnails = await generateThumbnails(videoFile);
|
| 746 |
+
|
| 747 |
+
// Use Whisper to find optimal trim point, extract frame, and get transcription
|
| 748 |
+
let trimPoint = duration;
|
| 749 |
+
let transcribedText = '';
|
| 750 |
+
|
| 751 |
+
if (!isLastSegment) {
|
| 752 |
+
updateProgress(`Analyzing video ${i + 1} with Whisper for optimal continuity...`);
|
| 753 |
+
try {
|
| 754 |
+
const dialogue = segment.action_timeline?.dialogue || '';
|
| 755 |
+
|
| 756 |
+
const whisperResult = await whisperAnalyzeAndExtract({
|
| 757 |
+
video_url: videoUrl,
|
| 758 |
+
dialogue: dialogue,
|
| 759 |
+
buffer_time: 0.3,
|
| 760 |
+
model_size: 'base'
|
| 761 |
+
});
|
| 762 |
+
|
| 763 |
+
if (whisperResult.success && whisperResult.frame_base64) {
|
| 764 |
+
const dataUrl = whisperResult.frame_base64;
|
| 765 |
+
const mime = dataUrl.startsWith('data:image/png') ? 'image/png' : 'image/jpeg';
|
| 766 |
+
const ext = mime === 'image/png' ? 'png' : 'jpg';
|
| 767 |
+
const base64Data = dataUrl.split(',')[1] || dataUrl;
|
| 768 |
+
const byteCharacters = atob(base64Data);
|
| 769 |
+
const byteNumbers = new Array(byteCharacters.length);
|
| 770 |
+
for (let j = 0; j < byteCharacters.length; j++) {
|
| 771 |
+
byteNumbers[j] = byteCharacters.charCodeAt(j);
|
| 772 |
+
}
|
| 773 |
+
const byteArray = new Uint8Array(byteNumbers);
|
| 774 |
+
const frameBlob = new Blob([byteArray], { type: mime });
|
| 775 |
+
currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.${ext}`, { type: mime });
|
| 776 |
+
|
| 777 |
+
if (whisperResult.trim_point) {
|
| 778 |
+
trimPoint = whisperResult.trim_point;
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
if (whisperResult.transcribed_text) {
|
| 782 |
+
transcribedText = whisperResult.transcribed_text;
|
| 783 |
+
console.log(`π Whisper transcription: "${transcribedText.substring(0, 100)}..."`);
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
console.log(`β
Whisper: Last word at ${whisperResult.last_word_timestamp?.toFixed(2)}s, frame at ${whisperResult.frame_timestamp?.toFixed(2)}s, trim at ${trimPoint.toFixed(2)}s`);
|
| 787 |
+
|
| 788 |
+
// REFINE NEXT SEGMENT PROMPT with frame + transcription
|
| 789 |
+
const nextSegment = segmentsToUse[i + 1];
|
| 790 |
+
if (nextSegment && currentImageFile) {
|
| 791 |
+
updateProgress(`Refining segment ${i + 2} prompt with visual and audio context...`);
|
| 792 |
+
try {
|
| 793 |
+
const { refinePromptWithContext } = await import('@/utils/api');
|
| 794 |
+
const refined = await refinePromptWithContext(
|
| 795 |
+
nextSegment,
|
| 796 |
+
currentImageFile,
|
| 797 |
+
transcribedText,
|
| 798 |
+
dialogue
|
| 799 |
+
);
|
| 800 |
+
segmentsToUse[i + 1] = refined.refined_prompt as typeof nextSegment;
|
| 801 |
+
console.log(`β
Refined segment ${i + 2} prompt for consistency`);
|
| 802 |
+
} catch (refineError) {
|
| 803 |
+
console.warn(`β οΈ Prompt refinement failed, using original:`, refineError);
|
| 804 |
+
}
|
| 805 |
+
}
|
| 806 |
+
} else {
|
| 807 |
+
console.log(`β οΈ Whisper failed (${whisperResult.error}), falling back to last frame extraction`);
|
| 808 |
+
const lastFrameFile = await extractLastFrame(videoBlob);
|
| 809 |
+
currentImageFile = lastFrameFile;
|
| 810 |
+
}
|
| 811 |
+
} catch (frameError) {
|
| 812 |
+
console.error(`β οΈ Whisper analysis failed, using fallback:`, frameError);
|
| 813 |
+
try {
|
| 814 |
+
const lastFrameFile = await extractLastFrame(videoBlob);
|
| 815 |
+
currentImageFile = lastFrameFile;
|
| 816 |
+
} catch {
|
| 817 |
+
// Continue with current image if all extraction fails
|
| 818 |
+
}
|
| 819 |
+
}
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
// Add to generated videos with trim metadata
|
| 823 |
+
const generatedVideo: GeneratedVideo = {
|
| 824 |
+
id: `video-${Date.now()}-${i}`,
|
| 825 |
+
url: videoUrl,
|
| 826 |
+
blobUrl,
|
| 827 |
+
segment,
|
| 828 |
+
duration,
|
| 829 |
+
thumbnails,
|
| 830 |
+
trimPoint,
|
| 831 |
+
};
|
| 832 |
+
addVideo(generatedVideo);
|
| 833 |
+
|
| 834 |
+
updateProgress(`Completed video ${i + 1} of ${segmentsToUse.length}`, i + 1, segmentsToUse.length);
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
// All done after fixing and continuing!
|
| 838 |
+
clearDraft();
|
| 839 |
+
setStep('completed');
|
| 840 |
+
updateProgress('All videos generated successfully!');
|
| 841 |
+
return; // Exit successfully - don't continue to normal retry logic
|
| 842 |
+
} else {
|
| 843 |
+
console.warn(`β οΈ Auto-fix failed: ${fixResult.error}, falling back to manual retry`);
|
| 844 |
+
}
|
| 845 |
+
} catch (fixError) {
|
| 846 |
+
console.error('β Auto-fix error:', fixError);
|
| 847 |
+
}
|
| 848 |
+
}
|
| 849 |
|
| 850 |
+
const outcome: FlowRetryOutcome = await handleFlowRetry({
|
| 851 |
+
attemptCount,
|
| 852 |
+
errorMessage,
|
| 853 |
+
isCancelled: false,
|
| 854 |
+
generatedCount: generatedVideos.length,
|
| 855 |
+
totalCount: segmentsToUse.length,
|
| 856 |
+
setError,
|
| 857 |
+
setStep,
|
| 858 |
+
setPartialCompletionError,
|
| 859 |
});
|
| 860 |
+
if (outcome === 'retry') {
|
| 861 |
+
console.log('π First attempt failed, auto-retrying...');
|
| 862 |
+
updateProgress('Generation failed, automatically retrying...');
|
| 863 |
+
return handleKlingFrameContinuityFlow(1);
|
| 864 |
+
}
|
| 865 |
+
}
|
| 866 |
|
| 867 |
} finally {
|
| 868 |
setIsGenerating(false);
|
|
|
|
| 874 |
// ============================================
|
| 875 |
// Original flow using KIE's extend API
|
| 876 |
|
| 877 |
+
const handleKlingExtendFlow = async (attemptCount: number = 0) => {
|
| 878 |
if (!imageFile || !formState.script.trim()) return;
|
| 879 |
|
| 880 |
setIsGenerating(true);
|
| 881 |
setError(null);
|
| 882 |
+
|
| 883 |
+
let payload: { segments: VeoSegment[] } = { segments: [] }; // Declare at function scope
|
| 884 |
|
| 885 |
try {
|
| 886 |
+
// Step 1: Get segments - skip prompt generation if segments already exist (retry mode)
|
| 887 |
+
if (attemptCount > 0 && segments.length > 0) {
|
| 888 |
+
// RETRY MODE: Use existing segments (they may have been edited)
|
| 889 |
+
console.log(`β»οΈ RETRY MODE: Using ${segments.length} existing segments (attempt ${attemptCount})`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
payload = { segments };
|
| 891 |
+
setStep('generating_video');
|
| 892 |
+
updateProgress(`Retrying video generation with ${segments.length} cached segments...`, 0, segments.length);
|
| 893 |
} else {
|
| 894 |
+
// NORMAL MODE: Generate prompts using streaming (GPT-5.2)
|
| 895 |
+
setStep('generating_prompts');
|
| 896 |
+
updateProgress('Streaming prompt generation with GPT-5.2...', 0, estimatedSegments);
|
| 897 |
+
|
| 898 |
+
const formData = new FormData();
|
| 899 |
+
formData.append('script', formState.script);
|
| 900 |
+
formData.append('style', formState.style || 'clean, lifestyle UGC');
|
| 901 |
+
formData.append('jsonFormat', 'standard');
|
| 902 |
+
formData.append('continuationMode', 'true');
|
| 903 |
+
formData.append('voiceType', formState.voiceType || '');
|
| 904 |
+
formData.append('energyLevel', formState.energyLevel || '');
|
| 905 |
+
formData.append('settingMode', 'single');
|
| 906 |
+
formData.append('cameraStyle', formState.cameraStyle || '');
|
| 907 |
+
formData.append('narrativeStyle', formState.narrativeStyle || '');
|
| 908 |
+
formData.append('image', imageFile);
|
| 909 |
+
formData.append('segment_mode', segmentMode);
|
| 910 |
+
formData.append('seconds_per_segment', String(secondsPerSegment));
|
| 911 |
+
if (segmentMode === 'ai_driven') formData.append('allowed_durations', '4,6,8');
|
| 912 |
+
|
| 913 |
+
streamedSegmentsRef.current = [];
|
| 914 |
+
const promptAbortExtend = new AbortController();
|
| 915 |
+
registerPromptAbortController(promptAbortExtend);
|
| 916 |
+
try {
|
| 917 |
+
payload = await generatePromptsStreaming(
|
| 918 |
+
formData,
|
| 919 |
+
(event: StreamEvent) => {
|
| 920 |
+
switch (event.event) {
|
| 921 |
+
case 'start':
|
| 922 |
+
streamedSegmentsRef.current = [];
|
| 923 |
+
updateSegments([]);
|
| 924 |
+
updateProgress(`Streaming ${event.total_segments} segments from GPT-5.2...`, 0, event.total_segments);
|
| 925 |
+
break;
|
| 926 |
+
case 'segment': {
|
| 927 |
+
const seg = (event as StreamSegmentEvent).segment;
|
| 928 |
+
if (seg) {
|
| 929 |
+
streamedSegmentsRef.current = [...streamedSegmentsRef.current, seg];
|
| 930 |
+
updateSegments(streamedSegmentsRef.current);
|
| 931 |
+
}
|
| 932 |
+
updateProgress(`Generated segment ${event.index + 1}... (${event.progress.toFixed(1)}%)`);
|
| 933 |
+
break;
|
| 934 |
+
}
|
| 935 |
+
case 'complete':
|
| 936 |
+
updateProgress('All segments generated! Starting video generation...');
|
| 937 |
+
break;
|
| 938 |
+
case 'error':
|
| 939 |
+
throw new Error(event.message);
|
| 940 |
+
}
|
| 941 |
+
},
|
| 942 |
+
{ signal: promptAbortExtend.signal }
|
| 943 |
+
);
|
| 944 |
+
if (!payload?.segments?.length) {
|
| 945 |
+
throw new Error('No segments generated from script');
|
| 946 |
+
}
|
| 947 |
+
updateProgress(`Generated ${payload.segments.length} segments. Starting video generation...`);
|
| 948 |
+
startGeneration(payload.segments);
|
| 949 |
+
} finally {
|
| 950 |
+
registerPromptAbortController(null);
|
| 951 |
}
|
|
|
|
|
|
|
| 952 |
}
|
| 953 |
|
| 954 |
// Step 2: Upload reference image once
|
|
|
|
| 967 |
try {
|
| 968 |
const lastVideoBlob = await fetch(generatedVideos[startIndex - 1].blobUrl!).then(r => r.blob());
|
| 969 |
const lastFrameFile = await extractLastFrame(lastVideoBlob);
|
| 970 |
+
const frameUploadResult = await uploadImage(lastFrameFile, { reference: true });
|
| 971 |
currentImageUrl = frameUploadResult.url;
|
| 972 |
updateProgress(`Using frame from segment ${startIndex} for segment ${startIndex + 1}...`);
|
| 973 |
} catch (frameError) {
|
|
|
|
| 1016 |
return extendResult;
|
| 1017 |
}
|
| 1018 |
}, 300000, (attempt) => {
|
| 1019 |
+
updateProgress(`Retrying video ${i + 1}... (attempt ${attempt}/${MAX_VIDEO_ATTEMPTS})`);
|
| 1020 |
});
|
| 1021 |
|
| 1022 |
// Download and save
|
|
|
|
| 1052 |
} catch (err) {
|
| 1053 |
console.error('Generation error:', err);
|
| 1054 |
const errorMessage = err instanceof Error ? err.message : 'Generation failed';
|
| 1055 |
+
const outcome = await handleFlowRetry({
|
| 1056 |
+
attemptCount,
|
| 1057 |
+
errorMessage,
|
| 1058 |
+
isCancelled: errorMessage.includes('cancelled') || isCancelling,
|
| 1059 |
+
generatedCount: generatedVideos.length,
|
| 1060 |
+
totalCount: payload.segments.length,
|
| 1061 |
+
setError,
|
| 1062 |
+
setStep,
|
| 1063 |
+
setPartialCompletionError,
|
| 1064 |
+
});
|
| 1065 |
+
if (outcome === 'retry') {
|
| 1066 |
+
console.log('π First attempt failed, auto-retrying...');
|
| 1067 |
+
updateProgress('Generation failed, automatically retrying...');
|
| 1068 |
+
return handleKlingExtendFlow(1);
|
| 1069 |
}
|
|
|
|
| 1070 |
} finally {
|
| 1071 |
setIsGenerating(false);
|
| 1072 |
// Clean up any remaining task IDs
|
|
|
|
| 1078 |
// REPLICATE GENERATION - FRAME CONTINUITY FLOW
|
| 1079 |
// ============================================
|
| 1080 |
// This mirrors the approach from standalone_video_creator.py:
|
| 1081 |
+
// 1. Generate prompts using streaming (GPT-5.2)
|
| 1082 |
// 2. For each segment, generate video with current reference image
|
| 1083 |
// 3. Extract last frame from generated video
|
| 1084 |
// 4. Use that frame as reference for next segment
|
| 1085 |
// 5. Result: Perfect visual continuity across all segments
|
| 1086 |
|
| 1087 |
+
const handleReplicateGeneration = async (attemptCount: number = 0) => {
|
| 1088 |
if (!formState.script.trim()) return;
|
| 1089 |
|
| 1090 |
setIsGenerating(true);
|
| 1091 |
setError(null);
|
| 1092 |
+
|
| 1093 |
+
let segmentsToUse: VeoSegment[] = []; // Declare at function scope
|
| 1094 |
|
| 1095 |
try {
|
| 1096 |
+
// Step 1: Get segments - skip prompt generation if segments already exist (retry mode)
|
|
|
|
|
|
|
| 1097 |
|
| 1098 |
+
if (attemptCount > 0 && segments.length > 0) {
|
| 1099 |
+
// RETRY MODE: Use existing segments
|
| 1100 |
+
console.log(`β»οΈ RETRY MODE: Using ${segments.length} existing segments (attempt ${attemptCount})`);
|
| 1101 |
+
segmentsToUse = segments;
|
| 1102 |
+
setStep('generating_video');
|
| 1103 |
+
updateProgress(`Retrying video generation with ${segments.length} cached segments...`, 0, segments.length);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1104 |
} else {
|
| 1105 |
+
// NORMAL MODE: Generate prompts using streaming (GPT-5.2)
|
| 1106 |
+
setStep('generating_prompts');
|
| 1107 |
+
updateProgress('Streaming prompt generation with GPT-5.2...', 0, estimatedSegments);
|
| 1108 |
+
|
| 1109 |
+
const formData = new FormData();
|
| 1110 |
+
formData.append('script', formState.script);
|
| 1111 |
+
formData.append('style', formState.style || 'clean, lifestyle UGC');
|
| 1112 |
+
formData.append('jsonFormat', 'standard');
|
| 1113 |
+
formData.append('continuationMode', 'true');
|
| 1114 |
+
formData.append('voiceType', formState.voiceType || '');
|
| 1115 |
+
formData.append('energyLevel', formState.energyLevel || '');
|
| 1116 |
+
formData.append('settingMode', 'single');
|
| 1117 |
+
formData.append('cameraStyle', formState.cameraStyle || '');
|
| 1118 |
+
formData.append('narrativeStyle', formState.narrativeStyle || '');
|
| 1119 |
+
|
| 1120 |
+
// If image provided, include it for GPT analysis
|
| 1121 |
+
if (imageFile) {
|
| 1122 |
+
formData.append('image', imageFile);
|
| 1123 |
+
} else {
|
| 1124 |
+
const placeholderBlob = new Blob(['placeholder'], { type: 'image/jpeg' });
|
| 1125 |
+
formData.append('image', placeholderBlob, 'placeholder.jpg');
|
| 1126 |
+
}
|
| 1127 |
+
formData.append('segment_mode', segmentMode);
|
| 1128 |
+
formData.append('seconds_per_segment', String(secondsPerSegment));
|
| 1129 |
+
if (segmentMode === 'ai_driven') formData.append('allowed_durations', '4,6,8');
|
| 1130 |
+
|
| 1131 |
+
streamedSegmentsRef.current = [];
|
| 1132 |
+
const promptAbortReplicate = new AbortController();
|
| 1133 |
+
registerPromptAbortController(promptAbortReplicate);
|
| 1134 |
+
try {
|
| 1135 |
+
const payload = await generatePromptsStreaming(
|
| 1136 |
+
formData,
|
| 1137 |
+
(event: StreamEvent) => {
|
| 1138 |
+
switch (event.event) {
|
| 1139 |
+
case 'start':
|
| 1140 |
+
streamedSegmentsRef.current = [];
|
| 1141 |
+
updateSegments([]);
|
| 1142 |
+
updateProgress(`Streaming ${event.total_segments} segments...`, 0, event.total_segments);
|
| 1143 |
+
break;
|
| 1144 |
+
case 'segment': {
|
| 1145 |
+
const seg = (event as StreamSegmentEvent).segment;
|
| 1146 |
+
if (seg) {
|
| 1147 |
+
streamedSegmentsRef.current = [...streamedSegmentsRef.current, seg];
|
| 1148 |
+
updateSegments(streamedSegmentsRef.current);
|
| 1149 |
+
}
|
| 1150 |
+
updateProgress(`Generated segment ${event.index + 1}... (${event.progress.toFixed(1)}%)`);
|
| 1151 |
+
break;
|
| 1152 |
+
}
|
| 1153 |
+
case 'complete':
|
| 1154 |
+
updateProgress('All segments generated! Starting Replicate generation...');
|
| 1155 |
+
break;
|
| 1156 |
+
case 'error':
|
| 1157 |
+
throw new Error(event.message);
|
| 1158 |
+
}
|
| 1159 |
+
},
|
| 1160 |
+
{ signal: promptAbortReplicate.signal }
|
| 1161 |
+
);
|
| 1162 |
|
| 1163 |
+
if (!payload?.segments?.length) {
|
| 1164 |
+
throw new Error('No segments generated from script');
|
| 1165 |
+
}
|
| 1166 |
|
| 1167 |
+
segmentsToUse = payload.segments;
|
| 1168 |
+
updateProgress(`Generated ${segmentsToUse.length} segments. Starting Replicate generation...`);
|
| 1169 |
+
startGeneration(segmentsToUse);
|
| 1170 |
+
} finally {
|
| 1171 |
+
registerPromptAbortController(null);
|
| 1172 |
+
}
|
| 1173 |
}
|
| 1174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1175 |
// Track current reference image (starts with original if provided)
|
| 1176 |
let currentImageFile = imageFile;
|
|
|
|
| 1177 |
|
| 1178 |
// Step 2: Generate videos segment by segment with frame continuity
|
| 1179 |
+
for (let i = 0; i < segmentsToUse.length; i++) {
|
| 1180 |
+
const segment = segmentsToUse[i];
|
| 1181 |
+
const isLastSegment = i === segmentsToUse.length - 1;
|
| 1182 |
|
| 1183 |
updateProgress(
|
| 1184 |
+
`Generating video ${i + 1} of ${segmentsToUse.length} with Replicate...${i > 0 ? ' (using last frame)' : ''}`,
|
| 1185 |
i,
|
| 1186 |
+
segmentsToUse.length
|
| 1187 |
);
|
| 1188 |
|
| 1189 |
// Convert structured segment to text prompt for Replicate
|
|
|
|
| 1196 |
let imageUrl: string | undefined;
|
| 1197 |
if (currentImageFile) {
|
| 1198 |
updateProgress(`Uploading reference image for segment ${i + 1}...`);
|
| 1199 |
+
const uploadResult = await uploadImage(currentImageFile, { reference: true });
|
| 1200 |
imageUrl = uploadResult.url;
|
| 1201 |
console.log(`πΌοΈ Segment ${i + 1} using image: ${i === 0 ? 'original' : 'last frame from previous'}`);
|
| 1202 |
}
|
|
|
|
| 1243 |
});
|
| 1244 |
|
| 1245 |
if (whisperResult.success && whisperResult.frame_base64) {
|
| 1246 |
+
// Convert base64 frame to File for next segment (backend may return PNG or JPEG)
|
| 1247 |
+
const dataUrl = whisperResult.frame_base64;
|
| 1248 |
+
const mime = dataUrl.startsWith('data:image/png') ? 'image/png' : 'image/jpeg';
|
| 1249 |
+
const ext = mime === 'image/png' ? 'png' : 'jpg';
|
| 1250 |
+
const base64Data = dataUrl.split(',')[1] || dataUrl;
|
| 1251 |
const byteCharacters = atob(base64Data);
|
| 1252 |
const byteNumbers = new Array(byteCharacters.length);
|
| 1253 |
for (let j = 0; j < byteCharacters.length; j++) {
|
| 1254 |
byteNumbers[j] = byteCharacters.charCodeAt(j);
|
| 1255 |
}
|
| 1256 |
const byteArray = new Uint8Array(byteNumbers);
|
| 1257 |
+
const frameBlob = new Blob([byteArray], { type: mime });
|
| 1258 |
+
currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.${ext}`, { type: mime });
|
| 1259 |
|
| 1260 |
// Store trim point for later merge
|
| 1261 |
if (whisperResult.trim_point) {
|
|
|
|
| 1271 |
console.log(`β
Whisper: Last word at ${whisperResult.last_word_timestamp?.toFixed(2)}s, frame at ${whisperResult.frame_timestamp?.toFixed(2)}s, trim at ${trimPoint.toFixed(2)}s`);
|
| 1272 |
|
| 1273 |
// REFINE NEXT SEGMENT PROMPT with frame + transcription
|
| 1274 |
+
const nextSegment = segmentsToUse[i + 1];
|
| 1275 |
if (nextSegment && currentImageFile) {
|
| 1276 |
updateProgress(`Refining segment ${i + 2} prompt with visual and audio context...`);
|
| 1277 |
try {
|
|
|
|
| 1283 |
dialogue
|
| 1284 |
);
|
| 1285 |
// Update the next segment with refined prompt
|
| 1286 |
+
segmentsToUse[i + 1] = refined.refined_prompt as typeof nextSegment;
|
| 1287 |
console.log(`β
Refined segment ${i + 2} prompt for consistency`);
|
| 1288 |
} catch (refineError) {
|
| 1289 |
console.warn(`β οΈ Prompt refinement failed, using original:`, refineError);
|
|
|
|
| 1316 |
thumbnails,
|
| 1317 |
trimPoint, // Store trim point for merge
|
| 1318 |
};
|
|
|
|
| 1319 |
addVideo(generatedVideo);
|
| 1320 |
|
| 1321 |
+
updateProgress(`Completed video ${i + 1} of ${segmentsToUse.length}`, i + 1, segmentsToUse.length);
|
| 1322 |
}
|
| 1323 |
|
| 1324 |
// All done!
|
|
|
|
| 1328 |
} catch (err) {
|
| 1329 |
console.error('Replicate generation error:', err);
|
| 1330 |
const errorMessage = err instanceof Error ? err.message : 'Replicate generation failed';
|
| 1331 |
+
const outcome = await handleFlowRetry({
|
| 1332 |
+
attemptCount,
|
| 1333 |
+
errorMessage,
|
| 1334 |
+
isCancelled: errorMessage.includes('cancelled') || isCancelling,
|
| 1335 |
+
generatedCount: state.generatedVideos.length,
|
| 1336 |
+
totalCount: segmentsToUse.length,
|
| 1337 |
+
setError,
|
| 1338 |
+
setStep,
|
| 1339 |
+
setPartialCompletionError,
|
| 1340 |
});
|
| 1341 |
+
if (outcome === 'retry') {
|
| 1342 |
+
console.log('π First attempt failed, auto-retrying...');
|
| 1343 |
+
updateProgress('Generation failed, automatically retrying...');
|
| 1344 |
+
return handleReplicateGeneration(1);
|
| 1345 |
+
}
|
| 1346 |
} finally {
|
| 1347 |
setIsGenerating(false);
|
| 1348 |
}
|
|
|
|
| 1413 |
}
|
| 1414 |
};
|
| 1415 |
|
| 1416 |
+
// Handler for reuse mode - skip prompt generation, go directly to video generation
|
| 1417 |
+
const handleReuseGeneration = (e: React.FormEvent) => {
|
| 1418 |
+
e.preventDefault();
|
| 1419 |
+
|
| 1420 |
+
if (!imageFile) {
|
| 1421 |
+
alert('Please upload a character image to start video generation');
|
| 1422 |
+
return;
|
| 1423 |
+
}
|
| 1424 |
+
|
| 1425 |
+
console.log(`π Starting video generation with ${segments.length} pre-loaded segments (REUSE MODE)`);
|
| 1426 |
+
|
| 1427 |
+
// Skip prompt generation entirely - go directly to video generation
|
| 1428 |
+
if (provider === 'kling') {
|
| 1429 |
+
if (generationMode === 'frame-continuity') {
|
| 1430 |
+
// Start from the video generation part of the flow
|
| 1431 |
+
setIsGenerating(true);
|
| 1432 |
+
setError(null);
|
| 1433 |
+
startGeneration(segments);
|
| 1434 |
+
handleKlingFrameContinuityFlow(0);
|
| 1435 |
+
} else {
|
| 1436 |
+
setIsGenerating(true);
|
| 1437 |
+
setError(null);
|
| 1438 |
+
startGeneration(segments);
|
| 1439 |
+
handleKlingExtendFlow(0);
|
| 1440 |
+
}
|
| 1441 |
+
} else {
|
| 1442 |
+
setIsGenerating(true);
|
| 1443 |
+
setError(null);
|
| 1444 |
+
startGeneration(segments);
|
| 1445 |
+
handleReplicateGeneration(0);
|
| 1446 |
+
}
|
| 1447 |
+
};
|
| 1448 |
+
|
| 1449 |
const isValid = provider === 'kling'
|
| 1450 |
+
? !!imageFile && (isReuseMode || formState.script.trim().length > 0)
|
| 1451 |
+
: (isReuseMode || formState.script.trim().length > 0);
|
| 1452 |
|
| 1453 |
return (
|
| 1454 |
<motion.div
|
|
|
|
| 1474 |
<span className="text-void-200"> Video Generation</span>
|
| 1475 |
</h1>
|
| 1476 |
<p className="text-void-400 mt-2">
|
| 1477 |
+
{isReuseMode ? (
|
| 1478 |
+
<span className="flex items-center gap-2">
|
| 1479 |
+
<span>β»οΈ Reusing {segments.length} cached segments - Upload image and click Generate to start</span>
|
| 1480 |
+
</span>
|
| 1481 |
+
) : (
|
| 1482 |
+
provider === 'kling'
|
| 1483 |
+
? 'Generate professional UGC videos with AI-powered segmentation'
|
| 1484 |
+
: 'Create unique videos with open-source models'
|
| 1485 |
+
)}
|
| 1486 |
</p>
|
| 1487 |
</div>
|
| 1488 |
</div>
|
| 1489 |
|
| 1490 |
+
{/* Content Warnings */}
|
| 1491 |
+
{contentWarnings && showWarnings && !isReuseMode && (
|
| 1492 |
+
<motion.div
|
| 1493 |
+
initial={{ opacity: 0, y: -10 }}
|
| 1494 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1495 |
+
className="mb-6 p-4 bg-gradient-to-r from-amber-500/10 to-red-500/10 border-2 border-amber-500/30 rounded-xl"
|
| 1496 |
+
>
|
| 1497 |
+
<div className="flex items-start gap-3">
|
| 1498 |
+
<span className="text-2xl">β οΈ</span>
|
| 1499 |
+
<div className="flex-1">
|
| 1500 |
+
<h3 className="font-bold text-amber-400 mb-2">Content Policy Warnings</h3>
|
| 1501 |
+
<div className="space-y-2 mb-3">
|
| 1502 |
+
{contentWarnings.warnings.map((warning, idx) => (
|
| 1503 |
+
<p key={idx} className="text-sm text-void-300">{warning}</p>
|
| 1504 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1505 |
</div>
|
| 1506 |
+
{contentWarnings.guidance && (
|
| 1507 |
+
<details className="mt-3">
|
| 1508 |
+
<summary className="text-sm text-amber-300 cursor-pointer hover:text-amber-200">
|
| 1509 |
+
View Content Guidelines
|
| 1510 |
+
</summary>
|
| 1511 |
+
<div className="mt-2 space-y-2 text-xs text-void-400">
|
| 1512 |
+
<div>
|
| 1513 |
+
<strong className="text-void-300">Avoid Public Figures:</strong>
|
| 1514 |
+
<ul className="list-disc list-inside ml-2">
|
| 1515 |
+
{contentWarnings.guidance.avoid_public_figures.map((item, idx) => (
|
| 1516 |
+
<li key={idx}>{item}</li>
|
| 1517 |
+
))}
|
| 1518 |
+
</ul>
|
| 1519 |
+
</div>
|
| 1520 |
+
<div>
|
| 1521 |
+
<strong className="text-void-300">Avoid Copyrighted Content:</strong>
|
| 1522 |
+
<ul className="list-disc list-inside ml-2">
|
| 1523 |
+
{contentWarnings.guidance.avoid_copyrighted.map((item, idx) => (
|
| 1524 |
+
<li key={idx}>{item}</li>
|
| 1525 |
+
))}
|
| 1526 |
+
</ul>
|
| 1527 |
+
</div>
|
| 1528 |
+
<div>
|
| 1529 |
+
<strong className="text-void-300">Best Practices:</strong>
|
| 1530 |
+
<ul className="list-disc list-inside ml-2">
|
| 1531 |
+
{contentWarnings.guidance.best_practices.map((item, idx) => (
|
| 1532 |
+
<li key={idx}>{item}</li>
|
| 1533 |
+
))}
|
| 1534 |
+
</ul>
|
| 1535 |
+
</div>
|
| 1536 |
+
</div>
|
| 1537 |
+
</details>
|
| 1538 |
+
)}
|
| 1539 |
</div>
|
| 1540 |
+
<button
|
| 1541 |
+
onClick={() => setShowWarnings(false)}
|
| 1542 |
+
className="text-void-400 hover:text-void-200 transition-colors"
|
| 1543 |
+
>
|
| 1544 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 1545 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 1546 |
+
</svg>
|
| 1547 |
+
</button>
|
| 1548 |
+
</div>
|
| 1549 |
+
</motion.div>
|
| 1550 |
+
)}
|
| 1551 |
+
|
| 1552 |
+
{/* Reuse Mode Banner */}
|
| 1553 |
+
{isReuseMode && (
|
| 1554 |
+
<motion.div
|
| 1555 |
+
initial={{ opacity: 0, y: -10 }}
|
| 1556 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1557 |
+
className="mb-6 p-4 bg-gradient-to-r from-green-500/10 to-blue-500/10 border-2 border-green-500/30 rounded-xl"
|
| 1558 |
+
>
|
| 1559 |
+
<div className="flex items-start gap-3">
|
| 1560 |
+
<span className="text-2xl">β»οΈ</span>
|
| 1561 |
+
<div className="flex-1">
|
| 1562 |
+
<h3 className="font-bold text-green-400 mb-1">Reusing Cached Prompts</h3>
|
| 1563 |
+
<p className="text-sm text-void-300 mb-2">
|
| 1564 |
+
{segments.length} segments are pre-loaded from your saved prompt.
|
| 1565 |
+
<strong className="text-green-400"> Prompt generation will be skipped.</strong>
|
| 1566 |
+
</p>
|
| 1567 |
+
<p className="text-xs text-void-400">
|
| 1568 |
+
Simply upload your character image and click "Generate Video" to start creating videos immediately!
|
| 1569 |
+
</p>
|
| 1570 |
</div>
|
| 1571 |
+
<button
|
| 1572 |
+
onClick={() => {
|
| 1573 |
+
updateSegments([]);
|
| 1574 |
+
alert('Cleared cached segments. You can now enter a new script.');
|
| 1575 |
+
}}
|
| 1576 |
+
className="px-3 py-1 text-xs bg-void-800 hover:bg-void-700 text-void-300 rounded-lg transition-colors"
|
| 1577 |
+
>
|
| 1578 |
+
Clear & Start Fresh
|
| 1579 |
+
</button>
|
| 1580 |
+
</div>
|
| 1581 |
+
</motion.div>
|
| 1582 |
)}
|
| 1583 |
|
| 1584 |
+
|
| 1585 |
{/* Draft Restored Notification */}
|
| 1586 |
{draftRestored && (
|
| 1587 |
<motion.div
|
|
|
|
| 1608 |
</motion.div>
|
| 1609 |
)}
|
| 1610 |
|
| 1611 |
+
<form onSubmit={isReuseMode ? handleReuseGeneration : handleSubmit}>
|
| 1612 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 1613 |
{/* Left Column - Script & Style */}
|
| 1614 |
<div className="space-y-6">
|
| 1615 |
+
{/* Script Input - Hidden in reuse mode */}
|
| 1616 |
+
{!isReuseMode && (
|
| 1617 |
+
<div className="card">
|
| 1618 |
+
<label className="block text-sm font-semibold text-void-200 mb-3">
|
| 1619 |
+
Script <span className="text-coral-400">*</span>
|
| 1620 |
+
</label>
|
| 1621 |
+
<textarea
|
| 1622 |
+
name="script"
|
| 1623 |
+
value={formState.script}
|
| 1624 |
+
onChange={handleChange}
|
| 1625 |
+
rows={10}
|
| 1626 |
+
className="textarea-field font-mono text-sm"
|
| 1627 |
+
placeholder="Enter your complete video script here...
|
| 1628 |
|
| 1629 |
The AI will automatically analyze and segment your script into optimal video chunks, typically 8 seconds each."
|
| 1630 |
+
required={!isReuseMode}
|
| 1631 |
+
/>
|
| 1632 |
+
<div className="flex items-center justify-between mt-3">
|
| 1633 |
+
<p className="text-xs text-void-500">
|
| 1634 |
+
AI will automatically segment your script
|
| 1635 |
+
</p>
|
| 1636 |
+
{wordCount > 0 && (
|
| 1637 |
+
<div className="flex items-center gap-4 text-xs">
|
| 1638 |
+
<span className="text-void-400">{wordCount} words</span>
|
| 1639 |
+
<span className={`font-semibold ${provider === 'kling' ? 'text-coral-400' : 'text-electric-400'}`}>
|
| 1640 |
+
~{estimatedSegments} segments
|
| 1641 |
+
</span>
|
| 1642 |
+
</div>
|
| 1643 |
+
)}
|
| 1644 |
+
</div>
|
| 1645 |
+
</div>
|
| 1646 |
+
)}
|
| 1647 |
+
|
| 1648 |
+
{/* Reuse Mode: Show segment count + same-image reminder */}
|
| 1649 |
+
{isReuseMode && (
|
| 1650 |
+
<div className="card border-2 border-green-500/30 bg-green-500/5">
|
| 1651 |
+
<div className="flex items-center justify-between mb-3">
|
| 1652 |
+
<h4 className="text-sm font-semibold text-green-400">
|
| 1653 |
+
β
Segments Loaded from Cache
|
| 1654 |
+
</h4>
|
| 1655 |
+
<span className="px-3 py-1 bg-green-500/20 text-green-300 text-xs rounded-full font-bold">
|
| 1656 |
+
{segments.length} segments ready
|
| 1657 |
+
</span>
|
| 1658 |
+
</div>
|
| 1659 |
+
<p className="text-sm text-void-300 mb-2">
|
| 1660 |
+
Your cached prompts are loaded and ready for video generation.
|
| 1661 |
+
</p>
|
| 1662 |
+
<p className="text-xs text-amber-200/90 bg-amber-500/20 border border-amber-500/40 rounded-lg px-3 py-2 mb-2">
|
| 1663 |
+
β οΈ Use the <strong>same reference image</strong> you used when creating this prompt for best visual continuity. The segment prompts describe that specific person and scene.
|
| 1664 |
+
</p>
|
| 1665 |
+
<p className="text-xs text-void-400">
|
| 1666 |
+
Estimated duration: ~{segments.length * 8} seconds
|
| 1667 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1668 |
</div>
|
| 1669 |
+
)}
|
| 1670 |
|
| 1671 |
+
{/* Style Input - Hidden in reuse mode */}
|
| 1672 |
+
{!isReuseMode && (
|
| 1673 |
+
<div className="card">
|
| 1674 |
+
<label className="block text-sm font-semibold text-void-200 mb-3">
|
| 1675 |
+
Visual Style
|
| 1676 |
+
</label>
|
| 1677 |
+
<textarea
|
| 1678 |
+
name="style"
|
| 1679 |
+
value={formState.style}
|
| 1680 |
+
onChange={handleChange}
|
| 1681 |
+
rows={3}
|
| 1682 |
+
className="textarea-field"
|
| 1683 |
+
placeholder="e.g., Cinematic, hyper-realistic, natural lighting, modern aesthetic, warm tones..."
|
| 1684 |
+
/>
|
| 1685 |
+
</div>
|
| 1686 |
+
)}
|
| 1687 |
+
|
| 1688 |
+
{/* Segment duration: fixed 4/6/8s or AI decides */}
|
| 1689 |
+
{!isReuseMode && (
|
| 1690 |
+
<div className="card border-2 border-void-500/30">
|
| 1691 |
+
<label className="block text-sm font-semibold text-void-200 mb-3">
|
| 1692 |
+
Segment duration
|
| 1693 |
+
</label>
|
| 1694 |
+
<div className="space-y-3">
|
| 1695 |
+
<label className="flex items-center gap-3 cursor-pointer">
|
| 1696 |
+
<input
|
| 1697 |
+
type="radio"
|
| 1698 |
+
name="segmentMode"
|
| 1699 |
+
value="fixed"
|
| 1700 |
+
checked={segmentMode === 'fixed'}
|
| 1701 |
+
onChange={() => setSegmentMode('fixed')}
|
| 1702 |
+
className="w-4 h-4 accent-coral-500"
|
| 1703 |
+
/>
|
| 1704 |
+
<span className="text-sm text-void-200">Fixed</span>
|
| 1705 |
+
<select
|
| 1706 |
+
value={secondsPerSegment}
|
| 1707 |
+
onChange={(e) => setSecondsPerSegment(Number(e.target.value) as 4 | 6 | 8)}
|
| 1708 |
+
disabled={segmentMode !== 'fixed'}
|
| 1709 |
+
className="ml-2 px-2 py-1 rounded bg-void-800 border border-void-600 text-void-200 text-sm"
|
| 1710 |
+
>
|
| 1711 |
+
<option value={4}>4 sec</option>
|
| 1712 |
+
<option value={6}>6 sec</option>
|
| 1713 |
+
<option value={8}>8 sec</option>
|
| 1714 |
+
</select>
|
| 1715 |
+
<span className="text-xs text-void-400">per segment</span>
|
| 1716 |
+
</label>
|
| 1717 |
+
<label className="flex items-start gap-3 cursor-pointer">
|
| 1718 |
+
<input
|
| 1719 |
+
type="radio"
|
| 1720 |
+
name="segmentMode"
|
| 1721 |
+
value="ai_driven"
|
| 1722 |
+
checked={segmentMode === 'ai_driven'}
|
| 1723 |
+
onChange={() => setSegmentMode('ai_driven')}
|
| 1724 |
+
className="mt-0.5 w-4 h-4 accent-coral-500"
|
| 1725 |
+
/>
|
| 1726 |
+
<div>
|
| 1727 |
+
<span className="text-sm font-medium text-void-100">AI decides</span>
|
| 1728 |
+
<p className="text-xs text-void-400 mt-0.5">
|
| 1729 |
+
AI splits the script and chooses 4, 6, or 8 sec per segment for natural pacing.
|
| 1730 |
+
</p>
|
| 1731 |
+
</div>
|
| 1732 |
+
</label>
|
| 1733 |
+
</div>
|
| 1734 |
+
</div>
|
| 1735 |
+
)}
|
| 1736 |
|
| 1737 |
{/* Generation Mode Selection (Kling only) */}
|
| 1738 |
{provider === 'kling' && (
|
|
|
|
| 1786 |
</div>
|
| 1787 |
)}
|
| 1788 |
|
| 1789 |
+
{/* Generation Preview - Only show in normal mode */}
|
| 1790 |
+
{!isReuseMode && estimatedSegments > 0 && (
|
| 1791 |
<motion.div
|
| 1792 |
initial={{ opacity: 0, scale: 0.95 }}
|
| 1793 |
animate={{ opacity: 1, scale: 1 }}
|
|
|
|
| 1976 |
className={`
|
| 1977 |
w-full py-4 font-semibold rounded-xl transition-all duration-300
|
| 1978 |
flex items-center justify-center gap-3
|
| 1979 |
+
${isReuseMode
|
| 1980 |
+
? 'bg-gradient-to-r from-green-500 to-blue-500 hover:from-green-400 hover:to-blue-400 text-white shadow-lg shadow-green-500/30'
|
| 1981 |
+
: provider === 'kling' ? 'btn-primary' : 'btn-electric'
|
| 1982 |
+
}
|
| 1983 |
disabled:opacity-50 disabled:cursor-not-allowed disabled:scale-100
|
| 1984 |
`}
|
| 1985 |
>
|
|
|
|
| 1988 |
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 1989 |
<span>Generating...</span>
|
| 1990 |
</>
|
| 1991 |
+
) : isReuseMode ? (
|
| 1992 |
+
<>
|
| 1993 |
+
<span className="text-xl">π</span>
|
| 1994 |
+
<span>
|
| 1995 |
+
Start Video Generation ({segments.length} segments)
|
| 1996 |
+
</span>
|
| 1997 |
+
</>
|
| 1998 |
) : (
|
| 1999 |
<>
|
| 2000 |
<SparklesIcon size={20} />
|
frontend/src/components/GenerationProgress.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { useGeneration } from '@/context/GenerationContext';
|
|
|
|
|
|
|
| 4 |
|
| 5 |
// Icons
|
| 6 |
const CheckIcon = () => (
|
|
@@ -69,7 +71,25 @@ const XIcon = () => (
|
|
| 69 |
|
| 70 |
export const GenerationProgress: React.FC = () => {
|
| 71 |
const { state, cancelGeneration } = useGeneration();
|
| 72 |
-
const { progress, provider, generatedVideos, segments, isCancelling, activeTaskIds } = state;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
const [elapsedTime, setElapsedTime] = useState(0);
|
| 75 |
const [activityLog, setActivityLog] = useState<ActivityLog[]>([]);
|
|
@@ -389,6 +409,21 @@ export const GenerationProgress: React.FC = () => {
|
|
| 389 |
</div>
|
| 390 |
</div>
|
| 391 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
{/* Bottom Progress Bar */}
|
| 393 |
<div className="card p-4">
|
| 394 |
<div className="flex items-center justify-between mb-2">
|
|
|
|
| 1 |
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { useGeneration } from '@/context/GenerationContext';
|
| 4 |
+
import { SegmentGenerationProgress } from './SegmentGenerationProgress';
|
| 5 |
+
import { SegmentPromptsViewer } from './SegmentPromptsViewer';
|
| 6 |
|
| 7 |
// Icons
|
| 8 |
const CheckIcon = () => (
|
|
|
|
| 71 |
|
| 72 |
export const GenerationProgress: React.FC = () => {
|
| 73 |
const { state, cancelGeneration } = useGeneration();
|
| 74 |
+
const { progress, provider, generatedVideos, segments, isCancelling, activeTaskIds, step } = state;
|
| 75 |
+
|
| 76 |
+
// Show enhanced UX during prompt generation (streaming)
|
| 77 |
+
// Use estimated count if segments not yet loaded, or actual count if available
|
| 78 |
+
if (step === 'generating_prompts') {
|
| 79 |
+
const estimatedCount = segments.length > 0 ? segments.length : (progress.total > 0 ? progress.total : 5);
|
| 80 |
+
const accentColor = provider === 'kling' ? 'coral' : 'electric';
|
| 81 |
+
return (
|
| 82 |
+
<div className="w-full max-w-4xl mx-auto space-y-8">
|
| 83 |
+
<SegmentGenerationProgress
|
| 84 |
+
segmentsCount={Math.max(estimatedCount, segments.length)}
|
| 85 |
+
onCancel={cancelGeneration}
|
| 86 |
+
/>
|
| 87 |
+
{segments.length > 0 && (
|
| 88 |
+
<SegmentPromptsViewer segments={segments} accentColor={accentColor} />
|
| 89 |
+
)}
|
| 90 |
+
</div>
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
|
| 94 |
const [elapsedTime, setElapsedTime] = useState(0);
|
| 95 |
const [activityLog, setActivityLog] = useState<ActivityLog[]>([]);
|
|
|
|
| 409 |
</div>
|
| 410 |
</div>
|
| 411 |
|
| 412 |
+
{/* Segment Prompts Viewer - Show during video generation */}
|
| 413 |
+
{segments.length > 0 && (
|
| 414 |
+
<motion.div
|
| 415 |
+
initial={{ opacity: 0, y: 20 }}
|
| 416 |
+
animate={{ opacity: 1, y: 0 }}
|
| 417 |
+
transition={{ delay: 0.3 }}
|
| 418 |
+
className="mb-8"
|
| 419 |
+
>
|
| 420 |
+
<SegmentPromptsViewer
|
| 421 |
+
segments={segments}
|
| 422 |
+
accentColor={accentColor}
|
| 423 |
+
/>
|
| 424 |
+
</motion.div>
|
| 425 |
+
)}
|
| 426 |
+
|
| 427 |
{/* Bottom Progress Bar */}
|
| 428 |
<div className="card p-4">
|
| 429 |
<div className="flex items-center justify-between mb-2">
|
frontend/src/components/Icons.tsx
CHANGED
|
@@ -239,6 +239,55 @@ export const BrainIcon: React.FC<IconProps> = ({ className = '', size = 24 }) =>
|
|
| 239 |
</svg>
|
| 240 |
);
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
// Logo icon for the app
|
| 243 |
export const LogoIcon: React.FC<IconProps> = ({ className = '', size = 40 }) => (
|
| 244 |
<svg
|
|
|
|
| 239 |
</svg>
|
| 240 |
);
|
| 241 |
|
| 242 |
+
export const ChevronDownIcon: React.FC<IconProps> = ({ className = '', size = 24 }) => (
|
| 243 |
+
<svg
|
| 244 |
+
className={className}
|
| 245 |
+
width={size}
|
| 246 |
+
height={size}
|
| 247 |
+
viewBox="0 0 24 24"
|
| 248 |
+
fill="none"
|
| 249 |
+
stroke="currentColor"
|
| 250 |
+
strokeWidth="2"
|
| 251 |
+
strokeLinecap="round"
|
| 252 |
+
strokeLinejoin="round"
|
| 253 |
+
>
|
| 254 |
+
<polyline points="6 9 12 15 18 9" />
|
| 255 |
+
</svg>
|
| 256 |
+
);
|
| 257 |
+
|
| 258 |
+
export const ChevronUpIcon: React.FC<IconProps> = ({ className = '', size = 24 }) => (
|
| 259 |
+
<svg
|
| 260 |
+
className={className}
|
| 261 |
+
width={size}
|
| 262 |
+
height={size}
|
| 263 |
+
viewBox="0 0 24 24"
|
| 264 |
+
fill="none"
|
| 265 |
+
stroke="currentColor"
|
| 266 |
+
strokeWidth="2"
|
| 267 |
+
strokeLinecap="round"
|
| 268 |
+
strokeLinejoin="round"
|
| 269 |
+
>
|
| 270 |
+
<polyline points="18 15 12 9 6 15" />
|
| 271 |
+
</svg>
|
| 272 |
+
);
|
| 273 |
+
|
| 274 |
+
export const CopyIcon: React.FC<IconProps> = ({ className = '', size = 24 }) => (
|
| 275 |
+
<svg
|
| 276 |
+
className={className}
|
| 277 |
+
width={size}
|
| 278 |
+
height={size}
|
| 279 |
+
viewBox="0 0 24 24"
|
| 280 |
+
fill="none"
|
| 281 |
+
stroke="currentColor"
|
| 282 |
+
strokeWidth="2"
|
| 283 |
+
strokeLinecap="round"
|
| 284 |
+
strokeLinejoin="round"
|
| 285 |
+
>
|
| 286 |
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
| 287 |
+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
| 288 |
+
</svg>
|
| 289 |
+
);
|
| 290 |
+
|
| 291 |
// Logo icon for the app
|
| 292 |
export const LogoIcon: React.FC<IconProps> = ({ className = '', size = 40 }) => (
|
| 293 |
<svg
|
frontend/src/components/ProviderSelect.tsx
CHANGED
|
@@ -38,6 +38,16 @@ const providers: ProviderCard[] = [
|
|
| 38 |
'Voice Type Selection',
|
| 39 |
],
|
| 40 |
badge: 'Recommended',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
},
|
| 42 |
{
|
| 43 |
id: 'replicate' as VideoProvider,
|
|
@@ -55,33 +65,20 @@ const providers: ProviderCard[] = [
|
|
| 55 |
'Community Models',
|
| 56 |
],
|
| 57 |
badge: 'Flexible',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
},
|
| 59 |
];
|
| 60 |
|
| 61 |
export const ProviderSelect: React.FC<ProviderSelectProps> = ({ onSelect }) => {
|
| 62 |
-
const [liveProviders, setLiveProviders] = React.useState<typeof providers | null>(null);
|
| 63 |
-
|
| 64 |
-
React.useEffect(() => {
|
| 65 |
-
let mounted = true;
|
| 66 |
-
fetch('/api/pricing')
|
| 67 |
-
.then(res => res.json())
|
| 68 |
-
.then(data => {
|
| 69 |
-
if (!mounted) return;
|
| 70 |
-
// Merge fetched pricing into static provider metadata
|
| 71 |
-
const mapped = providers.map(p => {
|
| 72 |
-
const found = (data.providers || []).find((x: any) => x.id === (p.id === 'kling' ? 'kie' : p.id));
|
| 73 |
-
return { ...p, pricing: found?.pricing };
|
| 74 |
-
});
|
| 75 |
-
setLiveProviders(mapped);
|
| 76 |
-
})
|
| 77 |
-
.catch(() => {
|
| 78 |
-
/* ignore β fall back to static providers */
|
| 79 |
-
});
|
| 80 |
-
return () => { mounted = false; };
|
| 81 |
-
}, []);
|
| 82 |
-
|
| 83 |
-
const list = liveProviders || providers;
|
| 84 |
-
|
| 85 |
return (
|
| 86 |
<div className="min-h-[70vh] flex flex-col items-center justify-center p-8">
|
| 87 |
{/* Hero Section */}
|
|
@@ -102,7 +99,7 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({ onSelect }) => {
|
|
| 102 |
|
| 103 |
{/* Provider Cards */}
|
| 104 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-5xl w-full">
|
| 105 |
-
{
|
| 106 |
<motion.div
|
| 107 |
key={provider.id}
|
| 108 |
initial={{ opacity: 0, y: 30 }}
|
|
|
|
| 38 |
'Voice Type Selection',
|
| 39 |
],
|
| 40 |
badge: 'Recommended',
|
| 41 |
+
pricing: {
|
| 42 |
+
per_second: 0.05,
|
| 43 |
+
samples: {
|
| 44 |
+
'1s': 0.05,
|
| 45 |
+
'8s': 0.4,
|
| 46 |
+
'30s': 1.5,
|
| 47 |
+
'60s': 3.0,
|
| 48 |
+
},
|
| 49 |
+
evidence: 'Veo 3 Fast (8s, with audio) β Kie.ai $0.40 (β$0.05/sec)',
|
| 50 |
+
},
|
| 51 |
},
|
| 52 |
{
|
| 53 |
id: 'replicate' as VideoProvider,
|
|
|
|
| 65 |
'Community Models',
|
| 66 |
],
|
| 67 |
badge: 'Flexible',
|
| 68 |
+
pricing: {
|
| 69 |
+
per_second: 0.15,
|
| 70 |
+
samples: {
|
| 71 |
+
'1s': 0.15,
|
| 72 |
+
'8s': 1.2,
|
| 73 |
+
'30s': 4.5,
|
| 74 |
+
'60s': 9.0,
|
| 75 |
+
},
|
| 76 |
+
evidence: 'google/veo-3-fast with_audio β $0.15 per second',
|
| 77 |
+
},
|
| 78 |
},
|
| 79 |
];
|
| 80 |
|
| 81 |
export const ProviderSelect: React.FC<ProviderSelectProps> = ({ onSelect }) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
return (
|
| 83 |
<div className="min-h-[70vh] flex flex-col items-center justify-center p-8">
|
| 84 |
{/* Hero Section */}
|
|
|
|
| 99 |
|
| 100 |
{/* Provider Cards */}
|
| 101 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-5xl w-full">
|
| 102 |
+
{providers.map((provider, index) => (
|
| 103 |
<motion.div
|
| 104 |
key={provider.id}
|
| 105 |
initial={{ opacity: 0, y: 30 }}
|
frontend/src/components/SavedPromptsLibrary.tsx
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
|
| 4 |
+
|
| 5 |
+
interface CachedPrompt {
|
| 6 |
+
prompt_id: string;
|
| 7 |
+
created_at: string;
|
| 8 |
+
updated_at: string;
|
| 9 |
+
metadata: {
|
| 10 |
+
script: string;
|
| 11 |
+
style: string;
|
| 12 |
+
model: string;
|
| 13 |
+
segments_count: number;
|
| 14 |
+
};
|
| 15 |
+
segments_count: number;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function SavedPromptsLibrary({ onClose, onReuse }: {
|
| 19 |
+
onClose: () => void;
|
| 20 |
+
onReuse: (payload: any) => void;
|
| 21 |
+
}) {
|
| 22 |
+
const [prompts, setPrompts] = useState<CachedPrompt[]>([]);
|
| 23 |
+
const [loading, setLoading] = useState(true);
|
| 24 |
+
const [error, setError] = useState<string | null>(null);
|
| 25 |
+
const [editingPromptId, setEditingPromptId] = useState<string | null>(null);
|
| 26 |
+
const [editingPayloadJson, setEditingPayloadJson] = useState('');
|
| 27 |
+
const [editError, setEditError] = useState<string | null>(null);
|
| 28 |
+
const [saving, setSaving] = useState(false);
|
| 29 |
+
const [validating, setValidating] = useState(false);
|
| 30 |
+
const [validationResult, setValidationResult] = useState<{
|
| 31 |
+
valid: boolean;
|
| 32 |
+
schema_errors?: string[];
|
| 33 |
+
ai_checked?: boolean;
|
| 34 |
+
ai_valid?: boolean;
|
| 35 |
+
ai_warnings?: string[];
|
| 36 |
+
ai_suggestions?: string[];
|
| 37 |
+
} | null>(null);
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
loadSavedPrompts();
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
const loadSavedPrompts = async () => {
|
| 44 |
+
try {
|
| 45 |
+
setLoading(true);
|
| 46 |
+
setError(null);
|
| 47 |
+
const response = await fetch(`${API_BASE}/api/cached-prompts?limit=50`);
|
| 48 |
+
if (!response.ok) throw new Error('Failed to load prompts');
|
| 49 |
+
const data = await response.json();
|
| 50 |
+
const list: CachedPrompt[] = data.prompts || [];
|
| 51 |
+
// Deduplicate: same script + same segment count = same prompt; keep most recent
|
| 52 |
+
const seen = new Map<string, CachedPrompt>();
|
| 53 |
+
const key = (p: CachedPrompt) => {
|
| 54 |
+
const script = (p.metadata?.script || '').trim().slice(0, 300);
|
| 55 |
+
return `${script}|${p.segments_count ?? p.metadata?.segments_count ?? 0}`;
|
| 56 |
+
};
|
| 57 |
+
for (const p of list) {
|
| 58 |
+
const k = key(p);
|
| 59 |
+
const existing = seen.get(k);
|
| 60 |
+
if (!existing || new Date(p.updated_at) > new Date(existing.updated_at)) {
|
| 61 |
+
seen.set(k, p);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
setPrompts(Array.from(seen.values()));
|
| 65 |
+
} catch (err) {
|
| 66 |
+
setError(err instanceof Error ? err.message : 'Failed to load prompts');
|
| 67 |
+
} finally {
|
| 68 |
+
setLoading(false);
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const handleReuse = async (promptId: string) => {
|
| 73 |
+
try {
|
| 74 |
+
const response = await fetch(`${API_BASE}/api/use-cached-prompt/${promptId}`, {
|
| 75 |
+
method: 'POST'
|
| 76 |
+
});
|
| 77 |
+
if (!response.ok) throw new Error('Failed to load prompt');
|
| 78 |
+
const { payload } = await response.json();
|
| 79 |
+
onReuse(payload);
|
| 80 |
+
onClose();
|
| 81 |
+
} catch (err) {
|
| 82 |
+
alert(err instanceof Error ? err.message : 'Failed to reuse prompt');
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const handleDelete = async (promptId: string) => {
|
| 87 |
+
if (!confirm('Delete this saved prompt?')) return;
|
| 88 |
+
|
| 89 |
+
try {
|
| 90 |
+
await fetch(`${API_BASE}/api/cached-prompts/${promptId}`, { method: 'DELETE' });
|
| 91 |
+
loadSavedPrompts(); // Refresh list
|
| 92 |
+
} catch (err) {
|
| 93 |
+
alert('Failed to delete prompt');
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
const handleEdit = async (promptId: string) => {
|
| 98 |
+
setEditError(null);
|
| 99 |
+
try {
|
| 100 |
+
const response = await fetch(`${API_BASE}/api/cached-prompts/${promptId}`);
|
| 101 |
+
if (!response.ok) throw new Error('Failed to load prompt');
|
| 102 |
+
const entry = await response.json();
|
| 103 |
+
const payload = entry.payload ?? { segments: entry.segments ?? [] };
|
| 104 |
+
setEditingPayloadJson(JSON.stringify(payload, null, 2));
|
| 105 |
+
setEditingPromptId(promptId);
|
| 106 |
+
} catch (err) {
|
| 107 |
+
setEditError(err instanceof Error ? err.message : 'Failed to load prompt');
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const validatePayload = (): { valid: boolean; payload?: any; error?: string } => {
|
| 112 |
+
try {
|
| 113 |
+
const parsed = JSON.parse(editingPayloadJson);
|
| 114 |
+
if (!parsed || typeof parsed !== 'object') return { valid: false, error: 'Payload must be an object' };
|
| 115 |
+
if (!Array.isArray(parsed.segments)) return { valid: false, error: 'Payload must have a "segments" array' };
|
| 116 |
+
return { valid: true, payload: parsed };
|
| 117 |
+
} catch {
|
| 118 |
+
return { valid: false, error: 'Invalid JSON' };
|
| 119 |
+
}
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const handleValidateWithAi = async () => {
|
| 123 |
+
const { valid, payload, error } = validatePayload();
|
| 124 |
+
if (!valid || !payload) {
|
| 125 |
+
setEditError(error ?? 'Invalid payload');
|
| 126 |
+
setValidationResult(null);
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
setEditError(null);
|
| 130 |
+
setValidationResult(null);
|
| 131 |
+
setValidating(true);
|
| 132 |
+
try {
|
| 133 |
+
const response = await fetch(`${API_BASE}/api/validate-payload`, {
|
| 134 |
+
method: 'POST',
|
| 135 |
+
headers: { 'Content-Type': 'application/json' },
|
| 136 |
+
body: JSON.stringify({ payload, use_ai: true }),
|
| 137 |
+
});
|
| 138 |
+
const data = await response.json().catch(() => ({}));
|
| 139 |
+
if (!response.ok) throw new Error(data.detail || response.statusText || 'Validation failed');
|
| 140 |
+
setValidationResult({
|
| 141 |
+
valid: data.valid,
|
| 142 |
+
schema_errors: data.schema_errors,
|
| 143 |
+
ai_checked: data.ai_checked,
|
| 144 |
+
ai_valid: data.ai_valid,
|
| 145 |
+
ai_warnings: data.ai_warnings,
|
| 146 |
+
ai_suggestions: data.ai_suggestions,
|
| 147 |
+
});
|
| 148 |
+
} catch (err) {
|
| 149 |
+
setEditError(err instanceof Error ? err.message : 'Validation request failed');
|
| 150 |
+
} finally {
|
| 151 |
+
setValidating(false);
|
| 152 |
+
}
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
const handleSaveEdit = async () => {
|
| 156 |
+
const { valid, payload, error } = validatePayload();
|
| 157 |
+
if (!valid || !payload || !editingPromptId) {
|
| 158 |
+
setEditError(error ?? 'Invalid payload');
|
| 159 |
+
return;
|
| 160 |
+
}
|
| 161 |
+
setEditError(null);
|
| 162 |
+
setSaving(true);
|
| 163 |
+
try {
|
| 164 |
+
const response = await fetch(`${API_BASE}/api/cached-prompts/${editingPromptId}`, {
|
| 165 |
+
method: 'PUT',
|
| 166 |
+
headers: { 'Content-Type': 'application/json' },
|
| 167 |
+
body: JSON.stringify(payload),
|
| 168 |
+
});
|
| 169 |
+
if (!response.ok) {
|
| 170 |
+
const errData = await response.json().catch(() => ({}));
|
| 171 |
+
throw new Error(errData.detail || response.statusText || 'Failed to update');
|
| 172 |
+
}
|
| 173 |
+
setEditingPromptId(null);
|
| 174 |
+
setEditingPayloadJson('');
|
| 175 |
+
loadSavedPrompts();
|
| 176 |
+
} catch (err) {
|
| 177 |
+
setEditError(err instanceof Error ? err.message : 'Failed to save');
|
| 178 |
+
} finally {
|
| 179 |
+
setSaving(false);
|
| 180 |
+
}
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
const closeEditModal = () => {
|
| 184 |
+
setEditingPromptId(null);
|
| 185 |
+
setEditingPayloadJson('');
|
| 186 |
+
setEditError(null);
|
| 187 |
+
setValidationResult(null);
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
return (
|
| 191 |
+
<div className="fixed inset-0 bg-void-950/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
| 192 |
+
<div className="bg-void-900/95 backdrop-blur-xl rounded-2xl shadow-2xl border border-void-700/50 max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
| 193 |
+
{/* Header */}
|
| 194 |
+
<div className="p-6 border-b border-void-700/50 flex justify-between items-center">
|
| 195 |
+
<h2 className="text-2xl font-bold text-void-100">πΎ My Saved Prompts</h2>
|
| 196 |
+
<button
|
| 197 |
+
onClick={onClose}
|
| 198 |
+
className="text-void-400 hover:text-void-100 text-2xl font-light leading-none transition-colors"
|
| 199 |
+
>
|
| 200 |
+
Γ
|
| 201 |
+
</button>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
{/* Content */}
|
| 205 |
+
<div className="flex-1 overflow-y-auto p-6">
|
| 206 |
+
{loading ? (
|
| 207 |
+
<div className="flex items-center justify-center py-12">
|
| 208 |
+
<div className="animate-spin rounded-full h-12 w-12 border-2 border-void-600 border-t-coral-500" />
|
| 209 |
+
</div>
|
| 210 |
+
) : error ? (
|
| 211 |
+
<div className="text-center py-12">
|
| 212 |
+
<p className="text-coral-400 mb-4">{error}</p>
|
| 213 |
+
<button
|
| 214 |
+
onClick={loadSavedPrompts}
|
| 215 |
+
className="px-4 py-2 bg-coral-500/20 text-coral-400 rounded-xl border border-coral-500/50 hover:bg-coral-500/30 transition-colors"
|
| 216 |
+
>
|
| 217 |
+
Retry
|
| 218 |
+
</button>
|
| 219 |
+
</div>
|
| 220 |
+
) : prompts.length === 0 ? (
|
| 221 |
+
<div className="text-center py-12">
|
| 222 |
+
<div className="text-6xl mb-4 opacity-60">π</div>
|
| 223 |
+
<p className="text-void-300 mb-2">No saved prompts yet</p>
|
| 224 |
+
<p className="text-sm text-void-500">Generate some prompts to see them here</p>
|
| 225 |
+
</div>
|
| 226 |
+
) : (
|
| 227 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 228 |
+
{prompts.map((prompt) => (
|
| 229 |
+
<div
|
| 230 |
+
key={prompt.prompt_id}
|
| 231 |
+
className="card hover:border-void-600/50 hover:bg-void-800/40 transition-all duration-200"
|
| 232 |
+
>
|
| 233 |
+
{/* Header */}
|
| 234 |
+
<div className="flex justify-between items-start mb-3">
|
| 235 |
+
<span className="text-xs text-void-500">
|
| 236 |
+
{new Date(prompt.created_at).toLocaleDateString('en-US', {
|
| 237 |
+
month: 'short',
|
| 238 |
+
day: 'numeric',
|
| 239 |
+
year: 'numeric'
|
| 240 |
+
})}
|
| 241 |
+
</span>
|
| 242 |
+
<span className="px-2 py-1 bg-electric-500/20 text-electric-400 text-xs rounded-full font-medium border border-electric-500/30">
|
| 243 |
+
{prompt.segments_count} segments
|
| 244 |
+
</span>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
{/* Content Preview */}
|
| 248 |
+
<div className="mb-4">
|
| 249 |
+
<p className="text-sm text-void-200 line-clamp-3 mb-2">
|
| 250 |
+
{(prompt.metadata?.script || '').slice(0, 120)}
|
| 251 |
+
{(prompt.metadata?.script || '').length > 120 ? 'β¦' : ''}
|
| 252 |
+
</p>
|
| 253 |
+
<span className="inline-block px-2 py-1 bg-void-800 text-void-300 text-xs rounded-lg border border-void-600/50">
|
| 254 |
+
{prompt.metadata?.style || 'β'}
|
| 255 |
+
</span>
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
{/* Actions */}
|
| 259 |
+
<div className="flex gap-2">
|
| 260 |
+
<button
|
| 261 |
+
onClick={() => handleReuse(prompt.prompt_id)}
|
| 262 |
+
className="flex-1 px-3 py-2 bg-coral-500/20 text-coral-400 rounded-xl border border-coral-500/50 hover:bg-coral-500/30 text-sm font-medium transition-colors"
|
| 263 |
+
>
|
| 264 |
+
β»οΈ Reuse
|
| 265 |
+
</button>
|
| 266 |
+
<button
|
| 267 |
+
onClick={() => handleEdit(prompt.prompt_id)}
|
| 268 |
+
className="px-3 py-2 bg-void-800 text-void-300 rounded-xl border border-void-600 hover:bg-void-700 hover:text-void-100 text-sm transition-colors"
|
| 269 |
+
title="Edit prompt"
|
| 270 |
+
>
|
| 271 |
+
βοΈ
|
| 272 |
+
</button>
|
| 273 |
+
<button
|
| 274 |
+
onClick={() => handleDelete(prompt.prompt_id)}
|
| 275 |
+
className="px-3 py-2 bg-void-800/80 text-red-400 rounded-xl border border-void-600 hover:bg-red-500/10 hover:border-red-500/40 text-sm transition-colors"
|
| 276 |
+
>
|
| 277 |
+
ποΈ
|
| 278 |
+
</button>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
))}
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
{/* Footer */}
|
| 287 |
+
<div className="p-4 border-t border-void-700/50">
|
| 288 |
+
<button
|
| 289 |
+
onClick={onClose}
|
| 290 |
+
className="w-full py-2.5 bg-void-800 text-void-200 rounded-xl border border-void-600 hover:bg-void-700 transition-colors"
|
| 291 |
+
>
|
| 292 |
+
Close
|
| 293 |
+
</button>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
|
| 297 |
+
{/* Edit modal - dark chrome to match app; light body so JSON is readable */}
|
| 298 |
+
{editingPromptId && (
|
| 299 |
+
<div className="fixed inset-0 bg-void-950/90 backdrop-blur-sm flex items-center justify-center p-4 z-[60]">
|
| 300 |
+
<div className="bg-void-900 rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col border border-void-600">
|
| 301 |
+
<div className="p-4 border-b border-void-600 flex justify-between items-center">
|
| 302 |
+
<h3 className="text-lg font-bold text-void-100">βοΈ Edit saved prompt</h3>
|
| 303 |
+
<button onClick={closeEditModal} className="text-void-400 hover:text-void-100 text-2xl font-bold leading-none transition-colors">Γ</button>
|
| 304 |
+
</div>
|
| 305 |
+
<div className="flex-1 overflow-hidden flex flex-col p-4 bg-void-800/30">
|
| 306 |
+
{editError && (
|
| 307 |
+
<p className="text-coral-400 text-sm mb-2 font-medium">{editError}</p>
|
| 308 |
+
)}
|
| 309 |
+
{validationResult && (
|
| 310 |
+
<div className="mb-3 p-3 rounded-xl bg-void-900/80 border border-void-600 text-sm">
|
| 311 |
+
{!validationResult.valid && validationResult.schema_errors && validationResult.schema_errors.length > 0 && (
|
| 312 |
+
<div className="mb-2">
|
| 313 |
+
<p className="font-medium text-coral-400 mb-1">Schema issues:</p>
|
| 314 |
+
<ul className="list-disc list-inside text-coral-300/90 space-y-0.5">
|
| 315 |
+
{validationResult.schema_errors.map((e, i) => (
|
| 316 |
+
<li key={i}>{e}</li>
|
| 317 |
+
))}
|
| 318 |
+
</ul>
|
| 319 |
+
</div>
|
| 320 |
+
)}
|
| 321 |
+
{validationResult.ai_checked && (
|
| 322 |
+
<>
|
| 323 |
+
{validationResult.ai_warnings && validationResult.ai_warnings.length > 0 && (
|
| 324 |
+
<div className="mb-2">
|
| 325 |
+
<p className="font-medium text-amber-400 mb-1">AI review warnings:</p>
|
| 326 |
+
<ul className="list-disc list-inside text-amber-300/90 space-y-0.5">
|
| 327 |
+
{validationResult.ai_warnings.map((w, i) => (
|
| 328 |
+
<li key={i}>{w}</li>
|
| 329 |
+
))}
|
| 330 |
+
</ul>
|
| 331 |
+
</div>
|
| 332 |
+
)}
|
| 333 |
+
{validationResult.ai_suggestions && validationResult.ai_suggestions.length > 0 && (
|
| 334 |
+
<div className="mb-2">
|
| 335 |
+
<p className="font-medium text-electric-400 mb-1">Suggestions:</p>
|
| 336 |
+
<ul className="list-disc list-inside text-electric-300/90 space-y-0.5">
|
| 337 |
+
{validationResult.ai_suggestions.map((s, i) => (
|
| 338 |
+
<li key={i}>{s}</li>
|
| 339 |
+
))}
|
| 340 |
+
</ul>
|
| 341 |
+
</div>
|
| 342 |
+
)}
|
| 343 |
+
{validationResult.valid && (!validationResult.ai_warnings?.length) && (!validationResult.ai_suggestions?.length) && validationResult.ai_valid !== false && (
|
| 344 |
+
<p className="text-electric-400 font-medium">β Schema and AI review passed.</p>
|
| 345 |
+
)}
|
| 346 |
+
</>
|
| 347 |
+
)}
|
| 348 |
+
</div>
|
| 349 |
+
)}
|
| 350 |
+
<p className="text-sm text-void-300 mb-2">
|
| 351 |
+
Edit the JSON below. Keep the <code className="bg-void-700 text-void-100 px-1.5 py-0.5 rounded font-mono text-xs">segments</code> array structure. Use "Validate with AI" to check schema and content.
|
| 352 |
+
</p>
|
| 353 |
+
<textarea
|
| 354 |
+
value={editingPayloadJson}
|
| 355 |
+
onChange={(e) => { setEditingPayloadJson(e.target.value); setValidationResult(null); }}
|
| 356 |
+
className="flex-1 w-full p-4 font-mono text-sm text-void-100 bg-void-950 border border-void-600 rounded-xl resize-none min-h-[320px] placeholder-void-500 focus:border-coral-500/50 focus:ring-2 focus:ring-coral-500/20 focus:outline-none"
|
| 357 |
+
spellCheck={false}
|
| 358 |
+
placeholder='{"segments": [...]}'
|
| 359 |
+
/>
|
| 360 |
+
<div className="flex gap-2 mt-3">
|
| 361 |
+
<button
|
| 362 |
+
onClick={handleValidateWithAi}
|
| 363 |
+
disabled={validating}
|
| 364 |
+
className="px-4 py-2 bg-amber-500/20 text-amber-400 rounded-xl border border-amber-500/40 hover:bg-amber-500/30 disabled:opacity-50 text-sm font-medium transition-colors"
|
| 365 |
+
>
|
| 366 |
+
{validating ? 'Validatingβ¦' : 'Validate with AI'}
|
| 367 |
+
</button>
|
| 368 |
+
<button
|
| 369 |
+
onClick={handleSaveEdit}
|
| 370 |
+
disabled={saving}
|
| 371 |
+
className="px-4 py-2 bg-coral-500/20 text-coral-400 rounded-xl border border-coral-500/50 hover:bg-coral-500/30 disabled:opacity-50 text-sm font-medium transition-colors"
|
| 372 |
+
>
|
| 373 |
+
{saving ? 'Savingβ¦' : 'Save changes'}
|
| 374 |
+
</button>
|
| 375 |
+
<button
|
| 376 |
+
onClick={closeEditModal}
|
| 377 |
+
className="px-4 py-2 bg-void-800 text-void-300 rounded-xl border border-void-600 hover:bg-void-700 hover:text-void-100 text-sm font-medium transition-colors"
|
| 378 |
+
>
|
| 379 |
+
Cancel
|
| 380 |
+
</button>
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
)}
|
| 386 |
+
</div>
|
| 387 |
+
);
|
| 388 |
+
}
|
frontend/src/components/SegmentGenerationProgress.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
segmentsCount: number;
|
| 5 |
+
currentSegmentIndex?: number; // Optional for streaming mode
|
| 6 |
+
realProgress?: number; // Optional for streaming mode
|
| 7 |
+
generatedSegments?: any[]; // Optional for streaming mode
|
| 8 |
+
onCancel?: () => void;
|
| 9 |
+
isStreaming?: boolean; // If true, use real progress; if false, simulate
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const GENERATION_TIPS = [
|
| 13 |
+
"π‘ AI is analyzing your reference image for character details...",
|
| 14 |
+
"π¬ Creating detailed camera movements for cinematic quality...",
|
| 15 |
+
"π€ Generating character descriptions with exact physical features...",
|
| 16 |
+
"π Crafting micro-expressions and natural gestures...",
|
| 17 |
+
"π¨ Designing scene continuity for seamless video flow...",
|
| 18 |
+
"π― Ensuring dialogue syncs perfectly with actions...",
|
| 19 |
+
"β¨ Adding production-quality details to each segment...",
|
| 20 |
+
"π Cross-checking consistency across all segments...",
|
| 21 |
+
"πΎ Auto-saving your prompts for recovery...",
|
| 22 |
+
"πͺ Almost done! Finalizing segment specifications..."
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
export function SegmentGenerationProgress({ segmentsCount, onCancel }: Props) {
|
| 26 |
+
const [progress, setProgress] = useState(0);
|
| 27 |
+
const [currentTip, setCurrentTip] = useState(0);
|
| 28 |
+
const [elapsedTime, setElapsedTime] = useState(0);
|
| 29 |
+
const [estimatedTime] = useState(
|
| 30 |
+
segmentsCount <= 2 ? 20 : segmentsCount <= 5 ? 50 : 90
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
// Progress simulation (realistic timing based on actual API performance)
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
const interval = setInterval(() => {
|
| 36 |
+
setProgress(prev => {
|
| 37 |
+
// Slow start (first 30%), medium middle (30-80%), slow end (80-100%)
|
| 38 |
+
if (prev < 30) return prev + 0.5; // Slow start
|
| 39 |
+
if (prev < 80) return prev + 0.8; // Medium speed
|
| 40 |
+
if (prev < 95) return prev + 0.2; // Slow down near end
|
| 41 |
+
return prev; // Stop at 95% until actual completion
|
| 42 |
+
});
|
| 43 |
+
}, 1000);
|
| 44 |
+
|
| 45 |
+
return () => clearInterval(interval);
|
| 46 |
+
}, []);
|
| 47 |
+
|
| 48 |
+
// Tip rotation
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
const interval = setInterval(() => {
|
| 51 |
+
setCurrentTip(prev => (prev + 1) % GENERATION_TIPS.length);
|
| 52 |
+
}, 4000); // Change tip every 4 seconds
|
| 53 |
+
|
| 54 |
+
return () => clearInterval(interval);
|
| 55 |
+
}, []);
|
| 56 |
+
|
| 57 |
+
// Elapsed time counter
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
const interval = setInterval(() => {
|
| 60 |
+
setElapsedTime(prev => prev + 1);
|
| 61 |
+
}, 1000);
|
| 62 |
+
|
| 63 |
+
return () => clearInterval(interval);
|
| 64 |
+
}, []);
|
| 65 |
+
|
| 66 |
+
const formatTime = (seconds: number) => {
|
| 67 |
+
const mins = Math.floor(seconds / 60);
|
| 68 |
+
const secs = seconds % 60;
|
| 69 |
+
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="max-w-2xl mx-auto p-8 glass-dark rounded-2xl shadow-2xl border-2 border-void-700/50">
|
| 74 |
+
{/* Header */}
|
| 75 |
+
<div className="flex justify-between items-center mb-6">
|
| 76 |
+
<div className="flex items-center gap-2 bg-coral-500/20 px-4 py-2 rounded-full border border-coral-500/30">
|
| 77 |
+
<div className="w-2 h-2 bg-coral-400 rounded-full animate-pulse" />
|
| 78 |
+
<span className="text-sm font-medium text-void-100">Generating {segmentsCount} segments</span>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<div className="font-mono text-lg font-bold text-void-100">
|
| 82 |
+
<span className="text-coral-400">{formatTime(elapsedTime)}</span>
|
| 83 |
+
<span className="text-void-600 mx-1">/</span>
|
| 84 |
+
<span className="text-void-400">~{formatTime(estimatedTime)}</span>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
{/* Progress Bar */}
|
| 89 |
+
<div className="relative h-10 bg-void-900/80 border border-void-700 rounded-full overflow-hidden mb-8">
|
| 90 |
+
<div
|
| 91 |
+
className="h-full bg-gradient-to-r from-coral-500 to-electric-500 rounded-full transition-all duration-500 ease-out relative"
|
| 92 |
+
style={{ width: `${progress}%` }}
|
| 93 |
+
>
|
| 94 |
+
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
|
| 95 |
+
</div>
|
| 96 |
+
<span className="absolute inset-0 flex items-center justify-center font-bold text-void-100 drop-shadow-lg">
|
| 97 |
+
{Math.round(progress)}%
|
| 98 |
+
</span>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
{/* Generation Steps */}
|
| 102 |
+
<div className="grid grid-cols-4 gap-4 mb-8">
|
| 103 |
+
<GenerationStep
|
| 104 |
+
icon="π"
|
| 105 |
+
label="Analyzing"
|
| 106 |
+
status={progress < 20 ? 'active' : 'complete'}
|
| 107 |
+
/>
|
| 108 |
+
<GenerationStep
|
| 109 |
+
icon="π¬"
|
| 110 |
+
label="Creating"
|
| 111 |
+
status={progress < 20 ? 'pending' : progress < 80 ? 'active' : 'complete'}
|
| 112 |
+
/>
|
| 113 |
+
<GenerationStep
|
| 114 |
+
icon="β
"
|
| 115 |
+
label="Validating"
|
| 116 |
+
status={progress < 80 ? 'pending' : progress < 95 ? 'active' : 'complete'}
|
| 117 |
+
/>
|
| 118 |
+
<GenerationStep
|
| 119 |
+
icon="πΎ"
|
| 120 |
+
label="Saving"
|
| 121 |
+
status={progress < 95 ? 'pending' : 'active'}
|
| 122 |
+
/>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
{/* Rotating Tips */}
|
| 126 |
+
<div
|
| 127 |
+
key={currentTip}
|
| 128 |
+
className="bg-void-900/60 backdrop-blur-sm p-4 rounded-xl mb-8 flex items-center gap-3 min-h-[60px] animate-fade-in border border-void-700/50"
|
| 129 |
+
>
|
| 130 |
+
<span className="text-2xl">π‘</span>
|
| 131 |
+
<p className="text-sm leading-relaxed text-void-200">{GENERATION_TIPS[currentTip]}</p>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* Segments Preview */}
|
| 135 |
+
<div className="mb-6">
|
| 136 |
+
<h4 className="text-sm mb-3 text-void-300 font-semibold">Segments Being Generated:</h4>
|
| 137 |
+
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
|
| 138 |
+
{Array.from({ length: segmentsCount }).map((_, i) => (
|
| 139 |
+
<SegmentCard
|
| 140 |
+
key={i}
|
| 141 |
+
number={i + 1}
|
| 142 |
+
status={
|
| 143 |
+
progress > (i / segmentsCount) * 100 ? 'complete' :
|
| 144 |
+
progress > ((i - 0.5) / segmentsCount) * 100 ? 'generating' :
|
| 145 |
+
'pending'
|
| 146 |
+
}
|
| 147 |
+
/>
|
| 148 |
+
))}
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
{/* Auto-Save Indicator */}
|
| 153 |
+
<div className="flex items-center justify-center gap-2 mb-6 text-sm text-void-300">
|
| 154 |
+
<span className={progress < 95 ? '' : 'animate-spin'}>πΎ</span>
|
| 155 |
+
<span>Auto-saving prompts for recovery...</span>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{/* Cancel Button */}
|
| 159 |
+
{onCancel && (
|
| 160 |
+
<button
|
| 161 |
+
onClick={onCancel}
|
| 162 |
+
className="w-full py-3 bg-red-500/20 border border-red-500/50 rounded-lg hover:bg-red-500/30 transition-colors text-void-100 font-medium"
|
| 163 |
+
>
|
| 164 |
+
Cancel Generation
|
| 165 |
+
</button>
|
| 166 |
+
)}
|
| 167 |
+
</div>
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
function GenerationStep({ icon, label, status }: {
|
| 172 |
+
icon: string;
|
| 173 |
+
label: string;
|
| 174 |
+
status: 'pending' | 'active' | 'complete';
|
| 175 |
+
}) {
|
| 176 |
+
return (
|
| 177 |
+
<div className="flex flex-col items-center gap-2">
|
| 178 |
+
<div className={`
|
| 179 |
+
w-12 h-12 rounded-full flex items-center justify-center text-xl transition-all border-2
|
| 180 |
+
${status === 'complete' ? 'bg-electric-500/30 border-electric-400 shadow-lg shadow-electric-500/50' : ''}
|
| 181 |
+
${status === 'active' ? 'bg-coral-500/30 border-coral-400 shadow-lg shadow-coral-500/50 animate-pulse' : ''}
|
| 182 |
+
${status === 'pending' ? 'bg-void-900/50 border-void-700' : ''}
|
| 183 |
+
`}>
|
| 184 |
+
<span className={status === 'complete' ? 'text-electric-300' : status === 'active' ? 'text-coral-300' : 'text-void-500'}>
|
| 185 |
+
{status === 'complete' ? 'β' : icon}
|
| 186 |
+
</span>
|
| 187 |
+
</div>
|
| 188 |
+
<span className={`text-xs ${status === 'active' ? 'font-semibold text-coral-300' : status === 'complete' ? 'text-electric-300' : 'text-void-400'}`}>
|
| 189 |
+
{label}
|
| 190 |
+
</span>
|
| 191 |
+
</div>
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function SegmentCard({ number, status }: {
|
| 196 |
+
number: number;
|
| 197 |
+
status: 'pending' | 'generating' | 'complete';
|
| 198 |
+
}) {
|
| 199 |
+
return (
|
| 200 |
+
<div className={`
|
| 201 |
+
p-2 rounded-lg text-center transition-all border-2
|
| 202 |
+
${status === 'complete' ? 'bg-electric-500/20 border-electric-500/50 shadow-lg shadow-electric-500/20' : ''}
|
| 203 |
+
${status === 'generating' ? 'bg-coral-500/20 border-coral-500/50 shadow-lg shadow-coral-500/30 animate-pulse' : ''}
|
| 204 |
+
${status === 'pending' ? 'bg-void-900/50 border-void-700' : ''}
|
| 205 |
+
`}>
|
| 206 |
+
<div className={`font-bold text-xs ${status === 'complete' ? 'text-electric-300' : status === 'generating' ? 'text-coral-300' : 'text-void-400'}`}>
|
| 207 |
+
#{number}
|
| 208 |
+
</div>
|
| 209 |
+
<div className="text-lg">
|
| 210 |
+
{status === 'complete' && <span className="text-electric-400">β</span>}
|
| 211 |
+
{status === 'generating' && <span className="animate-spin inline-block">βοΈ</span>}
|
| 212 |
+
{status === 'pending' && <span className="text-void-500">β³</span>}
|
| 213 |
+
</div>
|
| 214 |
+
<div className={`text-[10px] font-medium ${status === 'complete' ? 'text-electric-400' : status === 'generating' ? 'text-coral-400' : 'text-void-500'}`}>
|
| 215 |
+
{status === 'complete' && 'Ready'}
|
| 216 |
+
{status === 'generating' && 'Queue'}
|
| 217 |
+
{status === 'pending' && 'Queue'}
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
);
|
| 221 |
+
}
|
frontend/src/components/SegmentPromptsViewer.tsx
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
import type { VeoSegment } from '@/types';
|
| 4 |
+
import { ChevronDownIcon, CopyIcon, CheckIcon } from './Icons';
|
| 5 |
+
|
| 6 |
+
interface SegmentPromptsViewerProps {
|
| 7 |
+
segments: VeoSegment[];
|
| 8 |
+
accentColor: 'coral' | 'electric';
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const SegmentPromptsViewer: React.FC<SegmentPromptsViewerProps> = ({
|
| 12 |
+
segments,
|
| 13 |
+
accentColor
|
| 14 |
+
}) => {
|
| 15 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 16 |
+
const [expandedSegments, setExpandedSegments] = useState<Set<number>>(new Set());
|
| 17 |
+
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
| 18 |
+
|
| 19 |
+
const toggleSegment = (index: number) => {
|
| 20 |
+
const newExpanded = new Set(expandedSegments);
|
| 21 |
+
if (newExpanded.has(index)) {
|
| 22 |
+
newExpanded.delete(index);
|
| 23 |
+
} else {
|
| 24 |
+
newExpanded.add(index);
|
| 25 |
+
}
|
| 26 |
+
setExpandedSegments(newExpanded);
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const expandAll = () => {
|
| 30 |
+
setExpandedSegments(new Set(segments.map((_, i) => i)));
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const collapseAll = () => {
|
| 34 |
+
setExpandedSegments(new Set());
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const copySegment = (segment: VeoSegment, index: number) => {
|
| 38 |
+
const formatted = JSON.stringify(segment, null, 2);
|
| 39 |
+
navigator.clipboard.writeText(formatted);
|
| 40 |
+
setCopiedIndex(index);
|
| 41 |
+
setTimeout(() => setCopiedIndex(null), 2000);
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const copyAllSegments = () => {
|
| 45 |
+
const formatted = JSON.stringify({ segments }, null, 2);
|
| 46 |
+
navigator.clipboard.writeText(formatted);
|
| 47 |
+
setCopiedIndex(-1);
|
| 48 |
+
setTimeout(() => setCopiedIndex(null), 2000);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
if (segments.length === 0) return null;
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="mb-8">
|
| 55 |
+
<button
|
| 56 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 57 |
+
className={`
|
| 58 |
+
w-full card border-2 transition-colors
|
| 59 |
+
${accentColor === 'coral'
|
| 60 |
+
? 'border-coral-500/30 hover:border-coral-500/50'
|
| 61 |
+
: 'border-electric-500/30 hover:border-electric-500/50'
|
| 62 |
+
}
|
| 63 |
+
`}
|
| 64 |
+
>
|
| 65 |
+
<div className="flex items-center justify-between">
|
| 66 |
+
<div className="flex items-center gap-3">
|
| 67 |
+
<div className={`p-2 rounded-lg ${accentColor === 'coral' ? 'bg-coral-500/20' : 'bg-electric-500/20'}`}>
|
| 68 |
+
<svg className={`w-5 h-5 ${accentColor === 'coral' ? 'text-coral-400' : 'text-electric-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 69 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
| 70 |
+
</svg>
|
| 71 |
+
</div>
|
| 72 |
+
<div className="text-left">
|
| 73 |
+
<h3 className="font-bold text-void-100">View Segment Prompts</h3>
|
| 74 |
+
<p className="text-sm text-void-400">
|
| 75 |
+
{segments.length} detailed AI-generated prompts
|
| 76 |
+
</p>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
<motion.div
|
| 80 |
+
animate={{ rotate: isOpen ? 180 : 0 }}
|
| 81 |
+
transition={{ duration: 0.2 }}
|
| 82 |
+
>
|
| 83 |
+
<ChevronDownIcon size={24} className="text-void-400" />
|
| 84 |
+
</motion.div>
|
| 85 |
+
</div>
|
| 86 |
+
</button>
|
| 87 |
+
|
| 88 |
+
<AnimatePresence>
|
| 89 |
+
{isOpen && (
|
| 90 |
+
<motion.div
|
| 91 |
+
initial={{ opacity: 0, height: 0 }}
|
| 92 |
+
animate={{ opacity: 1, height: 'auto' }}
|
| 93 |
+
exit={{ opacity: 0, height: 0 }}
|
| 94 |
+
transition={{ duration: 0.3 }}
|
| 95 |
+
className="overflow-hidden"
|
| 96 |
+
>
|
| 97 |
+
<div className="mt-4 space-y-4">
|
| 98 |
+
{/* Controls */}
|
| 99 |
+
<div className="flex items-center justify-between gap-3 flex-wrap">
|
| 100 |
+
<div className="flex gap-2">
|
| 101 |
+
<button
|
| 102 |
+
onClick={expandAll}
|
| 103 |
+
className="btn-secondary-sm"
|
| 104 |
+
>
|
| 105 |
+
Expand All
|
| 106 |
+
</button>
|
| 107 |
+
<button
|
| 108 |
+
onClick={collapseAll}
|
| 109 |
+
className="btn-secondary-sm"
|
| 110 |
+
>
|
| 111 |
+
Collapse All
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
<button
|
| 115 |
+
onClick={copyAllSegments}
|
| 116 |
+
className={`
|
| 117 |
+
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
| 118 |
+
${accentColor === 'coral'
|
| 119 |
+
? 'bg-coral-500/10 text-coral-400 hover:bg-coral-500/20'
|
| 120 |
+
: 'bg-electric-500/10 text-electric-400 hover:bg-electric-500/20'
|
| 121 |
+
}
|
| 122 |
+
`}
|
| 123 |
+
>
|
| 124 |
+
{copiedIndex === -1 ? (
|
| 125 |
+
<>
|
| 126 |
+
<CheckIcon size={16} />
|
| 127 |
+
Copied All!
|
| 128 |
+
</>
|
| 129 |
+
) : (
|
| 130 |
+
<>
|
| 131 |
+
<CopyIcon size={16} />
|
| 132 |
+
Copy All JSON
|
| 133 |
+
</>
|
| 134 |
+
)}
|
| 135 |
+
</button>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
{/* Segment Cards */}
|
| 139 |
+
<div className="space-y-3">
|
| 140 |
+
{segments.map((segment, index) => {
|
| 141 |
+
const isExpanded = expandedSegments.has(index);
|
| 142 |
+
|
| 143 |
+
return (
|
| 144 |
+
<div
|
| 145 |
+
key={index}
|
| 146 |
+
className="card border border-void-700 hover:border-void-600 transition-colors"
|
| 147 |
+
>
|
| 148 |
+
{/* Segment Header */}
|
| 149 |
+
<div className="flex items-start justify-between gap-4">
|
| 150 |
+
<button
|
| 151 |
+
onClick={() => toggleSegment(index)}
|
| 152 |
+
className="flex-1 text-left"
|
| 153 |
+
>
|
| 154 |
+
<div className="flex items-center gap-3 mb-2">
|
| 155 |
+
<span className={`
|
| 156 |
+
px-2.5 py-0.5 rounded-full text-xs font-bold
|
| 157 |
+
${accentColor === 'coral'
|
| 158 |
+
? 'bg-coral-500/20 text-coral-400'
|
| 159 |
+
: 'bg-electric-500/20 text-electric-400'
|
| 160 |
+
}
|
| 161 |
+
`}>
|
| 162 |
+
Segment {index + 1}
|
| 163 |
+
</span>
|
| 164 |
+
<motion.div
|
| 165 |
+
animate={{ rotate: isExpanded ? 180 : 0 }}
|
| 166 |
+
transition={{ duration: 0.2 }}
|
| 167 |
+
>
|
| 168 |
+
<ChevronDownIcon size={16} className="text-void-400" />
|
| 169 |
+
</motion.div>
|
| 170 |
+
</div>
|
| 171 |
+
<p className="text-sm text-void-300 line-clamp-2">
|
| 172 |
+
{segment.action_timeline?.dialogue || 'No dialogue'}
|
| 173 |
+
</p>
|
| 174 |
+
</button>
|
| 175 |
+
<button
|
| 176 |
+
onClick={() => copySegment(segment, index)}
|
| 177 |
+
className="p-2 rounded-lg hover:bg-void-800 transition-colors text-void-400 hover:text-void-200"
|
| 178 |
+
title="Copy segment JSON"
|
| 179 |
+
>
|
| 180 |
+
{copiedIndex === index ? (
|
| 181 |
+
<CheckIcon size={16} className="text-green-400" />
|
| 182 |
+
) : (
|
| 183 |
+
<CopyIcon size={16} />
|
| 184 |
+
)}
|
| 185 |
+
</button>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
{/* Expanded Content */}
|
| 189 |
+
<AnimatePresence>
|
| 190 |
+
{isExpanded && (
|
| 191 |
+
<motion.div
|
| 192 |
+
initial={{ opacity: 0, height: 0 }}
|
| 193 |
+
animate={{ opacity: 1, height: 'auto' }}
|
| 194 |
+
exit={{ opacity: 0, height: 0 }}
|
| 195 |
+
className="overflow-hidden"
|
| 196 |
+
>
|
| 197 |
+
<div className="mt-4 pt-4 border-t border-void-700 space-y-4 text-sm">
|
| 198 |
+
{/* Dialogue */}
|
| 199 |
+
<div>
|
| 200 |
+
<h4 className="font-semibold text-void-200 mb-2">Dialogue</h4>
|
| 201 |
+
<p className="text-void-400 bg-void-900/50 p-3 rounded-lg">
|
| 202 |
+
"{segment.action_timeline?.dialogue}"
|
| 203 |
+
</p>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
{/* Character Description */}
|
| 207 |
+
<div>
|
| 208 |
+
<h4 className="font-semibold text-void-200 mb-2">Character</h4>
|
| 209 |
+
<div className="space-y-2">
|
| 210 |
+
<div>
|
| 211 |
+
<span className="text-xs text-void-500 uppercase tracking-wide">Current State:</span>
|
| 212 |
+
<p className="text-void-400 bg-void-900/50 p-2 rounded mt-1 text-xs leading-relaxed">
|
| 213 |
+
{segment.character_description?.current_state}
|
| 214 |
+
</p>
|
| 215 |
+
</div>
|
| 216 |
+
<div>
|
| 217 |
+
<span className="text-xs text-void-500 uppercase tracking-wide">Voice Matching:</span>
|
| 218 |
+
<p className="text-void-400 bg-void-900/50 p-2 rounded mt-1 text-xs leading-relaxed">
|
| 219 |
+
{segment.character_description?.voice_matching}
|
| 220 |
+
</p>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
{/* Scene Continuity */}
|
| 226 |
+
<div>
|
| 227 |
+
<h4 className="font-semibold text-void-200 mb-2">Scene</h4>
|
| 228 |
+
<div className="space-y-2">
|
| 229 |
+
<div>
|
| 230 |
+
<span className="text-xs text-void-500 uppercase tracking-wide">Environment:</span>
|
| 231 |
+
<p className="text-void-400 bg-void-900/50 p-2 rounded mt-1 text-xs leading-relaxed">
|
| 232 |
+
{segment.scene_continuity?.environment}
|
| 233 |
+
</p>
|
| 234 |
+
</div>
|
| 235 |
+
<div>
|
| 236 |
+
<span className="text-xs text-void-500 uppercase tracking-wide">Camera:</span>
|
| 237 |
+
<p className="text-void-400 bg-void-900/50 p-2 rounded mt-1 text-xs leading-relaxed">
|
| 238 |
+
{segment.scene_continuity?.camera_position} β’ {segment.scene_continuity?.camera_movement}
|
| 239 |
+
</p>
|
| 240 |
+
</div>
|
| 241 |
+
<div>
|
| 242 |
+
<span className="text-xs text-void-500 uppercase tracking-wide">Lighting:</span>
|
| 243 |
+
<p className="text-void-400 bg-void-900/50 p-2 rounded mt-1 text-xs leading-relaxed">
|
| 244 |
+
{segment.scene_continuity?.lighting_state}
|
| 245 |
+
</p>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{/* Synchronized Actions */}
|
| 251 |
+
<div>
|
| 252 |
+
<h4 className="font-semibold text-void-200 mb-2">Timeline</h4>
|
| 253 |
+
<div className="space-y-1.5">
|
| 254 |
+
{Object.entries(segment.action_timeline?.synchronized_actions || {}).map(([time, action]) => (
|
| 255 |
+
<div key={time} className="flex gap-3">
|
| 256 |
+
<span className={`
|
| 257 |
+
text-xs font-mono px-2 py-1 rounded
|
| 258 |
+
${accentColor === 'coral'
|
| 259 |
+
? 'bg-coral-500/10 text-coral-400'
|
| 260 |
+
: 'bg-electric-500/10 text-electric-400'
|
| 261 |
+
}
|
| 262 |
+
`}>
|
| 263 |
+
{time}
|
| 264 |
+
</span>
|
| 265 |
+
<p className="text-void-400 text-xs flex-1">
|
| 266 |
+
{action}
|
| 267 |
+
</p>
|
| 268 |
+
</div>
|
| 269 |
+
))}
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
{/* Segment Info */}
|
| 274 |
+
<div className="pt-3 border-t border-void-800">
|
| 275 |
+
<div className="grid grid-cols-2 gap-3 text-xs">
|
| 276 |
+
<div>
|
| 277 |
+
<span className="text-void-500">Duration:</span>
|
| 278 |
+
<span className="text-void-300 ml-2 font-medium">
|
| 279 |
+
{segment.segment_info?.duration}
|
| 280 |
+
</span>
|
| 281 |
+
</div>
|
| 282 |
+
<div>
|
| 283 |
+
<span className="text-void-500">Location:</span>
|
| 284 |
+
<span className="text-void-300 ml-2 font-medium">
|
| 285 |
+
{segment.segment_info?.location}
|
| 286 |
+
</span>
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
</motion.div>
|
| 292 |
+
)}
|
| 293 |
+
</AnimatePresence>
|
| 294 |
+
</div>
|
| 295 |
+
);
|
| 296 |
+
})}
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</motion.div>
|
| 300 |
+
)}
|
| 301 |
+
</AnimatePresence>
|
| 302 |
+
</div>
|
| 303 |
+
);
|
| 304 |
+
};
|
frontend/src/components/index.ts
CHANGED
|
@@ -5,4 +5,7 @@ export * from './GenerationProgress';
|
|
| 5 |
export * from './GenerationComplete';
|
| 6 |
export * from './ErrorDisplay';
|
| 7 |
export * from './Login';
|
|
|
|
|
|
|
|
|
|
| 8 |
|
|
|
|
| 5 |
export * from './GenerationComplete';
|
| 6 |
export * from './ErrorDisplay';
|
| 7 |
export * from './Login';
|
| 8 |
+
export * from './SegmentGenerationProgress';
|
| 9 |
+
export * from './SavedPromptsLibrary';
|
| 10 |
+
export * from './SegmentPromptsViewer';
|
| 11 |
|
frontend/src/context/GenerationContext.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
|
| 2 |
import type {
|
| 3 |
GenerationState,
|
| 4 |
GenerationStep,
|
|
@@ -24,6 +24,7 @@ const initialState: GenerationState = {
|
|
| 24 |
retryState: null,
|
| 25 |
activeTaskIds: [] as string[],
|
| 26 |
isCancelling: false,
|
|
|
|
| 27 |
};
|
| 28 |
|
| 29 |
// Action types
|
|
@@ -36,10 +37,11 @@ type GenerationAction =
|
|
| 36 |
| { type: 'SET_PROGRESS'; payload: { current?: number; total?: number; message?: string } }
|
| 37 |
| { type: 'SET_ERROR'; payload: string | null }
|
| 38 |
| { type: 'SET_TASK_ID'; payload: string | null }
|
| 39 |
-
| { type: 'SET_RETRY_STATE'; payload: { failedSegmentIndex: number; error: string } | null }
|
| 40 |
| { type: 'ADD_TASK_ID'; payload: string }
|
| 41 |
| { type: 'REMOVE_TASK_ID'; payload: string }
|
| 42 |
| { type: 'SET_CANCELLING'; payload: boolean }
|
|
|
|
| 43 |
| { type: 'RESET' };
|
| 44 |
|
| 45 |
// Reducer
|
|
@@ -75,6 +77,8 @@ function generationReducer(state: GenerationState, action: GenerationAction): Ge
|
|
| 75 |
return { ...state, activeTaskIds: state.activeTaskIds.filter(id => id !== action.payload) };
|
| 76 |
case 'SET_CANCELLING':
|
| 77 |
return { ...state, isCancelling: action.payload };
|
|
|
|
|
|
|
| 78 |
case 'RESET':
|
| 79 |
return { ...initialState, provider: state.provider };
|
| 80 |
default:
|
|
@@ -95,11 +99,13 @@ interface GenerationContextValue {
|
|
| 95 |
addVideo: (video: GeneratedVideo) => void;
|
| 96 |
updateProgress: (message: string, current?: number, total?: number) => void;
|
| 97 |
setError: (error: string | null) => void;
|
| 98 |
-
setRetryState: (state: { failedSegmentIndex: number; error: string } | null) => void;
|
| 99 |
updateSegments: (segments: VeoSegment[]) => void;
|
| 100 |
addTaskId: (taskId: string) => void;
|
| 101 |
removeTaskId: (taskId: string) => void;
|
|
|
|
| 102 |
cancelGeneration: () => Promise<void>;
|
|
|
|
| 103 |
reset: () => void;
|
| 104 |
}
|
| 105 |
|
|
@@ -108,6 +114,9 @@ const GenerationContext = createContext<GenerationContextValue | null>(null);
|
|
| 108 |
// Provider component
|
| 109 |
export function GenerationProvider({ children }: { children: ReactNode }) {
|
| 110 |
const [state, dispatch] = useReducer(generationReducer, initialState);
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
const value: GenerationContextValue = {
|
| 113 |
state,
|
|
@@ -170,25 +179,55 @@ export function GenerationProvider({ children }: { children: ReactNode }) {
|
|
| 170 |
dispatch({ type: 'REMOVE_TASK_ID', payload: taskId });
|
| 171 |
},
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
cancelGeneration: async () => {
|
| 174 |
dispatch({ type: 'SET_CANCELLING', payload: true });
|
| 175 |
try {
|
| 176 |
-
const
|
| 177 |
-
|
| 178 |
-
const
|
| 179 |
-
const
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
} catch (error) {
|
| 193 |
console.error('Error cancelling generation:', error);
|
| 194 |
dispatch({ type: 'SET_ERROR', payload: 'Failed to cancel generation' });
|
|
|
|
| 1 |
+
import React, { createContext, useContext, useReducer, useRef, ReactNode } from 'react';
|
| 2 |
import type {
|
| 3 |
GenerationState,
|
| 4 |
GenerationStep,
|
|
|
|
| 24 |
retryState: null,
|
| 25 |
activeTaskIds: [] as string[],
|
| 26 |
isCancelling: false,
|
| 27 |
+
partialCompletionError: null,
|
| 28 |
};
|
| 29 |
|
| 30 |
// Action types
|
|
|
|
| 37 |
| { type: 'SET_PROGRESS'; payload: { current?: number; total?: number; message?: string } }
|
| 38 |
| { type: 'SET_ERROR'; payload: string | null }
|
| 39 |
| { type: 'SET_TASK_ID'; payload: string | null }
|
| 40 |
+
| { type: 'SET_RETRY_STATE'; payload: { failedSegmentIndex: number; error: string; attemptCount: number } | null }
|
| 41 |
| { type: 'ADD_TASK_ID'; payload: string }
|
| 42 |
| { type: 'REMOVE_TASK_ID'; payload: string }
|
| 43 |
| { type: 'SET_CANCELLING'; payload: boolean }
|
| 44 |
+
| { type: 'SET_PARTIAL_COMPLETION_ERROR'; payload: string | null }
|
| 45 |
| { type: 'RESET' };
|
| 46 |
|
| 47 |
// Reducer
|
|
|
|
| 77 |
return { ...state, activeTaskIds: state.activeTaskIds.filter(id => id !== action.payload) };
|
| 78 |
case 'SET_CANCELLING':
|
| 79 |
return { ...state, isCancelling: action.payload };
|
| 80 |
+
case 'SET_PARTIAL_COMPLETION_ERROR':
|
| 81 |
+
return { ...state, partialCompletionError: action.payload };
|
| 82 |
case 'RESET':
|
| 83 |
return { ...initialState, provider: state.provider };
|
| 84 |
default:
|
|
|
|
| 99 |
addVideo: (video: GeneratedVideo) => void;
|
| 100 |
updateProgress: (message: string, current?: number, total?: number) => void;
|
| 101 |
setError: (error: string | null) => void;
|
| 102 |
+
setRetryState: (state: { failedSegmentIndex: number; error: string; attemptCount: number } | null) => void;
|
| 103 |
updateSegments: (segments: VeoSegment[]) => void;
|
| 104 |
addTaskId: (taskId: string) => void;
|
| 105 |
removeTaskId: (taskId: string) => void;
|
| 106 |
+
registerPromptAbortController: (controller: AbortController | null) => void;
|
| 107 |
cancelGeneration: () => Promise<void>;
|
| 108 |
+
setPartialCompletionError: (error: string | null) => void;
|
| 109 |
reset: () => void;
|
| 110 |
}
|
| 111 |
|
|
|
|
| 114 |
// Provider component
|
| 115 |
export function GenerationProvider({ children }: { children: ReactNode }) {
|
| 116 |
const [state, dispatch] = useReducer(generationReducer, initialState);
|
| 117 |
+
const promptAbortRef = useRef<AbortController | null>(null);
|
| 118 |
+
const stateRef = useRef(state);
|
| 119 |
+
stateRef.current = state;
|
| 120 |
|
| 121 |
const value: GenerationContextValue = {
|
| 122 |
state,
|
|
|
|
| 179 |
dispatch({ type: 'REMOVE_TASK_ID', payload: taskId });
|
| 180 |
},
|
| 181 |
|
| 182 |
+
setPartialCompletionError: (error) => {
|
| 183 |
+
dispatch({ type: 'SET_PARTIAL_COMPLETION_ERROR', payload: error });
|
| 184 |
+
},
|
| 185 |
+
|
| 186 |
+
registerPromptAbortController: (controller) => {
|
| 187 |
+
promptAbortRef.current = controller;
|
| 188 |
+
},
|
| 189 |
+
|
| 190 |
cancelGeneration: async () => {
|
| 191 |
dispatch({ type: 'SET_CANCELLING', payload: true });
|
| 192 |
try {
|
| 193 |
+
const s = stateRef.current;
|
| 194 |
+
const currentStep = s.step;
|
| 195 |
+
const segmentCount = s.segments.length;
|
| 196 |
+
const videoCount = s.generatedVideos.length;
|
| 197 |
+
|
| 198 |
+
if (currentStep === 'generating_prompts') {
|
| 199 |
+
if (promptAbortRef.current) {
|
| 200 |
+
promptAbortRef.current.abort();
|
| 201 |
+
promptAbortRef.current = null;
|
| 202 |
+
}
|
| 203 |
+
const msg = segmentCount > 0
|
| 204 |
+
? `Generation cancelled. ${segmentCount} segment prompt${segmentCount === 1 ? '' : 's'} generated.`
|
| 205 |
+
: 'Generation cancelled. No segment prompts generated yet.';
|
| 206 |
+
dispatch({ type: 'SET_ERROR', payload: msg });
|
| 207 |
+
dispatch({ type: 'SET_STEP', payload: 'error' });
|
| 208 |
+
return;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
if (currentStep === 'generating_video' || currentStep === 'processing') {
|
| 212 |
+
const { klingCancel } = await import('@/utils/api');
|
| 213 |
+
const currentTaskIds = [...s.activeTaskIds];
|
| 214 |
+
const cancelPromises = currentTaskIds.map(taskId =>
|
| 215 |
+
klingCancel(taskId).catch(err => {
|
| 216 |
+
console.warn(`Failed to cancel task ${taskId}:`, err);
|
| 217 |
+
})
|
| 218 |
+
);
|
| 219 |
+
await Promise.all(cancelPromises);
|
| 220 |
+
currentTaskIds.forEach(id => {
|
| 221 |
+
dispatch({ type: 'REMOVE_TASK_ID', payload: id });
|
| 222 |
+
});
|
| 223 |
+
dispatch({ type: 'SET_TASK_ID', payload: null });
|
| 224 |
+
const msg = videoCount > 0
|
| 225 |
+
? `Generation cancelled. ${videoCount} video segment${videoCount === 1 ? '' : 's'} generated.`
|
| 226 |
+
: 'Generation cancelled by user.';
|
| 227 |
+
dispatch({ type: 'SET_ERROR', payload: msg });
|
| 228 |
+
dispatch({ type: 'SET_STEP', payload: 'error' });
|
| 229 |
+
return;
|
| 230 |
+
}
|
| 231 |
} catch (error) {
|
| 232 |
console.error('Error cancelling generation:', error);
|
| 233 |
dispatch({ type: 'SET_ERROR', payload: 'Failed to cancel generation' });
|
frontend/src/types/index.ts
CHANGED
|
@@ -108,12 +108,8 @@ export interface CharacterDescription {
|
|
| 108 |
voice_matching: string;
|
| 109 |
}
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
'0:02-0:04': string;
|
| 114 |
-
'0:04-0:06': string;
|
| 115 |
-
'0:06-0:08': string;
|
| 116 |
-
}
|
| 117 |
|
| 118 |
export interface ActionTimeline {
|
| 119 |
dialogue: string;
|
|
@@ -188,9 +184,11 @@ export interface GenerationState {
|
|
| 188 |
retryState: {
|
| 189 |
failedSegmentIndex: number;
|
| 190 |
error: string;
|
|
|
|
| 191 |
} | null;
|
| 192 |
activeTaskIds: string[];
|
| 193 |
isCancelling: boolean;
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
export interface GeneratedVideo {
|
|
|
|
| 108 |
voice_matching: string;
|
| 109 |
}
|
| 110 |
|
| 111 |
+
/** 4s: 2 keys, 6s: 3 keys, 8s: 4 keys (e.g. "0:00-0:02", "0:02-0:04", ...) */
|
| 112 |
+
export type SynchronizedActions = Record<string, string>;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
export interface ActionTimeline {
|
| 115 |
dialogue: string;
|
|
|
|
| 184 |
retryState: {
|
| 185 |
failedSegmentIndex: number;
|
| 186 |
error: string;
|
| 187 |
+
attemptCount: number; // Track retry attempts: 0 = first try, 1 = auto-retry, 2+ = manual retry
|
| 188 |
} | null;
|
| 189 |
activeTaskIds: string[];
|
| 190 |
isCancelling: boolean;
|
| 191 |
+
partialCompletionError: string | null; // Error message for partial completion (some segments succeeded, others failed)
|
| 192 |
}
|
| 193 |
|
| 194 |
export interface GeneratedVideo {
|
frontend/src/utils/api.ts
CHANGED
|
@@ -5,9 +5,16 @@ import type {
|
|
| 5 |
ExtractedFrame,
|
| 6 |
LoginRequest,
|
| 7 |
LoginResponse,
|
| 8 |
-
AuthUser
|
|
|
|
| 9 |
} from '@/types';
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000';
|
| 12 |
|
| 13 |
// Get auth token from localStorage
|
|
@@ -25,6 +32,40 @@ export function removeAuthToken(): void {
|
|
| 25 |
localStorage.removeItem('auth_token');
|
| 26 |
}
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
// Generic API request handler
|
| 29 |
async function apiRequest<T>(
|
| 30 |
path: string,
|
|
@@ -62,64 +103,16 @@ async function apiRequest<T>(
|
|
| 62 |
});
|
| 63 |
|
| 64 |
if (!response.ok) {
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
removeAuthToken();
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
let errorMessage = `Request failed with status ${response.status}`;
|
| 71 |
-
|
| 72 |
-
// Try to extract error message from response
|
| 73 |
-
const contentType = response.headers.get('content-type');
|
| 74 |
-
const isJson = contentType && contentType.includes('application/json');
|
| 75 |
-
|
| 76 |
-
try {
|
| 77 |
-
if (isJson) {
|
| 78 |
-
const errorData = await response.json();
|
| 79 |
-
// Try multiple common error message fields
|
| 80 |
-
errorMessage = errorData.detail ||
|
| 81 |
-
errorData.message ||
|
| 82 |
-
errorData.error ||
|
| 83 |
-
(typeof errorData === 'string' ? errorData : errorMessage);
|
| 84 |
-
} else {
|
| 85 |
-
const text = await response.text();
|
| 86 |
-
if (text && text.trim()) {
|
| 87 |
-
errorMessage = text;
|
| 88 |
-
} else {
|
| 89 |
-
// Fall back to default message based on status code
|
| 90 |
-
if (response.status === 401) {
|
| 91 |
-
errorMessage = 'Incorrect username or password.';
|
| 92 |
-
} else if (response.status === 403) {
|
| 93 |
-
errorMessage = 'Access forbidden.';
|
| 94 |
-
} else if (response.status === 404) {
|
| 95 |
-
errorMessage = 'Resource not found.';
|
| 96 |
-
} else if (response.status >= 500) {
|
| 97 |
-
errorMessage = 'Server error. Please try again later.';
|
| 98 |
-
}
|
| 99 |
-
}
|
| 100 |
-
}
|
| 101 |
-
} catch (parseError) {
|
| 102 |
-
// If parsing fails, use status-based default
|
| 103 |
-
if (response.status === 401) {
|
| 104 |
-
errorMessage = 'Incorrect username or password.';
|
| 105 |
-
} else if (response.status === 403) {
|
| 106 |
-
errorMessage = 'Access forbidden.';
|
| 107 |
-
} else if (response.status === 404) {
|
| 108 |
-
errorMessage = 'Resource not found.';
|
| 109 |
-
} else if (response.status >= 500) {
|
| 110 |
-
errorMessage = 'Server error. Please try again later.';
|
| 111 |
-
}
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
const error = new Error(errorMessage);
|
| 115 |
console.error('API Error:', {
|
| 116 |
status: response.status,
|
| 117 |
statusText: response.statusText,
|
| 118 |
message: errorMessage,
|
| 119 |
-
url
|
| 120 |
-
contentType:
|
| 121 |
});
|
| 122 |
-
throw
|
| 123 |
}
|
| 124 |
|
| 125 |
return response.json();
|
|
@@ -191,19 +184,11 @@ export interface KlingGenerateResponse {
|
|
| 191 |
}
|
| 192 |
|
| 193 |
export async function klingGenerate(params: KlingGenerateParams): Promise<KlingGenerateResponse> {
|
| 194 |
-
return
|
| 195 |
-
method: 'POST',
|
| 196 |
-
headers: { 'Content-Type': 'application/json' },
|
| 197 |
-
body: JSON.stringify(params),
|
| 198 |
-
});
|
| 199 |
}
|
| 200 |
|
| 201 |
export async function klingExtend(taskId: string, prompt: string | object, seeds?: number, voiceType?: string): Promise<KlingGenerateResponse> {
|
| 202 |
-
return
|
| 203 |
-
method: 'POST',
|
| 204 |
-
headers: { 'Content-Type': 'application/json' },
|
| 205 |
-
body: JSON.stringify({ taskId, prompt, seeds, voiceType }),
|
| 206 |
-
});
|
| 207 |
}
|
| 208 |
|
| 209 |
export async function klingGetStatus(taskId: string): Promise<VideoStatusResponse> {
|
|
@@ -237,11 +222,7 @@ export interface ReplicateGenerateResponse {
|
|
| 237 |
}
|
| 238 |
|
| 239 |
export async function replicateGenerate(params: ReplicateGenerateParams): Promise<ReplicateGenerateResponse> {
|
| 240 |
-
return
|
| 241 |
-
method: 'POST',
|
| 242 |
-
headers: { 'Content-Type': 'application/json' },
|
| 243 |
-
body: JSON.stringify(params),
|
| 244 |
-
});
|
| 245 |
}
|
| 246 |
|
| 247 |
export async function replicateGetStatus(predictionId: string): Promise<VideoStatusResponse> {
|
|
@@ -274,24 +255,124 @@ export async function waitForReplicateVideo(
|
|
| 274 |
|
| 275 |
// ==================== PROMPT GENERATION ====================
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
method: 'POST',
|
| 280 |
body: formData,
|
|
|
|
| 281 |
});
|
| 282 |
-
|
| 283 |
if (!response.ok) {
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
}
|
| 291 |
-
|
|
|
|
| 292 |
}
|
| 293 |
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
}
|
| 296 |
|
| 297 |
export async function refinePromptContinuity(
|
|
@@ -310,11 +391,16 @@ export async function refinePromptContinuity(
|
|
| 310 |
|
| 311 |
// ==================== IMAGE UPLOAD ====================
|
| 312 |
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
const formData = new FormData();
|
| 315 |
formData.append('file', file);
|
|
|
|
| 316 |
|
| 317 |
-
return apiRequest<{ url: string; filename: string }>(
|
| 318 |
method: 'POST',
|
| 319 |
body: formData,
|
| 320 |
});
|
|
@@ -335,11 +421,7 @@ export interface ExtractFramesResponse {
|
|
| 335 |
}
|
| 336 |
|
| 337 |
export async function extractFrames(params: ExtractFramesParams): Promise<ExtractFramesResponse> {
|
| 338 |
-
return
|
| 339 |
-
method: 'POST',
|
| 340 |
-
headers: { 'Content-Type': 'application/json' },
|
| 341 |
-
body: JSON.stringify(params),
|
| 342 |
-
});
|
| 343 |
}
|
| 344 |
|
| 345 |
// ==================== WHISPER ANALYSIS ====================
|
|
@@ -367,11 +449,7 @@ export interface WhisperAnalyzeResponse {
|
|
| 367 |
* This is the optimized flow that combines Whisper analysis and frame extraction.
|
| 368 |
*/
|
| 369 |
export async function whisperAnalyzeAndExtract(params: WhisperAnalyzeParams): Promise<WhisperAnalyzeResponse> {
|
| 370 |
-
return
|
| 371 |
-
method: 'POST',
|
| 372 |
-
headers: { 'Content-Type': 'application/json' },
|
| 373 |
-
body: JSON.stringify(params),
|
| 374 |
-
});
|
| 375 |
}
|
| 376 |
|
| 377 |
/**
|
|
@@ -389,17 +467,10 @@ export async function refinePromptWithContext(
|
|
| 389 |
formData.append('lastFrame', frameFile);
|
| 390 |
formData.append('transcribedDialogue', transcribedDialogue);
|
| 391 |
formData.append('expectedDialogue', expectedDialogue);
|
| 392 |
-
|
| 393 |
-
const response = await fetch(`${API_BASE}/api/refine-prompt-continuity`, {
|
| 394 |
method: 'POST',
|
| 395 |
body: formData,
|
| 396 |
});
|
| 397 |
-
|
| 398 |
-
if (!response.ok) {
|
| 399 |
-
throw new Error(`Failed to refine prompt: ${response.status}`);
|
| 400 |
-
}
|
| 401 |
-
|
| 402 |
-
return response.json();
|
| 403 |
}
|
| 404 |
|
| 405 |
/**
|
|
@@ -423,15 +494,13 @@ export async function downloadVideo(url: string): Promise<Blob> {
|
|
| 423 |
|
| 424 |
// ==================== UTILITIES ====================
|
| 425 |
|
| 426 |
-
|
|
|
|
| 427 |
return new Promise((resolve, reject) => {
|
| 428 |
const video = document.createElement('video');
|
| 429 |
video.preload = 'metadata';
|
| 430 |
video.src = URL.createObjectURL(file);
|
| 431 |
-
video.onloadedmetadata = () =>
|
| 432 |
-
URL.revokeObjectURL(video.src);
|
| 433 |
-
resolve(video.duration);
|
| 434 |
-
};
|
| 435 |
video.onerror = () => {
|
| 436 |
URL.revokeObjectURL(video.src);
|
| 437 |
reject(new Error('Failed to load video metadata'));
|
|
@@ -439,54 +508,43 @@ export async function getVideoDuration(file: File): Promise<number> {
|
|
| 439 |
});
|
| 440 |
}
|
| 441 |
|
| 442 |
-
export async function
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
video.
|
| 446 |
-
|
| 447 |
-
video.
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
const duration = video.duration;
|
| 451 |
-
const thumbnails: string[] = [];
|
| 452 |
-
const canvas = document.createElement('canvas');
|
| 453 |
-
const ctx = canvas.getContext('2d');
|
| 454 |
-
|
| 455 |
-
if (!ctx) {
|
| 456 |
-
URL.revokeObjectURL(video.src);
|
| 457 |
-
reject(new Error('Could not get canvas context'));
|
| 458 |
-
return;
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
-
// Use video's actual dimensions for proper aspect ratio
|
| 462 |
-
// Scale down while maintaining aspect ratio (max 400px on longest side)
|
| 463 |
-
const maxSize = 400;
|
| 464 |
-
const videoWidth = video.videoWidth || 1080;
|
| 465 |
-
const videoHeight = video.videoHeight || 1920;
|
| 466 |
-
const scale = Math.min(maxSize / videoWidth, maxSize / videoHeight);
|
| 467 |
-
|
| 468 |
-
canvas.width = Math.round(videoWidth * scale);
|
| 469 |
-
canvas.height = Math.round(videoHeight * scale);
|
| 470 |
-
|
| 471 |
-
for (let i = 0; i < count; i++) {
|
| 472 |
-
const time = (duration / count) * i;
|
| 473 |
-
video.currentTime = time;
|
| 474 |
-
await new Promise<void>((res) => {
|
| 475 |
-
video.onseeked = () => res();
|
| 476 |
-
});
|
| 477 |
-
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 478 |
-
thumbnails.push(canvas.toDataURL('image/jpeg', 0.85));
|
| 479 |
-
}
|
| 480 |
-
|
| 481 |
-
URL.revokeObjectURL(video.src);
|
| 482 |
-
resolve(thumbnails);
|
| 483 |
-
};
|
| 484 |
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
}
|
| 491 |
|
| 492 |
// Wait for video completion using SSE
|
|
@@ -543,39 +601,168 @@ export async function generateVideoWithRetry(
|
|
| 543 |
onRetry?: (attempt: number) => void
|
| 544 |
): Promise<string> {
|
| 545 |
let lastError: Error | null = null;
|
| 546 |
-
|
| 547 |
-
for (let attempt = 0; attempt <
|
| 548 |
try {
|
| 549 |
if (attempt > 0) {
|
| 550 |
-
console.log(`π Retrying video generation (attempt ${attempt + 1}/
|
| 551 |
if (onRetry) {
|
| 552 |
onRetry(attempt + 1);
|
| 553 |
}
|
| 554 |
}
|
| 555 |
-
|
| 556 |
const result = await generateFn();
|
| 557 |
const videoUrl = await waitForKlingVideo(result.taskId, timeoutMs);
|
| 558 |
return videoUrl;
|
| 559 |
} catch (error) {
|
| 560 |
lastError = error instanceof Error ? error : new Error(String(error));
|
| 561 |
console.error(`β Video generation attempt ${attempt + 1} failed:`, lastError.message);
|
| 562 |
-
|
| 563 |
-
// If this was the first attempt, retry once
|
| 564 |
if (attempt === 0) {
|
| 565 |
-
console.log(
|
| 566 |
-
await new Promise(resolve => setTimeout(resolve,
|
| 567 |
continue;
|
| 568 |
}
|
| 569 |
-
|
| 570 |
-
// If both attempts failed, throw the error
|
| 571 |
throw lastError;
|
| 572 |
}
|
| 573 |
}
|
| 574 |
-
|
| 575 |
-
// This should never be reached, but TypeScript needs it
|
| 576 |
throw lastError || new Error('Video generation failed');
|
| 577 |
}
|
| 578 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
// ==================== VIDEO MERGE/EXPORT ====================
|
| 580 |
|
| 581 |
export interface ClipMetadata {
|
|
@@ -605,18 +792,9 @@ export async function mergeVideos(
|
|
| 605 |
method: 'POST',
|
| 606 |
body: formData,
|
| 607 |
});
|
| 608 |
-
|
| 609 |
if (!response.ok) {
|
| 610 |
-
|
| 611 |
-
try {
|
| 612 |
-
const errorData = await response.json();
|
| 613 |
-
errorMessage = errorData.detail || errorMessage;
|
| 614 |
-
} catch {
|
| 615 |
-
// Ignore JSON parse errors
|
| 616 |
-
}
|
| 617 |
-
throw new Error(errorMessage);
|
| 618 |
}
|
| 619 |
-
|
| 620 |
return response.blob();
|
| 621 |
}
|
| 622 |
|
|
|
|
| 5 |
ExtractedFrame,
|
| 6 |
LoginRequest,
|
| 7 |
LoginResponse,
|
| 8 |
+
AuthUser,
|
| 9 |
+
GenerationStep
|
| 10 |
} from '@/types';
|
| 11 |
|
| 12 |
+
// ==================== RETRY CONSTANTS ====================
|
| 13 |
+
export const RETRY_DELAY_MS = 2000;
|
| 14 |
+
export const MAX_VIDEO_ATTEMPTS = 2;
|
| 15 |
+
/** Max attempts before skipping auto-fix and using flow retry (attemptCount 0, 1 = 2 tries). */
|
| 16 |
+
export const AUTO_FIX_MAX_ATTEMPTS = 3;
|
| 17 |
+
|
| 18 |
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000';
|
| 19 |
|
| 20 |
// Get auth token from localStorage
|
|
|
|
| 32 |
localStorage.removeItem('auth_token');
|
| 33 |
}
|
| 34 |
|
| 35 |
+
function getStatusBasedErrorMessage(status: number): string {
|
| 36 |
+
if (status === 401) return 'Incorrect username or password.';
|
| 37 |
+
if (status === 403) return 'Access forbidden.';
|
| 38 |
+
if (status === 404) return 'Resource not found.';
|
| 39 |
+
if (status >= 500) return 'Server error. Please try again later.';
|
| 40 |
+
return `Request failed with status ${status}`;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/** Parse error message from a non-ok fetch response (JSON or text). */
|
| 44 |
+
async function parseFetchError(response: Response, defaultMessage: string): Promise<string> {
|
| 45 |
+
const contentType = response.headers.get('content-type');
|
| 46 |
+
const isJson = contentType?.includes('application/json');
|
| 47 |
+
try {
|
| 48 |
+
if (isJson) {
|
| 49 |
+
const errorData = await response.json();
|
| 50 |
+
return errorData.detail || errorData.message || errorData.error ||
|
| 51 |
+
(typeof errorData === 'string' ? errorData : defaultMessage);
|
| 52 |
+
}
|
| 53 |
+
const text = await response.text();
|
| 54 |
+
return text?.trim() ? text : getStatusBasedErrorMessage(response.status);
|
| 55 |
+
} catch {
|
| 56 |
+
return getStatusBasedErrorMessage(response.status);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** POST JSON to API with auth. */
|
| 61 |
+
async function apiPostJson<T>(path: string, body: object): Promise<T> {
|
| 62 |
+
return apiRequest<T>(path, {
|
| 63 |
+
method: 'POST',
|
| 64 |
+
headers: { 'Content-Type': 'application/json' },
|
| 65 |
+
body: JSON.stringify(body),
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
// Generic API request handler
|
| 70 |
async function apiRequest<T>(
|
| 71 |
path: string,
|
|
|
|
| 103 |
});
|
| 104 |
|
| 105 |
if (!response.ok) {
|
| 106 |
+
if (response.status === 401) removeAuthToken();
|
| 107 |
+
const errorMessage = await parseFetchError(response, `Request failed with status ${response.status}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
console.error('API Error:', {
|
| 109 |
status: response.status,
|
| 110 |
statusText: response.statusText,
|
| 111 |
message: errorMessage,
|
| 112 |
+
url,
|
| 113 |
+
contentType: response.headers.get('content-type'),
|
| 114 |
});
|
| 115 |
+
throw new Error(errorMessage);
|
| 116 |
}
|
| 117 |
|
| 118 |
return response.json();
|
|
|
|
| 184 |
}
|
| 185 |
|
| 186 |
export async function klingGenerate(params: KlingGenerateParams): Promise<KlingGenerateResponse> {
|
| 187 |
+
return apiPostJson<KlingGenerateResponse>('/api/veo/generate', params);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
}
|
| 189 |
|
| 190 |
export async function klingExtend(taskId: string, prompt: string | object, seeds?: number, voiceType?: string): Promise<KlingGenerateResponse> {
|
| 191 |
+
return apiPostJson<KlingGenerateResponse>('/api/veo/extend', { taskId, prompt, seeds, voiceType });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
export async function klingGetStatus(taskId: string): Promise<VideoStatusResponse> {
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
export async function replicateGenerate(params: ReplicateGenerateParams): Promise<ReplicateGenerateResponse> {
|
| 225 |
+
return apiPostJson<ReplicateGenerateResponse>('/api/replicate/generate', params);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
}
|
| 227 |
|
| 228 |
export async function replicateGetStatus(predictionId: string): Promise<VideoStatusResponse> {
|
|
|
|
| 255 |
|
| 256 |
// ==================== PROMPT GENERATION ====================
|
| 257 |
|
| 258 |
+
// Stream event types
|
| 259 |
+
export interface StreamEvent {
|
| 260 |
+
event: 'start' | 'segment' | 'complete' | 'error';
|
| 261 |
+
[key: string]: any;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
export interface StreamStartEvent extends StreamEvent {
|
| 265 |
+
event: 'start';
|
| 266 |
+
total_segments: number;
|
| 267 |
+
model: string;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
export interface StreamSegmentEvent extends StreamEvent {
|
| 271 |
+
event: 'segment';
|
| 272 |
+
index: number;
|
| 273 |
+
total: number;
|
| 274 |
+
progress: number;
|
| 275 |
+
segment: any;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
export interface StreamCompleteEvent extends StreamEvent {
|
| 279 |
+
event: 'complete';
|
| 280 |
+
message: string;
|
| 281 |
+
prompt_id: string;
|
| 282 |
+
payload: SegmentsPayload;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
export interface StreamErrorEvent extends StreamEvent {
|
| 286 |
+
event: 'error';
|
| 287 |
+
message: string;
|
| 288 |
+
error_type: string;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
export interface GeneratePromptsStreamingOptions {
|
| 292 |
+
signal?: AbortSignal;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/**
|
| 296 |
+
* Generate prompts with streaming - segments arrive in real-time
|
| 297 |
+
*
|
| 298 |
+
* @param formData - Form data with script, style, image, etc.
|
| 299 |
+
* @param onEvent - Callback for each stream event
|
| 300 |
+
* @param options - Optional { signal } for cancellation
|
| 301 |
+
* @returns Promise that resolves with complete payload
|
| 302 |
+
*/
|
| 303 |
+
export async function generatePromptsStreaming(
|
| 304 |
+
formData: FormData,
|
| 305 |
+
onEvent: (event: StreamEvent) => void,
|
| 306 |
+
options?: GeneratePromptsStreamingOptions
|
| 307 |
+
): Promise<SegmentsPayload> {
|
| 308 |
+
const response = await fetch(`${API_BASE}/api/generate-prompts-stream`, {
|
| 309 |
method: 'POST',
|
| 310 |
body: formData,
|
| 311 |
+
signal: options?.signal,
|
| 312 |
});
|
|
|
|
| 313 |
if (!response.ok) {
|
| 314 |
+
throw new Error(await parseFetchError(response, 'Failed to generate prompts'));
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
if (!response.body) {
|
| 318 |
+
throw new Error('No response body');
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
const reader = response.body.getReader();
|
| 322 |
+
const decoder = new TextDecoder();
|
| 323 |
+
let buffer = '';
|
| 324 |
+
let finalPayload: SegmentsPayload | null = null;
|
| 325 |
+
|
| 326 |
+
try {
|
| 327 |
+
while (true) {
|
| 328 |
+
const { done, value } = await reader.read();
|
| 329 |
+
if (done) break;
|
| 330 |
+
if (options?.signal?.aborted) {
|
| 331 |
+
reader.releaseLock();
|
| 332 |
+
throw new Error('Generation cancelled by user');
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// Decode chunk and add to buffer
|
| 336 |
+
buffer += decoder.decode(value, { stream: true });
|
| 337 |
+
|
| 338 |
+
// Split by newlines (NDJSON format)
|
| 339 |
+
const lines = buffer.split('\n');
|
| 340 |
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
| 341 |
+
|
| 342 |
+
// Process each complete line
|
| 343 |
+
for (const line of lines) {
|
| 344 |
+
if (!line.trim()) continue;
|
| 345 |
+
|
| 346 |
+
try {
|
| 347 |
+
const event = JSON.parse(line) as StreamEvent;
|
| 348 |
+
|
| 349 |
+
// Call event callback
|
| 350 |
+
onEvent(event);
|
| 351 |
+
|
| 352 |
+
// Store final payload from complete event
|
| 353 |
+
if (event.event === 'complete') {
|
| 354 |
+
finalPayload = (event as StreamCompleteEvent).payload;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// If error event, throw
|
| 358 |
+
if (event.event === 'error') {
|
| 359 |
+
throw new Error((event as StreamErrorEvent).message);
|
| 360 |
+
}
|
| 361 |
+
} catch (parseError) {
|
| 362 |
+
console.error('Failed to parse stream event:', parseError, 'Line:', line);
|
| 363 |
+
// Continue processing other events
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
}
|
| 367 |
+
} finally {
|
| 368 |
+
reader.releaseLock();
|
| 369 |
}
|
| 370 |
|
| 371 |
+
if (!finalPayload) {
|
| 372 |
+
throw new Error('Stream completed without final payload');
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
return finalPayload;
|
| 376 |
}
|
| 377 |
|
| 378 |
export async function refinePromptContinuity(
|
|
|
|
| 391 |
|
| 392 |
// ==================== IMAGE UPLOAD ====================
|
| 393 |
|
| 394 |
+
/** Use reference: true when uploading a last-frame for continuity (keeps higher quality). */
|
| 395 |
+
export async function uploadImage(
|
| 396 |
+
file: File,
|
| 397 |
+
options?: { reference?: boolean }
|
| 398 |
+
): Promise<{ url: string; filename: string }> {
|
| 399 |
const formData = new FormData();
|
| 400 |
formData.append('file', file);
|
| 401 |
+
const path = options?.reference ? '/api/upload-image?reference=true' : '/api/upload-image';
|
| 402 |
|
| 403 |
+
return apiRequest<{ url: string; filename: string }>(path, {
|
| 404 |
method: 'POST',
|
| 405 |
body: formData,
|
| 406 |
});
|
|
|
|
| 421 |
}
|
| 422 |
|
| 423 |
export async function extractFrames(params: ExtractFramesParams): Promise<ExtractFramesResponse> {
|
| 424 |
+
return apiPostJson<ExtractFramesResponse>('/api/extract-frames', params);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
}
|
| 426 |
|
| 427 |
// ==================== WHISPER ANALYSIS ====================
|
|
|
|
| 449 |
* This is the optimized flow that combines Whisper analysis and frame extraction.
|
| 450 |
*/
|
| 451 |
export async function whisperAnalyzeAndExtract(params: WhisperAnalyzeParams): Promise<WhisperAnalyzeResponse> {
|
| 452 |
+
return apiPostJson<WhisperAnalyzeResponse>('/api/whisper/analyze-and-extract', params);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
}
|
| 454 |
|
| 455 |
/**
|
|
|
|
| 467 |
formData.append('lastFrame', frameFile);
|
| 468 |
formData.append('transcribedDialogue', transcribedDialogue);
|
| 469 |
formData.append('expectedDialogue', expectedDialogue);
|
| 470 |
+
return apiRequest<{ refined_prompt: object; original_prompt: object }>('/api/refine-prompt-continuity', {
|
|
|
|
| 471 |
method: 'POST',
|
| 472 |
body: formData,
|
| 473 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
}
|
| 475 |
|
| 476 |
/**
|
|
|
|
| 494 |
|
| 495 |
// ==================== UTILITIES ====================
|
| 496 |
|
| 497 |
+
/** Load video from file; caller must revoke object URL when done. */
|
| 498 |
+
function loadVideoFromFile(file: File): Promise<HTMLVideoElement> {
|
| 499 |
return new Promise((resolve, reject) => {
|
| 500 |
const video = document.createElement('video');
|
| 501 |
video.preload = 'metadata';
|
| 502 |
video.src = URL.createObjectURL(file);
|
| 503 |
+
video.onloadedmetadata = () => resolve(video);
|
|
|
|
|
|
|
|
|
|
| 504 |
video.onerror = () => {
|
| 505 |
URL.revokeObjectURL(video.src);
|
| 506 |
reject(new Error('Failed to load video metadata'));
|
|
|
|
| 508 |
});
|
| 509 |
}
|
| 510 |
|
| 511 |
+
export async function getVideoDuration(file: File): Promise<number> {
|
| 512 |
+
const video = await loadVideoFromFile(file);
|
| 513 |
+
try {
|
| 514 |
+
return video.duration;
|
| 515 |
+
} finally {
|
| 516 |
+
URL.revokeObjectURL(video.src);
|
| 517 |
+
}
|
| 518 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
+
export async function generateThumbnails(file: File, count: number = 5): Promise<string[]> {
|
| 521 |
+
const video = await loadVideoFromFile(file);
|
| 522 |
+
video.muted = true;
|
| 523 |
+
try {
|
| 524 |
+
const ctx = document.createElement('canvas').getContext('2d');
|
| 525 |
+
if (!ctx) throw new Error('Could not get canvas context');
|
| 526 |
+
|
| 527 |
+
const maxSize = 400;
|
| 528 |
+
const videoWidth = video.videoWidth || 1080;
|
| 529 |
+
const videoHeight = video.videoHeight || 1920;
|
| 530 |
+
const scale = Math.min(maxSize / videoWidth, maxSize / videoHeight);
|
| 531 |
+
const w = Math.round(videoWidth * scale);
|
| 532 |
+
const h = Math.round(videoHeight * scale);
|
| 533 |
+
ctx.canvas.width = w;
|
| 534 |
+
ctx.canvas.height = h;
|
| 535 |
+
|
| 536 |
+
const duration = video.duration;
|
| 537 |
+
const thumbnails: string[] = [];
|
| 538 |
+
for (let i = 0; i < count; i++) {
|
| 539 |
+
video.currentTime = (duration / count) * i;
|
| 540 |
+
await new Promise<void>((res) => { video.onseeked = () => res(); });
|
| 541 |
+
ctx.drawImage(video, 0, 0, w, h);
|
| 542 |
+
thumbnails.push(ctx.canvas.toDataURL('image/jpeg', 0.85));
|
| 543 |
+
}
|
| 544 |
+
return thumbnails;
|
| 545 |
+
} finally {
|
| 546 |
+
URL.revokeObjectURL(video.src);
|
| 547 |
+
}
|
| 548 |
}
|
| 549 |
|
| 550 |
// Wait for video completion using SSE
|
|
|
|
| 601 |
onRetry?: (attempt: number) => void
|
| 602 |
): Promise<string> {
|
| 603 |
let lastError: Error | null = null;
|
| 604 |
+
|
| 605 |
+
for (let attempt = 0; attempt < MAX_VIDEO_ATTEMPTS; attempt++) {
|
| 606 |
try {
|
| 607 |
if (attempt > 0) {
|
| 608 |
+
console.log(`π Retrying video generation (attempt ${attempt + 1}/${MAX_VIDEO_ATTEMPTS})...`);
|
| 609 |
if (onRetry) {
|
| 610 |
onRetry(attempt + 1);
|
| 611 |
}
|
| 612 |
}
|
| 613 |
+
|
| 614 |
const result = await generateFn();
|
| 615 |
const videoUrl = await waitForKlingVideo(result.taskId, timeoutMs);
|
| 616 |
return videoUrl;
|
| 617 |
} catch (error) {
|
| 618 |
lastError = error instanceof Error ? error : new Error(String(error));
|
| 619 |
console.error(`β Video generation attempt ${attempt + 1} failed:`, lastError.message);
|
| 620 |
+
|
|
|
|
| 621 |
if (attempt === 0) {
|
| 622 |
+
console.log(`β³ Waiting ${RETRY_DELAY_MS / 1000} seconds before retry...`);
|
| 623 |
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
| 624 |
continue;
|
| 625 |
}
|
| 626 |
+
|
|
|
|
| 627 |
throw lastError;
|
| 628 |
}
|
| 629 |
}
|
| 630 |
+
|
|
|
|
| 631 |
throw lastError || new Error('Video generation failed');
|
| 632 |
}
|
| 633 |
|
| 634 |
+
/** Outcome of flow-level retry: either trigger retry or state was updated (partial/error). */
|
| 635 |
+
export type FlowRetryOutcome = 'retry' | 'handled';
|
| 636 |
+
|
| 637 |
+
/** Shared flow-level retry: on cancel show error; on first failure return 'retry'; else show partial or error. */
|
| 638 |
+
export async function handleFlowRetry(options: {
|
| 639 |
+
attemptCount: number;
|
| 640 |
+
errorMessage: string;
|
| 641 |
+
isCancelled: boolean;
|
| 642 |
+
generatedCount: number;
|
| 643 |
+
totalCount: number;
|
| 644 |
+
setError: (s: string) => void;
|
| 645 |
+
setStep: (s: GenerationStep) => void;
|
| 646 |
+
setPartialCompletionError: (s: string | null) => void;
|
| 647 |
+
}): Promise<FlowRetryOutcome> {
|
| 648 |
+
if (options.isCancelled) {
|
| 649 |
+
options.setError('Generation cancelled by user');
|
| 650 |
+
options.setStep('error');
|
| 651 |
+
return 'handled';
|
| 652 |
+
}
|
| 653 |
+
if (options.attemptCount === 0) {
|
| 654 |
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
| 655 |
+
return 'retry';
|
| 656 |
+
}
|
| 657 |
+
if (options.generatedCount > 0) {
|
| 658 |
+
options.setPartialCompletionError(
|
| 659 |
+
`Generation stopped at segment ${options.generatedCount + 1} of ${options.totalCount}. Reason: ${options.errorMessage}`
|
| 660 |
+
);
|
| 661 |
+
options.setStep('completed');
|
| 662 |
+
} else {
|
| 663 |
+
options.setError(options.errorMessage);
|
| 664 |
+
options.setStep('error');
|
| 665 |
+
}
|
| 666 |
+
return 'handled';
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
// ==================== CONTENT VALIDATION ====================
|
| 670 |
+
|
| 671 |
+
export interface ContentValidationRequest {
|
| 672 |
+
script: string;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
export interface ContentValidationResponse {
|
| 676 |
+
is_valid: boolean;
|
| 677 |
+
warnings: string[];
|
| 678 |
+
guidance?: {
|
| 679 |
+
avoid_public_figures: string[];
|
| 680 |
+
avoid_copyrighted: string[];
|
| 681 |
+
avoid_sensitive: string[];
|
| 682 |
+
best_practices: string[];
|
| 683 |
+
} | null;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
/**
|
| 687 |
+
* Validate script content before generation to catch potential issues early
|
| 688 |
+
* Checks for public figures, copyrighted content, and other policy violations
|
| 689 |
+
*/
|
| 690 |
+
export async function validateContent(script: string): Promise<ContentValidationResponse> {
|
| 691 |
+
return apiPostJson<ContentValidationResponse>('/api/validate-content', { script });
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
/**
|
| 695 |
+
* Get content policy guidance
|
| 696 |
+
*/
|
| 697 |
+
export async function getContentGuidance() {
|
| 698 |
+
return apiRequest('/api/content-guidance');
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
// ==================== PROMPT SAFETY ====================
|
| 702 |
+
|
| 703 |
+
export interface SafetyFixRequest {
|
| 704 |
+
segment: any; // VeoSegment
|
| 705 |
+
error_message: string;
|
| 706 |
+
attempt_count?: number;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
export interface SafetyFixResponse {
|
| 710 |
+
success: boolean;
|
| 711 |
+
fixed_segment: any | null;
|
| 712 |
+
changes_made: string | null;
|
| 713 |
+
error: string | null;
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
/**
|
| 717 |
+
* Automatically fix an unsafe prompt using AI
|
| 718 |
+
* Detects content that triggered safety filters and modifies it to be compliant
|
| 719 |
+
*/
|
| 720 |
+
export async function fixUnsafePrompt(request: SafetyFixRequest): Promise<SafetyFixResponse> {
|
| 721 |
+
return apiPostJson<SafetyFixResponse>('/api/safety/fix-unsafe-prompt', request);
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
/**
|
| 725 |
+
* Check if error is a Veo 3.1 safety/content policy violation
|
| 726 |
+
* Based on official Veo 3.1 API documentation
|
| 727 |
+
*
|
| 728 |
+
* Official error types:
|
| 729 |
+
* - PUBLIC_ERROR_MINOR: Generic internal error (wait and retry)
|
| 730 |
+
* - Support codes (8-digit): Specific safety categories
|
| 731 |
+
* - "Flagged for containing..." errors: Public figures, unsafe content
|
| 732 |
+
*/
|
| 733 |
+
export function isUnsafeSegmentError(errorMessage: string): boolean {
|
| 734 |
+
const upperError = errorMessage.toUpperCase();
|
| 735 |
+
|
| 736 |
+
// Official Veo 3.1 safety patterns from documentation
|
| 737 |
+
const safetyKeywords = [
|
| 738 |
+
'UNSAFE',
|
| 739 |
+
'CONTENT_POLICY',
|
| 740 |
+
'MODERATION',
|
| 741 |
+
'INAPPROPRIATE',
|
| 742 |
+
'VIOLATION',
|
| 743 |
+
'PROHIBITED',
|
| 744 |
+
'SAFETY',
|
| 745 |
+
'BLOCKED',
|
| 746 |
+
|
| 747 |
+
// Public figures (MOST COMMON!)
|
| 748 |
+
'PROMINENT PUBLIC FIGURE',
|
| 749 |
+
'PUBLIC FIGURE',
|
| 750 |
+
'CELEBRITY',
|
| 751 |
+
'POLITICIAN',
|
| 752 |
+
'FLAGGED FOR CONTAINING',
|
| 753 |
+
'PROMINENT PUBLIC',
|
| 754 |
+
];
|
| 755 |
+
|
| 756 |
+
// Check for safety keywords
|
| 757 |
+
const hasKeyword = safetyKeywords.some(keyword => upperError.includes(keyword));
|
| 758 |
+
|
| 759 |
+
// Check for 8-digit support code (indicates safety filter)
|
| 760 |
+
// Official Veo 3.1 uses support codes for safety categories
|
| 761 |
+
const hasSupportCode = /\b\d{8}\b/.test(errorMessage);
|
| 762 |
+
|
| 763 |
+
return hasKeyword || hasSupportCode;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
// ==================== VIDEO MERGE/EXPORT ====================
|
| 767 |
|
| 768 |
export interface ClipMetadata {
|
|
|
|
| 792 |
method: 'POST',
|
| 793 |
body: formData,
|
| 794 |
});
|
|
|
|
| 795 |
if (!response.ok) {
|
| 796 |
+
throw new Error(await parseFetchError(response, 'Failed to merge videos'));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
}
|
|
|
|
| 798 |
return response.blob();
|
| 799 |
}
|
| 800 |
|
frontend/tailwind.config.js
CHANGED
|
@@ -69,6 +69,7 @@ export default {
|
|
| 69 |
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
|
| 70 |
'shimmer': 'shimmer 2s linear infinite',
|
| 71 |
'spin-slow': 'spin 3s linear infinite',
|
|
|
|
| 72 |
},
|
| 73 |
keyframes: {
|
| 74 |
float: {
|
|
@@ -83,6 +84,10 @@ export default {
|
|
| 83 |
'0%': { backgroundPosition: '-200% 0' },
|
| 84 |
'100%': { backgroundPosition: '200% 0' },
|
| 85 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
},
|
| 87 |
},
|
| 88 |
},
|
|
|
|
| 69 |
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
|
| 70 |
'shimmer': 'shimmer 2s linear infinite',
|
| 71 |
'spin-slow': 'spin 3s linear infinite',
|
| 72 |
+
'fade-in': 'fadeIn 0.3s ease-in',
|
| 73 |
},
|
| 74 |
keyframes: {
|
| 75 |
float: {
|
|
|
|
| 84 |
'0%': { backgroundPosition: '-200% 0' },
|
| 85 |
'100%': { backgroundPosition: '200% 0' },
|
| 86 |
},
|
| 87 |
+
fadeIn: {
|
| 88 |
+
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
| 89 |
+
'100%': { opacity: '1', transform: 'translateY(0)' },
|
| 90 |
+
},
|
| 91 |
},
|
| 92 |
},
|
| 93 |
},
|
main.py
CHANGED
|
@@ -20,7 +20,8 @@ from api.video_export import router as export_router
|
|
| 20 |
from api.replicate_service import router as replicate_router
|
| 21 |
from api.whisper_service import router as whisper_router
|
| 22 |
from api.auth import router as auth_router
|
| 23 |
-
from api.
|
|
|
|
| 24 |
from utils.storage import cleanup_old_files
|
| 25 |
|
| 26 |
# Load environment variables
|
|
@@ -100,7 +101,30 @@ app.include_router(prompt_router, prefix="/api")
|
|
| 100 |
app.include_router(export_router, prefix="/api")
|
| 101 |
app.include_router(replicate_router, prefix="/api")
|
| 102 |
app.include_router(whisper_router, prefix="/api")
|
| 103 |
-
app.include_router(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
# Health check endpoints (must be before catch-all route)
|
| 106 |
@app.get("/health")
|
|
@@ -121,9 +145,10 @@ async def health_check():
|
|
| 121 |
|
| 122 |
# Serve static files (frontend) in production
|
| 123 |
frontend_dist_path = os.path.join(os.getcwd(), "frontend", "dist")
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
| 127 |
|
| 128 |
# Root endpoint - serve frontend or API info
|
| 129 |
@app.get("/")
|
|
|
|
| 20 |
from api.replicate_service import router as replicate_router
|
| 21 |
from api.whisper_service import router as whisper_router
|
| 22 |
from api.auth import router as auth_router
|
| 23 |
+
from api.prompt_safety import router as safety_router
|
| 24 |
+
from api.prompt_validator import validate_prompt_content, get_content_guidance
|
| 25 |
from utils.storage import cleanup_old_files
|
| 26 |
|
| 27 |
# Load environment variables
|
|
|
|
| 101 |
app.include_router(export_router, prefix="/api")
|
| 102 |
app.include_router(replicate_router, prefix="/api")
|
| 103 |
app.include_router(whisper_router, prefix="/api")
|
| 104 |
+
app.include_router(safety_router, prefix="/api/safety")
|
| 105 |
+
|
| 106 |
+
# Content validation endpoints
|
| 107 |
+
@app.post("/api/validate-content")
|
| 108 |
+
async def validate_content_endpoint(request: dict):
|
| 109 |
+
"""
|
| 110 |
+
Validate script content before generation to catch potential issues early.
|
| 111 |
+
Returns warnings about public figures, copyrighted content, etc.
|
| 112 |
+
"""
|
| 113 |
+
script = request.get("script", "")
|
| 114 |
+
is_valid, warnings = validate_prompt_content(script)
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
"is_valid": is_valid,
|
| 118 |
+
"warnings": warnings,
|
| 119 |
+
"guidance": get_content_guidance() if not is_valid else None
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@app.get("/api/content-guidance")
|
| 124 |
+
async def content_guidance_endpoint():
|
| 125 |
+
"""Get guidance on content policy compliance"""
|
| 126 |
+
return get_content_guidance()
|
| 127 |
+
|
| 128 |
|
| 129 |
# Health check endpoints (must be before catch-all route)
|
| 130 |
@app.get("/health")
|
|
|
|
| 145 |
|
| 146 |
# Serve static files (frontend) in production
|
| 147 |
frontend_dist_path = os.path.join(os.getcwd(), "frontend", "dist")
|
| 148 |
+
frontend_assets_path = os.path.join(frontend_dist_path, "assets")
|
| 149 |
+
if os.path.exists(frontend_dist_path) and os.path.isdir(frontend_assets_path):
|
| 150 |
+
# Serve static files (assets must exist; avoid crash when watcher has cleared dist)
|
| 151 |
+
app.mount("/assets", StaticFiles(directory=frontend_assets_path), name="assets")
|
| 152 |
|
| 153 |
# Root endpoint - serve frontend or API info
|
| 154 |
@app.get("/")
|
run-dev.sh
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Run backend + frontend build watcher so frontend auto-rebuilds on change (no manual npm run build).
|
| 3 |
+
|
| 4 |
+
set -e
|
| 5 |
+
cd "$(dirname "$0")"
|
| 6 |
+
|
| 7 |
+
if [ ! -d "venv" ]; then
|
| 8 |
+
echo "β venv not found. Run: bash setup.sh"
|
| 9 |
+
exit 1
|
| 10 |
+
fi
|
| 11 |
+
source venv/bin/activate
|
| 12 |
+
|
| 13 |
+
# Build frontend once, then start watcher in background
|
| 14 |
+
echo "π¦ Building frontend once..."
|
| 15 |
+
(cd frontend && npm run build)
|
| 16 |
+
echo "π Starting frontend build watcher (rebuilds on file change)..."
|
| 17 |
+
(cd frontend && npm run build:watch) &
|
| 18 |
+
WATCHER_PID=$!
|
| 19 |
+
trap "kill $WATCHER_PID 2>/dev/null" EXIT
|
| 20 |
+
|
| 21 |
+
# Wait for dist/assets so Python doesn't start while watcher cleared the dir
|
| 22 |
+
echo "β³ Waiting for frontend build to be ready..."
|
| 23 |
+
i=0
|
| 24 |
+
while [ $i -lt 30 ]; do
|
| 25 |
+
if [ -d "frontend/dist/assets" ] && [ -n "$(ls frontend/dist/assets 2>/dev/null)" ]; then
|
| 26 |
+
break
|
| 27 |
+
fi
|
| 28 |
+
sleep 1
|
| 29 |
+
i=$((i + 1))
|
| 30 |
+
done
|
| 31 |
+
if [ ! -d "frontend/dist/assets" ] || [ -z "$(ls frontend/dist/assets 2>/dev/null)" ]; then
|
| 32 |
+
echo "β frontend/dist/assets not ready after 30s. Check frontend build."
|
| 33 |
+
exit 1
|
| 34 |
+
fi
|
| 35 |
+
|
| 36 |
+
echo "π Starting backend at http://localhost:4000"
|
| 37 |
+
echo " Edit frontend files β auto-rebuild β refresh browser"
|
| 38 |
+
echo ""
|
| 39 |
+
python main.py
|
standalone_video_creator.py
CHANGED
|
@@ -789,9 +789,9 @@ async def main():
|
|
| 789 |
# Replicate key will be checked when needed
|
| 790 |
pass
|
| 791 |
|
| 792 |
-
# Generate structured prompts using GPT-
|
| 793 |
print_header("GENERATING VIDEO PROMPTS")
|
| 794 |
-
print_status("π€ Using GPT-
|
| 795 |
|
| 796 |
# Read reference image
|
| 797 |
with open(config['image_path'], 'rb') as f:
|
|
|
|
| 789 |
# Replicate key will be checked when needed
|
| 790 |
pass
|
| 791 |
|
| 792 |
+
# Generate structured prompts using GPT-5.2
|
| 793 |
print_header("GENERATING VIDEO PROMPTS")
|
| 794 |
+
print_status("π€ Using GPT-5.2 to generate structured prompts...")
|
| 795 |
|
| 796 |
# Read reference image
|
| 797 |
with open(config['image_path'], 'rb') as f:
|
storage/prompt_cache/.gitkeep
ADDED
|
File without changes
|
utils/prompt_cache.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prompt Cache System
|
| 3 |
+
Saves generated prompts temporarily for reuse and editing
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Dict, List, Optional, Any
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
# Storage directory for cached prompts
|
| 13 |
+
CACHE_DIR = Path("storage/prompt_cache")
|
| 14 |
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 15 |
+
|
| 16 |
+
# In-memory cache for quick access
|
| 17 |
+
_prompt_cache: Dict[str, Dict[str, Any]] = {}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def save_prompt(
|
| 21 |
+
prompt_id: str,
|
| 22 |
+
payload: Dict[str, Any],
|
| 23 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 24 |
+
) -> str:
|
| 25 |
+
"""
|
| 26 |
+
Save a generated prompt to cache
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
prompt_id: Unique identifier for the prompt
|
| 30 |
+
payload: The segments payload
|
| 31 |
+
metadata: Optional metadata (script, style, etc.)
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
The prompt_id
|
| 35 |
+
"""
|
| 36 |
+
cache_entry = {
|
| 37 |
+
"prompt_id": prompt_id,
|
| 38 |
+
"payload": payload,
|
| 39 |
+
"metadata": metadata or {},
|
| 40 |
+
"created_at": datetime.now().isoformat(),
|
| 41 |
+
"updated_at": datetime.now().isoformat()
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# Save to in-memory cache
|
| 45 |
+
_prompt_cache[prompt_id] = cache_entry
|
| 46 |
+
|
| 47 |
+
# Save to disk for persistence
|
| 48 |
+
cache_file = CACHE_DIR / f"{prompt_id}.json"
|
| 49 |
+
with open(cache_file, 'w') as f:
|
| 50 |
+
json.dump(cache_entry, f, indent=2)
|
| 51 |
+
|
| 52 |
+
print(f"πΎ Saved prompt to cache: {prompt_id}")
|
| 53 |
+
return prompt_id
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def get_prompt(prompt_id: str) -> Optional[Dict[str, Any]]:
|
| 57 |
+
"""
|
| 58 |
+
Retrieve a cached prompt
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
prompt_id: The prompt identifier
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
The cached prompt entry or None
|
| 65 |
+
"""
|
| 66 |
+
# Check in-memory cache first
|
| 67 |
+
if prompt_id in _prompt_cache:
|
| 68 |
+
return _prompt_cache[prompt_id]
|
| 69 |
+
|
| 70 |
+
# Check disk cache
|
| 71 |
+
cache_file = CACHE_DIR / f"{prompt_id}.json"
|
| 72 |
+
if cache_file.exists():
|
| 73 |
+
with open(cache_file, 'r') as f:
|
| 74 |
+
cache_entry = json.load(f)
|
| 75 |
+
_prompt_cache[prompt_id] = cache_entry
|
| 76 |
+
return cache_entry
|
| 77 |
+
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def update_prompt(
|
| 82 |
+
prompt_id: str,
|
| 83 |
+
payload: Optional[Dict[str, Any]] = None,
|
| 84 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 85 |
+
) -> Optional[Dict[str, Any]]:
|
| 86 |
+
"""
|
| 87 |
+
Update an existing cached prompt
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
prompt_id: The prompt identifier
|
| 91 |
+
payload: Updated payload (optional)
|
| 92 |
+
metadata: Updated metadata (optional)
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
The updated cache entry or None if not found
|
| 96 |
+
"""
|
| 97 |
+
cache_entry = get_prompt(prompt_id)
|
| 98 |
+
if not cache_entry:
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
# Update fields
|
| 102 |
+
if payload is not None:
|
| 103 |
+
cache_entry["payload"] = payload
|
| 104 |
+
if metadata is not None:
|
| 105 |
+
cache_entry["metadata"].update(metadata)
|
| 106 |
+
|
| 107 |
+
cache_entry["updated_at"] = datetime.now().isoformat()
|
| 108 |
+
|
| 109 |
+
# Save updated entry
|
| 110 |
+
_prompt_cache[prompt_id] = cache_entry
|
| 111 |
+
cache_file = CACHE_DIR / f"{prompt_id}.json"
|
| 112 |
+
with open(cache_file, 'w') as f:
|
| 113 |
+
json.dump(cache_entry, f, indent=2)
|
| 114 |
+
|
| 115 |
+
print(f"βοΈ Updated prompt in cache: {prompt_id}")
|
| 116 |
+
return cache_entry
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def list_prompts(limit: int = 50) -> List[Dict[str, Any]]:
|
| 120 |
+
"""
|
| 121 |
+
List all cached prompts (most recent first)
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
limit: Maximum number of prompts to return
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
List of cached prompts
|
| 128 |
+
"""
|
| 129 |
+
# Load all from disk if in-memory cache is empty
|
| 130 |
+
if not _prompt_cache:
|
| 131 |
+
for cache_file in CACHE_DIR.glob("*.json"):
|
| 132 |
+
try:
|
| 133 |
+
with open(cache_file, 'r') as f:
|
| 134 |
+
cache_entry = json.load(f)
|
| 135 |
+
_prompt_cache[cache_entry["prompt_id"]] = cache_entry
|
| 136 |
+
except Exception as e:
|
| 137 |
+
print(f"β οΈ Error loading {cache_file}: {e}")
|
| 138 |
+
|
| 139 |
+
# Sort by updated_at (most recent first)
|
| 140 |
+
prompts = sorted(
|
| 141 |
+
_prompt_cache.values(),
|
| 142 |
+
key=lambda x: x.get("updated_at", ""),
|
| 143 |
+
reverse=True
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
return prompts[:limit]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def delete_prompt(prompt_id: str) -> bool:
|
| 150 |
+
"""
|
| 151 |
+
Delete a cached prompt
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
prompt_id: The prompt identifier
|
| 155 |
+
|
| 156 |
+
Returns:
|
| 157 |
+
True if deleted, False if not found
|
| 158 |
+
"""
|
| 159 |
+
# Remove from in-memory cache
|
| 160 |
+
if prompt_id in _prompt_cache:
|
| 161 |
+
del _prompt_cache[prompt_id]
|
| 162 |
+
|
| 163 |
+
# Remove from disk
|
| 164 |
+
cache_file = CACHE_DIR / f"{prompt_id}.json"
|
| 165 |
+
if cache_file.exists():
|
| 166 |
+
cache_file.unlink()
|
| 167 |
+
print(f"ποΈ Deleted prompt from cache: {prompt_id}")
|
| 168 |
+
return True
|
| 169 |
+
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def cleanup_old_prompts(max_age_days: int = 7):
|
| 174 |
+
"""
|
| 175 |
+
Clean up prompts older than specified days
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
max_age_days: Maximum age in days
|
| 179 |
+
"""
|
| 180 |
+
from datetime import timedelta
|
| 181 |
+
|
| 182 |
+
cutoff = datetime.now() - timedelta(days=max_age_days)
|
| 183 |
+
deleted = 0
|
| 184 |
+
|
| 185 |
+
for cache_file in CACHE_DIR.glob("*.json"):
|
| 186 |
+
try:
|
| 187 |
+
with open(cache_file, 'r') as f:
|
| 188 |
+
cache_entry = json.load(f)
|
| 189 |
+
created_at = datetime.fromisoformat(cache_entry["created_at"])
|
| 190 |
+
|
| 191 |
+
if created_at < cutoff:
|
| 192 |
+
cache_file.unlink()
|
| 193 |
+
prompt_id = cache_entry["prompt_id"]
|
| 194 |
+
if prompt_id in _prompt_cache:
|
| 195 |
+
del _prompt_cache[prompt_id]
|
| 196 |
+
deleted += 1
|
| 197 |
+
except Exception as e:
|
| 198 |
+
print(f"β οΈ Error cleaning up {cache_file}: {e}")
|
| 199 |
+
|
| 200 |
+
if deleted > 0:
|
| 201 |
+
print(f"π§Ή Cleaned up {deleted} old prompts")
|
utils/prompt_generator.py
CHANGED
|
@@ -3,6 +3,7 @@ Advanced Prompt Generator using GPT-4o
|
|
| 3 |
Structured JSON generation with strict validation
|
| 4 |
"""
|
| 5 |
|
|
|
|
| 6 |
import re
|
| 7 |
import base64
|
| 8 |
from typing import List, Optional, Dict, Any
|
|
@@ -99,21 +100,190 @@ class SegmentsPayload(BaseModel):
|
|
| 99 |
segments: List[Segment]
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
def split_script_into_segments(
|
| 103 |
script: str,
|
| 104 |
seconds_per_segment: int = 8,
|
| 105 |
-
words_per_second: float =
|
| 106 |
) -> List[str]:
|
| 107 |
"""
|
| 108 |
-
Split script into segments based on timing
|
| 109 |
-
|
| 110 |
-
Args:
|
| 111 |
-
script: Full script text
|
| 112 |
-
seconds_per_segment: Target duration per segment
|
| 113 |
-
words_per_second: Speaking rate (adjust for VO tempo)
|
| 114 |
-
|
| 115 |
-
Returns:
|
| 116 |
-
List of script segments
|
| 117 |
"""
|
| 118 |
sentences = re.split(r'(?<=[.!?])\s+', script.strip())
|
| 119 |
sentences = [s.strip() for s in sentences if s.strip()]
|
|
@@ -166,6 +336,40 @@ You are a STRICT production-grade JSON generator for Veo 3 video prompts.
|
|
| 166 |
|
| 167 |
β οΈ CRITICAL: Your output will be VALIDATED. ANY field under minimum word count will be REJECTED.
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 170 |
π¨ CRITICAL: CHARACTER MUST MATCH REFERENCE IMAGE EXACTLY π¨
|
| 171 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -302,19 +506,21 @@ def _word_count(text: str) -> int:
|
|
| 302 |
return len(re.findall(r"\b\w+\b", text or ""))
|
| 303 |
|
| 304 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
def validate_segments_payload(
|
| 306 |
payload: Dict[str, Any],
|
| 307 |
expected_segments: int
|
| 308 |
) -> List[str]:
|
| 309 |
"""
|
| 310 |
-
Validate the generated payload against strict rules
|
| 311 |
-
|
| 312 |
-
Args:
|
| 313 |
-
payload: Generated payload
|
| 314 |
-
expected_segments: Expected number of segments
|
| 315 |
-
|
| 316 |
-
Returns:
|
| 317 |
-
List of validation errors (empty if valid)
|
| 318 |
"""
|
| 319 |
errors: List[str] = []
|
| 320 |
segs = payload.get("segments", [])
|
|
@@ -322,26 +528,32 @@ def validate_segments_payload(
|
|
| 322 |
if len(segs) != expected_segments:
|
| 323 |
errors.append(f"Expected {expected_segments} segments, got {len(segs)}.")
|
| 324 |
|
| 325 |
-
required_sync_keys = {"0:00-0:02", "0:02-0:04", "0:04-0:06", "0:06-0:08"}
|
| 326 |
physical_blocks, clothing_blocks, environment_blocks = [], [], []
|
| 327 |
|
| 328 |
for i, seg in enumerate(segs, start=1):
|
| 329 |
-
# Check segment info
|
| 330 |
si = seg.get("segment_info", {})
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
if si.get("total_segments") != expected_segments:
|
| 334 |
errors.append(
|
| 335 |
f"Segment {i}: total_segments should be {expected_segments}, "
|
| 336 |
f"got {si.get('total_segments')}."
|
| 337 |
)
|
| 338 |
|
| 339 |
-
# Check synchronized actions keys
|
| 340 |
sync = seg.get("action_timeline", {}).get("synchronized_actions", {})
|
| 341 |
if set(sync.keys()) != required_sync_keys:
|
| 342 |
errors.append(
|
| 343 |
f"Segment {i}: synchronized_actions must have keys "
|
| 344 |
-
f"{sorted(required_sync_keys)}."
|
| 345 |
)
|
| 346 |
|
| 347 |
# Word-count checks
|
|
@@ -381,10 +593,105 @@ def validate_segments_payload(
|
|
| 381 |
return errors
|
| 382 |
|
| 383 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
def generate_segments_payload(
|
| 385 |
inputs: VeoInputs,
|
| 386 |
image_bytes: Optional[bytes] = None,
|
| 387 |
-
model: str = "gpt-
|
| 388 |
api_key: Optional[str] = None
|
| 389 |
) -> Dict[str, Any]:
|
| 390 |
"""
|
|
@@ -492,3 +799,280 @@ def generate_segments_payload(
|
|
| 492 |
# ALWAYS return payload (even with warnings)
|
| 493 |
return payload
|
| 494 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
Structured JSON generation with strict validation
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
import json
|
| 7 |
import re
|
| 8 |
import base64
|
| 9 |
from typing import List, Optional, Dict, Any
|
|
|
|
| 100 |
segments: List[Segment]
|
| 101 |
|
| 102 |
|
| 103 |
+
# Veo supports 4, 6, or 8 second segments. Each duration has fixed sync keys (2-second blocks).
|
| 104 |
+
SEGMENT_DURATION_SECONDS = (4, 6, 8)
|
| 105 |
+
DURATION_SYNC_KEYS = {
|
| 106 |
+
4: ("00:00-00:04", ["0:00-0:02", "0:02-0:04"]),
|
| 107 |
+
6: ("00:00-00:06", ["0:00-0:02", "0:02-0:04", "0:04-0:06"]),
|
| 108 |
+
8: ("00:00-00:08", ["0:00-0:02", "0:02-0:04", "0:04-0:06", "0:06-0:08"]),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def get_duration_string(seconds: int) -> str:
|
| 113 |
+
"""Return Veo duration string for segment length, e.g. '00:00-00:08'."""
|
| 114 |
+
if seconds not in DURATION_SYNC_KEYS:
|
| 115 |
+
raise ValueError(f"Segment duration must be one of {SEGMENT_DURATION_SECONDS}, got {seconds}")
|
| 116 |
+
return DURATION_SYNC_KEYS[seconds][0]
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def get_sync_keys_for_duration(seconds: int) -> List[str]:
|
| 120 |
+
"""Return required synchronized_actions keys for the given segment duration."""
|
| 121 |
+
if seconds not in DURATION_SYNC_KEYS:
|
| 122 |
+
raise ValueError(f"Segment duration must be one of {SEGMENT_DURATION_SECONDS}, got {seconds}")
|
| 123 |
+
return list(DURATION_SYNC_KEYS[seconds][1])
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _flatten_sync_value(v: Any) -> str:
|
| 127 |
+
"""If v is a dict (e.g. {action, camera_movement, audio}), return a single string; else str(v)."""
|
| 128 |
+
if isinstance(v, dict):
|
| 129 |
+
for key in ("action", "on_screen_action", "audio", "audio_notes"):
|
| 130 |
+
if key in v and isinstance(v[key], str) and v[key].strip():
|
| 131 |
+
return v[key].strip()
|
| 132 |
+
for val in v.values():
|
| 133 |
+
if isinstance(val, str) and val.strip():
|
| 134 |
+
return val.strip()
|
| 135 |
+
return "Subject in frame, natural delivery."
|
| 136 |
+
return (v or "Subject in frame, natural delivery.").strip() if isinstance(v, str) else "Subject in frame, natural delivery."
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def normalize_segment_for_api(
|
| 140 |
+
segment: Dict[str, Any],
|
| 141 |
+
segment_text: str,
|
| 142 |
+
required_sync_keys: List[str],
|
| 143 |
+
duration_str: str,
|
| 144 |
+
) -> Dict[str, Any]:
|
| 145 |
+
"""
|
| 146 |
+
Ensure segment matches API contract: action_timeline.dialogue present,
|
| 147 |
+
synchronized_actions flat (key -> string), segment_info only allowed fields.
|
| 148 |
+
"""
|
| 149 |
+
segment = dict(segment)
|
| 150 |
+
at = segment.setdefault("action_timeline", {})
|
| 151 |
+
at = dict(at)
|
| 152 |
+
|
| 153 |
+
# 1. Ensure dialogue at action_timeline.dialogue (required by video APIs)
|
| 154 |
+
dialogue = at.get("dialogue") or (segment.get("segment_info") or {}).get("dialogue") or (segment.get("segment_info") or {}).get("dialogue_exact")
|
| 155 |
+
if not (dialogue and isinstance(dialogue, str) and dialogue.strip()):
|
| 156 |
+
dialogue = segment_text.strip()
|
| 157 |
+
at["dialogue"] = dialogue
|
| 158 |
+
|
| 159 |
+
# 2. Flatten synchronized_actions: only required keys, values must be strings
|
| 160 |
+
sync_raw = at.get("synchronized_actions") or {}
|
| 161 |
+
# GPT sometimes puts time keys at top level of action_timeline (e.g. "0:00-0:02": {...})
|
| 162 |
+
for key in required_sync_keys:
|
| 163 |
+
if key not in sync_raw and key in at and isinstance(at[key], (dict, str)):
|
| 164 |
+
sync_raw[key] = at[key]
|
| 165 |
+
flat_sync = {}
|
| 166 |
+
for k in required_sync_keys:
|
| 167 |
+
flat_sync[k] = _flatten_sync_value(sync_raw.get(k))
|
| 168 |
+
at["synchronized_actions"] = flat_sync
|
| 169 |
+
|
| 170 |
+
# Remove any time-range keys from action_timeline that are not "synchronized_actions" (cleanup)
|
| 171 |
+
for key in list(at.keys()):
|
| 172 |
+
if key in required_sync_keys and key != "synchronized_actions":
|
| 173 |
+
at.pop(key, None)
|
| 174 |
+
segment["action_timeline"] = at
|
| 175 |
+
|
| 176 |
+
# 3. segment_info: only segment_number, total_segments, duration, location, continuity_markers
|
| 177 |
+
si = segment.get("segment_info") or {}
|
| 178 |
+
default_markers = {
|
| 179 |
+
"start_position": "",
|
| 180 |
+
"end_position": "",
|
| 181 |
+
"start_expression": "",
|
| 182 |
+
"end_expression": "",
|
| 183 |
+
"start_gesture": "",
|
| 184 |
+
"end_gesture": "",
|
| 185 |
+
"location_status": "Unchanged.",
|
| 186 |
+
}
|
| 187 |
+
if isinstance(si.get("continuity_markers"), dict):
|
| 188 |
+
default_markers.update(si["continuity_markers"])
|
| 189 |
+
allowed_si = {
|
| 190 |
+
"segment_number": si.get("segment_number", 1),
|
| 191 |
+
"total_segments": si.get("total_segments", 1),
|
| 192 |
+
"duration": duration_str,
|
| 193 |
+
"location": si.get("location") or "Same as previous segment.",
|
| 194 |
+
"continuity_markers": default_markers,
|
| 195 |
+
}
|
| 196 |
+
segment["segment_info"] = allowed_si
|
| 197 |
+
|
| 198 |
+
return segment
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class SegmentPlanItem(BaseModel):
|
| 202 |
+
"""One segment in the AI-decided plan: duration and dialogue."""
|
| 203 |
+
duration_seconds: int # 4, 6, or 8
|
| 204 |
+
dialogue: str
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class SegmentPlan(BaseModel):
|
| 208 |
+
"""AI-generated plan: how to split the script into segments with chosen durations."""
|
| 209 |
+
segments: List[SegmentPlanItem]
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
async def generate_segment_plan_ai(
|
| 213 |
+
script: str,
|
| 214 |
+
style: str = "professional",
|
| 215 |
+
allowed_durations: Optional[List[int]] = None,
|
| 216 |
+
model: str = "gpt-5.2",
|
| 217 |
+
api_key: Optional[str] = None,
|
| 218 |
+
) -> List[Dict[str, Any]]:
|
| 219 |
+
"""
|
| 220 |
+
Let AI decide how many segments to create and which duration (4, 6, or 8 sec) each gets.
|
| 221 |
+
Returns list of {"duration_seconds": int, "dialogue": str}.
|
| 222 |
+
"""
|
| 223 |
+
client = OpenAI(api_key=api_key or os.getenv("OPENAI_API_KEY"))
|
| 224 |
+
allowed = allowed_durations or [4, 6, 8]
|
| 225 |
+
allowed = [d for d in allowed if d in SEGMENT_DURATION_SECONDS]
|
| 226 |
+
if not allowed:
|
| 227 |
+
allowed = [4, 6, 8]
|
| 228 |
+
allowed_str = ", ".join(str(d) for d in sorted(allowed))
|
| 229 |
+
|
| 230 |
+
user_prompt = f"""You are a video script editor. Your goal is to use as FEW segments as possible so the script becomes ready in fewer videos (faster and cost-optimal).
|
| 231 |
+
|
| 232 |
+
RULES:
|
| 233 |
+
- MINIMIZE the total number of segments. Prefer LONGER segments (8s first, then 6s, use 4s only when necessary).
|
| 234 |
+
- Use ONLY these durations (seconds): {allowed_str}.
|
| 235 |
+
- Assign each part of the script to exactly one segment. No overlap. No repeated text. Cover the entire script.
|
| 236 |
+
- Prefer 8-second segments: pack ~18β22 words per 8s segment (~2.2β2.8 words/sec). Use 6s (~14β17 words) or 4s (~8β11 words) only when a natural break is too short for 8s.
|
| 237 |
+
- Prefer natural break points (sentence boundaries, clauses) but prioritize FEWER segments over perfect phrasingβcombine short sentences into one 8s segment when reasonable.
|
| 238 |
+
|
| 239 |
+
SCRIPT:
|
| 240 |
+
\"\"\"{script.strip()}\"\"\"
|
| 241 |
+
|
| 242 |
+
STYLE CONTEXT: {style}
|
| 243 |
+
|
| 244 |
+
Output a JSON object with a single key "segments", which is an array of objects. Each object has:
|
| 245 |
+
- "duration_seconds": number (only {allowed_str})
|
| 246 |
+
- "dialogue": string (the exact text for that segment, no quotes/formatting changes)
|
| 247 |
+
|
| 248 |
+
Example format:
|
| 249 |
+
{{"segments": [{{"duration_seconds": 6, "dialogue": "Hello and welcome."}}, {{"duration_seconds": 8, "dialogue": "Today we will cover three main points."}}]}}
|
| 250 |
+
|
| 251 |
+
Return ONLY valid JSON, no markdown."""
|
| 252 |
+
|
| 253 |
+
response = client.chat.completions.create(
|
| 254 |
+
model=model,
|
| 255 |
+
messages=[
|
| 256 |
+
{"role": "system", "content": "You output only valid JSON. No explanation, no markdown."},
|
| 257 |
+
{"role": "user", "content": user_prompt},
|
| 258 |
+
],
|
| 259 |
+
response_format={"type": "json_object"},
|
| 260 |
+
)
|
| 261 |
+
text = response.choices[0].message.content or "{}"
|
| 262 |
+
data = json.loads(text)
|
| 263 |
+
raw = data.get("segments", [])
|
| 264 |
+
out = []
|
| 265 |
+
for i, item in enumerate(raw):
|
| 266 |
+
d = item.get("duration_seconds", 8)
|
| 267 |
+
if d not in SEGMENT_DURATION_SECONDS:
|
| 268 |
+
d = 8
|
| 269 |
+
if d not in allowed:
|
| 270 |
+
d = allowed[0]
|
| 271 |
+
out.append({"duration_seconds": d, "dialogue": (item.get("dialogue") or "").strip()})
|
| 272 |
+
return out
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# Higher words_per_second = fewer segments = faster + cost-optimal (fewer videos to generate).
|
| 276 |
+
DEFAULT_WORDS_PER_SECOND = 2.5
|
| 277 |
+
|
| 278 |
+
|
| 279 |
def split_script_into_segments(
|
| 280 |
script: str,
|
| 281 |
seconds_per_segment: int = 8,
|
| 282 |
+
words_per_second: float = DEFAULT_WORDS_PER_SECOND
|
| 283 |
) -> List[str]:
|
| 284 |
"""
|
| 285 |
+
Split script into segments based on timing.
|
| 286 |
+
Uses a higher default words_per_second to minimize segment count (fewer videos, faster, cheaper).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
"""
|
| 288 |
sentences = re.split(r'(?<=[.!?])\s+', script.strip())
|
| 289 |
sentences = [s.strip() for s in sentences if s.strip()]
|
|
|
|
| 336 |
|
| 337 |
β οΈ CRITICAL: Your output will be VALIDATED. ANY field under minimum word count will be REJECTED.
|
| 338 |
|
| 339 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 340 |
+
π¨ CONTENT POLICY - MANDATORY COMPLIANCE π¨
|
| 341 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 342 |
+
|
| 343 |
+
YOU MUST AVOID ALL OF THE FOLLOWING IN YOUR GENERATED PROMPTS:
|
| 344 |
+
|
| 345 |
+
β NO REAL PEOPLE:
|
| 346 |
+
- NO politicians (Trump, Biden, Modi, Putin, etc.)
|
| 347 |
+
- NO celebrities (Taylor Swift, BeyoncΓ©, Kardashians, etc.)
|
| 348 |
+
- NO athletes (LeBron, Ronaldo, Messi, etc.)
|
| 349 |
+
- NO tech CEOs (Elon Musk, Bezos, Zuckerberg, Gates, etc.)
|
| 350 |
+
- NO public figures of any kind
|
| 351 |
+
β USE: "a business executive", "a singer", "an athlete", "a tech entrepreneur"
|
| 352 |
+
|
| 353 |
+
β NO COPYRIGHTED CONTENT:
|
| 354 |
+
- NO brand names (Nike, Apple, Starbucks, McDonald's, Tesla, etc.)
|
| 355 |
+
- NO characters (Spider-Man, Batman, Mickey Mouse, etc.)
|
| 356 |
+
- NO trademarked terms
|
| 357 |
+
β USE: "athletic shoes", "tech company", "coffee shop", "a superhero"
|
| 358 |
+
|
| 359 |
+
β NO SENSITIVE CONTENT:
|
| 360 |
+
- NO violence, blood, gore, weapons
|
| 361 |
+
- NO explicit, sexual, or nude content
|
| 362 |
+
- NO hate speech or discriminatory language
|
| 363 |
+
- NO illegal activities or dangerous behavior
|
| 364 |
+
|
| 365 |
+
β
INSTEAD, USE:
|
| 366 |
+
- Generic roles: "a professional", "an entrepreneur", "a performer"
|
| 367 |
+
- Generic descriptions: "athletic footwear", "smartphone", "sports car"
|
| 368 |
+
- Safe, brand-neutral language
|
| 369 |
+
- Family-friendly content only
|
| 370 |
+
|
| 371 |
+
β οΈ If the script contains any violations, describe the character/scene generically WITHOUT using specific names or brands.
|
| 372 |
+
|
| 373 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 374 |
π¨ CRITICAL: CHARACTER MUST MATCH REFERENCE IMAGE EXACTLY π¨
|
| 375 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 506 |
return len(re.findall(r"\b\w+\b", text or ""))
|
| 507 |
|
| 508 |
|
| 509 |
+
def _duration_string_to_seconds(duration_str: str) -> Optional[int]:
|
| 510 |
+
"""Map Veo duration string to seconds (4, 6, or 8)."""
|
| 511 |
+
for sec, (dstr, _) in DURATION_SYNC_KEYS.items():
|
| 512 |
+
if dstr == duration_str:
|
| 513 |
+
return sec
|
| 514 |
+
return None
|
| 515 |
+
|
| 516 |
+
|
| 517 |
def validate_segments_payload(
|
| 518 |
payload: Dict[str, Any],
|
| 519 |
expected_segments: int
|
| 520 |
) -> List[str]:
|
| 521 |
"""
|
| 522 |
+
Validate the generated payload against strict rules.
|
| 523 |
+
Supports variable segment durations (4, 6, or 8 seconds).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
"""
|
| 525 |
errors: List[str] = []
|
| 526 |
segs = payload.get("segments", [])
|
|
|
|
| 528 |
if len(segs) != expected_segments:
|
| 529 |
errors.append(f"Expected {expected_segments} segments, got {len(segs)}.")
|
| 530 |
|
|
|
|
| 531 |
physical_blocks, clothing_blocks, environment_blocks = [], [], []
|
| 532 |
|
| 533 |
for i, seg in enumerate(segs, start=1):
|
|
|
|
| 534 |
si = seg.get("segment_info", {})
|
| 535 |
+
duration_str = si.get("duration", "")
|
| 536 |
+
duration_sec = _duration_string_to_seconds(duration_str)
|
| 537 |
+
if duration_sec is None:
|
| 538 |
+
errors.append(
|
| 539 |
+
f"Segment {i}: duration must be one of "
|
| 540 |
+
f"{[DURATION_SYNC_KEYS[s][0] for s in SEGMENT_DURATION_SECONDS]}, got {duration_str!r}."
|
| 541 |
+
)
|
| 542 |
+
required_sync_keys = set(get_sync_keys_for_duration(8))
|
| 543 |
+
else:
|
| 544 |
+
required_sync_keys = set(get_sync_keys_for_duration(duration_sec))
|
| 545 |
+
|
| 546 |
if si.get("total_segments") != expected_segments:
|
| 547 |
errors.append(
|
| 548 |
f"Segment {i}: total_segments should be {expected_segments}, "
|
| 549 |
f"got {si.get('total_segments')}."
|
| 550 |
)
|
| 551 |
|
|
|
|
| 552 |
sync = seg.get("action_timeline", {}).get("synchronized_actions", {})
|
| 553 |
if set(sync.keys()) != required_sync_keys:
|
| 554 |
errors.append(
|
| 555 |
f"Segment {i}: synchronized_actions must have keys "
|
| 556 |
+
f"{sorted(required_sync_keys)} for duration {duration_str}."
|
| 557 |
)
|
| 558 |
|
| 559 |
# Word-count checks
|
|
|
|
| 593 |
return errors
|
| 594 |
|
| 595 |
|
| 596 |
+
def _build_payload_summary_for_ai(payload: Dict[str, Any], max_chars_per_field: int = 400) -> str:
|
| 597 |
+
"""Build a compact summary of the payload for AI validation (stay within context limits)."""
|
| 598 |
+
def _excerpt(s: str, limit: int) -> str:
|
| 599 |
+
s = (s or "").strip()
|
| 600 |
+
if len(s) <= limit:
|
| 601 |
+
return s
|
| 602 |
+
return s[:limit] + "..."
|
| 603 |
+
segs = payload.get("segments", [])
|
| 604 |
+
parts = []
|
| 605 |
+
for i, seg in enumerate(segs, start=1):
|
| 606 |
+
dialogue = (seg.get("action_timeline") or {}).get("dialogue", "")
|
| 607 |
+
physical = _excerpt((seg.get("character_description") or {}).get("physical") or "", max_chars_per_field)
|
| 608 |
+
environment = _excerpt((seg.get("scene_continuity") or {}).get("environment") or "", max_chars_per_field)
|
| 609 |
+
parts.append(
|
| 610 |
+
f"Segment {i}:\n dialogue: {dialogue!r}\n physical (excerpt): {physical}\n environment (excerpt): {environment}"
|
| 611 |
+
)
|
| 612 |
+
return "\n\n".join(parts)
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def validate_segments_payload_with_ai(
|
| 616 |
+
payload: Dict[str, Any],
|
| 617 |
+
script: Optional[str] = None,
|
| 618 |
+
model: str = "gpt-4o-mini",
|
| 619 |
+
api_key: Optional[str] = None,
|
| 620 |
+
) -> Dict[str, Any]:
|
| 621 |
+
"""
|
| 622 |
+
Run rule-based validation, then optional AI review for content policy and consistency.
|
| 623 |
+
|
| 624 |
+
Returns:
|
| 625 |
+
{
|
| 626 |
+
"valid": bool (schema checks passed),
|
| 627 |
+
"schema_errors": list[str],
|
| 628 |
+
"ai_checked": bool,
|
| 629 |
+
"ai_valid": bool | None (None if AI not run),
|
| 630 |
+
"ai_warnings": list[str],
|
| 631 |
+
"ai_suggestions": list[str],
|
| 632 |
+
}
|
| 633 |
+
"""
|
| 634 |
+
expected = len(payload.get("segments", []))
|
| 635 |
+
schema_errors = validate_segments_payload(payload, expected)
|
| 636 |
+
result = {
|
| 637 |
+
"valid": len(schema_errors) == 0,
|
| 638 |
+
"schema_errors": schema_errors,
|
| 639 |
+
"ai_checked": False,
|
| 640 |
+
"ai_valid": None,
|
| 641 |
+
"ai_warnings": [],
|
| 642 |
+
"ai_suggestions": [],
|
| 643 |
+
}
|
| 644 |
+
if expected == 0:
|
| 645 |
+
return result
|
| 646 |
+
|
| 647 |
+
client = OpenAI(api_key=api_key or os.getenv("OPENAI_API_KEY"))
|
| 648 |
+
if not client.api_key:
|
| 649 |
+
return result
|
| 650 |
+
|
| 651 |
+
summary = _build_payload_summary_for_ai(payload)
|
| 652 |
+
prompt = f"""You are a video prompt quality reviewer. Review this segments payload summary for:
|
| 653 |
+
|
| 654 |
+
1. CONTENT POLICY: No real people (politicians, celebrities, athletes, CEOs), no brand names, no copyrighted characters, no violence/explicit/discriminatory content. Flag any violations.
|
| 655 |
+
2. CONSISTENCY: Character (physical) and environment should be the same across segments for visual continuity. Note if they seem to change.
|
| 656 |
+
3. DIALOGUE: Each segment's dialogue should be distinct (no overlap) and flow in order. Note any issues.
|
| 657 |
+
|
| 658 |
+
Payload summary:
|
| 659 |
+
{summary}
|
| 660 |
+
"""
|
| 661 |
+
if script:
|
| 662 |
+
prompt += f"\nOriginal script (for reference):\n{script[:2000]}"
|
| 663 |
+
|
| 664 |
+
prompt += """
|
| 665 |
+
|
| 666 |
+
Respond with a JSON object only:
|
| 667 |
+
{
|
| 668 |
+
"valid": true or false,
|
| 669 |
+
"warnings": ["list of specific issues found, or empty if none"],
|
| 670 |
+
"suggestions": ["list of short improvement suggestions, or empty if none"]
|
| 671 |
+
}
|
| 672 |
+
"""
|
| 673 |
+
|
| 674 |
+
try:
|
| 675 |
+
response = client.chat.completions.create(
|
| 676 |
+
model=model,
|
| 677 |
+
messages=[{"role": "user", "content": prompt}],
|
| 678 |
+
response_format={"type": "json_object"},
|
| 679 |
+
)
|
| 680 |
+
raw = response.choices[0].message.content or "{}"
|
| 681 |
+
data = json.loads(raw)
|
| 682 |
+
result["ai_checked"] = True
|
| 683 |
+
result["ai_valid"] = data.get("valid", True)
|
| 684 |
+
result["ai_warnings"] = data.get("warnings") or []
|
| 685 |
+
result["ai_suggestions"] = data.get("suggestions") or []
|
| 686 |
+
except Exception as e:
|
| 687 |
+
result["ai_warnings"] = [f"AI validation could not run: {str(e)}"]
|
| 688 |
+
return result
|
| 689 |
+
|
| 690 |
+
|
| 691 |
def generate_segments_payload(
|
| 692 |
inputs: VeoInputs,
|
| 693 |
image_bytes: Optional[bytes] = None,
|
| 694 |
+
model: str = "gpt-5.2",
|
| 695 |
api_key: Optional[str] = None
|
| 696 |
) -> Dict[str, Any]:
|
| 697 |
"""
|
|
|
|
| 799 |
# ALWAYS return payload (even with warnings)
|
| 800 |
return payload
|
| 801 |
|
| 802 |
+
|
| 803 |
+
def build_single_segment_prompt(
|
| 804 |
+
inputs: VeoInputs,
|
| 805 |
+
segment_text: str,
|
| 806 |
+
segment_index: int,
|
| 807 |
+
total_segments: int,
|
| 808 |
+
reference_character: Optional[Dict[str, Any]] = None,
|
| 809 |
+
reference_scene: Optional[Dict[str, Any]] = None,
|
| 810 |
+
segment_duration_seconds: int = 8,
|
| 811 |
+
) -> str:
|
| 812 |
+
"""
|
| 813 |
+
Build prompt for generating a SINGLE segment
|
| 814 |
+
|
| 815 |
+
Args:
|
| 816 |
+
inputs: Video generation inputs
|
| 817 |
+
segment_text: The dialogue for this specific segment
|
| 818 |
+
segment_index: Index of this segment (0-based)
|
| 819 |
+
total_segments: Total number of segments
|
| 820 |
+
reference_character: Character description from first segment (for consistency)
|
| 821 |
+
reference_scene: Scene description from first segment (for consistency)
|
| 822 |
+
|
| 823 |
+
Returns:
|
| 824 |
+
Formatted prompt string for single segment
|
| 825 |
+
"""
|
| 826 |
+
knobs = inputs.model_dump()
|
| 827 |
+
segment_number = segment_index + 1
|
| 828 |
+
|
| 829 |
+
# Build character consistency section
|
| 830 |
+
char_consistency = ""
|
| 831 |
+
if reference_character:
|
| 832 |
+
char_consistency = f"""
|
| 833 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 834 |
+
π CHARACTER CONSISTENCY (MANDATORY)
|
| 835 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 836 |
+
|
| 837 |
+
You MUST use these EXACT character descriptions (copy-paste from Segment 1):
|
| 838 |
+
|
| 839 |
+
physical (COPY EXACTLY):
|
| 840 |
+
\"\"\"{reference_character.get('physical', '')}\"\"\"
|
| 841 |
+
|
| 842 |
+
clothing (COPY EXACTLY):
|
| 843 |
+
\"\"\"{reference_character.get('clothing', '')}\"\"\"
|
| 844 |
+
|
| 845 |
+
β οΈ DO NOT modify these descriptions in any way - they must be IDENTICAL!
|
| 846 |
+
"""
|
| 847 |
+
else:
|
| 848 |
+
char_consistency = f"""
|
| 849 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 850 |
+
π¨ CHARACTER FROM REFERENCE IMAGE (SEGMENT 1)
|
| 851 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 852 |
+
|
| 853 |
+
A REFERENCE IMAGE IS PROVIDED. Analyze it carefully and describe:
|
| 854 |
+
- EXACT hair color, style, and length
|
| 855 |
+
- EXACT eye color and facial features
|
| 856 |
+
- EXACT skin tone and age appearance
|
| 857 |
+
- EXACT clothing (color, pattern, style)
|
| 858 |
+
- Any distinctive features (freckles, facial hair, glasses, etc.)
|
| 859 |
+
|
| 860 |
+
This description will be reused for ALL segments to ensure visual consistency.
|
| 861 |
+
"""
|
| 862 |
+
|
| 863 |
+
# Build scene consistency section
|
| 864 |
+
scene_consistency = ""
|
| 865 |
+
if reference_scene:
|
| 866 |
+
scene_consistency = f"""
|
| 867 |
+
βββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββββββββββββββββββββββββββββββ
|
| 868 |
+
π SCENE CONSISTENCY (MANDATORY)
|
| 869 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 870 |
+
|
| 871 |
+
You MUST use these EXACT scene descriptions (copy-paste from Segment 1):
|
| 872 |
+
|
| 873 |
+
environment (COPY EXACTLY):
|
| 874 |
+
\"\"\"{reference_scene.get('environment', '')}\"\"\"
|
| 875 |
+
|
| 876 |
+
lighting_state (COPY EXACTLY):
|
| 877 |
+
\"\"\"{reference_scene.get('lighting_state', '')}\"\"\"
|
| 878 |
+
|
| 879 |
+
props_in_frame (COPY EXACTLY):
|
| 880 |
+
\"\"\"{reference_scene.get('props_in_frame', '')}\"\"\"
|
| 881 |
+
|
| 882 |
+
background_elements (COPY EXACTLY):
|
| 883 |
+
\"\"\"{reference_scene.get('background_elements', '')}\"\"\"
|
| 884 |
+
|
| 885 |
+
β οΈ DO NOT modify these descriptions in any way - they must be IDENTICAL!
|
| 886 |
+
"""
|
| 887 |
+
|
| 888 |
+
prompt = f"""
|
| 889 |
+
You are a STRICT production-grade JSON generator for Veo 3 video prompts.
|
| 890 |
+
|
| 891 |
+
β οΈ CRITICAL: Your output will be VALIDATED. ANY field under minimum word count will be REJECTED.
|
| 892 |
+
|
| 893 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 894 |
+
π¨ CONTENT POLICY - MANDATORY COMPLIANCE π¨
|
| 895 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 896 |
+
|
| 897 |
+
YOU MUST AVOID ALL OF THE FOLLOWING:
|
| 898 |
+
β NO real people names (politicians, celebrities, athletes, tech CEOs)
|
| 899 |
+
β NO brand names (Nike, Apple, Tesla, Starbucks, etc.)
|
| 900 |
+
β NO copyrighted characters (Spider-Man, Batman, etc.)
|
| 901 |
+
β NO sensitive content (violence, explicit, hate speech)
|
| 902 |
+
|
| 903 |
+
β
USE: Generic roles ("a professional", "athletic shoes", "a smartphone")
|
| 904 |
+
|
| 905 |
+
Generating: Segment {segment_number} of {total_segments}
|
| 906 |
+
|
| 907 |
+
{char_consistency}
|
| 908 |
+
|
| 909 |
+
{scene_consistency}
|
| 910 |
+
|
| 911 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 912 |
+
MANDATORY WORD COUNT REQUIREMENTS - WILL BE VALIDATED
|
| 913 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 914 |
+
|
| 915 |
+
character_description.physical: MINIMUM 150 WORDS {'(COPY from above)' if reference_character else '(from reference image)'}
|
| 916 |
+
character_description.clothing: MINIMUM 100 WORDS {'(COPY from above)' if reference_character else '(from reference image)'}
|
| 917 |
+
character_description.current_state: MINIMUM 50 WORDS (segment-specific)
|
| 918 |
+
character_description.voice_matching: MINIMUM 50 WORDS (segment-specific)
|
| 919 |
+
|
| 920 |
+
scene_continuity.environment: MINIMUM 150 WORDS {'(COPY from above)' if reference_scene else '(create detailed description)'}
|
| 921 |
+
scene_continuity.camera_position: MINIMUM 50 WORDS
|
| 922 |
+
scene_continuity.lighting_state: MINIMUM 40 WORDS {'(COPY from above)' if reference_scene else ''}
|
| 923 |
+
scene_continuity.props_in_frame: MINIMUM 40 WORDS {'(COPY from above)' if reference_scene else ''}
|
| 924 |
+
scene_continuity.background_elements: MINIMUM 40 WORDS {'(COPY from above)' if reference_scene else ''}
|
| 925 |
+
|
| 926 |
+
action_timeline.micro_expressions: MINIMUM 40 WORDS
|
| 927 |
+
|
| 928 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 929 |
+
SEGMENT {segment_number} DETAILS
|
| 930 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 931 |
+
|
| 932 |
+
Dialogue for this segment (EXACT - do not modify):
|
| 933 |
+
\"\"\"{segment_text}\"\"\"
|
| 934 |
+
|
| 935 |
+
π¨ SCHEMA RULES (validation will fail otherwise):
|
| 936 |
+
- segment_info: Include ONLY segment_number, total_segments, duration, location, continuity_markers. Do NOT put dialogue, style_settings, or content_constraints inside segment_info.
|
| 937 |
+
- action_timeline: MUST have a top-level "dialogue" field set to the EXACT dialogue above (the quoted segment text). Video APIs read action_timeline.dialogue for this segment's lines.
|
| 938 |
+
- action_timeline.synchronized_actions: Must be a FLAT object. Each key (e.g. "0:00-0:02") must map to a SINGLE string (e.g. "Subject in frame, begins speaking."). Do NOT use nested objects like {{ "action": "...", "camera_movement": "..." }} under time keys.
|
| 939 |
+
|
| 940 |
+
Duration: "{get_duration_string(segment_duration_seconds)}"
|
| 941 |
+
Segment number: {segment_number}
|
| 942 |
+
Total segments: {total_segments}
|
| 943 |
+
Synchronized actions keys (flat string values only): {", ".join(get_sync_keys_for_duration(segment_duration_seconds))}
|
| 944 |
+
|
| 945 |
+
STYLE SETTINGS:
|
| 946 |
+
{knobs}
|
| 947 |
+
|
| 948 |
+
π¨ CRITICAL: NO BLUR TRANSITIONS π¨
|
| 949 |
+
- Segment starts SHARP and CLEAR at 0:00
|
| 950 |
+
- camera_movement describes movement from an already-focused state
|
| 951 |
+
- NO fade-in, NO blur, NO gradual focus at segment start
|
| 952 |
+
|
| 953 |
+
OUTPUT FORMAT:
|
| 954 |
+
Return ONLY valid JSON for this single segment (no markdown, no code blocks):
|
| 955 |
+
{{
|
| 956 |
+
"segment_info": {{ "segment_number", "total_segments", "duration", "location", "continuity_markers" only }},
|
| 957 |
+
"character_description": {{ ... }},
|
| 958 |
+
"scene_continuity": {{ ... }},
|
| 959 |
+
"action_timeline": {{ "dialogue": "<exact segment dialogue>", "synchronized_actions": {{ "0:00-0:02": "string", ... }}, "micro_expressions", "breathing_rhythm", "location_transition", "continuity_checkpoint" }}
|
| 960 |
+
}}
|
| 961 |
+
"""
|
| 962 |
+
|
| 963 |
+
return prompt
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
async def generate_single_segment(
|
| 967 |
+
inputs: VeoInputs,
|
| 968 |
+
segment_text: str,
|
| 969 |
+
segment_index: int,
|
| 970 |
+
total_segments: int,
|
| 971 |
+
image_bytes: Optional[bytes] = None,
|
| 972 |
+
reference_character: Optional[Dict[str, Any]] = None,
|
| 973 |
+
reference_scene: Optional[Dict[str, Any]] = None,
|
| 974 |
+
segment_duration_seconds: int = 8,
|
| 975 |
+
model: str = "gpt-5.2",
|
| 976 |
+
api_key: Optional[str] = None
|
| 977 |
+
) -> Dict[str, Any]:
|
| 978 |
+
"""
|
| 979 |
+
Generate a SINGLE segment using GPT-5.2
|
| 980 |
+
|
| 981 |
+
Args:
|
| 982 |
+
inputs: Video generation inputs
|
| 983 |
+
segment_text: Dialogue for this segment
|
| 984 |
+
segment_index: Index of this segment (0-based)
|
| 985 |
+
total_segments: Total number of segments
|
| 986 |
+
image_bytes: Reference image (only needed for first segment)
|
| 987 |
+
reference_character: Character description to reuse (from segment 1)
|
| 988 |
+
reference_scene: Scene description to reuse (from segment 1)
|
| 989 |
+
model: OpenAI model to use
|
| 990 |
+
api_key: OpenAI API key
|
| 991 |
+
|
| 992 |
+
Returns:
|
| 993 |
+
Single segment dictionary
|
| 994 |
+
"""
|
| 995 |
+
# Initialize OpenAI client
|
| 996 |
+
client = OpenAI(api_key=api_key or os.getenv('OPENAI_API_KEY'))
|
| 997 |
+
|
| 998 |
+
# Build prompt for this specific segment
|
| 999 |
+
user_prompt = build_single_segment_prompt(
|
| 1000 |
+
inputs=inputs,
|
| 1001 |
+
segment_text=segment_text,
|
| 1002 |
+
segment_index=segment_index,
|
| 1003 |
+
total_segments=total_segments,
|
| 1004 |
+
reference_character=reference_character,
|
| 1005 |
+
reference_scene=reference_scene,
|
| 1006 |
+
segment_duration_seconds=segment_duration_seconds,
|
| 1007 |
+
)
|
| 1008 |
+
|
| 1009 |
+
# Prepare messages
|
| 1010 |
+
system_content = "You are a precise JSON-only generator that must satisfy a strict schema."
|
| 1011 |
+
|
| 1012 |
+
messages = [
|
| 1013 |
+
{
|
| 1014 |
+
"role": "system",
|
| 1015 |
+
"content": system_content
|
| 1016 |
+
},
|
| 1017 |
+
{
|
| 1018 |
+
"role": "user",
|
| 1019 |
+
"content": []
|
| 1020 |
+
}
|
| 1021 |
+
]
|
| 1022 |
+
|
| 1023 |
+
# Add text prompt
|
| 1024 |
+
messages[1]["content"].append({
|
| 1025 |
+
"type": "text",
|
| 1026 |
+
"text": user_prompt
|
| 1027 |
+
})
|
| 1028 |
+
|
| 1029 |
+
# Add image only for first segment (character reference)
|
| 1030 |
+
if image_bytes and segment_index == 0:
|
| 1031 |
+
encoded_image = base64.b64encode(image_bytes).decode("utf-8")
|
| 1032 |
+
messages[1]["content"].append({
|
| 1033 |
+
"type": "image_url",
|
| 1034 |
+
"image_url": {
|
| 1035 |
+
"url": f"data:image/jpeg;base64,{encoded_image}"
|
| 1036 |
+
}
|
| 1037 |
+
})
|
| 1038 |
+
|
| 1039 |
+
required_keys = get_sync_keys_for_duration(segment_duration_seconds)
|
| 1040 |
+
duration_str = get_duration_string(segment_duration_seconds)
|
| 1041 |
+
|
| 1042 |
+
# For 8s we use strict Segment schema; for 4s/6s we use JSON mode (variable sync keys)
|
| 1043 |
+
if segment_duration_seconds == 8:
|
| 1044 |
+
response = client.beta.chat.completions.parse(
|
| 1045 |
+
model=model,
|
| 1046 |
+
response_format=Segment,
|
| 1047 |
+
messages=messages,
|
| 1048 |
+
)
|
| 1049 |
+
segment = response.choices[0].message.parsed.model_dump(by_alias=True)
|
| 1050 |
+
else:
|
| 1051 |
+
response = client.chat.completions.create(
|
| 1052 |
+
model=model,
|
| 1053 |
+
messages=messages,
|
| 1054 |
+
response_format={"type": "json_object"},
|
| 1055 |
+
)
|
| 1056 |
+
raw = response.choices[0].message.content or "{}"
|
| 1057 |
+
segment = json.loads(raw)
|
| 1058 |
+
# Basic normalize for 4s/6s: duration and sync keys (full normalizer runs below)
|
| 1059 |
+
si = segment.setdefault("segment_info", {})
|
| 1060 |
+
si["duration"] = duration_str
|
| 1061 |
+
si["total_segments"] = total_segments
|
| 1062 |
+
si["segment_number"] = segment_index + 1
|
| 1063 |
+
at = segment.setdefault("action_timeline", {})
|
| 1064 |
+
sync = at.setdefault("synchronized_actions", {})
|
| 1065 |
+
for k in required_keys:
|
| 1066 |
+
if k not in sync:
|
| 1067 |
+
sync[k] = (list(sync.values())[0] if sync else "Subject in frame, natural delivery.")
|
| 1068 |
+
at["synchronized_actions"] = {k: sync[k] for k in required_keys}
|
| 1069 |
+
|
| 1070 |
+
# Always normalize: ensure action_timeline.dialogue, flat synchronized_actions, clean segment_info
|
| 1071 |
+
segment = normalize_segment_for_api(
|
| 1072 |
+
segment, segment_text, required_keys, duration_str
|
| 1073 |
+
)
|
| 1074 |
+
|
| 1075 |
+
print(f"β
Generated segment {segment_index + 1}/{total_segments} ({segment_duration_seconds}s)")
|
| 1076 |
+
|
| 1077 |
+
return segment
|
| 1078 |
+
|