ffmpeg-skill 1.8.1 → 1.9.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/SKILL.md +15 -1
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +18 -2
- package/scripts/_common.py +21 -2
- package/scripts/broll.py +5 -5
- package/scripts/caption.py +2 -2
- package/scripts/cut.py +7 -11
- package/scripts/freeze.py +2 -5
- package/scripts/metadata.py +3 -5
- package/scripts/multicam.py +3 -2
- package/scripts/render.py +10 -3
- package/scripts/speedramp.py +4 -3
package/SKILL.md
CHANGED
|
@@ -66,7 +66,8 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
66
66
|
7. **Keep the user's originals.** Never overwrite the source file. Write new
|
|
67
67
|
files next to the input or where the user asked.
|
|
68
68
|
8. **Look at the picture.** Whenever the picture changed (captions, overlays,
|
|
69
|
-
graphics, crop/pad, resize, colour, transitions
|
|
69
|
+
graphics, crop/pad, resize, colour, transitions, a `join.py` that scaled or
|
|
70
|
+
padded a clip to the first clip's frame) run `look.py OUTPUT`
|
|
70
71
|
(contact sheet) or `look.py OUTPUT --at T`, view the PNG. The job is not
|
|
71
72
|
finished until the report's `Look:` line names that PNG; a probe alone
|
|
72
73
|
cannot see a caption sitting on someone's face. Audio-only jobs (sync,
|
|
@@ -162,6 +163,7 @@ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and n
|
|
|
162
163
|
| "remove the green screen", "chroma key this" | `overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00` |
|
|
163
164
|
| "sync the lav mic to the camera", "line up the two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` / `sync.py camA.mp4 camB.mp4 --trim-second` |
|
|
164
165
|
| "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
|
|
166
|
+
| "cut this and make it HEVC / AV1", "a ProRes intermediate of the trimmed clip" (an edit whose *output codec* the user named) | `cut.py input.mp4 --start 0:10 --end 0:40 --codec hevc` (`--codec h264\|hevc\|av1\|prores` and `--quality N` on every editing tool that re-encodes; ProRes needs `-o NAME.mov`; without a named codec leave the default) |
|
|
165
167
|
| "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` (`--normalize` meets the platform's loudness spec in the same call, no separate `loudness.py` pass) |
|
|
166
168
|
| "make a GIF preview" | `export.py input.mp4 --preset gif` |
|
|
167
169
|
| "make a small/low-res proxy for an analysis pass", "a cheap preview file" | `proxy.py input.mp4 [--width 640 --no-audio]` — not a delivery preset, see `export.py` for those |
|
|
@@ -253,6 +255,18 @@ Look: final_sheet.png (captions inside the safe area, logo top-right)
|
|
|
253
255
|
Notes: source was VFR, conformed to 30 fps; audio was mono, made stereo
|
|
254
256
|
```
|
|
255
257
|
|
|
258
|
+
The same five lines for a Japanese request, prose in Japanese around the English labels
|
|
259
|
+
(this is the shape a short job keeps too; English `Done:`/`Steps:` sentences with one Japanese
|
|
260
|
+
word in `Notes:` is not a Japanese report):
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
Done: final.mp4 — 59.98 秒、1080x1920、30 fps、H.264、AAC ステレオ、-14.1 LUFS
|
|
264
|
+
Steps: 0:12-1:12 をカット(無劣化)-> 9:16 にクロップ -> 字幕(ポップ、カラオケ)-> ラウドネス -14 -> Reels 書き出し
|
|
265
|
+
Check: reels — 12 項目すべて合格(verified: true)
|
|
266
|
+
Look: final_sheet.png(字幕はセーフエリア内、ロゴは右上)
|
|
267
|
+
Notes: 元は VFR だったので 30 fps に揃えた。音声はモノラルだったのでステレオにした
|
|
268
|
+
```
|
|
269
|
+
|
|
256
270
|
Keep it to those five lines plus anything the user must decide. Attach the contact sheet when the edit touched the picture. Never report success without the probe of the output; never describe a fix you did not run.
|
|
257
271
|
|
|
258
272
|
When a step fails, replace `Done:` with `Failed:` and keep the rest honest:
|
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
|
+
| `skill.version` | the npm / package.json version (`1.9.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`
|
|
@@ -83,7 +83,7 @@ on, the line says so.
|
|
|
83
83
|
```json
|
|
84
84
|
{
|
|
85
85
|
"contract_version": "1.0",
|
|
86
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.9.1", "execution_mode": "local", "kind": "execution",
|
|
87
87
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
88
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
89
|
"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": "1.
|
|
3
|
+
"version": "1.9.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
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, overrides `--crf`; up to 63 for av1; ignored by prores). `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither flag; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
4
4
|
|
|
5
|
+
## Time grammar (every time-taking flag, 1.9)
|
|
6
|
+
|
|
7
|
+
One parser, `time_arg()`, behind every `--start`, `--end`, `--at`, `--from`,
|
|
8
|
+
`--duration`, `--segments`, cue file and project field: seconds (`12.5`),
|
|
9
|
+
`mm:ss(.fff)` (`1:30`), `hh:mm:ss(.fff)` (`00:01:30.250`; a comma also works,
|
|
10
|
+
as in SRT). A four-part `hh:mm:ss:ff` is SMPTE non-drop-frame timecode at the
|
|
11
|
+
source's frame rate; append `@fps` (`00:01:02:15@29.97`) to name the rate
|
|
12
|
+
yourself, which is the only way for a tool with no input file (`caption.py
|
|
13
|
+
--text` without a video, and `--fps` there). A four-part value with no fps
|
|
14
|
+
anywhere is `kind: input` naming the flag. Nothing else about times differs
|
|
15
|
+
between tools.
|
|
16
|
+
|
|
5
17
|
## Contents
|
|
6
18
|
- probe.py — inspect
|
|
7
19
|
- cut.py — cut / join segments
|
|
@@ -43,7 +55,9 @@ Every script prints the same information with `--help`; this file exists so the
|
|
|
43
55
|
```
|
|
44
56
|
probe.py INPUT... [--compact] [--field duration|video.fps|...]
|
|
45
57
|
```
|
|
46
|
-
JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected}
|
|
58
|
+
JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected,hdr,hdr_signal,hdr_format}`
|
|
59
|
+
(`hdr_signal` is true only for a PQ / HLG transfer or Dolby Vision; `hdr` also counts
|
|
60
|
+
BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR" -- 2.0 renames),
|
|
47
61
|
`audio{codec,channels,sample_rate}`. `--compact` gives one line per file.
|
|
48
62
|
|
|
49
63
|
### cut.py — cut / join segments
|
|
@@ -386,7 +400,9 @@ for a `--platform` or a platform export preset), and reports `plan`, `tool`,
|
|
|
386
400
|
`tool_result` and `check`. Show the plan to the user, get the yes, execute:
|
|
387
401
|
one round trip instead of re-deriving the command.
|
|
388
402
|
`"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
|
|
389
|
-
so the rendered file meets the platform's loudness without a separate pass.
|
|
403
|
+
so the rendered file meets the platform's loudness without a separate pass. Since
|
|
404
|
+
1.9.0 it is on by default when the preset is a platform (`youtube|youtube4k|reels|x`)
|
|
405
|
+
and the project has no `loudness` stage; `"normalize": false` opts out.
|
|
390
406
|
|
|
391
407
|
Stages: clips (cut, optional speed) → join (transition) → silence → fit →
|
|
392
408
|
captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
|
package/scripts/_common.py
CHANGED
|
@@ -1322,6 +1322,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
1322
1322
|
"pix_fmt": video.get("pix_fmt"),
|
|
1323
1323
|
"bit_depth": _bit_depth(pix),
|
|
1324
1324
|
"hdr": hdr,
|
|
1325
|
+
# 1.9 (2.0 A1 pre-shipped as a parallel key): true only for a PQ / HLG transfer or Dolby
|
|
1326
|
+
# Vision, i.e. a genuinely HDR signal. `hdr` also counts BT.2020 primaries on an SDR
|
|
1327
|
+
# transfer ("BT.2020 SDR" in hdr_format) and keeps that meaning until 2.0 renames it.
|
|
1328
|
+
"hdr_signal": trc in ("smpte2084", "arib-std-b67") or bool(dovi),
|
|
1325
1329
|
"hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
|
|
1326
1330
|
"HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
|
|
1327
1331
|
"dolby_vision": dovi,
|
|
@@ -1429,10 +1433,25 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
1429
1433
|
v = value.strip().replace(",", ".")
|
|
1430
1434
|
if not v:
|
|
1431
1435
|
raise ValueError("empty time")
|
|
1436
|
+
if "@" in v:
|
|
1437
|
+
# 1.9: 'hh:mm:ss:ff@29.97' names the timecode's rate explicitly (docs/design-decisions.md,
|
|
1438
|
+
# time grammar); it overrides the source fps a tool passed in, and is meaningless without
|
|
1439
|
+
# the four-part form
|
|
1440
|
+
v, _, rate = v.rpartition("@")
|
|
1441
|
+
if "@" in v:
|
|
1442
|
+
raise ValueError(f"'{value}': only one @fps suffix is allowed")
|
|
1443
|
+
try:
|
|
1444
|
+
fps = float(rate)
|
|
1445
|
+
except ValueError:
|
|
1446
|
+
raise ValueError(f"bad @fps suffix in '{value}' (expected a number such as @29.97)")
|
|
1447
|
+
if fps <= 0:
|
|
1448
|
+
raise ValueError(f"bad @fps suffix in '{value}': the rate must be positive")
|
|
1449
|
+
if len(v.split(":")) != 4:
|
|
1450
|
+
raise ValueError(f"'{value}': the @fps suffix belongs to an hh:mm:ss:ff timecode, not to seconds or mm:ss")
|
|
1432
1451
|
parts = v.split(":")
|
|
1433
1452
|
if len(parts) == 4:
|
|
1434
1453
|
if fps is None or fps <= 0:
|
|
1435
|
-
raise MissingFpsError(f"'{value}' looks like an hh:mm:ss:ff SMPTE timecode, but no fps was given to convert its frame count to seconds")
|
|
1454
|
+
raise MissingFpsError(f"'{value}' looks like an hh:mm:ss:ff SMPTE timecode, but no fps was given to convert its frame count to seconds (append @fps, e.g. {value}@29.97, or use seconds / mm:ss / hh:mm:ss.ms)")
|
|
1436
1455
|
h, m, s, f = parts
|
|
1437
1456
|
if "." in f:
|
|
1438
1457
|
raise ValueError(f"bad SMPTE timecode: {value}")
|
|
@@ -1462,7 +1481,7 @@ def time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
|
|
|
1462
1481
|
except MissingFpsError as e:
|
|
1463
1482
|
die(f"{flag} {value!r}: {e}")
|
|
1464
1483
|
except ValueError as e:
|
|
1465
|
-
die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms or
|
|
1484
|
+
die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff at the source's fps or with an explicit @fps suffix)")
|
|
1466
1485
|
return 0.0 # unreachable
|
|
1467
1486
|
|
|
1468
1487
|
|
package/scripts/broll.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info,
|
|
24
|
+
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -69,13 +69,13 @@ def main() -> int:
|
|
|
69
69
|
meta_b = probe(path)
|
|
70
70
|
if not meta_b.get("video"):
|
|
71
71
|
die(f"{path} has no video stream")
|
|
72
|
-
at =
|
|
73
|
-
start_b =
|
|
72
|
+
at = time_arg(args.at[i], "--at", fps)
|
|
73
|
+
start_b = time_arg(per(args.from_, i, "0"), "--from", fps)
|
|
74
74
|
if args.end:
|
|
75
|
-
end =
|
|
75
|
+
end = time_arg(per(args.end, i, "0"), "--end", fps)
|
|
76
76
|
length = end - at
|
|
77
77
|
else:
|
|
78
|
-
length =
|
|
78
|
+
length = time_arg(per(args.duration, i, "4"), "--duration", fps)
|
|
79
79
|
if length <= 0:
|
|
80
80
|
die(f"cutaway {i + 1}: length must be > 0 (at {at:g}s, got {length:g}s)")
|
|
81
81
|
if dur_a and at >= dur_a:
|
package/scripts/caption.py
CHANGED
|
@@ -19,7 +19,7 @@ The subtitle codec is picked from the output container: mov_text for
|
|
|
19
19
|
Text-to-SRT input format (one cue per line, blank lines ignored):
|
|
20
20
|
0:00-0:03 Hello and welcome
|
|
21
21
|
00:00:03.500 --> 00:00:06 Second line | with a manual line break
|
|
22
|
-
00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode (hh:mm:ss:ff, needs --fps)
|
|
22
|
+
00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode (hh:mm:ss:ff, needs --fps or an @fps suffix: 00:00:03:15@29.97)
|
|
23
23
|
Text without a time is auto-timed after the previous cue (--auto-seconds)
|
|
24
24
|
|
|
25
25
|
Examples:
|
|
@@ -41,7 +41,7 @@ from _common import STATE, color_hex, load_brand, video_args, add_common, apply_
|
|
|
41
41
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
42
42
|
|
|
43
43
|
TIME_RE = re.compile(
|
|
44
|
-
r"^\s*(?P<a>[\d
|
|
44
|
+
r"^\s*(?P<a>[\d:.,@]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
|
package/scripts/cut.py
CHANGED
|
@@ -30,7 +30,7 @@ import sys
|
|
|
30
30
|
import tempfile
|
|
31
31
|
from typing import List, Tuple
|
|
32
32
|
|
|
33
|
-
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output,
|
|
33
|
+
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
|
|
34
34
|
|
|
35
35
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
36
36
|
DROPPED_STREAMS: List[str] = []
|
|
@@ -39,13 +39,9 @@ DROPPED_STREAMS: List[str] = []
|
|
|
39
39
|
NEAREST_KEYFRAMES: list = []
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def _t(value: str, fps) -> float:
|
|
43
|
-
"""
|
|
44
|
-
|
|
45
|
-
return parse_time(value, fps)
|
|
46
|
-
except (ValueError, MissingFpsError) as e:
|
|
47
|
-
die(f"bad time {value!r}: {e}")
|
|
48
|
-
return 0.0 # unreachable
|
|
42
|
+
def _t(value: str, fps, flag: str = "--segments") -> float:
|
|
43
|
+
"""time_arg() with the input's fps (SMPTE hh:mm:ss:ff, or @fps): the one parser every tool uses (1.9)."""
|
|
44
|
+
return time_arg(value, flag, fps)
|
|
49
45
|
|
|
50
46
|
|
|
51
47
|
def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
|
|
@@ -183,17 +179,17 @@ def main() -> int:
|
|
|
183
179
|
if args.segments:
|
|
184
180
|
segments = parse_segments(args.segments, fps)
|
|
185
181
|
else:
|
|
186
|
-
start = _t(args.start, fps)
|
|
182
|
+
start = _t(args.start, fps, "--start")
|
|
187
183
|
if start < 0:
|
|
188
184
|
die(f"--start must not be negative, got {args.start!r}")
|
|
189
185
|
if args.end and args.duration:
|
|
190
186
|
die("use --end or --duration, not both")
|
|
191
187
|
if args.end:
|
|
192
|
-
end = _t(args.end, fps)
|
|
188
|
+
end = _t(args.end, fps, "--end")
|
|
193
189
|
if end < 0:
|
|
194
190
|
die(f"--end must not be negative, got {args.end!r}")
|
|
195
191
|
elif args.duration:
|
|
196
|
-
end = start + _t(args.duration, fps)
|
|
192
|
+
end = start + _t(args.duration, fps, "--duration")
|
|
197
193
|
else:
|
|
198
194
|
end = total
|
|
199
195
|
if end <= start:
|
package/scripts/freeze.py
CHANGED
|
@@ -19,7 +19,7 @@ Examples:
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError,
|
|
22
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, time_arg, fmt_secs, run
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def main() -> int:
|
|
@@ -45,10 +45,7 @@ def main() -> int:
|
|
|
45
45
|
dur = meta.get("duration") or 0.0
|
|
46
46
|
fps = meta["video"].get("fps") or 30.0
|
|
47
47
|
if args.at is not None:
|
|
48
|
-
|
|
49
|
-
at = parse_time(args.at, (meta.get("video") or {}).get("fps"))
|
|
50
|
-
except (ValueError, MissingFpsError) as e:
|
|
51
|
-
die(f"--at {args.at!r}: {e}")
|
|
48
|
+
at = time_arg(args.at, "--at", (meta.get("video") or {}).get("fps"))
|
|
52
49
|
else:
|
|
53
50
|
at = dur
|
|
54
51
|
if at < 0 or at > dur:
|
package/scripts/metadata.py
CHANGED
|
@@ -30,7 +30,7 @@ import tempfile
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
from typing import Any, Dict, List, Optional
|
|
32
32
|
|
|
33
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info,
|
|
33
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, STATE, read_text_or_die
|
|
34
34
|
|
|
35
35
|
CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
|
|
36
36
|
TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
|
|
@@ -46,10 +46,8 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
|
|
|
46
46
|
if not line or line.startswith("#"):
|
|
47
47
|
continue
|
|
48
48
|
parts = line.split(None, 1)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
except ValueError: # MissingFpsError is a ValueError: chapter files carry no fps
|
|
52
|
-
die(f"{path}:{n}: cannot read the time in {line!r} (use seconds, mm:ss or hh:mm:ss.ms)")
|
|
49
|
+
# chapter files carry no fps, so hh:mm:ss:ff needs its @fps suffix; time_arg() says so
|
|
50
|
+
start = time_arg(parts[0], f"{path}:{n}")
|
|
53
51
|
title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
|
|
54
52
|
if entries and start <= entries[-1]["start"]:
|
|
55
53
|
die(f"{path}:{n}: chapter at {start:g}s does not come after the previous one at {entries[-1]['start']:g}s")
|
package/scripts/multicam.py
CHANGED
|
@@ -29,7 +29,7 @@ import argparse
|
|
|
29
29
|
import sys
|
|
30
30
|
from typing import List, Tuple
|
|
31
31
|
|
|
32
|
-
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info,
|
|
32
|
+
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs
|
|
33
33
|
from sync import measure_offset
|
|
34
34
|
|
|
35
35
|
|
|
@@ -42,9 +42,10 @@ def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
|
|
|
42
42
|
try:
|
|
43
43
|
rng, cam = raw.rsplit(":", 1)
|
|
44
44
|
a, b = rng.rsplit("-", 1)
|
|
45
|
-
|
|
45
|
+
c = int(cam)
|
|
46
46
|
except ValueError:
|
|
47
47
|
die(f"bad switch entry '{raw}' (want START-END:CAM)")
|
|
48
|
+
s, e = time_arg(a, f"--switch {raw!r} start"), time_arg(b, f"--switch {raw!r} end")
|
|
48
49
|
if not 0 <= c < n:
|
|
49
50
|
die(f"camera {c} does not exist (inputs are 0..{n - 1})")
|
|
50
51
|
if e <= s:
|
package/scripts/render.py
CHANGED
|
@@ -27,7 +27,7 @@ Project format (all keys optional except clips):
|
|
|
27
27
|
"audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2},
|
|
28
28
|
"loudness": {"lufs": -14, "tp": -1},
|
|
29
29
|
"fit": {"duration": 60},
|
|
30
|
-
"export": {"preset": "reels", "normalize": true},
|
|
30
|
+
"export": {"preset": "reels", "normalize": true}, (default for platform presets; false opts out)
|
|
31
31
|
"check": {"platform": "reels"}
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -56,7 +56,7 @@ import sys
|
|
|
56
56
|
from pathlib import Path
|
|
57
57
|
from typing import Any, Dict, List
|
|
58
58
|
|
|
59
|
-
from export import PRESETS
|
|
59
|
+
from export import PRESETS, PLATFORM_OF
|
|
60
60
|
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION
|
|
61
61
|
|
|
62
62
|
HERE = Path(__file__).resolve().parent
|
|
@@ -464,7 +464,14 @@ def main() -> int:
|
|
|
464
464
|
argv += ["--fit", ex["fit"]]
|
|
465
465
|
if ex.get("crf") is not None:
|
|
466
466
|
argv += ["--crf", str(ex["crf"])]
|
|
467
|
-
|
|
467
|
+
normalize = ex.get("normalize")
|
|
468
|
+
if normalize is None and ex["preset"] in PLATFORM_OF and not proj.get("loudness"):
|
|
469
|
+
# eval 8: a reels project without the key rendered fully, failed the loudness check and
|
|
470
|
+
# was rendered again; a platform preset with no loudness stage of its own gets the
|
|
471
|
+
# one-export behaviour by default ("normalize": false opts out)
|
|
472
|
+
normalize = True
|
|
473
|
+
info(f"export: --normalize on by default for the {ex['preset']} preset (set \"normalize\": false to skip)")
|
|
474
|
+
if normalize:
|
|
468
475
|
argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
|
|
469
476
|
sh("export.py", *argv)
|
|
470
477
|
stages_done.append("export")
|
package/scripts/speedramp.py
CHANGED
|
@@ -19,7 +19,7 @@ import argparse
|
|
|
19
19
|
import sys
|
|
20
20
|
from typing import List, Tuple
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs,
|
|
22
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
|
|
23
23
|
|
|
24
24
|
MAX_SPEED = 20.0
|
|
25
25
|
MIN_SPEED = 0.05
|
|
@@ -43,9 +43,10 @@ def parse_segment(raw: str) -> Tuple[float, float, float]:
|
|
|
43
43
|
try:
|
|
44
44
|
span, factor_s = raw.rsplit(":", 1)
|
|
45
45
|
start_s, end_s = span.rsplit("-", 1)
|
|
46
|
-
|
|
46
|
+
factor = float(factor_s)
|
|
47
47
|
except ValueError:
|
|
48
|
-
die(f"--segment must look like START-END:FACTOR
|
|
48
|
+
die(f"--segment must look like START-END:FACTOR, got '{raw}'")
|
|
49
|
+
start, end = time_arg(start_s, f"--segment {raw!r} start"), time_arg(end_s, f"--segment {raw!r} end")
|
|
49
50
|
if end <= start:
|
|
50
51
|
die(f"--segment {raw}: END must be after START")
|
|
51
52
|
if not MIN_SPEED <= factor <= MAX_SPEED:
|