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,159 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""ONE-TAKE gate: no passage is only a change of scene.
|
|
3
|
+
|
|
4
|
+
For a piece meant as one continuous shot (a camera that flies from one beat
|
|
5
|
+
into the next, an element that becomes the next screen). Two checks:
|
|
6
|
+
|
|
7
|
+
CARRY (blocks, timeline). Every scene after the first declares in
|
|
8
|
+
`carry` what on screen survives the passage and becomes the next beat
|
|
9
|
+
("the price chip grows into the checkout card"). A scene without it,
|
|
10
|
+
or whose `transition` is a plain `cut`, is a swap, not a passage.
|
|
11
|
+
CUTS (blocks, final MP4). On a small luma copy of every frame
|
|
12
|
+
(one_take.analysis_width x one_take.analysis_height), a frame whose
|
|
13
|
+
mean absolute difference to the previous one is above
|
|
14
|
+
one_take.cut_min_diff AND one_take.cut_ratio times the median of its
|
|
15
|
+
neighbours (one_take.neighbour_frames on each side) is a hard cut. A
|
|
16
|
+
whip or a fast push changes many frames in a row, so it is not an
|
|
17
|
+
isolated spike; a cut is one.
|
|
18
|
+
|
|
19
|
+
What it cannot see:
|
|
20
|
+
- A dissolve between two unrelated scenes has no spike: it passes CUTS.
|
|
21
|
+
Only the `carry` line and the eye catch it.
|
|
22
|
+
- Whether the declared carry is really on screen. Extract the frames
|
|
23
|
+
around each passage and look.
|
|
24
|
+
- A cut hidden under a flash or a whip blur spreads its change over
|
|
25
|
+
several frames and passes.
|
|
26
|
+
|
|
27
|
+
Exit code: 0 pass, 1 fail, 2 file or config unreadable.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import os
|
|
33
|
+
import statistics
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Iterable
|
|
38
|
+
|
|
39
|
+
from check_dead_frames import mean_abs_diff
|
|
40
|
+
from check_safe_zone import ReadError, all_frames, video_fps
|
|
41
|
+
from config import Config, ConfigError, add_config_arg, load, optional_block
|
|
42
|
+
from timeline import TimelineError, load as load_timeline
|
|
43
|
+
|
|
44
|
+
BLOCK = "one_take"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class OneTakeRules:
|
|
49
|
+
width: int
|
|
50
|
+
height: int
|
|
51
|
+
cut_min_diff: float
|
|
52
|
+
cut_ratio: float
|
|
53
|
+
neighbour_frames: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def rules(cfg: Config) -> tuple[OneTakeRules, bool]:
|
|
57
|
+
"""(rules, from_template). Refuses an incoherent block."""
|
|
58
|
+
b, from_template = optional_block(cfg, BLOCK)
|
|
59
|
+
try:
|
|
60
|
+
r = OneTakeRules(
|
|
61
|
+
width=int(b["analysis_width"]), height=int(b["analysis_height"]),
|
|
62
|
+
cut_min_diff=float(b["cut_min_diff"]), cut_ratio=float(b["cut_ratio"]),
|
|
63
|
+
neighbour_frames=int(b["neighbour_frames"]))
|
|
64
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
65
|
+
raise ConfigError(f"{BLOCK}: missing or invalid key ({e})") from e
|
|
66
|
+
if r.width <= 0 or r.height <= 0:
|
|
67
|
+
raise ConfigError(f"{BLOCK}.analysis_width and analysis_height must be > 0")
|
|
68
|
+
if r.cut_min_diff <= 0 or r.cut_ratio <= 1:
|
|
69
|
+
raise ConfigError(f"{BLOCK}.cut_min_diff must be > 0 and cut_ratio > 1")
|
|
70
|
+
if r.neighbour_frames < 1:
|
|
71
|
+
raise ConfigError(f"{BLOCK}.neighbour_frames must be >= 1")
|
|
72
|
+
return r, from_template
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def missing_carry(scenes) -> list[tuple[int, float, str]]:
|
|
76
|
+
"""(index, start, reason) of every scene after the first that is a swap."""
|
|
77
|
+
out = []
|
|
78
|
+
for i, sc in enumerate(scenes or []):
|
|
79
|
+
if i == 0:
|
|
80
|
+
continue
|
|
81
|
+
start = float(sc.get("start", 0.0))
|
|
82
|
+
if not str(sc.get("carry") or "").strip():
|
|
83
|
+
out.append((i, start, "no `carry`"))
|
|
84
|
+
elif sc.get("transition") == "cut":
|
|
85
|
+
out.append((i, start, "`transition` is a plain cut"))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def diffs(frames: Iterable[bytes]) -> list[float]:
|
|
90
|
+
"""diffs[i] = change from frame i to frame i + 1."""
|
|
91
|
+
out, prev = [], None
|
|
92
|
+
for fr in frames:
|
|
93
|
+
if prev is not None:
|
|
94
|
+
out.append(mean_abs_diff(fr, prev))
|
|
95
|
+
prev = fr
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cut_frames(d: list[float], r: OneTakeRules) -> list[int]:
|
|
100
|
+
"""Frame index (the first frame after the jump) of every isolated spike."""
|
|
101
|
+
out = []
|
|
102
|
+
k = r.neighbour_frames
|
|
103
|
+
for i, v in enumerate(d):
|
|
104
|
+
if v <= r.cut_min_diff:
|
|
105
|
+
continue
|
|
106
|
+
around = d[max(0, i - k):i] + d[i + 1:i + 1 + k]
|
|
107
|
+
base = statistics.median(around) if around else 0.0
|
|
108
|
+
if v > r.cut_ratio * base:
|
|
109
|
+
out.append(i + 1)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def report(name: str, cuts: list[int], fps: float, swaps, r: OneTakeRules,
|
|
114
|
+
from_template: bool) -> int:
|
|
115
|
+
print(f"{name}: one-take gate")
|
|
116
|
+
if from_template:
|
|
117
|
+
print(f" note: no '{BLOCK}' block in the project config; using the template's")
|
|
118
|
+
if swaps is None:
|
|
119
|
+
print(" note: no --timeline; the `carry` of each passage was not checked")
|
|
120
|
+
elif swaps:
|
|
121
|
+
print(f" SWAP: {len(swaps)} passage(s) with nothing carried into the next beat:")
|
|
122
|
+
for i, start, why in swaps[:10]:
|
|
123
|
+
print(f" scene {i} at {start:6.2f}s: {why}")
|
|
124
|
+
else:
|
|
125
|
+
print(" ok: every passage declares what it carries")
|
|
126
|
+
if cuts:
|
|
127
|
+
print(f" CUT: {len(cuts)} hard cut(s) (frame change above one_take.cut_min_diff="
|
|
128
|
+
f"{r.cut_min_diff} and {r.cut_ratio}x its neighbours):")
|
|
129
|
+
for f in cuts[:10]:
|
|
130
|
+
print(f" {f / fps:6.2f}s (frame {f})")
|
|
131
|
+
else:
|
|
132
|
+
print(" ok: no hard cut in the file")
|
|
133
|
+
return 1 if cuts or swaps else 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(argv: list[str] | None = None) -> int:
|
|
137
|
+
ap = argparse.ArgumentParser(description="One-take gate: every passage carries something (timeline) and the final MP4 has no hard cut.")
|
|
138
|
+
add_config_arg(ap)
|
|
139
|
+
ap.add_argument("video", help="final MP4")
|
|
140
|
+
ap.add_argument("--timeline", help="timeline JSON; checks the `carry` of every scene")
|
|
141
|
+
args = ap.parse_args(argv)
|
|
142
|
+
try:
|
|
143
|
+
cfg = load(args.config)
|
|
144
|
+
r, from_template = rules(cfg)
|
|
145
|
+
swaps = missing_carry(load_timeline(args.timeline).get("scenes")) if args.timeline else None
|
|
146
|
+
if not os.path.exists(args.video):
|
|
147
|
+
raise ReadError(f"video not found: {args.video}")
|
|
148
|
+
d = diffs(f for _, f in all_frames(args.video, r.width, r.height))
|
|
149
|
+
if not d:
|
|
150
|
+
raise ReadError("fewer than two frames decoded")
|
|
151
|
+
return report(os.path.basename(args.video), cut_frames(d, r), video_fps(args.video),
|
|
152
|
+
swaps, r, from_template)
|
|
153
|
+
except (ConfigError, ReadError, TimelineError, subprocess.SubprocessError, OSError) as e:
|
|
154
|
+
print(f"ERROR: {e}")
|
|
155
|
+
return 2
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
sys.exit(main())
|
|
@@ -540,6 +540,19 @@ def validate(c: Config) -> None:
|
|
|
540
540
|
_validate_audio_blocks(c)
|
|
541
541
|
|
|
542
542
|
|
|
543
|
+
def optional_block(c: Config, key: str) -> tuple[dict, bool]:
|
|
544
|
+
"""(block, from_template) for a block added after projects already had a
|
|
545
|
+
config. A project config without it uses the TEMPLATE's block, so the
|
|
546
|
+
number still lives in one file; the caller says so in its report."""
|
|
547
|
+
own = _read_json(c.source).get(key)
|
|
548
|
+
if isinstance(own, dict):
|
|
549
|
+
return _notes_out(own), False
|
|
550
|
+
tpl = _read_json(TEMPLATE).get(key)
|
|
551
|
+
if not isinstance(tpl, dict):
|
|
552
|
+
raise ConfigError(f"block {key!r} is missing from the config and from the template")
|
|
553
|
+
return _notes_out(tpl), True
|
|
554
|
+
|
|
555
|
+
|
|
543
556
|
# -------------------------------------------------------------------- CLI glue
|
|
544
557
|
|
|
545
558
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Tests of check_chrome.py: the page-chrome / slide-look rules on a layout
|
|
3
|
+
snapshot (DOM of a HyperFrames composition) and on the text layers of a
|
|
4
|
+
Remotion timeline."""
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from contextlib import redirect_stdout
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import check_chrome as CC
|
|
13
|
+
from config import TEMPLATE, load
|
|
14
|
+
|
|
15
|
+
CFG = load(TEMPLATE)
|
|
16
|
+
RULES = CC.rules(CFG)[0]
|
|
17
|
+
W, H = 1080, 1920
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def block(text, x0, y0, x1, y1, font=60, **kw):
|
|
21
|
+
return {"text": text, "box": [x0 * W, y0 * H, x1 * W, y1 * H], "font_px": font, **kw}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def snap(*blocks, shapes=(), t=1.0):
|
|
25
|
+
return {"width": W, "height": H, "samples": [{"t": t, "blocks": list(blocks), "shapes": list(shapes)}]}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def lint(data, allow=()):
|
|
29
|
+
samples = CC.samples_from_snapshot(data)
|
|
30
|
+
return CC.lint(samples, RULES, set(allow))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def rules_hit(rep):
|
|
34
|
+
return {h["rule"] for h in rep["hits"]}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestPageChrome(unittest.TestCase):
|
|
38
|
+
def test_clean_reel_passes(self):
|
|
39
|
+
rep = lint(snap(block("O erro que custa caro", 0.1, 0.40, 0.9, 0.48, font=96, align="center"),
|
|
40
|
+
block("custa", 0.35, 0.70, 0.65, 0.74, font=72, role="caption")))
|
|
41
|
+
self.assertTrue(rep["passed"], rep["hits"])
|
|
42
|
+
|
|
43
|
+
def test_corner_brand_label_fails(self):
|
|
44
|
+
rep = lint(snap(block("WIZZ", 0.05, 0.03, 0.15, 0.05, font=30)))
|
|
45
|
+
self.assertIn("C1", rules_hit(rep))
|
|
46
|
+
self.assertFalse(rep["passed"])
|
|
47
|
+
|
|
48
|
+
def test_tracked_caps_label_fails(self):
|
|
49
|
+
rep = lint(snap(block("NOVO MÉTODO", 0.3, 0.30, 0.7, 0.32, font=36, caps=True, tracking_em=0.2)))
|
|
50
|
+
self.assertIn("C2", rules_hit(rep))
|
|
51
|
+
|
|
52
|
+
def test_counter_fails(self):
|
|
53
|
+
for text in ("02 / 05", "01 - THE WAIT", "Parte 3", "Step 2"):
|
|
54
|
+
with self.subTest(text=text):
|
|
55
|
+
rep = lint(snap(block(text, 0.4, 0.5, 0.6, 0.52, font=40)))
|
|
56
|
+
self.assertIn("C3", rules_hit(rep))
|
|
57
|
+
|
|
58
|
+
def test_twenty_four_seven_is_not_a_counter(self):
|
|
59
|
+
rep = lint(snap(block("24/7 suporte", 0.4, 0.5, 0.6, 0.52, font=40)))
|
|
60
|
+
self.assertNotIn("C3", rules_hit(rep))
|
|
61
|
+
|
|
62
|
+
def test_timecode_and_metadata_fail(self):
|
|
63
|
+
for text in ("00:00:12", "30 FPS", "1080x1920", "REC"):
|
|
64
|
+
with self.subTest(text=text):
|
|
65
|
+
rep = lint(snap(block(text, 0.4, 0.5, 0.6, 0.52, font=40)))
|
|
66
|
+
self.assertIn("C4", rules_hit(rep))
|
|
67
|
+
|
|
68
|
+
def test_progress_bar_growing_fails(self):
|
|
69
|
+
data = {"width": W, "height": H, "samples": [
|
|
70
|
+
{"t": t, "blocks": [], "shapes": [{"id": "bar", "kind": "bar", "box": [0, 10, w * W, 18]}]}
|
|
71
|
+
for t, w in ((1.0, 0.1), (2.0, 0.3), (3.0, 0.5))]}
|
|
72
|
+
rep = lint(data)
|
|
73
|
+
self.assertIn("P1", rules_hit(rep))
|
|
74
|
+
|
|
75
|
+
def test_header_bar_fails(self):
|
|
76
|
+
rep = lint(snap(shapes=[{"kind": "bar", "box": [0, 0, W, 0.06 * H], "filled": True}]))
|
|
77
|
+
self.assertIn("C6", rules_hit(rep))
|
|
78
|
+
|
|
79
|
+
def test_full_frame_background_is_not_a_bar(self):
|
|
80
|
+
rep = lint(snap(shapes=[{"kind": "bar", "box": [0, 0, W, H], "filled": True}]))
|
|
81
|
+
self.assertNotIn("C6", rules_hit(rep))
|
|
82
|
+
|
|
83
|
+
def test_kicker_over_headline_fails(self):
|
|
84
|
+
rep = lint(snap(block("o segredo", 0.1, 0.38, 0.4, 0.40, font=30),
|
|
85
|
+
block("Pare de perder dinheiro", 0.1, 0.41, 0.9, 0.47, font=90)))
|
|
86
|
+
self.assertIn("C7", rules_hit(rep))
|
|
87
|
+
|
|
88
|
+
def test_left_headline_beside_right_media_fails(self):
|
|
89
|
+
rep = lint(snap(block("Seu app", 0.05, 0.40, 0.45, 0.46, font=90, align="left"),
|
|
90
|
+
shapes=[{"kind": "media", "box": [0.55 * W, 0.30 * H, 0.95 * W, 0.70 * H]}]))
|
|
91
|
+
hits = [h for h in rep["hits"] if h["rule"] == "D1"]
|
|
92
|
+
self.assertTrue(hits)
|
|
93
|
+
self.assertEqual(hits[0]["sev"], "FAIL")
|
|
94
|
+
|
|
95
|
+
def test_left_headline_alone_only_warns(self):
|
|
96
|
+
rep = lint(snap(block("Seu app", 0.05, 0.40, 0.45, 0.46, font=90, align="left")))
|
|
97
|
+
hits = [h for h in rep["hits"] if h["rule"] == "D1"]
|
|
98
|
+
self.assertEqual([h["sev"] for h in hits], ["WARN"])
|
|
99
|
+
self.assertTrue(rep["passed"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestExemptions(unittest.TestCase):
|
|
103
|
+
def test_caption_and_legal_roles_are_not_chrome(self):
|
|
104
|
+
rep = lint(snap(block("PARTE 2", 0.4, 0.5, 0.6, 0.52, font=40, role="caption"),
|
|
105
|
+
block("Resultados variam. CNPJ 00.000", 0.05, 0.95, 0.6, 0.97, font=24, role="legal")))
|
|
106
|
+
self.assertTrue(rep["passed"], rep["hits"])
|
|
107
|
+
|
|
108
|
+
def test_allow_list_from_a_measured_style(self):
|
|
109
|
+
data = {"width": W, "height": H, "samples": [
|
|
110
|
+
{"t": t, "blocks": [], "shapes": [{"id": "bar", "kind": "bar", "box": [0, 10, w * W, 18]}]}
|
|
111
|
+
for t, w in ((1.0, 0.1), (2.0, 0.3), (3.0, 0.5))]}
|
|
112
|
+
rep = lint(data, allow={"P1"})
|
|
113
|
+
self.assertTrue(rep["passed"])
|
|
114
|
+
self.assertEqual(rep["allowed"], ["P1"])
|
|
115
|
+
|
|
116
|
+
def test_text_in_flight_is_not_a_held_label(self):
|
|
117
|
+
# Same label at a different place 0.1 s later: it is moving through
|
|
118
|
+
# the corner, not parked there.
|
|
119
|
+
data = {"width": W, "height": H, "samples": [
|
|
120
|
+
{"t": 1.0, "blocks": [block("WIZZ", 0.05, 0.03, 0.15, 0.05, font=30)], "shapes": []},
|
|
121
|
+
{"t": 1.1, "blocks": [block("WIZZ", 0.25, 0.20, 0.35, 0.22, font=30)], "shapes": []}]}
|
|
122
|
+
self.assertNotIn("C1", rules_hit(lint(data)))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestTimeline(unittest.TestCase):
|
|
126
|
+
def test_remotion_text_layers_are_linted(self):
|
|
127
|
+
tl = {"duration": 10.0, "elements": [
|
|
128
|
+
{"at": 0.0, "until": 10.0, "kind": "text", "text": "02/05",
|
|
129
|
+
"box": [900, 40, 1040, 80], "font_px": 32},
|
|
130
|
+
{"at": 0.6, "kind": "text"}]} # an element without text is skipped
|
|
131
|
+
samples = CC.samples_from_timeline(tl, CFG.frame.width, CFG.frame.height, RULES)
|
|
132
|
+
rep = CC.lint(samples, RULES, set())
|
|
133
|
+
self.assertIn("C3", rules_hit(rep))
|
|
134
|
+
self.assertIn("C1", rules_hit(rep))
|
|
135
|
+
|
|
136
|
+
def test_timeline_progress_bar_from_box_end(self):
|
|
137
|
+
tl = {"duration": 10.0, "elements": [
|
|
138
|
+
{"at": 0.0, "until": 10.0, "kind": "bar", "box": [0, 1880, 100, 1890],
|
|
139
|
+
"box_end": [0, 1880, 1080, 1890]}]}
|
|
140
|
+
samples = CC.samples_from_timeline(tl, W, H, RULES)
|
|
141
|
+
self.assertIn("P1", rules_hit(CC.lint(samples, RULES, set())))
|
|
142
|
+
|
|
143
|
+
def test_flash_label_shorter_than_held_s_is_skipped(self):
|
|
144
|
+
tl = {"duration": 5.0, "elements": [
|
|
145
|
+
{"at": 1.0, "until": 1.1, "kind": "text", "text": "WIZZ",
|
|
146
|
+
"box": [40, 40, 200, 80], "font_px": 30}]}
|
|
147
|
+
samples = CC.samples_from_timeline(tl, W, H, RULES)
|
|
148
|
+
self.assertNotIn("C1", rules_hit(CC.lint(samples, RULES, set())))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestCli(unittest.TestCase):
|
|
152
|
+
def _run(self, args):
|
|
153
|
+
buf = io.StringIO()
|
|
154
|
+
with redirect_stdout(buf):
|
|
155
|
+
code = CC.main(["--config", str(TEMPLATE), *args])
|
|
156
|
+
return code, buf.getvalue()
|
|
157
|
+
|
|
158
|
+
def test_snapshot_fail_exits_1(self):
|
|
159
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
160
|
+
p = Path(tmp) / "layout.json"
|
|
161
|
+
p.write_text(json.dumps(snap(block("00:00:12", 0.4, 0.5, 0.6, 0.52, font=40))), encoding="utf-8")
|
|
162
|
+
code, out = self._run(["--snapshot", str(p)])
|
|
163
|
+
self.assertEqual(code, 1, out)
|
|
164
|
+
self.assertIn("C4", out)
|
|
165
|
+
|
|
166
|
+
def test_timeline_allow_key_is_read(self):
|
|
167
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
168
|
+
p = Path(tmp) / "t.json"
|
|
169
|
+
p.write_text(json.dumps({"duration": 5.0, "chrome_allow": ["C3"], "elements": [
|
|
170
|
+
{"at": 0.0, "until": 5.0, "kind": "text", "text": "Dia 3",
|
|
171
|
+
"box": [400, 900, 680, 960], "font_px": 40}]}), encoding="utf-8")
|
|
172
|
+
code, out = self._run(["--timeline", str(p)])
|
|
173
|
+
self.assertEqual(code, 0, out)
|
|
174
|
+
|
|
175
|
+
def test_missing_input_exits_2(self):
|
|
176
|
+
code, _ = self._run(["--snapshot", "/nonexistent.json"])
|
|
177
|
+
self.assertEqual(code, 2)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
unittest.main()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Tests of check_dead_frames.py: flat-field runs (blocks), frozen picture
|
|
3
|
+
(warns), the motion note, and the config fallback to the template. The
|
|
4
|
+
ffmpeg round trip is skipped when ffmpeg is absent."""
|
|
5
|
+
import copy
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from contextlib import redirect_stdout
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import check_dead_frames as DF
|
|
17
|
+
from config import TEMPLATE, load
|
|
18
|
+
|
|
19
|
+
CFG = load(TEMPLATE)
|
|
20
|
+
RULES = DF.rules(CFG)[0]
|
|
21
|
+
W, H = RULES.width, RULES.height
|
|
22
|
+
SIZE = W * H
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def flat(v: int = 0) -> bytes:
|
|
26
|
+
return bytes([v]) * SIZE
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def textured(seed: int, amp: int = 60) -> bytes:
|
|
30
|
+
rnd = random.Random(seed)
|
|
31
|
+
return bytes(100 + rnd.randrange(amp) for _ in range(SIZE))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def jitter(base: bytes, seed: int, amp: int = 3) -> bytes:
|
|
35
|
+
"""The same picture plus small per-pixel noise: a talking head that
|
|
36
|
+
barely moves (sensor noise, breathing, compression)."""
|
|
37
|
+
rnd = random.Random(seed)
|
|
38
|
+
return bytes(max(0, min(255, b + rnd.randrange(-amp, amp + 1))) for b in base)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestFrameMeasures(unittest.TestCase):
|
|
42
|
+
def test_uniform_frame_is_flat(self):
|
|
43
|
+
self.assertTrue(DF.is_flat(flat(0), RULES))
|
|
44
|
+
self.assertTrue(DF.is_flat(flat(200), RULES))
|
|
45
|
+
|
|
46
|
+
def test_textured_frame_is_not_flat(self):
|
|
47
|
+
self.assertFalse(DF.is_flat(textured(1), RULES))
|
|
48
|
+
|
|
49
|
+
def test_dark_frame_with_one_small_caption_is_not_flat(self):
|
|
50
|
+
# Range catches it even when std-dev is tiny: that frame belongs to
|
|
51
|
+
# the density gate, not to this one.
|
|
52
|
+
f = bytearray(flat(10))
|
|
53
|
+
f[SIZE // 2:SIZE // 2 + 6] = bytes([250]) * 6
|
|
54
|
+
self.assertFalse(DF.is_flat(bytes(f), RULES))
|
|
55
|
+
|
|
56
|
+
def test_mean_abs_diff(self):
|
|
57
|
+
self.assertEqual(DF.mean_abs_diff(flat(10), flat(10)), 0.0)
|
|
58
|
+
self.assertAlmostEqual(DF.mean_abs_diff(flat(10), flat(14)), 4.0)
|
|
59
|
+
|
|
60
|
+
def test_runs(self):
|
|
61
|
+
self.assertEqual(DF.runs([False, True, True, False, True]), [(1, 2), (4, 4)])
|
|
62
|
+
self.assertEqual(DF.runs([]), [])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestAnalyze(unittest.TestCase):
|
|
66
|
+
FPS = 30.0
|
|
67
|
+
|
|
68
|
+
def _rep(self, frames):
|
|
69
|
+
return DF.analyze(frames, self.FPS, RULES)
|
|
70
|
+
|
|
71
|
+
def test_short_black_dip_inside_a_transition_passes(self):
|
|
72
|
+
frames = [textured(i) for i in range(20)] + [flat()] * RULES.flat_max_frames \
|
|
73
|
+
+ [textured(100 + i) for i in range(20)]
|
|
74
|
+
rep = self._rep(frames)
|
|
75
|
+
self.assertTrue(rep["passed"], rep)
|
|
76
|
+
self.assertEqual(rep["flat_fail"], [])
|
|
77
|
+
self.assertEqual(len(rep["flat_ignored"]), 1)
|
|
78
|
+
|
|
79
|
+
def test_black_gap_longer_than_the_limit_fails(self):
|
|
80
|
+
frames = [textured(i) for i in range(20)] + [flat()] * (RULES.flat_max_frames + 2) \
|
|
81
|
+
+ [textured(100 + i) for i in range(20)]
|
|
82
|
+
rep = self._rep(frames)
|
|
83
|
+
self.assertFalse(rep["passed"])
|
|
84
|
+
(s, e), = rep["flat_fail"]
|
|
85
|
+
self.assertEqual((s, e), (20, 20 + RULES.flat_max_frames + 1))
|
|
86
|
+
|
|
87
|
+
def test_frozen_picture_warns_but_does_not_block(self):
|
|
88
|
+
still = textured(7)
|
|
89
|
+
n = int(self.FPS * (RULES.frozen_warn_s + 1.0))
|
|
90
|
+
frames = [textured(i) for i in range(10)] + [still] * n + [textured(50 + i) for i in range(10)]
|
|
91
|
+
rep = self._rep(frames)
|
|
92
|
+
self.assertTrue(rep["passed"])
|
|
93
|
+
self.assertEqual(len(rep["frozen_warn"]), 1)
|
|
94
|
+
|
|
95
|
+
def test_talking_head_micro_motion_is_not_frozen(self):
|
|
96
|
+
base = textured(3)
|
|
97
|
+
frames = [jitter(base, i) for i in range(int(self.FPS * 4))]
|
|
98
|
+
rep = self._rep(frames)
|
|
99
|
+
self.assertEqual(rep["frozen_warn"], [])
|
|
100
|
+
self.assertTrue(rep["passed"])
|
|
101
|
+
|
|
102
|
+
def test_motion_note_is_reported(self):
|
|
103
|
+
frames = [textured(i) for i in range(5)]
|
|
104
|
+
rep = self._rep(frames)
|
|
105
|
+
self.assertGreater(rep["motion_mean"], 0.0)
|
|
106
|
+
|
|
107
|
+
def test_no_frame_is_an_error(self):
|
|
108
|
+
with self.assertRaises(DF.ReadError):
|
|
109
|
+
self._rep([])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestConfigFallback(unittest.TestCase):
|
|
113
|
+
def test_config_without_block_uses_template(self):
|
|
114
|
+
raw = json.loads(TEMPLATE.read_text(encoding="utf-8"))
|
|
115
|
+
raw = {k: v for k, v in copy.deepcopy(raw).items() if k != "dead_frames"}
|
|
116
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
117
|
+
p = Path(tmp) / "effects.config.json"
|
|
118
|
+
p.write_text(json.dumps(raw), encoding="utf-8")
|
|
119
|
+
r, from_template = DF.rules(load(p))
|
|
120
|
+
self.assertTrue(from_template)
|
|
121
|
+
self.assertEqual(r.flat_max_frames, RULES.flat_max_frames)
|
|
122
|
+
|
|
123
|
+
def test_incoherent_block_is_refused(self):
|
|
124
|
+
raw = json.loads(TEMPLATE.read_text(encoding="utf-8"))
|
|
125
|
+
raw["dead_frames"]["flat_max_frames"] = -1
|
|
126
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
127
|
+
p = Path(tmp) / "effects.config.json"
|
|
128
|
+
p.write_text(json.dumps(raw), encoding="utf-8")
|
|
129
|
+
with self.assertRaises(DF.ConfigError):
|
|
130
|
+
DF.rules(load(p))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestCli(unittest.TestCase):
|
|
134
|
+
def test_missing_video_exits_2(self):
|
|
135
|
+
buf = io.StringIO()
|
|
136
|
+
with redirect_stdout(buf):
|
|
137
|
+
code = DF.main(["--config", str(TEMPLATE), "/nonexistent/final.mp4"])
|
|
138
|
+
self.assertEqual(code, 2)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "ffmpeg not installed")
|
|
142
|
+
class TestOnRealFile(unittest.TestCase):
|
|
143
|
+
def _make(self, tmp: str, black_s: float) -> str:
|
|
144
|
+
out = str(Path(tmp) / "piece.mp4")
|
|
145
|
+
src = ("testsrc2=size=270x480:rate=30:duration=1[a];"
|
|
146
|
+
f"color=black:size=270x480:rate=30:duration={black_s}[b];"
|
|
147
|
+
"testsrc2=size=270x480:rate=30:duration=1[c];[a][b][c]concat=n=3:v=1:a=0")
|
|
148
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-filter_complex", src,
|
|
149
|
+
"-pix_fmt", "yuv420p", out], check=True)
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
def _run(self, video: str) -> tuple[int, str]:
|
|
153
|
+
buf = io.StringIO()
|
|
154
|
+
with redirect_stdout(buf):
|
|
155
|
+
code = DF.main(["--config", str(TEMPLATE), video])
|
|
156
|
+
return code, buf.getvalue()
|
|
157
|
+
|
|
158
|
+
def test_half_second_black_gap_fails(self):
|
|
159
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
160
|
+
code, out = self._run(self._make(tmp, 0.5))
|
|
161
|
+
self.assertEqual(code, 1, out)
|
|
162
|
+
self.assertIn("FLAT", out)
|
|
163
|
+
|
|
164
|
+
def test_one_frame_black_dip_passes(self):
|
|
165
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
166
|
+
code, out = self._run(self._make(tmp, 1 / 30))
|
|
167
|
+
self.assertEqual(code, 0, out)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
unittest.main()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Tests of check_one_take.py: the `carry` of every passage (timeline), hard
|
|
3
|
+
cuts as isolated spikes of frame change (a whip is not one), and the config
|
|
4
|
+
fallback to the template. The ffmpeg round trip is skipped when ffmpeg is
|
|
5
|
+
absent."""
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from contextlib import redirect_stdout
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import check_one_take as OT
|
|
16
|
+
from config import TEMPLATE, load
|
|
17
|
+
|
|
18
|
+
CFG = load(TEMPLATE)
|
|
19
|
+
RULES = OT.rules(CFG)[0]
|
|
20
|
+
W, H = RULES.width, RULES.height
|
|
21
|
+
SIZE = W * H
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def gradient(offset: int) -> bytes:
|
|
25
|
+
"""A horizontal ramp shifted by `offset` columns: a camera that pans."""
|
|
26
|
+
return bytes(((x + offset) * 4) % 256 for _ in range(H) for x in range(W))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def flat(v: int) -> bytes:
|
|
30
|
+
return bytes([v]) * SIZE
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestCarry(unittest.TestCase):
|
|
34
|
+
def test_every_passage_with_carry_passes(self):
|
|
35
|
+
scenes = [{"start": 0.0}, {"start": 3.0, "transition": "push", "carry": "price chip grows into the card"}]
|
|
36
|
+
self.assertEqual(OT.missing_carry(scenes), [])
|
|
37
|
+
|
|
38
|
+
def test_scene_without_carry_is_a_swap(self):
|
|
39
|
+
scenes = [{"start": 0.0}, {"start": 3.0, "transition": "push"}, {"start": 6.0, "carry": " "}]
|
|
40
|
+
self.assertEqual([i for i, _, _ in OT.missing_carry(scenes)], [1, 2])
|
|
41
|
+
|
|
42
|
+
def test_plain_cut_with_carry_is_still_a_swap(self):
|
|
43
|
+
scenes = [{"start": 0.0}, {"start": 3.0, "transition": "cut", "carry": "the logo"}]
|
|
44
|
+
self.assertEqual(OT.missing_carry(scenes)[0][2], "`transition` is a plain cut")
|
|
45
|
+
|
|
46
|
+
def test_first_scene_needs_nothing(self):
|
|
47
|
+
self.assertEqual(OT.missing_carry([{"start": 0.0}]), [])
|
|
48
|
+
self.assertEqual(OT.missing_carry(None), [])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestCuts(unittest.TestCase):
|
|
52
|
+
def test_slow_pan_has_no_cut(self):
|
|
53
|
+
d = OT.diffs(gradient(i) for i in range(40))
|
|
54
|
+
self.assertEqual(OT.cut_frames(d, RULES), [])
|
|
55
|
+
|
|
56
|
+
def test_hard_cut_is_found_on_the_first_frame_after_it(self):
|
|
57
|
+
frames = [gradient(i) for i in range(20)] + [flat(240 - i) for i in range(20)]
|
|
58
|
+
d = OT.diffs(frames)
|
|
59
|
+
self.assertEqual(OT.cut_frames(d, RULES), [20])
|
|
60
|
+
|
|
61
|
+
def test_fast_whip_over_several_frames_is_not_a_cut(self):
|
|
62
|
+
# Large change on every frame for a while: a move, not a cut.
|
|
63
|
+
frames = [gradient(0)] * 10 + [gradient(i * 9) for i in range(1, 9)] + [gradient(72)] * 10
|
|
64
|
+
d = OT.diffs(frames)
|
|
65
|
+
self.assertGreater(max(d), RULES.cut_min_diff)
|
|
66
|
+
self.assertEqual(OT.cut_frames(d, RULES), [])
|
|
67
|
+
|
|
68
|
+
def test_small_jump_under_the_floor_is_ignored(self):
|
|
69
|
+
frames = [flat(100)] * 10 + [flat(100 + int(RULES.cut_min_diff) - 2)] * 10
|
|
70
|
+
self.assertEqual(OT.cut_frames(OT.diffs(frames), RULES), [])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestRules(unittest.TestCase):
|
|
74
|
+
def test_template_block_is_coherent(self):
|
|
75
|
+
r, _ = OT.rules(CFG)
|
|
76
|
+
self.assertGreater(r.cut_ratio, 1)
|
|
77
|
+
|
|
78
|
+
def test_project_config_without_block_falls_back_to_template(self):
|
|
79
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
80
|
+
data = json.loads(Path(TEMPLATE).read_text(encoding="utf-8"))
|
|
81
|
+
data.pop(OT.BLOCK)
|
|
82
|
+
p = Path(tmp) / "effects.config.json"
|
|
83
|
+
p.write_text(json.dumps(data), encoding="utf-8")
|
|
84
|
+
_, from_template = OT.rules(load(str(p)))
|
|
85
|
+
self.assertTrue(from_template)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "ffmpeg not installed")
|
|
89
|
+
class TestRoundTrip(unittest.TestCase):
|
|
90
|
+
def _render(self, tmp: str, filters: str) -> str:
|
|
91
|
+
out = str(Path(tmp) / "piece.mp4")
|
|
92
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", filters,
|
|
93
|
+
"-pix_fmt", "yuv420p", out], check=True)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
def _run(self, *args) -> tuple[int, str]:
|
|
97
|
+
buf = io.StringIO()
|
|
98
|
+
with redirect_stdout(buf):
|
|
99
|
+
code = OT.main(["--config", str(TEMPLATE), *args])
|
|
100
|
+
return code, buf.getvalue()
|
|
101
|
+
|
|
102
|
+
def test_two_colours_spliced_fail_and_a_timeline_without_carry_too(self):
|
|
103
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
104
|
+
video = self._render(tmp, "color=c=0x202020:s=270x480:d=1:r=24,format=gray[a];"
|
|
105
|
+
"color=c=0xe0e0e0:s=270x480:d=1:r=24,format=gray[b];"
|
|
106
|
+
"[a][b]concat=n=2:v=1")
|
|
107
|
+
tl = Path(tmp) / "t.json"
|
|
108
|
+
tl.write_text(json.dumps({"scenes": [{"start": 0}, {"start": 1, "transition": "cut"}]}))
|
|
109
|
+
code, out = self._run(video, "--timeline", str(tl))
|
|
110
|
+
self.assertEqual(code, 1, out)
|
|
111
|
+
self.assertIn("CUT", out)
|
|
112
|
+
self.assertIn("SWAP", out)
|
|
113
|
+
|
|
114
|
+
def test_continuous_gradient_passes(self):
|
|
115
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
116
|
+
video = self._render(tmp, "gradients=s=270x480:d=2:r=24:speed=0.02")
|
|
117
|
+
code, out = self._run(video)
|
|
118
|
+
self.assertEqual(code, 0, out)
|
|
119
|
+
|
|
120
|
+
def test_missing_video_is_an_input_error(self):
|
|
121
|
+
code, _ = self._run("/nonexistent.mp4")
|
|
122
|
+
self.assertEqual(code, 2)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
unittest.main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LemoLab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|