wizz-method 2.11.2 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- package/skills-registry.yaml +38 -29
- package/src/bmm-skills/module.yaml +1 -1
- 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 +12 -4
- package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
- package/src/modules/wizz/agents/wizz-maestro/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 +3 -2
- 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
- package/tools/installer/banner.js +80 -29
- package/tools/installer/commands/catalog.js +87 -0
- package/tools/installer/commands/install.js +9 -0
- package/tools/installer/guided/detect-tools.js +53 -0
- package/tools/installer/guided/flow.js +203 -0
- package/tools/installer/guided/labels.js +185 -0
- package/tools/installer/guided/messages.js +132 -0
- package/tools/installer/guided/outro.js +32 -0
- package/tools/installer/guided/profiles.js +95 -0
- package/tools/installer/guided/steps.js +154 -0
- package/tools/installer/install-messages.yaml +3 -3
- package/tools/installer/modules/external-manager.js +4 -0
- package/tools/installer/ui.js +57 -22
- package/wizz-modules.yaml +1 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_readme": "Every canonical number of the effects engine lives HERE. A doc cites the key, never rewrites the value: a number copied to a second place ends up with two versions. Copy this file to your project as effects.config.json and fill `offer`. Keys starting with `_` are notes and are ignored by the loader. A key whose number has a weak source carries a sibling `_source`.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"offer": "",
|
|
5
|
+
|
|
6
|
+
"paths": {
|
|
7
|
+
"media_root": "public",
|
|
8
|
+
"sfx_subdir": "sfx",
|
|
9
|
+
"_note": "Both relative to the folder that holds this config. media_root is what the renderer serves (Remotion `public/`); sound `src` values written into the timeline are relative to it (e.g. `sfx/pop.mp3`). Every script also accepts --sfx-dir to override."
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
"frame": {
|
|
13
|
+
"width": 1080,
|
|
14
|
+
"height": 1920,
|
|
15
|
+
"fps": 30,
|
|
16
|
+
"_note": "9:16 vertical. No script assumes this resolution: they all read it from here."
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
"safe_zone_profile": "organic",
|
|
20
|
+
"_safe_zone_profile": "Default profile. A timeline JSON can override it per piece with a top-level `safe_zone_profile`. The safe-zone and density gates measure against the declared profile.",
|
|
21
|
+
"safe_zones": {
|
|
22
|
+
"organic": {
|
|
23
|
+
"top": 250,
|
|
24
|
+
"bottom": 1570,
|
|
25
|
+
"left": 60,
|
|
26
|
+
"right": 920,
|
|
27
|
+
"recommended_margin": 30,
|
|
28
|
+
"_source": "Organic Reels/TikTok UI, measured on real pieces of the project this engine came from. The right edge is tighter than the left because the interaction icon column lives there. Third-party guides put the organic bottom UI at 320-400px on 1080x1920 (low to medium credibility, no own measurement)."
|
|
29
|
+
},
|
|
30
|
+
"ad": {
|
|
31
|
+
"top": 269,
|
|
32
|
+
"bottom": 1248,
|
|
33
|
+
"left": 65,
|
|
34
|
+
"right": 1015,
|
|
35
|
+
"recommended_margin": 30,
|
|
36
|
+
"_source": "Meta Business Help Center, 'About text overlays and the Safe Zone for ads in Stories and Reels' (https://www.facebook.com/business/help/980593475366490/): keep ~14% top, ~35% bottom and ~6% each side free of text, logo and key elements. Converted on 1080x1920: top 0.14*1920=268.8 -> 269, bottom 1920-0.35*1920=1248, sides 0.06*1080=64.8 -> 65 and 1015. Read from the indexed summary of the page on 2026-09-24, not the full HTML."
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
"safe_zone_gate": {
|
|
41
|
+
"dp_min": 15.0,
|
|
42
|
+
"edge_artifact_rows": 3,
|
|
43
|
+
"min_rows_to_fail": 10,
|
|
44
|
+
"sample_width": 216,
|
|
45
|
+
"_dp_min": "Row contrast (horizontal std-dev of luma) that counts as legible content. Own threshold, not density.dp_min: density asks 'is there anything here?', the safe zone asks 'is there LEGIBLE TEXT past the line?'. Measured on a real piece: real text 69, background glow and grid 7.0 to 8.6.",
|
|
46
|
+
"_edge_artifact_rows": "Rows ignored at each frame edge: the encoder leaves 1 to 2 dark pixels at the border, which pass dp_min on a light background without being content.",
|
|
47
|
+
"_min_rows_to_fail": "Below this many invading rows the gate warns instead of failing: a whole label past the line measured 27 rows, a rounded card corner grazing it measured 6."
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
"density": {
|
|
51
|
+
"n_bands": 7,
|
|
52
|
+
"min_filled": 3,
|
|
53
|
+
"dp_min": 7.0,
|
|
54
|
+
"step_s": 0.5,
|
|
55
|
+
"sample_width": 216,
|
|
56
|
+
"_note": "Uniform bands between the active safe zone top and bottom, only to measure how full the frame is. Measures local contrast (std-dev of luma), never mean brightness: a light background makes mean brightness pass everything.",
|
|
57
|
+
"_source": "min_filled 3 of 7 is a house rule of the source project, not an external benchmark."
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
"budget": {
|
|
61
|
+
"_readme": "The anti-excess engine. An effect only enters if its curve section still has budget, it respects the minimum distance and it does not repeat too much. When two candidates collide, the higher narrative strength wins.",
|
|
62
|
+
"gap_min_global": 2.0,
|
|
63
|
+
"burst_gap_s": 5.0,
|
|
64
|
+
"_burst_gap_s": "Two same-type effects further apart than this are not read as one burst: the viewer had visual rest between them. Also the widest hole fill_breaths.py tries to close.",
|
|
65
|
+
"per_effect": {
|
|
66
|
+
"punch": { "max_per_min": 10, "gap_min": 3.5, "max_in_row": 2, "dominant": true },
|
|
67
|
+
"flash": { "max_per_min": 10, "gap_min": 2.5, "max_in_row": 3, "dominant": false },
|
|
68
|
+
"shake": { "max_per_min": 3, "gap_min": 8.0, "max_in_row": 1, "dominant": true, "max_dur_s": 0.25 },
|
|
69
|
+
"interrupt": { "max_per_min": 7, "gap_min": 8.0, "max_in_row": 1, "dominant": true },
|
|
70
|
+
"transition": { "max_per_min": 30, "gap_min": 1.2, "max_in_row": 2, "dominant": false }
|
|
71
|
+
},
|
|
72
|
+
"_per_effect": "House numbers of the source project, tuned on 14 pieces after an audit measured one zoom punch every 2.5s. No external source.",
|
|
73
|
+
"curve": [
|
|
74
|
+
{ "name": "hook", "until_frac": 0.06, "weight": 1.2 },
|
|
75
|
+
{ "name": "body", "until_frac": 0.86, "weight": 1.0 },
|
|
76
|
+
{ "name": "cta", "until_frac": 1.0, "weight": 1.2 }
|
|
77
|
+
],
|
|
78
|
+
"_curve": "Weights are almost flat on purpose: dynamism belongs to the WHOLE video, never concentrated in the hook. Hook and CTA get 20% more, nothing else."
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
"sound": {
|
|
82
|
+
"_readme": "Which sound marks which happening. The key is SEMANTIC (what is happening in the story), never the file name. Changing SFX library means changing the `sound` column, without touching any rule.",
|
|
83
|
+
"narration_volume": 1.0,
|
|
84
|
+
"relative_ceiling": 0.65,
|
|
85
|
+
"_relative_ceiling": "No event `vol` may exceed relative_ceiling * narration_volume. A SFX that competes with the voice steals attention instead of marking it.",
|
|
86
|
+
"max_same_sound_in_row": 3,
|
|
87
|
+
"collision_window_s": 0.08,
|
|
88
|
+
"_collision_window_s": "Two sounds closer than this are heard as one dirty sound. House number, no external source.",
|
|
89
|
+
"reference_level_db": -20.0,
|
|
90
|
+
"max_peak_db": -3.0,
|
|
91
|
+
"_levels": "Level contract for every SFX file (normalize_audio.py): mean at reference_level_db, peak locked at max_peak_db, the peak always wins. The `vol` table below only means something if every file starts from the same level. Measured on a real piece: narration at -20.6 dB mean, an un-normalized whoosh ended at -36.1 dB after its vol, i.e. inaudible. An accent sits 6 to 10 dB under the voice.",
|
|
92
|
+
"_strength": "When an event has `triggers`, `strength` (0..1) is the narrative weight of the visual effect it fires, independent from `vol`: volume is mix, strength is how much the moment matters.",
|
|
93
|
+
"bed": {
|
|
94
|
+
"ambience": { "file": "bed/ambience.mp3", "vol": 0.13 },
|
|
95
|
+
"music": { "file": "bed/music.mp3", "vol": 0.2 },
|
|
96
|
+
"_readme": "OPTIONAL continuous layers under everything, relative to paths.media_root. Each enters only if the file exists; a missing file never breaks a render. Music must come from a source cleared for the piece's platform and use (paid ad vs organic)."
|
|
97
|
+
},
|
|
98
|
+
"events": {
|
|
99
|
+
"block_cut": { "sound": "whoosh", "vol": 0.55, "when": "switch of narrative block" },
|
|
100
|
+
"scene_cut": { "sound": "whoosh", "vol": 0.45, "when": "screen change inside the same block" },
|
|
101
|
+
"element_in": { "sound": "pop", "vol": 0.5, "when": "a card, line or label enters" },
|
|
102
|
+
"text_in": { "sound": "riser-metal", "vol": 0.42, "when": "a TEXT card enters: headline, title, anchor sentence" },
|
|
103
|
+
"list_item": { "sound": "coin", "vol": 0.42, "when": "item of a repeating list; the checklist plink" },
|
|
104
|
+
"verdict": {
|
|
105
|
+
"sound": "stamp",
|
|
106
|
+
"vol": 0.65,
|
|
107
|
+
"strength": 0.95,
|
|
108
|
+
"when": "verdict, seal or decision slammed down",
|
|
109
|
+
"triggers": ["shake"]
|
|
110
|
+
},
|
|
111
|
+
"message": { "sound": "notif", "vol": 0.55, "when": "a message, notification or alert arrives" },
|
|
112
|
+
"typing": { "sound": "type", "vol": 0.35, "when": "text being typed on screen" },
|
|
113
|
+
"paste": { "sound": "paste", "vol": 0.45, "when": "copy or paste" },
|
|
114
|
+
"click": { "sound": "click", "vol": 0.4, "when": "interface interaction, cursor, tap" },
|
|
115
|
+
"send": { "sound": "send", "vol": 0.5, "when": "send, publish, submit" },
|
|
116
|
+
"price": { "sound": "cash", "vol": 0.55, "when": "the price appears or changes" },
|
|
117
|
+
"gain": { "sound": "coin", "vol": 0.45, "when": "value gained, savings, counter going up" },
|
|
118
|
+
"done": { "sound": "success", "vol": 0.55, "when": "check, ready, task done" }
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
"ducking": {
|
|
123
|
+
"depth_db": 4.0,
|
|
124
|
+
"attack_ms": 30,
|
|
125
|
+
"release_ms": 200,
|
|
126
|
+
"assumed_sfx_duration_s": 0.4,
|
|
127
|
+
"_source": "extrapolation, no primary source. Research proposal: 3-6 dB depth, attack < 50 ms, release 150-300 ms, applied only when a transient SFX collides with the start of a spoken word. Derived from music-under-voice ducking guides (18-25 dB, attack < 300 ms, release 300-700 ms), which target a continuous bed, not short SFX.",
|
|
128
|
+
"_assumed_sfx_duration_s": "Used only when neither the timeline nor the file on disk gives a SFX duration."
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
"loudness": {
|
|
132
|
+
"target_lufs": -14.0,
|
|
133
|
+
"tolerance_lu": 1.0,
|
|
134
|
+
"max_true_peak_db": -1.0,
|
|
135
|
+
"lra": 11.0,
|
|
136
|
+
"encode_tp_margin_db": 1.0,
|
|
137
|
+
"_encode_tp_margin_db": "Measured on the test piece (2026-09-24): re-encoding the loudnorm output to AAC pushed the true peak from -1.0 to -0.8 dBTP, so the fix command failed its own gate. For lossy outputs the printed fix aims this much under max_true_peak_db.",
|
|
138
|
+
"_source": "Industry mastering practice for TikTok and Reels (-14 LUFS integrated, -1 dBTP), replicated by mastering guides; neither TikTok nor Meta publishes an official loudness spec. Mastering louder does not help: the platform turns it down and may distort on re-encode. lra is the ffmpeg loudnorm default, used only by the printed fix command."
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
"rhythm": {
|
|
142
|
+
"max_static_s": 5.0,
|
|
143
|
+
"hook_s": 3.0,
|
|
144
|
+
"hook_cut_every_s": 3.0,
|
|
145
|
+
"body_cut_every_s": [2.5, 4.0],
|
|
146
|
+
"cta_tail_s": 3.0,
|
|
147
|
+
"counts_as_change": ["scenes", "punches", "shakes", "flashes", "interrupts", "elements"],
|
|
148
|
+
"_source": "Weak source: pattern-interrupt and pacing blogs (change something every 3-5s; Reels cuts every 2.5-4s) plus the 3-second hook rate metric of Meta Ads Manager. That is why check_rhythm.py only warns, never blocks.",
|
|
149
|
+
"_hook_s": "Owner decision: the hook closes at 3s.",
|
|
150
|
+
"_cta_tail_s": "No source. Seconds at the end excluded from the body cadence, because the CTA lowers effect density on purpose to keep price and button legible."
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
"breaths": {
|
|
154
|
+
"min_silence_s": 0.22,
|
|
155
|
+
"silence_db": -30,
|
|
156
|
+
"edge_guard_s": 0.4,
|
|
157
|
+
"fast_speech_wps": 2.8,
|
|
158
|
+
"punch_peak_scale": 1.066,
|
|
159
|
+
"pace_profiles": {
|
|
160
|
+
"first": { "transition": "cut", "sound_event": "block_cut" },
|
|
161
|
+
"fast": { "transition": "whip", "sound_event": "block_cut" },
|
|
162
|
+
"slow": { "transition": "zoom", "sound_event": "element_in" }
|
|
163
|
+
},
|
|
164
|
+
"punch_sound_event": "element_in",
|
|
165
|
+
"_note": "An effect anchors in the real silence of speech, in the MIDDLE of the silence, never glued to a word. Below min_silence_s a gap is a pause between syllables, not a breath. punch_peak_scale is the real peak of a punch multiplied by the continuous micro-zoom (1.035 * 1.03); a point closer to the frame edge than the zone edge divided by it leaves the safe zone at the peak.",
|
|
166
|
+
"_fast_speech_wps": "House number: words per second above which a block reads as a burst and gets an aggressive cut. No external source."
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
"avatar": {
|
|
170
|
+
"_optional": "Only for pieces with a generated avatar. Delete the block otherwise.",
|
|
171
|
+
"max_full_shot_s": 4.5,
|
|
172
|
+
"max_occlusion_s": 5.0,
|
|
173
|
+
"max_take_s": 6.0,
|
|
174
|
+
"variation_step_s": 4.0,
|
|
175
|
+
"min_variation_s": 1.2,
|
|
176
|
+
"_source": "The gesture cycle of a generated avatar is around 7.5s and then visibly repeats. Every ceiling here derives from that observation on one provider; re-measure for another."
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Reference layer that plays a wizz-video-effects timeline in Remotion.
|
|
2
|
+
// Copy into the project and adapt; see README.md.
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { AbsoluteFill, Sequence, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
|
5
|
+
import { Audio } from "@remotion/media";
|
|
6
|
+
import {
|
|
7
|
+
EffectsTimeline,
|
|
8
|
+
allSounds,
|
|
9
|
+
flashOpacity,
|
|
10
|
+
instants,
|
|
11
|
+
narrationVolume,
|
|
12
|
+
punchScale,
|
|
13
|
+
shakeOffset,
|
|
14
|
+
transitionAt,
|
|
15
|
+
} from "./useEffects";
|
|
16
|
+
|
|
17
|
+
/** Narration with the ducking callback, plus every SFX at its `at` and `vol`. */
|
|
18
|
+
export const EffectsAudio: React.FC<{
|
|
19
|
+
timeline: EffectsTimeline;
|
|
20
|
+
narrationSrc: string;
|
|
21
|
+
narrationStart?: number; // seconds
|
|
22
|
+
}> = ({ timeline, narrationSrc, narrationStart = 0 }) => {
|
|
23
|
+
const { fps } = useVideoConfig();
|
|
24
|
+
const startFrame = Math.round(narrationStart * fps);
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<Sequence from={startFrame} name="narration">
|
|
28
|
+
<Audio
|
|
29
|
+
src={staticFile(narrationSrc)}
|
|
30
|
+
volume={narrationVolume(timeline.ducking ?? [], fps, startFrame)}
|
|
31
|
+
/>
|
|
32
|
+
</Sequence>
|
|
33
|
+
{allSounds(timeline).map((s, i) => (
|
|
34
|
+
<Sequence key={`sfx-${i}`} from={Math.round(s.at * fps)} name={`sfx ${s.event ?? s.src}`}>
|
|
35
|
+
<Audio src={staticFile(s.src)} volume={s.vol} />
|
|
36
|
+
</Sequence>
|
|
37
|
+
))}
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Shake moves everything inside it. Put the whole stage here, never the captions alone. */
|
|
43
|
+
export const ShakeStage: React.FC<{ timeline: EffectsTimeline; children: React.ReactNode }> = ({
|
|
44
|
+
timeline,
|
|
45
|
+
children,
|
|
46
|
+
}) => {
|
|
47
|
+
const frame = useCurrentFrame();
|
|
48
|
+
const { fps } = useVideoConfig();
|
|
49
|
+
const { x, y } = shakeOffset(frame / fps, instants(timeline.shakes));
|
|
50
|
+
return <AbsoluteFill style={{ transform: `translate(${x}px, ${y}px)` }}>{children}</AbsoluteFill>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Punch and scene transitions scale the shot. Wrap only the shot (footage or
|
|
54
|
+
* screen), inside a container with overflow hidden: scaling the whole frame
|
|
55
|
+
* pushes text through the safe zone at the peak. */
|
|
56
|
+
export const PunchShot: React.FC<{ timeline: EffectsTimeline; children: React.ReactNode }> = ({
|
|
57
|
+
timeline,
|
|
58
|
+
children,
|
|
59
|
+
}) => {
|
|
60
|
+
const frame = useCurrentFrame();
|
|
61
|
+
const { fps } = useVideoConfig();
|
|
62
|
+
const t = frame / fps;
|
|
63
|
+
const tr = transitionAt(frame, fps, timeline.scenes ?? []);
|
|
64
|
+
const scale = punchScale(t, instants(timeline.punches)) * tr.scale;
|
|
65
|
+
return (
|
|
66
|
+
<AbsoluteFill
|
|
67
|
+
style={{
|
|
68
|
+
transform: `translateX(${tr.x}px) scale(${scale})`,
|
|
69
|
+
filter: tr.blurPx > 0 ? `blur(${tr.blurPx}px)` : undefined,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{children}
|
|
73
|
+
</AbsoluteFill>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Opacity-only flash: moves no pixel, so it fits where a punch does not. */
|
|
78
|
+
export const FlashOverlay: React.FC<{ timeline: EffectsTimeline; color?: string }> = ({
|
|
79
|
+
timeline,
|
|
80
|
+
color = "#FFFFFF",
|
|
81
|
+
}) => {
|
|
82
|
+
const frame = useCurrentFrame();
|
|
83
|
+
const { fps } = useVideoConfig();
|
|
84
|
+
const opacity = flashOpacity(frame / fps, instants(timeline.flashes));
|
|
85
|
+
if (opacity <= 0) return null;
|
|
86
|
+
return <AbsoluteFill style={{ backgroundColor: color, opacity, pointerEvents: "none" }} />;
|
|
87
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Remotion reference layer
|
|
2
|
+
|
|
3
|
+
Reference code to **copy into your Remotion project** (for example `src/effects/`) and adapt. It is not a package and nothing imports it from here.
|
|
4
|
+
|
|
5
|
+
It plays a timeline written by this skill's scripts ([../../references/data-contract.md](../../references/data-contract.md)):
|
|
6
|
+
|
|
7
|
+
| Export | File | Does |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `EffectsAudio` | `EffectsLayer.tsx` | narration with the `ducking[]` volume callback, every `sfx[]` entry and every scene cut sound at its `at` and `vol` |
|
|
10
|
+
| `ShakeStage` | `EffectsLayer.tsx` | short decaying tremor on `shakes[]`, never longer than `SHAKE.durS` |
|
|
11
|
+
| `PunchShot` | `EffectsLayer.tsx` | zoom pulse on `punches[]` and the `zoom` / `whip` scene transitions |
|
|
12
|
+
| `FlashOverlay` | `EffectsLayer.tsx` | opacity-only flash on `flashes[]` |
|
|
13
|
+
| pure math | `useEffects.ts` | the same functions, frame in, value out, deterministic |
|
|
14
|
+
|
|
15
|
+
Usage sketch:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
<AbsoluteFill>
|
|
19
|
+
<ShakeStage timeline={tl}>
|
|
20
|
+
<div style={{ position: "absolute", overflow: "hidden", ...shotBox }}>
|
|
21
|
+
<PunchShot timeline={tl}>{/* silent screen or footage */}</PunchShot>
|
|
22
|
+
</div>
|
|
23
|
+
{/* cards and captions of the piece */}
|
|
24
|
+
</ShakeStage>
|
|
25
|
+
<FlashOverlay timeline={tl} />
|
|
26
|
+
<EffectsAudio timeline={tl} narrationSrc="narration.mp3" />
|
|
27
|
+
</AbsoluteFill>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Rules the layer assumes:
|
|
31
|
+
|
|
32
|
+
- It draws what the timeline says and decides nothing. How many effects and where was decided by the scripts and the budget.
|
|
33
|
+
- Wrap only the shot in `PunchShot`, inside a box with `overflow: hidden`. Scaling the whole frame pushes captions and cards through the safe zone at the peak.
|
|
34
|
+
- Shake constants (`SHAKE`, `PUNCH`, `FLASH`, `TRANSITION` in `useEffects.ts`) shape one accepted effect; they are component constants, not budget numbers.
|
|
35
|
+
- `src` values are relative to Remotion `public/` (`staticFile`), as the data contract says.
|
|
36
|
+
|
|
37
|
+
For the Remotion API itself (`<Audio>`, `<Sequence>`, `<Video>` props such as `objectFit`), invoke the `wizz-remotion-best-practices` skill.
|
|
@@ -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.
|