sushilideaclan01 commited on
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 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(file: UploadFile = File(...)):
 
 
 
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
- # Compress and store, get hosted URL
32
- hosted_url = await compress_and_store_image(data_url, public_url)
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
- GPT-4o Prompt Generation API
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
- generate_segments_payload,
15
- split_script_into_segments
 
 
 
 
 
 
 
 
 
 
16
  )
17
  from openai import OpenAI
18
  import os
19
  import json
 
20
 
21
  router = APIRouter()
22
 
23
 
24
- class PromptGenerationRequest(BaseModel):
25
- """Request for prompt generation"""
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-4o"),
54
- image: UploadFile = File(...)
 
 
 
55
  ):
56
  """
57
- Generate structured video prompts using GPT-4o
58
 
59
  This endpoint:
60
- 1. Splits the script into 8-second segments
61
- 2. Generates detailed production prompts using GPT-4o
62
- 3. Validates the output against strict rules
63
- 4. Returns structured JSON for video generation
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
- Validated segments payload ready for video generation
 
 
 
 
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
- # API/network errors only (validation is non-blocking now)
118
- raise HTTPException(
119
- status_code=500,
120
- detail=f"Prompt generation failed: {str(e)}"
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.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(payload: dict):
157
  """
158
- Validate a segments payload against strict rules
159
 
160
- Use this to check if a manually created or modified payload is valid
 
 
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
- "valid": False,
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-4o prompt generation is available
190
  """
191
  import os
192
 
@@ -194,7 +339,7 @@ async def prompt_status():
194
 
195
  return {
196
  "available": bool(openai_key),
197
- "message": "GPT-4o is configured" if openai_key
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-4o analyze:
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
- # Call GPT-4o with vision
311
- response = client.chat.completions.create(
312
- model="gpt-4o",
313
- messages=[
314
- {
315
- "role": "user",
316
- "content": [
317
- {
318
- "type": "text",
319
- "text": refinement_instructions
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
- # Parse the response
335
- refined_prompt = json.loads(response.choices[0].message.content)
 
 
 
 
 
 
 
 
 
 
 
 
336
 
337
- print(f"βœ… Prompt refined for visual continuity")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- 'code': callback_data.code
 
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 | GPT-4o script analysis & segmentation |
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:3000`.
 
 
 
 
 
 
 
 
 
 
 
 
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 with GPT-4o
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
- {/* Error Message */}
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-8">
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
- generatePrompts,
 
 
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, setRetryState, updateSegments, addTaskId, removeTaskId, state } = useGeneration();
41
- const { retryState, generatedVideos, segments, isCancelling } = state;
 
 
 
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
- const handleCancelRetry = () => {
145
- setRetryState(null);
146
- setIsGenerating(false);
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
- console.warn('Failed to save draft:', error);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 || !formState.script.trim()) return;
 
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
- const formData = new FormData();
297
- formData.append('script', formState.script);
298
- formData.append('style', formState.style || 'clean, lifestyle UGC');
299
- formData.append('jsonFormat', 'standard');
300
- formData.append('continuationMode', 'true');
301
- formData.append('voiceType', formState.voiceType || '');
302
- formData.append('energyLevel', formState.energyLevel || '');
303
- formData.append('settingMode', 'single');
304
- formData.append('cameraStyle', formState.cameraStyle || '');
305
- formData.append('narrativeStyle', formState.narrativeStyle || '');
306
- formData.append('image', imageFile);
307
-
308
- const payload = await generatePrompts(formData);
309
-
310
- if (!payload?.segments?.length) {
311
- throw new Error('No segments generated from script');
312
- }
 
 
 
 
 
 
 
 
 
 
313
 
314
- const segments = payload.segments;
315
- updateProgress(`Generated ${segments.length} segments. Starting video generation...`);
316
- startGeneration(segments);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
  // Track current reference image (starts with original)
319
  let currentImageFile = imageFile;
320
- const generatedVideos: GeneratedVideo[] = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
 
322
  // Step 2: Generate videos segment by segment with frame continuity
323
- for (let i = 0; i < segments.length; i++) {
324
- const segment = segments[i];
325
- const isLastSegment = i === segments.length - 1;
326
 
327
  updateProgress(
328
- `Generating video ${i + 1} of ${segments.length}...${i > 0 ? ' (using last frame from previous)' : ''}`,
329
  i,
330
- segments.length
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 base64Data = whisperResult.frame_base64.split(',')[1] || whisperResult.frame_base64;
 
 
 
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: 'image/jpeg' });
393
- currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.jpg`, { type: 'image/jpeg' });
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 = segments[i + 1];
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
- segments[i + 1] = refined.refined_prompt as typeof nextSegment;
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 ${segments.length}`, i + 1, segments.length);
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
- // Enable retry mode
471
- setRetryState({
472
- failedSegmentIndex: generatedVideos.length, // Current segment that failed
473
- error: errorMessage
 
 
 
 
 
474
  });
475
- setStep('configuring'); // Go back to form, but with retry overlay
 
 
 
 
 
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: Generate prompts using GPT-4o
495
- updateProgress('Analyzing script with GPT-4o...');
496
-
497
- const formData = new FormData();
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
- updateProgress(`Using existing ${segments.length} segments for retry...`);
 
515
  } else {
516
- // Normal mode: generate new segments
517
- payload = await generatePrompts(formData);
518
- if (!payload?.segments?.length) {
519
- throw new Error('No segments generated from script');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}/2)`);
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
- // If cancelled, don't show retry option
628
- if (errorMessage.includes('cancelled') || isCancelling) {
629
- setError('Generation cancelled by user');
630
- setStep('error');
631
- } else {
632
- // Enable retry mode
633
- setRetryState({
634
- failedSegmentIndex: generatedVideos.length, // Current segment that failed
635
- error: errorMessage
636
- });
637
- setStep('configuring'); // Go back to form, but with retry overlay
 
 
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-4o
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: Generate prompts using GPT-4o
665
- // Note: Replicate can work without an image, but for consistency we encourage one
666
- updateProgress('Analyzing script with GPT-4o...');
667
 
668
- const formData = new FormData();
669
- formData.append('script', formState.script);
670
- formData.append('style', formState.style || 'clean, lifestyle UGC');
671
- formData.append('jsonFormat', 'standard');
672
- formData.append('continuationMode', 'true');
673
- formData.append('voiceType', formState.voiceType || '');
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
- // Create a placeholder image for GPT-4o (it needs one for analysis)
684
- // In production, you might want to handle this differently
685
- const placeholderBlob = new Blob(['placeholder'], { type: 'image/jpeg' });
686
- formData.append('image', placeholderBlob, 'placeholder.jpg');
687
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
 
689
- const payload = await generatePrompts(formData);
 
 
690
 
691
- if (!payload?.segments?.length) {
692
- throw new Error('No segments generated from script');
 
 
 
 
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 < segments.length; i++) {
705
- const segment = segments[i];
706
- const isLastSegment = i === segments.length - 1;
707
 
708
  updateProgress(
709
- `Generating video ${i + 1} of ${segments.length} with Replicate...${i > 0 ? ' (using last frame)' : ''}`,
710
  i,
711
- segments.length
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 base64Data = whisperResult.frame_base64.split(',')[1] || whisperResult.frame_base64;
 
 
 
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: 'image/jpeg' });
780
- currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.jpg`, { type: 'image/jpeg' });
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 = segments[i + 1];
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
- segments[i + 1] = refined.refined_prompt as typeof nextSegment;
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 ${segments.length}`, i + 1, segments.length);
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
- // Enable retry mode
856
- setRetryState({
857
- failedSegmentIndex: generatedVideos.length, // Current segment that failed
858
- error: errorMessage
 
 
 
 
859
  });
860
- setStep('configuring'); // Go back to form, but with retry overlay
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
- {provider === 'kling'
961
- ? 'Generate professional UGC videos with AI-powered segmentation'
962
- : 'Create unique videos with open-source models'
963
- }
 
 
 
 
 
964
  </p>
965
  </div>
966
  </div>
967
 
968
- {/* Retry Modal */}
969
- {retryState && (
970
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
971
- <motion.div
972
- initial={{ opacity: 0, scale: 0.95 }}
973
- animate={{ opacity: 1, scale: 1 }}
974
- className="bg-void-900 border border-void-700 rounded-2xl p-6 max-w-2xl w-full shadow-2xl overflow-y-auto max-h-[90vh]"
975
- >
976
- <div className="flex items-center gap-3 mb-4 text-red-400">
977
- <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
978
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
979
- </svg>
980
- <h3 className="text-xl font-bold">Generation Failed</h3>
981
- </div>
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
- <div className="flex justify-end gap-3">
1022
- <button
1023
- onClick={handleCancelRetry}
1024
- className="px-4 py-2 rounded-lg text-void-300 hover:text-white hover:bg-void-800 transition-colors"
1025
- >
1026
- Cancel
1027
- </button>
1028
- <button
1029
- onClick={handleRetrySubmit}
1030
- className="px-6 py-2 bg-gradient-to-r from-coral-500 to-coral-600 text-white font-semibold rounded-lg hover:from-coral-400 hover:to-coral-500 shadow-lg shadow-coral-500/20"
1031
- >
1032
- Retry Generation
1033
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1034
  </div>
1035
- </motion.div>
1036
- </div>
 
 
 
 
 
 
 
 
 
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
- <div className="card">
1071
- <label className="block text-sm font-semibold text-void-200 mb-3">
1072
- Script <span className="text-coral-400">*</span>
1073
- </label>
1074
- <textarea
1075
- name="script"
1076
- value={formState.script}
1077
- onChange={handleChange}
1078
- rows={10}
1079
- className="textarea-field font-mono text-sm"
1080
- placeholder="Enter your complete video script here...
 
1081
 
1082
  The AI will automatically analyze and segment your script into optimal video chunks, typically 8 seconds each."
1083
- required
1084
- />
1085
- <div className="flex items-center justify-between mt-3">
1086
- <p className="text-xs text-void-500">
1087
- AI will automatically segment your script
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- </div>
1099
 
1100
- {/* Style Input */}
1101
- <div className="card">
1102
- <label className="block text-sm font-semibold text-void-200 mb-3">
1103
- Visual Style
1104
- </label>
1105
- <textarea
1106
- name="style"
1107
- value={formState.style}
1108
- onChange={handleChange}
1109
- rows={3}
1110
- className="textarea-field"
1111
- placeholder="e.g., Cinematic, hyper-realistic, natural lighting, modern aesthetic, warm tones..."
1112
- />
1113
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- ${provider === 'kling' ? 'btn-primary' : 'btn-electric'}
 
 
 
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
- {list.map((provider, index) => (
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 &quot;Validate with AI&quot; 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 { klingCancel } = await import('@/utils/api');
177
- // Cancel all active tasks
178
- const currentTaskIds = [...state.activeTaskIds];
179
- const cancelPromises = currentTaskIds.map(taskId =>
180
- klingCancel(taskId).catch(err => {
181
- console.warn(`Failed to cancel task ${taskId}:`, err);
182
- })
183
- );
184
- await Promise.all(cancelPromises);
185
- // Clear all task IDs
186
- currentTaskIds.forEach(id => {
187
- dispatch({ type: 'REMOVE_TASK_ID', payload: id });
188
- });
189
- dispatch({ type: 'SET_TASK_ID', payload: null });
190
- dispatch({ type: 'SET_ERROR', payload: 'Generation cancelled by user' });
191
- dispatch({ type: 'SET_STEP', payload: 'error' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- export interface SynchronizedActions {
112
- '0:00-0:02': string;
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
- // If unauthorized, clear token
66
- if (response.status === 401) {
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: url,
120
- contentType: contentType
121
  });
122
- throw error;
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 apiRequest<KlingGenerateResponse>('/api/veo/generate', {
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 apiRequest<KlingGenerateResponse>('/api/veo/extend', {
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 apiRequest<ReplicateGenerateResponse>('/api/replicate/generate', {
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
- export async function generatePrompts(formData: FormData): Promise<SegmentsPayload> {
278
- const response = await fetch(`${API_BASE}/api/generate-prompts`, {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  method: 'POST',
280
  body: formData,
 
281
  });
282
-
283
  if (!response.ok) {
284
- let errorMessage = 'Failed to generate prompts';
285
- try {
286
- const errorData = await response.json();
287
- errorMessage = errorData.detail || errorMessage;
288
- } catch {
289
- // Ignore
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  }
291
- throw new Error(errorMessage);
 
292
  }
293
 
294
- return response.json();
 
 
 
 
295
  }
296
 
297
  export async function refinePromptContinuity(
@@ -310,11 +391,16 @@ export async function refinePromptContinuity(
310
 
311
  // ==================== IMAGE UPLOAD ====================
312
 
313
- export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
 
 
 
 
314
  const formData = new FormData();
315
  formData.append('file', file);
 
316
 
317
- return apiRequest<{ url: string; filename: string }>('/api/upload-image', {
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 apiRequest<ExtractFramesResponse>('/api/extract-frames', {
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 apiRequest<WhisperAnalyzeResponse>('/api/whisper/analyze-and-extract', {
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
- export async function getVideoDuration(file: File): Promise<number> {
 
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 generateThumbnails(file: File, count: number = 5): Promise<string[]> {
443
- return new Promise((resolve, reject) => {
444
- const video = document.createElement('video');
445
- video.preload = 'metadata';
446
- video.src = URL.createObjectURL(file);
447
- video.muted = true;
448
-
449
- video.onloadedmetadata = async () => {
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
- video.onerror = () => {
486
- URL.revokeObjectURL(video.src);
487
- reject(new Error('Failed to load video'));
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 < 2; attempt++) {
548
  try {
549
  if (attempt > 0) {
550
- console.log(`πŸ”„ Retrying video generation (attempt ${attempt + 1}/2)...`);
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('⏳ Waiting 2 seconds before retry...');
566
- await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds before retry
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
- let errorMessage = 'Failed to merge videos';
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.pricing import router as pricing_router
 
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(pricing_router, prefix="/api")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if os.path.exists(frontend_dist_path):
125
- # Serve static files
126
- app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist_path, "assets")), name="assets")
 
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-4o
793
  print_header("GENERATING VIDEO PROMPTS")
794
- print_status("πŸ€– Using GPT-4o to generate structured prompts...")
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 = 2.2
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
- if si.get("duration") != "00:00-00:08":
332
- errors.append(f"Segment {i}: duration must be 00:00-00:08.")
 
 
 
 
 
 
 
 
 
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-4o",
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
+