ffmpeg-skill 1.24.1 → 1.26.0

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 CHANGED
@@ -504,7 +504,7 @@ python3 tests/test_picture.py # one tool group on its own (analysis, editing, au
504
504
  npm run release-check # pack, install, contract from the installed copy, MCP == contract, doctor, tests, contract evals
505
505
  npm run demo # python3 demos/build.py: synthetic footage -> every before/after demo + docs/demos/*.gif
506
506
  npm run demo:pipeline # examples/make_demo.sh: the older single end-to-end run of every script
507
- python3 evals/run.py --list # agent eval prompts (see evals/)
507
+ python3 evals/run.py --list # agent eval prompts (see evals/); regex-only grading, runnable from any agent harness -- see evals/README.md "Running this from Cursor, Codex, or another harness"
508
508
  node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
509
509
  ```
510
510
 
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 (`1.24.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.26.0`) | 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`
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
88
88
 
89
89
  | What 2.0 removes | Since | Replacement | To be ready today |
90
90
  |---|---|---|---|
91
- | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.24.1 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
- | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.24.1 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
- | `json` and `progress` in the MCP `inputSchema` | 1.24.1 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
- | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.24.1 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
- | Overwriting an existing output with only a warning | 1.24.1 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
91
+ | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.26.0 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
+ | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.26.0 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
+ | `json` and `progress` in the MCP `inputSchema` | 1.26.0 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
+ | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.26.0 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
+ | Overwriting an existing output with only a warning | 1.26.0 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
96
96
 
97
97
  ## Skill
98
98
 
99
99
  ```json
100
100
  {
101
101
  "contract_version": "1.0",
102
- "deprecated": [{"what": "...", "since": "1.24.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.24.1", "execution_mode": "local", "kind": "execution",
102
+ "deprecated": [{"what": "...", "since": "1.26.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
+ "skill": {"id": "ffmpeg-skill", "version": "1.26.0", "execution_mode": "local", "kind": "execution",
104
104
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
105
105
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
106
106
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -120,7 +120,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
120
120
  | Field | Meaning |
121
121
  |---|---|
122
122
  | `id`, `name`, `version`, `executable` | `ffmpeg-skill/cut`, `cut`, skill version, `scripts/cut.py` |
123
- | `examples[]` | `{prompts[], command}` (1.24.1): SKILL.md's own "User says" / "Do" table rows for this tool, machine-readable — `prompts` is the row's phrasing(s), `command` the row's `Do` text with the markdown backticks stripped. Every tool has at least one; parsed from SKILL.md at read time, so it never drifts from the table a person reads |
123
+ | `examples[]` | `{prompts[], command}` (1.26.0): SKILL.md's own "User says" / "Do" table rows for this tool, machine-readable — `prompts` is the row's phrasing(s), `command` the row's `Do` text with the markdown backticks stripped. Every tool has at least one; parsed from SKILL.md at read time, so it never drifts from the table a person reads |
124
124
  | `role` | `analysis`, `analysis_and_execution`, `execution` or `verification` (see below) |
125
125
  | `capabilities.required` | ffmpeg components the tool always needs |
126
126
  | `capabilities.optional[]` | `{capability, when}`: needed only for that flag or input |
@@ -129,7 +129,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
129
129
  | `output_schema` | what `--json` prints on stdout |
130
130
  | `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
131
131
  | `supports_json` | whether `--json` exists |
132
- | `supports_json_brief` | whether `--json-brief` exists (1.24.1): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
132
+ | `supports_json_brief` | whether `--json-brief` exists (1.26.0): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
133
133
  | `mutates_input` | always `false`: no tool overwrites its input |
134
134
  | `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
135
135
  | `verification` | `{required, tools}`: which tools to run on the output afterwards |
@@ -435,7 +435,7 @@ given a different type):
435
435
 
436
436
  | key | tool | what it holds |
437
437
  |---|---|---|
438
- | `caption` | `caption.py` | the cue-layout counts the run only printed before (`shifted`, `wrapped`, `rebalanced`, `split`, `extended`, `dropped`) plus `wrap` (`"phrase"` or `"measured"`) and `phrase_breaks`, the number of cues a phrase rule broke somewhere the 1.15 width rule would not. `broken_inside_word` (1.24.1) counts atoms hard-sliced at the live column's edge because they did not fit alone even at the size floor — never a rewrite, the sliced pieces are the exact original characters. `overlong` is now residual: it fires only when even a single character is wider than the column |
438
+ | `caption` | `caption.py` | the cue-layout counts the run only printed before (`shifted`, `wrapped`, `rebalanced`, `split`, `extended`, `dropped`) plus `wrap` (`"phrase"` or `"measured"`) and `phrase_breaks`, the number of cues a phrase rule broke somewhere the 1.15 width rule would not. `broken_inside_word` (1.26.0) counts atoms hard-sliced at the live column's edge because they did not fit alone even at the size floor — never a rewrite, the sliced pieces are the exact original characters. `overlong` is now residual: it fires only when even a single character is wider than the column |
439
439
  | `tracks`, `subtitle_tracks` | `caption.py --mode mux` | one entry per subtitle stream in the output — `{index, file, language, title, codec, default, cues, kept_from_input}`; a stream the input already carried has `file: null` and `kept_from_input: true`. `subtitle_tracks` is the total. Every field describes the file as written, not as asked for: an MPEG-4 output reports `title: null` (the muxer stores none) and `default: true` on its first track (the muxer always enables it), each with a `notes` line |
440
440
  | `auto_chapters` | `metadata.py --auto-chapters` | `{source, min_chapter, max_chapters, proposed, kept, titles, chapters, description_block, files}`. `titles` is always `"placeholder"`: the machine-readable form of "the skill did not name these". Each chapter carries its `evidence` (`start`, `silence`, `scene`, or `silence+scene` with the span, its length and the cut time) |
441
441
  | `audiogram` | `waveform.py` (every run) | `{style, background, image, position, vis_height, platform, captions, title, stages, verified}`. `background` is `"image"` or `"color"`; `verified` is true when the render probes at the asked-for frame size, frame rate and within 0.05 s of the source audio, and is `false` under `--dry-run`, where nothing was rendered to verify |
@@ -451,7 +451,7 @@ Per-tool keys added in 1.17, all additive:
451
451
  | `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
452
452
  | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
453
453
 
454
- Per-tool keys added in 1.24.1, all additive:
454
+ Per-tool keys added in 1.26.0, all additive:
455
455
 
456
456
  | key | tool | what it holds |
457
457
  |---|---|---|
@@ -459,7 +459,7 @@ Per-tool keys added in 1.24.1, all additive:
459
459
  | `text_unchanged` | `caption.py` | a sibling inside the `caption` block, **burn mode only** (`--mode mux` never touches the text and omits the key): `true` when the drawn text equals the cues that were handed in — nothing transcribed, no cue dropped, no cue **split** across two consecutive cues and no glyph stripped (`--emoji none`). Wrapping, line breaks and timing do not count: the words are the same. This tool never rewrites, shortens or translates a cue, so the key is a statement of what happened, not a judgement of the text |
460
460
 
461
461
 
462
- Per-tool keys added in 1.24.1, all additive:
462
+ Per-tool keys added in 1.26.0, all additive:
463
463
 
464
464
  | key | tool | what it holds |
465
465
  |---|---|---|
@@ -593,7 +593,7 @@ most often across the corpus in `evals/agent_prompts*.json`. Every tool -- inclu
593
593
  -- is still callable by name through `tools/call` regardless of what `tools/list` advertised; the
594
594
  contract (`ffmpeg-skill contract --json`) still describes all 42 unconditionally. Set
595
595
  `FFMPEG_SKILL_MCP_FULL=1` (anything but "" or `0`) in the server's environment to make `tools/list`
596
- return all 42, as every version before 1.24.1 did.
596
+ return all 42, as every version before 1.26.0 did.
597
597
 
598
598
  ### `prompts` capability: five workflow recipes
599
599
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.24.1",
3
+ "version": "1.26.0",
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/scripts/audio.py CHANGED
@@ -21,6 +21,7 @@ Examples:
21
21
  """
22
22
  import argparse
23
23
  import sys
24
+ from pathlib import Path
24
25
  from typing import Any, Dict, List
25
26
 
26
27
  from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
@@ -305,6 +306,8 @@ def main() -> int:
305
306
  cmd = ffmpeg_base() + inputs + ["-filter_complex", ";".join(graph), "-map", f"[{last}]"]
306
307
  if keep_video:
307
308
  cmd += ["-map", "0:v:0", "-c:v", "copy"]
309
+ if Path(output).suffix.lower() in (".mp4", ".mov", ".m4v"):
310
+ cmd += ["-movflags", "+faststart"]
308
311
  elif has_video:
309
312
  cmd += ["-vn"] # audio extension: the picture is dropped, not copied into a container that cannot hold it
310
313
  cmd += audio_codec_for(output, args.bitrate)
@@ -1260,6 +1260,8 @@ def main() -> int:
1260
1260
  for n in range(len(added)):
1261
1261
  maps += ["-map", f"{n + 1}:0"]
1262
1262
  cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
1263
+ if mp4_family:
1264
+ cmd += ["-movflags", "+faststart"]
1263
1265
  for i in range(existing_subs):
1264
1266
  cmd += [f"-c:s:{i}", "copy"]
1265
1267
  tracks: List[Dict[str, Any]] = []
package/scripts/cut.py CHANGED
@@ -78,7 +78,10 @@ def copy_args(meta: dict, dst: str) -> List[str]:
78
78
  """Stream-copy arguments: everything into a video container, audio only into an audio extension."""
79
79
  if is_audio_output(dst) and meta.get("video"):
80
80
  return ["-vn", "-c:a", "copy"]
81
- return ["-c", "copy"]
81
+ args = ["-c", "copy"]
82
+ if os.path.splitext(dst)[1].lower() in (".mp4", ".mov", ".m4v"):
83
+ args += ["-movflags", "+faststart"]
84
+ return args
82
85
 
83
86
 
84
87
  LOSSLESS_AUDIO = {"pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le", "flac"}
@@ -353,7 +356,10 @@ def main() -> int:
353
356
  with open(listfile, "w", encoding="utf-8") as fh:
354
357
  for p in parts:
355
358
  fh.write(concat_list_line(p) + "\n")
356
- cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy", output]
359
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy"]
360
+ if ext in (".mp4", ".mov", ".m4v"):
361
+ cmd += ["-movflags", "+faststart"]
362
+ cmd += [output]
357
363
  proc = run(cmd, check=False)
358
364
  if proc.returncode != 0:
359
365
  info("concat with stream copy failed, re-encoding the join")
package/scripts/sync.py CHANGED
@@ -335,6 +335,8 @@ def main() -> int:
335
335
  if head_trim > 0:
336
336
  cmd += ["-ss", f"{head_trim:.4f}"]
337
337
  cmd += ["-i", args.second, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy"]
338
+ if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
339
+ cmd += ["-movflags", "+faststart"]
338
340
  # the reference picture is the deliverable: pad the (possibly trimmed) second audio to
339
341
  # its length instead of -shortest, which cut the reference's tail whenever the second
340
342
  # file started earlier (head_trim > 0) or was simply shorter
@@ -350,7 +352,10 @@ def main() -> int:
350
352
  run(cmd)
351
353
  else:
352
354
  if head_trim > 0 and not drift_af:
353
- cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy", "-avoid_negative_ts", "make_zero", output]
355
+ cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy"]
356
+ if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
357
+ cmd += ["-movflags", "+faststart"]
358
+ cmd += ["-avoid_negative_ts", "make_zero", output]
354
359
  proc = run(cmd, check=False)
355
360
  if proc.returncode != 0:
356
361
  cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]