import json import shutil import subprocess import uuid from datetime import datetime, timezone from pathlib import Path from sqlmodel import Session from app.core.celery_app import celery_app from app.core.db import engine from app.models import ( Clip, ClipStatus, CutToMusicVideo, ExtraVideo, ProcessingJob, Project, ProjectStatus, Segment, TranscodingStatus, ) def _check_codec_compatibility(source_path: Path) -> tuple[bool, str, str]: """Return (is_compatible, video_codec, audio_codec).""" ffprobe = shutil.which("ffprobe") or "ffprobe" result = subprocess.run( [ ffprobe, "-v", "error", "-select_streams", "v:0,a:0", "-show_entries", "stream=codec_name,codec_type", "-of", "json", str(source_path), ], capture_output=True, text=True, ) video_codec, audio_codec = "unknown", "none" try: for stream in json.loads(result.stdout).get("streams", []): if stream.get("codec_type") == "video": video_codec = stream.get("codec_name", "unknown") elif stream.get("codec_type") == "audio": audio_codec = stream.get("codec_name", "none") except (json.JSONDecodeError, KeyError): pass is_compatible = video_codec == "h264" and audio_codec == "aac" return is_compatible, video_codec, audio_codec def _transcode_for_web(source_path: Path, output_path: Path) -> None: """Re-encode to H.264/AAC for Safari/web playback.""" ffmpeg = shutil.which("ffmpeg") or "ffmpeg" result = subprocess.run( [ ffmpeg, "-y", "-i", str(source_path), "-c:v", "libx264", "-preset", "fast", "-crf", "22", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(output_path), ], capture_output=True, text=True, ) if result.returncode != 0: raise RuntimeError(f"FFmpeg transcoding failed: {result.stderr[-500:]}") def _generate_mock_segments( num_segments: int, video_duration: float, min_duration: float | None, max_duration: float | None, ) -> list: """Generate realistic mock segments for testing without API quota.""" import random from fansifter_clipper.models import VideoSegment eff_min = min_duration if min_duration is not None else 10.0 eff_max = max_duration if max_duration is not None else 20.0 segments = [] for i in range(num_segments): duration = random.uniform(eff_min, eff_max) max_start = max(0, video_duration - duration) start = random.uniform(0, max_start) end = start + duration segments.append( VideoSegment( start=round(start, 2), end=round(end, 2), score=round(random.uniform(0.6, 1.0), 2), description=f"Mock Segment {i + 1}: Simulated high-energy moment at {int(start)}s", ) ) segments.sort(key=lambda s: s.start) return segments[:num_segments] @celery_app.task(bind=True) def analyze_video_task(self, project_id: str, job_id: str, use_mock: bool = False): """Phase 1: Extract segments using Gemini or generate mock data""" import os from app.core.config import settings if settings.GEMINI_API_KEY: os.environ["GEMINI_API_KEY"] = settings.GEMINI_API_KEY from fansifter_clipper.ffmpeg_utils import probe_video from fansifter_clipper.models import ProcessingConfig, VideoSegment # noqa: F401 from fansifter_clipper.processor import VideoProcessor with Session(engine) as session: project = session.get(Project, uuid.UUID(project_id)) job = session.get(ProcessingJob, uuid.UUID(job_id)) if not project or not job: return {"error": "Project or job not found"} try: project.status = ProjectStatus.ANALYZING job.status = "running" job.started_at = datetime.now(timezone.utc) job.current_step = "Probing video metadata" session.add(project) session.add(job) session.commit() # Download remote video before probing/analysis if project.video_path == "pending": from app.core.config import settings as app_settings from fansifter_clipper import GoogleDriveDownloader download_dir = ( Path(str(app_settings.MEDIA_ROOT)) / "uploads" / str(project.owner_id) / str(project.id) / "source" ) download_dir.mkdir(parents=True, exist_ok=True) job.current_step = "Downloading video" session.add(job) session.commit() if project.source_type == "google_drive_url": downloaded = GoogleDriveDownloader(cache_dir=download_dir).download(project.source_url) else: raise ValueError(f"Unexpected source type with pending path: {project.source_type}") project.video_path = str(downloaded) project.video_filename = downloaded.name project.file_size_bytes = downloaded.stat().st_size session.add(project) session.commit() job.current_step = "Probing video metadata" session.add(job) session.commit() video_path = Path(project.video_path) if not project.width: metadata = probe_video(video_path) project.width = metadata.width project.height = metadata.height project.duration_seconds = metadata.duration project.fps = metadata.fps session.add(project) session.commit() job.progress_percent = 10.0 session.add(job) session.commit() # Resolve extra video paths for individual clips analysis from sqlmodel import select as sa_select extra_videos_db = session.exec( sa_select(ExtraVideo).where(ExtraVideo.project_id == project.id) ).all() extra_paths: list[Path] = [] extra_path_to_id: dict[str, str] = {} for ev in extra_videos_db: if ev.status == "ready" and ev.video_path: p = Path(ev.video_path) extra_paths.append(p) extra_path_to_id[str(p)] = str(ev.id) elif ev.source_url and ev.source_type == "google_drive_url": from fansifter_clipper import GoogleDriveDownloader dl_dir = Path(project.video_path).parent / "extras" / str(ev.id) dl_dir.mkdir(parents=True, exist_ok=True) try: downloaded = GoogleDriveDownloader(cache_dir=dl_dir).download(ev.source_url) ev.video_path = str(downloaded) ev.status = "ready" session.add(ev) session.commit() extra_paths.append(downloaded) extra_path_to_id[str(downloaded)] = str(ev.id) except Exception: pass # Non-critical: skip unavailable extra videos if use_mock: job.current_step = "Generating mock segments (no API call)" session.add(job) session.commit() segments = _generate_mock_segments( num_segments=project.num_clips, video_duration=project.duration_seconds, min_duration=project.min_duration, max_duration=project.max_duration, ) else: job.current_step = "Analyzing video with Gemini" session.add(job) session.commit() from fansifter_clipper.models import VideoSource clips_video_source = project.clips_video_source or "main" video_source = VideoSource(clips_video_source) if not extra_paths and video_source == VideoSource.EXTRAS_ONLY: video_source = VideoSource.MAIN_ONLY segment_duration = ( (project.min_duration, project.max_duration) if project.min_duration is not None and project.max_duration is not None else None ) config = ProcessingConfig( input_video=video_path, output_dir=Path("/tmp"), num_segments=project.num_clips, segment_duration=segment_duration, extra_videos=extra_paths, video_source=video_source, ) processor = VideoProcessor(config) segments = processor.analyze() job.progress_percent = 70.0 job.current_step = "Saving segments to database" session.add(job) session.commit() import uuid as _uuid for idx, seg in enumerate(segments): src_ev_id = None if seg.source_video is not None: ev_id_str = extra_path_to_id.get(str(seg.source_video)) if ev_id_str: src_ev_id = _uuid.UUID(ev_id_str) db_segment = Segment( project_id=project.id, start_time=seg.start, end_time=seg.end, score=seg.score, description=seg.description, order_index=idx + 1, source_extra_video_id=src_ev_id, ) session.add(db_segment) session.commit() # Chorus analysis for cut-to-music preview job.progress_percent = 80.0 job.current_step = "Analyzing chorus for cut-to-music" session.add(job) session.commit() try: if use_mock: mid = (project.duration_seconds or 60.0) / 2.0 project.chorus_start = max(0.0, mid - 8.0) project.chorus_end = mid + 8.0 project.chorus_description = "Mock chorus: simulated high-energy section" else: from fansifter_clipper.cut_to_music_intelligence import CutToMusicIntelligence chorus = CutToMusicIntelligence().analyze_chorus(video_path) project.chorus_start = chorus.chorus_start project.chorus_end = chorus.chorus_end project.chorus_description = chorus.description session.add(project) session.commit() except Exception: # Chorus analysis is non-critical; don't fail the whole task pass project.status = ProjectStatus.ANALYSIS_COMPLETE project.updated_at = datetime.now(timezone.utc) job.status = "completed" job.progress_percent = 100.0 job.current_step = "Analysis complete" job.completed_at = datetime.now(timezone.utc) session.add(project) session.add(job) session.commit() return {"status": "success", "segments_count": len(segments)} except Exception as e: project.status = ProjectStatus.FAILED project.error_message = str(e) job.status = "failed" job.error_message = str(e) session.add(project) session.add(job) session.commit() raise @celery_app.task(bind=True) def generate_clips_task(self, project_id: str, job_id: str): """Phase 2: Generate clips from user-edited segments""" import os from app.core.config import settings if settings.GEMINI_API_KEY: os.environ["GEMINI_API_KEY"] = settings.GEMINI_API_KEY from fansifter_clipper.models import ProcessingConfig, VideoSegment from fansifter_clipper.processor import VideoProcessor with Session(engine) as session: project = session.get(Project, uuid.UUID(project_id)) job = session.get(ProcessingJob, uuid.UUID(job_id)) if not project or not job: return {"error": "Project or job not found"} try: project.status = ProjectStatus.PROCESSING job.status = "running" job.started_at = datetime.now(timezone.utc) job.current_step = "Preparing segments" session.add(project) session.add(job) session.commit() segments = [s for s in project.segments if s.is_selected] segments.sort(key=lambda s: s.order_index) if not segments: raise ValueError("No segments selected for processing") output_dir = Path(project.video_path).parent / "clips" output_dir.mkdir(exist_ok=True) job.progress_percent = 10.0 job.current_step = f"Processing {len(segments)} clips" session.add(job) session.commit() from sqlmodel import select as sa_select from fansifter_clipper.models import CaptionPosition, CaptionStyle # Resolve extra video paths for UGC segments extra_videos_db = session.exec( sa_select(ExtraVideo).where(ExtraVideo.project_id == project.id) ).all() ev_id_to_path: dict[str, Path] = { str(ev.id): Path(ev.video_path) for ev in extra_videos_db if ev.status == "ready" and ev.video_path } video_segments = [] for seg in segments: src_path: Path | None = None if seg.source_extra_video_id is not None: src_path = ev_id_to_path.get(str(seg.source_extra_video_id)) vs = VideoSegment( start=seg.start_time, end=seg.end_time, score=seg.score, description=seg.description, caption_text=seg.caption_text_override or None, caption_style=CaptionStyle(seg.caption_style_override) if seg.caption_style_override else None, caption_position=CaptionPosition(seg.caption_position_override) if seg.caption_position_override else None, source_video=src_path, ) video_segments.append(vs) # Build audio-pairing pool from ALL analyzed main-video segments (not just # selected ones), so that a fully-UGC selection still gets main-track audio. all_main_db = sorted( [s for s in project.segments if s.source_extra_video_id is None], key=lambda s: s.score, reverse=True, ) main_segs_for_audio = [ VideoSegment(start=s.start_time, end=s.end_time, score=s.score, description=s.description) for s in all_main_db ] # Fallback: use the chorus window when the project has no main-video segments if not main_segs_for_audio and project.chorus_start is not None: vid_dur = project.duration_seconds or 60.0 main_segs_for_audio = [ VideoSegment( start=project.chorus_start, end=min(project.chorus_end or project.chorus_start + 30.0, vid_dur), score=1.0, description="Chorus", ) ] segment_duration = ( (project.min_duration, project.max_duration) if project.min_duration is not None and project.max_duration is not None else None ) config = ProcessingConfig( input_video=Path(project.video_path), output_dir=output_dir, num_segments=len(video_segments), segment_duration=segment_duration, caption_text=project.caption_text or None, caption_style=CaptionStyle(project.caption_style) if project.caption_style else CaptionStyle.TIKTOK, caption_position=CaptionPosition(project.caption_position) if project.caption_position else CaptionPosition.CENTER, ) job.progress_percent = 20.0 job.current_step = "Generating clips with clipper" session.add(job) session.commit() processor = VideoProcessor(config) output_paths = processor.reframe_and_render( video_segments, output_dir=output_dir, main_video_segments=main_segs_for_audio or None, ) job.progress_percent = 80.0 job.current_step = "Saving clip records to database" session.add(job) session.commit() for segment, output_path in zip(segments, output_paths): clip = Clip( project_id=project.id, segment_id=segment.id, filename=output_path.name, file_path=str(output_path), status=ClipStatus.COMPLETED, file_size_bytes=output_path.stat().st_size, duration_seconds=segment.end_time - segment.start_time, completed_at=datetime.now(timezone.utc), ) session.add(clip) project.status = ProjectStatus.COMPLETED project.updated_at = datetime.now(timezone.utc) job.status = "completed" job.progress_percent = 100.0 job.current_step = "All clips generated successfully" job.completed_at = datetime.now(timezone.utc) session.add(project) session.add(job) session.commit() return {"status": "success", "clips_count": len(output_paths)} except Exception as e: project.status = ProjectStatus.FAILED project.error_message = str(e) job.status = "failed" job.error_message = str(e) session.add(project) session.add(job) session.commit() raise @celery_app.task(bind=True) def generate_cut_to_music_task(self, project_id: str, ctm_video_id: str): """Generate a single cut-to-music video (runs twice in parallel for variety).""" import os from sqlmodel import select as sa_select from app.core.config import settings if settings.GEMINI_API_KEY: os.environ["GEMINI_API_KEY"] = settings.GEMINI_API_KEY from fansifter_clipper import GoogleDriveDownloader from fansifter_clipper.cut_to_music_processor import CutToMusicProcessor from fansifter_clipper.models import CaptionPosition, CaptionStyle, CutToMusicConfig, VideoSource with Session(engine) as session: project = session.get(Project, uuid.UUID(project_id)) ctm_video = session.get(CutToMusicVideo, uuid.UUID(ctm_video_id)) if not project or not ctm_video: return {"error": "Project or CutToMusicVideo not found"} try: ctm_video.status = ClipStatus.PROCESSING session.add(ctm_video) session.commit() # Resolve extra video paths, downloading URLs if needed extra_videos = session.exec( sa_select(ExtraVideo).where(ExtraVideo.project_id == project.id) ).all() extra_paths = [] for ev in extra_videos: if ev.status == "ready" and ev.video_path: extra_paths.append(Path(ev.video_path)) elif ev.source_url and ev.source_type == "google_drive_url": dl_dir = Path(project.video_path).parent / "extras" / str(ev.id) dl_dir.mkdir(parents=True, exist_ok=True) downloaded = GoogleDriveDownloader(cache_dir=dl_dir).download(ev.source_url) ev.video_path = str(downloaded) ev.status = "ready" session.add(ev) session.commit() extra_paths.append(downloaded) output_dir = Path(project.video_path).parent / "cut_to_music" / f"run_{ctm_video.run_index}" output_dir.mkdir(parents=True, exist_ok=True) video_source = VideoSource(project.cut_to_music_video_source) if project.cut_to_music_video_source else VideoSource.ALL if not extra_paths and video_source == VideoSource.EXTRAS_ONLY: video_source = VideoSource.MAIN_ONLY total_duration = ( (project.min_duration, project.max_duration) if project.min_duration is not None and project.max_duration is not None else None ) config = CutToMusicConfig( input_video=Path(project.video_path), output_dir=output_dir, extra_videos=extra_paths, video_source=video_source, total_duration=total_duration, caption_text=project.caption_text or None, caption_style=CaptionStyle(project.caption_style) if project.caption_style else CaptionStyle.TIKTOK, caption_position=CaptionPosition(project.caption_position) if project.caption_position else CaptionPosition.CENTER, ) processor = CutToMusicProcessor(config) output_path = processor.process(output_dir=output_dir) # Rename to include run index so two parallel runs don't collide final_name = f"cut_to_music_{ctm_video.run_index}.mp4" final_path = output_path.parent / final_name if output_path != final_path: output_path.rename(final_path) ctm_video.filename = final_name ctm_video.file_path = str(final_path) ctm_video.status = ClipStatus.COMPLETED ctm_video.progress_percent = 100.0 ctm_video.completed_at = datetime.now(timezone.utc) ctm_video.file_size_bytes = final_path.stat().st_size session.add(ctm_video) session.commit() return {"status": "success", "output": str(final_path)} except Exception as e: ctm_video.status = ClipStatus.FAILED ctm_video.error_message = str(e) session.add(ctm_video) session.commit() raise @celery_app.task(bind=True) def detect_chorus_task(self, project_id: str): """Detect the cut-to-music chorus window without re-running full analysis.""" import os from app.core.config import settings if settings.GEMINI_API_KEY: os.environ["GEMINI_API_KEY"] = settings.GEMINI_API_KEY with Session(engine) as session: project = session.get(Project, uuid.UUID(project_id)) if not project: return {"error": "Project not found"} try: video_path = Path(project.video_path) if settings.GEMINI_API_KEY and video_path.exists(): from fansifter_clipper.cut_to_music_intelligence import CutToMusicIntelligence chorus = CutToMusicIntelligence().analyze_chorus(video_path) project.chorus_start = chorus.chorus_start project.chorus_end = chorus.chorus_end project.chorus_description = chorus.description else: # Fallback mock chorus mid = (project.duration_seconds or 60.0) / 2.0 project.chorus_start = max(0.0, mid - 8.0) project.chorus_end = mid + 8.0 project.chorus_description = "Estimated chorus section" project.updated_at = datetime.now(timezone.utc) session.add(project) session.commit() return {"status": "success", "chorus_start": project.chorus_start} except Exception as e: return {"status": "error", "error": str(e)} @celery_app.task(bind=True) def transcode_video_task(self, project_id: str): """Transcode video to H.264/AAC for Safari compatibility.""" import logging logger = logging.getLogger(__name__) with Session(engine) as session: project = session.get(Project, uuid.UUID(project_id)) if not project: return {"error": "Project not found"} project.transcoding_status = TranscodingStatus.PROCESSING session.add(project) session.commit() try: source_path = Path(project.video_path) is_compatible, video_codec, audio_codec = _check_codec_compatibility(source_path) if is_compatible: logger.info("Video already H.264/AAC, skipping transcoding: %s", project.id) project.transcoding_status = TranscodingStatus.NOT_NEEDED session.add(project) session.commit() return {"status": "not_needed", "reason": "Already H.264/AAC"} logger.info( "Transcoding video from %s/%s to H.264/AAC: %s", video_codec, audio_codec, project.id, ) transcoded_dir = source_path.parent.parent / "transcoded" transcoded_dir.mkdir(parents=True, exist_ok=True) output_path = transcoded_dir / "web_optimized.mp4" _transcode_for_web(source_path, output_path) project.transcoded_path = str(output_path) project.transcoding_status = TranscodingStatus.COMPLETE project.transcoding_error = None session.add(project) session.commit() logger.info("Transcoding complete: %s", project.id) return {"status": "success", "transcoded_path": str(output_path)} except Exception as e: logger.error("Transcoding failed for %s: %s", project.id, str(e)) project.transcoding_status = TranscodingStatus.FAILED project.transcoding_error = str(e) session.add(project) session.commit() return {"status": "failed", "error": str(e)}