ffmpeg-skill 2.22.5 → 2.23.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 +1 -1
- package/docs/contract.md +4 -3
- package/package.json +1 -1
- package/references/scripts.md +6 -1
- package/scripts/_common/probe.py +88 -2
- package/scripts/_contract.py +1 -0
- package/scripts/check.py +51 -5
package/README.md
CHANGED
|
@@ -270,7 +270,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
270
270
|
|---|---|
|
|
271
271
|
| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
|
|
272
272
|
| `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
|
|
273
|
-
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence
|
|
273
|
+
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence, short audio-dropout, single-frame-flash and flat-first-frame rows |
|
|
274
274
|
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
|
|
275
275
|
|
|
276
276
|
**Orchestration**
|
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
|
+
| `skill.version` | the npm / package.json version (`2.23.0`) | 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.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.23.0", "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"},
|
|
@@ -206,7 +206,8 @@ rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user).
|
|
|
206
206
|
The `audio` row FAILs (WARN when no loudness target applies) when the track is present but silent (peak at or below -50 dBFS, from
|
|
207
207
|
the loudness pass or, when loudness is skipped, one volumedetect pass). `check --content`
|
|
208
208
|
(opt-in; render.py `"check": {"content": true}`) adds `black`, `frozen` and `silence` rows
|
|
209
|
-
from one decode pass,
|
|
209
|
+
from one decode pass, `dropouts` (2.17) from one audio-only pass, and `flashes` and `first frame` (2.23) from one
|
|
210
|
+
video-only pass and a one-frame decode; without it the row set is unchanged. Thresholds:
|
|
210
211
|
`docs/design-decisions.md` ("check.py --content").
|
|
211
212
|
|
|
212
213
|
### Dry run
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.23.0",
|
|
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
|
@@ -1142,7 +1142,12 @@ duration), FAIL when it covers >= 95%, i.e. the whole video) and `silence` (shar
|
|
|
1142
1142
|
-50 dBFS: WARN > 50%, FAIL >= 95%); since 2.17 one more audio-only pass adds `dropouts`
|
|
1143
1143
|
(gaps of digital silence, <= -80 dBFS for 20 ms to 1 s with sound on both sides -- lost audio
|
|
1144
1144
|
at a join, a glitched decode; WARN when there is any, never FAIL, because a noise gate makes
|
|
1145
|
-
them on purpose).
|
|
1145
|
+
them on purpose); since 2.23 one more video pass adds `flashes` (single frames that differ from
|
|
1146
|
+
both neighbours by >= 12/255 mean luma while the neighbours differ by at most a quarter of that
|
|
1147
|
+
-- a stray frame at a cut, a decode glitch; WARN when there is any, with the first five times,
|
|
1148
|
+
never FAIL, because a camera flash or a strobe is real) and a one-frame decode adds `first frame`
|
|
1149
|
+
(WARN when it is one flat colour, luma spread < 4/255: the frame a feed's autoplay shows first).
|
|
1150
|
+
A render project enables it with `"check": {"platform":
|
|
1146
1151
|
"reels", "content": true}`.
|
|
1147
1152
|
|
|
1148
1153
|
### batch.py — same recipe over a folder, cached
|
package/scripts/_common/probe.py
CHANGED
|
@@ -12,7 +12,7 @@ import re
|
|
|
12
12
|
import statistics
|
|
13
13
|
from fractions import Fraction
|
|
14
14
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
|
-
from _common.emit import die
|
|
15
|
+
from _common.emit import die, warn
|
|
16
16
|
from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis
|
|
17
17
|
|
|
18
18
|
|
|
@@ -605,6 +605,15 @@ CONTENT_DROPOUT_MIN, CONTENT_DROPOUT_MAX = 0.02, 1.0 # seconds
|
|
|
605
605
|
CONTENT_DROPOUT_EDGE = 0.05 # a gap this close to the start or end is a lead-in/out, not a dropout
|
|
606
606
|
CONTENT_DROPOUT_PAD = 2.0 # zeros appended before the dropout pass; > MAX, so a tail that runs into it never counts
|
|
607
607
|
|
|
608
|
+
# 2.23: a flash is one frame that differs from both of its neighbours while they match each other
|
|
609
|
+
# (a stray frame from another shot, a white or black frame at a join, a decode glitch). Measured on
|
|
610
|
+
# 64x36 grey frames as the mean absolute luma difference (0-255): moving footage stays at a few
|
|
611
|
+
# units, a cut or a flash moves the whole frame by tens.
|
|
612
|
+
CONTENT_FLASH_JUMP = 12.0 # both steps into and out of the frame are at least this
|
|
613
|
+
CONTENT_FLASH_RETURN = 0.25 # ...and the frames either side differ by at most this share of the smaller step
|
|
614
|
+
CONTENT_FLAT_STD = 4.0 # first frame: luma standard deviation (0-255) below this is one flat colour
|
|
615
|
+
_DIFF_W, _DIFF_H = 64, 36
|
|
616
|
+
|
|
608
617
|
_BLACK_RE = re.compile(r"black_start:\s*([0-9.]+)\s+black_end:\s*([0-9.]+)")
|
|
609
618
|
_FREEZE_RE = re.compile(r"lavfi\.freezedetect\.freeze_(start|end):\s*([0-9.]+)")
|
|
610
619
|
|
|
@@ -619,7 +628,7 @@ def detect_content(path: str, duration: float, *, video: bool = True, audio: boo
|
|
|
619
628
|
ffmpeg = require_tool("ffmpeg")
|
|
620
629
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path]
|
|
621
630
|
if video:
|
|
622
|
-
cmd += ["-map", "0:
|
|
631
|
+
cmd += ["-map", "0:V:0", "-vf", f"blackdetect=d={CONTENT_MIN_SPAN}:pix_th={CONTENT_BLACK_PIX_TH},"
|
|
623
632
|
f"freezedetect=n={CONTENT_FREEZE_NOISE}:d={CONTENT_FREEZE_MIN}"]
|
|
624
633
|
if audio:
|
|
625
634
|
cmd += ["-map", "0:a:0", "-af", f"silencedetect=noise={SILENT_PEAK_DBFS:g}dB:d={CONTENT_SILENCE_MIN}"]
|
|
@@ -684,6 +693,83 @@ def _detect_dropouts(path: str, end: float) -> "List[Tuple[float, float]]":
|
|
|
684
693
|
return gaps # a gap still open at the end is a tail, never a dropout
|
|
685
694
|
|
|
686
695
|
|
|
696
|
+
def detect_flashes(path: str) -> "Optional[List[int]]":
|
|
697
|
+
"""Frame indexes of single-frame flashes in the first video stream: frame n differs from n-1 and
|
|
698
|
+
from n+1 by at least CONTENT_FLASH_JUMP while n-1 and n+1 differ by at most
|
|
699
|
+
CONTENT_FLASH_RETURN of the smaller step. One video-only pass; ffmpeg reduces each frame to
|
|
700
|
+
three 16-bit numbers (|f[n]-f[n-1]| and the two clipped halves of f[n]-f[n-2]): 6 bytes a
|
|
701
|
+
frame in the captured output (1.3 MB for an hour at 60 fps), read in place. The caller turns an index into a time;
|
|
702
|
+
the comparison itself needs no frame rate. None when no frame decoded or the pass failed
|
|
703
|
+
(nothing was compared; a failure is also a flash_pass_failed warning)."""
|
|
704
|
+
if dry_run_input_pending(path):
|
|
705
|
+
return []
|
|
706
|
+
graph = (f"[0:V:0]scale={_DIFF_W}:{_DIFF_H}:flags=area,format=gray16le,split=3[a][b][c];"
|
|
707
|
+
"[a]tblend=all_mode=difference,scale=1:1:flags=area[d];"
|
|
708
|
+
"[b]tmix=frames=3:weights='-1 0 1':scale=1,scale=1:1:flags=area[p];"
|
|
709
|
+
"[c]tmix=frames=3:weights='1 0 -1':scale=1,scale=1:1:flags=area[q];"
|
|
710
|
+
"[d][p][q]hstack=inputs=3")
|
|
711
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
|
|
712
|
+
"-filter_complex", graph, "-fps_mode", "passthrough", "-f", "rawvideo", "-"],
|
|
713
|
+
check=False, text=False, record=True)
|
|
714
|
+
if proc.returncode != 0:
|
|
715
|
+
# an unmeasured row, not the end of the report: the other content rows stand
|
|
716
|
+
warn("flash detection failed: " + (proc.stderr.decode(errors="replace").strip().splitlines() or ["ffmpeg exited " + str(proc.returncode)])[-1][:300],
|
|
717
|
+
"flash_pass_failed")
|
|
718
|
+
return None
|
|
719
|
+
# tblend has no output for frame 0, so record r is frame r+1: a zero record in front puts
|
|
720
|
+
# every value at its own frame's index. passthrough keeps the rawvideo muxer from padding a
|
|
721
|
+
# constant rate, which repeated records over timestamp gaps (a repeat is a zero step, and a
|
|
722
|
+
# flash before a gap went unreported)
|
|
723
|
+
if len(proc.stdout) < 6:
|
|
724
|
+
return None
|
|
725
|
+
data = bytes(6) + proc.stdout
|
|
726
|
+
n = len(data) // 6
|
|
727
|
+
|
|
728
|
+
def record(i: int) -> "Tuple[float, float]":
|
|
729
|
+
"""(|f[i] - f[i-1]|, |f[i] - f[i-2]|), 0-255; read from the buffer, never kept per frame."""
|
|
730
|
+
d, p, q = (int.from_bytes(data[i * 6 + k:i * 6 + k + 2], "little") / 256.0 for k in (0, 2, 4))
|
|
731
|
+
return d, p + q
|
|
732
|
+
|
|
733
|
+
flashes = []
|
|
734
|
+
into = record(1)[0] if n > 1 else 0.0
|
|
735
|
+
for i in range(1, n - 1):
|
|
736
|
+
out, back = record(i + 1)
|
|
737
|
+
if into >= CONTENT_FLASH_JUMP and out >= CONTENT_FLASH_JUMP and back <= CONTENT_FLASH_RETURN * min(into, out):
|
|
738
|
+
flashes.append(i)
|
|
739
|
+
into = out
|
|
740
|
+
return flashes
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def presentation_times(path: str, start: float) -> "List[float]":
|
|
744
|
+
"""The presentation time of every frame of the first video stream (attached pictures
|
|
745
|
+
excluded), in order: its packets' pts sorted, those before the stream's own start (an edit
|
|
746
|
+
list's hidden pre-roll, never shown) dropped. From packets, so no decode. [] when unreadable,
|
|
747
|
+
also when one packet has no pts: skipping it would shift every later time onto the wrong frame."""
|
|
748
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "V:0", "-show_entries", "packet=pts_time",
|
|
749
|
+
"-of", "csv=p=0", path], quiet=True, check=False)
|
|
750
|
+
times = []
|
|
751
|
+
for line in (proc.stdout or "").split() if proc.returncode == 0 else []:
|
|
752
|
+
try:
|
|
753
|
+
times.append(float(line.split(",")[0]))
|
|
754
|
+
except ValueError:
|
|
755
|
+
return []
|
|
756
|
+
return sorted(t for t in times if t >= start - 1e-6)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def first_frame_stats(path: str) -> "Optional[Tuple[float, float]]":
|
|
760
|
+
"""(mean, standard deviation) of the first frame's luma, 0-255, at 64x36; None when it does
|
|
761
|
+
not decode or the input is a pending dry-run placeholder."""
|
|
762
|
+
if dry_run_input_pending(path):
|
|
763
|
+
return None
|
|
764
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
|
|
765
|
+
"-map", "0:V:0", "-frames:v", "1", "-vf", f"scale={_DIFF_W}:{_DIFF_H}:flags=area,format=gray",
|
|
766
|
+
"-f", "rawvideo", "-"], check=False, text=False, record=True)
|
|
767
|
+
px = list(proc.stdout[:_DIFF_W * _DIFF_H])
|
|
768
|
+
if proc.returncode != 0 or len(px) < _DIFF_W * _DIFF_H:
|
|
769
|
+
return None
|
|
770
|
+
return statistics.fmean(px), statistics.pstdev(px)
|
|
771
|
+
|
|
772
|
+
|
|
687
773
|
SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
|
|
688
774
|
|
|
689
775
|
|
package/scripts/_contract.py
CHANGED
|
@@ -495,6 +495,7 @@ DIAGNOSTIC_CODES = {
|
|
|
495
495
|
# degraded or partial results (part 2)
|
|
496
496
|
"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
497
|
"output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
|
|
498
|
+
"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"),
|
|
498
499
|
"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}"),
|
|
499
500
|
"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"),
|
|
500
501
|
"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)"),
|
package/scripts/check.py
CHANGED
|
@@ -25,17 +25,20 @@ duration that is black: WARN > 10%, FAIL >= 95%), frozen (longest frozen span: W
|
|
|
25
25
|
max(3 s, 30% of the duration), FAIL when the whole video is frozen) and silence (share
|
|
26
26
|
below -50 dBFS: WARN > 50%, FAIL >= 95%); and, from one more audio-only pass (2.17),
|
|
27
27
|
dropouts (gaps of digital silence, <= -80 dBFS for 20 ms to 1 s with sound on both sides:
|
|
28
|
-
WARN when there is any)
|
|
28
|
+
WARN when there is any); and, from one more video pass and a one-frame decode (2.23), flashes
|
|
29
|
+
(single frames that differ from both neighbours while they match each other: WARN when there is
|
|
30
|
+
any) and first frame (one flat colour, the frame a feed's autoplay shows first: WARN). Without it
|
|
31
|
+
the row set is unchanged.
|
|
29
32
|
"""
|
|
30
33
|
import argparse
|
|
31
34
|
import json
|
|
32
35
|
import re
|
|
33
36
|
import sys
|
|
34
37
|
from fractions import Fraction
|
|
35
|
-
from typing import Any, Dict, List
|
|
38
|
+
from typing import Any, Dict, List, Optional
|
|
36
39
|
|
|
37
40
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
|
|
38
|
-
from _common.probe import SILENT_PEAK_DBFS, detect_content, measured_level_dbfs
|
|
41
|
+
from _common.probe import CONTENT_FLAT_STD, SILENT_PEAK_DBFS, detect_content, detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs
|
|
39
42
|
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
40
43
|
|
|
41
44
|
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
|
|
@@ -68,6 +71,22 @@ FROZEN_FAIL = 0.95 # a frozen span covering this share =
|
|
|
68
71
|
SILENCE_WARN, SILENCE_FAIL = 0.50, 0.95 # share of the duration below SILENT_PEAK_DBFS
|
|
69
72
|
|
|
70
73
|
|
|
74
|
+
def flash_times(flashes: List[int], v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[List[float]]" = None) -> str:
|
|
75
|
+
"""The flashes row's first five times on look.py's timeline (seconds from the container
|
|
76
|
+
start). A flash frame's own presentation time when `pts` has it (presentation_times(): exact,
|
|
77
|
+
gaps in the timestamps included); otherwise index / average rate plus the video's lead over
|
|
78
|
+
the container, marked "~" (approximate), and `frame N` when there is no frame rate either."""
|
|
79
|
+
origin = float(meta.get("start_time") or 0.0)
|
|
80
|
+
fps = float(v.get("fps") or 0.0)
|
|
81
|
+
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 "")
|
|
88
|
+
|
|
89
|
+
|
|
71
90
|
def _span_total(spans: Any) -> float:
|
|
72
91
|
return sum(max(0.0, e - s) for s, e in spans)
|
|
73
92
|
|
|
@@ -91,7 +110,7 @@ def main() -> int:
|
|
|
91
110
|
ap.add_argument("--max-mb", type=float, help="override max file size in MB")
|
|
92
111
|
ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
|
|
93
112
|
ap.add_argument("--content", action="store_true",
|
|
94
|
-
help="also check the content (one decode pass, plus an audio-only one): black, frozen, silence and
|
|
113
|
+
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")
|
|
95
114
|
add_common(ap)
|
|
96
115
|
args = ap.parse_args()
|
|
97
116
|
apply_common(args)
|
|
@@ -117,7 +136,8 @@ def main() -> int:
|
|
|
117
136
|
v, a = meta.get("video") or {}, meta.get("audio") or {}
|
|
118
137
|
rows: List[Dict[str, Any]] = []
|
|
119
138
|
|
|
120
|
-
|
|
139
|
+
# first frame / flashes: a fade from black or a strobe can be the edit, so neither is a mechanical fix
|
|
140
|
+
JUDGEMENT = {"duration", "aspect", "loudness", "true peak", "fps", "resolution", "first frame", "flashes"}
|
|
121
141
|
|
|
122
142
|
def row(name: str, status: str, value: Any, expect: Any, fix: str = "", reason: str = "") -> None:
|
|
123
143
|
# "format" rows are safe to fix mechanically; "judgement" rows change the content
|
|
@@ -284,6 +304,32 @@ def main() -> int:
|
|
|
284
304
|
f"longest {longest:.1f}s", f"<= {limit:.1f}s frozen",
|
|
285
305
|
"look.py the frozen span: a still title card is fine, a stuck decode is not",
|
|
286
306
|
reason="a picture that never changes is usually a stuck frame, not the edit")
|
|
307
|
+
if v and not dry_run_input_pending(args.input): # a plan's not-yet-written input: no rows, as above
|
|
308
|
+
first = first_frame_stats(args.input)
|
|
309
|
+
if first is None:
|
|
310
|
+
row("first frame", "WARN", "could not measure", f"spread >= {CONTENT_FLAT_STD:g} (not one flat colour)",
|
|
311
|
+
"look.py the first frame", reason="the first frame did not decode, so whether it is flat is unknown")
|
|
312
|
+
else:
|
|
313
|
+
mean, spread = first
|
|
314
|
+
flat = spread < CONTENT_FLAT_STD
|
|
315
|
+
row("first frame", "WARN" if flat else "PASS", f"luma {mean:.0f}, spread {spread:.1f}",
|
|
316
|
+
f"spread >= {CONTENT_FLAT_STD:g} (not one flat colour)",
|
|
317
|
+
"a fade from black is a choice; where autoplay or the preview shows the first frame, start on "
|
|
318
|
+
"a picture (cut.py --start a few frames in, or move a title card to frame 0)" if flat else "",
|
|
319
|
+
reason="a feed shows the first frame before anything plays, and a flat one reads as a broken upload" if flat else "")
|
|
320
|
+
flashes = detect_flashes(args.input)
|
|
321
|
+
pts = presentation_times(args.input, float(v.get("start_time") or 0.0)) if flashes else None
|
|
322
|
+
at = flash_times(flashes or [], v, meta, pts)
|
|
323
|
+
if flashes is None:
|
|
324
|
+
row("flashes", "WARN", "could not measure", "no single frame unlike both its neighbours",
|
|
325
|
+
"look.py the video: no frame decoded, so nothing was compared",
|
|
326
|
+
reason="the video did not decode, so whether it has flashes is unknown")
|
|
327
|
+
else:
|
|
328
|
+
row("flashes", "WARN" if flashes else "PASS", f"{len(flashes)}" + (f": {at}" if flashes else ""),
|
|
329
|
+
"no single frame unlike both its neighbours",
|
|
330
|
+
"look.py at those times: a stray frame at a cut or join is an edit error (re-cut with cut.py "
|
|
331
|
+
"--accurate, or join.py again); a camera flash or a strobe in the footage is fine" if flashes else "",
|
|
332
|
+
reason="one frame that matches neither neighbour is usually a frame left over at a cut, or a decode glitch" if flashes else "")
|
|
287
333
|
if "silence" in cd and span:
|
|
288
334
|
share = _span_total(cd["silence"]) / span
|
|
289
335
|
row("silence", "FAIL" if share >= SILENCE_FAIL else ("WARN" if share > SILENCE_WARN else "PASS"),
|