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.
- package/README.md +7 -5
- package/SKILL.md +1 -0
- package/docs/contract.md +37 -9
- package/mcp/server.py +28 -13
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +40 -14
- package/scripts/_ass_overlay.py +235 -2
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +65 -3
- package/scripts/_common/decision.py +89 -1
- package/scripts/_common/emit.py +31 -1
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +280 -22
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +45 -16
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +158 -18
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +46 -21
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/export.py +2 -2
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- package/scripts/look.py +31 -12
- package/scripts/multicam.py +6 -1
- package/scripts/overlay.py +20 -7
- package/scripts/redact.py +2 -2
- package/scripts/render.py +101 -25
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
package/scripts/_common/wrap.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"""
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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],
|
|
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
|
|
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,
|
|
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],
|
|
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
|
|
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],
|
|
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]"
|
|
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],
|
|
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
|
|
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
|
-
|
|
622
|
-
|
|
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
|
-
|
|
627
|
-
|
|
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
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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]"
|
|
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
|
-
|
|
658
|
-
rebalanced =
|
|
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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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"
|
|
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])
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
182
|
-
{"capability": "external:parakeet", "when": "--
|
|
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 --
|
|
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
|
|
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
|
|
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
|
|
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": "--
|
|
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)
|
|
1930
|
-
|
|
1931
|
-
|
|
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":
|
|
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 --
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|