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 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) run `look.py OUTPUT`
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.8.1`) | any release |
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.8.1", "execution_mode": "local", "kind": "execution",
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.8.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",
@@ -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
@@ -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, with a known fps, hh:mm:ss:ff)")
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, parse_time, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
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 = parse_time(args.at[i], fps)
73
- start_b = parse_time(per(args.from_, i, "0"), fps)
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 = parse_time(per(args.end, i, "0"), fps)
75
+ end = time_arg(per(args.end, i, "0"), "--end", fps)
76
76
  length = end - at
77
77
  else:
78
- length = parse_time(per(args.duration, i, "4"), fps)
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:
@@ -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:.,]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,]+)\s+(?P<text>.+)$"
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, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
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
- """parse_time() with the input's fps (SMPTE hh:mm:ss:ff) and every failure as kind input."""
44
- try:
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, parse_time, fmt_secs, run
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
- try:
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:
@@ -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, parse_time, probe, run, STATE, read_text_or_die
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
- try:
50
- start = parse_time(parts[0])
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")
@@ -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, parse_time, probe, run, x264_args, X264_PRESETS, fmt_secs
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
- s, e, c = parse_time(a), parse_time(b), int(cam)
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
- if ex.get("normalize"):
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")
@@ -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, parse_time
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
- start, end, factor = parse_time(start_s), parse_time(end_s), float(factor_s)
46
+ factor = float(factor_s)
47
47
  except ValueError:
48
- die(f"--segment must look like START-END:FACTOR (times in seconds or mm:ss), got '{raw}'")
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: