Update to better hand h265 / x265 encoded files.

This commit is contained in:
Brian McGonagill 2026-03-13 08:48:02 -05:00
parent 0e10143b5c
commit cdd27043a2
3 changed files with 101 additions and 12 deletions

67
app.py
View file

@ -62,10 +62,20 @@ VIDEO_EXTENSIONS = {
def get_video_info(filepath: str) -> dict | None:
"""
Use ffprobe to get duration, total bitrate, codec, and dimensions.
Bitrate resolution strategy (handles HEVC/MKV where stream-level
bit_rate is absent):
1. Stream-level bit_rate present for H.264/MP4, often missing for HEVC
2. Format-level bit_rate reliable for all containers
3. Derived from size/duration final fallback
"""
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'format=duration,bit_rate,size:stream=codec_name,width,height',
'-show_entries',
'format=duration,bit_rate,size:stream=codec_name,width,height,bit_rate',
'-of', 'json',
filepath,
]
@ -78,15 +88,26 @@ def get_video_info(filepath: str) -> dict | None:
stream = (data.get('streams') or [{}])[0]
duration = float(fmt.get('duration', 0))
bit_rate = int(fmt.get('bit_rate', 0))
size_bytes = int(fmt.get('size', 0))
codec = stream.get('codec_name', 'unknown')
width = stream.get('width', 0)
height = stream.get('height', 0)
if bit_rate == 0 and duration > 0:
# Prefer stream-level bitrate, fall back to format-level, then derive
stream_br = int(stream.get('bit_rate') or 0)
format_br = int(fmt.get('bit_rate') or 0)
if stream_br > 0:
bit_rate = stream_br
elif format_br > 0:
bit_rate = format_br
elif duration > 0:
bit_rate = int((size_bytes * 8) / duration)
else:
bit_rate = 0
# Target ≈ 1/3 of the total bitrate, reserving 128 kbps for audio.
# For HEVC sources the format bitrate already includes audio, so the
# same formula applies regardless of codec.
audio_bps = 128_000
video_bps = bit_rate - audio_bps if bit_rate > audio_bps else bit_rate
target_video_bps = max(int(video_bps / 3), 200_000)
@ -345,13 +366,21 @@ def run_compression_job(job_id: str) -> None:
src_path = file_info['path']
target_bitrate = file_info.get('target_bit_rate_bps', 1_000_000)
src_codec = file_info.get('codec', 'unknown').lower()
p = Path(src_path)
out_path = str(p.parent / (p.stem + suffix + p.suffix))
# Choose encoder to match the source codec.
# hevc / h265 / x265 → libx265
# everything else → libx264 (safe, universally supported)
is_hevc = src_codec in ('hevc', 'h265', 'x265')
encoder = 'libx265' if is_hevc else 'libx264'
push_event(job, {
'type': 'file_start', 'index': idx, 'total': total,
'filename': p.name, 'output': out_path,
'message': f'Compressing ({idx + 1}/{total}): {p.name}',
'encoder': encoder,
'message': f'Compressing ({idx + 1}/{total}): {p.name} [{encoder}]',
})
try:
@ -365,12 +394,34 @@ def run_compression_job(job_id: str) -> None:
duration_secs = 0
video_k = max(int(target_bitrate / 1000), 200)
# Build the encoder-specific part of the ffmpeg command.
#
# libx264 uses -maxrate / -bufsize for VBV (Video Buffering Verifier).
# libx265 passes those same constraints via -x265-params because its
# CLI option names differ from the generic ffmpeg flags.
# Both use AAC audio at 128 kbps.
# -movflags +faststart is only meaningful for MP4 containers; it is
# harmless (silently ignored) for MKV/MOV/etc.
if is_hevc:
vbv_maxrate = int(video_k * 1.5)
vbv_bufsize = video_k * 2
encoder_opts = [
'-c:v', 'libx265',
'-b:v', f'{video_k}k',
'-x265-params', f'vbv-maxrate={vbv_maxrate}:vbv-bufsize={vbv_bufsize}',
]
else:
encoder_opts = [
'-c:v', 'libx264',
'-b:v', f'{video_k}k',
'-maxrate', f'{int(video_k * 1.5)}k',
'-bufsize', f'{video_k * 2}k',
]
cmd = [
'ffmpeg', '-y', '-i', src_path,
'-c:v', 'libx264',
'-b:v', f'{video_k}k',
'-maxrate', f'{int(video_k * 1.5)}k',
'-bufsize', f'{video_k * 2}k',
*encoder_opts,
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
'-progress', 'pipe:1', '-nostats',

View file

@ -767,6 +767,35 @@ body {
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: var(--radius-pill);
border: 1px solid var(--border-base);
background: var(--bg-card2);
white-space: nowrap;
}
/* H.265 / HEVC — amber tint to flag it uses libx265 */
.codec-tag.hevc {
background: rgba(180, 100, 0, 0.10);
border-color: rgba(180, 100, 0, 0.35);
color: #7a4500;
}
[data-theme="dark"] .codec-tag.hevc {
background: rgba(251, 191, 36, 0.12);
border-color: rgba(251, 191, 36, 0.30);
color: #fbbf24;
}
/* H.264 / AVC — subtle blue tint */
.codec-tag.h264 {
background: rgba(26, 86, 219, 0.07);
border-color: rgba(26, 86, 219, 0.25);
color: #1e40af;
}
[data-theme="dark"] .codec-tag.h264 {
background: rgba(147, 197, 253, 0.10);
border-color: rgba(147, 197, 253, 0.25);
color: #93c5fd;
}
/* Checkbox styling */

View file

@ -272,11 +272,19 @@ function showScanStatus(msg, type) {
function renderFileTable(files) {
let html = '';
files.forEach((f, idx) => {
const sizeFmt = f.size_gb.toFixed(3);
const sizeFmt = f.size_gb.toFixed(3);
const curBitrate = f.bit_rate_mbps ? `${f.bit_rate_mbps} Mbps` : 'Unknown';
const tgtBitrate = f.target_bit_rate_mbps ? `${f.target_bit_rate_mbps} Mbps` : '—';
const codec = f.codec || 'unknown';
const pathDir = f.path.replace(f.name, '');
const codec = (f.codec || 'unknown').toLowerCase();
const pathDir = f.path.replace(f.name, '');
// Normalise codec label and pick a CSS modifier for the badge colour
const isHevc = ['hevc', 'h265', 'x265'].includes(codec);
const isH264 = ['h264', 'avc', 'x264'].includes(codec);
const codecLabel = isHevc ? 'H.265 / HEVC'
: isH264 ? 'H.264 / AVC'
: codec.toUpperCase();
const codecMod = isHevc ? 'hevc' : isH264 ? 'h264' : '';
html += `
<tr id="row-${idx}" data-path="${escHtml(f.path)}">
@ -305,7 +313,7 @@ function renderFileTable(files) {
<span class="bitrate-badge target">${escHtml(tgtBitrate)}</span>
</td>
<td class="col-codec">
<span class="codec-tag">${escHtml(codec)}</span>
<span class="codec-tag ${codecMod}" title="Encoder: ${isHevc ? 'libx265' : 'libx264'}">${escHtml(codecLabel)}</span>
</td>
</tr>`;
});
@ -370,6 +378,7 @@ els.compressBtn.addEventListener('click', async () => {
path: f.path,
size_bytes: f.size_bytes,
target_bit_rate_bps: f.target_bit_rate_bps || 1000000,
codec: f.codec || 'unknown',
})),
suffix,
};