ffmpeg-skill 3.8.11 → 3.8.12
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 +2 -2
- package/package.json +1 -1
- package/scripts/_common/decision.py +89 -2
- package/scripts/broll.py +74 -16
- package/scripts/fit.py +35 -4
- package/scripts/freeze.py +4 -2
- package/scripts/graphics.py +17 -8
- package/scripts/loop.py +15 -1
- package/scripts/overlay.py +12 -13
- package/scripts/pad.py +12 -4
- package/scripts/sync.py +6 -2
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.8.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.12`) | 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.8.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.12", "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"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.12",
|
|
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": [
|
|
@@ -176,6 +176,16 @@ def fmt_secs(value: Optional[float]) -> str:
|
|
|
176
176
|
return "?s" if value is None else f"{value:.3f}s"
|
|
177
177
|
|
|
178
178
|
|
|
179
|
+
def ntsc_rate(fps: float) -> float:
|
|
180
|
+
"""`fps` as the rate it is a rounding of: 23.976, 29.97, 47.952, 59.94 and 119.88 (what
|
|
181
|
+
probe() reports, and what a caller types after `@`) are N * 1000 / 1001. Any other rate is
|
|
182
|
+
returned as it is. timeline.exact_rate() reads the same five rates the same way."""
|
|
183
|
+
whole = round(fps * 1.001)
|
|
184
|
+
if whole in (24, 30, 48, 60, 120) and abs(fps - whole / 1.001) < 0.01 and abs(fps - whole) > 0.01:
|
|
185
|
+
return whole * 1000 / 1001
|
|
186
|
+
return fps
|
|
187
|
+
|
|
188
|
+
|
|
179
189
|
def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
180
190
|
"""Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
|
|
181
191
|
or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
|
|
@@ -212,7 +222,10 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
212
222
|
# 3596.4 s of video). This is exactly what fmt_smpte_time() inverts; before, the two
|
|
213
223
|
# disagreed by ~0.1 % on the fractional NTSC rates and drifted apart over long files.
|
|
214
224
|
total_frames = (int(h) * 3600 + int(m) * 60 + int(s)) * whole_fps + frame
|
|
215
|
-
|
|
225
|
+
# at the picture's exact rate: probe()'s `fps` is rounded to three decimals, and frame 37
|
|
226
|
+
# counted at 29.97 is 1.2345679 s, 1.2 microseconds past the frame at 37 * 1001 / 30000,
|
|
227
|
+
# so a start read from 00:00:01:07 was the frame after it (ntsc_rate)
|
|
228
|
+
return total_frames / ntsc_rate(fps)
|
|
216
229
|
if len(parts) > 3:
|
|
217
230
|
raise ValueError(f"bad time: {value}")
|
|
218
231
|
total = 0.0
|
|
@@ -315,7 +328,9 @@ def fmt_smpte_time(seconds: float, fps: float) -> str:
|
|
|
315
328
|
if seconds < 0:
|
|
316
329
|
seconds = 0.0
|
|
317
330
|
whole_fps = int(round(fps))
|
|
318
|
-
|
|
331
|
+
# the frames are counted at the rate parse_time() reads the same text at (29.97 as
|
|
332
|
+
# 30000/1001): at 29.97 itself 06:00:00:00 came back as 05:59:59:29
|
|
333
|
+
total_frames = int(round(seconds * ntsc_rate(fps)))
|
|
319
334
|
frame = total_frames % whole_fps
|
|
320
335
|
secs_total = total_frames // whole_fps
|
|
321
336
|
h, rem = divmod(secs_total, 3600)
|
|
@@ -440,6 +455,56 @@ def grid_at_or_after(t: float, v0: float, rate: float) -> float:
|
|
|
440
455
|
return v0 + math.ceil((t - v0) * rate - 1e-6) / rate
|
|
441
456
|
|
|
442
457
|
|
|
458
|
+
def frame_window(start: Optional[float], end: Optional[float], meta: Optional[Dict[str, Any]],
|
|
459
|
+
half_open: bool = False) -> Optional[Tuple[Optional[float], Optional[float]]]:
|
|
460
|
+
"""The two times a filter compares a frame's `t` with so that a window from `start` to `end`
|
|
461
|
+
seconds of `meta`'s picture holds the frames asked: half a frame before the first frame at or
|
|
462
|
+
after `start`, and half a frame after the last frame at or before `end` (`half_open`: half a
|
|
463
|
+
frame before the first frame at or after `end`, which is then outside). A bound written as
|
|
464
|
+
the time itself is compared with `t` on the frame it names: `{:.3f}` put frame 37 of a
|
|
465
|
+
30000/1001 picture (1.234567 s) after a start of "1.235", and `t` is a double (pts x time
|
|
466
|
+
base) that is not always the number the text reads as (the frame at 1.4 s of a 25 fps MP4 is
|
|
467
|
+
not `<= 1.400`; FFmpeg 6.1.1). The grid is the one grid_at_or_after() counts on. None when
|
|
468
|
+
there is no grid to count on (no rate: a dry run's pending input; a picture probe() suspects
|
|
469
|
+
of a variable rate): the caller keeps the text it had. A bound of None stays None."""
|
|
470
|
+
v = (meta or {}).get("video") or {}
|
|
471
|
+
rate = frame_rate(meta)
|
|
472
|
+
if not rate or v.get("variable_frame_rate_suspected"):
|
|
473
|
+
return None
|
|
474
|
+
v0, half = stream_starts(meta)[0], 0.5 / rate
|
|
475
|
+
lo = None if start is None else grid_at_or_after(start, v0, rate) - half
|
|
476
|
+
if end is None:
|
|
477
|
+
hi = None
|
|
478
|
+
elif half_open:
|
|
479
|
+
hi = grid_at_or_after(end, v0, rate) - half
|
|
480
|
+
else:
|
|
481
|
+
hi = v0 + math.floor((end - v0) * rate + 1e-6) / rate + half
|
|
482
|
+
return lo, hi
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def window_expr(start: Optional[float], end: Optional[float], meta: Optional[Dict[str, Any]],
|
|
486
|
+
half_open: bool = False) -> str:
|
|
487
|
+
"""The expression that is true on the frames of frame_window(): `between(t,LO,HI)`, or
|
|
488
|
+
`gte(t,LO)` with no end. With no start the window opens at 0, as it always did. Without a
|
|
489
|
+
grid it is the millisecond text the tools wrote before."""
|
|
490
|
+
bounds = frame_window(start, end, meta, half_open)
|
|
491
|
+
if bounds is None:
|
|
492
|
+
lo = f"{start:.3f}" if start is not None else "0"
|
|
493
|
+
return f"gte(t,{lo})" if end is None else f"between(t,{lo},{end:.3f})"
|
|
494
|
+
lo = f"{bounds[0]:.6f}" if bounds[0] is not None else "0"
|
|
495
|
+
return f"gte(t,{lo})" if bounds[1] is None else f"between(t,{lo},{bounds[1]:.6f})"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def windowed_overlay_x(x: Any, window: str) -> str:
|
|
499
|
+
"""An `overlay` x that puts the layer at `x` on the frames `window` is true on and off the
|
|
500
|
+
picture on every other frame. overlay's `enable` is not the main picture's alone: FFmpeg
|
|
501
|
+
evaluates it on each frame either input hands over, the layer's next one included, so a still
|
|
502
|
+
looped at 25 fps over a 30000/1001 picture was drawn from the frame before the window's
|
|
503
|
+
first to the frame before its last (36-66 for 37-67; FFmpeg 6.1.1). The position is
|
|
504
|
+
evaluated once per output frame with the main picture's `t`."""
|
|
505
|
+
return f"'if({window},{x},-w)'"
|
|
506
|
+
|
|
507
|
+
|
|
443
508
|
def av_trim(start: float, end: Optional[float], rate: float = 0.0, fill_gaps: bool = False) -> Tuple[str, str]:
|
|
444
509
|
"""The video and the audio filters that cut one segment [start, end) of the graph clock out of
|
|
445
510
|
both streams and shift both by the same constant, so an offset between them survives; `end`
|
|
@@ -536,6 +601,28 @@ def silence_filter(rate: Optional[int], lead: Optional[float] = None, tail: Opti
|
|
|
536
601
|
return ",".join(parts)
|
|
537
602
|
|
|
538
603
|
|
|
604
|
+
def tpad_length(seconds: float, meta: Optional[Dict[str, Any]]) -> float:
|
|
605
|
+
"""How long the picture `tpad=start_duration={seconds:.3f}` puts ahead of `meta`'s clip is:
|
|
606
|
+
tpad adds whole frames, the nearest count to the text it is given (0.23425 s at 25 fps is
|
|
607
|
+
written "0.234" and becomes 6 frames, 0.240 s). A sound delayed by `seconds` itself would
|
|
608
|
+
start up to half a frame before or after the picture it belongs to; delayed by this length it
|
|
609
|
+
moves with it (both streams of a clip shift by one constant: #409). `seconds` as it is when
|
|
610
|
+
there is no rate to count frames at (a dry run's pending input) or probe() suspects a
|
|
611
|
+
variable one."""
|
|
612
|
+
v = (meta or {}).get("video") or {}
|
|
613
|
+
rate = frame_rate(meta)
|
|
614
|
+
if not rate or v.get("variable_frame_rate_suspected"):
|
|
615
|
+
return seconds
|
|
616
|
+
try:
|
|
617
|
+
exact = Fraction(str(v.get("r_frame_rate") or ""))
|
|
618
|
+
except (ValueError, ZeroDivisionError):
|
|
619
|
+
exact = None
|
|
620
|
+
if exact is None or abs(float(exact) - rate) > 1e-6:
|
|
621
|
+
exact = Fraction(rate).limit_denominator(1001)
|
|
622
|
+
frames = math.floor(Fraction(f"{seconds:.3f}") * exact + Fraction(1, 2)) # a half rounds up, as av_rescale does
|
|
623
|
+
return float(frames / exact)
|
|
624
|
+
|
|
625
|
+
|
|
539
626
|
def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
|
|
540
627
|
"""The one length a clip holds once both streams start at its first frame (av_trim from the
|
|
541
628
|
video's start): the video's, or where the sound ends when it runs more than a frame past the
|
package/scripts/broll.py
CHANGED
|
@@ -18,10 +18,12 @@ Examples:
|
|
|
18
18
|
python3 broll.py talk.mp4 --insert demo.mp4 --at 30 --duration 8 --audio mix
|
|
19
19
|
"""
|
|
20
20
|
import argparse
|
|
21
|
+
import math
|
|
21
22
|
import sys
|
|
22
23
|
from typing import Any, Dict, List
|
|
23
24
|
|
|
24
|
-
from _common.decision import av_trim, conform_output_t, conform_to_length,
|
|
25
|
+
from _common.decision import (av_trim, conform_output_t, conform_to_length, frame_durations, frame_rate, frame_window,
|
|
26
|
+
grid_at_or_after, silence_filter, stream_starts, window_expr, windowed_overlay_x)
|
|
25
27
|
from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
28
|
from _common.emit import note, record_fallback
|
|
27
29
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
@@ -77,6 +79,11 @@ def main() -> int:
|
|
|
77
79
|
a_chain = (a_chain + "," if a_chain else "") + f"scale={ew}:{eh},setsar=1"
|
|
78
80
|
w, h = ew, eh
|
|
79
81
|
fps = meta_a["video"].get("fps") or 30.0
|
|
82
|
+
# the A-roll's frame grid, when it has one (frame_window): its exact rate and where it starts
|
|
83
|
+
grid = frame_window(0.0, None, meta_a) is not None
|
|
84
|
+
rate_a, v0a = frame_rate(meta_a), stream_starts(meta_a)[0]
|
|
85
|
+
fps_filter = frame_durations(meta_a, rate_a) # fps=30000/1001; a whole rate reads as it did (fps=25)
|
|
86
|
+
fps_filter = fps_filter[:-2] if fps_filter.endswith("/1") else fps_filter
|
|
80
87
|
has_audio_a = bool(meta_a.get("audio"))
|
|
81
88
|
|
|
82
89
|
def per(values: List[str], i: int, default: str) -> str:
|
|
@@ -105,7 +112,19 @@ def main() -> int:
|
|
|
105
112
|
dur_b = input_length(meta_b)
|
|
106
113
|
if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
|
|
107
114
|
die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
|
|
108
|
-
|
|
115
|
+
# the cutaway in whole frames: `first` is B's first frame at or after --from, `on` the
|
|
116
|
+
# A-roll's first frame at or after --at, and `span` runs to its first at or after the end
|
|
117
|
+
v0b, rate_b = stream_starts(meta_b)[0], frame_rate(meta_b)
|
|
118
|
+
first = max(v0b, grid_at_or_after(start_b, v0b, rate_b))
|
|
119
|
+
on = span = None
|
|
120
|
+
if grid:
|
|
121
|
+
on = grid_at_or_after(at, v0a, rate_a)
|
|
122
|
+
span = grid_at_or_after(at + length, v0a, rate_a) - on
|
|
123
|
+
if round(span * rate_a) < 1:
|
|
124
|
+
die(f"cutaway {i + 1}: no frame of the A-roll starts between {at:g}s and {at + length:g}s "
|
|
125
|
+
f"(its frames are {1 / rate_a:.4f}s apart): nothing would be shown")
|
|
126
|
+
cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b,
|
|
127
|
+
"first": first, "rate": rate_b, "on": on, "span": span})
|
|
109
128
|
# overlap is a property of the windows in time, not of the order they were given in: compared
|
|
110
129
|
# against the one given before it, a later window listed first was called overlapping (A179)
|
|
111
130
|
order = sorted(range(len(cutaways)), key=lambda k: cutaways[k]["at"])
|
|
@@ -123,7 +142,7 @@ def main() -> int:
|
|
|
123
142
|
cmd += ["-i", c["path"]]
|
|
124
143
|
|
|
125
144
|
# Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
|
|
126
|
-
# --at time, and overlaid on A;
|
|
145
|
+
# --at time, and overlaid on A; outside the window the overlay is placed off the picture and
|
|
127
146
|
# A shows through unchanged, so A's timeline is never touched.
|
|
128
147
|
parts: List[str] = []
|
|
129
148
|
cur = "[0:v]"
|
|
@@ -144,9 +163,32 @@ def main() -> int:
|
|
|
144
163
|
+ drawn_chain(pad + args.pad_color, as_limited(c["meta"]), [args.pad_color], pad + "black", line_meta=meta_a,
|
|
145
164
|
tag=f"pad{i}", labelled=True))
|
|
146
165
|
b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
pix = 'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'
|
|
167
|
+
if c["on"] is None:
|
|
168
|
+
# no grid to count the A-roll's frames on (a dry run's pending A-roll, a variable
|
|
169
|
+
# rate): the window as it was written before, to the millisecond
|
|
170
|
+
parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
|
|
171
|
+
f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={pix}[b{i}]")
|
|
172
|
+
window = window_expr(c["at"], c["at"] + c["length"], None)
|
|
173
|
+
else:
|
|
174
|
+
# The window is whole frames of the A-roll: from its first frame at or after --at to
|
|
175
|
+
# the frame before its first at or after the end. B is cut half a frame of its own
|
|
176
|
+
# before its first frame at or after --from, for as long as the window lasts, and
|
|
177
|
+
# that frame is put on the tick of the `fps` grid at or before the window's first
|
|
178
|
+
# frame, at the A-roll's exact rate (30000/1001, not probe()'s 29.97: frame 37
|
|
179
|
+
# counted at 29.97 comes 1.2 microseconds after the A-roll's and was shown a frame on)
|
|
180
|
+
# The cut never ends before B's first frame: a window shorter than half a frame of B
|
|
181
|
+
# (one 60 fps frame of the A-roll under a 24 fps cutaway) cut that frame off too
|
|
182
|
+
half_b = 0.5 / c["rate"] if c["rate"] else 0.0
|
|
183
|
+
parts.append(f"[{i + 1}:v]trim=start={max(0.0, c['first'] - half_b):.6f}:end={max(c['first'] + c['span'], c['first'] + 2 * half_b) - half_b:.6f},"
|
|
184
|
+
f"setpts=PTS-STARTPTS+{math.floor(c['on'] * rate_a + 1e-6) / rate_a:.6f}/TB,"
|
|
185
|
+
f"{convert[i + 1]}{b_sq}{geo},setsar=1,{fps_filter},format={pix}[b{i}]")
|
|
186
|
+
window = window_expr(c["on"], c["on"] + c["span"], meta_a, half_open=True)
|
|
187
|
+
# The window is in the cutaway's position: overlay's enable is evaluated on B's frames
|
|
188
|
+
# too. B's last frame is held when B ends (eof_action=repeat) and the position takes it
|
|
189
|
+
# off the picture: with eof_action=pass the window's last frame depended on where B's
|
|
190
|
+
# end fell against it, and an A-roll whose picture starts 13 ms late lost that frame.
|
|
191
|
+
parts.append(f"{cur}[b{i}]overlay={windowed_overlay_x(0, window)}:0{overlay_format(meta_a)}:eof_action=repeat[v{i}]")
|
|
150
192
|
cur = f"[v{i}]"
|
|
151
193
|
vout = cur
|
|
152
194
|
|
|
@@ -158,24 +200,40 @@ def main() -> int:
|
|
|
158
200
|
aout = "0:a:0" if has_audio_a else None
|
|
159
201
|
else:
|
|
160
202
|
layers: List[str] = []
|
|
203
|
+
# the cutaway's sound follows its picture: it starts with the window's first frame and
|
|
204
|
+
# lasts as long as the window, counted in samples of the mix (without a frame grid:
|
|
205
|
+
# --at and the length asked)
|
|
206
|
+
for c in cutaways:
|
|
207
|
+
c["s0"] = round((c["at"] if c["on"] is None else c["on"]) * MIX_RATE)
|
|
208
|
+
c["sn"] = round((c["length"] if c["on"] is None else c["span"]) * MIX_RATE)
|
|
161
209
|
if has_audio_a:
|
|
162
|
-
|
|
210
|
+
# `b` mutes the A-roll over exactly the samples B's sound takes, on the A-roll's
|
|
211
|
+
# own clock: aeval's `t` is each sample's time, and the bounds sit half a sample
|
|
212
|
+
# outside the first and the last. `volume=0:enable='between(t,...)'` is evaluated
|
|
213
|
+
# once per audio frame and muted 48128-96255 where 48000-95999 was asked
|
|
214
|
+
# (1024-sample frames; FFmpeg 6.1.1)
|
|
215
|
+
gate = ""
|
|
216
|
+
if args.audio == "b":
|
|
217
|
+
gate = ",aeval='val(ch)*not(" + "+".join(
|
|
218
|
+
f"between(t,{(c['s0'] - 0.5) / MIX_RATE:.9f},{(c['s0'] + c['sn'] - 0.5) / MIX_RATE:.9f})" for c in cutaways) + ")':c=same"
|
|
163
219
|
parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
|
|
164
220
|
layers.append("[a0]")
|
|
165
221
|
for i, c in enumerate(cutaways):
|
|
166
222
|
# B's sound is shifted by the same constant as its picture, whose first frame (the
|
|
167
|
-
# first at or after --from) lands on
|
|
168
|
-
# sample moved a track that starts 0.3 s late 0.279 s early (#409)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
# the delay is --at to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
|
|
223
|
+
# first at or after --from) lands on the window's first frame; rebasing the sound
|
|
224
|
+
# to its own first sample moved a track that starts 0.3 s late 0.279 s early (#409)
|
|
225
|
+
first, length = c["first"], c["length"] if c["on"] is None else c["span"]
|
|
226
|
+
# the delay is to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
|
|
172
227
|
# 1000 ms and wrote 1.2346 s as 1234 ms, the cutaway's sound 48 and 29 samples
|
|
173
228
|
# early. A whole millisecond keeps the text it had, rounded (silence_filter)
|
|
174
|
-
|
|
229
|
+
lead = c["s0"] / MIX_RATE
|
|
230
|
+
delay = silence_filter(MIX_RATE, lead=lead)
|
|
175
231
|
if "S:" not in delay:
|
|
176
|
-
delay = f"adelay={round(
|
|
177
|
-
|
|
178
|
-
|
|
232
|
+
delay = f"adelay={round(lead * 1000)}|{round(lead * 1000)}"
|
|
233
|
+
# under `b` the layer is as many samples as the gate mutes, padded or cut
|
|
234
|
+
exact = f"apad=whole_len={c['sn']},atrim=end_sample={c['sn']}," if args.audio == "b" else ""
|
|
235
|
+
parts.append(f"[{i + 1}:a:0]{av_trim(first, first + length)[1]},"
|
|
236
|
+
f"aformat=sample_rates=48000:channel_layouts=stereo,{exact}{delay}[ab{i}]")
|
|
179
237
|
layers.append(f"[ab{i}]")
|
|
180
238
|
# the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
|
|
181
239
|
# is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
|
package/scripts/fit.py
CHANGED
|
@@ -68,6 +68,24 @@ def atempo_chain(factor: float) -> str:
|
|
|
68
68
|
return ",".join(parts)
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def picture_output_t(target: float, meta: dict) -> str:
|
|
72
|
+
"""The output `-t` of a trim from the start that has no sound to end: `{target:.3f}` as it
|
|
73
|
+
always was, unless that text keeps another number of frames than `target` does. The frames
|
|
74
|
+
kept are those that start before `target`. 37 frames of a 30000/1001 picture are 1.2345667 s:
|
|
75
|
+
`-t 1.235` is past the 38th frame's start and wrote it, and `-t 1.001` for 1.0014 s stopped
|
|
76
|
+
before the 31st. The limit is then written half a frame before the first frame left out.
|
|
77
|
+
Without a frame grid (no rate: a dry run's pending input; a variable rate, which the output's
|
|
78
|
+
-r regrids) the millisecond text stays."""
|
|
79
|
+
text = f"{target:.3f}"
|
|
80
|
+
rate = frame_rate(meta)
|
|
81
|
+
if not rate or (meta.get("video") or {}).get("variable_frame_rate_suspected"):
|
|
82
|
+
return text
|
|
83
|
+
out = grid_at_or_after(target, stream_starts(meta)[0], rate) # the first frame left out
|
|
84
|
+
if out - 1 / rate + 1e-6 < float(text) <= out + 1e-9:
|
|
85
|
+
return text
|
|
86
|
+
return f"{out - 0.5 / rate:.6f}"
|
|
87
|
+
|
|
88
|
+
|
|
71
89
|
def main() -> int:
|
|
72
90
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
73
91
|
ap.add_argument("input")
|
|
@@ -189,9 +207,17 @@ def main() -> int:
|
|
|
189
207
|
vf.append("tblend=all_mode=average")
|
|
190
208
|
if has_audio:
|
|
191
209
|
af.append(atempo_chain(factor))
|
|
192
|
-
# atempo's sound
|
|
193
|
-
# millisecond
|
|
194
|
-
|
|
210
|
+
# atempo's sound is not the input's length over the factor (it comes out some
|
|
211
|
+
# milliseconds short or long), and the millisecond -t stays on a retimed track: padding
|
|
212
|
+
# it to the target's samples (apad after atempo in -af) never ended on FFmpeg 8.0.1
|
|
213
|
+
# (seen in CI: killed at the time limit), so the track keeps the length atempo gives.
|
|
214
|
+
# Where the sound is not retimed (a factor within 1e-4 of 1) the source's runs on and
|
|
215
|
+
# the -t ends it to the sample. A picture without sound that is not retimed either is
|
|
216
|
+
# a trim, counted in frames; one that is retimed, or regridded by --fps, keeps the
|
|
217
|
+
# millisecond
|
|
218
|
+
retimed = abs(factor - 1.0) > 1e-4
|
|
219
|
+
post += ["-t", f"{target:.3f}" if af else length_output_t(target, rate, runs_on=True) if rate
|
|
220
|
+
else f"{target:.3f}" if retimed or args.fps or has_audio else picture_output_t(target, meta)]
|
|
195
221
|
STATE.duration_hint = target
|
|
196
222
|
else:
|
|
197
223
|
if target < src_dur:
|
|
@@ -202,7 +228,12 @@ def main() -> int:
|
|
|
202
228
|
# picture would not be the one the start timecode below names
|
|
203
229
|
m = min(SEEK_MARGIN, round(start, 3))
|
|
204
230
|
pre_input += ["-ss", f"{start - m:.3f}"]
|
|
205
|
-
|
|
231
|
+
# without sound the -t only counts frames (picture_output_t). Not from the centre:
|
|
232
|
+
# there the output's clock starts at the seek, between two frames, and the encoder
|
|
233
|
+
# rounds each frame to its own tick
|
|
234
|
+
keep = (picture_output_t(target, meta) if not rate and not args.fps and not args.from_center
|
|
235
|
+
else length_output_t(target, rate, runs_on=True))
|
|
236
|
+
post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", keep]
|
|
206
237
|
# the output starts on the first frame at or after the seek (a seek before a
|
|
207
238
|
# picture that starts after the file's origin: on its first frame): its start
|
|
208
239
|
# timecode is that frame's, not the source's first frame's
|
package/scripts/freeze.py
CHANGED
|
@@ -19,7 +19,7 @@ Examples:
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
-
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts
|
|
22
|
+
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
|
|
23
23
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, time_arg, fmt_secs, run
|
|
24
24
|
from _common.emit import input_length
|
|
25
25
|
|
|
@@ -83,7 +83,9 @@ def main() -> int:
|
|
|
83
83
|
vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
|
|
84
84
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
85
85
|
if has_audio:
|
|
86
|
-
|
|
86
|
+
# the sound waits as long as the picture is held: whole frames (tpad_length), as the
|
|
87
|
+
# hold in the middle of a clip pads by the held frames' length
|
|
88
|
+
cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), lead=tpad_length(args.hold, meta))]
|
|
87
89
|
else:
|
|
88
90
|
# freeze N frames at `at` by holding on that one source frame for --hold seconds, then
|
|
89
91
|
# resuming the rest of the clip: split the timeline at `at` and hold the remainder's
|
package/scripts/graphics.py
CHANGED
|
@@ -51,6 +51,7 @@ from _common.fonts import font_choice, record_font_choice
|
|
|
51
51
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
52
52
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
53
53
|
from _common import drawtext_fontfile # noqa: E402
|
|
54
|
+
from _common.decision import window_expr, windowed_overlay_x # noqa: E402
|
|
54
55
|
from _common.emit import input_length # noqa: E402
|
|
55
56
|
|
|
56
57
|
# where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
|
|
@@ -232,7 +233,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
232
233
|
hint="give a --start inside the clip")
|
|
233
234
|
if e <= s:
|
|
234
235
|
die("--end must be after --start")
|
|
235
|
-
|
|
236
|
+
# the window's bounds sit half a frame from the frames they select (window_expr): `{:.3f}`
|
|
237
|
+
# put the frame at 1.234567 s of a 30000/1001 picture after a --start of "1.235"
|
|
238
|
+
en = f"enable='{window_expr(s, e, meta)}'"
|
|
236
239
|
base = min(W, H) * args.scale # scale everything from the short side
|
|
237
240
|
filters: List[str] = []
|
|
238
241
|
rate = f"{meta['video'].get('fps') or 30:g}"
|
|
@@ -253,6 +256,12 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
253
256
|
filters.append(f"{BOX}{len(boxes)}")
|
|
254
257
|
boxes.append((colour, alpha, drawbox_geo, x, y, w, h, enable, x_expr))
|
|
255
258
|
|
|
259
|
+
def layer_at(x, y, enable: str) -> str:
|
|
260
|
+
"""An overlay's `x=...:y=...` for a layer shown inside `enable`'s window: the window is in
|
|
261
|
+
the layer's position, which is evaluated on the picture's frames, where overlay's own
|
|
262
|
+
enable is evaluated on the layer's frames too (windowed_overlay_x)."""
|
|
263
|
+
return f"x={windowed_overlay_x(str(x).strip(chr(39)), enable[len('enable='):].strip(chr(39)))}:y={y}"
|
|
264
|
+
|
|
256
265
|
def layer_src(colour: str, alpha: float, w: int, h: int, left: int = 0, top: int = 0) -> str:
|
|
257
266
|
"""A colour source to overlay (a box, the lower-third bar, the progress fill), converted when it
|
|
258
267
|
has chroma. overlay places a 4:2:0 picture on even rows and columns only: a box that starts on
|
|
@@ -450,8 +459,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
450
459
|
x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{m_left})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{m_left}-({bar_w}+{m_left})*(1-({e:.3f}-t)/0.3),{m_left}))"
|
|
451
460
|
fc.append(f"{layer_src(bg, 0.85, bar_w, bar_h)}[bar]")
|
|
452
461
|
fc.append(f"{layer_src(primary, 1.0, int(base * 0.012), bar_h)}[acc]")
|
|
453
|
-
fc.append(f"[0:v][bar]overlay=
|
|
454
|
-
fc.append(f"[v1][acc]overlay=
|
|
462
|
+
fc.append(f"[0:v][bar]overlay={layer_at(x_expr, y0, en)}:eof_action=pass[v1]")
|
|
463
|
+
fc.append(f"[v1][acc]overlay={layer_at(x_expr, y0, en)}:eof_action=pass[v2]")
|
|
455
464
|
tx = f"({x_expr})+{tx_pad}"
|
|
456
465
|
x_rest, x_off = m_left + tx_pad, -bar_w + tx_pad
|
|
457
466
|
draws = []
|
|
@@ -546,10 +555,10 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
546
555
|
# a layer (overlay composites the alpha too; drawbox leaves it as it was, and takes a
|
|
547
556
|
# 10-bit picture to 8 bits)
|
|
548
557
|
fc.append(f"{layer_src(bg, 0.5, W, h, 0, (H - h) % 2)}[pbg]")
|
|
549
|
-
fc.append(f"[0:v][pbg]overlay=
|
|
558
|
+
fc.append(f"[0:v][pbg]overlay={layer_at(0, (H - h) // 2 * 2, en)}:eof_action=pass[v1]")
|
|
550
559
|
else:
|
|
551
560
|
fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
|
|
552
|
-
fc.append(f"[v1][pb]overlay=
|
|
561
|
+
fc.append(f"[v1][pb]overlay={layer_at(f'-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))', H - h, en)}:eof_action=pass[vout]")
|
|
553
562
|
|
|
554
563
|
elif args.template == "sticker":
|
|
555
564
|
# A social sticker: a filled chip of text that pops in. drawtext's box gives the chip
|
|
@@ -593,7 +602,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
593
602
|
if args.duration <= 0:
|
|
594
603
|
die(f"--duration must be > 0, got {args.duration:g}")
|
|
595
604
|
he = min(e, s + args.duration)
|
|
596
|
-
hen = f"enable='
|
|
605
|
+
hen = f"enable='{window_expr(s, he, meta)}'"
|
|
597
606
|
h1 = int(base * 0.085)
|
|
598
607
|
bar_h = max(3, int(base * 0.01))
|
|
599
608
|
hook_title = wrapped(args.title, h1)
|
|
@@ -642,7 +651,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
642
651
|
ke = ks + seg
|
|
643
652
|
pulse = f"1-0.15*min(1,(t-{ks:.3f})/{seg * 0.5:.3f})"
|
|
644
653
|
add_text(str(k),
|
|
645
|
-
f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='
|
|
654
|
+
f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='{window_expr(ks, ke, meta, half_open=k > 0)}'",
|
|
646
655
|
size=fs, color=primary, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
|
|
647
656
|
outline=float(max(2, fs // 40)), outline_color=bg, start=ks, end=ke,
|
|
648
657
|
scale_t=(0, seg * 500, 115))
|
|
@@ -766,7 +775,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
766
775
|
drawboxes = []
|
|
767
776
|
for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
|
|
768
777
|
if as_layers:
|
|
769
|
-
layers.append((layer_src(colour, alpha, w, h, x % 2, y % 2),
|
|
778
|
+
layers.append((layer_src(colour, alpha, w, h, x % 2, y % 2), layer_at(x_expr or x - x % 2, y - y % 2, enable)))
|
|
770
779
|
drawboxes.append(f"drawbox={geo}:color={ff_color(colour, alpha)}:t=fill:{enable}")
|
|
771
780
|
filters = [("" if as_layers else drawboxes[int(f[len(BOX):])]) if f.startswith(BOX) else f for f in filters]
|
|
772
781
|
filters = [f for f in filters if f]
|
package/scripts/loop.py
CHANGED
|
@@ -296,6 +296,8 @@ def main() -> int:
|
|
|
296
296
|
die(f"--duration ({target:g}s) must be longer than one repeat ({unit:.3f}s) -- use cut.py to trim instead")
|
|
297
297
|
stream_loop = math.ceil(target / unit) - 1
|
|
298
298
|
|
|
299
|
+
# a variable-rate source is conformed by the output's own -fps_mode cfr -r (cfr_args)
|
|
300
|
+
forced_rate = cfr_args(meta, fps if fade_frames and v.get("variable_frame_rate_suspected") else None)
|
|
299
301
|
if args.boomerang:
|
|
300
302
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", _boomerang_graph(meta, src_dur, args.times), "-map", "[v]"]
|
|
301
303
|
if meta.get("audio"):
|
|
@@ -318,6 +320,18 @@ def main() -> int:
|
|
|
318
320
|
fh.write((concat_list_line(repeat) + "\n") * (stream_loop + 1))
|
|
319
321
|
vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
|
|
320
322
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
|
|
323
|
+
v_end = stream_starts(meta)[0] + (v.get("duration") or 0.0)
|
|
324
|
+
if has_audio and not forced_rate and not fade_frames and src_dur - v_end > 0.5 / fps:
|
|
325
|
+
# A repeat is as long as the file, so where the sound outlasts the picture by more
|
|
326
|
+
# than half a frame the next repeat's first frame comes later than one frame after
|
|
327
|
+
# the last. What stood in that gap was left to the output's frame-rate handling:
|
|
328
|
+
# FFmpeg 6.1 repeated the last frame and showed the next repeat's first frame twice,
|
|
329
|
+
# from a frame before its time (a 1 s 25 fps clip with 1.23425 s of sound: frame 0 of
|
|
330
|
+
# the second repeat at 1.20 s and 1.24 s, its sound from 1.23425 s); FFmpeg 7.1 and 8
|
|
331
|
+
# held the last frame (50 packets for 56). The graph ends on the clip's own rate, so
|
|
332
|
+
# the gap is the last frame repeated and each later repeat's frames sit on the grid
|
|
333
|
+
# once. A clip without that gap has nothing to fill and keeps the command it had.
|
|
334
|
+
vtrim += f",fps={rate}"
|
|
321
335
|
if has_audio:
|
|
322
336
|
cmd += ["-filter_complex", f"[0:v:0]{vtrim}[v];[0:a:0]{atrim}[a]",
|
|
323
337
|
"-map", "[v]", "-map", "[a]"]
|
|
@@ -329,7 +343,7 @@ def main() -> int:
|
|
|
329
343
|
cmd += video_args(meta, args.crf, args.preset)
|
|
330
344
|
# a crossfade's repeat is already constant at `rate` (its graph's fps=): the loop keeps that rate,
|
|
331
345
|
# not a variable-rate source's average, which re-timed every repeat by a frame or two
|
|
332
|
-
cmd += ["-fps_mode", "passthrough"] if args.boomerang else
|
|
346
|
+
cmd += ["-fps_mode", "passthrough"] if args.boomerang else forced_rate
|
|
333
347
|
if has_audio:
|
|
334
348
|
cmd += aac_args()
|
|
335
349
|
else:
|
package/scripts/overlay.py
CHANGED
|
@@ -38,7 +38,7 @@ from _common import still_input_args # noqa: E402
|
|
|
38
38
|
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
39
39
|
from _common.emit import note, warn
|
|
40
40
|
from _common.color import is_rgb
|
|
41
|
-
from _common.decision import length_output_t
|
|
41
|
+
from _common.decision import length_output_t, window_expr, windowed_overlay_x
|
|
42
42
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
43
43
|
has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
|
|
44
44
|
from _common.probe import display_size, refuse_not_a_still, square_pixels, square_pixels_note
|
|
@@ -128,13 +128,12 @@ def _wrap_to_frame(args, frame_w: int, margins: Optional[dict], frame_h: int = 0
|
|
|
128
128
|
return wrapped
|
|
129
129
|
|
|
130
130
|
|
|
131
|
-
def enable_expr(start: Optional[float], end: Optional[float]) -> str:
|
|
131
|
+
def enable_expr(start: Optional[float], end: Optional[float], meta: Optional[dict] = None) -> str:
|
|
132
|
+
"""True on the main picture's frames from --start to --end, both inclusive (window_expr):
|
|
133
|
+
the bounds sit half a frame from the frames they select, not on a frame's own time."""
|
|
132
134
|
if start is None and end is None:
|
|
133
135
|
return ""
|
|
134
|
-
|
|
135
|
-
if end is None:
|
|
136
|
-
return f"gte(t,{s})"
|
|
137
|
-
return f"between(t,{s},{end:.3f})"
|
|
136
|
+
return window_expr(start, end, meta)
|
|
138
137
|
|
|
139
138
|
|
|
140
139
|
def alpha_expr(opacity: float, start: Optional[float], end: Optional[float], fade: float) -> str:
|
|
@@ -440,7 +439,7 @@ def main() -> int:
|
|
|
440
439
|
"from --start to --end without a fade", hint="drop --fade for the video layer")
|
|
441
440
|
|
|
442
441
|
output = args.output or default_output(args.input, "overlay", default_ext(alpha_codec) if alpha_codec else None)
|
|
443
|
-
enable = enable_expr(start, end)
|
|
442
|
+
enable = enable_expr(start, end, meta)
|
|
444
443
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
445
444
|
|
|
446
445
|
if args.image:
|
|
@@ -464,9 +463,9 @@ def main() -> int:
|
|
|
464
463
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
465
464
|
# a 10-bit main on a 10-bit line is composited at 10 bits in YUV: format=auto with an RGBA
|
|
466
465
|
# still took the whole picture through 8-bit RGB (overlay_format)
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
466
|
+
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
467
|
+
# picture's frames; overlay's enable is evaluated on the layer's frames too
|
|
468
|
+
ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
470
469
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
471
470
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
|
|
472
471
|
# the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
|
|
@@ -545,9 +544,9 @@ def main() -> int:
|
|
|
545
544
|
# which came back BT.601 under the BT.709 tag (#333's defect, on this path)
|
|
546
545
|
chain.append(f"lut=y=val:u=val:v=val:a=val*{args.opacity:g}")
|
|
547
546
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
547
|
+
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
548
|
+
# picture's frames; overlay's enable is evaluated on the layer's frames too
|
|
549
|
+
ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
551
550
|
# a VP9 layer stored with an alpha layer is decoded with libvpx-vp9: FFmpeg's own decoder
|
|
552
551
|
# drops the alpha, and the layer covered the picture as an opaque rectangle
|
|
553
552
|
cmd = ffmpeg_base() + ["-i", args.input] + decoder_args_for(pip_meta, args.video) + ["-i", args.video]
|
package/scripts/pad.py
CHANGED
|
@@ -16,8 +16,8 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
-
from _common.emit import record_fallback
|
|
20
|
-
from _common.decision import silence_filter
|
|
19
|
+
from _common.emit import note, record_fallback
|
|
20
|
+
from _common.decision import silence_filter, tpad_length
|
|
21
21
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
22
22
|
|
|
23
23
|
|
|
@@ -65,8 +65,16 @@ def main() -> int:
|
|
|
65
65
|
# front (one -filter_complex), so the record is made here, as broll and stabilize do
|
|
66
66
|
record_fallback(f"{args.input} is {v.get('width')}x{v.get('height')}: scaled to even dimensions first",
|
|
67
67
|
"odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
|
|
68
|
-
#
|
|
69
|
-
|
|
68
|
+
# The sound is delayed by what the picture is: tpad puts whole frames ahead of the clip
|
|
69
|
+
# (--start 0.23425 at 25 fps: 6 frames, 0.240 s), and a delay of --start itself moved the
|
|
70
|
+
# sound 5.75 ms ahead of its picture (tpad_length). The silence after the clip moves
|
|
71
|
+
# nothing and is --end as asked. Either is counted in samples when it is not a whole
|
|
72
|
+
# millisecond (silence_filter).
|
|
73
|
+
lead = tpad_length(args.start, meta)
|
|
74
|
+
if abs(lead - args.start) > 1e-9:
|
|
75
|
+
note(f"--start {args.start:g}: the picture can only be padded by whole frames, {lead:.6f} s here; "
|
|
76
|
+
f"the sound is delayed by the same {lead:.6f} s so the clip keeps its sync")
|
|
77
|
+
af = silence_filter(meta["audio"].get("sample_rate"), lead, args.end)
|
|
70
78
|
cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
|
|
71
79
|
else:
|
|
72
80
|
cmd += ["-vf", vf, "-map", "0:v:0"]
|
package/scripts/sync.py
CHANGED
|
@@ -441,8 +441,12 @@ def main() -> int:
|
|
|
441
441
|
# same command, and the seek stays next to the -i it drops the margin from
|
|
442
442
|
cmd += ["-i", args.second] + out_seek + default_selection_maps(second_meta, output)
|
|
443
443
|
af_parts = on_clock + drift_af
|
|
444
|
-
|
|
445
|
-
|
|
444
|
+
# one sound is written here (the default selection, or the probed track of a file
|
|
445
|
+
# with several: default_selection_maps), the one sec_sr is the rate of, so the delay
|
|
446
|
+
# is counted in its samples as --replace-audio's is. A whole millisecond (the
|
|
447
|
+
# default --fine-ms 1 without --fix-drift) keeps `adelay=<ms>:all=1`
|
|
448
|
+
if offset > 0 and round(offset * sec_sr):
|
|
449
|
+
af_parts.append(silence_filter(sec_sr, lead=offset))
|
|
446
450
|
if has_video:
|
|
447
451
|
vf = []
|
|
448
452
|
if delay_ms > 0:
|