headreel 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,32 +78,47 @@ You need:
78
78
 
79
79
  The Action and the command use the same settings.
80
80
 
81
- | Action input | Command flag | Default | Description |
82
- | ---------------- | ---------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
83
- | `style` | `--style` | (required) | The banner style. See [Styles](#styles). |
84
- | `username` | `--user` | repository owner | The GitHub user to show. The command requires it. |
85
- | `tagline` | `--tagline` | empty | One line under your name. |
86
- | `website` | `--website` | empty | Your website. |
87
- | `handle` | `--handle` | empty | A handle, for styles that show one. |
88
- | `options` | `--option <key=value>` | empty | Style options. Action: one `key: value` on each line. Command: repeat the flag, for example `--option beacons=5`. |
89
- | `output` | `--out` | `headreel.gif` | The path of the banner. |
90
- | `token` | `--token` | `github.token` | The token that reads your GitHub data. |
91
- | `publish_mode` | - | `commit` | `commit` adds a commit on every update. `branch` keeps one commit on its own branch, replaced on every update. |
92
- | `commit_to` | - | checked-out branch / `headreel` | The branch that gets the banner. The default is `headreel` in `branch` mode. |
93
- | `commit_message` | - | `chore: update headreel banner` | The commit message. |
94
- | - | `--config <file>` | - | A JSON file with these settings. Flags replace its values. |
81
+ | Action input | Command flag | Default | Description |
82
+ | ---------------- | ---------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
83
+ | `style` | `--style` | (required) | The banner style. See [Styles](#styles). |
84
+ | `username` | `--user` | repository owner | The GitHub user to show. The command requires it. |
85
+ | `tagline` | `--tagline` | empty | One line under your name. |
86
+ | `website` | `--website` | empty | Your website. |
87
+ | `handle` | `--handle` | empty | A handle, for styles that show one. |
88
+ | `options` | `--option <key=value>` | empty | Style options. Action: one `key: value` on each line. Command: repeat the flag, for example `--option accent=green`. |
89
+ | `output` | `--out` | `headreel.gif` | The path of the banner. |
90
+ | `token` | `--token` | `github.token` | The token that reads your GitHub data. |
91
+ | `publish_mode` | - | `commit` | `commit` adds a commit on every update. `branch` keeps one commit on its own branch, replaced on every update. |
92
+ | `commit_to` | - | checked-out branch / `headreel` | The branch that gets the banner. The default is `headreel` in `branch` mode. |
93
+ | `commit_message` | - | `chore: update headreel banner` | The commit message. |
94
+ | - | `--config <file>` | - | A JSON file with these settings. Flags replace its values. |
95
95
 
96
96
  Empty settings do not show on the banner. Your name comes from your GitHub profile. If your profile has no name, the banner shows your username.
97
97
 
98
98
  ## Styles
99
99
 
100
+ Set the style with `style: <name>` in the Action, or `--style <name>` in the command. Set an option with `options: 'accent: green'` in the Action, or `--option accent=green` in the command. For more than one option in the Action, put each on its own line.
101
+
100
102
  ### Contribution City
101
103
 
102
104
  `contribution-city`: each day of the last 12 months is one building. A taller building means more contributions. Beacons glow on your busiest days.
103
105
 
104
- | Option | Default | Description |
105
- | --------- | ------- | -------------------------------------------------- |
106
- | `beacons` | `8` | The number of busiest days with a beacon, 0 to 10. |
106
+ ![Contribution City banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/contribution-city.gif)
107
+
108
+ | Option | Default | Description |
109
+ | -------- | ------- | --------------------------------------------------------------------------------------------- |
110
+ | `accent` | `cyan` | Color of the roofs, windows, lights and links: `cyan`, `cobalt`, `green`, `violet` or `pink`. |
111
+
112
+ ### Highlights Reel
113
+
114
+ `highlights-reel`: a few profile facts, each on its own card, filmed by one camera. The camera travels between the cards, pushes into your busiest week, and ends on a wide shot of the whole reel.
115
+
116
+ ![Highlights Reel banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/highlights-reel.gif)
117
+
118
+ | Option | Default | Description |
119
+ | -------- | -------- | -------------------------------------------------------------------------------------- |
120
+ | `theme` | `light` | `light` or `dark`. Use `dark` if your profile is mostly viewed in GitHub dark mode. |
121
+ | `accent` | `cobalt` | Color of the bars, numbers and links: `cobalt`, `green`, `violet`, `orange` or `pink`. |
107
122
 
108
123
  ### Repo Galaxy
109
124
 
@@ -117,8 +132,6 @@ Empty settings do not show on the banner. Your name comes from your GitHub profi
117
132
  | `include_forks` | `false` | Show forked repositories too. |
118
133
  | `labels` | `top3` | `top3` names the three most starred repositories. `none` hides names. |
119
134
 
120
- Set an option with `options: 'beacons: 5'` in the Action, or `--option beacons=5` in the command. For more than one option in the Action, put each on its own line.
121
-
122
135
  ## Troubleshooting
123
136
 
124
137
  | Problem | Fix |
@@ -126,3 +139,11 @@ Set an option with `options: 'beacons: 5'` in the Action, or `--option beacons=5
126
139
  | "Permission denied" or 403 error | Add `permissions: contents: write` to the workflow file. |
127
140
  | "Could not resolve to a User" | Set `username` to a valid GitHub username. |
128
141
  | "No GitHub token found" | Run `gh auth login`, or set `GITHUB_TOKEN`. |
142
+
143
+ ## Support
144
+
145
+ If headreel is useful to you, please [star the repository](https://github.com/arifszn/headreel). It helps other people find it.
146
+
147
+ ## License
148
+
149
+ [MIT License](./LICENSE).
@@ -32,6 +32,17 @@ export const contributionsSchema = z.object({
32
32
  /** Calendar columns, oldest first. First and last weeks may be partial. */
33
33
  weeks: z.array(z.array(daySchema)),
34
34
  });
35
+ /** Normalizes the GraphQL contribution calendar into the stored shape. */
36
+ export function normalizeCalendar(calendar, from, to) {
37
+ return {
38
+ from,
39
+ to,
40
+ total: calendar.totalContributions,
41
+ weeks: calendar.weeks.map((week) => week.contributionDays
42
+ .map((d) => ({ date: d.date, weekday: d.weekday, count: d.contributionCount }))
43
+ .sort((a, b) => a.date.localeCompare(b.date))),
44
+ };
45
+ }
35
46
  const DAY_MS = 86_400_000;
36
47
  /**
37
48
  * The 365 days ending on `now` (UTC), matching GitHub's profile calendar.
@@ -51,13 +62,5 @@ export async function fetchContributions(client, login, now) {
51
62
  from: `${from}T00:00:00Z`,
52
63
  to: `${to}T23:59:59Z`,
53
64
  });
54
- const calendar = user.contributionsCollection.contributionCalendar;
55
- return {
56
- from,
57
- to,
58
- total: calendar.totalContributions,
59
- weeks: calendar.weeks.map((week) => week.contributionDays
60
- .map((d) => ({ date: d.date, weekday: d.weekday, count: d.contributionCount }))
61
- .sort((a, b) => a.date.localeCompare(b.date))),
62
- };
65
+ return normalizeCalendar(user.contributionsCollection.contributionCalendar, from, to);
63
66
  }
@@ -0,0 +1,141 @@
1
+ import { z } from 'zod';
2
+ import { contributionWindow, contributionsSchema, normalizeCalendar } from './contributions.js';
3
+ import { MAX_REPOS } from './repos.js';
4
+ // One query for the whole reel: the same 365-day window as Contribution City,
5
+ // the most starred owned public repos (the first is the top repo, the set
6
+ // counts the top languages and the total stars), and all-time merged pull
7
+ // requests.
8
+ const QUERY = /* GraphQL */ `
9
+ query Highlights($login: String!, $first: Int!, $from: DateTime!, $to: DateTime!) {
10
+ user(login: $login) {
11
+ contributionsCollection(from: $from, to: $to) {
12
+ contributionCalendar {
13
+ totalContributions
14
+ weeks {
15
+ contributionDays {
16
+ date
17
+ weekday
18
+ contributionCount
19
+ }
20
+ }
21
+ }
22
+ }
23
+ repos: repositories(
24
+ first: $first
25
+ ownerAffiliations: OWNER
26
+ privacy: PUBLIC
27
+ isFork: false
28
+ orderBy: { field: STARGAZERS, direction: DESC }
29
+ ) {
30
+ nodes {
31
+ name
32
+ stargazerCount
33
+ forkCount
34
+ primaryLanguage {
35
+ name
36
+ color
37
+ }
38
+ }
39
+ }
40
+ pullRequests(states: MERGED) {
41
+ totalCount
42
+ }
43
+ }
44
+ }
45
+ `;
46
+ const topRepoSchema = z.object({
47
+ name: z.string().min(1),
48
+ stars: z.number().int().min(0),
49
+ forks: z.number().int().min(0),
50
+ language: z
51
+ .object({
52
+ name: z.string().min(1),
53
+ /** Linguist color; null when the repo has no language or color. */
54
+ color: z
55
+ .string()
56
+ .regex(/^#[0-9a-fA-F]{6}$/)
57
+ .nullable(),
58
+ })
59
+ .nullable(),
60
+ });
61
+ /** Repos shown as pseudo cards around the top repo, most starred first. */
62
+ export const STARRED_REPOS = 6;
63
+ export const highlightsSchema = z.object({
64
+ contributions: contributionsSchema,
65
+ topRepo: topRepoSchema.nullable(),
66
+ /** Stars summed over the fetched repos. */
67
+ totalStars: z.number().int().min(0),
68
+ /** The most starred repos, top repo first, for the pseudo cards. */
69
+ starredRepos: z
70
+ .array(z.object({
71
+ name: z.string().min(1),
72
+ stars: z.number().int().min(0),
73
+ /** Linguist color of the primary language; null when none. */
74
+ color: z
75
+ .string()
76
+ .regex(/^#[0-9a-fA-F]{6}$/)
77
+ .nullable(),
78
+ }))
79
+ .max(STARRED_REPOS),
80
+ mergedPullRequests: z.number().int().min(0),
81
+ /** Top three languages by repo count over the fetched repos, most first. */
82
+ languages: z
83
+ .array(z.object({
84
+ name: z.string().min(1),
85
+ /** Linguist color; null when no repo sets one. */
86
+ color: z
87
+ .string()
88
+ .regex(/^#[0-9a-fA-F]{6}$/)
89
+ .nullable(),
90
+ count: z.number().int().min(1),
91
+ }))
92
+ .max(3),
93
+ });
94
+ export async function fetchHighlights(client, login, now) {
95
+ const { from, to } = contributionWindow(now);
96
+ const { user } = await client(QUERY, {
97
+ login,
98
+ first: MAX_REPOS,
99
+ from: `${from}T00:00:00Z`,
100
+ to: `${to}T23:59:59Z`,
101
+ });
102
+ const nodes = user.repos.nodes;
103
+ // Languages by repo count, ties by name; repos without a language are skipped.
104
+ const counts = new Map();
105
+ for (const n of nodes) {
106
+ if (!n.primaryLanguage)
107
+ continue;
108
+ const entry = counts.get(n.primaryLanguage.name) ?? {
109
+ color: n.primaryLanguage.color,
110
+ count: 0,
111
+ };
112
+ entry.count++;
113
+ counts.set(n.primaryLanguage.name, entry);
114
+ }
115
+ const languages = [...counts.entries()]
116
+ .sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]))
117
+ .slice(0, 3)
118
+ .map(([name, { color, count }]) => ({ name, color, count }));
119
+ const top = nodes[0];
120
+ return {
121
+ contributions: normalizeCalendar(user.contributionsCollection.contributionCalendar, from, to),
122
+ topRepo: top
123
+ ? {
124
+ name: top.name,
125
+ stars: top.stargazerCount,
126
+ forks: top.forkCount,
127
+ language: top.primaryLanguage
128
+ ? { name: top.primaryLanguage.name, color: top.primaryLanguage.color }
129
+ : null,
130
+ }
131
+ : null,
132
+ totalStars: nodes.reduce((sum, n) => sum + n.stargazerCount, 0),
133
+ starredRepos: nodes.slice(0, STARRED_REPOS).map((n) => ({
134
+ name: n.name,
135
+ stars: n.stargazerCount,
136
+ color: n.primaryLanguage?.color ?? null,
137
+ })),
138
+ mergedPullRequests: user.pullRequests.totalCount,
139
+ languages,
140
+ };
141
+ }
@@ -4,7 +4,7 @@ import * as gifencModule from 'gifenc';
4
4
  const gifenc = typeof gifencModule.quantize === 'function'
5
5
  ? gifencModule
6
6
  : gifencModule.default;
7
- const { GIFEncoder, quantize, applyPalette } = gifenc;
7
+ const { GIFEncoder, quantize } = gifenc;
8
8
  /** Number of frames sampled to build the shared palette. */
9
9
  const PALETTE_SAMPLES = 6;
10
10
  /** 255 real colors; the last global palette slot is the transparent index. */
@@ -33,12 +33,13 @@ const BAYER = (() => {
33
33
  * that does not change between frames dithers identically, which keeps the
34
34
  * frame deltas small and avoids shimmer.
35
35
  */
36
- function dither(rgba, width) {
36
+ function dither(rgba, width, pinned) {
37
37
  const out = new Uint8ClampedArray(rgba.length);
38
38
  for (let i = 0, p = 0; i < rgba.length; i += 4, p++) {
39
39
  const x = p % width;
40
40
  const y = (p - x) / width;
41
- const offset = BAYER[(y & 7) * 8 + (x & 7)] * DITHER_SPREAD;
41
+ const exact = pinned.size > 0 && pinned.has((rgba[i] << 16) | (rgba[i + 1] << 8) | rgba[i + 2]);
42
+ const offset = exact ? 0 : BAYER[(y & 7) * 8 + (x & 7)] * DITHER_SPREAD;
42
43
  out[i] = rgba[i] + offset;
43
44
  out[i + 1] = rgba[i + 1] + offset;
44
45
  out[i + 2] = rgba[i + 2] + offset;
@@ -46,12 +47,48 @@ function dither(rgba, width) {
46
47
  }
47
48
  return out;
48
49
  }
49
- function buildPalette(frames, frameBytes) {
50
+ /**
51
+ * Maps pixels to their nearest palette index, memoized per exact color.
52
+ * gifenc's `applyPalette` memoizes per rgb565 bucket instead, so the first pixel
53
+ * of a bucket in scan order picks the index for the whole bucket. Near-white
54
+ * pixels then land on white in one frame and on a pale grey in the next, and a
55
+ * flat card flickers. An exact memo is a pure function of the color.
56
+ */
57
+ function createMapper(palette) {
58
+ const memo = new Int16Array(1 << 24).fill(-1);
59
+ const nearest = (r, g, b) => {
60
+ let best = 0;
61
+ let bestDist = Infinity;
62
+ for (let k = 0; k < palette.length; k++) {
63
+ const c = palette[k];
64
+ const d = (c[0] - r) ** 2 + (c[1] - g) ** 2 + (c[2] - b) ** 2;
65
+ if (d < bestDist) {
66
+ bestDist = d;
67
+ best = k;
68
+ }
69
+ }
70
+ return best;
71
+ };
72
+ return (rgba) => {
73
+ const out = new Uint8Array(rgba.length / 4);
74
+ for (let i = 0, p = 0; i < rgba.length; i += 4, p++) {
75
+ const key = (rgba[i] << 16) | (rgba[i + 1] << 8) | rgba[i + 2];
76
+ let index = memo[key];
77
+ if (index < 0) {
78
+ index = nearest(rgba[i], rgba[i + 1], rgba[i + 2]);
79
+ memo[key] = index;
80
+ }
81
+ out[p] = index;
82
+ }
83
+ return out;
84
+ };
85
+ }
86
+ function buildPalette(frames, frameBytes, colors) {
50
87
  const step = Math.max(1, Math.floor(frames.length / PALETTE_SAMPLES));
51
88
  const picks = frames.filter((_, i) => i % step === 0).slice(0, PALETTE_SAMPLES);
52
89
  const sample = new Uint8ClampedArray(frameBytes * picks.length);
53
90
  picks.forEach((data, i) => sample.set(data, i * frameBytes));
54
- return quantize(sample, COLORS);
91
+ return quantize(sample, colors);
55
92
  }
56
93
  /**
57
94
  * Encodes RGBA frames into a looping GIF: one global palette, ordered
@@ -59,16 +96,22 @@ function buildPalette(frames, frameBytes) {
59
96
  */
60
97
  export function encodeGif(frames, spec) {
61
98
  const { width, height } = spec;
62
- const palette = buildPalette(frames, width * height * 4);
99
+ const pinnedColors = spec.pinned ?? [];
100
+ const palette = [
101
+ ...buildPalette(frames, width * height * 4, COLORS - pinnedColors.length),
102
+ ...pinnedColors.map((c) => [...c]),
103
+ ];
104
+ const pinned = new Set(pinnedColors.map(([r, g, b]) => (r << 16) | (g << 8) | b));
63
105
  const globalPalette = [...palette];
64
106
  while (globalPalette.length < TRANSPARENT)
65
107
  globalPalette.push([0, 0, 0]);
66
108
  globalPalette.push([0, 0, 0]);
109
+ const toIndex = createMapper(palette);
67
110
  const gif = GIFEncoder();
68
111
  const delay = Math.round(1000 / spec.fps);
69
112
  let previous;
70
113
  for (const [i, data] of frames.entries()) {
71
- const index = applyPalette(dither(data, width), palette);
114
+ const index = toIndex(dither(data, width, pinned));
72
115
  let pixels = index;
73
116
  if (previous) {
74
117
  pixels = new Uint8Array(index);
@@ -24,5 +24,9 @@ export async function renderBanner(style, input) {
24
24
  rng,
25
25
  });
26
26
  const frames = await renderFrames(sketch, { ...CANVAS, frames: style.frames });
27
- return encodeGif(frames, { ...CANVAS, fps: style.fps });
27
+ return encodeGif(frames, {
28
+ ...CANVAS,
29
+ fps: style.fps,
30
+ ...(style.pinned ? { pinned: style.pinned(options) } : {}),
31
+ });
28
32
  }
@@ -0,0 +1,72 @@
1
+ import { createCanvas } from '@napi-rs/canvas';
2
+ import { registerFonts } from './fonts.js';
3
+ const ELLIPSIS = '…';
4
+ /**
5
+ * Fits one line of text into `maxWidth`. It first shrinks from `size` toward
6
+ * `minSize` (pass `minSize === size` to keep the size), then cuts the text
7
+ * with an ellipsis. The result is never wider than `maxWidth`, so no line can
8
+ * leave the canvas or run into the scene.
9
+ */
10
+ export function fitLine(text, maxWidth, size, minSize, measure) {
11
+ if (measure(text, size) <= maxWidth)
12
+ return { text, size };
13
+ // Width scales about linearly with size; step down until it fits.
14
+ let fitted = Math.max(minSize, Math.floor((size * maxWidth) / measure(text, size)));
15
+ while (fitted > minSize && measure(text, fitted) > maxWidth)
16
+ fitted--;
17
+ if (measure(text, fitted) <= maxWidth)
18
+ return { text, size: fitted };
19
+ // Longest prefix that fits with the ellipsis.
20
+ const chars = [...text];
21
+ let lo = 0;
22
+ let hi = chars.length;
23
+ while (lo < hi) {
24
+ const mid = Math.ceil((lo + hi) / 2);
25
+ const candidate = `${chars.slice(0, mid).join('').trimEnd()}${ELLIPSIS}`;
26
+ if (measure(candidate, minSize) <= maxWidth)
27
+ lo = mid;
28
+ else
29
+ hi = mid - 1;
30
+ }
31
+ const cut = chars.slice(0, lo).join('').trimEnd();
32
+ return { text: cut ? `${cut}${ELLIPSIS}` : '', size: minSize };
33
+ }
34
+ let scratch;
35
+ /**
36
+ * Measures with the bundled fonts on a private canvas, so fitting never
37
+ * touches a sketch's own drawing state (p5 caches font and fill state).
38
+ * `tracking` is letter spacing in px, as the sketch draws it.
39
+ */
40
+ export function canvasMeasure(family, weight, tracking = 0) {
41
+ registerFonts();
42
+ scratch ??= createCanvas(1, 1).getContext('2d');
43
+ const ctx = scratch;
44
+ return (text, size) => {
45
+ ctx.font = `${weight} ${size}px "${family}"`;
46
+ ctx.letterSpacing = `${tracking}px`;
47
+ return ctx.measureText(text).width;
48
+ };
49
+ }
50
+ /** The identity column's lines, shared by every style (same fonts and sizes). */
51
+ export const IDENTITY_TYPE = {
52
+ name: { family: 'Space Grotesk', weight: 700, size: 50, minSize: 30, tracking: 3 },
53
+ tagline: { family: 'Space Grotesk', weight: 400, size: 17 },
54
+ website: { family: 'JetBrains Mono', weight: 400, size: 12 },
55
+ };
56
+ /**
57
+ * Fits the identity column: `name` shrinks then cuts, `tagline` and `website`
58
+ * cut. `column` bounds name and tagline; `websiteColumn` bounds the website,
59
+ * which some styles draw beside the scene rather than above it.
60
+ */
61
+ export function fitIdentity(lines, column, websiteColumn = column) {
62
+ const { name, tagline, website } = IDENTITY_TYPE;
63
+ return {
64
+ name: fitLine(lines.name, column, name.size, name.minSize, canvasMeasure(name.family, name.weight, name.tracking)),
65
+ tagline: lines.tagline
66
+ ? fitLine(lines.tagline, column, tagline.size, tagline.size, canvasMeasure(tagline.family, tagline.weight))
67
+ : null,
68
+ website: lines.website
69
+ ? fitLine(lines.website, websiteColumn, website.size, website.size, canvasMeasure(website.family, website.weight))
70
+ : null,
71
+ };
72
+ }
@@ -31,7 +31,9 @@ export function tileAt(weeks, w, d) {
31
31
  const depth = 6 - d;
32
32
  return { x: o.x + w * LAYOUT.cell + depth * LAYOUT.depthX, y: o.y - depth * LAYOUT.depthY };
33
33
  }
34
- export function buildCity(data, rng, beacons) {
34
+ /** Busiest days marked with a beacon. */
35
+ export const BEACONS = 8;
36
+ export function buildCity(data, rng) {
35
37
  const between = (min, max) => min + rng() * (max - min);
36
38
  const weeks = data.weeks.length;
37
39
  const maxCount = Math.max(0, ...data.weeks.flat().map((d) => d.count));
@@ -73,7 +75,7 @@ export function buildCity(data, rng, beacons) {
73
75
  const ranked = buildings
74
76
  .filter((b) => b.count > 0)
75
77
  .sort((a, b) => b.count - a.count || a.w - b.w || a.d - b.d);
76
- for (const b of ranked.slice(0, beacons))
78
+ for (const b of ranked.slice(0, BEACONS))
77
79
  b.beacon = true;
78
80
  // Back rows first, then left to right, so front towers occlude correctly.
79
81
  buildings.sort((a, b) => a.d - b.d || a.w - b.w);
@@ -9,6 +9,6 @@ export const contributionCity = {
9
9
  data: { name: 'contributions', schema: contributionsSchema, fetch: fetchContributions },
10
10
  options,
11
11
  createSketch({ data, options, identity, rng }) {
12
- return createCitySketch(buildCity(data, rng, options.beacons), identity, rng);
12
+ return createCitySketch(buildCity(data, rng), identity, rng, options.accent);
13
13
  },
14
14
  };
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
+ export const ACCENTS = ['cyan', 'cobalt', 'green', 'violet', 'pink'];
2
3
  export const options = z
3
4
  .object({
4
- /** Number of busiest days marked with a beacon. */
5
- beacons: z.coerce.number().int().min(0).max(10).default(8),
5
+ /** Color of the roofs, windows, glow, lines, scan beam and links. */
6
+ accent: z.enum(ACCENTS).default('cyan'),
6
7
  })
7
8
  .strict();