microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
ci/884-codeql-python-analysis

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

.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
9set -euo pipefail
10
11# Default values
12DEFAULT_FPS=10
13DEFAULT_WIDTH=1280
14DEFAULT_DITHER="sierra2_4a"
15DEFAULT_TONEMAP="hable"
16DEFAULT_LOOP=0
17
18usage() {
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
45err() {
46 printf "ERROR: %s\n" "$1" >&2
47 exit 1
48}
49
50get_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
59format_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
72find_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
92find_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
149detect_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
170main() {
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}
282Searched: 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
389main "$@"
390