ffmpeg-skill 3.8.2 → 3.8.4
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/__init__.py +1 -1
- package/scripts/_common/decision.py +56 -0
- package/scripts/broll.py +8 -2
- package/scripts/fit.py +11 -3
- package/scripts/freeze.py +3 -3
- package/scripts/loop.py +3 -2
- package/scripts/overlay.py +7 -2
- package/scripts/pad.py +3 -1
- package/scripts/sync.py +11 -3
- package/scripts/waveform.py +4 -1
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.4`) | 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.4", "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.4",
|
|
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": [
|
|
@@ -74,7 +74,7 @@ from _common.probe import (
|
|
|
74
74
|
display_size, pixel_aspect, square_pixels, square_pixels_note
|
|
75
75
|
)
|
|
76
76
|
from _common.decision import (
|
|
77
|
-
aac_args, add_pad_fill_args, conform_filter, conform_to_length, conform_output_t, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
|
|
77
|
+
aac_args, add_pad_fill_args, conform_filter, conform_to_length, conform_output_t, length_output_t, silence_filter, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
|
|
78
78
|
description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
|
|
79
79
|
frame_flow, label_shot_flow, MOTION_GRID, MOTION_SEARCH, MOTION_STATIC_PX, MOTION_PAN_SPREAD,
|
|
80
80
|
filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
|
|
@@ -456,6 +456,62 @@ def conform_output_t(length: float, rate: int) -> str:
|
|
|
456
456
|
return f"{length + 0.5 / rate:.6f}"
|
|
457
457
|
|
|
458
458
|
|
|
459
|
+
def length_output_t(length: float, rate: Optional[int], runs_on: bool = False) -> str:
|
|
460
|
+
"""The output `-t` of a tool that leaves the sound's end to it (overlay.py, waveform.py,
|
|
461
|
+
loop.py, sync.py --replace-audio): the millisecond text they always wrote unless it cuts
|
|
462
|
+
samples off the track at `rate` (`-t 1.234` wrote 59244 samples at 48 kHz as 59232), and then
|
|
463
|
+
the length to the microsecond, which FFmpeg rounds to the nearest sample. `runs_on` is for
|
|
464
|
+
sound that goes on past the length (an endless apad, a loop's next repeat): there the
|
|
465
|
+
millisecond text is also left when it rounds up (`-t 1.235` wrote 59261 as 59280). No half
|
|
466
|
+
sample of slack as in conform_output_t(): this `-t` ends the picture too, and with the slack a
|
|
467
|
+
layer's frame that starts exactly at the end came through on a 1/60000 or 1/90000 time base
|
|
468
|
+
(FFmpeg 6.1.1). A rate of 0 or None (no sound, a dry run's pending input) keeps the
|
|
469
|
+
millisecond text."""
|
|
470
|
+
text = f"{length:.3f}"
|
|
471
|
+
if rate:
|
|
472
|
+
def names(micro: int) -> int:
|
|
473
|
+
# the samples FFmpeg makes of a -t of `micro` microseconds: a rescale that rounds a
|
|
474
|
+
# half up, where Python's round() takes it to the even count (0.005 s at 44.1 kHz is
|
|
475
|
+
# 220.5 samples: 221 written, round() says 220)
|
|
476
|
+
return (micro * rate + 500_000) // 1_000_000
|
|
477
|
+
written, asked = names(round(float(text) * 1_000_000)), round(length * rate)
|
|
478
|
+
if written < asked or (runs_on and written != asked):
|
|
479
|
+
# the microsecond text is a rounding too (2.00001042 s is 96001 samples at 48 kHz and
|
|
480
|
+
# "2.000010" names 96000): the microsecond nearest the length, moved by whole
|
|
481
|
+
# microseconds until it names the count asked. A microsecond is a twentieth of a
|
|
482
|
+
# sample at 48 kHz, so this is a step or two, not conform_output_t()'s half sample
|
|
483
|
+
micro = round(length * 1_000_000)
|
|
484
|
+
while names(micro) < asked:
|
|
485
|
+
micro += 1
|
|
486
|
+
while names(micro) > asked:
|
|
487
|
+
micro -= 1
|
|
488
|
+
return f"{micro // 1_000_000}.{micro % 1_000_000:06d}"
|
|
489
|
+
return text
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def silence_filter(rate: Optional[int], lead: Optional[float] = None, tail: Optional[float] = None) -> str:
|
|
493
|
+
"""Silence of `lead` seconds ahead of a track and/or `tail` seconds after it (pad.py,
|
|
494
|
+
freeze.py). An amount in whole milliseconds is written as it always was (`adelay=MS:all=1`,
|
|
495
|
+
`apad=pad_dur=S.mmm`), the millisecond rounded, not cut: int(1.001 * 1000) is 1000. One that
|
|
496
|
+
is not is counted in samples at `rate`, pinned by an aformat as in conform_filter():
|
|
497
|
+
`adelay=234` and `apad=pad_dur=0.234` for 0.23425 s left the sound 12 samples short at 48 kHz.
|
|
498
|
+
A rate of 0 or None (a dry run's pending input) keeps the millisecond form."""
|
|
499
|
+
amounts = [v for v in (lead, tail) if v is not None]
|
|
500
|
+
if rate and any(round(v * rate) != round(round(v, 3) * rate) for v in amounts):
|
|
501
|
+
parts = [f"aformat=sample_rates={rate}"]
|
|
502
|
+
if lead:
|
|
503
|
+
parts.append(f"adelay={round(lead * rate)}S:all=1")
|
|
504
|
+
if tail:
|
|
505
|
+
parts.append(f"apad=pad_len={round(tail * rate)}")
|
|
506
|
+
return ",".join(parts)
|
|
507
|
+
parts = []
|
|
508
|
+
if lead is not None:
|
|
509
|
+
parts.append(f"adelay={round(lead * 1000)}:all=1")
|
|
510
|
+
if tail is not None:
|
|
511
|
+
parts.append(f"apad=pad_dur={tail:.3f}")
|
|
512
|
+
return ",".join(parts)
|
|
513
|
+
|
|
514
|
+
|
|
459
515
|
def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
|
|
460
516
|
"""The one length a clip holds once both streams start at its first frame (av_trim from the
|
|
461
517
|
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
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common.decision import av_trim, conform_output_t, conform_to_length, frame_rate, grid_at_or_after, stream_starts
|
|
24
|
+
from _common.decision import av_trim, conform_output_t, conform_to_length, frame_rate, grid_at_or_after, silence_filter, stream_starts
|
|
25
25
|
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
26
|
from _common.emit import note, record_fallback
|
|
27
27
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
@@ -162,8 +162,14 @@ def main() -> int:
|
|
|
162
162
|
# sample moved a track that starts 0.3 s late 0.279 s early (#409)
|
|
163
163
|
v0b = stream_starts(c["meta"])[0]
|
|
164
164
|
first = max(v0b, grid_at_or_after(c["from"], v0b, frame_rate(c["meta"])))
|
|
165
|
+
# the delay is --at to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
|
|
166
|
+
# 1000 ms and wrote 1.2346 s as 1234 ms, the cutaway's sound 48 and 29 samples
|
|
167
|
+
# early. A whole millisecond keeps the text it had, rounded (silence_filter)
|
|
168
|
+
delay = silence_filter(MIX_RATE, lead=c["at"])
|
|
169
|
+
if "S:" not in delay:
|
|
170
|
+
delay = f"adelay={round(c['at'] * 1000)}|{round(c['at'] * 1000)}"
|
|
165
171
|
parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
|
|
166
|
-
f"aformat=sample_rates=48000:channel_layouts=stereo,
|
|
172
|
+
f"aformat=sample_rates=48000:channel_layouts=stereo,{delay}[ab{i}]")
|
|
167
173
|
layers.append(f"[ab{i}]")
|
|
168
174
|
# the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
|
|
169
175
|
# is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
|
package/scripts/fit.py
CHANGED
|
@@ -39,7 +39,7 @@ import sys
|
|
|
39
39
|
from fractions import Fraction
|
|
40
40
|
from typing import List
|
|
41
41
|
|
|
42
|
-
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
42
|
+
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, length_output_t, stream_starts
|
|
43
43
|
from _common.timeline import exact_rate, output_timecode_args
|
|
44
44
|
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
|
|
45
45
|
from _common.emit import input_length
|
|
@@ -162,6 +162,12 @@ def main() -> int:
|
|
|
162
162
|
# (#491). Not under --dry-run, where render.py plans a clip's speed stage on the source in
|
|
163
163
|
# place of a cut that does not exist yet and records the stand-in itself (A97)
|
|
164
164
|
src_dur = (meta.get("duration") or 0.0) if STATE.dry_run else input_length(meta)
|
|
165
|
+
# The output's -t is all that ends the kept track where the source's sound runs on past
|
|
166
|
+
# the target (a trim; a speed change too small to retime), so it is written to the sample
|
|
167
|
+
# of that track's rate, not to the millisecond: `-t 1.234` wrote 59244 samples (48 kHz)
|
|
168
|
+
# as 59232 and `-t 1.235` wrote 59261 as 59280 (length_output_t). No sound, or a dry
|
|
169
|
+
# run's pending input, has no rate and keeps the millisecond.
|
|
170
|
+
rate = (audio_streams[args.audio_stream].get("sample_rate") if has_audio and audio_streams else None)
|
|
165
171
|
if args.method == "speed":
|
|
166
172
|
if src_dur <= 0 and STATE.dry_run:
|
|
167
173
|
src_dur = target # planning against an intermediate that does not exist yet
|
|
@@ -178,7 +184,9 @@ def main() -> int:
|
|
|
178
184
|
vf.append("tblend=all_mode=average")
|
|
179
185
|
if has_audio:
|
|
180
186
|
af.append(atempo_chain(factor))
|
|
181
|
-
|
|
187
|
+
# atempo's sound ends before the target (13 to 62 ms before it on FFmpeg 6.1.1), so the
|
|
188
|
+
# millisecond cuts nothing off a retimed track and stays
|
|
189
|
+
post += ["-t", f"{target:.3f}" if af else length_output_t(target, rate, runs_on=True)]
|
|
182
190
|
STATE.duration_hint = target
|
|
183
191
|
else:
|
|
184
192
|
if target < src_dur:
|
|
@@ -189,7 +197,7 @@ def main() -> int:
|
|
|
189
197
|
# picture would not be the one the start timecode below names
|
|
190
198
|
m = min(SEEK_MARGIN, round(start, 3))
|
|
191
199
|
pre_input += ["-ss", f"{start - m:.3f}"]
|
|
192
|
-
post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t",
|
|
200
|
+
post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", length_output_t(target, rate, runs_on=True)]
|
|
193
201
|
# the output starts on the first frame at or after the seek (a seek before a
|
|
194
202
|
# picture that starts after the file's origin: on its first frame): its start
|
|
195
203
|
# 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, stream_starts
|
|
22
|
+
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts
|
|
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
|
|
|
@@ -73,7 +73,7 @@ def main() -> int:
|
|
|
73
73
|
vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
|
|
74
74
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
75
75
|
if has_audio:
|
|
76
|
-
cmd += ["-map", "0:a:0?", "-af",
|
|
76
|
+
cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), tail=args.hold)]
|
|
77
77
|
elif at == 0 or cut_at <= v0 + 1e-6:
|
|
78
78
|
# A freeze at the very start has no preceding "head" segment to hold on to (the
|
|
79
79
|
# split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
|
|
@@ -82,7 +82,7 @@ def main() -> int:
|
|
|
82
82
|
vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
|
|
83
83
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
84
84
|
if has_audio:
|
|
85
|
-
cmd += ["-map", "0:a:0?", "-af",
|
|
85
|
+
cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), lead=args.hold)]
|
|
86
86
|
else:
|
|
87
87
|
# freeze N frames at `at` by holding on that one source frame for --hold seconds, then
|
|
88
88
|
# resuming the rest of the clip: split the timeline at `at` and hold the remainder's
|
package/scripts/loop.py
CHANGED
|
@@ -34,7 +34,7 @@ import tempfile
|
|
|
34
34
|
|
|
35
35
|
from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
36
36
|
from _common.emit import warn
|
|
37
|
-
from _common.decision import av_trim
|
|
37
|
+
from _common.decision import av_trim, length_output_t
|
|
38
38
|
from _common.probe import decode_gray_frames, decode_pcm_mono
|
|
39
39
|
from _common.emit import input_length
|
|
40
40
|
|
|
@@ -320,7 +320,8 @@ def main() -> int:
|
|
|
320
320
|
else:
|
|
321
321
|
cmd += ["-map", "0:v:0", "-vf", vtrim]
|
|
322
322
|
if target is not None:
|
|
323
|
-
|
|
323
|
+
# the sound ends at --duration to the sample, not to the millisecond (length_output_t)
|
|
324
|
+
cmd += ["-t", length_output_t(target, (meta.get("audio") or {}).get("sample_rate") if has_audio else None, runs_on=True)]
|
|
324
325
|
cmd += video_args(meta, args.crf, args.preset)
|
|
325
326
|
# a crossfade's repeat is already constant at `rate` (its graph's fps=): the loop keeps that rate,
|
|
326
327
|
# not a variable-rate source's average, which re-timed every repeat by a frame or two
|
package/scripts/overlay.py
CHANGED
|
@@ -38,6 +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
42
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
42
43
|
has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
|
|
43
44
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
@@ -362,6 +363,10 @@ def main() -> int:
|
|
|
362
363
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
363
364
|
if args.audio_stream and not audio_streams:
|
|
364
365
|
die("--audio-stream needs an input with audio streams")
|
|
366
|
+
# the -t that ends a layer at the main input's length: to the sample of the kept track when the
|
|
367
|
+
# millisecond would cut it (length_output_t); no sound, or a pending input, keeps the millisecond
|
|
368
|
+
kept_track = audio_streams[args.audio_stream] if audio_streams else (meta.get("audio") or {})
|
|
369
|
+
main_t = length_output_t(meta["duration"], kept_track.get("sample_rate")) if meta.get("duration") else None
|
|
365
370
|
# ffmpeg applies display rotation before the filter graph, so the frame this overlay is
|
|
366
371
|
# composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
|
|
367
372
|
# A non-square-pixel source is made square at its display aspect first (#411), so a logo or
|
|
@@ -454,7 +459,7 @@ def main() -> int:
|
|
|
454
459
|
# streams -- a preserved subtitle/data stream that ends earlier (run_keeping_subtitles)
|
|
455
460
|
# must not be allowed to cut the whole output short via -shortest's "stop at whichever
|
|
456
461
|
# mapped stream finishes first" semantics.
|
|
457
|
-
cmd += ["-t",
|
|
462
|
+
cmd += ["-t", main_t]
|
|
458
463
|
else:
|
|
459
464
|
# No known duration to bound by -t (e.g. probe found no video duration): -shortest is
|
|
460
465
|
# the only thing stopping the looped still from running forever. FFmpeg 7+'s
|
|
@@ -567,7 +572,7 @@ def main() -> int:
|
|
|
567
572
|
if meta.get("duration"):
|
|
568
573
|
# See the --image branch above: -t (exact, bounds only the main input) instead of
|
|
569
574
|
# -shortest (would also stop at a preserved subtitle/data stream that ends earlier).
|
|
570
|
-
cmd += ["-t",
|
|
575
|
+
cmd += ["-t", main_t]
|
|
571
576
|
else:
|
|
572
577
|
cmd += ["-shortest"]
|
|
573
578
|
else:
|
package/scripts/pad.py
CHANGED
|
@@ -17,6 +17,7 @@ import argparse
|
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
19
|
from _common.emit import record_fallback
|
|
20
|
+
from _common.decision import silence_filter
|
|
20
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
|
|
21
22
|
|
|
22
23
|
|
|
@@ -64,7 +65,8 @@ def main() -> int:
|
|
|
64
65
|
# front (one -filter_complex), so the record is made here, as broll and stabilize do
|
|
65
66
|
record_fallback(f"{args.input} is {v.get('width')}x{v.get('height')}: scaled to even dimensions first",
|
|
66
67
|
"odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
|
|
67
|
-
|
|
68
|
+
# the silence is counted in samples when --start/--end is not a whole millisecond
|
|
69
|
+
af = silence_filter(meta["audio"].get("sample_rate"), args.start, args.end)
|
|
68
70
|
cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
|
|
69
71
|
else:
|
|
70
72
|
cmd += ["-vf", vf, "-map", "0:v:0"]
|
package/scripts/sync.py
CHANGED
|
@@ -35,7 +35,7 @@ from typing import Dict, List
|
|
|
35
35
|
from _common.runner import fail_if_classified
|
|
36
36
|
from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
|
|
37
37
|
from _common.emit import record_skip, warn
|
|
38
|
-
from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
|
|
38
|
+
from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
|
|
39
39
|
from _common.timeline import output_timecode_args
|
|
40
40
|
from _common.runner import default_selection_maps
|
|
41
41
|
from _common.emit import input_length
|
|
@@ -377,9 +377,17 @@ def main() -> int:
|
|
|
377
377
|
# its length instead of -shortest, which cut the reference's tail whenever the second
|
|
378
378
|
# file started earlier (head_trim > 0) or was simply shorter
|
|
379
379
|
ref_dur = probe(args.reference).get("duration")
|
|
380
|
-
|
|
380
|
+
# the delay is the measured offset to the sample of the second's rate: at the default
|
|
381
|
+
# --fine-ms 1 without --fix-drift the offset is a whole millisecond and the text is the
|
|
382
|
+
# one it was; a finer step (0.25 ms: 0.23425 s) or a drift-adjusted offset (kept to
|
|
383
|
+
# 0.1 ms) was rounded to `adelay=234` (silence_filter)
|
|
384
|
+
lead = [silence_filter(sec_sr, lead=offset)] if offset > 0 and round(offset * sec_sr) else []
|
|
385
|
+
af_parts = on_clock + drift_af + lead + ["apad"]
|
|
381
386
|
cmd += ["-af", ",".join(af_parts)]
|
|
382
|
-
|
|
387
|
+
# to the sample of the rate the second's sound is written at, not to the millisecond:
|
|
388
|
+
# the endless apad is ended by this -t alone (length_output_t)
|
|
389
|
+
ref_t = length_output_t(ref_dur, (second_meta.get("audio") or {}).get("sample_rate"), runs_on=True) if ref_dur else None
|
|
390
|
+
cmd += aac_args() + (["-t", ref_t] if ref_dur else ["-shortest"]) + [output]
|
|
383
391
|
proc = run(cmd, check=False)
|
|
384
392
|
fail_if_classified(proc) # a cause the re-encode would hit too is reported, not retried (#415)
|
|
385
393
|
if proc.returncode != 0:
|
package/scripts/waveform.py
CHANGED
|
@@ -41,6 +41,7 @@ from _common import (add_common, apply_common, aac_args, default_output, die, dr
|
|
|
41
41
|
info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
|
|
42
42
|
fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
|
|
43
43
|
from _common.emit import absorb_stage_hw, note, warn
|
|
44
|
+
from _common.decision import length_output_t
|
|
44
45
|
|
|
45
46
|
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
46
47
|
|
|
@@ -232,7 +233,9 @@ def main() -> int:
|
|
|
232
233
|
# audio ends (a 12 s source came out 14.08 s on 5.1.1, #146), so the output is also capped
|
|
233
234
|
# at the source's own duration when probe knows it.
|
|
234
235
|
if meta.get("duration"):
|
|
235
|
-
|
|
236
|
+
# to the sample of the drawn track when the millisecond would cut it (length_output_t)
|
|
237
|
+
drawn_track = audio_streams[args.audio_stream] if audio_streams else meta["audio"]
|
|
238
|
+
cmd += ["-t", length_output_t(float(meta["duration"]), drawn_track.get("sample_rate"))]
|
|
236
239
|
cmd += ["-shortest", render]
|
|
237
240
|
titled_tmp = sibling_temp(output, "titled")
|
|
238
241
|
intermediates = [p for p in (render, titled_tmp) if p != output]
|