Skip to content

Commit aea968e

Browse files
committed
fix per-row frame counts (petdex sheets pad rows - no more blink), cursor+cpu overlay in demo, v4 recording
1 parent 97159f0 commit aea968e

5 files changed

Lines changed: 155 additions & 35 deletions

File tree

‎docs/demo_real.gif‎

1.16 MB
Loading

‎docs/demo_real.mp4‎

1.75 MB
Binary file not shown.

‎reflexarc/render.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,27 @@
4242
CELL_W, CELL_H = 192, 208
4343

4444

45+
def _count_valid_frames(frames: list[QPixmap]) -> int:
46+
"""petdex sheets pad rows with fully transparent frames (jumping has 5,
47+
waving has 4...). Count the non-empty prefix so animation never blinks
48+
out."""
49+
count = 0
50+
for pm in frames:
51+
img = pm.toImage()
52+
opaque = 0
53+
total = 0
54+
for y in range(0, img.height(), 16):
55+
for x in range(0, img.width(), 16):
56+
total += 1
57+
if img.pixelColor(x, y).alpha() > 20:
58+
opaque += 1
59+
if total and opaque > total * 0.02:
60+
count += 1
61+
else:
62+
break
63+
return max(1, count)
64+
65+
4566
class SpriteSheet:
4667
def __init__(self, pet_dir: Path) -> None:
4768
meta_path = pet_dir / "pet.json"
@@ -64,6 +85,7 @@ def __init__(self, pet_dir: Path) -> None:
6485
self.rows_count = max(1, img.height() // CELL_H)
6586
self.cell_h = img.height() // self.rows_count
6687
self.frames: dict[str, list[QPixmap]] = {}
88+
self.frame_counts: dict[str, int] = {}
6789
for r, name in enumerate(ROWS):
6890
if r >= self.rows_count:
6991
break
@@ -74,11 +96,13 @@ def __init__(self, pet_dir: Path) -> None:
7496
self.cell_w, self.cell_h))
7597
frames.append(pm)
7698
self.frames[name] = frames
99+
self.frame_counts[name] = _count_valid_frames(frames)
77100
self.default_frames = self.frames.get("idle") or next(iter(self.frames.values()))
78101

79102
def frame(self, row: str, index: int) -> QPixmap:
80103
frames = self.frames.get(row, self.default_frames)
81-
return frames[index % len(frames)]
104+
n = self.frame_counts.get(row, len(frames))
105+
return frames[int(index) % max(1, n)]
82106

83107

84108
class PetWindow(QWidget):

‎scripts/make_final_video.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
OUT_MP4 = ROOT / "docs" / "demo_real.mp4"
1818
OUT_GIF = ROOT / "docs" / "demo_real.gif"
1919

20-
FPS = 10
2120
SPEEDUP_START, SPEEDUP_END, SPEED = 46.0, 134.0, 3
2221

2322
CAPTIONS = [
@@ -62,6 +61,7 @@ def draw_caption(rgb: np.ndarray, text: str) -> np.ndarray:
6261

6362
def main() -> None:
6463
cap = cv2.VideoCapture(str(RAW))
64+
FPS = cap.get(cv2.CAP_PROP_FPS) or 20.0
6565
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
6666
frames: list[np.ndarray] = []
6767
times: list[float] = []
@@ -77,9 +77,10 @@ def main() -> None:
7777
frames.append(draw_caption(rgb, caption_for(t)))
7878
times.append(t)
7979
cap.release()
80-
print(f"kept {len(frames)} of {total} frames (~{len(frames)/FPS:.0f}s)")
80+
out_fps = float(FPS)
81+
print(f"kept {len(frames)} of {total} frames (~{len(frames)/out_fps:.0f}s @ {out_fps:.0f}fps)")
8182

82-
w = imageio.get_writer(str(OUT_MP4), fps=FPS, codec="libx264",
83+
w = imageio.get_writer(str(OUT_MP4), fps=out_fps, codec="libx264",
8384
quality=7, macro_block_size=None,
8485
ffmpeg_log_level="error")
8586
for fr in frames:
@@ -98,7 +99,7 @@ def pick(t0: float) -> list[np.ndarray]:
9899
gif_frames.append(im.convert("P", palette=Image.ADAPTIVE, colors=128))
99100
if gif_frames:
100101
gif_frames[0].save(OUT_GIF, save_all=True, append_images=gif_frames[1:],
101-
duration=200, loop=0, optimize=True)
102+
duration=int(1000 / out_fps * 2), loop=0, optimize=True)
102103
print("gif ->", OUT_GIF, f"{OUT_GIF.stat().st_size/1024/1024:.1f}MB")
103104

104105

‎scripts/record_demo.py‎

Lines changed: 125 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
ROOT = Path(__file__).resolve().parents[1]
3434
OUT = ROOT / "docs" / "demo_real_raw.mp4"
3535
OUT_W, OUT_H = 1920, 1080
36-
FPS = 10
36+
FPS = 20
3737

3838
PET_SCALE = 1.25 # physical sprite is 480x520 on this 200% display
3939

@@ -51,26 +51,55 @@ def find_pet_center() -> tuple[int, int] | None:
5151
return ((rect.left + rect.right) // 2, (rect.top + rect.bottom) // 2)
5252

5353
CODE_LINES = [
54-
"# day 6 with my desktop pet",
55-
"class Boba:",
56-
" def __init__(self):",
57-
" self.energy = 0.75",
58-
" self.boredom = 0.25",
54+
"# reflexarc - a nervous system for a tiny desktop creature",
55+
"import time",
56+
"from dataclasses import dataclass",
5957
"",
60-
" def live(self, senses):",
61-
" while True:",
62-
" drive = self.homeostasis(senses)",
63-
" act = self.instinct(drive)",
64-
" self.sprite.play(act)",
58+
"@dataclass",
59+
"class Drives:",
60+
" energy: float = 0.75",
61+
" boredom: float = 0.25",
62+
" mood: float = 0.65",
63+
" attachment: float = 0.40",
6564
"",
66-
"def homeostatic_drive(machine_state):",
67-
" energy -= 0.03 * hours",
68-
" boredom += 0.55 * idle_hours",
69-
" mood = ema(mood, target, tau)",
70-
" return dict(energy, boredom, mood)",
65+
"class Senses:",
66+
" def sample(self):",
67+
" idle = get_last_input_seconds()",
68+
" cpu = read_cpu_percent() / 100",
69+
" return dict(idle=idle, cpu=cpu, hour=local_hour())",
7170
"",
72-
"# the desk pet watches the user work...",
73-
"# typing fast makes it work too",
71+
"def homeostasis(drives, obs, dt):",
72+
" hours = dt / 3600",
73+
" drives.energy -= 0.03 * hours",
74+
" drives.boredom += 0.55 * hours if obs[\'idle\'] > 120 else 0",
75+
" target = 0.5 + 0.2 * (drives.energy - 0.5)",
76+
" drives.mood += (target - drives.mood) * min(1, dt / 900)",
77+
" return drives",
78+
"",
79+
"def instinct(drives):",
80+
" scores = {}",
81+
" scores[\'rest\'] = 2.4 * max(0, 0.62 - drives.energy) ** 1.5",
82+
" scores[\'walk\'] = 1.7 * drives.boredom * drives.energy",
83+
" scores[\'watch\'] = 1.3 * drives.attachment",
84+
" return max(scores, key=scores.get)",
85+
"",
86+
"def live(senses, sprite):",
87+
" drives = Drives()",
88+
" last = time.time()",
89+
" while True:",
90+
" now = time.time()",
91+
" obs = senses.sample()",
92+
" homeostasis(drives, obs, now - last)",
93+
" act = instinct(drives)",
94+
" sprite.play(act)",
95+
" last = now",
96+
" time.sleep(0.25)",
97+
"",
98+
"if __name__ == \'__main__\':",
99+
" live(Senses(), sprite=PETDEX_SPRITE)",
100+
"",
101+
"# the strange part: it is not animated, it is alive",
102+
"# every state above is earned by what THIS machine is doing",
74103
]
75104

76105

@@ -82,6 +111,71 @@ def burn(stop_flag) -> None: # runs in child processes
82111
x = 1.0001
83112

84113

114+
_FONT_CACHE: dict = {}
115+
116+
117+
def _font(size: int):
118+
from PIL import ImageFont
119+
if size not in _FONT_CACHE:
120+
f = None
121+
for name in ("segoeui.ttf", "arial.ttf"):
122+
try:
123+
f = ImageFont.truetype(name, size)
124+
break
125+
except OSError:
126+
continue
127+
_FONT_CACHE[size] = f or ImageFont.load_default()
128+
return _FONT_CACHE[size]
129+
130+
131+
def _draw_cursor_and_overlays(rgb: np.ndarray) -> np.ndarray:
132+
"""mss does not capture the mouse cursor, so we draw it ourselves.
133+
Also paints a live CPU meter so the 'stress' phase has visual proof."""
134+
from PIL import Image, ImageDraw
135+
import ctypes, ctypes.wintypes
136+
try:
137+
import psutil
138+
cpu = psutil.cpu_percent(interval=None) / 100.0
139+
except Exception:
140+
cpu = 0.0
141+
142+
img = Image.fromarray(rgb)
143+
draw = ImageDraw.Draw(img, "RGBA")
144+
145+
# --- CPU meter (top-right) ---
146+
x0, y0, w, h = img.width - 320, 24, 296, 54
147+
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=10,
148+
fill=(12, 12, 22, 180))
149+
pct = max(0.0, min(1.0, cpu))
150+
bar_w = int((w - 16) * pct)
151+
color = (90, 200, 120) if pct < 0.6 else ((240, 190, 60) if pct < 0.85
152+
else (235, 80, 80))
153+
draw.rounded_rectangle([x0 + 8, y0 + h - 20, x0 + 8 + bar_w, y0 + h - 8],
154+
radius=6, fill=color)
155+
draw.text((x0 + 10, y0 + 6), f"CPU {int(pct * 100)}%",
156+
font=_font(20), fill=(235, 235, 245))
157+
158+
# --- cursor ---
159+
user32 = ctypes.windll.user32
160+
pt = ctypes.wintypes.POINT()
161+
if user32.GetCursorPos(ctypes.byref(pt)):
162+
cx, cy = pt.x // 2, pt.y // 2 # physical -> 1080p half scale
163+
down = bool(user32.GetAsyncKeyState(0x01) & 0x8000)
164+
if down:
165+
draw.ellipse([cx - 26, cy - 26, cx + 26, cy + 26],
166+
outline=(255, 210, 90, 220), width=5)
167+
pts = [(cx, cy), (cx + 4, cy + 26), (cx + 11, cy + 18),
168+
(cx + 20, cy + 30), (cx + 27, cy + 25), (cx + 18, cy + 14),
169+
(cx + 28, cy + 11)]
170+
draw.polygon([(px, py) for px, py in pts],
171+
fill=(255, 255, 255, 235))
172+
draw.line([(cx, cy), (cx + 4, cy + 26), (cx + 11, cy + 18),
173+
(cx + 20, cy + 30), (cx + 27, cy + 25), (cx + 18, cy + 14),
174+
(cx + 28, cy + 11), (cx, cy)],
175+
fill=(20, 20, 20, 255), width=2)
176+
return np.asarray(img)
177+
178+
85179
def record_loop(stop_event: threading.Event) -> None:
86180
with mss.mss() as sct:
87181
mon = sct.monitors[1]
@@ -92,16 +186,15 @@ def record_loop(stop_event: threading.Event) -> None:
92186
interval = 1.0 / FPS
93187
start = time.time()
94188
written = 0
95-
last_rgb = None
96189
while not stop_event.is_set():
97190
frame = np.asarray(sct.grab(mon)) # BGRA
98191
frame = cv2.resize(frame, (OUT_W, OUT_H),
99192
interpolation=cv2.INTER_AREA)
100-
last_rgb = cv2.cvtColor(frame, cv2.COLOR_BGRA2RGB)
101-
# keep video time == real time: pad if we fell behind
193+
rgb = cv2.cvtColor(frame, cv2.COLOR_BGRA2RGB)
194+
rgb = _draw_cursor_and_overlays(rgb)
102195
due = int((time.time() - start) * FPS) + 1
103196
while written < due:
104-
writer.append_data(last_rgb)
197+
writer.append_data(rgb)
105198
written += 1
106199
sleep = (start + written * interval) - time.time()
107200
if sleep > 0:
@@ -130,17 +223,19 @@ def cb(hwnd, lparam):
130223
user32.EnumWindows(cb, 0)
131224

132225

226+
_TYPE_POS = {"i": 0}
227+
228+
133229
def type_code(seconds: float) -> None:
134-
"""Type real keystrokes (only ASCII) with human-ish rhythm."""
230+
"""Type real keystrokes (only ASCII) with human-ish rhythm. The cursor
231+
continues where the previous call stopped, so no line repeats in a row."""
135232
end = time.time() + seconds
136-
lines = list(CODE_LINES)
137233
while time.time() < end:
138-
for line in lines:
139-
if time.time() >= end:
140-
return
141-
pyautogui.typewrite(line, interval=0.035)
142-
pyautogui.press("enter")
143-
time.sleep(0.35)
234+
line = CODE_LINES[_TYPE_POS["i"] % len(CODE_LINES)]
235+
_TYPE_POS["i"] += 1
236+
pyautogui.typewrite(line, interval=0.035)
237+
pyautogui.press("enter")
238+
time.sleep(0.35)
144239

145240

146241
def main() -> None:

0 commit comments

Comments
 (0)