@topy-ai/maggie 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +2 -2
- package/bundled-references/agent-goal-design.md +92 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-deployment/SKILL.md +21 -1
- package/bundled-skills/maggie-design/SKILL.md +29 -1
- package/bundled-skills/maggie-spirit/SKILL.md +128 -0
- package/bundled-tools/clis/maggie_dash.py +12 -4
- package/bundled-tools/clis/maggie_spirit.py +143 -0
- package/bundled-tools/runtime/maggie_dash_pg.py +126 -0
- package/bundled-tools/runtime/maggie_dash_store.py +17 -5
- package/bundled-tools/runtime/spirit_agent.py +262 -0
- package/bundled-tools/runtime/spirit_base.py +256 -0
- package/bundled-tools/runtime/spirit_face.py +222 -0
- package/package.json +1 -1
- package/references/agent-goal-design.md +92 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostgreSQL backend for the MaggieDash content store.
|
|
3
|
+
|
|
4
|
+
A thin compatibility shim that mimics the subset of the stdlib sqlite3
|
|
5
|
+
connection API that MaggieDashStore uses, so the same store code runs on
|
|
6
|
+
Postgres unchanged. It translates `?` placeholders to `%s`, intercepts the few
|
|
7
|
+
SQLite-only statements (PRAGMA foreign_keys / table_info / user_version and
|
|
8
|
+
`sqlite_master`), and returns rows that support both name and positional access
|
|
9
|
+
plus `dict(row)` — exactly like `sqlite3.Row`.
|
|
10
|
+
|
|
11
|
+
Used when a Postgres DSN is supplied (ONEMAN forbids SQLite anywhere); the
|
|
12
|
+
SQLite path in MaggieDashStore is untouched. Requires psycopg2 (imported
|
|
13
|
+
lazily, only when a Postgres backend is actually requested).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Sequence
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Row(dict):
|
|
22
|
+
"""A dict that also supports positional access and `dict(row)`, like sqlite3.Row."""
|
|
23
|
+
__slots__ = ("_cols",)
|
|
24
|
+
|
|
25
|
+
def __init__(self, cols: Sequence[str], values: Sequence[Any]):
|
|
26
|
+
super().__init__(zip(cols, values))
|
|
27
|
+
self._cols = list(cols)
|
|
28
|
+
|
|
29
|
+
def __getitem__(self, key):
|
|
30
|
+
if isinstance(key, int):
|
|
31
|
+
return super().__getitem__(self._cols[key])
|
|
32
|
+
return super().__getitem__(key)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Result:
|
|
36
|
+
"""Cursor-like result supporting fetchone/fetchall/iteration over Row objects."""
|
|
37
|
+
def __init__(self, rows: list[Row]):
|
|
38
|
+
self._rows = rows
|
|
39
|
+
self._idx = 0
|
|
40
|
+
|
|
41
|
+
def fetchone(self):
|
|
42
|
+
if self._idx < len(self._rows):
|
|
43
|
+
row = self._rows[self._idx]
|
|
44
|
+
self._idx += 1
|
|
45
|
+
return row
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def fetchall(self):
|
|
49
|
+
rows = self._rows[self._idx:]
|
|
50
|
+
self._idx = len(self._rows)
|
|
51
|
+
return rows
|
|
52
|
+
|
|
53
|
+
def __iter__(self):
|
|
54
|
+
rows = self._rows[self._idx:]
|
|
55
|
+
self._idx = len(self._rows)
|
|
56
|
+
return iter(rows)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_PRAGMA_TABLE_INFO = re.compile(r"PRAGMA\s+table_info\s*\(\s*([^)]+?)\s*\)", re.IGNORECASE)
|
|
60
|
+
_USER_VERSION_SET = re.compile(r"PRAGMA\s+user_version\s*=\s*(\d+)", re.IGNORECASE)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PgConnection:
|
|
64
|
+
"""sqlite3.Connection-compatible shim backed by psycopg2."""
|
|
65
|
+
def __init__(self, dsn: str):
|
|
66
|
+
import psycopg2 # lazy: only when a Postgres backend is requested
|
|
67
|
+
self._conn = psycopg2.connect(dsn)
|
|
68
|
+
self._conn.autocommit = False
|
|
69
|
+
|
|
70
|
+
# -- the sqlite3.Connection surface MaggieDashStore relies on --
|
|
71
|
+
def execute(self, sql: str, params: Sequence[Any] | None = None) -> Result:
|
|
72
|
+
s = sql.strip()
|
|
73
|
+
up = s.upper()
|
|
74
|
+
if up.startswith("PRAGMA FOREIGN_KEYS"):
|
|
75
|
+
return Result([])
|
|
76
|
+
m = _PRAGMA_TABLE_INFO.search(s)
|
|
77
|
+
if m:
|
|
78
|
+
table = m.group(1).strip().strip('"').strip("'")
|
|
79
|
+
rows = self._run(
|
|
80
|
+
"SELECT column_name FROM information_schema.columns "
|
|
81
|
+
"WHERE table_schema='public' AND table_name=%s ORDER BY ordinal_position",
|
|
82
|
+
(table,))
|
|
83
|
+
# sqlite PRAGMA table_info columns: (cid, name, ...) -> row[1] is the name
|
|
84
|
+
return Result([Row(["cid", "name"], [i, r[0]]) for i, r in enumerate(rows)])
|
|
85
|
+
m = _USER_VERSION_SET.search(s)
|
|
86
|
+
if m:
|
|
87
|
+
self._run("CREATE TABLE IF NOT EXISTS maggiedash_meta (key TEXT PRIMARY KEY, value TEXT)")
|
|
88
|
+
self._run("INSERT INTO maggiedash_meta (key,value) VALUES ('user_version',%s) "
|
|
89
|
+
"ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value", (str(m.group(1)),))
|
|
90
|
+
return Result([])
|
|
91
|
+
if "FROM SQLITE_MASTER" in up:
|
|
92
|
+
rows = self._run("SELECT table_name FROM information_schema.tables WHERE table_schema='public'")
|
|
93
|
+
return Result([Row(["name"], [r[0]]) for r in rows])
|
|
94
|
+
# ordinary statement: translate placeholders and run
|
|
95
|
+
query = s.replace("?", "%s")
|
|
96
|
+
cur = self._conn.cursor()
|
|
97
|
+
try:
|
|
98
|
+
cur.execute(query, tuple(params) if params else None)
|
|
99
|
+
if cur.description is None:
|
|
100
|
+
return Result([])
|
|
101
|
+
cols = [d[0] for d in cur.description]
|
|
102
|
+
return Result([Row(cols, rec) for rec in cur.fetchall()])
|
|
103
|
+
finally:
|
|
104
|
+
cur.close()
|
|
105
|
+
|
|
106
|
+
def executescript(self, script: str) -> None:
|
|
107
|
+
cur = self._conn.cursor()
|
|
108
|
+
try:
|
|
109
|
+
cur.execute(script) # psycopg2 runs multiple ';'-separated statements
|
|
110
|
+
finally:
|
|
111
|
+
cur.close()
|
|
112
|
+
|
|
113
|
+
def _run(self, query: str, params: Sequence[Any] | None = None) -> list[tuple]:
|
|
114
|
+
cur = self._conn.cursor()
|
|
115
|
+
try:
|
|
116
|
+
cur.execute(query, tuple(params) if params else None)
|
|
117
|
+
return cur.fetchall() if cur.description is not None else []
|
|
118
|
+
finally:
|
|
119
|
+
cur.close()
|
|
120
|
+
|
|
121
|
+
def commit(self) -> None:
|
|
122
|
+
self._conn.commit()
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
if not self._conn.closed:
|
|
126
|
+
self._conn.close()
|
|
@@ -38,11 +38,23 @@ def checksum(value: dict[str, Any]) -> str:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
class MaggieDashStore:
|
|
41
|
-
def __init__(self, path: Path):
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
def __init__(self, path: Path | None = None, *, dsn: str | None = None):
|
|
42
|
+
# Postgres backend (ONEMAN forbids SQLite) when a DSN is supplied;
|
|
43
|
+
# otherwise the stdlib SQLite backend, unchanged.
|
|
44
|
+
if dsn:
|
|
45
|
+
from maggie_dash_pg import PgConnection
|
|
46
|
+
self.backend = "postgres"
|
|
47
|
+
self.path = dsn
|
|
48
|
+
# Never expose credentials in contracts/reports/CLI output.
|
|
49
|
+
self.storage_label = "env:MAGGIEDASH_DATABASE_URL"
|
|
50
|
+
self.connection = PgConnection(dsn)
|
|
51
|
+
else:
|
|
52
|
+
self.backend = "sqlite"
|
|
53
|
+
self.path = path.resolve()
|
|
54
|
+
self.storage_label = str(self.path)
|
|
55
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
self.connection = sqlite3.connect(self.path)
|
|
57
|
+
self.connection.row_factory = sqlite3.Row
|
|
46
58
|
self.connection.execute("PRAGMA foreign_keys = ON")
|
|
47
59
|
self.connection.executescript(
|
|
48
60
|
"""
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Animate the Core-Agent faces through the 32-state system.
|
|
3
|
+
|
|
4
|
+
The agent's face (hair / eyes / prop) is the fixed identity; its ONEMAN O-rim
|
|
5
|
+
is the animatable ring — it reuses the exact motion vocabulary + palette +
|
|
6
|
+
badges from sprite_system.CATALOG (thinking sweeps, loading fills, critical
|
|
7
|
+
blinks, success turns green, awaiting shows the clock badge, …). `speaking`
|
|
8
|
+
also opens the mouth. Emits a PNG frame strip + still per state, a sprite sheet
|
|
9
|
+
and states.json per agent, into brand/<role>/.
|
|
10
|
+
"""
|
|
11
|
+
import sys, os, json, math
|
|
12
|
+
HERE=os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0,HERE)
|
|
13
|
+
OUT_BASE=HERE
|
|
14
|
+
import spirit_face as F
|
|
15
|
+
from spirit_base import CATALOG, GLYPH, palette, GROUPS
|
|
16
|
+
from PIL import Image
|
|
17
|
+
|
|
18
|
+
W,H,cx,cy=F.W,F.H,F.cx,F.cy
|
|
19
|
+
R_IN,R_OUT=F.R_IN,F.R_OUT
|
|
20
|
+
shade=F.shade; OUT=F.OUT
|
|
21
|
+
|
|
22
|
+
# rim cells (annulus) ordered by angle from top, clockwise
|
|
23
|
+
RIM=[]
|
|
24
|
+
for y in range(H):
|
|
25
|
+
for x in range(W):
|
|
26
|
+
d=math.hypot(x-cx,y-cy)
|
|
27
|
+
if R_IN-0.5<=d<=R_OUT: RIM.append((x,y))
|
|
28
|
+
def _ang(p): return (math.atan2(p[1]-cy,p[0]-cx)+math.pi/2)%(2*math.pi)
|
|
29
|
+
RIM.sort(key=_ang)
|
|
30
|
+
ANG={p:_ang(p) for p in RIM}
|
|
31
|
+
|
|
32
|
+
BX,BY=22,0
|
|
33
|
+
def put_badge(px,name,bg,fg):
|
|
34
|
+
for yy in range(0,6):
|
|
35
|
+
for xx in range(21,27): px[(xx,yy)]=None
|
|
36
|
+
g=GLYPH[name]
|
|
37
|
+
for r in range(5):
|
|
38
|
+
for c in range(5): px[(BX+c,BY+r)]=fg if g[r][c]=="1" else bg
|
|
39
|
+
def tri(t,nf): x=(2*t/nf); return x if x<=1 else 2-x
|
|
40
|
+
|
|
41
|
+
WHT=F.WHT; EYE=F.EYE
|
|
42
|
+
EYEPROPS={"glasses","shades","monocle"} # cover the eyes -> don't redraw eyes under them
|
|
43
|
+
MOUTHPROPS={"cigar"} # at the mouth -> keep base mouth
|
|
44
|
+
|
|
45
|
+
# ---- expression layer: per-state face animation (generic, reusable) ----
|
|
46
|
+
# state -> (eye-mode, mouth-mode). Blink is added automatically on longer loops.
|
|
47
|
+
EXPR={
|
|
48
|
+
"off":("closed","flat"),"booting":("wake","small"),"idle":("open","smile_s"),
|
|
49
|
+
"active":("open","smile_s"),"paused":("half","flat"),"offline":("closed","flat"),
|
|
50
|
+
"thinking":("up","small"),"loading":("open","flat"),"syncing":("dart","flat"),
|
|
51
|
+
"writing":("down","small"),"streaming":("open","talk"),"queued":("open","flat"),
|
|
52
|
+
"scanning":("dart","flat"),"deploying":("determined","flat"),
|
|
53
|
+
"speaking":("open","talk"),"listening":("side","small"),
|
|
54
|
+
"notification":("wide","small"),"mention":("side","smile"),"recording":("open","flat"),
|
|
55
|
+
"success":("happy","grin"),"warning":("worried","frown"),"critical":("wide","frown"),
|
|
56
|
+
"blocked":("side","flat"),"error":("x","frown"),"retrying":("determined","flat"),
|
|
57
|
+
"awaiting":("up","small"),"approved":("happy","smile"),"rejected":("worried","frown"),
|
|
58
|
+
"secure":("open","smile_s"),"vulnerable":("worried","frown"),"maintenance":("down","flat"),
|
|
59
|
+
"celebrating":("happy","grin"),
|
|
60
|
+
}
|
|
61
|
+
def _clear(px,x0,y0,x1,y1,SK):
|
|
62
|
+
for y in range(y0,y1+1):
|
|
63
|
+
for x in range(x0,x1+1): px[(x,y)]=SK
|
|
64
|
+
def draw_eyes_expr(px,mode,t,nf,SK,lash,blink):
|
|
65
|
+
for s in(-1,1):
|
|
66
|
+
_clear(px,cx+s*3 if s>0 else cx-6, cy-3, cx+6 if s>0 else cx-3, cy, SK)
|
|
67
|
+
if blink: mode="closed"
|
|
68
|
+
def whites(s):
|
|
69
|
+
for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-2)]=WHT; px[(x,cy-1)]=WHT
|
|
70
|
+
def lashmark(s):
|
|
71
|
+
if lash: px[(cx+s*6,cy-2)]=OUT; px[(cx+s*5,cy-3)]=OUT
|
|
72
|
+
for s in(-1,1):
|
|
73
|
+
if mode=="closed":
|
|
74
|
+
for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-1)]=OUT
|
|
75
|
+
lashmark(s); continue
|
|
76
|
+
if mode=="half":
|
|
77
|
+
for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-1)]=WHT
|
|
78
|
+
px[(cx+s*4,cy-1)]=EYE
|
|
79
|
+
for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-2)]=OUT
|
|
80
|
+
continue
|
|
81
|
+
if mode=="happy":
|
|
82
|
+
px[(cx+s*5,cy-1)]=OUT; px[(cx+s*4,cy-2)]=OUT; px[(cx+s*3,cy-1)]=OUT; continue
|
|
83
|
+
if mode=="x":
|
|
84
|
+
px[(cx+s*3,cy-2)]=OUT; px[(cx+s*4,cy-1)]=OUT; px[(cx+s*5,cy)]=OUT
|
|
85
|
+
px[(cx+s*5,cy-2)]=OUT; px[(cx+s*3,cy)]=OUT; continue
|
|
86
|
+
whites(s); lashmark(s)
|
|
87
|
+
pdx, pdy = 0, 0
|
|
88
|
+
if mode=="up": pdy=-1
|
|
89
|
+
elif mode=="down": pdy=0; px[(cx+s*3,cy-2)]=EYE # lowered lid feel
|
|
90
|
+
elif mode=="side": pdx=1
|
|
91
|
+
elif mode=="dart": pdx=1 if (t%2==0) else -1
|
|
92
|
+
elif mode=="wide": pass
|
|
93
|
+
elif mode=="worried": px[(cx+s*4 if s<0 else cx+s*3,cy-3)]=OUT # inner brow down
|
|
94
|
+
elif mode=="determined": px[(cx+s*5,cy-3)]=OUT # outer brow
|
|
95
|
+
px[(cx+s*(4+pdx), cy-1+pdy)]=EYE
|
|
96
|
+
if mode not in("wide","up"): px[(cx+s*4, cy-2)]=EYE if pdy==0 else WHT
|
|
97
|
+
if mode=="wake" and t< nf//2:
|
|
98
|
+
for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-1)]=OUT; px[(x,cy-2)]=SK
|
|
99
|
+
def draw_mouth_expr(px,mode,t,SK,mcol):
|
|
100
|
+
_clear(px,cx-2,cy+3,cx+2,cy+5,SK)
|
|
101
|
+
if mode=="smile" or mode=="smile_s":
|
|
102
|
+
px[(cx-2,cy+4)]=OUT;px[(cx-1,cy+5)]=OUT;px[(cx,cy+5)]=OUT;px[(cx+1,cy+5)]=OUT;px[(cx+2,cy+4)]=OUT
|
|
103
|
+
elif mode=="grin":
|
|
104
|
+
px[(cx-2,cy+4)]=OUT;px[(cx+2,cy+4)]=OUT
|
|
105
|
+
for x in range(cx-1,cx+2): px[(x,cy+4)]=OUT; px[(x,cy+5)]=WHT
|
|
106
|
+
elif mode=="flat":
|
|
107
|
+
for x in range(cx-2,cx+2): px[(x,cy+4)]=OUT
|
|
108
|
+
elif mode=="frown":
|
|
109
|
+
px[(cx-2,cy+5)]=OUT;px[(cx-1,cy+4)]=OUT;px[(cx,cy+4)]=OUT;px[(cx+1,cy+4)]=OUT;px[(cx+2,cy+5)]=OUT
|
|
110
|
+
elif mode=="small":
|
|
111
|
+
px[(cx,cy+4)]=OUT; px[(cx-1,cy+4)]=OUT
|
|
112
|
+
elif mode=="talk":
|
|
113
|
+
if t%2:
|
|
114
|
+
for x in range(cx-1,cx+2): px[(x,cy+4)]=OUT;px[(x,cy+5)]=OUT
|
|
115
|
+
px[(cx,cy+5)]=F._rgb('7a1a12')
|
|
116
|
+
else:
|
|
117
|
+
for x in range(cx-1,cx+2): px[(x,cy+4)]=OUT
|
|
118
|
+
if mcol and mode in("smile","smile_s","flat","grin"): # lipstick tint
|
|
119
|
+
for x in range(cx-2,cx+3):
|
|
120
|
+
if px.get((x,cy+4))==OUT: px[(x,cy+4)]=mcol
|
|
121
|
+
|
|
122
|
+
# ---- per-agent signature micro-animation on the prop (always-on identity) ----
|
|
123
|
+
def prop_anim(px,prop,t,nf,propcol):
|
|
124
|
+
ph=t%max(1,nf); frac=t/max(1,nf); rgb=F._rgb
|
|
125
|
+
if prop=="cigar":
|
|
126
|
+
for k in range(2): # smoke curl rising
|
|
127
|
+
yy=(cy+2)-((ph+k*2)%6); xx=cx+9+((ph+k)%2)
|
|
128
|
+
if yy>=cy-4: px[(xx,yy)]=rgb('9a9aa8') if k==0 else rgb('6a6a7a')
|
|
129
|
+
px[(cx+8,cy+4)]=rgb('ff4422') if ph%2==0 else rgb('ff7a44') # ember flicker
|
|
130
|
+
elif prop=="headset":
|
|
131
|
+
px[(cx-6,cy+4)]=rgb('ff4422') if ph%2==0 else rgb('5a1a10') # mic light blink
|
|
132
|
+
elif prop in("shades","glasses"):
|
|
133
|
+
gx=cx-5+int(frac*11) # glint sweeps both lenses
|
|
134
|
+
for lx0,lx1 in((cx-5,cx-1),(cx+2,cx+6)):
|
|
135
|
+
if lx0<=gx<=lx1: px[(gx,cy-1)]=WHT
|
|
136
|
+
elif prop=="monocle":
|
|
137
|
+
gx=cx+2+int(frac*4)
|
|
138
|
+
if cx+2<=gx<=cx+6: px[(gx,cy-1)]=WHT
|
|
139
|
+
px[(cx+4+(1 if ph%2 else 0),cy+2)]=rgb('d4af37') # chain sway
|
|
140
|
+
elif prop=="pen":
|
|
141
|
+
if ph%2==0: px[(cx+8,cy-1)]=rgb('f2c75a') # write spark at the nib
|
|
142
|
+
elif prop=="earbuds":
|
|
143
|
+
w=rgb('9fe8ff')
|
|
144
|
+
if ph%2==0: px[(cx-10,cy)]=w; px[(cx+10,cy)]=w # sound waves pulse out
|
|
145
|
+
else: px[(cx-11,cy-1)]=w; px[(cx+11,cy-1)]=w
|
|
146
|
+
elif prop=="bandana":
|
|
147
|
+
dy=1 if ph%2 else 0; BC=rgb(propcol or 'ef4444')
|
|
148
|
+
px[(cx-11,cy+dy)]=BC; px[(cx-11,cy+1+dy)]=shade(BC,-22) # tail flutter
|
|
149
|
+
|
|
150
|
+
def founder_anim(px,t,nf):
|
|
151
|
+
rgb=F._rgb; ph=t%max(1,nf); frac=t/max(1,nf)
|
|
152
|
+
gx=cx-5+int(frac*11) # glasses glint sweep
|
|
153
|
+
for lx0,lx1 in((cx-5,cx-3),(cx+3,cx+5)):
|
|
154
|
+
if lx0<=gx<=lx1: px[(gx,cy-1)]=WHT
|
|
155
|
+
GL = rgb('39d353') if ph%2==0 else rgb('1f7a3a') # green mask-vent pulse
|
|
156
|
+
for k in range(3): px[(cx+3,cy+4+k)]=GL
|
|
157
|
+
BC=rgb('e23b3b'); BCd=shade(BC,-24); w=1 if ph%2 else 0 # right-side tails ripple in the wind
|
|
158
|
+
for strand in(0,1):
|
|
159
|
+
for k in range(2,6):
|
|
160
|
+
xx=cx+8+k; yy=(cy-2-strand*2)-(k*2)//3-(w if k>=3 else 0)
|
|
161
|
+
if 0<=xx<W and 0<=yy<H: px[(xx,yy)]=BC if k<3 else BCd
|
|
162
|
+
|
|
163
|
+
NF={"breathe":10,"breathe_slow":10,"pulse":8,"blink":2,"arc":12,"scan":12,"spin":10,
|
|
164
|
+
"dualarc":12,"fill":12,"boot":10,"dots":6,"sparkout":4,"sparkin":4,"celebrate":8}
|
|
165
|
+
|
|
166
|
+
def frames(base, rim_live, state, P, ctx):
|
|
167
|
+
grp,role,motion,badge,fps=CATALOG[state]
|
|
168
|
+
col=P[role] if role in P else P["accent"]
|
|
169
|
+
nf=NF[motion]; out=[]
|
|
170
|
+
SK=F.SKINS[ctx["skin"]]; lash=(ctx["gender"]=="f")
|
|
171
|
+
eyemode,mouthmode=EXPR[state]
|
|
172
|
+
for t in range(nf):
|
|
173
|
+
px=dict(base)
|
|
174
|
+
def setrim(fn):
|
|
175
|
+
for i,p in enumerate(rim_live): px[p]=fn(i,p)
|
|
176
|
+
n=len(rim_live)
|
|
177
|
+
if motion in("breathe","breathe_slow"):
|
|
178
|
+
cc=shade(col,(10 if motion=="breathe_slow" else 16)*tri(t,nf)); setrim(lambda i,p:cc)
|
|
179
|
+
elif motion=="pulse":
|
|
180
|
+
cc=shade(col,42*tri(t,nf)); setrim(lambda i,p:cc)
|
|
181
|
+
elif motion=="blink":
|
|
182
|
+
cc=col if t%2==0 else shade(col,-60); setrim(lambda i,p:cc)
|
|
183
|
+
elif motion in("arc","scan","spin"):
|
|
184
|
+
win={"arc":80,"scan":34,"spin":54}[motion]; a0=(t/nf)*2*math.pi; w=math.radians(win)
|
|
185
|
+
setrim(lambda i,p:(P["acc_hot"] if min((ANG[p]-a0)%(2*math.pi),(a0-ANG[p])%(2*math.pi))<=w/2 else P["acc_dim"]))
|
|
186
|
+
elif motion in("fill","boot"):
|
|
187
|
+
prog=(t+1)/nf; setrim(lambda i,p:(col if (i/max(1,n))<=prog else P["acc_dim"]))
|
|
188
|
+
elif motion=="dualarc":
|
|
189
|
+
a0=(t/nf)*2*math.pi; w=math.radians(70)
|
|
190
|
+
def cm(i,p):
|
|
191
|
+
for b in(a0,a0+math.pi):
|
|
192
|
+
if min((ANG[p]-b)%(2*math.pi),(b-ANG[p])%(2*math.pi))<=w/2: return P["acc_hot"]
|
|
193
|
+
return P["acc_dim"]
|
|
194
|
+
setrim(cm)
|
|
195
|
+
elif motion=="dots":
|
|
196
|
+
cc=shade(col,14*tri(t,nf)); setrim(lambda i,p:cc)
|
|
197
|
+
elif motion=="sparkin":
|
|
198
|
+
cc=shade(col,16*tri(t,nf)); setrim(lambda i,p:cc)
|
|
199
|
+
elif motion=="celebrate":
|
|
200
|
+
cyc=[P["green"],P["amber"],P["accent"],P["acc_spark"]]; cc=cyc[t%len(cyc)]; setrim(lambda i,p:cc)
|
|
201
|
+
else: # sparkout + fallbacks: steady rim
|
|
202
|
+
setrim(lambda i,p:col)
|
|
203
|
+
# ---- personalized expression (eyes + mouth per state) ----
|
|
204
|
+
blink = (nf>=6 and t==nf-1) # a blink each loop on longer states
|
|
205
|
+
eyeCov=(ctx["prop"] in EYEPROPS) or ctx.get("specs")
|
|
206
|
+
mouthCov=(ctx["prop"] in MOUTHPROPS) or ctx.get("mask")
|
|
207
|
+
if not eyeCov:
|
|
208
|
+
draw_eyes_expr(px,eyemode,t,nf,SK,lash,blink)
|
|
209
|
+
if not mouthCov:
|
|
210
|
+
mcol=F._rgb('c0392b') if ctx.get("mouth")=="lips" else None
|
|
211
|
+
draw_mouth_expr(px,mouthmode,t,SK,mcol)
|
|
212
|
+
if ctx.get("founder"): founder_anim(px,t,nf) # founder signature anim
|
|
213
|
+
else: prop_anim(px, ctx["prop"], t, nf, ctx.get("propcol"))
|
|
214
|
+
if badge:
|
|
215
|
+
nm,bg,fg=badge; put_badge(px,nm,P[bg],P[fg])
|
|
216
|
+
out.append(px)
|
|
217
|
+
return out, fps
|
|
218
|
+
|
|
219
|
+
def gen_agent(role, accent, gender, params, scale=8):
|
|
220
|
+
P=palette(accent)
|
|
221
|
+
base=F.face(accent, gender=gender, **params)
|
|
222
|
+
rim_live=[p for p in RIM if base.get(p) in (F._rgb(accent), shade(F._rgb(accent),-30))]
|
|
223
|
+
ctx={"skin":params.get("skin","mid"),"gender":gender,"prop":params.get("prop"),
|
|
224
|
+
"mouth":params.get("mouth"),"propcol":params.get("propcol"),
|
|
225
|
+
"specs":params.get("specs"),"mask":params.get("mask"),"founder":(role=="founder")}
|
|
226
|
+
out=os.path.join(OUT_BASE, role); os.makedirs(out, exist_ok=True)
|
|
227
|
+
# wipe old O+1 agent files
|
|
228
|
+
for fn in os.listdir(out):
|
|
229
|
+
if fn.endswith(('.png','.svg','.json')): os.remove(os.path.join(out,fn))
|
|
230
|
+
meta={"title":role.upper(),"accent":accent,"gender":gender,"W":W,"H":H,"scale":scale,"groups":GROUPS,"states":{}}
|
|
231
|
+
def render(px): return F.render(px,scale)
|
|
232
|
+
for s in CATALOG:
|
|
233
|
+
frs,fps=frames(base,rim_live,s,P,ctx)
|
|
234
|
+
imgs=[render(px) for px in frs]
|
|
235
|
+
w=sum(i.width for i in imgs); strip=Image.new("RGBA",(w,imgs[0].height),(0,0,0,0)); x=0
|
|
236
|
+
for i in imgs: strip.paste(i,(x,0)); x+=i.width
|
|
237
|
+
strip.save(os.path.join(out,f"anim-{s}.png"))
|
|
238
|
+
render(frs[0]).save(os.path.join(out,f"still-{s}.png"))
|
|
239
|
+
grp,role_,motion,badge,_=CATALOG[s]
|
|
240
|
+
meta["states"][s]={"group":grp,"frames":len(frs),"fps":fps,"motion":motion,"badge":badge[0] if badge else None}
|
|
241
|
+
# sprite sheet (8x4, representative frame)
|
|
242
|
+
def rep(m,n): return {"blink":0,"fill":int(n*.62),"boot":int(n*.62),"arc":n//4,"scan":n//4,"spin":n//4,"dualarc":n//4,"dots":1}.get(m,n//2)
|
|
243
|
+
cols,rows,cell=8,4,6
|
|
244
|
+
tw=W*cell*0+W; # overview uses small renders
|
|
245
|
+
cellimg=lambda px: F.render(px, 5)
|
|
246
|
+
order=[s for g in GROUPS for s in CATALOG if CATALOG[s][0]==g]
|
|
247
|
+
tile=W*5; pad=6
|
|
248
|
+
sheet=Image.new("RGBA",(cols*tile+(cols+1)*pad, rows*(H*5)+(rows+1)*pad),(16,16,24,255))
|
|
249
|
+
for i,s in enumerate(order):
|
|
250
|
+
frs,_=frames(base,rim_live,s,P,ctx); im=cellimg(frs[rep(CATALOG[s][2],len(frs))])
|
|
251
|
+
X=pad+(i%cols)*(tile+pad); Y=pad+(i//cols)*(H*5+pad); sheet.paste(im,(X,Y),im)
|
|
252
|
+
sheet.save(os.path.join(out,"sprite-sheet.png"))
|
|
253
|
+
json.dump(meta, open(os.path.join(out,"states.json"),"w"), indent=1)
|
|
254
|
+
return meta
|
|
255
|
+
|
|
256
|
+
if __name__=="__main__":
|
|
257
|
+
only=sys.argv[1] if len(sys.argv)>1 else None
|
|
258
|
+
for role,acc,g,p in list(F.CAST)+[F.FOUNDER]:
|
|
259
|
+
r=role.lower()
|
|
260
|
+
if only and r!=only: continue
|
|
261
|
+
m=gen_agent(r,acc,g,p)
|
|
262
|
+
print(f"{role} {acc} {g}: {len(m['states'])} states -> brand/{r}/")
|