ffmpeg-skill 3.8.21 → 3.8.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +9 -3
- package/scripts/_common/decision.py +62 -2
- package/scripts/_common/runner.py +27 -1
- package/scripts/_contract.py +3 -2
- package/scripts/audio.py +4 -2
- package/scripts/crop.py +7 -7
- package/scripts/cut.py +39 -3
- package/scripts/fit.py +12 -9
- package/scripts/freeze.py +34 -5
- package/scripts/overlay.py +14 -4
- package/scripts/reverse.py +15 -3
- package/scripts/speedramp.py +12 -9
- package/scripts/stabilize.py +8 -7
package/docs/contract.md
CHANGED
|
@@ -26,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
26
26
|
| Field | Meaning | Changes when |
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
29
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
29
|
+
| `skill.version` | the npm / package.json version (`3.8.23`) | any release |
|
|
30
30
|
|
|
31
31
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
32
32
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -154,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
154
154
|
"deprecated": [],
|
|
155
155
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
156
156
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
157
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
157
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.23", "execution_mode": "local", "kind": "execution",
|
|
158
158
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
159
159
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
160
160
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.23",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/references/scripts.md
CHANGED
|
@@ -175,7 +175,9 @@ the next source frame and may keep up to one frame of timestamp. A `.mp4`/`.mov`
|
|
|
175
175
|
edit list hides the priming. A source whose audio starts after its picture keeps
|
|
176
176
|
that offset. An audio-only `--accurate` cut
|
|
177
177
|
still uses `make_zero`; that priming is what `precision: codec_frame` counts
|
|
178
|
-
in the length
|
|
178
|
+
in the length, and it pads a sound that starts after `--start` with silence. A stream copy to an audio output
|
|
179
|
+
cannot: it starts at the sound's first packet, and an `audio_lead_dropped` warning (`{seconds,
|
|
180
|
+
segments}`) says how much silence it left out (from one frame of the picture on). Since 3.0.0 `keyframe_snapped` equals `start_snapped` (2.x: true
|
|
179
181
|
for every stream copy, `precision` `packet`); `start_snapped` is measured: true when a copied picture starts more
|
|
180
182
|
than a frame from `--start`, or when its start could not be measured (an unknown start is
|
|
181
183
|
never claimed exact); `null` under `--dry-run`.
|
|
@@ -344,7 +346,10 @@ the rectangle for you. Use this when the rectangle is already known (a
|
|
|
344
346
|
face-detection box, a saved crop, a hand-picked region). The rectangle must
|
|
345
347
|
lie entirely inside the source frame (after accounting for display rotation);
|
|
346
348
|
`--width`/`--height` must be even (4:2:0 chroma) and are refused, never
|
|
347
|
-
rounded, if they aren't; an odd `--x`/`--y` is taken as given.
|
|
349
|
+
rounded, if they aren't; an odd `--x`/`--y` is taken as given. The source's
|
|
350
|
+
subtitle track is kept (converted to mov_text in MP4/MOV, SubRip in Matroska);
|
|
351
|
+
`dropped_non_av_streams: true` says it was dropped: the container refused it, or
|
|
352
|
+
refused a data stream beside it (a timecode track), and the retry drops both.
|
|
348
353
|
|
|
349
354
|
### cropdetect.py — measure black bars, report the crop rectangle
|
|
350
355
|
```
|
|
@@ -515,7 +520,8 @@ default, keeps the original framing and lets the edges show: vidstab's own
|
|
|
515
520
|
"optimal zoom" is off). Needs an ffmpeg built with
|
|
516
521
|
`--enable-libvidstab`; `doctor` reports this tool `usable: no` when that's
|
|
517
522
|
missing (a plain Homebrew ffmpeg build, for example) rather than failing at
|
|
518
|
-
run time.
|
|
523
|
+
run time. The frames keep their times, so the source's subtitle track is kept
|
|
524
|
+
as `crop.py` keeps it (`dropped_non_av_streams` says when it could not be).
|
|
519
525
|
|
|
520
526
|
### sequence.py — numbered/globbed image sequence to video
|
|
521
527
|
```
|
|
@@ -711,20 +711,80 @@ def graph_with_cfr(graph: str, label: str, meta: Optional[Dict[str, Any]],
|
|
|
711
711
|
return f"{graph};[{bare}]{f}[{bare}cfr]", f"[{bare}cfr]"
|
|
712
712
|
|
|
713
713
|
|
|
714
|
-
def cfr_at_input(graph: str, meta: Optional[Dict[str, Any]], fps: Optional[float] = None, src: str = "[0:v]"
|
|
714
|
+
def cfr_at_input(graph: str, meta: Optional[Dict[str, Any]], fps: Optional[float] = None, src: str = "[0:v]",
|
|
715
|
+
origin: Optional[float] = None) -> str:
|
|
715
716
|
"""A -filter_complex graph with cfr_filter() on its main picture as it is decoded (`src`, which
|
|
716
717
|
the graph reads once), for a graph that composites onto that picture and moves no frame in
|
|
717
718
|
time. After an overlay the conform comes too late: FFmpeg 6.1's overlay ends its output at its
|
|
718
|
-
last frame's start, so an fps filter there lost the last frame (3.963 s of a 4 s picture).
|
|
719
|
+
last frame's start, so an fps filter there lost the last frame (3.963 s of a 4 s picture).
|
|
720
|
+
With `origin` (where the picture starts on the graph's clock, stream_starts()) the picture is
|
|
721
|
+
moved to start at 0 ahead of the conform, so the grid is laid from its first frame, and the
|
|
722
|
+
conformed frames get a microsecond time base, for a graph that cuts the picture on that grid
|
|
723
|
+
(freeze.py, #523): the caller counts the picture's times from its own start. The fps
|
|
724
|
+
filter's grid starts at the clock's 0, so a picture that starts 50 ms in was cut a frame off
|
|
725
|
+
the times computed from its start, and its output's time base is one frame, to which `trim`
|
|
726
|
+
rescales its bound (a half-frame bound written to the microsecond, 3.907407 at 27 fps, became
|
|
727
|
+
the frame at 3.888889 and cut it). The picture is not moved back after the conform: from
|
|
728
|
+
FFmpeg 7.0 setpts leaves its output frames no duration and no rate, and a tpad after it (the
|
|
729
|
+
hold) cloned nothing: 109 frames for 135 on 7.1."""
|
|
719
730
|
f = cfr_filter(meta, fps)
|
|
720
731
|
if not f:
|
|
721
732
|
return graph
|
|
722
733
|
if graph.count(src) != 1:
|
|
723
734
|
# a graph that read the picture twice, or not at all, would go unconformed or not parse
|
|
724
735
|
raise ValueError(f"cfr_at_input: the graph reads {src} {graph.count(src)} times, not once")
|
|
736
|
+
if origin is not None:
|
|
737
|
+
f = f"setpts=PTS-{origin:.6f}/TB,{f},settb=AVTB" if origin else f"{f},settb=AVTB"
|
|
725
738
|
return f"{src}{f}[cfrin];" + graph.replace(src, "[cfrin]")
|
|
726
739
|
|
|
727
740
|
|
|
741
|
+
def atempo_lead(sample_rate: Any) -> int:
|
|
742
|
+
"""How many samples of its input `atempo` leaves out at the start: half its window, which is
|
|
743
|
+
the power of two at or above sample_rate / 24 (2048 at 44.1 and 48 kHz, so 1024 samples, 21 ms
|
|
744
|
+
at 48 kHz). Its output opens on the sample half a window in (measured at 8, 22.05, 32, 44.1,
|
|
745
|
+
48 and 96 kHz on FFmpeg 6.1.1), so every sound after it is that much early: speedramp.py's beeps
|
|
746
|
+
came 21 ms early at 1x and 40 ms at 0.5x, and fit.py --method speed's the same (#523). 0 when
|
|
747
|
+
the rate is not known (a dry run's pending input)."""
|
|
748
|
+
try:
|
|
749
|
+
rate = int(sample_rate or 0)
|
|
750
|
+
except (TypeError, ValueError):
|
|
751
|
+
return 0
|
|
752
|
+
window = rate // 24
|
|
753
|
+
if window < 1:
|
|
754
|
+
return 0
|
|
755
|
+
pot = 1 << (window.bit_length() - 1)
|
|
756
|
+
return (pot * 2 if pot < window else pot) // 2
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def atempo_chain_with_lead(stages: List[str], sample_rate: Any) -> str:
|
|
760
|
+
"""`atempo` stages (each value as written) led by silence and trimmed after, so the output
|
|
761
|
+
keeps the input's timing from its first sample: a beep at 0.5 s of a 1x stage came out at
|
|
762
|
+
0.479 s (FFmpeg 6.1.1). Each stage leaves out atempo_lead() samples of its own input, which
|
|
763
|
+
are the earlier stages' tempo product times that many of the chain's input, so one lead of
|
|
764
|
+
their sum goes in front of the first stage: an `adelay` between two stages made FFmpeg 6.1.1
|
|
765
|
+
fail a graph that joins the result with `concat` ("Invalid data found when processing
|
|
766
|
+
input", speedramp.py with a 0.25x segment). That lead alone put the chain's first window,
|
|
767
|
+
which atempo fades in, over the input's first 21 ms: a 10 ms attack there came out 7 dB down
|
|
768
|
+
at 1x and was gone at 2x (#541 review). So one whole window more of silence goes in front
|
|
769
|
+
(as many of the chain's input samples as leave a window of it ahead of every stage), and
|
|
770
|
+
that much of the output, at the chain's tempo, is cut off again and its timestamps moved
|
|
771
|
+
back by it, so a track that starts after 0 keeps its start. The bare stages when the rate is
|
|
772
|
+
not known (a dry run's pending input)."""
|
|
773
|
+
lead = atempo_lead(sample_rate)
|
|
774
|
+
chain = ",".join(f"atempo={value}" for value in stages)
|
|
775
|
+
if not lead:
|
|
776
|
+
return chain
|
|
777
|
+
total, product, widest = 0.0, 1.0, 1.0
|
|
778
|
+
for value in stages:
|
|
779
|
+
total += lead * product
|
|
780
|
+
product *= float(value)
|
|
781
|
+
widest = max(widest, product)
|
|
782
|
+
extra = round(2 * lead * widest) # a window of silence ahead of the stage that shrinks it most
|
|
783
|
+
cut = round(extra / product)
|
|
784
|
+
return (f"adelay={round(total) + extra}S:all=1,{chain},"
|
|
785
|
+
f"atrim=start_sample={cut},asetpts=PTS-{cut}/SR/TB")
|
|
786
|
+
|
|
787
|
+
|
|
728
788
|
def delivered_rate(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Optional[float]:
|
|
729
789
|
"""The constant rate cfr_args() forces on the output (its -r), or None when it forces none and
|
|
730
790
|
the output keeps the source's rate -- what a start timecode is counted at
|
|
@@ -2813,6 +2813,30 @@ def default_selection_maps(meta: Optional[Dict[str, Any]], dst: str) -> List[str
|
|
|
2813
2813
|
return maps
|
|
2814
2814
|
|
|
2815
2815
|
|
|
2816
|
+
def subtitle_codec_args(src: Optional[str], output: str) -> List[str]:
|
|
2817
|
+
"""`-c:s` for a command that maps every subtitle stream of `src` (`-map 0:s?`) into `output`:
|
|
2818
|
+
copy, and for each text track in a format the container does not take as it is the container's
|
|
2819
|
+
own (subtitle_codec_for: mov_text in MP4/MOV, SubRip in Matroska for mov_text, WebVTT in WebM),
|
|
2820
|
+
as export.py and a crop of several tracks convert it. A plain copy of SubRip into MP4 failed,
|
|
2821
|
+
and the retry dropped the track (#520). Copy alone when the source cannot be read here."""
|
|
2822
|
+
args = ["-c:s", "copy"]
|
|
2823
|
+
if not src or not os.path.isfile(src):
|
|
2824
|
+
return args
|
|
2825
|
+
try:
|
|
2826
|
+
out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-select_streams", "s", "-show_entries",
|
|
2827
|
+
"stream=codec_name", "-of", "csv=p=0", *still_input_args(src), src],
|
|
2828
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
|
|
2829
|
+
errors="replace", timeout=_probe_limit()).stdout
|
|
2830
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
2831
|
+
return args
|
|
2832
|
+
ext = os.path.splitext(output)[1]
|
|
2833
|
+
for n, codec in enumerate(ln.strip() for ln in (out or "").splitlines() if ln.strip()):
|
|
2834
|
+
conv = subtitle_codec_for(codec, ext)
|
|
2835
|
+
if conv != "copy":
|
|
2836
|
+
args += [f"-c:s:{n}", conv]
|
|
2837
|
+
return args
|
|
2838
|
+
|
|
2839
|
+
|
|
2816
2840
|
def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool = False) -> bool:
|
|
2817
2841
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
2818
2842
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
@@ -2821,6 +2845,8 @@ def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool =
|
|
|
2821
2845
|
extra maps rather than let a tool that never touched subtitles start hard-failing because of
|
|
2822
2846
|
them. `cmd` is the full argv *without* the output path. Returns True only when the
|
|
2823
2847
|
retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped).
|
|
2848
|
+
A text track the container takes in another format is converted (subtitle_codec_args), so the
|
|
2849
|
+
retry is left for a track no conversion fits (a bitmap track in MPEG-4) or a refused data stream.
|
|
2824
2850
|
|
|
2825
2851
|
The retry is recorded with the first attempt's error (`diagnostics.fallbacks`,
|
|
2826
2852
|
`non_av_streams_dropped`): the caller's `dropped_non_av_streams` says that streams went, the
|
|
@@ -2833,7 +2859,7 @@ def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool =
|
|
|
2833
2859
|
skip_tmcd = ["-map", "-0:d:m:timecode"] if relabel_timecode else []
|
|
2834
2860
|
src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
|
|
2835
2861
|
first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + carried_stream_maps(src, output)
|
|
2836
|
-
+
|
|
2862
|
+
+ subtitle_codec_args(src, output) + ["-c:d", "copy", output], check=False)
|
|
2837
2863
|
if first.returncode == 0:
|
|
2838
2864
|
return False
|
|
2839
2865
|
if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):
|
package/scripts/_contract.py
CHANGED
|
@@ -495,6 +495,7 @@ DIAGNOSTIC_CODES = {
|
|
|
495
495
|
"hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
|
|
496
496
|
"source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture, surround channels, attached files or a second video stream the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
|
|
497
497
|
"audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
|
|
498
|
+
"audio_lead_dropped": ("warnings", "cut.py: an audio-only stream copy of a source whose sound starts after --start (by at least one frame of its picture) begins at the sound's first packet, so that much silence is left out and the extraction is early against the picture; kept as a copy within --tolerance, as a start that snaps is (--accurate pads it); a dry run reports it from the source, also where a run may re-encode instead (length_unjudged); {seconds: the total, segments: [{index, seconds}]}"),
|
|
498
499
|
"grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
|
|
499
500
|
"scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
|
|
500
501
|
"look_no_drawtext": ("fallbacks", "look.py on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
|
|
@@ -616,9 +617,9 @@ FONT_USED_SCHEMA = {
|
|
|
616
617
|
|
|
617
618
|
|
|
618
619
|
# the tools whose --json carries `dropped_non_av_streams` (a test reads the sources)
|
|
619
|
-
NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "cut", "deinterlace", "denoise", "export", "fit", "freeze",
|
|
620
|
+
NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "crop", "cut", "deinterlace", "denoise", "export", "fit", "freeze",
|
|
620
621
|
"graphics", "join", "loudness", "overlay", "pad", "proxy", "redact", "speedramp",
|
|
621
|
-
"sphere", "straighten"})
|
|
622
|
+
"sphere", "stabilize", "straighten"})
|
|
622
623
|
|
|
623
624
|
|
|
624
625
|
# Top-level keys a tool emits that its own branch in output_schema() does not list (#466 A194: the
|
package/scripts/audio.py
CHANGED
|
@@ -30,7 +30,7 @@ from typing import Any, Dict, List
|
|
|
30
30
|
from _common.probe import SILENT_PEAK_DBFS
|
|
31
31
|
from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
|
|
32
32
|
from _common.emit import file_origin, stream_end, warn
|
|
33
|
-
from _common.decision import LOSSLESS_AUDIO_EXT, conform_filter, conform_to_length
|
|
33
|
+
from _common.decision import LOSSLESS_AUDIO_EXT, atempo_chain_with_lead, conform_filter, conform_to_length
|
|
34
34
|
|
|
35
35
|
# --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
|
|
36
36
|
DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
|
|
@@ -559,7 +559,9 @@ def main() -> int:
|
|
|
559
559
|
fx.append(f"extrastereo=m={1 + 2 * args.stereo_widen:g}")
|
|
560
560
|
tempo_steps = atempo_steps(args.tempo) if args.tempo is not None and args.tempo != 1.0 else []
|
|
561
561
|
if tempo_steps:
|
|
562
|
-
|
|
562
|
+
# led by the samples atempo leaves out, at the rate the track has there (the source's:
|
|
563
|
+
# --sample-rate resamples after the chain), or it opened 21 ms in (#523)
|
|
564
|
+
fx.append(atempo_chain_with_lead([f"{f:.10g}" for f in tempo_steps], main_track_rate(args, meta)))
|
|
563
565
|
# the main track's length in the output: the source's, or the source's / --tempo on an audio
|
|
564
566
|
# output of the input's own sound (beside a picture, or for --replace, it is the picture's)
|
|
565
567
|
main_dur = dur / args.tempo if dur and tempo_steps and not (keep_video or args.replace) else dur
|
package/scripts/crop.py
CHANGED
|
@@ -22,9 +22,8 @@ Examples:
|
|
|
22
22
|
import argparse
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe,
|
|
26
|
-
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
27
|
-
from _common.runner import default_selection_maps
|
|
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
|
+
from _common.probe import display_size, probed_maps, square_pixels, square_pixels_note
|
|
28
27
|
from _common.emit import note
|
|
29
28
|
|
|
30
29
|
|
|
@@ -70,20 +69,21 @@ def main() -> int:
|
|
|
70
69
|
vf.insert(0, square_pixels(meta["video"]))
|
|
71
70
|
note(square_pixels_note(meta["video"]))
|
|
72
71
|
# the picture the rectangle was checked against, not the largest one default selection takes
|
|
73
|
-
cmd = ffmpeg_base() + ["-i", args.input] +
|
|
72
|
+
cmd = ffmpeg_base() + ["-i", args.input] + probed_maps(meta) + ["-vf", with_cfr(",".join(vf), meta, args.fps)]
|
|
74
73
|
cmd += video_args(meta, args.crf, args.preset)
|
|
75
74
|
cmd += cfr_args(meta, args.fps)
|
|
76
75
|
if has_audio:
|
|
77
76
|
cmd += aac_args()
|
|
78
77
|
else:
|
|
79
78
|
cmd += ["-an"]
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
# the crop keeps the timeline, so the subtitle/data tracks are copied as fit.py and color.py copy
|
|
80
|
+
# them, and dropped (said) only where the container refuses them (#520)
|
|
81
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
82
82
|
|
|
83
83
|
result = probe(output, role="output")
|
|
84
84
|
v = result["video"]
|
|
85
85
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']})")
|
|
86
|
-
emit(output)
|
|
86
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
87
87
|
return 0
|
|
88
88
|
|
|
89
89
|
|
package/scripts/cut.py
CHANGED
|
@@ -449,6 +449,19 @@ def first_frame_from(src: str, meta: dict, start: float, reencode: bool, edit_li
|
|
|
449
449
|
return key[0] - v0
|
|
450
450
|
|
|
451
451
|
|
|
452
|
+
def copied_lead(meta: dict, start: float, end: float) -> Optional[float]:
|
|
453
|
+
"""The seconds of a sound's late start that an audio-only stream copy of start..end leaves out
|
|
454
|
+
(#515): the copy begins at the first packet, so a sound that starts after `start` on the file's
|
|
455
|
+
clock (stream_starts) comes out that much early. None when it is less than one frame of the
|
|
456
|
+
source's picture (1 ms without one), which no picture could show, or when the start is unknown."""
|
|
457
|
+
audio = meta.get("audio") or {}
|
|
458
|
+
if not isinstance(audio.get("start_time"), (int, float)):
|
|
459
|
+
return None
|
|
460
|
+
lead = min(stream_starts(meta)[1], end) - start
|
|
461
|
+
fps = (meta.get("video") or {}).get("fps")
|
|
462
|
+
return round(lead, 6) if lead >= (1.0 / float(fps) if fps else 0.001) else None
|
|
463
|
+
|
|
464
|
+
|
|
452
465
|
def av_skew(out_meta: dict, edit_list_remedy: bool = False, expected: float = 0.0):
|
|
453
466
|
"""(seconds audio starts after video, warning note or None); None when either is missing.
|
|
454
467
|
A lossless cut can start its streams apart -- Core Media HEVC once gave 3.7 s of sound with no
|
|
@@ -1044,11 +1057,25 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
1044
1057
|
info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
|
|
1045
1058
|
f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy{alt}")
|
|
1046
1059
|
return _with_notes(cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["tolerance"], edit_list_ok), notes)
|
|
1047
|
-
if
|
|
1060
|
+
if audio_only and not reencode:
|
|
1048
1061
|
# an audio-only copy is judged by its written length too (above): a dry run that wrote
|
|
1049
1062
|
# nothing says a real run may still re-encode it (#510 review)
|
|
1050
|
-
pending = {"length_unjudged": True} if (STATE.dry_run and
|
|
1051
|
-
|
|
1063
|
+
pending = {"length_unjudged": True} if (STATE.dry_run and copy_t is None and tolerance >= 0) else {}
|
|
1064
|
+
lead = copied_lead(meta, start, end)
|
|
1065
|
+
if lead:
|
|
1066
|
+
# a copy cannot add silence: the sound starts at its first packet, `lead` earlier than it
|
|
1067
|
+
# sat after `start` (#515). Kept within --tolerance, as a start that snapped is, and said.
|
|
1068
|
+
# A dry run says it for a lead of --tolerance or more too: the run judges the written
|
|
1069
|
+
# length, which is short by up to a packet less than the lead (0.351 s for a lead of
|
|
1070
|
+
# 0.355), so it may keep that copy; it re-encodes and pads it otherwise (#542 review)
|
|
1071
|
+
unsure = STATE.dry_run and 0 <= tolerance <= lead
|
|
1072
|
+
notes.append(f"the sound starts {lead:.3f}s after {start:.3f}s in the source; a stream copy cannot "
|
|
1073
|
+
f"add silence, so the extraction starts with the sound, {lead:.3f}s early against the "
|
|
1074
|
+
"picture (--accurate pads it from the start"
|
|
1075
|
+
+ ("; a run re-encodes and pads it instead when the copy comes out --tolerance or more short)" if unsure else ")"))
|
|
1076
|
+
return _outcome(meta, dst, False, reasons, notes=notes, audio_lead_dropped_seconds=lead or None, **pending)
|
|
1077
|
+
if reencode:
|
|
1078
|
+
return _outcome(meta, dst, reencode, reasons)
|
|
1052
1079
|
if STATE.dry_run:
|
|
1053
1080
|
# the planned copy: whether it keeps an edit list is known; where it lands is not, so a
|
|
1054
1081
|
# real run that judges the written copy by its length may still re-encode it (#510)
|
|
@@ -1962,6 +1989,15 @@ def main() -> int:
|
|
|
1962
1989
|
f"(cut writes one audio track, on a stream copy as on a re-encode)")
|
|
1963
1990
|
warn(msg, "audio_tracks_dropped", source=len(src_audio), kept=kept, dropped=dropped_audio)
|
|
1964
1991
|
notes.append(msg)
|
|
1992
|
+
# a copied audio extraction that starts with the sound, not where it sat after --start (#515); an
|
|
1993
|
+
# outcome a re-cut replaced carries none, so this is what the written file holds
|
|
1994
|
+
leads = [(i + 1, o["audio_lead_dropped_seconds"]) for i, o in enumerate(outcomes) if o.get("audio_lead_dropped_seconds")]
|
|
1995
|
+
if leads:
|
|
1996
|
+
where = (f" (segment{'s' if len(leads) > 1 else ''} {', '.join(str(i) for i, _ in leads)})"
|
|
1997
|
+
if len(outcomes) > 1 else "")
|
|
1998
|
+
warn(f"the stream copy left out {sum(s for _, s in leads):.3f}s of silence before the sound{where}, so the "
|
|
1999
|
+
"sound is early against the source's picture; --accurate keeps it", "audio_lead_dropped",
|
|
2000
|
+
seconds=round(sum(s for _, s in leads), 6), segments=[{"index": i, "seconds": s} for i, s in leads])
|
|
1965
2001
|
emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
|
|
1966
2002
|
expect_streams=expect_streams, stream_reference=[meta], stream_windows=[(s, e) for s, e in requested],
|
|
1967
2003
|
dropped_non_av_streams=bool(DROPPED_STREAMS), dropped_audio_streams=max(dropped_audio, 0),
|
package/scripts/fit.py
CHANGED
|
@@ -36,9 +36,9 @@ Examples:
|
|
|
36
36
|
import argparse
|
|
37
37
|
import sys
|
|
38
38
|
from fractions import Fraction
|
|
39
|
-
from typing import List
|
|
39
|
+
from typing import Any, List
|
|
40
40
|
|
|
41
|
-
from _common.decision import SEEK_MARGIN, delivered_rate, frame_durations, frame_rate, grid_at_or_after, length_output_t, stream_starts
|
|
41
|
+
from _common.decision import SEEK_MARGIN, atempo_chain_with_lead, 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
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
|
|
@@ -53,18 +53,21 @@ def parse_aspect(value: str) -> Fraction:
|
|
|
53
53
|
return ratio
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def atempo_chain(factor: float) -> str:
|
|
57
|
-
"""atempo accepts 0.5..100 per instance; chain for extreme factors.
|
|
56
|
+
def atempo_chain(factor: float, sample_rate: Any = 0) -> str:
|
|
57
|
+
"""atempo accepts 0.5..100 per instance; chain for extreme factors. The chain is led by the
|
|
58
|
+
samples atempo leaves out (atempo_chain_with_lead, at the track's `sample_rate`), so the
|
|
59
|
+
retimed sound keeps its timing against the retimed picture (#523: about 21 ms early in input
|
|
60
|
+
time without them)."""
|
|
58
61
|
parts: List[str] = []
|
|
59
62
|
remaining = factor
|
|
60
63
|
while remaining < 0.5:
|
|
61
|
-
parts.append("
|
|
64
|
+
parts.append("0.5")
|
|
62
65
|
remaining /= 0.5
|
|
63
66
|
while remaining > 100.0:
|
|
64
|
-
parts.append("
|
|
67
|
+
parts.append("100.0")
|
|
65
68
|
remaining /= 100.0
|
|
66
|
-
parts.append(f"
|
|
67
|
-
return
|
|
69
|
+
parts.append(f"{remaining:.6f}")
|
|
70
|
+
return atempo_chain_with_lead(parts, sample_rate)
|
|
68
71
|
|
|
69
72
|
|
|
70
73
|
def picture_output_t(target: float, meta: dict) -> str:
|
|
@@ -218,7 +221,7 @@ def main() -> int:
|
|
|
218
221
|
vf.append(frame_durations(meta, delivered_rate(meta) or frame_rate(meta) or src_fps))
|
|
219
222
|
regridded = True
|
|
220
223
|
if has_audio:
|
|
221
|
-
af.append(atempo_chain(factor))
|
|
224
|
+
af.append(atempo_chain(factor, rate))
|
|
222
225
|
# atempo's sound is not the input's length over the factor (it comes out some
|
|
223
226
|
# milliseconds short or long), and the millisecond -t stays on a retimed track: padding
|
|
224
227
|
# it to the target's samples (apad after atempo in -af) never ended on FFmpeg 8.0.1
|
package/scripts/freeze.py
CHANGED
|
@@ -18,8 +18,9 @@ Examples:
|
|
|
18
18
|
"""
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
|
+
from fractions import Fraction
|
|
21
22
|
|
|
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.decision import av_trim, cfr_at_input, cfr_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
|
|
23
24
|
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
25
|
from _common.emit import input_length
|
|
25
26
|
|
|
@@ -63,6 +64,15 @@ def main() -> int:
|
|
|
63
64
|
# starts 0.1 s late 0.1 s early (147 frames for 150).
|
|
64
65
|
v0 = stream_starts(meta)[0]
|
|
65
66
|
rate = frame_rate(meta) or fps
|
|
67
|
+
if cfr_filter(meta):
|
|
68
|
+
# A VFR source is put on the delivered grid as it is decoded (the graph's cfr_at_input
|
|
69
|
+
# below), and every frame time is on that grid (#523): on the source's own frames the
|
|
70
|
+
# head ended at its last frame's nominal duration, so with --at inside a gap the hold, and
|
|
71
|
+
# everything after it, started the rest of the gap early against the sound, which is cut
|
|
72
|
+
# at --at (56 ms at --at 0.15, 93 ms at --at 2.2 on a 27 fps average clip with gaps from
|
|
73
|
+
# 0.1 and 2.1 s). A hold is then counted in frames of that rate, the rate its frames are
|
|
74
|
+
# cloned at.
|
|
75
|
+
rate = delivered_rate(meta) or rate
|
|
66
76
|
cut_at = grid_at_or_after(at, v0, rate)
|
|
67
77
|
# no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
|
|
68
78
|
# and the hold lost, so hold the last frame as --mode extend does (#416 review)
|
|
@@ -98,8 +108,11 @@ def main() -> int:
|
|
|
98
108
|
# the video actually holds for, a permanent A/V drift from this point on. Pad audio by
|
|
99
109
|
# the same, frame-rounded duration the video actually gets.
|
|
100
110
|
actual_hold = n / rate
|
|
101
|
-
|
|
102
|
-
|
|
111
|
+
# a conformed VFR picture starts at 0 on the graph's clock (cfr_at_input's origin), so its
|
|
112
|
+
# trims are counted from its own start; the sound's stay on the input's clock
|
|
113
|
+
vo = v0 if cfr_filter(meta) else 0.0
|
|
114
|
+
head_v, head_a = av_trim(v0 - vo, cut_at - vo, rate)[0], av_trim(v0, cut_at, rate)[1]
|
|
115
|
+
tail_v, tail_a = av_trim(cut_at - vo, None, rate)[0], av_trim(cut_at, None, rate)[1]
|
|
103
116
|
# The hold is its own segment: the frame at cut_at, cut on the input's clock and cloned to
|
|
104
117
|
# n frames there (tpad's stop clone), then shifted like the tail. From FFmpeg 7.0 setpts
|
|
105
118
|
# leaves its output's frame rate unknown and its frames' durations 0, so the earlier tpad
|
|
@@ -114,11 +127,27 @@ def main() -> int:
|
|
|
114
127
|
half = 0.5 / rate
|
|
115
128
|
# the graph closes on the rate the encoder line delivers (a VFR source is conformed to its
|
|
116
129
|
# average): closed on r_frame_rate, -r then resampled it and the picture ran late (#512)
|
|
117
|
-
hold_v = (f"trim=start={cut_at - half:.6f},trim=end_frame=1,"
|
|
130
|
+
hold_v = (f"trim=start={cut_at - vo - half:.6f},trim=end_frame=1,"
|
|
118
131
|
f"tpad=stop_mode=clone:stop={n - 1},setpts=PTS-STARTPTS")
|
|
132
|
+
# Every segment is a whole count of frames on one grid, so the joined picture's timestamps
|
|
133
|
+
# are rebuilt from its running frame count before the closing fps: FFmpeg 6.1.1's video
|
|
134
|
+
# concat gives a one-frame segment no length (it ends a segment at its last frame's
|
|
135
|
+
# timestamp), so a hold of one frame (--hold 0.04 at 30 fps) was dropped and the rest of
|
|
136
|
+
# the picture ran a frame before its sound, as speedramp.py's one-frame segments did
|
|
137
|
+
grid = frame_durations(meta, delivered_rate(meta) or rate)
|
|
138
|
+
q = Fraction(grid[len("fps="):]) if "/" in grid else Fraction(delivered_rate(meta) or rate).limit_denominator(1001)
|
|
119
139
|
vf = (f"[0:v]split=3[a][b][c];[a]{head_v}[head];"
|
|
120
140
|
f"[b]{hold_v}[hold];[c]{tail_v}[tail];"
|
|
121
|
-
f"[head][hold][tail]concat=n=3:v=1:a=0,{
|
|
141
|
+
f"[head][hold][tail]concat=n=3:v=1:a=0,setpts=N*{q.denominator}/({q.numerator}*TB),{grid}[outv]")
|
|
142
|
+
# the grid is laid from the picture's start (the picture moved to 0 ahead of the fps
|
|
143
|
+
# filter, and the trims above counted from there), on a microsecond time base: laid from
|
|
144
|
+
# the clock's 0, a picture that starts 50 ms in came out a frame early after the hold, and
|
|
145
|
+
# on the fps filter's base of one frame the trim's half-frame bound 3.907407 (--at 3.9)
|
|
146
|
+
# became frame 105 itself, which was cut from the head and held in place of frame 106.
|
|
147
|
+
# It is not moved back after the conform: from FFmpeg 7.0 a setpts leaves its frames no
|
|
148
|
+
# duration and no rate, and the hold's tpad after it cloned nothing (109 frames for 135
|
|
149
|
+
# on 7.1)
|
|
150
|
+
vf = cfr_at_input(vf, meta, origin=v0)
|
|
122
151
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", "[outv]"]
|
|
123
152
|
if has_audio:
|
|
124
153
|
af = (f"[0:a]asplit[aa][ab];[aa]{head_a}[ahead];"
|
package/scripts/overlay.py
CHANGED
|
@@ -187,6 +187,12 @@ def enable_expr(start: Optional[float], end: Optional[float], meta: Optional[dic
|
|
|
187
187
|
return window_expr(start, end, meta)
|
|
188
188
|
|
|
189
189
|
|
|
190
|
+
def fade_out_length(start: float, end: float, fade: float) -> float:
|
|
191
|
+
"""The fade-out of a layer shown from `start` to `end`: --fade, cut to the window when the window
|
|
192
|
+
is shorter, so the fade still ends at `end` (audio.py and cut.py cap theirs the same way)."""
|
|
193
|
+
return min(fade, end - start)
|
|
194
|
+
|
|
195
|
+
|
|
190
196
|
def alpha_expr(opacity: float, start: Optional[float], end: Optional[float], fade: float) -> str:
|
|
191
197
|
"""Time-varying alpha with linear fade in/out inside [start, end]."""
|
|
192
198
|
if fade <= 0 or (start is None and end is None):
|
|
@@ -195,8 +201,8 @@ def alpha_expr(opacity: float, start: Optional[float], end: Optional[float], fad
|
|
|
195
201
|
parts = [f"{opacity:g}"]
|
|
196
202
|
fin = f"min(1,(t-{s:.3f})/{fade:g})"
|
|
197
203
|
parts.append(fin)
|
|
198
|
-
if end is not None:
|
|
199
|
-
parts.append(f"min(1,({end:.3f}-t)/{fade:g})")
|
|
204
|
+
if end is not None and end > s:
|
|
205
|
+
parts.append(f"min(1,({end:.3f}-t)/{fade_out_length(s, end, fade):g})")
|
|
200
206
|
return "max(0," + "*".join(parts) + ")"
|
|
201
207
|
|
|
202
208
|
|
|
@@ -527,8 +533,12 @@ def main() -> int:
|
|
|
527
533
|
# the common ask, and a matching fade-out at the very end surprised every eval run)
|
|
528
534
|
s = start if start is not None else 0.0
|
|
529
535
|
chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
|
|
530
|
-
if end is not None and end >
|
|
531
|
-
|
|
536
|
+
if end is not None and end > s:
|
|
537
|
+
# a fade-out longer than the layer's window is cut to it, so the layer still leaves
|
|
538
|
+
# at --end fully faded (#517: with --end at or before --fade it was left out, and the
|
|
539
|
+
# layer vanished at full opacity); the text layer's alpha_expr() caps it the same way
|
|
540
|
+
out = fade_out_length(s, end, args.fade)
|
|
541
|
+
chain.append(f"fade=t=out:st={end - out:.3f}:d={out:g}:alpha=1")
|
|
532
542
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
533
543
|
grid = on_grid(x, y, (vw, vh), (lw, lh))
|
|
534
544
|
if grid:
|
package/scripts/reverse.py
CHANGED
|
@@ -15,7 +15,7 @@ import argparse
|
|
|
15
15
|
import math
|
|
16
16
|
import sys
|
|
17
17
|
|
|
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
|
|
18
|
+
from _common import add_common, apply_common, aac_args, cfr_args, cfr_filter, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
19
19
|
from _common.emit import warn
|
|
20
20
|
from _common.decision import (HELD_FRAMES_WARN_BYTES, av_clip_length, av_trim, delivered_rate, frame_durations, frame_rate,
|
|
21
21
|
held_frames_bytes, stream_starts)
|
|
@@ -49,6 +49,17 @@ def main() -> int:
|
|
|
49
49
|
f"({vdur:.0f}s of {v['width']}x{v['height']}) -- cut the part to reverse first (cut.py) if RAM is tight",
|
|
50
50
|
"memory_heavy", duration=meta.get("duration"), bytes=held)
|
|
51
51
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
52
|
+
# A VFR source is put on the delivered grid before the reversal (#523). `reverse` hands its
|
|
53
|
+
# frames the forward timestamps in order, so a reversed VFR picture kept its gaps where they
|
|
54
|
+
# were in forward time: every flash of the 27 fps average clip came 0.19 s late against the
|
|
55
|
+
# reversed sound (0.23 s without sound). On a constant grid the reversed picture is the forward
|
|
56
|
+
# one mirrored. Each slot takes the last frame that starts before the slot ends (round=down),
|
|
57
|
+
# which is the frame the reversed slot opens on: the nearest frame to its start (the forward
|
|
58
|
+
# conform's choice) dropped a last frame that starts after the last slot's middle, and the
|
|
59
|
+
# reversal then opened on the frame before it. The conformed picture's time base is one frame,
|
|
60
|
+
# and a trim rescales its bound to it (a half-frame bound can round onto a frame and cut it:
|
|
61
|
+
# freeze.py), so a microsecond base follows.
|
|
62
|
+
reversed_conform = f"{cfr_filter(meta)}:round=down,settb=AVTB" if cfr_filter(meta) else ""
|
|
52
63
|
if has_audio:
|
|
53
64
|
# Both streams are reversed about one end (#409); each reversed about its own end put a sound
|
|
54
65
|
# 0.6 s longer than its picture 0.396 s off. As in a join, both start at the first frame (the
|
|
@@ -73,13 +84,14 @@ def main() -> int:
|
|
|
73
84
|
vlen = meta["video"].get("duration") or length
|
|
74
85
|
end = math.ceil(length * rate - 1e-6) / rate
|
|
75
86
|
vtrim, atrim = av_trim(stream_starts(meta)[0], None)
|
|
87
|
+
conform = f"{reversed_conform}," if reversed_conform else ""
|
|
76
88
|
cmd += ["-filter_complex",
|
|
77
89
|
f"[0:v:0]tpad=stop_mode=clone:stop_duration={max(0.0, end - vlen) + 2 / rate:.6f},{vtrim},"
|
|
78
|
-
f"trim=end={end - 0.5 / max(rate, frame_rate(meta) or rate):.6f},reverse,{frame_durations(meta, rate)}[v];"
|
|
90
|
+
f"{conform}trim=end={end - 0.5 / max(rate, frame_rate(meta) or rate):.6f},reverse,{frame_durations(meta, rate)}[v];"
|
|
79
91
|
f"[0:a:0]{atrim},apad=whole_dur={end:.6f},atrim=end={end:.6f},areverse,asetpts=N/SR/TB[a]",
|
|
80
92
|
"-map", "[v]", "-map", "[a]"]
|
|
81
93
|
else:
|
|
82
|
-
cmd += ["-vf", "reverse"]
|
|
94
|
+
cmd += ["-vf", f"{reversed_conform},reverse" if reversed_conform else "reverse"]
|
|
83
95
|
cmd += video_args(meta, args.crf, args.preset)
|
|
84
96
|
cmd += cfr_args(meta)
|
|
85
97
|
if has_audio:
|
package/scripts/speedramp.py
CHANGED
|
@@ -18,9 +18,9 @@ Examples:
|
|
|
18
18
|
import argparse
|
|
19
19
|
import sys
|
|
20
20
|
from fractions import Fraction
|
|
21
|
-
from typing import List, Tuple
|
|
21
|
+
from typing import Any, List, Tuple
|
|
22
22
|
|
|
23
|
-
from _common.decision import av_trim, conform_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
23
|
+
from _common.decision import atempo_chain_with_lead, av_trim, conform_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
24
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
|
|
25
25
|
from _common.emit import input_length
|
|
26
26
|
|
|
@@ -28,18 +28,20 @@ MAX_SPEED = 20.0
|
|
|
28
28
|
MIN_SPEED = 0.05
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def atempo_chain(factor: float) -> str:
|
|
32
|
-
"""atempo accepts 0.5..100 per instance; chain for factors outside that range.
|
|
31
|
+
def atempo_chain(factor: float, sample_rate: Any = 0) -> str:
|
|
32
|
+
"""atempo accepts 0.5..100 per instance; chain for factors outside that range. The chain is
|
|
33
|
+
led by the samples atempo leaves out (atempo_chain_with_lead, at the track's `sample_rate`):
|
|
34
|
+
without them every segment's sound came out 21 ms early at 1x and 40 ms at 0.5x (#523)."""
|
|
33
35
|
parts: List[str] = []
|
|
34
36
|
remaining = factor
|
|
35
37
|
while remaining < 0.5:
|
|
36
|
-
parts.append("
|
|
38
|
+
parts.append("0.5")
|
|
37
39
|
remaining /= 0.5
|
|
38
40
|
while remaining > 100.0:
|
|
39
|
-
parts.append("
|
|
41
|
+
parts.append("100.0")
|
|
40
42
|
remaining /= 100.0
|
|
41
|
-
parts.append(f"
|
|
42
|
-
return
|
|
43
|
+
parts.append(f"{remaining:.6f}")
|
|
44
|
+
return atempo_chain_with_lead(parts, sample_rate)
|
|
43
45
|
|
|
44
46
|
|
|
45
47
|
def parse_segment(raw: str) -> Tuple[float, float, float]:
|
|
@@ -118,7 +120,8 @@ def main() -> int:
|
|
|
118
120
|
# leaves at the unscaled 1/rate, so every 2x segment pushed the rest of the picture two
|
|
119
121
|
# frames late against the sound (and added those frames)
|
|
120
122
|
vchain = f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, out_rate)}"
|
|
121
|
-
|
|
123
|
+
# a 1x segment is not retimed: atempo would place its sound by windows for nothing (#541)
|
|
124
|
+
achain = f"[0:a]{atrim}" if factor == 1.0 else f"[0:a]{atrim},{atempo_chain(factor, srate)}"
|
|
122
125
|
if out_rate:
|
|
123
126
|
# Each segment holds the frames (and samples) between its retimed ends on the whole
|
|
124
127
|
# output's grid, not its own length rounded: eight 0.5 s segments at 27 fps rounded
|
package/scripts/stabilize.py
CHANGED
|
@@ -27,9 +27,9 @@ import sys
|
|
|
27
27
|
import tempfile
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
-
from _common.probe import PROBED_VIDEO
|
|
31
|
-
from _common.runner import ffmpeg_failure_extra,
|
|
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,
|
|
30
|
+
from _common.probe import PROBED_VIDEO, probed_maps
|
|
31
|
+
from _common.runner import ffmpeg_failure_extra, require_filter
|
|
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_keeping_subtitles, 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,19 +104,20 @@ 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] +
|
|
107
|
+
cmd = ffmpeg_base() + ["-i", args.input] + probed_maps(meta) + ["-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:
|
|
111
111
|
cmd += aac_args()
|
|
112
112
|
else:
|
|
113
113
|
cmd += ["-an"]
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
# the frames keep their times, so the subtitle/data tracks are copied as fit.py and color.py
|
|
115
|
+
# copy them, and dropped (said) only where the container refuses them (#520)
|
|
116
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
116
117
|
|
|
117
118
|
result = probe(output, role="output")
|
|
118
119
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})")
|
|
119
|
-
emit(output)
|
|
120
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
120
121
|
return 0
|
|
121
122
|
|
|
122
123
|
|