pixelflux/example/index.html
2025-05-14 12:35:37 -04:00

250 lines
13 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<title>H.264 Stripe Demo</title>
<style>
body { margin: 0; overflow: hidden; background-color: #333; }
canvas { display: block; } /* Ensures canvas is block-level, good practice */
</style>
</head>
<body>
<!-- The canvas element where the decoded video stripes will be drawn. -->
<canvas id="videoCanvas"></canvas>
<script>
// --- Configuration ---
const WEBSOCKET_URL = "ws://localhost:9000"; // WebSocket server URL
const DISPLAY_WIDTH = 1920; // Hardcoded display width for the demo
const DISPLAY_HEIGHT = 1080; // Hardcoded display height for the demo
// --- End Configuration ---
// Get the canvas element and its 2D rendering context.
const canvas = document.getElementById("videoCanvas");
const ctx = canvas.getContext('2d');
// Set the canvas dimensions.
// The internal resolution of the canvas.
canvas.width = DISPLAY_WIDTH;
canvas.height = DISPLAY_HEIGHT;
// Optional: Style the canvas to fill the window if desired,
// or to be a fixed size. For this demo, it's fixed.
// canvas.style.width = "100vw";
// canvas.style.height = "100vh";
// canvas.style.objectFit = "contain"; // If scaling to window, maintain aspect ratio.
// This object will store VideoDecoder instances, one for each horizontal stripe.
// The key will be the stripe's Y-coordinate (stripe_y_start).
// The value will be an object: { decoder: VideoDecoder, pendingChunks: [] }
const stripeDecoders = {};
// Establish a WebSocket connection to the server.
const websocket = new WebSocket(WEBSOCKET_URL);
// Set the binary type to 'arraybuffer' to receive raw binary data.
websocket.binaryType = 'arraybuffer';
websocket.onopen = function(event) {
console.log("WebSocket connection opened to:", WEBSOCKET_URL);
// You could send an initial message to the server here if needed.
// For this demo, the server starts sending data upon connection.
};
websocket.onmessage = function(event) {
// `event.data` will be an ArrayBuffer containing the H.264 stripe data.
const receivedBuffer = event.data;
const dataView = new DataView(receivedBuffer);
// --- Parse the 10-byte Prefix ---
// Byte 0: DataType (should be 0x04 for H.264 stripe in this demo)
const dataType = dataView.getUint8(0);
if (dataType !== 0x04) {
console.warn(`Received unexpected data type: ${dataType}. Expected 0x04 for H.264 stripe.`);
return;
}
// Byte 1: Frame Type (0x01 for Key frame, 0x00 for Delta/P-frame)
const frameTypeByte = dataView.getUint8(1);
const chunkType = (frameTypeByte === 0x01) ? 'key' : 'delta';
// Bytes 2-3: Frame ID (unique ID for the full frame this stripe belongs to)
// Not strictly used for rendering in this minimal demo, but good to know it's there.
const frameId = dataView.getUint16(2, false); // false for big-endian
// Bytes 4-5: Stripe Y Start (vertical offset of this stripe)
const stripeYStart = dataView.getUint16(4, false); // false for big-endian
// Bytes 6-7: Stripe Width
const stripeWidth = dataView.getUint16(6, false); // false for big-endian
// Bytes 8-9: Stripe Height
const stripeHeight = dataView.getUint16(8, false); // false for big-endian
// The actual H.264 NALU data starts after the 10-byte prefix.
const h264NaluData = receivedBuffer.slice(10);
if (h264NaluData.byteLength === 0) {
console.warn(`Received empty H.264 NALU data for stripe Y=${stripeYStart}.`);
return;
}
// --- End Prefix Parsing ---
// Get or create the VideoDecoder for this specific stripe.
let decoderInfo = stripeDecoders[stripeYStart];
if (!decoderInfo) {
// If no decoder exists for this stripe's Y position, create one.
console.log(`Creating new VideoDecoder for stripe Y=${stripeYStart}, Width=${stripeWidth}, Height=${stripeHeight}`);
const newDecoder = new VideoDecoder({
output: (videoFrame) => {
// This is the output callback for the VideoDecoder.
// It's called when a frame has been successfully decoded.
try {
// Draw the decoded frame directly onto the canvas at its correct Y position.
ctx.drawImage(videoFrame, 0, stripeYStart);
} catch (e) {
console.error(`Error drawing decoded frame for stripe Y=${stripeYStart}:`, e);
} finally {
// IMPORTANT: Close the VideoFrame to release its resources.
videoFrame.close();
}
},
error: (e) => {
console.error(`VideoDecoder error for stripe Y=${stripeYStart}:`, e.message, e);
// More robust error handling might involve trying to reset or reconfigure the decoder.
// For this demo, we just log the error. If a decoder becomes permanently broken,
// its stripes might stop updating.
// Consider removing the decoder from stripeDecoders so it can be recreated on next keyframe.
if (stripeDecoders[stripeYStart] && stripeDecoders[stripeYStart].decoder === newDecoder) {
try {
if (newDecoder.state !== 'closed') newDecoder.close();
} catch (closeError) { /* ignore */ }
delete stripeDecoders[stripeYStart];
console.warn(`Removed faulty decoder for stripe Y=${stripeYStart}. It might be recreated on the next keyframe.`);
}
}
});
// Configuration for the VideoDecoder.
const decoderConfig = {
codec: 'avc1.42E01E', // Common H.264 baseline codec string.
codedWidth: stripeWidth, // Width of the NALU data.
codedHeight: stripeHeight, // Height of the NALU data.
optimizeForLatency: true, // Prioritize faster decoding.
};
// Store the new decoder and a place for pending chunks.
stripeDecoders[stripeYStart] = { decoder: newDecoder, pendingChunks: [], config: decoderConfig };
decoderInfo = stripeDecoders[stripeYStart];
// Asynchronously check if the configuration is supported and then configure.
VideoDecoder.isConfigSupported(decoderConfig)
.then(support => {
if (support.supported) {
newDecoder.configure(decoderConfig);
console.log(`VideoDecoder configured for stripe Y=${stripeYStart}. Processing ${decoderInfo.pendingChunks.length} pending chunks.`);
// Process any chunks that arrived while the decoder was being set up.
processPendingChunks(stripeYStart);
} else {
console.error(`VideoDecoder configuration not supported for stripe Y=${stripeYStart}:`, decoderConfig, support);
// If config not supported, remove the placeholder.
if (stripeDecoders[stripeYStart] && stripeDecoders[stripeYStart].decoder === newDecoder) {
delete stripeDecoders[stripeYStart];
}
}
})
.catch(e => {
console.error(`Error during VideoDecoder support check or configure for stripe Y=${stripeYStart}:`, e);
if (stripeDecoders[stripeYStart] && stripeDecoders[stripeYStart].decoder === newDecoder) {
delete stripeDecoders[stripeYStart];
}
});
}
// At this point, decoderInfo should refer to the entry in stripeDecoders.
// Create an EncodedVideoChunk from the NALU data.
const chunkTimestamp = performance.now() * 1000; // Timestamps in microseconds.
// Must be monotonically increasing for a given decoder.
const encodedChunk = new EncodedVideoChunk({
type: chunkType,
timestamp: chunkTimestamp,
data: h264NaluData
});
// If the decoder is configured, decode immediately. Otherwise, queue the chunk.
if (decoderInfo.decoder.state === "configured") {
try {
decoderInfo.decoder.decode(encodedChunk);
} catch (e) {
console.error(`Error decoding chunk for stripe Y=${stripeYStart}:`, e, encodedChunk);
// If decode fails, consider resetting/re-creating this specific decoder.
}
} else if (decoderInfo.decoder.state === "unconfigured" || decoderInfo.decoder.state === "configuring") {
// console.log(`Decoder for stripe Y=${stripeYStart} is ${decoderInfo.decoder.state}. Queuing chunk.`);
decoderInfo.pendingChunks.push(encodedChunk);
} else { // 'closed'
console.warn(`Decoder for stripe Y=${stripeYStart} is closed. Chunk dropped. Frame ID: ${frameId}`);
// If it's closed, it might have errored. It could be recreated on the next keyframe.
}
};
function processPendingChunks(stripeY) {
const decoderInfo = stripeDecoders[stripeY];
if (!decoderInfo || decoderInfo.decoder.state !== "configured" || !decoderInfo.pendingChunks) {
return;
}
// console.log(`Processing ${decoderInfo.pendingChunks.length} pending chunks for stripe Y=${stripeY}`);
while (decoderInfo.pendingChunks.length > 0) {
const chunkToDecode = decoderInfo.pendingChunks.shift();
try {
decoderInfo.decoder.decode(chunkToDecode);
} catch (e) {
console.error(`Error decoding pending chunk for stripe Y=${stripeY}:`, e, chunkToDecode);
// If decode fails here, the decoder might be in a bad state.
}
}
}
websocket.onerror = function(error) {
console.error("WebSocket error:", error);
};
websocket.onclose = function(event) {
console.log("WebSocket connection closed. Code:", event.code, "Reason:", event.reason);
// Clean up all decoders when the WebSocket closes.
for (const yPos in stripeDecoders) {
if (stripeDecoders.hasOwnProperty(yPos)) {
const decoderInfo = stripeDecoders[yPos];
if (decoderInfo.decoder && decoderInfo.decoder.state !== "closed") {
try {
decoderInfo.decoder.close();
console.log(`Closed decoder for stripe Y=${yPos} on WebSocket close.`);
} catch (e) {
console.error(`Error closing decoder for stripe Y=${yPos} on WebSocket close:`, e);
}
}
}
}
// Clear the decoders object.
for (let key in stripeDecoders) delete stripeDecoders[key];
};
// No explicit requestAnimationFrame loop is needed for this version because
// VideoDecoder's `output` callback draws directly to the canvas when a frame is ready.
// This simplifies the rendering logic for a bare-minimum demo.
// Graceful shutdown: Close decoders if the window is closed/reloaded.
window.addEventListener('beforeunload', () => {
console.log("Window is closing. Cleaning up decoders...");
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.close(); // Attempt to close WebSocket cleanly.
}
// The websocket.onclose handler will then take care of closing individual decoders.
// If onclose doesn't fire in time, the browser will clean up, but this is good practice.
});
</script>
</body>
</html>