From 90a0acaf02bcf91b2c40fa1837b9942b234ddf62 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 2 Jul 2026 15:22:57 -0700 Subject: [PATCH] =?UTF-8?q?ac-native:=20'cap'=20camera=20video=20recorder?= =?UTF-8?q?=20=E2=80=94=20hold=20space,=20clips=20become=20tapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested by @minanimals for music-video shooting on his blank: bend the screen back, play the song via dj/notepat, hold space per shot — every clip lands in /mnt/tapes as MP4 with the output mix as its soundtrack (auto-synced to the song), then auto-uploads to the account like any tape. - kernel: enable CONFIG_MEDIA_SUPPORT + UVC so /dev/video* exists - camera.c: color path — YUYV → ARGB display buffer beside the QR grayscale; buffer frees now happen under the display mutex - js-bindings: system.cameraStart/Stop/Ready/Error (continuous ~30fps stream thread), color-aware cameraBlit (+mirror arg), system.tapeStart/tapeStop (quiet — no 'tape rolling' in the take) - ac-native.c: PrintScreen tape toggle refactored into shared ac_tape_start/stop; TAPE overlay now drawn after the recorder submit so recordings stay clean of UI - pieces/cap.mjs: the piece — camera preview, hold-to-record, idle-only chrome --- fedac/native/kernel/config-minimal | 12 +- fedac/native/pieces/cap.mjs | 118 ++++++++++++++++++++ fedac/native/pieces/prompt.mjs | 1 + fedac/native/src/ac-native.c | 169 +++++++++++++++++------------ fedac/native/src/camera.c | 48 +++++++- fedac/native/src/camera.h | 9 +- fedac/native/src/js-bindings.c | 150 +++++++++++++++++++++++-- fedac/native/src/js-bindings.h | 7 ++ 8 files changed, 428 insertions(+), 86 deletions(-) create mode 100644 fedac/native/pieces/cap.mjs diff --git a/fedac/native/kernel/config-minimal b/fedac/native/kernel/config-minimal index 380223a10..529d49983 100644 --- a/fedac/native/kernel/config-minimal +++ b/fedac/native/kernel/config-minimal @@ -2605,7 +2605,17 @@ CONFIG_BCMA_POSSIBLE=y # CONFIG_MEDIA_CEC_SUPPORT is not set # end of CEC support -# CONFIG_MEDIA_SUPPORT is not set +# Media/V4L2 — built-in UVC webcam support for the 'cap' piece (camera.c). +# ThinkPad integrated cameras are USB Video Class devices; olddefconfig +# resolves the remaining V4L2 core dependencies from these seeds. +CONFIG_MEDIA_SUPPORT=y +CONFIG_MEDIA_SUPPORT_FILTER=y +CONFIG_MEDIA_SUBDRV_AUTOSELECT=y +CONFIG_MEDIA_CAMERA_SUPPORT=y +CONFIG_MEDIA_USB_SUPPORT=y +CONFIG_VIDEO_DEV=y +CONFIG_USB_VIDEO_CLASS=y +CONFIG_USB_VIDEO_CLASS_INPUT_EVDEV=y # # Graphics support diff --git a/fedac/native/pieces/cap.mjs b/fedac/native/pieces/cap.mjs new file mode 100644 index 000000000..679072852 --- /dev/null +++ b/fedac/native/pieces/cap.mjs @@ -0,0 +1,118 @@ +// cap, 2026.07.02 +// Camera video recorder — point the webcam, hold space (or touch) to +// record a clip. Clips are MP4 tapes: they land in /mnt/tapes/ with the +// audio output mix as the soundtrack, then auto-upload to your account. +// +// The music-video move (requested by @minanimals): play your song on the +// computer (dj / notepat) so every clip carries the track's audio for +// syncing in an editor — hold space for each shot, end up with a pile of +// pre-synced takes on prompt.ac. +// +// While recording the piece paints ONLY the camera frame: the runtime +// submits the framebuffer to the recorder right after paint, so any UI +// drawn here would be baked into the footage. The red TAPE overlay you +// see on-screen is composited after the submit and stays out of the file. + +const CAM_W = 640; // capture aspect from camera.c (4:3) +const CAM_H = 480; + +let recording = false; +let clipsThisSession = 0; +let bootFrame = 0; +let sys = null; // latched at boot — leave() doesn't receive the api + +function boot({ system }) { + sys = system; + system?.cameraStart?.(); +} + +function sim() { + bootFrame++; +} + +function fitRect(screen) { + // Contain: largest 4:3 rect centered on screen + let dw = screen.width; + let dh = Math.floor((dw * CAM_H) / CAM_W); + if (dh > screen.height) { + dh = screen.height; + dw = Math.floor((dh * CAM_W) / CAM_H); + } + return { + x: Math.floor((screen.width - dw) / 2), + y: Math.floor((screen.height - dh) / 2), + w: dw, + h: dh, + }; +} + +function paint({ wipe, ink, screen, system, sound }) { + const err = system?.cameraError?.() || ""; + const ready = system?.cameraReady?.(); + + if (err) { + wipe(0, 0, 0); + ink(255, 90, 90).write("camera error: " + err, { x: 8, y: 12 }); + ink(150, 150, 150).write("is a webcam connected?", { x: 8, y: 24 }); + return; + } + + if (!ready) { + wipe(0, 0, 0); + const dots = ".".repeat(1 + (Math.floor(bootFrame / 20) % 3)); + ink(180, 180, 180).write("warming up camera" + dots, { x: 8, y: 12 }); + return; + } + + recording = sound?.tape?.recording?.() || false; + + wipe(0, 0, 0); + const r = fitRect(screen); + system.cameraBlit(r.x, r.y, r.w, r.h); + + // Recording: nothing else — keep the footage clean. + if (recording) return; + + // Idle chrome + ink(255, 255, 255).write("cap", { x: 6, y: 6 }); + ink(200, 200, 200).write("hold SPACE to record", { + x: 6, + y: screen.height - 22, + }); + ink(130, 130, 130).write("play your song first — clips carry its audio", { + x: 6, + y: screen.height - 12, + }); + if (clipsThisSession > 0) { + const label = `${clipsThisSession} clip${clipsThisSession === 1 ? "" : "s"} → tapes`; + ink(120, 220, 255).write(label, { x: screen.width - label.length * 6 - 6, y: 6 }); + } +} + +function startClip(system) { + if (recording) return; + if (system?.tapeStart?.()) recording = true; +} + +function stopClip(system) { + if (!recording) return; + if (system?.tapeStop?.()) { + recording = false; + clipsThisSession++; + } +} + +function act({ event: e, system }) { + // Hold space (or hold a touch) to record; release to cut. + if (e.is("keyboard:down:space") && !e.repeat) startClip(system); + if (e.is("keyboard:up:space")) stopClip(system); + if (e.is("touch")) startClip(system); + if (e.is("lift")) stopClip(system); +} + +function leave() { + if (recording) stopClip(sys); + sys?.cameraStop?.(); +} + +export { boot, sim, paint, act, leave }; diff --git a/fedac/native/pieces/prompt.mjs b/fedac/native/pieces/prompt.mjs index a0c4d5235..beb7e1907 100644 --- a/fedac/native/pieces/prompt.mjs +++ b/fedac/native/pieces/prompt.mjs @@ -31,6 +31,7 @@ const CODE_NAMES = ["$roz"]; // Piece descriptions (for tab completion display) const PIECE_DESC = { "notepat": "synthesizer instrument", + "cap": "camera video recorder", "catnom": "category muncher game", "os": "system update (OTA)", "wifi": "network picker", diff --git a/fedac/native/src/ac-native.c b/fedac/native/src/ac-native.c index dcbd8023f..b5e1e2e0c 100644 --- a/fedac/native/src/ac-native.c +++ b/fedac/native/src/ac-native.c @@ -3483,6 +3483,77 @@ static void tape_upload_async(const char *tape_path) { system(cmd); } +// Shared tape start/stop — called by the PrintScreen key handler below and +// by the JS system.tapeStart()/tapeStop() bindings (js-bindings.c) that the +// 'cap' camera piece uses for hold-to-record. The pointers are latched in +// main() once the recorder exists. `quiet` skips the TTS announce + audible +// cues so camera clips don't open with "tape rolling" baked into the song's +// audio track. +static ACRecorder *g_tape_recorder = NULL; +static ACAudio *g_tape_audio = NULL; +static ACTts *g_tape_tts = NULL; + +int ac_tape_start(int quiet) { + if (!g_tape_recorder) return -1; + if (recorder_is_recording(g_tape_recorder)) return 0; + mkdir("/mnt/tapes", 0755); + time_t now = time(NULL); + struct tm *tm = gmtime(&now); + // Milliseconds for slug uniqueness + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + int ms = (int)(ts.tv_nsec / 1000000); + char rec_path[256]; + snprintf(rec_path, sizeof(rec_path), + "/mnt/tapes/%04d.%02d.%02d.%02d.%02d.%02d.%03d.mp4", + tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday, + tm->tm_hour, tm->tm_min, tm->tm_sec, ms); + if (recorder_start(g_tape_recorder, rec_path) != 0) return -1; + // Wire up audio tap + if (g_tape_audio) { + g_tape_audio->rec_userdata = g_tape_recorder; + g_tape_audio->rec_callback = rec_audio_tap; + } + // Latch the path + flag for the on-screen overlay + strncpy(g_tape_current_path, rec_path, sizeof(g_tape_current_path) - 1); + g_tape_current_path[sizeof(g_tape_current_path) - 1] = '\0'; + g_tape_recording = 1; + g_tape_start_sec = time(NULL); + if (!quiet) { + if (g_tape_tts) tts_speak(g_tape_tts, "tape rolling"); + if (g_tape_audio) { + audio_synth(g_tape_audio, WAVE_SINE, 660.0, 0.12, 0.2, 0.001, 0.10, 0.0); + audio_synth(g_tape_audio, WAVE_SINE, 880.0, 0.12, 0.15, 0.03, 0.09, 0.0); + } + } + return 0; +} + +int ac_tape_stop(int quiet) { + if (!g_tape_recorder || !recorder_is_recording(g_tape_recorder)) return -1; + // Remove audio tap before finalizing the file + if (g_tape_audio) { + g_tape_audio->rec_callback = NULL; + g_tape_audio->rec_userdata = NULL; + } + // Snapshot the path before stop (recorder_stop may clear it) + char saved_tape_path[256] = {0}; + strncpy(saved_tape_path, g_tape_current_path, sizeof(saved_tape_path) - 1); + recorder_stop(g_tape_recorder); + g_tape_recording = 0; + g_tape_current_path[0] = '\0'; + if (!quiet) { + if (g_tape_tts) tts_speak(g_tape_tts, "tape stopped"); + if (g_tape_audio) { + audio_synth(g_tape_audio, WAVE_SINE, 880.0, 0.12, 0.2, 0.001, 0.10, 0.0); + audio_synth(g_tape_audio, WAVE_SINE, 660.0, 0.12, 0.15, 0.03, 0.09, 0.0); + } + } + // Kick off background upload if we captured a path + if (saved_tape_path[0]) tape_upload_async(saved_tape_path); + return 0; +} + int main(int argc, char *argv[]) { struct timespec boot_start; clock_gettime(CLOCK_MONOTONIC, &boot_start); @@ -4155,6 +4226,10 @@ int main(int argc, char *argv[]) { ac_log("[ac-native] recorder ready (%dx%d)\n", screen->width, screen->height); // Expose to JS via sound.tape.* bindings if (rt) rt->recorder = recorder; + // Latch pointers for the shared ac_tape_start/stop helpers + g_tape_recorder = recorder; + g_tape_audio = audio; + g_tape_tts = tts; } } #endif @@ -4533,56 +4608,9 @@ int main(int argc, char *argv[]) { strcmp(input->events[i].key_name, "insert") == 0 || strcmp(input->events[i].key_name, "pause") == 0) && recorder) { if (recorder_is_recording(recorder)) { - // Stop: remove audio tap, finalize file - if (audio) { - audio->rec_callback = NULL; - audio->rec_userdata = NULL; - } - // Snapshot the path before stop (recorder_stop may clear it) - char saved_tape_path[256] = {0}; - strncpy(saved_tape_path, g_tape_current_path, sizeof(saved_tape_path) - 1); - recorder_stop(recorder); - // Clear the live overlay state - g_tape_recording = 0; - g_tape_current_path[0] = '\0'; - // TTS announce + audible cue (descending) - if (tts) tts_speak(tts, "tape stopped"); - audio_synth(audio, WAVE_SINE, 880.0, 0.12, 0.2, 0.001, 0.10, 0.0); - audio_synth(audio, WAVE_SINE, 660.0, 0.12, 0.15, 0.03, 0.09, 0.0); - // Kick off background upload if we captured a path - if (saved_tape_path[0]) { - tape_upload_async(saved_tape_path); - } + ac_tape_stop(0); } else { - // Start: generate timestamped slug, wire up audio tap - mkdir("/mnt/tapes", 0755); - time_t now = time(NULL); - struct tm *tm = gmtime(&now); - // Milliseconds for slug uniqueness - struct timespec ts; - clock_gettime(CLOCK_REALTIME, &ts); - int ms = (int)(ts.tv_nsec / 1000000); - char rec_path[256]; - snprintf(rec_path, sizeof(rec_path), - "/mnt/tapes/%04d.%02d.%02d.%02d.%02d.%02d.%03d.mp4", - tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday, - tm->tm_hour, tm->tm_min, tm->tm_sec, ms); - if (recorder_start(recorder, rec_path) == 0) { - // Wire up audio tap - if (audio) { - audio->rec_userdata = recorder; - audio->rec_callback = rec_audio_tap; - } - // Latch the path + flag for the on-screen overlay - strncpy(g_tape_current_path, rec_path, sizeof(g_tape_current_path) - 1); - g_tape_current_path[sizeof(g_tape_current_path) - 1] = '\0'; - g_tape_recording = 1; - g_tape_start_sec = time(NULL); - // TTS announce + audible cue (ascending) - if (tts) tts_speak(tts, "tape rolling"); - audio_synth(audio, WAVE_SINE, 660.0, 0.12, 0.2, 0.001, 0.10, 0.0); - audio_synth(audio, WAVE_SINE, 880.0, 0.12, 0.15, 0.03, 0.09, 0.0); - } + ac_tape_start(0); } } else if (strcmp(input->events[i].key_name, "power") == 0 || @@ -4790,27 +4818,6 @@ int main(int argc, char *argv[]) { clock_gettime(CLOCK_MONOTONIC, &_pf_paint1); - // Tape recording overlay — red REC dot + elapsed timer in - // the top-left corner. Blinks slowly so it reads as "live" - // without being visually distracting. - if (g_tape_recording) { - long elapsed = (long)(time(NULL) - g_tape_start_sec); - int blink = ((main_frame / 30) & 1); // toggle ~every 0.5s - // Red dot - if (blink) { - graph_ink(&graph, (ACColor){230, 40, 40, 240}); - graph_box(&graph, 6, 6, 8, 8, 1); - } - // "TAPE 0:23" label - char rec_label[32]; - snprintf(rec_label, sizeof(rec_label), "TAPE %ld:%02ld", - elapsed / 60, elapsed % 60); - graph_ink(&graph, (ACColor){0, 0, 0, 180}); - graph_box(&graph, 16, 4, (int)strlen(rec_label) * 6 + 4, 12, 1); - graph_ink(&graph, (ACColor){255, 220, 220, 255}); - font_draw_matrix(&graph, rec_label, 18, 6, 1); - } - // Crash overlay — red bar with error message when JS throws if (rt->crash_active) { rt->crash_frame++; @@ -5102,6 +5109,28 @@ int main(int argc, char *argv[]) { if (recorder_is_recording(recorder)) recorder_submit_video(recorder, screen->pixels, screen->stride); + // Tape recording overlay — drawn AFTER the recorder submit so + // the live screen shows recording state but the MP4 stays clean + // (matters for 'cap' camera footage headed to an edit bay). + if (g_tape_recording) { + graph_page(&graph, screen); + long elapsed = (long)(time(NULL) - g_tape_start_sec); + int blink = ((main_frame / 30) & 1); // toggle ~every 0.5s + // Red dot + if (blink) { + graph_ink(&graph, (ACColor){230, 40, 40, 240}); + graph_box(&graph, 6, 6, 8, 8, 1); + } + // "TAPE 0:23" label + char rec_label[32]; + snprintf(rec_label, sizeof(rec_label), "TAPE %ld:%02ld", + elapsed / 60, elapsed % 60); + graph_ink(&graph, (ACColor){0, 0, 0, 180}); + graph_box(&graph, 16, 4, (int)strlen(rec_label) * 6 + 4, 12, 1); + graph_ink(&graph, (ACColor){255, 220, 220, 255}); + font_draw_matrix(&graph, rec_label, 18, 6, 1); + } + // Draw recording indicator (red dot + duration) if (recorder_is_recording(recorder)) { graph_page(&graph, screen); diff --git a/fedac/native/src/camera.c b/fedac/native/src/camera.c index eb5a8024b..273854d20 100644 --- a/fedac/native/src/camera.c +++ b/fedac/native/src/camera.c @@ -79,6 +79,7 @@ int camera_open(ACCamera *cam) { cam->width = fmt.fmt.pix.width; cam->height = fmt.fmt.pix.height; + cam->pixfmt = fmt.fmt.pix.pixelformat; ac_log("[camera] format: %dx%d pixfmt=0x%08x\n", cam->width, cam->height, fmt.fmt.pix.pixelformat); @@ -146,8 +147,18 @@ int camera_open(ACCamera *cam) { camera_close(cam); return -1; } + pthread_mutex_init(&cam->display_mu, NULL); + + // Color display buffer — only useful for YUYV (MJPEG stays grayscale-less) + cam->display_rgb = malloc((size_t)cam->width * cam->height * sizeof(uint32_t)); + if (!cam->display_rgb) { + snprintf(cam->scan_error, sizeof(cam->scan_error), "rgb alloc failed"); + camera_close(cam); + return -1; + } cam->display_ready = 0; + cam->display_rgb_ready = 0; ac_log("[camera] ready: %dx%d, %d buffers\n", cam->width, cam->height, cam->buffer_count); @@ -171,12 +182,23 @@ void camera_close(ACCamera *cam) { cam->gray = NULL; } if (cam->display) { - pthread_mutex_destroy(&cam->display_mu); + // Free the display buffers under the mutex so a concurrent + // cameraBlit on the main thread can't read freed memory. + pthread_mutex_lock(&cam->display_mu); free(cam->display); cam->display = NULL; + if (cam->display_rgb) { + free(cam->display_rgb); + cam->display_rgb = NULL; + } + cam->display_ready = 0; + cam->display_rgb_ready = 0; + pthread_mutex_unlock(&cam->display_mu); + pthread_mutex_destroy(&cam->display_mu); } cam->gray_ready = 0; cam->display_ready = 0; + cam->display_rgb_ready = 0; } int camera_grab(ACCamera *cam) { @@ -201,11 +223,33 @@ int camera_grab(ACCamera *cam) { } cam->gray_ready = 1; - // Copy to display buffer for main thread rendering + // Copy to display buffers for main thread rendering. The color pass + // decodes full YUYV → ARGB32 (BT.601): each 4-byte group Y0 U Y1 V + // yields two pixels sharing chroma. if (cam->display) { pthread_mutex_lock(&cam->display_mu); memcpy(cam->display, cam->gray, cam->width * cam->height); cam->display_ready = 1; + if (cam->display_rgb && cam->pixfmt == V4L2_PIX_FMT_YUYV) { + uint32_t *dst = cam->display_rgb; + for (int i = 0; i < pixels; i += 2) { + int y0 = src[i * 2 + 0], u = src[i * 2 + 1]; + int y1 = src[i * 2 + 2], v = src[i * 2 + 3]; + int d = u - 128, e = v - 128; + for (int k = 0; k < 2; k++) { + int c = (k ? y1 : y0) - 16; + int r = (298 * c + 409 * e + 128) >> 8; + int g = (298 * c - 100 * d - 208 * e + 128) >> 8; + int b = (298 * c + 516 * d + 128) >> 8; + if (r < 0) r = 0; else if (r > 255) r = 255; + if (g < 0) g = 0; else if (g > 255) g = 255; + if (b < 0) b = 0; else if (b > 255) b = 255; + dst[i + k] = 0xFF000000u | ((uint32_t)r << 16) | + ((uint32_t)g << 8) | (uint32_t)b; + } + } + cam->display_rgb_ready = 1; + } pthread_mutex_unlock(&cam->display_mu); } diff --git a/fedac/native/src/camera.h b/fedac/native/src/camera.h index 581e1f448..d792e13a7 100644 --- a/fedac/native/src/camera.h +++ b/fedac/native/src/camera.h @@ -10,6 +10,7 @@ struct ACGraph; typedef struct { int fd; // V4L2 device fd (-1 = closed) int width, height; // capture resolution + uint32_t pixfmt; // negotiated V4L2 pixel format (YUYV or MJPEG) uint8_t *buffers[4]; // mmap'd V4L2 buffers int buffer_count; int streaming; // 1 = V4L2 streaming active @@ -20,9 +21,15 @@ typedef struct { // Display frame: mutex-protected copy for main thread rendering uint8_t *display; // copy of gray for rendering (width * height) - pthread_mutex_t display_mu; // protects display buffer + pthread_mutex_t display_mu; // protects display buffers volatile int display_ready; // 1 = new display frame available + // Color display frame (ARGB32, width * height) — filled from YUYV when + // the camera negotiates that format; the 'cap' piece renders this. + // Guarded by display_mu alongside the grayscale copy. + uint32_t *display_rgb; + volatile int display_rgb_ready; + // QR scan results volatile int scan_pending; // 1 = scan requested volatile int scan_done; // 1 = scan complete (check scan_result) diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c index c41553ea7..baab2ad2b 100644 --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -4094,14 +4094,18 @@ static JSValue js_scan_qr_stop(JSContext *ctx, JSValueConst this_val, int argc, return JS_UNDEFINED; } -// cameraBlit(x, y, w, h) — render camera display buffer to graph framebuffer +// cameraBlit(x, y, w, h, mirror) — render camera display buffer to graph +// framebuffer. Prefers the ARGB color frame (YUYV cameras) and falls back +// to grayscale. mirror=1 flips horizontally for a selfie-style preview — +// note the tape recorder captures the screen, so mirrored previews record +// mirrored footage. static JSValue js_camera_blit(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { (void)this_val; if (!current_rt || !current_rt->graph) return JS_FALSE; ACCamera *cam = ¤t_rt->camera; if (!cam->display || !cam->display_ready) return JS_FALSE; - int dx = 0, dy = 0, dw = 0, dh = 0; + int dx = 0, dy = 0, dw = 0, dh = 0, mirror = 0; if (argc >= 4) { JS_ToInt32(ctx, &dx, argv[0]); JS_ToInt32(ctx, &dy, argv[1]); @@ -4112,19 +4116,34 @@ static JSValue js_camera_blit(JSContext *ctx, JSValueConst this_val, int argc, J dw = current_rt->graph->fb->width; dh = current_rt->graph->fb->height; } + if (argc >= 5) JS_ToInt32(ctx, &mirror, argv[4]); if (dw <= 0 || dh <= 0) return JS_FALSE; - // Lock and copy the display buffer to a local copy + // Lock and copy whichever display buffer is freshest to a local copy. + // Pointer checks re-run under the lock — camera_close frees these + // buffers while holding display_mu. int cw = cam->width, ch = cam->height; int pixels = cw * ch; - uint8_t *local = malloc(pixels); - if (!local) return JS_FALSE; + int color = 0; + uint32_t *local_rgb = NULL; + uint8_t *local_gray = NULL; pthread_mutex_lock(&cam->display_mu); - memcpy(local, cam->display, pixels); + if (cam->display_rgb && cam->display_rgb_ready) { + local_rgb = malloc((size_t)pixels * sizeof(uint32_t)); + if (local_rgb) { + memcpy(local_rgb, cam->display_rgb, (size_t)pixels * sizeof(uint32_t)); + color = 1; + } + } + if (!color && cam->display && cam->display_ready) { + local_gray = malloc(pixels); + if (local_gray) memcpy(local_gray, cam->display, pixels); + } pthread_mutex_unlock(&cam->display_mu); + if (!color && !local_gray) return JS_FALSE; - // Blit grayscale to framebuffer with nearest-neighbor scaling + // Blit to framebuffer with nearest-neighbor scaling ACFramebuffer *fb = current_rt->graph->fb; for (int py = 0; py < dh; py++) { int fy = dy + py; @@ -4134,17 +4153,116 @@ static JSValue js_camera_blit(JSContext *ctx, JSValueConst this_val, int argc, J for (int px = 0; px < dw; px++) { int fx = dx + px; if (fx < 0 || fx >= fb->width) continue; - int sx = px * cw / dw; + int sx = (mirror ? dw - 1 - px : px) * cw / dw; if (sx >= cw) sx = cw - 1; - uint8_t g = local[sy * cw + sx]; - fb->pixels[fy * fb->stride + fx] = 0xFF000000u | ((uint32_t)g << 16) | ((uint32_t)g << 8) | g; + if (color) { + fb->pixels[fy * fb->stride + fx] = local_rgb[sy * cw + sx]; + } else { + uint8_t g = local_gray[sy * cw + sx]; + fb->pixels[fy * fb->stride + fx] = 0xFF000000u | ((uint32_t)g << 16) | ((uint32_t)g << 8) | g; + } } } - free(local); + free(local_rgb); + free(local_gray); return JS_TRUE; } +// --------------------------------------------------------------------------- +// Continuous camera streaming — system.cameraStart() / system.cameraStop() +// Powers the 'cap' piece: opens the webcam and grabs frames (~30fps) into +// the shared display buffers until stopped. Mutually exclusive with QR +// scanning, which owns the same ACCamera. +// --------------------------------------------------------------------------- + +static void *cam_stream_thread_fn(void *arg) { + ACRuntime *rt = (ACRuntime *)arg; + ACCamera *cam = &rt->camera; + + if (camera_open(cam) < 0) { + ac_log("[camera] stream open failed: %s\n", cam->scan_error); + rt->cam_stream_active = 0; + rt->cam_stream_running = 0; + return NULL; + } + + while (rt->cam_stream_active) { + camera_grab(cam); // EAGAIN just means no frame yet — keep pacing + usleep(33000); // ~30fps + } + + camera_close(cam); + rt->cam_stream_running = 0; + return NULL; +} + +static JSValue js_camera_start(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; (void)argc; (void)argv; (void)ctx; + if (!current_rt) return JS_FALSE; + // Already streaming, or QR scan owns the camera + if (current_rt->cam_stream_active || current_rt->cam_stream_running) return JS_TRUE; + if (current_rt->qr_scan_active) return JS_FALSE; + + current_rt->camera.scan_error[0] = 0; + current_rt->cam_stream_active = 1; + current_rt->cam_stream_running = 1; + if (pthread_create(¤t_rt->cam_stream_thread, NULL, + cam_stream_thread_fn, current_rt) != 0) { + current_rt->cam_stream_active = 0; + current_rt->cam_stream_running = 0; + return JS_FALSE; + } + pthread_detach(current_rt->cam_stream_thread); + ac_log("[camera] stream started\n"); + return JS_TRUE; +} + +static JSValue js_camera_stop(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; (void)argc; (void)argv; (void)ctx; + if (!current_rt) return JS_UNDEFINED; + current_rt->cam_stream_active = 0; + ac_log("[camera] stream stopped\n"); + return JS_UNDEFINED; +} + +// system.cameraReady() -> bool — true once a display frame has landed +static JSValue js_camera_ready(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; (void)argc; (void)argv; + if (!current_rt) return JS_FALSE; + return JS_NewBool(ctx, current_rt->camera.display_ready ? 1 : 0); +} + +// system.cameraError() -> string ("" while healthy/opening) +static JSValue js_camera_error(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; (void)argc; (void)argv; + if (!current_rt) return JS_NewString(ctx, ""); + // Only report once the stream thread has given up + if (current_rt->cam_stream_active || current_rt->cam_stream_running) + return JS_NewString(ctx, ""); + return JS_NewString(ctx, current_rt->camera.scan_error); +} + +// --------------------------------------------------------------------------- +// Tape control — system.tapeStart() / system.tapeStop() +// Thin wrappers over ac_tape_start/stop in ac-native.c (same code path as +// the PrintScreen key toggle: MP4 to /mnt/tapes + background cloud upload). +// Called quiet so recordings don't open with the "tape rolling" announce. +// --------------------------------------------------------------------------- + +extern int ac_tape_start(int quiet); +extern int ac_tape_stop(int quiet); + +static JSValue js_tape_start(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; (void)argc; (void)argv; + return JS_NewBool(ctx, ac_tape_start(1) == 0); +} + +static JSValue js_tape_stop(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; (void)argc; (void)argv; + return JS_NewBool(ctx, ac_tape_stop(1) == 0); +} + // --------------------------------------------------------------------------- // system.udp — Raw UDP fairy point co-presence // --------------------------------------------------------------------------- @@ -7006,7 +7124,15 @@ static JSValue build_system_obj(JSContext *ctx) { // QR camera scanning — system.scanQR() / system.scanQRStop() JS_SetPropertyStr(ctx, sys, "scanQR", JS_NewCFunction(ctx, js_scan_qr, "scanQR", 0)); JS_SetPropertyStr(ctx, sys, "scanQRStop", JS_NewCFunction(ctx, js_scan_qr_stop, "scanQRStop", 0)); - JS_SetPropertyStr(ctx, sys, "cameraBlit", JS_NewCFunction(ctx, js_camera_blit, "cameraBlit", 4)); + JS_SetPropertyStr(ctx, sys, "cameraBlit", JS_NewCFunction(ctx, js_camera_blit, "cameraBlit", 5)); + + // Continuous camera streaming + tape control — the 'cap' piece + JS_SetPropertyStr(ctx, sys, "cameraStart", JS_NewCFunction(ctx, js_camera_start, "cameraStart", 0)); + JS_SetPropertyStr(ctx, sys, "cameraStop", JS_NewCFunction(ctx, js_camera_stop, "cameraStop", 0)); + JS_SetPropertyStr(ctx, sys, "cameraReady", JS_NewCFunction(ctx, js_camera_ready, "cameraReady", 0)); + JS_SetPropertyStr(ctx, sys, "cameraError", JS_NewCFunction(ctx, js_camera_error, "cameraError", 0)); + JS_SetPropertyStr(ctx, sys, "tapeStart", JS_NewCFunction(ctx, js_tape_start, "tapeStart", 0)); + JS_SetPropertyStr(ctx, sys, "tapeStop", JS_NewCFunction(ctx, js_tape_stop, "tapeStop", 0)); JS_SetPropertyStr(ctx, sys, "qrPending", JS_NewBool(ctx, current_rt ? current_rt->qr_scan_active : 0)); // Deliver QR result one-shot during sim phase diff --git a/fedac/native/src/js-bindings.h b/fedac/native/src/js-bindings.h index 4eaae3885..ecdfe9d39 100644 --- a/fedac/native/src/js-bindings.h +++ b/fedac/native/src/js-bindings.h @@ -88,6 +88,13 @@ typedef struct { pthread_t qr_thread; volatile int qr_thread_running; + // Continuous camera streaming for the 'cap' piece (system.cameraStart). + // Shares rt->camera with QR scanning — the two modes are mutually + // exclusive; whichever starts first owns the device until stopped. + volatile int cam_stream_active; // 1 = keep grabbing frames + pthread_t cam_stream_thread; + volatile int cam_stream_running; // 1 while the thread is alive + // Piece navigation (system.jump) volatile int jump_requested; // 1 = JS called system.jump() char jump_target[128]; // piece name, e.g. "prompt" or "notepat" -- 2.51.2