Files
opencode-skill/skills/minimax-multimodal-toolkit/scripts/media_tools.sh
Kunthawat Greethong 7edf5bc4d0 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
2026-03-26 11:37:39 +07:00

544 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# MiniMax Multi-Modal Toolkit Media Tools CLI (pure bash)
#
# FFmpeg-based utilities for audio/video format conversion, concatenation,
# extraction, and trimming.
#
# Usage:
# bash scripts/media_tools.sh convert-video input.webm -o output.mp4
# bash scripts/media_tools.sh convert-audio input.wav -o output.mp3
# bash scripts/media_tools.sh concat-video seg1.mp4 seg2.mp4 -o merged.mp4
# bash scripts/media_tools.sh concat-audio part1.mp3 part2.mp3 -o combined.mp3
# bash scripts/media_tools.sh extract-audio input.mp4 -o audio.mp3
# bash scripts/media_tools.sh trim-video input.mp4 --start 5 --end 15 -o clip.mp4
# bash scripts/media_tools.sh add-audio --video video.mp4 --audio bgm.mp3 -o output.mp4
# bash scripts/media_tools.sh probe input.mp4
set -euo pipefail
# ============================================================================
# Probe / info helpers
# ============================================================================
probe_media() {
ffprobe -v error -show_format -show_streams -of json "$1" 2>/dev/null
}
get_duration() {
probe_media "$1" | jq -r '.format.duration // "0"'
}
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="${fps_str%/*}" den="${fps_str#*/}"
echo $(( (num + den/2) / den )) 2>/dev/null || echo 25
}
has_audio_stream() {
local out
out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$1" 2>/dev/null)"
[[ "$out" == *audio* ]]
}
has_video_stream() {
local out
out="$(ffprobe -v error -select_streams v -show_entries stream=codec_type -of csv=p=0 "$1" 2>/dev/null)"
[[ "$out" == *video* ]]
}
# ============================================================================
# Video codec maps
# ============================================================================
video_codec_for() {
case "$1" in
mp4|mov|mkv|avi|ts|flv) echo "libx264" ;;
webm) echo "libvpx-vp9" ;;
*) echo "libx264" ;;
esac
}
audio_codec_for_container() {
case "$1" in
mp4|mov|mkv|ts|flv) echo "aac" ;;
webm) echo "libopus" ;;
avi) echo "mp3" ;;
*) echo "aac" ;;
esac
}
audio_codec_for_format() {
case "$1" in
mp3) echo "libmp3lame" ;;
wav) echo "pcm_s16le" ;;
flac) echo "flac" ;;
ogg) echo "libvorbis" ;;
aac|m4a) echo "aac" ;;
opus) echo "libopus" ;;
wma) echo "wmav2" ;;
*) echo "libmp3lame" ;;
esac
}
get_ext() {
local name="$1"
echo "${name##*.}" | tr '[:upper:]' '[:lower:]'
}
# ============================================================================
# Subcommand: convert-video
# ============================================================================
cmd_convert_video() {
local input="" output="" crf=18 preset="medium" resolution="" fps=""
if [[ $# -gt 0 && "$1" != -* ]]; then input="$1"; shift; fi
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
--crf) crf="$2"; shift 2 ;;
--preset) preset="$2"; shift 2 ;;
--resolution) resolution="$2"; shift 2 ;;
--fps) fps="$2"; shift 2 ;;
*) [[ -z "$input" ]] && input="$1"; shift ;;
esac
done
[[ -z "$input" || ! -f "$input" ]] && { echo "Error: Input file not found: ${input:-<none>}" >&2; exit 1; }
[[ -z "$output" ]] && { echo "Error: -o/--output required" >&2; exit 1; }
local ext; ext="$(get_ext "$output")"
local v_codec; v_codec="$(video_codec_for "$ext")"
local a_codec; a_codec="$(audio_codec_for_container "$ext")"
mkdir -p "$(dirname "$output")"
local cmd=(ffmpeg -y -i "$input")
# Video filters
if [[ -n "$resolution" ]]; then
local w="${resolution%%x*}" h="${resolution##*x}"
cmd+=(-vf "scale=${w}:${h}")
fi
cmd+=(-c:v "$v_codec")
case "$v_codec" in
libx264|libx265) cmd+=(-crf "$crf" -preset "$preset" -pix_fmt yuv420p) ;;
libvpx-vp9) cmd+=(-crf "$crf" -b:v 0) ;;
esac
[[ -n "$fps" ]] && cmd+=(-r "$fps")
if has_audio_stream "$input"; then
cmd+=(-c:a "$a_codec" -b:a 192k)
else
cmd+=(-an)
fi
cmd+=("$output")
echo "Converting: $input -> $output ($v_codec/$a_codec)"
"${cmd[@]}" 2>/dev/null
echo " Done: $output"
}
# ============================================================================
# Subcommand: convert-audio
# ============================================================================
cmd_convert_audio() {
local input="" output="" bitrate="192k" sample_rate="" channels=""
if [[ $# -gt 0 && "$1" != -* ]]; then input="$1"; shift; fi
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
--bitrate) bitrate="$2"; shift 2 ;;
--sample-rate) sample_rate="$2"; shift 2 ;;
--channels) channels="$2"; shift 2 ;;
*) [[ -z "$input" ]] && input="$1"; shift ;;
esac
done
[[ -z "$input" || ! -f "$input" ]] && { echo "Error: Input file not found: ${input:-<none>}" >&2; exit 1; }
[[ -z "$output" ]] && { echo "Error: -o/--output required" >&2; exit 1; }
local ext; ext="$(get_ext "$output")"
local codec; codec="$(audio_codec_for_format "$ext")"
mkdir -p "$(dirname "$output")"
local cmd=(ffmpeg -y -i "$input" -c:a "$codec" -b:a "$bitrate")
[[ -n "$sample_rate" ]] && cmd+=(-ar "$sample_rate")
[[ -n "$channels" ]] && cmd+=(-ac "$channels")
cmd+=("$output")
echo "Converting audio: $input -> $output ($codec)"
"${cmd[@]}" 2>/dev/null
echo " Done: $output"
}
# ============================================================================
# Subcommand: concat-video
# ============================================================================
cmd_concat_video() {
local output="" crossfade=0.5
local inputs=()
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
--crossfade) crossfade="$2"; shift 2 ;;
*) inputs+=("$1"); shift ;;
esac
done
[[ ${#inputs[@]} -lt 2 ]] && { echo "Error: At least 2 input files required" >&2; exit 1; }
[[ -z "$output" ]] && { echo "Error: -o/--output required" >&2; exit 1; }
mkdir -p "$(dirname "$output")"
if [[ ${#inputs[@]} -eq 1 ]]; then
cp "${inputs[0]}" "$output"
return 0
fi
local fps; fps="$(get_video_fps "${inputs[0]}")"
local has_audio=true
for vp in "${inputs[@]}"; do
has_audio_stream "$vp" || { has_audio=false; break; }
done
if [[ "$(echo "$crossfade > 0" | bc -l)" == "1" ]]; then
local durations=()
for vp in "${inputs[@]}"; do durations+=("$(get_duration "$vp")"); done
local ff_inputs=()
for vp in "${inputs[@]}"; do ff_inputs+=(-i "$(cd "$(dirname "$vp")" && pwd)/$(basename "$vp")"); done
local n=${#inputs[@]}
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
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 fc
fc="$(IFS=';'; echo "${vf_parts[*]}${af_parts[*]:+;${af_parts[*]}}")"
local cmd=(ffmpeg -y "${ff_inputs[@]}" -filter_complex "$fc" -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")
echo "Concatenating $n videos with ${crossfade}s crossfade..."
if "${cmd[@]}" 2>/dev/null; then
echo " Done: $output"
return 0
fi
echo " Crossfade failed, falling back to re-encode..."
fi
# Fallback
local concat_file; concat_file="$(mktemp /tmp/concat_XXXXXX.txt)"
for vp in "${inputs[@]}"; 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" 2>/dev/null
rm -f "$concat_file"
echo " Done: $output"
}
# ============================================================================
# Subcommand: concat-audio
# ============================================================================
cmd_concat_audio() {
local output="" crossfade=0
local inputs=()
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
--crossfade) crossfade="$2"; shift 2 ;;
*) inputs+=("$1"); shift ;;
esac
done
[[ ${#inputs[@]} -lt 1 ]] && { echo "Error: At least 1 input file required" >&2; exit 1; }
[[ -z "$output" ]] && { echo "Error: -o/--output required" >&2; exit 1; }
mkdir -p "$(dirname "$output")"
if [[ ${#inputs[@]} -eq 1 ]]; then
cp "${inputs[0]}" "$output"
echo " Done: $output"
return 0
fi
local ext; ext="$(get_ext "$output")"
local codec; codec="$(audio_codec_for_format "$ext")"
local n=${#inputs[@]}
if [[ "$(echo "$crossfade > 0" | bc -l)" == "1" ]]; then
local ff_inputs=()
for ap in "${inputs[@]}"; do ff_inputs+=(-i "$(cd "$(dirname "$ap")" && pwd)/$(basename "$ap")"); done
local af_parts=()
if [[ $n -eq 2 ]]; then
af_parts+=("[0:a][1:a]acrossfade=d=${crossfade}:c1=tri:c2=tri[aout]")
else
af_parts+=("[0:a][1:a]acrossfade=d=${crossfade}:c1=tri:c2=tri[xa1]")
for ((i=2; i<n; i++)); do
local prev="[xa$((i-1))]" out="[xa${i}]"
[[ $i -eq $((n-1)) ]] && out="[aout]"
af_parts+=("${prev}[${i}:a]acrossfade=d=${crossfade}:c1=tri:c2=tri${out}")
done
fi
local fc; fc="$(IFS=';'; echo "${af_parts[*]}")"
echo "Concatenating $n audio files with ${crossfade}s crossfade..."
if ffmpeg -y "${ff_inputs[@]}" -filter_complex "$fc" -map "[aout]" \
-c:a "$codec" -b:a 192k "$output" 2>/dev/null; then
echo " Done: $output"
return 0
fi
echo " Crossfade failed, falling back..."
fi
# Fallback: concat demuxer
local concat_file; concat_file="$(mktemp /tmp/concat_XXXXXX.txt)"
for ap in "${inputs[@]}"; do
echo "file '$(cd "$(dirname "$ap")" && pwd)/$(basename "$ap")'" >> "$concat_file"
done
ffmpeg -y -f concat -safe 0 -i "$concat_file" -c:a "$codec" -b:a 192k "$output" 2>/dev/null
rm -f "$concat_file"
echo " Done: $output"
}
# ============================================================================
# Subcommand: extract-audio
# ============================================================================
cmd_extract_audio() {
local input="" output="" bitrate="192k"
if [[ $# -gt 0 && "$1" != -* ]]; then input="$1"; shift; fi
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
--bitrate) bitrate="$2"; shift 2 ;;
*) [[ -z "$input" ]] && input="$1"; shift ;;
esac
done
[[ -z "$input" || ! -f "$input" ]] && { echo "Error: Input not found: ${input:-<none>}" >&2; exit 1; }
[[ -z "$output" ]] && { echo "Error: -o/--output required" >&2; exit 1; }
has_audio_stream "$input" || { echo "Error: No audio stream in $input" >&2; exit 1; }
local ext; ext="$(get_ext "$output")"
local codec; codec="$(audio_codec_for_format "$ext")"
mkdir -p "$(dirname "$output")"
echo "Extracting audio: $input -> $output"
ffmpeg -y -i "$input" -vn -c:a "$codec" -b:a "$bitrate" "$output" 2>/dev/null
echo " Done: $output"
}
# ============================================================================
# Subcommand: trim-video
# ============================================================================
cmd_trim_video() {
local input="" output="" start="" end="" duration=""
if [[ $# -gt 0 && "$1" != -* ]]; then input="$1"; shift; fi
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
--start) start="$2"; shift 2 ;;
--end) end="$2"; shift 2 ;;
--duration) duration="$2"; shift 2 ;;
*) [[ -z "$input" ]] && input="$1"; shift ;;
esac
done
[[ -z "$input" || ! -f "$input" ]] && { echo "Error: Input not found: ${input:-<none>}" >&2; exit 1; }
[[ -z "$output" ]] && { echo "Error: -o/--output required" >&2; exit 1; }
mkdir -p "$(dirname "$output")"
local cmd=(ffmpeg -y)
[[ -n "$start" ]] && cmd+=(-ss "$start")
cmd+=(-i "$input")
if [[ -n "$duration" ]]; then
cmd+=(-t "$duration")
elif [[ -n "$end" ]]; then
local actual_start="${start:-0}"
local dur; dur="$(echo "$end - $actual_start" | bc -l)"
cmd+=(-t "$dur")
fi
cmd+=(-c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p)
has_audio_stream "$input" && cmd+=(-c:a aac -b:a 192k)
cmd+=("$output")
local start_str="${start:-0}s"
local end_str="${end:+${end}s}"
[[ -z "$end_str" && -n "$duration" ]] && end_str="+${duration}s"
[[ -z "$end_str" ]] && end_str="end"
echo "Trimming: $input [$start_str - $end_str] -> $output"
"${cmd[@]}" 2>/dev/null
echo " Done: $output"
}
# ============================================================================
# Subcommand: add-audio
# ============================================================================
cmd_add_audio() {
local video="" audio="" output="" volume=1.0 fade_in=0 fade_out=0 replace=false
while [[ $# -gt 0 ]]; do
case "$1" in
--video) video="$2"; shift 2 ;;
--audio) audio="$2"; shift 2 ;;
-o|--output) output="$2"; shift 2 ;;
--volume) volume="$2"; shift 2 ;;
--fade-in) fade_in="$2"; shift 2 ;;
--fade-out) fade_out="$2"; shift 2 ;;
--replace) replace=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
[[ -z "$video" || ! -f "$video" ]] && { echo "Error: Video not found: ${video:-<none>}" >&2; exit 1; }
[[ -z "$audio" || ! -f "$audio" ]] && { echo "Error: Audio not found: ${audio:-<none>}" >&2; exit 1; }
[[ -z "$output" ]] && { echo "Error: -o/--output required" >&2; exit 1; }
mkdir -p "$(dirname "$output")"
local duration; duration="$(get_duration "$video")"
local video_audio=false
has_audio_stream "$video" && video_audio=true
local af="[1:a]volume=${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
if $video_audio && ! $replace; then
af+="[newaudio];[0:a][newaudio]amix=inputs=2:duration=first:dropout_transition=2[aout]"
local mode="mixing with"
else
af+="[aout]"
local mode="replacing"
fi
echo "Adding audio ($mode original): $output"
ffmpeg -y -i "$video" -i "$audio" \
-filter_complex "$af" \
-map 0:v -map "[aout]" \
-c:v copy -c:a aac -b:a 192k -shortest "$output" 2>/dev/null
echo " Done: $output"
}
# ============================================================================
# Subcommand: probe
# ============================================================================
cmd_probe() {
local input=""
if [[ $# -gt 0 ]]; then input="$1"; fi
[[ -z "$input" || ! -f "$input" ]] && { echo "Error: File not found: ${input:-<none>}" >&2; exit 1; }
local info; info="$(probe_media "$input")"
local fmt_name dur size br
fmt_name="$(echo "$info" | jq -r '.format.format_long_name // "unknown"')"
dur="$(echo "$info" | jq -r '.format.duration // "0"')"
size="$(echo "$info" | jq -r '.format.size // "0"')"
br="$(echo "$info" | jq -r '.format.bit_rate // "0"')"
echo "File: $input"
echo "Format: $fmt_name"
printf "Duration: %.2fs\n" "$dur"
printf "Size: %.2f MB\n" "$(echo "$size / 1048576" | bc -l)"
printf "Bitrate: %.0f kbps\n" "$(echo "$br / 1000" | bc -l)"
echo "$info" | jq -r '.streams[] | if .codec_type == "video" then "Video: \(.codec_name) \(.width)x\(.height) @ \(.r_frame_rate) fps" elif .codec_type == "audio" then "Audio: \(.codec_name) \(.sample_rate)Hz \(.channels)ch" else empty end'
}
# ============================================================================
# Main dispatcher
# ============================================================================
usage() {
cat <<'EOF'
MiniMax Multi-Modal Toolkit Media Tools
Usage:
media_tools.sh <command> [options]
Commands:
convert-video Convert video format
convert-audio Convert audio format
concat-video Concatenate videos with crossfade
concat-audio Concatenate audio files
extract-audio Extract audio from video
trim-video Trim video by time range
add-audio Add/overlay audio on video
probe Show media file info
Examples:
media_tools.sh convert-video input.webm -o output.mp4
media_tools.sh convert-audio input.wav -o output.mp3
media_tools.sh concat-video seg1.mp4 seg2.mp4 -o merged.mp4
media_tools.sh extract-audio video.mp4 -o audio.mp3
media_tools.sh trim-video input.mp4 --start 5 --end 15 -o clip.mp4
media_tools.sh add-audio --video video.mp4 --audio bgm.mp3 -o output.mp4
media_tools.sh probe input.mp4
EOF
}
main() {
if [[ $# -eq 0 ]]; then
usage; exit 0
fi
local command="$1"; shift
case "$command" in
convert-video) cmd_convert_video "$@" ;;
convert-audio) cmd_convert_audio "$@" ;;
concat-video) cmd_concat_video "$@" ;;
concat-audio) cmd_concat_audio "$@" ;;
extract-audio) cmd_extract_audio "$@" ;;
trim-video) cmd_trim_video "$@" ;;
add-audio) cmd_add_audio "$@" ;;
probe) cmd_probe "$@" ;;
-h|--help|help) usage ;;
*) echo "Unknown command: $command" >&2; usage >&2; exit 1 ;;
esac
}
main "$@"