CoolFace
Apppublic

AI-Talent-Force/dev_caio

sourceHugging Facemitupdated 9mo agoView on Hugging Face
0likes
clip_extractor.py458 linesDownload Raw Back to core
1"""2ShortSmith v2 - Clip Extractor Module3 4Final clip extraction and output generation.5Handles cutting clips at precise timestamps with various output options.6"""7 8from pathlib import Path9from typing import List, Optional, Tuple10from dataclasses import dataclass, field11import shutil12 13from utils.logger import get_logger, LogTimer14from utils.helpers import (15    VideoProcessingError,16    ensure_dir,17    format_timestamp,18    get_unique_filename,19)20from config import get_config, ProcessingConfig21from core.video_processor import VideoProcessor, VideoMetadata22 23logger = get_logger("core.clip_extractor")24 25 26@dataclass27class ExtractedClip:28    """Represents an extracted video clip."""29    clip_path: Path              # Path to the clip file30    start_time: float            # Start timestamp in source video31    end_time: float              # End timestamp in source video32    hype_score: float            # Normalized hype score (0-1)33    rank: int                    # Rank among all clips (1 = best)34    thumbnail_path: Optional[Path] = None  # Path to thumbnail35 36    # Metadata37    source_video: Optional[Path] = None38    person_detected: bool = False39    person_screen_time: float = 0.0  # Percentage of clip with target person40 41    # Additional scores42    visual_score: float = 0.043    audio_score: float = 0.044    motion_score: float = 0.045 46    @property47    def duration(self) -> float:48        """Clip duration in seconds."""49        return self.end_time - self.start_time50 51    @property52    def time_range(self) -> str:53        """Human-readable time range."""54        return f"{format_timestamp(self.start_time)} - {format_timestamp(self.end_time)}"55 56    def to_dict(self) -> dict:57        """Convert to dictionary for JSON serialization."""58        return {59            "clip_path": str(self.clip_path),60            "start_time": self.start_time,61            "end_time": self.end_time,62            "duration": self.duration,63            "hype_score": round(self.hype_score, 4),64            "rank": self.rank,65            "time_range": self.time_range,66            "visual_score": round(self.visual_score, 4),67            "audio_score": round(self.audio_score, 4),68            "motion_score": round(self.motion_score, 4),69            "person_detected": self.person_detected,70            "person_screen_time": round(self.person_screen_time, 4),71        }72 73 74@dataclass75class ClipCandidate:76    """A candidate segment for clip extraction."""77    start_time: float78    end_time: float79    hype_score: float80    visual_score: float = 0.081    audio_score: float = 0.082    motion_score: float = 0.083    person_score: float = 0.0  # Target person visibility84 85    @property86    def duration(self) -> float:87        return self.end_time - self.start_time88 89 90class ClipExtractor:91    """92    Extracts final clips from video based on hype scores.93 94    Handles:95    - Selecting top segments based on scores96    - Enforcing diversity (minimum gap between clips)97    - Adjusting clip boundaries to scene cuts98    - Generating thumbnails99    """100 101    def __init__(102        self,103        video_processor: VideoProcessor,104        config: Optional[ProcessingConfig] = None,105    ):106        """107        Initialize clip extractor.108 109        Args:110            video_processor: VideoProcessor instance for clip cutting111            config: Processing configuration (uses default if None)112        """113        self.video_processor = video_processor114        self.config = config or get_config().processing115 116        logger.info(117            f"ClipExtractor initialized (duration={self.config.min_clip_duration}-"118            f"{self.config.max_clip_duration}s, gap={self.config.min_gap_between_clips}s)"119        )120 121    def select_clips(122        self,123        candidates: List[ClipCandidate],124        num_clips: int,125        enforce_diversity: bool = True,126    ) -> List[ClipCandidate]:127        """128        Select top clips from candidates.129 130        Args:131            candidates: List of clip candidates with scores132            num_clips: Number of clips to select133            enforce_diversity: Enforce minimum gap between clips134 135        Returns:136            List of selected ClipCandidate objects137        """138        if not candidates:139            logger.warning("No candidates provided for selection")140            return []141 142        # Sort by hype score143        sorted_candidates = sorted(144            candidates, key=lambda c: c.hype_score, reverse=True145        )146 147        if not enforce_diversity:148            return sorted_candidates[:num_clips]149 150        # Select with diversity constraint151        selected = []152        min_gap = self.config.min_gap_between_clips153 154        for candidate in sorted_candidates:155            if len(selected) >= num_clips:156                break157 158            # Check if this candidate is far enough from existing selections159            is_diverse = True160            for existing in selected:161                # Calculate gap between clip starts162                gap = abs(candidate.start_time - existing.start_time)163                if gap < min_gap:164                    is_diverse = False165                    break166 167            if is_diverse:168                selected.append(candidate)169 170        # If we couldn't get enough with diversity, relax constraint171        if len(selected) < num_clips:172            logger.warning(173                f"Only {len(selected)} diverse clips found, "174                f"relaxing diversity constraint"175            )176            for candidate in sorted_candidates:177                if candidate not in selected:178                    selected.append(candidate)179                if len(selected) >= num_clips:180                    break181 182        logger.info(f"Selected {len(selected)} clips from {len(candidates)} candidates")183        return selected184 185    def adjust_to_scene_boundaries(186        self,187        candidates: List[ClipCandidate],188        scene_boundaries: List[float],189        tolerance: float = 1.0,190    ) -> List[ClipCandidate]:191        """192        Adjust clip boundaries to align with scene cuts.193 194        Args:195            candidates: List of clip candidates196            scene_boundaries: List of scene boundary timestamps197            tolerance: Maximum adjustment in seconds198 199        Returns:200            List of adjusted ClipCandidate objects201        """202        if not scene_boundaries:203            return candidates204 205        adjusted = []206 207        for candidate in candidates:208            new_start = candidate.start_time209            new_end = candidate.end_time210 211            # Find nearest scene boundary for start212            for boundary in scene_boundaries:213                if abs(boundary - candidate.start_time) < tolerance:214                    new_start = boundary215                    break216 217            # Find nearest scene boundary for end218            for boundary in scene_boundaries:219                if abs(boundary - candidate.end_time) < tolerance:220                    new_end = boundary221                    break222 223            # Ensure minimum duration224            if new_end - new_start < self.config.min_clip_duration:225                # Keep original boundaries226                new_start = candidate.start_time227                new_end = candidate.end_time228 229            adjusted.append(ClipCandidate(230                start_time=new_start,231                end_time=new_end,232                hype_score=candidate.hype_score,233                visual_score=candidate.visual_score,234                audio_score=candidate.audio_score,235                motion_score=candidate.motion_score,236                person_score=candidate.person_score,237            ))238 239        return adjusted240 241    def extract_clips(242        self,243        video_path: str | Path,244        output_dir: str | Path,245        candidates: List[ClipCandidate],246        num_clips: Optional[int] = None,247        generate_thumbnails: bool = True,248        reencode: bool = False,249    ) -> List[ExtractedClip]:250        """251        Extract clips from video.252 253        Args:254            video_path: Path to source video255            output_dir: Directory for output clips256            candidates: List of clip candidates257            num_clips: Number of clips to extract (None = use config default)258            generate_thumbnails: Whether to generate thumbnails259            reencode: Whether to re-encode clips (slower but precise)260 261        Returns:262            List of ExtractedClip objects263        """264        video_path = Path(video_path)265        output_dir = ensure_dir(output_dir)266        num_clips = num_clips or self.config.default_num_clips267 268        with LogTimer(logger, f"Extracting {num_clips} clips"):269            # Select top clips270            selected = self.select_clips(candidates, num_clips)271 272            if not selected:273                logger.warning("No clips to extract")274                return []275 276            # Extract each clip277            clips = []278 279            for rank, candidate in enumerate(selected, 1):280                try:281                    clip = self._extract_single_clip(282                        video_path=video_path,283                        output_dir=output_dir,284                        candidate=candidate,285                        rank=rank,286                        generate_thumbnail=generate_thumbnails,287                        reencode=reencode,288                    )289                    clips.append(clip)290 291                except Exception as e:292                    logger.error(f"Failed to extract clip {rank}: {e}")293 294            logger.info(f"Successfully extracted {len(clips)} clips")295            return clips296 297    def _extract_single_clip(298        self,299        video_path: Path,300        output_dir: Path,301        candidate: ClipCandidate,302        rank: int,303        generate_thumbnail: bool,304        reencode: bool,305    ) -> ExtractedClip:306        """Extract a single clip."""307        # Generate output filename308        clip_filename = f"clip_{rank:02d}_{format_timestamp(candidate.start_time).replace(':', '-')}.mp4"309        clip_path = output_dir / clip_filename310 311        # Cut the clip312        self.video_processor.cut_clip(313            video_path=video_path,314            output_path=clip_path,315            start_time=candidate.start_time,316            end_time=candidate.end_time,317            reencode=reencode,318        )319 320        # Generate thumbnail321        thumbnail_path = None322        if generate_thumbnail:323            try:324                thumb_filename = f"thumb_{rank:02d}.jpg"325                thumbnail_path = output_dir / "thumbnails" / thumb_filename326                thumbnail_path.parent.mkdir(exist_ok=True)327 328                # Thumbnail at 1/3 into the clip329                thumb_time = candidate.start_time + (candidate.duration / 3)330                self.video_processor.generate_thumbnail(331                    video_path=video_path,332                    output_path=thumbnail_path,333                    timestamp=thumb_time,334                )335            except Exception as e:336                logger.warning(f"Failed to generate thumbnail for clip {rank}: {e}")337                thumbnail_path = None338 339        return ExtractedClip(340            clip_path=clip_path,341            start_time=candidate.start_time,342            end_time=candidate.end_time,343            hype_score=candidate.hype_score,344            rank=rank,345            thumbnail_path=thumbnail_path,346            source_video=video_path,347            visual_score=candidate.visual_score,348            audio_score=candidate.audio_score,349            motion_score=candidate.motion_score,350            person_detected=candidate.person_score > 0,351            person_screen_time=candidate.person_score,352        )353 354    def create_fallback_clips(355        self,356        video_path: str | Path,357        output_dir: str | Path,358        duration: float,359        num_clips: int,360    ) -> List[ExtractedClip]:361        """362        Create uniformly distributed clips when no highlights are detected.363 364        Args:365            video_path: Path to source video366            output_dir: Directory for output clips367            duration: Video duration in seconds368            num_clips: Number of clips to create369 370        Returns:371            List of fallback ExtractedClip objects372        """373        logger.warning("Creating fallback clips (no highlights detected)")374 375        clip_duration = self.config.default_clip_duration376        total_clip_time = clip_duration * num_clips377 378        if total_clip_time >= duration:379            # Video too short, adjust380            clip_duration = max(381                self.config.min_clip_duration,382                duration / (num_clips + 1)383            )384 385        # Calculate evenly spaced start times386        gap = (duration - clip_duration * num_clips) / (num_clips + 1)387        candidates = []388 389        for i in range(num_clips):390            start = gap + i * (clip_duration + gap)391            end = start + clip_duration392 393            candidates.append(ClipCandidate(394                start_time=start,395                end_time=min(end, duration),396                hype_score=0.5,  # Neutral score397            ))398 399        return self.extract_clips(400            video_path=video_path,401            output_dir=output_dir,402            candidates=candidates,403            num_clips=num_clips,404        )405 406    def merge_adjacent_candidates(407        self,408        candidates: List[ClipCandidate],409        max_gap: float = 2.0,410        max_duration: Optional[float] = None,411    ) -> List[ClipCandidate]:412        """413        Merge adjacent high-scoring candidates into longer clips.414 415        Args:416            candidates: List of clip candidates417            max_gap: Maximum gap between candidates to merge418            max_duration: Maximum merged clip duration419 420        Returns:421            List of merged ClipCandidate objects422        """423        max_duration = max_duration or self.config.max_clip_duration424 425        if not candidates:426            return []427 428        # Sort by start time429        sorted_candidates = sorted(candidates, key=lambda c: c.start_time)430        merged = []431        current = sorted_candidates[0]432 433        for candidate in sorted_candidates[1:]:434            gap = candidate.start_time - current.end_time435            potential_duration = candidate.end_time - current.start_time436 437            if gap <= max_gap and potential_duration <= max_duration:438                # Merge439                current = ClipCandidate(440                    start_time=current.start_time,441                    end_time=candidate.end_time,442                    hype_score=max(current.hype_score, candidate.hype_score),443                    visual_score=max(current.visual_score, candidate.visual_score),444                    audio_score=max(current.audio_score, candidate.audio_score),445                    motion_score=max(current.motion_score, candidate.motion_score),446                    person_score=max(current.person_score, candidate.person_score),447                )448            else:449                merged.append(current)450                current = candidate451 452        merged.append(current)453        return merged454 455 456# Export public interface457__all__ = ["ClipExtractor", "ExtractedClip", "ClipCandidate"]458