@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.
- package/package.json +1 -1
- package/registry.json +18 -0
- package/skills/graphic-chart/README.md +104 -0
- package/skills/graphic-chart/SKILL.md +318 -0
- package/skills/graphic-chart/evals/evals.json +30 -0
- package/skills/graphic-chart/references/chart-library.md +487 -0
- package/skills/graphic-chart/references/style-presets.md +219 -0
- package/skills/graphic-chart/scripts/export-chart.sh +182 -0
- package/skills/graphic-chart/scripts/screenshot-chart.mjs +143 -0
- package/skills/graphic-gif/README.md +99 -0
- package/skills/graphic-gif/SKILL.md +313 -0
- package/skills/graphic-gif/evals/evals.json +30 -0
- package/skills/graphic-gif/references/animation-library.md +446 -0
- package/skills/graphic-gif/references/style-presets.md +194 -0
- package/skills/graphic-gif/scripts/capture-and-encode.mjs +201 -0
- package/skills/graphic-gif/scripts/export-gif.sh +274 -0
|
@@ -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
|