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 @@ - + +
+ + +
+
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() {
- connected : not configured} - > - {s3Loading ? ( -
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'} /> - - {s3Msg && ( -
- {s3Msg.text} -
- )} -
- - -
- )} -
+ {section === 'storage' && } + {section === 'gpu' && } + {section === 'growing' && } + {section === 'sdi' && } + {section === 'ampp' && }
@@ -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'} /> + +
+ + +
+ )} +
+ ); +} + +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_bitrate_mbps', e.target.value)} placeholder="8" /> + + + + +
+ +
+ +
+
+ ); +} + +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_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" /> + + +
+ +
+
+ ); +} + +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. +
+
+ { e.preventDefault(); window.dispatchEvent(new CustomEvent('dragonflight-navigate', { detail: 'capture' })); }}> + Open Capture dashboard + +
+
+ ); +} + +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'} /> + + +
+ + +
+
+ ); +} + +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