From 4fdf95a15c62278031cf4a20db5d1efe430d2f9a Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Sat, 28 Mar 2026 19:51:54 +0000 Subject: [PATCH 1/2] feat: add audio file support (MP3, WAV, FLAC, OGG, AAC, M4A) Audio files were previously skipped as unreadable binaries. This adds: - `is_audio()` detection via magic bytes (ID3, RIFF/WAVE, fLaC, OggS, ADTS, M4A ftyp box) with extension fallback - Audio metadata extraction via `ffprobe`: title, artist, album, genre, year, duration, bitrate, codec, sample rate - `build_user_content()` audio mode passes rich metadata as text context so the LLM can suggest a name based on embedded tags - 13 new bats tests covering detection, metadata, and end-to-end naming - README updated with audio formats and ffprobe optional dependency Closes #19 --- README.md | 7 +++--- hat | 62 +++++++++++++++++++++++++++++++++++++++++++++ test.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5b9060d..58eb25b 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ Works with any OpenAI-compatible API: local servers (llama.cpp, Ollama, vLLM, LM ## Features - Animated Sorting Hat with drop animation, blinking eyes, and streaming thought bubble -- Supports text files and images including JPEG, PNG, GIF, BMP, TIFF, WebP, and SVG (via vision/multimodal models) -- Auto-detects image files by extension +- Supports text files, images (JPEG, PNG, GIF, BMP, TIFF, WebP, SVG), and audio files (MP3, WAV, FLAC, OGG, AAC, M4A) +- Auto-detects image and audio files by magic bytes and extension - Handles reasoning/thinking tokens from models like Qwen, DeepSeek, etc. - Quiet mode for scripting (`--quiet` / `-q`) - Configurable reasoning: guard clause defaults to no thinking, naming uses thinking. `--nothink` disables both, `--fullthink` enables both @@ -62,6 +62,7 @@ Works with any OpenAI-compatible API: local servers (llama.cpp, Ollama, vLLM, LM - An OpenAI-compatible LLM API endpoint - For image naming: a vision-capable model (e.g., GPT-4o, LLaVA, Qwen-VL) - Optional: `Pillow` (`pip install Pillow`) for EXIF metadata extraction from images +- Optional: `ffprobe` (from `ffmpeg`) for richer audio metadata (tags, duration, bitrate, codec) ## Installation @@ -188,7 +189,7 @@ done 1. **Guard clause**: Asks the LLM whether the current filename is already descriptive. If yes, skips the file. If no, the check conversation becomes context for the naming request (two-turn multi-turn). Use `--force` to skip the check entirely. 2. **Metadata collection**: Gathers file metadata (size, modification date, MIME type, EXIF for images) to give the LLM more context. Use `--no-metadata` to skip. -3. **File analysis**: For text files, reads the first 4KB of content. For images, base64-encodes and sends via the OpenAI multimodal format. +3. **File analysis**: For text files, reads the first 4KB of content. For images, base64-encodes and sends via the OpenAI multimodal format. For audio files, extracts tags, duration, bitrate, and codec via `ffprobe` (if available) and passes them as text context — no audio bytes are sent to the LLM. 4. **LLM query**: Sends the content, metadata, and any user context (`--context`) to your configured LLM with a prompt asking for a descriptive kebab-case filename. When the guard clause ran first, this becomes a multi-turn conversation with richer context. 5. **Streaming display**: Shows the model's reasoning tokens in a speech bubble above the animated hat (supports both `reasoning_content` field and `` tags). 6. **Name sanitization**: Cleans the response into a valid filename. When preserving extensions (default), the model only generates the name stem and the original extension is appended automatically. diff --git a/hat b/hat index 874aa2e..2039b35 100755 --- a/hat +++ b/hat @@ -72,6 +72,30 @@ is_binary() { [[ "$mime" == "binary" ]] } +# Detect audio by magic bytes first, then fall back to extension +is_audio() { + local file="$1" + local magic + magic=$(head -c 12 "$file" 2>/dev/null | od -A n -t x1 -N 12 2>/dev/null | tr -d ' \n') + # MP3: ID3 tag (49 44 33) or MPEG sync (ff fb / ff fa / ff f3 / ff f2) + [[ "$magic" == 494433* ]] && return 0 + [[ "$magic" == fffb* || "$magic" == fffa* || "$magic" == fff3* || "$magic" == fff2* ]] && return 0 + # FLAC: 66 4c 61 43 + [[ "$magic" == 664c6143* ]] && return 0 + # OGG: 4f 67 67 53 + [[ "$magic" == 4f676753* ]] && return 0 + # AAC (ADTS): ff f1 / ff f9 + [[ "$magic" == fff1* || "$magic" == fff9* ]] && return 0 + # WAV: RIFF (52 49 46 46) + WAVE at offset 8 (57 41 56 45) + [[ "$magic" == 52494646* ]] && [[ "${magic:16:8}" == "57415645" ]] && return 0 + # M4A / AAC in MP4: ftyp box — check bytes 4-7 for 66 74 79 70 + [[ "${magic:8:8}" == "66747970" ]] && return 0 + # Fall back to extension + local ext="${file##*.}" + ext="${ext,,}" + [[ "$ext" =~ ^(mp3|wav|flac|ogg|aac|m4a|opus|wma|aiff|ape)$ ]] +} + # Detect image by magic bytes first, then fall back to extension is_image() { local file="$1" @@ -124,6 +148,10 @@ if mode == "image": {'type': 'image_url', 'image_url': {'url': f'data:{mime};base64,{b64}'}}, {'type': 'text', 'text': prompt} ] +elif mode == "audio": + # Audio files can't be passed to the LLM directly — metadata was already + # injected into the prompt by collect_metadata(), so just forward the prompt. + content = prompt else: with open(file, 'r', errors='replace') as f: content = f.read(4000) @@ -246,6 +274,38 @@ if mode == 'image': if ed: meta['exif'] = ed except Exception: pass +elif mode == 'audio': + import subprocess + try: + result = subprocess.run( + ['ffprobe', '-v', 'quiet', '-print_format', 'json', + '-show_format', '-show_streams', file], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + probe = json.loads(result.stdout) + fmt = probe.get('format', {}) + tags = fmt.get('tags', {}) + # Normalise tag keys to lowercase + tags = {k.lower(): v for k, v in tags.items()} + for key in ('title', 'artist', 'album', 'album_artist', 'date', 'genre', 'track', 'comment'): + if tags.get(key): + meta[key] = tags[key][:100] + if fmt.get('duration'): + secs = float(fmt['duration']) + meta['duration'] = f'{int(secs//60)}:{int(secs%60):02d}' + if fmt.get('bit_rate'): + meta['bitrate_kbps'] = str(int(fmt['bit_rate']) // 1000) + # Codec from first audio stream + for stream in probe.get('streams', []): + if stream.get('codec_type') == 'audio': + if stream.get('codec_name'): + meta['codec'] = stream['codec_name'] + if stream.get('sample_rate'): + meta['sample_rate_hz'] = stream['sample_rate'] + break + except Exception: + pass print(json.dumps(meta, ensure_ascii=False)) " "$file" "$mode" } @@ -734,6 +794,8 @@ process_file() { local mode="text" if [[ "$force_image" == "true" ]] || is_image "$file"; then mode="image" + elif is_audio "$file"; then + mode="audio" fi if [[ "$mode" == "text" ]] && is_binary "$file"; then diff --git a/test.sh b/test.sh index dd77c14..182627a 100755 --- a/test.sh +++ b/test.sh @@ -12,8 +12,9 @@ setup_file() { # Source testable bash functions eval "$(sed -n '/^sanitize_name/,/^}/p' "$HAT")" eval "$(sed -n '/^is_binary/,/^}/p' "$HAT")" + eval "$(sed -n '/^is_audio/,/^}/p' "$HAT")" eval "$(sed -n '/^collect_metadata/,/^}/p' "$HAT")" - export -f sanitize_name is_binary collect_metadata + export -f sanitize_name is_binary is_audio collect_metadata # Start mock LLM server that handles multi-turn conversations export MOCK_PORT=18950 @@ -223,3 +224,75 @@ teardown_file() { assert_output --partial "could not reach LLM" refute_output --partial "Traceback" } + +# ── Audio detection ────────────────────────────────────────────────── + +@test "audio: mp3 is detected as audio" { + run is_audio "$TEST_ASSETS/sample.mp3" + assert_success +} + +@test "audio: wav is detected as audio" { + run is_audio "$TEST_ASSETS/sample.wav" + assert_success +} + +@test "audio: flac is detected as audio" { + run is_audio "$TEST_ASSETS/sample.flac" + assert_success +} + +@test "audio: ogg is detected as audio" { + run is_audio "$TEST_ASSETS/sample.ogg" + assert_success +} + +@test "audio: aac is detected as audio" { + run is_audio "$TEST_ASSETS/sample.aac" + assert_success +} + +@test "audio: m4a is detected as audio" { + run is_audio "$TEST_ASSETS/sample.m4a" + assert_success +} + +@test "audio: text file is not audio" { + run is_audio "$TEST_ASSETS/sample.txt" + assert_failure +} + +@test "audio: image file is not audio" { + run is_audio "$TEST_ASSETS/sample.jpg" + assert_failure +} + +# ── Audio metadata ─────────────────────────────────────────────────── + +@test "audio metadata: mp3 has size and modified" { + run collect_metadata "$TEST_ASSETS/sample.mp3" "audio" + assert_output --partial "size_bytes" + assert_output --partial "modified" +} + +@test "audio metadata: mp3 has mime_type" { + run collect_metadata "$TEST_ASSETS/sample.mp3" "audio" + assert_output --partial "mime_type" +} + +# ── Audio integration ──────────────────────────────────────────────── + +@test "audio: mp3 is processed (not skipped as binary)" { + run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.mp3' 2>/dev/null" + assert_output "suggested-name.mp3" +} + +@test "audio: flac is processed (not skipped as binary)" { + run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.flac' 2>/dev/null" + assert_output "suggested-name.flac" +} + +@test "audio: wav is processed (not skipped as binary)" { + run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.wav' 2>/dev/null" + assert_output "suggested-name.wav" +} From 17f12ffbeda22497bf7c44cc2215620c053063d9 Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Sat, 28 Mar 2026 22:21:15 +0000 Subject: [PATCH 2/2] feat: add video file support (MP4, WebM, MKV) Closes #18 Video files were previously skipped as unreadable binaries. This adds: - `is_video()` detection via magic bytes: EBML header (MKV/WebM), ftyp box with non-audio brand (MP4/MOV/M4V), AVI RIFF marker, MPEG sequence/pack headers. Extension fallback for .avi .mov .flv etc. - Refinement to `is_audio()` M4A detection: now checks the ftyp brand (M4A/M4B/M4P) so MP4 video files are no longer misclassified as audio - Video metadata extraction via `ffprobe`: title, duration, bitrate, video codec, resolution (WxH), frame rate - `build_user_content()` video mode passes metadata as text (no video frames sent to the LLM) - Test assets: sample.mp4 / .webm / .mkv (1-frame 64x64 via ffmpeg) - 9 new bats tests (47 total): detection for mp4/webm/mkv, negative cases (text, audio, m4a), end-to-end naming for all three formats --- hat | 75 +++++++++++++++++++++++++++++++++++++--- test-assets/sample.mkv | Bin 0 -> 1394 bytes test-assets/sample.mp4 | Bin 0 -> 1662 bytes test-assets/sample.webm | Bin 0 -> 585 bytes test.sh | 52 +++++++++++++++++++++++++++- 5 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 test-assets/sample.mkv create mode 100644 test-assets/sample.mp4 create mode 100644 test-assets/sample.webm diff --git a/hat b/hat index 2039b35..24c40ec 100755 --- a/hat +++ b/hat @@ -88,14 +88,42 @@ is_audio() { [[ "$magic" == fff1* || "$magic" == fff9* ]] && return 0 # WAV: RIFF (52 49 46 46) + WAVE at offset 8 (57 41 56 45) [[ "$magic" == 52494646* ]] && [[ "${magic:16:8}" == "57415645" ]] && return 0 - # M4A / AAC in MP4: ftyp box — check bytes 4-7 for 66 74 79 70 - [[ "${magic:8:8}" == "66747970" ]] && return 0 + # M4A / AAC in MP4: ftyp box — check bytes 4-7 for 66 74 79 70, + # but only if the ftyp brand is audio (M4A, M4B, mp42, etc.), not video + if [[ "${magic:8:8}" == "66747970" ]]; then + local brand="${magic:16:8}" + # M4A: 4d344120, M4B: 4d344220, M4P: 4d344220 — audio brands + [[ "$brand" == "4d344120" || "$brand" == "4d344220" || "$brand" == "4d345020" ]] && return 0 + fi # Fall back to extension local ext="${file##*.}" ext="${ext,,}" [[ "$ext" =~ ^(mp3|wav|flac|ogg|aac|m4a|opus|wma|aiff|ape)$ ]] } +# Detect video by magic bytes first, then fall back to extension +is_video() { + local file="$1" + local magic + magic=$(head -c 12 "$file" 2>/dev/null | od -A n -t x1 -N 12 2>/dev/null | tr -d ' \n') + # MKV / WebM: EBML header 1a 45 df a3 + [[ "$magic" == 1a45dfa3* ]] && return 0 + # AVI: RIFF (52 49 46 46) + AVI at offset 8 (41 56 49 20) + [[ "$magic" == 52494646* ]] && [[ "${magic:16:8}" == "41564920" ]] && return 0 + # MP4 / MOV / M4V: ftyp box at bytes 4-7 + if [[ "${magic:8:8}" == "66747970" ]]; then + local brand="${magic:16:8}" + # Exclude known audio-only brands (M4A, M4B, M4P) + [[ "$brand" != "4d344120" && "$brand" != "4d344220" && "$brand" != "4d345020" ]] && return 0 + fi + # MPEG-1/2 video: 00 00 01 b3 (sequence header) or 00 00 01 ba (pack header) + [[ "$magic" == 000001b3* || "$magic" == 000001ba* ]] && return 0 + # Fall back to extension + local ext="${file##*.}" + ext="${ext,,}" + [[ "$ext" =~ ^(mp4|mkv|avi|mov|webm|m4v|wmv|flv|mpeg|mpg|3gp|ogv|ts|mts|m2ts)$ ]] +} + # Detect image by magic bytes first, then fall back to extension is_image() { local file="$1" @@ -148,9 +176,9 @@ if mode == "image": {'type': 'image_url', 'image_url': {'url': f'data:{mime};base64,{b64}'}}, {'type': 'text', 'text': prompt} ] -elif mode == "audio": - # Audio files can't be passed to the LLM directly — metadata was already - # injected into the prompt by collect_metadata(), so just forward the prompt. +elif mode in ("audio", "video"): + # Audio/video files can't be passed to the LLM directly — metadata was + # already injected into the prompt by collect_metadata(). content = prompt else: with open(file, 'r', errors='replace') as f: @@ -306,6 +334,41 @@ elif mode == 'audio': break except Exception: pass +elif mode == 'video': + import subprocess + try: + result = subprocess.run( + ['ffprobe', '-v', 'quiet', '-print_format', 'json', + '-show_format', '-show_streams', file], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + probe = json.loads(result.stdout) + fmt = probe.get('format', {}) + tags = {k.lower(): v for k, v in fmt.get('tags', {}).items()} + for key in ('title', 'comment', 'date', 'artist', 'album'): + if tags.get(key): + meta[key] = tags[key][:100] + if fmt.get('duration'): + secs = float(fmt['duration']) + meta['duration'] = f'{int(secs//60)}:{int(secs%60):02d}' + if fmt.get('bit_rate'): + meta['bitrate_kbps'] = str(int(fmt['bit_rate']) // 1000) + for stream in probe.get('streams', []): + if stream.get('codec_type') == 'video': + if stream.get('codec_name'): + meta['video_codec'] = stream['codec_name'] + if stream.get('width') and stream.get('height'): + meta['resolution'] = str(stream['width']) + 'x' + str(stream['height']) + if stream.get('avg_frame_rate'): + fr = stream['avg_frame_rate'] + if '/' in fr: + n, d = fr.split('/') + if int(d) > 0: + meta['fps'] = str(round(int(n) / int(d), 2)) + break + except Exception: + pass print(json.dumps(meta, ensure_ascii=False)) " "$file" "$mode" } @@ -796,6 +859,8 @@ process_file() { mode="image" elif is_audio "$file"; then mode="audio" + elif is_video "$file"; then + mode="video" fi if [[ "$mode" == "text" ]] && is_binary "$file"; then diff --git a/test-assets/sample.mkv b/test-assets/sample.mkv new file mode 100644 index 0000000000000000000000000000000000000000..25fbed21b97fe6679219d94e2c7e3662c110e756 GIT binary patch literal 1394 zcmb_ce`p(39Dj-JtYx4RD?>#eEr?2++$A-(#gimWuvNR3rJdp*9`Ej6k|US9>)oYI z{A2Xm*raZwWnw3F4A+RD)}rVfSTzxy6!x!BD}u_zGAy%G(f#38zpq*S^|!~}d*9zb zKHqomj`%l~N_5)wM1P0xXSX5xi`x{PEN6*h8Fq$5OCWmA_3lqhq&$c{jgRabc-g5{ zi;o@sCckA${sl?Kxr&MR}O00l#vaxlu z9t|aSg8sh?tLJ>RC~hpq9$0&4>gH&8VB**lWGorte0<2qbKH}WfAjXMbA_#zv8~77 zYr4B(^QA9FE*_7~l*7-|>tY>+=c>g!Kl%0W^7XpiHP>UGE1wB%%+H+6)lRyo^~}ks zoUW8zG(CGr92kl}+Uf644EiI7dZ%1Z+l8s3S9%(KJM*?1{=f|{xZaUVF4~2`i;%qd zmPbL&+ux!6DcG0RWB1l#2;G1h*s}U?-~3Y^v?u-Jh4Zbot5>cdhHQAMT5N9Xr?&ly zT$@O(F2pOP_<`QRSo~nD^q&^(HMw>~{=4MV>f%4GUj%?;04T@!2_|<3mn(8^ZNJzSAWnwz3=CG_g0G={rv2Piz?BtT!bbN@-FXb+}?iU z>-fcwepv52JKz2#UT?ef6AOzwuxwZq^FUfbp6kLvp7PzvZcz03z`)P&>*&M)9#|?= z21T!77Brf4Fk}vP1h_yDglWeyd;I?K@p0dns!&5Cy3epuewM|TcCs4S7^b5dy4{0i zA`w~Ou|<;NH;W(5vPAj-3b zPK5y9&ht1)Y)3ThjA}BAdVo<=G?Gc1IzmSPJ826%YyswD7#WfVM%eQIp+wS94pfz3{s2QX>hk>@vnB7-jW5`O5&^pxU#l@5duLUheug60_O_>X|ljO zJIM>7ZV=cGHHA*BnlL0d5HLfCMMh!%Y?|!EB23Fg6x_b{+vdlOq1#r6PV8$x_uApB z--p**aOp<=vq(&>UH4)uPpFUB_ybITzi>WKEt^kwWwK-fniwov0Q+oW9?` VGW)KJ&OQ&f>*Y!BEnDtQe**O&(%k?6 literal 0 HcmV?d00001 diff --git a/test-assets/sample.mp4 b/test-assets/sample.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..0898271b8c421850692f37345ed4381e07befa0b GIT binary patch literal 1662 zcmZuyO=u)V6s}3!xP%}IS;!$mxvt_NGwJHdj!Cc~F}s+9!d_fN#HPBtI@3)5RH`b; z%pSyp;9k70;K8Hd4|?>Z8*qgM&x!#rqKKlnH_2sp{9aFHGX@`1_5Qy1s_MO}9>y3? z-9TryDj1t$#4&uiAB#e-2*%j?)F=htagj&|{`t(g;qa56uKw}WZ%5l--dg&RA3go| zy}sXB;Y}V_MsWerw1v0I+d_%8bPbN}768kyT)X;GbA@kS-vTmGF&tZ!9^@)@-1oe8 z)A#%~j9usS`ttI@!9i<3OH`H1vQ?R#Wm=`xbwv(LrCnB)cAdvEl5rroQEAZTiHh2udLh_Hdr^Q!8}E|hR=6Wr!mjJ=Ev9xqM3Mw}JW(LS=$DU(ZpV2|=W zBL`tz6=lu1jAWJcBJI#Ah8 z*G2GET9x5WrQsbJH9~u8fZTz9**g)#A}gVC8>>>qdoEbLz;| zH<+uN@38jp!9Ra}eXcWq_uBhUEPeaYTlat6IJ&^!eN^1p1ZH;ggNyl#w;!y&cL?o# z~L<4vFSI9s@jJwFZR0$_ykS=4yDf;jDDx|9|h(!`o<~p|D-9rv70DW56(AS zuU>O{0yxgrpRkR@v1jZ!qi>L%CK`-gQi-$Riz>Hx*ELZD@;-91=P6bsnS_lO$=S2} zFwd}8@6>V4Zjzf?!bC52EEB&@FL+y)Nv;Uf_!K=%Av>@~1iuW_RwH4v!=Ks04)|wBSDrn*f{Q_Anr3%j*uM2zgUxjR{N=lghxd<iz8fP~J=1zWKza;g4g>4PRqYwYdXS^N#7Xei3srM+KQ-BF+z%tQrgl7} eW;VJ|&wT7EO86xHHz)~hh@t2yiY_`Ry7w=Nlz349 literal 0 HcmV?d00001 diff --git a/test-assets/sample.webm b/test-assets/sample.webm new file mode 100644 index 0000000000000000000000000000000000000000..59e451e19a78530f135ae36490aa9cfa9345809c GIT binary patch literal 585 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1ehcReP@^K z^4;AXyt+lyb7flan#P3?o><7bY#{H3@9rL;oKVQ&^x!p3jB*gJOz>`?WD^tf8;Cg! zkznI!L4aJfN$dK?W_~9J--ceF#IiIq13g1CJwpS7a7R?WOJ|2e9K;bI=O0`Lb)Cia zM#kc!9o$#Wr@RlnCN!s!L22up=F+^Bjz)&I*0A`n0E^~EM&-xNElf#K3=cXtG&<~R zbePr11oHNu;^GC#2O82(%yM1a?&|04@8TNd(vE6zaq+d}1Kr68E7DIaL{>co=@5Wpbsysh;9^G1fns~Q;? z8a^;F%w^PaU~phyaOY@k?PzW3U}9omE3k1&;M%0{-GJfW|J4Bvi>Ea*XfRx4WMCCo r!yv&R5uyKufupa10VE!PMI02F6L/dev/null" assert_output "suggested-name.wav" } + +# ── Video detection ────────────────────────────────────────────────── + +@test "video: mp4 is detected as video" { + run is_video "$TEST_ASSETS/sample.mp4" + assert_success +} + +@test "video: webm is detected as video" { + run is_video "$TEST_ASSETS/sample.webm" + assert_success +} + +@test "video: mkv is detected as video" { + run is_video "$TEST_ASSETS/sample.mkv" + assert_success +} + +@test "video: text file is not video" { + run is_video "$TEST_ASSETS/sample.txt" + assert_failure +} + +@test "video: audio file is not video" { + run is_video "$TEST_ASSETS/sample.mp3" + assert_failure +} + +@test "video: m4a is not detected as video" { + run is_video "$TEST_ASSETS/sample.m4a" + assert_failure +} + +# ── Video integration ──────────────────────────────────────────────── + +@test "video: mp4 is processed (not skipped as binary)" { + run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.mp4' 2>/dev/null" + assert_output "suggested-name.mp4" +} + +@test "video: webm is processed (not skipped as binary)" { + run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.webm' 2>/dev/null" + assert_output "suggested-name.webm" +} + +@test "video: mkv is processed (not skipped as binary)" { + run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.mkv' 2>/dev/null" + assert_output "suggested-name.mkv" +}