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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/SKILL.md +165 -0
- package/bin/install.js +103 -0
- package/package.json +31 -0
- package/scripts/_common.py +271 -0
- package/scripts/caption.py +157 -0
- package/scripts/cut.py +135 -0
- package/scripts/export.py +112 -0
- package/scripts/fit.py +161 -0
- package/scripts/loudness.py +84 -0
- package/scripts/overlay.py +171 -0
- package/scripts/probe.py +58 -0
- package/scripts/sync.py +205 -0
|
@@ -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())
|