ffmpeg-skill 3.1.4 → 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/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/decision.py +22 -4
- package/scripts/_common/emit.py +10 -1
- package/scripts/_common/probe.py +3 -2
- package/scripts/_common/runner.py +34 -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/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
|
|
package/scripts/_common/emit.py
CHANGED
|
@@ -915,13 +915,22 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
|
|
|
915
915
|
platform = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}.get(cleaned[cleaned.index("--preset") + 1])
|
|
916
916
|
if platform and output and tool != "check":
|
|
917
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)
|
|
918
927
|
doc = {
|
|
919
928
|
"plan_version": PLAN_VERSION,
|
|
920
929
|
"created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
921
930
|
"tool": tool,
|
|
922
931
|
"argv": cleaned,
|
|
923
932
|
"cwd": os.getcwd(),
|
|
924
|
-
"inputs": [fingerprint(p) for p in
|
|
933
|
+
"inputs": [fingerprint(p) for p in plan_inputs],
|
|
925
934
|
# inputs that did not exist yet, so carry no fingerprint (render.py reports them on execution)
|
|
926
935
|
"unbound_inputs": _plan_unbound_inputs(ctx.commands, ctx),
|
|
927
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
|
)
|
|
@@ -441,6 +441,13 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
441
441
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
442
442
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
443
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 ())
|
|
444
451
|
if getattr(args, "timeout", None) is not None:
|
|
445
452
|
STATE.timeout = max(0.0, float(args.timeout))
|
|
446
453
|
else:
|
|
@@ -683,11 +690,15 @@ def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
|
|
|
683
690
|
# while every guard here reads it as a path. Two or more letters, so a Windows
|
|
684
691
|
# drive letter (`C:\x.mp4`) is a path; `pipe:` is the skill's own stdout/stdin and stays allowed.
|
|
685
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+.-]*:")
|
|
686
696
|
|
|
687
697
|
|
|
688
698
|
def looks_like_url(value: str) -> bool:
|
|
689
699
|
"""True for a value ffmpeg would open through a protocol rather than as a local file."""
|
|
690
|
-
|
|
700
|
+
scheme = _URL_SCHEME if os.name == "nt" else _URL_SCHEME_POSIX
|
|
701
|
+
return bool(scheme.match(value)) and not value.startswith("pipe:")
|
|
691
702
|
|
|
692
703
|
|
|
693
704
|
_PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat")
|
|
@@ -1705,13 +1716,34 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
|
1705
1716
|
_remember_output(cmd)
|
|
1706
1717
|
|
|
1707
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
|
+
|
|
1708
1735
|
def sibling_temp(dst: str, tag: str) -> str:
|
|
1709
1736
|
"""A hidden path next to `dst`, with its extension (the muxer is chosen by it), for a file
|
|
1710
1737
|
place_output(..., move=True) renames into place: same directory, so the rename never
|
|
1711
1738
|
crosses a filesystem."""
|
|
1712
1739
|
d, base = os.path.split(dst)
|
|
1713
1740
|
stem, ext = os.path.splitext(base)
|
|
1714
|
-
|
|
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
|
|
1715
1747
|
|
|
1716
1748
|
|
|
1717
1749
|
_DRAWTEXT_TMPDIR: "Optional[str]" = None
|
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.
|