diff --git a/docker-compose.yml b/docker-compose.yml
index 501123c..da15302 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js
index c874447..e221b36 100644
--- a/services/capture/src/capture-manager.js
+++ b/services/capture/src/capture-manager.js
@@ -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,
diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js
index e476ee4..1b866b7 100644
--- a/services/mam-api/src/routes/assets.js
+++ b/services/mam-api/src/routes/assets.js
@@ -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 {
diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js
index f233a46..b4d6c8e 100644
--- a/services/mam-api/src/routes/recorders.js
+++ b/services/mam-api/src/routes/recorders.js
@@ -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',
diff --git a/services/mam-api/src/routes/settings.js b/services/mam-api/src/routes/settings.js
index 89834e2..c9721cf 100644
--- a/services/mam-api/src/routes/settings.js
+++ b/services/mam-api/src/routes/settings.js
@@ -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) => {
diff --git a/services/premiere-plugin/index.html b/services/premiere-plugin/index.html
index 56d2750..aecec9c 100644
--- a/services/premiere-plugin/index.html
+++ b/services/premiere-plugin/index.html
@@ -138,7 +138,12 @@
Import Proxy
Hi-Res
-
+
+
+ Mount Live
+ Relink to Hi-Res
+
+
Import All
Export Timeline ↑
diff --git a/services/premiere-plugin/js/main.js b/services/premiere-plugin/js/main.js
index e337906..c267b4f 100644
--- a/services/premiere-plugin/js/main.js
+++ b/services/premiere-plugin/js/main.js
@@ -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) {
'
No tags ';
}
- 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));
+ }
+ });
+ });
}
// ============================================================================
diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx
index 31f735a..8d7e9c8 100644
--- a/services/web-ui/public/screens-admin.jsx
+++ b/services/web-ui/public/screens-admin.jsx
@@ -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 (
@@ -789,55 +768,21 @@ function Settings() {
- {[
- { 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) => (
-
+ {SECTIONS.map(s => (
+ setSection(s.id)}
+ style={{ cursor: 'pointer' }}>
{s.label}
))}
@@ -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 (
+
connected : not configured }>
+ {loading ? Loading…
: (<>
+
+ setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
+
+
+ setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" />
+ setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" />
+
+ setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" />
+ setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} />
+
+
+ {saving ? 'Saving…' : 'Save & apply'}
+ {testing ? 'Testing…' : 'Test connection'}
+
+ >)}
+
+ );
+}
+
+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
Loading…
;
+
+ const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
+ const enabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true;
+
+ return (
+
enabled : disabled }>
+
+
+ set('gpu_transcode_enabled', String(e.target.checked))} />
+ Route proxy jobs through GPU encoders when available
+
+
+
+
+ set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
+ h264_nvenc
+ hevc_nvenc
+ h264_vaapi
+ hevc_vaapi
+
+
+
+ set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
+ {['p1','p2','p3','p4','p5','p6','p7'].map(p => {p} )}
+
+
+
+
+
+ set('gpu_bitrate_mbps', e.target.value)} placeholder="8" />
+
+
+ set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
+ CBR
+ VBR
+ CQP
+
+
+
+
+
+ {saving ? 'Saving…' : 'Save'}
+
+
+ );
+}
+
+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
…
;
+ const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
+ const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;
+
+ return (
+
enabled : disabled }>
+
+
+ set('growing_enabled', String(e.target.checked))} />
+ Capture writes to the local SMB share first; Premier can edit while it's still growing.
+
+
+
+ set('growing_path', e.target.value)} placeholder="/growing" />
+
+
+ set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" />
+
+
+ set('growing_promote_after_seconds', e.target.value)} placeholder="8" />
+
+
+
+ {saving ? 'Saving…' : 'Save'}
+
+
+ );
+}
+
+function SdiSettingsCard() {
+ return (
+
per-recorder}>
+
+ SDI settings are configured per-recorder. Use{' '}
+ Ingest → Recorders → New recorder {' '}
+ to pick the DeckLink port, codec, and audio routing.
+
+
+
+ );
+}
+
+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
…
;
+
+ return (
+
connected : not configured }>
+
+ setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
+
+
+ setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} />
+
+
+
+ {saving ? 'Saving…' : 'Save'}
+ {testing ? 'Testing…' : 'Test connection'}
+
+
+ );
+}
+
+function SettingsMsg({ msg }) {
+ if (!msg) return null;
+ return (
+
+ {msg.text}
+
+ );
+}
+
function SField({ label, children }) {
return (
diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx
index 4e70ffd..51f07b2 100644
--- a/services/web-ui/public/screens-ingest.jsx
+++ b/services/web-ui/public/screens-ingest.jsx
@@ -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
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 (
+
+
+ {err && (
+
+ {err}
+
+ )}
+
+ );
+}
+
/* ===== Recorders ===== */
function _normRecorder(r) {
let elapsed = '—';
@@ -319,9 +372,11 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
return (
- {isRec
- ?
- :
}
+ {isRec && recorder.live_asset_id
+ ?
+ : isRec
+ ?
+ :
}
@@ -523,7 +578,9 @@ function MonitorTile({ feed, seed }) {
return (
-
+ {isLive && feed.live_asset_id
+ ?
+ :
}
{isLive &&
}
{isLive &&
REC }
diff --git a/services/worker/package.json b/services/worker/package.json
index 0db5fd1..b5d370f 100644
--- a/services/worker/package.json
+++ b/services/worker/package.json
@@ -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"
}
diff --git a/services/worker/src/ffmpeg/executor.js b/services/worker/src/ffmpeg/executor.js
index 4a0501a..d1bd3f3 100644
--- a/services/worker/src/ffmpeg/executor.js
+++ b/services/worker/src/ffmpeg/executor.js
@@ -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,
diff --git a/services/worker/src/index.js b/services/worker/src/index.js
index 53163cb..979b50e 100644
--- a/services/worker/src/index.js
+++ b/services/worker/src/index.js
@@ -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...');
diff --git a/services/worker/src/s3/client.js b/services/worker/src/s3/client.js
index 7a51294..c537716 100644
--- a/services/worker/src/s3/client.js
+++ b/services/worker/src/s3/client.js
@@ -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();
+ }
+};
diff --git a/services/worker/src/workers/promotion.js b/services/worker/src/workers/promotion.js
new file mode 100644
index 0000000..b50497d
--- /dev/null
+++ b/services/worker/src/workers/promotion.js
@@ -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); //
/.
+ 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)`);
+}
diff --git a/services/worker/src/workers/proxy.js b/services/worker/src/workers/proxy.js
index d7587a8..99b70e0 100644
--- a/services/worker/src/workers/proxy.js
+++ b/services/worker/src/workers/proxy.js
@@ -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