microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
.github/skills/experimental/video-to-gif/scripts/convert.sh
389lines · modecode
| 1 | #!/usr/bin/env bash |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | # |
| 5 | # convert.sh |
| 6 | # Convert video files to optimized GIF animations using FFmpeg two-pass palette optimization |
| 7 | # Features: HDR auto-detection with tonemapping, workspace-first file search, time range selection |
| 8 | |
| 9 | set -euo pipefail |
| 10 | |
| 11 | # Default values |
| 12 | DEFAULT_FPS=10 |
| 13 | DEFAULT_WIDTH=1280 |
| 14 | DEFAULT_DITHER="sierra2_4a" |
| 15 | DEFAULT_TONEMAP="hable" |
| 16 | DEFAULT_LOOP=0 |
| 17 | |
| 18 | usage() { |
| 19 | echo "Usage: ${0##*/} [OPTIONS] [INPUT_FILE]" |
| 20 | echo "" |
| 21 | echo "Convert video files to optimized GIF animations." |
| 22 | echo "" |
| 23 | echo "Options:" |
| 24 | echo " --input FILE Input video file (required if not positional)" |
| 25 | echo " --output FILE Output GIF file (defaults to input with .gif extension)" |
| 26 | echo " --fps N Frame rate (default: ${DEFAULT_FPS})" |
| 27 | echo " --width N Output width in pixels (default: ${DEFAULT_WIDTH})" |
| 28 | echo " --dither ALG Dithering algorithm (default: ${DEFAULT_DITHER})" |
| 29 | echo " Options: sierra2_4a, floyd_steinberg, bayer, none" |
| 30 | echo " --tonemap ALG HDR tonemapping algorithm (default: ${DEFAULT_TONEMAP})" |
| 31 | echo " Options: hable, reinhard, mobius, bt2390" |
| 32 | echo " --start N Start time in seconds (default: 0)" |
| 33 | echo " --duration N Duration to convert in seconds (default: full video)" |
| 34 | echo " --loop N GIF loop count, 0=infinite (default: ${DEFAULT_LOOP})" |
| 35 | echo " --skip-palette Use single-pass mode (faster, lower quality)" |
| 36 | echo " --help, -h Show this help message" |
| 37 | echo "" |
| 38 | echo "Examples:" |
| 39 | echo " ${0##*/} video.mp4" |
| 40 | echo " ${0##*/} --input video.mp4 --output demo.gif --fps 15" |
| 41 | echo " ${0##*/} --input video.mp4 --start 5 --duration 10" |
| 42 | exit 1 |
| 43 | } |
| 44 | |
| 45 | err() { |
| 46 | printf "ERROR: %s\n" "$1" >&2 |
| 47 | exit 1 |
| 48 | } |
| 49 | |
| 50 | get_file_size() { |
| 51 | local file="$1" |
| 52 | if [[ "$(uname)" == "Darwin" ]]; then |
| 53 | stat -f%z "${file}" |
| 54 | else |
| 55 | stat -c%s "${file}" |
| 56 | fi |
| 57 | } |
| 58 | |
| 59 | format_size() { |
| 60 | local bytes="$1" |
| 61 | if (( bytes >= 1048576 )); then |
| 62 | printf "%.2f MB" "$(echo "scale=2; ${bytes} / 1048576" | bc)" |
| 63 | elif (( bytes >= 1024 )); then |
| 64 | printf "%.2f KB" "$(echo "scale=2; ${bytes} / 1024" | bc)" |
| 65 | else |
| 66 | printf "%d bytes" "${bytes}" |
| 67 | fi |
| 68 | } |
| 69 | |
| 70 | # Find file using prefix matching to handle Unicode whitespace mismatches |
| 71 | # macOS screen recordings use non-breaking spaces (U+00A0) that look like ASCII spaces |
| 72 | find_by_prefix() { |
| 73 | local dir="$1" |
| 74 | local basename="$2" |
| 75 | |
| 76 | [[ -d "${dir}" ]] || return 1 |
| 77 | |
| 78 | local base_no_ext="${basename%.*}" |
| 79 | local ext="${basename##*.}" |
| 80 | local prefix="${base_no_ext:0:15}" |
| 81 | |
| 82 | local found_file |
| 83 | while IFS= read -r -d '' found_file; do |
| 84 | echo "${found_file}" |
| 85 | return 0 |
| 86 | done < <(find "${dir}" -maxdepth 1 -type f -name "${prefix}*.${ext}" -print0 2>/dev/null) |
| 87 | |
| 88 | return 1 |
| 89 | } |
| 90 | |
| 91 | # Search for file in workspace and common directories |
| 92 | find_video_file() { |
| 93 | local filename="$1" |
| 94 | |
| 95 | # Direct path lookup |
| 96 | if [[ -f "${filename}" ]]; then |
| 97 | echo "${filename}" |
| 98 | return 0 |
| 99 | fi |
| 100 | |
| 101 | # Extract directory and basename for prefix matching |
| 102 | local dir_part base_part |
| 103 | if [[ "${filename}" == */* ]]; then |
| 104 | dir_part="${filename%/*}" |
| 105 | base_part="${filename##*/}" |
| 106 | else |
| 107 | dir_part="" |
| 108 | base_part="${filename}" |
| 109 | fi |
| 110 | |
| 111 | # For absolute paths, try prefix matching in the specified directory |
| 112 | if [[ "${filename}" == /* ]]; then |
| 113 | if find_by_prefix "${dir_part}" "${base_part}"; then |
| 114 | return 0 |
| 115 | fi |
| 116 | return 1 |
| 117 | fi |
| 118 | |
| 119 | # Build search locations for relative paths |
| 120 | local search_dirs=("." "${PWD}") |
| 121 | |
| 122 | local git_root |
| 123 | if git_root=$(git rev-parse --show-toplevel 2>/dev/null); then |
| 124 | search_dirs+=("${git_root}") |
| 125 | fi |
| 126 | |
| 127 | if [[ "$(uname)" == "Darwin" ]]; then |
| 128 | search_dirs+=("${HOME}/Movies" "${HOME}/Downloads" "${HOME}/Desktop") |
| 129 | else |
| 130 | search_dirs+=("${HOME}/Videos" "${HOME}/Downloads" "${HOME}/Desktop") |
| 131 | fi |
| 132 | |
| 133 | for dir in "${search_dirs[@]}"; do |
| 134 | # Try exact match first |
| 135 | if [[ -f "${dir}/${filename}" ]]; then |
| 136 | echo "${dir}/${filename}" |
| 137 | return 0 |
| 138 | fi |
| 139 | # Fall back to prefix matching for Unicode whitespace issues |
| 140 | if find_by_prefix "${dir}" "${base_part}"; then |
| 141 | return 0 |
| 142 | fi |
| 143 | done |
| 144 | |
| 145 | return 1 |
| 146 | } |
| 147 | |
| 148 | # Detect if video is HDR using ffprobe |
| 149 | detect_hdr() { |
| 150 | local file="$1" |
| 151 | |
| 152 | if ! command -v ffprobe &>/dev/null; then |
| 153 | echo "false" |
| 154 | return |
| 155 | fi |
| 156 | |
| 157 | local color_info |
| 158 | color_info=$(ffprobe -v error -select_streams v:0 \ |
| 159 | -show_entries stream=color_primaries,color_transfer \ |
| 160 | -of csv=p=0 "${file}" 2>/dev/null || echo "") |
| 161 | |
| 162 | # Check for HDR indicators: bt2020 primaries or smpte2084 transfer |
| 163 | if [[ "${color_info}" == *"bt2020"* ]] || [[ "${color_info}" == *"smpte2084"* ]]; then |
| 164 | echo "true" |
| 165 | else |
| 166 | echo "false" |
| 167 | fi |
| 168 | } |
| 169 | |
| 170 | main() { |
| 171 | local input_file="" |
| 172 | local output_file="" |
| 173 | local fps="${DEFAULT_FPS}" |
| 174 | local width="${DEFAULT_WIDTH}" |
| 175 | local dither="${DEFAULT_DITHER}" |
| 176 | local tonemap="${DEFAULT_TONEMAP}" |
| 177 | local loop="${DEFAULT_LOOP}" |
| 178 | local start_time="" |
| 179 | local duration="" |
| 180 | local skip_palette=false |
| 181 | |
| 182 | # Parse arguments |
| 183 | while [[ $# -gt 0 ]]; do |
| 184 | case "$1" in |
| 185 | --input) |
| 186 | if [[ -z "${2:-}" || "$2" == --* ]]; then |
| 187 | err "--input requires a file path" |
| 188 | fi |
| 189 | input_file="$2" |
| 190 | shift 2 |
| 191 | ;; |
| 192 | --output) |
| 193 | if [[ -z "${2:-}" || "$2" == --* ]]; then |
| 194 | err "--output requires a file path" |
| 195 | fi |
| 196 | output_file="$2" |
| 197 | shift 2 |
| 198 | ;; |
| 199 | --fps) |
| 200 | if [[ -z "${2:-}" || "$2" == --* ]]; then |
| 201 | err "--fps requires a number" |
| 202 | fi |
| 203 | fps="$2" |
| 204 | shift 2 |
| 205 | ;; |
| 206 | --width) |
| 207 | if [[ -z "${2:-}" || "$2" == --* ]]; then |
| 208 | err "--width requires a number" |
| 209 | fi |
| 210 | width="$2" |
| 211 | shift 2 |
| 212 | ;; |
| 213 | --dither) |
| 214 | if [[ -z "${2:-}" || "$2" == --* ]]; then |
| 215 | err "--dither requires an algorithm name" |
| 216 | fi |
| 217 | dither="$2" |
| 218 | shift 2 |
| 219 | ;; |
| 220 | --tonemap) |
| 221 | if [[ -z "${2:-}" || "$2" == --* ]]; then |
| 222 | err "--tonemap requires an algorithm name" |
| 223 | fi |
| 224 | tonemap="$2" |
| 225 | shift 2 |
| 226 | ;; |
| 227 | --start) |
| 228 | if [[ -z "${2:-}" || "$2" == --* ]]; then |
| 229 | err "--start requires a number" |
| 230 | fi |
| 231 | start_time="$2" |
| 232 | shift 2 |
| 233 | ;; |
| 234 | --duration) |
| 235 | if [[ -z "${2:-}" || "$2" == --* ]]; then |
| 236 | err "--duration requires a number" |
| 237 | fi |
| 238 | duration="$2" |
| 239 | shift 2 |
| 240 | ;; |
| 241 | --loop) |
| 242 | if [[ -z "${2:-}" || "$2" == --* ]]; then |
| 243 | err "--loop requires a number" |
| 244 | fi |
| 245 | loop="$2" |
| 246 | shift 2 |
| 247 | ;; |
| 248 | --skip-palette) |
| 249 | skip_palette=true |
| 250 | shift |
| 251 | ;; |
| 252 | --help|-h) |
| 253 | usage |
| 254 | ;; |
| 255 | -*) |
| 256 | err "Unknown option: $1" |
| 257 | ;; |
| 258 | *) |
| 259 | if [[ -z "${input_file}" ]]; then |
| 260 | input_file="$1" |
| 261 | else |
| 262 | err "Unexpected argument: $1" |
| 263 | fi |
| 264 | shift |
| 265 | ;; |
| 266 | esac |
| 267 | done |
| 268 | |
| 269 | # Validate input file |
| 270 | if [[ -z "${input_file}" ]]; then |
| 271 | err "Input file is required. Use --input FILE or provide as positional argument." |
| 272 | fi |
| 273 | |
| 274 | # Search for file if not found at given path |
| 275 | if [[ ! -f "${input_file}" ]]; then |
| 276 | local found_file |
| 277 | if found_file=$(find_video_file "${input_file}") && [[ -n "${found_file}" ]]; then |
| 278 | echo "Found: ${found_file}" |
| 279 | input_file="${found_file}" |
| 280 | else |
| 281 | err "Input file not found: ${input_file} |
| 282 | Searched: current directory, workspace root, ~/Movies (or ~/Videos), ~/Downloads, ~/Desktop" |
| 283 | fi |
| 284 | fi |
| 285 | |
| 286 | # Set default output file if not specified |
| 287 | if [[ -z "${output_file}" ]]; then |
| 288 | output_file="${input_file%.*}.gif" |
| 289 | fi |
| 290 | |
| 291 | # Validate dithering algorithm |
| 292 | case "${dither}" in |
| 293 | sierra2_4a|floyd_steinberg|bayer|none) ;; |
| 294 | *) |
| 295 | err "Invalid dithering algorithm: ${dither}. Options: sierra2_4a, floyd_steinberg, bayer, none" |
| 296 | ;; |
| 297 | esac |
| 298 | |
| 299 | # Validate tonemapping algorithm |
| 300 | case "${tonemap}" in |
| 301 | hable|reinhard|mobius|bt2390) ;; |
| 302 | *) |
| 303 | err "Invalid tonemapping algorithm: ${tonemap}. Options: hable, reinhard, mobius, bt2390" |
| 304 | ;; |
| 305 | esac |
| 306 | |
| 307 | # Check for FFmpeg |
| 308 | if ! command -v ffmpeg &>/dev/null; then |
| 309 | echo "ERROR: FFmpeg is required but not installed." >&2 |
| 310 | echo "" >&2 |
| 311 | echo "Install FFmpeg:" >&2 |
| 312 | echo " macOS: brew install ffmpeg" >&2 |
| 313 | echo " Ubuntu: sudo apt install ffmpeg" >&2 |
| 314 | echo " Windows: choco install ffmpeg" >&2 |
| 315 | exit 1 |
| 316 | fi |
| 317 | |
| 318 | # Detect HDR content |
| 319 | local is_hdr |
| 320 | is_hdr=$(detect_hdr "${input_file}") |
| 321 | |
| 322 | # Build time range arguments |
| 323 | local time_args=() |
| 324 | if [[ -n "${start_time}" ]]; then |
| 325 | time_args+=(-ss "${start_time}") |
| 326 | fi |
| 327 | if [[ -n "${duration}" ]]; then |
| 328 | time_args+=(-t "${duration}") |
| 329 | fi |
| 330 | |
| 331 | echo "Converting: ${input_file}" |
| 332 | echo "Output: ${output_file}" |
| 333 | echo "Settings: ${fps} FPS, ${width}px width, ${dither} dithering, loop=${loop}" |
| 334 | if [[ -n "${start_time}" ]] || [[ -n "${duration}" ]]; then |
| 335 | echo "Time range: start=${start_time:-0}s, duration=${duration:-full}" |
| 336 | fi |
| 337 | if [[ "${is_hdr}" == "true" ]]; then |
| 338 | echo "HDR: Detected, applying ${tonemap} tonemapping" |
| 339 | fi |
| 340 | |
| 341 | # Build video filter chain |
| 342 | local base_filter="fps=${fps},scale=${width}:-1:flags=lanczos" |
| 343 | |
| 344 | # Add HDR tonemapping if detected |
| 345 | # Convert HDR to SDR using selected tonemapping algorithm, then explicitly convert to sRGB for accurate GIF colors |
| 346 | if [[ "${is_hdr}" == "true" ]]; then |
| 347 | base_filter="zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=${tonemap}:desat=0,zscale=t=iec61966-2-1:m=bt709:r=full,format=rgb24,${base_filter}" |
| 348 | fi |
| 349 | |
| 350 | if [[ "${skip_palette}" == true ]]; then |
| 351 | echo "Mode: Single-pass (faster, lower quality)" |
| 352 | echo "" |
| 353 | |
| 354 | ffmpeg "${time_args[@]}" -i "${input_file}" \ |
| 355 | -vf "${base_filter}" \ |
| 356 | -loop "${loop}" -y "${output_file}" |
| 357 | else |
| 358 | echo "Mode: Two-pass palette optimization" |
| 359 | echo "" |
| 360 | |
| 361 | local palette_file="/tmp/palette_$$.png" |
| 362 | |
| 363 | # Pass 1: Generate palette |
| 364 | echo "Pass 1: Generating optimized palette..." |
| 365 | ffmpeg "${time_args[@]}" -i "${input_file}" \ |
| 366 | -vf "${base_filter},palettegen=stats_mode=diff" \ |
| 367 | -y "${palette_file}" |
| 368 | |
| 369 | # Pass 2: Create GIF |
| 370 | echo "Pass 2: Creating GIF with palette..." |
| 371 | ffmpeg "${time_args[@]}" -i "${input_file}" -i "${palette_file}" \ |
| 372 | -filter_complex "${base_filter}[x];[x][1:v]paletteuse=dither=${dither}:diff_mode=rectangle" \ |
| 373 | -loop "${loop}" -y "${output_file}" |
| 374 | |
| 375 | # Cleanup palette file |
| 376 | rm -f "${palette_file}" |
| 377 | fi |
| 378 | |
| 379 | if [[ -f "${output_file}" ]]; then |
| 380 | local file_size |
| 381 | file_size=$(get_file_size "${output_file}") |
| 382 | echo "" |
| 383 | echo "Conversion complete: ${output_file} ($(format_size "${file_size}"))" |
| 384 | else |
| 385 | err "Conversion failed. Output file was not created." |
| 386 | fi |
| 387 | } |
| 388 | |
| 389 | main "$@" |
| 390 | |