@miraiclip/assistant 0.2.0 → 0.3.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/dist/index.cjs CHANGED
@@ -24,30 +24,39 @@ __export(src_exports, {
24
24
  BACKGROUND_TEMPLATE: () => BACKGROUND_TEMPLATE,
25
25
  ChatModelError: () => ChatModelError,
26
26
  DEFAULT_INSTRUCTIONS: () => DEFAULT_INSTRUCTIONS,
27
+ MIRAICLIP_SKILLS: () => MIRAICLIP_SKILLS,
27
28
  USER_KEY_HEADERS: () => USER_KEY_HEADERS,
28
29
  aiProvider: () => aiProvider,
29
30
  anthropicChatModel: () => anthropicChatModel,
30
31
  applyChecked: () => applyChecked,
31
32
  applyCommandsSchema: () => applyCommandsSchema,
33
+ assetRefs: () => assetRefs,
32
34
  chatModelsFromEnv: () => chatModelsFromEnv,
33
35
  commitFork: () => commitFork,
34
36
  createAssistant: () => createAssistant,
35
37
  createChatHandler: () => createChatHandler,
36
38
  createChatModel: () => createChatModel,
37
39
  defineChatModel: () => defineChatModel,
40
+ defineSkill: () => defineSkill,
38
41
  defineTool: () => defineTool,
39
42
  describeCommands: () => describeCommands,
40
43
  describeForAssistant: () => describeForAssistant,
44
+ describeSkills: () => describeSkills,
41
45
  editorTools: () => editorTools,
42
46
  forkProject: () => forkProject,
43
47
  geminiChatModel: () => geminiChatModel,
48
+ markupProblem: () => markupProblem,
44
49
  openAIChatModel: () => openAIChatModel,
50
+ parseFrontMatter: () => parseFrontMatter,
51
+ parseSkill: () => parseSkill,
45
52
  parseToolArguments: () => parseToolArguments,
46
53
  rateLimitedChatModel: () => rateLimitedChatModel,
47
54
  remoteChatModel: () => remoteChatModel,
48
55
  retryDelayMs: () => retryDelayMs,
49
56
  routedChatModel: () => routedChatModel,
50
57
  scriptedChatModel: () => scriptedChatModel,
58
+ skillTools: () => skillTools,
59
+ skillsFromFiles: () => skillsFromFiles,
51
60
  splitModelRef: () => splitModelRef,
52
61
  toAnthropicMessages: () => toAnthropicMessages,
53
62
  toGeminiContents: () => toGeminiContents,
@@ -161,7 +170,25 @@ function modelList(model, others = []) {
161
170
 
162
171
  // src/openai.ts
163
172
  function toOpenAIMessages(messages) {
164
- return messages.map((m) => {
173
+ const out = [];
174
+ let pending = [];
175
+ const flush = () => {
176
+ if (!pending.length) return;
177
+ out.push({ role: "user", content: [{ type: "text", text: "Images returned by the tool calls above:" }, ...pending] });
178
+ pending = [];
179
+ };
180
+ for (const m of messages) {
181
+ if (m.role !== "tool") flush();
182
+ out.push(one(m));
183
+ if (m.role === "tool" && m.images?.length) {
184
+ pending.push({ type: "text", text: `From ${m.name} (${m.toolCallId}):` }, ...m.images.map((i) => ({ type: "image_url", image_url: { url: `data:${i.mimeType};base64,${i.data}` } })));
185
+ }
186
+ }
187
+ flush();
188
+ return out;
189
+ }
190
+ function one(m) {
191
+ {
165
192
  if (m.role === "assistant") {
166
193
  const out = { role: "assistant", content: m.content || null };
167
194
  if (m.toolCalls?.length) {
@@ -171,7 +198,7 @@ function toOpenAIMessages(messages) {
171
198
  }
172
199
  if (m.role === "tool") return { role: "tool", tool_call_id: m.toolCallId, content: m.content };
173
200
  return { role: m.role, content: m.content };
174
- });
201
+ }
175
202
  }
176
203
  var FINISH = {
177
204
  stop: "stop",
@@ -181,7 +208,7 @@ var FINISH = {
181
208
  content_filter: "content_filter"
182
209
  };
183
210
  function openAIChatModel(options) {
184
- const base = trimSlash(options.baseUrl ?? "https://api.openai.com/v1");
211
+ const base2 = trimSlash(options.baseUrl ?? "https://api.openai.com/v1");
185
212
  const id = options.id ?? "openai";
186
213
  const doFetch = options.fetch ?? defaultFetch();
187
214
  const stream = options.stream ?? true;
@@ -199,7 +226,7 @@ function openAIChatModel(options) {
199
226
  label: options.label ?? "OpenAI",
200
227
  models,
201
228
  async listModels(opts = {}) {
202
- const json2 = await getJson(id, doFetch, `${base}/models`, headers(), opts.signal);
229
+ const json2 = await getJson(id, doFetch, `${base2}/models`, headers(), opts.signal);
203
230
  return (json2.data ?? []).map((m) => ({ id: m.id })).sort((a, b) => a.id.localeCompare(b.id));
204
231
  },
205
232
  async complete(request, opts = {}) {
@@ -215,7 +242,7 @@ function openAIChatModel(options) {
215
242
  const res = await sendWithRetry(
216
243
  id,
217
244
  doFetch,
218
- `${base}/chat/completions`,
245
+ `${base2}/chat/completions`,
219
246
  { method: "POST", headers: headers(), body: JSON.stringify(body) },
220
247
  { ...options.maxRetries !== void 0 ? { maxRetries: options.maxRetries } : {}, ...opts.signal ? { signal: opts.signal } : {} }
221
248
  );
@@ -249,15 +276,15 @@ async function readStream(body, onText) {
249
276
  const calls = /* @__PURE__ */ new Map();
250
277
  const handle = (data) => {
251
278
  if (data === "[DONE]") return;
252
- let chunk;
279
+ let chunk2;
253
280
  try {
254
- chunk = JSON.parse(data);
281
+ chunk2 = JSON.parse(data);
255
282
  } catch {
256
283
  return;
257
284
  }
258
- if (chunk.model) model = chunk.model;
259
- if (chunk.usage) use = usage(chunk.usage);
260
- for (const choice of chunk.choices ?? []) {
285
+ if (chunk2.model) model = chunk2.model;
286
+ if (chunk2.usage) use = usage(chunk2.usage);
287
+ for (const choice of chunk2.choices ?? []) {
261
288
  const delta = choice.delta;
262
289
  if (delta?.content) {
263
290
  content += delta.content;
@@ -294,8 +321,10 @@ function toAnthropicMessages(messages) {
294
321
  for (const m of messages) {
295
322
  if (m.role === "system") system.push(m.content);
296
323
  else if (m.role === "user") push("user", m.content.trim() ? [{ type: "text", text: m.content }] : []);
297
- else if (m.role === "tool") push("user", [{ type: "tool_result", tool_use_id: m.toolCallId, content: m.content }]);
298
- else {
324
+ else if (m.role === "tool") {
325
+ const content = m.images?.length ? [{ type: "text", text: m.content }, ...m.images.map((i) => ({ type: "image", source: { type: "base64", media_type: i.mimeType, data: i.data } }))] : m.content;
326
+ push("user", [{ type: "tool_result", tool_use_id: m.toolCallId, content }]);
327
+ } else {
299
328
  const thinking = m.toolCalls?.[0]?.meta?.anthropicThinking ?? [];
300
329
  push("assistant", [
301
330
  ...thinking,
@@ -334,7 +363,7 @@ function fromBlocks(blocks, stopReason, model, usage2) {
334
363
  return out;
335
364
  }
336
365
  function anthropicChatModel(options) {
337
- const base = trimSlash(options.baseUrl ?? "https://api.anthropic.com");
366
+ const base2 = trimSlash(options.baseUrl ?? "https://api.anthropic.com");
338
367
  const id = options.id ?? "anthropic";
339
368
  const doFetch = options.fetch ?? defaultFetch();
340
369
  const stream = options.stream ?? true;
@@ -351,7 +380,7 @@ function anthropicChatModel(options) {
351
380
  label: options.label ?? "Anthropic",
352
381
  models,
353
382
  async listModels(opts = {}) {
354
- const json2 = await getJson(id, doFetch, `${base}/v1/models?limit=1000`, headers(), opts.signal);
383
+ const json2 = await getJson(id, doFetch, `${base2}/v1/models?limit=1000`, headers(), opts.signal);
355
384
  return (json2.data ?? []).map((m) => ({ id: m.id, ...m.display_name ? { label: m.display_name } : {} }));
356
385
  },
357
386
  async complete(request, opts = {}) {
@@ -369,7 +398,7 @@ function anthropicChatModel(options) {
369
398
  const res = await sendWithRetry(
370
399
  id,
371
400
  doFetch,
372
- `${base}/v1/messages`,
401
+ `${base2}/v1/messages`,
373
402
  { method: "POST", headers: headers(), body: JSON.stringify(body) },
374
403
  { ...options.maxRetries !== void 0 ? { maxRetries: options.maxRetries } : {}, ...opts.signal ? { signal: opts.signal } : {} }
375
404
  );
@@ -470,12 +499,19 @@ function toGeminiContents(messages) {
470
499
  if (last && last.role === role) last.parts.push(...parts);
471
500
  else contents.push({ role, parts });
472
501
  };
502
+ let pending = [];
503
+ const flush = () => {
504
+ if (pending.length) push("user", [{ text: "Images returned by the tool calls above:" }, ...pending]);
505
+ pending = [];
506
+ };
473
507
  for (const m of messages) {
508
+ if (m.role !== "tool") flush();
474
509
  if (m.role === "system") system.push(m.content);
475
510
  else if (m.role === "user") push("user", m.content ? [{ text: m.content }] : []);
476
511
  else if (m.role === "tool") {
477
512
  const id = callIds.get(m.toolCallId);
478
513
  push("user", [{ functionResponse: { ...id ? { id } : {}, name: m.name, response: responseObject(m.content) } }]);
514
+ if (m.images?.length) pending.push({ text: `From ${m.name}:` }, ...m.images.map((i) => ({ inlineData: { mimeType: i.mimeType, data: i.data } })));
479
515
  } else {
480
516
  const calls = m.toolCalls ?? [];
481
517
  const signed = calls.some((c) => metaOf(c).signature);
@@ -490,6 +526,7 @@ function toGeminiContents(messages) {
490
526
  ]);
491
527
  }
492
528
  }
529
+ flush();
493
530
  return { system: system.join("\n\n"), contents };
494
531
  }
495
532
  var FINISH3 = {
@@ -547,7 +584,7 @@ function collector(onText) {
547
584
  }
548
585
  var bareModel = (model) => model.replace(/^models\//, "");
549
586
  function geminiChatModel(options) {
550
- const base = trimSlash(options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta");
587
+ const base2 = trimSlash(options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta");
551
588
  const id = options.id ?? "gemini";
552
589
  const doFetch = options.fetch ?? defaultFetch();
553
590
  const stream = options.stream ?? true;
@@ -562,7 +599,7 @@ function geminiChatModel(options) {
562
599
  label: options.label ?? "Google Gemini",
563
600
  models,
564
601
  async listModels(opts = {}) {
565
- const json2 = await getJson(id, doFetch, `${base}/models?pageSize=1000`, headers(), opts.signal);
602
+ const json2 = await getJson(id, doFetch, `${base2}/models?pageSize=1000`, headers(), opts.signal);
566
603
  return (json2.models ?? []).filter((m) => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes("generateContent")).map((m) => ({ id: bareModel(m.name), ...m.displayName ? { label: m.displayName } : {} }));
567
604
  },
568
605
  async complete(request, opts = {}) {
@@ -581,7 +618,7 @@ function geminiChatModel(options) {
581
618
  ...Object.keys(generationConfig).length ? { generationConfig } : {}
582
619
  };
583
620
  const model = bareModel(request.model ?? options.model);
584
- const url = `${base}/models/${encodeURIComponent(model)}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
621
+ const url = `${base2}/models/${encodeURIComponent(model)}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
585
622
  const res = await sendWithRetry(
586
623
  id,
587
624
  doFetch,
@@ -821,7 +858,7 @@ function routedChatModel(models, options = {}) {
821
858
  var USER_KEY_HEADERS = { provider: "x-ai-provider", key: "x-ai-key", baseUrl: "x-ai-base-url" };
822
859
  var hostedProviders = () => AI_PROVIDERS.filter((p) => !p.local && p.baseUrl).map((p) => p.id);
823
860
  function createChatHandler(model, options = {}) {
824
- const base = (options.basePath ?? "").replace(/\/+$/, "");
861
+ const base2 = (options.basePath ?? "").replace(/\/+$/, "");
825
862
  const maxBody = options.maxBodyBytes ?? 2e6;
826
863
  const byok = options.userKeys ? options.userKeys === true ? {} : options.userKeys : null;
827
864
  if (!model && !byok) throw new Error("createChatHandler needs a model, or userKeys so users can bring their own");
@@ -862,8 +899,8 @@ function createChatHandler(model, options = {}) {
862
899
  const noModel = () => json(401, { error: "Add your own API key to use AI here.", needsKey: true });
863
900
  return async function handle(request) {
864
901
  const path = new URL(request.url).pathname;
865
- if (base && path !== base && !path.startsWith(`${base}/`)) return null;
866
- const rest = path.slice(base.length).replace(/\/+$/, "");
902
+ if (base2 && path !== base2 && !path.startsWith(`${base2}/`)) return null;
903
+ const rest = path.slice(base2.length).replace(/\/+$/, "");
867
904
  const refused = await options.authorize?.(request);
868
905
  if (refused) return refused;
869
906
  if (rest === "" && request.method === "GET") {
@@ -1150,6 +1187,129 @@ function unknownFields(schema, payload) {
1150
1187
  return best && best.unknown.length ? best : null;
1151
1188
  }
1152
1189
 
1190
+ // src/markup.ts
1191
+ var VOID = /* @__PURE__ */ new Set(["br", "img", "hr", "input", "meta", "link", "source", "wbr", "col", "area", "base", "embed", "track", "param"]);
1192
+ var NAMED = /* @__PURE__ */ new Set(["amp", "lt", "gt", "quot", "apos"]);
1193
+ var HTML_ENTITY_HINTS = { nbsp: " ", copy: "©", reg: "®", trade: "™", mdash: "—", ndash: "–", hellip: "…", rarr: "→", larr: "←", times: "×", middot: "·", bull: "•", rsquo: "’", lsquo: "‘", ldquo: "“", rdquo: "”" };
1194
+ function markupProblem(markup) {
1195
+ const stack = [];
1196
+ const n2 = markup.length;
1197
+ let i = 0;
1198
+ const near = (at) => JSON.stringify(markup.slice(Math.max(0, at - 30), at + 30));
1199
+ const entity = (at) => {
1200
+ const m = /^&(#\d+|#x[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);/.exec(markup.slice(at, at + 40));
1201
+ if (!m) return `a bare "&" near ${near(at)}: write &`;
1202
+ const name = m[1];
1203
+ if (name.startsWith("#") || NAMED.has(name)) return void 0;
1204
+ const hint = HTML_ENTITY_HINTS[name];
1205
+ return `"&${name};" is an HTML entity, which this XML markup doesn't know: ${hint ? `write ${hint}` : "use a numeric reference (&#NNNN;) or the character itself"}`;
1206
+ };
1207
+ while (i < n2) {
1208
+ const c = markup[i];
1209
+ if (c === "&") {
1210
+ const problem = entity(i);
1211
+ if (problem) return problem;
1212
+ i = markup.indexOf(";", i) + 1;
1213
+ continue;
1214
+ }
1215
+ if (c !== "<") {
1216
+ i++;
1217
+ continue;
1218
+ }
1219
+ if (markup.startsWith("<!--", i)) {
1220
+ const end = markup.indexOf("-->", i + 4);
1221
+ if (end < 0) return `a comment is never closed near ${near(i)}`;
1222
+ i = end + 3;
1223
+ continue;
1224
+ }
1225
+ if (markup.startsWith("<![CDATA[", i)) {
1226
+ const end = markup.indexOf("]]>", i);
1227
+ if (end < 0) return `a CDATA section is never closed near ${near(i)}`;
1228
+ i = end + 3;
1229
+ continue;
1230
+ }
1231
+ if (markup.startsWith("<?", i)) {
1232
+ const end = markup.indexOf("?>", i);
1233
+ if (end < 0) return `"<?" is never closed near ${near(i)}`;
1234
+ i = end + 2;
1235
+ continue;
1236
+ }
1237
+ if (markup[i + 1] === "!") return `remove "<!\u2026" declarations (no DOCTYPE) near ${near(i)}`;
1238
+ if (markup[i + 1] === "/") {
1239
+ const m = /^<\/([A-Za-z_][\w.:-]*)\s*>/.exec(markup.slice(i, i + 200));
1240
+ if (!m) return `a broken closing tag near ${near(i)}`;
1241
+ const name2 = m[1];
1242
+ const open2 = stack.pop();
1243
+ if (!open2) return `"</${name2}>" closes nothing near ${near(i)}`;
1244
+ if (open2.name !== name2) return unclosed(open2, `before "</${name2}>"`);
1245
+ i += m[0].length;
1246
+ continue;
1247
+ }
1248
+ const tag = /^<([A-Za-z_][\w.:-]*)/.exec(markup.slice(i, i + 200));
1249
+ if (!tag) return `a bare "<" near ${near(i)}: write &lt; (also inside CSS, e.g. '&lt;integer&gt;')`;
1250
+ const name = tag[1];
1251
+ const start = i;
1252
+ i += tag[0].length;
1253
+ const seen = /* @__PURE__ */ new Set();
1254
+ for (; ; ) {
1255
+ while (i < n2 && /\s/.test(markup[i])) i++;
1256
+ if (i >= n2) return `the <${name}> tag is never finished near ${near(start)}`;
1257
+ if (markup.startsWith("/>", i)) {
1258
+ i += 2;
1259
+ break;
1260
+ }
1261
+ if (markup[i] === ">") {
1262
+ i++;
1263
+ if (name.toLowerCase() === "style") {
1264
+ const close = markup.slice(i).search(/<\/style\s*>/i);
1265
+ const body = close < 0 ? markup.slice(i) : markup.slice(i, i + close);
1266
+ const lt2 = body.indexOf("<");
1267
+ if (lt2 >= 0) return `a bare "<" in the <style> near ${near(i + lt2)}: in CSS write &lt; and &gt; (e.g. '&lt;integer&gt;')`;
1268
+ for (let a = body.indexOf("&"); a >= 0; a = body.indexOf("&", a + 1)) {
1269
+ const problem = entity(i + a);
1270
+ if (problem) return problem;
1271
+ }
1272
+ if (close < 0) return unclosed({ name, at: start }, "at the end");
1273
+ i += close;
1274
+ }
1275
+ stack.push({ name, at: start });
1276
+ break;
1277
+ }
1278
+ const attr = /^([A-Za-z_:][\w.:-]*)/.exec(markup.slice(i, i + 200));
1279
+ if (!attr) return `unexpected "${markup[i]}" inside the <${name}> tag near ${near(i)}`;
1280
+ const attrName = attr[1];
1281
+ if (seen.has(attrName)) return `the <${name}> tag has "${attrName}" twice near ${near(start)}`;
1282
+ seen.add(attrName);
1283
+ i += attrName.length;
1284
+ while (i < n2 && /\s/.test(markup[i])) i++;
1285
+ if (markup[i] !== "=") return `attribute "${attrName}" on <${name}> needs a quoted value (${attrName}="\u2026") near ${near(start)}`;
1286
+ i++;
1287
+ while (i < n2 && /\s/.test(markup[i])) i++;
1288
+ const q = markup[i];
1289
+ if (q !== '"' && q !== "'") return `quote the value of "${attrName}" on <${name}> near ${near(i)}`;
1290
+ const end = markup.indexOf(q, i + 1);
1291
+ if (end < 0) return `the value of "${attrName}" on <${name}> is never closed near ${near(i)}`;
1292
+ const value = markup.slice(i + 1, end);
1293
+ const lt = value.indexOf("<");
1294
+ if (lt >= 0) return `"<" inside the "${attrName}" value on <${name}>: write &lt; near ${near(i + 1 + lt)}`;
1295
+ for (let a = value.indexOf("&"); a >= 0; a = value.indexOf("&", a + 1)) {
1296
+ const problem = entity(i + 1 + a);
1297
+ if (problem) return problem;
1298
+ }
1299
+ i = end + 1;
1300
+ }
1301
+ }
1302
+ const open = stack.pop();
1303
+ return open ? unclosed(open, "at the end") : void 0;
1304
+ function unclosed(open2, where) {
1305
+ const fix = VOID.has(open2.name.toLowerCase()) ? `write <${open2.name} \u2026/>` : `close it with </${open2.name}>`;
1306
+ return `<${open2.name}> is never closed (${where}) near ${near(open2.at)}: ${fix}`;
1307
+ }
1308
+ }
1309
+ function assetRefs(markup) {
1310
+ return [...new Set([...markup.matchAll(/asset:([\w-]+)/g)].map((m) => m[1]))];
1311
+ }
1312
+
1153
1313
  // src/tools.ts
1154
1314
  var import_core2 = require("@miraiclip/core");
1155
1315
  function defineTool(tool) {
@@ -1224,7 +1384,116 @@ var US_NOTE = "Times in commands are integer MICROSECONDS (1 s = 1000000).";
1224
1384
  function editorTools(options = {}) {
1225
1385
  const transitionKinds = options.transitionKinds ?? Object.keys(import_core2.builtinTransitionParamSchemas);
1226
1386
  const presetIds = (slot) => import_core2.ANIMATION_PRESETS.filter((p) => p.slot === slot).map((p) => p.id.split(":")[1]);
1387
+ const checkTools = options.checkSequence ? [
1388
+ defineTool({
1389
+ name: "check_sequence",
1390
+ description: 'Check a stage sequence (data-libs="stage") as one continuous shot: for every label it reports whether something survives into the next beat (carried / anchored / bare), subjects that leave the frame (data-keep-in-frame), uneven or metronomic beat spacing and stray hard cuts, as warnings with times and a hint each. Run it after composing or editing a sequence and fix the bare boundaries; warnings never block an export.',
1391
+ inputSchema: {
1392
+ type: "object",
1393
+ properties: { clipId: { type: "string", description: "Check only this html clip (default: every html clip)." } },
1394
+ additionalProperties: false
1395
+ },
1396
+ async run(input, { project }) {
1397
+ try {
1398
+ const result = await options.checkSequence(project.toJSON(), typeof input.clipId === "string" ? { clipId: input.clipId } : {});
1399
+ return { ok: true, result };
1400
+ } catch (error) {
1401
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1402
+ }
1403
+ }
1404
+ }),
1405
+ defineTool({
1406
+ name: "place_sfx_from_events",
1407
+ description: "Put sound effects on a continuous shot's hits. Reads the sound moments the sequence declared (tl.hit, presses, impacts, shakes, carry landings), adds one audio clip at each (one sound per hit kind, so the film keeps one material set) and optionally ducks a music clip under them. Add the sounds as audio assets first (prefer CC0; keep to one material). Audio clips have no pan yet, so where on screen a hit happened is reported but not applied. " + US_NOTE,
1408
+ inputSchema: {
1409
+ type: "object",
1410
+ properties: {
1411
+ clipId: { type: "string", description: "The html sequence clip." },
1412
+ sounds: { type: "object", description: 'Audio asset id per hit kind, e.g. { "impact": "thud", "click": "tick" }. Kinds: impact, click, whoosh, land, shake.', additionalProperties: { type: "string" } },
1413
+ trackId: { type: "string", description: 'Audio track for the effects (default "sfx", created if missing).' },
1414
+ musicClipId: { type: "string", description: "A music audio clip to lower under the hits." },
1415
+ volume: { type: "number", minimum: 0, maximum: 4 },
1416
+ duckTo: { type: "number", minimum: 0, maximum: 1, description: "Music level during a hit, as a fraction of its own (default 0.35)." }
1417
+ },
1418
+ required: ["clipId", "sounds"],
1419
+ additionalProperties: false
1420
+ },
1421
+ async run(input, { project }) {
1422
+ const doc = project.toJSON();
1423
+ const clipId = String(input.clipId ?? "");
1424
+ const seq2 = doc.clips[clipId];
1425
+ if (!seq2 || seq2.kind !== "html") return { ok: false, error: `"${clipId}" is not an html clip` };
1426
+ let report;
1427
+ try {
1428
+ const checked = await options.checkSequence(doc, { clipId });
1429
+ const entry = checked.clips?.find((c) => c.clipId === clipId);
1430
+ report = entry?.report;
1431
+ if (!report) return { ok: false, error: entry?.skipped ?? "the sequence could not be checked" };
1432
+ } catch (error) {
1433
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1434
+ }
1435
+ const sounds = {};
1436
+ for (const [kind, assetId] of Object.entries(input.sounds ?? {})) {
1437
+ const asset = doc.assets[assetId];
1438
+ if (!asset || asset.kind !== "audio") return { ok: false, error: `sounds.${kind}: "${assetId}" is not an audio asset` };
1439
+ sounds[kind] = { assetId, durationUs: asset.durationUs ?? 1e6 };
1440
+ }
1441
+ const music = input.musicClipId !== void 0 ? doc.clips[String(input.musicClipId)] : void 0;
1442
+ if (input.musicClipId !== void 0 && (!music || music.kind !== "audio")) return { ok: false, error: `"${String(input.musicClipId)}" is not an audio clip` };
1443
+ const trackId = typeof input.trackId === "string" ? input.trackId : "sfx";
1444
+ const plan = (0, import_core2.planSoundFromEvents)({
1445
+ events: report.events ?? [],
1446
+ sequenceStartUs: seq2.startUs,
1447
+ sounds,
1448
+ trackId,
1449
+ existingClipIds: Object.keys(doc.clips),
1450
+ ...typeof input.volume === "number" ? { volume: input.volume } : {},
1451
+ ...typeof input.duckTo === "number" ? { duckTo: input.duckTo } : {},
1452
+ ...music ? { music: { clipId: music.id, startUs: music.startUs, durationUs: music.durationUs, volume: music.volume } } : {}
1453
+ });
1454
+ if (!plan.placements.length) return { ok: true, result: { placed: 0, skipped: plan.skipped, note: "nothing placed: the sequence has no hits with a sound; add tl.hit calls or more kinds in sounds" } };
1455
+ const commands = [...doc.tracks[trackId] ? [] : [{ type: "track/add", payload: { id: trackId, kind: "audio", name: "SFX" } }], ...plan.commands];
1456
+ const out = applyChecked(project, commands);
1457
+ if (!out.ok) return out;
1458
+ return { ...out, result: { placed: plan.placements.length, placements: plan.placements, skipped: plan.skipped, ducks: plan.ducks.length }, changes: [`Placed ${plan.placements.length} sound effects on ${trackId}${plan.ducks.length ? ` and ducked the music under ${plan.ducks.length} hit${plan.ducks.length === 1 ? "" : "s"}` : ""}`] };
1459
+ }
1460
+ })
1461
+ ] : [];
1462
+ const beatTool = defineTool({
1463
+ name: "set_beat_grid",
1464
+ description: "Write a music beat grid onto a sequence so S.beat(n) and S.downbeat(n) land on the music. Pass beats and downbeats in seconds from the start of the music, and the music clip so the offset between music and sequence is applied. " + US_NOTE,
1465
+ inputSchema: {
1466
+ type: "object",
1467
+ properties: {
1468
+ clipId: { type: "string", description: "The html sequence clip." },
1469
+ beats: { type: "array", items: { type: "number" } },
1470
+ downbeats: { type: "array", items: { type: "number" } },
1471
+ musicClipId: { type: "string", description: "The music audio clip the grid belongs to." },
1472
+ offsetSeconds: { type: "number", description: "Instead of musicClipId: how far into the music the sequence starts." }
1473
+ },
1474
+ required: ["clipId", "beats"],
1475
+ additionalProperties: false
1476
+ },
1477
+ run(input, { project }) {
1478
+ const doc = project.toJSON();
1479
+ const clipId = String(input.clipId ?? "");
1480
+ const seq2 = doc.clips[clipId];
1481
+ if (!seq2 || seq2.kind !== "html") return { ok: false, error: `"${clipId}" is not an html clip` };
1482
+ let offset = typeof input.offsetSeconds === "number" ? input.offsetSeconds : 0;
1483
+ if (input.musicClipId !== void 0) {
1484
+ const music = doc.clips[String(input.musicClipId)];
1485
+ if (!music || music.kind !== "audio") return { ok: false, error: `"${String(input.musicClipId)}" is not an audio clip` };
1486
+ offset = (seq2.startUs - music.startUs + music.trimStartUs) / 1e6;
1487
+ }
1488
+ const grid = (0, import_core2.applyBeatGrid)(seq2.template, { beats: input.beats ?? [], ...Array.isArray(input.downbeats) ? { downbeats: input.downbeats } : {} }, offset);
1489
+ if (!grid.found) return { ok: false, error: 'the template has no stage root (data-libs="stage")' };
1490
+ const out = applyChecked(project, [{ type: "clip/set-property", payload: { clipId, template: grid.template } }]);
1491
+ if (!out.ok) return out;
1492
+ return { ...out, result: { beats: grid.beats, downbeats: grid.downbeats, offsetSeconds: offset }, changes: [`Set a beat grid on ${clipId}: ${grid.beats} beats, ${grid.downbeats} downbeats`] };
1493
+ }
1494
+ });
1227
1495
  return [
1496
+ ...checkTools,
1228
1497
  defineTool({
1229
1498
  name: "get_state",
1230
1499
  description: `The project as it is now (after your edits so far): settings, assets, tracks with clips (ids, kinds, time ranges), transitions, and each clip's position, size, opacity, text style, volume, effects and animation. ${US_NOTE}`,
@@ -1265,7 +1534,7 @@ function editorTools(options = {}) {
1265
1534
  }),
1266
1535
  defineTool({
1267
1536
  name: "add_transition",
1268
- description: 'Add (or replace, or remove) a transition on cuts \u2014 where one clip ends exactly where the next starts on the same video track. Pick the cut by its two clip ids, by a time near it, or all cuts. A transition overlaps both clips around the cut, so its length is capped by the spare footage there; cuts with none are skipped and reported. When a video clip directly follows another on its track, "fade into / dissolve to / slide in / wipe to" that clip means a transition on the cut before it, not an animation. Slide / wipe direction is the direction of motion: "from the left" = right.',
1537
+ description: 'Add (or replace, or remove) a transition on cuts \u2014 where one clip ends exactly where the next starts on the same video track. Pick the cut by its two clip ids, by a time near it, or all cuts. A transition overlaps both clips around the cut, so its length is capped by the spare footage there; cuts with none are skipped and reported. zoomThrough is the exception: it dives into a region of the outgoing clip and arrives in the next one, holding the first/last frames, so it needs no spare footage (length capped by the shorter clip). When a video clip directly follows another on its track, "fade into / dissolve to / slide in / wipe to" that clip means a transition on the cut before it, not an animation. Slide / wipe direction is the direction of motion: "from the left" = right.',
1269
1538
  inputSchema: {
1270
1539
  type: "object",
1271
1540
  properties: {
@@ -1275,7 +1544,15 @@ function editorTools(options = {}) {
1275
1544
  nearSeconds: { type: "number", description: "Pick the cut closest to this time instead." },
1276
1545
  allCuts: { type: "boolean", description: "Apply to every cut." },
1277
1546
  durationSeconds: { type: "number", minimum: 0.1, maximum: 3, description: "Default 0.6." },
1278
- direction: { type: "string", enum: ["left", "right", "up", "down"], description: "wipe / slide only." }
1547
+ direction: { type: "string", enum: ["left", "right", "up", "down"], description: "wipe / slide only." },
1548
+ rect: {
1549
+ type: "object",
1550
+ description: "zoomThrough only: the region to dive into, as fractions of the frame (default the centre quarter).",
1551
+ properties: { x: { type: "number" }, y: { type: "number" }, w: { type: "number" }, h: { type: "number" } },
1552
+ required: ["x", "y", "w", "h"],
1553
+ additionalProperties: false
1554
+ },
1555
+ ease: { type: "string", enum: ["linear", "in", "out", "inOut"], description: "zoomThrough only (default inOut)." }
1279
1556
  },
1280
1557
  required: ["kind"],
1281
1558
  additionalProperties: false
@@ -1311,13 +1588,13 @@ function editorTools(options = {}) {
1311
1588
  if (cut.transition) changes.push(`Removed the transition at ${seconds(cut.atUs)}`);
1312
1589
  continue;
1313
1590
  }
1314
- const durationUs = Math.min(wanted, cut.maxTransitionUs);
1591
+ const durationUs = Math.min(wanted, (0, import_core2.transitionHoldsFrames)(kind) ? cut.maxHoldTransitionUs : cut.maxTransitionUs);
1315
1592
  if (durationUs < 1e5) {
1316
1593
  skipped.push({ ...cutInfo(cut), reason: `no spare footage (${cut.short === "from" ? "the first clip ends where its media ends" : cut.short === "to" ? "the second clip starts at its media's start" : "both clips use all their media"})` });
1317
1594
  if (cut.transition) commands.pop();
1318
1595
  continue;
1319
1596
  }
1320
- const params = input.direction && (kind === "wipe" || kind === "slide") ? { direction: input.direction } : void 0;
1597
+ const params = input.direction && (kind === "wipe" || kind === "slide") ? { direction: input.direction } : kind === "zoomThrough" && (input.rect || input.ease) ? { ...input.rect ? { rect: input.rect } : {}, ...input.ease ? { ease: input.ease } : {} } : void 0;
1321
1598
  commands.push({ type: "transition/add", payload: { kind, fromClipId: cut.fromClipId, toClipId: cut.toClipId, durationUs, ...params ? { params } : {} } });
1322
1599
  changes.push(`${cut.transition ? "Replaced the transition with" : "Added"} ${kind} at ${seconds(cut.atUs)} (${seconds(durationUs)})`);
1323
1600
  }
@@ -1658,7 +1935,8 @@ function editorTools(options = {}) {
1658
1935
  const out = applyChecked(project, commands);
1659
1936
  return out.ok ? { ...out, changes: [`Closed ${commands.length} gap${commands.length > 1 ? "s" : ""} on ${input.trackId}`] } : out;
1660
1937
  }
1661
- })
1938
+ }),
1939
+ beatTool
1662
1940
  ];
1663
1941
  }
1664
1942
  var Abort = class extends Error {
@@ -1667,6 +1945,7 @@ function applyChecked(project, commands) {
1667
1945
  const catalog = project.commandCatalog();
1668
1946
  const nothing = commands.length > 1 ? "Nothing in this batch was applied (not even the commands before it): resend the whole corrected batch." : "Nothing was applied.";
1669
1947
  const templates = /* @__PURE__ */ new Map();
1948
+ const batchAssets = new Set(commands.flatMap((c) => c?.type === "asset/add" && typeof c.payload?.id === "string" ? [c.payload.id] : []));
1670
1949
  for (const [index, command] of commands.entries()) {
1671
1950
  if (!catalog[command?.type]) {
1672
1951
  const looksLikeTool = /^[a-z]+(_[a-z]+)+$/.test(String(command?.type));
@@ -1681,7 +1960,7 @@ function applyChecked(project, commands) {
1681
1960
  }
1682
1961
  };
1683
1962
  }
1684
- const htmlError = checkHtmlParams(project, command, templates);
1963
+ const htmlError = checkHtmlMarkup(project, command, batchAssets) ?? checkHtmlParams(project, command, templates);
1685
1964
  if (htmlError) return { ok: false, error: { failedIndex: index, commandType: command.type, error: `${htmlError} ${nothing}` } };
1686
1965
  const extra = unknownFields(catalog[command?.type], command?.payload);
1687
1966
  if (extra) {
@@ -1725,6 +2004,22 @@ function applyChecked(project, commands) {
1725
2004
  ...changed.length ? { changes: describeCommands(changed) } : {}
1726
2005
  };
1727
2006
  }
2007
+ function checkHtmlMarkup(project, command, batchAssets) {
2008
+ const p = command.payload ?? {};
2009
+ const isHtmlAdd = command.type === "clip/add" && p.kind === "html";
2010
+ const isTemplateSet = command.type === "clip/set-property" && "template" in p;
2011
+ if (!isHtmlAdd && !isTemplateSet || typeof p.template !== "string") return void 0;
2012
+ const label = isHtmlAdd ? `html clip "${p.id ?? "(new)"}"` : `the template for "${p.clipId}"`;
2013
+ const problem = markupProblem(p.template);
2014
+ if (problem) return `${label} won't render: its markup is parsed as XML and ${problem}.`;
2015
+ const assets = project.getState().doc.assets;
2016
+ const missing = assetRefs(p.template).filter((id) => !assets[id] && !batchAssets.has(id));
2017
+ if (missing.length) {
2018
+ const images = Object.values(assets).filter((a) => a.kind === "image").map((a) => a.id);
2019
+ return `${label} uses ${missing.map((id) => `"asset:${id}"`).join(", ")}, but there is no such asset. Use an asset in the project (images: ${images.join(", ") || "none"}), add it in this batch with asset/add, or leave the image out.`;
2020
+ }
2021
+ return void 0;
2022
+ }
1728
2023
  function checkHtmlParams(project, command, templates) {
1729
2024
  const p = command.payload ?? {};
1730
2025
  let template;
@@ -1766,7 +2061,7 @@ function noCutReason(project, fromId, toId) {
1766
2061
  return `No cut ${a ? `starts after ${a.id}` : `leads into ${b.id}`} (it isn't directly after/before another clip on its track). ${tail}`;
1767
2062
  }
1768
2063
  function cutInfo(c) {
1769
- return { fromClipId: c.fromClipId, toClipId: c.toClipId, atSeconds: c.atUs / S, maxSeconds: c.maxTransitionUs / S, ...c.transition ? { transition: c.transition.kind } : {} };
2064
+ return { fromClipId: c.fromClipId, toClipId: c.toClipId, atSeconds: c.atUs / S, maxSeconds: c.maxTransitionUs / S, maxZoomThroughSeconds: c.maxHoldTransitionUs / S, ...c.transition ? { transition: c.transition.kind } : {} };
1770
2065
  }
1771
2066
  function applyCommandsSchema(catalog) {
1772
2067
  const [dispatch] = (0, import_core2.toToolDefinitions)(catalog, { mode: "dispatch" });
@@ -1853,8 +2148,126 @@ function commitFork(project, fork, label) {
1853
2148
  }
1854
2149
  }
1855
2150
 
2151
+ // src/skills.ts
2152
+ var NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
2153
+ function defineSkill(skill) {
2154
+ if (!NAME.test(skill.name)) throw new Error(`Skill name must match ${NAME} (got "${skill.name}")`);
2155
+ if (!skill.description.trim()) throw new Error(`Skill "${skill.name}" needs a description`);
2156
+ return skill;
2157
+ }
2158
+ function parseFrontMatter(markdown) {
2159
+ const text = markdown.replace(/^/, "").replace(/\r\n/g, "\n");
2160
+ const match = /^---\n([\s\S]*?)\n---\n?/.exec(text);
2161
+ if (!match) return { data: {}, body: text.trim() };
2162
+ const data = {};
2163
+ for (const line of match[1].split("\n")) {
2164
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
2165
+ if (!kv) continue;
2166
+ let value = kv[2].trim();
2167
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
2168
+ data[kv[1]] = value;
2169
+ }
2170
+ return { data, body: text.slice(match[0].length).trim() };
2171
+ }
2172
+ function parseSkill(markdown, options = {}) {
2173
+ const { data, body } = parseFrontMatter(markdown);
2174
+ const name = data.name || options.name;
2175
+ const description = data.description || options.description;
2176
+ if (!name) throw new Error("Skill has no name (front matter `name:`)");
2177
+ if (!description) throw new Error(`Skill "${name}" has no description (front matter \`description:\`)`);
2178
+ return defineSkill({ name, description, instructions: body, ...options.files && Object.keys(options.files).length ? { files: options.files } : {} });
2179
+ }
2180
+ function skillsFromFiles(files, options = {}) {
2181
+ const entry = options.entry ?? ["SKILL.md"];
2182
+ const ENTRY_FILES = /* @__PURE__ */ new Set([...entry, "SKILL.md", "EDITOR.md"]);
2183
+ const paths = Object.keys(files).map((p) => p.replace(/\\/g, "/"));
2184
+ const original = new Map(Object.keys(files).map((p) => [p.replace(/\\/g, "/"), p]));
2185
+ const read = (p) => files[original.get(p) ?? p];
2186
+ const dirs = [...new Set(paths.filter((p) => ENTRY_FILES.has(base(p))).map(dirname))].sort((a, b) => b.length - a.length);
2187
+ const skills = [];
2188
+ for (const dir of [...dirs].sort()) {
2189
+ const main = entry.map((f) => join(dir, f)).find((p) => p in files || original.has(p));
2190
+ if (!main) continue;
2191
+ const others = [...ENTRY_FILES].map((f) => join(dir, f)).filter((p) => p !== main && original.has(p));
2192
+ const fallback = others.map((p) => parseFrontMatter(read(p)).data).find((d) => d.name || d.description) ?? {};
2193
+ const own = {};
2194
+ for (const p of paths) {
2195
+ if (!p.startsWith(`${dir}/`) || !p.endsWith(".md") || ENTRY_FILES.has(p.slice(dir.length + 1))) continue;
2196
+ if (dirs.some((d) => d !== dir && d.startsWith(`${dir}/`) && p.startsWith(`${d}/`))) continue;
2197
+ own[p.slice(dir.length + 1)] = read(p);
2198
+ }
2199
+ skills.push(parseSkill(read(main), { name: fallback.name ?? base(dir), ...fallback.description ? { description: fallback.description } : {}, files: own }));
2200
+ }
2201
+ return skills;
2202
+ }
2203
+ function describeSkills(skills) {
2204
+ if (!skills.length) return "";
2205
+ return [
2206
+ "Skills (playbooks for bigger jobs):",
2207
+ ...skills.map((s) => `- ${s.name}: ${s.description}`),
2208
+ "When a request matches a skill, call load_skill with its name before your first edit and follow it. It may point to reference files: read them with read_skill_file when the step you're on needs them. A skill loaded in an earlier request may have been shortened in the history: load it again if you need it."
2209
+ ].join("\n");
2210
+ }
2211
+ function skillTools(skills) {
2212
+ if (!skills.length) return [];
2213
+ const byName = new Map(skills.map((s) => [s.name, s]));
2214
+ const names = skills.map((s) => s.name);
2215
+ return [
2216
+ defineTool({
2217
+ name: "load_skill",
2218
+ description: "Read a skill's playbook (see the skill list in the instructions). Call it before starting a job that matches one, then follow it.",
2219
+ inputSchema: { type: "object", properties: { name: { type: "string", enum: names } }, required: ["name"], additionalProperties: false },
2220
+ run(input) {
2221
+ const skill = byName.get(String(input.name));
2222
+ if (!skill) return { ok: false, error: `unknown skill "${input.name}"; skills are ${names.join(", ")}` };
2223
+ return { ok: true, result: { name: skill.name, instructions: skill.instructions, files: Object.keys(skill.files ?? {}) } };
2224
+ }
2225
+ }),
2226
+ defineTool({
2227
+ name: "read_skill_file",
2228
+ description: "Read a reference file of a skill (paths as load_skill lists them, e.g. reference/craft.md). Long files come in parts: pass `part` for the next one.",
2229
+ inputSchema: {
2230
+ type: "object",
2231
+ properties: { skill: { type: "string", enum: names }, path: { type: "string" }, part: { type: "integer", minimum: 1 } },
2232
+ required: ["skill", "path"],
2233
+ additionalProperties: false
2234
+ },
2235
+ run(input) {
2236
+ const skill = byName.get(String(input.skill));
2237
+ if (!skill) return { ok: false, error: `unknown skill "${input.skill}"; skills are ${names.join(", ")}` };
2238
+ const path = String(input.path).replace(/^\.\//, "");
2239
+ const text = skill.files?.[path];
2240
+ if (text === void 0) return { ok: false, error: `"${skill.name}" has no file "${path}"; its files are ${Object.keys(skill.files ?? {}).join(", ") || "none"}` };
2241
+ const parts = chunk(text, FILE_PART);
2242
+ const part = Math.min(Math.max(1, Number(input.part) || 1), parts.length);
2243
+ return { ok: true, result: { skill: skill.name, path, ...parts.length > 1 ? { part, parts: parts.length } : {}, content: parts[part - 1] } };
2244
+ }
2245
+ })
2246
+ ];
2247
+ }
2248
+ var FILE_PART = 15e3;
2249
+ function chunk(text, max) {
2250
+ if (text.length <= max) return [text];
2251
+ const out = [];
2252
+ let rest = text;
2253
+ while (rest.length > max) {
2254
+ let cut = rest.lastIndexOf("\n\n", max);
2255
+ if (cut < max / 2) cut = rest.lastIndexOf("\n", max);
2256
+ if (cut < max / 2) cut = max;
2257
+ out.push(rest.slice(0, cut));
2258
+ rest = rest.slice(cut).replace(/^\n+/, "");
2259
+ }
2260
+ if (rest) out.push(rest);
2261
+ return out;
2262
+ }
2263
+ var base = (p) => p.slice(p.lastIndexOf("/") + 1);
2264
+ var dirname = (p) => p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
2265
+ var join = (dir, f) => dir ? `${dir}/${f}` : f;
2266
+
1856
2267
  // src/run.ts
1857
2268
  var MAX_TOOL_RESULT = 2e4;
2269
+ var MAX_IMAGES_PER_RESULT = 4;
2270
+ var KEEP_IMAGE_RESULTS = 2;
1858
2271
  var MAX_OLD_TOOL_RESULT = 2e3;
1859
2272
  var DEFAULT_INSTRUCTIONS = `You are the editing assistant inside a video editor. You change the user's project by calling tools; every change you make lands as ONE undo step when you finish, so act confidently.
1860
2273
 
@@ -1882,7 +2295,9 @@ How to work:
1882
2295
 
1883
2296
  When you're done, reply in one to three short sentences: what you changed, in plain words (times in seconds, names over ids). Describe only edits your tools reported as applied; if a tool says something changed nothing or failed, fix it or say so. No markdown headings.`;
1884
2297
  function createAssistant(options) {
1885
- const tools = options.tools ?? editorTools();
2298
+ const skills = options.skills ?? [];
2299
+ const tools = [...options.tools ?? editorTools(), ...skillTools(skills)];
2300
+ const skillByName = new Map(skills.map((s) => [s.name, s]));
1886
2301
  const byName = new Map(tools.map((t) => [t.name, t]));
1887
2302
  const specs = tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
1888
2303
  const maxSteps = options.maxSteps ?? 12;
@@ -1899,6 +2314,19 @@ function createAssistant(options) {
1899
2314
  const system = [
1900
2315
  DEFAULT_INSTRUCTIONS,
1901
2316
  options.instructions?.trim(),
2317
+ describeSkills(skills),
2318
+ ...(turn.skills ?? []).flatMap((name) => {
2319
+ const skill = skillByName.get(name);
2320
+ if (!skill) return [];
2321
+ const files = Object.keys(skill.files ?? {});
2322
+ return [
2323
+ `The user started this request with the "${skill.name}" skill. It's loaded; follow it:
2324
+
2325
+ ${skill.instructions}${files.length ? `
2326
+
2327
+ (Its files, for read_skill_file: ${files.join(", ")}.)` : ""}`
2328
+ ];
2329
+ }),
1902
2330
  `Current project:
1903
2331
  ${describeForAssistant(fork.toJSON())}`,
1904
2332
  turn.context?.trim() ? `What the user is looking at: ${turn.context.trim()}` : void 0,
@@ -1907,6 +2335,7 @@ ${describeForAssistant(fork.toJSON())}`,
1907
2335
  let reply = "";
1908
2336
  let status = "no-changes";
1909
2337
  let error;
2338
+ let seesImages = true;
1910
2339
  try {
1911
2340
  for (let step = 0; ; step++) {
1912
2341
  if (step >= maxSteps) {
@@ -1916,16 +2345,24 @@ ${describeForAssistant(fork.toJSON())}`,
1916
2345
  break;
1917
2346
  }
1918
2347
  emit({ type: "step", index: step });
1919
- const response = await options.model.complete(
1920
- {
1921
- messages: [{ role: "system", content: system }, ...history, ...added],
1922
- tools: specs,
1923
- ...options.modelId ? { model: options.modelId } : {},
1924
- ...options.maxOutputTokens ? { maxOutputTokens: options.maxOutputTokens } : {},
1925
- ...options.params ? { params: options.params } : {}
1926
- },
1927
- { ...turn.signal ? { signal: turn.signal } : {}, onText: (delta) => emit({ type: "text", delta }) }
1928
- );
2348
+ const request2 = {
2349
+ messages: recentImagesOnly([{ role: "system", content: system }, ...history, ...added], seesImages ? KEEP_IMAGE_RESULTS : 0),
2350
+ tools: specs,
2351
+ ...options.modelId ? { model: options.modelId } : {},
2352
+ ...options.maxOutputTokens ? { maxOutputTokens: options.maxOutputTokens } : {},
2353
+ ...options.params ? { params: options.params } : {}
2354
+ };
2355
+ const callOptions = { ...turn.signal ? { signal: turn.signal } : {}, onText: (delta) => emit({ type: "text", delta }) };
2356
+ let response;
2357
+ try {
2358
+ response = await options.model.complete(request2, callOptions);
2359
+ } catch (err) {
2360
+ const hasImages = request2.messages.some((m) => m.role === "tool" && m.images?.length);
2361
+ const refused = err instanceof ChatModelError && (err.status === 400 || err.status === 415 || /image|inline|multimodal|vision|content type/i.test(err.message));
2362
+ if (!hasImages || !refused || turn.signal?.aborted) throw err;
2363
+ seesImages = false;
2364
+ response = await options.model.complete({ ...request2, messages: recentImagesOnly(request2.messages, 0) }, callOptions);
2365
+ }
1929
2366
  usage2.inputTokens += response.usage?.inputTokens ?? 0;
1930
2367
  usage2.outputTokens += response.usage?.outputTokens ?? 0;
1931
2368
  added.push({ role: "assistant", content: response.content, ...response.toolCalls.length ? { toolCalls: response.toolCalls } : {} });
@@ -1943,9 +2380,16 @@ ${describeForAssistant(fork.toJSON())}`,
1943
2380
  call,
1944
2381
  ok: out.ok,
1945
2382
  ...out.ok && out.changes?.length ? { changes: out.changes } : {},
1946
- ...!out.ok ? { error: short(out.error) } : {}
2383
+ ...!out.ok ? { error: short(out.error) } : {},
2384
+ ...out.ok && out.images?.length ? { images: out.images.slice(0, MAX_IMAGES_PER_RESULT) } : {}
2385
+ });
2386
+ added.push({
2387
+ role: "tool",
2388
+ toolCallId: call.id,
2389
+ name: call.name,
2390
+ content: cap(out.ok ? out.result ?? { ok: true } : { ok: false, error: out.error }, MAX_TOOL_RESULT),
2391
+ ...out.ok && out.images?.length ? { images: out.images.slice(0, MAX_IMAGES_PER_RESULT) } : {}
1947
2392
  });
1948
- added.push({ role: "tool", toolCallId: call.id, name: call.name, content: cap(out.ok ? out.result ?? { ok: true } : { ok: false, error: out.error }, MAX_TOOL_RESULT) });
1949
2393
  }
1950
2394
  }
1951
2395
  status = fork.recorded.length === 0 ? "no-changes" : turn.apply === "review" ? "review" : "applied";
@@ -1998,6 +2442,18 @@ async function runTool(tool, call, project, signal) {
1998
2442
  return { ok: false, error: err?.message ?? String(err) };
1999
2443
  }
2000
2444
  }
2445
+ function recentImagesOnly(messages, keep) {
2446
+ let seen = 0;
2447
+ const out = [...messages];
2448
+ for (let i = out.length - 1; i >= 0; i--) {
2449
+ const m = out[i];
2450
+ if (m.role !== "tool" || !m.images?.length) continue;
2451
+ if (seen++ < keep) continue;
2452
+ const { images: _dropped, ...rest } = m;
2453
+ out[i] = { ...rest, content: `${rest.content} [${_dropped.length} image${_dropped.length > 1 ? "s" : ""} shown earlier]` };
2454
+ }
2455
+ return out;
2456
+ }
2001
2457
  function cap(value, max) {
2002
2458
  const text = typeof value === "string" ? value : JSON.stringify(value);
2003
2459
  return text.length > max ? `${text.slice(0, max)}\u2026 [truncated ${text.length - max} chars]` : text;
@@ -2036,6 +2492,242 @@ function selectionNote(doc, selection) {
2036
2492
  return `Selected: ${list}. In this request, "this", "it", "the clip" and anything that names no target mean ONLY ${clips.map((c) => c.id).join(", ")}. Leave every other clip alone.`;
2037
2493
  }
2038
2494
 
2495
+ // src/skills.generated.ts
2496
+ var MIRAICLIP_SKILLS = [
2497
+ {
2498
+ "name": "captions",
2499
+ "description": "Add, fix or restyle word-timed captions or subtitles in the editor - karaoke, pop, word-by-word, boxed or subtitle-bar looks - from a transcript, an SRT/VTT the user pastes, or the editor's own transcription. Use for any request about captions, subtitles or on-screen text that follows speech.",
2500
+ "instructions": '# Captions\n\nLoad `edit-video` first if you haven\'t. Captions are `caption` clips on their own video track above everything else: one clip per short phrase, each word timed (`words: [{ text, startUs, durationUs }]`, times relative to the clip\'s start).\n\n## 1. Where the words come from\n\n| The project or user has\u2026 | Do |\n| --- | --- |\n| Caption clips already | Edit or restyle them; don\'t rebuild |\n| A pasted SRT/VTT or word-timed JSON | Build caption clips from it (step 3) |\n| Plain text, no timings | Spread the words evenly over the speech the user points to, about 2.5\u20133 words a second; say the timing is approximate |\n| Nothing | You can\'t hear the audio. Ask the user to transcribe it with the editor\'s transcription feature (if it has one) or paste a transcript, then come back |\n\nNever invent the words someone says. Fix obvious mis-hearings of names and brands; keep the word count per phrase the same so timings still line up.\n\nTimeline time = source time + `clip.startUs \u2212 clip.trimStartUs` of the video clip the speech belongs to. If the video was recut, map each word through the segment it falls in, and drop words that were cut.\n\n## 2. Pick a look\n\nIf the editor has a caption-look tool (`style_captions`), add the clips first, then apply a look with it (and the brand accent as its colour). Otherwise set `style` on each caption clip:\n\n| Look | `style` | Use for |\n| --- | --- | --- |\n| Social karaoke (default) | `{"preset":"karaoke","fontWeight":800,"fontSizeFrac":0.055,"strokeColor":"#000000","strokeWidthFrac":0.12,"highlightColor":"#ffd400"}` | Reels, Shorts, TikTok, podcasts |\n| Pop, one word big | `{"preset":"pop","display":"word","fontWeight":900,"fontSizeFrac":0.075,"textTransform":"uppercase","strokeColor":"#000000","strokeWidthFrac":0.14,"highlightColor":"#ffffff"}`, one word per clip | Hype edits, hooks |\n| Boxed highlight | `{"preset":"highlight","fontWeight":700,"fontSizeFrac":0.05,"color":"#ffffff","highlightColor":"#0a0d18","activeBackgroundColor":"#ffd400"}` | Clean brand look |\n| Subtitle bar | `{"preset":"plain","fontWeight":600,"fontSizeFrac":0.042,"backgroundColor":"#000000b3"}`, 6\u20138 words | Interviews, accessibility |\n| Reveal | `{"preset":"reveal","fontWeight":800,"fontSizeFrac":0.055,"shadowColor":"#000000","shadowBlurFrac":0.3,"shadowOffsetFrac":0}` | Storytelling, quotes |\n\n## 3. Build\n\n- Words per clip: 3\u20134 for 9:16 social, 4\u20136 for 16:9 social, 6\u20138 for subtitles. Break phrases at punctuation and pauses, never mid-name.\n- Position: `transform.y` 0.78\u20130.82 on 16:9, 0.68\u20130.72 on 9:16 (above the platform UI).\n- `track/add` `{ "id": "captions", "kind": "video" }` on top if it\'s missing, then one `apply_commands` batch of `clip/add` (kind `caption`), ids `cap-1`, `cap-2`, \u2026. Long videos: batches of about 150 commands.\n- Each clip runs from its first word\'s start to its last word\'s end (a little hold, \u2264 0.3 s, is fine); clips must not overlap.\n\n## 4. Check\n\nFrom the state: phrases stay within the words-per-clip limits above (they keep a caption to two lines at these sizes); captions don\'t overlap a lower third or callout in time and place (move them up, `y` 0.7, while it\'s on); nothing runs past the video\'s end.\n\nTo restyle every caption, use the look tool, or one batch of `clip/set-property` `{ clipId, style }` per caption clip (style merges).\n\nTell the user how many captions you added, the look, and to play one stretch to check the timing.'
2501
+ },
2502
+ {
2503
+ "name": "continuous-shot",
2504
+ "description": "Make a continuous shot: one unbroken camera move where every beat grows out of the last, with no cuts and nothing that feels like slides. Use when the user asks for a continuous shot, one take, no cuts, a single camera move, or says a film should not feel like a slideshow. Offers three concepts, writes a beat sheet, builds with the version-2 stage kit, runs check_sequence until no boundary is bare, and reviews on a contact sheet.",
2505
+ "instructions": '# Continuous shot\n\nOne take: a subject, a camera, and beats that each grow out of the one before. The shot is checked by measurement, not by feel: `check_sequence` says which beats start from nothing.\n\nLoad `sequence-core` (the stage kit API, including the version-2 verbs) and `motion` before building, and `look` for the palette and type. Load `direction` if the brief is thin. Never export; the user does that.\n\n## 1. Three concepts\n\nBefore any plan, offer **three concepts** that differ in their central picture, not in wording. Each is one sentence naming the subject and the world it travels through, e.g. "a single dot that becomes the product", "a cursor that opens each screen", "a camera that falls into each detail". Present them as choices the user can click (in the studio, as options) and wait for the pick. If the user is not there, pick the one that best matches the brief, say which, and continue.\n\n## 2. Beat sheet\n\nWrite the beat sheet (`reference/beat-sheet.md`) and show it: time \xB7 beat \xB7 what moves \xB7 what is still \xB7 what carries into the next beat. Choose a carry type for every boundary from `reference/carry-types.md`. Include the hook, one declared rest and the lockup. Ask for changes once; don\'t build until it is agreed, unless the user is away.\n\n## 3. Build\n\nCompose one sequence per act with `data-stage-version="2"` on the stage root. Mark every beat with `tl.label(name, time)` (labels are what the check looks at). Use the carry verbs from the sheet, the operated camera (`tl.camera`, `tl.follow`, `tl.whip`, `tl.handheld`) and declared rests (`tl.rest`). Put `data-keep-in-frame="t0,t1"` on the subject. Use `S.*` units, never pixels.\n\n## 4. Check until clean\n\nRun `check_sequence`. Fix every warning:\n\n- **bare boundary**: carry something across it with the verb its hint suggests, or redesign the beat;\n- **framing**: keep the subject in frame, or follow it with the camera;\n- **cadence**: vary the beat lengths;\n- **hard cut**: hide it in a move or remove it.\n\nRepeat until no boundary is bare and every subject stays in frame. A carry score under 0.5 is never acceptable. A deliberate `tl.cut` is allowed only if the sheet says why.\n\n## 5. Contact sheet and review\n\nShow the boundary frames (the filmstrip `compose_sequence` returns, and the frames around every label) and grade the draft against `reference/scorecard.md`. Fix any fail, then tell the user which concept was built, the beat sheet in one line, and what to look at. Warnings never block an export, but don\'t hand over a draft with a bare boundary. When the user is ready to export the final, suggest motion blur (`motionBlur: {}` on the export): a continuous shot moves a lot, and the shutter smear is what keeps fast moves from strobing. It is slower to render, so it is for the final, not for drafts or previews.\n\n## More than one act\n\nIf the film needs more than one act, use the existing hand-off between acts: the next act starts from the element and camera state the last one ended on (see `sequence-core`, "Hand-off between acts"). Run `check_sequence` across the join; it reports a hard join between clips as `act-join`.\n\n## Sound and music\n\nSound moments are `tl.hit` events; carry landings, presses, impacts and shakes add their own. After the check is clean:\n\n1. Pick one material for the film\'s sounds (all wood, all glass, all soft synth) and add one CC0 sound per hit kind as audio assets.\n2. `place_sfx_from_events` puts one sound at every hit and ducks the music under them. It reports where on screen each hit happened; audio clips have no pan yet, so that is not applied.\n3. To land carries on the music: Get the beat grid of the music (the host analyses it) and write it with `set_beat_grid`, then build carries on `S.beat(n)` and `S.downbeat(n)`.\n\nSound effects on the big moments only; a hit on every beat is noise.',
2506
+ "files": {
2507
+ "reference/beat-sheet.md": '# Beat sheet\n\nWrite the sheet before any HTML. It is the plan the shot is built and checked against. Show it to the user before building.\n\n## Template\n\n| time | beat | what moves | what is still | what carries into the next beat |\n|---|---|---|---|---|\n\n- **time**: start, in seconds; the last row is the end. Vary beat lengths: a film of equal beats reads as a metronome (the check warns under 0.25 variation).\n- **beat**: a name, which becomes the `tl.label`.\n- **what moves**: the subject and the camera.\n- **what is still**: only elements in a declared `tl.rest`, or the persistent ground. Everything else moves, or says why not.\n- **what carries**: which carry type (see `carry-types.md`) and which element survives.\n\nRules for the sheet:\n- Row 1 is the hook: the first second shows something moving that makes a viewer ask what it is.\n- Include at least one rest before the payoff, written as `rest 7.4\u20138.5` with what is allowed to move inside it.\n- The last row is the lockup: the cast folds into the logo or name. Nothing new arrives.\n- No row may say "cut" unless it is a deliberate `tl.cut`; give it a reason.\n- Words: at most about 7 on screen at any time.\n\n## Worked example: a 14 s film for "Kiln", an invented app that schedules pottery studio time\n\n| time | beat | what moves | what is still | carries |\n|---|---|---|---|---|\n| 0.0 | spark | a glowing dot drifts across a dark dot-grid, camera follows | the grid | persistent ground; the dot is the subject |\n| 1.6 | pot | the dot grows and morphs into a rounded vase silhouette; grid parallax shifts | none | same element, new job (morph) |\n| 3.4 | slot | the vase hops to the first slot of a weekly calendar that unrolls from its base | the calendar frame | same element hops onto the calendar |\n| 5.4 | fill | six more vases gather into the week; camera pulls back to show it | none | container (gather) |\n| 7.4 | rest | nothing moves except the cursor\'s idle blink | everything but the cursor | rest 7.4\u20138.5, allow `.cursor` |\n| 8.6 | press | the cursor presses "Book"; button squashes, screen shakes once | none | cause (press, shake, hit) |\n| 9.2 | confirm | an iris opens from the button into a confirmation card | none | material state (iris) |\n| 11.0 | lockup | the card folds into the wordmark "Kiln" on the grid | the grid | same element (morph) into the logo |\n| 13.0 | end | a slow handheld float, URL fades in | the wordmark | none; end hold under 1.2 s counts as exempt |\n\nSheet checks: beat lengths 1.6, 1.8, 2.0, 2.0, 1.2, 0.6, 1.8, 2.0, 1.0 (varied); one rest, declared; every boundary names a carry; hook at 0; lockup at 11.\n',
2508
+ "reference/carry-types.md": '# Carry types\n\nA continuous shot is a chain of beats where something from each beat becomes the next. Pick the carry for each boundary from this list, say which one in the beat sheet, and build it with the verbs named. `check_sequence` confirms it landed: the boundary must read **carried** (or **anchored** for a held ground).\n\nAll verbs need `data-stage-version="2"`. Sizes are in `S.*` units so every aspect ratio works.\n\n## 1. Same element, new job\n\nOne element stays on screen and becomes something else: a dot becomes a button, a button becomes a card.\nVerbs: `tl.morph(from, to)` (box, radius and colour become the target\'s; content rises in), `tl.hop(target, { to })` for an arc to a new slot.\nExample: a seed-shaped dot on a dark ground morphs into a rounded search field; the placeholder text rises as it settles.\n\n## 2. Scale\n\nThe camera falls into a detail and the detail is the next world. What was a full stop is a planet; what was a window is a room.\nVerbs: `tl.camera({ anchor, at, zoom })` for a deep zoom that keeps the anchor fixed on screen, `tl.zoomThrough(target, { into })` to hand over to an inner scene on a frame where only the target\'s surface is visible.\nExample: push into the dot of an "i" until its surface fills the frame, and the next scene is lit from inside it.\n\n## 3. Cause\n\nA hit or press causes the next beat: the click that sends the message, the drop that breaks the surface.\nVerbs: `tl.press(target, t)`, `tl.impact(target, pos)`, `tl.shake(pos)`, `tl.hit(name, pos)`. The effect of the cause (ripple, iris, scatter) starts on the same frame as the hit.\nExample: a cursor presses "Send"; the button squashes, the screen shakes once, and the iris that opens from the button reveals the reply.\n\n## 4. Container\n\nMany small things gather into one box, which is the next beat\'s subject.\nVerbs: `tl.gather(targets, { into })`, then `tl.morph` or `tl.hop` to move the container on.\nExample: ten scattered receipts fly on fanned arcs into one folder; the folder is the card that comes forward.\n\n## 5. Persistent ground\n\nThe ground stays and the cast changes. A dot grid, a horizon or a paper texture is always on screen and the camera travels across it, so the eye never loses its place. This reads **anchored** when nothing else survives.\nVerbs: `S.grid({ depth, spacing, dot })`, `data-depth` parallax layers, `tl.follow(target)`.\nExample: a lattice of dots stays under every beat; a bright dot rolls through it and the camera follows.\n\n## 6. Split that collapses\n\nOne element breaks into parts that act separately, then the parts rejoin as a different whole.\nVerbs: `tl.gather` in reverse reads as the collapse; for the split, `tl.hop` each part to its own slot with a short stagger. Hide the original at the instant of the split.\nExample: a headline splits into words that hop to the corners of a diagram, then fold back into the product name.\n\n## 7. Material state\n\nAn element changes state: solid to liquid, flat to deep, light to dark. The shape or position stays, the surface changes.\nVerbs: `tl.morph` with `radius` and colour, `tl.iris` for a lighting change that spreads from a point, `data-lod` to fade detail in as the camera nears.\nExample: a flat white card sharpens into a screen glowing from within, as an iris of dark opens from its centre.\n\n## 8. The subject as the pointer\n\nThe subject (a cursor, a ball, a finger) leads the eye, and the camera and the next beat are where it points or lands.\nVerbs: `tl.pointer({ path, clicks })`, `tl.follow(target, { lead })`, `tl.whip(target)`.\nExample: a cursor slides to a toggle, clicks, and the whip pan lands on the panel it opened.\n\n## Choosing\n\n- Every boundary needs one. If you can\'t name which, the beat is a cut: either redesign it or declare `tl.cut` and treat it as a deliberate hard cut.\n- Don\'t use the same carry on more than two boundaries in a row; the shot starts to feel like a formula.\n- Pair a carry with a sound moment (`tl.hit`): the landing is where the picture and the sound agree.\n- A rest (`tl.rest`) is not a carry; it is a pause between two carries, and it must be declared.\n',
2509
+ "reference/scorecard.md": "# Scorecard (review)\n\n`check_sequence` measures carry, framing and rhythm. These points it can't measure. Grade every draft against them, say what you changed, and show the contact sheet.\n\n1. **A hook in the first second.** Something moves and raises a question before any text is read. Fail: a logo, a title card, a fade in from black.\n2. **One subject the camera chases.** A single element the viewer can name (the dot, the cursor, the vase) is followed through the film. Fail: the camera wanders between equal elements.\n3. **A rest before the payoff.** One declared `tl.rest` (0.6\u20131.2 s) before the key moment, so the payoff lands. Fail: constant motion with no pause, or an undeclared still.\n4. **Small elements on a big ground.** The subject is small against a large ground at some point, so scale changes can be felt. Fail: everything fills the frame at all times.\n5. **No confetti or flourishes that aren't the product's own behaviour.** A particle burst, lens flare or sparkle that the product wouldn't do on its own is removed. A button squashing when pressed is fine.\n6. **A lockup the cast folds into.** The last beat is the logo or name made out of what has been on screen, not a new card. Fail: a fresh end card fades in.\n7. **A look chosen fresh.** Palette, type and ground are chosen for this subject (see the `look` skill), not reused from the last film or the sample.\n\nGrade each: pass, weak (say how to fix) or fail (fix now). Don't present a draft with a fail.\n"
2510
+ }
2511
+ },
2512
+ {
2513
+ "name": "direction",
2514
+ "description": "Direct a film from a brief before building it \u2014 the world or metaphor, the protagonist element and how it evolves, acts and a timed beat sheet with the technique and carry-over for each beat, the words per beat, the look and the music direction. Load it first for any film made from a brief.",
2515
+ "instructions": "# Direction\n\nA film that feels made, not assembled, was directed first. Before any tool that builds, write a **treatment**: one page that decides what the film is, and what happens on screen second by second. Then the look (`look` skill), the media and music (`media-sound`), and the acts (`sequence-core` + `motion`) follow from it.\n\n## 1. The idea\n\n- **The world.** One sentence: what world the film lives in, a metaphor taken from the subject.\n - A CRM's tools are planets that collide into one.\n - A course on agents is a live agent console.\n - A recipe book is a kitchen notebook.\n - A city break is a route drawn on a map.\n\n Not \"a modern, clean video\".\n- **The protagonist element.** One thing on screen that the film follows and transforms. This is what makes a film continuous instead of a slideshow. Examples:\n - ten orbs drift, collide and become one logo, which becomes a window, which opens into the product;\n - a cursor types a question, the question becomes a search bar, the results become the film's chapters;\n - a line draws a route, stops become chapters, and the route closes into the logo.\n\n Write the chain: element \u2192 what it becomes \u2192 what that becomes.\n- **The promise.** What the viewer should know or feel at the end, in one line. Every beat serves it.\n\n## 2. Acts and beats\n\n- **Length:** what the user asked for, within 10%. Otherwise:\n - 15\u201330 s for a launch, promo or intro;\n - 30\u201345 s for a trailer;\n - 45\u201390 s for a documentary.\n- **Acts:** 2\u20134 acts of 8\u201325 s, each one `compose_sequence`. An act is one movement of the idea. A 15 s film is one or two acts.\n- **Beats:**\n - 0.5\u20132 s each in motion graphics; 3\u20136 s in a documentary.\n - Each beat is **one idea and one move**, and says something new.\n - Each beat begins from something the last beat left on screen.\n\nWrite the beat sheet as a table, one row per beat:\n\n| Time | Beat (what it says) | On screen (what moves) | Technique | Carries into the next |\n|---|---|---|---|---|\n| 0.0\u20131.4 | Ten tools. Ten bills. | Six orbs drift in, labels on; the line types above | staggered-entrance, typewriter | The orbs |\n| 1.4\u20132.6 | One place. | The orbs accelerate inwards, collide, one big orb | collide-and-merge | The big orb |\n| \u2026 | | | | |\n\n- **Techniques** come from the `motion` skill's index. Pick one main technique per beat.\n- **Carry-over** is the element that bridges to the next beat: a word, a shape, a picture, a colour, the camera's move. If a row has no carry-over, the cut must be hidden (blur-through, zoom-through, whip-pan, match-cut), or the beat must change.\n- **Words:** at most about 7 words on screen per beat, and a reading budget of about 3\u20134 words a second plus half a second. Real material only: no invented people, numbers, reviews or quotes. Every word on screen is in the beat sheet.\n\n## 3. The arc by format\n\nUse these as guides. Reorder, merge or add the beats the brief needs.\n\n- **Launch / promo / intro:** the viewer's problem (concrete, visual) \u2192 the turn (the product appears, out of the problem) \u2192 2\u20134 things it does, shown in action \u2192 proof (a real number or name) \u2192 the call to action.\n- **Course:** the learner's goal \u2192 the course (level, length, format) \u2192 what you learn \u2192 a lesson moment \u2192 what you build \u2192 who teaches (only a real, named instructor) \u2192 what you leave with \u2192 how to join.\n- **Explainer:** the question \u2192 the idea, drawn \u2192 the steps, each growing from the last \u2192 the result.\n- **Trailer:** a quiet cold open \u2192 the title \u2192 who \u2192 the stakes \u2192 a build of short hits \u2192 the title reveal.\n- **Documentary:** cold open \u2192 title \u2192 chapters of stills, people, facts and quotes \u2192 the reveal. Slower beats, still moving.\n\n## 4. Look and sound direction\n\nIn the treatment, name:\n\n- **The look**, in one line: palette, type and finish from the subject (`look` skill), and the grid and picture treatment.\n- **The camera's character**: floating and tilting (playful), pushing and whipping (energetic), slow pushes and drifts (reflective).\n- **The music**: mood, tempo and where it hits (the collide, the title reveal), and the sound effects for the big moves (`media-sound`).\n\n## 5. Learn from breakdowns\n\nThe `motion` skill's `reference/breakdowns/` are grammar studies of whole films. Read the one closest to your brief with `read_skill_file`, and borrow its grammar, never its words or look:\n\n| Breakdown | What to borrow |\n|---|---|\n| `typed-ui-launch.md` | Typed type (typewriter, retype-in-place); a title becoming a window (carry-over, morph); blur-through cuts; zoom-out-reveal from five to five hundred; scale-to-fill and match-cut on the wordmark |\n| `editorial-explainer.md` | A rail or timeline that draws (line-draw); labelled cut-outs that slide on an editorial grid (staggered-entrance, parallax); highlight-sweep on key terms; zoom-through into a detail |\n| `kinetic-type-promo.md` | Words as the whole picture (kinetic-type, swap-in-place, scale-to-fill); cuts on the beat; whip-pan between statements; accent-punctuation |\n| `documentary-trailer.md` | Slow push and parallax on stills; mask-reveal titles; blur-through between chapters; a count-up for a real figure; holds that stay alive |\n| `playful-shapes.md` | Shapes as characters (pop-in, overshoot, orbit); collide-and-merge into the brand; split-and-scatter; depth-stack and a tilting camera |\n\n## 6. Check the treatment\n\nBefore building, check that:\n\n- the world and the protagonist element come from *this* subject, not a template;\n- the chain of transformations runs from the first beat to the last;\n- every beat has one idea, a technique and a carry-over (or a hidden cut);\n- the words are real, and at most about 7 per beat;\n- the length adds up to what was asked;\n- the end lands: the promise, then a held end card (at most 1.5 s, still drifting).\n\n## 7. Record it, show it, then build\n\n1. `plan_film`, with the look and the concept taken from the treatment:\n - idea = the world;\n - motif = the protagonist element;\n - layout = the grid;\n - motion = the camera's character.\n2. `direct_film` with the treatment: the world, the promise, the protagonist chain, the camera, the music (mood, and tempo if known), and the acts with their beats. For each beat give its time, the exact words on screen, what moves, the technique and the carry-over.\n - It checks the treatment (lengths, words, reading time, techniques, carry-overs) and refuses it with the fixes when something is off.\n - When it accepts the treatment, it returns a storyboard that the user sees in the chat.\n3. **Wait for approval.** Unless the user said to just make it, end your turn there. Summarise the world, the protagonist chain and the acts in two or three lines. The chat offers **Approve & build** and **Change direction**.\n - If the user asks for changes, revise the treatment and call `direct_film` again.\n - The acts can't be built until the treatment is approved.\n4. After approval:\n - get the pictures (`media-sound`);\n - add the music and take its beat grid (`get_beat_grid`), which snaps the beats to the music;\n - compose one `compose_sequence` per act, in order, each starting where the last ended, and fix each act from its filmstrip before the next;\n - place the sound effects on the big moves;\n - check the whole film with `preview_film`.\n\nIn your reply, give the world and the protagonist chain in one line each, then the acts and their beats in one line each.\n\n`reference/treatment.md` is a blank treatment to fill in, with a worked example.",
2516
+ "files": {
2517
+ "reference/treatment.md": '# Treatment\n\nFill this in before building. Keep it to one page: it is a decision, not an essay.\n\n```\nFilm: <subject> \u2014 <format>, <length> s, <aspect>\nPromise: <what the viewer knows or feels at the end, one line>\nWorld: <the metaphor the film lives in, one sentence>\nProtagonist: <element> \u2192 <becomes> \u2192 <becomes> \u2192 <ends as>\nLook: <palette (bg / ink / accent / second)>, <type>, <finish>, <grid>, <picture treatment>\nCamera: <its character: floating / pushing / drifting \u2026>\nMusic: <mood>, <tempo>, hits at <times>; SFX on <moves>\n\nAct 1 \u2014 <name> (<start>\u2013<end> s)\n| Time | Beat | On screen | Technique | Carries into the next |\n\u2026\nAct 2 \u2014 <name> (<start>\u2013<end> s)\n\u2026\nEnd card: <the line>, held <\u22641.5> s, still drifting\n```\n\n## Worked example\n\nA fictional neighbourhood bakery app, "Crumb", which lets you order bread for collection before it sells out.\n\n```\nFilm: Crumb \u2014 launch, 15 s, 16:9\nPromise: Order the loaf tonight; it\'s waiting for you at 8.\nWorld: The bakery\'s morning, told by one loaf of bread.\nProtagonist: a loaf (a rounded shape) \u2192 it rises \u2192 it becomes the app\'s order card \u2192\n the card\'s "Ready 8:00" becomes a clock \u2192 the clock face becomes the logo\'s O\nLook: flour-white bg #f6f1e7, crust-brown ink #3b2a1f, accent oven orange #e8772e,\n second rye #8a6a4f; a rounded serif for titles, a soft sans for labels;\n paper grain; centred axis; no photos \u2014 the loaf is drawn\nCamera: warm and slow: gentle pushes, one tilt when the card appears, a pull back at the end\nMusic: light acoustic pluck, ~110 bpm (a bar \u2248 2.2 s), a soft hit at 7.6 s (the order\n is placed); SFX: a pop on the card, ticks on the clock, a bell on the logo\n\nAct 1 \u2014 Sold out (0\u20137.6 s)\n| 0.0\u20131.6 | "By 9, it\'s gone." | Empty shelf line; the loaf shape slides off it and away | staggered-entrance, motion-blur | The shelf line |\n| 1.6\u20133.2 | "Every morning." | The shelf line becomes a sunrise arc; the loaf rises over it like the sun | morph, path-follow | The loaf |\n| 3.2\u20135.4 | "Not anymore." | The loaf drops into the frame centre and puffs (it rises) | overshoot | The loaf\'s outline |\n| 5.4\u20137.6 | (no words) | The outline stretches into a rounded card; "Sourdough \xB7 1" types in | morph, typewriter | The card |\n\nAct 2 \u2014 Ready at 8 (7.6\u201315 s)\n| 7.6\u20139.4 | "Order tonight." | A "Reserve" button pops; tap ripple; the card tilts towards us | pop-in, depth-stack | The card\'s time row |\n| 9.4\u201311.4 | "Ready at 8:00." | "8:00" swells; its digits roll from 23:14 to 8:00 | count-up, zoom-through | The clock digits |\n| 11.4\u201313.5 | "Crumb." | The 0 of 8:00 is pulled out; it becomes the O in the wordmark | carry-over, match-cut | The wordmark |\n| 13.5\u201315.0 | "crumb.app" | The URL rises under the wordmark; slow pull back, grain | mask-reveal, holds | \u2014 |\n```\n\nEvery beat has one idea and one move, and something that carries over. The words are at most 4 per beat. The length is 15 s, as asked.\n'
2518
+ }
2519
+ },
2520
+ {
2521
+ "name": "edit-video",
2522
+ "description": "The base playbook for editing in a Miraiclip editor - the edit loop, timing and layer rules, and where the craft references are (formats, safe zones, type, pacing, HTML title recipes, motion). Load it for any edit bigger than one change, and before any other skill.",
2523
+ "instructions": "# Edit video in the editor\n\nYou edit the user's open project with tools. Each request lands as one undo step when you finish. You can't watch the video, so you check your work by reading the project back (`get_state`) and by doing the geometry and timing in numbers.\n\n## Pick the workflow\n\n| The user wants\u2026 | Load |\n| --- | --- |\n| A recorded talk, interview or vlog tightened and packaged (punch-ins, captions, lower third, music) | `talking-head` |\n| Captions or subtitles | `captions` |\n| A launch, intro, feature promo or announcement for a product, service or business, from a brief, with no footage or only screenshots | `launch-video` (a recipe on top of `make-film`) |\n| Any other film from a brief: a course or programme promo, a trailer or teaser for a show, film, book or event, a documentary, biography, recap or explainer | `make-film` |\n| Motion graphics, an animated sequence or a title sequence on its own | `sequence-core` and `motion` (and `look` for its colours and type) |\n| The same video in another shape (9:16, 1:1, 4:5, 16:9) | `reformat` |\n| A screen recording turned into a tutorial or walkthrough | `screen-tutorial` |\n| A reusable template | `make-template` |\n| One change (a title, music, a transition, a look) | No other skill: the loop below |\n\n## The loop\n\n1. **Read** the project summary (or `get_state`). Use the ids it shows.\n2. **Settle the brief** if the request is open: shape, length, tone, the one thing to remember. Ask one short question only if guessing would waste the edit.\n3. **Plan** the edit as a list of steps. Give new clips and tracks readable ids (`title`, `cap-3`, `s2`).\n4. **Apply**: prefer the high-level tools (`add_transition`, `animate_clip`, `add_effect`, `trim_clip`, `close_gaps`, `set_keyframes`, `set_background`, and any text, caption or audio tools the editor has). Batch everything else in `apply_commands`; it is all-or-nothing, so a failed batch applied nothing: fix and resend all of it.\n5. **Check**: `get_state` after big changes. Check every new clip's start and end against its neighbours, every text against the safe zone, and that nothing ends after the video does.\n\n## Rules that prevent most failures\n\n- **Commands use integer microseconds** (1 s = 1000000). High-level tools take seconds.\n- **Tracks are layers**: the first track is the bottom. Bottom-up: background \u2192 footage \u2192 B-roll/overlays \u2192 titles \u2192 captions. Add a track with `track/add` before putting clips on it.\n- **Position with `transform`**: `x`, `y` are the clip's centre as fractions of the frame (0.5, 0.5 = centre); `scale: 1` fits the clip in the frame. `transform` in `clip/set-property` merges.\n- **Keyframe times are relative to the clip's start**; `anchor: \"end\"` measures from its end so exits follow trims.\n- **Transitions** need two clips on one track that touch exactly, and source media past the cut. Text, image, caption and HTML clips always have enough; a video clip that plays its whole file has none until trimmed.\n- **Look up a command's schema** (`get_command_schema`) the first time you use it. `reference/commands.md` is the short list.\n- **Never invent media.** Use assets in the project or ones a tool returned. Never invent stats, quotes or testimonials either: use the user's, or mark them as placeholders.\n- **Never present sample media as the brand's product.** A sample or placeholder screenshot in a browser frame labelled with the brand's address tells viewers \"this is their product\" when it isn't. Use screenshots the user supplied; otherwise design an abstract UI card in HTML (shapes, the brand's colours, their real feature names), or leave that scene out.\n- **HTML must parse as XML** (`reference/html-recipes.md` \u2192 Rules); the editor rejects a clip that won't, with the reason. `asset:<id>` must name an asset in the project.\n\n## Brand and direction\n\nDecide the look and the voice before the first scene, in this order:\n\n1. **What the user asked for wins.** Colours, theme, style, mood, references (\"like Apple\", \"playful\", \"dark and minimal\"), pacing and tone of voice in the request are applied as asked, even when they differ from the brand's own. Don't trade them for a default.\n2. **Otherwise, the brand's own identity.** For a named brand, company, product or website, use its real look and voice: call `get_brand` if the editor has it (the saved brand kit, or the brand's website: colours, fonts, logo, and its own headlines, buttons and copy). Use its primary colour for the accent moments, its background and text colours or a dark/light version that fits them, its fonts (or the closest the editor has), and its logo on the end card.\n3. **Only when there's nothing to go on**, use the base kit's neutral palette, and say so in your reply so the user can give you their colours.\n\nMix them sensibly: \"make it energetic\" for a brand with a calm site keeps the brand's colours and type and changes the motion and copy rhythm.\n\n**Voice**: write the copy the way the brand writes. Reuse its own terms and phrases (product names, the words in its headlines and buttons), match its sentence length and tone (plain, bold, technical, warm), and keep its capitalisation. Facts, numbers and claims come only from the user or the brand's own copy.\n\n## Making it look good\n\nRead `reference/craft.md` before building titles, graphics or captions: formats, safe zones, type sizes, pacing, motion timing, colour, audio levels and a review checklist.\n\n- **Fill the frame.** A full-frame scene's root is the composition's size (1920\xD71080 or 1080\xD71920): lay content across it on the grid (16:9: x 160\u20131760 px), with headlines at the craft sizes (hero 110\u2013160 px). A small block of content in one corner of a big empty frame reads as unfinished.\n- **Designed text**: if the editor has a text-style tool (`add_text_style`), start there. For anything custom, use **HTML clips** (`clip/add` with `kind: \"html\"`) built from the tested patterns in `reference/html-recipes.md` (real CSS layout, fonts, gradients, `@keyframes`). Images inside HTML are `src=\"asset:<id>\"`.\n- **Moving whole clips**: `animate_clip` (presets: in, loop, out, easing). Hand-made moves are `set_keyframes`. `reference/motion.md` has the presets and the useful moves (punch-in, picture-in-picture, push-in, ducking).\n- **Looks**: `add_effect`. At most one look per video, low intensities (0.1\u20130.4).\n- **Audio**: music under speech at about 0.15\u20130.3, fade in 1 s and out 2 s, ends with the video.\n\n## Before you finish\n\nGo through `reference/craft.md` \u2192 Review checklist against the project state: text inside safe zones and readable sizes, no overlaps between captions and lower thirds, no gaps or black frames between clips, no clip running past the end, consistent fonts and colours.\n\n## Hand-off\n\nReply in one to three sentences: what the video now is or what changed (times in seconds, names over ids), and anything the user should check (a claim on screen, a music choice). Suggest previewing the parts you changed.",
2524
+ "files": {
2525
+ "reference/commands.md": '# Commands (short reference)\n\nThe source of truth is `get_command_schema` on the live server. Times are integer microseconds. `?` = optional.\n\n## Project, assets, tracks\n\n| Command | Payload |\n| --- | --- |\n| `project/set-settings` | `{ width?, height?, fps?, name? }` |\n| `asset/add` | `{ id, kind: "video"\\|"audio"\\|"image"\\|"font", src, durationUs?, width?, height?, fps?, name? }`; fonts: `family`, `weight?`, `style?`, `weightRange?`; provenance: `source?`, `license?`, `attribution?` |\n| `asset/remove` | `{ id }` (fails while a clip uses it) |\n| `track/add` | `{ id, kind: "video"\\|"audio", name?, index? }` (later = on top) |\n| `track/remove` / `track/rename` / `track/reorder` | `{ id }` / `{ trackId, name }` / `{ trackId, index }` |\n| `track/set-property` | `{ trackId, muted?, solo?, locked?, hidden? }` |\n\n## Clips\n\n`clip/add` payload by `kind` (all take `id`, `trackId`, `startUs`, `durationUs`, `transform?: { x, y, scale, rotation, opacity }`):\n\n| kind | Extra fields |\n| --- | --- |\n| `video` | `assetId`, `trimStartUs?` (in-point in the file), `volume?`, `fadeInUs?`, `fadeOutUs?` |\n| `audio` | same as video (goes on an audio track) |\n| `image` | `assetId` |\n| `text` | `text` (`\\n` for lines), `fontFamily?`, `fontSizePx?` (48), `color?`, `fontWeight?`, `fontStyle?`, `lineHeight?`, `letterSpacing?` (em), `textAlign?` |\n| `caption` | `words: [{ text, startUs, durationUs }]` (times relative to the clip), `style?` (see below) |\n| `html` | `template`, `params?`, `widthPx?`, `heightPx?`, `animated?`, `animationOffsetUs?` |\n\nCaption `style`: `preset` (`plain`, `highlight`, `karaoke`, `pop`, `reveal`), `display` (`block`\\|`word`), `fontFamily`, `fontSizeFrac` (0.06 = 6% of frame height), `fontWeight`, `color`, `highlightColor`, `backgroundColor`, `activeBackgroundColor`, `textTransform` (`uppercase`), `strokeColor`, `strokeWidthFrac`, `shadowColor`, `shadowBlurFrac`, `shadowOffsetFrac`, `lineHeight`, `letterSpacing`. Style merges on `clip/set-property`; `null` clears optional fields.\n\n| Command | Payload |\n| --- | --- |\n| `clip/remove` | `{ clipId }` |\n| `clip/move` | `{ clipId, startUs?, trackId? }` |\n| `clip/trim` | `{ clipId, startUs?, durationUs?, trimStartUs? }` |\n| `clip/split` | `{ clipId, atUs, newClipId? }` (`atUs` is timeline time) |\n| `clip/duplicate` | `{ clipId, newClipId?, startUs?, trackId? }` |\n| `clip/set-property` | `{ clipId, \u2026fields of its kind }`: `transform` merges; html: `template`, `params` (merge), `unsetParams`, `widthPx`/`heightPx` (`null` = composition size), `animated`; custom kinds: `props` (merge), `unsetProps` |\n| `clip/set-speed` | `{ clipId, speed, keepDuration?, ripple? }`: video/audio, 0.0625\u201316 (0 = freeze, video only). Default keeps the footage, so the length changes; `keepDuration: true` keeps the length; `ripple: true` moves later clips on the track |\n| `clip/freeze` | `{ clipId, atUs, durationUs, freezeClipId?, rightClipId?, ripple? }`: hold the frame at `atUs` (timeline time; the clip\'s end holds its last frame). Splits the clip and pushes the rest of the track back unless `ripple: false` |\n\nThere is no ripple delete. To remove a section and close the gap, `clip/split` at both ends, `clip/remove` the middle, and `clip/move` every later clip left by the removed length, or rebuild the sequence with `mc recut`.\n\n## Keyframes, effects, transitions\n\n| Command | Payload |\n| --- | --- |\n| `keyframe/set` | `{ clipId, property: x\\|y\\|scale\\|rotation\\|opacity\\|volume, timeUs, value, easing?, anchor?: "start"\\|"end" }` |\n| `keyframe/remove` | `{ clipId, property, timeUs, anchor? }` |\n| `keyframe/clear` | `{ clipId, property? }` |\n| `effect/add` | `{ clipId, kind, params?, enabled?, index?, effectId? }` |\n| `effect/update` | `{ clipId, effectId, params?, enabled?, region? }` (params merge; `region: null` = whole clip) |\n| `effect/set-region-keyframe` | `{ clipId, effectId, property: x\\|y\\|width\\|height, timeUs, value, easing?, anchor? }` |\n| `effect/clear-region-keyframes` | `{ clipId, effectId, property? }` |\n| `effect/remove` / `effect/reorder` | `{ clipId, effectId }` / `{ clipId, effectId, index }` |\n\n`effect/add` takes `region?: { x, y, width, height, shape?: "rect"|"ellipse", feather?, invert? }` (fractions of the CLIP\'s frame, top-left origin) to apply the effect to part of the clip only: `blur` or `pixelate` a password field, or `colorAdjust` with `brightness: -0.5` and `invert: true` for a spotlight. The region follows the clip through zooms and moves.\n\nEffect ids are generated unless you pass `effectId` in `effect/add`. Pass one (e.g. `"grade"`) so you can update it later without reading the project JSON.\n| `transition/add` | `{ kind, fromClipId, toClipId, durationUs, params?, id? }` |\n| `transition/update` / `transition/remove` | `{ transitionId, durationUs?, params? }` / `{ transitionId }` |\n\nTransition kinds: `crossDissolve`, `dipToBlack`, `dipToWhite`, `wipe` and `slide` (`params.direction`: `left`, `right`, `up`, `down`).\n\n## Markers and plugins\n\n| Command | Payload |\n| --- | --- |\n| `marker/add` | `{ id?, timeUs, label, durationUs?, data? }`: a chapter, tutorial step or note (doesn\'t render) |\n| `marker/update` | `{ markerId, timeUs?, label?, durationUs?\\|null, data?\\|null }` |\n| `marker/remove` | `{ markerId }` |\n| `plugin/add` | `{ name, version, url, integrity?, replace? }`: a plugin module whose custom kinds the document uses (the server must run with `--allow-plugins`) |\n| `plugin/remove` | `{ name }` |\n\n## Effect kinds (param = default)\n\n| Category | Kinds |\n| --- | --- |\n| color | colorAdjust(brightness=0, contrast=0, saturation=0, hue=0), mono(intensity=1), invert, warm(intensity=1), cool(intensity=1), hueShift(hue=90), vibrance(amount=0.5), exposure(stops=0.5), contrastCurve(intensity=0.7), gamma(gamma=1.4), channelSwap, duotone(intensity, shadow=#1e1b4b, highlight=#f472b6), gradientMap(intensity, dark, mid, light), colorPop(intensity, color=#ef4444, tolerance=0.08), solarize, threshold, posterize(levels=4) |\n| film | sepia, vintage(0.8), polaroid, kodachrome, technicolor, thermal, grain(intensity=0.3), tealOrange, noir, bleachBypass, crossProcess, faded, matte, goldenHour, moonlight, cyberpunk, vaporwave, cyanotype, infrared, lomo, nightVision (all `intensity`, default 1) |\n| stylize | pixelate, halftone, dotMatrix, crosshatch, neonEdges(intensity, color), sketch, emboss, sharpen(amount=0.8), toon, oilPaint, hexPixelate, retro8bit, dither, frostedGlass |\n| glitch | rgbSplit, scanlines, crt, vhs, glitch, tvStatic, lensFringe |\n| light | blur(amount=0.02), vignette(intensity=0.6, size=0.5), glow(intensity=0.6, radius, threshold=0.55), dreamy, tiltShift(blur, focus=0.5, band=0.25), zoomBlur, motionBlur(distance, angle=0), colorVignette(intensity=0.8, color=#7c3aed, size=0.5), lightLeak(intensity=0.8, color=#ff7a18) |\n| distort | fisheye, pinch, swirl, wave, ripple, mirrorX, mirrorY, kaleidoscope |\n| frame | chromaKey(color=#00ff00, similarity=0.4, smoothness=0.1, spill=0.1), letterbox(size=0.12), roundedCorners(radius=0.08) |\n\nSizes are fractions of the frame. Film looks at intensity 1 are strong: start at 0.2\u20130.4.\n\n## Errors\n\nA failed `dispatch` / `apply_commands` changes nothing. `apply_commands` reports `failedIndex` and the error:\n\n| `kind` / code | Fix |\n| --- | --- |\n| `unknown-command` | Use a type from `list_commands` |\n| `invalid-payload` | Per-field issues listed; check `get_command_schema` |\n| `clip-not-found`, `track-not-found`, `asset-not-found`, `effect-not-found`, `transition-not-found`, `keyframe-not-found` | Read ids from `get_state` (or `get_project_json` for effect ids); earlier commands in the same batch count |\n| `duplicate-id` | Pick a new id |\n| `not-adjacent`, `different-tracks`, `duplicate-boundary` | Transition clips must touch on one track; one transition per cut |\n| `insufficient-handles` | Trim the clips so each has half the transition\'s length of extra media past the cut, or shorten the transition |\n| `not-html`, `not-text` | That field belongs to another clip kind |\n| `font-needs-family` | Font assets need `family` |\n',
2526
+ "reference/craft.md": '# Video craft\n\nConcrete defaults for videos people watch to the end. Break them when the brief says to, not by accident.\n\n## Formats\n\n| Target | Settings (`project/set-settings`) | Notes |\n| --- | --- | --- |\n| YouTube, website, landing page, slides | 1920\xD71080, 30 fps | Default. 60 fps only for screen recordings with fast scrolling |\n| Reels, TikTok, Shorts, Stories | 1080\xD71920, 30 fps | Design for sound off and a thumb over the bottom |\n| Instagram/LinkedIn feed | 1080\xD71350 (4:5) or 1080\xD71080 | 4:5 takes more of the feed |\n| Footage shot at 24/25/29.97 | Match the footage\'s fps | `fps: 29.97` is stored exactly |\n\nSet the format before adding clips. Changing it later means repositioning everything (the `reformat` skill does that).\n\n## Safe zones\n\nKeep text and faces inside these margins (fractions of the frame):\n\n| Shape | Keep clear |\n| --- | --- |\n| 16:9 | 5% on every side (title safe). Lower thirds sit at `y \u2248 0.82\u20130.86` |\n| 9:16 | Top 12% (status bar, account name), bottom 22% (caption, buttons), right 15% (like/share column). Captions at `y \u2248 0.68\u20130.72`, titles at `y \u2248 0.25\u20130.4` |\n| 1:1, 4:5 | 6% on every side |\n\n## Type\n\n- One font family per video, two weights (e.g. 800 for headlines, 500 for body). With no brand font, use **Inter** (OFL): download `InterVariable.woff2` from https://github.com/rsms/inter/releases into the folder and register it once: `{ "type": "asset/add", "payload": { "id": "inter", "kind": "font", "src": "InterVariable.woff2", "family": "Inter", "weightRange": [100, 900] } }`. System fonts differ between the machine you preview on and the one that exports, so a registered font also keeps the look stable. In an editor, register the font by URL (`src` an https URL) or use one the project already has.\n- Sizes for a 1920\xD71080 frame (scale by frame width for other shapes; on 1080\xD71920 use roughly the same pixel sizes, since the width is about the same):\n\n| Role | Size | Weight | Letter spacing |\n| --- | --- | --- | --- |\n| Hero headline (\u2264 6 words) | 110\u2013160 px | 800 | \u22120.02 to \u22120.04 em |\n| Headline | 80\u201396 px | 800 | \u22120.02 em |\n| Body / supporting line | 34\u201344 px | 500 | 0 |\n| Eyebrow / label | 24\u201328 px | 600, uppercase | 0.2\u20130.32 em |\n| Captions | `fontSizeFrac` 0.045\u20130.06 (16:9), 0.035\u20130.045 (9:16) | 700\u2013800 | 0 |\n\n- Max ~7 words on screen at once for a headline, ~12 for a supporting line. If it needs more, it needs another scene.\n- Reading time: on screen for at least `0.3 s \xD7 words + 1 s`. A 5-word title needs \u2265 2.5 s settled (after its entrance finishes).\n- Contrast: white or near-white text on dark, or dark on light. Over footage, add a scrim (a gradient behind the text) or a text shadow, never bare text on a busy shot.\n- Left-align multi-line text. Centre only short single lines and end cards.\n\n## Colour\n\n- Pick a palette before building: background, ink (text), muted text, one accent, optionally a second accent. Write them as CSS variables in every HTML clip so scenes match.\n- Dark, slightly coloured backgrounds (#0a0d18 rather than #000) look finished. Pure black and pure white backgrounds look like placeholders.\n- Accents go on one thing per scene: the key word, the number, the button.\n- Effects as a look: one per video, low intensity (`grain` 0.1\u20130.15, `vignette` 0.2\u20130.3, `warm`/`cool` 0.2\u20130.4). Never stack several looks.\n\n## Pacing\n\n| Content | Shot or scene length |\n| --- | --- |\n| Social promo, hook | First 1.5\u20132 s must say what this is. No logo intro first |\n| Motion-graphics scene | 2.5\u20134 s each, including the entrance |\n| Talking head | Cut every pause > 0.4 s. Punch in (scale 1.1\u20131.2) on every other segment, or every 5\u20138 s, to hide jump cuts |\n| B-roll over voice | 2\u20134 s per shot |\n| End card | 2.5\u20133.5 s, holds still long enough to read the URL |\n\n- Total length: social 15\u201345 s, product launch 30\u201375 s, explainer 60\u2013120 s. Shorter is almost always better.\n- Structure for promos: hook \u2192 problem or promise \u2192 2\u20134 proof points (one per scene) \u2192 call to action.\n- Enter elements in reading order with 80\u2013150 ms between them (stagger). Everything arriving at once reads as a slide, not a video.\n\n## Motion\n\n- Entrances: 400\u2013700 ms, ease-out (`cubic-bezier(.2,.8,.2,1)` in CSS, `smooth` or `snappy` presets). Exits: 250\u2013400 ms, ease-in, or let a transition carry them.\n- Distances: slide 40\u201380 px (or `OFFSET` 0.12 for presets), scale from 0.9\u20130.95 (subtle) or 0.6 with overshoot (pop) for one hero element per scene.\n- One continuous motion per scene keeps it alive: a slow Ken Burns on the background (`--loop kenburns`), a playhead sweep, a counter.\n- Between scenes: `crossDissolve` 400\u2013600 ms by default. `slide` or `wipe` only when there\'s a reason (a list advancing, before/after). `dipToBlack` for a change of chapter.\n- No spins, bounces or glitches unless the brief is playful.\n\n## Audio\n\n| Situation | Volume |\n| --- | --- |\n| Music alone | 0.8\u20131.0 |\n| Music under a voice | 0.15\u20130.3, or duck: keyframe `volume` down 300 ms before speech, back up after |\n| Voice | 1.0 (raise up to 1.5 if quiet) |\n| Sound effects (whoosh on transitions, click on CTA) | 0.4\u20130.7, sparingly |\n\n- Music: fade in 0.5\u20131 s (`fadeInUs`), fade out 1.5\u20133 s ending with the video. Trim the clip to end exactly at the video\'s end.\n- Cut on the beat when you can: line scene starts up with strong beats (ask the user for the BPM, or listen for where the track changes).\n- If there\'s no music file, say so. Don\'t invent one; offer to make the video without music or ask the user to add a licensed track to the folder.\n\n## Honest content\n\n- Numbers, claims, customer names and quotes on screen must come from the user\'s material. If you\'re not sure a claim is true, ask or leave it out.\n- Don\'t put logos or brand names of other companies on screen unless the user supplied them.\n\n## Review checklist\n\nGo through it on the contact sheet and on full-size `preview_frame`s (in an editor without previews: against the project state, in numbers):\n\n1. **First frame and first 2 s**: is there something to look at immediately, and does it say what the video is?\n2. **Legibility**: every piece of text readable at phone size, on screen long enough (reading-time rule above), contrast OK over its background.\n3. **Overflow and clipping**: nothing runs off the frame or out of its HTML raster (`widthPx` \xD7 `heightPx` cuts content off), no text wraps into an orphan word.\n4. **Safe zones** for the target platform.\n5. **Black or empty frames**: gaps between clips, a scene ending before the next starts, an animation starting from `opacity: 0` and never arriving.\n6. **Consistency**: same fonts, sizes, colours and positions for the same roles across scenes.\n7. **Pacing**: no scene that just sits there; nothing too fast to read.\n8. **Audio**: music level under voice, fades at the ends, no audio running past the last frame.\n9. **Ending**: CTA or end card holds still, then the video ends (no trailing black).\n',
2527
+ "reference/html-recipes.md": '# HTML clip recipes\n\nTested patterns for titles and motion graphics. All of them render the same in `preview_frame` and in export.\n\n## Rules (the renderer is strict)\n\n- The template is parsed as **XML**: close every tag (`<br/>`, `<img .../>`), quote attributes, write `&amp;`, and write `<` / `>` inside CSS as `&lt;` / `&gt;` (e.g. `@property` syntax `\'&lt;integer&gt;\'`).\n- Inline `<svg>` needs `xmlns="http://www.w3.org/2000/svg"` or it draws nothing.\n- No scripts, no network: images are `src="asset:<id>"`, fonts are font assets named by `family` in the CSS, anything else is a `data:` URI.\n- Static clips (no `animated`) render once. Clips with `"animated": true` render every frame and play CSS `@keyframes`:\n - Delay elements with the custom property `--d` (e.g. `style="--d:.4s"`). Never set `animation-delay`; the renderer controls it.\n - Always use `animation-fill-mode: both` (the `both` keyword) so elements stay at their end state.\n - `--T` is the clip\'s length in seconds: `--d: calc(var(--T) - .5s)` starts an exit 0.5 s before the end. `--d` inherits, so give animated children their own.\n - CSS transitions don\'t play. Use keyframes.\n - Infinite animations work, but every animated clip costs a full render per frame. Prefer **one animated full-frame clip per scene** over many small animated clips, and make anything that doesn\'t move a static clip.\n- Raster size: `widthPx` \xD7 `heightPx` (default: the composition size). Content outside it is cut off. For a full-frame scene, give the root element the composition\'s exact pixel size.\n- In JSON, the template is a string: use single quotes inside CSS and double quotes for attributes (escaped as `\\"`), or no quotes where CSS allows.\n\n## Base kit\n\nPut this `<style>` at the top of every scene so all scenes share one look. Change the palette variables per brand.\n\n```html\n<div class="root"><style>\n*{box-sizing:border-box;margin:0}\n.root{--bg:#0a0d18;--ink:#fff;--muted:#a8b2cc;--a1:#8b7bff;--a2:#22d3ee;--card:#121828;--line:#ffffff1c;\n width:1920px;height:1080px;position:relative;overflow:hidden;font-family:Inter,sans-serif;color:var(--ink)}\n@keyframes rise{from{transform:translateY(48px);opacity:0;filter:blur(10px)}}\n@keyframes fadein{from{opacity:0}}\n@keyframes pop{0%{transform:scale(.6);opacity:0}70%{transform:scale(1.06);opacity:1}100%{transform:scale(1)}}\n@keyframes reveal{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}\n@keyframes growx{from{transform:scaleX(0)}}\n@keyframes out{to{opacity:0;transform:translateY(-24px)}}\n.r{display:inline-block;animation:rise .7s cubic-bezier(.2,.8,.2,1) both}\n.f{animation:fadein .6s ease-out both}\n.p{display:inline-block;animation:pop .55s cubic-bezier(.2,.8,.2,1) both}\n.grad{background:linear-gradient(90deg,var(--a1),var(--a2));-webkit-background-clip:text;background-clip:text;color:transparent}\n.eyebrow{font-weight:600;font-size:26px;letter-spacing:.32em;color:var(--muted)}\n.card{border-radius:28px;background:var(--card);border:1px solid var(--line);box-shadow:0 40px 120px #0008}\n</style>\n<!-- scene content -->\n</div>\n```\n\nFor 1080\xD71920, change the root size to `width:1080px;height:1920px`.\n\n## Background (static, full frame, bottom track)\n\n```html\n<div style="width:1920px;height:1080px;background:radial-gradient(900px 600px at 15% 20%,#2a1f6b 0%,transparent 70%),radial-gradient(800px 600px at 90% 85%,#0e4a5c 0%,transparent 70%),#0a0d18"></div>\n```\n\nOne clip spanning the whole video, then `mc animate --clip bg --loop kenburns` and optionally `effect/add` `grain` with `{ "intensity": 0.12 }`. The slow drift keeps static scenes alive.\n\n## Kinetic headline (word by word)\n\n```html\n<div style="position:absolute;left:160px;top:360px">\n <div class="eyebrow f" style="--d:.05s">MIRAICLIP</div>\n <div style="height:28px"></div>\n <div style="font-weight:800;font-size:118px;line-height:1.08;letter-spacing:-.02em">\n <span class="r" style="--d:.20s">Build</span> <span class="r" style="--d:.32s">video</span> <span class="r" style="--d:.44s">editors,</span>\n </div>\n <div class="grad" style="font-weight:800;font-size:118px;line-height:1.08;letter-spacing:-.02em;animation:reveal 1s cubic-bezier(.65,0,.35,1) both;--d:1.05s">not video editor plumbing.</div>\n</div>\n```\n\nWord spans need the space between them outside the spans. 0.12 s per word. A gradient line is revealed as a whole (`reveal`), not word by word, so the gradient stays continuous.\n\n## Lower third (over footage)\n\nStatic raster, positioned with `transform`, animated with presets: `widthPx: 900, heightPx: 140, transform: { "x": 0.3, "y": 0.84 }`, then `mc animate --clip lt --in left --out fade --easing snappy`.\n\n```html\n<div style="display:flex;align-items:center;gap:22px;height:140px;padding:0 34px;border-radius:20px;background:#0a0d18e6;border:1px solid #ffffff22;font-family:Inter,sans-serif;color:#fff">\n <div style="width:8px;height:76px;border-radius:4px;background:linear-gradient(#8b7bff,#22d3ee)"></div>\n <div><div style="font:800 46px Inter,sans-serif">{{name}}</div><div style="font:500 30px Inter,sans-serif;color:#c3cbe0">{{role}}</div></div>\n</div>\n```\n\nPut the text in `params` (`{ "name": "Ada Lovelace", "role": "Founder, Analytical Co." }`) so it can be changed with `clip/set-property` without touching the markup. Hold it on screen 4\u20136 s.\n\n## Stat counters\n\n```html\n<style>\n@property --n{syntax:\'&lt;integer&gt;\';inherits:false;initial-value:0}\n@keyframes count{to{--n:var(--to)}}\n.num{font-weight:800;font-size:150px;line-height:1;letter-spacing:-.04em;counter-reset:n var(--n);animation:count 1.4s cubic-bezier(.2,.8,.2,1) both}\n.num::after{content:counter(n)}\n</style>\n<div class="card r" style="--d:.35s;width:507px;height:400px;padding:56px 48px">\n <div class="grad"><span class="num" style="--to:125;--d:.5s"></span><span style="font-size:64px;margin-left:6px">min</span></div>\n <div style="margin-top:28px;font-size:32px;line-height:1.35;color:var(--muted);font-weight:500">timeline with 4,464 clips</div>\n</div>\n```\n\nCounters are integers. For decimals or thousands separators, count the integer part and write the rest as static text. Three cards in a row with 0.15 s stagger (`display:flex;gap:40px`) fill a 16:9 frame.\n\n## Code card\n\n```html\n<style>.ln{font-family:\'JetBrains Mono\',Consolas,Menlo,monospace;font-size:28px;line-height:1.75;white-space:pre;color:#d7dcf0;animation:rise .5s cubic-bezier(.2,.8,.2,1) both}\n.k{color:#8b7bff}.s{color:#5eead4}.c{color:#6b7591}</style>\n<div class="card f" style="--d:.35s;position:absolute;left:900px;top:300px;width:860px;padding:44px 48px">\n <div class="ln" style="--d:.6s">project.<span class="k">dispatch</span>({</div>\n <div class="ln" style="--d:.75s"> type: <span class="s">"clip/add"</span>,</div>\n <div class="ln" style="--d:.9s">})</div>\n</div>\n```\n\nKeep lines under ~45 characters at 28 px in an 860 px card, or the text runs out of the card. Check with `preview_frame`.\n\n## Checklist / steps\n\n```html\n<style>.row{display:flex;align-items:center;gap:22px;margin-top:22px;padding:24px 30px;border-radius:20px;background:var(--card);border:1px solid var(--line);font-size:30px;animation:rise .5s cubic-bezier(.2,.8,.2,1) both}\n.ok{width:44px;height:44px;border-radius:50%;background:var(--a2);color:#04121a;font-weight:900;font-size:26px;display:flex;align-items:center;justify-content:center}</style>\n<div style="position:absolute;left:900px;top:260px;width:860px">\n <div class="row" style="--d:1.0s"><div class="ok">\u2713</div>Cut the pauses</div>\n <div class="row" style="--d:1.3s"><div class="ok">\u2713</div>Add captions</div>\n</div>\n```\n\n0.3 s between rows so each can be read as it lands.\n\n## Bar chart\n\n```html\n<style>@keyframes growy{from{transform:scaleY(0)}}\n.bar{position:absolute;bottom:0;width:120px;border-radius:14px 14px 0 0;transform-origin:bottom;animation:growy .8s cubic-bezier(.2,.8,.2,1) both}</style>\n<div style="position:absolute;left:300px;top:300px;width:1320px;height:560px;border-bottom:2px solid #ffffff33">\n <div class="bar" style="left:80px;height:180px;background:#ffffff33;--d:.3s"></div>\n <div class="bar" style="left:320px;height:290px;background:#ffffff33;--d:.45s"></div>\n <div class="bar" style="left:560px;height:520px;background:linear-gradient(#8b7bff,#22d3ee);--d:.6s"></div>\n</div>\n```\n\nHighlight the bar the story is about; mute the rest. Label values above bars with a `.f` text that fades in after its bar.\n\n## Line drawing (SVG)\n\n```html\n<style>@keyframes draw{from{stroke-dashoffset:1200}}.path{stroke-dasharray:1200;animation:draw 1.4s ease-out both}</style>\n<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="400" style="position:absolute;left:360px;top:420px">\n <path class="path" d="M10 380 L300 250 L600 300 L1190 20" fill="none" stroke="#22d3ee" stroke-width="10" stroke-linecap="round"/>\n</svg>\n```\n\n`stroke-dasharray` must be at least the path\'s length.\n\n## Screenshot in a browser frame\n\nRegister the screenshot as an image asset (`id: "shot"`), then:\n\n```html\n<div class="card r" style="--d:.2s;position:absolute;left:260px;top:170px;width:1400px;overflow:hidden">\n <div style="height:52px;display:flex;align-items:center;gap:10px;padding:0 22px;background:#1a2033">\n <span style="width:14px;height:14px;border-radius:50%;background:#ff5f57"></span><span style="width:14px;height:14px;border-radius:50%;background:#febc2e"></span><span style="width:14px;height:14px;border-radius:50%;background:#28c840"></span>\n <span style="margin-left:24px;font-size:22px;color:#8c95ad">{{url}}</span>\n </div>\n <img src="asset:shot" style="display:block;width:1400px;height:788px;object-fit:cover;object-position:top"/>\n</div>\n```\n\nFor a slow scroll through a tall screenshot, animate `object-position` from `top` to `bottom` over the scene, or put the screenshot in its own image clip and keyframe `y`.\n\n## Scrim behind text over footage\n\nA static full-width HTML clip on the track under the text: `heightPx: 420, transform: { "y": 0.81 }`:\n\n```html\n<div style="width:1920px;height:420px;background:linear-gradient(transparent,#000000cc)"></div>\n```\n\n## Call to action / end card\n\n```html\n<style>@keyframes pulse{0%,100%{box-shadow:0 0 0 0 #22d3ee66}50%{box-shadow:0 0 0 22px #22d3ee00}}\n.c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}\n.cta{margin-top:70px;padding:26px 54px;border-radius:999px;background:#fff;color:#0a0d18;font-weight:700;font-size:40px;animation:pulse 1.6s ease-in-out 3 both}</style>\n<div class="c">\n <div class="p" style="--d:.1s;font-weight:800;font-size:180px;letter-spacing:-.045em">{{brand}}</div>\n <div class="f" style="--d:.55s;margin-top:18px;font-size:42px;color:var(--muted);font-weight:500">{{tagline}}</div>\n <div class="f" style="--d:.9s"><div class="cta" style="--d:1.3s">{{url}}</div></div>\n</div>\n```\n\nThe pulse runs a fixed number of times (`3`) so the card ends still. Hold the end card \u2265 2.5 s.\n\n## Progress bar (9:16 stories, tutorials)\n\nAn animated clip on the top track spanning the whole video: `"animated": true, "widthPx": 1080, "heightPx": 12, "transform": { "y": 0.006 }`.\n\n```html\n<div style="width:1080px;height:12px;background:#ffffff26"><style>@keyframes growx{from{transform:scaleX(0)}}</style><div style="height:12px;background:#fff;transform-origin:left;animation:growx var(--T) linear both"></div></div>\n```\n\n`var(--T)` makes the bar exactly as long as the clip.\n\n## Beat-synced, playful style\n\nTested in the "funky launch" gallery sample. Pick the music\'s BPM first; then one beat = `60 / BPM` s, one bar = 4 beats. Give every scene a whole number of bars and write every `--d` in beats (`--d: calc(<beats> * <beat>s)`, or compute it when you generate the template). Hard cuts on bar lines feel energetic; save dissolves for calm videos.\n\n| Pattern | CSS |\n| --- | --- |\n| Colour flash per beat | `.flash{position:absolute;inset:0;animation:flash <4 beats>s step-end both}` with `@keyframes flash{0%{background:#2fd3b9}25%{background:#ff6f5e}50%{background:#ffb423}75%{background:#39a7ff}}` |\n| Sticker look | `border:4px solid #0c1010;box-shadow:8px 9px 0 #0c1010;border-radius:22px` (hard offset shadow, no blur) |\n| Boing in (overshoot) | `@keyframes boing{0%{transform:scale(0) rotate(-12deg)}60%{transform:scale(1.12) rotate(3deg)}80%{transform:scale(.96)}100%{transform:scale(1)}}` .6s |\n| Slam a word | `@keyframes slam{0%{transform:scale(2.4);opacity:0;filter:blur(18px)}55%{transform:scale(.94);opacity:1;filter:blur(0)}100%{transform:scale(1)}}` .45s, one word or letter per beat |\n| Retro offset shadow | `text-shadow:10px 10px 0 #ff6f5e,20px 20px 0 #39a7ff` |\n| Bounce on every beat | `animation:bob <beat>s cubic-bezier(.3,0,.3,1) infinite both` with a squash/stretch keyframe |\n| Sunburst | `repeating-conic-gradient(#ffffff22 0 8deg,transparent 8deg 22deg)` on a 2400 px square, slowly rotated |\n| Fly in from the right | `@keyframes fromright{0%{transform:translateX(120%) skewX(-12deg)}70%{transform:translateX(-4%) skewX(3deg)}100%{transform:none}}` |\n\n### Typewriter\n\n```html\n<style>@keyframes type{from{width:0}to{width:calc(var(--n) * 1ch)}}\n.tw{display:inline-block;overflow:hidden;white-space:nowrap;vertical-align:bottom}</style>\n<span class="tw" style="--d:.5s;--n:43;animation:type 2.5s steps(43) both">make a funky launch video for miraiclip.com</span>\n```\n\nMonospace font only, `--n` = character count, and **no `letter-spacing`** (it makes the text wider than `n \xD7 1ch` and the end gets cut off).\n\n### One element after another in the same spot\n\nShow item *i* from its start beat and hide it at the next one with two nested wrappers (one animation each, because `--d` sets the delay for every animation on an element):\n\n```html\n<style>@keyframes on{from{opacity:0}to{opacity:1}}@keyframes off{from{opacity:1}to{opacity:0}}\n.show{animation:on .01s step-end both}.hide{animation:off .01s step-end both}</style>\n<div class="hide" style="--d:2.22s;position:absolute;inset:0"><div class="show" style="--d:1.11s;position:absolute;inset:0">\u2026item 2\u2026</div></div>\n```\n\n### Screenshot montage\n\nFor one screenshot per beat, use **image clips** on their own track (one clip per beat, hard cuts, a small `scale` keyframe bump 0.8 \u2192 0.74 over 180 ms, `roundedCorners`), not images inside an animated HTML clip: an animated HTML clip re-inlines every image on every frame. Put titles and labels in one HTML overlay clip above them.\n',
2528
+ "reference/motion.md": '# Motion: presets and keyframes\n\nTwo ways to move things:\n\n| Moving\u2026 | Use |\n| --- | --- |\n| A whole clip (footage, photo, logo, a static HTML card, a caption block) | Animation presets via `mc animate`, or `keyframe/set` |\n| Parts inside a title or graphic (words, cards, bars, counters) | CSS `@keyframes` in an animated HTML clip (see `html-recipes.md`) |\n\n## Animation presets\n\nIn a Miraiclip editor, the `animate_clip` tool takes these same presets (`in`, `loop`, `out`, `easing`), on the clip as it is now; the `mc` commands below are for Claude Code.\n\nThe same presets as the Miraiclip editor\'s animation panel. A recipe has up to three slots: **in** (from the clip\'s start), **loop** (between in and out), **out** (into the end, end-anchored so it follows trims).\n\n```bash\nMC="node ${CLAUDE_PLUGIN_ROOT}/scripts/mc.mjs"\n$MC presets # list them\n$MC animate --clip title --in up --out fade # 600 ms in, 500 ms out, smooth\n$MC animate --clip logo --in pop --in-ms 500 --easing snappy\n$MC animate --clip photo-1,photo-2,photo-3 --loop kenburns\n$MC animate --clip badge --in zoom --loop pulse --out fade\n$MC animate --clip title # no slots: removes the animation\n```\n\n| Slot | Presets |\n| --- | --- |\n| in / out | `fade`, `up`, `down`, `left`, `right` (slide 12% of the frame + fade), `zoom` (from 60%), `spin`, `pop` (0 \u2192 110% \u2192 100%) |\n| loop | `pulse` (scale \xB16%, 1.2 s), `float` (y \xB12%, 2 s), `sway` (\xB13\xB0, 2.4 s), `kenburns` (scale +15% and drift over the clip) |\n| easing | `smooth` (ease-out in, ease-in out), `snappy` (fast start, long settle), `linear` |\n\n- The command reads the **saved** project file, so `apply_commands` the clip first, then run `animate`, then `apply_commands` its output. Presets animate from the clip\'s current `transform`, so set position and scale first.\n- Applying a recipe replaces the clip\'s visual keyframes (x, y, scale, rotation, opacity). Volume keyframes stay.\n- In and out share the clip: if they don\'t fit, both shrink.\n\n## Hand-made keyframes\n\n```json\n{ "type": "keyframe/set", "payload": { "clipId": "photo", "property": "scale", "timeUs": 0, "value": 1, "easing": "easeOut" } }\n{ "type": "keyframe/set", "payload": { "clipId": "photo", "property": "scale", "timeUs": 4000000, "value": 1.2 } }\n{ "type": "keyframe/set", "payload": { "clipId": "photo", "property": "opacity", "timeUs": 400000, "anchor": "end", "value": 1, "easing": "easeIn" } }\n{ "type": "keyframe/set", "payload": { "clipId": "photo", "property": "opacity", "timeUs": 0, "anchor": "end", "value": 0 } }\n```\n\n- Properties: `x`, `y`, `scale`, `rotation` (degrees), `opacity`, `volume`.\n- `timeUs` is relative to the clip\'s start, or with `anchor: "end"`, measured back from its end.\n- The easing on a keyframe shapes the segment **leaving** it. Values: `linear`, `hold`, `easeIn`, `easeOut`, `easeInOut`, or a bezier `{ "kind": "bezier", "x1": 0.2, "y1": 0.8, "x2": 0.2, "y2": 1 }`.\n- `keyframe/clear` with a `property` removes all keyframes of that property.\n\n## Useful moves\n\n| Move | How |\n| --- | --- |\n| Punch-in on a talking head (hide a jump cut) | `clip/set-property` `{ "transform": { "scale": 1.15 } }` on every other segment. Static, not animated. Keep the face in frame: adjust `y` a little (0.45\u20130.48) if the head gets cut |\n| Slow push-in on a photo or wide shot | `--loop kenburns`, or scale 1 \u2192 1.1 over the clip, `linear` |\n| Picture-in-picture | `transform: { "x": 0.8, "y": 0.78, "scale": 0.32 }` on an upper track, plus `effect/add` `roundedCorners`, then `--in pop` |\n| Split screen | Two clips on two tracks, `x: 0.25` and `x: 0.75`, `scale` so each fills half; crop is not available, so use footage that frames well |\n| Logo sting | Logo image clip, `--in pop --easing snappy`, then `--loop pulse` only if it stays on screen > 3 s |\n| Music duck under a line of voice | `volume` keyframes: 0.8 \u2192 0.2 over 300 ms before the line, back to 0.8 over 500 ms after |\n'
2529
+ }
2530
+ },
2531
+ {
2532
+ "name": "launch-video",
2533
+ "description": "Make a product or service launch, feature announcement, promo, intro or teaser film in the editor, in the brand's own look and voice \u2014 directed for this brand (a treatment), approved, then built as continuous motion-graphics acts. Use when the user wants a launch, promo or intro video and has no footage, or only screenshots and a logo.",
2534
+ "instructions": "# Launch video\n\nA recipe: the launch's story order and defaults. How to direct, design and build comes from the skills `make-film` calls on (`direction`, `look`, `media-sound`, `sequence-core`, `motion`). Load `make-film` and follow its steps, with what this file adds. For a film that isn't promoting a product or business (a course, a show, a story, an event), `make-film` alone is the recipe.\n\nWhen the user asks for a continuous shot, one take or no cuts, load `continuous-shot` instead and follow it; it replaces the act-by-act build below.\n\nA launch film people watch to the end is fast, specific and always moving. A deck of cards with a headline each is not a film, even with transitions.\n\n## Brand first\n\n- The user's direction (colours, theme, style, mood, voice) wins.\n- Otherwise call `get_brand` with the brand's site (the user's URL, or the brand's well-known domain). It reads the colours, light or dark look, fonts, logo, the site's own images and its copy, and imports the font, logo and images as `brand-*` assets. If the editor has no `get_brand`, ask the user for colours, font and screenshots.\n- The film's look is the brand's: `plan_film` with `style: \"brand\"`. A look the user names (\"like a movie trailer\", \"playful orbs\") is designed in the brand's colours unless they say otherwise.\n- **Only the brand's media goes in the film**: `brand-logo`, `brand-image-N` (each listed with what it shows), and files the user names. Anything else already in the project belongs to something else, however much it looks like a product screenshot; the editor refuses it.\n- **Copy comes from the brand's own words:** its headline, product and feature names, button wording. Facts and numbers come only from the user or the brand's site. List the brand's material (headings, features, numbers, quotes, images) before writing the treatment, and give each beat its own item.\n\n## The launch arc\n\n1. **The problem**: the viewer's pain, concrete and visual, in their words. Many tools, lost leads, hours of busywork.\n2. **The turn**: the product appears *out of* the problem. The protagonist element is born here: the scattered tools collide into it, the messy inbox folds into it.\n3. **The product in action**: 2\u20134 things it does, each shown happening (a message answered, a booking landing, a funnel filling), each growing from the last. Use real product images where they exist, on screens or cards the camera pushes into.\n4. **Proof**: one real number or name (only real ones).\n5. **The call to action**: the brand's own line and the address, held and drifting.\n\n**Defaults when the user doesn't say:**\n\n| Setting | Default |\n|---|---|\n| Length | 20\u201335 s, as 2\u20133 acts |\n| Music | Bright and driving, about 115\u2013128 bpm |\n| Camera | Pushing and tilting, with whip-pans or zoom-throughs between ideas |\n| Type | The brand's font, set large: 1\u20134 words for the punches, a short line at most |\n| Format | 16:9; offer 9:16 after |\n\nA length the user asks for is a requirement, within 10%. Each extra 10 s needs new material (a feature, a proof point), not a slower film. If there isn't enough material, make it shorter and say why.\n\n## Library scenes are blueprints\n\nThe editor's launch scenes are worked examples of single moments: a typed hook, a word slam, a 3D product card, a feature trio, a counting stat, a wordmark reveal. When one is close to a beat you want, read its code with `read_scene` and take the technique into your act. Don't fill them in as the film.\n\nOnly when `compose_sequence` isn't available does a launch fall back to `design_scene` for the brand's own moments, plus `add_scene` where a library scene fits a beat as-is.\n\n## Deliver\n\nReply with:\n\n- the length, and the length asked for if it differs, with the reason;\n- the world and the protagonist chain;\n- the acts in one line each;\n- the look: the colours (hex) and fonts, and where they came from (the user, the brand's site or kit, or neutral defaults), and which brand images appear.\n\nThen say what to check: claims and numbers on screen, and the music licence. Offer a 9:16 version (`reformat`)."
2535
+ },
2536
+ {
2537
+ "name": "look",
2538
+ "description": "Invent a film's look from its subject \u2014 palette, type, finish, the frame and its grid, picture treatments \u2014 and pass it to plan_film. Load it when planning any film from a brief; reference/scenes.md has the craft for design_scene stills.",
2539
+ "instructions": "# Look\n\nEvery film gets its own look, taken from the subject's world, never the last film's. Decide it before `plan_film`, and pass it as `style: \"custom\"` with its tokens. For a brand's film, use `style: \"brand\"` after `get_brand`. The user's direction always wins: the colours, mood or style they name.\n\n## Palette\n\n- **From what the subject looks like and how it should feel.** An environmental film might take moss green, river-water teal, smog grey and a warning amber. A jazz festival might take brass and night blue; a children's science course chalkboard green and crayon colours; a fintech launch ink, paper and one signal green.\n- **Four tokens:**\n - `bg`: the background;\n - `ink`: the text;\n - `primary`: the one accent;\n - `secondary`: a second accent, used much less.\n\n The editor derives the rest: muted text, card, line, and text on the accent.\n- **Contrast:** body text must read on the background, at better than 4.5:1. The accent must stand out from both.\n- **One accent:**\n - Use it on the key word, small accent shapes, the end dot, and one highlighted thing per frame.\n - An accent everywhere is no accent.\n - Photos may be the only saturated thing in the film; that is a look too.\n- **Light or dark from the tone:**\n - paper and daylight for calm, craft, learning and food;\n - night and glow for tech, drama and music.\n\n Not dark by default.\n\n## Type\n\n- **A typeface that speaks the subject's language:**\n\n | Subject | Typeface |\n |---|---|\n | Nature, history | A humanist serif |\n | Urgency, news | A condensed grotesk |\n | Product, UI | A geometric sans |\n | Kids, playful | A rounded sans |\n | Code | A mono |\n | Something handmade | A script, only then |\n\n Any Google Fonts family works.\n- **Two families at most:** a display face for titles (`.k-display`) and a body face.\n- **Scale, in container units:**\n\n | Use | Size |\n |---|---|\n | Hero | 11\u201316 cqmin |\n | Headline | 7\u201310 cqmin |\n | Body | 3.6\u20135 cqmin |\n | Labels | 2.6\u20133.2 cqmin, in caps with `letter-spacing: .2em` |\n\n Nothing under 2.5 cqmin. A word set huge (bleeding past the edges) is a motif, not a size.\n- **Setting:** tight leading on titles (1\u20131.05), `text-wrap: balance`, and the key word in the accent or in italic. At most about 7 words on screen in motion graphics, and about 25 in a still scene.\n\n## Finish\n\n- **Grain and letterbox** only for cinematic subjects (documentary, trailer).\n- **Texture** belongs to the concept: paper grain, a dot grid, a notebook grid, scanlines, soft colour blooms. Make it subtle, never on top of text. Make blooms and glows with `radial-gradient(circle, <colour>, transparent 65%)`, not `filter: blur()` on a big shape.\n- **Pace and joins from the tone:** slow dissolves for reflection, cuts and whips for energy.\n- **Music words** that fit the subject (the media-sound skill has the rest).\n\n## The frame\n\n- **Units:**\n - `cqmin` for type, spacing and radii;\n - `cqw` / `cqh` for layout widths and heights;\n - in a sequence's script, `S.w` / `S.h` / `S.min`.\n\n Never `px` for type, never `vw` / `vh`.\n- **Safe area:** keep text 6% in from every edge (`inset: 8cqh 7cqw`). With letterbox bars, keep it in the middle 70% of the height.\n- **Portrait:** `@container (max-aspect-ratio: 1/1) { \u2026 }` re-lays the frame for 9:16. Stack columns and enlarge type a little. Every film should survive a `reformat` to 9:16.\n- **Tokens in CSS:**\n\n | Variable | What it is |\n |---|---|\n | `var(--bg)` | Background |\n | `var(--ink)` | Text |\n | `var(--mut)` | Muted text |\n | `var(--p)` | Accent |\n | `var(--s)` | Second accent |\n | `var(--card)` | Card surface |\n | `var(--line)` | Hairlines |\n | `var(--onp)` | Text on the accent |\n\n Never hard-code a colour the plan already has, except in a sequence's tweens, which need the hex.\n\n## Grid and composition\n\n- **Pick a grid for the film** and keep to it:\n - a centred axis for statements;\n - thirds for picture plus caption;\n - a 12-column editorial grid for explainers;\n - a free field for playful shapes.\n- **Vary the composition from beat to beat:** full-bleed, split, grid, type only, close-up crop, diagram. Never the same composition twice in a row.\n- **Hierarchy:** one thing is biggest. The eye lands on it first, then the label, then the detail.\n- **Balance:** no big empty half, and nothing crammed in a corner. Empty space is fine when it is meant.\n\n## Pictures\n\n- **Show them** with `<img src=\"asset:ID\" class=\"k-cover\"/>` inside a sized frame with `overflow: hidden`. Never stretch a picture; use `object-fit: cover`.\n- **Treat pictures to fit the look.** Pick one treatment for the whole film:\n - **Duotone:** `filter: grayscale(1) contrast(1.1)`, plus an accent layer with `mix-blend-mode: multiply`.\n - **Frames:** rounded, film-strip edges, a polaroid border, a cut-out with a drop shadow.\n - **Texture:** soft blur and low opacity, when the pictures are atmosphere.\n- **Composition:**\n - A face or subject goes on a third, not dead centre under the text.\n - Crop for a close-up: use `object-position` and a larger scale.\n- **Legibility over pictures:** a gradient from the background (`linear-gradient(transparent 45%, color-mix(in srgb, var(--bg) 85%, transparent))`), the caption on a third.\n\n## Checklist before plan_film\n\n- The palette and type come from this subject. If it could be any brief's look, start again.\n- It isn't one of the named styles unless the user named it. The editor refuses a copy of a named style.\n- Text reads on the background, and the accent is used for one thing.\n- You know the grid, the picture treatment and the texture.\n\n`reference/scenes.md` is the craft for still scenes built with `design_scene` (its CSS animation classes, patterns and an example).",
2540
+ "files": {
2541
+ "reference/scenes.md": '# Still scenes (design_scene)\n\nHow to write `design_scene` scenes that look made for the brief and finished. Every scene is HTML and CSS in a full-frame box that is a CSS container. Units, safe area, tokens, type scale and picture treatments are in this skill\'s EDITOR.md (the frame applies to every film). This file is what is particular to still scenes: their CSS animation classes, rhythm across scenes, and patterns.\n\nFor motion graphics (one continuous move across a whole act), use `compose_sequence` instead, with the `sequence-core` and `motion` skills.\n\n## Motion\n\n- Entrances: `class="k-rise" style="--d:.4s"`. Stagger related items 0.12\u20130.25 s apart, in reading order. Everything has arrived by `T \u2212 1 s`, so the scene holds before it fades.\n- `--d` is inherited: set it on each element you stagger (and `--d:0s` on a container whose own animation must start at once).\n- One continuous move per scene (the camera push, `k-pan`, or `k-kb` on a picture), so nothing is ever frozen.\n- Your own `@keyframes` are welcome. Time them with `--T` when they last the whole scene: `animation: drift var(--T) linear both`.\n- Lines and shapes that draw: SVG with `stroke-dasharray` / `stroke-dashoffset` keyframes, or `.k-draw` on a thin div.\n- Typing: `.k-type` on an inline-block line, with `animation-timing-function: steps(n)` where n \u2248 its characters.\n\n## Rhythm\n\n- **Plan the sequence of compositions** before designing: e.g. full-bleed photo \u2192 split \u2192 big type \u2192 grid \u2192 close-up crop \u2192 diagram \u2192 full-bleed. Never the same composition twice in a row, and not one pattern on a loop.\n- **Change scale**: follow a wide shot with a detail. Crop into a picture (`object-position` and a larger scale) for a close-up, and a big number after small text.\n- **Keep it alive**: a scene\'s last entrance lands no more than about 2 s before it ends. Long scenes get a second beat (a label, a line that draws, a detail that appears) or a continuous move (`k-kb` on the picture, `k-pan` on a layer).\n- **Captions over photos**: a gradient for legibility, the caption on a third, and a small label or source line. With letterbox bars, keep it above the bottom bar.\n\n## Patterns\n\nThese are starting points. Mix them and make them belong to the concept.\n\n- **Statement**: one line set huge, the key word in the accent, a small label above. Use for a hook, a turn or a promise.\n- **Split**: a picture on one side (`k-cover` in a rounded frame with `k-kb`), headline and two lines on the other. In portrait, the picture goes above.\n- **Full-bleed picture**: the picture fills the frame (`k-fill` + `k-cover` + `k-kb`). A gradient from `var(--bg)` keeps the caption legible: `linear-gradient(transparent 45%, color-mix(in srgb, var(--bg) 85%, transparent))`.\n- **Grid**: 3\u20134 cards with a picture or icon, a title and one line. Deal them in with a stagger and give each a slight rotation.\n- **Diagram**: boxes joined by SVG lines that draw, for a flow, a loop or an architecture. Label each box. The film\'s own idea drawn out beats a stock card layout.\n- **Console / log**: monospaced lines typing in one after another, with prompts, statuses and a highlighted result. Use for technical subjects.\n- **List as a path**: items along a line or a rail that draws, each ticking off as it\'s reached.\n- **Number**: one real figure set huge, with its unit and one line of meaning.\n- **Collage**: 3\u20135 pictures overlapping at angles, settling while the camera pulls back.\n- **Close**: the name, one line, the call to action, the address.\n\n## Before you send a scene\n\n- Text inside the frame and its boxes; nothing overlapping; nothing under 2.5cqmin.\n- The frame is balanced: no big empty half, no content crammed in a corner.\n- It follows the concept: motif, layout and motion. It doesn\'t look like the scene before it.\n- Real words only: no placeholders, no invented people or numbers.\n\n## Example: a console syllabus (course on building AI agents)\n\n```html\n<div class="con k-fill">\n <div class="con-bar k-fade"><i></i><i></i><i></i><span>agent.run(course)</span></div>\n <div class="con-body">\n <p class="k-type" style="--d:.3s">&gt; plan --modules 4</p>\n <p class="con-ok k-rise" style="--d:1.1s"><b>01</b> Agent fundamentals <em>2 weeks</em></p>\n <p class="con-ok k-rise" style="--d:1.35s"><b>02</b> Tools and APIs <em>3 weeks</em></p>\n <p class="con-ok k-rise" style="--d:1.6s"><b>03</b> Planning and memory <em>3 weeks</em></p>\n <p class="con-ok k-rise" style="--d:1.85s"><b>04</b> Multi-agent systems <em>4 weeks</em></p>\n <p class="con-done k-pop" style="--d:2.4s">\u2713 12 weeks \xB7 4 projects</p>\n </div>\n</div>\n```\n\n```css\n.con{inset:14cqh 12cqw;display:flex;flex-direction:column;border-radius:2.4cqmin;background:var(--card);border:.15cqmin solid var(--line);overflow:hidden;font-family:\'JetBrains Mono Variable\',ui-monospace,monospace}\n.con-bar{display:flex;align-items:center;gap:1cqmin;padding:2cqmin 3cqmin;border-bottom:.15cqmin solid var(--line);font-size:2.8cqmin;color:var(--mut)}\n.con-bar i{width:1.4cqmin;height:1.4cqmin;border-radius:50%;background:var(--line)}\n.con-bar span{margin-left:2cqmin}\n.con-body{flex:1;padding:4cqmin 5cqmin;display:flex;flex-direction:column;justify-content:center;gap:2.4cqmin;font-size:4.6cqmin}\n.con-ok b{color:var(--p);margin-right:2cqmin}\n.con-ok em{font-style:normal;color:var(--mut);margin-left:2cqmin;font-size:3.2cqmin}\n.con-done{align-self:flex-start;margin-top:1cqmin;padding:1.2cqmin 2.6cqmin;border-radius:1cqmin;background:var(--p);color:var(--onp);font-size:3.4cqmin}\n```\n'
2542
+ }
2543
+ },
2544
+ {
2545
+ "name": "make-film",
2546
+ "description": "Make any film from a brief in the editor \u2014 a product or service intro, a launch or promo, a course or programme promo, a trailer or teaser, a documentary, biography or recap, an explainer \u2014 directed for the brief (a treatment), with its own look, built as continuous motion-graphics acts (compose_sequence) or designed scenes. Use whenever the user asks for a video about something and has no footage to edit, or only a few stills.",
2547
+ "instructions": '# Make a film from a brief\n\n**Design for the brief; don\'t fill a template.** The user asked for *their* film. Invent the world, the look and the moves that belong to this subject, then build them. A film assembled from the same parts as the last one reads as a template, whatever the words.\n\nLoad `edit-video` first if you haven\'t (its Brand and direction rules apply). This recipe calls on five skills, each for one job. Load each with `load_skill` when you reach its step:\n\n| Skill | Its job |\n|---|---|\n| `direction` | Brief \u2192 treatment: the world, the protagonist element, acts and a timed beat sheet |\n| `look` | Palette, type, finish, grid, picture treatment, from the subject |\n| `media-sound` | Pictures (find or generate), music chosen first, sound effects, mix |\n| `sequence-core` | The contract for compose_sequence acts: the stage kit API and its rules |\n| `motion` | The motion-graphics grammar and about 30 techniques, plus breakdowns of whole films |\n| `continuous-shot` | When the ask is a continuous shot, one take, no cuts, or "not a slideshow": three concepts, a beat sheet, and a measured check of every beat |\n\n## 1. Read the brief\n\nDecide, in one line each:\n\n- **Subject**: what the film is about, by name.\n- **Format**: the job the film does:\n\n | The brief is\u2026 | Format |\n |---|---|\n | A product, app, service, business or feature to introduce or promote (not a course) | `launch` |\n | A course, class, bootcamp, workshop, certification or learning programme | `course` |\n | A show, film, book, game, album or event to tease | `trailer` |\n | A real story: biography, history, true crime, a recap, a profile | `documentary` |\n | How something works, a concept, a process | `explainer` |\n\n- **Build mode**:\n - **Motion graphics** (`compose_sequence`) whenever it is available. Use it for launches, intros, promos, explainers, courses and trailers, and for anything where the user says "motion graphics", "animated", "dynamic", "kinetic", or names shapes or type as the subject. The film is a few continuous acts.\n - **Designed scenes** (`design_scene`, with `add_scene` where a library scene fits) for documentaries built on stills, or when `compose_sequence` isn\'t available. The film is a row of scenes joined by dissolves.\n\nThe user\'s own direction always wins: a style, colours, a mood, a length.\n\n## 2. Direct\n\nLoad `direction` and write the treatment. It covers:\n\n- the world;\n- the protagonist chain;\n- the promise;\n- acts with a timed beat sheet, with a technique and a carry-over per beat;\n- words per beat;\n- the look, camera and music direction.\n\nRead the closest breakdown in `motion` \u2192 `reference/breakdowns/` to borrow its grammar.\n\n## 3. Look, plan and approval\n\nLoad `look`, decide the palette, type and finish for this subject, and call `plan_film` with:\n\n- `format`, `subject`, `seconds`, `reason`;\n- the look as `style: "custom"` tokens (or `style: "brand"` after `get_brand`; a named style only when the user names it);\n- the `concept`, from the treatment:\n - idea = the world;\n - motif = the protagonist element;\n - layout = the grid;\n - motion = the camera\'s character;\n - imagery = the shot list.\n\nIt registers the fonts and returns the film\'s tokens and music words.\n\nThen record the treatment with `direct_film`. It checks the treatment and shows the user a storyboard. **Unless the user said to just make it, end your turn and wait for their approval** (the chat offers Approve & build and Change direction). Revise with `direct_film` if they ask for changes.\n\n## 4. Material (after approval)\n\nA film from a brief gets its own timeline. First remove the project\'s existing clips and tracks, in one `apply_commands` batch, unless the user asked to add to this project. Use 16:9 unless asked. Then load `media-sound`.\n\n- **Words come from the subject\'s world.** Use its real names, facts, lines and dates. Never invent people, numbers, reviews or quotes; mark a paraphrase as one ("\u2014 paraphrased"). The editor refuses placeholder copy.\n- **Pictures:** only the subject\'s own (user-added, `brand-*`, or from `find_images` / `generate_image`). Get them before building.\n- **Music first:** add the music under the whole film (volume 0.7, fade in 1 s and out 2 s). Then call `get_beat_grid`. It measures the beats and gives every act its beat times (`S.beat(n)`), so the big moves land on the music.\n\n## 5. Build\n\n**Motion graphics.** Load `sequence-core` and `motion`. Then compose one `compose_sequence` per act, in order:\n\n- Each act is one timeline. Elements persist and transform; the camera keeps moving (stillness only in a declared `tl.rest`); beats are labels; each beat grows out of the last.\n- The next act starts where the last one ended.\n- Look at each filmstrip and fix the act with `replace` before composing the next.\n- Use `design_scene` only for a single still moment, if one is needed at all.\n\n**Designed scenes.** Read `look` \u2192 `reference/scenes.md`.\n\n- Use `design_scene` for the film\'s own moments: your HTML and CSS following the concept. Check every preview, and fix it with `replace`.\n- Use `add_scene` only where a library scene fits a beat as-is.\n- Vary the compositions; don\'t settle into a loop.\n- Scenes dissolve into each other. Dip (`join: "dip"`) only at chapter breaks, and fade out (`fadeOut`) only at the very end.\n\n**Both modes:**\n\n- Use quote marks only for real quotes, with the speaker.\n- A film *about* an organisation isn\'t *by* it: no official-looking end card, no logo lock-up or "Join us" unless the user is that organisation. Credit the sources.\n- Once the acts are in, place sound effects on the big moves with `add_sound_effects` (a whoosh on a whip, a pop on a pop-in, an impact on a collide), and check that the music still runs to the end.\n\n## 6. Check and deliver\n\n`preview_film` shows the film and measures its motion. When a reviewer model is set (AI settings \u2192 Reviewer), call `review_film` next. It sends the film to that model for notes with times, fixes and a verdict. Fix the notes that matter; if you disagree with one, say why in your reply.\n\nCheck that:\n\n- it reads as one piece;\n- the length is within 10% of what was asked;\n- there is no unrelated media;\n- the music runs to the end.\n\nReply with:\n\n- the length;\n- the world and the protagonist chain, in one line each;\n- the look in one line;\n- the acts or beats in one line each;\n- what to check (facts, quotes, image rights, music licence).\n\nOffer their own pictures, a 9:16 version (`reformat`), or another direction.'
2548
+ },
2549
+ {
2550
+ "name": "make-template",
2551
+ "description": 'Design a reusable video template in the editor from a description - a short multi-scene video whose every visible word, colour and media slot becomes an editable field - for launches, product tours, agent demos, metrics reels, testimonials and similar. Use for "Create with AI" in the templates panel and whenever the user wants a template rather than one video.',
2552
+ "instructions": "# Make a template\n\nLoad `edit-video` first if you haven't. A template is a short, finished-looking video built so the editor can turn its texts, colours and media into a form: people fill the form and get their own video. Build it in the project as normal clips; the editor makes the fields afterwards.\n\n## 1. Brief \u2192 beat sheet\n\nSettle in two lines: what it's for (launch, walkthrough, results, proof\u2026), who watches, the one thing they remember. Then write the beats before building:\n\n```\n# Len Beat On screen Motion\n1 2.6s Hook eyebrow + two-line claim track-in, line 2 wipes on\n2 4.2s Line chart title, panel, 6-point line, tooltip line draws, dots pop\n\u2026\n6 2.8s End card wordmark, line, button glow blooms, button pings\n```\n\n- 4\u20137 scenes, 2.6\u20134.4 s each (5\u201325 s in total; shorter when the request asks for one scene). Hook \u2264 2.8 s and says what it is. End card about 2.8 s.\n- One idea per scene, at most about 7 words of headline.\n- **Every scene keeps moving after it lands**: a loop, a count, a draw, a drift, a ping. A scene that animates in and then holds looks like a slide.\n\n## 2. Build for editability\n\n- **Text**: every visible word is either a text-style clip (`add_text_style`, if the editor has it) or a `{{placeholder}}` in an HTML clip's `params` (e.g. `{{headline}}`, `{{stat1}}`), with short placeholder copy as the value. Name params by role in reading order (`hook_line1`, `bar3_value`). Each one becomes a field.\n- **Colour**: HTML clips take their colours from params named by role, as `#RRGGBB` values: `primary` and `secondary` for the two brand colours, `accent`, `bg`, `ink` (text). Brand kits match these names, so one brand change recolours the whole video. Never hard-code white text on a background that might change.\n- **Data is params too**: counts, chart points, percentages; keep the HTML working for any reasonable value (a counter from 0 to `{{value}}`).\n- **Media**: use the project's sample media as placeholders (each becomes a media slot). Never invent media URLs. Images inside HTML are `src=\"asset:<id>\"`.\n- **Defaults are samples**: invented names and companies, never real ones, and never present placeholder numbers or quotes as real.\n- **Look**: apply the user's direction (colours, theme, style, mood) as asked. When they ask for a template for their own brand, take its colours, fonts and voice from `get_brand` (`edit-video` \u2192 Brand and direction) as the defaults of the colour params and copy.\n\n## 3. Scenes\n\nOne animated full-frame HTML clip per scene, back to back on one track, over a background (`set_background`, or a static full-frame HTML background clip with `animate_clip` loop kenburns). Build each from `reference/html-recipes.md` in the `edit-video` skill (base kit, `--d` staggers in reading order, XML-strict markup). `reference/patterns.md` in this skill has the patterns that worked for the library's templates (counters, typing, spinner to check, spotlight, hotspots, charts, rings, avatars, star fills, walls, marquees, stamps): its classes (`.r`, `.disp`, `--spring`\u2026) come from a build kit you don't have here, so copy the idea and the `@keyframes`, and define the classes in the scene's own `<style>`.\n\nBetween scenes: `add_transition` crossDissolve about 0.3 s. Music, if the request implies it: a sample track at volume 0.5 with a 2 s fade-out.\n\nPitfalls (from the template library): one start time per element (nest elements for different times); inline `<svg>` needs `xmlns=\"http://www.w3.org/2000/svg\"`; write `<` and `>` in CSS as `&lt;` `&gt;` and `&#160;` instead of `&nbsp;`; no `transform-style:preserve-3d`; springs overshoot, so anything clipped by an edge eases out instead.\n\n## 4. Check\n\n`reference/scorecard.md` has the rubric. From the state: scenes back to back, every scene's entrances settled at least 1 s before its end, every visible word reachable as a field (text clip or `{{param}}`), `primary`/`secondary` used, nothing hard-coded that a theme or brand change would break.\n\n## 5. Reply\n\nFirst line: a short template name (2\u20134 words). Then one sentence: what it is, how many scenes, and which fields matter most.",
2553
+ "files": {
2554
+ "reference/patterns.md": '# Scene patterns\n\nPatterns proven in the first five templates (launch film, product tour, AI agent demo, metrics reel, testimonial wall). Each one renders the same in the studio preview and in export, in all four themes. Snippets assume the kit\'s `BASE` CSS, so `lift`, `clear`, `popIn`, `reveal`, `grow`, `spin`, `gone`, `--spring`, `--bounce`, `--out` and `--move` exist.\n\nContents:\n- Timing: start times, nesting, show-then-hide, staggers\n- Type: hook, typing, streaming lines, highlighter, wipe-down quote, stamp\n- Numbers and data: counter, line chart, ranked bars, goal ring, star fill, mini sparklines\n- UI: product window with a screenshot, spotlight, hotspots, spinner to check, workflow, phone tap and sheet, command palette\n- People and proof: avatar from a name, quote card wall, marquee rows, ticker\n- Endings: end card\n\n## Timing\n\n**Start times.** `style="--d:.4s"` is when an element\'s animations start, measured from the scene\'s start. Children inherit it unless they set their own. Every animation on one element shares it.\n\n**Different times on one thing: nest.**\n\n```html\n<!-- fades in at .9 s, fades out at 2.05 s -->\n<div class="cap" style="--d:.9s"><div style="--d:2.05s">Caption</div></div>\n```\n```css\n.cap{animation:fadein .3s ease-out both}\n.cap>div{animation:fadeout .25s ease-in both}\n```\n\n**Staggers.** Generate them in JS so they stay even: ``${[1,2,3].map((i)=>`<div class="card" style="--d:${(0.3+(i-1)*0.14).toFixed(2)}s">\u2026</div>`).join("")}``. Use 0.12\u20130.14 s for cards and rows, 0.45\u20130.5 s for steps that should be read one at a time.\n\n**Syncing to a linear reveal.** If a line draws from `d0` over `D` seconds with `linear`, point i of n (0-based) is reached at `d0 + D\xB7i/(n\u22121)`. Pop each dot then.\n\n## Type\n\n**Hook** (every template): the eyebrow tracks in, line 1 rises, and line 2 (accent) wipes on and keeps shimmering.\n\n```css\n@keyframes track{from{letter-spacing:.9em;opacity:0;filter:blur(6px)}}\n.eyebrow{animation:track 1.1s var(--out) both}\n.l2{display:inline-block;animation:reveal .9s cubic-bezier(.65,0,.35,1) both,shimmer 3.2s linear infinite}\n```\n```html\n<div class="eyebrow" style="--d:.05s">{{brand}}</div>\n<div class="h1 disp r" style="--d:.15s">{{hook1}}</div><div class="h1 disp grad l2" style="--d:.6s">{{hook2}}</div>\n```\n\n**Typing** (editable text, any length up to the cap): `.typed` animates `max-width` with `steps()`. Shorten it per line with `animation-duration`, and follow it with `<span class="caret"></span>`.\n\n**Streaming lines** (an answer arriving): each line is `display:table` with `animation:reveal .8s steps(24,end) both`, staggered by about 0.35 s.\n\n**Highlighter across wrapped lines:**\n\n```css\n@keyframes mark{from{background-size:0% 100%}to{background-size:100% 100%}}\n.mk{background:linear-gradient(color-mix(in srgb,{{secondary}} 34%,transparent),color-mix(in srgb,{{secondary}} 34%,transparent)) no-repeat 0 85%/0% 100%;\n -webkit-box-decoration-break:clone;box-decoration-break:clone;animation:mark 1.2s cubic-bezier(.65,0,.35,1) both}\n```\n\n**Wipe-down quote** (line by line, stepped): `@keyframes wipeDown{from{clip-path:inset(0 0 100% 0)}to{clip-path:inset(0 0 0 0)}}` with `steps(4,end)`, where 4 is about the number of lines.\n\n**Stamp slam:** `@keyframes slam{from{transform:rotate(-8deg) scale(2.4);opacity:0}}` with `cubic-bezier(.5,0,.75,0)` (accelerating into the hit), on an element whose resting transform is `rotate(-8deg)`, with a `var(--shade)` background so it reads over anything.\n\n## Numbers and data\n\n**Counter** (whole numbers up to 4 digits):\n\n```css\n@property --n{syntax:\'&lt;integer&gt;\';inherits:false;initial-value:0}\n@keyframes count{from{--n:0}to{--n:var(--to)}}\n.num{counter-reset:n var(--n);animation:count 1.6s var(--out) both}\n.num::after{content:counter(n)}\n```\n```html\n<span class="pre">{{heroPre}}</span><span class="num" style="--to:{{heroNum}};--d:.2s"></span><span class="unit">{{heroUnit}}</span>\n```\n\nKeep symbols in their own fields (`$`, `K`, `%`). Field pattern: `^[0-9]{1,4}$`. Inside `.grad`, put any blur on the `.grad` wrapper, never on `.num`.\n\n**Line chart from field values:**\n\n```html\n<div class="plot"> <!-- position:relative; a fixed height -->\n <div class="draw" style="--d:.7s"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 100" preserveAspectRatio="none">\n <g transform="translate(0 100) scale(1 -1)">\n <path d="M0 0 L0 {{p1}} L100 {{p2}} \u2026 L500 {{p6}} L500 0 Z" fill="url(#cg)"/>\n <path d="M0 {{p1}} L100 {{p2}} \u2026 L500 {{p6}}" fill="none" stroke="{{primary}}" stroke-width="6" vector-effect="non-scaling-stroke"/>\n </g></svg></div>\n <div class="dot" style="left:0%;bottom:calc({{p1}} * 1%)"><i style="--d:.7s"></i></div> \u2026\n</div>\n```\n\n- Flipping the y axis makes each field (0\u2013100) a height directly.\n- `preserveAspectRatio="none"` plus `non-scaling-stroke` keeps the line crisp at any shape.\n- Draw it on with a `reveal` on the wrapper (`linear`), not `stroke-dashoffset`, which non-scaling strokes break. A 2 px scan line can travel with it: `@keyframes scan{from{left:0}to{left:100%}}`, same timing.\n- Dots and tooltips are HTML placed with `bottom:calc({{pN}} * 1%)`.\n\n**Ranked bars, scaled to the largest** (pure CSS):\n\n```html\n<div class="fill" style="width:calc({{b1v}} / max({{b1v}},{{b2v}},{{b3v}},{{b4v}},{{b5v}}) * 82%);--d:.55s"></div>\n```\n\nAnimate with `@keyframes wgrow{from{width:0}}`, and put the value right after the bar in the same flex row so it rides along. The leader gets the gradient.\n\n**Goal ring that laps past 100 %:**\n\n```css\n@property --a{syntax:\'&lt;number&gt;\';inherits:false;initial-value:0}\n@keyframes ring{from{--a:0}to{--a:var(--to)}}\n.rg{position:absolute;inset:0;border-radius:50%;mask:radial-gradient(farthest-side,transparent calc(100% - 50px),#000 calc(100% - 49px));animation:ring 1.9s cubic-bezier(.45,0,.2,1) both}\n.r1{background:conic-gradient({{primary}} calc(min(var(--a),100) * 3.6deg),color-mix(in srgb,var(--ink) 10%,transparent) 0)}\n.r2{inset:64px;background:conic-gradient({{secondary}} calc(max(var(--a) - 100,0) * 3.6deg),transparent 0)}\n```\n\nGive the number in the middle the same duration and easing. On paper and pop, set the ring on its own card (`.gw::before` with `var(--surface)`, `var(--border)`, `var(--shadow)`).\n\n**Star fill from a rating field** (`4.9`): `style="--r:{{rating}}"` on a wrapper. Two rows of \u2605\u2605\u2605\u2605\u2605 are stacked; the top one is `width:calc(var(--r) / 5 * 100%);overflow:hidden;white-space:nowrap`, and grows from `width:0`.\n\n**Mini sparklines** (decoration, not data): an SVG polyline in `viewBox="0 0 100 30"` with `preserveAspectRatio="none"`, an area polygon under it, and a `reveal` 0.5 s after its tile lands.\n\n## UI\n\n**Product window with a screenshot field:**\n- a frame with a 44 px bar of three dots;\n- `<img src="asset:shot" alt=""/>` at a fixed size with `object-fit:cover`;\n- `var(--surface)`, `var(--border)`, `var(--shadow)`.\n\nWrite the coordinates of the areas you point at in the image\'s own pixel space, plus the bar offset. For the 9:16 window, scale them by its width ratio (for example, 0.762 for 960 / 1260).\n\n**Spotlight gliding between areas:** one element inside the window\'s `overflow:hidden`. `box-shadow:0 0 0 2400px #000000a6` dims everything else, and `outline:4px solid {{secondary}}` marks the edge. Animate `left/top/width/height` through keyframe stops (hold, move, hold), `cubic-bezier(.65,0,.35,1)`, and show one caption per stop with show-then-hide.\n\n**Hotspots:** a zero-size `.pin` at the point holds a numbered dot (`popIn` with `--bounce`), a ping ring (`@keyframes ping{from{transform:scale(.6);opacity:.8}to{transform:scale(2.6);opacity:0}}`, infinite) and a label that `reveal`s out of it. Flip a label to the left near the right edge.\n\n**Spinner to check:**\n\n```html\n<div class="st"><div class="sw" style="--d:1.05s"><div class="ring" style="--d:0s"></div></div><div class="ok" style="--d:1.05s">\u2713</div></div>\n```\n```css\n.sw{position:absolute;inset:0;animation:gone .18s ease-in both}\n.ring{position:absolute;inset:4px;border-radius:50%;border:4px solid color-mix(in srgb,var(--ink) 18%,transparent);border-top-color:{{secondary}};animation:spin .7s linear infinite}\n.ok{position:absolute;inset:0;border-radius:50%;background:{{secondary}};animation:popIn .7s var(--spring) both,clear .3s var(--out) both}\n```\n\n**Workflow run:**\n- node cards land in a wave;\n- the wires between them `grow` from the left as each node lights (`@keyframes lit{to{border-color:{{primary}};box-shadow:\u2026}}` on an inset overlay);\n- a check pops on each node;\n- a result chip closes it.\n\nIn 9:16 the flow becomes a column, and the wires use `growY` with `transform-origin:center top`.\n\n**Phone:** a 420\xD7860 frame with 64 px corners and a notch. A finger dot fades in, a tap ripple runs (`@keyframes tap{0%{transform:scale(.3);opacity:.9}100%{transform:scale(2.2);opacity:0}}`), then a sheet slides up (`@keyframes sheet{from{transform:translateY(100%)}}` with the spring) and a check pops on it.\n\n**Command palette:** the room dims (`@keyframes dim{to{background:var(--shade)}}`), the palette opens with a scale and fade, the query is typed, and a row gets picked (a background pulse), then the palette closes and a toast pops.\n\n## People and proof\n\n**Avatar from a name** (no image, and it follows edits):\n\n```css\n.av{width:64px;height:64px;border-radius:50%;overflow:hidden;font-size:0;line-height:64px;text-align:center;white-space:nowrap;\n color:#fff;background:linear-gradient(135deg,{{primary}},{{secondary}});font-family:var(--display);font-weight:var(--dw)}\n.av::first-letter{font-size:30px}\n```\n```html\n<span class="av">{{name}}</span>\n```\n\nThe same trick at 440 px makes a portrait card with a big initial.\n\n**Quote card wall:**\n- four columns of six cards, each column moving 420 px over the scene, alternating `up` and `down` (one column at a different speed);\n- the wall tipped back with `transform:perspective(2400px) rotateX(14deg)` on the wall element itself;\n- the top and bottom faded with `mask:linear-gradient(transparent,#000 14%,#000 80%,transparent)`.\n\nIn 9:16, hide two columns.\n\n**Marquee rows:** two rows of pills translating in opposite directions (`translateX(0 \u2192 \u2212640px)` and back) over the scene, with the edges faded by a horizontal mask. Repeat the items so a row never runs out.\n\n**Ticker:** one strip at the bottom of the hook, `translateX(0 \u2192 \u221250%)` linear over about twice the scene length, with its content written twice. Fill it with the template\'s own numbers (other fields), so it previews what\'s coming.\n\n## Endings\n\n**End card** (in the starter):\n- a radial glow blooms;\n- the wordmark comes into focus;\n- a gradient dot drops with `--bounce`;\n- the tagline rises;\n- the button pings three times (`@keyframes pulse{from{transform:scale(1);opacity:.7}to{transform:scale(1.35,1.8);opacity:0}}`).\n\nThe end card has `last: true`, so it doesn\'t exit.\n',
2555
+ "reference/scorecard.md": '# Scorecard, notes and metadata\n\n## Rubric\n\nScore 1\u20135 for the template and for the closest reference in its category (a template or component that does the same job). Compare like for like: the same copy, the same accent colour, about the same length.\n\n| Criterion | What to check | Bar to pass |\n| --- | --- | --- |\n| Hook | Is the message clear by second 2? | Equal or better |\n| Motion | Easing, timing, holds, no jank or pops; every scene keeps moving after it lands | Equal or better |\n| Typography and layout | Hierarchy, spacing, safe margins, in every theme and both formats | Equal or better |\n| Detail | Depth, micro-motion, transitions between beats | Equal or better |\n| Editability | Can a non-coder change every word, image, number and colour? | Better: every visible word, image and data point is a field |\n| Formats | How many shapes look designed, not cropped | Better: at least 2 (16:9 and 9:16), plus four themes |\n| Export fidelity | Export matches the preview | Export = preview (the user checks this on their machine) |\n\nA template **passes** when the first four are equal or better and editability and formats are better.\n\nWhat keeps a 4 from being a 5 on motion is usually the camera: a move into the area that matters (a zoom into the chart, a dolly through the wall). Note that as the next improvement rather than blocking on it.\n\n## notes.md\n\n```markdown\n# Notes \xB7 <Title>\n\nBuilt and checked <date> in the React studio (<commit>), headless Chromium. Export is yours.\n\n## Checks\n\n- [x] Opens with no console errors; <n> fields.\n- [x] 16:9 motion beats (`contact-sheet.png`); all four themes, one settled frame per scene (`contact-sheet-themes.png`); 9:16 in <themes> (`contact-sheet-vertical.png`).\n- [ ] Speed 1.00 and export, on the user\'s machine.\n\nFixed while checking: <one line each>.\n\n## Scores (1\u20135)\n\nReference: <what it was compared with, described by category; internal only>.\n\n| Criterion | Reference | Ours | Note |\n| --- | --- | --- | --- |\n| Hook | | | |\n| Motion | | | |\n| Typography and layout | | | |\n| Detail | | | |\n| Editability | | | |\n| Formats | | | |\n\n**Verdict: passes / not yet.**\n\n## Craft notes\n\n- <a new pattern or pitfall, in one or two lines; also add it to reference/patterns.md>\n```\n\n## template.json\n\nCatalog metadata that the template library website reads later:\n\n```json\n{\n "id": "metrics-reel",\n "title": "Metrics reel",\n "goal": "report-and-present",\n "type": "results",\n "formats": ["16:9", "9:16"],\n "themes": ["terminal", "midnight", "paper", "pop"],\n "defaultTheme": "terminal",\n "durationSec": 24,\n "style": ["data-viz", "kinetic-type"],\n "pace": "steady",\n "industry": ["saas", "any"],\n "startFrom": ["numbers"],\n "fields": 61,\n "assets": [{ "url": "https://assets.miraiclip.com/templates/_shared/", "license": "SIL OFL 1.1" }],\n "source": "new"\n}\n```\n\n| Key | Values |\n| --- | --- |\n| `goal` | `launch`, `show-how-it-works`, `build-trust`, `report-and-present`, `grow-on-social`, `explain-and-teach`, `celebrate-and-connect` |\n| `pace` | `calm`, `steady`, `energetic` |\n| `startFrom` | `text`, `screenshots`, `numbers`, `footage`, `recording` |\n| `assets` | One entry per file or shared folder, with its licence (and the credit, if the licence needs one) |\n'
2556
+ }
2557
+ },
2558
+ {
2559
+ "name": "media-sound",
2560
+ "description": "Pictures, music and sound effects for a film from a brief \u2014 when to find vs generate images and their rights, choosing music by mood and tempo before building, timing acts to the music, placing sound effects on moves, and mix levels. Load it when a film needs pictures or sound.",
2561
+ "instructions": '# Media and sound\n\n## Pictures\n\n**Only the subject\'s own media.** A film may show:\n\n- pictures or clips the user added in this chat or names;\n- a brand\'s own `brand-*` assets (from `get_brand`);\n- pictures you get with the image tools.\n\nAnything else already in the project (samples, an earlier project\'s media) has nothing to do with this subject. The editor refuses it.\n\n**Find or generate:**\n\n| The moment shows\u2026 | Tool | Query |\n|---|---|---|\n| Something real: a person, place, product, show, event, building | `find_images` | One specific query per moment: "Lisbon tram Alfama street", "The Mentalist Patrick Jane Simon Baker" |\n| A concept, mood or story moment with no real picture | `generate_image` | Describe the picture, not the film: "a student coding late at night, warm desk lamp". The film\'s palette is added for you, and pictures come without text |\n\n**Never:**\n\n- generate a real person\'s face, a real product\'s screens or a real logo;\n- put text in a generated picture (set the words in HTML).\n\n**Rights:** web images belong to their owners. Say so in your reply, and credit the source on screen when a film is *about* an organisation ("Images: NASA").\n\n**Not set up?** If a tool says it isn\'t configured, tell the user they can add a Tavily or Gemini key in AI settings \u2192 Images. Then design with type, shape and motion: a film without pictures can be very good.\n\n**Use pictures the way the look says** (the `look` skill: frames, duotone, texture). In motion graphics a picture is usually an element that moves: it flies in with blur, it is a tile in a grid that becomes a wall, it is the inside of a window the camera pushes into. It rarely holds as a full-frame slide.\n\n## Music\n\n**Choose the music before you build.** The music sets the pace, so its length and tempo shape the acts:\n\n1. Pick the mood and tempo from the brief and the look:\n\n | Film | Mood | Tempo |\n |---|---|---|\n | Playful | Bouncy, plucky | About 110\u2013125 bpm |\n | Launch | Bright, driving | About 115\u2013128 bpm |\n | Calm explainer, course | Light, warm | About 90\u2013110 bpm |\n | Trailer | Low drones and pulses that build to hits | Free |\n | Documentary | Piano, strings, ambient | Slow |\n\n2. `search_audio` with the music words `plan_film` returned, plus the mood and genre ("upbeat electronic pluck", "warm acoustic", "cinematic tension build"). Prefer a track at least as long as the film. Use `generate_audio` (music, `instrumental: true`) only when the user asks, or when nothing fits.\n3. Plan act lengths in whole musical phrases.\n - At 120 bpm a bar is 2 s, so a 4-bar phrase is 8 s and an 8-bar phrase is 16 s.\n - At 100 bpm, 4 bars are 9.6 s.\n\n If you don\'t know the tempo, keep acts at 8\u201312 s and beats at 0.5 s multiples. Big moves land on whole seconds.\n4. Add it before composing the acts: `add_audio` at 0 s, covering the whole film. Fade in 1 s and out 2 s.\n5. Call `get_beat_grid`. It measures the music\'s tempo and beats, and from then on every act composed with `compose_sequence` gets them as `S.beats`. It tells you the tempo, the bar length and the beat times; move the treatment\'s beats onto them.\n\n**The beat grid.** After `get_beat_grid`, `S.beats` / `S.beat(n)` in a sequence give the beat times *within that act*, so a move can land on the beat: `tl.label("hit", S.beat(8)).from(".w3", {...}, "hit")`. Put each beat\'s label on a beat. compose_sequence reports how many labels land within 80 ms of a beat. Without a grid, `S.beat(n)` is n \xD7 0.5 s, a steady 120 bpm.\n\n## Sound effects\n\nSound effects sell motion. Place them on moves, sparingly:\n\n| Move | Sound |\n|---|---|\n| A whip pan, zoom-through, fast fly-in | A short whoosh, starting about 0.1 s before the move peaks |\n| A pop-in, chip, badge | A soft pop or click |\n| Typing | Light key ticks (a short loop, not one per letter) |\n| A count-up, ticking list | Ticks |\n| A collide-and-merge, title reveal, logo lock-up | One impact or hit, with a riser into it |\n| A blur-through | An air swell |\n\n- Place them with `add_sound_effects`: name the act and its labels, and the kind of sound for each (whoosh, pop, tick, impact, riser, swell). It finds a fitting sound, puts it on its own track at the label\'s time in the film (a whoosh starts slightly before its move), and mixes it under the music.\n- **At most one effect per beat**, and none on every element of a stagger. Three good whooshes beat twenty.\n- Silence is a tool: drop the effects in a reflective act.\n\n## Mix levels\n\n| Layer | Volume |\n|---|---|\n| Music, no voice | 0.7 |\n| Music under a voice | 0.25\u20130.4 |\n| Voice | 1 |\n| Sound effects | 0.4\u20130.7 (they should be felt more than heard) |\n\n- Fade music out over the last 2 s, ending on the end card\'s hold.\n- Nothing should clip: if effects pile up at a hit, lower the music there or drop an effect.\n\n## In your reply\n\nSay what the music is (title and source) and its licence. Say which pictures came from the web, and that they belong to their owners. Say which pictures were generated.'
2562
+ },
2563
+ {
2564
+ "name": "motion",
2565
+ "description": "The motion-graphics grammar for compose_sequence acts \u2014 continuity, hidden cuts, a moving camera, kinetic type \u2014 plus about 30 atomic techniques with working stage-kit code. Load it before composing any act.",
2566
+ "instructions": "# Motion\n\nA film made from a brief should read as **one continuous move**, not slides. This skill is the grammar for that, and a library of techniques you combine inside compose_sequence acts. Each technique is a file with what it is, when to use it, its timing, the usual mistake, and a working snippet (markup, CSS, and the body of `stage.build((tl, S) => { \u2026 })`). Read one with `read_skill_file` (skill `motion`, path `reference/rules/<name>.md`), adapt it to your act, never paste it unchanged.\n\n## The grammar in short\n\n1. **Every beat grows out of the last one.** A title becomes a window's label. Tiles become accents. Five photos become five hundred. Nothing appears from nowhere inside an act. See `reference/continuity.md`.\n2. **Hidden cuts only.** When something must change completely, hide the cut:\n - inside a 0.2\u20130.3 s blur (blur-through);\n - in a push into an element (zoom-through);\n - in a whip (whip-pan);\n - or on the same element at two framings (match-cut).\n\n Never dissolve one full layout into another.\n3. **The camera keeps moving.** Push, pull, pan with the type, tilt in depth, untilt to resolve. A beat with a still camera needs something else moving; the only allowed stillness is a rest declared with `tl.rest`. See `reference/camera.md`.\n4. **Type is the main actor.** Lines type, retype in place, rise from masks and swap words. The key word is in the accent colour. Never more than about 7 words on screen.\n5. **One accent colour.** Use it on the key word, the small accent shapes and the end dot, and nowhere else.\n6. **Blur on every fast move** and a soft focus-in on every new element (motion-blur).\n7. **Short beats.** Most beats last 0.3\u20131.2 s. Holds are for reading, and they stay alive. See `reference/timing.md` for beat lengths, the reading budget and the motion targets.\n8. **The easing set.** expo.out in, expo.in out, power3.inOut / expo.inOut to move between places, back.out for pops, sine.inOut for drift (easing-tokens).\n\n## How to build an act with these\n\n1. Write the act's beats first: each beat is one idea, one move, and what it hands to the next beat.\n2. For each beat, pick **one** main technique from the index, plus a carry-over into the next beat. Two or three techniques at once is noise.\n3. Name every beat with `tl.label(\u2026)`, and put each beat's moves at its label. compose_sequence shows the beats on the timeline and checks the layout just after each one.\n4. Keep the camera moving across the whole act: one long `tl.camera(\u2026)` per beat, overlapping the next.\n5. Look at the filmstrip compose_sequence returns. Check that every frame has something moving, that the hand-offs are visible, and that nothing is off-frame. Fix with `replace`.\n\n## Things the stage kit does for you, and things to remember\n\n- Sizes in the script come from `S.w(%)`, `S.h(%)` and `S.min(%)`, so the act fits 16:9, 9:16 and 1:1. In CSS, use container units (cqw, cqh, cqmin).\n- The timeline owns `transform` on everything it animates. Centre elements with a grid wrapper, or with margins, never with a CSS `transform: translate(-50%, -50%)` on an animated element.\n- `S.split(sel, \"words\" | \"chars\")` wraps each word or character in a span (`.word` / `.char`) and returns them in reading order. Styled children such as `<em>` stay, with their own words split inside them.\n- For randomness, use `S.rand(seed)`: the same frames on every render. Measuring the DOM (`offsetLeft`, `getBoundingClientRect`) is fine: fonts have loaded when the script runs.\n- Tween colours to real colour values (hex or rgb), not `var(--\u2026)`.\n- Elements outside `.world` (a `.hud` layer) don't move with the camera.\n\n## Techniques\n\n| Technique | What it is / when |\n|---|---|\n| `accent-punctuation` | An accent square or dot jumps to the end of each word as it lands |\n| `anticipation` | A small counter-move before a big fast move |\n| `blur-through` | A hidden cut: the beat blurs out as the next blurs in |\n| `carry-over` | The last beat's element becomes part of the next (title \u2192 window label) |\n| `collide-and-merge` | Many elements converge and become one (tools \u2192 platform) |\n| `count-up` | A number rolls up on digit reels; for real stats only |\n| `depth-stack` | Cards fan out in depth while the camera tilts |\n| `easing-tokens` | The five eases and what each one is for |\n| `grid-assemble` | Tiles fly in to a grid, then a highlight wave keeps it alive |\n| `highlight-sweep` | A marker bar sweeps behind the key word |\n| `holds` | Reading time that stays alive: push, drift, a breathing accent |\n| `kinetic-type` | Words punch in one at a time; the key word lands in the accent |\n| `line-draw` | A line draws at a steady rate; nodes pop as it reaches them |\n| `mask-reveal` | Lines rise from behind an invisible edge |\n| `match-cut` | A hard cut between two framings of the same element |\n| `morph` | One shape grows into the next container (dot \u2192 card \u2192 window) |\n| `motion-blur` | Blur that peaks with speed on every fast move |\n| `orbit` | Satellites circle a hub, with labels kept upright |\n| `overshoot` | Landing past the mark and settling (back.out / elastic.out) |\n| `parallax` | Layers drift at different speeds to show depth |\n| `path-follow` | An arc from A to B with separate x and y eases |\n| `pop-in` | Chips and badges appear with an overshoot and focus-in |\n| `retype-in-place` | A typed line deletes and a new one types on the same baseline |\n| `scale-to-fill` | A word grows until it bleeds past both edges |\n| `split-and-scatter` | Letters fly apart, or assemble from scattered positions |\n| `staggered-entrance` | A list enters item by item with one shared move |\n| `swap-in-place` | One word in a fixed sentence slides out and the next slides in |\n| `typewriter` | Characters type with a caret that follows |\n| `whip-pan` | A very fast blurred camera move to the next panel |\n| `wipe` | A clip-path wipe with an accent bar riding the edge |\n| `zoom-out-reveal` | The camera pulls back to show the few are part of many |\n| `zoom-through` | The camera pushes into an element and the next beat is inside it |\n\n## More reading\n\n- `reference/continuity.md`: carry-overs, catalogued, and how one act hands off to the next.\n- `reference/timing.md`: beat lengths, the reading budget, holds, and the motion targets the film is checked against.\n- `reference/camera.md`: the `.world` layer, camera moves and their sizes.\n- `reference/breakdowns/`: grammar studies of finished films, beat by beat (what happens, the technique, what carries over). Read one with `read_skill_file` when you want to see how a whole film is put together; borrow its grammar, not its content or look.",
2567
+ "files": {
2568
+ "reference/breakdowns/documentary-trailer.md": "# Breakdown: documentary trailer (40 s)\n\nA grammar study of a style, not a description of one particular film: the trailer for a documentary or a true story. Still photographs carry it, kept alive by slow pushes and parallax. Titles rise out of darkness, facts are stated plainly, and the music builds to one reveal. Its pace is slower than motion graphics, but it is never still.\n\nUse it to learn the grammar. Build your own film's version of each idea; don't copy the beats.\n\n## Look\n\n- **Background:** near-black, with fine grain and a slight vignette. Letterbox bars are optional, and when used text stays inside the middle 70%.\n- **Type:** a restrained serif or a light condensed sans, generously tracked for titles (caps, `letter-spacing: .3em`) and plain for facts. Off-white, never pure white.\n- **Colour:** desaturated pictures (or duotone into one warm accent). The accent appears once or twice: a date, the title.\n- **Pictures:** real archive stills, documents and places. Each one gets a slow move. Captions name the place and date in small caps.\n- **Words on screen:** one short line per card, sometimes a single date or number.\n\n## Beats\n\n| # | Time | What happens | Technique | Carried into the next beat |\n|---|---|---|---|---|\n| 1 | 0.0\u20134.0 | Black. A single caption fades up: a place and a date. A low drone starts | holds, mask-reveal | The date's position |\n| 2 | 4.0\u20139.0 | A first still fades up behind the caption. The camera pushes slowly in; foreground and background drift apart (the picture is cut into two layers) | parallax, holds | The push |\n| 3 | 9.0\u201310.0 | The still blurs out as the next blurs in: a hidden cut on the push | blur-through | The push's direction |\n| 4 | 10.0\u201315.0 | A face (a portrait still), framed on a third. A real quote rises line by line beside it, attributed | mask-reveal | The portrait |\n| 5 | 15.0\u201316.0 | The title card: letters rise from a mask, very wide spacing, then hold dark | mask-reveal, holds | The black |\n| 6 | 16.0\u201324.0 | The build: four stills, 2 s each, each with a single plain fact. The cuts tighten (2 s \u2192 1.5 s \u2192 1 s), each a blur-through on the music's pulse | blur-through, kinetic-type | The tempo |\n| 7 | 24.0\u201327.0 | A real figure counts up on black (\"1,947 days\"), with one line under it | count-up | The number's centre |\n| 8 | 27.0\u201333.0 | The fastest section: half-second stills, a whip between two of them, the drone rising | whip-pan, motion-blur | The rising sound |\n| 9 | 33.0\u201334.0 | Silence and black: a hold, the one dead moment, on purpose | holds | \u2014 |\n| 10 | 34.0\u201340.0 | The title again, this time with the accent, then the date or platform. The slow push continues to the end | mask-reveal, holds | \u2014 |\n\n## The grammar, in short\n\n- **Stills are never still.** Every picture has a slow push, a pan or parallax for its whole time on screen. The camera move carries across the cut (a blur-through on a continuing push).\n- **Plain words.** Facts in short declarative lines. Only real quotes, with the speaker. Dates and places as small-caps captions.\n- **Acceleration.** Shot lengths shrink as the film builds (4 s \u2192 2 s \u2192 1 s \u2192 0.5 s), then stop dead for the reveal. The rhythm tells the story.\n- **Darkness is the transition.** Titles come out of black, and pictures fade up from black. Use dips only at the act breaks, not between every still.\n- **One number.** A single counted figure is a turning point. A wall of statistics is a report.\n- **The accent is saved** for the title and the payoff.\n\n## Timing targets\n\nThis is a documentary-paced film, so its targets are lower than motion graphics:\n\n- Motion density at least 0.6 (slow pushes count as motion; they should be visible, not imperceptible).\n- Holds over 0.8 s only where meant: the opening caption, the title, the silence.\n",
2569
+ "reference/breakdowns/editorial-explainer.md": '# Breakdown: editorial explainer (20 s)\n\nA grammar study of a style, not a description of one particular film: the magazine-style explainer. Cut-out pictures and labels sit on a clean editorial grid, a rail draws the argument across the frame, and the camera tracks along it. It explains one idea in a few steps, and every step is attached to the line that runs through the film.\n\nUse it to learn the grammar. Build your own film\'s version of each idea; don\'t copy the beats.\n\n## Look\n\n- **Background:** off-white paper, with a faint 12-column grid or dot grid. Hairline rules in a warm grey.\n- **Type:** a serif for headlines (big, tight, sentence case) and a small sans in caps for labels and numbers ("01", "STEP", "SOURCE").\n- **Colour:** one strong accent (a red, cobalt or green) for the rail, key words and markers. Everything else is ink on paper.\n- **Pictures:** cut-outs (subjects with their background removed) or tightly cropped photos in hard-edged frames, often duotoned into the ink and accent. Small captions next to them.\n- **Words on screen:** a headline (5\u20138 words) plus labels. Never paragraphs.\n\n## Beats\n\n| # | Time | What happens | Technique | Carried into the next beat |\n|---|---|---|---|---|\n| 1 | 0.0\u20131.8 | The question is set as a headline. It rises line by line from a mask, and the key word is underlined in the accent as it lands | mask-reveal, highlight-sweep | The underline |\n| 2 | 1.8\u20133.0 | The underline stretches past the headline and becomes the rail: a line that draws to the right edge. The camera starts to track along it | morph, line-draw | The rail and the camera\'s track |\n| 3 | 3.0\u20135.5 | The first node on the rail pops. A cut-out picture slides up behind it with a label ("01 \xB7 Collect") and a one-line caption. Slight parallax: the picture moves slower than the label | pop-in, staggered-entrance, parallax | The rail |\n| 4 | 5.5\u20138.0 | The rail keeps drawing. Node 2 pops, and a second cut-out and label arrive from the opposite side, so the frame stays balanced while tracking | line-draw, staggered-entrance | The rail |\n| 5 | 8.0\u201310.5 | Node 3. The picture is a diagram instead: three small boxes joined by lines that draw | line-draw, grid-assemble | The diagram\'s middle box |\n| 6 | 10.5\u201312.0 | The camera pushes into the middle box. It fills the frame and becomes the next scene\'s background panel | zoom-through | The panel |\n| 7 | 12.0\u201315.0 | Inside the panel, a real figure counts up, with its unit and source label. The key word of its caption gets the marker sweep | count-up, highlight-sweep | The figure |\n| 8 | 15.0\u201317.0 | The camera pulls back out through the panel. The whole rail is visible at once, all nodes lit: the argument at a glance | zoom-out-reveal | The full rail |\n| 9 | 17.0\u201320.0 | The rail curls into an underline under the answer (the headline\'s own words, now resolved). It holds and drifts | morph, holds | \u2014 |\n\n## The grammar, in short\n\n- **One line runs through the film.** The rail is the protagonist: it starts as an underline, carries the steps and ends as an underline. Every step hangs off it.\n- **Track, don\'t cut.** The camera follows the line sideways (a long, slow `x` move on `.world`), so new material enters from the frame edge as if it was always there.\n- **Balance alternates.** Content arrives above, then below the rail, or left then right, so the frame never tips.\n- **Labels make it editorial.** Small caps, numbers and sources next to every picture. They are what make a frame read as "explained" rather than "decorated".\n- **One push in, one pull out.** A single zoom-through into a detail and back is the film\'s big move. More than one gets dizzying.\n- **Markers instead of colour floods.** The accent appears as lines, dots and sweeps behind words.\n\n## Timing targets\n\n- 8\u201310 beats in 20 s; steps take 2\u20133 s each, because labels need reading time.\n- The rail draws at a steady rate (`linear`); everything else eases `expo.out`.\n- Motion density at least 0.85, from the constant tracking. No hold over 0.8 s apart from the final 1.5 s.\n',
2570
+ "reference/breakdowns/kinetic-type-promo.md": `# Breakdown: kinetic-type promo (12 s)
2571
+
2572
+ A grammar study of a style, not a description of one particular film: the all-type promo. There are no pictures. Words are the picture: they punch in on the beat, swap in place, fill the frame and fly through it. It suits a launch, an event, a sale or a manifesto, anything with a few strong lines and a beat to cut to.
2573
+
2574
+ Use it to learn the grammar. Build your own film's version of each idea; don't copy the beats.
2575
+
2576
+ ## Look
2577
+
2578
+ - **Background:** a flat colour that changes at the turns: black \u2192 the accent \u2192 white. The colour flips are the cuts.
2579
+ - **Type:** one heavy grotesk, very large (headline 14\u201330 cqmin, some words bleeding off the frame), tight tracking, all caps or sentence case but not both. A second weight at most.
2580
+ - **Colour:** two or three flat colours that swap roles (text and background trade places).
2581
+ - **Shapes:** a few accent shapes (a bar, a dot, a square) that act as punctuation.
2582
+ - **Words on screen:** 1\u20134 at a time.
2583
+
2584
+ ## Beats (at 120 bpm, a beat every 0.5 s)
2585
+
2586
+ | # | Time | What happens | Technique | Carried into the next beat |
2587
+ |---|---|---|---|---|
2588
+ | 1 | 0.0\u20131.0 | On black: "YOU" punches in (scale from 1.4, blur to sharp), then "MAKE" beside it on the next beat | kinetic-type, motion-blur | The line's baseline |
2589
+ | 2 | 1.0\u20132.0 | The third word swaps in place every beat: "VIDEOS" \u2192 "SLIDES" \u2192 "TEMPLATES" (each slides up out of a mask) | swap-in-place, mask-reveal | The slot |
2590
+ | 3 | 2.0\u20132.5 | The last word "TEMPLATES" is struck through by an accent bar that wipes across | wipe, accent-punctuation | The accent bar |
2591
+ | 4 | 2.5\u20133.0 | The bar grows to fill the frame: the background is now the accent colour | scale-to-fill | The accent background |
2592
+ | 5 | 3.0\u20134.5 | "WE MAKE" in black on the accent; then "FILMS." scales from tiny to bleeding past both edges | kinetic-type, scale-to-fill | The giant word |
2593
+ | 6 | 4.5\u20135.0 | Whip pan left: the giant word streaks out with heavy blur, and the next panel streaks in | whip-pan, motion-blur | The camera's speed |
2594
+ | 7 | 5.0\u20137.0 | Three short claims, one per beat pair, each a different size and position on a 3\xD73 grid ("NO TIMELINE." / "NO KEYFRAMES." / "JUST WORDS.") | kinetic-type, grid-assemble | The last claim |
2595
+ | 8 | 7.0\u20138.0 | "JUST WORDS." splits into letters that scatter, then reassemble as the product name | split-and-scatter | The name's letters |
2596
+ | 9 | 8.0\u201310.0 | The name holds, centre. Each beat, one letter bounces (a ripple across the name). An accent dot lands after it | overshoot, accent-punctuation | The name |
2597
+ | 10 | 10.0\u201312.0 | The colours flip to white. The URL types under the name; the camera eases back slightly | typewriter, holds | \u2014 |
2598
+
2599
+ ## The grammar, in short
2600
+
2601
+ - **The beat is the editor.** Every entrance, swap and flip lands on a beat (\`S.beat(n)\`). Off-beat moves look like mistakes.
2602
+ - **Type does everything.** Scale, weight, position and colour do the work pictures would. Contrast between neighbours keeps it lively: a tiny word next to a giant one, a left-aligned line after a centred one.
2603
+ - **Swap, don't replace.** A sentence stays and one word changes, so the eye stays in one place while the meaning moves.
2604
+ - **Colour flips are cuts.** A whole-frame colour change on a beat is the cleanest hidden cut in this style.
2605
+ - **Words fly through the camera.** scale-to-fill and whip-pan carry the energy between sections.
2606
+ - **Never more than 4 words.** If a line needs more, it's two beats.
2607
+
2608
+ ## Timing targets
2609
+
2610
+ - 18\u201324 moves in 12 s: a change on most beats.
2611
+ - Arrivals in 0.2\u20130.35 s (\`expo.out\`), exits in 0.15\u20130.25 s (\`expo.in\`).
2612
+ - Motion density at least 0.9. Median change is high: the colour flips spike it, and that's intended.
2613
+ `,
2614
+ "reference/breakdowns/playful-shapes.md": "# Breakdown: playful shapes intro (15 s)\n\nA grammar study of a style, not a description of one particular film: the bright, bouncy intro, where soft 3D-ish shapes are the characters. Orbs, pills and blobs drift, bounce, gather and merge, and the brand is what they become. It suits an introduction to a service, a friendly launch, or a feature for a broad audience.\n\nUse it to learn the grammar. Build your own film's version of each idea; don't copy the beats.\n\n## Look\n\n- **Background:** a light, saturated-but-soft ground (pale lilac, mint, butter) or a deep night blue for a glowing version. Soft colour blooms behind.\n- **Shapes:** spheres with a highlight (a radial gradient from a light spot to the colour) and a soft shadow or glow. 4\u20138 colours from one cheerful palette, plus the brand's colour for the hero shape.\n- **Type:** a rounded or geometric sans, bold, short lines. Labels sit on or under the shapes.\n- **Depth:** shapes sit at different scales and blur levels (near ones bigger and sharper), and the camera tilts gently, so the field feels 3D.\n- **Words on screen:** 2\u20135.\n\n## Beats\n\n| # | Time | What happens | Technique | Carried into the next beat |\n|---|---|---|---|---|\n| 1 | 0.0\u20131.2 | Orbs pop in one by one at scattered positions, each with an overshoot and a focus-in. Each carries a label: the jobs a small business juggles | pop-in, overshoot, staggered-entrance | The orbs |\n| 2 | 1.2\u20133.0 | The orbs drift, each at its own speed and scale (depth). The camera floats and tilts a few degrees. A line above: \"Seven apps. Seven logins.\" | parallax, depth-stack, kinetic-type | The orbs' drift |\n| 3 | 3.0\u20133.6 | Anticipation: every orb pulls back slightly outwards | anticipation | The tension |\n| 4 | 3.6\u20134.4 | They rush to the centre and collide. A squash-and-stretch on impact, a flash, one big orb in the brand colour | collide-and-merge, motion-blur | The big orb |\n| 5 | 4.4\u20135.6 | The big orb settles with a wobble (elastic). The brand name pops onto it. A soft hit in the music | overshoot, pop-in | The big orb |\n| 6 | 5.6\u20138.5 | The old orbs come back as small satellites orbiting the big one. Each satellite now shows a feature, connected | orbit | The orbit |\n| 7 | 8.5\u201310.5 | The camera follows one satellite and pushes into it. It opens into a rounded card showing the feature (a simple UI drawn in shapes) | zoom-through, morph | The card |\n| 8 | 10.5\u201312.5 | The card's elements bounce in one by one (a toggle flips, a chip pops, a checkmark draws) | pop-in, line-draw | The card |\n| 9 | 12.5\u201315.0 | Pull back: the card shrinks back into a satellite, and the whole system is visible again. The URL rises under it; the satellites keep orbiting | zoom-out-reveal, mask-reveal, holds | \u2014 |\n\n## The grammar, in short\n\n- **Shapes are characters.** They have weight: they anticipate before a big move, overshoot when they land, and squash on impact. That gives the film its personality.\n- **The story is a transformation.** Many things become one (collide-and-merge), and the one gives back the many as connected parts (orbit). That is the whole argument of an all-in-one product, told without a sentence.\n- **Depth makes it rich.** Scale and blur by distance, plus a slowly tilting camera. Flat shapes on a flat field look like clip art.\n- **Bouncy but controlled.** Use `back.out` and `elastic.out` on landings, and `sine.inOut` on drift. Elastic goes only on the hero moment; elastic everywhere is seasick.\n- **Always something floating.** Between beats the shapes keep drifting, so a hold never freezes.\n\n## Timing targets\n\n- 9\u201312 beats in 15 s.\n- Landings take 0.4\u20130.7 s, drifts are continuous, and the collide is fast (0.5\u20130.8 s, `expo.in`).\n- Motion density at least 0.9. No hold over 0.8 s except the final 1.5 s, which still orbits.\n",
2615
+ "reference/breakdowns/typed-ui-launch.md": `# Breakdown: typed-UI launch film (13.4 s)
2616
+
2617
+ A light, minimal product launch film for an AI video tool, 13.4 s at 16:9. It reads as one continuous move: every idea grows out of the one before it, the camera never stops, and the cuts are hidden inside blurs and zooms.
2618
+
2619
+ This file describes the film's grammar: timing, techniques, what carries over at each change, and how it measures. It is not code to copy. Use it to understand how a film like this is built, then build your own film's version of each idea.
2620
+
2621
+ ## Look
2622
+
2623
+ - Background: warm off-white paper (#f7f6f2-ish), with very soft colour blooms (pale blue and pink) drifting in the corners.
2624
+ - Type: one geometric sans, regular weight, dark grey-black. The key word of each line is set in one blue accent (#3b6cf6-ish), sometimes as a gradient.
2625
+ - Accent objects: small blue squares, and one blue dot at the very end.
2626
+ - Imagery: soft product UI panels (editor screenshots, almost unreadable, used as texture) and, late in the film, a set of vivid photographic thumbnails. The photos are the only saturated thing in the film.
2627
+ - Words on screen: never more than 6 at once.
2628
+
2629
+ ## Beats
2630
+
2631
+ | # | Time | What happens | Technique | Carried into the next beat |
2632
+ |---|---|---|---|---|
2633
+ | 1 | 0.0\u20132.1 | "Still hand-animating" types in, about 10 characters/s, with a caret. The second half is in the blue accent. The line fades and sharpens in over 0.3 s. From 1.0 s, blurred UI panels drift in around the edges. The camera eases back (the type shrinks from about 1.35\xD7 to 1\xD7). | Typewriter; blur-in; slow pull-back; depth blur on background texture | The line's position and the caret |
2634
+ | 2 | 2.2\u20134.4 | The line is replaced in place: "your launch video?" types in, with "video?" in blue. The caret blinks at the end for about 1 s. The panels keep drifting. | In-place retype (same baseline, same caret) | The whole frame |
2635
+ | 3 | 4.3\u20134.8 | The whole frame blurs out over 0.3 s into the paper and its colour bloom. The brand name blurs in, small, in a soft gradient. | Blur-through transition (the cut is hidden inside the blur) | The brand name |
2636
+ | 4 | 4.8\u20135.2 | The brand name becomes the label of the app's window: the window appears around it, tilted in perspective (about 8\u201310\xB0), and the camera is already rotating. | **Carry-over**: the name becomes part of the next object; 3D tilt | The window, the tilt |
2637
+ | 5 | 5.2\u20135.9 | Very close on the tilted window, "Hello, Sri." types in blue italic, then "What are we shipping today?" types in grey. The camera pans right with the typing, so the text slides across the frame at an angle. | Camera follows the typing; extreme close-up; perspective | The question and the camera move |
2638
+ | 6 | 5.9\u20137.0 | Option cards slide in under the question one after another ("Ship the demo", "Cut a launch video", "Render the changelog", "Draft the release post"). The camera keeps panning across them. | Staggered entrance during a camera move | The window |
2639
+ | 7 | 7.0\u20137.9 | The camera pulls back and un-tilts: the window flattens and shrinks to a small card in the centre, then blurs out. | Pull-back reveal; perspective resolves to flat | The centre of the frame |
2640
+ | 8 | 8.1\u20138.9 | A grid of small tiles (template thumbnails) blurs in at the centre, with four dark labelled cards around it. The tiles flicker between blue and grey as if being browsed. | Blur-in; living texture (tiles keep changing) | The tiles |
2641
+ | 9 | 8.9\u20139.5 | The grid collapses: tiles fly out and a few become small blue squares that move with the text. "Turn your product into video" types word by word, with "video" in blue, while the squares jump alongside like punctuation. The dark cards drift outwards. | Elements **transform** into the next beat's accents; kinetic words | The centre column |
2642
+ | 10 | 9.6\u201310.8 | Five photos fly in with motion blur and lock into a staggered column. A chip, "5 components", pops in over them (blur to sharp). | Motion-blurred fly-in; pop | The photo grid |
2643
+ | 11 | 10.9\u201311.1 | A hard zoom out: the five photos are revealed as part of a wall of many more, and the chip now says "500 components". | **Zoom-out reveal** with the counter changing (the same object, bigger idea) | The whole frame |
2644
+ | 12 | 11.2\u201312.3 | The wall blurs out. The brand wordmark is set huge, bleeding past both edges. Its colour sweeps from pale grey-blue to navy to near-black, left to right. | Scale-to-fill; colour sweep | The wordmark |
2645
+ | 13 | 12.4\u201313.1 | A match cut to an extreme crop of the wordmark's last letter and the blue dot. It holds with a slight drift. | **Match cut on the same element** (wide \u2192 detail) | \u2014 |
2646
+ | 14 | 13.2\u201313.4 | Blur and fade out. | Blur-out | \u2014 |
2647
+
2648
+ ## The grammar, in short
2649
+
2650
+ - **Beats are short.** 14 beats in 13.4 s: most last 0.3\u20131.2 s. The longest holds are about 1 s, on the two questions.
2651
+ - **Every change has a carry-over.** It might be the same line retyped, the brand name becoming the window label, tiles becoming squares, five photos becoming five hundred, or the wordmark becoming its own crop. Nothing just appears from nowhere.
2652
+ - **Hidden cuts.** Where a hard change is needed, it happens inside a 0.2\u20130.3 s blur (beats 3, 7, 11\u201312), or as a match cut on the same element (beat 13). No dissolves between unrelated layouts.
2653
+ - **The camera is always moving:** pull-back, rotation, pan with the type, untilt, zoom-out. It moves in depth (tilt and perspective), not just in 2D.
2654
+ - **Type is the main actor.** Lines type in with a caret, the key word is in the accent colour, and lines are replaced in place.
2655
+ - **Texture, not information.** The UI panels are atmosphere; nobody is meant to read them.
2656
+ - **One accent colour,** used on the key word, the small squares and the final dot.
2657
+ - **Motion blur on every fast move,** and a blur-in on every new element.
2658
+
2659
+ ## How it measures
2660
+
2661
+ Measured on 192\xD7108 greyscale frames at 10 fps (\`plan/sequences.md\` \u2192 motion metrics):
2662
+
2663
+ | Metric | Value |
2664
+ |---|---|
2665
+ | Motion density (share of steps with visible change) | 0.99 |
2666
+ | Median change per step (0\u2013255 scale) | 3.5 |
2667
+ | Holds over 0.8 s with no visible change | none |
2668
+ | Jumps (a big single-step change with calm frames either side) | none |
2669
+
2670
+ For comparison, a recent slide-like film made by the assistant (centred cards, about 4 s each, dissolves) measured density 0.63, median change 0.76, and 5 holds over 0.8 s. A 90 s documentary-style film measured 0.32, 0.45 and 17 holds.
2671
+ `,
2672
+ "reference/camera.md": '# Camera\n\nThe camera is the `.world` layer: everything inside `<div class="world">\u2026</div>` moves with it. `tl.camera(vars, position)` animates that layer:\n\n| Camera var | What it does | Typical range |\n|---|---|---|\n| `zoom` | push in (> 1) or pull back (< 1) | 0.9\u20131.15 for drift; 3\u20137 for zoom-through and reveals |\n| `x`, `y` | pan; always `S.w(%)` / `S.h(%)` | 2\u20136% for drift; 100% for a whip to the next panel |\n| `rotate` | roll | \xB11\u20133\xB0 |\n| `tiltX`, `tiltY` | tilt in depth (perspective is set for you) | 6\u201320\xB0 |\n| `duration`, `ease` | default 1.2 s, power2.inOut | match the beat or overlap it |\n\nElements **outside** `.world` (for example a `.hud` with a chip or a caption) don\'t move with the camera.\n\nThe camera zooms about the `.world` transform-origin (the centre by default). To push into a detail, put the origin on that detail. Measure it with `getBoundingClientRect()` before any camera move, then `tl.set(".world", { transformOrigin: "x% y%" }, 0)`.\n\n## The camera keeps moving, except in a declared rest\n\nGive each beat one camera move that lasts the beat, or longer, overlapping the next. A static camera is allowed only during a hold that has something else moving, and for less than 0.8 s. The one exception is a stretch you declare with `tl.rest(from, to)` (stage version 2): everything in `.world` is dead still on purpose, for a punchline or a pause. Undeclared stillness is still flagged, and the build fails if something moves during a declared rest. After composing, run `check_sequence` to see which beats start from nothing.\n\n## Moves and when to use them\n\n- **Slow push (zoom 1 \u2192 1.05\u20131.1, sine.inOut):** the default for any beat. It adds life without drawing attention.\n- **Pull back (zoom 1.3 \u2192 1):** opening a beat close on a detail and revealing context. At an extreme, it\'s a zoom-out-reveal.\n- **Pan with the type (x \u22123 to \u22126%):** while a line types or words land left to right, drift the same way so the text slides across at an angle.\n- **Tilt in depth (tiltX/tiltY 8\u201320\xB0):** windows, cards and stacks feel like objects in a space. Combine it with a slight zoom.\n- **Untilt to resolve (tilt \u2192 0, power3.inOut):** the end of a section. The frame flattens as the idea lands.\n- **Push-through (zoom to 4\u20137, expo.in):** into an element whose colour becomes the next background (zoom-through). Reset the camera on the frame where only that colour fills the screen.\n- **Whip (x \xB1100%, 0.3\u20130.4 s, power4.inOut, with blur):** to the next panel (whip-pan).\n- **Kick (zoom \u22125% in 0.25 s, then back):** on an impact (collide-and-merge, a beat hit).\n\n## Rules\n\n- Sizes are always in S units (`S.w`, `S.h`, `S.min`). Never pixel numbers: the act must work in 16:9, 9:16 and 1:1.\n- One camera move at a time per property. Start the next move when the last one ends (or with `">"`), not on top of it.\n- Pair tilt with `transform-style: preserve-3d` on `.world` and on any wrapper when children use `z` (depth-stack).\n- Keep the subject inside the safe area during moves (about 5% in from each edge, more in 9:16). Check the filmstrip.\n- Don\'t reset the camera to neutral between beats. Carry the motion on.\n\n## The operated camera (version 2)\n\nAdd `data-stage-version="2"` to the stage root and the camera becomes a track you can operate, not just keyframe. Everything below sums on the one `.world` layer, so moves never fight each other.\n\n| Call | Use it to |\n|---|---|\n| `tl.camera({ zoom })` | Zoom in log space: 1 to 100 reads 10 halfway, so a long dive feels even |\n| `tl.camera({ anchor, at: [x%, y%], zoom })` | Hold a subject at a screen position while the zoom changes. Use it for every zoom above 50\xD7 |\n| `tl.follow(target, { lead: 0.1 })` | Chase a moving subject, keyed slightly ahead of it |\n| `tl.whip(target, { duration })` | Snap to the next subject and land with it centred |\n| `tl.zoomThrough(target, { into })` | Push into a surface and hand over to the scene inside it |\n| `tl.handheld({ amount }, from, to)` | A slow float so a held shot still breathes |\n| `tl.shake(position, { amp, decay, seed })` | A hit shake that reads the same at any zoom |\n\nKeep the subject inside the safe area while the camera moves, and put hits on the beat with `tl.hit` so sound can follow them.\n',
2673
+ "reference/continuity.md": '# Continuity\n\nThe difference between a motion-graphics film and a slideshow is continuity: **every beat begins from something already on screen in the beat before.** If you can cover the previous frame with your hand and the new beat still makes sense on its own, it\'s a slide.\n\n## The test, beat by beat\n\nFor every change of beat, write one line: *"<what> from beat N becomes / moves into <what> in beat N+1"*. If you can\'t fill it in, design a carry-over before writing code.\n\n## Carry-overs, catalogued\n\n| Carry-over | Example | Technique |\n|---|---|---|\n| Same object, new role | The brand name becomes the label of the app window that grows around it | carry-over, morph |\n| Same line, new words | "Still hand-animating" deletes back and "your launch video?" types on the same baseline | retype-in-place, swap-in-place |\n| Shape grows into container | A dot becomes a card, the card becomes a window | morph |\n| Parts become accents | Grid tiles fly out, and a few become the small squares that punctuate the next line | grid-assemble \u2192 accent-punctuation |\n| Many become one | Ten orbs collide and merge into the logo | collide-and-merge |\n| One becomes many | Five photos are revealed as part of a wall of five hundred | zoom-out-reveal |\n| Same element, new framing | The wordmark cuts to an extreme close-up of its last letter | match-cut |\n| Into the element | The camera pushes into a card, and the next beat is inside it | zoom-through |\n| A path between | A dot travels from "Lead" to "Booked" and the target lights up | path-follow, line-draw |\n| The camera itself | A continuous pan that keeps going across a whip into the next panel | whip-pan, parallax |\n\nWhen nothing can carry over (a genuinely new subject), hide the cut: blur-through (0.25\u20130.3 s) or whip-pan. Use this once or twice per act at most.\n\n## Keep the anchor still while things change around it\n\nThe eye follows one thing at a time. During a hand-off, keep **one** element as the anchor: the caret, the key word, the logo. Move it smoothly while the new beat arrives around it. Changing everything at once reads as a cut, even without one.\n\n## Between acts\n\nA film is a few acts, each one compose_sequence clip. The join between acts follows the same rule:\n\n- **The next act starts where the last one ended.** If act 1 ends on the logo centred at 60% height, act 2\'s first frame has the logo centred at 60% height (same size, same colour), and act 2 moves on from there.\n- Write the end state of each act down (what\'s on screen, where, what size) before composing the next act, and reproduce it as act 2\'s starting state with `tl.set(\u2026, 0)`.\n- The default join is a 0.3 s dissolve. When the two frames match, the dissolve is invisible and the film reads as one move. Use `join: "cut"` when they match exactly. Use `join: "dip"` only at a real chapter break.\n- Keep the camera\'s motion going across the join: if act 1 ends drifting left, start act 2 drifting left.\n\n## Things that break continuity\n\n- A dissolve between two unrelated layouts.\n- An element that pops in from nothing in the middle of an act, with no cause (no line reaching it, no word landing).\n- Re-animating a whole sentence to change one word.\n- An act that starts on an empty frame and builds from zero (only the very first act may).\n- Resetting the camera to a neutral position between beats.\n\nRun `check_sequence` after composing: it names every beat that starts from nothing (bare) and suggests the carry that would fix it.\n',
2674
+ "reference/rules/accent-punctuation.md": '# Accent punctuation\n\n**What:** A small accent shape (a square, a dot) moves with the words like punctuation, jumping to the end of each word as it lands.\n**When:** Kinetic lines of short words ("Plan. Build. Ship."); it carries the accent colour and gives the eye a guide.\n**Timing:** Each jump 0.25\u20130.3 s (expo.out), timed with the word it follows.\n**Mistake:** More than one accent object per line, or an accent that wanders without following anything.\n\n```html\n<div class="world"><p class="line"><span class="txt">Plan. Build. Ship.</span><i class="sq"></i></p></div>\n```\n\n```css\n.line{position:absolute;left:14cqw;top:42cqh;font-size:11cqmin;font-weight:900;white-space:nowrap}.sq{position:absolute;left:0;top:.62em;width:.22em;height:.22em;background:var(--p)}\n```\n\n```js\nconst words = S.split(".line .txt", "words");\ntl.label("say", 0.2).set(".sq", { x: 0, scale: 0 }, 0);\nwords.forEach((w, i) => {\n const at = "say+=" + i * 0.45;\n tl.from(w, { y: S.h(5), opacity: 0, blur: 8, duration: 0.4, ease: "expo.out" }, at)\n .to(".sq", { x: w.offsetLeft + w.offsetWidth + S.min(1), scale: 1, rotate: 90 * (i + 1), duration: 0.3, ease: "expo.out" }, at);\n});\ntl.camera({ x: -S.w(3), zoom: 1.05, duration: 3, ease: "sine.inOut" }, 0);\n```\n',
2675
+ "reference/rules/anticipation.md": '# Anticipation\n\n**What:** A small move the opposite way just before the main move: a card pulls back and dips before it launches.\n**When:** Before any big, fast move (a launch, a throw, a whip). It tells the eye where to look a fraction before things happen.\n**Timing:** Wind-up 0.12\u20130.2 s (power2.out), then the main move 0.4\u20130.6 s (expo.out). The wind-up is 2\u20135% of the main move\'s distance.\n**Mistake:** A wind-up as big or as slow as the move itself: it reads as hesitation, not energy.\n\n```html\n<div class="world"><div class="card">Ship it</div></div>\n```\n\n```css\n.card{position:absolute;left:30cqw;top:42cqh;padding:3cqmin 5cqmin;border-radius:2cqmin;background:var(--p);color:var(--bg);font-size:6cqmin;font-weight:700}\n```\n\n```js\ntl.label("wind", 0.4)\n .to(".card", { x: -S.w(2), scale: 0.94, duration: 0.18, ease: "power2.out" }, "wind")\n .to(".card", { x: S.w(30), scale: 1, duration: 0.55, ease: "expo.out" }, ">")\n .camera({ x: -S.w(4), duration: 1.6, ease: "sine.inOut" }, "wind");\n```\n',
2676
+ "reference/rules/blur-through.md": '# Blur-through\n\n**What:** A hidden cut: the whole beat blurs out and the next one blurs in, overlapping by a few frames, so no layout ever dissolves into another.\n**When:** A hard change of idea inside an act when nothing can carry over (and at most once or twice per act).\n**Timing:** Out 0.25\u20130.3 s (power2.in, blur to 16\u201324 px, slight scale up). In 0.4 s (expo.out) starting halfway through the out.\n**Mistake:** A slow cross-dissolve between two full layouts: both are half-visible and the frame turns to mush.\n\n```html\n<div class="world"><div class="center"><p class="a">Too many tools.</p></div><div class="center"><p class="b">One platform.</p></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.a,.b{font-size:10cqmin;font-weight:800}.b{color:var(--p)}\n```\n\n```js\ntl.set(".b", { opacity: 0 }, 0)\n .label("a", 0)\n .from(".a", { opacity: 0, blur: 12, duration: 0.5, ease: "expo.out" }, "a")\n .label("cut", 1.6)\n .to(".a", { opacity: 0, blur: 22, scale: 1.08, duration: 0.28, ease: "power2.in" }, "cut")\n .fromTo(".b", { opacity: 0, blur: 22, scale: 0.94 }, { opacity: 1, blur: 0, scale: 1, duration: 0.4, ease: "expo.out" }, "cut+=0.14")\n .camera({ zoom: 1.08, duration: 3.4, ease: "sine.inOut" }, "a");\n```\n',
2677
+ "reference/rules/carry-over.md": '# Carry-over\n\n**What:** Something from the last beat becomes part of the next: a title becomes the label of the window that appears around it.\n**When:** Every change of beat inside an act. It is the main thing that makes a film one continuous move instead of slides.\n**Timing:** The hand-off move 0.5\u20130.8 s (expo.inOut); the new container arrives during it, never after.\n**Mistake:** Fading the old element out and a new copy in: the viewer must see the same object travel.\n\n```html\n<div class="world"><div class="win"><div class="bar"><i></i><i></i><i></i></div><div class="body"><p>Hello, Sam.</p><p class="q">What are we shipping today?</p></div></div><div class="center"><p class="name">Orbit Studio</p></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.name{font-size:10cqmin;font-weight:800;white-space:nowrap}.win{position:absolute;left:14cqw;top:14cqh;width:72cqw;height:72cqh;border-radius:2cqmin;background:var(--card);border:.3cqmin solid var(--line);overflow:hidden}.bar{height:8cqh;display:flex;gap:1cqmin;align-items:center;padding-left:2cqmin;border-bottom:.3cqmin solid var(--line)}.bar i{width:1.4cqmin;height:1.4cqmin;border-radius:50%;background:var(--line)}.body{padding:6cqmin;font-size:5cqmin;font-weight:600}.q{color:var(--mut)}\n```\n\n```js\ntl.label("name", 0)\n .from(".name", { opacity: 0, blur: 12, scale: 1.2, duration: 0.6, ease: "expo.out" }, "name")\n .label("hand", 1.3)\n // The name travels into the window\'s title bar; the window grows around it at the same time.\n .to(".name", { x: -S.w(22), y: -S.h(32), scale: 0.32, duration: 0.7, ease: "expo.inOut" }, "hand")\n .from(".win", { scale: 0.7, opacity: 0, blur: 10, duration: 0.7, ease: "expo.inOut" }, "hand")\n .from(".body p", { y: S.h(3), opacity: 0, duration: 0.5, stagger: 0.15, ease: "expo.out" }, "hand+=0.6")\n .camera({ zoom: 1.1, tiltY: -8, duration: 3.4, ease: "sine.inOut" }, "name");\n```\n\n## With `tl.hop`, `tl.gather` and `tl.morph` (version 2)\n\nThe hand-off can be a verb: `tl.hop` carries an element to its next slot on an arc and lands with a squash, `tl.gather` flies many elements into one container, and `tl.morph` turns one box into another. Each one records the carry, so the shot check counts it.\n\n```html v2\n<div class="world"><div class="dot"></div><div class="slot"><p class="label">Ready</p></div></div>\n```\n\n```css v2\n.dot{position:absolute;left:12cqw;top:60cqh;width:7cqmin;height:7cqmin;border-radius:50%;background:var(--p)}.slot{position:absolute;left:62cqw;top:56cqh;width:26cqw;height:16cqh;border-radius:2cqmin;background:var(--card);border:.3cqmin solid var(--line);display:grid;place-items:center}.label{margin:0;font-size:4.4cqmin;font-weight:700}\n```\n\n```js v2\ntl.label("dot", 0)\n .from(".dot", { scale: 0, duration: 0.4, ease: "back.out" }, "dot")\n .from(".slot", { opacity: 0, duration: 0.4, ease: "expo.out" }, "dot+=0.2")\n .hop(".dot", { to: ".slot", height: S.h(14), duration: 0.7 }, 0.9)\n .impact(".slot", 1.55, { squash: 0.06 })\n .from(".label", { y: S.h(2), opacity: 0, duration: 0.4, ease: "expo.out" }, 1.6)\n .camera({ zoom: 1.08, duration: 3, ease: "sine.inOut" }, "dot");\n```\n',
2678
+ "reference/rules/collide-and-merge.md": '# Collide and merge\n\n**What:** Several elements converge on one point and become one new element.\n**When:** Many-into-one ideas: tools into a platform, steps into a result, voices into one message.\n**Timing:** Gather 0.6\u20130.8 s (expo.in, accelerating into the hit). The merged element pops 0.1 s before they arrive (back.out). A short camera kick (zoom \u22125%) on the hit.\n**Mistake:** The parts arriving one by one and fading: the hit must be one moment.\n\n```html\n<div class="world"><i class="part"></i><i class="part"></i><i class="part"></i><i class="part"></i><i class="part"></i><div class="core">One</div></div>\n```\n\n```css\n.part{position:absolute;left:50%;top:50%;width:9cqmin;height:9cqmin;margin:-4.5cqmin 0 0 -4.5cqmin;border-radius:50%;background:var(--s);box-shadow:0 0 4cqmin var(--s)}.part:nth-child(2n){background:var(--p);box-shadow:0 0 4cqmin var(--p)}.core{position:absolute;left:50%;top:50%;width:24cqmin;height:24cqmin;margin:-12cqmin 0 0 -12cqmin;border-radius:50%;background:var(--p);color:var(--bg);display:grid;place-items:center;font-size:6cqmin;font-weight:800}\n```\n\n```js\nconst spots = [[-32, -20], [30, -24], [-36, 18], [34, 16], [0, 30]];\ntl.set(".core", { scale: 0, opacity: 0 }, 0)\n .set(".part", { x: (i) => S.w(spots[i][0]), y: (i) => S.h(spots[i][1]) }, 0)\n .label("drift", 0)\n .from(".part", { scale: 0, opacity: 0, duration: 0.6, stagger: 0.08, ease: "back.out" }, "drift")\n .to(".part", { y: (i) => S.h(spots[i][1] - 4), duration: 1.6, ease: "sine.inOut" }, "drift+=0.4")\n .label("hit", 2)\n .to(".part", { x: 0, y: 0, scale: 0.4, duration: 0.7, stagger: 0.03, ease: "expo.in" }, "hit")\n .to(".core", { scale: 1, opacity: 1, duration: 0.5, ease: "back.out" }, "hit+=0.6")\n .to(".part", { opacity: 0, duration: 0.15 }, "hit+=0.75")\n .camera({ zoom: 0.95, duration: 0.25, ease: "power2.out" }, "hit+=0.7")\n .camera({ zoom: 1.04, duration: 1.6, ease: "sine.inOut" }, ">");\n```\n',
2679
+ "reference/rules/count-up.md": '# Count-up\n\n**What:** A number rolls up to its value on digit reels (each digit is a column of 0\u20139 that slides), like an odometer.\n**When:** Stats and counters: "500 templates", "3\xD7 more calls". Rolling digits read as growth; a static number reads as a slide.\n**Timing:** 1.2\u20131.6 s (expo.out), reels staggered 0.1 s from the last digit. Hold the result 0.6\u20131 s with a slow push.\n**Mistake:** Inventing numbers: only count to a real figure from the brief or the user. The value is a `{{param}}`-friendly attribute.\n\n```html\n<div class="world"><div class="center"><div class="stat"><p><span class="num" data-to="500"></span><span class="plus">+</span></p><p class="cap">templates</p></div></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.stat{text-align:center}.stat p:first-child{font-size:22cqmin;font-weight:900;line-height:1;letter-spacing:-.03em}.slot{display:inline-block;height:1em;overflow:hidden;vertical-align:top}.reel{display:flex;flex-direction:column}.reel span{height:1em;line-height:1}.plus{color:var(--p)}.cap{font-size:5cqmin;color:var(--mut);margin-top:1.6cqmin}\n```\n\n```js\n// Build one reel per digit of data-to: 0\u20139 twice, so each reel spins at least once.\nconst host = document.querySelector(".num");\nconst reels = [], stops = [];\nfor (const ch of host.getAttribute("data-to")) {\n const slot = document.createElement("span");\n slot.className = "slot";\n if (/[0-9]/.test(ch)) {\n const reel = document.createElement("span");\n reel.className = "reel";\n for (let k = 0; k < 20; k++) {\n const d = document.createElement("span");\n d.textContent = String(k % 10);\n reel.appendChild(d);\n }\n slot.appendChild(reel);\n reels.push(reel);\n stops.push(10 + Number(ch));\n } else slot.textContent = ch;\n host.appendChild(slot);\n}\ntl.label("count", 0.3)\n .from(".stat", { opacity: 0, blur: 10, scale: 0.9, duration: 0.5, ease: "expo.out" }, 0)\n .to(reels, { y: (i) => -stops[i] * 5 + "%", duration: 1.4, stagger: { each: 0.1, from: "last" }, ease: "expo.out" }, "count")\n .from(".plus", { scale: 0, duration: 0.4, ease: "back.out" }, ">-=0.3")\n .camera({ zoom: 1.1, duration: 3.2, ease: "sine.inOut" }, 0);\n```\n\nEach reel holds 20 digits, so stopping on digit d means moving it up by (10 + d) \xD7 5% of its height.\n',
2680
+ "reference/rules/depth-stack.md": '# Depth stack\n\n**What:** Flat cards fan out in depth (z) while the camera tilts, so a stack of things becomes a space.\n**When:** Layers of a product, versions, a pile of documents, options to choose from.\n**Timing:** Fan-out 0.8\u20131 s (expo.inOut), stagger 0.05 s. Camera tilt 8\u201320\xB0 over the whole beat, then untilt to resolve.\n**Mistake:** Forgetting `transform-style: preserve-3d` on `.world` and the wrapper: the cards flatten into one plane.\n\n```html\n<div class="world"><div class="center"><div class="stack"><div class="card">Draft</div><div class="card">Review</div><div class="card">Approved</div><div class="card">Live</div></div></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.world,.center,.stack{transform-style:preserve-3d}.stack{display:grid}.card{grid-area:1/1;width:44cqmin;height:28cqmin;border-radius:2cqmin;background:var(--card);border:.3cqmin solid var(--line);display:grid;place-items:center;font-size:5cqmin;font-weight:700}.card:last-child{background:var(--p);color:var(--bg)}\n```\n\n```js\nconst cards = document.querySelectorAll(".card");\ntl.label("stack", 0)\n .from(cards, { y: S.h(6), opacity: 0, duration: 0.5, stagger: 0.06, ease: "expo.out" }, "stack")\n .label("fan", 0.9)\n .to(cards, { z: (i) => S.min((i - 3) * 16), x: (i) => S.w((i - 1.5) * 6), y: (i) => -S.h((i - 1.5) * 5), duration: 0.9, stagger: 0.05, ease: "expo.inOut" }, "fan")\n .camera({ tiltX: 18, tiltY: -24, zoom: 0.95, duration: 1.4, ease: "power3.inOut" }, "fan")\n .camera({ tiltX: 0, tiltY: 0, zoom: 1.05, duration: 1.2, ease: "power3.inOut" }, "fan+=1.9");\n```\n',
2681
+ "reference/rules/easing-tokens.md": '# Easing tokens\n\n**What:** A small set of eases, each with one job, used consistently across the film.\n**When:** Always: pick from these before trying anything else.\n**Timing:** expo.out for entrances (0.4\u20130.7 s); expo.in for exits (0.3\u20130.4 s); power3.inOut / expo.inOut for moves between places and the camera (0.6\u20131.2 s); back.out for pops; sine.inOut for drift and holds (2\u20134 s); linear only for lines drawing at a steady rate.\n**Mistake:** Linear moves for objects (mechanical), or a different ease on every element (no house style).\n\n```html\n<div class="world"><div class="rows"><p><span>expo.out</span><i></i></p><p><span>expo.in</span><i></i></p><p><span>power3.inOut</span><i></i></p><p><span>back.out</span><i></i></p><p><span>sine.inOut</span><i></i></p></div></div>\n```\n\n```css\n.rows{position:absolute;left:8cqw;top:14cqh;display:flex;flex-direction:column;gap:6cqh;font:500 3.6cqmin ui-monospace,monospace;color:var(--mut)}.rows p{position:relative;display:flex;align-items:center;width:84cqw;height:5cqmin}.rows span{width:22cqw}.rows i{position:absolute;left:22cqw;width:5cqmin;height:5cqmin;border-radius:50%;background:var(--p)}\n```\n\n```js\nconst eases = ["expo.out", "expo.in", "power3.inOut", "back.out", "sine.inOut"];\ntl.label("go", 0.3);\ndocument.querySelectorAll(".rows i").forEach((dot, i) => tl.to(dot, { x: S.w(52), duration: 1.2, ease: eases[i] }, "go"));\ntl.camera({ zoom: 1.03, duration: 2.4, ease: "sine.inOut" }, 0);\n```\n',
2682
+ "reference/rules/grid-assemble.md": '# Grid assemble\n\n**What:** Tiles fly in from scattered, seeded positions and lock into a grid; then the grid stays alive (a wave of highlight passes through it).\n**When:** Libraries, catalogues, "all your templates", any many-of-a-kind moment. The grid can later collapse or zoom out into a bigger wall.\n**Timing:** Fly-in 0.6\u20130.8 s (expo.out), stagger 0.03\u20130.05 s. A highlight wave 0.3 s per tile, from the centre.\n**Mistake:** Tiles that land and freeze: once assembled, something in the grid must keep changing.\n\n```html\n<div class="world"><div class="center"><div class="grid"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.grid{display:grid;grid-template-columns:repeat(4,14cqmin);gap:2cqmin}.grid i{height:10cqmin;border-radius:1.4cqmin;background:var(--card);border:.3cqmin solid var(--line)}\n```\n\n```js\nconst r = S.rand(3);\nconst tiles = document.querySelectorAll(".grid i");\nconst off = [...tiles].map(() => [(r() - 0.5) * 120, (r() - 0.5) * 100, (r() - 0.5) * 90]);\ntl.label("fly", 0)\n .from(tiles, { x: (i) => S.w(off[i][0]), y: (i) => S.h(off[i][1]), rotate: (i) => off[i][2], opacity: 0, blur: 10, duration: 0.7, stagger: 0.04, ease: "expo.out" }, "fly")\n .label("wave", 1.5)\n .to(tiles, { backgroundColor: "#ffcf5a", duration: 0.25, stagger: { each: 0.05, from: "center" }, ease: "power2.out" }, "wave")\n .to(tiles, { backgroundColor: "#1c2140", duration: 0.4, stagger: { each: 0.05, from: "center" }, ease: "power2.in" }, "wave+=0.3")\n .camera({ zoom: 1.08, tiltX: 8, duration: 3.4, ease: "sine.inOut" }, "fly");\n```\n\nTween colours to real colour values (hex, rgb), not `var(--\u2026)`: the timeline interpolates numbers.\n',
2683
+ "reference/rules/highlight-sweep.md": '# Highlight sweep\n\n**What:** A marker bar sweeps behind the key word, and the word flips to the contrast colour as it is covered.\n**When:** Emphasis in a sentence that is already on screen; editorial and explainer looks.\n**Timing:** 0.4\u20130.5 s (expo.inOut) after the line has landed; the colour flip at 40% of the sweep.\n**Mistake:** Sweeping several words in one sentence: emphasis on everything is emphasis on nothing.\n\n```html\n<div class="world"><div class="center"><p class="s">Make <span class="key"><i class="mark"></i><span class="kw">video</span></span> from your product</p></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.s{font-size:7.4cqmin;font-weight:700;white-space:nowrap}.key{position:relative;display:inline-block;isolation:isolate}.mark{position:absolute;left:-.12em;right:-.12em;top:.08em;bottom:.02em;background:var(--p);z-index:-1;transform-origin:0 50%}\n```\n\n```js\ntl.label("line", 0)\n .from(".s", { opacity: 0, y: S.h(4), blur: 10, duration: 0.5, ease: "expo.out" }, "line")\n .label("mark", 0.9)\n .from(".mark", { scaleX: 0, duration: 0.45, ease: "expo.inOut" }, "mark")\n .to(".kw", { color: "#10142a", duration: 0.15 }, "mark+=0.18")\n .camera({ zoom: 1.07, duration: 2.6, ease: "sine.inOut" }, "line");\n```\n',
2684
+ "reference/rules/holds.md": '# Holds\n\n**What:** A hold is a moment for reading, not a frozen frame: the main element stays put while something small keeps moving (a slow push, a drift, a blinking caret, a breathing glow).\n**When:** Every time the viewer has to read: questions, stats, the end card.\n**Timing:** Reading time: about 0.3 s per word plus 0.5 s. Nothing fully still for more than 0.8 s; the end card may hold up to 1.5 s with a slow drift. The exception is a stretch declared with `tl.rest(from, to)` (stage version 2), which is dead still on purpose and exempt from the motion metrics; the build fails if anything moves inside it.\n**Mistake:** An undeclared hold where nothing moves at all: the motion metrics flag it, and it reads as the film stalling.\n\n```html\n<div class="world"><div class="bloom"></div><div class="center"><div class="stat"><p class="big">3\xD7</p><p class="cap">more booked calls</p><i class="bar"></i></div></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.bloom{position:absolute;inset:-20cqh -20cqw;background:radial-gradient(circle at 40% 45%,#ffcf5a2e,transparent 35%)}.stat{text-align:center}.big{font-size:24cqmin;font-weight:900;line-height:1}.cap{font-size:5cqmin;color:var(--mut)}.bar{display:block;margin:2cqmin auto 0;width:20cqmin;height:.8cqmin;border-radius:1cqmin;background:var(--p);transform-origin:50% 50%}\n```\n\n```js\ntl.label("in", 0)\n .from(".stat", { opacity: 0, blur: 12, scale: 0.92, duration: 0.6, ease: "expo.out" }, "in")\n .label("hold", 0.6)\n // While the viewer reads: a slow push, a drifting bloom, a breathing accent.\n .camera({ zoom: 1.06, duration: 2.6, ease: "sine.inOut" }, "hold")\n .to(".bloom", { x: S.w(6), y: -S.h(4), duration: 2.6, ease: "sine.inOut" }, "hold")\n .to(".bar", { scaleX: 1.4, duration: 1.3, ease: "sine.inOut" }, "hold")\n .to(".bar", { scaleX: 1, duration: 1.3, ease: "sine.inOut" }, ">");\n```\n',
2685
+ "reference/rules/kinetic-type.md": '# Kinetic type\n\n**What:** Words arrive one at a time, each with its own punch (scale down from big, a little rotation), and the key word lands in the accent colour.\n**When:** Short claims of 3\u20137 words that carry the film. Type is the main actor.\n**Timing:** 0.12\u20130.2 s between words; each word 0.35\u20130.5 s (expo.out). Hold the full line 0.6\u20131 s while the camera keeps moving.\n**Mistake:** Animating every word differently. Pick one move for the line and vary only the key word.\n\n```html\n<div class="world"><p class="kt"><span class="txt">Turn your product into</span>&#160;<em>video</em></p></div>\n```\n\n```css\n.kt{position:absolute;inset:0;display:flex;flex-wrap:wrap;align-items:center;align-content:center;justify-content:center;text-align:center;padding:0 10cqw;font-size:9cqmin;font-weight:800;letter-spacing:-.02em;line-height:1.1}.kt em{font-style:normal;color:var(--p)}\n```\n\n```js\nconst words = [...S.split(".kt .txt", "words"), document.querySelector(".kt em")];\ntl.label("say", 0.2)\n .from(words, { scale: 1.8, opacity: 0, blur: 10, rotate: (i) => (i % 2 ? 4 : -4), duration: 0.45, stagger: 0.16, ease: "expo.out" }, "say")\n .from(".kt em", { y: S.h(3), duration: 0.5, ease: "back.out" }, "<")\n .camera({ zoom: 1.12, rotate: -1.5, duration: 3.2, ease: "sine.inOut" }, "say");\n```\n\nS.split keeps styled children: the key word can stay an `<em>` inside the line, and its own `.word` spans animate with the rest.\n',
2686
+ "reference/rules/line-draw.md": '# Line draw\n\n**What:** A line grows across the frame at a steady rate and each node pops as the line reaches it.\n**When:** Timelines, steps, journeys, connections between two things, charts that build.\n**Timing:** The line linear at 30\u201350% of the frame per second; each node pops (back.out, 0.35 s) exactly when the line reaches it.\n**Mistake:** Nodes popping on their own schedule: the line must be the cause.\n\n```html\n<div class="world"><div class="track"><i class="line"></i><span class="node" style="left:0%"><b>Sign up</b></span><span class="node" style="left:33%"><b>Onboard</b></span><span class="node" style="left:66%"><b>Launch</b></span><span class="node" style="left:100%"><b>Grow</b></span></div></div>\n```\n\n```css\n.track{position:absolute;left:14cqw;right:14cqw;top:48cqh;height:.8cqmin}.line{position:absolute;inset:0;background:var(--p);transform-origin:0 50%;border-radius:1cqmin}.node{position:absolute;top:-1.6cqmin;width:4cqmin;height:4cqmin;margin-left:-2cqmin;border-radius:50%;background:var(--ink)}.node b{position:absolute;top:6cqmin;left:-10cqmin;width:24cqmin;text-align:center;font-size:3.6cqmin;font-weight:600;white-space:nowrap}\n```\n\n```js\nconst fracs = [0, 0.33, 0.66, 1], drawS = 2;\ntl.label("draw", 0.3)\n .from(".line", { scaleX: 0, duration: drawS, ease: "linear" }, "draw");\ndocument.querySelectorAll(".node").forEach((n, i) => tl.from(n, { scale: 0, duration: 0.35, ease: "back.out" }, "draw+=" + fracs[i] * drawS));\ntl.camera({ x: -S.w(3), zoom: 1.06, duration: 2.8, ease: "sine.inOut" }, "draw");\n```\n',
2687
+ "reference/rules/mask-reveal.md": '# Mask reveal\n\n**What:** Each line rises into view from behind an invisible edge: the line sits in an `overflow: hidden` slot and moves up from below it.\n**When:** Headlines and statements. It feels precise and editorial, and it never shows half-faded text.\n**Timing:** 0.6\u20130.8 s per line (expo.out), lines 0.1\u20130.15 s apart. Exit the same way, upwards, 0.4 s (power3.in).\n**Mistake:** Forgetting the slot: without `overflow: hidden` on the wrapper the text just slides in from below.\n\n```html\n<div class="world"><h1 class="head"><span class="slot"><span class="ln">Every lead.</span></span><span class="slot"><span class="ln">One <em>inbox</em>.</span></span></h1></div>\n```\n\n```css\n.head{position:absolute;left:14cqw;top:30cqh;font-size:11cqmin;line-height:1.05;font-weight:800;letter-spacing:-.02em}.slot{display:block;overflow:hidden;padding-bottom:.08em}.ln{display:block}.head em{font-style:normal;color:var(--p)}\n```\n\n```js\ntl.label("in", 0.2)\n .from(".ln", { y: "110%", duration: 0.75, stagger: 0.12, ease: "expo.out" }, "in")\n .camera({ zoom: 1.08, duration: 3, ease: "sine.inOut" }, "in")\n .label("out", 2.4)\n .to(".ln", { y: "-110%", duration: 0.4, stagger: 0.06, ease: "power3.in" }, "out");\n```\n\nA wipe variant without the slot: animate `clipPath` from `"inset(0 0 100% 0)"` to `"inset(0 0 0% 0)"`.\n',
2688
+ "reference/rules/match-cut.md": '# Match cut\n\n**What:** A hard cut between two framings of the same element: the wide shot of a wordmark cuts to an extreme close-up of its last letter.\n**When:** Endings, reveals, and any change you want to feel decisive. The cut is hidden because the eye stays on the same thing.\n**Timing:** Hold the wide 0.6\u20131 s, cut on a frame (tl.set), then drift the close-up slowly (scale +3\u20136% over 1\u20131.5 s).\n**Mistake:** Cutting to a different element: without the shared shape it\'s just a jump.\n\n```html\n<div class="world"><div class="center"><p class="mark">orbit<i class="dot"></i></p></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.mark{font-size:20cqmin;font-weight:800;letter-spacing:-.04em;line-height:1}.mark .dot{display:inline-block;width:.16em;height:.16em;border-radius:50%;background:var(--p);margin-left:.04em}\n```\n\n```js\n// Centre the camera on the detail we cut to: the world\'s transform-origin goes on the dot.\nconst d = document.querySelector(".dot").getBoundingClientRect();\nconst ox = ((d.left + d.width / 2) / S.width) * 100, oy = ((d.top + d.height / 2) / S.height) * 100;\ntl.set(".world", { transformOrigin: ox + "% " + oy + "%" }, 0)\n .label("wide", 0)\n .from(".mark", { opacity: 0, blur: 12, duration: 0.5, ease: "expo.out" }, "wide")\n .camera({ zoom: 1.05, duration: 1.4, ease: "sine.out" }, "wide")\n .label("cut", 1.5)\n .camera({ zoom: 4, duration: 0.01 }, "cut")\n .camera({ zoom: 4.3, duration: 1.4, ease: "sine.inOut" }, ">");\n```\n\nThe camera zooms about the `.world` transform-origin: measure the detail (getBoundingClientRect, before any camera move) and put the origin on it.\n',
2689
+ "reference/rules/morph.md": '# Morph\n\n**What:** One shape becomes another: a dot grows into a card, a card into a window, by changing size and corner radius rather than swapping elements.\n**When:** Whenever the next beat\'s container can grow out of something already on screen.\n**Timing:** 0.5\u20130.8 s (power3.inOut or expo.inOut). Content inside fades or rises in during the last 40%.\n**Mistake:** Cross-fading two different elements and calling it a morph: the outline must change continuously.\n\n```html\n<div class="world"><div class="center"><div class="shape"><p class="copy">Your whole funnel, in one window</p></div></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.shape{width:8cqmin;height:8cqmin;border-radius:50%;background:var(--p);display:grid;place-items:center;overflow:hidden}.copy{color:var(--bg);font-size:4.4cqmin;font-weight:700;text-align:center;padding:0 4cqmin;white-space:nowrap}\n```\n\n```js\ntl.label("dot", 0)\n .from(".shape", { scale: 0, duration: 0.5, ease: "back.out" }, "dot")\n .label("grow", 0.9)\n .to(".shape", { width: S.min(110), height: S.min(34), borderRadius: "4cqmin", duration: 0.7, ease: "expo.inOut" }, "grow")\n .from(".copy", { opacity: 0, y: S.h(3), duration: 0.4, ease: "expo.out" }, "grow+=0.45")\n .camera({ zoom: 1.08, tiltX: 6, duration: 3, ease: "sine.inOut" }, "dot");\n```\n\nCentre with a grid wrapper, never with a CSS `transform` on the animated element: the timeline writes `transform` and would replace it.\n\n## With `tl.morph` (version 2)\n\nOn a `data-stage-version="2"` sequence, `tl.morph(from, to, \u2026)` does the whole move for you: the source\'s box, corner radius and colour become the target\'s, the target\'s content rises in over the last 40 %, and the two hand over on the last frame with no swap. Write the target where it should end up, and morph the source into it.\n\n```html v2\n<div class="world"><div class="shape"></div><div class="win"><p class="copy">Your whole funnel, in one window</p></div></div>\n```\n\n```css v2\n.shape{position:absolute;left:46cqw;top:44cqh;width:8cqmin;height:8cqmin;border-radius:50%;background:var(--p)}.win{position:absolute;left:14cqw;top:36cqh;width:72cqw;height:28cqh;border-radius:4cqmin;background:var(--p);display:grid;place-items:center}.copy{margin:0;color:var(--bg);font-size:4.4cqmin;font-weight:700;text-align:center}\n```\n\n```js v2\ntl.label("dot", 0)\n .from(".shape", { scale: 0, duration: 0.5, ease: "back.out" }, "dot")\n .morph(".shape", ".win", { duration: 0.8, ease: "expo.inOut" }, 0.9)\n .camera({ zoom: 1.08, tiltX: 6, duration: 3, ease: "sine.inOut" }, "dot");\n```\n\nThe source must be `position: absolute` or `relative`, and the target stays hidden until the hand-over, so don\'t animate its opacity. Chain morphs (a dot into a card, the card into a pill) and each one is a carried subject.\n',
2690
+ "reference/rules/motion-blur.md": '# Motion blur\n\n**What:** Fast moves carry blur, peaking where the speed peaks. With expo.out, blur from the from-value to 0 does this on its own.\n**When:** Every fly-in, fly-out, whip and fast scale. Also the blur-in on new elements (a soft 8\u201314 px focus-in).\n**Timing:** Blur 10\u201320 px on the fast part. Entrances: from blur, 0.4\u20130.6 s expo.out. Exits: to blur, 0.3\u20130.4 s expo.in.\n**Mistake:** Blur on slow moves (it looks out of focus, not fast), or blur that stays after the element has stopped.\n\n```html\n<div class="world"><div class="center"><div class="card">Fast</div></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.card{padding:4cqmin 8cqmin;border-radius:2cqmin;background:var(--p);color:var(--bg);font-size:9cqmin;font-weight:900}\n```\n\n```js\ntl.label("in", 0.2)\n .fromTo(".card", { x: -S.w(70), blur: 18 }, { x: 0, blur: 0, duration: 0.55, ease: "expo.out" }, "in")\n .label("out", 2)\n .to(".card", { x: S.w(70), blur: 18, duration: 0.4, ease: "expo.in" }, "out")\n .camera({ zoom: 1.05, duration: 2.6, ease: "sine.inOut" }, "in");\n```\n',
2691
+ "reference/rules/orbit.md": '# Orbit\n\n**What:** Satellites circle a hub: the ring rotates and each satellite counter-rotates so its label stays upright.\n**When:** Ecosystems, integrations, "everything connects to one place", a playful idle loop around a logo.\n**Timing:** A slow turn (60\u2013120\xB0 over 4\u20136 s, linear or sine.inOut) while other beats happen; satellites pop in with a stagger.\n**Mistake:** Spinning fast or a full turn per second: it becomes a loading spinner.\n\n```html\n<div class="world"><div class="hub">Hub</div><div class="ring"><span>SMS</span><span>Email</span><span>Calls</span><span>Chat</span><span>Ads</span><span>Pay</span></div></div>\n```\n\n```css\n.hub{position:absolute;left:50%;top:50%;width:22cqmin;height:22cqmin;margin:-11cqmin 0 0 -11cqmin;border-radius:50%;background:var(--p);color:var(--bg);display:grid;place-items:center;font-size:5cqmin;font-weight:800}.ring{position:absolute;left:50%;top:50%;width:0;height:0}.ring span{position:absolute;left:-6cqmin;top:-6cqmin;width:12cqmin;height:12cqmin;border-radius:50%;background:var(--card);border:.3cqmin solid var(--line);display:grid;place-items:center;font-size:2.8cqmin;font-weight:600}\n```\n\n```js\nconst sats = document.querySelectorAll(".ring span");\nconst R = S.min(32);\ntl.set(sats, { x: (i) => R * Math.cos((i / sats.length) * 2 * Math.PI), y: (i) => R * Math.sin((i / sats.length) * 2 * Math.PI) }, 0)\n .label("in", 0)\n .from(".hub", { scale: 0, duration: 0.5, ease: "back.out" }, "in")\n .from(sats, { scale: 0, opacity: 0, duration: 0.45, stagger: 0.08, ease: "back.out" }, "in+=0.3")\n .to(".ring", { rotate: 90, duration: 5, ease: "linear" }, "in")\n .to(sats, { rotate: -90, duration: 5, ease: "linear" }, "in")\n .camera({ zoom: 1.08, tiltX: 10, duration: 5, ease: "sine.inOut" }, "in");\n```\n\nPlace satellites with x/y on the satellites themselves, never with a CSS transform (the timeline owns `transform`).\n',
2692
+ "reference/rules/overshoot.md": '# Overshoot\n\n**What:** The element passes its resting value and settles back (back.out), or springs (elastic.out) for a playful brief.\n**When:** Anything that lands: chips, badges, icons, a logo arriving. Use back.out by default; elastic only when the brief is playful.\n**Timing:** 0.45\u20130.7 s. Stagger 0.06\u20130.1 s between siblings so they land one after another.\n**Mistake:** Overshoot on text paragraphs or on every element at once: it wobbles the whole frame.\n\n```html\n<div class="world"><div class="row"><span class="chip">Leads</span><span class="chip">Calls</span><span class="chip">Reviews</span></div></div>\n```\n\n```css\n.row{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:3cqmin}.chip{padding:2cqmin 4cqmin;border-radius:10cqmin;background:var(--card);border:.3cqmin solid var(--p);font-size:4.4cqmin;font-weight:600}\n```\n\n```js\ntl.label("land", 0.3)\n .from(".chip", { scale: 0, opacity: 0, duration: 0.6, stagger: 0.09, ease: "back.out" }, "land")\n .to(".chip", { y: -S.h(3), duration: 0.5, stagger: 0.09, ease: "elastic.out" }, "land+=1.1")\n .camera({ zoom: 1.06, duration: 2.4, ease: "sine.inOut" }, "land");\n```\n',
2693
+ "reference/rules/parallax.md": '# Parallax\n\n**What:** Layers at different depths move at different speeds: far things move little, near things move a lot.\n**When:** Any pan or drift over a scene with depth (backgrounds, blooms, UI panels as texture, foreground particles).\n**Timing:** The whole beat (2\u20134 s), sine.inOut. Rough ratio far : mid : near = 1 : 2.5 : 5.\n**Mistake:** Every layer at the same speed (that\'s a pan), or near layers so fast they pull focus from the subject.\n\n```html\n<div class="world"><div class="far"></div><div class="mid"><i></i><i></i><i></i></div><p class="title">One platform</p><div class="near"><i></i><i></i></div></div>\n```\n\n```css\n.far{position:absolute;inset:-10cqh -10cqw;background:radial-gradient(circle at 30% 40%,#5ad1ff33,transparent 40%),radial-gradient(circle at 75% 60%,#ff5d8f33,transparent 45%)}.mid i{position:absolute;width:22cqmin;height:14cqmin;border-radius:1.6cqmin;background:var(--card);opacity:.7}.mid i:nth-child(1){left:8cqw;top:14cqh}.mid i:nth-child(2){left:66cqw;top:10cqh}.mid i:nth-child(3){left:58cqw;top:64cqh}.title{position:absolute;inset:0;display:grid;place-items:center;font-size:10cqmin;font-weight:800}.near i{position:absolute;width:4cqmin;height:4cqmin;border-radius:50%;background:var(--p);filter:blur(.6cqmin)}.near i:nth-child(1){left:20cqw;top:76cqh}.near i:nth-child(2){left:84cqw;top:30cqh}\n```\n\n```js\ntl.label("drift", 0)\n .to(".far", { x: -S.w(3), duration: 4, ease: "sine.inOut" }, "drift")\n .to(".mid", { x: -S.w(8), duration: 4, ease: "sine.inOut" }, "drift")\n .to(".title", { x: -S.w(4), duration: 4, ease: "sine.inOut" }, "drift")\n .to(".near", { x: -S.w(18), duration: 4, ease: "sine.inOut" }, "drift");\n```\n\n## With `data-depth` (version 2)\n\nOn a `data-stage-version="2"` sequence, put `data-depth` on the layers instead of animating each one: under any camera pan a layer at depth `d` moves 1 / (1 + d) as far as depth 0, so far layers (larger depth) move less and foreground layers (negative depth, down to \u22120.5) move more. One camera move drives them all.\n\n```html v2\n<div class="world"><div class="far" data-depth="4"></div><div class="mid" data-depth="1.5"><i></i><i></i><i></i></div><p class="title">One platform</p><div class="near" data-depth="-0.5"><i></i><i></i></div></div>\n```\n\n```css v2\n.far{position:absolute;inset:-10cqh -10cqw;background:radial-gradient(circle at 30% 40%,#5ad1ff33,transparent 40%),radial-gradient(circle at 75% 60%,#ff5d8f33,transparent 45%)}.mid i{position:absolute;width:22cqmin;height:14cqmin;border-radius:1.6cqmin;background:var(--card);opacity:.7}.mid i:nth-child(1){left:8cqw;top:14cqh}.mid i:nth-child(2){left:66cqw;top:10cqh}.mid i:nth-child(3){left:58cqw;top:64cqh}.title{position:absolute;inset:0;display:grid;place-items:center;margin:0;font-size:10cqmin;font-weight:800}.near i{position:absolute;width:4cqmin;height:4cqmin;border-radius:50%;background:var(--p)}.near i:nth-child(1){left:20cqw;top:76cqh}.near i:nth-child(2){left:84cqw;top:30cqh}\n```\n\n```js v2\ntl.label("drift", 0)\n .camera({ x: -S.w(8), duration: 4, ease: "sine.inOut" }, "drift")\n .handheld({ amount: S.min(0.3) }, 0, 4);\n```\n',
2694
+ "reference/rules/path-follow.md": '# Path follow\n\n**What:** An element travels along a curve: separate eases on x and y give an arc without any path data.\n**When:** Anything that goes from A to B with intent: a message flying to a phone, a lead travelling to a calendar, a cursor.\n**Timing:** 1\u20131.6 s. x with power2.inOut; y up with sine.out for the first half, down with sine.in for the second.\n**Mistake:** Straight-line travel at constant speed: it reads as mechanical. Arc it and ease it.\n\n```html\n<div class="world"><div class="a">Lead</div><div class="b">Booked</div><i class="ball"></i></div>\n```\n\n```css\n.a,.b{position:absolute;top:66cqh;padding:1.6cqmin 3cqmin;border-radius:1.4cqmin;background:var(--card);font-size:4cqmin;font-weight:600}.a{left:8cqw}.b{left:76cqw}.ball{position:absolute;left:16cqw;top:60cqh;width:4cqmin;height:4cqmin;border-radius:50%;background:var(--p);box-shadow:0 0 3cqmin var(--p)}\n```\n\n```js\ntl.label("go", 0.4)\n .from(".a,.b", { opacity: 0, y: S.h(4), duration: 0.5, stagger: 0.15, ease: "expo.out" }, 0)\n .to(".ball", { x: S.w(64), duration: 1.4, ease: "power2.inOut" }, "go")\n .to(".ball", { y: -S.h(40), duration: 0.7, ease: "sine.out" }, "go")\n .to(".ball", { y: 0, duration: 0.7, ease: "sine.in" }, "go+=0.7")\n .to(".b", { scale: 1.12, backgroundColor: "#ffcf5a", color: "#10142a", duration: 0.35, ease: "back.out" }, "go+=1.35")\n .camera({ x: -S.w(4), duration: 2.6, ease: "sine.inOut" }, "go");\n```\n\nFor more than one bump, use keyframes: `y: [0, -S.h(30), -S.h(10), 0]` (the duration is shared evenly).\n',
2695
+ "reference/rules/pop-in.md": '# Pop-in\n\n**What:** A small element appears from nothing with a quick overshoot and a focus-in.\n**When:** Chips, badges, labels, counters on top of a picture, notification bubbles.\n**Timing:** 0.35\u20130.5 s (back.out), from scale 0 and blur 8 px. Pop chips 0.1\u20130.2 s apart.\n**Mistake:** Popping big things (cards, headlines): use a rise or a morph for those.\n\n```html\n<div class="world"><div class="center"><div class="tile"><span class="chip">5 components</span></div></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.tile{position:relative;width:56cqmin;height:34cqmin;border-radius:2cqmin;background:linear-gradient(135deg,#5ad1ff55,#ff5d8f55)}.chip{position:absolute;right:-4cqmin;top:-3cqmin;padding:1.4cqmin 2.6cqmin;border-radius:6cqmin;background:var(--p);color:var(--bg);font-size:3.6cqmin;font-weight:700}\n```\n\n```js\ntl.label("tile", 0)\n .from(".tile", { opacity: 0, y: S.h(5), blur: 10, duration: 0.6, ease: "expo.out" }, "tile")\n .from(".chip", { scale: 0, blur: 8, duration: 0.45, ease: "back.out" }, "tile+=0.5")\n .camera({ zoom: 1.08, tiltY: -6, duration: 2.6, ease: "sine.inOut" }, "tile");\n```\n',
2696
+ "reference/rules/retype-in-place.md": '# Retype in place\n\n**What:** A typed line deletes back to the caret and a new line types on the same baseline: the same object, a new idea.\n**When:** A question that turns into its answer, a before/after line, "hand-animating" \u2192 "your launch video?".\n**Timing:** Delete at twice the typing speed (0.03\u20130.04 s per character). 0.2 s pause with the caret blinking, then type.\n**Mistake:** Clearing the line with a fade or a cut: the carry-over is the caret staying put.\n\n```html\n<div class="world"><p class="line"><span class="a">Still hand-animating</span><span class="b">your launch video?</span><i class="caret"></i></p></div>\n```\n\n```css\n.line{position:absolute;left:12cqw;top:44cqh;font-size:7cqmin;font-weight:600;white-space:nowrap}.b{position:absolute;left:0;top:0;color:var(--p)}.caret{position:absolute;left:0;top:.12em;width:.07em;height:1.05em;background:var(--ink)}\n```\n\n```js\nconst a = S.split(".a", "chars"), b = S.split(".b", "chars");\nconst end = (c) => c.offsetLeft + c.offsetWidth;\ntl.set([...a, ...b], { opacity: 0 }, 0).label("a", 0.3).label("del", 2.2).label("b", 3.1);\na.forEach((c, i) => tl.set(c, { opacity: 1 }, "a+=" + i * 0.07).set(".caret", { x: end(c) }, "<"));\na.slice().reverse().forEach((c, i) => tl.set(c, { opacity: 0 }, "del+=" + i * 0.035).set(".caret", { x: c.offsetLeft }, "<"));\nb.forEach((c, i) => tl.set(c, { opacity: 1 }, "b+=" + i * 0.07).set(".caret", { x: end(c) }, "<"));\ntl.camera({ zoom: 1.06, x: -S.w(2), duration: 4.6, ease: "sine.inOut" }, 0);\n```\n',
2697
+ "reference/rules/scale-to-fill.md": '# Scale to fill\n\n**What:** A word or wordmark grows until it bleeds past both edges of the frame, often with a colour sweep.\n**When:** The brand moment near the end, or the single word that sums up the film.\n**Timing:** 0.8\u20131.2 s (expo.inOut), then a slow drift. Aim for about 1.2\u20131.4\xD7 the frame width.\n**Mistake:** Scaling the whole world with the camera: the background grows too and nothing feels big.\n\n```html\n<div class="world"><div class="center"><p class="big">launch</p></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.big{font-size:12cqmin;font-weight:900;letter-spacing:-.04em;white-space:nowrap;color:var(--mut)}\n```\n\n```js\ntl.label("set", 0)\n .from(".big", { opacity: 0, blur: 10, duration: 0.5, ease: "expo.out" }, "set")\n .label("fill", 1)\n // Measure the word so the scale fits any frame size.\n .to(".big", { scale: (i, el) => (S.width * 1.3) / el.offsetWidth, color: "#ffcf5a", duration: 1.1, ease: "expo.inOut" }, "fill")\n .camera({ x: -S.w(2), duration: 3, ease: "sine.inOut" }, "set");\n```\n',
2698
+ "reference/rules/split-and-scatter.md": '# Split and scatter\n\n**What:** A word breaks into its letters, which fly apart (or gather from scattered positions into the word).\n**When:** Breaking something apart (problems, chaos, "ten tools"), or assembling a name out of noise. Reverse it to assemble.\n**Timing:** Scatter 0.6\u20130.9 s (expo.out) with a small stagger (0.02 s). Use S.rand(seed) for the directions so every render matches.\n**Mistake:** Math.random for the directions: the frames change every render, and the lint refuses it.\n\n```html\n<div class="world"><div class="center"><p class="w">scattered</p></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.w{font-size:14cqmin;font-weight:900;letter-spacing:-.02em;white-space:nowrap}\n```\n\n```js\nconst chars = S.split(".w", "chars");\nconst r = S.rand(7);\nconst dx = chars.map(() => (r() - 0.5) * 90), dy = chars.map(() => (r() - 0.5) * 80), rot = chars.map(() => (r() - 0.5) * 160);\ntl.label("whole", 0)\n .from(chars, { opacity: 0, y: S.h(6), duration: 0.5, stagger: 0.03, ease: "expo.out" }, "whole")\n .label("burst", 1.4)\n .to(chars, { x: (i) => S.w(dx[i]), y: (i) => S.h(dy[i]), rotate: (i) => rot[i], opacity: 0.5, blur: 3, duration: 0.8, stagger: 0.02, ease: "expo.out" }, "burst")\n .camera({ zoom: 0.92, duration: 2.6, ease: "sine.inOut" }, "whole");\n```\n\nTo assemble instead: `tl.from(chars, { x: \u2026, y: \u2026, rotate: \u2026, opacity: 0 })` with the same seeded values.\n',
2699
+ "reference/rules/staggered-entrance.md": '# Staggered entrance\n\n**What:** Siblings enter one after another with the same move, so a list reads in order.\n**When:** Lists, option cards, feature rows, grids. Stagger from "first" for reading order, from "center" for symmetric groups.\n**Timing:** Each item 0.4\u20130.6 s (expo.out). Stagger 0.06\u20130.12 s. The whole group in under 1 s.\n**Mistake:** Stagger so long the last item arrives after the viewer has stopped reading, or no stagger at all (the group lands as a block).\n\n```html\n<div class="world"><ul class="list"><li>Capture every lead</li><li>Reply in seconds</li><li>Book the call</li><li>Get the review</li></ul></div>\n```\n\n```css\n.list{position:absolute;left:24cqw;top:24cqh;list-style:none;padding:0;display:flex;flex-direction:column;gap:2.6cqmin;font-size:5.4cqmin;font-weight:600}.list li{padding:1.6cqmin 3cqmin;border-left:.8cqmin solid var(--p);background:var(--card)}\n```\n\n```js\ntl.label("list", 0.2)\n .from(".list li", { x: -S.w(4), opacity: 0, blur: 8, duration: 0.5, stagger: 0.08, ease: "expo.out" }, "list")\n .camera({ y: -S.h(3), zoom: 1.05, duration: 2.6, ease: "sine.inOut" }, "list");\n```\n\nFrom the centre: `stagger: { each: 0.06, from: "center" }`.\n',
2700
+ "reference/rules/swap-in-place.md": '# Swap in place\n\n**What:** One word in a fixed sentence changes: the old word slides up and out, the new one slides up into the same slot.\n**When:** Lists of benefits, audiences or use cases ("Make it faster / simpler / yours"). The sentence is the carry-over.\n**Timing:** Each swap 0.45 s (expo.inOut); hold each word 0.6\u20130.9 s.\n**Mistake:** Re-typing or re-animating the whole sentence for each word.\n\n```html\n<div class="world"><div class="center"><p class="line">Make it <span class="slot"><b>faster</b><b>simpler</b><b>yours</b></span></p></div></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.line{font-size:9cqmin;font-weight:800;white-space:nowrap}.slot{display:inline-grid;overflow:hidden;vertical-align:bottom;color:var(--p);padding-bottom:.1em}.slot b{grid-area:1/1}\n```\n\n```js\nconst words = document.querySelectorAll(".slot b");\ntl.set([...words].slice(1), { y: "110%" }, 0)\n .from(".line", { opacity: 0, blur: 10, duration: 0.5, ease: "expo.out" }, 0);\nfor (let i = 1; i < words.length; i++) {\n const at = "swap" + i;\n tl.label(at, 0.4 + i * 1.1)\n .to(words[i - 1], { y: "-110%", duration: 0.45, ease: "expo.inOut" }, at)\n .to(words[i], { y: "0%", duration: 0.45, ease: "expo.inOut" }, at);\n}\ntl.camera({ zoom: 1.06, duration: 3.4, ease: "sine.inOut" }, 0);\n```\n\nThe slot is as wide as the widest word (all words share one grid cell).\n',
2701
+ "reference/rules/typewriter.md": '# Typewriter\n\n**What:** Characters appear one by one with a caret that follows the last typed character.\n**When:** Prompts, questions, a search box, an opening line. It makes the viewer read at the typing speed.\n**Timing:** About 10\u201314 characters per second (0.07\u20130.1 s each). Caret blink 0.4 s on, 0.4 s off while holding.\n**Mistake:** Typing long sentences (over about 30 characters): the viewer waits. Type the first half and reveal the rest.\n\n```html\n<div class="world"><p class="line"><span class="txt">What are we shipping today?</span><i class="caret"></i></p></div>\n```\n\n```css\n.line{position:absolute;left:12cqw;top:44cqh;font-size:7cqmin;font-weight:600;white-space:nowrap}.caret{position:absolute;left:0;top:.12em;width:.07em;height:1.05em;background:var(--p)}\n```\n\n```js\nconst chars = S.split(".txt", "chars");\nconst each = 0.08;\ntl.label("type", 0.4).set(chars, { opacity: 0 }, 0);\n// Each character and the caret change on the same frame; the caret sits at the end of the\n// last typed character (measured after fonts have loaded, so the widths are the real ones).\nchars.forEach((c, i) => tl.set(c, { opacity: 1 }, "type+=" + i * each).set(".caret", { x: c.offsetLeft + c.offsetWidth }, "<"));\ntl.to(".caret", { opacity: 0, duration: 0.01 }, ">+=0.4")\n .to(".caret", { opacity: 1, duration: 0.01 }, "+=0.4")\n .camera({ x: -S.w(3), zoom: 1.04, duration: 3.6, ease: "sine.inOut" }, "type");\n```\n\nTyping is opacity, not `display`: S.split gives each character an inline display of its own.\n',
2702
+ "reference/rules/whip-pan.md": '# Whip pan\n\n**What:** A very fast camera move sideways with heavy blur, from one panel to the next.\n**When:** Lists of equals (feature after feature), energetic promos, a hard change of subject that should still feel like one move.\n**Timing:** 0.3\u20130.4 s (power4.inOut). Blur peaks at the middle: 12\u201320 px up in half the time, down in the other half.\n**Mistake:** A whip at normal speed: without the speed and blur it is just a slow slide.\n\n```html\n<div class="world"><div class="strip"><section class="p1">Capture</section><section class="p2">Convert</section></div></div>\n```\n\n```css\n.strip{position:absolute;inset:0}.strip section{position:absolute;top:0;width:100cqw;height:100cqh;display:grid;place-items:center;font-size:14cqmin;font-weight:800}.p1{left:0}.p2{left:100cqw;color:var(--p)}\n```\n\n```js\ntl.label("hold", 0)\n .camera({ zoom: 1.04, duration: 1.2, ease: "sine.out" }, "hold")\n .label("whip", 1.3)\n .camera({ x: -S.w(100), zoom: 1, duration: 0.36, ease: "power4.inOut" }, "whip")\n .to(".world", { blur: 18, duration: 0.18, ease: "power2.in" }, "whip")\n .to(".world", { blur: 0, duration: 0.18, ease: "power2.out" }, ">")\n .camera({ x: -S.w(101), zoom: 1.05, duration: 1.6, ease: "sine.inOut" }, "whip+=0.36");\n```\n\nAdd an SFX whoosh centred on the whip (see the media-sound skill).\n\n## With `tl.whip` (version 2)\n\n`tl.whip(target, { duration })` snaps the camera to the target with an expo in-out and lands with it centred, so you don\'t compute the pan by hand.\n\n```html v2\n<div class="world"><div class="strip"><section class="p1">Capture</section><section class="p2">Convert</section></div></div>\n```\n\n```css v2\n.strip{position:absolute;inset:0}.strip section{position:absolute;top:0;width:100cqw;height:100cqh;display:grid;place-items:center;font-size:14cqmin;font-weight:800}.p1{left:0}.p2{left:100cqw;color:var(--p)}\n```\n\n```js v2\ntl.label("hold", 0)\n .camera({ zoom: 1.04, duration: 1.2, ease: "sine.out" }, "hold")\n .label("whip", 1.3)\n .whip(".p2", { duration: 0.36 }, "whip")\n .shake("whip+=0.36", { amp: S.min(0.8), decay: 0.3 })\n .to(".world", { blur: 18, duration: 0.18, ease: "power2.in" }, "whip")\n .to(".world", { blur: 0, duration: 0.18, ease: "power2.out" }, ">")\n .camera({ zoom: 1.05, duration: 1.6, ease: "sine.inOut" }, "whip+=0.36");\n```\n',
2703
+ "reference/rules/wipe.md": '# Wipe\n\n**What:** A panel is revealed by a moving edge (clip-path inset), with an accent bar riding the edge.\n**When:** Before/after, revealing a screenshot or picture, replacing one colour field with another.\n**Timing:** 0.6\u20130.9 s (power3.inOut). The bar moves with exactly the same timing and ease as the clip.\n**Mistake:** A wipe with no visible edge: it looks like a rendering glitch. Give it a bar or a hard colour edge.\n\n```html\n<div class="world"><div class="panel"><p>After</p></div><i class="edge"></i></div>\n```\n\n```css\n.panel{position:absolute;left:15cqw;top:20cqh;width:70cqw;height:60cqh;border-radius:2cqmin;background:var(--p);color:var(--bg);display:grid;place-items:center;font-size:12cqmin;font-weight:900}.edge{position:absolute;left:15cqw;top:16cqh;width:1cqmin;height:68cqh;background:var(--ink)}\n```\n\n```js\ntl.label("wipe", 0.4)\n .fromTo(".panel", { clipPath: "inset(0% 100% 0% 0%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: 0.8, ease: "power3.inOut" }, "wipe")\n .fromTo(".edge", { x: 0 }, { x: S.w(70), duration: 0.8, ease: "power3.inOut" }, "wipe")\n .to(".edge", { opacity: 0, duration: 0.2 }, ">")\n .camera({ zoom: 1.05, duration: 2.6, ease: "sine.inOut" }, 0);\n```\n',
2704
+ "reference/rules/zoom-out-reveal.md": '# Zoom-out reveal\n\n**What:** The camera starts close on a few items and pulls back hard to show they are part of something much bigger; a counter changes with it.\n**When:** Scale claims: 5 \u2192 500 templates, one store \u2192 a thousand stores, one message \u2192 a whole inbox.\n**Timing:** Hold close 0.8\u20131.2 s, pull back 0.8\u20131 s (expo.inOut), counter swap at the midpoint.\n**Mistake:** Zooming out to a wall of identical empty boxes: the wall needs variety (colour, content) to read as "many".\n\n```html\n<div class="world"><div class="center"><div class="wall"></div></div></div><div class="hud"><span class="chip"><span class="slot"><b>5</b><b>500</b></span> templates</span></div>\n```\n\n```css\n.center{position:absolute;inset:0;display:grid;place-items:center}.wall{display:grid;grid-template-columns:repeat(9,9cqmin);gap:1.4cqmin}.wall i{height:6cqmin;border-radius:.8cqmin}.hud{position:absolute;left:0;right:0;bottom:10cqh;display:flex;justify-content:center}.chip{padding:1.4cqmin 3cqmin;border-radius:6cqmin;background:var(--ink);color:var(--bg);font-size:4cqmin;font-weight:700}.slot{display:inline-grid;overflow:hidden;vertical-align:bottom}.slot b{grid-area:1/1}\n```\n\n```js\n// The wall is built here: 9 \xD7 7 tiles in seeded colours.\nconst wall = document.querySelector(".wall");\nconst r = S.rand(11), hues = ["#ffcf5a", "#5ad1ff", "#ff5d8f", "#8f7bff", "#3ddc97"];\nfor (let k = 0; k < 63; k++) {\n const t = document.createElement("i");\n t.style.background = hues[Math.floor(r() * hues.length)];\n t.style.opacity = String(0.35 + r() * 0.65);\n wall.appendChild(t);\n}\ntl.camera({ zoom: 4.2, duration: 0.01 }, 0)\n .set(".slot b:last-child", { y: "110%" }, 0)\n .label("close", 0.02)\n .camera({ zoom: 3.8, duration: 1.1, ease: "sine.inOut" }, "close")\n .from(".chip", { scale: 0, blur: 8, duration: 0.45, ease: "back.out" }, "close+=0.3")\n .label("reveal", 1.2)\n .camera({ zoom: 1, duration: 0.9, ease: "expo.inOut" }, "reveal")\n .to(".slot b:first-child", { y: "-110%", duration: 0.4, ease: "expo.inOut" }, "reveal+=0.35")\n .to(".slot b:last-child", { y: "0%", duration: 0.4, ease: "expo.inOut" }, "reveal+=0.35")\n .camera({ zoom: 1.04, duration: 1.4, ease: "sine.inOut" }, ">");\n```\n\nThe `.hud` sits outside `.world`, so the camera doesn\'t move it: use that for chips and captions that must stay put.\n',
2705
+ "reference/rules/zoom-through.md": '# Zoom-through\n\n**What:** The camera pushes into an element until it fills the frame, and the next beat is already inside it.\n**When:** Going deeper: from a product window into a feature, from a map into a city, from a card into its story.\n**Timing:** Push 0.5\u20130.7 s with expo.in (it accelerates into the cut). The reset happens on a frame where only the element\'s colour fills the screen.\n**Mistake:** Zooming into an element whose colour differs from the next beat\'s background: the reset flashes.\n\n```html\n<div class="world"><div class="card"><span>Automations</span></div><div class="next"><p>Every reply, <em>on time</em></p></div></div>\n```\n\n```css\n.card{position:absolute;left:50%;top:50%;width:30cqmin;height:18cqmin;margin:-9cqmin 0 0 -15cqmin;border-radius:2cqmin;background:var(--card);display:grid;place-items:center;font-size:3.6cqmin;font-weight:600}.next{position:absolute;inset:0;display:grid;place-items:center;background:var(--card);opacity:0}.next p{font-size:9cqmin;font-weight:800}.next em{font-style:normal;color:var(--p)}\n```\n\n```js\ntl.label("show", 0)\n .from(".card", { scale: 0.6, opacity: 0, blur: 10, duration: 0.5, ease: "expo.out" }, "show")\n .label("push", 1.2)\n .camera({ zoom: 7, duration: 0.6, ease: "expo.in" }, "push")\n .label("inside", 1.8)\n .set(".next", { opacity: 1 }, "inside")\n .camera({ zoom: 1, duration: 0.01 }, "inside")\n .from(".next p", { scale: 1.3, blur: 14, opacity: 0, duration: 0.6, ease: "expo.out" }, "inside")\n .camera({ zoom: 1.06, duration: 2, ease: "sine.inOut" }, "inside+=0.05");\n```\n\n## With `tl.zoomThrough` (version 2)\n\n`tl.zoomThrough(target, { into, \u2026 })` pushes the camera into the target until it fills the frame, brings the inner scene in over the last 12 % of the push, and resets the camera on the frame where only the target\'s surface is visible. The inner scene (`into`) sits outside `.world`, and the target and `into` should share a colour so the hand-over doesn\'t flash.\n\n```html v2\n<div class="world"><div class="card"><span>Automations</span></div></div><div class="next"><p>Every reply, <em>on time</em></p></div>\n```\n\n```css v2\n.card{position:absolute;left:50%;top:50%;width:30cqmin;height:18cqmin;margin:-9cqmin 0 0 -15cqmin;border-radius:2cqmin;background:var(--card);display:grid;place-items:center;font-size:3.6cqmin;font-weight:600}.next{position:absolute;inset:0;display:grid;place-items:center;background:var(--card)}.next p{margin:0;font-size:9cqmin;font-weight:800}.next em{font-style:normal;color:var(--p)}\n```\n\n```js v2\ntl.label("show", 0)\n .from(".card", { scale: 0.6, opacity: 0, blur: 10, duration: 0.5, ease: "expo.out" }, "show")\n .zoomThrough(".card", { fit: "cover", into: ".next", duration: 0.7, ease: "expo.in" }, 1.2)\n .label("inside", 1.9)\n .from(".next p", { scale: 1.3, blur: 14, opacity: 0, duration: 0.6, ease: "expo.out" }, "inside")\n .camera({ zoom: 1.06, duration: 2, ease: "sine.inOut" }, "inside+=0.05");\n```\n\nFor a dive that goes much deeper (tens or hundreds of times), key the zoom in screen space with `tl.camera({ anchor, at: [50, 50], zoom })` instead, and give each level `data-lod` so it fades in as it grows.\n',
2706
+ "reference/timing.md": '# Timing\n\nMotion graphics are fast. Most beats are shorter than you think, and the viewer reads faster than you fear, but only a few words at a time.\n\n## Beat lengths\n\n| What | Length |\n|---|---|\n| A typical beat (one idea, one move) | 0.3\u20131.2 s |\n| Entrance of an element | 0.4\u20130.7 s (expo.out) |\n| Exit | 0.3\u20130.4 s (expo.in) |\n| Move between places, morph, hand-off | 0.5\u20130.9 s (power3.inOut / expo.inOut) |\n| Pop (chips, badges) | 0.35\u20130.5 s (back.out) |\n| Hidden cut (blur-through, whip) | 0.25\u20130.4 s |\n| Stagger between siblings | 0.06\u20130.12 s (the whole group lands in under 1 s) |\n| Typing | 10\u201314 characters per second |\n| Camera move | as long as the beat, or longer, overlapping the next beat |\n| An act | 8\u201325 s, with a beat every 0.6\u20132 s |\n\nAs a rough guide, a 13\u201314 s launch film has about 14 beats.\n\n## Reading budget\n\n- **Up to 7 words on screen at once.** Split anything longer across beats, or retype in place.\n- A line needs about **0.3 s per word plus 0.5 s** on screen, fully legible, before it changes. A 4-word line: about 1.7 s from landing to leaving.\n- Count from when the last word has landed, not from when the first one starts.\n- Numbers and names need more time than words: add 0.3 s for each.\n\n## Holds\n\nA hold is reading time, not a frozen frame.\n\n- Nothing on screen may be fully still for more than **0.8 s**. During a hold, keep one small thing moving: a slow camera push (3\u20136%), a drift, a blinking caret, a breathing accent (holds).\n- The end card may hold up to **1.5 s**, with a slow drift.\n- If a hold feels long, the beat is too long. Shorten it, don\'t add decoration.\n\n## Motion targets\n\nThe film is checked against these, measured on small greyscale frames at 10 fps (frame-to-frame change on a 0\u2013255 scale):\n\n| Metric | Target for motion-graphics briefs |\n|---|---|\n| Motion density (share of steps with visible change) | \u2265 0.85 |\n| Median change per step | \u2265 1.5 |\n| Holds with no visible change | none over 0.8 s (end card: up to 1.5 s) |\n| Jumps (one big change with calm frames on both sides) | only where you meant a cut |\n\nFor reference, a well-made 13 s launch film measures density 0.99 with a median change of 3.5. Slide-like films with dissolves measure about 0.6 and 0.8, with several dead holds.\n\nDocumentary-paced films have lower targets (density \u2265 0.6, median change \u2265 0.6, no hold over 2 s), but the camera still keeps moving, except in rests you declare with `tl.rest`.\n\n## Fixing what the checks find\n\n`compose_sequence` measures each act after placing it (its `motion` line and `problems`), and `preview_film` measures the whole film (`motion`, `motionProblems`, and the `stillest` acts or scenes). Each finding has a fix:\n\n| Finding | What it means | Fix |\n|---|---|---|\n| "nothing moves 4.2\u20135.6 s" | A hold: every element has landed and the camera has stopped | Keep a slow camera move running across the beat (a push of 4\u20138%, a drift, a tilt). Give the landed elements a breathing move (holds). Or start the next beat sooner. Look at the act\'s script at that time: usually a `tl.camera` ends early, or the next label starts too late |\n| "62% of the time something visibly moves" | Too much of the act is still between moves | Overlap the beats: start each move before the last one ends (`"<+=0.3"`, `"label-=0.2"`). Make the camera moves long and overlapping, so one is always running |\n| "motion is faint: median change 0.8" | Things move, but too little to see at a glance | Move further (5\u201320% of the frame, not 1\u20132%). Scale more (0.8 \u2192 1, not 0.97 \u2192 1). Move several elements at once, or a big one. A slow drift alone reads as still |\n| "a hard jump at 3.0 s" | One frame changes a lot with calm on both sides: an unhidden cut or a `tl.set` that swaps a big element | Hide it: blur-through, zoom-through, whip-pan or a match cut. Or animate into the change instead of setting it |\n| "9 words on screen at 2.4 s" | Too much to read at once | Split the line across two beats, or clear the last beat\'s words before the new ones arrive |\n| "beat \u2026 adds 8 words but lasts 1.0 s" | Not enough time to read them | About 3.5 words a second plus half a second. Lengthen the beat, or cut words |\n\nFix with `compose_sequence` and `replace: "<act id>"`, then check the new `motion` line.\n\n## Rhythm\n\n- Vary beat lengths: three quick beats (0.4\u20130.6 s) then a longer one (1.2 s) reads as rhythm. Equal beats read as a metronome.\n- Put the strongest move on the beat where the music hits (when there\'s a beat grid, use `S.beat(n)` for positions).\n- Let the act end on motion that hands off (a drift, a push), not on a full stop. The only exception is the film\'s final frame.\n'
2707
+ }
2708
+ },
2709
+ {
2710
+ "name": "reformat",
2711
+ "description": "Turn the open project into another aspect ratio or platform version - 16:9 to 9:16 (Reels, TikTok, Shorts), 1:1 or 4:5, or a short social cut-down - re-laying out footage, titles and captions for the new frame. Use when the user wants the same video in a different shape.",
2712
+ "instructions": "# Reformat\n\nLoad `edit-video` first if you haven't. A reformat changes the frame, then re-lays out every clip for it. It edits this project; if the user wants to keep the original shape too, tell them to duplicate the project first (one sentence, then go on if they already said so).\n\n## 1. Plan per clip\n\nRead the state once and decide, for every clip:\n\n| Clip | In the new frame |\n| --- | --- |\n| Footage with a subject (video/image) | Fill the frame and keep the subject centred (formulas below) |\n| Footage where the whole frame matters (screen recordings, slides) | Keep `scale: 1`, and put a blurred copy behind it: the same clip on a lower track, scaled to fill, `add_effect` blur (0.04) and darken (brightness \u22120.2), volume 0 |\n| Full-frame HTML scenes | Rewrite the template for the new size (root `width`/`height`, stack side-by-side layouts vertically, recheck font sizes): `clip/set-property` `{ template, widthPx: null, heightPx: null }` |\n| Sized HTML (lower thirds, badges) and text | Move `x`/`y` into the new safe zone (`reference/craft.md` \u2192 Safe zones); break long lines |\n| Captions | `fontSizeFrac` \xD7 oldH/newH \xD7 1.2 (16:9 \u2192 9:16: 0.055 \u2192 about 0.04), `y` \u2192 0.7, 3 words per clip if you can rebuild them (`captions`) |\n\n### Reframing footage\n\n`scale: 1` fits the source inside the frame. To fill a `W\xD7H` frame from a `sw\xD7sh` source:\n\n- `fill = max((W/H) / (sw/sh), (sw/sh) / (W/H))`: 16:9 \u2192 9:16 is 3.16, \u2192 1:1 is 1.78, \u2192 4:5 is 2.22.\n- Subject at horizontal fraction `f` of the source (0.5 = centre): `x = 0.5 + (0.5 \u2212 f) \xD7 fill`. Keep `|x \u2212 0.5| \u2264 (fill \u2212 1) / 2`, or the frame shows an empty edge. Same for `y`.\n- You can't see the footage: assume the subject is centred unless the user says otherwise, and say so.\n\n## 2. Apply\n\n1. `project/set-settings` with the new `width` and `height` (same fps).\n2. One `apply_commands` batch with every `clip/set-property` from the plan.\n3. Animated clips: keyframes on `x`/`y`/`scale` hold values from the old layout. Re-apply preset animations with `animate_clip` (they start from the new transform); scale hand-made keyframes yourself (`set_keyframes`).\n\n## 3. Cut-downs\n\nFor a shorter social version: keep the strongest 15\u201330 s with the hook first, remove or `trim_clip` the rest, `close_gaps`, keep the end card. Social versions open on the most visual moment, not the title.\n\n## 4. Check and hand off\n\nFrom the state: every text and caption inside the new safe zones, nothing left at old positions, no footage showing an edge. Tell the user the new shape and length, what you assumed about subject position, and to scrub through the footage shots to check the framing."
2713
+ },
2714
+ {
2715
+ "name": "screen-tutorial",
2716
+ "description": "Turn a screen recording in the editor into a tutorial or product walkthrough - chapters per step, zooms onto the action, sped-up waiting, held frames, blurred private data, step callouts. Use when the project holds a screen recording and the user wants a how-to, demo or walkthrough.",
2717
+ "instructions": "# Screen tutorial\n\nLoad `edit-video` first if you haven't. A tutorial guides the eye: zoom to where the action is, cut or speed up dead time, hold a frame when something needs reading, and hide anything private.\n\nYou can't see the recording. The step times and the places on screen come from the user (\"at 0:12 they click Save, top right\"), from markers or a recording log already in the project, or from a transcript of the narration. If you have none of these, ask the user for the steps (time, what happens, where on screen) before editing.\n\n## 1. Structure\n\n- One `marker/add` per step: `durationUs` for the step's span, `data: { \"kind\": \"step\", \"n\": 1 }`, a 2\u20135 word `label`. Markers become chapters.\n- The recording sits on its own track at `scale: 1`, in the recording's shape.\n\n## 2. Pace\n\n- **Dead time** (loading, long typing, waiting): `clip/split` around it and `clip/set-speed` the middle 2\u20134\xD7 with `ripple: true`. Over 4\xD7 looks broken: cut instead.\n- **Hold a frame** when the viewer must read something: `clip/freeze` for 1\u20132 s.\n- **Cut** mistakes and repeats (split, remove, `close_gaps`).\n- Aim for one step every 4\u201310 s of finished video.\n\n## 3. Zoom to the action\n\n`set_keyframes` on the recording's `scale`, `x`, `y`. For a target box centred at `(cx, cy)` (fractions of the frame) at zoom `z`:\n\n- `scale = z`, `x = 0.5 + (0.5 \u2212 cx) \xD7 z`, `y = 0.5 + (0.5 \u2212 cy) \xD7 z`\n- clamp so the frame stays filled: `x` and `y` within `[1 \u2212 z/2, z/2]`\n\nZoom in over 0.4\u20130.6 s with easeInOut, stay while the step happens, zoom out before jumping to a distant part of the screen. `z` 1.5\u20132.5. Zoom when the target is small, not on every click.\n\n## 4. Privacy and focus\n\n- **Blur private data** the user points out (emails, keys, names): `effect/add` `pixelate` (or `blur` 0.03) with `region: { x, y, width, height }` as fractions of the clip's frame; it follows your zooms. If it moves, keyframe it with `effect/set-region-keyframe`. If you were told about private data but not where it is, ask.\n- **Spotlight** a control: `effect/add` `colorAdjust` `{ brightness: -0.45 }` with `region: { \u2026, shape: \"ellipse\", feather: 0.5, invert: true }` for the step's span.\n\n## 5. Callouts and titles\n\n- Step labels: a small pill or lower third near (not over) the target, 2\u20135 words, one on screen at a time (`reference/html-recipes.md`).\n- **An intro (\u2264 3\u20134 s) and an end card** (the result or next step). When `compose_sequence` is available, make each one a short act in the product's look (`get_brand` if it's a product), using one technique from the `motion` skill:\n - the title types in (typewriter), then the three things you'll learn swap in place (swap-in-place);\n - or the product's own UI card pushes in (zoom-through) and hands off to the recording's first frame.\n\n Use `plan_film` (format `explainer`, the brand's look) before it, so the act gets the colours and fonts. Without `compose_sequence`, use an html title (`reference/html-recipes.md` in `edit-video`).\n- Captions from the narration if there are words (`captions`), clear of the callouts.\n\n## 6. Check and hand off\n\nFrom the state: zoom values within the clamp, every step's label on screen during its action, sped-up parts \u2264 4\xD7, nothing private left without a region. Reply with the steps one line each (so the user can paste them into a help article) and ask them to check every frame where private data appears."
2718
+ },
2719
+ {
2720
+ "name": "sequence-core",
2721
+ "description": "The contract for compose_sequence acts \u2014 the stage kit API (tl, S, camera), positions, units, params, the hand-off between acts, and the rules every sequence must pass. Load it before writing your first act; load motion for the techniques.",
2722
+ "instructions": '# Sequence core\n\nA motion-graphics film is a few **acts**. Each act is one `compose_sequence` call: one continuous animation on **one timeline**, 8\u201325 s long, with the beats marked as labels. Elements stay on screen and change from beat to beat, and the camera keeps moving. This file is the contract for what you write. The `motion` skill has the techniques.\n\n## What you write\n\n`compose_sequence` takes four things, and wraps them in the film\'s stage (colours, fonts, the root):\n\n- **`markup`**: the act\'s elements.\n - Everything the camera sees goes inside `<div class="world">\u2026</div>`.\n - Put a `<div class="hud">\u2026</div>` beside it, not inside, for anything that must stay put while the camera moves (a caption bar, a small logo, a progress rail).\n- **`css`**: the look of those elements.\n- **`script`**: the body of `stage.build((tl, S) => { \u2026 })`. Write the body only, not the call.\n- **`durationSeconds`**: the act\'s length. The timeline must end within it. End it on a slow drift (a camera move, a breathing accent), so the act is still moving at its last frame.\n\n`params` (optional): `{{name}}` in the markup becomes an editable field in the editor, filled from `params`. Use it for the words people will want to change. Give each param a meaningful name (`headline`, `step2`, `cta`), not `a`, `b`, `c`. Values are inserted as text.\n\nThe stage gives you these CSS variables, from `plan_film`:\n\n| Variable | What it is |\n|---|---|\n| `--bg` | Background |\n| `--ink` | Text |\n| `--mut` | Muted text |\n| `--p` | The accent |\n| `--s` | The second accent |\n| `--card` | A card surface |\n| `--line` | Hairlines |\n| `--onp` | Text on the accent |\n\nIt also gives you two fonts: the body font on the root, and the display font on `.k-display`. Any other font family you name in the CSS is added to the project for you. Static helpers `k-fill` (fill the frame) and `k-cover` (a cover-fit picture) work. The animated `k-*` classes (`k-rise`, `k-pop`, `k-cam`, \u2026) belong to `design_scene` and are refused here: in a sequence every move is on the timeline.\n\n## The timeline: `tl`\n\nAll times are in **seconds**. Every method returns `tl`, so calls chain.\n\n| Call | Does |\n|---|---|\n| `tl.to(targets, vars, position)` | Animate from the current state to `vars` |\n| `tl.from(targets, vars, position)` | Animate from `vars` to the current (CSS) state: the usual entrance |\n| `tl.fromTo(targets, fromVars, toVars, position)` | Both ends explicit |\n| `tl.set(targets, vars, position)` | Change instantly at that time (a word swap, a caret jump, a hard cut) |\n| `tl.label(name, position)` | Mark a beat. Define a label before you use it in a position |\n| `tl.camera(vars, position)` | Move the camera (the `.world` layer). See below |\n\n**Targets**: a CSS selector (`".card"`), an element, or a list of elements (`S.split(\u2026)` returns one).\n\n**Vars**:\n\n- **Transform:** `x`, `y` (always through `S.w` / `S.h` / `S.min`), `scale`, `scaleX`, `scaleY`, `rotate`, `rotateX`, `rotateY` (degrees).\n- **Look:** `opacity`, `blur` (px, mapped to `filter: blur()`), `color`, `backgroundColor`, `borderRadius`, `width`, `height`, `clipPath` (strings like `"inset(0 100% 0 0)"`), CSS variables (`"--n": 10`).\n- **Keyframes:** an array (`x: [0, S.w(10), 0]`).\n- **Timing:** `duration` (default 0.6), `delay`, `ease`, and `stagger` (seconds between targets, or `{ each, from: "first" | "center" | "last" }`).\n\nAny value can be a function `(i, el) => value`.\n\n**Eases**:\n\n| Ease | Use it for |\n|---|---|\n| `"expo.out"` | Arrivals |\n| `"expo.in"` | Exits |\n| `"power3.inOut"` / `"expo.inOut"` | Moving between places |\n| `"back.out"` | Pops |\n| `"elastic.out"` | Wobbly landings |\n| `"sine.inOut"` | Drift |\n| `"linear"` | Constant rates |\n| `"steps(n)"` | Ticking, typing |\n\n`power1`\u2013`power4`, `circ`, `bounce` and anime.js names (`"out(3)"`) also work.\n\n**Positions**:\n\n| Position | Means |\n|---|---|\n| `2.4` | At that second |\n| `"hook"` | At the label |\n| `"hook+=0.3"` / `"hook-=0.2"` | Offset from the label |\n| `"<"` | With the previous step |\n| `">"` | After the previous step |\n| `"<+=0.2"` | Shortly after the previous step starts |\n| `"+=0.5"` | 0.5 s after everything so far |\n| *(none)* | At the end of everything so far |\n\n## The camera\n\n`tl.camera({ x, y, zoom, rotate, tiltX, tiltY, duration, ease }, position)` moves `.world`:\n\n- `zoom` is scale;\n- `tiltX` / `tiltY` rotate it in depth, with perspective already set;\n- the default is a 1.2 s `power2.inOut`-style ease.\n\nZoom pivots on `.world`\'s transform origin (the centre). To push into a corner, first `tl.set(".world", { transformOrigin: "70% 30%" }, t)`. A camera move with `duration: 0.01` is an instant reset, which hides a cut.\n\n## Continuous shot (version 2)\n\nPut `data-stage-version="2"` on the stage root to turn on the operated camera and the carry verbs. Without it everything above behaves as before. With it, `tl.camera` keys a camera track that follow, handheld and shake add to (zoom is interpolated in log space, so 1 to 100 reads 10 halfway), and these calls exist. Times are seconds, sizes are stage pixels (use `S.*`), and every call returns `tl`.\n\n**Carry one subject from beat to beat**\n\n| Call | Does |\n|---|---|\n| `tl.morph(from, to, { duration, ease, content, radius }, position)` | The `from` element\'s box, radius and colour become `to`\'s, and `to`\'s content rises in over the last 40 %. The two hand over on the last frame with no swap. `from` must be `position: absolute` or `relative` |\n| `tl.iris(target, { from, duration, ease, edge }, position)` | A circular clip opens from the centre of `from` until `target` covers the stage |\n| `tl.zoomThrough(target, { fit: "cover" \\| "contain", into, duration, ease }, position)` | Pushes the camera into `target` until it fills the frame. With `into` (an element outside `.world`), that scene takes over on the last frame and the camera resets |\n| `tl.hop(target, { to, height, duration, squash }, position)` | An arc to another element\'s slot, landing with a squash |\n| `tl.gather(targets, { into, arc, stagger, spin, duration }, position)` | Many elements fly on fanned arcs into one box |\n| `tl.impact(target, position, { squash, duration })` | A squash and spring-back, and a hit |\n| `tl.press(target, time)` | A pressed-button reaction, and a click hit |\n| `tl.pointer({ path, clicks, look })` | A cursor that passes through every `{ t, x, y }` knot on a smooth path and dips on each click time. `S.pointerPath(log)` builds the path from a recorded click log |\n\nElements these verbs drive keep their own `transform` and `opacity` for the verb\'s duration: don\'t animate the same properties with `tl.to`. Ease `"spring(zeta, omega)"` (defaults 0.55, 14) is a damped spring that has settled by the end of the tween.\n\n**Operate the camera**\n\n| Call | Does |\n|---|---|\n| `tl.camera({ anchor, at: [x%, y%], zoom, duration, ease }, position)` | Keys the zoom in screen space: the anchor element stays at that screen position, at any zoom. Use it for every zoom above 50\xD7 |\n| `tl.follow(target, { lead, from, to }, position)` | The camera keeps `target` near the centre, leading it by `lead` seconds (default 0.1) |\n| `tl.whip(target, { duration }, position)` | A fast expo snap that lands with `target` centred |\n| `tl.handheld({ amount }, from, to)` | A slow float, exactly zero outside its range and inside declared rests |\n| `tl.shake(position, { amp, decay, seed })` | A deterministic hit shake in screen space (the same size at any zoom), recorded as a hit |\n\n`data-depth="d"` on a child of `.world` gives parallax from the one camera (a layer moves 1 / (1 + d) as far as depth 0). `data-lod="minPx,maxPx"` fades an element in by its size on screen and drops it when it is off-frame.\n\n**Say what the shot does**\n\n| Call | Does |\n|---|---|\n| `tl.hit(name, position, { pan, kind })` | Marks a sound moment (`kind`: `impact`, `click`, `whoosh`, `land`, `shake`). Shakes, presses, impacts, pointer clicks and carry landings add their own |\n| `tl.rest(from, to, { allow })` | Declares a dead-still stretch. Only elements in `allow` may move, and the camera layers are zero. The build fails, naming what moved, if anything else does |\n| `tl.cut(position)` | Marks a deliberate hard cut |\n\nThe stage root reports `data-stage-carries`, `data-stage-events`, `data-stage-rests` and `data-stage-cuts` as JSON, for the checks and for placing sound.\n\n**Check the shot**\n\nMark every beat with `tl.label(name, time)`: labels are the beats the check looks at. Put `data-keep-in-frame="t0,t1"` on a subject that must stay on screen (add `,whole` to also forbid it being cut off by the frame edge). Then run `check_sequence` (when the tool is offered). For each label it compares the frame 0.2 s before and after and reports the beat as **carried** (something survives and moves, scales, or the camera moves with it), **anchored** (something large stays put) or **bare** (nothing survives), with a hint naming what left and what arrived. It also warns about subjects that leave the frame, beats spaced too evenly, and hard cuts outside a burst of cuts. A bare beat is fixed by carrying an element across it (`tl.morph`, `tl.hop`, `tl.zoomThrough`, `tl.gather`). Beats at `tl.cut` times, bursts of three or more beats within 1.5 s, and the last 1.2 s are not scored. Warnings never block an export.\n\n## The stage helpers: `S`\n\n| Helper | Gives |\n|---|---|\n| `S.w(p)`, `S.h(p)`, `S.min(p)` | `p` percent of the stage\'s width, height or shorter side, in pixels. Use these for every x, y and size in the script |\n| `S.width`, `S.height`, `S.portrait` | The stage size, and whether it is portrait |\n| `S.split(target, "words" \\| "chars")` | Wraps each word or character in a span (`.word` / `.char`). Returns them in reading order. Styled children (`<em>`) stay, with their words split inside |\n| `S.rand(seed)` | A repeatable random function: `const r = S.rand(7); r()` gives 0\u20131, the same numbers on every render |\n| `S.rect(el)`, `S.center(el)` | An element\'s resting box `{ x, y, w, h }` and centre `{ x, y }` in stage pixels (version 2) |\n| `S.grid({ depth, spacing, dot })` | A faint dot lattice behind the scene that keeps its dot size at any zoom, so camera moves read on an empty ground (version 2) |\n| `S.pointerPath(log)` | Turns a recorded click log `[{ t, x, y, type }]` into `{ path, clicks }` for `tl.pointer` (version 2) |\n| `S.beats`, `S.beat(n)` | The music\'s beat grid in seconds, when the film has one (otherwise `S.beat(n)` is n \xD7 0.5 s) |\n| `S.downbeats`, `S.downbeat(n)` | The first beat of each bar in seconds, from `data-downbeats` (otherwise `S.downbeat(n)` is n \xD7 2 s). Land a carry on `S.downbeat(n)` to put it on the music |\n\n## Rules (compose_sequence checks them)\n\nEach rule protects one of two things: every frame must be the same however often and in whatever order it is rendered (preview, scrubbing, export), and the act must fit any frame size.\n\n| Rule | Instead |\n|---|---|\n| `random`: no `Math.random()` | `S.rand(seed)` |\n| `clock`: no `Date`, `performance.now()` | Timeline positions |\n| `async-loop`: no `requestAnimationFrame`, `setTimeout`, `setInterval` | Put every change on the timeline. Frames are sought, never played |\n| `network`: no `fetch`, `XMLHttpRequest`, `import()` | Pictures are `asset:ID` references |\n| `infinite`: no `repeat: -1`, `loop: true` | Repeat a finite number of times within the act |\n| `owned-hooks`: no `__timelines`, `__miraiclipSeek`\u2026 | `stage.build` only |\n| `gsap`: GSAP isn\'t available | The `tl` API above |\n| `units`: no `vw`, `vh`, `vmin` | `cqw`, `cqh`, `cqmin` in CSS; `S.w/h/min` in the script |\n| `fixed`: no `position: fixed` | `position: absolute` inside the stage |\n| `external-script`: no `<script src>` | Everything inline |\n| `build`: `stage.build` exactly once | One timeline per act |\n| `version`: continuous-shot verbs need `data-stage-version="2"` | Add it to the stage root |\n| `carry-target`: a verb names an element the markup doesn\'t have | Add the element or fix the selector |\n| `zoom-target`: `zoomThrough` target outside `.world` | Move it inside the world |\n| `duration`, `libs` | compose_sequence sets these for you |\n\nWarnings (fix them):\n\n- `pixels`: a raw number over 40 for x, y, left, top, width or height won\'t fit another frame size. Use `S.*`.\n- `zoom-anchor`: a zoom above 50\xD7 without an `anchor` drifts off its subject. Key it in screen space.\n- `verb-transform`: a CSS `transform` on an element that `morph`, `hop`, `gather`, `impact` or `press` drives is replaced. Put it on a wrapper.\n- `engine`: calling anime.js directly bypasses the kit\'s deterministic seeking. Use `tl`.\n- `big-blur`: a large layer (30% of the frame or more) with `filter: blur()` can render with tile-shaped artifacts while the camera tilts. Make soft glows and colour blooms with a `radial-gradient(circle, <colour>, transparent 65%)` instead. Blur on small elements (an entrance focus-in, motion blur on a word) is fine.\n\nThe tool also runs the act once before placing it. A script error, or a timeline longer than the act, comes back as an error with the reason, and nothing is placed.\n\n## Things that trip people up\n\n- **The timeline owns `transform`** on every element it animates: a CSS `transform` on that element is replaced. Centre with a grid or flex wrapper, `inset` and margins, or put the CSS transform on a wrapper and animate the child.\n- **`from` animates to the CSS state.** Write the CSS for where the element should *end up*, then `tl.from` its entrance.\n- **Elements that appear later** start hidden with `tl.set(el, { opacity: 0 }, 0)`, or with a `from` at their beat. CSS `opacity: 0` with a later `to` also works. Nothing should flash at frame 0 by accident.\n- **Colours** tween to real values (`"#ffcf5a"`, `"rgb(\u2026)"`), not `var(--p)`. Read the plan\'s hex values.\n- **Measuring** (`el.offsetWidth`, `getBoundingClientRect()`) is fine: the script runs after fonts load.\n- **Keep the DOM fixed.** Create elements in the markup, or with `S.split`, before the timeline. Don\'t add or remove elements during it: use `opacity` and `tl.set`.\n\n## Hand-off between acts\n\nActs join with a 0.3 s dissolve by default. Design the dissolve away:\n\n- The next act\'s first frame starts where the last act\'s final frame ended: the same element, in the same place, at the same size.\n- Then it moves on. The orb that became the logo at the end of act 1 is the logo at the start of act 2.\n- Use `join: "cut"` when both sides hide the cut themselves (a blur-through or a whip on each side).\n- Use `join: "dip"` only at a real chapter break.\n\n## The loop\n\n1. Write the act\'s beats (motion skill, `reference/timing.md`).\n2. Compose it.\n3. Look at the filmstrip compose_sequence returns, and read its problems:\n - layout at each beat;\n - a timeline that ends early;\n - too few beats;\n - a still camera.\n4. Fix with `replace: "<act id>"`, which keeps the act\'s place and length.\n5. Then compose the next act.\n\n`preview_film` shows the whole film when every act is in.'
2723
+ },
2724
+ {
2725
+ "name": "talking-head",
2726
+ "description": "Package a recorded talk, interview, podcast clip or vlog in the editor - punch-ins on every other segment to hide jump cuts, captions, a lower third with the speaker's name, music under speech and an end card. Use when the project has footage of someone speaking and the user wants it tightened or packaged.",
2727
+ "instructions": "# Talking-head package\n\nLoad `edit-video` first if you haven't; its loop and `reference/craft.md` apply.\n\nYou can't hear the audio, so you can't find pauses or fillers yourself. Cutting by speech needs word timings: caption clips already in the project, a transcript the user pastes, or the editor's own transcription or cleanup feature (if it has one, point the user to it and continue with the rest). Never cut blind at guessed times.\n\n## 1. Brief\n\nFrom the request (ask once if it matters): platform and shape, who is speaking (name and role, for the lower third), keep it verbatim or trim to the best part. Defaults: same shape, captions, lower third, no music unless asked.\n\n## 2. Cuts (only with word timings)\n\n- With words: keep everything except pauses longer than about 0.45 s (leave 120 ms around words) and filler words (um, uh, erm, hmm). Do it with `clip/split` at both ends of each removed range, `clip/remove` the middle, then `close_gaps`.\n- To trim to the best part: pick the passage from the transcript (start on a strong sentence, not a greeting) and `trim_clip` to it.\n- Report the before and after length.\n\n## 3. Punch-ins\n\nEvery hard cut inside the same shot shows as a jump. On every other segment: `clip/set-property` `{ \"transform\": { \"scale\": 1.15, \"y\": 0.53 } }` (the slight `y` keeps the head in frame). Skip segments shorter than 1 s. Without cuts, punch in on a new segment every 5\u20138 s only if the user wants movement.\n\n## 4. Captions\n\nFollow the `captions` skill. For 9:16: 3 words per clip, `y` 0.7.\n\n## 5. Lower third, music, end card\n\n- **Lower third** at 1\u20132 s for 5 s: a text-style tool's lower third if the editor has one, else the Lower third recipe in `reference/html-recipes.md` with the name and role. `animate_clip` in from-left, out fade, easing snappy. While it's on, captions move up (`y` 0.7), or put the lower third top-left (`y` 0.12).\n- **Music** only if asked: under speech at 0.12\u20130.2, fade in 1 s, out 2 s, trimmed to end with the video.\n- **End card** (optional): a 3 s HTML scene after the last segment with name and handle or URL; crossDissolve from the last segment if it has headroom.\n\n## 6. Check and hand off\n\nFrom the state: captions never overlap the lower third, punch-in values the same on alternating segments, no gaps, music ends with the video. Report the length, what was cut (if anything), and ask the user to watch one punch-in to check the framing."
2728
+ }
2729
+ ];
2730
+
2039
2731
  // src/testing.ts
2040
2732
  function scriptedChatModel(steps, options = {}) {
2041
2733
  const requests = [];