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 +1 -1
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +14 -4
- package/scripts/_contract.py +7 -2
- package/scripts/batch.py +10 -2
- package/scripts/caption.py +14 -6
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -2
- package/scripts/cut.py +5 -1
- package/scripts/export.py +23 -1
- package/scripts/fit.py +3 -2
- package/scripts/join.py +1 -1
- package/scripts/multicam.py +2 -0
- package/scripts/overlay.py +7 -7
- package/scripts/render.py +4 -2
- package/scripts/sequence.py +1 -1
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.
|
|
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.
|
|
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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`
|
|
508
|
-
|
|
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
|
```
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
package/scripts/caption.py
CHANGED
|
@@ -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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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"):
|
package/scripts/multicam.py
CHANGED
|
@@ -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:
|
package/scripts/overlay.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
196
|
-
chain.append(f"fade=t=out:st={
|
|
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(
|
|
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
|
package/scripts/sequence.py
CHANGED
|
@@ -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
|