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,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 };
|