Media Toolkit Use this suite for practical audio and video operations that were previously spread across many narrow skills. Included Tools - Audio: , , , , , - Video: , , , , , , Workflow 1. Identify the medium, input format, and target output. 2. Pick the narrowest script that completes the job. 3. Keep transformations explicit: clip times, output codec, target size, caption source, or thumbnail cadence. 4. Verify output duration, dimensions, and bitrate-sensitive settings when the request depends on platform limits. Guardrails - Avoid recompression churn when a simple trim or extract is en…

, ts)\n if match:\n h, m, s = int(match.group(1)), int(match.group(2)), int(match.group(3))\n ms = int(match.group(4) or 0)\n return (h * 3600 + m * 60 + s) * 1000 + ms\n\n # Try MM:SS.ms or MM:SS\n match = re.match(r'^(\\d+):(\\d{2})(?:\\.(\\d+))?

Media Toolkit Use this suite for practical audio and video operations that were previously spread across many narrow skills. Included Tools - Audio: , , , , , - Video: , , , , , , Workflow 1. Identify the medium, input format, and target output. 2. Pick the narrowest script that completes the job. 3. Keep transformations explicit: clip times, output codec, target size, caption source, or thumbnail cadence. 4. Verify output duration, dimensions, and bitrate-sensitive settings when the request depends on platform limits. Guardrails - Avoid recompression churn when a simple trim or extract is en…

, ts)\n if match:\n m, s = int(match.group(1)), int(match.group(2))\n ms = int(match.group(3) or 0)\n return (m * 60 + s) * 1000 + ms\n\n # Try seconds.ms\n match = re.match(r'^(\\d+)(?:\\.(\\d+))?

Media Toolkit Use this suite for practical audio and video operations that were previously spread across many narrow skills. Included Tools - Audio: , , , , , - Video: , , , , , , Workflow 1. Identify the medium, input format, and target output. 2. Pick the narrowest script that completes the job. 3. Keep transformations explicit: clip times, output codec, target size, caption source, or thumbnail cadence. 4. Verify output duration, dimensions, and bitrate-sensitive settings when the request depends on platform limits. Guardrails - Avoid recompression churn when a simple trim or extract is en…

, ts)\n if match:\n s = int(match.group(1))\n ms = int(match.group(2) or 0)\n return s * 1000 + ms\n\n raise ValueError(f\"Invalid timestamp format: {ts}\")\n\n def trim(\n self,\n start: Optional[Union[str, int]] = None,\n end: Optional[Union[str, int]] = None,\n start_ms: Optional[int] = None,\n end_ms: Optional[int] = None\n ) -> 'AudioTrimmer':\n \"\"\"\n Trim audio to segment.\n\n Args:\n start: Start timestamp (HH:MM:SS, MM:SS, or seconds)\n end: End timestamp\n start_ms: Start position in milliseconds\n end_ms: End position in milliseconds\n\n Returns:\n Self for chaining\n \"\"\"\n # Parse timestamps\n if start is not None:\n start_ms = self._parse_timestamp(start)\n if end is not None:\n end_ms = self._parse_timestamp(end)\n\n # Default values\n if start_ms is None:\n start_ms = 0\n if end_ms is None:\n end_ms = len(self._audio)\n\n # Validate\n if start_ms \u003c 0:\n start_ms = 0\n if end_ms > len(self._audio):\n end_ms = len(self._audio)\n if start_ms >= end_ms:\n raise ValueError(\"Start must be before end\")\n\n self._audio = self._audio[start_ms:end_ms]\n return self\n\n def fade_in(self, duration_ms: int) -> 'AudioTrimmer':\n \"\"\"\n Apply fade in effect.\n\n Args:\n duration_ms: Fade duration in milliseconds\n\n Returns:\n Self for chaining\n \"\"\"\n if duration_ms > len(self._audio):\n duration_ms = len(self._audio)\n\n self._audio = self._audio.fade_in(duration_ms)\n return self\n\n def fade_out(self, duration_ms: int) -> 'AudioTrimmer':\n \"\"\"\n Apply fade out effect.\n\n Args:\n duration_ms: Fade duration in milliseconds\n\n Returns:\n Self for chaining\n \"\"\"\n if duration_ms > len(self._audio):\n duration_ms = len(self._audio)\n\n self._audio = self._audio.fade_out(duration_ms)\n return self\n\n def speed(self, factor: float) -> 'AudioTrimmer':\n \"\"\"\n Change playback speed (affects pitch).\n\n Args:\n factor: Speed multiplier (1.5 = 50% faster, 0.5 = half speed)\n\n Returns:\n Self for chaining\n \"\"\"\n if factor \u003c= 0:\n raise ValueError(\"Speed factor must be positive\")\n\n # Change frame rate to adjust speed\n new_frame_rate = int(self._audio.frame_rate * factor)\n self._audio = self._audio._spawn(\n self._audio.raw_data,\n overrides={'frame_rate': new_frame_rate}\n ).set_frame_rate(self._audio.frame_rate)\n\n return self\n\n def reverse(self) -> 'AudioTrimmer':\n \"\"\"\n Reverse the audio.\n\n Returns:\n Self for chaining\n \"\"\"\n self._audio = self._audio.reverse()\n return self\n\n def loop(self, times: int) -> 'AudioTrimmer':\n \"\"\"\n Loop the audio N times.\n\n Args:\n times: Number of times to repeat\n\n Returns:\n Self for chaining\n \"\"\"\n if times \u003c 1:\n raise ValueError(\"Times must be at least 1\")\n\n self._audio = self._audio * times\n return self\n\n def gain(self, db: float) -> 'AudioTrimmer':\n \"\"\"\n Adjust volume by dB.\n\n Args:\n db: Volume change in decibels (positive = louder)\n\n Returns:\n Self for chaining\n \"\"\"\n self._audio = self._audio + db\n return self\n\n def normalize(self, target_dbfs: float = -3.0) -> 'AudioTrimmer':\n \"\"\"\n Normalize audio to target level.\n\n Args:\n target_dbfs: Target level in dBFS\n\n Returns:\n Self for chaining\n \"\"\"\n change_in_dbfs = target_dbfs - self._audio.dBFS\n self._audio = self._audio.apply_gain(change_in_dbfs)\n return self\n\n def add_silence_start(self, duration_ms: int) -> 'AudioTrimmer':\n \"\"\"\n Add silence at the start.\n\n Args:\n duration_ms: Silence duration in milliseconds\n\n Returns:\n Self for chaining\n \"\"\"\n from pydub import AudioSegment\n silence = AudioSegment.silent(\n duration=duration_ms,\n frame_rate=self._audio.frame_rate\n )\n self._audio = silence + self._audio\n return self\n\n def add_silence_end(self, duration_ms: int) -> 'AudioTrimmer':\n \"\"\"\n Add silence at the end.\n\n Args:\n duration_ms: Silence duration in milliseconds\n\n Returns:\n Self for chaining\n \"\"\"\n from pydub import AudioSegment\n silence = AudioSegment.silent(\n duration=duration_ms,\n frame_rate=self._audio.frame_rate\n )\n self._audio = self._audio + silence\n return self\n\n def strip_silence(\n self,\n threshold: float = -50.0,\n chunk_size: int = 10,\n min_silence_len: int = 100\n ) -> 'AudioTrimmer':\n \"\"\"\n Strip leading and trailing silence.\n\n Args:\n threshold: Silence threshold in dBFS\n chunk_size: Analysis chunk size in ms\n min_silence_len: Minimum silence length to strip\n\n Returns:\n Self for chaining\n \"\"\"\n from pydub.silence import detect_leading_silence\n\n # Strip leading silence\n start_trim = detect_leading_silence(self._audio, silence_threshold=threshold, chunk_size=chunk_size)\n\n # Strip trailing silence\n reversed_audio = self._audio.reverse()\n end_trim = detect_leading_silence(reversed_audio, silence_threshold=threshold, chunk_size=chunk_size)\n\n if start_trim + end_trim \u003c len(self._audio):\n self._audio = self._audio[start_trim:len(self._audio) - end_trim]\n\n return self\n\n def overlay(\n self,\n other_file: str,\n position_ms: int = 0,\n volume: float = 0,\n loop: bool = False\n ) -> 'AudioTrimmer':\n \"\"\"\n Overlay another audio file.\n\n Args:\n other_file: Path to audio file to overlay\n position_ms: Position to start overlay\n volume: Volume adjustment for overlay (dB)\n loop: Loop overlay to fill duration\n\n Returns:\n Self for chaining\n \"\"\"\n from pydub import AudioSegment\n\n other = AudioSegment.from_file(other_file)\n\n # Adjust volume\n if volume != 0:\n other = other + volume\n\n # Loop if needed\n if loop:\n remaining = len(self._audio) - position_ms\n if len(other) \u003c remaining:\n repetitions = (remaining // len(other)) + 1\n other = other * repetitions\n other = other[:remaining]\n\n self._audio = self._audio.overlay(other, position=position_ms)\n return self\n\n def get_duration_ms(self) -> int:\n \"\"\"Get current audio duration in milliseconds.\"\"\"\n return len(self._audio)\n\n def get_duration_str(self) -> str:\n \"\"\"Get current audio duration as formatted string.\"\"\"\n total_seconds = len(self._audio) / 1000\n hours = int(total_seconds // 3600)\n minutes = int((total_seconds % 3600) // 60)\n seconds = total_seconds % 60\n\n if hours > 0:\n return f\"{hours}:{minutes:02d}:{seconds:05.2f}\"\n else:\n return f\"{minutes}:{seconds:05.2f}\"\n\n def save(\n self,\n output: str,\n format: Optional[str] = None,\n bitrate: int = 192\n ) -> str:\n \"\"\"\n Save audio to file.\n\n Args:\n output: Output file path\n format: Output format (optional, from extension)\n bitrate: Bitrate for lossy formats (kbps)\n\n Returns:\n Path to saved file\n \"\"\"\n output_path = Path(output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n\n if format is None:\n format = output_path.suffix.lstrip('.').lower()\n\n # Export parameters\n params = {}\n if format in ('mp3', 'ogg', 'm4a'):\n params['bitrate'] = f\"{bitrate}k\"\n\n self._audio.export(str(output_path), format=format, **params)\n return str(output_path)\n\n @classmethod\n def concatenate(\n cls,\n files: List[str],\n output: str,\n format: Optional[str] = None,\n bitrate: int = 192\n ) -> str:\n \"\"\"\n Concatenate multiple audio files.\n\n Args:\n files: List of input file paths\n output: Output file path\n format: Output format\n bitrate: Bitrate for lossy formats\n\n Returns:\n Path to saved file\n \"\"\"\n from pydub import AudioSegment\n\n if not files:\n raise ValueError(\"No files to concatenate\")\n\n combined = AudioSegment.empty()\n for filepath in files:\n segment = AudioSegment.from_file(filepath)\n combined += segment\n\n output_path = Path(output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n\n if format is None:\n format = output_path.suffix.lstrip('.').lower()\n\n params = {}\n if format in ('mp3', 'ogg', 'm4a'):\n params['bitrate'] = f\"{bitrate}k\"\n\n combined.export(str(output_path), format=format, **params)\n return str(output_path)\n\n @classmethod\n def concatenate_with_crossfade(\n cls,\n files: List[str],\n output: str,\n crossfade_ms: int = 1000,\n format: Optional[str] = None,\n bitrate: int = 192\n ) -> str:\n \"\"\"\n Concatenate files with crossfade transitions.\n\n Args:\n files: List of input file paths\n output: Output file path\n crossfade_ms: Crossfade duration in milliseconds\n format: Output format\n bitrate: Bitrate for lossy formats\n\n Returns:\n Path to saved file\n \"\"\"\n from pydub import AudioSegment\n\n if not files:\n raise ValueError(\"No files to concatenate\")\n\n combined = AudioSegment.from_file(files[0])\n\n for filepath in files[1:]:\n segment = AudioSegment.from_file(filepath)\n combined = combined.append(segment, crossfade=crossfade_ms)\n\n output_path = Path(output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n\n if format is None:\n format = output_path.suffix.lstrip('.').lower()\n\n params = {}\n if format in ('mp3', 'ogg', 'm4a'):\n params['bitrate'] = f\"{bitrate}k\"\n\n combined.export(str(output_path), format=format, **params)\n return str(output_path)\n\n\ndef main():\n \"\"\"CLI entry point.\"\"\"\n parser = argparse.ArgumentParser(\n description='Cut, trim, and edit audio segments',\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n %(prog)s --input podcast.mp3 --output segment.mp3 --start 05:30 --end 10:00\n %(prog)s --input song.mp3 --output faded.mp3 --fade-in 3000 --fade-out 5000\n %(prog)s --input lecture.mp3 --output fast.mp3 --speed 1.5\n %(prog)s --concat file1.mp3 file2.mp3 file3.mp3 --output merged.mp3\n \"\"\"\n )\n\n parser.add_argument('--input', '-i', help='Input audio file')\n parser.add_argument('--output', '-o', required=True, help='Output file path')\n parser.add_argument('--start', '-s', help='Start timestamp (HH:MM:SS or MM:SS)')\n parser.add_argument('--end', '-e', help='End timestamp')\n parser.add_argument('--fade-in', type=int, help='Fade in duration (ms)')\n parser.add_argument('--fade-out', type=int, help='Fade out duration (ms)')\n parser.add_argument('--speed', type=float, default=1.0, help='Speed multiplier')\n parser.add_argument('--gain', type=float, default=0, help='Volume adjustment (dB)')\n parser.add_argument('--normalize', type=float, help='Normalize to dBFS level')\n parser.add_argument('--reverse', action='store_true', help='Reverse audio')\n parser.add_argument('--loop', type=int, help='Loop N times')\n parser.add_argument('--concat', nargs='+', help='Files to concatenate')\n parser.add_argument('--crossfade', type=int, default=0, help='Crossfade duration (ms)')\n parser.add_argument('--bitrate', type=int, default=192, help='Output bitrate (kbps)')\n parser.add_argument('--segments', help='Multiple segments: \"00:00-05:00,10:00-15:00\"')\n parser.add_argument('--output-dir', help='Output directory for multiple segments')\n\n args = parser.parse_args()\n\n # Concatenation mode\n if args.concat:\n if args.crossfade > 0:\n output = AudioTrimmer.concatenate_with_crossfade(\n args.concat, args.output,\n crossfade_ms=args.crossfade,\n bitrate=args.bitrate\n )\n else:\n output = AudioTrimmer.concatenate(\n args.concat, args.output,\n bitrate=args.bitrate\n )\n print(f\"Concatenated {len(args.concat)} files -> {output}\")\n return\n\n # Single file mode\n if not args.input:\n parser.error(\"--input is required (unless using --concat)\")\n\n # Multiple segments mode\n if args.segments:\n if not args.output_dir:\n parser.error(\"--output-dir is required when using --segments\")\n\n Path(args.output_dir).mkdir(parents=True, exist_ok=True)\n segment_pairs = args.segments.split(',')\n\n for i, segment in enumerate(segment_pairs):\n start, end = segment.strip().split('-')\n trimmer = AudioTrimmer(args.input)\n trimmer.trim(start=start.strip(), end=end.strip())\n\n if args.fade_in:\n trimmer.fade_in(args.fade_in)\n if args.fade_out:\n trimmer.fade_out(args.fade_out)\n\n output_file = Path(args.output_dir) / f\"segment_{i+1:02d}.mp3\"\n trimmer.save(str(output_file), bitrate=args.bitrate)\n print(f\"Segment {i+1}: {start.strip()} - {end.strip()} -> {output_file}\")\n\n return\n\n # Standard trimming mode\n trimmer = AudioTrimmer(args.input)\n\n # Apply operations\n if args.start or args.end:\n trimmer.trim(start=args.start, end=args.end)\n\n if args.speed != 1.0:\n trimmer.speed(args.speed)\n\n if args.reverse:\n trimmer.reverse()\n\n if args.loop:\n trimmer.loop(args.loop)\n\n if args.gain != 0:\n trimmer.gain(args.gain)\n\n if args.normalize is not None:\n trimmer.normalize(args.normalize)\n\n if args.fade_in:\n trimmer.fade_in(args.fade_in)\n\n if args.fade_out:\n trimmer.fade_out(args.fade_out)\n\n # Save\n output = trimmer.save(args.output, bitrate=args.bitrate)\n print(f\"Saved: {output} (duration: {trimmer.get_duration_str()})\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":17249,"content_sha256":"a4f9315ab68af21978cd3884508842709ae105849ca14e0a21f1b1d8ca76ee86"},{"filename":"scripts/gif_workshop.py","content":"#!/usr/bin/env python3\n\"\"\"\nVideo to GIF Workshop - Convert videos to optimized GIFs\nFeatures: clipping, speed control, text overlays, and smart optimization.\n\"\"\"\n\nimport io\nimport os\nimport re\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Any, Callable, Dict, List, Optional, Tuple, Union\n\nimport numpy as np\nfrom PIL import Image, ImageDraw, ImageFont\n\ntry:\n from moviepy.editor import (\n ColorClip,\n CompositeVideoClip,\n TextClip,\n VideoFileClip,\n concatenate_videoclips,\n vfx,\n )\n HAS_MOVIEPY = True\nexcept ImportError:\n HAS_MOVIEPY = False\n\ntry:\n import imageio\n HAS_IMAGEIO = True\nexcept ImportError:\n HAS_IMAGEIO = False\n\n\nclass GifError(Exception):\n \"\"\"Custom exception for GIF processing errors.\"\"\"\n pass\n\n\n@dataclass\nclass GifConfig:\n \"\"\"Configuration for GIF generation.\"\"\"\n default_fps: int = 15\n default_width: int = 480\n default_colors: int = 256\n max_duration: float = 30.0\n default_loop: int = 0 # 0 = infinite\n\n\n# Presets for common use cases\nPRESETS = {\n 'twitter': {'width': 512, 'fps': 15, 'max_size_kb': 5000, 'colors': 256},\n 'discord': {'width': 256, 'fps': 15, 'max_size_kb': 8000, 'colors': 256},\n 'slack': {'width': 480, 'fps': 15, 'max_size_kb': 5000, 'colors': 256},\n 'reddit': {'width': 720, 'fps': 20, 'colors': 256},\n 'high': {'width': 640, 'fps': 20, 'colors': 256},\n 'medium': {'width': 480, 'fps': 15, 'colors': 256},\n 'low': {'width': 320, 'fps': 12, 'colors': 128},\n 'thumbnail': {'width': 200, 'fps': 10, 'colors': 64, 'max_duration': 3},\n 'reaction': {'width': 256, 'fps': 10, 'colors': 64, 'max_duration': 5},\n}\n\n# Text position mappings\nTEXT_POSITIONS = {\n 'top': ('center', 'top'),\n 'bottom': ('center', 'bottom'),\n 'center': ('center', 'center'),\n 'top-left': ('left', 'top'),\n 'top-right': ('right', 'top'),\n 'bottom-left': ('left', 'bottom'),\n 'bottom-right': ('right', 'bottom'),\n}\n\n\nclass GifWorkshop:\n \"\"\"\n Main class for video to GIF conversion.\n\n Supports chaining operations:\n GifWorkshop(\"video.mp4\").clip(0, 10).resize(480).to_gif(\"out.gif\")\n \"\"\"\n\n def __init__(\n self,\n source: Union[str, Path],\n fps: Optional[int] = None,\n width: Optional[int] = None\n ):\n \"\"\"\n Initialize GIF Workshop.\n\n Args:\n source: Path to video file\n fps: Target FPS (default: 15)\n width: Target width (default: original)\n \"\"\"\n if not HAS_MOVIEPY:\n raise ImportError(\"moviepy is required. Install with: pip install moviepy\")\n\n self.config = GifConfig()\n self._source_path = Path(source)\n\n if not self._source_path.exists():\n raise FileNotFoundError(f\"Video not found: {source}\")\n\n # Load video\n self._clip = VideoFileClip(str(self._source_path))\n self._original_duration = self._clip.duration\n self._original_size = self._clip.size\n\n # Settings\n self._fps = fps or self.config.default_fps\n self._width = width\n self._colors = self.config.default_colors\n self._loop = self.config.default_loop\n self._max_size_kb: Optional[int] = None\n self._text_overlays: List[Dict] = []\n self._operations: List[str] = []\n\n def __del__(self):\n \"\"\"Clean up video clip.\"\"\"\n if hasattr(self, '_clip') and self._clip:\n self._clip.close()\n\n def get_info(self) -> Dict[str, Any]:\n \"\"\"Get video information.\"\"\"\n return {\n 'source': str(self._source_path),\n 'duration': self._clip.duration,\n 'width': self._clip.size[0],\n 'height': self._clip.size[1],\n 'fps': self._clip.fps,\n 'frame_count': int(self._clip.duration * self._clip.fps),\n }\n\n def clip(\n self,\n start: Optional[Union[float, str]] = None,\n end: Optional[Union[float, str]] = None\n ) -> 'GifWorkshop':\n \"\"\"\n Select time range from video.\n\n Args:\n start: Start time (seconds or \"MM:SS\" format)\n end: End time (seconds or \"MM:SS\" format)\n \"\"\"\n start_sec = self._parse_time(start) if start is not None else 0\n end_sec = self._parse_time(end) if end is not None else self._clip.duration\n\n # Validate\n if start_sec \u003c 0:\n start_sec = 0\n if end_sec > self._clip.duration:\n end_sec = self._clip.duration\n if start_sec >= end_sec:\n raise GifError(f\"Invalid time range: {start_sec} to {end_sec}\")\n\n self._clip = self._clip.subclip(start_sec, end_sec)\n self._operations.append(f\"clip({start_sec:.1f}s-{end_sec:.1f}s)\")\n return self\n\n def clip_multi(self, ranges: List[Tuple[float, float]]) -> 'GifWorkshop':\n \"\"\"Clip and concatenate multiple time ranges.\"\"\"\n clips = []\n for start, end in ranges:\n clip = self._clip.subclip(start, end)\n clips.append(clip)\n\n self._clip = concatenate_videoclips(clips)\n self._operations.append(f\"clip_multi({len(ranges)} clips)\")\n return self\n\n def _parse_time(self, time_val: Union[float, str]) -> float:\n \"\"\"Parse time value to seconds.\"\"\"\n if isinstance(time_val, (int, float)):\n return float(time_val)\n\n # Parse MM:SS or HH:MM:SS format\n parts = str(time_val).split(':')\n if len(parts) == 2:\n return float(parts[0]) * 60 + float(parts[1])\n elif len(parts) == 3:\n return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])\n else:\n return float(time_val)\n\n def speed(self, factor: float) -> 'GifWorkshop':\n \"\"\"\n Adjust playback speed.\n\n Args:\n factor: Speed multiplier (2.0 = 2x faster, 0.5 = half speed)\n \"\"\"\n if factor \u003c= 0:\n raise GifError(\"Speed factor must be positive\")\n\n self._clip = self._clip.fx(vfx.speedx, factor)\n self._operations.append(f\"speed({factor}x)\")\n return self\n\n def reverse(self) -> 'GifWorkshop':\n \"\"\"Reverse the clip.\"\"\"\n self._clip = self._clip.fx(vfx.time_mirror)\n self._operations.append(\"reverse()\")\n return self\n\n def boomerang(self) -> 'GifWorkshop':\n \"\"\"Create boomerang effect (forward then reverse).\"\"\"\n reversed_clip = self._clip.fx(vfx.time_mirror)\n self._clip = concatenate_videoclips([self._clip, reversed_clip])\n self._operations.append(\"boomerang()\")\n return self\n\n def resize(\n self,\n width: Optional[int] = None,\n height: Optional[int] = None\n ) -> 'GifWorkshop':\n \"\"\"\n Resize the video.\n\n Args:\n width: Target width (maintains aspect if height not specified)\n height: Target height (maintains aspect if width not specified)\n \"\"\"\n if width and height:\n self._clip = self._clip.resize((width, height))\n elif width:\n self._clip = self._clip.resize(width=width)\n self._width = width\n elif height:\n self._clip = self._clip.resize(height=height)\n\n self._operations.append(f\"resize({width}x{height})\")\n return self\n\n def crop(\n self,\n x: int = 0,\n y: int = 0,\n width: Optional[int] = None,\n height: Optional[int] = None\n ) -> 'GifWorkshop':\n \"\"\"\n Crop video to region.\n\n Args:\n x: Left position\n y: Top position\n width: Crop width\n height: Crop height\n \"\"\"\n w = width or (self._clip.size[0] - x)\n h = height or (self._clip.size[1] - y)\n\n self._clip = self._clip.crop(x1=x, y1=y, x2=x + w, y2=y + h)\n self._operations.append(f\"crop({x},{y},{w},{h})\")\n return self\n\n def crop_to_aspect(self, aspect_w: int, aspect_h: int) -> 'GifWorkshop':\n \"\"\"\n Crop to specific aspect ratio.\n\n Args:\n aspect_w: Width ratio (e.g., 16 for 16:9)\n aspect_h: Height ratio (e.g., 9 for 16:9)\n \"\"\"\n current_w, current_h = self._clip.size\n current_aspect = current_w / current_h\n target_aspect = aspect_w / aspect_h\n\n if current_aspect > target_aspect:\n # Too wide, crop sides\n new_w = int(current_h * target_aspect)\n x = (current_w - new_w) // 2\n self._clip = self._clip.crop(x1=x, x2=x + new_w)\n else:\n # Too tall, crop top/bottom\n new_h = int(current_w / target_aspect)\n y = (current_h - new_h) // 2\n self._clip = self._clip.crop(y1=y, y2=y + new_h)\n\n self._operations.append(f\"crop_to_aspect({aspect_w}:{aspect_h})\")\n return self\n\n def set_fps(self, fps: int) -> 'GifWorkshop':\n \"\"\"Set output FPS.\"\"\"\n self._fps = fps\n self._operations.append(f\"set_fps({fps})\")\n return self\n\n def add_text(\n self,\n text: str,\n position: str = 'bottom',\n fontsize: int = 24,\n color: str = 'white',\n font: str = 'Arial',\n stroke_color: Optional[str] = 'black',\n stroke_width: int = 1,\n start_time: Optional[float] = None,\n end_time: Optional[float] = None,\n bg_color: Optional[str] = None\n ) -> 'GifWorkshop':\n \"\"\"\n Add text overlay.\n\n Args:\n text: Text to display\n position: Position on screen\n fontsize: Font size\n color: Text color\n font: Font name\n stroke_color: Outline color (None for no outline)\n stroke_width: Outline width\n start_time: When to start showing (None = start)\n end_time: When to stop showing (None = end)\n bg_color: Background color (None for transparent)\n \"\"\"\n self._text_overlays.append({\n 'text': text,\n 'position': position,\n 'fontsize': fontsize,\n 'color': color,\n 'font': font,\n 'stroke_color': stroke_color,\n 'stroke_width': stroke_width,\n 'start_time': start_time or 0,\n 'end_time': end_time or self._clip.duration,\n 'bg_color': bg_color,\n })\n self._operations.append(f\"add_text('{text[:20]}...')\")\n return self\n\n def add_caption_bar(\n self,\n text: str,\n position: str = 'bottom',\n background: str = 'black',\n padding: int = 10,\n fontsize: int = 20,\n color: str = 'white'\n ) -> 'GifWorkshop':\n \"\"\"Add caption with background bar.\"\"\"\n return self.add_text(\n text,\n position=position,\n fontsize=fontsize,\n color=color,\n bg_color=background\n )\n\n def _apply_text_overlays(self) -> None:\n \"\"\"Apply all text overlays to clip.\"\"\"\n if not self._text_overlays:\n return\n\n for overlay in self._text_overlays:\n try:\n txt_clip = TextClip(\n overlay['text'],\n fontsize=overlay['fontsize'],\n color=overlay['color'],\n font=overlay['font'],\n stroke_color=overlay['stroke_color'],\n stroke_width=overlay['stroke_width']\n )\n\n # Position\n pos = overlay['position']\n if pos in TEXT_POSITIONS:\n txt_clip = txt_clip.set_position(TEXT_POSITIONS[pos])\n else:\n txt_clip = txt_clip.set_position(('center', 'bottom'))\n\n # Timing\n txt_clip = txt_clip.set_start(overlay['start_time'])\n txt_clip = txt_clip.set_duration(overlay['end_time'] - overlay['start_time'])\n\n self._clip = CompositeVideoClip([self._clip, txt_clip])\n\n except Exception as e:\n # Text rendering can fail, continue without\n print(f\"Warning: Could not add text overlay: {e}\")\n\n def filter(self, filter_name: str) -> 'GifWorkshop':\n \"\"\"\n Apply color filter.\n\n Available: grayscale, sepia, invert, mirror\n \"\"\"\n if filter_name == 'grayscale':\n self._clip = self._clip.fx(vfx.blackwhite)\n elif filter_name == 'invert':\n self._clip = self._clip.fx(vfx.invert_colors)\n elif filter_name == 'mirror':\n self._clip = self._clip.fx(vfx.mirror_x)\n elif filter_name == 'sepia':\n # Apply sepia via color matrix\n def sepia_filter(get_frame, t):\n frame = get_frame(t)\n sepia_matrix = np.array([\n [0.393, 0.769, 0.189],\n [0.349, 0.686, 0.168],\n [0.272, 0.534, 0.131]\n ])\n sepia_frame = frame @ sepia_matrix.T\n return np.clip(sepia_frame, 0, 255).astype(np.uint8)\n self._clip = self._clip.fl(sepia_filter)\n\n self._operations.append(f\"filter({filter_name})\")\n return self\n\n def adjust(\n self,\n brightness: float = 0,\n contrast: float = 0\n ) -> 'GifWorkshop':\n \"\"\"\n Adjust brightness and contrast.\n\n Args:\n brightness: -1.0 to 1.0\n contrast: -1.0 to 1.0\n \"\"\"\n if brightness:\n factor = 1 + brightness\n self._clip = self._clip.fx(vfx.colorx, factor)\n\n if contrast:\n self._clip = self._clip.fx(vfx.lum_contrast, contrast=int(contrast * 100))\n\n self._operations.append(f\"adjust(b={brightness}, c={contrast})\")\n return self\n\n def fade_in(self, duration: float = 0.5) -> 'GifWorkshop':\n \"\"\"Add fade in effect.\"\"\"\n self._clip = self._clip.fx(vfx.fadein, duration)\n self._operations.append(f\"fade_in({duration}s)\")\n return self\n\n def fade_out(self, duration: float = 0.5) -> 'GifWorkshop':\n \"\"\"Add fade out effect.\"\"\"\n self._clip = self._clip.fx(vfx.fadeout, duration)\n self._operations.append(f\"fade_out({duration}s)\")\n return self\n\n def blur(self, intensity: float = 2) -> 'GifWorkshop':\n \"\"\"Apply blur effect (requires OpenCV).\"\"\"\n try:\n import cv2\n\n def blur_filter(get_frame, t):\n frame = get_frame(t)\n return cv2.GaussianBlur(frame, (0, 0), intensity)\n\n self._clip = self._clip.fl(blur_filter)\n self._operations.append(f\"blur({intensity})\")\n except ImportError:\n print(\"Warning: OpenCV required for blur effect\")\n\n return self\n\n def optimize(\n self,\n max_size_kb: Optional[int] = None,\n quality: str = 'medium',\n colors: Optional[int] = None,\n lossy: Optional[int] = None\n ) -> 'GifWorkshop':\n \"\"\"\n Configure optimization settings.\n\n Args:\n max_size_kb: Target maximum file size\n quality: Preset quality ('low', 'medium', 'high')\n colors: Color palette size (2-256)\n lossy: Lossy compression level (0-100)\n \"\"\"\n self._max_size_kb = max_size_kb\n\n if quality == 'low':\n self._fps = min(self._fps, 10)\n self._colors = 64\n elif quality == 'high':\n self._colors = 256\n else: # medium\n self._colors = 128\n\n if colors:\n self._colors = min(256, max(2, colors))\n\n self._operations.append(f\"optimize(max={max_size_kb}kb)\")\n return self\n\n def preset(self, preset_name: str) -> 'GifWorkshop':\n \"\"\"Apply a named preset.\"\"\"\n if preset_name not in PRESETS:\n raise GifError(f\"Unknown preset: {preset_name}\")\n\n settings = PRESETS[preset_name]\n\n if 'width' in settings:\n self.resize(width=settings['width'])\n if 'fps' in settings:\n self._fps = settings['fps']\n if 'colors' in settings:\n self._colors = settings['colors']\n if 'max_size_kb' in settings:\n self._max_size_kb = settings['max_size_kb']\n if 'max_duration' in settings and self._clip.duration > settings['max_duration']:\n self.clip(end=settings['max_duration'])\n\n self._operations.append(f\"preset({preset_name})\")\n return self\n\n def apply_filter(self, filter_func: Callable) -> 'GifWorkshop':\n \"\"\"\n Apply custom filter function to each frame.\n\n Args:\n filter_func: Function that takes PIL Image and returns PIL Image\n \"\"\"\n def frame_filter(get_frame, t):\n frame = get_frame(t)\n pil_img = Image.fromarray(frame)\n filtered = filter_func(pil_img)\n return np.array(filtered)\n\n self._clip = self._clip.fl(frame_filter)\n self._operations.append(\"apply_filter(custom)\")\n return self\n\n def get_frame_at(self, time: float) -> Image.Image:\n \"\"\"Get frame at specific time as PIL Image.\"\"\"\n frame = self._clip.get_frame(time)\n return Image.fromarray(frame)\n\n def get_best_frame(self) -> Image.Image:\n \"\"\"Get the best frame (middle of clip) as thumbnail.\"\"\"\n middle = self._clip.duration / 2\n return self.get_frame_at(middle)\n\n def export_frames(\n self,\n output_dir: Union[str, Path],\n format: str = 'png',\n every_n: int = 1\n ) -> List[str]:\n \"\"\"\n Export frames as images.\n\n Args:\n output_dir: Output directory\n format: Image format ('png', 'jpg')\n every_n: Export every Nth frame\n \"\"\"\n output_dir = Path(output_dir)\n output_dir.mkdir(parents=True, exist_ok=True)\n\n exported = []\n fps = self._clip.fps\n total_frames = int(self._clip.duration * fps)\n\n for i in range(0, total_frames, every_n):\n t = i / fps\n frame = self.get_frame_at(t)\n filepath = output_dir / f\"frame_{i:05d}.{format}\"\n frame.save(filepath)\n exported.append(str(filepath))\n\n return exported\n\n def to_gif(\n self,\n output_path: Union[str, Path],\n optimize: bool = True,\n colors: Optional[int] = None,\n loop: Optional[int] = None\n ) -> str:\n \"\"\"\n Export as GIF.\n\n Args:\n output_path: Output file path\n optimize: Enable optimization\n colors: Color palette size\n loop: Loop count (0 = infinite)\n\n Returns:\n Path to created GIF\n \"\"\"\n output_path = Path(output_path)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n\n # Apply text overlays\n self._apply_text_overlays()\n\n # Determine colors\n n_colors = colors or self._colors\n loop_count = loop if loop is not None else self._loop\n\n # Generate GIF\n self._clip.write_gif(\n str(output_path),\n fps=self._fps,\n colors=n_colors,\n opt='nq', # Neural quantization\n loop=loop_count\n )\n\n # Check file size and re-optimize if needed\n if self._max_size_kb:\n current_size = output_path.stat().st_size / 1024\n if current_size > self._max_size_kb:\n self._optimize_file_size(output_path)\n\n return str(output_path)\n\n def _optimize_file_size(self, filepath: Path) -> None:\n \"\"\"Try to reduce file size to meet target.\"\"\"\n if not self._max_size_kb:\n return\n\n current_size = filepath.stat().st_size / 1024\n\n # Try reducing colors\n colors = self._colors\n fps = self._fps\n\n while current_size > self._max_size_kb and (colors > 16 or fps > 5):\n if colors > 16:\n colors = max(16, colors // 2)\n else:\n fps = max(5, fps - 2)\n\n self._clip.write_gif(\n str(filepath),\n fps=fps,\n colors=colors,\n opt='nq',\n loop=self._loop\n )\n current_size = filepath.stat().st_size / 1024\n\n def to_video(\n self,\n output_path: Union[str, Path],\n codec: str = 'libx264'\n ) -> str:\n \"\"\"Export as video file (for comparison).\"\"\"\n output_path = Path(output_path)\n self._clip.write_videofile(\n str(output_path),\n codec=codec,\n fps=self._fps\n )\n return str(output_path)\n\n def __repr__(self) -> str:\n return f\"GifWorkshop('{self._source_path.name}', {self._clip.duration:.1f}s, {self._clip.size})\"\n\n\n# ==================== CLI ====================\n\nif __name__ == \"__main__\":\n import argparse\n\n parser = argparse.ArgumentParser(description='Video to GIF Workshop')\n parser.add_argument('input', help='Input video file')\n parser.add_argument('-o', '--output', required=True, help='Output GIF path')\n parser.add_argument('--start', type=float, help='Start time (seconds)')\n parser.add_argument('--end', type=float, help='End time (seconds)')\n parser.add_argument('--width', type=int, help='Output width')\n parser.add_argument('--fps', type=int, default=15, help='Frames per second')\n parser.add_argument('--speed', type=float, default=1.0, help='Speed multiplier')\n parser.add_argument('--max-size', type=int, help='Max file size in KB')\n parser.add_argument('--text', help='Text overlay')\n parser.add_argument('--text-position', default='bottom', help='Text position')\n parser.add_argument('--preset', help='Apply preset')\n parser.add_argument('--reverse', action='store_true', help='Reverse clip')\n parser.add_argument('--boomerang', action='store_true', help='Boomerang effect')\n\n args = parser.parse_args()\n\n # Create workshop\n workshop = GifWorkshop(args.input, fps=args.fps)\n\n # Apply options\n if args.start is not None or args.end is not None:\n workshop.clip(start=args.start, end=args.end)\n\n if args.width:\n workshop.resize(width=args.width)\n\n if args.speed != 1.0:\n workshop.speed(args.speed)\n\n if args.reverse:\n workshop.reverse()\n\n if args.boomerang:\n workshop.boomerang()\n\n if args.text:\n workshop.add_text(args.text, position=args.text_position)\n\n if args.preset:\n workshop.preset(args.preset)\n\n if args.max_size:\n workshop.optimize(max_size_kb=args.max_size)\n\n # Export\n output = workshop.to_gif(args.output)\n print(f\"Created: {output}\")\n\n # Show file size\n size_kb = Path(output).stat().st_size / 1024\n print(f\"Size: {size_kb:.1f} KB\")\n","content_type":"text/x-python; charset=utf-8","language":"python","size":22805,"content_sha256":"87f0dff44d484f3691b33e893410f8cacbb51a4a7317baafc5cc4817446276bc"},{"filename":"scripts/podcast_splitter.py","content":"#!/usr/bin/env python3\n\"\"\"\nPodcast Splitter - Split audio files by detecting silence.\n\nFeatures:\n- Silence detection with configurable threshold\n- Auto-split into segments/chapters\n- Silence removal and shortening\n- Batch processing\n\"\"\"\n\nimport argparse\nfrom pathlib import Path\nfrom typing import List, Tuple, Dict, Optional\n\n\nclass PodcastSplitter:\n \"\"\"Split audio files based on silence detection.\"\"\"\n\n def __init__(\n self,\n filepath: str,\n silence_thresh: float = -40,\n min_silence_len: int = 1000,\n keep_silence: int = 300\n ):\n \"\"\"\n Initialize splitter with audio file.\n\n Args:\n filepath: Path to input audio file\n silence_thresh: Silence threshold in dBFS (default -40)\n min_silence_len: Minimum silence length to detect in ms (default 1000)\n keep_silence: Silence to keep at segment edges in ms (default 300)\n \"\"\"\n self.filepath = Path(filepath)\n if not self.filepath.exists():\n raise FileNotFoundError(f\"Audio file not found: {filepath}\")\n\n self.silence_thresh = silence_thresh\n self.min_silence_len = min_silence_len\n self.keep_silence = keep_silence\n\n self._audio = None\n self._segments: List[Dict] = []\n self._silences: List[Tuple[int, int]] = []\n\n self._load_audio()\n\n def _load_audio(self):\n \"\"\"Load audio file using pydub.\"\"\"\n try:\n from pydub import AudioSegment\n self._audio = AudioSegment.from_file(str(self.filepath))\n except ImportError:\n raise ImportError(\"pydub is required. Install with: pip install pydub\")\n except Exception as e:\n raise ValueError(f\"Failed to load audio file: {e}\")\n\n def detect_silence(self) -> List[Tuple[int, int]]:\n \"\"\"\n Detect silence regions in the audio.\n\n Returns:\n List of (start_ms, end_ms) tuples for each silence region\n \"\"\"\n from pydub.silence import detect_silence\n\n self._silences = detect_silence(\n self._audio,\n min_silence_len=self.min_silence_len,\n silence_thresh=self.silence_thresh\n )\n\n return self._silences\n\n def print_silence_report(self):\n \"\"\"Print a report of detected silences.\"\"\"\n if not self._silences:\n self.detect_silence()\n\n total_duration = len(self._audio)\n total_silence = sum(end - start for start, end in self._silences)\n content_duration = total_duration - total_silence\n\n print(f\"\\nSilence Detection Report: {self.filepath.name}\")\n print(\"=\" * 50)\n print(f\"Total Duration: {self._format_time(total_duration)}\")\n print(f\"Content Duration: {self._format_time(content_duration)}\")\n print(f\"Total Silence: {self._format_time(total_silence)}\")\n print(f\"Silence Regions: {len(self._silences)}\")\n print(f\"\\nSettings:\")\n print(f\" Threshold: {self.silence_thresh} dBFS\")\n print(f\" Min Length: {self.min_silence_len} ms\")\n\n if self._silences:\n print(f\"\\nDetected Silences:\")\n for i, (start, end) in enumerate(self._silences[:20]): # Show first 20\n duration = end - start\n print(f\" {i+1:3d}. {self._format_time(start)} - {self._format_time(end)} ({duration}ms)\")\n\n if len(self._silences) > 20:\n print(f\" ... and {len(self._silences) - 20} more\")\n\n @staticmethod\n def _format_time(ms: int) -> str:\n \"\"\"Format milliseconds as MM:SS.\"\"\"\n seconds = ms / 1000\n minutes = int(seconds // 60)\n secs = seconds % 60\n return f\"{minutes:02d}:{secs:05.2f}\"\n\n def split_by_silence(\n self,\n min_silence_len: Optional[int] = None,\n max_segments: Optional[int] = None\n ) -> List[Dict]:\n \"\"\"\n Split audio at silence regions.\n\n Args:\n min_silence_len: Override minimum silence length for splitting\n max_segments: Maximum number of segments to create\n\n Returns:\n List of segment info dicts with start, end, duration\n \"\"\"\n from pydub.silence import split_on_silence\n\n # Use overrides if provided\n silence_len = min_silence_len or self.min_silence_len\n\n # Perform split\n chunks = split_on_silence(\n self._audio,\n min_silence_len=silence_len,\n silence_thresh=self.silence_thresh,\n keep_silence=self.keep_silence\n )\n\n # Build segment info\n self._segments = []\n position = 0\n\n for i, chunk in enumerate(chunks):\n if max_segments and i >= max_segments:\n # Merge remaining chunks\n remaining = chunks[i:]\n merged = sum(remaining[1:], remaining[0])\n self._segments.append({\n 'index': i,\n 'start': position,\n 'end': position + len(merged),\n 'duration': len(merged),\n 'audio': merged\n })\n break\n\n self._segments.append({\n 'index': i,\n 'start': position,\n 'end': position + len(chunk),\n 'duration': len(chunk),\n 'audio': chunk\n })\n position += len(chunk)\n\n return [\n {'index': s['index'], 'start': s['start'], 'end': s['end'], 'duration': s['duration']}\n for s in self._segments\n ]\n\n def remove_silence(self, min_length: int = 2000) -> 'PodcastSplitter':\n \"\"\"\n Remove silences longer than specified length.\n\n Args:\n min_length: Remove silences longer than this (ms)\n\n Returns:\n Self for chaining\n \"\"\"\n from pydub import AudioSegment\n\n # Detect silences\n if not self._silences:\n self.detect_silence()\n\n # Build new audio without long silences\n result = AudioSegment.empty()\n last_end = 0\n\n for start, end in self._silences:\n duration = end - start\n\n # Add content before this silence\n result += self._audio[last_end:start]\n\n # If silence is short enough, keep it\n if duration \u003c min_length:\n result += self._audio[start:end]\n\n last_end = end\n\n # Add remaining content\n result += self._audio[last_end:]\n\n self._audio = result\n self._silences = [] # Reset for re-detection\n\n return self\n\n def shorten_silence(self, max_length: int = 500) -> 'PodcastSplitter':\n \"\"\"\n Shorten all silences to maximum length.\n\n Args:\n max_length: Maximum silence length (ms)\n\n Returns:\n Self for chaining\n \"\"\"\n from pydub import AudioSegment\n\n # Detect silences\n if not self._silences:\n self.detect_silence()\n\n # Build new audio with shortened silences\n result = AudioSegment.empty()\n last_end = 0\n\n for start, end in self._silences:\n duration = end - start\n\n # Add content before this silence\n result += self._audio[last_end:start]\n\n # Add shortened silence\n silence_to_keep = min(duration, max_length)\n result += self._audio[start:start + silence_to_keep]\n\n last_end = end\n\n # Add remaining content\n result += self._audio[last_end:]\n\n self._audio = result\n self._silences = [] # Reset for re-detection\n\n return self\n\n def strip_silence(self, threshold: Optional[float] = None) -> 'PodcastSplitter':\n \"\"\"\n Remove leading and trailing silence only.\n\n Args:\n threshold: Silence threshold (optional, uses instance default)\n\n Returns:\n Self for chaining\n \"\"\"\n from pydub.silence import detect_leading_silence\n\n thresh = threshold or self.silence_thresh\n\n # Strip leading silence\n start_trim = detect_leading_silence(self._audio, silence_threshold=thresh)\n\n # Strip trailing silence\n reversed_audio = self._audio.reverse()\n end_trim = detect_leading_silence(reversed_audio, silence_threshold=thresh)\n\n if start_trim + end_trim \u003c len(self._audio):\n self._audio = self._audio[start_trim:len(self._audio) - end_trim]\n\n return self\n\n def export_segments(\n self,\n output_dir: str,\n prefix: str = \"segment\",\n format: str = \"mp3\",\n bitrate: int = 192\n ) -> List[str]:\n \"\"\"\n Export all segments to files.\n\n Args:\n output_dir: Output directory path\n prefix: Filename prefix\n format: Output format\n bitrate: Bitrate for lossy formats\n\n Returns:\n List of exported file paths\n \"\"\"\n if not self._segments:\n self.split_by_silence()\n\n output_path = Path(output_dir)\n output_path.mkdir(parents=True, exist_ok=True)\n\n exported = []\n for segment in self._segments:\n filename = f\"{prefix}_{segment['index'] + 1:02d}.{format}\"\n filepath = output_path / filename\n\n params = {}\n if format in ('mp3', 'ogg', 'm4a'):\n params['bitrate'] = f\"{bitrate}k\"\n\n segment['audio'].export(str(filepath), format=format, **params)\n exported.append(str(filepath))\n\n duration_str = self._format_time(segment['duration'])\n print(f\"Exported: {filename} ({duration_str})\")\n\n return exported\n\n def export_segment(\n self,\n index: int,\n output: str,\n format: Optional[str] = None,\n bitrate: int = 192\n ) -> str:\n \"\"\"\n Export a specific segment.\n\n Args:\n index: Segment index (0-based)\n output: Output file path\n format: Output format (from extension if not specified)\n bitrate: Bitrate for lossy formats\n\n Returns:\n Path to exported file\n \"\"\"\n if not self._segments:\n self.split_by_silence()\n\n if index \u003c 0 or index >= len(self._segments):\n raise IndexError(f\"Segment index {index} out of range (0-{len(self._segments)-1})\")\n\n output_path = Path(output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n\n if format is None:\n format = output_path.suffix.lstrip('.').lower()\n\n params = {}\n if format in ('mp3', 'ogg', 'm4a'):\n params['bitrate'] = f\"{bitrate}k\"\n\n self._segments[index]['audio'].export(str(output_path), format=format, **params)\n\n return str(output_path)\n\n def save(\n self,\n output: str,\n format: Optional[str] = None,\n bitrate: int = 192\n ) -> str:\n \"\"\"\n Save the (possibly modified) audio.\n\n Args:\n output: Output file path\n format: Output format\n bitrate: Bitrate for lossy formats\n\n Returns:\n Path to saved file\n \"\"\"\n output_path = Path(output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n\n if format is None:\n format = output_path.suffix.lstrip('.').lower()\n\n params = {}\n if format in ('mp3', 'ogg', 'm4a'):\n params['bitrate'] = f\"{bitrate}k\"\n\n self._audio.export(str(output_path), format=format, **params)\n\n return str(output_path)\n\n def get_segment_count(self) -> int:\n \"\"\"Get number of segments after splitting.\"\"\"\n if not self._segments:\n self.split_by_silence()\n return len(self._segments)\n\n def get_duration(self) -> int:\n \"\"\"Get current audio duration in milliseconds.\"\"\"\n return len(self._audio)\n\n\ndef main():\n \"\"\"CLI entry point.\"\"\"\n parser = argparse.ArgumentParser(\n description='Split audio files by detecting silence',\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n %(prog)s --input episode.mp3 --output-dir ./chapters/\n %(prog)s --input episode.mp3 --detect-only\n %(prog)s --input raw.mp3 --output clean.mp3 --remove-silence 2000\n %(prog)s --input episode.mp3 --output-dir ./chapters/ --threshold -35 --min-silence 2000\n \"\"\"\n )\n\n parser.add_argument('--input', '-i', required=True, help='Input audio file')\n parser.add_argument('--output', '-o', help='Output file (for silence removal)')\n parser.add_argument('--output-dir', '-d', help='Output directory for segments')\n parser.add_argument('--detect-only', action='store_true', help='Only detect/report silences')\n parser.add_argument('--threshold', '-t', type=float, default=-40, help='Silence threshold (dBFS)')\n parser.add_argument('--min-silence', '-m', type=int, default=1000, help='Minimum silence length (ms)')\n parser.add_argument('--keep-silence', '-k', type=int, default=300, help='Silence to keep at edges (ms)')\n parser.add_argument('--max-segments', type=int, help='Maximum segments to create')\n parser.add_argument('--remove-silence', type=int, help='Remove silences longer than (ms)')\n parser.add_argument('--shorten-silence', type=int, help='Cap silence length at (ms)')\n parser.add_argument('--strip', action='store_true', help='Strip leading/trailing silence')\n parser.add_argument('--prefix', default='segment', help='Output filename prefix')\n parser.add_argument('--format', '-f', default='mp3', help='Output format')\n parser.add_argument('--bitrate', '-b', type=int, default=192, help='Output bitrate (kbps)')\n\n args = parser.parse_args()\n\n # Initialize splitter\n splitter = PodcastSplitter(\n args.input,\n silence_thresh=args.threshold,\n min_silence_len=args.min_silence,\n keep_silence=args.keep_silence\n )\n\n # Detect-only mode\n if args.detect_only:\n splitter.print_silence_report()\n return\n\n # Silence modification modes\n if args.remove_silence:\n print(f\"Removing silences > {args.remove_silence}ms...\")\n splitter.remove_silence(args.remove_silence)\n\n if args.shorten_silence:\n print(f\"Shortening silences to max {args.shorten_silence}ms...\")\n splitter.shorten_silence(args.shorten_silence)\n\n if args.strip:\n print(\"Stripping leading/trailing silence...\")\n splitter.strip_silence()\n\n # Output handling\n if args.output:\n # Save modified audio\n output = splitter.save(args.output, format=args.format, bitrate=args.bitrate)\n duration = splitter._format_time(splitter.get_duration())\n print(f\"Saved: {output} ({duration})\")\n\n elif args.output_dir:\n # Split and export segments\n segments = splitter.split_by_silence(max_segments=args.max_segments)\n print(f\"Found {len(segments)} segments\")\n\n exported = splitter.export_segments(\n args.output_dir,\n prefix=args.prefix,\n format=args.format,\n bitrate=args.bitrate\n )\n print(f\"\\nExported {len(exported)} segments to {args.output_dir}\")\n\n else:\n # Just show detection results\n splitter.print_silence_report()\n segments = splitter.split_by_silence()\n print(f\"\\nWould create {len(segments)} segments\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":15488,"content_sha256":"8ce929d8bdee7a06510e8bb17ef1bc8ccde51522cecf8dda0a3c611cf7b3a987"},{"filename":"scripts/requirements.txt","content":"Pillow>=10.0.0\nffmpeg-python>=0.2.0\nimageio>=2.31.0\nlibrosa>=0.10.0\nmatplotlib>=3.7.0\nmoviepy>=1.0.3\nnumpy>=1.24.0\npandas>=2.0.0\npillow>=10.0.0\npydub>=0.25.0\nscipy>=1.10.0\nsoundfile>=0.12.0\n","content_type":"text/plain; charset=utf-8","language":null,"size":190,"content_sha256":"3edf4004942da260a82ee88b42ad9195be8f8808923d4bd528b864fbbb99e98d"},{"filename":"scripts/sfx_generator.py","content":"#!/usr/bin/env python3\n\"\"\"\nSound Effects Generator - Generate programmatic audio.\n\nFeatures:\n- Tone generation (sine, square, sawtooth, triangle)\n- Noise generation (white, pink, brown)\n- DTMF tones\n- Beep sequences\n- Fade effects\n\"\"\"\n\nimport argparse\nfrom pathlib import Path\nfrom typing import List, Union, Optional\nimport numpy as np\n\n\nclass SoundEffectsGenerator:\n \"\"\"Generate programmatic audio effects.\"\"\"\n\n # DTMF frequencies (row, column)\n DTMF_FREQ = {\n '1': (697, 1209), '2': (697, 1336), '3': (697, 1477), 'A': (697, 1633),\n '4': (770, 1209), '5': (770, 1336), '6': (770, 1477), 'B': (770, 1633),\n '7': (852, 1209), '8': (852, 1336), '9': (852, 1477), 'C': (852, 1633),\n '*': (941, 1209), '0': (941, 1336), '#': (941, 1477), 'D': (941, 1633),\n }\n\n def __init__(self, sample_rate: int = 44100):\n \"\"\"\n Initialize generator.\n\n Args:\n sample_rate: Sample rate in Hz (default 44100)\n \"\"\"\n self.sample_rate = sample_rate\n self._audio = np.array([], dtype=np.float32)\n\n def _generate_waveform(\n self,\n frequency: float,\n duration_ms: int,\n waveform: str = \"sine\"\n ) -> np.ndarray:\n \"\"\"Generate a waveform at given frequency.\"\"\"\n num_samples = int(self.sample_rate * duration_ms / 1000)\n t = np.linspace(0, duration_ms / 1000, num_samples, dtype=np.float32)\n\n if waveform == \"sine\":\n signal = np.sin(2 * np.pi * frequency * t)\n elif waveform == \"square\":\n signal = np.sign(np.sin(2 * np.pi * frequency * t))\n elif waveform == \"sawtooth\":\n signal = 2 * (t * frequency - np.floor(0.5 + t * frequency))\n elif waveform == \"triangle\":\n signal = 2 * np.abs(2 * (t * frequency - np.floor(0.5 + t * frequency))) - 1\n else:\n raise ValueError(f\"Unknown waveform: {waveform}\")\n\n return signal.astype(np.float32)\n\n def tone(\n self,\n frequency: float,\n duration: int = 1000,\n waveform: str = \"sine\",\n volume: float = 0.8\n ) -> 'SoundEffectsGenerator':\n \"\"\"\n Generate a tone.\n\n Args:\n frequency: Frequency in Hz\n duration: Duration in milliseconds\n waveform: Waveform type (sine, square, sawtooth, triangle)\n volume: Volume level (0.0 to 1.0)\n\n Returns:\n Self for chaining\n \"\"\"\n signal = self._generate_waveform(frequency, duration, waveform)\n signal *= volume\n self._audio = np.concatenate([self._audio, signal])\n return self\n\n def noise(\n self,\n noise_type: str = \"white\",\n duration: int = 1000,\n volume: float = 0.5\n ) -> 'SoundEffectsGenerator':\n \"\"\"\n Generate noise.\n\n Args:\n noise_type: Type of noise (white, pink, brown)\n duration: Duration in milliseconds\n volume: Volume level (0.0 to 1.0)\n\n Returns:\n Self for chaining\n \"\"\"\n num_samples = int(self.sample_rate * duration / 1000)\n\n if noise_type == \"white\":\n signal = np.random.uniform(-1, 1, num_samples)\n\n elif noise_type == \"pink\":\n # Generate pink noise using Voss-McCartney algorithm\n white = np.random.randn(num_samples)\n # Apply 1/f filter\n from scipy import signal as scipy_signal\n b, a = scipy_signal.butter(1, 0.01)\n pink = scipy_signal.lfilter(b, a, white)\n # Normalize\n signal = pink / np.max(np.abs(pink))\n\n elif noise_type == \"brown\":\n # Brown noise is integrated white noise\n white = np.random.randn(num_samples)\n brown = np.cumsum(white)\n # Normalize\n signal = brown / np.max(np.abs(brown))\n\n else:\n raise ValueError(f\"Unknown noise type: {noise_type}\")\n\n signal = signal.astype(np.float32) * volume\n self._audio = np.concatenate([self._audio, signal])\n return self\n\n def silence(self, duration: int = 1000) -> 'SoundEffectsGenerator':\n \"\"\"\n Generate silence.\n\n Args:\n duration: Duration in milliseconds\n\n Returns:\n Self for chaining\n \"\"\"\n num_samples = int(self.sample_rate * duration / 1000)\n signal = np.zeros(num_samples, dtype=np.float32)\n self._audio = np.concatenate([self._audio, signal])\n return self\n\n def dtmf(\n self,\n digit: str,\n duration: int = 200,\n volume: float = 0.7\n ) -> 'SoundEffectsGenerator':\n \"\"\"\n Generate a DTMF tone for a single digit.\n\n Args:\n digit: DTMF digit (0-9, *, #, A-D)\n duration: Duration in milliseconds\n volume: Volume level\n\n Returns:\n Self for chaining\n \"\"\"\n digit = digit.upper()\n if digit not in self.DTMF_FREQ:\n raise ValueError(f\"Invalid DTMF digit: {digit}\")\n\n f1, f2 = self.DTMF_FREQ[digit]\n\n num_samples = int(self.sample_rate * duration / 1000)\n t = np.linspace(0, duration / 1000, num_samples, dtype=np.float32)\n\n # DTMF is sum of two frequencies\n signal = np.sin(2 * np.pi * f1 * t) + np.sin(2 * np.pi * f2 * t)\n signal = signal / 2 * volume # Normalize\n\n self._audio = np.concatenate([self._audio, signal])\n return self\n\n def dtmf_sequence(\n self,\n digits: str,\n tone_duration: int = 150,\n gap: int = 50\n ) -> 'SoundEffectsGenerator':\n \"\"\"\n Generate DTMF sequence for multiple digits.\n\n Args:\n digits: String of DTMF digits\n tone_duration: Duration of each tone (ms)\n gap: Gap between tones (ms)\n\n Returns:\n Self for chaining\n \"\"\"\n for i, digit in enumerate(digits):\n if digit == ' ':\n self.silence(gap)\n continue\n\n self.dtmf(digit, tone_duration)\n\n if i \u003c len(digits) - 1:\n self.silence(gap)\n\n return self\n\n def beep(\n self,\n frequency: float = 800,\n duration: int = 200,\n waveform: str = \"sine\",\n volume: float = 0.8\n ) -> 'SoundEffectsGenerator':\n \"\"\"\n Generate a single beep.\n\n Args:\n frequency: Beep frequency in Hz\n duration: Duration in milliseconds\n waveform: Waveform type\n volume: Volume level\n\n Returns:\n Self for chaining\n \"\"\"\n return self.tone(frequency, duration, waveform, volume)\n\n def beep_sequence(\n self,\n frequencies: List[float],\n durations: Union[int, List[int]] = 200,\n gap: int = 100,\n waveform: str = \"sine\",\n volume: float = 0.8\n ) -> 'SoundEffectsGenerator':\n \"\"\"\n Generate a sequence of beeps.\n\n Args:\n frequencies: List of frequencies for each beep\n durations: Duration (or list of durations) in ms\n gap: Gap between beeps (ms)\n waveform: Waveform type\n volume: Volume level\n\n Returns:\n Self for chaining\n \"\"\"\n if isinstance(durations, int):\n durations = [durations] * len(frequencies)\n\n if len(durations) != len(frequencies):\n raise ValueError(\"Durations list must match frequencies list\")\n\n for i, (freq, dur) in enumerate(zip(frequencies, durations)):\n self.tone(freq, dur, waveform, volume)\n\n if i \u003c len(frequencies) - 1:\n self.silence(gap)\n\n return self\n\n def fade_in(self, duration_ms: int) -> 'SoundEffectsGenerator':\n \"\"\"\n Apply fade in effect.\n\n Args:\n duration_ms: Fade duration in milliseconds\n\n Returns:\n Self for chaining\n \"\"\"\n if len(self._audio) == 0:\n return self\n\n num_samples = int(self.sample_rate * duration_ms / 1000)\n num_samples = min(num_samples, len(self._audio))\n\n fade = np.linspace(0, 1, num_samples, dtype=np.float32)\n self._audio[:num_samples] *= fade\n\n return self\n\n def fade_out(self, duration_ms: int) -> 'SoundEffectsGenerator':\n \"\"\"\n Apply fade out effect.\n\n Args:\n duration_ms: Fade duration in milliseconds\n\n Returns:\n Self for chaining\n \"\"\"\n if len(self._audio) == 0:\n return self\n\n num_samples = int(self.sample_rate * duration_ms / 1000)\n num_samples = min(num_samples, len(self._audio))\n\n fade = np.linspace(1, 0, num_samples, dtype=np.float32)\n self._audio[-num_samples:] *= fade\n\n return self\n\n def volume(self, level: float) -> 'SoundEffectsGenerator':\n \"\"\"\n Adjust volume.\n\n Args:\n level: Volume level (0.0 to 1.0+)\n\n Returns:\n Self for chaining\n \"\"\"\n self._audio *= level\n return self\n\n def get_duration_ms(self) -> int:\n \"\"\"Get current audio duration in milliseconds.\"\"\"\n return int(len(self._audio) / self.sample_rate * 1000)\n\n def clear(self) -> 'SoundEffectsGenerator':\n \"\"\"Clear all audio data.\"\"\"\n self._audio = np.array([], dtype=np.float32)\n return self\n\n def save(\n self,\n output: str,\n bitrate: int = 192\n ) -> str:\n \"\"\"\n Save audio to file.\n\n Args:\n output: Output file path\n bitrate: Bitrate for MP3 (kbps)\n\n Returns:\n Path to saved file\n \"\"\"\n output_path = Path(output)\n output_path.parent.mkdir(parents=True, exist_ok=True)\n\n format_ext = output_path.suffix.lower()\n\n if format_ext == '.wav':\n # Save directly using soundfile\n import soundfile as sf\n sf.write(str(output_path), self._audio, self.sample_rate)\n\n elif format_ext == '.mp3':\n # Use pydub for MP3\n try:\n from pydub import AudioSegment\n import io\n\n # Convert to 16-bit PCM\n audio_int = (self._audio * 32767).astype(np.int16)\n\n # Create AudioSegment\n audio_segment = AudioSegment(\n audio_int.tobytes(),\n frame_rate=self.sample_rate,\n sample_width=2, # 16-bit\n channels=1\n )\n\n audio_segment.export(str(output_path), format='mp3', bitrate=f'{bitrate}k')\n\n except ImportError:\n raise ImportError(\"pydub is required for MP3 export. Install with: pip install pydub\")\n\n else:\n # Try soundfile for other formats\n import soundfile as sf\n sf.write(str(output_path), self._audio, self.sample_rate)\n\n return str(output_path)\n\n\ndef main():\n \"\"\"CLI entry point.\"\"\"\n parser = argparse.ArgumentParser(\n description='Generate programmatic audio effects',\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n %(prog)s --tone 440 --duration 1000 --output tone.wav\n %(prog)s --noise white --duration 2000 --output noise.wav\n %(prog)s --dtmf \"5551234\" --output phone.wav\n %(prog)s --beeps \"800,800,800\" --duration 100 --gap 100 --output alert.wav\n \"\"\"\n )\n\n parser.add_argument('--tone', '-t', type=float, help='Generate tone at frequency (Hz)')\n parser.add_argument('--noise', '-n', choices=['white', 'pink', 'brown'], help='Generate noise')\n parser.add_argument('--dtmf', help='Generate DTMF tones for digits')\n parser.add_argument('--beeps', help='Comma-separated frequencies for beep sequence')\n parser.add_argument('--duration', '-d', type=int, default=1000, help='Duration in ms')\n parser.add_argument('--gap', '-g', type=int, default=100, help='Gap between sounds (ms)')\n parser.add_argument('--waveform', '-w', default='sine',\n choices=['sine', 'square', 'sawtooth', 'triangle'],\n help='Waveform type for tones')\n parser.add_argument('--volume', '-v', type=float, default=0.8, help='Volume (0.0-1.0)')\n parser.add_argument('--sample-rate', type=int, default=44100, help='Sample rate (Hz)')\n parser.add_argument('--fade-in', type=int, help='Fade in duration (ms)')\n parser.add_argument('--fade-out', type=int, help='Fade out duration (ms)')\n parser.add_argument('--output', '-o', required=True, help='Output file')\n\n args = parser.parse_args()\n\n sfx = SoundEffectsGenerator(sample_rate=args.sample_rate)\n\n # Generate audio based on options\n if args.tone:\n sfx.tone(args.tone, args.duration, args.waveform, args.volume)\n print(f\"Generated {args.waveform} tone at {args.tone}Hz\")\n\n elif args.noise:\n sfx.noise(args.noise, args.duration, args.volume)\n print(f\"Generated {args.noise} noise\")\n\n elif args.dtmf:\n sfx.dtmf_sequence(args.dtmf, tone_duration=args.duration, gap=args.gap)\n print(f\"Generated DTMF for: {args.dtmf}\")\n\n elif args.beeps:\n frequencies = [float(f) for f in args.beeps.split(',')]\n sfx.beep_sequence(frequencies, args.duration, args.gap, args.waveform, args.volume)\n print(f\"Generated beep sequence: {frequencies}\")\n\n else:\n parser.error(\"Must specify --tone, --noise, --dtmf, or --beeps\")\n\n # Apply effects\n if args.fade_in:\n sfx.fade_in(args.fade_in)\n\n if args.fade_out:\n sfx.fade_out(args.fade_out)\n\n # Save\n output = sfx.save(args.output)\n print(f\"Saved: {output} ({sfx.get_duration_ms()}ms)\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":13780,"content_sha256":"b40d3201dff7f92e7d96b8c2bfeaeff81f6d65a03d660c630704c9b96b764ed6"},{"filename":"scripts/thumbnail_gen.py","content":"#!/usr/bin/env python3\n\"\"\"\nThumbnail Generator - Create optimized thumbnails with smart cropping.\n\"\"\"\n\nimport argparse\nimport os\nfrom typing import Dict, List, Optional, Tuple, Union\nfrom pathlib import Path\nfrom io import BytesIO\n\nfrom PIL import Image, ImageFilter\nimport numpy as np\n\n\nclass ThumbnailGenerator:\n \"\"\"Generate thumbnails with various cropping and sizing options.\"\"\"\n\n PRESETS = {\n \"web\": [\n (150, 150, \"small\"),\n (300, 300, \"medium\"),\n (600, 600, \"large\")\n ],\n \"social\": [\n (1080, 1080, \"instagram_square\"),\n (1080, 1350, \"instagram_portrait\"),\n (1080, 566, \"instagram_landscape\"),\n (1200, 675, \"twitter\"),\n (1200, 630, \"facebook\"),\n (1200, 627, \"linkedin\")\n ],\n \"icons\": [\n (16, 16, \"icon_16\"),\n (32, 32, \"icon_32\"),\n (48, 48, \"icon_48\"),\n (64, 64, \"icon_64\"),\n (128, 128, \"icon_128\"),\n (256, 256, \"icon_256\"),\n (512, 512, \"icon_512\")\n ],\n \"favicon\": [\n (16, 16, \"favicon_16\"),\n (32, 32, \"favicon_32\"),\n (180, 180, \"apple_touch\"),\n (192, 192, \"android_192\"),\n (512, 512, \"android_512\")\n ]\n }\n\n def __init__(self):\n \"\"\"Initialize the generator.\"\"\"\n self.image: Optional[Image.Image] = None\n self.original: Optional[Image.Image] = None\n self.filepath: Optional[str] = None\n\n def load(self, filepath: str) -> 'ThumbnailGenerator':\n \"\"\"\n Load an image file.\n\n Args:\n filepath: Path to image file\n\n Returns:\n Self for method chaining\n \"\"\"\n self.filepath = filepath\n self.original = Image.open(filepath)\n self.image = self.original.copy()\n\n # Convert to RGB if necessary (for JPEG output)\n if self.image.mode in ('RGBA', 'P'):\n self.image = self.image.convert('RGB')\n\n return self\n\n def resize(self, width: int, height: int, crop: str = \"fit\") -> 'ThumbnailGenerator':\n \"\"\"\n Resize image to specified dimensions.\n\n Args:\n width: Target width\n height: Target height\n crop: Resize mode - \"fit\", \"fill\", or \"stretch\"\n\n Returns:\n Self for method chaining\n \"\"\"\n if self.image is None:\n raise ValueError(\"No image loaded\")\n\n if crop == \"fit\":\n # Maintain aspect ratio, fit within bounds\n self.image.thumbnail((width, height), Image.Resampling.LANCZOS)\n elif crop == \"fill\":\n # Maintain aspect ratio, fill bounds (crop excess)\n self._resize_and_crop(width, height)\n elif crop == \"stretch\":\n # Ignore aspect ratio\n self.image = self.image.resize((width, height), Image.Resampling.LANCZOS)\n else:\n raise ValueError(f\"Unknown crop mode: {crop}\")\n\n return self\n\n def _resize_and_crop(self, width: int, height: int):\n \"\"\"Resize to fill and crop to exact dimensions.\"\"\"\n img = self.image\n img_ratio = img.width / img.height\n target_ratio = width / height\n\n if img_ratio > target_ratio:\n # Image is wider - resize by height, crop width\n new_height = height\n new_width = int(height * img_ratio)\n else:\n # Image is taller - resize by width, crop height\n new_width = width\n new_height = int(width / img_ratio)\n\n img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)\n\n # Crop to center\n left = (new_width - width) // 2\n top = (new_height - height) // 2\n right = left + width\n bottom = top + height\n\n self.image = img.crop((left, top, right, bottom))\n\n def resize_width(self, width: int) -> 'ThumbnailGenerator':\n \"\"\"\n Resize to specific width, maintaining aspect ratio.\n\n Args:\n width: Target width\n\n Returns:\n Self for method chaining\n \"\"\"\n if self.image is None:\n raise ValueError(\"No image loaded\")\n\n ratio = width / self.image.width\n height = int(self.image.height * ratio)\n self.image = self.image.resize((width, height), Image.Resampling.LANCZOS)\n return self\n\n def resize_height(self, height: int) -> 'ThumbnailGenerator':\n \"\"\"\n Resize to specific height, maintaining aspect ratio.\n\n Args:\n height: Target height\n\n Returns:\n Self for method chaining\n \"\"\"\n if self.image is None:\n raise ValueError(\"No image loaded\")\n\n ratio = height / self.image.height\n width = int(self.image.width * ratio)\n self.image = self.image.resize((width, height), Image.Resampling.LANCZOS)\n return self\n\n def crop_center(self, width: int, height: int) -> 'ThumbnailGenerator':\n \"\"\"\n Crop to specified size from center.\n\n Args:\n width: Crop width\n height: Crop height\n\n Returns:\n Self for method chaining\n \"\"\"\n return self.crop_position(width, height, \"center\")\n\n def crop_position(self, width: int, height: int,\n position: str = \"center\") -> 'ThumbnailGenerator':\n \"\"\"\n Crop to specified size from given position.\n\n Args:\n width: Crop width\n height: Crop height\n position: Crop position\n\n Returns:\n Self for method chaining\n \"\"\"\n if self.image is None:\n raise ValueError(\"No image loaded\")\n\n img_w, img_h = self.image.size\n\n # First, resize if image is smaller than crop size\n if img_w \u003c width or img_h \u003c height:\n scale = max(width / img_w, height / img_h)\n new_w = int(img_w * scale)\n new_h = int(img_h * scale)\n self.image = self.image.resize((new_w, new_h), Image.Resampling.LANCZOS)\n img_w, img_h = self.image.size\n\n # Calculate crop coordinates\n positions = {\n \"center\": ((img_w - width) // 2, (img_h - height) // 2),\n \"top\": ((img_w - width) // 2, 0),\n \"bottom\": ((img_w - width) // 2, img_h - height),\n \"left\": (0, (img_h - height) // 2),\n \"right\": (img_w - width, (img_h - height) // 2),\n \"top-left\": (0, 0),\n \"top-right\": (img_w - width, 0),\n \"bottom-left\": (0, img_h - height),\n \"bottom-right\": (img_w - width, img_h - height)\n }\n\n if position not in positions:\n raise ValueError(f\"Unknown position: {position}\")\n\n left, top = positions[position]\n left = max(0, left)\n top = max(0, top)\n\n self.image = self.image.crop((left, top, left + width, top + height))\n return self\n\n def crop_smart(self, width: int, height: int) -> 'ThumbnailGenerator':\n \"\"\"\n Smart crop using edge detection to find interesting region.\n\n Args:\n width: Crop width\n height: Crop height\n\n Returns:\n Self for method chaining\n \"\"\"\n if self.image is None:\n raise ValueError(\"No image loaded\")\n\n # First resize to be slightly larger than target\n img = self.image.copy()\n img_ratio = img.width / img.height\n target_ratio = width / height\n\n if img_ratio > target_ratio:\n new_height = height\n new_width = int(height * img_ratio)\n else:\n new_width = width\n new_height = int(width / img_ratio)\n\n img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)\n\n # Use edge detection to find interesting region\n gray = img.convert('L')\n edges = gray.filter(ImageFilter.FIND_EDGES)\n edge_array = np.array(edges)\n\n # Find center of mass of edges\n y_indices, x_indices = np.mgrid[0:edge_array.shape[0], 0:edge_array.shape[1]]\n\n total = edge_array.sum()\n if total > 0:\n center_x = int((x_indices * edge_array).sum() / total)\n center_y = int((y_indices * edge_array).sum() / total)\n else:\n center_x = new_width // 2\n center_y = new_height // 2\n\n # Calculate crop box centered on point of interest\n left = max(0, min(center_x - width // 2, new_width - width))\n top = max(0, min(center_y - height // 2, new_height - height))\n\n self.image = img.crop((left, top, left + width, top + height))\n return self\n\n def save(self, output: str, quality: int = 85, format: str = None) -> str:\n \"\"\"\n Save thumbnail to file.\n\n Args:\n output: Output file path\n quality: JPEG/WebP quality (1-100)\n format: Output format (auto-detect from extension if None)\n\n Returns:\n Output file path\n \"\"\"\n if self.image is None:\n raise ValueError(\"No image loaded\")\n\n # Detect format from extension\n if format is None:\n ext = Path(output).suffix.lower()\n format_map = {\n '.jpg': 'JPEG',\n '.jpeg': 'JPEG',\n '.png': 'PNG',\n '.webp': 'WEBP',\n '.gif': 'GIF'\n }\n format = format_map.get(ext, 'JPEG')\n\n # Convert to RGB for JPEG\n img = self.image\n if format == 'JPEG' and img.mode in ('RGBA', 'P'):\n img = img.convert('RGB')\n\n # Save with appropriate options\n save_kwargs = {}\n if format in ('JPEG', 'WEBP'):\n save_kwargs['quality'] = quality\n if format == 'PNG':\n save_kwargs['optimize'] = True\n\n img.save(output, format=format, **save_kwargs)\n return output\n\n def to_bytes(self, format: str = \"JPEG\", quality: int = 85) -> bytes:\n \"\"\"\n Get thumbnail as bytes.\n\n Args:\n format: Image format\n quality: Quality setting\n\n Returns:\n Image bytes\n \"\"\"\n if self.image is None:\n raise ValueError(\"No image loaded\")\n\n buffer = BytesIO()\n img = self.image\n\n if format == 'JPEG' and img.mode in ('RGBA', 'P'):\n img = img.convert('RGB')\n\n save_kwargs = {}\n if format in ('JPEG', 'WEBP'):\n save_kwargs['quality'] = quality\n\n img.save(buffer, format=format, **save_kwargs)\n return buffer.getvalue()\n\n def generate_sizes(self, sizes: List[Tuple[int, int]], output_dir: str,\n prefix: str = None) -> List[Dict]:\n \"\"\"\n Generate multiple thumbnail sizes.\n\n Args:\n sizes: List of (width, height) tuples\n output_dir: Output directory\n prefix: Filename prefix\n\n Returns:\n List of generated file info\n \"\"\"\n if self.original is None:\n raise ValueError(\"No image loaded\")\n\n os.makedirs(output_dir, exist_ok=True)\n\n if prefix is None:\n prefix = Path(self.filepath).stem if self.filepath else \"thumb\"\n\n results = []\n ext = \".jpg\"\n\n for width, height in sizes:\n # Reset to original\n self.image = self.original.copy()\n if self.image.mode in ('RGBA', 'P'):\n self.image = self.image.convert('RGB')\n\n # Resize\n self.resize(width, height, crop=\"fill\")\n\n # Save\n filename = f\"{prefix}_{width}x{height}{ext}\"\n output_path = os.path.join(output_dir, filename)\n self.save(output_path)\n\n results.append({\n \"size\": f\"{width}x{height}\",\n \"path\": output_path,\n \"file_size\": os.path.getsize(output_path)\n })\n\n return results\n\n def apply_preset(self, preset: str, output_dir: str) -> List[Dict]:\n \"\"\"\n Apply a preset configuration.\n\n Args:\n preset: Preset name\n output_dir: Output directory\n\n Returns:\n List of generated file info\n \"\"\"\n if preset not in self.PRESETS:\n raise ValueError(f\"Unknown preset: {preset}. Available: {list(self.PRESETS.keys())}\")\n\n os.makedirs(output_dir, exist_ok=True)\n results = []\n\n # Determine format based on preset\n ext = \".png\" if preset in (\"icons\", \"favicon\") else \".jpg\"\n\n for width, height, name in self.PRESETS[preset]:\n # Reset to original\n self.image = self.original.copy()\n if ext == \".jpg\" and self.image.mode in ('RGBA', 'P'):\n self.image = self.image.convert('RGB')\n\n # Resize\n self.resize(width, height, crop=\"fill\")\n\n # Save\n output_path = os.path.join(output_dir, f\"{name}{ext}\")\n self.save(output_path)\n\n results.append({\n \"name\": name,\n \"size\": f\"{width}x{height}\",\n \"path\": output_path,\n \"file_size\": os.path.getsize(output_path)\n })\n\n return results\n\n def process_folder(self, input_dir: str, output_dir: str,\n width: int, height: int,\n recursive: bool = False) -> List[Dict]:\n \"\"\"\n Process all images in a folder.\n\n Args:\n input_dir: Input directory\n output_dir: Output directory\n width: Thumbnail width\n height: Thumbnail height\n recursive: Include subdirectories\n\n Returns:\n List of processed file info\n \"\"\"\n supported = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'}\n os.makedirs(output_dir, exist_ok=True)\n\n results = []\n input_path = Path(input_dir)\n\n if recursive:\n files = input_path.rglob(\"*\")\n else:\n files = input_path.glob(\"*\")\n\n for file in files:\n if file.suffix.lower() in supported:\n try:\n self.load(str(file))\n self.resize(width, height, crop=\"fill\")\n\n output_file = os.path.join(output_dir, f\"thumb_{file.name}\")\n self.save(output_file)\n\n results.append({\n \"input\": str(file),\n \"output\": output_file,\n \"success\": True\n })\n except Exception as e:\n results.append({\n \"input\": str(file),\n \"error\": str(e),\n \"success\": False\n })\n\n return results\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Thumbnail Generator - Create optimized thumbnails\"\n )\n\n parser.add_argument(\"--input\", \"-i\", required=True, help=\"Input image or folder\")\n parser.add_argument(\"--output\", \"-o\", help=\"Output file or folder\")\n parser.add_argument(\"--size\", \"-s\", help=\"Size as WxH (e.g., 200x200)\")\n parser.add_argument(\"--sizes\", help=\"Multiple sizes as comma-separated WxH\")\n parser.add_argument(\"--preset\", \"-p\", choices=list(ThumbnailGenerator.PRESETS.keys()),\n help=\"Use preset sizes\")\n parser.add_argument(\"--crop\", \"-c\", choices=[\"fit\", \"fill\", \"stretch\", \"smart\"],\n default=\"fill\", help=\"Crop mode (default: fill)\")\n parser.add_argument(\"--format\", \"-f\", choices=[\"jpeg\", \"png\", \"webp\"],\n help=\"Output format\")\n parser.add_argument(\"--quality\", \"-q\", type=int, default=85,\n help=\"Quality (1-100, default: 85)\")\n parser.add_argument(\"--recursive\", \"-r\", action=\"store_true\",\n help=\"Process subfolders\")\n\n args = parser.parse_args()\n\n gen = ThumbnailGenerator()\n input_path = Path(args.input)\n\n if input_path.is_dir():\n # Batch processing\n if not args.size:\n parser.error(\"--size required for batch processing\")\n\n w, h = map(int, args.size.split('x'))\n output_dir = args.output or str(input_path / \"thumbnails\")\n\n results = gen.process_folder(\n str(input_path), output_dir, w, h,\n recursive=args.recursive\n )\n\n success = sum(1 for r in results if r[\"success\"])\n print(f\"Processed {success}/{len(results)} images\")\n print(f\"Output: {output_dir}\")\n\n else:\n # Single file\n gen.load(args.input)\n\n if args.preset:\n output_dir = args.output or \"./\"\n results = gen.apply_preset(args.preset, output_dir)\n print(f\"Generated {len(results)} thumbnails:\")\n for r in results:\n print(f\" {r['name']}: {r['size']} ({r['file_size']} bytes)\")\n\n elif args.sizes:\n sizes = [tuple(map(int, s.split('x'))) for s in args.sizes.split(',')]\n output_dir = args.output or \"./\"\n results = gen.generate_sizes(sizes, output_dir)\n print(f\"Generated {len(results)} thumbnails:\")\n for r in results:\n print(f\" {r['size']}: {r['path']}\")\n\n elif args.size:\n w, h = map(int, args.size.split('x'))\n\n if args.crop == \"smart\":\n gen.crop_smart(w, h)\n else:\n gen.resize(w, h, crop=args.crop)\n\n output = args.output or f\"thumb_{input_path.name}\"\n\n format_arg = args.format.upper() if args.format else None\n gen.save(output, quality=args.quality, format=format_arg)\n print(f\"Saved: {output}\")\n\n else:\n parser.error(\"Specify --size, --sizes, or --preset\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":17869,"content_sha256":"c39648d9b59638e973390fdd0e1ce32879d992b99ed770971d58d39fb5df2feb"},{"filename":"scripts/timelapse_creator.py","content":"#!/usr/bin/env python3\n\"\"\"\nTimelapse Creator - Create timelapse videos from images.\n\"\"\"\n\nimport argparse\nfrom pathlib import Path\nfrom typing import List\n\nfrom moviepy.editor import ImageSequenceClip\nfrom PIL import Image\n\n\nclass TimelapseCreator:\n \"\"\"Create timelapse videos.\"\"\"\n\n def __init__(self):\n \"\"\"Initialize creator.\"\"\"\n self.images = []\n\n def load_images_from_dir(self, directory: str, pattern: str = '*') -> 'TimelapseCreator':\n \"\"\"Load images from directory.\"\"\"\n path = Path(directory)\n self.images = sorted([\n str(p) for p in path.glob(pattern)\n if p.suffix.lower() in ['.jpg', '.jpeg', '.png', '.bmp']\n ])\n\n return self\n\n def create_timelapse(self, output: str, fps: int = 30) -> str:\n \"\"\"Create timelapse video.\"\"\"\n if not self.images:\n raise ValueError(\"No images loaded\")\n\n print(f\"Creating timelapse from {len(self.images)} images at {fps} FPS...\")\n\n clip = ImageSequenceClip(self.images, fps=fps)\n clip.write_videofile(output, logger=None)\n\n return output\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Timelapse Creator\")\n\n parser.add_argument(\"--input\", \"-i\", required=True, help=\"Input directory with images\")\n parser.add_argument(\"--output\", \"-o\", required=True, help=\"Output video file\")\n parser.add_argument(\"--fps\", type=int, default=30, help=\"Frames per second\")\n parser.add_argument(\"--pattern\", default=\"*\", help=\"File pattern (e.g., '*.jpg')\")\n\n args = parser.parse_args()\n\n creator = TimelapseCreator()\n creator.load_images_from_dir(args.input, pattern=args.pattern)\n\n print(f\"Found {len(creator.images)} images\")\n\n creator.create_timelapse(args.output, fps=args.fps)\n\n print(f\"\\nTimelapse created: {args.output}\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":1869,"content_sha256":"f24354a012cb9d50e997ab0be1921a638276c8182fef94c47d7ca50f9246a966"},{"filename":"scripts/video_captioner.py","content":"#!/usr/bin/env python3\n\"\"\"\nVideo Captioner - Add text overlays and subtitles to videos.\n\nFeatures:\n- Static text overlays\n- Timed captions with SRT support\n- Custom styling (font, color, position)\n- Style presets for social media\n- Batch captioning\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport re\nimport sys\nfrom pathlib import Path\nfrom typing import Dict, List, Optional, Tuple\nfrom moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip\nfrom moviepy.video.fx.all import fadein, fadeout\n\n\nclass VideoCaptioner:\n \"\"\"Add text overlays and captions to videos.\"\"\"\n\n POSITIONS = {\n 'top': lambda w, h: ('center', 50),\n 'bottom': lambda w, h: ('center', h - 100),\n 'center': lambda w, h: ('center', 'center'),\n 'top-left': lambda w, h: (50, 50),\n 'top-right': lambda w, h: (w - 50, 50),\n 'bottom-left': lambda w, h: (50, h - 100),\n 'bottom-right': lambda w, h: (w - 50, h - 100),\n }\n\n PRESETS = {\n 'instagram-story': {\n 'font': 'Arial-Bold',\n 'font_size': 60,\n 'color': 'white',\n 'stroke_color': 'black',\n 'stroke_width': 3,\n 'position': 'top'\n },\n 'youtube': {\n 'font': 'Arial',\n 'font_size': 48,\n 'color': 'yellow',\n 'bg_color': 'black',\n 'position': 'bottom'\n },\n 'minimal': {\n 'font': 'Arial',\n 'font_size': 42,\n 'color': 'white',\n 'stroke_color': 'black',\n 'stroke_width': 1,\n 'position': 'bottom'\n },\n 'bold': {\n 'font': 'Arial-Bold',\n 'font_size': 72,\n 'color': 'white',\n 'bg_color': 'black',\n 'position': 'center'\n }\n }\n\n def __init__(self):\n self.video = None\n self.filepath = None\n self.captions = []\n self.current_preset = 'minimal'\n\n def load(self, filepath: str) -> 'VideoCaptioner':\n \"\"\"\n Load video file.\n\n Args:\n filepath: Path to video file\n\n Returns:\n Self for method chaining\n \"\"\"\n if not os.path.exists(filepath):\n raise FileNotFoundError(f\"Video file not found: {filepath}\")\n\n self.filepath = filepath\n self.video = VideoFileClip(filepath)\n\n return self\n\n def _parse_position(self, position) -> Tuple:\n \"\"\"Parse position string or tuple to coordinates.\"\"\"\n if isinstance(position, tuple):\n return position\n\n if position in self.POSITIONS:\n return self.POSITIONS[position](self.video.w, self.video.h)\n\n raise ValueError(f\"Unknown position: {position}\")\n\n def add_text(self, text: str, position: str = 'bottom',\n font: str = 'Arial', font_size: int = 48,\n color: str = 'white', bg_color: str = None,\n stroke_color: str = None, stroke_width: int = 0,\n duration: float = None) -> 'VideoCaptioner':\n \"\"\"\n Add static text overlay to entire video.\n\n Args:\n text: Text to display\n position: Position ('top', 'bottom', 'center', or tuple)\n font: Font name\n font_size: Font size in pixels\n color: Text color\n bg_color: Background color (optional)\n stroke_color: Outline color (optional)\n stroke_width: Outline width (optional)\n duration: Duration in seconds (None = full video)\n\n Returns:\n Self for method chaining\n \"\"\"\n if self.video is None:\n raise ValueError(\"No video loaded. Call load() first.\")\n\n pos = self._parse_position(position)\n\n # Create text clip\n txt_clip = TextClip(\n text,\n fontsize=font_size,\n color=color,\n font=font,\n stroke_color=stroke_color,\n stroke_width=stroke_width,\n bg_color=bg_color,\n method='caption',\n size=(self.video.w - 100, None) # Leave margins\n ).set_position(pos)\n\n # Set duration\n if duration is None:\n txt_clip = txt_clip.set_duration(self.video.duration)\n else:\n txt_clip = txt_clip.set_duration(duration)\n\n self.captions.append({\n 'clip': txt_clip,\n 'start': 0,\n 'end': duration or self.video.duration\n })\n\n return self\n\n def add_caption(self, text: str, start: float, end: float,\n position: str = 'bottom', font: str = 'Arial',\n font_size: int = 48, color: str = 'white',\n bg_color: str = None, stroke_color: str = None,\n stroke_width: int = 0, fade: bool = False) -> 'VideoCaptioner':\n \"\"\"\n Add timed caption that appears during specified time range.\n\n Args:\n text: Caption text\n start: Start time in seconds\n end: End time in seconds\n position: Position ('top', 'bottom', 'center', or tuple)\n font: Font name\n font_size: Font size in pixels\n color: Text color\n bg_color: Background color (optional)\n stroke_color: Outline color (optional)\n stroke_width: Outline width (optional)\n fade: Apply fade in/out effect\n\n Returns:\n Self for method chaining\n \"\"\"\n if self.video is None:\n raise ValueError(\"No video loaded. Call load() first.\")\n\n pos = self._parse_position(position)\n duration = end - start\n\n # Create text clip\n txt_clip = TextClip(\n text,\n fontsize=font_size,\n color=color,\n font=font,\n stroke_color=stroke_color,\n stroke_width=stroke_width,\n bg_color=bg_color,\n method='caption',\n size=(self.video.w - 100, None)\n ).set_position(pos).set_start(start).set_duration(duration)\n\n # Apply fade effect\n if fade:\n fade_duration = min(0.5, duration / 4)\n txt_clip = fadein(txt_clip, fade_duration)\n txt_clip = fadeout(txt_clip, fade_duration)\n\n self.captions.append({\n 'clip': txt_clip,\n 'start': start,\n 'end': end\n })\n\n return self\n\n def import_srt(self, srt_filepath: str, **style_kwargs) -> 'VideoCaptioner':\n \"\"\"\n Import subtitles from SRT file.\n\n Args:\n srt_filepath: Path to SRT subtitle file\n **style_kwargs: Style parameters (font_size, color, etc.)\n\n Returns:\n Self for method chaining\n \"\"\"\n if not os.path.exists(srt_filepath):\n raise FileNotFoundError(f\"SRT file not found: {srt_filepath}\")\n\n # Parse SRT\n captions = self._parse_srt(srt_filepath)\n\n # Add captions\n for cap in captions:\n self.add_caption(\n text=cap['text'],\n start=cap['start'],\n end=cap['end'],\n **style_kwargs\n )\n\n return self\n\n def _parse_srt(self, filepath: str) -> List[Dict]:\n \"\"\"Parse SRT subtitle file.\"\"\"\n with open(filepath, 'r', encoding='utf-8') as f:\n content = f.read()\n\n # SRT format: number, timestamp, text, blank line\n pattern = r'(\\d+)\\n(\\d{2}:\\d{2}:\\d{2},\\d{3}) --> (\\d{2}:\\d{2}:\\d{2},\\d{3})\\n((?:.*\\n?)+?)(?:\\n\\n|\\Z)'\n matches = re.findall(pattern, content)\n\n captions = []\n for match in matches:\n start = self._parse_srt_time(match[1])\n end = self._parse_srt_time(match[2])\n text = match[3].strip()\n\n captions.append({\n 'start': start,\n 'end': end,\n 'text': text\n })\n\n return captions\n\n def _parse_srt_time(self, time_str: str) -> float:\n \"\"\"Convert SRT timestamp to seconds.\"\"\"\n # Format: HH:MM:SS,mmm\n h, m, s = time_str.split(':')\n s, ms = s.split(',')\n total_seconds = int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000\n return total_seconds\n\n def import_captions_json(self, json_filepath: str) -> 'VideoCaptioner':\n \"\"\"\n Import captions from JSON file.\n\n JSON format:\n {\n \"captions\": [\n {\"text\": \"...\", \"start\": 0.0, \"end\": 3.0, \"position\": \"bottom\", ...}\n ]\n }\n\n Args:\n json_filepath: Path to JSON file\n\n Returns:\n Self for method chaining\n \"\"\"\n with open(json_filepath, 'r') as f:\n data = json.load(f)\n\n for cap in data.get('captions', []):\n self.add_caption(**cap)\n\n return self\n\n def style_preset(self, preset: str) -> 'VideoCaptioner':\n \"\"\"\n Apply style preset.\n\n Args:\n preset: Preset name ('instagram-story', 'youtube', 'minimal', 'bold')\n\n Returns:\n Self for method chaining\n \"\"\"\n if preset not in self.PRESETS:\n raise ValueError(f\"Unknown preset: {preset}. Available: {list(self.PRESETS.keys())}\")\n\n self.current_preset = preset\n\n return self\n\n def preview_frame(self, time: float, output: str) -> str:\n \"\"\"\n Generate preview frame with captions at specific time.\n\n Args:\n time: Time in seconds\n output: Output image path\n\n Returns:\n Path to saved image\n \"\"\"\n if self.video is None:\n raise ValueError(\"No video loaded. Call load() first.\")\n\n # Composite all captions active at this time\n active_captions = [\n cap['clip'] for cap in self.captions\n if cap['start'] \u003c= time \u003c cap['end']\n ]\n\n if active_captions:\n composite = CompositeVideoClip([self.video] + active_captions)\n else:\n composite = self.video\n\n # Save frame\n composite.save_frame(output, t=time)\n\n return output\n\n def save(self, output: str, codec: str = 'libx264',\n fps: int = None, bitrate: str = None) -> str:\n \"\"\"\n Save video with captions.\n\n Args:\n output: Output video path\n codec: Video codec (default: libx264)\n fps: Frame rate (None = preserve original)\n bitrate: Video bitrate (None = auto)\n\n Returns:\n Path to saved video\n \"\"\"\n if self.video is None:\n raise ValueError(\"No video loaded. Call load() first.\")\n\n os.makedirs(os.path.dirname(output) or '.', exist_ok=True)\n\n # Composite video with all caption clips\n if self.captions:\n caption_clips = [cap['clip'] for cap in self.captions]\n final_video = CompositeVideoClip([self.video] + caption_clips)\n else:\n final_video = self.video\n\n # Write video\n write_params = {\n 'codec': codec,\n 'audio_codec': 'aac',\n 'logger': None\n }\n\n if fps:\n write_params['fps'] = fps\n if bitrate:\n write_params['bitrate'] = bitrate\n\n final_video.write_videofile(output, **write_params)\n\n return output\n\n def clear_captions(self) -> 'VideoCaptioner':\n \"\"\"Clear all captions.\"\"\"\n self.captions = []\n return self\n\n def close(self):\n \"\"\"Release video resources.\"\"\"\n if self.video:\n self.video.close()\n self.video = None\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Add text overlays and captions to videos',\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n # Simple text overlay\n python video_captioner.py input.mp4 --text \"Subscribe!\" --position bottom --output captioned.mp4\n\n # Add SRT subtitles\n python video_captioner.py input.mp4 --srt subtitles.srt --output subtitled.mp4\n\n # Custom styling\n python video_captioner.py input.mp4 --text \"Sale!\" --font-size 72 --color red --bg-color black --output promo.mp4\n\n # Use preset\n python video_captioner.py input.mp4 --text \"Check this out!\" --preset instagram-story --output story.mp4\n \"\"\"\n )\n\n parser.add_argument('input', help='Input video file')\n parser.add_argument('--output', '-o', required=True, help='Output video file')\n parser.add_argument('--text', '-t', help='Text to overlay')\n parser.add_argument('--srt', help='SRT subtitle file')\n parser.add_argument('--captions', help='JSON captions file')\n parser.add_argument('--position', '-p', default='bottom',\n help='Text position (top, bottom, center, etc.)')\n parser.add_argument('--font', '-f', default='Arial', help='Font name')\n parser.add_argument('--font-size', type=int, default=48, help='Font size')\n parser.add_argument('--color', '-c', default='white', help='Text color')\n parser.add_argument('--bg-color', help='Background color (optional)')\n parser.add_argument('--stroke-color', help='Outline color (optional)')\n parser.add_argument('--stroke-width', type=int, default=0, help='Outline width')\n parser.add_argument('--preset', choices=['instagram-story', 'youtube', 'minimal', 'bold'],\n help='Use style preset')\n parser.add_argument('--preview', type=float, help='Generate preview frame at time (seconds)')\n\n args = parser.parse_args()\n\n captioner = VideoCaptioner()\n\n try:\n # Load video\n print(f\"Loading video: {args.input}\")\n captioner.load(args.input)\n\n # Apply preset if specified\n if args.preset:\n captioner.style_preset(args.preset)\n preset_style = VideoCaptioner.PRESETS[args.preset]\n\n # Use preset values as defaults\n if not args.text and not args.srt and not args.captions:\n print(f\"Error: --text, --srt, or --captions required\")\n return\n\n style_kwargs = {\n 'font': args.font or preset_style.get('font', 'Arial'),\n 'font_size': args.font_size or preset_style.get('font_size', 48),\n 'color': args.color or preset_style.get('color', 'white'),\n 'bg_color': args.bg_color or preset_style.get('bg_color'),\n 'stroke_color': args.stroke_color or preset_style.get('stroke_color'),\n 'stroke_width': args.stroke_width or preset_style.get('stroke_width', 0),\n 'position': args.position or preset_style.get('position', 'bottom')\n }\n else:\n style_kwargs = {\n 'font': args.font,\n 'font_size': args.font_size,\n 'color': args.color,\n 'bg_color': args.bg_color,\n 'stroke_color': args.stroke_color,\n 'stroke_width': args.stroke_width,\n 'position': args.position\n }\n\n # Add captions\n if args.text:\n print(f\"Adding text overlay: {args.text}\")\n captioner.add_text(args.text, **style_kwargs)\n\n if args.srt:\n print(f\"Importing SRT subtitles: {args.srt}\")\n captioner.import_srt(args.srt, **style_kwargs)\n\n if args.captions:\n print(f\"Importing JSON captions: {args.captions}\")\n captioner.import_captions_json(args.captions)\n\n # Preview frame\n if args.preview is not None:\n preview_path = args.output.replace('.mp4', '_preview.png')\n print(f\"Generating preview frame at {args.preview}s: {preview_path}\")\n captioner.preview_frame(args.preview, preview_path)\n print(f\"✓ Preview saved: {preview_path}\")\n return\n\n # Save video\n print(f\"Rendering video with captions...\")\n captioner.save(args.output)\n print(f\"✓ Saved: {args.output}\")\n\n finally:\n captioner.close()\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":16043,"content_sha256":"3c9e166411632487b53101141abcef02346f8123ff64329039967a1190f3b639"},{"filename":"scripts/video_clipper.py","content":"#!/usr/bin/env python3\n\"\"\"\nVideo Clipper - Cut and trim video segments.\n\"\"\"\n\nimport argparse\nfrom pathlib import Path\nfrom typing import List\n\nfrom moviepy.editor import VideoFileClip\n\n\nclass VideoClipper:\n \"\"\"Clip and trim videos.\"\"\"\n\n def __init__(self):\n \"\"\"Initialize clipper.\"\"\"\n self.clip = None\n\n def load(self, filepath: str) -> 'VideoClipper':\n \"\"\"Load video.\"\"\"\n self.clip = VideoFileClip(filepath)\n return self\n\n def extract_segment(self, start: float, end: float, output: str) -> str:\n \"\"\"Extract segment between start and end times.\"\"\"\n subclip = self.clip.subclip(start, end)\n subclip.write_videofile(output, logger=None)\n subclip.close()\n return output\n\n def split_by_duration(self, chunk_duration: float, output_dir: str) -> List[str]:\n \"\"\"Split video into chunks of specified duration.\"\"\"\n output_path = Path(output_dir)\n output_path.mkdir(parents=True, exist_ok=True)\n\n duration = self.clip.duration\n chunks = []\n\n i = 0\n current_time = 0\n\n while current_time \u003c duration:\n end_time = min(current_time + chunk_duration, duration)\n\n output_file = output_path / f\"chunk_{i:03d}.mp4\"\n subclip = self.clip.subclip(current_time, end_time)\n subclip.write_videofile(str(output_file), logger=None)\n subclip.close()\n\n chunks.append(str(output_file))\n current_time = end_time\n i += 1\n\n return chunks\n\n def trim(self, start: float = None, end: float = None, output: str = None) -> str:\n \"\"\"Trim start and/or end of video.\"\"\"\n start = start or 0\n end = end or self.clip.duration\n\n trimmed = self.clip.subclip(start, end)\n trimmed.write_videofile(output, logger=None)\n trimmed.close()\n\n return output\n\n def close(self):\n \"\"\"Close video clip.\"\"\"\n if self.clip:\n self.clip.close()\n\n\ndef parse_time(time_str: str) -> float:\n \"\"\"Parse time string (HH:MM:SS or seconds).\"\"\"\n if ':' in time_str:\n parts = time_str.split(':')\n if len(parts) == 3:\n h, m, s = map(float, parts)\n return h * 3600 + m * 60 + s\n elif len(parts) == 2:\n m, s = map(float, parts)\n return m * 60 + s\n return float(time_str)\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Video Clipper\")\n\n parser.add_argument(\"--input\", \"-i\", required=True, help=\"Input video\")\n parser.add_argument(\"--output\", \"-o\", required=True, help=\"Output file/directory\")\n\n parser.add_argument(\"--start\", help=\"Start time (HH:MM:SS or seconds)\")\n parser.add_argument(\"--end\", help=\"End time (HH:MM:SS or seconds)\")\n parser.add_argument(\"--split\", type=float, help=\"Split into chunks (duration in seconds)\")\n\n args = parser.parse_args()\n\n clipper = VideoClipper()\n clipper.load(args.input)\n\n if args.split:\n chunks = clipper.split_by_duration(args.split, args.output)\n print(f\"Split into {len(chunks)} chunks in {args.output}/\")\n\n elif args.start or args.end:\n start = parse_time(args.start) if args.start else 0\n end = parse_time(args.end) if args.end else clipper.clip.duration\n\n clipper.extract_segment(start, end, args.output)\n print(f\"Clip extracted ({args.start or '0'} - {args.end or 'end'}) → {args.output}\")\n\n clipper.close()\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3502,"content_sha256":"be42957ded775b09e5c3b3aaaedafb97c383f23a7546e99f9bc6a888429035fe"},{"filename":"scripts/video_metadata_inspector.py","content":"#!/usr/bin/env python3\n\"\"\"\nVideo Metadata Inspector - Extract comprehensive metadata from video files.\n\nFeatures:\n- Duration, resolution, frame rate, file size\n- Video/audio codec details\n- Format and container information\n- Batch processing\n- Export to JSON/CSV\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport sys\nfrom datetime import timedelta\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional\nimport pandas as pd\nfrom moviepy.editor import VideoFileClip\nimport ffmpeg\n\n\nclass VideoMetadataInspector:\n \"\"\"Extract and analyze video file metadata.\"\"\"\n\n def __init__(self):\n self.filepath = None\n self.clip = None\n self.probe_data = None\n\n def load(self, filepath: str) -> 'VideoMetadataInspector':\n \"\"\"\n Load video file.\n\n Args:\n filepath: Path to video file\n\n Returns:\n Self for method chaining\n \"\"\"\n if not os.path.exists(filepath):\n raise FileNotFoundError(f\"Video file not found: {filepath}\")\n\n self.filepath = filepath\n\n # Load with moviepy for basic info\n try:\n self.clip = VideoFileClip(filepath)\n except Exception as e:\n raise ValueError(f\"Failed to load video: {e}\")\n\n # Probe with ffprobe for detailed codec info\n try:\n self.probe_data = ffmpeg.probe(filepath)\n except Exception as e:\n print(f\"Warning: FFprobe failed: {e}\")\n self.probe_data = None\n\n return self\n\n def get_basic_info(self) -> Dict[str, Any]:\n \"\"\"\n Get basic video information.\n\n Returns:\n Dictionary with duration, resolution, FPS, file size\n \"\"\"\n if self.clip is None:\n raise ValueError(\"No video loaded. Call load() first.\")\n\n file_size = os.path.getsize(self.filepath)\n\n info = {\n 'filename': os.path.basename(self.filepath),\n 'filepath': self.filepath,\n 'duration_seconds': round(self.clip.duration, 2),\n 'duration_formatted': str(timedelta(seconds=int(self.clip.duration))),\n 'width': self.clip.w,\n 'height': self.clip.h,\n 'fps': round(self.clip.fps, 2),\n 'aspect_ratio': f\"{self.clip.w}:{self.clip.h}\",\n 'file_size_bytes': file_size,\n 'file_size_mb': round(file_size / (1024 * 1024), 2)\n }\n\n # Calculate simplified aspect ratio\n from math import gcd\n divisor = gcd(self.clip.w, self.clip.h)\n info['aspect_ratio_simple'] = f\"{self.clip.w//divisor}:{self.clip.h//divisor}\"\n\n # Resolution category\n if self.clip.w >= 7680:\n info['resolution_category'] = '8K'\n elif self.clip.w >= 3840:\n info['resolution_category'] = '4K'\n elif self.clip.w >= 2560:\n info['resolution_category'] = '2K'\n elif self.clip.w >= 1920:\n info['resolution_category'] = '1080p'\n elif self.clip.w >= 1280:\n info['resolution_category'] = '720p'\n elif self.clip.w >= 854:\n info['resolution_category'] = '480p'\n else:\n info['resolution_category'] = 'SD'\n\n return info\n\n def get_video_info(self) -> Dict[str, Any]:\n \"\"\"\n Get video stream information.\n\n Returns:\n Dictionary with video codec, bitrate, pixel format\n \"\"\"\n if self.probe_data is None:\n return {}\n\n # Find video stream\n video_stream = next((s for s in self.probe_data['streams']\n if s['codec_type'] == 'video'), None)\n\n if video_stream is None:\n return {}\n\n info = {\n 'video_codec': video_stream.get('codec_name', 'unknown'),\n 'video_codec_long': video_stream.get('codec_long_name', 'unknown'),\n 'profile': video_stream.get('profile', 'unknown'),\n 'pixel_format': video_stream.get('pix_fmt', 'unknown'),\n 'color_space': video_stream.get('color_space', 'unknown'),\n 'color_range': video_stream.get('color_range', 'unknown'),\n }\n\n # Bitrate\n if 'bit_rate' in video_stream:\n bitrate_bps = int(video_stream['bit_rate'])\n info['bitrate_kbps'] = round(bitrate_bps / 1000, 2)\n info['bitrate_mbps'] = round(bitrate_bps / 1_000_000, 2)\n else:\n info['bitrate_kbps'] = None\n info['bitrate_mbps'] = None\n\n # Level\n if 'level' in video_stream:\n info['level'] = video_stream['level']\n\n return info\n\n def get_audio_info(self) -> Dict[str, Any]:\n \"\"\"\n Get audio stream information.\n\n Returns:\n Dictionary with audio codec, sample rate, channels\n \"\"\"\n if self.probe_data is None:\n return {}\n\n # Find audio stream\n audio_stream = next((s for s in self.probe_data['streams']\n if s['codec_type'] == 'audio'), None)\n\n if audio_stream is None:\n return {'has_audio': False}\n\n info = {\n 'has_audio': True,\n 'audio_codec': audio_stream.get('codec_name', 'unknown'),\n 'audio_codec_long': audio_stream.get('codec_long_name', 'unknown'),\n 'sample_rate': int(audio_stream.get('sample_rate', 0)),\n 'channels': audio_stream.get('channels', 0),\n 'channel_layout': audio_stream.get('channel_layout', 'unknown'),\n }\n\n # Sample rate in kHz\n if info['sample_rate']:\n info['sample_rate_khz'] = round(info['sample_rate'] / 1000, 1)\n\n # Bitrate\n if 'bit_rate' in audio_stream:\n bitrate_bps = int(audio_stream['bit_rate'])\n info['audio_bitrate_kbps'] = round(bitrate_bps / 1000, 2)\n else:\n info['audio_bitrate_kbps'] = None\n\n return info\n\n def get_format_info(self) -> Dict[str, Any]:\n \"\"\"\n Get container format information.\n\n Returns:\n Dictionary with container type, creation date, metadata\n \"\"\"\n if self.probe_data is None:\n return {}\n\n format_data = self.probe_data.get('format', {})\n\n info = {\n 'container_format': format_data.get('format_name', 'unknown'),\n 'container_long_name': format_data.get('format_long_name', 'unknown'),\n 'num_streams': format_data.get('nb_streams', 0),\n }\n\n # Metadata tags\n tags = format_data.get('tags', {})\n if tags:\n info['metadata'] = {\n 'title': tags.get('title'),\n 'artist': tags.get('artist'),\n 'album': tags.get('album'),\n 'date': tags.get('date'),\n 'comment': tags.get('comment'),\n 'encoder': tags.get('encoder'),\n }\n # Remove None values\n info['metadata'] = {k: v for k, v in info['metadata'].items() if v}\n\n # Overall bitrate\n if 'bit_rate' in format_data:\n bitrate_bps = int(format_data['bit_rate'])\n info['overall_bitrate_kbps'] = round(bitrate_bps / 1000, 2)\n info['overall_bitrate_mbps'] = round(bitrate_bps / 1_000_000, 2)\n\n return info\n\n def get_metadata(self) -> Dict[str, Any]:\n \"\"\"\n Get all metadata combined.\n\n Returns:\n Dictionary with all metadata categories\n \"\"\"\n metadata = {}\n metadata.update(self.get_basic_info())\n metadata.update(self.get_video_info())\n metadata.update(self.get_audio_info())\n metadata.update(self.get_format_info())\n\n return metadata\n\n def export_report(self, output: str, format: str = 'json') -> str:\n \"\"\"\n Export metadata report.\n\n Args:\n output: Output filepath\n format: Export format ('json', 'text')\n\n Returns:\n Path to exported file\n \"\"\"\n metadata = self.get_metadata()\n\n os.makedirs(os.path.dirname(output) or '.', exist_ok=True)\n\n if format == 'json':\n with open(output, 'w') as f:\n json.dump(metadata, f, indent=2)\n\n elif format == 'text':\n with open(output, 'w') as f:\n f.write(f\"Video Metadata Report: {metadata['filename']}\\n\")\n f.write(\"=\" * 60 + \"\\n\\n\")\n\n f.write(\"BASIC INFORMATION\\n\")\n f.write(\"-\" * 60 + \"\\n\")\n f.write(f\"Duration: {metadata['duration_formatted']} ({metadata['duration_seconds']}s)\\n\")\n f.write(f\"Resolution: {metadata['width']}x{metadata['height']} ({metadata['resolution_category']})\\n\")\n f.write(f\"Aspect Ratio: {metadata['aspect_ratio_simple']}\\n\")\n f.write(f\"Frame Rate: {metadata['fps']} fps\\n\")\n f.write(f\"File Size: {metadata['file_size_mb']} MB\\n\\n\")\n\n if metadata.get('video_codec'):\n f.write(\"VIDEO STREAM\\n\")\n f.write(\"-\" * 60 + \"\\n\")\n f.write(f\"Codec: {metadata['video_codec']} ({metadata.get('video_codec_long', 'N/A')})\\n\")\n f.write(f\"Profile: {metadata.get('profile', 'N/A')}\\n\")\n f.write(f\"Pixel Format: {metadata.get('pixel_format', 'N/A')}\\n\")\n if metadata.get('bitrate_mbps'):\n f.write(f\"Bitrate: {metadata['bitrate_mbps']} Mbps\\n\")\n f.write(\"\\n\")\n\n if metadata.get('has_audio'):\n f.write(\"AUDIO STREAM\\n\")\n f.write(\"-\" * 60 + \"\\n\")\n f.write(f\"Codec: {metadata.get('audio_codec', 'N/A')}\\n\")\n f.write(f\"Sample Rate: {metadata.get('sample_rate_khz', 'N/A')} kHz\\n\")\n f.write(f\"Channels: {metadata.get('channels', 'N/A')} ({metadata.get('channel_layout', 'N/A')})\\n\")\n if metadata.get('audio_bitrate_kbps'):\n f.write(f\"Bitrate: {metadata['audio_bitrate_kbps']} kbps\\n\")\n f.write(\"\\n\")\n\n f.write(\"CONTAINER FORMAT\\n\")\n f.write(\"-\" * 60 + \"\\n\")\n f.write(f\"Format: {metadata.get('container_format', 'N/A')}\\n\")\n f.write(f\"Streams: {metadata.get('num_streams', 'N/A')}\\n\")\n if metadata.get('overall_bitrate_mbps'):\n f.write(f\"Overall Bitrate: {metadata['overall_bitrate_mbps']} Mbps\\n\")\n\n return output\n\n def batch_inspect(self, input_files: List[str], output: str = None,\n format: str = 'csv') -> pd.DataFrame:\n \"\"\"\n Inspect multiple video files.\n\n Args:\n input_files: List of video file paths\n output: Output filepath (optional)\n format: Export format ('csv', 'json')\n\n Returns:\n DataFrame with metadata for all files\n \"\"\"\n results = []\n\n for i, filepath in enumerate(input_files, 1):\n print(f\"Processing {i}/{len(input_files)}: {os.path.basename(filepath)}\")\n\n try:\n self.load(filepath)\n metadata = self.get_metadata()\n results.append(metadata)\n except Exception as e:\n print(f\" Error: {e}\")\n results.append({'filename': os.path.basename(filepath), 'error': str(e)})\n\n df = pd.DataFrame(results)\n\n if output:\n os.makedirs(os.path.dirname(output) or '.', exist_ok=True)\n if format == 'csv':\n df.to_csv(output, index=False)\n elif format == 'json':\n df.to_json(output, orient='records', indent=2)\n\n return df\n\n def compare_videos(self, video_files: List[str]) -> pd.DataFrame:\n \"\"\"\n Compare metadata of multiple videos side-by-side.\n\n Args:\n video_files: List of video file paths\n\n Returns:\n DataFrame with comparison\n \"\"\"\n df = self.batch_inspect(video_files)\n\n # Select key comparison columns\n compare_cols = [\n 'filename', 'duration_seconds', 'width', 'height', 'fps',\n 'file_size_mb', 'video_codec', 'bitrate_mbps',\n 'audio_codec', 'sample_rate_khz', 'channels'\n ]\n\n # Keep only columns that exist\n compare_cols = [c for c in compare_cols if c in df.columns]\n\n return df[compare_cols]\n\n def close(self):\n \"\"\"Release video resources.\"\"\"\n if self.clip:\n self.clip.close()\n self.clip = None\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Extract and analyze video file metadata',\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n # Basic metadata\n python video_metadata_inspector.py video.mp4\n\n # Export to JSON\n python video_metadata_inspector.py video.mp4 --output metadata.json --format json\n\n # Batch inspect directory\n python video_metadata_inspector.py *.mp4 --output metadata.csv --format csv\n\n # Compare videos\n python video_metadata_inspector.py video1.mp4 video2.mp4 --compare\n \"\"\"\n )\n\n parser.add_argument('input', nargs='+', help='Input video file(s)')\n parser.add_argument('--output', '-o', help='Output file')\n parser.add_argument('--format', '-f', choices=['json', 'csv', 'text'], default='text',\n help='Output format (default: text)')\n parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed metadata')\n parser.add_argument('--compare', '-c', action='store_true', help='Compare multiple videos')\n\n args = parser.parse_args()\n\n inspector = VideoMetadataInspector()\n\n try:\n # Single file mode\n if len(args.input) == 1 and not args.compare:\n filepath = args.input[0]\n inspector.load(filepath)\n\n if args.verbose:\n # Full metadata\n metadata = inspector.get_metadata()\n print(json.dumps(metadata, indent=2))\n else:\n # Basic summary\n basic = inspector.get_basic_info()\n video = inspector.get_video_info()\n audio = inspector.get_audio_info()\n\n print(f\"\\n{basic['filename']}\")\n print(\"=\" * 60)\n print(f\"Duration: {basic['duration_formatted']} ({basic['duration_seconds']}s)\")\n print(f\"Resolution: {basic['width']}x{basic['height']} ({basic['resolution_category']})\")\n print(f\"Frame Rate: {basic['fps']} fps\")\n print(f\"File Size: {basic['file_size_mb']} MB\")\n\n if video.get('video_codec'):\n print(f\"\\nVideo Codec: {video['video_codec']}\")\n if video.get('bitrate_mbps'):\n print(f\"Video Bitrate: {video['bitrate_mbps']} Mbps\")\n\n if audio.get('has_audio'):\n print(f\"\\nAudio Codec: {audio['audio_codec']}\")\n print(f\"Sample Rate: {audio.get('sample_rate_khz', 'N/A')} kHz\")\n print(f\"Channels: {audio['channels']} ({audio['channel_layout']})\")\n\n # Export if requested\n if args.output:\n inspector.export_report(args.output, format=args.format)\n print(f\"\\n✓ Exported to: {args.output}\")\n\n # Compare mode\n elif args.compare:\n print(f\"\\nComparing {len(args.input)} videos...\\n\")\n df = inspector.compare_videos(args.input)\n print(df.to_string(index=False))\n\n if args.output:\n if args.format == 'csv':\n df.to_csv(args.output, index=False)\n elif args.format == 'json':\n df.to_json(args.output, orient='records', indent=2)\n print(f\"\\n✓ Exported to: {args.output}\")\n\n # Batch mode\n else:\n print(f\"\\nInspecting {len(args.input)} videos...\\n\")\n df = inspector.batch_inspect(args.input, output=args.output, format=args.format)\n\n # Print summary\n print(f\"\\nSummary:\")\n print(f\" Total files: {len(df)}\")\n if 'error' not in df.columns:\n print(f\" Total duration: {df['duration_seconds'].sum():.0f}s\")\n print(f\" Total size: {df['file_size_mb'].sum():.2f} MB\")\n print(f\" Resolutions: {df['resolution_category'].value_counts().to_dict()}\")\n\n if args.output:\n print(f\"\\n✓ Exported to: {args.output}\")\n\n finally:\n inspector.close()\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":16679,"content_sha256":"2ac663487af8ebe5b276780b6d8cfcdba3567e6e83775ae589bd9284c9be2aaa"},{"filename":"scripts/video_thumbnail_extractor.py","content":"#!/usr/bin/env python3\n\"\"\"\nVideo Thumbnail Extractor - Extract frames from videos.\n\"\"\"\n\nimport argparse\nfrom pathlib import Path\nfrom typing import List, Tuple\n\nfrom moviepy.editor import VideoFileClip\nfrom PIL import Image\nimport numpy as np\n\n\nclass VideoThumbnailExtractor:\n \"\"\"Extract thumbnails from videos.\"\"\"\n\n def __init__(self):\n \"\"\"Initialize extractor.\"\"\"\n self.clip = None\n\n def load(self, filepath: str) -> 'VideoThumbnailExtractor':\n \"\"\"Load video.\"\"\"\n self.clip = VideoFileClip(filepath)\n return self\n\n def extract_at_time(self, time: float, output: str) -> str:\n \"\"\"Extract frame at specific time (seconds).\"\"\"\n frame = self.clip.get_frame(time)\n img = Image.fromarray(frame)\n img.save(output)\n return output\n\n def extract_interval(self, interval: float, output_dir: str) -> List[str]:\n \"\"\"Extract frames at regular intervals.\"\"\"\n output_path = Path(output_dir)\n output_path.mkdir(parents=True, exist_ok=True)\n\n duration = self.clip.duration\n times = np.arange(0, duration, interval)\n\n frames = []\n for i, t in enumerate(times):\n frame = self.clip.get_frame(t)\n output_file = output_path / f\"frame_{i:04d}.jpg\"\n img = Image.fromarray(frame)\n img.save(str(output_file))\n frames.append(str(output_file))\n\n return frames\n\n def create_grid(self, grid: Tuple[int, int], output: str) -> str:\n \"\"\"Create thumbnail grid preview.\"\"\"\n cols, rows = grid\n n_thumbs = cols * rows\n\n duration = self.clip.duration\n times = np.linspace(0, duration * 0.95, n_thumbs)\n\n # Get first frame to determine size\n first_frame = self.clip.get_frame(times[0])\n h, w = first_frame.shape[:2]\n\n # Calculate thumbnail size\n thumb_w = w // 4\n thumb_h = h // 4\n\n # Create grid image\n grid_w = cols * thumb_w\n grid_h = rows * thumb_h\n grid_img = Image.new('RGB', (grid_w, grid_h))\n\n for i, t in enumerate(times):\n row = i // cols\n col = i % cols\n\n frame = self.clip.get_frame(t)\n thumb = Image.fromarray(frame).resize((thumb_w, thumb_h))\n\n x = col * thumb_w\n y = row * thumb_h\n grid_img.paste(thumb, (x, y))\n\n grid_img.save(output)\n return output\n\n def close(self):\n \"\"\"Close video clip.\"\"\"\n if self.clip:\n self.clip.close()\n\n\ndef parse_time(time_str: str) -> float:\n \"\"\"Parse time string (HH:MM:SS or seconds).\"\"\"\n if ':' in time_str:\n parts = time_str.split(':')\n if len(parts) == 3:\n h, m, s = map(float, parts)\n return h * 3600 + m * 60 + s\n elif len(parts) == 2:\n m, s = map(float, parts)\n return m * 60 + s\n return float(time_str)\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Video Thumbnail Extractor\")\n\n parser.add_argument(\"--input\", \"-i\", required=True, help=\"Input video\")\n parser.add_argument(\"--output\", \"-o\", required=True, help=\"Output file/directory\")\n\n group = parser.add_mutually_exclusive_group()\n group.add_argument(\"--time\", help=\"Extract at time (HH:MM:SS or seconds)\")\n group.add_argument(\"--interval\", type=float, help=\"Interval in seconds\")\n group.add_argument(\"--grid\", help=\"Grid size (e.g., 4x4)\")\n\n args = parser.parse_args()\n\n extractor = VideoThumbnailExtractor()\n extractor.load(args.input)\n\n if args.time:\n time_sec = parse_time(args.time)\n extractor.extract_at_time(time_sec, args.output)\n print(f\"Frame extracted at {args.time} → {args.output}\")\n\n elif args.interval:\n frames = extractor.extract_interval(args.interval, args.output)\n print(f\"Extracted {len(frames)} frames to {args.output}/\")\n\n elif args.grid:\n cols, rows = map(int, args.grid.split('x'))\n extractor.create_grid((cols, rows), args.output)\n print(f\"Grid preview ({args.grid}) → {args.output}\")\n\n extractor.close()\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4158,"content_sha256":"85b003a79d62fef48e53a9bacd54d6e86357c38b055ccefc446e18e1586a8b39"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Media Toolkit","type":"text"}]},{"type":"paragraph","content":[{"text":"Use this suite for practical audio and video operations that were previously spread across many narrow skills.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Included Tools","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Audio: ","type":"text"},{"text":"audio_analyzer.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"audio_converter.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"audio_normalizer.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"audio_trimmer.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"podcast_splitter.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sfx_generator.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Video: ","type":"text"},{"text":"video_captioner.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"video_clipper.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"video_metadata_inspector.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"video_thumbnail_extractor.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"gif_workshop.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"thumbnail_gen.py","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"timelapse_creator.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Workflow","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Identify the medium, input format, and target output.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pick the narrowest script that completes the job.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep transformations explicit: clip times, output codec, target size, caption source, or thumbnail cadence.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify output duration, dimensions, and bitrate-sensitive settings when the request depends on platform limits.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Guardrails","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Avoid recompression churn when a simple trim or extract is enough.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Call out when generated captions or “best frame” choices are heuristic.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"media-toolkit","author":"@skillopedia","source":{"stars":60,"repo_name":"chatgpt-skills","origin_url":"https://github.com/dkyazzentwatwa/chatgpt-skills/blob/HEAD/media-toolkit/SKILL.md","repo_owner":"dkyazzentwatwa","body_sha256":"b318a77df75323f195e08bb53d6f92ac6898c4165937d5eb172d750aa6f0e322","cluster_key":"8890af3e3f84ff36fa918ed1c0318310efda350b231ffe0cda45c73efa1a6ae5","clean_bundle":{"format":"clean-skill-bundle-v1","source":"dkyazzentwatwa/chatgpt-skills/media-toolkit/SKILL.md","attachments":[{"id":"4cdbaa80-6c6b-5977-a487-383f822c12f5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4cdbaa80-6c6b-5977-a487-383f822c12f5/attachment.yaml","path":"agents/openai.yaml","size":180,"sha256":"1acfbd5a862b537c33d76bbc0e0923d7c68edeca7a83eb4822c6a740de121a5f","contentType":"application/yaml; charset=utf-8"},{"id":"5b343306-3f51-5a33-aa10-44a546b9a84a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5b343306-3f51-5a33-aa10-44a546b9a84a/attachment.py","path":"scripts/audio_analyzer.py","size":23695,"sha256":"eec243ff72fc4f541ac30cacfd115b8edf825d4dc186031a5781b2ee74a2f2ba","contentType":"text/x-python; charset=utf-8"},{"id":"ef2f2455-3866-5eea-b7d0-3127eef7c7b2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ef2f2455-3866-5eea-b7d0-3127eef7c7b2/attachment.py","path":"scripts/audio_converter.py","size":10371,"sha256":"d0823c14427a2bcf7ceb07b0243ab547ee4f04e69e562bbfba614d0c4a932e15","contentType":"text/x-python; charset=utf-8"},{"id":"54124f4f-1823-5ed4-8d71-ea8a95c78822","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/54124f4f-1823-5ed4-8d71-ea8a95c78822/attachment.py","path":"scripts/audio_normalizer.py","size":11109,"sha256":"fc4146230976db26de6e6838f1863c92f85ed33074de8845d7163f6de0507095","contentType":"text/x-python; charset=utf-8"},{"id":"94f21ac8-3ea1-5fdc-879d-7f878045accd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/94f21ac8-3ea1-5fdc-879d-7f878045accd/attachment.py","path":"scripts/audio_trimmer.py","size":17249,"sha256":"a4f9315ab68af21978cd3884508842709ae105849ca14e0a21f1b1d8ca76ee86","contentType":"text/x-python; charset=utf-8"},{"id":"91f43c87-ab77-510d-a800-5fe4c0661a5c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/91f43c87-ab77-510d-a800-5fe4c0661a5c/attachment.py","path":"scripts/gif_workshop.py","size":22805,"sha256":"87f0dff44d484f3691b33e893410f8cacbb51a4a7317baafc5cc4817446276bc","contentType":"text/x-python; charset=utf-8"},{"id":"1774b2f7-bd59-5947-97ae-83100fce849a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1774b2f7-bd59-5947-97ae-83100fce849a/attachment.py","path":"scripts/podcast_splitter.py","size":15488,"sha256":"8ce929d8bdee7a06510e8bb17ef1bc8ccde51522cecf8dda0a3c611cf7b3a987","contentType":"text/x-python; charset=utf-8"},{"id":"48044b16-115a-553b-b5c9-da193ab5678b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/48044b16-115a-553b-b5c9-da193ab5678b/attachment.txt","path":"scripts/requirements.txt","size":190,"sha256":"3edf4004942da260a82ee88b42ad9195be8f8808923d4bd528b864fbbb99e98d","contentType":"text/plain; charset=utf-8"},{"id":"c5951aaa-ee4e-5543-8616-19ef946cb2bf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c5951aaa-ee4e-5543-8616-19ef946cb2bf/attachment.py","path":"scripts/sfx_generator.py","size":13780,"sha256":"b40d3201dff7f92e7d96b8c2bfeaeff81f6d65a03d660c630704c9b96b764ed6","contentType":"text/x-python; charset=utf-8"},{"id":"d209013a-0ee6-524e-9d67-00c4c1b3475a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d209013a-0ee6-524e-9d67-00c4c1b3475a/attachment.py","path":"scripts/thumbnail_gen.py","size":17869,"sha256":"c39648d9b59638e973390fdd0e1ce32879d992b99ed770971d58d39fb5df2feb","contentType":"text/x-python; charset=utf-8"},{"id":"be519967-2983-5fa4-9b34-a83245649fdd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/be519967-2983-5fa4-9b34-a83245649fdd/attachment.py","path":"scripts/timelapse_creator.py","size":1869,"sha256":"f24354a012cb9d50e997ab0be1921a638276c8182fef94c47d7ca50f9246a966","contentType":"text/x-python; charset=utf-8"},{"id":"2ac69688-e0de-5e83-a83a-1c034d9b8126","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2ac69688-e0de-5e83-a83a-1c034d9b8126/attachment.py","path":"scripts/video_captioner.py","size":16043,"sha256":"3c9e166411632487b53101141abcef02346f8123ff64329039967a1190f3b639","contentType":"text/x-python; charset=utf-8"},{"id":"9bc72ed3-62de-542e-814f-d42b208cbe7c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9bc72ed3-62de-542e-814f-d42b208cbe7c/attachment.py","path":"scripts/video_clipper.py","size":3502,"sha256":"be42957ded775b09e5c3b3aaaedafb97c383f23a7546e99f9bc6a888429035fe","contentType":"text/x-python; charset=utf-8"},{"id":"738aba97-2913-58f1-86fe-101228a0a7ac","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/738aba97-2913-58f1-86fe-101228a0a7ac/attachment.py","path":"scripts/video_metadata_inspector.py","size":16679,"sha256":"2ac663487af8ebe5b276780b6d8cfcdba3567e6e83775ae589bd9284c9be2aaa","contentType":"text/x-python; charset=utf-8"},{"id":"e8ec0d82-137f-52c9-abb4-03a6cac3cd0f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e8ec0d82-137f-52c9-abb4-03a6cac3cd0f/attachment.py","path":"scripts/video_thumbnail_extractor.py","size":4158,"sha256":"85b003a79d62fef48e53a9bacd54d6e86357c38b055ccefc446e18e1586a8b39","contentType":"text/x-python; charset=utf-8"}],"bundle_sha256":"15f28b99e8c55990abb077cf825252fbf4c0ecff751aa6a43232d78c7f36d042","attachment_count":15,"text_attachments":15,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"media-toolkit/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"media-content","category_label":"Media"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"media-content","import_tag":"clean-skills-v1","description":"Process audio and video with clipping, conversion, analysis, captions, thumbnails, GIFs, and batch utilities. Use for practical media manipulation workflows."}},"renderedAt":1782980918026}

Media Toolkit Use this suite for practical audio and video operations that were previously spread across many narrow skills. Included Tools - Audio: , , , , , - Video: , , , , , , Workflow 1. Identify the medium, input format, and target output. 2. Pick the narrowest script that completes the job. 3. Keep transformations explicit: clip times, output codec, target size, caption source, or thumbnail cadence. 4. Verify output duration, dimensions, and bitrate-sensitive settings when the request depends on platform limits. Guardrails - Avoid recompression churn when a simple trim or extract is en…