sushilideaclan01 commited on
Commit
66e744c
·
1 Parent(s): 8e0b1d0

Add video cancellation feature and update API integration

Browse files

- Implemented a new endpoint to cancel ongoing video generation tasks in `video_generation.py`.
- Enhanced the frontend to manage task IDs and cancellation state in `GenerationContext.tsx` and `GenerationForm.tsx`.
- Updated the UI to allow users to cancel video generation with a button in `GenerationProgress.tsx`.
- Added support for handling cancellation in the video generation process.
- Included new dependencies in `requirements.txt` for OpenAI Whisper.

api/image_service.py CHANGED
@@ -26,7 +26,7 @@ async def upload_image(file: UploadFile = File(...)):
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('PUBLIC_URL', 'http://localhost:4000')
30
 
31
  # Compress and store, get hosted URL
32
  hosted_url = await compress_and_store_image(data_url, public_url)
 
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)
api/video_generation.py CHANGED
@@ -15,6 +15,7 @@ from datetime import datetime
15
 
16
  from utils.image_processor import compress_and_store_image
17
  from utils.storage import video_results, sse_clients, cleanup_old_results
 
18
 
19
  router = APIRouter()
20
 
@@ -575,6 +576,44 @@ async def get_video_status(task_id: str):
575
  detail=f"Failed to check video status: {error_msg}"
576
  )
577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
  @router.get("/veo/download")
579
  async def download_video(url: str):
580
  """
 
15
 
16
  from utils.image_processor import compress_and_store_image
17
  from utils.storage import video_results, sse_clients, cleanup_old_results
18
+ import asyncio
19
 
20
  router = APIRouter()
21
 
 
576
  detail=f"Failed to check video status: {error_msg}"
577
  )
578
 
579
+ @router.post("/veo/cancel/{task_id}")
580
+ async def cancel_video_generation(task_id: str):
581
+ """
582
+ Cancel an ongoing video generation task
583
+ """
584
+ try:
585
+ # Send cancellation event to SSE clients
586
+ if task_id in sse_clients:
587
+ queue = sse_clients[task_id]
588
+ await send_sse_event(task_id, {
589
+ 'status': 'cancelled',
590
+ 'message': 'Video generation cancelled by user'
591
+ })
592
+ # Close the SSE connection
593
+ del sse_clients[task_id]
594
+ print(f"✅ Cancelled video generation: {task_id}")
595
+
596
+ # Mark result as cancelled
597
+ if task_id in video_results:
598
+ video_results[task_id] = {
599
+ 'code': 499, # Client Closed Request
600
+ 'msg': 'Video generation cancelled by user',
601
+ 'taskId': task_id,
602
+ 'timestamp': datetime.now().timestamp()
603
+ }
604
+
605
+ return JSONResponse(
606
+ status_code=200,
607
+ content={'code': 200, 'msg': 'Video generation cancelled', 'taskId': task_id}
608
+ )
609
+
610
+ except Exception as e:
611
+ print(f"❌ Error cancelling video generation: {str(e)}")
612
+ raise HTTPException(
613
+ status_code=500,
614
+ detail=f"Failed to cancel video generation: {str(e)}"
615
+ )
616
+
617
  @router.get("/veo/download")
618
  async def download_video(url: str):
619
  """
frontend/src/components/GenerationForm.tsx CHANGED
@@ -37,8 +37,8 @@ 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, state } = useGeneration();
41
- const { retryState, generatedVideos, segments } = state;
42
 
43
  // Draft storage key
44
  const draftKey = `video-gen-draft-${provider}`;
@@ -554,6 +554,11 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
554
 
555
  // Generate video with automatic retry (retries once on failure)
556
  updateProgress(`Processing video ${i + 1}... (this may take 1-2 minutes)`);
 
 
 
 
 
557
  const videoUrl = await generateVideoWithRetry(async () => {
558
  if (i === 0 || (i === startIndex && startIndex > 0)) {
559
  // First segment OR resuming after failure: use generate API with current image
@@ -567,6 +572,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
567
  voiceType: formState.voiceType,
568
  });
569
  currentTaskId = generateResult.taskId;
 
570
  return generateResult;
571
  } else {
572
  // Subsequent segments: use extend API
@@ -577,6 +583,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
577
  formState.voiceType
578
  );
579
  currentTaskId = extendResult.taskId;
 
580
  return extendResult;
581
  }
582
  }, 300000, (attempt) => {
@@ -602,6 +609,11 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
602
  });
603
 
604
  updateProgress(`Completed video ${i + 1} of ${payload.segments.length}`, i + 1, payload.segments.length);
 
 
 
 
 
605
  }
606
 
607
  clearDraft(); // Clear draft on successful generation
@@ -612,15 +624,23 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack
612
  console.error('Generation error:', err);
613
  const errorMessage = err instanceof Error ? err.message : 'Generation failed';
614
 
615
- // Enable retry mode
616
- setRetryState({
617
- failedSegmentIndex: generatedVideos.length, // Current segment that failed
618
- error: errorMessage
619
- });
620
- setStep('configuring'); // Go back to form, but with retry overlay
 
 
 
 
 
 
621
 
622
  } finally {
623
  setIsGenerating(false);
 
 
624
  }
625
  };
626
 
 
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}`;
 
554
 
555
  // Generate video with automatic retry (retries once on failure)
556
  updateProgress(`Processing video ${i + 1}... (this may take 1-2 minutes)`);
557
+ // Check if cancelled
558
+ if (isCancelling) {
559
+ throw new Error('Generation cancelled by user');
560
+ }
561
+
562
  const videoUrl = await generateVideoWithRetry(async () => {
563
  if (i === 0 || (i === startIndex && startIndex > 0)) {
564
  // First segment OR resuming after failure: use generate API with current image
 
572
  voiceType: formState.voiceType,
573
  });
574
  currentTaskId = generateResult.taskId;
575
+ addTaskId(currentTaskId);
576
  return generateResult;
577
  } else {
578
  // Subsequent segments: use extend API
 
583
  formState.voiceType
584
  );
585
  currentTaskId = extendResult.taskId;
586
+ addTaskId(currentTaskId);
587
  return extendResult;
588
  }
589
  }, 300000, (attempt) => {
 
609
  });
610
 
611
  updateProgress(`Completed video ${i + 1} of ${payload.segments.length}`, i + 1, payload.segments.length);
612
+
613
+ // Remove task ID after completion
614
+ if (currentTaskId) {
615
+ removeTaskId(currentTaskId);
616
+ }
617
  }
618
 
619
  clearDraft(); // Clear draft on successful generation
 
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
643
+ state.activeTaskIds.forEach(taskId => removeTaskId(taskId));
644
  }
645
  };
646
 
frontend/src/components/GenerationProgress.tsx CHANGED
@@ -61,9 +61,15 @@ interface ActivityLog {
61
  icon?: 'video' | 'audio' | 'brain' | 'download' | 'image';
62
  }
63
 
 
 
 
 
 
 
64
  export const GenerationProgress: React.FC = () => {
65
- const { state } = useGeneration();
66
- const { progress, provider, generatedVideos, segments } = state;
67
 
68
  const [elapsedTime, setElapsedTime] = useState(0);
69
  const [activityLog, setActivityLog] = useState<ActivityLog[]>([]);
@@ -176,18 +182,37 @@ export const GenerationProgress: React.FC = () => {
176
  <div>
177
  <h2 className="text-2xl font-bold text-void-100">Generating Videos</h2>
178
  <p className="text-void-400 text-sm mt-1">
179
- {provider === 'kling' ? 'Kling AI' : 'Replicate'} • {segments.length} segments
180
  </p>
181
  </div>
182
- <div className="flex items-center gap-6 text-sm">
183
- <div className="flex items-center gap-2 text-void-400">
184
- <ClockIcon />
185
- <span>Elapsed: <span className="text-void-200 font-mono">{formatTime(elapsedTime)}</span></span>
186
- </div>
187
- {progress.current > 0 && remainingTime > 0 && (
188
  <div className="flex items-center gap-2 text-void-400">
189
- <span>Est. remaining: <span className={`font-mono ${accentClass}`}>{formatTime(remainingTime)}</span></span>
 
190
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  )}
192
  </div>
193
  </div>
 
61
  icon?: 'video' | 'audio' | 'brain' | 'download' | 'image';
62
  }
63
 
64
+ const XIcon = () => (
65
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
66
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
67
+ </svg>
68
+ );
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[]>([]);
 
182
  <div>
183
  <h2 className="text-2xl font-bold text-void-100">Generating Videos</h2>
184
  <p className="text-void-400 text-sm mt-1">
185
+ {provider === 'kling' ? 'KIE API' : 'Replicate'} • {segments.length} segments
186
  </p>
187
  </div>
188
+ <div className="flex items-center gap-4">
189
+ <div className="flex items-center gap-6 text-sm">
 
 
 
 
190
  <div className="flex items-center gap-2 text-void-400">
191
+ <ClockIcon />
192
+ <span>Elapsed: <span className="text-void-200 font-mono">{formatTime(elapsedTime)}</span></span>
193
  </div>
194
+ {progress.current > 0 && remainingTime > 0 && (
195
+ <div className="flex items-center gap-2 text-void-400">
196
+ <span>Est. remaining: <span className={`font-mono ${accentClass}`}>{formatTime(remainingTime)}</span></span>
197
+ </div>
198
+ )}
199
+ </div>
200
+ {activeTaskIds.length > 0 && (
201
+ <button
202
+ onClick={cancelGeneration}
203
+ disabled={isCancelling}
204
+ className={`
205
+ px-4 py-2 rounded-lg font-medium text-sm transition-all
206
+ flex items-center gap-2
207
+ ${isCancelling
208
+ ? 'bg-void-700 text-void-400 cursor-not-allowed'
209
+ : 'bg-red-500/20 hover:bg-red-500/30 text-red-400 hover:text-red-300 border border-red-500/30'
210
+ }
211
+ `}
212
+ >
213
+ <XIcon />
214
+ {isCancelling ? 'Cancelling...' : 'Cancel Generation'}
215
+ </button>
216
  )}
217
  </div>
218
  </div>
frontend/src/context/GenerationContext.tsx CHANGED
@@ -22,6 +22,8 @@ const initialState: GenerationState = {
22
  error: null,
23
  taskId: null,
24
  retryState: null,
 
 
25
  };
26
 
27
  // Action types
@@ -35,6 +37,9 @@ type GenerationAction =
35
  | { type: 'SET_ERROR'; payload: string | null }
36
  | { type: 'SET_TASK_ID'; payload: string | null }
37
  | { type: 'SET_RETRY_STATE'; payload: { failedSegmentIndex: number; error: string } | null }
 
 
 
38
  | { type: 'RESET' };
39
 
40
  // Reducer
@@ -64,6 +69,12 @@ function generationReducer(state: GenerationState, action: GenerationAction): Ge
64
  return { ...state, taskId: action.payload };
65
  case 'SET_RETRY_STATE':
66
  return { ...state, retryState: action.payload };
 
 
 
 
 
 
67
  case 'RESET':
68
  return { ...initialState, provider: state.provider };
69
  default:
@@ -86,6 +97,9 @@ interface GenerationContextValue {
86
  setError: (error: string | null) => void;
87
  setRetryState: (state: { failedSegmentIndex: number; error: string } | null) => void;
88
  updateSegments: (segments: VeoSegment[]) => void;
 
 
 
89
  reset: () => void;
90
  }
91
 
@@ -148,6 +162,41 @@ export function GenerationProvider({ children }: { children: ReactNode }) {
148
  dispatch({ type: 'SET_SEGMENTS', payload: segments });
149
  },
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  reset: () => {
152
  dispatch({ type: 'RESET' });
153
  },
 
22
  error: null,
23
  taskId: null,
24
  retryState: null,
25
+ activeTaskIds: [] as string[],
26
+ isCancelling: false,
27
  };
28
 
29
  // Action types
 
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
 
69
  return { ...state, taskId: action.payload };
70
  case 'SET_RETRY_STATE':
71
  return { ...state, retryState: action.payload };
72
+ case 'ADD_TASK_ID':
73
+ return { ...state, activeTaskIds: [...state.activeTaskIds, action.payload] };
74
+ case 'REMOVE_TASK_ID':
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:
 
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
 
 
162
  dispatch({ type: 'SET_SEGMENTS', payload: segments });
163
  },
164
 
165
+ addTaskId: (taskId) => {
166
+ dispatch({ type: 'ADD_TASK_ID', payload: taskId });
167
+ },
168
+
169
+ removeTaskId: (taskId) => {
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' });
195
+ } finally {
196
+ dispatch({ type: 'SET_CANCELLING', payload: false });
197
+ }
198
+ },
199
+
200
  reset: () => {
201
  dispatch({ type: 'RESET' });
202
  },
frontend/src/types/index.ts CHANGED
@@ -183,6 +183,8 @@ export interface GenerationState {
183
  failedSegmentIndex: number;
184
  error: string;
185
  } | null;
 
 
186
  }
187
 
188
  export interface GeneratedVideo {
 
183
  failedSegmentIndex: number;
184
  error: string;
185
  } | null;
186
+ activeTaskIds: string[];
187
+ isCancelling: boolean;
188
  }
189
 
190
  export interface GeneratedVideo {
frontend/src/utils/api.ts CHANGED
@@ -210,6 +210,13 @@ export async function klingGetStatus(taskId: string): Promise<VideoStatusRespons
210
  return apiRequest<VideoStatusResponse>(`/api/veo/status/${taskId}`);
211
  }
212
 
 
 
 
 
 
 
 
213
  export function createKlingEventSource(taskId: string): EventSource {
214
  return new EventSource(`${API_BASE}/api/veo/events/${taskId}`);
215
  }
@@ -501,6 +508,10 @@ export function waitForKlingVideo(taskId: string, timeoutMs: number = 300000): P
501
  clearTimeout(timeout);
502
  eventSource.close();
503
  resolve(data.url);
 
 
 
 
504
  } else if (data.status === 'failed' || data.code !== undefined && data.code !== 200) {
505
  clearTimeout(timeout);
506
  eventSource.close();
 
210
  return apiRequest<VideoStatusResponse>(`/api/veo/status/${taskId}`);
211
  }
212
 
213
+ export async function klingCancel(taskId: string): Promise<{ code: number; msg: string; taskId: string }> {
214
+ return apiRequest<{ code: number; msg: string; taskId: string }>(`/api/veo/cancel/${taskId}`, {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ });
218
+ }
219
+
220
  export function createKlingEventSource(taskId: string): EventSource {
221
  return new EventSource(`${API_BASE}/api/veo/events/${taskId}`);
222
  }
 
508
  clearTimeout(timeout);
509
  eventSource.close();
510
  resolve(data.url);
511
+ } else if (data.status === 'cancelled') {
512
+ clearTimeout(timeout);
513
+ eventSource.close();
514
+ reject(new Error('Video generation cancelled by user'));
515
  } else if (data.status === 'failed' || data.code !== undefined && data.code !== 200) {
516
  clearTimeout(timeout);
517
  eventSource.close();
requirements.txt CHANGED
@@ -36,3 +36,4 @@ python-multipart==0.0.17
36
  # pytest==8.3.0
37
  # pytest-asyncio==0.24.0
38
 
 
 
36
  # pytest==8.3.0
37
  # pytest-asyncio==0.24.0
38
 
39
+ openai-whisper