ffmpeg-skill 1.4.13 → 1.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/contract.md +5 -4
- package/package.json +1 -1
- package/references/scripts.md +12 -3
- package/scripts/_common.py +142 -15
- package/scripts/audio.py +8 -4
- package/scripts/batch.py +15 -0
- package/scripts/broll.py +2 -1
- package/scripts/color.py +6 -0
- package/scripts/cut.py +10 -1
- package/scripts/export.py +5 -3
- package/scripts/graphics.py +2 -0
- package/scripts/insert.py +3 -0
- package/scripts/join.py +2 -1
- package/scripts/look.py +12 -3
- package/scripts/loudness.py +16 -4
- package/scripts/pad.py +8 -2
- package/scripts/proxy.py +4 -5
- package/scripts/redact.py +5 -1
- package/scripts/render.py +12 -1
- package/scripts/report.py +4 -1
- package/scripts/speedramp.py +1 -1
- package/scripts/verify.py +6 -0
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`1.4.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.4.15`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -46,7 +46,7 @@ For the whole of 1.x:
|
|
|
46
46
|
| Tool ids (`ffmpeg-skill/<name>`) and script names | never removed or renamed |
|
|
47
47
|
| CLI arguments (`argparse` dests, flags, positionals) | never removed, renamed, or made newly required; new optional arguments may be added |
|
|
48
48
|
| `--json` output keys, and the keys of `contract --json` / `doctor --json` | never removed or given a different type; new keys may be added |
|
|
49
|
-
| Exit codes (0 success, 1 failure, 2 unknown/undecidable in `doctor
|
|
49
|
+
| Exit codes (0 success, 1 failure incl. ffmpeg failures, 2 unknown/undecidable in `doctor`, 124 timeout, 127 missing tool, 128+signal interrupted) | unchanged |
|
|
50
50
|
| `contract_version` (`1.0`) | unchanged; a ToolSpec shape change is a major |
|
|
51
51
|
| MCP `tools/list` names and `inputSchema` property names | derived from the above, so covered by the same promise |
|
|
52
52
|
| Behaviour of a tool for the same input and arguments | may change only to fix a defect or to track an FFmpeg change, and every such change gets a CHANGELOG line |
|
|
@@ -83,7 +83,7 @@ on, the line says so.
|
|
|
83
83
|
```json
|
|
84
84
|
{
|
|
85
85
|
"contract_version": "1.0",
|
|
86
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.4.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.4.15", "execution_mode": "local", "kind": "execution",
|
|
87
87
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
88
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
89
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -318,7 +318,8 @@ before, and, when `--json` was given, on stdout:
|
|
|
318
318
|
`message` carries the script's own reason (missing input, ffprobe failure, the last
|
|
319
319
|
stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
|
|
320
320
|
flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
|
|
321
|
-
or run so the caller can retry or report without re-deriving the command
|
|
321
|
+
or run so the caller can retry or report without re-deriving the command; `kind: ffmpeg`
|
|
322
|
+
failures add `ffmpeg_returncode` (ffmpeg's own exit code; the process exits 1). `code` is a
|
|
322
323
|
purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
|
|
323
324
|
doesn't already make) for a caller that wants a stable enum instead of matching `kind`
|
|
324
325
|
strings. `retryable` is currently always `false`: none of the kinds are distinguishable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.15",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -281,6 +281,8 @@ rather than a continuous curve. `START`/`END` take seconds or `mm:ss`;
|
|
|
281
281
|
0.5 = half speed). Picking exactly where a ramp should ease in or out is a
|
|
282
282
|
judgement call for the calling agent, made concrete here as the segment
|
|
283
283
|
boundaries it supplies.
|
|
284
|
+
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
285
|
+
output; the result says so with `dropped_non_av_streams: true`.
|
|
284
286
|
|
|
285
287
|
### loop.py — repeat a clip
|
|
286
288
|
```
|
|
@@ -308,6 +310,8 @@ overlap or run past A's end, and B must have enough material from `--from`.
|
|
|
308
310
|
`--audio a` (default) keeps A's audio untouched and stream-copied; `b` replaces
|
|
309
311
|
it inside each window with B's; `mix` plays both. The output's length is
|
|
310
312
|
verified against A's.
|
|
313
|
+
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
314
|
+
output; the result says so with `dropped_non_av_streams: true`.
|
|
311
315
|
|
|
312
316
|
### metadata.py — chapter markers and container tags, streams copied
|
|
313
317
|
```
|
|
@@ -362,6 +366,8 @@ channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
|
|
|
362
366
|
silent track generated for clips without audio), then chains `xfade` +
|
|
363
367
|
`acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
|
|
364
368
|
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
369
|
+
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
370
|
+
output; the result says so with `dropped_non_av_streams: true`.
|
|
365
371
|
|
|
366
372
|
### render.py — the whole edit in one project.json
|
|
367
373
|
```
|
|
@@ -596,7 +602,8 @@ standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
|
|
|
596
602
|
speech so music dips under dialogue and swells in pauses. `--downmix` uses the
|
|
597
603
|
ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
|
|
598
604
|
leaves a 1-channel input untouched and downmixes >2 channels through
|
|
599
|
-
swresample. Video is always stream-copied
|
|
605
|
+
swresample. Video is always stream-copied, and so is a subtitle/data track
|
|
606
|
+
when the container can hold it (`dropped_non_av_streams` says when it could not).
|
|
600
607
|
Run `loudness.py` after this for final levels.
|
|
601
608
|
|
|
602
609
|
### loudness.py — EBU R128 normalisation
|
|
@@ -604,7 +611,8 @@ Run `loudness.py` after this for final levels.
|
|
|
604
611
|
loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
|
|
605
612
|
```
|
|
606
613
|
Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
|
|
607
|
-
the true-peak ceiling allows). Video
|
|
614
|
+
the true-peak ceiling allows). Video and any subtitle/data track are
|
|
615
|
+
stream-copied (`dropped_non_av_streams` reports a track the container refused); audio becomes AAC in
|
|
608
616
|
video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
|
|
609
617
|
The written file is measured again: a lossy encoder can push peaks past the
|
|
610
618
|
ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
|
|
@@ -629,6 +637,7 @@ proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]
|
|
|
629
637
|
Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
|
|
630
638
|
factor, re-encodes at a proxy-grade `--crf` (default 30) with the fastest
|
|
631
639
|
x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
|
|
632
|
-
run `color.py --to-sdr` first if SDR is wanted)
|
|
640
|
+
run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
|
|
641
|
+
track when the container can hold it (`dropped_non_av_streams`). Only executes the spec
|
|
633
642
|
given — does not decide which asset to proxy or what for.
|
|
634
643
|
|
package/scripts/_common.py
CHANGED
|
@@ -398,8 +398,12 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
398
398
|
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
399
399
|
# Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
|
|
400
400
|
# every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
|
|
401
|
+
# The process exit code is always 1 for an ffmpeg failure: ffmpeg's own code (1, 69, 218, 234,
|
|
402
|
+
# a negative signal number...) varies by build and by the failing stage, and 124/127/130/143
|
|
403
|
+
# are reserved for timeout, missing tool and interrupts. The raw code is kept in the JSON
|
|
404
|
+
# document as `ffmpeg_returncode` for a caller that wants it. docs/design-decisions.md.
|
|
401
405
|
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
402
|
-
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=
|
|
406
|
+
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode)
|
|
403
407
|
|
|
404
408
|
|
|
405
409
|
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
@@ -461,6 +465,112 @@ def _check_output_path(cmd: Sequence[str]) -> None:
|
|
|
461
465
|
parent = os.path.dirname(os.path.abspath(output))
|
|
462
466
|
if not os.path.isdir(parent):
|
|
463
467
|
die(f"output directory {parent!r} does not exist; create it first (this tool never creates directories)")
|
|
468
|
+
if not os.access(parent, os.W_OK):
|
|
469
|
+
die(f"output directory {parent!r} is not writable")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _pid_dead(pid: int) -> bool:
|
|
476
|
+
"""True only when the process is known not to exist. POSIX: signal 0. Windows: OpenProcess
|
|
477
|
+
fails with ERROR_INVALID_PARAMETER (87) for a pid that is not in use; any other outcome
|
|
478
|
+
(a handle, or access denied) means it is live. Unknown is treated as live."""
|
|
479
|
+
if os.name != "nt":
|
|
480
|
+
try:
|
|
481
|
+
os.kill(pid, 0)
|
|
482
|
+
except ProcessLookupError:
|
|
483
|
+
return True
|
|
484
|
+
except OSError:
|
|
485
|
+
pass
|
|
486
|
+
return False
|
|
487
|
+
try:
|
|
488
|
+
import ctypes
|
|
489
|
+
k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
490
|
+
handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
491
|
+
if handle:
|
|
492
|
+
k32.CloseHandle(handle)
|
|
493
|
+
return False
|
|
494
|
+
return k32.GetLastError() == 87
|
|
495
|
+
except Exception:
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class _OutputLock:
|
|
500
|
+
"""Two runs writing the same output at once used to both report `completed` while one of
|
|
501
|
+
them described the other's file (sweep F1). A lock file next to the output, created with
|
|
502
|
+
O_EXCL and holding the writer's pid, makes the second run refuse as `kind: input`. A lock
|
|
503
|
+
whose pid is dead (POSIX) or older than an hour is stale and taken over."""
|
|
504
|
+
def __init__(self, output: str) -> None:
|
|
505
|
+
self.path: Optional[str] = None
|
|
506
|
+
self.fd: Optional[int] = None
|
|
507
|
+
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
508
|
+
return
|
|
509
|
+
d, base = os.path.split(os.path.abspath(output))
|
|
510
|
+
self.path = os.path.join(d, f".{base}.ffskill-lock")
|
|
511
|
+
|
|
512
|
+
def __enter__(self) -> "_OutputLock":
|
|
513
|
+
if not self.path:
|
|
514
|
+
return self
|
|
515
|
+
for attempt in (0, 1):
|
|
516
|
+
try:
|
|
517
|
+
self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
518
|
+
os.write(self.fd, str(os.getpid()).encode())
|
|
519
|
+
return self
|
|
520
|
+
except FileExistsError:
|
|
521
|
+
if attempt == 0 and self._stale():
|
|
522
|
+
try:
|
|
523
|
+
os.remove(self.path)
|
|
524
|
+
except OSError:
|
|
525
|
+
pass
|
|
526
|
+
continue
|
|
527
|
+
die(f"another run is writing {os.path.basename(self.path)[1:-len('.ffskill-lock')]!r} right now "
|
|
528
|
+
f"(lock {self.path}); wait for it or choose a different --output/-o path")
|
|
529
|
+
except OSError:
|
|
530
|
+
return self # unlockable location (read-only dir surfaces elsewhere): proceed without a lock
|
|
531
|
+
return self
|
|
532
|
+
|
|
533
|
+
def _stale(self) -> bool:
|
|
534
|
+
try:
|
|
535
|
+
pid = int(open(self.path).read().strip() or "0")
|
|
536
|
+
if pid > 0 and _pid_dead(pid):
|
|
537
|
+
return True
|
|
538
|
+
import time
|
|
539
|
+
return time.time() - os.path.getmtime(self.path) > 3600
|
|
540
|
+
except (OSError, ValueError):
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
def __exit__(self, *exc: Any) -> None:
|
|
544
|
+
if self.fd is not None:
|
|
545
|
+
try:
|
|
546
|
+
os.close(self.fd)
|
|
547
|
+
except OSError:
|
|
548
|
+
pass
|
|
549
|
+
if self.path:
|
|
550
|
+
try:
|
|
551
|
+
os.remove(self.path)
|
|
552
|
+
except OSError:
|
|
553
|
+
pass
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
|
|
557
|
+
"""An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
|
|
558
|
+
encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
|
|
559
|
+
with an even-dimension scale prepended to its -vf chain (or a new -vf when the command had
|
|
560
|
+
none); None when the failure is something else or the graph is a -filter_complex the
|
|
561
|
+
caller has to fix itself."""
|
|
562
|
+
if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
|
|
563
|
+
return None
|
|
564
|
+
if "-filter_complex" in cmd:
|
|
565
|
+
return None
|
|
566
|
+
new = list(cmd)
|
|
567
|
+
if "-vf" in new:
|
|
568
|
+
i = new.index("-vf") + 1
|
|
569
|
+
new[i] = EVEN_SCALE + "," + new[i]
|
|
570
|
+
return new
|
|
571
|
+
if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
|
|
572
|
+
return None
|
|
573
|
+
return new[:-1] + ["-vf", EVEN_SCALE, new[-1]]
|
|
464
574
|
|
|
465
575
|
|
|
466
576
|
def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
@@ -555,24 +665,41 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
555
665
|
info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
|
|
556
666
|
if STATE.dry_run and is_ffmpeg:
|
|
557
667
|
return subprocess.CompletedProcess(list(cmd), 0, "", "")
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
proc =
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
668
|
+
with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
|
|
669
|
+
exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
|
|
670
|
+
proc = _execute(exec_cmd)
|
|
671
|
+
if proc.returncode != 0 and is_ffmpeg:
|
|
672
|
+
retry = _odd_dimension_retry(exec_cmd, proc.stderr or "")
|
|
673
|
+
if retry is not None:
|
|
674
|
+
info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
|
|
675
|
+
STATE.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
|
|
676
|
+
proc = _execute(retry)
|
|
677
|
+
elif "not divisible by 2" in (proc.stderr or ""):
|
|
678
|
+
die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
|
|
679
|
+
"cannot pad them itself; make them even first, e.g. fit.py --width/--height, then retry",
|
|
680
|
+
kind="input")
|
|
681
|
+
if proc.returncode != 0 and check:
|
|
682
|
+
_fail(exec_cmd, proc.returncode, proc.stderr or "")
|
|
683
|
+
if final and tmp:
|
|
684
|
+
if proc.returncode == 0:
|
|
685
|
+
try:
|
|
686
|
+
os.replace(tmp, final)
|
|
687
|
+
except OSError as e:
|
|
688
|
+
_cleanup_partial_output(exec_cmd)
|
|
689
|
+
die(f"could not replace {final} with the new output: {e}", kind="output")
|
|
690
|
+
_remember_output(cmd)
|
|
691
|
+
else:
|
|
568
692
|
_cleanup_partial_output(exec_cmd)
|
|
569
|
-
die(f"could not replace {final} with the new output: {e}", kind="output")
|
|
570
|
-
_remember_output(cmd)
|
|
571
|
-
else:
|
|
572
|
-
_cleanup_partial_output(exec_cmd)
|
|
573
693
|
return proc
|
|
574
694
|
|
|
575
695
|
|
|
696
|
+
def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
|
|
697
|
+
"""One attempt, never exiting on failure (run() decides after its retries)."""
|
|
698
|
+
if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
|
|
699
|
+
return _run_with_progress(exec_cmd, False)
|
|
700
|
+
return _run_captured(exec_cmd, False)
|
|
701
|
+
|
|
702
|
+
|
|
576
703
|
def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
|
|
577
704
|
"""Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
|
|
578
705
|
silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
|
package/scripts/audio.py
CHANGED
|
@@ -20,7 +20,7 @@ import argparse
|
|
|
20
20
|
import sys
|
|
21
21
|
from typing import List
|
|
22
22
|
|
|
23
|
-
from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, fmt_secs
|
|
23
|
+
from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
|
|
24
24
|
|
|
25
25
|
VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
|
|
26
26
|
|
|
@@ -226,8 +226,11 @@ def main() -> int:
|
|
|
226
226
|
if not keep_video:
|
|
227
227
|
# audio-only outputs: a looped music bed is infinite, -shortest ends the run with the main track
|
|
228
228
|
cmd.append("-shortest")
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
if keep_video:
|
|
230
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
231
|
+
else:
|
|
232
|
+
dropped_streams = bool(has_video and (meta.get("subtitle_streams") or meta.get("data_streams")))
|
|
233
|
+
run(cmd + [output])
|
|
231
234
|
r = probe(output, role="output")
|
|
232
235
|
a = r["audio"]
|
|
233
236
|
if r.get("video") and audio_out and not STATE.dry_run:
|
|
@@ -235,7 +238,8 @@ def main() -> int:
|
|
|
235
238
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
|
|
236
239
|
+ (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
|
|
237
240
|
emit(output, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
|
|
238
|
-
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f]
|
|
241
|
+
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
|
|
242
|
+
dropped_non_av_streams=dropped_streams)
|
|
239
243
|
return 0
|
|
240
244
|
|
|
241
245
|
|
package/scripts/batch.py
CHANGED
|
@@ -156,8 +156,23 @@ def main() -> int:
|
|
|
156
156
|
if not outdir.is_absolute():
|
|
157
157
|
outdir = folder / outdir
|
|
158
158
|
work = Path(args.work) if args.work else outdir / ".work"
|
|
159
|
+
# the children refuse an output whose directory does not exist, under --dry-run too, so the
|
|
160
|
+
# directories are created for the plan as well -- and removed again afterwards when a dry
|
|
161
|
+
# run created them and left them empty (a plan leaves nothing behind, sweep F15)
|
|
162
|
+
created = [d for d in (outdir, work) if not d.exists()]
|
|
159
163
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
160
164
|
work.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
if STATE.dry_run and created:
|
|
166
|
+
import atexit
|
|
167
|
+
|
|
168
|
+
def _remove_empty_dirs() -> None:
|
|
169
|
+
for d in sorted(created, key=lambda p: len(str(p)), reverse=True):
|
|
170
|
+
try:
|
|
171
|
+
if not any(d.iterdir()):
|
|
172
|
+
d.rmdir()
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
atexit.register(_remove_empty_dirs)
|
|
161
176
|
cache_path = outdir / ".ffskill_cache.json"
|
|
162
177
|
cache: Dict[str, Any] = {}
|
|
163
178
|
if cache_path.exists() and not args.force:
|
package/scripts/broll.py
CHANGED
|
@@ -145,7 +145,8 @@ def main() -> int:
|
|
|
145
145
|
if not STATE.dry_run and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
|
|
146
146
|
die(f"output is {fmt_secs(result.get('duration'))} but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
|
|
147
147
|
info(f"wrote {output} ({result.get('duration', 0):.3f}s, {len(cutaways)} cutaway(s), audio={args.audio})")
|
|
148
|
-
emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio
|
|
148
|
+
emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio,
|
|
149
|
+
dropped_non_av_streams=bool(meta_a.get("subtitle_streams") or meta_a.get("data_streams")))
|
|
149
150
|
return 0
|
|
150
151
|
|
|
151
152
|
|
package/scripts/color.py
CHANGED
|
@@ -285,6 +285,9 @@ def main() -> int:
|
|
|
285
285
|
output = args.output or default_output(args.input, "sdr")
|
|
286
286
|
tag = "sdr"
|
|
287
287
|
elif args.correct:
|
|
288
|
+
if v.get("hdr") and not args.force:
|
|
289
|
+
die(f"{args.input} is HDR ({v.get('hdr_format')}); --correct works on SDR pixels and would tag PQ/HLG data as BT.709 "
|
|
290
|
+
f"without a tone map (sweep F10). Run --to-sdr first, or --force to grade the raw values anyway")
|
|
288
291
|
vf = correction_chain(args)
|
|
289
292
|
output = args.output or default_output(args.input, "correct")
|
|
290
293
|
tag = "correct"
|
|
@@ -292,6 +295,9 @@ def main() -> int:
|
|
|
292
295
|
# "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
|
|
293
296
|
measurements = {"input": analyze_levels(args.input)}
|
|
294
297
|
else:
|
|
298
|
+
if v.get("hdr") and not args.force:
|
|
299
|
+
die(f"{args.input} is HDR ({v.get('hdr_format')}); a LUT made for SDR applied to PQ/HLG pixels gives a wrong picture "
|
|
300
|
+
f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
|
|
295
301
|
if not os.path.exists(args.lut):
|
|
296
302
|
die(f"LUT not found: {args.lut}")
|
|
297
303
|
if not (0.0 <= args.lut_strength <= 1.0):
|
package/scripts/cut.py
CHANGED
|
@@ -32,6 +32,8 @@ from typing import List, Tuple
|
|
|
32
32
|
|
|
33
33
|
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
|
|
34
34
|
|
|
35
|
+
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
36
|
+
DROPPED_STREAMS: List[str] = []
|
|
35
37
|
# keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
|
|
36
38
|
# (reported so the caller can choose a lossless cut at one of them next time)
|
|
37
39
|
NEAREST_KEYFRAMES: list = []
|
|
@@ -115,6 +117,12 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
115
117
|
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
|
|
116
118
|
if is_audio_output(dst) or not meta.get("video"):
|
|
117
119
|
cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
|
|
120
|
+
# ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
|
|
121
|
+
# trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
|
|
122
|
+
# sweep F2), so the re-encode carries video/audio only and the result says so
|
|
123
|
+
cmd += ["-sn", "-dn"]
|
|
124
|
+
if meta.get("subtitle_streams") or meta.get("data_streams"):
|
|
125
|
+
DROPPED_STREAMS.append(dst)
|
|
118
126
|
cmd += encode_args(meta, dst, crf, preset) + ["-avoid_negative_ts", "make_zero", dst]
|
|
119
127
|
elif audio_only:
|
|
120
128
|
# output-side seek: an input seek on a video file lands on the previous video keyframe and on
|
|
@@ -130,7 +138,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
130
138
|
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
|
|
131
139
|
if not reencode and tolerance >= 0 and not STATE.dry_run:
|
|
132
140
|
got = probe(dst).get("duration") or 0.0
|
|
133
|
-
if abs(got - dur)
|
|
141
|
+
if abs(got - dur) >= tolerance: # a snap of exactly the tolerance is not "within" it (sweep F20)
|
|
134
142
|
near = keyframes_near(src, start)
|
|
135
143
|
alt = ""
|
|
136
144
|
if near:
|
|
@@ -230,6 +238,7 @@ def main() -> int:
|
|
|
230
238
|
info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
|
|
231
239
|
+ ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
|
|
232
240
|
emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
|
|
241
|
+
dropped_non_av_streams=bool(DROPPED_STREAMS),
|
|
233
242
|
requested_start=round(segments[0][0], 6) if len(segments) == 1 else None,
|
|
234
243
|
requested_end=round(segments[0][1], 6) if len(segments) == 1 else None,
|
|
235
244
|
requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
|
package/scripts/export.py
CHANGED
|
@@ -68,8 +68,10 @@ def main() -> int:
|
|
|
68
68
|
meta = probe(args.input)
|
|
69
69
|
if not meta.get("video"):
|
|
70
70
|
die("input has no video stream")
|
|
71
|
+
notes: List[str] = []
|
|
71
72
|
if meta["video"].get("hdr") and args.preset not in ("prores", "copy"):
|
|
72
|
-
|
|
73
|
+
notes.append("source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
|
|
74
|
+
info("warning: " + notes[-1])
|
|
73
75
|
has_audio = bool(meta.get("audio"))
|
|
74
76
|
output = args.output or default_output(args.input, args.preset, p["ext"])
|
|
75
77
|
out_ext = Path(output).suffix.lstrip(".").lower()
|
|
@@ -92,7 +94,7 @@ def main() -> int:
|
|
|
92
94
|
cmd += ["-filter_complex", fc, "-loop", "0", output]
|
|
93
95
|
run(cmd)
|
|
94
96
|
info(f"wrote {output}")
|
|
95
|
-
emit(output)
|
|
97
|
+
emit(output, **({"notes": notes} if notes else {}))
|
|
96
98
|
return 0
|
|
97
99
|
|
|
98
100
|
if vf:
|
|
@@ -121,7 +123,7 @@ def main() -> int:
|
|
|
121
123
|
result = probe(output, role="output")
|
|
122
124
|
v = result["video"]
|
|
123
125
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
|
|
124
|
-
emit(output)
|
|
126
|
+
emit(output, **({"notes": notes} if notes else {}))
|
|
125
127
|
return 0
|
|
126
128
|
|
|
127
129
|
|
package/scripts/graphics.py
CHANGED
|
@@ -98,6 +98,8 @@ def main() -> int:
|
|
|
98
98
|
|
|
99
99
|
extra_inputs: List[str] = []
|
|
100
100
|
fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
|
|
101
|
+
if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
|
|
102
|
+
die(f"the frame is {W}x{H}; the templates are sized from it and need at least 64 px on the short side")
|
|
101
103
|
if args.template == "lower-third":
|
|
102
104
|
if not args.name:
|
|
103
105
|
die("lower-third needs --name")
|
package/scripts/insert.py
CHANGED
|
@@ -65,6 +65,9 @@ def main() -> int:
|
|
|
65
65
|
meta = probe(args.input)
|
|
66
66
|
if not meta.get("video"):
|
|
67
67
|
die("input has no image/video stream")
|
|
68
|
+
if (meta.get("duration") or 0) > 0.5 or meta.get("audio"):
|
|
69
|
+
die(f"{args.input} is a video, not a still image; insert.py animates a still (Ken Burns). "
|
|
70
|
+
f"For a clip use broll.py (cutaway) or cut.py/join.py")
|
|
68
71
|
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
69
72
|
ratio = sw / sh
|
|
70
73
|
|
package/scripts/join.py
CHANGED
|
@@ -206,7 +206,8 @@ def main() -> int:
|
|
|
206
206
|
expected = sum(durs) - d * (n - 1)
|
|
207
207
|
r = probe(output, role="output")
|
|
208
208
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
209
|
-
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3)
|
|
209
|
+
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3),
|
|
210
|
+
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
|
210
211
|
return 0
|
|
211
212
|
|
|
212
213
|
|
package/scripts/look.py
CHANGED
|
@@ -15,7 +15,7 @@ import sys
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import List
|
|
17
17
|
|
|
18
|
-
from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
|
|
18
|
+
from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
|
|
19
19
|
|
|
20
20
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
21
21
|
|
|
@@ -102,19 +102,28 @@ def main() -> int:
|
|
|
102
102
|
n = cols * rows
|
|
103
103
|
if not dur:
|
|
104
104
|
die("cannot build a contact sheet without a known duration")
|
|
105
|
+
frames_avail = int((meta["video"].get("fps") or 0) * dur) or 1
|
|
106
|
+
if n > frames_avail:
|
|
107
|
+
# a 2x2 sheet from a one-frame clip: tile waits for frames that never come and writes nothing
|
|
108
|
+
cols, rows = min(cols, frames_avail), 1
|
|
109
|
+
n = cols * rows
|
|
110
|
+
info(f"only {frames_avail} frame(s) available; sheet reduced to {cols}x{rows}")
|
|
105
111
|
step = dur / n
|
|
106
112
|
tile_w = max(2, (args.width // cols) // 2 * 2)
|
|
107
113
|
out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
|
|
108
114
|
# sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
|
|
109
115
|
vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc},"
|
|
110
116
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
111
|
-
|
|
117
|
+
# with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
|
|
118
|
+
seek = 0.0 if frames_avail < 4 else step / 2
|
|
119
|
+
cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
|
|
112
120
|
run(cmd)
|
|
113
121
|
outputs.append(out)
|
|
114
122
|
info(f"contact sheet: {n} frames every {step:.2f}s")
|
|
115
123
|
|
|
116
124
|
for o in outputs:
|
|
117
|
-
|
|
125
|
+
if STATE.dry_run or os.path.exists(o):
|
|
126
|
+
info(f"wrote {o}")
|
|
118
127
|
emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs)
|
|
119
128
|
if len(outputs) > 1 and not args.json:
|
|
120
129
|
for o in outputs:
|
package/scripts/loudness.py
CHANGED
|
@@ -18,7 +18,7 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
21
|
+
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
|
|
@@ -64,12 +64,19 @@ def main() -> int:
|
|
|
64
64
|
if stats.get("silent"):
|
|
65
65
|
info("audio is silent (integrated loudness -inf); nothing to normalise")
|
|
66
66
|
if args.measure_only:
|
|
67
|
-
|
|
67
|
+
if STATE.json:
|
|
68
|
+
emit(None, measured={"silent": True, "input_i": "-inf"})
|
|
69
|
+
else:
|
|
70
|
+
print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
|
|
68
71
|
return 0
|
|
69
72
|
die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
|
|
70
73
|
info(f"measured: {float(stats['input_i']):.1f} LUFS, TP {float(stats['input_tp']):.1f} dBTP, LRA {float(stats['input_lra']):.1f} LU")
|
|
71
74
|
if args.measure_only:
|
|
72
|
-
|
|
75
|
+
measured = {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}
|
|
76
|
+
if STATE.json:
|
|
77
|
+
emit(None, measured=measured) # the contract's document shape (status, commands), not a bare dict
|
|
78
|
+
else:
|
|
79
|
+
print(json.dumps(measured, indent=2))
|
|
73
80
|
return 0
|
|
74
81
|
|
|
75
82
|
output = args.output or default_output(args.input, "loudnorm")
|
|
@@ -83,7 +90,10 @@ def main() -> int:
|
|
|
83
90
|
bitrate_pinned = args.audio_bitrate is not None
|
|
84
91
|
bitrate = args.audio_bitrate or "192k"
|
|
85
92
|
|
|
93
|
+
dropped_streams = False
|
|
94
|
+
|
|
86
95
|
def encode(tp: float, bitrate: str) -> None:
|
|
96
|
+
nonlocal dropped_streams
|
|
87
97
|
af = (
|
|
88
98
|
f"loudnorm=I={args.lufs}:TP={tp}:LRA={args.lra}"
|
|
89
99
|
f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
|
|
@@ -94,6 +104,8 @@ def main() -> int:
|
|
|
94
104
|
cmd += ["-vn"] + audio_codec_for(output, bitrate)
|
|
95
105
|
else:
|
|
96
106
|
cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
|
|
107
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
108
|
+
return
|
|
97
109
|
cmd.append(output)
|
|
98
110
|
run(cmd)
|
|
99
111
|
|
|
@@ -142,7 +154,7 @@ def main() -> int:
|
|
|
142
154
|
f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
|
|
143
155
|
kind="verification", output=output, result=result,
|
|
144
156
|
hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
|
|
145
|
-
emit(output, result=result)
|
|
157
|
+
emit(output, result=result, dropped_non_av_streams=dropped_streams)
|
|
146
158
|
return 0
|
|
147
159
|
|
|
148
160
|
|
package/scripts/pad.py
CHANGED
|
@@ -16,7 +16,7 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
19
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def main() -> int:
|
|
@@ -57,7 +57,13 @@ def main() -> int:
|
|
|
57
57
|
cmd += aac_args()
|
|
58
58
|
else:
|
|
59
59
|
cmd += ["-an"]
|
|
60
|
-
|
|
60
|
+
if args.start > 0:
|
|
61
|
+
# a stream-copied subtitle track keeps its timestamps and would fire --start seconds early
|
|
62
|
+
# (sweep F3); drop it and say so, as freeze --mode insert and fit --method speed do
|
|
63
|
+
run(cmd + [output])
|
|
64
|
+
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
65
|
+
else:
|
|
66
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
61
67
|
|
|
62
68
|
result = probe(output, role="output")
|
|
63
69
|
v = result["video"]
|
package/scripts/proxy.py
CHANGED
|
@@ -25,7 +25,7 @@ Examples:
|
|
|
25
25
|
import argparse
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
|
-
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe,
|
|
28
|
+
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def even(n: float) -> int:
|
|
@@ -67,14 +67,13 @@ def main() -> int:
|
|
|
67
67
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", f"scale={out_w}:-2"]
|
|
68
68
|
cmd += video_args(meta, args.crf, "veryfast")
|
|
69
69
|
cmd += cfr_args(meta, args.fps)
|
|
70
|
-
cmd += ["-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"]
|
|
71
|
-
cmd
|
|
72
|
-
run(cmd)
|
|
70
|
+
cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
|
|
71
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
73
72
|
|
|
74
73
|
result = probe(output)
|
|
75
74
|
v = result["video"]
|
|
76
75
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']}, crf {args.crf})")
|
|
77
|
-
emit(output)
|
|
76
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
78
77
|
return 0
|
|
79
78
|
|
|
80
79
|
|
package/scripts/redact.py
CHANGED
|
@@ -74,7 +74,11 @@ def main() -> int:
|
|
|
74
74
|
output = args.output or default_output(args.input, "redact")
|
|
75
75
|
crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
|
|
76
76
|
if args.mode == "blur":
|
|
77
|
-
|
|
77
|
+
# boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
|
|
78
|
+
# so they get their own (halved) radius; a 30 px region with the default 20 used to fail
|
|
79
|
+
radius = max(1, min(args.blur_strength, min(args.width, args.height) // 2 - 1))
|
|
80
|
+
chroma = max(1, min(radius // 2, min(args.width, args.height) // 4 - 1))
|
|
81
|
+
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
78
82
|
else:
|
|
79
83
|
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
|
80
84
|
fc = f"[0:v]split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
|
package/scripts/render.py
CHANGED
|
@@ -141,7 +141,16 @@ def main() -> int:
|
|
|
141
141
|
# shared path, e.g. to inspect intermediates across runs); only the auto-derived default is
|
|
142
142
|
# made unique per process, since it's the one that's also auto-deleted at the end.
|
|
143
143
|
work = Path(args.work) if args.work else Path(f"{Path(output).with_suffix('')}_work_{os.getpid()}")
|
|
144
|
-
|
|
144
|
+
try:
|
|
145
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
except OSError as e:
|
|
147
|
+
die(f"cannot create the work directory {work}: {e}")
|
|
148
|
+
if not args.keep and not args.work:
|
|
149
|
+
# a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
|
|
150
|
+
# auto-named directory is ours alone, so remove it on every exit path
|
|
151
|
+
import atexit
|
|
152
|
+
import shutil
|
|
153
|
+
atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
|
|
145
154
|
frame = proj.get("frame") or {}
|
|
146
155
|
trans = proj.get("transition") or {}
|
|
147
156
|
brand_args: List[str] = ["--brand", rel(proj["brand"])] if proj.get("brand") else []
|
|
@@ -151,6 +160,8 @@ def main() -> int:
|
|
|
151
160
|
parts: List[str] = []
|
|
152
161
|
for i, c in enumerate(clips):
|
|
153
162
|
src = rel(c["src"])
|
|
163
|
+
if not os.path.exists(src):
|
|
164
|
+
die(f"clip {i}: source not found: {src}") # under --dry-run too: a plan for a missing file is no plan
|
|
154
165
|
if not STATE.dry_run:
|
|
155
166
|
probe(src)
|
|
156
167
|
needs_cut = c.get("in") is not None or c.get("out") is not None
|
package/scripts/report.py
CHANGED
|
@@ -158,7 +158,10 @@ def main() -> int:
|
|
|
158
158
|
if STATE.dry_run:
|
|
159
159
|
info(f"wrote {output}") # printed as "[dry-run] would write"; nothing is written
|
|
160
160
|
else:
|
|
161
|
-
|
|
161
|
+
try:
|
|
162
|
+
Path(output).write_text(doc, encoding="utf-8")
|
|
163
|
+
except OSError as e:
|
|
164
|
+
die(f"cannot write {output}: {e}", kind="output")
|
|
162
165
|
info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
|
|
163
166
|
emit(None, report=output, check=chk)
|
|
164
167
|
if not args.json:
|
package/scripts/speedramp.py
CHANGED
|
@@ -115,7 +115,7 @@ def main() -> int:
|
|
|
115
115
|
result = probe(output, role="output")
|
|
116
116
|
v = result["video"]
|
|
117
117
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {len(segments)} speed segments)")
|
|
118
|
-
emit(output)
|
|
118
|
+
emit(output, dropped_non_av_streams=bool(meta.get("subtitle_streams") or meta.get("data_streams")))
|
|
119
119
|
return 0
|
|
120
120
|
|
|
121
121
|
|
package/scripts/verify.py
CHANGED
|
@@ -70,12 +70,15 @@ def collect(paths: List[str]) -> List[Path]:
|
|
|
70
70
|
def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
71
71
|
t0 = time.time()
|
|
72
72
|
if argv[0] == "__check_hdr__":
|
|
73
|
+
was_json, STATE.json = STATE.json, False # probe()'s die() would print a JSON document of its own
|
|
73
74
|
try:
|
|
74
75
|
v = probe(argv[1]).get("video") or {}
|
|
75
76
|
ok = bool(v.get("hdr")) and v.get("bit_depth", 8) >= 10
|
|
76
77
|
err = "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"
|
|
77
78
|
except SystemExit:
|
|
78
79
|
ok, err = False, "output missing"
|
|
80
|
+
finally:
|
|
81
|
+
STATE.json = was_json
|
|
79
82
|
return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
|
|
80
83
|
try:
|
|
81
84
|
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
|
|
@@ -113,12 +116,15 @@ def main() -> int:
|
|
|
113
116
|
for f in files:
|
|
114
117
|
info(f"=== {f}")
|
|
115
118
|
entry: Dict = {"file": str(f), "steps": []}
|
|
119
|
+
was_json, STATE.json = STATE.json, False # same: one JSON document per run, printed at the end
|
|
116
120
|
try:
|
|
117
121
|
meta = probe(str(f))
|
|
118
122
|
except SystemExit:
|
|
119
123
|
entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
|
|
120
124
|
results.append(entry)
|
|
121
125
|
continue
|
|
126
|
+
finally:
|
|
127
|
+
STATE.json = was_json
|
|
122
128
|
entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
|
|
123
129
|
entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
|
|
124
130
|
entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}
|