Docs/Skills and code/skill video-visual-production

.claude/skills/video-visual-production/scripts/assets.js

Code215 lines12.1 KBSkill: video-visual-production

What the file says about itself

V4 Assets: satisfy the asset requests in a beat sheet and write one record per asset. node assets.js --sheet research/<slug>/video/beat-sheet-B1.json [--only id] [--force] Kinds: commons (Wikimedia Commons file with a free licence) and…

#!/usr/bin/env node
// V4 Assets: satisfy the asset requests in a beat sheet and write one record per asset.
//   node assets.js --sheet research/<slug>/video/beat-sheet-B1.json [--only id] [--force]
// Kinds: commons (Wikimedia Commons file with a free licence) and capture (headless Chrome, one passage of an article).
// Every asset gets a JSON record next to it and a row in assets.csv; an asset with no licence row is refused.
const fs = require('fs');
const path = require('path');
const U = require('./lib/util');
const cdp = require('./lib/cdp');

const UA = 'ecosystem-video-pipeline/0.1 (research project; contact via repo owner)';
const FREE = /^(CC0|Public domain|PD[- ]|CC BY(-SA)? [0-9.]+|GODL)/i;
const today = () => new Date().toISOString();

// ---------- Commons ----------
async function commons(a, outDir) {
  const u = new URL('https://commons.wikimedia.org/w/api.php');
  u.search = new URLSearchParams({
    action: 'query', format: 'json', titles: a.title, prop: 'imageinfo', iiprop: 'url|size|mime|sha1|extmetadata',
    iiurlwidth: String(a.width || 1600),
  }).toString();
  const j = await (await fetch(u, { headers: { 'user-agent': UA } })).json();
  const page = Object.values(j.query.pages)[0];
  const ii = page && page.imageinfo && page.imageinfo[0];
  if (!ii) throw new Error('Commons has no file ' + a.title);
  const m = ii.extmetadata || {};
  const val = (k) => String((m[k] && m[k].value) || '').replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').trim();
  const licence = val('LicenseShortName');
  if (!licence || !FREE.test(licence)) throw new Error(`REFUSED ${a.id}: licence "${licence || 'none'}" is not in the allowlist`);
  const url = ii.thumburl || ii.url;
  const res = await fetch(url, { headers: { 'user-agent': UA } });
  if (!res.ok) throw new Error('download failed ' + res.status);
  const buf = Buffer.from(await res.arrayBuffer());
  const ext = ii.mime === 'image/png' ? 'png' : 'jpg';
  const file = path.join(outDir, `${a.id}.${ext}`);
  fs.writeFileSync(file, buf);
  return {
    id: a.id, kind: 'commons', file: path.basename(file), source_url: ii.descriptionurl, download_url: url,
    title: a.title, licence, licence_url: val('LicenseUrl'), author: val('Artist'), credit: val('Credit'),
    original_w: ii.width, original_h: ii.height, retrieved_utc: today(), sha256: U.sha256(buf), bytes: buf.length,
    use: a.use || '', changes: a.changes || 'scaled and cropped for framing',
  };
}

// ---------- Capture (runs inside the page) ----------
function pageFn(args) {
  const norm = (s) => s.replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim();
  for (const el of document.querySelectorAll('*')) {
    const p = getComputedStyle(el).position;
    if (p === 'fixed' || p === 'sticky') el.style.setProperty('display', 'none', 'important');
  }
  const index = (root) => {
    const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
    let n, str = '', last = true;
    const map = [];
    while ((n = w.nextNode())) {
      const t = n.nodeValue;
      for (let i = 0; i < t.length; i++) {
        let ch = t[i];
        if (ch === '‘' || ch === '’') ch = "'"; else if (ch === '“' || ch === '”') ch = '"';
        if (/\s/.test(ch)) { if (last) continue; ch = ' '; last = true; } else last = false;
        str += ch; map.push([n, i]);
      }
    }
    return { str, map };
  };
  const range = (idx, phrase, from = 0) => {
    const p = norm(phrase);
    const i = idx.str.indexOf(p, from);
    if (i < 0) return null;
    const s = idx.map[i], e = idx.map[i + p.length - 1];
    const r = document.createRange();
    r.setStart(s[0], s[1]); r.setEnd(e[0], e[1] + 1);
    return r;
  };
  const A = norm(args.anchor[0]), B = norm(args.anchor[1]);
  let best = null;
  for (const el of document.querySelectorAll('p, div, section, article, li')) {
    const t = norm(el.innerText || '');
    if (t.includes(A) && t.includes(B) && (!best || t.length < best.len)) best = { el, len: t.length };
  }
  if (!best) return { error: 'anchor not found in page text' };
  const el = best.el;
  const idx = index(el);
  const ra = range(idx, args.anchor[0]);
  const rb = range(idx, args.anchor[1], idx.str.indexOf(norm(args.anchor[0])));
  if (!ra || !rb) return { error: 'anchor range failed' };
  const whole = document.createRange();
  whole.setStart(ra.startContainer, ra.startOffset); whole.setEnd(rb.endContainer, rb.endOffset);
  const sx = window.scrollX, sy = window.scrollY;
  const lines = [...whole.getClientRects()].filter((r) => r.width > 1 && r.height > 1);
  if (!lines.length) return { error: 'anchor has no visible rects (hidden or paywalled?)' };
  const box = el.getBoundingClientRect();
  const lineH = Math.max(...lines.map((r) => r.height));
  const pad = 20;
  const top = Math.min(...lines.map((r) => r.top)) - 7;
  const bottom = Math.max(...lines.map((r) => r.bottom)) + 7;
  const left = Math.max(0, Math.min(...lines.map((r) => r.left)) - pad * 1.5), right = Math.max(...lines.map((r) => r.right)) + pad * 1.5;
  const clip = { x: left + sx, y: top + sy, width: right - left, height: bottom - top };
  const hl = {};
  for (const h of args.highlights) {
    const r = range(idx, h);
    hl[h] = r ? [...r.getClientRects()].filter((q) => q.width > 1).map((q) => ({ x: q.left - left, y: q.top - top, w: q.width, h: q.height })) : null;
  }
  const font = getComputedStyle(el).fontSize;
  return { clip, hl, anchorText: whole.toString().replace(/\s+/g, ' ').trim(), blockText: norm(el.innerText).slice(0, 1200), title: document.title, font };
}

async function capture(a, outDir) {
  const chrome = await cdp.launch(9333 + Math.floor(Math.random() * 200));
  const recs = [];
  try {
    await chrome.send('Page.enable');
    await chrome.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 2, mobile: false });
    const loaded = chrome.waitEvent('Page.loadEventFired', 45000);
    await chrome.send('Page.navigate', { url: a.url });
    await loaded.catch(() => null);
    await cdp.sleep(3500);
    for (const c of a.crops) {
      const r = await chrome.send('Runtime.evaluate', {
        expression: '(' + pageFn.toString() + ')(' + JSON.stringify({ anchor: c.anchor, highlights: c.highlights || [] }) + ')',
        returnByValue: true,
      });
      const out = r.result.value;
      if (!out || out.error) throw new Error(c.id + ': ' + ((out && out.error) || 'no result'));
      const shot = await chrome.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { ...out.clip, scale: 1 } });
      const buf = Buffer.from(shot.data, 'base64');
      const w = buf.readUInt32BE(16), h = buf.readUInt32BE(20);
      const ratio = w / out.clip.width;
      const file = path.join(outDir, c.id + '.png');
      fs.writeFileSync(file, buf);
      const boxes = {};
      for (const [k, v] of Object.entries(out.hl)) boxes[k] = v && v.map((b) => ({ x: Math.round(b.x * ratio), y: Math.round(b.y * ratio), w: Math.round(b.w * ratio), h: Math.round(b.h * ratio) }));
      const missing = Object.entries(boxes).filter(([, v]) => !v).map(([k]) => k);
      if (missing.length) throw new Error(c.id + ': highlight phrase(s) not found on page: ' + missing.join(' | '));
      recs.push({
        id: c.id, kind: 'capture', file: path.basename(file), source_url: a.url, outlet: a.outlet, published: a.published,
        retrieved_utc: today(), page_title: out.title, anchor_text: out.anchorText, text_sha256: U.sha256(Buffer.from(out.anchorText)),
        block_font_css_px: out.font, image_w: w, image_h: h, css_to_px: ratio, boxes, sha256: U.sha256(buf), bytes: buf.length,
        captured: true, method: 'headless Chrome via DevTools; fixed and sticky overlays hidden; cropped to the lines of the quoted passage',
        use: c.use || '',
      });
    }
    return recs;
  } finally {
    await chrome.close();
  }
}

// ---------- Quote (typeset on purpose: the source is not a web page we can capture, e.g. a PDF) ----------
function quote(a) {
  return {
    id: a.id, kind: 'capture', captured: false, typeset_by_choice: true, source_url: a.url, outlet: a.outlet, published: a.published,
    fallback_text: a.text, highlights: a.highlights, retrieved_utc: today(), sha256: U.sha256(Buffer.from(a.text)), bytes: Buffer.byteLength(a.text),
    method: 'typeset by choice from a quoted fragment recorded in the fact ledger; not a capture', use: a.use || '',
  };
}

// ---------- main ----------
async function main() {
  const sheetPath = U.fromRoot(U.arg('sheet', ''));
  if (!fs.existsSync(sheetPath)) U.die('--sheet <beat-sheet.json> required');
  const sheet = U.readJSON(sheetPath);
  const only = U.arg('only', null);
  const outDir = path.join(U.buildDir(sheet.slug, sheet.beat), 'assets');
  fs.mkdirSync(outDir, { recursive: true });
  const videoDir = path.dirname(sheetPath);
  const records = [];
  let failed = 0;
  for (const a of sheet.assets || []) {
    const ids = a.crops ? a.crops.map((c) => c.id) : [a.id];
    if (a.kind === 'quote') { U.writeJSON(path.join(outDir, a.id + '.json'), quote(a)); records.push(quote(a)); U.say('ok     ' + a.id + ' (quote, typeset)'); continue; }
    if (only && !ids.includes(only)) continue;
    const cached = ids.map((id) => path.join(outDir, id + '.json'));
    if (!U.flag('force') && cached.every((p) => fs.existsSync(p) && U.readJSON(p).captured !== false)) { cached.forEach((p) => records.push(U.readJSON(p))); U.say('cached ' + ids.join(', ')); continue; }
    try {
      const out = a.kind === 'quote' ? [quote(a)] : a.kind === 'commons' ? [await commons(a, outDir)] : a.kind === 'capture' ? await capture(a, outDir) : (() => { throw new Error('unknown kind ' + a.kind); })();
      for (const rec of out) {
        U.writeJSON(path.join(outDir, rec.id + '.json'), rec);
        records.push(rec);
        U.say('ok     ' + rec.id + ' (' + rec.kind + ') ' + Math.round(rec.bytes / 1024) + ' KB');
      }
    } catch (e) {
      failed++;
      for (const id of ids) {
        const crop = (a.crops || []).find((c) => c.id === id) || {};
        const rec = { id, kind: a.kind, captured: false, fallback_text: crop.fallback_text || null, highlights: crop.highlights || null, error: e.message, source_url: a.url || a.title, outlet: a.outlet, published: a.published, retrieved_utc: today(), fallback: a.fallback ? 'typeset' : null };
        U.writeJSON(path.join(outDir, id + '.json'), rec);
        records.push(rec);
      }
      U.say('FAILED ' + ids.join(', ') + ': ' + e.message + (a.fallback ? '  -> will be typeset' : ''));
      if (!a.fallback) process.exitCode = 1;
    }
  }
  // assets.csv and credits.txt
  const esc = (s) => '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"';
  const rows = [['id', 'kind', 'source_url', 'licence', 'author', 'retrieved_utc', 'sha256', 'file', 'captured', 'use']].concat(
    records.map((r) => [r.id, r.kind, r.source_url, r.licence || (r.kind === 'capture' ? 'quoted passage, short, for review (not licensed)' : ''), r.author || r.outlet || '', r.retrieved_utc, r.sha256 || '', r.file || '', r.captured === false ? 'no' : 'yes', r.use || '']));
  fs.writeFileSync(path.join(videoDir, 'assets-' + sheet.beat + '.csv'), rows.map((r) => r.map(esc).join(',')).join('\n') + '\n');
  const credits = ['Credits for ' + sheet.beat + ' (generated by assets.js)', ''];
  for (const r of records.filter((x) => x.kind === 'commons')) {
    credits.push(`Photo: ${r.title.replace(/^File:/, '')} by ${r.author}, ${r.licence} (${r.licence_url || 'licence page on Commons'}), via Wikimedia Commons (${r.source_url}). Changes: ${r.changes}.`);
  }
  for (const r of records.filter((x) => x.kind === 'capture' && x.typeset_by_choice)) {
    credits.push(`Quoted fragment (typeset): ${r.outlet}, ${r.published} (${r.source_url}).`);
  }
for (const r of records.filter((x) => x.kind === 'capture' && x.captured !== false)) {
    credits.push(`Quoted passage: ${r.outlet}, ${r.published} (${r.source_url}); retrieved ${r.retrieved_utc.slice(0, 10)}.`);
  }
  credits.push('', 'Fonts: Poppins (SIL Open Font License 1.1). Sound effects and the placeholder bed are synthesised by the pipeline.');
  fs.writeFileSync(path.join(videoDir, 'credits-' + sheet.beat + '.txt'), credits.join('\n') + '\n');
  U.say(`assets: ${records.length} records, ${failed} failed; build folder ${outDir}`);
}
main().catch((e) => U.die(e.stack || e.message));

Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.