@oryzateam/hevc-player
v0.4.8
Published
Trình phát HEVC/H.265 cho web: libavcodec WASM thường trú, WebGL trong worker, hỗ trợ MoQ/HLS/MP4
Readme
@oryzateam/hevc-player
Trình phát HEVC/H.265 cho web, dựng thành React component.
Giải mã bằng libavcodec WASM thường trú và vẽ bằng WebGL trong worker, tự
dùng <video> native khi nguồn là H.264 (hoặc khi máy giải mã được HEVC bằng
phần cứng). Nguồn hỗ trợ: MoQ (Media-over-QUIC của MediaMTX), HLS, và
MP4 trực tiếp — kèm seek tới giây N và tự phục hồi khi lỗi mạng tạm thời.
Không phụ thuộc Tailwind; style tự chứa.
Cài đặt
npm install @oryzateam/hevc-playerreact/react-dom là peer dependency.
1. Chép lõi giải mã
Player nạp lõi WASM và hai worker tại /hevc/... lúc chạy. Thêm vào
package.json của app:
{
"scripts": {
"predev": "hevc-copy-assets public",
"prebuild": "hevc-copy-assets public"
}
}Thư mục kết quả (1.8 MB):
public/hevc/
├── libavcodec/ lõi WASM 512MB — nguồn 4K, một luồng
├── libavcodec-grid/ lõi WASM 64MB — ô lưới, tới 1080p
└── stream/ worker giải mã + worker renderChép chứ không để bundler xử lý, vì đây là worker + WASM: chúng phải là file
riêng, cùng origin, và worker tự nạp lẫn nhau bằng đường dẫn thật. Muốn host ở
chỗ khác (CDN, base path riêng) thì đổi bằng configureHevc — xem phần Cấu hình.
2. Bật cross-origin isolation — BẮT BUỘC
Lõi là bản pthread, dùng SharedArrayBuffer, nên trang phải được cách ly:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpThiếu hai header này thì worker chết ngay ở bước cấp bộ nhớ chia sẻ, với lỗi rất
mơ hồ. Kiểm tra nhanh trong console: crossOriginIsolated === true.
require-corp chặn mọi tài nguyên cross-origin không khai
Cross-Origin-Resource-Policy — kể cả ảnh từ CDN. Ba đường xử lý, theo thứ tự
nên thử:
Cross-Origin-Embedder-Policy: credentialless— Chrome 96+ không đòi CORP với tài nguyênno-cors, ảnh cross-origin vẫn tải được.- Đặt header chỉ trên route có player, phần còn lại của app không đụng tới.
- Yêu cầu origin của tài nguyên trả
Cross-Origin-Resource-Policy: cross-origin.
Next.js:
// next.config.js
async headers() {
return [{
source: "/vms/:path*",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "credentialless" },
],
}];
}3. Dùng
import { HevcPlayer } from "@oryzateam/hevc-player";
<HevcPlayer url={url} autoPlay muted controls />;Next.js App Router — component chạy client-only:
const HevcPlayer = dynamic(
() => import("@oryzateam/hevc-player").then((m) => m.HevcPlayer),
{ ssr: false },
);Kiểu nguồn tự nhận theo URL; ép bằng sourceType:
| sourceType | Nhận diện tự động khi |
| --- | --- |
| "moq" | https, cổng 8892, không có đuôi file |
| "mp4" | đường dẫn kết thúc .mp4 |
| "hls" | còn lại |
Props chính
| Prop | Mặc định | Ý nghĩa |
| --- | --- | --- |
| url | — | nguồn phát |
| sourceType | "auto" | ép kiểu nguồn |
| forceEngine | "auto" | "wasm" bỏ qua bước thử <video>; "native" không fallback |
| memoryProfile | tự đo | "small" (64MB) hoặc "large" (512MB) — xem bên dưới |
| fps | 25 | FPS render mục tiêu |
| objectFit | "contain" | như CSS object-fit |
Điều khiển qua ref
const player = useRef<HevcPlayerHandle>(null);
player.current?.play();
player.current?.pause();
player.current?.resume();
player.current?.toggle();
player.current?.stop();
player.current?.seek(30); // chỉ MP4
await player.current?.unmute(); // chỉ MoQ, phải gọi từ thao tác người dùngHook
const player = useHevcPlayer({ url, sourceType: "auto" });
// { status, engine, sourceSize, error, hasAudio, audioMuted,
// isPlaying, started, posterFrame, videoRef, canvasRef,
// play, pause, resume, toggle, stop, seek, unmute }status: idle · loading · queued · playing · paused · ended ·
error.
queued nghĩa là đang chờ suất giải mã (xem maxConcurrentStreams), khác hẳn
loading — UI nên hiện "đang chờ" thay vì spinner tải.
Tiếng
Một API duy nhất cho mọi nhánh: hasAudio, audioMuted, mute(), unmute().
| Nguồn | Tiếng | Bên dưới là gì |
| --- | --- | --- |
| MoQ | có | AudioContext tự dựng — resume()/suspend() |
| Native (<video>) | có | thuộc tính muted của <video> |
| HLS/MP4 qua WASM | không | nhánh này chỉ giải mã video, hasAudio luôn false |
{player.hasAudio && (
<button onClick={() => (player.audioMuted ? player.unmute() : player.mute())}>
{player.audioMuted ? "Bật tiếng" : "Tắt tiếng"}
</button>
)}unmute() PHẢI gọi từ một thao tác của người dùng — chính sách tự phát chặn cả
AudioContext.resume() lẫn việc bỏ muted của <video> đang tự phát. Chiều tắt
thì gọi lúc nào cũng được.
Ẩn controls gốc để tự vẽ nút thì vẫn dùng đúng API này, nên nút và tiếng không
thể lệch nhau: audioMuted đọc thẳng trạng thái thật, không phải bản sao của
lệnh vừa ra.
Với nhánh native, hasAudio dò theo mozHasAudio / audioTracks /
webkitAudioDecodedByteCount — không có API chuẩn nào cho việc này. Trên Chrome
nó chỉ chuyển sang true sau khi mẩu tiếng đầu tiên được giải mã, tức trễ vài
trăm ms so với lúc hình lên.
Vùng nhớ: memoryProfile
Lõi WASM cấp bộ nhớ một lần lúc khởi tạo và không nở ra được
(initial === maximum). Vượt trần không phải chậm đi mà là
memory access out of bounds rồi worker chết. Nên có hai bản lõi:
| | Vùng nhớ | Luồng pthread | Dùng cho |
| --- | --- | --- | --- |
| "small" | 64 MB | 1 | ô lưới, nguồn tới 1080p |
| "large" | 512 MB | 8 | một luồng, nguồn 4K |
Không truyền thì player tự đo: đọc moov (MP4/HLS) hoặc SPS trong gói khoá
đầu tiên (MoQ) rồi chọn. Không tốn request nào — dữ liệu đó vốn đã được parse
trong lúc mở nguồn.
Truyền tường minh khi bạn biết rõ hơn nó. Ví dụ lưới 9 ô:
<HevcPlayer memoryProfile="small" … />Chín ô camera 4K mà để tự đo thì ra chín bản 512 MB — 4.6 GB, tab chết. Ép
"small" là nói "chấp nhận không xem 4K ở ô lưới, đổi lại chín ô cùng sống" —
quyết định đó thuộc về app, không phải thư viện.
Cấu hình
Gọi một lần lúc bootstrap, trước khi mount player đầu tiên:
import { configureHevc } from "@oryzateam/hevc-player";
configureHevc({ maxConcurrentStreams: 9 });| Key | Mặc định | Ý nghĩa |
| --- | --- | --- |
| maxConcurrentStreams | 4 | trần luồng giải mã song song; vượt trần thì xếp hàng (queued) |
| targetFps | 25 | FPS render/decode mục tiêu |
| maxPendingAccessUnits | 24 | trần backpressure — access unit đã nạp mà worker chưa tiêu thụ |
| libavcodecBasePath | /hevc/libavcodec/ | thư mục lõi 512MB |
| libavcodecGridBasePath | /hevc/libavcodec-grid/ | thư mục lõi 64MB |
| streamWorkerPath | /hevc/stream/hevc-stream-worker.js | worker giải mã |
| renderWorkerPath | /hevc/stream/hevc-render-worker.js | worker render |
| libavcodecThreads | 8 | pthread cho "large" |
| libavcodecGridThreads | 1 | pthread cho "small" |
| mp4RangeBytes | 8 MiB | cỡ mỗi HTTP Range khi tải MP4 |
| moqFingerprintProxy | — | đổi URL vân tay chứng chỉ MoQ sang proxy cùng origin |
maxConcurrentStreams là van bộ nhớ duy nhất: mỗi luồng giữ một worker
libavcodec kèm nguyên vùng heap của nó. Lưới 9 ô nên đặt đúng 9, nếu không sẽ có
ô nằm chờ vô hạn — xem trực tiếp thì không ô nào dừng để nhả suất cả.
MoQ (MediaMTX)
Dán thẳng URL trang xem của MediaMTX, kể cả phần credentials:
https://user:pass@host:8892/tên-luồngPlayer tự tách thành <luồng>/moq (WebTransport) và <luồng>/fingerprint (vân
tay chứng chỉ), giữ credentials để gửi trong MOQT auth token — chúng đi trong
bản tin MOQT chứ không phải HTTP basic auth.
Yêu cầu: Chromium, và trang phải ở secure context (WebTransport không tồn tại ngoài https/localhost).
Chứng chỉ của listener QUIC khác chứng chỉ mà cùng cổng đó phục vụ trên TCP —
player luôn lấy từ endpoint /fingerprint. Nếu MediaMTX bắt xác thực để đọc
endpoint đó thì gọi thẳng từ trình duyệt sẽ chết ở CORS preflight (server không
khai Access-Control-Allow-Headers); lúc ấy đi vòng qua server của bạn:
configureHevc({
moqFingerprintProxy: (url) =>
`/api/moq/fingerprint?target=${encodeURIComponent(url)}`,
});Codec: avc3 đi WebCodecs, hev1 đi libavcodec — nên HEVC qua MoQ chạy được
cả trên máy không có bộ giải mã HEVC phần cứng.
Playground
npm install
npm run dev # http://localhost:5183, đã bật sẵn COOP/COEP
npm run dev:media # sinh nguồn HEVC mẫu bằng ffmpegCó sẵn: ô nhập URL, chọn kiểu nguồn (auto/mp4/hls/moq), chọn vùng nhớ,
FPS, nút tua, và bảng log realtime kèm badge engine/status/độ phân giải. Lõi lấy
thẳng từ assets/hevc/ (Vite dùng làm publicDir) nên không phải chép gì.
Proxy dev bọc URL thành /proxy?url=… để: tách user:pass@ thành header
Authorization (trình duyệt chặn URL nhúng credentials), chuyển tiếp Range và
header conditional cho nhánh seek, và viết lại URI trong playlist HLS. URL MoQ
không đi qua proxy — WebTransport là HTTP/3 trên UDP, proxy TCP không bọc
được.
Nút Tua chỉ có tác dụng với MP4 hỗ trợ HTTP Range. Nguồn fMP4 dạng
/get?start=…không seek được (sample table rỗng) — player log error, đúng thiết kế: loại này phải xin URL mới từ backend.
Phát triển
npm test # build rồi chạy test trên dist/
npm run typecheck
npm run buildTest chạy trên dist/ chứ không phải src/: thứ consumer thật sự nhận là bản đã
build, nên lỗi ở khâu export/bundle cũng bị bắt.
License
Xem LICENSE. Lõi libavcodec kèm giấy phép riêng trong
assets/hevc/libavcodec/LICENSE.
