scripts/source.mjs
scripts/source.mjsBrowse 27 files
5,909 tokens
21,546 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env node2/**3 * source.mjs — find and download licence-clean assets you don't have to generate.4 *5 * Generation is not always the right tool. You cannot generate a glTF mesh, a 16-bit HDRI6 * environment map, or a correctly-hinted variable font — and CC0 versions of all three exist at7 * production quality. This fetches those, records the licence for every file, and refuses to8 * hand you anything whose terms it can't state.9 *10 * Usage:11 * node scripts/source.mjs <kind> "<query>" [options]12 *13 * kind = hdri | model | texture | icon | font | image | video14 *15 * Options:16 * --limit N how many assets (default 5, cap 20)17 * --out DIR output dir (default assets/sourced)18 * --res 1k|2k|4k|8k resolution for hdri/model/texture (default 2k)19 * --list print a shortlist to stdout; download nothing and write no ledger20 *21 * Writes DIR/ASSETS-SOURCED.md — the licence ledger — on every real fetch. Read it before you ship.22 *23 * Sources (all no-key, verified live 2026-08):24 * hdri/model/texture Poly Haven CC0 no attribution required25 * icon Iconify per set licence reported per icon (MIT/Apache/CC-BY/OFL)26 * font Google Fonts OFL/Apache free for commercial use, no attribution in UI27 * image Openverse CC-BY/BY-SA ATTRIBUTION REQUIRED — ledger carries the string28 * video Coverr free-use no redistribution; see the taste warning below29 *30 * The video caveat is not legal boilerplate. Stock footage is generic by construction — the same31 * drone shot is on three thousand landing pages. Auteur's entire thesis is committed, specific32 * assets, so sourced video is allowed as an ambient loop, a texture, or a fallback, and is never33 * the peak scene. If the wow moment is stock, there is no wow moment.34 */35 36import { mkdir, writeFile, readFile } from 'fs/promises';37import { existsSync } from 'fs';38import { resolve, join, dirname } from 'path';39import { tmpdir } from 'os';40 41const args = process.argv.slice(2);42const KINDS = ['hdri', 'model', 'texture', 'icon', 'font', 'image', 'video'];43if (!args.length || args.includes('--help') || !KINDS.includes(args[0])) {44 console.log(`source.mjs — licence-clean asset sourcing45 46 node scripts/source.mjs <kind> "<query>" [--limit 5] [--out assets/sourced] [--res 2k] [--list]47 48 kind: ${KINDS.join(' | ')}49 50 hdri|model|texture Poly Haven, CC0 — what generation cannot make51 icon Iconify, licence per set52 font Google Fonts, OFL/Apache53 image Openverse, CC — attribution REQUIRED, ledger carries it54 video Coverr, free-use — ambient/fallback only, never the peak`);55 process.exit(args.length && !KINDS.includes(args[0]) ? 1 : 0);56}57 58const kind = args[0];59const get = (f, d) => { const i = args.indexOf(f); return i !== -1 ? args[i + 1] : d; };60const has = f => args.includes(f);61const query = (args[1] && !args[1].startsWith('--') ? args[1] : '').trim();62 63const limit = Math.min(parseInt(get('--limit', '5'), 10) || 5, 20);64const outDir = resolve(get('--out', 'assets/sourced'));65const res = get('--res', '2k');66const listOnly = has('--list');67 68if (!query) { console.error(`[source] no query. e.g. node scripts/source.mjs ${kind} "coastal dusk"`); process.exit(1); }69 70const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';71const H = { 'User-Agent': UA, Accept: '*/*' };72 73async function json(url) {74 const r = await fetch(url, { headers: H, signal: AbortSignal.timeout(45_000) });75 if (!r.ok) throw new Error(`${r.status} ${url.slice(0, 70)}`);76 return r.json();77}78async function text(url) {79 const r = await fetch(url, { headers: H, signal: AbortSignal.timeout(45_000) });80 if (!r.ok) throw new Error(`${r.status} ${url.slice(0, 70)}`);81 return r.text();82}83// Keep pure-digit tokens: Poly Haven names most of its library thing_01 … thing_09, so dropping84// short words made a specific asset unaddressable ("studio small 03" silently returned 09).85const words = q => q.toLowerCase().split(/[\s,]+/).filter(w => w.length > 2 || /^\d+$/.test(w));86 87/** score a Poly Haven asset against the query across name / tags / categories / attributes */88function phScore(slug, a, ws) {89 const hay = [slug, a.name, ...(a.tags || []), ...(a.categories || []), ...Object.values(a.attributes || {})]90 .join(' ').toLowerCase();91 return ws.reduce((n, w) => n + (hay.includes(w) ? 1 : 0), 0);92}93 94// ---------------------------------------------------------------- Poly Haven (CC0)95async function polyhaven(type) {96 const all = await json(`https://api.polyhaven.com/assets?t=${type}`);97 const ws = words(query);98 const ranked = Object.entries(all)99 .map(([slug, a]) => ({ slug, a, score: phScore(slug, a, ws) }))100 .filter(x => x.score > 0)101 .sort((x, y) => y.score - x.score || (y.a.download_count || 0) - (x.a.download_count || 0))102 .slice(0, limit);103 104 if (!ranked.length) { console.error(`[source] Poly Haven has no ${type} matching "${query}". Try broader words (its tags are physical: "coast", "dusk", "concrete", "chair").`); return []; }105 106 const out = [];107 for (const { slug, a, score } of ranked) {108 const files = await json(`https://api.polyhaven.com/files/${slug}`).catch(() => null);109 if (!files) continue;110 const item = {111 id: slug,112 title: a.name,113 licence: 'CC0',114 attribution: null, // CC0: none required115 credit: Object.keys(a.authors || {}).join(', '),116 landing: `https://polyhaven.com/a/${slug}`,117 note: [a.categories?.join('/'), Object.entries(a.attributes || {}).map(([k, v]) => `${k}=${v}`).join(' ')].filter(Boolean).join(' · '),118 score, files: [],119 };120 121 if (type === 'hdris') {122 const pick = files.hdri?.[res] || files.hdri?.['2k'] || files.hdri?.['1k'];123 const f = pick?.hdr || pick?.exr;124 if (f) item.files.push({ url: f.url, path: `${slug}_${res}.${f.url.split('.').pop()}`, size: f.size });125 } else if (type === 'models') {126 const g = files.gltf?.[res]?.gltf || files.gltf?.['2k']?.gltf || files.gltf?.['1k']?.gltf;127 if (g) {128 item.files.push({ url: g.url, path: `${slug}/${g.url.split('/').pop()}`, size: g.size });129 // a glTF without its .bin and textures is a broken file, not a smaller one130 for (const [rel, inc] of Object.entries(g.include || {})) item.files.push({ url: inc.url, path: `${slug}/${rel}`, size: inc.size });131 }132 } else { // textures133 const maps = { Diffuse: 'diff', nor_gl: 'nor', Rough: 'rough', arm: 'arm', Displacement: 'disp' };134 for (const [key, tag] of Object.entries(maps)) {135 const f = files[key]?.[res]?.jpg || files[key]?.['2k']?.jpg || files[key]?.['1k']?.jpg;136 if (f) item.files.push({ url: f.url, path: `${slug}/${slug}_${tag}_${res}.jpg`, size: f.size });137 }138 }139 if (item.files.length) out.push(item);140 }141 return out;142}143 144// ---------------------------------------------------------------- Iconify145async function icons() {146 const collections = await json('https://api.iconify.design/collections').catch(() => ({}));147 // multi-word queries return nothing; the index is single-keyword148 const terms = words(query).length ? words(query) : [query];149 const seen = new Set(), out = [];150 for (const term of terms) {151 if (out.length >= limit) break;152 const r = await json(`https://api.iconify.design/search?query=${encodeURIComponent(term)}&limit=${limit * 4}`).catch(() => null);153 for (const id of r?.icons || []) {154 if (out.length >= limit || seen.has(id)) continue;155 seen.add(id);156 const [set, name] = id.split(':');157 const c = collections[set] || {};158 out.push({159 id, title: `${c.name || set} / ${name}`,160 licence: c.license?.title || 'unknown',161 attribution: /CC.?BY/i.test(c.license?.title || '') ? `Icon "${name}" from ${c.name || set} — ${c.license?.title}` : null,162 credit: c.author?.name || '', landing: c.license?.url || `https://icon-sets.iconify.design/${set}/`,163 note: `set has ${c.total || '?'} icons`,164 files: [{ url: `https://api.iconify.design/${set}/${name}.svg?height=64`, path: `${set}-${name}.svg` }],165 });166 }167 }168 if (!out.length) console.error('[source] Iconify found nothing — its index is single-keyword; try one noun ("bottle", not "whisky bottle").');169 return out;170}171 172// ---------------------------------------------------------------- Google Fonts173const GF_BANNED = ['Inter', 'Space Grotesk', 'Instrument Serif', 'Playfair Display'];174async function fonts() {175 // Not inside --out: that just relocates 1.5MB from your cwd into your shipped assets tree.176 const cacheFile = join(tmpdir(), 'auteur-gf-metadata.json');177 let meta;178 if (existsSync(cacheFile)) meta = JSON.parse(await readFile(cacheFile, 'utf8'));179 else {180 meta = JSON.parse((await text('https://fonts.google.com/metadata/fonts')).replace(/^\)\]\}'\n?/, ''));181 // --list promises to write nothing; a 2.6MB metadata cache is still something.182 if (!listOnly) {183 await mkdir(outDir, { recursive: true });184 await writeFile(cacheFile, JSON.stringify(meta)); // fetch it once per project185 }186 }187 // Google's own category names overlap ("Sans Serif" contains "serif"), so a plain substring188 // match on "serif" happily returns Noto Sans Display. Resolve the category first, filter on it,189 // and only then rank the leftover words.190 const CAT = { serif: 'Serif', slab: 'Serif', sans: 'Sans Serif', grotesque: 'Sans Serif', grotesk: 'Sans Serif', mono: 'Monospace', monospace: 'Monospace', display: 'Display', handwriting: 'Handwriting', script: 'Handwriting' };191 const ws = words(query);192 const wantSans = ws.some(w => w === 'sans' || w.startsWith('grotes'));193 const wantCat = wantSans ? 'Sans Serif' : (ws.map(w => CAT[w]).find(Boolean) || null);194 const wantVariable = ws.some(w => w === 'variable' || w === 'vf');195 const rest = ws.filter(w => !CAT[w] && w !== 'variable' && w !== 'vf');196 197 // "Display", "Mono" and "Sans" are Google categories AND parts of real family names, so a query198 // like "Playfair Display" was read as a category and filtered its own family out. If the category199 // reading finds nothing, the word was part of a name — drop the constraint and search again.200 const inPool = cat => (meta.familyMetadataList || []).filter(f => (!cat || f.category === cat) && (!wantVariable || (f.axes || []).length));201 let pool = inPool(wantCat);202 if (wantCat && !pool.some(f => rest.some(w => f.family.toLowerCase().includes(w)))) {203 const byName = inPool(null).filter(f => rest.some(w => f.family.toLowerCase().includes(w)));204 if (byName.length) pool = byName;205 }206 const ranked = pool207 .map(f => {208 const name = f.family.toLowerCase();209 // A family must actually match something before it is a candidate. Scoring alone did not210 // enforce that — the "keep banned families listed but demoted" filter let every family211 // through, so `font "Bodoni Moda"` downloaded Roboto.212 const hits = rest.reduce((n, w) => n + (name.includes(w) ? 1 : 0), 0);213 const matched = hits > 0 || (wantCat && !rest.length);214 let score = (wantCat ? 1 : 0) + hits * 2;215 if (name === rest.join(' ')) score += 3; // exact family name wins outright216 if ((f.axes || []).length) score += 0.5; // variable axes are worth having217 // Google's popularity ranking is exactly what makes a font the AI default; a banned family218 // must never be the top suggestion, but it stays listed (flagged) rather than hidden.219 if (GF_BANNED.includes(f.family)) score -= 100;220 return { f, score, matched };221 })222 .filter(x => x.matched)223 .sort((a, b) => b.score - a.score || (a.f.popularity || 9999) - (b.f.popularity || 9999))224 .slice(0, limit);225 if (!ranked.length) console.error(`[source] no Google font matched. Say the shape: "serif variable", "mono", "display grotesk".`);226 227 const out = ranked.map(({ f }) => {228 const axes = (f.axes || []).map(a => `${a.tag} ${a.min}–${a.max}`).join(', ');229 const banned = GF_BANNED.includes(f.family);230 const spec = (f.axes || []).find(a => a.tag === 'wght')231 ? `${f.family.replace(/ /g, '+')}:wght@${(f.axes.find(a => a.tag === 'wght')).min}..${(f.axes.find(a => a.tag === 'wght')).max}`232 : f.family.replace(/ /g, '+');233 return {234 id: f.family, title: `${f.family} (${f.category})`,235 licence: 'OFL / Apache-2.0 (Google Fonts)', attribution: null, credit: '',236 landing: `https://fonts.google.com/specimen/${f.family.replace(/ /g, '+')}`,237 note: `${banned ? '⛔ ON THE AUTEUR BAN LIST — pick something else. ' : ''}popularity #${f.popularity ?? '?'} · weights ${Object.keys(f.fonts || {}).length}${axes ? ` · variable: ${axes}` : ' · static only'}`,238 css: `https://fonts.googleapis.com/css2?family=${spec}&display=swap`,239 files: [], // filled below with the real woff2240 family: f.family,241 };242 });243 244 // Most of what this skill builds must have zero third-party origins, so linking245 // fonts.googleapis.com is not an answer. Resolve the CSS with a browser UA (an old UA gets you246 // ttf), take the latin block, and fetch the actual woff2 so the page can self-host.247 for (const it of out) {248 try {249 const css = await text(it.css);250 // Google emits one @font-face per unicode subset, each preceded by a `/* latin */` comment.251 const latin = (css.match(/\/\*\s*latin\s*\*\/([\s\S]*?)(?=\/\*|$)/) || [, ''])[1] || css;252 const url = (latin.match(/url\((https:\/\/[^)]+\.woff2)\)/) || [])[1];253 if (url) {254 it.files.push({ url, path: `${it.family.replace(/ /g, '')}.woff2` });255 it.selfHost = `@font-face{font-family:'${it.family}';src:url('fonts/${it.family.replace(/ /g, '')}.woff2') format('woff2');font-display:swap;font-weight:${(latin.match(/font-weight:\s*([^;]+)/) || [, '400'])[1].trim()};font-style:normal}`;256 }257 } catch { /* leave it link-only; the ledger will show no file */ }258 }259 return out;260}261 262// ---------------------------------------------------------------- Openverse (CC)263async function images() {264 // commercial,modification filter is mandatory: the unfiltered index is full of by-nc-nd265 const r = await json(`https://api.openverse.org/v1/images/?q=${encodeURIComponent(query)}&page_size=${limit}&license_type=commercial,modification`);266 return (r.results || []).map(x => ({267 id: x.id, title: x.title || x.id,268 licence: `CC ${(x.license || '').toUpperCase()} ${x.license_version || ''}`.trim(),269 attribution: x.attribution || `"${x.title}" by ${x.creator} is licensed under CC ${(x.license || '').toUpperCase()} ${x.license_version || ''}`,270 credit: x.creator || '', landing: x.foreign_landing_url, note: `${x.width}×${x.height} ${x.filetype || ''} · ${x.provider || ''}`,271 files: [{ url: x.url, path: `${(x.title || x.id).replace(/[^\w-]+/g, '_').slice(0, 40)}.${x.filetype || 'jpg'}` }],272 }));273}274 275// ---------------------------------------------------------------- stock video (Coverr)276// Mixkit is deliberately not wired in: its search page lists mp4s, but its CDN throttles a277// download to ~15KB in 90s, so every fetch dies on timeout. One working source beats two listed.278const RES_RANK = { '2160p': 4, '1080p': 3, '720p': 2, '360p': 1 };279async function videos() {280 let html;281 try { html = await text(`https://coverr.co/s?q=${encodeURIComponent(query)}`); }282 catch (e) { console.error(`[source] coverr: ${e.message}`); return []; }283 284 // Keep only the curated library (slug `coverr-…`): `user-ai-generation-…` are user uploads285 // with unclear provenance, and cdn-staging is not a URL to build on.286 const best = new Map();287 for (const u of new Set(html.match(/https:\/\/cdn\.coverr\.co\/videos\/coverr-[^"' ]+\.mp4/g) || [])) {288 const [, slug, res] = u.match(/videos\/([^/]+)\/(\d+p)\.mp4$/) || [];289 if (!slug) continue;290 if (!best.has(slug) || RES_RANK[res] > RES_RANK[best.get(slug).res]) best.set(slug, { u, res });291 }292 const out = [...best.entries()].slice(0, limit).map(([slug, { u, res }]) => ({293 id: slug,294 title: slug.replace(/^coverr-/, '').replace(/-\d+$/, '').replace(/-/g, ' '),295 licence: 'Coverr License — free commercial use, redistribution prohibited',296 attribution: null, credit: 'Coverr',297 landing: `https://coverr.co/videos/${slug}`,298 note: `${res} · ⚠ stock — ambient loop / texture / fallback only, never the peak scene`,299 files: [{ url: u, path: `${slug}-${res}.mp4` }],300 }));301 if (!out.length) console.error('[source] no stock video matched — Coverr indexes simple nouns ("snow", "rain", "city", "smoke").');302 return out;303}304 305// ---------------------------------------------------------------- run306const fetchers = { hdri: () => polyhaven('hdris'), model: () => polyhaven('models'), texture: () => polyhaven('textures'), icon: icons, font: fonts, image: images, video: videos };307let items = [];308try { items = await fetchers[kind](); }309catch (e) { console.error(`[source] ${kind} lookup failed: ${e.message}`); process.exit(1); }310 311if (!items.length) { console.error('[source] nothing found.'); process.exit(1); }312 313// --list is a read loop: print the shortlist and touch nothing. Appending every exploratory314// search to the ledger buried three shipped assets under 260 lines of search history.315if (listOnly) {316 console.log(`317${items.length} match(es) for "${query}" (${kind}):318`);319 for (const it of items) {320 console.log(` ${it.id}`);321 console.log(` ${it.title} · ${it.licence}${it.credit ? ` · ${it.credit}` : ''}`);322 if (it.note) console.log(` ${it.note}`);323 if (it.css) console.log(` css: ${it.css}`);324 console.log(` ${it.landing}`);325 if (it.files.length) console.log(` ${it.files.length} file(s), ${Math.round(it.files.reduce((n, f) => n + (f.size || 0), 0) / 1024)}KB`);326 console.log('');327 }328 console.log(`Nothing downloaded and no ledger written (--list). Re-run without --list to fetch.`);329 process.exit(0);330}331 332await mkdir(outDir, { recursive: true });333let bytes = 0, files = 0;334{335 for (const it of items) {336 for (const f of it.files) {337 const dest = join(outDir, kind, f.path);338 if (existsSync(dest)) { f.skipped = true; continue; } // never re-download a cached master339 try {340 const r = await fetch(f.url, { headers: { ...H, Referer: it.landing || '' }, signal: AbortSignal.timeout(120_000) });341 if (!r.ok) { f.error = `HTTP ${r.status}`; continue; }342 const buf = Buffer.from(await r.arrayBuffer());343 await mkdir(dirname(dest), { recursive: true });344 await writeFile(dest, buf);345 f.written = buf.length; bytes += buf.length; files++;346 } catch (e) { f.error = e.message.split('\n')[0]; }347 }348 process.stderr.write(`[source] ${it.id} — ${it.files.filter(f => f.written || f.skipped).length}/${it.files.length} files\n`);349 }350}351 352// ---------------------------------------------------------------- ledger353const ledgerPath = join(outDir, 'ASSETS-SOURCED.md');354const prev = existsSync(ledgerPath) ? await readFile(ledgerPath, 'utf8') : `# ASSETS-SOURCED — licence ledger355 356Every file below came from someone else. This file is the record of what you may do with it.357**Before shipping:** every entry with an "attribution required" line must have that credit visible358on the site (a credits block in the footer is fine). CC0 and OFL entries need nothing.359 360Sourced assets are for production use; the moodboard in \`design/\` is not — do not confuse them.361`;362const block = [363 '',364 `## ${kind} — "${query}" · ${new Date().toISOString().slice(0, 10)}${listOnly ? ' (list only, nothing downloaded)' : ''}`,365 '',366 ...items.flatMap(it => {367 const l = [`### ${it.title}`];368 l.push(`- licence: **${it.licence}**${it.credit ? ` · by ${it.credit}` : ''}`);369 if (it.attribution) l.push(`- ⚠ **attribution required** — put this on the page: \`${it.attribution}\``);370 l.push(`- source: ${it.landing}`);371 if (it.note) l.push(`- ${it.note}`);372 if (it.selfHost) l.push(`- self-host (no third-party origin): \`${it.selfHost}\``);373 else if (it.css) l.push(`- css: \`<link rel="stylesheet" href="${it.css}">\` (woff2 could not be resolved — this links a third-party origin)`);374 for (const f of it.files) l.push(`- file: \`${kind}/${f.path}\`${f.written ? ` (${Math.round(f.written / 1024)}KB)` : f.skipped ? ' (cached)' : f.error ? ` — FAILED ${f.error}` : ' (not downloaded)'}`);375 return [...l, ''];376 }),377].join('\n');378await writeFile(ledgerPath, prev + block, 'utf8');379 380console.log(`\n=== source (${kind}) ===`);381console.log(`found : ${items.length} for "${query}"`);382const cached = items.reduce((n, it) => n + it.files.filter(f => f.skipped).length, 0);383if (!listOnly) console.log(`written : ${files} new${cached ? ` + ${cached} already on disk` : ''}, ${(bytes / 1048576).toFixed(1)}MB → ${join(outDir, kind)}`);384const needsCredit = items.filter(i => i.attribution);385if (needsCredit.length) console.log(`⚠ credit: ${needsCredit.length} asset(s) REQUIRE visible attribution — see the ledger`);386if (kind === 'video') console.log(`⚠ stock video is ambient/fallback material. If your peak scene is stock, you have no peak scene.`);387console.log(`ledger : ${ledgerPath}`);388process.exit(0);389