ffmpeg-skill 3.8.9 → 3.8.11
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/README.md +2 -2
- package/docs/contract.md +13 -7
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +37 -14
- package/scripts/_ass_overlay.py +7 -4
- package/scripts/_common/decision.py +19 -0
- package/scripts/_common/emit.py +8 -5
- package/scripts/_common/emoji.py +5 -1
- package/scripts/_common/fonts.py +42 -5
- package/scripts/_common/probe.py +5 -0
- package/scripts/_common/runner.py +42 -1
- package/scripts/_common/wrap.py +60 -18
- package/scripts/_contract.py +3 -3
- package/scripts/background.py +30 -10
- package/scripts/broll.py +8 -2
- package/scripts/caption.py +223 -71
- package/scripts/color.py +8 -5
- package/scripts/crop.py +3 -1
- package/scripts/export.py +16 -7
- package/scripts/freeze.py +6 -3
- package/scripts/graphics.py +3 -1
- package/scripts/grid.py +4 -2
- package/scripts/loop.py +9 -5
- package/scripts/metadata.py +4 -0
- package/scripts/redact.py +14 -2
- package/scripts/render.py +64 -2
- package/scripts/reverse.py +20 -7
- package/scripts/straighten.py +14 -3
package/scripts/graphics.py
CHANGED
|
@@ -564,7 +564,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
564
564
|
rise = int(fs * 0.5)
|
|
565
565
|
pop = f"min(1,(t-{s:.3f})/0.25)"
|
|
566
566
|
xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
|
|
567
|
-
|
|
567
|
+
# every position rises into place, on both routes: a bottom chip started `rise` px above
|
|
568
|
+
# its rest on drawtext and dropped, where the ASS \move below rises (A177)
|
|
569
|
+
ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}+{rise}*(1-{pop})")
|
|
568
570
|
alpha = f"min({pop},{fade_a})"
|
|
569
571
|
align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
|
|
570
572
|
y_rest = m_top if "top" in pos else H - m_bottom
|
package/scripts/grid.py
CHANGED
|
@@ -26,7 +26,7 @@ import os
|
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
28
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
|
|
29
|
-
as_limited, draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
|
|
29
|
+
as_limited, draws_chroma, drawn_chain, drawtext_text_opts, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
|
|
30
30
|
from _common.fonts import font_choice, record_font_choice
|
|
31
31
|
from _common import drawtext_fontfile # noqa: E402
|
|
32
32
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
@@ -173,7 +173,9 @@ def main() -> int:
|
|
|
173
173
|
if args.label == "auto":
|
|
174
174
|
font_file = label_fonts[i]["file"]
|
|
175
175
|
font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
|
|
176
|
-
drawn
|
|
176
|
+
# the textfile= route every other drawn text takes: escape_drawtext() is for a font name,
|
|
177
|
+
# and dropped an apostrophe or a percent sign from a file name's label (A178)
|
|
178
|
+
drawn.append((f"drawtext={drawtext_text_opts(labels[i])}:{font_opt}:fontsize={args.font_size}:"
|
|
177
179
|
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
178
180
|
f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
|
|
179
181
|
gap_r = args.gap if i % args.cols < args.cols - 1 else 0
|
package/scripts/loop.py
CHANGED
|
@@ -34,7 +34,7 @@ import tempfile
|
|
|
34
34
|
|
|
35
35
|
from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
36
36
|
from _common.emit import warn
|
|
37
|
-
from _common.decision import av_trim, length_output_t
|
|
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
|
|
39
39
|
from _common.emit import input_length
|
|
40
40
|
|
|
@@ -60,9 +60,8 @@ def _boomerang_graph(meta, src_dur: float, times) -> str:
|
|
|
60
60
|
f"-- cut the clip shorter first (cut.py)")
|
|
61
61
|
# reverse holds the clip's decoded frames; split, trim and loop pass references to the same
|
|
62
62
|
# frames, so the peak is about one clip (measured: the same max RSS as a plain reverse)
|
|
63
|
-
|
|
64
|
-
held
|
|
65
|
-
if held > 2 * 1024 ** 3:
|
|
63
|
+
held = held_frames_bytes(v, frames)
|
|
64
|
+
if held > HELD_FRAMES_WARN_BYTES:
|
|
66
65
|
warn(f"--boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
|
|
67
66
|
f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight",
|
|
68
67
|
"memory_heavy", bytes=int(held))
|
|
@@ -160,7 +159,12 @@ def _seamless_unit_cmd(meta, src: str, unit: str, rate: str, frames: int, fade_f
|
|
|
160
159
|
if has_audio:
|
|
161
160
|
# acrossfade writes nothing when its first input is exactly the fade long (FFmpeg 6.1 and
|
|
162
161
|
# 7.1, measured), so the fade is built by hand: tail faded out + head faded in, summed
|
|
163
|
-
|
|
162
|
+
# the sound is shifted by the picture's start, the constant setpts=PTS-STARTPTS takes off the
|
|
163
|
+
# picture: rebased to its own start (aresample's first_pts=0 alone), a picture that starts
|
|
164
|
+
# 0.2 s after its sound came out 200 ms behind it in every repeat (A168)
|
|
165
|
+
v0 = stream_starts(meta)[0]
|
|
166
|
+
graph += (f";[0:a:0]atrim=start={v0:.6f},asetpts=PTS-{v0:.6f}/TB,aresample=async=1:first_pts=0,"
|
|
167
|
+
f"apad,atrim=end={length:.6f},asplit=3[at][ah][ab];"
|
|
164
168
|
f"[at]atrim=start={cut:.6f},asetpts=PTS-STARTPTS,afade=t=out:d={fade:.6f}:curve=tri[ta];"
|
|
165
169
|
f"[ah]atrim=end={fade:.6f},asetpts=PTS-STARTPTS,afade=t=in:d={fade:.6f}:curve=tri[ha];"
|
|
166
170
|
f"[ta][ha]amix=inputs=2:normalize=0:duration=longest[xa];"
|
package/scripts/metadata.py
CHANGED
|
@@ -35,6 +35,7 @@ from _common import (guard_sidecar, add_common, apply_common, default_output, de
|
|
|
35
35
|
die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
|
|
36
36
|
STATE, read_text_or_die)
|
|
37
37
|
from _common.emit import input_length
|
|
38
|
+
from _common.runner import carried_stream_maps
|
|
38
39
|
|
|
39
40
|
CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
|
|
40
41
|
TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
|
|
@@ -267,6 +268,9 @@ def main() -> int:
|
|
|
267
268
|
cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "-1"]
|
|
268
269
|
else:
|
|
269
270
|
cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "0"]
|
|
271
|
+
# not the source's MP4/MOV chapter track: the muxer writes one from the chapters it is given,
|
|
272
|
+
# and the copy was a stray data track holding the old titles (A173)
|
|
273
|
+
cmd += carried_stream_maps(args.input, output, attachments=False)
|
|
270
274
|
for key, value in tags.items():
|
|
271
275
|
cmd += ["-metadata", f"{key}={value}"]
|
|
272
276
|
cmd += ["-c", "copy"]
|
package/scripts/redact.py
CHANGED
|
@@ -101,7 +101,14 @@ def main() -> int:
|
|
|
101
101
|
(square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
102
102
|
"-map", "0:v:0"]
|
|
103
103
|
else:
|
|
104
|
-
|
|
104
|
+
# exact=1: crop rounds an odd x/y down to the chroma grid of a 4:2:0 frame otherwise, and
|
|
105
|
+
# overlay always does, so an odd rectangle was taken from and put back one pixel up and
|
|
106
|
+
# left (its last column and row left as they were, one outside it changed: A170). The
|
|
107
|
+
# patch is taken exactly, given a transparent first column/row where x/y is odd, and put
|
|
108
|
+
# at the even position before it: the luma inside the rectangle is replaced, the luma
|
|
109
|
+
# outside is not (the chroma pair on an odd edge is shared, so it is half mixed)
|
|
110
|
+
crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1"
|
|
111
|
+
ox, oy = args.x % 2, args.y % 2
|
|
105
112
|
if args.mode == "blur":
|
|
106
113
|
# boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
|
|
107
114
|
# so they get their own (halved) radius; a 30 px region with the default 20 used to fail
|
|
@@ -110,7 +117,12 @@ def main() -> int:
|
|
|
110
117
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
111
118
|
else:
|
|
112
119
|
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
|
113
|
-
|
|
120
|
+
if ox or oy:
|
|
121
|
+
deep = overlay_format(meta)
|
|
122
|
+
region += (f",format=yuva444p{'10le' if deep else ''},"
|
|
123
|
+
f"pad={args.width + 2 * ox}:{args.height + 2 * oy}:{ox}:{oy}:color=black@0")
|
|
124
|
+
fc = (f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];"
|
|
125
|
+
f"[base][patched]overlay={args.x - ox}:{args.y - oy}{overlay_format(meta)}[out]")
|
|
114
126
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
115
127
|
if has_audio:
|
|
116
128
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
package/scripts/render.py
CHANGED
|
@@ -74,7 +74,7 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
74
74
|
|
|
75
75
|
from export import PRESETS, PLATFORM_OF
|
|
76
76
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
77
|
-
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
77
|
+
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit as _emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
78
78
|
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, owned_identity, read_json, remember_written
|
|
79
79
|
from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
|
|
80
80
|
from _common.decision import rebase_project_paths
|
|
@@ -691,6 +691,59 @@ def check_snap_to(snap: Any, label: str) -> None:
|
|
|
691
691
|
|
|
692
692
|
_LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
|
|
693
693
|
|
|
694
|
+
# source_carry for a render (A255): the one picture/sound source of a project with one, and what
|
|
695
|
+
# each stage that ran said about the start timecode ("kept", "dropped", None: a stage that compared
|
|
696
|
+
# nothing, "unknown": a cache hit whose entry did not keep it). main() resets both.
|
|
697
|
+
PROJECT_SOURCE: Dict[str, Any] = {"src": None, "timecode": []}
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _timecode_verdict(doc: Any) -> Optional[str]:
|
|
701
|
+
"""What a stage's document says about the start timecode: "kept", "dropped", or None when its
|
|
702
|
+
source_carry compared none."""
|
|
703
|
+
carry = doc.get("source_carry") if isinstance(doc, dict) else None
|
|
704
|
+
if not isinstance(carry, dict):
|
|
705
|
+
return None
|
|
706
|
+
return "dropped" if "timecode" in (carry.get("dropped") or []) else "kept" if "timecode" in (carry.get("kept") or []) else None
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def project_source(proj: Dict[str, Any], rel) -> Optional[str]:
|
|
710
|
+
"""The project's single picture/sound source -- its clips all one file, and no music,
|
|
711
|
+
replacement or effects track, no video layer and no audiogram picture beside it -- or None,
|
|
712
|
+
when the output mixes several (source_carry is then not compared, as for any tool with two
|
|
713
|
+
sources)."""
|
|
714
|
+
srcs = {os.path.realpath(rel(c["src"])) for c in proj.get("clips") or [] if isinstance(c, dict) and isinstance(c.get("src"), str)}
|
|
715
|
+
audio = proj.get("audio") if isinstance(proj.get("audio"), dict) else {}
|
|
716
|
+
others = [audio.get(k) for k in ("music", "replace", "effects") if audio.get(k)]
|
|
717
|
+
overlays = proj.get("overlays") if isinstance(proj.get("overlays"), list) else []
|
|
718
|
+
others += [ov.get("video") for ov in overlays if isinstance(ov, dict) and ov.get("video")]
|
|
719
|
+
if proj.get("audiogram"):
|
|
720
|
+
others.append("audiogram") # the picture is the waveform over an image, not the clip's
|
|
721
|
+
return next(iter(srcs)) if len(srcs) == 1 and not others else None
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def emit(output: Optional[str], **extra: Any) -> None:
|
|
725
|
+
"""emit(), with render.py's source_carry: the stages run as children, so this process never
|
|
726
|
+
probed the project's source as an input and its result carried no source_carry at all, even
|
|
727
|
+
for a one-clip render that lost the source's subtitle track (A255). The source is probed here
|
|
728
|
+
(after the stages' intermediates, which are not sources), so emit() compares it with the
|
|
729
|
+
delivered file. A stage that moves the picture's start writes the label of the frame it starts
|
|
730
|
+
on, which is not the source's: the timecode counts as kept when every stage that compared it
|
|
731
|
+
kept it, as dropped when one dropped it, and is compared with the source's own label when no
|
|
732
|
+
stage said (nothing ran, or a cache entry written before stages' source_carry was kept)."""
|
|
733
|
+
src = PROJECT_SOURCE.get("src")
|
|
734
|
+
if src and output and not STATE.dry_run and os.path.isfile(src) and os.path.isfile(output) \
|
|
735
|
+
and os.path.realpath(src) != os.path.realpath(output) and "source_carry" not in extra:
|
|
736
|
+
STATE.first_input, STATE.video_inputs, STATE.audio_inputs = None, set(), set()
|
|
737
|
+
probe(src)
|
|
738
|
+
said = PROJECT_SOURCE["timecode"]
|
|
739
|
+
if "dropped" in said:
|
|
740
|
+
STATE.expected_timecode = ""
|
|
741
|
+
elif "kept" in said and "unknown" not in said:
|
|
742
|
+
STATE.expected_timecode = (probe(output, role="output").get("timecode") or {}).get("value") or ""
|
|
743
|
+
else:
|
|
744
|
+
STATE.expected_timecode = None
|
|
745
|
+
_emit(output, **extra)
|
|
746
|
+
|
|
694
747
|
|
|
695
748
|
def _refuse_uncached_earlier_stage(stage: str) -> None:
|
|
696
749
|
"""--from STAGE promises the earlier stages come from the cache. When one does not, say so
|
|
@@ -783,6 +836,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
783
836
|
code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, error_extra=error_extra, **extra_fields)
|
|
784
837
|
_LAST_DOC.clear()
|
|
785
838
|
_LAST_DOC.update(doc if isinstance(doc, dict) else {})
|
|
839
|
+
PROJECT_SOURCE["timecode"].append(_timecode_verdict(doc))
|
|
786
840
|
absorb_stage(Path(script).stem, doc) # a stage's GPU facts (ran on VideoToolbox, fell back and why) and its diagnostics reach this result
|
|
787
841
|
out_path = str(doc.get("output") or "")
|
|
788
842
|
if not STATE.dry_run:
|
|
@@ -981,6 +1035,10 @@ def cache_lookup(stage: str, key: str, dest: str, tool: Optional[str] = None) ->
|
|
|
981
1035
|
shutil.copy2(art, dest) # ... and a copy where it does not. Never a move: the cache
|
|
982
1036
|
CACHE["saved_seconds"] += float(meta.get("seconds") or 0.0)
|
|
983
1037
|
result = meta.get("result")
|
|
1038
|
+
# what the stage said about the start timecode, for render's source_carry; an entry written
|
|
1039
|
+
# before its source_carry was kept does not say
|
|
1040
|
+
PROJECT_SOURCE["timecode"].append(_timecode_verdict(result) if isinstance(result, dict) and "source_carry" in result
|
|
1041
|
+
else "unknown")
|
|
984
1042
|
if isinstance(result, dict):
|
|
985
1043
|
absorb_stage(tool or stage, dict(result, status="completed", dry_run=False)) # tagged as the stage's tool, as a live run is
|
|
986
1044
|
else:
|
|
@@ -994,7 +1052,9 @@ def _stage_result(doc: Any) -> Dict[str, Any]:
|
|
|
994
1052
|
"""What a cache entry keeps of the stage's own --json document, replayed on a hit."""
|
|
995
1053
|
if not isinstance(doc, dict):
|
|
996
1054
|
return {}
|
|
997
|
-
|
|
1055
|
+
kept = {k: doc[k] for k in ("verified", "verification", "notes", "diagnostics") if k in doc}
|
|
1056
|
+
kept["source_carry"] = doc.get("source_carry") # None too: the stage compared nothing (render's source_carry)
|
|
1057
|
+
return kept
|
|
998
1058
|
|
|
999
1059
|
|
|
1000
1060
|
def cache_store(stage: str, key: str, produced: str, seconds: float, doc: Any = None) -> None:
|
|
@@ -1472,6 +1532,7 @@ def main() -> int:
|
|
|
1472
1532
|
add_hw_orchestrator_args(ap)
|
|
1473
1533
|
args = ap.parse_args()
|
|
1474
1534
|
apply_common(args)
|
|
1535
|
+
PROJECT_SOURCE.update(src=None, timecode=[])
|
|
1475
1536
|
STATE.audit_log = os.path.abspath(args.audit_log) if args.audit_log else None
|
|
1476
1537
|
|
|
1477
1538
|
if args.list_templates:
|
|
@@ -1650,6 +1711,7 @@ def main() -> int:
|
|
|
1650
1711
|
CACHE.clear()
|
|
1651
1712
|
CACHE.update(_fresh_cache())
|
|
1652
1713
|
CONTENT_KEYED.clear()
|
|
1714
|
+
PROJECT_SOURCE.update(src=project_source(proj, rel), timecode=[])
|
|
1653
1715
|
if args.cache:
|
|
1654
1716
|
cdir = Path(args.cache)
|
|
1655
1717
|
try:
|
package/scripts/reverse.py
CHANGED
|
@@ -17,7 +17,8 @@ import sys
|
|
|
17
17
|
|
|
18
18
|
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
|
|
19
19
|
from _common.emit import warn
|
|
20
|
-
from _common.decision import av_clip_length, av_trim, frame_durations, frame_rate,
|
|
20
|
+
from _common.decision import (HELD_FRAMES_WARN_BYTES, av_clip_length, av_trim, delivered_rate, frame_durations, frame_rate,
|
|
21
|
+
held_frames_bytes, stream_starts)
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
def main() -> int:
|
|
@@ -37,10 +38,16 @@ def main() -> int:
|
|
|
37
38
|
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
38
39
|
|
|
39
40
|
output = args.output or default_output(args.input, "reverse")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
# keyed on what is held (every decoded frame), not on the length alone (A174): the estimate
|
|
42
|
+
# loop.py --boomerang warns from, over the frames of the picture's length at its rate
|
|
43
|
+
v = meta["video"]
|
|
44
|
+
vdur = v.get("duration") or meta.get("duration") or 0.0
|
|
45
|
+
frames = v.get("nb_frames") or vdur * (frame_rate(meta) or v.get("fps") or 30.0)
|
|
46
|
+
held = held_frames_bytes(v, frames)
|
|
47
|
+
if held > HELD_FRAMES_WARN_BYTES:
|
|
48
|
+
warn(f"reverse holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
|
|
49
|
+
f"({vdur:.0f}s of {v['width']}x{v['height']}) -- cut the part to reverse first (cut.py) if RAM is tight",
|
|
50
|
+
"memory_heavy", duration=meta.get("duration"), bytes=held)
|
|
44
51
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
45
52
|
if has_audio:
|
|
46
53
|
# Both streams are reversed about one end (#409); each reversed about its own end put a sound
|
|
@@ -55,14 +62,20 @@ def main() -> int:
|
|
|
55
62
|
# setpts: from FFmpeg 7.0 setpts leaves its output's frame rate unknown and its frames'
|
|
56
63
|
# durations 0, so a tpad after it clones nothing (the picture stayed 0.6 s short on 7.1);
|
|
57
64
|
# the closing fps gives the frames their durations back (frame_durations).
|
|
58
|
-
rate
|
|
65
|
+
# the rate the encoder line delivers (cfr_args: a VFR source is conformed to its average
|
|
66
|
+
# rate), so the graph's closing fps and the output's -r are one grid: the graph closed on
|
|
67
|
+
# r_frame_rate and -r then resampled it, 51 frames for 49 that ran 81 ms past the sound (A234)
|
|
68
|
+
# The trim that drops the hold's spare clones stays on the source's own grid (half a frame
|
|
69
|
+
# of r_frame_rate before the end): half a delivered frame cut a real last frame that sits
|
|
70
|
+
# nearer the end than that, and reverse then opened on the one before it.
|
|
71
|
+
rate = delivered_rate(meta) or frame_rate(meta) or meta["video"].get("fps") or 30.0
|
|
59
72
|
length = av_clip_length(meta, rate)
|
|
60
73
|
vlen = meta["video"].get("duration") or length
|
|
61
74
|
end = math.ceil(length * rate - 1e-6) / rate
|
|
62
75
|
vtrim, atrim = av_trim(stream_starts(meta)[0], None)
|
|
63
76
|
cmd += ["-filter_complex",
|
|
64
77
|
f"[0:v:0]tpad=stop_mode=clone:stop_duration={max(0.0, end - vlen) + 2 / rate:.6f},{vtrim},"
|
|
65
|
-
f"trim=end={end - 0.5 / rate:.6f},reverse,{frame_durations(meta, rate)}[v];"
|
|
78
|
+
f"trim=end={end - 0.5 / max(rate, frame_rate(meta) or rate):.6f},reverse,{frame_durations(meta, rate)}[v];"
|
|
66
79
|
f"[0:a:0]{atrim},apad=whole_dur={end:.6f},atrim=end={end:.6f},areverse,asetpts=N/SR/TB[a]",
|
|
67
80
|
"-map", "[v]", "-map", "[a]"]
|
|
68
81
|
else:
|
package/scripts/straighten.py
CHANGED
|
@@ -80,9 +80,20 @@ def main() -> int:
|
|
|
80
80
|
dw, dh = display_size(meta["video"])
|
|
81
81
|
aspect = max(dw / dh, dh / dw) if dw and dh else 1.0
|
|
82
82
|
s = abs(math.cos(radians)) + aspect * abs(math.sin(radians))
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
83
|
+
# The window is the displayed frame's own size, to even (4:2:0 chroma), and the scaled frame
|
|
84
|
+
# is rounded UP to even so it still covers it. Scaling to iw*s and cropping back to
|
|
85
|
+
# trunc(iw/s/2)*2 lost to two roundings: 1920x1080 came out 1918x1078, and the scale's
|
|
86
|
+
# rounding left a non-square SAR such as 6112:6111 (A235).
|
|
87
|
+
cw, ch = (dw // 2 * 2, dh // 2 * 2) if dw and dh else (0, 0)
|
|
88
|
+
if cw and ch:
|
|
89
|
+
# the margin on each axis is a multiple of 4, so the centred crop's offset is even:
|
|
90
|
+
# crop floors an odd one to the chroma grid, which moved the window a pixel up and
|
|
91
|
+
# left and could bring the fill into the top-left corner
|
|
92
|
+
sw, sh = (c + math.ceil(max(0.0, c * s - c) / 4) * 4 for c in (cw, ch))
|
|
93
|
+
rotate = (f"scale={sw}:{sh},setsar=1,"
|
|
94
|
+
f"rotate={radians:.8f},crop={cw}:{ch},setsar=1")
|
|
95
|
+
else: # a dry run's pending input has no size yet: the old expression, planned only
|
|
96
|
+
rotate = f"scale=iw*{s:.8f}:ih*{s:.8f},rotate={radians:.8f},crop=trunc(iw/{s:.8f}/2)*2:trunc(ih/{s:.8f}/2)*2"
|
|
86
97
|
|
|
87
98
|
# a rotation of non-square pixels is a shear on screen: the picture is squared at its
|
|
88
99
|
# display aspect first (#411)
|