feat: Import 35+ skills, merge duplicates, add openclaw installer

Major updates:
- Added 35+ new skills from awesome-opencode-skills and antigravity repos
- Merged SEO skills into seo-master
- Merged architecture skills into architecture
- Merged security skills into security-auditor and security-coder
- Merged testing skills into testing-master and testing-patterns
- Merged pentesting skills into pentesting
- Renamed website-creator to thai-frontend-dev
- Replaced skill-creator with github version
- Removed Chutes references (use MiniMax API instead)
- Added install-openclaw-skills.sh for cross-platform installation
- Updated .env.example with MiniMax API credentials
This commit is contained in:
Kunthawat Greethong
2026-03-26 11:37:39 +07:00
parent 48595100a1
commit 7edf5bc4d0
469 changed files with 131580 additions and 417 deletions

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env bash
# Add background music to a video file (pure bash)
#
# Usage:
# bash scripts/video/add_bgm.sh --video input.mp4 --audio bgm.mp3 -o output.mp4
# bash scripts/video/add_bgm.sh --video input.mp4 --generate-bgm --music-prompt "upbeat pop" -o output.mp4
# bash scripts/video/add_bgm.sh --video input.mp4 --audio bgm.mp3 --replace-audio -o output.mp4
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
MUSIC_API_URL="${MINIMAX_API_HOST:-https://api.minimaxi.com}/v1/music_generation"
# ============================================================================
# Common functions
# ============================================================================
load_env() {
local env_file
for env_file in "$PROJECT_ROOT/.env" "$(pwd)/.env"; do
if [[ -f "$env_file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"; line="$(echo "$line" | xargs)"
[[ -z "$line" || "$line" != *=* ]] && continue
local key="${line%%=*}" val="${line#*=}"
key="$(echo "$key" | xargs)"; val="$(echo "$val" | xargs)"
if [[ ${#val} -ge 2 ]]; then
case "$val" in \"*\") val="${val:1:${#val}-2}" ;; \'*\') val="${val:1:${#val}-2}" ;; esac
fi
[[ -z "${!key:-}" ]] && export "$key=$val"
done < "$env_file"
fi
done
}
get_video_duration() {
ffprobe -v error -show_entries format=duration -of json "$1" 2>/dev/null | jq -r '.format.duration'
}
video_has_audio() {
local out
out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$1" 2>/dev/null)"
[[ "$out" == *audio* ]]
}
generate_music() {
local prompt="$1" output_path="$2" instrumental="${3:-false}"
local payload
local effective_prompt="${prompt:-background music, cinematic, ambient}"
if [[ "$instrumental" == "true" ]]; then
payload=$(jq -n \
--arg p "$effective_prompt. pure music, no lyrics" \
'{model: "music-2.5", prompt: $p, lyrics: "[intro] [outro]", output_format: "url"}')
else
payload=$(jq -n \
--arg p "$effective_prompt" \
'{model: "music-2.5", prompt: $p, lyrics: "[Intro]\nla da da\nla la la", output_format: "url"}')
fi
echo "Generating ${instrumental:+instrumental }music..."
echo " Prompt: $prompt"
local raw http_code response
raw="$(curl -s -w "\n%{http_code}" -X POST "$MUSIC_API_URL" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
-H "Content-Type: application/json" \
--max-time 300 -d "$payload")"
http_code="${raw##*$'\n'}"; response="${raw%$'\n'*}"
[[ "$http_code" -ge 400 ]] 2>/dev/null && { echo "Error: Music API HTTP $http_code" >&2; return 1; }
local sc
sc="$(echo "$response" | jq -r '.base_resp.status_code // 0')" 2>/dev/null || true
[[ "$sc" != "0" && -n "$sc" ]] && { echo "Error: Music API error: $(echo "$response" | jq '.base_resp')" >&2; return 1; }
local audio_url
audio_url="$(echo "$response" | jq -r '.data.audio_url // .data.audio // .data.audio_file.download_url // empty')"
[[ -z "$audio_url" ]] && { echo "Error: No audio URL in music response" >&2; return 1; }
mkdir -p "$(dirname "$output_path")"
# Download with retry
local attempt
for attempt in 1 2 3; do
if curl -s -o "$output_path" --max-time 120 "$audio_url" 2>/dev/null; then
local size; size="$(wc -c < "$output_path" | tr -d ' ')"
echo " Downloaded: $output_path ($size bytes)"
return 0
fi
if [[ $attempt -lt 3 ]]; then
local wait=$((2 ** attempt))
echo " Download attempt $attempt failed. Retrying in ${wait}s..."
sleep "$wait"
fi
done
echo "Error: Download failed after 3 attempts" >&2
return 1
}
# ============================================================================
# Main
# ============================================================================
main() {
load_env
local video="" audio="" output=""
local generate_bgm=false instrumental=false music_prompt=""
local bgm_volume=0.3 fade_in=0 fade_out=0 replace_audio=false
while [[ $# -gt 0 ]]; do
case "$1" in
--video) video="$2"; shift 2 ;;
--audio) audio="$2"; shift 2 ;;
--generate-bgm) generate_bgm=true; shift ;;
--instrumental) instrumental=true; shift ;;
--music-prompt) music_prompt="$2"; shift 2 ;;
--bgm-volume) bgm_volume="$2"; shift 2 ;;
--fade-in) fade_in="$2"; shift 2 ;;
--fade-out) fade_out="$2"; shift 2 ;;
--replace-audio) replace_audio=true; shift ;;
-o|--output) output="$2"; shift 2 ;;
-h|--help)
cat <<'USAGE'
Add Background Music to Video
Usage:
add_bgm.sh --video INPUT --audio BGM -o OUTPUT
add_bgm.sh --video INPUT --generate-bgm --music-prompt "style" -o OUTPUT
Options:
--video FILE Input video file (required)
--audio FILE Background music audio file
--generate-bgm Generate BGM via MiniMax API
--instrumental Make generated BGM instrumental
--music-prompt TEXT Prompt for BGM generation
--bgm-volume FLOAT BGM volume level (default: 0.3)
--fade-in SECS BGM fade-in duration
--fade-out SECS BGM fade-out duration
--replace-audio Replace original audio instead of mixing
-o, --output FILE Output video file (required)
Examples:
add_bgm.sh --video input.mp4 --audio bgm.mp3 -o output.mp4
add_bgm.sh --video input.mp4 --generate-bgm --music-prompt "upbeat pop" -o output.mp4
add_bgm.sh --video input.mp4 --audio bgm.mp3 --replace-audio -o output.mp4
USAGE
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [[ -z "$video" || ! -f "$video" ]]; then
echo "Error: Video file not found: ${video:-<none>}" >&2; exit 1
fi
if [[ -z "$audio" && "$generate_bgm" != "true" ]]; then
echo "Error: Provide --audio or --generate-bgm" >&2; exit 1
fi
if [[ -z "$output" ]]; then
echo "Error: --output / -o is required" >&2; exit 1
fi
local audio_path="$audio"
if $generate_bgm; then
if [[ -z "${MINIMAX_API_KEY:-}" ]]; then
echo "Error: MINIMAX_API_KEY not set." >&2; exit 1
fi
audio_path="${output%.*}_bgm.mp3"
generate_music "$music_prompt" "$audio_path" "$instrumental" || exit 1
fi
if [[ ! -f "$audio_path" ]]; then
echo "Error: Audio file not found: $audio_path" >&2; exit 1
fi
local duration
duration="$(get_video_duration "$video")"
echo "Video duration: $(printf '%.1f' "$duration")s"
mkdir -p "$(dirname "$output")"
local has_audio=false
video_has_audio "$video" && has_audio=true
local bgm_filter="[1:a]volume=${bgm_volume}"
[[ "$(echo "$fade_in > 0" | bc -l)" == "1" ]] && bgm_filter+=",afade=t=in:d=${fade_in}"
if [[ "$(echo "$fade_out > 0" | bc -l)" == "1" ]]; then
local fo_start
fo_start="$(echo "$duration - $fade_out" | bc -l)"
[[ "$(echo "$fo_start < 0" | bc -l)" == "1" ]] && fo_start=0
bgm_filter+=",afade=t=out:st=${fo_start}:d=${fade_out}"
fi
if $has_audio && ! $replace_audio; then
bgm_filter+="[bgm];[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout]"
echo "Merging video + audio (mixing with original, bgm_volume=${bgm_volume})..."
ffmpeg -y \
-i "$video" -i "$audio_path" \
-filter_complex "$bgm_filter" \
-map 0:v -map "[aout]" \
-c:v copy -c:a aac -shortest "$output" 2>/dev/null
else
bgm_filter+="[bgm]"
echo "Merging video + audio (${replace_audio:+replacing original}${replace_audio:-no original audio}, bgm_volume=${bgm_volume})..."
ffmpeg -y \
-i "$video" -i "$audio_path" \
-filter_complex "$bgm_filter" \
-map 0:v -map "[bgm]" \
-c:v copy -c:a aac -shortest "$output" 2>/dev/null
fi
echo "Output saved: $output"
echo "Done!"
}
main "$@"

View File

@@ -0,0 +1,479 @@
#!/usr/bin/env bash
# MiniMax Long Video Generation CLI (pure bash)
#
# Generates multi-segment videos by chaining scenes together.
# Each segment's last frame becomes the next segment's first frame.
# Optionally adds AI-generated background music.
#
# Usage:
# bash scripts/video/generate_long_video.sh \
# --scenes "A sunrise" "Birds flying" "A calm lake" \
# --output output/long_video.mp4
#
# bash scripts/video/generate_long_video.sh \
# --scenes "A robot waking up" "The robot walks outside" \
# --music-prompt "cinematic orchestral" \
# --output output/robot_story.mp4
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
API_BASE="${MINIMAX_API_HOST:-https://api.minimaxi.com}/v1"
MUSIC_API_URL="${API_BASE}/music_generation"
POLL_INTERVAL=10
MAX_WAIT_TIME=600
REQUEST_TIMEOUT=60
MAX_CONSECUTIVE_FAILURES=5
# ============================================================================
# Common functions
# ============================================================================
load_env() {
local env_file
for env_file in "$PROJECT_ROOT/.env" "$(pwd)/.env"; do
if [[ -f "$env_file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"; line="$(echo "$line" | xargs)"
[[ -z "$line" || "$line" != *=* ]] && continue
local key="${line%%=*}" val="${line#*=}"
key="$(echo "$key" | xargs)"; val="$(echo "$val" | xargs)"
if [[ ${#val} -ge 2 ]]; then
case "$val" in \"*\") val="${val:1:${#val}-2}" ;; \'*\') val="${val:1:${#val}-2}" ;; esac
fi
[[ -z "${!key:-}" ]] && export "$key=$val"
done < "$env_file"
fi
done
}
check_api_key() {
if [[ -z "${MINIMAX_API_KEY:-}" ]]; then
echo "Error: MINIMAX_API_KEY not set." >&2; exit 1
fi
}
image_to_data_url() {
local path="$1"
[[ -f "$path" ]] || { echo "Error: Image not found: $path" >&2; exit 1; }
local mime; mime="$(file -b --mime-type "$path" 2>/dev/null)" || mime="image/jpeg"
local b64; b64="$(base64 < "$path")"
echo "data:${mime};base64,${b64}"
}
resolve_image() {
local input="$1"
[[ -z "$input" ]] && return
case "$input" in
http://*|https://*|data:*) echo "$input" ;;
*) image_to_data_url "$input" ;;
esac
}
# ============================================================================
# Video API helpers (duplicated from generate_video.sh for standalone use)
# ============================================================================
_create_task() {
local payload="$1"
local raw http_code response
raw="$(curl -s -w "\n%{http_code}" -X POST "${API_BASE}/video_generation" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
-H "Content-Type: application/json" \
--max-time "$REQUEST_TIMEOUT" -d "$payload")"
http_code="${raw##*$'\n'}"; response="${raw%$'\n'*}"
[[ "$http_code" -ge 400 ]] 2>/dev/null && { echo "Error: HTTP $http_code" >&2; echo "$response" >&2; exit 1; }
local sc; sc="$(echo "$response" | jq -r '.base_resp.status_code // 0')" 2>/dev/null || true
[[ "$sc" != "0" && -n "$sc" ]] && { echo "Error: $(echo "$response" | jq '.base_resp')" >&2; exit 1; }
echo "$response" | jq -r '.task_id // empty'
}
_poll_task() {
local task_id="$1" start_time cf=0
start_time="$(date +%s)"
while true; do
local now=$(($(date +%s) - start_time))
[[ $now -gt $MAX_WAIT_TIME ]] && { echo "Error: Timeout" >&2; exit 1; }
local raw http_code response
if raw="$(curl -s -w "\n%{http_code}" -G "${API_BASE}/query/video_generation" \
-d "task_id=$task_id" -H "Authorization: Bearer ${MINIMAX_API_KEY}" \
--max-time "$REQUEST_TIMEOUT" 2>/dev/null)"; then
http_code="${raw##*$'\n'}"; response="${raw%$'\n'*}"; cf=0
else
cf=$((cf+1)); [[ $cf -ge $MAX_CONSECUTIVE_FAILURES ]] && { echo "Error: Too many failures" >&2; exit 1; }
sleep "$POLL_INTERVAL"; continue
fi
local status; status="$(echo "$response" | jq -r '.status // "Unknown"')"
echo " [${now}s] Status: $status" >&2
[[ "$status" == "Success" ]] && { echo "$response" | jq -r '.file_id // empty'; return 0; }
[[ "$status" == "Fail" || "$status" == "Failed" || "$status" == "Error" ]] && { echo "Error: Task failed" >&2; exit 1; }
sleep "$POLL_INTERVAL"
done
}
_download_video() {
local file_id="$1" output_path="$2"
local raw; raw="$(curl -s -G "${API_BASE}/files/retrieve" -d "file_id=$file_id" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" --max-time "$REQUEST_TIMEOUT")"
local dl_url; dl_url="$(echo "$raw" | jq -r '.file.download_url // empty')"
[[ -z "$dl_url" ]] && { echo "Error: No download_url" >&2; exit 1; }
mkdir -p "$(dirname "$output_path")"
curl -s -o "$output_path" --max-time $((REQUEST_TIMEOUT * 3)) "$dl_url"
echo " Video saved: $output_path ($(wc -c < "$output_path" | tr -d ' ') bytes)" >&2
}
# ============================================================================
# FFmpeg helpers
# ============================================================================
get_video_duration() {
ffprobe -v error -show_entries format=duration -of json "$1" 2>/dev/null | jq -r '.format.duration'
}
get_video_fps() {
local fps_str
fps_str="$(ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 "$1" 2>/dev/null)" || { echo 25; return; }
local num den
num="${fps_str%/*}"; den="${fps_str#*/}"
echo $(( (num + den/2) / den )) 2>/dev/null || echo 25
}
video_has_audio() {
local out
out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$1" 2>/dev/null)"
[[ "$out" == *audio* ]]
}
extract_last_frame() {
local video_path="$1" output_image="$2"
# Try frame-accurate method with sseof fallback
if ! ffmpeg -y -sseof -0.04 -i "$video_path" -frames:v 1 -q:v 2 "$output_image" 2>/dev/null; then
echo "Warning: Could not extract last frame" >&2
return 1
fi
[[ -f "$output_image" ]] || return 1
echo " Extracted last frame: $output_image" >&2
}
concatenate_videos() {
local output_path="$1" crossfade="$2"
shift 2
local video_paths=("$@")
local n=${#video_paths[@]}
if [[ $n -eq 1 ]]; then
cp "${video_paths[0]}" "$output_path"
return 0
fi
local fps
fps="$(get_video_fps "${video_paths[0]}")"
local has_audio=true
for vp in "${video_paths[@]}"; do
video_has_audio "$vp" || { has_audio=false; break; }
done
if [[ "$(echo "$crossfade > 0" | bc -l)" == "1" ]]; then
# Get durations
local durations=()
for vp in "${video_paths[@]}"; do
durations+=("$(get_video_duration "$vp")")
done
# Build inputs
local inputs=()
for vp in "${video_paths[@]}"; do
inputs+=(-i "$(cd "$(dirname "$vp")" && pwd)/$(basename "$vp")")
done
# Calculate offsets
local offsets=() cumulative=0
for ((i=0; i<n-1; i++)); do
local offset
offset="$(echo "$cumulative + ${durations[$i]} - $crossfade" | bc -l)"
offsets+=("$offset")
cumulative="$offset"
done
# Build filter
local vf_parts=() af_parts=()
if [[ $n -eq 2 ]]; then
vf_parts+=("[0:v][1:v]xfade=transition=fade:duration=${crossfade}:offset=${offsets[0]}[vout]")
$has_audio && af_parts+=("[0:a][1:a]acrossfade=d=${crossfade}:c1=tri:c2=tri[aout]")
else
vf_parts+=("[0:v][1:v]xfade=transition=fade:duration=${crossfade}:offset=${offsets[0]}[xv1]")
$has_audio && af_parts+=("[0:a][1:a]acrossfade=d=${crossfade}:c1=tri:c2=tri[xa1]")
for ((i=2; i<n; i++)); do
local out_v="[xv${i}]" out_a="[xa${i}]"
[[ $i -eq $((n-1)) ]] && { out_v="[vout]"; out_a="[aout]"; }
vf_parts+=("[xv$((i-1))][${i}:v]xfade=transition=fade:duration=${crossfade}:offset=${offsets[$((i-1))]}${out_v}")
$has_audio && af_parts+=("[xa$((i-1))][${i}:a]acrossfade=d=${crossfade}:c1=tri:c2=tri${out_a}")
done
fi
local filter_complex
filter_complex="$(IFS=';'; echo "${vf_parts[*]}${af_parts[*]:+;${af_parts[*]}}")"
local cmd=(ffmpeg -y "${inputs[@]}" -filter_complex "$filter_complex" -map "[vout]")
$has_audio && cmd+=(-map "[aout]")
cmd+=(-c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p -r "$fps")
$has_audio && cmd+=(-c:a aac -b:a 192k)
cmd+=("$output_path")
if "${cmd[@]}" 2>/dev/null; then
echo "Concatenated $n segments -> $output_path" >&2
return 0
fi
echo " Crossfade failed, falling back to re-encode concat..." >&2
fi
# Fallback: concat demuxer with re-encode
local concat_file
concat_file="$(mktemp /tmp/concat_XXXXXX.txt)"
for vp in "${video_paths[@]}"; do
echo "file '$(cd "$(dirname "$vp")" && pwd)/$(basename "$vp")'" >> "$concat_file"
done
ffmpeg -y -f concat -safe 0 -i "$concat_file" \
-c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p -r "$fps" \
-c:a aac -b:a 192k "$output_path" 2>/dev/null
rm -f "$concat_file"
echo "Concatenated $n segments -> $output_path" >&2
}
merge_video_audio() {
local video_path="$1" audio_path="$2" output_path="$3"
local bgm_volume="${4:-0.3}" fade_in="${5:-0}" fade_out="${6:-0}"
local duration
duration="$(get_video_duration "$video_path")"
local af="[1:a]volume=${bgm_volume}"
[[ "$(echo "$fade_in > 0" | bc -l)" == "1" ]] && af+=",afade=t=in:d=${fade_in}"
if [[ "$(echo "$fade_out > 0" | bc -l)" == "1" ]]; then
local fo_start
fo_start="$(echo "$duration - $fade_out" | bc -l)"
[[ "$(echo "$fo_start < 0" | bc -l)" == "1" ]] && fo_start=0
af+=",afade=t=out:st=${fo_start}:d=${fade_out}"
fi
af+="[bgm]"
mkdir -p "$(dirname "$output_path")"
ffmpeg -y -i "$video_path" -i "$audio_path" \
-filter_complex "$af" \
-map 0:v -map "[bgm]" \
-c:v copy -c:a aac -shortest "$output_path" 2>/dev/null
echo "Merged video+audio -> $output_path" >&2
}
generate_music_instrumental() {
local prompt="$1" output_path="$2"
local payload
payload=$(jq -n \
--arg p "${prompt:-cinematic background music, orchestral, ambient}. pure music, no lyrics" \
'{model: "music-2.5", prompt: $p, lyrics: "[intro] [outro]", output_format: "url"}')
echo "Generating instrumental music: $prompt" >&2
local raw http_code response
raw="$(curl -s -w "\n%{http_code}" -X POST "$MUSIC_API_URL" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
-H "Content-Type: application/json" \
--max-time 300 -d "$payload")"
http_code="${raw##*$'\n'}"; response="${raw%$'\n'*}"
[[ "$http_code" -ge 400 ]] 2>/dev/null && { echo "Error: Music API HTTP $http_code" >&2; return 1; }
local audio_url
audio_url="$(echo "$response" | jq -r '.data.audio_url // .data.audio // .data.audio_file.download_url // empty')"
[[ -z "$audio_url" ]] && { echo "Error: No audio URL in music response" >&2; return 1; }
mkdir -p "$(dirname "$output_path")"
curl -s -o "$output_path" --max-time 120 "$audio_url"
echo " Music saved: $output_path" >&2
}
# ============================================================================
# Main
# ============================================================================
main() {
load_env
check_api_key
local scenes=() model="" segment_duration=10 resolution="768P"
local first_frame="" subject_reference="" crossfade=0.5
local music_prompt="" bgm_volume=0.3 fade_in=0 fade_out=0
local output=""
while [[ $# -gt 0 ]]; do
case "$1" in
--scenes)
shift
while [[ $# -gt 0 && "$1" != --* ]]; do
scenes+=("$1"); shift
done
;;
--model) model="$2"; shift 2 ;;
--segment-duration) segment_duration="$2"; shift 2 ;;
--resolution) resolution="$2"; shift 2 ;;
--first-frame) first_frame="$2"; shift 2 ;;
--subject-reference) subject_reference="$2"; shift 2 ;;
--crossfade) crossfade="$2"; shift 2 ;;
--music-prompt) music_prompt="$2"; shift 2 ;;
--bgm-volume) bgm_volume="$2"; shift 2 ;;
--fade-in) fade_in="$2"; shift 2 ;;
--fade-out) fade_out="$2"; shift 2 ;;
-o|--output) output="$2"; shift 2 ;;
-h|--help)
cat <<'USAGE'
MiniMax Long Video Generation CLI
Usage:
generate_long_video.sh --scenes "scene1" "scene2" ... -o OUTPUT
Options:
--scenes TEXT... Scene prompts (2+ required)
--model MODEL Model name (default: auto)
--segment-duration SECS Duration per segment (default: 10)
--resolution RES Resolution: 768P, 1080P (default: 768P)
--first-frame FILE First frame for scene 1 (local file or URL)
--subject-reference FILE Subject reference image
--crossfade SECS Crossfade duration between scenes (default: 0.5)
--music-prompt TEXT Generate BGM with this prompt
--bgm-volume FLOAT BGM volume level (default: 0.3)
--fade-in SECS BGM fade-in duration
--fade-out SECS BGM fade-out duration
-o, --output FILE Output video file (required)
Examples:
generate_long_video.sh --scenes "A sunrise" "Birds flying" "Sunset" -o long.mp4
generate_long_video.sh --scenes "Scene 1" "Scene 2" --crossfade 1 --music-prompt "cinematic" -o movie.mp4
USAGE
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [[ ${#scenes[@]} -eq 0 ]]; then
echo "Error: --scenes is required" >&2; exit 1
fi
if [[ -z "$output" ]]; then
echo "Error: --output / -o is required" >&2; exit 1
fi
local output_dir
output_dir="$(dirname "$output")"
mkdir -p "$output_dir"
local tmpdir="$output_dir/tmp"
mkdir -p "$tmpdir"
echo "Temp directory: $tmpdir"
local segment_paths=()
local current_first_frame="$first_frame"
echo "=== Generating ${#scenes[@]} video segments ==="
echo ""
for i in "${!scenes[@]}"; do
local scene="${scenes[$i]}"
echo "--- Segment $((i+1))/${#scenes[@]} ---"
echo " Prompt: $scene"
local seg_output="$tmpdir/segment_$(printf '%03d' "$i").mp4"
# Determine mode
local seg_mode="t2v"
[[ -n "$current_first_frame" ]] && seg_mode="i2v"
[[ -n "$subject_reference" && -z "$current_first_frame" ]] && seg_mode="ref"
# Determine model
local seg_model="$model"
if [[ -z "$seg_model" ]]; then
case "$seg_mode" in
t2v|i2v) seg_model="MiniMax-Hailuo-2.3" ;;
ref) seg_model="S2V-01" ;;
esac
fi
# Build payload
local payload
payload=$(jq -n \
--arg m "$seg_model" \
--arg p "$scene" \
--argjson d "$segment_duration" \
--arg r "$resolution" \
'{model: $m, prompt: $p, duration: $d, resolution: $r}')
if [[ "$seg_mode" == "i2v" ]]; then
local ff_url; ff_url="$(resolve_image "$current_first_frame")"
payload=$(echo "$payload" | jq --arg ff "$ff_url" '. + {first_frame_image: $ff, prompt_optimizer: false}')
elif [[ "$seg_mode" == "ref" ]]; then
local si_url; si_url="$(resolve_image "$subject_reference")"
payload=$(echo "$payload" | jq --arg si "$si_url" '. + {subject_reference: [{type: "character", image: [$si]}]}')
fi
# Generate segment
local task_id file_id
if task_id="$(_create_task "$payload")" && [[ -n "$task_id" ]]; then
echo " Task created: $task_id"
if file_id="$(_poll_task "$task_id")" && [[ -n "$file_id" ]]; then
_download_video "$file_id" "$seg_output"
segment_paths+=("$seg_output")
# Extract last frame for next segment
local last_frame_path="$tmpdir/last_frame_$(printf '%03d' "$i").jpg"
if extract_last_frame "$seg_output" "$last_frame_path"; then
current_first_frame="$last_frame_path"
else
current_first_frame=""
fi
else
echo " Error: Polling failed for segment $((i+1))" >&2
[[ ${#segment_paths[@]} -eq 0 ]] && exit 1
break
fi
else
echo " Error generating segment $((i+1))" >&2
[[ ${#segment_paths[@]} -eq 0 ]] && exit 1
break
fi
done
if [[ ${#segment_paths[@]} -eq 0 ]]; then
echo "Error: No segments were generated." >&2; exit 1
fi
# Concatenate
local final_video="$output"
[[ -n "$music_prompt" ]] && final_video="$tmpdir/concatenated.mp4"
if [[ ${#segment_paths[@]} -eq 1 ]]; then
cp "${segment_paths[0]}" "$final_video"
else
concatenate_videos "$final_video" "$crossfade" "${segment_paths[@]}"
fi
# Add BGM if requested
if [[ -n "$music_prompt" ]]; then
echo ""
echo "--- Generating background music ---"
local music_path="$tmpdir/bgm.mp3"
if generate_music_instrumental "$music_prompt" "$music_path"; then
merge_video_audio "$final_video" "$music_path" "$output" "$bgm_volume" "$fade_in" "$fade_out" || {
echo "Warning: Failed to add BGM, using video without music" >&2
[[ "$final_video" != "$output" ]] && cp "$final_video" "$output"
}
else
echo "Warning: Failed to generate BGM" >&2
[[ "$final_video" != "$output" ]] && cp "$final_video" "$output"
fi
fi
echo ""
echo "=== Done! Output: $output ==="
echo " Intermediate files in: $tmpdir"
echo " Delete with: rm -rf $tmpdir"
}
main "$@"

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# MiniMax Template Video Generation CLI (pure bash)
#
# Usage:
# bash scripts/video/generate_template_video.sh \
# --template-id T00001 \
# --media image1.jpg image2.jpg \
# --text "Title" "Subtitle" \
# -o output/template_video.mp4
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
API_BASE="${MINIMAX_API_HOST:-https://api.minimaxi.com}/v1"
TEMPLATE_URL="${API_BASE}/video_template_generation"
QUERY_URL="${API_BASE}/query/video_template_generation"
POLL_INTERVAL=10
MAX_WAIT_TIME=600
REQUEST_TIMEOUT=60
MAX_CONSECUTIVE_FAILURES=5
# ============================================================================
# Common functions
# ============================================================================
load_env() {
local env_file
for env_file in "$PROJECT_ROOT/.env" "$(pwd)/.env"; do
if [[ -f "$env_file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"; line="$(echo "$line" | xargs)"
[[ -z "$line" || "$line" != *=* ]] && continue
local key="${line%%=*}" val="${line#*=}"
key="$(echo "$key" | xargs)"; val="$(echo "$val" | xargs)"
if [[ ${#val} -ge 2 ]]; then
case "$val" in \"*\") val="${val:1:${#val}-2}" ;; \'*\') val="${val:1:${#val}-2}" ;; esac
fi
[[ -z "${!key:-}" ]] && export "$key=$val"
done < "$env_file"
fi
done
}
check_api_key() {
if [[ -z "${MINIMAX_API_KEY:-}" ]]; then
echo "Error: MINIMAX_API_KEY not set." >&2; exit 1
fi
}
resolve_media_input() {
local value="$1"
case "$value" in
http://*|https://*|data:*) echo "$value"; return ;;
esac
[[ -f "$value" ]] || { echo "Error: Media file not found: $value" >&2; exit 1; }
local mime; mime="$(file -b --mime-type "$value" 2>/dev/null)" || mime="application/octet-stream"
local b64; b64="$(base64 < "$value")"
echo "data:${mime};base64,${b64}"
}
# ============================================================================
# Main
# ============================================================================
main() {
load_env
check_api_key
local template_id="" output=""
local media_inputs=() text_inputs=()
while [[ $# -gt 0 ]]; do
case "$1" in
--template-id) template_id="$2"; shift 2 ;;
--media)
shift
while [[ $# -gt 0 && "$1" != --* ]]; do
media_inputs+=("$1"); shift
done
;;
--text)
shift
while [[ $# -gt 0 && "$1" != --* ]]; do
text_inputs+=("$1"); shift
done
;;
-o|--output) output="$2"; shift 2 ;;
-h|--help)
cat <<'USAGE'
MiniMax Template Video Generation CLI
Usage:
generate_template_video.sh --template-id ID [--media FILE...] [--text TEXT...] -o OUTPUT
Options:
--template-id ID Template ID (required)
--media FILE... Media inputs (local files or URLs)
--text TEXT... Text inputs for template slots
-o, --output FILE Output video file (required)
Examples:
generate_template_video.sh --template-id T00001 --media image1.jpg image2.jpg --text "Title" "Subtitle" -o video.mp4
USAGE
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [[ -z "$template_id" ]]; then
echo "Error: --template-id is required" >&2; exit 1
fi
if [[ -z "$output" ]]; then
echo "Error: --output / -o is required" >&2; exit 1
fi
# Build payload
local payload
payload=$(jq -n --arg tid "$template_id" '{template_id: $tid}')
# Add media inputs
if [[ ${#media_inputs[@]} -gt 0 ]]; then
local media_json="[]"
for i in "${!media_inputs[@]}"; do
local resolved
resolved="$(resolve_media_input "${media_inputs[$i]}")"
media_json=$(echo "$media_json" | jq --arg url "$resolved" '. + [{value: $url}]')
echo " Media [$i]: ${media_inputs[$i]}"
done
payload=$(echo "$payload" | jq --argjson mi "$media_json" '. + {media_inputs: $mi}')
fi
# Add text inputs
if [[ ${#text_inputs[@]} -gt 0 ]]; then
local text_json="[]"
for i in "${!text_inputs[@]}"; do
text_json=$(echo "$text_json" | jq --arg t "${text_inputs[$i]}" '. + [{value: $t}]')
echo " Text [$i]: ${text_inputs[$i]}"
done
payload=$(echo "$payload" | jq --argjson ti "$text_json" '. + {text_inputs: $ti}')
fi
# Create task
echo "Creating template video task (template: $template_id)..."
local raw http_code response
raw="$(curl -s -w "\n%{http_code}" -X POST "$TEMPLATE_URL" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
-H "Content-Type: application/json" \
--max-time "$REQUEST_TIMEOUT" -d "$payload")"
http_code="${raw##*$'\n'}"; response="${raw%$'\n'*}"
[[ "$http_code" -ge 400 ]] 2>/dev/null && { echo "Error: HTTP $http_code" >&2; echo "$response" >&2; exit 1; }
local sc
sc="$(echo "$response" | jq -r '.base_resp.status_code // 0')" 2>/dev/null || true
[[ "$sc" != "0" && -n "$sc" ]] && { echo "Error: $(echo "$response" | jq '.base_resp')" >&2; exit 1; }
local task_id
task_id="$(echo "$response" | jq -r '.task_id // empty')"
[[ -z "$task_id" ]] && { echo "Error: No task_id in response" >&2; exit 1; }
echo "Task created: $task_id"
# Poll task
echo "Polling task $task_id..."
local start_time cf=0
start_time="$(date +%s)"
local video_url=""
while true; do
local elapsed=$(( $(date +%s) - start_time ))
[[ $elapsed -gt $MAX_WAIT_TIME ]] && { echo "Error: Timeout" >&2; exit 1; }
local poll_raw poll_code poll_resp
if poll_raw="$(curl -s -w "\n%{http_code}" -G "$QUERY_URL" \
-d "task_id=$task_id" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
--max-time "$REQUEST_TIMEOUT" 2>/dev/null)"; then
poll_code="${poll_raw##*$'\n'}"; poll_resp="${poll_raw%$'\n'*}"; cf=0
else
cf=$((cf+1))
echo " Poll error ($cf/$MAX_CONSECUTIVE_FAILURES)"
[[ $cf -ge $MAX_CONSECUTIVE_FAILURES ]] && { echo "Error: Too many failures" >&2; exit 1; }
sleep "$POLL_INTERVAL"; continue
fi
local status
status="$(echo "$poll_resp" | jq -r '.status // "Unknown"')"
echo " [${elapsed}s] Status: $status"
if [[ "$status" == "Success" ]]; then
local video_url
video_url="$(echo "$poll_resp" | jq -r '.video_url // empty')"
[[ -z "$video_url" ]] && { echo "Error: No video_url in response" >&2; exit 1; }
break
fi
[[ "$status" == "Fail" || "$status" == "Failed" || "$status" == "Error" ]] && {
echo "Error: Task failed: $(echo "$poll_resp" | jq -r '.base_resp.status_msg // "Unknown"')" >&2
exit 1
}
sleep "$POLL_INTERVAL"
done
# Download video directly from video_url
echo "Downloading video..."
mkdir -p "$(dirname "$output")"
curl -s -o "$output" --max-time $((REQUEST_TIMEOUT * 3)) "$video_url"
local size; size="$(wc -c < "$output" | tr -d ' ')"
echo "Video saved to: $output ($size bytes)"
echo "Done!"
}
main "$@"

View File

@@ -0,0 +1,329 @@
#!/usr/bin/env bash
# MiniMax Video Generation CLI (pure bash)
#
# Usage:
# bash scripts/video/generate_video.sh --mode t2v --prompt "A cat playing piano" -o output/cat.mp4
# bash scripts/video/generate_video.sh --mode i2v --prompt "Gentle breeze" --first-frame image.jpg -o output/anim.mp4
# bash scripts/video/generate_video.sh --mode sef --first-frame start.jpg --last-frame end.jpg -o output/sef.mp4
# bash scripts/video/generate_video.sh --mode ref --prompt "Person dancing" --subject-image person.jpg -o output/ref.mp4
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
API_BASE="${MINIMAX_API_HOST:-https://api.minimaxi.com}/v1"
POLL_INTERVAL=10
MAX_WAIT_TIME=600
REQUEST_TIMEOUT=60
MAX_CONSECUTIVE_FAILURES=5
# ============================================================================
# Common functions
# ============================================================================
load_env() {
local env_file
for env_file in "$PROJECT_ROOT/.env" "$(pwd)/.env"; do
if [[ -f "$env_file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"; line="$(echo "$line" | xargs)"
[[ -z "$line" || "$line" != *=* ]] && continue
local key="${line%%=*}" val="${line#*=}"
key="$(echo "$key" | xargs)"; val="$(echo "$val" | xargs)"
if [[ ${#val} -ge 2 ]]; then
case "$val" in \"*\") val="${val:1:${#val}-2}" ;; \'*\') val="${val:1:${#val}-2}" ;; esac
fi
[[ -z "${!key:-}" ]] && export "$key=$val"
done < "$env_file"
fi
done
}
check_api_key() {
if [[ -z "${MINIMAX_API_KEY:-}" ]]; then
echo "Error: MINIMAX_API_KEY environment variable is not set." >&2; exit 1
fi
}
image_to_data_url() {
local path="$1"
[[ -f "$path" ]] || { echo "Error: Image not found: $path" >&2; exit 1; }
local mime
mime="$(file -b --mime-type "$path" 2>/dev/null)" || mime="image/jpeg"
local b64
b64="$(base64 < "$path")"
echo "data:${mime};base64,${b64}"
}
resolve_image() {
local input="$1"
[[ -z "$input" ]] && return
case "$input" in
http://*|https://*|data:*) echo "$input" ;;
*) image_to_data_url "$input" ;;
esac
}
# ============================================================================
# Video generation functions
# ============================================================================
create_task() {
local payload="$1"
echo "Creating video generation task..." >&2
local raw_output http_code response
raw_output="$(curl -s -w "\n%{http_code}" \
-X POST "${API_BASE}/video_generation" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
-H "Content-Type: application/json" \
--max-time "$REQUEST_TIMEOUT" \
-d "$payload")"
http_code="${raw_output##*$'\n'}"
response="${raw_output%$'\n'*}"
if [[ "$http_code" -ge 400 ]] 2>/dev/null; then
echo "Error: API returned HTTP $http_code" >&2; echo "$response" >&2; exit 1
fi
local sc
sc="$(echo "$response" | jq -r '.base_resp.status_code // 0')" 2>/dev/null || true
if [[ "$sc" != "0" && -n "$sc" ]]; then
echo "Error: API error: $(echo "$response" | jq '.base_resp')" >&2; exit 1
fi
local task_id
task_id="$(echo "$response" | jq -r '.task_id // empty')"
if [[ -z "$task_id" ]]; then
echo "Error: No task_id in response" >&2; echo "$response" >&2; exit 1
fi
echo "Task created: $task_id" >&2
echo "$task_id"
}
poll_task() {
local task_id="$1"
echo "Polling task $task_id..." >&2
local start_time consecutive_failures=0
start_time="$(date +%s)"
while true; do
local now elapsed
now="$(date +%s)"
elapsed=$((now - start_time))
if [[ $elapsed -gt $MAX_WAIT_TIME ]]; then
echo "Error: Task $task_id timed out after ${MAX_WAIT_TIME}s" >&2; exit 1
fi
local raw_output http_code response
if raw_output="$(curl -s -w "\n%{http_code}" \
-G "${API_BASE}/query/video_generation" \
-d "task_id=$task_id" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
--max-time "$REQUEST_TIMEOUT" 2>/dev/null)"; then
http_code="${raw_output##*$'\n'}"
response="${raw_output%$'\n'*}"
consecutive_failures=0
else
consecutive_failures=$((consecutive_failures + 1))
echo " Poll error ($consecutive_failures/$MAX_CONSECUTIVE_FAILURES)" >&2
if [[ $consecutive_failures -ge $MAX_CONSECUTIVE_FAILURES ]]; then
echo "Error: Too many consecutive poll failures" >&2; exit 1
fi
sleep "$POLL_INTERVAL"; continue
fi
local status
status="$(echo "$response" | jq -r '.status // "Unknown"')"
echo " [${elapsed}s] Status: $status" >&2
if [[ "$status" == "Success" ]]; then
local file_id
file_id="$(echo "$response" | jq -r '.file_id // empty')"
if [[ -z "$file_id" ]]; then
echo "Error: Task succeeded but no file_id" >&2; exit 1
fi
echo "$file_id"
return 0
fi
if [[ "$status" == "Fail" || "$status" == "Failed" || "$status" == "Error" ]]; then
local err_msg
err_msg="$(echo "$response" | jq -r '.base_resp.status_msg // "Unknown error"')"
echo "Error: Task failed: $err_msg" >&2; exit 1
fi
sleep "$POLL_INTERVAL"
done
}
download_video() {
local file_id="$1" output_path="$2"
echo "Retrieving file $file_id..." >&2
local raw_output http_code response
raw_output="$(curl -s -w "\n%{http_code}" \
-G "${API_BASE}/files/retrieve" \
-d "file_id=$file_id" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
--max-time "$REQUEST_TIMEOUT")"
http_code="${raw_output##*$'\n'}"
response="${raw_output%$'\n'*}"
local dl_url
dl_url="$(echo "$response" | jq -r '.file.download_url // empty')"
if [[ -z "$dl_url" ]]; then
echo "Error: No download_url in file response" >&2; exit 1
fi
echo "Downloading video..." >&2
mkdir -p "$(dirname "$output_path")"
curl -s -o "$output_path" --max-time $((REQUEST_TIMEOUT * 3)) "$dl_url"
local size
size="$(wc -c < "$output_path" | tr -d ' ')"
echo "Video saved to: $output_path ($size bytes)" >&2
}
# ============================================================================
# Main
# ============================================================================
main() {
load_env
check_api_key
local mode="" prompt="" model="" duration=10 resolution="768P"
local first_frame="" last_frame="" subject_image=""
local prompt_optimizer="" fast_pretreatment="" callback_url="" aigc_watermark=""
local output=""
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) mode="$2"; shift 2 ;;
--prompt) prompt="$2"; shift 2 ;;
--model) model="$2"; shift 2 ;;
--duration) duration="$2"; shift 2 ;;
--resolution) resolution="$2"; shift 2 ;;
--first-frame) first_frame="$2"; shift 2 ;;
--last-frame) last_frame="$2"; shift 2 ;;
--subject-image) subject_image="$2"; shift 2 ;;
--prompt-optimizer) prompt_optimizer="$2"; shift 2 ;;
--fast-pretreatment) fast_pretreatment="$2"; shift 2 ;;
--callback-url) callback_url="$2"; shift 2 ;;
--aigc-watermark) aigc_watermark="$2"; shift 2 ;;
-o|--output) output="$2"; shift 2 ;;
-h|--help)
cat <<'USAGE'
MiniMax Video Generation CLI
Usage:
generate_video.sh --mode MODE [options] -o OUTPUT
Modes:
t2v Text-to-video
i2v Image-to-video (requires --first-frame)
sef Start-end frame (requires --first-frame and --last-frame)
ref Subject reference (requires --subject-image)
Options:
--mode MODE Generation mode: t2v, i2v, sef, ref (required)
--prompt TEXT Text prompt describing the video
--model MODEL Model name (default: T2V-01)
--first-frame FILE First frame image (local file or URL)
--last-frame FILE Last frame image (local file or URL)
--subject-image FILE Subject reference image (local file or URL)
-o, --output FILE Output video file (required)
Examples:
generate_video.sh --mode t2v --prompt "A cat playing piano" -o cat.mp4
generate_video.sh --mode i2v --prompt "Gentle breeze" --first-frame photo.jpg -o anim.mp4
generate_video.sh --mode sef --first-frame start.jpg --last-frame end.jpg -o sef.mp4
generate_video.sh --mode ref --prompt "Person dancing" --subject-image person.jpg -o ref.mp4
USAGE
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [[ -z "$mode" ]]; then
echo "Error: --mode is required (t2v, i2v, sef, ref)" >&2; exit 1
fi
if [[ -z "$output" ]]; then
echo "Error: --output / -o is required" >&2; exit 1
fi
# Default model per mode
if [[ -z "$model" ]]; then
case "$mode" in
t2v) model="MiniMax-Hailuo-2.3" ;;
i2v) model="MiniMax-Hailuo-2.3" ;;
sef) model="MiniMax-Hailuo-02" ;;
ref) model="S2V-01" ;;
esac
fi
# Build payload
local payload
payload=$(jq -n --arg m "$model" '{model: $m}')
[[ -n "$prompt" ]] && payload=$(echo "$payload" | jq --arg p "$prompt" '. + {prompt: $p}')
payload=$(echo "$payload" | jq --argjson d "$duration" '. + {duration: $d}')
payload=$(echo "$payload" | jq --arg r "$resolution" '. + {resolution: $r}')
[[ -n "$prompt_optimizer" ]] && payload=$(echo "$payload" | jq --argjson po "$(echo "$prompt_optimizer" | tr '[:upper:]' '[:lower:]' | jq -R 'test("true")')" '. + {prompt_optimizer: $po}')
[[ -n "$callback_url" ]] && payload=$(echo "$payload" | jq --arg cu "$callback_url" '. + {callback_url: $cu}')
[[ -n "$aigc_watermark" ]] && payload=$(echo "$payload" | jq --argjson aw "$aigc_watermark" '. + {aigc_watermark: $aw}')
case "$mode" in
t2v) ;;
i2v)
if [[ -z "$first_frame" ]]; then
echo "Error: --first-frame is required for i2v mode" >&2; exit 1
fi
local ff_url
ff_url="$(resolve_image "$first_frame")"
payload=$(echo "$payload" | jq --arg ff "$ff_url" '. + {first_frame_image: $ff}')
[[ -n "$fast_pretreatment" ]] && payload=$(echo "$payload" | jq --argjson fp "$(echo "$fast_pretreatment" | tr '[:upper:]' '[:lower:]' | jq -R 'test("true")')" '. + {fast_pretreatment: $fp}')
;;
sef)
if [[ -z "$first_frame" ]]; then
echo "Error: --first-frame is required for sef mode" >&2; exit 1
fi
local ff_url
ff_url="$(resolve_image "$first_frame")"
payload=$(echo "$payload" | jq --arg ff "$ff_url" '. + {first_frame_image: $ff}')
if [[ -n "$last_frame" ]]; then
local lf_url
lf_url="$(resolve_image "$last_frame")"
payload=$(echo "$payload" | jq --arg lf "$lf_url" '. + {last_frame_image: $lf}')
fi
;;
ref)
if [[ -z "$subject_image" ]]; then
echo "Error: --subject-image is required for ref mode" >&2; exit 1
fi
local si_url
si_url="$(resolve_image "$subject_image")"
payload=$(echo "$payload" | jq --arg si "$si_url" '. + {subject_reference: [{type: "character", image: [$si]}]}')
if [[ -n "$first_frame" ]]; then
local ff_url
ff_url="$(resolve_image "$first_frame")"
payload=$(echo "$payload" | jq --arg ff "$ff_url" '. + {first_frame_image: $ff}')
fi
;;
*)
echo "Error: Unknown mode: $mode" >&2; exit 1 ;;
esac
echo "Mode: $mode"
echo "Model: $model"
local task_id file_id
task_id="$(create_task "$payload")"
file_id="$(poll_task "$task_id")"
download_video "$file_id" "$output"
echo "Done!"
}
main "$@"