Monorepo for Aesthetic.Computer
aesthetic.computer
1// Snap, 2026.01.28
2// Camera piece for taking still photos (paintings/snaps)
3// Simple workflow: preview camera → tap to capture → save to painting
4
5/* #region 🏁 TODO
6 + Now
7 - [] Add filters/effects options
8 - [] Add countdown timer option
9 + Later
10 - [] Add selfie mode with face detection
11 - [] Support burst mode
12 - [] Add crop/frame options before saving
13 + Done
14 - [x] Basic camera preview
15 - [x] Tap anywhere to capture
16 - [x] Swap camera button
17 - [x] Flash effect on capture
18 - [x] Jump to prompt with painting saved
19#endregion */
20
21import {
22 CaptureButton,
23 SwapButton,
24 FlashEffect,
25 sounds,
26} from "./common/cap-ui.mjs";
27
28const { floor, min, max } = Math;
29
30let vid,
31 frame,
32 facing = "environment",
33 capturing = true,
34 captured = false;
35
36let captureBtn, swapBtn, flash;
37let videoInitialized = false;
38
39// 🥾 Boot
40function boot({ ui, params, colon, system }) {
41 // Parse parameters
42 if (params[0] === "me" || params[0] === "selfie") facing = "user";
43 if (colon[0] === "selfie" || colon[0] === "s") facing = "user";
44
45 flash = new FlashEffect();
46}
47
48// 🎨 Paint
49function paint({
50 api,
51 wipe,
52 ink,
53 paste,
54 video,
55 cameras,
56 system,
57 screen,
58 num: { randIntRange, clamp, rand },
59}) {
60 // Initialize video feed to match screen dimensions (not painting)
61 if (!vid) {
62 wipe(0);
63 vid = video("camera", {
64 width: screen.width,
65 height: screen.height,
66 facing,
67 fit: "contain", // Show full camera view (letterboxed if needed)
68 });
69 videoInitialized = true;
70 }
71
72 // Draw the video on each frame, filling the screen
73 if (capturing && !captured) {
74 frame = vid(function shader({ x, y }, c) {
75 // Subtle sparkle effect
76 if (rand() > 0.995) {
77 c[0] = clamp(c[0] + randIntRange(30, 80), 0, 255);
78 c[1] = clamp(c[1] + randIntRange(30, 80), 0, 255);
79 c[2] = clamp(c[2] + randIntRange(30, 80), 0, 255);
80 }
81 });
82 }
83
84 // Paste the video centered on screen (video may have different AR)
85 if (frame) {
86 // Center the frame on screen
87 const offsetX = floor((screen.width - frame.width) / 2);
88 const offsetY = floor((screen.height - frame.height) / 2);
89 wipe(0); // Clear first
90 paste(frame, offsetX, offsetY);
91 }
92
93 // Draw UI
94 const centerX = floor(screen.width / 2);
95 const bottomY = screen.height - 40;
96
97 // Capture button (large circle at bottom center)
98 if (!captureBtn) {
99 captureBtn = new CaptureButton({
100 x: centerX,
101 y: bottomY,
102 radius: 28,
103 type: "snap",
104 });
105 }
106 captureBtn.reposition({ x: centerX, y: bottomY });
107 captureBtn.disabled = captured;
108 captureBtn.paint(api);
109
110 // Swap button (top right, only if multiple cameras)
111 if (cameras > 1) {
112 if (!swapBtn) {
113 swapBtn = new SwapButton({ x: 0, y: 0, width: 48, height: 20 });
114 }
115 swapBtn.reposition({ right: 6, bottom: 6, screen });
116 swapBtn.disabled = captured;
117 swapBtn.paint(api);
118 }
119
120 // Hint text
121 if (!captured) {
122 ink(255, 255, 255, 160).write("tap to snap", {
123 x: centerX,
124 y: bottomY + 36,
125 center: "x",
126 });
127 } else {
128 ink(255, 255, 100, 200).write("SAVED", {
129 x: centerX,
130 y: bottomY + 36,
131 center: "x",
132 });
133 }
134
135 // Flash effect overlay
136 flash.update();
137 flash.paint(api);
138}
139
140// Bake the captured frame to the painting
141function bake({ paste }) {
142 if (captured && frame) {
143 paste(frame);
144 }
145}
146
147function act({ event: e, jump, video, cameras, sound, notice, leaving, hud }) {
148 // Capture button interaction
149 if (captureBtn && !captured) {
150 captureBtn.act(e, {
151 down: () => sounds.down(sound),
152 push: () => {
153 // Capture the current frame
154 sounds.shutter(sound);
155 flash.trigger();
156 captured = true;
157 capturing = false;
158 notice("SNAP!", ["yellow", "white"]);
159
160 // Auto-jump to prompt after a short delay
161 setTimeout(() => {
162 jump("prompt");
163 }, 500);
164 },
165 });
166 }
167
168 // Swap camera button
169 if (cameras > 1 && swapBtn && !captured) {
170 swapBtn.act(e, {
171 down: () => sounds.down(sound),
172 push: () => {
173 sounds.push(sound);
174 swapBtn.disabled = true;
175 const faceTo = facing === "user" ? "environment" : "user";
176 vid = video("camera:update", { facing: faceTo });
177 },
178 });
179 }
180
181 // Touch anywhere (not on buttons) to capture
182 if (e.is("touch") && !captured) {
183 const onCaptureBtn = captureBtn?.contains(e.x, e.y);
184 const onSwapBtn = swapBtn?.contains(e.x, e.y);
185
186 if (!onCaptureBtn && !onSwapBtn) {
187 sounds.down(sound);
188 }
189 }
190
191 if (e.is("lift") && !captured && !leaving()) {
192 const onCaptureBtn = captureBtn?.contains(e.x, e.y);
193 const onSwapBtn = swapBtn?.contains(e.x, e.y);
194
195 if (!onCaptureBtn && !onSwapBtn && !hud.currentLabel().btn.down) {
196 // Tap anywhere to capture
197 sounds.shutter(sound);
198 flash.trigger();
199 captured = true;
200 capturing = false;
201 notice("SNAP!", ["yellow", "white"]);
202
203 setTimeout(() => {
204 jump("prompt");
205 }, 500);
206 }
207 }
208
209 // Keyboard shortcuts
210 if (e.is("keyboard:down:enter") || e.is("keyboard:down: ")) {
211 if (!captured) {
212 sounds.shutter(sound);
213 flash.trigger();
214 captured = true;
215 capturing = false;
216 notice("SNAP!", ["yellow", "white"]);
217
218 setTimeout(() => {
219 jump("prompt");
220 }, 500);
221 }
222 }
223
224 if (e.is("keyboard:down:escape") || e.is("keyboard:down:`")) {
225 jump("prompt");
226 }
227
228 // Camera mode events
229 if (e.is("camera:mode:user")) {
230 facing = "user";
231 if (swapBtn) swapBtn.disabled = false;
232 capturing = true;
233 }
234
235 if (e.is("camera:mode:environment")) {
236 facing = "environment";
237 if (swapBtn) swapBtn.disabled = false;
238 capturing = true;
239 }
240
241 if (e.is("camera:denied")) {
242 notice("CAMERA DENIED", ["yellow", "red"]);
243 jump("prompt");
244 }
245}
246
247export { boot, paint, bake, act };
248
249export const system = "nopaint:bake-on-leave";
250export const nohud = true; // Minimal HUD for camera view