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
|
||||
|
||||
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
|
||||
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 { isConnected, lastMessage } = useWebSocket(wsUrl);
|
||||
const { startRecording, stopRecording, injectSCTE35, injectSCTE35ForPort } = useRecorder();
|
||||
const { startRecording, stopRecording, injectSCTE35, injectSCTE35ForPort, error: recorderError } = useRecorder();
|
||||
|
||||
// Clock
|
||||
useEffect(() => {
|
||||
|
|
@ -88,6 +88,17 @@ export default function App() {
|
|||
|
||||
return (
|
||||
<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 style={{
|
||||
|
|
@ -303,7 +314,7 @@ export default function App() {
|
|||
function defaultConfig(portIndex: number): RecorderConfig {
|
||||
return {
|
||||
port_index: portIndex,
|
||||
codec: 'PRORES',
|
||||
codec: 'prores',
|
||||
bitrate: '185M',
|
||||
quality_profile: 'hq',
|
||||
recording_path: `/recordings/port_${portIndex}_{timestamp}.mxf`,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ interface 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 [qualityProfile, setQualityProfile] = useState('hq');
|
||||
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 }}>
|
||||
<label style={labelStyle}>Codec</label>
|
||||
<select value={codec} onChange={e => setCodec(e.target.value as CodecType)} style={selectStyle}>
|
||||
<option value="PRORES">ProRes</option>
|
||||
<option value="DNXHD">DNxHD</option>
|
||||
<option value="H264">H.264</option>
|
||||
<option value="UNCOMPRESSED">Uncompressed</option>
|
||||
<option value="prores">ProRes</option>
|
||||
<option value="dnxhd">DNxHD</option>
|
||||
<option value="h264">H.264</option>
|
||||
<option value="uncompressed">Uncompressed</option>
|
||||
</select>
|
||||
</div>
|
||||
{codec === 'PRORES' && (
|
||||
{codec === 'prores' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Quality Profile</label>
|
||||
<select value={qualityProfile} onChange={e => setQualityProfile(e.target.value)} style={selectStyle}>
|
||||
|
|
@ -152,7 +152,7 @@ export function ConfigPanel({ portIndex, currentConfig, onSave, onClose }: Confi
|
|||
</select>
|
||||
</div>
|
||||
)}
|
||||
{(codec === 'DNXHD' || codec === 'H264') && (
|
||||
{(codec === 'dnxhd' || codec === 'h264') && (
|
||||
<div>
|
||||
<label style={labelStyle}>Bitrate</label>
|
||||
<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';
|
||||
|
||||
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() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -16,7 +29,7 @@ export function useRecorder() {
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
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) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(msg);
|
||||
|
|
@ -34,7 +47,7 @@ export function useRecorder() {
|
|||
method: 'POST',
|
||||
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) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(msg);
|
||||
|
|
@ -62,7 +75,7 @@ export function useRecorder() {
|
|||
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;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
|
|
@ -94,7 +107,7 @@ export function useRecorder() {
|
|||
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;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface PortStatus {
|
|||
srt_destinations: string[];
|
||||
}
|
||||
|
||||
export type CodecType = 'PRORES' | 'DNXHD' | 'UNCOMPRESSED' | 'H264';
|
||||
export type CodecType = 'prores' | 'dnxhd' | 'uncompressed' | 'h264';
|
||||
|
||||
export interface SRTDestination {
|
||||
url: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue