ffmpeg-skill 3.1.3 → 3.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -9
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/decision.py +62 -7
- package/scripts/_common/emit.py +37 -2
- package/scripts/_common/probe.py +71 -3
- package/scripts/_common/runner.py +36 -3
- package/scripts/_contract.py +2 -1
- package/scripts/batch.py +2 -2
- package/scripts/caption.py +40 -15
- package/scripts/graphics.py +5 -1
- package/scripts/insert.py +2 -1
- package/scripts/look.py +5 -4
- package/scripts/overlay.py +2 -1
- package/scripts/render.py +8 -0
- package/scripts/scenes.py +1 -1
- package/scripts/verify.py +7 -2
- package/scripts/waveform.py +14 -7
package/README.md
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<picture>
|
|
3
|
-
<source media="(prefers-color-scheme: dark)" srcset="brand/
|
|
4
|
-
<img src="brand/
|
|
5
|
-
</picture>
|
|
6
|
-
</p>
|
|
7
|
-
|
|
8
|
-
<p align="center">
|
|
9
|
-
<picture>
|
|
10
|
-
<source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-wordmark-white.svg">
|
|
11
|
-
<img src="brand/logo/ffmpeg-skill-wordmark-black.svg" height="48" alt="ffmpeg-skill">
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-horizontal-on-dark.svg">
|
|
4
|
+
<img src="brand/logo/ffmpeg-skill-horizontal.svg" height="72" alt="ffmpeg-skill">
|
|
12
5
|
</picture>
|
|
13
6
|
</p>
|
|
14
7
|
|
|
@@ -17,6 +10,7 @@ Claude Code, Cursor, Codex or any MCP client edits your footage with local FFmpe
|
|
|
17
10
|
No cloud · No API keys · Python standard library</p>
|
|
18
11
|
|
|
19
12
|
<p align="center">
|
|
13
|
+
<a href="https://kajisho5.github.io/ffmpeg-skill/"><b>Website</b></a> ·
|
|
20
14
|
<a href="#quick-start"><b>Quick start</b></a> ·
|
|
21
15
|
<a href="docs/demos.md">Demos</a> ·
|
|
22
16
|
<a href="#tools">Tools</a> ·
|
|
@@ -32,6 +26,10 @@ No cloud · No API keys · Python standard library</p>
|
|
|
32
26
|
<a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
|
|
33
27
|
</p>
|
|
34
28
|
|
|
29
|
+
<p align="center">
|
|
30
|
+
<a href="https://kajisho5.github.io/ffmpeg-skill/"><img src="brand/og.png" width="720" alt="ffmpeg-skill v3: give your coding agent a video editor. An agent runs probe, cut, fit, export and check, and the final file is measured: 59.98 s, 1080x1920, 30 fps."></a>
|
|
31
|
+
</p>
|
|
32
|
+
|
|
35
33
|
```bash
|
|
36
34
|
npx ffmpeg-skill
|
|
37
35
|
```
|
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 (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.1.5`) | 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`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"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"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.1.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.1.5", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.5",
|
|
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",
|
|
@@ -59,7 +59,7 @@ from _common.runner import (
|
|
|
59
59
|
ffmpeg_bsfs, ffmpeg_filters, ffmpeg_list_error, require_filter, filter_missing_hint, ffmpeg_release, ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
|
|
60
60
|
_odd_dimension_retry, _on_signal, _OutputLock, _pid_dead, place_output, PROBE_TIMEOUT, _progress_line,
|
|
61
61
|
read_text_or_die, refuse_output_is_input, _remember_output, require_tool, run, run_analysis, _run_captured,
|
|
62
|
-
run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, sibling_temp, _SIGNALS_INSTALLED, _stage_existing_output,
|
|
62
|
+
run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, sibling_temp, still_input_args, _SIGNALS_INSTALLED, _stage_existing_output,
|
|
63
63
|
STATE, _timed_out, _unwatch, _watch, X264_PRESETS
|
|
64
64
|
)
|
|
65
65
|
from _common.emit import (
|
|
@@ -212,7 +212,7 @@ __all__ = [
|
|
|
212
212
|
"decode_gray_frames", "frame_flow", "label_shot_flow", "MOTION_GRID", "MOTION_SEARCH", "MOTION_STATIC_PX", "MOTION_PAN_SPREAD",
|
|
213
213
|
"_run_captured", "run_keeping_subtitles", "run_tool", "_run_with_progress", "_SCRIPT_FONT_CACHE",
|
|
214
214
|
"_script_font_entry", "script_font_for_text", "script_font_status", "_script_font_uncached", "_SCRIPT_RANGES",
|
|
215
|
-
"SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp",
|
|
215
|
+
"SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp", "still_input_args",
|
|
216
216
|
"_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
|
|
217
217
|
"time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
|
|
218
218
|
"source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "matrix_chain", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
|
|
@@ -127,12 +127,18 @@ class MissingFpsError(ValueError):
|
|
|
127
127
|
silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
def _on_windows() -> bool:
|
|
131
|
+
"""Whether a backslash in a path is a separator (Windows) or a character of the name."""
|
|
132
|
+
return os.name == "nt"
|
|
133
|
+
|
|
134
|
+
|
|
130
135
|
def concat_list_line(path: str) -> str:
|
|
131
136
|
"""One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
|
|
132
137
|
inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
|
|
133
138
|
with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
|
|
134
139
|
name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py."""
|
|
135
|
-
|
|
140
|
+
# only on Windows: on POSIX the quoted form takes a backslash literally, and it is part of the name (#408)
|
|
141
|
+
escaped = (str(path).replace("\\", "/") if _on_windows() else str(path)).replace("'", "'\\''")
|
|
136
142
|
return f"file '{escaped}'"
|
|
137
143
|
|
|
138
144
|
|
|
@@ -252,8 +258,8 @@ def escape_filter_path(path: str, *, bind: bool = True) -> str:
|
|
|
252
258
|
must survive both passes needs two levels of escaping, so a Windows drive letter `D:/x.srt` is
|
|
253
259
|
written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
|
|
254
260
|
ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
|
|
255
|
-
|
|
256
|
-
|
|
261
|
+
On Windows backslashes are turned into forward slashes first (ffmpeg accepts them there); on
|
|
262
|
+
POSIX a backslash is part of the name and is escaped through both passes (#408); `,`, `;`, `[` and `]` are graph-level characters and survive with
|
|
257
263
|
one backslash. `'` is special: the graph parser also treats a quote as the start of a quoted
|
|
258
264
|
token, so a single `\\'` is consumed by the first pass and "Ryo's Mac/cues.srt" reaches the
|
|
259
265
|
filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
|
|
@@ -265,11 +271,23 @@ def escape_filter_path(path: str, *, bind: bool = True) -> str:
|
|
|
265
271
|
if bind and os.path.isfile(path) and path not in STATE.plan_inputs:
|
|
266
272
|
STATE.plan_inputs.append(path) # a plan binds subtitle/LUT/font files too (review 6)
|
|
267
273
|
p = str(Path(path))
|
|
268
|
-
|
|
274
|
+
if _on_windows():
|
|
275
|
+
p = p.replace("\\", "/")
|
|
276
|
+
else:
|
|
277
|
+
# on POSIX a backslash is a character of the name (#408): turning it into `/` opened a
|
|
278
|
+
# different path, so it is escaped through both passes instead
|
|
279
|
+
p = p.replace("\\", "\\\\\\\\")
|
|
269
280
|
p = p.replace(":", "\\\\:")
|
|
270
281
|
p = p.replace("'", "\\\\\\'")
|
|
271
282
|
for ch in (",", ";", "[", "]"):
|
|
272
283
|
p = p.replace(ch, "\\" + ch)
|
|
284
|
+
# the graph parser trims unescaped whitespace at either end of a value (#408: a relative
|
|
285
|
+
# " lead.srt" opened "lead.srt"); both passes keep an escaped one
|
|
286
|
+
lead = len(p) - len(p.lstrip())
|
|
287
|
+
trail = len(p) - len(p.rstrip())
|
|
288
|
+
if lead or trail:
|
|
289
|
+
body = p[lead:len(p) - trail] if trail else p[lead:]
|
|
290
|
+
p = "".join("\\\\" + c for c in p[:lead]) + body + "".join("\\\\" + c for c in p[len(p) - trail:] if trail)
|
|
273
291
|
return p
|
|
274
292
|
|
|
275
293
|
|
|
@@ -474,7 +492,7 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
|
|
|
474
492
|
return _x264_raw(crf, preset, keep_bt709)
|
|
475
493
|
if codec == "hevc":
|
|
476
494
|
if hdr:
|
|
477
|
-
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
495
|
+
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
478
496
|
return ["-c:v", "libx265", "-preset", preset, "-crf", str(_clamped_crf("libx265", crf + 2, 51, crf)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
479
497
|
"-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
|
|
480
498
|
params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
|
|
@@ -485,7 +503,8 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
|
|
|
485
503
|
if "libsvtav1" in ffmpeg_encoders():
|
|
486
504
|
args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(_clamped_crf("libsvtav1", crf, 63, crf)), "-pix_fmt", pix]
|
|
487
505
|
if hdr:
|
|
488
|
-
|
|
506
|
+
svt = hdr10_svtav1_params(v)
|
|
507
|
+
args += (["-svtav1-params", svt] if svt else []) + hdr_tags
|
|
489
508
|
elif keep_bt709:
|
|
490
509
|
params, extra = _sdr_bt709("libsvtav1")
|
|
491
510
|
args += (["-svtav1-params", params] if params else []) + extra
|
|
@@ -508,6 +527,42 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
|
|
|
508
527
|
return []
|
|
509
528
|
|
|
510
529
|
|
|
530
|
+
def hdr10_x265_params(v: Dict[str, Any]) -> str:
|
|
531
|
+
"""The source's HDR10 static metadata as x265 options (":master-display=...:max-cll=..."), or "".
|
|
532
|
+
FFmpeg before 7.0 does not pass the decoded frames' mastering-display / content-light side data
|
|
533
|
+
to libx265, so without these a 6.1 re-encode wrote PQ video with neither (#401). Given on 7+ too,
|
|
534
|
+
where they restate what FFmpeg would pass. x265 units: chromaticity 0.00002, luminance 0.0001 cd/m2."""
|
|
535
|
+
md, cl = _hdr10_parts(v)
|
|
536
|
+
out = ""
|
|
537
|
+
if md:
|
|
538
|
+
c = lambda k: round(md[k] * 50000)
|
|
539
|
+
out += (f":master-display=G({c('green_x')},{c('green_y')})B({c('blue_x')},{c('blue_y')})R({c('red_x')},{c('red_y')})"
|
|
540
|
+
f"WP({c('white_point_x')},{c('white_point_y')})L({round(md['max_luminance'] * 10000)},{round(md['min_luminance'] * 10000)})")
|
|
541
|
+
if cl:
|
|
542
|
+
out += f":max-cll={cl['max_content']},{cl['max_average']}"
|
|
543
|
+
return out
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def hdr10_svtav1_params(v: Dict[str, Any]) -> str:
|
|
547
|
+
"""The same for SVT-AV1 (-svtav1-params), which takes chromaticities and cd/m2 as decimals."""
|
|
548
|
+
md, cl = _hdr10_parts(v)
|
|
549
|
+
parts = []
|
|
550
|
+
if md:
|
|
551
|
+
f = lambda k: f"{md[k]:.5f}".rstrip("0").rstrip(".")
|
|
552
|
+
parts.append(f"mastering-display=G({f('green_x')},{f('green_y')})B({f('blue_x')},{f('blue_y')})R({f('red_x')},{f('red_y')})"
|
|
553
|
+
f"WP({f('white_point_x')},{f('white_point_y')})L({f('max_luminance')},{f('min_luminance')})")
|
|
554
|
+
if cl:
|
|
555
|
+
parts.append(f"content-light={cl['max_content']},{cl['max_average']}")
|
|
556
|
+
return ":".join(parts)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _hdr10_parts(v: Dict[str, Any]) -> "Tuple[Optional[Dict[str, float]], Optional[Dict[str, int]]]":
|
|
560
|
+
st = v.get("hdr10_static") or {}
|
|
561
|
+
if v.get("color_transfer") != "smpte2084":
|
|
562
|
+
return None, None
|
|
563
|
+
return st.get("mastering_display"), st.get("content_light")
|
|
564
|
+
|
|
565
|
+
|
|
511
566
|
def _clamped_crf(encoder: str, value: int, top: int, requested: int) -> int:
|
|
512
567
|
"""`value` capped at the encoder's maximum CRF, the cap recorded (`crf_clamped`) when it
|
|
513
568
|
bites: the HEVC HDR line asks x265 for CRF+2, so --quality 50 or 51 there is encoded at 51."""
|
|
@@ -667,7 +722,7 @@ def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
|
|
|
667
722
|
cs = v.get("color_space") or "bt2020nc"
|
|
668
723
|
prim = v.get("color_primaries") or "bt2020"
|
|
669
724
|
trc = v.get("color_transfer") or "arib-std-b67"
|
|
670
|
-
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
725
|
+
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
671
726
|
return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
672
727
|
"-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
|
|
673
728
|
|
package/scripts/_common/emit.py
CHANGED
|
@@ -137,6 +137,30 @@ def _report_dropped_audio_tracks(meta: Dict[str, Any], ctx: "Context") -> None:
|
|
|
137
137
|
ctx.notes.append(note_line)
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None:
|
|
141
|
+
"""A PQ source's mastering-display / content-light metadata that a PQ output came out without
|
|
142
|
+
(#401): measured on the written file, so whatever encoder or FFmpeg version dropped it is said.
|
|
143
|
+
An output that is no longer PQ (color.py --to-sdr, an HLG conversion) was converted on purpose."""
|
|
144
|
+
first = getattr(STATE, "first_input", None)
|
|
145
|
+
src = first[2] if first and len(first) > 2 else None
|
|
146
|
+
v = (meta or {}).get("video") or {}
|
|
147
|
+
if not src or v.get("color_transfer") != "smpte2084":
|
|
148
|
+
return
|
|
149
|
+
if len(getattr(STATE, "video_inputs", None) or ()) > 1:
|
|
150
|
+
return # several pictures: the first input's metadata need not be the output's source
|
|
151
|
+
have = v.get("hdr10_static") or {}
|
|
152
|
+
lost = [k for k in ("mastering_display", "content_light") if src.get(k) and not have.get(k)]
|
|
153
|
+
if not lost:
|
|
154
|
+
return
|
|
155
|
+
names = {"mastering_display": "mastering display", "content_light": "content light level"}
|
|
156
|
+
warn("the source's HDR10 " + " and ".join(names[k] for k in lost) + " metadata did not come through to this output "
|
|
157
|
+
"(the encoder or this FFmpeg build did not write it); players may tone-map it differently",
|
|
158
|
+
"hdr10_metadata_dropped", ctx=ctx, dropped=lost, codec=v.get("codec"))
|
|
159
|
+
note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
|
|
160
|
+
if note_line and note_line not in ctx.notes:
|
|
161
|
+
ctx.notes.append(note_line)
|
|
162
|
+
|
|
163
|
+
|
|
140
164
|
def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
|
|
141
165
|
ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
|
|
142
166
|
"""die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
|
|
@@ -178,8 +202,9 @@ def record_skip(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data:
|
|
|
178
202
|
|
|
179
203
|
|
|
180
204
|
def note(msg: str, ctx: "Optional[Context]" = None) -> None:
|
|
181
|
-
"""A plain `notes` line that also goes to stderr."""
|
|
205
|
+
"""A plain `notes` line that also goes to stderr, made inert as messages are (#400)."""
|
|
182
206
|
ctx = ctx or STATE
|
|
207
|
+
msg = neutralize(msg)
|
|
183
208
|
if msg not in ctx.notes:
|
|
184
209
|
ctx.notes.append(msg)
|
|
185
210
|
sys.stderr.write(f"{msg}\n")
|
|
@@ -682,6 +707,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
682
707
|
if output and not ctx.dry_run:
|
|
683
708
|
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
684
709
|
_report_dropped_audio_tracks(meta, ctx)
|
|
710
|
+
_report_dropped_hdr10_metadata(meta, ctx)
|
|
685
711
|
if ctx.json:
|
|
686
712
|
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
|
|
687
713
|
if meta:
|
|
@@ -889,13 +915,22 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
|
|
|
889
915
|
platform = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}.get(cleaned[cleaned.index("--preset") + 1])
|
|
890
916
|
if platform and output and tool != "check":
|
|
891
917
|
verify.append({"tool": "check", "platform": platform})
|
|
918
|
+
plan_inputs = _plan_inputs(ctx.commands, cleaned, ctx)
|
|
919
|
+
real = os.path.realpath(path)
|
|
920
|
+
# every file the plan names (#413 review): its inputs, side inputs included (an SRT, a still),
|
|
921
|
+
# and the output it promises, a default name too, which apply_common could not know
|
|
922
|
+
for named in plan_inputs + ([output] if output else []):
|
|
923
|
+
if os.path.realpath(named) == real:
|
|
924
|
+
refuse("input", "output_is_input" if named != output else "output_path_taken",
|
|
925
|
+
f"--plan {path!r} is also {'the output' if named == output else 'an input'} of this run "
|
|
926
|
+
f"({named!r}); give the plan its own path", output=path, other=named)
|
|
892
927
|
doc = {
|
|
893
928
|
"plan_version": PLAN_VERSION,
|
|
894
929
|
"created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
895
930
|
"tool": tool,
|
|
896
931
|
"argv": cleaned,
|
|
897
932
|
"cwd": os.getcwd(),
|
|
898
|
-
"inputs": [fingerprint(p) for p in
|
|
933
|
+
"inputs": [fingerprint(p) for p in plan_inputs],
|
|
899
934
|
# inputs that did not exist yet, so carry no fingerprint (render.py reports them on execution)
|
|
900
935
|
"unbound_inputs": _plan_unbound_inputs(ctx.commands, ctx),
|
|
901
936
|
"commands": list(ctx.commands),
|
package/scripts/_common/probe.py
CHANGED
|
@@ -13,7 +13,7 @@ import statistics
|
|
|
13
13
|
from fractions import Fraction
|
|
14
14
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
15
|
from _common.emit import die, warn
|
|
16
|
-
from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis
|
|
16
|
+
from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def fingerprint(path: str) -> Dict[str, Any]:
|
|
@@ -187,7 +187,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
187
187
|
die(f"input not found: {path}")
|
|
188
188
|
ffprobe = require_tool("ffprobe")
|
|
189
189
|
proc = run(
|
|
190
|
-
[ffprobe, "-v", "error",
|
|
190
|
+
[ffprobe, "-v", "error", *still_input_args(path),
|
|
191
|
+
"-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
|
|
191
192
|
quiet=True,
|
|
192
193
|
check=False,
|
|
193
194
|
)
|
|
@@ -317,6 +318,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
317
318
|
"hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
|
|
318
319
|
"HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
|
|
319
320
|
"dolby_vision": dovi,
|
|
321
|
+
# HDR10 static metadata (mastering display, content light level) as the encoders take it:
|
|
322
|
+
# FFmpeg before 7 does not hand it from the decoded frames to libx265 / libsvtav1, so the
|
|
323
|
+
# tools pass it explicitly (decision.hdr10_x265_params). None when the source has none.
|
|
324
|
+
"hdr10_static": _hdr10_static(ffprobe, path, video) if trc == "smpte2084" else None,
|
|
320
325
|
"color_space": video.get("color_space"),
|
|
321
326
|
"color_primaries": video.get("color_primaries"),
|
|
322
327
|
"color_transfer": video.get("color_transfer"),
|
|
@@ -355,13 +360,76 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
355
360
|
"language": (a.get("tags") or {}).get("language"),
|
|
356
361
|
"title": (a.get("tags") or {}).get("title"),
|
|
357
362
|
} for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
|
|
363
|
+
if role == "input" and out.get("video") and out["video"].get("codec") \
|
|
364
|
+
and os.path.splitext(path)[1].lower() not in _STILL_EXT:
|
|
365
|
+
# a still (an overlay's logo, an insert card) is not a second picture source (#412 review)
|
|
366
|
+
STATE.video_inputs.add(os.path.realpath(path))
|
|
358
367
|
if role == "input" and out.get("audio_streams"):
|
|
359
368
|
STATE.audio_inputs.add(os.path.realpath(path))
|
|
360
369
|
if role == "input" and getattr(STATE, "first_input", None) is None:
|
|
361
|
-
STATE.first_input = (path, len(out.get("audio_streams") or [])
|
|
370
|
+
STATE.first_input = (path, len(out.get("audio_streams") or []),
|
|
371
|
+
(out.get("video") or {}).get("hdr10_static"))
|
|
362
372
|
return out
|
|
363
373
|
|
|
364
374
|
|
|
375
|
+
def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
|
|
376
|
+
"""A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
|
|
377
|
+
a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
|
|
378
|
+
frame is decoded when either part is missing or incomplete in the stream. Each part is taken
|
|
379
|
+
from wherever it is complete first, the stream before the frame. Chromaticities are CIE 1931
|
|
380
|
+
x/y, luminances cd/m2. None when it has neither."""
|
|
381
|
+
out = _hdr10_parts(stream.get("side_data_list") or [])
|
|
382
|
+
if len(out) < 2:
|
|
383
|
+
# V:0, not v:0: an attached cover picture before the motion video has no HDR side data
|
|
384
|
+
proc = run([ffprobe, "-v", "error", "-select_streams", "V:0", "-read_intervals", "%+#1", "-show_frames",
|
|
385
|
+
"-show_entries", "frame=side_data_list", "-of", "json", path], quiet=True, check=False)
|
|
386
|
+
frames: List[Dict[str, Any]] = []
|
|
387
|
+
if proc.returncode == 0:
|
|
388
|
+
try:
|
|
389
|
+
frames = json.loads(proc.stdout or "{}").get("frames") or []
|
|
390
|
+
except ValueError:
|
|
391
|
+
pass
|
|
392
|
+
for k, v in _hdr10_parts((frames[0].get("side_data_list") or []) if frames else []).items():
|
|
393
|
+
out.setdefault(k, v)
|
|
394
|
+
return out or None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _hdr10_parts(sides: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
|
|
398
|
+
"""The complete parts in one side-data list: a mastering display with every field, a content
|
|
399
|
+
light level with both values; an incomplete record counts as absent."""
|
|
400
|
+
out: Dict[str, Any] = {}
|
|
401
|
+
for sd in sides:
|
|
402
|
+
kind = _side_kind(sd)
|
|
403
|
+
if kind == "mastering" and "mastering_display" not in out:
|
|
404
|
+
vals = {k: _ratio(sd.get(k)) for k in ("red_x", "red_y", "green_x", "green_y", "blue_x", "blue_y",
|
|
405
|
+
"white_point_x", "white_point_y", "min_luminance", "max_luminance")}
|
|
406
|
+
if all(v is not None for v in vals.values()):
|
|
407
|
+
out["mastering_display"] = {k: float(v) for k, v in vals.items()}
|
|
408
|
+
elif kind == "light" and "content_light" not in out:
|
|
409
|
+
mc, ma = _to_int(sd.get("max_content")), _to_int(sd.get("max_average"))
|
|
410
|
+
if mc is not None and ma is not None:
|
|
411
|
+
out["content_light"] = {"max_content": mc, "max_average": ma}
|
|
412
|
+
return out
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
_STILL_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif", ".avif", ".heic")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _ratio(v: Any) -> "Optional[Fraction]":
|
|
419
|
+
"""A side-data value such as "34000/50000" or "0/10000". Unlike _fraction() a zero is a value
|
|
420
|
+
(a mastering display's minimum luminance may be 0 cd/m2); only a missing or malformed one is None."""
|
|
421
|
+
try:
|
|
422
|
+
f = Fraction(str(v))
|
|
423
|
+
except (ValueError, ZeroDivisionError, TypeError):
|
|
424
|
+
return None
|
|
425
|
+
return f if f >= 0 else None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _side_kind(sd: Dict[str, Any]) -> "Optional[str]":
|
|
429
|
+
t = str(sd.get("side_data_type") or "")
|
|
430
|
+
return "mastering" if t == "Mastering display metadata" else "light" if t == "Content light level metadata" else None
|
|
431
|
+
|
|
432
|
+
|
|
365
433
|
def _bit_depth(pix_fmt: Optional[str]) -> int:
|
|
366
434
|
"""Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
|
|
367
435
|
chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
|
|
@@ -324,7 +324,7 @@ class Context:
|
|
|
324
324
|
|
|
325
325
|
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
|
|
326
326
|
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
|
|
327
|
-
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs")
|
|
327
|
+
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs")
|
|
328
328
|
|
|
329
329
|
def __init__(self) -> None:
|
|
330
330
|
self.reset()
|
|
@@ -333,6 +333,7 @@ class Context:
|
|
|
333
333
|
self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
|
|
334
334
|
self.json = False # emit() prints a JSON document instead of the output path
|
|
335
335
|
self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
|
|
336
|
+
self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
|
|
336
337
|
self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
|
|
337
338
|
self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
|
|
338
339
|
self.progress = False # run() streams percent / ETA to stderr for ffmpeg
|
|
@@ -440,6 +441,13 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
440
441
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
441
442
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
442
443
|
STATE.overwrite = bool(getattr(args, "overwrite", False))
|
|
444
|
+
if STATE.plan:
|
|
445
|
+
# the plan is a file this run writes itself: the side-file guards apply (#408), so it
|
|
446
|
+
# never replaces an input, the output or an existing file without --overwrite
|
|
447
|
+
named = [getattr(args, k, None) for k in ("input", "inputs", "reference", "compare")]
|
|
448
|
+
inputs = [p for v in named for p in (v if isinstance(v, (list, tuple)) else [v]) if isinstance(p, str)]
|
|
449
|
+
out = getattr(args, "output", None)
|
|
450
|
+
guard_sidecar(STATE.plan, *inputs, siblings=(out,) if isinstance(out, str) else ())
|
|
443
451
|
if getattr(args, "timeout", None) is not None:
|
|
444
452
|
STATE.timeout = max(0.0, float(args.timeout))
|
|
445
453
|
else:
|
|
@@ -682,11 +690,15 @@ def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
|
|
|
682
690
|
# while every guard here reads it as a path. Two or more letters, so a Windows
|
|
683
691
|
# drive letter (`C:\x.mp4`) is a path; `pipe:` is the skill's own stdout/stdin and stays allowed.
|
|
684
692
|
_URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
|
|
693
|
+
# Off Windows there are no drive letters, and ffmpeg reads a one-letter `x:y.mp4` as protocol `x`
|
|
694
|
+
# too (#408: it then failed as "Protocol not found", with no hint that ./x:y.mp4 works)
|
|
695
|
+
_URL_SCHEME_POSIX = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
685
696
|
|
|
686
697
|
|
|
687
698
|
def looks_like_url(value: str) -> bool:
|
|
688
699
|
"""True for a value ffmpeg would open through a protocol rather than as a local file."""
|
|
689
|
-
|
|
700
|
+
scheme = _URL_SCHEME if os.name == "nt" else _URL_SCHEME_POSIX
|
|
701
|
+
return bool(scheme.match(value)) and not value.startswith("pipe:")
|
|
690
702
|
|
|
691
703
|
|
|
692
704
|
_PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat")
|
|
@@ -1704,13 +1716,34 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
|
1704
1716
|
_remember_output(cmd)
|
|
1705
1717
|
|
|
1706
1718
|
|
|
1719
|
+
# extensions ffmpeg opens through the image2 demuxer, the only one that expands `%d` in a name
|
|
1720
|
+
# and the only one that has a `pattern_type` option (gif, avif, mp4... reject it: "Option
|
|
1721
|
+
# pattern_type not found")
|
|
1722
|
+
_IMAGE2_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".dpx", ".exr", ".tga",
|
|
1723
|
+
".ppm", ".pgm", ".pbm", ".pam", ".sgi", ".jp2", ".j2k", ".xbm", ".xpm", ".pcx", ".qoi")
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def still_input_args(path: str) -> List[str]:
|
|
1727
|
+
"""`-pattern_type none` for a still read through image2 whose path has a `%` in it (#408):
|
|
1728
|
+
image2 otherwise reads `logo%d.png` as a numbered sequence and opens `logo1.png`, a file the
|
|
1729
|
+
run's guards and fingerprints never looked at. Empty for every other path, a video or a GIF
|
|
1730
|
+
with a `%` in its name included: their demuxers never expand it and reject the option."""
|
|
1731
|
+
p = str(path)
|
|
1732
|
+
return ["-pattern_type", "none"] if "%" in p and os.path.splitext(p)[1].lower() in _IMAGE2_EXT else []
|
|
1733
|
+
|
|
1734
|
+
|
|
1707
1735
|
def sibling_temp(dst: str, tag: str) -> str:
|
|
1708
1736
|
"""A hidden path next to `dst`, with its extension (the muxer is chosen by it), for a file
|
|
1709
1737
|
place_output(..., move=True) renames into place: same directory, so the rename never
|
|
1710
1738
|
crosses a filesystem."""
|
|
1711
1739
|
d, base = os.path.split(dst)
|
|
1712
1740
|
stem, ext = os.path.splitext(base)
|
|
1713
|
-
|
|
1741
|
+
path = os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}{ext}")
|
|
1742
|
+
n = 1
|
|
1743
|
+
while os.path.lexists(path): # a leftover of an earlier run with this pid: not ours to replace
|
|
1744
|
+
n += 1
|
|
1745
|
+
path = os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}-{n}{ext}")
|
|
1746
|
+
return path
|
|
1714
1747
|
|
|
1715
1748
|
|
|
1716
1749
|
_DRAWTEXT_TMPDIR: "Optional[str]" = None
|
package/scripts/_contract.py
CHANGED
|
@@ -464,7 +464,8 @@ DIAGNOSTIC_CODES = {
|
|
|
464
464
|
"crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
|
|
465
465
|
# fonts and text
|
|
466
466
|
"emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
|
|
467
|
-
"
|
|
467
|
+
"hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
|
|
468
|
+
"audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
|
|
468
469
|
"grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
|
|
469
470
|
"scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
|
|
470
471
|
"look_no_drawtext": ("fallbacks", "look.py on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
|
package/scripts/batch.py
CHANGED
|
@@ -34,7 +34,7 @@ from typing import Any, Dict, List, Optional
|
|
|
34
34
|
|
|
35
35
|
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
|
|
36
36
|
from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw, stop_children
|
|
37
|
-
from _common.emit import absorb_stage_hw, audit, note, steps_encoder_report, warn
|
|
37
|
+
from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
|
|
38
38
|
|
|
39
39
|
HERE = Path(__file__).resolve().parent
|
|
40
40
|
MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
|
|
@@ -722,7 +722,7 @@ def main() -> int:
|
|
|
722
722
|
why = (" (reencode_reason: " + ", ".join(f"{k} x{v}" for k, v in reasons.items()) + ")") if reasons else ""
|
|
723
723
|
info(f"cut.py: {copied}/{len(all_cuts)} call(s) stayed fully lossless stream-copy "
|
|
724
724
|
f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} re-encoded a range or its --segments join{why}")
|
|
725
|
-
unverified = [os.path.basename(r["file"]) for r in results if r.get("ok") and not r.get("verified")]
|
|
725
|
+
unverified = [shown(os.path.basename(r["file"])) for r in results if r.get("ok") and not r.get("verified")]
|
|
726
726
|
if unverified and not STATE.dry_run:
|
|
727
727
|
note(f"{len(unverified)} item(s) completed but did not verify everything they wrote (see each row's unverified): "
|
|
728
728
|
+ ", ".join(unverified[:5]) + (" ..." if len(unverified) > 5 else ""))
|
package/scripts/caption.py
CHANGED
|
@@ -44,7 +44,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
44
44
|
from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
|
|
45
45
|
resolve as resolve_platform)
|
|
46
46
|
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
47
|
-
from _common import
|
|
47
|
+
from _common.emit import refuse # noqa: E402
|
|
48
|
+
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
48
49
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
49
50
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
50
51
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
|
@@ -1276,28 +1277,52 @@ def main() -> int:
|
|
|
1276
1277
|
# next to the output was replaced on a re-run, even one ffmpeg's check then refused.
|
|
1277
1278
|
# Side files whose existence depends on the cues (an _adjusted.srt, an emoji-forced ASS) are
|
|
1278
1279
|
# checked again where they are written, still before that write.
|
|
1279
|
-
claimed:
|
|
1280
|
-
|
|
1281
|
-
def claim(
|
|
1282
|
-
|
|
1280
|
+
claimed: Dict[str, str] = {} # real path -> which artifact of this run holds it
|
|
1281
|
+
|
|
1282
|
+
def claim(items) -> None:
|
|
1283
|
+
"""Reserve (path, artifact) pairs: the same artifact claiming its path again is a no-op,
|
|
1284
|
+
another artifact of this run claiming it is refused (#413 review: --write-srt and
|
|
1285
|
+
--write-ass naming one file, the SRT then replaced by the ASS)."""
|
|
1286
|
+
todo = []
|
|
1287
|
+
for path, owner in items:
|
|
1288
|
+
if not path:
|
|
1289
|
+
continue
|
|
1290
|
+
real = os.path.realpath(path)
|
|
1291
|
+
held = claimed.get(real)
|
|
1292
|
+
if held is None and os.path.exists(real):
|
|
1293
|
+
# a second name for the same file (a hard link) is the same path (#413 review)
|
|
1294
|
+
held = next((o for r, o in claimed.items()
|
|
1295
|
+
if os.path.exists(r) and os.path.samefile(r, real)), None)
|
|
1296
|
+
if held is None:
|
|
1297
|
+
claimed[real] = owner
|
|
1298
|
+
todo.append(path)
|
|
1299
|
+
elif held != owner:
|
|
1300
|
+
refuse("input", "output_path_taken",
|
|
1301
|
+
f"{path!r} is named for both the {held} and the {owner} this run writes; give each its own path",
|
|
1302
|
+
output=path, other=held)
|
|
1283
1303
|
refuse_existing_outputs(todo)
|
|
1284
|
-
|
|
1304
|
+
# and the rest of the side-file guards (#408): never an input, the video, a missing
|
|
1305
|
+
# directory or an option-shaped name, even under --overwrite
|
|
1306
|
+
side_inputs = [q for q in (args.input, getattr(args, "text", None), getattr(args, "ass", None),
|
|
1307
|
+
getattr(args, "brand", None), *(t[0] for t in srt_tracks)) if isinstance(q, str)]
|
|
1308
|
+
for path in todo:
|
|
1309
|
+
guard_sidecar(path, *side_inputs)
|
|
1285
1310
|
|
|
1286
1311
|
planned_out = args.output or (default_output(args.input, "captioned") if args.input else None)
|
|
1287
|
-
up_front: List[str] = []
|
|
1312
|
+
up_front: List[Tuple[str, str]] = []
|
|
1288
1313
|
if args.transcribe:
|
|
1289
|
-
up_front.append(args.write_srt or os.path.splitext(planned_out)[0] + ".srt")
|
|
1314
|
+
up_front.append((args.write_srt or os.path.splitext(planned_out)[0] + ".srt", "SRT"))
|
|
1290
1315
|
elif args.text:
|
|
1291
|
-
up_front.append(args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
|
|
1292
|
-
|
|
1316
|
+
up_front.append((args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
|
|
1317
|
+
else os.path.splitext(args.text)[0] + ".srt"), "SRT"))
|
|
1293
1318
|
if args.input:
|
|
1294
1319
|
if (args.animate != "none" or args.karaoke) and not args.ass:
|
|
1295
|
-
up_front.append(args.write_ass or os.path.splitext(planned_out)[0] + ".ass")
|
|
1320
|
+
up_front.append((args.write_ass or os.path.splitext(planned_out)[0] + ".ass", "ASS"))
|
|
1296
1321
|
if args.ass and args.offset and os.path.exists(args.ass):
|
|
1297
|
-
up_front.append(os.path.splitext(planned_out)[0] + "_offset.ass")
|
|
1322
|
+
up_front.append((os.path.splitext(planned_out)[0] + "_offset.ass", "offset ASS"))
|
|
1298
1323
|
# -o naming the input itself keeps its own, more specific refusal (_check_no_overwrite_input)
|
|
1299
1324
|
if os.path.realpath(planned_out) != os.path.realpath(args.input):
|
|
1300
|
-
up_front.append(planned_out)
|
|
1325
|
+
up_front.append((planned_out, "video"))
|
|
1301
1326
|
claim(up_front)
|
|
1302
1327
|
if args.transcribe:
|
|
1303
1328
|
# the sidecar goes next to the output like the --text one, not into the source folder
|
|
@@ -1358,7 +1383,7 @@ def main() -> int:
|
|
|
1358
1383
|
adjusted, changed = lay_out(parse_srt(srt_path))
|
|
1359
1384
|
if changed:
|
|
1360
1385
|
new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
|
|
1361
|
-
claim([new_srt])
|
|
1386
|
+
claim([(new_srt, "adjusted SRT")])
|
|
1362
1387
|
if STATE.dry_run:
|
|
1363
1388
|
info(f"[dry-run] would write {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
|
|
1364
1389
|
else:
|
|
@@ -1563,7 +1588,7 @@ def main() -> int:
|
|
|
1563
1588
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1564
1589
|
args._word_timings = whisper_word_timings(srt_path)
|
|
1565
1590
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1566
|
-
claim([ass_path])
|
|
1591
|
+
claim([(ass_path, "ASS")])
|
|
1567
1592
|
w, h = meta["video"]["width"], meta["video"]["height"]
|
|
1568
1593
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
1569
1594
|
w, h = h, w
|
package/scripts/graphics.py
CHANGED
|
@@ -32,7 +32,7 @@ import sys
|
|
|
32
32
|
from typing import List, Optional, Tuple
|
|
33
33
|
|
|
34
34
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
35
|
-
from _common import (require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
35
|
+
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
36
36
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
37
37
|
ffmpeg_base, info, label_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
38
38
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
@@ -648,6 +648,10 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
648
648
|
"overlays": len(emoji_overlays)}
|
|
649
649
|
ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
|
|
650
650
|
require_filter("ass", "drawing this text through libass") # before the sidecar is written
|
|
651
|
+
# a file this run writes itself: never the input or the video, a missing directory or an
|
|
652
|
+
# existing file without --overwrite is refused before anything is written (#408)
|
|
653
|
+
guard_sidecar(ass_path, *[q for q in (args.input, getattr(args, "brand", None), getattr(args, "font_file", None))
|
|
654
|
+
if isinstance(q, str)], siblings=(output,))
|
|
651
655
|
if STATE.dry_run:
|
|
652
656
|
info(f"[dry-run] would write {ass_path} ({len(elements)} text elements)")
|
|
653
657
|
else:
|
package/scripts/insert.py
CHANGED
|
@@ -27,6 +27,7 @@ import argparse
|
|
|
27
27
|
import math
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
+
from _common import still_input_args # noqa: E402
|
|
30
31
|
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
31
32
|
|
|
32
33
|
|
|
@@ -115,7 +116,7 @@ def main() -> int:
|
|
|
115
116
|
]
|
|
116
117
|
|
|
117
118
|
output = args.output or default_output(args.input, "insert", "mp4")
|
|
118
|
-
cmd = ffmpeg_base() + ["-loop", "1", "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
|
|
119
|
+
cmd = ffmpeg_base() + ["-loop", "1", *still_input_args(args.input), "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
|
|
119
120
|
# the SDR line, whatever the image is; its RGB (PNG) or BT.601 (JPEG) pixels are converted to
|
|
120
121
|
# the BT.709 that line is tagged with
|
|
121
122
|
cmd += video_args(None, args.crf, args.preset, source=meta)
|
package/scripts/look.py
CHANGED
|
@@ -17,6 +17,7 @@ from pathlib import Path
|
|
|
17
17
|
from typing import List, Optional
|
|
18
18
|
|
|
19
19
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
20
|
+
from _common import still_input_args # noqa: E402
|
|
20
21
|
from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
|
|
21
22
|
from _common import drawtext_fontfile # noqa: E402
|
|
22
23
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
@@ -239,8 +240,8 @@ def main() -> int:
|
|
|
239
240
|
tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
|
|
240
241
|
fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
|
|
241
242
|
f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
|
|
242
|
-
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
|
|
243
|
-
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", out]
|
|
243
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
|
|
244
|
+
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
|
|
244
245
|
run(cmd)
|
|
245
246
|
outputs.append(out)
|
|
246
247
|
elif args.at:
|
|
@@ -254,7 +255,7 @@ def main() -> int:
|
|
|
254
255
|
# several frames, or -o given as a stem/prefix without an image extension
|
|
255
256
|
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
256
257
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
257
|
-
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", out]
|
|
258
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
|
|
258
259
|
run(cmd)
|
|
259
260
|
outputs.append(out)
|
|
260
261
|
else:
|
|
@@ -281,7 +282,7 @@ def main() -> int:
|
|
|
281
282
|
tc_sheet = tc.replace(timecode_filter(font_prefix), timecode_filter(font_prefix, seek)) if seek else tc
|
|
282
283
|
vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc_sheet},"
|
|
283
284
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
284
|
-
cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
|
|
285
|
+
cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
|
|
285
286
|
run(cmd)
|
|
286
287
|
outputs.append(out)
|
|
287
288
|
info(f"contact sheet: {n} frames every {step:.2f}s")
|
package/scripts/overlay.py
CHANGED
|
@@ -30,6 +30,7 @@ import sys
|
|
|
30
30
|
from typing import List, Optional
|
|
31
31
|
|
|
32
32
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
33
|
+
from _common import still_input_args # noqa: E402
|
|
33
34
|
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
34
35
|
from _common.emit import warn
|
|
35
36
|
from _common.fonts import font_choice, record_font_choice
|
|
@@ -372,7 +373,7 @@ def main() -> int:
|
|
|
372
373
|
if enable:
|
|
373
374
|
ov += f":enable='{enable}'"
|
|
374
375
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
375
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
|
|
376
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
|
|
376
377
|
# the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
|
|
377
378
|
# RGB-to-YUV step used BT.601), and an untagged main is labelled BT.709 (tags only) so
|
|
378
379
|
# 7.1+'s negotiation does not convert one picture to the other's "unknown" matrix
|
package/scripts/render.py
CHANGED
|
@@ -342,6 +342,11 @@ def render_pack(names: List[str], args) -> int:
|
|
|
342
342
|
and platform check), so the pack reports per-platform results rather than one verdict."""
|
|
343
343
|
stem = Path(args.input).with_suffix("").name
|
|
344
344
|
outdir = Path(args.output).parent if args.output else Path(args.input).resolve().parent
|
|
345
|
+
# the table is a file this run writes itself (#408): an existing one is refused without
|
|
346
|
+
# --overwrite, before the first destination renders
|
|
347
|
+
if outdir.is_dir(): # a missing folder is the destinations' own refusal (and a dry run's plan)
|
|
348
|
+
side = [v for k, v in vars(args).items() if k != "output" and isinstance(v, str) and os.path.isfile(v)]
|
|
349
|
+
guard_sidecar(str(outdir / f"{stem}_pack.md"), args.input, *side)
|
|
345
350
|
dests = {name: str((load_template(name).get("check") or {}).get("platform") or name) for name in names}
|
|
346
351
|
rows: List[Dict[str, Any]] = []
|
|
347
352
|
outputs: List[str] = []
|
|
@@ -399,6 +404,9 @@ def render_pack(names: List[str], args) -> int:
|
|
|
399
404
|
lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
|
|
400
405
|
"Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
|
|
401
406
|
if not STATE.dry_run:
|
|
407
|
+
if not outdir.is_dir():
|
|
408
|
+
die(f"pack: the output folder {outdir} does not exist, so the table was not written", kind="input",
|
|
409
|
+
pack=rows, outputs=outputs)
|
|
402
410
|
Path(pack).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
403
411
|
info(("[dry-run] would write " if STATE.dry_run else "wrote ") + pack)
|
|
404
412
|
if failed:
|
package/scripts/scenes.py
CHANGED
|
@@ -302,7 +302,7 @@ def main() -> int:
|
|
|
302
302
|
stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
|
|
303
303
|
vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
|
|
304
304
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
305
|
-
run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
|
|
305
|
+
run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
|
|
306
306
|
info(f"wrote {args.sheet}")
|
|
307
307
|
result["sheet"] = args.sheet
|
|
308
308
|
|
package/scripts/verify.py
CHANGED
|
@@ -26,7 +26,7 @@ import time
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
from typing import Any, Dict, List, Optional, Tuple
|
|
28
28
|
|
|
29
|
-
from _common import STATE, add_common, apply_common, die, emit, info, probe
|
|
29
|
+
from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe
|
|
30
30
|
|
|
31
31
|
HERE = Path(__file__).resolve().parent
|
|
32
32
|
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
|
|
@@ -160,9 +160,14 @@ def main() -> int:
|
|
|
160
160
|
files = collect(args.paths)
|
|
161
161
|
tmp = None
|
|
162
162
|
if args.out:
|
|
163
|
+
# made first, so a --report inside a new --out folder passes the guard below (#413 review)
|
|
163
164
|
outdir = Path(args.out)
|
|
164
165
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
165
|
-
|
|
166
|
+
if args.report:
|
|
167
|
+
# a file this run writes itself (#408): not one of the media it verifies (a folder's
|
|
168
|
+
# files included), nor an existing file without --overwrite
|
|
169
|
+
guard_sidecar(args.report, *args.paths, *map(str, files))
|
|
170
|
+
if not args.out:
|
|
166
171
|
tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
|
|
167
172
|
outdir = Path(tmp.name)
|
|
168
173
|
|
package/scripts/waveform.py
CHANGED
|
@@ -36,9 +36,10 @@ import sys
|
|
|
36
36
|
|
|
37
37
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
38
38
|
from _common.probe import SILENT_PEAK_DBFS
|
|
39
|
+
from _common import still_input_args # noqa: E402
|
|
39
40
|
from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
|
|
40
41
|
info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
|
|
41
|
-
fmt_secs, child_args, run_tool, measured_level_dbfs)
|
|
42
|
+
fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
|
|
42
43
|
from _common.emit import absorb_stage_hw, warn
|
|
43
44
|
|
|
44
45
|
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
@@ -207,14 +208,15 @@ def main() -> int:
|
|
|
207
208
|
if args.title or args.srt or args.text:
|
|
208
209
|
# the visualisation is rendered first and the label/captions are drawn on it by the tools
|
|
209
210
|
# that own those code paths, so neither is re-implemented here
|
|
210
|
-
|
|
211
|
-
|
|
211
|
+
# hidden, pid-named siblings (#408): a fixed `<out>_vis` name was removed afterwards
|
|
212
|
+
# whoever had written it, the input or another file of the caller's included
|
|
213
|
+
render = sibling_temp(output, "vis")
|
|
212
214
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
213
215
|
if args.image:
|
|
214
216
|
# -framerate before -loop: a looped still defaults to 25 fps, and overlay takes its rate
|
|
215
217
|
# from the FIRST input -- so without this the file came out 25 fps however loud --fps or
|
|
216
218
|
# --platform said otherwise (the colour-plate path never had the bug: `color=` carries r=).
|
|
217
|
-
cmd += ["-framerate", f"{args.fps:g}", "-loop", "1", "-i", args.image]
|
|
219
|
+
cmd += ["-framerate", f"{args.fps:g}", "-loop", "1", *still_input_args(args.image), "-i", args.image]
|
|
218
220
|
cmd += ["-filter_complex", vf]
|
|
219
221
|
if args.image:
|
|
220
222
|
cmd += ["-map", "[v]"]
|
|
@@ -227,15 +229,20 @@ def main() -> int:
|
|
|
227
229
|
if meta.get("duration"):
|
|
228
230
|
cmd += ["-t", f"{float(meta['duration']):.3f}"]
|
|
229
231
|
cmd += ["-shortest", render]
|
|
230
|
-
|
|
231
|
-
intermediates = [p for p in (render,
|
|
232
|
+
titled_tmp = sibling_temp(output, "titled")
|
|
233
|
+
intermediates = [p for p in (render, titled_tmp) if p != output]
|
|
232
234
|
try:
|
|
233
235
|
run(cmd)
|
|
234
236
|
|
|
235
237
|
stages = ["waveform"]
|
|
236
238
|
current = render
|
|
237
239
|
if args.title:
|
|
238
|
-
titled =
|
|
240
|
+
titled = titled_tmp if (args.srt or args.text) else output
|
|
241
|
+
if titled == titled_tmp:
|
|
242
|
+
# graphics.py's libass route writes <its output stem>_gfx.ass beside it (#413 review)
|
|
243
|
+
titled_ass = os.path.splitext(titled)[0] + "_gfx.ass"
|
|
244
|
+
if not os.path.exists(titled_ass):
|
|
245
|
+
intermediates.append(titled_ass)
|
|
239
246
|
# a dry run rendered no _vis file for graphics.py to probe, so its default --end (the
|
|
240
247
|
# clip's end) came out 0 and the plan failed: give it the source's duration, which
|
|
241
248
|
# is what -t caps the render at. A real run is unchanged.
|