ccwrap 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +181 -0
  5. package/dist/data/commentary.d.ts +13 -0
  6. package/dist/data/commentary.js +102 -0
  7. package/dist/data/parser.d.ts +2 -0
  8. package/dist/data/parser.js +358 -0
  9. package/dist/data/types.d.ts +109 -0
  10. package/dist/data/types.js +1 -0
  11. package/dist/render.d.ts +2 -0
  12. package/dist/render.js +60 -0
  13. package/dist/video/Composition.d.ts +8 -0
  14. package/dist/video/Composition.js +50 -0
  15. package/dist/video/Root.d.ts +2 -0
  16. package/dist/video/Root.js +81 -0
  17. package/dist/video/components/AnimatedNumber.d.ts +10 -0
  18. package/dist/video/components/AnimatedNumber.js +16 -0
  19. package/dist/video/components/FadeIn.d.ts +8 -0
  20. package/dist/video/components/FadeIn.js +18 -0
  21. package/dist/video/components/GlowOrb.d.ts +8 -0
  22. package/dist/video/components/GlowOrb.js +19 -0
  23. package/dist/video/components/ParticleField.d.ts +5 -0
  24. package/dist/video/components/ParticleField.js +36 -0
  25. package/dist/video/index.d.ts +1 -0
  26. package/dist/video/index.js +3 -0
  27. package/dist/video/slides/ArchetypeSlide.d.ts +7 -0
  28. package/dist/video/slides/ArchetypeSlide.js +34 -0
  29. package/dist/video/slides/BusiestDaySlide.d.ts +7 -0
  30. package/dist/video/slides/BusiestDaySlide.js +23 -0
  31. package/dist/video/slides/CostSlide.d.ts +7 -0
  32. package/dist/video/slides/CostSlide.js +12 -0
  33. package/dist/video/slides/IntroSlide.d.ts +5 -0
  34. package/dist/video/slides/IntroSlide.js +21 -0
  35. package/dist/video/slides/ModelSlide.d.ts +7 -0
  36. package/dist/video/slides/ModelSlide.js +45 -0
  37. package/dist/video/slides/PeakHoursSlide.d.ts +7 -0
  38. package/dist/video/slides/PeakHoursSlide.js +48 -0
  39. package/dist/video/slides/SessionSlide.d.ts +7 -0
  40. package/dist/video/slides/SessionSlide.js +22 -0
  41. package/dist/video/slides/SummarySlide.d.ts +7 -0
  42. package/dist/video/slides/SummarySlide.js +52 -0
  43. package/dist/video/slides/TokensSlide.d.ts +7 -0
  44. package/dist/video/slides/TokensSlide.js +25 -0
  45. package/dist/video/styles.d.ts +45 -0
  46. package/dist/video/styles.js +84 -0
  47. package/package.json +58 -0
@@ -0,0 +1,358 @@
1
+ import { readFile } from "fs/promises";
2
+ import { glob } from "glob";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ // Pricing per million tokens (from Anthropic pricing page)
6
+ // Each model has: input, output, cache_write (25% premium), cache_read (90% discount)
7
+ const PRICING = {
8
+ "claude-opus-4-6": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
9
+ "claude-sonnet-4-6": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
10
+ "claude-sonnet-4-5-20250514": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
11
+ "claude-sonnet-4-20250514": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
12
+ "claude-haiku-4-5-20251001": { input: 0.80, output: 4, cacheWrite: 1.00, cacheRead: 0.08 },
13
+ "claude-3-5-sonnet-20241022": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
14
+ "claude-3-5-haiku-20241022": { input: 0.80, output: 4, cacheWrite: 1.00, cacheRead: 0.08 },
15
+ };
16
+ function estimateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
17
+ const pricing = Object.entries(PRICING).find(([key]) => model.includes(key))?.[1]
18
+ ?? (model.includes("opus") ? PRICING["claude-opus-4-6"]
19
+ : model.includes("haiku") ? PRICING["claude-haiku-4-5-20251001"]
20
+ : PRICING["claude-sonnet-4-6"]);
21
+ return (inputTokens * pricing.input +
22
+ outputTokens * pricing.output +
23
+ cacheCreationTokens * pricing.cacheWrite +
24
+ cacheReadTokens * pricing.cacheRead) / 1_000_000;
25
+ }
26
+ async function findJSONLFiles() {
27
+ const claudeDir = join(homedir(), ".claude", "projects");
28
+ const configDir = process.env.CLAUDE_CONFIG_DIR
29
+ ? join(process.env.CLAUDE_CONFIG_DIR, "projects")
30
+ : null;
31
+ const dirs = [claudeDir, configDir].filter(Boolean);
32
+ const allFiles = [];
33
+ for (const dir of dirs) {
34
+ try {
35
+ const files = await glob("**/*.jsonl", { cwd: dir, absolute: true });
36
+ allFiles.push(...files);
37
+ }
38
+ catch {
39
+ // Directory doesn't exist, skip
40
+ }
41
+ }
42
+ return allFiles;
43
+ }
44
+ async function parseJSONLFile(filePath) {
45
+ const content = await readFile(filePath, "utf-8");
46
+ const entries = [];
47
+ for (const line of content.split("\n")) {
48
+ if (!line.trim())
49
+ continue;
50
+ try {
51
+ const parsed = JSON.parse(line);
52
+ if (parsed.type === "assistant" && parsed.message?.usage) {
53
+ entries.push(parsed);
54
+ }
55
+ }
56
+ catch {
57
+ // Skip invalid lines
58
+ }
59
+ }
60
+ return entries;
61
+ }
62
+ function extractProject(filePath) {
63
+ // Path format: ~/.claude/projects/{encoded-project-path}/{sessionId}.jsonl
64
+ const parts = filePath.split("/projects/");
65
+ if (parts.length < 2)
66
+ return "unknown";
67
+ const afterProjects = parts[1];
68
+ const segments = afterProjects.split("/");
69
+ const projectEncoded = segments[0] ?? "unknown";
70
+ // Decode: -home-user-myproject -> /home/user/myproject
71
+ return projectEncoded
72
+ .replace(/^-/, "/")
73
+ .replace(/-/g, "/");
74
+ }
75
+ export async function loadAndComputeStats(range, periodLabel) {
76
+ const files = await findJSONLFiles();
77
+ if (files.length === 0) {
78
+ throw new Error("No Claude Code usage data found. Make sure you have used Claude Code before.");
79
+ }
80
+ // Parse all files
81
+ const allEntries = [];
82
+ for (const file of files) {
83
+ const entries = await parseJSONLFile(file);
84
+ for (const entry of entries) {
85
+ allEntries.push({ ...entry, _file: file });
86
+ }
87
+ }
88
+ if (allEntries.length === 0) {
89
+ throw new Error("No usage data entries found in Claude Code logs.");
90
+ }
91
+ // Sort by timestamp
92
+ allEntries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
93
+ // Filter by date range if provided
94
+ if (range) {
95
+ const sinceMs = range.since.getTime();
96
+ const untilMs = range.until.getTime();
97
+ const before = allEntries.length;
98
+ const filtered = allEntries.filter(e => {
99
+ const ts = new Date(e.timestamp).getTime();
100
+ return ts >= sinceMs && ts <= untilMs;
101
+ });
102
+ allEntries.length = 0;
103
+ allEntries.push(...filtered);
104
+ if (allEntries.length === 0) {
105
+ throw new Error(`No usage data found in the selected time period (${before} entries exist outside this range).`);
106
+ }
107
+ }
108
+ // Build session map
109
+ const sessionMap = new Map();
110
+ for (const entry of allEntries) {
111
+ const sid = entry.sessionId;
112
+ if (!sessionMap.has(sid))
113
+ sessionMap.set(sid, []);
114
+ sessionMap.get(sid).push(entry);
115
+ }
116
+ // Compute session stats
117
+ const sessions = [];
118
+ for (const [sessionId, entries] of sessionMap) {
119
+ const timestamps = entries.map(e => new Date(e.timestamp));
120
+ const startTime = new Date(Math.min(...timestamps.map(t => t.getTime())));
121
+ const endTime = new Date(Math.max(...timestamps.map(t => t.getTime())));
122
+ const models = [...new Set(entries.map(e => e.message?.model).filter(Boolean))];
123
+ let totalInput = 0, totalOutput = 0, totalCacheCreate = 0, totalCacheRead = 0, totalCost = 0;
124
+ for (const entry of entries) {
125
+ const u = entry.message?.usage;
126
+ if (u) {
127
+ totalInput += u.input_tokens ?? 0;
128
+ totalOutput += u.output_tokens ?? 0;
129
+ totalCacheCreate += u.cache_creation_input_tokens ?? 0;
130
+ totalCacheRead += u.cache_read_input_tokens ?? 0;
131
+ }
132
+ if (entry.costUSD) {
133
+ totalCost += entry.costUSD;
134
+ }
135
+ else if (entry.message?.model && u) {
136
+ totalCost += estimateCost(entry.message.model, u.input_tokens ?? 0, u.output_tokens ?? 0, u.cache_creation_input_tokens ?? 0, u.cache_read_input_tokens ?? 0);
137
+ }
138
+ }
139
+ sessions.push({
140
+ sessionId,
141
+ project: extractProject(entries[0]._file),
142
+ startTime,
143
+ endTime,
144
+ durationMinutes: (endTime.getTime() - startTime.getTime()) / 60_000,
145
+ messageCount: entries.length,
146
+ totalInputTokens: totalInput,
147
+ totalOutputTokens: totalOutput,
148
+ totalCacheCreationTokens: totalCacheCreate,
149
+ totalCacheReadTokens: totalCacheRead,
150
+ totalTokens: totalInput + totalOutput + totalCacheCreate + totalCacheRead,
151
+ models,
152
+ costUSD: totalCost,
153
+ });
154
+ }
155
+ // Hourly activity
156
+ const hourly = Array.from({ length: 24 }, (_, i) => ({
157
+ hour: i,
158
+ messageCount: 0,
159
+ tokenCount: 0,
160
+ }));
161
+ for (const entry of allEntries) {
162
+ const hour = new Date(entry.timestamp).getHours();
163
+ hourly[hour].messageCount++;
164
+ const u = entry.message?.usage;
165
+ if (u) {
166
+ hourly[hour].tokenCount += (u.input_tokens ?? 0) + (u.output_tokens ?? 0);
167
+ }
168
+ }
169
+ // Daily activity
170
+ const dailyMap = new Map();
171
+ const dailySessionSets = new Map();
172
+ for (const entry of allEntries) {
173
+ const date = new Date(entry.timestamp).toISOString().split("T")[0];
174
+ if (!dailyMap.has(date)) {
175
+ dailyMap.set(date, { date, messageCount: 0, tokenCount: 0, costUSD: 0, sessions: 0 });
176
+ dailySessionSets.set(date, new Set());
177
+ }
178
+ const day = dailyMap.get(date);
179
+ day.messageCount++;
180
+ const u = entry.message?.usage;
181
+ if (u) {
182
+ day.tokenCount += (u.input_tokens ?? 0) + (u.output_tokens ?? 0);
183
+ }
184
+ dailySessionSets.get(date).add(entry.sessionId);
185
+ }
186
+ for (const [date, sessionSet] of dailySessionSets) {
187
+ dailyMap.get(date).sessions = sessionSet.size;
188
+ }
189
+ const dailyActivity = [...dailyMap.values()].sort((a, b) => a.date.localeCompare(b.date));
190
+ // Model usage
191
+ const modelMap = new Map();
192
+ for (const entry of allEntries) {
193
+ const model = entry.message?.model ?? "unknown";
194
+ if (!modelMap.has(model))
195
+ modelMap.set(model, { tokens: 0, messages: 0, cost: 0 });
196
+ const m = modelMap.get(model);
197
+ m.messages++;
198
+ const u = entry.message?.usage;
199
+ if (u) {
200
+ m.tokens += (u.input_tokens ?? 0) + (u.output_tokens ?? 0);
201
+ }
202
+ }
203
+ const totalTokensAll = [...modelMap.values()].reduce((sum, m) => sum + m.tokens, 0);
204
+ const modelUsage = [...modelMap.entries()]
205
+ .map(([model, data]) => ({
206
+ model,
207
+ tokenCount: data.tokens,
208
+ messageCount: data.messages,
209
+ costUSD: data.cost,
210
+ percentage: totalTokensAll > 0 ? (data.tokens / totalTokensAll) * 100 : 0,
211
+ }))
212
+ .sort((a, b) => b.tokenCount - a.tokenCount);
213
+ // Totals
214
+ const totalTokens = sessions.reduce((s, sess) => s + sess.totalTokens, 0);
215
+ const totalInputTokens = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0);
216
+ const totalOutputTokens = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0);
217
+ const totalCost = sessions.reduce((s, sess) => s + sess.costUSD, 0);
218
+ const totalMessages = allEntries.length;
219
+ // Dates
220
+ const firstActivity = new Date(allEntries[0].timestamp);
221
+ const lastActivity = new Date(allEntries[allEntries.length - 1].timestamp);
222
+ const uniqueDays = new Set(allEntries.map(e => new Date(e.timestamp).toISOString().split("T")[0]));
223
+ const totalDaysActive = uniqueDays.size;
224
+ // Peak stats
225
+ const busiestDay = dailyActivity.reduce((best, d) => d.tokenCount > best.tokenCount ? d : best);
226
+ const busiestHour = hourly.reduce((best, h) => h.tokenCount > best.tokenCount ? h : best).hour;
227
+ const longestSession = sessions.reduce((best, s) => s.durationMinutes > best.durationMinutes ? s : best);
228
+ // Project frequency
229
+ const projectCounts = new Map();
230
+ for (const sess of sessions) {
231
+ projectCounts.set(sess.project, (projectCounts.get(sess.project) ?? 0) + sess.totalTokens);
232
+ }
233
+ const mostActiveProject = [...projectCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
234
+ // Streak
235
+ const sortedDays = [...uniqueDays].sort();
236
+ let maxStreak = 1, currentStreak = 1;
237
+ for (let i = 1; i < sortedDays.length; i++) {
238
+ const prev = new Date(sortedDays[i - 1]);
239
+ const curr = new Date(sortedDays[i]);
240
+ const diffDays = (curr.getTime() - prev.getTime()) / 86_400_000;
241
+ if (diffDays === 1) {
242
+ currentStreak++;
243
+ maxStreak = Math.max(maxStreak, currentStreak);
244
+ }
245
+ else {
246
+ currentStreak = 1;
247
+ }
248
+ }
249
+ // Scores
250
+ const nightTokens = hourly.filter(h => h.hour >= 22 || h.hour < 5).reduce((s, h) => s + h.tokenCount, 0);
251
+ const nightOwlScore = totalTokensAll > 0 ? Math.round((nightTokens / totalTokensAll) * 100) : 0;
252
+ const weekendEntries = allEntries.filter(e => {
253
+ const day = new Date(e.timestamp).getDay();
254
+ return day === 0 || day === 6;
255
+ });
256
+ const weekendWarriorScore = allEntries.length > 0
257
+ ? Math.round((weekendEntries.length / allEntries.length) * 100)
258
+ : 0;
259
+ const favoriteModel = modelUsage[0]?.model ?? "unknown";
260
+ const totalSessionHours = sessions.reduce((s, sess) => s + sess.durationMinutes, 0) / 60;
261
+ const avgSessionMinutes = sessions.length > 0 ? sessions.reduce((s, sess) => s + sess.durationMinutes, 0) / sessions.length : 0;
262
+ // Archetype
263
+ const { archetype, archetypeDescription } = computeArchetype({
264
+ nightOwlScore,
265
+ weekendWarriorScore,
266
+ avgSessionMinutes,
267
+ totalSessions: sessions.length,
268
+ streakDays: maxStreak,
269
+ totalTokens,
270
+ favoriteModel,
271
+ });
272
+ return {
273
+ periodLabel: periodLabel ?? "Your all-time",
274
+ totalTokens,
275
+ totalInputTokens,
276
+ totalOutputTokens,
277
+ totalCost,
278
+ totalSessions: sessions.length,
279
+ totalMessages,
280
+ totalDaysActive,
281
+ firstActivity,
282
+ lastActivity,
283
+ busiestDay,
284
+ busiestHour,
285
+ longestSession,
286
+ mostActiveProject,
287
+ hourlyActivity: hourly,
288
+ dailyActivity,
289
+ modelUsage,
290
+ tokensPerDay: totalDaysActive > 0 ? Math.round(totalTokens / totalDaysActive) : 0,
291
+ avgSessionMinutes: Math.round(avgSessionMinutes),
292
+ nightOwlScore,
293
+ weekendWarriorScore,
294
+ streakDays: maxStreak,
295
+ favoriteModel,
296
+ archetype,
297
+ archetypeDescription,
298
+ warAndPeaceEquivalent: Math.round((totalTokens / 580_000) * 100) / 100,
299
+ coffeeEquivalent: Math.round(totalCost / 5),
300
+ marathonEquivalent: Math.round((totalSessionHours / 4.5) * 100) / 100,
301
+ // Environmental estimates (rough but directionally correct)
302
+ // ~0.5 liters of water per 1M tokens for data center cooling
303
+ waterLiters: Math.round(totalTokens / 1_000_000 * 0.5 * 100) / 100,
304
+ // ~0.3g CO2 per 1K tokens (inference energy + grid mix)
305
+ co2Grams: Math.round(totalTokens / 1_000 * 0.3),
306
+ // ~0.001 kWh per 1K tokens
307
+ kwhUsed: Math.round(totalTokens / 1_000 * 0.001 * 100) / 100,
308
+ };
309
+ }
310
+ function computeArchetype(params) {
311
+ const { nightOwlScore, weekendWarriorScore, avgSessionMinutes, totalSessions, streakDays, totalTokens, favoriteModel } = params;
312
+ if (nightOwlScore > 50) {
313
+ return {
314
+ archetype: "The Night Owl Demon",
315
+ archetypeDescription: "You code when the world sleeps. Your commit history is a horror movie for morning people.",
316
+ };
317
+ }
318
+ if (weekendWarriorScore > 40) {
319
+ return {
320
+ archetype: "The Weekend Warrior",
321
+ archetypeDescription: "While others brunch, you prompt. Your weekends are just weekdays with better snacks.",
322
+ };
323
+ }
324
+ if (avgSessionMinutes > 120) {
325
+ return {
326
+ archetype: "The Marathon Runner",
327
+ archetypeDescription: "Your sessions are longer than most movies. Touch grass? You've never heard of her.",
328
+ };
329
+ }
330
+ if (streakDays > 14) {
331
+ return {
332
+ archetype: "The Streak Machine",
333
+ archetypeDescription: "Day after day after day. Your consistency is either impressive or concerning. We're not sure which.",
334
+ };
335
+ }
336
+ if (totalTokens > 10_000_000) {
337
+ return {
338
+ archetype: "The Token Whale",
339
+ archetypeDescription: "You consume tokens like a whale consumes krill. Anthropic's servers know you by name.",
340
+ };
341
+ }
342
+ if (favoriteModel.includes("opus")) {
343
+ return {
344
+ archetype: "The Connoisseur",
345
+ archetypeDescription: "Only the finest model for you. Opus or bust. You have expensive taste and zero regrets.",
346
+ };
347
+ }
348
+ if (totalSessions > 50) {
349
+ return {
350
+ archetype: "The Serial Prompter",
351
+ archetypeDescription: "So many sessions, so little time. You start conversations like others start sentences.",
352
+ };
353
+ }
354
+ return {
355
+ archetype: "The Pragmatist",
356
+ archetypeDescription: "Efficient, focused, balanced. You use AI like a precision tool. How boring. (Just kidding, we respect it.)",
357
+ };
358
+ }
@@ -0,0 +1,109 @@
1
+ export interface RawJSONLEntry {
2
+ type: "assistant" | "user" | "queue-operation" | "summary";
3
+ timestamp: string;
4
+ sessionId: string;
5
+ cwd?: string;
6
+ version?: string;
7
+ message?: {
8
+ model?: string;
9
+ role?: string;
10
+ usage?: {
11
+ input_tokens: number;
12
+ output_tokens: number;
13
+ cache_creation_input_tokens?: number;
14
+ cache_read_input_tokens?: number;
15
+ };
16
+ content?: Array<{
17
+ type: string;
18
+ text?: string;
19
+ thinking?: string;
20
+ }>;
21
+ };
22
+ costUSD?: number;
23
+ }
24
+ export interface SessionStats {
25
+ sessionId: string;
26
+ project: string;
27
+ startTime: Date;
28
+ endTime: Date;
29
+ durationMinutes: number;
30
+ messageCount: number;
31
+ totalInputTokens: number;
32
+ totalOutputTokens: number;
33
+ totalCacheCreationTokens: number;
34
+ totalCacheReadTokens: number;
35
+ totalTokens: number;
36
+ models: string[];
37
+ costUSD: number;
38
+ }
39
+ export interface HourlyActivity {
40
+ hour: number;
41
+ messageCount: number;
42
+ tokenCount: number;
43
+ }
44
+ export interface DailyActivity {
45
+ date: string;
46
+ messageCount: number;
47
+ tokenCount: number;
48
+ costUSD: number;
49
+ sessions: number;
50
+ }
51
+ export interface ModelUsage {
52
+ model: string;
53
+ tokenCount: number;
54
+ messageCount: number;
55
+ costUSD: number;
56
+ percentage: number;
57
+ }
58
+ export interface DateRange {
59
+ since: Date;
60
+ until: Date;
61
+ }
62
+ export interface WrappedStats {
63
+ periodLabel: string;
64
+ totalTokens: number;
65
+ totalInputTokens: number;
66
+ totalOutputTokens: number;
67
+ totalCost: number;
68
+ totalSessions: number;
69
+ totalMessages: number;
70
+ totalDaysActive: number;
71
+ firstActivity: Date;
72
+ lastActivity: Date;
73
+ busiestDay: DailyActivity;
74
+ busiestHour: number;
75
+ longestSession: SessionStats;
76
+ mostActiveProject: string;
77
+ hourlyActivity: HourlyActivity[];
78
+ dailyActivity: DailyActivity[];
79
+ modelUsage: ModelUsage[];
80
+ tokensPerDay: number;
81
+ avgSessionMinutes: number;
82
+ nightOwlScore: number;
83
+ weekendWarriorScore: number;
84
+ streakDays: number;
85
+ favoriteModel: string;
86
+ archetype: string;
87
+ archetypeDescription: string;
88
+ warAndPeaceEquivalent: number;
89
+ coffeeEquivalent: number;
90
+ marathonEquivalent: number;
91
+ waterLiters: number;
92
+ co2Grams: number;
93
+ kwhUsed: number;
94
+ }
95
+ export interface VideoProps {
96
+ stats: WrappedStats;
97
+ commentary: Commentary;
98
+ mode: "sassy" | "dark";
99
+ }
100
+ export interface Commentary {
101
+ tokensLine: string;
102
+ costLine: string;
103
+ modelLine: string;
104
+ busiestDayLine: string;
105
+ sessionLine: string;
106
+ peakHoursLine: string;
107
+ archetypeLine: string;
108
+ summaryLine: string;
109
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { WrappedStats, Commentary } from "./data/types";
2
+ export declare function renderVideo(stats: WrappedStats, commentary: Commentary, mode: "sassy" | "dark", outputPath: string): Promise<string>;
package/dist/render.js ADDED
@@ -0,0 +1,60 @@
1
+ import { bundle } from "@remotion/bundler";
2
+ import { renderMedia, selectComposition } from "@remotion/renderer";
3
+ import { resolve, dirname } from "path";
4
+ import { existsSync } from "fs";
5
+ import { fileURLToPath } from "url";
6
+ function findChromeBinary() {
7
+ const candidates = [
8
+ "/root/.cache/ms-playwright/chromium_headless_shell-1194/chrome-linux/headless_shell",
9
+ "/root/.cache/ms-playwright/chromium-1194/chrome-linux/chrome",
10
+ "/usr/bin/chromium-browser",
11
+ "/usr/bin/chromium",
12
+ "/usr/bin/google-chrome",
13
+ ];
14
+ return candidates.find(p => existsSync(p));
15
+ }
16
+ export async function renderVideo(stats, commentary, mode, outputPath) {
17
+ const resolvedOutput = resolve(process.cwd(), outputPath);
18
+ // Resolve entry point - works from both src/ (dev) and dist/ (published)
19
+ const thisDir = dirname(fileURLToPath(import.meta.url));
20
+ const tsEntry = resolve(thisDir, "video", "index.ts");
21
+ const jsEntry = resolve(thisDir, "video", "index.js");
22
+ const entryPoint = existsSync(tsEntry) ? tsEntry : jsEntry;
23
+ const bundleLocation = await bundle({
24
+ entryPoint,
25
+ webpackOverride: (config) => ({
26
+ ...config,
27
+ module: {
28
+ ...config.module,
29
+ rules: [
30
+ ...(config.module?.rules ?? []),
31
+ { test: /\.m?js$/, resolve: { fullySpecified: false } },
32
+ ],
33
+ },
34
+ }),
35
+ });
36
+ const browserExecutable = findChromeBinary();
37
+ const inputProps = { stats, commentary, mode };
38
+ const composition = await selectComposition({
39
+ serveUrl: bundleLocation,
40
+ id: "CCWrapped",
41
+ inputProps,
42
+ browserExecutable,
43
+ });
44
+ await renderMedia({
45
+ composition,
46
+ serveUrl: bundleLocation,
47
+ codec: "h264",
48
+ outputLocation: resolvedOutput,
49
+ inputProps,
50
+ browserExecutable,
51
+ chromiumOptions: {
52
+ enableMultiProcessOnLinux: true,
53
+ },
54
+ crf: 15,
55
+ pixelFormat: "yuv420p",
56
+ x264Preset: "slow",
57
+ imageFormat: "png",
58
+ });
59
+ return resolvedOutput;
60
+ }
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ import type { VideoProps } from "../data/types";
3
+ export declare const TOTAL_SLIDES = 9;
4
+ export declare const FPS = 30;
5
+ export declare const TOTAL_DURATION: number;
6
+ export declare const WIDTH = 900;
7
+ export declare const HEIGHT = 900;
8
+ export declare const CCWrappedComposition: React.FC<VideoProps>;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { AbsoluteFill, Sequence, useCurrentFrame, interpolate } from "remotion";
3
+ import { IntroSlide } from "./slides/IntroSlide";
4
+ import { TokensSlide } from "./slides/TokensSlide";
5
+ import { CostSlide } from "./slides/CostSlide";
6
+ import { ModelSlide } from "./slides/ModelSlide";
7
+ import { BusiestDaySlide } from "./slides/BusiestDaySlide";
8
+ import { SessionSlide } from "./slides/SessionSlide";
9
+ import { PeakHoursSlide } from "./slides/PeakHoursSlide";
10
+ import { ArchetypeSlide } from "./slides/ArchetypeSlide";
11
+ import { SummarySlide } from "./slides/SummarySlide";
12
+ // Timing (at 30fps)
13
+ const FADE_IN = 15; // 0.5s fade in
14
+ const READABLE = 180; // 6s fully visible
15
+ const FADE_OUT = 15; // 0.5s fade out
16
+ const SLIDE_CONTENT = FADE_IN + READABLE + FADE_OUT; // 5s total per slide
17
+ const BLACK_PAUSE = 25; // ~0.83s black between slides
18
+ const SLIDE_SLOT = SLIDE_CONTENT + BLACK_PAUSE; // full slot per slide
19
+ export const TOTAL_SLIDES = 9;
20
+ export const FPS = 30;
21
+ // Last slide has no black pause after it
22
+ export const TOTAL_DURATION = SLIDE_SLOT * TOTAL_SLIDES - BLACK_PAUSE;
23
+ // 900x900: renders sharp, displays crisp at 300x300 (3x density)
24
+ export const WIDTH = 900;
25
+ export const HEIGHT = 900;
26
+ const SlideWithTransition = ({ children, index }) => {
27
+ const frame = useCurrentFrame();
28
+ const fadeIn = interpolate(frame, [0, FADE_IN], [0, 1], {
29
+ extrapolateLeft: "clamp",
30
+ extrapolateRight: "clamp",
31
+ });
32
+ const fadeOut = index < TOTAL_SLIDES - 1
33
+ ? interpolate(frame, [FADE_IN + READABLE, SLIDE_CONTENT], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })
34
+ : 1; // last slide stays visible
35
+ return (_jsx(AbsoluteFill, { style: { opacity: fadeIn * fadeOut }, children: children }));
36
+ };
37
+ export const CCWrappedComposition = ({ stats, commentary, mode }) => {
38
+ const slides = [
39
+ _jsx(IntroSlide, { mode: mode, periodLabel: stats.periodLabel }),
40
+ _jsx(TokensSlide, { stats: stats, commentary: commentary.tokensLine, mode: mode }),
41
+ _jsx(CostSlide, { stats: stats, commentary: commentary.costLine, mode: mode }),
42
+ _jsx(ModelSlide, { stats: stats, commentary: commentary.modelLine, mode: mode }),
43
+ _jsx(BusiestDaySlide, { stats: stats, commentary: commentary.busiestDayLine, mode: mode }),
44
+ _jsx(SessionSlide, { stats: stats, commentary: commentary.sessionLine, mode: mode }),
45
+ _jsx(PeakHoursSlide, { stats: stats, commentary: commentary.peakHoursLine, mode: mode }),
46
+ _jsx(ArchetypeSlide, { stats: stats, commentary: commentary.archetypeLine, mode: mode }),
47
+ _jsx(SummarySlide, { stats: stats, commentary: commentary.summaryLine, mode: mode }),
48
+ ];
49
+ return (_jsx(AbsoluteFill, { style: { backgroundColor: mode === "dark" ? "#050505" : "#0a0a0f" }, children: slides.map((slide, i) => (_jsx(Sequence, { from: i * SLIDE_SLOT, durationInFrames: SLIDE_CONTENT, name: `Slide ${i + 1}`, children: _jsx(SlideWithTransition, { index: i, children: slide }) }, i))) }));
50
+ };
@@ -0,0 +1,2 @@
1
+ import React from "react";
2
+ export declare const RemotionRoot: React.FC;
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Composition } from "remotion";
3
+ import { CCWrappedComposition, TOTAL_DURATION, FPS, WIDTH, HEIGHT } from "./Composition";
4
+ // Default stats for preview mode
5
+ const defaultProps = {
6
+ mode: "sassy",
7
+ commentary: {
8
+ tokensLine: "That's a LOT of tokens bestie. Your AI dependency is showing.",
9
+ costLine: "You could've bought something nice. Instead you bought robot words.",
10
+ modelLine: "Sonnet? Balanced choice. Perfectly mid in the best way.",
11
+ busiestDayLine: "Valentine's Day?! You spent it with Claude?! We need to talk.",
12
+ sessionLine: "8.5 hours straight. Your chiropractor sends their regards.",
13
+ peakHoursLine: "10pm peak hour. The demons come out at night and they want code reviews.",
14
+ archetypeLine: "Night Owl Demon. The bags under your eyes have their own zip code.",
15
+ summaryLine: "Outsourcing your thinking at scale. Honestly? Respect.",
16
+ },
17
+ stats: {
18
+ periodLabel: "Your month",
19
+ totalTokens: 42_690_000,
20
+ totalInputTokens: 35_000_000,
21
+ totalOutputTokens: 7_690_000,
22
+ totalCost: 247.83,
23
+ totalSessions: 156,
24
+ totalMessages: 3847,
25
+ totalDaysActive: 89,
26
+ firstActivity: new Date("2025-06-01"),
27
+ lastActivity: new Date("2026-03-15"),
28
+ busiestDay: { date: "2026-02-14", messageCount: 247, tokenCount: 2_400_000, costUSD: 18.50, sessions: 8 },
29
+ busiestHour: 22,
30
+ longestSession: {
31
+ sessionId: "demo",
32
+ project: "/home/user/megaproject",
33
+ startTime: new Date("2026-02-14T14:00:00"),
34
+ endTime: new Date("2026-02-14T22:30:00"),
35
+ durationMinutes: 510,
36
+ messageCount: 247,
37
+ totalInputTokens: 2_000_000,
38
+ totalOutputTokens: 400_000,
39
+ totalCacheCreationTokens: 500_000,
40
+ totalCacheReadTokens: 300_000,
41
+ totalTokens: 3_200_000,
42
+ models: ["claude-sonnet-4-6"],
43
+ costUSD: 18.50,
44
+ },
45
+ mostActiveProject: "/home/user/megaproject",
46
+ hourlyActivity: Array.from({ length: 24 }, (_, i) => ({
47
+ hour: i,
48
+ messageCount: Math.floor(Math.sin(i * 0.5) * 100 + 120 + (i >= 20 || i <= 2 ? 150 : 0)),
49
+ tokenCount: Math.floor(Math.sin(i * 0.5) * 250000 + 300000 + (i >= 20 || i <= 2 ? 300000 : 0)),
50
+ })),
51
+ dailyActivity: Array.from({ length: 30 }, (_, i) => ({
52
+ date: `2026-02-${String(i + 1).padStart(2, "0")}`,
53
+ messageCount: Math.floor(Math.sin(i * 0.3) * 50 + 60),
54
+ tokenCount: Math.floor(Math.sin(i * 0.3) * 500000 + 600000),
55
+ costUSD: Math.sin(i * 0.3) * 10 + 12,
56
+ sessions: Math.floor(Math.sin(i * 0.3) * 2 + 3),
57
+ })),
58
+ modelUsage: [
59
+ { model: "claude-sonnet-4-6", tokenCount: 30_000_000, messageCount: 2500, costUSD: 150, percentage: 70 },
60
+ { model: "claude-opus-4-6", tokenCount: 10_000_000, messageCount: 800, costUSD: 80, percentage: 23 },
61
+ { model: "claude-haiku-4-5-20251001", tokenCount: 2_690_000, messageCount: 547, costUSD: 17, percentage: 7 },
62
+ ],
63
+ tokensPerDay: 479_663,
64
+ avgSessionMinutes: 45,
65
+ nightOwlScore: 68,
66
+ weekendWarriorScore: 23,
67
+ streakDays: 12,
68
+ favoriteModel: "claude-sonnet-4-6",
69
+ archetype: "The Night Owl Demon",
70
+ archetypeDescription: "You code when the world sleeps. Your commit history is a horror movie for morning people.",
71
+ warAndPeaceEquivalent: 73.6,
72
+ coffeeEquivalent: 50,
73
+ marathonEquivalent: 28.3,
74
+ waterLiters: 21.3,
75
+ co2Grams: 12807,
76
+ kwhUsed: 42.7,
77
+ },
78
+ };
79
+ export const RemotionRoot = () => {
80
+ return (_jsx(_Fragment, { children: _jsx(Composition, { id: "CCWrapped", component: CCWrappedComposition, durationInFrames: TOTAL_DURATION, fps: FPS, width: WIDTH, height: HEIGHT, defaultProps: defaultProps }) }));
81
+ };