fix: lowercase codec enum, recorder .done() bug, TrueNAS deploy override

- frontend types & components now use lowercase codec values (prores/dnxhd/
  h264/uncompressed) to match backend CodecType enum. This was the real
  cause of the silent Start Recording 422.
- recorder.py: replaced last .done() call on asyncio.subprocess.Process
  with returncode-is-None check (same bug previously fixed in hls.py).
  This was causing /api/ports/{n} to 500 during active recording and
  health checks to fail while any port was running.
- useRecorder now parses pydantic 422 error bodies so field-level detail
  reaches the UI, and App.tsx shows a fixed error banner.
- Added docker-compose.truenas.yml: no deltacast device passthrough,
  frontend on :8088. Lavfi testsrc2 fallback kicks in automatically and
  feeds the full HLS preview + recording pipeline with a test signal
  (1080p30 + 1kHz tone). Verified end-to-end on Wooglin: 442MB/1:56s
  H.264 MP4 + working HLS playlist.
This commit is contained in:
Zac Gaetano 2026-04-14 16:20:57 -04:00
parent 122d732db2
commit 17916571b3
6 changed files with 86 additions and 15 deletions

View file

@ -90,7 +90,7 @@ class PortRecorder:
self._start_time = None self._start_time = None
def get_status(self) -> PortStatus: def get_status(self) -> PortStatus:
is_recording = self._process is not None and not self._process.done() is_recording = self._process is not None and self._process.returncode is None
uptime_seconds = 0 uptime_seconds = 0
if self._start_time is not None: if self._start_time is not None:

View file

@ -0,0 +1,47 @@
version: '3.8'
# TrueNAS (Wooglin) override — no physical Deltacast card.
# The backend auto-detects missing /dev/deltacast{n} and falls back to
# a lavfi testsrc2 signal (colour bars + clock + 1 kHz tone) per port.
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
# NOTE: no devices: block — lavfi fallback kicks in automatically
volumes:
- recordings:/recordings
- hls:/tmp/hls
environment:
- FFMPEG_PATH=/usr/bin/ffmpeg
- RECORDING_DIR=/recordings
- DELTACAST_PORT_COUNT=8
- SRT_ENABLED=true
- SRT_LATENCY=5000
- PORT=8000
- LOG_LEVEL=INFO
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8088:80"
depends_on:
- backend
restart: unless-stopped
volumes:
recordings:
driver: local
hls:
driver: local

View file

@ -29,7 +29,7 @@ export default function App() {
const wsUrl = `ws://${window.location.host}/ws`; const wsUrl = `ws://${window.location.host}/ws`;
const { isConnected, lastMessage } = useWebSocket(wsUrl); const { isConnected, lastMessage } = useWebSocket(wsUrl);
const { startRecording, stopRecording, injectSCTE35, injectSCTE35ForPort } = useRecorder(); const { startRecording, stopRecording, injectSCTE35, injectSCTE35ForPort, error: recorderError } = useRecorder();
// Clock // Clock
useEffect(() => { useEffect(() => {
@ -88,6 +88,17 @@ export default function App() {
return ( return (
<div style={{ minHeight: '100vh', background: 'var(--bg-deepest)', color: 'var(--text-primary)' }}> <div style={{ minHeight: '100vh', background: 'var(--bg-deepest)', color: 'var(--text-primary)' }}>
{recorderError && (
<div style={{
position:'fixed', top:16, left:'50%', transform:'translateX(-50%)',
background:'#8B1E1E', color:'#fff', padding:'10px 18px', borderRadius:6,
fontFamily:'monospace', fontSize:13, zIndex:9999, maxWidth:'90vw',
boxShadow:'0 4px 16px rgba(0,0,0,.4)'
}}>
{recorderError}
</div>
)}
{/* ── HEADER ── */} {/* ── HEADER ── */}
<header style={{ <header style={{
@ -303,7 +314,7 @@ export default function App() {
function defaultConfig(portIndex: number): RecorderConfig { function defaultConfig(portIndex: number): RecorderConfig {
return { return {
port_index: portIndex, port_index: portIndex,
codec: 'PRORES', codec: 'prores',
bitrate: '185M', bitrate: '185M',
quality_profile: 'hq', quality_profile: 'hq',
recording_path: `/recordings/port_${portIndex}_{timestamp}.mxf`, recording_path: `/recordings/port_${portIndex}_{timestamp}.mxf`,

View file

@ -9,7 +9,7 @@ interface ConfigPanelProps {
} }
export function ConfigPanel({ portIndex, currentConfig, onSave, onClose }: ConfigPanelProps) { export function ConfigPanel({ portIndex, currentConfig, onSave, onClose }: ConfigPanelProps) {
const [codec, setCodec] = useState<CodecType>('PRORES'); const [codec, setCodec] = useState<CodecType>('prores');
const [bitrate, setBitrate] = useState('185M'); const [bitrate, setBitrate] = useState('185M');
const [qualityProfile, setQualityProfile] = useState('hq'); const [qualityProfile, setQualityProfile] = useState('hq');
const [recordingPath, setRecordingPath] = useState(`/recordings/port_${portIndex}_{timestamp}.mxf`); const [recordingPath, setRecordingPath] = useState(`/recordings/port_${portIndex}_{timestamp}.mxf`);
@ -136,13 +136,13 @@ export function ConfigPanel({ portIndex, currentConfig, onSave, onClose }: Confi
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Codec</label> <label style={labelStyle}>Codec</label>
<select value={codec} onChange={e => setCodec(e.target.value as CodecType)} style={selectStyle}> <select value={codec} onChange={e => setCodec(e.target.value as CodecType)} style={selectStyle}>
<option value="PRORES">ProRes</option> <option value="prores">ProRes</option>
<option value="DNXHD">DNxHD</option> <option value="dnxhd">DNxHD</option>
<option value="H264">H.264</option> <option value="h264">H.264</option>
<option value="UNCOMPRESSED">Uncompressed</option> <option value="uncompressed">Uncompressed</option>
</select> </select>
</div> </div>
{codec === 'PRORES' && ( {codec === 'prores' && (
<div> <div>
<label style={labelStyle}>Quality Profile</label> <label style={labelStyle}>Quality Profile</label>
<select value={qualityProfile} onChange={e => setQualityProfile(e.target.value)} style={selectStyle}> <select value={qualityProfile} onChange={e => setQualityProfile(e.target.value)} style={selectStyle}>
@ -152,7 +152,7 @@ export function ConfigPanel({ portIndex, currentConfig, onSave, onClose }: Confi
</select> </select>
</div> </div>
)} )}
{(codec === 'DNXHD' || codec === 'H264') && ( {(codec === 'dnxhd' || codec === 'h264') && (
<div> <div>
<label style={labelStyle}>Bitrate</label> <label style={labelStyle}>Bitrate</label>
<input type="text" value={bitrate} onChange={e => setBitrate(e.target.value)} placeholder="185M, 50M..." style={inputStyle} /> <input type="text" value={bitrate} onChange={e => setBitrate(e.target.value)} placeholder="185M, 50M..." style={inputStyle} />

View file

@ -3,6 +3,19 @@ import { RecorderConfig, SCTE35Marker } from '../types';
const API_BASE = '/api'; const API_BASE = '/api';
async function readApiError(response: Response, fallback: string): Promise<string> {
try {
const body = await response.json();
if (body?.detail) {
if (Array.isArray(body.detail)) {
return body.detail.map((d: any) => `${(d.loc || []).join('.')}: ${d.msg}`).join('; ');
}
if (typeof body.detail === 'string') return body.detail;
}
} catch { /* fall through */ }
return `${fallback} (HTTP ${response.status} ${response.statusText || ''})`.trim();
}
export function useRecorder() { export function useRecorder() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -16,7 +29,7 @@ export function useRecorder() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config), body: JSON.stringify(config),
}); });
if (!response.ok) throw new Error(`Failed to start recording: ${response.statusText}`); if (!response.ok) { const msg = await readApiError(response, `Failed to start recording`); throw new Error(msg); }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'; const msg = err instanceof Error ? err.message : 'Unknown error';
setError(msg); setError(msg);
@ -34,7 +47,7 @@ export function useRecorder() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
if (!response.ok) throw new Error(`Failed to stop recording: ${response.statusText}`); if (!response.ok) { const msg = await readApiError(response, `Failed to stop recording`); throw new Error(msg); }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'; const msg = err instanceof Error ? err.message : 'Unknown error';
setError(msg); setError(msg);
@ -62,7 +75,7 @@ export function useRecorder() {
webhook_url: webhookUrl || null, webhook_url: webhookUrl || null,
}), }),
}); });
if (!response.ok) throw new Error(`Failed to inject SCTE35: ${response.statusText}`); if (!response.ok) { const msg = await readApiError(response, `Failed to inject SCTE35`); throw new Error(msg); }
return await response.json() as SCTE35Marker; return await response.json() as SCTE35Marker;
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'; const msg = err instanceof Error ? err.message : 'Unknown error';
@ -94,7 +107,7 @@ export function useRecorder() {
srt_destination_url: srtDestinationUrl || null, srt_destination_url: srtDestinationUrl || null,
}), }),
}); });
if (!response.ok) throw new Error(`Failed to inject SCTE35 on port ${portIndex}: ${response.statusText}`); if (!response.ok) { const msg = await readApiError(response, `Failed to inject SCTE35 on port ${portIndex}`); throw new Error(msg); }
return await response.json() as SCTE35Marker; return await response.json() as SCTE35Marker;
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'; const msg = err instanceof Error ? err.message : 'Unknown error';

View file

@ -10,7 +10,7 @@ export interface PortStatus {
srt_destinations: string[]; srt_destinations: string[];
} }
export type CodecType = 'PRORES' | 'DNXHD' | 'UNCOMPRESSED' | 'H264'; export type CodecType = 'prores' | 'dnxhd' | 'uncompressed' | 'h264';
export interface SRTDestination { export interface SRTDestination {
url: string; url: string;