@muxiuesd/dsh-pixel-studio 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +56 -2
- package/client.js +2590 -0
- package/cordis.patch.yml +3 -0
- package/icon.png +0 -0
- package/index.js +620 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -4
package/cordis.patch.yml
ADDED
package/icon.png
ADDED
|
Binary file
|
package/index.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-pixel-studio.
|
|
3
|
+
*
|
|
4
|
+
* Owns the artwork store (plain JSON file under the profile directory) and a tiny
|
|
5
|
+
* same-origin HTTP API that the Client half calls with `fetch`. The Client half is
|
|
6
|
+
* an installed bundle module, so it has no direct RPC bridge to the Host: the
|
|
7
|
+
* official webServer route is the supported transport (same pattern as dsh-m).
|
|
8
|
+
*/
|
|
9
|
+
import { promises as fsp } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import zlib from 'node:zlib';
|
|
13
|
+
|
|
14
|
+
export const name = 'pixel-studio';
|
|
15
|
+
export const inject = ['webServer'];
|
|
16
|
+
|
|
17
|
+
const API_PATH = '/dsh-pixel-studio';
|
|
18
|
+
const HEADER = 'x-pixel-studio';
|
|
19
|
+
const MAX_BODY = 8 * 1024 * 1024;
|
|
20
|
+
const MAX_SIDE = 256;
|
|
21
|
+
const MAX_COLORS = 64;
|
|
22
|
+
const MAX_LAYERS = 8;
|
|
23
|
+
const MAX_EXPORT_PIXELS = 4096 * 4096;
|
|
24
|
+
|
|
25
|
+
const ID_PATTERN = /^[a-z0-9]{6,24}$/;
|
|
26
|
+
const COLOR_PATTERN = /^#[0-9a-f]{6}$/;
|
|
27
|
+
const FALLBACK_COLOR = '#000000';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PALETTE = [
|
|
30
|
+
'#000000', '#1d2b53', '#7e2553', '#008751',
|
|
31
|
+
'#ab5236', '#5f574f', '#c2c3c7', '#fff1e8',
|
|
32
|
+
'#ff004d', '#ffa300', '#ffec27', '#00e436',
|
|
33
|
+
'#29adff', '#83769c', '#ff77a8', '#ffccaa',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/* -------------------------------------------------------------------------- */
|
|
37
|
+
/* store */
|
|
38
|
+
/* -------------------------------------------------------------------------- */
|
|
39
|
+
|
|
40
|
+
function storeDirectory() {
|
|
41
|
+
const profile = process.env.DSH_PROFILE_DIR;
|
|
42
|
+
if (profile && profile.trim() !== '') return path.join(profile, 'pixel-studio');
|
|
43
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
44
|
+
return path.join(home, 'pixel-studio');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function storeFile() {
|
|
48
|
+
return path.join(storeDirectory(), 'works.json');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let cache = null;
|
|
52
|
+
let writeChain = Promise.resolve();
|
|
53
|
+
|
|
54
|
+
async function readStore() {
|
|
55
|
+
if (cache !== null) return cache;
|
|
56
|
+
try {
|
|
57
|
+
const text = await fsp.readFile(storeFile(), 'utf8');
|
|
58
|
+
const parsed = JSON.parse(text);
|
|
59
|
+
if (parsed && typeof parsed === 'object' && parsed.works && typeof parsed.works === 'object') {
|
|
60
|
+
cache = { version: 1, works: parsed.works };
|
|
61
|
+
} else {
|
|
62
|
+
cache = { version: 1, works: {} };
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
cache = { version: 1, works: {} };
|
|
66
|
+
}
|
|
67
|
+
return cache;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function persist(store) {
|
|
71
|
+
const run = async () => {
|
|
72
|
+
const dir = storeDirectory();
|
|
73
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
74
|
+
const target = storeFile();
|
|
75
|
+
const temporary = path.join(dir, `works.${process.pid}.${Date.now()}.tmp`);
|
|
76
|
+
await fsp.writeFile(temporary, JSON.stringify(store), 'utf8');
|
|
77
|
+
await fsp.rename(temporary, target);
|
|
78
|
+
};
|
|
79
|
+
writeChain = writeChain.then(run, run);
|
|
80
|
+
return writeChain;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* -------------------------------------------------------------------------- */
|
|
84
|
+
/* document model */
|
|
85
|
+
/* -------------------------------------------------------------------------- */
|
|
86
|
+
|
|
87
|
+
function newId() {
|
|
88
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
89
|
+
let id = '';
|
|
90
|
+
for (let i = 0; i < 12; i += 1) id += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
91
|
+
return id;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function clampInt(value, min, max, fallback) {
|
|
95
|
+
const number = Number(value);
|
|
96
|
+
if (!Number.isFinite(number)) return fallback;
|
|
97
|
+
return Math.min(max, Math.max(min, Math.round(number)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cleanName(value, fallback) {
|
|
101
|
+
if (typeof value !== 'string') return fallback;
|
|
102
|
+
const trimmed = value.trim().slice(0, 60);
|
|
103
|
+
return trimmed === '' ? fallback : trimmed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Palette slots are POSITIONAL: slot #n is the character `n` in every layer
|
|
108
|
+
* grid. So this must never drop or reorder an entry — dropping one would shift
|
|
109
|
+
* every later character to a different color and silently repaint the artwork.
|
|
110
|
+
* Invalid entries keep their slot and fall back to opaque black.
|
|
111
|
+
*/
|
|
112
|
+
function cleanPalette(value) {
|
|
113
|
+
const source = Array.isArray(value) ? value : [];
|
|
114
|
+
const palette = [];
|
|
115
|
+
for (const entry of source) {
|
|
116
|
+
if (palette.length >= MAX_COLORS) break;
|
|
117
|
+
const color = typeof entry === 'string' ? entry.trim().toLowerCase() : '';
|
|
118
|
+
palette.push(COLOR_PATTERN.test(color) ? color : FALLBACK_COLOR);
|
|
119
|
+
}
|
|
120
|
+
if (palette.length === 0) return DEFAULT_PALETTE.slice(0, 8);
|
|
121
|
+
return palette;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function blankRows(width, height) {
|
|
125
|
+
return Array.from({ length: height }, () => '.'.repeat(width));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Rewrites one layer's character grid so it matches width/height/palette exactly. */
|
|
129
|
+
function cleanRows(value, width, height, paletteLength) {
|
|
130
|
+
const rows = [];
|
|
131
|
+
const source = Array.isArray(value) ? value : [];
|
|
132
|
+
const allowed = '0123456789abcdefghijklmnopqrstuvwxyz'.slice(0, paletteLength);
|
|
133
|
+
for (let y = 0; y < height; y += 1) {
|
|
134
|
+
const raw = typeof source[y] === 'string' ? source[y] : '';
|
|
135
|
+
let row = '';
|
|
136
|
+
for (let x = 0; x < width; x += 1) {
|
|
137
|
+
const ch = raw[x] === undefined ? '.' : raw[x];
|
|
138
|
+
row += allowed.includes(ch) ? ch : '.';
|
|
139
|
+
}
|
|
140
|
+
rows.push(row);
|
|
141
|
+
}
|
|
142
|
+
return rows;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cleanLayers(value, width, height, paletteLength, fallbackName) {
|
|
146
|
+
const source = Array.isArray(value) ? value : [];
|
|
147
|
+
const layers = [];
|
|
148
|
+
for (const entry of source) {
|
|
149
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
150
|
+
layers.push({
|
|
151
|
+
name: cleanName(entry.name, fallbackName),
|
|
152
|
+
visible: entry.visible !== false,
|
|
153
|
+
rows: cleanRows(entry.rows, width, height, paletteLength),
|
|
154
|
+
});
|
|
155
|
+
if (layers.length >= MAX_LAYERS) break;
|
|
156
|
+
}
|
|
157
|
+
if (layers.length === 0) {
|
|
158
|
+
layers.push({ name: fallbackName, visible: true, rows: blankRows(width, height) });
|
|
159
|
+
}
|
|
160
|
+
return layers;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Validates and normalizes an incoming document. Throws on structural problems. */
|
|
164
|
+
function normalizeWork(input, previous) {
|
|
165
|
+
if (!input || typeof input !== 'object') throw new Error('invalid-document');
|
|
166
|
+
const base = previous || {};
|
|
167
|
+
const width = clampInt(input.w !== undefined ? input.w : base.w, 1, MAX_SIDE, 32);
|
|
168
|
+
const height = clampInt(input.h !== undefined ? input.h : base.h, 1, MAX_SIDE, 32);
|
|
169
|
+
const palette = cleanPalette(input.palette !== undefined ? input.palette : base.palette);
|
|
170
|
+
const rawPath = input.filePath !== undefined ? input.filePath : base.filePath;
|
|
171
|
+
const filePath = typeof rawPath === 'string' && rawPath.trim() !== '' ? path.resolve(rawPath.trim()) : null;
|
|
172
|
+
const work = {
|
|
173
|
+
id: typeof input.id === 'string' && ID_PATTERN.test(input.id) ? input.id : (base.id || newId()),
|
|
174
|
+
name: cleanName(input.name !== undefined ? input.name : base.name, '未命名作品'),
|
|
175
|
+
w: width,
|
|
176
|
+
h: height,
|
|
177
|
+
palette,
|
|
178
|
+
filePath,
|
|
179
|
+
layers: cleanLayers(input.layers !== undefined ? input.layers : base.layers, width, height, palette.length, '图层 1'),
|
|
180
|
+
updatedAt: Date.now(),
|
|
181
|
+
};
|
|
182
|
+
return work;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function summarize(work) {
|
|
186
|
+
return {
|
|
187
|
+
id: work.id,
|
|
188
|
+
name: work.name,
|
|
189
|
+
w: work.w,
|
|
190
|
+
h: work.h,
|
|
191
|
+
colors: work.palette.length,
|
|
192
|
+
layers: work.layers.length,
|
|
193
|
+
filePath: work.filePath || null,
|
|
194
|
+
updatedAt: work.updatedAt,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* -------------------------------------------------------------------------- */
|
|
199
|
+
/* PNG encoding (no dependencies: IHDR + IDAT stored in zlib + CRC32) */
|
|
200
|
+
/* -------------------------------------------------------------------------- */
|
|
201
|
+
|
|
202
|
+
const CRC_TABLE = (() => {
|
|
203
|
+
const table = new Uint32Array(256);
|
|
204
|
+
for (let n = 0; n < 256; n += 1) {
|
|
205
|
+
let c = n;
|
|
206
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
207
|
+
table[n] = c >>> 0;
|
|
208
|
+
}
|
|
209
|
+
return table;
|
|
210
|
+
})();
|
|
211
|
+
|
|
212
|
+
function crc32(buffer) {
|
|
213
|
+
let c = 0xffffffff;
|
|
214
|
+
for (let i = 0; i < buffer.length; i += 1) c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
|
|
215
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pngChunk(type, data) {
|
|
219
|
+
const length = Buffer.alloc(4);
|
|
220
|
+
length.writeUInt32BE(data.length, 0);
|
|
221
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
222
|
+
const crc = Buffer.alloc(4);
|
|
223
|
+
crc.writeUInt32BE(crc32(body), 0);
|
|
224
|
+
return Buffer.concat([length, body, crc]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function encodePng(width, height, rgba) {
|
|
228
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
229
|
+
const ihdr = Buffer.alloc(13);
|
|
230
|
+
ihdr.writeUInt32BE(width, 0);
|
|
231
|
+
ihdr.writeUInt32BE(height, 4);
|
|
232
|
+
ihdr[8] = 8; // bit depth
|
|
233
|
+
ihdr[9] = 6; // truecolor + alpha
|
|
234
|
+
ihdr[10] = 0;
|
|
235
|
+
ihdr[11] = 0;
|
|
236
|
+
ihdr[12] = 0;
|
|
237
|
+
const stride = width * 4;
|
|
238
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
239
|
+
for (let y = 0; y < height; y += 1) {
|
|
240
|
+
raw[y * (stride + 1)] = 0; // filter: none
|
|
241
|
+
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
|
242
|
+
}
|
|
243
|
+
return Buffer.concat([
|
|
244
|
+
signature,
|
|
245
|
+
pngChunk('IHDR', ihdr),
|
|
246
|
+
pngChunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
|
|
247
|
+
pngChunk('IEND', Buffer.alloc(0)),
|
|
248
|
+
]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function paletteRgb(palette) {
|
|
252
|
+
return palette.map((color) => [
|
|
253
|
+
parseInt(color.slice(1, 3), 16),
|
|
254
|
+
parseInt(color.slice(3, 5), 16),
|
|
255
|
+
parseInt(color.slice(5, 7), 16),
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Composes all visible layers into an RGBA buffer upscaled by `scale`. */
|
|
260
|
+
function renderRgba(work, scale) {
|
|
261
|
+
const width = work.w * scale;
|
|
262
|
+
const height = work.h * scale;
|
|
263
|
+
const rgba = Buffer.alloc(width * height * 4);
|
|
264
|
+
const rgb = paletteRgb(work.palette);
|
|
265
|
+
const layers = work.layers.filter((layer) => layer.visible !== false);
|
|
266
|
+
for (let y = 0; y < work.h; y += 1) {
|
|
267
|
+
for (let x = 0; x < work.w; x += 1) {
|
|
268
|
+
let color = null;
|
|
269
|
+
for (let l = layers.length - 1; l >= 0; l -= 1) {
|
|
270
|
+
const row = layers[l].rows[y];
|
|
271
|
+
const ch = row === undefined ? '.' : row[x];
|
|
272
|
+
const index = ch === undefined || ch === '.' ? -1 : Number.parseInt(ch, 36);
|
|
273
|
+
if (Number.isInteger(index) && index >= 0 && index < rgb.length) {
|
|
274
|
+
color = rgb[index];
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (color === null) continue;
|
|
279
|
+
for (let dy = 0; dy < scale; dy += 1) {
|
|
280
|
+
let offset = ((y * scale + dy) * width + x * scale) * 4;
|
|
281
|
+
for (let dx = 0; dx < scale; dx += 1) {
|
|
282
|
+
rgba[offset] = color[0];
|
|
283
|
+
rgba[offset + 1] = color[1];
|
|
284
|
+
rgba[offset + 2] = color[2];
|
|
285
|
+
rgba[offset + 3] = 255;
|
|
286
|
+
offset += 4;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { width, height, rgba };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function renderDataUrl(work, scale) {
|
|
295
|
+
const { width, height, rgba } = renderRgba(work, scale);
|
|
296
|
+
return {
|
|
297
|
+
dataUrl: `data:image/png;base64,${encodePng(width, height, rgba).toString('base64')}`,
|
|
298
|
+
width,
|
|
299
|
+
height,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* -------------------------------------------------------------------------- */
|
|
304
|
+
/* HTTP API */
|
|
305
|
+
/* -------------------------------------------------------------------------- */
|
|
306
|
+
|
|
307
|
+
/** Directory the exported file should land in: the Session's own directory when known. */
|
|
308
|
+
function resolveDirectory(ctx, sessionId) {
|
|
309
|
+
const workingDirectory = ctx.get('workingDirectory');
|
|
310
|
+
const sessions = ctx.get('sessions');
|
|
311
|
+
const session = sessions && typeof sessions.get === 'function' && typeof sessionId === 'string' && sessionId !== ''
|
|
312
|
+
? sessions.get(sessionId)
|
|
313
|
+
: undefined;
|
|
314
|
+
if (session && workingDirectory && typeof workingDirectory.get === 'function') {
|
|
315
|
+
try {
|
|
316
|
+
const directory = workingDirectory.get(session);
|
|
317
|
+
if (typeof directory === 'string' && directory !== '') return directory;
|
|
318
|
+
} catch {
|
|
319
|
+
// Fall through to the directory recorded in the Session header.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (session && session.header && typeof session.header.cwd === 'string' && session.header.cwd !== '') {
|
|
323
|
+
return session.header.cwd;
|
|
324
|
+
}
|
|
325
|
+
if (workingDirectory && typeof workingDirectory.defaultDirectory === 'string') {
|
|
326
|
+
return workingDirectory.defaultDirectory;
|
|
327
|
+
}
|
|
328
|
+
return os.homedir();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function fileStem(value) {
|
|
332
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
333
|
+
const cleaned = raw
|
|
334
|
+
.replace(/[\\/:*?"<>|]/g, '-')
|
|
335
|
+
.replace(/[\u0000-\u001f]/g, '')
|
|
336
|
+
.replace(/\s+/g, ' ')
|
|
337
|
+
.trim();
|
|
338
|
+
return (cleaned === '' ? 'pixel-art' : cleaned).slice(0, 80);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Writes `<name>.png` into `dir` without silently overwriting an existing file. */
|
|
342
|
+
async function writePng(dir, name, png) {
|
|
343
|
+
const stem = fileStem(name);
|
|
344
|
+
let target = path.join(dir, `${stem}.png`);
|
|
345
|
+
for (let index = 2; index <= 100; index += 1) {
|
|
346
|
+
try {
|
|
347
|
+
await fsp.access(target);
|
|
348
|
+
} catch {
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
target = path.join(dir, `${stem}-${index}.png`);
|
|
352
|
+
}
|
|
353
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
354
|
+
await fsp.writeFile(target, png);
|
|
355
|
+
return target;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Present drive letters as pick-able entries when the listing sits at a filesystem root. */
|
|
359
|
+
async function listDrives() {
|
|
360
|
+
const found = [];
|
|
361
|
+
for (let code = 65; code <= 90; code += 1) {
|
|
362
|
+
const drive = `${String.fromCharCode(code)}:\\`;
|
|
363
|
+
try {
|
|
364
|
+
await fsp.access(drive);
|
|
365
|
+
found.push({ name: drive, path: drive, hidden: false });
|
|
366
|
+
} catch {
|
|
367
|
+
// Absent drive letter.
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return found;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Lists one directory level (directories only) for the Client's save dialog. */
|
|
374
|
+
async function listDirectories(directory, showHidden) {
|
|
375
|
+
const resolved = path.resolve(directory);
|
|
376
|
+
const root = path.parse(resolved).root;
|
|
377
|
+
let entries;
|
|
378
|
+
try {
|
|
379
|
+
entries = await fsp.readdir(resolved, { withFileTypes: true });
|
|
380
|
+
} catch (error) {
|
|
381
|
+
const code = error && error.code;
|
|
382
|
+
if (code === 'ENOENT') throw new Error('目录不存在');
|
|
383
|
+
if (code === 'EACCES' || code === 'EPERM') throw new Error('没有访问该目录的权限');
|
|
384
|
+
if (code === 'ENOTDIR') throw new Error('不是目录');
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
const dirs = [];
|
|
388
|
+
for (const entry of entries) {
|
|
389
|
+
if (!entry.isDirectory()) continue;
|
|
390
|
+
const hidden = entry.name.startsWith('.');
|
|
391
|
+
if (hidden && showHidden !== true) continue;
|
|
392
|
+
dirs.push({ name: entry.name, path: path.join(resolved, entry.name), hidden });
|
|
393
|
+
}
|
|
394
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
|
395
|
+
const parent = resolved === root ? null : path.dirname(resolved);
|
|
396
|
+
if (parent === null) {
|
|
397
|
+
const drives = await listDrives();
|
|
398
|
+
dirs.unshift(...drives);
|
|
399
|
+
}
|
|
400
|
+
return { path: resolved, parent, root, dirs };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function sendJson(res, status, payload) {
|
|
404
|
+
const body = JSON.stringify(payload);
|
|
405
|
+
res.statusCode = status;
|
|
406
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
407
|
+
res.setHeader('cache-control', 'no-store');
|
|
408
|
+
res.setHeader('content-length', Buffer.byteLength(body));
|
|
409
|
+
res.end(body);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function readBody(req) {
|
|
413
|
+
return new Promise((resolve, reject) => {
|
|
414
|
+
const chunks = [];
|
|
415
|
+
let size = 0;
|
|
416
|
+
req.on('data', (chunk) => {
|
|
417
|
+
size += chunk.length;
|
|
418
|
+
if (size > MAX_BODY) {
|
|
419
|
+
reject(new Error('body-too-large'));
|
|
420
|
+
req.destroy();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
chunks.push(chunk);
|
|
424
|
+
});
|
|
425
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
426
|
+
req.on('error', reject);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function handleRequest(ctx, req, res) {
|
|
431
|
+
if (req.method !== 'POST') {
|
|
432
|
+
sendJson(res, 405, { ok: false, error: 'method-not-allowed' });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (req.headers[HEADER] !== '1') {
|
|
436
|
+
sendJson(res, 403, { ok: false, error: 'forbidden' });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
let payload;
|
|
440
|
+
try {
|
|
441
|
+
const text = await readBody(req);
|
|
442
|
+
payload = text === '' ? {} : JSON.parse(text);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
const tooLarge = error instanceof Error && error.message === 'body-too-large';
|
|
445
|
+
sendJson(res, tooLarge ? 413 : 400, { ok: false, error: tooLarge ? 'body-too-large' : 'bad-json' });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const op = payload && typeof payload === 'object' ? payload.op : undefined;
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
const store = await readStore();
|
|
452
|
+
if (op === 'list') {
|
|
453
|
+
const docs = Object.values(store.works).map(summarize).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
454
|
+
sendJson(res, 200, { ok: true, docs });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (op === 'get') {
|
|
458
|
+
const work = store.works[payload.id];
|
|
459
|
+
if (!work) {
|
|
460
|
+
sendJson(res, 404, { ok: false, error: 'not-found' });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
sendJson(res, 200, { ok: true, doc: work });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (op === 'create') {
|
|
467
|
+
const width = clampInt(payload.w, 1, MAX_SIDE, 32);
|
|
468
|
+
const height = clampInt(payload.h, 1, MAX_SIDE, 32);
|
|
469
|
+
const palette = cleanPalette(payload.palette);
|
|
470
|
+
const work = {
|
|
471
|
+
id: newId(),
|
|
472
|
+
name: cleanName(payload.name, '未命名作品'),
|
|
473
|
+
w: width,
|
|
474
|
+
h: height,
|
|
475
|
+
palette,
|
|
476
|
+
filePath: null,
|
|
477
|
+
layers: [{ name: '图层 1', visible: true, rows: blankRows(width, height) }],
|
|
478
|
+
updatedAt: Date.now(),
|
|
479
|
+
};
|
|
480
|
+
store.works[work.id] = work;
|
|
481
|
+
await persist(store);
|
|
482
|
+
sendJson(res, 200, { ok: true, doc: work });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (op === 'save') {
|
|
486
|
+
const source = payload && payload.doc;
|
|
487
|
+
if (!source || typeof source !== 'object') {
|
|
488
|
+
sendJson(res, 400, { ok: false, error: 'invalid-document' });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const previous = typeof source.id === 'string' ? store.works[source.id] : undefined;
|
|
492
|
+
const work = normalizeWork(source, previous);
|
|
493
|
+
store.works[work.id] = work;
|
|
494
|
+
await persist(store);
|
|
495
|
+
sendJson(res, 200, { ok: true, doc: work, savedAt: work.updatedAt });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (op === 'delete') {
|
|
499
|
+
const id = typeof payload.id === 'string' ? payload.id : '';
|
|
500
|
+
if (!ID_PATTERN.test(id)) {
|
|
501
|
+
sendJson(res, 400, { ok: false, error: 'invalid-id' });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
delete store.works[id];
|
|
505
|
+
await persist(store);
|
|
506
|
+
sendJson(res, 200, { ok: true });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (op === 'png') {
|
|
510
|
+
const work = store.works[payload.id];
|
|
511
|
+
if (!work) {
|
|
512
|
+
sendJson(res, 404, { ok: false, error: 'not-found' });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const maxScale = Math.max(1, Math.floor(Math.sqrt(MAX_EXPORT_PIXELS / (work.w * work.h))));
|
|
516
|
+
const scale = clampInt(payload.scale, 1, Math.min(64, maxScale), 8);
|
|
517
|
+
const rendered = renderDataUrl(work, scale);
|
|
518
|
+
sendJson(res, 200, { ok: true, ...rendered, scale });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (op === 'render') {
|
|
522
|
+
// Renders a document that has not been saved yet (used by the export button).
|
|
523
|
+
const work = normalizeWork(payload.doc, null);
|
|
524
|
+
const maxScale = Math.max(1, Math.floor(Math.sqrt(MAX_EXPORT_PIXELS / (work.w * work.h))));
|
|
525
|
+
const scale = clampInt(payload.scale, 1, Math.min(64, maxScale), 8);
|
|
526
|
+
const rendered = renderDataUrl(work, scale);
|
|
527
|
+
sendJson(res, 200, { ok: true, ...rendered, scale });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (op === 'export') {
|
|
531
|
+
// Exports an unsaved document at 1× straight into the conversation directory.
|
|
532
|
+
const work = normalizeWork(payload.doc, null);
|
|
533
|
+
const scale = clampInt(payload.scale, 1, 64, 1);
|
|
534
|
+
const { width, height, rgba } = renderRgba(work, scale);
|
|
535
|
+
const png = encodePng(width, height, rgba);
|
|
536
|
+
const directory = resolveDirectory(ctx, payload.sessionId);
|
|
537
|
+
const target = await writePng(directory, payload.name || work.name, png);
|
|
538
|
+
sendJson(res, 200, { ok: true, path: target, width, height, bytes: png.length });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (op === 'sessionDir') {
|
|
542
|
+
sendJson(res, 200, { ok: true, dir: resolveDirectory(ctx, payload.sessionId) });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (op === 'browse') {
|
|
546
|
+
const requested = typeof payload.path === 'string' && payload.path.trim() !== ''
|
|
547
|
+
? payload.path.trim()
|
|
548
|
+
: resolveDirectory(ctx, payload.sessionId);
|
|
549
|
+
const listing = await listDirectories(requested, payload.showHidden === true);
|
|
550
|
+
sendJson(res, 200, { ok: true, ...listing });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (op === 'mkdir') {
|
|
554
|
+
const parent = typeof payload.path === 'string' && payload.path.trim() !== '' ? path.resolve(payload.path.trim()) : '';
|
|
555
|
+
const raw = typeof payload.name === 'string' ? payload.name.trim() : '';
|
|
556
|
+
if (parent === '' || raw === '') {
|
|
557
|
+
sendJson(res, 400, { ok: false, error: 'invalid-path' });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const created = path.join(parent, fileStem(raw).replace(/^\.+/, '') || '新建文件夹');
|
|
561
|
+
await fsp.mkdir(created, { recursive: false });
|
|
562
|
+
sendJson(res, 200, { ok: true, path: created });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (op === 'exportTo') {
|
|
566
|
+
// Writes the render at an explicit path, replacing an existing file.
|
|
567
|
+
const work = normalizeWork(payload.doc, null);
|
|
568
|
+
const scale = clampInt(payload.scale, 1, 64, 1);
|
|
569
|
+
const rawTarget = typeof payload.path === 'string' ? payload.path.trim() : '';
|
|
570
|
+
if (rawTarget === '') {
|
|
571
|
+
sendJson(res, 400, { ok: false, error: 'invalid-path' });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const { width, height, rgba } = renderRgba(work, scale);
|
|
575
|
+
const png = encodePng(width, height, rgba);
|
|
576
|
+
const target = path.resolve(/\.png$/i.test(rawTarget) ? rawTarget : `${rawTarget}.png`);
|
|
577
|
+
let existed = false;
|
|
578
|
+
try {
|
|
579
|
+
await fsp.access(target);
|
|
580
|
+
existed = true;
|
|
581
|
+
} catch {
|
|
582
|
+
existed = false;
|
|
583
|
+
}
|
|
584
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
585
|
+
await fsp.writeFile(target, png);
|
|
586
|
+
sendJson(res, 200, { ok: true, path: target, width, height, bytes: png.length, overwritten: existed });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (op === 'ping') {
|
|
590
|
+
sendJson(res, 200, { ok: true, dir: storeDirectory(), count: Object.keys(store.works).length });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
sendJson(res, 400, { ok: false, error: 'unknown-op' });
|
|
594
|
+
} catch (error) {
|
|
595
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
596
|
+
sendJson(res, 500, { ok: false, error: 'internal', message });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/* -------------------------------------------------------------------------- */
|
|
601
|
+
/* plugin entry */
|
|
602
|
+
/* -------------------------------------------------------------------------- */
|
|
603
|
+
|
|
604
|
+
export function apply(ctx) {
|
|
605
|
+
// Warm the store so the first Client request does not pay for a cold read.
|
|
606
|
+
void readStore().catch(() => {});
|
|
607
|
+
|
|
608
|
+
ctx.effect(() => {
|
|
609
|
+
const dispose = ctx.webServer.register({
|
|
610
|
+
kind: 'exact',
|
|
611
|
+
path: API_PATH,
|
|
612
|
+
handler: (req, res) => {
|
|
613
|
+
void handleRequest(ctx, req, res);
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
return () => {
|
|
617
|
+
dispose();
|
|
618
|
+
};
|
|
619
|
+
});
|
|
620
|
+
}
|
package/locale/en.json
ADDED