ffmpeg-skill 3.5.0 → 3.6.1
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 +5 -4
- package/package.json +1 -1
- package/references/scripts.md +19 -1
- package/scripts/_ass_overlay.py +1 -1
- package/scripts/_common/color.py +10 -3
- package/scripts/_common/decision.py +9 -1
- package/scripts/_common/emit.py +23 -1
- package/scripts/_common/runner.py +132 -15
- package/scripts/_contract.py +7 -3
- package/scripts/batch.py +10 -8
- package/scripts/caption.py +2 -2
- package/scripts/export.py +2 -2
- package/scripts/render.py +73 -4
- package/scripts/silence.py +133 -8
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.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.6.1`) | 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.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.6.1", "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"},
|
|
@@ -752,7 +752,8 @@ silence a tool reported as fine while another measurement disagreed):
|
|
|
752
752
|
| `verification[]` step `duration` | `export.py` (a preset with a platform maximum, without `--allow-long`) | the written file's length against the maximum, measured as `check.py` measures it (the container's duration, `tolerance` 0). A length that cannot be read fails the step with `reason: not_measured` |
|
|
753
753
|
| `verification[]` step `duration` (`property: audio_duration`) | `loudness.py` | the output's sound against the input's, to one AAC frame (at least 0.03 s): a levels pass changes no timing. A length that cannot be read fails the step with `reason: not_measured` |
|
|
754
754
|
| `inputs_duration`, `transition_shortening`; step `duration`'s `inputs_total` | `join.py` | the clips' own total and how much the transitions took out of it. `expected_duration` is derived from the transition the join applied, so it cannot show that on its own |
|
|
755
|
-
| `margins` | `silence.py` | `{lead, tail}`: the seconds of each silence kept before speech starts (`--margin-lead`) and after it ends (`--margin-tail`); both equal `--margin` unless one is named. Absent under `--filler`
|
|
755
|
+
| `margins` | `silence.py` | `{lead, tail}`: the seconds of each silence kept before speech starts (`--margin-lead`) and after it ends (`--margin-tail`); both equal `--margin` unless one is named. Absent under `--filler` with neither `--speech-aware` nor a sentence flag, which removes no silences |
|
|
756
|
+
| `silence_positions`, `sentence` | `silence.py --sentence-min-silence` / `--sentence-margin-tail` | per entry of `silences`, `sentence_end` when the transcript word before it ends in sentence-final punctuation, else `inside`; and `{min_silence, margin_tail, source, word_timings, sentence_end_words, silences}`. `sentence_end_words: 0` means the transcript had no such punctuation, so every silence used `--min-silence` and `--margin-tail` (warning `sentence_ends_not_found`) |
|
|
756
757
|
| `silence_kinds`, `silence_breakdown` | `silence.py` | per detected silence, `digital` (at or below -90 dBFS) or `below_threshold` (quiet sound under `--threshold`), and the count and seconds of each with the level that defines it |
|
|
757
758
|
|
|
758
759
|
Keys added after 2.21, all additive (#367 follow-up, and a report of a filename with a space that
|
|
@@ -896,7 +897,7 @@ here claims otherwise until real exit-code/stderr sniffing exists to back that u
|
|
|
896
897
|
A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
897
898
|
(`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
|
|
898
899
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
899
|
-
`media_path_is_url
|
|
900
|
+
`media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
|
|
900
901
|
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
901
902
|
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
|
902
903
|
`encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.1",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/references/scripts.md
CHANGED
|
@@ -697,7 +697,8 @@ each shorter clip's last frame (with silence) out to the longest.
|
|
|
697
697
|
|
|
698
698
|
### silence.py — remove dead air / jump cuts
|
|
699
699
|
```
|
|
700
|
-
silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.2] [--margin-lead S] [--margin-tail S] [--min-keep 0.2] [--list]
|
|
700
|
+
silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.2] [--margin-lead S] [--margin-tail S] [--min-keep 0.2] [--list]
|
|
701
|
+
[--sentence-min-silence S] [--sentence-margin-tail S] (with --words FILE or --transcribe) [--edl keep.txt] [--timemap map.json|map.csv] [--speech-aware] [-o OUT]
|
|
701
702
|
silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|zh|es|de|fr|pt|it|nl|pl|ru|tr]
|
|
702
703
|
[--filler-words FILE] [--filler-extra W,W] [--filler-keep W,W] [--filler-pad 0.02]
|
|
703
704
|
[--transcribe] [--engine auto|parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper]
|
|
@@ -721,6 +722,23 @@ is mostly breath wants more tail than the next word wants lead (`--margin-lead 0
|
|
|
721
722
|
breaths inside a sentence and cuts only at sentence-boundary pauses. A `--threshold` set too high
|
|
722
723
|
(-25, say) counts a weak word ending as silence: the result then warns.
|
|
723
724
|
|
|
725
|
+
**Pauses inside a sentence and after one.** With word timings (`--words FILE` or `--transcribe`),
|
|
726
|
+
`--sentence-min-silence S` and `--sentence-margin-tail S` give a silence that follows a sentence
|
|
727
|
+
end its own floor and its own tail; every other silence keeps `--min-silence` and `--margin-tail`.
|
|
728
|
+
A sentence end is a transcript word whose text ends in `. ! ? 。 ! ? …` (also `।` and `؟`), read
|
|
729
|
+
from the transcript and never from the sound; the word before a silence is the last one
|
|
730
|
+
whose own midpoint lies before the silence's midpoint, which holds when the engine stretched a
|
|
731
|
+
word's timing into the pause. `--words talk.json --min-silence 0.2 --sentence-min-silence
|
|
732
|
+
0.3 --margin 0.1 --sentence-margin-tail 0.3` tightens pauses inside a sentence from 0.2 s and
|
|
733
|
+
after one from 0.3 s, leaving the sentence end more room. The result lists `silence_positions`
|
|
734
|
+
(`sentence_end` or `inside`, one per entry of `silences`) and `sentence` (`min_silence`,
|
|
735
|
+
`margin_tail`, `source`, `word_timings`, `sentence_end_words`, `silences`). With no such punctuation
|
|
736
|
+
in the transcript no silence is a sentence end: every one uses `--min-silence` and `--margin-tail`,
|
|
737
|
+
and the run warns `sentence_ends_not_found`. Not with `--speech-aware`,
|
|
738
|
+
which takes a sentence boundary from the pause's length instead; without a transcript the flags
|
|
739
|
+
are refused. With `--filler` they turn silence removal on as well (`--filler` alone removes
|
|
740
|
+
only the words), reading the same transcript.
|
|
741
|
+
|
|
724
742
|
**Cut levels (2.22).** Every run measures the RMS level 50 ms either side of each cut on the
|
|
725
743
|
source (`cut_points`: `source_time`, `output_time`, `edge` out/in, `rms_dbfs` of the loudest
|
|
726
744
|
channel, `null` for digital silence; the whole key is `null`, with a `cut_levels_unmeasured`
|
package/scripts/_ass_overlay.py
CHANGED
|
@@ -54,7 +54,7 @@ def ass_time(sec: float) -> str:
|
|
|
54
54
|
|
|
55
55
|
def ass_color(hex_rgb: str, alpha: int = 0) -> str:
|
|
56
56
|
h = str(hex_rgb).lstrip("#")
|
|
57
|
-
if len(h) != 6:
|
|
57
|
+
if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
|
|
58
58
|
raise ValueError(f"colour must be RRGGBB hex, got '{hex_rgb}'")
|
|
59
59
|
return f"&H{alpha:02X}{h[4:6]}{h[2:4]}{h[0:2]}".upper()
|
|
60
60
|
|
package/scripts/_common/color.py
CHANGED
|
@@ -312,13 +312,20 @@ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
|
|
|
312
312
|
return out[:at] + ["-vf", conv] + out[at:]
|
|
313
313
|
|
|
314
314
|
|
|
315
|
+
_HEX6 = re.compile(r"^[0-9A-Fa-f]{6}$")
|
|
316
|
+
|
|
317
|
+
|
|
315
318
|
def color_hex(value: str) -> str:
|
|
316
|
-
"""Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'.
|
|
319
|
+
"""Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'. Six hex digits only: the result is
|
|
320
|
+
written into libass styles and drawtext/color options, so a six-letter colour name ("yellow")
|
|
321
|
+
used to pass the length check and draw black, reported verified, and a quote or comma reached
|
|
322
|
+
the filter graph."""
|
|
317
323
|
v = str(value).strip().lstrip("#")
|
|
318
324
|
if v.lower().startswith("0x"):
|
|
319
325
|
v = v[2:]
|
|
320
|
-
if
|
|
321
|
-
die(f"colour must be RRGGBB, got '{value}'"
|
|
326
|
+
if not _HEX6.match(v):
|
|
327
|
+
die(f"colour must be six hex digits RRGGBB (e.g. FFD200 or #ffd200), got '{value}'; colour names are not "
|
|
328
|
+
"accepted here")
|
|
322
329
|
return v.upper()
|
|
323
330
|
|
|
324
331
|
|
|
@@ -148,7 +148,15 @@ def concat_list_line(path: str) -> str:
|
|
|
148
148
|
"""One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
|
|
149
149
|
inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
|
|
150
150
|
with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
|
|
151
|
-
name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py.
|
|
151
|
+
name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py.
|
|
152
|
+
|
|
153
|
+
A line break has no escape in this format: the demuxer reads each line as its own directive,
|
|
154
|
+
so a name containing one would add `file` entries to the list and pull in files the caller
|
|
155
|
+
never named. Such a path is refused."""
|
|
156
|
+
if "\n" in str(path) or "\r" in str(path):
|
|
157
|
+
refuse("input", "media_path_has_line_break",
|
|
158
|
+
f"{str(path)!r} contains a line break, which ffmpeg's concat list cannot hold (each line is a "
|
|
159
|
+
"directive of its own); rename the file", path=str(path))
|
|
152
160
|
# only on Windows: on POSIX the quoted form takes a backslash literally, and it is part of the name (#408)
|
|
153
161
|
escaped = (str(path).replace("\\", "/") if _on_windows() else str(path)).replace("'", "'\\''")
|
|
154
162
|
return f"file '{escaped}'"
|
package/scripts/_common/emit.py
CHANGED
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
import json
|
|
9
9
|
import math
|
|
10
10
|
import os
|
|
11
|
+
import stat
|
|
11
12
|
import sys
|
|
12
13
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
13
14
|
from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
|
|
@@ -319,7 +320,28 @@ def audit(event: str, details: Dict[str, Any], *, default_path: Optional[str] =
|
|
|
319
320
|
rec["plan_sha256"] = plan_sha256
|
|
320
321
|
rec["details"] = details
|
|
321
322
|
try:
|
|
322
|
-
|
|
323
|
+
# a default path is a hidden name in someone's output directory: never appended to through a
|
|
324
|
+
# symlink planted there. A log the caller named (--audit-log) is opened as named.
|
|
325
|
+
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | (0 if ctx.audit_log else getattr(os, "O_NOFOLLOW", 0))
|
|
326
|
+
checked = not ctx.audit_log and not hasattr(os, "O_NOFOLLOW")
|
|
327
|
+
if checked and os.path.islink(path):
|
|
328
|
+
raise OSError(f"{path} is a symbolic link")
|
|
329
|
+
fd = os.open(path, flags, 0o666)
|
|
330
|
+
if checked:
|
|
331
|
+
# no O_NOFOLLOW (Windows): a link that appeared between the look above and the open is
|
|
332
|
+
# caught after it -- the descriptor must be the file now at that name, not a link's
|
|
333
|
+
# target -- and nothing is written through it
|
|
334
|
+
try:
|
|
335
|
+
st, at = os.fstat(fd), os.lstat(path)
|
|
336
|
+
# st_nlink: a link swapped for a hard link to its target before the lstat would
|
|
337
|
+
# otherwise pass the identity check; a log this tool created has one name
|
|
338
|
+
if (stat.S_ISLNK(at.st_mode) or (st.st_dev, st.st_ino) != (at.st_dev, at.st_ino)
|
|
339
|
+
or not stat.S_ISREG(st.st_mode) or st.st_nlink != 1):
|
|
340
|
+
raise OSError(f"{path} was replaced by a link while it was opened")
|
|
341
|
+
except BaseException:
|
|
342
|
+
os.close(fd)
|
|
343
|
+
raise
|
|
344
|
+
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
|
323
345
|
f.write(json.dumps(json_safe(rec), ensure_ascii=False, sort_keys=True) + "\n")
|
|
324
346
|
except OSError as exc:
|
|
325
347
|
warn(f"could not append the {event} record to the audit log {path} ({exc}); the run is unaffected",
|
|
@@ -775,21 +775,69 @@ _URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
|
|
|
775
775
|
_URL_SCHEME_POSIX = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
776
776
|
|
|
777
777
|
|
|
778
|
+
def _is_subfile_url(value: str) -> bool:
|
|
779
|
+
"""ffmpeg's url_find_protocol() also opens `subfile,<options>:<url>` through the subfile
|
|
780
|
+
protocol: the scheme stops at the comma, so the regexes above never see it."""
|
|
781
|
+
return value.startswith("subfile,") and ":" in value[len("subfile,"):]
|
|
782
|
+
|
|
783
|
+
|
|
778
784
|
def looks_like_url(value: str) -> bool:
|
|
779
785
|
"""True for a value ffmpeg would open through a protocol rather than as a local file."""
|
|
786
|
+
if _is_subfile_url(value):
|
|
787
|
+
return True
|
|
780
788
|
scheme = _URL_SCHEME if os.name == "nt" else _URL_SCHEME_POSIX
|
|
781
789
|
return bool(scheme.match(value)) and not value.startswith("pipe:")
|
|
782
790
|
|
|
783
791
|
|
|
784
|
-
|
|
792
|
+
def url_scheme(value: str) -> str:
|
|
793
|
+
"""The protocol name ffmpeg would open `value` through (for a looks_like_url() value)."""
|
|
794
|
+
return "subfile" if _is_subfile_url(value) else value.split(":", 1)[0].lower()
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
_PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat", ".mpd")
|
|
785
798
|
_PLAYLIST_MAGIC = (b"#EXTM3U", b"ffconcat")
|
|
799
|
+
# ffmpeg probes a text format over the whole probe buffer (up to -probesize, 5,000,000 bytes), not
|
|
800
|
+
# over a fixed prefix, and reads that buffer as a C string, so the scan below stops at the first
|
|
801
|
+
# NUL byte: a real media file has one within its first few bytes, a manifest has none.
|
|
802
|
+
_MANIFEST_SCAN_LIMIT = 5_000_000
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _manifest_kind(path: str) -> Optional[str]:
|
|
806
|
+
""""DASH" or "IMF" when ffmpeg's own probe would read `path` as that manifest, whatever its
|
|
807
|
+
name: dash_probe() looks for `<MPD` and `dash:profile` or `3GPP:PSS:profile:DASH1`
|
|
808
|
+
(case-insensitively), imf_probe() for
|
|
809
|
+
`<CompositionPlaylist` and `ContentTitle>`. Either one names other files, local or remote, that
|
|
810
|
+
ffmpeg then opens (a DASH BaseURL is followed over HTTP even under -protocol_whitelist file)."""
|
|
811
|
+
buf = bytearray()
|
|
812
|
+
try:
|
|
813
|
+
with open(path, "rb") as fh:
|
|
814
|
+
while len(buf) < _MANIFEST_SCAN_LIMIT:
|
|
815
|
+
chunk = fh.read(65536)
|
|
816
|
+
if not chunk:
|
|
817
|
+
break
|
|
818
|
+
nul = chunk.find(b"\0")
|
|
819
|
+
if nul >= 0:
|
|
820
|
+
buf += chunk[:nul]
|
|
821
|
+
break
|
|
822
|
+
buf += chunk
|
|
823
|
+
except OSError:
|
|
824
|
+
return None
|
|
825
|
+
text = bytes(buf[:_MANIFEST_SCAN_LIMIT])
|
|
826
|
+
low = text.lower()
|
|
827
|
+
if b"<mpd" in low and (b"dash:profile" in low or b"3gpp:pss:profile:dash1" in low):
|
|
828
|
+
return "DASH"
|
|
829
|
+
if b"<CompositionPlaylist" in text and b"ContentTitle>" in text:
|
|
830
|
+
return "IMF"
|
|
831
|
+
return None
|
|
786
832
|
|
|
787
833
|
|
|
788
834
|
def _refuse_playlist(value: str) -> None:
|
|
789
|
-
"""Refuse an input that ffmpeg would read as a playlist (HLS .m3u8,
|
|
790
|
-
than as media. A playlist names other files by
|
|
791
|
-
|
|
792
|
-
|
|
835
|
+
"""Refuse an input that ffmpeg would read as a playlist (HLS .m3u8, a DASH .mpd, an IMF
|
|
836
|
+
composition playlist, an ffconcat list) rather than as media. A playlist names other files by
|
|
837
|
+
path or URL, so a media file handed to the skill could pull in any file the user can read, or
|
|
838
|
+
make ffmpeg fetch a URL. HLS and ffconcat are told by name or by their first bytes; DASH and IMF
|
|
839
|
+
by name or by the content ffmpeg's own probe looks for, anywhere before the first NUL byte. The
|
|
840
|
+
skill's own concat lists pass `-f concat` and are not checked here."""
|
|
793
841
|
if os.path.exists(value) and not os.path.isfile(value) and not os.path.isdir(value):
|
|
794
842
|
# a FIFO or a device: ffmpeg would block on it or read whatever it streams
|
|
795
843
|
refuse("input", "media_path_not_a_file",
|
|
@@ -801,9 +849,14 @@ def _refuse_playlist(value: str) -> None:
|
|
|
801
849
|
head = fh.read(16).lstrip(b"\xef\xbb\xbf \t\r\n")
|
|
802
850
|
except OSError:
|
|
803
851
|
pass
|
|
852
|
+
kind = None
|
|
804
853
|
if value.lower().endswith(_PLAYLIST_SUFFIXES) or head.startswith(_PLAYLIST_MAGIC):
|
|
854
|
+
kind = "HLS, DASH or ffconcat"
|
|
855
|
+
elif os.path.isfile(value):
|
|
856
|
+
kind = _manifest_kind(value)
|
|
857
|
+
if kind:
|
|
805
858
|
refuse("input", "media_path_is_playlist",
|
|
806
|
-
f"{value!r} is a playlist (
|
|
859
|
+
f"{value!r} is a playlist ({kind}), which ffmpeg would follow to the files or URLs it names; "
|
|
807
860
|
"this skill takes media files only. Pass the media file itself", path=value)
|
|
808
861
|
|
|
809
862
|
|
|
@@ -836,9 +889,9 @@ def _refuse_url_media(cmd: Sequence[str]) -> None:
|
|
|
836
889
|
for value in values:
|
|
837
890
|
if looks_like_url(value):
|
|
838
891
|
refuse("input", "media_path_is_url",
|
|
839
|
-
f"{value!r} would be opened by ffmpeg as a URL ({value
|
|
892
|
+
f"{value!r} would be opened by ffmpeg as a URL ({url_scheme(value)}:), not as a local file; "
|
|
840
893
|
"this skill reads and writes local files only. Pass a plain path (a local name containing "
|
|
841
|
-
"a colon works as ./name)", path=value, scheme=value
|
|
894
|
+
"a colon works as ./name)", path=value, scheme=url_scheme(value))
|
|
842
895
|
|
|
843
896
|
|
|
844
897
|
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
@@ -1243,10 +1296,73 @@ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str]
|
|
|
1243
1296
|
return list(cmd), None, None
|
|
1244
1297
|
d, base = os.path.split(output)
|
|
1245
1298
|
stem, ext = os.path.splitext(base)
|
|
1246
|
-
tmp =
|
|
1299
|
+
tmp = reserve_sibling(d, stem, ext)
|
|
1247
1300
|
return list(cmd[:-1]) + [tmp], output, tmp
|
|
1248
1301
|
|
|
1249
1302
|
|
|
1303
|
+
def _reserve(d: str, stem: str, ext: str, tag: str) -> "Tuple[str, Optional[int]]":
|
|
1304
|
+
"""(path, open descriptor) of a hidden sibling created exclusively; (path, None) when the
|
|
1305
|
+
directory refuses the create (the write that follows then fails there)."""
|
|
1306
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
|
|
1307
|
+
n = 1
|
|
1308
|
+
while True:
|
|
1309
|
+
path = os.path.join(d, f".{stem}.ffskill-" + (f"{tag}-" if tag else "") + f"{os.getpid()}"
|
|
1310
|
+
+ (f"-{n}" if n > 1 else "") + ext)
|
|
1311
|
+
try:
|
|
1312
|
+
return path, os.open(path, flags, 0o666)
|
|
1313
|
+
except FileExistsError:
|
|
1314
|
+
n += 1
|
|
1315
|
+
except OSError:
|
|
1316
|
+
return path, None
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def reserve_sibling(d: str, stem: str, ext: str, tag: str = "") -> str:
|
|
1320
|
+
"""Create the hidden sibling `.<stem>.ffskill-<pid><ext>` (or `...-<pid>-<n><ext>` when that
|
|
1321
|
+
name is taken) exclusively, and return its path, for ffmpeg to write with -y. O_EXCL never
|
|
1322
|
+
opens an existing entry, a symlink included, so a name planted in the output directory
|
|
1323
|
+
beforehand is never written through. What it cannot stop is someone who may delete files in
|
|
1324
|
+
that directory replacing this one between the create and ffmpeg's open: not possible in a
|
|
1325
|
+
sticky shared directory (/tmp), nor in one only the caller can write. A copy this process makes
|
|
1326
|
+
itself goes through copy_to_sibling(), which writes the descriptor it created."""
|
|
1327
|
+
path, fd = _reserve(d, stem, ext, tag)
|
|
1328
|
+
if fd is None:
|
|
1329
|
+
# fail closed: a name that was not created exclusively is never handed to ffmpeg
|
|
1330
|
+
refuse("input", "output_dir_not_writable", f"cannot create a temporary file in {d or '.'!r}",
|
|
1331
|
+
output=os.path.join(d, stem + ext), directory=d or ".")
|
|
1332
|
+
os.close(fd)
|
|
1333
|
+
return path
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def copy_to_sibling(src: str, dst: str) -> str:
|
|
1337
|
+
"""Copy `src` into a hidden sibling of `dst` created exclusively, through the descriptor that
|
|
1338
|
+
created it (never by reopening the name), and return the sibling's path for os.replace()."""
|
|
1339
|
+
import shutil
|
|
1340
|
+
d, base = os.path.split(dst)
|
|
1341
|
+
stem, ext = os.path.splitext(base)
|
|
1342
|
+
path, fd = _reserve(d, stem, ext, "")
|
|
1343
|
+
if fd is None:
|
|
1344
|
+
raise OSError(f"cannot create a temporary file beside {dst}")
|
|
1345
|
+
try:
|
|
1346
|
+
fout = os.fdopen(fd, "wb")
|
|
1347
|
+
except BaseException:
|
|
1348
|
+
os.close(fd)
|
|
1349
|
+
try:
|
|
1350
|
+
os.remove(path)
|
|
1351
|
+
except OSError:
|
|
1352
|
+
pass
|
|
1353
|
+
raise
|
|
1354
|
+
try:
|
|
1355
|
+
with fout, open(src, "rb") as fin:
|
|
1356
|
+
shutil.copyfileobj(fin, fout, 1 << 20)
|
|
1357
|
+
except BaseException:
|
|
1358
|
+
try:
|
|
1359
|
+
os.remove(path)
|
|
1360
|
+
except OSError:
|
|
1361
|
+
pass
|
|
1362
|
+
raise
|
|
1363
|
+
return path
|
|
1364
|
+
|
|
1365
|
+
|
|
1250
1366
|
HW_ENV = "FFMPEG_SKILL_HW"
|
|
1251
1367
|
# set only by render.py/batch.py --hw/--no-hw for the stages they run: "the outer command chose
|
|
1252
1368
|
# explicitly", so a child resolves $FFMPEG_SKILL_HW as a flag (export.py included), not a default
|
|
@@ -1846,7 +1962,6 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
|
1846
1962
|
_check_existing_output(cmd)
|
|
1847
1963
|
d, base = os.path.split(dst)
|
|
1848
1964
|
stem, ext = os.path.splitext(base)
|
|
1849
|
-
tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
|
|
1850
1965
|
with _OutputLock(dst):
|
|
1851
1966
|
if move:
|
|
1852
1967
|
try:
|
|
@@ -1855,14 +1970,16 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
|
1855
1970
|
return
|
|
1856
1971
|
except OSError:
|
|
1857
1972
|
pass
|
|
1973
|
+
tmp = None
|
|
1858
1974
|
try:
|
|
1859
|
-
|
|
1975
|
+
tmp = copy_to_sibling(src, dst)
|
|
1860
1976
|
os.replace(tmp, dst)
|
|
1861
1977
|
except OSError as e:
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1978
|
+
if tmp:
|
|
1979
|
+
try:
|
|
1980
|
+
os.remove(tmp)
|
|
1981
|
+
except OSError:
|
|
1982
|
+
pass
|
|
1866
1983
|
die(f"could not place {dst}: {e}", kind="output")
|
|
1867
1984
|
_remember_output(cmd)
|
|
1868
1985
|
|
package/scripts/_contract.py
CHANGED
|
@@ -243,7 +243,7 @@ DRY_RUN_ANALYSIS = {
|
|
|
243
243
|
"scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
|
|
244
244
|
"report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
|
|
245
245
|
"cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
|
|
246
|
-
"silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written. --filler --transcribe also runs the speech engine (its word timings are what the filler spans are planned on), which may download a model on first use",
|
|
246
|
+
"silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written. --filler --transcribe also runs the speech engine (its word timings are what the filler spans are planned on), which may download a model on first use; so does --transcribe with --sentence-min-silence or --sentence-margin-tail",
|
|
247
247
|
"loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
|
|
248
248
|
"check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
|
|
249
249
|
"stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
|
|
@@ -424,7 +424,8 @@ ERROR_REASONS = {
|
|
|
424
424
|
"output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
|
|
425
425
|
"output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
|
|
426
426
|
"output_is_directory": "the output path is a directory; details: {output}",
|
|
427
|
-
"media_path_is_playlist": "an input is a playlist
|
|
427
|
+
"media_path_is_playlist": "an input is a playlist that ffmpeg would follow to the files or URLs it names: HLS .m3u8/.m3u or an ffconcat list (by name or by its first bytes), a DASH manifest or an IMF composition playlist (by name or by the content ffmpeg's own probe looks for, whatever the file is called); the skill takes media files only; details: {path}",
|
|
428
|
+
"media_path_has_line_break": "a path written into a concat list (loop.py, join.py --copy, sequence.py, multi-part cuts) contains a line break, which the concat format cannot hold: each line is a directive, so the name would add entries to the list; details: {path}",
|
|
428
429
|
"media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
|
|
429
430
|
"join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
|
|
430
431
|
"output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
|
|
@@ -500,6 +501,7 @@ DIAGNOSTIC_CODES = {
|
|
|
500
501
|
"sync_low_confidence": ("warnings", "sync.py / multicam.py: a source's offset has a correlation confidence below 0.1; {path, confidence}"),
|
|
501
502
|
"cropdetect_windows_failed": ("warnings", "cropdetect.py: sampled windows ffmpeg could not decode (windows_failed); {failed, windows, starts}"),
|
|
502
503
|
"filler_words_untimed": ("warnings", "silence.py --filler: listed words in the transcript with no usable timing were not removed (filler.matched_untimed); {count}"),
|
|
504
|
+
"sentence_ends_not_found": ("warnings", "silence.py --sentence-min-silence / --sentence-margin-tail: the transcript has no sentence-final punctuation, so every silence used --min-silence and --margin-tail (sentence.sentence_end_words is 0)"),
|
|
503
505
|
"cut_near_sound": ("warnings", "silence.py: a cut point's level is within CUT_SOUND_MARGIN_DB of --threshold, so it may land in a breath or a word's weak tail (cut_warnings lists them)"),
|
|
504
506
|
"cut_levels_unmeasured": ("warnings", "silence.py: the source could not be decoded to measure the level at each cut; cut_points and cut_warnings are null (unmeasured, not silent)"),
|
|
505
507
|
"filler_removal_caution": ("warnings", "silence.py --filler: a removed word that is often an ordinary word, or a discourse marker added with --filler-extra (also in filler.warnings)"),
|
|
@@ -652,7 +654,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
652
654
|
"beat_grid": {"type": "object", "description": "--beats (1.17): {supported_beats, tempo_bpm, interval, confidence, phase, onsets, supported, unsupported, method, step_s (the envelope's real step: 220/22050 s for --beat-step 0.01, after 3.2.0), range_bpm, usable, onset_refinement (after 3.2.0: pcm-window-ratio when each onset was placed on the decoded samples, else null)}"}}
|
|
653
655
|
elif name == "silence":
|
|
654
656
|
extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
|
|
655
|
-
"margins": {"type": "object", "description": "{lead, tail}: the seconds of each silence kept before speech starts (--margin-lead) and after it ends (--margin-tail); both are --margin unless one is named; absent under --filler
|
|
657
|
+
"margins": {"type": "object", "description": "{lead, tail}: the seconds of each silence kept before speech starts (--margin-lead) and after it ends (--margin-tail); both are --margin unless one is named; absent under --filler with neither --speech-aware nor a sentence flag, which removes no silences"},
|
|
658
|
+
"silence_positions": {"type": "array", "description": "--sentence-min-silence / --sentence-margin-tail: per entry of silences, sentence_end when the transcript word before it ends in sentence-final punctuation, else inside"},
|
|
659
|
+
"sentence": {"type": "object", "description": "--sentence-min-silence / --sentence-margin-tail: {min_silence, margin_tail (the floor and tail given to a silence after a sentence end), source, word_timings, sentence_end_words (transcript words that end a sentence; 0 means none did, so every silence used --min-silence and --margin-tail), silences (how many of silences are sentence_end)}"},
|
|
656
660
|
"removed_seconds_total": {"type": "number", "description": "--filler: seconds removed in all, silence plus filler words (removed_seconds stays the silence alone)"},
|
|
657
661
|
"cut_points": {"type": ["array", "null"], "description": "2.22: per cut {source_time, output_time, edge (out|in), rms_dbfs (the loudest channel 50 ms either side on the source; null for digital silence), filler (true on a removed filler word's edge)}; null when the source could not be decoded for it (cut_levels_unmeasured warning)"},
|
|
658
662
|
"cut_warnings": {"type": ["array", "null"], "description": "the cut_points (not filler cuts) within 10 dB of --threshold: where to listen (a cut_near_sound warning); verified is unchanged"},
|
package/scripts/batch.py
CHANGED
|
@@ -33,7 +33,7 @@ from pathlib import Path
|
|
|
33
33
|
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
|
-
from _common.runner import HW_FORCED_ENV, _OutputLock, add_hw_orchestrator_args, env_hw, stop_children
|
|
36
|
+
from _common.runner import HW_FORCED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw, stop_children
|
|
37
37
|
from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
|
|
38
38
|
|
|
39
39
|
HERE = Path(__file__).resolve().parent
|
|
@@ -265,16 +265,18 @@ def deliver(src: str, final: Path) -> None:
|
|
|
265
265
|
except OSError:
|
|
266
266
|
# another filesystem: copy beside the final path and rename, so a copy that fails part
|
|
267
267
|
# way never leaves a truncated file where the caller's was
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
# created exclusively and written through the descriptor that created it: never through
|
|
269
|
+
# an entry planted at the name, and two threads delivering side by side get two names
|
|
270
|
+
tmp = None
|
|
270
271
|
try:
|
|
271
|
-
|
|
272
|
+
tmp = copy_to_sibling(str(src), str(final))
|
|
272
273
|
os.replace(tmp, final)
|
|
273
274
|
except OSError as exc:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
if tmp:
|
|
276
|
+
try:
|
|
277
|
+
os.remove(tmp)
|
|
278
|
+
except OSError:
|
|
279
|
+
pass
|
|
278
280
|
raise ItemError({"step": None, "tool": None, "kind": "output",
|
|
279
281
|
"message": f"could not deliver {src} to {final}: {exc}"})
|
|
280
282
|
log(f" delivered the last written file to {final}")
|
package/scripts/caption.py
CHANGED
|
@@ -926,8 +926,8 @@ def mux_subtitle_codec(output: str) -> str:
|
|
|
926
926
|
|
|
927
927
|
def ass_color(hex_rgb: str, alpha: int = 0) -> str:
|
|
928
928
|
h = hex_rgb.lstrip("#")
|
|
929
|
-
if len(h) != 6:
|
|
930
|
-
die(f"colour must be
|
|
929
|
+
if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
|
|
930
|
+
die(f"colour must be six hex digits RRGGBB, got '{hex_rgb}'")
|
|
931
931
|
r, g, b = h[0:2], h[2:4], h[4:6]
|
|
932
932
|
return f"&H{alpha:02X}{b}{g}{r}".upper()
|
|
933
933
|
|
package/scripts/export.py
CHANGED
|
@@ -51,7 +51,7 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
|
|
|
51
51
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
52
52
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
53
53
|
from _common.probe import square_pixels, square_pixels_note
|
|
54
|
-
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, run_analysis as _run_analysis, subtitle_codec_for
|
|
54
|
+
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
|
|
55
55
|
from _common.emit import file_identity
|
|
56
56
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
57
57
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
@@ -213,7 +213,7 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
213
213
|
if not STATE.dry_run:
|
|
214
214
|
d, base = os.path.split(output)
|
|
215
215
|
stem, ext = os.path.splitext(base)
|
|
216
|
-
fill_tmp =
|
|
216
|
+
fill_tmp = reserve_sibling(d, stem, ext, "fill") # created exclusively: never through a planted link
|
|
217
217
|
cmd += (p["audio"] if has_audio else ["-an"]) + [fill_tmp or output]
|
|
218
218
|
cmd += ["-map", "[key]"] + p["video"] + tc_args + cfr_args(meta) + ["-an", key]
|
|
219
219
|
try:
|
package/scripts/render.py
CHANGED
|
@@ -464,6 +464,64 @@ def check_overlay(ov: Any, label: str) -> None:
|
|
|
464
464
|
die(f"{label}: scale sizes an image, logo or video layer; text size is font_size", kind="input")
|
|
465
465
|
|
|
466
466
|
|
|
467
|
+
def project_input_files(proj: Dict[str, Any], rel: Any) -> List[str]:
|
|
468
|
+
"""Every file a project names as an input: clip sources, music/replace/effects, the brand,
|
|
469
|
+
caption files (a --text value that names an existing file), overlay images, videos and fonts, the
|
|
470
|
+
audiogram image, a beat grid's source and the chapters file. Values of the wrong type are left
|
|
471
|
+
to the stage that reads them."""
|
|
472
|
+
found: List[str] = []
|
|
473
|
+
|
|
474
|
+
def add(value: Any, must_exist: bool = False) -> None:
|
|
475
|
+
if isinstance(value, str) and value:
|
|
476
|
+
p = rel(value)
|
|
477
|
+
if not must_exist or os.path.isfile(p):
|
|
478
|
+
found.append(p)
|
|
479
|
+
|
|
480
|
+
for c in proj.get("clips") or []:
|
|
481
|
+
if isinstance(c, dict):
|
|
482
|
+
add(c.get("src"))
|
|
483
|
+
if isinstance(c.get("snap"), dict):
|
|
484
|
+
add(c["snap"].get("source"))
|
|
485
|
+
for key in ("audio", "captions", "audiogram", "snap"):
|
|
486
|
+
if not isinstance(proj.get(key), dict):
|
|
487
|
+
continue
|
|
488
|
+
obj = proj[key]
|
|
489
|
+
if key == "audio":
|
|
490
|
+
for k in ("music", "replace", "effects"):
|
|
491
|
+
add(obj.get(k))
|
|
492
|
+
elif key == "captions":
|
|
493
|
+
add(obj.get("srt"))
|
|
494
|
+
add(obj.get("ass"))
|
|
495
|
+
add(obj.get("text"), must_exist=True) # inline cue text is not a path
|
|
496
|
+
elif key == "audiogram":
|
|
497
|
+
add(obj.get("image"))
|
|
498
|
+
else:
|
|
499
|
+
add(obj.get("source"))
|
|
500
|
+
add(proj.get("brand"))
|
|
501
|
+
brand = proj.get("brand")
|
|
502
|
+
if isinstance(brand, str) and brand:
|
|
503
|
+
# the brand's own logo and font_file are read by the overlay and caption stages, resolved
|
|
504
|
+
# against the brand file as load_brand() resolves them; an unreadable brand is left to them
|
|
505
|
+
try:
|
|
506
|
+
data = json.loads(Path(rel(brand)).read_text(encoding="utf-8"))
|
|
507
|
+
except (OSError, ValueError):
|
|
508
|
+
data = None
|
|
509
|
+
if isinstance(data, dict):
|
|
510
|
+
base = Path(rel(brand)).resolve().parent
|
|
511
|
+
for k in ("logo", "font_file"):
|
|
512
|
+
v = data.get(k)
|
|
513
|
+
if isinstance(v, str) and v and "://" not in v:
|
|
514
|
+
found.append(v if os.path.isabs(v) else str(base / v))
|
|
515
|
+
add(proj.get("chapters"), must_exist=True)
|
|
516
|
+
overlays = proj.get("overlays")
|
|
517
|
+
for ov in (overlays if isinstance(overlays, list) else []): # any other type is the overlay stage's to refuse
|
|
518
|
+
if isinstance(ov, dict):
|
|
519
|
+
add(ov.get("image"))
|
|
520
|
+
add(ov.get("video"))
|
|
521
|
+
add(ov.get("font_file"))
|
|
522
|
+
return found
|
|
523
|
+
|
|
524
|
+
|
|
467
525
|
def require_object(obj: Any, label: str) -> None:
|
|
468
526
|
"""Refuse a value that is not an object where the run reads it with .get(): "export": "reels"
|
|
469
527
|
in a render, a string frame, a clip written as a bare path. 2.2.1 died there with a traceback
|
|
@@ -1216,8 +1274,13 @@ def main() -> int:
|
|
|
1216
1274
|
if not clips:
|
|
1217
1275
|
die("project.clips is empty")
|
|
1218
1276
|
output = rel(proj.get("output") or "final.mp4")
|
|
1219
|
-
# the final stage is a copy from the work dir, so run()'s own guard never sees the sources
|
|
1220
|
-
|
|
1277
|
+
# the final stage is a copy from the work dir, so run()'s own guard never sees the sources
|
|
1278
|
+
# (review 5): every file the project names is passed here, not only the clips, or --overwrite
|
|
1279
|
+
# replaced a music bed or a brand file named as the output
|
|
1280
|
+
if not args.stop_after or args.stop_after == "export":
|
|
1281
|
+
# only a run that ends in the final copy writes `output`; a --stop-after preview writes
|
|
1282
|
+
# its stage file inside the work directory and leaves `output` alone
|
|
1283
|
+
refuse_output_is_input(output, *([os.path.abspath(args.project)] if args.project else []), *project_input_files(proj, rel))
|
|
1221
1284
|
if _reaches("overlays", args.stop_after):
|
|
1222
1285
|
# #326: an overlay video is the caller's file, never an earlier stage's output, so a
|
|
1223
1286
|
# missing one is refused here, under --dry-run too, not after every stage before it ran
|
|
@@ -1396,7 +1459,12 @@ def main() -> int:
|
|
|
1396
1459
|
("color", "--color"), ("background", "--background"),
|
|
1397
1460
|
("width", "--width"), ("height", "--height"), ("fps", "--fps")):
|
|
1398
1461
|
if ag.get(key) is not None:
|
|
1399
|
-
|
|
1462
|
+
value = ag[key]
|
|
1463
|
+
if key == "image" and isinstance(value, str) and "://" not in value:
|
|
1464
|
+
# against the project file, as every other path here and as the output guard
|
|
1465
|
+
# checks it; it used to reach waveform.py as written, against the cwd
|
|
1466
|
+
value = rel(value)
|
|
1467
|
+
argv += [flag, str(value)]
|
|
1400
1468
|
argv += brand_args
|
|
1401
1469
|
sh("waveform.py", *argv, stage="audiogram")
|
|
1402
1470
|
current = nxt
|
|
@@ -1542,7 +1610,8 @@ def main() -> int:
|
|
|
1542
1610
|
for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin"), ("platform", "--platform"),
|
|
1543
1611
|
("chromakey", "--chromakey"), ("chromakey_similarity", "--chromakey-similarity"), ("chromakey_blend", "--chromakey-blend")):
|
|
1544
1612
|
if ov.get(k) is not None:
|
|
1545
|
-
|
|
1613
|
+
# font_file is a path: the same one project_input_files() guards
|
|
1614
|
+
argv += [flag, rel(ov[k]) if k == "font_file" and ov[k] else str(ov[k])]
|
|
1546
1615
|
if ov.get("box"):
|
|
1547
1616
|
argv.append("--box")
|
|
1548
1617
|
# review 12: the overlay stage was the one stage that never heard which destination this
|
package/scripts/silence.py
CHANGED
|
@@ -10,6 +10,8 @@ Examples:
|
|
|
10
10
|
python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.2 s margin
|
|
11
11
|
python3 silence.py talk.mp4 --threshold -40 --min-silence 1 --margin 0.25
|
|
12
12
|
python3 silence.py talk.mp4 --margin-lead 0.1 --margin-tail 0.4 # less before a word, more after one
|
|
13
|
+
python3 silence.py talk.mp4 --words talk.json --min-silence 0.2 --sentence-min-silence 0.3 --sentence-margin-tail 0.3
|
|
14
|
+
# pauses inside a sentence from 0.2 s, after one from 0.3 s with a longer tail
|
|
13
15
|
python3 silence.py talk.mp4 --list # print the silences and the resulting cut list, no output
|
|
14
16
|
python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
|
|
15
17
|
"""
|
|
@@ -59,6 +61,76 @@ CUT_LEVEL_RATE = 8000
|
|
|
59
61
|
CUT_LEVEL_CHANNELS = 2 # measured one by one; a surround source is folded to two first
|
|
60
62
|
DIGITAL_SILENCE_DB = -90.0 # 2.20: at or below this a span is digital silence (16-bit dither sits near -96)
|
|
61
63
|
BREATH_FLOOR = 0.12 # shortest gap silencedetect is asked for under --speech-aware
|
|
64
|
+
# a transcript word that ends in one of these (after any closing quote or bracket) ends a sentence
|
|
65
|
+
SENTENCE_END = ".!?\u3002\uff01\uff1f\u2026\u0964\u061f"
|
|
66
|
+
SENTENCE_CLOSERS = "\"'\u201d\u2019\u300d\u300f\uff09)]\u3011 "
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ends_sentence(word: str) -> bool:
|
|
70
|
+
"""Whether a transcript word carries sentence-final punctuation: read off the text the speech
|
|
71
|
+
engine wrote, never guessed from the sound."""
|
|
72
|
+
text = str(word or "").rstrip(SENTENCE_CLOSERS)
|
|
73
|
+
return bool(text) and text[-1] in SENTENCE_END
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def silence_positions(silences: List[Tuple[float, float]], words: "list",
|
|
77
|
+
duration: Optional[float] = None) -> List[str]:
|
|
78
|
+
"""Per silence, `sentence_end` when the word spoken before it ends a sentence, else `inside`.
|
|
79
|
+
The word before a silence is the last one whose own midpoint lies before the silence's
|
|
80
|
+
midpoint; a silence that runs to the end of the file ends at `duration`. An engine
|
|
81
|
+
stretches a word's timing into the pause beside it, the end of the word before or the start
|
|
82
|
+
of the word after (on demos/footage/ai_talk_en.mp4 three word starts sit 0.45 to 0.5 s before
|
|
83
|
+
the sound does), and a word's midpoint stays on its own side of the pause either way. A
|
|
84
|
+
silence with no word before it is `inside`."""
|
|
85
|
+
timed = sorted(words, key=lambda w: w["start"] + w["end"])
|
|
86
|
+
out: List[str] = []
|
|
87
|
+
for s, e in silences:
|
|
88
|
+
if e == float("inf"):
|
|
89
|
+
e = max(s, duration) if duration is not None else s
|
|
90
|
+
ref = (s + e) / 2
|
|
91
|
+
before = [w for w in timed if (w["start"] + w["end"]) / 2 < ref]
|
|
92
|
+
out.append("sentence_end" if before and ends_sentence(before[-1]["word"]) else "inside")
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sentence_words(args) -> "Tuple[list, str]":
|
|
97
|
+
"""(word timings, where they came from) for --sentence-min-silence / --sentence-margin-tail.
|
|
98
|
+
Under --filler the transcript that run read or made is reused; otherwise --words is read, or
|
|
99
|
+
--transcribe runs the local engine. Refuses without one: where a sentence ends is taken from
|
|
100
|
+
the transcript's punctuation, and there is no reading it off a pause."""
|
|
101
|
+
def usable(words: "list", source: str) -> "Tuple[list, str]":
|
|
102
|
+
# a word with no length or a time that is not a number cannot be placed against a pause
|
|
103
|
+
timed = [w for w in words if math.isfinite(w["start"]) and math.isfinite(w["end"]) and w["end"] > w["start"]]
|
|
104
|
+
if not timed:
|
|
105
|
+
die(f"no usable word timings in the transcript ({source}): every word's start and end must be "
|
|
106
|
+
"numbers with the end after the start, or there is nothing to place a sentence end against a pause",
|
|
107
|
+
kind="input")
|
|
108
|
+
return timed, source
|
|
109
|
+
|
|
110
|
+
if getattr(args, "_words", None) is not None:
|
|
111
|
+
return usable(args._words, args._words_source)
|
|
112
|
+
if args.words:
|
|
113
|
+
try:
|
|
114
|
+
raw = json.loads(read_text_or_die(args.words, "--words"))
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
|
|
117
|
+
words, _had_segments, _untimed = _words_from_transcript(raw)
|
|
118
|
+
if not words:
|
|
119
|
+
die(f"no word timings in {args.words}: the sentence flags need "
|
|
120
|
+
'{"words": [{"word": ..., "start": ..., "end": ...}]} (or the same inside "segments").',
|
|
121
|
+
kind="input")
|
|
122
|
+
return usable(words, f"whisper-json:{args.words}")
|
|
123
|
+
if args.transcribe:
|
|
124
|
+
asr_run = _asr.transcribe_words_result(args.input, None, model=args.model, engine=args.engine)
|
|
125
|
+
args._asr_notes = list(asr_run.notes)
|
|
126
|
+
if not asr_run.words:
|
|
127
|
+
die(f"{asr_run.engine or 'the local engine'} ran but produced no word-level timings, so there is "
|
|
128
|
+
"nothing to tell a sentence end from. Pass a transcript with word timings as --words.", kind="input")
|
|
129
|
+
family = "parakeet" if asr_run.engine in _asr.PARAKEET_ENGINES else "whisper"
|
|
130
|
+
return usable(asr_run.words, f"{family}:{asr_run.engine}" if asr_run.engine else "whisper")
|
|
131
|
+
die("--sentence-min-silence and --sentence-margin-tail need word timings: pass --words transcript.json "
|
|
132
|
+
"(a whisper JSON with word timestamps) or --transcribe. A sentence end is read from the transcript's "
|
|
133
|
+
"punctuation; a pause's length does not say where one is.", kind="input")
|
|
62
134
|
|
|
63
135
|
|
|
64
136
|
def speech_aware_silences(path: str, threshold: float, min_silence: float) -> "Tuple[List[Tuple[float, float]], List[Tuple[float, float]]]":
|
|
@@ -312,6 +384,7 @@ def resolve_filler(args, meta):
|
|
|
312
384
|
"faster-whisper word_timestamps=True / openai-whisper --word_timestamps True) and "
|
|
313
385
|
"pass the result with --words.", kind="input")
|
|
314
386
|
|
|
387
|
+
args._words, args._words_source = words, source # the sentence flags read the same transcript
|
|
315
388
|
lang = args.filler_lang
|
|
316
389
|
reported_lang = lang
|
|
317
390
|
if lang == "auto":
|
|
@@ -466,6 +539,13 @@ def main() -> int:
|
|
|
466
539
|
help="seconds of silence to keep after speech ends, when it should differ from --margin "
|
|
467
540
|
"(default: --margin); a speaker whose last syllable trails off needs more here than "
|
|
468
541
|
"before the next word")
|
|
542
|
+
ap.add_argument("--sentence-min-silence", type=float, default=None, metavar="SECONDS",
|
|
543
|
+
help="for a silence after a word that ends a sentence, remove it only when it is at least this "
|
|
544
|
+
"long, instead of --min-silence. Needs --words or --transcribe: a sentence end is the "
|
|
545
|
+
"transcript's punctuation")
|
|
546
|
+
ap.add_argument("--sentence-margin-tail", type=float, default=None, metavar="SECONDS",
|
|
547
|
+
help="for a silence after a word that ends a sentence, keep this much after the speech instead "
|
|
548
|
+
"of --margin-tail. Needs --words or --transcribe")
|
|
469
549
|
ap.add_argument("--min-keep", type=float, default=0.2, help="drop kept pieces shorter than this (default 0.2)")
|
|
470
550
|
ap.add_argument("--speech-aware", action="store_true",
|
|
471
551
|
help="keep breaths shorter than --min-silence when they sit inside a sentence "
|
|
@@ -496,7 +576,7 @@ def main() -> int:
|
|
|
496
576
|
fil.add_argument("--filler-pad", type=float, default=FILLER_PAD,
|
|
497
577
|
help=f"seconds trimmed either side of a filler word (default {FILLER_PAD})")
|
|
498
578
|
fil.add_argument("--words", metavar="FILE",
|
|
499
|
-
help="a whisper JSON with word-level timings, for --filler")
|
|
579
|
+
help="a whisper JSON with word-level timings, for --filler and the sentence flags")
|
|
500
580
|
fil.add_argument("--transcribe", action="store_true",
|
|
501
581
|
help="produce the word timings with a local speech engine, Parakeet or whisper (never required; the same "
|
|
502
582
|
"bridge caption.py uses)")
|
|
@@ -543,6 +623,19 @@ def main() -> int:
|
|
|
543
623
|
# unless one is named
|
|
544
624
|
tail = args.margin if args.margin_tail is None else args.margin_tail
|
|
545
625
|
lead = args.margin if args.margin_lead is None else args.margin_lead
|
|
626
|
+
by_sentence = args.sentence_min_silence is not None or args.sentence_margin_tail is not None
|
|
627
|
+
for flag, value in (("--sentence-min-silence", args.sentence_min_silence),
|
|
628
|
+
("--sentence-margin-tail", args.sentence_margin_tail)):
|
|
629
|
+
if value is not None and (not math.isfinite(value) or value < 0):
|
|
630
|
+
die(f"{flag} must be a finite number of seconds, 0 or more (got {value:g})", kind="input")
|
|
631
|
+
if by_sentence and args.speech_aware:
|
|
632
|
+
die("--speech-aware and the sentence flags answer the same question two ways: --speech-aware takes a "
|
|
633
|
+
"pause of --min-silence or longer as a sentence boundary, --sentence-min-silence and "
|
|
634
|
+
"--sentence-margin-tail take the transcript's punctuation. Pass one of them.", kind="input")
|
|
635
|
+
if by_sentence and not (args.words or args.transcribe):
|
|
636
|
+
sentence_words(args) # refuses: no transcript to read a sentence end from
|
|
637
|
+
sentence_min = args.min_silence if args.sentence_min_silence is None else args.sentence_min_silence
|
|
638
|
+
sentence_tail = tail if args.sentence_margin_tail is None else args.sentence_margin_tail
|
|
546
639
|
if args.filler_pad < 0:
|
|
547
640
|
die("--filler-pad cannot be negative: a negative pad turns each word's span inside out "
|
|
548
641
|
"(end before start) and the span is then silently dropped, so nothing is removed",
|
|
@@ -558,16 +651,44 @@ def main() -> int:
|
|
|
558
651
|
breaths: List[Tuple[float, float]] = []
|
|
559
652
|
if args.speech_aware:
|
|
560
653
|
silences, breaths = speech_aware_silences(args.input, args.threshold, args.min_silence)
|
|
561
|
-
elif args.filler:
|
|
654
|
+
elif args.filler and not by_sentence:
|
|
562
655
|
silences = [] # --filler alone: remove only the filler-word spans, not generic dead air
|
|
563
656
|
else:
|
|
564
|
-
|
|
657
|
+
# with the sentence flags each kind of pause has its own floor: measure at the shorter one
|
|
658
|
+
silences = detect(args.input, args.threshold, min(args.min_silence, sentence_min) if by_sentence else args.min_silence)
|
|
565
659
|
filler_info, filler_ranges = resolve_filler(args, meta) if args.filler else (None, [])
|
|
660
|
+
positions: List[str] = []
|
|
661
|
+
sentence_info: Optional[Dict[str, Any]] = None
|
|
662
|
+
if by_sentence:
|
|
663
|
+
words, words_source = sentence_words(args)
|
|
664
|
+
found = silence_positions(silences, words, duration)
|
|
665
|
+
# a silence that runs to the end of the file is as long as what is left of the file
|
|
666
|
+
kept_pairs = [(span, pos) for span, pos in zip(silences, found)
|
|
667
|
+
if (duration if span[1] == float("inf") else span[1]) - span[0]
|
|
668
|
+
>= (sentence_min if pos == "sentence_end" else args.min_silence)]
|
|
669
|
+
silences = [span for span, _ in kept_pairs]
|
|
670
|
+
positions = [pos for _, pos in kept_pairs]
|
|
671
|
+
marked = sum(1 for w in words if ends_sentence(w["word"]))
|
|
672
|
+
sentence_info = {"min_silence": sentence_min, "margin_tail": sentence_tail, "source": words_source,
|
|
673
|
+
"word_timings": len(words), "sentence_end_words": marked,
|
|
674
|
+
"silences": positions.count("sentence_end")}
|
|
675
|
+
if not marked:
|
|
676
|
+
warn("the transcript has no sentence-final punctuation, so no silence was taken as a sentence end: "
|
|
677
|
+
"every silence used --min-silence and --margin-tail", "sentence_ends_not_found")
|
|
678
|
+
|
|
679
|
+
def margined(spans: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
|
|
680
|
+
"""The stretch of each silence that is removed: its own tail for one after a sentence end."""
|
|
681
|
+
if not by_sentence:
|
|
682
|
+
return inside_margin(spans, duration, tail, lead)
|
|
683
|
+
out: List[Tuple[float, float]] = []
|
|
684
|
+
for span, pos in zip(spans, positions):
|
|
685
|
+
out += inside_margin([span], duration, sentence_tail if pos == "sentence_end" else tail, lead)
|
|
686
|
+
return out
|
|
566
687
|
# One sorted, merged removal list through the graph the tool already has: filler removal IS
|
|
567
688
|
# time-range removal, so it reuses keep_ranges() and the same select chain.
|
|
568
689
|
# --margin is kept around the silences only (inside_margin), not around filler spans, whose
|
|
569
690
|
# own trim is --filler-pad (#414); the merged list then goes through with no further margin.
|
|
570
|
-
removals = merge_spans(
|
|
691
|
+
removals = merge_spans(margined(silences) + list(filler_ranges))
|
|
571
692
|
keeps = keep_ranges(removals, duration, 0.0, args.min_keep)
|
|
572
693
|
# what the render holds (on a picture, each bound on its nearest frame): the report, the EDL
|
|
573
694
|
# and the time map all describe these, so a re-cut or a sum of `keep` is the rendered edit
|
|
@@ -582,7 +703,7 @@ def main() -> int:
|
|
|
582
703
|
# the silences alone; everything removed is `removed_seconds_total`.
|
|
583
704
|
silence_only = removed
|
|
584
705
|
if args.filler:
|
|
585
|
-
silence_keeps = keep_ranges(merge_spans(
|
|
706
|
+
silence_keeps = keep_ranges(merge_spans(margined(silences)), duration, 0.0, args.min_keep)
|
|
586
707
|
silence_only = max(0.0, duration - sum(e - s for s, e in rendered_ranges(silence_keeps, meta, duration, video_out)))
|
|
587
708
|
kinds = silence_kinds(args.input, silences, duration, args.threshold)
|
|
588
709
|
summary = {
|
|
@@ -592,11 +713,15 @@ def main() -> int:
|
|
|
592
713
|
"kept_duration": round(kept, 3),
|
|
593
714
|
"removed_seconds": round(silence_only, 3),
|
|
594
715
|
}
|
|
595
|
-
if silences or not args.filler or args.speech_aware:
|
|
716
|
+
if silences or not args.filler or args.speech_aware or by_sentence:
|
|
596
717
|
# the seconds of each silence this run kept: `tail` from where the silence starts (after
|
|
597
718
|
# the speech before it), `lead` up to where it ends (before the speech after it). Absent
|
|
598
|
-
# under --filler
|
|
719
|
+
# under --filler with neither --speech-aware nor a sentence flag: no silence is detected.
|
|
599
720
|
summary["margins"] = {"lead": lead, "tail": tail}
|
|
721
|
+
if sentence_info is not None:
|
|
722
|
+
# which of `silences` follow a sentence end, and the floor and tail those were given
|
|
723
|
+
summary["silence_positions"] = positions
|
|
724
|
+
summary["sentence"] = sentence_info
|
|
600
725
|
if kinds:
|
|
601
726
|
summary.update(kinds)
|
|
602
727
|
if args.speech_aware:
|
|
@@ -628,7 +753,7 @@ def main() -> int:
|
|
|
628
753
|
die(f"{len(keeps)} keep ranges is above --max-cuts {args.max_cuts}: the filter graph grows "
|
|
629
754
|
"with every range and a graph this size is slow and fragile. Raise --max-cuts if you "
|
|
630
755
|
"mean it, or use --min-silence/--filler-pad to merge the short ones.", kind="input")
|
|
631
|
-
filler_only = args.filler and not args.speech_aware
|
|
756
|
+
filler_only = args.filler and not args.speech_aware and not by_sentence
|
|
632
757
|
if filler_only:
|
|
633
758
|
info(f"--filler only (no --speech-aware): skipping generic silence detection, "
|
|
634
759
|
f"keeping {len(keeps)} range(s): {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s, filler only)")
|