ffmpeg-skill 2.10.11 → 2.12.0
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/README.md +4 -3
- package/docs/contract.md +107 -11
- package/mcp/server.py +7 -1
- package/package.json +1 -1
- package/references/scripts.md +92 -20
- package/scripts/_common/__init__.py +6 -4
- package/scripts/_common/asr.py +2 -2
- package/scripts/_common/decision.py +57 -11
- package/scripts/_common/emit.py +198 -2
- package/scripts/_common/fonts.py +93 -28
- package/scripts/_common/runner.py +246 -68
- package/scripts/_common/timeline.py +22 -6
- package/scripts/_contract.py +168 -10
- package/scripts/audio.py +2 -1
- package/scripts/batch.py +240 -105
- package/scripts/caption.py +68 -18
- package/scripts/color.py +2 -1
- package/scripts/cropdetect.py +19 -4
- package/scripts/export.py +42 -10
- package/scripts/graphics.py +24 -18
- package/scripts/grid.py +7 -3
- package/scripts/join.py +5 -3
- package/scripts/loop.py +4 -2
- package/scripts/multicam.py +11 -3
- package/scripts/overlay.py +14 -10
- package/scripts/render.py +223 -74
- package/scripts/report.py +77 -23
- package/scripts/reverse.py +4 -2
- package/scripts/sequence.py +4 -2
- package/scripts/silence.py +19 -7
- package/scripts/sync.py +18 -7
- package/scripts/verify.py +98 -24
- package/scripts/waveform.py +3 -2
|
@@ -71,10 +71,26 @@ PROBE_TIMEOUT = 120.0
|
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def _env_timeout() -> float:
|
|
74
|
+
return _read_env_timeout()[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_env_timeout() -> "Tuple[float, Optional[str]]":
|
|
78
|
+
"""(the per-call limit FFMPEG_SKILL_TIMEOUT sets, why its value was not taken as written or
|
|
79
|
+
None). Unset or blank is the default with nothing to say. A value that is not a number (or is
|
|
80
|
+
NaN) is the default, and a negative one is 0 (no limit) as it always was; both used to pass
|
|
81
|
+
without a word, and apply_common() now records them (`timeout_env_invalid`)."""
|
|
82
|
+
raw = os.environ.get("FFMPEG_SKILL_TIMEOUT")
|
|
83
|
+
if raw is None or not raw.strip():
|
|
84
|
+
return DEFAULT_TIMEOUT, None
|
|
74
85
|
try:
|
|
75
|
-
|
|
86
|
+
value = float(raw)
|
|
87
|
+
if value != value:
|
|
88
|
+
raise ValueError("NaN")
|
|
76
89
|
except ValueError:
|
|
77
|
-
return DEFAULT_TIMEOUT
|
|
90
|
+
return DEFAULT_TIMEOUT, f"FFMPEG_SKILL_TIMEOUT={raw!r} is not a number of seconds; using the default {DEFAULT_TIMEOUT:g} s"
|
|
91
|
+
if value < 0:
|
|
92
|
+
return 0.0, f"FFMPEG_SKILL_TIMEOUT={raw!r} is negative; read as 0, which means no time limit"
|
|
93
|
+
return value, None
|
|
78
94
|
|
|
79
95
|
|
|
80
96
|
# None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
|
|
@@ -217,7 +233,8 @@ class Context:
|
|
|
217
233
|
"""
|
|
218
234
|
|
|
219
235
|
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
|
|
220
|
-
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto"
|
|
236
|
+
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
|
|
237
|
+
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification")
|
|
221
238
|
|
|
222
239
|
def __init__(self) -> None:
|
|
223
240
|
self.reset()
|
|
@@ -246,6 +263,19 @@ class Context:
|
|
|
246
263
|
self.hw_env_ignored = False # $FFMPEG_SKILL_HW=1 was set but this tool's delivery preset does not take it (export.py)
|
|
247
264
|
self.hw_stages: List[Dict[str, Any]] = [] # render.py: the `hw` facts each stage (a child process) reported
|
|
248
265
|
self.hw_auto: List[str] = [] # CPU encoders (libx264/libx265) this ffmpeg build lacks, whose encodes ran on VideoToolbox without --hw
|
|
266
|
+
# The collector (docs/design-decisions.md, "Nothing happens silently"): what this run
|
|
267
|
+
# decided on its own -- a warning, a fallback, a step it skipped -- recorded as it happens
|
|
268
|
+
# by warn()/record_fallback()/record_skip() (stderr too) and merged by emit()/die() into
|
|
269
|
+
# the document: `notes` (strings) and `diagnostics: {warnings, fallbacks, skipped}`.
|
|
270
|
+
self.notes: List[str] = []
|
|
271
|
+
self.warnings: List[Dict[str, Any]] = []
|
|
272
|
+
self.fallbacks: List[Dict[str, Any]] = []
|
|
273
|
+
self.skipped: List[Dict[str, Any]] = []
|
|
274
|
+
self.audit_log: Optional[str] = None # render.py --audit-log FILE: where audit() appends; None = the event's own default
|
|
275
|
+
# render.py (stages, an executed plan, a pack's renders): the verification steps each child
|
|
276
|
+
# tool reported, tagged with `stage`, which emit() adds to this result's own so that
|
|
277
|
+
# `verified` is the conjunction of every stage's (absorb_stage())
|
|
278
|
+
self.stage_verification: List[Dict[str, Any]] = []
|
|
249
279
|
|
|
250
280
|
|
|
251
281
|
STATE = Context()
|
|
@@ -320,6 +350,10 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
320
350
|
STATE.overwrite = bool(getattr(args, "overwrite", False))
|
|
321
351
|
if getattr(args, "timeout", None) is not None:
|
|
322
352
|
STATE.timeout = max(0.0, float(args.timeout))
|
|
353
|
+
else:
|
|
354
|
+
env_limit, env_problem = _read_env_timeout()
|
|
355
|
+
if env_problem:
|
|
356
|
+
warn(env_problem, "timeout_env_invalid", value=os.environ.get("FFMPEG_SKILL_TIMEOUT"), timeout=env_limit)
|
|
323
357
|
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
324
358
|
args.preset = "veryfast"
|
|
325
359
|
STATE.codec = getattr(args, "codec", None) or None
|
|
@@ -377,9 +411,10 @@ _CHILDREN: List[Tuple[subprocess.Popen, Sequence[str]]] = []
|
|
|
377
411
|
_SIGNALS_INSTALLED = False
|
|
378
412
|
|
|
379
413
|
|
|
380
|
-
def
|
|
381
|
-
|
|
382
|
-
|
|
414
|
+
def stop_children() -> None:
|
|
415
|
+
"""Stop every child this tool is waiting on and remove each one's partial output: what the
|
|
416
|
+
signal handler does before it exits, for a tool that reports more than "interrupted" after a
|
|
417
|
+
signal (batch.py's partial table)."""
|
|
383
418
|
for proc, cmd in list(_CHILDREN):
|
|
384
419
|
try:
|
|
385
420
|
proc.terminate() # ffmpeg exits promptly on SIGTERM; a sibling script runs this same handler
|
|
@@ -393,6 +428,12 @@ def _on_signal(signum: int, frame: Any) -> None:
|
|
|
393
428
|
if cmd:
|
|
394
429
|
_cleanup_partial_output(cmd)
|
|
395
430
|
_CHILDREN.clear()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _on_signal(signum: int, frame: Any) -> None:
|
|
434
|
+
import signal as _signal
|
|
435
|
+
name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
|
|
436
|
+
stop_children()
|
|
396
437
|
die(f"interrupted by {name}: the running command was stopped and its partial output removed; nothing was written",
|
|
397
438
|
code=128 + signum, kind="interrupted")
|
|
398
439
|
|
|
@@ -531,12 +572,21 @@ def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
|
531
572
|
if a == "-i" and i + 1 < len(cmd):
|
|
532
573
|
inp = cmd[i + 1]
|
|
533
574
|
try:
|
|
534
|
-
|
|
535
|
-
die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
|
|
536
|
-
f"(would overwrite it while ffmpeg is still reading it) -- choose a different --output/-o path",
|
|
537
|
-
kind="input")
|
|
575
|
+
same = os.path.realpath(inp) == out_real
|
|
538
576
|
except OSError:
|
|
539
577
|
continue
|
|
578
|
+
if same:
|
|
579
|
+
_refuse_output_is_input(output, inp, out_real, "would overwrite it while ffmpeg is still reading it")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _refuse_output_is_input(output: str, inp: str, real: str, why: str) -> None:
|
|
583
|
+
"""The one refusal both output-is-input guards end in: structured, and in the audit log when
|
|
584
|
+
one is configured (render.py --audit-log)."""
|
|
585
|
+
details = {"output": output, "input": inp, "resolved": real}
|
|
586
|
+
audit("output_is_input_refused", details)
|
|
587
|
+
refuse("input", "output_is_input",
|
|
588
|
+
f"refusing to run: output {output!r} is the same file as input {inp!r} ({why}) -- choose a different --output/-o path",
|
|
589
|
+
**details)
|
|
540
590
|
|
|
541
591
|
|
|
542
592
|
def refuse_output_is_input(output: str, *inputs: str) -> None:
|
|
@@ -555,8 +605,7 @@ def refuse_output_is_input(output: str, *inputs: str) -> None:
|
|
|
555
605
|
except OSError:
|
|
556
606
|
continue
|
|
557
607
|
if same:
|
|
558
|
-
|
|
559
|
-
f"(the result would replace the source) -- choose a different --output/-o path", kind="input")
|
|
608
|
+
_refuse_output_is_input(output, inp, out_real, "the result would replace the source")
|
|
560
609
|
|
|
561
610
|
|
|
562
611
|
def _check_output_path(cmd: Sequence[str]) -> None:
|
|
@@ -567,12 +616,15 @@ def _check_output_path(cmd: Sequence[str]) -> None:
|
|
|
567
616
|
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
568
617
|
return
|
|
569
618
|
if os.path.isdir(output):
|
|
570
|
-
|
|
619
|
+
refuse("input", "output_is_directory",
|
|
620
|
+
f"output {output!r} is a directory; pass a file path (e.g. {os.path.join(output, 'result.mp4')!r})", output=output)
|
|
571
621
|
parent = os.path.dirname(os.path.abspath(output))
|
|
572
622
|
if not os.path.isdir(parent):
|
|
573
|
-
|
|
623
|
+
refuse("input", "output_dir_missing",
|
|
624
|
+
f"output directory {parent!r} does not exist; create it first (this tool never creates directories)",
|
|
625
|
+
output=output, directory=parent)
|
|
574
626
|
if not os.access(parent, os.W_OK):
|
|
575
|
-
|
|
627
|
+
refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
|
|
576
628
|
|
|
577
629
|
|
|
578
630
|
EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
|
@@ -602,14 +654,29 @@ def _pid_dead(pid: int) -> bool:
|
|
|
602
654
|
return False
|
|
603
655
|
|
|
604
656
|
|
|
657
|
+
LOCK_HOUR = 3600.0
|
|
658
|
+
LOCK_UNLIMITED = 10 * 365 * 86400.0 # --timeout 0: the deadline is pushed past any run (and kept short of 2038)
|
|
659
|
+
|
|
660
|
+
|
|
605
661
|
class _OutputLock:
|
|
606
662
|
"""Two runs writing the same output at once used to both report `completed` while one of
|
|
607
663
|
them described the other's file (sweep F1). A lock file next to the output, created with
|
|
608
|
-
O_EXCL and holding the writer's pid, makes the second run refuse
|
|
609
|
-
|
|
664
|
+
O_EXCL and holding the writer's pid, makes the second run refuse (`kind: input`,
|
|
665
|
+
`reason: output_locked`, the holder in `details`).
|
|
666
|
+
|
|
667
|
+
Stale locks: a lock whose pid is dead is taken over. A lock whose pid is alive is taken over
|
|
668
|
+
only once its holder's own time limit has passed: the writer sets the lock's mtime to that
|
|
669
|
+
deadline minus an hour, so "older than an hour" -- the rule every version since 1.x applies,
|
|
670
|
+
which this keeps compatible in both directions -- means "past the deadline". The deadline is
|
|
671
|
+
child_limit() from now (4x --timeout + 60 s, the ceiling on everything one run() does under
|
|
672
|
+
the lock); with --timeout 0 there is none and a live holder is never taken over. Through 2.10
|
|
673
|
+
the hour ran from the lock's creation, so a legitimate run longer than an hour lost its lock
|
|
674
|
+
to a second writer. A takeover is recorded (`diagnostics.fallbacks`, stderr, and the audit
|
|
675
|
+
log); so is running without a lock where none can be created (`diagnostics.warnings`)."""
|
|
610
676
|
def __init__(self, output: str) -> None:
|
|
611
677
|
self.path: Optional[str] = None
|
|
612
678
|
self.fd: Optional[int] = None
|
|
679
|
+
self.output = output
|
|
613
680
|
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
614
681
|
return
|
|
615
682
|
d, base = os.path.split(os.path.abspath(output))
|
|
@@ -618,33 +685,95 @@ class _OutputLock:
|
|
|
618
685
|
def __enter__(self) -> "_OutputLock":
|
|
619
686
|
if not self.path:
|
|
620
687
|
return self
|
|
688
|
+
taken: Optional[Dict[str, Any]] = None
|
|
621
689
|
for attempt in (0, 1):
|
|
622
690
|
try:
|
|
623
691
|
self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
624
692
|
os.write(self.fd, str(os.getpid()).encode())
|
|
693
|
+
self._set_deadline()
|
|
694
|
+
if taken is not None:
|
|
695
|
+
self._record_takeover(taken)
|
|
625
696
|
return self
|
|
626
697
|
except FileExistsError:
|
|
627
|
-
|
|
698
|
+
holder = self._holder()
|
|
699
|
+
if attempt == 0 and holder["stale"]:
|
|
628
700
|
try:
|
|
629
701
|
os.remove(self.path)
|
|
630
|
-
except OSError:
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
702
|
+
except OSError as exc:
|
|
703
|
+
# Windows will not delete a file another process holds open, and a live
|
|
704
|
+
# holder keeps its lock open until it finishes: the takeover fails and
|
|
705
|
+
# the run is refused below, with the reason, instead of passing silently
|
|
706
|
+
holder["takeover_error"] = str(exc)
|
|
707
|
+
holder["stale"] = False
|
|
708
|
+
else:
|
|
709
|
+
taken = holder
|
|
710
|
+
continue
|
|
711
|
+
name = os.path.basename(self.path)[1:-len(".ffskill-lock")]
|
|
712
|
+
refuse("input", "output_locked",
|
|
713
|
+
f"another run is writing {name!r} right now (lock {self.path}"
|
|
714
|
+
+ (f", pid {holder['pid']}" if holder.get("pid") else "") + "); wait for it or choose a different --output/-o path",
|
|
715
|
+
hint=("wait for that run, or choose another -o path; if pid "
|
|
716
|
+
f"{holder.get('pid')} is not an ffmpeg-skill run, delete {self.path}"),
|
|
717
|
+
lock=self.path, output=os.path.abspath(self.output), **{k: v for k, v in holder.items() if k != "stale"})
|
|
718
|
+
except OSError as exc:
|
|
719
|
+
# an unlockable location (a read-only directory surfaces elsewhere): the run goes
|
|
720
|
+
# on without a lock, and says so -- two runs could now both write this output
|
|
721
|
+
details = {"lock": self.path, "output": os.path.abspath(self.output), "error": str(exc)}
|
|
722
|
+
warn(f"could not create the output lock {self.path} ({exc}); running without one, so a second run "
|
|
723
|
+
"writing the same output at the same time would not be refused", "output_unlocked", **details)
|
|
724
|
+
audit("output_unlocked", details, default_path=self._audit_default())
|
|
725
|
+
return self
|
|
637
726
|
return self
|
|
638
727
|
|
|
639
|
-
def
|
|
728
|
+
def _audit_default(self) -> Optional[str]:
|
|
729
|
+
if not self.path:
|
|
730
|
+
return None
|
|
731
|
+
d, base = os.path.split(os.path.abspath(self.output))
|
|
732
|
+
return os.path.join(d, f".{base}.ffskill-audit.jsonl")
|
|
733
|
+
|
|
734
|
+
def _set_deadline(self) -> None:
|
|
735
|
+
import time
|
|
736
|
+
limit = child_limit()
|
|
737
|
+
deadline = time.time() + (limit if limit else LOCK_UNLIMITED)
|
|
738
|
+
try:
|
|
739
|
+
os.utime(self.path, (deadline - LOCK_HOUR, deadline - LOCK_HOUR))
|
|
740
|
+
except (OSError, OverflowError, ValueError) as exc:
|
|
741
|
+
warn(f"could not date the output lock {self.path} ({exc}); another run may take it over an hour from now",
|
|
742
|
+
"lock_deadline_unset", lock=self.path, error=str(exc))
|
|
743
|
+
|
|
744
|
+
def _holder(self) -> Dict[str, Any]:
|
|
745
|
+
"""Who holds the lock and whether it is stale: {pid, pid_alive, deadline (UTC, the
|
|
746
|
+
mtime plus an hour), why: pid_dead | past_deadline | unreadable | held, stale}."""
|
|
747
|
+
import datetime
|
|
748
|
+
import time
|
|
749
|
+
out: Dict[str, Any] = {"pid": None, "pid_alive": None, "deadline": None, "why": "unreadable", "stale": True}
|
|
640
750
|
try:
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
return time.time() - os.path.getmtime(self.path) > 3600
|
|
751
|
+
with open(self.path, encoding="utf-8", errors="replace") as f:
|
|
752
|
+
text = f.read().strip()
|
|
753
|
+
deadline = os.path.getmtime(self.path) + LOCK_HOUR
|
|
754
|
+
pid = int(text or "0")
|
|
646
755
|
except (OSError, ValueError):
|
|
647
|
-
return
|
|
756
|
+
return out
|
|
757
|
+
out["deadline"] = datetime.datetime.fromtimestamp(deadline, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
758
|
+
out["pid"] = pid if pid > 0 else None
|
|
759
|
+
if pid > 0 and _pid_dead(pid):
|
|
760
|
+
out.update(pid_alive=False, why="pid_dead")
|
|
761
|
+
return out
|
|
762
|
+
out["pid_alive"] = pid > 0 or None
|
|
763
|
+
if time.time() > deadline:
|
|
764
|
+
out["why"] = "past_deadline"
|
|
765
|
+
return out
|
|
766
|
+
out.update(why="held", stale=False)
|
|
767
|
+
return out
|
|
768
|
+
|
|
769
|
+
def _record_takeover(self, holder: Dict[str, Any]) -> None:
|
|
770
|
+
details = {"lock": self.path, "output": os.path.abspath(self.output),
|
|
771
|
+
**{k: v for k, v in holder.items() if k != "stale"}}
|
|
772
|
+
why = {"pid_dead": f"its pid {holder.get('pid')} no longer runs",
|
|
773
|
+
"past_deadline": f"its holder (pid {holder.get('pid')}, still running) is past its own time limit",
|
|
774
|
+
}.get(holder.get("why") or "", "it could not be read")
|
|
775
|
+
details["audit_log"] = audit("lock_takeover", details, default_path=self._audit_default())
|
|
776
|
+
record_fallback(f"took over the stale output lock {self.path}: {why}", "stale_lock_taken_over", **details)
|
|
648
777
|
|
|
649
778
|
def __exit__(self, *exc: Any) -> None:
|
|
650
779
|
if self.fd is not None:
|
|
@@ -698,6 +827,7 @@ def refuse_existing_outputs(paths: Sequence[str]) -> None:
|
|
|
698
827
|
.srt/.ass sidecars) calls this up front so a hand-edited sidecar is refused like the video
|
|
699
828
|
is, and a dry run predicts the same refusal."""
|
|
700
829
|
existing: List[str] = []
|
|
830
|
+
facts: List[Dict[str, Any]] = []
|
|
701
831
|
for output in paths:
|
|
702
832
|
if not output:
|
|
703
833
|
continue
|
|
@@ -708,21 +838,25 @@ def refuse_existing_outputs(paths: Sequence[str]) -> None:
|
|
|
708
838
|
continue
|
|
709
839
|
if not exists or real in STATE.written:
|
|
710
840
|
continue
|
|
841
|
+
size = mtime_ns = None
|
|
711
842
|
try:
|
|
712
843
|
st = os.stat(output)
|
|
713
844
|
STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
|
|
845
|
+
size, mtime_ns = st.st_size, st.st_mtime_ns
|
|
714
846
|
except OSError:
|
|
715
847
|
pass
|
|
716
848
|
if output not in existing:
|
|
717
849
|
existing.append(output)
|
|
850
|
+
facts.append({"path": output, "resolved": real, "size": size, "mtime_ns": mtime_ns})
|
|
718
851
|
if not existing or STATE.overwrite:
|
|
719
852
|
return
|
|
853
|
+
hint = "pass --overwrite, or choose another -o path"
|
|
720
854
|
if len(existing) == 1:
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
855
|
+
refuse("input", "output_exists",
|
|
856
|
+
f"refusing to overwrite existing output {existing[0]!r}: pass --overwrite to replace it, or choose another -o path",
|
|
857
|
+
hint=hint, outputs=facts)
|
|
858
|
+
refuse("input", "output_exists", "refusing to overwrite existing outputs " + ", ".join(repr(p) for p in existing)
|
|
859
|
+
+ ": pass --overwrite to replace them, or choose another -o path", hint=hint, outputs=facts)
|
|
726
860
|
|
|
727
861
|
|
|
728
862
|
def _remember_output(cmd: Sequence[str]) -> None:
|
|
@@ -928,7 +1062,10 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
928
1062
|
return proc, ran
|
|
929
1063
|
retry = _odd_dimension_retry(ran, proc.stderr or "")
|
|
930
1064
|
if retry is not None:
|
|
931
|
-
|
|
1065
|
+
# recorded, not only said: the output's frame is a pixel narrower or shorter than
|
|
1066
|
+
# the source's, which a caller comparing sizes has to be able to read
|
|
1067
|
+
record_fallback("source has odd dimensions; scaling to even before encoding (yuv420p needs it)",
|
|
1068
|
+
"odd_dimensions_scaled_even", ctx=ctx, output=cmd[-1], scale=EVEN_SCALE)
|
|
932
1069
|
ctx.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
|
|
933
1070
|
return _execute(retry), retry
|
|
934
1071
|
if "not divisible by 2" in (proc.stderr or ""):
|
|
@@ -1027,10 +1164,12 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
|
|
|
1027
1164
|
def dry_run_input_pending(path: str) -> bool:
|
|
1028
1165
|
"""True when a measurement cannot run because its input does not exist yet under --dry-run:
|
|
1029
1166
|
in a render.py/batch.py plan each stage's input is the previous stage's output, which a dry
|
|
1030
|
-
run never wrote. The measurement is then skipped
|
|
1031
|
-
|
|
1167
|
+
run never wrote. The measurement is then skipped rather than failing the plan, and the skip is
|
|
1168
|
+
recorded (`diagnostics.skipped`, `measurement_input_pending`, once per path) beside its stderr
|
|
1169
|
+
line; on a real file the measurement runs even under --dry-run."""
|
|
1032
1170
|
if STATE.dry_run and not os.path.exists(path):
|
|
1033
|
-
|
|
1171
|
+
record_skip(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped",
|
|
1172
|
+
"measurement_input_pending", path=path)
|
|
1034
1173
|
return True
|
|
1035
1174
|
return False
|
|
1036
1175
|
|
|
@@ -1093,9 +1232,19 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
|
1093
1232
|
(e.g. a container change) makes that first attempt fail; retry the same command without the
|
|
1094
1233
|
extra maps rather than let a tool that never touched subtitles start hard-failing because of
|
|
1095
1234
|
them. `cmd` is the full argv *without* the output path. Returns True only when the
|
|
1096
|
-
retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped).
|
|
1097
|
-
|
|
1235
|
+
retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped).
|
|
1236
|
+
|
|
1237
|
+
The retry is recorded with the first attempt's error (`diagnostics.fallbacks`,
|
|
1238
|
+
`non_av_streams_dropped`): the caller's `dropped_non_av_streams` says that streams went, the
|
|
1239
|
+
record says why, which is the only way to tell a container that cannot hold them from a first
|
|
1240
|
+
attempt that failed for another reason and happened to succeed the second time."""
|
|
1241
|
+
first = run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False)
|
|
1242
|
+
if first.returncode == 0:
|
|
1098
1243
|
return False
|
|
1244
|
+
error = " | ".join([ln.strip() for ln in (first.stderr or "").splitlines() if ln.strip()][-3:])[-500:]
|
|
1245
|
+
record_fallback(f"the subtitle/data streams could not be copied into {os.path.basename(output)} "
|
|
1246
|
+
f"({error or 'ffmpeg exit ' + str(first.returncode)}); encoding again without them",
|
|
1247
|
+
"non_av_streams_dropped", output=output, returncode=first.returncode, error=error)
|
|
1099
1248
|
run(cmd + [output])
|
|
1100
1249
|
return True
|
|
1101
1250
|
|
|
@@ -1356,41 +1505,70 @@ def ffmpeg_encoders() -> set:
|
|
|
1356
1505
|
ffmpeg_version(). That is the one case for ffprobe. On a mixed install (a Homebrew ffprobe next
|
|
1357
1506
|
to a static ffmpeg) its list is another build's, so a real run never uses it: the AV1/ProRes
|
|
1358
1507
|
refusals and the VideoToolbox check must describe the binary that encodes, and a plan that
|
|
1359
|
-
read ffprobe's list is re-decided against ffmpeg's when it runs.
|
|
1508
|
+
read ffprobe's list is re-decided against ffmpeg's when it runs.
|
|
1509
|
+
|
|
1510
|
+
An empty list is not "this build has none": when the list could not be read (the binary did
|
|
1511
|
+
not run, timed out, exited non-zero, or printed nothing a list is made of), the reason is kept
|
|
1512
|
+
(ffmpeg_list_error("encoders")) and recorded once as a `ffmpeg_list_unreadable` warning, so a
|
|
1513
|
+
refusal or a CPU note built on the empty set names the read failure, not a missing encoder."""
|
|
1360
1514
|
global _ENCODERS
|
|
1361
1515
|
if _ENCODERS is None:
|
|
1362
|
-
_ENCODERS =
|
|
1363
|
-
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
1364
|
-
# --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
|
|
1365
|
-
# (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
|
|
1366
|
-
limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
|
|
1367
|
-
try:
|
|
1368
|
-
out = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
|
|
1369
|
-
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=limit).stdout
|
|
1370
|
-
_ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
|
|
1371
|
-
except (OSError, subprocess.SubprocessError):
|
|
1372
|
-
pass
|
|
1516
|
+
_ENCODERS = _read_ff_list("-encoders", r"^\s*[VAS][.\w]{5}\s+(\S+)")
|
|
1373
1517
|
return _ENCODERS
|
|
1374
1518
|
|
|
1375
1519
|
|
|
1376
1520
|
def ffmpeg_bsfs() -> set:
|
|
1377
|
-
"""Names from `ffmpeg -bsfs`, read once, empty when it cannot be read
|
|
1378
|
-
|
|
1379
|
-
|
|
1521
|
+
"""Names from `ffmpeg -bsfs`, read once, empty when it cannot be read (the reason is kept and
|
|
1522
|
+
recorded as ffmpeg_encoders() keeps it); from ffprobe under --dry-run, for the reason
|
|
1523
|
+
ffmpeg_encoders() gives. color.py --retag rewrites an H.264/HEVC stream's colour description
|
|
1524
|
+
with h264_metadata/hevc_metadata only when the build has it."""
|
|
1380
1525
|
global _BSFS
|
|
1381
1526
|
if _BSFS is None:
|
|
1382
|
-
_BSFS =
|
|
1383
|
-
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
1384
|
-
limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
|
|
1385
|
-
try:
|
|
1386
|
-
out = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", "-bsfs"], stdout=subprocess.PIPE,
|
|
1387
|
-
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=limit).stdout
|
|
1388
|
-
_BSFS = set(re.findall(r"^\s*([a-z0-9_]+)\s*$", out, re.M))
|
|
1389
|
-
except (OSError, subprocess.SubprocessError):
|
|
1390
|
-
pass
|
|
1527
|
+
_BSFS = _read_ff_list("-bsfs", r"^\s*([a-z0-9_]+)\s*$")
|
|
1391
1528
|
return _BSFS
|
|
1392
1529
|
|
|
1393
1530
|
|
|
1531
|
+
# why the last read of a capability list came back empty: "-encoders"/"-bsfs" -> reason
|
|
1532
|
+
_LIST_ERRORS: Dict[str, str] = {}
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def ffmpeg_list_error(flag: str) -> Optional[str]:
|
|
1536
|
+
"""Why `ffmpeg -encoders` / `-bsfs` (flag "encoders" or "bsfs") could not be read, or None when
|
|
1537
|
+
it was read (or not yet asked for). Only while the cached list is empty: a list a test or a
|
|
1538
|
+
later read filled in has nothing to explain."""
|
|
1539
|
+
flag = "-" + flag.lstrip("-")
|
|
1540
|
+
cache = _ENCODERS if flag == "-encoders" else _BSFS if flag == "-bsfs" else None
|
|
1541
|
+
return None if cache else _LIST_ERRORS.get(flag)
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _read_ff_list(flag: str, pattern: str) -> set:
|
|
1545
|
+
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
1546
|
+
# --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
|
|
1547
|
+
# (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
|
|
1548
|
+
limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
|
|
1549
|
+
_LIST_ERRORS.pop(flag, None)
|
|
1550
|
+
names: set = set()
|
|
1551
|
+
why = None
|
|
1552
|
+
try:
|
|
1553
|
+
proc = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", flag], stdout=subprocess.PIPE,
|
|
1554
|
+
stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=limit)
|
|
1555
|
+
names = set(re.findall(pattern, proc.stdout or "", re.M))
|
|
1556
|
+
if not names:
|
|
1557
|
+
tail = [ln.strip() for ln in (proc.stderr or "").splitlines() if ln.strip()][-1:]
|
|
1558
|
+
why = (f"{tool} {flag} exited {proc.returncode}" if proc.returncode else f"{tool} {flag} listed nothing") + \
|
|
1559
|
+
(f": {tail[0][:200]}" if tail else "")
|
|
1560
|
+
except subprocess.TimeoutExpired:
|
|
1561
|
+
why = f"{tool} {flag} did not answer within {limit:g} s"
|
|
1562
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1563
|
+
why = f"{tool} {flag} could not run: {exc}"
|
|
1564
|
+
if why:
|
|
1565
|
+
_LIST_ERRORS[flag] = why
|
|
1566
|
+
what = "encoder" if flag == "-encoders" else "bitstream filter"
|
|
1567
|
+
warn(f"could not read this ffmpeg build's {what} list ({why}); nothing that needs it can be confirmed, which is not the same as the build lacking it",
|
|
1568
|
+
"ffmpeg_list_unreadable", list=flag.lstrip("-"), tool=tool, error=why)
|
|
1569
|
+
return names
|
|
1570
|
+
|
|
1571
|
+
|
|
1394
1572
|
def read_text_or_die(path: str, flag: str) -> str:
|
|
1395
1573
|
"""Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
|
|
1396
1574
|
with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
|
|
@@ -1415,4 +1593,4 @@ def read_text_or_die(path: str, flag: str) -> str:
|
|
|
1415
1593
|
# emit needs runner's Context/STATE/ERROR_CODE, so the two form a cycle that has to be cut
|
|
1416
1594
|
# somewhere: by the time this line runs every name emit reads from runner above is defined, and
|
|
1417
1595
|
# every name runner reads from emit is only ever read inside a function body, never at import.
|
|
1418
|
-
from _common.emit import _plan_at_exit, die, hw_report, info # noqa: E402
|
|
1596
|
+
from _common.emit import _plan_at_exit, audit, die, hw_report, info, record_fallback, record_skip, refuse, warn # noqa: E402
|
|
@@ -29,6 +29,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
29
29
|
from xml.sax.saxutils import escape as _xml_escape
|
|
30
30
|
|
|
31
31
|
from _common.decision import MissingFpsError, aspect_ratio, frame_size, parse_time
|
|
32
|
+
from _common.emit import warn
|
|
32
33
|
|
|
33
34
|
FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
|
|
34
35
|
# Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
|
|
@@ -482,17 +483,32 @@ WRITERS = {"edl": to_edl, "fcpxml": to_fcpxml, "otio": to_otio}
|
|
|
482
483
|
|
|
483
484
|
|
|
484
485
|
def parses(path: str, fmt: str) -> bool:
|
|
485
|
-
"""The verification step: the written file reads back as what it claims to be.
|
|
486
|
+
"""The verification step: the written file reads back as what it claims to be. A file that
|
|
487
|
+
does not is recorded with the reason (`diagnostics.warnings`, `timeline_parse_failed`: the
|
|
488
|
+
exception, or what was read instead), beside the step's `ok: false`."""
|
|
489
|
+
why = parse_error(path, fmt)
|
|
490
|
+
if why:
|
|
491
|
+
warn(f"{path} does not read back as {fmt}: {why}", "timeline_parse_failed", path=path, format=fmt, error=why)
|
|
492
|
+
return why is None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def parse_error(path: str, fmt: str) -> Optional[str]:
|
|
496
|
+
"""Why `path` does not read back as `fmt`, or None when it does (parses() without the record)."""
|
|
486
497
|
try:
|
|
487
498
|
text = Path(path).read_text(encoding="utf-8")
|
|
488
499
|
if fmt == "fcpxml":
|
|
489
500
|
import xml.etree.ElementTree as ET
|
|
490
|
-
|
|
501
|
+
tag = ET.fromstring(text.encode("utf-8")).tag
|
|
502
|
+
return None if tag == "fcpxml" else f"the root element is <{tag}>, not <fcpxml>"
|
|
491
503
|
if fmt == "otio":
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
504
|
+
doc = json.loads(text)
|
|
505
|
+
schema = doc.get("OTIO_SCHEMA") if isinstance(doc, dict) else None
|
|
506
|
+
return None if schema == "Timeline.1" else f"OTIO_SCHEMA is {schema!r}, not 'Timeline.1'"
|
|
507
|
+
if not text.startswith("TITLE:"):
|
|
508
|
+
return "the first line is not TITLE:"
|
|
509
|
+
return None if any(line[:3].isdigit() for line in text.splitlines()) else "no numbered event line"
|
|
510
|
+
except (OSError, ValueError, SyntaxError) as exc:
|
|
511
|
+
return f"{type(exc).__name__}: {exc}"
|
|
496
512
|
|
|
497
513
|
|
|
498
514
|
def summary(tl: Dict[str, Any], fmt: str) -> Dict[str, Any]:
|