ffmpeg-skill 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +3 -3
- package/docs/contract.md +7 -4
- package/package.json +1 -1
- package/references/scripts.md +6 -4
- package/scripts/_ass_overlay.py +1 -1
- package/scripts/_common/drawtext.py +1 -1
- package/scripts/_contract.py +36 -13
- package/scripts/caption.py +1 -1
- package/scripts/join.py +36 -2
package/SKILL.md
CHANGED
|
@@ -5,13 +5,13 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
|
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
7
7
|
|
|
8
|
-
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other
|
|
8
|
+
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 30 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
|
|
9
9
|
|
|
10
10
|
Shared flags, on every script: `--dry-run`; `--json` (output path, a probe of the output, the commands run); `--json-brief` (status/output/verified plus a `summary`; prefer on writing steps); `--fast` (preview quality); `--progress`; `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (step 7); `--plan FILE` (the dry run as a plan `render.py FILE` runs later; refuses if an input changed). Re-encoding tools also take `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264, HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
11
11
|
|
|
12
12
|
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
|
13
13
|
|
|
14
|
-
## Workflow (
|
|
14
|
+
## Workflow (in this order)
|
|
15
15
|
|
|
16
16
|
0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails with `kind: missing_tool` or an ffmpeg error naming the filter/encoder. Run `python3 <skill-dir>/scripts/_contract.py doctor` (or `npx ffmpeg-skill doctor`) after such a failure, or when asked what the machine can do: read `ok`/`usable`, report the missing capability. `contract --json`'s tool schema is for a *planning* agent, not this workflow.
|
|
17
17
|
1. **Probe what you must plan from.** Run `probe.py` on each input you plan from — duration, fps, resolution, codecs, channels, `variable_frame_rate_suspected` — and whenever the user asks about a file. No separate probe before every edit: every writing tool's `--json` already carries its input and a probe of the output. Plan from real numbers, never assumptions.
|
|
@@ -96,7 +96,7 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
|
|
|
96
96
|
| "add subtitles from this SRT", "burn in captions" | `caption.py input.mp4 --srt subs.srt` |
|
|
97
97
|
| "caption it with these lines" (text with times) | `caption.py input.mp4 --text cues.txt` |
|
|
98
98
|
| "keep the subtitles toggleable", "mux in an SRT" | `caption.py input.mp4 --srt subs.srt --mode mux`; repeat `--srt file:lang` for several languages, `.mkv` for more than two |
|
|
99
|
-
| "the captions are tiny / three lines on a Short", "don't chop the sentence" | `caption.py` shrinks the size until the cue fits `--max-lines` before splitting it (`--fit-size off`
|
|
99
|
+
| "the captions are tiny / three lines on a Short", "don't chop the sentence" | `caption.py` shrinks the size until the cue fits `--max-lines` before splitting it (`--fit-size off` keeps `--size` and splits instead, `--min-size` the floor). Keep the template's `--max-lines` (2 on a vertical) and let the size drop; raising it to dodge a shrink stacks two words per line. A word still wider than the column at the floor is sliced at the edge (hyphen preferred), never rewritten, automatic; `broken_inside_word` in the JSON counts it |
|
|
100
100
|
| "transcribe it and caption it" | `caption.py input.mp4 --transcribe --animate pop --karaoke` (needs a local whisper; else `--text`) |
|
|
101
101
|
| "TikTok-style captions with the words popping" | `caption.py input.mp4 --text cues.txt --animate pop --karaoke` |
|
|
102
102
|
| "our logo top-right", "a watermark" | `overlay.py input.mp4 --image logo.png --position top-right --scale 200` |
|
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 (`2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.4.0`) | 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`
|
|
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
119
119
|
"contract_version": "1.0",
|
|
120
120
|
"deprecated": [],
|
|
121
121
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
122
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.4.0", "execution_mode": "local", "kind": "execution",
|
|
123
123
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
124
124
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
125
125
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -353,7 +353,10 @@ read (`ffmpeg -filters` in a layout the parser does not recognise, or ffmpeg exi
|
|
|
353
353
|
non-zero), and it is never folded into `missing`, so an installed filter is not reported
|
|
354
354
|
absent, nor into `available`, so a failed detection is not a pass. `detection` gives the
|
|
355
355
|
status (`parsed`, `unparsed`, `failed`, `missing`), row count and detail of each listing;
|
|
356
|
-
`errors` lists the unreadable ones.
|
|
356
|
+
`errors` lists the unreadable ones. `filter:drawtext` is also rendered once, with the
|
|
357
|
+
`fontfile=` the tools pass by default: a crash there makes it `missing`; a crash only on a
|
|
358
|
+
bare fontconfig lookup leaves it `available` and adds a `notes` entry (`--font-file`, not a
|
|
359
|
+
bare `--font <family>`, on that machine). The filter parser recognises the FFmpeg 6/7 layout
|
|
357
360
|
(three flag characters, `..C acompressor A->A`) and the FFmpeg 8 layout (two, `T.
|
|
358
361
|
acompressor A->A`) by the io-spec token, so the flag width does not matter; fixtures for
|
|
359
362
|
both live in `tests/fixtures/`.
|
|
@@ -518,7 +521,7 @@ Per-tool keys added after 2.2.2, all additive:
|
|
|
518
521
|
| key | tool | what it holds |
|
|
519
522
|
|---|---|---|
|
|
520
523
|
| `sdr_path`, `notes` | `color.py --to-sdr` | `sdr_path` is `"tonemap"` for PQ / HLG / Dolby Vision input (and `--force` on an untagged file) or `"gamut"` for BT.2020 primaries on an SDR transfer, which is converted to BT.709 without a tone map (`--tonemap`/`--peak`/`--desat` do not apply); `notes` says which path was taken and why |
|
|
521
|
-
| `silent` | `join.py` (every run) | `[{index, path, peak_db}]`: inputs whose whole-file audio peak (volumedetect) is at or below `--silence-threshold` (default -50 dBFS) and that `--on-silent warn` (default) joined anyway, with a `notes` line; `[]` otherwise. `--on-silent fail` names them in the `kind: input` refusal's `problems` (reason `silent (peak -91.0 dBFS)`), `skip` lists them under `skipped`. An input with no audio stream is never silent; `verified` is unaffected |
|
|
524
|
+
| `silent` | `join.py` (every run) | `[{index, path, peak_db, at, end}]`: inputs whose whole-file audio peak (volumedetect) is at or below `--silence-threshold` (default -50 dBFS) and that `--on-silent warn` (default) joined anyway, with a `notes` line; `at`/`end` are where the input sits in the output, in seconds (a transition overlaps its neighbours). An input named by `--allow-silent N[,N]` (1-based) is a planned pause: always joined, listed with `intended: true`, no warning or note, whatever `--on-silent` says. `[]` otherwise. `--on-silent fail` names them in the `kind: input` refusal's `problems` (reason `silent (peak -91.0 dBFS)`), `skip` lists them under `skipped`. An input with no audio stream is never silent; `verified` is unaffected |
|
|
522
525
|
| `short_segments`, `duplicates` | `join.py` (every run) | warnings, never refusals, and the join command is unchanged: `short_segments: [{index, path, duration}]` names each measured input shorter than 2 frames at the join's fps (an audio-only join: shorter than 0.05 s); `duplicates: [{path, indices}]` names a path (compared resolved) listed more than once — repeating a clip can be intended. `[]` when none; each non-empty one adds a `notes` line |
|
|
523
526
|
| `silent` | `waveform.py` (every run) | `true` when the input audio's whole-file peak is at or below `--silence-threshold` (default -50 dBFS), which draws a flat line; `--on-silent warn` (default) renders it anyway with a `notes` line, `fail` refuses (`kind: input`) before ffmpeg runs. `false` when audible, `null` under `--dry-run` (nothing measured). `verified` is unaffected |
|
|
524
527
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -560,7 +560,7 @@ writes the resulting cut list exactly as it does today.
|
|
|
560
560
|
```
|
|
561
561
|
join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
|
|
562
562
|
[--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
|
|
563
|
-
join.py --list parts.txt [--on-missing fail|skip] [--on-silent warn|fail|skip] [--silence-threshold -50] [...]
|
|
563
|
+
join.py --list parts.txt [--on-missing fail|skip] [--on-silent warn|fail|skip] [--allow-silent N[,N]] [--silence-threshold -50] [...]
|
|
564
564
|
```
|
|
565
565
|
`--list FILE` (2.2) reads the clips from a file, one per line in order, paths relative to the
|
|
566
566
|
file (blank lines and `#` comments ignored; ffmpeg's `file 'x.wav'` lines accepted), for the
|
|
@@ -579,9 +579,11 @@ frame, rate or xfade offsets that rest on a pending input's unmeasured placehold
|
|
|
579
579
|
Every existing input with an audio stream is also measured (one volumedetect pass; not under `--dry-run`
|
|
580
580
|
too): a peak at or below `--silence-threshold` (default -50 dBFS) is silent, the trace of a TTS
|
|
581
581
|
call that wrote a valid but empty file. `--on-silent warn` (default) joins it and names it under
|
|
582
|
-
`silent: [{index, path, peak_db}]`
|
|
583
|
-
document (`silent (peak -91.0 dBFS)`);
|
|
584
|
-
|
|
582
|
+
`silent: [{index, path, peak_db, at, end}]` (`at`/`end`: where it sits in the output, seconds)
|
|
583
|
+
and in `notes`; `fail` refuses it in the same `kind: input` document (`silent (peak -91.0 dBFS)`);
|
|
584
|
+
`skip` leaves it out under `skipped`. A pause that is meant to be silent: `--allow-silent 3,7`
|
|
585
|
+
(1-based) joins those inputs whatever `--on-silent` says and lists them with `intended: true`,
|
|
586
|
+
no warning. A clip without an audio stream is never silent.
|
|
585
587
|
Two warnings never refuse or change the command: `short_segments: [{index, path, duration}]`
|
|
586
588
|
names a clip shorter than 2 frames at the join's fps (audio-only: 0.05 s), and
|
|
587
589
|
`duplicates: [{path, indices}]` a path listed more than once (a repeat can be intended); both
|
package/scripts/_ass_overlay.py
CHANGED
|
@@ -29,7 +29,7 @@ EMOJI_SENTINEL = "\ue000"
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def emoji_placeholder(box_px: float) -> str:
|
|
32
|
-
"""The ASS override that reserves exactly `box_px` of advance and draws nothing.
|
|
32
|
+
r"""The ASS override that reserves exactly `box_px` of advance and draws nothing.
|
|
33
33
|
|
|
34
34
|
Measured, not assumed (1.15 spec, open question 2). U+2588 FULL BLOCK is NOT 1.0 em: its
|
|
35
35
|
advance measured 0.83 em in FreeSans, 0.79 in WenQuanYi Zen Hei and 0.66 in DejaVu Sans,
|
|
@@ -98,7 +98,7 @@ def escape_drawtext(text: str) -> str:
|
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
|
|
101
|
-
"""`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
|
|
101
|
+
r"""`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
|
|
102
102
|
every character on every build shape this repo uses.
|
|
103
103
|
|
|
104
104
|
The filter-graph parser never sees the text at all: only the PATH is parsed, and
|
package/scripts/_contract.py
CHANGED
|
@@ -464,7 +464,7 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
464
464
|
"sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
|
|
465
465
|
"video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
|
|
466
466
|
"skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
|
|
467
|
-
"silent": {"type": "array", "description": "inputs whose audio peak is at or below --silence-threshold (default -50 dBFS) and that --on-silent warn (default) joined anyway: [{index, path, peak_db}] ([] otherwise; fail refuses
|
|
467
|
+
"silent": {"type": "array", "description": "inputs whose audio peak is at or below --silence-threshold (default -50 dBFS) and that --on-silent warn (default) joined anyway, plus every --allow-silent input that was silent: [{index, path, peak_db, at, end, intended?}]; at/end are the input's start and end in output seconds (a transition overlaps neighbours), intended: true marks an --allow-silent input (joined whatever --on-silent says, no warning). [] otherwise; fail refuses unallowed ones under problems, skip lists them under skipped; an input with no audio stream is never silent"},
|
|
468
468
|
"short_segments": {"type": "array", "description": "warning, never a refusal: measured inputs shorter than 2 frames at the join's fps (audio-only join: shorter than 0.05 s), [{index, path, duration}] ([] when none), each also in notes; a pending --dry-run input is not measured"},
|
|
469
469
|
"duplicates": {"type": "array", "description": "warning, never a refusal (a repeat can be intended): a path (compared resolved) listed more than once and joined each time, [{path, indices}] with 0-based list positions ([] when none), also in notes"},
|
|
470
470
|
"pending": {"type": "array", "description": "--dry-run: inputs that do not exist yet, planned on as an earlier step's output and never also under skipped: [{index, path}] ([] otherwise)"},
|
|
@@ -668,22 +668,42 @@ def _drawtext_probe() -> Dict[str, Any]:
|
|
|
668
668
|
present and doctor used to report the capability `available` anyway; every tool that actually
|
|
669
669
|
used it (look, scenes --sheet, overlay --text, graphics) then crashed on first real use (#100).
|
|
670
670
|
|
|
671
|
-
This runs the cheapest real drawtext render there is: a one-frame synthetic clip,
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
671
|
+
This runs the cheapest real drawtext render there is: a one-frame synthetic clip, with the
|
|
672
|
+
fontfile= the tools themselves pass by default (_common.fonts.default_font_file), or no font
|
|
673
|
+
at all where none resolves. A clean exit means drawtext genuinely works for the tools here.
|
|
674
|
+
Anything that could not prove either way (no ffmpeg, timeout, an ordinary nonzero exit with a
|
|
675
|
+
real ffmpeg error) is `unknown`, same "unknown is not missing" principle as every other
|
|
676
|
+
capability here. A crash specifically -- killed by signal on POSIX, or an unhandled access
|
|
677
|
+
violation surfacing as a huge unsigned exit code on Windows -- folds into `missing`. When the
|
|
678
|
+
fontfile= render works but a bare one crashes, drawtext stays available and the result
|
|
679
|
+
carries a `note`: only a fontconfig lookup (`--font <family>` with no file) would crash.
|
|
679
680
|
"""
|
|
680
681
|
exe = shutil.which("ffmpeg")
|
|
681
682
|
if not exe:
|
|
682
683
|
return {"status": "unknown", "detail": "ffmpeg not on PATH"}
|
|
684
|
+
# Probe the way the tools call drawtext: with the fontfile= they pick by default (#298). On a
|
|
685
|
+
# build whose fontconfig lookup crashes (#100) the tools still render, because they never ask
|
|
686
|
+
# fontconfig; probing only the bare `drawtext=text=x` reported such a machine as missing
|
|
687
|
+
# drawtext altogether. The bare render still runs, and its crash is reported as a note: it is
|
|
688
|
+
# the path `--font <family>` without a file would take.
|
|
689
|
+
from _common.fonts import default_font_file
|
|
690
|
+
from _common.decision import escape_filter_path
|
|
691
|
+
font_file = default_font_file(_default_font())
|
|
692
|
+
result = _drawtext_render(exe, f"drawtext=fontfile={escape_filter_path(font_file)}:text=x" if font_file else "drawtext=text=x")
|
|
693
|
+
if font_file and result["status"] == "available":
|
|
694
|
+
bare = _drawtext_render(exe, "drawtext=text=x")
|
|
695
|
+
if bare["status"] == "missing":
|
|
696
|
+
result["note"] = ("drawtext renders with fontfile= (what every drawtext tool passes by default) but crashes "
|
|
697
|
+
"when it has to resolve a font through fontconfig: pass --font-file, not a bare --font "
|
|
698
|
+
"<family>, on this machine (https://github.com/kajisho5/ffmpeg-skill/issues/100)")
|
|
699
|
+
return result
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _drawtext_render(exe: str, vf: str) -> Dict[str, Any]:
|
|
683
703
|
try:
|
|
684
704
|
proc = subprocess.run(
|
|
685
705
|
[exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
|
|
686
|
-
"-vf",
|
|
706
|
+
"-vf", vf, "-frames:v", "1", "-f", "null", "-"],
|
|
687
707
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=_DETECT_TIMEOUT,
|
|
688
708
|
)
|
|
689
709
|
except subprocess.TimeoutExpired:
|
|
@@ -695,9 +715,8 @@ def _drawtext_probe() -> Dict[str, Any]:
|
|
|
695
715
|
if proc.returncode < 0 or proc.returncode >= 0x80000000:
|
|
696
716
|
return {"status": "missing",
|
|
697
717
|
"detail": f"drawtext render crashed (exit {proc.returncode}) instead of failing cleanly -- "
|
|
698
|
-
"the filter is present in this build but cannot
|
|
699
|
-
"
|
|
700
|
-
"pass an explicit --font-file to every drawtext tool as a workaround"}
|
|
718
|
+
"the filter is present in this build but cannot render even with the fontfile= the "
|
|
719
|
+
"tools pass (see https://github.com/kajisho5/ffmpeg-skill/issues/100)"}
|
|
701
720
|
tail = " ".join(proc.stderr.strip().splitlines()[-2:])
|
|
702
721
|
return {"status": "unknown", "detail": f"drawtext probe exited {proc.returncode}: {tail}"}
|
|
703
722
|
|
|
@@ -809,6 +828,8 @@ def doctor() -> Dict[str, Any]:
|
|
|
809
828
|
# than downgrading it; see _drawtext_probe()'s own docstring for why a crash alone
|
|
810
829
|
# is the one case this exists to catch.
|
|
811
830
|
probe = _drawtext_probe()
|
|
831
|
+
if probe.get("note"):
|
|
832
|
+
drawtext_probe = probe
|
|
812
833
|
if probe["status"] == "missing":
|
|
813
834
|
drawtext_probe = probe
|
|
814
835
|
state[cap] = "missing"
|
|
@@ -832,6 +853,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
832
853
|
errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
|
|
833
854
|
if drawtext_probe is not None and drawtext_probe["status"] != "available":
|
|
834
855
|
errors.append(f"filter:drawtext: {drawtext_probe['detail']}")
|
|
856
|
+
notes = [f"filter:drawtext: {drawtext_probe['note']}"] if drawtext_probe and drawtext_probe.get("note") else []
|
|
835
857
|
return {
|
|
836
858
|
"version": skill_version(),
|
|
837
859
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
@@ -843,6 +865,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
843
865
|
"unknown": unknown,
|
|
844
866
|
"detection": {k: {"status": v["status"], "count": len(v["names"]), "detail": v["detail"]} for k, v in listings.items()},
|
|
845
867
|
"errors": errors,
|
|
868
|
+
**({"notes": notes} if notes else {}),
|
|
846
869
|
"ok": not missing_required and not unknown_required,
|
|
847
870
|
"tools": _tool_usability(state),
|
|
848
871
|
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
package/scripts/caption.py
CHANGED
|
@@ -954,7 +954,7 @@ def main() -> int:
|
|
|
954
954
|
sty.add_argument("--max-lines", type=int, default=2, help="most lines one cue may occupy; a longer cue is split into consecutive cues (default 2)")
|
|
955
955
|
sty.add_argument("--fit-size", choices=["auto", "on", "off"], default="auto",
|
|
956
956
|
help="shrink the caption size until the cue fits --max-lines, BEFORE splitting it: "
|
|
957
|
-
"'auto' (default) only when no --size was given, 'on' always, 'off'
|
|
957
|
+
"'auto' (default) only when no --size was given, 'on' always, 'off' keeps --size and splits the cue instead")
|
|
958
958
|
sty.add_argument("--min-size", type=int, default=None,
|
|
959
959
|
help="smallest size --fit-size may use, in ASS points (default 13 = 4.5%% of the frame height, "
|
|
960
960
|
"the legibility floor)")
|
package/scripts/join.py
CHANGED
|
@@ -127,6 +127,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
127
127
|
n = len(args.inputs)
|
|
128
128
|
durs = [m.get("duration") or 0.0 for m in metas]
|
|
129
129
|
d = args.duration if args.transition != "none" else 0.0
|
|
130
|
+
place_silent(durs, d)
|
|
130
131
|
note_short_segments(args.inputs, metas, MIN_AUDIO_SEGMENT, f"{MIN_AUDIO_SEGMENT:g}s")
|
|
131
132
|
for p, dur in zip(args.inputs, durs):
|
|
132
133
|
if d and dur <= d * 2 and not STATE.dry_run:
|
|
@@ -258,6 +259,32 @@ def find_silent(paths: List[str], audible: List[int], threshold: float) -> List[
|
|
|
258
259
|
return silent
|
|
259
260
|
|
|
260
261
|
|
|
262
|
+
def place_silent(lens: List[float], d: float) -> None:
|
|
263
|
+
"""Where each joined silent input sits in the output: `at`/`end` in output seconds. Input k
|
|
264
|
+
(in kept order) starts after the kept inputs before it, each overlapped by the transition d."""
|
|
265
|
+
for sil in STATE_SILENT:
|
|
266
|
+
if sil["index"] not in STATE_KEPT:
|
|
267
|
+
continue
|
|
268
|
+
k = STATE_KEPT.index(sil["index"])
|
|
269
|
+
if k >= len(lens):
|
|
270
|
+
continue
|
|
271
|
+
at = sum(lens[:k]) - k * d
|
|
272
|
+
sil["at"], sil["end"] = round(at, 3), round(at + lens[k], 3)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def parse_allow_silent(value: Optional[str], n: int) -> List[int]:
|
|
276
|
+
"""--allow-silent 3,7 -> 0-based indices; each must name an input (1..n)."""
|
|
277
|
+
if not value:
|
|
278
|
+
return []
|
|
279
|
+
out = []
|
|
280
|
+
for tok in value.split(","):
|
|
281
|
+
tok = tok.strip()
|
|
282
|
+
if not tok.isdigit() or not 1 <= int(tok) <= n:
|
|
283
|
+
die(f"--allow-silent takes input numbers 1..{n} separated by commas, got {tok!r}")
|
|
284
|
+
out.append(int(tok) - 1)
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
|
|
261
288
|
def main() -> int:
|
|
262
289
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
263
290
|
ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
|
|
@@ -270,6 +297,9 @@ def main() -> int:
|
|
|
270
297
|
help="an input whose audio peak is at or below --silence-threshold: warn (default; join it, "
|
|
271
298
|
"name it under `silent`), fail (refuse with the other input problems) or skip (leave it out, "
|
|
272
299
|
"under `skipped`); a clip with no audio stream is never silent")
|
|
300
|
+
ap.add_argument("--allow-silent", metavar="N[,N...]",
|
|
301
|
+
help="input numbers (1-based) whose silence is intended, e.g. a planned pause: always joined, "
|
|
302
|
+
"listed under `silent` with intended: true, no warning, whatever --on-silent says")
|
|
273
303
|
ap.add_argument("--silence-threshold", type=float, default=-50.0, metavar="DB",
|
|
274
304
|
help="peak level in dBFS at or below which an input counts as silent (default -50)")
|
|
275
305
|
ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
|
|
@@ -303,7 +333,10 @@ def main() -> int:
|
|
|
303
333
|
orig_inputs = list(args.inputs)
|
|
304
334
|
audible: List[int] = []
|
|
305
335
|
problems, pending = preflight(args.inputs, audible)
|
|
306
|
-
|
|
336
|
+
allowed = parse_allow_silent(args.allow_silent, len(args.inputs))
|
|
337
|
+
found = find_silent(args.inputs, audible, args.silence_threshold)
|
|
338
|
+
intended = [dict(s, intended=True) for s in found if s["index"] in allowed]
|
|
339
|
+
silent = [s for s in found if s["index"] not in allowed]
|
|
307
340
|
silent_problems = [{"index": s["index"], "path": s["path"], "reason": f"silent (peak {s['peak_db']:.1f} dBFS)"}
|
|
308
341
|
for s in silent]
|
|
309
342
|
refused = sorted((problems if args.on_missing == "fail" else [])
|
|
@@ -325,8 +358,8 @@ def main() -> int:
|
|
|
325
358
|
args.inputs = [p for i, p in enumerate(args.inputs) if i not in bad]
|
|
326
359
|
for p in dropped:
|
|
327
360
|
info(f"skipping #{p['index'] + 1} {p['path']}: {p['reason']}")
|
|
361
|
+
STATE_SILENT[:] = sorted(intended + (silent if args.on_silent == "warn" else []), key=lambda s: s["index"])
|
|
328
362
|
if silent and args.on_silent == "warn":
|
|
329
|
-
STATE_SILENT[:] = silent
|
|
330
363
|
listing = ", ".join(f"#{s['index'] + 1} {s['path']} (peak {s['peak_db']:.1f} dBFS)" for s in silent)
|
|
331
364
|
info(f"warning: {len(silent)} input(s) silent at or below {args.silence_threshold:g} dBFS, joined anyway: {listing}")
|
|
332
365
|
STATE_NOTES.append(f"{len(silent)} input(s) have no audible sound (peak at or below {args.silence_threshold:g} dBFS) "
|
|
@@ -413,6 +446,7 @@ def main() -> int:
|
|
|
413
446
|
# a crossfaded join gives each clip one length for both streams, so the offsets that place
|
|
414
447
|
# every picture also place its sound; the plain cut (concat) keeps the streams paired itself
|
|
415
448
|
lens = [clip_length(m, fps) for m in metas] if d else durs
|
|
449
|
+
place_silent(lens, d)
|
|
416
450
|
for p, dur in zip(args.inputs, lens):
|
|
417
451
|
if d and dur <= d * 2 and not STATE.dry_run:
|
|
418
452
|
die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
|