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 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.14`) | any release |
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.14", "execution_mode": "local", "kind": "execution",
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.14",
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": [
@@ -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). Every re-encoding
121
- script then adds `-fps_mode cfr` at the source's average rate. That flag is a
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
- cmd += ["-filter_complex", ";".join(parts), "-map", vout]
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)
@@ -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
- graph = ";".join([f"[0:v]{vf}[vsub]"] + chains)
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
- cmd += ["-filter_complex", ";".join(graph)]
1166
- for pad in ("[v]" if has_v else None, "[a]" if has_a else None):
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:
@@ -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
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
62
- if has_audio:
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
- cmd += ["-fps_mode", "cfr", "-r", f"{src_fps * 2:g}"]
66
+ rate_args = ["-fps_mode", "cfr", "-r", f"{src_fps * 2:g}"]
67
+ vf += f",fps={src_fps * 2:g}"
69
68
  else:
70
- cmd += cfr_args(meta)
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:
@@ -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
- key_chain = ",".join(vf + ["alphaextract", "format=yuv422p10le"])
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
- cmd = cmd + ["-filter_complex", f"[0:{PROBED_VIDEO}]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
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
- cmd += _platform_rate_args(args.preset, meta) or cfr_args(meta)
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 * fps))
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 / fps
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];"
@@ -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
- cmd += ["-filter_complex", ";".join(chains), "-map", f"[{last}]",
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]", "-map", f"0:a:{args.audio_stream}?"]
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
- cmd += ["-vf", ",".join(p for p in (square, text, finish) if p) if finish else (square + "," if square else "") + text,
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 by the output's own -fps_mode cfr -r (cfr_args)
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:
@@ -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
- refuse_not_a_still(args.image, probe(args.image), "--image",
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={int(vw * args.scale_percent / 100)}:-1")
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={int(vw * args.scale_percent / 100)}:-2")
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
- vf = drawn_chain(tpad + args.color, meta, [args.color], tpad + "black")
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 fit.get("aspect") and frame.get("aspect") and aspect_ratio(frame["aspect"]) is None:
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"]
@@ -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, labels = [], [], []
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 source's grid with its frame
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
- vparts.append(f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, rate)}[{vlabel}]")
115
- labels.append(f"[{vlabel}]")
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"[0:a]{atrim},{atempo_chain(factor)}[{alabel}]")
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
- concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(len(segments)))
121
- fc = ";".join(vparts + aparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=1[outv][outa]"
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)
@@ -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:
@@ -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)