@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.
- package/assets/motion/bin/flash-check.mjs +314 -0
- package/assets/motion/references/motion-language.md +75 -37
- package/assets/motion/references/runtime/gsap-easing-and-stagger.md +19 -19
- package/assets/motion/references/runtime/minimal-composition.md +6 -0
- package/assets/motion/skills/brand-kit/SKILL.md +4 -3
- package/assets/motion/skills/motion/SKILL.md +91 -2
- package/assets/motion/skills/video-qa/SKILL.md +29 -8
- package/dist/core/agent-session.js +8 -1
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/autopilot-verdict.d.ts +5 -1
- package/dist/core/autopilot-verdict.js +30 -13
- package/dist/core/autopilot-verdict.js.map +1 -1
- package/dist/core/ken-context.d.ts +7 -5
- package/dist/core/ken-context.js +106 -16
- package/dist/core/ken-context.js.map +1 -1
- package/dist/core/ken-prompt.js +24 -21
- package/dist/core/ken-prompt.js.map +1 -1
- package/dist/motion-agent/motion-check-tool.js +67 -3
- package/dist/motion-agent/motion-check-tool.js.map +1 -1
- package/dist/motion-agent/motion-prompt.d.ts +1 -1
- package/dist/motion-agent/motion-prompt.js +4 -4
- package/dist/motion-agent/motion-prompt.js.map +1 -1
- package/package.json +4 -4
|
@@ -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.**
|
|
35
|
-
|
|
36
|
-
the
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
and
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
74
|
-
|
|
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
|
|
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.
|
|
15
|
-
| `elastic.out(1, 0.3)` | Springy bounce. Same
|
|
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
|
|
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) |
|
|
33
|
-
| `elastic(amp, freq)` | Spring bounce. amp=magnitude, freq=oscillation speed |
|
|
34
|
-
| `bounce` | Ball-drop bouncing |
|
|
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
|
|
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
|
|
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 =
|
|
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% |
|
|
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 —
|
|
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
|
-
- **
|
|
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
|
|
23
|
-
|
|
24
|
-
silently changing the font, logo or
|
|
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.
|