analyzthis_design 2.5.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -208,23 +208,55 @@ var TOOLS = [
208
208
  },
209
209
  {
210
210
  name: 'analyzthis_mood_board',
211
- description: 'Mood Board or UI image board. Mood: tagged URLs + team deliberation. UI: Zara scouts 3-5 real shipped screens, then Arjun/Noor/Priya/Meera jury. She re-scouts if they skip (max 3 rounds). Use for visual direction before designing.',
211
+ description: 'Visual mood board. Zara crops 20-24 real UI elements (cards, tables, type, colour, nav) from 6+ live sites into a sectioned collage with real colours and fonts. Arjun/Noor/Priya/Meera vote as advice. The board opens in the browser and the USER clicks keep / maybe / reject — the user\'s choice overrides every persona, and the locked direction is saved for all personas. kind=mood keeps the older link-and-tag board.',
212
212
  inputSchema: {
213
213
  type: 'object',
214
214
  properties: {
215
215
  task: { type: 'string', description: 'Visual direction brief (e.g., "B2B fintech dashboard, trustworthy, high-contrast, data-dense")' },
216
- kind: { type: 'string', enum: ['mood', 'ui'], description: 'mood = tagged URLs (default). ui = Zara image-board scout.' },
217
- action: { type: 'string', enum: ['create', 'frames', 'jury', 'verdict'], description: 'create (default), frames (save Zara picks), jury (critique prompt), verdict (SHIP/REVISE/BLOCK after jury)' },
218
- verdict: { type: 'string', enum: ['SHIP', 'REVISE', 'BLOCK'], description: 'Jury verdict (action=verdict)' },
219
- summary: { type: 'string', description: 'Jury because-lines (action=verdict). Sent back to Zara if they skip.' },
220
- board: { type: 'string', description: 'Board id (frames and jury)' },
221
- urls: { type: 'array', items: { type: 'string' }, description: 'Web reference URLs to include (mood boards)' },
222
- url: { type: 'string', description: 'One frame URL (ui boards, action=frames)' },
223
- title: { type: 'string', description: 'Frame title' },
224
- steal: { type: 'string', description: 'One thing to take from this frame' },
225
- refuse: { type: 'string', description: 'One thing we will not copy' },
226
- peak: { type: 'string', description: 'The first-impression this frame proves' },
227
- auto: { type: 'boolean', description: 'Auto-collect references (mood boards only)' },
216
+ kind: { type: 'string', enum: ['ui', 'mood'], description: 'ui = visual element board (default). mood = older tagged-URL board.' },
217
+ action: { type: 'string', enum: ['create', 'brief', 'capture_script', 'tiles', 'film', 'jury', 'votes', 'show', 'decide', 'rescout', 'lock', 'status', 'frames', 'verdict'], description: 'create (default) → brief → tiles (Zara crops) and film (records urls scrolling as motion tiles; needs playwright-core + Chrome) → jury → votes (advice) → show (user decides in browser) → rescout or lock. decide records a user choice from chat. frames/verdict are older names for tiles/votes.' },
218
+ board: { type: 'string', description: 'Board id' },
219
+ adjectives: { type: 'array', items: { type: 'string' }, description: 'Three feel words for the brief' },
220
+ not_this: { type: 'string', description: 'One thing this board must not look like' },
221
+ sections: { type: 'array', items: { type: 'string', enum: ['first_screen', 'typography', 'color', 'components', 'imagery', 'motion'] }, description: 'Sections this board needs' },
222
+ confirmed_by_user: { type: 'boolean', description: 'User approved the brief (action=brief)' },
223
+ selector: { type: 'string', description: 'CSS selector for action=capture_script' },
224
+ text: { type: 'string', description: 'Visible text that narrows the selector match (action=capture_script)' },
225
+ tiles: {
226
+ type: 'array',
227
+ description: 'Element crops (action=tiles)',
228
+ items: {
229
+ type: 'object',
230
+ properties: {
231
+ page_url: { type: 'string', description: 'Exact page the element came from (required)' },
232
+ image_path: { type: 'string', description: 'Local image file, or the saved Page.captureScreenshot JSON file' },
233
+ image_base64: { type: 'string', description: 'Inline Page.captureScreenshot data when no file was written' },
234
+ image_url: { type: 'string', description: 'Remote image URL if no local crop' },
235
+ video_path: { type: 'string', description: 'Local WebM/MP4 scroll film (motion tiles)' },
236
+ stills: { type: 'array', items: { type: 'string' }, description: 'Up to 4 waypoint still image paths for a film' },
237
+ section: { type: 'string', enum: ['first_screen', 'typography', 'color', 'components', 'imagery', 'motion'] },
238
+ element: { type: 'string', description: 'What the element is, e.g. "pricing card"' },
239
+ tokens: { type: 'object', description: 'From capture_script: colors, fonts, radius, spacing, font_size' },
240
+ steal: { type: 'string' }, refuse: { type: 'string' }, peak: { type: 'string' },
241
+ concept: { type: 'boolean', description: 'True if this is a concept shot, not shipped product' },
242
+ },
243
+ },
244
+ },
245
+ votes: {
246
+ type: 'array',
247
+ description: 'Persona advice (action=votes): [{tile, persona, vote: keep|skip, because}]',
248
+ items: { type: 'object' },
249
+ },
250
+ verdict: { type: 'string', enum: ['SHIP', 'REVISE', 'BLOCK'], description: 'Team advice verdict (action=votes)' },
251
+ summary: { type: 'string', description: 'Team because-lines (action=votes)' },
252
+ tile: { type: 'string', description: 'Tile number or id (action=decide)' },
253
+ decision: { type: 'string', enum: ['keep', 'maybe', 'reject', 'clear'], description: 'User decision (action=decide)' },
254
+ note: { type: 'string', description: 'User note (decide, rescout, lock)' },
255
+ urls: { type: 'array', items: { type: 'string' }, description: 'Reference URLs (kind=mood), or pages to film (action=film)' },
256
+ element: { type: 'string', description: 'Tile name (action=film), default "Scroll film — <site>"' },
257
+ section: { type: 'string', enum: ['first_screen', 'typography', 'color', 'components', 'imagery', 'motion'], description: 'Section for action=film (default motion)' },
258
+ steal: { type: 'string' }, refuse: { type: 'string' }, peak: { type: 'string' },
259
+ auto: { type: 'boolean', description: 'Auto-collect references (kind=mood only)' },
228
260
  },
229
261
  },
230
262
  },
@@ -475,114 +507,147 @@ function handleCombinationPass(skillId, args) {
475
507
  return { prompt: prompt, skill: skillId, task: task, persona: skillId, verdicts: 1 };
476
508
  }
477
509
 
510
+ // One live board server per board id, kept for the life of the MCP process.
511
+ var moodBoardServers = {};
512
+
513
+ function tileCountLine(board) {
514
+ var sites = {};
515
+ board.tiles.forEach(function(t) { if (t.site) sites[t.site] = true; });
516
+ return board.tiles.length + ' tile(s) from ' + Object.keys(sites).length + ' site(s)';
517
+ }
518
+
478
519
  function handleMoodBoard(args) {
479
- var skill = readSkill('mood-board');
480
520
  var task = args.task || '';
481
- var kind = args.kind === 'ui' ? 'ui' : 'mood';
521
+ var kind = args.kind === 'mood' ? 'mood' : 'ui';
482
522
  var action = args.action || 'create';
483
523
  var mb = require('./moodboard');
524
+ var view = require('./moodboard-view');
525
+ var ref = { boardId: args.board };
526
+ var zaraNext = function(board) {
527
+ return 'You are Zara on a Scout Pass. Open real pages in the browser. Do not invent URLs.\n\n' + board.scout.brief +
528
+ '\n\nBoard id: ' + board.board_id +
529
+ '\nToken script for Runtime.evaluate: call analyzthis_mood_board action=capture_script selector=<css>.' +
530
+ '\nSave tiles with action=tiles, board=' + board.board_id + ', tiles=[{page_url,image_path,section,element,tokens,steal,refuse,peak}].' +
531
+ '\nThen action=jury for advice, then action=show and open the URL so the user decides.';
532
+ };
484
533
 
485
- if (action === 'frames') {
486
- try {
487
- var added = mb.addFrames({
488
- boardId: args.board,
489
- frames: args.frames || [{
490
- url: args.url,
491
- title: args.title,
492
- steal: args.steal,
493
- refuse: args.refuse,
494
- peak: args.peak,
495
- }],
534
+ try {
535
+ if (action === 'capture_script') {
536
+ return { result: mb.captureScript(args.selector || 'body', args.text || '') };
537
+ }
538
+
539
+ if (action === 'brief') {
540
+ var briefed = mb.setBrief({
541
+ boardId: args.board, adjectives: args.adjectives, not_this: args.not_this,
542
+ sections: args.sections, confirmed_by_user: args.confirmed_by_user,
496
543
  });
544
+ return { result: 'Brief saved.', board_id: briefed.board.board_id, brief: briefed.board.brief };
545
+ }
546
+
547
+ if (action === 'tiles' || action === 'frames') {
548
+ var tiles = args.tiles || args.frames || [{
549
+ page_url: args.page_url || args.url, element: args.element || args.title, section: args.section,
550
+ image_path: args.image_path, image_url: args.image_url, tokens: args.tokens,
551
+ video_path: args.video_path, stills: args.stills, steal: args.steal, refuse: args.refuse, peak: args.peak,
552
+ }];
553
+ var added = mb.addTiles({ boardId: args.board, tiles: tiles });
497
554
  return {
498
- result: 'Saved. ' + added.board.frames.length + ' frame(s). Next: action=jury on board ' + added.board.board_id,
555
+ result: 'Saved ' + added.added.length + ' tile(s). Board now has ' + tileCountLine(added.board) +
556
+ '. Target ' + mb.TILE_TARGET.min + '-' + mb.TILE_TARGET.max + ' from ' + mb.TILE_TARGET.sites + '+ sites.' +
557
+ (added.skipped.length ? ' Skipped: ' + JSON.stringify(added.skipped) : ''),
499
558
  board_id: added.board.board_id,
500
- scout: added.board.scout,
559
+ skipped: added.skipped,
501
560
  };
502
- } catch (e) {
503
- return { error: e.message };
504
561
  }
505
- }
506
562
 
507
- if (action === 'jury') {
508
- try {
509
- var juryBoard = mb.getBoard({ boardId: args.board });
510
- if (!juryBoard.frames || !juryBoard.frames.length) {
511
- return {
512
- error: 'NEED_SCOUT',
513
- board_id: juryBoard.board_id,
514
- scout: juryBoard.scout,
515
- prompt: 'Zara has not scouted yet.\n\n' + ((juryBoard.scout && juryBoard.scout.brief) || ''),
516
- };
517
- }
518
- var frameLines = juryBoard.frames.map(function(f, idx) {
519
- return (idx + 1) + '. ' + (f.title || 'Frame') + ' — ' + (f.url || '') +
520
- '\n Steal: ' + (f.steal || '') +
521
- '\n Refuse: ' + (f.refuse || '') +
522
- '\n Peak: ' + (f.peak || '');
523
- }).join('\n');
524
- var juryPrompt = 'You are the UI image-board jury: Arjun, Noor, Priya, Meera. Zara does not vote.\n\n' +
525
- 'Job: ' + (juryBoard.task || '') + '\n\n' +
526
- 'Frames:\n' + frameLines + '\n\n' +
527
- 'KEEP or SKIP each frame with one because-sentence. Rank the best 2-3. Verdict SHIP only if the set fits the job. REVISE or BLOCK sends Zara to scout again.\n' +
528
- 'Do not reward cinematic marketing motion on a daily working surface.\n\n' +
529
- 'Then call analyzthis_mood_board with action=verdict, board=' + juryBoard.board_id +
530
- ', verdict=SHIP|REVISE|BLOCK, and summary=the because-lines.';
531
- return { prompt: juryPrompt, board_id: juryBoard.board_id, scout: juryBoard.scout, frames: juryBoard.frames };
532
- } catch (e) {
533
- return { error: e.message };
563
+ if (action === 'jury') {
564
+ var juryBoard = mb.loadUiBoard(ref);
565
+ if (!juryBoard.tiles.length) return { error: 'NEED_SCOUT: Zara has not added tiles yet.' };
566
+ return { prompt: mb.juryPrompt(juryBoard, ''), board_id: juryBoard.board_id };
534
567
  }
535
- }
536
568
 
537
- if (action === 'verdict') {
538
- try {
539
- var judged = mb.recordJuryVerdict({
540
- boardId: args.board,
541
- verdict: args.verdict,
542
- summary: args.summary,
569
+ if (action === 'votes' || action === 'verdict') {
570
+ var voted = mb.recordPersonaVotes({
571
+ boardId: args.board, votes: args.votes || [], verdict: args.verdict, summary: args.summary,
543
572
  });
544
- var scout = judged.board.scout || {};
545
- if (scout.status === 'awaiting_scout') {
573
+ return {
574
+ result: 'Team advice saved' + (voted.ignored.length ? ' (ignored: ' + JSON.stringify(voted.ignored) + ')' : '') +
575
+ '. The user decides next: call action=show and open the URL in the browser.',
576
+ board_id: voted.board.board_id,
577
+ scout: voted.board.scout,
578
+ };
579
+ }
580
+
581
+ if (action === 'film') {
582
+ return require('./moodboard-film').filmTiles({
583
+ boardId: args.board, urls: args.urls || (args.page_url ? [args.page_url] : []),
584
+ section: args.section, element: args.element, steal: args.steal, refuse: args.refuse, peak: args.peak,
585
+ }).then(function(out) {
586
+ var added = out.saved ? out.saved.added : [];
546
587
  return {
547
- result: 'Jury skipped. Zara scouts again (round ' + scout.round + ').',
548
- board_id: judged.board.board_id,
549
- scout: scout,
550
- prompt: 'You are Zara on a Scout Pass. Search and open real pages. Do not invent URLs.\n\n' + scout.brief +
551
- '\n\nBoard id: ' + judged.board.board_id + '\nAfter 3-5 new frames, call action=frames, then action=jury.',
588
+ result: 'Filmed ' + added.length + ' site(s) as motion tiles' + (out.saved ? '. Board now has ' + tileCountLine(out.saved.board) : '') + '.' +
589
+ (out.failed.length ? ' Failed: ' + JSON.stringify(out.failed) : '') +
590
+ (out.saved && out.saved.skipped.length ? ' Skipped: ' + JSON.stringify(out.saved.skipped) : ''),
591
+ board_id: args.board,
552
592
  };
553
- }
554
- if (scout.status === 'raj') {
593
+ }).catch(function(e) { return { error: e.message }; });
594
+ }
595
+
596
+ if (action === 'show') {
597
+ var existing = moodBoardServers[args.board];
598
+ if (existing) return { result: 'Board is live at ' + existing.url + ' — open it with browser_navigate.', url: existing.url };
599
+ return view.serveBoard({ boardId: args.board }).then(function(live) {
600
+ moodBoardServers[args.board] = live;
555
601
  return {
556
- result: 'Max scout rounds reached. Raj or the designer picks from the frames on this board.',
557
- board_id: judged.board.board_id,
558
- scout: scout,
602
+ result: 'Board is live at ' + live.url + ' — open it with browser_navigate. The user clicks keep / maybe / reject; their choice overrides every persona.',
603
+ url: live.url,
559
604
  };
560
- }
561
- return {
562
- result: 'Jury SHIP. Board is done. User still /accept on the notes.',
563
- board_id: judged.board.board_id,
564
- scout: scout,
565
- };
566
- } catch (e) {
567
- return { error: e.message };
605
+ }).catch(function(e) { return { error: e.message }; });
606
+ }
607
+
608
+ if (action === 'decide') {
609
+ var decided = mb.recordUserDecision({ boardId: args.board, tile: args.tile, decision: args.decision, note: args.note });
610
+ return { result: 'User decision saved on tile ' + args.tile + '. It outranks every persona vote.', board_id: decided.board.board_id };
611
+ }
612
+
613
+ if (action === 'rescout') {
614
+ var again = mb.requestRescout({ boardId: args.board, note: args.note });
615
+ return { prompt: zaraNext(again.board), board_id: again.board.board_id, scout: again.board.scout };
616
+ }
617
+
618
+ if (action === 'lock') {
619
+ var locked = mb.lockDirection({ boardId: args.board, note: args.note });
620
+ return { result: 'Direction locked by the user and saved to the session as design_direction. Every persona must follow it.', direction: locked.board.direction };
621
+ }
622
+
623
+ if (action === 'status') {
624
+ var b = mb.loadUiBoard(ref);
625
+ return { result: JSON.stringify({ board_id: b.board_id, scout: b.scout, brief: b.brief, tiles: tileCountLine(b), direction: mb.computeDirection(b) }, null, 2) };
568
626
  }
627
+ } catch (e) {
628
+ return { error: e.message };
569
629
  }
570
630
 
571
631
  if (kind === 'ui') {
572
- if (!task) return { error: 'task is required to open a UI image board' };
573
- try {
574
- var created = mb.buildMoodBoard({ task: task, kind: 'ui', discover: false });
575
- var prompt = 'You are Zara on a Scout Pass. Search and open real pages. Do not invent URLs.\n\n' +
576
- created.board.scout.brief + '\n\n' +
577
- 'Board id: ' + created.board.board_id + '\n' +
578
- 'After 3-5 frames, call analyzthis_mood_board again with action=frames, board=' + created.board.board_id +
579
- ', and url/title/steal/refuse/peak. Then action=jury.\n';
580
- return { prompt: prompt, board_id: created.board.board_id, scout: created.board.scout, task: task };
581
- } catch (e) {
582
- return { error: e.message };
583
- }
632
+ if (!task) return { error: 'task is required to open a mood board' };
633
+ return mb.buildMoodBoard({
634
+ task: task, kind: 'ui', discover: false,
635
+ brief: { adjectives: args.adjectives, not_this: args.not_this, sections: args.sections },
636
+ }).then(function(created) {
637
+ return {
638
+ prompt: 'First show the user this brief (job, 3 adjectives, one "not this", sections) and let them correct it with action=brief confirmed_by_user=true.\n\n' + zaraNext(created.board),
639
+ board_id: created.board.board_id,
640
+ brief: created.board.brief,
641
+ };
642
+ }).catch(function(e) { return { error: e.message }; });
584
643
  }
585
644
 
645
+ return handleLinkMoodBoard(args);
646
+ }
647
+
648
+ function handleLinkMoodBoard(args) {
649
+ var skill = readSkill('mood-board');
650
+ var task = args.task || '';
586
651
  var urls = (args.urls || []).join('\n');
587
652
  var prompt = 'Build a mood board.\n\n' +
588
653
  (skill ? skill.slice(0, 3000) : '') + '\n\n' +
@@ -971,8 +1036,8 @@ function startStdioServer(catalog) {
971
1036
  if (method === 'tools/call') {
972
1037
  var toolName = params.name;
973
1038
  var toolArgs = params.arguments || {};
974
- var result = handleToolCall(toolName, toolArgs);
975
-
1039
+ // Some tools (mood board create / show) are async.
1040
+ Promise.resolve().then(function() { return handleToolCall(toolName, toolArgs); }).then(function(result) {
976
1041
  if (result.error) {
977
1042
  send({
978
1043
  jsonrpc: '2.0',
@@ -995,6 +1060,9 @@ function startStdioServer(catalog) {
995
1060
  },
996
1061
  });
997
1062
  }
1063
+ }).catch(function(e) {
1064
+ send({ jsonrpc: '2.0', id: id, error: { code: -32603, message: e.message } });
1065
+ });
998
1066
  return;
999
1067
  }
1000
1068
 
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Mood board scroll films.
5
+ *
6
+ * filmSite — opens a real page in headless Chrome, records it scrolling
7
+ * through four depths with its animations running, and saves a still at
8
+ * each depth. Wheel events drive the scroll so sites with their own scroll
9
+ * engine (Lenis, Locomotive, WebGL) move too.
10
+ * filmTiles — films each URL and puts the result on a UI board as a
11
+ * motion tile (film + poster + waypoint stills), then cleans up.
12
+ *
13
+ * Needs `playwright-core` (optional dependency) and Google Chrome installed.
14
+ */
15
+
16
+ var fs = require('fs');
17
+ var os = require('os');
18
+ var path = require('path');
19
+
20
+ var DEFAULTS = { width: 1280, height: 860, stops: 4, settleMs: 3000, holdMs: 1200 };
21
+
22
+ function loadPlaywright() {
23
+ var tries = [function() { return require('playwright-core'); },
24
+ function() { return require(require.resolve('playwright-core', { paths: [process.cwd()] })); }];
25
+ for (var i = 0; i < tries.length; i++) {
26
+ try { return tries[i](); } catch (e) { /* try next location */ }
27
+ }
28
+ throw new Error('Scroll films need playwright-core. Run: npm install playwright-core (Google Chrome must be installed).');
29
+ }
30
+
31
+ function launchChrome(chromium) {
32
+ return chromium.launch({ channel: 'chrome', headless: true }).catch(function() {
33
+ return chromium.launch({ headless: true });
34
+ }).catch(function() {
35
+ throw new Error('Could not start Chrome. Install Google Chrome, or run: npx playwright-core install chromium');
36
+ });
37
+ }
38
+
39
+ function wait(ms) { return new Promise(function(r) { setTimeout(r, ms); }); }
40
+
41
+ // Wheel-scrolls `distance` px in small steps so the film shows real motion.
42
+ function wheelBy(page, distance) {
43
+ var steps = Math.max(1, Math.round(distance / 60));
44
+ var chain = Promise.resolve();
45
+ for (var i = 0; i < steps; i++) {
46
+ chain = chain.then(function() { return page.mouse.wheel(0, 60); }).then(function() { return wait(40); });
47
+ }
48
+ return chain;
49
+ }
50
+
51
+ async function filmSite(opts) {
52
+ var given = opts || {};
53
+ opts = Object.assign({}, DEFAULTS);
54
+ Object.keys(given).forEach(function(k) { if (given[k] != null) opts[k] = given[k]; });
55
+ if (!/^https?:\/\//i.test(String(opts.url || ''))) throw new Error('Film needs an http(s) url');
56
+ var outDir = opts.outDir || fs.mkdtempSync(path.join(os.tmpdir(), 'analyzthis-film-'));
57
+ var size = { width: opts.width, height: opts.height };
58
+ var browser = await launchChrome(loadPlaywright().chromium);
59
+ var context;
60
+ try {
61
+ context = await browser.newContext({ viewport: size, recordVideo: { dir: outDir, size: size } });
62
+ var page = await context.newPage();
63
+ await page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
64
+ await wait(opts.settleMs);
65
+ await page.mouse.move(size.width / 2, size.height / 2);
66
+ var docH = await page.evaluate(function() { return document.documentElement.scrollHeight; });
67
+ // Virtual-scroll sites report no page height; still travel three screens.
68
+ var travel = Math.max(docH - size.height, size.height * 3);
69
+ var stills = [];
70
+ for (var i = 0; i < opts.stops; i++) {
71
+ if (i > 0) await wheelBy(page, Math.round(travel / opts.stops));
72
+ await wait(opts.holdMs);
73
+ var still = path.join(outDir, 'still-' + (i + 1) + '.png');
74
+ await page.screenshot({ path: still });
75
+ stills.push(still);
76
+ }
77
+ var video = page.video();
78
+ await context.close();
79
+ context = null;
80
+ return { url: opts.url, video: await video.path(), stills: stills, dir: outDir };
81
+ } finally {
82
+ if (context) await context.close().catch(function() {});
83
+ await browser.close().catch(function() {});
84
+ }
85
+ }
86
+
87
+ async function filmTiles(opts) {
88
+ opts = opts || {};
89
+ var urls = (opts.urls || []).filter(Boolean);
90
+ if (!opts.boardId || !urls.length) throw new Error('--board and at least one --url are required');
91
+ var mb = require('./moodboard');
92
+ var films = [];
93
+ var failed = [];
94
+ for (var i = 0; i < urls.length; i++) {
95
+ try { films.push(await filmSite({ url: urls[i], stops: opts.stops })); }
96
+ catch (e) { failed.push({ url: urls[i], reason: e.message }); }
97
+ }
98
+ var saved = null;
99
+ try {
100
+ if (films.length) {
101
+ saved = mb.addTiles({
102
+ project: opts.project, boardId: opts.boardId, workspaceDir: opts.workspaceDir,
103
+ tiles: films.map(function(f) {
104
+ return {
105
+ page_url: f.url, section: opts.section || 'motion',
106
+ element: opts.element || 'Scroll film — ' + mb.siteOf(f.url),
107
+ video_path: f.video, stills: f.stills,
108
+ steal: opts.steal, refuse: opts.refuse, peak: opts.peak,
109
+ };
110
+ }),
111
+ });
112
+ }
113
+ } finally {
114
+ films.forEach(function(f) { fs.rmSync(f.dir, { recursive: true, force: true }); });
115
+ }
116
+ return { saved: saved, failed: failed };
117
+ }
118
+
119
+ module.exports = { filmSite: filmSite, filmTiles: filmTiles, loadPlaywright: loadPlaywright };