"""Split an audio/video file into overlapping chunks and export MP4 video. Features -------- - Trim by --start and --end. - Split by estimated maximum file size with overlap. - Export MP4 chunks with a video track. - If --image is given, the image is used as a still video track. - If --image is omitted and the input has a video stream, the input video is used. Requirements ------------ ffmpeg and ffprobe must be installed and available from PATH, or specify them with --ffmpeg and --ffprobe. ex: python split_audio_with_overlap_mp4_video.py input.mp3 --image cover.png --start 00:10:00 --end 00:25:00 python split_audio_with_overlap_mp4_video.py input.mp4 --start 00:10:00 --end 00:25:00 """ from __future__ import annotations import argparse import json import os import re import shutil import subprocess import sys from pathlib import Path # ====== Default settings ====== DEFAULT_OUTPUT_DIR = "split_fixed" DEFAULT_AUDIO_BITRATE = "128k" DEFAULT_VIDEO_BITRATE = "300k" DEFAULT_MAX_SIZE_MB = 150.0 DEFAULT_OVERLAP_SEC = 60.0 DEFAULT_WIDTH = 1280 DEFAULT_HEIGHT = 720 DEFAULT_FPS = 30 VIDEO_EXTENSIONS = { ".mp4", ".m4v", ".mov", ".mkv", ".avi", ".webm", ".wmv", } def parse_time_to_sec(value: str | float | int | None) -> float | None: """Convert time text to seconds. Accepted forms: 90 -> 90 seconds 90.5 -> 90.5 seconds 01:30 -> 1 minute 30 seconds 01:02:03 -> 1 hour 2 minutes 3 seconds 01:02:03.5 -> 1 hour 2 minutes 3.5 seconds """ if value is None or value == "": return None text = str(value).strip() if re.fullmatch(r"\d+(\.\d+)?", text): return float(text) parts = text.split(":") if len(parts) not in (2, 3): raise argparse.ArgumentTypeError( f"時刻指定が不正です: {text} 例: 90, 01:30, 01:02:03" ) try: nums = [float(p) for p in parts] except ValueError as exc: raise argparse.ArgumentTypeError( f"時刻指定が不正です: {text} 例: 90, 01:30, 01:02:03" ) from exc if len(nums) == 2: hours = 0.0 minutes, seconds = nums else: hours, minutes, seconds = nums if hours < 0 or minutes < 0 or seconds < 0 or minutes >= 60 or seconds >= 60: raise argparse.ArgumentTypeError( f"時刻指定が不正です: {text} 分・秒は 0 以上 60 未満で指定してください。" ) return hours * 3600 + minutes * 60 + seconds def parse_bitrate_kbps(bitrate: str | int | float) -> float: """Convert bitrate such as '128k' or '1.2M' to kbps.""" text = str(bitrate).strip().lower() multiplier = 1.0 if text.endswith("k"): text = text[:-1] elif text.endswith("m"): text = text[:-1] multiplier = 1000.0 try: kbps = float(text) * multiplier except ValueError as exc: raise argparse.ArgumentTypeError( f"ビットレートが不正です: {bitrate} 例: 128k, 1M" ) from exc if kbps <= 0: raise argparse.ArgumentTypeError("ビットレートは正の値にしてください。") return kbps def ffmpeg_time(seconds: float) -> str: """Format seconds for ffmpeg options.""" return f"{seconds:.3f}" def check_executable(path_or_name: str, label: str) -> str: """Return executable path if available, otherwise exit with a clear error.""" resolved = shutil.which(path_or_name) if os.path.basename(path_or_name) == path_or_name else path_or_name if resolved and os.path.exists(resolved): return resolved print(f"エラー: {label} が見つかりません: {path_or_name}") print("ffmpeg をインストールして PATH に追加するか、--ffmpeg / --ffprobe で場所を指定してください。") sys.exit(1) def run_command(cmd: list[str]) -> subprocess.CompletedProcess[str]: """Run a command and raise a readable error on failure.""" result = subprocess.run( cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if result.returncode != 0: print("\nエラー: ffmpeg/ffprobe の実行に失敗しました。") print("実行コマンド:") print(" ".join(f'"{c}"' if " " in c else c for c in cmd)) print("\n--- stderr ---") print(result.stderr) sys.exit(result.returncode) return result def ffprobe_json(ffprobe: str, input_file: str) -> dict: """Read media metadata using ffprobe.""" cmd = [ ffprobe, "-v", "error", "-show_format", "-show_streams", "-of", "json", input_file, ] result = run_command(cmd) return json.loads(result.stdout) def get_duration_sec(metadata: dict) -> float: """Get duration from ffprobe metadata.""" fmt = metadata.get("format", {}) duration = fmt.get("duration") if duration is not None: return float(duration) durations = [] for stream in metadata.get("streams", []): if stream.get("duration") is not None: durations.append(float(stream["duration"])) if durations: return max(durations) print("エラー: 入力ファイルの長さを取得できませんでした。") sys.exit(1) def has_stream(metadata: dict, codec_type: str) -> bool: """Return True if a stream of codec_type exists.""" return any(s.get("codec_type") == codec_type for s in metadata.get("streams", [])) def build_video_filter(width: int, height: int) -> str: """Scale and pad video/image to a fixed canvas.""" return ( f"scale={width}:{height}:force_original_aspect_ratio=decrease," f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2," "format=yuv420p" ) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "音声または動画を、指定範囲だけ切り出し、" "サイズ上限+オーバーラップ付きでMP4動画として分割出力します。" ), formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument("input_file", help="入力音声/動画ファイル") parser.add_argument( "--image", default=None, help=( "MP4の映像として使う静止画像。" "音声ファイルから画像付きMP4を作る場合は指定してください。" ), ) parser.add_argument( "--output-dir", default=DEFAULT_OUTPUT_DIR, help="出力ディレクトリ。相対パスの場合は入力ファイルと同じ場所を基準にします。", ) parser.add_argument( "--audio-bitrate", "--bitrate", dest="audio_bitrate", default=DEFAULT_AUDIO_BITRATE, help="音声ビットレート", ) parser.add_argument( "--video-bitrate", default=DEFAULT_VIDEO_BITRATE, help="映像ビットレート。静止画なら 200k〜500k 程度で十分です。", ) parser.add_argument( "--max-size-mb", type=float, default=DEFAULT_MAX_SIZE_MB, help="チャンク最大サイズ MB。音声+映像ビットレートから概算します。", ) parser.add_argument( "--overlap-sec", type=float, default=DEFAULT_OVERLAP_SEC, help="オーバーラップ時間 秒", ) parser.add_argument( "--start", type=parse_time_to_sec, default=0.0, help="切り出し開始時刻。秒、MM:SS、HH:MM:SS で指定できます。", ) parser.add_argument( "--end", type=parse_time_to_sec, default=None, help="切り出し終了時刻。省略時は末尾まで。秒、MM:SS、HH:MM:SS で指定できます。", ) parser.add_argument("--width", type=int, default=DEFAULT_WIDTH, help="出力動画の幅") parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT, help="出力動画の高さ") parser.add_argument("--fps", type=int, default=DEFAULT_FPS, help="出力動画のフレームレート") parser.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg 実行ファイル") parser.add_argument("--ffprobe", default="ffprobe", help="ffprobe 実行ファイル") parser.add_argument( "--pause", action="store_true", help="処理終了後にEnter待ちします。Windowsでダブルクリック実行する場合に使います。", ) return parser def validate_args(args: argparse.Namespace, metadata: dict, duration_sec: float) -> tuple[float, float, bool]: """Validate arguments and return start, end, use_source_video.""" input_file = args.input_file if not os.path.isfile(input_file): print(f"エラー: ファイルが見つかりません: {input_file}") sys.exit(1) if args.image is not None and not os.path.isfile(args.image): print(f"エラー: 画像ファイルが見つかりません: {args.image}") sys.exit(1) if not has_stream(metadata, "audio"): print("エラー: 入力ファイルに音声ストリームが見つかりません。") sys.exit(1) input_has_video = has_stream(metadata, "video") use_source_video = args.image is None and input_has_video if args.image is None and not input_has_video: print("エラー: 音声ファイルから画像付きMP4を作るには --image を指定してください。") print("例: python split_audio_with_overlap_mp4_video.py input.mp3 --image cover.png") sys.exit(1) if args.max_size_mb <= 0: print("エラー: --max-size-mb は正の値にしてください。") sys.exit(1) if args.overlap_sec < 0: print("エラー: --overlap-sec は 0 以上にしてください。") sys.exit(1) if args.width <= 0 or args.height <= 0: print("エラー: --width と --height は正の値にしてください。") sys.exit(1) if args.fps <= 0: print("エラー: --fps は正の値にしてください。") sys.exit(1) start_sec = args.start or 0.0 end_sec = args.end if args.end is not None else duration_sec if start_sec < 0: print("エラー: --start は 0 以上にしてください。") sys.exit(1) if start_sec >= duration_sec: print(f"エラー: --start が入力ファイルの長さを超えています: {start_sec:.3f}秒") sys.exit(1) if end_sec > duration_sec: print( f"警告: --end が入力ファイルの長さを超えています。" f" {end_sec:.3f}秒 -> {duration_sec:.3f}秒 に丸めます。" ) end_sec = duration_sec if end_sec <= start_sec: print("エラー: --end は --start より後の時刻にしてください。") sys.exit(1) return start_sec, end_sec, use_source_video def make_output_dir(input_file: str, output_dir_arg: str) -> str: """Create output directory and return its absolute path.""" input_dir = os.path.dirname(os.path.abspath(input_file)) output_dir = output_dir_arg if not os.path.isabs(output_dir): output_dir = os.path.join(input_dir, output_dir) os.makedirs(output_dir, exist_ok=True) return output_dir def export_mp4_with_image( *, ffmpeg: str, input_file: str, image_file: str, out_path: str, start_sec: float, duration_sec: float, audio_bitrate: str, video_bitrate: str, width: int, height: int, fps: int, ) -> None: """Export one MP4 chunk using a still image as the video track.""" vf = build_video_filter(width, height) cmd = [ ffmpeg, "-y", "-loop", "1", "-framerate", str(fps), "-i", image_file, "-ss", ffmpeg_time(start_sec), "-t", ffmpeg_time(duration_sec), "-i", input_file, "-map", "0:v:0", "-map", "1:a:0", "-vf", vf, "-r", str(fps), "-c:v", "libx264", "-tune", "stillimage", "-b:v", video_bitrate, "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", audio_bitrate, "-shortest", "-movflags", "+faststart", out_path, ] run_command(cmd) def export_mp4_with_source_video( *, ffmpeg: str, input_file: str, out_path: str, start_sec: float, duration_sec: float, audio_bitrate: str, video_bitrate: str, width: int, height: int, fps: int, ) -> None: """Export one MP4 chunk using the source video's video stream.""" vf = build_video_filter(width, height) cmd = [ ffmpeg, "-y", "-ss", ffmpeg_time(start_sec), "-t", ffmpeg_time(duration_sec), "-i", input_file, "-map", "0:v:0", "-map", "0:a:0", "-vf", vf, "-r", str(fps), "-c:v", "libx264", "-b:v", video_bitrate, "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", audio_bitrate, "-movflags", "+faststart", out_path, ] run_command(cmd) def main() -> None: parser = build_parser() args = parser.parse_args() ffmpeg = check_executable(args.ffmpeg, "ffmpeg") ffprobe = check_executable(args.ffprobe, "ffprobe") if not os.path.isfile(args.input_file): print(f"エラー: ファイルが見つかりません: {args.input_file}") sys.exit(1) metadata = ffprobe_json(ffprobe, args.input_file) original_duration_sec = get_duration_sec(metadata) start_sec, end_sec, use_source_video = validate_args(args, metadata, original_duration_sec) selected_duration_sec = end_sec - start_sec audio_kbps = parse_bitrate_kbps(args.audio_bitrate) video_kbps = parse_bitrate_kbps(args.video_bitrate) total_kbps = audio_kbps + video_kbps bytes_per_sec = total_kbps * 1000.0 / 8.0 max_bytes = args.max_size_mb * 1024.0 * 1024.0 max_chunk_sec = max_bytes / bytes_per_sec step_sec = max_chunk_sec - args.overlap_sec if step_sec <= 0: print( "エラー: オーバーラップ時間が推定チャンク長以上です。\n" f"推定チャンク長: {max_chunk_sec:.3f}秒, " f"オーバーラップ: {args.overlap_sec:.3f}秒\n" "--overlap-sec を小さくするか、--max-size-mb を大きくしてください。" ) sys.exit(1) output_dir = make_output_dir(args.input_file, args.output_dir) filebody = Path(args.input_file).stem print("\n入力ファイル:", args.input_file) if args.image: print("使用する画像:", args.image) elif use_source_video: print("使用する映像: 入力動画の映像ストリーム") print("出力ディレクトリ:", output_dir) print("出力形式: mp4 video") print(f"音声ビットレート: {args.audio_bitrate}") print(f"映像ビットレート: {args.video_bitrate}") print(f"出力サイズ: {args.width} x {args.height}, {args.fps} fps") print(f"最大サイズ: {args.max_size_mb} MB") print(f"オーバーラップ長: {args.overlap_sec} 秒") print(f"元の長さ: {original_duration_sec:.3f} 秒") print(f"切り出し範囲: {start_sec:.3f} 秒 〜 {end_sec:.3f} 秒") print(f"切り出し後の長さ: {selected_duration_sec:.3f} 秒") print(f"推定チャンク長: 約 {max_chunk_sec:.3f} 秒") print(f"ステップ: {step_sec:.3f} 秒") print("\n分割開始") chunk_index = 0 chunk_start_in_range = 0.0 while chunk_start_in_range < selected_duration_sec: chunk_end_in_range = min(chunk_start_in_range + max_chunk_sec, selected_duration_sec) chunk_duration = chunk_end_in_range - chunk_start_in_range original_chunk_start = start_sec + chunk_start_in_range original_chunk_end = start_sec + chunk_end_in_range out_path = os.path.join(output_dir, f"{filebody}_{chunk_index:03}.mp4") print( f"> チャンク {chunk_index + 1}: " f"元ファイル上 {original_chunk_start:.3f}秒 〜 {original_chunk_end:.3f}秒" ) if args.image: export_mp4_with_image( ffmpeg=ffmpeg, input_file=args.input_file, image_file=args.image, out_path=out_path, start_sec=original_chunk_start, duration_sec=chunk_duration, audio_bitrate=args.audio_bitrate, video_bitrate=args.video_bitrate, width=args.width, height=args.height, fps=args.fps, ) else: export_mp4_with_source_video( ffmpeg=ffmpeg, input_file=args.input_file, out_path=out_path, start_sec=original_chunk_start, duration_sec=chunk_duration, audio_bitrate=args.audio_bitrate, video_bitrate=args.video_bitrate, width=args.width, height=args.height, fps=args.fps, ) print(f"> 保存: {out_path}({chunk_duration:.3f} 秒)") if chunk_end_in_range >= selected_duration_sec: break chunk_start_in_range += step_sec chunk_index += 1 print("\n✅ 指定範囲を、画像/映像付きMP4としてオーバーラップ分割しました。") if args.pause: input("\nPress ENTER to terminate>>\n") if __name__ == "__main__": main()