ffmpeg-skill 2.24.0 → 2.24.1
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 +3 -3
- package/package.json +1 -1
- package/scripts/_common/probe.py +13 -5
- package/scripts/check.py +23 -10
- package/scripts/cut.py +5 -1
- package/scripts/export.py +12 -2
- package/scripts/join.py +60 -9
- package/scripts/render.py +6 -1
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 (`2.24.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.24.1`) | 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`
|
|
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
131
131
|
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
|
|
132
132
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
133
133
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
134
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.24.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.24.1", "execution_mode": "local", "kind": "execution",
|
|
135
135
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
136
136
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
137
137
|
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
|
|
@@ -851,7 +851,7 @@ dry-run measurement of an earlier stage's output), `ffmpeg_list_unreadable` (`ff
|
|
|
851
851
|
tool used to print to stderr only (emoji fallbacks, silent or short inputs, memory-heavy jobs,
|
|
852
852
|
`--strip-dovi` without Dolby Vision, `render.py`'s frame-from-preset and `--normalize` defaults).
|
|
853
853
|
`render.py`'s plan, check and cache records (`check_dry_run`, `check_output_unparsed`, `cache_entry_damaged`, ...),
|
|
854
|
-
`export.py`'s `loudness_not_measured`, `loudness_outside_spec` and `
|
|
854
|
+
`export.py`'s `loudness_not_measured`, `loudness_outside_spec`, `colour_not_converted`, `size_over_limit` and `repair_incomplete`, `multicam.py`'s
|
|
855
855
|
`camera_not_started`, `report.py`'s `report_section_missing` and `batch.py`'s cache records
|
|
856
856
|
(`batch_cache_reset`, `batch_cache_output_changed`, ...) are listed there too. Every one of them
|
|
857
857
|
still prints its stderr line; the record is additive.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.24.
|
|
3
|
+
"version": "2.24.1",
|
|
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/scripts/_common/probe.py
CHANGED
|
@@ -830,7 +830,9 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
|
|
|
830
830
|
|
|
831
831
|
|
|
832
832
|
# What a stream-copy join compares (cut.py --segments, join.py --copy).
|
|
833
|
-
|
|
833
|
+
# codecs that carry no out-of-band configuration in these containers, so no extradata on
|
|
834
|
+
# either side is a match, not a difference
|
|
835
|
+
IN_BAND_CONFIG = ("mp3", "mp2", "vp8", "vp9")
|
|
834
836
|
TS_EXTS = (".ts", ".m2ts", ".mts")
|
|
835
837
|
_SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
|
|
836
838
|
"sample_aspect_ratio", "color_space", "color_range", "color_transfer", "color_primaries"),
|
|
@@ -879,8 +881,9 @@ def signatures_match(sigs: list, ext: str) -> bool:
|
|
|
879
881
|
|
|
880
882
|
# check.py --compare (2.24): the output's picture against a reference, frame by frame.
|
|
881
883
|
COMPARE_SSIM_WARN = 0.98 # mean SSIM below this: a visible difference somewhere is likely
|
|
882
|
-
|
|
883
|
-
|
|
884
|
+
# the channel letters follow the negotiated format: Y U V (or y u v) for YUV, R G B for RGB
|
|
885
|
+
_SSIM_SUMMARY = re.compile(r"SSIM [A-Za-z]:[0-9.]+ .*All:([0-9.]+)")
|
|
886
|
+
_PSNR_SUMMARY = re.compile(r"PSNR [a-z]:\S+ .*average:(\S+)")
|
|
884
887
|
_SSIM_FRAME = re.compile(r"n:(\d+) .*All:([0-9.]+)")
|
|
885
888
|
|
|
886
889
|
|
|
@@ -894,8 +897,13 @@ def compare_pictures(path: str, reference: str) -> "Optional[Dict[str, Any]]":
|
|
|
894
897
|
stats = os.path.join(tmp, "ssim.log")
|
|
895
898
|
# frames paired by index (settb/setpts=N): ssim and psnr otherwise pair by timestamp, so a
|
|
896
899
|
# shifted or irregular timestamp would meet the wrong reference frame
|
|
897
|
-
|
|
898
|
-
|
|
900
|
+
# shortest=1: by default the shorter file's last frame is repeated against the rest of
|
|
901
|
+
# the longer one, which measures pairs that do not exist. format=yuv444p on both: the
|
|
902
|
+
# filters otherwise take the first input's format, so an RGB file against a YUV one
|
|
903
|
+
# measured differently by argument order
|
|
904
|
+
graph = ("[0:V:0]settb=1/1000,setpts=N,format=yuv444p,split[a0][a1];"
|
|
905
|
+
"[1:V:0]settb=1/1000,setpts=N,format=yuv444p,split[b0][b1];"
|
|
906
|
+
f"[a0][b0]ssim=shortest=1:stats_file={escape_filter_path(stats, bind=False)};[a1][b1]psnr=shortest=1")
|
|
899
907
|
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-i", path, "-i", reference,
|
|
900
908
|
"-filter_complex", graph, "-f", "null", "-"], check=False, record=True)
|
|
901
909
|
if proc.returncode != 0:
|
package/scripts/check.py
CHANGED
|
@@ -35,7 +35,7 @@ import json
|
|
|
35
35
|
import re
|
|
36
36
|
import sys
|
|
37
37
|
from fractions import Fraction
|
|
38
|
-
from typing import Any, Dict, List, Optional
|
|
38
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
39
39
|
|
|
40
40
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
|
|
41
41
|
from _common.probe import (COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
|
|
@@ -95,6 +95,12 @@ def frame_time(n: int, v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[L
|
|
|
95
95
|
return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
def display_size(v: Dict[str, Any]) -> "Tuple[int, int]":
|
|
99
|
+
"""The frame size as decoded and auto-rotated, which is what --compare's ffmpeg pass sees."""
|
|
100
|
+
w, h = int(v.get("width") or 0), int(v.get("height") or 0)
|
|
101
|
+
return (h, w) if int(v.get("rotation") or 0) % 180 == 90 else (w, h)
|
|
102
|
+
|
|
103
|
+
|
|
98
104
|
def _span_total(spans: Any) -> float:
|
|
99
105
|
return sum(max(0.0, e - s) for s, e in spans)
|
|
100
106
|
|
|
@@ -147,6 +153,15 @@ def main() -> int:
|
|
|
147
153
|
if args.max_mb is not None:
|
|
148
154
|
spec["max_bytes"] = int(args.max_mb * 1024 * 1024)
|
|
149
155
|
|
|
156
|
+
expected_identity = None
|
|
157
|
+
if args.expect_identity is not None:
|
|
158
|
+
# parsed before any measurement; "" (an unset shell variable) is malformed, not absent
|
|
159
|
+
try:
|
|
160
|
+
size, mtime_ns = (int(x) for x in args.expect_identity.split(":"))
|
|
161
|
+
except ValueError:
|
|
162
|
+
die(f"--expect-identity takes SIZE:MTIME_NS from a tool's output_identity, got {args.expect_identity!r}")
|
|
163
|
+
expected_identity = {"size": size, "mtime_ns": mtime_ns}
|
|
164
|
+
|
|
150
165
|
# read before anything is measured and again after: a file replaced mid-check is not the one graded
|
|
151
166
|
identity_before = None if dry_run_input_pending(args.input) else file_identity(args.input)
|
|
152
167
|
meta = probe(args.input)
|
|
@@ -368,8 +383,8 @@ def main() -> int:
|
|
|
368
383
|
if not v or not rv:
|
|
369
384
|
row("similarity", "WARN", "not compared", "two pictures", "--compare needs a video stream in both files",
|
|
370
385
|
reason="one of the files has no picture")
|
|
371
|
-
elif (v
|
|
372
|
-
row("similarity", "WARN",
|
|
386
|
+
elif display_size(v) != display_size(rv):
|
|
387
|
+
row("similarity", "WARN", "not compared: %dx%d vs %dx%d (as displayed)" % (display_size(v) + display_size(rv)),
|
|
373
388
|
"the same frame size", "compare against a reference at the delivery size (fit.py or export.py it first)",
|
|
374
389
|
reason="SSIM and PSNR compare pixels, so the two frames must be the same size")
|
|
375
390
|
else:
|
|
@@ -393,17 +408,15 @@ def main() -> int:
|
|
|
393
408
|
nref = rv.get("nb_frames") or len(presentation_times(args.compare, float(rv.get("start_time") or 0.0))) or None
|
|
394
409
|
if nref and nout and int(nref) != int(nout):
|
|
395
410
|
STATE_NOTES.append(f"--compare: {nout} frames against the reference's {nref}; frames were paired in "
|
|
396
|
-
f"order, so an added or dropped frame shifts every later pair"
|
|
411
|
+
f"order, so an added or dropped frame shifts every later pair, and only the "
|
|
412
|
+
f"first {min(int(nref), int(nout))} were compared")
|
|
397
413
|
# read after every measurement, --compare's included, so a replacement at any point shows
|
|
398
414
|
identity = None if dry_run_input_pending(args.input) else file_identity(args.input)
|
|
399
|
-
if identity is not None and identity != identity_before and
|
|
415
|
+
if identity is not None and identity != identity_before and expected_identity is None:
|
|
400
416
|
STATE_NOTES.append("the file changed while it was being checked: input_identity is the file as it was "
|
|
401
417
|
"at the end, and the rows may describe an earlier one; check it again")
|
|
402
|
-
if
|
|
403
|
-
|
|
404
|
-
size, mtime_ns = (int(x) for x in args.expect_identity.split(":"))
|
|
405
|
-
except ValueError:
|
|
406
|
-
die(f"--expect-identity takes SIZE:MTIME_NS from a tool's output_identity, got {args.expect_identity!r}")
|
|
418
|
+
if expected_identity is not None:
|
|
419
|
+
size, mtime_ns = expected_identity["size"], expected_identity["mtime_ns"]
|
|
407
420
|
if identity is not None:
|
|
408
421
|
changed = identity != identity_before
|
|
409
422
|
same = identity == {"size": size, "mtime_ns": mtime_ns} and not changed
|
package/scripts/cut.py
CHANGED
|
@@ -80,7 +80,11 @@ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float,
|
|
|
80
80
|
piece anyone asked to keep. Refuses a removal that would leave nothing."""
|
|
81
81
|
least = 1.0 / fps if fps else 0.01
|
|
82
82
|
keep, cursor = [], 0.0
|
|
83
|
-
for s, e in
|
|
83
|
+
outside = [(s, e) for s, e in removed if min(total, e) <= min(total, max(0.0, s))]
|
|
84
|
+
if outside:
|
|
85
|
+
die("--remove " + ", ".join(f"{s:g}-{e:g}" for s, e in outside) + f" lies outside the file (0-{total:.3f}s), "
|
|
86
|
+
"so it removes nothing; check the units (seconds, mm:ss)")
|
|
87
|
+
for s, e in sorted((min(total, max(0.0, s)), min(total, e)) for s, e in removed):
|
|
84
88
|
if e <= cursor:
|
|
85
89
|
continue
|
|
86
90
|
if s - cursor >= least:
|
package/scripts/export.py
CHANGED
|
@@ -249,16 +249,20 @@ _TIMESTAMP_WARNING = re.compile(r"(?i)\b(dts|pts)\b|timestamp")
|
|
|
249
249
|
def media_damage(path: str) -> Dict[str, int]:
|
|
250
250
|
"""One full decode of `path` at -v warning: lines about timestamps (non-monotonic DTS, invalid
|
|
251
251
|
PTS) and every other error line, counted. What --repair is measured by, before and after."""
|
|
252
|
-
|
|
252
|
+
# repeat+: every occurrence on its own line, not "Last message repeated N times", which hid
|
|
253
|
+
# N timestamp warnings and was itself counted as a decode error
|
|
254
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path, "-f", "null", "-"],
|
|
253
255
|
check=False, record=True)
|
|
254
256
|
lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
|
|
255
|
-
|
|
257
|
+
# "Packet corrupt (stream = 0, dts = ...)" names a dts but is damage, not a timestamp problem
|
|
258
|
+
stamps = sum(1 for ln in lines if _TIMESTAMP_WARNING.search(ln) and "corrupt" not in ln.lower())
|
|
256
259
|
return {"timestamp_warnings": stamps, "decode_errors": len(lines) - stamps}
|
|
257
260
|
|
|
258
261
|
|
|
259
262
|
MIB = 1024 * 1024
|
|
260
263
|
SIZE_HEADROOM = 0.97 # of the byte budget given to the streams; the rest is container overhead
|
|
261
264
|
SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; refused instead
|
|
265
|
+
SIZE_MAX_VIDEO_KBPS = 400_000 # 400 Mb/s: above any delivery line, and libx264 refuses far larger values
|
|
262
266
|
|
|
263
267
|
|
|
264
268
|
def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
|
|
@@ -281,6 +285,9 @@ def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool
|
|
|
281
285
|
if has_audio and "-b:a" in p["audio"]:
|
|
282
286
|
audio_kbps = float(p["audio"][p["audio"].index("-b:a") + 1].rstrip("k"))
|
|
283
287
|
video_kbps = args.max_mb * MIB * 8 * SIZE_HEADROOM / dur / 1000 - audio_kbps
|
|
288
|
+
if video_kbps > SIZE_MAX_VIDEO_KBPS:
|
|
289
|
+
die(f"--max-mb {args.max_mb:g} allows {video_kbps / 1000:.0f} Mb/s over {dur:.1f}s, beyond any delivery bitrate; "
|
|
290
|
+
"a limit that large constrains nothing", hint="drop --max-mb (the preset's quality setting applies)")
|
|
284
291
|
if video_kbps < SIZE_MIN_VIDEO_KBPS:
|
|
285
292
|
die(f"--max-mb {args.max_mb:g} leaves {max(video_kbps, 0):.0f} kb/s for the picture over {dur:.1f}s "
|
|
286
293
|
f"(after {audio_kbps:.0f} kb/s of audio); under {SIZE_MIN_VIDEO_KBPS} kb/s it is not watchable",
|
|
@@ -365,6 +372,9 @@ def main() -> int:
|
|
|
365
372
|
vf.append(f"scale={p['w']}:-2")
|
|
366
373
|
if args.repair and args.preset != "copy":
|
|
367
374
|
die("--repair is a remux: use it with --preset copy (a re-encoding preset already rebuilds every frame)")
|
|
375
|
+
if args.max_mb is not None and args.preset == "gif":
|
|
376
|
+
# the GIF branch below returns before the size is ever applied or measured
|
|
377
|
+
die("--max-mb runs a two-pass x264 encode; --preset gif does not encode with x264 (shorten or shrink the GIF instead)")
|
|
368
378
|
cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + ["-i", args.input]
|
|
369
379
|
# Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
|
|
370
380
|
# kept where the container can hold them: ffmpeg's default stream selection takes one video
|
package/scripts/join.py
CHANGED
|
@@ -345,6 +345,45 @@ def _video_packets(path: str, limit: "Optional[int]" = None) -> "List[str]":
|
|
|
345
345
|
return (proc.stdout or "").split() if proc.returncode == 0 else []
|
|
346
346
|
|
|
347
347
|
|
|
348
|
+
SPAN_HEAD_PACKETS = 64 # enough to see the earliest presentation time past B-frame reordering
|
|
349
|
+
SPAN_TAIL_SECONDS = 10.0 # read from this far before the end, so a seek that lands early still reaches it
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _stream_span(path: str, kind: str, duration: float = 0.0, start: float = 0.0) -> "Optional[Tuple[float, float]]":
|
|
353
|
+
"""First packet's time and last packet's time plus its duration for the first `kind` stream
|
|
354
|
+
(V or a): measured, because Matroska/WebM state no stream length and MPEG-TS only an estimate.
|
|
355
|
+
None when the packets cannot be read or one has no time: a partial span would invent a gap."""
|
|
356
|
+
# JSON, not csv: older ffprobe builds (5.1) print side-data rows between the packet rows in csv.
|
|
357
|
+
# A long clip is read at its head and its tail only: every packet of a two-hour file is
|
|
358
|
+
# hundreds of thousands of entries for two numbers
|
|
359
|
+
cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", f"{kind}:0", "-show_entries",
|
|
360
|
+
"packet=pts_time,dts_time,duration_time", "-of", "json"]
|
|
361
|
+
if duration > 3 * SPAN_TAIL_SECONDS:
|
|
362
|
+
# read_intervals takes timestamps, so the tail starts from the file's own start time
|
|
363
|
+
# (an MPEG-TS clock can start hours in; an edit list can start it below zero)
|
|
364
|
+
cmd += ["-read_intervals", f"%+#{SPAN_HEAD_PACKETS},{start + duration - SPAN_TAIL_SECONDS:.3f}%"]
|
|
365
|
+
proc = run(cmd + [path], quiet=True, check=False)
|
|
366
|
+
try:
|
|
367
|
+
packets = json.loads(proc.stdout or "{}").get("packets") or [] if proc.returncode == 0 else []
|
|
368
|
+
except ValueError:
|
|
369
|
+
return None
|
|
370
|
+
times = []
|
|
371
|
+
for pkt in packets:
|
|
372
|
+
try:
|
|
373
|
+
# a packet ffprobe gives no pts (some muxers leave it on B-frames) still has its dts
|
|
374
|
+
t = float(pkt.get("pts_time", pkt.get("dts_time")))
|
|
375
|
+
except (TypeError, ValueError):
|
|
376
|
+
return None
|
|
377
|
+
try:
|
|
378
|
+
d = float(pkt.get("duration_time") or 0.0)
|
|
379
|
+
except (TypeError, ValueError):
|
|
380
|
+
d = 0.0
|
|
381
|
+
times.append((t, t + d))
|
|
382
|
+
if not times:
|
|
383
|
+
return None
|
|
384
|
+
return min(t for t, _ in times), max(e for _, e in times)
|
|
385
|
+
|
|
386
|
+
|
|
348
387
|
def join_copy(args, metas: List[Dict[str, Any]]) -> int:
|
|
349
388
|
"""--copy: the concat demuxer with -c copy, after checking that the clips can be joined that
|
|
350
389
|
way, and measured afterwards (video packet count and length against the clips')."""
|
|
@@ -354,6 +393,11 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
|
|
|
354
393
|
die("--copy keeps every clip's own frame and rate: --width, --height and --fps need a re-encode, so drop --copy")
|
|
355
394
|
if args.sample_rate or args.channels:
|
|
356
395
|
die("--copy keeps every clip's own sound: --sample-rate and --channels need a re-encode, so drop --copy")
|
|
396
|
+
encoder = [f for f, on in (("--codec", STATE.codec), ("--hw", STATE.hw and STATE.hw_source == "flag"),
|
|
397
|
+
("--quality", getattr(args, "quality_given", False)))
|
|
398
|
+
if on]
|
|
399
|
+
if encoder:
|
|
400
|
+
die(f"--copy keeps every clip's own encoding: {', '.join(encoder)} choose(s) an encode, so drop --copy")
|
|
357
401
|
output = args.output or default_output(args.inputs[0], "joined", os.path.splitext(args.inputs[0])[1].lstrip(".") or "mp4")
|
|
358
402
|
guard_sidecar(output, *args.inputs)
|
|
359
403
|
ext = os.path.splitext(output)[1].lower()
|
|
@@ -391,15 +435,19 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
|
|
|
391
435
|
hint="drop --copy, or re-cut those clips with cut.py --accurate", clips=[{"path": p, "differs": {"edit_list": True}} for p in hidden])
|
|
392
436
|
gaps = []
|
|
393
437
|
for p, m in zip(args.inputs, metas):
|
|
394
|
-
# each stream's
|
|
395
|
-
#
|
|
396
|
-
|
|
397
|
-
if not (vs and as_):
|
|
438
|
+
# each stream's span from its packets: Matroska/WebM state no stream length and an
|
|
439
|
+
# MPEG-TS stream length is an estimate, so a stated duration missed or invented gaps
|
|
440
|
+
if not (m.get("video") and m.get("audio")):
|
|
398
441
|
continue
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
442
|
+
length, origin = float(m.get("duration") or 0.0), float(m.get("start_time") or 0.0)
|
|
443
|
+
v_span, a_span = _stream_span(p, "V", length, origin), _stream_span(p, "a", length, origin)
|
|
444
|
+
if v_span is None or a_span is None:
|
|
445
|
+
refuse("input", "join_copy_mismatch",
|
|
446
|
+
f"--copy: could not measure where picture and sound start and end in {p} (a packet without a "
|
|
447
|
+
"time), so a gap at the join could not be ruled out", hint="drop --copy to re-encode the join",
|
|
448
|
+
clips=[{"path": p, "differs": {"av_span": "unmeasured"}}])
|
|
449
|
+
(vb, ve), (ab, ae) = v_span, a_span
|
|
450
|
+
if abs(vb - ab) > COPY_AV_GAP or abs(ve - ae) > COPY_AV_GAP:
|
|
403
451
|
gaps.append({"path": p, "video": [round(vb, 3), None if ve is None else round(ve, 3)],
|
|
404
452
|
"audio": [round(ab, 3), None if ae is None else round(ae, 3)]})
|
|
405
453
|
if gaps:
|
|
@@ -452,7 +500,8 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
|
|
|
452
500
|
info(f"wrote {output} ({fmt_secs(r.get('duration'))}, {n} clips, stream copy)")
|
|
453
501
|
emit(output, mode="copy", **reported(), clips=n, transition="none", reencoded=False,
|
|
454
502
|
verification=verification, expect_streams={"video": 1, "audio": 1 if all(m.get("audio") for m in metas) else 0},
|
|
455
|
-
stream_reference=metas
|
|
503
|
+
stream_reference=metas,
|
|
504
|
+
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
|
456
505
|
return 0
|
|
457
506
|
|
|
458
507
|
|
|
@@ -492,6 +541,8 @@ def main() -> int:
|
|
|
492
541
|
aud.add_argument("--channels", type=int, choices=[1, 2, 6, 8], help="output channel count (default: the widest clip)")
|
|
493
542
|
add_common(ap)
|
|
494
543
|
args = ap.parse_args()
|
|
544
|
+
# given at all, not only different from the default: --quality 18 asks for an encode as much as 23 does
|
|
545
|
+
args.quality_given = any(a == "--quality" or a.startswith("--quality=") for a in sys.argv[1:])
|
|
495
546
|
apply_common(args)
|
|
496
547
|
if args.fps is not None and args.fps <= 0:
|
|
497
548
|
die(f"--fps must be positive, got {args.fps:g}")
|
package/scripts/render.py
CHANGED
|
@@ -1192,7 +1192,12 @@ def main() -> int:
|
|
|
1192
1192
|
work.mkdir(parents=True, exist_ok=True)
|
|
1193
1193
|
except OSError as e:
|
|
1194
1194
|
die(f"cannot create the work directory {work}: {e}")
|
|
1195
|
-
|
|
1195
|
+
# --stop-after before the export reports a stage file inside the work directory as its output:
|
|
1196
|
+
# removing the directory at exit deleted the file the result named (and its output_identity)
|
|
1197
|
+
previewing = bool(args.stop_after) and args.stop_after != "export" and not STATE.dry_run
|
|
1198
|
+
if previewing and not args.keep and not args.work:
|
|
1199
|
+
info(f"--stop-after {args.stop_after}: keeping the work directory {work}, since the result's output is in it")
|
|
1200
|
+
if not args.keep and not args.work and not previewing:
|
|
1196
1201
|
# a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
|
|
1197
1202
|
# auto-named directory is ours alone, so remove it on every exit path
|
|
1198
1203
|
import atexit
|