ffmpeg-skill 2.2.2 → 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 +8 -2
- package/package.json +1 -1
- package/references/scripts.md +19 -3
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/probe.py +3 -0
- package/scripts/_common/runner.py +32 -14
- package/scripts/_contract.py +5 -0
- package/scripts/audio.py +44 -4
- package/scripts/caption.py +34 -5
- package/scripts/color.py +39 -4
- package/scripts/join.py +46 -7
- 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"},
|
|
@@ -492,6 +492,12 @@ Per-tool keys added in 1.18.0, all additive:
|
|
|
492
492
|
| `sources` | `sync.py` | `[{path, offset_s, confidence, drift_ppm}]`, one entry per SOURCE. Present for every run, including the original single-SOURCE shape (where it mirrors the top-level `second`/`offset_seconds`/`confidence` additively). With 2+ SOURCEs it is the *only* per-source shape: there is no top-level `second`/`offset_seconds` because there is no single pair to put there |
|
|
493
493
|
| `switch_mode`, `min_shot` | `multicam.py --switch energy` | `"energy"` and the `--min-shot` value used (default 1.5s), alongside the existing `cuts` (`[[start, end, camera], ...]`) which already carries the camera index for `--edl`'s companion cut list |
|
|
494
494
|
|
|
495
|
+
Per-tool keys added after 2.2.2, all additive:
|
|
496
|
+
|
|
497
|
+
| key | tool | what it holds |
|
|
498
|
+
|---|---|---|
|
|
499
|
+
| `sdr_path`, `notes` | `color.py --to-sdr` | `sdr_path` is `"tonemap"` for PQ / HLG / Dolby Vision input (and `--force` on an untagged file) or `"gamut"` for BT.2020 primaries on an SDR transfer, which is converted to BT.709 without a tone map (`--tonemap`/`--peak`/`--desat` do not apply); `notes` says which path was taken and why |
|
|
500
|
+
|
|
495
501
|
`check.py` also gains an informational `subtitles` row on **every** platform:
|
|
496
502
|
`PASS` when every soft subtitle stream carries a language tag, `WARN` when one
|
|
497
503
|
does not (or when there are none). Like `channels` and `chapters` it is never
|
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`.
|
|
@@ -573,7 +575,11 @@ frame, rate or xfade offsets that rest on a pending input's unmeasured placehold
|
|
|
573
575
|
Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
|
|
574
576
|
channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
|
|
575
577
|
silent track generated for clips without audio), then chains `xfade` +
|
|
576
|
-
`acrossfade`.
|
|
578
|
+
`acrossfade`. With a transition each clip has one length for picture and sound
|
|
579
|
+
-- the video stream's, or the audio's when it runs more than a frame longer --
|
|
580
|
+
and the shorter stream is padded (silence / last frame held), so every clip's
|
|
581
|
+
sound stays with its picture. Output length = sum of clip lengths −
|
|
582
|
+
transition × (n−1); `verified` checks the output's video-stream length against it. Clips must be
|
|
577
583
|
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
578
584
|
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
579
585
|
output; the result says so with `dropped_non_av_streams: true`.
|
|
@@ -1049,6 +1055,13 @@ caption.py INPUT --srt FILE[:LANG] | --ass FILE | --text CUES.txt [--write-srt O
|
|
|
1049
1055
|
[--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
|
|
1050
1056
|
caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
|
|
1051
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
|
+
|
|
1052
1065
|
**Caption margins (1.17.2).** `--margin` is the **vertical** distance from the
|
|
1053
1066
|
edge, in ASS units against the 288-line script grid (default 30; with
|
|
1054
1067
|
`--platform` it becomes that destination's `safe.top`/`safe.bottom`, e.g. 22 %
|
|
@@ -1384,6 +1397,9 @@ threshold ducks on quieter speech, a shorter release brings the bed back faster.
|
|
|
1384
1397
|
`--json`'s `audio` block reports the settings the run actually used.
|
|
1385
1398
|
`--effects FILE` mixes a third track (sound effects, atmos) at
|
|
1386
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}]`).
|
|
1387
1403
|
`--stereo-widen 0..1` widens the stereo image (`extrastereo=m=1+2*amount`) and
|
|
1388
1404
|
needs a real stereo source: it scales the side signal (L−R), so a mono track
|
|
1389
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",
|
package/scripts/_common/probe.py
CHANGED
|
@@ -283,6 +283,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
283
283
|
"color_range": video.get("color_range"),
|
|
284
284
|
"rotation": rotation,
|
|
285
285
|
"nb_frames": _to_int(video.get("nb_frames")),
|
|
286
|
+
# the stream's own length (a container's also counts audio priming and a longer track)
|
|
287
|
+
"duration": _to_float(video.get("duration")),
|
|
286
288
|
"bitrate": _to_int(video.get("bit_rate")),
|
|
287
289
|
}
|
|
288
290
|
if audio:
|
|
@@ -292,6 +294,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
292
294
|
"channel_layout": audio.get("channel_layout"),
|
|
293
295
|
"sample_rate": _to_int(audio.get("sample_rate")),
|
|
294
296
|
"bitrate": _to_int(audio.get("bit_rate")),
|
|
297
|
+
"duration": _to_float(audio.get("duration")),
|
|
295
298
|
}
|
|
296
299
|
# every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
|
|
297
300
|
out["audio_streams"] = [{
|
|
@@ -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/_contract.py
CHANGED
|
@@ -443,6 +443,11 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
443
443
|
extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
|
|
444
444
|
"targets": {"type": "object", "description": "the requested lufs / tp / lra"},
|
|
445
445
|
"result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"}}
|
|
446
|
+
elif name == "color":
|
|
447
|
+
extra = {"dropped_non_av_streams": {"type": "boolean"},
|
|
448
|
+
"sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
|
|
449
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr only: which path was taken and why"},
|
|
450
|
+
"measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
|
|
446
451
|
elif name == "cut":
|
|
447
452
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
448
453
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
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/color.py
CHANGED
|
@@ -169,12 +169,36 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str
|
|
|
169
169
|
return ",".join(chain)
|
|
170
170
|
|
|
171
171
|
|
|
172
|
+
def bt2020_sdr_to_bt709_chain(meta: dict) -> str:
|
|
173
|
+
"""Gamut conversion only, for BT.2020 primaries on an SDR transfer (probe: `bt2020_or_hdr`
|
|
174
|
+
true, `hdr_signal` false). Such a file is already display-referred SDR: linearising it with
|
|
175
|
+
its own transfer, mapping the primaries to BT.709 and re-applying the BT.709 transfer keeps
|
|
176
|
+
white at white and grey at grey. The tone map the HDR chain adds would treat 100 % SDR white
|
|
177
|
+
as a fraction of a 1000-nit peak and darken the picture (white Y 235 -> 151, grey 126 -> 90)."""
|
|
178
|
+
v = meta["video"]
|
|
179
|
+
trc = v.get("color_transfer") or "bt709"
|
|
180
|
+
space = v.get("color_space") or "bt2020nc"
|
|
181
|
+
return ",".join([
|
|
182
|
+
f"zscale=tin={trc}:pin=bt2020:min={space}:rin={v.get('color_range') or 'tv'}:t=linear",
|
|
183
|
+
"format=gbrpf32le",
|
|
184
|
+
"zscale=p=bt709",
|
|
185
|
+
"zscale=t=bt709:m=bt709:r=tv",
|
|
186
|
+
"format=yuv420p",
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_bt2020_sdr(v: dict) -> bool:
|
|
191
|
+
"""BT.2020 primaries on a non-HDR transfer and no Dolby Vision: gamut-only --to-sdr."""
|
|
192
|
+
return bool(v.get("bt2020_or_hdr")) and not v.get("hdr_signal") and not v.get("dolby_vision") \
|
|
193
|
+
and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
|
|
194
|
+
|
|
195
|
+
|
|
172
196
|
def main() -> int:
|
|
173
197
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
174
198
|
ap.add_argument("input")
|
|
175
199
|
ap.add_argument("-o", "--output", help="output file (default: <name>_sdr / _lut / _retag)")
|
|
176
200
|
mode = ap.add_mutually_exclusive_group(required=True)
|
|
177
|
-
mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020
|
|
201
|
+
mode.add_argument("--to-sdr", action="store_true", help="convert to SDR BT.709: tone-map HDR (PQ/HLG/Dolby Vision); BT.2020 primaries on an SDR transfer get a gamut conversion only, no tone map")
|
|
178
202
|
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
179
203
|
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
180
204
|
mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
|
|
@@ -183,7 +207,7 @@ def main() -> int:
|
|
|
183
207
|
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
184
208
|
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
185
209
|
ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
|
|
186
|
-
ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is
|
|
210
|
+
ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is tagged neither HDR nor BT.2020 (tone-mapped as PQ; a BT.2020 SDR file never needs it)")
|
|
187
211
|
ap.add_argument("--exposure", type=float, default=CORRECTION["exposure"][0], help="--correct: exposure in stops, -3..3 (default 0)")
|
|
188
212
|
ap.add_argument("--contrast", type=float, default=CORRECTION["contrast"][0], help="--correct: contrast, 0..2, 1=unchanged (default 1)")
|
|
189
213
|
ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
|
|
@@ -278,10 +302,21 @@ def main() -> int:
|
|
|
278
302
|
return 0
|
|
279
303
|
|
|
280
304
|
measurements = None
|
|
305
|
+
sdr_extra: dict = {}
|
|
281
306
|
if args.to_sdr:
|
|
282
307
|
if not v.get("bt2020_or_hdr") and not args.force:
|
|
283
308
|
die(f"{args.input} is not tagged as HDR (transfer={v.get('color_transfer')}, primaries={v.get('color_primaries')}). Use --force to tone-map anyway.")
|
|
284
|
-
|
|
309
|
+
if is_bt2020_sdr(v):
|
|
310
|
+
vf = bt2020_sdr_to_bt709_chain(meta)
|
|
311
|
+
sdr_path = "gamut"
|
|
312
|
+
note = (f"BT.2020 primaries on an SDR transfer ({v.get('color_transfer')}): converted the gamut to BT.709 "
|
|
313
|
+
f"without a tone map (--tonemap/--peak/--desat do not apply)")
|
|
314
|
+
else:
|
|
315
|
+
vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
|
|
316
|
+
sdr_path = "tonemap"
|
|
317
|
+
note = f"tone-mapped {v.get('hdr_format') or 'untagged input as PQ (--force)'} to SDR BT.709 with {args.tonemap}"
|
|
318
|
+
info(f"note: {note}")
|
|
319
|
+
sdr_extra = {"sdr_path": sdr_path, "notes": [note]}
|
|
285
320
|
output = args.output or default_output(args.input, "sdr")
|
|
286
321
|
tag = "sdr"
|
|
287
322
|
elif args.correct:
|
|
@@ -323,7 +358,7 @@ def main() -> int:
|
|
|
323
358
|
r = probe(output, role="output")
|
|
324
359
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
|
|
325
360
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
326
|
-
extra = {"dropped_non_av_streams": dropped_streams}
|
|
361
|
+
extra = {"dropped_non_av_streams": dropped_streams, **sdr_extra}
|
|
327
362
|
if measurements is not None:
|
|
328
363
|
measurements["output"] = analyze_levels(output)
|
|
329
364
|
extra["measurements"] = measurements
|
package/scripts/join.py
CHANGED
|
@@ -63,6 +63,20 @@ def unpending_length(durs: List[float], d: float) -> Optional[float]:
|
|
|
63
63
|
return round(sum(durs) - d * (len(durs) - 1), 3)
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def clip_length(meta: Dict[str, Any], fps: float) -> float:
|
|
67
|
+
"""The one length a clip holds in a crossfaded join, for its picture and its sound alike: the
|
|
68
|
+
video stream's, or the audio stream's when the sound runs more than a frame past the picture
|
|
69
|
+
(narration is never cut; an AAC tail adds no frame). The container duration (audio priming,
|
|
70
|
+
a longer track) is only the fallback when a stream length is unknown (a pending input)."""
|
|
71
|
+
v = (meta.get("video") or {}).get("duration")
|
|
72
|
+
a = (meta.get("audio") or {}).get("duration")
|
|
73
|
+
if not v:
|
|
74
|
+
return meta.get("duration") or 0.0
|
|
75
|
+
if a and fps and a > v + 1.0 / fps:
|
|
76
|
+
return a
|
|
77
|
+
return v
|
|
78
|
+
|
|
79
|
+
|
|
66
80
|
def expected_text(expected: Optional[float]) -> str:
|
|
67
81
|
"""unpending_length() as the stderr summary line puts it."""
|
|
68
82
|
return f"expected ~{expected:.3f}s" if expected is not None else "expected length unknown until the pending inputs exist"
|
|
@@ -293,7 +307,10 @@ def main() -> int:
|
|
|
293
307
|
w, h = w - (w % 2), h - (h % 2)
|
|
294
308
|
durs = [m.get("duration") or 0.0 for m in metas]
|
|
295
309
|
d = args.duration if args.transition != "none" else 0.0
|
|
296
|
-
for
|
|
310
|
+
# a crossfaded join gives each clip one length for both streams, so the offsets that place
|
|
311
|
+
# every picture also place its sound; the plain cut (concat) keeps the streams paired itself
|
|
312
|
+
lens = [clip_length(m, fps) for m in metas] if d else durs
|
|
313
|
+
for p, dur in zip(args.inputs, lens):
|
|
297
314
|
if d and dur <= d * 2 and not STATE.dry_run:
|
|
298
315
|
die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
|
|
299
316
|
|
|
@@ -347,14 +364,22 @@ def main() -> int:
|
|
|
347
364
|
chain = "".join(f"[v{i}][a{i}]" for i in range(n))
|
|
348
365
|
parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
|
|
349
366
|
else:
|
|
350
|
-
|
|
367
|
+
# pad the shorter stream to the clip's length: the audio with silence, the picture by
|
|
368
|
+
# holding its last frame, then cut both there -- each xfade and acrossfade then starts
|
|
369
|
+
# at the same clip boundary instead of the audio chain drifting by every clip's gap
|
|
370
|
+
for i in range(n):
|
|
371
|
+
vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
|
|
372
|
+
hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
|
|
373
|
+
parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
|
|
374
|
+
parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
|
|
375
|
+
vprev, aprev = "vp0", "ap0"
|
|
351
376
|
offset = 0.0
|
|
352
377
|
for i in range(1, n):
|
|
353
|
-
offset +=
|
|
378
|
+
offset += lens[i - 1] - d
|
|
354
379
|
vout = f"vx{i}" if i < n - 1 else "vout"
|
|
355
380
|
aout = f"ax{i}" if i < n - 1 else "aout"
|
|
356
|
-
parts.append(f"[{vprev}][
|
|
357
|
-
parts.append(f"[{aprev}][
|
|
381
|
+
parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
|
|
382
|
+
parts.append(f"[{aprev}][ap{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
|
|
358
383
|
vprev, aprev = vout, aout
|
|
359
384
|
|
|
360
385
|
# A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
|
|
@@ -367,12 +392,26 @@ def main() -> int:
|
|
|
367
392
|
"by its measured length")
|
|
368
393
|
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
369
394
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
395
|
+
if d:
|
|
396
|
+
# the fps filter already made the picture constant-rate and xfade places every frame: FFmpeg
|
|
397
|
+
# 5.1's default cfr output mode still duplicated the last one to meet the AAC tail (a 51-frame
|
|
398
|
+
# dissolve came out 52), which newer builds no longer do -- pass the frames through as built
|
|
399
|
+
cmd += ["-fps_mode", "passthrough"]
|
|
370
400
|
cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
|
|
371
401
|
run(cmd)
|
|
372
|
-
expected = unpending_length(
|
|
402
|
+
expected = unpending_length(lens, d)
|
|
373
403
|
r = probe(output, role="output")
|
|
374
|
-
|
|
404
|
+
# the picture is what the offsets place: measure the video stream, not the container (whose
|
|
405
|
+
# length also counts the AAC tail), against the length the clips add up to
|
|
406
|
+
measured = (r.get("video") or {}).get("duration") or r.get("duration")
|
|
407
|
+
verification = []
|
|
408
|
+
if expected is not None and measured is not None and not STATE.dry_run:
|
|
409
|
+
tol = max(1.5 / fps, 0.05)
|
|
410
|
+
verification.append({"step": "duration", "ok": abs(measured - expected) <= tol,
|
|
411
|
+
"measured": round(measured, 3), "expected": expected, "tolerance": round(tol, 3)})
|
|
412
|
+
info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
375
413
|
emit(output, mode="video", **reported(), clips=n, transition=args.transition, expected_duration=expected,
|
|
414
|
+
verification=verification,
|
|
376
415
|
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
|
377
416
|
return 0
|
|
378
417
|
|
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())
|