feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel

- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
This commit is contained in:
Zac Gaetano 2026-05-22 19:12:53 -04:00
parent 3fc8fbc230
commit 328f7b4f31
15 changed files with 882 additions and 90 deletions

View file

@ -39,6 +39,7 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /mnt/NVME/MAM/wild-dragon-live:/live
- /mnt/NVME/MAM/wild-dragon-growing:/growing
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
- /run/systemd:/run/systemd
@ -103,6 +104,9 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
GROWING_PATH: /growing
volumes:
- /mnt/NVME/MAM/wild-dragon-growing:/growing
networks:
- wild-dragon

View file

@ -1,9 +1,19 @@
import { spawn } from 'child_process';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-process by `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed).
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// ── Codec catalogue ──────────────────────────────────────────────────────
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
// / pix_fmt are layered on top from the per-recorder configuration.
@ -190,6 +200,16 @@ class CaptureManager {
const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4';
const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
// Growing-files: write master to the local SMB share instead of streaming
// to S3. Path is relative to the container's GROWING_PATH mount.
const growingPath = GROWING_ENABLED
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
: null;
if (growingPath) {
try { mkdirSync(dirname(growingPath), { recursive: true }); }
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
}
// Network sources cannot be opened by two FFmpeg processes simultaneously
// (one socket = one consumer). For SRT/RTMP the BullMQ worker generates
// the proxy after the recording stops.
@ -215,14 +235,22 @@ class CaptureManager {
const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// When growing-files is on, write directly to the SMB share so Premier
// can mount and edit the live file. Promotion worker uploads to S3 on EOF.
// Otherwise, stream the master to S3 via stdout pipe (legacy behavior).
const hiresOutput = growingPath ? growingPath : 'pipe:1';
const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];
const hiresProcess = spawn('ffmpeg', [
...inputArgs,
...sdiFilterArgs,
...hiresCodecArgs,
'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] });
hiresOutput,
], { stdio: hiresStdio });
const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
const hiresUpload = growingPath
? Promise.resolve({ growingPath })
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
const processes = { hires: hiresProcess };
const uploads = { hires: hiresUpload };
@ -321,6 +349,7 @@ class CaptureManager {
sourceUrl,
hiresKey,
proxyKey,
growingPath,
startedAt,
duration: 0,
uploads,
@ -371,6 +400,7 @@ class CaptureManager {
sourceType: currentSession.sourceType,
hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey,
growingPath: currentSession.growingPath || null,
startedAt: currentSession.startedAt,
stoppedAt,
duration,

View file

@ -353,6 +353,64 @@ router.get('/:id/stream', async (req, res, next) => {
}
});
// GET /:id/live-path
// Returns the SMB UNC path of a live (growing) asset so the Premiere panel
// can mount the file in place rather than downloading a proxy first.
// Editors mount the SMB share once; the panel passes individual file paths.
router.get('/:id/live-path', async (req, res, next) => {
try {
const { id } = req.params;
const a = await pool.query(
'SELECT id, project_id, display_name, status FROM assets WHERE id = $1',
[id]
);
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const asset = a.rows[0];
if (asset.status !== 'live') {
return res.status(409).json({ error: 'Asset is not currently growing', status: asset.status });
}
const s = await pool.query(
`SELECT key, value FROM settings
WHERE key IN ('growing_enabled', 'growing_smb_url')`
);
const cfg = {};
for (const { key, value } of s.rows) cfg[key] = value;
if (cfg.growing_enabled !== 'true') {
return res.status(409).json({ error: 'Growing-files mode is disabled' });
}
if (!cfg.growing_smb_url) {
return res.status(409).json({ error: 'No SMB URL configured — set growing_smb_url in Settings' });
}
// Find the recorder driving this asset so we know the right file extension.
const rec = await pool.query(
`SELECT recording_container FROM recorders
WHERE current_session_id = $1
ORDER BY updated_at DESC LIMIT 1`,
[asset.display_name]
);
const ext = rec.rows[0]?.recording_container || 'mov';
const smbRoot = cfg.growing_smb_url.replace(/\/+$/, '');
const winPath = smbRoot.replace(/^smb:\/\//, '\\\\').replace(/\//g, '\\') +
`\\${asset.project_id}\\${asset.display_name}.${ext}`;
const posix = smbRoot.replace(/^smb:\/\//, '//') +
`/${asset.project_id}/${asset.display_name}.${ext}`;
res.json({
smb_url: `${smbRoot}/${asset.project_id}/${asset.display_name}.${ext}`,
win_path: winPath,
posix_path: posix,
project_id: asset.project_id,
display_name: asset.display_name,
ext,
});
} catch (err) {
next(err);
}
});
// GET /:id/video
router.get('/:id/video', async (req, res, next) => {
try {

View file

@ -283,6 +283,15 @@ router.post('/:id/start', async (req, res, next) => {
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
// Growing-files mode is a global setting (settings table). When on, the
// capture container writes the master to its /growing/ mount instead of
// streaming it to S3 — Premiere can mount the SMB share and edit it live.
const growingRow = await pool.query(
`SELECT value FROM settings WHERE key = 'growing_enabled'`
);
const growingEnabled =
growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true;
const clipName = generateClipName(recorder.name);
// live-asset: create the asset row right now (status='live') so the
@ -343,6 +352,8 @@ router.post('/:id/start', async (req, res, next) => {
`PROJECT_ID=${recorder.project_id}`,
`CLIP_NAME=${clipName}`,
`ASSET_ID=${assetIdLive}`,
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
`GROWING_PATH=/growing`,
];
if (sourceType === 'srt' || sourceType === 'rtmp') {
@ -384,6 +395,7 @@ router.post('/:id/start', async (req, res, next) => {
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
if (sourceType === 'sdi') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
const containerConfig = {
Image: 'wild-dragon-capture:latest',

View file

@ -244,6 +244,50 @@ router.put('/transcoding', async (req, res, next) => {
}
});
// ── Growing files (SMB landing zone) ─────────────────────────────────────────
// Lets capture write its master output to a fast local SMB share instead of
// streaming directly to S3. Premiere can mount the share and edit the file
// while it's still being written; the promotion worker later moves the
// finalized file to S3 and flips the asset to status='ready'.
const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds'];
router.get('/growing', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT key, value FROM settings WHERE key = ANY($1)`,
[GROWING_KEYS]
);
const out = {
growing_enabled: 'false',
growing_path: '/growing',
growing_smb_url: '',
growing_promote_after_seconds: '8',
};
for (const { key, value } of result.rows) out[key] = value;
res.json(out);
} catch (err) {
next(err);
}
});
router.put('/growing', async (req, res, next) => {
try {
for (const key of GROWING_KEYS) {
if (req.body[key] !== undefined) {
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, String(req.body[key])]
);
}
}
res.json({ message: 'Growing-files settings saved' });
} catch (err) {
next(err);
}
});
// ── Capture service routing ───────────────────────────────────────────────────
router.get('/capture-service', async (req, res, next) => {

View file

@ -138,7 +138,12 @@
<button id="import-btn" disabled title="Download proxy and import into Premiere">Import Proxy</button>
<button id="import-hires-btn" class="secondary" disabled title="Download original hi-res and import into Premiere">Hi-Res</button>
</div>
<!-- Row 2: bulk + timeline actions -->
<!-- Row 2: growing-file actions -->
<div class="action-row">
<button id="mount-live-btn" class="secondary" disabled title="Open the live (growing) file directly from the SMB share">Mount Live</button>
<button id="relink-btn" class="secondary" disabled title="Relink the imported clip from proxy to the finalized hi-res original">Relink to Hi-Res</button>
</div>
<!-- Row 3: bulk + timeline actions -->
<div class="action-row">
<button id="import-all-btn" class="secondary" title="Import all visible assets as proxy">Import All</button>
<button id="export-timeline-btn" class="secondary" title="Push the current Premiere sequence to MAM">Export Timeline &#8593;</button>

View file

@ -58,6 +58,8 @@ function initDOMElements() {
importBtn: document.getElementById('import-btn'),
importHiresBtn: document.getElementById('import-hires-btn'),
importAllBtn: document.getElementById('import-all-btn'),
mountLiveBtn: document.getElementById('mount-live-btn'),
relinkBtn: document.getElementById('relink-btn'),
exportTimelineBtn: document.getElementById('export-timeline-btn'),
progressContainer: document.getElementById('progress-container'),
progressLabel: document.getElementById('progress-label'),
@ -136,6 +138,8 @@ function setupEventListeners() {
elements.importBtn.addEventListener('click', importSelectedAsset);
elements.importHiresBtn.addEventListener('click', importSelectedAssetHires);
elements.importAllBtn.addEventListener('click', importAllAssets);
elements.mountLiveBtn.addEventListener('click', mountLiveAsset);
elements.relinkBtn.addEventListener('click', relinkSelectedAsset);
elements.exportTimelineBtn.addEventListener('click', startExportTimeline);
elements.exportConfirmBtn.addEventListener('click', confirmExportTimeline);
elements.exportCancelBtn.addEventListener('click', cancelExportTimeline);
@ -410,8 +414,13 @@ function showAssetDetails(asset) {
'<span style="color:var(--text-secondary)">No tags</span>';
}
elements.importBtn.disabled = false;
elements.importHiresBtn.disabled = false;
var isLive = asset.status === 'live';
elements.importBtn.disabled = isLive;
elements.importHiresBtn.disabled = isLive;
elements.mountLiveBtn.disabled = !isLive;
// The relink button only makes sense once the live file has been finalized
// AND we have a record of it being mounted in this session.
elements.relinkBtn.disabled = !(asset.status === 'ready' && state.importedAssets['live:' + asset.id]);
}
function hideAssetDetails() {
@ -419,6 +428,166 @@ function hideAssetDetails() {
elements.detailsPanel.classList.add('hidden');
elements.importBtn.disabled = true;
elements.importHiresBtn.disabled = true;
elements.mountLiveBtn.disabled = true;
elements.relinkBtn.disabled = true;
}
// ============================================================================
// Mount Live — open the growing file directly from the SMB share
// ============================================================================
async function mountLiveAsset() {
if (!state.selectedAsset) {
showErrorMessage('No asset selected');
return;
}
var asset = state.selectedAsset;
try {
elements.mountLiveBtn.disabled = true;
showProgress('Resolving SMB path…', 10);
var res = await fetch(state.serverUrl + '/api/v1/assets/' + asset.id + '/live-path', {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!res.ok) {
var body = await res.json().catch(function () { return {}; });
throw new Error(body.error || ('HTTP ' + res.status));
}
var info = await res.json();
// Premiere on Windows wants UNC paths with backslashes; on Mac
// POSIX-style smb:// paths route through the Finder mount.
var isMac = navigator.platform.indexOf('Mac') !== -1;
var hostPath = isMac ? info.posix_path : info.win_path;
showProgress('Importing live file…', 60);
await importFileToPremiereProject(hostPath);
// Remember this asset so the Relink button knows what to swap later.
state.importedAssets['live:' + asset.id] = {
assetId: asset.id,
displayName: info.display_name,
livePath: hostPath,
};
try { localStorage.setItem('mam_imported_assets', JSON.stringify(state.importedAssets)); } catch (_) {}
startLiveStatusPoll(asset.id);
hideProgress();
showSuccessMessage('Mounted live: ' + info.display_name);
} catch (err) {
hideProgress();
showErrorMessage('Mount live failed: ' + err.message);
} finally {
elements.mountLiveBtn.disabled = !(state.selectedAsset && state.selectedAsset.status === 'live');
}
}
// Poll the asset until its status flips from 'live' to 'ready' so we can
// surface the Relink button. 5s cadence — matches the worker's promotion
// scan interval, so we typically catch the transition on the next tick.
var _livePolls = {};
function startLiveStatusPoll(assetId) {
if (_livePolls[assetId]) return;
_livePolls[assetId] = setInterval(async function () {
try {
var r = await fetch(state.serverUrl + '/api/v1/assets/' + assetId, {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!r.ok) return;
var a = await r.json();
if (a.status === 'ready') {
clearInterval(_livePolls[assetId]);
delete _livePolls[assetId];
logMessage('Live asset ' + assetId + ' finalized — relink available');
if (state.selectedAsset && state.selectedAsset.id === assetId) {
state.selectedAsset = a;
showAssetDetails(a);
}
_showFlash('"' + (a.display_name || assetId) + '" finalized — click Relink to swap to hi-res', 'info-message');
}
} catch (_) { /* transient — try again next tick */ }
}, 5000);
}
async function relinkSelectedAsset() {
if (!state.selectedAsset) return;
var asset = state.selectedAsset;
var entry = state.importedAssets['live:' + asset.id];
if (!entry) {
showErrorMessage('No live mount recorded for this asset');
return;
}
try {
elements.relinkBtn.disabled = true;
showProgress('Fetching hi-res link…', 10);
var hires = await getHiresDownloadInfo(asset.id);
var safeName = sanitizeFilename(hires.filename || (asset.display_name || asset.id) + '.mov');
showProgress('Downloading hi-res' + (hires.file_size ? ' (' + formatFileSize(hires.file_size) + ')' : '') + '…', 20);
var localPath = await downloadFile(hires.url, safeName);
showProgress('Relinking in Premiere…', 85);
await relinkInPremiere(entry.livePath, localPath);
saveImportMapping(localPath, safeName, asset);
hideProgress();
showSuccessMessage('Relinked to hi-res: ' + safeName);
} catch (err) {
hideProgress();
showErrorMessage('Relink failed: ' + err.message);
} finally {
elements.relinkBtn.disabled = false;
}
}
// Walks every project item and swaps clip media paths matching `oldPath`
// onto `newPath`. Premiere's ProjectItem.changeMediaPath() does the relink
// without touching timeline placement.
function relinkInPremiere(oldPath, newPath) {
var oldEsc = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
var newEsc = newPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
var script = [
'(function () {',
' var out = { success: false, relinked: 0, message: "" };',
' try {',
' if (!app.project) { out.message = "No project open"; return JSON.stringify(out); }',
' var oldPath = "' + oldEsc + '";',
' var newPath = "' + newEsc + '";',
' function walk(item) {',
' for (var i = 0; i < item.children.numItems; i++) {',
' var c = item.children[i];',
' if (c.type === 1 || c.type === 2) {', /* clip or sub-clip */
' if (c.getMediaPath() === oldPath) {',
' c.changeMediaPath(newPath);',
' out.relinked++;',
' }',
' }',
' if (c.children && c.children.numItems > 0) walk(c);',
' }',
' }',
' walk(app.project.rootItem);',
' out.success = out.relinked > 0;',
' out.message = out.relinked + " clip(s) relinked";',
' } catch (e) { out.message = e.message; }',
' return JSON.stringify(out);',
'})();',
].join('\n');
return new Promise(function (resolve, reject) {
csInterface.evalScript(script, function (resultStr) {
try {
var parsed = JSON.parse(resultStr);
if (parsed.success) resolve(parsed);
else reject(new Error(parsed.message || 'relink found no matching clips'));
} catch (e) {
reject(new Error('ExtendScript error: ' + resultStr));
}
});
});
}
// ============================================================================

View file

@ -749,36 +749,15 @@ function DetailRow({ k, v, mono }) {
}
function Settings() {
const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
const [s3Loading, setS3Loading] = React.useState(true);
const [s3Saving, setS3Saving] = React.useState(false);
const [s3Testing, setS3Testing] = React.useState(false);
const [s3Msg, setS3Msg] = React.useState(null);
const [secretExists, setSecretExists] = React.useState(false);
const [section, setSection] = React.useState('storage');
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/s3')
.then(data => {
setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });
setSecretExists(!!data.s3_secret_key_exists);
setS3Loading(false);
})
.catch(() => setS3Loading(false));
}, []);
const saveS3 = () => {
setS3Saving(true); setS3Msg(null);
window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
.then(() => { setS3Saving(false); setS3Msg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
.catch(e => { setS3Saving(false); setS3Msg({ ok: false, text: e.message }); });
};
const testS3 = () => {
setS3Testing(true); setS3Msg(null);
window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
.then(r => { setS3Testing(false); setS3Msg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
.catch(e => { setS3Testing(false); setS3Msg({ ok: false, text: e.message }); });
};
const SECTIONS = [
{ id: 'storage', label: 'S3 / Object storage', icon: 'hdd' },
{ id: 'gpu', label: 'GPU / Transcoding', icon: 'gpu' },
{ id: 'growing', label: 'Growing files (SMB)', icon: 'hdd' },
{ id: 'sdi', label: 'SDI capture', icon: 'video' },
{ id: 'ampp', label: 'AMPP integration', icon: 'link' },
];
return (
<div className="page">
@ -789,55 +768,21 @@ function Settings() {
<div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 24, alignItems: 'start' }}>
<nav className="settings-nav">
{[
{ id: 'storage', label: 'S3 / Object storage', icon: 'hdd' },
{ id: 'gpu', label: 'GPU / Transcoding', icon: 'gpu' },
{ id: 'sdi', label: 'SDI capture', icon: 'video' },
{ id: 'ampp', label: 'AMPP integration', icon: 'link' },
].map((s, i) => (
<a key={s.id} className={`settings-nav-item ${i === 0 ? 'active' : ''}`}>
{SECTIONS.map(s => (
<a key={s.id}
className={`settings-nav-item ${section === s.id ? 'active' : ''}`}
onClick={() => setSection(s.id)}
style={{ cursor: 'pointer' }}>
<Icon name={s.icon} size={14} />{s.label}
</a>
))}
</nav>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<SettingsCard
icon="hdd"
title="S3 / Object Storage"
sub="S3-compatible bucket for media asset storage"
tag={secretExists ? <span className="badge success">connected</span> : <span className="badge warning">not configured</span>}
>
{s3Loading ? (
<div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div>
) : (<>
<SField label="Endpoint URL">
<input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
</SField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Region">
<input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" />
</SField>
<SField label="Bucket">
<input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" />
</SField>
</div>
<SField label="Access key ID">
<input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" />
</SField>
<SField label="Secret access key">
<input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} />
</SField>
{s3Msg && (
<div style={{ fontSize: 12, padding: '6px 10px', borderRadius: 5, border: '1px solid', background: s3Msg.ok ? 'var(--success-soft)' : 'var(--danger-soft)', borderColor: s3Msg.ok ? 'var(--success)' : 'var(--danger)', color: s3Msg.ok ? 'var(--success)' : 'var(--danger)' }}>
{s3Msg.text}
</div>
)}
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={saveS3} disabled={s3Saving}>{s3Saving ? 'Saving…' : 'Save & apply'}</button>
<button className="btn ghost sm" onClick={testS3} disabled={s3Testing}>{s3Testing ? 'Testing…' : 'Test connection'}</button>
</div>
</>)}
</SettingsCard>
{section === 'storage' && <S3SettingsCard />}
{section === 'gpu' && <GpuSettingsCard />}
{section === 'growing' && <GrowingSettingsCard />}
{section === 'sdi' && <SdiSettingsCard />}
{section === 'ampp' && <AmppSettingsCard />}
</div>
</div>
</div>
@ -845,6 +790,250 @@ function Settings() {
);
}
function S3SettingsCard() {
const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [msg, setMsg] = React.useState(null);
const [secretExists, setSecretExists] = React.useState(false);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/s3')
.then(data => {
setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });
setSecretExists(!!data.s3_secret_key_exists);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
const test = () => {
setTesting(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
.then(r => { setTesting(false); setMsg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
.catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
};
return (
<SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage"
tag={secretExists ? <span className="badge success">connected</span> : <span className="badge warning">not configured</span>}>
{loading ? <div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div> : (<>
<SField label="Endpoint URL">
<input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
</SField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Region"><input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /></SField>
<SField label="Bucket"><input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>
</div>
<SField label="Access key ID"><input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /></SField>
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} /></SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
<button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
</div>
</>)}
</SettingsCard>
);
}
function GpuSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [msg, setMsg] = React.useState(null);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/transcoding').then(setCfg).catch(() => setCfg({}));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration"><div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div></SettingsCard>;
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
const enabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true;
return (
<SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration for proxy generation"
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
<SField label="Enable hardware encoding">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={enabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Route proxy jobs through GPU encoders when available</span>
</label>
</SField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Codec">
<select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
<option value="h264_nvenc">h264_nvenc</option>
<option value="hevc_nvenc">hevc_nvenc</option>
<option value="h264_vaapi">h264_vaapi</option>
<option value="hevc_vaapi">hevc_vaapi</option>
</select>
</SField>
<SField label="Preset">
<select className="field-input" value={cfg.gpu_preset || 'p4'} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
{['p1','p2','p3','p4','p5','p6','p7'].map(p => <option key={p} value={p}>{p}</option>)}
</select>
</SField>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Bitrate (Mbps)">
<input className="field-input mono" type="number" value={cfg.gpu_bitrate_mbps || ''} onChange={e => set('gpu_bitrate_mbps', e.target.value)} placeholder="8" />
</SField>
<SField label="Rate control">
<select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
<option value="cbr">CBR</option>
<option value="vbr">VBR</option>
<option value="cqp">CQP</option>
</select>
</SField>
</div>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
</SettingsCard>
);
}
function GrowingSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [msg, setMsg] = React.useState(null);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/growing').then(setCfg).catch(() => setCfg({
growing_enabled: 'false', growing_path: '/growing', growing_smb_url: '', growing_promote_after_seconds: '8',
}));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…"><div style={{color:'var(--text-3)'}}></div></SettingsCard>;
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;
return (
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
<SField label="Enable growing-file capture">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Capture writes to the local SMB share first; Premier can edit while it's still growing.</span>
</label>
</SField>
<SField label="Container mount path">
<input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />
</SField>
<SField label="SMB share URL (for editors)">
<input className="field-input mono" value={cfg.growing_smb_url || ''} onChange={e => set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" />
</SField>
<SField label="Promote-to-S3 idle threshold (seconds)">
<input className="field-input mono" type="number" value={cfg.growing_promote_after_seconds || ''} onChange={e => set('growing_promote_after_seconds', e.target.value)} placeholder="8" />
</SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
</SettingsCard>
);
}
function SdiSettingsCard() {
return (
<SettingsCard icon="video" title="SDI capture" sub="DeckLink device routing and defaults"
tag={<span className="badge neutral">per-recorder</span>}>
<div style={{ color: 'var(--text-3)', fontSize: 12.5, lineHeight: 1.6 }}>
SDI settings are configured per-recorder. Use{' '}
<strong style={{ color: 'var(--text-2)' }}>Ingest Recorders New recorder</strong>{' '}
to pick the DeckLink port, codec, and audio routing.
</div>
<div style={{ marginTop: 12 }}>
<a className="btn ghost sm" href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('dragonflight-navigate', { detail: 'capture' })); }}>
<Icon name="video" />Open Capture dashboard
</a>
</div>
</SettingsCard>
);
}
function AmppSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [msg, setMsg] = React.useState(null);
const [tokenExists, setTokenExists] = React.useState(false);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/ampp').then(d => {
setCfg({ ampp_base_url: d.ampp_base_url || '', ampp_token: '' });
setTokenExists(!!d.ampp_token_exists);
}).catch(() => setCfg({ ampp_base_url: '', ampp_token: '' }));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/ampp', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); if (cfg.ampp_token) setTokenExists(true); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
const test = () => {
setTesting(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/ampp/test', { method: 'POST', body: JSON.stringify(cfg) })
.then(r => { setTesting(false); setMsg({ ok: true, text: r.message || 'Connection OK' }); })
.catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="link" title="AMPP integration" sub="Loading…"><div style={{color:'var(--text-3)'}}></div></SettingsCard>;
return (
<SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP"
tag={tokenExists ? <span className="badge success">connected</span> : <span className="badge neutral">not configured</span>}>
<SField label="AMPP base URL">
<input className="field-input mono" value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
</SField>
<SField label="API token">
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} />
</SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
</div>
</SettingsCard>
);
}
function SettingsMsg({ msg }) {
if (!msg) return null;
return (
<div style={{ fontSize: 12, padding: '6px 10px', borderRadius: 5, border: '1px solid',
background: msg.ok ? 'var(--success-soft)' : 'var(--danger-soft)',
borderColor: msg.ok ? 'var(--success)' : 'var(--danger)',
color: msg.ok ? 'var(--success)' : 'var(--danger)' }}>
{msg.text}
</div>
);
}
function SField({ label, children }) {
return (
<div className="field">

View file

@ -181,6 +181,59 @@ function Upload({ navigate }) {
);
}
/* ===== Live preview (HLS) ====================================
Shared by RecorderRow + MonitorTile. The capture container writes
HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
and nginx.conf); we attach hls.js to a <video> when a recorder is
actively recording and has a live asset.
============================================================ */
function HlsPreview({ assetId, muted = true, controls = false, className }) {
const videoRef = React.useRef(null);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (!assetId || !videoRef.current) return;
const url = '/live/' + assetId + '/index.m3u8';
const v = videoRef.current;
// Safari can play HLS natively; everything else needs hls.js.
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = url;
const onErr = () => setErr('playback failed');
v.addEventListener('error', onErr);
return () => v.removeEventListener('error', onErr);
}
if (!window.Hls) { setErr('hls.js missing'); return; }
const hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
hls.loadSource(url);
hls.attachMedia(v);
hls.on(window.Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setErr(data.details || 'hls error');
});
return () => { try { hls.destroy(); } catch (_) {} };
}, [assetId]);
return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted={muted}
controls={controls}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
{err && (
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.5)' }}>
{err}
</div>
)}
</div>
);
}
/* ===== Recorders ===== */
function _normRecorder(r) {
let elapsed = '—';
@ -319,9 +372,11 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
return (
<div className={'recorder-row ' + recorder.status}>
<div className="recorder-preview">
{isRec
? <LiveStrip seed={recorder.id.length * 3} count={6} />
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
{isRec && recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_id} />
: isRec
? <LiveStrip seed={recorder.id.length * 3} count={6} />
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
</div>
<div className="recorder-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@ -523,7 +578,9 @@ function MonitorTile({ feed, seed }) {
return (
<div className="monitor-tile">
<FauxFrame />
{isLive && feed.live_asset_id
? <HlsPreview assetId={feed.live_asset_id} />
: <FauxFrame />}
{isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
{isLive && <span className="badge live">REC</span>}

View file

@ -9,6 +9,7 @@
"bullmq": "^5.0.0",
"pg": "^8.13.0",
"@aws-sdk/client-s3": "^3.500.0",
"@aws-sdk/lib-storage": "^3.500.0",
"@aws-sdk/s3-request-presigner": "^3.500.0",
"dotenv": "^16.4.0"
}

View file

@ -62,12 +62,15 @@ export const getMediaInfo = async (inputPath) => {
if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
}
const hasAudio = (info.streams || []).some(s => s.codec_type === 'audio');
return {
fps,
codec: videoStream?.codec_name || null,
resolution: videoStream ? `${videoStream.width}x${videoStream.height}` : null,
durationMs: fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
fileSizeBytes: fmt.size ? parseInt(fmt.size, 10) : null,
hasAudio,
};
};
@ -90,23 +93,49 @@ export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
videoBitrate = '10M',
audioCodec = 'aac',
audioBitrate = '192k',
hasAudio = true,
} = options;
// libx264 / yuv420p require even width AND height. Captured frames from SDI
// or upstream uploads sometimes arrive odd-sized (e.g. 1243x1125). Force-even
// before pixel-format conversion so the encoder never sees odd dimensions.
const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p";
// analyzeduration/probesize must be set BEFORE -i. Some ProRes captures
// write unusual timebases (60k tbn) that ffmpeg cannot resolve with the
// default 5MB probe — bump to 100MB so we always read enough of the file.
const args = [
'-analyzeduration', '100M',
'-probesize', '100M',
'-i', inputPath,
'-vf', vf,
'-c:v', videoCodec,
'-preset', videoPreset,
'-b:v', videoBitrate,
'-c:a', audioCodec,
'-b:a', audioBitrate,
'-movflags', '+faststart',
'-y',
outputPath,
];
if (hasAudio) {
args.push('-c:a', audioCodec, '-b:a', audioBitrate);
} else {
args.push('-an');
}
args.push('-movflags', '+faststart', '-y', outputPath);
await runFFmpeg(args);
};
// Single-frame poster — used as a fallback "proxy" for still-image assets
// so the library can show them without a transcoded video.
export const transcodeImage = async (inputPath, outputPath) => {
await runFFmpeg([
'-i', inputPath,
'-vf', "scale='min(1920,iw)':-2",
'-q:v', '3',
'-y', outputPath,
]);
};
export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
const args = [
'-i', inputPath,

View file

@ -3,6 +3,7 @@ import { Worker } from 'bullmq';
import { proxyWorker } from './workers/proxy.js';
import { thumbnailWorker } from './workers/thumbnail.js';
import { conformWorker } from './workers/conform.js';
import { startPromotionWorker } from './workers/promotion.js';
const parseRedisUrl = (url) => {
const parsed = new URL(url);
@ -51,9 +52,12 @@ const workers = [
createWorker('conform', conformWorker),
];
startPromotionWorker();
console.log('Wild Dragon Worker Service started');
console.log(`Redis: ${redisOptions.host}:${redisOptions.port}`);
console.log('Active queues: proxy, thumbnail, conform');
console.log('Background scans: promotion (growing-files → S3)');
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down...');

View file

@ -43,3 +43,21 @@ export const uploadToS3 = async (bucket, key, localPath) => {
client.destroy();
}
};
// Multipart-aware streaming upload — used by the promotion worker to push
// large growing-file masters without buffering them entirely in memory.
export const uploadStreamToS3 = async (bucket, key, readable) => {
const { Upload } = await import('@aws-sdk/lib-storage');
const client = createS3Client();
try {
const upload = new Upload({
client,
params: { Bucket: bucket, Key: key, Body: readable },
queueSize: 4,
partSize: 8 * 1024 * 1024,
});
await upload.done();
} finally {
client.destroy();
}
};

View file

@ -0,0 +1,125 @@
// Promotion worker — scans the growing-files SMB landing zone for stable
// captures (no mtime change for N seconds) and uploads them to S3, flipping
// the matching asset's status from 'growing' to 'ready' so the Premiere
// panel can relink to the proxy/hi-res URLs.
//
// Why a poll loop and not chokidar: NFS/SMB mounts don't reliably surface
// inotify events through the kernel; mtime polling is the boring-but-works
// answer for fairness across all storage backends.
import { readdir, stat, unlink, readFile } from 'node:fs/promises';
import { join, relative, basename } from 'node:path';
import { createReadStream } from 'node:fs';
import { query } from '../db/client.js';
import { uploadStreamToS3 } from '../s3/client.js';
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
const POLL_MS = 5000;
let inflight = new Set();
let idleThresholdMs = 8000;
async function loadIdleThreshold() {
try {
const r = await query(
`SELECT value FROM settings WHERE key = 'growing_promote_after_seconds'`
);
const sec = parseInt(r.rows[0]?.value, 10);
if (sec > 0) idleThresholdMs = sec * 1000;
} catch (_) { /* table not migrated yet — keep default */ }
}
async function* walk(dir) {
let entries = [];
try { entries = await readdir(dir, { withFileTypes: true }); }
catch (_) { return; }
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) yield* walk(full);
else if (e.isFile()) yield full;
}
}
async function promote(filePath) {
if (inflight.has(filePath)) return;
inflight.add(filePath);
try {
// Reconstruct the S3 key from the relative path under GROWING_PATH.
// Capture writes `${GROWING_PATH}/${projectId}/${clipName}.${ext}`, which
// mirrors `projects/${projectId}/masters/${clipName}.${ext}` in S3.
const rel = relative(GROWING_PATH, filePath); // <projectId>/<clip>.<ext>
const [projectId, fileName] = rel.split('/', 2);
if (!projectId || !fileName) return;
const s3Key = `projects/${projectId}/masters/${fileName}`;
// Find the matching live asset by display_name = clipName.
const clipName = basename(fileName, '.' + fileName.split('.').pop());
const r = await query(
`SELECT id, status FROM assets
WHERE project_id = $1 AND display_name = $2
ORDER BY created_at DESC LIMIT 1`,
[projectId, clipName]
);
if (r.rows.length === 0) {
console.warn(`[promotion] no asset row for ${rel} — skipping`);
return;
}
const asset = r.rows[0];
const st = await stat(filePath);
console.log(`[promotion] uploading ${rel} (${st.size} bytes) -> s3://${S3_BUCKET}/${s3Key}`);
await uploadStreamToS3(S3_BUCKET, s3Key, createReadStream(filePath));
await query(
`UPDATE assets
SET original_s3_key = $1,
file_size = $2,
status = 'ready',
updated_at = NOW()
WHERE id = $3`,
[s3Key, st.size, asset.id]
);
// Queue the proxy job so the editor gets a browser-playable proxy and
// the panel's "relink to hi-res" path becomes available.
const { Queue } = await import('bullmq');
const { hostname, port } = new URL(process.env.REDIS_URL || 'redis://queue:6379');
const proxyQueue = new Queue('proxy', {
connection: { host: hostname, port: parseInt(port, 10) || 6379 },
});
await proxyQueue.add('generate', {
assetId: asset.id,
inputKey: s3Key,
outputKey: `proxies/${asset.id}.mp4`,
});
await proxyQueue.close();
await unlink(filePath).catch(err => {
console.warn(`[promotion] could not unlink ${rel}: ${err.message}`);
});
console.log(`[promotion] asset ${asset.id} promoted, proxy queued, local file removed`);
} catch (err) {
console.error('[promotion] failed for', filePath, err);
} finally {
inflight.delete(filePath);
}
}
async function scan() {
const now = Date.now();
for await (const file of walk(GROWING_PATH)) {
if (inflight.has(file)) continue;
let st;
try { st = await stat(file); } catch (_) { continue; }
if (now - st.mtimeMs >= idleThresholdMs && st.size > 0) {
promote(file);
}
}
}
export function startPromotionWorker() {
loadIdleThreshold();
setInterval(loadIdleThreshold, 60_000);
setInterval(scan, POLL_MS);
console.log(`[promotion] watching ${GROWING_PATH} (idle threshold ${idleThresholdMs}ms)`);
}

View file

@ -4,7 +4,11 @@ import { tmpdir } from 'os';
import { Queue } from 'bullmq';
import { query } from '../db/client.js';
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
import { transcodeVideo, getMediaInfo } from '../ffmpeg/executor.js';
import { transcodeVideo, transcodeImage, getMediaInfo } from '../ffmpeg/executor.js';
// Codec names ffprobe reports for still-image inputs. These bypass the video
// transcode entirely — see proxyWorker below.
const IMAGE_CODECS = new Set(['png', 'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls']);
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
@ -31,16 +35,58 @@ export const proxyWorker = async (job) => {
console.log(`[proxy] Downloading ${inputKey} for asset ${assetId}`);
await downloadFromS3(S3_BUCKET, inputKey, inputPath);
// Extract source metadata (fps, codec, resolution, duration, size)
// Extract source metadata (fps, codec, resolution, duration, size, audio)
await job.updateProgress(20);
let mediaInfo = {};
let hasAudio = true;
try {
mediaInfo = await getMediaInfo(inputPath);
hasAudio = !!mediaInfo.hasAudio;
console.log(`[proxy] Metadata for ${assetId}: ${JSON.stringify(mediaInfo)}`);
} catch (err) {
console.warn(`[proxy] getMediaInfo failed for ${assetId}: ${err.message}`);
}
// Still images skip the video proxy — they have no temporal stream and
// x264 with a one-frame PNG input fails (Could not open encoder before EOF).
// Generate a scaled JPEG poster instead; the thumbnail job will downsize it.
const isImage = mediaInfo.codec && IMAGE_CODECS.has(mediaInfo.codec.toLowerCase());
if (isImage) {
const imageOutputKey = outputKey.replace(/\.mp4$/, '.jpg');
const imageOutputPath = outputPath.replace(/\.mp4$/, '.jpg');
console.log(`[proxy] Image asset ${assetId} (${mediaInfo.codec}) — emitting poster instead of video proxy`);
await job.updateProgress(40);
await transcodeImage(inputPath, imageOutputPath);
await job.updateProgress(70);
await uploadToS3(S3_BUCKET, imageOutputKey, imageOutputPath);
await job.updateProgress(90);
await query(
`UPDATE assets
SET thumbnail_s3_key = $1,
proxy_s3_key = NULL,
resolution = COALESCE($2, resolution),
file_size = COALESCE($3, file_size),
status = 'ready',
updated_at = NOW()
WHERE id = $4`,
[imageOutputKey, mediaInfo.resolution ?? null, mediaInfo.fileSizeBytes ?? null, assetId]
);
await unlink(imageOutputPath).catch(() => {});
await job.updateProgress(100);
return { assetId, outputKey: imageOutputKey };
}
// Empty/truncated capture: probe returned a video stream but ffmpeg can't
// read any frames. Bail with a clear message instead of dumping ~3KB of
// ffmpeg stderr into the failed-jobs list.
if (mediaInfo.durationMs === null && mediaInfo.codec) {
throw new Error(
`Empty or truncated source: codec=${mediaInfo.codec}, ` +
`resolution=${mediaInfo.resolution || 'unknown'}, no readable frames.`
);
}
// Transcode to H.264 proxy
await job.updateProgress(30);
console.log(`[proxy] Transcoding asset ${assetId}`);
@ -50,6 +96,7 @@ export const proxyWorker = async (job) => {
videoBitrate: '10M',
audioCodec: 'aac',
audioBitrate: '192k',
hasAudio,
});
// Upload proxy to S3