@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.
@@ -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