ffmpeg-skill 3.0.0 → 3.1.1
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 +15 -3
- package/package.json +1 -1
- package/scripts/_common/asr.py +15 -7
- package/scripts/_common/runner.py +77 -3
- package/scripts/_contract.py +6 -1
- package/scripts/render.py +21 -0
- package/scripts/silence.py +5 -1
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 (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.1.1`) | 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`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.1.1", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -788,7 +788,19 @@ auto` found engines and none transcribed, the refusal is `kind: input` with `rea
|
|
|
788
788
|
engines auto passed over for a language that is not English), `engine` (the first one it names)
|
|
789
789
|
and `engines: [{engine, reason, detail}]`, where `detail` carries an engine's own error line. A
|
|
790
790
|
named `--engine` that is not installed is `kind: missing_tool`; one that is installed and failed
|
|
791
|
-
is `kind: input`, `reason: "engine_failed"`, with `engine` and `detail`.
|
|
791
|
+
is `kind: input`, `reason: "engine_failed"`, with `engine` and `detail`. Since 3.1.0 the "no local
|
|
792
|
+
speech-to-text engine found" refusal itself is `kind: missing_tool` with `reason:
|
|
793
|
+
"asr_engine_missing"` (it was `kind: input`): a missing dependency, like a named engine that is
|
|
794
|
+
not installed.
|
|
795
|
+
|
|
796
|
+
An ffmpeg run that fails keeps `kind: ffmpeg` and exit code 1, and since 3.1.0 carries
|
|
797
|
+
`error.reason` and a `hint` when ffmpeg's own message names a cause a caller can act on
|
|
798
|
+
(`_common.runner.FFMPEG_FAILURE_CLASSES`): `disk_full` ("No space left on device"),
|
|
799
|
+
`encoder_missing` and `filter_missing` (`details.name`; the build lacks it, `doctor` says how to
|
|
800
|
+
get one that has it), `input_incomplete` ("moov atom not found": a cut-off recording or an
|
|
801
|
+
unfinished copy, which `--repair` cannot rebuild) and `input_unreadable`. A tool that copies the
|
|
802
|
+
source's subtitle and data streams retried the whole encode without them after any failure; it
|
|
803
|
+
now retries only when the source has such a stream and the failure is none of these.
|
|
792
804
|
|
|
793
805
|
`check.py` also gains an informational `subtitles` row on **every** platform:
|
|
794
806
|
`PASS` when every soft subtitle stream carries a language tag, `WARN` when one
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
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/scripts/_common/asr.py
CHANGED
|
@@ -447,11 +447,13 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
|
|
|
447
447
|
def die_no_engine(alternative: str, flag: str = "--transcribe") -> None:
|
|
448
448
|
"""The one "no local speech-to-text engine" refusal, in the one set of words.
|
|
449
449
|
|
|
450
|
-
kind
|
|
451
|
-
|
|
450
|
+
kind missing_tool (3.1.0; it was kind input, which read as a bad argument), `reason:
|
|
451
|
+
"asr_engine_missing"`, exit 1, and the three install lines -- caption.py and silence.py both
|
|
452
|
+
land here rather than each spelling out its own version of the same missing dependency.
|
|
452
453
|
"""
|
|
453
454
|
die(f"no local speech-to-text engine found for {flag}.\n" + ASR_INSTALL_HINT + "\n" + alternative,
|
|
454
|
-
kind="
|
|
455
|
+
kind="missing_tool", error_extra={"reason": "asr_engine_missing", "details": {}},
|
|
456
|
+
hint="install one of the engines above; `python3 scripts/_contract.py doctor` shows which are found")
|
|
455
457
|
|
|
456
458
|
|
|
457
459
|
def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
|
|
@@ -989,7 +991,7 @@ def transcribe_words(video: str, language: "Optional[str]" = None, model: "Optio
|
|
|
989
991
|
|
|
990
992
|
def transcribe_words_result(video: str, language: "Optional[str]" = None, model: "Optional[str]" = None,
|
|
991
993
|
audio_stream: int = 0, engine: "Optional[str]" = None,
|
|
992
|
-
language_flag: str = "--filler-lang") -> Transcription:
|
|
994
|
+
language_flag: str = "--filler-lang", prompt: "Optional[str]" = None) -> Transcription:
|
|
993
995
|
"""[{word, start, end}, ...] from a local speech engine, as a Transcription naming the engine.
|
|
994
996
|
|
|
995
997
|
`model` (else $FFMPEG_SKILL_ASR_MODEL, else base: requested_model()) is the model and
|
|
@@ -1001,7 +1003,10 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1001
1003
|
when the engine ran but its build produced no word-level timings, so the caller can refuse
|
|
1002
1004
|
naming that engine instead of pretending the audio had no words in it. No engine at all
|
|
1003
1005
|
raises through die_no_engine(), the same refusal caption.py gives. `language_flag` is the
|
|
1004
|
-
caller's flag for the language, named in assumed_english_note().
|
|
1006
|
+
caller's flag for the language, named in assumed_english_note(). `prompt` is passed to a
|
|
1007
|
+
Whisper engine as its initial prompt (whisper.cpp --prompt, faster-whisper initial_prompt,
|
|
1008
|
+
openai-whisper --initial_prompt): Whisper writes clean text and leaves out "uh"/"um" unless
|
|
1009
|
+
the context shows it that they are wanted, so silence.py --filler primes it with its own list.
|
|
1005
1010
|
"""
|
|
1006
1011
|
import shutil as _shutil
|
|
1007
1012
|
from _common.runner import SafeShutil
|
|
@@ -1056,7 +1061,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1056
1061
|
base = os.path.join(tmpdir, "out")
|
|
1057
1062
|
# whisper.cpp's own default is -l en: without "auto" it decodes any speech as English
|
|
1058
1063
|
cmd = [cli, "-m", model_path, "-f", wav, "--output-json-full", "-of", base, "-l", lang or "auto",
|
|
1059
|
-
*WHISPER_CPP_NO_CONTEXT]
|
|
1064
|
+
*WHISPER_CPP_NO_CONTEXT] + (["--prompt", prompt] if prompt else [])
|
|
1060
1065
|
proc = _asr_run(cmd, _subprocess, "whisper.cpp")
|
|
1061
1066
|
if proc.returncode == 0 and os.path.exists(base + ".json"):
|
|
1062
1067
|
words = _words_from_whisper_cpp_json(base + ".json")
|
|
@@ -1082,7 +1087,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1082
1087
|
def work() -> None:
|
|
1083
1088
|
try:
|
|
1084
1089
|
m = WhisperModel(model, device="cpu", compute_type="int8")
|
|
1085
|
-
segments, decoded = m.transcribe(wav, language=lang, word_timestamps=True
|
|
1090
|
+
segments, decoded = m.transcribe(wav, language=lang, word_timestamps=True,
|
|
1091
|
+
**({"initial_prompt": prompt} if prompt else {}))
|
|
1086
1092
|
heard.append(getattr(decoded, "language", None))
|
|
1087
1093
|
for seg in segments:
|
|
1088
1094
|
for w in (getattr(seg, "words", None) or []):
|
|
@@ -1117,6 +1123,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1117
1123
|
"--output_format", "json", "--output_dir", tmpdir]
|
|
1118
1124
|
if lang:
|
|
1119
1125
|
cmd += ["--language", lang]
|
|
1126
|
+
if prompt:
|
|
1127
|
+
cmd += ["--initial_prompt", prompt]
|
|
1120
1128
|
proc = _asr_run(cmd, _subprocess, "openai-whisper")
|
|
1121
1129
|
doc = os.path.join(tmpdir, "audio.json")
|
|
1122
1130
|
if proc.returncode == 0 and os.path.exists(doc):
|
|
@@ -598,6 +598,35 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
598
598
|
pass
|
|
599
599
|
|
|
600
600
|
|
|
601
|
+
# ffmpeg's own words for the failures a caller can act on. Each pattern gives `error.reason` and a
|
|
602
|
+
# hint; the kind stays "ffmpeg" and the exit code 1 (docs/design-decisions.md), so a caller that
|
|
603
|
+
# keyed on those sees no change. Matched against the stderr tail, the first hit wins.
|
|
604
|
+
FFMPEG_FAILURE_CLASSES = (
|
|
605
|
+
(re.compile(r"No space left on device"), "disk_full",
|
|
606
|
+
"the disk the output is written to is full: free space there (or write elsewhere with -o) and run again"),
|
|
607
|
+
(re.compile(r"Unknown encoder '([^']+)'|Encoder '?([\w-]+)'? not found|Requested encoder '?([\w-]+)'? not found"),
|
|
608
|
+
"encoder_missing", "this ffmpeg build has no {0} encoder: `python3 scripts/_contract.py doctor` lists what it has and how to get a fuller build"),
|
|
609
|
+
(re.compile(r"No such filter: '([^']+)'|Filter '?([\w-]+)'? not found"),
|
|
610
|
+
"filter_missing", "this ffmpeg build has no {0} filter: `python3 scripts/_contract.py doctor` lists what it has and how to get a fuller build"),
|
|
611
|
+
(re.compile(r"moov atom not found"), "input_incomplete",
|
|
612
|
+
"the input has no index (moov atom): a recording that was cut off or a copy that has not finished; "
|
|
613
|
+
"copy it again from its source. `export --preset copy --repair` cannot rebuild a missing index"),
|
|
614
|
+
(re.compile(r"Invalid data found when processing input"), "input_unreadable",
|
|
615
|
+
"ffmpeg cannot read the input as media: check that it plays and finished copying; `check.py --content` names damaged spans in a file that does"),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def classify_ffmpeg_failure(stderr: str) -> "Optional[Tuple[str, str, Dict[str, str]]]":
|
|
620
|
+
"""(reason, hint, details) for an ffmpeg stderr that names an actionable cause, else None."""
|
|
621
|
+
for pattern, reason, hint in FFMPEG_FAILURE_CLASSES:
|
|
622
|
+
m = pattern.search(stderr or "")
|
|
623
|
+
if m:
|
|
624
|
+
name = next((g for g in m.groups() if g), "")
|
|
625
|
+
details = {"name": name} if name else {}
|
|
626
|
+
return reason, hint.format(name or "that"), details
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
|
|
601
630
|
def _fail(cmd: Sequence[str], returncode: int, stderr: str, ctx: "Optional[Context]" = None, retried: bool = False) -> None:
|
|
602
631
|
# Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
|
|
603
632
|
# every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
|
|
@@ -630,7 +659,11 @@ def _fail(cmd: Sequence[str], returncode: int, stderr: str, ctx: "Optional[Conte
|
|
|
630
659
|
("ffmpeg reported no encoder-open or compression-session error, so the cause is not the GPU (see the message)"
|
|
631
660
|
if why is None else f"VideoToolbox refused it ({why}) and no CPU encoder line was recorded to retry with"))
|
|
632
661
|
extra = {"encoder": enc, "hw": report}
|
|
633
|
-
|
|
662
|
+
cls = classify_ffmpeg_failure(stderr)
|
|
663
|
+
if cls:
|
|
664
|
+
extra["hint"] = cls[1]
|
|
665
|
+
die(f"command failed ({returncode}): {what}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode,
|
|
666
|
+
error_extra={"reason": cls[0], "details": cls[2]} if cls else None, **extra)
|
|
634
667
|
|
|
635
668
|
|
|
636
669
|
def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
|
|
@@ -1419,6 +1452,22 @@ def child_args() -> List[str]:
|
|
|
1419
1452
|
return args
|
|
1420
1453
|
|
|
1421
1454
|
|
|
1455
|
+
def _has_non_av_streams(cmd: Sequence[str]) -> bool:
|
|
1456
|
+
"""Whether the command's first input has a subtitle or data stream (True when it cannot be
|
|
1457
|
+
told: the retry then runs as before)."""
|
|
1458
|
+
src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
|
|
1459
|
+
if not src or not os.path.isfile(src):
|
|
1460
|
+
return True
|
|
1461
|
+
try:
|
|
1462
|
+
out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", src],
|
|
1463
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
|
|
1464
|
+
timeout=PROBE_TIMEOUT).stdout
|
|
1465
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1466
|
+
return True
|
|
1467
|
+
kinds = {ln.strip() for ln in out.splitlines() if ln.strip()}
|
|
1468
|
+
return not kinds or bool(kinds & {"subtitle", "data", "attachment"})
|
|
1469
|
+
|
|
1470
|
+
|
|
1422
1471
|
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
1423
1472
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
1424
1473
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
@@ -1435,6 +1484,11 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
|
1435
1484
|
first = run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False)
|
|
1436
1485
|
if first.returncode == 0:
|
|
1437
1486
|
return False
|
|
1487
|
+
if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):
|
|
1488
|
+
# a full disk, a missing encoder or an unreadable input fails the second encode the same
|
|
1489
|
+
# way: report the first failure rather than run the whole encode again and blame streams
|
|
1490
|
+
# the source may not even have
|
|
1491
|
+
_fail(first.args, first.returncode, first.stderr or "")
|
|
1438
1492
|
error = " | ".join([ln.strip() for ln in (first.stderr or "").splitlines() if ln.strip()][-3:])[-500:]
|
|
1439
1493
|
record_fallback(f"the subtitle/data streams could not be copied into {os.path.basename(output)} "
|
|
1440
1494
|
f"({error or 'ffmpeg exit ' + str(first.returncode)}); encoding again without them",
|
|
@@ -1451,10 +1505,29 @@ def _limit_for(cmd: Sequence[str]) -> Optional[float]:
|
|
|
1451
1505
|
return STATE.timeout or None
|
|
1452
1506
|
|
|
1453
1507
|
|
|
1508
|
+
def _die_with_parent() -> None:
|
|
1509
|
+
"""Linux: have the kernel SIGKILL this child when the tool that started it dies, even by
|
|
1510
|
+
SIGKILL (which no handler can catch). Without it a tool killed by an agent's time limit left
|
|
1511
|
+
ffmpeg running orphaned, writing the output under its final name after the caller had been
|
|
1512
|
+
told the run failed. macOS and Windows have no equivalent; SIGTERM/SIGINT are handled there
|
|
1513
|
+
as everywhere (`kind: interrupted`, the child stopped)."""
|
|
1514
|
+
try:
|
|
1515
|
+
import ctypes
|
|
1516
|
+
ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, 9) # PR_SET_PDEATHSIG, SIGKILL
|
|
1517
|
+
except Exception: # noqa: BLE001 - best effort; a child without it is what every other OS gets
|
|
1518
|
+
pass
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def child_kwargs() -> Dict[str, Any]:
|
|
1522
|
+
"""Extra Popen arguments for an ffmpeg/ffprobe child (see _die_with_parent)."""
|
|
1523
|
+
return {"preexec_fn": _die_with_parent} if sys.platform.startswith("linux") else {}
|
|
1524
|
+
|
|
1525
|
+
|
|
1454
1526
|
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
1455
1527
|
"""Plain run with stdout/stderr captured."""
|
|
1456
1528
|
limit = _limit_for(cmd)
|
|
1457
|
-
child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace"
|
|
1529
|
+
child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
|
|
1530
|
+
**child_kwargs())
|
|
1458
1531
|
_watch(child, cmd)
|
|
1459
1532
|
try:
|
|
1460
1533
|
out, err = child.communicate(timeout=limit)
|
|
@@ -1504,7 +1577,8 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
1504
1577
|
full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
|
|
1505
1578
|
t0 = time.time()
|
|
1506
1579
|
limit = _limit_for(cmd)
|
|
1507
|
-
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace"
|
|
1580
|
+
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
|
|
1581
|
+
**child_kwargs())
|
|
1508
1582
|
_watch(proc, cmd)
|
|
1509
1583
|
assert proc.stdout is not None and proc.stderr is not None
|
|
1510
1584
|
lines: "queue.Queue[Optional[str]]" = queue.Queue()
|
package/scripts/_contract.py
CHANGED
|
@@ -412,6 +412,10 @@ DIAGNOSTICS_SCHEMA = {
|
|
|
412
412
|
# error.reason: a stable code for why a refusal happened, beside `kind` (which says what class of
|
|
413
413
|
# failure it is). Every refuse() call in scripts/ names one of these (a test reads the sources).
|
|
414
414
|
ERROR_REASONS = {
|
|
415
|
+
"disk_full": "kind ffmpeg: ffmpeg reported \"No space left on device\" while writing; the hint says to free space or write elsewhere; details: {}",
|
|
416
|
+
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command used (\"No such filter\"); details: {name}; the hint points at doctor",
|
|
417
|
+
"input_incomplete": "kind ffmpeg: the input has no index (\"moov atom not found\"): a recording cut off or a copy not finished; --repair cannot rebuild it; details: {}",
|
|
418
|
+
"input_unreadable": "kind ffmpeg: ffmpeg could not read the input as media (\"Invalid data found when processing input\"); details: {}",
|
|
415
419
|
"ffmpeg_unsupported": "kind missing_tool: the ffmpeg/ffprobe on PATH reports a version older than 6.1, which 3.0.0 no longer supports (5.x was deprecated in 2.14.0); details: {version, required}",
|
|
416
420
|
"output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
|
|
417
421
|
"output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
|
|
@@ -433,7 +437,8 @@ ERROR_REASONS = {
|
|
|
433
437
|
"plan_unknown_tool": "render.py PLAN: the plan names no tool render.py can run; details: {plan, plan_sha256, checked_at, tool, audit_log}",
|
|
434
438
|
"plan_cwd_missing": "render.py PLAN: the directory the plan was made in is gone; details: {plan, plan_sha256, checked_at, cwd, audit_log}",
|
|
435
439
|
"plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
|
|
436
|
-
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool
|
|
440
|
+
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
441
|
+
"asr_engine_missing": "kind missing_tool: --transcribe (caption.py, silence.py --filler) found no local speech-to-text engine; the message lists the install lines; details: {}",
|
|
437
442
|
"encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
|
|
438
443
|
}
|
|
439
444
|
|
package/scripts/render.py
CHANGED
|
@@ -1048,6 +1048,22 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
|
|
|
1048
1048
|
return 0
|
|
1049
1049
|
|
|
1050
1050
|
|
|
1051
|
+
def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
|
|
1052
|
+
"""Whether the render carries sound at this point: measured on the intermediate when it
|
|
1053
|
+
exists, otherwise (a dry run) from the sources: a clip with an audio stream, music or a
|
|
1054
|
+
replacement track."""
|
|
1055
|
+
if os.path.exists(current) and not STATE.dry_run: # a dry run may find an earlier render's file there
|
|
1056
|
+
return bool((probe(current).get("audio") or {}).get("codec"))
|
|
1057
|
+
au = proj.get("audio") or {}
|
|
1058
|
+
if au.get("music") or au.get("replace"):
|
|
1059
|
+
return True
|
|
1060
|
+
for c in proj.get("clips") or []:
|
|
1061
|
+
src = rel(c["src"]) if isinstance(c, dict) and c.get("src") else None
|
|
1062
|
+
if not src or not os.path.exists(src) or (probe(src).get("audio") or {}).get("codec"):
|
|
1063
|
+
return True
|
|
1064
|
+
return False
|
|
1065
|
+
|
|
1066
|
+
|
|
1051
1067
|
def main() -> int:
|
|
1052
1068
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1053
1069
|
ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
|
|
@@ -1543,6 +1559,11 @@ def main() -> int:
|
|
|
1543
1559
|
|
|
1544
1560
|
# ---- loudness
|
|
1545
1561
|
ld = proj.get("loudness")
|
|
1562
|
+
if ld and not _has_sound(current, proj, rel):
|
|
1563
|
+
# a template's loudness stage on a silent source (screen recording, timelapse, drone) used
|
|
1564
|
+
# to fail the render after the picture stages had run, while the dry run said it would work
|
|
1565
|
+
record_skip("the loudness stage is not run: the video has no audio", "loudness_no_audio", stage="loudness")
|
|
1566
|
+
ld = None
|
|
1546
1567
|
if ld:
|
|
1547
1568
|
nxt = str(work / f"loudnorm{mid}")
|
|
1548
1569
|
argv = [current, "-o", nxt]
|
package/scripts/silence.py
CHANGED
|
@@ -246,8 +246,12 @@ def resolve_filler(args, meta):
|
|
|
246
246
|
# faster-whisper word_timestamps=True, openai-whisper --word_timestamps True). An SRT
|
|
247
247
|
# cannot answer this question: a cue has a start and an end, a word does not.
|
|
248
248
|
# The call's own result, not module state: engine, routing and notes belong to this run.
|
|
249
|
+
# Whisper leaves "uh"/"um" out of its text unless the context shows them: prime it with the
|
|
250
|
+
# filler list of the language asked for (English under auto, where most filler cuts are)
|
|
251
|
+
primer = FILLER_WORDS.get(args.filler_lang if args.filler_lang != "auto" else "en") or []
|
|
252
|
+
prompt = (", ".join(sorted(primer)[:8]).capitalize() + ". So, I mean...") if primer else None
|
|
249
253
|
asr_run = _asr.transcribe_words_result(args.input, args.filler_lang if args.filler_lang != "auto" else None,
|
|
250
|
-
model=args.model, engine=args.engine)
|
|
254
|
+
model=args.model, engine=args.engine, prompt=prompt)
|
|
251
255
|
words, engine = asr_run.words, asr_run.engine
|
|
252
256
|
transcription = dict(asr_run.facts)
|
|
253
257
|
args._asr_notes = list(asr_run.notes)
|