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.
- package/package.json +1 -1
- package/skills-registry.yaml +19 -5
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +2 -2
- package/src/modules/wizz/_shared/video-pipeline.md +11 -3
- package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
- package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
- package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
- package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
- package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
- package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
- package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
- package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
- package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
- package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
- package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
- package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
- package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
- package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
- package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
- package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
- package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
- package/src/skills-lib/wizz-video-effects/scripts/timeline.py +85 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Pure, deterministic math for the effects timeline (references/data-contract.md).
|
|
2
|
+
// No Math.random, no Date: the same frame always gives the same value.
|
|
3
|
+
|
|
4
|
+
export type Instant = number | { at: number; [k: string]: unknown };
|
|
5
|
+
|
|
6
|
+
export type SfxEntry = { at: number; event?: string; src: string; vol: number; dur?: number };
|
|
7
|
+
|
|
8
|
+
export type Scene = {
|
|
9
|
+
start: number;
|
|
10
|
+
dur: number;
|
|
11
|
+
transition?: string;
|
|
12
|
+
sfx?: Omit<SfxEntry, "at">;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type Duck = {
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
depth_db: number;
|
|
19
|
+
attack_s: number;
|
|
20
|
+
release_s: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type EffectsTimeline = {
|
|
24
|
+
fps?: number;
|
|
25
|
+
duration?: number;
|
|
26
|
+
scenes?: Scene[];
|
|
27
|
+
sfx?: SfxEntry[];
|
|
28
|
+
punches?: Instant[];
|
|
29
|
+
shakes?: Instant[];
|
|
30
|
+
flashes?: Instant[];
|
|
31
|
+
ducking?: Duck[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Component constants. The budget (how many, how far apart) lives in
|
|
35
|
+
// effects.config.json and was already applied by the scripts; these only
|
|
36
|
+
// shape one effect once it has been accepted.
|
|
37
|
+
export const PUNCH = { peak: 1.035, windowS: 0.22 };
|
|
38
|
+
export const FLASH = { peakOpacity: 0.35, windowS: 0.18 };
|
|
39
|
+
export const SHAKE = { amplitudePx: 9, durS: 0.25 };
|
|
40
|
+
export const TRANSITION = { whipFrames: 6, zoomFrames: 9, zoomFrom: 1.12, whipPx: 140 };
|
|
41
|
+
|
|
42
|
+
export const instants = (items?: Instant[]): number[] =>
|
|
43
|
+
(items ?? []).map((i) => (typeof i === "number" ? i : Number(i.at))).sort((a, b) => a - b);
|
|
44
|
+
|
|
45
|
+
/** Every sound of the piece: sfx[] plus each scene's cut sound at its start. */
|
|
46
|
+
export const allSounds = (tl: EffectsTimeline): SfxEntry[] => {
|
|
47
|
+
const scene = (tl.scenes ?? [])
|
|
48
|
+
.filter((s) => s.sfx)
|
|
49
|
+
.map((s) => ({ ...(s.sfx as Omit<SfxEntry, "at">), at: s.start }));
|
|
50
|
+
return [...(tl.sfx ?? []), ...scene].sort((a, b) => a.at - b.at);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Narration gain for the ducking windows (same math as gain_db_at in sound.py).
|
|
54
|
+
* `frame` is relative to the <Audio>; pass its start frame when it is not 0. */
|
|
55
|
+
export const narrationVolume =
|
|
56
|
+
(ducking: Duck[], fps: number, startFrame = 0) =>
|
|
57
|
+
(frame: number): number => {
|
|
58
|
+
const t = (frame + startFrame) / fps;
|
|
59
|
+
let db = 0;
|
|
60
|
+
for (const w of ducking) {
|
|
61
|
+
if (t < w.start || t > w.end) continue;
|
|
62
|
+
let shape = 1;
|
|
63
|
+
if (t < w.start + w.attack_s) shape = (t - w.start) / w.attack_s;
|
|
64
|
+
else if (t > w.end - w.release_s) shape = (w.end - t) / w.release_s;
|
|
65
|
+
db = Math.min(db, -w.depth_db * Math.max(0, Math.min(1, shape)));
|
|
66
|
+
}
|
|
67
|
+
return Math.pow(10, db / 20);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Triangle pulse 0..1..0 centred on the nearest instant within the window. */
|
|
71
|
+
const pulse = (t: number, at: number[], windowS: number): number => {
|
|
72
|
+
let best = 0;
|
|
73
|
+
for (const a of at) {
|
|
74
|
+
const d = Math.abs(t - a);
|
|
75
|
+
if (d < windowS / 2) best = Math.max(best, 1 - d / (windowS / 2));
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const punchScale = (t: number, punches: number[]): number =>
|
|
81
|
+
1 + (PUNCH.peak - 1) * pulse(t, punches, PUNCH.windowS);
|
|
82
|
+
|
|
83
|
+
export const flashOpacity = (t: number, flashes: number[]): number =>
|
|
84
|
+
FLASH.peakOpacity * pulse(t, flashes, FLASH.windowS);
|
|
85
|
+
|
|
86
|
+
/** Short decaying tremor starting at each shake instant, never longer than SHAKE.durS. */
|
|
87
|
+
export const shakeOffset = (t: number, shakes: number[]): { x: number; y: number } => {
|
|
88
|
+
for (const a of shakes) {
|
|
89
|
+
const d = t - a;
|
|
90
|
+
if (d >= 0 && d < SHAKE.durS) {
|
|
91
|
+
const decay = 1 - d / SHAKE.durS;
|
|
92
|
+
const amp = SHAKE.amplitudePx * decay;
|
|
93
|
+
return { x: amp * Math.sin(d * 95), y: amp * 0.6 * Math.cos(d * 71) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { x: 0, y: 0 };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Scene transition at frame `f`: scale and horizontal offset for the incoming shot. */
|
|
100
|
+
export const transitionAt = (
|
|
101
|
+
f: number,
|
|
102
|
+
fps: number,
|
|
103
|
+
scenes: Scene[],
|
|
104
|
+
): { scale: number; x: number; blurPx: number } => {
|
|
105
|
+
for (const s of scenes) {
|
|
106
|
+
const from = Math.round(s.start * fps);
|
|
107
|
+
const local = f - from;
|
|
108
|
+
if (s.transition === "zoom" && local >= 0 && local < TRANSITION.zoomFrames) {
|
|
109
|
+
const p = local / TRANSITION.zoomFrames;
|
|
110
|
+
const ease = 1 - Math.pow(1 - p, 3);
|
|
111
|
+
return { scale: TRANSITION.zoomFrom + (1 - TRANSITION.zoomFrom) * ease, x: 0, blurPx: 0 };
|
|
112
|
+
}
|
|
113
|
+
if (s.transition === "whip" && local >= 0 && local < TRANSITION.whipFrames) {
|
|
114
|
+
const p = local / TRANSITION.whipFrames;
|
|
115
|
+
const ease = 1 - Math.pow(1 - p, 3);
|
|
116
|
+
return { scale: 1, x: TRANSITION.whipPx * (1 - ease), blurPx: 18 * (1 - ease) };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { scale: 1, x: 0, blurPx: 0 };
|
|
120
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SFX credits and licence
|
|
2
|
+
|
|
3
|
+
## Source
|
|
4
|
+
|
|
5
|
+
Every file in this folder comes from [Pixabay sound effects](https://pixabay.com/sound-effects/), either untouched or derived by cutting and pitching a Pixabay original (table below). The originals reached this skill through the `media-use` audio library (`manifest.json` there holds per-file descriptions) and were carried into the ad pipeline this engine was ported from.
|
|
6
|
+
|
|
7
|
+
## Licence: Pixabay Content License
|
|
8
|
+
|
|
9
|
+
Summary of [pixabay.com/service/license-summary](https://pixabay.com/service/license-summary/), read on 2026-09-24:
|
|
10
|
+
|
|
11
|
+
- Commercial use is allowed, **including paid ads**, with no attribution required (credit is given here for transparency).
|
|
12
|
+
- Modification is allowed.
|
|
13
|
+
- **Never standalone.** A SFX may only ship mixed into a video (narration, visuals, edit). Selling, uploading or redistributing the raw files on their own, as a sound pack or a library, is forbidden. This folder is a working asset of the skill, not a redistribution: do not publish it as a pack.
|
|
14
|
+
- No printing on physical goods for resale, and no commercial use tied to a recognizable third-party brand or logo shown in the content.
|
|
15
|
+
- **No indemnification.** Pixabay does not cover legal costs in a dispute (third-party risk analysis by PicDefense, consistent with the absence of an indemnity clause in the official terms). Keep the licence certificate or the page URL and download date of every new file you add.
|
|
16
|
+
- Content ID: the reported claims concern music tracks on YouTube, not short SFX. Risk is low, not zero; the Pixabay certificate is what clears a claim.
|
|
17
|
+
|
|
18
|
+
## Files shipped
|
|
19
|
+
|
|
20
|
+
Originals (untouched in content; some normalized in level by `normalize_audio.py`): `chime`, `click`, `click-soft`, `error`, `impact-bass-1`, `impact-bass-2`, `key-press`, `notification`, `ping`, `pop` (cut, see below), `riser`, `sparkle`, `typing`, `whoosh`, `whoosh-short`, `whoosh-cinematic`.
|
|
21
|
+
|
|
22
|
+
Derived files, one per sound the config's semantic events expect (`sound.events.*.sound`):
|
|
23
|
+
|
|
24
|
+
| File | Source (Pixabay original) | Treatment |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| `whoosh.mp3` | `whoosh-short.mp3` | none; byte-identical copy |
|
|
27
|
+
| `click.mp3` | itself | none |
|
|
28
|
+
| `pop.mp3` | itself (0.72s) | cut to 0.30s + 60 ms fade-out |
|
|
29
|
+
| `coin.mp3` | `ping.mp3` | `atrim=0.28:0.68` (drops the lead-in silence, keeps the plink tail) + 60 ms fade-out |
|
|
30
|
+
| `stamp.mp3` | `impact-bass-1.mp3` | cut to 0.45s + 60 ms fade-out |
|
|
31
|
+
| `notif.mp3` | `notification.mp3` | cut to 0.70s + 60 ms fade-out |
|
|
32
|
+
| `type.mp3` | `typing.mp3` | cut to 0.80s + 60 ms fade-out |
|
|
33
|
+
| `paste.mp3` | `click-soft.mp3` | copied without cut (already 0.37s) |
|
|
34
|
+
| `send.mp3` | `whoosh-short.mp3` | `asetrate=44100*1.28,aresample=44100` (higher pitch, ~0.45s) + 60 ms fade-out |
|
|
35
|
+
| `cash.mp3` | `chime.mp3` | cut to 0.60s + 60 ms fade-out |
|
|
36
|
+
| `success.mp3` | `sparkle.mp3` | cut to 0.70s + 60 ms fade-out (a different texture from `cash`) |
|
|
37
|
+
| `riser-metal.mp3` | `riser.mp3` | window 3.25s to 4.15s + `asetrate*1.8` (shorter and brighter) + fades, 0.54s; silence trimmed BEFORE normalizing |
|
|
38
|
+
|
|
39
|
+
`riser-metal.mp3` was missing from the original credits table. Its provenance was recovered from the working-session record of 2026-09-16 that created it (derivation above) and checked on 2026-09-24: the same window and rate applied to the Pixabay `riser.mp3` gives 0.50s, the shipped file is 0.54s (0.50s plus MP3 encoder padding). It is kept because its only source is a Pixabay original.
|
|
40
|
+
|
|
41
|
+
Every file follows the level contract of the config (`sound.reference_level_db`, `sound.max_peak_db`, peak always wins). Check with `python3 scripts/normalize_audio.py --check --sfx-dir assets/sfx`.
|
|
42
|
+
|
|
43
|
+
## Left out, and why
|
|
44
|
+
|
|
45
|
+
| File | Why |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `glitch-1.mp3`, `glitch-2.mp3`, `glitch-3.mp3` | Licence is fine (Pixabay); left out as a style decision: glitch is meme or fashion, with no performance data and a comedy tone that does not fit product and UI demos. |
|
|
48
|
+
|
|
49
|
+
No other file of the source folder was excluded. Nothing without proven Pixabay provenance is shipped.
|
|
50
|
+
|
|
51
|
+
## Adding a file
|
|
52
|
+
|
|
53
|
+
1. Only from a source cleared for the piece's use (paid ad vs organic) and portable across the platforms it will run on.
|
|
54
|
+
2. Save the licence certificate or the page URL and date in this file.
|
|
55
|
+
3. Trim silence, then run `normalize_audio.py`.
|
|
56
|
+
4. Map it to a semantic event in the config (the event is the key, the file is a column) and run `python3 scripts/sound.py coverage`.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Audio: which sound for which event, at what level
|
|
2
|
+
|
|
3
|
+
**No number on this page is canonical.** They live in the `sound`, `ducking` and `loudness` blocks of `effects.config.json`.
|
|
4
|
+
|
|
5
|
+
## Why the key is semantic
|
|
6
|
+
|
|
7
|
+
Measured in a production batch before this rule existed: eleven SFX files on disk, **two** used in practice (`pop` 184 times, `whoosh` 119), four never used. `pop` and `whoosh` are the neutral sounds, the ones that mean nothing in particular. The sounds that meant something (money, send, typing, stamp) sat idle.
|
|
8
|
+
|
|
9
|
+
The cause was not laziness: no rule tied a sound to an event, and without a rule whoever picks by hand always picks the sound that is never wrong. A whole piece with two sounds is the same as no sound design.
|
|
10
|
+
|
|
11
|
+
So whoever edits asks for **`verdict`**, not **`stamp.mp3`**:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
res = sound.map_requests([(6.2, "verdict"), (13.4, "price")], cfg)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
What comes with it:
|
|
18
|
+
|
|
19
|
+
1. **Changing SFX library is changing one column** of the config. No rule and no code changes.
|
|
20
|
+
2. **An invented event is refused** with the list of valid ones, instead of playing the wrong sound.
|
|
21
|
+
3. **Volume always comes from the config**, never from an argument: the mix ruler stays the same from start to finish.
|
|
22
|
+
|
|
23
|
+
## The events
|
|
24
|
+
|
|
25
|
+
`sound.events` in the template (the event is the key; `sound` is the file stem under the SFX folder):
|
|
26
|
+
|
|
27
|
+
| Event | Sound | When |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `block_cut` | whoosh | switch of narrative block |
|
|
30
|
+
| `scene_cut` | whoosh (lower) | screen change inside the same block |
|
|
31
|
+
| `element_in` | pop | a card, line or label enters |
|
|
32
|
+
| `text_in` | riser-metal | a text card enters: headline, title, anchor sentence |
|
|
33
|
+
| `list_item` | coin (lower) | item of a repeating list; the checklist plink |
|
|
34
|
+
| `verdict` | stamp | verdict, seal, decision slammed down. **Also triggers `shake`** |
|
|
35
|
+
| `message` | notif | a message, notification or alert arrives |
|
|
36
|
+
| `typing` | type | text being typed on screen |
|
|
37
|
+
| `paste` | paste | copy or paste |
|
|
38
|
+
| `click` | click | interface interaction, cursor, tap |
|
|
39
|
+
| `send` | send | send, publish, submit |
|
|
40
|
+
| `price` | cash | the price appears or changes |
|
|
41
|
+
| `gain` | coin | value gained, savings, counter going up |
|
|
42
|
+
| `done` | success | check, ready, task done |
|
|
43
|
+
|
|
44
|
+
`list_item` exists apart from `element_in` for one reason: the fifth item of a list does not deserve the volume of the first element entering the scene; without the distinction a ten-item list sounds like a machine gun. `text_in` exists because text entrances otherwise fell back to the neutral `pop` and whole batches came out sounding the same.
|
|
45
|
+
|
|
46
|
+
## The three refusals
|
|
47
|
+
|
|
48
|
+
- **Unknown event.** Refused, with the valid list in the message. Never falls back to a default sound.
|
|
49
|
+
- **Collision.** Two requests closer than `sound.collision_window_s` sound like one dirty sound, not two. The first stays.
|
|
50
|
+
- **Same sound too many times in a row.** `sound.max_same_sound_in_row`. A sound that repeats stops being information and becomes texture.
|
|
51
|
+
|
|
52
|
+
## Levels
|
|
53
|
+
|
|
54
|
+
Three layers, in this order:
|
|
55
|
+
|
|
56
|
+
1. **File level contract.** `normalize_audio.py` brings every SFX to `sound.reference_level_db` with the peak locked at `sound.max_peak_db`. The `vol` table only means something if every file starts from the same place: measured in production, a library ranging from -19.6 to -30.9 dB made the block-cut whoosh come out 15.5 dB under the voice, inaudible. An accent must sit roughly 6 to 10 dB under the voice: heard without competing. Trim silent tails before normalizing (the gate measures the whole file, so a long silent tail makes the sound come out too loud).
|
|
57
|
+
2. **Semantic volume.** `vol` per event, capped by `sound.relative_ceiling` times the narration volume; the loader **refuses the whole config** if any event exceeds it. An effect that competes with the voice takes attention instead of marking it, and the voice is what sells. The verdict is the loudest and still under the ceiling.
|
|
58
|
+
3. **Master loudness.** The final file is measured by `check_loudness.py` against `loudness.target_lufs` (-14 LUFS integrated), `loudness.tolerance_lu` and `loudness.max_true_peak_db` (-1 dBTP). TikTok and Reels normalise playback to about -14 LUFS; mastering louder brings nothing and risks distortion after the platform re-encodes. Meta never published an official number; -14 / -1 is the industry value replicated for both (sources in [editing-patterns.md](editing-patterns.md) and the research behind this skill). When it fails, the gate prints the two-pass ffmpeg `loudnorm` command that fixes it; for lossy outputs the fix aims `loudness.encode_tp_margin_db` under the peak ceiling, because AAC re-encoding overshoots the true peak. If the gain needed does not fit under the peak, loudnorm compresses instead of scaling and the gate says so: raise the narration before the render instead.
|
|
59
|
+
|
|
60
|
+
## Strength is not volume
|
|
61
|
+
|
|
62
|
+
The verdict plays at 0.65 and is worth 0.95 in the effects budget. Two different things:
|
|
63
|
+
|
|
64
|
+
- **volume** is mixing: how loud without covering the voice;
|
|
65
|
+
- **strength** is narrative weight: who wins the instant when two effects compete.
|
|
66
|
+
|
|
67
|
+
Deriving strength from volume cost a real defect: the verdict lost its instant to an ordinary punch, against the written rule. Strength is its own field (`strength`); where it is absent the module falls back to volume **and says so**.
|
|
68
|
+
|
|
69
|
+
## Ducking
|
|
70
|
+
|
|
71
|
+
Each SFX used to play over the narration at a fixed level, only adding. That is the most audible gap between amateur and professional editing.
|
|
72
|
+
|
|
73
|
+
`sound.py duck` writes `ducking[]` windows: the narration dips by `ducking.depth_db` only when a SFX sounds over the **start of a spoken word**, with `ducking.attack_ms` and `ducking.release_ms`. Overlapping windows merge. The Remotion volume callback is in [data-contract.md](data-contract.md).
|
|
74
|
+
|
|
75
|
+
The numbers are marked in the config as an **extrapolation without a primary source**: classic music ducking (18 to 25 dB under the voice) is for a continuous bed, not for short transient SFX, and no source gave numbers for the transient case. A small dip (3 to 6 dB), fast attack and short release is the adaptation.
|
|
76
|
+
|
|
77
|
+
## Background bed
|
|
78
|
+
|
|
79
|
+
`sound.bed` accepts an optional ambience and an optional music track. Each enters only if the file exists; without it the build goes on and says so. Music only from a source cleared for the piece's platform ([sfx-licensing.md](sfx-licensing.md)). With a music bed, classic ducking applies to the bed, not the small SFX dip above.
|
|
80
|
+
|
|
81
|
+
## Coverage
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python3 scripts/sound.py --config effects.config.json coverage
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Says which sounds are on disk, which sounds no event uses (waste) and which events point at a missing file (a broken render waiting to happen). Run it after touching the library.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Timeline data contract
|
|
2
|
+
|
|
3
|
+
The scripts in `scripts/` read and write one JSON object per piece: the **timeline**. It is the hand-off between whoever plans the edit (HyperFrames screens, `ctc-align` timing) and whoever renders the audio and effects (Remotion). Times are always **seconds from the start of the piece**, as floats. Every list is optional: an absent list is an empty list, never an error.
|
|
4
|
+
|
|
5
|
+
Numbers that decide anything (gaps, ceilings, depths) are never in the timeline: they live in `effects.config.json` and are cited by key.
|
|
6
|
+
|
|
7
|
+
## Top-level keys
|
|
8
|
+
|
|
9
|
+
| Key | Type | Read by | Written by | Meaning |
|
|
10
|
+
| --- | --- | --- | --- | --- |
|
|
11
|
+
| `fps` | int | `sound.py duck` | planner | render fps; default `frame.fps` of the config |
|
|
12
|
+
| `duration` | float | every check | planner | piece length; if absent, derived from the largest scene end (reports say so) |
|
|
13
|
+
| `safe_zone_profile` | string | `check_safe_zone.py`, `measure_density.py`, `fill_breaths.py` | planner | `organic` or `ad` (any key of `safe_zones`); overrides the config default for this piece |
|
|
14
|
+
| `words[]` | list | `sound.py duck`, `fill_breaths.py` | `ctc-align` | spoken words |
|
|
15
|
+
| `scenes[]` | list | `check_excess.py`, `check_rhythm.py`, `fill_breaths.py` | planner, `fill_breaths.py` | screens |
|
|
16
|
+
| `sfx[]` | list | `sound.py duck`, `fill_breaths.py` | planner, `fill_breaths.py` | point sounds |
|
|
17
|
+
| `punches[]` | list | `check_excess.py`, `check_rhythm.py` | planner, `fill_breaths.py` | zoom punches |
|
|
18
|
+
| `shakes[]` | list | same | planner | short shakes (`budget.per_effect.shake.max_dur_s`) |
|
|
19
|
+
| `flashes[]` | list | same | planner, `fill_breaths.py` | opacity flashes |
|
|
20
|
+
| `interrupts[]` | list | same | planner | pattern interrupts |
|
|
21
|
+
| `elements[]` | list | `check_rhythm.py` | planner | element entries (cards, labels) that count as visual change |
|
|
22
|
+
| `ducking[]` | list | renderer | `sound.py duck --write` | narration duck windows |
|
|
23
|
+
|
|
24
|
+
### `words[]` (ctc-align output, unchanged)
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{ "text": "price", "start": 1.84, "end": 2.21, "score": -0.12 }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Only `start` and `end` are required. Breaths are the gaps between words at least `breaths.min_silence_s` long.
|
|
31
|
+
|
|
32
|
+
### `scenes[]`
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"start": 3.2,
|
|
37
|
+
"dur": 4.1,
|
|
38
|
+
"transition": "whip",
|
|
39
|
+
"sfx": { "event": "block_cut", "src": "sfx/whoosh.mp3", "vol": 0.55 }
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `transition`: `cut`, `whip`, `zoom` or any name the renderer knows. Anything other than `""` or `cut` counts as a `transition` event in the budget.
|
|
44
|
+
- `sfx` (optional): the cut sound, played at `start`. Same shape as an `sfx[]` entry without `at`.
|
|
45
|
+
|
|
46
|
+
### `sfx[]`
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{ "at": 5.4, "event": "verdict", "src": "sfx/stamp.mp3", "vol": 0.65, "dur": 0.45 }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `event`: the semantic key of `sound.events`. Always write it: without it, scripts must rebuild the event from the `(file, vol)` pair, which only works while that pair is unique in the config.
|
|
53
|
+
- `src`: relative to `paths.media_root` (Remotion `staticFile(src)`).
|
|
54
|
+
- `vol`: copied from the config, never chosen by hand.
|
|
55
|
+
- `dur` (optional): seconds. Without it, `sound.py duck` probes the file, then falls back to `ducking.assumed_sfx_duration_s`.
|
|
56
|
+
|
|
57
|
+
### Effects: `punches[]`, `shakes[]`, `flashes[]`, `interrupts[]`, `elements[]`
|
|
58
|
+
|
|
59
|
+
Each item is a number (the instant) or an object with `at`:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
"punches": [2.4, { "at": 9.1, "vol": 0.5 }],
|
|
63
|
+
"interrupts": [{ "at": 14.0, "kind": "question-card" }],
|
|
64
|
+
"elements": [{ "at": 0.6, "kind": "text" }]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `ducking[]`
|
|
68
|
+
|
|
69
|
+
Written by `python3 scripts/sound.py duck TIMELINE --write`. One window per group of SFX that sound over the **start of a spoken word**; nothing else ducks.
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"start": 5.37,
|
|
74
|
+
"end": 6.05,
|
|
75
|
+
"depth_db": 4.0,
|
|
76
|
+
"attack_s": 0.03,
|
|
77
|
+
"release_s": 0.2,
|
|
78
|
+
"from_frame": 161,
|
|
79
|
+
"to_frame": 182,
|
|
80
|
+
"cause": "verdict@5.400s over word 'price'@5.500s"
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Shape of each window: the gain ramps linearly from 0 dB to `-depth_db` over `attack_s` starting at `start` (so full depth is in place when the SFX hits), holds, then ramps back to 0 dB over the last `release_s` before `end`. Overlapping windows are merged, deepest depth wins. Numbers come from the `ducking` block of the config, which marks them as an extrapolation without a primary source.
|
|
85
|
+
|
|
86
|
+
Remotion volume callback for the narration (same math as `gain_db_at` in `sound.py`). `frame` is relative to the `<Audio>`, so add the narration's start frame when it does not start at 0:
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
type Duck = { start: number; end: number; depth_db: number; attack_s: number; release_s: number };
|
|
90
|
+
|
|
91
|
+
export const narrationVolume = (ducking: Duck[], fps: number, startFrame = 0) => (frame: number) => {
|
|
92
|
+
const t = (frame + startFrame) / fps;
|
|
93
|
+
let db = 0;
|
|
94
|
+
for (const w of ducking) {
|
|
95
|
+
if (t < w.start || t > w.end) continue;
|
|
96
|
+
let shape = 1;
|
|
97
|
+
if (t < w.start + w.attack_s) shape = (t - w.start) / w.attack_s;
|
|
98
|
+
else if (t > w.end - w.release_s) shape = (w.end - t) / w.release_s;
|
|
99
|
+
db = Math.min(db, -w.depth_db * Math.max(0, Math.min(1, shape)));
|
|
100
|
+
}
|
|
101
|
+
return Math.pow(10, db / 20);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// <Audio src={staticFile(narration)} volume={narrationVolume(data.ducking, fps)} />
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Minimal example
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"fps": 30,
|
|
112
|
+
"duration": 12.0,
|
|
113
|
+
"safe_zone_profile": "ad",
|
|
114
|
+
"words": [
|
|
115
|
+
{ "text": "Stop", "start": 0.2, "end": 0.5 },
|
|
116
|
+
{ "text": "scrolling", "start": 0.55, "end": 1.1 },
|
|
117
|
+
{ "text": "this", "start": 1.6, "end": 1.8 },
|
|
118
|
+
{ "text": "costs", "start": 1.85, "end": 2.2 },
|
|
119
|
+
{ "text": "nine", "start": 5.5, "end": 5.8 }
|
|
120
|
+
],
|
|
121
|
+
"scenes": [
|
|
122
|
+
{ "start": 0.0, "dur": 5.0, "transition": "cut" },
|
|
123
|
+
{ "start": 5.0, "dur": 7.0, "transition": "whip", "sfx": { "event": "block_cut", "src": "sfx/whoosh.mp3", "vol": 0.55 } }
|
|
124
|
+
],
|
|
125
|
+
"sfx": [{ "at": 5.4, "event": "price", "src": "sfx/cash.mp3", "vol": 0.55 }],
|
|
126
|
+
"punches": [1.35],
|
|
127
|
+
"flashes": [],
|
|
128
|
+
"shakes": [],
|
|
129
|
+
"interrupts": [],
|
|
130
|
+
"elements": [{ "at": 0.6, "kind": "text" }],
|
|
131
|
+
"ducking": []
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Which script touches what
|
|
136
|
+
|
|
137
|
+
| Script | Reads | Writes | Exit codes |
|
|
138
|
+
| --- | --- | --- | --- |
|
|
139
|
+
| `fill_breaths.py TIMELINE [--narration F@OFF] [--visual MP4] [--write\|--out P]` | `words`, `scenes`, effects, `sfx`, `duration` | `scenes[].transition`, `scenes[].sfx`, `punches`, `flashes`, `sfx` | 0, 2 |
|
|
140
|
+
| `sound.py duck TIMELINE [--write]` | `sfx`, `scenes[].sfx`, `words`, `fps` | `ducking` | 0, 2 |
|
|
141
|
+
| `check_excess.py TIMELINE... [--warn]` | effects, `scenes`, `duration` | nothing | 0 pass, 1 fail, 2 error |
|
|
142
|
+
| `check_rhythm.py TIMELINE...` | `scenes`, effects, `elements`, `duration` | nothing | always 0 (warn only) |
|
|
143
|
+
| `check_safe_zone.py MP4 [--timeline T]` | `safe_zone_profile` | nothing | 0, 1, 2 |
|
|
144
|
+
| `measure_density.py MP4 [--timeline T]` | `safe_zone_profile` | nothing | 0, 1, 2 |
|
|
145
|
+
| `check_loudness.py MP4` | the final file only | nothing | 0, 1, 2 |
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# What this system does not do
|
|
2
|
+
|
|
3
|
+
A catalogue says what is possible; this page says what was already decided, so the discussion does not repeat. Every line has a reason. A refusal without a reason is fashion, and fashion changes; a refusal with a reason is a rule.
|
|
4
|
+
|
|
5
|
+
Before proposing a new effect, look for it here. If it is listed, it is not a gap, it is a decision, and reopening it needs a new argument. If it is neither here nor in [when-to-use.md](when-to-use.md), it is undecided territory: propose it **with its "do not use when" already written**. An effect without a refusal rule does not enter this system.
|
|
6
|
+
|
|
7
|
+
## Cut for legibility
|
|
8
|
+
|
|
9
|
+
These pieces sell by being read in three seconds on a phone. Anything that dirties the image works against that.
|
|
10
|
+
|
|
11
|
+
| Effect | Reason |
|
|
12
|
+
|---|---|
|
|
13
|
+
| Film grain | adds noise to an image whose job is to be sharp |
|
|
14
|
+
| VHS, scanlines | degradation aesthetic over a product sold as modern |
|
|
15
|
+
| Dust, scratches | same problem, and unrelated to the subject |
|
|
16
|
+
| Vignette | darkens corners where the layout already places content, and eats the safe zone |
|
|
17
|
+
| Light leak | removes contrast from text exactly where it is unpredictable |
|
|
18
|
+
| Chromatic aberration | illegibility pretending to be style, worst on small text |
|
|
19
|
+
| Letterbox | in a vertical format it is lost area where space is already short |
|
|
20
|
+
|
|
21
|
+
The 2026 research found VHS and grain described as a trend only for brand and aesthetic content, never for performance ads with product and price on screen ([editing-patterns.md](editing-patterns.md)).
|
|
22
|
+
|
|
23
|
+
## Cut for cost without return
|
|
24
|
+
|
|
25
|
+
| Effect | Reason |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Liquid, morph | complex, fragile in deterministic render, no evidence of retention |
|
|
28
|
+
| Lens distortion | same, and it fights the safe zone in ways that are hard to measure |
|
|
29
|
+
| Particles | heavy to render, and in a UI creative they illustrate nothing |
|
|
30
|
+
|
|
31
|
+
## Cut for contradicting a rule of the system
|
|
32
|
+
|
|
33
|
+
- **Crossdissolve and fade between takes.** The default is the hard cut: in vertical short video the cut is what holds attention. Fade only at the very start or end, if ever.
|
|
34
|
+
- **Automatic beat detection.** The beat of a narrated piece is the voice, and forced alignment (ctc-align) gives speech boundaries to the millisecond. A beat detector would give a worse signal over a clock that is already better.
|
|
35
|
+
- **Speed ramp.** Over narration it desynchronises speech from screen, and speech anchors all timing.
|
|
36
|
+
- **Typewriter out of context.** Only where the narration talks about writing. It competes with the first sentence, which is the most expensive sentence of the piece.
|
|
37
|
+
- **More shake to raise dynamism.** The refusal that repeats most in production: dynamism grows through **cut, punch, element entrance and new scene**, never through more vibration. Shake and drift are seasoning.
|
|
38
|
+
- **Meme sounds** ("vine boom", "record scratch", "glitch"). No performance data and a comedy tone; see [sfx-licensing.md](sfx-licensing.md).
|
|
39
|
+
- **Trending picker audio in ads.** Licensing, not taste; see [sfx-licensing.md](sfx-licensing.md).
|
|
40
|
+
|
|
41
|
+
## Avatar rule, which is also a refusal
|
|
42
|
+
|
|
43
|
+
**No reused avatar clip. No lip sync against audio that is not this piece's audio.** Without a take generated with the exact words of that block, the piece goes out **without the avatar**, in screens only. A whole piece was once rejected because the actor's mouth said last week's script while a gate showed green.
|
|
44
|
+
|
|
45
|
+
## Waiting list (gaps, not refusals)
|
|
46
|
+
|
|
47
|
+
A refusal closes a discussion; a gap invites it.
|
|
48
|
+
|
|
49
|
+
| Missing | Status |
|
|
50
|
+
|---|---|
|
|
51
|
+
| Background music | the most expensive open decision, because of licence and mix. The config accepts an optional music bed; the file must come from a source cleared for the piece's platform |
|
|
52
|
+
| Callouts | often requested, never built. For offers that demonstrate screens it is what is most missed |
|
|
53
|
+
| Re-enabling a giant-word overlay (`textpunch`) | it must go through the layout bands first; drawn on top of the frame it ignored the stage |
|
|
54
|
+
| Silence before the CTA | no reliable source; test as A/B, not as a rule |
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Editing patterns for ads and Reels: what has a source, and what does not
|
|
2
|
+
|
|
3
|
+
Research done on 2026-09-24 with real web sources (every link below was accessed that day). Each claim is tagged:
|
|
4
|
+
|
|
5
|
+
- **DATA**: official platform documentation, or a named vendor with real measurement infrastructure.
|
|
6
|
+
- **OPINION**: agency, creator or tool blog. Most "2026 benchmark" results on the web come from a cluster of look-alike blogs with no sample, method or primary source; treat their numbers as planning hints, never as a gate.
|
|
7
|
+
|
|
8
|
+
The rule for this skill follows from the tags: **a number backed only by OPINION becomes a config default that warns, never a gate that blocks.**
|
|
9
|
+
|
|
10
|
+
## Hook (0 to 3 s)
|
|
11
|
+
|
|
12
|
+
- **DATA.** Hook rate (thumb-stop ratio) is 3-second plays divided by impressions in Meta Ads Manager. The hook window of this skill is therefore 3 s (`rhythm.hook_s`). Sources: [Coinis glossary](https://coinis.com/glossary/hook-rate), [Adrio](https://adrio.ai/blog/thumbstop-rate-vs-hook-rate-vs-hold-rate).
|
|
13
|
+
- **DATA.** TikTok recommends stating the content's proposition in the first 3 seconds and says top ads use pattern interrupts (unexpected visual, bold claim, curiosity gap) that stop the scroll in 2 to 3 s. [TikTok Ads Help, creative best practices](https://ads.tiktok.com/help/article/creative-best-practices).
|
|
14
|
+
- **OPINION.** "Good" hook rate: floor 25%, good 30 to 40%, elite 40%+. [Motion, Creative Benchmarks 2026](https://motionapp.com/thumbstop-pulse/creative-benchmarks-2026) (full report not readable).
|
|
15
|
+
- **Consequence here:** the first frame already moves and already says something. The budget curve gives the hook a small bonus, never a spike: dynamism is spread over the whole video, not packed into the opening.
|
|
16
|
+
|
|
17
|
+
## Rhythm and retention in the body
|
|
18
|
+
|
|
19
|
+
- **DATA.** ThruPlay = watched to 15 s or to the end if shorter. Hold rate = viewers reaching 15 s (or 75%) divided by viewers past 3 s; it isolates body retention from the hook. [AdSights, ThruPlay](https://www.adsights.ai/resources/glossary/metrics/thruplay), [AdSights, hold rate vs ThruPlay](https://www.adsights.ai/resources/comparisons/hold-rate-vs-thruplay-rate).
|
|
20
|
+
- **OPINION.** Cut cadence: TikTok every 1.5 to 3 s, Reels every 2.5 to 4 s, "change something on screen every 3 to 5 s". [edicionvideopro](https://edicionvideopro.com/en/editing-for-platforms-video-marketing/pattern-interrupts-tiktok-retention-guide/). No primary source.
|
|
21
|
+
- **In this skill:** `rhythm.max_static_s` (longest stretch with no visual change), `rhythm.hook_cut_every_s` and `rhythm.body_cut_every_s` are defaults read by `check_rhythm.py`, which **warns only**.
|
|
22
|
+
|
|
23
|
+
## Call to action
|
|
24
|
+
|
|
25
|
+
- **OPINION.** Repeat the CTA visually mid-offer and again on the final frame (end card): name, plain-language offer, CTA, high contrast. [Sovran](https://sovran.ai/blog/direct-response-video-ad-endings).
|
|
26
|
+
- **In this skill:** a documentation rule, not a gate. The CTA itself is a script decision (who says what, and when) and belongs to the script, not to the edit. In the CTA block the edit lowers effect density: no punch or zoom on the final card, price and button readable.
|
|
27
|
+
|
|
28
|
+
## Captions and on-screen text
|
|
29
|
+
|
|
30
|
+
- **DATA (historical).** 80 to 85% of feed video is watched without sound ([Digiday, 2016-05-17](https://digiday.com/media/silent-world-facebook-video/)). The number is from 2016 and is repeated, not re-measured; it still justifies designing sound-off first.
|
|
31
|
+
- **OPINION.** Word-by-word (karaoke) captions dominate in 2026; high contrast (white or yellow with black outline), lower-middle third. [OpusClip](https://www.opus.pro/blog/tiktok-caption-subtitle-best-practices).
|
|
32
|
+
- **In this skill:** captions come from the ctc-align word timestamps; the caption band is exclusive (nothing readable inside it); every claim that matters is also on screen as text, because the SFX never carries critical information.
|
|
33
|
+
|
|
34
|
+
## Two safe zones, not one
|
|
35
|
+
|
|
36
|
+
| Profile | Source | Numbers |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `ad` | **DATA**, [Meta Business Help Center: text overlays and the Safe Zone for ads in Stories and Reels](https://www.facebook.com/business/help/980593475366490/) | keep about 14% top, 35% bottom and 6% each side free of text, logo and key elements |
|
|
39
|
+
| `organic` | **OPINION** ([campaignswift](https://campaignswift.com/blog/instagram-safe-zone-sizes), [pod2reels](https://www.pod2reels.com/blog/instagram-reels-safe-zone-guide)) plus measurement from production | the organic Reels UI (profile, caption, like/comment/share column, audio line) covers roughly 320 to 400 px at the bottom of a 1080x1920 frame and the right column |
|
|
40
|
+
|
|
41
|
+
The piece declares its profile; `check_safe_zone.py` measures against that profile. A piece that runs both as an ad and as an organic post is checked against both.
|
|
42
|
+
|
|
43
|
+
## Organic reach on Instagram
|
|
44
|
+
|
|
45
|
+
- **DATA.** Adam Mosseri, reported 2025-01-22: the three signals that matter most for ranking are watch time, likes per reach and sends per reach; sends weigh more for reaching non-followers. [Social Media Today](https://www.socialmediatoday.com/news/instagram-shares-algorithm-insights-2025/738034/). The "a send is worth 3 to 15 likes" multipliers found on blogs do not appear in any direct quote: do not use them.
|
|
46
|
+
- **DATA.** Accounts that repost third-party content 10+ times in 30 days are excluded from recommendations; the original is shown instead of near-identical reposts. [MediaPost](https://www.mediapost.com/publications/article/414773/instagram-algorithm-update-discourages-reposted-co.html), [PetaPixel, 2026-04-30](https://petapixel.com/2026/04/30/new-instagram-policies-target-reposted-content/).
|
|
47
|
+
- **Consequence for editing:** originality is a distribution rule, not taste. Each piece gets its own screens and its own dynamics; reuse the technical structure (bands, gates, data contract), never the composition.
|
|
48
|
+
|
|
49
|
+
## What reads as dated
|
|
50
|
+
|
|
51
|
+
- **OPINION, contradictory.** Some sources call VHS, scanlines and grain a 2026 trend; the same searches say heavy production lost ground to "barely edited" authenticity. Where VHS appears as a trend, it is brand or aesthetic content, never performance ads with product and price on screen. The refusals in [do-not-use.md](do-not-use.md) stand.
|
|
52
|
+
- **OPINION.** Classic talking head, ring-light testimonial and USP list are recognised as ads "in half a second" ([Pixis](https://pixis.ai/blog/ugc-in-2026-whats-working-whats-stale-and-how-to-refresh-your-hook-library/)).
|
|
53
|
+
|
|
54
|
+
## Gaps (recorded, not hidden)
|
|
55
|
+
|
|
56
|
+
- "Silence before the CTA" as a practice: no reliable dated source found. It is an A/B hypothesis, not a rule.
|
|
57
|
+
- The pattern-interrupt retention figure (58% vs 32%) and the "zoom +68% engagement" figure have no primary source.
|
|
58
|
+
- Meta's official video spec page returned 403; length guidance (6 to 15 s feed vs 15 to 60 s) comes from blogs and describes awareness use, not direct-response selling.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Gates: what each one measures, and what none of them sees
|
|
2
|
+
|
|
3
|
+
Two sentences this system paid to learn, more than once: **a gate that checks a different file from the one that will be rendered is not a gate**, and **green without proof is worth less than red**, because red at least says something needs eyes.
|
|
4
|
+
|
|
5
|
+
Thresholds are cited by config key. Every script exits `0` pass, `1` fail, `2` input missing or unreadable, so a pipeline can chain them with `set -e`.
|
|
6
|
+
|
|
7
|
+
## The table
|
|
8
|
+
|
|
9
|
+
| Gate | Runs on | Measures | Blocks? | What it does NOT see |
|
|
10
|
+
|---|---|---|---|---|
|
|
11
|
+
| `config.py` | the config | coherence of every block (ranges, event volumes under `sound.relative_ceiling`, budget values); refuses the whole config on the first error | yes | whether the numbers are right for this offer |
|
|
12
|
+
| `sound.py coverage` | config + SFX folder | sounds no event uses, events pointing at a missing file | reports | whether the sound fits the event (that is taste, reviewed by ear) |
|
|
13
|
+
| `normalize_audio.py --check` | SFX folder | every file at `sound.reference_level_db`, peak under `sound.max_peak_db` | yes | silent tails: it measures the whole file, so trim first |
|
|
14
|
+
| `check_excess.py` | timeline JSON | every sound together (`sfx[]` and scene cut sounds) against the three refusals: unknown event, collision, same sound in a row; per effect kind: ceiling per minute, smallest gap, longest run in a row (`budget.per_effect.*`); dominant effects of different kinds closer than `budget.gap_min_global`; always reports the densest 10 s cluster | yes (`--warn` only while calibrating) | effects drawn inside the screens themselves (HyperFrames timelines); only what the timeline declares |
|
|
15
|
+
| `check_rhythm.py` | timeline JSON | longest stretch with no visual change (`rhythm.max_static_s`), cut cadence in the hook (`rhythm.hook_s`, `rhythm.hook_cut_every_s`) and body (`rhythm.body_cut_every_s`), CTA tail excluded (`rhythm.cta_tail_s`). SFX never count as change | **no, warns only** | motion inside a screen; its numbers come from weak sources ([editing-patterns.md](editing-patterns.md)) |
|
|
16
|
+
| `check_safe_zone.py` | **final MP4** | per row, local contrast (std-dev, `safe_zone_gate.dp_min`) on every frame; the highest and lowest content rows must sit inside the piece's profile (`safe_zones.<profile>` top and bottom) | yes | whether what invades is text, a card or background glow; left and right edges. Full-screen footage fails by construction: frame it inside the zone. On a fail, extract the frame and look |
|
|
17
|
+
| `measure_density.py` | **final MP4** | how many of `density.n_bands` bands across the active safe zone have content (`density.dp_min`), sampled every `density.step_s`; fails under `density.min_filled` | yes | whether the content makes sense there: a band full of noise counts like a band full of information |
|
|
18
|
+
| `check_loudness.py` | **final MP4 or audio** | integrated loudness and true peak (ffmpeg `loudnorm`) against `loudness.*`; prints the two-pass fix and warns when the fix will switch loudnorm to dynamic compression | yes | the balance inside the mix (voice vs SFX); that is the level contract and the ear |
|
|
19
|
+
|
|
20
|
+
Order that matches the pipeline: config and coverage once per project; excess and rhythm on the timeline before the expensive render; safe zone, density and loudness on the final file.
|
|
21
|
+
|
|
22
|
+
## Two profiles, measured separately
|
|
23
|
+
|
|
24
|
+
The safe zone depends on where the piece runs: `safe_zones.ad` (Meta's ad guidance for Stories and Reels) and `safe_zones.organic` (the organic Reels UI). The timeline's `safe_zone_profile` picks the one to measure. A piece that runs as ad and as organic post passes both: run `check_safe_zone.py` again with `--profile` for the other one.
|
|
25
|
+
|
|
26
|
+
## Failures already paid for
|
|
27
|
+
|
|
28
|
+
- **A gate that could never fail.** The density measurement once ran with `|| true` in the build script and never rejected anything. A gate that cannot fail is decoration.
|
|
29
|
+
- **Verified the wrong file.** A voice check matched files by basename while the render played a different file with the same name; five pieces went green with old audio. Match by path, measure the file that renders.
|
|
30
|
+
- **Measured before the render finished.** A file read while still being written returned a plausible number over a third of the film. Unreadable samples are an error, never skipped.
|
|
31
|
+
- **Sampling missed the peak.** One frame per second missed entry animations lasting tenths of a second; the safe-zone gate decodes every frame.
|
|
32
|
+
- **Micro-zoom ate the margin.** An element 10 px inside the safe zone in the screen render crossed it in the final file, because the assembly scales the whole frame. Measure the final MP4, never the isolated screen.
|
|
33
|
+
- **A threshold that always fails gets bypassed by habit.** The safe-zone gate inherited the density threshold and flagged background glow on almost every frame; it now has its own (`safe_zone_gate.dp_min`).
|
|
34
|
+
|
|
35
|
+
## What no gate replaces
|
|
36
|
+
|
|
37
|
+
Every gate here can be green with a broken frame: a chip rendered blank, the actor covering the price, a mouth saying last week's script. **Extract frames from the final file and look**, including one frame of the CTA block, every time.
|