ffmpeg-skill 1.4.13 → 1.4.15

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/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`1.4.13`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.4.15`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -46,7 +46,7 @@ For the whole of 1.x:
46
46
  | Tool ids (`ffmpeg-skill/<name>`) and script names | never removed or renamed |
47
47
  | CLI arguments (`argparse` dests, flags, positionals) | never removed, renamed, or made newly required; new optional arguments may be added |
48
48
  | `--json` output keys, and the keys of `contract --json` / `doctor --json` | never removed or given a different type; new keys may be added |
49
- | Exit codes (0 success, 1 failure, 2 unknown/undecidable in `doctor`) | unchanged |
49
+ | Exit codes (0 success, 1 failure incl. ffmpeg failures, 2 unknown/undecidable in `doctor`, 124 timeout, 127 missing tool, 128+signal interrupted) | unchanged |
50
50
  | `contract_version` (`1.0`) | unchanged; a ToolSpec shape change is a major |
51
51
  | MCP `tools/list` names and `inputSchema` property names | derived from the above, so covered by the same promise |
52
52
  | Behaviour of a tool for the same input and arguments | may change only to fix a defect or to track an FFmpeg change, and every such change gets a CHANGELOG line |
@@ -83,7 +83,7 @@ on, the line says so.
83
83
  ```json
84
84
  {
85
85
  "contract_version": "1.0",
86
- "skill": {"id": "ffmpeg-skill", "version": "1.4.13", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.4.15", "execution_mode": "local", "kind": "execution",
87
87
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
88
88
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
89
89
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -318,7 +318,8 @@ before, and, when `--json` was given, on stdout:
318
318
  `message` carries the script's own reason (missing input, ffprobe failure, the last
319
319
  stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
320
320
  flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
321
- or run so the caller can retry or report without re-deriving the command. `code` is a
321
+ or run so the caller can retry or report without re-deriving the command; `kind: ffmpeg`
322
+ failures add `ffmpeg_returncode` (ffmpeg's own exit code; the process exits 1). `code` is a
322
323
  purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
323
324
  doesn't already make) for a caller that wants a stable enum instead of matching `kind`
324
325
  strings. `retryable` is currently always `false`: none of the kinds are distinguishable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.13",
3
+ "version": "1.4.15",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -281,6 +281,8 @@ rather than a continuous curve. `START`/`END` take seconds or `mm:ss`;
281
281
  0.5 = half speed). Picking exactly where a ramp should ease in or out is a
282
282
  judgement call for the calling agent, made concrete here as the segment
283
283
  boundaries it supplies.
284
+ A subtitle/data track in the source is not carried into the retimed/concatenated
285
+ output; the result says so with `dropped_non_av_streams: true`.
284
286
 
285
287
  ### loop.py — repeat a clip
286
288
  ```
@@ -308,6 +310,8 @@ overlap or run past A's end, and B must have enough material from `--from`.
308
310
  `--audio a` (default) keeps A's audio untouched and stream-copied; `b` replaces
309
311
  it inside each window with B's; `mix` plays both. The output's length is
310
312
  verified against A's.
313
+ A subtitle/data track in the source is not carried into the retimed/concatenated
314
+ output; the result says so with `dropped_non_av_streams: true`.
311
315
 
312
316
  ### metadata.py — chapter markers and container tags, streams copied
313
317
  ```
@@ -362,6 +366,8 @@ channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
362
366
  silent track generated for clips without audio), then chains `xfade` +
363
367
  `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
364
368
  longer than 2 × the transition. Use `--transition none` for a plain cut.
369
+ A subtitle/data track in the source is not carried into the retimed/concatenated
370
+ output; the result says so with `dropped_non_av_streams: true`.
365
371
 
366
372
  ### render.py — the whole edit in one project.json
367
373
  ```
@@ -596,7 +602,8 @@ standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
596
602
  speech so music dips under dialogue and swells in pauses. `--downmix` uses the
597
603
  ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
598
604
  leaves a 1-channel input untouched and downmixes >2 channels through
599
- swresample. Video is always stream-copied.
605
+ swresample. Video is always stream-copied, and so is a subtitle/data track
606
+ when the container can hold it (`dropped_non_av_streams` says when it could not).
600
607
  Run `loudness.py` after this for final levels.
601
608
 
602
609
  ### loudness.py — EBU R128 normalisation
@@ -604,7 +611,8 @@ Run `loudness.py` after this for final levels.
604
611
  loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
605
612
  ```
606
613
  Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
607
- the true-peak ceiling allows). Video is stream-copied; audio becomes AAC in
614
+ the true-peak ceiling allows). Video and any subtitle/data track are
615
+ stream-copied (`dropped_non_av_streams` reports a track the container refused); audio becomes AAC in
608
616
  video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
609
617
  The written file is measured again: a lossy encoder can push peaks past the
610
618
  ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
@@ -629,6 +637,7 @@ proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]
629
637
  Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
630
638
  factor, re-encodes at a proxy-grade `--crf` (default 30) with the fastest
631
639
  x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
632
- run `color.py --to-sdr` first if SDR is wanted). Only executes the spec
640
+ run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
641
+ track when the container can hold it (`dropped_non_av_streams`). Only executes the spec
633
642
  given — does not decide which asset to proxy or what for.
634
643
 
@@ -398,8 +398,12 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
398
398
  def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
399
399
  # Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
400
400
  # every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
401
+ # The process exit code is always 1 for an ffmpeg failure: ffmpeg's own code (1, 69, 218, 234,
402
+ # a negative signal number...) varies by build and by the failing stage, and 124/127/130/143
403
+ # are reserved for timeout, missing tool and interrupts. The raw code is kept in the JSON
404
+ # document as `ffmpeg_returncode` for a caller that wants it. docs/design-decisions.md.
401
405
  tail = "\n".join(stderr.strip().splitlines()[-15:])
402
- die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1, kind="ffmpeg")
406
+ die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode)
403
407
 
404
408
 
405
409
  def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
@@ -461,6 +465,112 @@ def _check_output_path(cmd: Sequence[str]) -> None:
461
465
  parent = os.path.dirname(os.path.abspath(output))
462
466
  if not os.path.isdir(parent):
463
467
  die(f"output directory {parent!r} does not exist; create it first (this tool never creates directories)")
468
+ if not os.access(parent, os.W_OK):
469
+ die(f"output directory {parent!r} is not writable")
470
+
471
+
472
+ EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
473
+
474
+
475
+ def _pid_dead(pid: int) -> bool:
476
+ """True only when the process is known not to exist. POSIX: signal 0. Windows: OpenProcess
477
+ fails with ERROR_INVALID_PARAMETER (87) for a pid that is not in use; any other outcome
478
+ (a handle, or access denied) means it is live. Unknown is treated as live."""
479
+ if os.name != "nt":
480
+ try:
481
+ os.kill(pid, 0)
482
+ except ProcessLookupError:
483
+ return True
484
+ except OSError:
485
+ pass
486
+ return False
487
+ try:
488
+ import ctypes
489
+ k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
490
+ handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
491
+ if handle:
492
+ k32.CloseHandle(handle)
493
+ return False
494
+ return k32.GetLastError() == 87
495
+ except Exception:
496
+ return False
497
+
498
+
499
+ class _OutputLock:
500
+ """Two runs writing the same output at once used to both report `completed` while one of
501
+ them described the other's file (sweep F1). A lock file next to the output, created with
502
+ O_EXCL and holding the writer's pid, makes the second run refuse as `kind: input`. A lock
503
+ whose pid is dead (POSIX) or older than an hour is stale and taken over."""
504
+ def __init__(self, output: str) -> None:
505
+ self.path: Optional[str] = None
506
+ self.fd: Optional[int] = None
507
+ if output == "-" or output.startswith("pipe:") or output.startswith("-"):
508
+ return
509
+ d, base = os.path.split(os.path.abspath(output))
510
+ self.path = os.path.join(d, f".{base}.ffskill-lock")
511
+
512
+ def __enter__(self) -> "_OutputLock":
513
+ if not self.path:
514
+ return self
515
+ for attempt in (0, 1):
516
+ try:
517
+ self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
518
+ os.write(self.fd, str(os.getpid()).encode())
519
+ return self
520
+ except FileExistsError:
521
+ if attempt == 0 and self._stale():
522
+ try:
523
+ os.remove(self.path)
524
+ except OSError:
525
+ pass
526
+ continue
527
+ die(f"another run is writing {os.path.basename(self.path)[1:-len('.ffskill-lock')]!r} right now "
528
+ f"(lock {self.path}); wait for it or choose a different --output/-o path")
529
+ except OSError:
530
+ return self # unlockable location (read-only dir surfaces elsewhere): proceed without a lock
531
+ return self
532
+
533
+ def _stale(self) -> bool:
534
+ try:
535
+ pid = int(open(self.path).read().strip() or "0")
536
+ if pid > 0 and _pid_dead(pid):
537
+ return True
538
+ import time
539
+ return time.time() - os.path.getmtime(self.path) > 3600
540
+ except (OSError, ValueError):
541
+ return True
542
+
543
+ def __exit__(self, *exc: Any) -> None:
544
+ if self.fd is not None:
545
+ try:
546
+ os.close(self.fd)
547
+ except OSError:
548
+ pass
549
+ if self.path:
550
+ try:
551
+ os.remove(self.path)
552
+ except OSError:
553
+ pass
554
+
555
+
556
+ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
557
+ """An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
558
+ encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
559
+ with an even-dimension scale prepended to its -vf chain (or a new -vf when the command had
560
+ none); None when the failure is something else or the graph is a -filter_complex the
561
+ caller has to fix itself."""
562
+ if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
563
+ return None
564
+ if "-filter_complex" in cmd:
565
+ return None
566
+ new = list(cmd)
567
+ if "-vf" in new:
568
+ i = new.index("-vf") + 1
569
+ new[i] = EVEN_SCALE + "," + new[i]
570
+ return new
571
+ if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
572
+ return None
573
+ return new[:-1] + ["-vf", EVEN_SCALE, new[-1]]
464
574
 
465
575
 
466
576
  def _check_existing_output(cmd: Sequence[str]) -> None:
@@ -555,24 +665,41 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
555
665
  info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
556
666
  if STATE.dry_run and is_ffmpeg:
557
667
  return subprocess.CompletedProcess(list(cmd), 0, "", "")
558
- exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
559
- if STATE.progress and is_ffmpeg and exec_cmd[-1] != "-":
560
- proc = _run_with_progress(exec_cmd, check)
561
- else:
562
- proc = _run_captured(exec_cmd, check)
563
- if final and tmp:
564
- if proc.returncode == 0:
565
- try:
566
- os.replace(tmp, final)
567
- except OSError as e:
668
+ with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
669
+ exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
670
+ proc = _execute(exec_cmd)
671
+ if proc.returncode != 0 and is_ffmpeg:
672
+ retry = _odd_dimension_retry(exec_cmd, proc.stderr or "")
673
+ if retry is not None:
674
+ info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
675
+ STATE.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
676
+ proc = _execute(retry)
677
+ elif "not divisible by 2" in (proc.stderr or ""):
678
+ die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
679
+ "cannot pad them itself; make them even first, e.g. fit.py --width/--height, then retry",
680
+ kind="input")
681
+ if proc.returncode != 0 and check:
682
+ _fail(exec_cmd, proc.returncode, proc.stderr or "")
683
+ if final and tmp:
684
+ if proc.returncode == 0:
685
+ try:
686
+ os.replace(tmp, final)
687
+ except OSError as e:
688
+ _cleanup_partial_output(exec_cmd)
689
+ die(f"could not replace {final} with the new output: {e}", kind="output")
690
+ _remember_output(cmd)
691
+ else:
568
692
  _cleanup_partial_output(exec_cmd)
569
- die(f"could not replace {final} with the new output: {e}", kind="output")
570
- _remember_output(cmd)
571
- else:
572
- _cleanup_partial_output(exec_cmd)
573
693
  return proc
574
694
 
575
695
 
696
+ def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
697
+ """One attempt, never exiting on failure (run() decides after its retries)."""
698
+ if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
699
+ return _run_with_progress(exec_cmd, False)
700
+ return _run_captured(exec_cmd, False)
701
+
702
+
576
703
  def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
577
704
  """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
578
705
  silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
package/scripts/audio.py CHANGED
@@ -20,7 +20,7 @@ import argparse
20
20
  import sys
21
21
  from typing import List
22
22
 
23
- from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, fmt_secs
23
+ from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
24
24
 
25
25
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
26
26
 
@@ -226,8 +226,11 @@ def main() -> int:
226
226
  if not keep_video:
227
227
  # audio-only outputs: a looped music bed is infinite, -shortest ends the run with the main track
228
228
  cmd.append("-shortest")
229
- cmd.append(output)
230
- run(cmd)
229
+ if keep_video:
230
+ dropped_streams = run_keeping_subtitles(cmd, output)
231
+ else:
232
+ dropped_streams = bool(has_video and (meta.get("subtitle_streams") or meta.get("data_streams")))
233
+ run(cmd + [output])
231
234
  r = probe(output, role="output")
232
235
  a = r["audio"]
233
236
  if r.get("video") and audio_out and not STATE.dry_run:
@@ -235,7 +238,8 @@ def main() -> int:
235
238
  info(f"wrote {output} ({fmt_secs(r['duration'])}, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
236
239
  + (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
237
240
  emit(output, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
238
- dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f])
241
+ dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
242
+ dropped_non_av_streams=dropped_streams)
239
243
  return 0
240
244
 
241
245
 
package/scripts/batch.py CHANGED
@@ -156,8 +156,23 @@ def main() -> int:
156
156
  if not outdir.is_absolute():
157
157
  outdir = folder / outdir
158
158
  work = Path(args.work) if args.work else outdir / ".work"
159
+ # the children refuse an output whose directory does not exist, under --dry-run too, so the
160
+ # directories are created for the plan as well -- and removed again afterwards when a dry
161
+ # run created them and left them empty (a plan leaves nothing behind, sweep F15)
162
+ created = [d for d in (outdir, work) if not d.exists()]
159
163
  outdir.mkdir(parents=True, exist_ok=True)
160
164
  work.mkdir(parents=True, exist_ok=True)
165
+ if STATE.dry_run and created:
166
+ import atexit
167
+
168
+ def _remove_empty_dirs() -> None:
169
+ for d in sorted(created, key=lambda p: len(str(p)), reverse=True):
170
+ try:
171
+ if not any(d.iterdir()):
172
+ d.rmdir()
173
+ except OSError:
174
+ pass
175
+ atexit.register(_remove_empty_dirs)
161
176
  cache_path = outdir / ".ffskill_cache.json"
162
177
  cache: Dict[str, Any] = {}
163
178
  if cache_path.exists() and not args.force:
package/scripts/broll.py CHANGED
@@ -145,7 +145,8 @@ def main() -> int:
145
145
  if not STATE.dry_run and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
146
146
  die(f"output is {fmt_secs(result.get('duration'))} but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
147
147
  info(f"wrote {output} ({result.get('duration', 0):.3f}s, {len(cutaways)} cutaway(s), audio={args.audio})")
148
- emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio)
148
+ emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio,
149
+ dropped_non_av_streams=bool(meta_a.get("subtitle_streams") or meta_a.get("data_streams")))
149
150
  return 0
150
151
 
151
152
 
package/scripts/color.py CHANGED
@@ -285,6 +285,9 @@ def main() -> int:
285
285
  output = args.output or default_output(args.input, "sdr")
286
286
  tag = "sdr"
287
287
  elif args.correct:
288
+ if v.get("hdr") and not args.force:
289
+ die(f"{args.input} is HDR ({v.get('hdr_format')}); --correct works on SDR pixels and would tag PQ/HLG data as BT.709 "
290
+ f"without a tone map (sweep F10). Run --to-sdr first, or --force to grade the raw values anyway")
288
291
  vf = correction_chain(args)
289
292
  output = args.output or default_output(args.input, "correct")
290
293
  tag = "correct"
@@ -292,6 +295,9 @@ def main() -> int:
292
295
  # "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
293
296
  measurements = {"input": analyze_levels(args.input)}
294
297
  else:
298
+ if v.get("hdr") and not args.force:
299
+ die(f"{args.input} is HDR ({v.get('hdr_format')}); a LUT made for SDR applied to PQ/HLG pixels gives a wrong picture "
300
+ f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
295
301
  if not os.path.exists(args.lut):
296
302
  die(f"LUT not found: {args.lut}")
297
303
  if not (0.0 <= args.lut_strength <= 1.0):
package/scripts/cut.py CHANGED
@@ -32,6 +32,8 @@ from typing import List, Tuple
32
32
 
33
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, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
34
34
 
35
+ # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
36
+ DROPPED_STREAMS: List[str] = []
35
37
  # keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
36
38
  # (reported so the caller can choose a lossless cut at one of them next time)
37
39
  NEAREST_KEYFRAMES: list = []
@@ -115,6 +117,12 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
115
117
  cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
116
118
  if is_audio_output(dst) or not meta.get("video"):
117
119
  cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
120
+ # ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
121
+ # trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
122
+ # sweep F2), so the re-encode carries video/audio only and the result says so
123
+ cmd += ["-sn", "-dn"]
124
+ if meta.get("subtitle_streams") or meta.get("data_streams"):
125
+ DROPPED_STREAMS.append(dst)
118
126
  cmd += encode_args(meta, dst, crf, preset) + ["-avoid_negative_ts", "make_zero", dst]
119
127
  elif audio_only:
120
128
  # output-side seek: an input seek on a video file lands on the previous video keyframe and on
@@ -130,7 +138,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
130
138
  die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
131
139
  if not reencode and tolerance >= 0 and not STATE.dry_run:
132
140
  got = probe(dst).get("duration") or 0.0
133
- if abs(got - dur) > tolerance:
141
+ if abs(got - dur) >= tolerance: # a snap of exactly the tolerance is not "within" it (sweep F20)
134
142
  near = keyframes_near(src, start)
135
143
  alt = ""
136
144
  if near:
@@ -230,6 +238,7 @@ def main() -> int:
230
238
  info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
231
239
  + ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
232
240
  emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
241
+ dropped_non_av_streams=bool(DROPPED_STREAMS),
233
242
  requested_start=round(segments[0][0], 6) if len(segments) == 1 else None,
234
243
  requested_end=round(segments[0][1], 6) if len(segments) == 1 else None,
235
244
  requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
package/scripts/export.py CHANGED
@@ -68,8 +68,10 @@ def main() -> int:
68
68
  meta = probe(args.input)
69
69
  if not meta.get("video"):
70
70
  die("input has no video stream")
71
+ notes: List[str] = []
71
72
  if meta["video"].get("hdr") and args.preset not in ("prores", "copy"):
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"))
73
+ notes.append("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"))
74
+ info("warning: " + notes[-1])
73
75
  has_audio = bool(meta.get("audio"))
74
76
  output = args.output or default_output(args.input, args.preset, p["ext"])
75
77
  out_ext = Path(output).suffix.lstrip(".").lower()
@@ -92,7 +94,7 @@ def main() -> int:
92
94
  cmd += ["-filter_complex", fc, "-loop", "0", output]
93
95
  run(cmd)
94
96
  info(f"wrote {output}")
95
- emit(output)
97
+ emit(output, **({"notes": notes} if notes else {}))
96
98
  return 0
97
99
 
98
100
  if vf:
@@ -121,7 +123,7 @@ def main() -> int:
121
123
  result = probe(output, role="output")
122
124
  v = result["video"]
123
125
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
124
- emit(output)
126
+ emit(output, **({"notes": notes} if notes else {}))
125
127
  return 0
126
128
 
127
129
 
@@ -98,6 +98,8 @@ def main() -> int:
98
98
 
99
99
  extra_inputs: List[str] = []
100
100
  fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
101
+ if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
102
+ die(f"the frame is {W}x{H}; the templates are sized from it and need at least 64 px on the short side")
101
103
  if args.template == "lower-third":
102
104
  if not args.name:
103
105
  die("lower-third needs --name")
package/scripts/insert.py CHANGED
@@ -65,6 +65,9 @@ def main() -> int:
65
65
  meta = probe(args.input)
66
66
  if not meta.get("video"):
67
67
  die("input has no image/video stream")
68
+ if (meta.get("duration") or 0) > 0.5 or meta.get("audio"):
69
+ die(f"{args.input} is a video, not a still image; insert.py animates a still (Ken Burns). "
70
+ f"For a clip use broll.py (cutaway) or cut.py/join.py")
68
71
  sw, sh = meta["video"]["width"], meta["video"]["height"]
69
72
  ratio = sw / sh
70
73
 
package/scripts/join.py CHANGED
@@ -206,7 +206,8 @@ def main() -> int:
206
206
  expected = sum(durs) - d * (n - 1)
207
207
  r = probe(output, role="output")
208
208
  info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
209
- emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3))
209
+ emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3),
210
+ dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
210
211
  return 0
211
212
 
212
213
 
package/scripts/look.py CHANGED
@@ -15,7 +15,7 @@ import sys
15
15
  from pathlib import Path
16
16
  from typing import List
17
17
 
18
- from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
18
+ from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
19
19
 
20
20
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
21
21
 
@@ -102,19 +102,28 @@ def main() -> int:
102
102
  n = cols * rows
103
103
  if not dur:
104
104
  die("cannot build a contact sheet without a known duration")
105
+ frames_avail = int((meta["video"].get("fps") or 0) * dur) or 1
106
+ if n > frames_avail:
107
+ # a 2x2 sheet from a one-frame clip: tile waits for frames that never come and writes nothing
108
+ cols, rows = min(cols, frames_avail), 1
109
+ n = cols * rows
110
+ info(f"only {frames_avail} frame(s) available; sheet reduced to {cols}x{rows}")
105
111
  step = dur / n
106
112
  tile_w = max(2, (args.width // cols) // 2 * 2)
107
113
  out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
108
114
  # sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
109
115
  vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc},"
110
116
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
111
- cmd = ffmpeg_base() + ["-ss", f"{step / 2:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
117
+ # with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
118
+ seek = 0.0 if frames_avail < 4 else step / 2
119
+ cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
112
120
  run(cmd)
113
121
  outputs.append(out)
114
122
  info(f"contact sheet: {n} frames every {step:.2f}s")
115
123
 
116
124
  for o in outputs:
117
- info(f"wrote {o}")
125
+ if STATE.dry_run or os.path.exists(o):
126
+ info(f"wrote {o}")
118
127
  emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs)
119
128
  if len(outputs) > 1 and not args.json:
120
129
  for o in outputs:
@@ -18,7 +18,7 @@ import os
18
18
  import re
19
19
  import sys
20
20
 
21
- from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, dry_run_input_pending
21
+ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
22
22
 
23
23
 
24
24
 
@@ -64,12 +64,19 @@ def main() -> int:
64
64
  if stats.get("silent"):
65
65
  info("audio is silent (integrated loudness -inf); nothing to normalise")
66
66
  if args.measure_only:
67
- print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
67
+ if STATE.json:
68
+ emit(None, measured={"silent": True, "input_i": "-inf"})
69
+ else:
70
+ print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
68
71
  return 0
69
72
  die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
70
73
  info(f"measured: {float(stats['input_i']):.1f} LUFS, TP {float(stats['input_tp']):.1f} dBTP, LRA {float(stats['input_lra']):.1f} LU")
71
74
  if args.measure_only:
72
- print(json.dumps({k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}, indent=2))
75
+ measured = {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}
76
+ if STATE.json:
77
+ emit(None, measured=measured) # the contract's document shape (status, commands), not a bare dict
78
+ else:
79
+ print(json.dumps(measured, indent=2))
73
80
  return 0
74
81
 
75
82
  output = args.output or default_output(args.input, "loudnorm")
@@ -83,7 +90,10 @@ def main() -> int:
83
90
  bitrate_pinned = args.audio_bitrate is not None
84
91
  bitrate = args.audio_bitrate or "192k"
85
92
 
93
+ dropped_streams = False
94
+
86
95
  def encode(tp: float, bitrate: str) -> None:
96
+ nonlocal dropped_streams
87
97
  af = (
88
98
  f"loudnorm=I={args.lufs}:TP={tp}:LRA={args.lra}"
89
99
  f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
@@ -94,6 +104,8 @@ def main() -> int:
94
104
  cmd += ["-vn"] + audio_codec_for(output, bitrate)
95
105
  else:
96
106
  cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
107
+ dropped_streams = run_keeping_subtitles(cmd, output)
108
+ return
97
109
  cmd.append(output)
98
110
  run(cmd)
99
111
 
@@ -142,7 +154,7 @@ def main() -> int:
142
154
  f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
143
155
  kind="verification", output=output, result=result,
144
156
  hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
145
- emit(output, result=result)
157
+ emit(output, result=result, dropped_non_av_streams=dropped_streams)
146
158
  return 0
147
159
 
148
160
 
package/scripts/pad.py CHANGED
@@ -16,7 +16,7 @@ Examples:
16
16
  import argparse
17
17
  import sys
18
18
 
19
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
19
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
20
20
 
21
21
 
22
22
  def main() -> int:
@@ -57,7 +57,13 @@ def main() -> int:
57
57
  cmd += aac_args()
58
58
  else:
59
59
  cmd += ["-an"]
60
- dropped_streams = run_keeping_subtitles(cmd, output)
60
+ if args.start > 0:
61
+ # a stream-copied subtitle track keeps its timestamps and would fire --start seconds early
62
+ # (sweep F3); drop it and say so, as freeze --mode insert and fit --method speed do
63
+ run(cmd + [output])
64
+ dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
65
+ else:
66
+ dropped_streams = run_keeping_subtitles(cmd, output)
61
67
 
62
68
  result = probe(output, role="output")
63
69
  v = result["video"]
package/scripts/proxy.py CHANGED
@@ -25,7 +25,7 @@ Examples:
25
25
  import argparse
26
26
  import sys
27
27
 
28
- from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, fmt_secs
28
+ from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
29
29
 
30
30
 
31
31
  def even(n: float) -> int:
@@ -67,14 +67,13 @@ def main() -> int:
67
67
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", f"scale={out_w}:-2"]
68
68
  cmd += video_args(meta, args.crf, "veryfast")
69
69
  cmd += cfr_args(meta, args.fps)
70
- cmd += ["-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"]
71
- cmd.append(output)
72
- run(cmd)
70
+ cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
71
+ dropped_streams = run_keeping_subtitles(cmd, output)
73
72
 
74
73
  result = probe(output)
75
74
  v = result["video"]
76
75
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']}, crf {args.crf})")
77
- emit(output)
76
+ emit(output, dropped_non_av_streams=dropped_streams)
78
77
  return 0
79
78
 
80
79
 
package/scripts/redact.py CHANGED
@@ -74,7 +74,11 @@ def main() -> int:
74
74
  output = args.output or default_output(args.input, "redact")
75
75
  crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
76
76
  if args.mode == "blur":
77
- region = f"{crop},boxblur={args.blur_strength}:{args.blur_strength}"
77
+ # boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
78
+ # so they get their own (halved) radius; a 30 px region with the default 20 used to fail
79
+ radius = max(1, min(args.blur_strength, min(args.width, args.height) // 2 - 1))
80
+ chroma = max(1, min(radius // 2, min(args.width, args.height) // 4 - 1))
81
+ region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
78
82
  else:
79
83
  region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
80
84
  fc = f"[0:v]split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
package/scripts/render.py CHANGED
@@ -141,7 +141,16 @@ def main() -> int:
141
141
  # shared path, e.g. to inspect intermediates across runs); only the auto-derived default is
142
142
  # made unique per process, since it's the one that's also auto-deleted at the end.
143
143
  work = Path(args.work) if args.work else Path(f"{Path(output).with_suffix('')}_work_{os.getpid()}")
144
- work.mkdir(parents=True, exist_ok=True)
144
+ try:
145
+ work.mkdir(parents=True, exist_ok=True)
146
+ except OSError as e:
147
+ die(f"cannot create the work directory {work}: {e}")
148
+ if not args.keep and not args.work:
149
+ # a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
150
+ # auto-named directory is ours alone, so remove it on every exit path
151
+ import atexit
152
+ import shutil
153
+ atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
145
154
  frame = proj.get("frame") or {}
146
155
  trans = proj.get("transition") or {}
147
156
  brand_args: List[str] = ["--brand", rel(proj["brand"])] if proj.get("brand") else []
@@ -151,6 +160,8 @@ def main() -> int:
151
160
  parts: List[str] = []
152
161
  for i, c in enumerate(clips):
153
162
  src = rel(c["src"])
163
+ if not os.path.exists(src):
164
+ die(f"clip {i}: source not found: {src}") # under --dry-run too: a plan for a missing file is no plan
154
165
  if not STATE.dry_run:
155
166
  probe(src)
156
167
  needs_cut = c.get("in") is not None or c.get("out") is not None
package/scripts/report.py CHANGED
@@ -158,7 +158,10 @@ def main() -> int:
158
158
  if STATE.dry_run:
159
159
  info(f"wrote {output}") # printed as "[dry-run] would write"; nothing is written
160
160
  else:
161
- Path(output).write_text(doc, encoding="utf-8")
161
+ try:
162
+ Path(output).write_text(doc, encoding="utf-8")
163
+ except OSError as e:
164
+ die(f"cannot write {output}: {e}", kind="output")
162
165
  info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
163
166
  emit(None, report=output, check=chk)
164
167
  if not args.json:
@@ -115,7 +115,7 @@ def main() -> int:
115
115
  result = probe(output, role="output")
116
116
  v = result["video"]
117
117
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {len(segments)} speed segments)")
118
- emit(output)
118
+ emit(output, dropped_non_av_streams=bool(meta.get("subtitle_streams") or meta.get("data_streams")))
119
119
  return 0
120
120
 
121
121
 
package/scripts/verify.py CHANGED
@@ -70,12 +70,15 @@ def collect(paths: List[str]) -> List[Path]:
70
70
  def step(name: str, argv: List[str], timeout: float) -> Dict:
71
71
  t0 = time.time()
72
72
  if argv[0] == "__check_hdr__":
73
+ was_json, STATE.json = STATE.json, False # probe()'s die() would print a JSON document of its own
73
74
  try:
74
75
  v = probe(argv[1]).get("video") or {}
75
76
  ok = bool(v.get("hdr")) and v.get("bit_depth", 8) >= 10
76
77
  err = "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"
77
78
  except SystemExit:
78
79
  ok, err = False, "output missing"
80
+ finally:
81
+ STATE.json = was_json
79
82
  return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
80
83
  try:
81
84
  proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
@@ -113,12 +116,15 @@ def main() -> int:
113
116
  for f in files:
114
117
  info(f"=== {f}")
115
118
  entry: Dict = {"file": str(f), "steps": []}
119
+ was_json, STATE.json = STATE.json, False # same: one JSON document per run, printed at the end
116
120
  try:
117
121
  meta = probe(str(f))
118
122
  except SystemExit:
119
123
  entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
120
124
  results.append(entry)
121
125
  continue
126
+ finally:
127
+ STATE.json = was_json
122
128
  entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
123
129
  entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
124
130
  entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}