ffmpeg-skill 3.8.14 → 3.8.16
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/references/gotchas.md +12 -2
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/decision.py +57 -0
- package/scripts/broll.py +5 -2
- package/scripts/caption.py +4 -3
- package/scripts/color.py +3 -2
- package/scripts/crop.py +2 -2
- package/scripts/cut.py +9 -3
- package/scripts/deinterlace.py +12 -7
- package/scripts/denoise.py +2 -2
- package/scripts/export.py +25 -5
- package/scripts/fit.py +45 -7
- package/scripts/freeze.py +9 -7
- package/scripts/graphics.py +7 -4
- package/scripts/loop.py +7 -2
- package/scripts/overlay.py +92 -4
- package/scripts/pad.py +7 -2
- package/scripts/proxy.py +2 -2
- package/scripts/redact.py +5 -2
- package/scripts/render.py +13 -1
- package/scripts/speedramp.py +49 -14
- package/scripts/sphere.py +2 -2
- package/scripts/stabilize.py +2 -2
- package/scripts/straighten.py +2 -2
package/docs/contract.md
CHANGED
|
@@ -26,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
26
26
|
| Field | Meaning | Changes when |
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
29
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
29
|
+
| `skill.version` | the npm / package.json version (`3.8.16`) | any release |
|
|
30
30
|
|
|
31
31
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
32
32
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -154,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
154
154
|
"deprecated": [],
|
|
155
155
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
156
156
|
{"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"}],
|
|
157
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
157
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.16", "execution_mode": "local", "kind": "execution",
|
|
158
158
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
159
159
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
160
160
|
"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.16",
|
|
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/references/gotchas.md
CHANGED
|
@@ -117,8 +117,18 @@ wrong frame — respect the automatic re-encode rather than forcing the copy.
|
|
|
117
117
|
|
|
118
118
|
### Variable frame rate
|
|
119
119
|
`probe.py` sets `variable_frame_rate_suspected` when `r_frame_rate` and
|
|
120
|
-
`avg_frame_rate` disagree (phone and screen recordings).
|
|
121
|
-
|
|
120
|
+
`avg_frame_rate` disagree (phone and screen recordings). On that flag `crop`, `denoise`,
|
|
121
|
+
`deinterlace`, `proxy`, `sphere`, `stabilize`, `straighten`, `redact`, `overlay`, `caption`,
|
|
122
|
+
`color`, `graphics`, `pad`, `freeze`, `loop`, `broll`, `cut` (a re-encode, `--segments`),
|
|
123
|
+
`export`, `fit` and `speedramp` conform the picture with the `fps` filter (the nearest frame
|
|
124
|
+
for each slot; `-fps_mode cfr` alone let a frame run up to two frames late against the sound,
|
|
125
|
+
#512); `join`, `multicam` and `grid` put each input on the output's rate with their own `fps`
|
|
126
|
+
filter. `reverse.py` is not changed: its `fps` filter comes after `reverse`, which hands the frames
|
|
127
|
+
the forward timestamps in order, so a reversed VFR picture keeps its gaps where they were in
|
|
128
|
+
forward time (each flash 0.19 s late on
|
|
129
|
+
a 27 fps average clip; see docs/design-decisions.md). The rate is `cfr_args()`'s: the probed
|
|
130
|
+
average, written as the nearest whole number when it is within 0.02 of one (29.98 is
|
|
131
|
+
conformed to 30, 29.97 stays 29.97), and 30 when no average was probed. That flag is a
|
|
122
132
|
whole-file average, and a phone clip at 29.98 against a nominal 30 trips it, so
|
|
123
133
|
`cut.py` decides on packet timestamps sampled in up to five windows (`vfr_check`)
|
|
124
134
|
instead: by default (`--vfr-guard sampled`, since 3.0.0) it switches to
|
|
@@ -83,7 +83,7 @@ from _common.decision import (
|
|
|
83
83
|
BEAT_REFRACTORY_S, BEAT_WINDOW_S, BEAT_SUPPORT_DIVISOR, BEAT_ALIGN_DIVISOR,
|
|
84
84
|
BEAT_Z_FLOOR, BEAT_Z_SPAN,
|
|
85
85
|
_onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
|
|
86
|
-
brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
|
|
86
|
+
brand_states_font, cfr_args, cfr_at_input, cfr_filter, graph_with_cfr, with_cfr, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
|
|
87
87
|
fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
|
|
88
88
|
signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
89
89
|
)
|
|
@@ -182,7 +182,7 @@ __all__ = [
|
|
|
182
182
|
"platform", "re", "shutil", "subprocess", "sys", "unicodedata",
|
|
183
183
|
"aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "LEGAL_RANGE_CHOICES", "LEGAL_RANGES", "legal_limits", "measure_signal_range", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
|
|
184
184
|
"audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
|
|
185
|
-
"brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_matrix_args", "bt709_matrix_filter", "bt709_tag_args", "cfr_args", "_char_em",
|
|
185
|
+
"brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_matrix_args", "bt709_matrix_filter", "bt709_tag_args", "cfr_args", "cfr_at_input", "cfr_filter", "graph_with_cfr", "with_cfr", "_char_em",
|
|
186
186
|
"char_script", "_check_existing_output", "refuse_existing_outputs", "guard_sidecar", "_check_no_overwrite_input", "_check_output_path", "child_args", "option_args",
|
|
187
187
|
"child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
|
|
188
188
|
"concat_list_line", "Context", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
|
|
@@ -655,6 +655,63 @@ def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Lis
|
|
|
655
655
|
return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
|
|
656
656
|
|
|
657
657
|
|
|
658
|
+
def cfr_filter(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> str:
|
|
659
|
+
"""The `fps` filter that puts the picture on the grid cfr_args() delivers, as the last filter of
|
|
660
|
+
the video chain; "" when cfr_args() forces no rate. The encoder line's `-fps_mode cfr -r` alone
|
|
661
|
+
let a frame fall up to about two frames behind its slot before it dropped one, so a VFR clip
|
|
662
|
+
conformed at its average rate showed its picture up to 56 ms after its sound (#512); the
|
|
663
|
+
filter takes the nearest frame for every slot. The encoder line keeps cfr_args(), which then
|
|
664
|
+
moves no frame."""
|
|
665
|
+
args = cfr_args(meta, fps)
|
|
666
|
+
return f"fps={args[-1]}" if args else ""
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def with_cfr(chain: str, meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> str:
|
|
670
|
+
"""`chain` (a -vf value or one filtergraph chain) with cfr_filter() appended when there is one.
|
|
671
|
+
A -vf value that is a graph of its own (drawn_chain()'s split/maskedmerge) gets it first
|
|
672
|
+
instead: maskedmerge, like overlay, ends its output at its last frame's start, and an fps
|
|
673
|
+
filter after it lost the last frame (graphics.py --template chapter: 3.963 s of 4)."""
|
|
674
|
+
return chain_with_rate(chain, cfr_filter(meta, fps))
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def chain_with_rate(chain: str, rate_filter: str) -> str:
|
|
678
|
+
"""`chain` with an `fps` filter `rate_filter` placed as with_cfr() places cfr_filter(): last in
|
|
679
|
+
a plain chain, first in a -vf that is a graph of its own (fit/export's blur pad, a drawn pad
|
|
680
|
+
with chroma), whose overlay or maskedmerge ends its output at its last frame's start on FFmpeg
|
|
681
|
+
6.1 and lost the last frame to an fps filter after it. For a rate that is not cfr_args()'s
|
|
682
|
+
own (export.py's preset and platform rates)."""
|
|
683
|
+
if not rate_filter:
|
|
684
|
+
return chain
|
|
685
|
+
if ";" in chain:
|
|
686
|
+
return f"{rate_filter},{chain}"
|
|
687
|
+
return f"{chain},{rate_filter}" if chain else rate_filter
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def graph_with_cfr(graph: str, label: str, meta: Optional[Dict[str, Any]],
|
|
691
|
+
fps: Optional[float] = None) -> Tuple[str, str]:
|
|
692
|
+
"""A -filter_complex graph and the video output label to map, with cfr_filter() after that
|
|
693
|
+
label when there is one: (graph + ";[v]fps=R[vcfr]", "[vcfr]"), else both unchanged."""
|
|
694
|
+
f = cfr_filter(meta, fps)
|
|
695
|
+
if not f:
|
|
696
|
+
return graph, label
|
|
697
|
+
bare = label.strip("[]")
|
|
698
|
+
return f"{graph};[{bare}]{f}[{bare}cfr]", f"[{bare}cfr]"
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def cfr_at_input(graph: str, meta: Optional[Dict[str, Any]], fps: Optional[float] = None, src: str = "[0:v]") -> str:
|
|
702
|
+
"""A -filter_complex graph with cfr_filter() on its main picture as it is decoded (`src`, which
|
|
703
|
+
the graph reads once), for a graph that composites onto that picture and moves no frame in
|
|
704
|
+
time. After an overlay the conform comes too late: FFmpeg 6.1's overlay ends its output at its
|
|
705
|
+
last frame's start, so an fps filter there lost the last frame (3.963 s of a 4 s picture)."""
|
|
706
|
+
f = cfr_filter(meta, fps)
|
|
707
|
+
if not f:
|
|
708
|
+
return graph
|
|
709
|
+
if graph.count(src) != 1:
|
|
710
|
+
# a graph that read the picture twice, or not at all, would go unconformed or not parse
|
|
711
|
+
raise ValueError(f"cfr_at_input: the graph reads {src} {graph.count(src)} times, not once")
|
|
712
|
+
return f"{src}{f}[cfrin];" + graph.replace(src, "[cfrin]")
|
|
713
|
+
|
|
714
|
+
|
|
658
715
|
def delivered_rate(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Optional[float]:
|
|
659
716
|
"""The constant rate cfr_args() forces on the output (its -r), or None when it forces none and
|
|
660
717
|
the output keeps the source's rate -- what a start timecode is counted at
|
package/scripts/broll.py
CHANGED
|
@@ -24,7 +24,7 @@ from typing import Any, Dict, List
|
|
|
24
24
|
|
|
25
25
|
from _common.decision import (av_trim, conform_output_t, conform_to_length, frame_durations, frame_rate, frame_window,
|
|
26
26
|
grid_at_or_after, silence_filter, stream_starts, window_expr, windowed_overlay_x)
|
|
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
|
|
27
|
+
from _common import STATE, add_common, aac_args, apply_common, cfr_args, cfr_at_input, 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
|
|
28
28
|
from _common.emit import note, record_fallback
|
|
29
29
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
30
30
|
from _common.emit import input_length
|
|
@@ -242,7 +242,10 @@ def main() -> int:
|
|
|
242
242
|
parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,{tail}[aout]")
|
|
243
243
|
aout = "[aout]"
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
# a VFR A-roll is conformed by the fps filter as it is decoded, at the rate cfr_args() delivers:
|
|
246
|
+
# -fps_mode cfr alone let a frame run up to two frames late against the sound (#512)
|
|
247
|
+
graph = cfr_at_input(";".join(parts), meta_a)
|
|
248
|
+
cmd += ["-filter_complex", graph, "-map", vout]
|
|
246
249
|
if aout:
|
|
247
250
|
cmd += ["-map", aout]
|
|
248
251
|
cmd += video_args(meta_a, args.crf, args.preset, convert_matrix=False) + cfr_args(meta_a, None)
|
package/scripts/caption.py
CHANGED
|
@@ -46,7 +46,7 @@ from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape_markup,
|
|
|
46
46
|
strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
|
|
47
47
|
from _common.emit import refuse # noqa: E402
|
|
48
48
|
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
49
|
-
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, NO_SPACE_SCRIPTS, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
49
|
+
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, NO_SPACE_SCRIPTS, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, cfr_at_input, with_cfr, default_output, die, escape_filter_path, ffmpeg_base, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
50
50
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
51
51
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
52
52
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
|
@@ -2082,10 +2082,11 @@ def main() -> int:
|
|
|
2082
2082
|
asset = spec[-1]
|
|
2083
2083
|
if asset not in STATE.plan_inputs:
|
|
2084
2084
|
STATE.plan_inputs.append(asset)
|
|
2085
|
-
|
|
2085
|
+
# the conform of a VFR source is the fps filter at the end of the chain (#512)
|
|
2086
|
+
graph = cfr_at_input(";".join([f"[0:v]{vf}[vsub]"] + chains), meta)
|
|
2086
2087
|
cmd += ["-filter_complex", graph, "-map", "[vout]"]
|
|
2087
2088
|
else:
|
|
2088
|
-
cmd += ["-map", "0:v:0", "-vf", vf]
|
|
2089
|
+
cmd += ["-map", "0:v:0", "-vf", with_cfr(vf, meta)]
|
|
2089
2090
|
if meta.get("audio"):
|
|
2090
2091
|
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
2091
2092
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
package/scripts/color.py
CHANGED
|
@@ -33,7 +33,7 @@ import sys
|
|
|
33
33
|
from typing import List, Optional
|
|
34
34
|
|
|
35
35
|
from _common.runner import carried_stream_maps, fail_if_classified
|
|
36
|
-
from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
36
|
+
from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, cfr_at_input, with_cfr, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
37
37
|
from _common.emit import note, warn
|
|
38
38
|
from _common.color import is_rgb, rgb_to_bt709_filter
|
|
39
39
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
@@ -648,13 +648,14 @@ def main() -> int:
|
|
|
648
648
|
# converts the graded YUV in front of alphamerge, so the matrix is labelled there too
|
|
649
649
|
fc = (f"[0:v]split[ka_c][ka_a];[ka_c]{vf}{matrix_label(meta)}[ka_g];[ka_a]alphaextract[ka_m];"
|
|
650
650
|
f"[ka_g][ka_m]alphamerge,{finish_chain(alpha_codec, args.alpha_mode, meta)}[ka_v]")
|
|
651
|
+
fc = cfr_at_input(fc, meta) # the conform of a VFR source: the fps filter (#512)
|
|
651
652
|
cmd = ffmpeg_base() + decoder_args_for(meta, args.input) + ["-i", args.input, "-filter_complex", fc,
|
|
652
653
|
"-map", "[ka_v]", "-map", f"0:a:{args.audio_stream}?"]
|
|
653
654
|
cmd += video_line(alpha_codec) + tag_args(meta) + cfr_args(meta) + (audio_line(alpha_codec) if has_audio else [])
|
|
654
655
|
if STATE.hw:
|
|
655
656
|
note(f"--hw: --alpha-codec {alpha_codec} stays on the CPU (no VideoToolbox encoder writes it with its alpha)")
|
|
656
657
|
else:
|
|
657
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
658
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(vf, meta), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
658
659
|
# --lut/--correct grade the source's own pixels, so a BT.601 source is converted to the BT.709
|
|
659
660
|
# the line is tagged with; --to-sdr's zscale chain already writes BT.709 and converts nothing more
|
|
660
661
|
cmd += x264_args(args.crf, args.preset, source=None if args.to_sdr else meta) + cfr_args(meta) + (aac_args() if has_audio else [])
|
package/scripts/crop.py
CHANGED
|
@@ -22,7 +22,7 @@ Examples:
|
|
|
22
22
|
import argparse
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
26
26
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
27
27
|
from _common.runner import default_selection_maps
|
|
28
28
|
from _common.emit import note
|
|
@@ -70,7 +70,7 @@ def main() -> int:
|
|
|
70
70
|
vf.insert(0, square_pixels(meta["video"]))
|
|
71
71
|
note(square_pixels_note(meta["video"]))
|
|
72
72
|
# the picture the rectangle was checked against, not the largest one default selection takes
|
|
73
|
-
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", ",".join(vf)]
|
|
73
|
+
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", with_cfr(",".join(vf), meta, args.fps)]
|
|
74
74
|
cmd += video_args(meta, args.crf, args.preset)
|
|
75
75
|
cmd += cfr_args(meta, args.fps)
|
|
76
76
|
if has_audio:
|
package/scripts/cut.py
CHANGED
|
@@ -65,7 +65,7 @@ from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, seve
|
|
|
65
65
|
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
66
66
|
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
67
67
|
from _common.timeline import output_timecode_args
|
|
68
|
-
from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
68
|
+
from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, cfr_filter, graph_with_cfr, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
69
69
|
|
|
70
70
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
71
71
|
DROPPED_STREAMS: List[str] = []
|
|
@@ -925,6 +925,9 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
925
925
|
m = min(SEEK_MARGIN, start)
|
|
926
926
|
cmd = ffmpeg_base() + ["-ss", f"{start - m:.6f}", "-i", src, "-ss", f"{m:.6f}", "-t", f"{dur:.6f}"]
|
|
927
927
|
cmd += stream_maps(meta, dst, copy=False)
|
|
928
|
+
# a VFR source is conformed by the fps filter at the rate cfr_args() delivers: the
|
|
929
|
+
# encoder line's -fps_mode cfr alone let a frame run up to two frames late (#512)
|
|
930
|
+
cmd += ["-vf", cfr_filter(meta)] if cfr_filter(meta) else []
|
|
928
931
|
# ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
|
|
929
932
|
# trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
|
|
930
933
|
# sweep F2), so the re-encode carries video/audio only and the result says so
|
|
@@ -1162,8 +1165,11 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
|
|
|
1162
1165
|
pads += f"[a{i}]"
|
|
1163
1166
|
outs = ("[v]" if has_v else "") + ("[a]" if has_a else "")
|
|
1164
1167
|
graph.append(f"{pads}concat=n={len(segments)}:v={int(has_v)}:a={int(has_a)}{outs}")
|
|
1165
|
-
|
|
1166
|
-
|
|
1168
|
+
# a VFR source is conformed by the fps filter after the concat, at the rate cfr_args() delivers
|
|
1169
|
+
# (#512); a constant-rate source's graph is unchanged
|
|
1170
|
+
fc, vpad = graph_with_cfr(";".join(graph), "[v]", meta) if has_v else (";".join(graph), None)
|
|
1171
|
+
cmd += ["-filter_complex", fc]
|
|
1172
|
+
for pad in (vpad, "[a]" if has_a else None):
|
|
1167
1173
|
if pad:
|
|
1168
1174
|
cmd += ["-map", pad]
|
|
1169
1175
|
if intermediate:
|
package/scripts/deinterlace.py
CHANGED
|
@@ -22,7 +22,7 @@ Examples:
|
|
|
22
22
|
import argparse
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
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, fmt_secs
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
26
26
|
|
|
27
27
|
MODES = {"frame": 0, "field": 1}
|
|
28
28
|
PARITIES = {"auto": -1, "tff": 0, "bff": 1}
|
|
@@ -58,16 +58,21 @@ def main() -> int:
|
|
|
58
58
|
output = args.output or default_output(args.input, "deint")
|
|
59
59
|
|
|
60
60
|
vf = f"yadif=mode={MODES[args.mode]}:parity={PARITIES[args.parity]}:deint={1 if args.only_interlaced else 0}"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
64
|
-
cmd += video_args(meta, args.crf, args.preset)
|
|
61
|
+
# the conform is the fps filter at the end of the chain, the rate the encoder line delivers:
|
|
62
|
+
# its -fps_mode cfr alone let a frame run up to two frames late (#512)
|
|
65
63
|
if args.mode == "field":
|
|
66
64
|
v = meta["video"]
|
|
67
65
|
src_fps = v.get("fps") or 30.0
|
|
68
|
-
|
|
66
|
+
rate_args = ["-fps_mode", "cfr", "-r", f"{src_fps * 2:g}"]
|
|
67
|
+
vf += f",fps={src_fps * 2:g}"
|
|
69
68
|
else:
|
|
70
|
-
|
|
69
|
+
rate_args = cfr_args(meta)
|
|
70
|
+
vf = with_cfr(vf, meta)
|
|
71
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
72
|
+
if has_audio:
|
|
73
|
+
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
74
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
75
|
+
cmd += rate_args
|
|
71
76
|
if has_audio:
|
|
72
77
|
cmd += aac_args()
|
|
73
78
|
else:
|
package/scripts/denoise.py
CHANGED
|
@@ -18,7 +18,7 @@ Examples:
|
|
|
18
18
|
import argparse
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
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, fmt_secs
|
|
21
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
22
22
|
|
|
23
23
|
# hqdn3d's own AVOptions default to 0 (off); these tested presets are the light/medium/heavy
|
|
24
24
|
# starting points its own documentation and common usage recommend (spatial then temporal,
|
|
@@ -72,7 +72,7 @@ def main() -> int:
|
|
|
72
72
|
output = args.output or default_output(args.input, "denoise")
|
|
73
73
|
|
|
74
74
|
vf = f"hqdn3d={ls:g}:{cs:g}:{lt:g}:{ct:g}"
|
|
75
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
75
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(vf, meta, args.fps), "-map", "0:v:0"]
|
|
76
76
|
if has_audio:
|
|
77
77
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
78
78
|
cmd += video_args(meta, args.crf, args.preset)
|
package/scripts/export.py
CHANGED
|
@@ -48,8 +48,8 @@ from pathlib import Path
|
|
|
48
48
|
from typing import Dict, List, Optional
|
|
49
49
|
|
|
50
50
|
from _common.runner import carried_stream_maps, child_exit_code, fail_if_classified, kept_clause, mark_delivered
|
|
51
|
-
from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
52
|
-
from _common.decision import hw_preset_video, restate_last_swap
|
|
51
|
+
from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, cfr_filter, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
52
|
+
from _common.decision import chain_with_rate, hw_preset_video, restate_last_swap
|
|
53
53
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
54
54
|
from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
|
|
55
55
|
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, subtitle_codec_for
|
|
@@ -203,10 +203,15 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
203
203
|
note(f"--hw: --preset {args.preset} stays on {p['video'][p['video'].index('-c:v') + 1]}: one ffmpeg run writes "
|
|
204
204
|
"the fill and the key as a pair, and VideoToolbox's ProRes encoder is not used for it")
|
|
205
205
|
fill_chain = ",".join(vf + (["premultiply=inplace=1"] if args.alpha_mode == "premultiplied" else []) + ["format=yuv422p10le"])
|
|
206
|
-
|
|
206
|
+
# Alpha uses full range even when the source picture is tagged limited range.
|
|
207
|
+
key_chain = ",".join(vf + ["alphaextract", "scale=in_range=pc:out_range=tv", "format=yuv422p10le"])
|
|
207
208
|
tc = ((meta.get("timecode") or {}).get("value"))
|
|
208
209
|
tc_args = ["-timecode", tc] if tc else []
|
|
209
|
-
|
|
210
|
+
# a VFR source is conformed by the fps filter, once, ahead of the split: both files hold the same
|
|
211
|
+
# frames, and -fps_mode cfr alone let a frame run up to two frames late against the sound (#512)
|
|
212
|
+
conform = cfr_filter(meta)
|
|
213
|
+
cmd = cmd + ["-filter_complex", f"[0:{PROBED_VIDEO}]{conform + ',' if conform else ''}split[f][k];"
|
|
214
|
+
f"[f]{fill_chain}[fill];[k]{key_chain}[key]"]
|
|
210
215
|
cmd += ["-map", "[fill]"] + probed_maps(meta, video=False, audio=has_audio) + p["video"] + tc_args + cfr_args(meta)
|
|
211
216
|
# the fill is always written to a hidden sibling and moved into place only after the encode
|
|
212
217
|
# succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
|
|
@@ -695,8 +700,23 @@ def main() -> int:
|
|
|
695
700
|
if args.preset != "copy":
|
|
696
701
|
# a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
|
|
697
702
|
# no longer be a copy, and would silently mislabel colour the agent never actually looked at
|
|
703
|
+
rate_args: List[str] = []
|
|
698
704
|
if "-r" not in video:
|
|
699
|
-
|
|
705
|
+
rate_args = _platform_rate_args(args.preset, meta) or cfr_args(meta)
|
|
706
|
+
cmd += rate_args
|
|
707
|
+
# the rate the line delivers (a preset's own -r, a platform's divided rate, or a VFR
|
|
708
|
+
# source's average) is reached by the fps filter, the nearest frame for each slot: the
|
|
709
|
+
# encoder line's -r alone let a frame run up to two frames late against the sound before
|
|
710
|
+
# it dropped one (#512)
|
|
711
|
+
rate_f = (f"fps={video[video.index('-r') + 1]}" if "-r" in video
|
|
712
|
+
else f"fps={rate_args[rate_args.index('-r') + 1]}" if "-r" in rate_args else "")
|
|
713
|
+
# it is placed as with_cfr() places it: first in a -vf that is a graph (a blur pad's
|
|
714
|
+
# split/overlay, a drawn pad's maskedmerge), whose last frame an fps filter after it lost
|
|
715
|
+
# on FFmpeg 6.1 (reels --pad-fill blur: 119 frames of a 4 s 30 fps picture)
|
|
716
|
+
if rate_f and "-vf" in cmd:
|
|
717
|
+
cmd[cmd.index("-vf") + 1] = chain_with_rate(cmd[cmd.index("-vf") + 1], rate_f)
|
|
718
|
+
elif rate_f:
|
|
719
|
+
cmd += ["-vf", rate_f]
|
|
700
720
|
if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
|
|
701
721
|
cmd += bt709_tag_args(video[video.index("-c:v") + 1])
|
|
702
722
|
if not p.get("intermediate"):
|
package/scripts/fit.py
CHANGED
|
@@ -38,9 +38,9 @@ import sys
|
|
|
38
38
|
from fractions import Fraction
|
|
39
39
|
from typing import List
|
|
40
40
|
|
|
41
|
-
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, length_output_t, stream_starts
|
|
41
|
+
from _common.decision import SEEK_MARGIN, delivered_rate, frame_durations, frame_rate, grid_at_or_after, length_output_t, stream_starts
|
|
42
42
|
from _common.timeline import exact_rate, output_timecode_args
|
|
43
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, probe, run, run_keeping_subtitles, validate_color, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
|
|
43
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, cfr_filter, default_output, die, ffmpeg_base, info, probe, run, run_keeping_subtitles, validate_color, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
|
|
44
44
|
from _common.emit import input_length
|
|
45
45
|
BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
|
|
46
46
|
|
|
@@ -162,6 +162,7 @@ def main() -> int:
|
|
|
162
162
|
post: List[str] = []
|
|
163
163
|
factor = 1.0
|
|
164
164
|
tc_first = None # seconds from the source's first frame to the output's, when a trim moves it
|
|
165
|
+
regridded = False # a speed change already put the picture on the delivered grid
|
|
165
166
|
|
|
166
167
|
# ---- rotate / flip
|
|
167
168
|
if args.rotate == 90:
|
|
@@ -204,6 +205,15 @@ def main() -> int:
|
|
|
204
205
|
elif factor < 1.0 and args.smooth == "blend":
|
|
205
206
|
vf.append(f"fps={src_fps:g}")
|
|
206
207
|
vf.append("tblend=all_mode=average")
|
|
208
|
+
elif not args.fps:
|
|
209
|
+
# the retimed frames are put back on the output's grid by the fps filter, the
|
|
210
|
+
# nearest frame for each slot: left to the encoder line's rate, a 1.11x speed-up
|
|
211
|
+
# showed every picture up to two frames late against its sound (#512's class).
|
|
212
|
+
# A rate the caller names is that grid (the conform below): conformed here to
|
|
213
|
+
# the source's 30 first, a 2x speed-up to --fps 60 dropped every other picture
|
|
214
|
+
# and the second fps filter repeated the rest
|
|
215
|
+
vf.append(frame_durations(meta, delivered_rate(meta) or frame_rate(meta) or src_fps))
|
|
216
|
+
regridded = True
|
|
207
217
|
if has_audio:
|
|
208
218
|
af.append(atempo_chain(factor))
|
|
209
219
|
# atempo's sound is not the input's length over the factor (it comes out some
|
|
@@ -243,11 +253,43 @@ def main() -> int:
|
|
|
243
253
|
else:
|
|
244
254
|
info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
|
|
245
255
|
|
|
256
|
+
# ---- the output's rate: after any retiming, ahead of the frame's own filters. --fit blur and
|
|
257
|
+
# --pad-fill blur are a split/overlay graph, and FFmpeg 6.1's overlay ends its output at its
|
|
258
|
+
# last frame's start, so an fps filter after it lost the last frame (#512's placement).
|
|
259
|
+
# A speed change without --fps has already been put on the delivered grid above
|
|
260
|
+
if args.fps:
|
|
261
|
+
vf.append(f"fps={args.fps:g}")
|
|
262
|
+
elif meta["video"].get("variable_frame_rate_suspected") and not regridded:
|
|
263
|
+
info("source looks variable-frame-rate; conforming to constant fps automatically")
|
|
264
|
+
# the conform is the fps filter, as --fps is: -fps_mode cfr alone let a frame run up to
|
|
265
|
+
# two frames late against the sound (#512)
|
|
266
|
+
vf.append(cfr_filter(meta))
|
|
267
|
+
|
|
246
268
|
# ---- aspect / size
|
|
247
269
|
if args.aspect or args.width or args.height:
|
|
248
270
|
# the one sizing rule, shared with render.py --export-timeline's sequence frame
|
|
249
271
|
out_w, out_h = frame_size(parse_aspect(args.aspect) if args.aspect else None, args.width, args.height, sw, sh)
|
|
250
|
-
if args.fit == "crop":
|
|
272
|
+
if args.fit == "crop" and sw and sh:
|
|
273
|
+
# The scaled frame's margin on each axis is a multiple of 4 and the offset is computed
|
|
274
|
+
# here, to even: crop floors an odd offset to the chroma grid of a 4:2:0 frame, so
|
|
275
|
+
# --crop-x 1 lost the source's last column and showed one column of the margin, and a
|
|
276
|
+
# centred crop sat a pixel left of / above the centre (#512). exact=1 would put the
|
|
277
|
+
# chroma half a sample off the luma instead. The size is the one
|
|
278
|
+
# force_original_aspect_ratio=increase gives (av_rescale rounds half away from zero),
|
|
279
|
+
# widened by at most 3 px on the cropped axis.
|
|
280
|
+
sc_w = max(out_w, (2 * out_h * sw + sh) // (2 * sh))
|
|
281
|
+
sc_h = max(out_h, (2 * out_w * sh + sw) // (2 * sw))
|
|
282
|
+
mx, my = (-(-(sc - o) // 4) * 4 for sc, o in ((sc_w, out_w), (sc_h, out_h)))
|
|
283
|
+
ox, oy = (int(m * f / 2 + 0.5) * 2 for m, f in ((mx, args.crop_x), (my, args.crop_y)))
|
|
284
|
+
for axis, m, f, got in (("x", mx, args.crop_x, ox), ("y", my, args.crop_y, oy)):
|
|
285
|
+
if abs(m * f - got) > 1e-9:
|
|
286
|
+
# the offset is kept even (the chroma grid); a caller who asked for another one
|
|
287
|
+
# is told which it got, as proxy.py says which width it wrote
|
|
288
|
+
note(f"--crop-{axis} {f:g}: crop offset {got}px, not {m * f:g}px: the offset is kept even "
|
|
289
|
+
f"(4:2:0 chroma), on a {m}px margin")
|
|
290
|
+
vf.append(f"scale={out_w + mx}:{out_h + my}")
|
|
291
|
+
vf.append(f"crop={out_w}:{out_h}:{ox}:{oy}")
|
|
292
|
+
elif args.fit == "crop": # a dry run's pending input has no size: the expressions, planned only
|
|
251
293
|
vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
|
|
252
294
|
vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
|
|
253
295
|
elif args.fit == "blur":
|
|
@@ -272,10 +314,6 @@ def main() -> int:
|
|
|
272
314
|
vf.insert(0, square_pixels(meta["video"]))
|
|
273
315
|
note(square_pixels_note(meta["video"]))
|
|
274
316
|
|
|
275
|
-
if args.fps:
|
|
276
|
-
vf.append(f"fps={args.fps:g}")
|
|
277
|
-
elif meta["video"].get("variable_frame_rate_suspected"):
|
|
278
|
-
info("source looks variable-frame-rate; conforming to constant fps automatically")
|
|
279
317
|
# the start timecode is counted at the rate the output is delivered at: a --fps that changes
|
|
280
318
|
# the rate relabels it (01:00:00:12 at 25 fps is 01:00:00:24 at 50) even with no trim, which
|
|
281
319
|
# is also how a render.py clip cut at its own rate reaches the project's frame.fps
|
package/scripts/freeze.py
CHANGED
|
@@ -19,8 +19,8 @@ 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, tpad_length
|
|
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, time_arg, fmt_secs, run
|
|
22
|
+
from _common.decision import av_trim, delivered_rate, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
|
|
23
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
24
24
|
from _common.emit import input_length
|
|
25
25
|
|
|
26
26
|
|
|
@@ -71,7 +71,7 @@ def main() -> int:
|
|
|
71
71
|
if holds_end:
|
|
72
72
|
# tpad's stop_duration clones the last frame for the given duration; no PTS surgery
|
|
73
73
|
# needed since it only ever appends past the real end.
|
|
74
|
-
vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
|
|
74
|
+
vf = with_cfr(f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}", meta)
|
|
75
75
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
76
76
|
if has_audio:
|
|
77
77
|
cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), tail=args.hold)]
|
|
@@ -80,7 +80,7 @@ def main() -> int:
|
|
|
80
80
|
# split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
|
|
81
81
|
# it -- ffmpeg fails filtering an empty stream). Symmetric to --mode extend at the other
|
|
82
82
|
# end: tpad's start_duration clones the *first* frame backwards instead.
|
|
83
|
-
vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
|
|
83
|
+
vf = with_cfr(f"tpad=start_mode=clone:start_duration={args.hold:.3f}", meta)
|
|
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
|
|
@@ -92,12 +92,12 @@ def main() -> int:
|
|
|
92
92
|
# FIRST frame -- the frame shown at `at` -- for --hold seconds (a segment of its own, below).
|
|
93
93
|
# Through 3.1.3 the head's last frame was held, the frame before `at`: at a cut, the last
|
|
94
94
|
# frame of the previous shot (#411).
|
|
95
|
-
n = max(1, round(args.hold *
|
|
95
|
+
n = max(1, round(args.hold * rate))
|
|
96
96
|
# The video hold length is rounded to a whole number of frames (n / fps), but the audio
|
|
97
97
|
# side used to pad by the raw --hold value -- up to half a frame duration off from what
|
|
98
98
|
# the video actually holds for, a permanent A/V drift from this point on. Pad audio by
|
|
99
99
|
# the same, frame-rounded duration the video actually gets.
|
|
100
|
-
actual_hold = n /
|
|
100
|
+
actual_hold = n / rate
|
|
101
101
|
head_v, head_a = av_trim(v0, cut_at, rate)
|
|
102
102
|
tail_v, tail_a = av_trim(cut_at, None, rate)
|
|
103
103
|
# The hold is its own segment: the frame at cut_at, cut on the input's clock and cloned to
|
|
@@ -112,11 +112,13 @@ def main() -> int:
|
|
|
112
112
|
# and 0.100, --at 0.06), and the hold was lost (#416 review). A second trim keeps that one
|
|
113
113
|
# frame; on a constant rate it is the frame at cut_at, as before.
|
|
114
114
|
half = 0.5 / rate
|
|
115
|
+
# the graph closes on the rate the encoder line delivers (a VFR source is conformed to its
|
|
116
|
+
# average): closed on r_frame_rate, -r then resampled it and the picture ran late (#512)
|
|
115
117
|
hold_v = (f"trim=start={cut_at - half:.6f},trim=end_frame=1,"
|
|
116
118
|
f"tpad=stop_mode=clone:stop={n - 1},setpts=PTS-STARTPTS")
|
|
117
119
|
vf = (f"[0:v]split=3[a][b][c];[a]{head_v}[head];"
|
|
118
120
|
f"[b]{hold_v}[hold];[c]{tail_v}[tail];"
|
|
119
|
-
f"[head][hold][tail]concat=n=3:v=1:a=0,{frame_durations(meta, rate)}[outv]")
|
|
121
|
+
f"[head][hold][tail]concat=n=3:v=1:a=0,{frame_durations(meta, delivered_rate(meta) or rate)}[outv]")
|
|
120
122
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", "[outv]"]
|
|
121
123
|
if has_audio:
|
|
122
124
|
af = (f"[0:a]asplit[aa][ab];[aa]{head_a}[ahead];"
|
package/scripts/graphics.py
CHANGED
|
@@ -36,7 +36,7 @@ from typing import List, Optional, Tuple
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
37
|
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
38
38
|
from _common.drawtext import shaping_refusal
|
|
39
|
-
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
39
|
+
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args, cfr_at_input, with_cfr,
|
|
40
40
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
41
41
|
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, probe, rgb_chain_to_bt709, run_keeping_subtitles, video_args,
|
|
42
42
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
@@ -837,14 +837,17 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
837
837
|
if finish:
|
|
838
838
|
chains.append(f"[{last}]{finish}[ka_v]")
|
|
839
839
|
last = "ka_v"
|
|
840
|
-
|
|
840
|
+
# the conform of a VFR source is the fps filter on the picture as it is decoded (#512)
|
|
841
|
+
cmd += ["-filter_complex", cfr_at_input(";".join(chains), meta), "-map", f"[{last}]",
|
|
841
842
|
"-map", f"0:a:{args.audio_stream}?"]
|
|
842
843
|
elif fc:
|
|
843
844
|
if finish:
|
|
844
845
|
fc.append(f"[vout]{finish}[ka_v]")
|
|
845
|
-
cmd += ["-filter_complex", ";".join(fc), "-map", "[ka_v]" if finish else "[vout]",
|
|
846
|
+
cmd += ["-filter_complex", cfr_at_input(";".join(fc), meta), "-map", "[ka_v]" if finish else "[vout]",
|
|
847
|
+
"-map", f"0:a:{args.audio_stream}?"]
|
|
846
848
|
else:
|
|
847
|
-
|
|
849
|
+
vf = ",".join(p for p in (square, text, finish) if p) if finish else (square + "," if square else "") + text
|
|
850
|
+
cmd += ["-vf", with_cfr(vf, meta),
|
|
848
851
|
"-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
849
852
|
if alpha_codec:
|
|
850
853
|
cmd += video_line(alpha_codec) + tag_args(meta) + cfr_args(meta)
|
package/scripts/loop.py
CHANGED
|
@@ -32,7 +32,7 @@ import os
|
|
|
32
32
|
import sys
|
|
33
33
|
import tempfile
|
|
34
34
|
|
|
35
|
-
from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
35
|
+
from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, 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
37
|
from _common.decision import HELD_FRAMES_WARN_BYTES, av_trim, held_frames_bytes, length_output_t, stream_starts
|
|
38
38
|
from _common.probe import decode_gray_frames, decode_pcm_mono
|
|
@@ -296,7 +296,9 @@ 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
|
|
299
|
+
# a variable-rate source is conformed to the rate cfr_args() delivers, by the fps filter in the
|
|
300
|
+
# picture's chain (below) and the output's -fps_mode cfr -r; the fps stage for a sound that
|
|
301
|
+
# outlasts the picture is for a constant-rate clip only, so the two never meet
|
|
300
302
|
forced_rate = cfr_args(meta, fps if fade_frames and v.get("variable_frame_rate_suspected") else None)
|
|
301
303
|
if args.boomerang:
|
|
302
304
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", _boomerang_graph(meta, src_dur, args.times), "-map", "[v]"]
|
|
@@ -319,6 +321,9 @@ def main() -> int:
|
|
|
319
321
|
with open(listfile, "w", encoding="utf-8") as fh:
|
|
320
322
|
fh.write((concat_list_line(repeat) + "\n") * (stream_loop + 1))
|
|
321
323
|
vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
|
|
324
|
+
# the conform of a VFR source is the fps filter at the end of the picture's chain, at the
|
|
325
|
+
# rate cfr_args() below delivers (#512)
|
|
326
|
+
vtrim = with_cfr(vtrim, meta, fps if fade_frames and v.get("variable_frame_rate_suspected") else None)
|
|
322
327
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
|
|
323
328
|
v_end = stream_starts(meta)[0] + (v.get("duration") or 0.0)
|
|
324
329
|
if has_audio and not forced_rate and not fade_frames and src_dur - v_end > 0.5 / fps:
|
package/scripts/overlay.py
CHANGED
|
@@ -29,13 +29,14 @@ A VP9 --video layer stored with an alpha layer (a WebM from export.py --preset v
|
|
|
29
29
|
with libvpx-vp9, so its transparency reaches the composite (FFmpeg's own VP9 decoder drops it).
|
|
30
30
|
"""
|
|
31
31
|
import argparse
|
|
32
|
+
import ast
|
|
32
33
|
import re
|
|
33
34
|
import sys
|
|
34
35
|
from typing import Any, Dict, List, Optional
|
|
35
36
|
|
|
36
37
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
38
|
from _common import still_input_args # noqa: E402
|
|
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, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
|
|
39
|
+
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, cfr_filter, cfr_at_input, with_cfr, default_output, die, escape_drawtext, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
|
|
39
40
|
from _common.emit import note, warn
|
|
40
41
|
from _common.color import is_rgb
|
|
41
42
|
from _common.decision import length_output_t, window_expr, windowed_overlay_x
|
|
@@ -86,6 +87,56 @@ def position_exprs(pos: str, margin: int, text_mode: bool, margins: Optional[dic
|
|
|
86
87
|
return x, y
|
|
87
88
|
|
|
88
89
|
|
|
90
|
+
def _scaled_height(w: int, src_w: int, src_h: int, factor: int) -> int:
|
|
91
|
+
"""The height scale=W:-1 (factor 1) or scale=W:-2 (factor 2) gives a src_w x src_h picture:
|
|
92
|
+
av_rescale(w, ih, iw * factor) * factor, rounded half away from zero as FFmpeg rounds it."""
|
|
93
|
+
den = src_w * factor
|
|
94
|
+
return max(factor, (2 * w * src_h + den) // (2 * den) * factor)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def on_grid(x: str, y: str, main: "tuple", layer: "tuple") -> "Optional[tuple]":
|
|
98
|
+
"""Where the layer goes so its top-left pixel is at (x, y) in luma, x and y being the overlay
|
|
99
|
+
expressions position_exprs() gives, as overlay evaluates them (the integer part).
|
|
100
|
+
|
|
101
|
+
overlay places a layer on a 4:2:0 picture on the chroma grid: an odd x or y is taken down to
|
|
102
|
+
the even one before it (#512: --position 11,13 drew the logo at 10,12, and a centred or
|
|
103
|
+
right-aligned layer whose position came out odd moved the same way). So the layer gets one
|
|
104
|
+
transparent column/row on the left/top where x/y is odd, and is overlaid at the even position
|
|
105
|
+
before it: its luma lands where it was asked, and the chroma sample on that edge is shared
|
|
106
|
+
with the pixel outside it. Returns (x_even, y_even, pad filter or ""), or None when a size is
|
|
107
|
+
unknown (a dry run's pending input): the overlay then gets the expressions, as before."""
|
|
108
|
+
(mw, mh), (lw, lh) = main, layer
|
|
109
|
+
if not (mw and mh and lw and lh):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def arith(node: Any) -> float:
|
|
113
|
+
# the four operations on whole numbers position_exprs() writes, and nothing else
|
|
114
|
+
if isinstance(node, ast.Expression):
|
|
115
|
+
return arith(node.body)
|
|
116
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, int):
|
|
117
|
+
return float(node.value)
|
|
118
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
|
119
|
+
return -arith(node.operand) if isinstance(node.op, ast.USub) else arith(node.operand)
|
|
120
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
|
|
121
|
+
a, b = arith(node.left), arith(node.right)
|
|
122
|
+
return {ast.Add: a + b, ast.Sub: a - b, ast.Mult: a * b}.get(type(node.op), a / b if b else 0.0)
|
|
123
|
+
raise ValueError(f"not a position: {ast.dump(node)}")
|
|
124
|
+
|
|
125
|
+
def value(expr: str, big: int, small: int, axis: str) -> Optional[int]:
|
|
126
|
+
num = re.sub(axis.upper(), str(big), re.sub(axis, str(small), expr))
|
|
127
|
+
try:
|
|
128
|
+
return int(arith(ast.parse(num, mode="eval"))) # the integer part, as overlay's (int) cast takes it
|
|
129
|
+
except (SyntaxError, ValueError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
px, py = value(x, mw, lw, "w"), value(y, mh, lh, "h")
|
|
133
|
+
if px is None or py is None:
|
|
134
|
+
return None
|
|
135
|
+
ox, oy = px & 1, py & 1 # two's complement: -3 is one column right of -4
|
|
136
|
+
pad = f"pad=iw+{ox}:ih+{oy}:{ox}:{oy}:color=black@0" if ox or oy else ""
|
|
137
|
+
return px - ox, py - oy, pad
|
|
138
|
+
|
|
139
|
+
|
|
89
140
|
def _wrap_to_frame(args, frame_w: int, margins: Optional[dict], frame_h: int = 0) -> str:
|
|
90
141
|
"""--text broken into lines that fit between the frame's side margins at --font-size, by the
|
|
91
142
|
same phrase rules graphics.py and caption.py wrap with; text that fits comes back unchanged.
|
|
@@ -443,11 +494,22 @@ def main() -> int:
|
|
|
443
494
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
444
495
|
|
|
445
496
|
if args.image:
|
|
446
|
-
|
|
497
|
+
image_meta = probe(args.image)
|
|
498
|
+
refuse_not_a_still(args.image, image_meta, "--image",
|
|
447
499
|
"--video composites a moving layer; `look.py FILE --at T` writes one frame of it as a PNG")
|
|
448
500
|
chain: List[str] = ["format=rgba"]
|
|
501
|
+
# a still that stores non-square pixels (a PNG's pHYs, a JPEG's density) is made square at
|
|
502
|
+
# its display aspect first, as a --video layer is: its size and position are computed in
|
|
503
|
+
# that frame, and placed from it at its stored size a 100x100 PNG at SAR 2:1 drew 100 px
|
|
504
|
+
# wide at x=120 of a 320 px main for top-right, 100 px short of the edge
|
|
505
|
+
if square_pixels(image_meta.get("video")):
|
|
506
|
+
chain.append(square_pixels(image_meta.get("video")))
|
|
507
|
+
note(square_pixels_note(image_meta.get("video"), "the --image"))
|
|
508
|
+
iw, ih = display_size(image_meta.get("video"))
|
|
509
|
+
lw = int(vw * args.scale_percent / 100) if args.scale_percent else args.scale or iw
|
|
510
|
+
lh = _scaled_height(lw, iw, ih, 1) if lw != iw and iw and ih else ih
|
|
449
511
|
if args.scale_percent:
|
|
450
|
-
chain.append(f"scale={
|
|
512
|
+
chain.append(f"scale={lw}:-1")
|
|
451
513
|
elif args.scale:
|
|
452
514
|
chain.append(f"scale={args.scale}:-1")
|
|
453
515
|
if args.opacity < 1:
|
|
@@ -461,6 +523,11 @@ def main() -> int:
|
|
|
461
523
|
if end is not None and end > args.fade:
|
|
462
524
|
chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
463
525
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
526
|
+
grid = on_grid(x, y, (vw, vh), (lw, lh))
|
|
527
|
+
if grid:
|
|
528
|
+
# padded in RGBA, where a one-pixel offset is exact, before the layer is made YUV
|
|
529
|
+
x, y = grid[0], grid[1]
|
|
530
|
+
chain += [grid[2]] if grid[2] else []
|
|
464
531
|
# a 10-bit main on a 10-bit line is composited at 10 bits in YUV: format=auto with an RGBA
|
|
465
532
|
# still took the whole picture through 8-bit RGB (overlay_format)
|
|
466
533
|
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
@@ -510,8 +577,11 @@ def main() -> int:
|
|
|
510
577
|
if main_dur and at >= main_dur:
|
|
511
578
|
die(f"--place {a:g}-{b:g}@{at:g}: starts after the main video ends ({main_dur:.3f} s)")
|
|
512
579
|
chain = [square_pixels(pip_meta["video"])] if square_pixels(pip_meta["video"]) else []
|
|
580
|
+
iw, ih = display_size(pip_meta["video"])
|
|
581
|
+
lw = int(vw * args.scale_percent / 100) if args.scale_percent else args.scale or iw
|
|
582
|
+
lh = _scaled_height(lw, iw, ih, 2) if (args.scale_percent or args.scale) and iw and ih else ih
|
|
513
583
|
if args.scale_percent:
|
|
514
|
-
chain.append(f"scale={
|
|
584
|
+
chain.append(f"scale={lw}:-2")
|
|
515
585
|
elif args.scale:
|
|
516
586
|
chain.append(f"scale={args.scale}:-2")
|
|
517
587
|
# an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
|
|
@@ -544,6 +614,15 @@ def main() -> int:
|
|
|
544
614
|
# which came back BT.601 under the BT.709 tag (#333's defect, on this path)
|
|
545
615
|
chain.append(f"lut=y=val:u=val:v=val:a=val*{args.opacity:g}")
|
|
546
616
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
617
|
+
grid = on_grid(x, y, (vw, vh), (lw, lh))
|
|
618
|
+
if grid:
|
|
619
|
+
x, y = grid[0], grid[1]
|
|
620
|
+
if grid[2]:
|
|
621
|
+
# padded at 4:4:4, where a one-pixel offset is exact; overlay takes the layer back
|
|
622
|
+
# to the main's 4:2:0 on its own grid, as the layer is now aligned with it
|
|
623
|
+
if not any(c.endswith("yuva444p") or c.endswith("yuva444p10le") for c in chain):
|
|
624
|
+
chain.append("format=yuva444p10le" if deep else "format=yuva444p")
|
|
625
|
+
chain.append(grid[2])
|
|
547
626
|
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
548
627
|
# picture's frames; overlay's enable is evaluated on the layer's frames too
|
|
549
628
|
ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
@@ -646,6 +725,15 @@ def main() -> int:
|
|
|
646
725
|
else:
|
|
647
726
|
cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
|
|
648
727
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
728
|
+
if cfr_filter(meta):
|
|
729
|
+
# the conform of a VFR source is the fps filter (in a graph, on the main picture as it is
|
|
730
|
+
# decoded): the encoder line's -fps_mode cfr alone let a frame run up to two frames late (#512)
|
|
731
|
+
if "-filter_complex" in cmd:
|
|
732
|
+
i = cmd.index("-filter_complex") + 1
|
|
733
|
+
cmd[i] = cfr_at_input(cmd[i], meta)
|
|
734
|
+
else:
|
|
735
|
+
i = cmd.index("-vf") + 1
|
|
736
|
+
cmd[i] = with_cfr(cmd[i], meta)
|
|
649
737
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
650
738
|
if not STATE.dry_run:
|
|
651
739
|
result = probe(output, role="output")
|
package/scripts/pad.py
CHANGED
|
@@ -18,7 +18,7 @@ import sys
|
|
|
18
18
|
|
|
19
19
|
from _common.emit import note, record_fallback
|
|
20
20
|
from _common.decision import silence_filter, tpad_length
|
|
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
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, 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
|
|
|
24
24
|
def main() -> int:
|
|
@@ -51,7 +51,12 @@ def main() -> int:
|
|
|
51
51
|
tpad = f"tpad=start_duration={args.start:.3f}:stop_duration={args.end:.3f}:color="
|
|
52
52
|
# a --color with chroma lands in the BT.709 the output is tagged with (tpad's own YUV is BT.601
|
|
53
53
|
# before FFmpeg 7.1, and for an untagged picture after it); black is the same under every matrix
|
|
54
|
-
|
|
54
|
+
# the conform of a VFR source is the fps filter right after tpad, which pads by time: before
|
|
55
|
+
# it, the padding was rounded to the delivered grid on top of the frames' own rounding (37 ms
|
|
56
|
+
# late), and after a drawn chain's maskedmerge the last frame was lost (#512)
|
|
57
|
+
conform = with_cfr("", meta)
|
|
58
|
+
vf = drawn_chain(tpad + args.color + ("," + conform if conform else ""), meta, [args.color],
|
|
59
|
+
tpad + "black" + ("," + conform if conform else ""))
|
|
55
60
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
56
61
|
if has_audio:
|
|
57
62
|
# One -filter_complex, not a -vf and an -af: the ffmpeg scheduler of Ubuntu 26.04's 8.0.1 and
|
package/scripts/proxy.py
CHANGED
|
@@ -26,7 +26,7 @@ Examples:
|
|
|
26
26
|
import argparse
|
|
27
27
|
import sys
|
|
28
28
|
|
|
29
|
-
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
|
|
29
|
+
from _common import add_common, apply_common, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
|
|
30
30
|
from _common.emit import note
|
|
31
31
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
32
32
|
|
|
@@ -77,7 +77,7 @@ def main() -> int:
|
|
|
77
77
|
+ (f"the source is {sw}px wide and a proxy is not upscaled" if asked > ceiling else "2px is the smallest width"))
|
|
78
78
|
output = args.output or default_output(args.input, "proxy")
|
|
79
79
|
|
|
80
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
|
|
80
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr((square + "," if square else "") + f"scale={out_w}:-2", meta, args.fps)]
|
|
81
81
|
cmd += video_args(meta, args.crf, "veryfast")
|
|
82
82
|
cmd += cfr_args(meta, args.fps)
|
|
83
83
|
cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
|
package/scripts/redact.py
CHANGED
|
@@ -27,7 +27,7 @@ Examples:
|
|
|
27
27
|
import argparse
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
-
from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
30
|
+
from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, cfr_at_input, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
31
31
|
from _common.emit import note
|
|
32
32
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
33
33
|
|
|
@@ -98,7 +98,8 @@ def main() -> int:
|
|
|
98
98
|
output = args.output or default_output(args.input, "redact")
|
|
99
99
|
if args.mode == "delogo":
|
|
100
100
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf",
|
|
101
|
-
(square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
101
|
+
with_cfr((square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
102
|
+
meta, args.fps),
|
|
102
103
|
"-map", "0:v:0"]
|
|
103
104
|
else:
|
|
104
105
|
# exact=1: crop rounds an odd x/y down to the chroma grid of a 4:2:0 frame otherwise, and
|
|
@@ -127,6 +128,8 @@ def main() -> int:
|
|
|
127
128
|
f"pad={args.width + 2 * ox}:{args.height + 2 * oy}:{ox}:{oy}:color=black@0")
|
|
128
129
|
fc = (f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];"
|
|
129
130
|
f"[base][patched]overlay={args.x - ox}:{args.y - oy}{overlay_format(meta)}[out]")
|
|
131
|
+
# a VFR source (or --fps) is conformed by the fps filter as it is decoded (#512)
|
|
132
|
+
fc = cfr_at_input(fc, meta, args.fps)
|
|
130
133
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
131
134
|
if has_audio:
|
|
132
135
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
package/scripts/render.py
CHANGED
|
@@ -1326,7 +1326,19 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
|
|
|
1326
1326
|
export = proj.get("export")
|
|
1327
1327
|
frame_from_preset(frame, export if isinstance(export, dict) else {})
|
|
1328
1328
|
fit = proj.get("fit")
|
|
1329
|
-
if isinstance(fit, dict) and
|
|
1329
|
+
if isinstance(fit, dict) and len(proj.get("clips") or []) == 1:
|
|
1330
|
+
# one clip: the render's fit stage reframes the clip's own picture with the fit object's
|
|
1331
|
+
# aspect, mode, sides and rate, the frame's only where the fit object has none (the
|
|
1332
|
+
# render's setdefault), so the sequence takes them by the same rule (#506: frame 16:9 with
|
|
1333
|
+
# fit 1:1 rendered a square and exported 1920x1080). Several clips are fitted inside the
|
|
1334
|
+
# join, which join.py sizes from the frame first; their sequence stays the frame's
|
|
1335
|
+
if fit.get("aspect") and aspect_ratio(str(fit["aspect"])) is None:
|
|
1336
|
+
die(f"--export-timeline: fit.aspect {fit['aspect']!r}: use W:H with whole numbers, like 16:9 "
|
|
1337
|
+
"(fit.py refuses it too)", kind="input")
|
|
1338
|
+
if fit.get("fit") is not None and fit["fit"] not in ("pad", "crop", "blur"):
|
|
1339
|
+
die(f"--export-timeline: fit.fit {fit['fit']!r}: use pad, crop or blur (fit.py's --fit)", kind="input")
|
|
1340
|
+
frame.update({k: fit[k] for k in ("aspect", "fit", "width", "height", "fps") if fit.get(k) is not None})
|
|
1341
|
+
elif isinstance(fit, dict) and fit.get("aspect") and frame.get("aspect") and aspect_ratio(frame["aspect"]) is None:
|
|
1330
1342
|
# the render hands fit.py the fit object's own aspect, never this one (only the preset
|
|
1331
1343
|
# match above reads it), and completes: the sequence is the frame's size without it
|
|
1332
1344
|
del frame["aspect"]
|
package/scripts/speedramp.py
CHANGED
|
@@ -17,9 +17,10 @@ Examples:
|
|
|
17
17
|
"""
|
|
18
18
|
import argparse
|
|
19
19
|
import sys
|
|
20
|
+
from fractions import Fraction
|
|
20
21
|
from typing import List, Tuple
|
|
21
22
|
|
|
22
|
-
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
23
|
+
from _common.decision import av_trim, conform_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
23
24
|
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
|
|
24
25
|
from _common.emit import input_length
|
|
25
26
|
|
|
@@ -85,7 +86,7 @@ def main() -> int:
|
|
|
85
86
|
has_audio = bool(meta.get("audio"))
|
|
86
87
|
output = args.output or default_output(args.input, "ramp")
|
|
87
88
|
|
|
88
|
-
vparts, aparts
|
|
89
|
+
vparts, aparts = [], []
|
|
89
90
|
# The coverage check above allows the last end to sit up to 0.5 s off the probed duration.
|
|
90
91
|
# That slack is for a caller who rounded the duration, not a reason to redraw the tail at
|
|
91
92
|
# the last factor. The end bound is omitted only when the stated end is the file's end
|
|
@@ -97,7 +98,12 @@ def main() -> int:
|
|
|
97
98
|
# the factor scaled that (+570 ms at 0.5x). The first segment starts at the first frame.
|
|
98
99
|
v0 = stream_starts(meta)[0]
|
|
99
100
|
rate = frame_rate(meta)
|
|
101
|
+
# on a VFR source the picture is delivered at the rate the encoder line forces (its average):
|
|
102
|
+
# closed on r_frame_rate, -r then resampled the joined picture and it drifted 113 ms late (#512)
|
|
103
|
+
out_rate = delivered_rate(meta) or rate
|
|
104
|
+
srate = int((meta.get("audio") or {}).get("sample_rate") or 0)
|
|
100
105
|
bounds = [v0] + [grid_at_or_after(end, v0, rate) for _, end, _ in segments[:-1]]
|
|
106
|
+
retimed = 0.0 # where the segment starts on the output's clock
|
|
101
107
|
for i, (start, end, factor) in enumerate(segments):
|
|
102
108
|
vlabel, alabel = f"v{i}", f"a{i}"
|
|
103
109
|
to_eof = i == len(segments) - 1 and last_runs_to_eof
|
|
@@ -107,23 +113,52 @@ def main() -> int:
|
|
|
107
113
|
die(f"--segment {start:g}-{end:g}:{factor:g} holds no frame: both ends fall on the frame at "
|
|
108
114
|
f"{bounds[i]:.3f}s; widen it or merge it into a neighbour")
|
|
109
115
|
vtrim, atrim = av_trim(bounds[i], seg_end, rate)
|
|
110
|
-
# the closing fps puts the sped-up frames back on the
|
|
116
|
+
# the closing fps puts the sped-up frames back on the delivered grid with its frame
|
|
111
117
|
# duration: concat starts the next segment after the last frame's duration, which setpts
|
|
112
118
|
# leaves at the unscaled 1/rate, so every 2x segment pushed the rest of the picture two
|
|
113
119
|
# frames late against the sound (and added those frames)
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
vchain = f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, out_rate)}"
|
|
121
|
+
achain = f"[0:a]{atrim},{atempo_chain(factor)}"
|
|
122
|
+
if out_rate:
|
|
123
|
+
# Each segment holds the frames (and samples) between its retimed ends on the whole
|
|
124
|
+
# output's grid, not its own length rounded: eight 0.5 s segments at 27 fps rounded
|
|
125
|
+
# to 14 frames each, concat advanced 14/27 s per segment and the last flash landed
|
|
126
|
+
# 130 ms late. The picture is sampled on the output's grid (an offset of under half
|
|
127
|
+
# a frame, and fps's start_time) and, but for a last segment that runs to the end,
|
|
128
|
+
# padded by a frame or two and cut to its count; the sound is padded or cut to its
|
|
129
|
+
# own count, so neither stream drifts across segments.
|
|
130
|
+
first = round(retimed * out_rate)
|
|
131
|
+
shift = first / out_rate - retimed
|
|
132
|
+
vchain = (f"[0:v]{vtrim},setpts=PTS/{factor:.6f}{-shift:+.6f}/TB,"
|
|
133
|
+
f"{frame_durations(meta, out_rate)}:start_time=0")
|
|
134
|
+
if seg_end is not None:
|
|
135
|
+
after = retimed + (seg_end - bounds[i]) / factor
|
|
136
|
+
vchain += f",tpad=stop=2:stop_mode=clone,trim=end_frame={round(after * out_rate) - first}"
|
|
137
|
+
if srate:
|
|
138
|
+
achain += f",{conform_filter(round(after * srate) - round(retimed * srate), srate)}"
|
|
139
|
+
retimed = after
|
|
140
|
+
vparts.append(f"{vchain}[{vlabel}]")
|
|
116
141
|
if has_audio:
|
|
117
|
-
aparts.append(f"
|
|
118
|
-
|
|
142
|
+
aparts.append(f"{achain}[{alabel}]")
|
|
143
|
+
|
|
144
|
+
# the two streams are joined by two concats: one concat over both started each segment after
|
|
145
|
+
# the longer of its two streams, so a picture a frame longer than its sound moved both late
|
|
146
|
+
n = len(segments)
|
|
147
|
+
# Every segment's picture is a whole count of frames on one grid, so the joined picture's
|
|
148
|
+
# timestamps are rebuilt from the running frame count: FFmpeg 6.1.1's video-only concat gave a
|
|
149
|
+
# one-frame segment no length (it takes a segment's end from its last frame's timestamp), so
|
|
150
|
+
# three one-frame segments put the rest of the picture a frame before its sound. The closing
|
|
151
|
+
# fps gives the frames their durations back (from 7.0 setpts leaves them 0, frame_durations)
|
|
152
|
+
regrid = ""
|
|
153
|
+
if out_rate:
|
|
154
|
+
grid = frame_durations(meta, out_rate)
|
|
155
|
+
q = Fraction(grid[len("fps="):]) if "/" in grid else Fraction(out_rate).limit_denominator(1001)
|
|
156
|
+
regrid = f",setpts=N*{q.denominator}/({q.numerator}*TB),{grid}"
|
|
157
|
+
fc = ";".join(vparts) + ";" + "".join(f"[v{i}]" for i in range(n)) + f"concat=n={n}:v=1:a=0{regrid}[outv]"
|
|
158
|
+
maps = ["-map", "[outv]"]
|
|
119
159
|
if has_audio:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
maps = ["-map", "[outv]", "-map", "[outa]"]
|
|
123
|
-
else:
|
|
124
|
-
concat_inputs = "".join(f"[v{i}]" for i in range(len(segments)))
|
|
125
|
-
fc = ";".join(vparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=0[outv]"
|
|
126
|
-
maps = ["-map", "[outv]"]
|
|
160
|
+
fc += ";" + ";".join(aparts) + ";" + "".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[outa]"
|
|
161
|
+
maps += ["-map", "[outa]"]
|
|
127
162
|
|
|
128
163
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc] + maps
|
|
129
164
|
cmd += video_args(meta, args.crf, args.preset)
|
package/scripts/sphere.py
CHANGED
|
@@ -33,7 +33,7 @@ import argparse
|
|
|
33
33
|
import math
|
|
34
34
|
import sys
|
|
35
35
|
|
|
36
|
-
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, fmt_secs
|
|
36
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
37
37
|
from _common.emit import note
|
|
38
38
|
|
|
39
39
|
# v360's own AVOption names for the input projections real 360 cameras/exports actually
|
|
@@ -124,7 +124,7 @@ def main() -> int:
|
|
|
124
124
|
f"yaw={args.yaw:g}:pitch={args.pitch:g}:roll={args.roll:g}:"
|
|
125
125
|
f"h_fov={args.h_fov:g}:v_fov={args.v_fov:g}:"
|
|
126
126
|
f"w={args.width}:h={args.height}:interp={args.interp}")
|
|
127
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", v360, "-map", "0:v:0"]
|
|
127
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(v360, meta, args.fps), "-map", "0:v:0"]
|
|
128
128
|
if has_audio:
|
|
129
129
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
130
130
|
cmd += video_args(meta, args.crf, args.preset)
|
package/scripts/stabilize.py
CHANGED
|
@@ -29,7 +29,7 @@ from pathlib import Path
|
|
|
29
29
|
|
|
30
30
|
from _common.probe import PROBED_VIDEO
|
|
31
31
|
from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
|
|
32
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
|
|
32
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def main() -> int:
|
|
@@ -104,7 +104,7 @@ def main() -> int:
|
|
|
104
104
|
# Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
|
|
105
105
|
# camera path makes no sense once every frame is locked to one fixed reference.
|
|
106
106
|
transform_vf += ":tripod=1"
|
|
107
|
-
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", transform_vf]
|
|
107
|
+
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", with_cfr(transform_vf, meta)]
|
|
108
108
|
cmd += video_args(meta, args.crf, args.preset)
|
|
109
109
|
cmd += cfr_args(meta)
|
|
110
110
|
if has_audio:
|
package/scripts/straighten.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import math
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
26
|
from _common.emit import note
|
|
27
27
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
28
28
|
|
|
@@ -100,7 +100,7 @@ def main() -> int:
|
|
|
100
100
|
if square_pixels(meta["video"]):
|
|
101
101
|
rotate = square_pixels(meta["video"]) + "," + rotate
|
|
102
102
|
note(square_pixels_note(meta["video"]))
|
|
103
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", rotate, "-map", "0:v:0"]
|
|
103
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(rotate, meta, args.fps), "-map", "0:v:0"]
|
|
104
104
|
if has_audio:
|
|
105
105
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
106
106
|
cmd += video_args(meta, args.crf, args.preset)
|