Launch
This commit is contained in:
504
scripts/render_social_videos.js
Normal file
504
scripts/render_social_videos.js
Normal file
@@ -0,0 +1,504 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user