@opendirectory.dev/skills 0.1.60 → 0.1.61

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,187 @@
1
+ // capture-frames.mjs — Capture HTML motion graphic as PNG frame sequence
2
+ //
3
+ // Args: <serve-dir> <html-filename> <frames-dir> <width> <height> <duration-seconds> <fps>
4
+ //
5
+ // Architecture: JS-driven renderFrame (NOT CSS @keyframes seeking)
6
+ //
7
+ // The HTML exposes window.renderFrame(timeMs) — a pure JS function that computes
8
+ // element styles (opacity, transform) directly from the time value. Playwright calls
9
+ // this once per frame. No CSS animation state, no WAAPI seeking, no timing races.
10
+ //
11
+ // Why NOT CSS @keyframes + currentTime:
12
+ // Chromium silently ignores anim.currentTime backward seeks on CSS animations.
13
+ // The animation "sticks" at whatever time it was when we injected pause CSS.
14
+ // For long animations (12s) with networkidle wait (8-12s for Google Fonts CDN),
15
+ // the animation finishes BEFORE we can pause it → all frames capture final state.
16
+ //
17
+ // The renderFrame approach:
18
+ // - Works for any duration, any seek direction
19
+ // - Fully deterministic — same input → same output
20
+ // - Browser preview also works (rAF loop in the HTML calls renderFrame)
21
+ // - No race conditions with font loading or network timing
22
+
23
+ import { chromium } from 'playwright';
24
+ import { createServer } from 'http';
25
+ import { readFileSync, statSync } from 'fs';
26
+ import { mkdir } from 'fs/promises';
27
+ import { join, extname } from 'path';
28
+
29
+ const SERVE_DIR = process.argv[2];
30
+ const HTML_FILE = process.argv[3];
31
+ const FRAMES_DIR = process.argv[4];
32
+ const VP_WIDTH = parseInt(process.argv[5]) || 1080;
33
+ const VP_HEIGHT = parseInt(process.argv[6]) || 1080;
34
+ const DURATION_S = parseFloat(process.argv[7]) || 9;
35
+ const FPS = parseInt(process.argv[8]) || 30;
36
+
37
+ const DURATION_MS = DURATION_S * 1000;
38
+ const TOTAL_FRAMES = Math.round(DURATION_S * FPS);
39
+
40
+ // ─── Static file server ───────────────────────────────────────────────────────
41
+
42
+ const MIME_TYPES = {
43
+ '.html': 'text/html',
44
+ '.css': 'text/css',
45
+ '.js': 'application/javascript',
46
+ '.json': 'application/json',
47
+ '.png': 'image/png',
48
+ '.jpg': 'image/jpeg',
49
+ '.jpeg': 'image/jpeg',
50
+ '.svg': 'image/svg+xml',
51
+ '.woff': 'font/woff',
52
+ '.woff2':'font/woff2',
53
+ '.ttf': 'font/ttf',
54
+ };
55
+
56
+ const server = createServer((req, res) => {
57
+ const decoded = decodeURIComponent(req.url);
58
+ const filePath = join(SERVE_DIR, decoded === '/' ? HTML_FILE : decoded);
59
+ try {
60
+ const content = readFileSync(filePath);
61
+ const ext = extname(filePath).toLowerCase();
62
+ res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
63
+ res.end(content);
64
+ } catch {
65
+ res.writeHead(404);
66
+ res.end('Not found');
67
+ }
68
+ });
69
+
70
+ const port = await new Promise((resolve) => {
71
+ server.listen(0, () => resolve(server.address().port));
72
+ });
73
+
74
+ console.log(` Local server on port ${port}`);
75
+
76
+ // ─── Launch browser ───────────────────────────────────────────────────────────
77
+
78
+ const browser = await chromium.launch({
79
+ args: [
80
+ '--no-sandbox',
81
+ '--disable-dev-shm-usage',
82
+ '--font-render-hinting=none',
83
+ ]
84
+ });
85
+
86
+ const context = await browser.newContext({
87
+ viewport: { width: VP_WIDTH, height: VP_HEIGHT },
88
+ deviceScaleFactor: 2,
89
+ });
90
+
91
+ const page = await context.newPage();
92
+
93
+ const pageErrors = [];
94
+ page.on('console', msg => { if (msg.type() === 'error') pageErrors.push(msg.text()); });
95
+ page.on('pageerror', err => pageErrors.push(err.message));
96
+
97
+ // ─── Navigate and wait for ready ─────────────────────────────────────────────
98
+ // networkidle is safe now — no CSS animation timing race.
99
+ // renderFrame(t) is pure JS math; it doesn't care how long the page took to load.
100
+
101
+ await page.goto(`http://localhost:${port}/`, { waitUntil: 'networkidle' });
102
+ await page.evaluate(() => document.fonts.ready);
103
+
104
+ console.log(' Waiting for window.__videoReady...');
105
+ try {
106
+ await page.waitForFunction(() => window.__videoReady === true, { timeout: 15000 });
107
+ } catch {
108
+ const bodyHTML = await page.evaluate(() => document.body.innerHTML.substring(0, 500));
109
+ console.error(' ERROR: window.__videoReady was never set after 15s.');
110
+ if (pageErrors.length > 0) {
111
+ console.error(' Browser console errors:');
112
+ pageErrors.forEach(e => console.error(' ', e));
113
+ }
114
+ console.error(' Ensure your HTML contains:');
115
+ console.error(' window.__videoReady = false;');
116
+ console.error(' document.fonts.ready.then(() => {');
117
+ console.error(' window.renderFrame(0);');
118
+ console.error(' window.__videoReady = true;');
119
+ console.error(' });');
120
+ console.error(' Page body preview:', bodyHTML);
121
+ await browser.close();
122
+ server.close();
123
+ process.exit(1);
124
+ }
125
+
126
+ // Verify renderFrame exists
127
+ const hasRenderFrame = await page.evaluate(() => typeof window.renderFrame === 'function');
128
+ if (!hasRenderFrame) {
129
+ console.error(' ERROR: window.renderFrame is not a function.');
130
+ console.error(' The HTML must expose window.renderFrame(timeMs) for frame-accurate capture.');
131
+ console.error(' See SKILL.md Step 3 for the required HTML structure.');
132
+ await browser.close();
133
+ server.close();
134
+ process.exit(1);
135
+ }
136
+
137
+ console.log(' window.renderFrame confirmed — ready to capture');
138
+
139
+ // Stop browser preview rAF loop — it races with Playwright's evaluate/screenshot:
140
+ // between renderFrame(t) and screenshot(), rAF fires renderFrame(elapsed) where
141
+ // elapsed >> t, overwriting the correct frame state.
142
+ await page.evaluate(() => {
143
+ if (typeof window.__stopPreview === 'function') window.__stopPreview();
144
+ });
145
+ await page.waitForTimeout(100); // drain any in-flight rAF before capture
146
+
147
+ // ─── Capture frames ───────────────────────────────────────────────────────────
148
+
149
+ await mkdir(FRAMES_DIR, { recursive: true });
150
+
151
+ console.log(` Capturing ${TOTAL_FRAMES} frames (${DURATION_S}s @ ${FPS}fps)...`);
152
+
153
+ for (let f = 0; f < TOTAL_FRAMES; f++) {
154
+ const ms = (f / FPS) * 1000;
155
+
156
+ // Call renderFrame with the exact timestamp for this frame.
157
+ // The function directly sets element styles — no CSS animation state involved.
158
+ // Force synchronous style recalculation so screenshot captures the updated frame.
159
+ await page.evaluate((t) => {
160
+ window.renderFrame(t);
161
+ void document.body.offsetHeight; // Trigger synchronous reflow
162
+ }, ms);
163
+
164
+ // One rAF cycle for GPU compositing
165
+ await page.waitForTimeout(16);
166
+
167
+ const padded = String(f + 1).padStart(4, '0');
168
+ await page.screenshot({
169
+ path: join(FRAMES_DIR, `frame_${padded}.png`),
170
+ animations: 'disabled',
171
+ clip: { x: 0, y: 0, width: VP_WIDTH, height: VP_HEIGHT },
172
+ });
173
+
174
+ if ((f + 1) % FPS === 0 || f === TOTAL_FRAMES - 1) {
175
+ process.stdout.write(`\r Captured ${f + 1}/${TOTAL_FRAMES} frames`);
176
+ }
177
+ }
178
+
179
+ console.log('');
180
+
181
+ await browser.close();
182
+ server.close();
183
+
184
+ const firstFrame = join(FRAMES_DIR, 'frame_0001.png');
185
+ const frameSizeKB = Math.round(statSync(firstFrame).size / 1024);
186
+ console.log(` ✓ ${TOTAL_FRAMES} frames saved to ${FRAMES_DIR}`);
187
+ console.log(` Frame size: ${frameSizeKB}KB each (${VP_WIDTH * 2}×${VP_HEIGHT * 2}px retina)`);
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env bash
2
+ # export-video.sh — Render HTML/CSS motion graphic to MP4
3
+ #
4
+ # Usage:
5
+ # bash scripts/export-video.sh <path-to-html> [output.mp4] [options]
6
+ #
7
+ # Options:
8
+ # --duration N Total animation duration in seconds (required)
9
+ # --fps N Frames per second (default: 30)
10
+ # --width N Canvas width in pixels (default: 1080)
11
+ # --height N Canvas height in pixels (default: 1080)
12
+ # --music <file> Path to audio file to add as background track (mp3/m4a/wav)
13
+ #
14
+ # Examples:
15
+ # bash scripts/export-video.sh ./q4-growth/video.html --duration 9
16
+ # bash scripts/export-video.sh ./q4-growth/video.html output.mp4 --duration 9 --fps 30
17
+ # bash scripts/export-video.sh ./q4-growth/video.html --duration 12 --width 1080 --height 1920
18
+ # bash scripts/export-video.sh ./q4-growth/video.html --duration 9 --music bg.mp3
19
+ #
20
+ # What this does:
21
+ # 1. Checks Node.js and FFmpeg are installed
22
+ # 2. Installs Playwright in a temp dir (uses cache after first run)
23
+ # 3. Runs capture-frames.mjs — headless Chromium seeks Web Animations API frame-by-frame
24
+ # 4. Runs FFmpeg: PNG sequence → H.264 MP4 (-pix_fmt yuv420p for max compatibility)
25
+ # 5. Optional: second FFmpeg pass to mix in background audio
26
+ # 6. Cleans up frames, reports output
27
+ #
28
+ # Output PNG dimensions: 2× input (deviceScaleFactor: 2 retina)
29
+ # Output MP4 dimensions: same as PNG (2× specified width/height)
30
+ set -euo pipefail
31
+
32
+ # ─── Colors ──────────────────────────────────────────────────────────────────
33
+ RED='\033[0;31m'
34
+ GREEN='\033[0;32m'
35
+ CYAN='\033[0;36m'
36
+ YELLOW='\033[1;33m'
37
+ BOLD='\033[1m'
38
+ NC='\033[0m'
39
+
40
+ info() { echo -e "${CYAN}ℹ${NC} $*"; }
41
+ ok() { echo -e "${GREEN}✓${NC} $*"; }
42
+ warn() { echo -e "${YELLOW}⚠${NC} $*"; }
43
+ err() { echo -e "${RED}✗${NC} $*" >&2; }
44
+
45
+ # ─── Parse flags ─────────────────────────────────────────────────────────────
46
+ DURATION=""
47
+ FPS=30
48
+ WIDTH=1080
49
+ HEIGHT=1080
50
+ MUSIC=""
51
+
52
+ POSITIONAL=()
53
+ while [[ $# -gt 0 ]]; do
54
+ case $1 in
55
+ --duration) DURATION="$2"; shift 2 ;;
56
+ --fps) FPS="$2"; shift 2 ;;
57
+ --width) WIDTH="$2"; shift 2 ;;
58
+ --height) HEIGHT="$2"; shift 2 ;;
59
+ --music) MUSIC="$2"; shift 2 ;;
60
+ *) POSITIONAL+=("$1"); shift ;;
61
+ esac
62
+ done
63
+ set -- "${POSITIONAL[@]}"
64
+
65
+ # ─── Input validation ─────────────────────────────────────────────────────────
66
+
67
+ if [[ $# -lt 1 ]]; then
68
+ err "Usage: bash scripts/export-video.sh <path-to-html> [output.mp4] [--duration N] [--fps N] [--width N] [--height N] [--music audio.mp3]"
69
+ err ""
70
+ err "Examples:"
71
+ err " bash scripts/export-video.sh ./my-video/video.html --duration 9"
72
+ err " bash scripts/export-video.sh ./my-video/video.html output.mp4 --duration 12 --fps 30"
73
+ exit 1
74
+ fi
75
+
76
+ INPUT_HTML="$1"
77
+ if [[ ! -f "$INPUT_HTML" ]]; then
78
+ err "File not found: $INPUT_HTML"
79
+ exit 1
80
+ fi
81
+ INPUT_HTML=$(cd "$(dirname "$INPUT_HTML")" && pwd)/$(basename "$INPUT_HTML")
82
+
83
+ if [[ -z "$DURATION" ]]; then
84
+ err "--duration is required (total animation length in seconds)"
85
+ err "Example: --duration 9"
86
+ exit 1
87
+ fi
88
+
89
+ if [[ $# -ge 2 ]]; then
90
+ OUTPUT_MP4="$2"
91
+ else
92
+ OUTPUT_MP4="$(dirname "$INPUT_HTML")/$(basename "$INPUT_HTML" .html).mp4"
93
+ fi
94
+
95
+ OUTPUT_DIR=$(cd "$(dirname "$OUTPUT_MP4")" 2>/dev/null && pwd || { mkdir -p "$(dirname "$OUTPUT_MP4")" && cd "$(dirname "$OUTPUT_MP4")" && pwd; })
96
+ OUTPUT_MP4="$OUTPUT_DIR/$(basename "$OUTPUT_MP4")"
97
+
98
+ if [[ -n "$MUSIC" && ! -f "$MUSIC" ]]; then
99
+ err "Music file not found: $MUSIC"
100
+ exit 1
101
+ fi
102
+
103
+ TOTAL_FRAMES=$(echo "$DURATION * $FPS" | bc | cut -d. -f1)
104
+
105
+ echo ""
106
+ echo -e "${BOLD}╔══════════════════════════════════════╗${NC}"
107
+ echo -e "${BOLD}║ Export Motion Graphic to MP4 ║${NC}"
108
+ echo -e "${BOLD}╚══════════════════════════════════════╝${NC}"
109
+ echo ""
110
+ info "Animation: ${DURATION}s @ ${FPS}fps → ${TOTAL_FRAMES} frames"
111
+ info "Canvas: ${WIDTH}×${HEIGHT}px (MP4 output: $((WIDTH*2))×$((HEIGHT*2))px @2× retina)"
112
+ [[ -n "$MUSIC" ]] && info "Audio: $MUSIC"
113
+ echo ""
114
+
115
+ # ─── Step 1: Check dependencies ──────────────────────────────────────────────
116
+
117
+ info "Checking dependencies..."
118
+
119
+ if ! command -v node &>/dev/null; then
120
+ err "Node.js is required but not installed."
121
+ err ""
122
+ err "Install Node.js:"
123
+ err " macOS: brew install node"
124
+ err " or visit https://nodejs.org"
125
+ exit 1
126
+ fi
127
+ ok "Node.js found ($(node --version))"
128
+
129
+ if ! command -v ffmpeg &>/dev/null; then
130
+ err "FFmpeg is required but not installed."
131
+ err ""
132
+ err "Install FFmpeg:"
133
+ err " macOS: brew install ffmpeg"
134
+ err " Ubuntu: sudo apt install ffmpeg"
135
+ exit 1
136
+ fi
137
+ ok "FFmpeg found ($(ffmpeg -version 2>&1 | head -1 | cut -d' ' -f3))"
138
+
139
+ # ─── Step 2: Set up Node dependencies ────────────────────────────────────────
140
+
141
+ TEMP_DIR=$(mktemp -d)
142
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
143
+ FRAMES_DIR="$TEMP_DIR/frames"
144
+ TEMP_SCRIPT="$TEMP_DIR/capture-frames.mjs"
145
+
146
+ cp "$SCRIPT_DIR/capture-frames.mjs" "$TEMP_SCRIPT"
147
+
148
+ info "Setting up Playwright..."
149
+ cd "$TEMP_DIR"
150
+
151
+ cat > "$TEMP_DIR/package.json" << 'PKG'
152
+ { "name": "video-export", "private": true, "type": "module" }
153
+ PKG
154
+
155
+ npm install playwright 2>/dev/null || {
156
+ err "Failed to install Playwright."
157
+ rm -rf "$TEMP_DIR"
158
+ exit 1
159
+ }
160
+
161
+ npx playwright install chromium 2>/dev/null || {
162
+ err "Failed to install Chromium for Playwright."
163
+ rm -rf "$TEMP_DIR"
164
+ exit 1
165
+ }
166
+ ok "Playwright ready"
167
+ echo ""
168
+
169
+ # ─── Step 3: Capture frames ───────────────────────────────────────────────────
170
+
171
+ SERVE_DIR=$(dirname "$INPUT_HTML")
172
+ HTML_FILENAME=$(basename "$INPUT_HTML")
173
+
174
+ info "Capturing ${TOTAL_FRAMES} frames from ${HTML_FILENAME}..."
175
+ echo ""
176
+
177
+ node "$TEMP_SCRIPT" \
178
+ "$SERVE_DIR" \
179
+ "$HTML_FILENAME" \
180
+ "$FRAMES_DIR" \
181
+ "$WIDTH" \
182
+ "$HEIGHT" \
183
+ "$DURATION" \
184
+ "$FPS" || {
185
+ err "Frame capture failed."
186
+ rm -rf "$TEMP_DIR"
187
+ exit 1
188
+ }
189
+
190
+ echo ""
191
+ ok "Frames captured"
192
+ echo ""
193
+
194
+ # ─── Step 4: Assemble MP4 with FFmpeg ────────────────────────────────────────
195
+
196
+ SILENT_MP4="$TEMP_DIR/silent.mp4"
197
+
198
+ info "Assembling MP4 (H.264, yuv420p)..."
199
+
200
+ # -pix_fmt yuv420p: required for QuickTime / iOS / social platform compatibility
201
+ # -crf 20: high quality (18=lossless, 28=lower quality)
202
+ # -movflags +faststart: move metadata to file start (enables streaming)
203
+ ffmpeg -y \
204
+ -framerate "$FPS" \
205
+ -i "$FRAMES_DIR/frame_%04d.png" \
206
+ -c:v libx264 \
207
+ -crf 20 \
208
+ -pix_fmt yuv420p \
209
+ -movflags +faststart \
210
+ "$SILENT_MP4" 2>/dev/null || {
211
+ err "FFmpeg assembly failed."
212
+ rm -rf "$TEMP_DIR"
213
+ exit 1
214
+ }
215
+
216
+ ok "Video assembled (silent)"
217
+ echo ""
218
+
219
+ # ─── Step 5: Add audio (optional) ────────────────────────────────────────────
220
+
221
+ if [[ -n "$MUSIC" ]]; then
222
+ info "Adding background audio: $MUSIC"
223
+
224
+ # -c:v copy: no re-encode of video stream
225
+ # -c:a aac: encode audio as AAC (universal MP4 support)
226
+ # -shortest: truncate to shortest stream (video or audio)
227
+ ffmpeg -y \
228
+ -i "$SILENT_MP4" \
229
+ -i "$MUSIC" \
230
+ -c:v copy \
231
+ -c:a aac \
232
+ -b:a 192k \
233
+ -shortest \
234
+ "$OUTPUT_MP4" 2>/dev/null || {
235
+ warn "Audio mixing failed — saving silent version"
236
+ cp "$SILENT_MP4" "$OUTPUT_MP4"
237
+ }
238
+ ok "Audio mixed"
239
+ else
240
+ cp "$SILENT_MP4" "$OUTPUT_MP4"
241
+ fi
242
+
243
+ # ─── Step 6: Cleanup + report ─────────────────────────────────────────────────
244
+
245
+ rm -rf "$TEMP_DIR"
246
+
247
+ FILE_SIZE=$(du -h "$OUTPUT_MP4" | cut -f1 | xargs)
248
+
249
+ echo ""
250
+ echo -e "${BOLD}════════════════════════════════════════${NC}"
251
+ ok "MP4 exported successfully!"
252
+ echo ""
253
+ echo -e " ${BOLD}File:${NC} $OUTPUT_MP4"
254
+ echo -e " ${BOLD}Size:${NC} $FILE_SIZE"
255
+ echo -e " ${BOLD}Duration:${NC} ${DURATION}s @ ${FPS}fps"
256
+ echo ""
257
+ echo " Compatible with: QuickTime, iOS, Android, Twitter, LinkedIn, Instagram"
258
+ echo -e "${BOLD}════════════════════════════════════════${NC}"
259
+ echo ""
260
+
261
+ if command -v open &>/dev/null; then
262
+ open "$OUTPUT_MP4"
263
+ elif command -v xdg-open &>/dev/null; then
264
+ xdg-open "$OUTPUT_MP4"
265
+ fi