import os from fastapi import APIRouter, BackgroundTasks, HTTPException from app.models.schemas import ( ProcessRequest, ProcessResponse, JobStatus, SubtitleStyle, TrimRequest, TrimResponse, VideoInfoResponse, TranscribeRequest, RenderRequest, ) from app.models.job_store import job_store from app.services.transcriber import transcribe_video, segments_to_ass from app.services.translator import translate_segments, TranslationMode from app.services.video_processor import process_video, trim_video, get_video_info, get_video_duration, extract_frame from app.services.thumbnail import generate_thumbnail, generate_catchphrase, get_video_timestamps from app.config import settings router = APIRouter() async def process_task( job_id: str, bgm_id: str | None, bgm_volume: float, subtitle_style: SubtitleStyle | None, keep_original_audio: bool, translation_mode: str | None = None, use_vocal_separation: bool = False, ): """Background task for full video processing pipeline.""" job = job_store.get_job(job_id) if not job or not job.video_path: job_store.update_job(job_id, status=JobStatus.FAILED, error="Job or video not found") return try: # Progress callback for real-time status updates async def progress_callback(step: str, progress: int): step_to_status = { "vocal_separation": JobStatus.EXTRACTING_AUDIO, "extracting_audio": JobStatus.EXTRACTING_AUDIO, "noise_reduction": JobStatus.NOISE_REDUCTION, "transcribing": JobStatus.TRANSCRIBING, } status = step_to_status.get(step, JobStatus.TRANSCRIBING) print(f"[Progress] Step: {step} -> Status: {status}, Progress: {progress}%") job_store.update_job(job_id, status=status, progress=progress) # Start with initial status job_store.update_job(job_id, status=JobStatus.EXTRACTING_AUDIO, progress=10) success, message, segments, detected_lang = await transcribe_video( job.video_path, use_noise_reduction=True, noise_reduction_level="medium", use_vocal_separation=use_vocal_separation, progress_callback=progress_callback, ) # Handle special cases if not success: if message in ("NO_AUDIO", "SILENT_AUDIO", "SINGING_ONLY"): # Video has no usable speech - allow manual subtitle input or BGM-only processing if message == "NO_AUDIO": audio_status = "no_audio_stream" elif message == "SILENT_AUDIO": audio_status = "audio_silent" else: # SINGING_ONLY audio_status = "singing_only" job_store.update_job( job_id, status=JobStatus.AWAITING_SUBTITLE, progress=35, has_audio=message != "NO_AUDIO", # Has audio if it's singing audio_status=audio_status, ) return else: job_store.update_job(job_id, status=JobStatus.FAILED, error=message) return # Audio OK - speech detected and transcribed job_store.update_job(job_id, transcript=segments, progress=50, has_audio=True, audio_status="ok", detected_language=detected_lang) # Step 2: Translate/Rewrite (only for Chinese content) # Check if the detected language is Chinese (zh, zh-cn, zh-tw, chinese) is_chinese = detected_lang and detected_lang.lower() in ['zh', 'zh-cn', 'zh-tw', 'chinese', 'mandarin'] if is_chinese: job_store.update_job(job_id, status=JobStatus.TRANSLATING, progress=55) mode = translation_mode or settings.TRANSLATION_MODE success, message, segments = await translate_segments( segments, mode=mode, max_tokens=settings.TRANSLATION_MAX_TOKENS, ) if not success: # Continue with original text if translation fails print(f"Translation warning: {message}") job_store.update_job(job_id, transcript=segments, progress=70) else: # Skip translation for non-Chinese content - just use original text as-is print(f"Skipping GPT translation for non-Chinese content (detected: {detected_lang})") # Set translated to original text for subtitle generation for seg in segments: seg.translated = seg.text job_store.update_job(job_id, transcript=segments, progress=70) # Step 3: Generate subtitle file style = subtitle_style or SubtitleStyle() subtitle_content = segments_to_ass( segments, use_translated=True, font_size=style.font_size, font_color=style.font_color.lstrip("#"), outline_color=style.outline_color.lstrip("#"), font_name=style.font_name, position=style.position, # top, center, bottom outline_width=style.outline_width, bold=style.bold, shadow=style.shadow, background_box=style.background_box, background_opacity=style.background_opacity, animation=style.animation, ) # Save subtitle file job_dir = os.path.dirname(job.video_path) subtitle_path = os.path.join(job_dir, "subtitle.ass") with open(subtitle_path, "w", encoding="utf-8") as f: f.write(subtitle_content) # Step 4: Process video job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=75) # Determine BGM path bgm_path = None if bgm_id: bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.mp3") if not os.path.exists(bgm_path): bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.wav") # Output path output_dir = os.path.join(settings.PROCESSED_DIR, job_id) os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, "output.mp4") success, message = await process_video( input_path=job.video_path, output_path=output_path, subtitle_path=subtitle_path, bgm_path=bgm_path, bgm_volume=bgm_volume, keep_original_audio=keep_original_audio, ) if success: job_store.update_job( job_id, status=JobStatus.COMPLETED, output_path=output_path, progress=100, ) else: job_store.update_job(job_id, status=JobStatus.FAILED, error=message) except Exception as e: job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) @router.post("/", response_model=ProcessResponse) async def start_processing( request: ProcessRequest, background_tasks: BackgroundTasks ): """Start video processing (transcribe, translate, add subtitles/BGM).""" job = job_store.get_job(request.job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.video_path: raise HTTPException(status_code=400, detail="Video not downloaded yet") if job.status == JobStatus.DOWNLOADING: raise HTTPException(status_code=400, detail="Download still in progress") # Start background processing background_tasks.add_task( process_task, request.job_id, request.bgm_id, request.bgm_volume, request.subtitle_style, request.keep_original_audio, request.translation_mode, request.use_vocal_separation, ) mode_info = f" (mode: {request.translation_mode or settings.TRANSLATION_MODE})" vocal_sep_info = ", vocal separation" if request.use_vocal_separation else "" return ProcessResponse( job_id=request.job_id, status=JobStatus.TRANSCRIBING, message=f"Processing started{mode_info}{vocal_sep_info}" ) @router.post("/{job_id}/transcribe") async def transcribe_only(job_id: str, background_tasks: BackgroundTasks): """Transcribe video only (without full processing).""" job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.video_path: raise HTTPException(status_code=400, detail="Video not downloaded yet") async def transcribe_task(): job_store.update_job(job_id, status=JobStatus.TRANSCRIBING, progress=35) success, message, segments = await transcribe_video(job.video_path) if success: job_store.update_job( job_id, transcript=segments, status=JobStatus.PENDING, progress=50, ) else: job_store.update_job(job_id, status=JobStatus.FAILED, error=message) background_tasks.add_task(transcribe_task) return {"message": "Transcription started"} @router.put("/{job_id}/transcript") async def update_transcript(job_id: str, segments: list[dict]): """Update transcript segments (for manual editing).""" job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") from app.models.schemas import TranscriptSegment updated_segments = [] for seg in segments: # Handle both dict and Pydantic model-like objects if hasattr(seg, 'model_dump'): seg = seg.model_dump() updated_segments.append(TranscriptSegment(**seg)) job_store.update_job(job_id, transcript=updated_segments) return {"message": "Transcript updated"} async def continue_processing_task( job_id: str, bgm_id: str | None, bgm_volume: float, subtitle_style: SubtitleStyle | None, keep_original_audio: bool, skip_subtitle: bool = False, ): """Continue processing for jobs that were awaiting subtitle input.""" job = job_store.get_job(job_id) if not job or not job.video_path: job_store.update_job(job_id, status=JobStatus.FAILED, error="Job or video not found") return try: subtitle_path = None if not skip_subtitle and job.transcript: # Generate subtitle file from existing transcript job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=60) style = subtitle_style or SubtitleStyle() subtitle_content = segments_to_ass( job.transcript, use_translated=True, font_size=style.font_size, font_color=style.font_color.lstrip("#"), outline_color=style.outline_color.lstrip("#"), font_name=style.font_name, position=style.position, outline_width=style.outline_width, bold=style.bold, shadow=style.shadow, background_box=style.background_box, background_opacity=style.background_opacity, animation=style.animation, ) job_dir = os.path.dirname(job.video_path) subtitle_path = os.path.join(job_dir, "subtitle.ass") with open(subtitle_path, "w", encoding="utf-8") as f: f.write(subtitle_content) else: # Skip subtitle - process with BGM only job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=60) # Process video job_store.update_job(job_id, progress=75) bgm_path = None if bgm_id: bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.mp3") if not os.path.exists(bgm_path): bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.wav") output_dir = os.path.join(settings.PROCESSED_DIR, job_id) os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, "output.mp4") success, message = await process_video( input_path=job.video_path, output_path=output_path, subtitle_path=subtitle_path, bgm_path=bgm_path, bgm_volume=bgm_volume, keep_original_audio=keep_original_audio and job.has_audio, # Only keep if has audio ) if success: job_store.update_job( job_id, status=JobStatus.COMPLETED, output_path=output_path, progress=100, ) else: job_store.update_job(job_id, status=JobStatus.FAILED, error=message) except Exception as e: job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) @router.post("/{job_id}/continue") async def continue_processing( job_id: str, request: ProcessRequest, background_tasks: BackgroundTasks, ): """Continue or reprocess a job (supports awaiting_subtitle and completed status).""" job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") # 허용된 상태: awaiting_subtitle (음성없음), completed (재처리) allowed_statuses = [JobStatus.AWAITING_SUBTITLE, JobStatus.COMPLETED] if job.status not in allowed_statuses: raise HTTPException( status_code=400, detail=f"Job status must be 'awaiting_subtitle' or 'completed', current: {job.status}" ) background_tasks.add_task( continue_processing_task, job_id, request.bgm_id, request.bgm_volume, request.subtitle_style, request.keep_original_audio, skip_subtitle=not job.transcript, # Skip if no transcript ) mode = "reprocessing" if job.status == JobStatus.COMPLETED else "no audio mode" return ProcessResponse( job_id=job_id, status=JobStatus.PROCESSING, message=f"Continuing processing ({mode})" ) @router.post("/{job_id}/manual-subtitle") async def add_manual_subtitle( job_id: str, segments: list, background_tasks: BackgroundTasks, ): """Add manual subtitle segments and continue processing.""" job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if job.status != JobStatus.AWAITING_SUBTITLE: raise HTTPException( status_code=400, detail=f"Job status must be 'awaiting_subtitle', current: {job.status}" ) # Parse and save the manual segments from app.models.schemas import TranscriptSegment manual_segments = [TranscriptSegment(**seg) for seg in segments] job_store.update_job(job_id, transcript=manual_segments) return {"message": "Manual subtitles added", "segment_count": len(manual_segments)} @router.get("/{job_id}/video-info", response_model=VideoInfoResponse) async def get_job_video_info(job_id: str): """Get video information for trimming UI.""" job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.video_path or not os.path.exists(job.video_path): raise HTTPException(status_code=400, detail="Video file not found") info = await get_video_info(job.video_path) if not info: # Fallback to just duration duration = await get_video_duration(job.video_path) if duration is None: raise HTTPException(status_code=500, detail="Could not get video info") info = {"duration": duration} return VideoInfoResponse( duration=info.get("duration", 0), width=info.get("width"), height=info.get("height"), ) @router.get("/{job_id}/frame") async def get_frame_at_timestamp(job_id: str, timestamp: float): """ Extract a single frame at the specified timestamp. Returns the frame as a JPEG image. Used for precise trimming preview. """ from fastapi.responses import FileResponse import tempfile job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.video_path or not os.path.exists(job.video_path): raise HTTPException(status_code=400, detail="Video file not found") # Get video duration to validate timestamp duration = await get_video_duration(job.video_path) if duration is None: raise HTTPException(status_code=500, detail="Could not get video duration") # Clamp timestamp to valid range timestamp = max(0, min(timestamp, duration - 0.01)) # Create temp file for frame temp_dir = tempfile.gettempdir() frame_path = os.path.join(temp_dir, f"frame_{job_id}_{timestamp:.3f}.jpg") success, message = await extract_frame(job.video_path, frame_path, timestamp) if not success: raise HTTPException(status_code=500, detail=message) return FileResponse( path=frame_path, media_type="image/jpeg", headers={ "Cache-Control": "public, max-age=60", } ) @router.post("/{job_id}/trim", response_model=TrimResponse) async def trim_job_video( job_id: str, request: TrimRequest, background_tasks: BackgroundTasks, ): """ Trim video to specified time range and optionally reprocess. This creates a new trimmed video file, replacing the original. Use this to remove sections with unwanted content (like original subtitles). """ job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.video_path or not os.path.exists(job.video_path): raise HTTPException(status_code=400, detail="Video file not found") # Validate time range duration = await get_video_duration(job.video_path) if duration is None: raise HTTPException(status_code=500, detail="Could not get video duration") if request.start_time < 0 or request.end_time > duration: raise HTTPException( status_code=400, detail=f"Invalid time range. Video duration is {duration:.1f}s" ) if request.start_time >= request.end_time: raise HTTPException( status_code=400, detail="Start time must be less than end time" ) # Update status job_store.update_job(job_id, status=JobStatus.TRIMMING, progress=5) # Create trimmed video path video_dir = os.path.dirname(job.video_path) video_ext = os.path.splitext(job.video_path)[1] trimmed_path = os.path.join(video_dir, f"trimmed{video_ext}") # Perform trim success, message = await trim_video( job.video_path, trimmed_path, request.start_time, request.end_time, ) if not success: job_store.update_job(job_id, status=JobStatus.FAILED, error=message) return TrimResponse( job_id=job_id, success=False, message=message, ) # Replace original with trimmed video original_path = job.video_path backup_path = os.path.join(video_dir, f"original_backup{video_ext}") try: # Backup original os.rename(original_path, backup_path) # Move trimmed to original location os.rename(trimmed_path, original_path) # Remove backup os.remove(backup_path) except Exception as e: job_store.update_job(job_id, status=JobStatus.FAILED, error=f"File operation failed: {e}") return TrimResponse( job_id=job_id, success=False, message=f"File operation failed: {e}", ) # Get new duration new_duration = await get_video_duration(original_path) # Reset job state for next step job_store.update_job( job_id, status=JobStatus.READY_FOR_TRIM, # Stay in trim-ready state for manual workflow progress=30, transcript=None, # Clear old transcript output_path=None, # Clear old output error=None, ) # Optionally start reprocessing if request.reprocess: background_tasks.add_task( process_task, job_id, None, # bgm_id 0.3, # bgm_volume None, # subtitle_style True, # keep_original_audio None, # translation_mode False, # use_vocal_separation ) message = f"Video trimmed to {new_duration:.1f}s. Reprocessing started." else: message = f"Video trimmed to {new_duration:.1f}s. Ready for processing." return TrimResponse( job_id=job_id, success=True, message=message, new_duration=new_duration, ) # ============================================================ # Step-by-step Processing API (Manual Workflow) # ============================================================ async def transcribe_step_task( job_id: str, translation_mode: str | None = None, use_vocal_separation: bool = False, ): """Background task for transcription step only (audio extraction + STT + translation).""" job = job_store.get_job(job_id) if not job or not job.video_path: job_store.update_job(job_id, status=JobStatus.FAILED, error="Job or video not found") return try: # Progress callback for real-time status updates async def progress_callback(step: str, progress: int): step_to_status = { "vocal_separation": JobStatus.EXTRACTING_AUDIO, "extracting_audio": JobStatus.EXTRACTING_AUDIO, "noise_reduction": JobStatus.NOISE_REDUCTION, "transcribing": JobStatus.TRANSCRIBING, } status = step_to_status.get(step, JobStatus.TRANSCRIBING) print(f"[Progress] Step: {step} -> Status: {status}, Progress: {progress}%") job_store.update_job(job_id, status=status, progress=progress) # Start with initial status job_store.update_job(job_id, status=JobStatus.EXTRACTING_AUDIO, progress=10) success, message, segments, detected_lang = await transcribe_video( job.video_path, use_noise_reduction=True, noise_reduction_level="medium", use_vocal_separation=use_vocal_separation, progress_callback=progress_callback, ) # Handle special cases if not success: if message in ("NO_AUDIO", "SILENT_AUDIO", "SINGING_ONLY"): if message == "NO_AUDIO": audio_status = "no_audio_stream" elif message == "SILENT_AUDIO": audio_status = "audio_silent" else: audio_status = "singing_only" job_store.update_job( job_id, status=JobStatus.AWAITING_SUBTITLE, progress=35, has_audio=message != "NO_AUDIO", audio_status=audio_status, ) return else: job_store.update_job(job_id, status=JobStatus.FAILED, error=message) return # Audio OK - speech detected and transcribed job_store.update_job(job_id, transcript=segments, progress=50, has_audio=True, audio_status="ok", detected_language=detected_lang) # Translate/Rewrite (only for Chinese content) is_chinese = detected_lang and detected_lang.lower() in ['zh', 'zh-cn', 'zh-tw', 'chinese', 'mandarin'] if is_chinese: job_store.update_job(job_id, status=JobStatus.TRANSLATING, progress=55) mode = translation_mode or settings.TRANSLATION_MODE success, message, segments = await translate_segments( segments, mode=mode, max_tokens=settings.TRANSLATION_MAX_TOKENS, ) if not success: print(f"Translation warning: {message}") job_store.update_job(job_id, transcript=segments, progress=70) else: # Skip translation for non-Chinese content print(f"Skipping GPT translation for non-Chinese content (detected: {detected_lang})") for seg in segments: seg.translated = seg.text job_store.update_job(job_id, transcript=segments, progress=70) # STOP HERE - Set status to AWAITING_REVIEW for user to review script job_store.update_job( job_id, status=JobStatus.AWAITING_REVIEW, progress=70, ) except Exception as e: job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) async def render_step_task( job_id: str, bgm_id: str | None, bgm_volume: float, subtitle_style: SubtitleStyle | None, keep_original_audio: bool, intro_text: str | None = None, intro_duration: float = 0.7, intro_font_size: int = 100, ): """Background task for final video rendering (subtitle composition + BGM + intro text).""" job = job_store.get_job(job_id) if not job or not job.video_path: job_store.update_job(job_id, status=JobStatus.FAILED, error="Job or video not found") return try: subtitle_path = None if job.transcript: # Generate subtitle file from transcript job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=75) style = subtitle_style or SubtitleStyle() # When intro text is shown, delay subtitles so they don't overlap subtitle_offset = intro_duration if intro_text else 0.0 subtitle_content = segments_to_ass( job.transcript, use_translated=True, font_size=style.font_size, font_color=style.font_color.lstrip("#"), outline_color=style.outline_color.lstrip("#"), font_name=style.font_name, position=style.position, outline_width=style.outline_width, bold=style.bold, shadow=style.shadow, background_box=style.background_box, background_opacity=style.background_opacity, animation=style.animation, time_offset=subtitle_offset, ) job_dir = os.path.dirname(job.video_path) subtitle_path = os.path.join(job_dir, "subtitle.ass") with open(subtitle_path, "w", encoding="utf-8") as f: f.write(subtitle_content) else: # No subtitle - BGM only mode job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=75) # Determine BGM path bgm_path = None if bgm_id: bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.mp3") if not os.path.exists(bgm_path): bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.wav") # Output path output_dir = os.path.join(settings.PROCESSED_DIR, job_id) os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, "output.mp4") job_store.update_job(job_id, progress=80) success, message = await process_video( input_path=job.video_path, output_path=output_path, subtitle_path=subtitle_path, bgm_path=bgm_path, bgm_volume=bgm_volume, keep_original_audio=keep_original_audio, intro_text=intro_text, intro_duration=intro_duration, intro_font_size=intro_font_size, ) if success: job_store.update_job( job_id, status=JobStatus.COMPLETED, output_path=output_path, progress=100, ) else: job_store.update_job(job_id, status=JobStatus.FAILED, error=message) except Exception as e: job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) @router.post("/{job_id}/start-transcription", response_model=ProcessResponse) async def start_transcription( job_id: str, request: TranscribeRequest, background_tasks: BackgroundTasks, ): """ Start transcription step (audio extraction + STT + translation). After completion, job status will be 'awaiting_review' for user to review the script. """ job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.video_path: raise HTTPException(status_code=400, detail="Video not downloaded yet") # Allow starting from ready_for_trim or pending status allowed_statuses = [JobStatus.READY_FOR_TRIM, JobStatus.PENDING] if job.status not in allowed_statuses: raise HTTPException( status_code=400, detail=f"Job status must be 'ready_for_trim' or 'pending', current: {job.status}" ) background_tasks.add_task( transcribe_step_task, job_id, request.translation_mode, request.use_vocal_separation, ) return ProcessResponse( job_id=job_id, status=JobStatus.EXTRACTING_AUDIO, message="Transcription started. Script will be ready for review." ) @router.post("/{job_id}/render", response_model=ProcessResponse) async def render_video( job_id: str, request: RenderRequest, background_tasks: BackgroundTasks, ): """ Render final video with subtitles and BGM. Call this after reviewing and editing the script. """ job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.video_path: raise HTTPException(status_code=400, detail="Video not found") # Allow rendering from awaiting_review, awaiting_subtitle, or completed (re-render) allowed_statuses = [JobStatus.AWAITING_REVIEW, JobStatus.AWAITING_SUBTITLE, JobStatus.COMPLETED] if job.status not in allowed_statuses: raise HTTPException( status_code=400, detail=f"Job status must be 'awaiting_review', 'awaiting_subtitle', or 'completed', current: {job.status}" ) background_tasks.add_task( render_step_task, job_id, request.bgm_id, request.bgm_volume, request.subtitle_style, request.keep_original_audio, request.intro_text, request.intro_duration, request.intro_font_size, ) return ProcessResponse( job_id=job_id, status=JobStatus.PROCESSING, message="Final video rendering started." ) @router.post("/{job_id}/retranslate") async def retranslate(job_id: str, background_tasks: BackgroundTasks): """ Re-run GPT translation on existing transcript segments. Useful when user wants to regenerate the Korean script. """ import logging logger = logging.getLogger(__name__) job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.transcript: raise HTTPException(status_code=400, detail="No transcript found. Run transcription first.") # Copy segments to avoid reference issues segments_copy = [seg.model_copy() for seg in job.transcript] logger.info(f"[Retranslate] Starting for job {job_id} with {len(segments_copy)} segments") async def retranslate_task(): try: logger.info(f"[Retranslate] Background task started for job {job_id}") job_store.update_job(job_id, status=JobStatus.TRANSLATING, progress=55) # Reset translations for seg in segments_copy: seg.translated = None mode = settings.TRANSLATION_MODE logger.info(f"[Retranslate] Using mode: {mode}") success, message, translated_segments = await translate_segments( segments_copy, mode=mode, max_tokens=settings.TRANSLATION_MAX_TOKENS, ) logger.info(f"[Retranslate] Translation result: success={success}, message={message}") if success: job_store.update_job( job_id, transcript=translated_segments, status=JobStatus.AWAITING_REVIEW, progress=70, ) logger.info(f"[Retranslate] Job {job_id} updated with new translation") else: job_store.update_job(job_id, status=JobStatus.FAILED, error=message) logger.error(f"[Retranslate] Translation failed: {message}") except Exception as e: logger.exception(f"[Retranslate] Exception in retranslate_task: {e}") job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) # Use asyncio.create_task to properly run async background task import asyncio asyncio.create_task(retranslate_task()) return {"message": "Re-translation started", "job_id": job_id} @router.post("/{job_id}/skip-trim", response_model=ProcessResponse) async def skip_trim(job_id: str): """ Skip the trimming step and proceed to transcription. Updates status from 'ready_for_trim' to 'pending'. """ job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if job.status != JobStatus.READY_FOR_TRIM: raise HTTPException( status_code=400, detail=f"Job status must be 'ready_for_trim', current: {job.status}" ) job_store.update_job(job_id, status=JobStatus.PENDING) return ProcessResponse( job_id=job_id, status=JobStatus.PENDING, message="Trim skipped. Ready for transcription." ) # ============================================================ # Thumbnail Generation API # ============================================================ @router.get("/{job_id}/thumbnail-timestamps") async def get_thumbnail_timestamps(job_id: str, count: int = 5): """ Get suggested timestamps for thumbnail frame selection. Returns evenly distributed timestamps from the video. """ job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.video_path or not os.path.exists(job.video_path): raise HTTPException(status_code=400, detail="Video file not found") timestamps = await get_video_timestamps(job.video_path, count) return { "job_id": job_id, "timestamps": timestamps, "count": len(timestamps), } @router.post("/{job_id}/generate-catchphrase") async def api_generate_catchphrase(job_id: str, style: str = "homeshopping"): """ Generate a catchy thumbnail text using GPT. Available styles: - homeshopping: 홈쇼핑 스타일 (강렬한 어필) - viral: 바이럴 스타일 (호기심 유발) - informative: 정보성 스타일 (명확한 전달) """ job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.transcript: raise HTTPException(status_code=400, detail="No transcript found. Run transcription first.") success, message, catchphrase = await generate_catchphrase(job.transcript, style) if not success: raise HTTPException(status_code=500, detail=message) return { "job_id": job_id, "catchphrase": catchphrase, "style": style, } @router.post("/{job_id}/thumbnail") async def create_thumbnail( job_id: str, timestamp: float = 2.0, style: str = "homeshopping", custom_text: str | None = None, font_size: int = 80, position: str = "center", ): """ Generate a thumbnail with text overlay. Args: timestamp: Time in seconds to extract frame (default: 2.0) style: Catchphrase style (homeshopping, viral, informative) custom_text: Custom text to use (skip GPT generation if provided) font_size: Font size for text overlay (default: 80) position: Text position (top, center, bottom) Returns: Thumbnail file path and the text used """ job = job_store.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.video_path or not os.path.exists(job.video_path): raise HTTPException(status_code=400, detail="Video file not found") # Use empty transcript if not available transcript = job.transcript or [] success, message, thumbnail_path = await generate_thumbnail( job_id=job_id, video_path=job.video_path, transcript=transcript, timestamp=timestamp, style=style, custom_text=custom_text, font_size=font_size, position=position, ) if not success: raise HTTPException(status_code=500, detail=message) # Extract text from message text_used = message.replace("Thumbnail generated: ", "") return { "job_id": job_id, "thumbnail_path": thumbnail_path, "thumbnail_url": f"/api/jobs/{job_id}/thumbnail", "text": text_used, "timestamp": timestamp, }