@kenkaiiii/ggcoder 5.66.4 → 5.67.1

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.
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ // Photosensitivity screen on decoded pixels against the WCAG 2.3.1 general and red flash
3
+ // thresholds. As in EA's open-source IRIS tool, an area's mean change only counts on frames
4
+ // where at least a quarter of the area changes, so moving content mostly cancels out rather
5
+ // than reading as flashing. IRIS applies this to the whole screen; here it applies to every
6
+ // WCAG 10° field (a third of the frame each way), so a flash filling part of the frame is
7
+ // caught too. A transition is a move of 0.1 relative luminance (or 20 red units) away from the
8
+ // last peak or valley, as WCAG defines it; more than six in any second fails. Renders above
9
+ // 60 fps are sampled at 60. A screen, not a certification.
10
+ import { spawn } from "node:child_process";
11
+ import { createReadStream } from "node:fs";
12
+ import { stat } from "node:fs/promises";
13
+ import { createHash } from "node:crypto";
14
+ import { once } from "node:events";
15
+ import { resolve } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { ffmpegBinary } from "./media-binaries.mjs";
18
+
19
+ /** Cells per side of the analysis grid; every aspect ratio maps onto it. */
20
+ export const GRID = 48;
21
+ /** Frames per analysed second; slower renders are repeated, so one second is always 60 frames. */
22
+ export const ANALYSIS_FPS = 60;
23
+ /** WCAG approximates the 10° field as 341×256 px of a 1024×768 screen: a third each way. */
24
+ const FIELD = GRID / 3;
25
+ const FIELD_CELLS = FIELD * FIELD;
26
+ /** A field's change counts once a quarter of it changes. */
27
+ const AREA = 0.25 * FIELD_CELLS;
28
+ const WINDOWS_PER_SIDE = GRID - FIELD + 1;
29
+ const WINDOWS = WINDOWS_PER_SIDE * WINDOWS_PER_SIDE;
30
+ /** More than three flashes (six opposing transitions) within any one second fails. */
31
+ const MAX_TRANSITIONS = 6;
32
+ /** Transitions per field within the trailing second never exceed one per analysed frame. */
33
+ const RING = 64;
34
+ const CELLS = GRID * GRID;
35
+ const FRAME_BYTES = CELLS * 3;
36
+ const CHANNELS = {
37
+ // Relative luminance 0–1: a 0.1 swing, unless even the darker state is 0.8 or brighter.
38
+ // A cell has changed once it moves by more than encoding noise (about 1/255 linear).
39
+ general: { threshold: 0.1, darkLimit: 0.8, noise: 0.004 },
40
+ // WCAG's red measure: (R − G − B) × 320 where R / (R + G + B) ≥ 0.8, else 0.
41
+ red: { threshold: 20, darkLimit: Infinity, noise: 1 },
42
+ };
43
+ /** sRGB to linear light, applied before averaging so mixed cells keep their real brightness. */
44
+ const LINEAR = "if(lte(val,10.31475),val/12.92,255*pow((val/255+0.055)/1.055,2.4))";
45
+
46
+ function createChannel({ threshold, darkLimit, noise }) {
47
+ const side = GRID + 1;
48
+ const changed = new Float64Array(side * side);
49
+ const change = new Float64Array(side * side);
50
+ const level = new Float64Array(side * side);
51
+ // Per field: the accumulated counted change, its last peak and valley, and the field's
52
+ // mean at each, for WCAG's "darker image" rule.
53
+ const signal = new Float64Array(WINDOWS);
54
+ const peakValue = new Float64Array(WINDOWS);
55
+ const peakLevel = new Float64Array(WINDOWS);
56
+ const valleyValue = new Float64Array(WINDOWS);
57
+ const valleyLevel = new Float64Array(WINDOWS);
58
+ /** 1 after a rise, -1 after a fall, 0 before the first transition. */
59
+ const direction = new Int8Array(WINDOWS);
60
+ const ring = new Int32Array(WINDOWS * RING);
61
+ const head = new Int32Array(WINDOWS);
62
+ const tail = new Int32Array(WINDOWS);
63
+ const failures = [];
64
+ let peak = 0;
65
+
66
+ return {
67
+ update(frame, previous, current) {
68
+ // Summed-area tables, so each field's totals cost four lookups.
69
+ for (let y = 0; y < GRID; y++) {
70
+ let rowChanged = 0;
71
+ let rowChange = 0;
72
+ let rowLevel = 0;
73
+ for (let x = 0; x < GRID; x++) {
74
+ const cell = y * GRID + x;
75
+ const delta = current[cell] - previous[cell];
76
+ if (Math.abs(delta) > noise) rowChanged++;
77
+ rowChange += delta;
78
+ rowLevel += current[cell];
79
+ const at = (y + 1) * side + x + 1;
80
+ changed[at] = changed[at - side] + rowChanged;
81
+ change[at] = change[at - side] + rowChange;
82
+ level[at] = level[at - side] + rowLevel;
83
+ }
84
+ }
85
+ let most = 0;
86
+ for (let wy = 0; wy < WINDOWS_PER_SIDE; wy++) {
87
+ for (let wx = 0; wx < WINDOWS_PER_SIDE; wx++) {
88
+ const window = wy * WINDOWS_PER_SIDE + wx;
89
+ const a = wy * side + wx;
90
+ const b = a + FIELD;
91
+ const c = a + FIELD * side;
92
+ const d = c + FIELD;
93
+ const area = changed[d] - changed[b] - changed[c] + changed[a];
94
+ const step = (change[d] - change[b] - change[c] + change[a]) / FIELD_CELLS;
95
+ const mean = (level[d] - level[b] - level[c] + level[a]) / FIELD_CELLS;
96
+ if (frame === 1) {
97
+ peakLevel[window] = mean - step;
98
+ valleyLevel[window] = mean - step;
99
+ }
100
+ const value = signal[window] + (area >= AREA ? step : 0);
101
+ signal[window] = value;
102
+ let transition = false;
103
+ if (direction[window] >= 0) {
104
+ if (value > peakValue[window]) {
105
+ peakValue[window] = value;
106
+ peakLevel[window] = mean;
107
+ } else if (peakValue[window] - value >= threshold) {
108
+ transition = Math.min(mean, peakLevel[window]) < darkLimit;
109
+ direction[window] = -1;
110
+ valleyValue[window] = value;
111
+ valleyLevel[window] = mean;
112
+ }
113
+ }
114
+ if (direction[window] <= 0) {
115
+ if (value < valleyValue[window]) {
116
+ valleyValue[window] = value;
117
+ valleyLevel[window] = mean;
118
+ } else if (value - valleyValue[window] >= threshold) {
119
+ transition = Math.min(mean, valleyLevel[window]) < darkLimit;
120
+ direction[window] = 1;
121
+ peakValue[window] = value;
122
+ peakLevel[window] = mean;
123
+ }
124
+ }
125
+ const base = window * RING;
126
+ while (
127
+ tail[window] < head[window] &&
128
+ frame - ring[base + (tail[window] % RING)] >= ANALYSIS_FPS
129
+ )
130
+ tail[window]++;
131
+ if (transition) {
132
+ ring[base + (head[window] % RING)] = frame;
133
+ head[window]++;
134
+ }
135
+ const count = head[window] - tail[window];
136
+ if (count > most) most = count;
137
+ }
138
+ }
139
+ peak = Math.max(peak, most);
140
+ if (most > MAX_TRANSITIONS) {
141
+ const start = Math.max(0, frame + 1 - ANALYSIS_FPS) / ANALYSIS_FPS;
142
+ const end = (frame + 1) / ANALYSIS_FPS;
143
+ const last = failures.at(-1);
144
+ if (last && start <= last.end) last.end = end;
145
+ else failures.push({ start, end });
146
+ }
147
+ },
148
+ summary() {
149
+ return {
150
+ maxFlashesPerSecond: peak / 2,
151
+ failures: failures.slice(0, 20).map(({ start, end }) => ({
152
+ start: Math.round(start * 100) / 100,
153
+ end: Math.round(end * 100) / 100,
154
+ })),
155
+ };
156
+ },
157
+ };
158
+ }
159
+
160
+ /** Pure analysis over linear-light RGB frames of GRID × GRID cells (3 bytes per cell). */
161
+ export function createFlashAnalyzer() {
162
+ const general = createChannel(CHANNELS.general);
163
+ const red = createChannel(CHANNELS.red);
164
+ let luminance = new Float64Array(CELLS);
165
+ let redness = new Float64Array(CELLS);
166
+ let previousLuminance = new Float64Array(CELLS);
167
+ let previousRedness = new Float64Array(CELLS);
168
+ let frames = 0;
169
+ return {
170
+ push(frame) {
171
+ if (!(frame instanceof Uint8Array) || frame.length !== FRAME_BYTES)
172
+ throw new Error(`Expected ${FRAME_BYTES}-byte analysis frames`);
173
+ for (let cell = 0; cell < CELLS; cell++) {
174
+ const r = frame[cell * 3] / 255;
175
+ const g = frame[cell * 3 + 1] / 255;
176
+ const b = frame[cell * 3 + 2] / 255;
177
+ luminance[cell] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
178
+ const total = r + g + b;
179
+ redness[cell] = total > 0 && r / total >= 0.8 ? Math.max(0, (r - g - b) * 320) : 0;
180
+ }
181
+ if (frames > 0) {
182
+ general.update(frames, previousLuminance, luminance);
183
+ red.update(frames, previousRedness, redness);
184
+ }
185
+ [previousLuminance, luminance] = [luminance, previousLuminance];
186
+ [previousRedness, redness] = [redness, previousRedness];
187
+ frames++;
188
+ },
189
+ result() {
190
+ const generalSummary = general.summary();
191
+ const redSummary = red.summary();
192
+ return {
193
+ ok: frames >= 2 && !generalSummary.failures.length && !redSummary.failures.length,
194
+ method: "rendered-pixels",
195
+ standard: "WCAG 2.3.1 general and red flash thresholds",
196
+ analysisFps: ANALYSIS_FPS,
197
+ frames,
198
+ duration: frames / ANALYSIS_FPS,
199
+ general: generalSummary,
200
+ red: redSummary,
201
+ };
202
+ },
203
+ };
204
+ }
205
+
206
+ /** Decode a video into linear-light analysis frames and return the flash report. */
207
+ export async function analyzeFlashes(file, signal) {
208
+ const binary = await ffmpegBinary();
209
+ const child = spawn(
210
+ binary,
211
+ [
212
+ "-hide_banner",
213
+ "-nostdin",
214
+ "-loglevel",
215
+ "error",
216
+ "-xerror",
217
+ "-protocol_whitelist",
218
+ "file,pipe",
219
+ "-i",
220
+ file,
221
+ "-map",
222
+ "0:v:0",
223
+ "-an",
224
+ "-vf",
225
+ [
226
+ // Linearise before any averaging: shrinking sRGB values first darkens fine detail
227
+ // by up to half the flash threshold.
228
+ "format=gbrp",
229
+ `lutrgb=r='${LINEAR}':g='${LINEAR}':b='${LINEAR}'`,
230
+ `scale=${GRID}:${GRID}:flags=area`,
231
+ `fps=${ANALYSIS_FPS}`,
232
+ "format=rgb24",
233
+ ].join(","),
234
+ "-f",
235
+ "rawvideo",
236
+ "-pix_fmt",
237
+ "rgb24",
238
+ "pipe:1",
239
+ ],
240
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, signal },
241
+ );
242
+ let stderr = "";
243
+ child.stderr.setEncoding("utf8");
244
+ child.stderr.on("data", (text) => {
245
+ if (stderr.length < 16_000) stderr += text;
246
+ });
247
+ const closed = once(child, "close");
248
+ closed.catch(() => {});
249
+ const analyzer = createFlashAnalyzer();
250
+ const frame = new Uint8Array(FRAME_BYTES);
251
+ let filled = 0;
252
+ for await (const chunk of child.stdout) {
253
+ let offset = 0;
254
+ while (offset < chunk.length) {
255
+ const take = Math.min(FRAME_BYTES - filled, chunk.length - offset);
256
+ frame.set(chunk.subarray(offset, offset + take), filled);
257
+ filled += take;
258
+ offset += take;
259
+ if (filled === FRAME_BYTES) {
260
+ analyzer.push(frame);
261
+ filled = 0;
262
+ }
263
+ }
264
+ }
265
+ const [code] = await closed;
266
+ if (code !== 0)
267
+ throw new Error(`FFmpeg could not decode the video: ${stderr.trim().slice(-2000)}`);
268
+ if (filled !== 0) throw new Error("Decoded video ended mid-frame; flashing is unverified");
269
+ const result = analyzer.result();
270
+ if (result.frames < 2) throw new Error("Too few decoded frames; flashing is unverified");
271
+ return result;
272
+ }
273
+
274
+ async function videoHash(file, signal) {
275
+ const hash = createHash("sha256");
276
+ for await (const chunk of createReadStream(file, { signal })) hash.update(chunk);
277
+ return hash.digest("hex");
278
+ }
279
+
280
+ async function main() {
281
+ const [video, ...extra] = process.argv.slice(2);
282
+ if (!video || extra.length) throw new Error("usage: flash-check.mjs <rendered-video>");
283
+ const file = resolve(video);
284
+ if (!(await stat(file)).isFile()) throw new Error("Expected a local rendered video file");
285
+ const controller = new AbortController();
286
+ const abort = () => controller.abort();
287
+ process.once("SIGTERM", abort);
288
+ process.once("SIGINT", abort);
289
+ const started = performance.now();
290
+ try {
291
+ const videoSha256 = await videoHash(file, controller.signal);
292
+ const result = await analyzeFlashes(file, controller.signal);
293
+ if ((await videoHash(file, controller.signal)) !== videoSha256)
294
+ throw new Error("Video changed during analysis; flashing is unverified");
295
+ process.stdout.write(
296
+ `${JSON.stringify({ ...result, file, videoSha256, elapsedMs: Math.round(performance.now() - started) })}\n`,
297
+ );
298
+ if (!result.ok) process.exitCode = 1;
299
+ } finally {
300
+ process.removeListener("SIGTERM", abort);
301
+ process.removeListener("SIGINT", abort);
302
+ }
303
+ }
304
+
305
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
306
+ try {
307
+ await main();
308
+ } catch (error) {
309
+ process.stderr.write(
310
+ `Flash verification failed: ${error instanceof Error ? error.message : "invalid input"}\n`,
311
+ );
312
+ process.exitCode = 1;
313
+ }
314
+ }
@@ -10,7 +10,9 @@ copy an earlier video's.
10
10
  Make a showcase piece, as if the best motion designers will judge it frame by
11
11
  frame. Ambition means deliberate craft, not length or effect count. Every
12
12
  colour, word, cut and ease needs a reason. Cut generic defaults: plain fades,
13
- slowly drifting gradients, text that slides up and sits there.
13
+ slowly drifting gradients, text that slides up and sits there. Judge it as the
14
+ kind of video it is: an explainer by how clearly it teaches, a tribute by how
15
+ it honours its subject, a launch by how it makes people want the product.
14
16
 
15
17
  ## Plan first
16
18
 
@@ -20,58 +22,94 @@ Before writing code, decide these and record them in the `Concept` and
20
22
  - **Concept**: the idea the video demonstrates, in one sentence. Show it; don't
21
23
  just state it.
22
24
  - **Motif**: one shape or element that recurs and links every scene.
25
+ - **Register**: the tone (calm, playful, urgent, tender, precise…) and what it
26
+ means for size, speed, easing and colour. Take it from the brief, the brand
27
+ and the viewing context.
23
28
  - **Palette**: each colour's role.
24
29
  - **Type**: each family's role.
25
30
  - **Beat**: tempo, arc and frame rate.
26
31
 
27
32
  ## Principles
28
33
 
34
+ **Register.** Map the tone to motion before choosing anything else. Quiet,
35
+ steady motion carries information and reading; expressive motion is saved for
36
+ the moments that matter, because it only reads as special against calm. These
37
+ pairings are illustrative, not presets: calm is long, soft moves and few of
38
+ them; playful is springy overshoot, squash and stretch; luxurious is slow and
39
+ precise with generous space; urgent is fast moves and hard cuts; tender is
40
+ warm, slow and held; technical is precise and snapping.
41
+
29
42
  **Continuity.** Each scene hands something to the next: a shape, a word, a
30
43
  colour field or a camera move. Prefer match cuts and shape handoffs to
31
44
  cross-fades. The `type-as-spatial-handoff` study in [README.md](README.md) shows
32
45
  type becoming the next scene.
33
46
 
34
- **Palette.** At most four flat colours: a dark neutral, a light neutral, one
35
- saturated hero colour and one bright accent used sparingly. Derive them from
36
- the brand or subject. Full-frame colour blocks that change on most cuts carry
37
- energy; gradients and glows are seasoning, not the base. Keep text readable.
38
-
39
- **Type.** One heavy display family, set huge: hero words around 250–360 px at
40
- 1080p, one to three words per shot. Keep any explanatory line short. Animate
41
- weight and width axes as motion, not only position and opacity. Add at most one
42
- contrasting voice, such as an italic serif, plus small uppercase tracked mono
43
- for labels and UI. Bundled axes (see `fonts.mjs list` and its specimen): weight
44
- and width in Mona Sans, Hubot Sans, Anybody, Archivo, Bricolage Grotesque and
45
- Martian Mono; italics in Fraunces and Newsreader. Brand fonts win.
46
-
47
- **Motion.** Entrances `expo.out`; exits `power3.in`; moves `expo.inOut` or
48
- `power3.inOut`; pops `back.out`; settles `elastic.out`, sparingly. Most
49
- durations 0.12–0.7 s; staggers 0.012–0.06 s. Use real animation principles
50
- where they help: anticipation before big moves, squash and stretch on impact,
51
- arcs instead of straight paths, overlapping action, follow-through and smears on
52
- fast moves. Every move starts, travels and lands on purpose; nothing drifts. For
53
- calm or luxury subjects, lengthen durations and soften eases, and stay just as
54
- deliberate.
55
-
56
- **Pace.** Cut on a beat grid even without music: 0.5 s at 120 BPM by default,
57
- or the track's beats (`hf beats`) when there is music. For a short promo: a slow
58
- opening, a fast-cut middle, one showpiece moment, a climax such as a
59
- convergence or particle burst, then an end card held about 2 s. Declare that
60
- hold in the hold plan before checking.
61
-
62
- **3D, when the subject earns it.** One hero object with a physical material
63
- (clearcoat or iridescent) and environment lighting; a few supporting elements
64
- such as orbiting rings, trails or sparse debris; a soft coloured glow; a
65
- cinematic camera move such as an orbit or dolly zoom. Resolve 3D back to flat 2D
66
- to hand off to the next scene. Use the bundled Three.js (`three.mjs`); no 3D by
47
+ **Palette.** A few colours, each with a role, derived from the brand, subject or
48
+ references. How many, how saturated and how often the frame changes colour
49
+ follow the register: full-frame colour blocks that change on most cuts suit a
50
+ loud, kinetic piece; a calm, tender or documentary piece holds a steadier field
51
+ and lets one accent carry attention. Gradients and glows are seasoning, not the
52
+ base. Keep text readable.
53
+
54
+ **No harmful flashing.** Anything flashing over more than about 3% of the frame
55
+ counts. Keep light/dark swaps, and swaps to and from saturated red, to at most
56
+ six in any one second (three flashes): faster flashing can trigger seizures,
57
+ and `motion_check` fails it. A swap on every beat at 120 BPM is fine; a white
58
+ flash frame on every half-beat is not.
59
+
60
+ **Type.** Choose faces for the register and the reading job. Size follows the
61
+ viewing context and how much there is to read: a kinetic poster can set one to
62
+ three huge words per shot; an explainer, documentary or lyric video sets whole
63
+ lines at a comfortable reading size. Size type against the frame's shorter side
64
+ (in vertical video, the width inside the safe margins) and check that the
65
+ longest word fits. Animate weight and width axes as motion, not only position
66
+ and opacity. Use one display family, at most one contrasting voice, such as an
67
+ italic serif, and small uppercase tracked mono for labels and UI where the
68
+ subject calls for it. Bundled axes (see `fonts.mjs list` and its specimen):
69
+ weight and width in Mona Sans, Hubot Sans, Anybody, Archivo, Bricolage
70
+ Grotesque and Martian Mono; italics in Fraunces and Newsreader. Brand fonts win.
71
+
72
+ **Motion.** Smooth by default, bounce by choice. Entrances settle on the
73
+ critically damped spring or `power3.out` / `expo.out`; exits `power3.in`; moves
74
+ `power3.inOut` or `expo.inOut`. Overshoot (`back.out`, `elastic.out` or an
75
+ underdamped spring) is a register chosen for playful or physical work, never
76
+ the default; see [easing](runtime/gsap-easing-and-stagger.md). Durations follow
77
+ the register: roughly 0.15–0.5 s for snappy, energetic or technical motion, and
78
+ 0.6–1.5 s or longer for calm, luxurious or tender work. Staggers run 0.012–0.06 s
79
+ for type and longer for larger elements. Use real animation principles where
80
+ they help: anticipation before big moves, squash and stretch on impact, arcs
81
+ instead of straight paths, overlapping action, follow-through and smears on fast
82
+ moves. Lead with one movement at a time, with smaller overlapping motion under
83
+ it. Every move starts, travels and lands on purpose; nothing drifts without a
84
+ reason.
85
+
86
+ **Pace.** Keep time on a grid: the track's beats (`hf beats`) when there is
87
+ music, otherwise a tempo chosen for the register. Cut by how much there is to
88
+ take in, not by the grid alone: a simple image can last one beat; a line to
89
+ read or an idea to grasp holds for several. Meaningful text stays still and
90
+ readable for at least the longer of 0.8 s and one second per 15 characters,
91
+ longer for the key line; repeated or decorative words can go faster. Vary the
92
+ density (build, release, rest) so the video is neither always fast nor always
93
+ slow. Shape the whole to its form, with a beginning, development, peak and
94
+ resolution even in five seconds. Illustratively: a launch builds to its
95
+ product, an explainer steps through its idea, a lyric video follows the song's
96
+ sections, a tribute lets each moment breathe. End on a composed frame held long
97
+ enough to read, and declare that hold in the hold plan before checking.
98
+
99
+ **3D, when the subject earns it.** One hero object whose material, light and
100
+ camera come from the concept and register, not a stock tech look; add
101
+ supporting elements only when they mean something. Resolve 3D back to flat 2D to
102
+ hand off to the next scene. Use the bundled Three.js (`three.mjs`); no 3D by
67
103
  default.
68
104
 
69
105
  **Chrome, when it suits.** A small persistent mono layer (labels, timecode,
70
106
  chapter name, a progress bar with markers) makes a tech subject feel like a pro
71
107
  tool. Leave it out elsewhere.
72
108
 
73
- **Frame rate.** 60 fps for fast, dense motion, where smears and quick cuts read
74
- cleaner; 30 fps for calmer pieces. 60 fps doubles render time.
109
+ **Frame rate.** 30 fps suits most work. Use 60 fps when fast, dense motion or
110
+ scrolling UI must read smoothly; it doubles render time. Renders carry no
111
+ motion blur, so fast moves stutter at 24 fps: keep 24 fps for slow, filmic or
112
+ handmade work.
75
113
 
76
114
  **Truth.** Show only real product UI, numbers and claims from the user's
77
115
  sources.
@@ -79,7 +117,7 @@ sources.
79
117
  ## Building blocks
80
118
 
81
119
  Library looks (`library.mjs list looks`) are complete palette, type and rhythm
82
- systems; when one fits the concept, it replaces the palette and type defaults
120
+ systems; when one fits the concept, it replaces the palette and type choices
83
121
  above. Library pieces are optional building blocks.
84
122
 
85
123
  ## Check against the plan
@@ -6,19 +6,19 @@ Built-in eases: `power1`, `power2`, `power3`, `power4`, `back`, `bounce`, `circ`
6
6
 
7
7
  Each has `.in`, `.out`, `.inOut` variants.
8
8
 
9
- | Ease | Use for |
10
- | ------------------------------------------ | ----------------------------------------------------------------------------------------------- |
11
- | `power1.out`, `power2.out` | Gentle motion for secondary elements (a caption fade, a small shift). NOT the entrance default. |
12
- | `power3.out` (house default), `power4.out` | The standard long-tail settle. Entrances, title cards, hero reveals. |
13
- | `sine.inOut` | Long, slow, calm motion. Crossfades, ambient drift. |
14
- | `back.out(1.7)` | Overshoot then settle. RARE — explicitly-playful register only, never a default. |
15
- | `elastic.out(1, 0.3)` | Springy bounce. Same playful-only rule; prefer a baked spring (see Spring Eases below). |
16
- | `expo.inOut` | Snappy, dramatic. Quick transitions between hero scenes. |
17
- | `none` (linear) | Camera moves with timed counterpoint, mechanical motion. |
9
+ | Ease | Use for |
10
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
11
+ | `power1.out`, `power2.out` | Gentle motion for secondary elements (a caption fade, a small shift). NOT the entrance default. |
12
+ | `power3.out` (house default), `power4.out` | The standard long-tail settle. Entrances, title cards, hero reveals. |
13
+ | `sine.inOut` | Long, slow, calm motion. Crossfades, ambient drift. |
14
+ | `back.out(1.7)` | Overshoot then settle. A chosen register (playful, physical), never a default. |
15
+ | `elastic.out(1, 0.3)` | Springy bounce. Same register rule; prefer a baked spring (see Spring Eases below). |
16
+ | `expo.inOut` | Snappy, dramatic. Quick transitions between hero scenes. |
17
+ | `none` (linear) | Camera moves with timed counterpoint, mechanical motion. |
18
18
 
19
19
  Pick `.out` for entrances, `.in` for exits, `.inOut` for symmetric moves and continuous motion.
20
20
 
21
- **Smooth beats bouncy** — the motion doctrine (`rules/spring-pop-entrance.md`, the workflows' `motion-language.md`): entrances default to `power3.out` or the baked critically-damped spring (see Spring Eases below); overshoot eases (`back` / `elastic` / `bounce`) are a rare, explicitly-playful register, never the house style.
21
+ **Smooth by default, bounce by choice** — the same rule as the Motion section of [`motion-language.md`](../motion-language.md): entrances default to `power3.out` or the baked critically-damped spring (see Spring Eases below); overshoot eases (`back` / `elastic` / `bounce`, or an underdamped spring) are a register the brief chooses for playful or physical work, never the house style.
22
22
 
23
23
  ## Easing Vocabulary (character & mood)
24
24
 
@@ -29,15 +29,15 @@ The full palette by character (each family has `.in`, `.out`, `.inOut` variants)
29
29
  | Family | Character | Typical use |
30
30
  | -------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
31
31
  | `power1`–`power4` | Gentle (1) to aggressive (4) acceleration curves | General purpose. **power3 is the house workhorse**; power2 for gentle secondary motion, power4 for dramatic snaps |
32
- | `back(N)` | Overshoot then settle. N controls how far past the target (1=subtle, 4=wild) | RARE — explicitly-playful register only, never a default. Keep N ≤ 2; prefer a baked spring at ζ 0.6–0.7 (physical settle, see Spring Eases) |
33
- | `elastic(amp, freq)` | Spring bounce. amp=magnitude, freq=oscillation speed | RARE — same playful-only rule; the baked spring (below) is the physical version |
34
- | `bounce` | Ball-drop bouncing | RARE — physical-comedy register only (something literally dropping) |
32
+ | `back(N)` | Overshoot then settle. N controls how far past the target (1=subtle, 4=wild) | Chosen register only (playful, physical), never a default. Keep N ≤ 2; prefer a baked spring at ζ 0.6–0.7 (physical settle, see Spring Eases) |
33
+ | `elastic(amp, freq)` | Spring bounce. amp=magnitude, freq=oscillation speed | Same register rule; the baked spring (below) is the physical version |
34
+ | `bounce` | Ball-drop bouncing | Physical-comedy register only (something literally dropping) |
35
35
  | `expo` | Extreme acceleration curve (much steeper than power4) | Premium/luxury reveals, dramatic entrances |
36
36
  | `sine` | Smooth, organic, no hard edges | Ambient float, breathing, Ken Burns, anything that loops. `.inOut` for yoyo motion |
37
37
  | `circ` | Circular acceleration (starts very fast, ends very gentle or vice versa) | Camera moves, scene transitions, orbital motion |
38
38
  | `steps(N)` | Discrete N-step jumps, no interpolation | Typing effects, cursor blink, counter ticks, retro/digital aesthetics |
39
39
 
40
- **Mood mapping:** Match easing character to the beat's emotional content. Smooth/organic easings (`sine`, `power1`) feel contemplative and drifting. Aggressive deceleration (`power4.out`, `expo.out`) feels snappy and confident. Spring overshoot (`back.out`) feels bouncy and physical — but bouncy is a register, not an emphasis tool; reach for it only on explicitly-playful beats. The storyboard's mood description should guide which character fits — not a formula.
40
+ **Mood mapping:** Match easing character to the beat's emotional content. Smooth/organic easings (`sine`, `power1`) feel contemplative and drifting. Aggressive deceleration (`power4.out`, `expo.out`) feels snappy and confident. Spring overshoot (`back.out`) feels bouncy and physical — but bouncy is a register, not an emphasis tool; reach for it only when the brief's register is playful or physical. The register recorded in `frame.md` should guide which character fits — not a formula.
41
41
 
42
42
  ## Defaults
43
43
 
@@ -58,7 +58,7 @@ Setting defaults at timeline scope is preferred — it documents the motion lang
58
58
 
59
59
  ## Spring Eases (baked physics, seek-safe)
60
60
 
61
- The "iOS feel" is a **damped spring's velocity curve**, not a bounce: a fast launch into a long asymptotic settle. Well-made system animations are critically damped or close to it — they barely overshoot, or don't at all. `power3.out` / `expo.out` approximate that curve; when you want the exact one — or a _physical_ overshoot for the rare playful register — bake the spring's closed-form solution into a function ease.
61
+ The "iOS feel" is a **damped spring's velocity curve**, not a bounce: a fast launch into a long asymptotic settle. Well-made system animations are critically damped or close to it — they barely overshoot, or don't at all. `power3.out` / `expo.out` approximate that curve; when you want the exact one — or a _physical_ overshoot for a playful register — bake the spring's closed-form solution into a function ease.
62
62
 
63
63
  Why not a real-time spring library: an interactive spring is a stateful integrator (velocity accumulates frame to frame), which cannot be seeked deterministically — you'd have to simulate frames 0…N−1 to render frame N. The closed form below is a **pure function of progress** — no state, nothing to desync, seek-safe by construction. This is also why interaction-lib spring solvers are banned in compositions.
64
64
 
@@ -67,7 +67,7 @@ Why not a real-time spring library: an interactive spring is a stateful integrat
67
67
  // response ≈ seconds one oscillation would take (0.3–0.6 for entrances)
68
68
  // dampingFraction 1.0 = critically damped — smooth settle, NO overshoot (house default)
69
69
  // 0.80–0.85 ≈ the iOS system register — ~1–1.5% overshoot, felt not seen
70
- // 0.60–0.70 = explicitly playful — ~5–10% overshoot (rare; replaces back.out)
70
+ // 0.60–0.70 = playful register — ~5–10% overshoot (by choice; replaces back.out)
71
71
  function springEase({ response = 0.5, dampingFraction = 1 } = {}) {
72
72
  const w = (2 * Math.PI) / response; // undamped natural frequency
73
73
  const z = dampingFraction;
@@ -120,20 +120,20 @@ tl.fromTo(
120
120
  | ----------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
121
121
  | **1.0 (default)** | none (monotone) | The house settle — the exact curve `power3.out` approximates. Product / enterprise / serious tone. |
122
122
  | 0.80–0.85 | ~1–1.5% | "Alive, not bouncy" — the iOS system default register. The overshoot is felt, not seen. |
123
- | 0.60–0.70 | ~5–10% | Explicitly-playful ONLY (same rule as `back.out`, which this replaces — a spring's second-order settle reads physical where `back` reads cartoon). |
123
+ | 0.60–0.70 | ~5–10% | Playful register, by choice (same rule as `back.out`, which this replaces — a spring's second-order settle reads physical where `back` reads cartoon). |
124
124
  | < 0.55 | > 12% | Don't. Cartoon-wobble territory. |
125
125
 
126
126
  | response | duration (ζ=1) | feel |
127
127
  | --------- | -------------- | ------------------------------------------------------------ |
128
128
  | 0.25–0.35 | 0.37–0.51s | tight snap — chips, small UI |
129
129
  | 0.35–0.50 | 0.51–0.74s | standard entrance |
130
- | 0.50–0.70 | 0.74–1.03s | weighted hero landing — check the `t ≤ 0.5s` visibility rule |
130
+ | 0.50–0.70 | 0.74–1.03s | weighted hero landing — when the shot opens on it, start it early so the subject is seen within about half a second |
131
131
 
132
132
  Craft notes:
133
133
 
134
134
  - **ζ=1 vs `power3.out`**: the true spring front-loads harder (~67% vs ~58% travelled at quarter-time) and settles on a longer asymptotic tail; max shape difference ~11%. That long tail is the "premium" read — use it when the settle IS the shot (a wordmark landing, a final lockup).
135
135
  - **At ζ<1, overshooting curves go on transforms only** — never on `opacity` (it would push past 1) or color. Split opacity onto its own `power2.out` tween at the same timeline position.
136
- - **Doctrine unchanged**: ζ below ~0.8 is still the rare, explicitly-playful exception (`rules/spring-pop-entrance.md`). The default of this section is ζ=1 — real spring physics is not a license for bounce.
136
+ - **Default unchanged**: ζ below ~0.8 is a register the brief chooses, never a default. The default of this section is ζ=1 — real spring physics is not a license for bounce.
137
137
 
138
138
  ## Stagger
139
139
 
@@ -67,3 +67,9 @@ What the runtime actually requires:
67
67
  Everything else in the skeleton is ordinary HTML and CSS: the `#root` box, `.clip` positioning, and fonts are yours to choose.
68
68
 
69
69
  This pattern is **standalone** (top-level `index.html`) — no `<template>` wrapper around the root. For sub-compositions (files loaded by `data-composition-src`), see `sub-compositions.md`.
70
+
71
+ ## Vertical (9:16)
72
+
73
+ For phone-first video, set the viewport meta to `width=1080, height=1920`, give the root `data-width="1080"` and `data-height="1920"`, and render with the matching size (`--resolution portrait`). Everything else stays the same.
74
+
75
+ Size type against the frame's width, not its height: a 96 px headline that suits a 1920 px-wide frame takes over half the width of a 1080 px one, so check that the longest word fits. When the video will play in a feed (Reels, TikTok, Shorts), keep text, logos and key subjects out of the areas the app covers. Meta asks for the top 14%, the bottom 35% and 6% on each side to stay clear (about 270 px, 670 px and 65 px at 1080×1920); TikTok covers a similar band at the bottom and a column of buttons on the right.
@@ -19,9 +19,10 @@ Do not overwrite a reusable kit while editing a video.
19
19
  3. Record the selected kit and bindings in the video's `frame.md`.
20
20
 
21
21
  The kit's colours, fonts and motion personality feed the plan: they replace
22
- the motion-language defaults. Required identity outranks taste; if it conflicts
23
- with a requested treatment, resolve that actual choice with the user instead of
24
- silently changing the font, logo or colours.
22
+ the motion-language defaults and set its palette, type and register. Required
23
+ identity outranks taste; if it conflicts with a requested treatment, resolve
24
+ that actual choice with the user instead of silently changing the font, logo or
25
+ colours.
25
26
 
26
27
  If the source was already captured and the kit approved, reuse it. Do not
27
28
  repeat a brand interview, capture, audit or approval for a new headline.