dragonflight/services/worker/src/workers/hls.js
Zac Gaetano 8ea750f5df feat(playback): HLS VOD rendition for browser (supplements MP4 proxy)
Browser playback of recorded assets moves to HLS, retiring the MP4
range-stitching path for VOD. MP4 proxy is kept for the Premiere panel.

- worker/hls.js: remuxToHls() stream-copies the proxy MP4 → fMP4 HLS
  (playlist.m3u8 + init.mp4 + segment_*.m4s) via existing segmentToHls,
  uploads to hls/<id>/, sets assets.hls_s3_key. hlsWorker backfills from
  an existing proxy.
- proxy.js: generate HLS inline after the MP4 upload (local file, no
  re-download, no re-encode); best-effort/non-fatal.
- worker/index.js: register 'hls' worker wherever 'proxy' runs.
- mam-api: GET /assets/:id/hls/:file serves playlist/init/segments as
  whole-object GETs (no Range → sidesteps RustFS bug), strict filename
  validation. /stream prefers hls_s3_key (type:'hls'). reprocess?type=hls
  backfills. Migration 025 adds assets.hls_s3_key.
- Frontend unchanged: hls.js path already handles type:'hls'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:18:15 -04:00

73 lines
2.8 KiB
JavaScript

import { join } from 'path';
import { tmpdir } from 'os';
import { mkdtemp, readdir, rm, unlink } from 'fs/promises';
import { Queue } from 'bullmq';
import { query } from '../db/client.js';
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
import { segmentToHls } from '../ffmpeg/executor.js';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
const parseRedisUrl = (url) => {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};
// Remux a local, already-browser-compatible MP4 (H.264/AAC, as the proxy
// worker produces) into an fMP4 HLS rendition and upload it to hls/<assetId>/.
// This is a stream COPY (no re-encode) — it costs seconds, not minutes.
//
// HLS is served to the browser as whole-file GETs through mam-api, which
// sidesteps the RustFS ranged-GET bug that the MP4 /video path has to stitch
// around. The MP4 proxy is kept for the Premiere panel + downloads.
//
// Returns the playlist S3 key and sets assets.hls_s3_key.
export async function remuxToHls(localMp4Path, assetId) {
const workDir = await mkdtemp(join(tmpdir(), `hls-${assetId}-`));
try {
// Produces playlist.m3u8 + init.mp4 + segment_NNNNN.m4s in workDir.
await segmentToHls(localMp4Path, workDir);
const prefix = `hls/${assetId}`;
const files = await readdir(workDir);
if (!files.includes('playlist.m3u8')) {
throw new Error('segmentToHls produced no playlist.m3u8');
}
for (const f of files) {
await uploadToS3(S3_BUCKET, `${prefix}/${f}`, join(workDir, f));
}
const playlistKey = `${prefix}/playlist.m3u8`;
await query(
'UPDATE assets SET hls_s3_key = $1, updated_at = NOW() WHERE id = $2',
[playlistKey, assetId]
);
return playlistKey;
} finally {
await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
// Backfill worker: remux an EXISTING proxy MP4 into HLS for assets that
// predate the proxy-worker HLS step. Enqueued by POST /assets/:id/reprocess?type=hls.
export const hlsWorker = async (job) => {
const { assetId, proxyKey } = job.data;
if (!proxyKey) throw new Error('hls job requires proxyKey');
const tmpPath = join(tmpdir(), `hls-src-${job.id}.mp4`);
try {
await job.updateProgress(10);
console.log(`[hls] Downloading proxy ${proxyKey} for asset ${assetId}`);
await downloadFromS3(S3_BUCKET, proxyKey, tmpPath);
await job.updateProgress(40);
const key = await remuxToHls(tmpPath, assetId);
console.log(`[hls] Asset ${assetId} HLS rendition complete → ${key}`);
await job.updateProgress(100);
return { assetId, hlsKey: key };
} finally {
await unlink(tmpPath).catch(() => {});
}
};
export const hlsQueue = new Queue('hls', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});