ffmpeg-skill 3.8.17 → 3.8.19
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 +3 -3
- package/package.json +1 -1
- package/references/scripts.md +16 -5
- package/scripts/_common/decision.py +60 -2
- package/scripts/_common/fonts.py +31 -0
- package/scripts/_common/probe.py +14 -0
- package/scripts/_contract.py +2 -1
- package/scripts/broll.py +13 -2
- package/scripts/caption.py +10 -2
- package/scripts/cut.py +2 -1
- package/scripts/fit.py +11 -5
- package/scripts/graphics.py +12 -4
- package/scripts/grid.py +14 -5
- package/scripts/join.py +11 -1
- package/scripts/metadata.py +4 -1
- package/scripts/overlay.py +14 -2
- package/scripts/redact.py +5 -1
- package/scripts/render.py +5 -1
- package/scripts/report.py +26 -3
- package/scripts/sequence.py +39 -2
- package/scripts/silence.py +4 -0
- package/scripts/sync.py +4 -1
- package/scripts/verify.py +22 -2
package/docs/contract.md
CHANGED
|
@@ -26,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
26
26
|
| Field | Meaning | Changes when |
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
29
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
29
|
+
| `skill.version` | the npm / package.json version (`3.8.19`) | any release |
|
|
30
30
|
|
|
31
31
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
32
32
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -154,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
154
154
|
"deprecated": [],
|
|
155
155
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
156
156
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
157
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
157
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.19", "execution_mode": "local", "kind": "execution",
|
|
158
158
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
159
159
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
160
160
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -956,7 +956,7 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
|
956
956
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
957
957
|
`output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
|
|
958
958
|
`media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
|
|
959
|
-
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_extension_missing` (a media output with no file extension, which leaves ffmpeg no container to choose), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `still_unreadable` (a file with an image name that ffmpeg cannot decode as one), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
959
|
+
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_extension_missing` (a media output with no file extension, which leaves ffmpeg no container to choose), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `still_unreadable` (a file with an image name that ffmpeg cannot decode as one; for `sequence.py`, a frame of the sequence that does not decode, when its decode check completes; when the check cannot be made a note says so and the encode runs), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
960
960
|
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
|
961
961
|
`encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
|
|
962
962
|
and `english_only`), and `error.details`, the facts the message is written from, so a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.19",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|
package/references/scripts.md
CHANGED
|
@@ -311,7 +311,8 @@ fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
|
|
|
311
311
|
`speed` retimes video and audio together (pitch-preserving `atempo`); it
|
|
312
312
|
refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
|
|
313
313
|
(frame blending, fast) or `--smooth interpolate` (motion-compensated
|
|
314
|
-
`minterpolate`, fluid but roughly 10-20x slower than realtime
|
|
314
|
+
`minterpolate`, fluid but roughly 10-20x slower than realtime; its frames are
|
|
315
|
+
made at `--fps` when given, else at the source's rate). `trim` keeps
|
|
315
316
|
the head (or the middle with `--from-center`). `--width`/`--height` set the
|
|
316
317
|
output size: give one and the other follows the aspect (source aspect if
|
|
317
318
|
`--aspect` isn't also given); give both for an exact frame. `--rotate` applies
|
|
@@ -522,7 +523,12 @@ sequence.py --dir DIR --pattern "frame_%04d.png"|"*.png" --fps N
|
|
|
522
523
|
Turns a numbered or glob-matched set of still images into a video. Glob
|
|
523
524
|
matches are ordered naturally (`img2` before `img10`). The match
|
|
524
525
|
is checked on disk before ffmpeg runs (an empty match or a missing first
|
|
525
|
-
frame is refused here, not discovered from an opaque ffmpeg error)
|
|
526
|
+
frame is refused here, not discovered from an opaque ffmpeg error), and every
|
|
527
|
+
frame is decoded once (ffprobe) before the encode. When that check completes, a
|
|
528
|
+
frame that is not an image it can decode (a text file named `.png`, a truncated
|
|
529
|
+
PNG) is refused, `kind: input`, `reason: still_unreadable`, with the frames
|
|
530
|
+
named. When ffprobe fails on the frame list or reads back no frame, the frames
|
|
531
|
+
are not checked: a note says so and the encode runs.
|
|
526
532
|
|
|
527
533
|
### waveform.py — audio waveform/spectrum visualization video
|
|
528
534
|
```
|
|
@@ -656,6 +662,7 @@ ends -- a cutaway, not a splice, so the output is exactly as long as A. One
|
|
|
656
662
|
`--insert`/`--at` pair per cutaway (`--duration`, `--end`, `--from` are per
|
|
657
663
|
cutaway too, or given once for all; defaults 4 s and 0); windows may not
|
|
658
664
|
overlap or run past A's end, and B must have enough material from `--from`.
|
|
665
|
+
A still picture as B is shown for the whole window.
|
|
659
666
|
`--audio a` (default) keeps A's audio untouched and stream-copied; `b` replaces
|
|
660
667
|
it inside each window with B's; `mix` plays both. The output's length is
|
|
661
668
|
verified against A's.
|
|
@@ -717,7 +724,8 @@ bottom-right corner; `--label none` skips it. No audio unless `--audio-from`
|
|
|
717
724
|
picks one input's track by index -- mixing every clip's audio together is
|
|
718
725
|
rarely useful for a comparison grid, so this tool never does that silently.
|
|
719
726
|
Runs only as long as the shortest clip by default; `--pad` instead holds
|
|
720
|
-
each shorter clip's last frame (with silence) out to the longest.
|
|
727
|
+
each shorter clip's last frame (with silence) out to the longest. A still
|
|
728
|
+
picture fills its cell for the grid's length; a grid of stills only is refused.
|
|
721
729
|
|
|
722
730
|
### silence.py — remove dead air / jump cuts
|
|
723
731
|
```
|
|
@@ -1192,7 +1200,8 @@ hand-built step:
|
|
|
1192
1200
|
and `check.py --platform podcast`. `--template all` renders `tiktok, reels, shorts, youtube, x,
|
|
1193
1201
|
linkedin, facebook` (not the audio template, not the `youtube-shorts` alias) into
|
|
1194
1202
|
`<stem>_<platform>.mp4`, runs each platform's check and writes `<stem>_pack.md` with one row per
|
|
1195
|
-
destination; `report.py --pack <stem>_pack.md` renders that table
|
|
1203
|
+
destination; `report.py --pack <stem>_pack.md` renders that table (and refuses a file whose first
|
|
1204
|
+
table has other columns) as a single HTML page. A pack
|
|
1196
1205
|
whose destinations did not all pass exits non-zero with the per-destination rows in `pack`.
|
|
1197
1206
|
|
|
1198
1207
|
`audiogram` (1.16) is the one-call form of the podcast-to-video job: an `audiogram` stage runs
|
|
@@ -1607,7 +1616,9 @@ like column; `--margin PX` sets all four edges and wins over `--platform`.
|
|
|
1607
1616
|
read by `caption.py` and, for `font` and `colour`, by `graphics.py`; it wins
|
|
1608
1617
|
over the older top-level `caption` block where both set the same key, and that
|
|
1609
1618
|
block still carries the burn-in-only defaults (`animate`, `karaoke`, `bold`,
|
|
1610
|
-
`outline`). `"lang"` is the script hint `--lang` would give.
|
|
1619
|
+
`outline`). `"lang"` is the script hint `--lang` would give. A key no tool
|
|
1620
|
+
reads is refused, naming it (`loudness`, read by none, gets a note), and a
|
|
1621
|
+
`font_file` that is not a font is refused where text is drawn with it. When a
|
|
1611
1622
|
user mentions brand guidelines, colours, "our font" or a logo, ask for or
|
|
1612
1623
|
write a brand.json once and reuse it across every output.
|
|
1613
1624
|
|
|
@@ -16,11 +16,21 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
16
16
|
from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
|
|
17
17
|
is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
|
|
18
18
|
from _common.alpha import rgb_to_bt709_line
|
|
19
|
-
from _common.emit import die, input_length, record_fallback, refuse, warn
|
|
19
|
+
from _common.emit import die, input_length, note, record_fallback, refuse, warn
|
|
20
20
|
from _common.runner import read_json
|
|
21
21
|
from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def boxblur_chroma_radius(want: int) -> str:
|
|
25
|
+
"""boxblur's chroma radius as an expression boxblur evaluates on the chroma planes' own size
|
|
26
|
+
(cw x ch, so a 4:1:1 or 4:1:0 frame's quarter-width planes are what is measured, not an assumed
|
|
27
|
+
4:2:0): `want`, or the largest radius that plane holds, floor((min(cw, ch) - 1) / 2), or 0 on a
|
|
28
|
+
plane too small for 1. On 4:2:0 that is redact.py's earlier (min(w, h) // 2 - 1) // 2. boxblur
|
|
29
|
+
refuses a radius above half the plane (6.1); 7.1 also refuses 1 on a 2 px plane, which this
|
|
30
|
+
bound leaves out (#527)."""
|
|
31
|
+
return f"max(0\\,min({int(want)}\\,floor((min(cw\\,ch)-1)/2)))"
|
|
32
|
+
|
|
33
|
+
|
|
24
34
|
def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0,
|
|
25
35
|
meta: Optional[Dict[str, Any]] = None) -> str:
|
|
26
36
|
"""The letterbox/pillarbox step shared by fit.py and export.py, as one -vf segment.
|
|
@@ -41,9 +51,12 @@ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken
|
|
|
41
51
|
# boxblur rejects a radius larger than half the smaller dimension ("radius 20, must be
|
|
42
52
|
# <= 8" on a 16 px target); clamp instead of failing an otherwise valid request
|
|
43
53
|
radius = max(1, min(int(blur), max(1, min(out_w, out_h) // 2 - 1)))
|
|
54
|
+
# left unset, the chroma radius was the luma one, and a 4:2:0 68x120 frame's 34x60 chroma
|
|
55
|
+
# planes failed at 20 ("must be <= 17", #527). On a larger frame it is still the luma one
|
|
56
|
+
chroma = boxblur_chroma_radius(radius)
|
|
44
57
|
return (f"split[__fitfg][__fitbg];"
|
|
45
58
|
f"[__fitbg]scale={out_w}:{out_h}:force_original_aspect_ratio=increase,crop={out_w}:{out_h},"
|
|
46
|
-
f"boxblur={radius}:2" + (f",eq=brightness=-{darken:g}" if darken else "") + "[__fitbgb];"
|
|
59
|
+
f"boxblur={radius}:2:{chroma}:2" + (f",eq=brightness=-{darken:g}" if darken else "") + "[__fitbgb];"
|
|
47
60
|
f"[__fitfg]scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[__fitfgs];"
|
|
48
61
|
f"[__fitbgb][__fitfgs]overlay=(W-w)/2:(H-h)/2:format=auto")
|
|
49
62
|
pad = f"pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color="
|
|
@@ -1295,6 +1308,46 @@ BRAND_DEFAULTS: Dict[str, Any] = {
|
|
|
1295
1308
|
}
|
|
1296
1309
|
|
|
1297
1310
|
|
|
1311
|
+
# Every key a tool reads from a brand file, per block (#510). `styles.caption` is merged over the
|
|
1312
|
+
# top-level `caption` block (brand_caption_style()), so the two take the same keys. `colors.accent`
|
|
1313
|
+
# is a palette entry BRAND_DEFAULTS and examples/brand.json carry; `loudness` is read by no tool
|
|
1314
|
+
# (A107) and is kept out of the refusal, since the example shipped it until 3.8.8: it gets a note.
|
|
1315
|
+
BRAND_KEYS: Dict[str, frozenset] = {
|
|
1316
|
+
"": frozenset({"font", "font_file", "colors", "logo", "logo_position", "logo_scale", "logo_opacity",
|
|
1317
|
+
"safe_margin", "caption", "styles", "lang", "emoji_assets", "loudness"}),
|
|
1318
|
+
"colors": frozenset({"primary", "text", "outline", "background", "accent", "upcoming"}),
|
|
1319
|
+
"caption": frozenset({"size", "position", "animate", "karaoke", "bold", "outline", "box", "color", "colour",
|
|
1320
|
+
"font", "emoji_assets"}),
|
|
1321
|
+
"styles": frozenset({"caption"}),
|
|
1322
|
+
}
|
|
1323
|
+
BRAND_KEYS["styles.caption"] = BRAND_KEYS["caption"]
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def check_brand_keys(data: Dict[str, Any], path: str) -> None:
|
|
1327
|
+
"""Refuse a brand file key no tool reads, as render.py refuses a project's (check_keys()): a
|
|
1328
|
+
misspelt `"safe_margn"` or `"colours"` was dropped without a word and the defaults drawn."""
|
|
1329
|
+
import difflib
|
|
1330
|
+
import json
|
|
1331
|
+
blocks = [("", data)]
|
|
1332
|
+
for name in ("colors", "caption", "styles"):
|
|
1333
|
+
if isinstance(data.get(name), dict):
|
|
1334
|
+
blocks.append((name, data[name]))
|
|
1335
|
+
if isinstance(data.get("styles"), dict) and isinstance(data["styles"].get("caption"), dict):
|
|
1336
|
+
blocks.append(("styles.caption", data["styles"]["caption"]))
|
|
1337
|
+
for block, obj in blocks:
|
|
1338
|
+
for key in obj:
|
|
1339
|
+
if key in BRAND_KEYS[block]:
|
|
1340
|
+
continue
|
|
1341
|
+
where = f"{block}.{key}" if block else str(key)
|
|
1342
|
+
near = difflib.get_close_matches(str(key), sorted(BRAND_KEYS[block]), n=1)
|
|
1343
|
+
# quoted as JSON: the key is the caller's text, and a quote or a line break in it stays one line
|
|
1344
|
+
die(f"brand file {path}: unknown key {json.dumps(where)}" + (f' (did you mean "{near[0]}"?)' if near else ""),
|
|
1345
|
+
kind="input", hint=f"the keys read{' in ' + block if block else ''}: " + ", ".join(sorted(BRAND_KEYS[block])))
|
|
1346
|
+
if "loudness" in data:
|
|
1347
|
+
note(f'brand file {path}: "loudness" is read by no tool and was ignored (a target is loudness.py -I, '
|
|
1348
|
+
'export.py\'s preset, or a render project\'s "loudness")')
|
|
1349
|
+
|
|
1350
|
+
|
|
1298
1351
|
def load_brand(path: Optional[str]) -> Dict[str, Any]:
|
|
1299
1352
|
"""Load brand.json (fonts, colours, logo, safe margins, caption defaults); missing keys fall back to defaults."""
|
|
1300
1353
|
import copy
|
|
@@ -1322,6 +1375,7 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
|
|
|
1322
1375
|
die(f'brand file {path}: "{key}" must be an object {{...}}, got {type(value).__name__}', kind="input")
|
|
1323
1376
|
if key in ("logo", "font_file", "font") and not isinstance(value, str):
|
|
1324
1377
|
die(f'brand file {path}: "{key}" must be a string, got {type(value).__name__}', kind="input")
|
|
1378
|
+
check_brand_keys(data, path)
|
|
1325
1379
|
base = Path(path).resolve().parent
|
|
1326
1380
|
for k, v in data.items():
|
|
1327
1381
|
if v is None and isinstance(brand.get(k), dict):
|
|
@@ -1333,6 +1387,10 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
|
|
|
1333
1387
|
for key in ("logo", "font_file"):
|
|
1334
1388
|
if brand.get(key) and not os.path.isabs(brand[key]):
|
|
1335
1389
|
brand[key] = str(base / brand[key])
|
|
1390
|
+
# a file the run reads: a --plan binds it and is never written over it (review of #534's
|
|
1391
|
+
# sweep: caption.py hands libass the font's folder, so its plan named neither)
|
|
1392
|
+
if brand.get(key) and os.path.isfile(brand[key]) and brand[key] not in STATE.plan_inputs:
|
|
1393
|
+
STATE.plan_inputs.append(brand[key])
|
|
1336
1394
|
brand["_path"] = str(path)
|
|
1337
1395
|
# What the FILE said, separate from BRAND_DEFAULTS' filler: a brand.json that never mentions
|
|
1338
1396
|
# a font must not read as "the caller chose a font" (which would switch font-by-script off).
|
package/scripts/_common/fonts.py
CHANGED
|
@@ -278,6 +278,37 @@ _SCRIPT_RANGES = (
|
|
|
278
278
|
)
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
# The first bytes of the font files FreeType opens (drawtext and libass both load through it):
|
|
282
|
+
# TrueType, OpenType/CFF, Apple 'true'/'typ1', a collection, WOFF/WOFF2, PostScript Type 1 (PFA
|
|
283
|
+
# text, PFB segment marker), BDF and PCF bitmap fonts.
|
|
284
|
+
_FONT_MAGIC = (b"\x00\x01\x00\x00", b"OTTO", b"true", b"typ1", b"ttcf", b"wOFF", b"wOF2",
|
|
285
|
+
b"%!PS-AdobeFont", b"%!FontType1", b"\x80\x01", b"STARTFONT", b"\x01fcp")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def require_font_file(path: str, label: str = "--font-file") -> str:
|
|
289
|
+
"""Refuse a font file that is not there or is not a font, before anything is drawn with it
|
|
290
|
+
(#510). drawtext that cannot load its `fontfile=` asks fontconfig for a default face instead
|
|
291
|
+
and exits 0, and libass given the file's directory finds no face in it and does the same, so a
|
|
292
|
+
text file named `brand.ttf` drew DejaVu Sans with `font_used.substituted: false`. Read by the
|
|
293
|
+
first bytes, not the extension; a gzipped PCF (`.pcf.gz`) and a Mac `.dfont` (no signature)
|
|
294
|
+
pass by name."""
|
|
295
|
+
shown_path = str(path)
|
|
296
|
+
if not os.path.isfile(shown_path):
|
|
297
|
+
die(f"{label} {shown_path}: no such file", kind="input")
|
|
298
|
+
try:
|
|
299
|
+
with open(shown_path, "rb") as fh:
|
|
300
|
+
head = fh.read(16)
|
|
301
|
+
except OSError as exc:
|
|
302
|
+
die(f"{label} {shown_path} cannot be read: {exc.strerror or exc}", kind="input")
|
|
303
|
+
lower = shown_path.lower()
|
|
304
|
+
if head.startswith(_FONT_MAGIC) or lower.endswith(".dfont") or (lower.endswith(".pcf.gz") and head.startswith(b"\x1f\x8b")):
|
|
305
|
+
return shown_path
|
|
306
|
+
die(f"{label} {shown_path} is not a font file (it starts with {head[:8]!r}, which no TrueType, OpenType, "
|
|
307
|
+
"WOFF, Type 1, BDF or PCF font does): drawing with it would fall back to another face without a word",
|
|
308
|
+
kind="input", hint="name a .ttf, .otf or .ttc file (fc-list lists the installed ones), or --font FAMILY")
|
|
309
|
+
return shown_path
|
|
310
|
+
|
|
311
|
+
|
|
281
312
|
def font_family_of_file(path: str) -> "Optional[str]":
|
|
282
313
|
"""The family name of a font FILE -- what libass wants, given a --font-file. `fc-scan` reads
|
|
283
314
|
the file directly; without fontconfig the file stem is the honest best guess."""
|
package/scripts/_common/probe.py
CHANGED
|
@@ -905,6 +905,20 @@ def refuse_not_a_still(path: str, meta: Dict[str, Any], flag: str, instead: str)
|
|
|
905
905
|
+ (f" ({length:.3f}s)" if length else "") + f", and {flag} is read as one picture", kind="input", hint=instead)
|
|
906
906
|
|
|
907
907
|
|
|
908
|
+
def is_still_input(meta: Dict[str, Any]) -> bool:
|
|
909
|
+
"""A probe document of one picture read by an image demuxer (_is_still()'s rule, on the
|
|
910
|
+
document): a still given where a clip is taken. Read with a plain `-i` it is one frame, so a
|
|
911
|
+
tool that shows it for a while opens it with still_loop_args() (#510)."""
|
|
912
|
+
fmt = str((meta or {}).get("format") or "")
|
|
913
|
+
return not (meta or {}).get("dry_run") and (fmt == "image2" or fmt.endswith("_pipe"))
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def still_loop_args(path: str, seconds: float) -> List[str]:
|
|
917
|
+
"""The input options that show a still picture for `seconds`: `-loop 1` repeats its one frame
|
|
918
|
+
(the image demuxers' option) and `-t` ends the repeat, so the input is bounded."""
|
|
919
|
+
return ["-loop", "1", *still_input_args(path), "-t", f"{max(seconds, 0.001):.3f}"]
|
|
920
|
+
|
|
921
|
+
|
|
908
922
|
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
909
923
|
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
910
924
|
|
package/scripts/_contract.py
CHANGED
|
@@ -261,6 +261,7 @@ DRY_RUN_ANALYSIS = {
|
|
|
261
261
|
}
|
|
262
262
|
DRY_RUN_NOTES = {
|
|
263
263
|
"verify": "not supported: the flag is accepted but the steps run and outputs are written",
|
|
264
|
+
"sequence": "no ffmpeg runs; ffprobe decodes every frame of the sequence once, and when that check completes a frame that does not decode as an image is refused (still_unreadable) as in a real run; when ffprobe fails on the frame list or reads back no frame, a note says the frames were not checked and the dry run completes; the video is not written",
|
|
264
265
|
}
|
|
265
266
|
|
|
266
267
|
# Whether a tool re-encodes each stream *when that stream is present in the input* -- not whether
|
|
@@ -436,7 +437,7 @@ ERROR_REASONS = {
|
|
|
436
437
|
"output_is_directory": "the output path is a directory; details: {output}",
|
|
437
438
|
"media_path_is_playlist": "an input is a playlist that ffmpeg would follow to the files or URLs it names: HLS .m3u8/.m3u or an ffconcat list (by name or by its first bytes), a DASH manifest or an IMF composition playlist (by name or by the content ffmpeg's own probe looks for, whatever the file is called); the skill takes media files only; details: {path}",
|
|
438
439
|
"media_path_has_line_break": "a path written into a concat list (loop.py, join.py --copy, sequence.py, multi-part cuts) contains a line break, which the concat format cannot hold: each line is a directive, so the name would add entries to the list; details: {path}",
|
|
439
|
-
"still_unreadable": "an input read by an image demuxer (a .png, .jpg, ... name) has a picture stream with no frame size: the file is not an image ffmpeg can decode (a text file named .png); details: {path, format, codec}",
|
|
440
|
+
"still_unreadable": "an input read by an image demuxer (a .png, .jpg, ... name) has a picture stream with no frame size: the file is not an image ffmpeg can decode (a text file named .png); details: {path, format, codec}. sequence.py: a frame of the sequence that does not decode (every frame is decoded once before the encode; when that check cannot be made, a note says so and nothing is refused); details: {path (the first), frames, frames_unreadable, frames_total}",
|
|
440
441
|
"media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
|
|
441
442
|
"join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
|
|
442
443
|
"output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
|
package/scripts/broll.py
CHANGED
|
@@ -26,7 +26,7 @@ from _common.decision import (av_trim, conform_output_t, conform_to_length, fram
|
|
|
26
26
|
grid_at_or_after, silence_filter, stream_starts, window_expr, windowed_overlay_x)
|
|
27
27
|
from _common import STATE, add_common, aac_args, apply_common, cfr_args, cfr_at_input, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
28
28
|
from _common.emit import note, record_fallback
|
|
29
|
-
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
29
|
+
from _common.probe import display_size, is_still_input, square_pixels, square_pixels_note, still_loop_args
|
|
30
30
|
from _common.emit import input_length
|
|
31
31
|
|
|
32
32
|
|
|
@@ -109,7 +109,9 @@ def main() -> int:
|
|
|
109
109
|
die(f"cutaway {i + 1}: --at {at:g}s is past the end of the A-roll ({dur_a:.3f}s)")
|
|
110
110
|
if dur_a and at + length > dur_a + 0.01:
|
|
111
111
|
die(f"cutaway {i + 1}: {at:g}s + {length:g}s runs past the end of the A-roll ({dur_a:.3f}s)")
|
|
112
|
-
|
|
112
|
+
# a still has no material to run out of: it is looped for the window. image2 gives some (a
|
|
113
|
+
# TGA) a duration of one frame, which this refused as "only -0.460s from 0.5s" (review of #532)
|
|
114
|
+
dur_b = None if is_still_input(meta_b) else input_length(meta_b)
|
|
113
115
|
if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
|
|
114
116
|
die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
|
|
115
117
|
# the cutaway in whole frames: `first` is B's first frame at or after --from, `on` the
|
|
@@ -139,6 +141,15 @@ def main() -> int:
|
|
|
139
141
|
output = args.output or default_output(args.input, "broll")
|
|
140
142
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
141
143
|
for c in cutaways:
|
|
144
|
+
# a still B-roll is one frame to a plain -i: it showed for one frame of the cutaway and A
|
|
145
|
+
# came back under it (#510). Looped, it lasts the window it is trimmed to. The bound is past
|
|
146
|
+
# the trim's end, which runs from B's first frame at or after --from on the loop's own
|
|
147
|
+
# grid for the window's whole frames: --from + --duration cut that frame off a short window
|
|
148
|
+
# (--from 0.041 --duration 0.033 needs the frame at 0.08, review of #532). A frame of B
|
|
149
|
+
# past it costs nothing: the trim drops it.
|
|
150
|
+
if is_still_input(c["meta"]):
|
|
151
|
+
frame_b = 1 / c["rate"] if c["rate"] else 0.04
|
|
152
|
+
cmd += still_loop_args(c["path"], max(c["from"], c["first"]) + max(c["length"], c["span"] or 0) + 2 * frame_b)
|
|
142
153
|
cmd += ["-i", c["path"]]
|
|
143
154
|
|
|
144
155
|
# Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
|
package/scripts/caption.py
CHANGED
|
@@ -56,7 +56,7 @@ from _common.asr import ENGINE_CHOICES
|
|
|
56
56
|
from _common.emit import warn
|
|
57
57
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
58
58
|
from _common.runner import carried_stream_maps, refuse_output_is_input, same_file, subtitle_codec_for, subtitle_container_holds
|
|
59
|
-
from _common.fonts import font_choice, record_font_choice
|
|
59
|
+
from _common.fonts import font_choice, record_font_choice, require_font_file
|
|
60
60
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
61
61
|
write_srt)
|
|
62
62
|
from _common.asr import srt_blocks
|
|
@@ -1327,6 +1327,11 @@ def main() -> int:
|
|
|
1327
1327
|
if args.brand and bcap.get("karaoke") and not args.karaoke:
|
|
1328
1328
|
args.karaoke = True
|
|
1329
1329
|
if args.brand and brand.get("font_file") and not args.fonts_dir:
|
|
1330
|
+
# the brand's face is found in its directory by libass, so a file there that is no font
|
|
1331
|
+
# left libass to another face without a word (#510); a soft track (--mode mux) draws nothing,
|
|
1332
|
+
# and nor does a run with no input video, which only writes the SRT (review of #532)
|
|
1333
|
+
if args.mode != "mux" and args.input:
|
|
1334
|
+
require_font_file(brand["font_file"], "the --brand file's font_file")
|
|
1330
1335
|
args.fonts_dir = str(Path(brand["font_file"]).parent)
|
|
1331
1336
|
if not (args.srt or args.ass or args.text or args.transcribe):
|
|
1332
1337
|
die("give one of --srt, --ass, --text or --transcribe")
|
|
@@ -1569,8 +1574,11 @@ def main() -> int:
|
|
|
1569
1574
|
refuse_existing_outputs(todo)
|
|
1570
1575
|
# and the rest of the side-file guards (#408): never an input, the video, a missing
|
|
1571
1576
|
# directory or an option-shaped name, even under --overwrite
|
|
1577
|
+
# a brand file's font_file and logo are read too (review of #534's sweep: --write-srt or
|
|
1578
|
+
# --write-ass naming the brand's font replaced it)
|
|
1572
1579
|
side_inputs = [q for q in (args.input, getattr(args, "text", None), getattr(args, "ass", None),
|
|
1573
|
-
getattr(args, "brand", None),
|
|
1580
|
+
getattr(args, "brand", None), brand.get("font_file"), brand.get("logo"),
|
|
1581
|
+
*(t[0] for t in srt_tracks)) if isinstance(q, str) and q]
|
|
1574
1582
|
for path in todo:
|
|
1575
1583
|
guard_sidecar(path, *side_inputs)
|
|
1576
1584
|
|
package/scripts/cut.py
CHANGED
|
@@ -1618,7 +1618,8 @@ def main() -> int:
|
|
|
1618
1618
|
+ "; give every segment at least one frame", kind="input")
|
|
1619
1619
|
|
|
1620
1620
|
output = args.output or default_output(args.input, "cut")
|
|
1621
|
-
|
|
1621
|
+
# --snap-source is read for its beat grid and is never an -i of the cut (#522's sweep)
|
|
1622
|
+
refuse_output_is_input(output, args.input, *([args.snap_source] if args.snap_source else []))
|
|
1622
1623
|
ext = os.path.splitext(output)[1].lower() or ".mp4" # an upper-case .MP4 is still faststart
|
|
1623
1624
|
|
|
1624
1625
|
# A segment that runs past the end of the video, with a join after it: whatever joins it (the
|
package/scripts/fit.py
CHANGED
|
@@ -201,7 +201,10 @@ def main() -> int:
|
|
|
201
201
|
vf.append(f"setpts={1/factor:.8f}*PTS")
|
|
202
202
|
src_fps = meta["video"].get("fps") or 30.0
|
|
203
203
|
if factor < 1.0 and args.smooth == "interpolate":
|
|
204
|
-
|
|
204
|
+
# the new frames are made at the rate delivered: made at the source's 30 and
|
|
205
|
+
# then put on --fps 60 by the fps filter below, every other frame of the slow
|
|
206
|
+
# motion was a repeat (#527). Without --fps it is the source's rate, as before
|
|
207
|
+
vf.append(f"minterpolate=fps={args.fps or src_fps:g}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1")
|
|
205
208
|
elif factor < 1.0 and args.smooth == "blend":
|
|
206
209
|
vf.append(f"fps={src_fps:g}")
|
|
207
210
|
vf.append("tblend=all_mode=average")
|
|
@@ -259,11 +262,14 @@ def main() -> int:
|
|
|
259
262
|
# A speed change without --fps has already been put on the delivered grid above
|
|
260
263
|
if args.fps:
|
|
261
264
|
vf.append(f"fps={args.fps:g}")
|
|
262
|
-
elif meta["video"].get("variable_frame_rate_suspected")
|
|
265
|
+
elif meta["video"].get("variable_frame_rate_suspected"):
|
|
266
|
+
# said whichever filter conforms it: a speed change's regrid above is that conform, and the
|
|
267
|
+
# line was missing there (#527)
|
|
263
268
|
info("source looks variable-frame-rate; conforming to constant fps automatically")
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
269
|
+
if not regridded:
|
|
270
|
+
# the conform is the fps filter, as --fps is: -fps_mode cfr alone let a frame run up to
|
|
271
|
+
# two frames late against the sound (#512)
|
|
272
|
+
vf.append(cfr_filter(meta))
|
|
267
273
|
|
|
268
274
|
# ---- aspect / size
|
|
269
275
|
if args.aspect or args.width or args.height:
|
package/scripts/graphics.py
CHANGED
|
@@ -36,7 +36,7 @@ from typing import List, Optional, Tuple
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
37
|
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
38
38
|
from _common.drawtext import shaping_refusal
|
|
39
|
-
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args, cfr_at_input, with_cfr,
|
|
39
|
+
from _common import (guard_sidecar, refuse_output_is_input, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args, cfr_at_input, with_cfr,
|
|
40
40
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
41
41
|
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, probe, rgb_chain_to_bt709, run_keeping_subtitles, video_args,
|
|
42
42
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
@@ -47,7 +47,7 @@ from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_
|
|
|
47
47
|
from _common.emit import note, warn
|
|
48
48
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
49
49
|
report as alpha_report, source_alpha, source_format, tag_args, video_line)
|
|
50
|
-
from _common.fonts import font_choice, record_font_choice
|
|
50
|
+
from _common.fonts import font_choice, record_font_choice, require_font_file
|
|
51
51
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
52
52
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
53
53
|
from _common import drawtext_fontfile # noqa: E402
|
|
@@ -175,6 +175,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
175
175
|
if args.brand and style.get("color") and not args.text_color:
|
|
176
176
|
text_c = color_hex(style["color"])
|
|
177
177
|
args.lang = args.lang or (brand.get("lang") if args.brand else None)
|
|
178
|
+
# checked where text is drawn with it: the progress template draws a bar only (review of #532)
|
|
179
|
+
if args.template != "progress" and (args.font_file or brand.get("font_file")):
|
|
180
|
+
require_font_file(args.font_file or brand["font_file"], "--font-file" if args.font_file else "the --brand file's font_file")
|
|
178
181
|
# a font that covers the text before drawtext renders boxes instead of glyphs (1.12)
|
|
179
182
|
_script, script_file, _family = script_font_for_text(
|
|
180
183
|
" ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t),
|
|
@@ -670,6 +673,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
670
673
|
"through libass, or upgrade FFmpeg", "drawtext_newline_glyph", text_render="drawtext")
|
|
671
674
|
|
|
672
675
|
output = args.output or default_output(args.input, "gfx", default_ext(alpha_codec) if alpha_codec else None)
|
|
676
|
+
# the brand file and the font file are read here and by drawtext's fontfile=, never as an -i, so
|
|
677
|
+
# run()'s guard did not see them (#522's sweep)
|
|
678
|
+
refuse_output_is_input(output, *[q for q in (args.brand, args.font_file or brand.get("font_file")) if isinstance(q, str) and q])
|
|
673
679
|
ass_path = None
|
|
674
680
|
emoji_result = None
|
|
675
681
|
if route == "ass":
|
|
@@ -751,8 +757,10 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
751
757
|
require_filter("ass", "drawing this text through libass") # before the sidecar is written
|
|
752
758
|
# a file this run writes itself: never the input or the video, a missing directory or an
|
|
753
759
|
# existing file without --overwrite is refused before anything is written (#408)
|
|
754
|
-
|
|
755
|
-
|
|
760
|
+
# the font the text is drawn with, a brand file's font_file included (review of #534: only
|
|
761
|
+
# --font-file was here, and --write-ass naming the brand's font replaced it with ASS text)
|
|
762
|
+
guard_sidecar(ass_path, *[q for q in (args.input, getattr(args, "brand", None), args.font_file or brand.get("font_file"))
|
|
763
|
+
if isinstance(q, str) and q], siblings=(output,))
|
|
756
764
|
if STATE.dry_run:
|
|
757
765
|
info(f"[dry-run] would write {ass_path} ({len(elements)} text elements)")
|
|
758
766
|
else:
|
package/scripts/grid.py
CHANGED
|
@@ -32,7 +32,7 @@ from _common import drawtext_fontfile # noqa: E402
|
|
|
32
32
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
33
33
|
from _common.emit import note, record_fallback # noqa: E402
|
|
34
34
|
from _common.fonts import script_font_for_text # noqa: E402
|
|
35
|
-
from _common.probe import square_pixels # noqa: E402
|
|
35
|
+
from _common.probe import is_still_input, square_pixels, still_loop_args # noqa: E402
|
|
36
36
|
from _common.wrap import text_width_em # noqa: E402
|
|
37
37
|
from _common.emit import input_length # noqa: E402
|
|
38
38
|
from _common.decision import conform_output_t, conform_to_length # noqa: E402
|
|
@@ -116,8 +116,15 @@ def main() -> int:
|
|
|
116
116
|
if args.audio_from is not None and not metas[args.audio_from].get("audio"):
|
|
117
117
|
die(f"--audio-from {args.audio_from}: {args.inputs[args.audio_from]} has no audio stream")
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
# a still picture has no length of its own: it lasts as long as the grid (#510). Counted as a
|
|
120
|
+
# clip of 0 s, it made the grid 0 s long, and the run ended in a traceback on the empty file.
|
|
121
|
+
stills = [is_still_input(m) for m in metas]
|
|
122
|
+
if all(stills):
|
|
123
|
+
die("every cell is a still picture, so the grid has no length to take from a clip", kind="input",
|
|
124
|
+
hint="give at least one video cell, or make a clip of a still first (insert.py STILL --duration T)")
|
|
125
|
+
durations = [input_length(m) for m, still in zip(metas, stills) if not still]
|
|
120
126
|
target_duration = max(durations) if args.pad else min(durations)
|
|
127
|
+
durations = [target_duration if still else input_length(m) for m, still in zip(metas, stills)]
|
|
121
128
|
|
|
122
129
|
# each label is drawn with a font that covers its own script, as every other text-drawing tool
|
|
123
130
|
# picks one (through 3.1.3 a Japanese or Thai filename came out as tofu, #411); `font_used` is
|
|
@@ -139,11 +146,13 @@ def main() -> int:
|
|
|
139
146
|
if args.label == "auto" and cut:
|
|
140
147
|
note(f"{len(cut)} cell label(s) wider than the {args.cell_width} px cell at --font-size "
|
|
141
148
|
f"{args.font_size} were shortened from the end with an ellipsis")
|
|
142
|
-
|
|
149
|
+
# named after the first cell; a still there would give its own extension (white_grid.png), and
|
|
150
|
+
# the image muxer cannot write the grid's frames to one file name (review of #532)
|
|
151
|
+
output = args.output or default_output(args.inputs[0], "grid", "mp4" if stills[0] else None)
|
|
143
152
|
|
|
144
153
|
cmd = ffmpeg_base()
|
|
145
|
-
for p in args.inputs:
|
|
146
|
-
cmd += ["-i", p]
|
|
154
|
+
for p, still in zip(args.inputs, stills):
|
|
155
|
+
cmd += (still_loop_args(p, target_duration) if still else []) + ["-i", p]
|
|
147
156
|
|
|
148
157
|
# each cell converted by its own tags to the BT.709 the grid is tagged with (a BT.601 or an RGB
|
|
149
158
|
# input): without it 5.1/6.1 relabelled them, and 7.1+ converted cells to one another's matrix.
|
package/scripts/join.py
CHANGED
|
@@ -45,7 +45,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
45
45
|
|
|
46
46
|
from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
|
|
47
47
|
from _common.runner import read_text_or_die
|
|
48
|
-
from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, as_limited, draws_chroma, drawn_chain, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
48
|
+
from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, refuse_output_is_input, place_output, refuse, sibling_temp, as_limited, draws_chroma, drawn_chain, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
49
49
|
from _common.emit import note, warn
|
|
50
50
|
from _common.decision import av_clip_length, av_trim, stream_starts
|
|
51
51
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
@@ -245,6 +245,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
245
245
|
if len(set(chans)) > 1:
|
|
246
246
|
info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
|
|
247
247
|
output = args.output or default_output(args.inputs[0], "joined")
|
|
248
|
+
refuse_list_as_output(args, output)
|
|
248
249
|
if not is_audio_output(output):
|
|
249
250
|
die(f"audio-only inputs cannot fill a video container: give -o an audio extension (.wav, .flac, .mp3, .m4a, .ogg, .opus), not {output}")
|
|
250
251
|
|
|
@@ -283,6 +284,13 @@ CONCAT_DIRECTIVES = frozenset({"inpoint", "outpoint", "duration", "stream", "exa
|
|
|
283
284
|
"file_packet_meta", "option", "stream_meta", "stream_codec", "stream_extradata", "chapter"})
|
|
284
285
|
|
|
285
286
|
|
|
287
|
+
def refuse_list_as_output(args: argparse.Namespace, output: str) -> None:
|
|
288
|
+
"""The --list file is read here and is never an -i of the join, so run()'s guard did not see
|
|
289
|
+
an output named like it (#522's sweep): refused once the output is known, before any write."""
|
|
290
|
+
if args.list:
|
|
291
|
+
refuse_output_is_input(output, args.list, why="the clip list would be replaced by the join")
|
|
292
|
+
|
|
293
|
+
|
|
286
294
|
def read_list(path: str) -> List[str]:
|
|
287
295
|
"""--list FILE: one segment per line, in order, relative to the list file's own folder.
|
|
288
296
|
Blank lines and # comments are ignored, and ffmpeg's concat-demuxer spelling
|
|
@@ -494,6 +502,7 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
|
|
|
494
502
|
die(f"--copy keeps every clip's own encoding: {', '.join(encoder)} choose(s) an encode, so drop --copy")
|
|
495
503
|
output = args.output or default_output(args.inputs[0], "joined", os.path.splitext(args.inputs[0])[1].lstrip(".") or "mp4")
|
|
496
504
|
guard_sidecar(output, *args.inputs)
|
|
505
|
+
refuse_list_as_output(args, output)
|
|
497
506
|
ext = os.path.splitext(output)[1].lower()
|
|
498
507
|
n = len(args.inputs)
|
|
499
508
|
pending = any(m.get("dry_run") for m in metas)
|
|
@@ -929,6 +938,7 @@ def main() -> int:
|
|
|
929
938
|
STATE_NOTES.append("the planned trim lengths count each pending input as 0 s long: a real run trims "
|
|
930
939
|
"or holds it to its measured length")
|
|
931
940
|
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
941
|
+
refuse_list_as_output(args, output)
|
|
932
942
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
933
943
|
# the fps filter already made the picture constant-rate, and the per-clip trims (then xfade or
|
|
934
944
|
# concat) place every frame: FFmpeg 5.1's default cfr output mode still duplicated the last one
|
package/scripts/metadata.py
CHANGED
|
@@ -35,7 +35,7 @@ from _common import (guard_sidecar, add_common, apply_common, default_output, de
|
|
|
35
35
|
die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
|
|
36
36
|
STATE, read_text_or_die)
|
|
37
37
|
from _common.emit import input_length
|
|
38
|
-
from _common.runner import carried_stream_maps
|
|
38
|
+
from _common.runner import carried_stream_maps, refuse_output_is_input
|
|
39
39
|
|
|
40
40
|
CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
|
|
41
41
|
TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
|
|
@@ -247,6 +247,9 @@ def main() -> int:
|
|
|
247
247
|
output = args.output or default_output(args.input, "meta", Path(args.input).suffix.lstrip(".") or "mp4")
|
|
248
248
|
if os.path.abspath(output) == os.path.abspath(args.input):
|
|
249
249
|
die("output must differ from the input (metadata.py never rewrites a file in place)")
|
|
250
|
+
if args.chapters:
|
|
251
|
+
# the chapter list is read here and handed to ffmpeg as a metadata file of its own (#522's sweep)
|
|
252
|
+
refuse_output_is_input(output, args.chapters, why="the chapter file would be replaced by the output")
|
|
250
253
|
out_ext = Path(output).suffix.lower()
|
|
251
254
|
if (args.chapters or args.clear_chapters or args.auto_chapters) and out_ext not in CHAPTER_CONTAINERS:
|
|
252
255
|
die(f"{out_ext or 'this'} container cannot hold chapter markers; write to one of "
|
package/scripts/overlay.py
CHANGED
|
@@ -45,8 +45,8 @@ from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder
|
|
|
45
45
|
from _common.probe import display_size, refuse_not_a_still, square_pixels, square_pixels_note
|
|
46
46
|
from _common.drawtext import shaping_refusal
|
|
47
47
|
from _common.wrap import wrap_text
|
|
48
|
-
from _common import text_width_em
|
|
49
|
-
from _common.fonts import font_choice, record_font_choice
|
|
48
|
+
from _common import text_width_em, refuse_output_is_input
|
|
49
|
+
from _common.fonts import font_choice, record_font_choice, require_font_file
|
|
50
50
|
from _common import drawtext_fontfile # noqa: E402
|
|
51
51
|
|
|
52
52
|
# Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
|
|
@@ -361,6 +361,7 @@ def main() -> int:
|
|
|
361
361
|
# at --font-size), so a project that asked for 200 px text completed with the 42 px default.
|
|
362
362
|
if args.text and not (args.image or args.video) and (args.scale is not None or args.scale_percent is not None):
|
|
363
363
|
die("--scale and --scale-percent size an image, logo or video layer; text size is --font-size", kind="input")
|
|
364
|
+
font_file_from = "--font-file" # what the refusal of a font file that is not one names
|
|
364
365
|
if args.brand:
|
|
365
366
|
if args.margin == ap.get_default("margin"):
|
|
366
367
|
args.margin = int(brand.get("safe_margin", args.margin))
|
|
@@ -368,6 +369,7 @@ def main() -> int:
|
|
|
368
369
|
args.font = brand.get("font", args.font)
|
|
369
370
|
if not args.font_file and brand.get("font_file"):
|
|
370
371
|
args.font_file = brand["font_file"]
|
|
372
|
+
font_file_from = "the --brand file's font_file"
|
|
371
373
|
if args.text:
|
|
372
374
|
# 1.15: drawtext never reorders or re-clusters (no harfbuzz), so Devanagari matras and
|
|
373
375
|
# Thai/Lao mark stacking come out wrong on EVERY build. A wrong frame is not a delivery:
|
|
@@ -393,6 +395,8 @@ def main() -> int:
|
|
|
393
395
|
"a font without the glyph draws an empty box (monochrome at best with one). --emoji none "
|
|
394
396
|
"leaves them out; caption.py/graphics.py composite colour PNGs with --emoji-assets",
|
|
395
397
|
"emoji_monochrome")
|
|
398
|
+
if args.text and args.font_file:
|
|
399
|
+
require_font_file(args.font_file, font_file_from)
|
|
396
400
|
script_file = script_family = None
|
|
397
401
|
if args.text and not args.font_file:
|
|
398
402
|
# 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
|
|
@@ -490,6 +494,9 @@ def main() -> int:
|
|
|
490
494
|
"from --start to --end without a fade", hint="drop --fade for the video layer")
|
|
491
495
|
|
|
492
496
|
output = args.output or default_output(args.input, "overlay", default_ext(alpha_codec) if alpha_codec else None)
|
|
497
|
+
# the brand file and a text layer's font file are read here and by drawtext's fontfile=, never
|
|
498
|
+
# as an -i, so run()'s guard did not see them (#522's sweep)
|
|
499
|
+
refuse_output_is_input(output, *[q for q in (args.brand, args.font_file if args.text else None) if isinstance(q, str) and q])
|
|
493
500
|
enable = enable_expr(start, end, meta)
|
|
494
501
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
495
502
|
|
|
@@ -533,6 +540,11 @@ def main() -> int:
|
|
|
533
540
|
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
534
541
|
# picture's frames; overlay's enable is evaluated on the layer's frames too
|
|
535
542
|
ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
543
|
+
if not meta.get("duration"):
|
|
544
|
+
# nothing to bound by -t: the overlay ends with the main picture. -shortest alone never
|
|
545
|
+
# ended a still main (one picture, no duration): overlay kept repeating it under the
|
|
546
|
+
# looped layer and the run went on until its timeout (#510)
|
|
547
|
+
ov += ":shortest=1"
|
|
536
548
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
537
549
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
|
|
538
550
|
# the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
|
package/scripts/redact.py
CHANGED
|
@@ -30,6 +30,7 @@ import sys
|
|
|
30
30
|
from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, cfr_at_input, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
31
31
|
from _common.emit import note
|
|
32
32
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
33
|
+
from _common.decision import boxblur_chroma_radius
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
def main() -> int:
|
|
@@ -118,7 +119,10 @@ def main() -> int:
|
|
|
118
119
|
# --blur-strength 1, which left colour-only detail readable (review of #521). (FFmpeg
|
|
119
120
|
# 7.1 refuses a chroma radius of 1 on a 2 px chroma plane, a 4 px wide region; 6.1
|
|
120
121
|
# took it.) 0 is only a plane too small to blur: the luma still is
|
|
121
|
-
|
|
122
|
+
# On the chroma planes' own size, which boxblur measures: a 4:1:1 or 4:1:0 source's
|
|
123
|
+
# quarter-width planes failed at the 4:2:0 bound (a 30x30 region: "radius 7, must be
|
|
124
|
+
# <= 3", #527's sweep). On 4:2:0 the radius is the one it was
|
|
125
|
+
chroma = boxblur_chroma_radius(max(1, radius // 2))
|
|
122
126
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
123
127
|
else:
|
|
124
128
|
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"
|
package/scripts/render.py
CHANGED
|
@@ -1602,7 +1602,11 @@ def main() -> int:
|
|
|
1602
1602
|
# the project file and every clip it names are inputs the JSON must not replace
|
|
1603
1603
|
where = Path(args.project).resolve().parent if args.project else Path.cwd()
|
|
1604
1604
|
srcs = [c["src"] for c in proj.get("clips") or [] if isinstance(c, dict) and isinstance(c.get("src"), str)]
|
|
1605
|
-
|
|
1605
|
+
# every file the project reads, not only its clips: a template's brand, its font_file and
|
|
1606
|
+
# logo, the cue or SRT file, the chapters (review of #534's sweep: --write-project naming
|
|
1607
|
+
# the brand's font or logo replaced it with the JSON)
|
|
1608
|
+
guard_sidecar(args.write_project, args.project, *[str(where / s) for s in srcs],
|
|
1609
|
+
*project_input_files(proj, lambda p: p if os.path.isabs(str(p)) else str(where / str(p))))
|
|
1606
1610
|
try:
|
|
1607
1611
|
Path(args.write_project).write_text(json.dumps(proj, indent=2) + "\n", encoding="utf-8")
|
|
1608
1612
|
except OSError as exc:
|
package/scripts/report.py
CHANGED
|
@@ -14,6 +14,7 @@ import base64
|
|
|
14
14
|
import html
|
|
15
15
|
import json
|
|
16
16
|
import os
|
|
17
|
+
import re
|
|
17
18
|
import sys
|
|
18
19
|
import tempfile
|
|
19
20
|
from pathlib import Path
|
|
@@ -145,6 +146,10 @@ def md_cells(line: str) -> List[str]:
|
|
|
145
146
|
return cells
|
|
146
147
|
|
|
147
148
|
|
|
149
|
+
# the columns of render.py's render_pack() table, in its order
|
|
150
|
+
PACK_HEADER = ["platform", "file", "size", "duration", "check"]
|
|
151
|
+
|
|
152
|
+
|
|
148
153
|
def pack_report(args) -> int:
|
|
149
154
|
"""render.py --template all writes <stem>_pack.md: one row per destination. This renders the
|
|
150
155
|
same rows as HTML, so a pack can be handed over the way a single delivery report is -- no
|
|
@@ -152,19 +157,37 @@ def pack_report(args) -> int:
|
|
|
152
157
|
text = read_text_or_die(args.pack, "--pack")
|
|
153
158
|
rows: List[List[str]] = []
|
|
154
159
|
header: List[str] = []
|
|
160
|
+
delimiter: Optional[List[str]] = None
|
|
155
161
|
for line in text.split("\n"):
|
|
156
162
|
line = line.strip()
|
|
157
163
|
if not line.startswith("|"):
|
|
164
|
+
if header:
|
|
165
|
+
break # the table ended: a later table in the file is not the pack's
|
|
158
166
|
continue
|
|
159
167
|
cells = md_cells(line)
|
|
160
|
-
if all(set(c) <= set("-: ") for c in cells):
|
|
161
|
-
continue
|
|
162
168
|
if not header:
|
|
163
169
|
header = cells
|
|
170
|
+
elif delimiter is None:
|
|
171
|
+
delimiter = cells # the line under the header, whatever it is: checked below
|
|
164
172
|
else:
|
|
173
|
+
# every later line is a row, a separator-like one too: `| | |` after a row was skipped
|
|
174
|
+
# as a delimiter and never counted (review of #532)
|
|
165
175
|
rows.append(cells)
|
|
176
|
+
# the table render.py --template all writes, and only that one: any Markdown table was taken
|
|
177
|
+
# and handed over as a pack report with `verified: true` (#510)
|
|
178
|
+
if header and header != PACK_HEADER:
|
|
179
|
+
die(f"{args.pack}: its table's columns are {' | '.join(header)}, not the pack table's "
|
|
180
|
+
f"({' | '.join(PACK_HEADER)}) that render.py --template all writes", kind="input")
|
|
181
|
+
# a header with a row under it and no delimiter line is no Markdown table (review of #532)
|
|
182
|
+
if header and delimiter is not None and not (
|
|
183
|
+
len(delimiter) == len(PACK_HEADER) and all(re.fullmatch(r":?-+:?", c) for c in delimiter)):
|
|
184
|
+
die(f"{args.pack}: the line under the pack table's header is not its delimiter row "
|
|
185
|
+
f"(|---|---|---|---|---|), so it is no Markdown table", kind="input")
|
|
186
|
+
bad = next((i for i, r in enumerate(rows) if len(r) != len(PACK_HEADER)), None)
|
|
187
|
+
if bad is not None:
|
|
188
|
+
die(f"{args.pack}: row {bad + 1} of the pack table has {len(rows[bad])} cells, not {len(PACK_HEADER)}", kind="input")
|
|
166
189
|
if not rows:
|
|
167
|
-
die(f"{args.pack}: no pack table found (expected the Markdown table render.py --template all writes)")
|
|
190
|
+
die(f"{args.pack}: no pack table found (expected the Markdown table render.py --template all writes)", kind="input")
|
|
168
191
|
title = args.title or f"Social pack — {Path(args.pack).stem.replace('_pack', '')}"
|
|
169
192
|
output = args.output or str(Path(args.pack).with_suffix(".html"))
|
|
170
193
|
guard_sidecar(output, args.pack)
|
package/scripts/sequence.py
CHANGED
|
@@ -21,8 +21,8 @@ import tempfile
|
|
|
21
21
|
from fractions import Fraction
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
|
|
25
|
-
from _common.emit import note, warn
|
|
24
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
|
|
25
|
+
from _common.emit import note, refuse, warn
|
|
26
26
|
from _common.probe import display_size, square_pixels_note
|
|
27
27
|
from _common.timeline import TimelineError, exact_rate
|
|
28
28
|
|
|
@@ -60,6 +60,31 @@ def concat_list(frames: list, rate: Fraction) -> str:
|
|
|
60
60
|
return "\n".join(lines)
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def undecodable_frames(list_path: Path, frames: list, rate: Fraction) -> "list | None":
|
|
64
|
+
"""The frames of the list that do not decode to a picture, in order; None when the check could
|
|
65
|
+
not be made (ffprobe failed). ffprobe decodes the same concat list the encode reads, so a frame
|
|
66
|
+
is what the encode would get: frame i is decoded at the i-th start the list gives it. Only the
|
|
67
|
+
first frame was probed, and a later one that was not an image (a text file named .png, a
|
|
68
|
+
truncated PNG) was left out by the decoder while the fps filter repeated its neighbour: the run
|
|
69
|
+
completed with that frame missing (#529)."""
|
|
70
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-f", "concat", "-safe", "0", "-i", str(list_path),
|
|
71
|
+
"-select_streams", "v:0", "-show_entries", "frame=best_effort_timestamp_time", "-of", "csv=p=0"],
|
|
72
|
+
quiet=True, check=False)
|
|
73
|
+
if proc.returncode != 0:
|
|
74
|
+
return None
|
|
75
|
+
decoded = set()
|
|
76
|
+
for line in proc.stdout.splitlines():
|
|
77
|
+
try:
|
|
78
|
+
decoded.add(round(float(line.strip().rstrip(",")) * rate))
|
|
79
|
+
except ValueError:
|
|
80
|
+
continue
|
|
81
|
+
if not decoded:
|
|
82
|
+
# the first frame was probed as an image, so it decodes: nothing read back is a check that
|
|
83
|
+
# did not happen, not every frame missing
|
|
84
|
+
return None
|
|
85
|
+
return [f for i, f in enumerate(frames) if i not in decoded]
|
|
86
|
+
|
|
87
|
+
|
|
63
88
|
def natural_key(name: str) -> list:
|
|
64
89
|
"""img2 < img10: digit runs compare as numbers, the rest case-insensitively."""
|
|
65
90
|
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]
|
|
@@ -164,6 +189,18 @@ def main() -> int:
|
|
|
164
189
|
with tempfile.TemporaryDirectory(prefix="ffmpeg-skill-sequence-") as tmp:
|
|
165
190
|
list_path = Path(tmp) / "frames.txt"
|
|
166
191
|
list_path.write_text(concat_list([f.resolve() for f in frames], rate), encoding="utf-8")
|
|
192
|
+
# every frame decodes, or the run is refused before the encode (a dry run too: ffprobe runs
|
|
193
|
+
# under --dry-run, as every probe does), naming the frames that do not
|
|
194
|
+
bad = undecodable_frames(list_path, frames, rate)
|
|
195
|
+
if bad is None:
|
|
196
|
+
note("the frames could not be checked before the encode (ffprobe failed on the frame list); "
|
|
197
|
+
"a frame that does not decode would be left out of the video")
|
|
198
|
+
elif bad:
|
|
199
|
+
names = ", ".join(f.name for f in bad[:5]) + (f" and {len(bad) - 5} more" if len(bad) > 5 else "")
|
|
200
|
+
refuse("input", "still_unreadable",
|
|
201
|
+
f"{len(bad)} of {len(frames)} frames do not decode as an image ({names}); they would be "
|
|
202
|
+
"missing from the video. Replace or remove them",
|
|
203
|
+
path=str(bad[0]), frames=[str(f) for f in bad], frames_unreadable=len(bad), frames_total=len(frames))
|
|
167
204
|
|
|
168
205
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", str(list_path)]
|
|
169
206
|
vf = [f"scale={out_w}:{out_h}", "setsar=1", f"fps={rate.numerator}/{rate.denominator}"]
|
package/scripts/silence.py
CHANGED
|
@@ -679,6 +679,10 @@ def main() -> int:
|
|
|
679
679
|
refuse_existing_outputs([args.timemap])
|
|
680
680
|
media_out = None if (args.list or args.filler_list) else (args.output or default_output(args.input, "tight"))
|
|
681
681
|
guard_sidecar(args.edl, args.input, args.words, args.filler_words, siblings=(media_out,))
|
|
682
|
+
if media_out:
|
|
683
|
+
# the word timings and the filler list are read here, never as an -i: run()'s guard did not
|
|
684
|
+
# see a cut named like one of them (#522's sweep). Before any decode, so a dry run says it too
|
|
685
|
+
refuse_output_is_input(media_out, *[p for p in (args.words, args.filler_words) if p])
|
|
682
686
|
|
|
683
687
|
if not math.isfinite(args.margin) or args.margin < 0:
|
|
684
688
|
# nan made inside_margin() drop every silence and re-encode the whole file unchanged
|
package/scripts/sync.py
CHANGED
|
@@ -32,7 +32,7 @@ import os
|
|
|
32
32
|
import sys
|
|
33
33
|
from typing import Dict, List
|
|
34
34
|
|
|
35
|
-
from _common.runner import fail_if_classified
|
|
35
|
+
from _common.runner import fail_if_classified, refuse_output_is_input
|
|
36
36
|
from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, run, decode_pcm_mono, rms_envelope
|
|
37
37
|
from _common.emit import record_skip, warn
|
|
38
38
|
from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
|
|
@@ -365,6 +365,9 @@ def main() -> int:
|
|
|
365
365
|
|
|
366
366
|
if args.replace_audio or args.trim_second:
|
|
367
367
|
output = args.output or default_output(args.reference if args.replace_audio else args.second, "synced", "mp4")
|
|
368
|
+
# --trim-second's command takes only SECOND as -i, so run()'s guard never saw the reference:
|
|
369
|
+
# REF named SECOND_synced.mp4 (the default name) or `-o REF` was replaced (#522)
|
|
370
|
+
refuse_output_is_input(output, args.reference, args.second)
|
|
368
371
|
# second started later (offset > 0) -> delay it by `offset` (pad the head)
|
|
369
372
|
# second started earlier (offset < 0) -> drop its first `-offset` seconds
|
|
370
373
|
delay_ms = int(round(offset * 1000))
|
package/scripts/verify.py
CHANGED
|
@@ -18,6 +18,7 @@ import argparse
|
|
|
18
18
|
import contextlib
|
|
19
19
|
import io
|
|
20
20
|
import json
|
|
21
|
+
import os
|
|
21
22
|
import subprocess
|
|
22
23
|
import sys
|
|
23
24
|
import tempfile
|
|
@@ -318,6 +319,7 @@ def main() -> int:
|
|
|
318
319
|
# the run will write -- step outputs, caption's sidecars, the cue file -- against every input,
|
|
319
320
|
# through a link or another case of the name included, before the first is written.
|
|
320
321
|
every_input = [str(f) for f in files]
|
|
322
|
+
every_written: List[str] = []
|
|
321
323
|
for f, _entry, plan, cues in jobs:
|
|
322
324
|
written = [("cue file", str(cues))] if cues else []
|
|
323
325
|
for name, argv in plan:
|
|
@@ -328,15 +330,33 @@ def main() -> int:
|
|
|
328
330
|
written += [(name, str(Path(out).with_suffix(ext))) for ext in (".srt", ".ass")]
|
|
329
331
|
for name, out in written:
|
|
330
332
|
refuse_output_is_input(out, *every_input, why=f"it is where verify writes the {name!r} step's output for {str(f)!r}")
|
|
333
|
+
every_written += [out for _name, out in written]
|
|
334
|
+
if args.report:
|
|
335
|
+
# the report is written last, over whatever is at its path: one of the names the steps write
|
|
336
|
+
# (`--report OUT/clip_cut.mp4`) was replaced by the Markdown after the steps had read it (#522)
|
|
337
|
+
guard_sidecar(args.report, siblings=every_written)
|
|
331
338
|
|
|
332
339
|
results = []
|
|
333
340
|
started["step"] = True
|
|
334
341
|
for f, entry, plan, cues in jobs:
|
|
335
342
|
info(f"=== {f}")
|
|
336
|
-
|
|
343
|
+
# the cue file is verify's own write: an existing one is replaced only with --overwrite, as
|
|
344
|
+
# every step's output is. Without it the caption step fails on it, as a step fails on its
|
|
345
|
+
# existing output, and the file is left as it was (#522; it was replaced unasked)
|
|
346
|
+
# A link or a folder at its name is never written through, --overwrite or not.
|
|
347
|
+
odd = bool(cues and os.path.lexists(cues) and (os.path.islink(cues) or not os.path.isfile(cues)))
|
|
348
|
+
cues_taken = odd or bool(cues and os.path.lexists(cues) and not FORWARD["overwrite"])
|
|
349
|
+
if cues and not cues_taken:
|
|
337
350
|
cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
|
|
338
351
|
for name, argv in plan:
|
|
339
|
-
|
|
352
|
+
if cues_taken and argv[0] == "caption.py":
|
|
353
|
+
msg = (f"refusing to write the cue file the caption step reads through {str(cues)!r}: it is a link "
|
|
354
|
+
"or not a regular file" if odd else
|
|
355
|
+
f"refusing to overwrite the existing cue file {str(cues)!r} the caption step reads: pass --overwrite to replace it")
|
|
356
|
+
r = {"step": name, "ok": False, "seconds": 0.0, "error": _cell(msg), "kind": "input",
|
|
357
|
+
"reason": "output_exists", "message": msg}
|
|
358
|
+
else:
|
|
359
|
+
r = step(name, argv, args.timeout)
|
|
340
360
|
entry["steps"].append(r)
|
|
341
361
|
info(f" {'PASS' if r['ok'] else 'FAIL'} {name:16s} {r['seconds']:6.1f}s {r['error']}")
|
|
342
362
|
results.append(entry)
|