@opendirectory.dev/skills 0.1.58 → 0.1.60

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.
@@ -0,0 +1,201 @@
1
+ // capture-and-encode.mjs — Frame-accurate CSS animation capture + GIF assembly
2
+ //
3
+ // Args: <serve-dir> <html-filename> <output-gif> <duration-ms> <fps> <loop> <width> <height>
4
+ //
5
+ // How frame seeking works:
6
+ // 1. All CSS animations are paused via injected stylesheet
7
+ // 2. For each frame timestamp, Web Animations API seeks every animation to that time
8
+ // 3. A screenshot is captured after a brief paint-settle wait
9
+ // 4. gifenc assembles frames into a GIF with the correct frame delay
10
+ //
11
+ // Frame count = Math.floor((durationMs / 1000) * fps)
12
+ // The final frame at t=durationMs is intentionally EXCLUDED — it would duplicate t=0
13
+ // at the loop point, causing a visible stutter.
14
+
15
+ import { chromium } from 'playwright';
16
+ import { createServer } from 'http';
17
+ import { readFileSync, writeFileSync } from 'fs';
18
+ import { join, extname } from 'path';
19
+ import gifencPkg from 'gifenc';
20
+ const { GIFEncoder, quantize, applyPalette } = gifencPkg;
21
+
22
+ const SERVE_DIR = process.argv[2];
23
+ const HTML_FILE = process.argv[3];
24
+ const OUTPUT_GIF = process.argv[4];
25
+ const DURATION_MS = parseInt(process.argv[5]) || 3000;
26
+ const FPS = parseInt(process.argv[6]) || 12;
27
+ const LOOP = process.argv[7] !== 'false';
28
+ const VP_WIDTH = parseInt(process.argv[8]) || 800;
29
+ const VP_HEIGHT = parseInt(process.argv[9]) || 800;
30
+
31
+ // ─── Static file server ───────────────────────────────────
32
+
33
+ const MIME_TYPES = {
34
+ '.html': 'text/html',
35
+ '.css': 'text/css',
36
+ '.js': 'application/javascript',
37
+ '.json': 'application/json',
38
+ '.png': 'image/png',
39
+ '.jpg': 'image/jpeg',
40
+ '.jpeg': 'image/jpeg',
41
+ '.gif': 'image/gif',
42
+ '.svg': 'image/svg+xml',
43
+ '.webp': 'image/webp',
44
+ '.woff': 'font/woff',
45
+ '.woff2':'font/woff2',
46
+ '.ttf': 'font/ttf',
47
+ };
48
+
49
+ const server = createServer((req, res) => {
50
+ const decoded = decodeURIComponent(req.url);
51
+ const filePath = join(SERVE_DIR, decoded === '/' ? HTML_FILE : decoded);
52
+ try {
53
+ const content = readFileSync(filePath);
54
+ const ext = extname(filePath).toLowerCase();
55
+ res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
56
+ res.end(content);
57
+ } catch {
58
+ res.writeHead(404);
59
+ res.end('Not found');
60
+ }
61
+ });
62
+
63
+ const port = await new Promise((resolve) => {
64
+ server.listen(0, () => resolve(server.address().port));
65
+ });
66
+
67
+ console.log(` Local server on port ${port}`);
68
+
69
+ // ─── PNG decode helper ────────────────────────────────────
70
+ // Try sharp first (fast, C extension), fall back to jimp (pure JS).
71
+
72
+ async function decodePNG(buffer) {
73
+ try {
74
+ const sharp = (await import('sharp')).default;
75
+ const { data, info } = await sharp(buffer)
76
+ .ensureAlpha()
77
+ .raw()
78
+ .toBuffer({ resolveWithObject: true });
79
+ return { data: new Uint8ClampedArray(data), width: info.width, height: info.height };
80
+ } catch {
81
+ // sharp not available — try jimp
82
+ const Jimp = (await import('jimp')).default;
83
+ const img = await Jimp.read(buffer);
84
+ const { width, height } = img.bitmap;
85
+ // jimp stores RGBA in .bitmap.data
86
+ return { data: new Uint8ClampedArray(img.bitmap.data), width, height };
87
+ }
88
+ }
89
+
90
+ // ─── Launch browser ───────────────────────────────────────
91
+
92
+ const browser = await chromium.launch();
93
+ const page = await browser.newPage({
94
+ viewport: { width: VP_WIDTH, height: VP_HEIGHT },
95
+ });
96
+
97
+ await page.goto(`http://localhost:${port}/`, { waitUntil: 'networkidle' });
98
+ await page.evaluate(() => document.fonts.ready);
99
+ await page.waitForTimeout(500);
100
+
101
+ // ─── Pause all animations ─────────────────────────────────
102
+ // Critical: must pause before any frames are captured.
103
+ // Web Animations API currentTime setting only works reliably when paused.
104
+
105
+ await page.addStyleTag({ content: `
106
+ *, *::before, *::after {
107
+ animation-play-state: paused !important;
108
+ }
109
+ ` });
110
+
111
+ // Allow one repaint cycle after pausing
112
+ await page.waitForTimeout(100);
113
+
114
+ // Check animation count for diagnostics
115
+ const animCount = await page.evaluate(() => document.getAnimations().length);
116
+ console.log(` Found ${animCount} CSS animation(s)`);
117
+
118
+ if (animCount === 0) {
119
+ console.warn(' WARNING: No animations detected. The GIF will be a static image.');
120
+ console.warn(' Check that your HTML has CSS @keyframes animations.');
121
+ }
122
+
123
+ // ─── Calculate frame timing ───────────────────────────────
124
+
125
+ const frameCount = Math.floor((DURATION_MS / 1000) * FPS);
126
+ const frameIntervalMs = DURATION_MS / frameCount;
127
+
128
+ console.log(` Capturing ${frameCount} frames (${(frameIntervalMs).toFixed(1)}ms each)`);
129
+
130
+ // ─── Capture frames ───────────────────────────────────────
131
+
132
+ const frames = [];
133
+
134
+ for (let i = 0; i < frameCount; i++) {
135
+ const timeMs = i * frameIntervalMs;
136
+
137
+ // Seek all animations to this timestamp
138
+ await page.evaluate((t) => {
139
+ document.getAnimations().forEach(anim => {
140
+ try {
141
+ anim.currentTime = t;
142
+ } catch {
143
+ // Some animations may reject currentTime assignment — ignore
144
+ }
145
+ });
146
+ }, timeMs);
147
+
148
+ // Wait for paint to settle (requestAnimationFrame cycle)
149
+ await page.waitForTimeout(40);
150
+
151
+ const screenshot = await page.screenshot({
152
+ type: 'png',
153
+ clip: { x: 0, y: 0, width: VP_WIDTH, height: VP_HEIGHT },
154
+ });
155
+
156
+ frames.push(screenshot);
157
+
158
+ if ((i + 1) % 10 === 0 || i === frameCount - 1) {
159
+ process.stdout.write(`\r Frame ${i + 1}/${frameCount}`);
160
+ }
161
+ }
162
+
163
+ console.log('');
164
+ console.log(' All frames captured');
165
+
166
+ await browser.close();
167
+ server.close();
168
+
169
+ // ─── Assemble GIF with gifenc ─────────────────────────────
170
+
171
+ console.log(' Assembling GIF...');
172
+
173
+ const gif = GIFEncoder();
174
+ const frameDelayMs = Math.round(frameIntervalMs);
175
+
176
+ for (let i = 0; i < frames.length; i++) {
177
+ const { data, width, height } = await decodePNG(frames[i]);
178
+
179
+ // Quantize: find optimal palette for this frame
180
+ // 256 colors max for GIF spec; fewer colors = smaller file
181
+ const palette = quantize(data, 256);
182
+ const index = applyPalette(data, palette);
183
+
184
+ gif.writeFrame(index, width, height, {
185
+ palette,
186
+ delay: frameDelayMs, // milliseconds per frame
187
+ repeat: LOOP ? 0 : -1, // 0 = infinite loop, -1 = no loop
188
+ });
189
+
190
+ if ((i + 1) % 10 === 0 || i === frames.length - 1) {
191
+ process.stdout.write(`\r Encoded ${i + 1}/${frames.length} frames`);
192
+ }
193
+ }
194
+
195
+ console.log('');
196
+
197
+ const output = gif.bytes();
198
+ writeFileSync(OUTPUT_GIF, Buffer.from(output));
199
+
200
+ const sizeKB = Math.round(output.byteLength / 1024);
201
+ console.log(` ✓ GIF saved: ${OUTPUT_GIF} (${sizeKB}KB before optimization)`);
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env bash
2
+ # export-gif.sh — Capture CSS animation frames and assemble as a looping GIF
3
+ #
4
+ # Usage:
5
+ # bash scripts/export-gif.sh <path-to-html> [output.gif] [options]
6
+ #
7
+ # Options:
8
+ # --duration N Animation duration in seconds (default: 3.0)
9
+ # --fps N Frames per second (default: 12)
10
+ # --no-loop Disable GIF looping (default: loops forever)
11
+ # --optimization quality | balanced | filesize (default: balanced)
12
+ # --width N Canvas width in pixels (default: 800)
13
+ # --height N Canvas height in pixels (default: 800)
14
+ #
15
+ # Examples:
16
+ # bash scripts/export-gif.sh ./my-anim/animation.html
17
+ # bash scripts/export-gif.sh ./my-anim/animation.html ./output.gif --duration 2 --fps 15
18
+ # bash scripts/export-gif.sh ./my-anim/animation.html --optimization filesize
19
+ #
20
+ # What this does:
21
+ # 1. Launches headless Chromium via Playwright at the specified canvas size
22
+ # 2. Injects CSS to pause all animations
23
+ # 3. Uses the Web Animations API to seek to each frame timestamp
24
+ # 4. Screenshots each frame as PNG
25
+ # 5. Assembles frames into a GIF using gifenc
26
+ # 6. Runs gifsicle for palette optimization and size reduction
27
+ #
28
+ # The resulting GIF has crisp, accurate CSS animation — no timing drift.
29
+ set -euo pipefail
30
+
31
+ # ─── Colors ────────────────────────────────────────────────
32
+ RED='\033[0;31m'
33
+ GREEN='\033[0;32m'
34
+ CYAN='\033[0;36m'
35
+ YELLOW='\033[1;33m'
36
+ BOLD='\033[1m'
37
+ NC='\033[0m'
38
+
39
+ info() { echo -e "${CYAN}ℹ${NC} $*"; }
40
+ ok() { echo -e "${GREEN}✓${NC} $*"; }
41
+ warn() { echo -e "${YELLOW}⚠${NC} $*"; }
42
+ err() { echo -e "${RED}✗${NC} $*" >&2; }
43
+
44
+ # ─── Parse flags ──────────────────────────────────────────
45
+ DURATION=3.0
46
+ FPS=12
47
+ LOOP=true
48
+ OPTIMIZATION=balanced
49
+ WIDTH=800
50
+ HEIGHT=800
51
+
52
+ POSITIONAL=()
53
+ while [[ $# -gt 0 ]]; do
54
+ case $1 in
55
+ --duration)
56
+ DURATION="$2"
57
+ shift 2
58
+ ;;
59
+ --fps)
60
+ FPS="$2"
61
+ shift 2
62
+ ;;
63
+ --no-loop)
64
+ LOOP=false
65
+ shift
66
+ ;;
67
+ --optimization)
68
+ OPTIMIZATION="$2"
69
+ shift 2
70
+ ;;
71
+ --width)
72
+ WIDTH="$2"
73
+ shift 2
74
+ ;;
75
+ --height)
76
+ HEIGHT="$2"
77
+ shift 2
78
+ ;;
79
+ *)
80
+ POSITIONAL+=("$1")
81
+ shift
82
+ ;;
83
+ esac
84
+ done
85
+ set -- "${POSITIONAL[@]}"
86
+
87
+ # ─── Input validation ─────────────────────────────────────
88
+
89
+ if [[ $# -lt 1 ]]; then
90
+ err "Usage: bash scripts/export-gif.sh <path-to-html> [output.gif] [--duration N] [--fps N] [--no-loop] [--optimization quality|balanced|filesize]"
91
+ err ""
92
+ err "Examples:"
93
+ err " bash scripts/export-gif.sh ./my-anim/animation.html"
94
+ err " bash scripts/export-gif.sh ./my-anim/animation.html ./output.gif --duration 2 --fps 15"
95
+ err " bash scripts/export-gif.sh ./my-anim/animation.html --optimization filesize"
96
+ exit 1
97
+ fi
98
+
99
+ INPUT_HTML="$1"
100
+ if [[ ! -f "$INPUT_HTML" ]]; then
101
+ err "File not found: $INPUT_HTML"
102
+ exit 1
103
+ fi
104
+
105
+ INPUT_HTML=$(cd "$(dirname "$INPUT_HTML")" && pwd)/$(basename "$INPUT_HTML")
106
+
107
+ if [[ $# -ge 2 ]]; then
108
+ OUTPUT_GIF="$2"
109
+ else
110
+ OUTPUT_GIF="$(dirname "$INPUT_HTML")/$(basename "$INPUT_HTML" .html).gif"
111
+ fi
112
+
113
+ OUTPUT_DIR=$(dirname "$OUTPUT_GIF")
114
+ mkdir -p "$OUTPUT_DIR"
115
+ OUTPUT_GIF="$OUTPUT_DIR/$(basename "$OUTPUT_GIF")"
116
+
117
+ # Duration in milliseconds for Node.js script
118
+ DURATION_MS=$(echo "$DURATION * 1000" | bc | cut -d. -f1)
119
+
120
+ echo ""
121
+ echo -e "${BOLD}╔══════════════════════════════════════╗${NC}"
122
+ echo -e "${BOLD}║ Export Animation to GIF ║${NC}"
123
+ echo -e "${BOLD}╚══════════════════════════════════════╝${NC}"
124
+ echo ""
125
+ info "Animation: ${DURATION}s @ ${FPS}fps → $(echo "$DURATION * $FPS" | bc | cut -d. -f1) frames"
126
+ info "Canvas: ${WIDTH}×${HEIGHT}px | Loop: $LOOP | Optimization: $OPTIMIZATION"
127
+ echo ""
128
+
129
+ # ─── Step 1: Check dependencies ───────────────────────────
130
+
131
+ info "Checking dependencies..."
132
+
133
+ if ! command -v node &>/dev/null; then
134
+ err "Node.js is required but not installed."
135
+ err ""
136
+ err "Install Node.js:"
137
+ err " macOS: brew install node"
138
+ err " or visit https://nodejs.org and download the installer"
139
+ exit 1
140
+ fi
141
+
142
+ ok "Node.js found ($(node --version))"
143
+
144
+ # Check/install gifsicle for optimization
145
+ GIFSICLE_AVAILABLE=false
146
+ if command -v gifsicle &>/dev/null; then
147
+ GIFSICLE_AVAILABLE=true
148
+ ok "gifsicle found"
149
+ else
150
+ warn "gifsicle not found — skipping optimization pass"
151
+ warn "Install for smaller GIFs: brew install gifsicle"
152
+ fi
153
+
154
+ # ─── Step 2: Create the capture+encode script ─────────────
155
+
156
+ TEMP_DIR=$(mktemp -d)
157
+ TEMP_SCRIPT="$TEMP_DIR/capture-and-encode.mjs"
158
+
159
+ # Get the directory of THIS script so we can find the bundled mjs
160
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
161
+
162
+ # Copy the bundled capture-and-encode.mjs into the temp dir
163
+ cp "$SCRIPT_DIR/capture-and-encode.mjs" "$TEMP_SCRIPT"
164
+
165
+ SERVE_DIR=$(dirname "$INPUT_HTML")
166
+ HTML_FILENAME=$(basename "$INPUT_HTML")
167
+
168
+ # ─── Step 3: Install dependencies in temp directory ───────
169
+
170
+ info "Setting up dependencies (gifenc + Playwright)..."
171
+ info "This may take a moment on first run..."
172
+ echo ""
173
+
174
+ cd "$TEMP_DIR"
175
+
176
+ cat > "$TEMP_DIR/package.json" << 'PKG'
177
+ { "name": "gif-export", "private": true, "type": "module" }
178
+ PKG
179
+
180
+ npm install gifenc sharp playwright 2>/dev/null || {
181
+ # sharp is optional (faster PNG decode) — retry without it
182
+ warn "sharp failed to install, falling back to jimp..."
183
+ npm install gifenc jimp playwright 2>/dev/null || {
184
+ err "Failed to install dependencies."
185
+ err "Try running: npm install gifenc playwright"
186
+ rm -rf "$TEMP_DIR"
187
+ exit 1
188
+ }
189
+ }
190
+
191
+ npx playwright install chromium 2>/dev/null || {
192
+ err "Failed to install Chromium browser for Playwright."
193
+ err "Try running manually: npx playwright install chromium"
194
+ rm -rf "$TEMP_DIR"
195
+ exit 1
196
+ }
197
+ ok "Dependencies ready"
198
+ echo ""
199
+
200
+ # ─── Step 4: Capture frames + assemble GIF ────────────────
201
+
202
+ info "Capturing ${DURATION}s of animation at ${FPS}fps..."
203
+ echo ""
204
+
205
+ UNOPTIMIZED_GIF="$TEMP_DIR/unoptimized.gif"
206
+
207
+ node "$TEMP_SCRIPT" \
208
+ "$SERVE_DIR" \
209
+ "$HTML_FILENAME" \
210
+ "$UNOPTIMIZED_GIF" \
211
+ "$DURATION_MS" \
212
+ "$FPS" \
213
+ "$LOOP" \
214
+ "$WIDTH" \
215
+ "$HEIGHT" || {
216
+ err "GIF capture failed."
217
+ rm -rf "$TEMP_DIR"
218
+ exit 1
219
+ }
220
+
221
+ ok "Frames captured and assembled"
222
+ echo ""
223
+
224
+ # ─── Step 5: Optimize with gifsicle ──────────────────────
225
+
226
+ if [[ "$GIFSICLE_AVAILABLE" == "true" ]]; then
227
+ info "Optimizing GIF (gifsicle $OPTIMIZATION)..."
228
+
229
+ case $OPTIMIZATION in
230
+ quality) GIFSICLE_ARGS="-O2 --colors 256" ;;
231
+ balanced) GIFSICLE_ARGS="-O3 --lossy=80 --colors 128" ;;
232
+ filesize) GIFSICLE_ARGS="-O3 --lossy=120 --colors 64" ;;
233
+ *) GIFSICLE_ARGS="-O3 --lossy=80 --colors 128" ;;
234
+ esac
235
+
236
+ # shellcheck disable=SC2086
237
+ gifsicle $GIFSICLE_ARGS "$UNOPTIMIZED_GIF" -o "$OUTPUT_GIF" || {
238
+ warn "gifsicle optimization failed — using unoptimized GIF"
239
+ cp "$UNOPTIMIZED_GIF" "$OUTPUT_GIF"
240
+ }
241
+
242
+ BEFORE=$(du -k "$UNOPTIMIZED_GIF" | cut -f1)
243
+ AFTER=$(du -k "$OUTPUT_GIF" | cut -f1)
244
+ if [[ $BEFORE -gt 0 ]]; then
245
+ SAVINGS=$(echo "scale=0; (($BEFORE - $AFTER) * 100) / $BEFORE" | bc)
246
+ ok "Optimized: ${BEFORE}KB → ${AFTER}KB (${SAVINGS}% smaller)"
247
+ fi
248
+ else
249
+ cp "$UNOPTIMIZED_GIF" "$OUTPUT_GIF"
250
+ fi
251
+
252
+ # ─── Step 6: Cleanup and success ──────────────────────────
253
+
254
+ rm -rf "$TEMP_DIR"
255
+
256
+ echo ""
257
+ echo -e "${BOLD}════════════════════════════════════════${NC}"
258
+ ok "GIF exported successfully!"
259
+ echo ""
260
+ echo -e " ${BOLD}File:${NC} $OUTPUT_GIF"
261
+ echo ""
262
+ FILE_SIZE=$(du -h "$OUTPUT_GIF" | cut -f1 | xargs)
263
+ echo " Size: $FILE_SIZE"
264
+ echo ""
265
+ echo " This GIF works everywhere — email, Slack, Notion, social."
266
+ echo -e "${BOLD}════════════════════════════════════════${NC}"
267
+ echo ""
268
+
269
+ # Open the GIF automatically
270
+ if command -v open &>/dev/null; then
271
+ open "$OUTPUT_GIF"
272
+ elif command -v xdg-open &>/dev/null; then
273
+ xdg-open "$OUTPUT_GIF"
274
+ fi