ffmpeg-skill 1.1.1 → 1.3.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
@@ -26,7 +26,7 @@ npx ffmpeg-skill
26
26
 
27
27
  ![before / after demo](assets/demo.gif)
28
28
 
29
- `ffmpeg-skill` is an [Agent Skill](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills) for Claude Code, Cursor, Codex and any agent that reads `SKILL.md`. It teaches the agent a fixed workflow (probe → edit losslessly where possible → check → verify) and ships **40 tools** that do the actual work with `ffmpeg` / `ffprobe`: cut, join, silence removal, fit to duration and aspect, captions and karaoke, overlays and motion graphics, HDR → SDR and LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, whole-edit project rendering, batch folders. Every tool is also an MCP tool, and the whole set is described by a machine-readable contract.
29
+ `ffmpeg-skill` is an [Agent Skill](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills) for Claude Code, Cursor, Codex and any agent that reads `SKILL.md`. It teaches the agent a fixed workflow (probe → edit losslessly where possible → check → verify) and ships **41 tools** that do the actual work with `ffmpeg` / `ffprobe`: cut, join, silence removal, fit to duration and aspect, captions and karaoke, overlays and motion graphics, HDR → SDR and LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, whole-edit project rendering, batch folders. Every tool is also an MCP tool, and the whole set is described by a machine-readable contract.
30
30
 
31
31
  If `ffmpeg` and `python3` are on your PATH, it works: offline, on footage you would rather not upload.
32
32
 
@@ -140,7 +140,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
140
140
  1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
141
141
  2. **Lossless when possible.** `cut.py`, `join.py` and `loudness.py` stream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
142
142
  3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress` (percent and ETA). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
143
- 4. **Machine-readable contract.** `contract --json` describes all 40 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 40 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
143
+ 4. **Machine-readable contract.** `contract --json` describes all 41 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 40 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
144
144
  5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
145
145
  6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
146
146
  7. **Unknown is not missing.** When a listing cannot be read (a layout the parser does not know, ffmpeg exiting non-zero) the affected capabilities are `unknown`: never `missing`, never silently `available`. An installed filter is not reported absent; a failed detection is not a pass.
@@ -149,7 +149,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
149
149
 
150
150
  ## Tools
151
151
 
152
- 40 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, non-zero exit and a reason on stderr on failure.
152
+ 41 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, non-zero exit and a reason on stderr on failure.
153
153
 
154
154
  **Analysis and inspection**
155
155
 
@@ -184,6 +184,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
184
184
  | `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 |
185
185
  | `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 |
186
186
  | `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length |
187
+ | `metadata.py` | Write container chapter markers from a `TIME TITLE` text file and title/artist/comment tags, every stream copied bit for bit |
187
188
  | `grid.py` | Composite `--cols`x`--rows` clips into one grid, each cell letterboxed and labelled with its filename by default (`--label none` to skip) |
188
189
 
189
190
  **Audio**
@@ -272,7 +273,7 @@ npx ffmpeg-skill contract --json # or: python3 scripts/_contract.py -
272
273
  npx ffmpeg-skill contract --json --static # without environment detection
273
274
  ```
274
275
 
275
- The contract is generated from the code that runs, not maintained beside it. For each of the 40 tools (`ffmpeg-skill/<name>`) it states:
276
+ The contract is generated from the code that runs, not maintained beside it. For each of the 41 tools (`ffmpeg-skill/<name>`) it states:
276
277
 
277
278
  | Field | Meaning |
278
279
  |---|---|
package/SKILL.md CHANGED
@@ -118,7 +118,7 @@ This skill cuts, joins, measures, syncs, exports and checks files — it execute
118
118
 
119
119
  The line in general: if the same input and the same explicit parameters always produce the same, verifiable output, it belongs here. If the "right" answer depends on taste, content understanding, or what looks or sounds good, it belongs to whichever skill or agent makes that judgement — this skill only ever executes parameters it's given, never infers them from what something looks or sounds like.
120
120
 
121
- If a request needs an FFmpeg feature none of the 40 scripts expose, say so and name the closest built-in option (`--dry-run` to show what would run, or a documented limitation) — never fall back to guessing a raw `ffmpeg`/`ffprobe` invocation or a hand-built filter graph outside `scripts/*.py`. A raw command bypasses every guarantee this skill makes (no shell, typed arguments, verification afterwards); it is exactly the failure mode this skill exists to prevent, so it is never the fallback when a script's flag doesn't cover something.
121
+ If a request needs an FFmpeg feature none of the 41 scripts expose, say so and name the closest built-in option (`--dry-run` to show what would run, or a documented limitation) — never fall back to guessing a raw `ffmpeg`/`ffprobe` invocation or a hand-built filter graph outside `scripts/*.py`. A raw command bypasses every guarantee this skill makes (no shell, typed arguments, verification afterwards); it is exactly the failure mode this skill exists to prevent, so it is never the fallback when a script's flag doesn't cover something.
122
122
 
123
123
  ## Request → script
124
124
 
@@ -128,7 +128,7 @@ If a request needs an FFmpeg feature none of the 40 scripts expose, say so and n
128
128
  | "cut from 1:20 to 2:05", "trim the first 10 seconds" | `cut.py input.mp4 --start 1:20 --end 2:05` |
129
129
  | "keep only these parts", "remove the middle" | `cut.py input.mp4 --segments 0-1:00,1:30-2:00` |
130
130
  | "make it exactly 60 seconds", "fit it in 30s" | `fit.py input.mp4 --duration 60` (speed) or `--method trim` |
131
- | "make it vertical / for TikTok / 9:16", "square for Instagram" | `fit.py input.mp4 --aspect 9:16 --fit pad` (or `--fit crop`) |
131
+ | "make it vertical / for TikTok / 9:16", "square for Instagram" | `fit.py input.mp4 --aspect 9:16 --fit pad` (or `--fit crop`); add `--pad-fill blur` for the blurred-background bars phone editors produce |
132
132
  | "resize to a specific height, width follows" | `fit.py input.mp4 --height 1080` (or `--width`, or both for an exact frame) |
133
133
  | "crop to this exact box/rectangle" (known x/y/width/height, not an aspect ratio) | `crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920` |
134
134
  | "are there black bars on this?", "what's the crop rectangle to remove the letterboxing" | `cropdetect.py input.mp4` |
@@ -149,6 +149,8 @@ If a request needs an FFmpeg feature none of the 40 scripts expose, say so and n
149
149
  | "add some black at the start before the title card" | `pad.py clip.mp4 --start 1.5` |
150
150
  | "speed up here, slam into slow-mo there, then speed back up" (known segments) | `speedramp.py action.mp4 --segment 0-3:1.0 --segment 3-4:0.25 --segment 4-8:2.0` |
151
151
  | "loop this background clip to fill 30 seconds" | `loop.py bg_loop.mp4 --duration 30` |
152
+ | "add chapters at 0:00 Intro, 2:15 Setup, …", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (one `TIME TITLE` per line; streams are copied, nothing re-encodes) |
153
+ | "set the title / artist / comment on the file" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
152
154
  | "put these videos in a 4x2 grid with the filename on each" | `grid.py t1.mp4 t2.mp4 t3.mp4 t4.mp4 t5.mp4 t6.mp4 t7.mp4 t8.mp4 --cols 4 --rows 2` |
153
155
  | "add subtitles from this SRT", "burn in captions" | `caption.py input.mp4 --srt subs.srt` |
154
156
  | "caption it with these lines" (plain text with times) | `caption.py input.mp4 --text cues.txt` |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.1.1",
4
- "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 40 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.",
3
+ "version": "1.3.0",
4
+ "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 41 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",
7
7
  "video",
@@ -58,7 +58,7 @@ the result was "lossless stream copy" or "re-encoded".
58
58
  ### fit.py — target duration and/or aspect, rotate/flip
59
59
  ```
60
60
  fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
61
- [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--height H] [--pad-color black]]
61
+ [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--height H] [--pad-color black] [--pad-fill color|blur [--pad-blur 20]]]
62
62
  [--rotate 90|180|270] [--flip h|v] [--fps N] [-o OUT]
63
63
  ```
64
64
  `speed` retimes video and audio together (pitch-preserving `atempo`); it
@@ -73,6 +73,10 @@ is separate from the rotation *metadata* fit.py already reads to size a
73
73
  source correctly); `--flip h|v` mirrors the picture; both can combine, rotate
74
74
  first. `--fps` forces a constant frame rate; VFR sources are conformed
75
75
  automatically even without it.
76
+ `--pad-fill blur` fills the letterbox/pillarbox bars with a blurred, scaled-to-cover copy
77
+ of the frame (the look every phone editor gives landscape footage posted as a Short/Reel)
78
+ instead of the solid `--pad-color`; `--pad-blur` is the blur radius. `export.py --fit pad`
79
+ takes the same two flags.
76
80
 
77
81
  ### crop.py — crop to an exact pixel rectangle
78
82
  ```
@@ -283,6 +287,22 @@ loop point (no crossfade at the seam) -- a clip that doesn't already loop
283
287
  cleanly will show a visible cut/pop at each repeat, which is a property of
284
288
  the source material this tool cannot fix.
285
289
 
290
+ ### metadata.py — chapter markers and container tags, streams copied
291
+ ```
292
+ metadata.py INPUT [--chapters chapters.txt | --clear-chapters]
293
+ [--title T] [--artist A] [--album A] [--comment C] [--date D] [--genre G] [-o OUT]
294
+ ```
295
+ `chapters.txt` holds one chapter per line, `TIME TITLE` (cut.py's time syntax:
296
+ seconds, mm:ss, hh:mm:ss.ms); each chapter ends where the next starts and the
297
+ last runs to the end of the file. Starts must ascend and lie inside the file.
298
+ Every stream is `-c copy` (bit for bit; `probe` reports the result under
299
+ `chapters` and `tags`), so this is instant and lossless. Chapter markers need a
300
+ container that can hold them (.mp4/.m4v/.m4a/.mov, .mkv/.mka/.webm); `.wav`,
301
+ `.gif`, `.mp3` and `.flac` outputs are refused for `--chapters` rather than
302
+ silently dropping them. Tags alone are written to any container that has them.
303
+ `--clear-chapters` removes existing markers; an empty tag value (`--comment ""`)
304
+ clears that tag.
305
+
286
306
  ### grid.py — composite clips into a grid
287
307
  ```
288
308
  grid.py CLIP1 CLIP2 [...] --cols N --rows N [--cell-width W] [--cell-height H]
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import json
10
10
  import os
11
11
  import platform
12
+ import argparse
12
13
  import re
13
14
  import shutil
14
15
  import subprocess
@@ -106,6 +107,31 @@ def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
106
107
  return str(max(vertical, horizontal))
107
108
 
108
109
 
110
+ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int) -> str:
111
+ """The letterbox/pillarbox step shared by fit.py and export.py, as one -vf segment.
112
+
113
+ fill="color": scale to fit, then pad with a solid colour (the historical behaviour).
114
+ fill="blur": the bars are a blurred, scaled-to-cover copy of the same frame -- what every
115
+ phone editor's "make it vertical" does with landscape footage (#139). Built as a small
116
+ graph inside the -vf chain: split, one branch scaled to cover and cropped to the frame
117
+ then boxblur'ed, the other scaled to fit, overlaid centred. Only `filter:boxblur` is
118
+ needed beyond the usual scale/pad set, and that is already required by redact.py."""
119
+ if fill == "blur":
120
+ radius = max(1, int(blur))
121
+ return (f"split[__fitfg][__fitbg];"
122
+ f"[__fitbg]scale={out_w}:{out_h}:force_original_aspect_ratio=increase,crop={out_w}:{out_h},"
123
+ f"boxblur={radius}:2[__fitbgb];"
124
+ f"[__fitfg]scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[__fitfgs];"
125
+ f"[__fitbgb][__fitfgs]overlay=(W-w)/2:(H-h)/2:format=auto")
126
+ return f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={color}"
127
+
128
+
129
+ def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
130
+ parser.add_argument("--pad-fill", choices=["color", "blur"], default="color",
131
+ help="what fills the letterbox/pillarbox bars under --fit pad: a solid --pad-color (default) or a blurred, scaled-up copy of the frame")
132
+ parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
133
+
134
+
109
135
  def die(msg: str, code: int = 1, kind: str = "input") -> "None":
110
136
  """Exit with a message. Under --json also print a machine-readable failure document
111
137
  (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
@@ -442,7 +468,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
442
468
  die(f"input not found: {path}")
443
469
  ffprobe = require_tool("ffprobe")
444
470
  proc = run(
445
- [ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path],
471
+ [ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
446
472
  quiet=True,
447
473
  check=False,
448
474
  )
@@ -476,6 +502,15 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
476
502
  "audio": None,
477
503
  "subtitle_streams": len(subs),
478
504
  "data_streams": data_stream_count,
505
+ # container-level chapter markers and the common tags, so metadata.py's result is
506
+ # verifiable the same way every other tool's is (additive keys, 1.x-safe)
507
+ "chapters": [{
508
+ "index": n,
509
+ "start": _to_float(ch.get("start_time")),
510
+ "end": _to_float(ch.get("end_time")),
511
+ "title": (ch.get("tags") or {}).get("title"),
512
+ } for n, ch in enumerate(raw.get("chapters") or [])],
513
+ "tags": {k.lower(): v for k, v in (fmt.get("tags") or {}).items() if k.lower() in ("title", "artist", "album", "comment", "date", "genre")},
479
514
  # every subtitle stream in file order: index n here is `-map 0:s:n`
480
515
  "subtitle_stream_details": [{
481
516
  "index": n,
@@ -74,7 +74,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
74
74
  required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"}, HDR_X265, {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
75
75
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
76
76
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
77
- required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
77
+ required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"},
78
+ {"capability": "filter:boxblur", "when": "--pad-fill blur"}],
78
79
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
79
80
  "crop": dict(role="execution", inputs=["video asset"], outputs=["video artifact cropped to the given pixel rectangle"],
80
81
  required=FF + [X264, AAC], optional=[HDR_X265],
@@ -112,6 +113,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
112
113
  "speedramp": dict(role="execution", inputs=["video asset"], outputs=["video artifact with a stepped speed ramp applied across segments"],
113
114
  required=FF + [X264, AAC], optional=[HDR_X265],
114
115
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
116
+ "metadata": dict(role="execution", inputs=["video or audio asset", "chapters text file (--chapters)"], outputs=["the same streams, stream-copied, with chapter markers and/or title/artist/comment tags written"],
117
+ required=FF, optional=[],
118
+ video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
115
119
  "loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration"],
116
120
  required=FF + [X264, AAC], optional=[],
117
121
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
@@ -174,7 +178,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
174
178
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
175
179
  required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
176
180
  {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
177
- {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}],
181
+ {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"}],
178
182
  video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
179
183
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
180
184
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
@@ -239,6 +243,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
239
243
  "freeze": dict(video="always", audio="always", note="the tpad/concat filter graph always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
240
244
  "pad": dict(video="always", audio="always", note="the tpad filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
241
245
  "speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
246
+ "metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change"),
242
247
  "loop": dict(video="always", audio="always", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present"),
243
248
  "insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
244
249
  "background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
package/scripts/export.py CHANGED
@@ -26,8 +26,7 @@ import sys
26
26
  from pathlib import Path
27
27
  from typing import Dict, List
28
28
 
29
- from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color
30
-
29
+ from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color, pad_filters, add_pad_fill_args
31
30
  PRESETS: Dict[str, Dict] = {
32
31
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
33
32
  "youtube4k": {"w": 3840, "h": 2160, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "2160p H.264, AAC 192k"},
@@ -48,6 +47,7 @@ def main() -> int:
48
47
  ap.add_argument("--preset", choices=sorted(PRESETS), help="delivery preset")
49
48
  ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how to reach the preset frame when aspect differs (default pad)")
50
49
  ap.add_argument("--pad-color", default="black")
50
+ add_pad_fill_args(ap)
51
51
  ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
52
52
  ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
53
53
  ap.add_argument("--crf", type=int, help="override CRF")
@@ -80,7 +80,7 @@ def main() -> int:
80
80
  if args.fit == "crop":
81
81
  vf += [f"scale={p['w']}:{p['h']}:force_original_aspect_ratio=increase", f"crop={p['w']}:{p['h']}"]
82
82
  else:
83
- vf += [f"scale={p['w']}:{p['h']}:force_original_aspect_ratio=decrease", f"pad={p['w']}:{p['h']}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"]
83
+ vf.append(pad_filters(p["w"], p["h"], args.pad_fill, args.pad_color, args.pad_blur))
84
84
  vf.append("setsar=1")
85
85
  else:
86
86
  vf.append(f"scale={p['w']}:-2")
package/scripts/fit.py CHANGED
@@ -4,7 +4,7 @@
4
4
  Duration: --duration N with --method speed (retime video+audio, pitch-preserving
5
5
  via atempo chaining) or --method trim (keep the first N seconds, or a centred
6
6
  window with --from-center). Aspect: --aspect 16:9|9:16|1:1|4:5|W:H with
7
- --fit pad (letterbox/pillarbox with --pad-color, default black) or --fit crop.
7
+ --fit pad (letterbox/pillarbox with --pad-color, default black, or --pad-fill blur\nfor a blurred copy of the frame behind the picture) or --fit crop.
8
8
  --width and/or --height set the output size: give one and the other follows
9
9
  the aspect (source aspect if --aspect is not also given); give both for an
10
10
  exact frame. --rotate 90|180|270 (clockwise) and --flip h|v apply a new
@@ -24,6 +24,7 @@ Examples:
24
24
  python3 fit.py input.mp4 --duration 60 # speed up/down to exactly 60s
25
25
  python3 fit.py input.mp4 --duration 30 --method trim
26
26
  python3 fit.py input.mp4 --aspect 9:16 --fit pad --width 1080
27
+ python3 fit.py input.mp4 --aspect 9:16 --fit pad --pad-fill blur # the phone-editor look: blurred frame behind the bars
27
28
  python3 fit.py input.mp4 --aspect 1:1 --fit crop --duration 15
28
29
  python3 fit.py input.mp4 --aspect 9:16 --fit crop --crop-x 1 # keep the right edge (e.g. product held stage-right)
29
30
  python3 fit.py input.mp4 --height 1080 # width follows the source aspect
@@ -37,8 +38,7 @@ import sys
37
38
  from fractions import Fraction
38
39
  from typing import List
39
40
 
40
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args
41
-
41
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args
42
42
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
43
43
 
44
44
 
@@ -93,6 +93,7 @@ def main() -> int:
93
93
  a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect); with --height also given, both are used directly")
94
94
  a.add_argument("--height", type=int, help="output height in px (default: keep source height or the height implied by the aspect); with --width also given, both are used directly")
95
95
  a.add_argument("--pad-color", default="black", help="pad colour, e.g. black, white, 0x101010 (default black)")
96
+ add_pad_fill_args(a)
96
97
  a.add_argument("--crop-x", type=float, default=0.5, help="with --fit crop, horizontal anchor 0=left, 0.5=centre (default), 1=right")
97
98
  a.add_argument("--crop-y", type=float, default=0.5, help="with --fit crop, vertical anchor 0=top, 0.5=centre (default), 1=bottom")
98
99
  r = ap.add_argument_group("rotate / flip")
@@ -115,6 +116,8 @@ def main() -> int:
115
116
  if not 0.0 <= args.crop_y <= 1.0:
116
117
  die(f"--crop-y must be 0..1, got {args.crop_y}")
117
118
  validate_color(args.pad_color, "--pad-color")
119
+ if args.pad_blur <= 0:
120
+ die(f"--pad-blur must be > 0, got {args.pad_blur}")
118
121
 
119
122
  meta = probe(args.input)
120
123
  if not meta.get("video"):
@@ -212,8 +215,7 @@ def main() -> int:
212
215
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
213
216
  vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
214
217
  else:
215
- vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease")
216
- vf.append(f"pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}")
218
+ vf.append(pad_filters(out_w, out_h, args.pad_fill, args.pad_color, args.pad_blur))
217
219
  vf.append("setsar=1")
218
220
 
219
221
  if args.fps:
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """Write container chapters and title/artist/comment tags without touching the streams.
3
+
4
+ Chapter markers are what YouTube, VLC, Apple Podcasts and MKV players show as a
5
+ seekable list; graphics.py --template chapter burns a *card* into the picture, this
6
+ writes the *metadata*. Every stream is copied bit for bit (-c copy): the only thing
7
+ that changes is the container's metadata, so this is instant and lossless.
8
+
9
+ Chapters come from a text file, one per line, `TIME TITLE` with the same time syntax
10
+ as cut.py (seconds, mm:ss, hh:mm:ss.ms). Each chapter ends where the next starts; the
11
+ last one ends at the file's duration. Containers with no chapter support (.wav, .gif,
12
+ .mp3, .flac) are refused for --chapters rather than silently dropping them; tags alone
13
+ are written wherever the container can hold them.
14
+
15
+ Examples:
16
+ python3 metadata.py episode.mp4 --chapters chapters.txt
17
+ python3 metadata.py episode.mp4 --title "Episode 12" --artist "Studio" --comment "final cut"
18
+ python3 metadata.py master.mkv --chapters chapters.txt --title "Master" -o master_tagged.mkv
19
+ python3 metadata.py episode.mp4 --clear-chapters
20
+
21
+ chapters.txt:
22
+ 0:00 Intro
23
+ 2:15 Setup
24
+ 1:03:00 Outro
25
+ """
26
+ import argparse
27
+ import os
28
+ import sys
29
+ import tempfile
30
+ from pathlib import Path
31
+ from typing import Any, Dict, List, Optional
32
+
33
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, STATE
34
+
35
+ CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
36
+ TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
37
+
38
+
39
+ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
40
+ """`TIME TITLE` per line -> [{"start", "end", "title"}], validated: ascending starts, every
41
+ start inside the file, the last chapter running to the file's end."""
42
+ text = Path(path).read_text(encoding="utf-8")
43
+ entries: List[Dict[str, Any]] = []
44
+ for n, raw in enumerate(text.splitlines(), start=1):
45
+ line = raw.strip()
46
+ if not line or line.startswith("#"):
47
+ continue
48
+ parts = line.split(None, 1)
49
+ try:
50
+ start = parse_time(parts[0])
51
+ except ValueError:
52
+ die(f"{path}:{n}: cannot read the time in {line!r} (use seconds, mm:ss or hh:mm:ss.ms)")
53
+ title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
54
+ if entries and start <= entries[-1]["start"]:
55
+ die(f"{path}:{n}: chapter at {start:g}s does not come after the previous one at {entries[-1]['start']:g}s")
56
+ if duration and start >= duration:
57
+ die(f"{path}:{n}: chapter at {start:g}s starts at or after the end of the file ({duration:.3f}s)")
58
+ entries.append({"start": start, "title": title})
59
+ if not entries:
60
+ die(f"{path}: no chapters found (one per line: `0:00 Intro`)")
61
+ for i, e in enumerate(entries):
62
+ e["end"] = entries[i + 1]["start"] if i + 1 < len(entries) else duration
63
+ return entries
64
+
65
+
66
+ def _ffmeta_escape(value: str) -> str:
67
+ # ffmetadata: backslash escapes =, ;, #, \ and newline
68
+ out = []
69
+ for ch in value:
70
+ if ch in "=;#\\":
71
+ out.append("\\" + ch)
72
+ elif ch == "\n":
73
+ out.append("\\\n")
74
+ else:
75
+ out.append(ch)
76
+ return "".join(out)
77
+
78
+
79
+ def write_ffmetadata(chapters: List[Dict[str, Any]], path: str) -> None:
80
+ lines = [";FFMETADATA1"]
81
+ for c in chapters:
82
+ lines += ["[CHAPTER]", "TIMEBASE=1/1000", f"START={int(round(c['start'] * 1000))}",
83
+ f"END={int(round(c['end'] * 1000))}", f"title={_ffmeta_escape(c['title'])}"]
84
+ Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
85
+
86
+
87
+ def main() -> int:
88
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
89
+ ap.add_argument("input")
90
+ ap.add_argument("-o", "--output", help="output file (default: <name>_meta.<ext>)")
91
+ ap.add_argument("--chapters", help="text file, one chapter per line: `TIME TITLE` (cut.py time syntax)")
92
+ ap.add_argument("--clear-chapters", action="store_true", help="remove every chapter marker the input carries")
93
+ for key in TAG_KEYS:
94
+ ap.add_argument(f"--{key}", help=f"set the container's {key} tag (empty string clears it)")
95
+ add_common(ap)
96
+ args = ap.parse_args()
97
+ apply_common(args)
98
+
99
+ if args.chapters and args.clear_chapters:
100
+ die("--chapters and --clear-chapters exclude each other")
101
+ tags = {k: getattr(args, k) for k in TAG_KEYS if getattr(args, k) is not None}
102
+ if not args.chapters and not args.clear_chapters and not tags:
103
+ die("nothing to write: give --chapters FILE, --clear-chapters and/or --title/--artist/...")
104
+ if args.chapters and not os.path.exists(args.chapters):
105
+ die(f"chapters file not found: {args.chapters}")
106
+
107
+ meta = probe(args.input)
108
+ output = args.output or default_output(args.input, "meta")
109
+ if os.path.abspath(output) == os.path.abspath(args.input):
110
+ die("output must differ from the input (metadata.py never rewrites a file in place)")
111
+ out_ext = Path(output).suffix.lower()
112
+ if (args.chapters or args.clear_chapters) and out_ext not in CHAPTER_CONTAINERS:
113
+ die(f"{out_ext or 'this'} container cannot hold chapter markers; write to one of "
114
+ f"{', '.join(sorted(CHAPTER_CONTAINERS))} (the streams are copied, so choose the matching family: .mp4/.mov/.m4a for MPEG-4, .mkv/.mka/.webm for Matroska)")
115
+
116
+ duration = meta.get("duration") or 0.0
117
+ chapters: Optional[List[Dict[str, Any]]] = parse_chapters(args.chapters, duration) if args.chapters else None
118
+
119
+ cmd = ffmpeg_base() + ["-i", args.input]
120
+ tmpdir = None
121
+ if chapters:
122
+ tmpdir = tempfile.TemporaryDirectory(prefix="ffskill_meta_")
123
+ ffmeta = os.path.join(tmpdir.name, "chapters.ffmeta")
124
+ write_ffmetadata(chapters, ffmeta)
125
+ cmd += ["-i", ffmeta, "-map", "0", "-map_metadata", "0", "-map_chapters", "1"]
126
+ elif args.clear_chapters:
127
+ cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "-1"]
128
+ else:
129
+ cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "0"]
130
+ for key, value in tags.items():
131
+ cmd += ["-metadata", f"{key}={value}"]
132
+ cmd += ["-c", "copy", output]
133
+ run(cmd)
134
+ if tmpdir:
135
+ tmpdir.cleanup()
136
+
137
+ result = probe(output, role="output")
138
+ written = result.get("chapters") or []
139
+ if chapters is not None and not STATE["dry_run"] and len(written) != len(chapters):
140
+ die(f"wrote {len(written)} chapters but {len(chapters)} were asked for", kind="output")
141
+ if args.clear_chapters and not STATE["dry_run"] and written:
142
+ die(f"{len(written)} chapters survived --clear-chapters", kind="output")
143
+ info(f"wrote {output} ({len(written)} chapters, tags: {', '.join(sorted(tags)) or 'unchanged'}, streams copied)")
144
+ emit(output, chapters=written, tags=result.get("tags") or {}, streams_copied=True)
145
+ return 0
146
+
147
+
148
+ if __name__ == "__main__":
149
+ sys.exit(main())