ffmpeg-skill 3.6.2 → 3.6.3
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 +3 -2
- package/package.json +1 -1
- package/scripts/_contract.py +3 -0
- package/scripts/join.py +135 -14
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.6.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.6.3`) | 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.6.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.6.3", "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"},
|
|
@@ -760,6 +760,7 @@ silence a tool reported as fine while another measurement disagreed):
|
|
|
760
760
|
| `verification[]` step `duration` | `export.py` (a preset with a platform maximum, without `--allow-long`) | the written file's length against the maximum, measured as `check.py` measures it (the container's duration, `tolerance` 0). A length that cannot be read fails the step with `reason: not_measured` |
|
|
761
761
|
| `verification[]` step `duration` (`property: audio_duration`) | `loudness.py` | the output's sound against the input's, to one AAC frame (at least 0.03 s): a levels pass changes no timing. A length that cannot be read fails the step with `reason: not_measured` |
|
|
762
762
|
| `inputs_duration`, `transition_shortening`; step `duration`'s `inputs_total` | `join.py` | the clips' own total and how much the transitions took out of it. `expected_duration` is derived from the transition the join applied, so it cannot show that on its own |
|
|
763
|
+
| `frame_rate`, `clip_frames`, `transition_frames` | `join.py` (video join; added after 3.6.2) | the join's rate as an exact fraction (`"30000/1001"`), the whole frames of it each clip holds (its sound is cut to the samples those frames last), and the frames each transition overlaps (`--duration` rounded to whole frames, at least 1; `0` for `--transition none`). Lengths and xfade offsets are counted in these frames, never in rounded seconds (#455, #468); `inputs_duration` and `expected_duration` are derived from them. A pending `--dry-run` input counts 0 frames |
|
|
763
764
|
| `margins` | `silence.py` | `{lead, tail}`: the seconds of each silence kept before speech starts (`--margin-lead`) and after it ends (`--margin-tail`); both equal `--margin` unless one is named. Absent under `--filler` with neither `--speech-aware` nor a sentence flag, which removes no silences |
|
|
764
765
|
| `silence_positions`, `sentence` | `silence.py --sentence-min-silence` / `--sentence-margin-tail` | per entry of `silences`, `sentence_end` when the transcript word before it ends in sentence-final punctuation, else `inside`; and `{min_silence, margin_tail, source, word_timings, sentence_end_words, silences}`. `sentence_end_words: 0` means the transcript had no such punctuation, so every silence used `--min-silence` and `--margin-tail` (warning `sentence_ends_not_found`) |
|
|
765
766
|
| `silence_kinds`, `silence_breakdown` | `silence.py` | per detected silence, `digital` (at or below -90 dBFS) or `below_threshold` (quiet sound under `--threshold`), and the count and seconds of each with the level that defines it |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.6.
|
|
3
|
+
"version": "3.6.3",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/scripts/_contract.py
CHANGED
|
@@ -770,6 +770,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
770
770
|
elif name == "join":
|
|
771
771
|
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
772
772
|
"expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
|
|
773
|
+
"frame_rate": {"type": "string", "description": "video mode only: the join's frame rate as an exact fraction (\"30000/1001\"), the rate every clip length and xfade offset is counted in"},
|
|
774
|
+
"clip_frames": {"type": "array", "items": {"type": "integer"}, "description": "video mode only: per clip, the whole frames of frame_rate it holds in the join (its picture's, or the frames that cover a sound running more than a frame past it); its sound is cut to the samples those frames last. A pending --dry-run input counts 0"},
|
|
775
|
+
"transition_frames": {"type": "integer", "description": "video mode only: the frames each xfade/acrossfade overlaps (--duration rounded to whole frames, at least 1; 0 for --transition none)"},
|
|
773
776
|
"sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
|
|
774
777
|
"video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
|
|
775
778
|
"skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
|
package/scripts/join.py
CHANGED
|
@@ -36,9 +36,11 @@ audio stream is never silent.
|
|
|
36
36
|
"""
|
|
37
37
|
import argparse
|
|
38
38
|
import json
|
|
39
|
+
import math
|
|
39
40
|
import os
|
|
40
41
|
import tempfile
|
|
41
42
|
import sys
|
|
43
|
+
from fractions import Fraction
|
|
42
44
|
from typing import Any, Dict, List, Optional, Tuple
|
|
43
45
|
|
|
44
46
|
from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
|
|
@@ -46,6 +48,7 @@ from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, pla
|
|
|
46
48
|
from _common.emit import note, warn
|
|
47
49
|
from _common.decision import av_clip_length, av_trim, stream_starts
|
|
48
50
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
51
|
+
from _common.timeline import exact_rate
|
|
49
52
|
|
|
50
53
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
51
54
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -112,6 +115,81 @@ def clip_length(meta: Dict[str, Any], fps: float) -> float:
|
|
|
112
115
|
return av_clip_length(meta, fps)
|
|
113
116
|
|
|
114
117
|
|
|
118
|
+
# the video join's sound rate (every clip is resampled to it before the trims)
|
|
119
|
+
JOIN_AUDIO_RATE = 48000
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def output_rate(fps: float, first: Dict[str, Any], asked: bool) -> Fraction:
|
|
123
|
+
"""The join's frame rate as an exact fraction: the first clip's own r_frame_rate/avg_frame_rate
|
|
124
|
+
when `fps` is that clip's (30000/1001, not 29.97), else the NTSC fraction or the plain one
|
|
125
|
+
`fps` names (timeline.exact_rate). Every clip length, offset and sound length is counted in
|
|
126
|
+
frames of this rate, so the graph's fps filter runs at the same fraction."""
|
|
127
|
+
if not asked:
|
|
128
|
+
for key in ("r_frame_rate", "avg_frame_rate"):
|
|
129
|
+
try:
|
|
130
|
+
q = Fraction(str(first.get(key) or ""))
|
|
131
|
+
except (ValueError, ZeroDivisionError):
|
|
132
|
+
continue
|
|
133
|
+
# the declared rate also wins when the average was taken over a length that leaves out
|
|
134
|
+
# the last frame's duration (#475: 5 frames in 0.1333 s read 37.5 fps, and the whole
|
|
135
|
+
# join ran at 75/2)
|
|
136
|
+
if 0 < q <= 240 and (abs(float(q) - fps) < 1e-3 or short_last_frame(first, q)):
|
|
137
|
+
return q
|
|
138
|
+
return exact_rate(fps)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def short_last_frame(video: Optional[Dict[str, Any]], q: Fraction) -> bool:
|
|
142
|
+
"""Whether a picture's duration is its frame count less one frame at rate `q`: a container
|
|
143
|
+
that stores no duration for the last frame (MOV written with -fps_mode passthrough, seen on
|
|
144
|
+
FFmpeg 7.1) reads 5 frames as 0.1333 s at 30 fps (#475)."""
|
|
145
|
+
v = video or {}
|
|
146
|
+
nb, dur = v.get("nb_frames"), v.get("duration")
|
|
147
|
+
if not nb or nb < 2 or not dur or q <= 0:
|
|
148
|
+
return False
|
|
149
|
+
step = float(1 / q)
|
|
150
|
+
return abs(dur - (nb - 1) * step) < 0.25 * step and abs(dur - nb * step) > 0.25 * step
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def with_whole_picture(meta: Dict[str, Any], q: Fraction) -> Dict[str, Any]:
|
|
154
|
+
"""`meta` with its picture counted as every frame it holds when its duration leaves out the
|
|
155
|
+
last one (short_last_frame), so the clip-length rule does not read the sound as running a
|
|
156
|
+
frame past the picture and hold the clip longer (#475). Other clips are returned as they are."""
|
|
157
|
+
v = meta.get("video") or {}
|
|
158
|
+
# each clip at its OWN declared rate: at the join's rate a complete 30 fps clip of 5 frames
|
|
159
|
+
# (5/30 s) also equals 4 frames at --fps 24 and would be stretched to 5/24 s
|
|
160
|
+
try:
|
|
161
|
+
own = Fraction(str(v.get("r_frame_rate") or ""))
|
|
162
|
+
except (ValueError, ZeroDivisionError):
|
|
163
|
+
own = q
|
|
164
|
+
if not 0 < own <= 240:
|
|
165
|
+
own = q
|
|
166
|
+
if not short_last_frame(v, own):
|
|
167
|
+
return meta
|
|
168
|
+
return dict(meta, video=dict(v, duration=float(v["nb_frames"] / own)))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def clip_frames(meta: Dict[str, Any], length: float, q: Fraction) -> int:
|
|
172
|
+
"""How many frames of rate `q` a clip of `length` seconds (clip_length) holds in the join: its
|
|
173
|
+
picture's frames, or, when the clip lasts as long as its sound, enough frames to cover that
|
|
174
|
+
sound (narration is never cut; a hundredth of a frame is rounding, not sound). A pending
|
|
175
|
+
dry-run stub's 0 s stays 0; a measured clip holds at least one frame (#455: a length in
|
|
176
|
+
milliseconds rounded up started every next clip late, and the errors added up)."""
|
|
177
|
+
exact = length * q.numerator / q.denominator
|
|
178
|
+
vlen = (meta.get("video") or {}).get("duration") or 0.0
|
|
179
|
+
frames = math.ceil(exact - 0.01) if vlen and length > vlen else round(exact)
|
|
180
|
+
return frames if meta.get("dry_run") else max(1, frames)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def frame_time(frames: int, q: Fraction) -> str:
|
|
184
|
+
"""The time of frame `frames` at rate `q` in seconds, rounded down to the microsecond: never
|
|
185
|
+
after that frame's own timestamp, so a filter that starts on the first frame at or after it
|
|
186
|
+
(xfade's offset) starts on that frame and not the next (#468: 2.566667 written 2.567 started
|
|
187
|
+
the transition a frame late). Trailing zeros are dropped (0.5, not 0.500000)."""
|
|
188
|
+
us = (frames * q.denominator * 1_000_000) // q.numerator
|
|
189
|
+
text = f"{us // 1_000_000}.{us % 1_000_000:06d}".rstrip("0").rstrip(".")
|
|
190
|
+
return text or "0"
|
|
191
|
+
|
|
192
|
+
|
|
115
193
|
def find_duplicates(paths: List[str]) -> List[Dict[str, Any]]:
|
|
116
194
|
"""Every path (compared by its resolved absolute form) listed more than once, with the 0-based
|
|
117
195
|
list positions it appears at. A warning, not a refusal: repeating a clip can be intentional."""
|
|
@@ -543,6 +621,10 @@ def main() -> int:
|
|
|
543
621
|
apply_common(args)
|
|
544
622
|
if args.fps is not None and args.fps <= 0:
|
|
545
623
|
die(f"--fps must be positive, got {args.fps:g}")
|
|
624
|
+
# nan and inf are refused by the parser (finite_float); a negative length was taken as the
|
|
625
|
+
# one-frame minimum and the join succeeded with a transition nobody asked for
|
|
626
|
+
if args.duration < 0:
|
|
627
|
+
die(f"--duration must be 0 or more seconds, got {args.duration:g}")
|
|
546
628
|
|
|
547
629
|
if args.list:
|
|
548
630
|
if args.inputs:
|
|
@@ -666,6 +748,9 @@ def main() -> int:
|
|
|
666
748
|
w, h = fw, fh
|
|
667
749
|
fps = args.fps or first.get("fps") or 30.0
|
|
668
750
|
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
751
|
+
# the exact rate every length below is counted in (30000/1001, not 29.97)
|
|
752
|
+
rate_q = output_rate(fps, first, bool(args.fps))
|
|
753
|
+
fps = float(rate_q)
|
|
669
754
|
w, h = w - (w % 2), h - (h % 2)
|
|
670
755
|
note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
|
|
671
756
|
durs = [m.get("duration") or 0.0 for m in metas]
|
|
@@ -675,11 +760,37 @@ def main() -> int:
|
|
|
675
760
|
# its picture and its sound together. The plain cut used to take the raw streams, and a sound
|
|
676
761
|
# past the picture (a music bed padded to the container, even a 20 ms AAC tail) left a hole in
|
|
677
762
|
# the video there.
|
|
678
|
-
|
|
763
|
+
# Each length is a whole number of frames of the output rate, and each sound length the
|
|
764
|
+
# samples those frames last (#455): a length in milliseconds rounded 5 frames at 30 fps
|
|
765
|
+
# (0.166667 s) up to 0.167 s, the concat filter started the next clip after it, and 60 such
|
|
766
|
+
# clips left one-frame holes from about 8 s on. Sample counts come from the running frame
|
|
767
|
+
# total, so the sound never drifts from the picture by more than one sample.
|
|
768
|
+
lmetas = [with_whole_picture(m, rate_q) for m in metas]
|
|
769
|
+
frames = [clip_frames(m, clip_length(m, fps), rate_q) for m in lmetas]
|
|
770
|
+
# the transition is a whole number of frames too, so xfade's offsets (and acrossfade's
|
|
771
|
+
# overlap) land on frames of the output rate (#468). A transition is at least one frame:
|
|
772
|
+
# --duration 0 used to build acrossfade=ns=0, which FFmpeg refuses
|
|
773
|
+
tframes = max(1, round(d * fps)) if args.transition != "none" else 0
|
|
774
|
+
d = tframes / fps
|
|
775
|
+
lens = [f / fps for f in frames]
|
|
679
776
|
place_silent(lens, d)
|
|
680
|
-
for p,
|
|
681
|
-
if d and
|
|
682
|
-
die(f"{p} is only {
|
|
777
|
+
for p, nf in zip(args.inputs, frames):
|
|
778
|
+
if d and nf <= tframes * 2 and not STATE.dry_run:
|
|
779
|
+
die(f"{p} is only {nf / fps:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
|
|
780
|
+
# every sample boundary is a frame position of the OUTPUT rounded once: clip i spans output
|
|
781
|
+
# frames [start_i, start_i + frames_i) and overlaps the clip before it over [start_i,
|
|
782
|
+
# start_i + tframes), so its samples and each overlap's are differences of rounded
|
|
783
|
+
# cumulative positions and their sum telescopes to the output's own length. Rounding each
|
|
784
|
+
# overlap on its own (14414.4 samples -> 14414 at 30000/1001 and 9 frames) left the sound
|
|
785
|
+
# 0.4 samples longer per transition.
|
|
786
|
+
def at(frame: int) -> int:
|
|
787
|
+
return round(frame * JOIN_AUDIO_RATE / rate_q)
|
|
788
|
+
starts, start = [], 0
|
|
789
|
+
for i, nf in enumerate(frames):
|
|
790
|
+
starts.append(start)
|
|
791
|
+
start += nf - tframes
|
|
792
|
+
samples = [at(s0 + nf) - at(s0) for s0, nf in zip(starts, frames)]
|
|
793
|
+
overlaps = [at(s0 + tframes) - at(s0) for s0 in starts]
|
|
683
794
|
|
|
684
795
|
cmd = ffmpeg_base()
|
|
685
796
|
extra_inputs: List[str] = []
|
|
@@ -745,7 +856,10 @@ def main() -> int:
|
|
|
745
856
|
if len(set(chans)) > 1:
|
|
746
857
|
info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
|
|
747
858
|
for i in range(n):
|
|
748
|
-
|
|
859
|
+
# the fps filter's time base is one frame (1/rate), so every timestamp downstream is a
|
|
860
|
+
# frame number and an offset in seconds is rounded to a whole frame, never a microsecond
|
|
861
|
+
# off it on any FFmpeg (AVTB left xfade to compare microseconds)
|
|
862
|
+
parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={rate_q.numerator}/{rate_q.denominator},format={pixfmt}[v{i}]")
|
|
749
863
|
# the sound is shifted by the picture's start, not to its own (#409): #338 started each
|
|
750
864
|
# clip's picture at 0, and a per-stream PTS-STARTPTS then moved a sound that starts 0.3 s
|
|
751
865
|
# after its picture 0.3 s early. Sound before the first frame is dropped and a late sound
|
|
@@ -755,23 +869,29 @@ def main() -> int:
|
|
|
755
869
|
|
|
756
870
|
# pad the shorter stream to the clip's length: the audio with silence, the picture by holding
|
|
757
871
|
# its last frame, then cut both there -- each clip boundary is then the same for both chains
|
|
872
|
+
# -- by frame and by sample, never by rounded seconds (#455)
|
|
758
873
|
for i in range(n):
|
|
759
|
-
vlen = (
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
874
|
+
vlen = (lmetas[i].get("video") or {}).get("duration") or lens[i]
|
|
875
|
+
have = round(vlen * fps)
|
|
876
|
+
# one frame more than the gap is held, and the trim cuts at the clip's last frame
|
|
877
|
+
hold = f"tpad=stop_mode=clone:stop={frames[i] - have + 1}," if frames[i] > have else ""
|
|
878
|
+
parts.append(f"[v{i}]{hold}trim=end_frame={frames[i]}[vp{i}]")
|
|
879
|
+
parts.append(f"[a{i}]apad=whole_len={samples[i]},atrim=end_sample={samples[i]}[ap{i}]")
|
|
763
880
|
if args.transition == "none":
|
|
764
881
|
chain = "".join(f"[vp{i}][ap{i}]" for i in range(n))
|
|
765
882
|
parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
|
|
766
883
|
else:
|
|
767
884
|
vprev, aprev = "vp0", "ap0"
|
|
768
|
-
|
|
885
|
+
start = 0
|
|
769
886
|
for i in range(1, n):
|
|
770
|
-
|
|
887
|
+
# clip i starts on frame `start` of the output; xfade starts on the first frame at or
|
|
888
|
+
# after its offset, so the offset is that frame's time rounded down (#468)
|
|
889
|
+
start += frames[i - 1] - tframes
|
|
771
890
|
vout = f"vx{i}" if i < n - 1 else "vout"
|
|
772
891
|
aout = f"ax{i}" if i < n - 1 else "aout"
|
|
773
|
-
parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={
|
|
774
|
-
|
|
892
|
+
parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={frame_time(tframes, rate_q)}"
|
|
893
|
+
f":offset={frame_time(start, rate_q)}[{vout}]")
|
|
894
|
+
parts.append(f"[{aprev}][ap{i}]acrossfade=ns={overlaps[i]}:c1=tri:c2=tri[{aout}]")
|
|
775
895
|
vprev, aprev = vout, aout
|
|
776
896
|
|
|
777
897
|
# A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
|
|
@@ -784,7 +904,7 @@ def main() -> int:
|
|
|
784
904
|
"by its measured length")
|
|
785
905
|
elif not d and any(m.get("dry_run") for m in metas):
|
|
786
906
|
# the plain cut trims/holds every clip to clip_length too (2.4.1), so a pending clip's
|
|
787
|
-
# planned trim=
|
|
907
|
+
# planned trim=end_frame=0 is the stub's placeholder, not the cut
|
|
788
908
|
STATE_NOTES.append("the planned trim lengths count each pending input as 0 s long: a real run trims "
|
|
789
909
|
"or holds it to its measured length")
|
|
790
910
|
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
@@ -818,6 +938,7 @@ def main() -> int:
|
|
|
818
938
|
info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
819
939
|
overlap = overlap_report(lens, expected)
|
|
820
940
|
emit(output, mode="video", **overlap, **reported(), clips=n, transition=args.transition, expected_duration=expected,
|
|
941
|
+
frame_rate=f"{rate_q.numerator}/{rate_q.denominator}", clip_frames=frames, transition_frames=tframes,
|
|
821
942
|
verification=verification, expect_streams={"video": 1, "audio": 1}, stream_reference=metas,
|
|
822
943
|
audio_substituted=substituted,
|
|
823
944
|
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|