Files
Omair Saleh 056c47581f feat: editorial review dashboard + elite-grade pilot batch (5 SKUs)
Ships the second dashboard surface — a Pattern Library + Preview Theatre — that
presents the 4-section PDP pilot batch back to Umar, compliance, and the board
in an editorial format. Adds the full data layer that drives it: 5 source-backed
per-SKU drafts at QA 100/100, 15 competitor PDP semantic extracts, PubMed
evidence packs, EFSA claims library extension, JV brand voice guide, hand-curated
product FAQs, and the Matrixify-ready CSV exports for Lewis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 18:50:09 +08:00

117 lines
4.4 KiB
JavaScript

// Usage: node capture.mjs <loom-url> <out-dir> [intervalSeconds]
// Opens a Loom share/embed URL, plays it, captures a frame every N seconds,
// then builds a contact sheet via ffmpeg for fast scanning.
import { chromium } from 'playwright';
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const rawUrl = process.argv[2];
const outDir = path.resolve(process.argv[3] || './frames');
const intervalSec = parseFloat(process.argv[4] || '3');
if (!rawUrl) {
console.error('Usage: node capture.mjs <loom-url> <out-dir> [intervalSeconds]');
process.exit(1);
}
// Convert /share/<id> -> /embed/<id>?autoplay=true so the real video loads
// instead of the 4-second preview clip Loom shows on share pages.
const m = rawUrl.match(/loom\.com\/(?:share|embed)\/([a-f0-9]+)/i);
const url = m
? `https://www.loom.com/embed/${m[1]}?autoplay=true&hideEmbedTopBar=true&hide_owner=true`
: rawUrl;
fs.mkdirSync(outDir, { recursive: true });
// Clean old frames so the contact sheet doesn't pick them up
for (const f of fs.readdirSync(outDir)) {
if (f.startsWith('frame-') || f === 'contact-sheet.jpg') fs.unlinkSync(path.join(outDir, f));
}
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const page = await ctx.newPage();
console.log(`Opening ${url}`);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForSelector('video', { timeout: 60000 });
// Force-mute and play
await page.evaluate(async () => {
const v = document.querySelector('video');
v.muted = true;
try { await v.play(); } catch {}
});
// Poll until the real video metadata loads (preview clip is ~4s; real video >> 30s usually)
let realDuration = 0, realSrc = '';
for (let i = 0; i < 30; i++) {
await page.waitForTimeout(1000);
const { duration, src, readyState } = await page.evaluate(() => {
const v = document.querySelector('video');
return { duration: v.duration, src: v.currentSrc, readyState: v.readyState };
});
console.log(` poll ${i}: dur=${duration?.toFixed(1)}s rs=${readyState} src=...${(src || '').slice(-50)}`);
realDuration = duration;
realSrc = src;
if (Number.isFinite(duration) && duration > 15 && readyState >= 2) break;
}
if (!Number.isFinite(realDuration) || realDuration <= 0) {
console.error('Could not read video duration. Last src:', realSrc);
await browser.close();
process.exit(2);
}
console.log(`\nUsing duration: ${realDuration.toFixed(1)}s, src: ${realSrc}`);
const steps = Math.floor(realDuration / intervalSec);
console.log(`Capturing ${steps + 1} frames at ${intervalSec}s intervals -> ${outDir}`);
for (let i = 0; i <= steps; i++) {
const t = Math.min(i * intervalSec, realDuration - 0.1);
await page.evaluate(
(t) =>
new Promise((resolve) => {
const v = document.querySelector('video');
v.pause();
const onSeeked = () => { v.removeEventListener('seeked', onSeeked); resolve(); };
v.addEventListener('seeked', onSeeked);
v.currentTime = t;
}),
t,
);
await page.waitForTimeout(250);
const file = path.join(outDir, `frame-${String(i).padStart(3, '0')}.png`);
await page.locator('video').first().screenshot({ path: file });
if ((i + 1) % 5 === 0 || i === steps) console.log(` ${i + 1}/${steps + 1} (t=${t.toFixed(1)}s)`);
}
// Manifest mapping frame index -> seconds
const manifest = Array.from({ length: steps + 1 }, (_, i) => ({
frame: `frame-${String(i).padStart(3, '0')}.png`,
seconds: Math.min(i * intervalSec, realDuration - 0.1),
mmss: (() => {
const t = Math.min(i * intervalSec, realDuration - 0.1);
const mm = Math.floor(t / 60).toString().padStart(2, '0');
const ss = Math.floor(t % 60).toString().padStart(2, '0');
return `${mm}:${ss}`;
})(),
}));
fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
await browser.close();
// Contact sheet via ffmpeg using %03d sequence pattern (Windows-safe, no glob needed)
const cols = 6;
const rows = Math.ceil((steps + 1) / cols);
const sheet = path.join(outDir, 'contact-sheet.jpg');
const inPattern = path.join(outDir, 'frame-%03d.png').replace(/\\/g, '/');
console.log(`\nBuilding contact sheet (${cols}x${rows}): ${sheet}`);
execSync(
`ffmpeg -y -framerate 1 -i "${inPattern}" -vf "scale=480:-1,tile=${cols}x${rows}" "${sheet.replace(/\\/g, '/')}"`,
{ stdio: 'inherit' },
);
console.log('Done.');