diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index d1674e7..cbb96dd 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -757,59 +757,32 @@ class CaptureManager { const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe'; const WAIT_MS = 30_000; - // Determine audio FIFO path based on source type - const idx = (typeof device === 'number' || /^\d+$/.test(String(device))) - ? parseInt(device, 10) : 0; - const portIdx = (sourceType === 'deltacast') - ? ((typeof port === 'number' || /^\d+$/.test(String(port))) - ? parseInt(port, 10) : idx) - : idx; - - let audioFifoDir, audioFifoPath; - if (sourceType === 'deltacast') { - audioFifoDir = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast'; - } else { - audioFifoDir = process.env.DECKLINK_AUDIO_DIR || '/dev/shm/decklink'; - } - audioFifoPath = `${audioFifoDir}/audio-${portIdx}.fifo`; - - // ── Frame-coupled audio (FC_VERSION 2) ──────────────────────────────── - // The bridge no longer owns the audio FIFO: audio rides in the framecache - // ring entry with its video frame. capture-manager CREATES the audio FIFO - // and fc_pipe WRITES it, sourced from the SAME ring entry as the video, so - // audio is frame-locked (no second transport, no drift). We mkfifo here - // (idempotent) so fc_pipe and ffmpeg have a stable rendezvous path. - const { existsSync: _exists, mkdirSync: _mkdir } = await import('node:fs'); - try { _mkdir(audioFifoDir, { recursive: true }); } catch { /* exists */ } - if (!_exists(audioFifoPath)) { - try { - execFileSync('mkfifo', ['-m', '0666', audioFifoPath]); - } catch (e) { - if (!_exists(audioFifoPath)) { - throw new Error(`failed to create audio FIFO ${audioFifoPath}: ${e.message}`); - } - } - } + // Single-input AVI: fc_pipe muxes video+audio into ONE streaming AVI + // container on stdout. ffmpeg reads it as a SINGLE input (-f avi -i pipe:0), + // which eliminates the confirmed two-live-pipe deadlock (ffmpeg given a raw + // video pipe AND a separate live audio FIFO stalled forever probing input 0). + // No audio FIFO is created or used on this path anymore: audio rides inside + // the AVI as interleaved 01wb chunks, frame-coupled to each 00dc video chunk + // (both come from the SAME framecache ring entry in fc_pipe's read loop). // Video dimensions and fps come from env vars injected by node-agent - // (populated from the bridge's format JSON on signal lock). + // (populated from the bridge's format JSON on signal lock). fc_pipe also + // reads them from the slot header for the AVI header; these stay for logging. const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080'; const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001'; const fcInterlaced = process.env.DELTACAST_INTERLACED === '1'; - console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} audio=${audioFifoPath} (frame-coupled)`); + console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} mode=avi (single-input video+audio, frame-coupled)`); - // Spawn fc_pipe: opens the framecache slot with its own read cursor and, - // for each ring entry, writes the VIDEO bytes to stdout (ffmpeg rawvideo - // input 0) AND that frame's AUDIO bytes to the audio FIFO (ffmpeg s16le - // input 1) IN LOCKSTEP from one cursor read. Because both come from the - // SAME ring entry in the same iteration, audio can never drift from video - // — the "audio ahead of video" offset is eliminated at the root. - // argv: - const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS), audioFifoPath], { + // Spawn fc_pipe in AVI mode: for each ring entry it emits a 00dc video chunk + // followed by a 01wb audio chunk into one AVI byte stream on stdout. ffmpeg + // reads that single stream and maps 0:v / 0:a. Because video and its audio + // are interleaved from the same ring entry, audio can never drift from video. + // argv: --avi + const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS), '--avi'], { stdio: ['ignore', 'pipe', 'pipe'], }); - // Pause until piped to ffmpeg (avoids OS pipe-buffer fill stall — see + // Pause until piped to ffmpeg (avoids OS pipe-buffer fill stall - see // the network path above for the full rationale). fcPipeProcess.stdout.pause(); fcPipeProcess.stderr.on('data', chunk => { @@ -821,42 +794,23 @@ class CaptureManager { return { inputArgs: [ - // fc_pipe stdout → ffmpeg rawvideo input 0 (video). + // fc_pipe stdout -> ffmpeg AVI input 0. ONE input carries both streams: + // 0:v = UYVY422 video (00dc chunks), 0:a = pcm_s16le audio (01wb chunks). + // The AVI demuxer reads the strf headers + the chunk stream with no index + // and no seeking, so streaming over a pipe is fine (RIFF/movi sizes are + // left as the streaming sentinel by fc_pipe). '-thread_queue_size', '512', - '-f', 'rawvideo', - '-pix_fmt', 'uyvy422', - '-video_size', fcSize, - '-framerate', fcFps, - '-i', 'pipe:0', - // Audio FIFO → ffmpeg input 1. - // - // fc_pipe writes this FIFO from the SAME framecache ring entry as the - // video it sends to input 0, one entry per loop iteration — so the - // audio is exactly each video frame's SDI-embedded audio, delivered - // frame-locked. There is no independent audio buffer to race ahead. - // - // Do NOT use -use_wallclock_as_timestamps here. fc_pipe feeds raw - // s16le at a steady 48000 samples/s off the SAME SDI clock as video, - // so letting ffmpeg derive audio PTS from the sample count keeps audio - // and video in one clock domain (no drift). Wallclock stamps audio by - // arrival wall-time instead — when the HEVC encoder dips under realtime - // the audio ends up 3–18% LONGER than the frame-count video, and the - // master aresample=async=1 then pads seconds of LEADING SILENCE to - // "align" them → the silent-head + start-stutter + apparent "no audio" - // regression (reverts commit d6b0b3a; restores 8e5405c/55a72af). - '-thread_queue_size', '512', - '-f', 's16le', - '-ar', '48000', - '-ac', '2', + '-f', 'avi', // Optional fixed A/V trim (env AUDIO_OFFSET_MS); default empty = no shift. + // Applied as an input option so it shifts the AVI's audio relative to video. ...audioOffsetArgs(), - '-i', audioFifoPath, + '-i', 'pipe:0', ], isNetwork: false, bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */ - audioFifo: audioFifoPath, /* flushed just before ffmpeg opens it (A/V align) */ + audioFifo: null, /* no separate audio FIFO on the AVI path */ interlaced: fcInterlaced, - audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */ + audioInputIndex: 0, /* audio is inside the single AVI input (0:a) */ _fcPipeProcess: fcPipeProcess, /* stored for clean stop */ }; } diff --git a/services/framecache/CMakeLists.txt b/services/framecache/CMakeLists.txt index 92970bc..27733d6 100644 --- a/services/framecache/CMakeLists.txt +++ b/services/framecache/CMakeLists.txt @@ -46,7 +46,7 @@ install(TARGETS net_ingest DESTINATION bin) add_executable(fc_pipe client/fc_pipe.c ) -target_link_libraries(fc_pipe fc_client) +target_link_libraries(fc_pipe fc_client pthread) target_include_directories(fc_pipe PRIVATE src client) # ── test consumer (dev utility) ────────────────────────────────────── diff --git a/services/framecache/client/fc_client.c b/services/framecache/client/fc_client.c index 5a60129..7070582 100644 --- a/services/framecache/client/fc_client.c +++ b/services/framecache/client/fc_client.c @@ -229,3 +229,19 @@ uint64_t fc_consumer_dropped(fc_consumer_t *c) { return c->local_dropped; } + +int fc_consumer_info(fc_consumer_t *c, fc_stream_info_t *info) +{ + if (!c || !info) return -1; + fc_header_t *hdr = (fc_header_t *)c->base; + info->width = hdr->width; + info->height = hdr->height; + info->fps_num = hdr->fps_num; + info->fps_den = hdr->fps_den; + info->pixel_format = hdr->pixel_format; + info->frame_size = hdr->frame_size; + info->audio_rate = hdr->audio_rate ? hdr->audio_rate : FC_AUDIO_RATE; + info->audio_channels = hdr->audio_channels ? hdr->audio_channels : FC_AUDIO_CHANNELS; + info->audio_sample_bytes = FC_AUDIO_SAMPLE_BYTES; /* s16le */ + return 0; +} diff --git a/services/framecache/client/fc_client.h b/services/framecache/client/fc_client.h index 84c13a7..79dc760 100644 --- a/services/framecache/client/fc_client.h +++ b/services/framecache/client/fc_client.h @@ -81,6 +81,23 @@ uint64_t fc_consumer_write_cursor(fc_consumer_t *c); /** Frames dropped by this consumer since open. */ uint64_t fc_consumer_dropped(fc_consumer_t *c); +/* Stream format info read from the slot header (set at slot creation by the + * bridge). Used by fc_pipe to emit a correct AVI/container header. */ +typedef struct { + uint32_t width; + uint32_t height; + uint32_t fps_num; + uint32_t fps_den; + uint32_t pixel_format; /* FC_PIX_UYVY422 */ + uint32_t frame_size; /* video bytes per frame (width*height*2 for UYVY422) */ + uint32_t audio_rate; /* 48000 */ + uint32_t audio_channels; /* 2 */ + uint32_t audio_sample_bytes; /* 2 (s16le) */ +} fc_stream_info_t; + +/** Fill *info from the slot header. Returns 0 on success, -1 on error. */ +int fc_consumer_info(fc_consumer_t *c, fc_stream_info_t *info); + #ifdef __cplusplus } #endif diff --git a/services/framecache/client/fc_pipe.c b/services/framecache/client/fc_pipe.c index 483b50d..f7fc945 100644 --- a/services/framecache/client/fc_pipe.c +++ b/services/framecache/client/fc_pipe.c @@ -1,54 +1,35 @@ /** - * fc_pipe.c — Framecache slot → stdout(video) + FIFO(audio) pipe adapter. + * fc_pipe.c — Framecache slot → stdout pipe adapter. * * FRAME-COUPLED AUDIO (FC_VERSION 2): * Each framecache ring entry carries the VIDEO frame AND that frame's - * SDI-embedded AUDIO together. fc_pipe reads ONE entry per loop iteration - * and writes: - * - the video bytes → stdout (ffmpeg rawvideo input 0 / pipe:0) - * - the audio bytes → audio FIFO (ffmpeg s16le input 1) - * IN LOCKSTEP from the SAME cursor read. Because both come out of the same - * ring entry in the same iteration, audio can never drift ahead of (or - * behind) its video frame — there is no second independent buffer/transport - * to race. This eliminates the constant "audio ahead of video" offset at the - * root. + * SDI-embedded AUDIO together (written by the JOINED bridge from one slot). + * fc_pipe reads ONE entry per loop iteration. * - * capture-manager.js spawns this process, pipes its stdout to ffmpeg input 0, - * and passes the audio FIFO path it created as argv[3]; ffmpeg reads that FIFO - * as input 1. + * TWO OUTPUT MODES: * - * Each consumer instance has its own independent read cursor, so multiple - * fc_pipe processes reading from the same slot never interfere with each other - * (growing + proxy + HLS all read the same SDI signal simultaneously). + * 1) AVI MODE (default when audio is wanted; selected with --avi or by giving + * an arg of "avi"): fc_pipe writes a SINGLE streaming AVI container to + * stdout — video and audio INTERLEAVED in one byte stream. ffmpeg reads it + * as ONE input: + * ffmpeg -f avi -i pipe:0 -map 0:v ... -map 0:a ... + * This eliminates the two-live-pipe deadlock: when ffmpeg was given a raw + * video pipe AND a separate audio FIFO it stalled forever probing input 0. + * The AVI muxer writes its header once, then for each ring entry emits a + * '00dc' video chunk followed by a '01wb' audio chunk — frame-coupled by + * construction (both come from the same ring entry in the same iteration). * - * Usage: - * fc_pipe [wait_ms] [audio_fifo_path] + * 2) RAW MODE (legacy, video-only): if no audio FIFO / avi flag is given, + * fc_pipe writes raw UYVY422 video bytes to stdout as before. * - * audio_fifo_path optional: if omitted (or "-"), audio is NOT emitted and - * fc_pipe behaves video-only (legacy / network video-only sources). + * The old split video-stdout / audio-FIFO design is REMOVED — it was the + * source of the ffmpeg deadlock. * - * Audio FIFO lifecycle: - * - Opened O_WRONLY|O_NONBLOCK with retry; until a reader (ffmpeg input 1) - * attaches the open returns ENXIO. We keep delivering VIDEO meanwhile so - * ffmpeg makes progress and opens the FIFO. Audio for those very first - * pre-roll frames is dropped (sub-frame startup gap inside pre-roll). - * - Once open we switch the fd to blocking and write each frame's audio with - * the same write_all() as video, keeping them coupled. If the signal has - * no embedded audio on a frame (audio_size 0) we synthesize exactly that - * frame's worth of silence so ffmpeg input 1 never starves and the audio - * timeline length always equals the video timeline length (no drift). - * - On audio FIFO EPIPE (ffmpeg input 1 reader died, e.g. session restart), - * we close and re-open the FIFO; VIDEO delivery is unaffected. + * Usage: fc_pipe [wait_ms] [mode] + * mode: "--avi" | "avi" → single streaming AVI (video+audio) on stdout. + * omitted | "-" → raw UYVY422 video-only on stdout. * - * Terminates on: - * - SIGTERM / SIGINT (clean stop from capture-manager) - * - stdout EPIPE (ffmpeg exited) - * - Slot disappears (bridge stopped) - * - * Exit codes: - * 0 clean stop (SIGTERM) - * 1 slot not found within wait_ms - * 2 stdout write error (EPIPE) + * Terminates on: SIGTERM/SIGINT, stdout EPIPE (ffmpeg exited), slot gone. */ #include "../src/slot.h" @@ -63,6 +44,7 @@ #include #include #include +#include static volatile int g_stop = 0; static void on_signal(int s) { (void)s; g_stop = 1; } @@ -75,31 +57,213 @@ static int write_all_fd(int fd, const void *buf, size_t len) { ssize_t n = write(fd, p + off, len - off); if (n > 0) { off += (size_t)n; continue; } if (n < 0 && errno == EINTR) continue; - return -1; /* EPIPE or other fatal error */ + return -1; + } + return 0; +} + +/* ── Little-endian byte emitters into a caller buffer ────────────────────────── */ +static inline void put_u16(uint8_t **pp, uint16_t v) { + uint8_t *p = *pp; p[0] = (uint8_t)(v & 0xff); p[1] = (uint8_t)((v >> 8) & 0xff); *pp = p + 2; +} +static inline void put_u32(uint8_t **pp, uint32_t v) { + uint8_t *p = *pp; + p[0] = (uint8_t)(v & 0xff); p[1] = (uint8_t)((v >> 8) & 0xff); + p[2] = (uint8_t)((v >> 16) & 0xff); p[3] = (uint8_t)((v >> 24) & 0xff); + *pp = p + 4; +} +static inline void put_fourcc(uint8_t **pp, const char *cc) { + uint8_t *p = *pp; p[0] = (uint8_t)cc[0]; p[1] = (uint8_t)cc[1]; + p[2] = (uint8_t)cc[2]; p[3] = (uint8_t)cc[3]; *pp = p + 4; +} + +/* ── Streaming AVI header ───────────────────────────────────────────────────── + * Builds RIFF('AVI ') + LIST('hdrl'){ avih + strl(vids) + strl(auds) } + + * LIST('movi'). For a streaming AVI over a pipe we cannot seek back to patch + * the RIFF and movi sizes, so we set them to 0x7FFFFFFF; ffmpeg's AVI demuxer + * reads the strf headers and the 00dc/01wb chunk stream regardless. The hdrl + * LIST size IS fixed/known, so it is written correctly. dwFlags is 0 — we do + * NOT set AVIF_HASINDEX / AVIF_MUSTUSEINDEX (there is no index in a stream). + * + * Writes the header to *out and returns its length. Buffer must be >= 512. */ +static size_t build_avi_header(uint8_t *out, + uint32_t width, uint32_t height, + uint32_t fps_num, uint32_t fps_den, + uint32_t video_bytes, + uint32_t audio_rate, uint32_t audio_channels, + uint32_t audio_sample_bytes) { + const uint32_t STREAMING = 0x7FFFFFFFu; + + const uint16_t bits_per_sample = (uint16_t)(audio_sample_bytes * 8u); + const uint16_t block_align = (uint16_t)(audio_channels * audio_sample_bytes); + const uint32_t avg_bytes_sec = audio_rate * block_align; + /* dwMicroSecPerFrame = 1e6 * fps_den / fps_num */ + const uint32_t usec_per_frame = + (uint32_t)((1000000.0 * (double)fps_den / (double)fps_num) + 0.5); + + /* Fixed sub-sizes (data bytes only, excluding the 8-byte ckID+ckSize). */ + const uint32_t AVIH_DATA = 56; /* MainAVIHeader */ + const uint32_t STRH_DATA = 56; /* AVISTREAMHEADER */ + const uint32_t BIH_DATA = 40; /* BITMAPINFOHEADER */ + const uint32_t WFX_DATA = 18; /* WAVEFORMATEX (cbSize=0) */ + + /* LIST('strl') sizes = 4 (the 'strl' fourcc) + contained chunks. */ + const uint32_t vstrl_size = 4 + (8 + STRH_DATA) + (8 + BIH_DATA); /* 4+64+48 = 116 */ + const uint32_t astrl_size = 4 + (8 + STRH_DATA) + (8 + WFX_DATA); /* 4+64+26 = 94 */ + /* LIST('hdrl') size = 4 (the 'hdrl' fourcc) + avih chunk + both strl LISTs. */ + const uint32_t hdrl_size = 4 + (8 + AVIH_DATA) + (8 + vstrl_size) + (8 + astrl_size); + + uint8_t *p = out; + + /* RIFF 'AVI ' (size unseekable → streaming sentinel) */ + put_fourcc(&p, "RIFF"); + put_u32(&p, STREAMING); + put_fourcc(&p, "AVI "); + + /* LIST 'hdrl' */ + put_fourcc(&p, "LIST"); + put_u32(&p, hdrl_size); + put_fourcc(&p, "hdrl"); + + /* avih — MainAVIHeader (56 bytes) */ + put_fourcc(&p, "avih"); + put_u32(&p, AVIH_DATA); + put_u32(&p, usec_per_frame); /* dwMicroSecPerFrame */ + put_u32(&p, 0); /* dwMaxBytesPerSec */ + put_u32(&p, 0); /* dwPaddingGranularity */ + put_u32(&p, 0); /* dwFlags — NO index flags */ + put_u32(&p, 0); /* dwTotalFrames (unknown in stream) */ + put_u32(&p, 0); /* dwInitialFrames */ + put_u32(&p, 2); /* dwStreams (video + audio) */ + put_u32(&p, 0); /* dwSuggestedBufferSize */ + put_u32(&p, width); /* dwWidth */ + put_u32(&p, height); /* dwHeight */ + put_u32(&p, 0); put_u32(&p, 0); put_u32(&p, 0); put_u32(&p, 0); /* dwReserved[4] */ + + /* LIST 'strl' — VIDEO */ + put_fourcc(&p, "LIST"); + put_u32(&p, vstrl_size); + put_fourcc(&p, "strl"); + + /* strh — AVISTREAMHEADER 'vids' (56 bytes) */ + put_fourcc(&p, "strh"); + put_u32(&p, STRH_DATA); + put_fourcc(&p, "vids"); /* fccType */ + put_fourcc(&p, "UYVY"); /* fccHandler */ + put_u32(&p, 0); /* dwFlags */ + put_u16(&p, 0); /* wPriority */ + put_u16(&p, 0); /* wLanguage */ + put_u32(&p, 0); /* dwInitialFrames */ + put_u32(&p, fps_den); /* dwScale = 1001 */ + put_u32(&p, fps_num); /* dwRate = 60000 */ + put_u32(&p, 0); /* dwStart */ + put_u32(&p, 0); /* dwLength (unknown) */ + put_u32(&p, video_bytes); /* dwSuggestedBufferSize */ + put_u32(&p, 0xFFFFFFFFu); /* dwQuality (-1 default) */ + put_u32(&p, video_bytes); /* dwSampleSize (fixed for uncompressed) */ + put_u16(&p, 0); put_u16(&p, 0); /* rcFrame.left, top */ + put_u16(&p, (uint16_t)width); /* rcFrame.right */ + put_u16(&p, (uint16_t)height); /* rcFrame.bottom */ + + /* strf — BITMAPINFOHEADER (40 bytes) */ + put_fourcc(&p, "strf"); + put_u32(&p, BIH_DATA); + put_u32(&p, 40); /* biSize */ + put_u32(&p, width); /* biWidth */ + put_u32(&p, height); /* biHeight */ + put_u16(&p, 1); /* biPlanes */ + put_u16(&p, 16); /* biBitCount (UYVY422 = 16bpp) */ + put_fourcc(&p, "UYVY"); /* biCompression fourcc */ + put_u32(&p, video_bytes); /* biSizeImage = W*H*2 */ + put_u32(&p, 0); /* biXPelsPerMeter */ + put_u32(&p, 0); /* biYPelsPerMeter */ + put_u32(&p, 0); /* biClrUsed */ + put_u32(&p, 0); /* biClrImportant */ + + /* LIST 'strl' — AUDIO */ + put_fourcc(&p, "LIST"); + put_u32(&p, astrl_size); + put_fourcc(&p, "strl"); + + /* strh — AVISTREAMHEADER 'auds' (56 bytes) */ + put_fourcc(&p, "strh"); + put_u32(&p, STRH_DATA); + put_fourcc(&p, "auds"); /* fccType */ + put_u32(&p, 0); /* fccHandler (none for PCM) */ + put_u32(&p, 0); /* dwFlags */ + put_u16(&p, 0); /* wPriority */ + put_u16(&p, 0); /* wLanguage */ + put_u32(&p, 0); /* dwInitialFrames */ + put_u32(&p, block_align); /* dwScale = nBlockAlign */ + put_u32(&p, avg_bytes_sec); /* dwRate = nAvgBytesPerSec */ + put_u32(&p, 0); /* dwStart */ + put_u32(&p, 0); /* dwLength (unknown) */ + put_u32(&p, avg_bytes_sec); /* dwSuggestedBufferSize (~1s) */ + put_u32(&p, 0xFFFFFFFFu); /* dwQuality */ + put_u32(&p, block_align); /* dwSampleSize = nBlockAlign */ + put_u16(&p, 0); put_u16(&p, 0); put_u16(&p, 0); put_u16(&p, 0); /* rcFrame */ + + /* strf — WAVEFORMATEX (18 bytes) */ + put_fourcc(&p, "strf"); + put_u32(&p, WFX_DATA); + put_u16(&p, 1); /* wFormatTag = WAVE_FORMAT_PCM */ + put_u16(&p, (uint16_t)audio_channels); /* nChannels */ + put_u32(&p, audio_rate); /* nSamplesPerSec */ + put_u32(&p, avg_bytes_sec); /* nAvgBytesPerSec */ + put_u16(&p, block_align); /* nBlockAlign */ + put_u16(&p, bits_per_sample); /* wBitsPerSample */ + put_u16(&p, 0); /* cbSize */ + + /* LIST 'movi' — frames follow. Size unseekable → streaming sentinel. */ + put_fourcc(&p, "LIST"); + put_u32(&p, STREAMING); + put_fourcc(&p, "movi"); + + return (size_t)(p - out); +} + +/* Write a single AVI chunk: 4-byte fourcc + u32 LE size + data (+ pad byte if + * the size is odd, per the RIFF even-alignment rule). Returns 0 / -1. */ +static int write_avi_chunk(int fd, const char *cc, + const uint8_t *data, uint32_t size) { + uint8_t hdr[8]; + uint8_t *p = hdr; + put_fourcc(&p, cc); + put_u32(&p, size); + if (write_all_fd(fd, hdr, 8) < 0) return -1; + if (size && write_all_fd(fd, data, size) < 0) return -1; + if (size & 1u) { + uint8_t pad = 0; + if (write_all_fd(fd, &pad, 1) < 0) return -1; } return 0; } int main(int argc, char *argv[]) { if (argc < 2) { - fprintf(stderr, "Usage: %s [wait_ms] [audio_fifo_path]\n", argv[0]); + fprintf(stderr, "Usage: %s [wait_ms] [--avi|-]\n", argv[0]); return 1; } - const char *slot_id = argv[1]; - uint64_t wait_ms = argc >= 3 ? (uint64_t)atoll(argv[2]) : 30000; - const char *audio_fifo = (argc >= 4 && strcmp(argv[3], "-") != 0) ? argv[3] : NULL; + const char *slot_id = argv[1]; + uint64_t wait_ms = argc >= 3 ? (uint64_t)atoll(argv[2]) : 30000; + + /* AVI mode is selected by an explicit flag in argv[3]. Anything that is not + * "--avi"/"avi" (including "-" or omitted) → legacy raw video-only mode. */ + int avi_mode = 0; + if (argc >= 4) { + const char *m = argv[3]; + if (strcmp(m, "--avi") == 0 || strcmp(m, "avi") == 0) avi_mode = 1; + } signal(SIGTERM, on_signal); signal(SIGINT, on_signal); - signal(SIGPIPE, SIG_IGN); /* detect EPIPE via write() return value */ + signal(SIGPIPE, SIG_IGN); - /* Set stdout to binary/blocking mode — no newline translation */ fcntl(STDOUT_FILENO, F_SETFL, fcntl(STDOUT_FILENO, F_GETFL, 0) & ~O_NONBLOCK); - fprintf(stderr, "[fc_pipe] opening slot '%s' (wait %llums) audio_fifo=%s\n", - slot_id, (unsigned long long)wait_ms, - audio_fifo ? audio_fifo : "(none)"); + fprintf(stderr, "[fc_pipe] opening slot '%s' (wait %llums) mode=%s\n", + slot_id, (unsigned long long)wait_ms, avi_mode ? "avi" : "rawvideo"); fc_consumer_t *c = fc_consumer_open(slot_id, wait_ms); if (!c) { @@ -108,80 +272,115 @@ int main(int argc, char *argv[]) { return 1; } - fprintf(stderr, "[fc_pipe] slot open, streaming video→stdout%s\n", - audio_fifo ? " + audio→FIFO (frame-coupled)" : ""); + /* Pull stream format from the slot header for the AVI header. */ + fc_stream_info_t si; + if (fc_consumer_info(c, &si) != 0 || si.width == 0 || si.height == 0) { + fprintf(stderr, "[fc_pipe] failed to read slot stream info\n"); + fc_consumer_close(c); + return 1; + } + if (si.fps_num == 0) { si.fps_num = 60000; si.fps_den = 1001; } + if (si.fps_den == 0) si.fps_den = 1; + if (si.audio_rate == 0) si.audio_rate = 48000; + if (si.audio_channels == 0) si.audio_channels = 2; + if (si.audio_sample_bytes == 0) si.audio_sample_bytes = 2; - int afd = -1; /* audio FIFO fd, -1 until a reader attaches */ - int logged_aud = 0; + const uint32_t video_bytes = si.frame_size ? si.frame_size + : si.width * si.height * 2u; + const uint32_t a_blockalign = si.audio_channels * si.audio_sample_bytes; + /* Samples per video frame for synthesized silence when a frame has no audio: + * round(audio_rate * fps_den / fps_num). Bytes = samples * blockalign. */ + uint32_t silence_bytes = 0; + { + double spf = (double)si.audio_rate * (double)si.fps_den / (double)si.fps_num; + uint32_t samples = (uint32_t)(spf + 0.5); + silence_bytes = samples * a_blockalign; + } + uint8_t *silence = NULL; + if (avi_mode && silence_bytes) { + silence = (uint8_t *)calloc(1, silence_bytes); + if (!silence) silence_bytes = 0; + } + + if (avi_mode) { + uint8_t hdr[512]; + size_t hlen = build_avi_header(hdr, si.width, si.height, + si.fps_num, si.fps_den, video_bytes, + si.audio_rate, si.audio_channels, + si.audio_sample_bytes); + if (write_all_fd(STDOUT_FILENO, hdr, hlen) < 0) { + fprintf(stderr, "[fc_pipe] stdout EPIPE writing AVI header\n"); + fc_consumer_close(c); free(silence); + return 1; + } + fprintf(stderr, + "[fc_pipe] slot open, streaming AVI(video+audio) → stdout " + "(%ux%u %u/%u, %ub/frame, audio %uHz %uch s%ule, silence=%uB/frame)\n", + si.width, si.height, si.fps_num, si.fps_den, video_bytes, + si.audio_rate, si.audio_channels, si.audio_sample_bytes * 8u, + silence_bytes); + } else { + fprintf(stderr, "[fc_pipe] slot open, streaming raw video → stdout (%ux%u)\n", + si.width, si.height); + } uint64_t frames_out = 0; uint64_t total_dropped = 0; uint64_t audio_bytes = 0; - uint64_t audio_gaps = 0; /* frames where embedded audio was absent (silence filled) */ + uint64_t audio_gaps = 0; while (!g_stop) { fc_frame_ref_t ref; - int rc = fc_consumer_read(c, &ref, 2000 /* 2s timeout */); - + int rc = fc_consumer_read(c, &ref, 2000); if (rc == FC_TIMEOUT) continue; if (rc == FC_ERROR) break; - if (rc == FC_LAPPED) { total_dropped = fc_consumer_dropped(c); fprintf(stderr, "[fc_pipe] WARNING: frame lapped mid-read — total dropped: %llu\n", (unsigned long long)total_dropped); continue; } - if (rc == FC_DROPPED) { total_dropped = fc_consumer_dropped(c); fprintf(stderr, "[fc_pipe] WARNING: consumer fell behind — total dropped: %llu\n", (unsigned long long)total_dropped); } - /* ── Try to (re)attach the audio FIFO without stalling video ────────── - * O_WRONLY|O_NONBLOCK returns ENXIO until ffmpeg input 1 opens the read - * end. We keep delivering video so ffmpeg progresses and opens it. */ - if (audio_fifo && afd < 0) { - int fd = open(audio_fifo, O_WRONLY | O_NONBLOCK); - if (fd >= 0) { - /* Switch to blocking for coupled writes. */ - int fl = fcntl(fd, F_GETFL, 0); - if (fl >= 0) fcntl(fd, F_SETFL, fl & ~O_NONBLOCK); - afd = fd; - if (!logged_aud) { - fprintf(stderr, "[fc_pipe] audio FIFO reader attached — coupled audio live\n"); - logged_aud = 1; - } + if (avi_mode) { + /* Interleave THIS frame's video + audio in one stream. Both are + * sourced from the SAME ring entry ⇒ frame-coupled by construction. + * Video first (00dc), then audio (01wb). */ + if (write_avi_chunk(STDOUT_FILENO, "00dc", ref.data, ref.size) < 0) { + if (!g_stop) + fprintf(stderr, "[fc_pipe] stdout EPIPE (video) — ffmpeg exited\n"); + break; } - /* else: not ready yet (ENXIO) — deliver video, retry next frame. */ - } - - /* ── Write VIDEO to stdout ──────────────────────────────────────────── */ - if (write_all_fd(STDOUT_FILENO, ref.data, ref.size) < 0) { - if (!g_stop) - fprintf(stderr, "[fc_pipe] stdout EPIPE — ffmpeg exited\n"); - break; - } - - /* ── Write THIS frame's AUDIO to the FIFO, in lockstep ──────────────── - * Same ring entry, same iteration ⇒ frame-coupled. The bridge writer - * guarantees every entry carries one frame-interval of audio (real - * embedded PCM, or silence when the signal has none), so ref.audio_size - * is non-zero in steady state and the audio timeline length always - * tracks the video timeline length (no drift). A 0-size entry (only at - * the very first frame, or a video-only net source) contributes nothing - * and is harmless because ffmpeg derives audio PTS from sample count. */ - if (afd >= 0) { if (ref.audio_size > 0 && ref.audio) { - if (write_all_fd(afd, ref.audio, ref.audio_size) < 0) { - fprintf(stderr, "[fc_pipe] audio FIFO EPIPE — will reattach\n"); - close(afd); afd = -1; - } else { - audio_bytes += ref.audio_size; + if (write_avi_chunk(STDOUT_FILENO, "01wb", ref.audio, ref.audio_size) < 0) { + if (!g_stop) + fprintf(stderr, "[fc_pipe] stdout EPIPE (audio) — ffmpeg exited\n"); + break; } + audio_bytes += ref.audio_size; } else { - audio_gaps++; /* diagnostics: frame without embedded audio */ + /* No embedded audio this frame: emit one frame-interval of + * silence so the audio stream length tracks the video and + * ffmpeg never starves on the audio demuxer. */ + if (silence_bytes && + write_avi_chunk(STDOUT_FILENO, "01wb", silence, silence_bytes) < 0) { + if (!g_stop) + fprintf(stderr, "[fc_pipe] stdout EPIPE (silence) — ffmpeg exited\n"); + break; + } + audio_bytes += silence_bytes; + audio_gaps++; + } + } else { + /* Legacy raw video-only: write the UYVY422 bytes straight to stdout. */ + if (write_all_fd(STDOUT_FILENO, ref.data, ref.size) < 0) { + if (!g_stop) + fprintf(stderr, "[fc_pipe] stdout EPIPE — ffmpeg exited\n"); + break; } } @@ -195,7 +394,7 @@ int main(int argc, char *argv[]) { } } - if (afd >= 0) close(afd); + free(silence); fc_consumer_close(c); fprintf(stderr, "[fc_pipe] done frames=%llu dropped=%llu audio_bytes=%llu gaps=%llu\n", (unsigned long long)frames_out,