feat(recorders): add xdcam_hd422 codec + config modal + edit button

- Add xdcam_hd422 to VIDEO_CODECS (mpeg2video, yuv422p, dc10, short GOP)
- Auto-derive MXF container for xdcam_hd422 in new-recorder modal
- Include xdcam_hd422 in BITRATE_CODECS for bitrate input
- Add XDCAM HD422 preset button (50Mbps) + dropdown option
- Add CODEC_LABELS entry for display
- Build RecorderConfigModal with codec/bitrate/growing toggle via PATCH
- Add settings icon to RecorderRow to open config modal
- Enable actual Premiere import (replace stale debug-only probe)
This commit is contained in:
Zac Gaetano 2026-06-04 11:39:00 +00:00
parent 4045e30cd2
commit 952c0e89af
4 changed files with 169 additions and 13 deletions

View file

@ -139,6 +139,16 @@ const VIDEO_CODECS = {
bitrateControl: true, bitrateControl: true,
pixFmt: 'p010le', pixFmt: 'p010le',
}, },
// XDCAM HD422 (MPEG-2 422) — the broadcast-standard codec for MXF OP1a.
// Used both in growing-files mode (via raw2bmx, which reads elementary
// MPEG-2 422 essence FIFOs) and in finalized MXF recordings (ffmpeg muxer).
// `-dc 10` + CBR bitrate (operator target, default 50M) match XDCAM HD422
// essence. Short GOP (-g 15, -bf 2) keeps edit-friendly structure.
xdcam_hd422: {
args: ['-c:v', 'mpeg2video', '-pix_fmt', 'yuv422p', '-dc', '10', '-g', '15', '-bf', '2'],
bitrateControl: true,
pixFmt: 'yuv422p',
},
}; };
// nvenc codecs available in the capture image. Used both to validate the master // nvenc codecs available in the capture image. Used both to validate the master

View file

@ -1979,12 +1979,13 @@ function importFileToPremiereProject(filePath) {
'(function() {', '(function() {',
' var result = { success: false, message: "" };', ' var result = { success: false, message: "" };',
' try {', ' try {',
' var lf = new File("C:/Users/Administrator/Documents/df-import-log.txt");', ' if (!app.project) { result.message = "No active Premiere Pro project"; return JSON.stringify(result); }',
' var opened = lf.open("a");', ' var f = new File("' + safePath + '");',
' result.message = "opened=" + opened + " fsName=" + lf.fsName + " err=" + (lf.error || "none");', ' if (!f.exists) { result.message = "File does not exist: ' + safePath + '"; return JSON.stringify(result); }',
' if (opened) { lf.writeln((new Date()).toString() + " v1.2.5 noop probe"); lf.close(); }', ' app.project.importFiles(["' + safePath + '"], true);',
' /* keep success=false so the message surfaces in the panel popup */', ' result.success = true;',
' } catch (e) { result.message = "caught: " + e.message; }', ' result.message = "File imported successfully";',
' } catch (e) { result.message = "Error importing file: " + e.message; }',
' return JSON.stringify(result);', ' return JSON.stringify(result);',
'})();', '})();',
].join('\n'); ].join('\n');

View file

@ -156,9 +156,9 @@ function NewRecorderModal({ open, onClose }) {
const [recBitrate, setRecBitrate] = React.useState('25'); const [recBitrate, setRecBitrate] = React.useState('25');
// Container is derived from the codec, not manually chosen: HEVC/ProRes/DNxHR // Container is derived from the codec, not manually chosen: HEVC/ProRes/DNxHR
// MOV (fragmented, growing-capable); H.264 MP4. // MOV (fragmented, growing-capable); H.264 MP4.
const recContainer = (recCodec === 'h264' || recCodec === 'h264_nvenc' || recCodec === 'libx264') ? 'mp4' : 'mov'; const recContainer = (recCodec === 'h264' || recCodec === 'h264_nvenc' || recCodec === 'libx264') ? 'mp4' : (recCodec === 'xdcam_hd422' ? 'mxf' : 'mov');
// Codecs whose bitrate is operator-controlled (everything except ProRes). // Codecs whose bitrate is operator-controlled (everything except ProRes).
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']); const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq', 'xdcam_hd422']);
const codecUsesBitrate = BITRATE_CODECS.has(recCodec); const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
const [proxyOn, setProxyOn] = React.useState(true); const [proxyOn, setProxyOn] = React.useState(true);
const [growingOn, setGrowingOn] = React.useState(false); const [growingOn, setGrowingOn] = React.useState(false);
@ -416,9 +416,10 @@ function NewRecorderModal({ open, onClose }) {
H.264 MP4), and master audio is always PCM (valid in MOV). */} H.264 MP4), and master audio is always PCM (valid in MOV). */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{[ {[
{ id: 'hevc', label: 'HEVC Master (MOV)', codec: 'hevc_nvenc', bitrate: '25' }, { id: 'hevc', label: 'HEVC Master (MOV)', codec: 'hevc_nvenc', bitrate: '25' },
{ id: 'h264', label: 'H.264 Proxy-friendly (MP4)', codec: 'h264_nvenc', bitrate: '25' }, { id: 'h264', label: 'H.264 Proxy-friendly (MP4)', codec: 'h264_nvenc', bitrate: '25' },
{ id: 'dnxhr', label: 'DNxHR HQ (MOV)', codec: 'dnxhr_hq', bitrate: '145' }, { id: 'xdcam', label: 'XDCAM HD422 (MXF OP1a)', codec: 'xdcam_hd422', bitrate: '50' },
{ id: 'dnxhr', label: 'DNxHR HQ (MOV)', codec: 'dnxhr_hq', bitrate: '145' },
].map(p => ( ].map(p => (
<button key={p.id} <button key={p.id}
className={`btn ghost sm${recCodec === p.codec ? ' active' : ''}`} className={`btn ghost sm${recCodec === p.codec ? ' active' : ''}`}
@ -437,6 +438,7 @@ function NewRecorderModal({ open, onClose }) {
onChange={e => setRecCodec(e.target.value)} disabled={growingOn} onChange={e => setRecCodec(e.target.value)} disabled={growingOn}
style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}> style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}>
{growingOn && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>} {growingOn && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>}
<option value="xdcam_hd422">XDCAM HD422 (MXF OP1a)</option>
<option value="hevc_nvenc">All-Intra HEVC (NVENC, GPU, growing)</option> <option value="hevc_nvenc">All-Intra HEVC (NVENC, GPU, growing)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option> <option value="h264_nvenc">H.264 (NVENC, GPU)</option>
<option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option> <option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option>

View file

@ -601,11 +601,28 @@ function HlsPreviewUrl({ url }) {
} }
/* ===== Recorders ===== */ /* ===== Recorders ===== */
const CODEC_LABELS = {
xdcam_hd422: 'XDCAM HD422',
hevc_nvenc: 'HEVC (NVENC)',
h264_nvenc: 'H.264 (NVENC)',
prores_hq: 'ProRes 422 HQ',
prores_422: 'ProRes 422',
prores_lt: 'ProRes 422 LT',
prores_proxy: 'ProRes 422 Proxy',
dnxhr_hq: 'DNxHR HQ',
dnxhr_sq: 'DNxHR SQ',
dnxhd: 'DNxHD',
libx264: 'H.264 (x264)',
libx265: 'H.265 (x265)',
h264: 'H.264',
h265: 'H.265',
};
function _normRecorder(r) { function _normRecorder(r) {
const cfg = r.source_config || {}; const cfg = r.source_config || {};
// Surface the capture port for SDI / Deltacast recorders so the recorder card // Surface the capture port for SDI / Deltacast recorders so the recorder card
// can show which physical input the recorder is bound to. For Deltacast, // can show which physical input the recorder is bound to. For Deltacast,
// cfg.port is the bridge port index (0-7). For Blackmagic SDI, cfg.device // cfg.port is the bridge port index (0-7). For Blackmagic SDI, cfg.device
// is something like /dev/blackmagic/dv0 we slice off the trailing index. // is something like /dev/blackmagic/dv0 we slice off the trailing index.
let capturePort = null; let capturePort = null;
if (r.source_type === 'deltacast') { if (r.source_type === 'deltacast') {
@ -615,6 +632,22 @@ function _normRecorder(r) {
const m = dev.match(/(\d+)$/); const m = dev.match(/(\d+)$/);
if (m) capturePort = `SDI ${m[1]}`; if (m) capturePort = `SDI ${m[1]}`;
} }
return {
...r,
source: r.source_type || '·',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
codec: CODEC_LABELS[r.recording_codec] || r.recording_codec || '·',
res: r.recording_resolution || '·',
framerate: r.recording_framerate || 'native',
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
capturePort,
previewUrl: r.preview_url || null,
elapsed: '·',
bitrate: '·',
health: 100,
audio: false,
};
}
return { return {
...r, ...r,
source: r.source_type || '·', source: r.source_type || '·',
@ -704,6 +737,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
const [err, setErr] = React.useState(null); const [err, setErr] = React.useState(null);
const [liveStatus, setLiveStatus] = React.useState(null); const [liveStatus, setLiveStatus] = React.useState(null);
const [clipName, setClipName] = React.useState(''); const [clipName, setClipName] = React.useState('');
const [configOpen, setConfigOpen] = React.useState(false);
// Project override for this take. Defaults to the recorder's configured project. // Project override for this take. Defaults to the recorder's configured project.
const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || ''); const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || '');
const [confirm, confirmModal] = window.useConfirm(); const [confirm, confirmModal] = window.useConfirm();
@ -889,10 +923,20 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
: <button className="btn subtle sm" onClick={toggle} disabled={pending}> : <button className="btn subtle sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>} {pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
</button>} </button>}
<button className="icon-btn" onClick={() => setConfigOpen(true)} title="Edit recorder settings" aria-label="Edit recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="settings" />
</button>
<button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}> <button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="x" /> <Icon name="x" />
</button> </button>
</div> </div>
{configOpen && (
<RecorderConfigModal
recorder={recorder}
onClose={() => setConfigOpen(false)}
onSaved={() => { setConfigOpen(false); onRefresh(); }}
/>
)}
</div> </div>
); );
} }
@ -2334,4 +2378,103 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
); );
} }
/* ===== Recorder Config Editor ===== */
function RecorderConfigModal({ recorder, onClose, onSaved }) {
const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc');
const [bitrate, setBitrate] = React.useState(() => {
const raw = recorder.recording_video_bitrate || '';
const m = String(raw).match(/^(\d+(?:\.\d+)?)/);
return m ? m[1] : '25';
});
const [growingOn, setGrowingOn] = React.useState(!!recorder.growing_enabled);
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq', 'xdcam_hd422']);
const codecUsesBitrate = BITRATE_CODECS.has(codec);
const showBitrate = codecUsesBitrate || growingOn;
const container = (codec === 'h264' || codec === 'h264_nvenc' || codec === 'libx264') ? 'mp4' : (codec === 'xdcam_hd422' ? 'mxf' : 'mov');
const save = async () => {
setSaving(true);
setErr(null);
const body = {
recording_codec: codec,
recording_container: container,
growing_enabled: growingOn,
};
if ((codecUsesBitrate || growingOn) && bitrate) {
body.recording_video_bitrate = `${bitrate}M`;
}
try {
const res = await window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'PATCH', body: JSON.stringify(body) });
if (res && res.error) { setErr(res.error); setSaving(false); return; }
onSaved();
} catch (e) {
setErr(e.message || 'Save failed');
setSaving(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Edit recorder</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{recorder.name}</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Video codec</label>
<select className="field-input" value={codec} onChange={e => setCodec(e.target.value)}
style={{ appearance: 'auto' }}>
<option value="xdcam_hd422">XDCAM HD422 (MXF OP1a)</option>
<option value="hevc_nvenc">All-Intra HEVC (NVENC, GPU)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
<option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option>
<option value="prores_422">ProRes 422</option>
<option value="prores_lt">ProRes 422 LT</option>
<option value="prores_proxy">ProRes 422 Proxy</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="dnxhr_sq">DNxHR SQ</option>
<option value="libx264">H.264 (x264, CPU)</option>
<option value="libx265">H.265 (x265, CPU)</option>
</select>
</div>
{showBitrate && (
<div className="field">
<label className="field-label">
Target bitrate (Mbps)
{growingOn && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--warn, #d9a441)' }}>· GROWING</span>}
</label>
<input className="field-input" type="number" min="1" max="400" step="1"
value={bitrate} onChange={e => setBitrate(e.target.value)} />
</div>
)}
<div className="field">
<label className="field-label">
<input type="checkbox" checked={growingOn} onChange={e => setGrowingOn(e.target.checked)}
style={{ marginRight: 6, verticalAlign: -2 }} />
Growing files (edit-while-record)
</label>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 2 }}>
Wraps XDCAM HD422 in MXF OP1a with periodic index updates so editors can import the file mid-record.
</div>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
}
Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule, YouTubeImport }); Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule, YouTubeImport });