@opendirectory.dev/skills 0.1.67 → 0.1.69
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 +8 -0
- package/skills/vid-product-launch/README.md +153 -0
- package/skills/vid-product-launch/SKILL.md +713 -0
- package/skills/vid-product-launch/references/scene-library.md +896 -0
- package/skills/vid-product-launch/references/style-presets.md +180 -0
- package/skills/vid-product-launch/scripts/capture-frames.mjs +187 -0
- package/skills/vid-product-launch/scripts/export-video.sh +495 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# export-video.sh — Render product launch HTML 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: 1920)
|
|
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
|
+
# --letterbox Pass letterbox flag through to HTML (for cinematic tone)
|
|
14
|
+
# --both-orientations Generate 16:9 AND 9:16 versions from a single HTML file
|
|
15
|
+
#
|
|
16
|
+
# Examples:
|
|
17
|
+
# bash scripts/export-video.sh launch/gooseworks-ai/product-launch.html --duration 60
|
|
18
|
+
# bash scripts/export-video.sh launch/gooseworks-ai/product-launch.html --duration 60 --fps 30 --width 1920 --height 1080
|
|
19
|
+
# bash scripts/export-video.sh launch/gooseworks-ai/product-launch.html --duration 60 --music bg.mp3
|
|
20
|
+
# bash scripts/export-video.sh launch/gooseworks-ai/product-launch.html --duration 60 --both-orientations
|
|
21
|
+
#
|
|
22
|
+
# What this does:
|
|
23
|
+
# 1. Checks Node.js and FFmpeg are installed
|
|
24
|
+
# 2. Installs Playwright in a temp dir (uses cache after first run)
|
|
25
|
+
# 3. Runs capture-frames.mjs — headless Chromium calls renderFrame(t) per frame
|
|
26
|
+
# 4. Runs FFmpeg: PNG sequence → H.264 MP4 (-pix_fmt yuv420p for max compatibility)
|
|
27
|
+
# 5. Optional: second FFmpeg pass to mix in background audio
|
|
28
|
+
# 6. Cleans up frames, reports output
|
|
29
|
+
#
|
|
30
|
+
# Output PNG dimensions: 2× input (deviceScaleFactor: 2 retina)
|
|
31
|
+
# Output MP4 dimensions: same as PNG (2× specified width/height)
|
|
32
|
+
set -euo pipefail
|
|
33
|
+
|
|
34
|
+
# ─── Colors ──────────────────────────────────────────────────────────────────
|
|
35
|
+
RED='\033[0;31m'
|
|
36
|
+
GREEN='\033[0;32m'
|
|
37
|
+
CYAN='\033[0;36m'
|
|
38
|
+
YELLOW='\033[1;33m'
|
|
39
|
+
BOLD='\033[1m'
|
|
40
|
+
NC='\033[0m'
|
|
41
|
+
|
|
42
|
+
info() { echo -e "${CYAN}ℹ${NC} $*"; }
|
|
43
|
+
ok() { echo -e "${GREEN}✓${NC} $*"; }
|
|
44
|
+
warn() { echo -e "${YELLOW}⚠${NC} $*"; }
|
|
45
|
+
err() { echo -e "${RED}✗${NC} $*" >&2; }
|
|
46
|
+
|
|
47
|
+
# ─── Parse flags ─────────────────────────────────────────────────────────────
|
|
48
|
+
DURATION=""
|
|
49
|
+
FPS=30
|
|
50
|
+
WIDTH=1920
|
|
51
|
+
HEIGHT=1080
|
|
52
|
+
MUSIC=""
|
|
53
|
+
LETTERBOX=false
|
|
54
|
+
BOTH_ORIENTATIONS=false
|
|
55
|
+
|
|
56
|
+
POSITIONAL=()
|
|
57
|
+
while [[ $# -gt 0 ]]; do
|
|
58
|
+
case $1 in
|
|
59
|
+
--duration) DURATION="$2"; shift 2 ;;
|
|
60
|
+
--fps) FPS="$2"; shift 2 ;;
|
|
61
|
+
--width) WIDTH="$2"; shift 2 ;;
|
|
62
|
+
--height) HEIGHT="$2"; shift 2 ;;
|
|
63
|
+
--music) MUSIC="$2"; shift 2 ;;
|
|
64
|
+
--letterbox) LETTERBOX=true; shift ;;
|
|
65
|
+
--both-orientations) BOTH_ORIENTATIONS=true; shift ;;
|
|
66
|
+
*) POSITIONAL+=("$1"); shift ;;
|
|
67
|
+
esac
|
|
68
|
+
done
|
|
69
|
+
set -- "${POSITIONAL[@]}"
|
|
70
|
+
|
|
71
|
+
# ─── Input validation ─────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
if [[ $# -lt 1 ]]; then
|
|
74
|
+
err "Usage: bash scripts/export-video.sh <path-to-html> [output.mp4] [--duration N] [--fps N] [--width N] [--height N] [--music audio.mp3] [--letterbox] [--both-orientations]"
|
|
75
|
+
err ""
|
|
76
|
+
err "Examples:"
|
|
77
|
+
err " bash scripts/export-video.sh launch/my-product/product-launch.html --duration 60"
|
|
78
|
+
err " bash scripts/export-video.sh launch/my-product/product-launch.html output.mp4 --duration 60 --fps 30"
|
|
79
|
+
exit 1
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
INPUT_HTML="$1"
|
|
83
|
+
if [[ ! -f "$INPUT_HTML" ]]; then
|
|
84
|
+
err "File not found: $INPUT_HTML"
|
|
85
|
+
exit 1
|
|
86
|
+
fi
|
|
87
|
+
INPUT_HTML=$(cd "$(dirname "$INPUT_HTML")" && pwd)/$(basename "$INPUT_HTML")
|
|
88
|
+
|
|
89
|
+
if [[ -z "$DURATION" ]]; then
|
|
90
|
+
err "--duration is required (total animation length in seconds)"
|
|
91
|
+
err "Example: --duration 60"
|
|
92
|
+
exit 1
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
if [[ $# -ge 2 ]]; then
|
|
96
|
+
OUTPUT_MP4="$2"
|
|
97
|
+
else
|
|
98
|
+
OUTPUT_MP4="$(dirname "$INPUT_HTML")/product-launch.mp4"
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
OUTPUT_DIR=$(cd "$(dirname "$OUTPUT_MP4")" 2>/dev/null && pwd || { mkdir -p "$(dirname "$OUTPUT_MP4")" && cd "$(dirname "$OUTPUT_MP4")" && pwd; })
|
|
102
|
+
OUTPUT_MP4="$OUTPUT_DIR/$(basename "$OUTPUT_MP4")"
|
|
103
|
+
|
|
104
|
+
if [[ -n "$MUSIC" && ! -f "$MUSIC" ]]; then
|
|
105
|
+
err "Music file not found: $MUSIC"
|
|
106
|
+
exit 1
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
TOTAL_FRAMES=$(echo "$DURATION * $FPS" | bc | cut -d. -f1)
|
|
110
|
+
|
|
111
|
+
echo ""
|
|
112
|
+
echo -e "${BOLD}╔══════════════════════════════════════╗${NC}"
|
|
113
|
+
echo -e "${BOLD}║ Export Product Launch to MP4 ║${NC}"
|
|
114
|
+
echo -e "${BOLD}╚══════════════════════════════════════╝${NC}"
|
|
115
|
+
echo ""
|
|
116
|
+
info "Animation: ${DURATION}s @ ${FPS}fps → ${TOTAL_FRAMES} frames"
|
|
117
|
+
info "Canvas: ${WIDTH}×${HEIGHT}px (MP4 output: $((WIDTH*2))×$((HEIGHT*2))px @2× retina)"
|
|
118
|
+
[[ "$LETTERBOX" == "true" ]] && info "Letterbox: enabled (2.35:1)"
|
|
119
|
+
[[ "$BOTH_ORIENTATIONS" == "true" ]] && info "Both orientations: will export 16:9 + 9:16"
|
|
120
|
+
[[ -n "$MUSIC" ]] && info "Audio: $MUSIC"
|
|
121
|
+
echo ""
|
|
122
|
+
|
|
123
|
+
# ─── Step 1: Check dependencies ──────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
info "Checking dependencies..."
|
|
126
|
+
|
|
127
|
+
if ! command -v node &>/dev/null; then
|
|
128
|
+
err "Node.js is required but not installed."
|
|
129
|
+
err ""
|
|
130
|
+
err "Install Node.js:"
|
|
131
|
+
err " macOS: brew install node"
|
|
132
|
+
err " or visit https://nodejs.org"
|
|
133
|
+
exit 1
|
|
134
|
+
fi
|
|
135
|
+
ok "Node.js found ($(node --version))"
|
|
136
|
+
|
|
137
|
+
if ! command -v ffmpeg &>/dev/null; then
|
|
138
|
+
err "FFmpeg is required but not installed."
|
|
139
|
+
err ""
|
|
140
|
+
err "Install FFmpeg:"
|
|
141
|
+
err " macOS: brew install ffmpeg"
|
|
142
|
+
err " Ubuntu: sudo apt install ffmpeg"
|
|
143
|
+
exit 1
|
|
144
|
+
fi
|
|
145
|
+
ok "FFmpeg found ($(ffmpeg -version 2>&1 | head -1 | cut -d' ' -f3))"
|
|
146
|
+
|
|
147
|
+
# ─── Step 2: Set up Node dependencies ────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
TEMP_DIR=$(mktemp -d)
|
|
150
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
151
|
+
FRAMES_DIR="$TEMP_DIR/frames"
|
|
152
|
+
TEMP_SCRIPT="$TEMP_DIR/capture-frames.mjs"
|
|
153
|
+
|
|
154
|
+
cp "$SCRIPT_DIR/capture-frames.mjs" "$TEMP_SCRIPT"
|
|
155
|
+
|
|
156
|
+
info "Setting up Playwright..."
|
|
157
|
+
cd "$TEMP_DIR"
|
|
158
|
+
|
|
159
|
+
cat > "$TEMP_DIR/package.json" << 'PKG'
|
|
160
|
+
{ "name": "video-export", "private": true, "type": "module" }
|
|
161
|
+
PKG
|
|
162
|
+
|
|
163
|
+
npm install playwright 2>/dev/null || {
|
|
164
|
+
err "Failed to install Playwright."
|
|
165
|
+
rm -rf "$TEMP_DIR"
|
|
166
|
+
exit 1
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
npx playwright install chromium 2>/dev/null || {
|
|
170
|
+
err "Failed to install Chromium for Playwright."
|
|
171
|
+
rm -rf "$TEMP_DIR"
|
|
172
|
+
exit 1
|
|
173
|
+
}
|
|
174
|
+
ok "Playwright ready"
|
|
175
|
+
echo ""
|
|
176
|
+
|
|
177
|
+
# ─── SFX Synthesis ───────────────────────────────────────────────────────────
|
|
178
|
+
#
|
|
179
|
+
# All SFX synthesized natively with FFmpeg aevalsrc/anoisesrc.
|
|
180
|
+
# Zero external audio files needed.
|
|
181
|
+
|
|
182
|
+
synth_sfx_type() {
|
|
183
|
+
local sfx_type="$1"
|
|
184
|
+
local sfx_dir="$2"
|
|
185
|
+
local sfx_path="$sfx_dir/${sfx_type}.wav"
|
|
186
|
+
[[ -f "$sfx_path" ]] && return 0
|
|
187
|
+
|
|
188
|
+
case "$sfx_type" in
|
|
189
|
+
word-hit)
|
|
190
|
+
# Cinematic word impact — sub punch (50Hz) + transient click (2.2kHz) + noise burst
|
|
191
|
+
# Three layers mixed: felt body + sharp attack + air
|
|
192
|
+
ffmpeg -y \
|
|
193
|
+
-f lavfi -i "aevalsrc=0.85*sin(2*PI*50*t)*exp(-t/0.045)+0.45*sin(2*PI*120*t)*exp(-t/0.028):s=44100:d=0.18" \
|
|
194
|
+
-f lavfi -i "aevalsrc=0.55*sin(2*PI*2200*t)*exp(-t/0.005)+0.30*sin(2*PI*1100*t)*exp(-t/0.008):s=44100:d=0.02" \
|
|
195
|
+
-f lavfi -i "anoisesrc=s=44100:d=0.015" \
|
|
196
|
+
-filter_complex \
|
|
197
|
+
"[2:a]highpass=f=3500,lowpass=f=9000,volume=0.45,afade=t=out:st=0.008:d=0.007[burst];
|
|
198
|
+
[0:a][1:a]amix=inputs=2:normalize=0[body];
|
|
199
|
+
[body][burst]amix=inputs=2:normalize=0[out]" \
|
|
200
|
+
-map "[out]" -t 0.18 "$sfx_path" 2>/dev/null ;;
|
|
201
|
+
|
|
202
|
+
type-sequence)
|
|
203
|
+
# Typewriter loop — 15Hz narrow clicks for 1.9s (matches TYPE_DUR 1800ms)
|
|
204
|
+
# sin(t*PI*15)^30 creates 15Hz pulses each ~9ms wide — no multi-arg functions needed
|
|
205
|
+
# 1200Hz click + 200Hz body thud, bandpass 400–5000Hz for mechanical keyboard character
|
|
206
|
+
ffmpeg -y -f lavfi \
|
|
207
|
+
-i "aevalsrc=sin(t*PI*15)^30*(sin(t*2*PI*1200)*0.30+sin(t*2*PI*200)*0.18)*0.7:s=44100:d=1.9" \
|
|
208
|
+
-af "highpass=f=400,lowpass=f=5000" \
|
|
209
|
+
"$sfx_path" 2>/dev/null ;;
|
|
210
|
+
|
|
211
|
+
whoosh)
|
|
212
|
+
# Directional sweep — two-band noise (body + air), 700ms
|
|
213
|
+
ffmpeg -y \
|
|
214
|
+
-f lavfi -i "anoisesrc=s=44100:d=0.7" \
|
|
215
|
+
-f lavfi -i "anoisesrc=s=44100:d=0.7" \
|
|
216
|
+
-filter_complex \
|
|
217
|
+
"[0:a]bandpass=f=1100:width_type=h:width=900,volume=0.7,afade=t=in:st=0:d=0.06,afade=t=out:st=0.45:d=0.25[lo];
|
|
218
|
+
[1:a]highpass=f=4000,lowpass=f=8000,volume=0.3,afade=t=in:st=0:d=0.03,afade=t=out:st=0.20:d=0.15[hi];
|
|
219
|
+
[lo][hi]amix=inputs=2:normalize=0[out]" \
|
|
220
|
+
-map "[out]" -t 0.7 "$sfx_path" 2>/dev/null ;;
|
|
221
|
+
|
|
222
|
+
tension-riser)
|
|
223
|
+
# 2.8s ascending tension build — amplitude ramps from silence to peak at reveal-boom
|
|
224
|
+
# Layer 1: bandpass rumble (200Hz) growing louder via volume ramp
|
|
225
|
+
# Layer 2: sub tone (55Hz) with linear amplitude ramp t/2.8 → starts silent, peaks at end
|
|
226
|
+
ffmpeg -y \
|
|
227
|
+
-f lavfi -i "anoisesrc=s=44100:d=2.9" \
|
|
228
|
+
-f lavfi -i "aevalsrc=0.22*sin(2*PI*55*t)*(t/2.8):s=44100:d=2.9" \
|
|
229
|
+
-filter_complex \
|
|
230
|
+
"[0:a]bandpass=f=250:width_type=h:width=400,afade=t=in:st=0:d=1.8,afade=t=out:st=2.55:d=0.35[rumble];
|
|
231
|
+
[rumble]volume=0.45[rumble_v];
|
|
232
|
+
[1:a]afade=t=in:st=0:d=2.4[tone];
|
|
233
|
+
[rumble_v][tone]amix=inputs=2:normalize=0[out]" \
|
|
234
|
+
-map "[out]" -t 2.9 "$sfx_path" 2>/dev/null ;;
|
|
235
|
+
|
|
236
|
+
reveal-boom)
|
|
237
|
+
# Full cinematic impact — sub (45Hz) + body (90Hz) + high shimmer + reverb tail
|
|
238
|
+
# aecho adds 85ms tail at 0.25 decay — makes it feel cinematic not digital
|
|
239
|
+
ffmpeg -y \
|
|
240
|
+
-f lavfi -i "aevalsrc=0.90*sin(2*PI*45*t)*exp(-t/0.12)+0.50*sin(2*PI*90*t)*exp(-t/0.07)+0.25*sin(2*PI*180*t)*exp(-t/0.04):s=44100:d=0.6" \
|
|
241
|
+
-f lavfi -i "anoisesrc=s=44100:d=0.10" \
|
|
242
|
+
-filter_complex \
|
|
243
|
+
"[1:a]highpass=f=4500,lowpass=f=11000,volume=0.40,afade=t=out:st=0.05:d=0.05[shimmer];
|
|
244
|
+
[0:a][shimmer]amix=inputs=2:normalize=0[mix];
|
|
245
|
+
[mix]aecho=0.8:0.4:85:0.25[out]" \
|
|
246
|
+
-map "[out]" -t 0.9 "$sfx_path" 2>/dev/null ;;
|
|
247
|
+
|
|
248
|
+
counter-tick)
|
|
249
|
+
# Harmonic data tick — 880Hz + 440Hz + 1760Hz overtones, satisfying click
|
|
250
|
+
ffmpeg -y -f lavfi \
|
|
251
|
+
-i "aevalsrc=0.38*sin(2*PI*880*t)*exp(-t/0.010)+0.22*sin(2*PI*440*t)*exp(-t/0.018)+0.15*sin(2*PI*1760*t)*exp(-t/0.006)+0.08*sin(2*PI*2640*t)*exp(-t/0.004):s=44100:d=0.08" \
|
|
252
|
+
"$sfx_path" 2>/dev/null ;;
|
|
253
|
+
|
|
254
|
+
cta-chime)
|
|
255
|
+
# A major chord resolution — 440 + 554 + 659Hz + octave, musical arrival
|
|
256
|
+
# aecho adds 60ms shimmer tail at 0.35 decay — bell-like character, not digital blip
|
|
257
|
+
ffmpeg -y -f lavfi \
|
|
258
|
+
-i "aevalsrc=(0.38*sin(2*PI*440*t)+0.30*sin(2*PI*554*t)+0.25*sin(2*PI*659*t)+0.12*sin(2*PI*880*t))*exp(-t/0.60):s=44100:d=1.2" \
|
|
259
|
+
-af "aecho=0.8:0.88:60:0.35" \
|
|
260
|
+
"$sfx_path" 2>/dev/null ;;
|
|
261
|
+
|
|
262
|
+
*)
|
|
263
|
+
warn "Unknown SFX type: ${sfx_type} — skipping"
|
|
264
|
+
return 1 ;;
|
|
265
|
+
esac
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
add_sfx_to_video() {
|
|
269
|
+
local in_mp4="$1"
|
|
270
|
+
local out_mp4="$2"
|
|
271
|
+
local html_file="$3"
|
|
272
|
+
local music_file="${4:-}"
|
|
273
|
+
local sfx_dir="$TEMP_DIR/sfx"
|
|
274
|
+
|
|
275
|
+
mkdir -p "$sfx_dir"
|
|
276
|
+
|
|
277
|
+
# Write Node.js timeline extractor once
|
|
278
|
+
if [[ ! -f "$TEMP_DIR/extract-sfx.mjs" ]]; then
|
|
279
|
+
cat > "$TEMP_DIR/extract-sfx.mjs" << 'NODEEOF'
|
|
280
|
+
import { readFileSync } from 'node:fs';
|
|
281
|
+
const html = readFileSync(process.argv[2], 'utf-8');
|
|
282
|
+
const m = html.match(/window\.__sfxTimeline\s*=\s*(\[[\s\S]*?\]);/);
|
|
283
|
+
if (!m) process.exit(0);
|
|
284
|
+
try {
|
|
285
|
+
// Use Function() not JSON.parse — timeline uses JS syntax (single quotes, trailing commas, // comments)
|
|
286
|
+
const arr = new Function('return ' + m[1])();
|
|
287
|
+
arr.forEach(e =>
|
|
288
|
+
process.stdout.write(`${e.ms}|${e.sfx}|${e.vol !== undefined ? e.vol : 1.0}\n`)
|
|
289
|
+
);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
process.stderr.write('SFX parse error: ' + err.message + '\n');
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
NODEEOF
|
|
295
|
+
fi
|
|
296
|
+
|
|
297
|
+
# Parse SFX timeline from HTML
|
|
298
|
+
local events
|
|
299
|
+
events=$(node "$TEMP_DIR/extract-sfx.mjs" "$html_file" 2>/dev/null) || events=""
|
|
300
|
+
|
|
301
|
+
# No SFX timeline found — fall back to music-only or silent copy
|
|
302
|
+
if [[ -z "$events" ]]; then
|
|
303
|
+
warn "No SFX timeline found in HTML (add window.__sfxTimeline to enable SFX)"
|
|
304
|
+
if [[ -n "$music_file" ]]; then
|
|
305
|
+
info "Adding background audio: $music_file"
|
|
306
|
+
ffmpeg -y -i "$in_mp4" -i "$music_file" \
|
|
307
|
+
-c:v copy -c:a aac -b:a 192k -shortest \
|
|
308
|
+
"$out_mp4" 2>/dev/null || { warn "Audio mix failed — saving silent"; cp "$in_mp4" "$out_mp4"; }
|
|
309
|
+
ok "Audio mixed"
|
|
310
|
+
else
|
|
311
|
+
cp "$in_mp4" "$out_mp4"
|
|
312
|
+
fi
|
|
313
|
+
return
|
|
314
|
+
fi
|
|
315
|
+
|
|
316
|
+
local event_count
|
|
317
|
+
event_count=$(echo "$events" | grep -c '.' 2>/dev/null) || event_count=0
|
|
318
|
+
info "Synthesizing SFX: ${event_count} events..."
|
|
319
|
+
|
|
320
|
+
# Synthesize each unique SFX type
|
|
321
|
+
local synthesized=()
|
|
322
|
+
while IFS='|' read -r ms sfx vol; do
|
|
323
|
+
[[ -z "$sfx" ]] && continue
|
|
324
|
+
if ! printf '%s\n' "${synthesized[@]:-}" | grep -qx "$sfx"; then
|
|
325
|
+
synthesized+=("$sfx")
|
|
326
|
+
synth_sfx_type "$sfx" "$sfx_dir"
|
|
327
|
+
fi
|
|
328
|
+
done <<< "$events"
|
|
329
|
+
|
|
330
|
+
# Build FFmpeg inputs + filter_complex
|
|
331
|
+
local ffmpeg_inputs=()
|
|
332
|
+
local filter_parts=()
|
|
333
|
+
local mix_labels=()
|
|
334
|
+
local input_idx=1 # 0 = in_mp4
|
|
335
|
+
|
|
336
|
+
if [[ -n "$music_file" ]]; then
|
|
337
|
+
ffmpeg_inputs+=("-i" "$music_file")
|
|
338
|
+
filter_parts+=("[1:a]volume=0.22[bg_music]")
|
|
339
|
+
mix_labels+=("[bg_music]")
|
|
340
|
+
input_idx=2
|
|
341
|
+
fi
|
|
342
|
+
|
|
343
|
+
local event_idx=0
|
|
344
|
+
while IFS='|' read -r ms sfx vol; do
|
|
345
|
+
[[ -z "$sfx" ]] && continue
|
|
346
|
+
local sfx_path="$sfx_dir/${sfx}.wav"
|
|
347
|
+
[[ ! -f "$sfx_path" ]] && continue
|
|
348
|
+
|
|
349
|
+
ffmpeg_inputs+=("-i" "$sfx_path")
|
|
350
|
+
filter_parts+=("[${input_idx}:a]volume=${vol},adelay=${ms}|${ms}[s${event_idx}]")
|
|
351
|
+
mix_labels+=("[s${event_idx}]")
|
|
352
|
+
input_idx=$((input_idx + 1))
|
|
353
|
+
event_idx=$((event_idx + 1))
|
|
354
|
+
done <<< "$events"
|
|
355
|
+
|
|
356
|
+
if [[ $event_idx -eq 0 && ${#mix_labels[@]} -eq 0 ]]; then
|
|
357
|
+
cp "$in_mp4" "$out_mp4"
|
|
358
|
+
return
|
|
359
|
+
fi
|
|
360
|
+
|
|
361
|
+
local total_inputs=${#mix_labels[@]}
|
|
362
|
+
local all_labels
|
|
363
|
+
printf -v all_labels '%s' "${mix_labels[@]}"
|
|
364
|
+
filter_parts+=("${all_labels}amix=inputs=${total_inputs}:normalize=0:dropout_transition=0[finalsfx]")
|
|
365
|
+
|
|
366
|
+
local filter_complex
|
|
367
|
+
filter_complex=$(IFS=';'; echo "${filter_parts[*]}")
|
|
368
|
+
|
|
369
|
+
ffmpeg -y \
|
|
370
|
+
-i "$in_mp4" \
|
|
371
|
+
"${ffmpeg_inputs[@]}" \
|
|
372
|
+
-filter_complex "$filter_complex" \
|
|
373
|
+
-map 0:v \
|
|
374
|
+
-map "[finalsfx]" \
|
|
375
|
+
-c:v copy \
|
|
376
|
+
-c:a aac -b:a 192k \
|
|
377
|
+
"$out_mp4" 2>/dev/null || {
|
|
378
|
+
warn "SFX mixing failed — saving without SFX"
|
|
379
|
+
if [[ -n "$music_file" ]]; then
|
|
380
|
+
ffmpeg -y -i "$in_mp4" -i "$music_file" \
|
|
381
|
+
-c:v copy -c:a aac -b:a 192k -shortest \
|
|
382
|
+
"$out_mp4" 2>/dev/null || cp "$in_mp4" "$out_mp4"
|
|
383
|
+
else
|
|
384
|
+
cp "$in_mp4" "$out_mp4"
|
|
385
|
+
fi
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
ok "SFX mixed: ${event_idx} events"
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
# ─── Helper: render one orientation ──────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
render_orientation() {
|
|
395
|
+
local w="$1"
|
|
396
|
+
local h="$2"
|
|
397
|
+
local out_mp4="$3"
|
|
398
|
+
local frames_subdir="$TEMP_DIR/frames_${w}x${h}"
|
|
399
|
+
|
|
400
|
+
info "Capturing ${TOTAL_FRAMES} frames at ${w}×${h}..."
|
|
401
|
+
echo ""
|
|
402
|
+
|
|
403
|
+
node "$TEMP_SCRIPT" \
|
|
404
|
+
"$(dirname "$INPUT_HTML")" \
|
|
405
|
+
"$(basename "$INPUT_HTML")" \
|
|
406
|
+
"$frames_subdir" \
|
|
407
|
+
"$w" \
|
|
408
|
+
"$h" \
|
|
409
|
+
"$DURATION" \
|
|
410
|
+
"$FPS" || {
|
|
411
|
+
err "Frame capture failed (${w}×${h})."
|
|
412
|
+
rm -rf "$TEMP_DIR"
|
|
413
|
+
exit 1
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
echo ""
|
|
417
|
+
ok "Frames captured (${w}×${h})"
|
|
418
|
+
echo ""
|
|
419
|
+
|
|
420
|
+
local silent_mp4="$TEMP_DIR/silent_${w}x${h}.mp4"
|
|
421
|
+
|
|
422
|
+
info "Assembling MP4 (H.264, yuv420p) → ${out_mp4}..."
|
|
423
|
+
|
|
424
|
+
ffmpeg -y \
|
|
425
|
+
-framerate "$FPS" \
|
|
426
|
+
-i "$frames_subdir/frame_%04d.png" \
|
|
427
|
+
-c:v libx264 \
|
|
428
|
+
-crf 20 \
|
|
429
|
+
-pix_fmt yuv420p \
|
|
430
|
+
-movflags +faststart \
|
|
431
|
+
"$silent_mp4" 2>/dev/null || {
|
|
432
|
+
err "FFmpeg assembly failed (${w}×${h})."
|
|
433
|
+
rm -rf "$TEMP_DIR"
|
|
434
|
+
exit 1
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
ok "Video assembled (silent)"
|
|
438
|
+
echo ""
|
|
439
|
+
|
|
440
|
+
add_sfx_to_video "$silent_mp4" "$out_mp4" "$INPUT_HTML" "${MUSIC}"
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
# ─── Step 3 + 4 + 5: Capture + assemble ──────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
if [[ "$BOTH_ORIENTATIONS" == "true" ]]; then
|
|
446
|
+
# 16:9 landscape
|
|
447
|
+
LANDSCAPE_MP4="${OUTPUT_MP4%.mp4}-16x9.mp4"
|
|
448
|
+
render_orientation 1920 1080 "$LANDSCAPE_MP4"
|
|
449
|
+
|
|
450
|
+
# 9:16 portrait
|
|
451
|
+
PORTRAIT_MP4="${OUTPUT_MP4%.mp4}-9x16.mp4"
|
|
452
|
+
render_orientation 1080 1920 "$PORTRAIT_MP4"
|
|
453
|
+
else
|
|
454
|
+
render_orientation "$WIDTH" "$HEIGHT" "$OUTPUT_MP4"
|
|
455
|
+
fi
|
|
456
|
+
|
|
457
|
+
# ─── Step 6: Cleanup + report ─────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
rm -rf "$TEMP_DIR"
|
|
460
|
+
|
|
461
|
+
echo ""
|
|
462
|
+
echo -e "${BOLD}════════════════════════════════════════${NC}"
|
|
463
|
+
ok "Product launch video exported!"
|
|
464
|
+
echo ""
|
|
465
|
+
|
|
466
|
+
if [[ "$BOTH_ORIENTATIONS" == "true" ]]; then
|
|
467
|
+
LANDSCAPE_SIZE=$(du -h "$LANDSCAPE_MP4" | cut -f1 | xargs)
|
|
468
|
+
PORTRAIT_SIZE=$(du -h "$PORTRAIT_MP4" | cut -f1 | xargs)
|
|
469
|
+
echo -e " ${BOLD}16:9 (landscape):${NC} $LANDSCAPE_MP4 (${LANDSCAPE_SIZE})"
|
|
470
|
+
echo -e " ${BOLD}9:16 (portrait):${NC} $PORTRAIT_MP4 (${PORTRAIT_SIZE})"
|
|
471
|
+
else
|
|
472
|
+
FILE_SIZE=$(du -h "$OUTPUT_MP4" | cut -f1 | xargs)
|
|
473
|
+
echo -e " ${BOLD}File:${NC} $OUTPUT_MP4"
|
|
474
|
+
echo -e " ${BOLD}Size:${NC} $FILE_SIZE"
|
|
475
|
+
fi
|
|
476
|
+
|
|
477
|
+
echo -e " ${BOLD}Duration:${NC} ${DURATION}s @ ${FPS}fps"
|
|
478
|
+
echo ""
|
|
479
|
+
echo " Compatible with: QuickTime, iOS, Android, Twitter, LinkedIn, Instagram"
|
|
480
|
+
echo -e "${BOLD}════════════════════════════════════════${NC}"
|
|
481
|
+
echo ""
|
|
482
|
+
|
|
483
|
+
if command -v open &>/dev/null; then
|
|
484
|
+
if [[ "$BOTH_ORIENTATIONS" == "true" ]]; then
|
|
485
|
+
open "$LANDSCAPE_MP4"
|
|
486
|
+
else
|
|
487
|
+
open "$OUTPUT_MP4"
|
|
488
|
+
fi
|
|
489
|
+
elif command -v xdg-open &>/dev/null; then
|
|
490
|
+
if [[ "$BOTH_ORIENTATIONS" == "true" ]]; then
|
|
491
|
+
xdg-open "$LANDSCAPE_MP4"
|
|
492
|
+
else
|
|
493
|
+
xdg-open "$OUTPUT_MP4"
|
|
494
|
+
fi
|
|
495
|
+
fi
|