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>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
[
|
||||
{
|
||||
"frame": "frame-000.png",
|
||||
"seconds": 0,
|
||||
"mmss": "00:00"
|
||||
},
|
||||
{
|
||||
"frame": "frame-001.png",
|
||||
"seconds": 3,
|
||||
"mmss": "00:03"
|
||||
},
|
||||
{
|
||||
"frame": "frame-002.png",
|
||||
"seconds": 6,
|
||||
"mmss": "00:06"
|
||||
},
|
||||
{
|
||||
"frame": "frame-003.png",
|
||||
"seconds": 9,
|
||||
"mmss": "00:09"
|
||||
},
|
||||
{
|
||||
"frame": "frame-004.png",
|
||||
"seconds": 12,
|
||||
"mmss": "00:12"
|
||||
},
|
||||
{
|
||||
"frame": "frame-005.png",
|
||||
"seconds": 15,
|
||||
"mmss": "00:15"
|
||||
},
|
||||
{
|
||||
"frame": "frame-006.png",
|
||||
"seconds": 18,
|
||||
"mmss": "00:18"
|
||||
},
|
||||
{
|
||||
"frame": "frame-007.png",
|
||||
"seconds": 21,
|
||||
"mmss": "00:21"
|
||||
},
|
||||
{
|
||||
"frame": "frame-008.png",
|
||||
"seconds": 24,
|
||||
"mmss": "00:24"
|
||||
},
|
||||
{
|
||||
"frame": "frame-009.png",
|
||||
"seconds": 27,
|
||||
"mmss": "00:27"
|
||||
},
|
||||
{
|
||||
"frame": "frame-010.png",
|
||||
"seconds": 30,
|
||||
"mmss": "00:30"
|
||||
},
|
||||
{
|
||||
"frame": "frame-011.png",
|
||||
"seconds": 33,
|
||||
"mmss": "00:33"
|
||||
},
|
||||
{
|
||||
"frame": "frame-012.png",
|
||||
"seconds": 36,
|
||||
"mmss": "00:36"
|
||||
},
|
||||
{
|
||||
"frame": "frame-013.png",
|
||||
"seconds": 39,
|
||||
"mmss": "00:39"
|
||||
},
|
||||
{
|
||||
"frame": "frame-014.png",
|
||||
"seconds": 42,
|
||||
"mmss": "00:42"
|
||||
},
|
||||
{
|
||||
"frame": "frame-015.png",
|
||||
"seconds": 45,
|
||||
"mmss": "00:45"
|
||||
},
|
||||
{
|
||||
"frame": "frame-016.png",
|
||||
"seconds": 48,
|
||||
"mmss": "00:48"
|
||||
},
|
||||
{
|
||||
"frame": "frame-017.png",
|
||||
"seconds": 51,
|
||||
"mmss": "00:51"
|
||||
},
|
||||
{
|
||||
"frame": "frame-018.png",
|
||||
"seconds": 54,
|
||||
"mmss": "00:54"
|
||||
},
|
||||
{
|
||||
"frame": "frame-019.png",
|
||||
"seconds": 57,
|
||||
"mmss": "00:57"
|
||||
},
|
||||
{
|
||||
"frame": "frame-020.png",
|
||||
"seconds": 60,
|
||||
"mmss": "01:00"
|
||||
},
|
||||
{
|
||||
"frame": "frame-021.png",
|
||||
"seconds": 63,
|
||||
"mmss": "01:03"
|
||||
},
|
||||
{
|
||||
"frame": "frame-022.png",
|
||||
"seconds": 66,
|
||||
"mmss": "01:06"
|
||||
},
|
||||
{
|
||||
"frame": "frame-023.png",
|
||||
"seconds": 69,
|
||||
"mmss": "01:09"
|
||||
},
|
||||
{
|
||||
"frame": "frame-024.png",
|
||||
"seconds": 72,
|
||||
"mmss": "01:12"
|
||||
},
|
||||
{
|
||||
"frame": "frame-025.png",
|
||||
"seconds": 75,
|
||||
"mmss": "01:15"
|
||||
},
|
||||
{
|
||||
"frame": "frame-026.png",
|
||||
"seconds": 78,
|
||||
"mmss": "01:18"
|
||||
},
|
||||
{
|
||||
"frame": "frame-027.png",
|
||||
"seconds": 81,
|
||||
"mmss": "01:21"
|
||||
},
|
||||
{
|
||||
"frame": "frame-028.png",
|
||||
"seconds": 84,
|
||||
"mmss": "01:24"
|
||||
},
|
||||
{
|
||||
"frame": "frame-029.png",
|
||||
"seconds": 87,
|
||||
"mmss": "01:27"
|
||||
},
|
||||
{
|
||||
"frame": "frame-030.png",
|
||||
"seconds": 90,
|
||||
"mmss": "01:30"
|
||||
},
|
||||
{
|
||||
"frame": "frame-031.png",
|
||||
"seconds": 93,
|
||||
"mmss": "01:33"
|
||||
},
|
||||
{
|
||||
"frame": "frame-032.png",
|
||||
"seconds": 96,
|
||||
"mmss": "01:36"
|
||||
},
|
||||
{
|
||||
"frame": "frame-033.png",
|
||||
"seconds": 99,
|
||||
"mmss": "01:39"
|
||||
},
|
||||
{
|
||||
"frame": "frame-034.png",
|
||||
"seconds": 102,
|
||||
"mmss": "01:42"
|
||||
},
|
||||
{
|
||||
"frame": "frame-035.png",
|
||||
"seconds": 105,
|
||||
"mmss": "01:45"
|
||||
},
|
||||
{
|
||||
"frame": "frame-036.png",
|
||||
"seconds": 108,
|
||||
"mmss": "01:48"
|
||||
},
|
||||
{
|
||||
"frame": "frame-037.png",
|
||||
"seconds": 111,
|
||||
"mmss": "01:51"
|
||||
},
|
||||
{
|
||||
"frame": "frame-038.png",
|
||||
"seconds": 114,
|
||||
"mmss": "01:54"
|
||||
},
|
||||
{
|
||||
"frame": "frame-039.png",
|
||||
"seconds": 117,
|
||||
"mmss": "01:57"
|
||||
},
|
||||
{
|
||||
"frame": "frame-040.png",
|
||||
"seconds": 120,
|
||||
"mmss": "02:00"
|
||||
},
|
||||
{
|
||||
"frame": "frame-041.png",
|
||||
"seconds": 123,
|
||||
"mmss": "02:03"
|
||||
},
|
||||
{
|
||||
"frame": "frame-042.png",
|
||||
"seconds": 126,
|
||||
"mmss": "02:06"
|
||||
},
|
||||
{
|
||||
"frame": "frame-043.png",
|
||||
"seconds": 129,
|
||||
"mmss": "02:09"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"phrases":[],"schemaVersion":"1.1.3"}
|
||||
@@ -0,0 +1,272 @@
|
||||
[
|
||||
{
|
||||
"frame": "frame-000.png",
|
||||
"seconds": 0,
|
||||
"mmss": "00:00"
|
||||
},
|
||||
{
|
||||
"frame": "frame-001.png",
|
||||
"seconds": 3,
|
||||
"mmss": "00:03"
|
||||
},
|
||||
{
|
||||
"frame": "frame-002.png",
|
||||
"seconds": 6,
|
||||
"mmss": "00:06"
|
||||
},
|
||||
{
|
||||
"frame": "frame-003.png",
|
||||
"seconds": 9,
|
||||
"mmss": "00:09"
|
||||
},
|
||||
{
|
||||
"frame": "frame-004.png",
|
||||
"seconds": 12,
|
||||
"mmss": "00:12"
|
||||
},
|
||||
{
|
||||
"frame": "frame-005.png",
|
||||
"seconds": 15,
|
||||
"mmss": "00:15"
|
||||
},
|
||||
{
|
||||
"frame": "frame-006.png",
|
||||
"seconds": 18,
|
||||
"mmss": "00:18"
|
||||
},
|
||||
{
|
||||
"frame": "frame-007.png",
|
||||
"seconds": 21,
|
||||
"mmss": "00:21"
|
||||
},
|
||||
{
|
||||
"frame": "frame-008.png",
|
||||
"seconds": 24,
|
||||
"mmss": "00:24"
|
||||
},
|
||||
{
|
||||
"frame": "frame-009.png",
|
||||
"seconds": 27,
|
||||
"mmss": "00:27"
|
||||
},
|
||||
{
|
||||
"frame": "frame-010.png",
|
||||
"seconds": 30,
|
||||
"mmss": "00:30"
|
||||
},
|
||||
{
|
||||
"frame": "frame-011.png",
|
||||
"seconds": 33,
|
||||
"mmss": "00:33"
|
||||
},
|
||||
{
|
||||
"frame": "frame-012.png",
|
||||
"seconds": 36,
|
||||
"mmss": "00:36"
|
||||
},
|
||||
{
|
||||
"frame": "frame-013.png",
|
||||
"seconds": 39,
|
||||
"mmss": "00:39"
|
||||
},
|
||||
{
|
||||
"frame": "frame-014.png",
|
||||
"seconds": 42,
|
||||
"mmss": "00:42"
|
||||
},
|
||||
{
|
||||
"frame": "frame-015.png",
|
||||
"seconds": 45,
|
||||
"mmss": "00:45"
|
||||
},
|
||||
{
|
||||
"frame": "frame-016.png",
|
||||
"seconds": 48,
|
||||
"mmss": "00:48"
|
||||
},
|
||||
{
|
||||
"frame": "frame-017.png",
|
||||
"seconds": 51,
|
||||
"mmss": "00:51"
|
||||
},
|
||||
{
|
||||
"frame": "frame-018.png",
|
||||
"seconds": 54,
|
||||
"mmss": "00:54"
|
||||
},
|
||||
{
|
||||
"frame": "frame-019.png",
|
||||
"seconds": 57,
|
||||
"mmss": "00:57"
|
||||
},
|
||||
{
|
||||
"frame": "frame-020.png",
|
||||
"seconds": 60,
|
||||
"mmss": "01:00"
|
||||
},
|
||||
{
|
||||
"frame": "frame-021.png",
|
||||
"seconds": 63,
|
||||
"mmss": "01:03"
|
||||
},
|
||||
{
|
||||
"frame": "frame-022.png",
|
||||
"seconds": 66,
|
||||
"mmss": "01:06"
|
||||
},
|
||||
{
|
||||
"frame": "frame-023.png",
|
||||
"seconds": 69,
|
||||
"mmss": "01:09"
|
||||
},
|
||||
{
|
||||
"frame": "frame-024.png",
|
||||
"seconds": 72,
|
||||
"mmss": "01:12"
|
||||
},
|
||||
{
|
||||
"frame": "frame-025.png",
|
||||
"seconds": 75,
|
||||
"mmss": "01:15"
|
||||
},
|
||||
{
|
||||
"frame": "frame-026.png",
|
||||
"seconds": 78,
|
||||
"mmss": "01:18"
|
||||
},
|
||||
{
|
||||
"frame": "frame-027.png",
|
||||
"seconds": 81,
|
||||
"mmss": "01:21"
|
||||
},
|
||||
{
|
||||
"frame": "frame-028.png",
|
||||
"seconds": 84,
|
||||
"mmss": "01:24"
|
||||
},
|
||||
{
|
||||
"frame": "frame-029.png",
|
||||
"seconds": 87,
|
||||
"mmss": "01:27"
|
||||
},
|
||||
{
|
||||
"frame": "frame-030.png",
|
||||
"seconds": 90,
|
||||
"mmss": "01:30"
|
||||
},
|
||||
{
|
||||
"frame": "frame-031.png",
|
||||
"seconds": 93,
|
||||
"mmss": "01:33"
|
||||
},
|
||||
{
|
||||
"frame": "frame-032.png",
|
||||
"seconds": 96,
|
||||
"mmss": "01:36"
|
||||
},
|
||||
{
|
||||
"frame": "frame-033.png",
|
||||
"seconds": 99,
|
||||
"mmss": "01:39"
|
||||
},
|
||||
{
|
||||
"frame": "frame-034.png",
|
||||
"seconds": 102,
|
||||
"mmss": "01:42"
|
||||
},
|
||||
{
|
||||
"frame": "frame-035.png",
|
||||
"seconds": 105,
|
||||
"mmss": "01:45"
|
||||
},
|
||||
{
|
||||
"frame": "frame-036.png",
|
||||
"seconds": 108,
|
||||
"mmss": "01:48"
|
||||
},
|
||||
{
|
||||
"frame": "frame-037.png",
|
||||
"seconds": 111,
|
||||
"mmss": "01:51"
|
||||
},
|
||||
{
|
||||
"frame": "frame-038.png",
|
||||
"seconds": 114,
|
||||
"mmss": "01:54"
|
||||
},
|
||||
{
|
||||
"frame": "frame-039.png",
|
||||
"seconds": 117,
|
||||
"mmss": "01:57"
|
||||
},
|
||||
{
|
||||
"frame": "frame-040.png",
|
||||
"seconds": 120,
|
||||
"mmss": "02:00"
|
||||
},
|
||||
{
|
||||
"frame": "frame-041.png",
|
||||
"seconds": 123,
|
||||
"mmss": "02:03"
|
||||
},
|
||||
{
|
||||
"frame": "frame-042.png",
|
||||
"seconds": 126,
|
||||
"mmss": "02:06"
|
||||
},
|
||||
{
|
||||
"frame": "frame-043.png",
|
||||
"seconds": 129,
|
||||
"mmss": "02:09"
|
||||
},
|
||||
{
|
||||
"frame": "frame-044.png",
|
||||
"seconds": 132,
|
||||
"mmss": "02:12"
|
||||
},
|
||||
{
|
||||
"frame": "frame-045.png",
|
||||
"seconds": 135,
|
||||
"mmss": "02:15"
|
||||
},
|
||||
{
|
||||
"frame": "frame-046.png",
|
||||
"seconds": 138,
|
||||
"mmss": "02:18"
|
||||
},
|
||||
{
|
||||
"frame": "frame-047.png",
|
||||
"seconds": 141,
|
||||
"mmss": "02:21"
|
||||
},
|
||||
{
|
||||
"frame": "frame-048.png",
|
||||
"seconds": 144,
|
||||
"mmss": "02:24"
|
||||
},
|
||||
{
|
||||
"frame": "frame-049.png",
|
||||
"seconds": 147,
|
||||
"mmss": "02:27"
|
||||
},
|
||||
{
|
||||
"frame": "frame-050.png",
|
||||
"seconds": 150,
|
||||
"mmss": "02:30"
|
||||
},
|
||||
{
|
||||
"frame": "frame-051.png",
|
||||
"seconds": 153,
|
||||
"mmss": "02:33"
|
||||
},
|
||||
{
|
||||
"frame": "frame-052.png",
|
||||
"seconds": 156,
|
||||
"mmss": "02:36"
|
||||
},
|
||||
{
|
||||
"frame": "frame-053.png",
|
||||
"seconds": 159,
|
||||
"mmss": "02:39"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"phrases":[],"schemaVersion":"1.1.3"}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Loom Capture Tool
|
||||
|
||||
Bun/Node script that captures Loom video walkthroughs as frame sequences when no transcript is available. Built for the JV project where Umar shares fast cursor-pointing demos via Loom.
|
||||
|
||||
## Captures stored here
|
||||
|
||||
| Folder | Loom | Date | Duration | Frames | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `1-june-pdp-walkthrough/` | https://www.loom.com/share/ce4501e9de834cfdae628c60fe9e41d3 | 1 Jun 2026 | 2:12 | 45 | Umar walks through the JV "High Strength Calcium Citrate" PDP showing empty Results + FAQ sections; ends on a competitor reference. The direct brief on what content needs populating. |
|
||||
| `21-may-uiux-walkthrough/` | https://www.loom.com/share/55449ee0013148e7936344b83caf9ba8 | 21 May 2026 | 2:42 | 54 | Broader UI/UX walkthrough showing reference patterns from Wild Nutrition (Scientific Studies), Spacegoods (Timeline), Grüns + Club EarlyBird (Comparison Tables), JV's own Figma (Results, FAQs). |
|
||||
|
||||
Each folder contains:
|
||||
- `frame-000.png` … `frame-NNN.png` — full-resolution frames at 3-second intervals
|
||||
- `contact-sheet.jpg` — 6-col grid of all frames for fast scanning
|
||||
- `manifest.json` — frame index → seconds + mm:ss
|
||||
- `transcript.txt` — Loom transcript JSON (both videos are **empty** — Umar didn't enable auto-transcription, returns `{"phrases":[],"schemaVersion":"1.1.3"}`)
|
||||
|
||||
## Re-capture a Loom
|
||||
|
||||
```sh
|
||||
cd tools/loom-capture
|
||||
node capture.mjs <loom-share-or-embed-url> ./out-folder [intervalSeconds]
|
||||
```
|
||||
|
||||
Examples:
|
||||
```sh
|
||||
node capture.mjs https://www.loom.com/share/<id> ./my-capture 3
|
||||
node capture.mjs https://www.loom.com/share/<id> ./my-capture 5 # less granular, faster
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Script auto-converts `/share/<id>` → `/embed/<id>?autoplay=true` so the **real video** loads instead of Loom's 4-second preview clip.
|
||||
- Cleans the target folder before re-running.
|
||||
- Writes `manifest.json` mapping frame index → timestamp.
|
||||
- Builds a `contact-sheet.jpg` (6 columns) via local ffmpeg using `%03d` sequence pattern (Windows-compatible — no `glob` support needed).
|
||||
|
||||
## Try to fetch the transcript
|
||||
|
||||
```sh
|
||||
node transcript.mjs <loom-share-url> ./out/transcript.txt
|
||||
```
|
||||
|
||||
Strategies it tries (in order):
|
||||
1. Parse `__NEXT_DATA__` on the share page for embedded transcript URL
|
||||
2. Intercept network responses during embed playback (`cdn.loom.com/mediametadata/transcription/<id>-2.json` + `captions/<id>-2.vtt`)
|
||||
3. Toggle captions UI if present
|
||||
4. Direct GET against well-known endpoints
|
||||
|
||||
For both JV Looms the endpoint returns an empty phrases array, confirming Umar didn't enable transcription. If a Loom returns a VTT, the script also writes a `.plain.txt` stripped version.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Node 18+ (tested on Node 24)
|
||||
- `playwright` (installed locally in this folder via `npm i playwright` + `npx playwright install chromium`)
|
||||
- `ffmpeg` on PATH (any modern build — globbing not required)
|
||||
|
||||
## Why frames-not-video?
|
||||
|
||||
Loom doesn't expose its mp4 publicly without auth, so direct `yt-dlp`-style download fails. Frame capture via Playwright `<video>` element seek + screenshot is the cleanest fallback — no auth, decent visual fidelity, fast to scan via the contact sheet.
|
||||
|
||||
## Where this fits in the project
|
||||
|
||||
These captures are the visual ground truth for the **4 PDP sections** Umar wants populated (Scientific Studies, Timeline/Results, Comparison Tables, FAQs). The plan referencing them is `../../PLAN_v2_2026-06-02.md`.
|
||||
@@ -0,0 +1,116 @@
|
||||
// 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.');
|
||||
Generated
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "loom-capture",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loom-capture",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "loom-capture",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Usage: node transcript.mjs <loom-url> <out-file>
|
||||
// Tries multiple strategies to extract a Loom transcript:
|
||||
// 1. __NEXT_DATA__ from the share page (contains transcript URL if available)
|
||||
// 2. Network interception for vtt/json transcript requests during playback
|
||||
// 3. Direct fetch of well-known transcript endpoints
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const url = process.argv[2];
|
||||
const outFile = path.resolve(process.argv[3] || './transcript.txt');
|
||||
|
||||
if (!url) { console.error('Usage: node transcript.mjs <loom-url> <out-file>'); process.exit(1); }
|
||||
|
||||
const idMatch = url.match(/loom\.com\/(?:share|embed)\/([a-f0-9]+)/i);
|
||||
const videoId = idMatch?.[1];
|
||||
if (!videoId) { console.error('Could not extract Loom video ID from URL'); process.exit(1); }
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
const candidates = [];
|
||||
page.on('response', async (res) => {
|
||||
const u = res.url();
|
||||
const ct = (res.headers()['content-type'] || '').toLowerCase();
|
||||
if (u.includes('transcript') || u.endsWith('.vtt') || u.includes('captions') || u.includes('/cc/')) {
|
||||
try {
|
||||
const body = await res.text();
|
||||
candidates.push({ url: u, ct, len: body.length, body });
|
||||
console.log(` ✓ candidate: ${u} (${body.length} bytes, ${ct})`);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Opening share page for video ${videoId}...`);
|
||||
await page.goto(`https://www.loom.com/share/${videoId}`, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
|
||||
// Try __NEXT_DATA__
|
||||
const nextData = await page.evaluate(() => {
|
||||
const el = document.getElementById('__NEXT_DATA__');
|
||||
return el ? el.textContent : null;
|
||||
});
|
||||
let nextDataTranscriptUrl = null;
|
||||
if (nextData) {
|
||||
try {
|
||||
const parsed = JSON.parse(nextData);
|
||||
const json = JSON.stringify(parsed);
|
||||
const m = json.match(/https?:[^"]*?(?:transcript|caption|\.vtt)[^"]*/gi);
|
||||
if (m && m.length) {
|
||||
console.log(` __NEXT_DATA__ hints: ${m.slice(0, 3).join(' , ')}`);
|
||||
nextDataTranscriptUrl = m[0];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Now load the embed and force playback to trigger any transcript fetch
|
||||
console.log(`Opening embed and playing to trigger transcript request...`);
|
||||
await page.goto(`https://www.loom.com/embed/${videoId}?autoplay=true&hideEmbedTopBar=true`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('video', { timeout: 60000 });
|
||||
await page.evaluate(async () => {
|
||||
const v = document.querySelector('video'); v.muted = true; try { await v.play(); } catch {}
|
||||
});
|
||||
// Let it run a few seconds so player loads captions if they exist
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
// Also try toggling closed-captions UI button if present
|
||||
try {
|
||||
const ccBtn = page.locator('button[aria-label*="caption" i], button[aria-label*="subtitle" i], [data-test*="caption" i]').first();
|
||||
if (await ccBtn.count() > 0) {
|
||||
await ccBtn.click({ timeout: 3000 });
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Direct GraphQL/REST guesses (most fail w/o auth but cheap to try)
|
||||
const guesses = [
|
||||
`https://www.loom.com/api/v1/videos/${videoId}/transcripts`,
|
||||
`https://www.loom.com/api/v1/videos/${videoId}/captions`,
|
||||
`https://www.loom.com/api/campaigns/sessions/${videoId}/transcript-text`,
|
||||
`https://www.loom.com/api/campaigns/sessions/${videoId}/transcripts`,
|
||||
];
|
||||
for (const g of guesses) {
|
||||
try {
|
||||
const r = await page.request.get(g);
|
||||
if (r.ok()) {
|
||||
const body = await r.text();
|
||||
if (body && body.length > 50 && !body.startsWith('<!DOCTYPE')) {
|
||||
candidates.push({ url: g, ct: r.headers()['content-type'] || '', len: body.length, body });
|
||||
console.log(` ✓ guess hit: ${g} (${body.length} bytes)`);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (candidates.length === 0) {
|
||||
console.log('No transcript found via any strategy.');
|
||||
fs.writeFileSync(outFile, '[no transcript available]\n');
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
// Pick best candidate (largest non-html)
|
||||
candidates.sort((a, b) => b.len - a.len);
|
||||
const best = candidates[0];
|
||||
fs.writeFileSync(outFile, best.body);
|
||||
console.log(`\nSaved: ${outFile} (${best.len} bytes from ${best.url})`);
|
||||
|
||||
// If it's VTT, also write a stripped plain-text version
|
||||
if (best.ct.includes('vtt') || best.body.startsWith('WEBVTT')) {
|
||||
const plain = best.body
|
||||
.split('\n')
|
||||
.filter((l) => l && !/^\d+$/.test(l) && !l.startsWith('WEBVTT') && !/^\d\d:\d\d/.test(l) && !l.startsWith('NOTE'))
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const plainFile = outFile.replace(/\.(txt|vtt)$/i, '') + '.plain.txt';
|
||||
fs.writeFileSync(plainFile, plain);
|
||||
console.log(`Plain-text version: ${plainFile}`);
|
||||
}
|
||||
Reference in New Issue
Block a user