Launch
This commit is contained in:
@@ -1,192 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fix_images.js
|
||||
* Finds broken image URLs in lexicon/catalog files and replaces them
|
||||
* using Wikimedia Commons API.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
|
||||
const FILES = [
|
||||
'constants/lexiconBatch1.ts',
|
||||
'constants/lexiconBatch2.ts',
|
||||
'services/backend/mockCatalog.ts',
|
||||
];
|
||||
|
||||
// Known manual fixes (botanicalName -> correct Wikimedia filename)
|
||||
const MANUAL_FIXES = {
|
||||
'Chlorophytum comosum': 'Chlorophytum_comosum_01.jpg',
|
||||
'Syngonium podophyllum': 'Syngonium_podophyllum1.jpg',
|
||||
'Fuchsia hybrida': 'Fuchsia_%27Beacon%27.jpg',
|
||||
'Tillandsia usneoides': 'Tillandsia_usneoides_leaves.jpg',
|
||||
'Tillandsia ionantha': 'Tillandsia_ionantha0.jpg',
|
||||
};
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function httpGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'GreenLens-ImageFixer/1.0 (educational plant app)'
|
||||
}
|
||||
}, (res) => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
resolve(httpGet(res.headers.location));
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkUrl(url) {
|
||||
return new Promise((resolve) => {
|
||||
const req = https.get(url, {
|
||||
headers: { 'User-Agent': 'GreenLens-ImageFixer/1.0' }
|
||||
}, (res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
req.on('error', () => resolve(false));
|
||||
req.setTimeout(8000, () => { req.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function searchWikimediaImage(botanicalName) {
|
||||
const encoded = encodeURIComponent(botanicalName);
|
||||
const url = `https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch=${encoded}&gsrlimit=5&prop=imageinfo&iiprop=url&iiurlwidth=500&format=json`;
|
||||
|
||||
try {
|
||||
const res = await httpGet(url);
|
||||
if (res.status !== 200) return null;
|
||||
const data = JSON.parse(res.body);
|
||||
const pages = data.query && data.query.pages;
|
||||
if (!pages) return null;
|
||||
|
||||
for (const page of Object.values(pages)) {
|
||||
const info = page.imageinfo && page.imageinfo[0];
|
||||
if (!info) continue;
|
||||
const thumbUrl = info.thumburl || info.url;
|
||||
if (thumbUrl && (thumbUrl.endsWith('.jpg') || thumbUrl.endsWith('.png') || thumbUrl.endsWith('.JPG') || thumbUrl.endsWith('.PNG'))) {
|
||||
return thumbUrl;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` API error for "${botanicalName}": ${e.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wikimediaThumbUrl(filename) {
|
||||
// Build a 500px thumb URL from a bare filename
|
||||
const name = filename.replace(/ /g, '_');
|
||||
const hash = require('crypto').createHash('md5').update(name).digest('hex');
|
||||
const d1 = hash[0];
|
||||
const d2 = hash.substring(0, 2);
|
||||
const ext = name.split('.').pop().toLowerCase();
|
||||
const isJpg = ['jpg', 'jpeg'].includes(ext);
|
||||
return `https://upload.wikimedia.org/wikipedia/commons/thumb/${d1}/${d2}/${name}/500px-${name}`;
|
||||
}
|
||||
|
||||
function parseEntries(content) {
|
||||
// Match blocks: find name, botanicalName, imageUri
|
||||
const entries = [];
|
||||
const regex = /name:\s*['"]([^'"]+)['"]\s*,[\s\S]*?botanicalName:\s*['"]([^'"]+)['"]\s*,[\s\S]*?imageUri:\s*['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = regex.exec(content)) !== null) {
|
||||
entries.push({
|
||||
name: m[1],
|
||||
botanicalName: m[2],
|
||||
imageUri: m[3],
|
||||
index: m.index,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function processFile(filepath) {
|
||||
console.log(`\n=== Processing ${filepath} ===`);
|
||||
let content = fs.readFileSync(filepath, 'utf8');
|
||||
const entries = parseEntries(content);
|
||||
console.log(`Found ${entries.length} entries`);
|
||||
|
||||
let fixCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const { name, botanicalName, imageUri } = entry;
|
||||
|
||||
// Check if URL is broken
|
||||
process.stdout.write(` Checking ${botanicalName}... `);
|
||||
const ok = await checkUrl(imageUri);
|
||||
if (ok) {
|
||||
console.log('OK');
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
console.log('BROKEN');
|
||||
|
||||
let newUrl = null;
|
||||
|
||||
// Check manual fixes first
|
||||
if (MANUAL_FIXES[botanicalName]) {
|
||||
const filename = MANUAL_FIXES[botanicalName];
|
||||
const thumb = wikimediaThumbUrl(filename);
|
||||
console.log(` -> Manual fix: ${thumb}`);
|
||||
newUrl = thumb;
|
||||
} else {
|
||||
// Query Wikimedia Commons API
|
||||
console.log(` -> Searching Wikimedia for "${botanicalName}"...`);
|
||||
newUrl = await searchWikimediaImage(botanicalName);
|
||||
if (newUrl) {
|
||||
console.log(` -> Found: ${newUrl}`);
|
||||
} else {
|
||||
console.log(` -> No result found, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
if (newUrl) {
|
||||
// Replace the old URL in content (escape for regex)
|
||||
const escapedOld = imageUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
content = content.replace(new RegExp(escapedOld, 'g'), newUrl);
|
||||
fixCount++;
|
||||
}
|
||||
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
if (fixCount > 0) {
|
||||
fs.writeFileSync(filepath, content, 'utf8');
|
||||
console.log(` => Wrote ${fixCount} fixes to ${filepath}`);
|
||||
} else {
|
||||
console.log(` => No changes needed`);
|
||||
}
|
||||
|
||||
return fixCount;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('GreenLens Image URL Fixer');
|
||||
console.log('========================');
|
||||
let totalFixes = 0;
|
||||
for (const file of FILES) {
|
||||
if (!fs.existsSync(file)) {
|
||||
console.log(`\nSkipping ${file} (not found)`);
|
||||
continue;
|
||||
}
|
||||
totalFixes += await processFile(file);
|
||||
}
|
||||
console.log(`\nDone. Total fixes: ${totalFixes}`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fix_images.js
|
||||
* Finds broken image URLs in lexicon/catalog files and replaces them
|
||||
* using Wikimedia Commons API.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
|
||||
const FILES = [
|
||||
'constants/lexiconBatch1.ts',
|
||||
'constants/lexiconBatch2.ts',
|
||||
'services/backend/mockCatalog.ts',
|
||||
];
|
||||
|
||||
// Known manual fixes (botanicalName -> correct Wikimedia filename)
|
||||
const MANUAL_FIXES = {
|
||||
'Chlorophytum comosum': 'Chlorophytum_comosum_01.jpg',
|
||||
'Syngonium podophyllum': 'Syngonium_podophyllum1.jpg',
|
||||
'Fuchsia hybrida': 'Fuchsia_%27Beacon%27.jpg',
|
||||
'Tillandsia usneoides': 'Tillandsia_usneoides_leaves.jpg',
|
||||
'Tillandsia ionantha': 'Tillandsia_ionantha0.jpg',
|
||||
};
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function httpGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'GreenLens-ImageFixer/1.0 (educational plant app)'
|
||||
}
|
||||
}, (res) => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
resolve(httpGet(res.headers.location));
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkUrl(url) {
|
||||
return new Promise((resolve) => {
|
||||
const req = https.get(url, {
|
||||
headers: { 'User-Agent': 'GreenLens-ImageFixer/1.0' }
|
||||
}, (res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
req.on('error', () => resolve(false));
|
||||
req.setTimeout(8000, () => { req.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function searchWikimediaImage(botanicalName) {
|
||||
const encoded = encodeURIComponent(botanicalName);
|
||||
const url = `https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch=${encoded}&gsrlimit=5&prop=imageinfo&iiprop=url&iiurlwidth=500&format=json`;
|
||||
|
||||
try {
|
||||
const res = await httpGet(url);
|
||||
if (res.status !== 200) return null;
|
||||
const data = JSON.parse(res.body);
|
||||
const pages = data.query && data.query.pages;
|
||||
if (!pages) return null;
|
||||
|
||||
for (const page of Object.values(pages)) {
|
||||
const info = page.imageinfo && page.imageinfo[0];
|
||||
if (!info) continue;
|
||||
const thumbUrl = info.thumburl || info.url;
|
||||
if (thumbUrl && (thumbUrl.endsWith('.jpg') || thumbUrl.endsWith('.png') || thumbUrl.endsWith('.JPG') || thumbUrl.endsWith('.PNG'))) {
|
||||
return thumbUrl;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` API error for "${botanicalName}": ${e.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wikimediaThumbUrl(filename) {
|
||||
// Build a 500px thumb URL from a bare filename
|
||||
const name = filename.replace(/ /g, '_');
|
||||
const hash = require('crypto').createHash('md5').update(name).digest('hex');
|
||||
const d1 = hash[0];
|
||||
const d2 = hash.substring(0, 2);
|
||||
const ext = name.split('.').pop().toLowerCase();
|
||||
const isJpg = ['jpg', 'jpeg'].includes(ext);
|
||||
return `https://upload.wikimedia.org/wikipedia/commons/thumb/${d1}/${d2}/${name}/500px-${name}`;
|
||||
}
|
||||
|
||||
function parseEntries(content) {
|
||||
// Match blocks: find name, botanicalName, imageUri
|
||||
const entries = [];
|
||||
const regex = /name:\s*['"]([^'"]+)['"]\s*,[\s\S]*?botanicalName:\s*['"]([^'"]+)['"]\s*,[\s\S]*?imageUri:\s*['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = regex.exec(content)) !== null) {
|
||||
entries.push({
|
||||
name: m[1],
|
||||
botanicalName: m[2],
|
||||
imageUri: m[3],
|
||||
index: m.index,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function processFile(filepath) {
|
||||
console.log(`\n=== Processing ${filepath} ===`);
|
||||
let content = fs.readFileSync(filepath, 'utf8');
|
||||
const entries = parseEntries(content);
|
||||
console.log(`Found ${entries.length} entries`);
|
||||
|
||||
let fixCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const { name, botanicalName, imageUri } = entry;
|
||||
|
||||
// Check if URL is broken
|
||||
process.stdout.write(` Checking ${botanicalName}... `);
|
||||
const ok = await checkUrl(imageUri);
|
||||
if (ok) {
|
||||
console.log('OK');
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
console.log('BROKEN');
|
||||
|
||||
let newUrl = null;
|
||||
|
||||
// Check manual fixes first
|
||||
if (MANUAL_FIXES[botanicalName]) {
|
||||
const filename = MANUAL_FIXES[botanicalName];
|
||||
const thumb = wikimediaThumbUrl(filename);
|
||||
console.log(` -> Manual fix: ${thumb}`);
|
||||
newUrl = thumb;
|
||||
} else {
|
||||
// Query Wikimedia Commons API
|
||||
console.log(` -> Searching Wikimedia for "${botanicalName}"...`);
|
||||
newUrl = await searchWikimediaImage(botanicalName);
|
||||
if (newUrl) {
|
||||
console.log(` -> Found: ${newUrl}`);
|
||||
} else {
|
||||
console.log(` -> No result found, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
if (newUrl) {
|
||||
// Replace the old URL in content (escape for regex)
|
||||
const escapedOld = imageUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
content = content.replace(new RegExp(escapedOld, 'g'), newUrl);
|
||||
fixCount++;
|
||||
}
|
||||
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
if (fixCount > 0) {
|
||||
fs.writeFileSync(filepath, content, 'utf8');
|
||||
console.log(` => Wrote ${fixCount} fixes to ${filepath}`);
|
||||
} else {
|
||||
console.log(` => No changes needed`);
|
||||
}
|
||||
|
||||
return fixCount;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('GreenLens Image URL Fixer');
|
||||
console.log('========================');
|
||||
let totalFixes = 0;
|
||||
for (const file of FILES) {
|
||||
if (!fs.existsSync(file)) {
|
||||
console.log(`\nSkipping ${file} (not found)`);
|
||||
continue;
|
||||
}
|
||||
totalFixes += await processFile(file);
|
||||
}
|
||||
console.log(`\nDone. Total fixes: ${totalFixes}`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
|
||||
@@ -1,314 +1,314 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const ts = require('typescript');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const OUTPUT_DIR = path.join(ROOT_DIR, 'audits', 'semantic-search');
|
||||
const CATEGORY_DIR = path.join(OUTPUT_DIR, 'categories');
|
||||
const ROOT_EXPORT_PATH = path.join(ROOT_DIR, 'all-plants-categories.csv');
|
||||
const BATCH_1_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch1.ts');
|
||||
const BATCH_2_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch2.ts');
|
||||
|
||||
const AUDIT_PRIORITY = [
|
||||
'pet_friendly',
|
||||
'air_purifier',
|
||||
'medicinal',
|
||||
'low_light',
|
||||
'bright_light',
|
||||
'sun',
|
||||
'easy',
|
||||
'high_humidity',
|
||||
'hanging',
|
||||
'tree',
|
||||
'large',
|
||||
'patterned',
|
||||
'flowering',
|
||||
'succulent',
|
||||
];
|
||||
|
||||
const HIGH_CONFIDENCE_MANUAL_REVIEW_CATEGORIES = new Set([
|
||||
'pet_friendly',
|
||||
'air_purifier',
|
||||
'medicinal',
|
||||
]);
|
||||
|
||||
const CATEGORY_DISPLAY_ORDER = [
|
||||
'easy',
|
||||
'pet_friendly',
|
||||
'flowering',
|
||||
'succulent',
|
||||
'patterned',
|
||||
'tree',
|
||||
'large',
|
||||
'medicinal',
|
||||
'hanging',
|
||||
'air_purifier',
|
||||
'low_light',
|
||||
'bright_light',
|
||||
'high_humidity',
|
||||
'sun',
|
||||
];
|
||||
|
||||
const resolveTsFilePath = (fromFile, specifier) => {
|
||||
if (!specifier.startsWith('.')) return null;
|
||||
const fromDirectory = path.dirname(fromFile);
|
||||
const absoluteBase = path.resolve(fromDirectory, specifier);
|
||||
const candidates = [
|
||||
absoluteBase,
|
||||
`${absoluteBase}.ts`,
|
||||
`${absoluteBase}.tsx`,
|
||||
path.join(absoluteBase, 'index.ts'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const loadTsModule = (absolutePath, cache = new Map()) => {
|
||||
if (cache.has(absolutePath)) return cache.get(absolutePath);
|
||||
|
||||
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
fileName: absolutePath,
|
||||
reportDiagnostics: false,
|
||||
}).outputText;
|
||||
|
||||
const module = { exports: {} };
|
||||
cache.set(absolutePath, module.exports);
|
||||
|
||||
const localRequire = (specifier) => {
|
||||
const resolvedTsPath = resolveTsFilePath(absolutePath, specifier);
|
||||
if (resolvedTsPath) return loadTsModule(resolvedTsPath, cache);
|
||||
return require(specifier);
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
module,
|
||||
exports: module.exports,
|
||||
require: localRequire,
|
||||
__dirname: path.dirname(absolutePath),
|
||||
__filename: absolutePath,
|
||||
console,
|
||||
process,
|
||||
Buffer,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
};
|
||||
|
||||
vm.runInNewContext(transpiled, sandbox, { filename: absolutePath });
|
||||
cache.set(absolutePath, module.exports);
|
||||
return module.exports;
|
||||
};
|
||||
|
||||
const ensureDir = (directoryPath) => {
|
||||
fs.mkdirSync(directoryPath, { recursive: true });
|
||||
};
|
||||
|
||||
const csvEscape = (value) => {
|
||||
const stringValue = String(value ?? '');
|
||||
if (/[",\n]/.test(stringValue)) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return stringValue;
|
||||
};
|
||||
|
||||
const writeCsv = (filePath, rows) => {
|
||||
if (!rows.length) {
|
||||
fs.writeFileSync(filePath, '', 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = Object.keys(rows[0]);
|
||||
const lines = [headers.join(',')];
|
||||
rows.forEach((row) => {
|
||||
lines.push(headers.map((header) => csvEscape(row[header])).join(','));
|
||||
});
|
||||
fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const normalizeCategoryFilename = (category) => category.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase();
|
||||
|
||||
const sortCategories = (categories = []) => (
|
||||
[...categories].sort((left, right) => {
|
||||
const leftIndex = CATEGORY_DISPLAY_ORDER.indexOf(left);
|
||||
const rightIndex = CATEGORY_DISPLAY_ORDER.indexOf(right);
|
||||
const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
|
||||
const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
|
||||
return normalizedLeft - normalizedRight || left.localeCompare(right);
|
||||
})
|
||||
);
|
||||
|
||||
const buildRiskFlags = (entry) => {
|
||||
const categories = new Set(entry.categories || []);
|
||||
const flags = [];
|
||||
|
||||
if (categories.has('low_light') && categories.has('sun')) {
|
||||
flags.push('light_conflict_low_light_and_sun');
|
||||
}
|
||||
if (categories.has('low_light') && categories.has('bright_light')) {
|
||||
flags.push('light_conflict_low_light_and_bright_light');
|
||||
}
|
||||
if (categories.has('succulent') && categories.has('high_humidity')) {
|
||||
flags.push('succulent_high_humidity_combo_review');
|
||||
}
|
||||
|
||||
(entry.categories || []).forEach((category) => {
|
||||
if (HIGH_CONFIDENCE_MANUAL_REVIEW_CATEGORIES.has(category)) {
|
||||
flags.push(`${category}_requires_external_evidence`);
|
||||
}
|
||||
});
|
||||
|
||||
return [...new Set(flags)];
|
||||
};
|
||||
|
||||
const toAuditRow = (entry, category) => ({
|
||||
category,
|
||||
source_file: entry.sourceFile,
|
||||
source_index: entry.sourceIndex,
|
||||
name: entry.name,
|
||||
botanical_name: entry.botanicalName,
|
||||
description: entry.description || '',
|
||||
light: entry.careInfo?.light || '',
|
||||
temp: entry.careInfo?.temp || '',
|
||||
water_interval_days: entry.careInfo?.waterIntervalDays ?? '',
|
||||
all_categories: sortCategories(entry.categories || []).join('|'),
|
||||
risk_flags: buildRiskFlags(entry).join('|'),
|
||||
audit_status: '',
|
||||
evidence_source: '',
|
||||
evidence_url: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const toPlantCategoryRow = (entry) => ({
|
||||
source_file: entry.sourceFile,
|
||||
source_index: entry.sourceIndex,
|
||||
name: entry.name,
|
||||
botanical_name: entry.botanicalName,
|
||||
all_categories: sortCategories(entry.categories || []).join('|'),
|
||||
category_count: (entry.categories || []).length,
|
||||
description: entry.description || '',
|
||||
light: entry.careInfo?.light || '',
|
||||
temp: entry.careInfo?.temp || '',
|
||||
water_interval_days: entry.careInfo?.waterIntervalDays ?? '',
|
||||
});
|
||||
|
||||
const loadBatchEntries = () => {
|
||||
const batch1Entries = loadTsModule(BATCH_1_PATH).LEXICON_BATCH_1_ENTRIES;
|
||||
const batch2Entries = loadTsModule(BATCH_2_PATH).LEXICON_BATCH_2_ENTRIES;
|
||||
|
||||
if (!Array.isArray(batch1Entries) || !Array.isArray(batch2Entries)) {
|
||||
throw new Error('Could not load lexicon batch entries.');
|
||||
}
|
||||
|
||||
return [
|
||||
...batch1Entries.map((entry, index) => ({ ...entry, sourceFile: 'constants/lexiconBatch1.ts', sourceIndex: index + 1 })),
|
||||
...batch2Entries.map((entry, index) => ({ ...entry, sourceFile: 'constants/lexiconBatch2.ts', sourceIndex: index + 1 })),
|
||||
];
|
||||
};
|
||||
|
||||
const main = () => {
|
||||
ensureDir(CATEGORY_DIR);
|
||||
const entries = loadBatchEntries();
|
||||
const categories = [...new Set(entries.flatMap((entry) => entry.categories || []))].sort();
|
||||
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalEntries: entries.length,
|
||||
categories: categories.map((category) => ({
|
||||
category,
|
||||
count: entries.filter((entry) => (entry.categories || []).includes(category)).length,
|
||||
priority: AUDIT_PRIORITY.indexOf(category) >= 0 ? AUDIT_PRIORITY.indexOf(category) + 1 : 999,
|
||||
})).sort((left, right) =>
|
||||
left.priority - right.priority ||
|
||||
right.count - left.count ||
|
||||
left.category.localeCompare(right.category)),
|
||||
};
|
||||
|
||||
const plantCategoryRows = [...entries]
|
||||
.sort((left, right) =>
|
||||
left.botanicalName.localeCompare(right.botanicalName) ||
|
||||
left.name.localeCompare(right.name))
|
||||
.map((entry) => toPlantCategoryRow(entry));
|
||||
|
||||
const masterRows = [];
|
||||
const suspiciousRows = [];
|
||||
|
||||
categories.forEach((category) => {
|
||||
const categoryEntries = entries
|
||||
.filter((entry) => (entry.categories || []).includes(category))
|
||||
.sort((left, right) =>
|
||||
left.botanicalName.localeCompare(right.botanicalName) ||
|
||||
left.name.localeCompare(right.name));
|
||||
|
||||
const rows = categoryEntries.map((entry) => {
|
||||
const row = toAuditRow(entry, category);
|
||||
masterRows.push(row);
|
||||
|
||||
const riskFlags = row.risk_flags ? row.risk_flags.split('|').filter(Boolean) : [];
|
||||
if (riskFlags.length > 0) {
|
||||
suspiciousRows.push({
|
||||
category,
|
||||
source_file: entry.sourceFile,
|
||||
source_index: entry.sourceIndex,
|
||||
name: entry.name,
|
||||
botanical_name: entry.botanicalName,
|
||||
risk_flags: riskFlags.join('|'),
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
writeCsv(path.join(CATEGORY_DIR, `${normalizeCategoryFilename(category)}.csv`), rows);
|
||||
});
|
||||
|
||||
writeCsv(path.join(OUTPUT_DIR, 'all-plants-categories.csv'), plantCategoryRows);
|
||||
writeCsv(ROOT_EXPORT_PATH, plantCategoryRows);
|
||||
writeCsv(path.join(OUTPUT_DIR, 'master.csv'), masterRows);
|
||||
writeCsv(path.join(OUTPUT_DIR, 'suspicious.csv'), suspiciousRows);
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'suspicious.json'), `${JSON.stringify(suspiciousRows, null, 2)}\n`, 'utf8');
|
||||
|
||||
const readme = `# Semantic Search Audit
|
||||
|
||||
Generated: ${summary.generatedAt}
|
||||
|
||||
Files:
|
||||
- \`summary.json\`: category counts and suggested audit order
|
||||
- \`all-plants-categories.csv\`: one row per plant with its full category list
|
||||
- \`master.csv\`: all category assignments with blank evidence columns
|
||||
- \`suspicious.csv\`: entries that require elevated review based on rule flags
|
||||
- \`categories/*.csv\`: per-category audit sheets
|
||||
|
||||
Suggested audit order:
|
||||
${summary.categories.map((item) => `- ${item.category} (${item.count})`).join('\n')}
|
||||
|
||||
Workflow:
|
||||
1. Review one category CSV at a time.
|
||||
2. Fill \`audit_status\`, \`evidence_source\`, \`evidence_url\`, and \`notes\`.
|
||||
3. Apply only high-confidence source-tag corrections to the lexicon batch files.
|
||||
4. Rebuild the server catalog from batches after source edits.
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readme, 'utf8');
|
||||
|
||||
console.log(`Audit artifacts written to ${OUTPUT_DIR}`);
|
||||
console.log(`Categories exported: ${categories.length}`);
|
||||
console.log(`Suspicious rows flagged: ${suspiciousRows.length}`);
|
||||
};
|
||||
|
||||
main();
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const ts = require('typescript');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const OUTPUT_DIR = path.join(ROOT_DIR, 'audits', 'semantic-search');
|
||||
const CATEGORY_DIR = path.join(OUTPUT_DIR, 'categories');
|
||||
const ROOT_EXPORT_PATH = path.join(ROOT_DIR, 'all-plants-categories.csv');
|
||||
const BATCH_1_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch1.ts');
|
||||
const BATCH_2_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch2.ts');
|
||||
|
||||
const AUDIT_PRIORITY = [
|
||||
'pet_friendly',
|
||||
'air_purifier',
|
||||
'medicinal',
|
||||
'low_light',
|
||||
'bright_light',
|
||||
'sun',
|
||||
'easy',
|
||||
'high_humidity',
|
||||
'hanging',
|
||||
'tree',
|
||||
'large',
|
||||
'patterned',
|
||||
'flowering',
|
||||
'succulent',
|
||||
];
|
||||
|
||||
const HIGH_CONFIDENCE_MANUAL_REVIEW_CATEGORIES = new Set([
|
||||
'pet_friendly',
|
||||
'air_purifier',
|
||||
'medicinal',
|
||||
]);
|
||||
|
||||
const CATEGORY_DISPLAY_ORDER = [
|
||||
'easy',
|
||||
'pet_friendly',
|
||||
'flowering',
|
||||
'succulent',
|
||||
'patterned',
|
||||
'tree',
|
||||
'large',
|
||||
'medicinal',
|
||||
'hanging',
|
||||
'air_purifier',
|
||||
'low_light',
|
||||
'bright_light',
|
||||
'high_humidity',
|
||||
'sun',
|
||||
];
|
||||
|
||||
const resolveTsFilePath = (fromFile, specifier) => {
|
||||
if (!specifier.startsWith('.')) return null;
|
||||
const fromDirectory = path.dirname(fromFile);
|
||||
const absoluteBase = path.resolve(fromDirectory, specifier);
|
||||
const candidates = [
|
||||
absoluteBase,
|
||||
`${absoluteBase}.ts`,
|
||||
`${absoluteBase}.tsx`,
|
||||
path.join(absoluteBase, 'index.ts'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const loadTsModule = (absolutePath, cache = new Map()) => {
|
||||
if (cache.has(absolutePath)) return cache.get(absolutePath);
|
||||
|
||||
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
fileName: absolutePath,
|
||||
reportDiagnostics: false,
|
||||
}).outputText;
|
||||
|
||||
const module = { exports: {} };
|
||||
cache.set(absolutePath, module.exports);
|
||||
|
||||
const localRequire = (specifier) => {
|
||||
const resolvedTsPath = resolveTsFilePath(absolutePath, specifier);
|
||||
if (resolvedTsPath) return loadTsModule(resolvedTsPath, cache);
|
||||
return require(specifier);
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
module,
|
||||
exports: module.exports,
|
||||
require: localRequire,
|
||||
__dirname: path.dirname(absolutePath),
|
||||
__filename: absolutePath,
|
||||
console,
|
||||
process,
|
||||
Buffer,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
};
|
||||
|
||||
vm.runInNewContext(transpiled, sandbox, { filename: absolutePath });
|
||||
cache.set(absolutePath, module.exports);
|
||||
return module.exports;
|
||||
};
|
||||
|
||||
const ensureDir = (directoryPath) => {
|
||||
fs.mkdirSync(directoryPath, { recursive: true });
|
||||
};
|
||||
|
||||
const csvEscape = (value) => {
|
||||
const stringValue = String(value ?? '');
|
||||
if (/[",\n]/.test(stringValue)) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return stringValue;
|
||||
};
|
||||
|
||||
const writeCsv = (filePath, rows) => {
|
||||
if (!rows.length) {
|
||||
fs.writeFileSync(filePath, '', 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = Object.keys(rows[0]);
|
||||
const lines = [headers.join(',')];
|
||||
rows.forEach((row) => {
|
||||
lines.push(headers.map((header) => csvEscape(row[header])).join(','));
|
||||
});
|
||||
fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const normalizeCategoryFilename = (category) => category.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase();
|
||||
|
||||
const sortCategories = (categories = []) => (
|
||||
[...categories].sort((left, right) => {
|
||||
const leftIndex = CATEGORY_DISPLAY_ORDER.indexOf(left);
|
||||
const rightIndex = CATEGORY_DISPLAY_ORDER.indexOf(right);
|
||||
const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
|
||||
const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
|
||||
return normalizedLeft - normalizedRight || left.localeCompare(right);
|
||||
})
|
||||
);
|
||||
|
||||
const buildRiskFlags = (entry) => {
|
||||
const categories = new Set(entry.categories || []);
|
||||
const flags = [];
|
||||
|
||||
if (categories.has('low_light') && categories.has('sun')) {
|
||||
flags.push('light_conflict_low_light_and_sun');
|
||||
}
|
||||
if (categories.has('low_light') && categories.has('bright_light')) {
|
||||
flags.push('light_conflict_low_light_and_bright_light');
|
||||
}
|
||||
if (categories.has('succulent') && categories.has('high_humidity')) {
|
||||
flags.push('succulent_high_humidity_combo_review');
|
||||
}
|
||||
|
||||
(entry.categories || []).forEach((category) => {
|
||||
if (HIGH_CONFIDENCE_MANUAL_REVIEW_CATEGORIES.has(category)) {
|
||||
flags.push(`${category}_requires_external_evidence`);
|
||||
}
|
||||
});
|
||||
|
||||
return [...new Set(flags)];
|
||||
};
|
||||
|
||||
const toAuditRow = (entry, category) => ({
|
||||
category,
|
||||
source_file: entry.sourceFile,
|
||||
source_index: entry.sourceIndex,
|
||||
name: entry.name,
|
||||
botanical_name: entry.botanicalName,
|
||||
description: entry.description || '',
|
||||
light: entry.careInfo?.light || '',
|
||||
temp: entry.careInfo?.temp || '',
|
||||
water_interval_days: entry.careInfo?.waterIntervalDays ?? '',
|
||||
all_categories: sortCategories(entry.categories || []).join('|'),
|
||||
risk_flags: buildRiskFlags(entry).join('|'),
|
||||
audit_status: '',
|
||||
evidence_source: '',
|
||||
evidence_url: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const toPlantCategoryRow = (entry) => ({
|
||||
source_file: entry.sourceFile,
|
||||
source_index: entry.sourceIndex,
|
||||
name: entry.name,
|
||||
botanical_name: entry.botanicalName,
|
||||
all_categories: sortCategories(entry.categories || []).join('|'),
|
||||
category_count: (entry.categories || []).length,
|
||||
description: entry.description || '',
|
||||
light: entry.careInfo?.light || '',
|
||||
temp: entry.careInfo?.temp || '',
|
||||
water_interval_days: entry.careInfo?.waterIntervalDays ?? '',
|
||||
});
|
||||
|
||||
const loadBatchEntries = () => {
|
||||
const batch1Entries = loadTsModule(BATCH_1_PATH).LEXICON_BATCH_1_ENTRIES;
|
||||
const batch2Entries = loadTsModule(BATCH_2_PATH).LEXICON_BATCH_2_ENTRIES;
|
||||
|
||||
if (!Array.isArray(batch1Entries) || !Array.isArray(batch2Entries)) {
|
||||
throw new Error('Could not load lexicon batch entries.');
|
||||
}
|
||||
|
||||
return [
|
||||
...batch1Entries.map((entry, index) => ({ ...entry, sourceFile: 'constants/lexiconBatch1.ts', sourceIndex: index + 1 })),
|
||||
...batch2Entries.map((entry, index) => ({ ...entry, sourceFile: 'constants/lexiconBatch2.ts', sourceIndex: index + 1 })),
|
||||
];
|
||||
};
|
||||
|
||||
const main = () => {
|
||||
ensureDir(CATEGORY_DIR);
|
||||
const entries = loadBatchEntries();
|
||||
const categories = [...new Set(entries.flatMap((entry) => entry.categories || []))].sort();
|
||||
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalEntries: entries.length,
|
||||
categories: categories.map((category) => ({
|
||||
category,
|
||||
count: entries.filter((entry) => (entry.categories || []).includes(category)).length,
|
||||
priority: AUDIT_PRIORITY.indexOf(category) >= 0 ? AUDIT_PRIORITY.indexOf(category) + 1 : 999,
|
||||
})).sort((left, right) =>
|
||||
left.priority - right.priority ||
|
||||
right.count - left.count ||
|
||||
left.category.localeCompare(right.category)),
|
||||
};
|
||||
|
||||
const plantCategoryRows = [...entries]
|
||||
.sort((left, right) =>
|
||||
left.botanicalName.localeCompare(right.botanicalName) ||
|
||||
left.name.localeCompare(right.name))
|
||||
.map((entry) => toPlantCategoryRow(entry));
|
||||
|
||||
const masterRows = [];
|
||||
const suspiciousRows = [];
|
||||
|
||||
categories.forEach((category) => {
|
||||
const categoryEntries = entries
|
||||
.filter((entry) => (entry.categories || []).includes(category))
|
||||
.sort((left, right) =>
|
||||
left.botanicalName.localeCompare(right.botanicalName) ||
|
||||
left.name.localeCompare(right.name));
|
||||
|
||||
const rows = categoryEntries.map((entry) => {
|
||||
const row = toAuditRow(entry, category);
|
||||
masterRows.push(row);
|
||||
|
||||
const riskFlags = row.risk_flags ? row.risk_flags.split('|').filter(Boolean) : [];
|
||||
if (riskFlags.length > 0) {
|
||||
suspiciousRows.push({
|
||||
category,
|
||||
source_file: entry.sourceFile,
|
||||
source_index: entry.sourceIndex,
|
||||
name: entry.name,
|
||||
botanical_name: entry.botanicalName,
|
||||
risk_flags: riskFlags.join('|'),
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
writeCsv(path.join(CATEGORY_DIR, `${normalizeCategoryFilename(category)}.csv`), rows);
|
||||
});
|
||||
|
||||
writeCsv(path.join(OUTPUT_DIR, 'all-plants-categories.csv'), plantCategoryRows);
|
||||
writeCsv(ROOT_EXPORT_PATH, plantCategoryRows);
|
||||
writeCsv(path.join(OUTPUT_DIR, 'master.csv'), masterRows);
|
||||
writeCsv(path.join(OUTPUT_DIR, 'suspicious.csv'), suspiciousRows);
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'suspicious.json'), `${JSON.stringify(suspiciousRows, null, 2)}\n`, 'utf8');
|
||||
|
||||
const readme = `# Semantic Search Audit
|
||||
|
||||
Generated: ${summary.generatedAt}
|
||||
|
||||
Files:
|
||||
- \`summary.json\`: category counts and suggested audit order
|
||||
- \`all-plants-categories.csv\`: one row per plant with its full category list
|
||||
- \`master.csv\`: all category assignments with blank evidence columns
|
||||
- \`suspicious.csv\`: entries that require elevated review based on rule flags
|
||||
- \`categories/*.csv\`: per-category audit sheets
|
||||
|
||||
Suggested audit order:
|
||||
${summary.categories.map((item) => `- ${item.category} (${item.count})`).join('\n')}
|
||||
|
||||
Workflow:
|
||||
1. Review one category CSV at a time.
|
||||
2. Fill \`audit_status\`, \`evidence_source\`, \`evidence_url\`, and \`notes\`.
|
||||
3. Apply only high-confidence source-tag corrections to the lexicon batch files.
|
||||
4. Rebuild the server catalog from batches after source edits.
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readme, 'utf8');
|
||||
|
||||
console.log(`Audit artifacts written to ${OUTPUT_DIR}`);
|
||||
console.log(`Categories exported: ${categories.length}`);
|
||||
console.log(`Suspicious rows flagged: ${suspiciousRows.length}`);
|
||||
};
|
||||
|
||||
main();
|
||||
|
||||
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;
|
||||
});
|
||||
286
scripts/social-video-renderer.html
Normal file
286
scripts/social-video-renderer.html
Normal file
@@ -0,0 +1,286 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>GreenLens Social Video Renderer</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0b1711;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 360px;
|
||||
height: 640px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
image-rendering: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="720" height="1280"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const WIDTH = canvas.width;
|
||||
const HEIGHT = canvas.height;
|
||||
|
||||
function roundedRectPath(x, y, w, h, r) {
|
||||
const radius = Math.min(r, w / 2, h / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, radius);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, radius);
|
||||
ctx.arcTo(x, y + h, x, y, radius);
|
||||
ctx.arcTo(x, y, x + w, y, radius);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function easeOutCubic(t) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function drawBackground(accentA, accentB) {
|
||||
const bg = ctx.createLinearGradient(0, 0, WIDTH, HEIGHT);
|
||||
bg.addColorStop(0, '#07110c');
|
||||
bg.addColorStop(0.55, accentA || '#10251a');
|
||||
bg.addColorStop(1, accentB || '#29553f');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.fillStyle = '#d9f5d0';
|
||||
ctx.beginPath();
|
||||
ctx.arc(80, 120, 180, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 0.12;
|
||||
ctx.beginPath();
|
||||
ctx.arc(WIDTH - 40, HEIGHT - 120, 260, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawImageCover(img, x, y, w, h, scale = 1, offsetX = 0, offsetY = 0) {
|
||||
const imgRatio = img.width / img.height;
|
||||
const boxRatio = w / h;
|
||||
let drawW;
|
||||
let drawH;
|
||||
|
||||
if (imgRatio > boxRatio) {
|
||||
drawH = h * scale;
|
||||
drawW = drawH * imgRatio;
|
||||
} else {
|
||||
drawW = w * scale;
|
||||
drawH = drawW / imgRatio;
|
||||
}
|
||||
|
||||
const dx = x + (w - drawW) / 2 + offsetX;
|
||||
const dy = y + (h - drawH) / 2 + offsetY;
|
||||
ctx.drawImage(img, dx, dy, drawW, drawH);
|
||||
}
|
||||
|
||||
function drawCard(scene, progress) {
|
||||
const imageX = 52;
|
||||
const imageY = 160;
|
||||
const imageW = WIDTH - 104;
|
||||
const imageH = 700;
|
||||
const scale = 1.02 + progress * 0.07;
|
||||
const slide = (1 - progress) * 22;
|
||||
|
||||
ctx.save();
|
||||
roundedRectPath(imageX, imageY, imageW, imageH, 42);
|
||||
ctx.clip();
|
||||
drawImageCover(scene.image, imageX, imageY, imageW, imageH, scale, 0, slide);
|
||||
|
||||
const overlay = ctx.createLinearGradient(0, imageY, 0, imageY + imageH);
|
||||
overlay.addColorStop(0, 'rgba(4, 10, 7, 0.08)');
|
||||
overlay.addColorStop(0.55, 'rgba(4, 10, 7, 0.18)');
|
||||
overlay.addColorStop(1, 'rgba(4, 10, 7, 0.7)');
|
||||
ctx.fillStyle = overlay;
|
||||
ctx.fillRect(imageX, imageY, imageW, imageH);
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
|
||||
ctx.lineWidth = 2;
|
||||
roundedRectPath(imageX, imageY, imageW, imageH, 42);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawLogo(logo, progress) {
|
||||
const size = 84;
|
||||
const x = 56;
|
||||
const y = 56 - (1 - progress) * 10;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = progress;
|
||||
roundedRectPath(x, y, size, size, 24);
|
||||
ctx.clip();
|
||||
ctx.drawImage(logo, x, y, size, size);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawText(scene, progress) {
|
||||
const titleY = 942 - (1 - progress) * 18;
|
||||
const subtitleY = 1128 - (1 - progress) * 14;
|
||||
const alpha = clamp(progress * 1.15, 0, 1);
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
if (scene.badge) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(217,245,208,0.13)';
|
||||
roundedRectPath(56, 922 - 76, 220, 48, 22);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#d9f5d0';
|
||||
ctx.font = '700 24px Arial';
|
||||
ctx.fillText(scene.badge.toUpperCase(), 76, 878);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '700 64px Arial';
|
||||
wrapText(scene.title, 56, titleY, WIDTH - 112, 74);
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.84)';
|
||||
ctx.font = '400 30px Arial';
|
||||
wrapText(scene.subtitle, 56, subtitleY, WIDTH - 112, 42);
|
||||
|
||||
if (scene.cta) {
|
||||
ctx.save();
|
||||
const ctaY = HEIGHT - 98;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.10)';
|
||||
roundedRectPath(56, ctaY - 38, WIDTH - 112, 58, 20);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#d9f5d0';
|
||||
ctx.font = '700 24px Arial';
|
||||
ctx.fillText(scene.cta, 82, ctaY);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function wrapText(text, x, y, maxWidth, lineHeight) {
|
||||
const words = text.split(/\s+/);
|
||||
let line = '';
|
||||
let lineY = y;
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = line ? `${line} ${word}` : word;
|
||||
const width = ctx.measureText(testLine).width;
|
||||
if (width > maxWidth && line) {
|
||||
ctx.fillText(line, x, lineY);
|
||||
line = word;
|
||||
lineY += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (line) {
|
||||
ctx.fillText(line, x, lineY);
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function renderVideo(payload) {
|
||||
const logo = await loadImage(payload.logo);
|
||||
const scenes = await Promise.all(
|
||||
payload.scenes.map(async (scene) => ({
|
||||
...scene,
|
||||
image: await loadImage(scene.image),
|
||||
}))
|
||||
);
|
||||
|
||||
const stream = canvas.captureStream(payload.fps || 30);
|
||||
const mimeType = 'video/webm;codecs=vp9';
|
||||
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 5_000_000 });
|
||||
const chunks = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
const stopped = new Promise((resolve) => {
|
||||
recorder.onstop = resolve;
|
||||
});
|
||||
|
||||
const totalDuration = scenes.reduce((sum, scene) => sum + scene.durationMs, 0);
|
||||
const startedAt = performance.now();
|
||||
recorder.start(250);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
function frame(now) {
|
||||
const elapsed = now - startedAt;
|
||||
const cappedElapsed = Math.min(elapsed, totalDuration);
|
||||
|
||||
let offset = 0;
|
||||
let activeScene = scenes[scenes.length - 1];
|
||||
let sceneElapsed = activeScene.durationMs;
|
||||
|
||||
for (const scene of scenes) {
|
||||
if (cappedElapsed <= offset + scene.durationMs) {
|
||||
activeScene = scene;
|
||||
sceneElapsed = cappedElapsed - offset;
|
||||
break;
|
||||
}
|
||||
offset += scene.durationMs;
|
||||
}
|
||||
|
||||
const normalized = clamp(sceneElapsed / activeScene.durationMs, 0, 1);
|
||||
const intro = easeOutCubic(clamp(normalized / 0.24, 0, 1));
|
||||
|
||||
drawBackground(payload.accentA, payload.accentB);
|
||||
drawCard(activeScene, normalized);
|
||||
drawLogo(logo, intro);
|
||||
drawText(activeScene, intro);
|
||||
|
||||
if (elapsed < totalDuration) {
|
||||
requestAnimationFrame(frame);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
recorder.stop();
|
||||
await stopped;
|
||||
|
||||
const blob = new Blob(chunks, { type: mimeType });
|
||||
const uploadUrl = `/upload?name=${encodeURIComponent(payload.outputName)}`;
|
||||
const response = await fetch(uploadUrl, { method: 'POST', body: blob });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
window.renderVideo = renderVideo;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,55 +1,55 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Using exact string parsing since importing the TS files directly in tsx could have issues if the environment isn't fully set up, but tsx should work. Let's just import them.
|
||||
import { LEXICON_BATCH_1_ENTRIES } from '../constants/lexiconBatch1';
|
||||
import { LEXICON_BATCH_2_ENTRIES } from '../constants/lexiconBatch2';
|
||||
|
||||
const allPlants = [...LEXICON_BATCH_1_ENTRIES, ...LEXICON_BATCH_2_ENTRIES];
|
||||
|
||||
async function checkUrl(url: string): Promise<boolean> {
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'Referer': 'https://commons.wikimedia.org/'
|
||||
};
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET', headers });
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`Checking ${allPlants.length} plants...`);
|
||||
let failedCount = 0;
|
||||
const concurrency = 10;
|
||||
|
||||
for (let i = 0; i < allPlants.length; i += concurrency) {
|
||||
const batch = allPlants.slice(i, i + concurrency);
|
||||
const results = await Promise.all(batch.map(async p => {
|
||||
const ok = await checkUrl(p.imageUri);
|
||||
return {
|
||||
name: p.name,
|
||||
url: p.imageUri,
|
||||
ok
|
||||
};
|
||||
}));
|
||||
|
||||
for (const res of results) {
|
||||
if (!res.ok) {
|
||||
console.log(`❌ Failed: ${res.name} -> ${res.url}`);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failedCount === 0) {
|
||||
console.log("✅ All image URLs are reachable!");
|
||||
} else {
|
||||
console.log(`❌ ${failedCount} URLs failed.`);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Using exact string parsing since importing the TS files directly in tsx could have issues if the environment isn't fully set up, but tsx should work. Let's just import them.
|
||||
import { LEXICON_BATCH_1_ENTRIES } from '../constants/lexiconBatch1';
|
||||
import { LEXICON_BATCH_2_ENTRIES } from '../constants/lexiconBatch2';
|
||||
|
||||
const allPlants = [...LEXICON_BATCH_1_ENTRIES, ...LEXICON_BATCH_2_ENTRIES];
|
||||
|
||||
async function checkUrl(url: string): Promise<boolean> {
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'Referer': 'https://commons.wikimedia.org/'
|
||||
};
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET', headers });
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`Checking ${allPlants.length} plants...`);
|
||||
let failedCount = 0;
|
||||
const concurrency = 10;
|
||||
|
||||
for (let i = 0; i < allPlants.length; i += concurrency) {
|
||||
const batch = allPlants.slice(i, i + concurrency);
|
||||
const results = await Promise.all(batch.map(async p => {
|
||||
const ok = await checkUrl(p.imageUri);
|
||||
return {
|
||||
name: p.name,
|
||||
url: p.imageUri,
|
||||
ok
|
||||
};
|
||||
}));
|
||||
|
||||
for (const res of results) {
|
||||
if (!res.ok) {
|
||||
console.log(`❌ Failed: ${res.name} -> ${res.url}`);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failedCount === 0) {
|
||||
console.log("✅ All image URLs are reachable!");
|
||||
} else {
|
||||
console.log(`❌ ${failedCount} URLs failed.`);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
Reference in New Issue
Block a user