Отключете напреднала обработка на видео в браузъра. Научете се да достъпвате и манипулирате директно сурови данни от равнините на VideoFrame с WebCodecs API за персонализирани ефекти и анализ.
Достъп до равнините на WebCodecs VideoFrame: Подробен поглед върху манипулацията на сурови видео данни
Години наред високопроизводителната обработка на видео в уеб браузъра изглеждаше като далечна мечта. Разработчиците често бяха ограничени от <video> елемента и 2D Canvas API, които, макар и мощни, създаваха проблеми с производителността и ограничаваха достъпа до основните сурови видео данни. Появата на WebCodecs API коренно промени тази среда, предоставяйки нисконивов достъп до вградените в браузъра медийни кодеци. Една от най-революционните му функции е възможността за директен достъп и манипулация на суровите данни на отделни видео кадри чрез обекта VideoFrame.
Тази статия е подробно ръководство за разработчици, които искат да надхвърлят простото възпроизвеждане на видео. Ще разгледаме тънкостите на достъпа до равнините на VideoFrame, ще изясним концепции като цветови пространства и разположение в паметта и ще предоставим практически примери, които ще ви дадат възможност да изградите следващото поколение видео приложения в браузъра – от филтри в реално време до сложни задачи за компютърно зрение.
Предварителни изисквания
За да извлечете максимална полза от това ръководство, трябва да имате добри познания по:
- Модерен JavaScript: Включително асинхронно програмиране (
async/await, Promises). - Основни видео концепции: Познаването на термини като кадри, резолюция и кодеци е полезно.
- Браузърни API-та: Опит с API-та като Canvas 2D или WebGL ще бъде от полза, но не е задължителен.
Разбиране на видео кадри, цветови пространства и равнини
Преди да се потопим в API, първо трябва да изградим ясен мисловен модел за това как всъщност изглеждат данните на един видео кадър. Дигиталното видео е поредица от неподвижни изображения или кадри. Всеки кадър е мрежа от пиксели, а всеки пиксел има цвят. Начинът, по който този цвят се съхранява, се определя от цветовото пространство и пикселния формат.
RGBA: „Родният език“ на уеб
Повечето уеб разработчици са запознати с цветовия модел RGBA. Всеки пиксел се представя от четири компонента: червен (Red), зелен (Green), син (Blue) и алфа (прозрачност, Alpha). Данните обикновено се съхраняват последователно (interleaved) в паметта, което означава, че стойностите R, G, B и A за един пиксел се записват една след друга:
[R1, G1, B1, A1, R2, G2, B2, A2, ...]
При този модел цялото изображение се съхранява в един непрекъснат блок памет. Можем да си го представим като една единствена „равнина“ (plane) с данни.
YUV: Езикът на видео компресията
Видео кодеците обаче рядко работят директно с RGBA. Те предпочитат YUV (или по-точно Y'CbCr) цветови пространства. Този модел разделя информацията за изображението на:
- Y (Luma): Информация за яркостта или нивата на сивото. Човешкото око е най-чувствително към промените в яркостта.
- U (Cb) и V (Cr): Информация за цветността или цветовите разлики. Човешкото око е по-малко чувствително към детайлите в цвета, отколкото към тези в яркостта.
Това разделяне е ключово за ефективната компресия. Чрез намаляване на резолюцията на U и V компонентите – техника, наречена chroma subsampling – можем значително да намалим размера на файла с минимална видима загуба на качество. Това води до равнини (planar) пикселни формати, при които Y, U и V компонентите се съхраняват в отделни блокове памет, или „равнини“.
Често срещан формат е I420 (тип YUV 4:2:0), където за всеки блок от 2x2 пиксела има четири Y проби, но само по една U и V проба. Това означава, че U и V равнините са с наполовина по-малка ширина и височина от Y равнината.
Разбирането на тази разлика е критично важно, защото WebCodecs ви дава директен достъп до точно тези равнини, така както декодерът ги предоставя.
Обектът VideoFrame: Вашият портал към пикселните данни
Централната част от този пъзел е обектът VideoFrame. Той представлява един-единствен кадър от видеото и съдържа не само пикселните данни, но и важна метаинформация.
Ключови свойства на VideoFrame
format: Низ, указващ пикселния формат (напр. 'I420', 'NV12', 'RGBA').codedWidth/codedHeight: Пълните размери на кадъра, както е съхранен в паметта, включително всяко допълване (padding), изисквано от кодека.displayWidth/displayHeight: Размерите, които трябва да се използват за показване на кадъра.timestamp: Времевият маркер за представяне (presentation timestamp) на кадъра в микросекунди.duration: Продължителността на кадъра в микросекунди.
Магическият метод: copyTo()
Основният метод за достъп до сурови пикселни данни е videoFrame.copyTo(destination, options). Този асинхронен метод копира данните от равнините на кадъра в буфер, който вие предоставяте.
destination:ArrayBufferили типизиран масив (катоUint8Array), достатъчно голям, за да побере данните.options: Обект, който указва кои равнини да се копират и тяхното разположение в паметта. Ако се пропусне, копира всички равнини в един непрекъснат буфер.
Методът връща Promise, който се разрешава с масив от PlaneLayout обекти, по един за всяка равнина в кадъра. Всеки PlaneLayout обект съдържа две изключително важни части информация:
offset: Отместването в байтове, откъдето започват данните на тази равнина в целевия буфер.stride: Броят байтове между началото на един ред пиксели и началото на следващия ред за тази равнина.
Критична концепция: Stride срещу Width
Това е един от най-честите източници на объркване за разработчици, които тепърва навлизат в нисконивовото графично програмиране. Не можете да приемете, че всеки ред с пикселни данни е плътно подреден един след друг.
- Width (ширина) е броят пиксели в един ред на изображението.
- Stride (наричан още pitch или line step) е броят байтове в паметта от началото на един ред до началото на следващия.
Често stride ще бъде по-голям от width * bytes_per_pixel. Това е така, защото паметта често се допълва (padding), за да се подравни с хардуерните граници (напр. 32- или 64-байтови граници) за по-бърза обработка от процесора или графичния ускорител. Винаги трябва да използвате stride, за да изчислите адреса в паметта на пиксел в определен ред.
Игнорирането на stride ще доведе до изкривени или деформирани изображения и неправилен достъп до данните.
Практически пример 1: Достъп и показване на равнина в нива на сивото
Нека започнем с прост, но мощен пример. Повечето видеоклипове в уеб са кодирани в YUV формат като I420. Равнината 'Y' на практика представлява пълно изображение в нива на сивото. Можем да извлечем само тази равнина и да я изобразим върху canvas.
async function displayGrayscale(videoFrame) {
// Приемаме, че videoFrame е в YUV формат като 'I420' или 'NV12'.
if (!videoFrame.format.startsWith('I4')) {
console.error('Този пример изисква равнинен формат YUV 4:2:0.');
videoFrame.close();
return;
}
const yPlaneInfo = videoFrame.layout[0]; // Y равнината винаги е първа.
// Създаваме буфер, който да съдържа само данните от Y равнината.
const yPlaneData = new Uint8Array(yPlaneInfo.stride * videoFrame.codedHeight);
// Копираме Y равнината в нашия буфер.
await videoFrame.copyTo(yPlaneData, {
rect: { x: 0, y: 0, width: videoFrame.codedWidth, height: videoFrame.codedHeight },
layout: [yPlaneInfo]
});
// Сега yPlaneData съдържа суровите пиксели в нива на сивото.
// Трябва да го изобразим. Ще създадем RGBA буфер за canvas-а.
const canvas = document.getElementById('my-canvas');
canvas.width = videoFrame.displayWidth;
canvas.height = videoFrame.displayHeight;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(canvas.width, canvas.height);
// Обхождаме пикселите на canvas-а и ги запълваме с данни от Y равнината.
for (let y = 0; y < videoFrame.displayHeight; y++) {
for (let x = 0; x < videoFrame.displayWidth; x++) {
// Важно: Използвайте stride, за да намерите правилния индекс в източника!
const yIndex = y * yPlaneInfo.stride + x;
const luma = yPlaneData[yIndex];
// Изчисляваме индекса в целевия RGBA ImageData буфер.
const rgbaIndex = (y * canvas.width + x) * 4;
imageData.data[rgbaIndex] = luma; // Червено
imageData.data[rgbaIndex + 1] = luma; // Зелено
imageData.data[rgbaIndex + 2] = luma; // Синьо
imageData.data[rgbaIndex + 3] = 255; // Алфа
}
}
ctx.putImageData(imageData, 0, 0);
// КРИТИЧНО: Винаги затваряйте VideoFrame, за да освободите паметта му.
videoFrame.close();
}
Този пример подчертава няколко ключови стъпки: идентифициране на правилното разположение на равнината, заделяне на целеви буфер, използване на copyTo за извличане на данните и правилно обхождане на данните с помощта на stride за конструиране на ново изображение.
Практически пример 2: Манипулация на място (филтър Сепия)
Сега нека извършим директна манипулация на данни. Филтърът сепия е класически ефект, който е лесен за реализиране. За този пример е по-лесно да се работи с RGBA кадър, който може да получите от canvas или WebGL контекст.
async function applySepiaFilter(videoFrame) {
// Този пример предполага, че входящият кадър е 'RGBA' или 'BGRA'.
if (videoFrame.format !== 'RGBA' && videoFrame.format !== 'BGRA') {
console.error('Примерът с филтър сепия изисква RGBA кадър.');
videoFrame.close();
return null;
}
// Заделяме буфер, който да съдържа пикселните данни.
const frameDataSize = videoFrame.allocationSize();
const frameData = new Uint8Array(frameDataSize);
await videoFrame.copyTo(frameData);
const layout = videoFrame.layout[0]; // RGBA е една единствена равнина
// Сега манипулираме данните в буфера.
for (let y = 0; y < videoFrame.codedHeight; y++) {
for (let x = 0; x < videoFrame.codedWidth; x++) {
const pixelIndex = y * layout.stride + x * 4; // 4 байта на пиксел (R,G,B,A)
const r = frameData[pixelIndex];
const g = frameData[pixelIndex + 1];
const b = frameData[pixelIndex + 2];
const tr = 0.393 * r + 0.769 * g + 0.189 * b;
const tg = 0.349 * r + 0.686 * g + 0.168 * b;
const tb = 0.272 * r + 0.534 * g + 0.131 * b;
frameData[pixelIndex] = Math.min(255, tr);
frameData[pixelIndex + 1] = Math.min(255, tg);
frameData[pixelIndex + 2] = Math.min(255, tb);
// Алфа каналът (frameData[pixelIndex + 3]) остава непроменен.
}
}
// Създаваме *нов* VideoFrame с модифицираните данни.
const newFrame = new VideoFrame(frameData, {
format: videoFrame.format,
codedWidth: videoFrame.codedWidth,
codedHeight: videoFrame.codedHeight,
timestamp: videoFrame.timestamp,
duration: videoFrame.duration
});
// Не забравяйте да затворите оригиналния кадър!
videoFrame.close();
return newFrame;
}
Това демонстрира пълен цикъл на четене-промяна-запис: копиране на данните, обхождането им с помощта на stride, прилагане на математическа трансформация на всеки пиксел и конструиране на нов VideoFrame с получените данни. Този нов кадър може да бъде изобразен на canvas, изпратен към VideoEncoder или предаден на следваща стъпка за обработка.
Производителността има значение: JavaScript срещу WebAssembly (WASM)
Обхождането на милиони пиксели за всеки кадър (1080p кадър има над 2 милиона пиксела, или 8 милиона точки данни в RGBA) в JavaScript може да бъде бавно. Въпреки че модерните JS енджини са изключително бързи, за обработка в реално време на видео с висока резолюция (HD, 4K), този подход лесно може да претовари основната нишка, което води до накъсано потребителско изживяване.
Тук WebAssembly (WASM) се превръща в основен инструмент. WASM ви позволява да изпълнявате код, написан на езици като C++, Rust или Go, с почти нативна скорост в браузъра. Работният процес за обработка на видео става:
- В JavaScript: Използвайте
videoFrame.copyTo(), за да получите суровите пикселни данни вArrayBuffer. - Предаване към WASM: Предайте референция към този буфер на вашия компилиран WASM модул. Това е много бърза операция, тъй като не включва копиране на данни.
- В WASM (C++/Rust): Изпълнете вашите високооптимизирани алгоритми за обработка на изображения директно върху буфера с памет. Това е в пъти по-бързо от цикъл в JavaScript.
- Връщане към JavaScript: След като WASM приключи, контролът се връща към JavaScript. След това можете да използвате модифицирания буфер, за да създадете нов
VideoFrame.
За всяко сериозно приложение за манипулация на видео в реално време – като виртуални фонове, разпознаване на обекти или сложни филтри – използването на WebAssembly не е просто опция, а необходимост.
Работа с различни пикселни формати (напр. I420, NV12)
Въпреки че RGBA е прост формат, най-често ще получавате кадри в равнинни YUV формати от VideoDecoder. Нека разгледаме как да работим с напълно равнинен формат като I420.
Един VideoFrame във формат I420 ще има три дескриптора за разположение в своя layout масив:
layout[0]: Y равнината (luma). Размерите саcodedWidthxcodedHeight.layout[1]: U равнината (chroma). Размерите саcodedWidth/2xcodedHeight/2.layout[2]: V равнината (chroma). Размерите саcodedWidth/2xcodedHeight/2.
Ето как бихте копирали и трите равнини в един единствен буфер:
async function extractI420Planes(videoFrame) {
const totalSize = videoFrame.allocationSize({ format: 'I420' });
const allPlanesData = new Uint8Array(totalSize);
const layouts = await videoFrame.copyTo(allPlanesData);
// layouts е масив от 3 PlaneLayout обекта
console.log('Y Plane Layout:', layouts[0]); // { offset: 0, stride: ... }
console.log('U Plane Layout:', layouts[1]); // { offset: ..., stride: ... }
console.log('V Plane Layout:', layouts[2]); // { offset: ..., stride: ... }
// Вече можете да достъпвате всяка равнина в буфера `allPlanesData`
// като използвате специфичните за нея offset и stride.
const yPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[0].offset,
layouts[0].stride * videoFrame.codedHeight
);
// Обърнете внимание, че размерите на цветността са наполовина!
const uPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[1].offset,
layouts[1].stride * (videoFrame.codedHeight / 2)
);
const vPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[2].offset,
layouts[2].stride * (videoFrame.codedHeight / 2)
);
console.log('Accessed Y plane size:', yPlaneView.byteLength);
console.log('Accessed U plane size:', uPlaneView.byteLength);
videoFrame.close();
}
Друг често срещан формат е NV12, който е полу-равнинен. Той има две равнини: една за Y и втора, където U и V стойностите са подредени последователно (напр. [U1, V1, U2, V2, ...]). WebCodecs API се справя с това прозрачно; един VideoFrame във формат NV12 просто ще има две разположения в своя layout масив.
Предизвикателства и добри практики
Работата на толкова ниско ниво е мощна, но идва с отговорности.
Управлението на паметта е от първостепенно значение
Един VideoFrame заема значително количество памет, която често се управлява извън хийпа на JavaScript garbage collector-а. Ако не освободите изрично тази памет, ще предизвикате изтичане на памет (memory leak), което може да срине таба на браузъра.
Винаги, винаги извиквайте videoFrame.close(), когато приключите работа с даден кадър.
Асинхронна природа
Целият достъп до данни е асинхронен. Архитектурата на вашето приложение трябва да обработва правилно потока от Promises и async/await, за да се избегнат състезателни условия (race conditions) и да се осигури плавен конвейер за обработка.
Съвместимост с браузъри
WebCodecs е модерен API. Въпреки че се поддържа във всички основни браузъри, винаги проверявайте за неговата наличност и бъдете наясно с всякакви специфични за доставчика детайли на внедряване или ограничения. Използвайте откриване на функции (feature detection), преди да се опитате да използвате API.
Заключение: Нова граница за уеб видеото
Възможността за директен достъп и манипулация на суровите данни от равнините на VideoFrame чрез WebCodecs API е промяна на парадигмата за уеб-базираните медийни приложения. Тя премахва „черната кутия“ на елемента <video> и дава на разработчиците детайлния контрол, запазен преди това за нативни приложения.
Чрез разбирането на основите на разположението на видео паметта – равнини, stride и цветови формати – и чрез използването на силата на WebAssembly за критични по отношение на производителността операции, вече можете да създавате невероятно сложни инструменти за обработка на видео директно в браузъра. От цветова корекция в реално време и персонализирани визуални ефекти до машинно обучение от страна на клиента и видео анализ, възможностите са огромни. Ерата на високопроизводителното, нисконивово видео в уеб наистина започна.