pixifact-cli 0.4.0 → 0.6.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.
@@ -0,0 +1,697 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { parseSceneTemplate } from 'pixifact/compiler';
6
+
7
+ export const editorSessionProtocolVersion = 3;
8
+
9
+ export type EditorContextSyncState = 'synced' | 'saving' | 'conflict' | 'error';
10
+ export type EditorPreviewState = 'loading' | 'ready' | 'error';
11
+
12
+ export type EditorNodeContext =
13
+ | {
14
+ kind: 'pixi';
15
+ type: string;
16
+ id?: string;
17
+ props: Record<string, unknown>;
18
+ childCount: number;
19
+ }
20
+ | {
21
+ kind: 'sceneInstance';
22
+ type: string;
23
+ id?: string;
24
+ scene: string;
25
+ props: Record<string, unknown>;
26
+ events: Record<string, string>;
27
+ slots: Record<string, number>;
28
+ }
29
+ | {
30
+ kind: 'slotOutlet';
31
+ name: string;
32
+ };
33
+
34
+ export type EditorSelectionContext =
35
+ | { kind: 'scene' }
36
+ | {
37
+ kind: 'node';
38
+ locator: string;
39
+ node: EditorNodeContext;
40
+ };
41
+
42
+ export interface EditorBrowserContext {
43
+ scene: {
44
+ path: string;
45
+ previewState: EditorPreviewState;
46
+ revision: string;
47
+ syncState: EditorContextSyncState;
48
+ };
49
+ selection: EditorSelectionContext;
50
+ }
51
+
52
+ export interface EditorSessionResumeState {
53
+ scenePath: string;
54
+ selectedLocator?: string;
55
+ }
56
+
57
+ export interface EditorContext extends EditorBrowserContext {
58
+ protocolVersion: typeof editorSessionProtocolVersion;
59
+ projectRoot: string;
60
+ editor: {
61
+ connected: true;
62
+ updatedAt: string;
63
+ };
64
+ }
65
+
66
+ export interface EditorSessionDescriptor {
67
+ protocolVersion: typeof editorSessionProtocolVersion;
68
+ projectRoot: string;
69
+ pid: number;
70
+ origin: string;
71
+ token: string;
72
+ }
73
+
74
+ interface EditorBrowserSocket {
75
+ send(data: string): void;
76
+ }
77
+
78
+ interface EditorHostSessionOptions {
79
+ projectRoot: string;
80
+ screenshotTimeoutMs?: number;
81
+ token: string;
82
+ now?: () => Date;
83
+ }
84
+
85
+ interface EditorSessionLookupOptions {
86
+ projectRoot: string;
87
+ sessionsRoot?: string;
88
+ fetch?: typeof fetch;
89
+ }
90
+
91
+ interface ClaimEditorSessionOptions extends EditorSessionLookupOptions {
92
+ descriptor: EditorSessionDescriptor;
93
+ }
94
+
95
+ interface PendingEditorScreenshot {
96
+ browser: EditorBrowserSocket;
97
+ resolve(response: Response): void;
98
+ scene: {
99
+ path: string;
100
+ revision: string;
101
+ };
102
+ timeout: ReturnType<typeof setTimeout>;
103
+ }
104
+
105
+ export interface EditorScreenshotResult {
106
+ ok: true;
107
+ scene: string;
108
+ revision: string;
109
+ width: number;
110
+ height: number;
111
+ data: Uint8Array;
112
+ }
113
+
114
+ function jsonResponse(value: unknown, status = 200) {
115
+ return Response.json(value, { status });
116
+ }
117
+
118
+ function sceneVersion(source: string) {
119
+ return `sha256:${createHash('sha256').update(source).digest('hex')}`;
120
+ }
121
+
122
+ function authorizationMatches(request: Request, token: string) {
123
+ return request.headers.get('authorization') === `Bearer ${token}`;
124
+ }
125
+
126
+ function resolveScenePath(projectRoot: string, scenePath: string) {
127
+ if (!scenePath || path.isAbsolute(scenePath) || path.extname(scenePath).toLowerCase() !== '.scene') {
128
+ throw new Error('Editor context Scene path must be a project-relative .scene file.');
129
+ }
130
+ const absolutePath = fs.realpathSync(path.resolve(projectRoot, scenePath));
131
+ if (absolutePath !== projectRoot && !absolutePath.startsWith(`${projectRoot}${path.sep}`)) {
132
+ throw new Error('Editor context Scene path must stay inside the project root.');
133
+ }
134
+ return absolutePath;
135
+ }
136
+
137
+ function sourcePosition(message: string, source: string) {
138
+ const match = message.match(/\boffset (\d+)\b/);
139
+ if (!match) {
140
+ return {};
141
+ }
142
+ const offset = Number(match[1]);
143
+ const lines = source.slice(0, offset).split('\n');
144
+ return {
145
+ line: lines.length,
146
+ column: lines.at(-1)!.length + 1,
147
+ };
148
+ }
149
+
150
+ function isRecord(value: unknown): value is Record<string, unknown> {
151
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
152
+ }
153
+
154
+ function isEditorBrowserContext(value: unknown): value is EditorBrowserContext {
155
+ if (!isRecord(value) || !isRecord(value.scene) || !isRecord(value.selection)) {
156
+ return false;
157
+ }
158
+ const syncState = value.scene.syncState;
159
+ if (
160
+ typeof value.scene.path !== 'string'
161
+ || typeof value.scene.revision !== 'string'
162
+ || !['synced', 'saving', 'conflict', 'error'].includes(String(syncState))
163
+ || !['loading', 'ready', 'error'].includes(String(value.scene.previewState))
164
+ ) {
165
+ return false;
166
+ }
167
+ if (value.selection.kind === 'scene') {
168
+ return true;
169
+ }
170
+ return value.selection.kind === 'node'
171
+ && typeof value.selection.locator === 'string'
172
+ && isRecord(value.selection.node);
173
+ }
174
+
175
+ export function createEditorHostSession(options: EditorHostSessionOptions) {
176
+ const projectRoot = fs.realpathSync(options.projectRoot);
177
+ const now = options.now ?? (() => new Date());
178
+ const screenshotTimeoutMs = options.screenshotTimeoutMs ?? 10_000;
179
+ const browsers = new Set<EditorBrowserSocket>();
180
+ const pendingScreenshots = new Map<string, PendingEditorScreenshot>();
181
+ let activeBrowser: EditorBrowserSocket | undefined;
182
+ let context: EditorContext | undefined;
183
+ let resume: EditorSessionResumeState | undefined;
184
+ let screenshotSequence = 0;
185
+
186
+ function settleScreenshot(requestId: string, response: Response) {
187
+ const pending = pendingScreenshots.get(requestId);
188
+ if (!pending) return;
189
+ pendingScreenshots.delete(requestId);
190
+ clearTimeout(pending.timeout);
191
+ pending.resolve(response);
192
+ }
193
+
194
+ function rejectBrowserScreenshots(browser: EditorBrowserSocket, error: string) {
195
+ for (const [requestId, pending] of pendingScreenshots) {
196
+ if (pending.browser === browser) {
197
+ settleScreenshot(requestId, jsonResponse({ ok: false, error }, 409));
198
+ }
199
+ }
200
+ }
201
+
202
+ function sendState(
203
+ socket: EditorBrowserSocket,
204
+ type: 'editorSessionActive' | 'editorSessionStandby',
205
+ details: { error?: string; reason?: 'takenOver' } = {},
206
+ ) {
207
+ socket.send(JSON.stringify({
208
+ type,
209
+ protocolVersion: editorSessionProtocolVersion,
210
+ ...details,
211
+ ...(resume ? { resume } : {}),
212
+ }));
213
+ }
214
+
215
+ function notifyStandbyBrowsers() {
216
+ for (const socket of browsers) {
217
+ if (socket !== activeBrowser) sendState(socket, 'editorSessionStandby');
218
+ }
219
+ }
220
+
221
+ function open(socket: EditorBrowserSocket) {
222
+ browsers.add(socket);
223
+ if (activeBrowser) {
224
+ sendState(socket, 'editorSessionStandby');
225
+ return false;
226
+ }
227
+ activeBrowser = socket;
228
+ sendState(socket, 'editorSessionActive');
229
+ return true;
230
+ }
231
+
232
+ function close(socket: EditorBrowserSocket) {
233
+ browsers.delete(socket);
234
+ if (socket === activeBrowser) {
235
+ rejectBrowserScreenshots(socket, 'Active Editor browser disconnected during screenshot capture.');
236
+ activeBrowser = undefined;
237
+ context = undefined;
238
+ }
239
+ }
240
+
241
+ function message(socket: EditorBrowserSocket, data: string | ArrayBuffer | Uint8Array) {
242
+ const parsed = JSON.parse(String(data)) as Record<string, unknown>;
243
+ if (parsed.protocolVersion !== editorSessionProtocolVersion) {
244
+ throw new Error('Editor session protocol version does not match. Restart Pixifact Editor.');
245
+ }
246
+ if (parsed.type === 'editorSessionTakeoverRequested') {
247
+ if (!browsers.has(socket) || socket === activeBrowser) return;
248
+ if (activeBrowser && context?.scene.syncState !== 'synced') {
249
+ sendState(socket, 'editorSessionStandby', {
250
+ error: '当前 Editor 尚未同步,暂时无法接管。',
251
+ });
252
+ return;
253
+ }
254
+ const previous = activeBrowser;
255
+ if (previous) {
256
+ rejectBrowserScreenshots(previous, 'Active Editor browser changed during screenshot capture.');
257
+ }
258
+ activeBrowser = socket;
259
+ context = undefined;
260
+ for (const browser of browsers) {
261
+ if (browser === socket) {
262
+ sendState(browser, 'editorSessionActive');
263
+ } else {
264
+ sendState(browser, 'editorSessionStandby', {
265
+ ...(browser === previous ? { reason: 'takenOver' as const } : {}),
266
+ });
267
+ }
268
+ }
269
+ return;
270
+ }
271
+ if (
272
+ socket === activeBrowser
273
+ && (parsed.type === 'editorScreenshotCompleted' || parsed.type === 'editorScreenshotFailed')
274
+ ) {
275
+ const requestId = typeof parsed.requestId === 'string' ? parsed.requestId : '';
276
+ const pending = pendingScreenshots.get(requestId);
277
+ if (!pending || pending.browser !== socket) return;
278
+ if (parsed.type === 'editorScreenshotFailed') {
279
+ const error = typeof parsed.error === 'string'
280
+ ? parsed.error
281
+ : 'Editor browser failed to capture the Scene screenshot.';
282
+ settleScreenshot(requestId, jsonResponse({ ok: false, error }, 500));
283
+ return;
284
+ }
285
+ if (
286
+ !isRecord(parsed.scene)
287
+ || parsed.scene.path !== pending.scene.path
288
+ || parsed.scene.revision !== pending.scene.revision
289
+ || !Number.isInteger(parsed.width)
290
+ || Number(parsed.width) <= 0
291
+ || !Number.isInteger(parsed.height)
292
+ || Number(parsed.height) <= 0
293
+ || typeof parsed.dataUrl !== 'string'
294
+ ) {
295
+ settleScreenshot(requestId, jsonResponse({
296
+ ok: false,
297
+ error: 'Editor browser returned an invalid screenshot response.',
298
+ }, 502));
299
+ return;
300
+ }
301
+ const match = parsed.dataUrl.match(/^data:image\/png;base64,([A-Za-z0-9+/]+={0,2})$/);
302
+ const png = match ? Buffer.from(match[1], 'base64') : undefined;
303
+ if (!png || !png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
304
+ settleScreenshot(requestId, jsonResponse({
305
+ ok: false,
306
+ error: 'Editor browser did not return PNG screenshot data.',
307
+ }, 502));
308
+ return;
309
+ }
310
+ settleScreenshot(requestId, new Response(png, {
311
+ headers: {
312
+ 'cache-control': 'no-store',
313
+ 'content-type': 'image/png',
314
+ 'x-pixifact-height': String(parsed.height),
315
+ 'x-pixifact-revision': pending.scene.revision,
316
+ 'x-pixifact-scene': encodeURIComponent(pending.scene.path),
317
+ 'x-pixifact-width': String(parsed.width),
318
+ },
319
+ }));
320
+ return;
321
+ }
322
+ if (socket !== activeBrowser || parsed.type !== 'editorContextChanged') return;
323
+ if (!isEditorBrowserContext(parsed.context)) {
324
+ throw new Error('Editor context message is invalid.');
325
+ }
326
+ context = {
327
+ protocolVersion: editorSessionProtocolVersion,
328
+ projectRoot,
329
+ editor: {
330
+ connected: true,
331
+ updatedAt: now().toISOString(),
332
+ },
333
+ ...structuredClone(parsed.context),
334
+ };
335
+ resume = {
336
+ scenePath: context.scene.path,
337
+ ...(context.selection.kind === 'node'
338
+ ? { selectedLocator: context.selection.locator }
339
+ : {}),
340
+ };
341
+ notifyStandbyBrowsers();
342
+ }
343
+
344
+ function synchronizedContext() {
345
+ if (!activeBrowser) {
346
+ return jsonResponse({
347
+ ok: false,
348
+ error: 'No active Editor browser session.',
349
+ }, 409);
350
+ }
351
+ if (!context) {
352
+ return jsonResponse({
353
+ ok: false,
354
+ error: 'Editor context is not ready.',
355
+ }, 409);
356
+ }
357
+ if (context.scene.syncState !== 'synced') {
358
+ return jsonResponse({
359
+ ok: false,
360
+ error: 'Editor Scene is not synchronized.',
361
+ syncState: context.scene.syncState,
362
+ }, 409);
363
+ }
364
+
365
+ const source = fs.readFileSync(resolveScenePath(projectRoot, context.scene.path), 'utf8');
366
+ const revision = sceneVersion(source);
367
+ if (revision !== context.scene.revision) {
368
+ try {
369
+ parseSceneTemplate(source);
370
+ } catch (error) {
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ return jsonResponse({
373
+ ok: false,
374
+ error: 'Scene parsing failed.',
375
+ scene: {
376
+ path: context.scene.path,
377
+ revision,
378
+ },
379
+ diagnostics: [{
380
+ path: '__scene__',
381
+ prop: 'source',
382
+ expected: 'valid Pixifact .scene source',
383
+ actual: message,
384
+ ...sourcePosition(message, source),
385
+ }],
386
+ }, 422);
387
+ }
388
+ return jsonResponse({
389
+ ok: false,
390
+ error: 'Editor context is updating.',
391
+ scene: {
392
+ path: context.scene.path,
393
+ revision,
394
+ },
395
+ }, 409);
396
+ }
397
+ return context;
398
+ }
399
+
400
+ function contextResponse() {
401
+ const result = synchronizedContext();
402
+ return result instanceof Response ? result : jsonResponse(result);
403
+ }
404
+
405
+ function screenshotResponse() {
406
+ const result = synchronizedContext();
407
+ if (result instanceof Response) return result;
408
+ if (result.scene.previewState !== 'ready') {
409
+ return jsonResponse({
410
+ ok: false,
411
+ error: 'Editor Scene preview is not ready.',
412
+ previewState: result.scene.previewState,
413
+ }, 409);
414
+ }
415
+ const browser = activeBrowser!;
416
+ const requestId = `screenshot-${++screenshotSequence}`;
417
+ const scene = {
418
+ path: result.scene.path,
419
+ revision: result.scene.revision,
420
+ };
421
+ return new Promise<Response>((resolve) => {
422
+ const timeout = setTimeout(() => {
423
+ settleScreenshot(requestId, jsonResponse({
424
+ ok: false,
425
+ error: 'Editor screenshot capture timed out.',
426
+ }, 504));
427
+ }, screenshotTimeoutMs);
428
+ pendingScreenshots.set(requestId, { browser, resolve, scene, timeout });
429
+ browser.send(JSON.stringify({
430
+ type: 'editorScreenshotRequested',
431
+ protocolVersion: editorSessionProtocolVersion,
432
+ requestId,
433
+ scene,
434
+ }));
435
+ });
436
+ }
437
+
438
+ function fetchRequest(request: Request) {
439
+ const pathname = new URL(request.url).pathname;
440
+ if (
441
+ pathname !== '/api/editor-session/health'
442
+ && pathname !== '/api/editor-context'
443
+ && pathname !== '/api/editor-screenshot'
444
+ ) {
445
+ return undefined;
446
+ }
447
+ if (!authorizationMatches(request, options.token)) {
448
+ return jsonResponse({ ok: false, error: 'Editor session token is invalid.' }, 401);
449
+ }
450
+ if (pathname === '/api/editor-session/health') {
451
+ return jsonResponse({
452
+ protocolVersion: editorSessionProtocolVersion,
453
+ projectRoot,
454
+ });
455
+ }
456
+ if (pathname === '/api/editor-screenshot') {
457
+ if (request.method !== 'POST') {
458
+ return jsonResponse({ ok: false, error: 'Editor screenshot requires POST.' }, 405);
459
+ }
460
+ return screenshotResponse();
461
+ }
462
+ return contextResponse();
463
+ }
464
+
465
+ function notifyProjectFileChanged(changedPath: string) {
466
+ activeBrowser?.send(JSON.stringify({ type: 'projectFileChanged', path: changedPath }));
467
+ }
468
+
469
+ return {
470
+ open,
471
+ close,
472
+ message,
473
+ fetch: fetchRequest,
474
+ notifyProjectFileChanged,
475
+ };
476
+ }
477
+
478
+ export function defaultEditorSessionsRoot() {
479
+ return path.join(os.tmpdir(), 'pixifact', 'editor-sessions');
480
+ }
481
+
482
+ export function editorSessionDescriptorPath(projectRoot: string, sessionsRoot = defaultEditorSessionsRoot()) {
483
+ const canonicalRoot = fs.realpathSync(projectRoot);
484
+ const projectHash = createHash('sha256').update(canonicalRoot).digest('hex');
485
+ return path.join(sessionsRoot, `${projectHash}.json`);
486
+ }
487
+
488
+ function validateDescriptor(value: unknown): EditorSessionDescriptor {
489
+ if (
490
+ !isRecord(value)
491
+ || typeof value.protocolVersion !== 'number'
492
+ || typeof value.projectRoot !== 'string'
493
+ || typeof value.pid !== 'number'
494
+ || typeof value.origin !== 'string'
495
+ || typeof value.token !== 'string'
496
+ ) {
497
+ throw new Error('Pixifact Editor session descriptor is invalid.');
498
+ }
499
+ if (value.protocolVersion !== editorSessionProtocolVersion) {
500
+ throw new Error('Editor session protocol version does not match. Restart Pixifact Editor.');
501
+ }
502
+ return value as unknown as EditorSessionDescriptor;
503
+ }
504
+
505
+ export function readEditorSessionDescriptor(projectRoot: string, sessionsRoot = defaultEditorSessionsRoot()) {
506
+ const descriptorPath = editorSessionDescriptorPath(projectRoot, sessionsRoot);
507
+ if (!fs.existsSync(descriptorPath)) {
508
+ return undefined;
509
+ }
510
+ return validateDescriptor(JSON.parse(fs.readFileSync(descriptorPath, 'utf8')));
511
+ }
512
+
513
+ export function writeEditorSessionDescriptor(
514
+ descriptor: EditorSessionDescriptor,
515
+ sessionsRoot = defaultEditorSessionsRoot(),
516
+ ) {
517
+ fs.mkdirSync(sessionsRoot, { recursive: true });
518
+ fs.writeFileSync(
519
+ editorSessionDescriptorPath(descriptor.projectRoot, sessionsRoot),
520
+ `${JSON.stringify(descriptor, null, 2)}\n`,
521
+ { encoding: 'utf8', flag: 'wx', mode: 0o600 },
522
+ );
523
+ }
524
+
525
+ export function removeEditorSessionDescriptor(
526
+ descriptor: EditorSessionDescriptor,
527
+ sessionsRoot = defaultEditorSessionsRoot(),
528
+ ) {
529
+ const descriptorPath = editorSessionDescriptorPath(descriptor.projectRoot, sessionsRoot);
530
+ if (!fs.existsSync(descriptorPath)) {
531
+ return;
532
+ }
533
+ const current = readEditorSessionDescriptor(descriptor.projectRoot, sessionsRoot);
534
+ if (current?.token === descriptor.token) {
535
+ fs.unlinkSync(descriptorPath);
536
+ }
537
+ }
538
+
539
+ export async function findActiveEditorSession(options: EditorSessionLookupOptions) {
540
+ const projectRoot = fs.realpathSync(options.projectRoot);
541
+ const descriptor = readEditorSessionDescriptor(projectRoot, options.sessionsRoot);
542
+ if (!descriptor) {
543
+ return undefined;
544
+ }
545
+ if (descriptor.projectRoot !== projectRoot) {
546
+ throw new Error('Pixifact Editor session belongs to a different project.');
547
+ }
548
+ const fetcher = options.fetch ?? fetch;
549
+ let response: Response;
550
+ try {
551
+ response = await fetcher(`${descriptor.origin}/api/editor-session/health`, {
552
+ headers: { authorization: `Bearer ${descriptor.token}` },
553
+ });
554
+ } catch {
555
+ return undefined;
556
+ }
557
+ if (!response.ok) {
558
+ return undefined;
559
+ }
560
+ const health = validateDescriptorHealth(await response.json());
561
+ if (health.projectRoot !== projectRoot) {
562
+ throw new Error('Pixifact Editor Host health check returned a different project.');
563
+ }
564
+ return descriptor;
565
+ }
566
+
567
+ function validateDescriptorHealth(value: unknown) {
568
+ if (!isRecord(value) || typeof value.protocolVersion !== 'number' || typeof value.projectRoot !== 'string') {
569
+ throw new Error('Pixifact Editor Host health response is invalid.');
570
+ }
571
+ if (value.protocolVersion !== editorSessionProtocolVersion) {
572
+ throw new Error('Editor session protocol version does not match. Restart Pixifact Editor.');
573
+ }
574
+ return value as { protocolVersion: number; projectRoot: string };
575
+ }
576
+
577
+ export async function claimEditorSession(options: ClaimEditorSessionOptions) {
578
+ for (let attempt = 0; attempt < 2; attempt += 1) {
579
+ const current = readEditorSessionDescriptor(options.projectRoot, options.sessionsRoot);
580
+ const active = await findActiveEditorSession(options);
581
+ if (active) {
582
+ return { descriptor: active, owned: false as const };
583
+ }
584
+ if (current) {
585
+ removeEditorSessionDescriptor(current, options.sessionsRoot);
586
+ }
587
+ try {
588
+ writeEditorSessionDescriptor(options.descriptor, options.sessionsRoot);
589
+ return { descriptor: options.descriptor, owned: true as const };
590
+ } catch (error) {
591
+ if (!isRecord(error) || error.code !== 'EEXIST') {
592
+ throw error;
593
+ }
594
+ }
595
+ }
596
+ throw new Error('Pixifact Editor session could not be registered.');
597
+ }
598
+
599
+ export async function queryEditorContext(options: EditorSessionLookupOptions) {
600
+ const projectRoot = fs.realpathSync(options.projectRoot);
601
+ const descriptor = readEditorSessionDescriptor(projectRoot, options.sessionsRoot);
602
+ if (!descriptor) {
603
+ return {
604
+ ok: false,
605
+ error: 'No Pixifact Editor Host is running for this project.',
606
+ };
607
+ }
608
+ const fetcher = options.fetch ?? fetch;
609
+ try {
610
+ const response = await fetcher(`${descriptor.origin}/api/editor-context`, {
611
+ headers: { authorization: `Bearer ${descriptor.token}` },
612
+ });
613
+ const result = await response.json() as Record<string, unknown>;
614
+ if (response.ok) {
615
+ if (result.protocolVersion !== editorSessionProtocolVersion) {
616
+ return {
617
+ ok: false,
618
+ error: 'Editor session protocol version does not match. Restart Pixifact Editor.',
619
+ };
620
+ }
621
+ if (result.projectRoot !== projectRoot) {
622
+ return {
623
+ ok: false,
624
+ error: 'Pixifact Editor context belongs to a different project.',
625
+ };
626
+ }
627
+ }
628
+ return result;
629
+ } catch {
630
+ return {
631
+ ok: false,
632
+ error: 'Pixifact Editor Host is not reachable for this project.',
633
+ };
634
+ }
635
+ }
636
+
637
+ export async function captureEditorScreenshot(
638
+ options: EditorSessionLookupOptions,
639
+ ): Promise<EditorScreenshotResult | { ok: false; error: string; [key: string]: unknown }> {
640
+ const projectRoot = fs.realpathSync(options.projectRoot);
641
+ const descriptor = readEditorSessionDescriptor(projectRoot, options.sessionsRoot);
642
+ if (!descriptor) {
643
+ return {
644
+ ok: false,
645
+ error: 'No Pixifact Editor Host is running for this project.',
646
+ };
647
+ }
648
+ if (descriptor.projectRoot !== projectRoot) {
649
+ return {
650
+ ok: false,
651
+ error: 'Pixifact Editor session belongs to a different project.',
652
+ };
653
+ }
654
+ const fetcher = options.fetch ?? fetch;
655
+ let response: Response;
656
+ try {
657
+ response = await fetcher(`${descriptor.origin}/api/editor-screenshot`, {
658
+ method: 'POST',
659
+ headers: { authorization: `Bearer ${descriptor.token}` },
660
+ });
661
+ } catch {
662
+ return {
663
+ ok: false,
664
+ error: 'Pixifact Editor Host is not reachable for this project.',
665
+ };
666
+ }
667
+ if (!response.ok) {
668
+ return await response.json() as { ok: false; error: string; [key: string]: unknown };
669
+ }
670
+ const sceneHeader = response.headers.get('x-pixifact-scene');
671
+ const revision = response.headers.get('x-pixifact-revision');
672
+ const width = Number(response.headers.get('x-pixifact-width'));
673
+ const height = Number(response.headers.get('x-pixifact-height'));
674
+ if (
675
+ response.headers.get('content-type') !== 'image/png'
676
+ || !sceneHeader
677
+ || !revision
678
+ || !Number.isInteger(width)
679
+ || width <= 0
680
+ || !Number.isInteger(height)
681
+ || height <= 0
682
+ ) {
683
+ return { ok: false, error: 'Pixifact Editor Host returned invalid screenshot metadata.' };
684
+ }
685
+ const data = new Uint8Array(await response.arrayBuffer());
686
+ if (!Buffer.from(data.subarray(0, 8)).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
687
+ return { ok: false, error: 'Pixifact Editor Host did not return PNG screenshot data.' };
688
+ }
689
+ return {
690
+ ok: true,
691
+ scene: decodeURIComponent(sceneHeader),
692
+ revision,
693
+ width,
694
+ height,
695
+ data,
696
+ };
697
+ }