@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.
- package/.env.example +7 -0
- package/README.md +52 -9
- package/bin/library.mjs +11 -0
- package/entries/ada/entry.md +55 -0
- package/entries/ada/strip.webp +0 -0
- package/entries/air/captures/2/measure.json +325 -0
- package/entries/air/captures/2/strip.webp +0 -0
- package/entries/air/entry.md +62 -0
- package/entries/air/strip.webp +0 -0
- package/entries/ais-community-dark-depth/strip.webp +0 -0
- package/entries/apple-home/captures/2/measure.json +91 -0
- package/entries/apple-home/captures/2/strip.webp +0 -0
- package/entries/apple-home/entry.md +64 -0
- package/entries/apple-home/strip.webp +0 -0
- package/entries/apple-macbook-neo/captures/2/measure.json +570 -0
- package/entries/apple-macbook-neo/captures/2/strip.webp +0 -0
- package/entries/apple-macbook-neo/entry.md +62 -0
- package/entries/apple-macbook-neo/strip.webp +0 -0
- package/entries/apple-watch-ultra-4/entry.md +63 -0
- package/entries/apple-watch-ultra-4/measure.json +481 -0
- package/entries/apple-watch-ultra-4/strip.webp +0 -0
- package/entries/bpco/captures/2/strip.webp +0 -0
- package/entries/bpco/captures/3/strip.webp +0 -0
- package/entries/bpco/entry.md +81 -0
- package/entries/bpco/strip.webp +0 -0
- package/entries/column/captures/2/measure.json +996 -0
- package/entries/column/captures/2/strip.webp +0 -0
- package/entries/column/entry.md +82 -0
- package/entries/column/strip.webp +0 -0
- package/entries/cthdrl/captures/2/measure.json +251 -0
- package/entries/cthdrl/captures/2/strip.webp +0 -0
- package/entries/cthdrl/entry.md +86 -0
- package/entries/cthdrl/strip.webp +0 -0
- package/entries/dala-craftedbygc/entry.md +69 -0
- package/entries/dala-craftedbygc/strip.webp +0 -0
- package/entries/dock/captures/2/measure.json +206 -0
- package/entries/dock/captures/2/strip.webp +0 -0
- package/entries/dock/entry.md +62 -0
- package/entries/dock/strip.webp +0 -0
- package/entries/dub/captures/2/measure.json +37 -0
- package/entries/dub/captures/2/strip.webp +0 -0
- package/entries/dub/entry.md +61 -0
- package/entries/dub/strip.webp +0 -0
- package/entries/duolingo/captures/2/measure.json +138 -0
- package/entries/duolingo/captures/2/strip.webp +0 -0
- package/entries/duolingo/entry.md +65 -0
- package/entries/duolingo/strip.webp +0 -0
- package/entries/elevenlabs/captures/2/measure.json +160 -0
- package/entries/elevenlabs/captures/2/strip.webp +0 -0
- package/entries/elevenlabs/entry.md +62 -0
- package/entries/elevenlabs/strip.webp +0 -0
- package/entries/flying-papers/captures/2/measure.json +291 -0
- package/entries/flying-papers/captures/2/strip.webp +0 -0
- package/entries/flying-papers/entry.md +61 -0
- package/entries/flying-papers/strip.webp +0 -0
- package/entries/gsap/captures/2/measure.json +120 -0
- package/entries/gsap/captures/2/strip.webp +0 -0
- package/entries/gsap/entry.md +62 -0
- package/entries/gsap/strip.webp +0 -0
- package/entries/letters/captures/2/measure.json +20 -0
- package/entries/letters/captures/2/strip.webp +0 -0
- package/entries/letters/entry.md +62 -0
- package/entries/letters/strip.webp +0 -0
- package/entries/micro/captures/2/measure.json +852 -0
- package/entries/micro/captures/2/strip.webp +0 -0
- package/entries/micro/entry.md +61 -0
- package/entries/micro/strip.webp +0 -0
- package/entries/monopo-saigon/captures/2/measure.json +145 -0
- package/entries/monopo-saigon/captures/2/strip.webp +0 -0
- package/entries/monopo-saigon/entry.md +64 -0
- package/entries/monopo-saigon/strip.webp +0 -0
- package/entries/notion/captures/2/measure.json +338 -0
- package/entries/notion/captures/2/strip.webp +0 -0
- package/entries/notion/entry.md +66 -0
- package/entries/notion/strip.webp +0 -0
- package/entries/origin-financial/captures/2/measure.json +169 -0
- package/entries/origin-financial/captures/2/strip.webp +0 -0
- package/entries/origin-financial/entry.md +65 -0
- package/entries/origin-financial/strip.webp +0 -0
- package/entries/oryzo-ai/captures/2/measure.json +39 -0
- package/entries/oryzo-ai/captures/2/strip.webp +0 -0
- package/entries/oryzo-ai/entry.md +76 -0
- package/entries/oryzo-ai/strip.webp +0 -0
- package/entries/silencio/captures/2/strip.webp +0 -0
- package/entries/silencio/entry.md +114 -0
- package/entries/silencio/motion/product-story/frame-0.webp +0 -0
- package/entries/silencio/motion/product-story/frame-1.webp +0 -0
- package/entries/silencio/motion/product-story/frame-2.webp +0 -0
- package/entries/silencio/motion/product-story/frame-3.webp +0 -0
- package/entries/silencio/motion/product-story/frame-4.webp +0 -0
- package/entries/silencio/motion/product-story/frame-5.webp +0 -0
- package/entries/silencio/motion/product-story/frame-6.webp +0 -0
- package/entries/silencio/motion/product-story/frame-7.webp +0 -0
- package/entries/silencio/strip.webp +0 -0
- package/entries/steep/captures/2/measure.json +20 -0
- package/entries/steep/captures/2/strip.webp +0 -0
- package/entries/steep/entry.md +63 -0
- package/entries/steep/strip.webp +0 -0
- package/entries/stripe/captures/2/measure.json +892 -0
- package/entries/stripe/captures/2/strip.webp +0 -0
- package/entries/stripe/entry.md +61 -0
- package/entries/stripe/strip.webp +0 -0
- package/entries/superpower/captures/2/measure.json +179 -0
- package/entries/superpower/captures/2/strip.webp +0 -0
- package/entries/superpower/entry.md +64 -0
- package/entries/superpower/strip.webp +0 -0
- package/entries/superr/entry.md +54 -0
- package/entries/superr/strip.webp +0 -0
- package/entries/svz-weare/captures/2/measure.json +129 -0
- package/entries/svz-weare/captures/2/strip.webp +0 -0
- package/entries/svz-weare/entry.md +147 -0
- package/entries/svz-weare/motion/hero-transition/frame-0.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-1.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-2.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-3.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-4.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-5.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-6.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-7.webp +0 -0
- package/entries/svz-weare/strip.webp +0 -0
- package/entries/thoughtlab/captures/2/measure.json +93 -0
- package/entries/thoughtlab/captures/2/strip.webp +0 -0
- package/entries/thoughtlab/entry.md +62 -0
- package/entries/thoughtlab/strip.webp +0 -0
- package/entries/ventriloc/entry.md +54 -0
- package/entries/ventriloc/strip.webp +0 -0
- package/entries/vivid-and-co/entry.md +58 -0
- package/entries/vivid-and-co/strip.webp +0 -0
- package/package.json +24 -5
- package/src/cli/add.mjs +100 -24
- package/src/cli/motion.mjs +168 -0
- package/src/cli/refresh.mjs +6 -0
- package/src/cli/ui.mjs +2 -1
- package/src/entry.mjs +14 -1
- package/src/index/build.mjs +9 -3
- package/src/index/embed.mjs +1 -1
- package/src/ingest/agreement.mjs +33 -0
- package/src/ingest/attach.mjs +67 -0
- package/src/ingest/draft.mjs +5 -2
- package/src/ingest/frames.mjs +20 -6
- package/src/ingest/measure.mjs +34 -1
- package/src/ingest/observed.mjs +29 -0
- package/src/mcp/http.mjs +1 -1
- package/src/mcp/instructions.mjs +2 -1
- package/src/mcp/prompts.mjs +12 -1
- package/src/mcp/resources.mjs +14 -0
- package/src/mcp/tools.mjs +48 -5
- package/src/motion.mjs +94 -0
- package/src/ui/build.mjs +83 -7
- package/src/ui/serve.mjs +29 -4
- package/src/ui/templates/entry.html +9 -1
- package/entries/ais-community-dark-depth/strip.png +0 -0
- package/tests/README.md +0 -33
- package/tests/checks/cli-check.sh +0 -13
- package/tests/checks/docker.sh +0 -10
- package/tests/checks/entry-detail.sh +0 -52
- package/tests/checks/entry.sh +0 -55
- package/tests/checks/export.sh +0 -16
- package/tests/checks/fetch-on-start.sh +0 -26
- package/tests/checks/frames-dense.sh +0 -27
- package/tests/checks/http.sh +0 -22
- package/tests/checks/hygiene.sh +0 -29
- package/tests/checks/inbox.sh +0 -27
- package/tests/checks/index-degrade.sh +0 -32
- package/tests/checks/index.sh +0 -36
- package/tests/checks/ingest-mp4.sh +0 -47
- package/tests/checks/ingest.sh +0 -39
- package/tests/checks/instructions.sh +0 -31
- package/tests/checks/licence-gate.sh +0 -24
- package/tests/checks/mcp-stdout.sh +0 -31
- package/tests/checks/measure.sh +0 -79
- package/tests/checks/minors.sh +0 -84
- package/tests/checks/optional-deps.sh +0 -55
- package/tests/checks/prompt-add-entry.sh +0 -28
- package/tests/checks/recommend.sh +0 -107
- package/tests/checks/resources.sh +0 -47
- package/tests/checks/rrf.sh +0 -120
- package/tests/checks/seed-sync.sh +0 -22
- package/tests/checks/similar.sh +0 -62
- package/tests/checks/ui-build.sh +0 -59
- package/tests/checks/vocab.sh +0 -30
- package/tests/fixtures/entry-ok/entry.md +0 -41
- package/tests/fixtures/entry-ok/strip.png +0 -0
- package/tests/fixtures/page/index.html +0 -47
- package/tests/fixtures/three-frame.webp +0 -0
- 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
|
+
}
|
package/src/cli/refresh.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/src/index/build.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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++;
|
package/src/index/embed.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/ingest/draft.mjs
CHANGED
|
@@ -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
|
-
|
|
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: '' },
|
package/src/ingest/frames.mjs
CHANGED
|
@@ -59,20 +59,34 @@ async function videoFrames(input, want) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
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
|
-
|
|
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().
|
|
79
|
+
.png().toBuffer();
|
|
80
|
+
await writeStrip(sharp(grid), out);
|
|
67
81
|
}
|
|
68
82
|
|
|
69
|
-
export async function sampleFrames(input,
|
|
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,
|
|
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,
|
|
105
|
+
await compositeStrip(tiles, w, h, out);
|
|
92
106
|
return { frames, durationMs, sampled };
|
|
93
107
|
}
|
package/src/ingest/measure.mjs
CHANGED
|
@@ -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() {
|
package/src/mcp/instructions.mjs
CHANGED
|
@@ -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.`;
|
package/src/mcp/prompts.mjs
CHANGED
|
@@ -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.
|
|
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.
|
package/src/mcp/resources.mjs
CHANGED
|
@@ -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',
|