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,369 @@
1
+ /**
2
+ * src/pipeline/aeSwap.js
3
+ * ----------------------
4
+ * Prepares an After Effects project for rendering by:
5
+ *
6
+ * 1. Copying the AE template + footage folder to the output directory
7
+ * 2. Replacing screenshot footage files with the user's actual screenshots
8
+ * 3. Writing a render_manifest.json with brand/text/screenshot data
9
+ * 4. Copying ae_swap.jsx into the project folder
10
+ * 5. Optionally running aerender to produce the final video
11
+ *
12
+ * The ae_swap.jsx ExtendScript reads the manifest and swaps all
13
+ * [SWAP_*] layers (colors, text, screenshots, images) when AE opens.
14
+ */
15
+
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+ const { execSync, exec } = require('child_process');
19
+
20
+ const TEMPLATES_DIR = path.join(__dirname, '../../templates');
21
+
22
+ /**
23
+ * prepareAEProject(opts)
24
+ *
25
+ * @param {Object} opts
26
+ * @param {string} opts.templatePath — path to the AE template .aep (or auto-detect)
27
+ * @param {Object} opts.brand — brand object from detectBrand
28
+ * @param {Array} opts.texts — array of { white, accent } per screen
29
+ * @param {Array} opts.screenshots — array of { file } paths to raw screenshots
30
+ * @param {string} opts.outputDir — launch-kit output directory
31
+ * @param {Object} opts.log — { info, success, warn } log helpers
32
+ *
33
+ * @returns {Object} { aepPath, manifestPath, canRender }
34
+ */
35
+ async function prepareAEProject(opts) {
36
+ const {
37
+ templatePath = null,
38
+ brand = {},
39
+ texts = [],
40
+ screenshots = [],
41
+ outputDir,
42
+ log,
43
+ } = opts;
44
+
45
+ const sourceDir = path.join(outputDir, 'source');
46
+ fs.mkdirSync(sourceDir, { recursive: true });
47
+
48
+ // ── 1. Find the AE template ────────────────────────────────────
49
+ const aepTemplate = templatePath || findAETemplate();
50
+
51
+ if (!aepTemplate) {
52
+ if (log) log.warn('No AE template found in templates/. Skipping video generation.');
53
+ return { aepPath: null, manifestPath: null, canRender: false };
54
+ }
55
+
56
+ // ── 2. Copy AE project to output ──────────────────────────────
57
+ const aepName = 'app-launch.aep';
58
+ const aepDest = path.join(sourceDir, aepName);
59
+
60
+ // Copy the AEP file
61
+ fs.copyFileSync(aepTemplate, aepDest);
62
+
63
+ // Copy the footage folder structure
64
+ const templateDir = path.dirname(aepTemplate);
65
+ const footageDir = findFootageDir(templateDir);
66
+
67
+ if (footageDir) {
68
+ const destFootageDir = path.join(sourceDir, path.basename(footageDir));
69
+ copyDirSync(footageDir, destFootageDir);
70
+ }
71
+
72
+ // ── 3. Replace screenshot footage files ───────────────────────
73
+ // The AE template references screen_01_raw.png, screen_02_raw.png, etc.
74
+ // We replace those files with the user's actual screenshots.
75
+ const assetsDir = findAssetsDir(sourceDir);
76
+
77
+ if (assetsDir) {
78
+ for (let i = 0; i < screenshots.length; i++) {
79
+ const srcFile = screenshots[i].file || screenshots[i].png;
80
+ if (!srcFile || !fs.existsSync(srcFile)) continue;
81
+
82
+ const destFile = path.join(assetsDir, `screen_${String(i + 1).padStart(2, '0')}_raw.png`);
83
+ fs.copyFileSync(srcFile, destFile);
84
+ }
85
+ }
86
+
87
+ // ── 4. Write render manifest ──────────────────────────────────
88
+ const manifest = {
89
+ brand: {
90
+ appName: brand.appName || null,
91
+ primary: brand.primary || '#1B2A4A',
92
+ secondary: brand.secondary || '#435B98',
93
+ accent: brand.accent || '#9B7FD4',
94
+ background: brand.background || '#1B2A4A',
95
+ icon: brand.icon || null,
96
+ logo: brand.logo || null,
97
+ font: brand.font || null,
98
+ tagline: brand.tagline || null,
99
+ },
100
+ texts: texts.map(t => ({
101
+ white: t.white || '',
102
+ accent: t.accent || '',
103
+ })),
104
+ screenshots: screenshots.map((s, i) => ({
105
+ file: `screen_${String(i + 1).padStart(2, '0')}_raw.png`,
106
+ })),
107
+ template: 'app_launch_vertical_v1',
108
+ timestamp: new Date().toISOString(),
109
+ };
110
+
111
+ const manifestPath = path.join(sourceDir, 'render_manifest.json');
112
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
113
+
114
+ // ── 5. Copy ae_swap.jsx into the project folder ───────────────
115
+ const swapScript = path.join(TEMPLATES_DIR, 'ae_swap.jsx');
116
+ if (fs.existsSync(swapScript)) {
117
+ fs.copyFileSync(swapScript, path.join(sourceDir, 'ae_swap.jsx'));
118
+ }
119
+
120
+ // ── 6. Check if aerender is available ─────────────────────────
121
+ const canRender = isAERenderAvailable();
122
+
123
+ if (log) {
124
+ log.success(`AE project prepared: ${aepName}`);
125
+ log.info(`Manifest: render_manifest.json (${texts.length} screens)`);
126
+ if (canRender) {
127
+ log.info('aerender found — can render locally');
128
+ } else {
129
+ log.info('aerender not found — use AE manually or submit to render server');
130
+ }
131
+ }
132
+
133
+ return { aepPath: aepDest, manifestPath, canRender };
134
+ }
135
+
136
+ /**
137
+ * renderLocally(opts)
138
+ *
139
+ * Runs aerender to produce the video. Only works if AE is installed.
140
+ *
141
+ * @param {string} opts.aepPath — path to the prepared .aep file
142
+ * @param {string} opts.outputDir — where to save the rendered video
143
+ * @param {string} opts.comp — comp name to render (default: auto-detect MASTER_*)
144
+ * @param {Function} opts.onProgress — callback for progress updates
145
+ *
146
+ * @returns {string} path to rendered video file
147
+ */
148
+ async function renderLocally(opts) {
149
+ const {
150
+ aepPath,
151
+ outputDir,
152
+ comp = null,
153
+ onProgress,
154
+ } = opts;
155
+
156
+ const outputFile = path.join(outputDir, 'video', 'preview.mp4');
157
+ fs.mkdirSync(path.join(outputDir, 'video'), { recursive: true });
158
+
159
+ // First run the swap script
160
+ const swapScript = path.join(path.dirname(aepPath), 'ae_swap.jsx');
161
+ const aerenderPath = findAERender();
162
+
163
+ if (!aerenderPath) {
164
+ throw new Error('aerender not found. Install After Effects or use the render server.');
165
+ }
166
+
167
+ // Step 1: Run the swap script via AppleScript (opens AE, runs JSX, saves, quits)
168
+ // Note: aerender's -s flag causes timeSpanStart errors on many projects,
169
+ // so we use AppleScript to run the swap script in the AE GUI instead.
170
+ if (fs.existsSync(swapScript)) {
171
+ if (onProgress) onProgress('Running swap script in After Effects...');
172
+ try {
173
+ execSync(
174
+ `osascript` +
175
+ ` -e 'with timeout of 120 seconds'` +
176
+ ` -e 'tell application "Adobe After Effects 2026"'` +
177
+ ` -e 'activate'` +
178
+ ` -e 'open file (POSIX file "${aepPath}" as string)'` +
179
+ ` -e 'delay 10'` +
180
+ ` -e 'DoScriptFile "${swapScript}"'` +
181
+ ` -e 'delay 3'` +
182
+ ` -e 'DoScript "app.project.save();"'` +
183
+ ` -e 'delay 2'` +
184
+ ` -e 'DoScript "app.quit();"'` +
185
+ ` -e 'end tell'` +
186
+ ` -e 'end timeout'`,
187
+ { timeout: 180000, stdio: 'pipe' }
188
+ );
189
+ // Wait for AE to fully quit before aerender starts
190
+ await new Promise(r => setTimeout(r, 5000));
191
+ } catch (err) {
192
+ // Swap script errors are non-fatal — render with whatever state we have
193
+ }
194
+ }
195
+
196
+ // Step 2: Build aerender command for the actual render
197
+ const args = [
198
+ '-project', `"${aepPath}"`,
199
+ '-output', `"${outputFile}"`,
200
+ '-RStemplate', '"Best Settings"',
201
+ '-v', 'ERRORS_AND_PROGRESS',
202
+ '-continueOnMissingFootage',
203
+ ];
204
+
205
+ if (comp) {
206
+ args.push('-comp', `"${comp}"`);
207
+ }
208
+
209
+ const cmd = `"${aerenderPath}" ${args.join(' ')}`;
210
+
211
+ return new Promise((resolve, reject) => {
212
+ if (onProgress) onProgress('Starting After Effects render...');
213
+
214
+ const proc = exec(cmd, { timeout: 300000 }); // 5 min timeout
215
+
216
+ proc.stdout.on('data', (data) => {
217
+ const line = data.toString().trim();
218
+ if (onProgress && line.length > 0) {
219
+ // Parse AE progress output
220
+ const frameMatch = line.match(/PROGRESS:\s+(.+)/);
221
+ if (frameMatch) {
222
+ onProgress(frameMatch[1]);
223
+ }
224
+ }
225
+ });
226
+
227
+ proc.stderr.on('data', (data) => {
228
+ // AE logs to stderr sometimes
229
+ });
230
+
231
+ proc.on('close', (code) => {
232
+ if (code === 0 && fs.existsSync(outputFile)) {
233
+ resolve(outputFile);
234
+ } else {
235
+ reject(new Error(`aerender exited with code ${code}`));
236
+ }
237
+ });
238
+
239
+ proc.on('error', (err) => {
240
+ reject(err);
241
+ });
242
+ });
243
+ }
244
+
245
+ module.exports = { prepareAEProject, renderLocally };
246
+
247
+
248
+ // ── Helpers ──────────────────────────────────────────────────────
249
+
250
+ /**
251
+ * Find the AE template in the templates directory.
252
+ * Looks for .aep files in templates/ or templates/ae/
253
+ */
254
+ function findAETemplate() {
255
+ const searchDirs = [
256
+ TEMPLATES_DIR,
257
+ path.join(TEMPLATES_DIR, 'ae'),
258
+ ];
259
+
260
+ for (const dir of searchDirs) {
261
+ if (!fs.existsSync(dir)) continue;
262
+ try {
263
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
264
+ if (entry.name.endsWith('.aep')) {
265
+ return path.join(dir, entry.name);
266
+ }
267
+ // Check inside subdirectories
268
+ if (entry.isDirectory()) {
269
+ const subDir = path.join(dir, entry.name);
270
+ for (const sub of fs.readdirSync(subDir)) {
271
+ if (sub.endsWith('.aep')) {
272
+ return path.join(subDir, sub);
273
+ }
274
+ }
275
+ }
276
+ }
277
+ } catch {}
278
+ }
279
+
280
+ return null;
281
+ }
282
+
283
+ /**
284
+ * Find the (Footage) directory inside a template folder.
285
+ */
286
+ function findFootageDir(dir) {
287
+ try {
288
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
289
+ if (entry.isDirectory() && entry.name.includes('Footage')) {
290
+ return path.join(dir, entry.name);
291
+ }
292
+ }
293
+ } catch {}
294
+ return null;
295
+ }
296
+
297
+ /**
298
+ * Find the assets directory inside the output source folder.
299
+ * Walks into (Footage)/Assets/ or similar.
300
+ */
301
+ function findAssetsDir(sourceDir) {
302
+ const candidates = [
303
+ path.join(sourceDir, '(Footage)', 'Assets'),
304
+ path.join(sourceDir, 'Footage', 'Assets'),
305
+ path.join(sourceDir, '(Footage)'),
306
+ path.join(sourceDir, 'Assets'),
307
+ ];
308
+
309
+ for (const dir of candidates) {
310
+ if (fs.existsSync(dir)) return dir;
311
+ }
312
+
313
+ // Create a default
314
+ const defaultDir = path.join(sourceDir, '(Footage)', 'Assets');
315
+ fs.mkdirSync(defaultDir, { recursive: true });
316
+ return defaultDir;
317
+ }
318
+
319
+ /**
320
+ * Check if aerender is available on this system.
321
+ */
322
+ function isAERenderAvailable() {
323
+ return !!findAERender();
324
+ }
325
+
326
+ /**
327
+ * Find the aerender executable.
328
+ */
329
+ function findAERender() {
330
+ // macOS paths
331
+ const macPaths = [
332
+ '/Applications/Adobe After Effects 2025/aerender',
333
+ '/Applications/Adobe After Effects 2024/aerender',
334
+ '/Applications/Adobe After Effects 2026/aerender',
335
+ '/Applications/Adobe After Effects CC 2024/aerender',
336
+ ];
337
+
338
+ for (const p of macPaths) {
339
+ if (fs.existsSync(p)) return p;
340
+ }
341
+
342
+ // Try which/where
343
+ try {
344
+ const result = execSync('which aerender 2>/dev/null', { encoding: 'utf8' }).trim();
345
+ if (result) return result;
346
+ } catch {}
347
+
348
+ return null;
349
+ }
350
+
351
+ /**
352
+ * Recursively copy a directory.
353
+ */
354
+ function copyDirSync(src, dest) {
355
+ fs.mkdirSync(dest, { recursive: true });
356
+
357
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
358
+ const srcPath = path.join(src, entry.name);
359
+ const destPath = path.join(dest, entry.name);
360
+
361
+ if (entry.name.startsWith('.')) continue; // skip .DS_Store etc.
362
+
363
+ if (entry.isDirectory()) {
364
+ copyDirSync(srcPath, destPath);
365
+ } else {
366
+ fs.copyFileSync(srcPath, destPath);
367
+ }
368
+ }
369
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * src/pipeline/download.js
3
+ * Fetches the completed render bundle (ZIP) and unpacks it.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const https = require('https');
9
+
10
+ const API_BASE = process.env.SCREENCRAFT_API || 'https://api.screencraft.dev';
11
+
12
+ async function downloadBundle(jobId, outputDir) {
13
+ if (jobId.startsWith('dev-')) return; // Dev mode
14
+
15
+ const zipPath = path.join(outputDir, '_bundle.zip');
16
+
17
+ // Download ZIP
18
+ await new Promise((resolve, reject) => {
19
+ const file = fs.createWriteStream(zipPath);
20
+ https.get(`${API_BASE}/v1/render/${jobId}/download`, res => {
21
+ res.pipe(file);
22
+ file.on('finish', () => { file.close(); resolve(); });
23
+ }).on('error', reject);
24
+ });
25
+
26
+ // Unpack — requires unzipper or archiver
27
+ // For now: leave the zip and note it's there
28
+ // In production: unzip to outputDir and delete the zip
29
+ return zipPath;
30
+ }
31
+
32
+ module.exports = { downloadBundle };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * src/pipeline/queue.js
3
+ * ---------------------
4
+ * Submits a render job to the ScreenCraft render server.
5
+ * The render server runs After Effects via aerender on a Mac.
6
+ *
7
+ * In Phase 1 (manual rendering), this just packages the job
8
+ * and logs instructions for you to render manually.
9
+ *
10
+ * In Phase 2 (automated), it POSTs to your render API and polls.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const chalk = require('chalk');
16
+
17
+ const API_BASE = process.env.SCREENCRAFT_API || 'https://api.screencraft.dev';
18
+
19
+ /**
20
+ * Submit a render job.
21
+ * Returns a jobId string.
22
+ */
23
+ async function submitRenderJob({ brand, screenshots, texts, config }) {
24
+ const isDev = process.env.NODE_ENV === 'development' || process.env.SCREENCRAFT_DEV;
25
+
26
+ if (isDev) {
27
+ // In dev mode: write a render manifest locally instead of POSTing
28
+ const manifest = {
29
+ brand,
30
+ texts,
31
+ template: 'app_launch_vertical_v1',
32
+ duration: config.video?.duration || 30,
33
+ music: config.video?.music || 'upbeat',
34
+ createdAt: new Date().toISOString(),
35
+ screenshots: screenshots.map(s => ({ png: s.png })),
36
+ };
37
+
38
+ const manifestPath = path.join(process.cwd(), 'launch-kit', 'render_manifest.json');
39
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
40
+
41
+ console.log('');
42
+ console.log(chalk.hex('#C9A84C')(' DEV MODE: Render manifest written to launch-kit/render_manifest.json'));
43
+ console.log(chalk.dim(' To render manually:'));
44
+ console.log(chalk.dim(' 1. Open source/app-launch.aep in After Effects'));
45
+ console.log(chalk.dim(' 2. Run the swap script: File → Scripts → Run Script → ae_swap.jsx'));
46
+ console.log(chalk.dim(' 3. Add to render queue → render'));
47
+ console.log('');
48
+
49
+ return 'dev-' + Date.now();
50
+ }
51
+
52
+ // Production: POST to render API
53
+ const res = await fetch(`${API_BASE}/v1/render`, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({
57
+ brand,
58
+ texts,
59
+ template: 'app_launch_vertical_v1',
60
+ duration: config.video?.duration || 30,
61
+ music: config.video?.music || 'upbeat',
62
+ screenshots: screenshots.map(s => ({ png: s.png })),
63
+ }),
64
+ });
65
+
66
+ if (!res.ok) throw new Error(`Render API error: ${res.status}`);
67
+ const data = await res.json();
68
+ return data.jobId;
69
+ }
70
+
71
+ /**
72
+ * Poll for job completion.
73
+ * Calls onProgress(stepLabel) as the job advances.
74
+ */
75
+ async function pollJob(jobId, { onProgress } = {}) {
76
+ if (jobId.startsWith('dev-')) return; // Dev mode — skip polling
77
+
78
+ const maxWait = 180_000; // 3 minutes
79
+ const interval = 3_000;
80
+ const start = Date.now();
81
+
82
+ while (Date.now() - start < maxWait) {
83
+ await sleep(interval);
84
+
85
+ const res = await fetch(`${API_BASE}/v1/render/${jobId}/status`);
86
+ const data = await res.json();
87
+
88
+ if (onProgress) onProgress(data.step || 'Rendering...');
89
+
90
+ if (data.status === 'complete') return data;
91
+ if (data.status === 'error') throw new Error(data.message || 'Render failed');
92
+ }
93
+
94
+ throw new Error('Render timed out after 3 minutes');
95
+ }
96
+
97
+ function sleep(ms) {
98
+ return new Promise(resolve => setTimeout(resolve, ms));
99
+ }
100
+
101
+ module.exports = { submitRenderJob, pollJob };