vibemovie 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1669 @@
1
+ // src/index.ts
2
+ import { createCascade, createConsentLedger, makeEvent, notify as vibeCoreNotify } from "@pooriaarab/vibe-core";
3
+ import { writeFile } from "fs/promises";
4
+
5
+ // src/scenes.ts
6
+ var RATIOS = ["16:9", "9:16", "1:1"];
7
+ var TEMPLATES = ["documentary", "speedrun", "meme"];
8
+ var MAX_TASK_ROWS = 5;
9
+ var MAX_DIFF_FILES = 4;
10
+ var MAX_TERM_LINES = 5;
11
+ var PACING = {
12
+ documentary: { title: 4200, tasks: 4200, diff: 4200, terminal: 4600, merge: 4600, end: 3400 },
13
+ speedrun: { title: 2500, tasks: 2500, diff: 2500, terminal: 2800, merge: 2800, end: 2200 },
14
+ meme: { title: 3600, tasks: 3600, diff: 3600, terminal: 3900, merge: 3900, end: 3e3 }
15
+ };
16
+ var TRANSITIONS = {
17
+ title: "fade",
18
+ tasks: "slide",
19
+ diff: "scale",
20
+ terminal: "rise",
21
+ merge: "fade",
22
+ end: "fade"
23
+ };
24
+ function isRecord(v) {
25
+ return typeof v === "object" && v !== null && !Array.isArray(v);
26
+ }
27
+ function num(v) {
28
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
29
+ }
30
+ function str(v) {
31
+ return typeof v === "string" && v.trim().length > 0 ? v : null;
32
+ }
33
+ function strArr(v) {
34
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.length > 0) : [];
35
+ }
36
+ function firstStr(payload, keys) {
37
+ for (const k of keys) {
38
+ const v = str(payload[k]);
39
+ if (v !== null) return v;
40
+ }
41
+ return null;
42
+ }
43
+ function firstNum(payload, keys) {
44
+ for (const k of keys) {
45
+ const v = num(payload[k]);
46
+ if (v !== null) return v;
47
+ }
48
+ return null;
49
+ }
50
+ function normalize(events) {
51
+ const out = [];
52
+ for (const e of events) {
53
+ if (!isRecord(e)) continue;
54
+ const kind = str(e.kind) ?? str(e.type) ?? "event";
55
+ let ts = 0;
56
+ if (num(e.ts) !== null) ts = num(e.ts);
57
+ else if (typeof e.timestamp === "number" && Number.isFinite(e.timestamp)) ts = e.timestamp;
58
+ else if (typeof e.timestamp === "string") {
59
+ const parsed = Date.parse(e.timestamp);
60
+ if (!Number.isNaN(parsed)) ts = parsed;
61
+ }
62
+ out.push({
63
+ kind,
64
+ ts,
65
+ agent: str(e.agent),
66
+ cwd: str(e.cwd),
67
+ payload: isRecord(e.payload) ? e.payload : {}
68
+ });
69
+ }
70
+ return out.sort((a, b) => a.ts - b.ts);
71
+ }
72
+ function basename(p) {
73
+ const parts = p.split(/[\\/]/).filter((s) => s.length > 0);
74
+ return parts.length > 0 ? parts[parts.length - 1] : p;
75
+ }
76
+ function formatMinutes(totalMinutes) {
77
+ const h = Math.floor(totalMinutes / 60);
78
+ const m = totalMinutes % 60;
79
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
80
+ }
81
+ function plural(n, word) {
82
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
83
+ }
84
+ var TASK_KINDS = /* @__PURE__ */ new Set(["task-done", "task", "todo-done", "commit", "milestone"]);
85
+ var PR_OPEN_KINDS = /* @__PURE__ */ new Set(["pr-opened", "pull-request", "pull-request-opened"]);
86
+ var PR_MERGE_KINDS = /* @__PURE__ */ new Set(["pr-merged", "pull-request-merged", "merge"]);
87
+ function collectTasks(events) {
88
+ const rows = [];
89
+ for (const e of events) {
90
+ if (!TASK_KINDS.has(e.kind)) continue;
91
+ const label = firstStr(e.payload, ["label", "task", "title", "summary", "message"]) ?? `Task ${rows.length + 1}`;
92
+ const durationMin = firstNum(e.payload, ["durationMin", "duration_min", "minutes"]);
93
+ rows.push(durationMin !== null ? { label, durationMin } : { label });
94
+ }
95
+ return rows;
96
+ }
97
+ function collectDiff(events) {
98
+ const files = /* @__PURE__ */ new Set();
99
+ let additions = 0;
100
+ let deletions = 0;
101
+ let filesChanged = 0;
102
+ for (const e of events) {
103
+ for (const f of strArr(e.payload["files"])) files.add(f);
104
+ additions += firstNum(e.payload, ["additions", "adds", "added"]) ?? 0;
105
+ deletions += firstNum(e.payload, ["deletions", "dels", "removed"]) ?? 0;
106
+ const fc = firstNum(e.payload, ["filesChanged", "files_changed"]);
107
+ if (fc !== null) filesChanged = Math.max(filesChanged, fc);
108
+ }
109
+ if (filesChanged === 0) filesChanged = files.size;
110
+ return { filesChanged, additions, deletions, files: [...files].slice(0, MAX_DIFF_FILES) };
111
+ }
112
+ function collectPr(events) {
113
+ let opened = null;
114
+ let merged = null;
115
+ for (const e of events) {
116
+ if (PR_MERGE_KINDS.has(e.kind) && merged === null) merged = e;
117
+ if (PR_OPEN_KINDS.has(e.kind) && opened === null) opened = e;
118
+ }
119
+ const source = merged ?? opened;
120
+ if (source === null) return null;
121
+ return {
122
+ pr: firstNum(source.payload, ["pr", "number", "prNumber", "pr_number"]),
123
+ branch: firstStr(source.payload, ["branch", "base", "into"]) ?? "main",
124
+ reviewers: firstNum(source.payload, ["reviewers", "reviewedBy", "approvals"]),
125
+ merged: merged !== null
126
+ };
127
+ }
128
+ function collectTermLines(events, pr) {
129
+ const lines = [];
130
+ const push = (text, cls = "") => {
131
+ if (lines.length < MAX_TERM_LINES) lines.push({ text, cls });
132
+ };
133
+ for (const e of events) {
134
+ if (lines.length >= MAX_TERM_LINES) break;
135
+ if (e.kind === "tests-pass") {
136
+ const passed = firstNum(e.payload, ["passed", "count", "tests"]);
137
+ push("$ npm test");
138
+ push(passed !== null ? `\u2713 ${passed} passed` : "\u2713 tests passed", "ok");
139
+ } else if (e.kind === "tests-fail") {
140
+ const failed = firstNum(e.payload, ["failed", "count"]);
141
+ push("$ npm test");
142
+ push(failed !== null ? `\u2717 ${failed} failing` : "\u2717 tests failing");
143
+ } else if (e.kind === "error") {
144
+ const msg = firstStr(e.payload, ["message", "error", "label"]) ?? "something broke";
145
+ push(`\u2717 ${msg}`);
146
+ }
147
+ }
148
+ if (pr !== null && lines.length <= MAX_TERM_LINES - 2) {
149
+ push("$ git push");
150
+ push(`\u2192 pushed to origin/${pr.branch}`, "ok");
151
+ }
152
+ if (lines.length === 0 && events.length > 0) {
153
+ push("$ vibemovie render");
154
+ push(`\u2713 ${plural(events.length, "event")} recapped`, "ok");
155
+ }
156
+ return lines;
157
+ }
158
+ function captions(template, stats) {
159
+ const { totalMinutes, tasks, diff, pr } = stats;
160
+ const dur = formatMinutes(totalMinutes);
161
+ const prWord = pr !== null ? pr.merged ? "merged" : "opened" : "";
162
+ const prNum = pr?.pr !== null && pr?.pr !== void 0 ? `#${pr.pr} ` : "";
163
+ if (template === "speedrun") {
164
+ return {
165
+ title: totalMinutes > 0 ? `${dur}. go.` : "a session. go.",
166
+ tasks: `${plural(tasks, "task")}. done.`,
167
+ diff: `+${diff.additions} \u2212${diff.deletions} across ${plural(diff.filesChanged, "file")}.`,
168
+ terminal: "green. ship.",
169
+ merge: `${prNum}${prWord}. next.`,
170
+ end: "gg."
171
+ };
172
+ }
173
+ if (template === "meme") {
174
+ return {
175
+ title: "touch grass? never heard of it.",
176
+ tasks: `${plural(tasks, "task")} speedrun any%`,
177
+ diff: "number go up",
178
+ terminal: "it compiles. ship it.",
179
+ merge: pr !== null && pr.merged ? "LGTM said the reviewer" : "CI roulette champion",
180
+ end: "same time tomorrow?"
181
+ };
182
+ }
183
+ return {
184
+ title: totalMinutes > 0 ? `${dur} in the flow.` : "A session worth replaying.",
185
+ tasks: `${plural(tasks, "task")} landed, clean.`,
186
+ diff: diff.additions >= diff.deletions ? `${plural(diff.filesChanged, "file")} touched \u2014 more added than removed.` : `${plural(diff.filesChanged, "file")} touched \u2014 more removed than added.`,
187
+ terminal: stats.hasTests ? "Tests green. Shipped." : "The commands tell the story.",
188
+ merge: pr !== null ? `Pull request ${prNum}${prWord}.` : "",
189
+ end: "That's the session. Run it back."
190
+ };
191
+ }
192
+ function scene(kind, template, caption, data) {
193
+ return {
194
+ id: `scene-${kind}`,
195
+ kind,
196
+ transition: TRANSITIONS[kind],
197
+ duration: PACING[template][kind],
198
+ caption,
199
+ data
200
+ };
201
+ }
202
+ function buildScenes(events, opts = {}) {
203
+ const template = opts.template ?? "documentary";
204
+ const norm = normalize(events);
205
+ const first = norm[0];
206
+ const last = norm[norm.length - 1];
207
+ const totalMinutes = first !== void 0 && last !== void 0 && last.ts > first.ts ? Math.round((last.ts - first.ts) / 6e4) : 0;
208
+ const sessionName = opts.title ?? (first?.cwd != null ? basename(first.cwd) : "vibe session");
209
+ const agent = first?.agent ?? "agentic";
210
+ const tasks = collectTasks(norm);
211
+ const diff = collectDiff(norm);
212
+ const pr = collectPr(norm);
213
+ const termLines = collectTermLines(norm, pr);
214
+ const hasTests = norm.some((e) => e.kind === "tests-pass");
215
+ const caps = captions(template, { totalMinutes, tasks: tasks.length, diff, pr, hasTests });
216
+ const scenes = [
217
+ scene("title", template, caps.title, {
218
+ sessionName,
219
+ totalMinutes,
220
+ subtitle: `${agent} coding session \xB7 recapped on-device`
221
+ })
222
+ ];
223
+ if (tasks.length > 0) {
224
+ scenes.push(
225
+ scene("tasks", template, caps.tasks, { total: tasks.length, tasks: tasks.slice(0, MAX_TASK_ROWS) })
226
+ );
227
+ }
228
+ if (diff.filesChanged > 0 || diff.additions + diff.deletions > 0) {
229
+ scenes.push(scene("diff", template, caps.diff, diff));
230
+ }
231
+ if (termLines.length > 0) {
232
+ scenes.push(scene("terminal", template, caps.terminal, { lines: termLines }));
233
+ }
234
+ if (pr !== null) {
235
+ scenes.push(scene("merge", template, caps.merge, pr));
236
+ }
237
+ scenes.push(scene("end", template, caps.end, { tagline: "generated on-device \xB7 hyperframes" }));
238
+ return scenes;
239
+ }
240
+
241
+ // src/hyperframes.ts
242
+ function escapeHtml(s) {
243
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
244
+ }
245
+ function embedJson(value) {
246
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
247
+ }
248
+ var TICK_SVG = '<svg class="tick" width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="#3a3024" stroke-width="1.4"/><path d="M5.5 10.2l3 3 6-6.4" stroke="#7fae8a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" pathLength="1" stroke-dasharray="1" stroke-dashoffset="1"/></svg>';
249
+ function sceneHtml(scene2) {
250
+ switch (scene2.kind) {
251
+ case "title": {
252
+ const d = scene2.data;
253
+ return `<section class="scene" id="${scene2.id}"><svg class="reel" viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="2"><circle cx="50" cy="50" r="46"/><circle cx="50" cy="20" r="9"/><circle cx="76" cy="65" r="9"/><circle cx="24" cy="65" r="9"/><circle cx="50" cy="50" r="6" fill="currentColor" stroke="none"/></svg><div class="session-title">${escapeHtml(d.sessionName)}</div><div class="session-sub" id="titleCounter">0h 00m</div><div class="session-caption">${escapeHtml(d.subtitle)}</div></section>`;
254
+ }
255
+ case "tasks": {
256
+ const d = scene2.data;
257
+ const rows = d.tasks.map((t) => {
258
+ const dur = t.durationMin !== void 0 ? `<span class="dur">${escapeHtml(formatMinutes(t.durationMin))}</span>` : "";
259
+ return `<div class="task-row">${TICK_SVG}<span class="label">${escapeHtml(t.label)}</span>${dur}</div>`;
260
+ }).join("");
261
+ const more = d.total > d.tasks.length ? `<div class="task-more">+${d.total - d.tasks.length} more</div>` : "";
262
+ return `<section class="scene" id="${scene2.id}"><div class="tasks-head"><b>${d.total}</b> ${d.total === 1 ? "task" : "tasks"} completed</div><div class="task-list">${rows}${more}</div></section>`;
263
+ }
264
+ case "diff": {
265
+ const d = scene2.data;
266
+ const files = d.files.map((f) => `<div class="f">${escapeHtml(f)}</div>`).join("");
267
+ const more = d.filesChanged > d.files.length ? `<div class="f">+${d.filesChanged - d.files.length} more</div>` : "";
268
+ return `<section class="scene" id="${scene2.id}"><div class="diff-head">${d.filesChanged} ${d.filesChanged === 1 ? "file" : "files"} changed</div><div class="diff-bar-wrap"><div class="diff-bar"><div class="add" id="diffAdd"></div><div class="remove" id="diffRemove"></div></div><div class="diff-stats"><span class="plus">+${d.additions}</span><span class="minus">\u2212${d.deletions}</span></div></div><div class="diff-files" id="diffFiles">${files}${more}</div></section>`;
269
+ }
270
+ case "terminal": {
271
+ return `<section class="scene" id="${scene2.id}"><div class="terminal"><div class="terminal-bar"><span></span><span></span><span></span><span class="name">vibe \u2014 zsh</span></div><div class="terminal-body" id="termBody"></div></div></section>`;
272
+ }
273
+ case "merge": {
274
+ const d = scene2.data;
275
+ const head = d.pr !== null ? `PR #${d.pr} ${d.merged ? "merged" : "opened"}` : `PR ${d.merged ? "merged" : "opened"}`;
276
+ const sub = `into ${escapeHtml(d.branch)}` + (d.reviewers !== null ? ` \xB7 reviewed by ${d.reviewers}` : "");
277
+ return `<section class="scene" id="${scene2.id}"><svg class="merge-badge" id="mergeBadge" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><circle cx="14" cy="12" r="4"/><circle cx="14" cy="36" r="4"/><circle cx="34" cy="12" r="4"/><path d="M14 16v12a8 8 0 0 0 8 8h4"/><path d="M34 16v4"/></svg><div class="merge-head">${escapeHtml(head)}</div><div class="merge-sub">${sub}</div><div class="confetti" id="confetti"></div></section>`;
278
+ }
279
+ case "end": {
280
+ const d = scene2.data;
281
+ return `<section class="scene" id="${scene2.id}"><div class="end-mark">vibe<b>movie</b></div><div class="end-sub">${escapeHtml(d.tagline)}</div><svg class="loop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M17 2l4 4-4 4"/><path d="M3 12v-2a4 4 0 0 1 4-4h14"/><path d="M7 22l-4-4 4-4"/><path d="M21 12v2a4 4 0 0 1-4 4H3"/></svg></section>`;
282
+ }
283
+ }
284
+ }
285
+ function css(ratio) {
286
+ const screenSizing = ratio === "16:9" ? ".screen{position:relative;aspect-ratio:16/9;overflow:hidden;" : ratio === "9:16" ? ".screen{position:relative;height:min(74vh,760px);width:calc(min(74vh,760px) * 9 / 16);max-width:100%;margin:0 auto;overflow:hidden;" : ".screen{position:relative;height:min(74vh,640px);width:calc(min(74vh,640px));max-width:100%;margin:0 auto;overflow:hidden;";
287
+ return `
288
+ :root{
289
+ --bg: #0a0908; --bg-raised: #14120e; --frame: #050403;
290
+ --ink: #f2ede1; --ink-dim: #a89e8c; --ink-faint: #6b6255;
291
+ --gold: #d9a94e; --add: #7fae8a; --remove: #b25a45; --merge: #a084d6;
292
+ --ease: cubic-bezier(0.16, 1, 0.3, 1); --radius: 14px;
293
+ }
294
+ *{ box-sizing: border-box; }
295
+ html,body{ margin:0; padding:0; min-height:100%; background: var(--bg); color: var(--ink);
296
+ font-family: -apple-system, "SF Pro Text", "Segoe UI", Roboto, system-ui, sans-serif;
297
+ -webkit-font-smoothing: antialiased; }
298
+ body{ padding: 40px 20px 64px; display:flex; justify-content:center; }
299
+ .stage{ width:100%; max-width: 900px; }
300
+ .topbar{ display:flex; align-items:baseline; justify-content:space-between; margin-bottom: 22px; flex-wrap: wrap; gap: 10px; }
301
+ .wordmark{ font-size: 1.35rem; font-weight: 700; letter-spacing: -0.03em; }
302
+ .wordmark b{ font-weight: 700; color: var(--gold); }
303
+ .tagline{ display:block; font-size: 0.78rem; color: var(--ink-faint); margin-top: 2px; }
304
+ .badge-local{ display:inline-flex; align-items:center; gap:7px; font-size: 0.72rem; font-weight: 600;
305
+ color: var(--ink-dim); background: var(--bg-raised); border: 1px solid #262019;
306
+ padding: 6px 12px; border-radius: 999px; white-space: nowrap; }
307
+ .badge-local .dot{ width:7px; height:7px; border-radius:50%; background: #7fc98a; }
308
+ @media (prefers-reduced-motion: no-preference){
309
+ .badge-local .dot{ animation: pulse-dot 2.4s var(--ease) infinite; }
310
+ }
311
+ @keyframes pulse-dot{ 0%{ box-shadow: 0 0 0 0 rgba(127,201,138,0.45);} 70%{ box-shadow: 0 0 0 6px rgba(127,201,138,0);} 100%{ box-shadow: 0 0 0 0 rgba(127,201,138,0);} }
312
+ .player{ position: relative; background: var(--frame); border-radius: var(--radius);
313
+ box-shadow: 0 24px 60px -20px rgba(0,0,0,0.75), 0 2px 0 rgba(255,255,255,0.02) inset;
314
+ overflow: hidden; border: 1px solid #201a12; }
315
+ .filmstrip{ height: 12px; background-color: #171009;
316
+ background-image: radial-gradient(circle 3.2px at 12px 6px, var(--bg) 3.2px, transparent 3.6px);
317
+ background-size: 24px 12px; background-repeat: repeat-x; }
318
+ ${screenSizing}
319
+ background: radial-gradient(120% 100% at 50% 0%, #171008 0%, #0a0705 60%, #050302 100%); }
320
+ .screen::before{ content:""; position:absolute; inset:0; pointer-events:none; z-index: 5;
321
+ box-shadow: inset 0 0 90px 20px rgba(0,0,0,0.55); }
322
+ .scene{ position:absolute; inset:0; display:none; flex-direction:column; align-items:center;
323
+ justify-content:center; text-align:center; padding: 8% 9%; will-change: transform, opacity; }
324
+ .scene.is-live{ display:flex; }
325
+ .reel{ position:absolute; top:8%; right:6%; width: 90px; height: 90px; opacity: .16; }
326
+ @media (prefers-reduced-motion: no-preference){ .reel{ animation: spin 26s linear infinite; } }
327
+ @keyframes spin{ to{ transform: rotate(360deg); } }
328
+ .session-title{ font-size: clamp(1.6rem, 4.4vw, 2.7rem); font-weight: 700; letter-spacing: -0.03em; margin: 0 0 10px; }
329
+ .session-sub{ font-family: "SF Mono", "Menlo", monospace; font-size: clamp(1rem, 2.6vw, 1.35rem);
330
+ color: var(--gold); font-weight: 600; letter-spacing: -0.01em; }
331
+ .session-caption{ margin-top: 14px; font-size: 0.82rem; color: var(--ink-faint); }
332
+ .tasks-head{ font-size: clamp(1.3rem, 3.4vw, 2rem); font-weight: 700; letter-spacing: -0.03em; margin-bottom: 22px; }
333
+ .tasks-head b{ color: var(--gold); font-size: 1.25em; }
334
+ .task-list{ width: min(440px, 84%); display:flex; flex-direction:column; gap: 12px; }
335
+ .task-row{ display:flex; align-items:center; gap: 12px; text-align:left; padding: 10px 4px; border-bottom: 1px solid #201a12; }
336
+ .task-row:last-child{ border-bottom: none; }
337
+ .task-row .tick{ flex: none; }
338
+ .task-row .label{ flex:1; font-size: 0.92rem; color: var(--ink); }
339
+ .task-row .dur{ font-family: "SF Mono", Menlo, monospace; font-size: 0.74rem; color: var(--ink-faint); }
340
+ .task-more{ font-size: 0.8rem; color: var(--ink-faint); text-align:left; padding: 6px 4px 0 32px; }
341
+ .diff-head{ font-size: clamp(1.3rem, 3.4vw, 2rem); font-weight: 700; letter-spacing: -0.03em; margin-bottom: 20px; }
342
+ .diff-bar-wrap{ width: min(440px, 84%); }
343
+ .diff-bar{ height: 14px; border-radius: 7px; overflow:hidden; display:flex; background: #1a140d; }
344
+ .diff-bar .add{ background: var(--add); height:100%; width:0%; }
345
+ .diff-bar .remove{ background: var(--remove); height:100%; width:0%; }
346
+ .diff-stats{ margin-top: 10px; font-family: "SF Mono", Menlo, monospace; font-size: 0.9rem; display:flex; gap: 16px; justify-content:center; }
347
+ .diff-stats .plus{ color: var(--add); }
348
+ .diff-stats .minus{ color: var(--remove); }
349
+ .diff-files{ margin-top: 18px; display:flex; flex-direction:column; gap: 6px; align-items:center; }
350
+ .diff-files .f{ font-family: "SF Mono", Menlo, monospace; font-size: 0.74rem; color: var(--ink-dim); opacity: 0; transform: translateY(8px); }
351
+ .terminal{ width: min(460px, 88%); background: #0d0b08; border: 1px solid #241d14; border-radius: 10px;
352
+ overflow:hidden; text-align:left; box-shadow: 0 14px 34px -16px rgba(0,0,0,0.7); }
353
+ .terminal-bar{ display:flex; align-items:center; gap:6px; padding: 8px 10px; background: #161109; border-bottom: 1px solid #241d14; }
354
+ .terminal-bar span{ width:9px; height:9px; border-radius:50%; background:#3a3024; }
355
+ .terminal-bar .name{ margin-left: 8px; font-size: 0.7rem; color: var(--ink-faint); font-family: "SF Mono", Menlo, monospace; }
356
+ .terminal-body{ padding: 14px 16px; font-family: "SF Mono", Menlo, monospace; font-size: 0.78rem;
357
+ line-height: 1.7; min-height: 108px; color: var(--ink-dim); }
358
+ .terminal-body .ok{ color: var(--add); }
359
+ .terminal-body .cursor{ display:inline-block; width: 6px; height: 12px; background: var(--gold); vertical-align: -2px; margin-left: 2px; }
360
+ @media (prefers-reduced-motion: no-preference){ .terminal-body .cursor.blink{ animation: blink 1s steps(1) infinite; } }
361
+ @keyframes blink{ 50%{ opacity: 0; } }
362
+ .merge-badge{ width: 74px; height: 74px; margin-bottom: 14px; color: var(--merge); }
363
+ .merge-head{ font-size: clamp(1.3rem, 3.4vw, 2rem); font-weight:700; letter-spacing:-0.03em; }
364
+ .merge-sub{ margin-top: 6px; font-size: 0.85rem; color: var(--ink-dim); }
365
+ .confetti{ position:absolute; inset:0; pointer-events:none; overflow:hidden; }
366
+ .confetti i{ position:absolute; top:-10%; width: 7px; height: 12px; opacity:0; border-radius: 2px; }
367
+ .confetti.is-firing i{ animation: fall 1.8s ease-in forwards; }
368
+ @keyframes fall{ 0%{ opacity: 1; transform: translateY(0) rotate(0deg);} 100%{ opacity: 0; transform: translateY(220px) rotate(300deg);} }
369
+ .end-mark{ font-size: clamp(1.4rem, 3.6vw, 2.2rem); font-weight:700; letter-spacing:-0.03em; }
370
+ .end-mark b{ color: var(--gold); }
371
+ .end-sub{ margin-top: 8px; font-size: 0.8rem; color: var(--ink-faint); }
372
+ .loop-icon{ width: 26px; height: 26px; margin-top: 16px; color: var(--ink-faint); }
373
+ @media (prefers-reduced-motion: no-preference){ .loop-icon{ animation: spin 3.5s linear infinite; } }
374
+ .scene-title{ position:absolute; left: 20px; bottom: 18px; z-index: 6; font-size: 0.72rem; font-weight: 600;
375
+ letter-spacing: 0.02em; color: var(--ink-dim); background: rgba(5,4,3,0.55); backdrop-filter: blur(6px);
376
+ padding: 6px 11px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.06);
377
+ opacity: 0; transform: translateY(6px); transition: opacity .35s var(--ease), transform .35s var(--ease); }
378
+ .scene-title.is-shown{ opacity: 1; transform: translateY(0); }
379
+ .captions{ position: relative; z-index: 6; background: var(--bg-raised); border-top: 1px solid #201a12;
380
+ color: var(--ink); font-size: 0.86rem; padding: 12px 18px; min-height: 20px; text-align:center; }
381
+ .controls{ display:flex; align-items:center; gap: 12px; padding: 12px 16px;
382
+ background: var(--bg-raised); border-top: 1px solid #201a12; }
383
+ .icon-btn{ flex: none; width: 34px; height: 34px; border-radius: 9px; display:flex; align-items:center;
384
+ justify-content:center; background: #201a12; border: 1px solid #2c2417; color: var(--ink); cursor: pointer;
385
+ transition: background .16s var(--ease); }
386
+ .icon-btn:hover{ background: #2c2417; }
387
+ .icon-btn svg{ width: 15px; height: 15px; }
388
+ .icon-btn:focus-visible{ outline: 2px solid var(--gold); outline-offset: 2px; }
389
+ .time{ font-family: "SF Mono", Menlo, monospace; font-size: 0.76rem; color: var(--ink-faint); flex: none; width: 38px; }
390
+ .time.total{ text-align: right; }
391
+ .scrubber{ flex: 1; position: relative; height: 22px; display:flex; align-items:center; cursor: pointer; }
392
+ .scrubber-track{ position:absolute; left:0; right:0; top: 50%; height: 4px; transform: translateY(-50%);
393
+ background: #241d14; border-radius: 999px; overflow: hidden; }
394
+ .scrubber-fill{ position:absolute; left:0; top:0; bottom:0; width: 0%; background: var(--gold); border-radius: 999px; }
395
+ .scrubber-markers{ position:absolute; inset:0; }
396
+ .scrubber-markers i{ position:absolute; top:50%; width:1px; height: 10px; background: rgba(0,0,0,0.5); transform: translate(0, -50%); }
397
+ .scrubber-thumb{ position:absolute; top:50%; width: 11px; height:11px; border-radius:50%;
398
+ background: var(--ink); transform: translate(-50%, -50%); box-shadow: 0 2px 6px rgba(0,0,0,0.5); }
399
+ .foot{ margin-top: 18px; text-align:center; font-size: 0.74rem; color: var(--ink-faint); }
400
+ @media (max-width: 560px){ .controls{ flex-wrap: wrap; } }
401
+ `;
402
+ }
403
+ function engineJs(scenesJson) {
404
+ return `
405
+ (function(){
406
+ "use strict";
407
+ var SCENES = ${scenesJson};
408
+ var offsets = []; var acc = 0; var i;
409
+ for (i = 0; i < SCENES.length; i++) { offsets.push(acc); acc += SCENES[i].duration; }
410
+ var TOTAL = acc;
411
+
412
+ var els = {};
413
+ for (i = 0; i < SCENES.length; i++) { els[SCENES[i].id] = document.getElementById(SCENES[i].id); }
414
+
415
+ var sceneTitleEl = document.getElementById('sceneTitle');
416
+ var captionsEl = document.getElementById('captions');
417
+ var playBtn = document.getElementById('playBtn');
418
+ var playIcon = document.getElementById('playIcon');
419
+ var scrubber = document.getElementById('scrubber');
420
+ var scrubberFill = document.getElementById('scrubberFill');
421
+ var scrubberThumb= document.getElementById('scrubberThumb');
422
+ var scrubberMarkers = document.getElementById('scrubberMarkers');
423
+ var timeCurrent = document.getElementById('timeCurrent');
424
+ var timeTotal = document.getElementById('timeTotal');
425
+
426
+ for (i = 1; i < SCENES.length; i++) {
427
+ var m = document.createElement('i');
428
+ m.style.left = (offsets[i] / TOTAL * 100) + '%';
429
+ scrubberMarkers.appendChild(m);
430
+ }
431
+
432
+ function fmt(ms){
433
+ var s = Math.floor(ms/1000);
434
+ var m = Math.floor(s/60); s = s%60;
435
+ return m + ':' + (s<10?'0':'') + s;
436
+ }
437
+ timeTotal.textContent = fmt(TOTAL);
438
+
439
+ function esc(s){
440
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
441
+ }
442
+ function easeOutExpo(x){ return x >= 1 ? 1 : 1 - Math.pow(2, -10*x); }
443
+ function easeOutBack(x){ var c1 = 1.7, c3 = c1+1; return 1 + c3*Math.pow(x-1,3) + c1*Math.pow(x-1,2); }
444
+ function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }
445
+
446
+ /* deterministic PRNG so confetti is identical on every play */
447
+ function mulberry32(a){
448
+ return function(){
449
+ a |= 0; a = a + 0x6D2B79F5 | 0;
450
+ var t = Math.imul(a ^ a >>> 15, 1 | a);
451
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
452
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
453
+ };
454
+ }
455
+ var rand = mulberry32(12648430);
456
+ var confettiColors = ['#d9a94e','#7fae8a','#b25a45','#a084d6'];
457
+ function buildConfetti(){
458
+ var c = document.getElementById('confetti');
459
+ if (!c) return;
460
+ c.innerHTML = '';
461
+ for (var j = 0; j < 16; j++) {
462
+ var el = document.createElement('i');
463
+ el.style.left = (8 + rand()*84) + '%';
464
+ el.style.background = confettiColors[j % confettiColors.length];
465
+ el.style.animationDelay = (rand()*0.5) + 's';
466
+ el.style.transform = 'rotate(' + Math.floor(rand()*360) + 'deg)';
467
+ c.appendChild(el);
468
+ }
469
+ }
470
+ buildConfetti();
471
+
472
+ var CPS = 34; /* terminal typing speed, chars per second */
473
+
474
+ var renderers = {
475
+ title: function(scene, localT){
476
+ var el = document.getElementById('titleCounter');
477
+ var totalMin = Math.round(scene.data.totalMinutes * easeOutExpo(clamp(localT/2400, 0, 1)));
478
+ var h = Math.floor(totalMin/60), m = totalMin%60;
479
+ el.textContent = h + 'h ' + (m<10?'0':'') + m + 'm';
480
+ },
481
+ tasks: function(scene, localT){
482
+ var rows = els[scene.id].querySelectorAll('.task-row');
483
+ for (var j = 0; j < rows.length; j++) {
484
+ var start = 450 + j*380;
485
+ var p = easeOutExpo(clamp((localT-start)/420, 0, 1));
486
+ var row = rows[j];
487
+ row.style.opacity = p;
488
+ row.style.transform = 'translateY(' + ((1-p)*14) + 'px)';
489
+ row.querySelector('.tick path').setAttribute('stroke-dashoffset', String(1-p));
490
+ }
491
+ },
492
+ diff: function(scene, localT){
493
+ var adds = scene.data.additions, dels = scene.data.deletions;
494
+ var total = adds + dels;
495
+ var p = easeOutExpo(clamp((localT-350)/900, 0, 1));
496
+ document.getElementById('diffAdd').style.width = (p * (total > 0 ? adds/total : 0) * 100) + '%';
497
+ document.getElementById('diffRemove').style.width = (p * (total > 0 ? dels/total : 0) * 100) + '%';
498
+ var files = document.getElementById('diffFiles').querySelectorAll('.f');
499
+ for (var j = 0; j < files.length; j++) {
500
+ var start = 500 + j*160;
501
+ var fp = easeOutExpo(clamp((localT-start)/300, 0, 1));
502
+ files[j].style.opacity = fp;
503
+ files[j].style.transform = 'translateY(' + ((1-fp)*8) + 'px)';
504
+ }
505
+ },
506
+ terminal: function(scene, localT){
507
+ var lines = scene.data.lines;
508
+ var out = '';
509
+ var t0 = 250;
510
+ for (var j = 0; j < lines.length; j++) {
511
+ var elapsed = localT - t0;
512
+ t0 += (lines[j].text.length / CPS) * 1000 + 260;
513
+ if (elapsed <= 0) continue;
514
+ var chars = Math.min(lines[j].text.length, Math.floor(elapsed/1000*CPS));
515
+ var shown = esc(lines[j].text.slice(0, chars));
516
+ var typing = chars < lines[j].text.length;
517
+ var cls = lines[j].cls ? ' class="' + lines[j].cls + '"' : '';
518
+ out += '<div' + cls + '>' + shown + (typing ? '<span class="cursor blink"></span>' : '') + '</div>';
519
+ }
520
+ document.getElementById('termBody').innerHTML = out;
521
+ },
522
+ merge: function(scene, localT, justEntered){
523
+ var p = easeOutBack(clamp(localT/480, 0, 1));
524
+ var badge = document.getElementById('mergeBadge');
525
+ badge.style.transform = 'scale(' + Math.max(0,p) + ') rotate(' + ((1-clamp(localT/480,0,1))*-10) + 'deg)';
526
+ if (justEntered) {
527
+ var c = document.getElementById('confetti');
528
+ c.classList.remove('is-firing');
529
+ void c.offsetWidth;
530
+ c.classList.add('is-firing');
531
+ }
532
+ },
533
+ end: function(){}
534
+ };
535
+
536
+ function sceneIndexAt(time){
537
+ for (var j = SCENES.length-1; j >= 0; j--) { if (time >= offsets[j]) return j; }
538
+ return 0;
539
+ }
540
+
541
+ function applyTransition(el, type, localT, dur){
542
+ var inWin = Math.min(560, dur*0.28);
543
+ var outWin = Math.min(560, dur*0.28);
544
+ var opacity = 1, tf = 'none';
545
+ if (localT < inWin) {
546
+ var p = easeOutExpo(localT/inWin);
547
+ if (type==='fade'){ opacity=p; tf = 'scale(' + (0.98+0.02*p) + ')'; }
548
+ else if (type==='slide'){ opacity=p; tf = 'translateX(' + ((1-p)*44) + 'px)'; }
549
+ else if (type==='scale'){ opacity=p; tf = 'scale(' + (0.9+0.1*p) + ')'; }
550
+ else if (type==='rise'){ opacity=p; tf = 'translateY(' + ((1-p)*40) + 'px)'; }
551
+ } else if (localT > dur-outWin) {
552
+ var p2 = easeOutExpo((localT-(dur-outWin))/outWin);
553
+ var q = 1-p2;
554
+ if (type==='fade'){ opacity=q; tf = 'scale(' + (1-0.02*p2) + ')'; }
555
+ else if (type==='slide'){ opacity=q; tf = 'translateX(' + (-p2*44) + 'px)'; }
556
+ else if (type==='scale'){ opacity=q; tf = 'scale(' + (1-0.1*p2) + ')'; }
557
+ else if (type==='rise'){ opacity=q; tf = 'translateY(' + (-p2*40) + 'px)'; }
558
+ }
559
+ el.style.opacity = opacity;
560
+ el.style.transform = tf;
561
+ }
562
+
563
+ var isPlaying = true;
564
+ var t = 0;
565
+ var lastSceneIdx = -1;
566
+ var lastFrame = null;
567
+
568
+ function render(){
569
+ var idx = sceneIndexAt(t);
570
+ var scene = SCENES[idx];
571
+ var localT = t - offsets[idx];
572
+ var justEntered = idx !== lastSceneIdx;
573
+
574
+ for (var j = 0; j < SCENES.length; j++) {
575
+ var el = els[SCENES[j].id];
576
+ if (j === idx) {
577
+ el.classList.add('is-live');
578
+ applyTransition(el, SCENES[j].transition, localT, SCENES[j].duration);
579
+ } else {
580
+ el.classList.remove('is-live');
581
+ }
582
+ }
583
+
584
+ renderers[scene.kind](scene, localT, justEntered);
585
+
586
+ if (justEntered) {
587
+ sceneTitleEl.textContent = scene.kind.replace(/^\\w/, function(c){ return c.toUpperCase(); });
588
+ sceneTitleEl.classList.add('is-shown');
589
+ captionsEl.textContent = scene.caption;
590
+ }
591
+ lastSceneIdx = idx;
592
+
593
+ var pct = (t/TOTAL*100);
594
+ scrubberFill.style.width = pct + '%';
595
+ scrubberThumb.style.left = pct + '%';
596
+ timeCurrent.textContent = fmt(t);
597
+ }
598
+
599
+ function loop(ts){
600
+ if (lastFrame === null) lastFrame = ts;
601
+ var dt = ts - lastFrame;
602
+ lastFrame = ts;
603
+ if (isPlaying) { t += dt; if (t >= TOTAL) t = t % TOTAL; }
604
+ render();
605
+ requestAnimationFrame(loop);
606
+ }
607
+ requestAnimationFrame(loop);
608
+
609
+ function setPlaying(v){
610
+ isPlaying = v;
611
+ playBtn.setAttribute('aria-label', v ? 'Pause' : 'Play');
612
+ playIcon.innerHTML = v
613
+ ? '<rect x="3" y="2" width="4" height="12" rx="1"/><rect x="9" y="2" width="4" height="12" rx="1"/>'
614
+ : '<path d="M4 2l10 6-10 6z"/>';
615
+ }
616
+ playBtn.addEventListener('click', function(){ setPlaying(!isPlaying); });
617
+
618
+ function seekFromEvent(e){
619
+ var rect = scrubber.getBoundingClientRect();
620
+ var x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
621
+ var p = clamp(x/rect.width, 0, 1);
622
+ t = p * TOTAL;
623
+ lastSceneIdx = -1;
624
+ render();
625
+ }
626
+ var dragging = false;
627
+ scrubber.addEventListener('pointerdown', function(e){
628
+ dragging = true; scrubber.setPointerCapture(e.pointerId); seekFromEvent(e);
629
+ });
630
+ scrubber.addEventListener('pointermove', function(e){ if (dragging) seekFromEvent(e); });
631
+ scrubber.addEventListener('pointerup', function(){ dragging = false; });
632
+ })();
633
+ `;
634
+ }
635
+ function renderHyperframes(scenes, opts = {}) {
636
+ const ratio = opts.ratio ?? "16:9";
637
+ const titleScene = scenes.find((s) => s.kind === "title");
638
+ const docTitle = opts.title ?? titleScene?.data.sessionName ?? "session recap";
639
+ const scenesJson = embedJson(scenes);
640
+ const body = scenes.map(sceneHtml).join("\n ");
641
+ return `<!DOCTYPE html>
642
+ <html lang="en">
643
+ <head>
644
+ <meta charset="UTF-8">
645
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
646
+ <title>vibemovie \u2014 ${escapeHtml(docTitle)}</title>
647
+ <style>${css(ratio)}</style>
648
+ </head>
649
+ <body>
650
+ <div class="stage">
651
+
652
+ <div class="topbar">
653
+ <div>
654
+ <div class="wordmark">vibe<b>movie</b></div>
655
+ <span class="tagline">${escapeHtml(docTitle)}</span>
656
+ </div>
657
+ <div class="badge-local"><span class="dot"></span>local \xB7 no data out</div>
658
+ </div>
659
+
660
+ <div class="player">
661
+ <div class="filmstrip"></div>
662
+ <div class="screen" id="screen">
663
+ ${body}
664
+ <div class="scene-title" id="sceneTitle"></div>
665
+ </div>
666
+ <div class="captions" id="captions"></div>
667
+ <div class="controls">
668
+ <button class="icon-btn" id="playBtn" aria-label="Pause">
669
+ <svg id="playIcon" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12" rx="1"/><rect x="9" y="2" width="4" height="12" rx="1"/></svg>
670
+ </button>
671
+ <div class="time" id="timeCurrent">0:00</div>
672
+ <div class="scrubber" id="scrubber">
673
+ <div class="scrubber-track"><div class="scrubber-fill" id="scrubberFill"></div></div>
674
+ <div class="scrubber-markers" id="scrubberMarkers"></div>
675
+ <div class="scrubber-thumb" id="scrubberThumb"></div>
676
+ </div>
677
+ <div class="time total" id="timeTotal">0:00</div>
678
+ </div>
679
+ </div>
680
+
681
+ <div class="foot">hyperframes \xB7 rendered offline \u2014 no keys, no network, no data out</div>
682
+
683
+ </div>
684
+ <script>${engineJs(scenesJson)}</script>
685
+ </body>
686
+ </html>
687
+ `;
688
+ }
689
+
690
+ // src/cinematic.ts
691
+ import { execFileSync } from "child_process";
692
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "fs";
693
+ import { tmpdir } from "os";
694
+ import { basename as basename2, join } from "path";
695
+ var ENGINES = ["hyperframes", "cinematic"];
696
+ var STYLE = "cinematic 1980s film still, warm tungsten light with subtle magenta and cyan neon accents, 35mm film grain, shallow depth of field, night";
697
+ var SET = "the SAME single cozy 1980s home office at night: a wooden desk front and center with a glowing laptop and mechanical keyboard, a steaming mug, sticky notes on the wall behind it, a warm desk lamp on the left, and a bookshelf with a glowing retro radio on the right";
698
+ var DEV = "a young woman software developer in her early twenties, big voluminous curly blonde 80s hair, natural makeup with soft neutral eyeshadow and red lipstick, wearing a VIBRANT colorful 80s outfit: a bold color-blocked cropped jacket in hot pink and electric blue over a bright teal tee, vibrant and colorful";
699
+ var LOCK = "Identical woman and identical office as the reference (same face, big curly blonde hair, natural red-lip makeup, vibrant hot-pink-and-blue cropped jacket, teal tee, same desk/laptop/lamp/sticky-notes/bookshelf). Only change pose and camera.";
700
+ var EYE = "looking at what she is doing, not at the camera";
701
+ function plural2(n, word) {
702
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
703
+ }
704
+ function deriveReferencePrompts() {
705
+ return {
706
+ hero: `${STYLE}. Wide reference of ${DEV}, settling in to work at ${SET}.`,
707
+ face: `${STYLE}. Clean sharp front-facing headshot portrait of ${DEV}, looking straight at camera, evenly lit, crisp focus on her face.`
708
+ };
709
+ }
710
+ function beatFor(scene2) {
711
+ switch (scene2.kind) {
712
+ case "title": {
713
+ const d = scene2.data;
714
+ const dur = d.totalMinutes > 0 ? `${formatMinutes(d.totalMinutes)} in the flow` : "a session worth replaying";
715
+ return {
716
+ kind: scene2.kind,
717
+ keyframe: `${LOCK} Wide establishing shot from across the room: she drops into her desk chair and flips open the glowing laptop, screen light spilling onto her face, ${EYE}. ${STYLE}`,
718
+ motion: `she sits down at the desk and opens the laptop, the screen wakes and lights her face, smooth settle-in. ${STYLE}`,
719
+ vo: `[energetically] ${d.sessionName} \u2014 ${dur}. Let's run it back.`
720
+ };
721
+ }
722
+ case "tasks": {
723
+ const d = scene2.data;
724
+ const first = d.tasks[0]?.label ?? "the first task";
725
+ return {
726
+ kind: scene2.kind,
727
+ keyframe: `${LOCK} Close-up side profile: she types fast on the mechanical keyboard, then reaches up and ticks a sticky note on the wall, mid-action, ${EYE}. ${STYLE}`,
728
+ motion: `she types quickly, then reaches over and checks off a sticky note, natural working rhythm. ${STYLE}`,
729
+ vo: d.total === 1 ? `[excited] One task landed, clean \u2014 ${first}.` : `[excited] ${plural2(d.total, "task")} landed, clean \u2014 kicked off by ${first}.`
730
+ };
731
+ }
732
+ case "diff": {
733
+ const d = scene2.data;
734
+ return {
735
+ kind: scene2.kind,
736
+ keyframe: `${LOCK} Over-the-shoulder shot from behind her: she leans into the glowing laptop covered in code, one hand mid-scroll, screen glow catching her hair, ${EYE}. ${STYLE}`,
737
+ motion: `she scrolls through the diff and leans in closer to the code, subtle continuous motion. ${STYLE}`,
738
+ vo: `[excited] ${plural2(d.filesChanged, "file")} touched \u2014 plus ${d.additions}, minus ${d.deletions}.`
739
+ };
740
+ }
741
+ case "terminal": {
742
+ const d = scene2.data;
743
+ const hasTests = d.lines.some((l) => l.cls === "ok" && l.text.includes("passed"));
744
+ return {
745
+ kind: scene2.kind,
746
+ keyframe: `${LOCK} Three-quarter shot from the lamp side: she throws a triumphant fist pump at the glowing screen, mid-cheer, ${EYE}. ${STYLE}`,
747
+ motion: `she reads the screen, then pumps her fist in celebration, energetic. ${STYLE}`,
748
+ vo: hasTests ? "[shouting] GREEN! Green across the board \u2014 every test, passing!" : "[excited] The commands tell the story \u2014 clean, top to bottom."
749
+ };
750
+ }
751
+ case "merge": {
752
+ const d = scene2.data;
753
+ const vo = d.pr !== null ? d.merged ? `[excited] And there it is \u2014 pull request number ${d.pr}, merged into ${d.branch}! No conflicts, no mercy!` : `[excited] Pull request number ${d.pr} is open on ${d.branch} \u2014 reviews incoming!` : d.merged ? `[excited] Merged into ${d.branch}! No conflicts, no mercy!` : `[excited] The pull request is open on ${d.branch}!`;
754
+ return {
755
+ kind: scene2.kind,
756
+ keyframe: `${LOCK} Low-angle shot from the desk surface: she hits the enter key with a flourish and throws both hands up, screen glow flaring, mid-celebration, ${EYE}. ${STYLE}`,
757
+ motion: `she slams enter, throws both hands up and leans back laughing, joyful release. ${STYLE}`,
758
+ vo
759
+ };
760
+ }
761
+ case "end": {
762
+ return {
763
+ kind: scene2.kind,
764
+ keyframe: `${LOCK} Wide slightly dutch-angle shot: she closes the laptop and leans back in her chair with a satisfied smile, lamplight warm on the room, ${EYE}. ${STYLE}`,
765
+ motion: `she gently closes the laptop and leans back, smiling, calm settle. ${STYLE}`,
766
+ vo: `[warmly] ${scene2.caption} This is vibemovie.`
767
+ };
768
+ }
769
+ }
770
+ }
771
+ function deriveBeats(scenes) {
772
+ return scenes.map(beatFor);
773
+ }
774
+ function cinematicAvailable(env = process.env) {
775
+ const key = env["WAVESPEED_API_KEY"];
776
+ return typeof key === "string" && key.trim().length > 0;
777
+ }
778
+ var defaultExec = (file, args) => {
779
+ execFileSync(file, [...args], { stdio: "ignore" });
780
+ };
781
+ function ffmpegAvailable(execFn = defaultExec) {
782
+ try {
783
+ execFn("ffmpeg", ["-version"]);
784
+ return true;
785
+ } catch {
786
+ return false;
787
+ }
788
+ }
789
+ var API_BASE = "https://api.wavespeed.ai/api/v3";
790
+ var MODEL_T2I = "bytedance/seedream-v4";
791
+ var MODEL_EDIT = "wavespeed-ai/flux-kontext-max";
792
+ var MODEL_I2V = "kwaivgi/kling-v2.5-turbo-pro/image-to-video";
793
+ var MODEL_FACE_SWAP = "wavespeed-ai/video-face-swap";
794
+ var MODEL_TTS = "elevenlabs/eleven-v3";
795
+ var T2I_SIZE = "1920*1080";
796
+ var CLIP_SECONDS = 5;
797
+ var NEGATIVE_PROMPT = "morphing, warping, distorted face, changing outfit, extra fingers, flicker, duplicated objects, ghosting, double image, cloned furniture";
798
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
799
+ async function renderCinematic(scenes, opts) {
800
+ const apiKey = opts.apiKey ?? process.env["WAVESPEED_API_KEY"];
801
+ if (typeof apiKey !== "string" || apiKey.trim().length === 0) {
802
+ throw new Error("cinematic: WAVESPEED_API_KEY is required (set it in the environment or pass opts.apiKey)");
803
+ }
804
+ const fetchFn = opts.deps?.fetchFn ?? fetch;
805
+ const execFn = opts.deps?.execFn ?? defaultExec;
806
+ const sleepFn = opts.deps?.sleepFn ?? sleep;
807
+ const log = opts.log ?? ((msg) => process.stderr.write(`${msg}
808
+ `));
809
+ const voiceId = opts.voiceId ?? "George";
810
+ const workDir = opts.workDir ?? mkdtempSync(join(tmpdir(), "vibemovie-cinematic-"));
811
+ mkdirSync(workDir, { recursive: true });
812
+ const headers = { Authorization: `Bearer ${apiKey}` };
813
+ const post = async (model, body) => {
814
+ const res = await fetchFn(`${API_BASE}/${model}`, {
815
+ method: "POST",
816
+ headers: { ...headers, "Content-Type": "application/json" },
817
+ body: JSON.stringify(body)
818
+ });
819
+ const j = await res.json();
820
+ const id = j.data?.id;
821
+ if (id === void 0) throw new Error(`cinematic: ${model} did not return a prediction id`);
822
+ return id;
823
+ };
824
+ const poll = async (id, maxTicks = 95, intervalMs = 3e3) => {
825
+ for (let i = 0; i < maxTicks; i++) {
826
+ const res = await fetchFn(`${API_BASE}/predictions/${id}/result`, { headers });
827
+ const j = await res.json();
828
+ const status = j.data?.status;
829
+ const out = j.data?.outputs?.[0];
830
+ if (status === "completed" && out !== void 0) return out;
831
+ if (status === "failed") throw new Error(`cinematic: prediction ${id} failed`);
832
+ await sleepFn(intervalMs);
833
+ }
834
+ throw new Error(`cinematic: prediction ${id} timed out`);
835
+ };
836
+ const download = async (url, path) => {
837
+ const res = await fetchFn(url);
838
+ writeFileSync(path, Buffer.from(await res.arrayBuffer()));
839
+ };
840
+ const upload = async (path) => {
841
+ const form = new FormData();
842
+ form.append("file", new Blob([readFileSync(path)]), basename2(path));
843
+ const res = await fetchFn(`${API_BASE}/media/upload/binary`, { method: "POST", headers, body: form });
844
+ const j = await res.json();
845
+ const url = j.data?.download_url;
846
+ if (url === void 0) throw new Error("cinematic: media upload failed");
847
+ return url;
848
+ };
849
+ const t2i = async (prompt) => poll(await post(MODEL_T2I, { prompt, size: T2I_SIZE }));
850
+ const edit = async (prompt, image) => poll(await post(MODEL_EDIT, { prompt, image }));
851
+ const kling = async (first, last, motion) => poll(
852
+ await post(MODEL_I2V, {
853
+ image: first,
854
+ last_image: last,
855
+ prompt: motion,
856
+ duration: CLIP_SECONDS,
857
+ negative_prompt: NEGATIVE_PROMPT
858
+ })
859
+ );
860
+ const tts = async (text) => poll(
861
+ await post(MODEL_TTS, {
862
+ text,
863
+ voice_id: voiceId,
864
+ stability: 0.3,
865
+ similarity: 0.75,
866
+ use_speaker_boost: true
867
+ }),
868
+ 60,
869
+ 2500
870
+ );
871
+ const beats = deriveBeats(scenes);
872
+ if (beats.length < 2) throw new Error("cinematic: need at least 2 scenes to build a film");
873
+ log("cinematic: hero + face ref...");
874
+ const { hero: heroPrompt, face: facePrompt } = deriveReferencePrompts();
875
+ const [heroUrl, faceUrl] = await Promise.all([t2i(heroPrompt), t2i(facePrompt)]);
876
+ log(`cinematic: ${beats.length} keyframes (parallel)...`);
877
+ const kfUrls = await Promise.all(beats.map((b) => edit(b.keyframe, heroUrl)));
878
+ const lastKfPath = join(workDir, "end.jpg");
879
+ await download(kfUrls[kfUrls.length - 1], lastKfPath);
880
+ const clipCount = beats.length - 1;
881
+ log(`cinematic: ${clipCount} Kling clips (parallel, first+last frame chained)...`);
882
+ const clipPaths = await Promise.all(
883
+ beats.slice(0, -1).map(async (b, i) => {
884
+ const path = join(workDir, `c${i}.mp4`);
885
+ try {
886
+ await download(await kling(kfUrls[i], kfUrls[i + 1], b.motion), path);
887
+ log(`cinematic: clip ${i}`);
888
+ return path;
889
+ } catch (err) {
890
+ log(`cinematic: clip ${i} failed (${err instanceof Error ? err.message : String(err)}) \u2014 skipping`);
891
+ return null;
892
+ }
893
+ })
894
+ );
895
+ const clips = clipPaths.filter((p) => p !== null && existsSync(p));
896
+ if (clips.length === 0) throw new Error("cinematic: all Kling clips failed");
897
+ const segs = [];
898
+ for (let i = 0; i < clips.length; i++) {
899
+ const seg = join(workDir, `s${i}.mp4`);
900
+ execFn("ffmpeg", [
901
+ "-y",
902
+ "-i",
903
+ clips[i],
904
+ "-an",
905
+ "-vf",
906
+ "scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,setsar=1,fps=24",
907
+ "-c:v",
908
+ "libx264",
909
+ "-pix_fmt",
910
+ "yuv420p",
911
+ seg
912
+ ]);
913
+ segs.push(seg);
914
+ }
915
+ const endCard = join(workDir, "end.mp4");
916
+ execFn("ffmpeg", [
917
+ "-y",
918
+ "-i",
919
+ lastKfPath,
920
+ "-vf",
921
+ `zoompan=z='min(zoom+0.0008,1.06)':d=${CLIP_SECONDS * 24}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=1280x720:fps=24,setsar=1`,
922
+ "-an",
923
+ "-c:v",
924
+ "libx264",
925
+ "-pix_fmt",
926
+ "yuv420p",
927
+ endCard
928
+ ]);
929
+ segs.push(endCard);
930
+ const listPath = join(workDir, "list.txt");
931
+ writeFileSync(listPath, segs.map((s) => `file '${s.replace(/'/g, `'\\''`)}'`).join("\n"));
932
+ const baseRaw = join(workDir, "base_raw.mp4");
933
+ execFn("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-an", baseRaw]);
934
+ log(`cinematic: base cut assembled (${segs.length} segments)`);
935
+ let base = join(workDir, "base.mp4");
936
+ try {
937
+ log("cinematic: face-swap identity lock...");
938
+ const swapped = await poll(
939
+ await post(MODEL_FACE_SWAP, { video: await upload(baseRaw), face_image: faceUrl, target_gender: "female" }),
940
+ 140
941
+ );
942
+ await download(swapped, base);
943
+ } catch (err) {
944
+ log(`cinematic: face-swap failed (${err instanceof Error ? err.message : String(err)}) \u2014 using the raw cut`);
945
+ copyFileSync(baseRaw, base);
946
+ }
947
+ const graded = join(workDir, "base_g.mp4");
948
+ execFn("ffmpeg", [
949
+ "-y",
950
+ "-i",
951
+ base,
952
+ "-vf",
953
+ "eq=contrast=1.06:saturation=1.08:brightness=0.01,colorbalance=rs=0.05:gs=0.01:bs=-0.04:rm=0.03:bm=-0.02,setsar=1",
954
+ "-an",
955
+ "-c:v",
956
+ "libx264",
957
+ "-pix_fmt",
958
+ "yuv420p",
959
+ graded
960
+ ]);
961
+ log(`cinematic: ${beats.length} VO lines (parallel)...`);
962
+ const voPaths = await Promise.all(
963
+ beats.map(async (b, i) => {
964
+ const raw = join(workDir, `raw${i}.mp3`);
965
+ await download(await tts(b.vo), raw);
966
+ const processed = join(workDir, `r${i}.mp3`);
967
+ execFn("ffmpeg", [
968
+ "-y",
969
+ "-i",
970
+ raw,
971
+ "-af",
972
+ "highpass=f=80,acompressor=threshold=-18dB:ratio=2.5,volume=2dB",
973
+ processed
974
+ ]);
975
+ log(`cinematic: line ${i} (${voiceId})`);
976
+ return processed;
977
+ })
978
+ );
979
+ const dur = beats.length * CLIP_SECONDS;
980
+ const inputs = ["-i", graded];
981
+ for (const p of voPaths) inputs.push("-i", p);
982
+ inputs.push("-f", "lavfi", "-i", `anoisesrc=color=pink:d=${dur}:a=0.025`);
983
+ const hissIdx = 1 + voPaths.length;
984
+ let fc = "";
985
+ voPaths.forEach((_, i) => {
986
+ const d = i * CLIP_SECONDS * 1e3;
987
+ fc += `[${i + 1}:a]adelay=${d}|${d}[v${i}];`;
988
+ });
989
+ fc += `[${hissIdx}:a]highpass=f=300,lowpass=f=3000,volume=0.5[hiss];`;
990
+ fc += voPaths.map((_, i) => `[v${i}]`).join("") + `[hiss]amix=inputs=${voPaths.length + 1}:duration=longest:dropout_transition=0,volume=1.3[a]`;
991
+ execFn("ffmpeg", [
992
+ "-y",
993
+ ...inputs,
994
+ "-filter_complex",
995
+ fc,
996
+ "-map",
997
+ "0:v",
998
+ "-map",
999
+ "[a]",
1000
+ "-c:v",
1001
+ "copy",
1002
+ "-c:a",
1003
+ "aac",
1004
+ "-shortest",
1005
+ opts.out
1006
+ ]);
1007
+ log(`cinematic: DONE -> ${opts.out}`);
1008
+ return { path: opts.out, beats };
1009
+ }
1010
+
1011
+ // src/recipes/shots.ts
1012
+ var SHOT_ROLES = [
1013
+ "hook",
1014
+ "establish",
1015
+ "reveal",
1016
+ "feature",
1017
+ "transition",
1018
+ "hero",
1019
+ "text-card",
1020
+ "close",
1021
+ "action",
1022
+ "emotion"
1023
+ ];
1024
+ var ENERGY_LEVELS = ["low", "med", "high"];
1025
+ var shotRecipes = [
1026
+ // ── hook ──────────────────────────────────────────────────────────────
1027
+ {
1028
+ id: "brand-ink-open",
1029
+ role: "hook",
1030
+ name: "Brand Ink Open",
1031
+ purpose: "Stamp the brand wordmark into paper grain before any product shot \u2014 the audience memorizes the name in one quiet held beat.",
1032
+ energy: "low",
1033
+ suggestedSeconds: 3,
1034
+ promptHints: "Wordmark slowly imprints into textured paper grain with a soft letterpress emboss, settles, then holds dead-still for a full breath before anything moves.",
1035
+ pitfall: "The held beat belongs to the wordmark settle frame, not a content card; keep opening energy low so it does not crush the later build."
1036
+ },
1037
+ {
1038
+ id: "magician-card-flourish",
1039
+ role: "hook",
1040
+ name: "Magician Card Flourish",
1041
+ purpose: 'A single hero card is conjured from a flash of light \u2014 the ritual of "making it appear" is the hook.',
1042
+ energy: "high",
1043
+ suggestedSeconds: 4,
1044
+ promptHints: "A short blue star-flash, then the card launches from a tiny point at frame center, spins along a curving arc toward camera, and hard-locks dead-still front-facing at near-full-frame.",
1045
+ pitfall: "The flash must read as light (0.3s), the spin must land exactly front-facing with no settling tail, and the card freezes on arrival \u2014 cheap color-block light effects were repeatedly rejected."
1046
+ },
1047
+ {
1048
+ id: "dataviz-landscape-open",
1049
+ role: "hook",
1050
+ name: "Dataviz Landscape Open",
1051
+ purpose: "A brand-level abstract open: the data world behind the product shot as a dark landscape of glowing streams converging into one trunk.",
1052
+ energy: "low",
1053
+ suggestedSeconds: 6,
1054
+ promptHints: "Slow steady low-altitude camera flight over a dark field; many thin glowing streams flow in from the deep background and merge into a single bright trunk, scattered with small readable ID labels across three depth layers.",
1055
+ pitfall: 'Use at most once per film, and do not pair with glow-flyline in an adjacent segment \u2014 two "dark glowing lines" reads as the same trick.'
1056
+ },
1057
+ {
1058
+ id: "trailer-grammar-moves",
1059
+ role: "hook",
1060
+ name: "Trailer Grammar",
1061
+ purpose: "The structural moments of a trailer \u2014 the open front-loads the three punchiest shots in 0.9s, then a black beat, then the real start.",
1062
+ energy: "high",
1063
+ suggestedSeconds: 5,
1064
+ promptHints: "Three fastest, most striking shots cut back-to-back in under a second, then hard-cut to pure black and hold one silent beat before the title opens.",
1065
+ pitfall: "The black beat must be pure black and empty \u2014 it is a breath; anything in it breaks the trailer hook."
1066
+ },
1067
+ {
1068
+ id: "input-trigger-moves",
1069
+ role: "hook",
1070
+ name: "Input Trigger",
1071
+ purpose: "First-person grammar \u2014 the viewer is using the product, not watching it. The cursor is the hand; the trigger fires the narrative.",
1072
+ energy: "med",
1073
+ suggestedSeconds: 5,
1074
+ promptHints: "An enlarged cursor with personality slides in and clicks a target; the camera pushes toward the click point then gently eases back \u2014 slow, anchored on the cursor, it comes back.",
1075
+ pitfall: "Cap at two input-triggers per film \u2014 each should be a section-level switch; three rapid keypresses reads as a bug."
1076
+ },
1077
+ // ── establish ─────────────────────────────────────────────────────────
1078
+ {
1079
+ id: "crane-rise-reveal",
1080
+ role: "establish",
1081
+ name: "Crane Rise Reveal",
1082
+ purpose: "Establish from detail to whole: start glued to one real data row, then a crane arm rises and pulls back so rows flow in until the full dashboard fills frame.",
1083
+ energy: "high",
1084
+ suggestedSeconds: 5,
1085
+ promptHints: "Camera locked on a tight close-up of a single data row, then rises vertically along Y and pulls back on a decelerating ease, rows streaming in from below as the full dashboard fills the frame and settles to a true still.",
1086
+ pitfall: "The starting detail is stared at 3.2x zoom \u2014 rasterize it hires first or text blurs; pick rise XOR dive per film (do not pair with a drone-dive)."
1087
+ },
1088
+ {
1089
+ id: "overhead-camera-moves",
1090
+ role: "establish",
1091
+ name: "Overhead Camera",
1092
+ purpose: 'The tilt angle itself tells the story: the page lies flat, the camera "looks up" to right it, content rows flowing into view like unrolling a blueprint.',
1093
+ energy: "med",
1094
+ suggestedSeconds: 5,
1095
+ promptHints: "Page lies flat at a steep tilt showing only a thin perspective band of its top edge; the camera tilts up toward level, rows of content flowing into view one by one until the full page reads flat.",
1096
+ pitfall: 'Pick this OR crane-rise-reveal for an establishing open \u2014 two "reveal opens" in one film reads as the same trick.'
1097
+ },
1098
+ {
1099
+ id: "icon-field-colorize",
1100
+ role: "establish",
1101
+ name: "Icon Field Colorize",
1102
+ purpose: 'Lay out the full capability surface as a field of grey icons, then wash it with brand-color waves \u2014 "look how much this does", lit up at once.',
1103
+ energy: "med",
1104
+ suggestedSeconds: 4,
1105
+ promptHints: "Over a hundred small grey icons stagger into frame and fill it as background texture, hold one beat, then brand color sweeps across as fast descending horizontal waves (blue first, then orange/green/red in lower bands).",
1106
+ pitfall: "The color pass must NOT be a same-frame hard flip \u2014 it is multiple color waves sweeping down over about half a second; a hard flip reads as a swapped image."
1107
+ },
1108
+ {
1109
+ id: "morph-from-primitive",
1110
+ role: "establish",
1111
+ name: "Morph From Primitive",
1112
+ purpose: "The subject grows in place rather than flying in \u2014 a stroked circle takes a breath, then its outline continuously morphs into a rounded card as content fades in.",
1113
+ energy: "med",
1114
+ suggestedSeconds: 5,
1115
+ promptHints: "A simple stroked circle at center breathes (scale 1 to 1.12 to 1) in anticipation, then its outline smoothly morphs into a rounded-rect card while the content fades in.",
1116
+ pitfall: "The breath is the anticipation beat \u2014 without it the morph reads as a pop; it is the reverse direction from UI-to-primitive."
1117
+ },
1118
+ {
1119
+ id: "skeleton-reveal",
1120
+ role: "establish",
1121
+ name: "Skeleton Reveal",
1122
+ purpose: 'Stage the product UI arrival as three fidelity jumps: hand sketch to grey skeleton bars to real content \u2014 the "becoming real" arc.',
1123
+ energy: "med",
1124
+ suggestedSeconds: 6,
1125
+ promptHints: "UI arrives in three jumps: a quick hand-drawn sketch, a fast swap to grey skeleton bars rolling in, then a slow push as the skeleton resolves into the real filled product UI.",
1126
+ pitfall: 'Each "becomes real" swap is a beat \u2014 the two fidelity transitions are the whole point; rushing them kills the arc.'
1127
+ },
1128
+ // ── reveal ────────────────────────────────────────────────────────────
1129
+ {
1130
+ id: "neon-frame-orbit-drop",
1131
+ role: "reveal",
1132
+ name: "Neon Frame Orbit Drop",
1133
+ purpose: "A grand one-off reveal: a neon frame traces itself then orbits from the left side to the right while all components drop in from above simultaneously and snap into place.",
1134
+ energy: "high",
1135
+ suggestedSeconds: 4,
1136
+ promptHints: "A neon outline frame traces itself, then the camera arcs continuously from the page left side to its right while every component and text drops from above at once, each snapping dead with its shadow vanishing on contact \u2014 a single simultaneous whole-page arrival.",
1137
+ pitfall: '"Simultaneous" is the grammar \u2014 all components start, land, and shed shadows on the same frames; stagger it and it stops being a unified reveal (that is the runway-rain variant instead).'
1138
+ },
1139
+ {
1140
+ id: "spotlight-sweep-moves",
1141
+ role: "reveal",
1142
+ name: "Spotlight Sweep",
1143
+ purpose: "In the dark the audience only sees what the light shows \u2014 light is illumination and editing: where it sweeps, panels appear; when it leaves, they exit.",
1144
+ energy: "med",
1145
+ suggestedSeconds: 4,
1146
+ promptHints: "Near-black scene; a purple-edged spotlight glow creeps along UI panel edges and logos (lit, slightly blurred \u2014 light stroking the interface, not a stroke animation). Move and expand strictly at constant linear speed; a panel lights up where the light touches and dims as it passes.",
1147
+ pitfall: "Constant linear speed is the\u547D\u95E8 \u2014 easing makes the light feel intentional; only linear reads as a mechanical searchlight sweep."
1148
+ },
1149
+ {
1150
+ id: "stroke-segment-build",
1151
+ role: "reveal",
1152
+ name: "Stroke Segment Build",
1153
+ purpose: 'An Alien-titles reveal: the title "develops" \u2014 discrete stroke segments light up out of order, unreadable for most of the time, then the last segments land and the word suddenly reads. Recognition is the hook.',
1154
+ energy: "med",
1155
+ suggestedSeconds: 5,
1156
+ promptHints: "Title broken into discrete stroke segments; segments light up out of order over the first part (mysterious fragments), then the final key segments land and the full word suddenly reads \u2014 recognition is the beat.",
1157
+ pitfall: "The hook strength depends on how long the unreadable period holds and how late the key segment lands \u2014 do not blow the reveal early."
1158
+ },
1159
+ // ── feature ───────────────────────────────────────────────────────────
1160
+ {
1161
+ id: "deck-deal-flyin",
1162
+ role: "feature",
1163
+ name: "Deck Deal Fly-In",
1164
+ purpose: 'Show "there is a LOT here, pouring in" \u2014 cards deal themselves flying into their grid slots with urgency; build information density as the first impression.',
1165
+ energy: "high",
1166
+ suggestedSeconds: 4,
1167
+ promptHints: "A stack teases (what is this pile?), then dozens of cards deal out and fly into the page, each slamming into its slot with urgency \u2014 the audience feels things pouring in.",
1168
+ pitfall: 'Abstract "flood" multi-round non-convergence was rejected; find a physical metaphor (dealing) before writing motion \u2014 group motion needs a metaphor before code.'
1169
+ },
1170
+ {
1171
+ id: "row-embed",
1172
+ role: "feature",
1173
+ name: "Row Embed",
1174
+ purpose: 'Detail-page rows do not "appear", they "grow in" \u2014 each row drops from above and slots precisely into the layout; the accent seam at embed is the click of it snapping shut.',
1175
+ energy: "med",
1176
+ suggestedSeconds: 2,
1177
+ promptHints: "Structured rows drop from above one by one and slot precisely into the page layout; on each embed a thin accent-colored seam flashes at the join \u2014 the visual foley of it snapping shut.",
1178
+ pitfall: "Fly-in targets MUST be real layout slots that embed on landing \u2014 floating above the grid without landing reads as fake."
1179
+ },
1180
+ {
1181
+ id: "document-typewriter-reveal",
1182
+ role: "feature",
1183
+ name: "Document Typewriter Reveal",
1184
+ purpose: 'The whole real-typeset document writes itself out behind a caret, sidebar entries drop into the rail \u2014 the densest info beat; the whole persuasion is "this document is real".',
1185
+ energy: "med",
1186
+ suggestedSeconds: 4,
1187
+ promptHints: "A full page of real typeset document writes itself out left-to-right behind a single accent caret riding the reveal edge; sidebar history entries drop into the rail in sequence; camera holds on the page with both columns in frame.",
1188
+ pitfall: "Mock content must be publish-grade (native layout, full text, complete sidebar) \u2014 a slapdash screenshot-plus-slogan document forces a full reshoot; never put real customer or member names in mock data."
1189
+ },
1190
+ {
1191
+ id: "type-and-filter",
1192
+ role: "feature",
1193
+ name: "Type And Filter",
1194
+ purpose: 'Let the viewer "do it once" \u2014 see what is typed, how the page responds, where they clicked. The one lens that simulates real human operation, so it must move like a hand, not a script.',
1195
+ energy: "med",
1196
+ suggestedSeconds: 3,
1197
+ promptHints: "Simulated real-speed human operation: type a few characters, the list filters and re-settles, the cursor clicks into a target card that lands in its real grid slot.",
1198
+ pitfall: "First drafts are always too fast \u2014 type at about three frames per character; a filtered target card that floats above the grid instead of landing in a real slot reads as fake."
1199
+ },
1200
+ {
1201
+ id: "before-after-slider-scrub",
1202
+ role: "feature",
1203
+ name: "Before/After Slider Scrub",
1204
+ purpose: "The standard AI-product comparison: same frame, before (flat) overlaid with after (crisp); the divider whips to 70 percent with overshoot, holds, then crawls back to prove where the change is. Fast-sweep then slow-scan is the rhythm.",
1205
+ energy: "med",
1206
+ suggestedSeconds: 5,
1207
+ promptHints: "Two overlaid versions of the same frame (before: flat low-contrast; after: crisp) split by a vertical divider with a round handle; the handle whips hard from the left edge to about 70 percent with overshoot, holds one beat, then crawls back at a fifth of the speed so the difference is readable.",
1208
+ pitfall: "Both versions must share identical layout and camera \u2014 different layouts read as two pages and the comparison fails; use real screenshots for both."
1209
+ },
1210
+ {
1211
+ id: "integration-hub-map",
1212
+ role: "feature",
1213
+ name: "Integration Hub Map",
1214
+ purpose: '"One product connects everything" \u2014 the page flips over to become a hub, icons appear, then light tubes connect to them in two beats and breathe the flow.',
1215
+ energy: "high",
1216
+ suggestedSeconds: 5,
1217
+ promptHints: "Old page flips a full 180 degrees to become a new hub page (one fast continuous flip with a brief edge flash at the 90-degree profile, no stop); icons appear all at once, then glowing light tubes connect out to them across two beats and pulse softly to keep flowing.",
1218
+ pitfall: 'The flip must be complete and continuous with no 90-degree-profile pause \u2014 a stop there was rejected; "constant speed" here means no pauses, not literal linear.'
1219
+ },
1220
+ {
1221
+ id: "command-palette-summon",
1222
+ role: "feature",
1223
+ name: "Command Palette Summon",
1224
+ purpose: "The signature ritual: a soft chime, the whole UI dims aside, the command palette drops from upper-center, candidates stagger in; type two letters and the list narrows live.",
1225
+ energy: "med",
1226
+ suggestedSeconds: 5,
1227
+ promptHints: "A soft chime, the whole UI world dims to make way, a command palette drops from upper-center, candidate rows stagger in; two characters typed at human speed and the list narrows live \u2014 the squeeze comes from row-height collapse, not fade.",
1228
+ pitfall: "Type at real human speed (at least 12 frames between the two keys); the narrowing squeeze must come from row-height collapse, not opacity fade."
1229
+ },
1230
+ {
1231
+ id: "list-stack-press",
1232
+ role: "feature",
1233
+ name: "List Stack Press",
1234
+ purpose: '"Stacking has weight" \u2014 each new card lands and presses the whole pile down before it springs back; the counter ticks in lockstep to nail the quantity.',
1235
+ energy: "med",
1236
+ suggestedSeconds: 3,
1237
+ promptHints: "Feed or inbox cards stack one by one; each new card lands and presses the whole settled pile down, which springs back up; a counter ticks up in lockstep with each landing.",
1238
+ pitfall: "Stack and list info lenses must be shot head-on \u2014 even in a fully stylized film this lens was rolled back to front-view individually; validate camera per lens, do not apply globally."
1239
+ },
1240
+ // ── transition ────────────────────────────────────────────────────────
1241
+ {
1242
+ id: "wipe-transitions",
1243
+ role: "transition",
1244
+ name: "Wipe Transitions",
1245
+ purpose: 'The geometric-wipe family \u2014 both pages stay still while one geometric boundary sweeps across to hand off. Clock-wipe = "dashboard refreshed a screen of data"; blinds-slice = "flip the page".',
1246
+ energy: "med",
1247
+ suggestedSeconds: 5,
1248
+ promptHints: "Old and new pages both still; a geometric boundary sweeps across to reveal the new \u2014 either a clock-hand radar sweep from center (data-refresh feel) or vertical blinds flipping in a staggered wave (page-turn feel). The wipe edge carries bright highlight lines so it does not read as a slideshow transition.",
1249
+ pitfall: "The wipe boundary MUST carry highlight or glow lines \u2014 a line-less wipe reads as a PowerPoint transition; light lines need a dark edge-stroke on light areas and a bright white core on dark."
1250
+ },
1251
+ {
1252
+ id: "card-flip-reveal",
1253
+ role: "transition",
1254
+ name: "Card Flip Reveal",
1255
+ purpose: 'A semantic flip \u2014 a card front (feature UI) and back (the number it produced) are a cause-and-effect pair; the flip answers "so what?". Three staggered cards read as "results roll in".',
1256
+ energy: "high",
1257
+ suggestedSeconds: 5,
1258
+ promptHints: 'A row of cards each flips over; the front is the feature UI, the back is the result number it produced \u2014 the flip answers "so what?". Three cards flip in a stagger, reading as consecutive results.',
1259
+ pitfall: "Same technique root as a grid wave-flip but different semantics \u2014 that is batch entrance, this is cause-to-result; do not conflate."
1260
+ },
1261
+ {
1262
+ id: "bubble-swarm-takeover",
1263
+ role: "transition",
1264
+ name: "Bubble Swarm Takeover",
1265
+ purpose: '"Curtain" thinking \u2014 a swarm of brand objects drifts into foreground, swells to fill the frame hiding a hard cut, then drifts apart to reveal the new scene. Longer transitions mean more brand exposure, not slow.',
1266
+ energy: "high",
1267
+ suggestedSeconds: 4,
1268
+ promptHints: "A swarm of brand objects (bubbles, petals, icons) drifts in and swells to completely fill or occlude the frame (hard cut hidden at peak occlusion), then drifts apart to reveal a new scene.",
1269
+ pitfall: 'The peak MUST truly occlude the full frame or the cut shows; the hidden-cut variant is the one-to-three-frame "invisible scissors" \u2014 pick by how long you want the brand on screen.'
1270
+ },
1271
+ {
1272
+ id: "line-carry-transition",
1273
+ role: "transition",
1274
+ name: "Line Carry Transition",
1275
+ purpose: "The scene never changes \u2014 a single line carries you across. A progress bar fills, its tip extends off the card edge; the camera follows it panning into the new world, the line dog-legs to frame the next card.",
1276
+ energy: "med",
1277
+ suggestedSeconds: 5,
1278
+ promptHints: "A progress bar fills and its tip extends into a long line off the card edge; the camera follows the line panning sideways into a new scene, the line dog-legs to draw the next card frame, the frame closes and content fades in \u2014 the eye never leaves the line.",
1279
+ pitfall: "Graphic continuity IS the transition \u2014 do not cross-fade; the line belongs to the whole cut, not to one element."
1280
+ },
1281
+ {
1282
+ id: "color-block-step-wipe",
1283
+ role: "transition",
1284
+ name: "Color-Block Step Wipe",
1285
+ purpose: "Zero interpolation, zero easing \u2014 color blocks grow like retro pixel-game tiles, every step a hard cut, stillness between steps. The stutter itself is the rhythm.",
1286
+ energy: "high",
1287
+ suggestedSeconds: 3,
1288
+ promptHints: "Brand color blocks grow in hard discrete steps with no easing and no interpolation \u2014 each step is a hard cut, fully still between steps (retro pixel-game tile growth). Three to five steps total; a content card can ride each step.",
1289
+ pitfall: "Distinct from the smooth clock and blinds wipes \u2014 this is the stuttered no-tween pixel feel; do not smooth it out."
1290
+ },
1291
+ {
1292
+ id: "brand-frame-snap",
1293
+ role: "transition",
1294
+ name: "Brand Frame Snap",
1295
+ purpose: "Wrap real screen recordings in a thick brand-color picture frame \u2014 the frame appears first, then its color becomes the chapter code. On a chapter switch the whole frame hard-flips color in one frame.",
1296
+ energy: "med",
1297
+ suggestedSeconds: 4,
1298
+ promptHints: "A thick brand-color picture frame draws around a screen recording (frame first, content second \u2014 ritual); on a chapter switch the entire frame hard-flips to a new brand color in a single frame (no gradient), the window content and corner badges swapping in the same frame.",
1299
+ pitfall: "The flip MUST be same-frame across frame, window content, and badge text \u2014 even two frames apart and the gear-shift scatters into three small animations; keep the color code consistent film-wide."
1300
+ },
1301
+ // ── hero ──────────────────────────────────────────────────────────────
1302
+ {
1303
+ id: "spotlight-hero-card",
1304
+ role: "hero",
1305
+ name: "Spotlight Hero Card",
1306
+ purpose: "The single-protagonist open: a wandering spotlight locks onto one card, the camera pushes in on an angle, the card rises, hovers, is scanned by a contour beam, then reseats.",
1307
+ energy: "med",
1308
+ suggestedSeconds: 5,
1309
+ promptHints: "A roaming spotlight narrows and locks onto one card in a grid; camera pushes from a flat full-page to a tilted close-up (left-side angle), the card rises with overshoot, hovers with a gentle bob, a glowing rounded-rect contour beam traces it twice (fast-bright then slow-dim), then it reseats.",
1310
+ pitfall: "Open with a SINGLE card and one complete action arc \u2014 a multi-card dance cannot carry a first impression; slow the rise-to-reseat toward three seconds, first drafts are always too fast."
1311
+ },
1312
+ {
1313
+ id: "crash-zoom-punch",
1314
+ role: "hero",
1315
+ name: "Crash Zoom Punch",
1316
+ purpose: 'One beat slams from a wide to a target close-up \u2014 "look at THIS". Land with an overshoot bounce (spring) or a hard impact shake (weight), by emphasis.',
1317
+ energy: "high",
1318
+ suggestedSeconds: 3,
1319
+ promptHints: "In about six frames, crash-zoom from a wide framing to an extreme close-up on the target card filling 60 to 75 percent of frame; either overshoot and rebound 3 to 6 percent (spring) or land hard with a high-frequency impact shake that decays in six frames.",
1320
+ pitfall: "Do not mix the two landings (rebound plus shake reads as a glitch); cap at two crash-zooms per film, and the landing target must be high-res or text blurs."
1321
+ },
1322
+ {
1323
+ id: "runway-ground-skim",
1324
+ role: "hero",
1325
+ name: "Runway Ground Skim",
1326
+ purpose: "Treat the UI as a runway: low-angle, cards hang at staggered heights then rain down in an overlapping fast volley, landing dead with zero bounce, then the whole page stands up to vertical.",
1327
+ energy: "high",
1328
+ suggestedSeconds: 4,
1329
+ promptHints: "Strong low-angle perspective, page lying flat like a runway; UI cards suspended at staggered heights then drop in an overlapping rain (small stagger, gravity acceleration), each landing dead-still with no bounce; once all are down the whole page rotates up to vertical and the camera pulls to center.",
1330
+ pitfall: "The drop feel is crisp: zero bounce (the squish was rejected), a short drop frame count (longer was rejected as slow), and overlapping parallelism (waiting for one to land before starting the next was rejected)."
1331
+ },
1332
+ {
1333
+ id: "segmented-thumb-hero",
1334
+ role: "hero",
1335
+ name: "Segmented Thumb Hero",
1336
+ purpose: 'A mode switch told entirely by the segmented control thumb sliding eight frames \u2014 "we moved from A to B" with no page context; the control is the stage.',
1337
+ energy: "med",
1338
+ suggestedSeconds: 4,
1339
+ promptHints: "Extreme close-up of a segmented control; cursor slides in, presses with a ripple, the thumb slider glides eight frames to the other segment, and a new icon pops in \u2014 four beats: arrival, decision, response, reward.",
1340
+ pitfall: "All four beats are required \u2014 drop the cursor and the UI looks self-animated; drop the icon pop and the switch feels unrewarded."
1341
+ },
1342
+ {
1343
+ id: "space-camera-moves",
1344
+ role: "hero",
1345
+ name: "Space Camera",
1346
+ purpose: "Treat the flat page as a real 3D object the camera flies around \u2014 exploded-view (parts blow out along Z then reassemble) or drone-dive (god-view plunges to a hero close-up).",
1347
+ energy: "high",
1348
+ suggestedSeconds: 5,
1349
+ promptHints: "Either the whole page tilts in 3D and its components blow out along the Z-axis, hover, then reassemble in reverse with an impact shake; or a near-vertical overhead hover, then a hard dive that air-cushions to a stop on a hero-card close-up, motion-blurred through the dive.",
1350
+ pitfall: "Both variants need real layered or high-res screenshots \u2014 one flat page cannot explode, and the dive lands on a close-up that blurs without hires rasterization; cap at two of these big moves per film."
1351
+ },
1352
+ // ── text-card ─────────────────────────────────────────────────────────
1353
+ {
1354
+ id: "paper-title-card",
1355
+ role: "text-card",
1356
+ name: "Paper Title Card",
1357
+ purpose: 'Give the audience one sentence of breath between two product beats \u2014 say "what is next and why it matters". Letterpress emboss keeps the card in the same world as the paper-and-ink visuals.',
1358
+ energy: "low",
1359
+ suggestedSeconds: 2,
1360
+ promptHints: "A single short phrase embossed into textured paper with a letterpress press, holds for one breath between two scenes \u2014 concrete copy naming the feature and its payoff, never abstract metaphor.",
1361
+ pitfall: "Copy must be concrete (feature name plus specific payoff) \u2014 abstract metaphor words get rewritten; place a guiding card before an important feature, it is a chapter signpost not decoration."
1362
+ },
1363
+ {
1364
+ id: "cel-flash-stomp",
1365
+ role: "text-card",
1366
+ name: "Cel Flash Stomp",
1367
+ purpose: "Stomp-typography alone is just weight; background color-flash alone is just flash. Welded: the word slam-frame is the flash ignition \u2014 subject still, world quaking in peripheral vision. The opposite of screenshake.",
1368
+ energy: "high",
1369
+ suggestedSeconds: 5,
1370
+ promptHints: "Each word slams down dead-still onto frame; on each slam-frame the solid background color hard-flashes to a new hue and back. Subject rock-still, only the world flashes \u2014 peripheral-vision impact. Three words, three beats.",
1371
+ pitfall: "Sound-dependent \u2014 each word-slam needs a kick and the flash must align to the beat; without the kick it is just a janky strobe."
1372
+ },
1373
+ {
1374
+ id: "gradient-word-sweep",
1375
+ role: "text-card",
1376
+ name: "Gradient Word Sweep",
1377
+ purpose: '"Energize" one keyword in an otherwise-neutral line \u2014 a gradient color sweep zips across the chars like an injection; the wavefront is brightest, then it settles to a steady glow with linking sparks.',
1378
+ energy: "high",
1379
+ suggestedSeconds: 3,
1380
+ promptHints: "One keyword in an otherwise-neutral line gets electrified: a bright gradient color sweep zips across the characters left-to-right in 15 to 20 frames (the wavefront brightest, trailing off), then settles to a steady glow with thin linking sparks between chars breathing.",
1381
+ pitfall: "Fill-process must NOT follow a light dot; sparks appear only after fill; glow stays restrained. A slow sweep reads as a progress bar \u2014 keep it 15 to 20 frames."
1382
+ },
1383
+ {
1384
+ id: "letterspace-materialize",
1385
+ role: "text-card",
1386
+ name: "Letterspace Materialize",
1387
+ purpose: 'The wordmark does not fade or type \u2014 it "crystallizes": all letters strokes begin growing simultaneously and converge to the word in one synchronized breath (wide-tracked caps).',
1388
+ energy: "low",
1389
+ suggestedSeconds: 4,
1390
+ promptHints: "All letters of a wide-tracked uppercase wordmark begin drawing their strokes at once and converge to the complete word in a single synchronized breath \u2014 like an invisible hand writing all letters simultaneously, every letter starting and finishing on the same frames.",
1391
+ pitfall: "Strokes must be continuous (no mask-halves \u2014 a half-then-rest reads as fake) AND synchronized (staggered letters read as typewriter semantics, not whole-word ritual)."
1392
+ },
1393
+ {
1394
+ id: "split-flap-title",
1395
+ role: "text-card",
1396
+ name: "Split-Flap Title",
1397
+ purpose: 'The wordmark as a station-board "broadcast" with mechanical announcing cadence \u2014 for countdowns, ship-dates, metric reveals, or retro-mechanical texture.',
1398
+ energy: "med",
1399
+ suggestedSeconds: 5,
1400
+ promptHints: "A title rendered as a split-flap station departure board: a stretch of scrambled flapping, then characters cascade-flip left-to-right to settle on the real title, holding dead-still. Mechanical announcing cadence.",
1401
+ pitfall: "Sound-dependent \u2014 the mechanical flap-click on each settle is half the effect; without it the cascade is lifeless."
1402
+ },
1403
+ {
1404
+ id: "type-rhythm-sync",
1405
+ role: "text-card",
1406
+ name: "Type Rhythm Sync",
1407
+ purpose: "Bind the title hard to the audio track \u2014 beat-bound (drum hits) or speech-bound (narrator word-by-word). The title or slogan synced tight to the music bed.",
1408
+ energy: "high",
1409
+ suggestedSeconds: 5,
1410
+ promptHints: "Title or slogan bound tight to the audio: variant A \u2014 each syllable or weight-pulse hits on a drum beat in a short decay window (dance feel); variant B \u2014 each word lights up following the narrator cadence (follow-along feel).",
1411
+ pitfall: "Strong sound dependency \u2014 the pulse must really land on the beat; unsynced pulses over a silent track read as broken."
1412
+ },
1413
+ // ── close ─────────────────────────────────────────────────────────────
1414
+ {
1415
+ id: "outro-group-photo-launch",
1416
+ role: "close",
1417
+ name: "Outro Group Photo Launch",
1418
+ purpose: "Recall one representative element from each feature shown for a group photo, then the wordmark enters as the climax \u2014 launch-event scale, energy pushed to the film peak.",
1419
+ energy: "high",
1420
+ suggestedSeconds: 5,
1421
+ promptHints: "Representative elements from every feature shown fly in from the four edges to gather around the center for a group photo, then the brand wordmark drops in as the climax \u2014 launch-event scale, crane plus stage light plus particles, the film highest energy.",
1422
+ pitfall: "First drafts are almost always too conservative \u2014 start at product-launch-keynote scale with crane, stage-light, particles; structure before effects (the four-way gather is the skeleton, atmosphere comes after)."
1423
+ },
1424
+ {
1425
+ id: "ui-strip-away-outro",
1426
+ role: "close",
1427
+ name: "UI Strip-Away Outro",
1428
+ purpose: 'The only "subtraction" outro \u2014 one click detonates all the UI to exit, leaving black with just the semantic focus (the clicked button). "Launch equals complexity returns to zero".',
1429
+ energy: "med",
1430
+ suggestedSeconds: 4,
1431
+ promptHints: "A single click detonates the entire UI to evaporate \u2014 evaporating in order from the outer edges to the center, one layer every few frames, each layer flying outward off-frame; the black field leaves only the one clicked button.",
1432
+ pitfall: "Evaporation must be ordered (outer-to-center, staggered, each layer with directional motion outward) \u2014 random or same-frame-all-vanish reads as a power-outage glitch."
1433
+ },
1434
+ {
1435
+ id: "ui-to-brand-morph",
1436
+ role: "close",
1437
+ name: "UI To Brand Morph",
1438
+ purpose: 'The brand finale fourth path \u2014 a product UI element morphs itself into the brand logo ("the UI you use every day IS this brand").',
1439
+ energy: "high",
1440
+ suggestedSeconds: 4,
1441
+ promptHints: 'A product UI element morphs itself into the brand mark \u2014 an icon flips once and resolves into the logo, one continuous deformation telling "the daily-use UI is the brand" in a single beat.',
1442
+ pitfall: "It is a transformation (the reverse direction from morph-from-primitive, and distinct from a gather-without-transform group photo) \u2014 keep it one clean morph, do not pile effects."
1443
+ },
1444
+ {
1445
+ id: "edit-hook-moves",
1446
+ role: "close",
1447
+ name: "Edit Hook (Button Ending)",
1448
+ purpose: "Rhetoric on the timeline itself \u2014 the ending that takes it back: fade to black, logo holds (audience thinks it is over), a sudden easter-egg hard-cut, cut back to logo. A trailer button-ending.",
1449
+ energy: "med",
1450
+ suggestedSeconds: 5,
1451
+ promptHints: 'Fade to black, the logo fades in and holds (audience assumes it is over), then a sudden short hard-cut to a UI close-up easter-egg, then hard-cut back to the logo to truly close \u2014 playing with the audience "it is finished" expectation.',
1452
+ pitfall: "At most one per film and only at the very end \u2014 a button-ending mid-film reads as a glitch."
1453
+ },
1454
+ {
1455
+ id: "text-column-converge",
1456
+ role: "close",
1457
+ name: "Text Column Converge",
1458
+ purpose: "Two words sit left and right like a table-of-contents standoff; only on the last word do they converge into the product name \u2014 one convergence retcons the whole rotation as suspense setup. Recap or version-reveal energy.",
1459
+ energy: "med",
1460
+ suggestedSeconds: 6,
1461
+ promptHints: "Words alternate in left and right columns (perfectly still, equal margins), rotating through a feature list; only on the final word do the two halves converge once into the product name \u2014 one single convergence, no gradual shrink.",
1462
+ pitfall: "The convergence must happen exactly once and only at the end; any gradual shrink spoils the reveal and dilutes it to a progress bar."
1463
+ },
1464
+ // ── action ────────────────────────────────────────────────────────────
1465
+ {
1466
+ id: "beat-cut-moves",
1467
+ role: "action",
1468
+ name: "Beat Cut",
1469
+ purpose: "In highlight or sprint segments, make the CUT itself the drumbeat \u2014 trailer-style accelerating approach (rapid cuts into a held freeze) or awards-ceremony consecutive-flash ritual.",
1470
+ energy: "high",
1471
+ suggestedSeconds: 4,
1472
+ promptHints: "The cut itself is the beat: variant A \u2014 trailer-style accelerating approach, several ever-faster cuts building to a dead-still hold; variant B \u2014 awards-ceremony, live footage punctuated by hard flashes then a long held freeze.",
1473
+ pitfall: "Density-type high energy \u2014 sub-second beats, several channels jumping together; without an audio bed of kicks it reads as chaotic, not rhythmic."
1474
+ },
1475
+ {
1476
+ id: "speed-ramp-freeze",
1477
+ role: "action",
1478
+ name: "Speed Ramp Freeze",
1479
+ purpose: "Uniform flow reads as a slideshow. Two time-remap moves on one motion: speed-ramp (fast to gaze to fast, blur on the fast parts only) and freeze-annotate (flow to freeze, marker-pen circle the target to unfreeze).",
1480
+ energy: "high",
1481
+ suggestedSeconds: 5,
1482
+ promptHints: "On one continuous motion: variant speed-ramp \u2014 fast, then a slow gaze window, then fast again, with motion blur only on the fast segments (fast-blurred and slow-sharp contrast is half the effect); variant freeze \u2014 flow hard-freezes, a marker-pen circle strokes around the target, then it unfreezes and accelerates to catch up.",
1483
+ pitfall: 'The slow or freeze window needs enough frames or the gaze does not land; the fast-to-slow slope contrast needs about tenfold or "slowed down" is not readable.'
1484
+ },
1485
+ {
1486
+ id: "montage-rhythm-moves",
1487
+ role: "action",
1488
+ name: "Montage Rhythm",
1489
+ purpose: "Paragraph-level rhythm design \u2014 charge-burst (blackout hold then explode), flow-sketch (three ultra-close mechanical clicks then whip to the result), or chain-open (a momentum-handoff opening chain).",
1490
+ energy: "high",
1491
+ suggestedSeconds: 5,
1492
+ promptHints: "Paragraph-level rhythm shapes: A \u2014 pure black silent hold for one beat then explode open (EDM blackout); B \u2014 three ultra-close mechanical detail shots then whip-pan to the lit result; C \u2014 a chain of momentum handoffs opening the film.",
1493
+ pitfall: "Variant A is at most one per film and MUST be sound-designed (the blackout frame cuts audio to dead silence, the explode frame is the audio boom) \u2014 unsynced it reads as broken."
1494
+ },
1495
+ {
1496
+ id: "slam-entrance-moves",
1497
+ role: "action",
1498
+ name: "Slam Entrance",
1499
+ purpose: 'The top of the entrance-vocabulary force scale \u2014 "slam". Directional (fisheye whip-in), weight (drop with ring-plus-dust-plus-shake), or conduction (the shockwave shoves neighbors aside).',
1500
+ energy: "high",
1501
+ suggestedSeconds: 4,
1502
+ promptHints: "Card slams in with weight: A \u2014 fisheye-exaggerated perspective whip-in along the lens snapping flat; B \u2014 card drops at oversized scale, a ring burst plus dust plus impact shake all igniting on the same frame; C \u2014 the shockwave visibly shoves neighbor cards aside and they rebound.",
1503
+ pitfall: "All three carry screenshake \u2014 do not stack shake-family cards in the same shot; one screenshake event per beat."
1504
+ },
1505
+ {
1506
+ id: "scroll-brake-moves",
1507
+ role: "action",
1508
+ name: "Scroll Brake",
1509
+ purpose: 'A whole year of updates scrolls past as a fast blur-band, then an exponential-deceleration hard-brake stops dead on today release, which lifts off highlighted while the rest dims. Density says "always shipping"; the stop says "today is the big one".',
1510
+ energy: "high",
1511
+ suggestedSeconds: 5,
1512
+ promptHints: "A long vertical list scrolls past fast (blurring into a color band), then an exponential-deceleration hard-brake stops dead on one entry \u2014 that entry lifts off the surface and highlights while the rest dims; variant B snaps four L-brackets onto the stopped entry on the exact brake frame.",
1513
+ pitfall: "The high-speed band does not need real readable text (it is a blur) \u2014 placeholder grey blocks are fine; reserve real text for the braked entry."
1514
+ },
1515
+ // ── emotion ───────────────────────────────────────────────────────────
1516
+ {
1517
+ id: "light-play-moves",
1518
+ role: "emotion",
1519
+ name: "Light Play",
1520
+ purpose: "Treat light as a fourth brush (sweep, wipe, bloom): a dark-scene title reveal by a light sweep, crown the hero card with light, or impact-flare the slam frame. Light does what motion cannot.",
1521
+ energy: "med",
1522
+ suggestedSeconds: 5,
1523
+ promptHints: 'Light as a fourth brushstroke: a soft light sweep reveals a title out of darkness; or a quiet bloom crowns the hero card with light (low energy, the "lit" moment is the peak); or a hard impact-flare blooms on a slam frame.',
1524
+ pitfall: "The crown variant is deliberately low-energy \u2014 the bloom is the only peak; over-animate it and it stops being a crown and becomes a trick."
1525
+ },
1526
+ {
1527
+ id: "tension-camera-moves",
1528
+ role: "emotion",
1529
+ name: "Tension Camera",
1530
+ purpose: 'Small moves, all the force in emotional semantics \u2014 freeze-and-orbit, tilt-righted, imperceptible slow push, or pull-back-dim. Each is a direct word for "what should the audience feel here".',
1531
+ energy: "med",
1532
+ suggestedSeconds: 5,
1533
+ promptHints: "Small moves, heavy emotion: A \u2014 freeze mid-motion and orbit the camera around to examine; B \u2014 a tilted uneasy angle rolls back to level on the beat; C \u2014 an imperceptibly slow push building pressure, hard-cut to release at the peak; D \u2014 pull back while surroundings extinguish layer by layer, leaving one suspended point.",
1534
+ pitfall: 'Variant C "first two seconds must be unnoticeable" is the design intent, not a flaw \u2014 judge it on full playback, not a frame pull.'
1535
+ },
1536
+ {
1537
+ id: "collab-cursor-moves",
1538
+ role: "emotion",
1539
+ name: "Collab Cursor",
1540
+ purpose: 'Elevate the named collab cursor to an actor \u2014 two named cursors choreograph a handoff story in pure dark space with zero UI, or several drift as ambient particles for "the team is here" warmth.',
1541
+ energy: "med",
1542
+ suggestedSeconds: 5,
1543
+ promptHints: "Named collab cursors (with identity chips) as actors: A \u2014 two named cursors move in dark empty space, their positions telling a handoff story with no UI at all; B \u2014 several cursors drift in and float as ambient particles for team presence, one stopping to type a cameo.",
1544
+ pitfall: "Chip color equals identity code, must be consistent film-wide (blue equals Designer stays blue); a mid-film color change reads as a different person."
1545
+ },
1546
+ {
1547
+ id: "voice-waveform-live",
1548
+ role: "emotion",
1549
+ name: "Voice Waveform Live",
1550
+ purpose: 'The only functional voiceprint \u2014 the "listening to you" live receipt. Speaking towers tall, pauses shrink to a dot-row, history scrolls left. The audience reads "it is really listening" from the rise and fall alone.',
1551
+ energy: "med",
1552
+ suggestedSeconds: 5,
1553
+ promptHints: 'A live functional voiceprint: while "speaking" the waveform towers tall and dynamic, on "pauses" it shrinks to a dot-row, history scrolling left \u2014 the audience reads "it is genuinely listening" from the rise and fall alone, no other content needed.',
1554
+ pitfall: "If the film has a real narrator or voice track, the waveform envelope MUST follow that voice \u2014 the speak and pause segments align to the audio speak and pause; a one-second offset reads as fake."
1555
+ }
1556
+ ];
1557
+
1558
+ // src/recipes/index.ts
1559
+ function pickRecipes(role, n) {
1560
+ if (!Number.isInteger(n) || n <= 0) return [];
1561
+ const out = [];
1562
+ for (const r of shotRecipes) {
1563
+ if (out.length >= n) break;
1564
+ if (r.role === role) out.push(r);
1565
+ }
1566
+ return out;
1567
+ }
1568
+ function recipesForArc(roles) {
1569
+ const out = [];
1570
+ const seen = /* @__PURE__ */ new Set();
1571
+ for (const role of roles) {
1572
+ const match = shotRecipes.find((r) => r.role === role);
1573
+ if (match !== void 0 && !seen.has(match.id)) {
1574
+ seen.add(match.id);
1575
+ out.push(match);
1576
+ }
1577
+ }
1578
+ return out;
1579
+ }
1580
+
1581
+ // src/index.ts
1582
+ function createHyperframesRunner() {
1583
+ return {
1584
+ capability: "video",
1585
+ available: () => Promise.resolve(true),
1586
+ generate: (req) => {
1587
+ const r = req;
1588
+ return Promise.resolve(renderHyperframes(r.scenes, { ratio: r.ratio, title: r.title }));
1589
+ }
1590
+ };
1591
+ }
1592
+ async function renderMovie(events, opts = {}) {
1593
+ const scenes = buildScenes(events, opts);
1594
+ const log = opts.log ?? ((msg) => process.stderr.write(`${msg}
1595
+ `));
1596
+ const sink = opts.notify ?? vibeCoreNotify;
1597
+ let out = opts.out;
1598
+ if ((opts.engine ?? "hyperframes") === "cinematic") {
1599
+ if (cinematicAvailable() && ffmpegAvailable()) {
1600
+ const cinOut = out ?? "./vibe-recap.mp4";
1601
+ const cinOpts = { out: cinOut, log };
1602
+ if (opts.apiKey !== void 0) cinOpts.apiKey = opts.apiKey;
1603
+ const cin = await renderCinematic(scenes, cinOpts);
1604
+ const result2 = { engine: "cinematic", path: cin.path };
1605
+ notifyRenderDone(sink, result2);
1606
+ return result2;
1607
+ }
1608
+ const why = !cinematicAvailable() ? "WAVESPEED_API_KEY is not set" : "ffmpeg is not on PATH";
1609
+ log(`vibemovie: cinematic engine unavailable (${why}) \u2014 falling back to hyperframes (offline, zero keys)`);
1610
+ if (out !== void 0) {
1611
+ const htmlOut = out.replace(/\.(mp4|mov|webm|mkv)$/i, ".html");
1612
+ if (htmlOut !== out) {
1613
+ log(`vibemovie: writing the hyperframes HTML to ${htmlOut}`);
1614
+ out = htmlOut;
1615
+ }
1616
+ }
1617
+ }
1618
+ const cascade = createCascade({
1619
+ consent: createConsentLedger(),
1620
+ pickLocal: (capability) => Promise.resolve(capability === "video" ? createHyperframesRunner() : null)
1621
+ });
1622
+ const resolved = await cascade.resolve({ capability: "video", allowEgress: false });
1623
+ const provider = resolved.provider;
1624
+ if (resolved.tier !== "local" || !("capability" in provider)) {
1625
+ throw new Error("vibemovie v0 renders via the local Hyperframes tier only");
1626
+ }
1627
+ const req = { scenes };
1628
+ if (opts.ratio !== void 0) req.ratio = opts.ratio;
1629
+ if (opts.title !== void 0) req.title = opts.title;
1630
+ const html = await provider.generate(req);
1631
+ if (out !== void 0) {
1632
+ await writeFile(out, html, "utf8");
1633
+ const result2 = { engine: "hyperframes", html, path: out };
1634
+ notifyRenderDone(sink, result2);
1635
+ return result2;
1636
+ }
1637
+ const result = { engine: "hyperframes", html };
1638
+ notifyRenderDone(sink, result);
1639
+ return result;
1640
+ }
1641
+ function notifyRenderDone(sink, result) {
1642
+ try {
1643
+ sink(
1644
+ makeEvent("render-done", "vibemovie", process.cwd(), {
1645
+ summary: `rendered ${result.engine} recap${result.path !== void 0 ? ` \u2192 ${result.path}` : ""}`,
1646
+ outputPath: result.path ?? null
1647
+ })
1648
+ );
1649
+ } catch {
1650
+ }
1651
+ }
1652
+
1653
+ export {
1654
+ RATIOS,
1655
+ TEMPLATES,
1656
+ buildScenes,
1657
+ renderHyperframes,
1658
+ ENGINES,
1659
+ deriveReferencePrompts,
1660
+ deriveBeats,
1661
+ cinematicAvailable,
1662
+ renderCinematic,
1663
+ SHOT_ROLES,
1664
+ ENERGY_LEVELS,
1665
+ shotRecipes,
1666
+ pickRecipes,
1667
+ recipesForArc,
1668
+ renderMovie
1669
+ };