rikrok 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/assets/icon.svg +1 -0
- package/assets/readme/beat-flow.jpg +0 -0
- package/assets/readme/beat-headline.jpg +0 -0
- package/assets/readme/beat-next.jpg +0 -0
- package/assets/readme/beat-play.jpg +0 -0
- package/assets/readme/beat-status.jpg +0 -0
- package/assets/readme/beats.jpg +0 -0
- package/assets/wordmark.png +0 -0
- package/assets/wordmark.svg +1 -0
- package/bin/rikrok.mjs +63 -0
- package/launchd/com.rikrok.plist.tmpl +25 -0
- package/package.json +70 -0
- package/remotion/Reel.tsx +513 -0
- package/remotion/Root.tsx +71 -0
- package/remotion/index.ts +4 -0
- package/remotion/public/silence.wav +0 -0
- package/remotion/theme.ts +52 -0
- package/scripts/gen-icon.mjs +117 -0
- package/scripts/ui-check.mjs +76 -0
- package/server/feed.mjs +153 -0
- package/server/public/app.js +342 -0
- package/server/public/icon-180.png +0 -0
- package/server/public/icon-512.png +0 -0
- package/server/public/icon.svg +1 -0
- package/server/public/index.html +112 -0
- package/server/public/manifest.webmanifest +14 -0
- package/server/public/sw.js +22 -0
- package/src/cli/backfill.mjs +13 -0
- package/src/cli/config.mjs +29 -0
- package/src/cli/demo.mjs +14 -0
- package/src/cli/doctor.mjs +152 -0
- package/src/cli/feed.mjs +7 -0
- package/src/cli/hook.mjs +77 -0
- package/src/cli/install.mjs +66 -0
- package/src/cli/recap.mjs +66 -0
- package/src/cli/setup.mjs +162 -0
- package/src/cli/voice.mjs +214 -0
- package/src/cli/watch.mjs +5 -0
- package/src/hooks/comment.mjs +21 -0
- package/src/lib/backfill.mjs +40 -0
- package/src/lib/config.mjs +89 -0
- package/src/lib/evidence.mjs +21 -0
- package/src/lib/gitinfo.mjs +40 -0
- package/src/lib/llm.mjs +258 -0
- package/src/lib/narrate.mjs +148 -0
- package/src/lib/palette.mjs +27 -0
- package/src/lib/paths.mjs +29 -0
- package/src/lib/pipeline.mjs +180 -0
- package/src/lib/render-job.mjs +76 -0
- package/src/lib/script-claude.mjs +48 -0
- package/src/lib/state.mjs +19 -0
- package/src/lib/stt.mjs +26 -0
- package/src/lib/watcher.mjs +102 -0
- package/src/sources/claude.mjs +168 -0
- package/src/sources/index.mjs +26 -0
- package/src/voices/clone.mjs +66 -0
- package/src/voices/fx.mjs +22 -0
- package/src/voices/index.mjs +46 -0
- package/src/voices/module.mjs +18 -0
- package/src/voices/none.mjs +23 -0
- package/src/voices/openai-speech.mjs +34 -0
- package/src/voices/say.mjs +32 -0
- package/test/fixtures/claude-session.jsonl +23 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
AbsoluteFill,
|
|
4
|
+
Audio,
|
|
5
|
+
Easing,
|
|
6
|
+
Sequence,
|
|
7
|
+
interpolate,
|
|
8
|
+
staticFile,
|
|
9
|
+
useCurrentFrame,
|
|
10
|
+
} from "remotion";
|
|
11
|
+
import { theme, Beat, Flow, ReelProps } from "./theme";
|
|
12
|
+
|
|
13
|
+
const { colors, fonts, bezier, safe } = theme;
|
|
14
|
+
|
|
15
|
+
const easeEnter = Easing.bezier(...bezier.enter);
|
|
16
|
+
const easeExit = Easing.bezier(...bezier.exit);
|
|
17
|
+
|
|
18
|
+
// Shared in/out motion per beat: rise + fade in, settle, fade out (build up, tear down).
|
|
19
|
+
function useBeatMotion(durationFrames: number) {
|
|
20
|
+
const frame = useCurrentFrame();
|
|
21
|
+
const inF = Math.min(14, durationFrames / 3);
|
|
22
|
+
const outF = Math.min(10, durationFrames / 4);
|
|
23
|
+
const enter = interpolate(frame, [0, inF], [0, 1], {
|
|
24
|
+
extrapolateRight: "clamp",
|
|
25
|
+
easing: easeEnter,
|
|
26
|
+
});
|
|
27
|
+
const exit = interpolate(frame, [durationFrames - outF, durationFrames], [1, 0], {
|
|
28
|
+
extrapolateLeft: "clamp",
|
|
29
|
+
easing: easeExit,
|
|
30
|
+
});
|
|
31
|
+
return { opacity: Math.min(enter, exit), rise: (1 - enter) * 46 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ScoreBug: React.FC<{
|
|
35
|
+
projectName: string;
|
|
36
|
+
accent: string;
|
|
37
|
+
scoreBug: ReelProps["scoreBug"];
|
|
38
|
+
handle?: string;
|
|
39
|
+
}> = ({ projectName, accent, scoreBug, handle }) => {
|
|
40
|
+
const frame = useCurrentFrame();
|
|
41
|
+
const opacity = interpolate(frame, [8, 24], [0, 1], {
|
|
42
|
+
extrapolateLeft: "clamp",
|
|
43
|
+
extrapolateRight: "clamp",
|
|
44
|
+
easing: easeEnter,
|
|
45
|
+
});
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
style={{
|
|
49
|
+
position: "absolute",
|
|
50
|
+
top: safe.top,
|
|
51
|
+
left: safe.x,
|
|
52
|
+
right: safe.x,
|
|
53
|
+
display: "flex",
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
gap: 22,
|
|
56
|
+
opacity,
|
|
57
|
+
fontFamily: fonts.mono,
|
|
58
|
+
fontSize: 26,
|
|
59
|
+
letterSpacing: "0.12em",
|
|
60
|
+
textTransform: "uppercase",
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<div
|
|
64
|
+
style={{
|
|
65
|
+
width: 16,
|
|
66
|
+
height: 16,
|
|
67
|
+
borderRadius: 3,
|
|
68
|
+
background: accent,
|
|
69
|
+
flexShrink: 0,
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
<div style={{ color: accent, fontWeight: 600, whiteSpace: "nowrap" }}>{projectName}</div>
|
|
73
|
+
<div style={{ flex: 1, height: 1, background: colors.line }} />
|
|
74
|
+
<div style={{ color: colors.textDim, whiteSpace: "nowrap" }}>
|
|
75
|
+
{scoreBug.durationMin >= 100
|
|
76
|
+
? `${Math.floor(scoreBug.durationMin / 60)}h${scoreBug.durationMin % 60 ? ` ${scoreBug.durationMin % 60}m` : ""}`
|
|
77
|
+
: `${scoreBug.durationMin}min`}
|
|
78
|
+
</div>
|
|
79
|
+
<div style={{ color: colors.cyan, whiteSpace: "nowrap" }}>{scoreBug.done} done</div>
|
|
80
|
+
<div style={{ color: colors.red, whiteSpace: "nowrap" }}>{scoreBug.open} open</div>
|
|
81
|
+
{handle && <div style={{ position: "absolute", top: 44, right: 0, color: colors.textDim, fontSize: 22, letterSpacing: "0.08em", textTransform: "none" }}>{handle}</div>}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
const HeadlineCard: React.FC<{
|
|
88
|
+
headline: string;
|
|
89
|
+
accent: string;
|
|
90
|
+
durationFrames: number;
|
|
91
|
+
where?: string;
|
|
92
|
+
}> = ({ headline, accent, durationFrames, where }) => {
|
|
93
|
+
const { opacity, rise } = useBeatMotion(durationFrames);
|
|
94
|
+
const frame = useCurrentFrame();
|
|
95
|
+
const rule = interpolate(frame, [4, 20], [0, 1], {
|
|
96
|
+
extrapolateLeft: "clamp",
|
|
97
|
+
extrapolateRight: "clamp",
|
|
98
|
+
easing: easeEnter,
|
|
99
|
+
});
|
|
100
|
+
const [project, ...rest] = headline.split(":");
|
|
101
|
+
const outcome = rest.join(":").trim();
|
|
102
|
+
return (
|
|
103
|
+
<AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
|
|
104
|
+
<div style={{ transform: `translateY(${rise}px)` }}>
|
|
105
|
+
<div
|
|
106
|
+
style={{
|
|
107
|
+
fontFamily: fonts.mono,
|
|
108
|
+
fontSize: 30,
|
|
109
|
+
letterSpacing: "0.16em",
|
|
110
|
+
textTransform: "uppercase",
|
|
111
|
+
color: accent,
|
|
112
|
+
marginBottom: 34,
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
Session Recap
|
|
116
|
+
</div>
|
|
117
|
+
<div
|
|
118
|
+
style={{
|
|
119
|
+
fontFamily: fonts.display,
|
|
120
|
+
fontWeight: 700,
|
|
121
|
+
fontSize: outcome.length > 34 ? 88 : 104,
|
|
122
|
+
lineHeight: 1.04,
|
|
123
|
+
color: colors.text,
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
{outcome ? (
|
|
127
|
+
<>
|
|
128
|
+
<span style={{ color: accent }}>{project.trim()}.</span> {outcome}
|
|
129
|
+
</>
|
|
130
|
+
) : (
|
|
131
|
+
headline
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
<div
|
|
135
|
+
style={{
|
|
136
|
+
marginTop: 44,
|
|
137
|
+
height: 6,
|
|
138
|
+
width: `${rule * 38}%`,
|
|
139
|
+
background: accent,
|
|
140
|
+
borderRadius: 3,
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
{where && (
|
|
144
|
+
<div
|
|
145
|
+
style={{
|
|
146
|
+
marginTop: 30,
|
|
147
|
+
fontFamily: fonts.mono,
|
|
148
|
+
fontSize: 28,
|
|
149
|
+
letterSpacing: "0.06em",
|
|
150
|
+
color: colors.textDim,
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
{where}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</AbsoluteFill>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const visualLabel: Record<string, string> = {
|
|
162
|
+
files: "Files touched",
|
|
163
|
+
commits: "Git log",
|
|
164
|
+
commands: "Commands",
|
|
165
|
+
text: "From the session",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const PlayBeat: React.FC<{ beat: Beat; accent: string; index: number }> = ({
|
|
169
|
+
beat,
|
|
170
|
+
accent,
|
|
171
|
+
index,
|
|
172
|
+
}) => {
|
|
173
|
+
const { opacity, rise } = useBeatMotion(beat.durationFrames);
|
|
174
|
+
const frame = useCurrentFrame();
|
|
175
|
+
const lines = beat.visual?.lines ?? [];
|
|
176
|
+
return (
|
|
177
|
+
<AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
|
|
178
|
+
<div style={{ transform: `translateY(${rise}px)` }}>
|
|
179
|
+
<div
|
|
180
|
+
style={{
|
|
181
|
+
fontFamily: fonts.mono,
|
|
182
|
+
fontSize: 28,
|
|
183
|
+
letterSpacing: "0.16em",
|
|
184
|
+
color: accent,
|
|
185
|
+
marginBottom: 26,
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
PLAY {index}
|
|
189
|
+
</div>
|
|
190
|
+
<div
|
|
191
|
+
style={{
|
|
192
|
+
fontFamily: fonts.display,
|
|
193
|
+
fontWeight: 650,
|
|
194
|
+
fontSize: 74,
|
|
195
|
+
lineHeight: 1.08,
|
|
196
|
+
color: colors.text,
|
|
197
|
+
marginBottom: lines.length ? 54 : 0,
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
{beat.caption}
|
|
201
|
+
</div>
|
|
202
|
+
{lines.length > 0 && (
|
|
203
|
+
<div
|
|
204
|
+
style={{
|
|
205
|
+
borderLeft: `5px solid ${accent}`,
|
|
206
|
+
background: colors.panel,
|
|
207
|
+
borderRadius: "0 14px 14px 0",
|
|
208
|
+
padding: "34px 38px",
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
<div
|
|
212
|
+
style={{
|
|
213
|
+
fontFamily: fonts.mono,
|
|
214
|
+
fontSize: 24,
|
|
215
|
+
letterSpacing: "0.14em",
|
|
216
|
+
textTransform: "uppercase",
|
|
217
|
+
color: colors.textDim,
|
|
218
|
+
marginBottom: 20,
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
{visualLabel[beat.visual?.type ?? "text"] ?? "Evidence"}
|
|
222
|
+
</div>
|
|
223
|
+
{lines.map((l, i) => {
|
|
224
|
+
const lineIn = interpolate(frame, [10 + i * 5, 20 + i * 5], [0, 1], {
|
|
225
|
+
extrapolateLeft: "clamp",
|
|
226
|
+
extrapolateRight: "clamp",
|
|
227
|
+
easing: easeEnter,
|
|
228
|
+
});
|
|
229
|
+
return (
|
|
230
|
+
<div
|
|
231
|
+
key={i}
|
|
232
|
+
style={{
|
|
233
|
+
fontFamily: fonts.mono,
|
|
234
|
+
fontSize: 30,
|
|
235
|
+
lineHeight: 1.7,
|
|
236
|
+
color: colors.text,
|
|
237
|
+
opacity: lineIn,
|
|
238
|
+
transform: `translateX(${(1 - lineIn) * 20}px)`,
|
|
239
|
+
whiteSpace: "nowrap",
|
|
240
|
+
overflow: "hidden",
|
|
241
|
+
textOverflow: "ellipsis",
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
{l}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
})}
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</AbsoluteFill>
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
// Flow beat: what moves. Nodes laid out in the order things happen, edges drawn in one
|
|
257
|
+
// after another as the narration reaches them, a dot travelling along each edge, and the
|
|
258
|
+
// nodes this session changed lit in the accent.
|
|
259
|
+
const KIND_LABEL: Record<string, string> = { ui: "UI", api: "API", data: "DATA", service: "SERVICE", job: "JOB", external: "EXTERNAL" };
|
|
260
|
+
|
|
261
|
+
function layoutFlow(flow: Flow) {
|
|
262
|
+
// Order: follow edges from nodes that nothing points at; fall back to given order.
|
|
263
|
+
const incoming = new Map(flow.nodes.map((n) => [n.id, 0]));
|
|
264
|
+
for (const e of flow.edges) incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1);
|
|
265
|
+
const order: string[] = [];
|
|
266
|
+
const seen = new Set<string>();
|
|
267
|
+
const visit = (id: string) => {
|
|
268
|
+
if (seen.has(id)) return;
|
|
269
|
+
seen.add(id);
|
|
270
|
+
order.push(id);
|
|
271
|
+
for (const e of flow.edges) if (e.from === id) visit(e.to);
|
|
272
|
+
};
|
|
273
|
+
for (const n of flow.nodes) if ((incoming.get(n.id) ?? 0) === 0) visit(n.id);
|
|
274
|
+
for (const n of flow.nodes) visit(n.id);
|
|
275
|
+
// One column, top to bottom: the natural reading order on a 9:16 canvas.
|
|
276
|
+
const n = order.length;
|
|
277
|
+
const boxW = theme.canvas.width - safe.x * 2;
|
|
278
|
+
const boxH = n <= 4 ? 128 : 108;
|
|
279
|
+
const gap = n <= 4 ? 96 : 76;
|
|
280
|
+
const pos = new Map<string, { x: number; y: number }>();
|
|
281
|
+
order.forEach((id, i) => pos.set(id, { x: 0, y: i * (boxH + gap) }));
|
|
282
|
+
return { order, pos, boxW, boxH, totalH: n * boxH + (n - 1) * gap };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const FlowBeat: React.FC<{ beat: Beat; accent: string }> = ({ beat, accent }) => {
|
|
286
|
+
const { opacity, rise } = useBeatMotion(beat.durationFrames);
|
|
287
|
+
const frame = useCurrentFrame();
|
|
288
|
+
const flow = beat.flow ?? { nodes: [], edges: [], changed: [] };
|
|
289
|
+
const { pos, boxW, boxH, totalH } = layoutFlow(flow);
|
|
290
|
+
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
|
291
|
+
const changed = new Set(flow.changed);
|
|
292
|
+
// edges appear across the middle 70% of the beat, evenly spaced
|
|
293
|
+
const edgeCount = Math.max(1, flow.edges.length);
|
|
294
|
+
const span = beat.durationFrames * 0.7;
|
|
295
|
+
const perEdge = span / edgeCount;
|
|
296
|
+
const edgeProgress = (i: number) => interpolate(frame, [12 + i * perEdge, 12 + (i + 1) * perEdge], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: easeEnter });
|
|
297
|
+
const W = theme.canvas.width - safe.x * 2;
|
|
298
|
+
const H = totalH + 40;
|
|
299
|
+
const center = (id: string) => {
|
|
300
|
+
const p = pos.get(id) ?? { x: 0, y: 0 };
|
|
301
|
+
return { x: p.x + boxW / 2, y: p.y + boxH / 2 };
|
|
302
|
+
};
|
|
303
|
+
const edgePath = (a: { x: number; y: number }, b: { x: number; y: number }) => {
|
|
304
|
+
// vertical column: leave the bottom of one box, enter the top of the next; arrows that
|
|
305
|
+
// go back up run along the right edge instead
|
|
306
|
+
const down = b.y > a.y;
|
|
307
|
+
if (down) return { sx: a.x, sy: a.y + boxH / 2, ex: b.x, ey: b.y - boxH / 2 };
|
|
308
|
+
return { sx: a.x + boxW / 2 - 40, sy: a.y - boxH / 2, ex: b.x + boxW / 2 - 40, ey: b.y + boxH / 2 };
|
|
309
|
+
};
|
|
310
|
+
return (
|
|
311
|
+
<AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
|
|
312
|
+
<div style={{ transform: `translateY(${rise}px)` }}>
|
|
313
|
+
<div style={{ fontFamily: fonts.mono, fontSize: 28, letterSpacing: "0.16em", color: accent, marginBottom: 26 }}>HOW IT MOVES</div>
|
|
314
|
+
<div style={{ fontFamily: fonts.display, fontWeight: 650, fontSize: 60, lineHeight: 1.1, color: colors.text, marginBottom: 44 }}>{beat.caption}</div>
|
|
315
|
+
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ overflow: "visible" }}>
|
|
316
|
+
<defs>
|
|
317
|
+
<marker id="flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
|
|
318
|
+
<path d="M0 0L10 5L0 10z" fill={colors.textDim} />
|
|
319
|
+
</marker>
|
|
320
|
+
</defs>
|
|
321
|
+
{flow.edges.map((e, i) => {
|
|
322
|
+
const t = edgeProgress(i);
|
|
323
|
+
if (t <= 0) return null;
|
|
324
|
+
const { sx, sy, ex, ey } = edgePath(center(e.from), center(e.to));
|
|
325
|
+
const x = sx + (ex - sx) * t, y = sy + (ey - sy) * t;
|
|
326
|
+
const mx = (sx + ex) / 2, my = (sy + ey) / 2;
|
|
327
|
+
return (
|
|
328
|
+
<g key={i}>
|
|
329
|
+
<line x1={sx} y1={sy} x2={x} y2={y} stroke={colors.textDim} strokeWidth={5} strokeLinecap="round" markerEnd={t >= 0.98 ? "url(#flow-arrow)" : undefined} />
|
|
330
|
+
<circle cx={x} cy={y} r={11} fill={accent} />
|
|
331
|
+
{e.label && t >= 0.98 && (
|
|
332
|
+
<text x={mx + 28} y={my + 8} textAnchor="start" fontFamily={fonts.mono} fontSize={24} fill={colors.textDim} letterSpacing="0.1em">
|
|
333
|
+
{e.label}
|
|
334
|
+
</text>
|
|
335
|
+
)}
|
|
336
|
+
</g>
|
|
337
|
+
);
|
|
338
|
+
})}
|
|
339
|
+
{flow.nodes.map((n, i) => {
|
|
340
|
+
const p = pos.get(n.id) ?? { x: 0, y: 0 };
|
|
341
|
+
const lit = changed.has(n.id);
|
|
342
|
+
const nodeIn = interpolate(frame, [4 + i * 4, 16 + i * 4], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: easeEnter });
|
|
343
|
+
return (
|
|
344
|
+
<g key={n.id} transform={`translate(${p.x} ${p.y})`} opacity={nodeIn}>
|
|
345
|
+
<rect width={boxW} height={boxH} rx={18} fill={lit ? accent : colors.panel} stroke={lit ? accent : colors.line} strokeWidth={3} />
|
|
346
|
+
<text x={28} y={38} fontFamily={fonts.mono} fontSize={22} letterSpacing="0.14em" fill={lit ? "rgba(0,0,0,0.6)" : colors.textDim}>
|
|
347
|
+
{KIND_LABEL[n.kind] ?? "SERVICE"}
|
|
348
|
+
</text>
|
|
349
|
+
<text x={28} y={boxH - 34} fontFamily={fonts.display} fontWeight={650} fontSize={boxH > 110 ? 44 : 38} fill={lit ? "#000" : colors.text}>
|
|
350
|
+
{n.label}
|
|
351
|
+
</text>
|
|
352
|
+
</g>
|
|
353
|
+
);
|
|
354
|
+
})}
|
|
355
|
+
</svg>
|
|
356
|
+
</div>
|
|
357
|
+
</AbsoluteFill>
|
|
358
|
+
);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const StatusBeat: React.FC<{ beat: Beat; accent: string }> = ({ beat, accent }) => {
|
|
362
|
+
const { opacity, rise } = useBeatMotion(beat.durationFrames);
|
|
363
|
+
const col = (
|
|
364
|
+
label: string,
|
|
365
|
+
items: string[],
|
|
366
|
+
color: string,
|
|
367
|
+
mark: string,
|
|
368
|
+
) => (
|
|
369
|
+
<div style={{ flex: 1 }}>
|
|
370
|
+
<div
|
|
371
|
+
style={{
|
|
372
|
+
fontFamily: fonts.mono,
|
|
373
|
+
fontSize: 26,
|
|
374
|
+
letterSpacing: "0.16em",
|
|
375
|
+
textTransform: "uppercase",
|
|
376
|
+
color,
|
|
377
|
+
marginBottom: 24,
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
{label}
|
|
381
|
+
</div>
|
|
382
|
+
{(items.length ? items : ["—"]).map((it, i) => (
|
|
383
|
+
<div
|
|
384
|
+
key={i}
|
|
385
|
+
style={{
|
|
386
|
+
fontFamily: fonts.serif,
|
|
387
|
+
fontSize: 34,
|
|
388
|
+
lineHeight: 1.5,
|
|
389
|
+
color: colors.text,
|
|
390
|
+
marginBottom: 14,
|
|
391
|
+
display: "flex",
|
|
392
|
+
gap: 14,
|
|
393
|
+
}}
|
|
394
|
+
>
|
|
395
|
+
<span style={{ color, flexShrink: 0 }}>{mark}</span>
|
|
396
|
+
<span>{it}</span>
|
|
397
|
+
</div>
|
|
398
|
+
))}
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
return (
|
|
402
|
+
<AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
|
|
403
|
+
<div style={{ transform: `translateY(${rise}px)` }}>
|
|
404
|
+
<div
|
|
405
|
+
style={{
|
|
406
|
+
fontFamily: fonts.mono,
|
|
407
|
+
fontSize: 28,
|
|
408
|
+
letterSpacing: "0.16em",
|
|
409
|
+
color: accent,
|
|
410
|
+
marginBottom: 26,
|
|
411
|
+
}}
|
|
412
|
+
>
|
|
413
|
+
STATUS
|
|
414
|
+
</div>
|
|
415
|
+
<div
|
|
416
|
+
style={{
|
|
417
|
+
fontFamily: fonts.display,
|
|
418
|
+
fontWeight: 650,
|
|
419
|
+
fontSize: 66,
|
|
420
|
+
lineHeight: 1.1,
|
|
421
|
+
color: colors.text,
|
|
422
|
+
marginBottom: 56,
|
|
423
|
+
}}
|
|
424
|
+
>
|
|
425
|
+
{beat.caption}
|
|
426
|
+
</div>
|
|
427
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 44 }}>
|
|
428
|
+
{col("Shipped", beat.achieved ?? [], colors.cyan, "✓")}
|
|
429
|
+
{col("Open", beat.open ?? [], colors.red, "○")}
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
</AbsoluteFill>
|
|
433
|
+
);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const NextStepCard: React.FC<{ beat: Beat; accent: string }> = ({ beat, accent }) => {
|
|
437
|
+
const { opacity, rise } = useBeatMotion(beat.durationFrames);
|
|
438
|
+
return (
|
|
439
|
+
<AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
|
|
440
|
+
<div style={{ transform: `translateY(${rise}px)` }}>
|
|
441
|
+
<div
|
|
442
|
+
style={{
|
|
443
|
+
background: accent,
|
|
444
|
+
borderRadius: 22,
|
|
445
|
+
padding: "64px 56px",
|
|
446
|
+
boxShadow: "0 30px 80px rgba(0,0,0,0.45)",
|
|
447
|
+
}}
|
|
448
|
+
>
|
|
449
|
+
<div
|
|
450
|
+
style={{
|
|
451
|
+
fontFamily: fonts.mono,
|
|
452
|
+
fontSize: 28,
|
|
453
|
+
letterSpacing: "0.18em",
|
|
454
|
+
textTransform: "uppercase",
|
|
455
|
+
color: "rgba(0, 0, 0, 0.72)",
|
|
456
|
+
marginBottom: 28,
|
|
457
|
+
}}
|
|
458
|
+
>
|
|
459
|
+
Next step
|
|
460
|
+
</div>
|
|
461
|
+
<div
|
|
462
|
+
style={{
|
|
463
|
+
fontFamily: fonts.display,
|
|
464
|
+
fontWeight: 700,
|
|
465
|
+
fontSize: 68,
|
|
466
|
+
lineHeight: 1.12,
|
|
467
|
+
color: colors.bg,
|
|
468
|
+
}}
|
|
469
|
+
>
|
|
470
|
+
{beat.caption}
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
</AbsoluteFill>
|
|
475
|
+
);
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
export const Reel: React.FC<ReelProps> = (props) => {
|
|
479
|
+
let playIdx = 0;
|
|
480
|
+
return (
|
|
481
|
+
<AbsoluteFill style={{ background: colors.bg }}>
|
|
482
|
+
<Audio src={staticFile(props.audioFile)} />
|
|
483
|
+
{props.beats.map((beat, i) => {
|
|
484
|
+
let content: React.ReactNode = null;
|
|
485
|
+
if (beat.kind === "headline") {
|
|
486
|
+
content = (
|
|
487
|
+
<HeadlineCard
|
|
488
|
+
headline={props.headline}
|
|
489
|
+
accent={props.accent}
|
|
490
|
+
durationFrames={beat.durationFrames}
|
|
491
|
+
where={props.where}
|
|
492
|
+
/>
|
|
493
|
+
);
|
|
494
|
+
} else if (beat.kind === "play") {
|
|
495
|
+
playIdx++;
|
|
496
|
+
content = <PlayBeat beat={beat} accent={props.accent} index={playIdx} />;
|
|
497
|
+
} else if (beat.kind === "flow") {
|
|
498
|
+
content = <FlowBeat beat={beat} accent={props.accent} />;
|
|
499
|
+
} else if (beat.kind === "status") {
|
|
500
|
+
content = <StatusBeat beat={beat} accent={props.accent} />;
|
|
501
|
+
} else {
|
|
502
|
+
content = <NextStepCard beat={beat} accent={props.accent} />;
|
|
503
|
+
}
|
|
504
|
+
return (
|
|
505
|
+
<Sequence key={i} from={beat.startFrame} durationInFrames={beat.durationFrames}>
|
|
506
|
+
{content}
|
|
507
|
+
</Sequence>
|
|
508
|
+
);
|
|
509
|
+
})}
|
|
510
|
+
<ScoreBug projectName={props.projectName} accent={props.accent} scoreBug={props.scoreBug} handle={props.handle} />
|
|
511
|
+
</AbsoluteFill>
|
|
512
|
+
);
|
|
513
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Composition } from "remotion";
|
|
3
|
+
import "@fontsource-variable/bricolage-grotesque";
|
|
4
|
+
import "@fontsource/spectral/400.css";
|
|
5
|
+
import "@fontsource/spectral/600.css";
|
|
6
|
+
import "@fontsource/ibm-plex-mono/400.css";
|
|
7
|
+
import "@fontsource/ibm-plex-mono/600.css";
|
|
8
|
+
import { Reel } from "./Reel";
|
|
9
|
+
import { theme, ReelProps } from "./theme";
|
|
10
|
+
|
|
11
|
+
const defaultProps: ReelProps = {
|
|
12
|
+
projectName: "example-app",
|
|
13
|
+
accent: "#69C9D0",
|
|
14
|
+
headline: "example-app: signup form guarded and tested",
|
|
15
|
+
where: "~/example-app · main",
|
|
16
|
+
scoreBug: { durationMin: 42, done: 3, open: 1 },
|
|
17
|
+
beats: [
|
|
18
|
+
{ kind: "headline", caption: "", startFrame: 0, durationFrames: 90 },
|
|
19
|
+
{
|
|
20
|
+
kind: "play",
|
|
21
|
+
caption: "Double submit fixed",
|
|
22
|
+
startFrame: 90,
|
|
23
|
+
durationFrames: 120,
|
|
24
|
+
visual: { type: "files", lines: ["src/components/SignupForm.tsx", "src/hooks/useSubmitGuard.ts"] },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
kind: "flow",
|
|
28
|
+
caption: "Signup submit, guarded",
|
|
29
|
+
startFrame: 210,
|
|
30
|
+
durationFrames: 200,
|
|
31
|
+
flow: {
|
|
32
|
+
nodes: [
|
|
33
|
+
{ id: "btn", label: "Sign up button", kind: "ui" },
|
|
34
|
+
{ id: "guard", label: "submit guard", kind: "service" },
|
|
35
|
+
{ id: "api", label: "POST /signup", kind: "api" },
|
|
36
|
+
{ id: "db", label: "users table", kind: "data" },
|
|
37
|
+
],
|
|
38
|
+
edges: [
|
|
39
|
+
{ from: "btn", to: "guard", label: "tap" },
|
|
40
|
+
{ from: "guard", to: "api", label: "once" },
|
|
41
|
+
{ from: "api", to: "db", label: "writes" },
|
|
42
|
+
],
|
|
43
|
+
changed: ["guard"],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
kind: "status",
|
|
48
|
+
caption: "3 done, 1 open",
|
|
49
|
+
startFrame: 410,
|
|
50
|
+
durationFrames: 100,
|
|
51
|
+
achieved: ["Guard on signup form", "4 tests green", "1 commit on main"],
|
|
52
|
+
open: ["Checkout guard test"],
|
|
53
|
+
},
|
|
54
|
+
{ kind: "next", caption: "Deploy to staging and verify on a phone", startFrame: 510, durationFrames: 110 },
|
|
55
|
+
],
|
|
56
|
+
audioFile: "silence.wav",
|
|
57
|
+
totalFrames: 620,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const RemotionRoot: React.FC = () => (
|
|
61
|
+
<Composition
|
|
62
|
+
id="Reel"
|
|
63
|
+
component={Reel}
|
|
64
|
+
width={theme.canvas.width}
|
|
65
|
+
height={theme.canvas.height}
|
|
66
|
+
fps={theme.canvas.fps}
|
|
67
|
+
durationInFrames={620}
|
|
68
|
+
defaultProps={defaultProps}
|
|
69
|
+
calculateMetadata={({ props }) => ({ durationInFrames: props.totalFrames })}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
Binary file
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Rik Rok look: black canvas, white type, cyan for what shipped, red for what is
|
|
2
|
+
// still open, and a cyan/red offset ghost on the headline. Colour is semantic,
|
|
3
|
+
// one idea per beat, motion only through interpolate/spring with named curves.
|
|
4
|
+
export const theme = {
|
|
5
|
+
colors: {
|
|
6
|
+
bg: "#000000",
|
|
7
|
+
text: "#ffffff",
|
|
8
|
+
textDim: "#9a9a9a",
|
|
9
|
+
cyan: "#69C9D0", // done, the good state
|
|
10
|
+
red: "#EE1D52", // open, the costly state
|
|
11
|
+
line: "rgba(255, 255, 255, 0.18)",
|
|
12
|
+
panel: "rgba(255, 255, 255, 0.06)",
|
|
13
|
+
},
|
|
14
|
+
fonts: {
|
|
15
|
+
display: "'Bricolage Grotesque Variable', 'Helvetica Neue', sans-serif",
|
|
16
|
+
serif: "'Spectral', Georgia, serif",
|
|
17
|
+
mono: "'IBM Plex Mono', ui-monospace, monospace",
|
|
18
|
+
},
|
|
19
|
+
canvas: { width: 1080, height: 1920, fps: 30 },
|
|
20
|
+
bezier: {
|
|
21
|
+
enter: [0.16, 1, 0.3, 1] as const,
|
|
22
|
+
exit: [0.7, 0, 0.84, 0] as const,
|
|
23
|
+
},
|
|
24
|
+
safe: { top: 140, bottom: 220, x: 84 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type FlowNode = { id: string; label: string; kind: "ui" | "api" | "data" | "service" | "job" | "external" };
|
|
28
|
+
export type FlowEdge = { from: string; to: string; label: string };
|
|
29
|
+
export type Flow = { nodes: FlowNode[]; edges: FlowEdge[]; changed: string[] };
|
|
30
|
+
|
|
31
|
+
export type Beat = {
|
|
32
|
+
kind: "headline" | "play" | "flow" | "status" | "next";
|
|
33
|
+
caption: string;
|
|
34
|
+
startFrame: number;
|
|
35
|
+
durationFrames: number;
|
|
36
|
+
visual?: { type: string; lines: string[] };
|
|
37
|
+
achieved?: string[];
|
|
38
|
+
open?: string[];
|
|
39
|
+
flow?: Flow;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ReelProps = {
|
|
43
|
+
projectName: string;
|
|
44
|
+
accent: string;
|
|
45
|
+
headline: string;
|
|
46
|
+
where?: string; // "~/my-app · main": repo path + branch
|
|
47
|
+
handle?: string; // optional handle shown under the score bug
|
|
48
|
+
scoreBug: { durationMin: number; done: number; open: number };
|
|
49
|
+
beats: Beat[];
|
|
50
|
+
audioFile: string;
|
|
51
|
+
totalFrames: number;
|
|
52
|
+
};
|