ffmpeg-skill 3.6.0 → 3.6.2

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.
@@ -29,7 +29,11 @@ ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6,
29
29
  # a word. A Thai run is therefore one atom, broken only at the spaces (or the manual `|`) the
30
30
  # writer put there; an over-long run stays long on its own line, the rule long Latin words
31
31
  # already follow.
32
- NO_SPACE_SCRIPTS = ("ja", "zh", "ko")
32
+ # Korean is NOT here: it is written with spaces between words (eojeol), so a Hangul run is a word
33
+ # like a Latin one -- broken at its spaces, and cut inside only by _slice_atom's escape hatch.
34
+ # Per-character atoms broke `시작|합니다` and, once a pass moved the break, joined the halves
35
+ # with no space (`늦지마세요`) while the result said text_unchanged.
36
+ NO_SPACE_SCRIPTS = ("ja", "zh")
33
37
  NO_BOUNDARY_SCRIPTS = ("th",) # per-character breaking would chop words: keep the run whole
34
38
 
35
39
 
@@ -304,9 +308,11 @@ def _join(left: str, atom: str, spaced: bool) -> str:
304
308
 
305
309
 
306
310
  def _break_spaced(first: str, second: str) -> bool:
307
- """Did a space stand at the break between these two wrapped lines? Only spaced scripts put one
308
- there -- a CJK/Thai break sits between two characters that were written with nothing between
309
- them, and re-joining them with a space would insert a character the cue never had."""
311
+ """A guess at whether a space stood at the break between these two wrapped lines, from the
312
+ scripts on each side. The wrapper's own passes do not use it: the greedy fill records the real
313
+ answer per break (`gaps`) and every pass carries it along, because a script cannot tell -- a
314
+ Japanese cue written with spaces (`はい そうです`) lost one whenever a pass moved a break that
315
+ stood at a space. Kept as the fallback for a caller that has only the two strings."""
310
316
  if not first or not second:
311
317
  return False
312
318
  return char_script(first[-1]) not in NO_SPACE_SCRIPTS and char_script(second[0]) not in NO_SPACE_SCRIPTS \
@@ -477,7 +483,18 @@ def best_break(atoms: "Sequence[Tuple[str, bool]]", max_em: float,
477
483
  return None if best is None else best[1]
478
484
 
479
485
 
480
- def _fix_orphans(lines: "List[str]", max_em: float) -> "List[str]":
486
+ def _gaps(lines: "List[str]", gaps: "Optional[List[bool]]") -> "List[bool]":
487
+ """The per-break space flags a pass reads and updates: the caller's list (updated in place, so
488
+ the next pass sees the moved breaks), or the _break_spaced() guess when the caller has none."""
489
+ if gaps is None:
490
+ return [_break_spaced(a, b) for a, b in zip(lines, lines[1:])]
491
+ if len(gaps) != max(len(lines) - 1, 0):
492
+ raise ValueError("gaps must hold one flag per break")
493
+ return gaps
494
+
495
+
496
+ def _fix_orphans(lines: "List[str]", max_em: float,
497
+ gaps: "Optional[List[bool]]" = None) -> "List[str]":
481
498
  """No last line that is a single stranded atom.
482
499
 
483
500
  Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
@@ -486,6 +503,7 @@ def _fix_orphans(lines: "List[str]", max_em: float) -> "List[str]":
486
503
  onto it -- but only while the result still fits and the line above does not become an orphan
487
504
  itself, so a two-word cue is never made worse."""
488
505
  lines = list(lines)
506
+ gaps = _gaps(lines, gaps)
489
507
  for _ in range(len(lines)):
490
508
  if len(lines) < 2:
491
509
  break
@@ -499,14 +517,16 @@ def _fix_orphans(lines: "List[str]", max_em: float) -> "List[str]":
499
517
  new_prev = ""
500
518
  for atom, sp in prev[:-1]:
501
519
  new_prev = _join(new_prev, atom, sp)
502
- new_last = _join(moved, tail[0][0], _break_spaced(lines[-2], lines[-1]))
520
+ new_last = _join(moved, tail[0][0], gaps[-1])
503
521
  if text_width_em(new_last) > max_em or text_width_em(new_prev) < ORPHAN_MIN_EM:
504
522
  break
505
523
  lines[-2], lines[-1] = new_prev, new_last
524
+ gaps[-1] = spaced
506
525
  return lines
507
526
 
508
527
 
509
- def _fix_weak_lines(lines: "List[str]", max_em: float) -> "List[str]":
528
+ def _fix_weak_lines(lines: "List[str]", max_em: float,
529
+ gaps: "Optional[List[bool]]" = None) -> "List[str]":
510
530
  """R2, generalised: _fix_orphans run at *every* boundary, against _is_weak_line.
511
531
 
512
532
  1.15 only ever looked at the last line, so a stranded digit or kana in the middle of a
@@ -514,6 +534,7 @@ def _fix_weak_lines(lines: "List[str]", max_em: float) -> "List[str]":
514
534
  of the line above moves down onto it -- with 1.15's two guards intact (the result must still
515
535
  fit, and the line above must not itself become weak), so the line count never changes."""
516
536
  lines = list(lines)
537
+ gaps = _gaps(lines, gaps)
517
538
  for i in range(len(lines) - 1, 0, -1):
518
539
  for _ in range(len(lines)):
519
540
  if not _is_weak_line(lines[i]):
@@ -521,19 +542,21 @@ def _fix_weak_lines(lines: "List[str]", max_em: float) -> "List[str]":
521
542
  prev = _atoms(lines[i - 1])
522
543
  if len(prev) < 2:
523
544
  break
524
- moved, _spaced = prev[-1]
545
+ moved, spaced = prev[-1]
525
546
  new_prev = ""
526
547
  for atom, sp in prev[:-1]:
527
548
  new_prev = _join(new_prev, atom, sp)
528
- new_last = _join(moved, lines[i], _break_spaced(lines[i - 1], lines[i]))
549
+ new_last = _join(moved, lines[i], gaps[i - 1])
529
550
  if text_width_em(new_last) > max_em or _is_weak_line(new_prev) \
530
551
  or text_width_em(new_prev) < ORPHAN_MIN_EM:
531
552
  break
532
553
  lines[i - 1], lines[i] = new_prev, new_last
554
+ gaps[i - 1] = spaced
533
555
  return lines
534
556
 
535
557
 
536
- def _rebalance(lines: "List[str]", max_em: float) -> "List[str]":
558
+ def _rebalance(lines: "List[str]", max_em: float,
559
+ gaps: "Optional[List[bool]]" = None) -> "List[str]":
537
560
  """Move each break to the one that minimises the widest line of the pair, without changing the
538
561
  line count.
539
562
 
@@ -544,11 +567,12 @@ def _rebalance(lines: "List[str]", max_em: float) -> "List[str]":
544
567
  if len(lines) < 2:
545
568
  return lines
546
569
  out = list(lines)
570
+ gaps = _gaps(out, gaps)
547
571
  for i in range(len(out) - 1):
548
572
  first, second = out[i], out[i + 1]
549
573
  tail_atoms = _atoms(second)
550
574
  if tail_atoms:
551
- tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
575
+ tail_atoms[0] = (tail_atoms[0][0], gaps[i])
552
576
  atoms = _atoms(first) + tail_atoms
553
577
  if not atoms or any(char_script(ch) in NO_SPACE_SCRIPTS for ch in first + second):
554
578
  continue
@@ -568,13 +592,15 @@ def _rebalance(lines: "List[str]", max_em: float) -> "List[str]":
568
592
  continue
569
593
  key = (max(wa, wb), abs(wa - wb))
570
594
  if best is None or key < best[0]:
571
- best = (key, a, b)
595
+ best = (key, a, b, atoms[cut][1])
572
596
  if best is not None:
573
597
  out[i], out[i + 1] = best[1], best[2]
598
+ gaps[i] = best[3]
574
599
  return out
575
600
 
576
601
 
577
- def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]") -> "Tuple[List[str], int]":
602
+ def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]",
603
+ gaps: "Optional[List[bool]]" = None) -> "Tuple[List[str], int]":
578
604
  """_rebalance with R1-R4 deciding, for every script rather than spaced ones only.
579
605
 
580
606
  Returns the new lines and how many breaks a phrase rule moved away from the position 1.15's
@@ -582,12 +608,13 @@ def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]")
582
608
  if len(lines) < 2:
583
609
  return list(lines), 0
584
610
  out = list(lines)
611
+ gaps = _gaps(out, gaps)
585
612
  moved = 0
586
613
  for i in range(len(out) - 1):
587
614
  first, second = out[i], out[i + 1]
588
615
  tail_atoms = _atoms(second)
589
616
  if tail_atoms:
590
- tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
617
+ tail_atoms[0] = (tail_atoms[0][0], gaps[i])
591
618
  atoms = _split_hyphens(_atoms(first) + tail_atoms)
592
619
  if len(atoms) < 2:
593
620
  continue
@@ -602,11 +629,13 @@ def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]")
602
629
  if (a, b) != (first, second):
603
630
  moved += 1
604
631
  out[i], out[i + 1] = a, b
632
+ gaps[i] = atoms[cut][1]
605
633
  return out, moved
606
634
 
607
635
 
608
636
  def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
609
- sliced: "Optional[List[int]]" = None) -> "List[str]":
637
+ sliced: "Optional[List[int]]" = None,
638
+ gaps: "Optional[List[bool]]" = None) -> "List[str]":
610
639
  """The greedy fill on its own: the line count every mode must keep.
611
640
 
612
641
  `slice_overlong` (off by default -- only caption.py's final burn-in pass turns it on, never
@@ -615,47 +644,65 @@ def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
615
644
  katakana run that already fits is completely untouched -- is hard-sliced by _slice_atom()
616
645
  instead of kept whole. `sliced`, when given, gets one entry (the number of extra lines that
617
646
  one atom produced) per atom actually sliced, which is how the caller counts
618
- `broken_inside_word` without re-deriving it from the output lines."""
647
+ `broken_inside_word` without re-deriving it from the output lines.
648
+
649
+ `gaps`, when given, is filled with one flag per break: did a space stand there in `raw`? The
650
+ passes in _balance() carry it, so a break they move keeps (or drops) exactly that space."""
619
651
  current = ""
652
+ current_gap = False # did a space stand before `current`'s first atom
620
653
  chunk: "List[str]" = []
621
- for atom, spaced in _atoms(raw):
622
- if slice_overlong and not current and text_width_em(atom) > max_em:
654
+ breaks: "List[bool]" = []
655
+
656
+ def emit(line: str, gap_before: bool) -> None:
657
+ if chunk:
658
+ breaks.append(gap_before)
659
+ chunk.append(line)
660
+
661
+ def start_line(atom: str, spaced: bool) -> "Tuple[str, bool]":
662
+ """The new current line opening with `atom`, sliced first when it is too wide alone."""
663
+ if slice_overlong and text_width_em(atom) > max_em:
623
664
  pieces = _slice_atom(atom, max_em)
624
665
  if sliced is not None and len(pieces) > 1:
625
666
  sliced.append(len(pieces) - 1)
626
- chunk.extend(pieces[:-1])
627
- current = pieces[-1]
667
+ if len(pieces) > 1:
668
+ emit(pieces[0], spaced)
669
+ for piece in pieces[1:-1]:
670
+ emit(piece, False)
671
+ return pieces[-1], False
672
+ return atom, spaced
673
+
674
+ for atom, spaced in _atoms(raw):
675
+ if not current:
676
+ current, current_gap = start_line(atom, spaced)
628
677
  continue
629
678
  candidate = _join(current, atom, spaced)
630
- if current and text_width_em(candidate) > max_em:
631
- chunk.append(current)
632
- if slice_overlong and text_width_em(atom) > max_em:
633
- pieces = _slice_atom(atom, max_em)
634
- if sliced is not None and len(pieces) > 1:
635
- sliced.append(len(pieces) - 1)
636
- chunk.extend(pieces[:-1])
637
- current = pieces[-1]
638
- else:
639
- current = atom
679
+ if text_width_em(candidate) > max_em:
680
+ emit(current, current_gap)
681
+ current, current_gap = start_line(atom, spaced)
640
682
  else:
641
683
  current = candidate
642
684
  if current:
643
- chunk.append(current)
685
+ emit(current, current_gap)
686
+ if gaps is not None:
687
+ gaps[:] = breaks
644
688
  return chunk
645
689
 
646
690
 
647
- def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]") -> "List[str]":
691
+ def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]",
692
+ gaps: "Optional[List[bool]]" = None) -> "List[str]":
648
693
  """The post-passes for one greedy chunk, in the mode's own order. Never changes the count:
649
694
  a pass that would is discarded, exactly as 1.15 did."""
650
695
  if len(chunk) < 2:
651
696
  return chunk
697
+ gaps = list(gaps) if gaps is not None else None
652
698
  if mode == "measured":
653
- fixed = _fix_orphans(chunk, max_em)
654
- rebalanced = _rebalance(fixed, max_em)
699
+ fixed = _fix_orphans(chunk, max_em, gaps)
700
+ rebalanced = _rebalance(fixed, max_em, list(gaps) if gaps is not None else None)
655
701
  else:
656
- fixed = _fix_weak_lines(_fix_orphans(chunk, max_em), max_em)
657
- rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang)
658
- rebalanced = _fix_weak_lines(rebalanced, max_em)
702
+ fixed = _fix_weak_lines(_fix_orphans(chunk, max_em, gaps), max_em, gaps)
703
+ later = list(gaps) if gaps is not None else None
704
+ rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang, later)
705
+ rebalanced = _fix_weak_lines(rebalanced, max_em, later)
659
706
  if len(rebalanced) == len(chunk):
660
707
  return rebalanced
661
708
  return fixed if len(fixed) == len(chunk) else chunk
@@ -681,8 +728,9 @@ def wrap_text(text: str, max_em: float, *, balance: bool = True, mode: str = "ph
681
728
  for raw in text.split("\n"):
682
729
  if not raw.strip():
683
730
  continue
684
- chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced)
685
- lines.extend(_balance(chunk, max_em, mode, lang) if balance else chunk)
731
+ gaps: "List[bool]" = []
732
+ chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps)
733
+ lines.extend(_balance(chunk, max_em, mode, lang, gaps) if balance else chunk)
686
734
  return lines or [text]
687
735
  def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
688
736
  lang: "Optional[str]" = None, slice_overlong: bool = False,
@@ -701,10 +749,12 @@ def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
701
749
  for raw in text.split("\n"):
702
750
  if not raw.strip():
703
751
  continue
704
- chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced)
752
+ gaps: "List[bool]" = []
753
+ chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps)
705
754
  greedy.extend(chunk)
706
- wrapped.extend(_balance(list(chunk), max_em, mode, lang))
707
- measured.extend(chunk if mode == "measured" else _balance(list(chunk), max_em, "measured", None))
755
+ wrapped.extend(_balance(list(chunk), max_em, mode, lang, gaps))
756
+ measured.extend(chunk if mode == "measured"
757
+ else _balance(list(chunk), max_em, "measured", None, gaps))
708
758
  if not greedy:
709
759
  greedy = [text]
710
760
  return (wrapped or [text], greedy, measured or [text])
@@ -178,8 +178,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
178
178
  video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
179
179
  "silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
180
180
  required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"},
181
- {"capability": "external:whisper", "when": "--filler --transcribe"},
182
- {"capability": "external:parakeet", "when": "--filler --transcribe (English; either engine family satisfies it)"}] + AUDIO_OUT,
181
+ {"capability": "external:whisper", "when": "--transcribe (with --filler or a sentence flag)"},
182
+ {"capability": "external:parakeet", "when": "--transcribe (with --filler or a sentence flag; English; either engine family satisfies it)"}] + AUDIO_OUT,
183
183
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
184
184
  "join": dict(role="execution", inputs=["two or more video assets, or two or more audio-only assets"], outputs=["concatenated video artifact", "concatenated audio artifact (audio-only inputs, audio output extension)"],
185
185
  required=FF + ["filter:xfade", "filter:acrossfade"],
@@ -289,7 +289,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
289
289
  "caption": dict(video="conditional", audio="conditional", note="--mode burn (default) always re-encodes both streams to render pixels; --mode mux copies video and audio untouched and only adds a subtitle stream"),
290
290
  "overlay": dict(video="always", audio="always"),
291
291
  "graphics": dict(video="always", audio="always"),
292
- "sync": dict(video="conditional", audio="conditional", note="video is -c:v copy only for --trim-second when the second file started earlier (offset<0) and the copy succeeds; it is re-encoded whenever --replace-audio's stream copy fails, or in --trim-second when the second file started later (offset>=0, the common case) or --fix-drift is used"),
292
+ "sync": dict(video="conditional", audio="conditional", note="video is -c:v copy only for --replace-audio (the reference picture) while that copy succeeds; --trim-second always re-encodes a video second file, since a copy would start at the keyframe before the trim point; a sound-only second file that started earlier is stream-copied"),
293
293
  "multicam": dict(video="always", audio="always"),
294
294
  "audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
295
295
  "loudness": dict(video="never", audio="always"),
@@ -421,10 +421,12 @@ ERROR_REASONS = {
421
421
  "input_incomplete": "kind ffmpeg: the input has no index (\"moov atom not found\"): a recording cut off or a copy not finished; --repair cannot rebuild it; details: {}",
422
422
  "input_unreadable": "kind ffmpeg: ffmpeg could not read the input as media (\"Invalid data found when processing input\"); details: {}",
423
423
  "ffmpeg_unsupported": "kind missing_tool: the ffmpeg/ffprobe on PATH reports a version older than 6.1, which 3.0.0 no longer supports (5.x was deprecated in 2.14.0); details: {version, required}",
424
+ "read_only_plan_refused": "--plan in a process run with FFMPEG_SKILL_READ_ONLY=1 (mcp/server.py sets it for the tools annotated readOnlyHint: probe, check, cropdetect), which must write no file; details: {plan}",
424
425
  "output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
425
426
  "output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
426
427
  "output_is_directory": "the output path is a directory; details: {output}",
427
- "media_path_is_playlist": "an input is a playlist (HLS .m3u8/.m3u or an ffconcat list, by name or by its first bytes) that ffmpeg would follow to the files it names; the skill takes media files only; details: {path}",
428
+ "media_path_is_playlist": "an input is a playlist that ffmpeg would follow to the files or URLs it names: HLS .m3u8/.m3u or an ffconcat list (by name or by its first bytes), a DASH manifest or an IMF composition playlist (by name or by the content ffmpeg's own probe looks for, whatever the file is called); the skill takes media files only; details: {path}",
429
+ "media_path_has_line_break": "a path written into a concat list (loop.py, join.py --copy, sequence.py, multi-part cuts) contains a line break, which the concat format cannot hold: each line is a directive, so the name would add entries to the list; details: {path}",
428
430
  "media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
429
431
  "join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
430
432
  "output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
@@ -443,9 +445,9 @@ ERROR_REASONS = {
443
445
  "plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
444
446
  "decoder_missing": "color.py / graphics.py / overlay.py: a VP9 source or --video layer stored with an alpha layer, on a build with no libvpx-vp9 decoder (FFmpeg's own VP9 decoder drops the alpha), kind missing_tool; details: {name}",
445
447
  "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)",
446
- "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: {}",
448
+ "asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
447
449
  "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}",
448
- "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",
450
+ "no_speech": "kind input: --transcribe (caption.py; silence.py with --filler or a sentence flag) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
449
451
  "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",
450
452
  "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",
451
453
  }
@@ -617,7 +619,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
617
619
  "verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
618
620
  "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}, "property": {"type": "string"}, "expected": {}, "measured": {}, "delta": {}, "tolerance": {}, "provenance": {"enum": ["specification", "user_specified", "independent_measurement", "input_premeasurement"]}, "attribution": {"enum": ["artifact", "reference", "instrument", "provenance", "unresolved"]}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's. Since 2.18 a step may carry the property it measured with expected, measured, delta, tolerance and the provenance of the expected value; cut, join and export add a `streams` step comparing the output's video/audio streams with the input's; a step with ok false carries `attribution` (unresolved until a tool knows the cause)"},
619
621
  "unverified": {"type": "array", "items": {"type": "string"}, "description": "2.18: the properties of the output this tool did not measure (the unverified range), e.g. caption's rendered_placement; `verified` covers the measured properties only"},
620
- "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode. Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
622
+ "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode (kept only when the written start timecode is the label of the frame the output starts on: the source's own, or moved there by a tool that moved the picture's start; a join of several ranges writes none). Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
621
623
  "diagnostics": DIAGNOSTICS_SCHEMA}
622
624
  extra: Dict[str, Any] = {}
623
625
  if name == "check":
@@ -665,7 +667,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
665
667
  "hint": {"type": "string", "description": "when nothing sat below --threshold: the track's measured level and the --threshold that would find its pauses"},
666
668
  "speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"},
667
669
  "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), ...}"},
668
- "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"}}
670
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "--transcribe (with --filler or a sentence flag) 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"}}
669
671
  elif name == "sync":
670
672
  extra = {"reference": {"type": "string"}, "second": {"type": "string"}, "offset_seconds": {"type": "number"}, "confidence": {"type": "number"}, "meaning": {"type": "string"}, "drift": {"type": "object"},
671
673
  "sources": {"type": "array", "description": "1.18: [{path, offset_s, confidence, drift_ppm}], one per SOURCE; the only per-source shape once more than one SOURCE is given; with --fix-drift each also carries drift_skipped_reason"},
@@ -693,7 +695,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
693
695
  "files": {"type": "array", "description": "one entry per file: {file, probe, steps, skipped}. A step is {step, ok, seconds, error (one line, for the table)}, plus the tool's verified when it completed, and on a failure its own kind, reason, message, details and exit_code (reason step_timeout or step_output_unparsed when the tool printed no failure document); skipped lists the steps that do not apply as {step, reason: quick|no_video|no_audio|not_hdr|not_multichannel|input_unreadable, message}"},
694
696
  "failed": {"type": "integer"}, "total": {"type": "integer"}}
695
697
  elif name == "batch":
696
- extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable, ...} when a cache entry was not served; skipped \"timeout\" or \"interrupted\" for an item that never started and interrupted true for one stopped while running. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
698
+ extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable, ...} when a cache entry was not served; skipped \"timeout\" or \"interrupted\" for an item that never started and interrupted true for one stopped while running; under --watch, skipped \"settling\" (ok false, not cached) for a source that changed since the previous poll, or, first seen, was modified within one poll interval: it is processed on a later pass. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
697
699
  "processed": {"type": "integer"}, "total": {"type": "integer"},
698
700
  "cut_stream_copy": {"type": ["object", "null"], "description": "cut.py calls across the folder: {calls, stream_copy, reencoded, stream_copy_rate}; null when no cut.py step ran"},
699
701
  "cut_reencode_reasons": {"type": ["object", "null"], "description": "how many cut.py calls named each reencode_reason ({} when none re-encoded); a re-encoded call whose row came from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as unknown; null when no cut.py step ran"}}
@@ -1758,6 +1760,17 @@ MCP_JSON_EXEMPT = ("look", "probe")
1758
1760
  # them out of every inputSchema (1.10.0-1.x kept them unless FFMPEG_SKILL_MCP_LEAN=1). A client
1759
1761
  # that still sends them is not refused -- build_argv() maps them like any other key.
1760
1762
  MCP_TRANSPORT_FLAGS = ("json", "progress")
1763
+ # what a read-only tool's (mcp_read_only: probe, check, cropdetect) MCP inputSchema says of the two
1764
+ # keys that would write a file: `plan` is refused over MCP and `overwrite` only governed that plan.
1765
+ # The names stay (3.x never removes an inputSchema property); the CLI keeps both working (every
1766
+ # tool gets --plan, docs/design-decisions.md).
1767
+ MCP_READ_ONLY_REFUSED = {
1768
+ "plan": "refused over MCP (read-only tool; error.reason read_only_plan_refused): this tool writes no file here; run it from the CLI to write a plan",
1769
+ "overwrite": "no effect over MCP: this read-only tool writes no file (it governed --plan, which is refused here)",
1770
+ }
1771
+ # set by mcp/server.py in the environment of a read-only tool's process: --plan is refused
1772
+ # (kind input, error.reason read_only_plan_refused), however the raw argv spells it
1773
+ READ_ONLY_ENV = "FFMPEG_SKILL_READ_ONLY"
1761
1774
  # default MCP tools/list surface (roadmap P1-7, shipped 1.18.3): the 42-tool schema dump costs
1762
1775
  # every MCP session context whether it needs it or not. These 12 are the tools eval iterations
1763
1776
  # 17-20's ground-truth `expect` lists actually name most often across the 118 agent prompts in
@@ -1904,6 +1917,13 @@ def mcp_input_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
1904
1917
  structured["anyOf"] = one_of[0] if len(one_of) == 1 else [{"allOf": [{"anyOf": g} for g in one_of]}]
1905
1918
  for dest in MCP_TRANSPORT_FLAGS:
1906
1919
  props.pop(dest, None)
1920
+ if mcp_read_only(spec):
1921
+ # a tool that writes no artifact is annotated readOnlyHint; --plan is the one file it could
1922
+ # still write (and --overwrite only governs that file). mcp/server.py runs the tool with
1923
+ # FFMPEG_SKILL_READ_ONLY=1, which refuses --plan however it is spelled; the descriptions say so
1924
+ for dest, text in MCP_READ_ONLY_REFUSED.items():
1925
+ if dest in props:
1926
+ props[dest]["description"] = text
1907
1927
  if structured.get("required"):
1908
1928
  structured["required"] = [d for d in structured["required"] if d not in MCP_TRANSPORT_FLAGS]
1909
1929
  if not structured["required"]:
@@ -1924,14 +1944,23 @@ def mcp_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
1924
1944
  "annotations": mcp_annotations(spec)}
1925
1945
 
1926
1946
 
1947
+ def mcp_read_only(spec: Dict[str, Any]) -> bool:
1948
+ """A tool that writes no artifact and does not touch its input: over MCP it writes nothing at
1949
+ all (mcp/server.py refuses its `plan`, see mcp_input_schema), so it is annotated readOnlyHint."""
1950
+ return not (bool(spec.get("produces_artifact")) or bool(spec.get("mutates_input")))
1951
+
1952
+
1927
1953
  def mcp_annotations(spec: Dict[str, Any]) -> Dict[str, Any]:
1928
1954
  """MCP ToolAnnotations (spec 2025-06-18) derived from the ToolSpec flags. Hints only: clients
1929
- must not treat them as a security boundary. Every tool runs locally (no open world); none edits
1930
- its input in place; a tool that writes nothing is read-only."""
1931
- writes = bool(spec.get("produces_artifact")) or bool(spec.get("mutates_input"))
1955
+ must not treat them as a security boundary. Every tool runs locally (no open world). A tool
1956
+ that writes nothing is read-only (mcp_read_only: the server runs it with
1957
+ FFMPEG_SKILL_READ_ONLY=1, which refuses `--plan` however it is spelled). Every other tool is
1958
+ destructive: none edits its input in place, but with `overwrite` it replaces an existing file at its output or plan path, which is not an
1959
+ additive update."""
1960
+ writes = not mcp_read_only(spec)
1932
1961
  return {"title": spec["name"],
1933
1962
  "readOnlyHint": not writes,
1934
- "destructiveHint": bool(spec.get("mutates_input")),
1963
+ "destructiveHint": writes,
1935
1964
  "idempotentHint": spec.get("idempotency_hint") in ("bit_exact", "content_equivalent", "cached"),
1936
1965
  "openWorldHint": False}
1937
1966
 
@@ -1979,7 +2008,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1979
2008
  # first time it runs; faster-whisper is a library, so its fetch is made from the skill's own
1980
2009
  # process. A caller that sandboxes the network must know that before a first --transcribe.
1981
2010
  "model_downloads": {
1982
- "when": "first use of a speech engine (--transcribe; silence.py --filler --transcribe runs it under --dry-run too, caption.py --transcribe does not) whose model is not cached yet; media is never uploaded",
2011
+ "when": "first use of a speech engine (--transcribe; silence.py --transcribe, with --filler or a sentence flag, runs it under --dry-run too, caption.py --transcribe does not) whose model is not cached yet; media is never uploaded",
1983
2012
  "in_process": {"faster-whisper": "Hugging Face (Systran/faster-whisper-<model>)"},
1984
2013
  "child_process": {"parakeet-mlx": "Hugging Face (mlx-community/parakeet-*)",
1985
2014
  "openai-whisper": "OpenAI (its model checkpoint)"},
@@ -1993,8 +2022,8 @@ def build(detect: bool = True) -> Dict[str, Any]:
1993
2022
  "canonical": True,
1994
2023
  "transports": ["cli", "mcp"],
1995
2024
  "argument_mapping": {
1996
- "positional": "listed in input_schema.positional, in order; array values expand to several arguments",
1997
- "options": "key -> --key with '_' replaced by '-'; booleans are flags; arrays repeat the flag; input_schema.properties[key].cli lists the accepted spellings",
2025
+ "positional": "listed in input_schema.positional, in order; array values expand to several arguments; when any of them starts with '-', all of them follow '--' after every option, so none is read as an option",
2026
+ "options": "key -> --key with '_' replaced by '-'; booleans are flags; arrays repeat the flag; input_schema.properties[key].cli lists the accepted spellings; a value starting with '-' is one token, --long-flag=value (\"-50%\" is text, not an option)",
1998
2027
  "exceptions": "per tool in mcp.argument_exceptions (key -> flag), e.g. output -> -o, loudness.lufs -> -I, graphics.count_from -> --from",
1999
2028
  "json": "--json is appended for every tool except look and probe (probe prints JSON by default)",
2000
2029
  },
package/scripts/audio.py CHANGED
@@ -13,7 +13,7 @@ Examples:
13
13
  python3 audio.py clip.mp4 --fade-in 0.5 --fade-out 1 --stereo
14
14
  python3 audio.py talk.mp4 --music bed.mp3 --duck --duck-threshold -30 --duck-release 250 # ducks earlier and recovers faster
15
15
  python3 audio.py band.wav --stereo-widen 0.5 -o wide.wav # wider stereo image (a real stereo source; mono is refused)
16
- python3 audio.py surround.mov --downmix # 5.1 -> stereo with proper centre/LFE weights
16
+ python3 audio.py surround.mov --downmix # 5.1 -> stereo, ITU-R BS.775 weights, scaled not to clip
17
17
  python3 audio.py clip.mp4 --replace narration.wav # swap the audio track entirely
18
18
  python3 audio.py interview.mp4 -o interview.wav # extract the audio (no video in the output)
19
19
  python3 audio.py multi.mkv --audio-stream 1 --voice -o lav.m4a # pick the second audio track, clean it, write M4A
@@ -29,6 +29,8 @@ from _common.probe import SILENT_PEAK_DBFS
29
29
  from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
30
30
  from _common.emit import warn
31
31
 
32
+ # --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
33
+ DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
32
34
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
33
35
 
34
36
  # --voice [light|medium|strong] (1.13). "medium" is the chain --voice has always produced, so a
@@ -191,7 +193,7 @@ def main() -> int:
191
193
  help="widen the stereo image, 0..1 (0 = untouched, 1 = maximum); needs a real stereo source: a mono input is "
192
194
  "refused (duplicating it leaves both channels identical, so there is nothing to widen) and more than two "
193
195
  "channels are refused unless --downmix folds them to stereo first")
194
- fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
196
+ fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo with the ITU-R BS.775 weights (centre and surrounds -3 dB, LFE dropped), scaled so the fold-down cannot clip")
195
197
  fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
196
198
  dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
197
199
  dyn.add_argument("--compress", action="store_true", help="compressor (acompressor); order: gate -> compressor -> limiter")
@@ -301,7 +303,11 @@ def main() -> int:
301
303
 
302
304
  fx: List[str] = []
303
305
  if args.downmix:
304
- fx.append("pan=stereo|FL=0.707*FC+FL+0.5*BL+0.5*SL+0.5*LFE|FR=0.707*FC+FR+0.5*BR+0.5*SR+0.5*LFE")
306
+ # ITU-R BS.775: centre and surrounds at -3 dB (0.707), LFE dropped. pan's '<' scales each
307
+ # output so its gains sum to 1 over the channels the input has (1/2.414 on 5.1, 1/3.121 on
308
+ # 7.1), so in-phase channels at full scale cannot clip; '=' summed 2.7x and clipped a loud
309
+ # 5.1 while the run said verified.
310
+ fx.append(DOWNMIX_PAN)
305
311
  if args.voice:
306
312
  fx.append(VOICE_LEVELS[args.voice])
307
313
  elif args.denoise: