ffmpeg-skill 3.1.6 → 3.1.7
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 +9 -6
- package/package.json +1 -1
- package/references/scripts.md +7 -2
- package/scripts/_common/asr.py +49 -10
- package/scripts/_common/fonts.py +26 -0
- package/scripts/_common/probe.py +20 -3
- package/scripts/_common/runner.py +34 -0
- package/scripts/_common/timeline.py +118 -23
- package/scripts/_contract.py +32 -1
- package/scripts/caption.py +95 -11
- package/scripts/export.py +5 -13
- package/scripts/silence.py +30 -5
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.1.7`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.1.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.1.7", "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"},
|
|
@@ -786,8 +786,10 @@ Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
|
|
|
786
786
|
| `matched_untimed`, `matched_too_long` | `silence.py --filler` (inside `filler`) | listed words that were not cut: no usable timing in the transcript (also a `filler_words_untimed` warning), or longer than a filler (`FILLER_MAX_WORD`, a held vowel) |
|
|
787
787
|
|
|
788
788
|
`caption.py --transcribe` and `silence.py --filler --transcribe`: when a local speech engine ran
|
|
789
|
-
and produced no cue
|
|
790
|
-
|
|
789
|
+
and produced no cue (for `--filler`: a Whisper transcript with no text at all; text without word
|
|
790
|
+
timings is a build without them, refused as such), the refusal is `"<engine> found no speech in
|
|
791
|
+
<input>"` with `kind: input`, `reason: "no_speech"` and `engine` in the failure document (the
|
|
792
|
+
three ASR reasons below are in `error.reason` too, with `error.details`) — distinct from the "no local
|
|
791
793
|
speech-to-text engine found" refusal, which now only means no engine was found. When `--engine
|
|
792
794
|
auto` found engines and none transcribed, the refusal is `kind: input` with `reason:
|
|
793
795
|
"engine_failed"` (one ran and failed) or `"english_only"` (the only engines found were Parakeet
|
|
@@ -865,8 +867,9 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
|
865
867
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
866
868
|
`media_path_is_url`, `media_path_is_playlist` (an HLS or ffconcat playlist given as media),
|
|
867
869
|
`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`,
|
|
868
|
-
`render.py`'s `plan_*` reasons,
|
|
869
|
-
`encoder_list_unreadable
|
|
870
|
+
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
|
871
|
+
`encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
|
|
872
|
+
and `english_only`), and `error.details`, the facts the message is written from, so a
|
|
870
873
|
caller never parses the prose:
|
|
871
874
|
|
|
872
875
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.7",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -659,7 +659,8 @@ so the user can edit the list by hand. Quiet rooms need `--threshold -40`
|
|
|
659
659
|
to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
660
660
|
|
|
661
661
|
**Breaths and weak word endings.** `--margin` is the padding kept on each side of every cut
|
|
662
|
-
(default 0.2 s since 2.22, 0.15 s before; 0.3 s for a speaker who trails off).
|
|
662
|
+
(default 0.2 s since 2.22, 0.15 s before; 0.3 s for a speaker who trails off). It is kept around
|
|
663
|
+
silences only: a filler word's cut is the word plus `--filler-pad` (since 3.1.6). `--speech-aware` keeps the short
|
|
663
664
|
breaths inside a sentence and cuts only at sentence-boundary pauses. A `--threshold` set too high
|
|
664
665
|
(-25, say) counts a weak word ending as silence: the result then warns.
|
|
665
666
|
|
|
@@ -852,7 +853,11 @@ OTIO's adapters). Clips keep their in/out and speed (an M2 line, a `timeMap`, a
|
|
|
852
853
|
`LinearTimeWarp`); `transition` becomes a cross dissolve centred on each cut, trimmed so the
|
|
853
854
|
timeline is exactly as long as `join.py`'s crossfade would render it; `audio.music` is its own
|
|
854
855
|
track; inline `chapters` are markers. Times take the render's own grammar (`"0:05"`,
|
|
855
|
-
`hh:mm:ss:ff` at the source's fps
|
|
856
|
+
`hh:mm:ss:ff` at the source's fps; an `out` past a source's end ends at it, as the render
|
|
857
|
+
cuts it, with a note; a chapter metadata.py would refuse is refused). Source positions count
|
|
858
|
+
from each source's start timecode at its own frame rate (EDL source timecodes, drop-frame
|
|
859
|
+
where the source's is; the FCPXML asset's `start`; OTIO's `available_range`), so a conform
|
|
860
|
+
finds the frames (3.1.6). The sequence is the project's `frame` sized by
|
|
856
861
|
fit.py's rule (`{aspect 16:9, width 1920}` is 1920x1080; an aspect alone takes the export
|
|
857
862
|
preset's size when the preset has that aspect; an aspect fit.py refuses, such as `16/9`, is
|
|
858
863
|
refused unless the project's own `fit.aspect` replaces it in the render). A clip of another
|
package/scripts/_common/asr.py
CHANGED
|
@@ -115,7 +115,9 @@ def die_no_speech(engine: str, video: str) -> None:
|
|
|
115
115
|
"""An engine ran and heard nothing: kind input, `reason: "no_speech"`, naming the engine and
|
|
116
116
|
the input -- not the "no engine found" refusal (one was found) and not "no cues found in"
|
|
117
117
|
the engine's own temporary SRT (a path the caller never gave and that is already deleted)."""
|
|
118
|
+
# the reason is error.reason as every refusal's is (#414), and stays top-level where 2.x put it
|
|
118
119
|
die(f"{engine} found no speech in {video}", kind="input", reason=NO_SPEECH_REASON, engine=engine,
|
|
120
|
+
error_extra={"reason": NO_SPEECH_REASON, "details": {"engine": engine}},
|
|
119
121
|
hint="check that the input (and --audio-stream) carries the speech, or write the cues by hand with --text")
|
|
120
122
|
|
|
121
123
|
|
|
@@ -135,6 +137,7 @@ def die_engine_failed(engine: str, video: str, tried: "List[Dict[str, str]]", in
|
|
|
135
137
|
detail = next((t["detail"] for t in tried if t["engine"] == engine), "failed")
|
|
136
138
|
die(f"--engine {engine} could not transcribe {video}: {engine} {detail}", kind="input",
|
|
137
139
|
reason=ENGINE_FAILED_REASON, engine=engine, detail=detail,
|
|
140
|
+
error_extra={"reason": ENGINE_FAILED_REASON, "details": {"engine": engine, "detail": detail}},
|
|
138
141
|
hint="fix the engine (its own message is in detail), or use --engine auto to fall back to another installed engine")
|
|
139
142
|
|
|
140
143
|
|
|
@@ -152,7 +155,9 @@ def die_engines_failed(tried: "List[Dict[str, str]]", video: str, flag: str, alt
|
|
|
152
155
|
die(f"no installed speech-to-text engine could transcribe {video} for {flag}:\n"
|
|
153
156
|
+ "\n".join(f" {t['engine']} {t['detail']}" for t in tried) + "\n" + alternative,
|
|
154
157
|
kind="input", reason=ENGINE_FAILED_REASON if failed else ENGLISH_ONLY_REASON,
|
|
155
|
-
engine=(failed or tried)[0]["engine"], engines=[dict(t) for t in tried], hint=hint
|
|
158
|
+
engine=(failed or tried)[0]["engine"], engines=[dict(t) for t in tried], hint=hint,
|
|
159
|
+
error_extra={"reason": ENGINE_FAILED_REASON if failed else ENGLISH_ONLY_REASON,
|
|
160
|
+
"details": {"engine": (failed or tried)[0]["engine"], "engines": [dict(t) for t in tried]}})
|
|
156
161
|
|
|
157
162
|
|
|
158
163
|
def _failed(engine: str, detail: str) -> "Dict[str, str]":
|
|
@@ -317,15 +322,11 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
|
|
|
317
322
|
parakeet_model: Optional[str] = None) -> Transcription:
|
|
318
323
|
"""`model` is what the Whisper engines run; the Parakeet engines are offered `parakeet_model`
|
|
319
324
|
(default `model`), which differs only for a Parakeet model from $FFMPEG_SKILL_ASR_MODEL."""
|
|
320
|
-
from _common import
|
|
325
|
+
from _common import STATE
|
|
321
326
|
pmodel = model if parakeet_model is None else parakeet_model
|
|
322
327
|
language = engine_language(language)
|
|
323
328
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
324
|
-
|
|
325
|
-
# is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
|
|
326
|
-
# reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
|
|
327
|
-
run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
328
|
-
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
|
|
329
|
+
engine_wav(ffmpeg, video, audio_stream, wav)
|
|
329
330
|
# every engine found that did not transcribe, and why: the refusal names them rather than
|
|
330
331
|
# telling the caller to install what is installed
|
|
331
332
|
tried: "List[Dict[str, str]]" = []
|
|
@@ -980,6 +981,37 @@ def _heard_nothing(text: str, key: str) -> bool:
|
|
|
980
981
|
return isinstance(doc, dict) and doc.get(key) == []
|
|
981
982
|
|
|
982
983
|
|
|
984
|
+
def _whisper_heard_nothing(path: str, key: str) -> bool:
|
|
985
|
+
"""True when a Whisper engine's JSON is well formed and none of its segments (`key`:
|
|
986
|
+
whisper.cpp's "transcription", openai-whisper's "segments") holds any text: the engine heard
|
|
987
|
+
no speech (#414). A document with text and no word timings is a build without them, not
|
|
988
|
+
silence, and stays the "no word-level timings" refusal."""
|
|
989
|
+
try:
|
|
990
|
+
doc = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
991
|
+
except (OSError, ValueError):
|
|
992
|
+
return False
|
|
993
|
+
if not isinstance(doc, dict) or not isinstance(doc.get(key), list):
|
|
994
|
+
return False
|
|
995
|
+
# every segment a dict with a text field, all of them blank: a segment with no `text` at all is
|
|
996
|
+
# output this skill cannot read, not silence (as _heard_nothing() reads Parakeet's)
|
|
997
|
+
return all(isinstance(seg, dict) and isinstance(seg.get("text"), str) and not seg["text"].strip()
|
|
998
|
+
for seg in doc[key])
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def engine_wav(ffmpeg: str, video: str, audio_stream: int, wav: str) -> None:
|
|
1002
|
+
"""The 16 kHz mono WAV an engine transcribes, on the file's clock: a sound that starts after
|
|
1003
|
+
the file's start is padded with silence to its place (aresample first_pts=0, as
|
|
1004
|
+
decode_pcm_mono() does since #409), so every cue and word time counts from the file's start,
|
|
1005
|
+
not from the sound's first sample (#414: a sound 0.276 s late gave cues 0.276 s early).
|
|
1006
|
+
A measurement input in our own temp dir, not a deliverable, so not a run() call (no
|
|
1007
|
+
--dry-run gate, not recorded); it keeps the time limit and reports an unreadable input as
|
|
1008
|
+
kind ffmpeg instead of a CalledProcessError traceback."""
|
|
1009
|
+
from _common import run_analysis
|
|
1010
|
+
run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
1011
|
+
"-map", f"0:a:{audio_stream}", "-vn", "-af", "aresample=async=1:first_pts=0",
|
|
1012
|
+
"-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
|
|
1013
|
+
|
|
1014
|
+
|
|
983
1015
|
def transcribe_words(video: str, language: "Optional[str]" = None, model: "Optional[str]" = None,
|
|
984
1016
|
audio_stream: int = 0, engine: "Optional[str]" = None) -> "Tuple[List[Dict[str, Any]], Optional[str]]":
|
|
985
1017
|
"""transcribe_words_result()'s (words, engine) alone: the call shape this function has had
|
|
@@ -1021,9 +1053,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1021
1053
|
tmpdir = _tempfile.mkdtemp(prefix="ffskill_asrw_")
|
|
1022
1054
|
try:
|
|
1023
1055
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
1024
|
-
|
|
1025
|
-
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000",
|
|
1026
|
-
"-c:a", "pcm_s16le", wav])
|
|
1056
|
+
engine_wav(ffmpeg, video, audio_stream, wav)
|
|
1027
1057
|
|
|
1028
1058
|
tried: "List[Dict[str, str]]" = [] # engines found that gave no words, and why
|
|
1029
1059
|
|
|
@@ -1065,6 +1095,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1065
1095
|
proc = _asr_run(cmd, _subprocess, "whisper.cpp")
|
|
1066
1096
|
if proc.returncode == 0 and os.path.exists(base + ".json"):
|
|
1067
1097
|
words = _words_from_whisper_cpp_json(base + ".json")
|
|
1098
|
+
if not words and _whisper_heard_nothing(base + ".json", "transcription"):
|
|
1099
|
+
die_no_speech("whisper.cpp", video)
|
|
1068
1100
|
info(f"word timings from whisper.cpp ({len(words)} words)")
|
|
1069
1101
|
return Transcription(words=words, engine="whisper.cpp",
|
|
1070
1102
|
facts=dict(facts, engine="whisper.cpp", model=model_path,
|
|
@@ -1083,6 +1115,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1083
1115
|
collected: list = []
|
|
1084
1116
|
crashed: list = []
|
|
1085
1117
|
heard: list = []
|
|
1118
|
+
spoke: list = [] # a segment with text: speech was heard, word timings or not
|
|
1086
1119
|
|
|
1087
1120
|
def work() -> None:
|
|
1088
1121
|
try:
|
|
@@ -1091,6 +1124,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1091
1124
|
**({"initial_prompt": prompt} if prompt else {}))
|
|
1092
1125
|
heard.append(getattr(decoded, "language", None))
|
|
1093
1126
|
for seg in segments:
|
|
1127
|
+
if str(getattr(seg, "text", "") or "").strip():
|
|
1128
|
+
spoke.append(True)
|
|
1094
1129
|
for w in (getattr(seg, "words", None) or []):
|
|
1095
1130
|
collected.append({"word": str(w.word).strip(),
|
|
1096
1131
|
"start": float(w.start), "end": float(w.end)})
|
|
@@ -1109,6 +1144,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1109
1144
|
tried.append(_failed("faster-whisper", "found but failed: " + line))
|
|
1110
1145
|
failed = failed or "faster-whisper"
|
|
1111
1146
|
else:
|
|
1147
|
+
if not collected and not spoke:
|
|
1148
|
+
die_no_speech("faster-whisper", video)
|
|
1112
1149
|
info(f"word timings from faster-whisper ({len(collected)} words)")
|
|
1113
1150
|
return Transcription(words=[w for w in collected if w["word"]], engine="faster-whisper",
|
|
1114
1151
|
facts=dict(facts, engine="faster-whisper", model=model,
|
|
@@ -1129,6 +1166,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1129
1166
|
doc = os.path.join(tmpdir, "audio.json")
|
|
1130
1167
|
if proc.returncode == 0 and os.path.exists(doc):
|
|
1131
1168
|
words = _words_from_openai_whisper_json(doc)
|
|
1169
|
+
if not words and _whisper_heard_nothing(doc, "segments"):
|
|
1170
|
+
die_no_speech("openai-whisper", video)
|
|
1132
1171
|
info(f"word timings from openai-whisper ({len(words)} words)")
|
|
1133
1172
|
return Transcription(words=words, engine="openai-whisper",
|
|
1134
1173
|
facts=dict(facts, engine="openai-whisper", model=model,
|
package/scripts/_common/fonts.py
CHANGED
|
@@ -61,6 +61,13 @@ def resolve_font(font_name: str) -> "Dict[str, object]":
|
|
|
61
61
|
# common CJK system fonts when the request looks CJK (so Japanese text does not render as
|
|
62
62
|
# boxes in Arial), and Arial only as the last resort.
|
|
63
63
|
wanted = _family_key(font_name)
|
|
64
|
+
# The family's own file first, from the table of what Windows installs under which file
|
|
65
|
+
# name (#414): "Malgun Gothic" is malgun.ttf and "Times New Roman" times.ttf, which no
|
|
66
|
+
# file-name prefix finds -- the prefix rule resolved them to YuGothM.ttc and arial.ttf and
|
|
67
|
+
# reported a font that is installed as substituted.
|
|
68
|
+
for name in windows_family_files(wanted):
|
|
69
|
+
if (fonts / name).is_file():
|
|
70
|
+
return {"file": str(fonts / name), "family": font_name, "substituted": False, "via": "windows_fonts"}
|
|
64
71
|
try:
|
|
65
72
|
files = sorted(fonts.iterdir()) if fonts.is_dir() else []
|
|
66
73
|
except OSError:
|
|
@@ -199,6 +206,25 @@ WINDOWS_FONTS = {
|
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
|
|
209
|
+
# Latin families Windows installs that WINDOWS_FONTS (a per-script table) does not name, by the
|
|
210
|
+
# file each one is installed as: a file name that does not start with the family's name
|
|
211
|
+
WINDOWS_LATIN_FONTS = [("arial.ttf", "Arial"), ("times.ttf", "Times New Roman"), ("cour.ttf", "Courier New"),
|
|
212
|
+
("segoeui.ttf", "Segoe UI"), ("tahoma.ttf", "Tahoma"), ("verdana.ttf", "Verdana"),
|
|
213
|
+
("georgia.ttf", "Georgia"), ("calibri.ttf", "Calibri"), ("cambria.ttc", "Cambria"),
|
|
214
|
+
("consola.ttf", "Consolas"), ("trebuc.ttf", "Trebuchet MS"), ("comic.ttf", "Comic Sans MS"),
|
|
215
|
+
("impact.ttf", "Impact"), ("seguiemj.ttf", "Segoe UI Emoji")]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def windows_family_files(wanted_key: str) -> "List[str]":
|
|
219
|
+
"""The Windows Fonts file names of the family whose _family_key() is `wanted_key`, best first,
|
|
220
|
+
from WINDOWS_FONTS and WINDOWS_LATIN_FONTS ([] for a family neither names)."""
|
|
221
|
+
out: "List[str]" = []
|
|
222
|
+
for name, family in [e for entries in WINDOWS_FONTS.values() for e in entries] + WINDOWS_LATIN_FONTS:
|
|
223
|
+
if wanted_key and _family_key(family) == wanted_key and name not in out:
|
|
224
|
+
out.append(name)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
202
228
|
_SCRIPT_RANGES = (
|
|
203
229
|
("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF))), # Hangul syllables + Jamo
|
|
204
230
|
("kana", ((0x3040, 0x309F), (0x30A0, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F))), # hiragana/katakana
|
package/scripts/_common/probe.py
CHANGED
|
@@ -146,23 +146,40 @@ def _tag(entry: Dict[str, Any], key: str) -> Optional[str]:
|
|
|
146
146
|
return next((v for k, v in (entry.get("tags") or {}).items() if k.lower() == key), None)
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
def _nominal_fps(video: Optional[Dict[str, Any]]) -> Optional[int]:
|
|
150
|
+
"""The integer timecode rate of the picture (30 for 29.97), from avg_frame_rate, else
|
|
151
|
+
r_frame_rate: the order probe()'s `fps` uses, which the timeline export validates against."""
|
|
152
|
+
for key in ("avg_frame_rate", "r_frame_rate"):
|
|
153
|
+
try:
|
|
154
|
+
num, _, den = str((video or {}).get(key) or "").partition("/")
|
|
155
|
+
rate = float(num) / float(den or 1)
|
|
156
|
+
except (ValueError, ZeroDivisionError):
|
|
157
|
+
continue
|
|
158
|
+
if 0 < rate <= 240:
|
|
159
|
+
return int(round(rate))
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
149
163
|
def start_timecode(streams: List[Dict[str, Any]], fmt: Dict[str, Any],
|
|
150
164
|
video: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
151
165
|
"""The first well-formed `timecode` tag, from the video stream, then a `tmcd` data stream,
|
|
152
166
|
then the container: {value, drop_frame, source}. `drop_frame` is the `;` (or `.`) separator
|
|
153
167
|
before the frames field; `source` is `stream:<ffprobe index>` or `format`. None when no
|
|
154
|
-
source carries one (all three null). A tag that is not HH:MM:SS[:;.]FF
|
|
155
|
-
repaired."""
|
|
168
|
+
source carries one (all three null). A tag that is not HH:MM:SS[:;.]FF, or whose frames field
|
|
169
|
+
the picture's rate cannot reach, is skipped, not repaired."""
|
|
156
170
|
candidates = []
|
|
157
171
|
if video is not None:
|
|
158
172
|
candidates.append((video, f"stream:{video.get('index')}"))
|
|
159
173
|
candidates += [(s, f"stream:{s.get('index')}") for s in streams
|
|
160
174
|
if s.get("codec_type") == "data" and str(s.get("codec_tag_string", "")).lower() == "tmcd"]
|
|
161
175
|
candidates.append((fmt, "format"))
|
|
176
|
+
nominal = _nominal_fps(video)
|
|
162
177
|
for entry, source in candidates:
|
|
163
178
|
value = _tag(entry, "timecode")
|
|
164
179
|
m = _TIMECODE.match(value.strip()) if isinstance(value, str) else None
|
|
165
|
-
|
|
180
|
+
# a frames field the picture's rate cannot reach (frame 29 at 25 fps) is no timecode
|
|
181
|
+
# either: the next candidate (a tmcd track's own tag) may still hold a real one (#427)
|
|
182
|
+
if m and not (nominal and int(m.group(5)) >= nominal):
|
|
166
183
|
return {"value": value.strip(), "drop_frame": m.group(4) in ";.", "source": source}
|
|
167
184
|
return {"value": None, "drop_frame": None, "source": None}
|
|
168
185
|
|
|
@@ -1486,6 +1486,40 @@ def _has_non_av_streams(cmd: Sequence[str]) -> bool:
|
|
|
1486
1486
|
return not kinds or bool(kinds & {"subtitle", "data", "attachment"})
|
|
1487
1487
|
|
|
1488
1488
|
|
|
1489
|
+
# eia_608 (a QuickTime c608 caption track) decodes to text, so it converts like one (ffmpeg 6.1)
|
|
1490
|
+
TEXT_SUBTITLES = frozenset({"mov_text", "subrip", "srt", "ass", "ssa", "webvtt", "text", "eia_608"})
|
|
1491
|
+
MP4_FAMILY = frozenset({"mp4", "m4v", "mov"})
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def subtitle_codec_for(codec: str, out_ext: str) -> str:
|
|
1495
|
+
"""The -c:s for one source subtitle stream going into a container with extension `out_ext`
|
|
1496
|
+
(no dot): copy when the container holds it as it is, else the container's own text format --
|
|
1497
|
+
mov_text in MPEG-4, srt in Matroska for a mov_text track, webvtt in WebM. A bitmap track is
|
|
1498
|
+
returned as copy; subtitle_container_holds() says whether that copy can work."""
|
|
1499
|
+
out_ext = out_ext.lstrip(".").lower()
|
|
1500
|
+
if out_ext == "mov" and codec == "eia_608":
|
|
1501
|
+
return "copy" # QuickTime holds a c608 track as it is; .mp4 has no tag for it
|
|
1502
|
+
if out_ext in MP4_FAMILY and codec in TEXT_SUBTITLES and codec != "mov_text":
|
|
1503
|
+
return "mov_text"
|
|
1504
|
+
if out_ext == "mkv" and codec == "mov_text":
|
|
1505
|
+
return "srt"
|
|
1506
|
+
if out_ext == "webm" and codec in TEXT_SUBTITLES and codec != "webvtt":
|
|
1507
|
+
return "webvtt"
|
|
1508
|
+
return "copy"
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def subtitle_container_holds(codec: str, out_ext: str) -> bool:
|
|
1512
|
+
"""Whether a subtitle stream of `codec` can be written to an `out_ext` container at all,
|
|
1513
|
+
converted by subtitle_codec_for() where it must be: Matroska takes any, MPEG-4 a text track or
|
|
1514
|
+
DVD bitmaps, WebM only text (as WebVTT)."""
|
|
1515
|
+
out_ext = out_ext.lstrip(".").lower()
|
|
1516
|
+
if out_ext in MP4_FAMILY:
|
|
1517
|
+
return codec in TEXT_SUBTITLES or codec == "dvd_subtitle"
|
|
1518
|
+
if out_ext == "webm":
|
|
1519
|
+
return codec in TEXT_SUBTITLES
|
|
1520
|
+
return True
|
|
1521
|
+
|
|
1522
|
+
|
|
1489
1523
|
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
1490
1524
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
1491
1525
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
@@ -23,6 +23,7 @@ under `not_exported`, never silently dropped.
|
|
|
23
23
|
import json
|
|
24
24
|
import math
|
|
25
25
|
import os
|
|
26
|
+
import re
|
|
26
27
|
from fractions import Fraction
|
|
27
28
|
from pathlib import Path
|
|
28
29
|
from typing import Any, Dict, List, Optional, Tuple
|
|
@@ -110,6 +111,48 @@ def _picture(meta: Dict[str, Any]) -> Optional[Tuple[int, int]]:
|
|
|
110
111
|
return (w, h)
|
|
111
112
|
|
|
112
113
|
|
|
114
|
+
_TC = re.compile(r"^(\d{2}):(\d{2}):(\d{2})[:;.](\d{2,3})$")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _drop_frame(drop: Optional[bool], rate: Fraction) -> bool:
|
|
118
|
+
"""Drop-frame counting applies to the NTSC rates only (29.97 and 59.94)."""
|
|
119
|
+
return bool(drop) and rate.denominator == 1001 and round(float(rate)) in (30, 60)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def start_tc_frames(tc: Dict[str, Any], rate: Fraction) -> int:
|
|
123
|
+
"""A source's start timecode (probe()'s `timecode`: {value, drop_frame}) as a frame count at
|
|
124
|
+
its own `rate`, drop-frame counted where it is one; 0 when the source has none (#414). A
|
|
125
|
+
label that does not exist at that rate (frame 29 at 25 fps, or a frame drop-frame counting
|
|
126
|
+
skips) is read as none, as probe() skips a malformed tag, rather than as a shifted start."""
|
|
127
|
+
m = _TC.match(str((tc or {}).get("value") or "").strip())
|
|
128
|
+
if not m:
|
|
129
|
+
return 0
|
|
130
|
+
h, mi, se, fr = (int(g) for g in m.groups())
|
|
131
|
+
nominal = int(round(float(rate)))
|
|
132
|
+
if mi > 59 or se > 59 or fr >= nominal:
|
|
133
|
+
return 0
|
|
134
|
+
frames = ((h * 60 + mi) * 60 + se) * nominal + fr
|
|
135
|
+
if _drop_frame(tc.get("drop_frame"), rate):
|
|
136
|
+
if se == 0 and mi % 10 and fr < nominal // 15:
|
|
137
|
+
return 0
|
|
138
|
+
minutes = h * 60 + mi
|
|
139
|
+
frames -= (nominal // 15) * (minutes - minutes // 10)
|
|
140
|
+
return frames
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _tc_label(frames: int, rate: Fraction, drop: bool) -> str:
|
|
144
|
+
"""`frames` as hh:mm:ss:ff at the nominal integer rate; drop-frame (`;`) for an NTSC source
|
|
145
|
+
whose own timecode is drop-frame, so the label is the one the media itself shows."""
|
|
146
|
+
nominal = int(round(float(rate)))
|
|
147
|
+
if not _drop_frame(drop, rate):
|
|
148
|
+
return _tc(frames, nominal)
|
|
149
|
+
d = nominal // 15
|
|
150
|
+
per_10, per_min = nominal * 600 - d * 9, nominal * 60 - d
|
|
151
|
+
tens, rem = divmod(frames, per_10)
|
|
152
|
+
frames += d * 9 * tens + (d * ((rem - d) // per_min) if rem > d else 0)
|
|
153
|
+
return _tc(frames, nominal)[:-3] + ";" + _tc(frames, nominal)[-2:]
|
|
154
|
+
|
|
155
|
+
|
|
113
156
|
def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
|
|
114
157
|
"""The editor-neutral timeline: every time in frames at `rate`.
|
|
115
158
|
|
|
@@ -172,6 +215,14 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
|
|
|
172
215
|
if start < 0:
|
|
173
216
|
raise TimelineError(f"clips[{i}].in must not be negative, got {c['in']!r}")
|
|
174
217
|
end = _seconds(c["out"], f"clips[{i}].out", clip_fps) if c.get("out") is not None else dur
|
|
218
|
+
# as cut.py reads them (#414): an in at or past the media's end is refused, an out past it
|
|
219
|
+
# is the media's end -- the render clamps it there, so the timeline does too, and says so
|
|
220
|
+
if dur > 0 and start >= dur:
|
|
221
|
+
raise TimelineError(f"clips[{i}].in ({start:g}s) is at or past the end of {os.path.basename(src)} ({dur:.3f}s)")
|
|
222
|
+
if dur > 0 and end > dur + 1e-6:
|
|
223
|
+
notes.append(f"clips[{i}].out ({end:g}s) is past the end of {os.path.basename(src)} ({dur:.3f}s): "
|
|
224
|
+
"the clip ends at the media's end, as the render cuts it")
|
|
225
|
+
end = dur
|
|
175
226
|
if end <= start:
|
|
176
227
|
raise TimelineError(f"clip {i}: out ({end:g}) is not after in ({start:g})")
|
|
177
228
|
speed = _number(c.get("speed") or 1.0, f"clips[{i}].speed")
|
|
@@ -182,8 +233,17 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
|
|
|
182
233
|
if reframe:
|
|
183
234
|
reframed.append(os.path.basename(src))
|
|
184
235
|
src_in, src_out = fr(start), fr(end)
|
|
236
|
+
# the source's own clock (#414): its frame rate, and its start timecode as a frame count at
|
|
237
|
+
# that rate. EDL source timecodes are counted at it (a 25 fps clip in a 29.97 sequence has
|
|
238
|
+
# no frame :29); FCPXML and OTIO start the media at it, so a conform finds the frames.
|
|
239
|
+
clip_rate = exact_rate(float(clip_fps)) if clip_fps else rate
|
|
240
|
+
tc = meta.get("timecode") or {}
|
|
241
|
+
tc_start = start_tc_frames(tc, clip_rate)
|
|
185
242
|
clips.append({"index": i, "src": os.path.abspath(src), "name": os.path.basename(src),
|
|
186
243
|
"src_in": src_in, "src_out": src_out, "speed": speed,
|
|
244
|
+
"in_seconds": Fraction(start).limit_denominator(1000000), "rate": clip_rate,
|
|
245
|
+
"tc_start": tc_start, "tc_seconds": Fraction(tc_start) / clip_rate,
|
|
246
|
+
"drop_frame": _drop_frame(tc.get("drop_frame"), clip_rate),
|
|
187
247
|
"length": int(round((src_out - src_in) / speed)),
|
|
188
248
|
"media_frames": fr(dur), "has_video": bool(meta.get("video")),
|
|
189
249
|
"has_audio": bool(meta.get("audio")),
|
|
@@ -219,12 +279,24 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
|
|
|
219
279
|
markers: List[Dict[str, Any]] = []
|
|
220
280
|
chapters = proj.get("chapters")
|
|
221
281
|
if isinstance(chapters, list):
|
|
282
|
+
total_seconds = Fraction(total) / rate
|
|
283
|
+
previous: Optional[float] = None
|
|
222
284
|
for j, ch in enumerate(chapters):
|
|
223
285
|
# read as render.py's chapters stage reads it (metadata.py): a chapter list carries
|
|
224
|
-
# no fps, so hh:mm:ss:ff needs its @fps there and here alike
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
286
|
+
# no fps, so hh:mm:ss:ff needs its @fps there and here alike. What metadata.py refuses
|
|
287
|
+
# is refused here too, never dropped (#414): a chapter at or past the end, or one that
|
|
288
|
+
# does not come after the one before it.
|
|
289
|
+
at_s = _seconds(ch.get("at", 0), f"chapters[{j}].at", None)
|
|
290
|
+
if at_s < 0:
|
|
291
|
+
raise TimelineError(f"chapters[{j}].at must not be negative, got {ch.get('at')!r}")
|
|
292
|
+
if previous is not None and at_s <= previous:
|
|
293
|
+
raise TimelineError(f"chapters[{j}]: the chapter at {at_s:g}s does not come after the previous one "
|
|
294
|
+
f"at {previous:g}s (metadata.py refuses it too)")
|
|
295
|
+
if at_s >= total_seconds:
|
|
296
|
+
raise TimelineError(f"chapters[{j}]: the chapter at {at_s:g}s starts at or after the end of the "
|
|
297
|
+
f"timeline ({float(total_seconds):.3f}s); metadata.py refuses it too")
|
|
298
|
+
previous = at_s
|
|
299
|
+
markers.append({"at": min(fr(at_s), total - 1), "title": str(ch.get("title") or "")})
|
|
228
300
|
elif chapters:
|
|
229
301
|
notes.append("chapters given as a file are not exported as markers; write them inline as [{at, title}]")
|
|
230
302
|
|
|
@@ -283,27 +355,38 @@ def to_edl(tl: Dict[str, Any]) -> str:
|
|
|
283
355
|
ends = starts[1:] + [tl["total"]]
|
|
284
356
|
lines = [f"TITLE: {tl['name']}", "FCM: NON-DROP FRAME", ""]
|
|
285
357
|
n = 0
|
|
286
|
-
prev_src_end =
|
|
358
|
+
prev_src_end = ""
|
|
359
|
+
rate = tl["rate"]
|
|
360
|
+
|
|
361
|
+
def src_tc(c: Dict[str, Any], seq_frames: int) -> str:
|
|
362
|
+
"""The source timecode `seq_frames` (sequence frames of source media) after the clip's in
|
|
363
|
+
point: counted at the clip's own rate from its own start timecode (#414)."""
|
|
364
|
+
own = c.get("rate") or rate
|
|
365
|
+
seconds = c.get("in_seconds", Fraction(c["src_in"]) / rate) + Fraction(seq_frames) / rate
|
|
366
|
+
return _tc_label(int(round(seconds * own)) + c.get("tc_start", 0), own, c.get("drop_frame", False))
|
|
367
|
+
|
|
287
368
|
for i, c in enumerate(clips):
|
|
288
369
|
n += 1
|
|
289
370
|
chan = "B" if c["has_audio"] and c["has_video"] else ("V" if c["has_video"] else "A")
|
|
290
371
|
rec_in, rec_out = hour + starts[i], hour + ends[i]
|
|
291
|
-
s_in = c
|
|
292
|
-
s_out =
|
|
372
|
+
s_in = src_tc(c, 0)
|
|
373
|
+
s_out = src_tc(c, int(round((ends[i] - starts[i]) * c["speed"])))
|
|
293
374
|
if i > 0 and d:
|
|
294
|
-
lines.append(f"{n:03d} AX {chan:<5} C {
|
|
375
|
+
lines.append(f"{n:03d} AX {chan:<5} C {prev_src_end} {prev_src_end} "
|
|
295
376
|
f"{_tc(rec_in, fps_int)} {_tc(rec_in, fps_int)}")
|
|
296
|
-
lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {
|
|
377
|
+
lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {s_in} {s_out} "
|
|
297
378
|
f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
|
|
298
379
|
lines.append(f"* FROM CLIP NAME: {clips[i - 1]['name']}")
|
|
299
380
|
lines.append(f"* TO CLIP NAME: {c['name']}")
|
|
300
381
|
else:
|
|
301
|
-
lines.append(f"{n:03d} AX {chan:<5} C {
|
|
382
|
+
lines.append(f"{n:03d} AX {chan:<5} C {s_in} {s_out} "
|
|
302
383
|
f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
|
|
303
384
|
lines.append(f"* FROM CLIP NAME: {c['name']}")
|
|
304
385
|
lines.append(f"* SOURCE FILE: {c['src']}")
|
|
305
386
|
if abs(c["speed"] - 1.0) > 1e-6:
|
|
306
|
-
|
|
387
|
+
# the speed in source frames per second, at the rate the source labels are counted in
|
|
388
|
+
own_fps = int(round(float(c.get("rate") or rate)))
|
|
389
|
+
lines.append(f"M2 AX {own_fps * c['speed']:05.1f} {s_in}")
|
|
307
390
|
lines.append("")
|
|
308
391
|
prev_src_end = s_out
|
|
309
392
|
if tl["music"]:
|
|
@@ -326,6 +409,11 @@ def _t(frames: int, rate: Fraction) -> str:
|
|
|
326
409
|
return f"{value.numerator}s" if value.denominator == 1 else f"{value.numerator}/{value.denominator}s"
|
|
327
410
|
|
|
328
411
|
|
|
412
|
+
def _ts(value: Fraction) -> str:
|
|
413
|
+
"""A rational FCPXML time from seconds."""
|
|
414
|
+
return f"{value.numerator}s" if value.denominator == 1 else f"{value.numerator}/{value.denominator}s"
|
|
415
|
+
|
|
416
|
+
|
|
329
417
|
def _url(path: str) -> str:
|
|
330
418
|
# pathlib builds the percent-encoded file:// URL itself (and the drive-letter form on
|
|
331
419
|
# Windows): no network-library import in scripts/, not even for quoting (see the no-fetch test)
|
|
@@ -361,12 +449,14 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
361
449
|
return seq_fmt
|
|
362
450
|
return fmt(c["picture"][0], c["picture"][1], _t(1, exact_rate(float(c["fps"]))) if c["fps"] else fd)
|
|
363
451
|
|
|
364
|
-
def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool, format_id: str = ""
|
|
452
|
+
def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool, format_id: str = "",
|
|
453
|
+
start: Fraction = Fraction(0)) -> str:
|
|
365
454
|
if path in ids:
|
|
366
455
|
return ids[path]
|
|
367
456
|
rid = f"r{len(ids) + 1}"
|
|
368
457
|
ids[path] = rid
|
|
369
|
-
|
|
458
|
+
# the media starts at its own start timecode (#414): a clip's start is counted from there
|
|
459
|
+
res.append(f' <asset id="{rid}" name="{q(name)}" start="{_ts(start)}" duration="{_t(frames, rate)}" '
|
|
370
460
|
f'hasVideo="{int(has_video)}" hasAudio="{int(has_audio)}"'
|
|
371
461
|
+ (f' format="{format_id}"' if has_video else "") + ' audioSources="1" audioChannels="2">\n'
|
|
372
462
|
f' <media-rep kind="original-media" src="{q(_url(path))}"/>\n </asset>')
|
|
@@ -377,7 +467,8 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
377
467
|
markers = list(tl["markers"])
|
|
378
468
|
for i, c in enumerate(tl["clips"]):
|
|
379
469
|
cid = clip_fmt(c)
|
|
380
|
-
|
|
470
|
+
base = c.get("tc_seconds", Fraction(0))
|
|
471
|
+
rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"], cid, base)
|
|
381
472
|
if i > 0 and d:
|
|
382
473
|
spine.append(f' <transition name="Cross Dissolve" offset="{_t(c["record_in"] - c["trim_head"], rate)}" '
|
|
383
474
|
f'duration="{_t(d, rate)}"/>')
|
|
@@ -386,14 +477,15 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
386
477
|
if abs(c["speed"] - 1.0) > 1e-6:
|
|
387
478
|
# timeMap maps clip-local time to source time; two linear points give a constant speed
|
|
388
479
|
inner.append(" <timeMap>")
|
|
389
|
-
inner.append(f' <timept time="0s" value="{
|
|
480
|
+
inner.append(f' <timept time="0s" value="{_ts(base + Fraction(src_start) / rate)}" interp="linear"/>')
|
|
390
481
|
inner.append(f' <timept time="{_t(c["visible"], rate)}" '
|
|
391
|
-
f'value="{
|
|
482
|
+
f'value="{_ts(base + Fraction(src_start + int(round(c["visible"] * c["speed"]))) / rate)}" interp="linear"/>')
|
|
392
483
|
inner.append(" </timeMap>")
|
|
393
484
|
start_attr = "0s"
|
|
394
485
|
else:
|
|
395
|
-
start_attr =
|
|
396
|
-
|
|
486
|
+
start_attr = _ts(base + Fraction(src_start) / rate)
|
|
487
|
+
# the clip's local time: the source's (from its start timecode), or 0 under a timeMap
|
|
488
|
+
local0 = base + Fraction(src_start) / rate if abs(c["speed"] - 1.0) <= 1e-6 else Fraction(0)
|
|
397
489
|
# the FCPXML 1.10 DTD's order inside an asset-clip: timeMap, then the adjust-* elements
|
|
398
490
|
# (adjust-conform), then anchored items (the music bed), then markers. Anything out of
|
|
399
491
|
# that order does not validate, and Final Cut validates what it imports against that DTD.
|
|
@@ -404,13 +496,14 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
404
496
|
if i == 0 and tl["music"]:
|
|
405
497
|
m = tl["music"]
|
|
406
498
|
mid = asset(m["src"], m["name"], m["media_frames"], False, True)
|
|
407
|
-
inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{
|
|
499
|
+
inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{_ts(local0)}" name="{q(m["name"])}" '
|
|
408
500
|
f'start="0s" duration="{_t(m["length"], rate)}" audioRole="music"/>')
|
|
409
501
|
for mk in [m for m in markers if c["record_in"] <= m["at"] < c["record_in"] + c["visible"]]:
|
|
410
|
-
inner.append(f' <chapter-marker start="{
|
|
502
|
+
inner.append(f' <chapter-marker start="{_ts(local0 + Fraction(mk["at"] - c["record_in"]) / rate)}" '
|
|
411
503
|
f'duration="{fd}" value="{q(mk["title"])}"/>')
|
|
412
504
|
head = (f' <asset-clip ref="{rid}" offset="{_t(c["record_in"], rate)}" name="{q(c["name"])}" '
|
|
413
|
-
f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="{cid}"
|
|
505
|
+
f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="{cid}" '
|
|
506
|
+
f'tcFormat="{"DF" if c.get("drop_frame") else "NDF"}"')
|
|
414
507
|
spine.append(head + (">\n" + "\n".join(inner) + "\n </asset-clip>" if inner else "/>"))
|
|
415
508
|
out = ['<?xml version="1.0" encoding="UTF-8"?>', "<!DOCTYPE fcpxml>", '<fcpxml version="1.10">',
|
|
416
509
|
" <resources>", *res, " </resources>", " <library>", f' <event name="{q(tl["name"])}">',
|
|
@@ -444,15 +537,17 @@ def to_otio(tl: Dict[str, Any]) -> str:
|
|
|
444
537
|
# OTIO: in_offset is the part before the cut, out_offset the part after
|
|
445
538
|
"in_offset": _rt(c["trim_head"], rate), "out_offset": _rt(tl["clips"][i - 1]["trim_tail"], rate)})
|
|
446
539
|
src_start = c["src_in"] + int(round(c["trim_head"] * c["speed"]))
|
|
540
|
+
# the media's range starts at its start timecode (#414), in timeline frames
|
|
541
|
+
base = c.get("tc_seconds", Fraction(0)) * rate
|
|
447
542
|
clip = {"OTIO_SCHEMA": "Clip.2", "name": c["name"], "metadata": {}, "enabled": True,
|
|
448
543
|
# OTIO's own convention: source_range starts at the media's in point and lasts the
|
|
449
544
|
# clip's length on the timeline; a LinearTimeWarp says how fast the media runs
|
|
450
545
|
# through it (core OTIO does not rescale durations by effects).
|
|
451
|
-
"source_range": _range(src_start, c["visible"], rate),
|
|
546
|
+
"source_range": _range(base + src_start, c["visible"], rate),
|
|
452
547
|
"effects": [], "markers": [],
|
|
453
548
|
"media_references": {"DEFAULT_MEDIA": {
|
|
454
549
|
"OTIO_SCHEMA": "ExternalReference.1", "name": c["name"], "metadata": {},
|
|
455
|
-
"target_url": _url(c["src"]), "available_range": _range(
|
|
550
|
+
"target_url": _url(c["src"]), "available_range": _range(base, c["media_frames"], rate),
|
|
456
551
|
"available_image_bounds": None}},
|
|
457
552
|
"active_media_reference_key": "DEFAULT_MEDIA"}
|
|
458
553
|
if abs(c["speed"] - 1.0) > 1e-6:
|
package/scripts/_contract.py
CHANGED
|
@@ -440,6 +440,9 @@ ERROR_REASONS = {
|
|
|
440
440
|
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
441
441
|
"asr_engine_missing": "kind missing_tool: --transcribe (caption.py, silence.py --filler) found no local speech-to-text engine; the message lists the install lines; details: {}",
|
|
442
442
|
"encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
|
|
443
|
+
"no_speech": "kind input: --transcribe (caption.py, silence.py --filler) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
|
|
444
|
+
"engine_failed": "kind input: --transcribe: an installed speech engine ran and failed (a named --engine, or every engine --engine auto found); details: {engine, detail | engines: [{engine, reason, detail}]}; also top-level reason, engine and detail/engines",
|
|
445
|
+
"english_only": "kind input: --transcribe under --engine auto found only Parakeet engines, which it runs for English speech only, and the language is not English; details: {engine, engines}; also top-level reason, engine and engines",
|
|
443
446
|
}
|
|
444
447
|
|
|
445
448
|
# The codes of the collector's records (docs/design-decisions.md, "Nothing happens silently"):
|
|
@@ -547,13 +550,23 @@ FONT_USED_SCHEMA = {
|
|
|
547
550
|
}
|
|
548
551
|
|
|
549
552
|
|
|
553
|
+
# the tools whose --json carries `dropped_non_av_streams` (a test reads the sources)
|
|
554
|
+
NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "cut", "deinterlace", "denoise", "export", "fit", "freeze",
|
|
555
|
+
"graphics", "join", "loudness", "overlay", "pad", "proxy", "redact", "speedramp",
|
|
556
|
+
"sphere", "straighten"})
|
|
557
|
+
|
|
558
|
+
|
|
550
559
|
def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
|
|
551
560
|
"""What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
|
|
552
561
|
tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
|
|
553
562
|
if name == "probe":
|
|
554
563
|
return {"type": "object", "description": "one probe document, or an array of them for several inputs",
|
|
555
564
|
"properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
|
|
556
|
-
"video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]}
|
|
565
|
+
"video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
|
|
566
|
+
"timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
|
|
567
|
+
"chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
|
|
568
|
+
"subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"}},
|
|
569
|
+
"additionalProperties": True}
|
|
557
570
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
558
571
|
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
|
|
559
572
|
"probe": {"type": "object", "description": "probe of the output when a file was written"},
|
|
@@ -594,6 +607,13 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
594
607
|
"speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"}}
|
|
595
608
|
elif name == "silence":
|
|
596
609
|
extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
|
|
610
|
+
"removed_seconds_total": {"type": "number", "description": "--filler: seconds removed in all, silence plus filler words (removed_seconds stays the silence alone)"},
|
|
611
|
+
"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)"},
|
|
612
|
+
"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"},
|
|
613
|
+
"timemap": {"type": "string", "description": "--timemap FILE: the source-to-output time map written (JSON or CSV)"},
|
|
614
|
+
"silence_kinds": {"type": "array", "description": "2.20: per detected silence, digital (at or below -90 dBFS) or below_threshold"},
|
|
615
|
+
"silence_breakdown": {"type": "object", "description": "2.20: {digital, below_threshold}: the count and seconds of each, with the level that defines it"},
|
|
616
|
+
"hint": {"type": "string", "description": "when nothing sat below --threshold: the track's measured level and the --threshold that would find its pauses"},
|
|
597
617
|
"speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"},
|
|
598
618
|
"filler": {"type": "object", "description": "--filler: {lang, source (whisper-json:FILE | whisper:ENGINE | parakeet:ENGINE), engine, transcription (with --transcribe: {engine, model, language, routing}), removed, removed_count, matched_untimed (listed words in the transcript with no usable timing, not removed), matched_too_long (listed words longer than a filler, kept), ...}"},
|
|
599
619
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "--filler --transcribe under --engine auto: present when the English-only Parakeet model ran on English assumed (no --filler-lang, no language detected, no Whisper engine installed or none that succeeded); the transcript is wrong if the speech is not English"}}
|
|
@@ -651,6 +671,15 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
651
671
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
|
|
652
672
|
elif name == "cut":
|
|
653
673
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
674
|
+
"mode": {"enum": ["copy", "hybrid", "accurate"], "description": "copy: nothing re-encoded at any stage, the join included; accurate: --accurate was asked for or forced; hybrid: asked for lossless but a segment or the join re-encoded anyway (reencode_reason says why)"},
|
|
675
|
+
"requested_start": {"type": ["number", "null"], "description": "one segment: the requested start, clamped to the media's duration (null for --segments)"},
|
|
676
|
+
"requested_end": {"type": ["number", "null"], "description": "one segment: the requested end, clamped to the media's duration (null for --segments)"},
|
|
677
|
+
"requested_segments": {"type": ["array", "null"], "description": "--segments: [[start, end], ...] requested, clamped to the media's duration (null for one segment)"},
|
|
678
|
+
"requested_duration": {"type": "number", "description": "the requested length, as expected_duration"},
|
|
679
|
+
"output_duration": {"type": ["number", "null"], "description": "the written file's length, measured (null under --dry-run)"},
|
|
680
|
+
"duration_delta_seconds": {"type": ["number", "null"], "description": "output_duration minus requested_duration (null under --dry-run)"},
|
|
681
|
+
"nearest_keyframes": {"type": ["array", "null"], "description": "the keyframes within 5 s of a start whose stream copy snapped past --tolerance and was re-encoded (null otherwise)"},
|
|
682
|
+
"snap": {"type": ["object", "null"], "description": "--snap beats: {mode, tolerance, confidence, tempo_bpm, grid, grid_points, moved, snapped, unchanged, source}; null otherwise"},
|
|
654
683
|
"dropped_audio_streams": {"type": "integer", "description": "audio tracks of the source this cut did not write (cut writes one, on a stream copy as on a re-encode); 0 for a source with one or none. A notes line and an audio_tracks_dropped warning say so too"},
|
|
655
684
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
656
685
|
"precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
@@ -696,6 +725,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
696
725
|
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
|
|
697
726
|
if name in ("overlay", "graphics", "caption", "grid"):
|
|
698
727
|
extra["font_used"] = FONT_USED_SCHEMA
|
|
728
|
+
if name in NON_AV_STREAM_TOOLS and "dropped_non_av_streams" not in extra:
|
|
729
|
+
extra["dropped_non_av_streams"] = {"type": "boolean", "description": "true when the source's subtitle or data streams are not in the output: either the run leaves them out by design (a path whose output they would no longer match, such as speedramp.py's, join.py's or freeze.py's), or they could not be copied and the encode ran again without them (then a non_av_streams_dropped fallback says why); export.py's non_av_streams lists them"}
|
|
699
730
|
if name == "cropdetect":
|
|
700
731
|
extra.update({"windows": {"type": "integer", "description": "sampled windows (--samples)"},
|
|
701
732
|
"windows_failed": {"type": "integer", "description": "windows ffmpeg could not decode and that were skipped (a cropdetect_windows_failed warning when > 0); every window failing is a kind ffmpeg failure"}})
|
package/scripts/caption.py
CHANGED
|
@@ -54,6 +54,7 @@ from _common import asr as _asr
|
|
|
54
54
|
from _common.asr import ENGINE_CHOICES
|
|
55
55
|
from _common.emit import warn
|
|
56
56
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
57
|
+
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
57
58
|
from _common.fonts import font_choice, record_font_choice
|
|
58
59
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
59
60
|
write_srt)
|
|
@@ -145,7 +146,9 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
|
|
|
145
146
|
return [total_cs]
|
|
146
147
|
ffmpeg = require_tool("ffmpeg")
|
|
147
148
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
|
|
148
|
-
"-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn",
|
|
149
|
+
"-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn",
|
|
150
|
+
# a sound that starts late is padded to its place on the file's clock (#414)
|
|
151
|
+
"-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
|
|
149
152
|
proc = run_analysis(cmd, check=False, text=False) # under --timeout like every other measurement
|
|
150
153
|
n = len(proc.stdout) // 2
|
|
151
154
|
if proc.returncode != 0 or n < 800:
|
|
@@ -887,6 +890,23 @@ def track_title_for(code: Optional[str], given: Optional[str]) -> Optional[str]:
|
|
|
887
890
|
return LANG_TITLES.get(code.split("-")[0].lower(), code)
|
|
888
891
|
|
|
889
892
|
|
|
893
|
+
# a timing line in the grammar ffmpeg's SRT demuxer and parse_srt() read alike: hours, minutes,
|
|
894
|
+
# seconds and three-digit milliseconds on both sides (ffmpeg allows position fields after it)
|
|
895
|
+
_SHARED_SRT_TIMING = re.compile(r"^\s*\d{2,}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{2,}:\d{2}:\d{2}[,.]\d{3}(\s.*)?$")
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def srt_timings_shared_grammar(path: str) -> bool:
|
|
899
|
+
"""True when every timing line of the SRT at `path` reads the same to ffmpeg's SRT demuxer as
|
|
900
|
+
to parse_srt(). ffmpeg refuses `00:01,000` (no hours) and reads `00:00:01,5` as 1.005 s, where
|
|
901
|
+
parse_srt() reads 1.5 s (#414); such a file is handed to ffmpeg as a rewritten copy."""
|
|
902
|
+
text = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n")
|
|
903
|
+
# a cue's timing line is its first line with "-->", as parse_srt() reads it (and ffmpeg too);
|
|
904
|
+
# a later line holding "-->" is dialogue, not a timing this check should judge
|
|
905
|
+
blocks = [[ln for ln in b.split("\n") if ln.strip()] for b in re.split(r"\n\s*\n", text)]
|
|
906
|
+
timings = [next((ln for ln in b if "-->" in ln), None) for b in blocks]
|
|
907
|
+
return all(_SHARED_SRT_TIMING.match(line) for line in timings if line is not None)
|
|
908
|
+
|
|
909
|
+
|
|
890
910
|
def mux_subtitle_codec(output: str) -> str:
|
|
891
911
|
ext = Path(output).suffix.lower()
|
|
892
912
|
if ext in (".mp4", ".m4v", ".mov"):
|
|
@@ -1130,6 +1150,11 @@ def main() -> int:
|
|
|
1130
1150
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
1131
1151
|
if args.audio_stream and not audio_streams:
|
|
1132
1152
|
die("--audio-stream needs an input with audio streams")
|
|
1153
|
+
if args.transcribe and not audio_streams:
|
|
1154
|
+
# refused before any engine runs, as silence.py refuses it, not an unclassified
|
|
1155
|
+
# "Stream map '0:a:0' matches no streams" from the WAV extraction (#414)
|
|
1156
|
+
die(f"--transcribe needs speech to transcribe, and {args.input} has no audio stream",
|
|
1157
|
+
kind="input", hint="pick the file that carries the sound, or write the cues with --text")
|
|
1133
1158
|
if args.mode != "mux":
|
|
1134
1159
|
# before any transcription or sidecar: a build without libass fails these at parse time
|
|
1135
1160
|
# with a message that names no filter (#393), so ask the build first, dry run included
|
|
@@ -1380,9 +1405,15 @@ def main() -> int:
|
|
|
1380
1405
|
side_notes: List[str] = list(getattr(args, "_asr_notes", None) or [])
|
|
1381
1406
|
ass_counts: dict = {} # --ass: ass_shifted / ass_unshifted (with --offset) and ass_malformed, into `caption`
|
|
1382
1407
|
ass_sample_path = args.ass
|
|
1408
|
+
normalised_only = False # srt_path is a copy of the caller's SRT that only rewrites its timings
|
|
1383
1409
|
if args.srt and not (args.text or args.transcribe) and os.path.exists(srt_path or ""):
|
|
1384
1410
|
adjusted, changed = lay_out(parse_srt(srt_path))
|
|
1385
|
-
|
|
1411
|
+
# ffmpeg's own SRT reader takes a narrower grammar than parse_srt(): a time without hours
|
|
1412
|
+
# is refused and ",5" is read as 5 ms where this tool reads 500. A file outside the grammar
|
|
1413
|
+
# both share goes to ffmpeg as the copy this tool writes, so it gets the cues counted
|
|
1414
|
+
# here (#414)
|
|
1415
|
+
normalised_only = not changed and not srt_timings_shared_grammar(srt_path)
|
|
1416
|
+
if changed or normalised_only:
|
|
1386
1417
|
new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
|
|
1387
1418
|
claim([(new_srt, "adjusted SRT")])
|
|
1388
1419
|
if STATE.dry_run:
|
|
@@ -1392,8 +1423,10 @@ def main() -> int:
|
|
|
1392
1423
|
info(f"wrote {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
|
|
1393
1424
|
srt_path = new_srt
|
|
1394
1425
|
planned_cues = adjusted
|
|
1395
|
-
|
|
1396
|
-
|
|
1426
|
+
why = ("--offset/--max-lines/--min-duration" if changed else
|
|
1427
|
+
"its timings rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
|
|
1428
|
+
side_notes.append(f"the {'muxed' if args.mode == 'mux' else 'burned'} subtitles are {new_srt}, the adjusted copy of "
|
|
1429
|
+
f"{os.path.basename(args.srt)} this run writes ({why})"
|
|
1397
1430
|
+ ("; re-run this command without --dry-run to produce it" if STATE.dry_run else ""))
|
|
1398
1431
|
if args.ass and args.offset and os.path.exists(args.ass):
|
|
1399
1432
|
shifted = os.path.splitext(output)[0] + "_offset.ass"
|
|
@@ -1424,10 +1457,34 @@ def main() -> int:
|
|
|
1424
1457
|
and not (STATE.dry_run and (args.text or args.transcribe))):
|
|
1425
1458
|
die(f"SRT file not found: {srt_path}")
|
|
1426
1459
|
codec = mux_subtitle_codec(output)
|
|
1460
|
+
out_ext = Path(output).suffix.lower().lstrip(".")
|
|
1461
|
+
# The picture and the sound are copied: a WebM holds only VP8/VP9/AV1 and Opus/Vorbis, so
|
|
1462
|
+
# anything else is refused here, not left to fail unclassified in the muxer (#414)
|
|
1463
|
+
if out_ext == "webm":
|
|
1464
|
+
vcodec = (meta.get("video") or {}).get("codec")
|
|
1465
|
+
# the stream -map 0:a:N copies (--audio-stream), not always the first one
|
|
1466
|
+
audio_streams = meta.get("audio_streams") or []
|
|
1467
|
+
acodec = audio_streams[args.audio_stream].get("codec") if args.audio_stream < len(audio_streams) else None
|
|
1468
|
+
bad = [c for c in (vcodec,) if c and c not in ("vp8", "vp9", "av1")] + \
|
|
1469
|
+
[c for c in (acodec,) if c and c not in ("opus", "vorbis")]
|
|
1470
|
+
if bad:
|
|
1471
|
+
die(f"--mode mux copies the picture and the sound, and a .webm holds only VP8/VP9/AV1 video and "
|
|
1472
|
+
f"Opus/Vorbis audio; {args.input} carries {' and '.join(bad)}", kind="input",
|
|
1473
|
+
hint="write .mkv (any codec) or .mp4 instead, or re-encode first with export.py")
|
|
1427
1474
|
# Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
|
|
1428
|
-
# language to build a multi-language set)
|
|
1429
|
-
#
|
|
1475
|
+
# language to build a multi-language set): copied where the output container holds them as
|
|
1476
|
+
# they are, else converted to its own text format as export.py converts them (#380, #414);
|
|
1477
|
+
# a track the container cannot hold at all (a bitmap one in MPEG-4 or WebM) is refused.
|
|
1430
1478
|
existing_subs = meta.get("subtitle_streams") or 0
|
|
1479
|
+
existing_details = list(meta.get("subtitle_stream_details") or [])
|
|
1480
|
+
existing_codecs = [(existing_details[i] if i < len(existing_details) else {}).get("codec") or ""
|
|
1481
|
+
for i in range(existing_subs)]
|
|
1482
|
+
unheld = sorted({c or "unknown" for c in existing_codecs if not subtitle_container_holds(c, out_ext)})
|
|
1483
|
+
if unheld:
|
|
1484
|
+
die(f"{args.input} already carries a subtitle track ({', '.join(unheld)}) that a .{out_ext} file cannot "
|
|
1485
|
+
"hold, and --mode mux keeps every existing track", kind="input",
|
|
1486
|
+
hint="write .mkv, which holds any subtitle codec")
|
|
1487
|
+
existing_out = [subtitle_codec_for(c, out_ext) for c in existing_codecs]
|
|
1431
1488
|
# the first entry's path is srt_path, which --offset/--max-lines may have repointed at an
|
|
1432
1489
|
# adjusted copy; the rest are taken as written
|
|
1433
1490
|
added = [(srt_path, srt_tracks[0][1] if srt_tracks else args.language)] + \
|
|
@@ -1435,6 +1492,18 @@ def main() -> int:
|
|
|
1435
1492
|
for path, _lang in added[1:]:
|
|
1436
1493
|
if not os.path.exists(path) and not STATE.dry_run:
|
|
1437
1494
|
die(f"SRT file not found: {path}")
|
|
1495
|
+
for n in range(1, len(added)):
|
|
1496
|
+
path, lang = added[n]
|
|
1497
|
+
if os.path.exists(path) and not srt_timings_shared_grammar(path):
|
|
1498
|
+
# the same rule as the first track (#414): ffmpeg is given what parse_srt() reads
|
|
1499
|
+
copy = f"{os.path.splitext(output)[0]}_track{n}.srt"
|
|
1500
|
+
claim([(copy, f"normalised SRT {n}")])
|
|
1501
|
+
if not STATE.dry_run:
|
|
1502
|
+
write_srt(parse_srt(path), copy)
|
|
1503
|
+
info(f"wrote {copy} (timings of {os.path.basename(path)} rewritten as hh:mm:ss,mmm)")
|
|
1504
|
+
side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
|
|
1505
|
+
"rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
|
|
1506
|
+
added[n] = (copy, lang)
|
|
1438
1507
|
titles = list(args.track_title or [])
|
|
1439
1508
|
mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
|
|
1440
1509
|
dropped_titles: List[str] = []
|
|
@@ -1453,13 +1522,16 @@ def main() -> int:
|
|
|
1453
1522
|
if mp4_family:
|
|
1454
1523
|
cmd += ["-movflags", "+faststart"]
|
|
1455
1524
|
for i in range(existing_subs):
|
|
1456
|
-
cmd += [f"-c:s:{i}",
|
|
1525
|
+
cmd += [f"-c:s:{i}", existing_out[i]]
|
|
1457
1526
|
tracks: List[Dict[str, Any]] = []
|
|
1527
|
+
converted: List[str] = []
|
|
1458
1528
|
for i in range(existing_subs):
|
|
1459
|
-
|
|
1460
|
-
|
|
1529
|
+
detail = existing_details[i] if i < len(existing_details) else {}
|
|
1530
|
+
written = detail.get("codec") if existing_out[i] == "copy" else existing_out[i]
|
|
1531
|
+
if existing_out[i] != "copy":
|
|
1532
|
+
converted.append(f"{detail.get('codec')} -> {existing_out[i]}")
|
|
1461
1533
|
tracks.append({"index": i, "file": None, "language": detail.get("language"),
|
|
1462
|
-
"title": detail.get("title"), "codec":
|
|
1534
|
+
"title": detail.get("title"), "codec": written,
|
|
1463
1535
|
"default": False, "cues": None, "kept_from_input": True})
|
|
1464
1536
|
for n, (path, lang) in enumerate(added):
|
|
1465
1537
|
idx = existing_subs + n
|
|
@@ -1510,6 +1582,9 @@ def main() -> int:
|
|
|
1510
1582
|
notes.append(f"{total} subtitle tracks in an MPEG-4 container: the tracks are all there, "
|
|
1511
1583
|
"but many players only ever show the first -- write to .mkv for a "
|
|
1512
1584
|
"deliverable a viewer can actually switch")
|
|
1585
|
+
if converted:
|
|
1586
|
+
notes.append(f"the input's own subtitle track(s) were converted to the .{out_ext} container's text format "
|
|
1587
|
+
f"({', '.join(converted)}): their text and timing are kept, styling a format cannot carry is not")
|
|
1513
1588
|
if dropped_titles:
|
|
1514
1589
|
notes.append("an MPEG-4 container has no per-track title this tool can write back "
|
|
1515
1590
|
f"({', '.join(dropped_titles)} would be dropped), so the tracks are "
|
|
@@ -1521,6 +1596,14 @@ def main() -> int:
|
|
|
1521
1596
|
"as given and may not survive")
|
|
1522
1597
|
run(cmd)
|
|
1523
1598
|
result = probe(output, role="output")
|
|
1599
|
+
# the language and title each track carries in the written file: a converted track keeps
|
|
1600
|
+
# only what the container can store (MPEG-4 drops a two-letter code and every title)
|
|
1601
|
+
written = result.get("subtitle_stream_details") or []
|
|
1602
|
+
if len(written) == len(tracks):
|
|
1603
|
+
for track, detail in zip(tracks, written):
|
|
1604
|
+
track["language"] = detail.get("language")
|
|
1605
|
+
if track["kept_from_input"]:
|
|
1606
|
+
track["title"] = detail.get("title")
|
|
1524
1607
|
info(f"wrote {output} ({fmt_secs(result.get('duration'))}, mux, {len(added)} "
|
|
1525
1608
|
f"subtitle track(s) added, codec {codec})")
|
|
1526
1609
|
emit(output, tracks=tracks, subtitle_tracks=total, **({"notes": notes} if notes else {}),
|
|
@@ -1587,7 +1670,8 @@ def main() -> int:
|
|
|
1587
1670
|
cues if (args.text or args.transcribe) else (
|
|
1588
1671
|
planned_cues if planned_cues is not None else parse_srt(srt_path)))
|
|
1589
1672
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1590
|
-
|
|
1673
|
+
# a copy that only rewrote the timings keeps the caller's word-timing JSON beside it
|
|
1674
|
+
args._word_timings = whisper_word_timings(args.srt if normalised_only else srt_path)
|
|
1591
1675
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1592
1676
|
claim([(ass_path, "ASS")])
|
|
1593
1677
|
w, h = display_size(meta["video"])
|
package/scripts/export.py
CHANGED
|
@@ -42,6 +42,7 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
|
|
|
42
42
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
43
43
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
44
44
|
from _common.probe import square_pixels, square_pixels_note
|
|
45
|
+
from _common.runner import subtitle_codec_for
|
|
45
46
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
46
47
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
47
48
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
@@ -142,19 +143,10 @@ def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: s
|
|
|
142
143
|
|
|
143
144
|
# Text subtitle codecs ffmpeg can convert to the container's own text track: mov_text is the one
|
|
144
145
|
# text track MP4/MOV hold, Matroska holds SubRip/ASS/WebVTT as they are but not mov_text.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def _subtitle_codec(codec: str, out_ext: str) -> str:
|
|
150
|
-
"""The -c:s for one source subtitle stream: copy when the output container holds it as it is,
|
|
151
|
-
else the container's own text format (a bitmap track is tried as a copy; a container that
|
|
152
|
-
refuses it is the fallback below)."""
|
|
153
|
-
if out_ext in _MP4_FAMILY and codec in _TEXT_SUBTITLES and codec != "mov_text":
|
|
154
|
-
return "mov_text"
|
|
155
|
-
if out_ext == "mkv" and codec == "mov_text":
|
|
156
|
-
return "srt"
|
|
157
|
-
return "copy"
|
|
146
|
+
# the -c:s for one source subtitle stream (#380), shared with caption.py --mode mux (#414): copy
|
|
147
|
+
# when the output container holds it as it is, else the container's own text format (a bitmap
|
|
148
|
+
# track is tried as a copy; a container that refuses it is the fallback below)
|
|
149
|
+
_subtitle_codec = subtitle_codec_for
|
|
158
150
|
|
|
159
151
|
|
|
160
152
|
def _data_streams(path: str) -> List[Dict[str, object]]:
|
package/scripts/silence.py
CHANGED
|
@@ -83,10 +83,29 @@ def speech_aware_silences(path: str, threshold: float, min_silence: float) -> "T
|
|
|
83
83
|
return boundaries, breaths
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
def inside_margin(silences: List[Tuple[float, float]], duration: float, margin: float) -> List[Tuple[float, float]]:
|
|
87
|
+
"""Each silence with --margin kept on both sides: the stretch actually removed. A silence too
|
|
88
|
+
short to hold two margins removes nothing (it is dropped, never turned inside out).
|
|
89
|
+
|
|
90
|
+
The margin is applied to the silences alone, before they are merged with filler spans (#414):
|
|
91
|
+
a filler word's span is the word itself plus --filler-pad, and taking a 0.2 s margin off each
|
|
92
|
+
side of it turned every word narrower than 2 x --margin inside out -- kept, and still counted
|
|
93
|
+
as removed."""
|
|
94
|
+
out: List[Tuple[float, float]] = []
|
|
95
|
+
for s, e in silences:
|
|
96
|
+
a = s + margin
|
|
97
|
+
b = duration if e == float("inf") else min(duration, e - margin)
|
|
98
|
+
if b > a:
|
|
99
|
+
out.append((a, b))
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
86
103
|
def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: float, min_keep: float) -> List[Tuple[float, float]]:
|
|
87
104
|
keeps: List[Tuple[float, float]] = []
|
|
88
105
|
cursor = 0.0
|
|
89
106
|
for s, e in sorted(silences):
|
|
107
|
+
if e != float("inf") and e - margin <= s + margin:
|
|
108
|
+
continue # narrower than two margins: nothing of it is removed (#414)
|
|
90
109
|
s_adj = max(cursor, s + margin)
|
|
91
110
|
if s_adj - cursor >= min_keep:
|
|
92
111
|
keeps.append((cursor, s_adj))
|
|
@@ -503,6 +522,9 @@ def main() -> int:
|
|
|
503
522
|
media_out = None if (args.list or args.filler_list) else (args.output or default_output(args.input, "tight"))
|
|
504
523
|
guard_sidecar(args.edl, args.input, args.words, args.filler_words, siblings=(media_out,))
|
|
505
524
|
|
|
525
|
+
if not math.isfinite(args.margin) or args.margin < 0:
|
|
526
|
+
# nan made inside_margin() drop every silence and re-encode the whole file unchanged
|
|
527
|
+
die(f"--margin must be a finite number of seconds, 0 or more (got {args.margin:g})", kind="input")
|
|
506
528
|
if args.filler_pad < 0:
|
|
507
529
|
die("--filler-pad cannot be negative: a negative pad turns each word's span inside out "
|
|
508
530
|
"(end before start) and the span is then silently dropped, so nothing is removed",
|
|
@@ -525,8 +547,10 @@ def main() -> int:
|
|
|
525
547
|
filler_info, filler_ranges = resolve_filler(args, meta) if args.filler else (None, [])
|
|
526
548
|
# One sorted, merged removal list through the graph the tool already has: filler removal IS
|
|
527
549
|
# time-range removal, so it reuses keep_ranges() and the same select chain.
|
|
528
|
-
|
|
529
|
-
|
|
550
|
+
# --margin is kept around the silences only (inside_margin), not around filler spans, whose
|
|
551
|
+
# own trim is --filler-pad (#414); the merged list then goes through with no further margin.
|
|
552
|
+
removals = merge_spans(inside_margin(silences, duration, args.margin) + list(filler_ranges))
|
|
553
|
+
keeps = keep_ranges(removals, duration, 0.0, args.min_keep)
|
|
530
554
|
# what the render holds (on a picture, each bound on its nearest frame): the report, the EDL
|
|
531
555
|
# and the time map all describe these, so a re-cut or a sum of `keep` is the rendered edit
|
|
532
556
|
video_out = bool(meta.get("video")) and not (media_out and is_audio_output(media_out))
|
|
@@ -540,7 +564,7 @@ def main() -> int:
|
|
|
540
564
|
# the silences alone; everything removed is `removed_seconds_total`.
|
|
541
565
|
silence_only = removed
|
|
542
566
|
if args.filler:
|
|
543
|
-
silence_keeps = keep_ranges(merge_spans(
|
|
567
|
+
silence_keeps = keep_ranges(merge_spans(inside_margin(silences, duration, args.margin)), duration, 0.0, args.min_keep)
|
|
544
568
|
silence_only = max(0.0, duration - sum(e - s for s, e in rendered_ranges(silence_keeps, meta, duration, video_out)))
|
|
545
569
|
kinds = silence_kinds(args.input, silences, duration, args.threshold)
|
|
546
570
|
summary = {
|
|
@@ -649,8 +673,9 @@ def main() -> int:
|
|
|
649
673
|
return 0
|
|
650
674
|
if not keeps or not held:
|
|
651
675
|
die("nothing would be kept; raise --threshold (e.g. -45) or check the audio")
|
|
652
|
-
if
|
|
653
|
-
|
|
676
|
+
if removed < 0.05:
|
|
677
|
+
# what the render removes, filler spans included: --filler alone has no silences at all
|
|
678
|
+
info(f"no removable {'filler' if filler_only else 'silence or filler' if args.filler else 'silence'} found; output would equal the input")
|
|
654
679
|
|
|
655
680
|
output = args.output or default_output(args.input, "tight")
|
|
656
681
|
# the join of more than 32 ranges concats a list of chunks, so its last command never names the
|