ffmpeg-skill 1.5.0 → 1.5.2

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
@@ -239,7 +239,7 @@ commands work with `talk.wav` in place of `talk.mp4`. What changes:
239
239
 
240
240
  ## Report format
241
241
 
242
- Reply in the language the user wrote their request in — a Japanese request gets a Japanese report, English gets English, Chinese gets Chinese, and so on for any other language. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English. A mid-conversation language switch follows the user's latest message, not the first one.
242
+ Reply in the language the user wrote their request in — a Japanese request gets a Japanese report, English gets English, Chinese gets Chinese, and so on for any other language. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English. A mid-conversation language switch follows the user's latest message, not the first one. This holds for a one-command job too: a three-second audio trim answered with English labels, numbers and one Japanese word in `Notes:` is an English report; the `Done:` line's own description (what was cut, from where) and `Steps:` are written in the user's language even when the values are technical.
243
243
 
244
244
  Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
245
245
 
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.5.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.5.2`) | 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.5.0", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.5.2", "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.5.0",
3
+ "version": "1.5.2",
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",
@@ -55,7 +55,9 @@ keyframes, instant, lossless); if the snapped result deviates more than
55
55
  `--tolerance` (0.5 s) from the request, that segment is re-encoded automatically
56
56
  (x264 CRF 18). `--accurate` always re-encodes; `--tolerance -1` never does.
57
57
  Multiple segments are concatenated in the order given. stderr reports whether
58
- the result was "lossless stream copy" or "re-encoded".
58
+ the result was "lossless stream copy" or "re-encoded"; when the snap forced a
59
+ re-encode, the result's `lossless_alternative` names the nearest keyframe
60
+ `--start` that would stream-copy instead, so the trade can be offered.
59
61
 
60
62
  ### fit.py — target duration and/or aspect, rotate/flip
61
63
  ```
@@ -504,8 +506,9 @@ not — a timecode-shaped cue with no fps available is refused rather than misre
504
506
  Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|` is a line break.
505
507
  `--animate`/`--karaoke` generate a styled ASS (PlayRes = video size) from the
506
508
  SRT/text cues: `pop` is the short-form "bouncy" entrance, `--karaoke` fills each
507
- word from `--color` to `--highlight-color` evenly across the cue (word timing
508
- is distributed, not transcribed). The ASS is kept next to the output so the
509
+ word from `--color` to `--highlight-color` across the cue; `--karaoke-timing
510
+ energy` (default) follows the speech loudness in the audio, `even` splits the
511
+ cue equally (word timing is derived, not transcribed). The ASS is kept next to the
509
512
  user can hand-tune timings and re-run with `--ass`.
510
513
  `--mode burn` (default) renders subtitles into the picture and always
511
514
  re-encodes both streams. `--mode mux` copies video and audio untouched and
@@ -533,6 +536,9 @@ playing. `--video` composites a second video as a picture-in-picture layer
533
536
  input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
534
537
  (with `--video`) keys out that colour first for green-screen compositing.
535
538
 
539
+ `--fade S` fades the overlay in at `--start` (or 0); the fade-out happens
540
+ only at `--end`, so a logo with no `--end` stays to the last frame.
541
+
536
542
  ### sync.py — offset detection, alignment, drift correction
537
543
  ```
538
544
  sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--fix-drift [--drift-window 60]]
@@ -628,7 +634,11 @@ export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|cr
628
634
  export.py --list
629
635
  ```
630
636
  Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
631
- trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
637
+ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`. It
638
+ does not touch levels: for youtube / youtube4k / reels / x the written file is
639
+ measured and the result's `loudness` (and a `notes` line) says when it is
640
+ outside the platform's LUFS / true-peak spec, naming the `loudness.py` call
641
+ that fixes it -- plan that pass instead of discovering it from `check.py`.
632
642
 
633
643
  ### proxy.py — low-bitrate proxy for analysis/preview
634
644
  ```
@@ -181,7 +181,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
181
181
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
182
182
  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)"},
183
183
  {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
184
- {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"}],
184
+ {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"},
185
+ {"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
185
186
  video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
186
187
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
187
188
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
@@ -392,6 +393,9 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
392
393
  extra = {"results": {"type": "array"}, "processed": {"type": "integer"}, "total": {"type": "integer"}}
393
394
  elif name == "report":
394
395
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
396
+ elif name == "export":
397
+ extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec"},
398
+ "notes": {"type": "array", "items": {"type": "string"}}}
395
399
  elif name == "loudness":
396
400
  extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
397
401
  elif name == "cut":
@@ -399,7 +403,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
399
403
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
400
404
  "precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
401
405
  "description": "packet: stream copy on a packet/keyframe boundary; sample: decoded audio trimmed to the sample, lossless output; codec_frame: sample-trimmed then framed by a lossy encoder (priming delay adds to the length); frame: re-encoded video"},
402
- "reencoded": {"type": "boolean"}}
406
+ "reencoded": {"type": "boolean"},
407
+ "lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"}}
403
408
  elif name == "join":
404
409
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
405
410
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
package/scripts/batch.py CHANGED
@@ -224,12 +224,20 @@ def main() -> int:
224
224
  results = one_pass()
225
225
  if args.watch:
226
226
  info(f"watching {folder} every {args.watch:g}s (Ctrl-C to stop)")
227
+ # the shared SIGINT handler (install_signal_handlers) exits 130 with "nothing was written",
228
+ # which is wrong for a watch that already processed files: while idle between passes,
229
+ # let Ctrl-C be a plain KeyboardInterrupt so the summary below prints (review 5)
230
+ import signal
227
231
  try:
228
232
  while True:
229
- time.sleep(args.watch)
233
+ previous = signal.signal(signal.SIGINT, signal.default_int_handler)
234
+ try:
235
+ time.sleep(args.watch)
236
+ finally:
237
+ signal.signal(signal.SIGINT, previous)
230
238
  results = one_pass()
231
239
  except KeyboardInterrupt:
232
- pass
240
+ info("watch stopped")
233
241
  done = sum(1 for r in results if r["ok"])
234
242
  info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
235
243
  if not args.json:
@@ -487,9 +487,17 @@ def main() -> int:
487
487
  if args.transcribe:
488
488
  if not args.input:
489
489
  die("--transcribe needs the input video")
490
- srt_path = args.write_srt or os.path.splitext(args.input)[0] + ".srt"
491
- cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
492
- info(f"wrote {srt_path} ({len(cues)} cues)")
490
+ # the sidecar goes next to the output like the --text one, not into the source folder
491
+ # where it silently replaced a hand-written <input>.srt (review 5)
492
+ srt_path = args.write_srt or os.path.splitext(args.output or default_output(args.input, "captioned"))[0] + ".srt"
493
+ if STATE.dry_run:
494
+ cues = []
495
+ info(f"[dry-run] would transcribe {args.input} and write {srt_path}")
496
+ else:
497
+ if os.path.exists(srt_path) and not getattr(args, "overwrite", False):
498
+ info(f"warning: {srt_path} already exists and will be replaced by the transcript (pass --overwrite to confirm)")
499
+ cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
500
+ info(f"wrote {srt_path} ({len(cues)} cues)")
493
501
  args.text = None
494
502
  if args.text:
495
503
  cues = parse_text_cues(args.text, args.auto_seconds, args.gap, fps_for_tc)
@@ -515,7 +523,7 @@ def main() -> int:
515
523
  output = args.output or default_output(args.input, "captioned")
516
524
 
517
525
  if args.mode == "mux":
518
- if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
526
+ if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and (args.text or args.transcribe))):
519
527
  die(f"SRT file not found: {srt_path}")
520
528
  codec = mux_subtitle_codec(output)
521
529
  # Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
@@ -543,7 +551,7 @@ def main() -> int:
543
551
  return 0
544
552
 
545
553
  if (args.animate != "none" or args.karaoke) and not args.ass:
546
- cues_for_ass = cues if args.text else parse_srt(srt_path)
554
+ cues_for_ass = cues if (args.text or args.transcribe) else parse_srt(srt_path)
547
555
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
548
556
  w, h = meta["video"]["width"], meta["video"]["height"]
549
557
  if meta["video"].get("rotation") in (90, -90, 270, -270):
@@ -563,7 +571,7 @@ def main() -> int:
563
571
  if args.fonts_dir:
564
572
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
565
573
  else:
566
- if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
574
+ if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and (args.text or args.transcribe))):
567
575
  die(f"SRT file not found: {srt_path}")
568
576
  style = [
569
577
  f"FontName={ass_font_name(args.font)}",
package/scripts/check.py CHANGED
@@ -127,7 +127,7 @@ def main() -> int:
127
127
  row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
128
128
  row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
129
129
  if spec["codecs"]:
130
- row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube"),
130
+ row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube").replace("broadcast", "prores"),
131
131
  reason="the platform's player may refuse to decode this codec at all, not just look worse")
132
132
  pf = v.get("pix_fmt") or ""
133
133
  if args.platform in ("reels", "tiktok", "x", "linkedin"):
package/scripts/color.py CHANGED
@@ -22,7 +22,7 @@ import os
22
22
  import sys
23
23
  from typing import List
24
24
 
25
- from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
25
+ from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
26
26
 
27
27
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
28
28
 
@@ -293,7 +293,7 @@ def main() -> int:
293
293
  tag = "correct"
294
294
  # OBSERVED technical measurements (signalstats: luma / saturation distribution), never a
295
295
  # "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
296
- measurements = {"input": analyze_levels(args.input)}
296
+ measurements = None if STATE.dry_run else {"input": analyze_levels(args.input)}
297
297
  else:
298
298
  if v.get("hdr") and not args.force:
299
299
  die(f"{args.input} is HDR ({v.get('hdr_format')}); a LUT made for SDR applied to PQ/HLG pixels gives a wrong picture "
package/scripts/cut.py CHANGED
@@ -245,7 +245,11 @@ def main() -> int:
245
245
  requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
246
246
  duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
247
247
  mode=mode, keyframe_snapped=keyframe_snapped,
248
- nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None)
248
+ nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None,
249
+ # the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
250
+ lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
251
+ f"stream copy with no re-encode, {abs(min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])) - segments[0][0]):.2f}s off the requested start")
252
+ if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None)
249
253
  return 0
250
254
 
251
255
 
package/scripts/export.py CHANGED
@@ -27,6 +27,7 @@ from pathlib import Path
27
27
  from typing import Dict, List
28
28
 
29
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, fmt_secs
30
+ from check import SPECS as PLATFORMS, measure_loudness
30
31
  PRESETS: Dict[str, Dict] = {
31
32
  "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"},
32
33
  "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"},
@@ -40,6 +41,10 @@ PRESETS: Dict[str, Dict] = {
40
41
 
41
42
 
42
43
 
44
+ # which check.py platform a preset targets (its loudness spec is measured after the write)
45
+ PLATFORM_OF = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}
46
+
47
+
43
48
  def main() -> int:
44
49
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
45
50
  ap.add_argument("input", nargs="?")
@@ -123,7 +128,24 @@ def main() -> int:
123
128
  result = probe(output, role="output")
124
129
  v = result["video"]
125
130
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
126
- emit(output, **({"notes": notes} if notes else {}))
131
+ extra: Dict[str, object] = {}
132
+ platform = PLATFORM_OF.get(args.preset)
133
+ if platform and has_audio and not STATE.dry_run:
134
+ # Every eval run that exported for a platform then had to come back with loudness.py: the
135
+ # preset scales and tags but does not touch levels, and only check.py said so. Measure the
136
+ # written file here so the result names the gap and the caller plans one pass, not two.
137
+ spec = PLATFORMS[platform]
138
+ m = measure_loudness(output)
139
+ if m:
140
+ ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
141
+ extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
142
+ if not ok:
143
+ notes.append(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP; "
144
+ f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file (or before export)")
145
+ info("warning: " + notes[-1])
146
+ if notes:
147
+ extra["notes"] = notes
148
+ emit(output, **extra)
127
149
  return 0
128
150
 
129
151
 
package/scripts/fit.py CHANGED
@@ -239,12 +239,13 @@ def main() -> int:
239
239
  else:
240
240
  cmd += ["-an"]
241
241
  cmd += post
242
- if abs(factor - 1.0) > 1e-4:
242
+ if abs(factor - 1.0) > 1e-4 or "-ss" in pre_input:
243
243
  # A subtitle/data stream stream-copied by run_keeping_subtitles keeps the source's
244
244
  # original timestamps; --method speed retimes video (setpts) and audio (atempo) but has
245
245
  # no equivalent way to retime a copied subtitle track, so it would desync from the
246
246
  # now-faster/slower picture. Drop them here rather than ship a captions track that lies
247
- # about when a line is spoken.
247
+ # about when a line is spoken. --method trim's -ss moves the timeline the same way: a copied
248
+ # track kept its cues at the source's times and doubled the output's length (review 5).
248
249
  run(cmd + [output])
249
250
  dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
250
251
  else:
package/scripts/join.py CHANGED
@@ -68,7 +68,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
68
68
  cmd += ["-filter_complex", ";".join(parts), "-map", "[aout]", "-vn"] + audio_codec_for(output) + [output]
69
69
  run(cmd)
70
70
  expected = sum(durs) - d * (n - 1)
71
- r = probe(output)
71
+ r = probe(output, role="output")
72
72
  a = r.get("audio") or {}
73
73
  if not STATE.dry_run:
74
74
  if r.get("video"):
@@ -77,6 +77,8 @@ def main() -> int:
77
77
  die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
78
78
  if args.fps is not None and args.fps <= 0:
79
79
  die(f"--fps must be positive, got {args.fps:g}")
80
+ if not 0 <= args.audio < len(args.inputs):
81
+ die(f"--audio {args.audio}: inputs are numbered 0..{len(args.inputs) - 1}")
80
82
 
81
83
  n = len(args.inputs)
82
84
  if n < 2:
@@ -103,7 +103,7 @@ def main() -> int:
103
103
  ap.add_argument("--margin", type=int, default=24, help="margin from the edges in px (default 24)")
104
104
  ap.add_argument("--start", help="show from this time (default: whole video)")
105
105
  ap.add_argument("--end", help="hide after this time")
106
- ap.add_argument("--fade", type=float, default=0.0, help="fade in/out duration in seconds")
106
+ ap.add_argument("--fade", type=float, default=0.0, help="fade-in duration in seconds (at --start or 0); the fade-out happens only at --end")
107
107
  ap.add_argument("--opacity", type=float, default=1.0, help="0..1 (default 1)")
108
108
  img = ap.add_argument_group("image options")
109
109
  img.add_argument("--scale", type=int, help="scale the image to this width in px (keeps aspect)")
@@ -188,12 +188,13 @@ def main() -> int:
188
188
  if args.opacity < 1:
189
189
  chain.append(f"colorchannelmixer=aa={args.opacity:g}")
190
190
  if args.fade > 0:
191
- # no --start/--end: fade in at 0 and out at the end of the video
191
+ # no --start: fade in at 0. The fade-out only exists when --end names a moment the
192
+ # overlay leaves; without --end it stays to the last frame ("fade in at the start" is
193
+ # the common ask, and a matching fade-out at the very end surprised every eval run)
192
194
  s = start if start is not None else 0.0
193
- e = end if end is not None else (meta.get("duration") or 0.0)
194
195
  chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
195
- if e > args.fade:
196
- chain.append(f"fade=t=out:st={e - args.fade:.3f}:d={args.fade:g}:alpha=1")
196
+ if end is not None and end > args.fade:
197
+ chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
197
198
  x, y = position_exprs(args.position, args.margin, text_mode=False)
198
199
  ov = f"overlay={x}:{y}:format=auto"
199
200
  if enable:
@@ -249,8 +250,7 @@ def main() -> int:
249
250
  opts.append(f"fontfile={escape_filter_path(args.font_file)}")
250
251
  else:
251
252
  opts.append(f"font='{escape_drawtext(args.font)}'")
252
- alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None),
253
- end if end is not None else ((meta.get("duration") or None) if args.fade > 0 else None), args.fade)
253
+ alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None), end, args.fade)
254
254
  opts.append(f"fontcolor={args.font_color}")
255
255
  if alpha != "1":
256
256
  opts.append(f"alpha='{alpha}'")
package/scripts/render.py CHANGED
@@ -55,7 +55,7 @@ import sys
55
55
  from pathlib import Path
56
56
  from typing import Any, Dict, List
57
57
 
58
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output
58
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input
59
59
 
60
60
  HERE = Path(__file__).resolve().parent
61
61
 
@@ -133,6 +133,8 @@ def main() -> int:
133
133
  if not clips:
134
134
  die("project.clips is empty")
135
135
  output = rel(proj.get("output") or "final.mp4")
136
+ # the final stage is a copy from the work dir, so run()'s own guard never sees the sources (review 5)
137
+ refuse_output_is_input(output, *[rel(c.get("src")) for c in clips if c.get("src")])
136
138
  # The default work dir name comes only from the output path, with no PID or timestamp --
137
139
  # two concurrent render.py runs targeting the same output (a batch.py "project" recipe
138
140
  # processing several files in parallel, or simply running render.py twice by mistake) shared
@@ -363,7 +365,7 @@ def main() -> int:
363
365
  else:
364
366
  if not STATE.dry_run:
365
367
  place_output(current, output)
366
- info(f"copied final stage to {output}")
368
+ info(("[dry-run] would copy" if STATE.dry_run else "copied") + f" final stage to {output}")
367
369
  current = output
368
370
 
369
371
  # ---- check
@@ -1,4 +1,3 @@
1
- import re
2
1
  #!/usr/bin/env python3
3
2
  """Turn a numbered image sequence into a video.
4
3
 
@@ -16,6 +15,7 @@ Examples:
16
15
  python3 sequence.py --dir frames --pattern "*.png" --fps 30 --start-number 1
17
16
  """
18
17
  import argparse
18
+ import re
19
19
  import sys
20
20
  import tempfile
21
21
  from pathlib import Path