Розблокуйте розширену обробку відео в браузері. Навчіться напряму отримувати доступ та маніпулювати сирими даними площин VideoFrame за допомогою WebCodecs API для створення власних ефектів та аналізу.
Доступ до площин VideoFrame у WebCodecs: Глибоке занурення в маніпуляцію сирими відеоданими
Роками високопродуктивна обробка відео у веб-браузері здавалася далекою мрією. Розробники часто були обмежені можливостями елемента <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, ...]
У цій моделі все зображення зберігається в одному суцільному блоці пам'яті. Ми можемо вважати, що це одна "площина" даних.
YUV: Мова стиснення відео
Однак відеокодеки рідко працюють безпосередньо з RGBA. Вони віддають перевагу колірним просторам YUV (або, точніше, Y'CbCr). Ця модель розділяє інформацію про зображення на:
- Y (Luma): Яскравість або інформація у відтінках сірого. Людське око найбільш чутливе до змін яскравості.
- U (Cb) та V (Cr): Кольоровість або інформація про різницю кольорів. Людське око менш чутливе до деталей кольору, ніж до деталей яскравості.
Це розділення є ключовим для ефективного стиснення. Зменшуючи роздільну здатність компонентів U та V — техніка, що називається колірною субдискретизацією (chroma subsampling) — ми можемо значно зменшити розмір файлу з мінімальною відчутною втратою якості. Це призводить до планарних піксельних форматів, де компоненти 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: Часова мітка представлення кадру в мікросекундах.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. Це тому, що пам'ять часто доповнюється для вирівнювання за апаратними межами (наприклад, 32- або 64-байтними межами) для швидшої обробки CPU або GPU. Ви завжди повинні використовувати 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 (яскравість). Розміри:codedWidthxcodedHeight.layout[1]: Площина U (кольоровість). Розміри:codedWidth/2xcodedHeight/2.layout[2]: Площина V (кольоровість). Розміри: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:', layouts[0]); // { offset: 0, stride: ... }
console.log('Розміщення площини U:', layouts[1]); // { offset: ..., stride: ... }
console.log('Розміщення площини V:', layouts[2]); // { offset: ..., stride: ... }
// Тепер ви можете отримати доступ до кожної площини в буфері `allPlanesData`
// використовуючи її специфічне зміщення та крок (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('Розмір доступної площини Y:', yPlaneView.byteLength);
console.log('Розмір доступної площини U:', uPlaneView.byteLength);
videoFrame.close();
}
Іншим поширеним форматом є NV12, який є напівпланарним. Він має дві площини: одну для Y, і другу, де значення U та V чергуються (наприклад, [U1, V1, U2, V2, ...]). WebCodecs API обробляє це прозоро; VideoFrame у форматі NV12 просто матиме два розміщення у своєму масиві layout.
Виклики та найкращі практики
Робота на такому низькому рівні є потужною, але вона пов'язана з відповідальністю.
Управління пам'яттю є першочерговим
VideoFrame утримує значний обсяг пам'яті, яка часто управляється поза купою збирача сміття JavaScript. Якщо ви явно не звільните цю пам'ять, ви спричините витік пам'яті, що може призвести до збою вкладки браузера.
Завжди, завжди викликайте videoFrame.close(), коли ви закінчили роботу з кадром.
Асинхронна природа
Весь доступ до даних є асинхронним. Архітектура вашого додатку повинна правильно обробляти потік Promise та async/await, щоб уникнути станів гонитви та забезпечити плавний конвеєр обробки.
Сумісність з браузерами
WebCodecs — це сучасний API. Хоча він підтримується у всіх основних браузерах, завжди перевіряйте його доступність та будьте в курсі будь-яких специфічних для постачальника деталей реалізації чи обмежень. Використовуйте визначення функцій (feature detection) перед спробою використання API.
Висновок: Новий рубіж для веб-відео
Можливість прямого доступу та маніпуляції сирими даними площин VideoFrame через WebCodecs API — це зміна парадигми для медіа-додатків у вебі. Вона усуває "чорну скриньку" елемента <video> і надає розробникам гранулярний контроль, який раніше був зарезервований для нативних додатків.
Розуміючи основи розміщення відео в пам'яті — площини, крок (stride) та колірні формати — та використовуючи потужність WebAssembly для критично важливих для продуктивності операцій, ви тепер можете створювати неймовірно складні інструменти для обробки відео безпосередньо в браузері. Від корекції кольору в реальному часі та власних візуальних ефектів до машинного навчання на стороні клієнта та відеоаналізу — можливості величезні. Ера високопродуктивного, низькорівневого відео в Інтернеті справді почалася.