ffmpeg-skill 3.1.6 → 3.1.8
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 +22 -6
- package/package.json +1 -1
- package/references/scripts.md +19 -6
- package/scripts/_common/asr.py +49 -10
- package/scripts/_common/decision.py +13 -1
- package/scripts/_common/fonts.py +26 -0
- package/scripts/_common/probe.py +20 -3
- package/scripts/_common/runner.py +136 -22
- package/scripts/_common/timeline.py +118 -23
- package/scripts/_contract.py +33 -2
- package/scripts/batch.py +175 -28
- package/scripts/caption.py +95 -11
- package/scripts/check.py +7 -0
- package/scripts/color.py +3 -0
- package/scripts/cut.py +3 -0
- package/scripts/export.py +22 -15
- package/scripts/metadata.py +9 -2
- package/scripts/render.py +39 -4
- package/scripts/silence.py +30 -5
- package/scripts/sync.py +3 -0
- package/scripts/waveform.py +8 -0
|
@@ -14,7 +14,7 @@ import re
|
|
|
14
14
|
import shutil
|
|
15
15
|
import subprocess
|
|
16
16
|
import sys
|
|
17
|
-
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
INSTALL_HINTS = {
|
|
@@ -108,6 +108,13 @@ _FFMPEG_VERSION: "Optional[Tuple[int, int]]" = None
|
|
|
108
108
|
_FFMPEG_VERSION_IS_RELEASE = False # ffmpeg_version() read a release number, not the libavutil major
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
def _probe_limit() -> float:
|
|
112
|
+
"""The ceiling for a short read-only ffprobe/ffmpeg query run outside run(): PROBE_TIMEOUT, or
|
|
113
|
+
the caller's --timeout when that is lower (#415). Never unlimited: a version or stream-list read
|
|
114
|
+
that hangs is a stall, whatever --timeout 0 asks of the encodes."""
|
|
115
|
+
return min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
|
|
116
|
+
|
|
117
|
+
|
|
111
118
|
def ffmpeg_version() -> "Tuple[int, int]":
|
|
112
119
|
"""(major, minor) of the FFmpeg build on PATH, parsed once from `ffprobe -version`; (0, 0)
|
|
113
120
|
when it cannot be read. ffprobe rather than ffmpeg because --dry-run promises never to run
|
|
@@ -120,7 +127,7 @@ def ffmpeg_version() -> "Tuple[int, int]":
|
|
|
120
127
|
_FFMPEG_VERSION = (0, 0)
|
|
121
128
|
try:
|
|
122
129
|
out = subprocess.run([found_exe("ffprobe"), "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
|
|
123
|
-
timeout=
|
|
130
|
+
timeout=_probe_limit()).stdout
|
|
124
131
|
m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
|
|
125
132
|
if m:
|
|
126
133
|
_FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
|
|
@@ -158,7 +165,7 @@ def ffmpeg_release() -> "Optional[Tuple[int, int, int]]":
|
|
|
158
165
|
_FFMPEG_RELEASE_READ = True
|
|
159
166
|
try:
|
|
160
167
|
out = subprocess.run([found_exe("ffprobe"), "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
|
|
161
|
-
timeout=
|
|
168
|
+
timeout=_probe_limit()).stdout
|
|
162
169
|
m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)(?:\.(\d+))?", out)
|
|
163
170
|
if m:
|
|
164
171
|
_FFMPEG_RELEASE = (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
|
|
@@ -325,7 +332,7 @@ class Context:
|
|
|
325
332
|
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
|
|
326
333
|
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
|
|
327
334
|
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
|
|
328
|
-
"source_timecode", "smpte_hint")
|
|
335
|
+
"source_timecode", "smpte_hint", "delivered")
|
|
329
336
|
|
|
330
337
|
def __init__(self) -> None:
|
|
331
338
|
self.reset()
|
|
@@ -337,6 +344,7 @@ class Context:
|
|
|
337
344
|
self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
|
|
338
345
|
self.source_timecode = None # (start timecode, duration) of the first input probed that carries one (time_arg's SMPTE hint)
|
|
339
346
|
self.smpte_hint = None # what die() says when an hh:mm:ss:ff flag read past the end looks like a source timecode (#411)
|
|
347
|
+
self.delivered: List[str] = [] # deliverables written before a later step (export --normalize, render's chapters/check): a stop then keeps them (#415)
|
|
340
348
|
self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
|
|
341
349
|
self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
|
|
342
350
|
self.progress = False # run() streams percent / ETA to stderr for ffmpeg
|
|
@@ -533,12 +541,52 @@ def stop_children() -> None:
|
|
|
533
541
|
_CHILDREN.clear()
|
|
534
542
|
|
|
535
543
|
|
|
544
|
+
def mark_delivered(path: str) -> None:
|
|
545
|
+
"""Record that this run has written its deliverable and still has a step to run on it (export's
|
|
546
|
+
--normalize, render's chapters and check stages): a timeout or an interrupt from then on says the
|
|
547
|
+
file is there and kept, instead of "nothing was written" (#415)."""
|
|
548
|
+
if path and path not in STATE.delivered:
|
|
549
|
+
STATE.delivered.append(path)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def child_exit_code(doc: Any, returncode: Optional[int]) -> int:
|
|
553
|
+
"""The exit code a tool passes on from a child tool that failed: the code its document states,
|
|
554
|
+
else its process's own, else 1. A child killed by a signal has a negative returncode (-9), which
|
|
555
|
+
as an exit code wraps (247); it is passed on as the shell reports it, 128 + the signal (137)."""
|
|
556
|
+
code = (doc.get("exit_code") if isinstance(doc, dict) else None) or returncode or 1
|
|
557
|
+
try:
|
|
558
|
+
code = int(code)
|
|
559
|
+
except (TypeError, ValueError):
|
|
560
|
+
return 1
|
|
561
|
+
return 128 - code if code < 0 else code
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def kept_deliverables() -> List[str]:
|
|
565
|
+
"""The deliverables mark_delivered() named that are on disk."""
|
|
566
|
+
return [p for p in STATE.delivered if os.path.isfile(p)]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def kept_clause() -> str:
|
|
570
|
+
"""How a stopped run's message ends: "nothing was written", or which deliverable it had already
|
|
571
|
+
written and keeps (not the finished result: the step that was stopped did not run on it)."""
|
|
572
|
+
kept = kept_deliverables()
|
|
573
|
+
if not kept:
|
|
574
|
+
return "nothing was written"
|
|
575
|
+
return (f"{', '.join(kept)} had already been written by this run and {'is' if len(kept) == 1 else 'are'} kept, "
|
|
576
|
+
"without the step that was stopped (pass --overwrite to make it again)")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _kept_fields() -> Dict[str, Any]:
|
|
580
|
+
kept = kept_deliverables()
|
|
581
|
+
return {"output": kept[-1]} if kept else {}
|
|
582
|
+
|
|
583
|
+
|
|
536
584
|
def _on_signal(signum: int, frame: Any) -> None:
|
|
537
585
|
import signal as _signal
|
|
538
586
|
name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
|
|
539
587
|
stop_children()
|
|
540
|
-
die(f"interrupted by {name}: the running command was stopped and its partial output removed;
|
|
541
|
-
code=128 + signum, kind="interrupted")
|
|
588
|
+
die(f"interrupted by {name}: the running command was stopped and its partial output removed; {kept_clause()}",
|
|
589
|
+
code=128 + signum, kind="interrupted", **_kept_fields())
|
|
542
590
|
|
|
543
591
|
|
|
544
592
|
def install_signal_handlers() -> None:
|
|
@@ -895,10 +943,13 @@ class _OutputLock:
|
|
|
895
943
|
the hour ran from the lock's creation, so a legitimate run longer than an hour lost its lock
|
|
896
944
|
to a second writer. A takeover is recorded (`diagnostics.fallbacks`, stderr, and the audit
|
|
897
945
|
log); so is running without a lock where none can be created (`diagnostics.warnings`)."""
|
|
898
|
-
def __init__(self, output: str) -> None:
|
|
946
|
+
def __init__(self, output: str, on_locked: "Optional[Callable[[str, str, Dict[str, Any]], None]]" = None) -> None:
|
|
947
|
+
# on_locked(message, hint, details): a caller that must not exit (batch.py's item worker)
|
|
948
|
+
# raises its own error for a held lock instead of the refusal below
|
|
899
949
|
self.path: Optional[str] = None
|
|
900
950
|
self.fd: Optional[int] = None
|
|
901
951
|
self.output = output
|
|
952
|
+
self.on_locked = on_locked
|
|
902
953
|
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
903
954
|
return
|
|
904
955
|
d, base = os.path.split(os.path.abspath(output))
|
|
@@ -931,12 +982,14 @@ class _OutputLock:
|
|
|
931
982
|
taken = holder
|
|
932
983
|
continue
|
|
933
984
|
name = os.path.basename(self.path)[1:-len(".ffskill-lock")]
|
|
934
|
-
|
|
935
|
-
f"
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
985
|
+
msg = (f"another run is writing {name!r} right now (lock {self.path}"
|
|
986
|
+
+ (f", pid {holder['pid']}" if holder.get("pid") else "") + "); wait for it or choose a different --output/-o path")
|
|
987
|
+
hint = ("wait for that run, or choose another -o path; if pid "
|
|
988
|
+
f"{holder.get('pid')} is not an ffmpeg-skill run, delete {self.path}")
|
|
989
|
+
details = dict(lock=self.path, output=os.path.abspath(self.output), **{k: v for k, v in holder.items() if k != "stale"})
|
|
990
|
+
if self.on_locked:
|
|
991
|
+
self.on_locked(msg, hint, details)
|
|
992
|
+
refuse("input", "output_locked", msg, hint=hint, **details)
|
|
940
993
|
except OSError as exc:
|
|
941
994
|
# an unlockable location (a read-only directory surfaces elsewhere): the run goes
|
|
942
995
|
# on without a lock, and says so -- two runs could now both write this output
|
|
@@ -1110,11 +1163,28 @@ def _remember_output(cmd: Sequence[str]) -> None:
|
|
|
1110
1163
|
pass
|
|
1111
1164
|
|
|
1112
1165
|
|
|
1166
|
+
def remember_written(*paths: Optional[str]) -> None:
|
|
1167
|
+
"""Count files a child tool of this run wrote (render.py's stages, batch.py's steps) as this
|
|
1168
|
+
run's own, as run() counts its ffmpeg outputs: a later stage that replaces one is not refused
|
|
1169
|
+
as replacing someone else's file (#415)."""
|
|
1170
|
+
for path in paths:
|
|
1171
|
+
if path and os.path.isfile(path):
|
|
1172
|
+
try:
|
|
1173
|
+
STATE.written.add(os.path.realpath(path))
|
|
1174
|
+
except OSError:
|
|
1175
|
+
pass
|
|
1176
|
+
|
|
1177
|
+
|
|
1113
1178
|
def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
|
|
1114
1179
|
_cleanup_partial_output(cmd)
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1180
|
+
if _is_ffmpeg(cmd) or seconds < PROBE_TIMEOUT:
|
|
1181
|
+
advice = "Raise --timeout (or FFMPEG_SKILL_TIMEOUT) if the job is genuinely that long, or check the input for a stall"
|
|
1182
|
+
else:
|
|
1183
|
+
# a probe's own ceiling, which a higher --timeout does not raise: a read this long is a stall (#415)
|
|
1184
|
+
advice = (f"A probe is limited to {PROBE_TIMEOUT:g} s whatever --timeout says, so this is a stall, not a long "
|
|
1185
|
+
"job: check that the input is readable (a network share, a file still being written)")
|
|
1186
|
+
die(f"{os.path.basename(cmd[0])} exceeded the {seconds:g} s time limit and was killed; {kept_clause()}. {advice}",
|
|
1187
|
+
code=124, kind="timeout", **_kept_fields())
|
|
1118
1188
|
|
|
1119
1189
|
|
|
1120
1190
|
def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str], Optional[str]]:
|
|
@@ -1446,7 +1516,7 @@ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subpro
|
|
|
1446
1516
|
child.communicate()
|
|
1447
1517
|
_unwatch(child)
|
|
1448
1518
|
name = os.path.basename(str(argv[0]))
|
|
1449
|
-
msg = f"{name} did not finish within {limit
|
|
1519
|
+
msg = f"{name} did not finish within {limit:g} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
|
|
1450
1520
|
doc = {"status": "failed", "exit_code": 124,
|
|
1451
1521
|
"error": {"kind": "timeout", "message": msg, "code": ERROR_CODE["timeout"], "retryable": ERROR_RETRYABLE},
|
|
1452
1522
|
"commands": []}
|
|
@@ -1479,13 +1549,56 @@ def _has_non_av_streams(cmd: Sequence[str]) -> bool:
|
|
|
1479
1549
|
try:
|
|
1480
1550
|
out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", src],
|
|
1481
1551
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
|
|
1482
|
-
timeout=
|
|
1552
|
+
timeout=_probe_limit()).stdout
|
|
1483
1553
|
except (OSError, subprocess.TimeoutExpired):
|
|
1484
1554
|
return True
|
|
1485
1555
|
kinds = {ln.strip() for ln in out.splitlines() if ln.strip()}
|
|
1486
1556
|
return not kinds or bool(kinds & {"subtitle", "data", "attachment"})
|
|
1487
1557
|
|
|
1488
1558
|
|
|
1559
|
+
def fail_if_classified(proc: subprocess.CompletedProcess) -> None:
|
|
1560
|
+
"""Stop with a failed attempt's own error when ffmpeg named a cause a second encode would hit
|
|
1561
|
+
the same way (a full disk, a missing encoder or filter, an unreadable input): the rule
|
|
1562
|
+
run_keeping_subtitles() follows since #389, for every copy-then-re-encode or keep-then-drop
|
|
1563
|
+
fallback (#415). Returns when the failure is unclassified, so the caller's retry runs."""
|
|
1564
|
+
if proc.returncode != 0 and classify_ffmpeg_failure(proc.stderr or ""):
|
|
1565
|
+
_fail(proc.args, proc.returncode, proc.stderr or "")
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
# eia_608 (a QuickTime c608 caption track) decodes to text, so it converts like one (ffmpeg 6.1)
|
|
1569
|
+
TEXT_SUBTITLES = frozenset({"mov_text", "subrip", "srt", "ass", "ssa", "webvtt", "text", "eia_608"})
|
|
1570
|
+
MP4_FAMILY = frozenset({"mp4", "m4v", "mov"})
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def subtitle_codec_for(codec: str, out_ext: str) -> str:
|
|
1574
|
+
"""The -c:s for one source subtitle stream going into a container with extension `out_ext`
|
|
1575
|
+
(no dot): copy when the container holds it as it is, else the container's own text format --
|
|
1576
|
+
mov_text in MPEG-4, srt in Matroska for a mov_text track, webvtt in WebM. A bitmap track is
|
|
1577
|
+
returned as copy; subtitle_container_holds() says whether that copy can work."""
|
|
1578
|
+
out_ext = out_ext.lstrip(".").lower()
|
|
1579
|
+
if out_ext == "mov" and codec == "eia_608":
|
|
1580
|
+
return "copy" # QuickTime holds a c608 track as it is; .mp4 has no tag for it
|
|
1581
|
+
if out_ext in MP4_FAMILY and codec in TEXT_SUBTITLES and codec != "mov_text":
|
|
1582
|
+
return "mov_text"
|
|
1583
|
+
if out_ext == "mkv" and codec == "mov_text":
|
|
1584
|
+
return "srt"
|
|
1585
|
+
if out_ext == "webm" and codec in TEXT_SUBTITLES and codec != "webvtt":
|
|
1586
|
+
return "webvtt"
|
|
1587
|
+
return "copy"
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def subtitle_container_holds(codec: str, out_ext: str) -> bool:
|
|
1591
|
+
"""Whether a subtitle stream of `codec` can be written to an `out_ext` container at all,
|
|
1592
|
+
converted by subtitle_codec_for() where it must be: Matroska takes any, MPEG-4 a text track or
|
|
1593
|
+
DVD bitmaps, WebM only text (as WebVTT)."""
|
|
1594
|
+
out_ext = out_ext.lstrip(".").lower()
|
|
1595
|
+
if out_ext in MP4_FAMILY:
|
|
1596
|
+
return codec in TEXT_SUBTITLES or codec == "dvd_subtitle"
|
|
1597
|
+
if out_ext == "webm":
|
|
1598
|
+
return codec in TEXT_SUBTITLES
|
|
1599
|
+
return True
|
|
1600
|
+
|
|
1601
|
+
|
|
1489
1602
|
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
1490
1603
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
1491
1604
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
@@ -1516,10 +1629,11 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
|
1516
1629
|
|
|
1517
1630
|
|
|
1518
1631
|
def _limit_for(cmd: Sequence[str]) -> Optional[float]:
|
|
1519
|
-
"""The wall-clock ceiling for this command: ffprobe (and other read-only probes) get a
|
|
1520
|
-
|
|
1632
|
+
"""The wall-clock ceiling for this command: ffprobe (and other read-only probes) get a short
|
|
1633
|
+
one, PROBE_TIMEOUT or --timeout when that is lower (as _read_ff_list() does: a caller's
|
|
1634
|
+
--timeout 2 bounds every child, #415), ffmpeg the configured one; None means unlimited."""
|
|
1521
1635
|
if not _is_ffmpeg(cmd):
|
|
1522
|
-
return PROBE_TIMEOUT if STATE.timeout else None
|
|
1636
|
+
return min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else None
|
|
1523
1637
|
return STATE.timeout or None
|
|
1524
1638
|
|
|
1525
1639
|
|
|
@@ -1893,7 +2007,7 @@ def _read_ff_list(flag: str, pattern: str) -> set:
|
|
|
1893
2007
|
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
1894
2008
|
# --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
|
|
1895
2009
|
# (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
|
|
1896
|
-
limit =
|
|
2010
|
+
limit = _probe_limit()
|
|
1897
2011
|
_LIST_ERRORS.pop(flag, None)
|
|
1898
2012
|
names: set = set()
|
|
1899
2013
|
why = None
|
|
@@ -23,6 +23,7 @@ under `not_exported`, never silently dropped.
|
|
|
23
23
|
import json
|
|
24
24
|
import math
|
|
25
25
|
import os
|
|
26
|
+
import re
|
|
26
27
|
from fractions import Fraction
|
|
27
28
|
from pathlib import Path
|
|
28
29
|
from typing import Any, Dict, List, Optional, Tuple
|
|
@@ -110,6 +111,48 @@ def _picture(meta: Dict[str, Any]) -> Optional[Tuple[int, int]]:
|
|
|
110
111
|
return (w, h)
|
|
111
112
|
|
|
112
113
|
|
|
114
|
+
_TC = re.compile(r"^(\d{2}):(\d{2}):(\d{2})[:;.](\d{2,3})$")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _drop_frame(drop: Optional[bool], rate: Fraction) -> bool:
|
|
118
|
+
"""Drop-frame counting applies to the NTSC rates only (29.97 and 59.94)."""
|
|
119
|
+
return bool(drop) and rate.denominator == 1001 and round(float(rate)) in (30, 60)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def start_tc_frames(tc: Dict[str, Any], rate: Fraction) -> int:
|
|
123
|
+
"""A source's start timecode (probe()'s `timecode`: {value, drop_frame}) as a frame count at
|
|
124
|
+
its own `rate`, drop-frame counted where it is one; 0 when the source has none (#414). A
|
|
125
|
+
label that does not exist at that rate (frame 29 at 25 fps, or a frame drop-frame counting
|
|
126
|
+
skips) is read as none, as probe() skips a malformed tag, rather than as a shifted start."""
|
|
127
|
+
m = _TC.match(str((tc or {}).get("value") or "").strip())
|
|
128
|
+
if not m:
|
|
129
|
+
return 0
|
|
130
|
+
h, mi, se, fr = (int(g) for g in m.groups())
|
|
131
|
+
nominal = int(round(float(rate)))
|
|
132
|
+
if mi > 59 or se > 59 or fr >= nominal:
|
|
133
|
+
return 0
|
|
134
|
+
frames = ((h * 60 + mi) * 60 + se) * nominal + fr
|
|
135
|
+
if _drop_frame(tc.get("drop_frame"), rate):
|
|
136
|
+
if se == 0 and mi % 10 and fr < nominal // 15:
|
|
137
|
+
return 0
|
|
138
|
+
minutes = h * 60 + mi
|
|
139
|
+
frames -= (nominal // 15) * (minutes - minutes // 10)
|
|
140
|
+
return frames
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _tc_label(frames: int, rate: Fraction, drop: bool) -> str:
|
|
144
|
+
"""`frames` as hh:mm:ss:ff at the nominal integer rate; drop-frame (`;`) for an NTSC source
|
|
145
|
+
whose own timecode is drop-frame, so the label is the one the media itself shows."""
|
|
146
|
+
nominal = int(round(float(rate)))
|
|
147
|
+
if not _drop_frame(drop, rate):
|
|
148
|
+
return _tc(frames, nominal)
|
|
149
|
+
d = nominal // 15
|
|
150
|
+
per_10, per_min = nominal * 600 - d * 9, nominal * 60 - d
|
|
151
|
+
tens, rem = divmod(frames, per_10)
|
|
152
|
+
frames += d * 9 * tens + (d * ((rem - d) // per_min) if rem > d else 0)
|
|
153
|
+
return _tc(frames, nominal)[:-3] + ";" + _tc(frames, nominal)[-2:]
|
|
154
|
+
|
|
155
|
+
|
|
113
156
|
def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
|
|
114
157
|
"""The editor-neutral timeline: every time in frames at `rate`.
|
|
115
158
|
|
|
@@ -172,6 +215,14 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
|
|
|
172
215
|
if start < 0:
|
|
173
216
|
raise TimelineError(f"clips[{i}].in must not be negative, got {c['in']!r}")
|
|
174
217
|
end = _seconds(c["out"], f"clips[{i}].out", clip_fps) if c.get("out") is not None else dur
|
|
218
|
+
# as cut.py reads them (#414): an in at or past the media's end is refused, an out past it
|
|
219
|
+
# is the media's end -- the render clamps it there, so the timeline does too, and says so
|
|
220
|
+
if dur > 0 and start >= dur:
|
|
221
|
+
raise TimelineError(f"clips[{i}].in ({start:g}s) is at or past the end of {os.path.basename(src)} ({dur:.3f}s)")
|
|
222
|
+
if dur > 0 and end > dur + 1e-6:
|
|
223
|
+
notes.append(f"clips[{i}].out ({end:g}s) is past the end of {os.path.basename(src)} ({dur:.3f}s): "
|
|
224
|
+
"the clip ends at the media's end, as the render cuts it")
|
|
225
|
+
end = dur
|
|
175
226
|
if end <= start:
|
|
176
227
|
raise TimelineError(f"clip {i}: out ({end:g}) is not after in ({start:g})")
|
|
177
228
|
speed = _number(c.get("speed") or 1.0, f"clips[{i}].speed")
|
|
@@ -182,8 +233,17 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
|
|
|
182
233
|
if reframe:
|
|
183
234
|
reframed.append(os.path.basename(src))
|
|
184
235
|
src_in, src_out = fr(start), fr(end)
|
|
236
|
+
# the source's own clock (#414): its frame rate, and its start timecode as a frame count at
|
|
237
|
+
# that rate. EDL source timecodes are counted at it (a 25 fps clip in a 29.97 sequence has
|
|
238
|
+
# no frame :29); FCPXML and OTIO start the media at it, so a conform finds the frames.
|
|
239
|
+
clip_rate = exact_rate(float(clip_fps)) if clip_fps else rate
|
|
240
|
+
tc = meta.get("timecode") or {}
|
|
241
|
+
tc_start = start_tc_frames(tc, clip_rate)
|
|
185
242
|
clips.append({"index": i, "src": os.path.abspath(src), "name": os.path.basename(src),
|
|
186
243
|
"src_in": src_in, "src_out": src_out, "speed": speed,
|
|
244
|
+
"in_seconds": Fraction(start).limit_denominator(1000000), "rate": clip_rate,
|
|
245
|
+
"tc_start": tc_start, "tc_seconds": Fraction(tc_start) / clip_rate,
|
|
246
|
+
"drop_frame": _drop_frame(tc.get("drop_frame"), clip_rate),
|
|
187
247
|
"length": int(round((src_out - src_in) / speed)),
|
|
188
248
|
"media_frames": fr(dur), "has_video": bool(meta.get("video")),
|
|
189
249
|
"has_audio": bool(meta.get("audio")),
|
|
@@ -219,12 +279,24 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
|
|
|
219
279
|
markers: List[Dict[str, Any]] = []
|
|
220
280
|
chapters = proj.get("chapters")
|
|
221
281
|
if isinstance(chapters, list):
|
|
282
|
+
total_seconds = Fraction(total) / rate
|
|
283
|
+
previous: Optional[float] = None
|
|
222
284
|
for j, ch in enumerate(chapters):
|
|
223
285
|
# read as render.py's chapters stage reads it (metadata.py): a chapter list carries
|
|
224
|
-
# no fps, so hh:mm:ss:ff needs its @fps there and here alike
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
286
|
+
# no fps, so hh:mm:ss:ff needs its @fps there and here alike. What metadata.py refuses
|
|
287
|
+
# is refused here too, never dropped (#414): a chapter at or past the end, or one that
|
|
288
|
+
# does not come after the one before it.
|
|
289
|
+
at_s = _seconds(ch.get("at", 0), f"chapters[{j}].at", None)
|
|
290
|
+
if at_s < 0:
|
|
291
|
+
raise TimelineError(f"chapters[{j}].at must not be negative, got {ch.get('at')!r}")
|
|
292
|
+
if previous is not None and at_s <= previous:
|
|
293
|
+
raise TimelineError(f"chapters[{j}]: the chapter at {at_s:g}s does not come after the previous one "
|
|
294
|
+
f"at {previous:g}s (metadata.py refuses it too)")
|
|
295
|
+
if at_s >= total_seconds:
|
|
296
|
+
raise TimelineError(f"chapters[{j}]: the chapter at {at_s:g}s starts at or after the end of the "
|
|
297
|
+
f"timeline ({float(total_seconds):.3f}s); metadata.py refuses it too")
|
|
298
|
+
previous = at_s
|
|
299
|
+
markers.append({"at": min(fr(at_s), total - 1), "title": str(ch.get("title") or "")})
|
|
228
300
|
elif chapters:
|
|
229
301
|
notes.append("chapters given as a file are not exported as markers; write them inline as [{at, title}]")
|
|
230
302
|
|
|
@@ -283,27 +355,38 @@ def to_edl(tl: Dict[str, Any]) -> str:
|
|
|
283
355
|
ends = starts[1:] + [tl["total"]]
|
|
284
356
|
lines = [f"TITLE: {tl['name']}", "FCM: NON-DROP FRAME", ""]
|
|
285
357
|
n = 0
|
|
286
|
-
prev_src_end =
|
|
358
|
+
prev_src_end = ""
|
|
359
|
+
rate = tl["rate"]
|
|
360
|
+
|
|
361
|
+
def src_tc(c: Dict[str, Any], seq_frames: int) -> str:
|
|
362
|
+
"""The source timecode `seq_frames` (sequence frames of source media) after the clip's in
|
|
363
|
+
point: counted at the clip's own rate from its own start timecode (#414)."""
|
|
364
|
+
own = c.get("rate") or rate
|
|
365
|
+
seconds = c.get("in_seconds", Fraction(c["src_in"]) / rate) + Fraction(seq_frames) / rate
|
|
366
|
+
return _tc_label(int(round(seconds * own)) + c.get("tc_start", 0), own, c.get("drop_frame", False))
|
|
367
|
+
|
|
287
368
|
for i, c in enumerate(clips):
|
|
288
369
|
n += 1
|
|
289
370
|
chan = "B" if c["has_audio"] and c["has_video"] else ("V" if c["has_video"] else "A")
|
|
290
371
|
rec_in, rec_out = hour + starts[i], hour + ends[i]
|
|
291
|
-
s_in = c
|
|
292
|
-
s_out =
|
|
372
|
+
s_in = src_tc(c, 0)
|
|
373
|
+
s_out = src_tc(c, int(round((ends[i] - starts[i]) * c["speed"])))
|
|
293
374
|
if i > 0 and d:
|
|
294
|
-
lines.append(f"{n:03d} AX {chan:<5} C {
|
|
375
|
+
lines.append(f"{n:03d} AX {chan:<5} C {prev_src_end} {prev_src_end} "
|
|
295
376
|
f"{_tc(rec_in, fps_int)} {_tc(rec_in, fps_int)}")
|
|
296
|
-
lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {
|
|
377
|
+
lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {s_in} {s_out} "
|
|
297
378
|
f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
|
|
298
379
|
lines.append(f"* FROM CLIP NAME: {clips[i - 1]['name']}")
|
|
299
380
|
lines.append(f"* TO CLIP NAME: {c['name']}")
|
|
300
381
|
else:
|
|
301
|
-
lines.append(f"{n:03d} AX {chan:<5} C {
|
|
382
|
+
lines.append(f"{n:03d} AX {chan:<5} C {s_in} {s_out} "
|
|
302
383
|
f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
|
|
303
384
|
lines.append(f"* FROM CLIP NAME: {c['name']}")
|
|
304
385
|
lines.append(f"* SOURCE FILE: {c['src']}")
|
|
305
386
|
if abs(c["speed"] - 1.0) > 1e-6:
|
|
306
|
-
|
|
387
|
+
# the speed in source frames per second, at the rate the source labels are counted in
|
|
388
|
+
own_fps = int(round(float(c.get("rate") or rate)))
|
|
389
|
+
lines.append(f"M2 AX {own_fps * c['speed']:05.1f} {s_in}")
|
|
307
390
|
lines.append("")
|
|
308
391
|
prev_src_end = s_out
|
|
309
392
|
if tl["music"]:
|
|
@@ -326,6 +409,11 @@ def _t(frames: int, rate: Fraction) -> str:
|
|
|
326
409
|
return f"{value.numerator}s" if value.denominator == 1 else f"{value.numerator}/{value.denominator}s"
|
|
327
410
|
|
|
328
411
|
|
|
412
|
+
def _ts(value: Fraction) -> str:
|
|
413
|
+
"""A rational FCPXML time from seconds."""
|
|
414
|
+
return f"{value.numerator}s" if value.denominator == 1 else f"{value.numerator}/{value.denominator}s"
|
|
415
|
+
|
|
416
|
+
|
|
329
417
|
def _url(path: str) -> str:
|
|
330
418
|
# pathlib builds the percent-encoded file:// URL itself (and the drive-letter form on
|
|
331
419
|
# Windows): no network-library import in scripts/, not even for quoting (see the no-fetch test)
|
|
@@ -361,12 +449,14 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
361
449
|
return seq_fmt
|
|
362
450
|
return fmt(c["picture"][0], c["picture"][1], _t(1, exact_rate(float(c["fps"]))) if c["fps"] else fd)
|
|
363
451
|
|
|
364
|
-
def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool, format_id: str = ""
|
|
452
|
+
def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool, format_id: str = "",
|
|
453
|
+
start: Fraction = Fraction(0)) -> str:
|
|
365
454
|
if path in ids:
|
|
366
455
|
return ids[path]
|
|
367
456
|
rid = f"r{len(ids) + 1}"
|
|
368
457
|
ids[path] = rid
|
|
369
|
-
|
|
458
|
+
# the media starts at its own start timecode (#414): a clip's start is counted from there
|
|
459
|
+
res.append(f' <asset id="{rid}" name="{q(name)}" start="{_ts(start)}" duration="{_t(frames, rate)}" '
|
|
370
460
|
f'hasVideo="{int(has_video)}" hasAudio="{int(has_audio)}"'
|
|
371
461
|
+ (f' format="{format_id}"' if has_video else "") + ' audioSources="1" audioChannels="2">\n'
|
|
372
462
|
f' <media-rep kind="original-media" src="{q(_url(path))}"/>\n </asset>')
|
|
@@ -377,7 +467,8 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
377
467
|
markers = list(tl["markers"])
|
|
378
468
|
for i, c in enumerate(tl["clips"]):
|
|
379
469
|
cid = clip_fmt(c)
|
|
380
|
-
|
|
470
|
+
base = c.get("tc_seconds", Fraction(0))
|
|
471
|
+
rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"], cid, base)
|
|
381
472
|
if i > 0 and d:
|
|
382
473
|
spine.append(f' <transition name="Cross Dissolve" offset="{_t(c["record_in"] - c["trim_head"], rate)}" '
|
|
383
474
|
f'duration="{_t(d, rate)}"/>')
|
|
@@ -386,14 +477,15 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
386
477
|
if abs(c["speed"] - 1.0) > 1e-6:
|
|
387
478
|
# timeMap maps clip-local time to source time; two linear points give a constant speed
|
|
388
479
|
inner.append(" <timeMap>")
|
|
389
|
-
inner.append(f' <timept time="0s" value="{
|
|
480
|
+
inner.append(f' <timept time="0s" value="{_ts(base + Fraction(src_start) / rate)}" interp="linear"/>')
|
|
390
481
|
inner.append(f' <timept time="{_t(c["visible"], rate)}" '
|
|
391
|
-
f'value="{
|
|
482
|
+
f'value="{_ts(base + Fraction(src_start + int(round(c["visible"] * c["speed"]))) / rate)}" interp="linear"/>')
|
|
392
483
|
inner.append(" </timeMap>")
|
|
393
484
|
start_attr = "0s"
|
|
394
485
|
else:
|
|
395
|
-
start_attr =
|
|
396
|
-
|
|
486
|
+
start_attr = _ts(base + Fraction(src_start) / rate)
|
|
487
|
+
# the clip's local time: the source's (from its start timecode), or 0 under a timeMap
|
|
488
|
+
local0 = base + Fraction(src_start) / rate if abs(c["speed"] - 1.0) <= 1e-6 else Fraction(0)
|
|
397
489
|
# the FCPXML 1.10 DTD's order inside an asset-clip: timeMap, then the adjust-* elements
|
|
398
490
|
# (adjust-conform), then anchored items (the music bed), then markers. Anything out of
|
|
399
491
|
# that order does not validate, and Final Cut validates what it imports against that DTD.
|
|
@@ -404,13 +496,14 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
404
496
|
if i == 0 and tl["music"]:
|
|
405
497
|
m = tl["music"]
|
|
406
498
|
mid = asset(m["src"], m["name"], m["media_frames"], False, True)
|
|
407
|
-
inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{
|
|
499
|
+
inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{_ts(local0)}" name="{q(m["name"])}" '
|
|
408
500
|
f'start="0s" duration="{_t(m["length"], rate)}" audioRole="music"/>')
|
|
409
501
|
for mk in [m for m in markers if c["record_in"] <= m["at"] < c["record_in"] + c["visible"]]:
|
|
410
|
-
inner.append(f' <chapter-marker start="{
|
|
502
|
+
inner.append(f' <chapter-marker start="{_ts(local0 + Fraction(mk["at"] - c["record_in"]) / rate)}" '
|
|
411
503
|
f'duration="{fd}" value="{q(mk["title"])}"/>')
|
|
412
504
|
head = (f' <asset-clip ref="{rid}" offset="{_t(c["record_in"], rate)}" name="{q(c["name"])}" '
|
|
413
|
-
f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="{cid}"
|
|
505
|
+
f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="{cid}" '
|
|
506
|
+
f'tcFormat="{"DF" if c.get("drop_frame") else "NDF"}"')
|
|
414
507
|
spine.append(head + (">\n" + "\n".join(inner) + "\n </asset-clip>" if inner else "/>"))
|
|
415
508
|
out = ['<?xml version="1.0" encoding="UTF-8"?>', "<!DOCTYPE fcpxml>", '<fcpxml version="1.10">',
|
|
416
509
|
" <resources>", *res, " </resources>", " <library>", f' <event name="{q(tl["name"])}">',
|
|
@@ -444,15 +537,17 @@ def to_otio(tl: Dict[str, Any]) -> str:
|
|
|
444
537
|
# OTIO: in_offset is the part before the cut, out_offset the part after
|
|
445
538
|
"in_offset": _rt(c["trim_head"], rate), "out_offset": _rt(tl["clips"][i - 1]["trim_tail"], rate)})
|
|
446
539
|
src_start = c["src_in"] + int(round(c["trim_head"] * c["speed"]))
|
|
540
|
+
# the media's range starts at its start timecode (#414), in timeline frames
|
|
541
|
+
base = c.get("tc_seconds", Fraction(0)) * rate
|
|
447
542
|
clip = {"OTIO_SCHEMA": "Clip.2", "name": c["name"], "metadata": {}, "enabled": True,
|
|
448
543
|
# OTIO's own convention: source_range starts at the media's in point and lasts the
|
|
449
544
|
# clip's length on the timeline; a LinearTimeWarp says how fast the media runs
|
|
450
545
|
# through it (core OTIO does not rescale durations by effects).
|
|
451
|
-
"source_range": _range(src_start, c["visible"], rate),
|
|
546
|
+
"source_range": _range(base + src_start, c["visible"], rate),
|
|
452
547
|
"effects": [], "markers": [],
|
|
453
548
|
"media_references": {"DEFAULT_MEDIA": {
|
|
454
549
|
"OTIO_SCHEMA": "ExternalReference.1", "name": c["name"], "metadata": {},
|
|
455
|
-
"target_url": _url(c["src"]), "available_range": _range(
|
|
550
|
+
"target_url": _url(c["src"]), "available_range": _range(base, c["media_frames"], rate),
|
|
456
551
|
"available_image_bounds": None}},
|
|
457
552
|
"active_media_reference_key": "DEFAULT_MEDIA"}
|
|
458
553
|
if abs(c["speed"] - 1.0) > 1e-6:
|
package/scripts/_contract.py
CHANGED
|
@@ -219,7 +219,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
219
219
|
"render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)", "editor timeline (.fcpxml / .edl / .otio) instead of a render (--export-timeline)"],
|
|
220
220
|
required=FF, optional=[{"capability": "delegated", "when": "each stage (and each destination of --template) runs cut / join / fit / caption / graphics / overlay / audio / loudness / export / check with their capabilities"}],
|
|
221
221
|
video_required=False, audio_only=True, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
222
|
-
"batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
|
|
222
|
+
"batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir (none for a steps recipe, not a render project, in which no step names {out}: its rows carry written: false)", "content-hash cache"],
|
|
223
223
|
required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
|
|
224
224
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="cached", deterministic=True),
|
|
225
225
|
"verify": dict(role="verification", inputs=["media files and/or folders"], outputs=["PASS/FAIL JSON per step", "Markdown report (--report)", "step outputs (--out / --keep)"],
|
|
@@ -440,6 +440,9 @@ ERROR_REASONS = {
|
|
|
440
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
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: {}",
|
|
442
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}",
|
|
443
|
+
"no_speech": "kind input: --transcribe (caption.py, silence.py --filler) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
|
|
444
|
+
"engine_failed": "kind input: --transcribe: an installed speech engine ran and failed (a named --engine, or every engine --engine auto found); details: {engine, detail | engines: [{engine, reason, detail}]}; also top-level reason, engine and detail/engines",
|
|
445
|
+
"english_only": "kind input: --transcribe under --engine auto found only Parakeet engines, which it runs for English speech only, and the language is not English; details: {engine, engines}; also top-level reason, engine and engines",
|
|
443
446
|
}
|
|
444
447
|
|
|
445
448
|
# The codes of the collector's records (docs/design-decisions.md, "Nothing happens silently"):
|
|
@@ -547,13 +550,23 @@ FONT_USED_SCHEMA = {
|
|
|
547
550
|
}
|
|
548
551
|
|
|
549
552
|
|
|
553
|
+
# the tools whose --json carries `dropped_non_av_streams` (a test reads the sources)
|
|
554
|
+
NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "cut", "deinterlace", "denoise", "export", "fit", "freeze",
|
|
555
|
+
"graphics", "join", "loudness", "overlay", "pad", "proxy", "redact", "speedramp",
|
|
556
|
+
"sphere", "straighten"})
|
|
557
|
+
|
|
558
|
+
|
|
550
559
|
def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
|
|
551
560
|
"""What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
|
|
552
561
|
tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
|
|
553
562
|
if name == "probe":
|
|
554
563
|
return {"type": "object", "description": "one probe document, or an array of them for several inputs",
|
|
555
564
|
"properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
|
|
556
|
-
"video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]}
|
|
565
|
+
"video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
|
|
566
|
+
"timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
|
|
567
|
+
"chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
|
|
568
|
+
"subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"}},
|
|
569
|
+
"additionalProperties": True}
|
|
557
570
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
558
571
|
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
|
|
559
572
|
"probe": {"type": "object", "description": "probe of the output when a file was written"},
|
|
@@ -594,6 +607,13 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
594
607
|
"speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"}}
|
|
595
608
|
elif name == "silence":
|
|
596
609
|
extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
|
|
610
|
+
"removed_seconds_total": {"type": "number", "description": "--filler: seconds removed in all, silence plus filler words (removed_seconds stays the silence alone)"},
|
|
611
|
+
"cut_points": {"type": ["array", "null"], "description": "2.22: per cut {source_time, output_time, edge (out|in), rms_dbfs (the loudest channel 50 ms either side on the source; null for digital silence), filler (true on a removed filler word's edge)}; null when the source could not be decoded for it (cut_levels_unmeasured warning)"},
|
|
612
|
+
"cut_warnings": {"type": ["array", "null"], "description": "the cut_points (not filler cuts) within 10 dB of --threshold: where to listen (a cut_near_sound warning); verified is unchanged"},
|
|
613
|
+
"timemap": {"type": "string", "description": "--timemap FILE: the source-to-output time map written (JSON or CSV)"},
|
|
614
|
+
"silence_kinds": {"type": "array", "description": "2.20: per detected silence, digital (at or below -90 dBFS) or below_threshold"},
|
|
615
|
+
"silence_breakdown": {"type": "object", "description": "2.20: {digital, below_threshold}: the count and seconds of each, with the level that defines it"},
|
|
616
|
+
"hint": {"type": "string", "description": "when nothing sat below --threshold: the track's measured level and the --threshold that would find its pauses"},
|
|
597
617
|
"speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"},
|
|
598
618
|
"filler": {"type": "object", "description": "--filler: {lang, source (whisper-json:FILE | whisper:ENGINE | parakeet:ENGINE), engine, transcription (with --transcribe: {engine, model, language, routing}), removed, removed_count, matched_untimed (listed words in the transcript with no usable timing, not removed), matched_too_long (listed words longer than a filler, kept), ...}"},
|
|
599
619
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "--filler --transcribe under --engine auto: present when the English-only Parakeet model ran on English assumed (no --filler-lang, no language detected, no Whisper engine installed or none that succeeded); the transcript is wrong if the speech is not English"}}
|
|
@@ -651,6 +671,15 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
651
671
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
|
|
652
672
|
elif name == "cut":
|
|
653
673
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
674
|
+
"mode": {"enum": ["copy", "hybrid", "accurate"], "description": "copy: nothing re-encoded at any stage, the join included; accurate: --accurate was asked for or forced; hybrid: asked for lossless but a segment or the join re-encoded anyway (reencode_reason says why)"},
|
|
675
|
+
"requested_start": {"type": ["number", "null"], "description": "one segment: the requested start, clamped to the media's duration (null for --segments)"},
|
|
676
|
+
"requested_end": {"type": ["number", "null"], "description": "one segment: the requested end, clamped to the media's duration (null for --segments)"},
|
|
677
|
+
"requested_segments": {"type": ["array", "null"], "description": "--segments: [[start, end], ...] requested, clamped to the media's duration (null for one segment)"},
|
|
678
|
+
"requested_duration": {"type": "number", "description": "the requested length, as expected_duration"},
|
|
679
|
+
"output_duration": {"type": ["number", "null"], "description": "the written file's length, measured (null under --dry-run)"},
|
|
680
|
+
"duration_delta_seconds": {"type": ["number", "null"], "description": "output_duration minus requested_duration (null under --dry-run)"},
|
|
681
|
+
"nearest_keyframes": {"type": ["array", "null"], "description": "the keyframes within 5 s of a start whose stream copy snapped past --tolerance and was re-encoded (null otherwise)"},
|
|
682
|
+
"snap": {"type": ["object", "null"], "description": "--snap beats: {mode, tolerance, confidence, tempo_bpm, grid, grid_points, moved, snapped, unchanged, source}; null otherwise"},
|
|
654
683
|
"dropped_audio_streams": {"type": "integer", "description": "audio tracks of the source this cut did not write (cut writes one, on a stream copy as on a re-encode); 0 for a source with one or none. A notes line and an audio_tracks_dropped warning say so too"},
|
|
655
684
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
656
685
|
"precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
@@ -696,6 +725,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
696
725
|
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
|
|
697
726
|
if name in ("overlay", "graphics", "caption", "grid"):
|
|
698
727
|
extra["font_used"] = FONT_USED_SCHEMA
|
|
728
|
+
if name in NON_AV_STREAM_TOOLS and "dropped_non_av_streams" not in extra:
|
|
729
|
+
extra["dropped_non_av_streams"] = {"type": "boolean", "description": "true when the source's subtitle or data streams are not in the output: either the run leaves them out by design (a path whose output they would no longer match, such as speedramp.py's, join.py's or freeze.py's), or they could not be copied and the encode ran again without them (then a non_av_streams_dropped fallback says why); export.py's non_av_streams lists them"}
|
|
699
730
|
if name == "cropdetect":
|
|
700
731
|
extra.update({"windows": {"type": "integer", "description": "sampled windows (--samples)"},
|
|
701
732
|
"windows_failed": {"type": "integer", "description": "windows ffmpeg could not decode and that were skipped (a cropdetect_windows_failed warning when > 0); every window failing is a kind ffmpeg failure"}})
|