ffmpeg-skill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env python3
2
+ """Shared helpers for ffmpeg-skill scripts.
3
+
4
+ Standard library only. Locates ffmpeg/ffprobe on PATH, runs them with clear
5
+ error reporting, and provides a compact media probe used by every script.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import platform
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from fractions import Fraction
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Sequence
18
+
19
+ INSTALL_HINTS = {
20
+ "Darwin": " brew install ffmpeg",
21
+ "Linux": (
22
+ " Debian/Ubuntu: sudo apt install ffmpeg\n"
23
+ " Fedora: sudo dnf install ffmpeg\n"
24
+ " Arch: sudo pacman -S ffmpeg"
25
+ ),
26
+ "Windows": (
27
+ " winget install Gyan.FFmpeg\n"
28
+ " or: choco install ffmpeg\n"
29
+ " or download a build from https://ffmpeg.org/download.html and add it to PATH"
30
+ ),
31
+ }
32
+
33
+
34
+ def die(msg: str, code: int = 1) -> "None":
35
+ sys.stderr.write(f"error: {msg}\n")
36
+ sys.exit(code)
37
+
38
+
39
+ def info(msg: str) -> None:
40
+ sys.stderr.write(f"{msg}\n")
41
+
42
+
43
+ def require_tool(name: str) -> str:
44
+ """Return the absolute path of ffmpeg/ffprobe or exit with install steps."""
45
+ path = shutil.which(name)
46
+ if path:
47
+ return path
48
+ system = platform.system()
49
+ hint = INSTALL_HINTS.get(system, " See https://ffmpeg.org/download.html")
50
+ die(
51
+ f"'{name}' was not found on PATH.\n"
52
+ f"Install FFmpeg (which includes ffprobe) for {system}:\n{hint}",
53
+ code=127,
54
+ )
55
+ return "" # unreachable
56
+
57
+
58
+ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
59
+ """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True."""
60
+ if not quiet:
61
+ info("$ " + " ".join(shell_quote(c) for c in cmd))
62
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
63
+ if check and proc.returncode != 0:
64
+ tail = "\n".join(proc.stderr.strip().splitlines()[-15:])
65
+ die(f"command failed ({proc.returncode}): {cmd[0]}\n{tail}", code=proc.returncode or 1)
66
+ return proc
67
+
68
+
69
+ def shell_quote(s: str) -> str:
70
+ if not s or any(ch in s for ch in " \t\"'\;|&<>()[]{}$*?"):
71
+ return "'" + s.replace("'", "'\\''") + "'"
72
+ return s
73
+
74
+
75
+ def ffmpeg_base(overwrite: bool = True) -> List[str]:
76
+ cmd = [require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"]
77
+ cmd.append("-y" if overwrite else "-n")
78
+ return cmd
79
+
80
+
81
+ def probe(path: str) -> Dict[str, Any]:
82
+ """Return a compact, script-friendly description of a media file."""
83
+ if not os.path.exists(path):
84
+ die(f"input not found: {path}")
85
+ ffprobe = require_tool("ffprobe")
86
+ proc = run(
87
+ [ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path],
88
+ quiet=True,
89
+ check=False,
90
+ )
91
+ if proc.returncode != 0:
92
+ die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
93
+ raw = json.loads(proc.stdout or "{}")
94
+ fmt = raw.get("format", {})
95
+ streams = raw.get("streams", [])
96
+ video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
97
+ audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
98
+ subs = [s for s in streams if s.get("codec_type") == "subtitle"]
99
+
100
+ duration = _to_float(fmt.get("duration"))
101
+ if duration is None and video:
102
+ duration = _to_float(video.get("duration"))
103
+ if duration is None and audio:
104
+ duration = _to_float(audio.get("duration"))
105
+
106
+ out: Dict[str, Any] = {
107
+ "file": path,
108
+ "format": fmt.get("format_name"),
109
+ "duration": duration,
110
+ "size_bytes": _to_int(fmt.get("size")),
111
+ "bitrate": _to_int(fmt.get("bit_rate")),
112
+ "video": None,
113
+ "audio": None,
114
+ "subtitle_streams": len(subs),
115
+ }
116
+ if video:
117
+ r_rate = _fraction(video.get("r_frame_rate"))
118
+ avg_rate = _fraction(video.get("avg_frame_rate"))
119
+ fps = float(avg_rate) if avg_rate else (float(r_rate) if r_rate else None)
120
+ vfr = bool(r_rate and avg_rate and abs(float(r_rate) - float(avg_rate)) > 0.01)
121
+ w, h = _to_int(video.get("width")), _to_int(video.get("height"))
122
+ rotation = 0
123
+ for sd in video.get("side_data_list", []) or []:
124
+ if "rotation" in sd:
125
+ rotation = int(round(float(sd["rotation"])))
126
+ if "rotate" in (video.get("tags") or {}):
127
+ try:
128
+ rotation = int(video["tags"]["rotate"])
129
+ except ValueError:
130
+ pass
131
+ out["video"] = {
132
+ "codec": video.get("codec_name"),
133
+ "profile": video.get("profile"),
134
+ "width": w,
135
+ "height": h,
136
+ "display_aspect": video.get("display_aspect_ratio") or _aspect_string(w, h),
137
+ "fps": round(fps, 3) if fps else None,
138
+ "r_frame_rate": video.get("r_frame_rate"),
139
+ "avg_frame_rate": video.get("avg_frame_rate"),
140
+ "variable_frame_rate_suspected": vfr,
141
+ "pix_fmt": video.get("pix_fmt"),
142
+ "color_space": video.get("color_space"),
143
+ "color_primaries": video.get("color_primaries"),
144
+ "color_transfer": video.get("color_transfer"),
145
+ "color_range": video.get("color_range"),
146
+ "rotation": rotation,
147
+ "nb_frames": _to_int(video.get("nb_frames")),
148
+ "bitrate": _to_int(video.get("bit_rate")),
149
+ }
150
+ if audio:
151
+ out["audio"] = {
152
+ "codec": audio.get("codec_name"),
153
+ "channels": _to_int(audio.get("channels")),
154
+ "channel_layout": audio.get("channel_layout"),
155
+ "sample_rate": _to_int(audio.get("sample_rate")),
156
+ "bitrate": _to_int(audio.get("bit_rate")),
157
+ }
158
+ return out
159
+
160
+
161
+ def default_output(input_path: str, suffix: str, ext: Optional[str] = None) -> str:
162
+ p = Path(input_path)
163
+ new_ext = ext if ext else p.suffix.lstrip(".") or "mp4"
164
+ return str(p.with_name(f"{p.stem}_{suffix}.{new_ext}"))
165
+
166
+
167
+ def parse_time(value: str) -> float:
168
+ """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250') or SRT '00:01:30,250'."""
169
+ v = value.strip().replace(",", ".")
170
+ if not v:
171
+ raise ValueError("empty time")
172
+ parts = v.split(":")
173
+ if len(parts) > 3:
174
+ raise ValueError(f"bad time: {value}")
175
+ total = 0.0
176
+ for part in parts:
177
+ total = total * 60 + float(part)
178
+ return total
179
+
180
+
181
+ def fmt_srt_time(seconds: float) -> str:
182
+ if seconds < 0:
183
+ seconds = 0.0
184
+ ms = int(round(seconds * 1000))
185
+ h, rem = divmod(ms, 3_600_000)
186
+ m, rem = divmod(rem, 60_000)
187
+ s, ms = divmod(rem, 1000)
188
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
189
+
190
+
191
+ def escape_filter_path(path: str) -> str:
192
+ """Escape a path for use inside an ffmpeg filter graph option value."""
193
+ p = str(Path(path))
194
+ p = p.replace("\\", "/")
195
+ p = p.replace(":", "\\:").replace("'", "\\'").replace(",", "\\,").replace("[", "\\[").replace("]", "\\]")
196
+ return p
197
+
198
+
199
+ def escape_drawtext(text: str) -> str:
200
+ return (
201
+ text.replace("\\", "\\\\")
202
+ .replace(":", "\\:")
203
+ .replace("'", "\\\\\\'")
204
+ .replace("%", "\\%")
205
+ .replace(",", "\\,")
206
+ .replace("[", "\\[")
207
+ .replace("]", "\\]")
208
+ )
209
+
210
+
211
+ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
212
+ args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
213
+ if keep_bt709:
214
+ args += ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
215
+ return args
216
+
217
+
218
+ def aac_args(bitrate: str = "192k") -> List[str]:
219
+ return ["-c:a", "aac", "-b:a", bitrate]
220
+
221
+
222
+ AUDIO_CODECS = {
223
+ ".wav": ["-c:a", "pcm_s16le"],
224
+ ".flac": ["-c:a", "flac"],
225
+ ".mp3": ["-c:a", "libmp3lame", "-q:a", "0"],
226
+ ".m4a": ["-c:a", "aac", "-b:a", "256k"],
227
+ ".aac": ["-c:a", "aac", "-b:a", "256k"],
228
+ ".ogg": ["-c:a", "libvorbis", "-q:a", "6"],
229
+ ".opus": ["-c:a", "libopus", "-b:a", "128k"],
230
+ }
231
+
232
+
233
+ def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str]:
234
+ """Pick an audio codec that the output container can actually hold."""
235
+ ext = os.path.splitext(output_path)[1].lower()
236
+ return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
237
+
238
+
239
+ def print_json(obj: Any) -> None:
240
+ sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
241
+
242
+
243
+ def _to_float(v: Any) -> Optional[float]:
244
+ try:
245
+ return float(v) if v is not None else None
246
+ except (TypeError, ValueError):
247
+ return None
248
+
249
+
250
+ def _to_int(v: Any) -> Optional[int]:
251
+ try:
252
+ return int(v) if v is not None else None
253
+ except (TypeError, ValueError):
254
+ return None
255
+
256
+
257
+ def _fraction(v: Optional[str]) -> Optional[Fraction]:
258
+ if not v or v in ("0/0", "0"):
259
+ return None
260
+ try:
261
+ f = Fraction(v)
262
+ return f if f > 0 else None
263
+ except (ValueError, ZeroDivisionError):
264
+ return None
265
+
266
+
267
+ def _aspect_string(w: Optional[int], h: Optional[int]) -> Optional[str]:
268
+ if not w or not h:
269
+ return None
270
+ f = Fraction(w, h)
271
+ return f"{f.numerator}:{f.denominator}"
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """Burn SRT/ASS subtitles into a video, or generate an SRT from plain text.
3
+
4
+ Styling (font, size, colour, outline, position) applies to SRT input via
5
+ libass force_style. ASS files carry their own styles and are rendered as-is.
6
+
7
+ Text-to-SRT input format (one cue per line, blank lines ignored):
8
+ 0:00-0:03 Hello and welcome
9
+ 00:00:03.500 --> 00:00:06 Second line | with a manual line break
10
+ Text without a time is auto-timed after the previous cue (--auto-seconds)
11
+
12
+ Examples:
13
+ python3 caption.py input.mp4 --srt subs.srt
14
+ python3 caption.py input.mp4 --srt subs.srt --font "Noto Sans CJK JP" --size 28 --position top
15
+ python3 caption.py --text cues.txt --write-srt cues.srt # only produce the SRT
16
+ python3 caption.py input.mp4 --text cues.txt # generate + burn in one go
17
+ """
18
+ import argparse
19
+ import os
20
+ import re
21
+ import sys
22
+ from typing import List, Tuple
23
+
24
+ from _common import aac_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
25
+
26
+ ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
27
+
28
+ TIME_RE = re.compile(
29
+ r"^\s*(?P<a>[\d:.,]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,]+)\s+(?P<text>.+)$"
30
+ )
31
+
32
+
33
+ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[float, float, str]]:
34
+ cues: List[Tuple[float, float, str]] = []
35
+ cursor = 0.0
36
+ with open(path, encoding="utf-8") as fh:
37
+ for raw in fh:
38
+ line = raw.rstrip("\n")
39
+ if not line.strip():
40
+ continue
41
+ m = TIME_RE.match(line)
42
+ if m:
43
+ try:
44
+ start, end = parse_time(m.group("a")), parse_time(m.group("b"))
45
+ except ValueError:
46
+ start, end, text = cursor, cursor + auto_seconds, line.strip()
47
+ else:
48
+ text = m.group("text").strip()
49
+ else:
50
+ start, end, text = cursor, cursor + auto_seconds, line.strip()
51
+ if end <= start:
52
+ die(f"cue '{line}': end must be after start")
53
+ text = text.replace(" | ", "\n").replace("|", "\n")
54
+ cues.append((start, end, text))
55
+ cursor = end + gap
56
+ if not cues:
57
+ die(f"no cues found in {path}")
58
+ return cues
59
+
60
+
61
+ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
62
+ with open(path, "w", encoding="utf-8") as fh:
63
+ for i, (s, e, t) in enumerate(cues, 1):
64
+ fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
65
+
66
+
67
+ def ass_color(hex_rgb: str, alpha: int = 0) -> str:
68
+ h = hex_rgb.lstrip("#")
69
+ if len(h) != 6:
70
+ die(f"colour must be RRGGBB hex, got '{hex_rgb}'")
71
+ r, g, b = h[0:2], h[2:4], h[4:6]
72
+ return f"&H{alpha:02X}{b}{g}{r}".upper()
73
+
74
+
75
+ def main() -> int:
76
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
77
+ ap.add_argument("input", nargs="?", help="video to burn captions into (omit with --write-srt to only generate)")
78
+ ap.add_argument("-o", "--output", help="output video (default: <name>_captioned.<ext>)")
79
+ src = ap.add_argument_group("subtitle source")
80
+ src.add_argument("--srt", help="SRT file to burn")
81
+ src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
82
+ src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
83
+ src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
84
+ src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
85
+ src.add_argument("--gap", type=float, default=0.0, help="gap after auto-timed cues in seconds")
86
+ sty = ap.add_argument_group("style (SRT only)")
87
+ sty.add_argument("--font", default="DejaVu Sans", help="font family, e.g. 'Noto Sans CJK JP' for Japanese")
88
+ sty.add_argument("--fonts-dir", help="directory with extra .ttf/.otf files")
89
+ sty.add_argument("--size", type=int, default=24, help="font size in ASS points (relative to a 288p script height, scales automatically)")
90
+ sty.add_argument("--color", default="FFFFFF", help="text colour RRGGBB (default FFFFFF)")
91
+ sty.add_argument("--outline-color", default="000000", help="outline colour RRGGBB")
92
+ sty.add_argument("--outline", type=float, default=2.0, help="outline width (default 2)")
93
+ sty.add_argument("--shadow", type=float, default=0.0, help="shadow depth (default 0)")
94
+ sty.add_argument("--bold", action="store_true")
95
+ sty.add_argument("--position", choices=sorted(ALIGN), default="bottom", help="on-screen placement (default bottom)")
96
+ sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
97
+ sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
98
+ enc = ap.add_argument_group("encoding")
99
+ enc.add_argument("--crf", type=int, default=18)
100
+ enc.add_argument("--preset", default="medium")
101
+ args = ap.parse_args()
102
+
103
+ if not (args.srt or args.ass or args.text):
104
+ die("give one of --srt, --ass or --text")
105
+
106
+ srt_path = args.srt
107
+ if args.text:
108
+ cues = parse_text_cues(args.text, args.auto_seconds, args.gap)
109
+ srt_path = args.write_srt or os.path.splitext(args.text)[0] + ".srt"
110
+ write_srt(cues, srt_path)
111
+ info(f"wrote {srt_path} ({len(cues)} cues)")
112
+ if not args.input:
113
+ print(srt_path)
114
+ return 0
115
+
116
+ if not args.input:
117
+ die("input video is required unless you only use --text/--write-srt")
118
+ probe(args.input)
119
+
120
+ if args.ass:
121
+ if not os.path.exists(args.ass):
122
+ die(f"ASS file not found: {args.ass}")
123
+ vf = f"ass={escape_filter_path(args.ass)}"
124
+ if args.fonts_dir:
125
+ vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
126
+ else:
127
+ if not srt_path or not os.path.exists(srt_path):
128
+ die(f"SRT file not found: {srt_path}")
129
+ style = [
130
+ f"FontName={args.font}",
131
+ f"FontSize={args.size}",
132
+ f"PrimaryColour={ass_color(args.color)}",
133
+ f"OutlineColour={ass_color(args.outline_color)}",
134
+ f"BackColour={ass_color(args.outline_color, 0x80)}",
135
+ f"BorderStyle={3 if args.box else 1}",
136
+ f"Outline={args.outline:g}",
137
+ f"Shadow={args.shadow:g}",
138
+ f"Bold={-1 if args.bold else 0}",
139
+ f"Alignment={ALIGN[args.position]}",
140
+ f"MarginV={args.margin}",
141
+ ]
142
+ force = ",".join(style).replace("\\", "\\\\").replace("'", "\\'")
143
+ vf = f"subtitles={escape_filter_path(srt_path)}:force_style='{force}'"
144
+ if args.fonts_dir:
145
+ vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
146
+
147
+ output = args.output or default_output(args.input, "captioned")
148
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + x264_args(args.crf, args.preset) + aac_args() + [output]
149
+ run(cmd)
150
+ result = probe(output)
151
+ info(f"wrote {output} ({result.get('duration'):.3f}s)")
152
+ print(output)
153
+ return 0
154
+
155
+
156
+ if __name__ == "__main__":
157
+ sys.exit(main())
package/scripts/cut.py ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """Cut a clip or several segments out of a video and (optionally) join them.
3
+
4
+ Lossless stream copy (-c copy) is preferred. Cuts snap to keyframes in that
5
+ mode, so if frame accuracy matters pass --accurate to re-encode. Multiple
6
+ segments are cut individually and concatenated with the concat demuxer.
7
+
8
+ Examples:
9
+ python3 cut.py input.mp4 --start 00:00:10 --end 00:00:25
10
+ python3 cut.py input.mp4 --segments 0:05-0:12,1:00-1:20 -o highlights.mp4
11
+ python3 cut.py input.mp4 --start 3.5 --duration 10 --accurate
12
+ """
13
+ import argparse
14
+ import os
15
+ import sys
16
+ import tempfile
17
+ from typing import List, Tuple
18
+
19
+ from _common import aac_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
20
+
21
+
22
+ def parse_segments(spec: str) -> List[Tuple[float, float]]:
23
+ segs = []
24
+ for raw in spec.split(","):
25
+ raw = raw.strip()
26
+ if not raw:
27
+ continue
28
+ if "-" not in raw:
29
+ die(f"segment '{raw}' must look like START-END (e.g. 0:05-0:12)")
30
+ a, b = raw.rsplit("-", 1)
31
+ start, end = parse_time(a), parse_time(b)
32
+ if end <= start:
33
+ die(f"segment '{raw}': end must be after start")
34
+ segs.append((start, end))
35
+ if not segs:
36
+ die("no segments given")
37
+ return segs
38
+
39
+
40
+ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: int, preset: str, tolerance: float = 0.5) -> bool:
41
+ """Cut one segment. Returns True if the result was re-encoded."""
42
+ dur = end - start
43
+ if reencode:
44
+ cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"]
45
+ cmd += x264_args(crf, preset) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
46
+ else:
47
+ cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}", "-c", "copy", "-avoid_negative_ts", "make_zero", dst]
48
+ proc = run(cmd, check=False)
49
+ if proc.returncode != 0:
50
+ if not reencode:
51
+ info("stream copy failed, falling back to re-encode")
52
+ return cut_one(src, start, end, dst, True, crf, preset, tolerance)
53
+ die(f"ffmpeg failed:\n{proc.stderr.strip()}")
54
+ if not reencode and tolerance >= 0:
55
+ got = probe(dst).get("duration") or 0.0
56
+ if abs(got - dur) > tolerance:
57
+ info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
58
+ f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy")
59
+ return cut_one(src, start, end, dst, True, crf, preset, tolerance)
60
+ return reencode
61
+
62
+
63
+ def main() -> int:
64
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
65
+ ap.add_argument("input")
66
+ ap.add_argument("-o", "--output", help="output file (default: <name>_cut.<ext>)")
67
+ g = ap.add_argument_group("range (single segment)")
68
+ g.add_argument("--start", default="0", help="start time (seconds, mm:ss or hh:mm:ss.ms). default 0")
69
+ g.add_argument("--end", help="end time")
70
+ g.add_argument("--duration", help="duration instead of --end")
71
+ ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
72
+ ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
73
+ ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
74
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
75
+ ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
76
+ args = ap.parse_args()
77
+
78
+ meta = probe(args.input)
79
+ total = meta.get("duration") or 0.0
80
+
81
+ if args.segments:
82
+ segments = parse_segments(args.segments)
83
+ else:
84
+ start = parse_time(args.start)
85
+ if args.end and args.duration:
86
+ die("use --end or --duration, not both")
87
+ if args.end:
88
+ end = parse_time(args.end)
89
+ elif args.duration:
90
+ end = start + parse_time(args.duration)
91
+ else:
92
+ end = total
93
+ if end <= start:
94
+ die("end must be after start")
95
+ segments = [(start, end)]
96
+
97
+ for s, e in segments:
98
+ if total and s >= total:
99
+ die(f"segment start {s:.3f}s is beyond the media duration {total:.3f}s")
100
+ segments = [(s, min(e, total) if total else e) for s, e in segments]
101
+
102
+ output = args.output or default_output(args.input, "cut")
103
+ ext = os.path.splitext(output)[1] or ".mp4"
104
+
105
+ reencoded = False
106
+ if len(segments) == 1:
107
+ reencoded = cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance)
108
+ else:
109
+ with tempfile.TemporaryDirectory(prefix="ffskill_cut_") as tmp:
110
+ parts = []
111
+ for i, (s, e) in enumerate(segments):
112
+ part = os.path.join(tmp, f"part{i:03d}{ext}")
113
+ reencoded |= cut_one(args.input, s, e, part, args.accurate, args.crf, args.preset, args.tolerance)
114
+ parts.append(part)
115
+ listfile = os.path.join(tmp, "list.txt")
116
+ with open(listfile, "w", encoding="utf-8") as fh:
117
+ for p in parts:
118
+ fh.write("file '" + p.replace("'", "'\\''") + "'\n")
119
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy", output]
120
+ proc = run(cmd, check=False)
121
+ if proc.returncode != 0:
122
+ info("concat with stream copy failed, re-encoding the join")
123
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + x264_args(args.crf, args.preset) + aac_args() + [output]
124
+ run(cmd)
125
+
126
+ result = probe(output)
127
+ expected = sum(e - s for s, e in segments)
128
+ info(f"wrote {output} ({result.get('duration'):.3f}s, expected ~{expected:.3f}s, "
129
+ + ("re-encoded" if reencoded else "lossless stream copy") + ")")
130
+ print(output)
131
+ return 0
132
+
133
+
134
+ if __name__ == "__main__":
135
+ sys.exit(main())
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """Export with delivery presets. Scales/pads to the preset's frame (keeping
3
+ the source aspect inside it), constrains duration where the platform does,
4
+ sets BT.709 tags, and picks sensible codecs/bitrates.
5
+
6
+ Presets:
7
+ youtube 1920x1080 H.264 CRF 18 high profile, AAC 192k, 48 kHz, faststart
8
+ youtube4k 3840x2160 H.264 CRF 18, AAC 192k
9
+ reels 1080x1920 9:16 H.264 CRF 20, AAC 128k, max 90 s (also Shorts/TikTok)
10
+ x 1280x720 H.264 CRF 22, AAC 128k, max 140 s (Twitter/X)
11
+ prores ProRes 422 HQ .mov, PCM 16-bit audio (editing master)
12
+ h265 HEVC CRF 24 (libx265) with hvc1 tag for Apple compatibility
13
+ gif 480px wide palette-optimised GIF at 12 fps (short previews)
14
+
15
+ Examples:
16
+ python3 export.py final.mp4 --preset youtube
17
+ python3 export.py final.mp4 --preset reels --fit crop
18
+ python3 export.py final.mp4 --preset prores -o master.mov
19
+ python3 export.py --list
20
+ """
21
+ import argparse
22
+ import sys
23
+ from typing import Dict, List
24
+
25
+ from _common import default_output, die, ffmpeg_base, info, probe, run
26
+
27
+ PRESETS: Dict[str, Dict] = {
28
+ "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
29
+ "youtube4k": {"w": 3840, "h": 2160, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "2160p H.264, AAC 192k"},
30
+ "reels": {"w": 1080, "h": 1920, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-profile:v", "high", "-pix_fmt", "yuv420p", "-r", "30"], "audio": ["-c:a", "aac", "-b:a", "128k", "-ar", "48000"], "max": 90.0, "desc": "9:16 1080x1920, 30fps, max 90s (Reels/Shorts/TikTok)"},
31
+ "x": {"w": 1280, "h": 720, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "medium", "-crf", "22", "-profile:v", "high", "-pix_fmt", "yuv420p", "-r", "30"], "audio": ["-c:a", "aac", "-b:a", "128k", "-ar", "44100"], "max": 140.0, "desc": "720p H.264, max 140s (Twitter/X)"},
32
+ "prores": {"w": None, "h": None, "ext": "mov", "video": ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"], "audio": ["-c:a", "pcm_s16le"], "max": None, "desc": "ProRes 422 HQ master, PCM audio, source resolution"},
33
+ "h265": {"w": None, "h": None, "ext": "mp4", "video": ["-c:v", "libx265", "-preset", "medium", "-crf", "24", "-pix_fmt", "yuv420p", "-tag:v", "hvc1"], "audio": ["-c:a", "aac", "-b:a", "160k"], "max": None, "desc": "HEVC CRF 24, hvc1 tag, source resolution"},
34
+ "gif": {"w": 480, "h": None, "ext": "gif", "video": [], "audio": [], "max": None, "desc": "480px palette GIF, 12fps"},
35
+ }
36
+
37
+ BT709 = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
38
+
39
+
40
+ def main() -> int:
41
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
42
+ ap.add_argument("input", nargs="?")
43
+ ap.add_argument("-o", "--output", help="output file (default: <name>_<preset>.<ext>)")
44
+ ap.add_argument("--preset", choices=sorted(PRESETS), help="delivery preset")
45
+ ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how to reach the preset frame when aspect differs (default pad)")
46
+ ap.add_argument("--pad-color", default="black")
47
+ ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
48
+ ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
49
+ ap.add_argument("--crf", type=int, help="override CRF")
50
+ ap.add_argument("--list", action="store_true", help="list presets and exit")
51
+ args = ap.parse_args()
52
+
53
+ if args.list:
54
+ for name, p in PRESETS.items():
55
+ print(f"{name:10s} {p['desc']}")
56
+ return 0
57
+ if not args.input or not args.preset:
58
+ die("input and --preset are required (or use --list)")
59
+
60
+ p = PRESETS[args.preset]
61
+ meta = probe(args.input)
62
+ if not meta.get("video"):
63
+ die("input has no video stream")
64
+ has_audio = bool(meta.get("audio"))
65
+ output = args.output or default_output(args.input, args.preset, p["ext"])
66
+
67
+ vf: List[str] = []
68
+ if p["w"] and not args.no_scale:
69
+ if p["h"]:
70
+ if args.fit == "crop":
71
+ vf += [f"scale={p['w']}:{p['h']}:force_original_aspect_ratio=increase", f"crop={p['w']}:{p['h']}"]
72
+ else:
73
+ vf += [f"scale={p['w']}:{p['h']}:force_original_aspect_ratio=decrease", f"pad={p['w']}:{p['h']}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"]
74
+ vf.append("setsar=1")
75
+ else:
76
+ vf.append(f"scale={p['w']}:-2")
77
+ cmd = ffmpeg_base() + ["-i", args.input]
78
+
79
+ if args.preset == "gif":
80
+ chain = ",".join(["fps=12"] + vf) if vf else "fps=12"
81
+ fc = f"[0:v]{chain},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
82
+ cmd += ["-filter_complex", fc, "-loop", "0", output]
83
+ run(cmd)
84
+ info(f"wrote {output}")
85
+ print(output)
86
+ return 0
87
+
88
+ if vf:
89
+ cmd += ["-vf", ",".join(vf)]
90
+ video = list(p["video"])
91
+ if args.crf is not None and "-crf" in video:
92
+ video[video.index("-crf") + 1] = str(args.crf)
93
+ cmd += video
94
+ if args.preset not in ("prores",):
95
+ cmd += BT709
96
+ if p["ext"] == "mp4":
97
+ cmd += ["-movflags", "+faststart"]
98
+ cmd += (p["audio"] if has_audio else ["-an"])
99
+ if p["max"] and not args.allow_long and (meta.get("duration") or 0) > p["max"]:
100
+ info(f"trimming to the platform maximum of {p['max']:.0f}s (use --allow-long to keep full length)")
101
+ cmd += ["-t", f"{p['max']:.3f}"]
102
+ cmd.append(output)
103
+ run(cmd)
104
+ result = probe(output)
105
+ v = result["video"]
106
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
107
+ print(output)
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())