056c47581f
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>
117 lines
4.4 KiB
JavaScript
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.');
|