sushilideaclan01 commited on
Commit
d51b30c
Β·
1 Parent(s): 82a1419

improve the flow

Browse files
api/prompt_generation.py CHANGED
@@ -361,6 +361,29 @@ async def refine_prompt_for_continuity(
361
  try:
362
  # Read the image
363
  image_bytes = await lastFrame.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  encoded_image = base64.b64encode(image_bytes).decode('utf-8')
365
 
366
  # Parse the segment prompt
@@ -379,109 +402,116 @@ async def refine_prompt_for_continuity(
379
  audio_context = ""
380
  if transcribedDialogue.strip():
381
  audio_context = f"""
382
-
383
- ═══════════════════════════════════════════════════════════
384
- AUDIO CONTINUITY CONTEXT (WHAT WAS ACTUALLY SPOKEN)
385
- ═══════════════════════════════════════════════════════════
386
-
387
- Previous segment's dialogue (from Whisper transcription):
388
- \"{transcribedDialogue.strip()}\"
389
-
390
- Expected dialogue was:
391
- \"{expectedDialogue.strip() if expectedDialogue.strip() else 'Not provided'}\"
392
-
393
- IMPORTANT: The next segment should continue naturally from what was ACTUALLY said.
394
- If there are differences between expected and transcribed dialogue, use the TRANSCRIBED version
395
- as the ground truth for continuity (it's what the viewer actually heard).
396
  """
397
 
398
- # Build the refinement prompt
399
- refinement_instructions = f"""
400
- You are a video continuity expert. Your task is to UPDATE the provided segment prompt to ensure PERFECT VISUAL AND AUDIO CONTINUITY with the previous video segment.
401
-
402
- ═══════════════════════════════════════════════════════════
403
- VISUAL CONTINUITY (from attached image)
404
- ═══════════════════════════════════════════════════════════
405
-
406
- Analyze the image carefully - this is the ACTUAL last frame from the previous video.
407
-
408
- 1. Update the character_description to match the ACTUAL person in the image:
409
- - Physical appearance (EXACT age, hair color/style, facial features, skin tone)
410
- - Clothing (EXACTLY what they're wearing - color, style, pattern)
411
- - Current state (their actual expression and posture at this moment)
412
- - Voice matching (adjust to match their appearance)
413
-
414
- 2. Update the scene_continuity to match the ACTUAL environment:
415
- - Environment (describe what you see - bedroom, office, outdoor, etc.)
416
- - Camera position (maintain the SAME angle/framing)
417
- - Lighting state (match the EXACT lighting conditions in the image)
418
- - Props and background elements (describe what's actually visible)
419
- - Spatial relationships (match the actual layout)
420
- {audio_context}
421
- ═══════════════════════════════════════════════════════════
422
- ORIGINAL PROMPT TO UPDATE
423
- ═══════════════════════════════════════════════════════════
424
-
425
- {json.dumps(segment_data, indent=2)}
426
 
427
- ═══════════════════════════════════════════════════════════
428
- CRITICAL RULES
429
- ═══════════════��═══════════════════════════════════════════
430
-
431
- - Be EXTREMELY specific about what you see in the image
432
- - If the image shows a young woman with red hair, describe EXACTLY that
433
- - If it's a sunset beach scene, describe EXACTLY that setting
434
- - If they're wearing a beige blazer, describe EXACTLY that clothing
435
- - Match colors, styles, and details PRECISELY to what's visible
436
- - Maintain the SAME camera angle and distance
437
- - Keep the action_timeline.dialogue EXACTLY as provided (this is the NEXT segment's dialogue)
438
- - Update segment_info.continuity_markers to reflect the visual state
439
- - Adjust synchronized_actions to fit the actual character appearance
440
-
441
- 🚨 CRITICAL: NO BLUR TRANSITIONS AT SEGMENT START 🚨
442
- - The video MUST start immediately at 0:00 with a SHARP, CLEAR, IN-FOCUS frame
443
- - NO fade-in, NO blur transition, NO gradual focus effect at the start
444
- - The first frame (0:00) must be as clear and sharp as any other frame
445
- - camera_movement MUST describe movement that starts from a clear, sharp state
446
 
 
 
 
447
 
448
- The goal is SEAMLESS video extension with ZERO visual or audio discontinuity.
 
 
 
 
449
 
450
- Return ONLY the updated JSON segment object with the same structure. No explanation, just the corrected JSON.
451
- """
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,
@@ -491,7 +521,7 @@ Return ONLY the updated JSON segment object with the same structure. No explanat
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,
 
361
  try:
362
  # Read the image
363
  image_bytes = await lastFrame.read()
364
+
365
+ # Detect MIME type from image bytes (PNG starts with \x89PNG, JPEG with \xff\xd8)
366
+ mime_type = "image/png" if image_bytes[:4] == b'\x89PNG' else "image/jpeg"
367
+
368
+ # Compress image to reduce token usage (resize to max 512px, convert to JPEG)
369
+ try:
370
+ from PIL import Image
371
+ import io
372
+ img = Image.open(io.BytesIO(image_bytes))
373
+ # Resize to max 512px on longest side (saves ~75% tokens vs full resolution)
374
+ max_dim = 512
375
+ if max(img.size) > max_dim:
376
+ ratio = max_dim / max(img.size)
377
+ new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio))
378
+ img = img.resize(new_size, Image.LANCZOS)
379
+ # Convert to JPEG for smaller size
380
+ buf = io.BytesIO()
381
+ img.convert('RGB').save(buf, format='JPEG', quality=85)
382
+ image_bytes = buf.getvalue()
383
+ mime_type = "image/jpeg"
384
+ except Exception as resize_err:
385
+ print(f"⚠️ Image resize skipped: {resize_err}")
386
+
387
  encoded_image = base64.b64encode(image_bytes).decode('utf-8')
388
 
389
  # Parse the segment prompt
 
402
  audio_context = ""
403
  if transcribedDialogue.strip():
404
  audio_context = f"""
405
+ AUDIO CONTEXT: Previous segment said: "{transcribedDialogue.strip()}"
406
+ Expected: "{expectedDialogue.strip() if expectedDialogue.strip() else 'N/A'}"
407
+ Continue naturally from what was ACTUALLY said (use transcription as ground truth).
 
 
 
 
 
 
 
 
 
 
 
408
  """
409
 
410
+ # Build compact refinement prompt (shorter = more reliable responses)
411
+ segment_json = json.dumps(segment_data, indent=2)
412
+ refinement_instructions = f"""Update this video segment prompt for VISUAL CONTINUITY with the attached image (last frame from previous video).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
+ WHAT TO UPDATE:
415
+ 1. character_description: Match the ACTUAL person in the image (appearance, clothing, expression, posture)
416
+ 2. scene_continuity: Match environment, camera angle, lighting, props exactly as shown
417
+ 3. continuity_markers: Reflect current visual state
418
+ 4. synchronized_actions: Fit actual character appearance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
 
420
+ {audio_context}
421
+ SEGMENT TO UPDATE:
422
+ {segment_json}
423
 
424
+ RULES:
425
+ - Be specific about what you SEE in the image
426
+ - Keep action_timeline.dialogue EXACTLY as-is (this is the NEXT segment's script)
427
+ - NO blur/fade transitions at start - video starts sharp and clear at 0:00
428
+ - Maintain same camera angle and distance as image
429
 
430
+ Return the updated JSON segment object with the same structure."""
 
431
 
432
  print(f"πŸ”„ Refining prompt for visual continuity...")
433
 
434
+ system_msg = "You are a video continuity expert. Analyze the provided image and update the JSON segment prompt to ensure seamless visual continuity. Always respond with a single valid JSON object matching the input structure."
435
+
436
  messages = [
437
+ {"role": "system", "content": system_msg},
438
  {
439
  "role": "user",
440
  "content": [
441
  {"type": "text", "text": refinement_instructions},
442
  {
443
  "type": "image_url",
444
+ "image_url": {
445
+ "url": f"data:{mime_type};base64,{encoded_image}",
446
+ "detail": "low" # Use low detail to reduce token usage
447
+ }
448
  }
449
  ]
450
  }
451
  ]
452
 
453
+ # Try GPT-4o first, then gpt-4o-mini as fallback
454
+ models_to_try = ["gpt-4o", "gpt-4o-mini"]
455
  content = None
456
+
457
+ for model_name in models_to_try:
458
+ try:
459
+ response = client.chat.completions.create(
460
+ model=model_name,
461
+ messages=messages,
462
+ response_format={"type": "json_object"},
463
+ max_tokens=4096,
464
+ temperature=0.3,
465
+ )
466
+ choice = response.choices[0]
467
+ content = choice.message.content
468
+ finish_reason = choice.finish_reason
469
+
470
+ if content and content.strip():
471
+ if model_name != "gpt-4o":
472
+ print(f"βœ… Got response from fallback model {model_name}")
473
+ break
474
+
475
+ print(f"⚠️ {model_name} returned empty (finish_reason={finish_reason})")
476
+ if finish_reason == "content_filter":
477
+ print(f" πŸ›‘οΈ Blocked by content filter, trying next model...")
478
+ content = None # Reset for next model
479
+
480
+ except Exception as model_err:
481
+ print(f"⚠️ {model_name} failed: {model_err}")
482
+ content = None
483
+ continue
484
+
485
+ # Last resort: try without json_object mode (some content triggers filter only in JSON mode)
486
+ if not content or not content.strip():
487
+ print(f"⚠️ All models failed with JSON mode, trying without response_format...")
488
+ try:
489
+ messages_plain = [
490
+ {"role": "system", "content": system_msg + " Return ONLY raw JSON, no markdown, no explanation."},
491
+ messages[1] # Same user message with image
492
+ ]
493
+ response = client.chat.completions.create(
494
+ model="gpt-4o-mini",
495
+ messages=messages_plain,
496
+ max_tokens=4096,
497
+ temperature=0.3,
498
+ )
499
+ raw_content = response.choices[0].message.content
500
+ if raw_content and raw_content.strip():
501
+ # Extract JSON from response (might have markdown fences)
502
+ cleaned = raw_content.strip()
503
+ if cleaned.startswith("```"):
504
+ # Remove markdown code fences
505
+ lines = cleaned.split('\n')
506
+ lines = [l for l in lines if not l.strip().startswith("```")]
507
+ cleaned = '\n'.join(lines)
508
+ content = cleaned
509
+ print(f"βœ… Got response without JSON mode")
510
+ except Exception as plain_err:
511
+ print(f"⚠️ Plain mode also failed: {plain_err}")
512
 
513
  if not content or not content.strip():
514
+ print(f"⚠️ All refinement attempts failed, using original segment")
515
  return JSONResponse(content={
516
  "refined_prompt": segment_data,
517
  "original_prompt": segment_data,
 
521
  try:
522
  refined_prompt = json.loads(content)
523
  except json.JSONDecodeError as je:
524
+ print(f"⚠️ Failed to parse response as JSON: {str(je)[:100]}, using original")
525
  return JSONResponse(content={
526
  "refined_prompt": segment_data,
527
  "original_prompt": segment_data,
frontend/src/components/GenerationComplete.tsx CHANGED
@@ -134,8 +134,13 @@ export const GenerationComplete: React.FC = () => {
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>
@@ -271,8 +276,15 @@ export const GenerationComplete: React.FC = () => {
271
  <VideoIcon size={24} className={accentColor === 'coral' ? 'text-coral-400' : 'text-electric-400'} />
272
  </div>
273
  <div>
274
- <h3 className="font-bold text-lg text-void-100">Final Exported Video</h3>
275
- <p className="text-sm text-void-400">All segments merged into one video</p>
 
 
 
 
 
 
 
276
  </div>
277
  </div>
278
 
@@ -348,12 +360,17 @@ export const GenerationComplete: React.FC = () => {
348
  {isMerging ? (
349
  <>
350
  <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
351
- <span>Merging...</span>
352
  </>
353
  ) : (
354
  <>
355
  <VideoIcon size={20} />
356
- <span>Export Final Video</span>
 
 
 
 
 
357
  </>
358
  )}
359
  </button>
@@ -402,9 +419,13 @@ export const GenerationComplete: React.FC = () => {
402
  className="text-center text-void-500 text-sm mt-8"
403
  >
404
  {mergedVideoUrl
405
- ? 'Your final video is ready! Download it or re-merge with different settings.'
 
 
406
  : generatedVideos.length >= 2
407
- ? '"Export Final Video" will merge all segments into a single video file with Whisper-optimized trim points.'
 
 
408
  : 'Videos are ready to use in your video editor or social media.'
409
  }
410
  </motion.p>
 
134
  {partialCompletionError}
135
  </p>
136
  <p className="text-void-400 text-xs">
137
+ The successfully generated videos are displayed below. You can still merge and export them.
138
  </p>
139
+ {generatedVideos.length >= 2 && (
140
+ <p className="text-green-400 text-xs mt-2 font-medium">
141
+ {generatedVideos.length} segments available for merge and export.
142
+ </p>
143
+ )}
144
  </div>
145
  </div>
146
  </motion.div>
 
276
  <VideoIcon size={24} className={accentColor === 'coral' ? 'text-coral-400' : 'text-electric-400'} />
277
  </div>
278
  <div>
279
+ <h3 className="font-bold text-lg text-void-100">
280
+ {partialCompletionError ? 'Merged Partial Video' : 'Final Exported Video'}
281
+ </h3>
282
+ <p className="text-sm text-void-400">
283
+ {partialCompletionError
284
+ ? `${generatedVideos.length} available segments merged into one video`
285
+ : 'All segments merged into one video'
286
+ }
287
+ </p>
288
  </div>
289
  </div>
290
 
 
360
  {isMerging ? (
361
  <>
362
  <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
363
+ <span>Merging {generatedVideos.length} segments...</span>
364
  </>
365
  ) : (
366
  <>
367
  <VideoIcon size={20} />
368
+ <span>
369
+ {partialCompletionError
370
+ ? `Merge ${generatedVideos.length} Available Segments`
371
+ : 'Export Final Video'
372
+ }
373
+ </span>
374
  </>
375
  )}
376
  </button>
 
419
  className="text-center text-void-500 text-sm mt-8"
420
  >
421
  {mergedVideoUrl
422
+ ? partialCompletionError
423
+ ? `Your partial video (${generatedVideos.length} segments) is ready! Download it or start a new generation.`
424
+ : 'Your final video is ready! Download it or re-merge with different settings.'
425
  : generatedVideos.length >= 2
426
+ ? partialCompletionError
427
+ ? `You can merge the ${generatedVideos.length} successfully generated segments into a single video.`
428
+ : '"Export Final Video" will merge all segments into a single video file with Whisper-optimized trim points.'
429
  : 'Videos are ready to use in your video editor or social media.'
430
  }
431
  </motion.p>
frontend/src/components/GenerationForm.tsx CHANGED
@@ -297,7 +297,8 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
297
  setError(null);
298
 
299
  let segmentsToUse: VeoSegment[] = [];
300
- let currentImageFile = imageFile; // Declare at function scope for catch block access
 
301
 
302
  try {
303
 
@@ -380,8 +381,8 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
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;
@@ -402,11 +403,15 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
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
  );
@@ -536,13 +541,49 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
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!
544
  clearDraft(); // Clear draft on successful generation
545
- clearDraft(); // Clear draft on successful generation
546
  setStep('completed');
547
  updateProgress('All videos generated successfully!');
548
 
@@ -558,312 +599,35 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
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);
869
  }
@@ -881,6 +645,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
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)
@@ -1036,6 +801,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
1036
  duration,
1037
  thumbnails,
1038
  });
 
1039
 
1040
  updateProgress(`Completed video ${i + 1} of ${payload.segments.length}`, i + 1, payload.segments.length);
1041
 
@@ -1052,20 +818,39 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
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);
@@ -1091,6 +876,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
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)
@@ -1317,6 +1103,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
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
  }
@@ -1328,20 +1115,39 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
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);
 
297
  setError(null);
298
 
299
  let segmentsToUse: VeoSegment[] = [];
300
+ let currentImageFile: File = imageFile; // Track reference image across segments and catch block
301
+ let completedVideoCount = 0; // Track locally to avoid stale React state closure
302
 
303
  try {
304
 
 
381
  }
382
  }
383
 
384
+ // Reset reference image to original for this run
385
+ currentImageFile = imageFile;
386
 
387
  // RESUME SUPPORT: Start from where we left off if retrying
388
  const startIndex = attemptCount > 0 ? generatedVideos.length : 0;
 
403
 
404
  // Step 2: Generate videos segment by segment with frame continuity
405
  for (let i = startIndex; i < segmentsToUse.length; i++) {
 
406
  const isLastSegment = i === segmentsToUse.length - 1;
407
+
408
+ // Per-segment safety retry loop with hard cap (prevents infinite retries)
409
+ for (let safetyAttempt = 0; safetyAttempt <= AUTO_FIX_MAX_ATTEMPTS; safetyAttempt++) {
410
+ try {
411
+ const segment = segmentsToUse[i];
412
 
413
  updateProgress(
414
+ `Generating video ${i + 1} of ${segmentsToUse.length}...${i > 0 ? ' (using last frame from previous)' : ''}${safetyAttempt > 0 ? ` (safety retry ${safetyAttempt}/${AUTO_FIX_MAX_ATTEMPTS})` : ''}`,
415
  i,
416
  segmentsToUse.length
417
  );
 
541
  trimPoint, // Store trim point for merge
542
  };
543
  addVideo(generatedVideo);
544
+ completedVideoCount++;
545
 
546
  updateProgress(`Completed video ${i + 1} of ${segmentsToUse.length}`, i + 1, segmentsToUse.length);
547
+ break; // Segment succeeded - exit safety retry loop, move to next segment
548
+
549
+ } catch (segErr) {
550
+ const segErrMsg = segErr instanceof Error ? segErr.message : String(segErr);
551
+
552
+ // Safety error with retries remaining: auto-fix the prompt and retry this segment
553
+ if (isUnsafeSegmentError(segErrMsg) && safetyAttempt < AUTO_FIX_MAX_ATTEMPTS) {
554
+ console.log(`πŸ›‘οΈ Safety error on segment ${i + 1}, auto-fix attempt ${safetyAttempt + 1}/${AUTO_FIX_MAX_ATTEMPTS}...`);
555
+ updateProgress(`Safety issue in segment ${i + 1}, auto-fixing (attempt ${safetyAttempt + 1}/${AUTO_FIX_MAX_ATTEMPTS})...`);
556
+
557
+ try {
558
+ const fixResult = await fixUnsafePrompt({
559
+ segment: segmentsToUse[i],
560
+ error_message: segErrMsg,
561
+ attempt_count: safetyAttempt + 1
562
+ });
563
+
564
+ if (fixResult.success && fixResult.fixed_segment) {
565
+ console.log(`βœ… Auto-fix successful: ${fixResult.changes_made}`);
566
+ updateProgress(`Auto-fix successful: ${fixResult.changes_made}`);
567
+ segmentsToUse[i] = fixResult.fixed_segment;
568
+ updateSegments([...segmentsToUse]);
569
+ await new Promise(resolve => setTimeout(resolve, 1000));
570
+ continue; // Retry this segment with the fixed prompt
571
+ } else {
572
+ console.warn(`⚠️ Auto-fix returned no usable fix: ${fixResult.error}`);
573
+ }
574
+ } catch (fixErr) {
575
+ console.error('❌ Auto-fix API error:', fixErr);
576
+ }
577
+ }
578
+
579
+ // Non-safety error, fix failed, or max safety retries exhausted β†’ propagate to outer handler
580
+ throw segErr;
581
+ }
582
+ } // end per-segment safety retry loop
583
  }
584
 
585
  // All done!
586
  clearDraft(); // Clear draft on successful generation
 
587
  setStep('completed');
588
  updateProgress('All videos generated successfully!');
589
 
 
599
  return;
600
  }
601
 
602
+ // If some videos were generated, show partial completion immediately
603
+ // (don't retry from scratch -- that would waste the already-generated videos)
604
+ if (completedVideoCount > 0) {
605
+ console.log(`⚠️ Partial completion: ${completedVideoCount}/${segmentsToUse.length} segments generated`);
606
+ setPartialCompletionError(
607
+ `Generation stopped at segment ${completedVideoCount + 1} of ${segmentsToUse.length}. ` +
608
+ `${completedVideoCount} video${completedVideoCount > 1 ? 's' : ''} generated successfully. ` +
609
+ `Reason: ${errorMessage}`
610
+ );
611
+ setStep('completed');
612
+ } else {
613
+ // No videos generated at all -- use normal retry logic
614
+ const outcome: FlowRetryOutcome = await handleFlowRetry({
615
+ attemptCount,
616
+ errorMessage,
617
+ isCancelled: false,
618
+ generatedCount: 0,
619
+ totalCount: segmentsToUse.length,
620
+ setError,
621
+ setStep,
622
+ setPartialCompletionError,
623
+ });
624
+ if (outcome === 'retry') {
625
+ console.log('πŸ”„ No videos generated, auto-retrying...');
626
+ updateProgress('Generation failed, automatically retrying...');
627
+ return handleKlingFrameContinuityFlow(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
  }
629
  }
630
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  } finally {
632
  setIsGenerating(false);
633
  }
 
645
  setError(null);
646
 
647
  let payload: { segments: VeoSegment[] } = { segments: [] }; // Declare at function scope
648
+ let completedVideoCount = 0; // Track locally to avoid stale React state closure
649
 
650
  try {
651
  // Step 1: Get segments - skip prompt generation if segments already exist (retry mode)
 
801
  duration,
802
  thumbnails,
803
  });
804
+ completedVideoCount++;
805
 
806
  updateProgress(`Completed video ${i + 1} of ${payload.segments.length}`, i + 1, payload.segments.length);
807
 
 
818
  } catch (err) {
819
  console.error('Generation error:', err);
820
  const errorMessage = err instanceof Error ? err.message : 'Generation failed';
821
+ const isUserCancel = (err as Error & { name?: string })?.name === 'AbortError' || errorMessage.includes('cancelled') || isCancelling;
822
+
823
+ if (isUserCancel) {
824
+ setStep('error');
825
+ setIsGenerating(false);
826
+ return;
827
+ }
828
+
829
+ // If some videos were generated, show partial completion immediately
830
+ if (completedVideoCount > 0) {
831
+ console.log(`⚠️ Partial completion: ${completedVideoCount}/${payload.segments.length} segments generated`);
832
+ setPartialCompletionError(
833
+ `Generation stopped at segment ${completedVideoCount + 1} of ${payload.segments.length}. ` +
834
+ `${completedVideoCount} video${completedVideoCount > 1 ? 's' : ''} generated successfully. ` +
835
+ `Reason: ${errorMessage}`
836
+ );
837
+ setStep('completed');
838
+ } else {
839
+ const outcome = await handleFlowRetry({
840
+ attemptCount,
841
+ errorMessage,
842
+ isCancelled: false,
843
+ generatedCount: 0,
844
+ totalCount: payload.segments.length,
845
+ setError,
846
+ setStep,
847
+ setPartialCompletionError,
848
+ });
849
+ if (outcome === 'retry') {
850
+ console.log('πŸ”„ No videos generated, auto-retrying...');
851
+ updateProgress('Generation failed, automatically retrying...');
852
+ return handleKlingExtendFlow(1);
853
+ }
854
  }
855
  } finally {
856
  setIsGenerating(false);
 
876
  setError(null);
877
 
878
  let segmentsToUse: VeoSegment[] = []; // Declare at function scope
879
+ let completedVideoCount = 0; // Track locally to avoid stale React state closure
880
 
881
  try {
882
  // Step 1: Get segments - skip prompt generation if segments already exist (retry mode)
 
1103
  trimPoint, // Store trim point for merge
1104
  };
1105
  addVideo(generatedVideo);
1106
+ completedVideoCount++;
1107
 
1108
  updateProgress(`Completed video ${i + 1} of ${segmentsToUse.length}`, i + 1, segmentsToUse.length);
1109
  }
 
1115
  } catch (err) {
1116
  console.error('Replicate generation error:', err);
1117
  const errorMessage = err instanceof Error ? err.message : 'Replicate generation failed';
1118
+ const isUserCancel = (err as Error & { name?: string })?.name === 'AbortError' || errorMessage.includes('cancelled') || isCancelling;
1119
+
1120
+ if (isUserCancel) {
1121
+ setStep('error');
1122
+ setIsGenerating(false);
1123
+ return;
1124
+ }
1125
+
1126
+ // If some videos were generated, show partial completion immediately
1127
+ if (completedVideoCount > 0) {
1128
+ console.log(`⚠️ Partial completion: ${completedVideoCount}/${segmentsToUse.length} segments generated`);
1129
+ setPartialCompletionError(
1130
+ `Generation stopped at segment ${completedVideoCount + 1} of ${segmentsToUse.length}. ` +
1131
+ `${completedVideoCount} video${completedVideoCount > 1 ? 's' : ''} generated successfully. ` +
1132
+ `Reason: ${errorMessage}`
1133
+ );
1134
+ setStep('completed');
1135
+ } else {
1136
+ const outcome = await handleFlowRetry({
1137
+ attemptCount,
1138
+ errorMessage,
1139
+ isCancelled: false,
1140
+ generatedCount: 0,
1141
+ totalCount: segmentsToUse.length,
1142
+ setError,
1143
+ setStep,
1144
+ setPartialCompletionError,
1145
+ });
1146
+ if (outcome === 'retry') {
1147
+ console.log('πŸ”„ No videos generated, auto-retrying...');
1148
+ updateProgress('Generation failed, automatically retrying...');
1149
+ return handleReplicateGeneration(1);
1150
+ }
1151
  }
1152
  } finally {
1153
  setIsGenerating(false);
frontend/src/components/GenerationProgress.tsx CHANGED
@@ -71,7 +71,7 @@ const XIcon = () => (
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
@@ -217,23 +217,21 @@ export const GenerationProgress: React.FC = () => {
217
  </div>
218
  )}
219
  </div>
220
- {activeTaskIds.length > 0 && (
221
- <button
222
- onClick={cancelGeneration}
223
- disabled={isCancelling}
224
- className={`
225
- px-4 py-2 rounded-lg font-medium text-sm transition-all
226
- flex items-center gap-2
227
- ${isCancelling
228
- ? 'bg-void-700 text-void-400 cursor-not-allowed'
229
- : 'bg-red-500/20 hover:bg-red-500/30 text-red-400 hover:text-red-300 border border-red-500/30'
230
- }
231
- `}
232
- >
233
- <XIcon />
234
- {isCancelling ? 'Cancelling...' : 'Cancel Generation'}
235
- </button>
236
- )}
237
  </div>
238
  </div>
239
 
 
71
 
72
  export const GenerationProgress: React.FC = () => {
73
  const { state, cancelGeneration } = useGeneration();
74
+ const { progress, provider, generatedVideos, segments, isCancelling, 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
 
217
  </div>
218
  )}
219
  </div>
220
+ <button
221
+ onClick={cancelGeneration}
222
+ disabled={isCancelling}
223
+ className={`
224
+ px-4 py-2 rounded-lg font-medium text-sm transition-all
225
+ flex items-center gap-2
226
+ ${isCancelling
227
+ ? 'bg-void-700 text-void-400 cursor-not-allowed'
228
+ : 'bg-red-500/20 hover:bg-red-500/30 text-red-400 hover:text-red-300 border border-red-500/30'
229
+ }
230
+ `}
231
+ >
232
+ <XIcon />
233
+ {isCancelling ? 'Cancelling...' : 'Cancel Generation'}
234
+ </button>
 
 
235
  </div>
236
  </div>
237
 
frontend/src/context/GenerationContext.tsx CHANGED
@@ -209,23 +209,35 @@ export function GenerationProvider({ children }: { children: ReactNode }) {
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) {
 
209
  }
210
 
211
  if (currentStep === 'generating_video' || currentStep === 'processing') {
212
+ // Cancel any active API tasks
213
  const { klingCancel } = await import('@/utils/api');
214
  const currentTaskIds = [...s.activeTaskIds];
215
+ if (currentTaskIds.length > 0) {
216
+ const cancelPromises = currentTaskIds.map(taskId =>
217
+ klingCancel(taskId).catch(err => {
218
+ console.warn(`Failed to cancel task ${taskId}:`, err);
219
+ })
220
+ );
221
+ await Promise.all(cancelPromises);
222
+ currentTaskIds.forEach(id => {
223
+ dispatch({ type: 'REMOVE_TASK_ID', payload: id });
224
+ });
225
+ dispatch({ type: 'SET_TASK_ID', payload: null });
226
+ }
227
+
228
+ // If some videos were generated, show partial completion so user can merge them
229
+ if (videoCount > 0) {
230
+ const totalCount = s.segments.length || s.progress.total;
231
+ dispatch({
232
+ type: 'SET_PARTIAL_COMPLETION_ERROR',
233
+ payload: `Generation cancelled by user at segment ${videoCount + 1} of ${totalCount}. ` +
234
+ `${videoCount} video${videoCount > 1 ? 's' : ''} generated successfully.`
235
+ });
236
+ dispatch({ type: 'SET_STEP', payload: 'completed' });
237
+ } else {
238
+ dispatch({ type: 'SET_ERROR', payload: 'Generation cancelled by user.' });
239
+ dispatch({ type: 'SET_STEP', payload: 'error' });
240
+ }
241
  return;
242
  }
243
  } catch (error) {