open-research-protocol 0.4.27 → 0.4.29
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/CHANGELOG.md +48 -0
- package/README.md +39 -14
- package/bin/orp.js +14 -1
- package/cli/__pycache__/orp.cpython-311.pyc +0 -0
- package/cli/orp.py +1124 -5
- package/docs/START_HERE.md +14 -0
- package/package.json +5 -1
- package/packages/orp-workspace-launcher/src/codex.js +822 -0
- package/packages/orp-workspace-launcher/src/index.js +10 -0
- package/packages/orp-workspace-launcher/src/ledger.js +11 -1
- package/packages/orp-workspace-launcher/test/codex.test.js +309 -0
- package/scripts/__pycache__/orp-kernel-agent-pilot.cpython-311.pyc +0 -0
- package/scripts/__pycache__/orp-kernel-agent-replication.cpython-311.pyc +0 -0
- package/scripts/__pycache__/orp-kernel-benchmark.cpython-311.pyc +0 -0
- package/scripts/__pycache__/orp-kernel-canonical-continuation.cpython-311.pyc +0 -0
- package/scripts/__pycache__/orp-kernel-continuation-pilot.cpython-311.pyc +0 -0
- package/scripts/render-terminal-demo.py +262 -134
|
@@ -14,30 +14,33 @@ GIF_PATH = ASSETS / "terminal-demo.gif"
|
|
|
14
14
|
POSTER_PATH = ASSETS / "terminal-demo-poster.png"
|
|
15
15
|
STORYBOARD_PATH = ASSETS / "terminal-demo-storyboard.png"
|
|
16
16
|
|
|
17
|
-
WIDTH =
|
|
18
|
-
HEIGHT =
|
|
19
|
-
SIDE_MARGIN =
|
|
20
|
-
SHELL_TOP =
|
|
21
|
-
BOTTOM_MARGIN =
|
|
17
|
+
WIDTH = 1400
|
|
18
|
+
HEIGHT = 900
|
|
19
|
+
SIDE_MARGIN = 64
|
|
20
|
+
SHELL_TOP = 188
|
|
21
|
+
BOTTOM_MARGIN = 128
|
|
22
22
|
WINDOW_PADDING = 28
|
|
23
23
|
|
|
24
|
-
OUTER_BG = "#
|
|
25
|
-
WINDOW_BG = "#
|
|
26
|
-
WINDOW_EDGE = "#
|
|
27
|
-
TITLEBAR_BG = "#
|
|
28
|
-
TERMINAL_BG = "#
|
|
29
|
-
INK = "#
|
|
30
|
-
MUTED = "#
|
|
31
|
-
DIM = "#
|
|
32
|
-
ACCENT = "#
|
|
33
|
-
SKY = "#
|
|
34
|
-
SOFT = "#
|
|
35
|
-
CORAL = "#
|
|
36
|
-
LIME = "#
|
|
37
|
-
GOLD = "#
|
|
24
|
+
OUTER_BG = "#050C13"
|
|
25
|
+
WINDOW_BG = "#081929"
|
|
26
|
+
WINDOW_EDGE = "#173B5A"
|
|
27
|
+
TITLEBAR_BG = "#10283D"
|
|
28
|
+
TERMINAL_BG = "#071525"
|
|
29
|
+
INK = "#EAF7F3"
|
|
30
|
+
MUTED = "#9AB2C8"
|
|
31
|
+
DIM = "#63798F"
|
|
32
|
+
ACCENT = "#70F0DF"
|
|
33
|
+
SKY = "#8AB8FF"
|
|
34
|
+
SOFT = "#F3D9B7"
|
|
35
|
+
CORAL = "#FF9B88"
|
|
36
|
+
LIME = "#BDF59E"
|
|
37
|
+
GOLD = "#F6D06F"
|
|
38
|
+
MASCOT_DARK = "#14324A"
|
|
39
|
+
MASCOT_BODY = "#DDFCF4"
|
|
40
|
+
MASCOT_WING = "#62DDCC"
|
|
38
41
|
|
|
39
42
|
FONT_MONO = "/System/Library/Fonts/Menlo.ttc"
|
|
40
|
-
FONT_SANS_BOLD = "/System/Library/Fonts/Supplemental/
|
|
43
|
+
FONT_SANS_BOLD = "/System/Library/Fonts/Supplemental/DIN Alternate Bold.ttf"
|
|
41
44
|
|
|
42
45
|
|
|
43
46
|
def load_font(path: str, size: int) -> ImageFont.ImageFont:
|
|
@@ -50,160 +53,156 @@ def load_font(path: str, size: int) -> ImageFont.ImageFont:
|
|
|
50
53
|
MONO_18 = load_font(FONT_MONO, 18)
|
|
51
54
|
MONO_20 = load_font(FONT_MONO, 20)
|
|
52
55
|
MONO_24 = load_font(FONT_MONO, 24)
|
|
53
|
-
MONO_28 = load_font(FONT_MONO, 28)
|
|
54
56
|
MONO_32 = load_font(FONT_MONO, 32)
|
|
55
57
|
SANS_22 = load_font(FONT_SANS_BOLD, 22)
|
|
56
58
|
SANS_26 = load_font(FONT_SANS_BOLD, 26)
|
|
57
59
|
SANS_34 = load_font(FONT_SANS_BOLD, 34)
|
|
58
60
|
SANS_52 = load_font(FONT_SANS_BOLD, 52)
|
|
61
|
+
SANS_66 = load_font(FONT_SANS_BOLD, 66)
|
|
59
62
|
|
|
60
63
|
CONTENT_X = SIDE_MARGIN + WINDOW_PADDING + 46
|
|
61
|
-
CONTENT_Y = SHELL_TOP +
|
|
62
|
-
CONTENT_WIDTH =
|
|
64
|
+
CONTENT_Y = SHELL_TOP + 132
|
|
65
|
+
CONTENT_WIDTH = 760
|
|
63
66
|
LINE_HEIGHT = 34
|
|
64
|
-
TYPING_MS =
|
|
67
|
+
TYPING_MS = 48
|
|
65
68
|
COMMAND_SETTLE_MS = 320
|
|
66
|
-
OUTPUT_STEP_MS =
|
|
67
|
-
SCENE_HOLD_MS =
|
|
69
|
+
OUTPUT_STEP_MS = 180
|
|
70
|
+
SCENE_HOLD_MS = 8500
|
|
68
71
|
|
|
69
72
|
SCENES = [
|
|
70
73
|
{
|
|
71
74
|
"id": "home",
|
|
72
|
-
"label": "
|
|
73
|
-
"headline": "
|
|
75
|
+
"label": "field guide",
|
|
76
|
+
"headline": "context before claims",
|
|
77
|
+
"sermon": [
|
|
78
|
+
"Read the room first.",
|
|
79
|
+
"Then route the agent.",
|
|
80
|
+
"Receipts beat vibes.",
|
|
81
|
+
],
|
|
74
82
|
"command": "orp home",
|
|
75
83
|
"output_font": "small",
|
|
76
84
|
"line_height": 30,
|
|
77
85
|
"output": [
|
|
78
|
-
("ORP 0.4.
|
|
79
|
-
("Agent-first CLI for workspace ledgers,
|
|
80
|
-
("Repo", SKY),
|
|
81
|
-
(" root: ~/code/open-research-protocol", INK),
|
|
82
|
-
(" config: orp.yml (missing)", ACCENT),
|
|
83
|
-
(" git: yes, branch=main, commit=1a2b3c4", SOFT),
|
|
86
|
+
("ORP 0.4.28", ACCENT),
|
|
87
|
+
("Agent-first CLI for workspace ledgers, agendas, secrets, and research workflows.", INK),
|
|
84
88
|
("Daily Loop", SKY),
|
|
85
89
|
(" orp workspace tabs main", ACCENT),
|
|
86
|
-
(
|
|
87
|
-
(
|
|
90
|
+
(" orp project refresh --json", INK),
|
|
91
|
+
(" orp agenda focus", SOFT),
|
|
92
|
+
(" orp mode breakdown granular-breakdown", SKY),
|
|
88
93
|
],
|
|
89
94
|
},
|
|
90
95
|
{
|
|
91
|
-
"id": "
|
|
92
|
-
"label": "
|
|
93
|
-
"headline": "
|
|
94
|
-
"
|
|
96
|
+
"id": "workspace",
|
|
97
|
+
"label": "workspace",
|
|
98
|
+
"headline": "save every live thread",
|
|
99
|
+
"sermon": [
|
|
100
|
+
"A tab is a thread.",
|
|
101
|
+
"A thread needs a path.",
|
|
102
|
+
"A crash should not win.",
|
|
103
|
+
],
|
|
104
|
+
"command": "orp workspace tabs main",
|
|
95
105
|
"output": [
|
|
96
|
-
("
|
|
97
|
-
("
|
|
98
|
-
("
|
|
99
|
-
("
|
|
100
|
-
("
|
|
101
|
-
("
|
|
102
|
-
("workspace.title=main-cody-1", SKY),
|
|
103
|
-
("workspace.tab_count=18", SOFT),
|
|
106
|
+
("saved tabs: 24", ACCENT),
|
|
107
|
+
("projects: grouped by repo", SKY),
|
|
108
|
+
("orp", INK),
|
|
109
|
+
(" path: ~/code/orp", INK),
|
|
110
|
+
(" resume: cd '~/code/orp' && codex resume 019d32d3-d8b2-7fa2-aaec-c74b5134afd6", SOFT),
|
|
111
|
+
("claude and codex recovery commands stay side-by-side", ACCENT),
|
|
104
112
|
],
|
|
105
113
|
},
|
|
106
114
|
{
|
|
107
115
|
"id": "secrets",
|
|
108
116
|
"label": "secrets",
|
|
109
|
-
"headline": "save
|
|
110
|
-
"
|
|
117
|
+
"headline": "save keys without spilling them",
|
|
118
|
+
"sermon": [
|
|
119
|
+
"Name the credential.",
|
|
120
|
+
"Hide the value.",
|
|
121
|
+
"Reuse it safely.",
|
|
122
|
+
],
|
|
123
|
+
"command": "orp secrets add --alias openai-primary --provider openai",
|
|
111
124
|
"output": [
|
|
112
125
|
("Secret value:", ACCENT),
|
|
113
126
|
(" sk-...", INK),
|
|
114
127
|
("secret.alias=openai-primary", SKY),
|
|
115
128
|
("secret.provider=openai", ACCENT),
|
|
116
|
-
("
|
|
117
|
-
("
|
|
118
|
-
("next: orp secrets
|
|
129
|
+
("username: optional", INK),
|
|
130
|
+
("value: stored locally, not printed", SKY),
|
|
131
|
+
("next: orp secrets keychain-spend-policy openai-primary --daily-spend-cap-usd 5", SOFT),
|
|
119
132
|
],
|
|
120
133
|
},
|
|
121
134
|
{
|
|
122
|
-
"id": "
|
|
123
|
-
"label": "
|
|
124
|
-
"headline": "
|
|
125
|
-
"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
("recovery: copy the exact cd && resume command you need", SKY),
|
|
130
|
-
("tools: codex resume and claude --resume are both tracked", ACCENT),
|
|
131
|
-
("tail tabs: orp · orp-web-app · RigidityCore · frg-site", SOFT),
|
|
135
|
+
"id": "research",
|
|
136
|
+
"label": "research",
|
|
137
|
+
"headline": "ask in lanes, spend with consent",
|
|
138
|
+
"sermon": [
|
|
139
|
+
"Dry-run the plan.",
|
|
140
|
+
"Use OpenAI when it helps.",
|
|
141
|
+
"Spend only on purpose.",
|
|
132
142
|
],
|
|
133
|
-
|
|
134
|
-
{
|
|
135
|
-
"id": "schedule",
|
|
136
|
-
"label": "schedule",
|
|
137
|
-
"headline": "automate the next loop",
|
|
138
|
-
"command": 'orp schedule add codex --name morning-summary --prompt "Summarize this repo"',
|
|
143
|
+
"command": 'orp research ask "Should we expand this project?" --json',
|
|
139
144
|
"output": [
|
|
140
|
-
("
|
|
141
|
-
("
|
|
142
|
-
("
|
|
143
|
-
("
|
|
144
|
-
("
|
|
145
|
-
("Codex session id: none", SKY),
|
|
146
|
-
("Next steps:", ACCENT),
|
|
147
|
-
(" orp schedule run morning-summary", SOFT),
|
|
145
|
+
("status: planned", ACCENT),
|
|
146
|
+
("lanes: plan -> reason -> web -> deep research", SKY),
|
|
147
|
+
("openai: ready when --execute is explicit", INK),
|
|
148
|
+
("spend_preflight: checked before provider calls", SOFT),
|
|
149
|
+
("answer path: orp/research/<run_id>/ANSWER.json", ACCENT),
|
|
148
150
|
],
|
|
149
151
|
},
|
|
150
152
|
{
|
|
151
153
|
"id": "governance",
|
|
152
154
|
"label": "governance",
|
|
153
|
-
"headline": "checkpoint the
|
|
155
|
+
"headline": "checkpoint the work, not the vibes",
|
|
156
|
+
"sermon": [
|
|
157
|
+
"Progress gets named.",
|
|
158
|
+
"Repos stay recoverable.",
|
|
159
|
+
"Evidence stays separate.",
|
|
160
|
+
],
|
|
154
161
|
"command": 'orp checkpoint create -m "capture loop state"',
|
|
155
162
|
"output": [
|
|
156
|
-
("
|
|
157
|
-
("branch
|
|
158
|
-
("
|
|
159
|
-
("
|
|
160
|
-
("
|
|
163
|
+
("checkpoint: capture loop state", ACCENT),
|
|
164
|
+
("branch: work/release-hardening", INK),
|
|
165
|
+
("backup: ready", SKY),
|
|
166
|
+
("doctor: clean enough to keep moving", ACCENT),
|
|
167
|
+
("boundary: ORP is process; proof lives in canonical artifacts", SOFT),
|
|
161
168
|
],
|
|
162
169
|
},
|
|
163
170
|
{
|
|
164
|
-
"id": "
|
|
165
|
-
"label": "
|
|
166
|
-
"headline": "
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
("active_milestone=v10.3", SKY),
|
|
172
|
-
("active_phase=395", ACCENT),
|
|
173
|
-
("band=verification", INK),
|
|
174
|
-
("next_action=Execute Phase 395", SKY),
|
|
175
|
-
("blocked_by=(none)", SOFT),
|
|
171
|
+
"id": "breakdown",
|
|
172
|
+
"label": "breakdown",
|
|
173
|
+
"headline": "make hard work legible",
|
|
174
|
+
"sermon": [
|
|
175
|
+
"Broad first.",
|
|
176
|
+
"Then lanes.",
|
|
177
|
+
"Then atoms.",
|
|
176
178
|
],
|
|
177
|
-
|
|
178
|
-
{
|
|
179
|
-
"id": "synthesis",
|
|
180
|
-
"label": "synthesis",
|
|
181
|
-
"headline": "scan, synthesize, collaborate",
|
|
182
|
-
"command": "orp exchange repo synthesize /path/to/source",
|
|
179
|
+
"command": "orp mode breakdown granular-breakdown",
|
|
183
180
|
"output": [
|
|
184
|
-
("
|
|
185
|
-
("
|
|
186
|
-
("
|
|
187
|
-
("
|
|
188
|
-
("
|
|
189
|
-
("
|
|
190
|
-
("
|
|
181
|
+
("sequence:", ACCENT),
|
|
182
|
+
(" L0 whole frame", INK),
|
|
183
|
+
(" L1 boundary", SKY),
|
|
184
|
+
(" L2 major lanes", INK),
|
|
185
|
+
(" L4 atomic obligations", SOFT),
|
|
186
|
+
(" L7 durable checklist", ACCENT),
|
|
187
|
+
("use when the project feels bigger than your hands", SKY),
|
|
191
188
|
],
|
|
192
189
|
},
|
|
193
190
|
{
|
|
194
|
-
"id": "
|
|
195
|
-
"label": "
|
|
196
|
-
"headline": "
|
|
197
|
-
"
|
|
191
|
+
"id": "share",
|
|
192
|
+
"label": "publish",
|
|
193
|
+
"headline": "share the protocol forward",
|
|
194
|
+
"sermon": [
|
|
195
|
+
"Open research is a relay.",
|
|
196
|
+
"Make the next handoff kinder.",
|
|
197
|
+
"Leave a map.",
|
|
198
|
+
],
|
|
199
|
+
"command": "orp report summary --json",
|
|
198
200
|
"output": [
|
|
199
|
-
("
|
|
200
|
-
("
|
|
201
|
-
("
|
|
202
|
-
("
|
|
203
|
-
("
|
|
204
|
-
("nudge.release=drop the first obvious framing", SOFT),
|
|
205
|
-
("nudge.micro_loop:", ACCENT),
|
|
206
|
-
("- zoom out, rotate, re-enter deliberately", SKY),
|
|
201
|
+
("packet: ready", ACCENT),
|
|
202
|
+
("report: current state summarized", INK),
|
|
203
|
+
("handoff: agent-readable", SKY),
|
|
204
|
+
("install: npm install -g open-research-protocol", SOFT),
|
|
205
|
+
("message: keep research open, recoverable, and kind", ACCENT),
|
|
207
206
|
],
|
|
208
207
|
},
|
|
209
208
|
]
|
|
@@ -247,27 +246,29 @@ def wrap_command(command: str, font: ImageFont.ImageFont, max_width: int) -> lis
|
|
|
247
246
|
|
|
248
247
|
def draw_background(draw: ImageDraw.ImageDraw) -> None:
|
|
249
248
|
draw.rectangle((0, 0, WIDTH, HEIGHT), fill=OUTER_BG)
|
|
249
|
+
for y in range(0, HEIGHT, 6):
|
|
250
|
+
blend = y / max(1, HEIGHT)
|
|
251
|
+
red = int(5 + blend * 4)
|
|
252
|
+
green = int(12 + blend * 10)
|
|
253
|
+
blue = int(19 + blend * 20)
|
|
254
|
+
draw.rectangle((0, y, WIDTH, y + 6), fill=(red, green, blue))
|
|
250
255
|
orbit_colors = [ACCENT, SKY, SOFT, ACCENT, SKY]
|
|
251
256
|
orbit_points = [
|
|
252
|
-
(84,
|
|
253
|
-
(WIDTH -
|
|
254
|
-
(
|
|
255
|
-
(
|
|
256
|
-
(WIDTH - 210, HEIGHT - 162),
|
|
257
|
-
(WIDTH - 116, HEIGHT - 110),
|
|
257
|
+
(84, 76),
|
|
258
|
+
(WIDTH - 92, 78),
|
|
259
|
+
(118, HEIGHT - 120),
|
|
260
|
+
(268, HEIGHT - 188),
|
|
258
261
|
]
|
|
259
262
|
for idx, (x, y) in enumerate(orbit_points):
|
|
260
263
|
r = 8 if idx % 2 == 0 else 6
|
|
261
264
|
color = orbit_colors[idx % len(orbit_colors)]
|
|
262
265
|
draw.ellipse((x - r, y - r, x + r, y + r), fill=color)
|
|
263
|
-
draw.line((106, HEIGHT - 118, 232, HEIGHT - 176), fill="#17324D", width=2)
|
|
264
|
-
draw.line((WIDTH - 210, HEIGHT - 162, WIDTH - 116, HEIGHT - 110), fill="#17324D", width=2)
|
|
265
266
|
|
|
266
267
|
|
|
267
268
|
def draw_terminal_shell(draw: ImageDraw.ImageDraw) -> tuple[int, int, int, int]:
|
|
268
269
|
left = SIDE_MARGIN
|
|
269
270
|
top = SHELL_TOP
|
|
270
|
-
right =
|
|
271
|
+
right = 966
|
|
271
272
|
bottom = HEIGHT - BOTTOM_MARGIN
|
|
272
273
|
draw.rounded_rectangle((left, top, right, bottom), radius=36, fill=WINDOW_BG, outline=WINDOW_EDGE, width=2)
|
|
273
274
|
draw.rounded_rectangle(
|
|
@@ -300,11 +301,133 @@ def draw_scene_header(draw: ImageDraw.ImageDraw, scene: dict) -> None:
|
|
|
300
301
|
label = scene["label"].upper()
|
|
301
302
|
label_box = draw.textbbox((0, 0), label, font=MONO_20)
|
|
302
303
|
label_x = (WIDTH - (label_box[2] - label_box[0])) // 2
|
|
303
|
-
draw.text((label_x,
|
|
304
|
+
draw.text((label_x, 24), label, font=MONO_20, fill=DIM)
|
|
304
305
|
headline = scene["headline"]
|
|
305
|
-
headline_box = draw.textbbox((0, 0), headline, font=
|
|
306
|
+
headline_box = draw.textbbox((0, 0), headline, font=SANS_66)
|
|
306
307
|
headline_x = (WIDTH - (headline_box[2] - headline_box[0])) // 2
|
|
307
|
-
draw.text((headline_x,
|
|
308
|
+
draw.text((headline_x, 58), headline, font=SANS_66, fill=ACCENT)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def draw_speech_bubble(draw: ImageDraw.ImageDraw, scene: dict) -> None:
|
|
312
|
+
left, top, right, bottom = 996, 202, 1332, 398
|
|
313
|
+
draw.rounded_rectangle((left, top, right, bottom), radius=28, fill="#0E2335", outline="#1E4969", width=2)
|
|
314
|
+
draw.polygon([(1060, bottom - 2), (1096, bottom - 2), (1076, bottom + 34)], fill="#0E2335", outline="#1E4969")
|
|
315
|
+
draw.text((left + 28, top + 22), "protocol note", font=MONO_20, fill=SKY)
|
|
316
|
+
y = top + 62
|
|
317
|
+
for line in scene.get("sermon", []):
|
|
318
|
+
for wrapped in wrap_line(str(line), SANS_26, right - left - 54):
|
|
319
|
+
draw.text((left + 28, y), wrapped, font=SANS_26, fill=INK)
|
|
320
|
+
y += 32
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def draw_mascot_mouth(
|
|
324
|
+
draw: ImageDraw.ImageDraw,
|
|
325
|
+
cx: int,
|
|
326
|
+
cy: int,
|
|
327
|
+
scene: dict,
|
|
328
|
+
pulse: int,
|
|
329
|
+
scale: float = 1.0,
|
|
330
|
+
) -> None:
|
|
331
|
+
expression = {
|
|
332
|
+
"home": "smile",
|
|
333
|
+
"workspace": "focused",
|
|
334
|
+
"secrets": "wink",
|
|
335
|
+
"research": "curious",
|
|
336
|
+
"governance": "proud",
|
|
337
|
+
"breakdown": "ooh",
|
|
338
|
+
"share": "beam",
|
|
339
|
+
"hero": "beam",
|
|
340
|
+
}.get(str(scene.get("id", "")), "smile")
|
|
341
|
+
def p(dx: float, dy: float) -> tuple[int, int]:
|
|
342
|
+
return (round(cx + dx * scale), round(cy + dy * scale))
|
|
343
|
+
|
|
344
|
+
def box(x1: float, y1: float, x2: float, y2: float) -> tuple[int, int, int, int]:
|
|
345
|
+
return (*p(x1, y1), *p(x2, y2))
|
|
346
|
+
|
|
347
|
+
width_3 = max(2, round(3 * scale))
|
|
348
|
+
width_4 = max(2, round(4 * scale))
|
|
349
|
+
width_5 = max(2, round(5 * scale))
|
|
350
|
+
wobble = int(math.sin(pulse / 2.0) * 2 * scale)
|
|
351
|
+
if expression == "focused":
|
|
352
|
+
draw.arc(box(-20, 14, 20, 42), start=205, end=335, fill=MASCOT_DARK, width=width_4)
|
|
353
|
+
draw.line((*p(-12, 32 + wobble), *p(12, 32 - wobble)), fill=MASCOT_DARK, width=width_3)
|
|
354
|
+
elif expression == "wink":
|
|
355
|
+
draw.arc(box(-22, 8, 22, 38), start=18, end=162, fill=CORAL, width=width_5)
|
|
356
|
+
draw.ellipse(box(18, 18, 26, 26), fill=GOLD)
|
|
357
|
+
elif expression == "curious":
|
|
358
|
+
draw.ellipse(box(-12, 16, 12, 40), fill=MASCOT_DARK)
|
|
359
|
+
draw.ellipse(box(-5, 22, 5, 34), fill=CORAL)
|
|
360
|
+
elif expression == "proud":
|
|
361
|
+
draw.arc(box(-26, 4, 26, 42), start=22, end=158, fill=MASCOT_DARK, width=width_5)
|
|
362
|
+
draw.ellipse(box(-16, 30, -8, 38), fill=ACCENT)
|
|
363
|
+
draw.ellipse(box(8, 30, 16, 38), fill=ACCENT)
|
|
364
|
+
elif expression == "ooh":
|
|
365
|
+
draw.ellipse(box(-15, 14, 15, 46), fill=MASCOT_DARK)
|
|
366
|
+
draw.ellipse(box(-6, 23, 6, 37), fill="#FBE7C8")
|
|
367
|
+
elif expression == "beam":
|
|
368
|
+
draw.rounded_rectangle(box(-26, 16, 26, 42), radius=round(12 * scale), fill=MASCOT_DARK)
|
|
369
|
+
for tooth_x in (-13, 0, 13):
|
|
370
|
+
draw.line((*p(tooth_x, 17), *p(tooth_x, 40)), fill="#DDFCF4", width=max(1, round(2 * scale)))
|
|
371
|
+
draw.arc(box(-28, 6, 28, 48), start=20, end=160, fill=CORAL, width=width_3)
|
|
372
|
+
else:
|
|
373
|
+
draw.arc(box(-24, 10, 24, 42), start=20, end=160, fill=MASCOT_DARK, width=width_5)
|
|
374
|
+
draw.ellipse(box(-9, 30, 9, 38), fill=CORAL)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def draw_mascot(
|
|
378
|
+
draw: ImageDraw.ImageDraw,
|
|
379
|
+
scene: dict,
|
|
380
|
+
pulse: int = 0,
|
|
381
|
+
*,
|
|
382
|
+
center: tuple[int, int] = (1136, 610),
|
|
383
|
+
scale: float = 1.0,
|
|
384
|
+
) -> None:
|
|
385
|
+
base_cx, base_cy = center
|
|
386
|
+
cx = base_cx
|
|
387
|
+
cy = base_cy + int(math.sin(pulse / 3.0) * 4 * scale)
|
|
388
|
+
|
|
389
|
+
def p(dx: float, dy: float) -> tuple[int, int]:
|
|
390
|
+
return (round(cx + dx * scale), round(cy + dy * scale))
|
|
391
|
+
|
|
392
|
+
def box(x1: float, y1: float, x2: float, y2: float) -> tuple[int, int, int, int]:
|
|
393
|
+
return (*p(x1, y1), *p(x2, y2))
|
|
394
|
+
|
|
395
|
+
outline_width = max(2, round(3 * scale))
|
|
396
|
+
eye_width = max(2, round(3 * scale))
|
|
397
|
+
draw.ellipse(box(-82, 84, 82, 110), fill="#03101A")
|
|
398
|
+
draw.polygon(
|
|
399
|
+
[
|
|
400
|
+
p(-78, -52),
|
|
401
|
+
p(-50, -122),
|
|
402
|
+
p(-16, -54),
|
|
403
|
+
p(16, -54),
|
|
404
|
+
p(50, -122),
|
|
405
|
+
p(78, -52),
|
|
406
|
+
p(66, 58),
|
|
407
|
+
p(34, 88),
|
|
408
|
+
p(-34, 88),
|
|
409
|
+
p(-66, 58),
|
|
410
|
+
],
|
|
411
|
+
fill=MASCOT_BODY,
|
|
412
|
+
outline="#122B42",
|
|
413
|
+
)
|
|
414
|
+
draw.polygon([p(-48, -84), p(-28, -52), p(-62, -52)], fill="#CFFFF8")
|
|
415
|
+
draw.polygon([p(48, -84), p(28, -52), p(62, -52)], fill="#CFFFF8")
|
|
416
|
+
draw.pieslice(box(-86, -30, -38, 56), start=92, end=270, fill=MASCOT_WING, outline="#122B42", width=outline_width)
|
|
417
|
+
draw.pieslice(box(38, -30, 86, 56), start=270, end=88, fill=MASCOT_WING, outline="#122B42", width=outline_width)
|
|
418
|
+
draw.ellipse(box(-52, -40, -10, 4), fill=TERMINAL_BG, outline="#122B42", width=eye_width)
|
|
419
|
+
draw.ellipse(box(10, -40, 52, 4), fill=TERMINAL_BG, outline="#122B42", width=eye_width)
|
|
420
|
+
blink = pulse % 17 == 0
|
|
421
|
+
if blink:
|
|
422
|
+
draw.line((*p(-43, -18), *p(-19, -18)), fill=ACCENT, width=max(2, round(4 * scale)))
|
|
423
|
+
draw.line((*p(19, -18), *p(43, -18)), fill=ACCENT, width=max(2, round(4 * scale)))
|
|
424
|
+
else:
|
|
425
|
+
draw.ellipse(box(-39, -30, -23, -14), fill=ACCENT)
|
|
426
|
+
draw.ellipse(box(23, -30, 39, -14), fill=ACCENT)
|
|
427
|
+
draw.polygon([p(-10, -2), p(10, -2), p(0, 15)], fill=GOLD)
|
|
428
|
+
draw_mascot_mouth(draw, cx, cy, scene, pulse, scale=scale)
|
|
429
|
+
draw.ellipse(box(-18, 66, -6, 78), fill=ACCENT)
|
|
430
|
+
draw.ellipse(box(6, 66, 18, 78), fill=SKY)
|
|
308
431
|
|
|
309
432
|
|
|
310
433
|
def render_scene(scene: dict, typed_chars: Optional[int] = None, shown_lines: int = 0, cursor: bool = False) -> Image.Image:
|
|
@@ -313,6 +436,9 @@ def render_scene(scene: dict, typed_chars: Optional[int] = None, shown_lines: in
|
|
|
313
436
|
draw_background(draw)
|
|
314
437
|
draw_scene_header(draw, scene)
|
|
315
438
|
draw_terminal_shell(draw)
|
|
439
|
+
pulse = int((typed_chars or 0) + shown_lines * 3)
|
|
440
|
+
draw_speech_bubble(draw, scene)
|
|
441
|
+
draw_mascot(draw, scene, pulse=pulse)
|
|
316
442
|
|
|
317
443
|
command = scene["command"] if typed_chars is None else scene["command"][:typed_chars]
|
|
318
444
|
command_lines = wrap_command(command, MONO_32, CONTENT_WIDTH)
|
|
@@ -340,7 +466,7 @@ def render_scene(scene: dict, typed_chars: Optional[int] = None, shown_lines: in
|
|
|
340
466
|
footer_box = draw.textbbox((0, 0), footer, font=MONO_24)
|
|
341
467
|
footer_width = (footer_box[2] - footer_box[0]) + 46
|
|
342
468
|
footer_x = (WIDTH - footer_width) // 2
|
|
343
|
-
footer_y = HEIGHT -
|
|
469
|
+
footer_y = HEIGHT - 84
|
|
344
470
|
draw.rounded_rectangle((footer_x, footer_y, footer_x + footer_width, footer_y + 54), radius=15, fill=ACCENT)
|
|
345
471
|
draw.text((footer_x + 23, footer_y + 14), footer, font=MONO_24, fill=WINDOW_BG)
|
|
346
472
|
return image
|
|
@@ -356,7 +482,7 @@ def build_storyboard(final_frames: list[Image.Image]) -> Image.Image:
|
|
|
356
482
|
board_height = thumb_height * rows + gutter * (rows + 1) + 72
|
|
357
483
|
storyboard = Image.new("RGBA", (board_width, board_height), OUTER_BG)
|
|
358
484
|
draw = ImageDraw.Draw(storyboard)
|
|
359
|
-
draw.text((gutter, 20), "ORP
|
|
485
|
+
draw.text((gutter, 20), "ORP mascot walkthrough storyboard", font=SANS_34, fill=INK)
|
|
360
486
|
for idx, frame in enumerate(final_frames):
|
|
361
487
|
thumb = frame.copy()
|
|
362
488
|
thumb.thumbnail((thumb_width, thumb_height))
|
|
@@ -370,6 +496,8 @@ def build_storyboard(final_frames: list[Image.Image]) -> Image.Image:
|
|
|
370
496
|
|
|
371
497
|
def main() -> None:
|
|
372
498
|
ASSETS.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
for old_scene in ASSETS.glob("terminal-scene-*.png"):
|
|
500
|
+
old_scene.unlink()
|
|
373
501
|
frames: list[Image.Image] = []
|
|
374
502
|
durations: list[int] = []
|
|
375
503
|
final_scene_frames: list[Image.Image] = []
|