diff --git a/app.py b/app.py index 97fca89..8d180e4 100644 --- a/app.py +++ b/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', diff --git a/static/css/main.css b/static/css/main.css index 75fc31b..c23a460 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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 */ diff --git a/static/js/app.js b/static/js/app.js index 7c29f4e..d9602c2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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 += ` @@ -305,7 +313,7 @@ function renderFileTable(files) { ${escHtml(tgtBitrate)} - ${escHtml(codec)} + ${escHtml(codecLabel)} `; }); @@ -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, };