@opendirectory.dev/skills 0.1.60 → 0.1.62
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 +10 -0
- package/skills/vid-motion-graphics/README.md +89 -0
- package/skills/vid-motion-graphics/SKILL.md +332 -0
- package/skills/vid-motion-graphics/evals/evals.json +27 -0
- package/skills/vid-motion-graphics/references/scene-library.md +504 -0
- package/skills/vid-motion-graphics/references/style-presets.md +202 -0
- package/skills/vid-motion-graphics/scripts/capture-frames.mjs +187 -0
- package/skills/vid-motion-graphics/scripts/export-video.sh +265 -0
|
@@ -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
|