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.
@@ -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
- 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]
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
- print(output)
245
+ emit(output)
153
246
  return 0
154
247
 
155
248
 
@@ -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
- print(output)
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
- print(output)
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
- print(output)
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
- print(output)
169
+ emit(output)
157
170
  return 0
158
171
 
159
172
 
@@ -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())