ffmpeg-skill 2.5.0 → 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/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.5.0`) | any release |
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.5.0", "execution_mode": "local", "kind": "execution",
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.5.0",
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",
@@ -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. Control characters are still stripped: a one-line burnt-in
110
- label has no use for them.
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 = re.sub(r"[\x00-\x1f\x7f]", "", text or "")
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:
@@ -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,
@@ -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),