@yojahny/wp-design-library 0.3.0 → 0.5.1

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.
Files changed (186) hide show
  1. package/.env.example +7 -0
  2. package/README.md +52 -9
  3. package/bin/library.mjs +11 -0
  4. package/entries/ada/entry.md +55 -0
  5. package/entries/ada/strip.webp +0 -0
  6. package/entries/air/captures/2/measure.json +325 -0
  7. package/entries/air/captures/2/strip.webp +0 -0
  8. package/entries/air/entry.md +62 -0
  9. package/entries/air/strip.webp +0 -0
  10. package/entries/ais-community-dark-depth/strip.webp +0 -0
  11. package/entries/apple-home/captures/2/measure.json +91 -0
  12. package/entries/apple-home/captures/2/strip.webp +0 -0
  13. package/entries/apple-home/entry.md +64 -0
  14. package/entries/apple-home/strip.webp +0 -0
  15. package/entries/apple-macbook-neo/captures/2/measure.json +570 -0
  16. package/entries/apple-macbook-neo/captures/2/strip.webp +0 -0
  17. package/entries/apple-macbook-neo/entry.md +62 -0
  18. package/entries/apple-macbook-neo/strip.webp +0 -0
  19. package/entries/apple-watch-ultra-4/entry.md +63 -0
  20. package/entries/apple-watch-ultra-4/measure.json +481 -0
  21. package/entries/apple-watch-ultra-4/strip.webp +0 -0
  22. package/entries/bpco/captures/2/strip.webp +0 -0
  23. package/entries/bpco/captures/3/strip.webp +0 -0
  24. package/entries/bpco/entry.md +81 -0
  25. package/entries/bpco/strip.webp +0 -0
  26. package/entries/column/captures/2/measure.json +996 -0
  27. package/entries/column/captures/2/strip.webp +0 -0
  28. package/entries/column/entry.md +82 -0
  29. package/entries/column/strip.webp +0 -0
  30. package/entries/cthdrl/captures/2/measure.json +251 -0
  31. package/entries/cthdrl/captures/2/strip.webp +0 -0
  32. package/entries/cthdrl/entry.md +86 -0
  33. package/entries/cthdrl/strip.webp +0 -0
  34. package/entries/dala-craftedbygc/entry.md +69 -0
  35. package/entries/dala-craftedbygc/strip.webp +0 -0
  36. package/entries/dock/captures/2/measure.json +206 -0
  37. package/entries/dock/captures/2/strip.webp +0 -0
  38. package/entries/dock/entry.md +62 -0
  39. package/entries/dock/strip.webp +0 -0
  40. package/entries/dub/captures/2/measure.json +37 -0
  41. package/entries/dub/captures/2/strip.webp +0 -0
  42. package/entries/dub/entry.md +61 -0
  43. package/entries/dub/strip.webp +0 -0
  44. package/entries/duolingo/captures/2/measure.json +138 -0
  45. package/entries/duolingo/captures/2/strip.webp +0 -0
  46. package/entries/duolingo/entry.md +65 -0
  47. package/entries/duolingo/strip.webp +0 -0
  48. package/entries/elevenlabs/captures/2/measure.json +160 -0
  49. package/entries/elevenlabs/captures/2/strip.webp +0 -0
  50. package/entries/elevenlabs/entry.md +62 -0
  51. package/entries/elevenlabs/strip.webp +0 -0
  52. package/entries/flying-papers/captures/2/measure.json +291 -0
  53. package/entries/flying-papers/captures/2/strip.webp +0 -0
  54. package/entries/flying-papers/entry.md +61 -0
  55. package/entries/flying-papers/strip.webp +0 -0
  56. package/entries/gsap/captures/2/measure.json +120 -0
  57. package/entries/gsap/captures/2/strip.webp +0 -0
  58. package/entries/gsap/entry.md +62 -0
  59. package/entries/gsap/strip.webp +0 -0
  60. package/entries/letters/captures/2/measure.json +20 -0
  61. package/entries/letters/captures/2/strip.webp +0 -0
  62. package/entries/letters/entry.md +62 -0
  63. package/entries/letters/strip.webp +0 -0
  64. package/entries/micro/captures/2/measure.json +852 -0
  65. package/entries/micro/captures/2/strip.webp +0 -0
  66. package/entries/micro/entry.md +61 -0
  67. package/entries/micro/strip.webp +0 -0
  68. package/entries/monopo-saigon/captures/2/measure.json +145 -0
  69. package/entries/monopo-saigon/captures/2/strip.webp +0 -0
  70. package/entries/monopo-saigon/entry.md +64 -0
  71. package/entries/monopo-saigon/strip.webp +0 -0
  72. package/entries/notion/captures/2/measure.json +338 -0
  73. package/entries/notion/captures/2/strip.webp +0 -0
  74. package/entries/notion/entry.md +66 -0
  75. package/entries/notion/strip.webp +0 -0
  76. package/entries/origin-financial/captures/2/measure.json +169 -0
  77. package/entries/origin-financial/captures/2/strip.webp +0 -0
  78. package/entries/origin-financial/entry.md +65 -0
  79. package/entries/origin-financial/strip.webp +0 -0
  80. package/entries/oryzo-ai/captures/2/measure.json +39 -0
  81. package/entries/oryzo-ai/captures/2/strip.webp +0 -0
  82. package/entries/oryzo-ai/entry.md +76 -0
  83. package/entries/oryzo-ai/strip.webp +0 -0
  84. package/entries/silencio/captures/2/strip.webp +0 -0
  85. package/entries/silencio/entry.md +114 -0
  86. package/entries/silencio/motion/product-story/frame-0.webp +0 -0
  87. package/entries/silencio/motion/product-story/frame-1.webp +0 -0
  88. package/entries/silencio/motion/product-story/frame-2.webp +0 -0
  89. package/entries/silencio/motion/product-story/frame-3.webp +0 -0
  90. package/entries/silencio/motion/product-story/frame-4.webp +0 -0
  91. package/entries/silencio/motion/product-story/frame-5.webp +0 -0
  92. package/entries/silencio/motion/product-story/frame-6.webp +0 -0
  93. package/entries/silencio/motion/product-story/frame-7.webp +0 -0
  94. package/entries/silencio/strip.webp +0 -0
  95. package/entries/steep/captures/2/measure.json +20 -0
  96. package/entries/steep/captures/2/strip.webp +0 -0
  97. package/entries/steep/entry.md +63 -0
  98. package/entries/steep/strip.webp +0 -0
  99. package/entries/stripe/captures/2/measure.json +892 -0
  100. package/entries/stripe/captures/2/strip.webp +0 -0
  101. package/entries/stripe/entry.md +61 -0
  102. package/entries/stripe/strip.webp +0 -0
  103. package/entries/superpower/captures/2/measure.json +179 -0
  104. package/entries/superpower/captures/2/strip.webp +0 -0
  105. package/entries/superpower/entry.md +64 -0
  106. package/entries/superpower/strip.webp +0 -0
  107. package/entries/superr/entry.md +54 -0
  108. package/entries/superr/strip.webp +0 -0
  109. package/entries/svz-weare/captures/2/measure.json +129 -0
  110. package/entries/svz-weare/captures/2/strip.webp +0 -0
  111. package/entries/svz-weare/entry.md +147 -0
  112. package/entries/svz-weare/motion/hero-transition/frame-0.webp +0 -0
  113. package/entries/svz-weare/motion/hero-transition/frame-1.webp +0 -0
  114. package/entries/svz-weare/motion/hero-transition/frame-2.webp +0 -0
  115. package/entries/svz-weare/motion/hero-transition/frame-3.webp +0 -0
  116. package/entries/svz-weare/motion/hero-transition/frame-4.webp +0 -0
  117. package/entries/svz-weare/motion/hero-transition/frame-5.webp +0 -0
  118. package/entries/svz-weare/motion/hero-transition/frame-6.webp +0 -0
  119. package/entries/svz-weare/motion/hero-transition/frame-7.webp +0 -0
  120. package/entries/svz-weare/strip.webp +0 -0
  121. package/entries/thoughtlab/captures/2/measure.json +93 -0
  122. package/entries/thoughtlab/captures/2/strip.webp +0 -0
  123. package/entries/thoughtlab/entry.md +62 -0
  124. package/entries/thoughtlab/strip.webp +0 -0
  125. package/entries/ventriloc/entry.md +54 -0
  126. package/entries/ventriloc/strip.webp +0 -0
  127. package/entries/vivid-and-co/entry.md +58 -0
  128. package/entries/vivid-and-co/strip.webp +0 -0
  129. package/package.json +24 -5
  130. package/src/cli/add.mjs +100 -24
  131. package/src/cli/motion.mjs +168 -0
  132. package/src/cli/refresh.mjs +6 -0
  133. package/src/cli/ui.mjs +2 -1
  134. package/src/entry.mjs +14 -1
  135. package/src/index/build.mjs +9 -3
  136. package/src/index/embed.mjs +1 -1
  137. package/src/ingest/agreement.mjs +33 -0
  138. package/src/ingest/attach.mjs +67 -0
  139. package/src/ingest/draft.mjs +5 -2
  140. package/src/ingest/frames.mjs +20 -6
  141. package/src/ingest/measure.mjs +34 -1
  142. package/src/ingest/observed.mjs +29 -0
  143. package/src/mcp/http.mjs +1 -1
  144. package/src/mcp/instructions.mjs +2 -1
  145. package/src/mcp/prompts.mjs +12 -1
  146. package/src/mcp/resources.mjs +14 -0
  147. package/src/mcp/tools.mjs +48 -5
  148. package/src/motion.mjs +94 -0
  149. package/src/ui/build.mjs +83 -7
  150. package/src/ui/serve.mjs +29 -4
  151. package/src/ui/templates/entry.html +9 -1
  152. package/entries/ais-community-dark-depth/strip.png +0 -0
  153. package/tests/README.md +0 -33
  154. package/tests/checks/cli-check.sh +0 -13
  155. package/tests/checks/docker.sh +0 -10
  156. package/tests/checks/entry-detail.sh +0 -52
  157. package/tests/checks/entry.sh +0 -55
  158. package/tests/checks/export.sh +0 -16
  159. package/tests/checks/fetch-on-start.sh +0 -26
  160. package/tests/checks/frames-dense.sh +0 -27
  161. package/tests/checks/http.sh +0 -22
  162. package/tests/checks/hygiene.sh +0 -29
  163. package/tests/checks/inbox.sh +0 -27
  164. package/tests/checks/index-degrade.sh +0 -32
  165. package/tests/checks/index.sh +0 -36
  166. package/tests/checks/ingest-mp4.sh +0 -47
  167. package/tests/checks/ingest.sh +0 -39
  168. package/tests/checks/instructions.sh +0 -31
  169. package/tests/checks/licence-gate.sh +0 -24
  170. package/tests/checks/mcp-stdout.sh +0 -31
  171. package/tests/checks/measure.sh +0 -79
  172. package/tests/checks/minors.sh +0 -84
  173. package/tests/checks/optional-deps.sh +0 -55
  174. package/tests/checks/prompt-add-entry.sh +0 -28
  175. package/tests/checks/recommend.sh +0 -107
  176. package/tests/checks/resources.sh +0 -47
  177. package/tests/checks/rrf.sh +0 -120
  178. package/tests/checks/seed-sync.sh +0 -22
  179. package/tests/checks/similar.sh +0 -62
  180. package/tests/checks/ui-build.sh +0 -59
  181. package/tests/checks/vocab.sh +0 -30
  182. package/tests/fixtures/entry-ok/entry.md +0 -41
  183. package/tests/fixtures/entry-ok/strip.png +0 -0
  184. package/tests/fixtures/page/index.html +0 -47
  185. package/tests/fixtures/three-frame.webp +0 -0
  186. package/tests/run.sh +0 -16
@@ -0,0 +1,168 @@
1
+ // Repository-only authoring command. Playback and MCP readers do not need ffmpeg or R2 credentials.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import yaml from 'js-yaml';
8
+ import sharp from 'sharp';
9
+ import { paths } from '../paths.mjs';
10
+ import { parseEntry } from '../entry.mjs';
11
+ import { isHttpsUrl, localMotionPath, validateMotion } from '../motion.mjs';
12
+
13
+ const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
14
+ function probe(file) {
15
+ const info = JSON.parse(execFileSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0',
16
+ '-show_entries', 'format=duration:stream=width,height,r_frame_rate', '-of', 'json', file], { encoding: 'utf8' }));
17
+ if (!info.streams?.[0]) throw new Error('input has no video stream');
18
+ return { ...info.streams[0], duration: Number(info.format?.duration) };
19
+ }
20
+ function ffmpeg(args) {
21
+ try { execFileSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-nostdin', ...args], { stdio: ['ignore', 'pipe', 'pipe'] }); }
22
+ catch (e) { throw new Error(`ffmpeg: ${e.stderr?.toString().trim() || e.message}`); }
23
+ }
24
+ function writeEntry(entry) {
25
+ const file = path.join(entry.dir, 'entry.md');
26
+ const tmp = `${file}.motion-tmp`;
27
+ fs.writeFileSync(tmp, `---\n${yaml.dump(entry.fm, { lineWidth: 110, noRefs: true })}---\n\n${entry.body}\n`);
28
+ fs.renameSync(tmp, file);
29
+ }
30
+ function entryFor(p, slug) {
31
+ if (!ID.test(slug)) throw new Error('a valid --slug is required');
32
+ return parseEntry(path.join(p.entries, slug));
33
+ }
34
+
35
+ export async function prepareMotion({ p = paths(), slug, input, id, title, notes, capture = 1, start = 0, duration = 15 }) {
36
+ const entry = entryFor(p, slug);
37
+ if (!ID.test(id) || !title?.trim() || !notes?.trim()) throw new Error('--id, --title and --notes are required');
38
+ if ((entry.fm.motion?.clips ?? []).some((c) => c.id === id)) throw new Error(`clip ${id} already exists; choose a new id`);
39
+ if (!Number.isInteger(capture) || capture < 1 || capture > (entry.fm.captured?.captures?.length ?? 1)) throw new Error('--capture must name an existing capture');
40
+ if (!Number.isFinite(start) || start < 0 || !Number.isFinite(duration) || duration <= 0 || duration > 15) throw new Error('--start must be nonnegative and --duration must be 0–15 seconds');
41
+ const source = fs.realpathSync(input);
42
+ if (!fs.statSync(source).isFile()) throw new Error('input must be a local video file');
43
+ const info = probe(source);
44
+ if (Number.isFinite(info.duration) && start >= info.duration) throw new Error('--start is beyond the video');
45
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'library-motion-'));
46
+ const evidenceDir = path.join(entry.dir, 'motion', id);
47
+ if (fs.existsSync(evidenceDir)) throw new Error('motion evidence directory already exists');
48
+ try {
49
+ const mp4 = path.join(tmp, 'clip.mp4');
50
+ const width = Math.min(1280, info.width), height = Math.min(720, info.height);
51
+ ffmpeg(['-ss', String(start), '-i', source, '-t', String(duration), '-map', '0:v:0', '-an', '-sn', '-dn',
52
+ '-vf', `scale=${width}:${height}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=30`,
53
+ '-c:v', 'libx264', '-preset', 'medium', '-crf', '24', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-map_metadata', '-1', mp4]);
54
+ const encoded = probe(mp4);
55
+ if (!Number.isFinite(encoded.duration) || encoded.duration < 0.25) throw new Error('clip is empty or too short');
56
+ const durationMs = Math.round(encoded.duration * 1000);
57
+ const frames = [];
58
+ const evidenceTmp = path.join(tmp, 'evidence');
59
+ fs.mkdirSync(evidenceTmp);
60
+ for (let i = 0; i < 8; i++) {
61
+ const at = Math.floor(i * Math.max(0, durationMs - 100) / 7);
62
+ const name = `frame-${i}.webp`;
63
+ const png = path.join(tmp, 'frame.png');
64
+ ffmpeg(['-y', '-ss', String(at / 1000), '-i', mp4, '-frames:v', '1', '-vf', 'scale=640:-2', png]);
65
+ await sharp(png).webp({ quality: 78 }).toFile(path.join(evidenceTmp, name));
66
+ frames.push({ at_ms: at, image: `motion/${id}/${name}` });
67
+ }
68
+ const buffer = fs.readFileSync(mp4);
69
+ const sha256 = createHash('sha256').update(buffer).digest('hex');
70
+ const key = `motion/${slug}/${id}-${sha256.slice(0, 16)}.mp4`;
71
+ const local = localMotionPath(path.join(p.data, '.library', 'media'), key);
72
+ const clip = {
73
+ id, title, notes, duration_ms: durationMs, width: encoded.width, height: encoded.height, fps: 30,
74
+ bytes: buffer.length, sha256, key, poster: frames[0].image, frames,
75
+ source: { capture, filename: path.basename(source), start_ms: Math.round(start * 1000) },
76
+ };
77
+ fs.mkdirSync(path.dirname(evidenceDir), { recursive: true });
78
+ fs.cpSync(evidenceTmp, evidenceDir, { recursive: true });
79
+ entry.fm.motion ??= {};
80
+ entry.fm.motion.clips = [...(entry.fm.motion.clips ?? []), clip];
81
+ const errors = validateMotion(entry);
82
+ if (errors.length) throw new Error(errors.join('; '));
83
+ fs.mkdirSync(path.dirname(local), { recursive: true });
84
+ fs.copyFileSync(mp4, local);
85
+ writeEntry(entry);
86
+ return { slug, clip, local, published: false };
87
+ } catch (e) {
88
+ fs.rmSync(evidenceDir, { recursive: true, force: true });
89
+ throw e;
90
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
91
+ }
92
+
93
+ export function r2Config(env = process.env) {
94
+ const required = ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET', 'R2_PUBLIC_BASE_URL'];
95
+ const missing = required.filter((k) => !env[k]);
96
+ if (missing.length) throw new Error(`configure ${missing.join(', ')} locally before publishing`);
97
+ if (!/^[a-f0-9]{32}$/.test(env.R2_ACCOUNT_ID)) throw new Error('invalid R2_ACCOUNT_ID');
98
+ if (!isHttpsUrl(env.R2_PUBLIC_BASE_URL)) throw new Error('R2_PUBLIC_BASE_URL must be HTTPS without credentials');
99
+ const base = new URL(env.R2_PUBLIC_BASE_URL);
100
+ if (base.search || base.hash) throw new Error('R2_PUBLIC_BASE_URL must not contain a query or fragment');
101
+ if (base.hostname.endsWith('.r2.dev')) throw new Error('use a production custom domain for R2_PUBLIC_BASE_URL');
102
+ return { account: env.R2_ACCOUNT_ID, bucket: env.R2_BUCKET, base: base.href.replace(/\/$/, ''),
103
+ accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY };
104
+ }
105
+
106
+ export async function publishMotion({ p = paths(), slug, id, config = r2Config(), upload, fetcher = fetch }) {
107
+ const entry = entryFor(p, slug);
108
+ const errors = validateMotion(entry);
109
+ if (errors.length) throw new Error(errors.join('; '));
110
+ const clips = (entry.fm.motion?.clips ?? []).filter((c) => !id || c.id === id);
111
+ if (!clips.length) throw new Error('no matching motion clips');
112
+ // Preflight the entire selection before uploading anything.
113
+ const work = clips.map((clip) => {
114
+ const file = localMotionPath(path.join(p.data, '.library', 'media'), clip.key);
115
+ const body = fs.readFileSync(file);
116
+ if (body.length !== clip.bytes || createHash('sha256').update(body).digest('hex') !== clip.sha256)
117
+ throw new Error(`local video checksum mismatch: ${clip.id}`);
118
+ return { clip, body, url: `${config.base}/${clip.key}` };
119
+ });
120
+ let client;
121
+ if (!upload) {
122
+ const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
123
+ client = new S3Client({ region: 'auto', endpoint: `https://${config.account}.r2.cloudflarestorage.com`,
124
+ credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey } });
125
+ upload = (args) => client.send(new PutObjectCommand(args));
126
+ }
127
+ try {
128
+ for (const { clip, body, url } of work) {
129
+ await upload({ Bucket: config.bucket, Key: clip.key, Body: body, ContentType: 'video/mp4',
130
+ CacheControl: 'public, max-age=31536000, immutable', Metadata: { sha256: clip.sha256 } });
131
+ const response = await fetcher(url, { headers: { Range: 'bytes=0-31' }, signal: AbortSignal.timeout(20000) });
132
+ const type = response.headers.get('content-type')?.split(';')[0];
133
+ const range = response.headers.get('content-range');
134
+ if (response.status !== 206 || type !== 'video/mp4' || range !== `bytes 0-31/${clip.bytes}`) {
135
+ await response.body?.cancel();
136
+ throw new Error(`uploaded ${clip.id}, but public playback verification failed (HTTP ${response.status}); entry URL was not changed`);
137
+ }
138
+ const bytes = Buffer.from(await response.arrayBuffer());
139
+ if (!bytes.equals(body.subarray(0, 32))) throw new Error(`public video bytes differ for ${clip.id}`);
140
+ }
141
+ for (const { clip, url } of work) clip.url = url;
142
+ writeEntry(entry);
143
+ return { slug, published: work.map(({ clip, url }) => ({ id: clip.id, url })) };
144
+ } finally { client?.destroy(); }
145
+ }
146
+
147
+ export async function run(args = []) {
148
+ try {
149
+ const [action, ...rest] = args;
150
+ const options = {}, positional = [];
151
+ const allowed = new Set(['slug', 'id', 'title', 'notes', 'capture', 'start', 'duration']);
152
+ for (let i = 0; i < rest.length; i++) {
153
+ if (!rest[i].startsWith('--')) { positional.push(rest[i]); continue; }
154
+ const name = rest[i].slice(2);
155
+ if (!allowed.has(name) || rest[i + 1] === undefined || rest[i + 1].startsWith('--')) throw new Error(`invalid option ${rest[i]}`);
156
+ options[name] = rest[++i];
157
+ }
158
+ let result;
159
+ if (action === 'prepare') {
160
+ if (positional.length !== 1) throw new Error('prepare requires one local video');
161
+ for (const k of ['capture', 'start', 'duration']) if (options[k] !== undefined) options[k] = Number(options[k]);
162
+ result = await prepareMotion({ ...options, input: positional[0] });
163
+ } else if (action === 'publish' && positional.length === 0) result = await publishMotion(options);
164
+ else throw new Error('usage: library motion prepare <video> --slug <slug> --id <id> --title <title> --notes <notes> [--start 0 --duration 15 --capture 1] | publish --slug <slug> [--id <id>]');
165
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
166
+ return 0;
167
+ } catch (e) { process.stderr.write(`motion: ${e.message}\n`); return 1; }
168
+ }
@@ -23,6 +23,12 @@ export async function refreshEntry({ slug, transport }) {
23
23
  const method = e.fm.captured?.method;
24
24
  if (!url) return { refused: `${slug}: refresh needs source.url` };
25
25
  if (method === 'frames') return { refused: `${slug}: refresh needs captured.method measured or both, not frames` };
26
+ // Refresh writes measure.json and the screenshots into the entry ROOT, which is capture 1's
27
+ // slot. On a multi-capture entry it cannot know that capture 1 is the one source.url
28
+ // describes — attaching a measured capture flips method to `both` and unlocks this path —
29
+ // so it would attribute a fresh measurement to a capture that never made it.
30
+ if ((e.fm.captured?.captures?.length ?? 0) > 1)
31
+ return { refused: `${slug}: refresh cannot update a multi-capture entry; it does not know which capture's slot it would write` };
26
32
  const refusal = await refuseUnsafeUrl(url, { transport });
27
33
  if (refusal) return { refused: refusal };
28
34
 
package/src/cli/ui.mjs CHANGED
@@ -7,7 +7,8 @@ function opt(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i
7
7
  export async function run(args = []) {
8
8
  const p = paths();
9
9
  const outDir = path.join(p.data, 'ui');
10
- const r = await buildUi({ indexPath: p.index, entriesDir: p.entries, outDir });
10
+ const previewMediaDir = args.includes('--local-media') ? path.join(p.data, '.library', 'media') : undefined;
11
+ const r = await buildUi({ indexPath: p.index, entriesDir: p.entries, outDir, previewMediaDir });
11
12
  process.stdout.write(`ui: ${r.entries} entries, ${r.drafts} drafts, built into ${outDir}\n`);
12
13
  if (args.includes('--serve')) {
13
14
  const port = Number(opt(args, '--port') ?? 4180);
package/src/entry.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import yaml from 'js-yaml';
4
+ import { validateMotion } from './motion.mjs';
4
5
  import { resolveTerm } from './vocab.mjs';
5
6
 
6
7
  export const REQUIRED_BLANKS = ['roles', 'feel', 'motion.devices', 'motion.notes', 'type.display', 'type.body', 'body',
@@ -57,7 +58,19 @@ export function validateEntry(entry, vocab) {
57
58
  errors.push(`licence gate: tier ported is not allowed with source.kind ${fm.source.kind}`);
58
59
  if (blank(fm.ported_from)) errors.push('ported_from: required for tier ported');
59
60
  }
60
- if (!fs.existsSync(path.join(entry.dir, 'strip.png'))) errors.push('strip.png: missing');
61
+ // Every strip a capture names, not only the root one: get_entry hands back `strips` built
62
+ // from these paths, so a capture claiming an artifact it never wrote would otherwise be
63
+ // reported ok by `check` and then serve a path to a file that is not there.
64
+ // The same resolution get_entry uses, basename equality and the conventional fallback
65
+ // included — validating anything else would pass a path the server never serves, or refuse
66
+ // one it does.
67
+ const strips = new Set(['strip.webp']);
68
+ (fm.captured?.captures ?? []).forEach((c, i) => {
69
+ const named = (c.artifacts ?? []).find((a) => path.basename(String(a)) === 'strip.webp');
70
+ strips.add(named ?? (i === 0 ? 'strip.webp' : `captures/${i + 1}/strip.webp`));
71
+ });
72
+ for (const s of strips) if (!fs.existsSync(path.join(entry.dir, s))) errors.push(`${s}: missing`);
73
+ errors.push(...validateMotion(entry));
61
74
  return { ok: errors.length === 0, errors };
62
75
  }
63
76
 
@@ -13,6 +13,12 @@ function isoDate(v) {
13
13
  return v instanceof Date ? v.toISOString().slice(0, 10) : String(v).slice(0, 10);
14
14
  }
15
15
 
16
+ function searchableBody(entry) {
17
+ return [entry.body, entry.fm.motion?.notes,
18
+ ...(entry.fm.motion?.clips ?? []).map((clip) => `${clip.title}\n${clip.notes}`)]
19
+ .filter(Boolean).join('\n');
20
+ }
21
+
16
22
  export async function buildIndex({ entriesDir, indexPath, vocab, models }) {
17
23
  fs.mkdirSync(path.dirname(indexPath), { recursive: true });
18
24
  const tmpPath = indexPath + '.tmp';
@@ -34,7 +40,7 @@ export async function buildIndex({ entriesDir, indexPath, vocab, models }) {
34
40
  insEntry.run({ slug: e.slug, title: e.fm.title, tier: e.fm.tier, source_kind: e.fm.source.kind,
35
41
  captured_at: isoDate(e.fm.captured.at), dir: e.dir, body: e.body, fm: JSON.stringify(e.fm) });
36
42
  for (const [f, t] of tags) insTag.run(e.slug, f, t);
37
- insFts.run(e.slug, e.fm.title, e.body, tags.map(([f, t]) => `${f}/${t}`).join(' '));
43
+ insFts.run(e.slug, e.fm.title, searchableBody(e), tags.map(([f, t]) => `${f}/${t}`).join(' '));
38
44
  });
39
45
  let n = 0;
40
46
  for (const dir of listEntries(entriesDir)) {
@@ -51,9 +57,9 @@ export async function buildIndex({ entriesDir, indexPath, vocab, models }) {
51
57
  tx(e, tags);
52
58
  if (emb && db.vec) {
53
59
  const tagsStr = tags.map(([f, t]) => `${f}/${t}`).join(' ');
54
- const tv = await emb.text(e.fm.title + '\n' + e.body + '\n' + tagsStr);
60
+ const tv = await emb.text(e.fm.title + '\n' + searchableBody(e) + '\n' + tagsStr);
55
61
  insVecText.run(e.slug, Buffer.from(tv.buffer));
56
- const iv = await emb.image(path.join(e.dir, 'strip.png'));
62
+ const iv = await emb.image(path.join(e.dir, 'strip.webp'));
57
63
  insVecImage.run(e.slug, Buffer.from(iv.buffer));
58
64
  }
59
65
  n++;
@@ -32,7 +32,7 @@ export async function getEmbedder({ models, mode = process.env.LIBRARY_EMBED } =
32
32
  arm: 'vec', dims: DIMS,
33
33
  text: async (s) => stubVec('t:' + s, DIMS.text),
34
34
  // ponytail: seeded on byte length + basename, not pixel content. Every
35
- // indexed strip shares the basename strip.png, so two strips of the same
35
+ // indexed strip shares the basename strip.webp, so two strips of the same
36
36
  // byte length collide on the same vector — fine for exercising the vec
37
37
  // arm offline, not a stand-in for real image similarity.
38
38
  image: async (p) => stubVec('i:' + fs.readFileSync(p).length + ':' + path.basename(p), DIMS.image),
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { observedColours } from './observed.mjs';
4
+
5
+ // Records numbers, never prose. "canvas declared dark-blue but the frames read warm" is an
6
+ // interpretation, and this library's rule is that the session is the tagger — the server
7
+ // never guesses what a design feels like. Here are the declared values and the observed
8
+ // ones; what the difference means belongs in the body.
9
+ export async function computeAgreement({ fm, dir }) {
10
+ const captures = fm.captured?.captures ?? [];
11
+ // Two DISTINCT captures, newest first. A `both` capture satisfies both predicates, so a
12
+ // plain find() on each side lets one capture stand in as its own frames source — the
13
+ // attached capture is then ignored while the block is stamped with today's date. The
14
+ // frames side is resolved first because it is the one actually read: the newest frames
15
+ // capture is the one an attach just added. One `both` capture on its own therefore
16
+ // yields null, which is right — there is no second opinion to record.
17
+ const frames = captures.findLast((c) => c.method === 'frames' || c.method === 'both');
18
+ const measured = captures.findLast((c) => c !== frames && (c.method === 'measured' || c.method === 'both'));
19
+ if (!measured || !frames) return null;
20
+
21
+ const declared = fm.palette;
22
+ if (!declared || Object.values(declared).every((v) => !v)) return null;
23
+
24
+ const strip = (frames.artifacts ?? []).find((a) => a.endsWith('strip.webp'));
25
+ if (!strip) return null;
26
+ const file = path.join(dir, strip);
27
+ if (!fs.existsSync(file)) return null;
28
+
29
+ return {
30
+ at: new Date().toISOString().slice(0, 10),
31
+ palette: { declared: { ...declared }, observed: await observedColours(file), from: strip },
32
+ };
33
+ }
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { parseEntry } from '../entry.mjs';
5
+ import { computeAgreement } from './agreement.mjs';
6
+
7
+ function isBlank(v) {
8
+ return v == null || v === '' || (Array.isArray(v) && v.length === 0)
9
+ || (typeof v === 'object' && Object.values(v).every((x) => x === '' || x == null));
10
+ }
11
+
12
+ // An entry written before captures[] existed describes one capture in its top-level fields.
13
+ // Synthesise it rather than dropping it, so attaching to an old entry does not lose what it
14
+ // already recorded.
15
+ function existingCaptureFrom(fm) {
16
+ const c = { method: fm.captured.method, input: fm.source?.url ?? null, artifacts: ['strip.webp'] };
17
+ if (fm.captured.method !== 'frames') c.artifacts.push('measure.json', 'screen-1440.png', 'screen-390.png');
18
+ if (fm.media?.frames) c.frames = fm.media.frames;
19
+ if (fm.media?.duration_ms) c.duration_ms = fm.media.duration_ms;
20
+ return c;
21
+ }
22
+
23
+ // `both` keeps its existing meaning — measured AND frames are present — which is what
24
+ // refresh.mjs is really asking when it says "needs measured or both, not frames": is there
25
+ // a URL here I can re-measure.
26
+ function summaryMethod(captures) {
27
+ const measured = captures.some((c) => c.method === 'measured' || c.method === 'both');
28
+ const frames = captures.some((c) => c.method === 'frames' || c.method === 'both');
29
+ if (measured && frames) return 'both';
30
+ return measured ? 'measured' : 'frames';
31
+ }
32
+
33
+ export async function attachCapture({ entriesDir, slug, capture, palette, type, media, sourceUrl, tool }) {
34
+ const dir = path.join(entriesDir, slug);
35
+ const e = parseEntry(dir);
36
+ const fm = e.fm;
37
+
38
+ // An EMPTY captures[] is treated like an absent one: the entry still describes a capture in
39
+ // its top-level fields, and nextCaptureIndex counts it as capture 1, so synthesising it here
40
+ // is what keeps the list and the captures/<n>/ paths agreeing.
41
+ const captures = fm.captured.captures?.length ? fm.captured.captures : [existingCaptureFrom(fm)];
42
+ captures.push(capture);
43
+
44
+ // Fill only if blank. palette and type are mechanically derived, but the session may have
45
+ // corrected them, and an attach must never overwrite a decision it did not make.
46
+ if (palette && isBlank(fm.palette)) fm.palette = palette;
47
+ if (type && isBlank(fm.type)) fm.type = type;
48
+ if (media && isBlank(fm.media)) fm.media = media;
49
+ // Same rule for source.url: an entry ingested from a local file has none, and without this
50
+ // a measured capture attached to it leaves refresh refusing "needs source.url" forever.
51
+ if (sourceUrl && isBlank(fm.source?.url)) fm.source = { ...fm.source, url: sourceUrl };
52
+
53
+ fm.captured = {
54
+ ...fm.captured,
55
+ method: summaryMethod(captures),
56
+ at: new Date().toISOString().slice(0, 10),
57
+ tool: tool ?? fm.captured.tool,
58
+ captures,
59
+ };
60
+
61
+ const agreement = await computeAgreement({ fm, dir });
62
+ if (agreement) fm.agreement = agreement;
63
+
64
+ const body = e.body ? `\n${e.body}\n` : '';
65
+ fs.writeFileSync(path.join(dir, 'entry.md'), `---\n${yaml.dump(fm, { lineWidth: 120 })}---\n${body}`);
66
+ return { dir, captures, method: fm.captured.method };
67
+ }
@@ -10,13 +10,16 @@ function isBlank(v) {
10
10
  return v == null || v === '' || (Array.isArray(v) && v.length === 0);
11
11
  }
12
12
 
13
- export function writeDraft({ entriesDir, slug, title, source, media, captured, palette, type }) {
13
+ export function writeDraft({ entriesDir, slug, title, source, media, captured, palette, type, captures }) {
14
14
  const dir = path.join(entriesDir, slug);
15
15
  fs.mkdirSync(dir, { recursive: true });
16
16
  const fm = {
17
17
  slug, title, tier: 'inspiration',
18
18
  source: { kind: source.kind, url: source.url ?? null, license: source.license },
19
- captured, media,
19
+ // captures[] is additive: an entry without it means a single capture, so every entry
20
+ // written before this change stays valid and nothing migrates.
21
+ captured: captures ? { ...captured, captures } : captured,
22
+ media,
20
23
  roles: [], feel: [],
21
24
  palette: palette ?? { canvas: '', ink: '', accent: '' },
22
25
  type: type ?? { display: '', body: '' },
@@ -59,20 +59,34 @@ async function videoFrames(input, want) {
59
59
  }
60
60
  }
61
61
 
62
- export async function compositeStrip(tiles, w, h, outPng) {
62
+ // A strip is a contact sheet, and these ship inside the npm package: a six-column sheet of
63
+ // 1440-wide frames is 9600x6000, ~50 MB as PNG, and 56 of them made the tarball too large for
64
+ // npm to build at all. 2400px leaves ~400px per frame, which keeps a headline and a nav
65
+ // readable; 1800 does not. The single write point for every strip in the library.
66
+ const STRIP_WIDTH = 2400, STRIP_QUALITY = 80;
67
+ export function writeStrip(pipeline, out) {
68
+ return pipeline.resize({ width: STRIP_WIDTH, withoutEnlargement: true }).webp({ quality: STRIP_QUALITY }).toFile(out);
69
+ }
70
+
71
+ export async function compositeStrip(tiles, w, h, out) {
63
72
  const cols = tiles.length > 12 ? 6 : Math.min(3, tiles.length), rows = Math.ceil(tiles.length / cols);
64
- await sharp({ create: { width: w * cols, height: h * rows, channels: 3, background: '#000' } })
73
+ // Composited to a buffer at grid size and only then handed to writeStrip. sharp applies
74
+ // resize BEFORE composite whatever order they are called in, so resizing the same pipeline
75
+ // would lay unscaled tiles onto a shrunken canvas — clipping the last column, or refusing
76
+ // outright once a tile is taller than the resized sheet.
77
+ const grid = await sharp({ create: { width: w * cols, height: h * rows, channels: 3, background: '#000' } })
65
78
  .composite(tiles.map((b, k) => ({ input: b, left: (k % cols) * w, top: Math.floor(k / cols) * h })))
66
- .png().toFile(outPng);
79
+ .png().toBuffer();
80
+ await writeStrip(sharp(grid), out);
67
81
  }
68
82
 
69
- export async function sampleFrames(input, outPng, { frames: want } = {}) {
83
+ export async function sampleFrames(input, out, { frames: want } = {}) {
70
84
  if (VIDEO.test(input)) {
71
85
  const { files, durationMs, cleanup } = await videoFrames(input, want);
72
86
  try {
73
87
  const tiles = await Promise.all(files.map((f) => sharp(f).png().toBuffer()));
74
88
  const first = await sharp(files[0]).metadata();
75
- await compositeStrip(tiles, first.width, first.height, outPng);
89
+ await compositeStrip(tiles, first.width, first.height, out);
76
90
  return { frames: files.length, durationMs, sampled: files.map((_, i) => i) };
77
91
  } finally {
78
92
  cleanup();
@@ -88,6 +102,6 @@ export async function sampleFrames(input, outPng, { frames: want } = {}) {
88
102
  const sampled = n >= frames ? [...Array(frames).keys()] : [...Array(n).keys()].map((i) => Math.round((i * (frames - 1)) / (n - 1)));
89
103
  const w = meta.width, h = meta.pageHeight ?? meta.height;
90
104
  const tiles = await Promise.all(sampled.map((i) => sharp(input, { page: i }).png().toBuffer()));
91
- await compositeStrip(tiles, w, h, outPng);
105
+ await compositeStrip(tiles, w, h, out);
92
106
  return { frames, durationMs, sampled };
93
107
  }
@@ -1,9 +1,34 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import sharp from 'sharp';
3
4
 
4
5
  const VIEWPORT_WIDE = { width: 1440, height: 900 };
5
6
  const VIEWPORT_NARROW = { width: 390, height: 844 };
6
7
 
8
+ export function captureFailure({ status, title = '', bodyText = '' }) {
9
+ if ([401, 403, 429, 451].includes(status)) return `page refused capture (HTTP ${status})`;
10
+ if (status >= 400) return `page returned HTTP ${status}`;
11
+ const text = `${title}\n${bodyText}`.replace(/\s+/g, ' ').toLowerCase();
12
+ if (/sorry,? you have been blocked|unable to access|attention required|cf-ray|access denied/.test(text))
13
+ return 'page refused capture (block page detected)';
14
+ // A loaded document with no readable page content is normally a still-hydrating
15
+ // application or a challenge screen. Do not preserve its spinner as a reference.
16
+ if (bodyText.replace(/\s+/g, '').length < 40) return 'page did not render meaningful content';
17
+ return null;
18
+ }
19
+
20
+ async function blankScreenshotReason(file) {
21
+ const { data } = await sharp(file).resize(48, 30, { fit: 'fill' }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
22
+ const colours = new Set();
23
+ const counts = new Map();
24
+ for (let i = 0; i < data.length; i += 3) {
25
+ const key = `${data[i] >> 4},${data[i + 1] >> 4},${data[i + 2] >> 4}`;
26
+ colours.add(key); counts.set(key, (counts.get(key) || 0) + 1);
27
+ }
28
+ const dominant = Math.max(...counts.values()) / (data.length / 3);
29
+ return colours.size <= 2 || dominant >= 0.995 ? 'page screenshot did not render meaningful content' : null;
30
+ }
31
+
7
32
  // Runs inside the page via page.evaluate — no Node globals, no sharp, nothing
8
33
  // from this module's closure crosses the Playwright boundary.
9
34
  function collect() {
@@ -111,7 +136,13 @@ export async function measure(url, outDir, { chrome = process.env.CHROME_PATH, r
111
136
  const page = await browser.newPage();
112
137
  await page.setViewportSize(VIEWPORT_WIDE);
113
138
  try {
114
- await page.goto(url, { waitUntil: 'load', timeout: 45000 });
139
+ const response = await page.goto(url, { waitUntil: 'load', timeout: 45000 });
140
+ // Let client-rendered pages complete their initial hydration before judging
141
+ // the document or writing any image artifact.
142
+ await page.waitForTimeout(1000);
143
+ const diagnostic = await page.evaluate(() => ({ title: document.title, bodyText: document.body?.innerText || '' }));
144
+ const failure = captureFailure({ status: response?.status(), ...diagnostic });
145
+ if (failure) return { ok: false, error: failure };
115
146
  } catch (err) {
116
147
  return { ok: false, error: `page failed to load: ${err.message}` };
117
148
  }
@@ -119,6 +150,8 @@ export async function measure(url, outDir, { chrome = process.env.CHROME_PATH, r
119
150
  fs.mkdirSync(outDir, { recursive: true });
120
151
  const shot1440 = path.join(outDir, 'screen-1440.png');
121
152
  await page.screenshot({ path: shot1440, fullPage: false });
153
+ const blankShot = await blankScreenshotReason(shot1440);
154
+ if (blankShot) return { ok: false, error: blankShot };
122
155
  screenshots[1440] = shot1440;
123
156
 
124
157
  if (record) {
@@ -0,0 +1,29 @@
1
+ import sharp from 'sharp';
2
+
3
+ // Dominant colours as a pixel-frequency histogram, NOT a perceptual palette. A strip is
4
+ // frames stitched together, so a colour filling most of the running time dominates whether
5
+ // or not a viewer would call it the palette — that ceiling is recorded in the README.
6
+ //
7
+ // Four bits per channel keeps near-identical shades in one bucket. Resizing to 32x32 makes
8
+ // the cost constant regardless of how long the strip is, and interpolates at band edges,
9
+ // which is why this returns the top N rather than the whole histogram.
10
+ export async function observedColours(imagePath, { top = 3 } = {}) {
11
+ const { data, info } = await sharp(imagePath)
12
+ .resize(32, 32, { fit: 'fill' })
13
+ .raw()
14
+ .toBuffer({ resolveWithObject: true });
15
+
16
+ const counts = new Map();
17
+ for (let i = 0; i < data.length; i += info.channels) {
18
+ const key = ((data[i] >> 4) << 8) | ((data[i + 1] >> 4) << 4) | (data[i + 2] >> 4);
19
+ counts.set(key, (counts.get(key) ?? 0) + 1);
20
+ }
21
+
22
+ return [...counts.entries()]
23
+ // Count descending, then key ascending: without the tie-break, two colours with equal
24
+ // pixel counts would order by Map insertion and the result would not be reproducible.
25
+ .sort((a, b) => b[1] - a[1] || a[0] - b[0])
26
+ .slice(0, top)
27
+ .map(([key]) => '#' + [(key >> 8) & 15, (key >> 4) & 15, key & 15]
28
+ .map((v) => (v * 17).toString(16).padStart(2, '0')).join(''));
29
+ }
package/src/mcp/http.mjs CHANGED
@@ -44,7 +44,7 @@ async function handle(req, res, p, token, embedder) {
44
44
  }
45
45
  // req.url, not url.pathname: WHATWG URL parsing already folds `%2e%2e`/`..` before
46
46
  // serveFile's own prefix check would ever see them (see src/ui/serve.mjs).
47
- return serveFile(path.join(p.data, 'ui'), req.url, res);
47
+ return serveFile(path.join(p.data, 'ui'), req.url, res, req);
48
48
  }
49
49
 
50
50
  export async function serveHttp() {
@@ -14,4 +14,5 @@ Where the project's own conventions and this library disagree, the project wins.
14
14
 
15
15
  BUDGET: a tool result is re-read on every later turn, so a study costs the transcript, not the call. One recommend, or one search per role you actually need, then get_entry on the three to five you keep, is a complete study. A further search costs more than it finds. Use get_entry with detail:"summary" to decide, detail:"full" only to build.
16
16
 
17
- READING A HIT: ranks.fts null means it matched by semantic distance alone, with no keyword in common. A role whose hits are all weak has no reference — report that rather than filling it with a weak one.`;
17
+ READING A HIT: ranks.fts null means it matched by semantic distance alone, with no keyword in common. A role whose hits are all weak has no reference — report that rather than filling it with a weak one.
18
+ Use get_motion for timestamped images and optional video URLs when studying motion.`;
@@ -16,7 +16,7 @@ export function addEntryPrompt(ctx, a) {
16
16
  Arguments given:
17
17
  ${given || '- (none)'}
18
18
 
19
- The server does the mechanical half: it samples frames into \`strip.png\` and writes a
19
+ The server does the mechanical half: it samples frames into \`strip.webp\` and writes a
20
20
  draft with the judgement fields blank. **The session is the tagger.** Nothing in the
21
21
  server guesses what a design feels like, and a draft is never saved with a guess the
22
22
  session did not make.
@@ -56,6 +56,17 @@ session did not make.
56
56
  --title "content: <title>" --body "<one line: source kind and licence>"\`. No attribution
57
57
  lines in the commit. The hosted instance picks the entry up on the redeploy after merge.
58
58
 
59
+ ## When the draft holds more than one capture
60
+
61
+ \`captured.captures\` lists every capture on this entry. Open the strip of **each** one, not
62
+ only the first — a second source exists because it shows something the first cannot, and
63
+ reading one of two is worse than reading one, because it looks complete.
64
+
65
+ The body then carries a \`## Sources\` section: one line per capture naming what it
66
+ contributed, and one line for each place they disagree, saying which source you trusted and
67
+ why. If an \`agreement\` block is present the server has already recorded the declared and
68
+ observed colours; it makes no claim about which is right, and saying so is your job.
69
+
59
70
  ## Tagging rubric
60
71
 
61
72
  Read the whole page before tagging any section: roster first, then feel, then motion.
@@ -1,8 +1,22 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { parseEntry } from '../entry.mjs';
5
+ import { motionFrame } from '../motion.mjs';
4
6
 
5
7
  export function registerResources(server, ctx) {
8
+ server.registerResource(
9
+ 'motion-frame',
10
+ new ResourceTemplate('library://motion/{slug}/{clip}/{frame}', { list: undefined }),
11
+ { title: 'Motion frame', description: 'One timestamped WebP frame from a motion clip.', mimeType: 'image/webp' },
12
+ async (uri, { slug, clip, frame }) => {
13
+ if (!/^[a-z0-9-]+$/.test(slug) || !/^[a-z0-9-]+$/.test(clip) || !/^(?:[0-9]|1[01])$/.test(frame))
14
+ throw new Error('invalid motion frame');
15
+ const entry = parseEntry(path.join(ctx.paths.entries, slug));
16
+ const image = motionFrame(entry, clip, Number(frame));
17
+ return { contents: [{ uri: uri.href, mimeType: 'image/webp', blob: image.data }] };
18
+ },
19
+ );
6
20
  server.registerResource(
7
21
  'vocab',
8
22
  'library://vocab',