wizz-method 2.11.2 → 2.12.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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/skills-registry.yaml +19 -5
  3. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +1 -1
  4. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
  5. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
  6. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +2 -2
  7. package/src/modules/wizz/_shared/video-pipeline.md +11 -3
  8. package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
  9. package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
  10. package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
  11. package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
  12. package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
  13. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
  14. package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
  15. package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
  16. package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
  17. package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
  18. package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
  19. package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
  20. package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
  21. package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
  22. package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
  23. package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
  24. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
  25. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
  26. package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
  27. package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
  28. package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
  29. package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
  30. package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
  31. package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
  32. package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
  33. package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
  34. package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
  35. package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
  36. package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
  37. package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
  38. package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
  39. package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
  40. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
  41. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
  42. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
  43. package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
  44. package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
  45. package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
  46. package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
  47. package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
  48. package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
  49. package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
  50. package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
  51. package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
  52. package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
  53. package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
  54. package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
  55. package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
  56. package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
  57. package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
  58. package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
  59. package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
  60. package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
  61. package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
  62. package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
  63. package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
  64. package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
  65. package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
  66. package/src/skills-lib/wizz-video-effects/scripts/timeline.py +85 -0
@@ -0,0 +1,40 @@
1
+ # SFX and music licensing: organic post vs paid ad
2
+
3
+ Research done on 2026-09-24; every source accessed that day. The question is never "is this sound trending" but "can this file run in a paid ad on the platform where the piece will run".
4
+
5
+ ## The central trap
6
+
7
+ - **Trending audio from the Instagram or TikTok picker is for personal use.** Meta mutes it at boost approval (reported since about Q1 2026 by [killthedj](https://killthedj.com/meta-sound-collection/); the official Meta announcement was not found, treat as third-party report). TikTok forbids the general library for commercial use; businesses must use the Commercial Music Library ([TikTok Ads Help](https://ads.tiktok.com/help/article/commercial-music-library?lang=en)). This is structural, not taste: **no trending picker audio in any ad.**
8
+ - **Meta has two things called "Sound Collection".** The in-app picker (availability varies by account type and region; "available in the app is not cleared for paid" per [usethirdchair, 2026-09-11](https://usethirdchair.com/blog/meta-sound-collection-explained-for-brands-and-rights-holders)) and the **Meta Business Sound Collection** (Business Suite / sound.facebook.com, 14,000+ royalty-free tracks and effects, cleared for videos, ads, Reels and Stories **inside Facebook and Instagram only**: [Meta Business Help 880459498798521](https://www.facebook.com/business/help/880459498798521), [1821972904537292](https://www.facebook.com/business/help/1821972904537292)).
9
+ - **TikTok CML is valid only inside TikTok.** Reposting the same video elsewhere loses the licence ([Velveteen](https://www.velveteen.fm/guides/tiktok-for-musicians/tiktok-commercial-music-library-explained)).
10
+
11
+ ## Source table
12
+
13
+ | Source | Organic Reels/TikTok | Paid ad | Attribution | Traps |
14
+ |---|---|---|---|---|
15
+ | Instagram picker trending audio | yes | **no** | n/a | muted at boost approval, no warning beforehand |
16
+ | TikTok general library | yes | **no** | n/a | commercial use requires the CML |
17
+ | Meta Sound Collection (in-app picker) | varies | not guaranteed | no | in the app does not mean cleared for paid |
18
+ | Meta Business Sound Collection | yes | yes, Facebook/Instagram only | no | not portable to TikTok or YouTube |
19
+ | TikTok CML | yes | yes, TikTok only | no | loses the licence outside TikTok; SFX coverage not confirmed in the official docs |
20
+ | **Pixabay Content License** ([summary](https://pixabay.com/service/license-summary/)) | yes | **yes**, mixed into a work | no | no standalone use (reselling the raw file); no printing on merch; content showing third-party brands; **zero indemnification** ([PicDefense](https://picdefense.io/resources/source-intel/pixabay/)); keep the licence certificate per file |
21
+ | Mixkit ([terms](https://mixkit.co/terms/), [license](https://mixkit.co/license/)) | yes | yes per the general terms | no | the specific "Sound Effects Free License" text could not be read on 2026-09-24: **open it by hand before adding Mixkit files** |
22
+ | Freesound CC0 | yes | yes | no | mixed library: confirm the licence of each file |
23
+ | Freesound CC-BY | yes | yes | **always** | visible credit is hard in a 15 s ad; keep a per-file record |
24
+ | Freesound CC-BY-NC | only truly non-commercial | **never** | yes | "commercial gain" includes ads and brand content |
25
+ | Epidemic Sound ([Pro plan](https://help.epidemicsound.com/hc/en-us/articles/26247236323858-Pro-plan)) | depends on plan | Pro and up (paid social); Business broader | no | confirm what happens to published work after cancelling |
26
+ | Artlist ([licence](https://help.artlist.io/hc/en-us/articles/29490991524253-Understanding-Artlist-s-license)) | yes | Pro and up only | no | the Social plan explicitly excludes paid media |
27
+
28
+ ## What this skill ships
29
+
30
+ - `assets/sfx/` holds only files with proven Pixabay provenance (see `assets/sfx/CREDITS.md` for the file list, derived cuts and what was left out).
31
+ - Portability decides the default: Pixabay files work on Meta ads, Reels and TikTok without switching library per channel. If a pipeline ever renders per platform, the Meta Business Sound Collection becomes a zero-risk option for Meta-only pieces.
32
+ - **Left out on purpose:** "vine boom", "record scratch" and "glitch". None has performance data, every source is a meme blog or a post count, and the comedy tone does not fit product and UI demos. They are out as fashion, not as a licence ban; reopening needs an argument, not enthusiasm.
33
+ - `cash` and `coin` stay: the concept is alive (TikTok discovery pages), but the file must come from a licensed library, never a viral clip captured from a feed.
34
+
35
+ ## Adding a sound
36
+
37
+ 1. Download from a source in the table whose "Paid ad" column says yes for the piece's platform.
38
+ 2. Save the licence certificate or the page URL and date next to the entry in `CREDITS.md`.
39
+ 3. Run `normalize_audio.py` so the file enters the level contract.
40
+ 4. Map it to a semantic event in the config (the event is the key, the file is a column). Run `sound.py` for coverage.
@@ -0,0 +1,129 @@
1
+ # When to use each effect, and when not
2
+
3
+ A catalogue of effects is easy; the refusal rule is what decides whether a video looks professional or zooms every two seconds.
4
+
5
+ **No number on this page is canonical.** Every value lives in the project's `effects.config.json` and is cited here by key. If this page and the config disagree, the config wins and this page is wrong.
6
+
7
+ ## How to read
8
+
9
+ Each effect has seven fields. The first three are the decision; the last four are execution.
10
+
11
+ | Field | Question |
12
+ |---|---|
13
+ | **Purpose** | what narrative work the effect does |
14
+ | **Enters when** | the trigger: always an event in the speech or on screen, never the clock |
15
+ | **Does not enter when** | the refusal; if empty, the effect is not ready for automatic use |
16
+ | **Intensity / duration** | config key or component constant |
17
+ | **Frequency** | ceiling and minimum gap from the previous one |
18
+ | **Conflicts** | what it cannot coexist with |
19
+ | **Priority** | the 0-1 `strength` it carries into the budget when competing for an instant |
20
+
21
+ Strength is not the effect's importance: it is the strength of the **narrative claim of that concrete instant**. The same punch is worth 0.9 on the sentence that turns the offer and 0.3 on an ordinary breath.
22
+
23
+ ## The rule above all others
24
+
25
+ **An effect that always happens stops marking what matters.**
26
+
27
+ A production system once had the rule "zoom punch on every SFX with volume >= 0.5". Almost every SFX sat at 0.50 or 0.55, so it fired on all of them: one zoom every 2.5 seconds, measured. The effect that existed to underline became the background. Hence the three laws:
28
+
29
+ 1. **Every effect goes through the budget.** No path writes an effect straight into the timeline data.
30
+ 2. **When it does not fit, switch effect; do not drop the instant.** Where the screen has headroom at the top, `punch`; where content sits against the safe zone, `flash`, which only touches opacity and moves no pixel.
31
+ 3. **The instant comes from a speech breath, never from the clock.** An effect on top of a word competes with it (`fill_breaths.py`).
32
+
33
+ ## Camera movement
34
+
35
+ ### `punch`: zoom pulse
36
+ - **Purpose:** underline the sentence just said. The system's exclamation mark.
37
+ - **Enters when:** the breath right after a sentence that carries the argument (the offer turn, the number that hurts, the promise), and at a narrative block cut.
38
+ - **Does not enter when:** screen content sits inside the punch margin of the piece's safe-zone profile (computed from `breaths.punch_peak_scale`); the punch scales around the centre and multiplies with micro-zoom, so at its peak it pushes content through the safe zone. `fill_breaths.py --visual` measures this per breath and gives the breath to a `flash` instead. Nor over a block of text the viewer must read whole, nor twice in the same sentence.
39
+ - **Intensity / duration:** peak about 1.035 (1.05 when the triggering sound is loud), window about 0.22 s.
40
+ - **Frequency:** `budget.per_effect.punch` (`max_per_min`, `gap_min`, `max_in_row`).
41
+ - **Conflicts:** `shake` in the same window, never both.
42
+ - **Priority:** 0.9 offer turn, 0.7 block cut, 0.4 an unloaded breath; below that it is not worth spending.
43
+
44
+ ### `microZoom`: slow continuous zoom
45
+ - **Purpose:** take the image out of photograph state. Not noticed; it is what keeps a still shot from looking still.
46
+ - **Enters when:** by default, on any shot longer than two seconds.
47
+ - **Does not enter when:** the screen already has strong motion of its own, or content is within about 21 px of the safe-zone edge: micro-zoom eats that margin and has failed pieces by one pixel.
48
+ - **Frequency:** continuous; does not count against the budget. It multiplies the punch, which is why the punch ceiling must account for it.
49
+
50
+ ### `drift`: handheld camera
51
+ - **Purpose:** physical presence for a shot that is otherwise a generated image.
52
+ - **Enters when:** actor shot or scene with depth.
53
+ - **Does not enter when:** the screen is a list, a table or anything read line by line. **Never raise its amplitude.**
54
+ - **Conflicts:** `shake`.
55
+
56
+ ## Impact
57
+
58
+ ### `shake`: short tremor
59
+ - **Purpose:** physical weight for something that lands: a stamp, a seal, a verdict.
60
+ - **Enters when:** the `verdict` sound event fires. That is the only trigger, declared in the config (`triggers` on the event).
61
+ - **Does not enter when:** there is no visual impact; never sustained, never as background rhythm, **never over text the viewer must read**.
62
+ - **Frequency:** `budget.per_effect.shake`, deliberately the rarest effect in the system, with `max_dur_s`.
63
+ - **Conflicts:** `drift` and `punch` in the same window.
64
+ - **Priority:** 0.95. When a verdict lands it almost always wins: it is the only effect with an exclusive semantic trigger.
65
+
66
+ ### `flash`: cut flash
67
+ - **Purpose:** mark a change without displacing anything. The punch's substitute exactly where the punch does not fit.
68
+ - **Enters when:** narrative block cut; as punch substitute on a screen pressed against the safe zone.
69
+ - **Does not enter when:** inside the same block without a screen change: a flash without a cut reads as a render glitch.
70
+ - **Frequency:** `budget.per_effect.flash`; more tolerant than punch because it moves no pixel.
71
+ - **Priority:** 0.6 block cut, 0.35 as substitute.
72
+
73
+ ## Transitions
74
+
75
+ The default transition is the **hard cut**. A transition is justified only when the screen change is also a change of subject.
76
+
77
+ - **`whip`** (blurred sweep): block cut during fast speech (`breaths.fast_speech_wps` and up; `breaths.pace_profiles` picks transition and cut sound per pace). Not on dragging speech or within the same subject. Duration in **frames**, while everything else is in seconds.
78
+ - **`zoom`** (scale transition): block cut in a dragging stretch, where whip would be violent. Not when the next shot opens with a punch.
79
+ - **`cut`**: every other cut. Never refused; it is the default.
80
+ - Fade and crossdissolve: cut (see [do-not-use.md](do-not-use.md)).
81
+
82
+ All transitions share `budget.per_effect.transition`.
83
+
84
+ ## Pattern interrupts
85
+
86
+ Share a tight budget (`budget.per_effect.interrupt`): a break that repeats stops breaking.
87
+
88
+ - **`suddenzoom`**: the sentence reframes the argument ("but you won't like the answer"). Not in the same block as another interrupt.
89
+ - **`freeze`**: a reaction to hold (the price, the result, the face). Not while speech continues on top: freezing the image with the voice running reads as a render failure.
90
+
91
+ ## Text and captions
92
+
93
+ - **Karaoke caption:** always when there is narration, from ctc-align word timestamps. The caption band is **exclusive**: no readable content inside it, ever. Silenced while a headline is on screen, but keep that window short: silencing captions removes a whole density band.
94
+ - **Headline:** at the start of a block, anchoring the subject. It does not repeat the caption; the price leads the headline only if the hook is the price. An unknown style name must fail, never fall back silently to a default.
95
+
96
+ ## Element entrance and exit
97
+
98
+ - **Entrance:** on the word that names the element, not before: nothing appears before it is mentioned. Two levels avoid the empty screen while waiting: the element exists as **base** (opacity about 0.4) from the start of the beat and rises to **highlight** (opacity 1) on the word.
99
+ - **Exit:** an element **recedes to context** (higher, smaller, low opacity); it never vanishes into emptiness. It leaves for good only when something else enters at the same instant.
100
+
101
+ ## Rhythm by stretch
102
+
103
+ Read by `check_rhythm.py`, which warns and never blocks (the sources are weak, see [editing-patterns.md](editing-patterns.md)):
104
+
105
+ - **Hook** closes at `rhythm.hook_s` (3 s, the window Meta's hook rate measures). Something moves from the first frame.
106
+ - **Body:** no stretch longer than `rhythm.max_static_s` without any visual change; cut cadence around `rhythm.body_cut_every_s`.
107
+ - **CTA:** lower effect density; no punch or zoom on the final card; price and button readable. Repeat the CTA visually mid-offer and on the final frame.
108
+
109
+ ## Avatar
110
+
111
+ Only for pieces with a generated avatar (`avatar.*`, optional block):
112
+
113
+ - **Full-shot ceiling:** after it, the actor leaves the shot or recedes to a card. The cut prefers the end of a sentence, then the longest pause near the ceiling, and only as a last resort the raw clock.
114
+ - **Occlusion ceiling:** a full-shot actor with nothing on top for longer is a layout error.
115
+ - **Take ceiling:** a generated avatar's gesture cycle repeats visibly after about 7.5 s.
116
+ - After each render, look at a frame of the CTA block: the actor has covered the price and the button with every gate green.
117
+
118
+ ## Checklist before approving a piece
119
+
120
+ No gate replaces this; several items are here because they failed in production.
121
+
122
+ 1. The excess gate passes, or what fails is justified in writing.
123
+ 2. No dominant effect closer than `budget.gap_min_global` to another.
124
+ 3. The caption band is clean **in the rendered frame**, not in a lint.
125
+ 4. A frame of the CTA block, looked at: price and button visible.
126
+ 5. If there is an avatar, the mouth matches this piece's audio. Look; do not trust the gate.
127
+ 6. No shake over text meant to be read.
128
+ 7. Loudness gate passes on the final file.
129
+ 8. The largest cluster of effects is not an accident: it marks something, or it goes.
@@ -0,0 +1,211 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Anti-excess engine: effects compete for a budget instead of all entering.
3
+
4
+ Why it exists. A rule like "zoom punch on every SFX with vol >= 0.5" fired on
5
+ almost every SFX of the source project (one zoom every 2.5s on the busiest
6
+ pieces). An effect that always happens stops marking what matters.
7
+
8
+ This module does not decide IF a SFX asks for an effect (that is `triggers`
9
+ in the sound config) nor HOW the effect is drawn. It only decides WHICH
10
+ candidates, from a list that already arrived too long, earn the right to
11
+ appear: by narrative strength first, then a ceiling per curve section, a
12
+ minimum distance between repeats of the same kind, a minimum global distance
13
+ between dominant effects, and a limit on same-kind effects in a burst.
14
+
15
+ Usage:
16
+ from budget import Candidate, select
17
+ res = select([Candidate(t=3.2, kind="punch", strength=0.8, reason="verdict")],
18
+ total=55.0, cfg=cfg)
19
+ print(res.report())
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from bisect import bisect_left, insort
24
+ from collections.abc import Iterable
25
+ from dataclasses import dataclass
26
+
27
+ from config import Config, CurveSection
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Candidate:
32
+ """An effect asking for a place in the timeline."""
33
+ t: float
34
+ kind: str # key of budget.per_effect: punch, flash, shake, interrupt, transition
35
+ strength: float # 0..1, narrative claim
36
+ reason: str # short human-readable why
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Refusal:
41
+ candidate: Candidate
42
+ rule: str # "gap_min" | "gap_min_global" | "max_in_row" | "ceiling" | "unknown_kind"
43
+ detail: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Result:
48
+ accepted: tuple[Candidate, ...] # always sorted by t
49
+ refusals: tuple[Refusal, ...]
50
+
51
+ def by_kind(self) -> dict[str, int]:
52
+ count: dict[str, int] = {}
53
+ for c in self.accepted:
54
+ count[c.kind] = count.get(c.kind, 0) + 1
55
+ return count
56
+
57
+ def report(self) -> str:
58
+ lines = [f"accepted: {len(self.accepted)} refused: {len(self.refusals)}"]
59
+ if self.accepted:
60
+ lines.append("by kind:")
61
+ for kind, n in self.by_kind().items():
62
+ lines.append(f" {kind:<10} {n}")
63
+ if self.refusals:
64
+ lines.append("refusals:")
65
+ for r in self.refusals:
66
+ lines.append(f" t={r.candidate.t:6.1f}s {r.candidate.kind:<10} "
67
+ f"[{r.rule}] {r.detail}")
68
+ return "\n".join(lines)
69
+
70
+
71
+ def _durations_and_weights(cfg: Config, total: float) -> list[tuple[str, float, float]]:
72
+ """Seconds of each curve section with its weight. The curve stores
73
+ CUMULATIVE fractions: a section runs from the previous until_frac to its own."""
74
+ out = []
75
+ prev = 0.0
76
+ for s in cfg.budget.curve:
77
+ out.append((s.name, (s.until_frac - prev) * total, s.weight))
78
+ prev = s.until_frac
79
+ return out
80
+
81
+
82
+ def section_ceiling(cfg: Config, kind: str, section: CurveSection, total: float) -> int:
83
+ """How many `kind` events fit in this section.
84
+
85
+ The whole-piece ceiling (budget.ceiling, from max_per_min) is SPLIT across
86
+ the sections in proportion to duration * weight. A heavier section gets a
87
+ bigger slice of the SAME total; it never invents a new ceiling:
88
+
89
+ slice = total_ceiling * (dur * weight) / sum(dur_i * weight_i)
90
+
91
+ Rounded to the nearest integer. A short section may round to 0: there is
92
+ no per-section floor, only the whole-piece floor of 1.
93
+ """
94
+ total_ceiling = cfg.budget.ceiling(kind, total)
95
+ parts = _durations_and_weights(cfg, total)
96
+ weighted = sum(d * w for _, d, w in parts)
97
+ if weighted <= 0:
98
+ return total_ceiling
99
+ dur, weight = next((d, w) for name, d, w in parts if name == section.name)
100
+ return round(total_ceiling * (dur * weight) / weighted)
101
+
102
+
103
+ def _nearest_gap(times: list[float], t: float) -> float | None:
104
+ """Distance to the nearest neighbour in a sorted list; None when empty."""
105
+ if not times:
106
+ return None
107
+ i = bisect_left(times, t)
108
+ near = []
109
+ if i > 0:
110
+ near.append(t - times[i - 1])
111
+ if i < len(times):
112
+ near.append(times[i] - t)
113
+ return min(near)
114
+
115
+
116
+ def _in_row_if_inserted(accepted: list[Candidate], pos: int, kind: str,
117
+ t: float, burst_gap: float) -> int:
118
+ """How many same-kind events would sit in the SAME BURST if a candidate
119
+ entered at `pos` of a list sorted by t.
120
+
121
+ Counted on the FINAL ORDER BY TIME, never on processing order (which is by
122
+ strength): a candidate accepted early for its strength can end up
123
+ surrounded, in time, by same-kind neighbours accepted later.
124
+
125
+ `burst_gap` separates bursts. Without it, "in a row" would span the whole
126
+ piece and a third punch 20s after the second would fail max_in_row=2,
127
+ cancelling the per-piece ceiling. A rule that cancels another rule of the
128
+ same module is wrong.
129
+ """
130
+ back, i, prev = 0, pos - 1, t
131
+ while i >= 0 and accepted[i].kind == kind and prev - accepted[i].t <= burst_gap:
132
+ back, prev, i = back + 1, accepted[i].t, i - 1
133
+ fwd, i, nxt = 0, pos, t
134
+ while i < len(accepted) and accepted[i].kind == kind and accepted[i].t - nxt <= burst_gap:
135
+ fwd, nxt, i = fwd + 1, accepted[i].t, i + 1
136
+ return back + fwd + 1
137
+
138
+
139
+ def as_candidate(c) -> Candidate:
140
+ """Accept a Candidate or a dict with the same keys (sound.py emits dicts
141
+ on purpose, so the two modules stay independent). A missing key fails
142
+ here with its name, not later mid-selection."""
143
+ if isinstance(c, Candidate):
144
+ return c
145
+ try:
146
+ return Candidate(t=float(c["t"]), kind=str(c["kind"]),
147
+ strength=float(c["strength"]), reason=str(c.get("reason", "")))
148
+ except (KeyError, TypeError) as e:
149
+ raise TypeError(
150
+ f"invalid candidate: {c!r}. Expected a Candidate or a dict with keys "
151
+ f"t, kind, strength (reason optional). Missing or wrong: {e}") from e
152
+
153
+
154
+ def _check(cand: Candidate, cfg: Config, total: float, state: dict) -> tuple[str, str] | None:
155
+ """The first rule the candidate breaks, as (rule, detail), or None."""
156
+ rule = cfg.budget.rule(cand.kind)
157
+ if rule is None:
158
+ return "unknown_kind", (f"kind {cand.kind!r} is not in budget.per_effect. "
159
+ f"Known: {', '.join(sorted(cfg.budget.per_effect))}")
160
+ sec = cfg.budget.section(cand.t, total)
161
+ cap = section_ceiling(cfg, cand.kind, sec, total)
162
+ used = state["per_section"].get((cand.kind, sec.name), 0)
163
+ if used >= cap:
164
+ return "ceiling", f"section {sec.name!r} of {cand.kind} already has {used} of {cap} allowed"
165
+ gap = _nearest_gap(state["times"].get(cand.kind, []), cand.t)
166
+ if gap is not None and gap < rule.gap_min:
167
+ return "gap_min", f"gap {gap:.1f}s < {rule.gap_min:.1f}s required for {cand.kind}"
168
+ if rule.dominant:
169
+ g = _nearest_gap(state["dominant"], cand.t)
170
+ if g is not None and g < cfg.budget.gap_min_global:
171
+ return "gap_min_global", (f"gap {g:.1f}s < {cfg.budget.gap_min_global:.1f}s "
172
+ f"required between dominant effects")
173
+ pos = bisect_left([c.t for c in state["accepted"]], cand.t)
174
+ row = _in_row_if_inserted(state["accepted"], pos, cand.kind, cand.t, cfg.budget.burst_gap_s)
175
+ if row > rule.max_in_row:
176
+ return "max_in_row", (f"{row} {cand.kind} in one burst (burst gap "
177
+ f"{cfg.budget.burst_gap_s}s) > {rule.max_in_row} allowed")
178
+ return None
179
+
180
+
181
+ def select(candidates: Iterable[Candidate | dict], total: float, cfg: Config) -> Result:
182
+ """Decide which candidates enter, by these rules in order:
183
+
184
+ 1. Process by decreasing strength (tie: increasing t): the strongest
185
+ narrative claim picks its place first. PRIORITY is by strength;
186
+ final ACCEPTANCE is read in time order.
187
+ 2. Unknown kind is never accepted silently.
188
+ 3. Ceiling per curve section (see `section_ceiling`).
189
+ 4. gap_min of the kind against accepted events of the SAME kind.
190
+ 5. gap_min_global against accepted DOMINANT events of any kind (a
191
+ non-dominant candidate is not held by this rule).
192
+ 6. max_in_row, measured on the final timeline sorted by t.
193
+
194
+ Deterministic: same input, same Result (no sets, no hash order, no random).
195
+ """
196
+ ordered = sorted((as_candidate(c) for c in candidates), key=lambda c: (-c.strength, c.t))
197
+ state = {"accepted": [], "per_section": {}, "times": {}, "dominant": []}
198
+ refusals: list[Refusal] = []
199
+ for cand in ordered:
200
+ broken = _check(cand, cfg, total, state)
201
+ if broken:
202
+ refusals.append(Refusal(cand, *broken))
203
+ continue
204
+ pos = bisect_left([c.t for c in state["accepted"]], cand.t)
205
+ state["accepted"].insert(pos, cand)
206
+ key = (cand.kind, cfg.budget.section(cand.t, total).name)
207
+ state["per_section"][key] = state["per_section"].get(key, 0) + 1
208
+ insort(state["times"].setdefault(cand.kind, []), cand.t)
209
+ if cfg.budget.rule(cand.kind).dominant:
210
+ insort(state["dominant"], cand.t)
211
+ return Result(accepted=tuple(state["accepted"]), refusals=tuple(refusals))
@@ -0,0 +1,218 @@
1
+ # -*- coding: utf-8 -*-
2
+ """EXCESS gate: measures what the other gates do not.
3
+
4
+ Why it exists. Every other gate measures a LACK (of density, of anchor, of
5
+ room inside the safe zone). None measured the opposite: an audit counted one
6
+ zoom punch every 2.5s on the busiest pieces and no gate said anything. This
7
+ script counts the real effects of a timeline JSON and fails when they exceed
8
+ the budget in the config (`budget`).
9
+
10
+ Timeline keys read (see references/data-contract.md; all may be absent):
11
+ punches, shakes, flashes numbers or objects {"at": ...}
12
+ interrupts objects {"at": ..., "kind": ...}
13
+ scenes a scene with `transition` other than "" or
14
+ "cut" counts as a transition event
15
+ duration if absent, derived from the scenes (and said)
16
+ sfx, scenes[].sfx every sound, checked TOGETHER against the three
17
+ refusals of sound.map_requests (unknown event,
18
+ collision, same sound too many times in a row)
19
+
20
+ Per effect kind it reports: count and per minute against the piece ceiling
21
+ (budget.ceiling), smallest gap against gap_min, longest run of same-kind
22
+ events closer than gap_min against max_in_row, and pairs of DIFFERENT dominant
23
+ kinds closer than gap_min_global. It always reports the largest cluster of
24
+ dominant effects in a CLUSTER_WINDOW_S window: it is what the viewer sees.
25
+
26
+ Exit code: 0 pass, 1 fail, 2 usage error or missing file. --warn measures and
27
+ always exits 0 (use it only while calibrating; the gate is meant to block).
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ from config import Budget, Config, ConfigError, add_config_arg, load
37
+ import sound
38
+ import timeline as tl
39
+
40
+ # Report window for the largest cluster. A display choice, not a rule: the
41
+ # span a viewer perceives at once.
42
+ CLUSTER_WINDOW_S = 10.0
43
+
44
+
45
+ def smallest_gap(times: list[float]) -> tuple[float | None, float | None]:
46
+ """(gap, instant of the first of the pair), or (None, None) with < 2 events."""
47
+ if len(times) < 2:
48
+ return None, None
49
+ a, b = min(zip(times, times[1:]), key=lambda p: p[1] - p[0])
50
+ return b - a, a
51
+
52
+
53
+ def longest_run(times: list[float], gap_min: float) -> tuple[int, float | None]:
54
+ """Longest run where each consecutive pair is closer than gap_min. Reuses
55
+ gap_min as the only threshold the config gives for 'glued'."""
56
+ if not times:
57
+ return 0, None
58
+ best = cur = 1
59
+ cur_start = best_start = times[0]
60
+ for a, b in zip(times, times[1:]):
61
+ if b - a < gap_min:
62
+ cur += 1
63
+ else:
64
+ cur, cur_start = 1, b
65
+ if cur > best:
66
+ best, best_start = cur, cur_start
67
+ return best, best_start
68
+
69
+
70
+ def _dominant(events: dict[str, list[float]], budget: Budget) -> list[tuple[float, str]]:
71
+ return sorted((t, kind) for kind, lst in events.items() for t in lst
72
+ if (r := budget.rule(kind)) and r.dominant)
73
+
74
+
75
+ def dominant_crossings(events: dict[str, list[float]], budget: Budget) -> list[tuple]:
76
+ """Pairs of DIFFERENT dominant kinds closer than gap_min_global. The
77
+ per-kind gap only guards repeats of the SAME kind; a punch glued to a
78
+ shake passes both and still overloads the same instant."""
79
+ marked = _dominant(events, budget)
80
+ found = []
81
+ for i, (ta, ka) in enumerate(marked):
82
+ for tb, kb in marked[i + 1:]:
83
+ dt = tb - ta
84
+ if dt >= budget.gap_min_global:
85
+ break
86
+ if ka != kb:
87
+ found.append((ta, ka, tb, kb, dt))
88
+ return found
89
+
90
+
91
+ def largest_cluster(events: dict[str, list[float]], budget: Budget,
92
+ window: float = CLUSTER_WINDOW_S) -> tuple[int, float | None]:
93
+ """Window of `window` seconds with the most dominant effects (two pointers)."""
94
+ dom = [t for t, _ in _dominant(events, budget)]
95
+ if not dom:
96
+ return 0, None
97
+ best_n, best_t, j = 1, dom[0], 0
98
+ for i in range(len(dom)):
99
+ while dom[i] - dom[j] >= window:
100
+ j += 1
101
+ if i - j + 1 > best_n:
102
+ best_n, best_t = i - j + 1, dom[j]
103
+ return best_n, best_t
104
+
105
+
106
+ def _kind_row(kind: str, times: list[float], total: float, cfg: Config) -> tuple[dict, list[str]]:
107
+ rule = cfg.budget.rule(kind)
108
+ n = len(times)
109
+ per_min = (n / total * 60.0) if total > 0 else 0.0
110
+ ceiling = cfg.budget.ceiling(kind, total)
111
+ gap, gap_at = smallest_gap(times)
112
+ run, run_at = longest_run(times, rule.gap_min)
113
+ row = {"kind": kind, "n": n, "per_min": per_min, "ceiling": ceiling,
114
+ "gap_measured": gap, "gap_required": rule.gap_min,
115
+ "run_measured": run, "run_allowed": rule.max_in_row}
116
+ errs = []
117
+ if n > ceiling:
118
+ errs.append(f"{kind}: {n} event(s) > ceiling of {ceiling} for {total:.1f}s "
119
+ f"({per_min:.1f}/min, limit budget.per_effect.{kind}.max_per_min="
120
+ f"{rule.max_per_min:g})")
121
+ if gap is not None and gap < rule.gap_min:
122
+ errs.append(f"{kind}: gap of {gap:.2f}s at {gap_at:.2f}s < gap_min {rule.gap_min:.1f}s")
123
+ if run > rule.max_in_row:
124
+ errs.append(f"{kind}: {run} in a row from {run_at:.2f}s > max_in_row {rule.max_in_row}")
125
+ return row, errs
126
+
127
+
128
+ def sound_violations(data: dict, cfg: Config) -> list[str]:
129
+ """The three sound refusals of sound.map_requests, applied to EVERY sound
130
+ in the timeline at once (sfx[] plus scene cut sounds). Sounds mapped in
131
+ separate passes can each be fine alone and still collide together."""
132
+ requests, errs = [], []
133
+ for s in tl.sounds(data):
134
+ ev = sound.event_of(s, cfg)
135
+ if ev is None:
136
+ errs.append(f"sound {s.get('src', '?')}@{float(s['at']):.2f}s refused "
137
+ f"(unknown_event): no `event` and no unique (file, vol) match in "
138
+ f"sound.events")
139
+ continue
140
+ requests.append((float(s["at"]), ev))
141
+ for r in sound.map_requests(requests, cfg).refusals:
142
+ errs.append(f"sound {r.event}@{r.t:.2f}s refused ({r.rule}): {r.detail}")
143
+ return errs
144
+
145
+
146
+ def analyze(data: dict, name: str, cfg: Config) -> dict:
147
+ """Measure one timeline and return a structured report. Never prints."""
148
+ total, derived = tl.duration(data)
149
+ events = tl.effect_events(data)
150
+ rows, violations = [], []
151
+ for kind in cfg.budget.per_effect:
152
+ row, errs = _kind_row(kind, events.get(kind, []), total, cfg)
153
+ rows.append(row)
154
+ violations.extend(errs)
155
+ for ta, ka, tb, kb, dt in dominant_crossings(events, cfg.budget):
156
+ violations.append(f"{ka}@{ta:.2f}s glued to {kb}@{tb:.2f}s ({dt:.2f}s) "
157
+ f"< gap_min_global {cfg.budget.gap_min_global:.1f}s")
158
+ violations.extend(sound_violations(data, cfg))
159
+ n_dom, t_dom = largest_cluster(events, cfg.budget)
160
+ return {"name": name, "duration": total, "duration_derived": derived, "rows": rows,
161
+ "cluster": {"n": n_dom, "start": t_dom}, "violations": violations,
162
+ "passed": not violations}
163
+
164
+
165
+ def print_report(rep: dict) -> None:
166
+ print(f"== excess of {rep['name']} ==")
167
+ if rep["duration_derived"]:
168
+ print(f" warning: no 'duration' in the JSON; derived from scenes: {rep['duration']:.2f}s")
169
+ print(f" {'kind':<10} | {'n':>3} | {'per min':>7} | {'cap':>4} | "
170
+ f"{'min gap':>8} | {'required':>8} | {'in a row':>8}")
171
+ for r in rep["rows"]:
172
+ gap = f"{r['gap_measured']:.2f}s" if r["gap_measured"] is not None else "-"
173
+ print(f" {r['kind']:<10} | {r['n']:>3} | {r['per_min']:>7.1f} | {r['ceiling']:>4} | "
174
+ f"{gap:>8} | {r['gap_required']:>7.1f}s | "
175
+ f"{str(r['run_measured']) + '/' + str(r['run_allowed']):>8}")
176
+ c = rep["cluster"]
177
+ if c["start"] is not None:
178
+ print(f" largest cluster: {c['n']} dominant effect(s) in {CLUSTER_WINDOW_S:.0f}s "
179
+ f"from {c['start']:.2f}s")
180
+ else:
181
+ print(" largest cluster: no dominant effect in the piece")
182
+ for v in rep["violations"]:
183
+ print(f" ERROR: {v}")
184
+ print(f" FAILED: {len(rep['violations'])} excess violation(s)" if rep["violations"]
185
+ else " ok: no excess measured")
186
+ print()
187
+
188
+
189
+ def main(argv: list[str] | None = None) -> int:
190
+ ap = argparse.ArgumentParser(
191
+ description="Excess gate: count effects of timeline JSON(s) against the budget.")
192
+ add_config_arg(ap)
193
+ ap.add_argument("timelines", nargs="+", help="timeline JSON path(s)")
194
+ ap.add_argument("--warn", action="store_true", help="measure but always exit 0")
195
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
196
+ args = ap.parse_args(argv)
197
+ try:
198
+ cfg = load(args.config)
199
+ reports = [analyze(tl.load(p), Path(p).stem, cfg) for p in args.timelines]
200
+ except (ConfigError, tl.TimelineError) as e:
201
+ print(f"ERROR: {e}")
202
+ return 2
203
+ if args.json:
204
+ print(json.dumps({r["name"]: r for r in reports}, ensure_ascii=False, indent=2))
205
+ else:
206
+ for r in reports:
207
+ print_report(r)
208
+ if len(reports) > 1:
209
+ failed = [r["name"] for r in reports if not r["passed"]]
210
+ print(f"== summary: {len(failed)}/{len(reports)} failed: {', '.join(failed)} =="
211
+ if failed else f"== summary: {len(reports)}/{len(reports)} passed ==")
212
+ if args.warn:
213
+ return 0
214
+ return 0 if all(r["passed"] for r in reports) else 1
215
+
216
+
217
+ if __name__ == "__main__":
218
+ sys.exit(main())