This commit is contained in:
2026-03-29 10:26:38 -05:00
parent 05d4f6e78b
commit b1c99893a6
1628 changed files with 67782 additions and 60143 deletions

View File

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

View File

@@ -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();

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

View 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>

View File

@@ -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();