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.
@@ -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=PROBE_TIMEOUT).stdout
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=PROBE_TIMEOUT).stdout
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; nothing was written",
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
- refuse("input", "output_locked",
935
- f"another run is writing {name!r} right now (lock {self.path}"
936
- + (f", pid {holder['pid']}" if holder.get("pid") else "") + "); wait for it or choose a different --output/-o path",
937
- hint=("wait for that run, or choose another -o path; if pid "
938
- f"{holder.get('pid')} is not an ffmpeg-skill run, delete {self.path}"),
939
- lock=self.path, output=os.path.abspath(self.output), **{k: v for k, v in holder.items() if k != "stale"})
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
- die(f"{os.path.basename(cmd[0])} exceeded the {seconds:.0f} s time limit and was killed; nothing was written. "
1116
- f"Raise --timeout (or FFMPEG_SKILL_TIMEOUT) if the job is genuinely that long, or check the input for a stall",
1117
- code=124, kind="timeout")
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:.0f} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
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=PROBE_TIMEOUT).stdout
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 fixed
1520
- short one, ffmpeg the configured one; None means unlimited."""
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 = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
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
- at = fr(_seconds(ch.get("at", 0), f"chapters[{j}].at", None))
226
- if 0 <= at < total:
227
- markers.append({"at": at, "title": str(ch.get("title") or "")})
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 = 0
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["src_in"]
292
- s_out = s_in + int(round((ends[i] - starts[i]) * c["speed"]))
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 {_tc(prev_src_end, fps_int)} {_tc(prev_src_end, fps_int)} "
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} {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
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 {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
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
- lines.append(f"M2 AX {fps_int * c['speed']:05.1f} {_tc(s_in, fps_int)}")
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 = "") -> 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
- res.append(f' <asset id="{rid}" name="{q(name)}" start="0s" duration="{_t(frames, rate)}" '
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
- rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"], cid)
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="{_t(src_start, rate)}" interp="linear"/>')
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="{_t(src_start + int(round(c["visible"] * c["speed"])), rate)}" interp="linear"/>')
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 = _t(src_start, rate)
396
- local0 = src_start if abs(c["speed"] - 1.0) <= 1e-6 else 0
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="{_t(local0, rate)}" name="{q(m["name"])}" '
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="{_t(local0 + mk["at"] - c["record_in"], rate)}" '
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}" tcFormat="NDF"')
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(0, c["media_frames"], rate),
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:
@@ -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"]}}, "additionalProperties": True}
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"}})