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.
- package/.claude/settings.local.json +30 -0
- package/.env.example +3 -0
- package/MCP_README.md +200 -0
- package/README.md +148 -0
- package/bin/screencraft.js +61 -0
- package/package.json +31 -0
- package/src/auth/keystore.js +148 -0
- package/src/commands/init.js +119 -0
- package/src/commands/launch.js +405 -0
- package/src/detectors/detectBrand.js +1222 -0
- package/src/detectors/simulator.js +317 -0
- package/src/generators/analyzeStyleReference.js +471 -0
- package/src/generators/compositePSD.js +682 -0
- package/src/generators/copy.js +147 -0
- package/src/mcp/index.js +394 -0
- package/src/pipeline/aeSwap.js +369 -0
- package/src/pipeline/download.js +32 -0
- package/src/pipeline/queue.js +101 -0
- package/src/server/index.js +627 -0
- package/src/server/public/app.js +738 -0
- package/src/server/public/index.html +255 -0
- package/src/server/public/style.css +751 -0
- package/src/server/session.js +36 -0
- package/templates/ae/(Footage)/Assets/This Hip-Hop Upbeat (Short version).wav +0 -0
- package/templates/ae/(Footage)/Assets/screen_01_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_02_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_03_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_04_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_05_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_06_raw.png +0 -0
- package/templates/ae/Motion Forge Test 1.0 (converted).aep +0 -0
- package/templates/ae_swap.jsx +284 -0
- package/templates/layouts/minimal.psd +0 -0
- package/templates/screencraft.config.example.js +165 -0
- package/test/output/layout_test.png +0 -0
- package/test/output/style_profile.json +64 -0
- package/test/reference.png +0 -0
- package/test/test_brand.js +69 -0
- package/test/test_psd.js +83 -0
- 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 };
|