Mọi bài viết

Dự án của tôiMột Đời — Lyric VideoRender trên CPU rồi ghép phim

Hướng dẫnPhần mềm

Render 9.848 khung hình 1080p trên CPU rồi ghép thành phim

Chụp 9.848 khung hình 1080p30 từ Chromium, hai cái bẫy khi chạy dài, và ba mức bitrate cho ba mục đích khác nhau.

Quy trình render thật: chụp 9.848 khung hình 1080p30 từ Chromium trong 2.604 giây với 0 khung lỗi, hai lỗi môi trường đã gặp, và cách chọn bitrate: CRF 20 cho bản gốc, và cách đưa file ra khỏi build bằng Internet Archive.

Đây là thành phẩm của hai chương trước: 5 phút 28 giây, 1080p, 30 khung hình mỗi giây, chữ sáng theo đúng giọng hát.

Bản 1080p30 phát từ Internet Archive. Bản gốc CRF 20 nặng 639 MB, Archive tạo thêm bản 480p 32 MB cho mạng yếu.

Phần còn lại của bài là những gì xảy ra phía sau khung hình đó.

Chọn cách bắt khung hình

Engine chạy trong trình duyệt, nên câu hỏi là bắt 9.848 khung hình ra file bằng cách nào. Hai cách đã đo:

CáchThời gian mỗi khungGhi chú
Gọi API chụp màn hình của trình duyệt~7,4 giâyMáy không GPU, trình duyệt vẽ bằng phần mềm
Trang tự xuất ảnh từ canvas rồi ghi ra file~1,5 giâyNhanh gần 5 lần

Mình chọn cách thứ hai. Script chỉ làm ba việc: đặt thời điểm t, xin ảnh JPEG chất lượng 88 từ chính trang, ghi ra f%05d.jpg. Không có lớp trung gian nào chụp lại màn hình.

Hai cái bẫy khi chạy dài

Lệnh chạy cả bài, chạy nền vì mất hàng chục phút:

bash render/run_capture.sh --start 0 --end 328.25 \
  --out ./frames --force

Log in ra tiến độ kèm dự phóng thời gian còn lại, đọc được ngay:

frame 9324/9848 t=310.80s  3.75 fps  elapsed 2488s  eta 2.3min  avg 336KB  skipped 0
DONE 9849 frames in 2603.7s (3.78 fps, 3223MB, skipped 0)

Lần chạy cuối ghi 9.849 file, đánh số từ 0 đến 9848, hết 2.603,7 giây (43 phút 24 giây), 0 khung bị bỏ, 3,2 GB ảnh JPEG, trung bình khoảng 335 KB mỗi khung. Mốc 9.848 là số khung cần cho 328,25 giây ở 30 khung/giây; dư một file vì script ghi cả khung ở mốc kết thúc.

Hai lỗi đã gặp, cả hai đều thuộc loại "chạy thử thì được, chạy dài thì chết":

  • Thiếu thư viện đồ hoạ: bản Chromium dùng để render giải nén thư viện ra thư mục tạm. Thư mục tạm bị dọn giữa các lần chạy, lần sau trình duyệt thoát sau 0,7 giây với lỗi thiếu libGLESv2.so. Cách chữa là ghim thư mục tạm cố định cho mọi lần bắt khung, không dùng thư mục tạm chung.
  • Đánh số khung hình: khi bắt một đoạn giữa bài, số thứ tự file phải bắt đầu đúng vị trí (start × fps), nếu không thì khi ghép, đoạn đó sẽ bị đặt sai chỗ trên timeline. Cách chữa là script tự tính số bắt đầu từ thời điểm bắt đầu.

Ghép phim, ba mức bitrate cho ba việc

ffmpeg -y -framerate 30 -start_number 0 -i frames/f%05d.jpg \
  -i audio/mot_doi_48k.wav \
  -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p \
  -c:a aac -b:a 160k -shortest -movflags +faststart out.mp4

Ba bản, cùng một nguồn khung hình:

BảnCấu hìnhDung lượngDùng để
Bản gốcCRF 20, preset medium639 MB (≈16 Mbps)Lưu trữ và phát trong bài qua Internet Archive
Bản 8 Mbps8 Mbps, hai lượt, maxrate 10M321 MB (8,19 Mbps)Gửi cho người cần file nhẹ, vẫn đủ nét 1080p
Bản nhẹ720p, CRF 3023 MBGửi qua ứng dụng chat

Bản 8 Mbps dùng hai lượt nên dung lượng bám sát mục tiêu, không vọt lên như CRF khi gặp đoạn nhiều chuyển động. Với CRF thì dung lượng là kết quả, còn với hai lượt thì dung lượng là đầu vào.

Vì sao bản gốc lên tới 16 Mbps trong khi hình chủ yếu là nền tối và chữ? Vì hạt phim. Engine có thêm một lớp nhiễu rất nhẹ trên mỗi khung hình để tránh cảm giác "phẳng kỹ thuật số". Với bộ nén, lớp nhiễu đó là thông tin mới ở từng khung, không thể tái sử dụng từ khung trước, nên bitrate tăng; và giảm chất lượng nén xuống thì hạt cũng bị bào mòn theo.

Mình giữ hạt cho bản gốc và bản 8 Mbps, còn bản 720p làm riêng cho việc gửi qua chat thì chấp nhận hạt mờ đi một chút để file nhẹ.

Không bản nào nằm trong repo hay trong Docker image. Bản gốc nằm ở Internet Archive, blog chỉ giữ một iframe, nên mỗi lần deploy không phải đẩy video lên VPS và người xem tải bytes từ Archive chứ không từ server của mình. Archive cũng tạo thêm một bản 480p nặng 32 MB, ai dùng mạng yếu thì chọn trong trình phát.

Kiểm tra trước khi coi là xong

Ba phép kiểm tra rẻ tiền nhưng bắt được gần hết lỗi:

  1. Đếm khung hình: 328,25 giây × 30 khung = 9.848 khung cần cho bài hát; trên đĩa có 9.849 file, không thiếu khung nào. File mp4 sau khi ghép còn 9.847 khung, vì -shortest cắt đúng theo độ dài âm thanh.
  2. Dò lại file đầu ra: thời lượng 328,25 giây, đúng bằng thời lượng bài hát; 1920×1080, 30/1 khung hình mỗi giây, hình h264, tiếng aac.
  3. Xem lại đúng những chỗ khó: quãng nghỉ 32,5 giây, quãng nghỉ 18,1 giây, khối mở đầu và khối kết thúc. Vì engine tất định, chỉ cần render lại vài đoạn ngắn là kiểm tra được, không phải chạy lại cả bài.

Nếu bạn định làm điều tương tự

Ba điều mình sẽ làm y như vậy nếu làm lại:

  • Để chính trang web xuất ảnh, đừng chụp màn hình; chênh gần 5 lần thời gian.
  • Với việc chạy hàng chục phút, ghim đường dẫn thư mục tạm và in tiến độ kèm dự phóng thời gian ra log. Lỗi môi trường luôn chỉ lộ ra khi chạy dài.
  • Thời gian render engine 2D này là 3,78 khung/giây trên CPU. Muốn nhanh hơn nữa thì phải đổi cách vẽ, không phải đổi bộ nén: nút cổ chai nằm ở khâu vẽ khung hình.

Gắn thẻ

ffmpegx264chromium headlessbitraterender video

Hỏi đáp

Chụp khung hình bằng cách nào nhanh nhất?

Trong máy không có GPU, gọi API chụp màn hình của trình duyệt tốn khoảng 7,4 giây mỗi khung, còn để chính trang web tự xuất ảnh từ canvas rồi ghi ra file chỉ tốn khoảng 1,5 giây mỗi khung. Chênh nhau gần năm lần, nên mình chọn cách thứ hai: trang tự xuất ảnh JPEG, script chỉ việc ghi file.

Vì sao phải có thư mục tạm riêng cho trình duyệt?

Bản Chromium dùng để render giải nén các thư viện đồ hoạ ra thư mục tạm. Nếu thư mục đó bị dọn giữa chừng, lần chạy sau thiếu thư viện và trình duyệt thoát ngay sau chưa đầy một giây. Phải ghim một thư mục tạm cố định và không cho ai dọn nó.

Nên chọn bitrate nào cho video 1080p30 dài 5 phút 28 giây?

Bài này xuất ba bản: bản gốc CRF 20 nặng 639 MB (khoảng 16 Mbps) để lưu trữ và phát trên blog qua Internet Archive, bản 8 Mbps hai lượt nặng 321 MB để gửi cho người cần file nhẹ, và bản 720p CRF 30 nặng 23 MB để gửi qua ứng dụng chat. Bitrate cao chủ yếu do hạt phim (grain): mỗi khung hình đều có nhiễu ngẫu nhiên nên nén không ăn.

Làm sao biết render có bị sót khung hình không?

Script ghi tiến độ theo từng lô và đếm số khung đã ghi, số khung bị bỏ. Lần chạy cuối ghi 9.849 file khung, 0 khung bị bỏ. Sau đó kiểm tra lại thời lượng phim đầu ra bằng công cụ dò file: 328,25 giây, đúng bằng thời lượng bài hát.

Nguồn tham khảo

  1. FFmpeg Documentation - FFmpeg
  2. Playwright — Browser automation - Microsoft
  3. @sparticuz/chromium — Chromium build cho môi trường không có GUI - GitHub
  4. yt-dlp - GitHub

Về tác giả

Ban biên tập

Viết cho Nhặt Nhạnh.

  • 159 bài
  • Viết hướng dẫn
  • Quan tâm phần mềm