ffmpeg-skill 3.8.15 → 3.8.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/contract.md +3 -3
- package/mcp/server.py +143 -21
- package/package.json +1 -1
- package/references/gotchas.md +12 -2
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/alpha.py +1 -0
- package/scripts/_common/color.py +30 -1
- package/scripts/_common/decision.py +57 -0
- package/scripts/_common/emit.py +55 -6
- package/scripts/_common/probe.py +14 -1
- package/scripts/_common/runner.py +452 -80
- package/scripts/_common/wrap.py +24 -8
- package/scripts/_contract.py +18 -2
- package/scripts/batch.py +7 -1
- package/scripts/broll.py +5 -2
- package/scripts/caption.py +11 -7
- 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 +23 -4
- 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/metadata.py +5 -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 +24 -12
- package/scripts/silence.py +2 -2
- 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/scripts/verify.py +32 -2
package/scripts/_common/wrap.py
CHANGED
|
@@ -282,16 +282,32 @@ def _slice_atom(atom: str, max_em: float) -> "List[str]":
|
|
|
282
282
|
No dictionary, no hyphenation library: an existing hyphen is preferred as the cut (the same
|
|
283
283
|
break R1 already allows), then whatever is left is cut again at exactly the widest prefix
|
|
284
284
|
that still measures within `max_em`, one character at a time. The characters themselves are
|
|
285
|
-
never rewritten -- every piece concatenates back to the original atom exactly.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
never rewritten -- every piece concatenates back to the original atom exactly.
|
|
286
|
+
|
|
287
|
+
Iterative, and each piece measures only the prefixes up to the first that is too wide (a
|
|
288
|
+
prefix is never narrower than a shorter one): the recursive form measured every prefix of the
|
|
289
|
+
remaining atom for each piece it cut, so a 1 KB hyphenated cue took seconds and 4 KB minutes
|
|
290
|
+
(A11, #465), in caption.py's own process, which --timeout (a limit on each ffmpeg call) does
|
|
291
|
+
not bound. The pieces are the ones the recursive form cut."""
|
|
292
292
|
pieces: "List[str]" = []
|
|
293
|
+
rest = atom
|
|
294
|
+
while True:
|
|
295
|
+
# latest hyphen whose left side still fits -- keeps the left half as large as possible;
|
|
296
|
+
# a rest whose every prefix fits, itself included, is the last piece
|
|
297
|
+
cut = 0
|
|
298
|
+
for end in range(1, len(rest) + 1):
|
|
299
|
+
if text_width_em(rest[:end]) > max_em:
|
|
300
|
+
break
|
|
301
|
+
if 1 <= end - 1 <= len(rest) - 2 and rest[end - 1] in _HYPHENS:
|
|
302
|
+
cut = end
|
|
303
|
+
else:
|
|
304
|
+
return pieces + [rest]
|
|
305
|
+
if not cut:
|
|
306
|
+
break
|
|
307
|
+
pieces.append(rest[:cut])
|
|
308
|
+
rest = rest[cut:]
|
|
293
309
|
cur = ""
|
|
294
|
-
for ch in
|
|
310
|
+
for ch in rest:
|
|
295
311
|
candidate = cur + ch
|
|
296
312
|
if cur and text_width_em(candidate) > max_em:
|
|
297
313
|
pieces.append(cur)
|
package/scripts/_contract.py
CHANGED
|
@@ -436,6 +436,7 @@ ERROR_REASONS = {
|
|
|
436
436
|
"output_is_directory": "the output path is a directory; details: {output}",
|
|
437
437
|
"media_path_is_playlist": "an input is a playlist that ffmpeg would follow to the files or URLs it names: HLS .m3u8/.m3u or an ffconcat list (by name or by its first bytes), a DASH manifest or an IMF composition playlist (by name or by the content ffmpeg's own probe looks for, whatever the file is called); the skill takes media files only; details: {path}",
|
|
438
438
|
"media_path_has_line_break": "a path written into a concat list (loop.py, join.py --copy, sequence.py, multi-part cuts) contains a line break, which the concat format cannot hold: each line is a directive, so the name would add entries to the list; details: {path}",
|
|
439
|
+
"still_unreadable": "an input read by an image demuxer (a .png, .jpg, ... name) has a picture stream with no frame size: the file is not an image ffmpeg can decode (a text file named .png); details: {path, format, codec}",
|
|
439
440
|
"media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
|
|
440
441
|
"join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
|
|
441
442
|
"output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
|
|
@@ -446,7 +447,7 @@ ERROR_REASONS = {
|
|
|
446
447
|
"output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
|
|
447
448
|
"output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
|
|
448
449
|
"timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
|
|
449
|
-
"output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
|
|
450
|
+
"output_locked": "another run holds the output's lock, or is taking a stale one over (why: takeover_in_progress), or a stale lock cannot be taken over safely because the filesystem gives no OS file lock (why: takeover_unavailable: delete it by hand); details: {lock, output, pid, pid_alive, deadline, why}",
|
|
450
451
|
"plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
|
|
451
452
|
"inputs: [{path, status: unchanged|changed|missing, expected: {size, sha256_head_tail}, actual: {size, "
|
|
452
453
|
"sha256_head_tail} | \"missing\"}], unbound_inputs, audit_log}",
|
|
@@ -1943,6 +1944,11 @@ MCP_PROMPTS: List[Dict[str, Any]] = [
|
|
|
1943
1944
|
"to keep HDR and only drop the Dolby Vision layer, when strip_dovi is asked for). A flat, washed-out "
|
|
1944
1945
|
"look after a re-encode usually means HDR metadata was lost upstream; `probe` shows `hdr: true` "
|
|
1945
1946
|
"only for a real PQ/HLG/Dolby Vision signal, and `bt2020_or_hdr: true` also for BT.2020 SDR."),
|
|
1947
|
+
# A23 (#465): strip_dovi was taken and changed nothing; true now asks for the other recipe
|
|
1948
|
+
"flags": {"strip_dovi": (
|
|
1949
|
+
"Keep {input} HDR and drop only its Dolby Vision layer: `color.py {input} --strip-dovi`, a stream copy "
|
|
1950
|
+
"(HEVC only) that removes the Dolby Vision RPU so players use the HLG/HDR10 base layer. A source with no "
|
|
1951
|
+
"Dolby Vision metadata gets a `dovi_not_detected` warning.")},
|
|
1946
1952
|
},
|
|
1947
1953
|
]
|
|
1948
1954
|
|
|
@@ -1968,7 +1974,17 @@ def mcp_prompt_get(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1968
1974
|
fields: Dict[str, str] = {a["name"]: str((arguments or {}).get(a["name"]) or a.get("default", "")) for a in prompt["arguments"]}
|
|
1969
1975
|
for opt_name, clause in prompt["optional"].items():
|
|
1970
1976
|
fields[opt_name] = clause.format(**fields) if fields.get(opt_name) else ""
|
|
1971
|
-
|
|
1977
|
+
template = prompt["template"]
|
|
1978
|
+
for flag, flag_template in (prompt.get("flags") or {}).items():
|
|
1979
|
+
# a yes/no argument picks the recipe; every prompt argument arrives as a string, so
|
|
1980
|
+
# "false" is no, and a value that is neither is a caller mistake (ValueError, as above)
|
|
1981
|
+
value = (arguments or {}).get(flag)
|
|
1982
|
+
word = str(value).strip().lower() if value is not None else ""
|
|
1983
|
+
if word in ("true", "1", "yes"):
|
|
1984
|
+
template = flag_template
|
|
1985
|
+
elif word not in ("", "false", "0", "no"):
|
|
1986
|
+
raise ValueError(f"prompt {name!r}: {flag} must be true or false, got {value!r}")
|
|
1987
|
+
text = template.format(**fields)
|
|
1972
1988
|
return {"description": prompt["description"], "messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
|
|
1973
1989
|
|
|
1974
1990
|
|
package/scripts/batch.py
CHANGED
|
@@ -699,10 +699,15 @@ def main() -> int:
|
|
|
699
699
|
created = [d for d in (outdir, work) if not d.exists()]
|
|
700
700
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
701
701
|
work.mkdir(parents=True, exist_ok=True)
|
|
702
|
-
|
|
702
|
+
# a real run that is refused before any item starts (--jobs, an output collision, a final path
|
|
703
|
+
# that is an input) leaves nothing behind either (A187, #465: it left out/ and out/.work)
|
|
704
|
+
item_started = {"item": False}
|
|
705
|
+
if created:
|
|
703
706
|
import atexit
|
|
704
707
|
|
|
705
708
|
def _remove_empty_dirs() -> None:
|
|
709
|
+
if not STATE.dry_run and item_started["item"]:
|
|
710
|
+
return
|
|
706
711
|
for d in sorted(created, key=lambda p: len(str(p)), reverse=True):
|
|
707
712
|
try:
|
|
708
713
|
if not any(d.iterdir()):
|
|
@@ -850,6 +855,7 @@ def main() -> int:
|
|
|
850
855
|
def run_item(i: int, src: Path) -> Dict[str, Any]:
|
|
851
856
|
"""process() in the item's own work directory, which goes when it ends, however it ends."""
|
|
852
857
|
import shutil
|
|
858
|
+
item_started["item"] = True
|
|
853
859
|
iw = item_work(i, src)
|
|
854
860
|
try:
|
|
855
861
|
return process(src, recipe, outdir, iw, deadline)
|
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
|
|
@@ -55,7 +55,7 @@ from _common import asr as _asr
|
|
|
55
55
|
from _common.asr import ENGINE_CHOICES
|
|
56
56
|
from _common.emit import warn
|
|
57
57
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
58
|
-
from _common.runner import carried_stream_maps, subtitle_codec_for, subtitle_container_holds
|
|
58
|
+
from _common.runner import carried_stream_maps, refuse_output_is_input, same_file, subtitle_codec_for, subtitle_container_holds
|
|
59
59
|
from _common.fonts import font_choice, record_font_choice
|
|
60
60
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
61
61
|
write_srt)
|
|
@@ -1586,9 +1586,12 @@ def main() -> int:
|
|
|
1586
1586
|
up_front.append((args.write_ass or os.path.splitext(planned_out)[0] + ".ass", "ASS"))
|
|
1587
1587
|
if args.ass and args.offset and os.path.exists(args.ass):
|
|
1588
1588
|
up_front.append((os.path.splitext(planned_out)[0] + "_offset.ass", "offset ASS"))
|
|
1589
|
-
# -o naming the input
|
|
1590
|
-
|
|
1591
|
-
|
|
1589
|
+
# -o naming the input (the same name, a hard link, another case on a case-insensitive
|
|
1590
|
+
# volume) is refused here, before any sidecar or transcript is written: run()'s own guard
|
|
1591
|
+
# came after the .srt was already beside it (review of #530)
|
|
1592
|
+
if same_file(planned_out, args.input):
|
|
1593
|
+
refuse_output_is_input(planned_out, args.input, why="the captioned video would replace the source")
|
|
1594
|
+
up_front.append((planned_out, "video"))
|
|
1592
1595
|
claim(up_front)
|
|
1593
1596
|
if args.transcribe:
|
|
1594
1597
|
# the sidecar goes next to the output like the --text one, not into the source folder
|
|
@@ -2082,10 +2085,11 @@ def main() -> int:
|
|
|
2082
2085
|
asset = spec[-1]
|
|
2083
2086
|
if asset not in STATE.plan_inputs:
|
|
2084
2087
|
STATE.plan_inputs.append(asset)
|
|
2085
|
-
|
|
2088
|
+
# the conform of a VFR source is the fps filter at the end of the chain (#512)
|
|
2089
|
+
graph = cfr_at_input(";".join([f"[0:v]{vf}[vsub]"] + chains), meta)
|
|
2086
2090
|
cmd += ["-filter_complex", graph, "-map", "[vout]"]
|
|
2087
2091
|
else:
|
|
2088
|
-
cmd += ["-map", "0:v:0", "-vf", vf]
|
|
2092
|
+
cmd += ["-map", "0:v:0", "-vf", with_cfr(vf, meta)]
|
|
2089
2093
|
if meta.get("audio"):
|
|
2090
2094
|
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
2091
2095
|
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
|
|
@@ -207,7 +207,11 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
207
207
|
key_chain = ",".join(vf + ["alphaextract", "scale=in_range=pc:out_range=tv", "format=yuv422p10le"])
|
|
208
208
|
tc = ((meta.get("timecode") or {}).get("value"))
|
|
209
209
|
tc_args = ["-timecode", tc] if tc else []
|
|
210
|
-
|
|
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]"]
|
|
211
215
|
cmd += ["-map", "[fill]"] + probed_maps(meta, video=False, audio=has_audio) + p["video"] + tc_args + cfr_args(meta)
|
|
212
216
|
# the fill is always written to a hidden sibling and moved into place only after the encode
|
|
213
217
|
# succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
|
|
@@ -696,8 +700,23 @@ def main() -> int:
|
|
|
696
700
|
if args.preset != "copy":
|
|
697
701
|
# a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
|
|
698
702
|
# no longer be a copy, and would silently mislabel colour the agent never actually looked at
|
|
703
|
+
rate_args: List[str] = []
|
|
699
704
|
if "-r" not in video:
|
|
700
|
-
|
|
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]
|
|
701
720
|
if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
|
|
702
721
|
cmd += bt709_tag_args(video[video.index("-c:v") + 1])
|
|
703
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/metadata.py
CHANGED
|
@@ -219,8 +219,11 @@ def main() -> int:
|
|
|
219
219
|
args = ap.parse_args()
|
|
220
220
|
apply_common(args)
|
|
221
221
|
if args.auto_chapters: # the only mode that writes them
|
|
222
|
-
|
|
223
|
-
|
|
222
|
+
# against the video this run writes, the default name too (A6, #465: --chapters-out at
|
|
223
|
+
# input_meta.mp4 wrote the chapter text over the video it had just written)
|
|
224
|
+
video_out = args.output or default_output(args.input, "meta", Path(args.input).suffix.lstrip(".") or "mp4")
|
|
225
|
+
guard_sidecar(args.chapters_out, args.input, siblings=(args.description_out, video_out))
|
|
226
|
+
guard_sidecar(args.description_out, args.input, siblings=(video_out,))
|
|
224
227
|
|
|
225
228
|
if args.chapters and args.clear_chapters:
|
|
226
229
|
die("--chapters and --clear-chapters exclude each other")
|