From 664bbf17957eaf751327dbf810e3b3ee2e7e194a Mon Sep 17 00:00:00 2001 From: marksverdhei Date: Sat, 28 Mar 2026 22:21:15 +0000 Subject: [PATCH] feat: add video file support (MP4, WebM, MKV) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_video() detects MKV/WebM (EBML header), AVI (RIFF...AVI), MP4/MOV/M4V (ftyp box, excluding M4A/M4B/M4P audio brands), and MPEG-1/2 (sequence/pack header), with extension fallback for mp4/mkv/avi/mov/webm/m4v/wmv/flv/mpeg/ mpg/3gp/ogv/ts/mts/m2ts. Routes video files through the same metadata-only path as audio (ffprobe for tags/duration/codec; no raw bytes sent to the LLM) — collect_metadata and build_user_content already handle both 'audio' and 'video' modes. Adds 9 bats tests (mp4/webm/mkv detection + negative cases + e2e naming) and 3 tiny test fixtures (sample.mp4/.webm/.mkv). Re-land of #22 (hai-pilgrim's PR is 3 months stale). Cherry-pick conflict in hat's is_audio() resolved in favour of HEAD's richer version with #26 context; conflict in test.sh export -f resolved by combining both sides (needs is_image AND is_video). Closes #18. --- hat | 66 ++++++++++++++++++++++++++++++++++++++-- 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, 114 insertions(+), 4 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 a189c35..e8c751b 100755 --- a/hat +++ b/hat @@ -104,6 +104,29 @@ is_audio() { [[ "$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" @@ -156,9 +179,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: @@ -314,6 +337,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" } @@ -804,6 +862,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" +}