Files
Greenlens/scripts/render_social_videos.js
2026-04-02 11:39:57 +02:00

505 lines
16 KiB
JavaScript

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