screencraft 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.
Files changed (40) hide show
  1. package/.claude/settings.local.json +30 -0
  2. package/.env.example +3 -0
  3. package/MCP_README.md +200 -0
  4. package/README.md +148 -0
  5. package/bin/screencraft.js +61 -0
  6. package/package.json +31 -0
  7. package/src/auth/keystore.js +148 -0
  8. package/src/commands/init.js +119 -0
  9. package/src/commands/launch.js +405 -0
  10. package/src/detectors/detectBrand.js +1222 -0
  11. package/src/detectors/simulator.js +317 -0
  12. package/src/generators/analyzeStyleReference.js +471 -0
  13. package/src/generators/compositePSD.js +682 -0
  14. package/src/generators/copy.js +147 -0
  15. package/src/mcp/index.js +394 -0
  16. package/src/pipeline/aeSwap.js +369 -0
  17. package/src/pipeline/download.js +32 -0
  18. package/src/pipeline/queue.js +101 -0
  19. package/src/server/index.js +627 -0
  20. package/src/server/public/app.js +738 -0
  21. package/src/server/public/index.html +255 -0
  22. package/src/server/public/style.css +751 -0
  23. package/src/server/session.js +36 -0
  24. package/templates/ae/(Footage)/Assets/This Hip-Hop Upbeat (Short version).wav +0 -0
  25. package/templates/ae/(Footage)/Assets/screen_01_raw.png +0 -0
  26. package/templates/ae/(Footage)/Assets/screen_02_raw.png +0 -0
  27. package/templates/ae/(Footage)/Assets/screen_03_raw.png +0 -0
  28. package/templates/ae/(Footage)/Assets/screen_04_raw.png +0 -0
  29. package/templates/ae/(Footage)/Assets/screen_05_raw.png +0 -0
  30. package/templates/ae/(Footage)/Assets/screen_06_raw.png +0 -0
  31. package/templates/ae/Motion Forge Test 1.0 (converted).aep +0 -0
  32. package/templates/ae_swap.jsx +284 -0
  33. package/templates/layouts/minimal.psd +0 -0
  34. package/templates/screencraft.config.example.js +165 -0
  35. package/test/output/layout_test.png +0 -0
  36. package/test/output/style_profile.json +64 -0
  37. package/test/reference.png +0 -0
  38. package/test/test_brand.js +69 -0
  39. package/test/test_psd.js +83 -0
  40. package/test/test_style_analysis.js +114 -0
@@ -0,0 +1,627 @@
1
+ /**
2
+ * src/server/index.js
3
+ * -------------------
4
+ * Express server for ScreenCraft web UI.
5
+ * Wraps existing pipeline functions with API endpoints.
6
+ */
7
+
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const express = require('express');
11
+
12
+ const { detectFramework, detectBrand } = require('../detectors/detectBrand');
13
+ const { captureScreenshots } = require('../detectors/simulator');
14
+ const { suggestHeadlines } = require('../generators/copy');
15
+ const { compositePSD } = require('../generators/compositePSD');
16
+ const { validateKey, getStoredKey } = require('../auth/keystore');
17
+ const { prepareAEProject, renderLocally } = require('../pipeline/aeSwap');
18
+
19
+ const app = express();
20
+ const PORT = process.env.SCREENCRAFT_PORT || 3141;
21
+
22
+ app.use(express.json({ limit: '50mb' }));
23
+ app.use(express.static(path.join(__dirname, 'public')));
24
+
25
+ // ── Session state (shared with MCP server) ───────────────────────
26
+ const { session, resetSession } = require('./session');
27
+
28
+ // ── Logging helper (mirrors CLI) ──────────────────────────────────
29
+ const log = {
30
+ step: (n, msg) => console.log(`[${n}] ${msg}`),
31
+ success: (msg) => console.log(` ✓ ${msg}`),
32
+ info: (msg) => console.log(` ↳ ${msg}`),
33
+ warn: (msg) => console.log(` ⚠ ${msg}`),
34
+ };
35
+
36
+ // ── API: Detect brand + framework ────────────────────────────────
37
+ app.post('/api/detect', async (req, res) => {
38
+ try {
39
+ const projectPath = req.body.projectPath || process.cwd();
40
+
41
+ if (!fs.existsSync(projectPath)) {
42
+ return res.status(400).json({ error: 'Project path does not exist' });
43
+ }
44
+
45
+ resetSession();
46
+ session.projectPath = projectPath;
47
+
48
+ // Load config if exists
49
+ const configPath = path.join(projectPath, 'screencraft.config.js');
50
+ const config = fs.existsSync(configPath) ? require(configPath) : {};
51
+
52
+ session.outputDir = path.join(projectPath, config.output?.dir || './launch-kit');
53
+ fs.mkdirSync(session.outputDir, { recursive: true });
54
+ fs.mkdirSync(path.join(session.outputDir, 'screenshots'), { recursive: true });
55
+ fs.mkdirSync(path.join(session.outputDir, 'video'), { recursive: true });
56
+ fs.mkdirSync(path.join(session.outputDir, 'source'), { recursive: true });
57
+ fs.mkdirSync(path.join(session.outputDir, 'brand'), { recursive: true });
58
+
59
+ const framework = await detectFramework(projectPath);
60
+ const brand = await detectBrand(projectPath, config);
61
+
62
+ session.framework = framework;
63
+ session.brand = brand;
64
+
65
+ // Read icon as base64 if it exists
66
+ let iconBase64 = null;
67
+ if (brand.icon && fs.existsSync(brand.icon)) {
68
+ const ext = path.extname(brand.icon).slice(1) || 'png';
69
+ const buf = fs.readFileSync(brand.icon);
70
+ iconBase64 = `data:image/${ext};base64,${buf.toString('base64')}`;
71
+ }
72
+
73
+ res.json({
74
+ framework: {
75
+ name: framework.name,
76
+ platform: framework.platform,
77
+ },
78
+ brand: {
79
+ appName: brand.appName,
80
+ primary: brand.primary,
81
+ secondary: brand.secondary,
82
+ accent: brand.accent,
83
+ background: brand.background,
84
+ icon: iconBase64,
85
+ font: brand.font,
86
+ logo: brand.logo ? true : false,
87
+ },
88
+ });
89
+ } catch (err) {
90
+ console.error('Detect error:', err);
91
+ res.status(500).json({ error: err.message });
92
+ }
93
+ });
94
+
95
+ // ── API: Update brand colors ──────────────────────────────────────
96
+ app.post('/api/brand/update', (req, res) => {
97
+ if (!session.brand) {
98
+ return res.status(400).json({ error: 'Run detect first' });
99
+ }
100
+ const { primary, secondary, accent, background, appName } = req.body;
101
+ if (primary) session.brand.primary = primary;
102
+ if (secondary) session.brand.secondary = secondary;
103
+ if (accent) session.brand.accent = accent;
104
+ if (background) session.brand.background = background;
105
+ if (appName) session.brand.appName = appName;
106
+ res.json({ brand: session.brand });
107
+ });
108
+
109
+ // ── API: List available templates ─────────────────────────────────
110
+ app.get('/api/templates', (req, res) => {
111
+ const layoutsDir = path.join(__dirname, '..', '..', 'templates', 'layouts');
112
+ const templates = [];
113
+
114
+ try {
115
+ if (fs.existsSync(layoutsDir)) {
116
+ const entries = fs.readdirSync(layoutsDir, { withFileTypes: true });
117
+
118
+ // Flat PSD files
119
+ entries
120
+ .filter(e => !e.isDirectory() && e.name.endsWith('.psd'))
121
+ .forEach(e => {
122
+ const name = e.name.replace('.psd', '');
123
+ templates.push({
124
+ name,
125
+ label: name.charAt(0).toUpperCase() + name.slice(1),
126
+ description: 'PSD template with [SWAP_*] layers',
127
+ badge: 'PSD',
128
+ });
129
+ });
130
+
131
+ // Subdirectories with template.psd
132
+ entries
133
+ .filter(e => e.isDirectory())
134
+ .forEach(e => {
135
+ const tPath = path.join(layoutsDir, e.name, 'template.psd');
136
+ if (fs.existsSync(tPath)) {
137
+ templates.push({
138
+ name: e.name,
139
+ label: e.name.charAt(0).toUpperCase() + e.name.slice(1),
140
+ description: 'PSD template with [SWAP_*] layers',
141
+ badge: 'PSD',
142
+ });
143
+ }
144
+ });
145
+ }
146
+ } catch {}
147
+
148
+ // Always include fallback option
149
+ templates.push({
150
+ name: 'auto',
151
+ label: 'Auto (Fallback)',
152
+ description: templates.length > 0
153
+ ? 'Let ScreenCraft choose the best template automatically'
154
+ : 'Programmatic compositing — no PSD templates found yet',
155
+ badge: templates.length > 0 ? null : 'Built-in',
156
+ });
157
+
158
+ res.json({ templates });
159
+ });
160
+
161
+ // ── API: Select template ──────────────────────────────────────────
162
+ app.post('/api/template/select', (req, res) => {
163
+ session.selectedTemplate = req.body.template || null;
164
+ res.json({ ok: true });
165
+ });
166
+
167
+ // ── API: Screenshot init — check for manual folder or boot simulator ──
168
+ app.post('/api/screenshots/init', async (req, res) => {
169
+ if (!session.projectPath) {
170
+ return res.status(400).json({ error: 'Run detect first' });
171
+ }
172
+
173
+ const screenshotDir = path.join(session.outputDir, 'screenshots');
174
+ fs.mkdirSync(screenshotDir, { recursive: true });
175
+
176
+ // Mode 1: check for manual screenshots folder
177
+ const manualDir = path.join(session.projectPath, 'screenshots');
178
+ if (fs.existsSync(manualDir)) {
179
+ const files = fs.readdirSync(manualDir)
180
+ .filter(f => /\.(png|jpg|jpeg)$/i.test(f))
181
+ .sort()
182
+ .slice(0, 6);
183
+
184
+ if (files.length > 0) {
185
+ session.screenshots = files.map((f, i) => ({
186
+ index: i + 1,
187
+ file: path.join(manualDir, f),
188
+ label: null,
189
+ }));
190
+
191
+ const screenshotData = session.screenshots.map((s, i) => {
192
+ const buf = fs.readFileSync(s.file);
193
+ return {
194
+ index: s.index,
195
+ label: s.label,
196
+ filename: path.basename(s.file),
197
+ base64: `data:image/png;base64,${buf.toString('base64')}`,
198
+ };
199
+ });
200
+
201
+ return res.json({ mode: 'manual', screenshots: screenshotData });
202
+ }
203
+ }
204
+
205
+ // Mode 2: need simulator — boot if needed
206
+ if (!session.framework?.platform?.includes('ios') && !session.framework?.platform?.includes('android')) {
207
+ return res.json({
208
+ mode: 'none',
209
+ error: 'No screenshots/ folder found and no iOS/Android framework detected. Place PNGs in a screenshots/ folder in your project.',
210
+ });
211
+ }
212
+
213
+ try {
214
+ const { execSync } = require('child_process');
215
+
216
+ if (session.framework.platform.includes('ios')) {
217
+ // Check for already-booted simulator
218
+ let deviceUdid = null;
219
+ try {
220
+ const list = execSync('xcrun simctl list devices booted -j', { encoding: 'utf8' });
221
+ const json = JSON.parse(list);
222
+ for (const runtime of Object.values(json.devices)) {
223
+ for (const device of runtime) {
224
+ if (device.state === 'Booted') {
225
+ deviceUdid = device.udid;
226
+ session._simDevice = deviceUdid;
227
+ log.success(`Simulator running: ${device.name}`);
228
+ return res.json({ mode: 'simulator', platform: 'ios', device: device.name, ready: true });
229
+ }
230
+ }
231
+ }
232
+ } catch {}
233
+
234
+ // No simulator running — don't auto-boot (it won't have the app)
235
+ return res.json({
236
+ mode: 'waiting',
237
+ platform: 'ios',
238
+ message: 'No simulator running. Build and run your app in Xcode first (Cmd+R), then come back and click "Check Again".',
239
+ });
240
+ }
241
+
242
+ // Android
243
+ const devices = execSync('adb devices', { encoding: 'utf8' });
244
+ if (devices.includes('\tdevice')) {
245
+ session._simDevice = 'android';
246
+ return res.json({ mode: 'simulator', platform: 'android', ready: true });
247
+ }
248
+ return res.status(500).json({ error: 'No Android device/emulator found.' });
249
+ } catch (err) {
250
+ res.status(500).json({ error: err.message });
251
+ }
252
+ });
253
+
254
+ // ── API: Capture one screenshot from simulator ────────────────────
255
+ app.post('/api/screenshots/capture', async (req, res) => {
256
+ if (!session._simDevice) {
257
+ return res.status(400).json({ error: 'Run screenshots/init first' });
258
+ }
259
+
260
+ const screenshotDir = path.join(session.outputDir, 'screenshots');
261
+ const nextIndex = session.screenshots.length + 1;
262
+ if (nextIndex > 6) {
263
+ return res.status(400).json({ error: 'Maximum 6 screenshots' });
264
+ }
265
+
266
+ const outFile = path.join(screenshotDir, `screen_${String(nextIndex).padStart(2, '0')}_raw.png`);
267
+
268
+ try {
269
+ const { execSync } = require('child_process');
270
+ const platform = session.framework.platform;
271
+
272
+ // Small delay to let animations settle
273
+ await new Promise(r => setTimeout(r, 500));
274
+
275
+ if (platform.includes('ios')) {
276
+ execSync(`xcrun simctl io "${session._simDevice}" screenshot "${outFile}"`, { timeout: 10000 });
277
+ } else {
278
+ execSync(`adb exec-out screencap -p > "${outFile}"`, { timeout: 10000 });
279
+ }
280
+
281
+ if (!fs.existsSync(outFile) || fs.statSync(outFile).size === 0) {
282
+ throw new Error('Screenshot file is empty. The simulator may not be fully loaded yet.');
283
+ }
284
+
285
+ const buf = fs.readFileSync(outFile);
286
+ const screenshot = {
287
+ index: nextIndex,
288
+ file: outFile,
289
+ label: null,
290
+ };
291
+ session.screenshots.push(screenshot);
292
+
293
+ res.json({
294
+ index: nextIndex,
295
+ filename: path.basename(outFile),
296
+ base64: `data:image/png;base64,${buf.toString('base64')}`,
297
+ total: session.screenshots.length,
298
+ });
299
+ } catch (err) {
300
+ console.error('Capture error:', err);
301
+ res.status(500).json({ error: `Capture failed: ${err.message}` });
302
+ }
303
+ });
304
+
305
+ // ── API: Remove a screenshot ──────────────────────────────────────
306
+ app.post('/api/screenshots/remove', (req, res) => {
307
+ const { index } = req.body;
308
+ if (index < 0 || index >= session.screenshots.length) {
309
+ return res.status(400).json({ error: 'Invalid index' });
310
+ }
311
+ session.screenshots.splice(index, 1);
312
+ res.json({ ok: true, total: session.screenshots.length });
313
+ });
314
+
315
+ // ── API: Reorder screenshots ──────────────────────────────────────
316
+ app.post('/api/screenshots/reorder', (req, res) => {
317
+ const { order } = req.body; // array of indices, e.g. [2, 0, 1, 3]
318
+ if (!order || !session.screenshots.length) {
319
+ return res.status(400).json({ error: 'No screenshots to reorder' });
320
+ }
321
+ session.screenshots = order.map(i => session.screenshots[i]);
322
+ res.json({ ok: true });
323
+ });
324
+
325
+ // ── API: Generate headlines ───────────────────────────────────────
326
+ app.post('/api/headlines', async (req, res) => {
327
+ if (!session.screenshots.length || !session.brand) {
328
+ return res.status(400).json({ error: 'Run detect + screenshots first' });
329
+ }
330
+
331
+ try {
332
+ const configPath = path.join(session.projectPath, 'screencraft.config.js');
333
+ const config = fs.existsSync(configPath) ? require(configPath) : {};
334
+
335
+ const suggestions = await suggestHeadlines(
336
+ session.screenshots,
337
+ session.brand,
338
+ config
339
+ );
340
+
341
+ session.suggestions = suggestions;
342
+
343
+ res.json({ suggestions });
344
+ } catch (err) {
345
+ console.error('Headlines error:', err);
346
+ res.status(500).json({ error: err.message });
347
+ }
348
+ });
349
+
350
+ // ── API: Approve headlines ────────────────────────────────────────
351
+ app.post('/api/approve', (req, res) => {
352
+ const { approvedTexts } = req.body;
353
+ // Array of { white, accent } per screenshot
354
+ if (!approvedTexts || !approvedTexts.length) {
355
+ return res.status(400).json({ error: 'Provide approvedTexts array' });
356
+ }
357
+ session.approvedTexts = approvedTexts;
358
+ res.json({ ok: true });
359
+ });
360
+
361
+ // ── API: Check license ────────────────────────────────────────────
362
+ app.post('/api/license/check', async (req, res) => {
363
+ const key = req.body.key || getStoredKey();
364
+ if (!key) {
365
+ return res.json({ valid: false, tier: null, stored: false });
366
+ }
367
+
368
+ try {
369
+ const tier = await validateKey(key);
370
+ res.json({ valid: !!tier, tier, stored: !!getStoredKey() });
371
+ } catch (err) {
372
+ res.json({ valid: false, tier: null, error: err.message });
373
+ }
374
+ });
375
+
376
+ // ── API: Render ───────────────────────────────────────────────────
377
+ app.post('/api/render', async (req, res) => {
378
+ if (!session.approvedTexts.length || !session.screenshots.length) {
379
+ return res.status(400).json({ error: 'Approve headlines first' });
380
+ }
381
+
382
+ const screenshotsOnly = req.body.screenshotsOnly !== false;
383
+
384
+ // Start render in background, respond immediately
385
+ session.renderStatus = { phase: 'compositing', progress: 0, message: 'Starting...' };
386
+ res.json({ started: true });
387
+
388
+ try {
389
+ // Composite PSD/PNGs
390
+ const psdOutputs = [];
391
+ for (let i = 0; i < session.screenshots.length; i++) {
392
+ const screen = session.screenshots[i];
393
+ const text = session.approvedTexts[i];
394
+ const outPng = path.join(session.outputDir, 'screenshots', `screen_${String(i + 1).padStart(2, '0')}.png`);
395
+ const outPsd = path.join(session.outputDir, 'source', `screen_${String(i + 1).padStart(2, '0')}.psd`);
396
+
397
+ session.renderStatus = {
398
+ phase: 'compositing',
399
+ progress: Math.round((i / session.screenshots.length) * 50),
400
+ message: `Compositing screen ${i + 1} of ${session.screenshots.length}...`,
401
+ };
402
+
403
+ await compositePSD({
404
+ templateName: session.selectedTemplate === 'auto' ? null : session.selectedTemplate,
405
+ screenshotPath: screen.file,
406
+ headlineWhite: text.white,
407
+ headlineAccent: text.accent,
408
+ brand: session.brand,
409
+ font: session.brand.font || {},
410
+ outputPng: outPng,
411
+ outputPsd: outPsd,
412
+ writePsd: !screenshotsOnly,
413
+ });
414
+
415
+ psdOutputs.push({ png: outPng, psd: outPsd });
416
+ }
417
+
418
+ // Copy brand assets
419
+ const assetsDir = path.join(session.outputDir, 'brand');
420
+ fs.mkdirSync(assetsDir, { recursive: true });
421
+
422
+ const copyAsset = (src, destName) => {
423
+ try {
424
+ if (src && fs.existsSync(src)) {
425
+ fs.copyFileSync(src, path.join(assetsDir, destName));
426
+ }
427
+ } catch {}
428
+ };
429
+ copyAsset(session.brand.icon, `app-icon${path.extname(session.brand.icon || '.png')}`);
430
+ copyAsset(session.brand.logo, `logo${path.extname(session.brand.logo || '.png')}`);
431
+ if (session.brand.font?.file) copyAsset(session.brand.font.file, `font${path.extname(session.brand.font.file)}`);
432
+
433
+ session.renderStatus = { phase: 'compositing', progress: 50, message: 'Screenshots done' };
434
+
435
+ // Build outputs list
436
+ session.outputs = psdOutputs.map((o, i) => ({
437
+ type: 'screenshot',
438
+ index: i + 1,
439
+ png: o.png,
440
+ psd: o.psd,
441
+ }));
442
+
443
+ if (!screenshotsOnly) {
444
+ // AE project
445
+ session.renderStatus = { phase: 'ae-prep', progress: 60, message: 'Preparing After Effects project...' };
446
+
447
+ try {
448
+ const ae = await prepareAEProject({
449
+ brand: session.brand,
450
+ texts: session.approvedTexts,
451
+ screenshots: session.screenshots,
452
+ outputDir: session.outputDir,
453
+ log,
454
+ });
455
+
456
+ if (ae.aepPath) {
457
+ session.outputs.push({ type: 'aep', path: ae.aepPath });
458
+
459
+ if (ae.canRender) {
460
+ session.renderStatus = { phase: 'rendering', progress: 70, message: 'Rendering video...' };
461
+
462
+ try {
463
+ const videoPath = await renderLocally({
464
+ aepPath: ae.aepPath,
465
+ outputDir: session.outputDir,
466
+ onProgress: (step) => {
467
+ session.renderStatus.message = step;
468
+ },
469
+ });
470
+ session.outputs.push({ type: 'video', path: videoPath });
471
+ } catch (err) {
472
+ log.warn('Local render failed: ' + err.message);
473
+ session.outputs.push({ type: 'video-error', message: 'Open source/app-launch.aep in After Effects' });
474
+ }
475
+ }
476
+ }
477
+ } catch (err) {
478
+ log.warn('AE prep failed: ' + err.message);
479
+ }
480
+ }
481
+
482
+ session.renderStatus = { phase: 'done', progress: 100, message: 'Complete' };
483
+ } catch (err) {
484
+ console.error('Render error:', err);
485
+ session.renderStatus = { phase: 'error', progress: 0, message: err.message };
486
+ }
487
+ });
488
+
489
+ // ── API: Render status (polling) ──────────────────────────────────
490
+ app.get('/api/render/status', (req, res) => {
491
+ res.json(session.renderStatus);
492
+ });
493
+
494
+ // ── API: Output files ─────────────────────────────────────────────
495
+ app.get('/api/output', (req, res) => {
496
+ const files = session.outputs.map(o => {
497
+ if (o.type === 'screenshot' && o.png && fs.existsSync(o.png)) {
498
+ return {
499
+ type: 'screenshot',
500
+ index: o.index,
501
+ filename: path.basename(o.png),
502
+ url: `/api/output/file/${path.basename(o.png)}`,
503
+ hasPsd: o.psd && fs.existsSync(o.psd),
504
+ psdUrl: o.psd ? `/api/output/file/${path.basename(o.psd)}` : null,
505
+ };
506
+ }
507
+ if (o.type === 'video' && o.path && fs.existsSync(o.path)) {
508
+ return {
509
+ type: 'video',
510
+ filename: path.basename(o.path),
511
+ url: `/api/output/file/${path.basename(o.path)}`,
512
+ };
513
+ }
514
+ if (o.type === 'aep') {
515
+ return { type: 'aep', filename: 'app-launch.aep' };
516
+ }
517
+ if (o.type === 'video-error') {
518
+ return { type: 'video-error', message: o.message };
519
+ }
520
+ return null;
521
+ }).filter(Boolean);
522
+
523
+ res.json({ files, outputDir: session.outputDir });
524
+ });
525
+
526
+ // ── API: Serve output files ───────────────────────────────────────
527
+ app.get('/api/output/file/:filename', (req, res) => {
528
+ if (!session.outputDir) return res.status(404).send('No output');
529
+ const filename = req.params.filename;
530
+
531
+ // Search in screenshots/, source/, video/
532
+ const dirs = ['screenshots', 'source', 'video', 'brand'];
533
+ for (const dir of dirs) {
534
+ const fp = path.join(session.outputDir, dir, filename);
535
+ if (fs.existsSync(fp)) {
536
+ return res.sendFile(fp);
537
+ }
538
+ }
539
+ res.status(404).send('File not found');
540
+ });
541
+
542
+ // ── API: Open output folder ───────────────────────────────────────
543
+ app.post('/api/output/open', (req, res) => {
544
+ if (!session.outputDir) return res.status(400).json({ error: 'No output yet' });
545
+ const { exec } = require('child_process');
546
+ exec(`open "${session.outputDir}"`);
547
+ res.json({ ok: true });
548
+ });
549
+
550
+ // ── API: Session bootstrap (for MCP pre-populated data) ───────────
551
+ app.get('/api/session', (req, res) => {
552
+ // Returns what MCP has already populated so frontend can skip ahead
553
+ const data = {
554
+ projectPath: session.projectPath,
555
+ skipToStep: session._skipToStep || null,
556
+ hasBrand: !!session.brand,
557
+ hasScreenshots: session.screenshots.length > 0,
558
+ hasHeadlines: session.approvedTexts.length > 0,
559
+ suggestedScreens: session._suggestedScreens || null,
560
+ };
561
+
562
+ if (session.brand) {
563
+ let iconBase64 = null;
564
+ if (session.brand.icon && fs.existsSync(session.brand.icon)) {
565
+ const ext = path.extname(session.brand.icon).slice(1) || 'png';
566
+ const buf = fs.readFileSync(session.brand.icon);
567
+ iconBase64 = `data:image/${ext};base64,${buf.toString('base64')}`;
568
+ }
569
+ data.brand = {
570
+ appName: session.brand.appName,
571
+ primary: session.brand.primary,
572
+ secondary: session.brand.secondary,
573
+ accent: session.brand.accent,
574
+ background: session.brand.background,
575
+ icon: iconBase64,
576
+ font: session.brand.font,
577
+ logo: session.brand.logo ? true : false,
578
+ };
579
+ data.framework = session.framework ? {
580
+ name: session.framework.name,
581
+ platform: session.framework.platform,
582
+ } : null;
583
+ }
584
+
585
+ if (session.screenshots.length > 0) {
586
+ data.screenshots = session.screenshots.map((s, i) => {
587
+ let base64 = null;
588
+ if (s.file && fs.existsSync(s.file)) {
589
+ const buf = fs.readFileSync(s.file);
590
+ base64 = `data:image/png;base64,${buf.toString('base64')}`;
591
+ }
592
+ return {
593
+ index: s.index || i + 1,
594
+ label: s.label,
595
+ filename: path.basename(s.file),
596
+ base64,
597
+ };
598
+ });
599
+ }
600
+
601
+ if (session.approvedTexts.length > 0) {
602
+ data.headlines = session.approvedTexts;
603
+ data.suggestions = session.suggestions;
604
+ }
605
+
606
+ res.json(data);
607
+ });
608
+
609
+ // ── SPA fallback ──────────────────────────────────────────────────
610
+ app.get('/{*path}', (req, res) => {
611
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
612
+ });
613
+
614
+ // ── Start ─────────────────────────────────────────────────────────
615
+ function startServer() {
616
+ app.listen(PORT, () => {
617
+ console.log('');
618
+ console.log(` ◆ ScreenCraft UI → http://localhost:${PORT}`);
619
+ console.log('');
620
+ });
621
+
622
+ // Auto-open browser
623
+ const { exec } = require('child_process');
624
+ exec(`open http://localhost:${PORT}`);
625
+ }
626
+
627
+ module.exports = { app, startServer };