ffmpeg-skill 2.4.2 → 2.5.1
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 +1 -1
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +9 -1
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/drawtext.py +14 -3
- package/scripts/_common/text.py +1 -1
- package/scripts/_contract.py +6 -3
- package/scripts/graphics.py +6 -6
- package/scripts/loop.py +95 -11
package/README.md
CHANGED
|
@@ -222,7 +222,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
222
222
|
| `freeze.py` | Hold a frame for N seconds (`--at`, `--hold`, `--mode insert/extend`) — an end-card hold or a comedic beat |
|
|
223
223
|
| `pad.py` | Add black/silent padding at the start and/or end of the timeline (`--start`, `--end`) — distinct from `fit.py --fit pad`'s per-frame letterbox bars |
|
|
224
224
|
| `speedramp.py` | Step through different constant speeds across a clip via `--segment START-END:FACTOR` (repeatable) — distinct from `fit.py`'s single whole-clip speed factor |
|
|
225
|
-
| `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length |
|
|
225
|
+
| `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length; `--boomerang` plays it forward then backward |
|
|
226
226
|
| `broll.py` | Cut away to a B-roll clip over the A-roll for a window (`--insert B --at T --duration D`, repeatable) and come back; A's length and audio untouched by default |
|
|
227
227
|
| `metadata.py` | Write container chapter markers from a `TIME TITLE` text file and title/artist/comment tags, every stream copied bit for bit; `--auto-chapters` proposes the markers from measured pauses and scene cuts and names them `Chapter N` for you to rename |
|
|
228
228
|
| `grid.py` | Composite `--cols`x`--rows` clips into one grid, each cell letterboxed and labelled with its filename by default (`--label none` to skip) |
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.5.1`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
119
119
|
"contract_version": "1.0",
|
|
120
120
|
"deprecated": [],
|
|
121
121
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
122
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.5.1", "execution_mode": "local", "kind": "execution",
|
|
123
123
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
124
124
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
125
125
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -394,7 +394,7 @@ output; the result says so with `dropped_non_av_streams: true`.
|
|
|
394
394
|
|
|
395
395
|
### loop.py — repeat a clip
|
|
396
396
|
```
|
|
397
|
-
loop.py INPUT --times N | --duration T [-o OUT]
|
|
397
|
+
loop.py INPUT --times N | --duration T [--boomerang] [-o OUT]
|
|
398
398
|
```
|
|
399
399
|
`--times` repeats the whole clip that many times back to back (2 =
|
|
400
400
|
original + 1 repeat). `--duration` instead loops (and trims the last
|
|
@@ -403,6 +403,14 @@ bed, or filling a fixed slot length with a short clip. Does not smooth the
|
|
|
403
403
|
loop point (no crossfade at the seam) -- a clip that doesn't already loop
|
|
404
404
|
cleanly will show a visible cut/pop at each repeat, which is a property of
|
|
405
405
|
the source material this tool cannot fix.
|
|
406
|
+
`--boomerang` plays the clip forward, then backward (frames 0..N-1, then
|
|
407
|
+
N-2..1, repeated), so any clip loops without a jump and neither turnaround
|
|
408
|
+
frame is shown twice. `--times` then counts round trips (1 is allowed), and
|
|
409
|
+
`--duration` trims the last one. A boomerang is silent (reversed sound plays
|
|
410
|
+
backwards; a source's audio is dropped with a `notes` line), so add a bed with
|
|
411
|
+
`audio.py`. It holds the decoded clip in memory like `reverse.py` and warns
|
|
412
|
+
above ~2 GiB; a clip needs 3 frames at least, and one cycle at most 32767
|
|
413
|
+
(ffmpeg's `loop` filter).
|
|
406
414
|
|
|
407
415
|
### broll.py — cut away to a B-roll clip and come back
|
|
408
416
|
```
|
|
@@ -94,7 +94,7 @@ from _common.color import (
|
|
|
94
94
|
bt709_tag_args, color_hex, _COLOR_TOKEN_RE, _sdr_bt709, validate_color
|
|
95
95
|
)
|
|
96
96
|
from _common.text import (
|
|
97
|
-
ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw,
|
|
97
|
+
ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align,
|
|
98
98
|
drawtext_shaping, drawtext_text_opts, emoji_asset_for, EMOJI_ASSET_HINT, emoji_clusters, emoji_codepoint_name,
|
|
99
99
|
_EMOJI_COLOR_FAMILIES, _emoji_color_font, emoji_filter_chain, _emoji_name_candidates, EMOJI_RANGES,
|
|
100
100
|
_EMOJI_REGIONAL, emoji_support, _EMOJI_SUPPORT_CACHE, _EMOJI_TAIL, escape_drawtext, _family_rank, FC_LANG,
|
|
@@ -188,7 +188,7 @@ __all__ = [
|
|
|
188
188
|
"BEAT_REFRACTORY_S", "BEAT_WINDOW_S", "BEAT_SUPPORT_DIVISOR", "BEAT_ALIGN_DIVISOR",
|
|
189
189
|
"BEAT_Z_FLOOR", "BEAT_Z_SPAN",
|
|
190
190
|
"_onset_strength", "_pick_onsets", "_autocorrelation_peak", "_grid_score",
|
|
191
|
-
"default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw",
|
|
191
|
+
"default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw", "drawtext_center_align",
|
|
192
192
|
"_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
|
|
193
193
|
"dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
|
|
194
194
|
"emoji_codepoint_name", "_EMOJI_COLOR_FAMILIES", "_emoji_color_font", "emoji_filter_chain",
|
|
@@ -97,6 +97,13 @@ def escape_drawtext(text: str) -> str:
|
|
|
97
97
|
)
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def drawtext_center_align() -> str:
|
|
101
|
+
"""`:text_align=C` for a centred drawtext, so each line of a multi-line label is centred on
|
|
102
|
+
its own. The option arrived in FFmpeg 6.1 (5.x and 6.0 reject it); there the block is still
|
|
103
|
+
centred as a whole by its x expression, with its lines left-aligned inside it."""
|
|
104
|
+
return ":text_align=C" if ffmpeg_version() >= (6, 1) else ""
|
|
105
|
+
|
|
106
|
+
|
|
100
107
|
def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
|
|
101
108
|
r"""`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
|
|
102
109
|
every character on every build shape this repo uses.
|
|
@@ -106,8 +113,11 @@ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
|
|
|
106
113
|
`%{...}` scanner, which is the reason `%` was unsafe (a bare `\%` logs "Stray %" on one build
|
|
107
114
|
and fails the whole filter chain on another). With the scanner off, `'`, `%`, `:`, `,`, `[`,
|
|
108
115
|
`]`, `;` and `\` all reach the picture verbatim -- 1.15 fixes `overlay.py --text "it's 100%
|
|
109
|
-
done"` losing both characters.
|
|
110
|
-
|
|
116
|
+
done"` losing both characters. A newline is kept (`\r\n` and `\r` become `\n`): drawtext
|
|
117
|
+
draws it as a line break, and graphics.py's wrap puts them there on purpose (stripping them
|
|
118
|
+
ran a wrapped title off both edges of the frame). A tab, vertical tab or form
|
|
119
|
+
feed becomes a space, as the wrap measures it; other control characters are stripped (that
|
|
120
|
+
dates from the inline-escape route, where they broke the graph parser).
|
|
111
121
|
|
|
112
122
|
The file is UTF-8, mode 0600, in a private per-run directory (see _drawtext_tmpdir) that is
|
|
113
123
|
removed when the process ends. It is *registered* here and written by run() only if the
|
|
@@ -115,7 +125,8 @@ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
|
|
|
115
125
|
printed plan therefore names a path that no longer exists once the run is over, which is the
|
|
116
126
|
same promise every other temp file in this skill makes.
|
|
117
127
|
"""
|
|
118
|
-
cleaned =
|
|
128
|
+
cleaned = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
|
129
|
+
cleaned = re.sub(r"[\x00-\x08\x0e-\x1f\x7f]", "", re.sub(r"[\t\x0b\x0c]", " ", cleaned))
|
|
119
130
|
import hashlib
|
|
120
131
|
name = "t_" + hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:16] + ".txt"
|
|
121
132
|
if tmpdir is None:
|
package/scripts/_common/text.py
CHANGED
|
@@ -31,7 +31,7 @@ from _common.emoji import (
|
|
|
31
31
|
)
|
|
32
32
|
from _common.drawtext import (
|
|
33
33
|
drawtext_boxborderw, SHAPING_SCRIPTS, BIDI_SCRIPTS, _SHAPING_BUILD_CACHE, drawtext_shaping, needs_shaping,
|
|
34
|
-
escape_drawtext, drawtext_text_opts,
|
|
34
|
+
escape_drawtext, drawtext_text_opts, drawtext_center_align,
|
|
35
35
|
)
|
|
36
36
|
from _common.wrap import (
|
|
37
37
|
ADVANCE_EM, NO_SPACE_SCRIPTS, NO_BOUNDARY_SCRIPTS, LATIN_EM, LEADING_VOWELS, _is_mark, _char_em,
|
package/scripts/_contract.py
CHANGED
|
@@ -126,8 +126,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
126
126
|
required=FF, optional=[{"capability": "filter:silencedetect", "when": "--auto-chapters"},
|
|
127
127
|
{"capability": "filter:scdet", "when": "--auto-chapters --from scenes|both"}],
|
|
128
128
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
129
|
-
"loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration"],
|
|
130
|
-
required=FF + [X264, AAC], optional=[],
|
|
129
|
+
"loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration (--boomerang: played forward then backward, silent)"],
|
|
130
|
+
required=FF + [X264, AAC], optional=[{"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}],
|
|
131
131
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
132
132
|
"insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
|
|
133
133
|
required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
|
|
@@ -267,7 +267,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
267
267
|
"speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
|
|
268
268
|
"broll": dict(video="always", audio="conditional", note="the overlay graph always re-encodes the video stream; A's audio is stream-copied under --audio a and re-encoded to AAC under --audio b/mix"),
|
|
269
269
|
"metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change -- --auto-chapters decodes to measure, but still writes with -c copy"),
|
|
270
|
-
"loop": dict(video="always", audio="
|
|
270
|
+
"loop": dict(video="always", audio="conditional", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
|
|
271
271
|
"insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
|
|
272
272
|
"background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
|
|
273
273
|
"reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
|
|
@@ -451,6 +451,9 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
451
451
|
"sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
|
|
452
452
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr only: which path was taken and why"},
|
|
453
453
|
"measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
|
|
454
|
+
elif name == "loop":
|
|
455
|
+
extra = {"boomerang": {"type": "boolean", "description": "--boomerang: frames 0..N-1 then N-2..1, repeated, so neither turnaround frame is shown twice"},
|
|
456
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
|
|
454
457
|
elif name == "cut":
|
|
455
458
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
456
459
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
package/scripts/graphics.py
CHANGED
|
@@ -35,7 +35,7 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as
|
|
|
35
35
|
from _common import (aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
36
36
|
color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path,
|
|
37
37
|
ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args,
|
|
38
|
-
drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
38
|
+
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
39
39
|
LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
|
|
40
40
|
emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
|
|
41
41
|
EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
|
|
@@ -356,14 +356,14 @@ def main() -> int:
|
|
|
356
356
|
h2 = int(base * 0.045)
|
|
357
357
|
filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
|
|
358
358
|
add_text(args.title,
|
|
359
|
-
f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}",
|
|
359
|
+
f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}",
|
|
360
360
|
size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
|
|
361
361
|
y=H / 2 - (h2 if args.subtitle else 0), outline=max(1.0, h1 / 20.0),
|
|
362
362
|
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
363
363
|
filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{h1 // 2 + (0 if args.subtitle else 0)}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
|
|
364
364
|
if args.subtitle:
|
|
365
365
|
add_text(args.subtitle,
|
|
366
|
-
f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}",
|
|
366
|
+
f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}",
|
|
367
367
|
size=h2, color=primary, font=ass_font_family(), align=5, x=W / 2,
|
|
368
368
|
y=H / 2 + h1 // 2 + int(base * 0.03), outline=max(1.0, h2 / 20.0),
|
|
369
369
|
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
@@ -444,8 +444,8 @@ def main() -> int:
|
|
|
444
444
|
filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
|
|
445
445
|
hook_title = wrapped(args.title, h1)
|
|
446
446
|
add_text(hook_title,
|
|
447
|
-
f"drawtext={drawtext_text_opts(hook_title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}
|
|
448
|
-
f"x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
|
|
447
|
+
f"drawtext={drawtext_text_opts(hook_title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}"
|
|
448
|
+
f"{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
|
|
449
449
|
size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
|
|
450
450
|
outline=max(1.0, h1 / 20.0), outline_color="000000", start=s, end=he)
|
|
451
451
|
filters.append(f"drawbox=x=0:y=0:w='iw*max(0,1-(t-{s:.3f})/{max(0.001, he - s):.3f})':h={bar_h}:"
|
|
@@ -466,7 +466,7 @@ def main() -> int:
|
|
|
466
466
|
meme_line = wrapped(text.upper(), fs)
|
|
467
467
|
add_text(meme_line,
|
|
468
468
|
f"drawtext={drawtext_text_opts(meme_line)}:{fo}:fontsize={fs}:fontcolor={white}:"
|
|
469
|
-
f"borderw={bw}:bordercolor={black}:x=(w-text_w)/2:y={y}:{en}",
|
|
469
|
+
f"borderw={bw}:bordercolor={black}{drawtext_center_align()}:x=(w-text_w)/2:y={y}:{en}",
|
|
470
470
|
size=fs, color="FFFFFF", font=ass_font_family(), bold=True,
|
|
471
471
|
align=(8 if y == f"{m_top}" else 2), x=W / 2,
|
|
472
472
|
y=(m_top if y == f"{m_top}" else H - m_bottom),
|
package/scripts/loop.py
CHANGED
|
@@ -8,11 +8,17 @@ target length. Audio loops along with the video when present. This tool
|
|
|
8
8
|
does not smooth the loop point (no crossfade at the seam) -- a clip that
|
|
9
9
|
doesn't already loop cleanly will show a visible cut/pop at each repeat;
|
|
10
10
|
that's a judgement call about the source material, not something a --times
|
|
11
|
-
or --duration flag can fix.
|
|
11
|
+
or --duration flag can fix. --boomerang is the exception: it plays the clip
|
|
12
|
+
forward, then backward, so every repeat meets frames that match. Each
|
|
13
|
+
turnaround frame is shown once (frames 0..N-1, then N-2..1), so the motion
|
|
14
|
+
does not stall at either end. A boomerang is silent, because reversed sound
|
|
15
|
+
plays backwards; add a bed with audio.py. Like reverse.py, it holds the
|
|
16
|
+
decoded clip in memory.
|
|
12
17
|
|
|
13
18
|
Examples:
|
|
14
19
|
python3 loop.py bg_loop.mp4 --times 3
|
|
15
20
|
python3 loop.py texture.mp4 --duration 30
|
|
21
|
+
python3 loop.py wave.mp4 --boomerang --times 4
|
|
16
22
|
"""
|
|
17
23
|
import argparse
|
|
18
24
|
import math
|
|
@@ -21,6 +27,72 @@ import sys
|
|
|
21
27
|
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
22
28
|
|
|
23
29
|
|
|
30
|
+
# The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
|
|
31
|
+
LOOP_FILTER_MAX_FRAMES = 32767
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _boomerang_graph(meta, src_dur: float, times) -> str:
|
|
35
|
+
"""Frames 0..N-1, then N-2..1, repeated: `times` round trips, or forever for --duration (-t ends it).
|
|
36
|
+
|
|
37
|
+
The backward half is the source minus frame 0, reversed, minus its first frame (N-1). The
|
|
38
|
+
forward half already shows both ends, so each turnaround appears once. That is unlike the
|
|
39
|
+
common split/reverse/concat recipe, which shows frame N-1 twice and, on every repeat, frame 0
|
|
40
|
+
twice. Trimming by frame index this way needs no frame count."""
|
|
41
|
+
v = meta["video"]
|
|
42
|
+
frames = v.get("nb_frames") or round((v.get("duration") or src_dur) * (v.get("fps") or 30.0))
|
|
43
|
+
if frames < 3:
|
|
44
|
+
die(f"--boomerang needs a clip of at least 3 frames, got {frames}")
|
|
45
|
+
cycle = 2 * frames - 2
|
|
46
|
+
if cycle > LOOP_FILTER_MAX_FRAMES:
|
|
47
|
+
die(f"--boomerang cycle is {cycle} frames; ffmpeg's loop filter holds at most {LOOP_FILTER_MAX_FRAMES} "
|
|
48
|
+
f"-- cut the clip shorter first (cut.py)")
|
|
49
|
+
# reverse holds the clip's decoded frames; split, trim and loop pass references to the same
|
|
50
|
+
# frames, so the peak is about one clip (measured: the same max RSS as a plain reverse)
|
|
51
|
+
bytes_per_frame = v["width"] * v["height"] * 1.5 * (2 if (v.get("bit_depth") or 8) > 8 else 1)
|
|
52
|
+
held = bytes_per_frame * frames
|
|
53
|
+
if held > 2 * 1024 ** 3:
|
|
54
|
+
info(f"warning: --boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
|
|
55
|
+
f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight")
|
|
56
|
+
repeats = -1 if times is None else times - 1
|
|
57
|
+
# Retimed by frame index at the source's nominal rate: a VFR source's reversed half carries
|
|
58
|
+
# its gaps mirrored, and a CFR conform after the graph duplicated a frame against them (a
|
|
59
|
+
# turnaround shown twice), so the output is made constant-rate here and passed through as is.
|
|
60
|
+
# fps= after setpts drops or repeats nothing; it tells the encoder the rate, whose time base
|
|
61
|
+
# otherwise follows the source's r_frame_rate and rounds the new timestamps unevenly.
|
|
62
|
+
rate = _nominal_rate(v)
|
|
63
|
+
return ("[0:v]split[f][r];[f]setpts=PTS-STARTPTS[fw];"
|
|
64
|
+
"[r]trim=start_frame=1,reverse,trim=start_frame=1,setpts=PTS-STARTPTS[bw];"
|
|
65
|
+
f"[fw][bw]concat=n=2:v=1:a=0,loop=loop={repeats}:size={LOOP_FILTER_MAX_FRAMES},"
|
|
66
|
+
f"setpts=N/(({rate})*TB),fps={rate}[v]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Above this a "frame rate" is a time base (90000/1 from MPEG-TS, 1000/1 from Matroska), not a rate.
|
|
70
|
+
MAX_PLAUSIBLE_FPS = 240
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fraction(text):
|
|
74
|
+
try:
|
|
75
|
+
num, _, den = (text or "").partition("/")
|
|
76
|
+
value = float(num) / float(den or 1)
|
|
77
|
+
except (ValueError, ZeroDivisionError):
|
|
78
|
+
return None
|
|
79
|
+
return value if 0 < value <= MAX_PLAUSIBLE_FPS else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _nominal_rate(v) -> str:
|
|
83
|
+
"""The source's frame rate as an exact fraction: r_frame_rate when it is within 1% of the
|
|
84
|
+
average (an iPhone clip: 60/1 against 59.95 from a 1/600 time base), else the average; with
|
|
85
|
+
neither usable, frames over the video's duration, else 30."""
|
|
86
|
+
r, avg = v.get("r_frame_rate"), v.get("avg_frame_rate")
|
|
87
|
+
r_val, avg_val = _fraction(r), _fraction(avg)
|
|
88
|
+
if r_val and (not avg_val or abs(r_val - avg_val) <= 0.01 * avg_val):
|
|
89
|
+
return r
|
|
90
|
+
if avg_val:
|
|
91
|
+
return avg
|
|
92
|
+
counted = (v.get("nb_frames") or 0) / (v.get("duration") or float("inf"))
|
|
93
|
+
return f"{counted:g}" if 0 < counted <= MAX_PLAUSIBLE_FPS else "30"
|
|
94
|
+
|
|
95
|
+
|
|
24
96
|
def main() -> int:
|
|
25
97
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
26
98
|
ap.add_argument("input")
|
|
@@ -28,6 +100,8 @@ def main() -> int:
|
|
|
28
100
|
group = ap.add_mutually_exclusive_group(required=True)
|
|
29
101
|
group.add_argument("--times", type=int, help="repeat the whole clip this many times (2 = original + 1 repeat)")
|
|
30
102
|
group.add_argument("--duration", help="loop (and trim the last repeat) to hit exactly this target duration (seconds or mm:ss)")
|
|
103
|
+
ap.add_argument("--boomerang", action="store_true",
|
|
104
|
+
help="play forward then backward (--times counts round trips; 1 is allowed); drops audio")
|
|
31
105
|
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
32
106
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
33
107
|
add_common(ap)
|
|
@@ -40,12 +114,14 @@ def main() -> int:
|
|
|
40
114
|
src_dur = meta.get("duration") or 0.0
|
|
41
115
|
if src_dur <= 0:
|
|
42
116
|
die("input has no measurable duration to loop")
|
|
43
|
-
has_audio = bool(meta.get("audio"))
|
|
117
|
+
has_audio = bool(meta.get("audio")) and not args.boomerang
|
|
118
|
+
notes = []
|
|
44
119
|
output = args.output or default_output(args.input, "loop")
|
|
45
120
|
|
|
46
121
|
if args.times is not None:
|
|
47
|
-
if args.times < 2:
|
|
48
|
-
die(f"--times must be >=
|
|
122
|
+
if args.times < (1 if args.boomerang else 2):
|
|
123
|
+
die(f"--times must be >= 1 with --boomerang, got {args.times}" if args.boomerang else
|
|
124
|
+
f"--times must be >= 2 (1 is just the original clip), got {args.times}")
|
|
49
125
|
target = None
|
|
50
126
|
stream_loop = args.times - 1
|
|
51
127
|
else:
|
|
@@ -54,14 +130,19 @@ def main() -> int:
|
|
|
54
130
|
die(f"--duration ({target:g}s) must be longer than the source ({src_dur:.3f}s) -- use cut.py to trim instead")
|
|
55
131
|
stream_loop = math.ceil(target / src_dur) - 1
|
|
56
132
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
133
|
+
if args.boomerang:
|
|
134
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", _boomerang_graph(meta, src_dur, args.times), "-map", "[v]"]
|
|
135
|
+
if meta.get("audio"):
|
|
136
|
+
notes.append("audio dropped: a boomerang plays half its sound backwards -- add a bed with audio.py")
|
|
137
|
+
else:
|
|
138
|
+
# -stream_loop repeats the whole input read (video and audio together) at the demuxer level
|
|
139
|
+
# -- exact and lossless-in-intent for a re-encode target, unlike a filter-graph loop that
|
|
140
|
+
# would need separate video/audio filters kept in lockstep by hand.
|
|
141
|
+
cmd = ffmpeg_base() + ["-stream_loop", str(stream_loop), "-i", args.input]
|
|
61
142
|
if target is not None:
|
|
62
143
|
cmd += ["-t", f"{target:.3f}"]
|
|
63
144
|
cmd += video_args(meta, args.crf, args.preset)
|
|
64
|
-
cmd += cfr_args(meta)
|
|
145
|
+
cmd += ["-fps_mode", "passthrough"] if args.boomerang else cfr_args(meta)
|
|
65
146
|
if has_audio:
|
|
66
147
|
cmd += aac_args()
|
|
67
148
|
else:
|
|
@@ -71,8 +152,11 @@ def main() -> int:
|
|
|
71
152
|
|
|
72
153
|
result = probe(output, role="output")
|
|
73
154
|
v = result["video"]
|
|
74
|
-
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, source {src_dur:.3f}s
|
|
75
|
-
|
|
155
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, source {src_dur:.3f}s "
|
|
156
|
+
f"{'boomeranged' if args.boomerang else 'looped'})")
|
|
157
|
+
for note in notes:
|
|
158
|
+
info(note)
|
|
159
|
+
emit(output, boomerang=args.boomerang, **({"notes": notes} if notes else {}))
|
|
76
160
|
return 0
|
|
77
161
|
|
|
78
162
|
|