Bài viết "H.264 Is Magic" từ năm 2016 lại lên top Hacker News. Mười năm sau, AV1 và HEVC đã phổ biến hơn nhiều, nhưng nếu bạn cần một file video chạy được trên mọi trình duyệt, mọi chiếc điện thoại Android đời cũ và mọi Smart TV trong phòng họp của khách hàng, thì câu trả lời vẫn là H.264. Vấn đề là đa số developer mình gặp chỉ copy một lệnh ffmpeg -i input.mov output.mp4 trên StackOverflow rồi thôi. Kết quả là file 400MB cho một clip demo 2 phút, video phải tải xong mới play, hoặc chạy được trên Chrome nhưng màn hình đen trên Safari. Bài này tổng hợp những gì mình rút ra sau vài năm làm hệ thống upload và streaming video: không nói lý thuyết DCT hay motion vector, chỉ tập trung vào các flag bạn thực sự cần.
1. Hiểu đủ để chọn đúng flag: CRF, preset và GOP
H.264 nén video dựa trên ba ý tưởng chính: chia frame thành I-frame (ảnh đầy đủ), P-frame và B-frame (chỉ lưu phần khác biệt so với frame khác). Một nhóm frame bắt đầu bằng I-frame gọi là GOP. Bạn chỉ cần nắm ba "núm vặn" sau:
- CRF (Constant Rate Factor): thang 0–51, mặc định 23. Số càng nhỏ chất lượng càng cao và file càng nặng. Cứ tăng 6 đơn vị thì dung lượng giảm khoảng một nửa. Với video web, khoảng 20–24 là hợp lý; 18 gần như không phân biệt được với bản gốc.
-
Preset: từ
ultrafastđếnveryslow. Preset không đổi chất lượng ở cùng một CRF, nó đổi thời gian encode lấy dung lượng.slowthường cho file nhỏ hơnmediumkhoảng 5–10%, đổi lại encode lâu gấp đôi. - GOP / keyframe interval: quyết định bạn có thể seek tới đâu và cắt segment HLS ở đâu. Với streaming, nên cố định GOP (ví dụ 2 giây).
flowchart LR
A[Video gốc .mov/.mkv] --> B{Mục đích?}
B -->|File tải về / embed ngắn| C[MP4 + CRF 23 + faststart]
B -->|Video dài, nhiều thiết bị| D[Encode nhiều bitrate]
D --> E[Cố định GOP 2s]
E --> F[HLS segments .ts/.m4s]
C --> G[CDN / Object storage]
F --> G
G --> H[Browser: video tag / hls.js]
2. Lệnh FFmpeg "chuẩn" cho MP4 trên web
Đây là lệnh mình dùng làm mặc định với FFmpeg 7.x, chạy ổn trên mọi trình duyệt hiện đại:
ffmpeg -i input.mov \
-c:v libx264 -preset slow -crf 22 \
-profile:v high -level:v 4.1 \
-pix_fmt yuv420p \
-vf "scale='min(1920,iw)':-2" \
-c:a aac -b:a 128k -ac 2 \
-movflags +faststart \
output.mp4
# Kiểm tra kết quả
ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,profile,pix_fmt,width,height,bit_rate \
-of default=nw=1 output.mp4
Vài flag quan trọng mà người ta hay quên:
-
-pix_fmt yuv420p: video quay từ iPhone hoặc export từ After Effects hay ở dạngyuv422p/yuv444p. Chrome trên desktop vẫn play được, nhưng Safari, Firefox trên Android và nhiều TV sẽ cho màn hình đen. Đây là nguyên nhân số một của bug "chỉ hỏng trên máy sếp". -
-movflags +faststart: chuyểnmoovatom (metadata) lên đầu file. Thiếu flag này thì trình duyệt phải tải gần hết file mới biết cách play. Riêng flag này đã giúp time-to-first-frame của team mình giảm từ vài giây xuống dưới 500ms. -
scale='min(1920,iw)':-2: chỉ thu nhỏ khi video lớn hơn 1080p, và-2giữ chiều cao là số chẵn, vì H.264 với yuv420p bắt buộc kích thước chẵn. Thiếu nó bạn sẽ gặp lỗiheight not divisible by 2. -
-profile:v high -level:v 4.1: tương thích với hầu hết thiết bị sản xuất từ khoảng 2012 trở lại đây.
Nếu đã có sẵn file MP4 mà chỉ thiếu faststart thì không cần encode lại: ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4 chạy chỉ trong vài giây.
3. Batch encode tự động bằng Python
Khi user upload video lên hệ thống, bạn cần một worker xử lý tự động: đọc metadata, bỏ qua file đã đạt chuẩn và encode những file còn lại. Script dưới đây dùng Python 3.11+, chỉ cần thư viện chuẩn:
import json
import subprocess
import sys
from pathlib import Path
def probe(path: Path) -> dict:
out = subprocess.run(
["ffprobe", "-v", "error", "-print_format", "json",
"-show_streams", "-show_format", str(path)],
capture_output=True, text=True, check=True,
).stdout
data = json.loads(out)
video = next(s for s in data["streams"] if s["codec_type"] == "video")
return {
"codec": video["codec_name"],
"pix_fmt": video.get("pix_fmt"),
"height": int(video["height"]),
"size_mb": int(data["format"]["size"]) / 1024 / 1024,
}
def is_web_ready(info: dict) -> bool:
return (info["codec"] == "h264"
and info["pix_fmt"] == "yuv420p"
and info["height"] <= 1080)
def encode(src: Path, dst: Path, crf: int = 22) -> None:
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-c:v", "libx264", "-preset", "slow", "-crf", str(crf),
"-pix_fmt", "yuv420p", "-vf", "scale='min(1920,iw)':-2",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", str(dst),
]
subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL)
if __name__ == "__main__":
src_dir, out_dir = Path(sys.argv[1]), Path(sys.argv[2])
out_dir.mkdir(exist_ok=True)
for f in sorted(src_dir.glob("*.[mM][oOpP][vV4]")):
info = probe(f)
dst = out_dir / f"{f.stem}.mp4"
if is_web_ready(info):
# Chỉ remux để thêm faststart, không encode lại
subprocess.run(["ffmpeg", "-y", "-i", str(f), "-c", "copy",
"-movflags", "+faststart", str(dst)],
check=True, stderr=subprocess.DEVNULL)
else:
encode(f, dst)
new_mb = dst.stat().st_size / 1024 / 1024
print(f"{f.name}: {info['size_mb']:.1f}MB -> {new_mb:.1f}MB")
Chạy thử: python3 encode_batch.py ./uploads ./web. Với một folder video quay từ điện thoại, mình thường thấy dung lượng giảm 60–80% mà mắt thường gần như không nhận ra khác biệt. Trên production, nên chạy script này trong một job queue (Celery, BullMQ...) và giới hạn số job song song, vì libx264 ăn hết mọi core CPU bạn cho nó.
4. Video dài: chuyển sang HLS với nhiều mức chất lượng
Với video dài hơn vài phút, hoặc khi user dùng 4G chập chờn, một file MP4 duy nhất là không đủ. HLS chia video thành các segment nhỏ kèm nhiều mức bitrate, và player tự chuyển chất lượng theo băng thông.
sequenceDiagram
participant P as Player (hls.js)
participant C as CDN
P->>C: GET master.m3u8
C-->>P: Danh sách 1080p / 720p / 480p
P->>C: GET 720p/index.m3u8
P->>C: GET 720p/seg_000.ts
Note over P: Băng thông giảm
P->>C: GET 480p/seg_001.ts
Lệnh dưới đây tạo 2 mức chất lượng. Điểm mấu chốt là -g 48 -keyint_min 48 -sc_threshold 0 (video 24fps, keyframe đúng mỗi 2 giây), giúp các segment ở mọi mức bitrate thẳng hàng với nhau để player chuyển mượt mà không bị giật:
ffmpeg -i input.mp4 \
-filter_complex "[0:v]split=2[a][b];[a]scale=-2:1080[v1];[b]scale=-2:720[v2]" \
-map "[v1]" -map 0:a -map "[v2]" -map 0:a \
-c:v libx264 -preset veryfast -pix_fmt yuv420p \
-g 48 -keyint_min 48 -sc_threshold 0 \
-b:v:0 5000k -maxrate:v:0 5350k -bufsize:v:0 7500k \
-b:v:1 2800k -maxrate:v:1 3000k -bufsize:v:1 4200k \
-c:a aac -b:a 128k \
-f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_filename "out/%v/seg_%03d.ts" \
-master_pl_name master.m3u8 \
-var_stream_map "v:0,a:0 v:1,a:1" \
"out/%v/index.m3u8"
Ở phía frontend, Safari hỗ trợ HLS sẵn, còn các trình duyệt khác thì dùng hls.js:
import Hls from "hls.js";
const video = document.querySelector("video");
const src = "/videos/demo/master.m3u8";
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src; // Safari, iOS
} else if (Hls.isSupported()) {
const hls = new Hls({ capLevelToPlayerSize: true });
hls.loadSource(src);
hls.attachMedia(video);
}
capLevelToPlayerSize: true là một option nhỏ nhưng hiệu quả: player 400px trên mobile sẽ không tải bản 1080p, tiết kiệm đáng kể băng thông CDN.
Kết luận
H.264 không còn là codec mới nhất, nhưng vẫn là lựa chọn an toàn nhất khi cần tương thích. Những việc bạn có thể làm ngay hôm nay:
-
Luôn thêm
-pix_fmt yuv420pvà-movflags +faststart. Hai flag này xử lý được khoảng 80% bug video trên web. -
Dùng CRF thay vì bitrate cố định cho MP4: bắt đầu với
-crf 22 -preset slow, rồi điều chỉnh theo mắt. -
Kiểm tra bằng
ffprobetrước khi encode: file đã đạt chuẩn thì chỉ cần remux với-c copy, nhanh hơn hàng trăm lần. -
Video trên 3–5 phút thì chuyển sang HLS, cố định GOP bằng
-gvà-sc_threshold 0. -
Đưa encode vào queue riêng, đừng chạy
ffmpegtrong request handler của API server.
Khi đã nắm chắc nền tảng này, chuyển sang AV1 (libsvtav1) hay HEVC chỉ là thay vài flag, vì tư duy về CRF, GOP và packaging vẫn giữ nguyên.
Top comments (0)