Update to better hand h265 / x265 encoded files.
This commit is contained in:
parent
0e10143b5c
commit
cdd27043a2
3 changed files with 101 additions and 12 deletions
67
app.py
67
app.py
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue