Media Utilities Internal utilities for media assembly. Used by producer skills. These scripts wrap FFmpeg to provide reliable media operations. Prerequisites - FFmpeg must be installed: (macOS) or (Linux) - Check with: Available Utilities audio concat.py Concatenate multiple audio files into one. audio mix.py Mix voice/narration with background music (with optional ducking). video concat.py Concatenate multiple video clips. video audio merge.py Add audio track(s) to video. video strip audio.py Remove audio from video files (for replacing with custom audio). check ffmpeg.py Verify FFmpeg insta…

, md_content, re.MULTILINE)\n title = h1_match.group(1) if h1_match else \"Report\"\n \n # Embed images as base64\n md_content = embed_images_as_base64(md_content, input_file.parent)\n \n # Convert markdown to HTML\n extensions = ['tables', 'fenced_code', 'toc', 'nl2br']\n md = markdown.Markdown(extensions=extensions)\n html_body = md.convert(md_content)\n \n # Add TOC if requested\n toc_html = \"\"\n if include_toc and hasattr(md, 'toc'):\n toc_html = f'\u003cdiv class=\"toc\">\u003ch2>Table of Contents\u003c/h2>{md.toc}\u003c/div>'\n \n # Get CSS\n css_content = get_style_css(style, title)\n css = CSS(string=css_content)\n \n # Add generated date\n generated_date = datetime.now().strftime(\"%B %d, %Y\")\n \n # Full HTML document\n html_content = f'''\n \u003c!DOCTYPE html>\n \u003chtml>\n \u003chead>\n \u003cmeta charset=\"utf-8\">\n \u003ctitle>{title}\u003c/title>\n \u003c/head>\n \u003cbody>\n {toc_html}\n {html_body}\n \u003cfooter style=\"margin-top: 40pt; padding-top: 12pt; border-top: 1px solid #e0e0e0; font-size: 9pt; color: #666;\">\n Generated on {generated_date}\n \u003c/footer>\n \u003c/body>\n \u003c/html>\n '''\n \n # Generate PDF\n HTML(string=html_content).write_pdf(output_file, stylesheets=[css])\n \n print(f\"✓ PDF generated: {output_file}\")\n print(f\" Style: {style}\")\n print(f\" Size: {output_file.stat().st_size / 1024:.1f} KB\")\n \n return str(output_file)\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Convert Markdown reports to professional PDF documents',\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nStyles:\n business Clean, professional look (default)\n executive Executive summary style with larger fonts\n technical Technical documentation style\n minimal Minimal styling, maximum content\n\nExamples:\n # Basic conversion\n python report_to_pdf.py -i analysis.md -o analysis.pdf\n \n # With custom title and executive style\n python report_to_pdf.py -i report.md -o report.pdf --title \"Q4 Market Analysis\" --style executive\n \n # Technical documentation\n python report_to_pdf.py -i docs.md -o docs.pdf --style technical --toc\n \"\"\"\n )\n \n parser.add_argument('--input', '-i', required=True,\n help='Input Markdown file path')\n parser.add_argument('--output', '-o',\n help='Output PDF file path (default: same name as input with .pdf)')\n parser.add_argument('--title', '-t',\n help='Document title (default: extracted from first H1)')\n parser.add_argument('--style', '-s', default='business',\n choices=['business', 'executive', 'technical', 'minimal'],\n help='PDF style theme (default: business)')\n parser.add_argument('--toc', action='store_true',\n help='Include table of contents')\n \n args = parser.parse_args()\n \n # Check dependencies\n if not check_dependencies():\n sys.exit(1)\n \n # Default output path\n if not args.output:\n input_path = Path(args.input)\n args.output = str(input_path.with_suffix('.pdf'))\n \n # Convert\n markdown_to_pdf(args.input, args.output, args.title, args.style, args.toc)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14823,"content_sha256":"4c3c4950c274cb3758a18d55d7f2409fde4a6fd41a49dd5198148b749ecd3a3e"},{"filename":"scripts/video_audio_merge.py","content":"#!/usr/bin/env python3\n\"\"\"\nMerge audio track(s) with video.\nCan replace video audio or mix with existing.\n\"\"\"\n\nimport argparse\nimport subprocess\nimport sys\nimport os\nfrom pathlib import Path\nfrom datetime import datetime\n\n\ndef merge_video_audio(\n video_file: str,\n audio_file: str,\n output_file: str = None,\n replace_audio: bool = True,\n audio_volume: float = 1.0,\n video_volume: float = 0.0,\n sync_offset: float = 0\n) -> dict:\n \"\"\"Merge audio track with video.\n \n Args:\n video_file: Path to video file\n audio_file: Path to audio file to add\n output_file: Output file path (auto-generated if None)\n replace_audio: If True, replace video audio. If False, mix with it.\n audio_volume: Volume of the audio track (0.0-1.0)\n video_volume: Volume of original video audio (0.0-1.0, only used if not replacing)\n sync_offset: Audio offset in seconds (positive = delay audio)\n \n Returns:\n dict with success/error and output file path\n \"\"\"\n # Validate inputs\n if not os.path.exists(video_file):\n return {\"error\": f\"Video file not found: {video_file}\"}\n if not os.path.exists(audio_file):\n return {\"error\": f\"Audio file not found: {audio_file}\"}\n \n # Generate output filename if not provided\n if output_file is None:\n timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n output_file = f\"video_merged_{timestamp}.mp4\"\n \n try:\n if replace_audio:\n # Simple replacement - just use the new audio\n cmd = [\n \"ffmpeg\",\n \"-y\",\n \"-i\", video_file,\n \"-i\", audio_file,\n \"-c:v\", \"copy\", # Don't re-encode video\n \"-map\", \"0:v:0\", # Use video from first input\n \"-map\", \"1:a:0\", # Use audio from second input\n \"-c:a\", \"aac\",\n \"-b:a\", \"192k\",\n \"-shortest\", # End when shortest stream ends\n ]\n \n if sync_offset != 0:\n # Add audio delay/advance\n if sync_offset > 0:\n cmd.extend([\"-itsoffset\", str(sync_offset)])\n else:\n # Negative offset - trim beginning of audio\n cmd.extend([\"-ss\", str(abs(sync_offset))])\n \n cmd.append(output_file)\n \n else:\n # Mix audio with original video audio\n filter_parts = []\n \n # Adjust volumes\n if video_volume != 1.0:\n filter_parts.append(f\"[0:a]volume={video_volume}[va]\")\n video_audio_label = \"[va]\"\n else:\n video_audio_label = \"[0:a]\"\n \n if audio_volume != 1.0:\n filter_parts.append(f\"[1:a]volume={audio_volume}[aa]\")\n new_audio_label = \"[aa]\"\n else:\n new_audio_label = \"[1:a]\"\n \n # Handle sync offset\n if sync_offset != 0:\n if sync_offset > 0:\n filter_parts.append(f\"[1:a]adelay={int(sync_offset*1000)}|{int(sync_offset*1000)}[delayed]\")\n new_audio_label = \"[delayed]\"\n # Negative offset would need different handling\n \n # Mix the two audio streams\n filter_parts.append(f\"{video_audio_label}{new_audio_label}amix=inputs=2:duration=first[mixed]\")\n \n filter_complex = \";\".join(filter_parts)\n \n cmd = [\n \"ffmpeg\",\n \"-y\",\n \"-i\", video_file,\n \"-i\", audio_file,\n \"-filter_complex\", filter_complex,\n \"-map\", \"0:v:0\",\n \"-map\", \"[mixed]\",\n \"-c:v\", \"copy\",\n \"-c:a\", \"aac\",\n \"-b:a\", \"192k\",\n output_file\n ]\n \n proc = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=600\n )\n \n if proc.returncode != 0:\n return {\"error\": f\"FFmpeg error: {proc.stderr[-500:]}\"}\n \n return {\n \"success\": True,\n \"file\": output_file,\n \"video_file\": video_file,\n \"audio_file\": audio_file,\n \"replaced_audio\": replace_audio,\n \"sync_offset\": sync_offset\n }\n \n except Exception as e:\n return {\"error\": f\"Merge failed: {e}\"}\n\n\ndef add_multiple_audio_tracks(\n video_file: str,\n voice_file: str = None,\n music_file: str = None,\n output_file: str = None,\n voice_volume: float = 1.0,\n music_volume: float = 0.3,\n duck_music: bool = True\n) -> dict:\n \"\"\"Add voice and music tracks to video (common use case).\n \n Args:\n video_file: Path to video file\n voice_file: Path to voice/narration audio\n music_file: Path to background music\n output_file: Output file path\n voice_volume: Volume for voice track\n music_volume: Volume for music track\n duck_music: Whether to duck music when voice is present\n \n Returns:\n dict with success/error and output file path\n \"\"\"\n if not os.path.exists(video_file):\n return {\"error\": f\"Video file not found: {video_file}\"}\n \n # Generate output filename if not provided\n if output_file is None:\n timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n output_file = f\"video_with_audio_{timestamp}.mp4\"\n \n try:\n inputs = [\"-i\", video_file]\n filter_parts = []\n audio_streams = []\n \n # Add voice track\n if voice_file and os.path.exists(voice_file):\n inputs.extend([\"-i\", voice_file])\n voice_idx = len(inputs) // 2 - 1\n if voice_volume != 1.0:\n filter_parts.append(f\"[{voice_idx}:a]volume={voice_volume}[voice]\")\n audio_streams.append(\"[voice]\")\n else:\n audio_streams.append(f\"[{voice_idx}:a]\")\n \n # Add music track\n if music_file and os.path.exists(music_file):\n inputs.extend([\"-i\", music_file])\n music_idx = len(inputs) // 2 - 1\n \n music_filters = [f\"volume={music_volume}\"]\n \n # Loop music if needed and trim to video length\n video_duration = _get_duration(video_file)\n if video_duration:\n music_filters.append(f\"aloop=loop=-1:size=2e+09\")\n music_filters.append(f\"atrim=0:{video_duration}\")\n \n filter_parts.append(f\"[{music_idx}:a]{','.join(music_filters)}[music_adj]\")\n \n if duck_music and voice_file:\n # Use sidechaincompress for ducking\n voice_label = audio_streams[-1]\n filter_parts.append(\n f\"[music_adj]{voice_label}sidechaincompress=threshold=0.02:ratio=6:attack=50:release=400[music_ducked]\"\n )\n audio_streams.append(\"[music_ducked]\")\n else:\n audio_streams.append(\"[music_adj]\")\n \n if not audio_streams:\n return {\"error\": \"No audio files provided\"}\n \n # Mix all audio streams\n if len(audio_streams) > 1:\n stream_labels = \"\".join(audio_streams)\n filter_parts.append(f\"{stream_labels}amix=inputs={len(audio_streams)}:duration=first[final_audio]\")\n output_audio = \"[final_audio]\"\n else:\n output_audio = audio_streams[0]\n \n filter_complex = \";\".join(filter_parts) if filter_parts else None\n \n cmd = [\n \"ffmpeg\",\n \"-y\",\n *inputs,\n ]\n \n if filter_complex:\n cmd.extend([\"-filter_complex\", filter_complex])\n cmd.extend([\"-map\", \"0:v:0\", \"-map\", output_audio])\n else:\n cmd.extend([\"-map\", \"0:v:0\", \"-map\", \"1:a:0\"])\n \n cmd.extend([\n \"-c:v\", \"copy\",\n \"-c:a\", \"aac\",\n \"-b:a\", \"192k\",\n \"-shortest\",\n output_file\n ])\n \n proc = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=600\n )\n \n if proc.returncode != 0:\n return {\"error\": f\"FFmpeg error: {proc.stderr[-500:]}\"}\n \n return {\n \"success\": True,\n \"file\": output_file,\n \"voice_file\": voice_file,\n \"music_file\": music_file,\n \"duck_music\": duck_music\n }\n \n except Exception as e:\n return {\"error\": f\"Failed to add audio: {e}\"}\n\n\ndef _get_duration(file_path: str) -> float:\n \"\"\"Get duration of media file in seconds.\"\"\"\n try:\n proc = subprocess.run(\n [\n \"ffprobe\",\n \"-v\", \"quiet\",\n \"-show_entries\", \"format=duration\",\n \"-of\", \"default=noprint_wrappers=1:nokey=1\",\n file_path\n ],\n capture_output=True,\n text=True,\n timeout=30\n )\n if proc.returncode == 0:\n return float(proc.stdout.strip())\n except:\n pass\n return None\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Merge audio with video\",\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n # Replace video audio with new audio\n python video_audio_merge.py --video clip.mp4 --audio voiceover.mp3 -o final.mp4\n \n # Mix new audio with existing video audio\n python video_audio_merge.py --video clip.mp4 --audio music.mp3 --mix\n \n # Add voice + music with ducking\n python video_audio_merge.py --video clip.mp4 --voice narration.wav --music bg.mp3\n \n # Sync offset (delay audio by 0.5 seconds)\n python video_audio_merge.py --video clip.mp4 --audio audio.mp3 --offset 0.5\n \"\"\"\n )\n \n parser.add_argument(\"--video\", \"-v\", required=True,\n help=\"Video file\")\n parser.add_argument(\"--audio\", \"-a\",\n help=\"Audio file to merge\")\n parser.add_argument(\"--voice\",\n help=\"Voice/narration audio file\")\n parser.add_argument(\"--music\", \"-m\",\n help=\"Background music file\")\n parser.add_argument(\"--output\", \"-o\",\n help=\"Output file path\")\n parser.add_argument(\"--mix\", action=\"store_true\",\n help=\"Mix with existing video audio instead of replacing\")\n parser.add_argument(\"--audio-volume\", type=float, default=1.0,\n help=\"Volume for audio track (0.0-1.0)\")\n parser.add_argument(\"--music-volume\", type=float, default=0.3,\n help=\"Volume for music track (0.0-1.0)\")\n parser.add_argument(\"--no-duck\", action=\"store_true\",\n help=\"Disable music ducking under voice\")\n parser.add_argument(\"--offset\", type=float, default=0,\n help=\"Audio sync offset in seconds\")\n \n args = parser.parse_args()\n \n if args.voice or args.music:\n # Multi-track mode\n print(f\"🎬 Adding audio tracks to video...\")\n result = add_multiple_audio_tracks(\n args.video,\n args.voice,\n args.music,\n args.output,\n args.audio_volume,\n args.music_volume,\n not args.no_duck\n )\n elif args.audio:\n # Simple merge mode\n print(f\"🎬 Merging audio with video...\")\n result = merge_video_audio(\n args.video,\n args.audio,\n args.output,\n not args.mix,\n args.audio_volume,\n 0.5 if args.mix else 0,\n args.offset\n )\n else:\n print(\"Error: Provide --audio or --voice/--music\", file=sys.stderr)\n sys.exit(1)\n \n if \"error\" in result:\n print(f\"❌ Error: {result['error']}\", file=sys.stderr)\n sys.exit(1)\n else:\n print(f\"✅ Created: {result['file']}\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12209,"content_sha256":"640a6336f79beb6158f7541e4b3672f6e8fcfc254579e9d7c538cbde26073510"},{"filename":"scripts/video_concat.py","content":"#!/usr/bin/env python3\n\"\"\"\nConcatenate multiple video files into one.\nHandles resolution/format differences with re-encoding.\n\"\"\"\n\nimport argparse\nimport subprocess\nimport sys\nimport tempfile\nimport os\nfrom pathlib import Path\nfrom datetime import datetime\n\n\ndef concat_video(\n input_files: list,\n output_file: str = None,\n transition: str = None,\n transition_duration: float = 0.5,\n resolution: str = None,\n fps: int = None\n) -> dict:\n \"\"\"Concatenate multiple video files.\n \n Args:\n input_files: List of paths to video files\n output_file: Output file path (auto-generated if None)\n transition: Transition type ('fade', 'dissolve', None)\n transition_duration: Duration of transition in seconds\n resolution: Target resolution ('1080p', '720p', '4k', or 'WxH')\n fps: Target frame rate\n \n Returns:\n dict with success/error and output file path\n \"\"\"\n if not input_files:\n return {\"error\": \"No input files provided\"}\n \n # Validate input files exist\n for f in input_files:\n if not os.path.exists(f):\n return {\"error\": f\"Input file not found: {f}\"}\n \n # Generate output filename if not provided\n if output_file is None:\n timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n output_file = f\"video_concat_{timestamp}.mp4\"\n \n # Parse resolution\n scale_filter = None\n if resolution:\n res_map = {\n \"4k\": \"3840:2160\",\n \"1080p\": \"1920:1080\",\n \"720p\": \"1280:720\",\n \"480p\": \"854:480\"\n }\n scale = res_map.get(resolution.lower(), resolution)\n scale_filter = f\"scale={scale}:force_original_aspect_ratio=decrease,pad={scale}:(ow-iw)/2:(oh-ih)/2\"\n \n try:\n if transition:\n return _concat_with_transition(\n input_files, output_file, transition, \n transition_duration, scale_filter, fps\n )\n else:\n return _concat_simple(input_files, output_file, scale_filter, fps)\n \n except Exception as e:\n return {\"error\": f\"Concatenation failed: {e}\"}\n\n\ndef _concat_simple(input_files: list, output_file: str, \n scale_filter: str, fps: int) -> dict:\n \"\"\"Simple concatenation, re-encoding to ensure compatibility.\"\"\"\n \n # Check if all files have audio\n all_have_audio = all(_has_audio(f) for f in input_files)\n \n # Build filter for each input\n filter_parts = []\n for i in range(len(input_files)):\n filters = []\n if scale_filter:\n filters.append(scale_filter)\n if fps:\n filters.append(f\"fps={fps}\")\n \n if filters:\n filter_str = \",\".join(filters)\n filter_parts.append(f\"[{i}:v]{filter_str}[v{i}]\")\n else:\n filter_parts.append(f\"[{i}:v]null[v{i}]\")\n \n # Audio - only if all files have audio\n if all_have_audio:\n filter_parts.append(f\"[{i}:a]anull[a{i}]\")\n \n # Concat all streams\n video_inputs = \"\".join(f\"[v{i}]\" for i in range(len(input_files)))\n filter_parts.append(f\"{video_inputs}concat=n={len(input_files)}:v=1:a=0[outv]\")\n \n if all_have_audio:\n audio_inputs = \"\".join(f\"[a{i}]\" for i in range(len(input_files)))\n filter_parts.append(f\"{audio_inputs}concat=n={len(input_files)}:v=0:a=1[outa]\")\n \n filter_complex = \";\".join(filter_parts)\n \n # Build inputs\n inputs = []\n for f in input_files:\n inputs.extend([\"-i\", f])\n \n # Build map arguments\n map_args = [\"-map\", \"[outv]\"]\n if all_have_audio:\n map_args.extend([\"-map\", \"[outa]\"])\n \n cmd = [\n \"ffmpeg\",\n \"-y\",\n *inputs,\n \"-filter_complex\", filter_complex,\n *map_args,\n \"-c:v\", \"libx264\",\n \"-preset\", \"medium\",\n \"-crf\", \"23\",\n ]\n \n # Only add audio codec if we have audio\n if all_have_audio:\n cmd.extend([\"-c:a\", \"aac\", \"-b:a\", \"192k\"])\n \n cmd.append(output_file)\n \n proc = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=600 # 10 minute timeout\n )\n \n if proc.returncode != 0:\n return {\"error\": f\"FFmpeg error: {proc.stderr[-500:]}\"}\n \n return {\n \"success\": True,\n \"file\": output_file,\n \"files_concatenated\": len(input_files),\n \"has_audio\": all_have_audio\n }\n\n\ndef _has_audio(file_path: str) -> bool:\n \"\"\"Check if a video file has an audio stream.\"\"\"\n try:\n result = subprocess.run(\n [\"ffprobe\", \"-v\", \"quiet\", \"-select_streams\", \"a\",\n \"-show_entries\", \"stream=codec_type\", \"-of\", \"csv=p=0\", file_path],\n capture_output=True, text=True\n )\n return bool(result.stdout.strip())\n except Exception:\n return False\n\n\ndef _concat_with_transition(input_files: list, output_file: str,\n transition: str, duration: float,\n scale_filter: str, fps: int) -> dict:\n \"\"\"Concatenation with transitions between clips.\"\"\"\n \n n = len(input_files)\n \n # Check if any files have audio\n has_audio = all(_has_audio(f) for f in input_files)\n \n # Build inputs\n inputs = []\n for f in input_files:\n inputs.extend([\"-i\", f])\n \n # Build complex filter with xfade\n filter_parts = []\n \n # First, normalize all inputs\n for i in range(n):\n filters = []\n if scale_filter:\n filters.append(scale_filter)\n if fps:\n filters.append(f\"fps={fps}\")\n filters.append(\"format=yuv420p\")\n \n filter_str = \",\".join(filters)\n filter_parts.append(f\"[{i}:v]{filter_str}[v{i}]\")\n \n # Apply transitions between clips\n # Get durations of each clip to calculate offset\n durations = []\n for f in input_files:\n dur = _get_duration(f)\n if dur is None:\n return {\"error\": f\"Could not get duration of {f}\"}\n durations.append(dur)\n \n # Build xfade chain\n current_label = \"[v0]\"\n offset = durations[0] - duration\n \n for i in range(1, n):\n next_label = f\"[v{i}]\"\n out_label = f\"[xf{i}]\" if i \u003c n - 1 else \"[outv]\"\n \n # xfade transition\n filter_parts.append(\n f\"{current_label}{next_label}xfade=transition={transition}:duration={duration}:offset={offset}{out_label}\"\n )\n \n current_label = out_label\n offset += durations[i] - duration\n \n # Audio crossfade (only if all files have audio)\n if has_audio:\n current_audio = \"[0:a]\"\n audio_offset = durations[0] - duration\n \n for i in range(1, n):\n next_audio = f\"[{i}:a]\"\n out_audio = f\"[xa{i}]\" if i \u003c n - 1 else \"[outa]\"\n \n filter_parts.append(\n f\"{current_audio}{next_audio}acrossfade=d={duration}{out_audio}\"\n )\n \n current_audio = out_audio\n \n filter_complex = \";\".join(filter_parts)\n \n # Build command\n map_args = [\"-map\", \"[outv]\"]\n if has_audio:\n map_args.extend([\"-map\", \"[outa]\"])\n \n cmd = [\n \"ffmpeg\",\n \"-y\",\n *inputs,\n \"-filter_complex\", filter_complex,\n *map_args,\n \"-c:v\", \"libx264\",\n \"-preset\", \"medium\",\n \"-crf\", \"23\",\n \"-c:a\", \"aac\",\n \"-b:a\", \"192k\",\n output_file\n ]\n \n proc = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=600\n )\n \n if proc.returncode != 0:\n return {\"error\": f\"FFmpeg error: {proc.stderr[-500:]}\"}\n \n return {\n \"success\": True,\n \"file\": output_file,\n \"files_concatenated\": len(input_files),\n \"transition\": transition,\n \"transition_duration\": duration\n }\n\n\ndef _get_duration(file_path: str) -> float:\n \"\"\"Get duration of video file in seconds.\"\"\"\n try:\n proc = subprocess.run(\n [\n \"ffprobe\",\n \"-v\", \"quiet\",\n \"-show_entries\", \"format=duration\",\n \"-of\", \"default=noprint_wrappers=1:nokey=1\",\n file_path\n ],\n capture_output=True,\n text=True,\n timeout=30\n )\n if proc.returncode == 0:\n return float(proc.stdout.strip())\n except:\n pass\n return None\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Concatenate video files\",\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n # Simple concatenation\n python video_concat.py -i clip1.mp4 clip2.mp4 clip3.mp4 -o final.mp4\n \n # With fade transition\n python video_concat.py -i *.mp4 -o final.mp4 --transition fade --duration 1.0\n \n # Normalize to 1080p\n python video_concat.py -i *.mp4 -o final.mp4 --resolution 1080p\n \nTransition types: fade, dissolve, wipeleft, wiperight, slideup, slidedown\n \"\"\"\n )\n \n parser.add_argument(\"-i\", \"--input\", nargs=\"+\", required=True,\n help=\"Input video files to concatenate (in order)\")\n parser.add_argument(\"-o\", \"--output\",\n help=\"Output file path\")\n parser.add_argument(\"--transition\", \"-t\",\n choices=[\"fade\", \"dissolve\", \"wipeleft\", \"wiperight\", \n \"slideup\", \"slidedown\", \"circlecrop\"],\n help=\"Transition type between clips\")\n parser.add_argument(\"--duration\", \"-d\", type=float, default=0.5,\n help=\"Transition duration in seconds (default: 0.5)\")\n parser.add_argument(\"--resolution\", \"-r\",\n help=\"Target resolution (1080p, 720p, 4k, or WxH)\")\n parser.add_argument(\"--fps\", type=int,\n help=\"Target frame rate\")\n \n args = parser.parse_args()\n \n print(f\"🎬 Concatenating {len(args.input)} video files...\")\n \n result = concat_video(\n args.input,\n args.output,\n args.transition,\n args.duration,\n args.resolution,\n args.fps\n )\n \n if \"error\" in result:\n print(f\"❌ Error: {result['error']}\", file=sys.stderr)\n sys.exit(1)\n else:\n print(f\"✅ Created: {result['file']}\")\n print(f\" Files: {result['files_concatenated']}\")\n if result.get(\"transition\"):\n print(f\" Transition: {result['transition']} ({result['transition_duration']}s)\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10572,"content_sha256":"0a669a057c7e2b3f17d15623d3db4cea0c5ce41fee27d42dcd9d1c14c7e4fc31"},{"filename":"scripts/video_strip_audio.py","content":"#!/usr/bin/env python3\n\"\"\"\nStrip audio from video files.\nUseful for replacing with custom voiceover/music.\n\"\"\"\n\nimport argparse\nimport subprocess\nimport sys\nimport os\nfrom pathlib import Path\nfrom datetime import datetime\n\n\ndef strip_audio(\n input_file: str,\n output_file: str = None,\n copy_video: bool = True\n) -> dict:\n \"\"\"Remove audio track from a video file.\n \n Args:\n input_file: Path to input video file\n output_file: Output file path (auto-generated if None)\n copy_video: If True, copy video stream without re-encoding (faster)\n \n Returns:\n dict with success/error and output file path\n \"\"\"\n if not os.path.exists(input_file):\n return {\"error\": f\"Input file not found: {input_file}\"}\n \n # Generate output filename if not provided\n if output_file is None:\n input_path = Path(input_file)\n output_file = str(input_path.parent / f\"silent_{input_path.name}\")\n \n try:\n if copy_video:\n # Fast path: just copy video, no re-encoding\n cmd = [\n \"ffmpeg\",\n \"-y\",\n \"-i\", input_file,\n \"-an\", # Remove audio\n \"-c:v\", \"copy\", # Copy video without re-encoding\n output_file\n ]\n else:\n # Re-encode video (slower but more compatible)\n cmd = [\n \"ffmpeg\",\n \"-y\",\n \"-i\", input_file,\n \"-an\",\n \"-c:v\", \"libx264\",\n \"-preset\", \"medium\",\n \"-crf\", \"23\",\n output_file\n ]\n \n proc = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=300\n )\n \n if proc.returncode != 0:\n return {\"error\": f\"FFmpeg error: {proc.stderr[-500:]}\"}\n \n return {\n \"success\": True,\n \"file\": output_file,\n \"input\": input_file,\n \"reencoded\": not copy_video\n }\n \n except Exception as e:\n return {\"error\": f\"Strip audio failed: {e}\"}\n\n\ndef strip_audio_batch(\n input_files: list,\n output_dir: str = None,\n prefix: str = \"silent_\",\n copy_video: bool = True\n) -> dict:\n \"\"\"Strip audio from multiple video files.\n \n Args:\n input_files: List of input video file paths\n output_dir: Output directory (same as input if None)\n prefix: Prefix for output filenames\n copy_video: If True, copy video stream without re-encoding\n \n Returns:\n dict with results for each file\n \"\"\"\n if not input_files:\n return {\"error\": \"No input files provided\"}\n \n results = []\n successful = 0\n failed = 0\n \n for input_file in input_files:\n if not os.path.exists(input_file):\n results.append({\n \"input\": input_file,\n \"error\": \"File not found\"\n })\n failed += 1\n continue\n \n input_path = Path(input_file)\n \n if output_dir:\n out_path = Path(output_dir) / f\"{prefix}{input_path.name}\"\n else:\n out_path = input_path.parent / f\"{prefix}{input_path.name}\"\n \n result = strip_audio(input_file, str(out_path), copy_video)\n results.append(result)\n \n if result.get(\"success\"):\n successful += 1\n else:\n failed += 1\n \n return {\n \"success\": failed == 0,\n \"results\": results,\n \"successful\": successful,\n \"failed\": failed,\n \"files\": [r.get(\"file\") for r in results if r.get(\"success\")]\n }\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Strip audio from video files\",\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=\"\"\"\nExamples:\n # Strip audio from single file\n python video_strip_audio.py -i video.mp4 -o silent_video.mp4\n \n # Strip audio from multiple files\n python video_strip_audio.py -i clip1.mp4 clip2.mp4 clip3.mp4\n \n # Strip audio and re-encode video\n python video_strip_audio.py -i video.mp4 --reencode\n \n # Batch process with custom output directory\n python video_strip_audio.py -i *.mp4 --output-dir ./silent/\n \"\"\"\n )\n \n parser.add_argument(\"-i\", \"--input\", nargs=\"+\", required=True,\n help=\"Input video file(s)\")\n parser.add_argument(\"-o\", \"--output\",\n help=\"Output file path (single file mode)\")\n parser.add_argument(\"--output-dir\",\n help=\"Output directory (batch mode)\")\n parser.add_argument(\"--prefix\", default=\"silent_\",\n help=\"Prefix for output filenames (default: 'silent_')\")\n parser.add_argument(\"--reencode\", action=\"store_true\",\n help=\"Re-encode video instead of copying\")\n \n args = parser.parse_args()\n \n if len(args.input) == 1 and args.output:\n # Single file mode\n print(f\"🔇 Stripping audio from {args.input[0]}...\")\n result = strip_audio(args.input[0], args.output, not args.reencode)\n \n if \"error\" in result:\n print(f\"❌ Error: {result['error']}\", file=sys.stderr)\n sys.exit(1)\n else:\n print(f\"✅ Created: {result['file']}\")\n else:\n # Batch mode\n print(f\"🔇 Stripping audio from {len(args.input)} files...\")\n result = strip_audio_batch(\n args.input, \n args.output_dir, \n args.prefix,\n not args.reencode\n )\n \n if result.get(\"failed\", 0) > 0:\n print(f\"⚠️ {result['successful']} succeeded, {result['failed']} failed\")\n for r in result[\"results\"]:\n if \"error\" in r:\n print(f\" ❌ {r.get('input', 'unknown')}: {r['error']}\")\n sys.exit(1)\n else:\n print(f\"✅ Processed {result['successful']} files:\")\n for f in result[\"files\"]:\n print(f\" {f}\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":6123,"content_sha256":"d97415b1b937df61b4e747f5f709a2f9f094e6d3ed721eb2370bb4b6db299988"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Media Utilities","type":"text"}]},{"type":"paragraph","content":[{"text":"Internal utilities for media assembly. Used by producer skills.","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"These scripts wrap FFmpeg to provide reliable media operations.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Prerequisites","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"FFmpeg","type":"text","marks":[{"type":"strong"}]},{"text":" must be installed: ","type":"text"},{"text":"brew install ffmpeg","type":"text","marks":[{"type":"code_inline"}]},{"text":" (macOS) or ","type":"text"},{"text":"apt install ffmpeg","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Linux)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check with: ","type":"text"},{"text":"python3 check_ffmpeg.py","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Available Utilities","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"audio_concat.py","type":"text"}]},{"type":"paragraph","content":[{"text":"Concatenate multiple audio files into one.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Simple concatenation\npython3 audio_concat.py -i intro.wav segment1.wav outro.wav -o podcast.mp3\n\n# With crossfade between clips\npython3 audio_concat.py -i track1.wav track2.wav --crossfade 2.0\n\n# With normalization\npython3 audio_concat.py -i *.wav -o mixed.mp3 --normalize","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"audio_mix.py","type":"text"}]},{"type":"paragraph","content":[{"text":"Mix voice/narration with background music (with optional ducking).","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Voice + music with ducking (music lowers when voice plays)\npython3 audio_mix.py --voice narration.wav --music background.mp3 -o final.mp3\n\n# Adjust music volume (default: 0.3)\npython3 audio_mix.py --voice voice.wav --music music.mp3 --music-volume 0.2\n\n# No ducking\npython3 audio_mix.py --voice voice.wav --music music.mp3 --no-duck\n\n# With fade in/out on music\npython3 audio_mix.py --voice voice.wav --music music.mp3 --fade-in 2 --fade-out 3","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"video_concat.py","type":"text"}]},{"type":"paragraph","content":[{"text":"Concatenate multiple video clips.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Simple concatenation\npython3 video_concat.py -i clip1.mp4 clip2.mp4 clip3.mp4 -o final.mp4\n\n# With fade transition\npython3 video_concat.py -i *.mp4 -o final.mp4 --transition fade --duration 1.0\n\n# Normalize to 1080p\npython3 video_concat.py -i *.mp4 -o final.mp4 --resolution 1080p\n\n# Available transitions: fade, dissolve, wipeleft, wiperight, slideup, slidedown","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"video_audio_merge.py","type":"text"}]},{"type":"paragraph","content":[{"text":"Add audio track(s) to video.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Replace video audio\npython3 video_audio_merge.py --video clip.mp4 --audio voiceover.mp3 -o final.mp4\n\n# Add voice + music with ducking\npython3 video_audio_merge.py --video clip.mp4 --voice narration.wav --music bg.mp3\n\n# Mix with existing video audio\npython3 video_audio_merge.py --video clip.mp4 --audio music.mp3 --mix\n\n# Audio sync offset\npython3 video_audio_merge.py --video clip.mp4 --audio audio.mp3 --offset 0.5","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"video_strip_audio.py","type":"text"}]},{"type":"paragraph","content":[{"text":"Remove audio from video files (for replacing with custom audio).","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Strip audio from single file\npython3 video_strip_audio.py -i video.mp4 -o silent_video.mp4\n\n# Strip audio from multiple files (batch mode)\npython3 video_strip_audio.py -i clip1.mp4 clip2.mp4 clip3.mp4\n\n# Strip with custom output directory\npython3 video_strip_audio.py -i *.mp4 --output-dir ./silent/\n\n# Re-encode video instead of copying\npython3 video_strip_audio.py -i video.mp4 --reencode","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"check_ffmpeg.py","type":"text"}]},{"type":"paragraph","content":[{"text":"Verify FFmpeg installation.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 check_ffmpeg.py\n# ✅ FFmpeg is available!\n# ffmpeg version 6.0 ...","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"report_to_pdf.py","type":"text"}]},{"type":"paragraph","content":[{"text":"Convert Markdown reports to professional PDF documents.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Basic conversion\npython3 report_to_pdf.py -i analysis.md -o analysis.pdf\n\n# With custom title and executive style\npython3 report_to_pdf.py -i report.md -o report.pdf --title \"Q4 Market Analysis\" --style executive\n\n# Technical documentation with table of contents\npython3 report_to_pdf.py -i docs.md -o docs.pdf --style technical --toc","type":"text"}]},{"type":"paragraph","content":[{"text":"Available styles:","type":"text","marks":[{"type":"strong"}]}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Style","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"business","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Clean, professional (default)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"executive","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Executive summary with larger fonts","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"technical","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Technical documentation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"minimal","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Minimal styling, maximum content","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Requires:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"pip install markdown weasyprint","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Usage by Producer Skills","type":"text"}]},{"type":"paragraph","content":[{"text":"These utilities are called by the producer skills to assemble final outputs:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"python"},"content":[{"text":"from pathlib import Path\nimport subprocess\nimport sys\n\n# Get path to media-utils\nUTILS_PATH = Path(__file__).parent.parent.parent / \"media-utils\" / \"scripts\"\n\ndef concat_audio(files: list, output: str):\n cmd = [\n sys.executable,\n str(UTILS_PATH / \"audio_concat.py\"),\n \"-i\", *files,\n \"-o\", output\n ]\n subprocess.run(cmd, check=True)","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Output Formats","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Utility","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default Output","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Options","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"audio_concat","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Inherits from input","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"audio_mix","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP3","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"video_concat","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP4 (H.264)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP4","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"video_audio_merge","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP4 (H.264)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP4","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"video_strip_audio","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP4 (copy)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP4","type":"text"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"media-utils","author":"@skillopedia","source":{"stars":13,"repo_name":"skills","origin_url":"https://github.com/michaelboeding/skills/blob/HEAD/skills/media-utils/SKILL.md","repo_owner":"michaelboeding","body_sha256":"4a53f1216ff3285eb655b4282481f15161e5811319a7a0b3a1209c0b6ef61281","cluster_key":"f8813f7ad838c40ec1700fb4666f47a8ff1075592a54930861dc57dd147a73a8","clean_bundle":{"format":"clean-skill-bundle-v1","source":"michaelboeding/skills/skills/media-utils/SKILL.md","attachments":[{"id":"0e609048-a123-5b64-b340-83a83ccc12e8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0e609048-a123-5b64-b340-83a83ccc12e8/attachment.py","path":"scripts/audio_concat.py","size":7022,"sha256":"1e19ba77fe24fc84b075b949d364bdb0ef422ebdd878be0f8e9a2e6a0d2fe688","contentType":"text/x-python; charset=utf-8"},{"id":"239817e3-0576-57fd-a2ca-942e8e63e84a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/239817e3-0576-57fd-a2ca-942e8e63e84a/attachment.py","path":"scripts/audio_mix.py","size":9885,"sha256":"6e153d64fc4100c9e97648a2c4ff8f824695df6e9082a8b5d21344191dcd8b0b","contentType":"text/x-python; charset=utf-8"},{"id":"ec2daca0-1ac2-541f-a507-4c55420e7b87","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ec2daca0-1ac2-541f-a507-4c55420e7b87/attachment.py","path":"scripts/check_ffmpeg.py","size":4447,"sha256":"75e6796202c2c4685910f3fd81d89c8f851a676bcd6996175014c6efcdddd5be","contentType":"text/x-python; charset=utf-8"},{"id":"a6eea94a-457f-57fa-bcff-13a56484ce42","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a6eea94a-457f-57fa-bcff-13a56484ce42/attachment.py","path":"scripts/report_to_pdf.py","size":14823,"sha256":"4c3c4950c274cb3758a18d55d7f2409fde4a6fd41a49dd5198148b749ecd3a3e","contentType":"text/x-python; charset=utf-8"},{"id":"8970966d-e931-531e-9e20-720b9adbe938","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8970966d-e931-531e-9e20-720b9adbe938/attachment.py","path":"scripts/video_audio_merge.py","size":12209,"sha256":"640a6336f79beb6158f7541e4b3672f6e8fcfc254579e9d7c538cbde26073510","contentType":"text/x-python; charset=utf-8"},{"id":"2246b141-8d33-5cc4-98d2-3c2931ef2b93","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2246b141-8d33-5cc4-98d2-3c2931ef2b93/attachment.py","path":"scripts/video_concat.py","size":10572,"sha256":"0a669a057c7e2b3f17d15623d3db4cea0c5ce41fee27d42dcd9d1c14c7e4fc31","contentType":"text/x-python; charset=utf-8"},{"id":"0eb78375-323d-5a44-a597-5b47e4026fdc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0eb78375-323d-5a44-a597-5b47e4026fdc/attachment.py","path":"scripts/video_strip_audio.py","size":6123,"sha256":"d97415b1b937df61b4e747f5f709a2f9f094e6d3ed721eb2370bb4b6db299988","contentType":"text/x-python; charset=utf-8"}],"bundle_sha256":"f2205a8d06de3d25a5f64a1eee930b9629c5ed2a6284b34dccf04a10b2c332b8","attachment_count":7,"text_attachments":7,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/media-utils/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":"Internal utility skill for media assembly operations. NOT called directly by users. Used by producer skills (video-producer, podcast-producer, audio-producer, social-producer) to stitch, mix, and assemble final media outputs.\n"}},"renderedAt":1782980150160}

Media Utilities Internal utilities for media assembly. Used by producer skills. These scripts wrap FFmpeg to provide reliable media operations. Prerequisites - FFmpeg must be installed: (macOS) or (Linux) - Check with: Available Utilities audio concat.py Concatenate multiple audio files into one. audio mix.py Mix voice/narration with background music (with optional ducking). video concat.py Concatenate multiple video clips. video audio merge.py Add audio track(s) to video. video strip audio.py Remove audio from video files (for replacing with custom audio). check ffmpeg.py Verify FFmpeg insta…