slides-grab 1.0.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.
Files changed (51) hide show
  1. package/AGENTS.md +80 -0
  2. package/LICENSE +21 -0
  3. package/PROGRESS.md +39 -0
  4. package/README.md +120 -0
  5. package/SETUP.md +51 -0
  6. package/bin/ppt-agent.js +204 -0
  7. package/convert.cjs +184 -0
  8. package/package.json +51 -0
  9. package/prd.json +135 -0
  10. package/prd.md +104 -0
  11. package/scripts/editor-server.js +779 -0
  12. package/scripts/html2pdf.js +217 -0
  13. package/scripts/validate-slides.js +416 -0
  14. package/skills/ppt-design-skill/SKILL.md +38 -0
  15. package/skills/ppt-plan-skill/SKILL.md +37 -0
  16. package/skills/ppt-pptx-skill/SKILL.md +37 -0
  17. package/skills/ppt-presentation-skill/SKILL.md +57 -0
  18. package/src/editor/codex-edit.js +213 -0
  19. package/src/editor/editor.html +1733 -0
  20. package/src/editor/js/editor-bbox.js +332 -0
  21. package/src/editor/js/editor-chat.js +56 -0
  22. package/src/editor/js/editor-direct-edit.js +110 -0
  23. package/src/editor/js/editor-dom.js +55 -0
  24. package/src/editor/js/editor-init.js +284 -0
  25. package/src/editor/js/editor-navigation.js +54 -0
  26. package/src/editor/js/editor-select.js +264 -0
  27. package/src/editor/js/editor-send.js +157 -0
  28. package/src/editor/js/editor-sse.js +163 -0
  29. package/src/editor/js/editor-state.js +32 -0
  30. package/src/editor/js/editor-utils.js +167 -0
  31. package/src/editor/screenshot.js +73 -0
  32. package/src/resolve.js +159 -0
  33. package/templates/chart.html +121 -0
  34. package/templates/closing.html +54 -0
  35. package/templates/content.html +50 -0
  36. package/templates/contents.html +60 -0
  37. package/templates/cover.html +64 -0
  38. package/templates/custom/.gitkeep +0 -0
  39. package/templates/custom/README.md +7 -0
  40. package/templates/diagram.html +98 -0
  41. package/templates/quote.html +31 -0
  42. package/templates/section-divider.html +43 -0
  43. package/templates/split-layout.html +41 -0
  44. package/templates/statistics.html +55 -0
  45. package/templates/team.html +49 -0
  46. package/templates/timeline.html +59 -0
  47. package/themes/corporate.css +8 -0
  48. package/themes/executive.css +10 -0
  49. package/themes/modern-dark.css +9 -0
  50. package/themes/sage.css +9 -0
  51. package/themes/warm.css +8 -0
@@ -0,0 +1,779 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readdir, readFile, writeFile, mkdtemp, rm, mkdir } from 'node:fs/promises';
4
+ import { watch as fsWatch } from 'node:fs';
5
+ import { basename, dirname, join, resolve, relative, sep } from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { tmpdir } from 'node:os';
9
+
10
+ import {
11
+ SLIDE_SIZE,
12
+ buildCodexEditPrompt,
13
+ buildCodexExecArgs,
14
+ buildClaudeExecArgs,
15
+ CLAUDE_MODELS,
16
+ isClaudeModel,
17
+ normalizeSelection,
18
+ scaleSelectionToScreenshot,
19
+ writeAnnotatedScreenshot,
20
+ } from '../src/editor/codex-edit.js';
21
+
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = dirname(__filename);
24
+ const PACKAGE_ROOT = process.env.PPT_AGENT_PACKAGE_ROOT || resolve(__dirname, '..');
25
+
26
+ let express;
27
+ let screenshotMod;
28
+
29
+ async function loadDeps() {
30
+ if (!express) {
31
+ express = (await import('express')).default;
32
+ }
33
+ if (!screenshotMod) {
34
+ screenshotMod = await import('../src/editor/screenshot.js');
35
+ }
36
+ }
37
+
38
+ const DEFAULT_PORT = 3456;
39
+ const DEFAULT_SLIDES_DIR = 'slides';
40
+ const CODEX_MODELS = ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.3-codex-spark'];
41
+ const ALL_MODELS = [...CODEX_MODELS, ...CLAUDE_MODELS];
42
+ const DEFAULT_CODEX_MODEL = CODEX_MODELS[0];
43
+ const SLIDE_FILE_PATTERN = /^slide-.*\.html$/i;
44
+
45
+ const MAX_RUNS = 200;
46
+ const MAX_LOG_CHARS = 800_000;
47
+
48
+ function printUsage() {
49
+ process.stdout.write(`Usage: slides-grab edit [options]\n\n`);
50
+ process.stdout.write(`Options:\n`);
51
+ process.stdout.write(` --port <number> Server port (default: ${DEFAULT_PORT})\n`);
52
+ process.stdout.write(` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})\n`);
53
+ process.stdout.write(` Model is selected in editor UI dropdown.\n`);
54
+ process.stdout.write(` -h, --help Show this help message\n`);
55
+ }
56
+
57
+ function parseArgs(argv) {
58
+ const opts = {
59
+ port: DEFAULT_PORT,
60
+ slidesDir: DEFAULT_SLIDES_DIR,
61
+ help: false,
62
+ };
63
+
64
+ for (let i = 0; i < argv.length; i += 1) {
65
+ const arg = argv[i];
66
+ if (arg === '-h' || arg === '--help') {
67
+ opts.help = true;
68
+ continue;
69
+ }
70
+
71
+ if (arg === '--port') {
72
+ opts.port = Number(argv[i + 1]);
73
+ i += 1;
74
+ continue;
75
+ }
76
+
77
+ if (arg.startsWith('--port=')) {
78
+ opts.port = Number(arg.slice('--port='.length));
79
+ continue;
80
+ }
81
+
82
+ if (arg === '--slides-dir') {
83
+ opts.slidesDir = argv[i + 1];
84
+ i += 1;
85
+ continue;
86
+ }
87
+
88
+ if (arg.startsWith('--slides-dir=')) {
89
+ opts.slidesDir = arg.slice('--slides-dir='.length);
90
+ continue;
91
+ }
92
+
93
+ if (arg === '--codex-model') {
94
+ // Backward compatibility: ignore legacy CLI option.
95
+ i += 1;
96
+ continue;
97
+ }
98
+
99
+ throw new Error(`Unknown option: ${arg}`);
100
+ }
101
+
102
+ if (!Number.isInteger(opts.port) || opts.port <= 0) {
103
+ throw new Error('`--port` must be a positive integer.');
104
+ }
105
+
106
+ if (typeof opts.slidesDir !== 'string' || opts.slidesDir.trim() === '') {
107
+ throw new Error('`--slides-dir` must be a non-empty path.');
108
+ }
109
+
110
+ opts.slidesDir = opts.slidesDir.trim();
111
+
112
+ return opts;
113
+ }
114
+
115
+ const sseClients = new Set();
116
+
117
+ function broadcastSSE(event, data) {
118
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
119
+ for (const res of sseClients) {
120
+ res.write(payload);
121
+ }
122
+ }
123
+
124
+ let browserPromise = null;
125
+
126
+ async function getScreenshotBrowser() {
127
+ if (!browserPromise) {
128
+ browserPromise = screenshotMod.createScreenshotBrowser();
129
+ }
130
+ return browserPromise;
131
+ }
132
+
133
+ async function closeBrowser() {
134
+ if (browserPromise) {
135
+ const { browser } = await getScreenshotBrowser();
136
+ browserPromise = null;
137
+ await browser.close();
138
+ }
139
+ }
140
+
141
+ async function withScreenshotPage(callback) {
142
+ const { browser } = await getScreenshotBrowser();
143
+ const { context, page } = await screenshotMod.createScreenshotPage(browser);
144
+ try {
145
+ return await callback(page);
146
+ } finally {
147
+ await context.close().catch(() => {});
148
+ }
149
+ }
150
+
151
+ function toPosixPath(inputPath) {
152
+ return inputPath.split(sep).join('/');
153
+ }
154
+
155
+ function toSlidePathLabel(slidesDirectory, slideFile) {
156
+ const relativePath = relative(process.cwd(), join(slidesDirectory, slideFile));
157
+ const hasParentTraversal = relativePath.startsWith('..');
158
+ const label = !hasParentTraversal && relativePath !== '' ? relativePath : join(slidesDirectory, slideFile);
159
+ return toPosixPath(label);
160
+ }
161
+
162
+ async function listSlideFiles(slidesDirectory) {
163
+ const entries = await readdir(slidesDirectory, { withFileTypes: true });
164
+ return entries
165
+ .filter((entry) => entry.isFile() && SLIDE_FILE_PATTERN.test(entry.name))
166
+ .map((entry) => entry.name)
167
+ .sort((a, b) => {
168
+ const numA = Number.parseInt(a.match(/\d+/)?.[0] ?? '0', 10);
169
+ const numB = Number.parseInt(b.match(/\d+/)?.[0] ?? '0', 10);
170
+ return numA - numB || a.localeCompare(b);
171
+ });
172
+ }
173
+
174
+ function normalizeSlideFilename(rawSlide, source = '`slide`') {
175
+ const slide = typeof rawSlide === 'string' ? basename(rawSlide.trim()) : '';
176
+ if (!slide || !SLIDE_FILE_PATTERN.test(slide)) {
177
+ throw new Error(`Missing or invalid ${source}.`);
178
+ }
179
+ return slide;
180
+ }
181
+
182
+ function normalizeSlideHtml(rawHtml) {
183
+ if (typeof rawHtml !== 'string' || rawHtml.trim() === '') {
184
+ throw new Error('Missing or invalid `html`.');
185
+ }
186
+ return rawHtml;
187
+ }
188
+
189
+ function sanitizeTargets(rawTargets) {
190
+ if (!Array.isArray(rawTargets)) return [];
191
+
192
+ return rawTargets
193
+ .filter((target) => target && typeof target === 'object')
194
+ .slice(0, 30)
195
+ .map((target) => ({
196
+ xpath: typeof target.xpath === 'string' ? target.xpath.slice(0, 500) : '',
197
+ tag: typeof target.tag === 'string' ? target.tag.slice(0, 40) : '',
198
+ text: typeof target.text === 'string' ? target.text.slice(0, 400) : '',
199
+ }))
200
+ .filter((target) => target.xpath);
201
+ }
202
+
203
+ function normalizeSelections(rawSelections) {
204
+ if (!Array.isArray(rawSelections) || rawSelections.length === 0) {
205
+ throw new Error('At least one selection is required.');
206
+ }
207
+
208
+ return rawSelections.slice(0, 24).map((selection) => {
209
+ const selectionSource = selection?.bbox && typeof selection.bbox === 'object'
210
+ ? selection.bbox
211
+ : selection;
212
+
213
+ const bbox = normalizeSelection(selectionSource, SLIDE_SIZE);
214
+ const targets = sanitizeTargets(selection?.targets);
215
+
216
+ return { bbox, targets };
217
+ });
218
+ }
219
+
220
+ function normalizeModel(rawModel) {
221
+ const model = typeof rawModel === 'string' ? rawModel.trim() : '';
222
+ if (!model) return DEFAULT_CODEX_MODEL;
223
+ if (!ALL_MODELS.includes(model)) {
224
+ throw new Error(`Invalid \`model\`. Allowed models: ${ALL_MODELS.join(', ')}`);
225
+ }
226
+ return model;
227
+ }
228
+
229
+ function randomRunId() {
230
+ const ts = Date.now();
231
+ const rand = Math.floor(Math.random() * 100000);
232
+ return `run-${ts}-${rand}`;
233
+ }
234
+
235
+ function spawnCodexEdit({ prompt, imagePath, model, cwd, onLog }) {
236
+ const codexBin = process.env.PPT_AGENT_CODEX_BIN || 'codex';
237
+ const args = buildCodexExecArgs({ prompt, imagePath, model });
238
+
239
+ return new Promise((resolvePromise, rejectPromise) => {
240
+ const child = spawn(codexBin, args, { cwd, stdio: 'pipe' });
241
+
242
+ let stdout = '';
243
+ let stderr = '';
244
+
245
+ child.stdout.on('data', (chunk) => {
246
+ const text = chunk.toString();
247
+ stdout += text;
248
+ onLog('stdout', text);
249
+ process.stdout.write(text);
250
+ });
251
+
252
+ child.stderr.on('data', (chunk) => {
253
+ const text = chunk.toString();
254
+ stderr += text;
255
+ onLog('stderr', text);
256
+ process.stderr.write(text);
257
+ });
258
+
259
+ child.on('close', (code) => {
260
+ resolvePromise({ code: code ?? 1, stdout, stderr });
261
+ });
262
+
263
+ child.on('error', (error) => {
264
+ rejectPromise(error);
265
+ });
266
+ });
267
+ }
268
+
269
+ function spawnClaudeEdit({ prompt, imagePath, model, cwd, onLog }) {
270
+ const claudeBin = process.env.PPT_AGENT_CLAUDE_BIN || 'claude';
271
+ const args = buildClaudeExecArgs({ prompt, imagePath, model });
272
+
273
+ // Remove CLAUDECODE env var to avoid "nested session" detection error
274
+ const env = { ...process.env };
275
+ delete env.CLAUDECODE;
276
+
277
+ return new Promise((resolvePromise, rejectPromise) => {
278
+ const child = spawn(claudeBin, args, {
279
+ cwd,
280
+ stdio: ['ignore', 'pipe', 'pipe'],
281
+ env,
282
+ });
283
+
284
+ let stdout = '';
285
+ let stderr = '';
286
+
287
+ child.stdout.on('data', (chunk) => {
288
+ const text = chunk.toString();
289
+ stdout += text;
290
+ onLog('stdout', text);
291
+ process.stdout.write(text);
292
+ });
293
+
294
+ child.stderr.on('data', (chunk) => {
295
+ const text = chunk.toString();
296
+ stderr += text;
297
+ onLog('stderr', text);
298
+ process.stderr.write(text);
299
+ });
300
+
301
+ child.on('close', (code) => {
302
+ resolvePromise({ code: code ?? 1, stdout, stderr });
303
+ });
304
+
305
+ child.on('error', (error) => {
306
+ rejectPromise(error);
307
+ });
308
+ });
309
+ }
310
+
311
+ function createRunStore() {
312
+ const activeRunsBySlide = new Map();
313
+ const runStore = new Map();
314
+ const runOrder = [];
315
+
316
+ function toRunSummary(run) {
317
+ return {
318
+ runId: run.runId,
319
+ slide: run.slide,
320
+ model: run.model,
321
+ status: run.status,
322
+ code: run.code,
323
+ message: run.message,
324
+ startedAt: run.startedAt,
325
+ finishedAt: run.finishedAt,
326
+ prompt: run.prompt,
327
+ selectionsCount: run.selectionsCount,
328
+ logSize: run.log.length,
329
+ logPreview: run.log.slice(-2000),
330
+ };
331
+ }
332
+
333
+ return {
334
+ hasActiveRunForSlide(slide) {
335
+ return activeRunsBySlide.has(slide);
336
+ },
337
+
338
+ getActiveRunId(slide) {
339
+ return activeRunsBySlide.get(slide) ?? null;
340
+ },
341
+
342
+ startRun({ runId, slide, prompt, selectionsCount, model }) {
343
+ activeRunsBySlide.set(slide, runId);
344
+
345
+ const run = {
346
+ runId,
347
+ slide,
348
+ status: 'running',
349
+ code: null,
350
+ message: 'Running',
351
+ prompt,
352
+ model,
353
+ selectionsCount,
354
+ startedAt: new Date().toISOString(),
355
+ finishedAt: null,
356
+ log: '',
357
+ };
358
+
359
+ runStore.set(runId, run);
360
+ runOrder.push(runId);
361
+
362
+ while (runOrder.length > MAX_RUNS) {
363
+ const oldestRunId = runOrder.shift();
364
+ if (!oldestRunId) continue;
365
+ runStore.delete(oldestRunId);
366
+ }
367
+
368
+ return toRunSummary(run);
369
+ },
370
+
371
+ appendLog(runId, chunk) {
372
+ const run = runStore.get(runId);
373
+ if (!run) return;
374
+
375
+ run.log += chunk;
376
+ if (run.log.length > MAX_LOG_CHARS) {
377
+ run.log = run.log.slice(run.log.length - MAX_LOG_CHARS);
378
+ }
379
+ },
380
+
381
+ finishRun(runId, { status, code, message }) {
382
+ const run = runStore.get(runId);
383
+ if (!run) return null;
384
+
385
+ run.status = status;
386
+ run.code = code;
387
+ run.message = message;
388
+ run.finishedAt = new Date().toISOString();
389
+
390
+ if (activeRunsBySlide.get(run.slide) === runId) {
391
+ activeRunsBySlide.delete(run.slide);
392
+ }
393
+
394
+ return toRunSummary(run);
395
+ },
396
+
397
+ clearActiveRun(slide, runId) {
398
+ if (activeRunsBySlide.get(slide) === runId) {
399
+ activeRunsBySlide.delete(slide);
400
+ }
401
+ },
402
+
403
+ listRuns(limit = 60) {
404
+ return runOrder
405
+ .slice(Math.max(0, runOrder.length - limit))
406
+ .reverse()
407
+ .map((runId) => runStore.get(runId))
408
+ .filter(Boolean)
409
+ .map((run) => toRunSummary(run));
410
+ },
411
+
412
+ getRunLog(runId) {
413
+ const run = runStore.get(runId);
414
+ if (!run) return null;
415
+ return run.log;
416
+ },
417
+
418
+ listActiveRuns() {
419
+ return Array.from(activeRunsBySlide.entries()).map(([slide, runId]) => ({ slide, runId }));
420
+ },
421
+ };
422
+ }
423
+
424
+ async function startServer(opts) {
425
+ await loadDeps();
426
+ const slidesDirectory = resolve(process.cwd(), opts.slidesDir);
427
+ await mkdir(slidesDirectory, { recursive: true });
428
+
429
+ const runStore = createRunStore();
430
+
431
+ const app = express();
432
+ app.use(express.json({ limit: '5mb' }));
433
+ app.use('/js', express.static(join(PACKAGE_ROOT, 'src', 'editor', 'js')));
434
+
435
+ const editorHtmlPath = join(PACKAGE_ROOT, 'src', 'editor', 'editor.html');
436
+
437
+ function broadcastRunsSnapshot() {
438
+ broadcastSSE('runsSnapshot', {
439
+ runs: runStore.listRuns(),
440
+ activeRuns: runStore.listActiveRuns(),
441
+ });
442
+ }
443
+
444
+ app.get('/', async (_req, res) => {
445
+ try {
446
+ const html = await readFile(editorHtmlPath, 'utf-8');
447
+ res.type('html').send(html);
448
+ } catch (err) {
449
+ res.status(500).send(`Failed to load editor: ${err.message}`);
450
+ }
451
+ });
452
+
453
+ app.get('/slides/:file', async (req, res) => {
454
+ let file;
455
+ try {
456
+ file = normalizeSlideFilename(req.params.file, 'slide filename');
457
+ } catch {
458
+ return res.status(400).send('Invalid slide filename');
459
+ }
460
+
461
+ const filePath = join(slidesDirectory, file);
462
+ try {
463
+ const html = await readFile(filePath, 'utf-8');
464
+ res.type('html').send(html);
465
+ } catch {
466
+ res.status(404).send(`Slide not found: ${file}`);
467
+ }
468
+ });
469
+
470
+ app.get('/api/slides', async (_req, res) => {
471
+ try {
472
+ const files = await listSlideFiles(slidesDirectory);
473
+ res.json(files);
474
+ } catch (err) {
475
+ res.status(500).json({ error: err.message });
476
+ }
477
+ });
478
+
479
+ app.post('/api/slides/:file/save', async (req, res) => {
480
+ let file;
481
+ try {
482
+ file = normalizeSlideFilename(req.params.file, '`slide`');
483
+ } catch (error) {
484
+ return res.status(400).json({ error: error.message });
485
+ }
486
+
487
+ const bodySlide = req.body?.slide;
488
+ if (bodySlide !== undefined) {
489
+ let normalizedBodySlide;
490
+ try {
491
+ normalizedBodySlide = normalizeSlideFilename(bodySlide, '`slide`');
492
+ } catch (error) {
493
+ return res.status(400).json({ error: error.message });
494
+ }
495
+
496
+ if (normalizedBodySlide !== file) {
497
+ return res.status(400).json({ error: '`slide` does not match the requested file.' });
498
+ }
499
+ }
500
+
501
+ let html;
502
+ try {
503
+ html = normalizeSlideHtml(req.body?.html);
504
+ } catch (error) {
505
+ return res.status(400).json({ error: error.message });
506
+ }
507
+
508
+ const filePath = join(slidesDirectory, file);
509
+ try {
510
+ await readFile(filePath, 'utf-8');
511
+ } catch {
512
+ return res.status(404).json({ error: `Slide not found: ${file}` });
513
+ }
514
+
515
+ try {
516
+ await writeFile(filePath, html, 'utf8');
517
+ return res.json({
518
+ success: true,
519
+ slide: file,
520
+ bytes: Buffer.byteLength(html, 'utf8'),
521
+ });
522
+ } catch (error) {
523
+ return res.status(500).json({
524
+ success: false,
525
+ error: `Failed to save ${file}: ${error.message}`,
526
+ });
527
+ }
528
+ });
529
+
530
+ app.get('/api/models', (_req, res) => {
531
+ res.json({
532
+ models: ALL_MODELS,
533
+ defaultModel: DEFAULT_CODEX_MODEL,
534
+ });
535
+ });
536
+
537
+ app.get('/api/runs', (_req, res) => {
538
+ res.json({
539
+ runs: runStore.listRuns(100),
540
+ activeRuns: runStore.listActiveRuns(),
541
+ });
542
+ });
543
+
544
+ app.get('/api/runs/:runId/log', (req, res) => {
545
+ const log = runStore.getRunLog(req.params.runId);
546
+ if (log === null) {
547
+ return res.status(404).send('Run not found');
548
+ }
549
+
550
+ res.type('text/plain').send(log);
551
+ });
552
+
553
+ app.get('/api/events', (req, res) => {
554
+ res.writeHead(200, {
555
+ 'Content-Type': 'text/event-stream',
556
+ 'Cache-Control': 'no-cache',
557
+ Connection: 'keep-alive',
558
+ });
559
+ res.write('event: connected\ndata: {}\n\n');
560
+
561
+ sseClients.add(res);
562
+ req.on('close', () => sseClients.delete(res));
563
+
564
+ const snapshotPayload = {
565
+ runs: runStore.listRuns(),
566
+ activeRuns: runStore.listActiveRuns(),
567
+ };
568
+ res.write(`event: runsSnapshot\ndata: ${JSON.stringify(snapshotPayload)}\n\n`);
569
+ });
570
+
571
+ app.post('/api/apply', async (req, res) => {
572
+ const { slide, prompt, selections, model } = req.body ?? {};
573
+
574
+ if (!slide || typeof slide !== 'string' || !SLIDE_FILE_PATTERN.test(slide)) {
575
+ return res.status(400).json({ error: 'Missing or invalid `slide`.' });
576
+ }
577
+
578
+ if (typeof prompt !== 'string' || prompt.trim() === '') {
579
+ return res.status(400).json({ error: 'Missing or invalid `prompt`.' });
580
+ }
581
+
582
+ let selectedModel;
583
+ try {
584
+ selectedModel = normalizeModel(model);
585
+ } catch (error) {
586
+ return res.status(400).json({ error: error.message });
587
+ }
588
+
589
+ if (runStore.hasActiveRunForSlide(slide)) {
590
+ return res.status(409).json({
591
+ error: `Slide ${slide} already has an active run.`,
592
+ runId: runStore.getActiveRunId(slide),
593
+ });
594
+ }
595
+
596
+ let normalizedSelections;
597
+ try {
598
+ normalizedSelections = normalizeSelections(selections);
599
+ } catch (error) {
600
+ return res.status(400).json({ error: error.message });
601
+ }
602
+
603
+ const runId = randomRunId();
604
+
605
+ const runSummary = runStore.startRun({
606
+ runId,
607
+ slide,
608
+ prompt: prompt.trim(),
609
+ selectionsCount: normalizedSelections.length,
610
+ model: selectedModel,
611
+ });
612
+
613
+ broadcastSSE('applyStarted', {
614
+ runId,
615
+ slide,
616
+ model: selectedModel,
617
+ selectionsCount: normalizedSelections.length,
618
+ selectionBoxes: normalizedSelections.map((selection) => selection.bbox),
619
+ });
620
+ broadcastRunsSnapshot();
621
+
622
+ const tmpPath = await mkdtemp(join(tmpdir(), 'editor-codex-'));
623
+ const screenshotPath = join(tmpPath, 'slide.png');
624
+ const annotatedPath = join(tmpPath, 'slide-annotated.png');
625
+
626
+ try {
627
+ await withScreenshotPage(async (page) => {
628
+ await screenshotMod.captureSlideScreenshot(
629
+ page,
630
+ slide,
631
+ screenshotPath,
632
+ `http://localhost:${opts.port}/slides`,
633
+ { useHttp: true },
634
+ );
635
+ });
636
+
637
+ const scaledBoxes = normalizedSelections.map((selection) =>
638
+ scaleSelectionToScreenshot(
639
+ selection.bbox,
640
+ SLIDE_SIZE,
641
+ screenshotMod.SCREENSHOT_SIZE,
642
+ ),
643
+ );
644
+
645
+ await writeAnnotatedScreenshot(screenshotPath, annotatedPath, scaledBoxes);
646
+
647
+ const codexPrompt = buildCodexEditPrompt({
648
+ slideFile: slide,
649
+ slidePath: toSlidePathLabel(slidesDirectory, slide),
650
+ userPrompt: prompt,
651
+ selections: normalizedSelections,
652
+ });
653
+
654
+ const usesClaude = isClaudeModel(selectedModel);
655
+ const spawnEdit = usesClaude ? spawnClaudeEdit : spawnCodexEdit;
656
+ const result = await spawnEdit({
657
+ prompt: codexPrompt,
658
+ imagePath: annotatedPath,
659
+ model: selectedModel,
660
+ cwd: process.cwd(),
661
+ onLog: (stream, chunk) => {
662
+ runStore.appendLog(runId, chunk);
663
+ broadcastSSE('applyLog', { runId, slide, stream, chunk });
664
+ },
665
+ });
666
+
667
+ const engineLabel = isClaudeModel(selectedModel) ? 'Claude' : 'Codex';
668
+ const success = result.code === 0;
669
+ const message = success
670
+ ? `${engineLabel} edit completed.`
671
+ : `${engineLabel} exited with code ${result.code}.`;
672
+
673
+ runStore.finishRun(runId, {
674
+ status: success ? 'success' : 'failed',
675
+ code: result.code,
676
+ message,
677
+ });
678
+
679
+ broadcastSSE('applyFinished', {
680
+ runId,
681
+ slide,
682
+ model: selectedModel,
683
+ success,
684
+ code: result.code,
685
+ message,
686
+ });
687
+ broadcastRunsSnapshot();
688
+
689
+ res.json({
690
+ ...runSummary,
691
+ success,
692
+ runId,
693
+ model: selectedModel,
694
+ code: result.code,
695
+ message,
696
+ });
697
+ } catch (error) {
698
+ const message = error instanceof Error ? error.message : String(error);
699
+
700
+ runStore.finishRun(runId, {
701
+ status: 'failed',
702
+ code: -1,
703
+ message,
704
+ });
705
+
706
+ broadcastSSE('applyFinished', {
707
+ runId,
708
+ slide,
709
+ model: selectedModel,
710
+ success: false,
711
+ code: -1,
712
+ message,
713
+ });
714
+ broadcastRunsSnapshot();
715
+
716
+ res.status(500).json({
717
+ success: false,
718
+ runId,
719
+ error: message,
720
+ });
721
+ } finally {
722
+ runStore.clearActiveRun(slide, runId);
723
+ await rm(tmpPath, { recursive: true, force: true }).catch(() => {});
724
+ }
725
+ });
726
+
727
+ let debounceTimer = null;
728
+ const watcher = fsWatch(slidesDirectory, { persistent: false }, (_eventType, filename) => {
729
+ if (!filename || !SLIDE_FILE_PATTERN.test(filename)) return;
730
+ clearTimeout(debounceTimer);
731
+ debounceTimer = setTimeout(() => {
732
+ broadcastSSE('fileChanged', { file: filename });
733
+ }, 300);
734
+ });
735
+
736
+ const server = app.listen(opts.port, () => {
737
+ process.stdout.write('\n slides-grab editor\n');
738
+ process.stdout.write(' ─────────────────────────────────────\n');
739
+ process.stdout.write(` Local: http://localhost:${opts.port}\n`);
740
+ process.stdout.write(` Models: ${ALL_MODELS.join(', ')}\n`);
741
+ process.stdout.write(` Slides: ${slidesDirectory}\n`);
742
+ process.stdout.write(' ─────────────────────────────────────\n\n');
743
+ });
744
+
745
+ async function shutdown() {
746
+ process.stdout.write('\n[editor] Shutting down...\n');
747
+ watcher.close();
748
+ for (const client of sseClients) {
749
+ client.end();
750
+ }
751
+ sseClients.clear();
752
+ server.close();
753
+ await closeBrowser();
754
+ process.exit(0);
755
+ }
756
+
757
+ process.on('SIGINT', shutdown);
758
+ process.on('SIGTERM', shutdown);
759
+ }
760
+
761
+ const args = process.argv.slice(2);
762
+
763
+ let opts;
764
+ try {
765
+ opts = parseArgs(args);
766
+ } catch (error) {
767
+ process.stderr.write(`[editor] ${error.message}\n`);
768
+ process.exit(1);
769
+ }
770
+
771
+ if (opts.help) {
772
+ printUsage();
773
+ process.exit(0);
774
+ }
775
+
776
+ startServer(opts).catch((err) => {
777
+ process.stderr.write(`[editor] Fatal: ${err.message}\n`);
778
+ process.exit(1);
779
+ });