ffmpeg-skill 0.9.0 → 0.12.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.
Files changed (57) hide show
  1. package/README.md +315 -122
  2. package/SKILL.md +115 -18
  3. package/bin/install.js +16 -2
  4. package/mcp/server.py +2 -0
  5. package/package.json +15 -3
  6. package/references/ci-platform-pitfalls.md +111 -0
  7. package/references/process-pitfalls.md +85 -0
  8. package/references/scripts.md +122 -11
  9. package/scripts/_common.py +247 -15
  10. package/scripts/_contract.py +420 -48
  11. package/scripts/audio.py +101 -8
  12. package/scripts/background.py +73 -0
  13. package/scripts/caption.py +97 -18
  14. package/scripts/check.py +21 -7
  15. package/scripts/color.py +104 -13
  16. package/scripts/crop.py +79 -0
  17. package/scripts/cut.py +85 -11
  18. package/scripts/export.py +16 -7
  19. package/scripts/fit.py +76 -12
  20. package/scripts/graphics.py +12 -3
  21. package/scripts/insert.py +128 -0
  22. package/scripts/join.py +88 -8
  23. package/scripts/loudness.py +3 -3
  24. package/scripts/multicam.py +11 -1
  25. package/scripts/overlay.py +64 -5
  26. package/scripts/proxy.py +82 -0
  27. package/scripts/render.py +13 -2
  28. package/scripts/reverse.py +56 -0
  29. package/scripts/scenes.py +15 -3
  30. package/scripts/sequence.py +124 -0
  31. package/scripts/silence.py +2 -2
  32. package/scripts/stabilize.py +83 -0
  33. package/scripts/sync.py +9 -1
  34. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  36. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  45. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  46. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  47. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  48. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  51. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  52. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  53. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  54. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  55. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  56. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
package/scripts/color.py CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env python3
2
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.
3
+ real tone mapping, apply a .cube LUT (Log footage, creative grades), fix wrong
4
+ colour tags without re-encoding, or apply typed primary colour correction
5
+ (exposure, contrast, saturation, white balance).
5
6
 
6
7
  Examples:
7
8
  python3 color.py iphone_hdr.mov --to-sdr # PQ/HLG -> BT.709 SDR, hable tonemap
@@ -11,16 +12,61 @@ Examples:
11
12
  python3 color.py wrongly_tagged.mp4 --retag bt709 # metadata only, stream copy
12
13
  python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
13
14
  python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
15
+ python3 color.py flat.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05
14
16
  """
15
17
  import argparse
16
18
  import os
17
19
  import sys
18
20
  from typing import List
19
21
 
20
- 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
22
+ from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
21
23
 
22
24
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
23
25
 
26
+ # Typed primary correction: each flag is one option of one real, always-available libavfilter filter
27
+ # (never a caller-supplied filter string). Range is this script's own safe subset of what the filter
28
+ # documents (`ffmpeg -h filter=<name>`), not the filter's full technical range. default is each filter's
29
+ # own documented no-op value, so every stage below is always emitted and the chain never depends on
30
+ # which flags were actually given.
31
+ CORRECTION = {
32
+ # flag default lo hi unit
33
+ "exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
34
+ "contrast": (1.0, 0.0, 2.0, "x"), # eq filter; 0=flat grey, 1=unchanged, 2=double contrast
35
+ "saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
36
+ "temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (its own default)
37
+ "tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
38
+ }
39
+
40
+
41
+ def _checked(args: argparse.Namespace, flag: str) -> float:
42
+ _, lo, hi, unit = CORRECTION[flag]
43
+ value = getattr(args, flag)
44
+ if not (lo <= value <= hi):
45
+ die(f"--{flag} {value:g} is outside {lo:g}..{hi:g} {unit} (the safe range this tool guarantees)")
46
+ return value
47
+
48
+
49
+ def correction_chain(args: argparse.Namespace) -> str:
50
+ """Four always-present filter stages, in a fixed order chosen so each stage sees a picture already
51
+ corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint, so
52
+ contrast/saturation act on colour-balanced footage) -> contrast -> saturation (the most creative-
53
+ adjacent stage, applied last). `tint` (-1 green .. +1 magenta) is not a single ffmpeg option: it is
54
+ expressed as colorbalance's three midtone channels (gm=-tint, rm=bm=tint/2) so a positive tint shifts
55
+ midtones toward magenta and a negative one toward green without changing overall midtone lightness,
56
+ the same balanced-axis convention colour tools use for a one-dial tint control."""
57
+ exposure = _checked(args, "exposure")
58
+ contrast = _checked(args, "contrast")
59
+ saturation = _checked(args, "saturation")
60
+ temperature = _checked(args, "temperature")
61
+ tint = _checked(args, "tint")
62
+ gm, rm, bm = -tint, tint / 2.0, tint / 2.0
63
+ return ",".join([
64
+ f"exposure=exposure={exposure:g}",
65
+ f"colortemperature=temperature={temperature:g}",
66
+ f"colorbalance=rm={rm:g}:gm={gm:g}:bm={bm:g}",
67
+ f"eq=contrast={contrast:g}:saturation={saturation:g}",
68
+ ])
69
+
24
70
 
25
71
  def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
26
72
  v = meta["video"]
@@ -48,11 +94,23 @@ def main() -> int:
48
94
  mode.add_argument("--lut", help=".cube LUT to apply (3D)")
49
95
  mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
50
96
  mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
97
+ mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint")
51
98
  ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
52
99
  ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
53
100
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
54
101
  ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
55
102
  ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
103
+ ap.add_argument("--exposure", type=float, default=CORRECTION["exposure"][0], help="--correct: exposure in stops, -3..3 (default 0)")
104
+ ap.add_argument("--contrast", type=float, default=CORRECTION["contrast"][0], help="--correct: contrast, 0..2, 1=unchanged (default 1)")
105
+ ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
106
+ ap.add_argument("--temperature", type=float, default=CORRECTION["temperature"][0], help="--correct: white-balance temperature in Kelvin, 2000..12000, 6500=unchanged (default 6500)")
107
+ ap.add_argument("--tint", type=float, default=CORRECTION["tint"][0], help="--correct: green(-1)/magenta(+1) tint, 0=unchanged (default 0)")
108
+ ap.add_argument("--audio-stream", type=int, default=0,
109
+ help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
110
+ "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
111
+ "the first track, same as leaving it unset always did. Only affects modes that re-encode "
112
+ "audio (--to-sdr, --lut, --correct, and --retag's re-encode fallback) -- --strip-dovi and "
113
+ "a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
56
114
  ap.add_argument("--crf", type=int, default=18)
57
115
  ap.add_argument("--preset", default="medium")
58
116
  add_common(ap)
@@ -64,6 +122,11 @@ def main() -> int:
64
122
  die("input has no video stream")
65
123
  v = meta["video"]
66
124
  has_audio = bool(meta.get("audio"))
125
+ audio_streams = meta.get("audio_streams") or []
126
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
127
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
128
+ if args.audio_stream and not audio_streams:
129
+ die("--audio-stream needs an input with audio streams")
67
130
 
68
131
  if args.strip_dovi:
69
132
  output = args.output or default_output(args.input, "nodv")
@@ -76,7 +139,7 @@ def main() -> int:
76
139
  cmd += ["-movflags", "+faststart"]
77
140
  cmd.append(output)
78
141
  run(cmd)
79
- r = probe(output)
142
+ r = probe(output, role="output")
80
143
  info(f"wrote {output} (dolby_vision={r['video'].get('dolby_vision')})")
81
144
  emit(output)
82
145
  return 0
@@ -96,22 +159,46 @@ def main() -> int:
96
159
  cmd += ["-movflags", "+faststart"]
97
160
  cmd.append(output)
98
161
  proc = run(cmd, check=False)
162
+ dropped_streams = False
99
163
  if proc.returncode != 0:
100
- # some codecs cannot carry retagged colour info without a bitstream filter; fall back to re-encode
101
- info("stream copy could not rewrite tags, re-encoding")
102
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", "0:a:0?"] + x264_args(args.crf, args.preset, keep_bt709=False)
103
- cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
104
- run(cmd)
164
+ # Some codecs cannot carry retagged colour info without a bitstream filter, so the
165
+ # stream copy above fails and we fall back to re-encoding video+audio. The copy path
166
+ # (-map 0 -c copy) keeps every stream -- extra audio tracks, subtitles, chapters,
167
+ # attached pictures -- byte-for-byte; -c:s/-c:d copy here keeps that same guarantee
168
+ # for subtitle/data streams even though video/audio must be re-encoded. Only if THAT
169
+ # also fails (e.g. a subtitle codec genuinely incompatible with the target container)
170
+ # do we drop to video+selected-audio-only, and even then we say so explicitly rather
171
+ # than silently reporting "completed" with streams missing.
172
+ info("stream copy could not rewrite tags, re-encoding video/audio (subtitles/data streams kept)")
173
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
174
+ "-map", "0:s?", "-map", "0:d?"] + x264_args(args.crf, args.preset, keep_bt709=False)
175
+ cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
176
+ cmd += (aac_args() if has_audio else []) + ["-c:s", "copy", "-c:d", "copy"] + [output]
177
+ proc2 = run(cmd, check=False)
178
+ if proc2.returncode != 0:
179
+ info("re-encode with subtitles/data streams kept also failed; dropping them")
180
+ dropped_streams = True
181
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"] + x264_args(args.crf, args.preset, keep_bt709=False)
182
+ cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
183
+ run(cmd)
105
184
  info(f"wrote {output} (tags -> {args.retag})")
106
- emit(output)
185
+ emit(output, reencoded=proc.returncode != 0, dropped_non_av_streams=dropped_streams)
107
186
  return 0
108
187
 
188
+ measurements = None
109
189
  if args.to_sdr:
110
190
  if not v.get("hdr") and not args.force:
111
191
  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.")
112
192
  vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
113
193
  output = args.output or default_output(args.input, "sdr")
114
194
  tag = "sdr"
195
+ elif args.correct:
196
+ vf = correction_chain(args)
197
+ output = args.output or default_output(args.input, "correct")
198
+ tag = "correct"
199
+ # OBSERVED technical measurements (signalstats: luma / saturation distribution), never a
200
+ # "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
201
+ measurements = {"input": analyze_levels(args.input)}
115
202
  else:
116
203
  if not os.path.exists(args.lut):
117
204
  die(f"LUT not found: {args.lut}")
@@ -124,13 +211,17 @@ def main() -> int:
124
211
  output = args.output or default_output(args.input, "lut")
125
212
  tag = "lut"
126
213
 
127
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a:0?"]
214
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
128
215
  cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
129
216
  run(cmd)
130
- r = probe(output)
217
+ r = probe(output, role="output")
131
218
  info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
132
219
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
133
- emit(output)
220
+ if measurements is not None:
221
+ measurements["output"] = analyze_levels(output)
222
+ emit(output, measurements=measurements)
223
+ else:
224
+ emit(output)
134
225
  return 0
135
226
 
136
227
 
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """Crop a video to an exact pixel rectangle.
3
+
4
+ {x, y, width, height} are literal pixel offsets and dimensions in the SOURCE
5
+ frame, not aspect-ratio-relative -- for cropping to a target aspect ratio
6
+ (e.g. 16:9 -> 9:16) use fit.py --fit crop instead, which computes the
7
+ rectangle for you and lets you steer it with --crop-x/--crop-y. This tool is
8
+ for when the caller already knows the exact rectangle (a face-detection box,
9
+ a saved crop from a previous edit, a hand-picked region).
10
+
11
+ The rectangle must lie entirely inside the source frame after accounting for
12
+ any display rotation, and --width/--height must be even (required for 4:2:0
13
+ chroma subsampling, the pixel format every encoder here uses) -- given values
14
+ are validated and refused, never silently rounded, since a caller-specified
15
+ rectangle should do exactly what was asked or fail loudly.
16
+
17
+ Examples:
18
+ python3 crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920
19
+ python3 crop.py input.mp4 --x 0 --y 140 --width 1920 --height 800 -o cropped.mp4
20
+ """
21
+ import argparse
22
+ import sys
23
+
24
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
25
+
26
+
27
+ def main() -> int:
28
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
29
+ ap.add_argument("input")
30
+ ap.add_argument("-o", "--output", help="output file (default: <name>_crop.<ext>)")
31
+ ap.add_argument("--x", type=int, required=True, help="left edge of the crop rectangle, in source pixels")
32
+ ap.add_argument("--y", type=int, required=True, help="top edge of the crop rectangle, in source pixels")
33
+ ap.add_argument("--width", type=int, required=True, help="crop width in px (must be even)")
34
+ ap.add_argument("--height", type=int, required=True, help="crop height in px (must be even)")
35
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
36
+ ap.add_argument("--preset", default="medium", help="x264 preset")
37
+ ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
38
+ add_common(ap)
39
+ args = ap.parse_args()
40
+ apply_common(args)
41
+
42
+ if args.x < 0 or args.y < 0:
43
+ die(f"--x/--y must be >= 0, got x={args.x} y={args.y}")
44
+ if args.width <= 0 or args.height <= 0:
45
+ die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
46
+ if args.width % 2 or args.height % 2:
47
+ die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
48
+
49
+ meta = probe(args.input)
50
+ if not meta.get("video"):
51
+ die("input has no video stream")
52
+ sw, sh = meta["video"]["width"], meta["video"]["height"]
53
+ if meta["video"].get("rotation") in (90, -90, 270, -270):
54
+ sw, sh = sh, sw
55
+ if args.x + args.width > sw or args.y + args.height > sh:
56
+ die(f"crop rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
57
+ has_audio = bool(meta.get("audio"))
58
+
59
+ output = args.output or default_output(args.input, "crop")
60
+ vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}", "setsar=1"]
61
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", ",".join(vf)]
62
+ cmd += video_args(meta, args.crf, args.preset)
63
+ cmd += cfr_args(meta, args.fps)
64
+ if has_audio:
65
+ cmd += aac_args()
66
+ else:
67
+ cmd += ["-an"]
68
+ cmd.append(output)
69
+ run(cmd)
70
+
71
+ result = probe(output, role="output")
72
+ v = result["video"]
73
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']})")
74
+ emit(output)
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__":
79
+ sys.exit(main())
package/scripts/cut.py CHANGED
@@ -5,10 +5,24 @@ Lossless stream copy (-c copy) is preferred. Cuts snap to keyframes in that
5
5
  mode, so if frame accuracy matters pass --accurate to re-encode. Multiple
6
6
  segments are cut individually and concatenated with the concat demuxer.
7
7
 
8
+ Audio: a stream copy lands on a packet boundary (about 21 ms for AAC, one
9
+ demuxer block for WAV); --accurate decodes and trims to the sample. The output
10
+ extension picks the codec: -o out.wav writes PCM (never an AAC packet inside a
11
+ WAV), -o out.m4a writes AAC; an audio extension on a video input drops the
12
+ picture (mp4 -> wav extraction). The result reports `precision`
13
+ (packet / sample / codec_frame / frame) and the measured duration error, plus
14
+ `mode` (copy / accurate / hybrid -- "hybrid" means a lossless cut silently
15
+ re-encoded because the keyframe snap exceeded --tolerance), `keyframe_snapped`,
16
+ `requested_start`/`requested_end` (or `requested_segments` for --segments),
17
+ `requested_duration`, `output_duration` and `duration_delta_seconds` -- so a
18
+ caller never has to trust "it probably cut where I asked" on faith.
19
+
8
20
  Examples:
9
21
  python3 cut.py input.mp4 --start 00:00:10 --end 00:00:25
10
22
  python3 cut.py input.mp4 --segments 0:05-0:12,1:00-1:20 -o highlights.mp4
11
23
  python3 cut.py input.mp4 --start 3.5 --duration 10 --accurate
24
+ python3 cut.py talk.wav --start 1.2345 --end 2.3456 --accurate -o part.wav # sample-exact
25
+ python3 cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav # audio extraction
12
26
  """
13
27
  import argparse
14
28
  import os
@@ -16,7 +30,7 @@ import sys
16
30
  import tempfile
17
31
  from typing import List, Tuple
18
32
 
19
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
33
+ from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run
20
34
 
21
35
 
22
36
  def parse_segments(spec: str) -> List[Tuple[float, float]]:
@@ -37,21 +51,68 @@ def parse_segments(spec: str) -> List[Tuple[float, float]]:
37
51
  return segs
38
52
 
39
53
 
54
+ def encode_args(meta: dict, dst: str, crf: int, preset: str) -> List[str]:
55
+ """Codec arguments for a re-encoded cut: video + AAC into a video container, otherwise the codec
56
+ the audio extension names (PCM for .wav, FLAC, MP3, AAC...) with no video stream."""
57
+ if is_audio_output(dst) or not meta.get("video"):
58
+ return ["-vn"] + audio_codec_for(dst)
59
+ return video_args(meta, crf, preset) + cfr_args(meta) + aac_args()
60
+
61
+
62
+ def copy_args(meta: dict, dst: str) -> List[str]:
63
+ """Stream-copy arguments: everything into a video container, audio only into an audio extension."""
64
+ if is_audio_output(dst) and meta.get("video"):
65
+ return ["-vn", "-c:a", "copy"]
66
+ return ["-c", "copy"]
67
+
68
+
69
+ LOSSLESS_AUDIO = {"pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le", "flac"}
70
+
71
+
72
+ def precision_of(meta: dict, dst: str, reencoded: bool) -> str:
73
+ """How exact the cut is, measured on what was written:
74
+
75
+ packet stream copy; the cut lands on a packet (audio) or keyframe (video) boundary
76
+ sample decoded audio trimmed to the sample and written losslessly (PCM / FLAC)
77
+ codec_frame decoded audio trimmed to the sample, then framed by a lossy encoder (AAC 1024,
78
+ MP3 1152, Opus 960 samples) which also adds its priming delay to the reported length
79
+ frame re-encoded video: the picture is frame-exact, the audio underneath is sample-trimmed
80
+ """
81
+ if not reencoded:
82
+ return "packet"
83
+ if is_audio_output(dst) or not meta.get("video"):
84
+ codec = audio_codec_for(dst)[1]
85
+ return "sample" if codec in LOSSLESS_AUDIO else "codec_frame"
86
+ return "frame"
87
+
88
+
40
89
  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
90
  """Cut one segment. Returns True if the result was re-encoded."""
42
91
  dur = end - start
43
92
  meta = meta or probe(src)
93
+ audio_only = is_audio_output(dst) or not meta.get("video")
94
+ if not reencode and audio_only and audio_codec_for(dst)[1].startswith("pcm") and not str((meta.get("audio") or {}).get("codec", "")).startswith("pcm"):
95
+ info(f"{(meta.get('audio') or {}).get('codec')} packets cannot be copied into a PCM container; decoding to PCM")
96
+ reencode = True
44
97
  if reencode:
45
- cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"]
46
- cmd += video_args(meta, crf, preset) + cfr_args(meta) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
98
+ # -ss before -i seeks, then decoding discards samples up to the exact start (accurate_seek);
99
+ # atrim bounds the decoded stream to the requested length at sample resolution.
100
+ cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
101
+ if is_audio_output(dst) or not meta.get("video"):
102
+ cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
103
+ cmd += encode_args(meta, dst, crf, preset) + ["-avoid_negative_ts", "make_zero", dst]
104
+ elif audio_only:
105
+ # output-side seek: an input seek on a video file lands on the previous video keyframe and on
106
+ # FLAC/MP3 on a coarse index; reading from the start and dropping packets is exact to the packet
107
+ cmd = ffmpeg_base() + ["-i", src, "-ss", f"{start:.6f}", "-t", f"{dur:.6f}"] + copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst]
47
108
  else:
48
- cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}", "-c", "copy", "-avoid_negative_ts", "make_zero", dst]
109
+ cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"] + copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst]
49
110
  proc = run(cmd, check=False)
50
111
  if proc.returncode != 0:
51
112
  if not reencode:
52
113
  info("stream copy failed, falling back to re-encode")
53
114
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
54
- die(f"ffmpeg failed:\n{proc.stderr.strip()}")
115
+ die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
55
116
  if not reencode and tolerance >= 0 and not STATE["dry_run"]:
56
117
  got = probe(dst).get("duration") or 0.0
57
118
  if abs(got - dur) > tolerance:
@@ -70,7 +131,7 @@ def main() -> int:
70
131
  g.add_argument("--end", help="end time")
71
132
  g.add_argument("--duration", help="duration instead of --end")
72
133
  ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
73
- 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)")
134
+ ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
74
135
  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)")
75
136
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
76
137
  ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
@@ -126,14 +187,27 @@ def main() -> int:
126
187
  proc = run(cmd, check=False)
127
188
  if proc.returncode != 0:
128
189
  info("concat with stream copy failed, re-encoding the join")
129
- cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + video_args(meta, args.crf, args.preset) + cfr_args(meta) + aac_args() + [output]
190
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + encode_args(meta, output, args.crf, args.preset) + [output]
130
191
  run(cmd)
131
192
 
132
- result = probe(output)
193
+ result = probe(output, role="output")
133
194
  expected = sum(e - s for s, e in segments)
134
- info(f"wrote {output} ({result.get('duration'):.3f}s, expected ~{expected:.3f}s, "
135
- + ("re-encoded" if reencoded else "lossless stream copy") + ")")
136
- emit(output)
195
+ precision = precision_of(meta, output, reencoded)
196
+ got = result.get("duration")
197
+ error_ms = round((got - expected) * 1000, 3) if got is not None and not STATE["dry_run"] else None
198
+ # mode: "copy" (untouched lossless), "accurate" (--accurate was asked for), "hybrid" (asked for
199
+ # lossless but the keyframe snap exceeded --tolerance so this segment silently re-encoded instead)
200
+ mode = "copy" if not reencoded else ("accurate" if args.accurate else "hybrid")
201
+ keyframe_snapped = precision == "packet"
202
+ info(f"wrote {output} ({got:.3f}s, expected ~{expected:.3f}s, "
203
+ + ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
204
+ emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
205
+ requested_start=round(segments[0][0], 6) if len(segments) == 1 else None,
206
+ requested_end=round(segments[0][1], 6) if len(segments) == 1 else None,
207
+ requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
208
+ requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
209
+ duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
210
+ mode=mode, keyframe_snapped=keyframe_snapped)
137
211
  return 0
138
212
 
139
213
 
package/scripts/export.py CHANGED
@@ -11,15 +11,19 @@ Presets:
11
11
  prores ProRes 422 HQ .mov, PCM 16-bit audio (editing master)
12
12
  h265 HEVC CRF 24 (libx265) with hvc1 tag for Apple compatibility
13
13
  gif 480px wide palette-optimised GIF at 12 fps (short previews)
14
+ copy stream copy, no re-encode: same codecs, container and colour
15
+ tags as the source (a delivery target with nothing to change)
14
16
 
15
17
  Examples:
16
18
  python3 export.py final.mp4 --preset youtube
17
19
  python3 export.py final.mp4 --preset reels --fit crop
18
20
  python3 export.py final.mp4 --preset prores -o master.mov
21
+ python3 export.py final.mp4 --preset copy -o delivered.mp4
19
22
  python3 export.py --list
20
23
  """
21
24
  import argparse
22
25
  import sys
26
+ from pathlib import Path
23
27
  from typing import Dict, List
24
28
 
25
29
  from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
@@ -32,6 +36,7 @@ PRESETS: Dict[str, Dict] = {
32
36
  "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
37
  "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
38
  "gif": {"w": 480, "h": None, "ext": "gif", "video": [], "audio": [], "max": None, "desc": "480px palette GIF, 12fps"},
39
+ "copy": {"w": None, "h": None, "ext": None, "video": ["-c:v", "copy"], "audio": ["-c:a", "copy"], "max": None, "desc": "stream copy, no re-encode (source codecs/container/colour tags unchanged)"},
35
40
  }
36
41
 
37
42
  BT709 = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
@@ -63,10 +68,11 @@ def main() -> int:
63
68
  meta = probe(args.input)
64
69
  if not meta.get("video"):
65
70
  die("input has no video stream")
66
- if meta["video"].get("hdr") and args.preset != "prores":
71
+ if meta["video"].get("hdr") and args.preset not in ("prores", "copy"):
67
72
  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"))
68
73
  has_audio = bool(meta.get("audio"))
69
74
  output = args.output or default_output(args.input, args.preset, p["ext"])
75
+ out_ext = Path(output).suffix.lstrip(".").lower()
70
76
 
71
77
  vf: List[str] = []
72
78
  if p["w"] and not args.no_scale:
@@ -97,11 +103,14 @@ def main() -> int:
97
103
  if STATE["fast"] and "-preset" in video:
98
104
  video[video.index("-preset") + 1] = "veryfast"
99
105
  cmd += video
100
- if "-r" not in video:
101
- cmd += cfr_args(meta)
102
- if args.preset not in ("prores",):
103
- cmd += BT709
104
- if p["ext"] == "mp4":
106
+ if args.preset != "copy":
107
+ # a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
108
+ # no longer be a copy, and would silently mislabel colour the agent never actually looked at
109
+ if "-r" not in video:
110
+ cmd += cfr_args(meta)
111
+ if args.preset not in ("prores",):
112
+ cmd += BT709
113
+ if out_ext == "mp4":
105
114
  cmd += ["-movflags", "+faststart"]
106
115
  cmd += (p["audio"] if has_audio else ["-an"])
107
116
  if p["max"] and not args.allow_long and (meta.get("duration") or 0) > p["max"]:
@@ -109,7 +118,7 @@ def main() -> int:
109
118
  cmd += ["-t", f"{p['max']:.3f}"]
110
119
  cmd.append(output)
111
120
  run(cmd)
112
- result = probe(output)
121
+ result = probe(output, role="output")
113
122
  v = result["video"]
114
123
  info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
115
124
  emit(output)