| """ |
| Background Handler for Text Story module. |
| Handles gameplay video loading from HuggingFace Dataset storage. |
| """ |
|
|
| import os |
| import random |
| import logging |
| from moviepy.editor import VideoFileClip, vfx |
| from typing import Optional, List |
| from huggingface_hub import hf_hub_download, list_repo_files |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| HF_BACKGROUNDS_FOLDER = "gameplay_backgrounds" |
|
|
| |
| LOCAL_CACHE_DIR = "cache/gameplay_backgrounds" |
|
|
|
|
| class BackgroundHandler: |
| """ |
| Handles gameplay background video processing. |
| Downloads from HuggingFace Dataset (HF_REPO env variable). |
| |
| Features: |
| - Download videos from HF Dataset |
| - Random video selection |
| - Audio removal |
| - Slow motion (0.7x) |
| - Dark overlay |
| - Seamless looping |
| """ |
| |
| def __init__(self): |
| |
| self.repo_id = os.getenv("HF_REPO", "") |
| self.folder = HF_BACKGROUNDS_FOLDER |
| self.cache_dir = LOCAL_CACHE_DIR |
| |
| if not self.repo_id: |
| logger.warning("BackgroundHandler: HF_REPO not set! Using green screen background.") |
| self.available_videos = [] |
| return |
| |
| |
| os.makedirs(self.cache_dir, exist_ok=True) |
| |
| |
| self.available_videos = self._list_available_videos() |
| |
| if self.available_videos: |
| logger.info(f"BackgroundHandler: Found {len(self.available_videos)} videos in {self.repo_id}/{self.folder}") |
| else: |
| logger.warning(f"BackgroundHandler: No videos found in {self.repo_id}/{self.folder}") |
| |
| self._ensure_folder_exists() |
| |
| def _list_available_videos(self) -> List[str]: |
| """List available video files in HF Dataset folder.""" |
| if not self.repo_id: |
| return [] |
| |
| try: |
| all_files = list_repo_files( |
| repo_id=self.repo_id, |
| repo_type="dataset" |
| ) |
| |
| |
| videos = [ |
| f for f in all_files |
| if f.startswith(f"{self.folder}/") |
| and f.lower().endswith('.mp4') |
| ] |
| |
| return videos |
| |
| except Exception as e: |
| logger.error(f"BackgroundHandler: Failed to list files - {e}") |
| return [] |
| |
| def _ensure_folder_exists(self): |
| """Create gameplay_backgrounds folder in HF Dataset with a placeholder file.""" |
| try: |
| from huggingface_hub import HfApi |
| import tempfile |
| |
| hf_token = os.getenv("HF_TOKEN", "") |
| if not hf_token: |
| logger.warning("BackgroundHandler: HF_TOKEN not set, cannot create folder") |
| return |
| |
| api = HfApi(token=hf_token) |
| |
| |
| placeholder_content = """# Gameplay Backgrounds |
| |
| Place your background videos here (.mp4 only). |
| |
| These videos will be: |
| - Randomly selected for text story backgrounds |
| - Slowed down (0.7x) |
| - Darkened for better text visibility |
| - Center-cropped if 16:9 |
| |
| Recommended: 9:16 vertical videos (TikTok/Reels format) |
| """ |
| |
| |
| with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: |
| f.write(placeholder_content) |
| temp_path = f.name |
| |
| api.upload_file( |
| path_or_fileobj=temp_path, |
| path_in_repo=f"{self.folder}/README.md", |
| repo_id=self.repo_id, |
| repo_type="dataset" |
| ) |
| |
| |
| os.remove(temp_path) |
| |
| logger.info(f"BackgroundHandler: Created folder {self.folder}/ in HF Dataset") |
| logger.info(f"BackgroundHandler: Upload .mp4 videos to {self.repo_id}/{self.folder}/") |
| |
| except Exception as e: |
| logger.error(f"BackgroundHandler: Failed to create folder - {e}") |
| |
| def _download_video(self, filename: str) -> Optional[str]: |
| """Download a video from HF Dataset to local cache.""" |
| try: |
| |
| local_name = os.path.basename(filename) |
| cached_path = os.path.join(self.cache_dir, local_name) |
| |
| if os.path.exists(cached_path): |
| logger.info(f"BackgroundHandler: Using cached {local_name}") |
| return cached_path |
| |
| |
| logger.info(f"BackgroundHandler: Downloading {filename}...") |
| |
| downloaded_path = hf_hub_download( |
| repo_id=self.repo_id, |
| filename=filename, |
| repo_type="dataset", |
| local_dir=self.cache_dir, |
| local_dir_use_symlinks=False |
| ) |
| |
| logger.info(f"BackgroundHandler: Downloaded to {downloaded_path}") |
| return downloaded_path |
| |
| except Exception as e: |
| logger.error(f"BackgroundHandler: Download failed - {e}") |
| return None |
| |
| def get_random_video(self) -> Optional[str]: |
| """Get random video from HF Dataset and download it.""" |
| if not self.available_videos: |
| |
| self.available_videos = self._list_available_videos() |
| |
| if not self.available_videos: |
| logger.warning("BackgroundHandler: No videos available") |
| return None |
| |
| selected = random.choice(self.available_videos) |
| logger.info(f"BackgroundHandler: Selected {selected}") |
| |
| return self._download_video(selected) |
| |
| def load_and_process(self, |
| target_duration: float, |
| video_path: str = None) -> Optional[VideoFileClip]: |
| """ |
| Load and process a background video. |
| |
| Args: |
| target_duration: Required duration in seconds |
| video_path: Optional specific video path (or random if None) |
| |
| Returns: |
| Processed VideoFileClip or None |
| """ |
| |
| if video_path is None: |
| video_path = self.get_random_video() |
| |
| if not video_path or not os.path.exists(video_path): |
| logger.warning("BackgroundHandler: No video available, creating solid background") |
| return self._create_solid_background(target_duration) |
| |
| try: |
| |
| clip = VideoFileClip(video_path).without_audio() |
| logger.info(f"BackgroundHandler: Loaded {video_path}, duration: {clip.duration:.1f}s") |
| |
| |
| clip = clip.fx(vfx.speedx, 0.7) |
| |
| |
| clip = self._loop_to_duration(clip, target_duration) |
| |
| |
| clip = self._apply_visual_effects(clip) |
| |
| return clip |
| |
| except Exception as e: |
| logger.error(f"BackgroundHandler: Failed to process video - {e}") |
| return self._create_solid_background(target_duration) |
| |
| def _loop_to_duration(self, clip: VideoFileClip, target_duration: float) -> VideoFileClip: |
| """Loop video to match target duration.""" |
| if clip.duration >= target_duration: |
| return clip.subclip(0, target_duration) |
| |
| loops_needed = int(target_duration / clip.duration) + 1 |
| looped = clip.loop(n=loops_needed) |
| return looped.subclip(0, target_duration) |
| |
| def _apply_visual_effects(self, clip: VideoFileClip) -> VideoFileClip: |
| """ |
| Apply resize, crop (if needed), dark overlay, and saturation reduction. |
| |
| - 9:16 videos: just resize (no crop needed) |
| - 16:9 videos: center crop to 9:16 |
| """ |
| target_w, target_h = 1080, 1920 |
| target_ratio = target_w / target_h |
| |
| |
| clip_ratio = clip.w / clip.h |
| |
| |
| |
| is_vertical = clip_ratio < 0.7 |
| |
| if is_vertical: |
| |
| if clip.w == target_w and clip.h == target_h: |
| |
| logger.info(f"BackgroundHandler: Video is already {target_w}x{target_h}, no resize") |
| else: |
| |
| logger.info(f"BackgroundHandler: Video is vertical ({clip.w}x{clip.h}), resizing to {target_w}x{target_h}") |
| clip = clip.resize(newsize=(target_w, target_h)) |
| else: |
| |
| logger.info(f"BackgroundHandler: Video is horizontal ({clip.w}x{clip.h}), center cropping to 9:16") |
| |
| |
| new_h = target_h |
| new_w = int(clip_ratio * new_h) |
| clip = clip.resize(height=new_h) |
| |
| |
| x_center = new_w // 2 |
| clip = clip.crop(x_center=x_center, width=target_w, height=target_h) |
| |
| |
| clip = clip.fx(vfx.colorx, 0.6) |
| |
| |
| clip = clip.fx(vfx.lum_contrast, lum=-10, contrast=-0.1) |
| |
| return clip |
| |
| def _create_solid_background(self, duration: float) -> VideoFileClip: |
| """Create GREEN SCREEN background for chroma key overlay.""" |
| from moviepy.editor import ColorClip |
| |
| |
| return ColorClip( |
| size=(1080, 1920), |
| color=(0, 255, 0), |
| duration=duration |
| ) |
|
|