ffmpeg-skill 0.12.0 → 0.12.5

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 (38) hide show
  1. package/README.md +37 -1
  2. package/SKILL.md +12 -0
  3. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  4. package/package.json +1 -1
  5. package/references/scripts.md +17 -1
  6. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  7. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  8. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  9. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  10. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  20. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  21. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  22. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  29. package/scripts/_common.py +55 -2
  30. package/scripts/_contract.py +72 -1
  31. package/scripts/caption.py +11 -2
  32. package/scripts/color.py +93 -21
  33. package/scripts/fit.py +13 -4
  34. package/scripts/graphics.py +6 -4
  35. package/scripts/look.py +13 -8
  36. package/scripts/overlay.py +21 -9
  37. package/scripts/scenes.py +8 -2
  38. package/scripts/stabilize.py +22 -4
package/README.md CHANGED
@@ -29,6 +29,12 @@ npx ffmpeg-skill
29
29
 
30
30
  If `ffmpeg` and `python3` are on your PATH, it works: offline, on footage you would rather not upload.
31
31
 
32
+ > **SPEC** (Self-Producing Execution Contract), coined by this project's author
33
+ > [kajisho5](https://github.com/kajisho5): each tool's `input_schema` — the part of its contract
34
+ > and MCP tool definition that has to track the CLI flag-for-flag — is never hand-authored beside
35
+ > the code. It's derived, at run time, from the same `argparse` parser that already defines the
36
+ > CLI, and CI fails the build if any of it drifts. → [full explanation](#what-is-spec)
37
+
32
38
  ---
33
39
 
34
40
  ## Standalone, and in an ecosystem
@@ -94,6 +100,8 @@ python3 $S/fit.py input.mp4 --duration 60 --aspect 9:16 --dry-run # print the
94
100
  python3 $S/export.py input.mp4 --preset reels --json # structured result with a probe of the output
95
101
  ```
96
102
 
103
+ On Windows in Git Bash, `python3` is only on PATH if Python was installed from the Microsoft Store; a python.org install exposes `python` (or the `py` launcher) instead — replace `python3` with `python` above if you see a "command not found". `bin/install.js` and `doctor`/`contract` already handle this for you; only the raw script examples above need it spelled out manually.
104
+
97
105
  More requests and the commands behind them: [examples/README.md](examples/README.md). To see everything run end-to-end on generated footage: `npm run demo`.
98
106
 
99
107
  ## How it works
@@ -180,7 +188,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
180
188
  | `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; animated and word-by-word karaoke timed to the speech energy; optional local transcription |
181
189
  | `overlay.py` | Logos, watermarks and titles with position, time range, opacity, fades; `--video` for picture-in-picture, `--chromakey` for green-screen compositing |
182
190
  | `graphics.py` | Lower-thirds, title cards, chapter chips, progress bars, countdowns, corner bugs drawn by FFmpeg from a brand kit |
183
- | `color.py` | HDR10 / HLG / Dolby Vision → SDR BT.709 tone mapping, DV layer stripping, 3D LUT (.cube), colour-tag rewriting, typed primary correction (exposure/contrast/saturation/white balance) |
191
+ | `color.py` | HDR10 / HLG / Dolby Vision → SDR BT.709 tone mapping, DV layer stripping, 3D LUT (.cube), colour-tag rewriting, typed primary correction (exposure/contrast/saturation/gamma/white balance/lift-gain/levels/curves) |
184
192
 
185
193
  **Delivery**
186
194
 
@@ -216,6 +224,34 @@ Picture tools (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `scen
216
224
 
217
225
  ## Built for agents
218
226
 
227
+ ### What is SPEC?
228
+
229
+ This project's author, [kajisho5](https://github.com/kajisho5), coined **SPEC** (Self-Producing
230
+ Execution Contract) for the pattern this skill's tool layer is built on: each tool's `input_schema`
231
+ — the part of its contract that has to track the CLI exactly, flag for flag — is never
232
+ hand-authored side by side with the code. It is derived, at run time, from the one thing that
233
+ actually has to be correct for the CLI to work at all: the script's own `argparse` parser.
234
+
235
+ Concretely, `scripts/_contract.py`'s `_capture_parser()` imports every tool script and
236
+ intercepts its `parse_args()` call to get the live, fully-built parser object — flags, types,
237
+ choices, defaults, required/positional, mutually exclusive groups, all of it. `input_schema` is
238
+ built straight from that object. (The rest of a `ToolSpec` — `role`, `capabilities`, `inputs`,
239
+ `outputs`, `output_schema` — comes from a hand-authored table, `TOOL_META`, since those facts
240
+ aren't things a parser can express; only `input_schema` is parser-derived.)
241
+
242
+ - **The contract**'s `input_schema` for every tool is generated from the live parser directly.
243
+ - **The MCP server** (`mcp/server.py`) carries no schema of its own; `tools/list` is translated
244
+ straight from the contract, `input_schema` included.
245
+ - **The docs** (`docs/contract.md`'s field reference, this README's tool table) describe the same
246
+ shape. `tests/test_contract.py` runs on every CI run and fails the build if any of them drift
247
+ out of sync with what the code actually does — it catches drift, it doesn't fix it for you.
248
+
249
+ The result: add a flag to a script's `argparse` block, and `input_schema` and the MCP tool
250
+ definition follow with no second edit; if a docs page or a `TOOL_META` entry falls behind, CI
251
+ catches it rather than letting it drift silently. There is no separate `input_schema` file to
252
+ forget to update, and no version of "what CLI flags does this tool accept" that can quietly go
253
+ stale.
254
+
219
255
  ### Machine-readable contract
220
256
 
221
257
  ```bash
package/SKILL.md CHANGED
@@ -299,6 +299,18 @@ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | out
299
299
  `caption.py --fonts-dir ./fonts --font "Noto Sans CJK JP"`). Without a
300
300
  matching font you get boxes, not an error. Install: `apt install fonts-noto-cjk`,
301
301
  `brew install --cask font-noto-sans-cjk`.
302
+ - **Windows drawtext crashes on some real builds.** On certain Windows ffmpeg
303
+ builds (e.g. winget's gyan.dev), `drawtext` crashes with an access violation
304
+ whenever it resolves a font by family name through fontconfig, even with a
305
+ valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `overlay.py --text`
306
+ and `graphics.py` all resolve a concrete `--font-file` by default when one is
307
+ available (`fontfile=` skips fontconfig entirely and is the form confirmed
308
+ not to crash), so this should already be handled automatically. If a
309
+ drawtext tool still crashes, pass `--font-file` explicitly rather than
310
+ relying on `--font`/`font=` resolution; `doctor` also runs a real one-frame
311
+ drawtext probe and reports `filter:drawtext` missing (with the crash detail
312
+ in `errors[]`) rather than a false "available" from the `-filters` listing
313
+ alone.
302
314
  - **Keyframe cuts.** A lossless `cut.py` result may start up to one GOP (often
303
315
  1–10 s) earlier than requested; the script re-encodes automatically when the
304
316
  deviation exceeds 0.5 s. If the user insists on lossless output, pass
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.12.0",
3
+ "version": "0.12.5",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 28 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",
@@ -352,7 +352,23 @@ color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [
352
352
  color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
353
353
  color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
354
354
  color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
355
- ```
355
+ color.py INPUT --correct [--exposure -3..3] [--contrast 0..2] [--saturation 0..2] [--gamma 0.1..10]
356
+ [--temperature 2000..12000] [--tint -1..1] [--lift -1..1] [--gain -1..1]
357
+ [--levels-in-black 0..255] [--levels-in-white 0..255] [--levels-out-black 0..255] [--levels-out-white 0..255]
358
+ [--curves color_negative|cross_process|darker|increase_contrast|lighter|linear_contrast|medium_contrast|negative|strong_contrast|vintage] [-o OUT]
359
+ ```
360
+ `--correct` is typed primary colour correction, no filter string ever accepted:
361
+ `--exposure`/`--contrast`/`--saturation`/`--gamma` (`exposure`/`eq` filters),
362
+ `--temperature`/`--tint`/`--lift`/`--gain` (`colortemperature`/`colorbalance`
363
+ filters — `--tint` sets midtones, `--lift` shadows, `--gain` highlights, a
364
+ classic three-way correction), `--levels-*` (`colorlevels`, 8-bit units
365
+ converted to the filter's own 0..1 range, only added to the chain when at
366
+ least one is given) and `--curves` (the `curves` filter's own built-in
367
+ presets, only added when given). Every flag is range-checked against this
368
+ script's own safe subset of what `ffmpeg -h filter=<name>` documents before
369
+ ffmpeg runs. `--json`'s `measurements` reports `analyze_levels()` (signalstats
370
+ luma/saturation) for input and output side by side.
371
+
356
372
  iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
357
373
  `probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
358
374
  tone-maps it from the HLG base layer. When the user wants to keep HDR but
@@ -269,6 +269,20 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
269
269
  return _run_captured(list(cmd), check)
270
270
 
271
271
 
272
+ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
273
+ """Run an ffmpeg command that already maps its video/audio, trying first to also
274
+ stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
275
+ there are none). A source whose subtitle codec cannot be copied into the target container
276
+ (e.g. a container change) makes that first attempt fail; retry the same command without the
277
+ extra maps rather than let a tool that never touched subtitles start hard-failing because of
278
+ them. `cmd` is the full argv *without* the output path. Returns True only when the
279
+ retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
280
+ if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
281
+ return False
282
+ run(cmd + [output])
283
+ return True
284
+
285
+
272
286
  def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
273
287
  """Plain run with stdout/stderr captured."""
274
288
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
@@ -325,7 +339,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
325
339
 
326
340
 
327
341
  def shell_quote(s: str) -> str:
328
- if not s or any(ch in s for ch in " \t\"'\;|&<>()[]{}$*?"):
342
+ if not s or any(ch in s for ch in " \t\\\"';|&<>()[]{}$*?"):
329
343
  return "'" + s.replace("'", "'\\''") + "'"
330
344
  return s
331
345
 
@@ -387,7 +401,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
387
401
  return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
388
402
  "video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
389
403
  "color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
390
- "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
404
+ "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
391
405
  die(f"input not found: {path}")
392
406
  ffprobe = require_tool("ffprobe")
393
407
  proc = run(
@@ -405,6 +419,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
405
419
  video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
406
420
  audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
407
421
  subs = [s for s in streams if s.get("codec_type") == "subtitle"]
422
+ data_stream_count = sum(1 for s in streams if s.get("codec_type") in ("data", "attachment"))
408
423
 
409
424
  duration = _to_float(fmt.get("duration"))
410
425
  if duration is None and video:
@@ -423,6 +438,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
423
438
  "video": None,
424
439
  "audio": None,
425
440
  "subtitle_streams": len(subs),
441
+ "data_streams": data_stream_count,
426
442
  # every subtitle stream in file order: index n here is `-map 0:s:n`
427
443
  "subtitle_stream_details": [{
428
444
  "index": n,
@@ -582,6 +598,43 @@ def escape_filter_path(path: str) -> str:
582
598
  return p
583
599
 
584
600
 
601
+ def default_font_file(font_name: str) -> Optional[str]:
602
+ """Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
603
+ `fontfile=<path>` instead of `font=<name>`, when possible.
604
+
605
+ On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
606
+ resolution crashes with an access violation whenever it has to resolve a font by family name
607
+ -- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
608
+ confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
609
+ ignored on Windows for that reason: a fixed, near-universally-present system font is used
610
+ instead of trying to resolve the requested family (which would crash the same way).
611
+
612
+ On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
613
+ same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
614
+ just already resolved to a path -- fontfile= skips a redundant fontconfig lookup and equally
615
+ sidesteps the same class of crash if it exists on some build there too, but the fallback below
616
+ (returning None) is exercised routinely there, not just on failure.
617
+
618
+ Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
619
+ Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
620
+ """
621
+ if platform.system() == "Windows":
622
+ windir = os.environ.get("WINDIR", "C:\\Windows")
623
+ candidate = Path(windir) / "Fonts" / "arial.ttf"
624
+ return str(candidate) if candidate.exists() else None
625
+ exe = shutil.which("fc-match")
626
+ if not exe:
627
+ return None
628
+ try:
629
+ proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
630
+ except (subprocess.TimeoutExpired, OSError):
631
+ return None
632
+ if proc.returncode != 0:
633
+ return None
634
+ path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
635
+ return path if path and os.path.exists(path) else None
636
+
637
+
585
638
  def escape_drawtext(text: str) -> str:
586
639
  return (
587
640
  text.replace("\\", "\\\\")
@@ -127,7 +127,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
127
127
  required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
128
128
  {"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
129
129
  {"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
130
- {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}],
130
+ {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"},
131
+ {"capability": "filter:colorlevels", "when": "--correct with any --levels-*"}, {"capability": "filter:curves", "when": "--correct --curves"}],
131
132
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
132
133
  "proxy": dict(role="execution", inputs=["video asset"], outputs=["low-resolution, low-bitrate proxy artifact for downstream analysis, preview or editing decisions"],
133
134
  required=FF + [X264, AAC], optional=[HDR_X265],
@@ -482,6 +483,50 @@ def _default_font() -> str:
482
483
  return str(BRAND_DEFAULTS["font"])
483
484
 
484
485
 
486
+ def _drawtext_probe() -> Dict[str, Any]:
487
+ """Actually render one frame through drawtext, rather than trusting `-filters` alone.
488
+
489
+ `-filters` only reports whether this ffmpeg build was compiled with the filter; it never
490
+ proves drawtext can actually execute. On some real Windows ffmpeg builds (winget's gyan.dev
491
+ 9.x), drawtext crashes with an access violation whenever it has to resolve a font through
492
+ fontconfig -- with or without a valid fonts.conf -- so `-filters` correctly reports drawtext
493
+ present and doctor used to report the capability `available` anyway; every tool that actually
494
+ used it (look, scenes --sheet, overlay --text, graphics) then crashed on first real use (#100).
495
+
496
+ This runs the cheapest real drawtext render there is: a one-frame synthetic clip, no font=
497
+ given at all (ffmpeg's own default resolution -- the same path that crashed). A clean exit
498
+ means drawtext genuinely works here. Anything that could not prove either way (no ffmpeg,
499
+ timeout, an ordinary nonzero exit with a real ffmpeg error) is `unknown`, same "unknown is not
500
+ missing" principle as every other capability here. A crash specifically -- killed by signal on
501
+ POSIX, or an unhandled access violation surfacing as a huge unsigned exit code on Windows -- is
502
+ the one case this function exists to catch, and folds into `missing`: the filter is present in
503
+ the build but cannot actually be used as ffmpeg's own default would use it.
504
+ """
505
+ exe = shutil.which("ffmpeg")
506
+ if not exe:
507
+ return {"status": "unknown", "detail": "ffmpeg not on PATH"}
508
+ try:
509
+ proc = subprocess.run(
510
+ [exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
511
+ "-vf", "drawtext=text=x", "-frames:v", "1", "-f", "null", "-"],
512
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT,
513
+ )
514
+ except subprocess.TimeoutExpired:
515
+ return {"status": "unknown", "detail": f"drawtext probe did not exit within {_DETECT_TIMEOUT}s"}
516
+ except OSError as e:
517
+ return {"status": "unknown", "detail": f"drawtext probe: {e}"}
518
+ if proc.returncode == 0:
519
+ return {"status": "available", "detail": "one-frame drawtext render succeeded"}
520
+ if proc.returncode < 0 or proc.returncode >= 0x80000000:
521
+ return {"status": "missing",
522
+ "detail": f"drawtext render crashed (exit {proc.returncode}) instead of failing cleanly -- "
523
+ "the filter is present in this build but cannot be used as-is, likely a fontconfig "
524
+ "resolution crash (see https://github.com/kajisho5/ffmpeg-skill/issues/100); "
525
+ "pass an explicit --font-file to every drawtext tool as a workaround"}
526
+ tail = " ".join(proc.stderr.strip().splitlines()[-2:])
527
+ return {"status": "unknown", "detail": f"drawtext probe exited {proc.returncode}: {tail}"}
528
+
529
+
485
530
  def _font_available(font_name: str) -> Dict[str, Any]:
486
531
  """Whether `font_name` (a fontconfig family name, as passed to drawtext's `font=`) is actually
487
532
  installed, distinct from silently resolving to a substitute.
@@ -561,6 +606,7 @@ def doctor() -> Dict[str, Any]:
561
606
  sets = {k: set(v["names"]) for k, v in listings.items()}
562
607
  state: Dict[str, str] = {} # capability -> available | missing | unknown
563
608
  wanted = required_capabilities()
609
+ drawtext_probe: Optional[Dict[str, Any]] = None
564
610
 
565
611
  def _from(kind: str, name: str) -> str:
566
612
  lst = listings[kind]
@@ -577,6 +623,23 @@ def doctor() -> Dict[str, Any]:
577
623
  state[cap] = "available" if shutil.which("ffprobe") else "missing"
578
624
  elif cap.startswith("encoder:"):
579
625
  state[cap] = _from("encoders", cap[8:])
626
+ elif cap == "filter:drawtext":
627
+ listing_state = _from("filters", "drawtext")
628
+ if listing_state == "available":
629
+ # Only escalate an "available" listing to "missing" on an unambiguous crash --
630
+ # an ordinary nonzero exit (a real ffmpeg's own -h/-filters-only build variance, or
631
+ # in tests a fake ffmpeg shim that only implements -filters/-encoders/-bsfs/-version)
632
+ # proves nothing either way, so it leaves the listing-based result standing rather
633
+ # than downgrading it; see _drawtext_probe()'s own docstring for why a crash alone
634
+ # is the one case this exists to catch.
635
+ probe = _drawtext_probe()
636
+ if probe["status"] == "missing":
637
+ drawtext_probe = probe
638
+ state[cap] = "missing"
639
+ else:
640
+ state[cap] = "available"
641
+ else:
642
+ state[cap] = listing_state
580
643
  elif cap.startswith("filter:"):
581
644
  state[cap] = _from("filters", cap[7:])
582
645
  elif cap.startswith("bsf:"):
@@ -591,6 +654,8 @@ def doctor() -> Dict[str, Any]:
591
654
  unknown = sorted(c for c, st in state.items() if st == "unknown")
592
655
  unknown_required = [c for c in unknown if c in wanted["required"]]
593
656
  errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
657
+ if drawtext_probe is not None and drawtext_probe["status"] != "available":
658
+ errors.append(f"filter:drawtext: {drawtext_probe['detail']}")
594
659
  return {
595
660
  "version": skill_version(),
596
661
  "python": ".".join(str(x) for x in sys.version_info[:3]),
@@ -630,6 +695,12 @@ def _capability_fix_hint(cap: str) -> str:
630
695
  full_hint = "install/build ffmpeg with it enabled"
631
696
  if cap.startswith("encoder:"):
632
697
  return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
698
+ if cap == "filter:drawtext":
699
+ return ("drawtext crashed instead of rendering a frame (see errors[] for the exit detail) -- "
700
+ "every drawtext tool already resolves a concrete font file automatically when one can "
701
+ "be found (#100); if it still crashes, use --no-timecode with look.py or scenes.py "
702
+ "--sheet to skip drawtext entirely, or pass --font-file explicitly to overlay.py/"
703
+ "graphics.py (the two that accept it) rather than relying on font= resolution")
633
704
  if cap.startswith("filter:"):
634
705
  return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
635
706
  if cap.startswith("bsf:"):
@@ -427,14 +427,23 @@ def main() -> int:
427
427
  if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
428
428
  die(f"SRT file not found: {srt_path}")
429
429
  codec = mux_subtitle_codec(output)
430
+ # Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
431
+ # language to build a multi-language set) -- copied byte-identical, distinct from the
432
+ # newly-added SRT's own codec below.
433
+ existing_subs = meta.get("subtitle_streams") or 0
430
434
  maps = ["-map", "0:v:0"]
431
435
  cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
432
436
  if meta.get("audio"):
433
437
  maps += ["-map", f"0:a:{args.audio_stream}"]
438
+ if existing_subs:
439
+ maps += ["-map", "0:s?"]
434
440
  maps += ["-map", "1:0"]
435
- cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else []) + ["-c:s", codec]
441
+ cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
442
+ for i in range(existing_subs):
443
+ cmd += [f"-c:s:{i}", "copy"]
444
+ cmd += [f"-c:s:{existing_subs}", codec]
436
445
  if args.language:
437
- cmd += ["-metadata:s:s:0", f"language={args.language}"]
446
+ cmd += [f"-metadata:s:s:{existing_subs}", f"language={args.language}"]
438
447
  cmd += [output]
439
448
  run(cmd)
440
449
  result = probe(output, role="output")
package/scripts/color.py CHANGED
@@ -2,7 +2,8 @@
2
2
  """Colour management: convert HDR (HDR10/PQ, HLG, BT.2020) to SDR BT.709 with
3
3
  real tone mapping, apply a .cube LUT (Log footage, creative grades), fix wrong
4
4
  colour tags without re-encoding, or apply typed primary colour correction
5
- (exposure, contrast, saturation, white balance).
5
+ (exposure, contrast, saturation, gamma, white balance, three-way
6
+ lift/gain, levels, curves).
6
7
 
7
8
  Examples:
8
9
  python3 color.py iphone_hdr.mov --to-sdr # PQ/HLG -> BT.709 SDR, hable tonemap
@@ -13,21 +14,26 @@ Examples:
13
14
  python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
14
15
  python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
15
16
  python3 color.py flat.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05
17
+ python3 color.py flat.mp4 --correct --gamma 1.2 --lift 0.04 --gain -0.03 # three-way shadows/gamma/highlights
18
+ python3 color.py flat.mp4 --correct --levels-in-black 16 --levels-in-white 235 --curves medium_contrast
16
19
  """
17
20
  import argparse
18
21
  import os
19
22
  import sys
20
23
  from typing import List
21
24
 
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
25
+ 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, run_keeping_subtitles, x264_args
23
26
 
24
27
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
25
28
 
26
29
  # Typed primary correction: each flag is one option of one real, always-available libavfilter filter
27
30
  # (never a caller-supplied filter string). Range is this script's own safe subset of what the filter
28
31
  # 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.
32
+ # own documented no-op value, so every stage in this dict is always emitted and the chain never depends
33
+ # on which flags were actually given (exposure/temperature/tint/gamma/lift/gain). `colorlevels` and
34
+ # `curves` (see LEVELS and CURVES_PRESETS below) are the exception: they only add a term to the chain
35
+ # when the caller actually asks for them, because "levels 0..255 in, 0..255 out" and "no curve" are
36
+ # already the identity operation without emitting a no-op filter term for it.
31
37
  CORRECTION = {
32
38
  # flag default lo hi unit
33
39
  "exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
@@ -35,8 +41,27 @@ CORRECTION = {
35
41
  "saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
36
42
  "temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (its own default)
37
43
  "tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
44
+ "gamma": (1.0, 0.1, 10.0, "x"), # eq filter's own gamma option; 1=unchanged (its own default)
45
+ "lift": (0.0, -1.0, 1.0, "x"), # colorbalance shadows (rs=gs=bs); 0=unchanged
46
+ "gain": (0.0, -1.0, 1.0, "x"), # colorbalance highlights (rh=gh=bh); 0=unchanged
38
47
  }
39
48
 
49
+ # colorlevels takes fractional 0.0..1.0 input/output black/white points; this tool exposes the
50
+ # familiar 8-bit 0..255 unit instead and divides by 255.0 when building the filter (same convention
51
+ # as the rest of CORRECTION: a human-friendly CLI unit formatted into the filter's own native unit).
52
+ LEVELS = {
53
+ # flag default lo hi
54
+ "levels_in_black": (0, 0, 255),
55
+ "levels_in_white": (255, 0, 255),
56
+ "levels_out_black": (0, 0, 255),
57
+ "levels_out_white": (255, 0, 255),
58
+ }
59
+
60
+ # curves filter's real built-in presets (`ffmpeg -h filter=curves`), excluding its own "none" (0):
61
+ # omitting --curves already gets that identity result without adding a filter term for it.
62
+ CURVES_PRESETS = ["color_negative", "cross_process", "darker", "increase_contrast", "lighter",
63
+ "linear_contrast", "medium_contrast", "negative", "strong_contrast", "vintage"]
64
+
40
65
 
41
66
  def _checked(args: argparse.Namespace, flag: str) -> float:
42
67
  _, lo, hi, unit = CORRECTION[flag]
@@ -46,26 +71,65 @@ def _checked(args: argparse.Namespace, flag: str) -> float:
46
71
  return value
47
72
 
48
73
 
74
+ def _checked_levels(args: argparse.Namespace, flag: str) -> int:
75
+ _, lo, hi = LEVELS[flag]
76
+ value = getattr(args, flag)
77
+ if not (lo <= value <= hi):
78
+ die(f"--{flag.replace('_', '-')} {value} is outside {lo}..{hi} (8-bit units, scaled to colorlevels' own 0..1 range)")
79
+ return value
80
+
81
+
49
82
  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."""
83
+ """Always-present filter stages, in a fixed order chosen so each stage sees a picture already
84
+ corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint/
85
+ lift/gain, so contrast/saturation act on colour-balanced footage) -> contrast/saturation/gamma
86
+ (the most creative-adjacent stage of the always-on chain). `tint` (-1 green .. +1 magenta) is not
87
+ a single ffmpeg option: it is expressed as colorbalance's three midtone channels (gm=-tint,
88
+ rm=bm=tint/2) so a positive tint shifts midtones toward magenta and a negative one toward green
89
+ without changing overall midtone lightness, the same balanced-axis convention colour tools use
90
+ for a one-dial tint control. `lift` and `gain` extend the same colorbalance call to the shadow
91
+ (rs=gs=bs=lift) and highlight (rh=gh=bh=gain) channels, giving a classic three-way shadows/
92
+ midtones/highlights correction in one filter invocation. `gamma` is folded into the same `eq`
93
+ term contrast/saturation already use, as `eq`'s own `gamma` option. Two further stages are
94
+ appended only when asked for, since their own identity value would otherwise add a no-op filter
95
+ term to the chain: `colorlevels` (--levels-*, 8-bit units scaled to the filter's 0..1 range) and
96
+ `curves` (--curves, one of the filter's own named presets)."""
57
97
  exposure = _checked(args, "exposure")
58
98
  contrast = _checked(args, "contrast")
59
99
  saturation = _checked(args, "saturation")
60
100
  temperature = _checked(args, "temperature")
61
101
  tint = _checked(args, "tint")
102
+ gamma = _checked(args, "gamma")
103
+ lift = _checked(args, "lift")
104
+ gain = _checked(args, "gain")
105
+ in_black = _checked_levels(args, "levels_in_black")
106
+ in_white = _checked_levels(args, "levels_in_white")
107
+ out_black = _checked_levels(args, "levels_out_black")
108
+ out_white = _checked_levels(args, "levels_out_white")
109
+ if in_black >= in_white:
110
+ die(f"--levels-in-black {in_black} must be less than --levels-in-white {in_white}")
111
+ if out_black >= out_white:
112
+ die(f"--levels-out-black {out_black} must be less than --levels-out-white {out_white}")
113
+
62
114
  gm, rm, bm = -tint, tint / 2.0, tint / 2.0
63
- return ",".join([
115
+ terms = [
64
116
  f"exposure=exposure={exposure:g}",
65
117
  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
- ])
118
+ f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}",
119
+ f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}",
120
+ ]
121
+ if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
122
+ rimin, rimax = in_black / 255.0, in_white / 255.0
123
+ romin, romax = out_black / 255.0, out_white / 255.0
124
+ terms.append(
125
+ f"colorlevels=rimin={rimin:g}:gimin={rimin:g}:bimin={rimin:g}:"
126
+ f"rimax={rimax:g}:gimax={rimax:g}:bimax={rimax:g}:"
127
+ f"romin={romin:g}:gomin={romin:g}:bomin={romin:g}:"
128
+ f"romax={romax:g}:gomax={romax:g}:bomax={romax:g}"
129
+ )
130
+ if args.curves:
131
+ terms.append(f"curves=preset={args.curves}")
132
+ return ",".join(terms)
69
133
 
70
134
 
71
135
  def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
@@ -94,7 +158,7 @@ def main() -> int:
94
158
  mode.add_argument("--lut", help=".cube LUT to apply (3D)")
95
159
  mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
96
160
  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")
161
+ mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint/--gamma/--lift/--gain/--levels-*/--curves")
98
162
  ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
99
163
  ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
100
164
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
@@ -105,6 +169,14 @@ def main() -> int:
105
169
  ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
106
170
  ap.add_argument("--temperature", type=float, default=CORRECTION["temperature"][0], help="--correct: white-balance temperature in Kelvin, 2000..12000, 6500=unchanged (default 6500)")
107
171
  ap.add_argument("--tint", type=float, default=CORRECTION["tint"][0], help="--correct: green(-1)/magenta(+1) tint, 0=unchanged (default 0)")
172
+ ap.add_argument("--gamma", type=float, default=CORRECTION["gamma"][0], help="--correct: master gamma (eq filter's own gamma), 0.1..10, 1=unchanged (default 1)")
173
+ ap.add_argument("--lift", type=float, default=CORRECTION["lift"][0], help="--correct: shadows lift (colorbalance rs/gs/bs), -1..1, 0=unchanged (default 0)")
174
+ ap.add_argument("--gain", type=float, default=CORRECTION["gain"][0], help="--correct: highlights gain (colorbalance rh/gh/bh), -1..1, 0=unchanged (default 0)")
175
+ ap.add_argument("--levels-in-black", type=int, default=LEVELS["levels_in_black"][0], help="--correct: colorlevels input black point, 0..255 (default 0, unchanged)")
176
+ ap.add_argument("--levels-in-white", type=int, default=LEVELS["levels_in_white"][0], help="--correct: colorlevels input white point, 0..255 (default 255, unchanged)")
177
+ ap.add_argument("--levels-out-black", type=int, default=LEVELS["levels_out_black"][0], help="--correct: colorlevels output black point, 0..255 (default 0, unchanged)")
178
+ ap.add_argument("--levels-out-white", type=int, default=LEVELS["levels_out_white"][0], help="--correct: colorlevels output white point, 0..255 (default 255, unchanged)")
179
+ ap.add_argument("--curves", choices=CURVES_PRESETS, default=None, help="--correct: curves filter built-in preset (default: none, no curves term added)")
108
180
  ap.add_argument("--audio-stream", type=int, default=0,
109
181
  help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
110
182
  "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
@@ -212,16 +284,16 @@ def main() -> int:
212
284
  tag = "lut"
213
285
 
214
286
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
215
- cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
216
- run(cmd)
287
+ cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
288
+ dropped_streams = run_keeping_subtitles(cmd, output)
217
289
  r = probe(output, role="output")
218
290
  info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
219
291
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
292
+ extra = {"dropped_non_av_streams": dropped_streams}
220
293
  if measurements is not None:
221
294
  measurements["output"] = analyze_levels(output)
222
- emit(output, measurements=measurements)
223
- else:
224
- emit(output)
295
+ extra["measurements"] = measurements
296
+ emit(output, **extra)
225
297
  return 0
226
298
 
227
299
 
package/scripts/fit.py CHANGED
@@ -37,7 +37,7 @@ import sys
37
37
  from fractions import Fraction
38
38
  from typing import List
39
39
 
40
- 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
40
+ 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, run_keeping_subtitles, x264_args
41
41
 
42
42
  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)}
43
43
 
@@ -225,15 +225,24 @@ def main() -> int:
225
225
  cmd += aac_args()
226
226
  else:
227
227
  cmd += ["-an"]
228
- cmd += post + [output]
229
- run(cmd)
228
+ cmd += post
229
+ if abs(factor - 1.0) > 1e-4:
230
+ # A subtitle/data stream stream-copied by run_keeping_subtitles keeps the source's
231
+ # original timestamps; --method speed retimes video (setpts) and audio (atempo) but has
232
+ # no equivalent way to retime a copied subtitle track, so it would desync from the
233
+ # now-faster/slower picture. Drop them here rather than ship a captions track that lies
234
+ # about when a line is spoken.
235
+ run(cmd + [output])
236
+ dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
237
+ else:
238
+ dropped_streams = run_keeping_subtitles(cmd, output)
230
239
 
231
240
  result = probe(output, role="output")
232
241
  msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
233
242
  if abs(factor - 1.0) > 1e-4:
234
243
  msg += f", speed {factor:.3f}x"
235
244
  info(msg)
236
- emit(output)
245
+ emit(output, dropped_non_av_streams=dropped_streams)
237
246
  return 0
238
247
 
239
248
 
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Optional
23
23
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, video_args
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args
25
25
 
26
26
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
27
 
@@ -33,6 +33,9 @@ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
33
33
  def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str:
34
34
  if font_file or brand.get("font_file"):
35
35
  return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
36
+ resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
37
+ if resolved:
38
+ return f"fontfile={escape_filter_path(resolved)}"
36
39
  return f"font='{font or brand.get('font', 'DejaVu Sans')}'"
37
40
 
38
41
 
@@ -163,11 +166,10 @@ def main() -> int:
163
166
  cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
164
167
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
165
168
  cmd += aac_args() if meta.get("audio") else ["-an"]
166
- cmd.append(output)
167
- run(cmd)
169
+ dropped_streams = run_keeping_subtitles(cmd, output)
168
170
  r = probe(output, role="output")
169
171
  info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
170
- emit(output, template=args.template)
172
+ emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
171
173
  return 0
172
174
 
173
175
 
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, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run
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
19
19
 
20
20
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
21
21
 
@@ -26,8 +26,8 @@ def fmt_hms(sec: float) -> str:
26
26
  return f"{int(h):02d}:{int(m):02d}:{s_:06.3f}"
27
27
 
28
28
 
29
- def timecode_filter() -> str:
30
- return f"drawtext=text='%{{pts\\:hms}}':{FONT}"
29
+ def timecode_filter(font_prefix: str) -> str:
30
+ return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -49,7 +49,12 @@ def main() -> int:
49
49
  dur = meta.get("duration") or 0.0
50
50
  stem = Path(args.input).stem
51
51
  outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
52
- tc = "" if args.no_timecode else "," + timecode_filter()
52
+ # a resolvable font file, given as fontfile=, is the only form confirmed not to crash drawtext's
53
+ # own fontconfig resolution on some real Windows ffmpeg builds (#100); font= is the fallback when
54
+ # nothing can be resolved, unchanged from before this existed.
55
+ default_font = default_font_file("DejaVu Sans")
56
+ font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
57
+ tc = "" if args.no_timecode else "," + timecode_filter(font_prefix)
53
58
  # HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
54
59
  if meta["video"].get("hdr"):
55
60
  v = meta["video"]
@@ -67,8 +72,8 @@ def main() -> int:
67
72
  sec = parse_time(t)
68
73
  out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
69
74
  half = args.width // 2
70
- stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
71
- tcs = tc.replace("," + timecode_filter(), "") + stamp
75
+ stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
76
+ tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
72
77
  fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
73
78
  f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
74
79
  cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
@@ -81,8 +86,8 @@ def main() -> int:
81
86
  if dur and sec > dur:
82
87
  die(f"--at {t} is beyond the duration ({dur:.2f}s)")
83
88
  out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
84
- stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
85
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(), '')}{stamp}", "-frames:v", "1", out]
89
+ stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
90
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", out]
86
91
  run(cmd)
87
92
  outputs.append(out)
88
93
  else:
@@ -24,7 +24,7 @@ import argparse
24
24
  import sys
25
25
  from typing import List, Optional
26
26
 
27
- from _common import STATE, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
27
+ from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, x264_args
28
28
 
29
29
  POS = {
30
30
  "top-left": ("{m}", "{m}"),
@@ -144,6 +144,8 @@ def main() -> int:
144
144
  args.font = brand.get("font", args.font)
145
145
  if not args.font_file and brand.get("font_file"):
146
146
  args.font_file = brand["font_file"]
147
+ if not args.font_file:
148
+ args.font_file = default_font_file(args.font)
147
149
  meta = probe(args.input)
148
150
  if not meta.get("video"):
149
151
  die("input has no video stream")
@@ -193,12 +195,19 @@ def main() -> int:
193
195
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
194
196
  cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
195
197
  fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
196
- cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?", "-shortest"]
197
- # -shortest alone is not exact on FFmpeg 7+: the muxer keeps up to shortest_buf_duration (10 s)
198
- # of the looped still after the video ended, and the file came out 2 s long on 8.1 / 9.0.
199
- # The output must be as long as the main input, so say so explicitly.
198
+ cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
200
199
  if meta.get("duration"):
200
+ # An explicit -t is exact and, unlike -shortest, only bounds the *main* input's
201
+ # streams -- a preserved subtitle/data stream that ends earlier (run_keeping_subtitles)
202
+ # must not be allowed to cut the whole output short via -shortest's "stop at whichever
203
+ # mapped stream finishes first" semantics.
201
204
  cmd += ["-t", f"{meta['duration']:.3f}"]
205
+ else:
206
+ # No known duration to bound by -t (e.g. probe found no video duration): -shortest is
207
+ # the only thing stopping the looped still from running forever. FFmpeg 7+'s
208
+ # shortest_buf_duration slack (up to 10s) is an accepted imprecision here since there is
209
+ # no better bound available.
210
+ cmd += ["-shortest"]
202
211
  elif args.video:
203
212
  pip_meta = probe(args.video)
204
213
  if not pip_meta.get("video"):
@@ -219,9 +228,13 @@ def main() -> int:
219
228
  ov += f":enable='{enable}'"
220
229
  cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
221
230
  fc = f"[1:v]{','.join(chain)}[ov];[0:v][ov]{ov}[out]"
222
- cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?", "-shortest"]
231
+ cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
223
232
  if meta.get("duration"):
233
+ # See the --image branch above: -t (exact, bounds only the main input) instead of
234
+ # -shortest (would also stop at a preserved subtitle/data stream that ends earlier).
224
235
  cmd += ["-t", f"{meta['duration']:.3f}"]
236
+ else:
237
+ cmd += ["-shortest"]
225
238
  else:
226
239
  x, y = position_exprs(args.position, args.margin, text_mode=True)
227
240
  opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
@@ -245,12 +258,11 @@ def main() -> int:
245
258
 
246
259
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
247
260
  cmd += aac_args() if meta.get("audio") else ["-an"]
248
- cmd.append(output)
249
- run(cmd)
261
+ dropped_streams = run_keeping_subtitles(cmd, output)
250
262
  if not STATE.dry_run:
251
263
  result = probe(output, role="output")
252
264
  info(f"wrote {output} ({result['duration']:.3f}s)")
253
- emit(output)
265
+ emit(output, dropped_non_av_streams=dropped_streams)
254
266
  return 0
255
267
 
256
268
 
package/scripts/scenes.py CHANGED
@@ -26,7 +26,7 @@ import subprocess
26
26
  import sys
27
27
  from typing import Dict, List, Tuple
28
28
 
29
- from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
29
+ from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run
30
30
 
31
31
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
32
32
 
@@ -107,6 +107,7 @@ def main() -> int:
107
107
  ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
108
108
  ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
109
109
  ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
110
+ ap.add_argument("--no-timecode", action="store_true", help="--sheet without the burnt-in timecode stamp (a way out if drawtext itself is unusable, see doctor)")
110
111
  add_common(ap)
111
112
  args = ap.parse_args()
112
113
  apply_common(args)
@@ -183,7 +184,12 @@ def main() -> int:
183
184
  # exactly one frame per scene: the frame index at the scene start
184
185
  fps = meta["video"].get("fps") or 30.0
185
186
  expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
186
- vf = (f"select='{expr}',scale={tile_w}:-2,drawtext=text='%{{pts\\:hms}}':fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6,"
187
+ stamp = ""
188
+ if not args.no_timecode:
189
+ default_font = default_font_file("DejaVu Sans")
190
+ font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
191
+ stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
192
+ vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
187
193
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
188
194
  run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
189
195
  info(f"wrote {args.sheet}")
@@ -9,12 +9,18 @@ this run only -- it is not a caller-facing artifact.
9
9
  --shakiness (1 = barely shaky, fast; 10 = very shaky, slow analysis) and
10
10
  --smoothing (how many neighbouring frames to average the camera path over)
11
11
  are the two knobs that matter most; --zoom crops in slightly to hide the
12
- black edges stabilizing can introduce (0 = keep the original framing and let
13
- edges show; ffmpeg's own --crop-mode is not exposed as a raw flag here).
12
+ edges stabilizing can introduce (0 = keep the original framing and let edges
13
+ show). --crop chooses what happens to any edge vidstab reveals that --zoom
14
+ doesn't crop away: "keep" (default) stretches the border pixels, "black"
15
+ fills it in solid black instead. --tripod locks the frame fully still
16
+ against a single reference frame (e.g. a camera meant to be static but
17
+ nudged, or a shot you want dead-locked rather than merely smoothed) instead
18
+ of following the camera's intended motion.
14
19
 
15
20
  Examples:
16
21
  python3 stabilize.py shaky.mp4
17
22
  python3 stabilize.py shaky.mp4 --shakiness 8 --smoothing 20 --zoom 5
23
+ python3 stabilize.py locked-off.mp4 --tripod --crop black
18
24
  """
19
25
  import argparse
20
26
  import sys
@@ -31,6 +37,8 @@ def main() -> int:
31
37
  ap.add_argument("--shakiness", type=int, default=5, help="1 (barely shaky) .. 10 (very shaky), default 5")
32
38
  ap.add_argument("--smoothing", type=int, default=15, help="frames of camera-path smoothing on each side, default 15")
33
39
  ap.add_argument("--zoom", type=float, default=0.0, help="percent to zoom in to hide stabilization edges, 0..100 (default 0)")
40
+ ap.add_argument("--crop", choices=["keep", "black"], default="keep", help="edges --zoom doesn't crop away: keep (stretch border pixels, default) or black (fill solid black)")
41
+ ap.add_argument("--tripod", action="store_true", help="lock the frame fully still against a single reference frame instead of smoothing the camera's motion")
34
42
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
35
43
  ap.add_argument("--preset", default="medium", help="x264 preset")
36
44
  add_common(ap)
@@ -56,13 +64,23 @@ def main() -> int:
56
64
 
57
65
  if not STATE["dry_run"]:
58
66
  ffmpeg = require_tool("ffmpeg")
67
+ detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
68
+ if args.tripod:
69
+ # A frame number, not a boolean: frame 1 is the standard reference for "lock to
70
+ # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
71
+ detect_vf += ":tripod=1"
59
72
  detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
60
- "-vf", f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}", "-f", "null", "-"]
73
+ "-vf", detect_vf, "-f", "null", "-"]
61
74
  proc = run(detect_cmd, check=False)
62
75
  if proc.returncode != 0:
63
76
  die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}")
64
77
 
65
- transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:zoom={args.zoom:g}:optzoom=1"
78
+ crop_mode = {"keep": 0, "black": 1}[args.crop]
79
+ transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
80
+ if args.tripod:
81
+ # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
82
+ # camera path makes no sense once every frame is locked to one fixed reference.
83
+ transform_vf += ":tripod=1"
66
84
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", transform_vf]
67
85
  cmd += video_args(meta, args.crf, args.preset)
68
86
  cmd += cfr_args(meta)