ffmpeg-skill 2.4.0 → 2.4.2
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 +2 -2
- package/scripts/join.py +25 -18
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.4.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.4.2`) | 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.4.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.4.2", "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.4.
|
|
3
|
+
"version": "2.4.2",
|
|
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
|
@@ -591,10 +591,10 @@ are always present (`[]` when none) and noted.
|
|
|
591
591
|
Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
|
|
592
592
|
channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
|
|
593
593
|
silent track generated for clips without audio), then chains `xfade` +
|
|
594
|
-
`acrossfade
|
|
594
|
+
`acrossfade` (or `concat` for `--transition none`). Every clip has one length for picture and sound
|
|
595
595
|
-- the video stream's, or the audio's when it runs more than a frame longer --
|
|
596
596
|
and the shorter stream is padded (silence / last frame held), so every clip's
|
|
597
|
-
sound stays with its picture. Output length = sum of clip lengths −
|
|
597
|
+
sound stays with its picture and a plain cut leaves no hole in the video. Output length = sum of clip lengths −
|
|
598
598
|
transition × (n−1); `verified` checks the output's video-stream length against it. Clips must be
|
|
599
599
|
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
600
600
|
A subtitle/data track in the source is not carried into the retimed/concatenated
|
package/scripts/join.py
CHANGED
|
@@ -81,7 +81,7 @@ def unpending_length(durs: List[float], d: float) -> Optional[float]:
|
|
|
81
81
|
|
|
82
82
|
|
|
83
83
|
def clip_length(meta: Dict[str, Any], fps: float) -> float:
|
|
84
|
-
"""The one length a clip holds in a
|
|
84
|
+
"""The one length a clip holds in a join, for its picture and its sound alike: the
|
|
85
85
|
video stream's, or the audio stream's when the sound runs more than a frame past the picture
|
|
86
86
|
(narration is never cut; an AAC tail adds no frame). The container duration (audio priming,
|
|
87
87
|
a longer track) is only the fallback when a stream length is unknown (a pending input)."""
|
|
@@ -443,9 +443,12 @@ def main() -> int:
|
|
|
443
443
|
note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
|
|
444
444
|
durs = [m.get("duration") or 0.0 for m in metas]
|
|
445
445
|
d = args.duration if args.transition != "none" else 0.0
|
|
446
|
-
#
|
|
447
|
-
#
|
|
448
|
-
|
|
446
|
+
# every clip gets one length for both streams, so whatever places the next clip -- xfade's
|
|
447
|
+
# offsets, or the concat filter, which starts it after the LONGER stream of this one -- places
|
|
448
|
+
# its picture and its sound together. The plain cut used to take the raw streams, and a sound
|
|
449
|
+
# past the picture (a music bed padded to the container, even a 20 ms AAC tail) left a hole in
|
|
450
|
+
# the video there.
|
|
451
|
+
lens = [clip_length(m, fps) for m in metas]
|
|
449
452
|
place_silent(lens, d)
|
|
450
453
|
for p, dur in zip(args.inputs, lens):
|
|
451
454
|
if d and dur <= d * 2 and not STATE.dry_run:
|
|
@@ -497,18 +500,17 @@ def main() -> int:
|
|
|
497
500
|
parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
|
|
498
501
|
parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
|
|
499
502
|
|
|
503
|
+
# pad the shorter stream to the clip's length: the audio with silence, the picture by holding
|
|
504
|
+
# its last frame, then cut both there -- each clip boundary is then the same for both chains
|
|
505
|
+
for i in range(n):
|
|
506
|
+
vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
|
|
507
|
+
hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
|
|
508
|
+
parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
|
|
509
|
+
parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
|
|
500
510
|
if args.transition == "none":
|
|
501
|
-
chain = "".join(f"[
|
|
511
|
+
chain = "".join(f"[vp{i}][ap{i}]" for i in range(n))
|
|
502
512
|
parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
|
|
503
513
|
else:
|
|
504
|
-
# pad the shorter stream to the clip's length: the audio with silence, the picture by
|
|
505
|
-
# holding its last frame, then cut both there -- each xfade and acrossfade then starts
|
|
506
|
-
# at the same clip boundary instead of the audio chain drifting by every clip's gap
|
|
507
|
-
for i in range(n):
|
|
508
|
-
vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
|
|
509
|
-
hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
|
|
510
|
-
parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
|
|
511
|
-
parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
|
|
512
514
|
vprev, aprev = "vp0", "ap0"
|
|
513
515
|
offset = 0.0
|
|
514
516
|
for i in range(1, n):
|
|
@@ -527,13 +529,18 @@ def main() -> int:
|
|
|
527
529
|
if d and any(m.get("dry_run") for m in metas[:-1]):
|
|
528
530
|
STATE_NOTES.append("the planned xfade offsets count each pending input as 0 s long: a real run offsets "
|
|
529
531
|
"by its measured length")
|
|
532
|
+
elif not d and any(m.get("dry_run") for m in metas):
|
|
533
|
+
# the plain cut trims/holds every clip to clip_length too (2.4.1), so a pending clip's
|
|
534
|
+
# planned trim=duration=0.000 is the stub's placeholder, not the cut
|
|
535
|
+
STATE_NOTES.append("the planned trim lengths count each pending input as 0 s long: a real run trims "
|
|
536
|
+
"or holds it to its measured length")
|
|
530
537
|
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
531
538
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
539
|
+
# the fps filter already made the picture constant-rate, and the per-clip trims (then xfade or
|
|
540
|
+
# concat) place every frame: FFmpeg 5.1's default cfr output mode still duplicated the last one
|
|
541
|
+
# to meet the AAC tail (a 51-frame dissolve came out 52), which newer builds no longer do -- pass
|
|
542
|
+
# the frames through as built, for a plain cut as much as for a transition
|
|
543
|
+
cmd += ["-fps_mode", "passthrough"]
|
|
537
544
|
cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
|
|
538
545
|
run(cmd)
|
|
539
546
|
expected = unpending_length(lens, d)
|