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/scripts/export.py CHANGED
@@ -38,10 +38,12 @@ import tempfile
38
38
  from pathlib import Path
39
39
  from typing import Dict, List
40
40
 
41
+ from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
41
42
  from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
42
43
  from _common.decision import hw_preset_video, restate_last_swap
43
44
  from _common.emit import note, record_fallback, record_skip, warn
44
45
  from _common.probe import square_pixels, square_pixels_note
46
+ from _common.runner import subtitle_codec_for
45
47
  from check import SPECS as PLATFORMS, measure_loudness
46
48
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
47
49
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
@@ -142,19 +144,10 @@ def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: s
142
144
 
143
145
  # Text subtitle codecs ffmpeg can convert to the container's own text track: mov_text is the one
144
146
  # text track MP4/MOV hold, Matroska holds SubRip/ASS/WebVTT as they are but not mov_text.
145
- _TEXT_SUBTITLES = frozenset({"mov_text", "subrip", "srt", "ass", "ssa", "webvtt", "text"})
146
- _MP4_FAMILY = frozenset({"mp4", "m4v", "mov"})
147
-
148
-
149
- def _subtitle_codec(codec: str, out_ext: str) -> str:
150
- """The -c:s for one source subtitle stream: copy when the output container holds it as it is,
151
- else the container's own text format (a bitmap track is tried as a copy; a container that
152
- refuses it is the fallback below)."""
153
- if out_ext in _MP4_FAMILY and codec in _TEXT_SUBTITLES and codec != "mov_text":
154
- return "mov_text"
155
- if out_ext == "mkv" and codec == "mov_text":
156
- return "srt"
157
- return "copy"
147
+ # the -c:s for one source subtitle stream (#380), shared with caption.py --mode mux (#414): copy
148
+ # when the output container holds it as it is, else the container's own text format (a bitmap
149
+ # track is tried as a copy; a container that refuses it is the fallback below)
150
+ _subtitle_codec = subtitle_codec_for
158
151
 
159
152
 
160
153
  def _data_streams(path: str) -> List[Dict[str, object]]:
@@ -191,6 +184,9 @@ def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data
191
184
  attempt = run(cmd + extra + [output], check=False)
192
185
  if attempt.returncode == 0:
193
186
  return dropped
187
+ # a full disk or a missing encoder fails every rung the same way: that error, not a
188
+ # fallback that blames the subtitles and encodes again (#415, #389's rule)
189
+ fail_if_classified(attempt)
194
190
  error = " | ".join([ln.strip() for ln in (attempt.stderr or "").splitlines() if ln.strip()][-3:])[-500:]
195
191
  going = rungs[n + 1][1]
196
192
  record_fallback(f"{os.path.basename(output)} could not hold the source's {' and '.join(t for t in ('subtitle', 'data') if t in going)} "
@@ -355,7 +351,9 @@ def main() -> int:
355
351
  notes.append("source is %s (%s). This preset outputs SDR BT.709 tags without converting it; run color.py --to-sdr first for correct colours." % (kind, meta["video"].get("hdr_format")))
356
352
  warn(notes[-1], "colour_not_converted", preset=args.preset, hdr_format=meta["video"].get("hdr_format"))
357
353
  has_audio = bool(meta.get("audio"))
358
- output = args.output or default_output(args.input, args.preset, p["ext"])
354
+ # copy keeps the source's container, so its default name keeps the source's extension (as
355
+ # metadata.py's does): default_output()'s .webm -> .mp4 is for the re-encoding presets
356
+ output = args.output or default_output(args.input, args.preset, p["ext"] or Path(args.input).suffix.lstrip(".") or None)
359
357
  out_ext = Path(output).suffix.lstrip(".").lower()
360
358
 
361
359
  vf: List[str] = []
@@ -474,6 +472,10 @@ def main() -> int:
474
472
  dropped = _run_keeping_non_av(cmd, output, sub_codecs, len(data)) if (subs or data) else []
475
473
  if not (subs or data):
476
474
  run(cmd + [output])
475
+ if not STATE.dry_run:
476
+ # the export is on disk: a stop in the reads and the --normalize pass that follow keeps it,
477
+ # and says so (#415)
478
+ mark_delivered(output)
477
479
  result = probe(output, role="output")
478
480
  v = result["video"]
479
481
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
@@ -516,7 +518,12 @@ def main() -> int:
516
518
  err = child.get("error") or {}
517
519
  if os.path.exists(tmp):
518
520
  os.remove(tmp)
519
- die(f"--normalize: loudness.py failed: {err.get('message') or proc.stderr.strip()[-300:]}",
521
+ # the child's "nothing was written" is about its own temp file; this run's export is
522
+ # on disk, and the child's exit code (124 timeout, 130/143 interrupted) is the
523
+ # run's, as render.py and waveform.py pass a stage's on (#415)
524
+ why = (err.get("message") or proc.stderr.strip()[-300:]).replace("; nothing was written", "")
525
+ die(f"--normalize: loudness.py failed: {why} ({kept_clause()})",
526
+ code=child_exit_code(child, proc.returncode) if proc.returncode else 1,
520
527
  kind=err.get("kind") or "ffmpeg", output=output, hint=err.get("hint"))
521
528
  os.replace(tmp, output)
522
529
  m = measure_loudness(output)
@@ -221,7 +221,8 @@ def main() -> int:
221
221
  die(f"chapters file not found: {args.chapters}")
222
222
 
223
223
  meta = probe(args.input)
224
- output = args.output or default_output(args.input, "meta")
224
+ # streams are copied, so the source's own container always holds them (a .webm stays .webm)
225
+ output = args.output or default_output(args.input, "meta", Path(args.input).suffix.lstrip(".") or "mp4")
225
226
  if os.path.abspath(output) == os.path.abspath(args.input):
226
227
  die("output must differ from the input (metadata.py never rewrites a file in place)")
227
228
  out_ext = Path(output).suffix.lower()
@@ -249,7 +250,13 @@ def main() -> int:
249
250
  cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "0"]
250
251
  for key, value in tags.items():
251
252
  cmd += ["-metadata", f"{key}={value}"]
252
- cmd += ["-c", "copy", output]
253
+ cmd += ["-c", "copy"]
254
+ if out_ext in (".mp4", ".mov", ".m4v", ".m4a"):
255
+ # A stream copy into the MPEG-4 family writes the index (moov) after the media unless told
256
+ # otherwise, so without +faststart a faststart input -- render's export -- came out with
257
+ # its index at the end (#415; every other MP4 write here is faststart since #279)
258
+ cmd += ["-movflags", "+faststart"]
259
+ cmd.append(output)
253
260
  run(cmd)
254
261
  if tmpdir:
255
262
  tmpdir.cleanup()
package/scripts/render.py CHANGED
@@ -75,7 +75,7 @@ from typing import Any, Dict, List, Optional, Sequence
75
75
  from export import PRESETS, PLATFORM_OF
76
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
77
77
  from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
78
- from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, found_exe
78
+ from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, remember_written
79
79
  from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
80
80
  import subprocess
81
81
  from _contract import CONTRACT_VERSION
@@ -351,6 +351,7 @@ def render_pack(names: List[str], args) -> int:
351
351
  rows: List[Dict[str, Any]] = []
352
352
  outputs: List[str] = []
353
353
  failed: List[str] = []
354
+ stopped: List[Dict[str, Any]] = [] # destinations a timeout stopped: {kind, exit_code}
354
355
  for name in names:
355
356
  # the destination decides the container: an audio-only destination in a pack must not be
356
357
  # handed a .mp4 name the single-template form would never have written (review 12)
@@ -394,6 +395,9 @@ def render_pack(names: List[str], args) -> int:
394
395
  outputs.append(out)
395
396
  if not ok:
396
397
  failed.append(name)
398
+ err = doc.get("error") or {}
399
+ if err.get("kind") in ("timeout", "interrupted"):
400
+ stopped.append({"kind": err["kind"], "exit_code": child_exit_code(doc, proc.returncode)})
397
401
  pack = str(outdir / f"{stem}_pack.md")
398
402
  lines = [f"# Social pack — {stem}", "",
399
403
  "| platform | file | size | duration | check |", "|---|---|---|---|---|"]
@@ -410,8 +414,11 @@ def render_pack(names: List[str], args) -> int:
410
414
  Path(pack).write_text("\n".join(lines) + "\n", encoding="utf-8")
411
415
  info(("[dry-run] would write " if STATE.dry_run else "wrote ") + pack)
412
416
  if failed:
417
+ # a destination that was stopped makes the pack a stopped run: its kind and exit code (124,
418
+ # 130/143), as a stage's are render.py's (docs/contract.md); otherwise a verification failure
413
419
  die(f"pack: {len(failed)} of {len(rows)} destinations failed: {', '.join(failed)}",
414
- kind="verification", output=pack, dry_run=STATE.dry_run, pack=rows, outputs=outputs)
420
+ code=stopped[0]["exit_code"] if stopped else 1, kind=stopped[0]["kind"] if stopped else "verification",
421
+ output=pack, dry_run=STATE.dry_run, pack=rows, outputs=outputs)
415
422
  emit(pack, pack=rows, outputs=outputs, stages=["pack"],
416
423
  verification=[{"step": "check", "ok": r["ok"], "platform": r["platform"]} for r in rows])
417
424
  return 0
@@ -577,6 +584,8 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
577
584
  # child's document standing, which a caller reading _LAST_DOC would misattribute.
578
585
  _LAST_DOC.clear()
579
586
  _LAST_DOC["cached"] = True
587
+ if not STATE.dry_run:
588
+ remember_written(dest) # placed by this run, as a stage's own write would be (#415)
580
589
  return dest
581
590
  CACHE["misses"].append(stage)
582
591
  _refuse_uncached_earlier_stage(stage)
@@ -603,12 +612,27 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
603
612
  extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
604
613
  error_extra = absorb_stage(Path(script).stem, doc) # the stage's reason/details and records ride along
605
614
  extra_fields.update(_stage_failure_hw(doc))
606
- die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
615
+ why = err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]
616
+ if kept_deliverables() and err.get("kind") in ("timeout", "interrupted"):
617
+ # a stage after the delivery (chapters, check): the stage's own "nothing was written"
618
+ # is about its own file, while the delivery is on disk (#415)
619
+ why = why.replace("; nothing was written", "") + f" ({kept_clause()})"
620
+ extra_fields.setdefault("output", kept_deliverables()[-1])
621
+ elif err.get("kind") in ("timeout", "interrupted") and doc.get("output") and os.path.isfile(str(doc["output"])):
622
+ # the stage was stopped after writing its own deliverable and kept it (export.py's
623
+ # --normalize pass): its message says so, and the file is this run's output too
624
+ extra_fields.setdefault("output", str(doc["output"]))
625
+ die(f"{script} failed: {why}",
607
626
  code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, error_extra=error_extra, **extra_fields)
608
627
  _LAST_DOC.clear()
609
628
  _LAST_DOC.update(doc if isinstance(doc, dict) else {})
610
629
  absorb_stage(Path(script).stem, doc) # a stage's GPU facts (ran on VideoToolbox, fell back and why) and its diagnostics reach this result
611
630
  out_path = str(doc.get("output") or "")
631
+ if not STATE.dry_run:
632
+ # A file a stage of this run wrote is this run's, not someone else's: the chapters stage
633
+ # places its tagged copy over the export's delivery, which the output guard would
634
+ # otherwise refuse as an existing file without --overwrite (#415).
635
+ remember_written(out_path, dest)
612
636
  if key:
613
637
  cache_store(stage, key, out_path or (dest or ""), time.time() - started, doc)
614
638
  return out_path
@@ -951,8 +975,10 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
951
975
  if proc.returncode != 0 or doc.get("status") != "completed":
952
976
  err = doc.get("error") or {}
953
977
  error_extra = absorb_stage(tool, doc) # the tool's reason/details and records ride along
978
+ # the tool's exit code is the plan's (124 timeout, 130/143 interrupted), as sh() passes a stage's on
954
979
  die(f"{tool} failed while executing the plan: {err.get('message') or proc.stderr.strip()[-300:]}",
955
- kind=err.get("kind", "ffmpeg"), plan=path, tool=tool, error_extra=error_extra, **_stage_failure_hw(doc))
980
+ code=child_exit_code(doc, proc.returncode), kind=err.get("kind", "ffmpeg"), plan=path, tool=tool,
981
+ error_extra=error_extra, **_stage_failure_hw(doc))
956
982
  absorb_stage(tool, doc) # the tool's GPU facts (fell back and why, ran on VideoToolbox) and diagnostics reach this result
957
983
  output = doc.get("output") or plan.get("output")
958
984
  check_result = None
@@ -1611,6 +1637,8 @@ def main() -> int:
1611
1637
  place_output(current, output)
1612
1638
  info(("[dry-run] would copy" if STATE.dry_run else "copied") + f" final stage to {output}")
1613
1639
  current = output
1640
+ if not STATE.dry_run:
1641
+ mark_delivered(output) # the chapters and check stages run on it: a stop from here keeps it, and says so (#415)
1614
1642
 
1615
1643
  # ---- chapters (metadata.py on the delivered file: streams copied, markers written)
1616
1644
  ch = proj.get("chapters")
@@ -1648,6 +1676,13 @@ def main() -> int:
1648
1676
  exit_code = 1
1649
1677
  elif check_result.get("error") or check_result.get("status") == "failed":
1650
1678
  err = check_result.get("error")
1679
+ if isinstance(err, dict) and err.get("kind") in ("timeout", "interrupted"):
1680
+ # stopped, not failed: the run exits with check.py's own code (124, 130/143), as a
1681
+ # stage that stopped does, and says the delivery it had written is kept (#415)
1682
+ why = str(err.get("message") or "").replace("; nothing was written", "")
1683
+ die(f"check.py failed: {why} ({kept_clause()})",
1684
+ code=int(check_result.get("exit_code") or err.get("exit_code") or 1), kind=err["kind"],
1685
+ stage="check.py", output=output, stages=stages_done)
1651
1686
  info(f"check: could not run check.py — {err.get('message') if isinstance(err, dict) else err}")
1652
1687
  exit_code = 1
1653
1688
  else:
@@ -83,10 +83,29 @@ def speech_aware_silences(path: str, threshold: float, min_silence: float) -> "T
83
83
  return boundaries, breaths
84
84
 
85
85
 
86
+ def inside_margin(silences: List[Tuple[float, float]], duration: float, margin: float) -> List[Tuple[float, float]]:
87
+ """Each silence with --margin kept on both sides: the stretch actually removed. A silence too
88
+ short to hold two margins removes nothing (it is dropped, never turned inside out).
89
+
90
+ The margin is applied to the silences alone, before they are merged with filler spans (#414):
91
+ a filler word's span is the word itself plus --filler-pad, and taking a 0.2 s margin off each
92
+ side of it turned every word narrower than 2 x --margin inside out -- kept, and still counted
93
+ as removed."""
94
+ out: List[Tuple[float, float]] = []
95
+ for s, e in silences:
96
+ a = s + margin
97
+ b = duration if e == float("inf") else min(duration, e - margin)
98
+ if b > a:
99
+ out.append((a, b))
100
+ return out
101
+
102
+
86
103
  def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: float, min_keep: float) -> List[Tuple[float, float]]:
87
104
  keeps: List[Tuple[float, float]] = []
88
105
  cursor = 0.0
89
106
  for s, e in sorted(silences):
107
+ if e != float("inf") and e - margin <= s + margin:
108
+ continue # narrower than two margins: nothing of it is removed (#414)
90
109
  s_adj = max(cursor, s + margin)
91
110
  if s_adj - cursor >= min_keep:
92
111
  keeps.append((cursor, s_adj))
@@ -503,6 +522,9 @@ def main() -> int:
503
522
  media_out = None if (args.list or args.filler_list) else (args.output or default_output(args.input, "tight"))
504
523
  guard_sidecar(args.edl, args.input, args.words, args.filler_words, siblings=(media_out,))
505
524
 
525
+ if not math.isfinite(args.margin) or args.margin < 0:
526
+ # nan made inside_margin() drop every silence and re-encode the whole file unchanged
527
+ die(f"--margin must be a finite number of seconds, 0 or more (got {args.margin:g})", kind="input")
506
528
  if args.filler_pad < 0:
507
529
  die("--filler-pad cannot be negative: a negative pad turns each word's span inside out "
508
530
  "(end before start) and the span is then silently dropped, so nothing is removed",
@@ -525,8 +547,10 @@ def main() -> int:
525
547
  filler_info, filler_ranges = resolve_filler(args, meta) if args.filler else (None, [])
526
548
  # One sorted, merged removal list through the graph the tool already has: filler removal IS
527
549
  # time-range removal, so it reuses keep_ranges() and the same select chain.
528
- removals = merge_spans(list(silences) + list(filler_ranges))
529
- keeps = keep_ranges(removals, duration, args.margin, args.min_keep)
550
+ # --margin is kept around the silences only (inside_margin), not around filler spans, whose
551
+ # own trim is --filler-pad (#414); the merged list then goes through with no further margin.
552
+ removals = merge_spans(inside_margin(silences, duration, args.margin) + list(filler_ranges))
553
+ keeps = keep_ranges(removals, duration, 0.0, args.min_keep)
530
554
  # what the render holds (on a picture, each bound on its nearest frame): the report, the EDL
531
555
  # and the time map all describe these, so a re-cut or a sum of `keep` is the rendered edit
532
556
  video_out = bool(meta.get("video")) and not (media_out and is_audio_output(media_out))
@@ -540,7 +564,7 @@ def main() -> int:
540
564
  # the silences alone; everything removed is `removed_seconds_total`.
541
565
  silence_only = removed
542
566
  if args.filler:
543
- silence_keeps = keep_ranges(merge_spans(list(silences)), duration, args.margin, args.min_keep)
567
+ silence_keeps = keep_ranges(merge_spans(inside_margin(silences, duration, args.margin)), duration, 0.0, args.min_keep)
544
568
  silence_only = max(0.0, duration - sum(e - s for s, e in rendered_ranges(silence_keeps, meta, duration, video_out)))
545
569
  kinds = silence_kinds(args.input, silences, duration, args.threshold)
546
570
  summary = {
@@ -649,8 +673,9 @@ def main() -> int:
649
673
  return 0
650
674
  if not keeps or not held:
651
675
  die("nothing would be kept; raise --threshold (e.g. -45) or check the audio")
652
- if not silences or removed < 0.05:
653
- info("no removable silence found; output would equal the input")
676
+ if removed < 0.05:
677
+ # what the render removes, filler spans included: --filler alone has no silences at all
678
+ info(f"no removable {'filler' if filler_only else 'silence or filler' if args.filler else 'silence'} found; output would equal the input")
654
679
 
655
680
  output = args.output or default_output(args.input, "tight")
656
681
  # the join of more than 32 ranges concats a list of chunks, so its last command never names the
package/scripts/sync.py CHANGED
@@ -32,6 +32,7 @@ import os
32
32
  import sys
33
33
  from typing import Dict, List
34
34
 
35
+ from _common.runner import fail_if_classified
35
36
  from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
36
37
  from _common.emit import record_skip, warn
37
38
  from _common.decision import stream_starts
@@ -377,6 +378,7 @@ def main() -> int:
377
378
  cmd += ["-af", ",".join(af_parts)]
378
379
  cmd += aac_args() + (["-t", f"{ref_dur:.3f}"] if ref_dur else ["-shortest"]) + [output]
379
380
  proc = run(cmd, check=False)
381
+ fail_if_classified(proc) # a cause the re-encode would hit too is reported, not retried (#415)
380
382
  if proc.returncode != 0:
381
383
  cmd = [c for c in cmd if c != "copy"]
382
384
  idx = cmd.index("-c:v"); del cmd[idx]
@@ -389,6 +391,7 @@ def main() -> int:
389
391
  cmd += ["-movflags", "+faststart"]
390
392
  cmd += ["-avoid_negative_ts", "make_zero", output]
391
393
  proc = run(cmd, check=False)
394
+ fail_if_classified(proc)
392
395
  if proc.returncode != 0:
393
396
  cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
394
397
  run(cmd)
@@ -284,6 +284,14 @@ def main() -> int:
284
284
  notes.append(f"the visualisation band and {args.platform}'s bottom safe zone overlap by "
285
285
  f"{overlap}px: the captions or the app's own UI will sit over the waveform "
286
286
  f"(lower --vis-height to {max(0.05, (args.height - safe_px) / args.height):.2f})")
287
+ if STATE.dry_run and (args.title or args.srt or args.text):
288
+ # graphics.py / caption.py plan on the render this dry run did not write, whose frame they
289
+ # cannot measure: their sizes and offsets come out 0 in the plan, and the real run takes
290
+ # them from the WxH render (#415). Said here, so a plan is not read as the real numbers.
291
+ later = " and ".join(n for n, on in (("title", args.title), ("captions", args.srt or args.text)) if on)
292
+ notes.append(f"the {later} stage was planned on the visualisation render this dry run did not write, so the "
293
+ f"font sizes and offsets in its planned command are placeholders (0); the real run sizes them "
294
+ f"from the {args.width}x{args.height} render")
287
295
  if silent_peak is not None:
288
296
  warn(f"{args.input} is silent (peak {silent_peak:g} dBFS); the visualisation is a flat line", "silent_input",
289
297
  path=args.input, peak_db=silent_peak)