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 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.6`) | any release |
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.6", "execution_mode": "local", "kind": "execution",
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, the refusal is `"<engine> found no speech in <input>"` with `kind: input`,
790
- `reason: "no_speech"` and `engine` in the failure document — distinct from the "no local
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, and `--codec av1`/`prores`'s `encoder_missing` and
869
- `encoder_list_unreadable`), and `error.details`, the facts the message is written from, so a
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.6",
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",
@@ -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). `--speech-aware` keeps the short
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), and the sequence is the project's `frame` sized by
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
@@ -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 run_analysis, STATE
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
- # A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
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
- run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
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,
@@ -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
@@ -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 is skipped, not
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
- if m:
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
- at = fr(_seconds(ch.get("at", 0), f"chapters[{j}].at", None))
226
- if 0 <= at < total:
227
- markers.append({"at": at, "title": str(ch.get("title") or "")})
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 = 0
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["src_in"]
292
- s_out = s_in + int(round((ends[i] - starts[i]) * c["speed"]))
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 {_tc(prev_src_end, fps_int)} {_tc(prev_src_end, fps_int)} "
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} {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
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 {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
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
- lines.append(f"M2 AX {fps_int * c['speed']:05.1f} {_tc(s_in, fps_int)}")
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 = "") -> 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
- res.append(f' <asset id="{rid}" name="{q(name)}" start="0s" duration="{_t(frames, rate)}" '
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
- rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"], cid)
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="{_t(src_start, rate)}" interp="linear"/>')
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="{_t(src_start + int(round(c["visible"] * c["speed"])), rate)}" interp="linear"/>')
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 = _t(src_start, rate)
396
- local0 = src_start if abs(c["speed"] - 1.0) <= 1e-6 else 0
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="{_t(local0, rate)}" name="{q(m["name"])}" '
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="{_t(local0 + mk["at"] - c["record_in"], rate)}" '
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}" tcFormat="NDF"')
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(0, c["media_frames"], rate),
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:
@@ -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"]}}, "additionalProperties": True}
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"}})
@@ -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", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
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
- if changed:
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
- side_notes.append(f"the burned subtitles are {new_srt}, the adjusted copy of "
1396
- f"{os.path.basename(args.srt)} this run writes (--offset/--max-lines/--min-duration)"
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) -- copied byte-identical, distinct from the
1429
- # newly-added SRTs' own codec below.
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}", "copy"]
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
- kept = (meta.get("subtitle_stream_details") or [])
1460
- detail = kept[i] if i < len(kept) else {}
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": detail.get("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
- args._word_timings = whisper_word_timings(srt_path)
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
- _TEXT_SUBTITLES = frozenset({"mov_text", "subrip", "srt", "ass", "ssa", "webvtt", "text"})
146
- _MP4_FAMILY = frozenset({"mp4", "m4v", "mov"})
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]]:
@@ -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
- removals = merge_spans(list(silences) + list(filler_ranges))
529
- keeps = keep_ranges(removals, duration, args.margin, args.min_keep)
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(list(silences)), duration, args.margin, args.min_keep)
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 not silences or removed < 0.05:
653
- info("no removable silence found; output would equal the input")
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