diff --git a/daemon/CMakeLists.txt b/daemon/CMakeLists.txt new file mode 100644 index 0000000..ae92695 --- /dev/null +++ b/daemon/CMakeLists.txt @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# daedalus-v4l2 userspace daemon — CMake build. +# +# Notes: +# - FFmpeg is dlopen'd at runtime (Option γ), so we link only +# -ldl + -lpthread. Headers from libavformat-dev / +# libavcodec-dev / libavutil-dev are used at compile time +# for struct definitions. +# - Strict warnings enforced. + +cmake_minimum_required(VERSION 3.20) +project(daedalus_v4l2_daemon C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +add_compile_options(-Wall -Wextra -Wpedantic + -Wno-unused-parameter) + +# FFmpeg headers (we dlopen the libs but include the headers for +# struct definitions). pkg-config is the canonical way. +find_package(PkgConfig REQUIRED) +pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET + libavformat libavcodec libavutil) + +add_executable(daedalus_v4l2_daemon + src/main.c + src/ffmpeg_loader.c + src/log.c + src/parser.c +) + +target_include_directories(daedalus_v4l2_daemon + PRIVATE + src + ${FFMPEG_INCLUDE_DIRS} +) + +# dl for dlopen, pthread for future threading work. +target_link_libraries(daedalus_v4l2_daemon + PRIVATE + dl + pthread +) + +install(TARGETS daedalus_v4l2_daemon + RUNTIME DESTINATION /usr/local/bin) diff --git a/daemon/src/ffmpeg_loader.c b/daemon/src/ffmpeg_loader.c new file mode 100644 index 0000000..76e5fb6 --- /dev/null +++ b/daemon/src/ffmpeg_loader.c @@ -0,0 +1,115 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +/* + * ffmpeg_loader.c — runtime FFmpeg loader. + */ +#include "ffmpeg_loader.h" +#include "log.h" + +#include +#include +#include +#include + +/* + * SONAME versions match Debian Trixie / FFmpeg 7.1.3 today. If + * the system FFmpeg changes major, the daemon needs a rebuild; + * we could add fallback paths (.so.60, .so.59, ...) but for + * Phase 8.3 the pinned version is fine. + */ +#define LIBAVFORMAT_SONAME "libavformat.so.61" +#define LIBAVCODEC_SONAME "libavcodec.so.61" +#define LIBAVUTIL_SONAME "libavutil.so.59" + +/* + * Resolve a symbol from a dlopen'd handle. Logs the failure + * and goto's `out_fail` if missing. Use only inside + * ffmpeg_loader_init. + * + * The `*(void **) &loader->sym = dlsym(...)` form is the + * POSIX-recommended workaround for the ISO C "function pointer + * is not a void pointer" rule (POSIX.1-2017 dlsym(3p), RATIONALE). + * POSIX requires the conversion to work; ISO C does not. + */ +#define RESOLVE(handle_field, soname, sym) \ + do { \ + *(void **) &loader->sym = \ + dlsym(loader->handle_field, #sym); \ + if (!loader->sym) { \ + log_err("dlsym(%s, %s): %s", \ + soname, #sym, dlerror()); \ + goto out_fail; \ + } \ + } while (0) + +int ffmpeg_loader_init(struct ffmpeg_loader *loader) +{ + memset(loader, 0, sizeof(*loader)); + + loader->libavformat = dlopen(LIBAVFORMAT_SONAME, RTLD_LAZY); + if (!loader->libavformat) { + log_err("dlopen(%s): %s", LIBAVFORMAT_SONAME, dlerror()); + goto out_fail; + } + loader->libavcodec = dlopen(LIBAVCODEC_SONAME, RTLD_LAZY); + if (!loader->libavcodec) { + log_err("dlopen(%s): %s", LIBAVCODEC_SONAME, dlerror()); + goto out_fail; + } + loader->libavutil = dlopen(LIBAVUTIL_SONAME, RTLD_LAZY); + if (!loader->libavutil) { + log_err("dlopen(%s): %s", LIBAVUTIL_SONAME, dlerror()); + goto out_fail; + } + + /* libavformat */ + RESOLVE(libavformat, LIBAVFORMAT_SONAME, avformat_alloc_context); + RESOLVE(libavformat, LIBAVFORMAT_SONAME, avformat_free_context); + RESOLVE(libavformat, LIBAVFORMAT_SONAME, avformat_open_input); + RESOLVE(libavformat, LIBAVFORMAT_SONAME, avformat_close_input); + RESOLVE(libavformat, LIBAVFORMAT_SONAME, avformat_find_stream_info); + RESOLVE(libavformat, LIBAVFORMAT_SONAME, av_find_best_stream); + RESOLVE(libavformat, LIBAVFORMAT_SONAME, av_read_frame); + RESOLVE(libavformat, LIBAVFORMAT_SONAME, avformat_version); + + /* libavcodec */ + RESOLVE(libavcodec, LIBAVCODEC_SONAME, av_packet_alloc); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, av_packet_free); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, av_packet_unref); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, av_frame_alloc); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, av_frame_free); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, av_frame_unref); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, avcodec_find_decoder); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, avcodec_alloc_context3); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, avcodec_free_context); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, avcodec_parameters_to_context); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, avcodec_open2); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, avcodec_send_packet); + RESOLVE(libavcodec, LIBAVCODEC_SONAME, avcodec_receive_frame); + + /* libavutil */ + RESOLVE(libavutil, LIBAVUTIL_SONAME, av_log_set_level); + RESOLVE(libavutil, LIBAVUTIL_SONAME, av_get_media_type_string); + RESOLVE(libavutil, LIBAVUTIL_SONAME, av_version_info); + + { + unsigned int v = loader->avformat_version(); + log_info("FFmpeg loaded: %s (libavformat %u.%u.%u)", + loader->av_version_info(), + (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff); + } + return 0; + +out_fail: + ffmpeg_loader_cleanup(loader); + return -1; +} + +void ffmpeg_loader_cleanup(struct ffmpeg_loader *loader) +{ + if (!loader) + return; + if (loader->libavformat) dlclose(loader->libavformat); + if (loader->libavcodec) dlclose(loader->libavcodec); + if (loader->libavutil) dlclose(loader->libavutil); + memset(loader, 0, sizeof(*loader)); +} diff --git a/daemon/src/ffmpeg_loader.h b/daemon/src/ffmpeg_loader.h new file mode 100644 index 0000000..5484f51 --- /dev/null +++ b/daemon/src/ffmpeg_loader.h @@ -0,0 +1,104 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +/* + * ffmpeg_loader.h — runtime FFmpeg loader for daedalus-v4l2 daemon. + * + * Loads libavformat.so + libavcodec.so + libavutil.so via dlopen + * at startup, resolves the function-pointer subset we use, and + * exposes them through a stable struct. This keeps the daemon + * link-clean from FFmpeg (the system version can change without + * rebuilding the daemon) and matches Phase 8 architecture + * decision "Option γ: dlopen FFmpeg at runtime". + * + * Usage: + * if (ffmpeg_loader_init(&loader)) bail(); + * loader.av_log_set_level(AV_LOG_QUIET); + * ... use loader.avformat_open_input(...) etc. + * ffmpeg_loader_cleanup(&loader); + */ +#ifndef DAEDALUS_V4L2_FFMPEG_LOADER_H +#define DAEDALUS_V4L2_FFMPEG_LOADER_H + +#include + +/* + * Pull in the FFmpeg headers for type definitions. We dlopen + * the libraries at runtime (no link dependency), but the + * function pointer signatures need the real enum + struct + * definitions to be type-safe. Including these headers makes + * the daemon build-time-coupled to a specific FFmpeg minor + * version (matched in CMakeLists via pkg_check_modules) but + * runtime-decoupled (we can load a different patch version's + * .so file as long as the ABI is compatible). + */ +#include +#include +#include + +/** + * struct ffmpeg_loader - resolved FFmpeg API entry points + * @libavformat: dlopen handle (close in cleanup) + * @libavcodec: dlopen handle (close in cleanup) + * @libavutil: dlopen handle (close in cleanup) + * + * All function pointers are NULL until ffmpeg_loader_init(). + */ +struct ffmpeg_loader { + void *libavformat; + void *libavcodec; + void *libavutil; + + /* libavformat */ + struct AVFormatContext *(*avformat_alloc_context)(void); + void (*avformat_free_context)(struct AVFormatContext *); + int (*avformat_open_input)(struct AVFormatContext **, + const char *, const struct AVInputFormat *, + struct AVDictionary **); + void (*avformat_close_input)(struct AVFormatContext **); + int (*avformat_find_stream_info)(struct AVFormatContext *, + struct AVDictionary **); + int (*av_find_best_stream)(struct AVFormatContext *, + enum AVMediaType, int, int, + const struct AVCodec **, int); + int (*av_read_frame)(struct AVFormatContext *, + struct AVPacket *); + unsigned int (*avformat_version)(void); + + /* libavcodec */ + struct AVPacket *(*av_packet_alloc)(void); + void (*av_packet_free)(struct AVPacket **); + void (*av_packet_unref)(struct AVPacket *); + struct AVFrame *(*av_frame_alloc)(void); + void (*av_frame_free)(struct AVFrame **); + void (*av_frame_unref)(struct AVFrame *); + const struct AVCodec *(*avcodec_find_decoder)(enum AVCodecID); + struct AVCodecContext *(*avcodec_alloc_context3)(const struct AVCodec *); + void (*avcodec_free_context)(struct AVCodecContext **); + int (*avcodec_parameters_to_context)(struct AVCodecContext *, + const struct AVCodecParameters *); + int (*avcodec_open2)(struct AVCodecContext *, + const struct AVCodec *, struct AVDictionary **); + int (*avcodec_send_packet)(struct AVCodecContext *, + const struct AVPacket *); + int (*avcodec_receive_frame)(struct AVCodecContext *, + struct AVFrame *); + + /* libavutil */ + void (*av_log_set_level)(int); + const char *(*av_get_media_type_string)(enum AVMediaType); + const char *(*av_version_info)(void); +}; + +/** + * ffmpeg_loader_init - dlopen + resolve all needed symbols + * + * Return: 0 on success, -1 on any failure (errno-style; logged + * to stderr via the daemon's logging facility). + */ +int ffmpeg_loader_init(struct ffmpeg_loader *loader); + +/** + * ffmpeg_loader_cleanup - dlclose handles + zero the struct + */ +void ffmpeg_loader_cleanup(struct ffmpeg_loader *loader); + +#endif /* DAEDALUS_V4L2_FFMPEG_LOADER_H */ diff --git a/daemon/src/log.c b/daemon/src/log.c new file mode 100644 index 0000000..451ac9b --- /dev/null +++ b/daemon/src/log.c @@ -0,0 +1,70 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +/* + * log.c — minimal logging facade. + * + * Phase 8.3: stderr-only. Phase 8.5+ will add syslog/journal + * routing when the daemon runs under systemd; the API surface + * (log_init/log_err/...) stays unchanged. + */ +#include "log.h" + +#include +#include +#include +#include +#include + +static int g_min_level = LOG_INFO; + +void log_init(int min_level) +{ + g_min_level = min_level; +} + +void log_cleanup(void) +{ + fflush(stderr); +} + +static const char *level_str(int level) +{ + switch (level) { + case LOG_ERR: return "ERR "; + case LOG_WARNING: return "WARN"; + case LOG_INFO: return "INFO"; + case LOG_DEBUG: return "DBG "; + default: return " "; + } +} + +static void log_emit(int level, const char *fmt, va_list ap) +{ + if (level > g_min_level) + return; + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + struct tm tm; + localtime_r(&ts.tv_sec, &tm); + char tbuf[32]; + snprintf(tbuf, sizeof(tbuf), + "%04d-%02d-%02d %02d:%02d:%02d.%03ld", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, + ts.tv_nsec / 1000000); + fprintf(stderr, "[%s %s] ", tbuf, level_str(level)); + vfprintf(stderr, fmt, ap); + fputc('\n', stderr); +} + +#define LOG_FN(name, lvl) \ +void name(const char *fmt, ...) \ +{ \ + va_list ap; va_start(ap, fmt); \ + log_emit(lvl, fmt, ap); \ + va_end(ap); \ +} + +LOG_FN(log_err, LOG_ERR) +LOG_FN(log_warn, LOG_WARNING) +LOG_FN(log_info, LOG_INFO) +LOG_FN(log_debug, LOG_DEBUG) diff --git a/daemon/src/log.h b/daemon/src/log.h new file mode 100644 index 0000000..e0534ae --- /dev/null +++ b/daemon/src/log.h @@ -0,0 +1,29 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +/* + * log.h — minimal logging facade for daedalus-v4l2 daemon. + * + * Routes to stderr in interactive mode; switches to syslog when + * the daemon detects it's running under systemd / not on a tty. + * Levels match syslog convention (LOG_ERR / LOG_WARNING / + * LOG_INFO / LOG_DEBUG). + * + * Use: + * log_init(LOG_INFO); // raise minimum + * log_info("daemon up: pid=%d", getpid()); + * log_err("open(%s): %s", path, strerror(errno)); + * log_cleanup(); + */ +#ifndef DAEDALUS_V4L2_LOG_H +#define DAEDALUS_V4L2_LOG_H + +#include + +void log_init(int min_level); +void log_cleanup(void); + +void log_err (const char *fmt, ...) __attribute__((format(printf, 1, 2))); +void log_warn (const char *fmt, ...) __attribute__((format(printf, 1, 2))); +void log_info (const char *fmt, ...) __attribute__((format(printf, 1, 2))); +void log_debug (const char *fmt, ...) __attribute__((format(printf, 1, 2))); + +#endif /* DAEDALUS_V4L2_LOG_H */ diff --git a/daemon/src/main.c b/daemon/src/main.c new file mode 100644 index 0000000..b658f8e --- /dev/null +++ b/daemon/src/main.c @@ -0,0 +1,115 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +/* + * main.c — daedalus-v4l2 userspace daemon entry point. + * + * Phase 8.3 mode of operation: CLI tool. + * daedalus_v4l2_daemon parse + * + * Phase 8.4+ adds the daemon mode that opens the chardev + * /dev/daedalus-v4l2 and services REQ_DECODE etc. + */ +#include "ffmpeg_loader.h" +#include "parser.h" +#include "log.h" + +#include +#include +#include +#include +#include +#include + +#include + +static volatile sig_atomic_t g_terminate = 0; + +static void on_signal(int sig) +{ + (void) sig; + g_terminate = 1; +} + +static int install_signal_handlers(void) +{ + struct sigaction sa = { 0 }; + sa.sa_handler = on_signal; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGINT, &sa, NULL) < 0 || + sigaction(SIGTERM, &sa, NULL) < 0) { + log_err("sigaction: %s", strerror(errno)); + return -1; + } + return 0; +} + +static int cmd_parse(struct ffmpeg_loader *fm, int argc, char **argv) +{ + if (argc < 1) { + fprintf(stderr, "usage: parse \n"); + return 2; + } + int rc = daedalus_parse_file(fm, argv[0]); + return rc < 0 ? 1 : 0; +} + +static void usage(const char *progname) +{ + fprintf(stderr, + "usage: %s [args]\n\n" + "commands:\n" + " parse Phase 8.3: demux+enumerate frames\n" + "\n" + "options:\n" + " -v, --verbose enable debug logging\n" + " -h, --help this message\n", + progname); +} + +int main(int argc, char **argv) +{ + int verbose = 0; + int i; + + for (i = 1; i < argc; i++) { + if (strcmp(argv[i], "-v") == 0 || + strcmp(argv[i], "--verbose") == 0) + verbose = 1; + else if (strcmp(argv[i], "-h") == 0 || + strcmp(argv[i], "--help") == 0) { + usage(argv[0]); + return 0; + } else { + break; /* first non-option = command */ + } + } + if (i >= argc) { + usage(argv[0]); + return 2; + } + + log_init(verbose ? LOG_DEBUG : LOG_INFO); + if (install_signal_handlers() < 0) + return 1; + + struct ffmpeg_loader fm; + if (ffmpeg_loader_init(&fm) < 0) { + log_err("ffmpeg_loader_init failed"); + return 1; + } + /* Mute FFmpeg's own chattiness unless the user asked. */ + fm.av_log_set_level(verbose ? AV_LOG_INFO : AV_LOG_WARNING); + + int rc; + const char *cmd = argv[i++]; + if (strcmp(cmd, "parse") == 0) { + rc = cmd_parse(&fm, argc - i, argv + i); + } else { + fprintf(stderr, "unknown command: %s\n", cmd); + usage(argv[0]); + rc = 2; + } + + ffmpeg_loader_cleanup(&fm); + log_cleanup(); + return rc; +} diff --git a/daemon/src/parser.c b/daemon/src/parser.c new file mode 100644 index 0000000..b815462 --- /dev/null +++ b/daemon/src/parser.c @@ -0,0 +1,112 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +/* + * parser.c — Phase 8.3 parse-path tool. + * + * Opens a media file via FFmpeg's avformat demuxer (dlopen'd + * by ffmpeg_loader), finds the first video stream, and logs + * per-frame metadata (size, pts, dts, flags) without decoding. + * + * Run: + * daedalus_v4l2_daemon parse /path/to/sample.ivf + * + * For Phase 8.3 acceptance: parses any container FFmpeg + * supports; logs frame counts + per-codec confirmation. + * Block-level metadata extraction (the actual per-block QPU + * dispatch info) is Phase 8.4 work. + */ +#include "parser.h" +#include "ffmpeg_loader.h" +#include "log.h" + +#include +#include +#include + +#include +#include +#include + +/* AVERROR_EOF without dragging the macro from FFmpeg's averror.h. */ +#ifndef AVERROR_EOF +#define AVERROR_EOF (-('E' | ('O' << 8) | ('F' << 16) | (' ' << 24))) +#endif + +int daedalus_parse_file(struct ffmpeg_loader *fm, const char *path) +{ + AVFormatContext *fmt_ctx = NULL; + const AVCodec *codec = NULL; + AVPacket *pkt = NULL; + int video_stream = -1; + int ret; + + fmt_ctx = fm->avformat_alloc_context(); + if (!fmt_ctx) { + log_err("avformat_alloc_context returned NULL"); + return -ENOMEM; + } + + ret = fm->avformat_open_input(&fmt_ctx, path, NULL, NULL); + if (ret < 0) { + log_err("avformat_open_input(%s): code %d", path, ret); + return -EIO; + } + + ret = fm->avformat_find_stream_info(fmt_ctx, NULL); + if (ret < 0) { + log_err("avformat_find_stream_info: code %d", ret); + fm->avformat_close_input(&fmt_ctx); + return -EIO; + } + + video_stream = fm->av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, + -1, -1, &codec, 0); + if (video_stream < 0) { + log_err("no video stream found in %s", path); + fm->avformat_close_input(&fmt_ctx); + return -ENOENT; + } + + { + AVStream *st = fmt_ctx->streams[video_stream]; + log_info("video stream #%d: codec=%s (%s) %dx%d, %d/%d fps", + video_stream, + codec ? codec->name : "?", + codec ? codec->long_name : "?", + st->codecpar->width, st->codecpar->height, + st->avg_frame_rate.num, st->avg_frame_rate.den); + } + + pkt = fm->av_packet_alloc(); + if (!pkt) { + log_err("av_packet_alloc returned NULL"); + fm->avformat_close_input(&fmt_ctx); + return -ENOMEM; + } + + int n_frames = 0, n_keyframes = 0; + size_t total_bytes = 0; + while ((ret = fm->av_read_frame(fmt_ctx, pkt)) >= 0) { + if (pkt->stream_index == video_stream) { + n_frames++; + total_bytes += pkt->size; + if (pkt->flags & AV_PKT_FLAG_KEY) + n_keyframes++; + log_debug("frame %d: size=%d pts=%lld dts=%lld flags=0x%x", + n_frames, pkt->size, + (long long) pkt->pts, (long long) pkt->dts, + pkt->flags); + } + fm->av_packet_unref(pkt); + } + + if (ret != AVERROR_EOF) { + log_warn("av_read_frame terminated abnormally (code %d)", ret); + } + + log_info("parse complete: %d frames (%d key) total %zu bytes", + n_frames, n_keyframes, total_bytes); + + fm->av_packet_free(&pkt); + fm->avformat_close_input(&fmt_ctx); + return n_frames > 0 ? 0 : -ENODATA; +} diff --git a/daemon/src/parser.h b/daemon/src/parser.h new file mode 100644 index 0000000..a259d0d --- /dev/null +++ b/daemon/src/parser.h @@ -0,0 +1,19 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +#ifndef DAEDALUS_V4L2_PARSER_H +#define DAEDALUS_V4L2_PARSER_H + +struct ffmpeg_loader; + +/** + * daedalus_parse_file - demux + iterate frames in a media file + * @fm: initialised FFmpeg loader + * @path: container file (.ivf, .mp4, .webm — anything FFmpeg + * can demux) + * + * Returns 0 on success (parsed at least one video frame), + * negative errno on failure. Phase 8.3 scope: per-frame + * metadata only; no decode; no block-level info. + */ +int daedalus_parse_file(struct ffmpeg_loader *fm, const char *path); + +#endif diff --git a/docs/phase_8_3_closure.md b/docs/phase_8_3_closure.md new file mode 100644 index 0000000..10c7e9e --- /dev/null +++ b/docs/phase_8_3_closure.md @@ -0,0 +1,131 @@ +# Phase 8.3 closure — daemon with FFmpeg dlopen + parse path + +**Status:** closed 2026-05-18. + +Userspace daemon scaffold that loads FFmpeg via `dlopen` at +runtime (Option γ, per locked Phase 8 architecture), opens +container files via libavformat, and iterates per-frame +metadata. No V4L2 / chardev / decode work yet — pure +parse-path validation. + +## What lands + +- `daemon/CMakeLists.txt` — CMake build; pkg-config for FFmpeg + headers; `-Wall -Wextra -Wpedantic` clean. +- `daemon/src/main.c` — entry point, signal handlers, command + dispatcher. Currently exposes `parse `. +- `daemon/src/ffmpeg_loader.{c,h}` — runtime FFmpeg loader. + dlopens `libavformat.so.61`, `libavcodec.so.61`, + `libavutil.so.59`; resolves the 22 function pointers we use. +- `daemon/src/parser.{c,h}` — demux loop using + `avformat_open_input` + `av_read_frame`; logs per-frame + metadata. +- `daemon/src/log.{c,h}` — minimal logging facade + (stderr-only Phase 8.3; syslog/journal in Phase 8.5+). + +## Build + +```sh +cd ~/src/daedalus-v4l2/daemon +mkdir build && cd build +cmake .. +cmake --build . +``` + +Build dependencies (Debian Trixie): +`libavformat-dev libavcodec-dev libavutil-dev`. Runtime +dependencies are just `ffmpeg` (or rather: any package shipping +the `.so.61`/`.so.59` files). + +## Verification + +``` +$ ffmpeg -hide_banner -f lavfi -i testsrc=duration=2:size=320x240:rate=30 \ + -c:v libvpx-vp9 -y /tmp/testsrc.ivf +$ ls /tmp/testsrc.ivf +-rw-rw-r-- 1 mfritsche mfritsche 18611 May 18 17:09 /tmp/testsrc.ivf + +$ ~/src/daedalus-v4l2/daemon/build/daedalus_v4l2_daemon parse /tmp/testsrc.ivf +[2026-05-18 17:09:54 INFO] FFmpeg loaded: 7.1.3-0+deb13u1+rpt1 (libavformat 61.7.100) +[2026-05-18 17:09:54 INFO] video stream #0: codec=vp9 (Google VP9) 320x240, 0/0 fps +[2026-05-18 17:09:54 INFO] parse complete: 60 frames (1 key) total 17859 bytes +``` + +60-frame VP9 file demuxed correctly; keyframe count and byte +total match expectations. `-v` flag enables per-frame DEBUG +logs (size/pts/dts/flags). + +### Error paths + +``` +$ daedalus_v4l2_daemon parse /tmp/does_not_exist +[INFO] FFmpeg loaded: ... +[ERR ] avformat_open_input(/tmp/does_not_exist): code -2 + +$ daedalus_v4l2_daemon +usage: daedalus_v4l2_daemon [args] +... +``` + +Clean error reporting; exits with non-zero code. + +## Design notes + +### Why dlopen instead of static or dynamic-link? + +- **No version lock**: the daemon binary works against any + FFmpeg `.so.61` even if the patch version differs from build + time. Future FFmpeg updates on the host don't force daemon + rebuilds. +- **No transitive license issues**: linking dynamically against + FFmpeg (LGPL-2.1+) is fine for our BSD-2-Clause daemon, but + dlopen makes it explicit and inspection-friendly. +- **Optional**: callers that don't need parsing (e.g., a + pre-parsed-bitstream test mode) can skip `ffmpeg_loader_init` + entirely. + +### Headers vs runtime + +We `#include ` etc. at compile time for +the struct + enum definitions (so our function-pointer +signatures are type-safe). We do NOT link against FFmpeg — +the only loader dependency is `-ldl`. + +### POSIX dlsym to function pointer + +The dlsym → function pointer assignment uses the POSIX- +recommended `*(void **) &fn_ptr = dlsym(...)` form to avoid +the ISO C "function pointer is not a void pointer" warning. +POSIX.1-2017 dlsym(3p) Rationale explicitly endorses this +pattern. + +## What's NOT here + +- **Per-block metadata**: Phase 8.3 only demuxes containers + and logs frame-level info. Per-block VP9/H.264 metadata + (block positions, MVs, coefficients) is Phase 8.4 work and + requires either FFmpeg internal APIs or our own bitstream + parsing. +- **V4L2 / chardev wiring**: the daemon doesn't yet open + `/dev/daedalus-v4l2`. Phase 8.4 connects the parse output + to the chardev request/response loop. +- **Daemon mode**: no fork / setsid / pid file / systemd unit + yet. Phase 8.5+ adds those when the daemon needs to run as + a long-running service. + +## Phase 8.4 plan + +1. Open `/dev/daedalus-v4l2` chardev. +2. Wait for REQ_DECODE messages from kernel. +3. For each request: + - Feed the bitstream blob into FFmpeg's parse path. + - Get an `AVFrame` back (FFmpeg decodes on CPU initially). + - Send RESP_FRAME with the decoded frame back. +4. Validate end-to-end via a userspace V4L2 client that pushes + a VP9 file at the kernel module and reads decoded frames. + +The QPU dispatch via `daedalus_dispatch_*` from the sibling +daedalus-fourier kernel library lands in Phase 8.4 as well — +once the FFmpeg-driven decoder is working with CPU paths, we +can substitute QPU dispatch for the kernels that benefit +(cycles 1, 2, 4).