feat(growing-files): Phase 1 - live HLS preview during recording

While a recorder is running, the capture container tees an HLS
stream into /live/<assetId>/ alongside the ProRes master upload.
The asset row is pre-created at recorder start with status='live'
so the clip appears in the library immediately. /api/v1/assets/:id/stream
returns the HLS playlist URL until recording stops, then proxy.

* docker-compose: shared wild-dragon-live mount on api/capture/web-ui
* migration 001-add-live-status: idempotent ALTER TYPE for asset_status
* mam-api: runMigrations() on boot; recorders.js pre-creates live asset
  + passes ASSET_ID; assets.js POST upserts on existing live row instead
  of inserting a duplicate, and stream route returns HLS for live assets
* capture: parallel HLS ffmpeg into /live/<assetId>/; ASSET_ID env
* web-ui: nginx serves /live/, preview.js loads hls.js, LIVE badge added
This commit is contained in:
Zac Gaetano 2026-05-18 07:29:50 -04:00
parent 6a8e4ac250
commit 7d76f9c549
10 changed files with 272 additions and 51 deletions

View file

@ -34,6 +34,7 @@ services:
- "${PORT_MAM_API:-7432}:3000" - "${PORT_MAM_API:-7432}:3000"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /mnt/NVME/MAM/wild-dragon-live:/live
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL} REDIS_URL: ${REDIS_URL}
@ -64,6 +65,8 @@ services:
S3_SECRET_KEY: ${S3_SECRET_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1} S3_REGION: ${S3_REGION:-us-east-1}
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000} MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
networks: networks:
- wild-dragon - wild-dragon
@ -87,6 +90,8 @@ services:
build: ./services/web-ui build: ./services/web-ui
ports: ports:
- "${PORT_WEB_UI:-7434}:80" - "${PORT_WEB_UI:-7434}:80"
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
networks: networks:
- wild-dragon - wild-dragon

View file

@ -72,6 +72,7 @@ class CaptureManager {
* @returns {Object} Session info * @returns {Object} Session info
*/ */
async start({ async start({
assetId,
projectId, projectId,
binId, binId,
clipName, clipName,
@ -82,6 +83,7 @@ class CaptureManager {
listenPort, listenPort,
streamKey, streamKey,
}) { }) {
this._assetIdForHls = assetId || null;
if (this.state.recording) { if (this.state.recording) {
throw new Error('Capture already in progress'); throw new Error('Capture already in progress');
} }
@ -138,6 +140,34 @@ class CaptureManager {
const processes = { hires: hiresProcess }; const processes = { hires: hiresProcess };
const uploads = { hires: hiresUpload }; const uploads = { hires: hiresUpload };
let hlsProcess = null;
let hlsDir = null;
if (isNetwork && this._assetIdForHls) {
try {
const fs = await import('node:fs');
hlsDir = '/live/' + this._assetIdForHls;
fs.mkdirSync(hlsDir, { recursive: true });
const hlsArgs = [
...inputArgs,
'-map', '0:v:0?', '-map', '0:a:0?',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', hlsDir + '/seg-%05d.ts',
hlsDir + '/index.m3u8',
];
hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
processes.hls = hlsProcess;
console.log('[HLS] tee started -> ' + hlsDir);
} catch (err) {
console.error('[HLS] tee failed:', err.message);
}
}
hiresProcess.stderr.on('data', (data) => { hiresProcess.stderr.on('data', (data) => {
const text = data.toString(); const text = data.toString();
@ -223,9 +253,8 @@ class CaptureManager {
if (processes.hires) { if (processes.hires) {
processes.hires.kill('SIGINT'); processes.hires.kill('SIGINT');
} }
if (processes.proxy) { if (processes.proxy) processes.proxy.kill('SIGINT');
processes.proxy.kill('SIGINT'); if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
}
try { try {
// Wait for all in-flight S3 uploads to complete // Wait for all in-flight S3 uploads to complete

View file

@ -24,3 +24,103 @@ app.use('/capture', captureRoutes);
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Wild Dragon Capture Service listening on port ${PORT}`); console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
}); });
async function bootstrapAutoStart() {
const recorderId = process.env.RECORDER_ID;
const sourceType = process.env.SOURCE_TYPE;
if (!recorderId || !sourceType) {
console.log('[bootstrap] no RECORDER_ID/SOURCE_TYPE - on-demand sidecar');
return;
}
const projectId = process.env.PROJECT_ID;
const clipName = process.env.CLIP_NAME;
if (!projectId || !clipName) {
console.error('[bootstrap] missing PROJECT_ID or CLIP_NAME - cannot start');
return;
}
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
const listenPort = process.env.LISTEN_PORT
? parseInt(process.env.LISTEN_PORT, 10)
: undefined;
const streamKey = process.env.STREAM_KEY || undefined;
const sourceUrl = process.env.SOURCE_URL || undefined;
if (sourceType === 'sdi') {
console.warn('[bootstrap] SDI auto-start not supported');
return;
}
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
try {
const session = await captureManager.start({
assetId: process.env.ASSET_ID || null,
projectId,
binId: process.env.BIN_ID || null,
clipName,
sourceType,
sourceUrl,
listen,
listenPort,
streamKey,
});
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
} catch (err) {
console.error('[bootstrap] failed to start capture:', err);
}
}
let shuttingDown = false;
async function gracefulShutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[shutdown] ${signal} received`);
const status = captureManager.getStatus();
if (status.recording) {
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
try {
const completed = await captureManager.stop(status.sessionId);
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: completed.projectId,
binId: completed.binId,
clipName: completed.clipName,
sourceType: completed.sourceType,
hiresKey: completed.hiresKey,
proxyKey: completed.proxyKey,
needsProxy: completed.proxyKey === null,
duration: completed.duration,
capturedAt: completed.startedAt,
}),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] asset registered with mam-api');
}
} catch (mamErr) {
console.error('[shutdown] failed to register asset:', mamErr.message);
}
} catch (err) {
console.error('[shutdown] error during stop:', err);
}
}
server.close(() => {
console.log('[shutdown] http server closed - exiting');
process.exit(0);
});
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View file

@ -0,0 +1,7 @@
-- 2026-05: add 'live' to asset_status for growing-file ingest
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'live' AND enumtypid = 'asset_status'::regtype) THEN
ALTER TYPE asset_status ADD VALUE 'live' BEFORE 'ingesting';
END IF;
END $$;

View file

@ -68,6 +68,27 @@ app.use('/api/v1/ampp', amppRouter);
app.use(errorHandler); app.use(errorHandler);
// ── Start ──────────────────────────────────────────────────────────────────── // ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
const dir = join(__dirnameMig, 'db', 'migrations');
let files = [];
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
for (const f of files) {
const sql = readFileSync(join(dir, f), 'utf8');
try {
await pool.query(sql);
console.log('[migration] applied ' + f);
} catch (err) {
console.error('[migration] failed ' + f, err.message);
}
}
}
await runMigrations();
app.listen(PORT, () => { app.listen(PORT, () => {
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)'; const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
console.log(`MAM API listening on port ${PORT}`); console.log(`MAM API listening on port ${PORT}`);

View file

@ -112,38 +112,69 @@ router.post('/', async (req, res, next) => {
return res.status(400).json({ error: 'projectId and clipName are required' }); return res.status(400).json({ error: 'projectId and clipName are required' });
} }
const id = uuidv4();
const thumbnailKey = `thumbnails/${id}.jpg`;
const durationMs = duration ? Math.round(duration * 1000) : null; const durationMs = duration ? Math.round(duration * 1000) : null;
const result = await pool.query( // Phase 1 growing-files: an asset row may already exist in status='live'
`INSERT INTO assets ( // (pre-created at recorder start so the library shows the recording while
id, project_id, bin_id, // it is happening). If so we UPDATE that row instead of inserting a new
filename, display_name, // one -- otherwise we would have two rows per recording.
status, media_type, const existing = await pool.query(
original_s3_key, proxy_s3_key, `SELECT * FROM assets
duration_ms, WHERE project_id = $1 AND display_name = $2 AND status = 'live'
created_at, updated_at ORDER BY created_at DESC LIMIT 1`,
) [projectId, clipName]
VALUES (
$1, $2, $3,
$4, $4,
'processing', 'video',
$5, $6,
$7,
COALESCE($8::timestamptz, NOW()), NOW()
)
RETURNING *`,
[
id, projectId, binId || null,
clipName,
hiresKey || null, proxyKey || null,
durationMs,
capturedAt || null,
]
); );
const asset = result.rows[0]; let id;
let asset;
if (existing.rows.length > 0) {
id = existing.rows[0].id;
const upd = await pool.query(
`UPDATE assets
SET status = 'processing',
original_s3_key = COALESCE($2, original_s3_key),
proxy_s3_key = COALESCE($3, proxy_s3_key),
duration_ms = COALESCE($4, duration_ms),
bin_id = COALESCE(bin_id, $5),
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[id, hiresKey || null, proxyKey || null, durationMs, binId || null]
);
asset = upd.rows[0];
} else {
id = uuidv4();
const ins = await pool.query(
`INSERT INTO assets (
id, project_id, bin_id,
filename, display_name,
status, media_type,
original_s3_key, proxy_s3_key,
duration_ms,
created_at, updated_at
)
VALUES (
$1, $2, $3,
$4, $4,
'processing', 'video',
$5, $6,
$7,
COALESCE($8::timestamptz, NOW()), NOW()
)
RETURNING *`,
[
id, projectId, binId || null,
clipName,
hiresKey || null, proxyKey || null,
durationMs,
capturedAt || null,
]
);
asset = ins.rows[0];
}
const thumbnailKey = `thumbnails/${id}.jpg`;
// Dispatch thumbnail job — proxy already in S3 from capture // Dispatch thumbnail job — proxy already in S3 from capture
if (proxyKey) { if (proxyKey) {
@ -161,7 +192,7 @@ router.post('/', async (req, res, next) => {
asset.status = 'ready'; asset.status = 'ready';
} }
res.status(201).json(asset); res.status(existing.rows.length > 0 ? 200 : 201).json(asset);
} catch (err) { } catch (err) {
next(err); next(err);
} }
@ -339,28 +370,20 @@ router.delete('/:id', async (req, res, next) => {
router.get('/:id/stream', async (req, res, next) => { router.get('/:id/stream', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const result = await pool.query( const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
'SELECT proxy_s3_key FROM assets WHERE id = $1', if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
[id] const a = r.rows[0];
); if (a.status === 'live') {
return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Asset not found' });
} }
const key = a.proxy_s3_key || a.original_s3_key;
const { proxy_s3_key } = result.rows[0]; if (!key) return res.status(404).json({ error: 'No stream available yet' });
const url = await getSignedUrlForObject(key);
if (!proxy_s3_key) {
return res.status(400).json({ error: 'No proxy available for this asset' });
}
const url = await getSignedUrlForObject(proxy_s3_key);
res.json({ url }); res.json({ url });
} catch (err) { } catch (err) {
next(err); next(err);
} }
}); });
// GET /:id/thumbnail - Signed URL for thumbnail image // GET /:id/thumbnail - Signed URL for thumbnail image
router.get('/:id/thumbnail', async (req, res, next) => { router.get('/:id/thumbnail', async (req, res, next) => {
try { try {

View file

@ -197,6 +197,22 @@ router.post('/:id/start', async (req, res, next) => {
// Generate clip name with timestamp // Generate clip name with timestamp
const clipName = generateClipName(recorder.name); const clipName = generateClipName(recorder.name);
// live-asset: create the asset row right now (status='live') so the library
// shows the recording while it is happening. The capture container will
// tee an HLS stream into /live/<assetId>/.
const assetIdLive = (await import('uuid')).v4();
try {
await pool.query(
`INSERT INTO assets (
id, project_id, bin_id, filename, display_name, status, media_type,
original_s3_key, created_at, updated_at
) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
[assetIdLive, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.mov`]
);
} catch (e) {
console.warn('[recorders] could not pre-create live asset:', e.message);
}
// Determine source config and whether this is a listener-mode recorder // Determine source config and whether this is a listener-mode recorder
const sourceConfig = recorder.source_config || {}; const sourceConfig = recorder.source_config || {};
const isListener = sourceConfig.mode === 'listener'; const isListener = sourceConfig.mode === 'listener';
@ -224,6 +240,7 @@ router.post('/:id/start', async (req, res, next) => {
`PROXY_RESOLUTION=${recorder.proxy_resolution}`, `PROXY_RESOLUTION=${recorder.proxy_resolution}`,
`PROJECT_ID=${recorder.project_id}`, `PROJECT_ID=${recorder.project_id}`,
`CLIP_NAME=${clipName}`, `CLIP_NAME=${clipName}`,
`ASSET_ID=${assetIdLive}`,
]; ];
// Add source-specific env vars for SRT/RTMP // Add source-specific env vars for SRT/RTMP
@ -250,6 +267,7 @@ router.post('/:id/start', async (req, res, next) => {
Privileged: true, Privileged: true,
NetworkMode: dockerNetwork, NetworkMode: dockerNetwork,
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined, PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
Binds: ['/mnt/NVME/MAM/wild-dragon-live:/live'],
}, },
NetworkingConfig: { NetworkingConfig: {
EndpointsConfig: { EndpointsConfig: {

View file

@ -38,6 +38,14 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
} }
# Live HLS served from /live (bind-mounted shared volume), low cache so playlist refreshes
location /live/ {
alias /live/;
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
add_header Cache-Control "no-cache";
add_header Access-Control-Allow-Origin *;
}
# API proxy - forward to mam-api service # API proxy - forward to mam-api service
location /api/ { location /api/ {
set $api_upstream http://mam-api:3000; set $api_upstream http://mam-api:3000;

View file

@ -302,6 +302,8 @@
.first-splash-dot{width:8px;height:8px;background:oklch(55% 0.20 266);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite} .first-splash-dot{width:8px;height:8px;background:oklch(55% 0.20 266);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite}
@keyframes fsPulse{0%,100%{opacity:.35;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}} @keyframes fsPulse{0%,100%{opacity:.35;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
.first-splash-title{font-size:13px;color:var(--text-secondary);letter-spacing:.04em} .first-splash-title{font-size:13px;color:var(--text-secondary);letter-spacing:.04em}
.badge-live { background: oklch(64% 0.22 25 / 0.18); color: oklch(70% 0.22 25); border: 1px solid oklch(64% 0.22 25 / 0.4); animation: liveBlink 1.4s ease-in-out infinite; }
@keyframes liveBlink { 0%,100% { opacity: 0.7 } 50% { opacity: 1 } }
</style> </style>
</head> </head>
<body> <body>
@ -477,7 +479,7 @@
<script src="js/api.js?v=5"></script> <script src="js/api.js?v=5"></script>
<script src="js/topbar-strip.js?v=1"></script> <script src="js/topbar-strip.js?v=1"></script>
<script src="js/preview.js?v=3"></script> <script src="js/preview.js?v=4"></script>
<script src="js/selection.js?v=1"></script> <script src="js/selection.js?v=1"></script>
<script> <script>
const state = { const state = {
@ -703,7 +705,7 @@
} }
function statusBadgeClass(s) { function statusBadgeClass(s) {
const map = { ingesting:'badge-ingesting', processing:'badge-processing', ready:'badge-ready', error:'badge-error', archived:'badge-archived' }; const map = { live:'badge-live', ingesting:'badge-ingesting', processing:'badge-processing', ready:'badge-ready', error:'badge-error', archived:'badge-archived' };
return map[s] || 'badge-idle'; return map[s] || 'badge-idle';
} }

View file

@ -79,7 +79,15 @@
if (!r.ok) throw new Error(r.statusText); if (!r.ok) throw new Error(r.statusText);
const { url } = await r.json(); const { url } = await r.json();
const tag = mt === 'audio' ? 'audio' : 'video'; const tag = mt === 'audio' ? 'audio' : 'video';
stage.innerHTML = `<${tag} controls autoplay playsinline src="${esc(url)}"></${tag}>`; if (url && url.endsWith('.m3u8')) {
stage.innerHTML = `<${tag} id="prevPlayer" controls autoplay playsinline></${tag}>`;
const v = stage.querySelector('#prevPlayer');
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; }
else if (window.Hls && window.Hls.isSupported()) { const h = new window.Hls(); h.loadSource(url); h.attachMedia(v); }
else { await new Promise(r => { const sc = document.createElement('script'); sc.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.0/dist/hls.min.js'; sc.onload = r; document.head.appendChild(sc); }); const h = new window.Hls({ liveSyncDuration: 6 }); h.loadSource(url); h.attachMedia(v); }
} else {
stage.innerHTML = `<${tag} controls autoplay playsinline src="${esc(url)}"></${tag}>`;
}
} catch (err) { } catch (err) {
stage.innerHTML = `<div class="preview-empty">Stream URL failed: ${esc(err.message)}</div>`; stage.innerHTML = `<div class="preview-empty">Stream URL failed: ${esc(err.message)}</div>`;
} }