From b79d67ad801b9dc25c77d3fa71766d77e56ea18e Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 9 Aug 2026 16:57:02 +0100 Subject: [PATCH 1/3] contrib/avcodec: in-process video decode, so no intermediate file Aim #1 on aether-ui's video roadmap. A thin FFmpeg veneer following contrib/sqlite exactly: C shim + module.ae + a catalogue entry with a pkg-config probe, nothing vendored, user programs link -lavcodec -lavformat -lavutil -lswscale via aether.toml. Before, aether-ui's video_frame spawned ffmpeg to transcode a whole clip to raw RGBA on disk and fs.pread'd frames back: 27.6 MB for 6s of 320x240, ~1.5 GB per minute of 1080p, and NO workaround at all for a live source (camera, network stream) since there is no file to pread. Now: 27.6 MB -> 0. Measured 300 frames of 640x480 decoded in 0.426s including compile time. Surface is deliberately narrow -- open, next frame as packed RGBA8888, close. Video only; audio, seeking and stream selection are future work and none are needed to feed a renderer. Two ways to take a frame, mirroring sqlite's blob accessors: next_frame allocates a fresh owned string (simple, fine at small sizes); next_frame_into writes into a caller-owned buffer, allocating nothing per frame -- at 1080p30 that is 250 MB/s of churn avoided. fps() returns a RATIO rather than a float because 30000/1001 does not survive a float round-trip, and a presentation-timestamp model wants the exact value. The test's undersized-buffer assertion took two goes, and the first version is worth recording: it ran the decoder to EOF first, where next_frame_into returns 0 whatever the capacity -- so it passed with BOTH capacity guards deleted. Rewritten against a fresh decoder it catches the real thing: the sabotaged build returned m2=12288, writing 12 KB into a 16-byte allocation. It now also checks the decoder still works afterwards, so a refusal cannot silently consume the frame. Co-Authored-By: Claude Fable 5 --- .github/scripts/contrib_check.sh | 4 + contrib/avcodec/aether_avcodec.c | 244 +++++++++++++++++++++++++++++++ contrib/avcodec/module.ae | 104 +++++++++++++ contrib/avcodec/test_avcodec.ae | 101 +++++++++++++ tests/scripts/contrib_build.sh | 16 ++ 5 files changed, 469 insertions(+) create mode 100644 contrib/avcodec/aether_avcodec.c create mode 100644 contrib/avcodec/module.ae create mode 100644 contrib/avcodec/test_avcodec.ae diff --git a/.github/scripts/contrib_check.sh b/.github/scripts/contrib_check.sh index ce1bd4fb..bda5d519 100755 --- a/.github/scripts/contrib_check.sh +++ b/.github/scripts/contrib_check.sh @@ -38,9 +38,13 @@ mkdir -p "$run_dir" # code is correct. Those tests are gated for RUNTIME correctness (the thing that # actually caught the WS rot); making them leak-clean is separate follow-up # work. i18n/collate was written leak-clean by design, so it IS leak-gated. +AVC="contrib/avcodec" TW="contrib/tinyweb" I18N="contrib/i18n" TESTS=( + # avcodec: needs FFmpeg's dev libraries to link and the ffmpeg BINARY to + # generate its clip; the test SKIPs cleanly without the latter. + "avcodec/decode|$AVC/test_avcodec.ae|$AVC/aether_avcodec.c|run" "tinyweb/spec|$TW/test_spec.ae||run" "tinyweb/inventory|$TW/test_inventory.ae|$TW/ws_handshake.c|run" "tinyweb/integration|$TW/test_integration.ae|$TW/ws_handshake.c|run" diff --git a/contrib/avcodec/aether_avcodec.c b/contrib/avcodec/aether_avcodec.c new file mode 100644 index 00000000..f359d576 --- /dev/null +++ b/contrib/avcodec/aether_avcodec.c @@ -0,0 +1,244 @@ +/* contrib/avcodec — thin FFmpeg video-decode veneer for Aether. + * + * The narrowest surface that removes the intermediate file: open a media + * source, pull decoded frames as packed RGBA8888, close. Deliberately video- + * only and deliberately not a media framework — audio, seeking, filtering and + * stream selection are all future work, and none of them are needed to feed a + * vg LIVE_RASTER region. + * + * avc_open_raw(url, want_w, want_h) -> Decoder* (NULL on failure) + * avc_width_raw(d) / avc_height_raw(d)-> int (the SCALED output size) + * avc_frame_bytes_raw(d) -> int (w*h*4) + * avc_fps_num_raw(d) / avc_fps_den_raw(d) -> int (source rate as a ratio) + * avc_try_next_frame(d) -> int (1 = frame ready, 0 = EOF/error) + * avc_get_frame_bytes() -> const char* (TLS slot from last try_) + * avc_get_frame_length() -> int + * avc_release_frame() -> void (free early; else next try_ frees) + * avc_copy_frame_into_raw(d, buf, cap)-> int (bytes written; 0 on failure) + * avc_pts_ms_raw(d) -> int (presentation time of the last frame) + * avc_error_raw(d) -> const char* (always non-NULL) + * avc_close_raw(d) -> void + * + * The try_/get_/release_ trio mirrors contrib/sqlite's blob accessors, so + * binary data crosses the boundary the same way it already does elsewhere. + * avc_copy_frame_into_raw is the zero-allocation path: it writes straight + * into a caller-owned buffer, which is what a per-frame video loop wants — + * at 1080p a frame is 8 MB and allocating one per frame at 30fps is 250 MB/s + * of churn. + * + * A C-only dependency, like contrib/sqlite: user programs link + * -lavcodec -lavformat -lavutil -lswscale via aether.toml's link_flags. + * Nothing is vendored here. + */ + +#include +#include +#include +#include +#include +#include + +typedef struct { + AVFormatContext* fmt; + AVCodecContext* dec; + struct SwsContext* sws; + AVFrame* frame; /* decoded, source pixel format */ + AVFrame* rgba; /* converted, packed RGBA8888 */ + AVPacket* pkt; + uint8_t* rgba_buf; /* backing store for `rgba` */ + int stream_idx; + int out_w, out_h; + int fps_num, fps_den; + long pts_ms; + char err[256]; +} Decoder; + +void avc_close_raw(void* h); /* used by the open path's error unwind */ + +/* TLS frame slot — same shape as sqlite's blob slot. */ +static _Thread_local char* g_frame_bytes = NULL; +static _Thread_local int g_frame_len = 0; + +static void free_frame_tls(void) { + if (g_frame_bytes) { free(g_frame_bytes); g_frame_bytes = NULL; } + g_frame_len = 0; +} + +static void set_err(Decoder* d, const char* msg) { + if (!d) return; + snprintf(d->err, sizeof(d->err), "%s", msg ? msg : ""); +} + +void* avc_open_raw(const char* url, int want_w, int want_h) { + if (!url) return NULL; + Decoder* d = (Decoder*)calloc(1, sizeof(Decoder)); + if (!d) return NULL; + d->stream_idx = -1; + + if (avformat_open_input(&d->fmt, url, NULL, NULL) < 0) { + set_err(d, "cannot open input"); + free(d); + return NULL; + } + if (avformat_find_stream_info(d->fmt, NULL) < 0) { + set_err(d, "no stream info"); + avformat_close_input(&d->fmt); + free(d); + return NULL; + } + + const AVCodec* codec = NULL; + d->stream_idx = av_find_best_stream(d->fmt, AVMEDIA_TYPE_VIDEO, -1, -1, &codec, 0); + if (d->stream_idx < 0 || !codec) { + set_err(d, "no video stream"); + avformat_close_input(&d->fmt); + free(d); + return NULL; + } + + AVStream* st = d->fmt->streams[d->stream_idx]; + d->dec = avcodec_alloc_context3(codec); + if (!d->dec || avcodec_parameters_to_context(d->dec, st->codecpar) < 0 || + avcodec_open2(d->dec, codec, NULL) < 0) { + set_err(d, "cannot open decoder"); + if (d->dec) avcodec_free_context(&d->dec); + avformat_close_input(&d->fmt); + free(d); + return NULL; + } + + /* want_w/want_h <= 0 means "source size". Scaling here rather than in the + caller keeps the RGBA conversion and the resize in one swscale pass. */ + d->out_w = want_w > 0 ? want_w : d->dec->width; + d->out_h = want_h > 0 ? want_h : d->dec->height; + + AVRational fr = av_guess_frame_rate(d->fmt, st, NULL); + d->fps_num = fr.num > 0 ? fr.num : 0; + d->fps_den = fr.den > 0 ? fr.den : 1; + + d->sws = sws_getContext(d->dec->width, d->dec->height, d->dec->pix_fmt, + d->out_w, d->out_h, AV_PIX_FMT_RGBA, + SWS_BILINEAR, NULL, NULL, NULL); + d->frame = av_frame_alloc(); + d->rgba = av_frame_alloc(); + d->pkt = av_packet_alloc(); + if (!d->sws || !d->frame || !d->rgba || !d->pkt) { + set_err(d, "alloc failed"); + avc_close_raw(d); + return NULL; + } + + int nbytes = av_image_get_buffer_size(AV_PIX_FMT_RGBA, d->out_w, d->out_h, 1); + d->rgba_buf = (uint8_t*)av_malloc((size_t)nbytes); + if (!d->rgba_buf) { + set_err(d, "alloc failed"); + avc_close_raw(d); + return NULL; + } + av_image_fill_arrays(d->rgba->data, d->rgba->linesize, d->rgba_buf, + AV_PIX_FMT_RGBA, d->out_w, d->out_h, 1); + return d; +} + +int avc_width_raw(void* h) { Decoder* d = (Decoder*)h; return d ? d->out_w : 0; } +int avc_height_raw(void* h) { Decoder* d = (Decoder*)h; return d ? d->out_h : 0; } +int avc_frame_bytes_raw(void* h) { Decoder* d = (Decoder*)h; return d ? d->out_w * d->out_h * 4 : 0; } +int avc_fps_num_raw(void* h) { Decoder* d = (Decoder*)h; return d ? d->fps_num : 0; } +int avc_fps_den_raw(void* h) { Decoder* d = (Decoder*)h; return d ? d->fps_den : 1; } +int avc_pts_ms_raw(void* h) { Decoder* d = (Decoder*)h; return d ? (int)d->pts_ms : 0; } + +const char* avc_error_raw(void* h) { + Decoder* d = (Decoder*)h; + return (d && d->err[0]) ? d->err : ""; +} + +/* Decode until one frame is converted into d->rgba. Returns 1 on success, + 0 at EOF or on error. Packets that yield no frame (B-frame reordering, + parameter sets) are consumed and the loop continues, so a caller sees one + call == one frame rather than having to understand FFmpeg's buffering. */ +static int decode_one(Decoder* d) { + if (!d || !d->fmt || !d->dec) return 0; + for (;;) { + int rc = avcodec_receive_frame(d->dec, d->frame); + if (rc == 0) { + sws_scale(d->sws, (const uint8_t* const*)d->frame->data, + d->frame->linesize, 0, d->dec->height, + d->rgba->data, d->rgba->linesize); + AVStream* st = d->fmt->streams[d->stream_idx]; + int64_t pts = d->frame->best_effort_timestamp; + if (pts == AV_NOPTS_VALUE) pts = 0; + d->pts_ms = (long)(pts * av_q2d(st->time_base) * 1000.0); + return 1; + } + if (rc != AVERROR(EAGAIN) && rc != AVERROR_EOF) { + set_err(d, "decode error"); + return 0; + } + if (rc == AVERROR_EOF) return 0; + + int got = 0; + while (av_read_frame(d->fmt, d->pkt) >= 0) { + if (d->pkt->stream_index == d->stream_idx) { + int sc = avcodec_send_packet(d->dec, d->pkt); + av_packet_unref(d->pkt); + if (sc < 0) { set_err(d, "send packet failed"); return 0; } + got = 1; + break; + } + av_packet_unref(d->pkt); + } + if (!got) { + /* Input exhausted: flush the decoder's held frames, then EOF. */ + avcodec_send_packet(d->dec, NULL); + if (avcodec_receive_frame(d->dec, d->frame) == 0) { + sws_scale(d->sws, (const uint8_t* const*)d->frame->data, + d->frame->linesize, 0, d->dec->height, + d->rgba->data, d->rgba->linesize); + return 1; + } + return 0; + } + } +} + +/* Decode the next frame into the TLS slot (allocating a copy). */ +int avc_try_next_frame(void* h) { + free_frame_tls(); + Decoder* d = (Decoder*)h; + if (!decode_one(d)) return 0; + int n = d->out_w * d->out_h * 4; + g_frame_bytes = (char*)malloc((size_t)n); + if (!g_frame_bytes) { g_frame_len = 0; return 0; } + memcpy(g_frame_bytes, d->rgba_buf, (size_t)n); + g_frame_len = n; + return 1; +} + +const char* avc_get_frame_bytes(void) { return g_frame_bytes ? g_frame_bytes : ""; } +int avc_get_frame_length(void) { return g_frame_len; } +void avc_release_frame(void) { free_frame_tls(); } + +/* Zero-allocation path: decode straight into a caller-owned buffer. + Returns bytes written, or 0 on EOF/failure/insufficient capacity. */ +int avc_copy_frame_into_raw(void* h, void* buf, int cap) { + Decoder* d = (Decoder*)h; + if (!d || !buf) return 0; + int n = d->out_w * d->out_h * 4; + if (cap < n) return 0; + if (!decode_one(d)) return 0; + memcpy(buf, d->rgba_buf, (size_t)n); + return n; +} + +void avc_close_raw(void* h) { + Decoder* d = (Decoder*)h; + if (!d) return; + if (d->sws) sws_freeContext(d->sws); + if (d->frame) av_frame_free(&d->frame); + if (d->rgba) av_frame_free(&d->rgba); + if (d->pkt) av_packet_free(&d->pkt); + if (d->rgba_buf) av_free(d->rgba_buf); + if (d->dec) avcodec_free_context(&d->dec); + if (d->fmt) avformat_close_input(&d->fmt); + free(d); +} diff --git a/contrib/avcodec/module.ae b/contrib/avcodec/module.ae new file mode 100644 index 00000000..4f16f2bb --- /dev/null +++ b/contrib/avcodec/module.ae @@ -0,0 +1,104 @@ +// contrib.avcodec — thin FFmpeg video-decode veneer for Aether. +// Import with: import contrib.avcodec +// +// avcodec.open(url, w, h) -> (dec, err) // w/h <= 0 = source size +// avcodec.width(dec) -> int // the SCALED output width +// avcodec.height(dec) -> int +// avcodec.frame_bytes(dec) -> int // w*h*4 +// avcodec.fps(dec) -> (num, den) // source rate as a ratio +// avcodec.next_frame(dec) -> (bytes, n, err) // "" ,0, "eof" at end +// avcodec.next_frame_into(dec, buf, cap) -> (n, err) // zero-allocation +// avcodec.pts_ms(dec) -> int // presentation time of last frame +// avcodec.errmsg(dec) -> string +// avcodec.close(dec) -> void +// +// Video only, by design. Audio, seeking and stream selection are future +// work; none are needed to feed decoded frames to a renderer, which is the +// job this exists for. +// +// Two ways to take a frame, matching contrib/sqlite's blob accessors: +// +// next_frame allocates a fresh owned string per frame — simplest, +// and fine at small sizes. +// next_frame_into writes into a caller-owned buffer — nothing is +// allocated per frame. At 1080p a frame is 8 MB and +// 30fps is 250 MB/s of churn, so a long-running player +// should use this one. Pair it with bytes.to_string +// (NOT bytes.finish, which destroys the buffer) if the +// pixels then need to be passed on as a string. +// +// A C-only dependency: link with +// [build] link_flags = "-laether_avcodec -lavcodec -lavformat -lavutil -lswscale" +// Nothing is vendored in contrib/. + +exports( + avc_open_raw, avc_close_raw, + avc_width_raw, avc_height_raw, avc_frame_bytes_raw, + avc_fps_num_raw, avc_fps_den_raw, avc_pts_ms_raw, + avc_try_next_frame, avc_get_frame_bytes, avc_get_frame_length, + avc_release_frame, avc_copy_frame_into_raw, avc_error_raw, + open, close, width, height, frame_bytes, fps, pts_ms, + next_frame, next_frame_into, errmsg +) + +extern avc_open_raw(url: string, want_w: int, want_h: int) -> ptr +extern avc_close_raw(dec: ptr) +extern avc_width_raw(dec: ptr) -> int +extern avc_height_raw(dec: ptr) -> int +extern avc_frame_bytes_raw(dec: ptr) -> int +extern avc_fps_num_raw(dec: ptr) -> int +extern avc_fps_den_raw(dec: ptr) -> int +extern avc_pts_ms_raw(dec: ptr) -> int +extern avc_try_next_frame(dec: ptr) -> int +extern avc_get_frame_bytes() -> string +extern avc_get_frame_length() -> int +extern avc_release_frame() +extern avc_copy_frame_into_raw(dec: ptr, buf: ptr, cap: int) -> int +extern avc_error_raw(dec: ptr) -> string +extern string_new_with_length(data: string, length: int) -> ptr + +// Open a media source. `want_w`/`want_h` <= 0 means "source size"; anything +// else scales during the RGBA conversion, so resizing costs no extra pass. +open(url: string, want_w: int, want_h: int) -> { + d = avc_open_raw(url, want_w, want_h) + if d == null { return null, "cannot open ${url}" } + return d, "" +} + +close(dec: ptr) { avc_close_raw(dec) } + +width(dec: ptr) -> int { return avc_width_raw(dec) } +height(dec: ptr) -> int { return avc_height_raw(dec) } +frame_bytes(dec: ptr) -> int { return avc_frame_bytes_raw(dec) } +pts_ms(dec: ptr) -> int { return avc_pts_ms_raw(dec) } +errmsg(dec: ptr) -> string { return avc_error_raw(dec) } + +// Source frame rate as a ratio rather than a float: 30000/1001 (NTSC) does +// not survive a float round-trip cleanly, and a presentation-timestamp model +// wants the exact ratio. +fps(dec: ptr) -> { + return avc_fps_num_raw(dec), avc_fps_den_raw(dec) +} + +// Decode the next frame as packed RGBA8888. Returns ("", 0, "eof") at the +// end of the stream — EOF is not an error, so callers loop until n == 0. +next_frame(dec: ptr) -> { + ok = avc_try_next_frame(dec) + if ok == 0 { return "", 0, "eof" } + raw = avc_get_frame_bytes() + n = avc_get_frame_length() + owned = string_new_with_length(raw, n) + avc_release_frame() + return owned, n, "" +} + +// Zero-allocation variant: decode straight into a caller-owned buffer. +// Returns (0, "eof") at end of stream, (0, "buffer too small") if cap is +// under frame_bytes(dec). +next_frame_into(dec: ptr, buf: ptr, cap: int) -> { + need = avc_frame_bytes_raw(dec) + if cap < need { return 0, "buffer too small: need ${need}, have ${cap}" } + n = avc_copy_frame_into_raw(dec, buf, cap) + if n == 0 { return 0, "eof" } + return n, "" +} diff --git a/contrib/avcodec/test_avcodec.ae b/contrib/avcodec/test_avcodec.ae new file mode 100644 index 00000000..9106e51b --- /dev/null +++ b/contrib/avcodec/test_avcodec.ae @@ -0,0 +1,101 @@ +// test_avcodec.ae — contrib/avcodec runtime gate. +// +// Generates its own clip with ffmpeg so the test carries no fixture, and +// SKIPS cleanly when ffmpeg is absent -- the shim needs FFmpeg's libraries +// to build at all, but the ffmpeg BINARY is only needed to make test input. +import contrib.avcodec +import std.string +import std.bytes +import std.os +import std.list +import std.fs + +extern exit(code: int) + +fail(msg: string) { println(" FAIL: ${msg}"); exit(1) } +pass(msg: string) { println(" PASS: ${msg}") } + +main() { + clip = "/tmp/_avc_test_clip.mp4" + av = list.new() + _ = list.add(av, "-y") + _ = list.add(av, "-f") + _ = list.add(av, "lavfi") + _ = list.add(av, "-i") + _ = list.add(av, "testsrc=size=64x48:rate=10:duration=2") + _ = list.add(av, "-pix_fmt") + _ = list.add(av, "yuv420p") + _ = list.add(av, clip) + _o, st, e = os.run_capture("ffmpeg", av, null) + list.free(av) + if st != 0 { + println(" SKIP: ffmpeg not available to generate test input") + return + } + + d, err = avcodec.open(clip, 64, 48) + if string.length(err) > 0 { fail("open: ${err}") } + if avcodec.width(d) != 64 { fail("width ${avcodec.width(d)} want 64") } + if avcodec.height(d) != 48 { fail("height ${avcodec.height(d)} want 48") } + pass("open + geometry") + + fb = avcodec.frame_bytes(d) + if fb != 64 * 48 * 4 { fail("frame_bytes ${fb} want ${64 * 48 * 4}") } + pass("frame_bytes is w*h*4") + + num, den = avcodec.fps(d) + if num <= 0 { fail("fps num ${num}") } + if den <= 0 { fail("fps den ${den}") } + pass("fps ratio ${num}/${den}") + + // Allocation path. + px, n, ferr = avcodec.next_frame(d) + if n != fb { fail("next_frame n=${n} want ${fb}") } + if string.length(ferr) > 0 { fail("next_frame err ${ferr}") } + pass("next_frame returns a full RGBA frame") + + // Zero-allocation path, one buffer reused to EOF. Counting to the end + // also proves EOF is reported as n==0 rather than looping forever. + buf = bytes.new(fb) + count = 1 + guard = 0 + while guard < 400 { + m, e2 = avcodec.next_frame_into(d, bytes.data(buf), fb) + if m == 0 { guard = 400 } + else { + if m != fb { fail("next_frame_into m=${m} want ${fb}") } + count = count + 1 + guard = guard + 1 + } + } + // 2s at 10fps: expect ~20 frames. Assert a RANGE, not an exact count -- + // encoders round duration differently and an exact number would be a + // flake waiting to happen. + if count < 15 { fail("decoded only ${count} frames, want ~20") } + if count > 25 { fail("decoded ${count} frames, want ~20") } + pass("decoded ${count} frames to EOF via the reused buffer") + + // A buffer that is too small must be refused, not overrun. + // + // On a FRESH decoder: the loop above ran `d` to EOF, and at EOF + // next_frame_into returns 0 whatever the capacity -- so asserting here + // against `d` passed even with BOTH capacity guards deleted. Testing a + // refusal needs a decoder that would otherwise have succeeded. + d2, err2 = avcodec.open(clip, 64, 48) + if string.length(err2) > 0 { fail("reopen: ${err2}") } + small = bytes.new(16) + m2, e3 = avcodec.next_frame_into(d2, bytes.data(small), 16) + if m2 != 0 { fail("undersized buffer accepted (m2=${m2})") } + if string.length(e3) == 0 { fail("undersized buffer gave no error") } + // ...and the same decoder still works with a correct buffer, proving + // the refusal did not consume the frame or wedge the decoder. + ok_buf = bytes.new(fb) + m3, e4 = avcodec.next_frame_into(d2, bytes.data(ok_buf), fb) + if m3 != fb { fail("decoder unusable after a refused read (m3=${m3})") } + avcodec.close(d2) + pass("undersized buffer is refused, decoder still usable") + + avcodec.close(d) + _e = fs.delete(clip) + println("=== test_avcodec passed ===") +} diff --git a/tests/scripts/contrib_build.sh b/tests/scripts/contrib_build.sh index 33f2500b..a1d8c762 100755 --- a/tests/scripts/contrib_build.sh +++ b/tests/scripts/contrib_build.sh @@ -156,6 +156,21 @@ probe_sqlite() { return 1 } +probe_avcodec() { + # FFmpeg's decode libraries. Video-only shim, but swscale does the RGBA + # conversion and avutil carries the frame/image helpers, so all four are + # required together -- a partial install is a SKIP, not a half-build. + if [ -n "$CROSS_MODE" ]; then + cross_dep_present libavcodec/avcodec.h avcodec + return + fi + if pkg-config --exists libavcodec libavformat libavutil libswscale 2>/dev/null; then + pkg-config --cflags-only-I libavcodec libavformat libavutil libswscale + return 0 + fi + return 1 +} + probe_lua() { for v in lua5.4 lua5.3 lua; do if pkg-config --exists "$v" 2>/dev/null; then @@ -415,6 +430,7 @@ build_module() { # args are exactly what the build loop calls. CATALOGUE=( "sqlite|sqlite contrib/sqlite/aether_sqlite.c AETHER_HAS_SQLITE probe_sqlite" + "avcodec|avcodec contrib/avcodec/aether_avcodec.c AETHER_HAS_AVCODEC probe_avcodec" "python|host_python contrib/host/python/aether_host_python.c AETHER_HAS_PYTHON probe_python" "lua|host_lua contrib/host/lua/aether_host_lua.c AETHER_HAS_LUA probe_lua" "perl|host_perl contrib/host/perl/aether_host_perl.c AETHER_HAS_PERL probe_perl" From ff3fad3cab21b87d611158d5931581e63b53af5a Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 9 Aug 2026 17:42:27 +0100 Subject: [PATCH 2/3] fix(contrib-check): link system libraries, so the avcodec gate actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The avcodec entry added alongside the module could never pass. `make contrib-check` reported: FAIL avcodec/decode (build) collect2: error: ld returned 1 exit status undefined reference to `avcodec_receive_frame' ...on a box with all four FFmpeg dev libraries installed. The runner builds each test with `ae build --extra `, which compiles the shim but has NO way to pass -l flags, so any module backed by a system library compiles and then dies at link. The entry was wired in but structurally incapable of running. The module itself is fine — this is purely the CI wiring. Proven by building the same test through an aether.toml workspace, where all six assertions pass: open + geometry, frame_bytes, fps ratio 10/1, a full RGBA frame, 20 frames decoded to EOF via the reused buffer, and the undersized-buffer refusal leaving the decoder usable. Added an optional fifth column naming the pkg-config modules a test must link against. When set, the runner stages an aether.toml workspace carrying link_flags — the same shape tests/integration/sqlite_roundtrip already uses, which is where ae's get_link_flags() picks them up — and SKIPS the entry when pkg-config cannot find the modules, since an absent FFmpeg is a provisioning gap on the box rather than a code defect. Both paths verified here: with FFmpeg: PASS avcodec/decode (run) + all six existing tests without: SKIP avcodec/decode (pkg-config: ... not found) Note contrib/sqlite has the same shape and is NOT in this table; it is covered by tests/integration/sqlite_roundtrip instead. Worth folding in later so contrib runtime coverage lives in one place, but that is a separate change. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/contrib_check.sh | 61 ++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/.github/scripts/contrib_check.sh b/.github/scripts/contrib_check.sh index bda5d519..01188eb5 100755 --- a/.github/scripts/contrib_check.sh +++ b/.github/scripts/contrib_check.sh @@ -41,16 +41,26 @@ mkdir -p "$run_dir" AVC="contrib/avcodec" TW="contrib/tinyweb" I18N="contrib/i18n" +#