Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

macos: jump() polling + boot-animation prelude \u2192 piece handoff

Two pieces of plumbing that make the macOS host feel like a real AC
session instead of a single-piece launcher:

1. piece_pending_jump(): reads globalThis.__pending_jump (set by the
prompt's system.jump() or a top-level jump()), clears the slot,
returns the requested piece name. main.c polls this between frames
and swaps pieces \u2014 destroys the old PieceCtx, loads <name>.mjs
from the current piece's directory, reframes/boots the new one.
Colon params strip cleanly ("clock:cdefg" \u2192 clock); @handle
pieces log and no-op for now (needs net).

2. AC_BOOT_ANIM=1 prelude: before piece_load(), the host runs the 120
shared boot_anim frames through the same SDL texture/present path
the piece uses. Handle / city / hour / title_scale / title reuse
the AC_SHOT_* env surface so a single config drives screenshots,
recordings, and live boots. Events drain during the prelude so the
window doesn't go unresponsive.

Verified: a test piece that calls system.jump("notepat") on boot
triggers the swap and notepat's audio starts emitting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+241
+217
fedac/native/macos/main.c
··· 377 377 return ok ? 0 : 1; 378 378 } 379 379 380 + // Record mode — dump every boot-animation frame as a PNG into a directory. 381 + // Pairs with ffmpeg to turn the sequence into an mp4/mkv. All AC_SHOT_* 382 + // config applies (handle, city, hour, density, title scale, panel, 383 + // badges…); only the output path semantics differ. 384 + // 385 + // Extra knob: AC_RECORD_HOLD_FRAMES extends the last frame so the TTS 386 + // greeting (which runs longer than the 2 s animation) has time to finish. 387 + // Default 0 — add 60 frames (1 s @ 60 fps) of held final frame per second 388 + // of pad you want. 389 + static int run_record_mode(void) { 390 + const char *out_dir = getenv("AC_RECORD_DIR"); 391 + if (!out_dir || !out_dir[0]) return 0; 392 + 393 + int out_w = getenv("AC_SHOT_W") ? atoi(getenv("AC_SHOT_W")) : 1280; 394 + int out_h = getenv("AC_SHOT_H") ? atoi(getenv("AC_SHOT_H")) : 800; 395 + int density = getenv("AC_SHOT_DENSITY") ? atoi(getenv("AC_SHOT_DENSITY")) : 2; 396 + if (density < 1) density = 1; 397 + if (density > 8) density = 8; 398 + int fb_w = out_w / density; if (fb_w < 32) fb_w = 32; 399 + int fb_h = out_h / density; if (fb_h < 32) fb_h = 32; 400 + int hold = getenv("AC_RECORD_HOLD_FRAMES") 401 + ? atoi(getenv("AC_RECORD_HOLD_FRAMES")) : 0; 402 + if (hold < 0) hold = 0; 403 + if (hold > 600) hold = 600; // 10 s cap 404 + 405 + // Title + title_scale: same logic as screenshot mode. Duplicated 406 + // rather than factored because both are short and the env-var surface 407 + // keeps the code site readable. 408 + char title_buf[128]; 409 + const char *t_env = getenv("AC_SHOT_TITLE"); 410 + const char *h_env = getenv("AC_SHOT_HANDLE"); 411 + if (t_env && t_env[0]) { 412 + snprintf(title_buf, sizeof title_buf, "%s", t_env); 413 + } else if (h_env && h_env[0]) { 414 + const char *h = (h_env[0] == '@') ? h_env + 1 : h_env; 415 + snprintf(title_buf, sizeof title_buf, "hi @%s", h); 416 + } else { 417 + snprintf(title_buf, sizeof title_buf, "hi"); 418 + } 419 + const char *city = getenv("AC_SHOT_CITY"); 420 + if (!city || !city[0]) city = "Los Angeles"; 421 + int hour = getenv("AC_SHOT_HOUR") ? atoi(getenv("AC_SHOT_HOUR")) : 10; 422 + 423 + int title_scale = 0; 424 + const char *ts_env = getenv("AC_SHOT_TITLE_SCALE"); 425 + if (ts_env && ts_env[0]) { 426 + title_scale = atoi(ts_env); 427 + } else { 428 + int title_len = 0; 429 + for (const char *p = title_buf; *p; p++) title_len++; 430 + if (title_len < 1) title_len = 1; 431 + int target_px = fb_w * 55 / 100; 432 + int auto_s = target_px / (title_len * 4); 433 + if (auto_s < 3) auto_s = 3; 434 + if (auto_s > 8) auto_s = 8; 435 + title_scale = auto_s; 436 + } 437 + 438 + BootAnimConfig cfg = { 439 + .title = title_buf, 440 + .city = city, 441 + .title_colors = NULL, 442 + .title_colors_len = 0, 443 + .hour = hour, 444 + .git_hash = getenv("AC_SHOT_GIT_HASH"), 445 + .build_ts = getenv("AC_SHOT_BUILD_TS"), 446 + .build_name = getenv("AC_SHOT_BUILD_NAME"), 447 + .driver_name = getenv("AC_SHOT_DRIVER"), 448 + .is_new_version = getenv("AC_SHOT_FRESH") ? 1 : 0, 449 + .show_install = getenv("AC_SHOT_INSTALL") ? 1 : 0, 450 + .is_installed = getenv("AC_SHOT_INSTALLED") ? 1 : 0, 451 + .has_claude_badge = getenv("AC_SHOT_CLAUDE") ? 1 : 0, 452 + .has_github_badge = getenv("AC_SHOT_GITHUB") ? 1 : 0, 453 + .title_scale = title_scale, 454 + }; 455 + 456 + ACFramebuffer *fb = fb_create(fb_w, fb_h); 457 + if (!fb) { fprintf(stderr, "[record] fb_create failed\n"); return 1; } 458 + ACGraph g; 459 + graph_init(&g, fb); 460 + font_init(); 461 + 462 + BootAnimState state = {0}; 463 + int total = BOOT_ANIM_FRAMES + hold; 464 + for (int f = 0; f < total; f++) { 465 + // Hold frames freeze at the last real animation frame so any 466 + // padding for TTS length doesn't show a shrinking time bar or 467 + // re-fading subtitle — the picture just stays. 468 + int anim_f = f < BOOT_ANIM_FRAMES ? f : BOOT_ANIM_FRAMES - 1; 469 + boot_anim_render_frame(&g, fb, anim_f, &cfg, &state); 470 + 471 + uint32_t *out_pixels = fb->pixels; 472 + uint32_t *scaled = NULL; 473 + int stride = fb->stride, w = fb->width, h = fb->height; 474 + if (density > 1) { 475 + scaled = upscale_nn(fb->pixels, fb->width, fb->height, density); 476 + if (!scaled) { fprintf(stderr, "[record] upscale_nn failed at f=%d\n", f); break; } 477 + out_pixels = scaled; w = fb->width * density; h = fb->height * density; stride = w; 478 + } 479 + char path[1024]; 480 + snprintf(path, sizeof path, "%s/frame_%05d.png", out_dir, f); 481 + if (!png_write_argb(path, out_pixels, w, h, stride)) { 482 + fprintf(stderr, "[record] write failed at f=%d\n", f); 483 + free(scaled); break; 484 + } 485 + free(scaled); 486 + } 487 + fprintf(stderr, "[record] %d frames → %s (anim=%d hold=%d, %dx%d)\n", 488 + total, out_dir, BOOT_ANIM_FRAMES, hold, fb->width * density, fb->height * density); 489 + fb_destroy(fb); 490 + return 0; 491 + } 492 + 380 493 int main(int argc, char **argv) { 381 494 // Screenshot mode short-circuits everything else — no SDL, no piece, 382 495 // just one frame of the boot animation to a PNG. Gated on AC_SHOT_PNG 383 496 // so normal launches (and `make app`) behave exactly as before. 384 497 if (getenv("AC_SHOT_PNG")) { 385 498 return run_screenshot_mode(); 499 + } 500 + if (getenv("AC_RECORD_DIR")) { 501 + return run_record_mode(); 386 502 } 387 503 388 504 // --test-tone: exercise the audio engine only; no window, no piece. ··· 502 618 .stride = fb_w, 503 619 }; 504 620 if (!fb.pixels) { fprintf(stderr, "fb alloc failed\n"); return 1; } 621 + 622 + // Boot animation prelude — 2 s of the shared boot_anim renderer before 623 + // the piece loads. Gated on AC_BOOT_ANIM (default off so existing 624 + // notepat launches stay snappy; the demo pipeline exports it so every 625 + // session opens with "hi @handle. enjoy <city>!"). Hour + handle + 626 + // city pull from the same AC_SHOT_* vars the screenshot/record modes 627 + // use, so a single config covers both worlds. 628 + if (getenv("AC_BOOT_ANIM")) { 629 + ACFramebuffer bafb = { 630 + .pixels = fb.pixels, .width = fb.width, 631 + .height = fb.height, .stride = fb.stride, 632 + }; 633 + ACGraph bg; 634 + graph_init(&bg, &bafb); 635 + font_init(); 636 + 637 + char title_buf[128]; 638 + const char *h_env = getenv("AC_SHOT_HANDLE"); 639 + const char *t_env = getenv("AC_SHOT_TITLE"); 640 + if (t_env && t_env[0]) snprintf(title_buf, sizeof title_buf, "%s", t_env); 641 + else if (h_env && h_env[0]) { 642 + const char *h = (h_env[0] == '@') ? h_env + 1 : h_env; 643 + snprintf(title_buf, sizeof title_buf, "hi @%s", h); 644 + } else snprintf(title_buf, sizeof title_buf, "hi"); 645 + 646 + BootAnimConfig anim = { 647 + .title = title_buf, 648 + .city = (getenv("AC_SHOT_CITY") && getenv("AC_SHOT_CITY")[0]) 649 + ? getenv("AC_SHOT_CITY") : "Los Angeles", 650 + .hour = getenv("AC_SHOT_HOUR") ? atoi(getenv("AC_SHOT_HOUR")) : 10, 651 + .title_scale = getenv("AC_SHOT_TITLE_SCALE") 652 + ? atoi(getenv("AC_SHOT_TITLE_SCALE")) : 0, 653 + }; 654 + BootAnimState st = {0}; 655 + Uint64 frame_start = SDL_GetTicks(); 656 + int target_ms = 1000 / 60; 657 + for (int f = 0; f < BOOT_ANIM_FRAMES; f++) { 658 + boot_anim_render_frame(&bg, &bafb, f, &anim, &st); 659 + SDL_UpdateTexture(tex, NULL, fb.pixels, fb.stride * (int)sizeof(uint32_t)); 660 + SDL_RenderClear(ren); 661 + SDL_RenderTexture(ren, tex, NULL, NULL); 662 + SDL_RenderPresent(ren); 663 + // Drain events so the OS doesn't mark the window unresponsive. 664 + SDL_Event ev; while (SDL_PollEvent(&ev)) { 665 + if (ev.type == SDL_EVENT_QUIT) goto boot_anim_done; 666 + } 667 + Uint64 now = SDL_GetTicks(); 668 + int elapsed = (int)(now - frame_start); 669 + int want = (f + 1) * target_ms; 670 + if (want > elapsed) SDL_Delay(want - elapsed); 671 + } 672 + boot_anim_done: ; 673 + } 505 674 506 675 PieceCtx *pc = piece_load(piece_path, &fb); 507 676 if (!pc) { ··· 751 920 752 921 piece_sim(pc); 753 922 render_frame(&rctx); 923 + 924 + // Piece-swap: the piece (or any global code) can set 925 + // globalThis.__pending_jump = "<name>". We poll between frames, 926 + // destroy the old ctx, and load the new piece. Target resolves 927 + // against the current piece's directory so prompt→notepat etc. 928 + // stay within fedac/native/pieces/. 929 + char *jump_to = piece_pending_jump(pc); 930 + if (jump_to) { 931 + char parent[1200]; 932 + snprintf(parent, sizeof parent, "%s", piece_path); 933 + char *sl = strrchr(parent, '/'); 934 + if (sl) *sl = 0; else parent[0] = 0; 935 + 936 + // Accept bare "notepat", "notepat:param", "@handle/piece" — for 937 + // this first cut only strip colon params (the prompt passes 938 + // them through); @user lookups aren't supported without net. 939 + char name[256]; 940 + snprintf(name, sizeof name, "%s", jump_to); 941 + char *colon = strchr(name, ':'); 942 + if (colon) *colon = 0; 943 + if (name[0] == '@') { 944 + fprintf(stderr, "[jump] @handle pieces not supported yet (%s)\n", jump_to); 945 + free(jump_to); continue; 946 + } 947 + 948 + char candidate[1600]; 949 + if (parent[0]) snprintf(candidate, sizeof candidate, "%s/%s.mjs", parent, name); 950 + else snprintf(candidate, sizeof candidate, "%s.mjs", name); 951 + 952 + fprintf(stderr, "[jump] %s → %s\n", jump_to, candidate); 953 + free(jump_to); 954 + 955 + PieceCtx *next = piece_load(candidate, &fb); 956 + if (!next) { 957 + fprintf(stderr, "[jump] load failed, staying on current piece\n"); 958 + continue; 959 + } 960 + piece_destroy(pc); 961 + pc = next; 962 + rctx.pc = pc; 963 + // New path becomes the basis for subsequent jumps. 964 + static char path_owned[1600]; 965 + snprintf(path_owned, sizeof path_owned, "%s", candidate); 966 + piece_path = path_owned; 967 + piece_reframe(pc, fb.width, fb.height); 968 + if (overlay) piece_set_overlay(pc, 1); 969 + piece_boot(pc); 970 + } 754 971 } 755 972 756 973 SDL_RemoveEventWatch(resize_watch, &rctx);
+19
fedac/native/macos/piece.c
··· 925 925 JS_FreeValue(pc->jsctx, arg); 926 926 } 927 927 928 + char *piece_pending_jump(PieceCtx *pc) { 929 + if (!pc || !pc->jsctx) return NULL; 930 + JSContext *cx = pc->jsctx; 931 + JSValue global = JS_GetGlobalObject(cx); 932 + JSValue pj = JS_GetPropertyStr(cx, global, "__pending_jump"); 933 + char *out = NULL; 934 + if (JS_IsString(pj)) { 935 + const char *s = JS_ToCString(cx, pj); 936 + if (s) { 937 + out = strdup(s); 938 + JS_FreeCString(cx, s); 939 + } 940 + JS_SetPropertyStr(cx, global, "__pending_jump", JS_UNDEFINED); 941 + } 942 + JS_FreeValue(cx, pj); 943 + JS_FreeValue(cx, global); 944 + return out; 945 + } 946 + 928 947 void piece_boot(PieceCtx *pc) { call_lifecycle_with_api(pc, pc->boot_fn); } 929 948 void piece_paint(PieceCtx *pc) { call_lifecycle_with_api(pc, pc->paint_fn); } 930 949 void piece_sim(PieceCtx *pc) { call_lifecycle_with_api(pc, pc->sim_fn); }
+5
fedac/native/macos/piece.h
··· 51 51 // Teardown. 52 52 void piece_destroy(PieceCtx *ctx); 53 53 54 + // Poll the piece for a pending jump() call. Returns a newly-malloc'd 55 + // string with the target piece name and clears the JS slot so each 56 + // target is only seen once. NULL when no jump is queued. Caller frees. 57 + char *piece_pending_jump(PieceCtx *ctx); 58 + 54 59 #endif