const fs = require('fs'); const fsp = fs.promises; const http = require('http'); const path = require('path'); const { spawn } = require('child_process'); const ROOT = process.cwd(); const RENDERER_PATH = path.join(ROOT, 'scripts', 'social-video-renderer.html'); const OUTPUT_DIR = path.join(ROOT, 'generated', 'social-videos'); const EDGE_DEBUG_PORT = Number(process.env.EDGE_DEBUG_PORT || '9222'); const videos = [ { outputName: 'greenlens-yellow-leaves.webm', accentA: '#143625', accentB: '#3f855f', scenes: [ { image: '/greenlns-landing/public/unhealthy-plant.png', badge: 'Plant Rescue', title: 'Why Are My Plant Leaves Turning Yellow?', subtitle: 'Start with a scan instead of another random guess.', cta: 'Save this for your next plant emergency', durationMs: 2600, }, { image: '/greenlns-landing/public/scan-feature.png', badge: 'GreenLens', title: 'Scan The Plant In Seconds', subtitle: 'Get the plant name and a faster clue about what is going wrong.', cta: 'Open the app and scan', durationMs: 2600, }, { image: '/greenlns-landing/public/ai-analysis.png', badge: 'Care Help', title: 'See The Likely Care Issue', subtitle: 'Less guessing. Faster fixes. Better plant care.', cta: 'Plant ID plus care guidance', durationMs: 2600, }, { image: '/greenlns-landing/public/plant-collection.png', badge: 'Result', title: 'Give Your Plant A Better Recovery Plan', subtitle: 'GreenLens helps you move from panic to action.', cta: 'GreenLens', durationMs: 2400, }, ], }, { outputName: 'greenlens-mystery-plant.webm', accentA: '#18392b', accentB: '#5ba174', scenes: [ { image: '/greenlns-landing/public/hero-plant.png', badge: 'Mystery Plant', title: 'I Had This Plant For Months', subtitle: 'And I still did not know what it was.', cta: 'No more mystery plants', durationMs: 2600, }, { image: '/greenlns-landing/public/scan-feature.png', badge: 'Scan', title: 'Point. Scan. Identify.', subtitle: 'Get the plant name in a few seconds with GreenLens.', cta: 'Tap to scan', durationMs: 2500, }, { image: '/greenlns-landing/public/ai-analysis.png', badge: 'Know More', title: 'See The Species And Care Basics', subtitle: 'That means less overwatering and fewer avoidable mistakes.', cta: 'Plant ID and care basics', durationMs: 2600, }, { image: '/greenlns-landing/public/track-feature.png', badge: 'Next Step', title: 'Now You Can Actually Care For It Right', subtitle: 'Knowing the name changes everything.', cta: 'Comment for more plant scans', durationMs: 2300, }, ], }, { outputName: 'greenlens-plant-routine.webm', accentA: '#102d21', accentB: '#4f916d', scenes: [ { image: '/greenlns-landing/public/plant-collection.png', badge: 'POV', title: 'You Love Plants But Forget Their Care Routine', subtitle: 'Too many plants. Too many watering schedules.', cta: 'Plant parent problems', durationMs: 2700, }, { image: '/greenlns-landing/public/track-feature.png', badge: 'Tracking', title: 'Keep Care Details In One Place', subtitle: 'Watering, light needs, and reminders without mental overload.', cta: 'Track care with GreenLens', durationMs: 2600, }, { image: '/greenlns-landing/public/scan-feature.png', badge: 'Simple Start', title: 'Scan First Then Build The Routine', subtitle: 'A better system starts with a better plant ID.', cta: 'Scan and track', durationMs: 2500, }, { image: '/greenlns-landing/public/hero-plant.png', badge: 'Calmer Care', title: 'Healthy Plants Need Fewer Guesswork Decisions', subtitle: 'GreenLens makes plant care easier to manage.', cta: 'Follow for more plant shortcuts', durationMs: 2400, }, ], }, { outputName: 'greenlens-sick-plant-rescue.webm', accentA: '#1e2c18', accentB: '#63844d', scenes: [ { image: '/greenlns-landing/public/unhealthy-plant.png', badge: 'Rescue', title: 'This Plant Looked Like It Was Dying', subtitle: 'The first move was not more water. It was a diagnosis.', cta: 'Stop guessing first', durationMs: 2700, }, { image: '/greenlns-landing/public/scan-feature.png', badge: 'Step 1', title: 'Scan The Problem Plant', subtitle: 'Use GreenLens to identify what you are actually dealing with.', cta: 'Start with a scan', durationMs: 2500, }, { image: '/greenlns-landing/public/ai-analysis.png', badge: 'Step 2', title: 'Get Care Guidance Faster', subtitle: 'Know whether the issue points to watering, light, or something else.', cta: 'Faster plant triage', durationMs: 2600, }, { image: '/greenlns-landing/public/plant-collection.png', badge: 'Step 3', title: 'Turn Panic Into A Care Plan', subtitle: 'A stressed plant needs informed action, not random fixes.', cta: 'Send this to a plant parent', durationMs: 2400, }, ], }, { outputName: 'greenlens-fast-demo.webm', accentA: '#183724', accentB: '#56a074', scenes: [ { image: '/greenlns-landing/public/scan-feature.png', badge: 'Fast Demo', title: 'Scan Any Plant In Seconds', subtitle: 'GreenLens turns a quick camera scan into plant insight.', cta: 'Simple plant ID', durationMs: 2500, }, { image: '/greenlns-landing/public/ai-analysis.png', badge: 'AI', title: 'See The Name And Care Guidance', subtitle: 'Get plant details without digging through search results.', cta: 'Plant ID plus help', durationMs: 2600, }, { image: '/greenlns-landing/public/track-feature.png', badge: 'Routine', title: 'Track Care After The Scan', subtitle: 'Keep watering and plant health details organized.', cta: 'Track what matters', durationMs: 2400, }, { image: '/greenlns-landing/public/hero-plant.png', badge: 'GreenLens', title: 'Plant ID And Care Help In One App', subtitle: 'Use it on your next mystery plant.', cta: 'GreenLens', durationMs: 2400, }, ], }, ]; async function ensureDir(dir) { await fsp.mkdir(dir, { recursive: true }); } function contentType(filePath) { const ext = path.extname(filePath).toLowerCase(); if (ext === '.html') return 'text/html; charset=utf-8'; if (ext === '.png') return 'image/png'; if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; if (ext === '.webm') return 'video/webm'; if (ext === '.mp4') return 'video/mp4'; if (ext === '.svg') return 'image/svg+xml'; return 'application/octet-stream'; } async function startServer() { await ensureDir(OUTPUT_DIR); const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, 'http://127.0.0.1'); if (req.method === 'POST' && url.pathname === '/upload') { const name = path.basename(url.searchParams.get('name') || 'output.webm'); const target = path.join(OUTPUT_DIR, name); const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', async () => { await fsp.writeFile(target, Buffer.concat(chunks)); res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end(target); }); return; } let filePath; if (url.pathname === '/renderer') { filePath = RENDERER_PATH; } else { filePath = path.join(ROOT, decodeURIComponent(url.pathname.replace(/^\/+/, ''))); } const normalized = path.normalize(filePath); if (!normalized.startsWith(ROOT)) { res.writeHead(403); res.end('Forbidden'); return; } const data = await fsp.readFile(normalized); res.writeHead(200, { 'Content-Type': contentType(normalized) }); res.end(data); } catch (error) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end(String(error)); } }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); return { server, port: address.port }; } async function fetchJson(url, retries = 60) { let lastError; for (let attempt = 0; attempt < retries; attempt += 1) { try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response.json(); } catch (error) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 250)); } } throw lastError; } async function waitForPageReady(sessionSend, retries = 80) { for (let attempt = 0; attempt < retries; attempt += 1) { const result = await sessionSend('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true, }).catch(() => null); if (result && result.result && result.result.value === 'complete') { return; } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error('Timed out waiting for renderer page readiness'); } function runCommand(command, timeoutMs = 30000) { return new Promise((resolve, reject) => { const child = spawn( 'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', ['-NoProfile', '-Command', command], { stdio: ['ignore', 'pipe', 'pipe'] } ); let stdout = ''; let stderr = ''; const timeout = setTimeout(() => { child.kill(); reject(new Error(`Command timed out: ${command}`)); }, timeoutMs); child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); child.on('exit', (code) => { clearTimeout(timeout); if (code === 0) { resolve(stdout.trim()); } else { reject(new Error(stderr || stdout || `Command failed with code ${code}`)); } }); }); } async function startEdge(profileDir) { const escapedProfile = profileDir.replace(/'/g, "''"); const command = [ "$p = Start-Process -FilePath 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'", `-ArgumentList @('--headless','--disable-gpu','--no-sandbox','--disable-crash-reporter','--disable-breakpad','--noerrdialogs','--autoplay-policy=no-user-gesture-required','--mute-audio','--hide-scrollbars','--remote-debugging-port=${EDGE_DEBUG_PORT}',`, `'--user-data-dir=${escapedProfile}','about:blank')`, '-PassThru;', 'Write-Output $p.Id', ].join(' '); const pidText = await runCommand(command, 30000); return Number(pidText); } async function stopEdge(pid) { if (!pid) return; await runCommand(`Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`, 15000).catch(() => {}); } class CDPClient { constructor(wsUrl) { this.ws = new WebSocket(wsUrl); this.id = 0; this.pending = new Map(); this.events = []; this.waiters = []; } async connect() { await new Promise((resolve, reject) => { this.ws.addEventListener('open', resolve, { once: true }); this.ws.addEventListener('error', reject, { once: true }); }); this.ws.addEventListener('message', (event) => { const message = JSON.parse(event.data); if (message.id) { const pending = this.pending.get(message.id); if (pending) { this.pending.delete(message.id); if (message.error) { pending.reject(new Error(message.error.message)); } else { pending.resolve(message.result); } } return; } this.events.push(message); this.waiters = this.waiters.filter((waiter) => { if (waiter.predicate(message)) { waiter.resolve(message); return false; } return true; }); }); } send(method, params = {}, sessionId) { const id = ++this.id; const message = sessionId ? { id, method, params, sessionId } : { id, method, params }; this.ws.send(JSON.stringify(message)); return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); }); } waitFor(predicate, timeoutMs = 30000) { const existing = this.events.find(predicate); if (existing) { return Promise.resolve(existing); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.waiters = this.waiters.filter((waiter) => waiter.resolve !== resolve); reject(new Error('Timed out waiting for event')); }, timeoutMs); this.waiters.push({ predicate, resolve: (message) => { clearTimeout(timeout); resolve(message); }, }); }); } close() { this.ws.close(); } } async function run() { await ensureDir(OUTPUT_DIR); const profileDir = path.join(ROOT, '.edge-profile-render'); const { server, port } = await startServer(); const useExternalEdge = process.env.EDGE_EXTERNAL === '1'; let edgePid = null; try { console.log(`server:${port}`); console.log(`externalEdge:${useExternalEdge}`); if (!useExternalEdge) { edgePid = await startEdge(profileDir); } console.log('fetching-devtools-version'); const version = await fetchJson(`http://127.0.0.1:${EDGE_DEBUG_PORT}/json/version`); console.log('devtools-version-ready'); const client = new CDPClient(version.webSocketDebuggerUrl); await client.connect(); console.log('cdp-connected'); const { targetId } = await client.send('Target.createTarget', { url: 'about:blank', }); console.log(`target:${targetId}`); const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true, }); console.log(`session:${sessionId}`); const sessionSend = (method, params = {}) => client.send(method, params, sessionId); await sessionSend('Page.enable'); await sessionSend('Runtime.enable'); await sessionSend('Page.navigate', { url: `http://127.0.0.1:${port}/renderer`, }); await waitForPageReady(sessionSend); for (const video of videos) { const payload = { ...video, logo: '/assets/icon.png', fps: 30, }; const expression = `window.renderVideo(${JSON.stringify(payload)})`; const result = await sessionSend('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }); if (!result.result || !result.result.value) { throw new Error(`Renderer did not return a file path for ${video.outputName}`); } } client.close(); } finally { if (!useExternalEdge) { await stopEdge(edgePid); } server.close(); } } run().catch((error) => { console.error(error); process.exitCode = 1; });