headreel 1.2.0 → 1.4.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/README.md +52 -19
- package/assets/fonts/Caveat.ttf +0 -0
- package/assets/fonts/OFL-Caveat.txt +93 -0
- package/dist/cli/run.js +2 -2
- package/dist/core/data/contributions.js +12 -9
- package/dist/core/data/highlights.js +141 -0
- package/dist/core/data/now-playing.js +282 -0
- package/dist/core/encode/gif.js +50 -7
- package/dist/core/fonts.js +3 -0
- package/dist/core/pipeline.js +5 -1
- package/dist/core/text.js +72 -0
- package/dist/styles/contribution-city/city.js +4 -2
- package/dist/styles/contribution-city/index.js +1 -1
- package/dist/styles/contribution-city/options.js +3 -2
- package/dist/styles/contribution-city/sketch.js +62 -32
- package/dist/styles/highlights-reel/index.js +16 -0
- package/dist/styles/highlights-reel/options.js +11 -0
- package/dist/styles/highlights-reel/palette.js +68 -0
- package/dist/styles/highlights-reel/reel.js +346 -0
- package/dist/styles/highlights-reel/sketch.js +555 -0
- package/dist/styles/index.js +4 -0
- package/dist/styles/now-playing/deck.js +105 -0
- package/dist/styles/now-playing/index.js +16 -0
- package/dist/styles/now-playing/options.js +19 -0
- package/dist/styles/now-playing/palette.js +69 -0
- package/dist/styles/now-playing/sketch.js +610 -0
- package/dist/styles/repo-galaxy/sketch.js +16 -8
- package/package.json +1 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { paletteOf } from './palette.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where the reel lives: four cards on one desk, filmed by one camera.
|
|
4
|
+
*
|
|
5
|
+
* The cards sit in a serpentine (row one left to right, row two right to left),
|
|
6
|
+
* just far enough apart that a neighbour is off frame (or under the identity
|
|
7
|
+
* band) at the holding zoom, so the pull-back at the end reads as one tight,
|
|
8
|
+
* laid-out sheet rather than a queue. Everything here is a pure function of the
|
|
9
|
+
* data; the sketch only draws.
|
|
10
|
+
*/
|
|
11
|
+
export const FRAMES = 425;
|
|
12
|
+
export const LAYOUT = {
|
|
13
|
+
width: 1280,
|
|
14
|
+
height: 400,
|
|
15
|
+
/** Holding zoom. */
|
|
16
|
+
z: 0.62,
|
|
17
|
+
card: { w: 1000, h: 500, r: 18 },
|
|
18
|
+
/** Station pitch: a neighbour clears the frame, or sits under the identity band. */
|
|
19
|
+
spacing: { x: 1240, y: 680 },
|
|
20
|
+
/** At the hold the card sits right of centre, clearing the identity band. */
|
|
21
|
+
offX: 374,
|
|
22
|
+
/** The one push-in: twice the holding zoom, landing right of centre on the busiest week. */
|
|
23
|
+
pushZ: 1.24,
|
|
24
|
+
pushOffX: 169,
|
|
25
|
+
pushUpY: 40,
|
|
26
|
+
/**
|
|
27
|
+
* The top-repo card holds a grid of pseudo repo cards, drawn at `scale`, the
|
|
28
|
+
* top repo first. The second push-in lands on it at `z / scale`, so the pseudo
|
|
29
|
+
* card fills the frame exactly as a whole card does at the hold.
|
|
30
|
+
*/
|
|
31
|
+
mini: { scale: 0.2, x: 520, y: 84, gap: 16, cols: 2 },
|
|
32
|
+
/** Moves longer than this world distance dip their zoom; shorter ones stay flat. */
|
|
33
|
+
travel: 1200,
|
|
34
|
+
/** Weekly strip, card-local. */
|
|
35
|
+
strip: { x: 70, baseline: 440, height: 165 },
|
|
36
|
+
/** Wide-shot frame band, screen space: right of the identity text. */
|
|
37
|
+
band: { left: 490, right: 1262, top: 14, bottom: 386 },
|
|
38
|
+
};
|
|
39
|
+
/** Segment weights, in frames at four cards; the plan normalizes them to `FRAMES`. */
|
|
40
|
+
const WEIGHTS = {
|
|
41
|
+
hold: 52,
|
|
42
|
+
travel: 20,
|
|
43
|
+
pushTravel: 20,
|
|
44
|
+
pushHold: 46,
|
|
45
|
+
focusTravel: 20,
|
|
46
|
+
focusHold: 46,
|
|
47
|
+
wideHold: 34,
|
|
48
|
+
return: 24,
|
|
49
|
+
};
|
|
50
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
51
|
+
/** Languages without a linguist color: the theme's soft grey. */
|
|
52
|
+
const NEUTRAL = { light: [102, 106, 124], dark: [140, 146, 168] };
|
|
53
|
+
/** Cards in play order. */
|
|
54
|
+
const CARD_ORDER = ['contributions', 'top_repo', 'pull_requests', 'languages'];
|
|
55
|
+
/** Longest run of consecutive days with at least one contribution. */
|
|
56
|
+
export function longestStreak(contributions) {
|
|
57
|
+
let best = 0;
|
|
58
|
+
let run = 0;
|
|
59
|
+
for (const week of contributions.weeks) {
|
|
60
|
+
for (const day of week) {
|
|
61
|
+
run = day.count > 0 ? run + 1 : 0;
|
|
62
|
+
if (run > best)
|
|
63
|
+
best = run;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return best;
|
|
67
|
+
}
|
|
68
|
+
/** "2026-03-28" .. "2026-04-03" -> "Mar 28 to Apr 3". */
|
|
69
|
+
function dateRange(from, to) {
|
|
70
|
+
const [, fm, fd] = from.split('-').map(Number);
|
|
71
|
+
const [, tm, td] = to.split('-').map(Number);
|
|
72
|
+
const start = `${MONTHS[fm - 1]} ${fd}`;
|
|
73
|
+
return fm === tm ? `${start} to ${td}` : `${start} to ${MONTHS[tm - 1]} ${td}`;
|
|
74
|
+
}
|
|
75
|
+
function busiestWeek(contributions) {
|
|
76
|
+
let index = -1;
|
|
77
|
+
let total = 0;
|
|
78
|
+
contributions.weeks.forEach((week, i) => {
|
|
79
|
+
const sum = week.reduce((s, d) => s + d.count, 0);
|
|
80
|
+
if (sum > total) {
|
|
81
|
+
total = sum;
|
|
82
|
+
index = i;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
if (index < 0)
|
|
86
|
+
return null;
|
|
87
|
+
const week = contributions.weeks[index];
|
|
88
|
+
return {
|
|
89
|
+
index,
|
|
90
|
+
total,
|
|
91
|
+
days: week.map((d) => d.count),
|
|
92
|
+
label: dateRange(week[0].date, week[week.length - 1].date),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Linguist colors include pale and dark ones: darken for the white card, lighten
|
|
97
|
+
* for the dark one, so every dot reads on its card.
|
|
98
|
+
*/
|
|
99
|
+
export function languageColor(hex, theme = 'light') {
|
|
100
|
+
if (!hex)
|
|
101
|
+
return NEUTRAL[theme];
|
|
102
|
+
const n = Number.parseInt(hex.slice(1), 16);
|
|
103
|
+
const [h, s, l] = rgbToHsl([(n >> 16) & 255, (n >> 8) & 255, n & 255]);
|
|
104
|
+
const lightness = theme === 'light' ? Math.min(l, 0.55) : Math.max(l, 0.52);
|
|
105
|
+
return hslToRgb(h, Math.min(s, 0.85), lightness);
|
|
106
|
+
}
|
|
107
|
+
/** Serpentine desk: row one left to right, row two right to left. */
|
|
108
|
+
export function stationOf(index) {
|
|
109
|
+
const row = Math.floor(index / 2);
|
|
110
|
+
const col = row % 2 === 0 ? index % 2 : 1 - (index % 2);
|
|
111
|
+
return { x: col * LAYOUT.spacing.x, y: row * LAYOUT.spacing.y };
|
|
112
|
+
}
|
|
113
|
+
function holdPose(index) {
|
|
114
|
+
const s = stationOf(index);
|
|
115
|
+
return { x: s.x - LAYOUT.offX, y: s.y, z: LAYOUT.z };
|
|
116
|
+
}
|
|
117
|
+
/** Pulls back far enough to hold every card at once, inside the identity band. */
|
|
118
|
+
function widePose(count) {
|
|
119
|
+
const { w, h } = LAYOUT.card;
|
|
120
|
+
let left = Infinity;
|
|
121
|
+
let right = -Infinity;
|
|
122
|
+
let top = Infinity;
|
|
123
|
+
let bottom = -Infinity;
|
|
124
|
+
for (let i = 0; i < count; i++) {
|
|
125
|
+
const s = stationOf(i);
|
|
126
|
+
left = Math.min(left, s.x - w / 2);
|
|
127
|
+
right = Math.max(right, s.x + w / 2);
|
|
128
|
+
top = Math.min(top, s.y - h / 2);
|
|
129
|
+
bottom = Math.max(bottom, s.y + h / 2);
|
|
130
|
+
}
|
|
131
|
+
const pad = 40;
|
|
132
|
+
left -= pad;
|
|
133
|
+
right += pad;
|
|
134
|
+
top -= pad;
|
|
135
|
+
bottom += pad;
|
|
136
|
+
const z = Math.min((LAYOUT.band.right - LAYOUT.band.left) / (right - left), (LAYOUT.band.bottom - LAYOUT.band.top) / (bottom - top));
|
|
137
|
+
const bandCx = (LAYOUT.band.left + LAYOUT.band.right) / 2;
|
|
138
|
+
return {
|
|
139
|
+
x: (left + right) / 2 - (bandCx - LAYOUT.width / 2) / z,
|
|
140
|
+
y: (top + bottom) / 2,
|
|
141
|
+
z,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Card-local top-left corner of pseudo card `k` on the top-repo card. */
|
|
145
|
+
export function plateAt(k) {
|
|
146
|
+
const { scale, x, y, gap, cols } = LAYOUT.mini;
|
|
147
|
+
const w = LAYOUT.card.w * scale;
|
|
148
|
+
const h = LAYOUT.card.h * scale;
|
|
149
|
+
return { x: x + (k % cols) * (w + gap), y: y + Math.floor(k / cols) * (h + gap) };
|
|
150
|
+
}
|
|
151
|
+
/** Where the second push-in lands: the top repo's pseudo card, framed like a hold. */
|
|
152
|
+
function focusPoseOf(index) {
|
|
153
|
+
const s = stationOf(index);
|
|
154
|
+
const { scale } = LAYOUT.mini;
|
|
155
|
+
const plate = plateAt(0);
|
|
156
|
+
const z = LAYOUT.z / scale;
|
|
157
|
+
return {
|
|
158
|
+
x: s.x -
|
|
159
|
+
LAYOUT.card.w / 2 +
|
|
160
|
+
plate.x +
|
|
161
|
+
(LAYOUT.card.w * scale) / 2 -
|
|
162
|
+
(LAYOUT.offX * LAYOUT.z) / z,
|
|
163
|
+
y: s.y - LAYOUT.card.h / 2 + plate.y + (LAYOUT.card.h * scale) / 2,
|
|
164
|
+
z,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Where the push-in lands: the busiest week's opened column, framed right of
|
|
169
|
+
* centre. Null when there is no busiest week (an empty year), so no push.
|
|
170
|
+
*/
|
|
171
|
+
function pushPoseOf(card, weekCount) {
|
|
172
|
+
if (card.busiest === null)
|
|
173
|
+
return null;
|
|
174
|
+
const pitch = (LAYOUT.card.w - 2 * LAYOUT.strip.x) / weekCount;
|
|
175
|
+
const s = stationOf(0);
|
|
176
|
+
const localX = LAYOUT.strip.x + (card.busiest + 0.5) * pitch;
|
|
177
|
+
const localY = LAYOUT.strip.baseline - LAYOUT.strip.height / 2;
|
|
178
|
+
return {
|
|
179
|
+
x: s.x - LAYOUT.card.w / 2 + localX - LAYOUT.pushOffX,
|
|
180
|
+
y: s.y - LAYOUT.card.h / 2 + localY - LAYOUT.pushUpY,
|
|
181
|
+
z: LAYOUT.pushZ,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function buildPlan(cards, push) {
|
|
185
|
+
const hold = (i) => holdPose(i);
|
|
186
|
+
if (cards.length === 1 && !push) {
|
|
187
|
+
// The empty state: one card, drift only.
|
|
188
|
+
return {
|
|
189
|
+
keys: [
|
|
190
|
+
{ frame: 0, ...hold(0) },
|
|
191
|
+
{ frame: FRAMES, ...hold(0) },
|
|
192
|
+
],
|
|
193
|
+
builds: [{ start: 0, end: 0 }],
|
|
194
|
+
focus: { start: 0, end: 0 },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const segs = [{ weight: WEIGHTS.hold, pose: hold(0) }];
|
|
198
|
+
if (push) {
|
|
199
|
+
segs.push({ weight: WEIGHTS.pushTravel, pose: push });
|
|
200
|
+
segs.push({ weight: WEIGHTS.pushHold, pose: push });
|
|
201
|
+
}
|
|
202
|
+
for (let i = 1; i < cards.length; i++) {
|
|
203
|
+
segs.push({ weight: WEIGHTS.travel, pose: hold(i), build: i });
|
|
204
|
+
segs.push({ weight: WEIGHTS.hold, pose: hold(i) });
|
|
205
|
+
if (cards[i].kind === 'top_repo') {
|
|
206
|
+
// Total stars first, then into the top repo's pseudo card.
|
|
207
|
+
const focus = focusPoseOf(i);
|
|
208
|
+
segs.push({ weight: WEIGHTS.focusTravel, pose: focus, focus: true });
|
|
209
|
+
segs.push({ weight: WEIGHTS.focusHold, pose: focus });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (cards.length >= 2) {
|
|
213
|
+
const wide = widePose(cards.length);
|
|
214
|
+
segs.push({ weight: WEIGHTS.travel, pose: wide });
|
|
215
|
+
segs.push({ weight: WEIGHTS.wideHold, pose: wide });
|
|
216
|
+
}
|
|
217
|
+
// Back into card 0: its pose is the loop's first key, so the seam closes.
|
|
218
|
+
segs.push({ weight: WEIGHTS.return, pose: hold(0) });
|
|
219
|
+
const total = segs.reduce((s, g) => s + g.weight, 0);
|
|
220
|
+
const scale = FRAMES / total;
|
|
221
|
+
const keys = [{ frame: 0, ...hold(0) }];
|
|
222
|
+
const builds = cards.map(() => ({ start: 0, end: 0 }));
|
|
223
|
+
let focus = { start: 0, end: 0 };
|
|
224
|
+
let frame = 0;
|
|
225
|
+
for (const seg of segs) {
|
|
226
|
+
const start = frame;
|
|
227
|
+
frame += seg.weight * scale;
|
|
228
|
+
const end = Math.min(FRAMES, Math.round(frame));
|
|
229
|
+
keys.push({ frame: end, ...seg.pose });
|
|
230
|
+
if (seg.build !== undefined) {
|
|
231
|
+
// A card builds while the camera arrives, and settles shortly after.
|
|
232
|
+
builds[seg.build] = {
|
|
233
|
+
start,
|
|
234
|
+
end: Math.min(FRAMES, end + Math.round(WEIGHTS.hold * scale * 0.4)),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (seg.focus) {
|
|
238
|
+
// The top repo's details build as the camera lands in its pseudo card.
|
|
239
|
+
focus = {
|
|
240
|
+
start: Math.round(start + (end - start) * 0.5),
|
|
241
|
+
end: Math.min(FRAMES, end + Math.round(WEIGHTS.focusHold * scale * 0.4)),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
keys[keys.length - 1].frame = FRAMES;
|
|
246
|
+
return { keys, builds, focus };
|
|
247
|
+
}
|
|
248
|
+
export function buildReel(data, _rng, options) {
|
|
249
|
+
const contributions = data.contributions;
|
|
250
|
+
const busiest = busiestWeek(contributions);
|
|
251
|
+
const streak = longestStreak(contributions);
|
|
252
|
+
const cards = [];
|
|
253
|
+
let push = null;
|
|
254
|
+
const theme = options.theme;
|
|
255
|
+
for (const id of CARD_ORDER) {
|
|
256
|
+
switch (id) {
|
|
257
|
+
case 'contributions': {
|
|
258
|
+
// The one card that is always filmed, even at zero.
|
|
259
|
+
const card = {
|
|
260
|
+
kind: 'contributions',
|
|
261
|
+
total: contributions.total,
|
|
262
|
+
streak: streak > 0 ? streak : null,
|
|
263
|
+
weeks: contributions.weeks.map((w) => w.reduce((s, d) => s + d.count, 0)),
|
|
264
|
+
busiest: busiest?.index ?? null,
|
|
265
|
+
busiestDays: busiest?.days ?? [],
|
|
266
|
+
busiestLabel: busiest?.label ?? '',
|
|
267
|
+
};
|
|
268
|
+
push = pushPoseOf(card, card.weeks.length);
|
|
269
|
+
cards.push(card);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'top_repo':
|
|
273
|
+
if (data.topRepo) {
|
|
274
|
+
cards.push({
|
|
275
|
+
kind: 'top_repo',
|
|
276
|
+
totalStars: data.totalStars,
|
|
277
|
+
plates: data.starredRepos.map((r) => ({
|
|
278
|
+
name: r.name,
|
|
279
|
+
stars: r.stars,
|
|
280
|
+
color: languageColor(r.color, theme),
|
|
281
|
+
})),
|
|
282
|
+
name: data.topRepo.name,
|
|
283
|
+
stars: data.topRepo.stars,
|
|
284
|
+
forks: data.topRepo.forks,
|
|
285
|
+
language: data.topRepo.language
|
|
286
|
+
? {
|
|
287
|
+
name: data.topRepo.language.name,
|
|
288
|
+
color: languageColor(data.topRepo.language.color, theme),
|
|
289
|
+
}
|
|
290
|
+
: null,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
case 'pull_requests':
|
|
295
|
+
if (data.mergedPullRequests > 0) {
|
|
296
|
+
cards.push({ kind: 'pull_requests', merged: data.mergedPullRequests });
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
case 'languages':
|
|
300
|
+
if (data.languages.length > 0) {
|
|
301
|
+
cards.push({
|
|
302
|
+
kind: 'languages',
|
|
303
|
+
languages: data.languages.map((l) => ({
|
|
304
|
+
name: l.name,
|
|
305
|
+
color: languageColor(l.color, theme),
|
|
306
|
+
count: l.count,
|
|
307
|
+
})),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { cards, plan: buildPlan(cards, push), palette: paletteOf(options) };
|
|
314
|
+
}
|
|
315
|
+
function rgbToHsl([r, g, b]) {
|
|
316
|
+
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
|
|
317
|
+
const max = Math.max(rn, gn, bn);
|
|
318
|
+
const min = Math.min(rn, gn, bn);
|
|
319
|
+
const l = (max + min) / 2;
|
|
320
|
+
if (max === min)
|
|
321
|
+
return [0, 0, l];
|
|
322
|
+
const d = max - min;
|
|
323
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
324
|
+
const h = max === rn
|
|
325
|
+
? (gn - bn) / d + (gn < bn ? 6 : 0)
|
|
326
|
+
: max === gn
|
|
327
|
+
? (bn - rn) / d + 2
|
|
328
|
+
: (rn - gn) / d + 4;
|
|
329
|
+
return [h / 6, s, l];
|
|
330
|
+
}
|
|
331
|
+
function hslToRgb(h, s, l) {
|
|
332
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
333
|
+
const p = 2 * l - q;
|
|
334
|
+
const channel = (t) => {
|
|
335
|
+
const k = (t + 1) % 1;
|
|
336
|
+
const v = k < 1 / 6
|
|
337
|
+
? p + (q - p) * 6 * k
|
|
338
|
+
: k < 1 / 2
|
|
339
|
+
? q
|
|
340
|
+
: k < 2 / 3
|
|
341
|
+
? p + (q - p) * (2 / 3 - k) * 6
|
|
342
|
+
: p;
|
|
343
|
+
return Math.round(v * 255);
|
|
344
|
+
};
|
|
345
|
+
return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)];
|
|
346
|
+
}
|