wizz-method 2.21.0 → 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/THIRD-PARTY-NOTICES.md +24 -0
- package/package.json +4 -2
- package/removals.txt +3 -0
- package/skills-registry.yaml +133 -4
- package/src/bmm-skills/1-analysis/research/wizz-market-research/steps/step-06-research-completion.md +27 -0
- package/src/bmm-skills/1-analysis/wizz-set-feature/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-create-story/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +8 -1
- package/src/bmm-skills/4-implementation/wizz-quick-dev/SKILL.md +2 -2
- package/src/bmm-skills/4-implementation/wizz-quick-dev/step-03-implement.md +2 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-status/SKILL.md +1 -1
- package/src/core-skills/wizz-party-mode/customize.toml +3 -3
- package/src/modules/lowticket/README.md +2 -1
- package/src/modules/lowticket/module.yaml +1 -1
- package/src/modules/lowticket/skills/wizz-clone-do-expert/SKILL.md +86 -0
- package/src/modules/lowticket/skills/wizz-clone-do-expert/references/clone-prompt-template.md +21 -0
- package/src/modules/lowticket/skills/wizz-lowticket-trafego/references/reguas.md +2 -0
- package/src/modules/wizz/_shared/cerebro.md +1 -1
- package/src/modules/wizz/_shared/organizacao.md +2 -2
- package/src/modules/wizz/_shared/video-pipeline.md +2 -0
- package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-copy/customize.toml +5 -0
- package/src/modules/wizz/agents/wizz-copy/knowledge/playbook.md +3 -2
- package/src/modules/wizz/agents/wizz-growth/customize.toml +7 -2
- package/src/modules/wizz/agents/wizz-growth/knowledge/playbook.md +3 -2
- package/src/modules/wizz/agents/wizz-social/knowledge/blueprint-roteiro-viral.md +14 -0
- package/src/modules/wizz/agents/wizz-social/knowledge/legenda-instagram.md +12 -0
- package/src/skills-lib/wizz-cerebro/SKILL.md +18 -5
- package/src/skills-lib/wizz-design-needs/SKILL.md +7 -0
- package/src/skills-lib/wizz-design-needs/references/design-state.md +34 -2
- package/src/skills-lib/wizz-design-needs/references/gap-analysis.md +1 -1
- package/src/skills-lib/wizz-design-needs/scripts/design_state.py +305 -0
- package/src/skills-lib/wizz-design-needs/scripts/tests/test_design_state.py +143 -0
- package/src/skills-lib/wizz-impeccable/references/design-rules.md +18 -0
- package/src/skills-lib/wizz-ios-build/SKILL.md +61 -0
- package/src/skills-lib/wizz-motion-3d-director/references/motion-paths.md +2 -0
- package/src/skills-lib/wizz-offer-forge/SKILL.md +2 -2
- package/src/skills-lib/wizz-paid-ads/SKILL.md +2 -1
- package/src/skills-lib/wizz-paywall-upgrade-cro/SKILL.md +1 -0
- package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/README.md +13 -13
- package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/SKILL.md +11 -7
- package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/assets/template.html +8 -21
- package/src/skills-lib/wizz-pesquisa-de-publico/references/interview-mode.md +69 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/SKILL.md +6 -2
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/audit-protocol.md +7 -34
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/component-sources.md +25 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/design-sources.yaml +325 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mandatory-process.md +1 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mining-rubric.md +123 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/source-first-protocol.md +10 -4
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/web-access-and-evidence.md +1 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/design_sources.py +221 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/tests/test_design_sources.py +130 -0
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +7 -1
- package/src/skills-lib/wizz-seo-audit/SKILL.md +4 -0
- package/src/skills-lib/wizz-seo-audit/references/gsc-regex-perguntas.md +64 -0
- package/src/skills-lib/wizz-signup-flow-cro/SKILL.md +1 -0
- package/src/skills-lib/wizz-signup-flow-cro/references/trial-abuse.md +40 -0
- package/src/skills-lib/wizz-taste-skill/references/anti-slop-tells.md +8 -0
- package/src/skills-lib/wizz-ui-component-curator/SKILL.md +8 -7
- package/src/skills-lib/wizz-ui-ux-pro-max/SKILL.md +1 -1
- package/src/skills-lib/wizz-ui-ux-pro-max/references/common-rules-checklist.md +11 -0
- package/src/skills-lib/wizz-video-effects/LICENSE-motionmaxxing.txt +202 -0
- package/src/skills-lib/wizz-video-effects/SKILL.md +7 -2
- package/src/skills-lib/wizz-video-effects/assets/chrome/collect_layout.js +113 -0
- package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +45 -0
- package/src/skills-lib/wizz-video-effects/references/data-contract.md +32 -1
- package/src/skills-lib/wizz-video-effects/references/gates.md +4 -1
- package/src/skills-lib/wizz-video-effects/references/one-take.md +41 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_chrome.py +376 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_dead_frames.py +193 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_one_take.py +159 -0
- package/src/skills-lib/wizz-video-effects/scripts/config.py +13 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_chrome.py +181 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_dead_frames.py +171 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_one_take.py +126 -0
- package/src/skills-lib/wizz-video-styles/LICENSE-lemo-opuscar.txt +21 -0
- package/src/skills-lib/wizz-video-styles/SKILL.md +16 -7
- package/src/skills-lib/wizz-video-styles/assets/film-styles/dark-keynote.html +78 -0
- package/src/skills-lib/wizz-video-styles/assets/film-styles/remotion/Riso.tsx +63 -0
- package/src/skills-lib/wizz-video-styles/assets/film-styles/risograph.html +69 -0
- package/src/skills-lib/wizz-video-styles/assets/film-styles/swiss-motion.html +84 -0
- package/src/skills-lib/wizz-video-styles/references/check-ins.md +25 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/DIRECTOR.md +139 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/archive-lighttable.md +109 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/art-deco.md +112 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/ascii-crt.md +115 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/backrooms.md +125 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/blueprint.md +120 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/brick-toy.md +114 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/briefing-board.md +122 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/caption-news.md +137 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/cel-anime-80s.md +119 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/city-symphony.md +142 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/countdown.md +132 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/crayon-book.md +109 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/dark-keynote.md +117 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/data-physical.md +119 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/dataviz.md +123 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/engraving.md +123 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/game-show.md +118 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/glass-product.md +110 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/halftone-dossier.md +122 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/hd-2d.md +111 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/hologram-hud.md +129 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/impasto.md +124 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/ink-wash.md +120 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/iso-infographic.md +117 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/kino-montage.md +151 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/lidar-survey.md +118 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/living-screencast.md +124 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/lowpoly-island.md +112 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/microgame.md +122 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/microscope.md +108 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/midcentury-toon.md +124 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/news-package.md +158 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/newsreel.md +136 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/one-line.md +114 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/paper-lantern.md +116 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/paper-popup.md +119 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/papercut-red.md +110 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/pictogram-motion.md +119 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/pixel-rpg.md +117 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/risograph.md +125 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/rubber-hose.md +112 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/satellite-briefing.md +116 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/scifi-toon.md +121 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/shadow-puppet.md +124 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/silent-film.md +109 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/silkscreen-poster.md +140 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/spy-titles.md +116 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/stained-glass.md +123 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/swiss-motion.md +115 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/teardown.md +127 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/telestrator.md +137 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/tilt-shift.md +107 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/ukiyoe.md +112 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/urban-sketch.md +126 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/visual-investigation.md +140 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/watercolor.md +113 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/white-model.md +115 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/whiteboard.md +118 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles/woodcut.md +122 -0
- package/src/skills-lib/wizz-video-styles/references/film-styles.md +140 -0
- package/src/skills-lib/wizz-video-styles/references/insert-recipes.md +33 -0
- package/src/skills-lib/wizz-video-styles/references/motion-laws.md +62 -0
- package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +1 -0
- package/src/skills-lib/wizz-video-styles/scripts/beat_grid.py +38 -6
- package/src/skills-lib/wizz-video-styles/scripts/blind_review.py +159 -0
- package/src/skills-lib/wizz-video-styles/scripts/taste.mjs +110 -0
- package/src/skills-lib/wizz-video-styles/scripts/test_blind_review.py +82 -0
- package/src/skills-lib/wizz-vsl/LICENSE +49 -0
- package/src/skills-lib/wizz-vsl/SKILL.md +122 -0
- package/src/skills-lib/wizz-vsl/references/assembly-qa.md +123 -0
- package/src/skills-lib/wizz-vsl/references/blocks-and-leads.md +149 -0
- package/src/skills-lib/wizz-vsl/references/diagnosis.md +95 -0
- package/src/skills-lib/wizz-vsl/references/intake.md +200 -0
- package/src/skills-lib/wizz-vsl/references/mechanisms.md +138 -0
- package/src/skills-lib/wizz-vsl/references/niches.md +40 -0
- package/src/skills-lib/wizz-vsl/references/offer.md +91 -0
- package/src/skills-lib/wizz-vsl/references/production.md +75 -0
- package/src/skills-lib/wizz-web-security/SKILL.md +2 -0
- package/tools/hooks/README.md +10 -9
- package/tools/hooks/wizz-arrumar.js +1 -0
- package/tools/hooks/wizz-artifact-guard.js +31 -5
- package/tools/hooks/wizz-cerebro-lib.js +36 -13
- package/tools/hooks/wizz-feature-lib.js +4 -1
- package/tools/hooks/wizz-feature.js +66 -76
- package/tools/hooks/wizz-sprint-archive-lib.js +311 -0
- package/tools/installer/core/hooks-settings.js +1 -0
- package/tools/installer/core/installer.js +6 -0
- package/tools/installer/guided/labels.js +16 -0
- package/tools/installer/install-text.js +2 -0
- package/tools/installer/modules/cli-config.js +3 -0
- package/tools/installer/modules/core-seed.js +72 -0
- package/tools/installer/modules/mcp-config.js +3 -0
- package/tools/installer/ui.js +22 -26
- /package/src/skills-lib/{wizz-pesquisa-de-publico-do-piva → wizz-pesquisa-de-publico}/prompts.md +0 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""PAGE-CHROME gate: does the piece look like a slide or a HUD?
|
|
3
|
+
|
|
4
|
+
Reads what text and shapes are on screen, never the pixels, from one of two
|
|
5
|
+
sources:
|
|
6
|
+
|
|
7
|
+
--snapshot LAYOUT.json a layout snapshot of an HTML composition
|
|
8
|
+
(HyperFrames, or Remotion Studio, which is also a DOM), written by
|
|
9
|
+
assets/chrome/collect_layout.js at a few seek times.
|
|
10
|
+
--timeline TIMELINE the Remotion timeline: the text layers declared in
|
|
11
|
+
elements[] (`text`, `box`, `font_px`, `role`, `until`).
|
|
12
|
+
|
|
13
|
+
Rules (thresholds in the config block chrome_lint):
|
|
14
|
+
C1 corner/edge label small text parked in a corner or an edge band FAIL
|
|
15
|
+
C2 tracked-caps label small uppercase text with wide letter-spacing FAIL
|
|
16
|
+
C3 counter "02 / 05", "01 - THE WAIT", "Parte 3", bare "01" FAIL
|
|
17
|
+
C4 production metadata timecode, FPS, BPM, resolution, REC, version FAIL
|
|
18
|
+
C6 header/footer bar a wide bar or hairline in the top or bottom band FAIL
|
|
19
|
+
C7 kicker over headline a small line right above a headline 2x its size FAIL
|
|
20
|
+
D1 deck layout left-anchored headline with media on the right,
|
|
21
|
+
a sub-line or 2+ lines: FAIL; alone: WARN
|
|
22
|
+
P1 progress bar a thin bar that grows over time FAIL
|
|
23
|
+
|
|
24
|
+
Not chrome: text whose `role` is in chrome_lint.exempt_roles (captions,
|
|
25
|
+
legally required ad text, product UI). A rule the chosen style measured on
|
|
26
|
+
real videos (the progress device of `desafio-numero`) is allowed with
|
|
27
|
+
`--allow P1` or `chrome_allow: ["P1"]` in the timeline; allowed rules are
|
|
28
|
+
printed, never silent.
|
|
29
|
+
|
|
30
|
+
What it cannot see: text drawn into a <canvas>, an image, a video or burned
|
|
31
|
+
into footage; text a Remotion component draws without declaring it in the
|
|
32
|
+
timeline; whether a flagged element is still the right call. A PASS is not
|
|
33
|
+
taste: look at the frames.
|
|
34
|
+
|
|
35
|
+
Rules ported from gate G5 of motionmaxxing's lint.mjs (Apache-2.0,
|
|
36
|
+
github.com/Tejashmakwana/motionmaxxing), rewritten for 9:16; see
|
|
37
|
+
THIRD-PARTY-NOTICES.md.
|
|
38
|
+
|
|
39
|
+
Exit code: 0 pass (warnings allowed), 1 fail, 2 input or config unreadable.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
46
|
+
import sys
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
import timeline as tl
|
|
51
|
+
from config import Config, ConfigError, add_config_arg, load, optional_block
|
|
52
|
+
|
|
53
|
+
BLOCK = "chrome_lint"
|
|
54
|
+
SEVERITY = {"C1": "FAIL", "C2": "FAIL", "C3": "FAIL", "C4": "FAIL", "C6": "FAIL",
|
|
55
|
+
"C7": "FAIL", "D1": "FAIL", "P1": "FAIL"}
|
|
56
|
+
NAMES = {"C1": "corner/edge label", "C2": "tracked-caps label", "C3": "counter",
|
|
57
|
+
"C4": "production metadata", "C6": "header/footer bar", "C7": "kicker over headline",
|
|
58
|
+
"D1": "deck layout (left headline block)", "P1": "progress bar"}
|
|
59
|
+
|
|
60
|
+
RX_COUNTER_NUM = re.compile(r"^\s*0?\d{1,2}\s*(?:[/·|]|-(?=\s*[A-Za-z]{3,}))\s*(?:0?\d{1,2}\b|[A-Za-zÀ-ú]{3,})")
|
|
61
|
+
RX_COUNTER_WORD = re.compile(
|
|
62
|
+
r"\b(chapter|scene|act|step|part|episode|shot|take|beat|"
|
|
63
|
+
r"cap[ií]tulo|cena|ato|passo|parte|etapa|epis[oó]dio)\s*0?\d", re.I)
|
|
64
|
+
RX_COUNTER_BARE = re.compile(r"^\s*0\d\s*$")
|
|
65
|
+
RX_META = re.compile(
|
|
66
|
+
r"\bBPM\b|\bFPS\b|\bFRAME\s*\d|\b(?:1920|3840|1280|1080|720)\s*[x×]\s*(?:1080|2160|720|1920|1280)\b"
|
|
67
|
+
r"|\bT\s*\+\s*\d|\bTC\b|\b\d{1,2}:\d{2}:\d{2}(?:[.:]\d+)?\b|\b\d{1,2}:\d{2}\.\d{1,3}\b|\bREC\b")
|
|
68
|
+
RX_VERSION = re.compile(r"\bv\d+\.\d+(?:\.\d+)?\b|©", re.I)
|
|
69
|
+
RX_24_7 = re.compile(r"^\s*24\s*/\s*7\b")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ChromeRules:
|
|
74
|
+
small_text: float
|
|
75
|
+
corner_x: float
|
|
76
|
+
corner_y: float
|
|
77
|
+
edge_band: float
|
|
78
|
+
caps_tracking_em: float
|
|
79
|
+
caps_max_size: float
|
|
80
|
+
counter_max_size: float
|
|
81
|
+
headline_min_size: float
|
|
82
|
+
kicker_ratio: float
|
|
83
|
+
kicker_gap: float
|
|
84
|
+
deck_left_x: float
|
|
85
|
+
bar_min_width: float
|
|
86
|
+
bar_max_height: float
|
|
87
|
+
bar_band: float
|
|
88
|
+
progress_min_growth: float
|
|
89
|
+
held_s: float
|
|
90
|
+
exempt_roles: frozenset
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rules(cfg: Config) -> tuple[ChromeRules, bool]:
|
|
94
|
+
b, from_template = optional_block(cfg, BLOCK)
|
|
95
|
+
try:
|
|
96
|
+
nums = {k: float(b[k]) for k in ChromeRules.__dataclass_fields__ if k != "exempt_roles"}
|
|
97
|
+
r = ChromeRules(**nums, exempt_roles=frozenset(str(x) for x in b["exempt_roles"]))
|
|
98
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
99
|
+
raise ConfigError(f"{BLOCK}: missing or invalid key ({e})") from e
|
|
100
|
+
bad = [k for k, v in nums.items() if v <= 0]
|
|
101
|
+
if bad:
|
|
102
|
+
raise ConfigError(f"{BLOCK}: must be > 0: {', '.join(bad)}")
|
|
103
|
+
return r, from_template
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------ inputs
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Block:
|
|
110
|
+
text: str
|
|
111
|
+
x0: float
|
|
112
|
+
y0: float
|
|
113
|
+
x1: float
|
|
114
|
+
y1: float
|
|
115
|
+
size: float # font size / short side of the frame
|
|
116
|
+
caps: bool
|
|
117
|
+
tracking: float # letter-spacing in em
|
|
118
|
+
role: str
|
|
119
|
+
align: str
|
|
120
|
+
lines: int
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def cx(self) -> float:
|
|
124
|
+
return (self.x0 + self.x1) / 2
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def cy(self) -> float:
|
|
128
|
+
return (self.y0 + self.y1) / 2
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Shape:
|
|
133
|
+
sid: str
|
|
134
|
+
kind: str # bar | rule | media | surface
|
|
135
|
+
x0: float
|
|
136
|
+
y0: float
|
|
137
|
+
x1: float
|
|
138
|
+
y1: float
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _norm_box(box, w: float, h: float) -> tuple[float, float, float, float]:
|
|
142
|
+
x0, y0, x1, y1 = (float(v) for v in box)
|
|
143
|
+
return x0 / w, y0 / h, x1 / w, y1 / h
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _block(d: dict, w: float, h: float) -> Block | None:
|
|
147
|
+
text = str(d.get("text") or "").strip()
|
|
148
|
+
if not text or "box" not in d:
|
|
149
|
+
return None
|
|
150
|
+
x0, y0, x1, y1 = _norm_box(d["box"], w, h)
|
|
151
|
+
caps = bool(d.get("caps")) or (text.upper() == text and any(c.isalpha() for c in text))
|
|
152
|
+
return Block(text, x0, y0, x1, y1, float(d.get("font_px", 0)) / min(w, h), caps,
|
|
153
|
+
float(d.get("tracking_em", 0.0)), str(d.get("role") or ""),
|
|
154
|
+
str(d.get("align") or ""), int(d.get("lines", 1)))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _shape(d: dict, i: int, w: float, h: float) -> Shape:
|
|
158
|
+
x0, y0, x1, y1 = _norm_box(d["box"], w, h)
|
|
159
|
+
return Shape(str(d.get("id", i)), str(d.get("kind", "bar")), x0, y0, x1, y1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def samples_from_snapshot(data: dict) -> list[dict]:
|
|
163
|
+
"""[{t, blocks: [Block], shapes: [Shape]}] from a collect_layout.js file."""
|
|
164
|
+
w, h = float(data["width"]), float(data["height"])
|
|
165
|
+
out = []
|
|
166
|
+
for s in data.get("samples") or []:
|
|
167
|
+
blocks = [b for b in (_block(x, w, h) for x in s.get("blocks") or []) if b]
|
|
168
|
+
shapes = [_shape(x, i, w, h) for i, x in enumerate(s.get("shapes") or []) if "box" in x]
|
|
169
|
+
out.append({"t": float(s["t"]), "blocks": blocks, "shapes": shapes})
|
|
170
|
+
return sorted(out, key=lambda s: s["t"])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def samples_from_timeline(data: dict, w: int, h: int, r: ChromeRules) -> list[dict]:
|
|
174
|
+
"""One sample per layer start, with every declared layer visible then.
|
|
175
|
+
A layer shorter than held_s is in flight and is skipped; a bar with a
|
|
176
|
+
`box_end` wider than its `box` is a growing bar (P1)."""
|
|
177
|
+
total, _ = tl.duration(data)
|
|
178
|
+
layers = []
|
|
179
|
+
for i, e in enumerate(data.get("elements") or []):
|
|
180
|
+
if not isinstance(e, dict) or "box" not in e:
|
|
181
|
+
continue
|
|
182
|
+
start = float(e.get("at", 0.0))
|
|
183
|
+
end = float(e.get("until", total))
|
|
184
|
+
if end - start < r.held_s:
|
|
185
|
+
continue
|
|
186
|
+
layers.append((start, end, i, e))
|
|
187
|
+
out = []
|
|
188
|
+
for t in sorted({start for start, _, _, _ in layers}):
|
|
189
|
+
blocks, shapes = [], []
|
|
190
|
+
for start, end, i, e in layers:
|
|
191
|
+
if not start <= t < end:
|
|
192
|
+
continue
|
|
193
|
+
if e.get("text"):
|
|
194
|
+
b = _block(e, w, h)
|
|
195
|
+
if b:
|
|
196
|
+
blocks.append(b)
|
|
197
|
+
elif e.get("kind") in ("bar", "rule", "media", "surface"):
|
|
198
|
+
shapes.append(_shape(e, i, w, h))
|
|
199
|
+
if e.get("box_end"):
|
|
200
|
+
shapes.append(_shape({**e, "box": e["box_end"]}, i, w, h))
|
|
201
|
+
out.append({"t": t, "blocks": blocks, "shapes": shapes, "timeline": True})
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------- rules
|
|
206
|
+
|
|
207
|
+
def _held(b: Block, t: float, samples: list[dict], r: ChromeRules) -> bool:
|
|
208
|
+
"""Parked, not flying through: the same text at the same place in the
|
|
209
|
+
neighbouring samples within held_s. No neighbour at all: held."""
|
|
210
|
+
near = [s for s in samples if s["t"] != t and abs(s["t"] - t) <= r.held_s]
|
|
211
|
+
if not near:
|
|
212
|
+
return True
|
|
213
|
+
return any(c.text == b.text and abs(c.x0 - b.x0) < 0.006 and abs(c.y0 - b.y0) < 0.006
|
|
214
|
+
for s in near for c in s["blocks"])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _text_rules(b: Block, r: ChromeRules) -> list[str]:
|
|
218
|
+
hits = []
|
|
219
|
+
small = b.size <= r.small_text
|
|
220
|
+
corner = (b.cx < r.corner_x or b.cx > 1 - r.corner_x) and (b.cy < r.corner_y or b.cy > 1 - r.corner_y)
|
|
221
|
+
edge = b.y1 < r.edge_band or b.y0 > 1 - r.edge_band or b.x1 < r.edge_band / 2 or b.x0 > 1 - r.edge_band / 2
|
|
222
|
+
if small and (corner or edge):
|
|
223
|
+
hits.append("C1")
|
|
224
|
+
letters = sum(1 for c in b.text if c.isalpha())
|
|
225
|
+
if b.caps and b.tracking >= r.caps_tracking_em and b.size <= r.caps_max_size and letters >= 2:
|
|
226
|
+
hits.append("C2")
|
|
227
|
+
counter = RX_COUNTER_NUM.search(b.text) or RX_COUNTER_WORD.search(b.text) or RX_COUNTER_BARE.search(b.text)
|
|
228
|
+
if b.size <= r.counter_max_size and counter and not RX_24_7.search(b.text):
|
|
229
|
+
hits.append("C3")
|
|
230
|
+
if (RX_META.search(b.text) and b.size <= r.counter_max_size) or (RX_VERSION.search(b.text) and small):
|
|
231
|
+
hits.append("C4")
|
|
232
|
+
return hits
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _kickers(free: list[Block], r: ChromeRules) -> dict[int, Block]:
|
|
236
|
+
out = {}
|
|
237
|
+
for a in free:
|
|
238
|
+
for i, b in enumerate(free):
|
|
239
|
+
if a is b or a.size > r.small_text or b.size < max(a.size * r.kicker_ratio, r.headline_min_size):
|
|
240
|
+
continue
|
|
241
|
+
gap = b.y0 - a.y1
|
|
242
|
+
aligned = abs(a.x0 - b.x0) < 0.02 or abs(a.cx - b.cx) < 0.02
|
|
243
|
+
if -0.01 < gap < r.kicker_gap and aligned:
|
|
244
|
+
out[i] = a
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _deck(free: list[Block], shapes: list[Shape], kickers: dict[int, Block], r: ChromeRules):
|
|
249
|
+
biggest = max((b.size for b in free), default=0.0)
|
|
250
|
+
for i, b in enumerate(free):
|
|
251
|
+
left = 0.02 <= b.x0 < r.deck_left_x and b.x1 < 0.78
|
|
252
|
+
if not (b.size >= r.headline_min_size and b.size >= 0.85 * biggest and left
|
|
253
|
+
and b.align != "center" and abs(b.cx - 0.5) > 0.06):
|
|
254
|
+
continue
|
|
255
|
+
why = []
|
|
256
|
+
if any(c is not b and 0.02 <= c.size <= 0.6 * b.size and -0.005 < c.y0 - b.y1 < 0.08
|
|
257
|
+
and abs(c.x0 - b.x0) < 0.03 for c in free):
|
|
258
|
+
why.append("sub-line")
|
|
259
|
+
if i in kickers:
|
|
260
|
+
why.append("kicker")
|
|
261
|
+
if any(s.kind in ("media", "surface") and 0.04 <= (s.x1 - s.x0) * (s.y1 - s.y0) <= 0.7
|
|
262
|
+
and (s.x0 + s.x1) / 2 > 0.5 and s.x0 > b.x1 - 0.05 and s.y1 > b.y0 and s.y0 < b.y1 + 0.2
|
|
263
|
+
for s in shapes):
|
|
264
|
+
why.append("media on the right")
|
|
265
|
+
if b.lines >= 2:
|
|
266
|
+
why.append(f"{b.lines} lines")
|
|
267
|
+
yield b, why
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _bars(shapes: list[Shape], r: ChromeRules) -> list[Shape]:
|
|
271
|
+
out = []
|
|
272
|
+
for s in shapes:
|
|
273
|
+
if s.kind in ("media", "surface"):
|
|
274
|
+
continue
|
|
275
|
+
w, h, cy = s.x1 - s.x0, s.y1 - s.y0, (s.y0 + s.y1) / 2
|
|
276
|
+
if w >= r.bar_min_width and h <= r.bar_max_height and (cy < r.bar_band or cy > 1 - r.bar_band):
|
|
277
|
+
out.append(s)
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _progress(samples: list[dict], r: ChromeRules) -> list[tuple[str, float, float]]:
|
|
282
|
+
"""(id, first width, last width) of thin bars that grow over time."""
|
|
283
|
+
widths: dict[str, list[float]] = {}
|
|
284
|
+
for s in samples:
|
|
285
|
+
for sh in s["shapes"]:
|
|
286
|
+
if sh.kind not in ("media", "surface") and sh.y1 - sh.y0 <= 0.02:
|
|
287
|
+
widths.setdefault(sh.sid, []).append(sh.x1 - sh.x0)
|
|
288
|
+
out = []
|
|
289
|
+
for sid, ws in widths.items():
|
|
290
|
+
steps = [b - a for a, b in zip(ws, ws[1:])]
|
|
291
|
+
grows = ws[-1] - ws[0] >= r.progress_min_growth and all(d >= -0.005 for d in steps)
|
|
292
|
+
if len(ws) >= 2 and grows:
|
|
293
|
+
out.append((sid, ws[0], ws[-1]))
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def lint(samples: list[dict], r: ChromeRules, allow: set[str]) -> dict:
|
|
298
|
+
hits: dict[tuple, dict] = {}
|
|
299
|
+
|
|
300
|
+
def add(rule: str, t: float, what: str, sev: str | None = None):
|
|
301
|
+
key = (rule, what)
|
|
302
|
+
h = hits.setdefault(key, {"rule": rule, "what": what, "times": [], "sev": sev or SEVERITY[rule]})
|
|
303
|
+
if t not in h["times"]:
|
|
304
|
+
h["times"].append(t)
|
|
305
|
+
|
|
306
|
+
for s in samples:
|
|
307
|
+
t = s["t"]
|
|
308
|
+
free = [b for b in s["blocks"] if b.role not in r.exempt_roles and _held(b, t, samples, r)]
|
|
309
|
+
for b in free:
|
|
310
|
+
for rule in _text_rules(b, r):
|
|
311
|
+
add(rule, t, b.text)
|
|
312
|
+
kickers = _kickers(free, r)
|
|
313
|
+
for i, a in kickers.items():
|
|
314
|
+
add("C7", t, f"{a.text!r} over {free[i].text!r}")
|
|
315
|
+
for b, why in _deck(free, s["shapes"], kickers, r):
|
|
316
|
+
add("D1", t, f"{b.text!r}" + (f" with {', '.join(why)}" if why else " alone"),
|
|
317
|
+
"FAIL" if why else "WARN")
|
|
318
|
+
for sh in _bars(s["shapes"], r):
|
|
319
|
+
add("C6", t, f"{sh.kind} {sh.sid} at y={sh.y0:.2f}-{sh.y1:.2f}")
|
|
320
|
+
for sid, w0, w1 in _progress(samples, r):
|
|
321
|
+
add("P1", samples[0]["t"], f"bar {sid} grows {w0:.2f} -> {w1:.2f} of the width")
|
|
322
|
+
used = sorted({h["rule"] for h in hits.values()} & allow)
|
|
323
|
+
kept = [h for h in hits.values() if h["rule"] not in allow]
|
|
324
|
+
kept.sort(key=lambda h: (h["rule"], h["times"][0]))
|
|
325
|
+
return {"hits": kept, "allowed": used, "passed": not any(h["sev"] == "FAIL" for h in kept)}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --------------------------------------------------------------------- CLI
|
|
329
|
+
|
|
330
|
+
def report(rep: dict, source: str, n_samples: int, from_template: bool) -> int:
|
|
331
|
+
print(f"{source}: {n_samples} sample(s)")
|
|
332
|
+
if from_template:
|
|
333
|
+
print(f" note: no '{BLOCK}' block in the project config; using the template's")
|
|
334
|
+
for h in rep["hits"]:
|
|
335
|
+
times = ", ".join(f"{t:.2f}s" for t in sorted(h["times"])[:6])
|
|
336
|
+
print(f" {h['sev']:4} {h['rule']} {NAMES[h['rule']]}: {h['what']} [{times}]")
|
|
337
|
+
for rule in rep["allowed"]:
|
|
338
|
+
print(f" allowed {rule} {NAMES[rule]} (the style measured it)")
|
|
339
|
+
print(f"GATE chrome: {'PASS' if rep['passed'] else 'FAIL'}")
|
|
340
|
+
return 0 if rep["passed"] else 1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def main(argv: list[str] | None = None) -> int:
|
|
344
|
+
ap = argparse.ArgumentParser(description="Page-chrome / slide-look lint on a layout snapshot or a timeline.")
|
|
345
|
+
add_config_arg(ap)
|
|
346
|
+
src = ap.add_mutually_exclusive_group(required=True)
|
|
347
|
+
src.add_argument("--snapshot", help="layout JSON written by assets/chrome/collect_layout.js")
|
|
348
|
+
src.add_argument("--timeline", help="timeline JSON with text layers in elements[]")
|
|
349
|
+
ap.add_argument("--allow", default="", help="comma-separated rules the chosen style measured (e.g. P1)")
|
|
350
|
+
args = ap.parse_args(argv)
|
|
351
|
+
try:
|
|
352
|
+
cfg = load(args.config)
|
|
353
|
+
r, from_template = rules(cfg)
|
|
354
|
+
allow = {a.strip() for a in args.allow.split(",") if a.strip()}
|
|
355
|
+
if args.snapshot:
|
|
356
|
+
p = Path(args.snapshot)
|
|
357
|
+
if not p.exists():
|
|
358
|
+
raise tl.TimelineError(f"snapshot not found: {p}")
|
|
359
|
+
samples = samples_from_snapshot(json.loads(p.read_text(encoding="utf-8")))
|
|
360
|
+
source = p.name
|
|
361
|
+
else:
|
|
362
|
+
data = tl.load(args.timeline)
|
|
363
|
+
allow |= set(data.get("chrome_allow") or [])
|
|
364
|
+
samples = samples_from_timeline(data, cfg.frame.width, cfg.frame.height, r)
|
|
365
|
+
source = Path(args.timeline).name
|
|
366
|
+
unknown = allow - set(SEVERITY)
|
|
367
|
+
if unknown:
|
|
368
|
+
raise ConfigError(f"unknown rule(s) in allow: {', '.join(sorted(unknown))}")
|
|
369
|
+
return report(lint(samples, r, allow), source, len(samples), from_template)
|
|
370
|
+
except (ConfigError, tl.TimelineError, json.JSONDecodeError, KeyError, ValueError, OSError) as e:
|
|
371
|
+
print(f"ERROR: {e}")
|
|
372
|
+
return 2
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
sys.exit(main())
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""DEAD-FRAME gate, measured on the FINAL MP4, every frame.
|
|
3
|
+
|
|
4
|
+
Two measures on a small luma copy of each frame (dead_frames.analysis_width x
|
|
5
|
+
dead_frames.analysis_height):
|
|
6
|
+
|
|
7
|
+
FLAT (blocks). A frame that is one uniform field: std-dev under
|
|
8
|
+
dead_frames.flat_std AND range under dead_frames.flat_range. A run of
|
|
9
|
+
MORE than dead_frames.flat_max_frames flat frames fails: a black or
|
|
10
|
+
blank gap between scenes, a render that lost a layer, a fade that
|
|
11
|
+
bottoms out on nothing. Shorter dips (a frame or three inside a
|
|
12
|
+
transition) are listed and ignored.
|
|
13
|
+
FROZEN (warns). A frame whose mean absolute difference to the previous
|
|
14
|
+
one is under dead_frames.frozen_diff. A frozen run longer than
|
|
15
|
+
dead_frames.frozen_warn_s is printed as a warning: a held card while
|
|
16
|
+
the voice talks may be the point (a price being read), so this never
|
|
17
|
+
blocks.
|
|
18
|
+
|
|
19
|
+
Plus a MOTION note: the mean frame-to-frame change against
|
|
20
|
+
dead_frames.motion_band_reference. It is a question, never a gate, and the
|
|
21
|
+
reference comes from horizontal brand films, not vertical Reels.
|
|
22
|
+
|
|
23
|
+
What it cannot see:
|
|
24
|
+
- A talking head is never flat and rarely frozen: sensor noise, breathing
|
|
25
|
+
and compression keep the frame difference above the threshold even when
|
|
26
|
+
the shot is visually dead. Boredom on camera is for the eye.
|
|
27
|
+
- Grain, a noise overlay or a looping background keep a dead slide
|
|
28
|
+
"moving". A caret or one small ticking element does too.
|
|
29
|
+
- A dark frame with one small line of text is not flat (its range is
|
|
30
|
+
high). Whether the frame is full enough is measure_density.py.
|
|
31
|
+
- Freezes under the threshold inside a region (half the frame still) do
|
|
32
|
+
not count: the measure is the whole frame.
|
|
33
|
+
|
|
34
|
+
Ported from gate G2 of motionmaxxing (Apache-2.0,
|
|
35
|
+
github.com/Tejashmakwana/motionmaxxing), adapted to 9:16; see
|
|
36
|
+
THIRD-PARTY-NOTICES.md.
|
|
37
|
+
|
|
38
|
+
Exit code: 0 pass (warnings allowed), 1 fail, 2 file or config unreadable.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import os
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from typing import Iterable
|
|
48
|
+
|
|
49
|
+
from check_safe_zone import ReadError, all_frames, video_fps
|
|
50
|
+
from config import Config, ConfigError, add_config_arg, load, optional_block
|
|
51
|
+
|
|
52
|
+
BLOCK = "dead_frames"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class DeadFrameRules:
|
|
57
|
+
width: int
|
|
58
|
+
height: int
|
|
59
|
+
flat_std: float
|
|
60
|
+
flat_range: int
|
|
61
|
+
flat_max_frames: int
|
|
62
|
+
frozen_diff: float
|
|
63
|
+
frozen_warn_s: float
|
|
64
|
+
motion_band: tuple[float, float, float]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rules(cfg: Config) -> tuple[DeadFrameRules, bool]:
|
|
68
|
+
"""(rules, from_template). Refuses an incoherent block."""
|
|
69
|
+
b, from_template = optional_block(cfg, BLOCK)
|
|
70
|
+
try:
|
|
71
|
+
band = tuple(float(v) for v in b["motion_band_reference"])
|
|
72
|
+
r = DeadFrameRules(
|
|
73
|
+
width=int(b["analysis_width"]), height=int(b["analysis_height"]),
|
|
74
|
+
flat_std=float(b["flat_std"]), flat_range=int(b["flat_range"]),
|
|
75
|
+
flat_max_frames=int(b["flat_max_frames"]), frozen_diff=float(b["frozen_diff"]),
|
|
76
|
+
frozen_warn_s=float(b["frozen_warn_s"]), motion_band=band)
|
|
77
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
78
|
+
raise ConfigError(f"{BLOCK}: missing or invalid key ({e})") from e
|
|
79
|
+
if r.width <= 0 or r.height <= 0:
|
|
80
|
+
raise ConfigError(f"{BLOCK}.analysis_width and analysis_height must be > 0")
|
|
81
|
+
if r.flat_max_frames < 0 or r.flat_std <= 0 or r.flat_range <= 0:
|
|
82
|
+
raise ConfigError(f"{BLOCK}.flat_* must be > 0 (flat_max_frames >= 0)")
|
|
83
|
+
if r.frozen_diff <= 0 or r.frozen_warn_s <= 0:
|
|
84
|
+
raise ConfigError(f"{BLOCK}.frozen_diff and frozen_warn_s must be > 0")
|
|
85
|
+
if len(band) != 3 or not band[0] <= band[1] <= band[2]:
|
|
86
|
+
raise ConfigError(f"{BLOCK}.motion_band_reference must be [q1, median, q3]")
|
|
87
|
+
return r, from_template
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_flat(frame: bytes, r: DeadFrameRules) -> bool:
|
|
91
|
+
n = len(frame)
|
|
92
|
+
if n == 0:
|
|
93
|
+
return True
|
|
94
|
+
if max(frame) - min(frame) >= r.flat_range:
|
|
95
|
+
return False
|
|
96
|
+
s = sum(frame)
|
|
97
|
+
var = sum(v * v for v in frame) / n - (s / n) ** 2
|
|
98
|
+
return (var ** 0.5 if var > 0 else 0.0) < r.flat_std
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def mean_abs_diff(a: bytes, b: bytes) -> float:
|
|
102
|
+
return sum(abs(x - y) for x, y in zip(a, b)) / max(1, len(a))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def runs(flags: list[bool]) -> list[tuple[int, int]]:
|
|
106
|
+
"""(first, last) index of every run of True."""
|
|
107
|
+
out, start = [], None
|
|
108
|
+
for i, f in enumerate(flags):
|
|
109
|
+
if f and start is None:
|
|
110
|
+
start = i
|
|
111
|
+
elif not f and start is not None:
|
|
112
|
+
out.append((start, i - 1))
|
|
113
|
+
start = None
|
|
114
|
+
if start is not None:
|
|
115
|
+
out.append((start, len(flags) - 1))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def analyze(frames: Iterable[bytes], fps: float, r: DeadFrameRules) -> dict:
|
|
120
|
+
flat, frozen, diffs, prev = [], [False], [], None
|
|
121
|
+
for fr in frames:
|
|
122
|
+
flat.append(is_flat(fr, r))
|
|
123
|
+
if prev is not None:
|
|
124
|
+
d = mean_abs_diff(fr, prev)
|
|
125
|
+
diffs.append(d)
|
|
126
|
+
frozen.append(d < r.frozen_diff)
|
|
127
|
+
prev = fr
|
|
128
|
+
if not flat:
|
|
129
|
+
raise ReadError("no frame decoded")
|
|
130
|
+
flat_runs = runs(flat)
|
|
131
|
+
too_long = [(s, e) for s, e in flat_runs if e - s + 1 > r.flat_max_frames]
|
|
132
|
+
# A flat run is also "frozen"; report it once, as flat.
|
|
133
|
+
frozen_runs = [(s - 1, e) for s, e in runs(frozen)
|
|
134
|
+
if (e - s + 2) / fps > r.frozen_warn_s and not all(flat[s:e + 1])]
|
|
135
|
+
return {
|
|
136
|
+
"frames": len(flat),
|
|
137
|
+
"fps": fps,
|
|
138
|
+
"flat_fail": too_long,
|
|
139
|
+
"flat_ignored": [x for x in flat_runs if x not in too_long],
|
|
140
|
+
"frozen_warn": frozen_runs,
|
|
141
|
+
"motion_mean": sum(diffs) / len(diffs) if diffs else 0.0,
|
|
142
|
+
"passed": not too_long,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _span(s: int, e: int, fps: float) -> str:
|
|
147
|
+
return f"{s / fps:6.2f}s -> {(e + 1) / fps:6.2f}s ({e - s + 1} f)"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def report(name: str, rep: dict, r: DeadFrameRules, from_template: bool) -> int:
|
|
151
|
+
fps = rep["fps"]
|
|
152
|
+
print(f"{name}: {rep['frames']} frames @ {fps:.2f} fps")
|
|
153
|
+
if from_template:
|
|
154
|
+
print(f" note: no '{BLOCK}' block in the project config; using the template's")
|
|
155
|
+
if rep["flat_fail"]:
|
|
156
|
+
print(f" FLAT: {len(rep['flat_fail'])} run(s) of more than "
|
|
157
|
+
f"dead_frames.flat_max_frames={r.flat_max_frames} uniform frames:")
|
|
158
|
+
for s, e in rep["flat_fail"][:10]:
|
|
159
|
+
print(f" {_span(s, e, fps)}")
|
|
160
|
+
else:
|
|
161
|
+
print(f" ok: no run of more than {r.flat_max_frames} flat frames")
|
|
162
|
+
if rep["flat_ignored"]:
|
|
163
|
+
print(f" {len(rep['flat_ignored'])} short dip(s) ignored: "
|
|
164
|
+
+ ", ".join(f"{s / fps:.2f}s" for s, _ in rep["flat_ignored"][:6]))
|
|
165
|
+
for s, e in rep["frozen_warn"][:10]:
|
|
166
|
+
print(f" WARN frozen picture {_span(s, e, fps)} > dead_frames.frozen_warn_s="
|
|
167
|
+
f"{r.frozen_warn_s}: is something alive on it, or is the hold the point?")
|
|
168
|
+
q1, med, q3 = r.motion_band
|
|
169
|
+
print(f" motion {rep['motion_mean']:.1f} (reference band {q1}-{q3}, median {med}, "
|
|
170
|
+
"horizontal brand films; a question, not a gate)")
|
|
171
|
+
return 0 if rep["passed"] else 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main(argv: list[str] | None = None) -> int:
|
|
175
|
+
ap = argparse.ArgumentParser(description="Dead-frame gate: flat runs (block) and frozen picture (warn) on the final MP4.")
|
|
176
|
+
add_config_arg(ap)
|
|
177
|
+
ap.add_argument("video", help="final MP4")
|
|
178
|
+
args = ap.parse_args(argv)
|
|
179
|
+
try:
|
|
180
|
+
cfg = load(args.config)
|
|
181
|
+
r, from_template = rules(cfg)
|
|
182
|
+
if not os.path.exists(args.video):
|
|
183
|
+
raise ReadError(f"video not found: {args.video}")
|
|
184
|
+
frames = (f for _, f in all_frames(args.video, r.width, r.height))
|
|
185
|
+
rep = analyze(frames, video_fps(args.video), r)
|
|
186
|
+
return report(os.path.basename(args.video), rep, r, from_template)
|
|
187
|
+
except (ConfigError, ReadError, subprocess.SubprocessError, OSError) as e:
|
|
188
|
+
print(f"ERROR: {e}")
|
|
189
|
+
return 2
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
sys.exit(main())
|