Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 63 additions & 3 deletions hat
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,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"
Expand Down Expand Up @@ -157,9 +180,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:
Expand Down Expand Up @@ -386,6 +409,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"
}
Expand Down Expand Up @@ -876,6 +934,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
Expand Down
Binary file added test-assets/sample.mkv
Binary file not shown.
Binary file added test-assets/sample.mp4
Binary file not shown.
Binary file added test-assets/sample.webm
Binary file not shown.
52 changes: 51 additions & 1 deletion test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ setup_file() {
eval "$(sed -n '/^is_binary/,/^}/p' "$HAT")"
eval "$(sed -n '/^is_image/,/^}/p' "$HAT")"
eval "$(sed -n '/^is_audio/,/^}/p' "$HAT")"
eval "$(sed -n '/^is_video/,/^}/p' "$HAT")"
eval "$(sed -n '/^collect_metadata/,/^}/p' "$HAT")"
export -f sanitize_name is_binary is_image is_audio collect_metadata
export -f sanitize_name is_binary is_image is_audio is_video collect_metadata

# Start mock LLM server that handles multi-turn conversations
export MOCK_PORT=18950
Expand Down Expand Up @@ -454,6 +455,55 @@ teardown_file() {
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"
}

# ── Preview flag ─────────────────────────────────────────────────────

@test "preview: --preview shows content preview on stderr for text file" {
Expand Down
Loading