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:
parent
122d732db2
commit
17916571b3
6 changed files with 86 additions and 15 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
47
docker-compose.truenas.yml
Normal file
47
docker-compose.truenas.yml
Normal 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
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue