Skip to content

Commit dd7b4ea

Browse files
P10: add quality guards and finish showcase
* fix: render historical snake segment colors * fix: validate GIF output and create parent directory * feat: add animation and artifact quality guards * test: cover animation quality limits * chore: keep deterministic test command * ci: validate and regenerate snake on main updates * docs: finish project showcase and architecture
1 parent d5031a8 commit dd7b4ea

6 files changed

Lines changed: 147 additions & 49 deletions

File tree

‎.github/workflows/generate-snake.yml‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ name: Generate GitHub Snake
22

33
on:
44
workflow_dispatch:
5+
push:
6+
branches: [main]
57
schedule:
68
- cron: "17 3 * * 0"
79

@@ -24,10 +26,12 @@ jobs:
2426
uses: actions/setup-node@v4
2527
with:
2628
node-version: 22
27-
cache: npm
2829

2930
- name: Install dependencies
30-
run: npm ci
31+
run: npm install --no-audit --no-fund
32+
33+
- name: Run tests
34+
run: npm test
3135

3236
- name: Generate snake
3337
env:
@@ -36,25 +40,24 @@ jobs:
3640
run: npm run generate
3741

3842
- name: Publish generated artifact
39-
env:
40-
GH_TOKEN: ${{ github.token }}
4143
run: |
4244
set -euo pipefail
4345
git config user.name "github-actions[bot]"
4446
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
4547
48+
mkdir -p output-branch
49+
4650
if git ls-remote --exit-code --heads origin output >/dev/null 2>&1; then
4751
git fetch origin output
4852
git worktree add --detach output-branch origin/output
49-
rm -f output-branch/snake.gif
5053
else
5154
git worktree add --detach output-branch
5255
git -C output-branch checkout --orphan output
5356
git -C output-branch rm -rf . >/dev/null 2>&1 || true
5457
fi
5558
59+
rm -f output-branch/snake.gif
5660
cp output/snake.gif output-branch/snake.gif
57-
printf '%s\n' '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect width="1" height="1" fill="#0d1117"/></svg>' > output-branch/README.svg
58-
git -C output-branch add snake.gif README.svg
61+
git -C output-branch add snake.gif
5962
git -C output-branch commit -m "chore: publish snake artifact" || true
6063
git -C output-branch push origin HEAD:output

‎README.md‎

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,54 @@
11
# 🐍 GitHub Snake Remix
22

3-
A custom GitHub contribution snake animation built from scratch with **TypeScript + SVG**.
3+
A custom GitHub contribution snake animation built from scratch with **TypeScript + SVG**, then encoded to an animated GIF for profile embedding.
44

55
This project is intentionally independent from the original profile-repository snake and does **not** use Platane/snk.
66

7-
## Current architecture
7+
## How it works
88

99
```text
10-
Contribution data
10+
GitHub GraphQL
1111
↓
12-
Domain model
12+
Contribution calendar
1313
↓
14-
Snake engine
14+
Deterministic grid
1515
↓
16-
SVG renderer
16+
Custom snake path
1717
↓
18-
snake.svg
18+
Growth + contribution colors
19+
↓
20+
Animation timeline
21+
↓
22+
SVG frame renderer
23+
↓
24+
Animated GIF
25+
↓
26+
output/snake.gif
1927
```
2028

21-
The core engine is GitHub-agnostic. GitHub API access and GitHub Actions publishing will be added separately.
29+
## Visual rules
2230

23-
## Contribution colors
31+
The snake grows by one segment when it consumes a non-empty contribution cell.
2432

25-
Non-empty contribution levels use four green shades:
33+
Each new segment keeps the contribution intensity it consumed:
2634

2735
- Level 1 → light green
2836
- Level 2 → medium green
2937
- Level 3 → dark green
3038
- Level 4 → deepest green
3139

32-
Empty cells use a dark GitHub-style background.
40+
The background follows GitHub's dark visual style.
41+
42+
## Quality guards
43+
44+
The generator rejects:
45+
46+
- empty animation timelines
47+
- runaway frame counts above 500
48+
- invalid GIF headers
49+
- missing/empty artifacts
50+
- GIFs larger than 1.5 MB
51+
- unreasonable output dimensions
3352

3453
## Local development
3554

@@ -39,21 +58,26 @@ npm test
3958
npm run build
4059
```
4160

42-
## Design principles
61+
To generate against real GitHub data locally:
62+
63+
```bash
64+
CONTRIBUTION_TOKEN=... npm run generate
65+
```
66+
67+
Keep the token in your shell environment or a secret manager. Never commit it.
68+
69+
## GitHub Action
70+
71+
The workflow supports manual dispatch, weekly regeneration and regeneration after changes land on `main`.
4372

44-
- TypeScript with strict compiler settings.
45-
- Deterministic animation state.
46-
- SVG output rather than a browser-only animation.
47-
- No GitHub token in source code.
48-
- No dependency on the old profile repository.
49-
- Small, testable core before adding GitHub data and automation.
73+
The contribution calendar is read with `CONTRIBUTION_TOKEN`. The workflow's built-in `GITHUB_TOKEN` is used only for publishing the generated artifact.
5074

5175
## Profile output
5276

53-
The generated animation is published to the `output` branch by GitHub Actions.
77+
The generated public artifact is published to the `output` branch:
5478

5579
```html
5680
<img src="https://raw.githubusercontent.com/codelovingcat/github-snake-remix/output/snake.gif" alt="GitHub contribution snake" />
5781
```
5882

59-
Set the repository secret `CONTRIBUTION_TOKEN` before running the workflow.
83+
Set the repository secret `CONTRIBUTION_TOKEN` before the first generation run.

‎src/gif-output.ts‎

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import fs from "node:fs/promises";
22
import { createWriteStream } from "node:fs";
3-
import { Readable } from "node:stream";
43
import GIFEncoder from "gif-encoder-2";
54
import sharp from "sharp";
65
import type { AnimationFrame } from "./animation-timeline.js";
76
import { renderSvg } from "./svg-renderer.js";
7+
import { validateGifArtifact, validateAnimationFrameCount } from "./quality.js";
88

99
export interface GifOptions {
1010
readonly width?: number;
1111
readonly height?: number;
1212
readonly delayMilliseconds?: number;
1313
readonly repeat?: number;
1414
readonly quality?: number;
15+
readonly maxBytes?: number;
1516
}
1617

1718
export async function renderAnimatedGif(
@@ -21,7 +22,11 @@ export async function renderAnimatedGif(
2122
outputPath: string,
2223
options: GifOptions = {}
2324
): Promise<void> {
24-
if (frames.length === 0) throw new Error("At least one animation frame is required.");
25+
validateAnimationFrameCount(frames.length);
26+
27+
if (columns < 1 || rows < 1) {
28+
throw new Error("GIF grid dimensions must be positive.");
29+
}
2530

2631
const cellSize = 12;
2732
const gap = 3;
@@ -31,53 +36,69 @@ export async function renderAnimatedGif(
3136
const repeat = options.repeat ?? 0;
3237
const quality = options.quality ?? 10;
3338

34-
if (width <= 0 || height <= 0 || delay <= 0 || quality < 1 || quality > 30) {
35-
throw new Error("Invalid GIF output options.");
39+
if (width <= 0 || height <= 0 || width > 1000 || height > 300 || delay <= 0 || quality < 1 || quality > 30) {
40+
throw new Error("Invalid GIF output dimensions or encoding options.");
3641
}
3742

43+
await fs.mkdir(new URL(".", `file://${outputPath}`).pathname, { recursive: true });
44+
3845
const encoder = new GIFEncoder(width, height);
3946
encoder.setRepeat(repeat);
4047
encoder.setDelay(delay);
4148
encoder.setQuality(quality);
4249

4350
await new Promise<void>((resolve, reject) => {
4451
const output = createWriteStream(outputPath);
45-
encoder.createReadStream().pipe(output);
4652
output.on("finish", resolve);
4753
output.on("error", reject);
48-
encoder.start();
54+
55+
const stream = encoder.createReadStream();
56+
stream.on("error", reject);
57+
stream.pipe(output);
4958

5059
void (async () => {
5160
try {
61+
encoder.start();
62+
5263
for (const frame of frames) {
5364
const svg = renderSvg(
54-
frame.state.segments.map((segment) => ({
55-
x: segment.position.x,
56-
y: segment.position.y,
57-
level: segment.level
58-
})),
65+
framesToCells(frame),
5966
frame.state,
6067
columns,
6168
rows
6269
);
63-
const png = await sharp(Buffer.from(svg)).png().raw().toBuffer({ resolveWithObject: true });
70+
const png = await sharp(Buffer.from(svg))
71+
.png()
72+
.raw()
73+
.toBuffer({ resolveWithObject: true });
6474
encoder.addFrame(png.data);
6575
}
76+
6677
encoder.finish();
6778
} catch (error) {
6879
encoder.abort();
6980
reject(error);
7081
}
7182
})();
7283
});
84+
85+
await validateGifArtifact(outputPath, options.maxBytes);
86+
}
87+
88+
function framesToCells(frame: AnimationFrame) {
89+
return frame.state.segments.map((segment) => ({
90+
x: segment.position.x,
91+
y: segment.position.y,
92+
level: segment.level
93+
}));
7394
}
7495

7596
export async function writeAnimatedGif(
7697
frames: readonly AnimationFrame[],
7798
columns: number,
7899
rows: number,
79-
outputPath: string
100+
outputPath: string,
101+
options: GifOptions = {}
80102
): Promise<void> {
81-
await fs.mkdir(new URL(".", `file://${outputPath}`).pathname, { recursive: true }).catch(() => undefined);
82-
await renderAnimatedGif(frames, columns, rows, outputPath);
103+
await renderAnimatedGif(frames, columns, rows, outputPath, options);
83104
}

‎src/quality.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import fs from "node:fs/promises";
2+
3+
export const MAX_ANIMATION_FRAMES = 500;
4+
export const MAX_GIF_BYTES = 1_500_000;
5+
6+
export function validateAnimationFrameCount(frameCount: number): void {
7+
if (!Number.isInteger(frameCount) || frameCount < 1 || frameCount > MAX_ANIMATION_FRAMES) {
8+
throw new Error(`Animation frame count must be between 1 and ${MAX_ANIMATION_FRAMES}.`);
9+
}
10+
}
11+
12+
export async function validateGifArtifact(
13+
filePath: string,
14+
maxBytes = MAX_GIF_BYTES
15+
): Promise<void> {
16+
const stat = await fs.stat(filePath);
17+
18+
if (!stat.isFile() || stat.size < 20) {
19+
throw new Error("Generated GIF artifact is missing or empty.");
20+
}
21+
22+
if (stat.size > maxBytes) {
23+
throw new Error(`Generated GIF artifact exceeds the ${maxBytes}-byte limit.`);
24+
}
25+
26+
const header = Buffer.alloc(6);
27+
const handle = await fs.open(filePath, "r");
28+
try {
29+
await handle.read(header, 0, header.length, 0);
30+
} finally {
31+
await handle.close();
32+
}
33+
34+
const signature = header.toString("ascii");
35+
if (signature !== "GIF87a" && signature !== "GIF89a") {
36+
throw new Error("Generated artifact is not a valid GIF.");
37+
}
38+
}

‎src/svg-renderer.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { contributionColor } from "./colors.js";
21
import type { Cell, Point, SnakeState } from "./domain.js";
2+
import { contributionColor } from "./colors.js";
33

44
export interface SvgOptions {
55
readonly cellSize?: number;
66
readonly gap?: number;
77
readonly background?: string;
8-
readonly snakeColor?: string;
98
}
109

1110
export function renderSvg(
@@ -22,7 +21,6 @@ export function renderSvg(
2221
const cellSize = options.cellSize ?? 12;
2322
const gap = options.gap ?? 3;
2423
const background = options.background ?? "#0d1117";
25-
const snakeColor = options.snakeColor ?? "#f0f6fc";
2624
const width = columns * (cellSize + gap) + gap;
2725
const height = rows * (cellSize + gap) + gap;
2826

@@ -33,10 +31,10 @@ export function renderSvg(
3331
}).join("");
3432

3533
const snakeRects = snake.segments.map((segment, index) => {
36-
const x = gap + segment.x * (cellSize + gap);
37-
const y = gap + segment.y * (cellSize + gap);
38-
const opacity = Math.max(0.45, 1 - index * 0.035);
39-
return `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="3" fill="${snakeColor}" opacity="${opacity.toFixed(3)}"/>`;
34+
const x = gap + segment.position.x * (cellSize + gap);
35+
const y = gap + segment.position.y * (cellSize + gap);
36+
const opacity = Math.max(0.55, 1 - index * 0.025);
37+
return `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="3" fill="${segment.color}" opacity="${opacity.toFixed(3)}"/>`;
4038
}).join("");
4139

4240
return [

‎tests/quality.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { MAX_ANIMATION_FRAMES, validateAnimationFrameCount } from "../src/quality.js";
4+
5+
test("accepts normal animation frame counts", () => {
6+
assert.doesNotThrow(() => validateAnimationFrameCount(371));
7+
});
8+
9+
test("rejects runaway animation frame counts", () => {
10+
assert.throws(
11+
() => validateAnimationFrameCount(MAX_ANIMATION_FRAMES + 1),
12+
/frame count must be between/
13+
);
14+
});

0 commit comments

Comments
 (0)