ffmpeg-skill 2.2.3 → 2.2.4
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/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +14 -2
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/runner.py +32 -14
- package/scripts/audio.py +44 -4
- package/scripts/caption.py +34 -5
- package/scripts/render.py +11 -4
- package/scripts/sync.py +5 -1
- package/scripts/waveform.py +56 -33
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 (`2.2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.2.4`) | 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`
|
|
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
119
119
|
"contract_version": "1.0",
|
|
120
120
|
"deprecated": [],
|
|
121
121
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
122
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.2.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.2.4", "execution_mode": "local", "kind": "execution",
|
|
123
123
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
124
124
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
125
125
|
"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": "2.2.
|
|
3
|
+
"version": "2.2.4",
|
|
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
|
@@ -338,8 +338,10 @@ sticker template and `--srt`/`--text` burns captions by running caption.py
|
|
|
338
338
|
afterwards -- both as second processes, so neither of those code paths is
|
|
339
339
|
re-implemented here. `--image` must be a readable local file: a URL is refused
|
|
340
340
|
(`kind: input`), nothing is fetched, and the skill never invents cover art --
|
|
341
|
-
give an image or a colour.
|
|
342
|
-
|
|
341
|
+
give an image or a colour. The title and caption stages receive `--overwrite`, `--timeout`,
|
|
342
|
+
`--fast` and `--dry-run`, and a failed stage reports that tool's own error kind; a missing
|
|
343
|
+
`--srt`/`--text` file is refused (`kind: input`) before anything is encoded. Without any of
|
|
344
|
+
these flags the command line is byte-identical to 1.15's. Every run's result carries an `audiogram` object (style,
|
|
343
345
|
background, image, position, vis_height, platform, captions, title, stages,
|
|
344
346
|
verified). `render.py --template audiogram` is the one-call form; it is
|
|
345
347
|
deliberately not part of `--template all`.
|
|
@@ -1053,6 +1055,13 @@ caption.py INPUT --srt FILE[:LANG] | --ass FILE | --text CUES.txt [--write-srt O
|
|
|
1053
1055
|
[--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
|
|
1054
1056
|
caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
|
|
1055
1057
|
```
|
|
1058
|
+
**Side files and `--overwrite` (2.2.4).** The files caption.py writes besides the
|
|
1059
|
+
video -- the `.srt` from `--text`/`--transcribe` (or `--write-srt`), the generated
|
|
1060
|
+
`.ass` (or `--write-ass`), `<output>_adjusted.srt`, `<output>_offset.ass` -- are
|
|
1061
|
+
refused like the video when they already exist, all named together, before speech
|
|
1062
|
+
recognition and before the first write, dry runs included; `--overwrite` replaces
|
|
1063
|
+
them. A hand-corrected transcript next to the output is never replaced silently.
|
|
1064
|
+
|
|
1056
1065
|
**Caption margins (1.17.2).** `--margin` is the **vertical** distance from the
|
|
1057
1066
|
edge, in ASS units against the 288-line script grid (default 30; with
|
|
1058
1067
|
`--platform` it becomes that destination's `safe.top`/`safe.bottom`, e.g. 22 %
|
|
@@ -1388,6 +1397,9 @@ threshold ducks on quieter speech, a shorter release brings the bed back faster.
|
|
|
1388
1397
|
`--json`'s `audio` block reports the settings the run actually used.
|
|
1389
1398
|
`--effects FILE` mixes a third track (sound effects, atmos) at
|
|
1390
1399
|
`--effects-volume` and is never ducked — effects are cut to the picture.
|
|
1400
|
+
Every `--music` / `--effects` / `--replace` file is checked before ffmpeg runs (under
|
|
1401
|
+
`--dry-run` too): missing, empty, unreadable or with no audio stream, all of them are named in
|
|
1402
|
+
one refusal (`kind: input`, `problems: [{flag, path, reason}]`).
|
|
1391
1403
|
`--stereo-widen 0..1` widens the stereo image (`extrastereo=m=1+2*amount`) and
|
|
1392
1404
|
needs a real stereo source: it scales the side signal (L−R), so a mono track
|
|
1393
1405
|
duplicated to two channels has nothing to scale. A 1-channel input is refused
|
|
@@ -52,7 +52,7 @@ for _stream in (sys.stdout, sys.stderr):
|
|
|
52
52
|
# demos/ or evals/ has ever reached for through `_common`.
|
|
53
53
|
|
|
54
54
|
from _common.runner import (
|
|
55
|
-
add_common, apply_common, _check_existing_output, _check_no_overwrite_input, _check_output_path, child_args,
|
|
55
|
+
add_common, apply_common, _check_existing_output, refuse_existing_outputs, _check_no_overwrite_input, _check_output_path, child_args,
|
|
56
56
|
child_limit, _CHILDREN, _cleanup_partial_output, _cmdline, CODECS, Context, DEFAULT_TIMEOUT,
|
|
57
57
|
_DRAWTEXT_PENDING, _DRAWTEXT_TMPDIR, _drawtext_tmpdir, dry_run_input_pending, _ENCODERS, _env_timeout,
|
|
58
58
|
ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, _FFMPEG_VERSION,
|
|
@@ -179,7 +179,7 @@ __all__ = [
|
|
|
179
179
|
"aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
|
|
180
180
|
"audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
|
|
181
181
|
"brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_tag_args", "cfr_args", "_char_em",
|
|
182
|
-
"char_script", "_check_existing_output", "_check_no_overwrite_input", "_check_output_path", "child_args",
|
|
182
|
+
"char_script", "_check_existing_output", "refuse_existing_outputs", "_check_no_overwrite_input", "_check_output_path", "child_args",
|
|
183
183
|
"child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
|
|
184
184
|
"concat_list_line", "Context", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
|
|
185
185
|
"filler_spans", "FILLER_WORDS", "FILLER_AMBIGUOUS", "FILLER_DISCOURSE_MARKERS",
|
|
@@ -542,21 +542,39 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
|
542
542
|
output = cmd[-1]
|
|
543
543
|
if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
|
|
544
544
|
return
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
545
|
+
refuse_existing_outputs([output])
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def refuse_existing_outputs(paths: Sequence[str]) -> None:
|
|
549
|
+
"""_check_existing_output() for every file a run will write, named together, before the
|
|
550
|
+
first of them is written. A tool whose outputs are not all ffmpeg's last argument (caption.py's
|
|
551
|
+
.srt/.ass sidecars) calls this up front so a hand-edited sidecar is refused like the video
|
|
552
|
+
is, and a dry run predicts the same refusal."""
|
|
553
|
+
existing: List[str] = []
|
|
554
|
+
for output in paths:
|
|
555
|
+
if not output:
|
|
556
|
+
continue
|
|
557
|
+
try:
|
|
558
|
+
exists = os.path.isfile(output)
|
|
559
|
+
real = os.path.realpath(output)
|
|
560
|
+
except OSError:
|
|
561
|
+
continue
|
|
562
|
+
if not exists or real in STATE.written:
|
|
563
|
+
continue
|
|
564
|
+
try:
|
|
565
|
+
st = os.stat(output)
|
|
566
|
+
STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
|
|
567
|
+
except OSError:
|
|
568
|
+
pass
|
|
569
|
+
if output not in existing:
|
|
570
|
+
existing.append(output)
|
|
571
|
+
if not existing or STATE.overwrite:
|
|
558
572
|
return
|
|
559
|
-
|
|
573
|
+
if len(existing) == 1:
|
|
574
|
+
die(f"refusing to overwrite existing output {existing[0]!r}: pass --overwrite to replace it, or choose another -o path",
|
|
575
|
+
kind="input", hint="pass --overwrite, or choose another -o path")
|
|
576
|
+
die("refusing to overwrite existing outputs " + ", ".join(repr(p) for p in existing)
|
|
577
|
+
+ ": pass --overwrite to replace them, or choose another -o path",
|
|
560
578
|
kind="input", hint="pass --overwrite, or choose another -o path")
|
|
561
579
|
|
|
562
580
|
|
package/scripts/audio.py
CHANGED
|
@@ -20,11 +20,12 @@ Examples:
|
|
|
20
20
|
python3 audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav
|
|
21
21
|
"""
|
|
22
22
|
import argparse
|
|
23
|
+
import os
|
|
23
24
|
import sys
|
|
24
25
|
from pathlib import Path
|
|
25
26
|
from typing import Any, Dict, List
|
|
26
27
|
|
|
27
|
-
from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
|
|
28
|
+
from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
|
|
28
29
|
|
|
29
30
|
VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
|
|
30
31
|
|
|
@@ -93,6 +94,41 @@ def dynamics_filter(name: str, args: argparse.Namespace) -> str:
|
|
|
93
94
|
return name + ("=" + ":".join(opts) if opts else "")
|
|
94
95
|
|
|
95
96
|
|
|
97
|
+
def preflight_beds(args: argparse.Namespace) -> List[Dict[str, Any]]:
|
|
98
|
+
"""Every problem with every --replace / --music / --effects file, found before anything runs:
|
|
99
|
+
missing, a directory, empty, unreadable, or readable but with no audio stream (a picture-only
|
|
100
|
+
clip given as a bed used to reach ffmpeg's `[1:a:0]` and fail there, as kind ffmpeg, one file
|
|
101
|
+
at a time). Under --dry-run a file that does not exist yet is an earlier stage's output and is
|
|
102
|
+
left to the plan, as join.py's preflight does; a real file is checked under --dry-run too."""
|
|
103
|
+
problems: List[Dict[str, Any]] = []
|
|
104
|
+
beds = [(f, v) for f, v in (("--replace", args.replace), ("--music", args.music),
|
|
105
|
+
("--effects", args.effects)) if v]
|
|
106
|
+
if not beds:
|
|
107
|
+
return problems
|
|
108
|
+
ffprobe = require_tool("ffprobe")
|
|
109
|
+
for flag, path in beds:
|
|
110
|
+
if dry_run_input_pending(path):
|
|
111
|
+
continue
|
|
112
|
+
reason = None
|
|
113
|
+
if not os.path.exists(path):
|
|
114
|
+
reason = "missing"
|
|
115
|
+
elif os.path.isdir(path):
|
|
116
|
+
reason = "a directory, not a file"
|
|
117
|
+
elif os.path.getsize(path) == 0:
|
|
118
|
+
reason = "empty (0 bytes)"
|
|
119
|
+
else:
|
|
120
|
+
proc = run([ffprobe, "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", path],
|
|
121
|
+
quiet=True, check=False)
|
|
122
|
+
kinds = {line.strip() for line in (proc.stdout or "").splitlines() if line.strip()}
|
|
123
|
+
if proc.returncode != 0 or not kinds:
|
|
124
|
+
reason = "unreadable: " + ((proc.stderr or "").strip().splitlines() or ["no streams"])[-1]
|
|
125
|
+
elif "audio" not in kinds:
|
|
126
|
+
reason = "no audio stream"
|
|
127
|
+
if reason:
|
|
128
|
+
problems.append({"flag": flag, "path": path, "reason": reason})
|
|
129
|
+
return problems
|
|
130
|
+
|
|
131
|
+
|
|
96
132
|
def main() -> int:
|
|
97
133
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
98
134
|
ap.add_argument("input")
|
|
@@ -179,6 +215,13 @@ def main() -> int:
|
|
|
179
215
|
if args.stereo_widen is not None and not (0.0 <= args.stereo_widen <= 1.0):
|
|
180
216
|
die(f"--stereo-widen must be 0..1 (0 = untouched, 1 = maximum), got {args.stereo_widen:g}")
|
|
181
217
|
|
|
218
|
+
problems = preflight_beds(args)
|
|
219
|
+
if problems:
|
|
220
|
+
die(f"{len(problems)} of the extra audio files cannot be mixed: "
|
|
221
|
+
+ "; ".join(f"{p['flag']} {p['path']}: {p['reason']}" for p in problems),
|
|
222
|
+
kind="input", problems=problems,
|
|
223
|
+
hint="each --music / --effects / --replace file must be a readable file with an audio stream")
|
|
224
|
+
|
|
182
225
|
meta = probe(args.input)
|
|
183
226
|
dur = meta.get("duration") or 0.0
|
|
184
227
|
has_video = bool(meta.get("video"))
|
|
@@ -209,7 +252,6 @@ def main() -> int:
|
|
|
209
252
|
main_src = f"0:a:{args.audio_stream}"
|
|
210
253
|
idx = 1
|
|
211
254
|
if args.replace:
|
|
212
|
-
probe(args.replace)
|
|
213
255
|
inputs += ["-i", args.replace]
|
|
214
256
|
main_src = f"{idx}:a:0"
|
|
215
257
|
idx += 1
|
|
@@ -251,7 +293,6 @@ def main() -> int:
|
|
|
251
293
|
last = "main"
|
|
252
294
|
|
|
253
295
|
if args.music:
|
|
254
|
-
probe(args.music)
|
|
255
296
|
if args.music_loop:
|
|
256
297
|
inputs += ["-stream_loop", "-1", "-i", args.music]
|
|
257
298
|
else:
|
|
@@ -276,7 +317,6 @@ def main() -> int:
|
|
|
276
317
|
if args.effects:
|
|
277
318
|
# A third bed, mixed in at its own level and deliberately never ducked: effects are cut
|
|
278
319
|
# to the picture, so dipping them under speech would move them off their own frames.
|
|
279
|
-
probe(args.effects)
|
|
280
320
|
inputs += ["-i", args.effects]
|
|
281
321
|
e = f"{idx}:a:0"
|
|
282
322
|
idx += 1
|
package/scripts/caption.py
CHANGED
|
@@ -44,7 +44,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
44
44
|
from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
|
|
45
45
|
resolve as resolve_platform)
|
|
46
46
|
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
47
|
-
from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
|
|
47
|
+
from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
|
|
48
48
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
49
49
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
50
50
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
|
@@ -1155,9 +1155,38 @@ def main() -> int:
|
|
|
1155
1155
|
die(f"{len(missing_srt)} SRT file(s) not found: " + ", ".join(m["path"] for m in missing_srt),
|
|
1156
1156
|
kind="input", problems=missing_srt)
|
|
1157
1157
|
srt_path = args.srt
|
|
1158
|
+
if args.transcribe and not args.input:
|
|
1159
|
+
die("--transcribe needs the input video")
|
|
1160
|
+
# 2.2.4: every file this run writes -- the video AND the .srt/.ass sidecars -- is checked
|
|
1161
|
+
# against --overwrite here, before speech recognition and before the first write, dry runs
|
|
1162
|
+
# included. Through 2.2.3 only ffmpeg's last argument was: a hand-corrected transcript or ASS
|
|
1163
|
+
# next to the output was replaced on a re-run, even one ffmpeg's check then refused.
|
|
1164
|
+
# Side files whose existence depends on the cues (an _adjusted.srt, an emoji-forced ASS) are
|
|
1165
|
+
# checked again where they are written, still before that write.
|
|
1166
|
+
claimed: set = set()
|
|
1167
|
+
|
|
1168
|
+
def claim(paths) -> None:
|
|
1169
|
+
todo = [p for p in paths if p and os.path.realpath(p) not in claimed]
|
|
1170
|
+
refuse_existing_outputs(todo)
|
|
1171
|
+
claimed.update(os.path.realpath(p) for p in todo)
|
|
1172
|
+
|
|
1173
|
+
planned_out = args.output or (default_output(args.input, "captioned") if args.input else None)
|
|
1174
|
+
up_front: List[str] = []
|
|
1175
|
+
if args.transcribe:
|
|
1176
|
+
up_front.append(args.write_srt or os.path.splitext(planned_out)[0] + ".srt")
|
|
1177
|
+
elif args.text:
|
|
1178
|
+
up_front.append(args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
|
|
1179
|
+
else os.path.splitext(args.text)[0] + ".srt"))
|
|
1180
|
+
if args.input:
|
|
1181
|
+
if (args.animate != "none" or args.karaoke) and not args.ass:
|
|
1182
|
+
up_front.append(args.write_ass or os.path.splitext(planned_out)[0] + ".ass")
|
|
1183
|
+
if args.ass and args.offset and os.path.exists(args.ass):
|
|
1184
|
+
up_front.append(os.path.splitext(planned_out)[0] + "_offset.ass")
|
|
1185
|
+
# -o naming the input itself keeps its own, more specific refusal (_check_no_overwrite_input)
|
|
1186
|
+
if os.path.realpath(planned_out) != os.path.realpath(args.input):
|
|
1187
|
+
up_front.append(planned_out)
|
|
1188
|
+
claim(up_front)
|
|
1158
1189
|
if args.transcribe:
|
|
1159
|
-
if not args.input:
|
|
1160
|
-
die("--transcribe needs the input video")
|
|
1161
1190
|
# the sidecar goes next to the output like the --text one, not into the source folder
|
|
1162
1191
|
# where it silently replaced a hand-written <input>.srt (review 5)
|
|
1163
1192
|
srt_path = args.write_srt or os.path.splitext(args.output or default_output(args.input, "captioned"))[0] + ".srt"
|
|
@@ -1165,8 +1194,6 @@ def main() -> int:
|
|
|
1165
1194
|
cues = []
|
|
1166
1195
|
info(f"[dry-run] would transcribe {args.input} and write {srt_path}")
|
|
1167
1196
|
else:
|
|
1168
|
-
if os.path.exists(srt_path) and not getattr(args, "overwrite", False):
|
|
1169
|
-
info(f"warning: {srt_path} already exists and will be replaced by the transcript (pass --overwrite to confirm)")
|
|
1170
1197
|
cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
|
|
1171
1198
|
args._word_timings = whisper_word_timings(srt_path)
|
|
1172
1199
|
cues, changed = lay_out(cues)
|
|
@@ -1211,6 +1238,7 @@ def main() -> int:
|
|
|
1211
1238
|
adjusted, changed = lay_out(parse_srt(srt_path))
|
|
1212
1239
|
if changed:
|
|
1213
1240
|
new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
|
|
1241
|
+
claim([new_srt])
|
|
1214
1242
|
if STATE.dry_run:
|
|
1215
1243
|
info(f"[dry-run] would write {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
|
|
1216
1244
|
else:
|
|
@@ -1393,6 +1421,7 @@ def main() -> int:
|
|
|
1393
1421
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1394
1422
|
args._word_timings = whisper_word_timings(srt_path)
|
|
1395
1423
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1424
|
+
claim([ass_path])
|
|
1396
1425
|
w, h = meta["video"]["width"], meta["video"]["height"]
|
|
1397
1426
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
1398
1427
|
w, h = h, w
|
package/scripts/render.py
CHANGED
|
@@ -64,7 +64,7 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
64
64
|
|
|
65
65
|
from export import PRESETS, PLATFORM_OF
|
|
66
66
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
67
|
-
from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
|
|
67
|
+
from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
|
|
68
68
|
import subprocess
|
|
69
69
|
from _contract import CONTRACT_VERSION
|
|
70
70
|
from batch import file_key
|
|
@@ -1010,14 +1010,21 @@ def main() -> int:
|
|
|
1010
1010
|
problems.append({"index": i, "path": src, "reason": "a directory, not a file"})
|
|
1011
1011
|
elif os.path.getsize(src) == 0:
|
|
1012
1012
|
problems.append({"index": i, "path": src, "reason": "empty (0 bytes)"})
|
|
1013
|
+
elif not STATE.dry_run:
|
|
1014
|
+
# 2.2.4: readable, too -- one ffprobe per source, nothing decoded, the same test
|
|
1015
|
+
# join.py's preflight makes. Before, a real run probed the sources one by one and
|
|
1016
|
+
# stopped at the first unreadable file; the dry run already named every one via join.
|
|
1017
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-show_entries", "stream=codec_type",
|
|
1018
|
+
"-of", "csv=p=0", src], quiet=True, check=False)
|
|
1019
|
+
kinds = {line.strip() for line in (proc.stdout or "").splitlines() if line.strip()}
|
|
1020
|
+
if proc.returncode != 0 or not kinds & {"audio", "video"}:
|
|
1021
|
+
first = ((proc.stderr or "").strip().splitlines() or ["no audio or video stream"])[-1]
|
|
1022
|
+
problems.append({"index": i, "path": src, "reason": f"unreadable: {first}"})
|
|
1013
1023
|
if problems:
|
|
1014
1024
|
die(f"{len(problems)} of {len(clips)} clip sources unusable: "
|
|
1015
1025
|
+ "; ".join(f"clip {p['index']}: {p['reason']}: {p['path']}" for p in problems),
|
|
1016
1026
|
kind="input", problems=problems,
|
|
1017
1027
|
hint="fix or remove these clips in the project; nothing was cut")
|
|
1018
|
-
if not STATE.dry_run:
|
|
1019
|
-
for c in clips:
|
|
1020
|
-
probe(rel(c["src"])) # an unreadable source stops the run before any clip is cut
|
|
1021
1028
|
|
|
1022
1029
|
parts: List[str] = []
|
|
1023
1030
|
for i, c in enumerate(clips):
|
package/scripts/sync.py
CHANGED
|
@@ -186,7 +186,11 @@ def measure_offset(ref_path: str, oth_path: str, start: float, seconds: float, s
|
|
|
186
186
|
ref = envelope(ref_s, step)
|
|
187
187
|
oth = envelope(oth_s, step)
|
|
188
188
|
if len(ref) < 10 or len(oth) < 10:
|
|
189
|
-
|
|
189
|
+
# name the file (or both): sync.py and multicam.py measure several sources, and a bare
|
|
190
|
+
# "not enough audio" left the caller to guess which one was too short
|
|
191
|
+
short = [p for p, env in ((ref_path, ref), (oth_path, oth)) if len(env) < 10]
|
|
192
|
+
die(f"not enough audio to analyse in {' and '.join(short)} "
|
|
193
|
+
f"(needs at least {10 * step_ms / 1000:g} s of audio from {start:g} s)", kind="input")
|
|
190
194
|
max_lag = int(max_offset * SR / step)
|
|
191
195
|
lag, score = cross_correlate(ref, oth, max_lag)
|
|
192
196
|
offset = lag * step / SR
|
package/scripts/waveform.py
CHANGED
|
@@ -30,14 +30,14 @@ invented: give an image or a colour.
|
|
|
30
30
|
python3 waveform.py ep.m4a --image cover.png --srt ep.srt --title "Episode 12" -o ep.mp4
|
|
31
31
|
"""
|
|
32
32
|
import argparse
|
|
33
|
+
import json
|
|
33
34
|
import os
|
|
34
|
-
import subprocess
|
|
35
35
|
import sys
|
|
36
36
|
|
|
37
37
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
38
38
|
from _common import (add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base,
|
|
39
39
|
info, load_brand, pad_filters, probe, run, STATE, validate_color, video_args, X264_PRESETS,
|
|
40
|
-
fmt_secs)
|
|
40
|
+
fmt_secs, child_args, run_tool)
|
|
41
41
|
|
|
42
42
|
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
43
43
|
|
|
@@ -114,6 +114,11 @@ def main() -> int:
|
|
|
114
114
|
die(f"--vis-height must be between 0 and 1, got {args.vis_height:g}")
|
|
115
115
|
if args.srt and args.text:
|
|
116
116
|
die("--srt and --text exclude each other (both name the cues to burn)")
|
|
117
|
+
# caption.py would refuse a missing cue file too, but only after the visualisation had been
|
|
118
|
+
# encoded: check it here, before any encode, and name the flag the caller gave
|
|
119
|
+
for flag, path in (("--srt", args.srt), ("--text", args.text)):
|
|
120
|
+
if path and not os.path.isfile(path):
|
|
121
|
+
die(f"{flag} file not found: {path}", kind="input")
|
|
117
122
|
|
|
118
123
|
if args.width <= 0 or args.height <= 0:
|
|
119
124
|
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
@@ -193,30 +198,40 @@ def main() -> int:
|
|
|
193
198
|
if meta.get("duration"):
|
|
194
199
|
cmd += ["-t", f"{float(meta['duration']):.3f}"]
|
|
195
200
|
cmd += ["-shortest", render]
|
|
196
|
-
|
|
201
|
+
stem, ext = os.path.splitext(output)
|
|
202
|
+
intermediates = [p for p in (render, stem + "_titled" + ext) if p != output]
|
|
203
|
+
try:
|
|
204
|
+
run(cmd)
|
|
197
205
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
206
|
+
stages = ["waveform"]
|
|
207
|
+
current = render
|
|
208
|
+
if args.title:
|
|
209
|
+
titled = stem + "_titled" + ext if (args.srt or args.text) else output
|
|
210
|
+
# a dry run rendered no _vis file for graphics.py to probe, so its default --end (the
|
|
211
|
+
# clip's end) came out 0 and the plan failed: give it the source's duration, which
|
|
212
|
+
# is what -t caps the render at. A real run is unchanged.
|
|
213
|
+
end = (["--end", f"{float(meta['duration']):.3f}"]
|
|
214
|
+
if STATE.dry_run and meta.get("duration") else [])
|
|
215
|
+
_child("graphics.py", [current, "--template", "sticker", "--text", args.title,
|
|
216
|
+
"--position", "top-left"] + end + ["-o", titled]
|
|
217
|
+
+ (["--brand", args.brand] if args.brand else []))
|
|
218
|
+
stages.append("title")
|
|
219
|
+
current = titled
|
|
220
|
+
if args.srt or args.text:
|
|
221
|
+
_child("caption.py", [current] + (["--srt", args.srt] if args.srt else ["--text", args.text])
|
|
222
|
+
+ (["--platform", args.platform] if args.platform else []) + ["-o", output])
|
|
223
|
+
stages.append("captions")
|
|
224
|
+
current = output
|
|
225
|
+
if current != output and not STATE.dry_run:
|
|
226
|
+
os.replace(current, output)
|
|
227
|
+
current = output
|
|
228
|
+
finally:
|
|
229
|
+
# the intermediates exist only to keep each tool's own code path the only one there is;
|
|
230
|
+
# a failed stage removes them too, not only a finished chain
|
|
231
|
+
if not STATE.dry_run:
|
|
232
|
+
for temp in intermediates:
|
|
233
|
+
if os.path.exists(temp):
|
|
234
|
+
os.remove(temp)
|
|
220
235
|
|
|
221
236
|
result = probe(output, role="output")
|
|
222
237
|
v = result["video"] or {}
|
|
@@ -268,16 +283,24 @@ def main() -> int:
|
|
|
268
283
|
|
|
269
284
|
def _child(script_name: str, argv: "list") -> None:
|
|
270
285
|
"""Run one of this skill's own tools as a second process, so the code path it owns (the ASS
|
|
271
|
-
generator, the drawtext template) stays the only one there is.
|
|
286
|
+
generator, the drawtext template) stays the only one there is. The shared flags (--overwrite,
|
|
287
|
+
--timeout, --fast, --dry-run) reach it through child_args(), as render.py's stages get them,
|
|
288
|
+
and a failure is re-raised with the child's own kind, exit code and hint."""
|
|
272
289
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
273
|
-
cmd = [
|
|
274
|
-
if
|
|
275
|
-
|
|
276
|
-
info("-> " + " ".join(os.path.basename(c) if c.endswith(".py") else str(c) for c in cmd[1:]))
|
|
277
|
-
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
|
|
290
|
+
cmd = [os.path.join(here, script_name)] + [str(a) for a in argv] + child_args() + ["--json"]
|
|
291
|
+
info("-> " + " ".join(os.path.basename(c) if c.endswith(".py") else str(c) for c in cmd[:-1]))
|
|
292
|
+
proc = run_tool(cmd)
|
|
278
293
|
if proc.returncode != 0:
|
|
279
|
-
|
|
280
|
-
|
|
294
|
+
try:
|
|
295
|
+
doc = json.loads(proc.stdout.strip() or "{}")
|
|
296
|
+
except ValueError:
|
|
297
|
+
doc = {}
|
|
298
|
+
if not isinstance(doc, dict):
|
|
299
|
+
doc = {}
|
|
300
|
+
err = doc.get("error") or {}
|
|
301
|
+
extra = {"hint": err["hint"]} if err.get("hint") else {}
|
|
302
|
+
die(f"{script_name} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
|
|
303
|
+
code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name, **extra)
|
|
281
304
|
|
|
282
305
|
if __name__ == "__main__":
|
|
283
306
|
sys.exit(main())
|