Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

macos: WIP — demo_script + shutdown_anim + main.c/piece.c updates

Snapshot commit to preserve in-progress macOS native work during
branch consolidation. Adds new demo_script.{c,h} + scripts/ dir,
new shutdown_anim.{c,h}, and further updates to main.c/piece.c/piece.h
plus Makefile tweaks.

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

+882 -11
+2 -2
fedac/native/macos/Makefile
··· 35 35 $(error AUDIO must be 'core' or 'sdl') 36 36 endif 37 37 38 - HOST_SRCS := main.c piece.c png_writer.c $(AUDIO_SRC) 38 + HOST_SRCS := main.c piece.c png_writer.c demo_script.c $(AUDIO_SRC) 39 39 HOST_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(HOST_SRCS)) 40 40 # Shared with Linux ac-native: audio synth, boot-screen renderer, and the 41 41 # portable graphics/font/framebuffer stack the boot animation draws into. 42 42 # qrcodegen is a transitive dep of graph.c (for graph_qr) even though the 43 43 # boot animation itself doesn't render QR codes. 44 - SHARED_CORE_SRCS := synth_core.c boot_anim.c graph.c font.c framebuffer.c color.c 44 + SHARED_CORE_SRCS := synth_core.c boot_anim.c shutdown_anim.c graph.c font.c framebuffer.c color.c 45 45 SHARED_QR_SRCS := qrcodegen.c 46 46 SHARED_OBJS := $(patsubst %.c,$(BUILD)/shared-%.o,$(SHARED_CORE_SRCS) $(SHARED_QR_SRCS)) 47 47 LDFLAGS += $(SDL3_LIBS_BASE) $(AUDIO_FRAMEWORKS)
+199
fedac/native/macos/demo_script.c
··· 1 + // demo_script.c — parser for AC Native demo scripts. 2 + #include "demo_script.h" 3 + 4 + #include <stdio.h> 5 + #include <stdlib.h> 6 + #include <string.h> 7 + #include <ctype.h> 8 + #include <errno.h> 9 + 10 + static char *xstrdup(const char *s) { 11 + if (!s) return NULL; 12 + size_t n = strlen(s); 13 + char *out = (char *)malloc(n + 1); 14 + if (!out) return NULL; 15 + memcpy(out, s, n + 1); 16 + return out; 17 + } 18 + 19 + // Trim leading + trailing whitespace in-place, returning a pointer into 20 + // the buffer (which may be the original start or shifted forward). 21 + static char *trim(char *s) { 22 + while (*s && isspace((unsigned char)*s)) s++; 23 + char *end = s + strlen(s); 24 + while (end > s && isspace((unsigned char)end[-1])) { *--end = 0; } 25 + return s; 26 + } 27 + 28 + // Parse "[M:SS(.mmm)?]" at the start of line. Returns seconds and writes 29 + // the tail pointer to *rest (past the closing bracket + whitespace). 30 + // Returns -1 on parse failure. 31 + static double parse_timestamp(const char *line, const char **rest) { 32 + if (*line != '[') return -1; 33 + line++; 34 + char *endp; 35 + long mm = strtol(line, &endp, 10); 36 + if (endp == line || *endp != ':') return -1; 37 + line = endp + 1; 38 + double ss = strtod(line, &endp); 39 + if (endp == line) return -1; 40 + line = endp; 41 + if (*line != ']') return -1; 42 + line++; 43 + while (*line && isspace((unsigned char)*line)) line++; 44 + if (rest) *rest = line; 45 + return (double)mm * 60.0 + ss; 46 + } 47 + 48 + static void push_event(DemoScript *s, DemoEvent ev) { 49 + if (s->n_events == s->cap_events) { 50 + int ncap = s->cap_events ? s->cap_events * 2 : 32; 51 + DemoEvent *ne = (DemoEvent *)realloc(s->events, ncap * sizeof(DemoEvent)); 52 + if (!ne) { free(ev.arg); return; } 53 + s->events = ne; 54 + s->cap_events = ncap; 55 + } 56 + s->events[s->n_events++] = ev; 57 + } 58 + 59 + // Front-matter line: "# key: value" — consumed only while no non-comment 60 + // event has appeared yet. Returns 1 if handled, 0 otherwise. 61 + static int try_front_matter(DemoScript *s, const char *line) { 62 + if (line[0] != '#') return 0; 63 + // Skip the leading '#' + whitespace; find ':' to decide if it's a kv. 64 + const char *p = line + 1; 65 + while (*p && isspace((unsigned char)*p)) p++; 66 + const char *colon = strchr(p, ':'); 67 + if (!colon) return 1; // plain comment, consume but do nothing 68 + char key[64]; 69 + size_t klen = (size_t)(colon - p); 70 + if (klen >= sizeof(key)) return 1; 71 + memcpy(key, p, klen); 72 + key[klen] = 0; 73 + char *tk = trim(key); 74 + const char *val_start = colon + 1; 75 + while (*val_start && isspace((unsigned char)*val_start)) val_start++; 76 + // Strip trailing newline. 77 + char val[512]; 78 + strncpy(val, val_start, sizeof(val) - 1); 79 + val[sizeof(val) - 1] = 0; 80 + char *tv = trim(val); 81 + 82 + if (!strcmp(tk, "title")) { free(s->title); s->title = xstrdup(tv); } 83 + else if (!strcmp(tk, "voice")) { free(s->voice); s->voice = xstrdup(tv); } 84 + else if (!strcmp(tk, "rate")) s->rate = atoi(tv); 85 + else if (!strcmp(tk, "handle")) { free(s->handle); s->handle = xstrdup(tv); } 86 + else if (!strcmp(tk, "city")) { free(s->city); s->city = xstrdup(tv); } 87 + else if (!strcmp(tk, "hour")) s->hour = atoi(tv); 88 + else if (!strcmp(tk, "duration")) s->duration_sec = atof(tv); 89 + else if (!strcmp(tk, "subtitles")) s->subtitles = (!strcmp(tv, "true") || atoi(tv)); 90 + else if (!strcmp(tk, "narration")) s->narration = (!strcmp(tv, "true") || atoi(tv)); 91 + else if (!strcmp(tk, "window")) { 92 + int w = 0, h = 0; 93 + if (sscanf(tv, "%dx%d", &w, &h) == 2) { s->win_w = w; s->win_h = h; } 94 + } 95 + return 1; 96 + } 97 + 98 + static DemoEventKind kind_from_token(const char *tok) { 99 + if (!strcmp(tok, "key")) return DEMO_EV_KEY; 100 + if (!strcmp(tok, "say")) return DEMO_EV_SAY; 101 + if (!strcmp(tok, "caption")) return DEMO_EV_CAPTION; 102 + if (!strcmp(tok, "wait")) return DEMO_EV_WAIT; 103 + if (!strcmp(tok, "env")) return DEMO_EV_ENV; 104 + return -1; 105 + } 106 + 107 + DemoScript *demo_script_load(const char *path) { 108 + FILE *f = fopen(path, "r"); 109 + if (!f) { 110 + fprintf(stderr, "[demo] cannot read %s: %s\n", path, strerror(errno)); 111 + return NULL; 112 + } 113 + DemoScript *s = (DemoScript *)calloc(1, sizeof(DemoScript)); 114 + if (!s) { fclose(f); return NULL; } 115 + // Defaults 116 + s->voice = xstrdup("Samantha"); 117 + s->rate = 160; 118 + s->hour = 13; 119 + s->win_w = 1280; 120 + s->win_h = 800; 121 + s->subtitles = 1; 122 + s->narration = 1; 123 + 124 + char buf[2048]; 125 + int saw_event = 0; 126 + while (fgets(buf, sizeof(buf), f)) { 127 + // Strip trailing newline/\r. 128 + size_t L = strlen(buf); 129 + while (L > 0 && (buf[L-1] == '\n' || buf[L-1] == '\r')) buf[--L] = 0; 130 + char *line = trim(buf); 131 + if (!*line) continue; 132 + 133 + // Front-matter (# key: value) only valid before the first event. 134 + if (!saw_event && line[0] == '#') { 135 + try_front_matter(s, line); 136 + continue; 137 + } 138 + // Comment after events start — skip. 139 + if (line[0] == '#') continue; 140 + 141 + if (line[0] != '[') continue; // ignore stray lines 142 + const char *after_ts = NULL; 143 + double t = parse_timestamp(line, &after_ts); 144 + if (t < 0) { 145 + fprintf(stderr, "[demo] skip bad line: %s\n", line); 146 + continue; 147 + } 148 + saw_event = 1; 149 + 150 + // Next token is the KIND. 151 + const char *p = after_ts; 152 + while (*p && isspace((unsigned char)*p)) p++; 153 + const char *kstart = p; 154 + while (*p && !isspace((unsigned char)*p)) p++; 155 + size_t klen = (size_t)(p - kstart); 156 + char kind_tok[32]; 157 + if (klen == 0 || klen >= sizeof(kind_tok)) continue; 158 + memcpy(kind_tok, kstart, klen); 159 + kind_tok[klen] = 0; 160 + while (*p && isspace((unsigned char)*p)) p++; 161 + 162 + DemoEventKind k = kind_from_token(kind_tok); 163 + if ((int)k < 0) { 164 + fprintf(stderr, "[demo] unknown kind '%s'\n", kind_tok); 165 + continue; 166 + } 167 + DemoEvent ev = { .t = t, .kind = k, .arg = NULL }; 168 + if (k != DEMO_EV_WAIT && *p) ev.arg = xstrdup(p); 169 + push_event(s, ev); 170 + } 171 + fclose(f); 172 + 173 + // Sort events by t (simple insertion sort; demo lists are small). 174 + for (int i = 1; i < s->n_events; i++) { 175 + DemoEvent cur = s->events[i]; 176 + int j = i - 1; 177 + while (j >= 0 && s->events[j].t > cur.t) { 178 + s->events[j+1] = s->events[j]; 179 + j--; 180 + } 181 + s->events[j+1] = cur; 182 + } 183 + return s; 184 + } 185 + 186 + void demo_script_free(DemoScript *s) { 187 + if (!s) return; 188 + free(s->title); free(s->voice); free(s->handle); free(s->city); 189 + for (int i = 0; i < s->n_events; i++) free(s->events[i].arg); 190 + free(s->events); 191 + free(s); 192 + } 193 + 194 + double demo_script_duration(const DemoScript *s) { 195 + if (!s || s->n_events == 0) return s ? s->duration_sec : 0; 196 + double last = s->events[s->n_events - 1].t; 197 + double inferred = last + 2.0; 198 + return (s->duration_sec > inferred) ? s->duration_sec : inferred; 199 + }
+58
fedac/native/macos/demo_script.h
··· 1 + // demo_script.h — parser for AC Native demo scripts. 2 + // See scripts/DEMO_SCRIPT_FORMAT.md for the format. 3 + // 4 + // A demo script is a single Markdown file with YAML-ish front-matter 5 + // (# key: value lines starting with '#') followed by timestamped 6 + // events of the form: 7 + // 8 + // [M:SS.mmm] KIND ARG 9 + // 10 + // where KIND is one of: key | say | caption | wait | env. 11 + // Absolute timestamps in seconds-from-start. Everything the parser 12 + // returns is owned by the caller and freed via demo_script_free(). 13 + #ifndef AC_DEMO_SCRIPT_H 14 + #define AC_DEMO_SCRIPT_H 15 + 16 + #include <stddef.h> 17 + 18 + typedef enum { 19 + DEMO_EV_KEY, 20 + DEMO_EV_SAY, 21 + DEMO_EV_CAPTION, // empty arg → clear current caption 22 + DEMO_EV_WAIT, // no-op; just an anchor 23 + DEMO_EV_ENV, // arg = "NAME=value" 24 + } DemoEventKind; 25 + 26 + typedef struct { 27 + double t; // seconds from start 28 + DemoEventKind kind; 29 + char *arg; // malloc'd; NULL for WAIT 30 + } DemoEvent; 31 + 32 + typedef struct { 33 + char *title; 34 + char *voice; // default "Samantha" 35 + int rate; // default 160 (wpm for `say`) 36 + char *handle; 37 + char *city; 38 + int hour; 39 + int win_w, win_h; 40 + double duration_sec; // 0 = auto (inferred from last event + 2s) 41 + int subtitles; 42 + int narration; 43 + 44 + DemoEvent *events; 45 + int n_events; 46 + int cap_events; 47 + } DemoScript; 48 + 49 + // Parse a demo script from disk. Returns NULL on error (prints reason 50 + // to stderr). Caller must demo_script_free(). 51 + DemoScript *demo_script_load(const char *path); 52 + 53 + void demo_script_free(DemoScript *s); 54 + 55 + // Return total demo duration — max(duration_sec, last_event_t + 2). 56 + double demo_script_duration(const DemoScript *s); 57 + 58 + #endif
+258 -8
fedac/native/macos/main.c
··· 17 17 #include "audio.h" 18 18 #include "png_writer.h" 19 19 #include "boot_anim.h" 20 + #include "shutdown_anim.h" 20 21 #include "graph.h" 21 22 #include "font.h" 22 23 #include "framebuffer.h" 24 + #include "demo_script.h" 23 25 24 26 // Initial window size in logical points. The framebuffer is win / DENSITY, 25 27 // so 640×480 @ d=2 yields a 320×240 canvas — a classic retro resolution ··· 211 213 // Flags the tray / hotkey callbacks write; the main loop polls them. 212 214 static volatile int g_toggle_visible = 0; 213 215 static volatile int g_quit_requested = 0; 216 + // Set by piece.c's poweroff() JS binding. Main loop runs the shutdown 217 + // animation when this flips, then exits. exposed via extern. 218 + volatile int g_poweroff_requested = 0; 214 219 215 220 static void SDLCALL tray_show_hide(void *ud, SDL_TrayEntry *entry) { 216 221 (void)ud; (void)entry; ··· 526 531 527 532 char bundle_piece[1200], bundle_lib[1200]; 528 533 const char *piece_path = NULL; 529 - if (argc > 1) { 530 - piece_path = argv[1]; 531 - } else { 534 + const char *demo_path = NULL; 535 + // Parse flags — supports `--demo <path>` preceded or followed by the 536 + // piece path. Remaining positional arg (first one that doesn't start 537 + // with '--') is the piece to load. 538 + for (int i = 1; i < argc; i++) { 539 + if (strcmp(argv[i], "--demo") == 0 && i + 1 < argc) { 540 + demo_path = argv[++i]; 541 + } else if (argv[i][0] != '-' || argv[i][1] == 0) { 542 + if (!piece_path) piece_path = argv[i]; 543 + } 544 + } 545 + if (!piece_path) { 532 546 piece_path = detect_bundle(bundle_piece, sizeof(bundle_piece), 533 547 bundle_lib, sizeof(bundle_lib)); 534 548 if (!piece_path) piece_path = "../test-pieces/hello.mjs"; 535 549 } 536 550 551 + // If --demo is set, parse the script up front so we can apply its 552 + // front-matter (handle, city, window, duration) before SDL_Init. 553 + DemoScript *demo = NULL; 554 + if (demo_path) { 555 + demo = demo_script_load(demo_path); 556 + if (!demo) return 2; 557 + fprintf(stderr, "[demo] loaded %s — %d events, %.1fs\n", 558 + demo_path, demo->n_events, demo_script_duration(demo)); 559 + // Front-matter → env vars (only if caller hasn't set them). 560 + if (demo->handle && !getenv("AC_SHOT_HANDLE")) 561 + setenv("AC_SHOT_HANDLE", demo->handle, 1); 562 + if (demo->city && !getenv("AC_SHOT_CITY")) 563 + setenv("AC_SHOT_CITY", demo->city, 1); 564 + if (!getenv("AC_SHOT_HOUR")) { 565 + char buf[16]; snprintf(buf, sizeof buf, "%d", demo->hour); 566 + setenv("AC_SHOT_HOUR", buf, 1); 567 + } 568 + if (!getenv("AC_WIN_W")) { 569 + char buf[16]; snprintf(buf, sizeof buf, "%d", demo->win_w); 570 + setenv("AC_WIN_W", buf, 1); 571 + } 572 + if (!getenv("AC_WIN_H")) { 573 + char buf[16]; snprintf(buf, sizeof buf, "%d", demo->win_h); 574 + setenv("AC_WIN_H", buf, 1); 575 + } 576 + // Auto-exit after the demo finishes if AC_HEADLESS_MS isn't set. 577 + if (!getenv("AC_HEADLESS_MS")) { 578 + char buf[32]; 579 + snprintf(buf, sizeof buf, "%d", 580 + (int)(demo_script_duration(demo) * 1000)); 581 + setenv("AC_HEADLESS_MS", buf, 1); 582 + } 583 + } 584 + 537 585 if (!SDL_Init(SDL_INIT_VIDEO)) { 538 586 fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); 539 587 return 1; ··· 639 687 const char *early_frame_dir = getenv("AC_FRAME_DUMP_DIR"); 640 688 int early_frame_idx = 0; 641 689 690 + // Demo-script wall-clock anchor. Events dispatch from this tick so the 691 + // timeline covers boot animation + piece life + shutdown — captions, 692 + // say(), key injection all share one clock. Main loop re-uses it. 693 + Uint64 start_tick = SDL_GetTicks(); 694 + int demo_cur = 0; 695 + char active_caption[512] = {0}; // current burn-in subtitle, or "" 696 + 642 697 // Boot animation prelude — 2 s of the shared boot_anim renderer before 643 698 // the piece loads. Gated on AC_BOOT_ANIM (default off so existing 644 699 // notepat launches stay snappy; the demo pipeline exports it so every ··· 676 731 int target_ms = 1000 / 60; 677 732 for (int f = 0; f < BOOT_ANIM_FRAMES; f++) { 678 733 boot_anim_render_frame(&bg, &bafb, f, &anim, &st); 734 + // Demo-script dispatch during boot (captions + say, no keys 735 + // since no piece loaded yet). Key events are LEFT IN PLACE 736 + // for the main piece loop to dispatch once the piece is up — 737 + // without this guard, a key whose timestamp happens to land 738 + // on the last boot frame gets silently consumed (the piece 739 + // never sees it, so e.g. the first 'n' of 'notepat' vanishes). 740 + if (demo) { 741 + double now_sec = (SDL_GetTicks() - start_tick) / 1000.0; 742 + while (demo_cur < demo->n_events && 743 + now_sec >= demo->events[demo_cur].t) { 744 + DemoEvent *ev = &demo->events[demo_cur]; 745 + if (ev->kind == DEMO_EV_KEY) { 746 + // Stop here — leave this (and any later events) for 747 + // the main loop. Do NOT advance demo_cur. 748 + break; 749 + } 750 + if (ev->kind == DEMO_EV_CAPTION) { 751 + if (ev->arg && ev->arg[0]) 752 + snprintf(active_caption, sizeof active_caption, "%s", ev->arg); 753 + else active_caption[0] = 0; 754 + } else if (ev->kind == DEMO_EV_SAY && ev->arg && ev->arg[0]) { 755 + // Implicit caption: whatever is spoken is also shown. 756 + snprintf(active_caption, sizeof active_caption, "%s", ev->arg); 757 + char cmd[1024]; 758 + snprintf(cmd, sizeof cmd, "say -v %s -r %d \"%s\" &", 759 + demo->voice, demo->rate, ev->arg); 760 + system(cmd); 761 + } 762 + demo_cur++; 763 + } 764 + } 765 + // Burn-in caption overlay (system-wide — works during boot too). 766 + if (active_caption[0]) { 767 + int tw = font_measure_matrix(active_caption, 1); 768 + int th = 11; 769 + int pad_x = 6, pad_y = 3; 770 + int bw = tw + pad_x * 2, bh = th + pad_y * 2; 771 + int bx = (bafb.width - bw) / 2; 772 + int by = bafb.height - bh - 6; 773 + graph_ink(&bg, (ACColor){20, 15, 10, 210}); 774 + graph_box(&bg, bx, by, bw, bh, 1); 775 + graph_ink(&bg, (ACColor){228, 216, 188, 255}); 776 + font_draw_matrix(&bg, active_caption, bx + pad_x, by + pad_y, 1); 777 + } 679 778 SDL_UpdateTexture(tex, NULL, fb.pixels, fb.stride * (int)sizeof(uint32_t)); 680 779 SDL_RenderClear(ren); 681 780 SDL_RenderTexture(ren, tex, NULL, NULL); ··· 915 1014 if (au) audio_wav_start(au, wav_out); 916 1015 } 917 1016 918 - Uint64 start_tick = SDL_GetTicks(); 919 - 920 1017 int running = 1; 921 1018 int hidden = 0; 1019 + // Bye-animation state: when g_poweroff_requested flips, play 90 frames 1020 + // of red/white strobe with "bye @handle" before exiting. Matches the 1021 + // Linux-path draw_shutdown_anim() in src/ac-native.c. 1022 + int bye_frames_played = 0; 1023 + const int bye_total_frames = 90; 922 1024 while (running) { 923 1025 if (g_quit_requested) { running = 0; break; } 924 1026 if (g_toggle_visible) { ··· 959 1061 seq[seq_cur].hold_ms); 960 1062 seq_cur++; 961 1063 } 1064 + // Demo script dispatch — events run in order, absolute timestamps. 1065 + if (demo) { 1066 + double now_sec = (SDL_GetTicks() - start_tick) / 1000.0; 1067 + while (demo_cur < demo->n_events && 1068 + now_sec >= demo->events[demo_cur].t) { 1069 + DemoEvent *ev = &demo->events[demo_cur]; 1070 + switch (ev->kind) { 1071 + case DEMO_EV_KEY: { 1072 + if (ev->arg) { 1073 + PieceEvent pd = {0}; 1074 + snprintf(pd.key, sizeof pd.key, "%s", ev->arg); 1075 + snprintf(pd.type, sizeof pd.type, "keyboard:down:%s", ev->arg); 1076 + piece_act(pc, &pd); 1077 + PieceEvent pu = {0}; 1078 + snprintf(pu.key, sizeof pu.key, "%s", ev->arg); 1079 + snprintf(pu.type, sizeof pu.type, "keyboard:up:%s", ev->arg); 1080 + piece_act(pc, &pu); 1081 + fprintf(stderr, "[demo] key %s @ %.2fs\n", ev->arg, ev->t); 1082 + } 1083 + break; 1084 + } 1085 + case DEMO_EV_CAPTION: { 1086 + if (ev->arg && ev->arg[0]) { 1087 + snprintf(active_caption, sizeof active_caption, 1088 + "%s", ev->arg); 1089 + } else { 1090 + active_caption[0] = 0; 1091 + } 1092 + fprintf(stderr, "[demo] caption @ %.2fs — %s\n", 1093 + ev->t, ev->arg ? ev->arg : "(clear)"); 1094 + break; 1095 + } 1096 + case DEMO_EV_SAY: { 1097 + // Fire-and-forget — let `say` play through speakers 1098 + // synchronously with the visual. Audio tap won't 1099 + // pick this up (it captures piece output, not 1100 + // system audio), so for a clean recording the post- 1101 + // mix step at tools/vo-pipeline.mjs re-reads this 1102 + // same demo file and burns the TTS into the final 1103 + // video. 1104 + if (ev->arg && ev->arg[0]) { 1105 + // Implicit caption: whatever is spoken is also 1106 + // shown as a subtitle. Explicit [caption ...] at 1107 + // the same timestamp overwrites this. 1108 + snprintf(active_caption, sizeof active_caption, 1109 + "%s", ev->arg); 1110 + char cmd[1024]; 1111 + snprintf(cmd, sizeof cmd, 1112 + "say -v %s -r %d \"%s\" &", 1113 + demo->voice, demo->rate, ev->arg); 1114 + system(cmd); 1115 + fprintf(stderr, "[demo] say @ %.2fs — %s\n", 1116 + ev->t, ev->arg); 1117 + } 1118 + break; 1119 + } 1120 + case DEMO_EV_ENV: { 1121 + if (ev->arg) { 1122 + char *eq = strchr(ev->arg, '='); 1123 + if (eq) { 1124 + *eq = 0; 1125 + setenv(ev->arg, eq + 1, 1); 1126 + *eq = '='; 1127 + } 1128 + } 1129 + break; 1130 + } 1131 + case DEMO_EV_WAIT: break; 1132 + } 1133 + demo_cur++; 1134 + } 1135 + } 1136 + 962 1137 // Drain pending keyups whose hold window has elapsed. 963 1138 int now_rel = (int)(SDL_GetTicks() - start_tick); 964 1139 for (int i = 0; i < ups_len; i++) { ··· 998 1173 } 999 1174 SDL_Event ev; 1000 1175 while (SDL_PollEvent(&ev)) { 1176 + // Demo mode takes over input — swallow user keys / mouse / 1177 + // touch so they don't interfere with the scripted timeline. 1178 + // Quit and resize still count (window X / Cmd-Q / drag). 1179 + if (demo && (ev.type == SDL_EVENT_KEY_DOWN || 1180 + ev.type == SDL_EVENT_KEY_UP || 1181 + ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN || 1182 + ev.type == SDL_EVENT_MOUSE_BUTTON_UP || 1183 + ev.type == SDL_EVENT_MOUSE_MOTION || 1184 + ev.type == SDL_EVENT_FINGER_DOWN || 1185 + ev.type == SDL_EVENT_FINGER_UP || 1186 + ev.type == SDL_EVENT_FINGER_MOTION)) { 1187 + continue; 1188 + } 1001 1189 // Remap mouse/touch coords from window pixels into the logical 1002 1190 // FB_W × FB_H canvas so pieces see native framebuffer coords 1003 1191 // regardless of fullscreen scale factor or retina backing. ··· 1011 1199 continue; 1012 1200 } 1013 1201 else if (ev.type == SDL_EVENT_KEY_DOWN) { 1014 - if (ev.key.key == SDLK_ESCAPE) { running = 0; continue; } 1202 + // Escape used to quit the whole app here, but that starved 1203 + // pieces of the escape key event — notepat wants triple- 1204 + // escape to exit back to the prompt. Cmd+Q / window close / 1205 + // tray "Quit" still exit via g_quit_requested. 1015 1206 // Cmd+= / Cmd+- live-adjust pixel density (zoom in/out). 1016 1207 // Cmd+0 resets to 1 (web-AC parity). Absorbed — piece never 1017 1208 // sees these keypresses. ··· 1057 1248 } 1058 1249 } 1059 1250 1060 - piece_sim(pc); 1061 - render_frame(&rctx); 1251 + // Once the piece calls system.poweroff(), stop simming/painting 1252 + // and take over the framebuffer with the shutdown animation. 1253 + if (!g_poweroff_requested && piece_poweroff_requested(pc)) { 1254 + g_poweroff_requested = 1; 1255 + bye_frames_played = 0; 1256 + fprintf(stderr, "[bye] poweroff requested — playing shutdown animation\n"); 1257 + } 1258 + if (g_poweroff_requested) { 1259 + // Derive "bye @handle" from AC_SHOT_HANDLE (or fall back to bye). 1260 + char bye_title[80]; 1261 + const char *h = getenv("AC_SHOT_HANDLE"); 1262 + if (h && h[0]) snprintf(bye_title, sizeof bye_title, "bye @%s", h); 1263 + else snprintf(bye_title, sizeof bye_title, "bye"); 1264 + ShutdownAnimConfig sacfg = { .title = bye_title }; 1265 + // Build an ACGraph view over fb->pixels for the animation helpers. 1266 + ACGraph sgraph; 1267 + graph_init(&sgraph, (ACFramebuffer *)&fb); 1268 + shutdown_anim_render_frame(&sgraph, (ACFramebuffer *)&fb, 1269 + bye_frames_played, &sacfg); 1270 + bye_frames_played++; 1271 + // Present the shutdown frame directly — bypass render_frame so 1272 + // piece_paint doesn't overwrite our strobe. 1273 + SDL_UpdateTexture(tex, NULL, fb.pixels, 1274 + fb.stride * (int)sizeof(uint32_t)); 1275 + SDL_RenderClear(ren); 1276 + SDL_RenderTexture(ren, tex, NULL, NULL); 1277 + SDL_RenderPresent(ren); 1278 + if (bye_frames_played >= SHUTDOWN_ANIM_FRAMES) { 1279 + running = 0; 1280 + break; 1281 + } 1282 + } else { 1283 + piece_sim(pc); 1284 + piece_paint(pc); 1285 + // Burn-in caption overlay (demo-script mode). 1286 + if (active_caption[0]) { 1287 + ACGraph cgraph; 1288 + graph_init(&cgraph, (ACFramebuffer *)&fb); 1289 + // Measure text, draw a translucent dark box along the 1290 + // bottom strip, then the caption in warm cream. 1291 + int scale = 1; 1292 + int tw = font_measure_matrix(active_caption, scale); 1293 + if (tw > fb.width - 16) scale = 1; // keep minimal 1294 + int th = 11 * scale; // MatrixChunky8 baseline 1295 + int pad_x = 6, pad_y = 3; 1296 + int box_w = tw + pad_x * 2; 1297 + int box_h = th + pad_y * 2; 1298 + int box_x = (fb.width - box_w) / 2; 1299 + int box_y = fb.height - box_h - 6; 1300 + graph_ink(&cgraph, (ACColor){20, 15, 10, 210}); 1301 + graph_box(&cgraph, box_x, box_y, box_w, box_h, 1); 1302 + graph_ink(&cgraph, (ACColor){228, 216, 188, 255}); 1303 + font_draw_matrix(&cgraph, active_caption, 1304 + box_x + pad_x, box_y + pad_y, scale); 1305 + } 1306 + SDL_UpdateTexture(tex, NULL, fb.pixels, 1307 + fb.stride * (int)sizeof(uint32_t)); 1308 + SDL_RenderClear(ren); 1309 + SDL_RenderTexture(ren, tex, NULL, NULL); 1310 + SDL_RenderPresent(ren); 1311 + } 1062 1312 1063 1313 // Per-frame PNG dump for offline recording. Upscales density× 1064 1314 // nearest-neighbor so the chunky-pixel aesthetic reads correctly
+22 -1
fedac/native/macos/piece.c
··· 666 666 " listPieces,\n" 667 667 " jump(p) { globalThis.__pending_jump = p; },\n" 668 668 " reboot() { console.log('[sys] reboot requested (noop on macOS)'); },\n" 669 - " poweroff() { console.log('[sys] poweroff requested (noop on macOS)'); },\n" 669 + " poweroff() { console.log('[sys] poweroff requested'); globalThis.__poweroff_requested = 1; },\n" 670 670 " sshStarted: false, startSSH: noop,\n" 671 671 " };\n" 672 672 " // Top-level jump() + kidlisp() as globals (prompt + KidLisp return\n" ··· 942 942 JS_FreeValue(cx, pj); 943 943 JS_FreeValue(cx, global); 944 944 return out; 945 + } 946 + 947 + // Poll for system.poweroff() — returns non-zero once per trigger, then 948 + // clears the flag so the main loop can play the shutdown animation 949 + // exactly one time. 950 + int piece_poweroff_requested(PieceCtx *pc) { 951 + if (!pc || !pc->jsctx) return 0; 952 + JSContext *cx = pc->jsctx; 953 + JSValue global = JS_GetGlobalObject(cx); 954 + JSValue flag = JS_GetPropertyStr(cx, global, "__poweroff_requested"); 955 + int requested = 0; 956 + if (JS_IsNumber(flag) || JS_IsBool(flag)) { 957 + int32_t v = 0; JS_ToInt32(cx, &v, flag); 958 + if (v) { 959 + requested = 1; 960 + JS_SetPropertyStr(cx, global, "__poweroff_requested", JS_UNDEFINED); 961 + } 962 + } 963 + JS_FreeValue(cx, flag); 964 + JS_FreeValue(cx, global); 965 + return requested; 945 966 } 946 967 947 968 void piece_boot(PieceCtx *pc) { call_lifecycle_with_api(pc, pc->boot_fn); }
+5
fedac/native/macos/piece.h
··· 56 56 // target is only seen once. NULL when no jump is queued. Caller frees. 57 57 char *piece_pending_jump(PieceCtx *ctx); 58 58 59 + // Poll for system.poweroff(). Returns non-zero exactly once per trigger 60 + // (then clears the flag) so the host can play a shutdown animation and 61 + // exit cleanly. 62 + int piece_poweroff_requested(PieceCtx *ctx); 63 + 59 64 #endif
+185
fedac/native/macos/scripts/DEMO_SCRIPT_FORMAT.md
··· 1 + # AC Native Demo Script Format 2 + 3 + **Goal:** a single file that describes a scripted demo run — key 4 + injection timeline, TTS narration, burn-in subtitles — so the macOS 5 + host (and eventually Linux) can **play the demo AND record it** in one 6 + pass, with no external stitching / ffmpeg post-processing. 7 + 8 + Invoked like: 9 + 10 + ```sh 11 + ac-native ../pieces/prompt.mjs --demo demos/intro.md 12 + ``` 13 + 14 + Two format proposals below. Markdown is the recommended one — human- 15 + readable, diffable, comments survive, timestamps obvious. 16 + 17 + --- 18 + 19 + ## A) Markdown (preferred) 20 + 21 + A single `.md` file. YAML-ish front-matter for run config, then one 22 + event per line keyed on a `[M:SS.mmm]` timestamp. Lines that start 23 + with `#` after the front-matter are comments. 24 + 25 + ```markdown 26 + # title: AC Native — intro walkthrough 27 + # voice: Samantha 28 + # rate: 160 29 + # handle: jeffrey 30 + # city: Los Angeles 31 + # hour: 13 32 + # window: 1280x800 33 + # subtitles: true # burn into framebuffer 34 + # narration: true # run `say` into the audio mix 35 + 36 + # ─── Opening ───────────────────────────────────────────── 37 + [0:00.5] say hi @jeffrey 38 + [0:00.5] caption Welcome to AC Native 39 + 40 + # ─── Type 'notepat' slowly; say each letter ────────────── 41 + [0:02.0] say n 42 + [0:02.0] key n 43 + [0:03.0] say o 44 + [0:03.0] key o 45 + [0:04.0] say t 46 + [0:04.0] key t 47 + [0:05.0] say e 48 + [0:05.0] key e 49 + [0:06.0] say p 50 + [0:06.0] key p 51 + [0:07.0] say a 52 + [0:07.0] key a 53 + [0:08.0] say t 54 + [0:08.0] key t 55 + [0:09.5] key enter 56 + 57 + # ─── In notepat: prompt user to press C; then press it ─── 58 + [0:12.5] say Press the C key to play a C note 59 + [0:12.5] caption Press 'C' to play a C note 60 + [0:15.0] key c 61 + 62 + # ─── Back to the prompt via triple-escape ─────────────── 63 + [0:15.8] say Now let's go back to the prompt 64 + [0:16.0] key escape 65 + [0:16.2] key escape 66 + [0:17.7] key escape 67 + 68 + # ─── Type 'off' and let the shutdown animation fire ──── 69 + [0:18.2] say o 70 + [0:18.2] key o 71 + [0:19.2] say f 72 + [0:19.2] key f 73 + [0:20.2] say f 74 + [0:20.2] key f 75 + [0:20.5] key enter 76 + # prompt.mjs calls system.poweroff() → bye @jeffrey animation plays 77 + ``` 78 + 79 + ### Event grammar 80 + 81 + ``` 82 + event := '[' TIMESTAMP ']' SPACE KIND SPACE ARG 83 + TIMESTAMP := <mm>:<ss>[.<ms>] 84 + KIND := 'key' | 'say' | 'caption' | 'wait' | 'env' 85 + ARG := rest-of-line (trimmed) 86 + ``` 87 + 88 + - **`key`** — inject a key event at the timestamp (exactly what 89 + `AC_INJECT_SEQUENCE` does today, but with absolute offsets not 90 + cumulative deltas). Key names: single character, `enter`, 91 + `escape`, `pageup`, `pagedown`, `arrow{left,right,up,down}`, etc. 92 + - **`say`** — pipe text through `say -v <voice> -r <rate>`. Mixed 93 + into the audio track via `aresample + async=1` so timing is 94 + sample-accurate. 95 + - **`caption`** — render text as burn-in subtitle at bottom of the 96 + framebuffer. Persists until the next `caption` event or an 97 + explicit empty `caption` clears it. Styled via libass-equivalent 98 + font rules baked into the binary. 99 + - **`wait`** — pure timeline anchor. Useful for grouping; doesn't 100 + fire anything but reserves a timestamp the parser can check. 101 + - **`env`** — set an env var for the run (equivalent to prefixing 102 + the `ac-native` invocation with `VAR=value`). Handy for late- 103 + decision things like `handle` if you want to override front-matter. 104 + 105 + --- 106 + 107 + ## B) JSON (alternative, for programmatic generation) 108 + 109 + ```json 110 + { 111 + "title": "AC Native — intro walkthrough", 112 + "voice": "Samantha", 113 + "rate": 160, 114 + "handle": "jeffrey", 115 + "city": "Los Angeles", 116 + "hour": 13, 117 + "window": { "w": 1280, "h": 800 }, 118 + "subtitles": true, 119 + "narration": true, 120 + "events": [ 121 + { "t": 0.5, "kind": "say", "text": "hi @jeffrey" }, 122 + { "t": 0.5, "kind": "caption", "text": "Welcome to AC Native" }, 123 + { "t": 2.0, "kind": "say", "text": "n" }, 124 + { "t": 2.0, "kind": "key", "key": "n" }, 125 + { "t": 3.0, "kind": "say", "text": "o" }, 126 + { "t": 3.0, "kind": "key", "key": "o" }, 127 + ... 128 + ] 129 + } 130 + ``` 131 + 132 + Same semantics, just machine-friendlier. Easier to generate from 133 + `waltz-seq.py` style tools; harder to diff in a PR review. For 134 + tooling (ffmpeg, analytics), both formats can share a compiled JSON 135 + intermediate that the binary reads. 136 + 137 + --- 138 + 139 + ## Implementation notes (what needs to change in the mac host) 140 + 141 + 1. **New CLI flag** — `--demo <path>` in `main.c` alongside the 142 + piece path arg. When present: 143 + - Parse front-matter into env-var equivalents (`AC_SHOT_HANDLE`, 144 + etc.) so downstream code is unchanged. 145 + - Build an event list. Dispatch events from the main loop on 146 + each frame based on `SDL_GetTicks() - start_tick`. 147 + 148 + 2. **Subtitles** — render via `font_draw_matrix()` into a reserved 149 + bottom strip of the framebuffer. State lives in main.c (current 150 + caption + expiry time). Framebuffer is already the same surface 151 + that gets PNG-dumped + displayed + encoded to video, so no extra 152 + plumbing. 153 + 154 + 3. **TTS** — shell out to `say -v <voice> -r <rate> -o <tmpwav> 155 + "<text>"` at event time, then mix the wav into the audio tap. 156 + For recording, we can mix into `synth.wav` directly or emit a 157 + separate `narration.wav` that `demo.sh`-style post-encode joins 158 + at the end. 159 + 160 + 4. **Headless gating** — already supported via `AC_HEADLESS_MS`; 161 + demo scripts can set an explicit `duration:` in front-matter or 162 + infer from the latest event. 163 + 164 + 5. **Compatibility** — `AC_INJECT_SEQUENCE` stays for short one- 165 + off invocations; `--demo` is the higher-level equivalent that 166 + adds narration + captions. 167 + 168 + --- 169 + 170 + ## Pipeline consolidation 171 + 172 + Once the binary supports `--demo`, the existing 173 + `tools/vo-pipeline.mjs` (post-process ffmpeg pipeline for stitching 174 + TTS + subtitles onto an already-rendered video) becomes optional — 175 + only needed when you want to narrate something that wasn't scripted 176 + to begin with (e.g. a hardware capture). 177 + 178 + This makes the feedback loop: 179 + 180 + ``` 181 + edit demo.md → ac-native --demo demo.md → one .mkv, done 182 + ``` 183 + 184 + No external ffmpeg, no concat filter, no `?v=N` cachebuster on the 185 + asset host. The recorded video IS the final video.
+56
fedac/native/macos/scripts/demos/intro.md
··· 1 + # title: AC Native — intro walkthrough 2 + # voice: Samantha 3 + # rate: 160 4 + # handle: jeffrey 5 + # city: Los Angeles 6 + # hour: 13 7 + # window: 1280x800 8 + # duration: 22.5 9 + # subtitles: true 10 + # narration: true 11 + # 12 + # Canonical demo for lacma-2026 + general intro videos. Everything below 13 + # is read by the `--demo` parser in main.c: keys get injected, say events 14 + # shell out to macOS `say`, captions render as burn-in subtitles. 15 + 16 + # ─── Boot greeting ─────────────────────────────────────── 17 + [0:00.5] say hi @jeffrey 18 + 19 + # ─── Type 'notepat' slowly, say each letter ────────────── 20 + [0:02.0] say n 21 + [0:02.0] key n 22 + [0:03.0] say o 23 + [0:03.0] key o 24 + [0:04.0] say t 25 + [0:04.0] key t 26 + [0:05.0] say e 27 + [0:05.0] key e 28 + [0:06.0] say p 29 + [0:06.0] key p 30 + [0:07.0] say a 31 + [0:07.0] key a 32 + [0:08.0] say t 33 + [0:08.0] key t 34 + [0:09.5] key enter 35 + 36 + # ─── In notepat: say "press C", play C ────────────────── 37 + [0:12.5] say Press the C key to play a C note 38 + [0:12.5] caption Press 'C' to play a C note 39 + [0:15.3] key c 40 + 41 + # ─── Back to prompt via triple-escape ─────────────────── 42 + [0:15.8] say Now let's go back to the prompt 43 + [0:15.8] caption Back to the prompt 44 + [0:16.0] key escape 45 + [0:16.2] key escape 46 + [0:17.7] key escape 47 + 48 + # ─── Type 'off' and let shutdown animation fire ───────── 49 + [0:18.5] say o 50 + [0:18.5] key o 51 + [0:19.5] say f 52 + [0:19.5] key f 53 + [0:20.5] say f 54 + [0:20.5] key f 55 + [0:20.8] key enter 56 + # prompt.mjs calls system.poweroff() → bye @jeffrey animation plays
+68
fedac/native/src/shutdown_anim.c
··· 1 + // shutdown_anim.c — canonical ac-native farewell animation. 2 + // 3 + // 90 frames, red/white chaotic strobe, jittered "bye @handle" title + 4 + // subtitle, fades to black. Math mirrors draw_shutdown_anim() in 5 + // ac-native.c so the Linux hardware path and the macOS host produce 6 + // visually identical farewells. 7 + #include "shutdown_anim.h" 8 + 9 + #include <stdint.h> 10 + #include <string.h> 11 + #include "graph.h" 12 + #include "font.h" 13 + 14 + void shutdown_anim_render_frame(ACGraph *graph, ACFramebuffer *screen, 15 + int f, const ShutdownAnimConfig *cfg) { 16 + if (!graph || !screen) return; 17 + if (f < 0) f = 0; 18 + if (f >= SHUTDOWN_ANIM_FRAMES) f = SHUTDOWN_ANIM_FRAMES - 1; 19 + 20 + const char *title = (cfg && cfg->title && cfg->title[0]) ? cfg->title : "bye"; 21 + 22 + // Final frame is pure black so the halt / exit lands on something clean. 23 + if (f == SHUTDOWN_ANIM_FRAMES - 1) { 24 + graph_wipe(graph, (ACColor){0, 0, 0, 255}); 25 + return; 26 + } 27 + 28 + double t = (double)f / (double)SHUTDOWN_ANIM_FRAMES; 29 + 30 + // Chaotic strobe — pseudo-random 6-phase cycle. 31 + int phase = (f * 7 + f / 3) % 6; 32 + uint8_t br, bg, bb; 33 + if (phase < 2) { br = 220; bg = 20; bb = 20; } // red 34 + else if (phase < 3) { br = 255; bg = 255; bb = 255; } // white 35 + else if (phase < 5) { br = 180; bg = 0; bb = 0; } // dark red 36 + else { br = 10; bg = 10; bb = 10; } // near black 37 + 38 + // Fade toward end. 39 + double fade = 1.0 - t * t; 40 + br = (uint8_t)(br * fade); 41 + bg = (uint8_t)(bg * fade); 42 + bb = (uint8_t)(bb * fade); 43 + graph_wipe(graph, (ACColor){br, bg, bb, 255}); 44 + 45 + // Title + subtitle — hidden for the last ~15% of the sequence so the 46 + // fade to black reads cleanly. 47 + if (t < 0.85) { 48 + int alpha = (int)(255.0 * (1.0 - t / 0.85)); 49 + int jx = (f * 13 % 7) - 3; // -3..+3 jitter 50 + int jy = (f * 17 % 5) - 2; // -2..+2 51 + // Every 3rd frame: full-white flicker. Otherwise: red-biased. 52 + uint8_t tr = (f % 3 == 0) ? 255 : 200; 53 + uint8_t tg = (f % 3 == 0) ? 255 : 40; 54 + uint8_t tb = (f % 3 == 0) ? 255 : 40; 55 + graph_ink(graph, (ACColor){tr, tg, tb, (uint8_t)alpha}); 56 + int tw = font_measure_matrix(title, 3); 57 + font_draw_matrix(graph, title, 58 + (screen->width - tw) / 2 + jx, 59 + screen->height / 2 - 20 + jy, 3); 60 + 61 + graph_ink(graph, (ACColor){(uint8_t)(120 * fade), 40, 40, 62 + (uint8_t)(alpha / 2)}); 63 + int sw = font_measure_matrix("aesthetic.computer", 1); 64 + font_draw_matrix(graph, "aesthetic.computer", 65 + (screen->width - sw) / 2 + jx / 2, 66 + screen->height / 2 + 10 + jy / 2, 1); 67 + } 68 + }
+29
fedac/native/src/shutdown_anim.h
··· 1 + // shutdown_anim.h — host-independent renderer for the ac-native shutdown 2 + // farewell. Paired with boot_anim.h: same aesthetic family, different 3 + // mood. 90 frames (1.5 s @ 60 fps) of a chaotic red/white strobe with a 4 + // jittered "bye @handle" title and an "aesthetic.computer" subtitle, 5 + // ending on a black frame. 6 + // 7 + // Shared by the Linux PID-1 init path (src/ac-native.c, via 8 + // draw_shutdown_anim()) and the macOS host (macos/main.c, polling the 9 + // g_poweroff_requested flag). Caller owns frame timing + display present. 10 + #ifndef AC_SHUTDOWN_ANIM_H 11 + #define AC_SHUTDOWN_ANIM_H 12 + 13 + #include "graph.h" 14 + 15 + #define SHUTDOWN_ANIM_FRAMES 90 16 + 17 + typedef struct { 18 + // e.g. "bye @jeffrey" — drawn large + jittered. If NULL, renderer 19 + // falls back to plain "bye". 20 + const char *title; 21 + } ShutdownAnimConfig; 22 + 23 + // Render frame `f` (0..SHUTDOWN_ANIM_FRAMES-1) into `graph`/`screen`. 24 + // The final frame (f == SHUTDOWN_ANIM_FRAMES - 1) is pure black so the 25 + // process can exit cleanly on top of it. 26 + void shutdown_anim_render_frame(ACGraph *graph, ACFramebuffer *screen, 27 + int f, const ShutdownAnimConfig *cfg); 28 + 29 + #endif