feat(refboard): video budget system, lazy lifecycle, incremental sync fixes

Video memory:
- Lazy <video> creation: constructor makes placeholder only, initVideo()
  creates element with preload='metadata' when near viewport
- Tiered budgets: 1 playing / 6 initialized / 4 poster textures max
- Aggressive teardown: offscreen videos lose <video> element, poster,
  and all textures — zero memory for offscreen videos
- Poster textures dropped while playing (don't keep both resident)
- Dimension caching in scene data (nativeW/nativeH) avoids re-init

Server:
- HTTP Range support (206 Partial Content) for video seeking
- Proper end clamping and invalid range rejection (416)

Sync:
- Remove redundant broadcastSceneDebounced() from broadcastTransform()
- Remove duplicate broadcastElements() from drag-end handler

VideoControls:
- Event-driven (timeupdate/play/pause/seeked) instead of rAF polling
- Tracks actual HTMLVideoElement reference, rebinds on init/teardown
- Seek uses ref-based commit on pointerUp, not conditional onChange
This commit is contained in:
Hiren Kangad
2026-03-10 11:56:33 +05:30
parent ae050d2119
commit 89010335be
8 changed files with 523 additions and 311 deletions
+34 -6
View File
@@ -18,25 +18,53 @@ app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// ---- Image proxy (MinIO → browser) ----
// ---- Media proxy (MinIO → browser) with HTTP Range support ----
app.get('/api/images/*', async (req, res) => {
try {
const objectPath = req.params[0]; // everything after /api/images/
if (!objectPath) return res.status(400).json({ error: 'Missing path' });
const { minioClient, MINIO_BUCKET } = require('./minio');
const stat = await minioClient.statObject(MINIO_BUCKET, objectPath);
if (stat.metaData?.['content-type']) {
res.setHeader('Content-Type', stat.metaData['content-type']);
}
const contentType = stat.metaData?.['content-type'] || 'application/octet-stream';
const totalSize = stat.size;
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.setHeader('Accept-Ranges', 'bytes');
const rangeHeader = req.headers.range;
if (rangeHeader && totalSize) {
// Parse Range: bytes=start-end
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const requestedEnd = match[2] ? parseInt(match[2], 10) : totalSize - 1;
const end = Math.min(requestedEnd, totalSize - 1);
if (start >= totalSize || end < start) {
res.status(416).setHeader('Content-Range', `bytes */${totalSize}`).end();
return;
}
const chunkSize = end - start + 1;
res.status(206);
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`);
res.setHeader('Content-Length', chunkSize);
const stream = await minioClient.getPartialObject(MINIO_BUCKET, objectPath, start, chunkSize);
stream.pipe(res);
return;
}
}
// Full response
res.setHeader('Content-Type', contentType);
if (totalSize) res.setHeader('Content-Length', totalSize);
const stream = await minioClient.getObject(MINIO_BUCKET, objectPath);
stream.pipe(res);
} catch (err) {
if (err.code === 'NoSuchKey' || err.code === 'NotFound') {
return res.status(404).json({ error: 'Image not found' });
}
console.error('[server] image proxy error:', err);
return res.status(500).json({ error: 'Failed to serve image' });
console.error('[server] media proxy error:', err);
return res.status(500).json({ error: 'Failed to serve media' });
}
});