ffmpeg-skill 0.8.1 → 0.8.3
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 +19 -9
- package/package.json +2 -1
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/_common.py +87 -30
- package/scripts/audio.py +5 -4
- package/scripts/caption.py +8 -1
- package/scripts/check.py +12 -5
- package/scripts/probe.py +3 -1
- package/scripts/render.py +2 -2
- package/scripts/sync.py +19 -8
package/SKILL.md
CHANGED
|
@@ -30,20 +30,26 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
30
30
|
at CRF 18 (the default) and only use `export.py` for the last step; for
|
|
31
31
|
anything with more than two steps use `render.py` with a project.json.
|
|
32
32
|
5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X`
|
|
33
|
-
for the destination the user named.
|
|
34
|
-
codec, size, true peak, colour
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
for the destination the user named. Each row is marked `format` or
|
|
34
|
+
`judgement`. Format rows (codec, pixel format, size, true peak, colour
|
|
35
|
+
tags, VFR) are safe to fix mechanically. Judgement rows change the content:
|
|
36
|
+
duration (cut loses material, speed changes motion), aspect (crop loses
|
|
37
|
+
edges), fps (drops motion), loudness (ambience must not be boosted). Fix
|
|
38
|
+
those only when the user's request already implies the answer, otherwise
|
|
39
|
+
state the choice and its cost in one line. Mention WARNs; do not chase them.
|
|
37
40
|
6. **Verify the output.** Run `probe.py` on each result and confirm duration,
|
|
38
41
|
resolution, fps and audio match what was requested. Report those numbers to
|
|
39
42
|
the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
|
|
40
43
|
7. **Keep the user's originals.** Never overwrite the source file. Write new
|
|
41
44
|
files next to the input or where the user asked.
|
|
42
|
-
8. **Look at the picture.**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
8. **Look at the picture.** Whenever the picture changed (captions, overlays,
|
|
46
|
+
graphics, crop/pad, resize, colour, transitions) run `look.py OUTPUT`
|
|
47
|
+
(contact sheet) or `look.py OUTPUT --at T`, view the PNG, and judge it like
|
|
48
|
+
an editor: text inside the frame and not over faces, logos where asked,
|
|
49
|
+
crops keeping the subject, colours not washed out, transitions landing
|
|
50
|
+
where intended. The job is not finished until the report's `Look:` line
|
|
51
|
+
names that PNG; a probe alone cannot see a caption sitting on someone's
|
|
52
|
+
face. Audio-only jobs (sync, loudness, silence) write `Look: not needed`.
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
## Before you run anything: what to ask, what to assume
|
|
@@ -54,6 +60,7 @@ Ask one short question only when the answer changes the output materially and th
|
|
|
54
60
|
- **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, and state which you chose. Ask if the content is a talk (trimming loses words) and the change is large.
|
|
55
61
|
- **Captions** without a text source: use `--transcribe` if a local whisper exists, otherwise ask for the text or a timed file; never invent dialogue.
|
|
56
62
|
- **Fonts and brand**: if the user mentions a brand, colours or "our font", ask for or create `brand.json` once and reuse it.
|
|
63
|
+
- **CJK / non-Latin text**: check that a font exists before rendering (`fc-list :lang=ja file` / `:lang=ko` / `:lang=zh`); pass it with `--font "Name"` or `--font-file /path.ttf`. Tofu boxes are a failed job, not a style.
|
|
57
64
|
- Anything else (crop position, transition type, caption style): pick the conventional default, say what you picked, and offer the alternative in one line.
|
|
58
65
|
|
|
59
66
|
Do not ask for things `probe.py` can tell you.
|
|
@@ -130,6 +137,9 @@ Keep it to those five lines plus anything the user must decide. Attach the conta
|
|
|
130
137
|
noise, not the content. Leave the level, say so, and offer music or narration.
|
|
131
138
|
- Captions burned before a crop/resize: text lands off-frame. Frame changes first, then text.
|
|
132
139
|
- Anything chained by hand through three re-encodes: use `render.py` so the plan is one file and the user can change one number.
|
|
140
|
+
- `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot loses people at the edges. Check the sheet; pad (bars) or a reframe is often the honest answer.
|
|
141
|
+
- Conforming 60 fps to 30 halves the motion samples: fine for a talking head, visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows it.
|
|
142
|
+
- "Make it 60 seconds" on a 3-minute talk by speed change is unwatchable (3×); by trim it drops two thirds of the words. Ask which, or propose a highlight cut with `scenes.py`.
|
|
133
143
|
|
|
134
144
|
## Gotchas
|
|
135
145
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: MCP server, batch processing, declarative project rendering, brand kits, motion-graphics templates, HTML delivery reports, scene detection, delivery checks, cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"test": "python3 tests/test_all.py",
|
|
28
|
+
"release-check": "bash tests/release_check.sh",
|
|
28
29
|
"demo": "bash examples/make_demo.sh"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/_common.py
CHANGED
|
@@ -56,7 +56,46 @@ def require_tool(name: str) -> str:
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo")
|
|
59
|
-
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Context:
|
|
62
|
+
"""Per-process settings that the shared flags (--dry-run, --json, --progress, --fast) set once.
|
|
63
|
+
|
|
64
|
+
Scripts read it as attributes (``STATE.dry_run``) or, for older call sites, like a dict
|
|
65
|
+
(``STATE["dry_run"]``). Keeping it a single explicit object rather than module globals makes
|
|
66
|
+
it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands")
|
|
70
|
+
_KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands")
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self.reset()
|
|
74
|
+
|
|
75
|
+
def reset(self) -> None:
|
|
76
|
+
self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
|
|
77
|
+
self.json = False # emit() prints a JSON document instead of the output path
|
|
78
|
+
self.progress = False # run() streams percent / ETA to stderr for ffmpeg
|
|
79
|
+
self.fast = False # x264 preset forced to veryfast
|
|
80
|
+
self.duration_hint: Optional[float] = None # expected output length, for the progress percent
|
|
81
|
+
self.commands: List[str] = [] # every ffmpeg command line, for --json and --dry-run
|
|
82
|
+
|
|
83
|
+
# mapping-style access kept for backwards compatibility
|
|
84
|
+
def __getitem__(self, key: str) -> Any:
|
|
85
|
+
if key not in self._KEYS:
|
|
86
|
+
raise KeyError(key)
|
|
87
|
+
return getattr(self, key)
|
|
88
|
+
|
|
89
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
90
|
+
if key not in self._KEYS:
|
|
91
|
+
raise KeyError(key)
|
|
92
|
+
setattr(self, key, value)
|
|
93
|
+
|
|
94
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
95
|
+
return getattr(self, key, default) if key in self._KEYS else default
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
STATE = Context()
|
|
60
99
|
|
|
61
100
|
|
|
62
101
|
def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
@@ -69,19 +108,19 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
69
108
|
|
|
70
109
|
|
|
71
110
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
72
|
-
STATE
|
|
73
|
-
STATE
|
|
74
|
-
STATE
|
|
75
|
-
STATE
|
|
76
|
-
if STATE
|
|
111
|
+
STATE.dry_run = bool(getattr(args, "dry_run", False))
|
|
112
|
+
STATE.json = bool(getattr(args, "json", False))
|
|
113
|
+
STATE.progress = bool(getattr(args, "progress", False))
|
|
114
|
+
STATE.fast = bool(getattr(args, "fast", False))
|
|
115
|
+
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
77
116
|
args.preset = "veryfast"
|
|
78
117
|
|
|
79
118
|
|
|
80
119
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
81
120
|
"""Final stdout line: the output path, or a JSON document with --json."""
|
|
82
|
-
if STATE
|
|
83
|
-
doc: Dict[str, Any] = {"output": output, "dry_run": STATE
|
|
84
|
-
if output and not STATE
|
|
121
|
+
if STATE.json:
|
|
122
|
+
doc: Dict[str, Any] = {"output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
|
|
123
|
+
if output and not STATE.dry_run and os.path.exists(output):
|
|
85
124
|
doc["probe"] = probe(output)
|
|
86
125
|
doc.update(extra)
|
|
87
126
|
print_json(doc)
|
|
@@ -89,32 +128,58 @@ def emit(output: Optional[str], **extra: Any) -> None:
|
|
|
89
128
|
print(output)
|
|
90
129
|
|
|
91
130
|
|
|
131
|
+
def _cmdline(cmd: Sequence[str]) -> str:
|
|
132
|
+
return " ".join(shell_quote(c) for c in cmd)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_ffmpeg(cmd: Sequence[str]) -> bool:
|
|
136
|
+
return os.path.basename(cmd[0]).startswith("ffmpeg")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
140
|
+
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
141
|
+
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1)
|
|
142
|
+
|
|
143
|
+
|
|
92
144
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
93
145
|
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
94
146
|
|
|
95
|
-
|
|
96
|
-
|
|
147
|
+
ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
|
|
148
|
+
(a fake successful CompletedProcess is returned so scripts can keep planning), and run
|
|
149
|
+
with a progress readout under --progress. ffprobe and other tools always run.
|
|
97
150
|
"""
|
|
98
|
-
is_ffmpeg =
|
|
151
|
+
is_ffmpeg = _is_ffmpeg(cmd)
|
|
99
152
|
if is_ffmpeg:
|
|
100
|
-
STATE
|
|
153
|
+
STATE.commands.append(_cmdline(cmd))
|
|
101
154
|
if not quiet:
|
|
102
|
-
info(("[dry-run] $ " if STATE
|
|
103
|
-
if STATE
|
|
155
|
+
info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
|
|
156
|
+
if STATE.dry_run and is_ffmpeg:
|
|
104
157
|
return subprocess.CompletedProcess(list(cmd), 0, "", "")
|
|
105
|
-
if STATE
|
|
158
|
+
if STATE.progress and is_ffmpeg and cmd[-1] != "-":
|
|
106
159
|
return _run_with_progress(list(cmd), check)
|
|
160
|
+
return _run_captured(list(cmd), check)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
164
|
+
"""Plain run with stdout/stderr captured."""
|
|
107
165
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
108
166
|
if check and proc.returncode != 0:
|
|
109
|
-
|
|
110
|
-
die(f"command failed ({proc.returncode}): {cmd[0]}\n{tail}", code=proc.returncode or 1)
|
|
167
|
+
_fail(cmd, proc.returncode, proc.stderr)
|
|
111
168
|
return proc
|
|
112
169
|
|
|
113
170
|
|
|
171
|
+
def _progress_line(done: float, total: float, elapsed: float) -> str:
|
|
172
|
+
if total > 0:
|
|
173
|
+
pct = min(99.9, done / total * 100)
|
|
174
|
+
eta = (elapsed / pct * (100 - pct)) if pct > 0.5 else 0
|
|
175
|
+
return f"\r {pct:5.1f}% {done:7.1f}s / {total:.1f}s ETA {eta:4.0f}s"
|
|
176
|
+
return f"\r {done:7.1f}s encoded"
|
|
177
|
+
|
|
178
|
+
|
|
114
179
|
def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
115
180
|
"""Run ffmpeg with -progress on a pipe and print percent/ETA to stderr."""
|
|
116
181
|
import time
|
|
117
|
-
total = STATE.
|
|
182
|
+
total = STATE.duration_hint or 0.0
|
|
118
183
|
full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
|
|
119
184
|
t0 = time.time()
|
|
120
185
|
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
@@ -126,13 +191,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
126
191
|
done = int(line.split("=")[1]) / 1_000_000
|
|
127
192
|
except ValueError:
|
|
128
193
|
continue
|
|
129
|
-
|
|
130
|
-
pct = min(99.9, done / total * 100)
|
|
131
|
-
elapsed = time.time() - t0
|
|
132
|
-
eta = (elapsed / pct * (100 - pct)) if pct > 0.5 else 0
|
|
133
|
-
msg = f"\r {pct:5.1f}% {done:7.1f}s / {total:.1f}s ETA {eta:4.0f}s"
|
|
134
|
-
else:
|
|
135
|
-
msg = f"\r {done:7.1f}s encoded"
|
|
194
|
+
msg = _progress_line(done, total, time.time() - t0)
|
|
136
195
|
if msg != last:
|
|
137
196
|
sys.stderr.write(msg)
|
|
138
197
|
sys.stderr.flush()
|
|
@@ -140,11 +199,9 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
140
199
|
_, err = proc.communicate()
|
|
141
200
|
if last:
|
|
142
201
|
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
143
|
-
result = subprocess.CompletedProcess(full, proc.returncode, "", err)
|
|
144
202
|
if check and proc.returncode != 0:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return result
|
|
203
|
+
_fail(cmd, proc.returncode, err)
|
|
204
|
+
return subprocess.CompletedProcess(full, proc.returncode, "", err)
|
|
148
205
|
|
|
149
206
|
|
|
150
207
|
def shell_quote(s: str) -> str:
|
package/scripts/audio.py
CHANGED
|
@@ -6,7 +6,7 @@ Examples:
|
|
|
6
6
|
python3 audio.py interview.mp4 --denoise # FFT noise reduction
|
|
7
7
|
python3 audio.py interview.mp4 --voice # highpass + de-esser + compressor + denoise
|
|
8
8
|
python3 audio.py talk.mp4 --music bed.mp3 --duck # music under speech, auto-ducked
|
|
9
|
-
python3 audio.py talk.mp4 --music bed.mp3 --music-volume -18 --fade-out 3
|
|
9
|
+
python3 audio.py talk.mp4 --music bed.mp3 --music-volume -18 --music-fade-out 3 # bed fades, voice does not
|
|
10
10
|
python3 audio.py clip.mp4 --fade-in 0.5 --fade-out 1 --stereo
|
|
11
11
|
python3 audio.py surround.mov --downmix # 5.1 -> stereo with proper centre/LFE weights
|
|
12
12
|
python3 audio.py clip.mp4 --replace narration.wav # swap the audio track entirely
|
|
@@ -37,7 +37,8 @@ def main() -> int:
|
|
|
37
37
|
music.add_argument("--music-loop", action="store_true", help="loop the music if shorter than the video")
|
|
38
38
|
fades = ap.add_argument_group("fades / layout")
|
|
39
39
|
fades.add_argument("--fade-in", type=float, default=0.0, help="seconds")
|
|
40
|
-
fades.add_argument("--fade-out", type=float, default=0.0, help="seconds")
|
|
40
|
+
fades.add_argument("--fade-out", type=float, default=0.0, help="seconds; fades the whole final mix (voice included)")
|
|
41
|
+
music.add_argument("--music-fade-out", type=float, default=0.0, help="seconds; fades only the music bed at the end, voice untouched")
|
|
41
42
|
fades.add_argument("--stereo", action="store_true", help="force 2-channel output (mono is duplicated to both sides)")
|
|
42
43
|
fades.add_argument("--mono", action="store_true", help="force 1-channel output")
|
|
43
44
|
fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
|
|
@@ -90,8 +91,8 @@ def main() -> int:
|
|
|
90
91
|
m = f"{idx}:a:0"
|
|
91
92
|
idx += 1
|
|
92
93
|
mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{dur:.3f}" if dur else "anull"]
|
|
93
|
-
if args.
|
|
94
|
-
mfx.append(f"afade=t=out:st={max(0.0, dur - args.
|
|
94
|
+
if args.music_fade_out and dur:
|
|
95
|
+
mfx.append(f"afade=t=out:st={max(0.0, dur - args.music_fade_out):.3f}:d={args.music_fade_out:g}")
|
|
95
96
|
graph.append(f"[{m}]{','.join(mfx)}[music]")
|
|
96
97
|
if args.duck:
|
|
97
98
|
graph.append("[main]asplit=2[mainA][sc]")
|
package/scripts/caption.py
CHANGED
|
@@ -344,7 +344,14 @@ def main() -> int:
|
|
|
344
344
|
args.text = None
|
|
345
345
|
if args.text:
|
|
346
346
|
cues = parse_text_cues(args.text, args.auto_seconds, args.gap)
|
|
347
|
-
|
|
347
|
+
if args.write_srt:
|
|
348
|
+
srt_path = args.write_srt
|
|
349
|
+
elif args.input:
|
|
350
|
+
# keep generated files next to the output, not in the user's source folder
|
|
351
|
+
out_guess = args.output or default_output(args.input, "captioned")
|
|
352
|
+
srt_path = os.path.splitext(out_guess)[0] + ".srt"
|
|
353
|
+
else:
|
|
354
|
+
srt_path = os.path.splitext(args.text)[0] + ".srt"
|
|
348
355
|
write_srt(cues, srt_path)
|
|
349
356
|
info(f"wrote {srt_path} ({len(cues)} cues)")
|
|
350
357
|
if not args.input:
|
package/scripts/check.py
CHANGED
|
@@ -86,12 +86,17 @@ def main() -> int:
|
|
|
86
86
|
v, a = meta.get("video") or {}, meta.get("audio") or {}
|
|
87
87
|
rows: List[Dict[str, Any]] = []
|
|
88
88
|
|
|
89
|
+
JUDGEMENT = {"duration", "aspect", "loudness", "fps", "resolution"}
|
|
90
|
+
|
|
89
91
|
def row(name: str, status: str, value: Any, expect: Any, fix: str = "") -> None:
|
|
90
|
-
|
|
92
|
+
# "format" rows are safe to fix mechanically; "judgement" rows change the content
|
|
93
|
+
# (what is cut, what is cropped, how loud ambience gets) and need a decision
|
|
94
|
+
rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
|
|
95
|
+
"kind": "judgement" if name in JUDGEMENT else "format"})
|
|
91
96
|
|
|
92
97
|
dur = meta.get("duration") or 0.0
|
|
93
98
|
if spec["max_duration"]:
|
|
94
|
-
row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "fit.py --duration
|
|
99
|
+
row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "decide with the user: cut.py keeps quality but drops content; fit.py --duration speeds up (audio pitch-preserved, motion faster)")
|
|
95
100
|
else:
|
|
96
101
|
row("duration", "PASS", f"{dur:.2f}s", "any")
|
|
97
102
|
|
|
@@ -101,7 +106,7 @@ def main() -> int:
|
|
|
101
106
|
w, h = h, w
|
|
102
107
|
asp = aspect_name(w, h)
|
|
103
108
|
if spec["aspects"]:
|
|
104
|
-
row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad
|
|
109
|
+
row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad (keeps everything, adds bars) or crop (fills the frame, loses the edges: check the subject with look.py)")
|
|
105
110
|
else:
|
|
106
111
|
row("aspect", "PASS", asp, "any")
|
|
107
112
|
short = min(w, h)
|
|
@@ -109,7 +114,7 @@ def main() -> int:
|
|
|
109
114
|
row("resolution", "PASS" if short >= spec["min_height"] else "WARN", f"{w}x{h}", f"short side >= {spec['min_height']}", "upscaling will not add detail; re-export from the master")
|
|
110
115
|
fps = v.get("fps") or 0
|
|
111
116
|
if spec["fps_max"]:
|
|
112
|
-
row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30")
|
|
117
|
+
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)")
|
|
113
118
|
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)")
|
|
114
119
|
if spec["codecs"]:
|
|
115
120
|
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"))
|
|
@@ -143,7 +148,7 @@ def main() -> int:
|
|
|
143
148
|
lm = measure_loudness(args.input)
|
|
144
149
|
if lm:
|
|
145
150
|
diff = abs(lm["lufs"] - spec["lufs"])
|
|
146
|
-
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g}")
|
|
151
|
+
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so")
|
|
147
152
|
row("true peak", "PASS" if lm["tp"] <= spec["tp"] + 0.05 else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP", f"loudness.py --tp {spec['tp']:g}")
|
|
148
153
|
elif args.platform in ("podcast",):
|
|
149
154
|
row("audio", "FAIL", "none", "audio stream", "audio.py --replace")
|
|
@@ -157,6 +162,8 @@ def main() -> int:
|
|
|
157
162
|
print(f"{args.input} — {args.platform}")
|
|
158
163
|
for r in rows:
|
|
159
164
|
line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
|
|
165
|
+
if r["status"] != "PASS" and r["kind"] == "judgement":
|
|
166
|
+
line += " [judgement]"
|
|
160
167
|
if r["status"] != "PASS" and r["fix"]:
|
|
161
168
|
line += f" -> {r['fix']}"
|
|
162
169
|
print(line)
|
package/scripts/probe.py
CHANGED
|
@@ -12,7 +12,7 @@ Examples:
|
|
|
12
12
|
import argparse
|
|
13
13
|
import sys
|
|
14
14
|
|
|
15
|
-
from _common import analyze_levels, print_json, probe
|
|
15
|
+
from _common import add_common, analyze_levels, apply_common, print_json, probe
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def main() -> int:
|
|
@@ -21,7 +21,9 @@ def main() -> int:
|
|
|
21
21
|
ap.add_argument("--compact", action="store_true", help="one human-readable line per file instead of JSON")
|
|
22
22
|
ap.add_argument("--field", help="print only this top-level field (e.g. duration) or dotted path (video.fps)")
|
|
23
23
|
ap.add_argument("--analyze", action="store_true", help="also sample picture levels (first 20 s) and flag Log-looking footage")
|
|
24
|
+
add_common(ap) # --json / --dry-run / --fast / --progress accepted for uniformity; output is JSON already
|
|
24
25
|
args = ap.parse_args()
|
|
26
|
+
apply_common(args)
|
|
25
27
|
|
|
26
28
|
results = [probe(p) for p in args.inputs]
|
|
27
29
|
if args.analyze:
|
package/scripts/render.py
CHANGED
|
@@ -24,7 +24,7 @@ Project format (all keys optional except clips):
|
|
|
24
24
|
{"logo": true},
|
|
25
25
|
{"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
|
|
26
26
|
],
|
|
27
|
-
"audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "
|
|
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
30
|
"export": {"preset": "reels"},
|
|
@@ -289,7 +289,7 @@ def main() -> int:
|
|
|
289
289
|
for k, flag in (("music", "--music"), ("replace", "--replace")):
|
|
290
290
|
if au.get(k):
|
|
291
291
|
argv += [flag, rel(au[k])]
|
|
292
|
-
for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
|
|
292
|
+
for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
|
|
293
293
|
if au.get(k) is not None:
|
|
294
294
|
argv += [flag, str(au[k])]
|
|
295
295
|
for k, flag in (("voice", "--voice"), ("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|
package/scripts/sync.py
CHANGED
|
@@ -30,6 +30,22 @@ from _common import video_args, add_common, apply_common, emit, aac_args, audio_
|
|
|
30
30
|
|
|
31
31
|
SR = 8000 # decode sample rate
|
|
32
32
|
|
|
33
|
+
# Scoring constants for coarse alignment. Both come from tests/bench_sync.py on real dialogue and
|
|
34
|
+
# music with +/-30 s offsets, gain, noise and EQ (see evals/results and CHANGELOG 0.8.0):
|
|
35
|
+
#
|
|
36
|
+
# MIN_OVERLAP_FRACTION: lags whose overlap with the other track is shorter than this share of the
|
|
37
|
+
# shorter track are never candidates. With the documented rule "analysis window >= 4x the largest
|
|
38
|
+
# expected offset" a true offset keeps >= 75 % overlap, so 0.35 costs nothing there, while the
|
|
39
|
+
# coincidental peaks on quasi-periodic material (music, tone beds) live below it. Raising it to
|
|
40
|
+
# 0.5 started rejecting true 28 s offsets in 60 s windows; lowering it to 0.2 let the partial
|
|
41
|
+
# matches back in (86 % -> 95 % of 60 s stress cases fixed by this alone).
|
|
42
|
+
# OVERLAP_WEIGHT_EXP: normalised similarity is multiplied by (overlap fraction) ** exponent so a
|
|
43
|
+
# perfect match over 55 % of the window cannot tie a perfect match over 100 %. 0.5 keeps a true
|
|
44
|
+
# 75 % overlap at x0.87 and a 53 % one (28 s in 60 s) at x0.73 while a coincidental 40 % match
|
|
45
|
+
# drops to x0.63; exponent 1.0 over-penalised large true offsets, 0.25 left exact ties.
|
|
46
|
+
MIN_OVERLAP_FRACTION = 0.35
|
|
47
|
+
OVERLAP_WEIGHT_EXP = 0.5
|
|
48
|
+
|
|
33
49
|
|
|
34
50
|
def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
|
|
35
51
|
ffmpeg = require_tool("ffmpeg")
|
|
@@ -119,10 +135,7 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
|
|
|
119
135
|
lr, lo = len(ref), len(other)
|
|
120
136
|
max_lag = min(max_lag, n // 2 - 1)
|
|
121
137
|
best_lag, best_val, second = 0, -float("inf"), -float("inf")
|
|
122
|
-
|
|
123
|
-
# largest expected offset) true offsets always keep >= 75 % overlap, while short-overlap lags are
|
|
124
|
-
# where coincidental matches on quasi-periodic material (music, tone beds) live
|
|
125
|
-
min_overlap = max(10, int(0.35 * min(lr, lo)))
|
|
138
|
+
min_overlap = max(10, int(MIN_OVERLAP_FRACTION * min(lr, lo))) # see constants above
|
|
126
139
|
scores = []
|
|
127
140
|
for lag in range(-max_lag, max_lag + 1):
|
|
128
141
|
# corr[lag] = sum_i ref[i] * other[i - lag] -> ref index range and other index range overlap:
|
|
@@ -135,10 +148,8 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
|
|
|
135
148
|
if denom <= 0:
|
|
136
149
|
continue
|
|
137
150
|
val = corr[lag % n].real / denom
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
# offsets (28 s in 60 s = 53 % overlap -> x0.94) competitive while still breaking exact ties.
|
|
141
|
-
val *= ((r1 - r0) / min(lr, lo)) ** 0.5
|
|
151
|
+
val *= ((r1 - r0) / min(lr, lo)) ** OVERLAP_WEIGHT_EXP # longer overlap wins ties
|
|
152
|
+
|
|
142
153
|
scores.append((val, lag))
|
|
143
154
|
if val > best_val:
|
|
144
155
|
second = best_val
|