ffmpeg-skill 0.1.0 → 0.3.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/README.md +27 -15
- package/SKILL.md +118 -29
- package/package.json +2 -2
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/_common.py +64 -2
- package/scripts/audio.py +130 -0
- package/scripts/caption.py +98 -5
- package/scripts/color.py +119 -0
- package/scripts/cut.py +16 -10
- package/scripts/export.py +9 -3
- package/scripts/fit.py +15 -2
- package/scripts/join.py +111 -0
- package/scripts/look.py +101 -0
- package/scripts/loudness.py +4 -2
- package/scripts/overlay.py +5 -3
- package/scripts/silence.py +123 -0
- package/scripts/sync.py +124 -28
package/scripts/caption.py
CHANGED
|
@@ -11,6 +11,7 @@ Text-to-SRT input format (one cue per line, blank lines ignored):
|
|
|
11
11
|
|
|
12
12
|
Examples:
|
|
13
13
|
python3 caption.py input.mp4 --srt subs.srt
|
|
14
|
+
python3 caption.py input.mp4 --text cues.txt --animate pop --karaoke # word-by-word highlight, TikTok style
|
|
14
15
|
python3 caption.py input.mp4 --srt subs.srt --font "Noto Sans CJK JP" --size 28 --position top
|
|
15
16
|
python3 caption.py --text cues.txt --write-srt cues.srt # only produce the SRT
|
|
16
17
|
python3 caption.py input.mp4 --text cues.txt # generate + burn in one go
|
|
@@ -21,7 +22,7 @@ import re
|
|
|
21
22
|
import sys
|
|
22
23
|
from typing import List, Tuple
|
|
23
24
|
|
|
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
|
+
from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
|
|
25
26
|
|
|
26
27
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
27
28
|
|
|
@@ -58,12 +59,84 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
|
|
|
58
59
|
return cues
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def parse_srt(path: str) -> List[Tuple[float, float, str]]:
|
|
63
|
+
cues: List[Tuple[float, float, str]] = []
|
|
64
|
+
block: List[str] = []
|
|
65
|
+
with open(path, encoding="utf-8-sig") as fh:
|
|
66
|
+
content = fh.read().replace("\r\n", "\n") + "\n\n"
|
|
67
|
+
for line in content.split("\n"):
|
|
68
|
+
if line.strip():
|
|
69
|
+
block.append(line)
|
|
70
|
+
continue
|
|
71
|
+
if block:
|
|
72
|
+
times = next((b for b in block if "-->" in b), None)
|
|
73
|
+
if times:
|
|
74
|
+
a, b = times.split("-->")
|
|
75
|
+
text = "\n".join(block[block.index(times) + 1:]).strip()
|
|
76
|
+
cues.append((parse_time(a), parse_time(b), text))
|
|
77
|
+
block = []
|
|
78
|
+
if not cues:
|
|
79
|
+
die(f"no cues found in {path}")
|
|
80
|
+
return cues
|
|
81
|
+
|
|
82
|
+
|
|
61
83
|
def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
|
|
62
84
|
with open(path, "w", encoding="utf-8") as fh:
|
|
63
85
|
for i, (s, e, t) in enumerate(cues, 1):
|
|
64
86
|
fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
|
|
65
87
|
|
|
66
88
|
|
|
89
|
+
def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int) -> None:
|
|
90
|
+
"""Write a styled ASS file with optional animation and word-by-word highlight."""
|
|
91
|
+
def t(sec: float) -> str:
|
|
92
|
+
cs = int(round(sec * 100))
|
|
93
|
+
h, rem = divmod(cs, 360000)
|
|
94
|
+
m, rem = divmod(rem, 6000)
|
|
95
|
+
s_, cs = divmod(rem, 100)
|
|
96
|
+
return f"{h}:{m:02d}:{s_:02d}.{cs:02d}"
|
|
97
|
+
|
|
98
|
+
scale = play_h / 288.0 # our --size is relative to a 288-line script like force_style
|
|
99
|
+
size = int(round(args.size * scale))
|
|
100
|
+
margin = int(round(args.margin * scale))
|
|
101
|
+
# karaoke: PrimaryColour is the "sung" colour, SecondaryColour the "not yet sung" one
|
|
102
|
+
primary = ass_color(args.highlight_color if args.karaoke else args.color)
|
|
103
|
+
secondary = ass_color(args.color)
|
|
104
|
+
outline = ass_color(args.outline_color)
|
|
105
|
+
back = ass_color(args.outline_color, 0x80)
|
|
106
|
+
header = [
|
|
107
|
+
"[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}", "WrapStyle: 0", "ScaledBorderAndShadow: yes", "",
|
|
108
|
+
"[V4+ Styles]",
|
|
109
|
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
|
110
|
+
f"Style: Default,{args.font},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin},{margin},{margin},1",
|
|
111
|
+
"", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
112
|
+
]
|
|
113
|
+
lines = []
|
|
114
|
+
for start, end, text in cues:
|
|
115
|
+
text = text.replace("\n", "\\N")
|
|
116
|
+
fx = ""
|
|
117
|
+
if args.animate == "fade":
|
|
118
|
+
fx = "{\\fad(200,200)}"
|
|
119
|
+
elif args.animate == "pop":
|
|
120
|
+
fx = "{\\fad(80,120)\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)}"
|
|
121
|
+
elif args.animate == "slide":
|
|
122
|
+
fx = "{\\fad(150,150)\\move(%d,%d,%d,%d,0,250)}" % (play_w // 2, play_h - margin + int(30 * scale), play_w // 2, play_h - margin)
|
|
123
|
+
body = text
|
|
124
|
+
if args.karaoke:
|
|
125
|
+
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
126
|
+
dur_cs = max(1, int(round((end - start) * 100)))
|
|
127
|
+
segments = body.split("\\N")
|
|
128
|
+
words = [w for seg in segments for w in seg.split(" ") if w]
|
|
129
|
+
per = max(1, dur_cs // max(1, len(words)))
|
|
130
|
+
out_segments = []
|
|
131
|
+
for seg in segments:
|
|
132
|
+
ws = [w for w in seg.split(" ") if w]
|
|
133
|
+
out_segments.append(" ".join(f"{{\\kf{per}}}{w}" for w in ws))
|
|
134
|
+
body = "\\N".join(out_segments)
|
|
135
|
+
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
|
|
136
|
+
with open(path, "w", encoding="utf-8-sig") as fh:
|
|
137
|
+
fh.write("\n".join(header + lines) + "\n")
|
|
138
|
+
|
|
139
|
+
|
|
67
140
|
def ass_color(hex_rgb: str, alpha: int = 0) -> str:
|
|
68
141
|
h = hex_rgb.lstrip("#")
|
|
69
142
|
if len(h) != 6:
|
|
@@ -95,10 +168,17 @@ def main() -> int:
|
|
|
95
168
|
sty.add_argument("--position", choices=sorted(ALIGN), default="bottom", help="on-screen placement (default bottom)")
|
|
96
169
|
sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
|
|
97
170
|
sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
|
|
171
|
+
anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
|
|
172
|
+
anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default="none", help="per-cue entrance animation")
|
|
173
|
+
anim.add_argument("--karaoke", action="store_true", help="word-by-word highlight (fills from --color to --highlight-color across each cue)")
|
|
174
|
+
anim.add_argument("--highlight-color", default="FFD200", help="karaoke fill colour RRGGBB (default FFD200)")
|
|
175
|
+
anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
|
|
98
176
|
enc = ap.add_argument_group("encoding")
|
|
99
177
|
enc.add_argument("--crf", type=int, default=18)
|
|
100
178
|
enc.add_argument("--preset", default="medium")
|
|
179
|
+
add_common(ap)
|
|
101
180
|
args = ap.parse_args()
|
|
181
|
+
apply_common(args)
|
|
102
182
|
|
|
103
183
|
if not (args.srt or args.ass or args.text):
|
|
104
184
|
die("give one of --srt, --ass or --text")
|
|
@@ -115,7 +195,20 @@ def main() -> int:
|
|
|
115
195
|
|
|
116
196
|
if not args.input:
|
|
117
197
|
die("input video is required unless you only use --text/--write-srt")
|
|
118
|
-
probe(args.input)
|
|
198
|
+
meta = probe(args.input)
|
|
199
|
+
if not meta.get("video"):
|
|
200
|
+
die("input has no video stream")
|
|
201
|
+
|
|
202
|
+
output = args.output or default_output(args.input, "captioned")
|
|
203
|
+
if (args.animate != "none" or args.karaoke) and not args.ass:
|
|
204
|
+
cues_for_ass = cues if args.text else parse_srt(srt_path)
|
|
205
|
+
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
206
|
+
w, h = meta["video"]["width"], meta["video"]["height"]
|
|
207
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
208
|
+
w, h = h, w
|
|
209
|
+
write_ass(cues_for_ass, ass_path, args, w, h)
|
|
210
|
+
info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
|
|
211
|
+
args.ass = ass_path
|
|
119
212
|
|
|
120
213
|
if args.ass:
|
|
121
214
|
if not os.path.exists(args.ass):
|
|
@@ -144,12 +237,12 @@ def main() -> int:
|
|
|
144
237
|
if args.fonts_dir:
|
|
145
238
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
146
239
|
|
|
147
|
-
|
|
148
|
-
cmd
|
|
240
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
|
|
241
|
+
cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
|
|
149
242
|
run(cmd)
|
|
150
243
|
result = probe(output)
|
|
151
244
|
info(f"wrote {output} ({result.get('duration'):.3f}s)")
|
|
152
|
-
|
|
245
|
+
emit(output)
|
|
153
246
|
return 0
|
|
154
247
|
|
|
155
248
|
|
package/scripts/color.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Colour management: convert HDR (HDR10/PQ, HLG, BT.2020) to SDR BT.709 with
|
|
3
|
+
real tone mapping, apply a .cube LUT (Log footage, creative grades), or fix
|
|
4
|
+
wrong colour tags without re-encoding.
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
python3 color.py iphone_hdr.mov --to-sdr # PQ/HLG -> BT.709 SDR, hable tonemap
|
|
8
|
+
python3 color.py iphone_hdr.mov --to-sdr --tonemap mobius --peak 1000
|
|
9
|
+
python3 color.py slog3.mp4 --lut SLog3_to_Rec709.cube # apply LUT (any Log -> 709 or a look)
|
|
10
|
+
python3 color.py clip.mp4 --lut look.cube --lut-strength 0.6
|
|
11
|
+
python3 color.py wrongly_tagged.mp4 --retag bt709 # metadata only, stream copy
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
|
|
19
|
+
|
|
20
|
+
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
24
|
+
v = meta["video"]
|
|
25
|
+
trc = v.get("color_transfer") or "smpte2084"
|
|
26
|
+
prim = v.get("color_primaries") or "bt2020"
|
|
27
|
+
space = v.get("color_space") or "bt2020nc"
|
|
28
|
+
# zscale needs explicit input tags when the file lacks them
|
|
29
|
+
chain: List[str] = [
|
|
30
|
+
f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear:npl={peak:g}",
|
|
31
|
+
"format=gbrpf32le",
|
|
32
|
+
"zscale=p=bt709",
|
|
33
|
+
f"tonemap=tonemap={tonemap}:desat={desat:g}" + (":peak=%g" % (peak / 100.0) if tonemap in ("bt2390",) else ""),
|
|
34
|
+
"zscale=t=bt709:m=bt709:r=tv",
|
|
35
|
+
"format=yuv420p",
|
|
36
|
+
]
|
|
37
|
+
return ",".join(chain)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> int:
|
|
41
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
42
|
+
ap.add_argument("input")
|
|
43
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_sdr / _lut / _retag)")
|
|
44
|
+
mode = ap.add_mutually_exclusive_group(required=True)
|
|
45
|
+
mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020) to SDR BT.709")
|
|
46
|
+
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
47
|
+
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
48
|
+
ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
|
|
49
|
+
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
50
|
+
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
51
|
+
ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
|
|
52
|
+
ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
|
|
53
|
+
ap.add_argument("--crf", type=int, default=18)
|
|
54
|
+
ap.add_argument("--preset", default="medium")
|
|
55
|
+
add_common(ap)
|
|
56
|
+
args = ap.parse_args()
|
|
57
|
+
apply_common(args)
|
|
58
|
+
|
|
59
|
+
meta = probe(args.input)
|
|
60
|
+
if not meta.get("video"):
|
|
61
|
+
die("input has no video stream")
|
|
62
|
+
v = meta["video"]
|
|
63
|
+
has_audio = bool(meta.get("audio"))
|
|
64
|
+
|
|
65
|
+
if args.retag:
|
|
66
|
+
tags = {
|
|
67
|
+
"bt709": ["bt709", "bt709", "bt709"],
|
|
68
|
+
"bt2020-pq": ["bt2020nc", "bt2020", "smpte2084"],
|
|
69
|
+
"bt2020-hlg": ["bt2020nc", "bt2020", "arib-std-b67"],
|
|
70
|
+
"bt601": ["smpte170m", "smpte170m", "smpte170m"],
|
|
71
|
+
}[args.retag]
|
|
72
|
+
output = args.output or default_output(args.input, "retag")
|
|
73
|
+
ext = os.path.splitext(output)[1].lower()
|
|
74
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy",
|
|
75
|
+
"-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
|
|
76
|
+
if ext in (".mp4", ".mov", ".m4v"):
|
|
77
|
+
cmd += ["-movflags", "+faststart"]
|
|
78
|
+
cmd.append(output)
|
|
79
|
+
proc = run(cmd, check=False)
|
|
80
|
+
if proc.returncode != 0:
|
|
81
|
+
# some codecs cannot carry retagged colour info without a bitstream filter; fall back to re-encode
|
|
82
|
+
info("stream copy could not rewrite tags, re-encoding")
|
|
83
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", "0:a?"] + x264_args(args.crf, args.preset, keep_bt709=False)
|
|
84
|
+
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
|
|
85
|
+
run(cmd)
|
|
86
|
+
info(f"wrote {output} (tags -> {args.retag})")
|
|
87
|
+
emit(output)
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
if args.to_sdr:
|
|
91
|
+
if not v.get("hdr") and not args.force:
|
|
92
|
+
die(f"{args.input} is not tagged as HDR (transfer={v.get('color_transfer')}, primaries={v.get('color_primaries')}). Use --force to tone-map anyway.")
|
|
93
|
+
vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
|
|
94
|
+
output = args.output or default_output(args.input, "sdr")
|
|
95
|
+
tag = "sdr"
|
|
96
|
+
else:
|
|
97
|
+
if not os.path.exists(args.lut):
|
|
98
|
+
die(f"LUT not found: {args.lut}")
|
|
99
|
+
lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
|
|
100
|
+
if 0 < args.lut_strength < 1:
|
|
101
|
+
# blend graded and original
|
|
102
|
+
vf = f"split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},format=yuv420p"
|
|
103
|
+
else:
|
|
104
|
+
vf = f"{lut},format=yuv420p"
|
|
105
|
+
output = args.output or default_output(args.input, "lut")
|
|
106
|
+
tag = "lut"
|
|
107
|
+
|
|
108
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a?"]
|
|
109
|
+
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
|
|
110
|
+
run(cmd)
|
|
111
|
+
r = probe(output)
|
|
112
|
+
info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
|
|
113
|
+
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
114
|
+
emit(output)
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
sys.exit(main())
|
package/scripts/cut.py
CHANGED
|
@@ -16,7 +16,7 @@ import sys
|
|
|
16
16
|
import tempfile
|
|
17
17
|
from typing import List, Tuple
|
|
18
18
|
|
|
19
|
-
from _common import aac_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
19
|
+
from _common import STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def parse_segments(spec: str) -> List[Tuple[float, float]]:
|
|
@@ -37,26 +37,27 @@ def parse_segments(spec: str) -> List[Tuple[float, float]]:
|
|
|
37
37
|
return segs
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: int, preset: str, tolerance: float = 0.5) -> bool:
|
|
40
|
+
def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: int, preset: str, tolerance: float = 0.5, meta: dict = None) -> bool:
|
|
41
41
|
"""Cut one segment. Returns True if the result was re-encoded."""
|
|
42
42
|
dur = end - start
|
|
43
|
+
meta = meta or probe(src)
|
|
43
44
|
if reencode:
|
|
44
45
|
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
|
+
cmd += x264_args(crf, preset) + cfr_args(meta) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
|
|
46
47
|
else:
|
|
47
48
|
cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}", "-c", "copy", "-avoid_negative_ts", "make_zero", dst]
|
|
48
49
|
proc = run(cmd, check=False)
|
|
49
50
|
if proc.returncode != 0:
|
|
50
51
|
if not reencode:
|
|
51
52
|
info("stream copy failed, falling back to re-encode")
|
|
52
|
-
return cut_one(src, start, end, dst, True, crf, preset, tolerance)
|
|
53
|
+
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
53
54
|
die(f"ffmpeg failed:\n{proc.stderr.strip()}")
|
|
54
|
-
if not reencode and tolerance >= 0:
|
|
55
|
+
if not reencode and tolerance >= 0 and not STATE["dry_run"]:
|
|
55
56
|
got = probe(dst).get("duration") or 0.0
|
|
56
57
|
if abs(got - dur) > tolerance:
|
|
57
58
|
info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
|
|
58
59
|
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 cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
60
61
|
return reencode
|
|
61
62
|
|
|
62
63
|
|
|
@@ -73,10 +74,15 @@ def main() -> int:
|
|
|
73
74
|
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
75
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
|
|
75
76
|
ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
|
|
77
|
+
add_common(ap)
|
|
76
78
|
args = ap.parse_args()
|
|
79
|
+
apply_common(args)
|
|
77
80
|
|
|
78
81
|
meta = probe(args.input)
|
|
79
82
|
total = meta.get("duration") or 0.0
|
|
83
|
+
if meta.get("video", {}) and meta["video"].get("variable_frame_rate_suspected") and not args.accurate:
|
|
84
|
+
info("source looks variable-frame-rate; lossless cuts on VFR are unreliable, switching to --accurate")
|
|
85
|
+
args.accurate = True
|
|
80
86
|
|
|
81
87
|
if args.segments:
|
|
82
88
|
segments = parse_segments(args.segments)
|
|
@@ -104,13 +110,13 @@ def main() -> int:
|
|
|
104
110
|
|
|
105
111
|
reencoded = False
|
|
106
112
|
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)
|
|
113
|
+
reencoded = cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance, meta)
|
|
108
114
|
else:
|
|
109
115
|
with tempfile.TemporaryDirectory(prefix="ffskill_cut_") as tmp:
|
|
110
116
|
parts = []
|
|
111
117
|
for i, (s, e) in enumerate(segments):
|
|
112
118
|
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)
|
|
119
|
+
reencoded |= cut_one(args.input, s, e, part, args.accurate, args.crf, args.preset, args.tolerance, meta)
|
|
114
120
|
parts.append(part)
|
|
115
121
|
listfile = os.path.join(tmp, "list.txt")
|
|
116
122
|
with open(listfile, "w", encoding="utf-8") as fh:
|
|
@@ -120,14 +126,14 @@ def main() -> int:
|
|
|
120
126
|
proc = run(cmd, check=False)
|
|
121
127
|
if proc.returncode != 0:
|
|
122
128
|
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]
|
|
129
|
+
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + x264_args(args.crf, args.preset) + cfr_args(meta) + aac_args() + [output]
|
|
124
130
|
run(cmd)
|
|
125
131
|
|
|
126
132
|
result = probe(output)
|
|
127
133
|
expected = sum(e - s for s, e in segments)
|
|
128
134
|
info(f"wrote {output} ({result.get('duration'):.3f}s, expected ~{expected:.3f}s, "
|
|
129
135
|
+ ("re-encoded" if reencoded else "lossless stream copy") + ")")
|
|
130
|
-
|
|
136
|
+
emit(output)
|
|
131
137
|
return 0
|
|
132
138
|
|
|
133
139
|
|
package/scripts/export.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import sys
|
|
23
23
|
from typing import Dict, List
|
|
24
24
|
|
|
25
|
-
from _common import default_output, die, ffmpeg_base, info, probe, run
|
|
25
|
+
from _common import add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
|
|
26
26
|
|
|
27
27
|
PRESETS: Dict[str, Dict] = {
|
|
28
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"},
|
|
@@ -48,7 +48,9 @@ def main() -> int:
|
|
|
48
48
|
ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
|
|
49
49
|
ap.add_argument("--crf", type=int, help="override CRF")
|
|
50
50
|
ap.add_argument("--list", action="store_true", help="list presets and exit")
|
|
51
|
+
add_common(ap)
|
|
51
52
|
args = ap.parse_args()
|
|
53
|
+
apply_common(args)
|
|
52
54
|
|
|
53
55
|
if args.list:
|
|
54
56
|
for name, p in PRESETS.items():
|
|
@@ -61,6 +63,8 @@ def main() -> int:
|
|
|
61
63
|
meta = probe(args.input)
|
|
62
64
|
if not meta.get("video"):
|
|
63
65
|
die("input has no video stream")
|
|
66
|
+
if meta["video"].get("hdr") and args.preset != "prores":
|
|
67
|
+
info("warning: source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
|
|
64
68
|
has_audio = bool(meta.get("audio"))
|
|
65
69
|
output = args.output or default_output(args.input, args.preset, p["ext"])
|
|
66
70
|
|
|
@@ -82,7 +86,7 @@ def main() -> int:
|
|
|
82
86
|
cmd += ["-filter_complex", fc, "-loop", "0", output]
|
|
83
87
|
run(cmd)
|
|
84
88
|
info(f"wrote {output}")
|
|
85
|
-
|
|
89
|
+
emit(output)
|
|
86
90
|
return 0
|
|
87
91
|
|
|
88
92
|
if vf:
|
|
@@ -91,6 +95,8 @@ def main() -> int:
|
|
|
91
95
|
if args.crf is not None and "-crf" in video:
|
|
92
96
|
video[video.index("-crf") + 1] = str(args.crf)
|
|
93
97
|
cmd += video
|
|
98
|
+
if "-r" not in video:
|
|
99
|
+
cmd += cfr_args(meta)
|
|
94
100
|
if args.preset not in ("prores",):
|
|
95
101
|
cmd += BT709
|
|
96
102
|
if p["ext"] == "mp4":
|
|
@@ -104,7 +110,7 @@ def main() -> int:
|
|
|
104
110
|
result = probe(output)
|
|
105
111
|
v = result["video"]
|
|
106
112
|
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
|
|
107
|
-
|
|
113
|
+
emit(output)
|
|
108
114
|
return 0
|
|
109
115
|
|
|
110
116
|
|
package/scripts/fit.py
CHANGED
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
from fractions import Fraction
|
|
20
20
|
from typing import List
|
|
21
21
|
|
|
22
|
-
from _common import aac_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
22
|
+
from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
23
23
|
|
|
24
24
|
ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
|
|
25
25
|
|
|
@@ -63,6 +63,8 @@ def main() -> int:
|
|
|
63
63
|
d.add_argument("--method", choices=["speed", "trim"], default="speed", help="how to reach the duration (default speed)")
|
|
64
64
|
d.add_argument("--from-center", action="store_true", help="with --method trim, keep the middle instead of the start")
|
|
65
65
|
d.add_argument("--max-speed", type=float, default=4.0, help="refuse speed factors above this (default 4x)")
|
|
66
|
+
d.add_argument("--smooth", choices=["none", "blend", "interpolate"], default="none",
|
|
67
|
+
help="slow-motion quality: blend (frame blending) or interpolate (motion-compensated, slow but fluid). default none = duplicate frames")
|
|
66
68
|
a = ap.add_argument_group("aspect")
|
|
67
69
|
a.add_argument("--aspect", help="target aspect ratio, e.g. 16:9, 9:16, 1:1, 4:5")
|
|
68
70
|
a.add_argument("--fit", choices=["pad", "crop"], default="pad", help="pad (letterbox) or crop to reach the aspect (default pad)")
|
|
@@ -72,7 +74,9 @@ def main() -> int:
|
|
|
72
74
|
e.add_argument("--crf", type=int, default=18)
|
|
73
75
|
e.add_argument("--preset", default="medium")
|
|
74
76
|
e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
77
|
+
add_common(ap)
|
|
75
78
|
args = ap.parse_args()
|
|
79
|
+
apply_common(args)
|
|
76
80
|
|
|
77
81
|
if not args.duration and not args.aspect and not args.width and not args.fps:
|
|
78
82
|
die("nothing to do: give --duration, --aspect, --width and/or --fps")
|
|
@@ -103,6 +107,12 @@ def main() -> int:
|
|
|
103
107
|
die(f"required speed factor {factor:.2f}x exceeds --max-speed {args.max_speed}x; use --method trim or raise the limit")
|
|
104
108
|
if abs(factor - 1.0) > 1e-4:
|
|
105
109
|
vf.append(f"setpts={1/factor:.8f}*PTS")
|
|
110
|
+
src_fps = meta["video"].get("fps") or 30.0
|
|
111
|
+
if factor < 1.0 and args.smooth == "interpolate":
|
|
112
|
+
vf.append(f"minterpolate=fps={src_fps:g}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1")
|
|
113
|
+
elif factor < 1.0 and args.smooth == "blend":
|
|
114
|
+
vf.append(f"fps={src_fps:g}")
|
|
115
|
+
vf.append("tblend=all_mode=average")
|
|
106
116
|
if has_audio:
|
|
107
117
|
af.append(atempo_chain(factor))
|
|
108
118
|
post += ["-t", f"{target:.3f}"]
|
|
@@ -133,6 +143,8 @@ def main() -> int:
|
|
|
133
143
|
|
|
134
144
|
if args.fps:
|
|
135
145
|
vf.append(f"fps={args.fps:g}")
|
|
146
|
+
elif meta["video"].get("variable_frame_rate_suspected"):
|
|
147
|
+
info("source looks variable-frame-rate; conforming to constant fps automatically")
|
|
136
148
|
|
|
137
149
|
output = args.output or default_output(args.input, "fit")
|
|
138
150
|
cmd = ffmpeg_base() + pre_input + ["-i", args.input]
|
|
@@ -141,6 +153,7 @@ def main() -> int:
|
|
|
141
153
|
if af:
|
|
142
154
|
cmd += ["-af", ",".join(af)]
|
|
143
155
|
cmd += x264_args(args.crf, args.preset)
|
|
156
|
+
cmd += cfr_args(meta, args.fps) if not args.fps else []
|
|
144
157
|
if has_audio:
|
|
145
158
|
cmd += aac_args()
|
|
146
159
|
else:
|
|
@@ -153,7 +166,7 @@ def main() -> int:
|
|
|
153
166
|
if abs(factor - 1.0) > 1e-4:
|
|
154
167
|
msg += f", speed {factor:.3f}x"
|
|
155
168
|
info(msg)
|
|
156
|
-
|
|
169
|
+
emit(output)
|
|
157
170
|
return 0
|
|
158
171
|
|
|
159
172
|
|
package/scripts/join.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Join clips with transitions, normalising resolution, frame rate and audio
|
|
3
|
+
layout so mismatched sources (phone + camera + screen recording) cut together.
|
|
4
|
+
|
|
5
|
+
Transitions (xfade): fade, dissolve, wipeleft, wiperight, wipeup, wipedown,
|
|
6
|
+
slideleft, slideright, circleopen, fadeblack, fadewhite, smoothleft, none.
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
python3 join.py a.mp4 b.mp4 c.mp4 -o final.mp4 # 0.5 s crossfade, size/fps from the first clip
|
|
10
|
+
python3 join.py *.mp4 --transition fadeblack --duration 1 -o reel.mp4
|
|
11
|
+
python3 join.py a.mov b.mp4 --transition none --width 1920 --height 1080 --fps 30
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
|
|
18
|
+
|
|
19
|
+
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
20
|
+
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
25
|
+
ap.add_argument("inputs", nargs="+", help="two or more clips in order")
|
|
26
|
+
ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
|
|
27
|
+
ap.add_argument("--transition", choices=TRANSITIONS, default="fade", help="transition between clips (default fade)")
|
|
28
|
+
ap.add_argument("--duration", type=float, default=0.5, help="transition length in seconds (default 0.5)")
|
|
29
|
+
ap.add_argument("--width", type=int, help="output width (default: first clip)")
|
|
30
|
+
ap.add_argument("--height", type=int, help="output height (default: first clip)")
|
|
31
|
+
ap.add_argument("--fps", type=float, help="output frame rate (default: first clip)")
|
|
32
|
+
ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
|
|
33
|
+
ap.add_argument("--pad-color", default="black")
|
|
34
|
+
ap.add_argument("--crf", type=int, default=18)
|
|
35
|
+
ap.add_argument("--preset", default="medium")
|
|
36
|
+
add_common(ap)
|
|
37
|
+
args = ap.parse_args()
|
|
38
|
+
apply_common(args)
|
|
39
|
+
|
|
40
|
+
if len(args.inputs) < 2:
|
|
41
|
+
die("give at least two clips")
|
|
42
|
+
metas = [probe(p) for p in args.inputs]
|
|
43
|
+
for p, m in zip(args.inputs, metas):
|
|
44
|
+
if not m.get("video"):
|
|
45
|
+
die(f"{p} has no video stream")
|
|
46
|
+
first = metas[0]["video"]
|
|
47
|
+
w = args.width or first["width"]
|
|
48
|
+
h = args.height or first["height"]
|
|
49
|
+
if first.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
|
|
50
|
+
w, h = h, w
|
|
51
|
+
fps = args.fps or first.get("fps") or 30.0
|
|
52
|
+
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
53
|
+
w, h = w - (w % 2), h - (h % 2)
|
|
54
|
+
durs = [m.get("duration") or 0.0 for m in metas]
|
|
55
|
+
d = args.duration if args.transition != "none" else 0.0
|
|
56
|
+
for p, dur in zip(args.inputs, durs):
|
|
57
|
+
if d and dur <= d * 2:
|
|
58
|
+
die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
|
|
59
|
+
|
|
60
|
+
cmd = ffmpeg_base()
|
|
61
|
+
extra_inputs: List[str] = []
|
|
62
|
+
parts: List[str] = []
|
|
63
|
+
n = len(args.inputs)
|
|
64
|
+
for i, (p, m) in enumerate(zip(args.inputs, metas)):
|
|
65
|
+
cmd += ["-i", p]
|
|
66
|
+
# silent audio for clips without an audio track
|
|
67
|
+
audio_src: List[str] = []
|
|
68
|
+
for i, m in enumerate(metas):
|
|
69
|
+
if m.get("audio"):
|
|
70
|
+
audio_src.append(f"{i}:a:0")
|
|
71
|
+
else:
|
|
72
|
+
idx = n + len(extra_inputs)
|
|
73
|
+
extra_inputs += ["-f", "lavfi", "-t", f"{durs[i]:.3f}", "-i", "anullsrc=r=48000:cl=stereo"]
|
|
74
|
+
audio_src.append(f"{idx}:a:0")
|
|
75
|
+
cmd += extra_inputs
|
|
76
|
+
|
|
77
|
+
if args.fit == "crop":
|
|
78
|
+
geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
79
|
+
else:
|
|
80
|
+
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
|
|
81
|
+
for i in range(n):
|
|
82
|
+
parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format=yuv420p,settb=AVTB[v{i}]")
|
|
83
|
+
parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[a{i}]")
|
|
84
|
+
|
|
85
|
+
if args.transition == "none":
|
|
86
|
+
chain = "".join(f"[v{i}][a{i}]" for i in range(n))
|
|
87
|
+
parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
|
|
88
|
+
else:
|
|
89
|
+
vprev, aprev = "v0", "a0"
|
|
90
|
+
offset = 0.0
|
|
91
|
+
for i in range(1, n):
|
|
92
|
+
offset += durs[i - 1] - d
|
|
93
|
+
vout = f"vx{i}" if i < n - 1 else "vout"
|
|
94
|
+
aout = f"ax{i}" if i < n - 1 else "aout"
|
|
95
|
+
parts.append(f"[{vprev}][v{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
|
|
96
|
+
parts.append(f"[{aprev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
|
|
97
|
+
vprev, aprev = vout, aout
|
|
98
|
+
|
|
99
|
+
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
100
|
+
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
101
|
+
cmd += x264_args(args.crf, args.preset) + aac_args() + [output]
|
|
102
|
+
run(cmd)
|
|
103
|
+
expected = sum(durs) - d * (n - 1)
|
|
104
|
+
r = probe(output)
|
|
105
|
+
info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
106
|
+
emit(output, clips=n, transition=args.transition, expected_duration=round(expected, 3))
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|