cortico-world-canvas 0.1.0
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/LICENSE +21 -0
- package/README.md +162 -0
- package/package.json +52 -0
- package/src/ENV_PROMPT.md +63 -0
- package/src/GAME_GUIDE.md +20 -0
- package/src/RESEARCH.md +58 -0
- package/src/brush-renderer.ts +56 -0
- package/src/brushes.ts +1 -0
- package/src/config.ts +22 -0
- package/src/definition.ts +11 -0
- package/src/drawing.ts +238 -0
- package/src/engine-child.ts +23 -0
- package/src/game.ts +207 -0
- package/src/index.ts +13 -0
- package/src/inspect.ts +47 -0
- package/src/motion.ts +132 -0
- package/src/paint.ts +132 -0
- package/src/public/app.js +304 -0
- package/src/public/brush-alpha.js +39 -0
- package/src/public/brush-engine.js +40 -0
- package/src/public/corti-cursor.svg +213 -0
- package/src/public/index.html +47 -0
- package/src/public/styles.css +1326 -0
- package/src/server.ts +306 -0
- package/src/store.ts +286 -0
- package/src/tools.ts +111 -0
- package/src/world.ts +120 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { createServer, type Server, type ServerResponse } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import express from 'express';
|
|
6
|
+
import { createCanvas, loadImage } from 'canvas';
|
|
7
|
+
import { object } from './drawing.ts';
|
|
8
|
+
import { BrushRenderer } from './brush-renderer.ts';
|
|
9
|
+
import { toolAction } from './paint.ts';
|
|
10
|
+
import { inspectImage } from './inspect.ts';
|
|
11
|
+
import { animateStep, type Cursor } from './motion.ts';
|
|
12
|
+
import { CanvasStore, type Reference } from './store.ts';
|
|
13
|
+
import { GameStore, type GameStatus } from './game.ts';
|
|
14
|
+
|
|
15
|
+
export interface CanvasReply { text: string; images?: { png: string; fallback: string }[] }
|
|
16
|
+
interface CanvasServerOptions {
|
|
17
|
+
directory: string;
|
|
18
|
+
port: number;
|
|
19
|
+
animationMs?: number;
|
|
20
|
+
browserPath?: string;
|
|
21
|
+
onReferences?: (references: Reference[]) => void;
|
|
22
|
+
/** 倒计时到点的通知正文,由 World 作为内部事件投递。 */
|
|
23
|
+
onGameEvent?: (text: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const GAME_COMMANDS = ['canvas_game_enter', 'canvas_game_round', 'canvas_game_timer', 'canvas_game_reveal', 'canvas_game_scoreboard', 'canvas_game_score', 'canvas_game_exit'];
|
|
27
|
+
|
|
28
|
+
export class CanvasServer {
|
|
29
|
+
readonly store: CanvasStore;
|
|
30
|
+
readonly game: GameStore;
|
|
31
|
+
private server: Server | null = null;
|
|
32
|
+
private subscribers = new Set<ServerResponse>();
|
|
33
|
+
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
35
|
+
private queued = 0;
|
|
36
|
+
private painting: { step: number; total: number; label: string } | null = null;
|
|
37
|
+
private cursor: Cursor = { x: 0, y: 0, mode: 'idle' };
|
|
38
|
+
private preview: string | null = null;
|
|
39
|
+
private stopping = false;
|
|
40
|
+
private readonly brush: BrushRenderer;
|
|
41
|
+
url = '';
|
|
42
|
+
|
|
43
|
+
constructor(private readonly options: CanvasServerOptions) {
|
|
44
|
+
this.store = new CanvasStore(options.directory);
|
|
45
|
+
this.brush = new BrushRenderer(options.browserPath);
|
|
46
|
+
this.game = new GameStore({
|
|
47
|
+
directory: this.store.directory,
|
|
48
|
+
onChange: () => this.publish(),
|
|
49
|
+
onExpire: (round) => options.onGameEvent?.(`[你画我猜] 第 ${round.id} 局倒计时结束,题目「${round.answer}」${round.hint ? `(提示:${round.hint})` : ''}。之后的回答不计分;请先用 canvas_game_score 给倒计时内答对的观众记分,再用 canvas_game_reveal 揭示答案。`),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
state() {
|
|
54
|
+
return { board: this.store.board, references: this.store.references, queued: this.queued,
|
|
55
|
+
painting: this.painting, cursor: this.cursor, preview: this.preview, game: this.game.view() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private gameCommand(name: string, args: Record<string, unknown>): CanvasReply {
|
|
59
|
+
const reply = (message: string, status: GameStatus): CanvasReply => ({ text: JSON.stringify({ message, ...status }) });
|
|
60
|
+
switch (name) {
|
|
61
|
+
case 'canvas_game_enter': {
|
|
62
|
+
const status = this.game.enter();
|
|
63
|
+
return { text: `${this.game.guide()}\n\n${JSON.stringify({ message: '画室网页已切换到你画我猜界面', ...status })}` };
|
|
64
|
+
}
|
|
65
|
+
case 'canvas_game_round': {
|
|
66
|
+
const status = this.game.startRound(args);
|
|
67
|
+
return reply(`第 ${status.round!.id} 局已开始,倒计时 ${status.round!.remainingSeconds} 秒;题目只有你知道`, status);
|
|
68
|
+
}
|
|
69
|
+
case 'canvas_game_timer': {
|
|
70
|
+
const status = this.game.timer(args);
|
|
71
|
+
return reply(status.round ? `倒计时已重设为 ${status.round.remainingSeconds} 秒` : '本局已取消,未揭示答案', status);
|
|
72
|
+
}
|
|
73
|
+
case 'canvas_game_reveal': {
|
|
74
|
+
const status = this.game.reveal(args);
|
|
75
|
+
return reply(`第 ${status.lastReveal!.roundId} 局已揭示答案「${status.lastReveal!.answer}」`, status);
|
|
76
|
+
}
|
|
77
|
+
case 'canvas_game_scoreboard': return reply('当前游戏状态', this.game.status());
|
|
78
|
+
case 'canvas_game_score': {
|
|
79
|
+
const result = this.game.score(args);
|
|
80
|
+
return { text: JSON.stringify({ message: `已更新 ${result.changes.length} 项`, ...result }) };
|
|
81
|
+
}
|
|
82
|
+
default: return reply('已回到自由画板', this.game.exit());
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private emit(type: string, data: unknown): void {
|
|
87
|
+
const packet = `data: ${JSON.stringify({ type, data })}\n\n`;
|
|
88
|
+
for (const response of this.subscribers) {
|
|
89
|
+
if (response.writableLength > 4 * 1024 * 1024) { response.destroy(); this.subscribers.delete(response); }
|
|
90
|
+
else response.write(packet);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private publish(): void { this.emit('state', this.state()); }
|
|
95
|
+
|
|
96
|
+
private enqueue<T>(action: () => Promise<T> | T): Promise<T> {
|
|
97
|
+
if (this.stopping) return Promise.reject(new Error('画布服务已停止'));
|
|
98
|
+
if (this.queued >= 16) return Promise.reject(new Error('画布队列已满,请稍后重试'));
|
|
99
|
+
this.queued++;
|
|
100
|
+
this.publish();
|
|
101
|
+
const result = this.tail.then(() => { if (this.stopping) throw new Error('画布调用已取消'); return action(); });
|
|
102
|
+
this.tail = result.catch(() => undefined).finally(() => { this.queued--; this.publish(); });
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
command(name: string, args: Record<string, unknown>): Promise<CanvasReply> {
|
|
107
|
+
// Game commands touch no pixels and answer immediately instead of waiting behind a playing batch.
|
|
108
|
+
if (GAME_COMMANDS.includes(name)) {
|
|
109
|
+
if (this.stopping) return Promise.reject(new Error('画布服务已停止'));
|
|
110
|
+
try { return Promise.resolve(this.gameCommand(name, args)); } catch (error) { return Promise.reject(error); }
|
|
111
|
+
}
|
|
112
|
+
return this.enqueue(async () => {
|
|
113
|
+
if (name === 'canvas_new') {
|
|
114
|
+
this.store.newBoard(args);
|
|
115
|
+
this.cursor = { x: 0, y: 0, mode: 'idle' };
|
|
116
|
+
return this.snapshot('已新建画布');
|
|
117
|
+
}
|
|
118
|
+
if (name === 'canvas_snapshot') return this.snapshot('当前画布', undefined, true, args);
|
|
119
|
+
if (name === 'canvas_save') {
|
|
120
|
+
const saved = this.store.save(args.filename);
|
|
121
|
+
return { text: JSON.stringify({ ...saved, url: `${this.url}${saved.url}` }) };
|
|
122
|
+
}
|
|
123
|
+
if (name === 'canvas_undo') {
|
|
124
|
+
const id = this.store.undo(args.step_id);
|
|
125
|
+
return this.snapshot(`已撤销步骤 ${id}`);
|
|
126
|
+
}
|
|
127
|
+
if (name === 'canvas_references') {
|
|
128
|
+
const selected = this.store.referenceImages(args.ids);
|
|
129
|
+
if (args.region !== undefined && selected.length !== 1) throw new Error('查看局部参考图时,ids 须选择一张图片');
|
|
130
|
+
const views = await Promise.all(selected.map(async ({ ref, png }) => {
|
|
131
|
+
const inspected = args.region !== undefined || args.grid !== undefined ? inspectImage(await loadImage(png), args) : { png, view: undefined };
|
|
132
|
+
return { ref, ...inspected };
|
|
133
|
+
}));
|
|
134
|
+
return {
|
|
135
|
+
text: JSON.stringify({ references: this.store.references, shown: selected.map(({ ref }) => ref.id),
|
|
136
|
+
views: views.filter(({ view }) => view).map(({ ref, view }) => ({ id: ref.id, ...view })) }),
|
|
137
|
+
images: views.map(({ ref, png, view }) => ({ png: png.toString('base64'), fallback: `[参考图:${ref.name},ID=${ref.id},${ref.width}×${ref.height}${view ? ',观察区域见 views' : ''}]` })),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (!['canvas_draw', 'canvas_edit', 'canvas_transform'].includes(name)) throw new Error('未知画布工具');
|
|
141
|
+
const editing = name !== 'canvas_draw';
|
|
142
|
+
const board = this.store.current();
|
|
143
|
+
const steps = name === 'canvas_transform' ? this.store.prepareTransform(args) : editing ? this.store.prepareEdits(args.updates) : this.store.prepare(args.actions);
|
|
144
|
+
const previous = board.steps.filter((old) => steps.some((step) => step.id === old.id)).map((step) => ({ step_id: step.id, action: toolAction(step.action) }));
|
|
145
|
+
const planned = editing ? board.steps.map((old) => steps.find((step) => step.id === old.id) ?? old) : [...board.steps, ...steps];
|
|
146
|
+
this.store.validateLayers(planned);
|
|
147
|
+
let working = [...board.steps];
|
|
148
|
+
let canvas = this.store.copyCanvas();
|
|
149
|
+
const duration = Math.min(this.options.animationMs ?? 850, 12000 / steps.length);
|
|
150
|
+
try {
|
|
151
|
+
for (let index = 0; index < steps.length; index++) {
|
|
152
|
+
if (this.stopping) throw new Error('画布调用已取消');
|
|
153
|
+
this.painting = { step: index + 1, total: steps.length, label: steps[index].action.label };
|
|
154
|
+
if (steps[index].action.kind === 'paint') this.store.cacheRaster(steps[index], await this.brush.render(board, steps[index]));
|
|
155
|
+
}
|
|
156
|
+
for (let index = 0; index < steps.length; index++) {
|
|
157
|
+
if (this.stopping) throw new Error('画布调用已取消');
|
|
158
|
+
this.painting = { step: index + 1, total: steps.length, label: steps[index].action.label };
|
|
159
|
+
const position = editing ? planned.findIndex((step) => step.id === steps[index].id) : working.length;
|
|
160
|
+
const later = editing ? planned.slice(position + 1) : [];
|
|
161
|
+
// Edits preview the final dependency graph, including clips changed in the same batch.
|
|
162
|
+
if (editing) canvas = this.store.replay(planned.slice(0, position));
|
|
163
|
+
const frameBoard = { ...board, steps: editing ? planned : [...working, steps[index]] };
|
|
164
|
+
const preview = editing ? createCanvas(board.width, board.height) : canvas;
|
|
165
|
+
// Flood fills depend on lower pixels; other layers can be flattened once per edited step.
|
|
166
|
+
const upper = editing && !later.some((step) => step.action.kind === 'fill') ? createCanvas(board.width, board.height) : null;
|
|
167
|
+
if (upper) for (const step of later) this.store.draw(upper, frameBoard, step);
|
|
168
|
+
this.cursor = await animateStep(canvas, board, steps[index], this.cursor, duration, (cursor) => {
|
|
169
|
+
if (this.stopping) throw new Error('画布调用已取消');
|
|
170
|
+
this.cursor = cursor;
|
|
171
|
+
if (editing) {
|
|
172
|
+
preview.getContext('2d').drawImage(canvas, 0, 0);
|
|
173
|
+
if (upper) preview.getContext('2d').drawImage(upper, 0, 0);
|
|
174
|
+
else for (const step of later) this.store.draw(preview, frameBoard, step);
|
|
175
|
+
}
|
|
176
|
+
this.preview = preview.toBuffer('image/png').toString('base64');
|
|
177
|
+
this.emit('frame', { ...this.painting, cursor, png: this.preview });
|
|
178
|
+
}, (target) => this.store.draw(target, frameBoard, steps[index]));
|
|
179
|
+
working = editing ? planned : [...working, steps[index]];
|
|
180
|
+
}
|
|
181
|
+
if (this.stopping) throw new Error('画布调用已取消');
|
|
182
|
+
this.store.commit(working, editing ? this.store.replay(working) : canvas);
|
|
183
|
+
const reply = this.snapshot(`已${editing ? '修改' : '完成'} ${steps.length} 个步骤`, steps.map((step) => step.id));
|
|
184
|
+
if (editing) reply.text = JSON.stringify({ ...JSON.parse(reply.text), previous });
|
|
185
|
+
return reply;
|
|
186
|
+
} finally {
|
|
187
|
+
this.preview = null;
|
|
188
|
+
this.painting = null;
|
|
189
|
+
this.cursor = { ...this.cursor, mode: 'idle' };
|
|
190
|
+
this.publish();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private snapshot(message: string, completed?: string[], includeSteps = false, args: Record<string, unknown> = {}): CanvasReply {
|
|
196
|
+
const board = this.store.current();
|
|
197
|
+
const { steps, ...description } = board;
|
|
198
|
+
const inspected = args.region !== undefined || args.grid !== undefined ? inspectImage(this.store.canvas!, args) : undefined;
|
|
199
|
+
return {
|
|
200
|
+
text: JSON.stringify({ message, board: { ...description, stepCount: steps.length, ...(includeSteps ? { steps: steps.map((step) => ({ id: step.id, action: toolAction(step.action) })) } : {}) },
|
|
201
|
+
...(inspected ? { view: inspected.view } : {}),
|
|
202
|
+
...(completed ? { completed: steps.filter((step) => completed.includes(step.id)).map((step) => ({ id: step.id, label: step.action.label })) } : {}) }),
|
|
203
|
+
images: [{ png: (inspected?.png ?? this.store.png()).toString('base64'), fallback: `[画布截图:${board.title},${board.width}×${board.height},${board.steps.length} 步${inspected ? ',观察区域见 view' : ''}]` }],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async start(): Promise<void> {
|
|
208
|
+
const app = express();
|
|
209
|
+
app.disable('x-powered-by');
|
|
210
|
+
app.use((req, res, next) => {
|
|
211
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
212
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
213
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'none'");
|
|
214
|
+
if (!['GET', 'HEAD'].includes(req.method) && req.headers.origin && req.headers.origin !== this.url) {
|
|
215
|
+
res.status(403).json({ error: '写入只接受本机同源请求' }); return;
|
|
216
|
+
}
|
|
217
|
+
next();
|
|
218
|
+
});
|
|
219
|
+
app.use(express.json({ limit: '14mb' }));
|
|
220
|
+
app.get('/api/state', (_req, res) => res.json(this.state()));
|
|
221
|
+
app.get('/api/canvas.png', (_req, res) => {
|
|
222
|
+
if (!this.store.board) { res.status(404).end(); return; }
|
|
223
|
+
res.type('png').send(this.store.png());
|
|
224
|
+
});
|
|
225
|
+
app.get('/events', (_req, res) => {
|
|
226
|
+
res.set({ 'Content-Type': 'text/event-stream', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
227
|
+
res.flushHeaders();
|
|
228
|
+
res.write(`data: ${JSON.stringify({ type: 'state', data: this.state() })}\n\n`);
|
|
229
|
+
this.subscribers.add(res);
|
|
230
|
+
res.on('close', () => this.subscribers.delete(res));
|
|
231
|
+
});
|
|
232
|
+
app.post('/api/command', (req, res) => {
|
|
233
|
+
void (async () => {
|
|
234
|
+
const body = object(req.body);
|
|
235
|
+
const reply = await this.command(String(body.name), object(body.args ?? {}));
|
|
236
|
+
// canvas_game_enter answers with the guide text ahead of its JSON line; other replies are JSON throughout.
|
|
237
|
+
const json = reply.text.slice(reply.text.lastIndexOf('\n') + 1);
|
|
238
|
+
res.json({ result: JSON.parse(json), state: this.state() });
|
|
239
|
+
})().catch((error) => res.status(400).json({ error: String(error.message ?? error) }));
|
|
240
|
+
});
|
|
241
|
+
app.post('/api/references', (req, res) => {
|
|
242
|
+
void this.enqueue(async () => {
|
|
243
|
+
const body = object(req.body);
|
|
244
|
+
const ref = await this.store.upload(body.name, body.base64);
|
|
245
|
+
this.options.onReferences?.([ref]);
|
|
246
|
+
return ref;
|
|
247
|
+
}).then((reference) => res.json({ reference, references: this.store.references }))
|
|
248
|
+
.catch((error) => res.status(400).json({ error: String(error.message ?? error) }));
|
|
249
|
+
});
|
|
250
|
+
app.patch('/api/references/:id', (req, res) => {
|
|
251
|
+
void this.enqueue(() => {
|
|
252
|
+
this.store.renameReference(String(req.params.id), object(req.body).name);
|
|
253
|
+
this.options.onReferences?.(this.store.references.filter((ref) => ref.id === req.params.id));
|
|
254
|
+
}).then(() => res.json({ references: this.store.references }))
|
|
255
|
+
.catch((error) => res.status(400).json({ error: String(error.message ?? error) }));
|
|
256
|
+
});
|
|
257
|
+
app.delete('/api/references/:id', (req, res) => {
|
|
258
|
+
void this.enqueue(() => this.store.removeReference(String(req.params.id)))
|
|
259
|
+
.then(() => res.json({ references: this.store.references }))
|
|
260
|
+
.catch((error) => res.status(400).json({ error: String(error.message ?? error) }));
|
|
261
|
+
});
|
|
262
|
+
app.use('/references', express.static(join(this.store.directory, 'references')));
|
|
263
|
+
app.use('/exports', express.static(join(this.store.directory, 'exports'), { setHeaders: (res) => res.setHeader('Content-Disposition', 'attachment') }));
|
|
264
|
+
const publicDir = fileURLToPath(new URL('./public/', import.meta.url));
|
|
265
|
+
app.get('/overlay', (_req, res) => res.sendFile(join(publicDir, 'index.html')));
|
|
266
|
+
app.use(express.static(publicDir));
|
|
267
|
+
app.use((error: { status?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
268
|
+
res.status(error.status ?? 400).json({ error: error.message ?? '请求失败' });
|
|
269
|
+
});
|
|
270
|
+
for (let offset = 0; offset < (this.options.port === 0 ? 1 : 50); offset++) {
|
|
271
|
+
const candidate = createServer(app);
|
|
272
|
+
try {
|
|
273
|
+
await new Promise<void>((resolve, reject) => {
|
|
274
|
+
candidate.once('error', reject);
|
|
275
|
+
candidate.listen(this.options.port + offset, '127.0.0.1', () => { candidate.removeListener('error', reject); resolve(); });
|
|
276
|
+
});
|
|
277
|
+
this.server = candidate;
|
|
278
|
+
this.url = `http://127.0.0.1:${(candidate.address() as AddressInfo).port}`;
|
|
279
|
+
this.heartbeat = setInterval(() => { for (const response of this.subscribers) response.write(': heartbeat\n\n'); }, 15000);
|
|
280
|
+
this.heartbeat.unref();
|
|
281
|
+
return;
|
|
282
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error; }
|
|
283
|
+
}
|
|
284
|
+
throw new Error('画布服务没有可用端口');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async stop(): Promise<void> {
|
|
288
|
+
this.stopping = true;
|
|
289
|
+
this.game.close();
|
|
290
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
291
|
+
await this.tail;
|
|
292
|
+
await this.brush.close();
|
|
293
|
+
for (const response of this.subscribers) response.end();
|
|
294
|
+
this.subscribers.clear();
|
|
295
|
+
if (this.server) {
|
|
296
|
+
const server = this.server;
|
|
297
|
+
this.server = null;
|
|
298
|
+
await new Promise<void>((resolve) => { server.close(() => resolve()); server.closeIdleConnections(); });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async abort(): Promise<void> {
|
|
303
|
+
this.stopping = true;
|
|
304
|
+
await this.brush.close();
|
|
305
|
+
}
|
|
306
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHash, randomUUID, randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { createCanvas, Image, loadImage, type Canvas } from 'canvas';
|
|
5
|
+
import { blank, color, draw, label, number, object, parseActions, replay, type Board, type DrawStep } from './drawing.ts';
|
|
6
|
+
import { contours, multiply, validateTransform, type Matrix } from './paint.ts';
|
|
7
|
+
|
|
8
|
+
export interface Reference { id: string; name: string; width: number; height: number; url: string }
|
|
9
|
+
interface SavedCanvas { filename: string; path: string; url: string }
|
|
10
|
+
|
|
11
|
+
function atomicJson(path: string, value: unknown): void {
|
|
12
|
+
const temp = `${path}.tmp`;
|
|
13
|
+
writeFileSync(temp, JSON.stringify(value));
|
|
14
|
+
renameSync(temp, path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function patchAction(step: DrawStep, input: unknown): Record<string, unknown> {
|
|
18
|
+
if (!step.action.paint) throw new Error('patch 需要 p5.brush 步骤;旧动作请使用 action 完整替换');
|
|
19
|
+
const patch = object(input);
|
|
20
|
+
const styles: Record<string, readonly string[]> = {
|
|
21
|
+
fill: ['color', 'mode', 'opacity', 'bleed', 'texture'],
|
|
22
|
+
stroke: ['brush', 'color', 'width', 'pressure'],
|
|
23
|
+
hatch: ['brush', 'color', 'width', 'spacing', 'angle'],
|
|
24
|
+
};
|
|
25
|
+
const merged: Record<string, unknown> = { ...step.action.paint, label: step.action.label };
|
|
26
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
27
|
+
if (!['label', 'path', 'matrix', 'instances', 'clip_to', ...Object.keys(styles)].includes(key)) throw new Error(`未知 patch 字段: ${key}`);
|
|
28
|
+
if (Object.hasOwn(styles, key)) {
|
|
29
|
+
if (value === null) { delete merged[key]; continue; }
|
|
30
|
+
const nested = { ...(merged[key] as Record<string, unknown> | undefined) };
|
|
31
|
+
for (const [field, change] of Object.entries(object(value))) {
|
|
32
|
+
if (!styles[key].includes(field)) throw new Error(`未知 patch.${key} 字段: ${field}`);
|
|
33
|
+
if (change === null) delete nested[field];
|
|
34
|
+
else nested[field] = change;
|
|
35
|
+
}
|
|
36
|
+
merged[key] = nested;
|
|
37
|
+
} else if ((key === 'clip_to' || key === 'instances') && value === null) delete merged[key];
|
|
38
|
+
else merged[key] = value;
|
|
39
|
+
}
|
|
40
|
+
return merged;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class CanvasStore {
|
|
44
|
+
board: Board | null = null;
|
|
45
|
+
canvas: Canvas | null = null;
|
|
46
|
+
references: Reference[] = [];
|
|
47
|
+
readonly directory: string;
|
|
48
|
+
private rasters = new Map<string, Image>();
|
|
49
|
+
private rasterBytes = 0;
|
|
50
|
+
|
|
51
|
+
constructor(directory: string) {
|
|
52
|
+
this.directory = resolve(directory);
|
|
53
|
+
for (const subdir of ['boards', 'references', 'exports', 'layers']) mkdirSync(join(this.directory, subdir), { recursive: true });
|
|
54
|
+
const indexPath = join(this.directory, 'index.json');
|
|
55
|
+
if (existsSync(indexPath)) {
|
|
56
|
+
const index = JSON.parse(readFileSync(indexPath, 'utf8')) as { boardId: string | null; references: Reference[] };
|
|
57
|
+
this.references = index.references;
|
|
58
|
+
if (index.boardId) {
|
|
59
|
+
this.board = JSON.parse(readFileSync(join(this.directory, 'boards', `${index.boardId}.json`), 'utf8')) as Board;
|
|
60
|
+
this.canvas = this.replay(this.board.steps);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private persistIndex(board: Board | null, references = this.references): void {
|
|
66
|
+
atomicJson(join(this.directory, 'index.json'), { boardId: board?.id ?? null, references });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
current(): Board {
|
|
70
|
+
if (!this.board) throw new Error('尚未新建画布,请先调用 canvas_new');
|
|
71
|
+
return this.board;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
newBoard(args: Record<string, unknown>): Board {
|
|
75
|
+
const board: Board = {
|
|
76
|
+
id: randomUUID(), title: label(args.title, '标题'),
|
|
77
|
+
width: number(args.width ?? 1024, 'width', 64, 2048, true),
|
|
78
|
+
height: number(args.height ?? 1024, 'height', 64, 2048, true),
|
|
79
|
+
background: color(args.background ?? '#ffffff'), revision: 0, steps: [],
|
|
80
|
+
};
|
|
81
|
+
const canvas = blank(board);
|
|
82
|
+
atomicJson(join(this.directory, 'boards', `${board.id}.json`), board);
|
|
83
|
+
this.persistIndex(board);
|
|
84
|
+
this.board = board;
|
|
85
|
+
this.canvas = canvas;
|
|
86
|
+
return board;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
prepare(input: unknown): DrawStep[] {
|
|
90
|
+
const board = this.current();
|
|
91
|
+
const actions = parseActions(input, board);
|
|
92
|
+
if (board.steps.length + actions.length > 5000) throw new Error('单张画布最多 5000 步,请保存后新建画布');
|
|
93
|
+
return actions.map((action) => ({ id: randomUUID(), action, seed: randomBytes(4).readUInt32LE() }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
prepareEdits(input: unknown): DrawStep[] {
|
|
97
|
+
const board = this.current();
|
|
98
|
+
if (!Array.isArray(input) || input.length < 1 || input.length > 64) throw new Error('每批需要 1–64 个修改');
|
|
99
|
+
const updates = input.map(object);
|
|
100
|
+
const ids = new Set<unknown>();
|
|
101
|
+
const originals = updates.map((update) => {
|
|
102
|
+
const step = board.steps.find((step) => step.id === update.step_id);
|
|
103
|
+
if (!step) throw new Error('找不到当前画布中的 step_id');
|
|
104
|
+
if (ids.has(step.id)) throw new Error('同一批不能重复修改同一个 step_id');
|
|
105
|
+
ids.add(step.id);
|
|
106
|
+
if (Object.hasOwn(update, 'action') === Object.hasOwn(update, 'patch')) throw new Error('每个修改须指定 action 或 patch,且只能指定一项');
|
|
107
|
+
return step;
|
|
108
|
+
});
|
|
109
|
+
const actions = parseActions(updates.map((update, index) => Object.hasOwn(update, 'patch') ? patchAction(originals[index], update.patch) : update.action), board);
|
|
110
|
+
const edits = originals.map((step, index) => {
|
|
111
|
+
return { id: step.id, seed: step.seed, action: actions[index] };
|
|
112
|
+
});
|
|
113
|
+
const byId = new Map(edits.map((step) => [step.id, step]));
|
|
114
|
+
this.validateLayers(board.steps.map((step) => byId.get(step.id) ?? step));
|
|
115
|
+
return edits;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
prepareTransform(args: Record<string, unknown>): DrawStep[] {
|
|
119
|
+
if (!Array.isArray(args.step_ids) || !args.step_ids.length || args.step_ids.length > 64 || new Set(args.step_ids).size !== args.step_ids.length) throw new Error('step_ids 须为 1–64 个不重复的步骤 ID');
|
|
120
|
+
const ids = args.step_ids;
|
|
121
|
+
const dx = number(args.dx ?? 0, 'dx', -4096, 4096), dy = number(args.dy ?? 0, 'dy', -4096, 4096);
|
|
122
|
+
const sx = number(args.scale_x ?? 1, 'scale_x', 0.05, 20), sy = number(args.scale_y ?? sx, 'scale_y', 0.05, 20);
|
|
123
|
+
const angle = number(args.rotate ?? 0, 'rotate', -360, 360) * Math.PI / 180;
|
|
124
|
+
const origin = args.origin === undefined ? { x: 0, y: 0 } : object(args.origin);
|
|
125
|
+
const x = number(origin.x, 'origin.x', -4096, 4096), y = number(origin.y, 'origin.y', -4096, 4096);
|
|
126
|
+
const c = Math.cos(angle), s = Math.sin(angle);
|
|
127
|
+
const matrix: Matrix = [c * sx, s * sx, -s * sy, c * sy, x + dx - c * sx * x + s * sy * y, y + dy - s * sx * x - c * sy * y];
|
|
128
|
+
let instances = 0;
|
|
129
|
+
return ids.map((id) => {
|
|
130
|
+
const step = this.current().steps.find((step) => step.id === id);
|
|
131
|
+
if (!step || step.action.kind !== 'paint') throw new Error('canvas_transform 需要当前画布中的 p5.brush 步骤 ID');
|
|
132
|
+
const paint = { ...step.action.paint!, matrix: multiply(matrix, step.action.paint!.matrix) };
|
|
133
|
+
instances += paint.instances?.length ?? 1;
|
|
134
|
+
if (instances > 256) throw new Error('每批 p5.brush 实例总数最多 256');
|
|
135
|
+
paint.matrix.forEach((value) => number(value, 'matrix', -8192, 8192));
|
|
136
|
+
validateTransform(contours(paint.path), paint.matrix, paint.instances);
|
|
137
|
+
return { id: step.id, seed: step.seed, action: { ...step.action, paint } };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
validateLayers(steps: DrawStep[]): void {
|
|
142
|
+
const earlier = new Map<string, DrawStep>();
|
|
143
|
+
for (const step of steps) {
|
|
144
|
+
const clip = step.action.paint?.clip_to;
|
|
145
|
+
if (clip) {
|
|
146
|
+
const target = earlier.get(clip);
|
|
147
|
+
if (!target?.action.paint || contours(target.action.paint.path).some((part) => !part.closed)) throw new Error(`clip_to ${clip} 必须引用更早的闭合 p5.brush 部件;请先修改或撤销依赖它的步骤`);
|
|
148
|
+
}
|
|
149
|
+
earlier.set(step.id, step);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
cacheRaster(step: DrawStep, png: Buffer): void {
|
|
154
|
+
const hash = createHash('sha256').update(png).digest('hex');
|
|
155
|
+
const path = join(this.directory, 'layers', `${hash}.png`);
|
|
156
|
+
if (!existsSync(path)) writeFileSync(path, png, { flag: 'wx' });
|
|
157
|
+
step.raster = hash;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private raster(step: DrawStep): Image {
|
|
161
|
+
const key = step.raster!;
|
|
162
|
+
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('p5.brush 图层缓存缺失或损坏');
|
|
163
|
+
let image = this.rasters.get(key);
|
|
164
|
+
if (image) this.rasters.delete(key);
|
|
165
|
+
else {
|
|
166
|
+
image = new Image(); image.src = readFileSync(join(this.directory, 'layers', `${key}.png`));
|
|
167
|
+
this.rasterBytes += image.width * image.height * 4;
|
|
168
|
+
}
|
|
169
|
+
this.rasters.set(key, image);
|
|
170
|
+
while (this.rasterBytes > 128 * 1024 * 1024 && this.rasters.size > 1) {
|
|
171
|
+
const [oldKey, old] = this.rasters.entries().next().value!;
|
|
172
|
+
this.rasters.delete(oldKey); this.rasterBytes -= old.width * old.height * 4;
|
|
173
|
+
}
|
|
174
|
+
return image;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
draw(canvas: Canvas, board: Board, step: DrawStep): void { draw(canvas, board, step, (step) => this.raster(step)); }
|
|
178
|
+
replay(steps: DrawStep[]): Canvas {
|
|
179
|
+
this.validateLayers(steps);
|
|
180
|
+
return replay({ ...this.current(), steps }, (step) => this.raster(step));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
copyCanvas(): Canvas {
|
|
184
|
+
const board = this.current();
|
|
185
|
+
const canvas = createCanvas(board.width, board.height);
|
|
186
|
+
canvas.getContext('2d').drawImage(this.canvas!, 0, 0);
|
|
187
|
+
return canvas;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
commit(steps: DrawStep[], canvas: Canvas): void {
|
|
191
|
+
const board = { ...this.current(), revision: this.current().revision + 1, steps };
|
|
192
|
+
atomicJson(join(this.directory, 'boards', `${board.id}.json`), board);
|
|
193
|
+
this.board = board;
|
|
194
|
+
this.canvas = canvas;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
undo(id: unknown): string {
|
|
198
|
+
const board = this.current();
|
|
199
|
+
if (typeof id !== 'string' || !board.steps.some((step) => step.id === id)) throw new Error('找不到当前画布中的 step_id');
|
|
200
|
+
const steps = board.steps.filter((step) => step.id !== id);
|
|
201
|
+
this.commit(steps, this.replay(steps));
|
|
202
|
+
return id;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
png(): Buffer { this.current(); return this.canvas!.toBuffer('image/png'); }
|
|
206
|
+
|
|
207
|
+
save(filename?: unknown): SavedCanvas {
|
|
208
|
+
const board = this.current();
|
|
209
|
+
let stem: string;
|
|
210
|
+
if (filename !== undefined) {
|
|
211
|
+
stem = label(filename, '文件名').replace(/\.png$/i, '');
|
|
212
|
+
if (/[<>:"/\\|?*\x00-\x1f]/.test(stem) || /^\.+$/.test(stem)) throw new Error('文件名不能包含目录或特殊字符');
|
|
213
|
+
} else stem = board.title.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
|
214
|
+
const name = `${stem}-${randomUUID().slice(0, 8)}.png`;
|
|
215
|
+
const path = join(this.directory, 'exports', name);
|
|
216
|
+
writeFileSync(path, this.png(), { flag: 'wx' });
|
|
217
|
+
return { filename: name, path, url: `/exports/${encodeURIComponent(name)}` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async upload(name: unknown, data: unknown): Promise<Reference> {
|
|
221
|
+
if (this.references.length >= 64) throw new Error('参考图队列最多 64 张,请先移除不需要的图片');
|
|
222
|
+
const displayName = label(name, '参考图名称');
|
|
223
|
+
if (typeof data !== 'string' || data.length > 14_000_000 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) throw new Error('参考图必须是最多 10 MiB 的 PNG/JPEG');
|
|
224
|
+
const bytes = Buffer.from(data, 'base64');
|
|
225
|
+
if (bytes.length > 10 * 1024 * 1024) throw new Error('参考图不能超过 10 MiB');
|
|
226
|
+
const size = imageDimensions(bytes);
|
|
227
|
+
if (size.width < 1 || size.height < 1 || size.width > 4096 || size.height > 4096) throw new Error('参考图尺寸必须在 1–4096 像素之间');
|
|
228
|
+
const image = await loadImage(bytes);
|
|
229
|
+
const normalized = createCanvas(image.width, image.height);
|
|
230
|
+
normalized.getContext('2d').drawImage(image, 0, 0);
|
|
231
|
+
const id = randomUUID();
|
|
232
|
+
const reference: Reference = { id, name: displayName, width: image.width, height: image.height, url: `/references/${id}.png` };
|
|
233
|
+
writeFileSync(join(this.directory, 'references', `${id}.png`), normalized.toBuffer('image/png'));
|
|
234
|
+
const next = [...this.references, reference];
|
|
235
|
+
this.persistIndex(this.board, next);
|
|
236
|
+
this.references = next;
|
|
237
|
+
return reference;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
renameReference(id: string, name: unknown): void {
|
|
241
|
+
if (!this.references.some((ref) => ref.id === id)) throw new Error('参考图不存在');
|
|
242
|
+
const next = this.references.map((ref) => ref.id === id ? { ...ref, name: label(name, '参考图名称') } : ref);
|
|
243
|
+
this.persistIndex(this.board, next);
|
|
244
|
+
this.references = next;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
removeReference(id: string): void {
|
|
248
|
+
if (!this.references.some((ref) => ref.id === id)) throw new Error('参考图不存在');
|
|
249
|
+
const next = this.references.filter((ref) => ref.id !== id);
|
|
250
|
+
this.persistIndex(this.board, next);
|
|
251
|
+
this.references = next;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
referenceImages(ids: unknown): { ref: Reference; png: Buffer }[] {
|
|
255
|
+
if (ids !== undefined && (!Array.isArray(ids) || ids.length > 8 || ids.some((id) => typeof id !== 'string'))) throw new Error('ids 须为最多 8 个参考图 ID');
|
|
256
|
+
const selected = ids === undefined ? this.references.slice(0, 8).map((ref) => ref.id) : ids as string[];
|
|
257
|
+
return selected.map((id) => {
|
|
258
|
+
const ref = this.references.find((ref) => ref.id === id);
|
|
259
|
+
if (!ref) throw new Error(`参考图不存在: ${id}`);
|
|
260
|
+
return { ref, png: readFileSync(join(this.directory, 'references', `${id}.png`)) };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Read dimensions before invoking the native decoder; uploads accept PNG and JPEG only. */
|
|
266
|
+
export function imageDimensions(bytes: Buffer): { width: number; height: number } {
|
|
267
|
+
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
268
|
+
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
269
|
+
}
|
|
270
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
271
|
+
let offset = 2;
|
|
272
|
+
while (offset + 3 < bytes.length) {
|
|
273
|
+
if (bytes[offset++] !== 0xff) break;
|
|
274
|
+
while (bytes[offset] === 0xff) offset++;
|
|
275
|
+
const marker = bytes[offset++];
|
|
276
|
+
if (marker === 0xda || marker === 0xd9) break;
|
|
277
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
278
|
+
if (offset + 2 > bytes.length) break;
|
|
279
|
+
const length = bytes.readUInt16BE(offset);
|
|
280
|
+
if (length < 2 || offset + length > bytes.length) break;
|
|
281
|
+
if ([0xc0, 0xc1, 0xc2].includes(marker) && length >= 7) return { width: bytes.readUInt16BE(offset + 5), height: bytes.readUInt16BE(offset + 3) };
|
|
282
|
+
offset += length;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
throw new Error('无法读取 PNG/JPEG 参考图');
|
|
286
|
+
}
|