ffmpeg-skill 0.12.0 → 0.16.12
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 +56 -7
- package/SKILL.md +26 -2
- package/bin/install.js +12 -3
- package/mcp/server.py +8 -0
- package/package.json +2 -2
- package/references/scripts.md +177 -2
- package/scripts/_common.py +112 -5
- package/scripts/_contract.py +128 -6
- package/scripts/background.py +11 -2
- package/scripts/batch.py +58 -5
- package/scripts/caption.py +48 -5
- package/scripts/check.py +8 -0
- package/scripts/color.py +102 -22
- package/scripts/crop.py +2 -0
- package/scripts/cropdetect.py +106 -0
- package/scripts/cut.py +4 -0
- package/scripts/deinterlace.py +85 -0
- package/scripts/denoise.py +94 -0
- package/scripts/export.py +2 -1
- package/scripts/fit.py +26 -6
- package/scripts/freeze.py +108 -0
- package/scripts/graphics.py +7 -5
- package/scripts/grid.py +142 -0
- package/scripts/join.py +4 -1
- package/scripts/look.py +13 -8
- package/scripts/loop.py +80 -0
- package/scripts/multicam.py +10 -2
- package/scripts/overlay.py +27 -10
- package/scripts/pad.py +68 -0
- package/scripts/redact.py +100 -0
- package/scripts/render.py +17 -3
- package/scripts/scenes.py +8 -2
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +123 -0
- package/scripts/sphere.py +126 -0
- package/scripts/stabilize.py +22 -4
- package/scripts/straighten.py +97 -0
- package/scripts/verify.py +25 -2
- package/scripts/waveform.py +92 -0
package/scripts/_common.py
CHANGED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
import platform
|
|
12
|
+
import re
|
|
12
13
|
import shutil
|
|
13
14
|
import subprocess
|
|
14
15
|
import sys
|
|
@@ -205,6 +206,10 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
205
206
|
success path, so a failed run() call never routed through it. Remove whatever ffmpeg managed
|
|
206
207
|
to write so a caller scanning the output directory after a failure never mistakes a partial
|
|
207
208
|
artifact for a real (if unverified) one."""
|
|
209
|
+
# run() also executes ffprobe, whose last argument is an INPUT. Never
|
|
210
|
+
# interpret a read-only tool's failure as permission to remove that file.
|
|
211
|
+
if not _is_ffmpeg(cmd):
|
|
212
|
+
return
|
|
208
213
|
output = cmd[-1]
|
|
209
214
|
if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
|
|
210
215
|
return
|
|
@@ -269,6 +274,20 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
269
274
|
return _run_captured(list(cmd), check)
|
|
270
275
|
|
|
271
276
|
|
|
277
|
+
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
278
|
+
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
279
|
+
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
280
|
+
there are none). A source whose subtitle codec cannot be copied into the target container
|
|
281
|
+
(e.g. a container change) makes that first attempt fail; retry the same command without the
|
|
282
|
+
extra maps rather than let a tool that never touched subtitles start hard-failing because of
|
|
283
|
+
them. `cmd` is the full argv *without* the output path. Returns True only when the
|
|
284
|
+
retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
|
|
285
|
+
if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
|
|
286
|
+
return False
|
|
287
|
+
run(cmd + [output])
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
|
|
272
291
|
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
273
292
|
"""Plain run with stdout/stderr captured."""
|
|
274
293
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
@@ -325,7 +344,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
325
344
|
|
|
326
345
|
|
|
327
346
|
def shell_quote(s: str) -> str:
|
|
328
|
-
if not s or any(ch in s for ch in " \t
|
|
347
|
+
if not s or any(ch in s for ch in " \t\\\"';|&<>()[]{}$*?"):
|
|
329
348
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
330
349
|
return s
|
|
331
350
|
|
|
@@ -387,7 +406,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
387
406
|
return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
|
|
388
407
|
"video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
|
|
389
408
|
"color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
|
|
390
|
-
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
|
|
409
|
+
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
|
|
391
410
|
die(f"input not found: {path}")
|
|
392
411
|
ffprobe = require_tool("ffprobe")
|
|
393
412
|
proc = run(
|
|
@@ -405,6 +424,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
405
424
|
video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
|
|
406
425
|
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
407
426
|
subs = [s for s in streams if s.get("codec_type") == "subtitle"]
|
|
427
|
+
data_stream_count = sum(1 for s in streams if s.get("codec_type") in ("data", "attachment"))
|
|
408
428
|
|
|
409
429
|
duration = _to_float(fmt.get("duration"))
|
|
410
430
|
if duration is None and video:
|
|
@@ -423,6 +443,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
423
443
|
"video": None,
|
|
424
444
|
"audio": None,
|
|
425
445
|
"subtitle_streams": len(subs),
|
|
446
|
+
"data_streams": data_stream_count,
|
|
426
447
|
# every subtitle stream in file order: index n here is `-map 0:s:n`
|
|
427
448
|
"subtitle_stream_details": [{
|
|
428
449
|
"index": n,
|
|
@@ -582,15 +603,84 @@ def escape_filter_path(path: str) -> str:
|
|
|
582
603
|
return p
|
|
583
604
|
|
|
584
605
|
|
|
606
|
+
def default_font_file(font_name: str) -> Optional[str]:
|
|
607
|
+
"""Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
|
|
608
|
+
`fontfile=<path>` instead of `font=<name>`, when possible.
|
|
609
|
+
|
|
610
|
+
On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
|
|
611
|
+
resolution crashes with an access violation whenever it has to resolve a font by family name
|
|
612
|
+
-- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
|
|
613
|
+
confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
|
|
614
|
+
ignored on Windows for that reason: a fixed, near-universally-present system font is used
|
|
615
|
+
instead of trying to resolve the requested family (which would crash the same way).
|
|
616
|
+
|
|
617
|
+
On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
|
|
618
|
+
same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
|
|
619
|
+
just already resolved to a path -- fontfile= skips a redundant fontconfig lookup and equally
|
|
620
|
+
sidesteps the same class of crash if it exists on some build there too, but the fallback below
|
|
621
|
+
(returning None) is exercised routinely there, not just on failure.
|
|
622
|
+
|
|
623
|
+
Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
|
|
624
|
+
Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
|
|
625
|
+
"""
|
|
626
|
+
if platform.system() == "Windows":
|
|
627
|
+
windir = os.environ.get("WINDIR", "C:\\Windows")
|
|
628
|
+
candidate = Path(windir) / "Fonts" / "arial.ttf"
|
|
629
|
+
return str(candidate) if candidate.exists() else None
|
|
630
|
+
exe = shutil.which("fc-match")
|
|
631
|
+
if not exe:
|
|
632
|
+
return None
|
|
633
|
+
try:
|
|
634
|
+
proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
|
|
635
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
636
|
+
return None
|
|
637
|
+
if proc.returncode != 0:
|
|
638
|
+
return None
|
|
639
|
+
path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
|
|
640
|
+
return path if path and os.path.exists(path) else None
|
|
641
|
+
|
|
642
|
+
|
|
585
643
|
def escape_drawtext(text: str) -> str:
|
|
644
|
+
"""Escape `text` for use as a single-quoted drawtext option value (`text='<this>'`).
|
|
645
|
+
|
|
646
|
+
Every ffmpeg filter-graph special character (`\\ : % , [ ] ;`) needs a backslash
|
|
647
|
+
escape regardless of the surrounding quotes -- the graph parser still splits on an
|
|
648
|
+
unescaped `,`/`;` or ends an option list on an unescaped `:`/`[`/`]` even while
|
|
649
|
+
"inside" a quoted value. The quote character itself has no reliable backslash
|
|
650
|
+
escape at all: `\\'` and the POSIX shell `'\\''` close-insert-reopen trick both
|
|
651
|
+
parse fine in a simple `-vf` chain, but silently corrupt a `-filter_complex` chain
|
|
652
|
+
that uses explicit `[label]` pads -- confirmed by rendering the result: the text
|
|
653
|
+
value doesn't end where the quote closes it, and trailing option names/values
|
|
654
|
+
(fontfile=..., fontsize=...) leak into the rendered picture as literal text
|
|
655
|
+
instead of being parsed as options. A quote is therefore dropped outright rather
|
|
656
|
+
than escaped -- losing one apostrophe from a label is a fine trade for "the
|
|
657
|
+
filter graph parses the way the code intends, on every call shape this codebase
|
|
658
|
+
uses it in".
|
|
659
|
+
|
|
660
|
+
`%` has the same problem the quote character did: `\%` is not a real escape as
|
|
661
|
+
far as drawtext's own text-expansion scanner (on by default, `expansion=normal`,
|
|
662
|
+
for `%{pts}`/`%{localtime}`/etc.) is concerned -- a bare backslash-escaped `%`
|
|
663
|
+
always logs "Stray % near ..." (confirmed with the minimal case
|
|
664
|
+
`text='100\%done'`), which is merely noisy on one ffmpeg
|
|
665
|
+
build (the warning is printed, the file still gets written) but a hard filtering
|
|
666
|
+
failure that writes no output at all on another. Every caller of this function
|
|
667
|
+
only ever wants a literal label, never `%{...}` expansion, so `%` is dropped
|
|
668
|
+
outright rather than chasing a per-build-safe escape (`expansion=none` on the
|
|
669
|
+
filter would also fix it, but needs touching every drawtext= call site instead
|
|
670
|
+
of the one shared helper). Control characters (newline, tab, ...) are dropped
|
|
671
|
+
for the same reason: none are meaningful in a one-line burnt-in label, and
|
|
672
|
+
unlike the graph-special characters above, ffmpeg's own text-expansion scanner
|
|
673
|
+
-- not just the graph parser -- is involved in whether they're actually safe."""
|
|
674
|
+
text = re.sub(r"[\x00-\x1f\x7f]", "", text)
|
|
586
675
|
return (
|
|
587
|
-
text.replace("
|
|
676
|
+
text.replace("'", "")
|
|
677
|
+
.replace("%", "")
|
|
678
|
+
.replace("\\", "\\\\")
|
|
588
679
|
.replace(":", "\\:")
|
|
589
|
-
.replace("'", "\\\\\\'")
|
|
590
|
-
.replace("%", "\\%")
|
|
591
680
|
.replace(",", "\\,")
|
|
592
681
|
.replace("[", "\\[")
|
|
593
682
|
.replace("]", "\\]")
|
|
683
|
+
.replace(";", "\\;")
|
|
594
684
|
)
|
|
595
685
|
|
|
596
686
|
|
|
@@ -762,6 +852,23 @@ def color_hex(value: str) -> str:
|
|
|
762
852
|
return v.upper()
|
|
763
853
|
|
|
764
854
|
|
|
855
|
+
_COLOR_TOKEN_RE = re.compile(r"^(0[xX][0-9A-Fa-f]{6,8}|#[0-9A-Fa-f]{6,8}|[A-Za-z][A-Za-z0-9]*)(@[0-9.]+)?$")
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def validate_color(value: str, flag: str = "--color") -> str:
|
|
859
|
+
"""Refuse a colour argument that isn't a plain ffmpeg colour token (named colour, 0xRRGGBB[AA],
|
|
860
|
+
#RRGGBB[AA], optionally with an @alpha suffix). Every caller that string-formats a colour flag
|
|
861
|
+
straight into a filter graph (color=c=..., tpad=...:color=..., rotate=...:fillcolor=...) must
|
|
862
|
+
validate it first -- ffmpeg filter options are comma/colon-delimited, so an unvalidated value
|
|
863
|
+
containing those characters lets a caller splice in an entirely different filter (a real,
|
|
864
|
+
demonstrated filter-graph injection: --color "black,drawtext=text=..." renders arbitrary burnt-in
|
|
865
|
+
text), not just an odd colour. This is the same "no filter graph accepted from the caller"
|
|
866
|
+
invariant every other typed flag in this codebase already holds to."""
|
|
867
|
+
if not _COLOR_TOKEN_RE.match(value):
|
|
868
|
+
die(f"{flag} must be a plain colour (a name, 0xRRGGBB[AA], or #RRGGBB[AA], optionally @alpha), got '{value}'")
|
|
869
|
+
return value
|
|
870
|
+
|
|
871
|
+
|
|
765
872
|
def print_json(obj: Any) -> None:
|
|
766
873
|
sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
|
|
767
874
|
|
package/scripts/_contract.py
CHANGED
|
@@ -79,6 +79,42 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
79
79
|
"crop": dict(role="execution", inputs=["video asset"], outputs=["video artifact cropped to the given pixel rectangle"],
|
|
80
80
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
81
81
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
82
|
+
"deinterlace": dict(role="execution", inputs=["video asset"], outputs=["deinterlaced (progressive) video artifact"],
|
|
83
|
+
required=FF + [X264, AAC, "filter:yadif"], optional=[HDR_X265],
|
|
84
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
85
|
+
"denoise": dict(role="execution", inputs=["video asset"], outputs=["denoised video artifact"],
|
|
86
|
+
required=FF + [X264, AAC, "filter:hqdn3d"], optional=[HDR_X265],
|
|
87
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
88
|
+
"cropdetect": dict(role="analysis", inputs=["video asset"], outputs=["detected crop rectangle JSON on stdout (no file)"],
|
|
89
|
+
required=FF + ["filter:cropdetect"], optional=[],
|
|
90
|
+
video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=False, idempotency="environment_dependent", deterministic=False),
|
|
91
|
+
"redact": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the given pixel rectangle blurred or pixelated"],
|
|
92
|
+
required=FF + [X264, AAC, "filter:boxblur"], optional=[HDR_X265],
|
|
93
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
94
|
+
"waveform": dict(role="execution", inputs=["audio or video asset (audio track only)"], outputs=["generated waveform or spectrum visualization video artifact"],
|
|
95
|
+
required=FF + [X264, AAC, "filter:showwaves"], optional=[{"capability": "filter:showspectrum", "when": "--style spectrum"}],
|
|
96
|
+
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
97
|
+
"sphere": dict(role="execution", inputs=["360/spherical video asset"], outputs=["flat rectilinear video artifact of the chosen viewport"],
|
|
98
|
+
required=FF + [X264, AAC, "filter:v360"], optional=[HDR_X265],
|
|
99
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
100
|
+
"grid": dict(role="execution", inputs=["cols*rows video assets, filled left-to-right top-to-bottom"], outputs=["composited grid video artifact"],
|
|
101
|
+
required=FF + [X264, "filter:xstack"], optional=[{"capability": "filter:drawtext", "when": "--label auto (the default)"}, {"capability": AAC, "when": "--audio-from"}],
|
|
102
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
103
|
+
"straighten": dict(role="execution", inputs=["video asset"], outputs=["video artifact rotated by the given angle (horizon correction)"],
|
|
104
|
+
required=FF + [X264, AAC, "filter:rotate"], optional=[HDR_X265],
|
|
105
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
106
|
+
"freeze": dict(role="execution", inputs=["video asset"], outputs=["video artifact with a frame held for the given duration"],
|
|
107
|
+
required=FF + [X264, AAC, "filter:tpad"], optional=[HDR_X265],
|
|
108
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
109
|
+
"pad": dict(role="execution", inputs=["video asset"], outputs=["video artifact with black/silent padding added at the start/end"],
|
|
110
|
+
required=FF + [X264, AAC, "filter:tpad"], optional=[HDR_X265],
|
|
111
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
112
|
+
"speedramp": dict(role="execution", inputs=["video asset"], outputs=["video artifact with a stepped speed ramp applied across segments"],
|
|
113
|
+
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
114
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
115
|
+
"loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration"],
|
|
116
|
+
required=FF + [X264, AAC], optional=[],
|
|
117
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
82
118
|
"insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
|
|
83
119
|
required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
|
|
84
120
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
@@ -111,29 +147,32 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
111
147
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
112
148
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
113
149
|
"audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
|
|
114
|
-
required=FF
|
|
115
|
-
|
|
150
|
+
required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
|
|
151
|
+
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"},
|
|
152
|
+
{"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
|
|
116
153
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
117
154
|
"loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
|
|
118
|
-
required=FF + ["filter:loudnorm", AAC
|
|
155
|
+
required=FF + ["filter:loudnorm"], optional=[{"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
|
|
119
156
|
video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
120
157
|
"silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
|
|
121
158
|
required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"}] + AUDIO_OUT,
|
|
122
159
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
123
160
|
"join": dict(role="execution", inputs=["two or more video assets, or two or more audio-only assets"], outputs=["concatenated video artifact", "concatenated audio artifact (audio-only inputs, audio output extension)"],
|
|
124
|
-
required=FF + [
|
|
161
|
+
required=FF + ["filter:xfade", "filter:acrossfade"],
|
|
162
|
+
optional=[{"capability": X264, "when": "joining video inputs"}, {"capability": AAC, "when": "joining video inputs, or an audio-only join whose output extension isn't .mp3/.opus/.ogg/.flac"}, HDR_X265] + AUDIO_OUT,
|
|
125
163
|
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
126
164
|
"color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
|
|
127
165
|
required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
|
|
128
166
|
{"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
|
|
129
167
|
{"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
|
|
130
|
-
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}
|
|
168
|
+
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"},
|
|
169
|
+
{"capability": "filter:colorlevels", "when": "--correct with any --levels-*"}, {"capability": "filter:curves", "when": "--correct --curves"}],
|
|
131
170
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
132
171
|
"proxy": dict(role="execution", inputs=["video asset"], outputs=["low-resolution, low-bitrate proxy artifact for downstream analysis, preview or editing decisions"],
|
|
133
172
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
134
173
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
135
174
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
136
|
-
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "
|
|
175
|
+
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
|
|
137
176
|
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
|
|
138
177
|
{"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}],
|
|
139
178
|
video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
@@ -168,6 +207,7 @@ DRY_RUN_ANALYSIS = {
|
|
|
168
207
|
"multicam": "audio is decoded to align the cameras; the switched output is not written",
|
|
169
208
|
"scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
|
|
170
209
|
"report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
|
|
210
|
+
"cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
|
|
171
211
|
}
|
|
172
212
|
DRY_RUN_NOTES = {
|
|
173
213
|
"probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
|
|
@@ -188,6 +228,18 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
188
228
|
"cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, a VFR source, or a keyframe snap past --tolerance (see cut.py --json: mode, keyframe_snapped)"),
|
|
189
229
|
"fit": dict(video="always", audio="always", note="always re-encodes to AAC when audio is present, even if only --fps or --aspect was asked for"),
|
|
190
230
|
"crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
|
|
231
|
+
"deinterlace": dict(video="always", audio="always", note="the yadif filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
232
|
+
"denoise": dict(video="always", audio="always", note="the hqdn3d filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
233
|
+
"cropdetect": dict(video="never", audio="never", note="analysis only, no artifact"),
|
|
234
|
+
"redact": dict(video="always", audio="always", note="the boxblur/pixelate filter_complex always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
235
|
+
"waveform": dict(video="always", audio="always", note="always encodes a fresh generated visualization clip; the source audio is always re-encoded to AAC"),
|
|
236
|
+
"sphere": dict(video="always", audio="always", note="the v360 filter always forces a re-encode of the video stream; audio is passed through to AAC unchanged"),
|
|
237
|
+
"straighten": dict(video="always", audio="always", note="the rotate filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
238
|
+
"grid": dict(video="always", audio="conditional", note="the xstack composite always forces a re-encode of the video stream; there is no audio at all unless --audio-from picks one input's track, which is then re-encoded to AAC"),
|
|
239
|
+
"freeze": dict(video="always", audio="always", note="the tpad/concat filter graph always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
240
|
+
"pad": dict(video="always", audio="always", note="the tpad filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
241
|
+
"speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
|
|
242
|
+
"loop": dict(video="always", audio="always", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present"),
|
|
191
243
|
"insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
|
|
192
244
|
"background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
|
|
193
245
|
"reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
|
|
@@ -482,6 +534,50 @@ def _default_font() -> str:
|
|
|
482
534
|
return str(BRAND_DEFAULTS["font"])
|
|
483
535
|
|
|
484
536
|
|
|
537
|
+
def _drawtext_probe() -> Dict[str, Any]:
|
|
538
|
+
"""Actually render one frame through drawtext, rather than trusting `-filters` alone.
|
|
539
|
+
|
|
540
|
+
`-filters` only reports whether this ffmpeg build was compiled with the filter; it never
|
|
541
|
+
proves drawtext can actually execute. On some real Windows ffmpeg builds (winget's gyan.dev
|
|
542
|
+
9.x), drawtext crashes with an access violation whenever it has to resolve a font through
|
|
543
|
+
fontconfig -- with or without a valid fonts.conf -- so `-filters` correctly reports drawtext
|
|
544
|
+
present and doctor used to report the capability `available` anyway; every tool that actually
|
|
545
|
+
used it (look, scenes --sheet, overlay --text, graphics) then crashed on first real use (#100).
|
|
546
|
+
|
|
547
|
+
This runs the cheapest real drawtext render there is: a one-frame synthetic clip, no font=
|
|
548
|
+
given at all (ffmpeg's own default resolution -- the same path that crashed). A clean exit
|
|
549
|
+
means drawtext genuinely works here. Anything that could not prove either way (no ffmpeg,
|
|
550
|
+
timeout, an ordinary nonzero exit with a real ffmpeg error) is `unknown`, same "unknown is not
|
|
551
|
+
missing" principle as every other capability here. A crash specifically -- killed by signal on
|
|
552
|
+
POSIX, or an unhandled access violation surfacing as a huge unsigned exit code on Windows -- is
|
|
553
|
+
the one case this function exists to catch, and folds into `missing`: the filter is present in
|
|
554
|
+
the build but cannot actually be used as ffmpeg's own default would use it.
|
|
555
|
+
"""
|
|
556
|
+
exe = shutil.which("ffmpeg")
|
|
557
|
+
if not exe:
|
|
558
|
+
return {"status": "unknown", "detail": "ffmpeg not on PATH"}
|
|
559
|
+
try:
|
|
560
|
+
proc = subprocess.run(
|
|
561
|
+
[exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
|
|
562
|
+
"-vf", "drawtext=text=x", "-frames:v", "1", "-f", "null", "-"],
|
|
563
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT,
|
|
564
|
+
)
|
|
565
|
+
except subprocess.TimeoutExpired:
|
|
566
|
+
return {"status": "unknown", "detail": f"drawtext probe did not exit within {_DETECT_TIMEOUT}s"}
|
|
567
|
+
except OSError as e:
|
|
568
|
+
return {"status": "unknown", "detail": f"drawtext probe: {e}"}
|
|
569
|
+
if proc.returncode == 0:
|
|
570
|
+
return {"status": "available", "detail": "one-frame drawtext render succeeded"}
|
|
571
|
+
if proc.returncode < 0 or proc.returncode >= 0x80000000:
|
|
572
|
+
return {"status": "missing",
|
|
573
|
+
"detail": f"drawtext render crashed (exit {proc.returncode}) instead of failing cleanly -- "
|
|
574
|
+
"the filter is present in this build but cannot be used as-is, likely a fontconfig "
|
|
575
|
+
"resolution crash (see https://github.com/kajisho5/ffmpeg-skill/issues/100); "
|
|
576
|
+
"pass an explicit --font-file to every drawtext tool as a workaround"}
|
|
577
|
+
tail = " ".join(proc.stderr.strip().splitlines()[-2:])
|
|
578
|
+
return {"status": "unknown", "detail": f"drawtext probe exited {proc.returncode}: {tail}"}
|
|
579
|
+
|
|
580
|
+
|
|
485
581
|
def _font_available(font_name: str) -> Dict[str, Any]:
|
|
486
582
|
"""Whether `font_name` (a fontconfig family name, as passed to drawtext's `font=`) is actually
|
|
487
583
|
installed, distinct from silently resolving to a substitute.
|
|
@@ -561,6 +657,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
561
657
|
sets = {k: set(v["names"]) for k, v in listings.items()}
|
|
562
658
|
state: Dict[str, str] = {} # capability -> available | missing | unknown
|
|
563
659
|
wanted = required_capabilities()
|
|
660
|
+
drawtext_probe: Optional[Dict[str, Any]] = None
|
|
564
661
|
|
|
565
662
|
def _from(kind: str, name: str) -> str:
|
|
566
663
|
lst = listings[kind]
|
|
@@ -577,6 +674,23 @@ def doctor() -> Dict[str, Any]:
|
|
|
577
674
|
state[cap] = "available" if shutil.which("ffprobe") else "missing"
|
|
578
675
|
elif cap.startswith("encoder:"):
|
|
579
676
|
state[cap] = _from("encoders", cap[8:])
|
|
677
|
+
elif cap == "filter:drawtext":
|
|
678
|
+
listing_state = _from("filters", "drawtext")
|
|
679
|
+
if listing_state == "available":
|
|
680
|
+
# Only escalate an "available" listing to "missing" on an unambiguous crash --
|
|
681
|
+
# an ordinary nonzero exit (a real ffmpeg's own -h/-filters-only build variance, or
|
|
682
|
+
# in tests a fake ffmpeg shim that only implements -filters/-encoders/-bsfs/-version)
|
|
683
|
+
# proves nothing either way, so it leaves the listing-based result standing rather
|
|
684
|
+
# than downgrading it; see _drawtext_probe()'s own docstring for why a crash alone
|
|
685
|
+
# is the one case this exists to catch.
|
|
686
|
+
probe = _drawtext_probe()
|
|
687
|
+
if probe["status"] == "missing":
|
|
688
|
+
drawtext_probe = probe
|
|
689
|
+
state[cap] = "missing"
|
|
690
|
+
else:
|
|
691
|
+
state[cap] = "available"
|
|
692
|
+
else:
|
|
693
|
+
state[cap] = listing_state
|
|
580
694
|
elif cap.startswith("filter:"):
|
|
581
695
|
state[cap] = _from("filters", cap[7:])
|
|
582
696
|
elif cap.startswith("bsf:"):
|
|
@@ -591,6 +705,8 @@ def doctor() -> Dict[str, Any]:
|
|
|
591
705
|
unknown = sorted(c for c, st in state.items() if st == "unknown")
|
|
592
706
|
unknown_required = [c for c in unknown if c in wanted["required"]]
|
|
593
707
|
errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
|
|
708
|
+
if drawtext_probe is not None and drawtext_probe["status"] != "available":
|
|
709
|
+
errors.append(f"filter:drawtext: {drawtext_probe['detail']}")
|
|
594
710
|
return {
|
|
595
711
|
"version": skill_version(),
|
|
596
712
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
@@ -630,6 +746,12 @@ def _capability_fix_hint(cap: str) -> str:
|
|
|
630
746
|
full_hint = "install/build ffmpeg with it enabled"
|
|
631
747
|
if cap.startswith("encoder:"):
|
|
632
748
|
return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
|
|
749
|
+
if cap == "filter:drawtext":
|
|
750
|
+
return ("drawtext crashed instead of rendering a frame (see errors[] for the exit detail) -- "
|
|
751
|
+
"every drawtext tool already resolves a concrete font file automatically when one can "
|
|
752
|
+
"be found (#100); if it still crashes, use --no-timecode with look.py or scenes.py "
|
|
753
|
+
"--sheet to skip drawtext entirely, or pass --font-file explicitly to overlay.py/"
|
|
754
|
+
"graphics.py (the two that accept it) rather than relying on font= resolution")
|
|
633
755
|
if cap.startswith("filter:"):
|
|
634
756
|
return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
|
|
635
757
|
if cap.startswith("bsf:"):
|
package/scripts/background.py
CHANGED
|
@@ -14,7 +14,7 @@ import argparse
|
|
|
14
14
|
import math
|
|
15
15
|
import sys
|
|
16
16
|
|
|
17
|
-
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
|
|
17
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def main() -> int:
|
|
@@ -49,11 +49,20 @@ def main() -> int:
|
|
|
49
49
|
c0, c1 = args.gradient.split(":")
|
|
50
50
|
except ValueError:
|
|
51
51
|
die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
|
|
52
|
+
validate_color(c0, "--gradient")
|
|
53
|
+
validate_color(c1, "--gradient")
|
|
52
54
|
rad = math.radians(args.angle)
|
|
53
55
|
x1 = round(args.width * math.cos(rad))
|
|
54
56
|
y1 = round(args.width * math.sin(rad))
|
|
55
|
-
|
|
57
|
+
# gradients defaults to seed=-1 (a random seed picked fresh each run) and speed=0.01 (a
|
|
58
|
+
# slow rotation applied every frame), so without pinning both, this "static" background
|
|
59
|
+
# was neither reproducible between runs nor actually static across its own duration --
|
|
60
|
+
# violating the bit_exact/deterministic contract _contract.py declares for this tool.
|
|
61
|
+
# speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
|
|
62
|
+
# motionless the filter allows.
|
|
63
|
+
src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}:seed=0:speed=1e-05"
|
|
56
64
|
else:
|
|
65
|
+
validate_color(args.color, "--color")
|
|
57
66
|
src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
|
|
58
67
|
|
|
59
68
|
output = args.output
|
package/scripts/batch.py
CHANGED
|
@@ -36,6 +36,13 @@ from _common import STATE, add_common, apply_common, die, emit, info
|
|
|
36
36
|
|
|
37
37
|
HERE = Path(__file__).resolve().parent
|
|
38
38
|
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
|
|
39
|
+
# recipe steps name the script to run as plain, untrusted JSON -- run_step() joins it onto HERE
|
|
40
|
+
# with the `/` operator, which silently ignores the left side when the right side is itself an
|
|
41
|
+
# absolute path (Path("/scripts") / "/tmp/evil.py" == Path("/tmp/evil.py")), and does nothing to
|
|
42
|
+
# stop a "../" traversal either. Without this allowlist, a batch.json a caller didn't author
|
|
43
|
+
# themselves (from a template, a shared config, anywhere) could name any Python file on disk and
|
|
44
|
+
# have it executed with the caller's own privileges on every matching media file.
|
|
45
|
+
ALLOWED_STEP_SCRIPTS = {p.name for p in HERE.glob("*.py") if not p.name.startswith("_")}
|
|
39
46
|
|
|
40
47
|
|
|
41
48
|
def file_key(path: Path) -> str:
|
|
@@ -51,11 +58,27 @@ def file_key(path: Path) -> str:
|
|
|
51
58
|
|
|
52
59
|
|
|
53
60
|
def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
54
|
-
|
|
61
|
+
# A "project" recipe is just {"project": "<path>", "clip_key": N} -- the actual settings
|
|
62
|
+
# (export preset, captions, everything) live in the file at that path, not in this dict.
|
|
63
|
+
# Hashing only `recipe` meant editing project.json's content (without touching batch.json
|
|
64
|
+
# itself) left the key, and so every cache hit, unchanged: a preset swapped from "copy" to
|
|
65
|
+
# "x" (a real re-encode) still served the old cached output. Fold the referenced file's own
|
|
66
|
+
# content into the key so a content change invalidates the cache like any other edit would.
|
|
67
|
+
project_content = ""
|
|
68
|
+
if recipe.get("project"):
|
|
69
|
+
try:
|
|
70
|
+
project_content = Path(recipe["project"]).read_text(encoding="utf-8")
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
|
|
55
74
|
|
|
56
75
|
|
|
57
76
|
def run_step(argv: List[str]) -> bool:
|
|
58
|
-
|
|
77
|
+
script = argv[0]
|
|
78
|
+
if script not in ALLOWED_STEP_SCRIPTS:
|
|
79
|
+
die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
|
|
80
|
+
f"(must be a bare filename like 'silence.py', found in scripts/)")
|
|
81
|
+
cmd = [sys.executable, str(HERE / script)] + argv[1:]
|
|
59
82
|
if STATE["fast"]:
|
|
60
83
|
cmd.append("--fast")
|
|
61
84
|
if STATE["dry_run"]:
|
|
@@ -68,10 +91,20 @@ def run_step(argv: List[str]) -> bool:
|
|
|
68
91
|
return True
|
|
69
92
|
|
|
70
93
|
|
|
71
|
-
def
|
|
94
|
+
def final_path(src: Path, recipe: Dict[str, Any], outdir: Path) -> Path:
|
|
72
95
|
suffix = recipe.get("suffix", "_out")
|
|
96
|
+
# By default final_ext falls back to each source's OWN extension, so files that only differ
|
|
97
|
+
# by extension don't collide -- but a recipe that fixes "ext" (e.g. converting a folder of
|
|
98
|
+
# mixed .mp4/.mov masters to one format) makes every source with the same stem land on the
|
|
99
|
+
# same final path, e.g. clip.mp4 and clip.mov both -> clip_out.mp4. process() has no collision
|
|
100
|
+
# detection of its own; see one_pass()'s pre-flight check, which uses this same computation
|
|
101
|
+
# to catch that before any file is actually processed (and the earlier one silently clobbered).
|
|
73
102
|
final_ext = recipe.get("ext") or src.suffix.lstrip(".") or "mp4"
|
|
74
|
-
|
|
103
|
+
return outdir / f"{src.stem}{suffix}.{final_ext}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict[str, Any]:
|
|
107
|
+
final = final_path(src, recipe, outdir)
|
|
75
108
|
t0 = time.time()
|
|
76
109
|
if recipe.get("project"):
|
|
77
110
|
proj = json.loads(Path(recipe["project"]).read_text(encoding="utf-8"))
|
|
@@ -140,6 +173,18 @@ def main() -> int:
|
|
|
140
173
|
def one_pass() -> List[Dict[str, Any]]:
|
|
141
174
|
results = []
|
|
142
175
|
files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
|
|
176
|
+
# Two different sources can compute the same final path (most often a fixed recipe "ext"
|
|
177
|
+
# collapsing e.g. clip.mp4 and clip.mov to the same clip_out.mp4) -- catch that before
|
|
178
|
+
# processing anything, rather than letting the later one silently overwrite the earlier
|
|
179
|
+
# one's finished output with the cache still recording both as "ok".
|
|
180
|
+
by_final: Dict[Path, List[Path]] = {}
|
|
181
|
+
for src in files:
|
|
182
|
+
by_final.setdefault(final_path(src, recipe, outdir), []).append(src)
|
|
183
|
+
collisions = {dst: srcs for dst, srcs in by_final.items() if len(srcs) > 1}
|
|
184
|
+
if collisions:
|
|
185
|
+
detail = "; ".join(f"{dst.name} <- {', '.join(s.name for s in srcs)}" for dst, srcs in collisions.items())
|
|
186
|
+
die(f"{len(collisions)} output filename collision(s) in this batch -- rename the sources, "
|
|
187
|
+
f"or add a distinguishing \"suffix\"/\"ext\" per run, or split into separate globs: {detail}")
|
|
143
188
|
for src in files:
|
|
144
189
|
key = f"{file_key(src)}:{rkey}"
|
|
145
190
|
hit = cache.get(key)
|
|
@@ -152,7 +197,15 @@ def main() -> int:
|
|
|
152
197
|
results.append(r)
|
|
153
198
|
if r["ok"] and not STATE["dry_run"]:
|
|
154
199
|
cache[key] = r
|
|
155
|
-
|
|
200
|
+
# write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
|
|
201
|
+
# a concurrent manual run) could leave a truncated file that json.loads() above
|
|
202
|
+
# then silently treats as "no cache" (a ValueError -> {}), discarding every prior
|
|
203
|
+
# entry. Write to a sibling temp file and rename into place: same-directory
|
|
204
|
+
# renames are atomic on POSIX and os.replace() is atomic on Windows too, so a
|
|
205
|
+
# reader only ever sees the old complete file or the new complete file.
|
|
206
|
+
tmp = cache_path.parent / f"{cache_path.name}.tmp{os.getpid()}"
|
|
207
|
+
tmp.write_text(json.dumps(cache, indent=2), encoding="utf-8")
|
|
208
|
+
os.replace(tmp, cache_path)
|
|
156
209
|
return results
|
|
157
210
|
|
|
158
211
|
results = one_pass()
|