ffmpeg-skill 2.23.0 → 2.24.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 +17 -17
- package/SKILL.md +54 -56
- package/docs/contract.md +7 -5
- package/package.json +1 -1
- package/references/devices.md +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +48 -8
- package/scripts/_common/drawtext.py +1 -1
- package/scripts/_common/emit.py +14 -0
- package/scripts/_common/probe.py +86 -0
- package/scripts/_common/timeline.py +31 -2
- package/scripts/_contract.py +10 -4
- package/scripts/batch.py +2 -2
- package/scripts/check.py +80 -7
- package/scripts/color.py +4 -2
- package/scripts/cut.py +43 -49
- package/scripts/export.py +103 -5
- package/scripts/join.py +161 -2
- package/scripts/render.py +1 -1
package/scripts/_common/probe.py
CHANGED
|
@@ -827,3 +827,89 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
|
|
|
827
827
|
if duration - cuts[-1] < min_len and len(cuts) > 1:
|
|
828
828
|
cuts.pop()
|
|
829
829
|
return cuts
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# What a stream-copy join compares (cut.py --segments, join.py --copy).
|
|
833
|
+
IN_BAND_CONFIG = ("mp3", "mp2")
|
|
834
|
+
TS_EXTS = (".ts", ".m2ts", ".mts")
|
|
835
|
+
_SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
|
|
836
|
+
"sample_aspect_ratio", "color_space", "color_range", "color_transfer", "color_primaries"),
|
|
837
|
+
"audio": ("codec_name", "profile", "sample_rate", "channels", "time_base")}
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def stream_signature(doc: Dict[str, Any]) -> "List[Dict[str, Any]]":
|
|
841
|
+
"""What a stream-copy join needs to be identical across parts, from one ffprobe document
|
|
842
|
+
(-show_streams with -show_data_hash sha256): each stream's type, codec parameters, rotation
|
|
843
|
+
and extradata hash. Shared by cut.py's --segments join and join.py --copy."""
|
|
844
|
+
sig = []
|
|
845
|
+
for st in doc.get("streams") or []:
|
|
846
|
+
kind = st.get("codec_type")
|
|
847
|
+
entry = {"type": kind, "extradata_hash": st.get("extradata_hash")}
|
|
848
|
+
entry.update({f: st.get(f) for f in _SIG_FIELDS.get(kind, ("codec_name",))})
|
|
849
|
+
if kind == "video":
|
|
850
|
+
entry["rotation"] = next((sd.get("rotation") for sd in st.get("side_data_list") or []
|
|
851
|
+
if "rotation" in sd), None)
|
|
852
|
+
sig.append(entry)
|
|
853
|
+
return sig
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def signatures_match(sigs: list, ext: str) -> bool:
|
|
857
|
+
"""True when every part can be joined by stream copy: same streams in the same order with the
|
|
858
|
+
same parameters. A missing extradata hash only matches another missing one where the codec (or
|
|
859
|
+
an MPEG-TS output) carries its configuration in-band -- PCM has none at all."""
|
|
860
|
+
if not sigs or any(sig is None for sig in sigs):
|
|
861
|
+
return False
|
|
862
|
+
first = sigs[0]
|
|
863
|
+
for sig in sigs[1:]:
|
|
864
|
+
if len(sig) != len(first):
|
|
865
|
+
return False
|
|
866
|
+
for a, b in zip(first, sig):
|
|
867
|
+
if {k: v for k, v in a.items() if k != "extradata_hash"} != {k: v for k, v in b.items() if k != "extradata_hash"}:
|
|
868
|
+
return False
|
|
869
|
+
ha, hb = a.get("extradata_hash"), b.get("extradata_hash")
|
|
870
|
+
if ha or hb:
|
|
871
|
+
if ha != hb:
|
|
872
|
+
return False
|
|
873
|
+
else:
|
|
874
|
+
codec = str(a.get("codec_name") or "")
|
|
875
|
+
if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
|
|
876
|
+
return False
|
|
877
|
+
return True
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# check.py --compare (2.24): the output's picture against a reference, frame by frame.
|
|
881
|
+
COMPARE_SSIM_WARN = 0.98 # mean SSIM below this: a visible difference somewhere is likely
|
|
882
|
+
_SSIM_SUMMARY = re.compile(r"SSIM Y:[0-9.]+ .*All:([0-9.]+)")
|
|
883
|
+
_PSNR_SUMMARY = re.compile(r"PSNR y:\S+ .*average:(\S+)")
|
|
884
|
+
_SSIM_FRAME = re.compile(r"n:(\d+) .*All:([0-9.]+)")
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def compare_pictures(path: str, reference: str) -> "Optional[Dict[str, Any]]":
|
|
888
|
+
"""SSIM (mean and the worst frame) and PSNR (mean) of `path`'s first video stream against
|
|
889
|
+
`reference`'s, frame by frame in decode order, in one pass. ffmpeg's ssim and psnr filters
|
|
890
|
+
need equal sizes; the caller checks that first. None when the pass fails."""
|
|
891
|
+
import tempfile
|
|
892
|
+
from _common.decision import escape_filter_path
|
|
893
|
+
with tempfile.TemporaryDirectory(prefix="ffskill_compare_") as tmp:
|
|
894
|
+
stats = os.path.join(tmp, "ssim.log")
|
|
895
|
+
# frames paired by index (settb/setpts=N): ssim and psnr otherwise pair by timestamp, so a
|
|
896
|
+
# shifted or irregular timestamp would meet the wrong reference frame
|
|
897
|
+
graph = ("[0:V:0]settb=1/1000,setpts=N,split[a0][a1];[1:V:0]settb=1/1000,setpts=N,split[b0][b1];"
|
|
898
|
+
f"[a0][b0]ssim=stats_file={escape_filter_path(stats, bind=False)};[a1][b1]psnr")
|
|
899
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-i", path, "-i", reference,
|
|
900
|
+
"-filter_complex", graph, "-f", "null", "-"], check=False, record=True)
|
|
901
|
+
if proc.returncode != 0:
|
|
902
|
+
return None
|
|
903
|
+
ssim, psnr = _SSIM_SUMMARY.search(proc.stderr), _PSNR_SUMMARY.search(proc.stderr)
|
|
904
|
+
frames = []
|
|
905
|
+
try:
|
|
906
|
+
with open(stats, encoding="utf-8") as fh:
|
|
907
|
+
frames = [(int(m.group(1)), float(m.group(2))) for m in map(_SSIM_FRAME.search, fh) if m]
|
|
908
|
+
except OSError:
|
|
909
|
+
pass
|
|
910
|
+
if not ssim or not frames:
|
|
911
|
+
return None
|
|
912
|
+
worst = min(frames, key=lambda f: f[1])
|
|
913
|
+
mean_psnr = psnr.group(1) if psnr else None
|
|
914
|
+
return {"ssim": float(ssim.group(1)), "worst_ssim": worst[1], "worst_frame": worst[0] - 1, "frames": len(frames),
|
|
915
|
+
"psnr": None if mean_psnr is None else (float("inf") if mean_psnr == "inf" else float(mean_psnr))}
|
|
@@ -492,13 +492,42 @@ def parses(path: str, fmt: str) -> bool:
|
|
|
492
492
|
return why is None
|
|
493
493
|
|
|
494
494
|
|
|
495
|
+
def _xml_root_tag(data: bytes) -> str:
|
|
496
|
+
"""The root element of a well-formed XML document, read with expat directly.
|
|
497
|
+
|
|
498
|
+
ElementTree expands entities declared in an internal DTD, and on Python 3.9/3.10 (both
|
|
499
|
+
supported) that is an entity-expansion ("billion laughs") risk on a file the tool did not
|
|
500
|
+
write. A timeline never declares an entity (to_fcpxml writes a bare `<!DOCTYPE fcpxml>`), so
|
|
501
|
+
a declaration is refused rather than expanded. Errors are ElementTree's ParseError, so a
|
|
502
|
+
failure reads the same as before."""
|
|
503
|
+
from xml.etree.ElementTree import ParseError
|
|
504
|
+
from xml.parsers import expat
|
|
505
|
+
parser = expat.ParserCreate()
|
|
506
|
+
parser.SetParamEntityParsing(expat.XML_PARAM_ENTITY_PARSING_NEVER)
|
|
507
|
+
root: List[str] = []
|
|
508
|
+
|
|
509
|
+
def start(name: str, _attrs: Any) -> None:
|
|
510
|
+
if not root:
|
|
511
|
+
root.append(name)
|
|
512
|
+
|
|
513
|
+
def entity(name: str, *_rest: Any) -> None:
|
|
514
|
+
raise ParseError(f"the document declares the entity {name!r}; a timeline never declares one")
|
|
515
|
+
|
|
516
|
+
parser.StartElementHandler = start
|
|
517
|
+
parser.EntityDeclHandler = entity
|
|
518
|
+
try:
|
|
519
|
+
parser.Parse(data, True)
|
|
520
|
+
except expat.ExpatError as exc:
|
|
521
|
+
raise ParseError(str(exc)) from None
|
|
522
|
+
return root[0]
|
|
523
|
+
|
|
524
|
+
|
|
495
525
|
def parse_error(path: str, fmt: str) -> Optional[str]:
|
|
496
526
|
"""Why `path` does not read back as `fmt`, or None when it does (parses() without the record)."""
|
|
497
527
|
try:
|
|
498
528
|
text = Path(path).read_text(encoding="utf-8")
|
|
499
529
|
if fmt == "fcpxml":
|
|
500
|
-
|
|
501
|
-
tag = ET.fromstring(text.encode("utf-8")).tag
|
|
530
|
+
tag = _xml_root_tag(text.encode("utf-8"))
|
|
502
531
|
return None if tag == "fcpxml" else f"the root element is <{tag}>, not <fcpxml>"
|
|
503
532
|
if fmt == "otio":
|
|
504
533
|
doc = json.loads(text)
|
package/scripts/_contract.py
CHANGED
|
@@ -290,7 +290,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
290
290
|
"audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
|
|
291
291
|
"loudness": dict(video="never", audio="always"),
|
|
292
292
|
"silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
|
|
293
|
-
"join": dict(video="
|
|
293
|
+
"join": dict(video="conditional", audio="conditional", note="--copy --transition none is -c copy of the first video and audio stream (clips with matching parameters only); every other join re-encodes both"),
|
|
294
294
|
"color": dict(video="conditional", audio="conditional", note="--strip-dovi and --retag (when the stream copy succeeds) are -c copy of both streams; --retag falls back to re-encoding only if the copy attempt fails or ffprobe still reads the old tags on the copy (a codec with no metadata bitstream filter here); --to-sdr / --lut / --correct always re-encode both"),
|
|
295
295
|
"proxy": dict(video="always", audio="conditional", note="video is always re-encoded at proxy-grade quality; audio is re-encoded when present, dropped entirely with --no-audio or when the source has none"),
|
|
296
296
|
"export": dict(video="conditional", audio="conditional", note="--preset copy is -c:v copy -c:a copy (no re-encode); every other preset re-encodes both"),
|
|
@@ -417,6 +417,7 @@ ERROR_REASONS = {
|
|
|
417
417
|
"output_is_directory": "the output path is a directory; details: {output}",
|
|
418
418
|
"media_path_is_playlist": "an input is a playlist (HLS .m3u8/.m3u or an ffconcat list, by name or by its first bytes) that ffmpeg would follow to the files it names; the skill takes media files only; details: {path}",
|
|
419
419
|
"media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
|
|
420
|
+
"join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
|
|
420
421
|
"output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
|
|
421
422
|
"output_looks_like_option": "the output starts with '-' (other than '-' for stdout), which ffmpeg would read as an option; ./name writes a file of that name; details: {output}",
|
|
422
423
|
"media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
|
|
@@ -496,6 +497,8 @@ DIAGNOSTIC_CODES = {
|
|
|
496
497
|
"colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
|
|
497
498
|
"output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
|
|
498
499
|
"flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
|
|
500
|
+
"repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
|
|
501
|
+
"size_over_limit": ("warnings", "export.py --max-mb: the written file is over the size asked for (the encoder overshot its bitrate); {size, limit}"),
|
|
499
502
|
"loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
|
|
500
503
|
"camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
|
|
501
504
|
"loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
|
|
@@ -542,6 +545,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
542
545
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
543
546
|
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
|
|
544
547
|
"probe": {"type": "object", "description": "probe of the output when a file was written"},
|
|
548
|
+
"output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
|
|
545
549
|
"encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
|
|
546
550
|
"hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item), on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it, and when this ffmpeg build has no libx264/libx265 and an encode ran on VideoToolbox instead: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export, and for the build without libx264/libx265), source: flag|env|auto (auto: nobody asked, the build has no libx264/libx265 and this Apple Silicon machine ran VideoToolbox in its place), used: true|false whether the encoder that ran last is VideoToolbox, null under --dry-run (nothing ran) and when this process ran no encode itself (batch.py: its stages are child processes; its top-level hw gathers the items' requested/source/fallback/notes and each results row carries its item's encoder and hw), fallback: a job VideoToolbox refused (encoder open or compression session failure; nothing else is retried) was re-encoded on the CPU, notes: why an encode stayed on or fell back to the CPU, and, when $FFMPEG_SKILL_HW chose the GPU and it ran, what that costs and that --no-hw opts out}. A VideoToolbox encode also adds a top-level notes line: its quality is not CRF-equivalent. A kind: ffmpeg failure under --hw / $FFMPEG_SKILL_HW carries encoder and hw too"},
|
|
547
551
|
"plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
|
|
@@ -552,8 +556,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
552
556
|
extra: Dict[str, Any] = {}
|
|
553
557
|
if name == "check":
|
|
554
558
|
extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
|
|
555
|
-
"
|
|
556
|
-
"
|
|
559
|
+
"input_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file graded, the same shape as a writing tool's output_identity"},
|
|
560
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), or when --compare paired a different number of frames than the reference has"},
|
|
561
|
+
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
557
562
|
elif name == "caption":
|
|
558
563
|
extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
|
|
559
564
|
"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
|
|
@@ -613,7 +618,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
613
618
|
elif name == "report":
|
|
614
619
|
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
|
|
615
620
|
elif name == "export":
|
|
616
|
-
extra = {"
|
|
621
|
+
extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
|
|
622
|
+
"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
|
|
617
623
|
"trimmed": {"type": "object", "description": "present when the source was longer than the preset's platform maximum and the export was cut to it (no --allow-long): {from, to} in seconds; notes says so too"},
|
|
618
624
|
"non_av_streams": {"type": "object", "description": "the source's subtitle and data streams: {kept: [{type: subtitle|data, index (n-th of its type), codec, language (subtitle), output_codec}], dropped: [{type, index, codec, language, reason: write_failed|gif|not_in_output}]}, read back from the written file (under --dry-run, as planned). A subtitle track is copied when the container holds it as it is, else converted to the container's text track (mov_text in .mp4/.mov, SubRip in .mkv); a trimmed export cuts it at the same point. Both lists are empty for a source with neither"},
|
|
619
625
|
"dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
|
package/scripts/batch.py
CHANGED
|
@@ -49,7 +49,7 @@ ALLOWED_STEP_SCRIPTS = {p.name for p in HERE.glob("*.py") if not p.name.startswi
|
|
|
49
49
|
|
|
50
50
|
def file_key(path: Path) -> str:
|
|
51
51
|
st = path.stat()
|
|
52
|
-
h = hashlib.sha1()
|
|
52
|
+
h = hashlib.sha1(usedforsecurity=False) # a change detector, not a secret
|
|
53
53
|
h.update(f"{path.name}|{st.st_size}|{int(st.st_mtime)}".encode())
|
|
54
54
|
with open(path, "rb") as fh: # first and last MB: cheap and good enough to detect changes
|
|
55
55
|
h.update(fh.read(1 << 20))
|
|
@@ -75,7 +75,7 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
|
75
75
|
# content, so say so (each item that needs it then fails on reading it)
|
|
76
76
|
warn(f"recipe.project {recipe['project']} could not be read ({exc}); the cache key covers the recipe only",
|
|
77
77
|
"recipe_project_unreadable", path=str(recipe["project"]), error=str(exc))
|
|
78
|
-
return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
|
|
78
|
+
return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode(), usedforsecurity=False).hexdigest()[:12]
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
def gpu_key() -> str:
|
package/scripts/check.py
CHANGED
|
@@ -38,7 +38,9 @@ from fractions import Fraction
|
|
|
38
38
|
from typing import Any, Dict, List, Optional
|
|
39
39
|
|
|
40
40
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
|
|
41
|
-
from _common.probe import CONTENT_FLAT_STD, SILENT_PEAK_DBFS,
|
|
41
|
+
from _common.probe import (COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
|
|
42
|
+
detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
|
|
43
|
+
from _common.emit import file_identity
|
|
42
44
|
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
43
45
|
|
|
44
46
|
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
|
|
@@ -71,20 +73,26 @@ FROZEN_FAIL = 0.95 # a frozen span covering this share =
|
|
|
71
73
|
SILENCE_WARN, SILENCE_FAIL = 0.50, 0.95 # share of the duration below SILENT_PEAK_DBFS
|
|
72
74
|
|
|
73
75
|
|
|
76
|
+
STATE_NOTES: List[str] = []
|
|
77
|
+
|
|
78
|
+
|
|
74
79
|
def flash_times(flashes: List[int], v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[List[float]]" = None) -> str:
|
|
75
80
|
"""The flashes row's first five times on look.py's timeline (seconds from the container
|
|
76
81
|
start). A flash frame's own presentation time when `pts` has it (presentation_times(): exact,
|
|
77
82
|
gaps in the timestamps included); otherwise index / average rate plus the video's lead over
|
|
78
83
|
the container, marked "~" (approximate), and `frame N` when there is no frame rate either."""
|
|
84
|
+
return ", ".join(frame_time(n, v, meta, pts) for n in flashes[:5]) + (" ..." if len(flashes) > 5 else "")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def frame_time(n: int, v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[List[float]]" = None) -> str:
|
|
88
|
+
"""Frame `n` (decode order) on look.py's timeline, as flash_times() describes: exact from
|
|
89
|
+
`pts`, else "~" index / average rate plus the video's lead, else `frame N`."""
|
|
79
90
|
origin = float(meta.get("start_time") or 0.0)
|
|
91
|
+
if pts and n < len(pts):
|
|
92
|
+
return f"{max(0.0, pts[n] - origin):.2f}s"
|
|
80
93
|
fps = float(v.get("fps") or 0.0)
|
|
81
94
|
lead = max(0.0, float(v.get("start_time") or 0.0) - origin)
|
|
82
|
-
|
|
83
|
-
def one(n: int) -> str:
|
|
84
|
-
if pts and n < len(pts):
|
|
85
|
-
return f"{max(0.0, pts[n] - origin):.2f}s"
|
|
86
|
-
return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
|
|
87
|
-
return ", ".join(one(n) for n in flashes[:5]) + (" ..." if len(flashes) > 5 else "")
|
|
95
|
+
return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
|
|
88
96
|
|
|
89
97
|
|
|
90
98
|
def _span_total(spans: Any) -> float:
|
|
@@ -100,6 +108,7 @@ def aspect_name(w: int, h: int) -> str:
|
|
|
100
108
|
|
|
101
109
|
|
|
102
110
|
def main() -> int:
|
|
111
|
+
STATE_NOTES.clear() # module state: a second main() in one process starts without the first's notes
|
|
103
112
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
104
113
|
ap.add_argument("input")
|
|
105
114
|
ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
|
|
@@ -111,6 +120,12 @@ def main() -> int:
|
|
|
111
120
|
ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
|
|
112
121
|
ap.add_argument("--content", action="store_true",
|
|
113
122
|
help="also check the content (one decode pass, plus an audio-only and a video-only one): black, frozen, silence, dropouts, flashes and first frame rows")
|
|
123
|
+
ap.add_argument("--compare", metavar="REFERENCE",
|
|
124
|
+
help="also measure the picture against REFERENCE, frame by frame (SSIM and PSNR, one pass): a "
|
|
125
|
+
"similarity row, WARN when the mean SSIM is under 0.98, with the worst frame's time")
|
|
126
|
+
ap.add_argument("--expect-identity", metavar="SIZE:MTIME_NS",
|
|
127
|
+
help="the output_identity a writing tool reported ({size}:{mtime_ns}): an identity row FAILs when "
|
|
128
|
+
"the file at INPUT is not that file (a later run replaced it, or a failed run left an older one)")
|
|
114
129
|
add_common(ap)
|
|
115
130
|
args = ap.parse_args()
|
|
116
131
|
apply_common(args)
|
|
@@ -132,6 +147,8 @@ def main() -> int:
|
|
|
132
147
|
if args.max_mb is not None:
|
|
133
148
|
spec["max_bytes"] = int(args.max_mb * 1024 * 1024)
|
|
134
149
|
|
|
150
|
+
# read before anything is measured and again after: a file replaced mid-check is not the one graded
|
|
151
|
+
identity_before = None if dry_run_input_pending(args.input) else file_identity(args.input)
|
|
135
152
|
meta = probe(args.input)
|
|
136
153
|
v, a = meta.get("video") or {}, meta.get("audio") or {}
|
|
137
154
|
rows: List[Dict[str, Any]] = []
|
|
@@ -345,11 +362,65 @@ def main() -> int:
|
|
|
345
362
|
"again); a noise gate makes these on purpose" if gaps else "",
|
|
346
363
|
reason="a short stretch of digital silence in the middle of the sound is usually lost audio at a join or a glitched decode" if gaps else "")
|
|
347
364
|
|
|
365
|
+
if args.compare and not dry_run_input_pending(args.input) and not dry_run_input_pending(args.compare):
|
|
366
|
+
ref = probe(args.compare)
|
|
367
|
+
rv = ref.get("video") or {}
|
|
368
|
+
if not v or not rv:
|
|
369
|
+
row("similarity", "WARN", "not compared", "two pictures", "--compare needs a video stream in both files",
|
|
370
|
+
reason="one of the files has no picture")
|
|
371
|
+
elif (v.get("width"), v.get("height")) != (rv.get("width"), rv.get("height")):
|
|
372
|
+
row("similarity", "WARN", f"not compared: {v.get('width')}x{v.get('height')} vs {rv.get('width')}x{rv.get('height')}",
|
|
373
|
+
"the same frame size", "compare against a reference at the delivery size (fit.py or export.py it first)",
|
|
374
|
+
reason="SSIM and PSNR compare pixels, so the two frames must be the same size")
|
|
375
|
+
else:
|
|
376
|
+
cmp_ = compare_pictures(args.input, args.compare)
|
|
377
|
+
if cmp_ is None:
|
|
378
|
+
row("similarity", "WARN", "could not measure", f"mean SSIM >= {COMPARE_SSIM_WARN:g}",
|
|
379
|
+
"look.py --compare at a few times", reason="the comparison pass failed")
|
|
380
|
+
else:
|
|
381
|
+
pts = presentation_times(args.input, float(v.get("start_time") or 0.0))
|
|
382
|
+
at = frame_time(cmp_["worst_frame"], v, meta, pts)
|
|
383
|
+
seek = "0" if at.startswith("frame") else at.lstrip("~").rstrip("s")
|
|
384
|
+
psnr = "identical" if cmp_["psnr"] == float("inf") else (f"{cmp_['psnr']:.1f} dB" if cmp_["psnr"] is not None else "?")
|
|
385
|
+
low = cmp_["ssim"] < COMPARE_SSIM_WARN
|
|
386
|
+
row("similarity", "WARN" if low else "PASS",
|
|
387
|
+
f"SSIM {cmp_['ssim']:.4f} (worst {cmp_['worst_ssim']:.4f} at {at}), PSNR {psnr}, {cmp_['frames']} frames",
|
|
388
|
+
f"mean SSIM >= {COMPARE_SSIM_WARN:g}",
|
|
389
|
+
f"look.py --compare {args.compare} --at {seek}" if low else "",
|
|
390
|
+
reason="the picture differs visibly from the reference somewhere" if low else "")
|
|
391
|
+
# frames counted from the packets when a container does not state nb_frames
|
|
392
|
+
nout = v.get("nb_frames") or len(pts) or None
|
|
393
|
+
nref = rv.get("nb_frames") or len(presentation_times(args.compare, float(rv.get("start_time") or 0.0))) or None
|
|
394
|
+
if nref and nout and int(nref) != int(nout):
|
|
395
|
+
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")
|
|
397
|
+
# read after every measurement, --compare's included, so a replacement at any point shows
|
|
398
|
+
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 not args.expect_identity:
|
|
400
|
+
STATE_NOTES.append("the file changed while it was being checked: input_identity is the file as it was "
|
|
401
|
+
"at the end, and the rows may describe an earlier one; check it again")
|
|
402
|
+
if args.expect_identity:
|
|
403
|
+
try:
|
|
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}")
|
|
407
|
+
if identity is not None:
|
|
408
|
+
changed = identity != identity_before
|
|
409
|
+
same = identity == {"size": size, "mtime_ns": mtime_ns} and not changed
|
|
410
|
+
row("identity", "PASS" if same else "FAIL", f"{identity['size']}:{identity['mtime_ns']}", f"{size}:{mtime_ns}",
|
|
411
|
+
"" if same else "re-run the tool that writes this file and check what it reports, not what the path holds",
|
|
412
|
+
reason="" if same else ("the file changed while it was being checked" if changed
|
|
413
|
+
else "the file at this path is not the one the run being checked wrote"))
|
|
414
|
+
elif not dry_run_input_pending(args.input):
|
|
415
|
+
row("identity", "FAIL", "no regular file at this path", f"{size}:{mtime_ns}",
|
|
416
|
+
"re-run the tool that writes this file and check what it reports, not what the path holds",
|
|
417
|
+
reason="the file at this path is not the one the run being checked wrote")
|
|
348
418
|
failed = [r for r in rows if r["status"] == "FAIL"]
|
|
349
419
|
warned = [r for r in rows if r["status"] == "WARN"]
|
|
350
420
|
notes: List[str] = []
|
|
351
421
|
if not named:
|
|
352
422
|
notes.append("no --platform given: youtube's spec was assumed, so judgement rows (duration, aspect, fps, resolution, loudness, true peak) are WARN, not FAIL; name a platform to enforce them")
|
|
423
|
+
notes += STATE_NOTES
|
|
353
424
|
if not args.json:
|
|
354
425
|
width = max(len(r["check"]) for r in rows)
|
|
355
426
|
print(f"{args.input} — {args.platform}" + ("" if named else " (assumed)"))
|
|
@@ -366,6 +437,8 @@ def main() -> int:
|
|
|
366
437
|
for n in notes:
|
|
367
438
|
print(f" note: {n}")
|
|
368
439
|
extra: Dict[str, Any] = {"notes": notes} if notes else {}
|
|
440
|
+
if identity is not None:
|
|
441
|
+
extra["input_identity"] = identity
|
|
369
442
|
if failed:
|
|
370
443
|
die(f"{len(failed)} of {len(rows)} {args.platform} checks failed: {', '.join(r['check'] for r in failed)}",
|
|
371
444
|
kind="verification", output=None, dry_run=STATE.dry_run,
|
package/scripts/color.py
CHANGED
|
@@ -26,7 +26,9 @@ from typing import List, Optional
|
|
|
26
26
|
from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
27
27
|
from _common.emit import warn
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
# the curves of FFmpeg's CPU `tonemap` filter (every version from 5.1 to 9). bt2390 was listed
|
|
30
|
+
# until 2.24 but exists only in tonemap_opencl/libplacebo, so choosing it always failed.
|
|
31
|
+
TONEMAPS = ["hable", "mobius", "reinhard", "clip", "linear", "gamma"]
|
|
30
32
|
|
|
31
33
|
# Typed primary correction: each flag is one option of one real, always-available libavfilter filter
|
|
32
34
|
# (never a caller-supplied filter string). Range is this script's own safe subset of what the filter
|
|
@@ -164,7 +166,7 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str
|
|
|
164
166
|
f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear:npl={peak:g}",
|
|
165
167
|
"format=gbrpf32le",
|
|
166
168
|
"zscale=p=bt709",
|
|
167
|
-
f"tonemap=tonemap={tonemap}:desat={desat:g}"
|
|
169
|
+
f"tonemap=tonemap={tonemap}:desat={desat:g}",
|
|
168
170
|
"zscale=t=bt709:m=bt709:r=tv",
|
|
169
171
|
"format=yuv420p",
|
|
170
172
|
]
|
package/scripts/cut.py
CHANGED
|
@@ -57,6 +57,7 @@ from typing import List, Tuple
|
|
|
57
57
|
|
|
58
58
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
59
59
|
from _common import require_tool
|
|
60
|
+
from _common.probe import TS_EXTS, signatures_match, stream_signature
|
|
60
61
|
from _common import sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
61
62
|
|
|
62
63
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
@@ -73,7 +74,28 @@ def _t(value: str, fps, flag: str = "--segments") -> float:
|
|
|
73
74
|
return time_arg(value, flag, fps)
|
|
74
75
|
|
|
75
76
|
|
|
76
|
-
def
|
|
77
|
+
def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None) -> List[Tuple[float, float]]:
|
|
78
|
+
"""--remove: what is left of [0, total] once `removed` is dropped (merged where they overlap,
|
|
79
|
+
clamped to the file). A leftover shorter than one frame (10 ms without a frame rate) is not a
|
|
80
|
+
piece anyone asked to keep. Refuses a removal that would leave nothing."""
|
|
81
|
+
least = 1.0 / fps if fps else 0.01
|
|
82
|
+
keep, cursor = [], 0.0
|
|
83
|
+
for s, e in sorted((max(0.0, s), min(total, e)) for s, e in removed):
|
|
84
|
+
if e <= cursor:
|
|
85
|
+
continue
|
|
86
|
+
if s - cursor >= least:
|
|
87
|
+
keep.append((cursor, s))
|
|
88
|
+
cursor = max(cursor, e)
|
|
89
|
+
if total - cursor >= least:
|
|
90
|
+
keep.append((cursor, total))
|
|
91
|
+
if not keep:
|
|
92
|
+
die(f"--remove drops the whole file ({total:.3f}s); nothing would be left to keep")
|
|
93
|
+
return keep
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_segments(spec: str, fps=None, clamp: bool = False) -> List[Tuple[float, float]]:
|
|
97
|
+
"""START-END,... as seconds. `clamp` (--remove) lets a range start before 0, since the file
|
|
98
|
+
clamps it; --segments refuses that."""
|
|
77
99
|
segs = []
|
|
78
100
|
for raw in spec.split(","):
|
|
79
101
|
raw = raw.strip()
|
|
@@ -83,7 +105,7 @@ def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
|
|
|
83
105
|
die(f"segment '{raw}' must look like START-END (e.g. 0:05-0:12)")
|
|
84
106
|
a, b = raw.rsplit("-", 1)
|
|
85
107
|
start, end = _t(a, fps), _t(b, fps)
|
|
86
|
-
if start < 0:
|
|
108
|
+
if start < 0 and not clamp:
|
|
87
109
|
die(f"segment '{raw}': start must be >= 0")
|
|
88
110
|
if end <= start:
|
|
89
111
|
die(f"segment '{raw}': end must be after start")
|
|
@@ -927,13 +949,6 @@ DECLICK_S = 0.02
|
|
|
927
949
|
# keyframe still decodes from the keyframe BEFORE it
|
|
928
950
|
SEEK_MARGIN = 1.0
|
|
929
951
|
# codecs whose configuration travels in the packets, so a part with no extradata is not missing any
|
|
930
|
-
IN_BAND_CONFIG = ("mp3", "mp2")
|
|
931
|
-
TS_EXTS = (".ts", ".m2ts", ".mts")
|
|
932
|
-
_SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
|
|
933
|
-
"sample_aspect_ratio", "color_transfer", "color_primaries"),
|
|
934
|
-
"audio": ("codec_name", "profile", "sample_rate", "channels", "time_base")}
|
|
935
|
-
|
|
936
|
-
|
|
937
952
|
_PARTS: dict = {}
|
|
938
953
|
|
|
939
954
|
|
|
@@ -952,21 +967,10 @@ def _part_probe(path: str):
|
|
|
952
967
|
|
|
953
968
|
|
|
954
969
|
def join_signature(path: str):
|
|
955
|
-
"""What a stream-copy join needs to be identical across parts
|
|
956
|
-
|
|
970
|
+
"""What a stream-copy join needs to be identical across parts (_common.probe.stream_signature).
|
|
971
|
+
None when ffprobe cannot read the part."""
|
|
957
972
|
doc = _part_probe(path)
|
|
958
|
-
if doc is None
|
|
959
|
-
return None
|
|
960
|
-
sig = []
|
|
961
|
-
for st in doc["streams"]:
|
|
962
|
-
kind = st.get("codec_type")
|
|
963
|
-
entry = {"type": kind, "extradata_hash": st.get("extradata_hash")}
|
|
964
|
-
entry.update({f: st.get(f) for f in _SIG_FIELDS.get(kind, ("codec_name",))})
|
|
965
|
-
if kind == "video":
|
|
966
|
-
entry["rotation"] = next((sd.get("rotation") for sd in st.get("side_data_list") or []
|
|
967
|
-
if "rotation" in sd), None)
|
|
968
|
-
sig.append(entry)
|
|
969
|
-
return sig
|
|
973
|
+
return None if doc is None else stream_signature(doc)
|
|
970
974
|
|
|
971
975
|
|
|
972
976
|
# how far a measured join step may be from the predicted one: ffprobe prints the parts' times
|
|
@@ -995,30 +999,6 @@ def predicted_join_steps(parts) -> list:
|
|
|
995
999
|
return [None if a is None or b is None else (a[1] - a[3]) + (b[2] - b[0]) for a, b in zip(spans, spans[1:])]
|
|
996
1000
|
|
|
997
1001
|
|
|
998
|
-
def signatures_match(sigs: list, ext: str) -> bool:
|
|
999
|
-
"""True when every part can be joined by stream copy: same streams in the same order with the
|
|
1000
|
-
same parameters. A missing extradata hash only matches another missing one where the codec (or
|
|
1001
|
-
an MPEG-TS output) carries its configuration in-band -- PCM has none at all."""
|
|
1002
|
-
if not sigs or any(sig is None for sig in sigs):
|
|
1003
|
-
return False
|
|
1004
|
-
first = sigs[0]
|
|
1005
|
-
for sig in sigs[1:]:
|
|
1006
|
-
if len(sig) != len(first):
|
|
1007
|
-
return False
|
|
1008
|
-
for a, b in zip(first, sig):
|
|
1009
|
-
if {k: v for k, v in a.items() if k != "extradata_hash"} != {k: v for k, v in b.items() if k != "extradata_hash"}:
|
|
1010
|
-
return False
|
|
1011
|
-
ha, hb = a.get("extradata_hash"), b.get("extradata_hash")
|
|
1012
|
-
if ha or hb:
|
|
1013
|
-
if ha != hb:
|
|
1014
|
-
return False
|
|
1015
|
-
else:
|
|
1016
|
-
codec = str(a.get("codec_name") or "")
|
|
1017
|
-
if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
|
|
1018
|
-
return False
|
|
1019
|
-
return True
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
1002
|
def _fraction_rate(text: str) -> float:
|
|
1023
1003
|
""""30000/1001" or "30" as a number."""
|
|
1024
1004
|
num, _, den = str(text).partition("/")
|
|
@@ -1316,10 +1296,13 @@ def main() -> int:
|
|
|
1316
1296
|
ap.add_argument("input")
|
|
1317
1297
|
ap.add_argument("-o", "--output", help="output file (default: <name>_cut.<ext>)")
|
|
1318
1298
|
g = ap.add_argument_group("range (single segment)")
|
|
1319
|
-
g.add_argument("--start",
|
|
1299
|
+
g.add_argument("--start", help="start time (seconds, mm:ss or hh:mm:ss.ms). default 0")
|
|
1320
1300
|
g.add_argument("--end", help="end time")
|
|
1321
1301
|
g.add_argument("--duration", help="duration instead of --end")
|
|
1322
1302
|
ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
|
|
1303
|
+
ap.add_argument("--remove", metavar="START-END[,...]",
|
|
1304
|
+
help="the inverse of --segments: drop these ranges and keep the rest, joined in order "
|
|
1305
|
+
"(e.g. '1:20-2:05,10:00-10:30'); overlapping ranges merge")
|
|
1323
1306
|
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
|
|
1324
1307
|
ap.add_argument("--edit-list", action="store_true", help="a single-segment .mp4/.mov stream copy keeps the MP4 edit list that hides the keyframe's pre-roll, so the picture starts at --start (default: -avoid_negative_ts make_zero, the picture starts at the keyframe); a player that ignores edit lists shows the pre-roll")
|
|
1325
1308
|
ap.add_argument("--keep-hevc", action="store_true", help="a re-encode of an SDR HEVC source stays HEVC (x265 8-bit BT.709; VideoToolbox under --hw) instead of x264 (default: x264 for SDR, x265 Main10 for HDR); --codec overrides it")
|
|
@@ -1369,10 +1352,21 @@ def main() -> int:
|
|
|
1369
1352
|
forced.append("codec")
|
|
1370
1353
|
|
|
1371
1354
|
fps = (meta.get("video") or {}).get("fps")
|
|
1355
|
+
if args.remove:
|
|
1356
|
+
if args.segments or args.end or args.duration or args.start is not None:
|
|
1357
|
+
die("--remove names what to drop from the whole file: it does not combine with --segments, --start, --end or --duration")
|
|
1358
|
+
if args.snap == "beats":
|
|
1359
|
+
# a snapped edge of what is kept could move into a range the caller asked to drop
|
|
1360
|
+
die("--remove drops exactly the ranges given: --snap beats would move the kept edges, possibly into them; "
|
|
1361
|
+
"snap the ranges first (scenes.py --beats) or cut with --segments --snap beats")
|
|
1362
|
+
if not total:
|
|
1363
|
+
die(f"--remove needs the input's duration, which {args.input} does not report"
|
|
1364
|
+
+ (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
|
|
1365
|
+
args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(parse_segments(args.remove, fps, clamp=True), total, fps))
|
|
1372
1366
|
if args.segments:
|
|
1373
1367
|
segments = parse_segments(args.segments, fps)
|
|
1374
1368
|
else:
|
|
1375
|
-
start = _t(args.start, fps, "--start")
|
|
1369
|
+
start = _t(args.start or "0", fps, "--start")
|
|
1376
1370
|
if start < 0:
|
|
1377
1371
|
die(f"--start must not be negative, got {args.start!r}")
|
|
1378
1372
|
if args.end and args.duration:
|