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:
parent
4045e30cd2
commit
952c0e89af
4 changed files with 169 additions and 13 deletions
|
|
@ -139,6 +139,16 @@ const VIDEO_CODECS = {
|
|||
bitrateControl: true,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1979,12 +1979,13 @@ function importFileToPremiereProject(filePath) {
|
|||
'(function() {',
|
||||
' var result = { success: false, message: "" };',
|
||||
' try {',
|
||||
' var lf = new File("C:/Users/Administrator/Documents/df-import-log.txt");',
|
||||
' var opened = lf.open("a");',
|
||||
' result.message = "opened=" + opened + " fsName=" + lf.fsName + " err=" + (lf.error || "none");',
|
||||
' if (opened) { lf.writeln((new Date()).toString() + " v1.2.5 noop probe"); lf.close(); }',
|
||||
' /* keep success=false so the message surfaces in the panel popup */',
|
||||
' } catch (e) { result.message = "caught: " + e.message; }',
|
||||
' if (!app.project) { result.message = "No active Premiere Pro project"; return JSON.stringify(result); }',
|
||||
' var f = new File("' + safePath + '");',
|
||||
' if (!f.exists) { result.message = "File does not exist: ' + safePath + '"; return JSON.stringify(result); }',
|
||||
' app.project.importFiles(["' + safePath + '"], true);',
|
||||
' result.success = true;',
|
||||
' result.message = "File imported successfully";',
|
||||
' } catch (e) { result.message = "Error importing file: " + e.message; }',
|
||||
' return JSON.stringify(result);',
|
||||
'})();',
|
||||
].join('\n');
|
||||
|
|
|
|||
|
|
@ -156,9 +156,9 @@ function NewRecorderModal({ open, onClose }) {
|
|||
const [recBitrate, setRecBitrate] = React.useState('25');
|
||||
// Container is derived from the codec, not manually chosen: HEVC/ProRes/DNxHR
|
||||
// → 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).
|
||||
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 [proxyOn, setProxyOn] = React.useState(true);
|
||||
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). */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
{[
|
||||
{ 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: 'dnxhr', label: 'DNxHR HQ (MOV)', codec: 'dnxhr_hq', bitrate: '145' },
|
||||
{ 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: 'xdcam', label: 'XDCAM HD422 (MXF OP1a)', codec: 'xdcam_hd422', bitrate: '50' },
|
||||
{ id: 'dnxhr', label: 'DNxHR HQ (MOV)', codec: 'dnxhr_hq', bitrate: '145' },
|
||||
].map(p => (
|
||||
<button key={p.id}
|
||||
className={`btn ghost sm${recCodec === p.codec ? ' active' : ''}`}
|
||||
|
|
@ -437,6 +438,7 @@ function NewRecorderModal({ open, onClose }) {
|
|||
onChange={e => setRecCodec(e.target.value)} disabled={growingOn}
|
||||
style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}>
|
||||
{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="h264_nvenc">H.264 (NVENC, GPU)</option>
|
||||
<option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option>
|
||||
|
|
|
|||
|
|
@ -601,11 +601,28 @@ function HlsPreviewUrl({ url }) {
|
|||
}
|
||||
|
||||
/* ===== 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) {
|
||||
const cfg = r.source_config || {};
|
||||
// Surface the capture port for SDI / Deltacast recorders so the recorder card
|
||||
// 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
|
||||
// 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
|
||||
// is something like /dev/blackmagic/dv0 — we slice off the trailing index.
|
||||
let capturePort = null;
|
||||
if (r.source_type === 'deltacast') {
|
||||
|
|
@ -615,6 +632,22 @@ function _normRecorder(r) {
|
|||
const m = dev.match(/(\d+)$/);
|
||||
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 {
|
||||
...r,
|
||||
source: r.source_type || '·',
|
||||
|
|
@ -704,6 +737,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
|||
const [err, setErr] = React.useState(null);
|
||||
const [liveStatus, setLiveStatus] = React.useState(null);
|
||||
const [clipName, setClipName] = React.useState('');
|
||||
const [configOpen, setConfigOpen] = React.useState(false);
|
||||
// Project override for this take. Defaults to the recorder's configured project.
|
||||
const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || '');
|
||||
const [confirm, confirmModal] = window.useConfirm();
|
||||
|
|
@ -889,10 +923,20 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
|||
: <button className="btn subtle sm" onClick={toggle} disabled={pending}>
|
||||
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
|
||||
</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)' }}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
{configOpen && (
|
||||
<RecorderConfigModal
|
||||
recorder={recorder}
|
||||
onClose={() => setConfigOpen(false)}
|
||||
onSaved={() => { setConfigOpen(false); onRefresh(); }}
|
||||
/>
|
||||
)}
|
||||
</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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue