paper-motion 0.2.7 → 0.4.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.
- package/CHANGELOG.md +119 -0
- package/bin/pamo.mjs +332 -10
- package/examples/breath/stills/contact-sheet.jpg +0 -0
- package/examples/breath/stills/t000.00.png +0 -0
- package/examples/breath/stills/t002.00.png +0 -0
- package/examples/breath/stills/t004.00.png +0 -0
- package/examples/breath/stills/t006.00.png +0 -0
- package/examples/breath/stills/t008.00.png +0 -0
- package/examples/breath/stills/t010.00.png +0 -0
- package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
- package/examples/chrome-endcard/stills/t000.00.png +0 -0
- package/examples/chrome-endcard/stills/t001.33.png +0 -0
- package/examples/chrome-endcard/stills/t002.67.png +0 -0
- package/examples/chrome-endcard/stills/t004.00.png +0 -0
- package/examples/chrome-endcard/stills/t005.33.png +0 -0
- package/examples/chrome-endcard/stills/t006.67.png +0 -0
- package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
- package/examples/halo-explainer/stills/t000.00.png +0 -0
- package/examples/halo-explainer/stills/t006.00.png +0 -0
- package/examples/halo-explainer/stills/t012.00.png +0 -0
- package/examples/halo-explainer/stills/t018.00.png +0 -0
- package/examples/halo-explainer/stills/t024.00.png +0 -0
- package/examples/halo-explainer/stills/t030.00.png +0 -0
- package/examples/install-loop/stills/contact-sheet.jpg +0 -0
- package/examples/install-loop/stills/t000.00.png +0 -0
- package/examples/install-loop/stills/t001.00.png +0 -0
- package/examples/install-loop/stills/t002.00.png +0 -0
- package/examples/install-loop/stills/t003.00.png +0 -0
- package/examples/install-loop/stills/t004.00.png +0 -0
- package/examples/install-loop/stills/t005.00.png +0 -0
- package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
- package/examples/orbit-keynote/stills/t000.00.png +0 -0
- package/examples/orbit-keynote/stills/t006.50.png +0 -0
- package/examples/orbit-keynote/stills/t013.00.png +0 -0
- package/examples/orbit-keynote/stills/t019.50.png +0 -0
- package/examples/orbit-keynote/stills/t026.00.png +0 -0
- package/examples/orbit-keynote/stills/t032.50.png +0 -0
- package/examples/signal/stills/contact-sheet.jpg +0 -0
- package/examples/signal/stills/t000.00.png +0 -0
- package/examples/signal/stills/t001.67.png +0 -0
- package/examples/signal/stills/t003.33.png +0 -0
- package/examples/signal/stills/t005.00.png +0 -0
- package/examples/signal/stills/t006.67.png +0 -0
- package/examples/signal/stills/t008.33.png +0 -0
- package/lib/chain.mjs +366 -0
- package/lib/customize.mjs +40 -2
- package/lib/edit.mjs +326 -0
- package/lib/gather.mjs +241 -0
- package/lib/host.mjs +25 -0
- package/lib/inventory.mjs +523 -0
- package/lib/live-board.js +3 -1
- package/lib/piece.mjs +223 -0
- package/lib/registry.mjs +45 -11
- package/lib/studio.mjs +572 -0
- package/package.json +2 -1
- package/runtime/brand.js +1 -1
- package/runtime/index.js +1 -0
- package/runtime/player.js +6 -3
- package/runtime/swatch.html +21 -0
- package/studio/fonts/GeistPixel-Square.woff2 +0 -0
- package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
- package/studio/index.html +503 -0
package/lib/edit.mjs
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Your video: the piece as the user edits it in the studio. Everything here needs no judgment, so the studio does it
|
|
2
|
+
// itself and the agent only reads what happened: the words and numbers of a part, how it arrives, its background and
|
|
3
|
+
// settings, its length, the transition after it, the order, adding, duplicating, swapping and removing parts, the
|
|
4
|
+
// brand's colours, type and pace, and building the first version from the inventory. Every change can be undone.
|
|
5
|
+
//
|
|
6
|
+
// The files stay the source of truth: chain.json (order, seconds, joins), each part's scene.html CONFIG (its content
|
|
7
|
+
// and settings), brand/tokens.json and brand/motion.json (the style).
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import vm from 'node:vm';
|
|
12
|
+
import { fillConfig, evalFill, normalize, loadRaw, propose, writePlan, describe } from './inventory.mjs';
|
|
13
|
+
|
|
14
|
+
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
15
|
+
const writeJson = (f, v) => fs.writeFileSync(f, JSON.stringify(v, null, 2) + '\n');
|
|
16
|
+
const human = (k) => k.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
17
|
+
|
|
18
|
+
// ── a part's CONFIG ────────────────────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** The CONFIG object of a scene, read as data (it is a literal: no code of the scene runs). */
|
|
21
|
+
export function readConfig(html) {
|
|
22
|
+
const start = html.indexOf('const CONFIG = {'), end = html.indexOf('\n};', start);
|
|
23
|
+
if (start < 0 || end < 0) return {};
|
|
24
|
+
try { return vm.runInNewContext(`(${html.slice(start + 15, end + 2)})`, Object.create(null), { timeout: 200 }); } catch { return {}; }
|
|
25
|
+
}
|
|
26
|
+
const partFile = (dir, part) => path.join(dir, part.dir, 'scene.html');
|
|
27
|
+
|
|
28
|
+
// What a person may edit in a part, by how it is edited: words (`text`, `lines`, `rows`, `numbers`, `image`), and the
|
|
29
|
+
// labels they read. The block's own content keys come from its `needs`; its other words from its main text params.
|
|
30
|
+
const LABEL = {
|
|
31
|
+
text: 'Sentence', mark: 'Words in your accent colour', caption: 'Caption', title: 'Title', line: 'Line under it', label: 'What it counts',
|
|
32
|
+
command: 'Command', output: 'What it prints', cwd: 'Folder', working: 'While it works', stats: 'Numbers', image: 'Picture', url: 'Address',
|
|
33
|
+
word: 'The word', name: 'Your name', domain: 'Where to go', corner: 'In the corner', a: 'First line', b: 'Then', bLine: 'Line under it',
|
|
34
|
+
before: 'Before', after: 'After', words: 'The words that change', items: 'Items', toasts: 'Notifications', bars: 'Bars', points: 'Values',
|
|
35
|
+
labels: 'Labels', tag: 'Note', value: 'Number', rows: 'Rows', fields: 'Fields', button: 'The button', done: 'When it is done', answer: 'What it shows',
|
|
36
|
+
query: 'What is typed', placeholder: 'Placeholder', script: 'Script', emphasis: 'Words to stress', shots: 'Words, one per beat', toggle: 'The switch',
|
|
37
|
+
file: 'File name', code: 'Code', highlight: 'Line to light up', add: 'New lines', remove: 'Lines that go', svg: 'Logo (SVG)', columns: 'Columns', move: 'The move',
|
|
38
|
+
};
|
|
39
|
+
const SKIP = new Set(['background', 'format', 'entrance', 'metal']);
|
|
40
|
+
function asField(key, value) {
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
if (value.every((x) => typeof x === 'string' || typeof x === 'number')) return { key, kind: 'lines', value: value.join('\n') };
|
|
43
|
+
if (value.every((x) => Array.isArray(x))) {
|
|
44
|
+
// a column empty in every row (a unit nobody uses) is left out of what the person edits, and put back on save
|
|
45
|
+
const shape = value[0]?.map((c) => (typeof c === 'number' ? 'n' : Array.isArray(c) ? 'list' : 's')) ?? [];
|
|
46
|
+
const hide = shape.map((_, i) => value.every((r) => r[i] === '' || r[i] == null));
|
|
47
|
+
return { key, kind: 'rows', value: value.map((r) => r.filter((_, i) => !hide[i]).map((c) => (Array.isArray(c) ? c.join(', ') : c)).join(' | ')).join('\n'), shape, hide, columns: shape.length - hide.filter(Boolean).length };
|
|
48
|
+
}
|
|
49
|
+
if (value.every((x) => x && typeof x === 'object' && 'text' in x)) return { key, kind: 'lines', value: value.map((x) => x.text).join('\n'), objects: true };
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (value && typeof value === 'object') return Object.entries(value).filter(([, v]) => typeof v !== 'object').map(([k, v]) => ({ key: `${key}.${k}`, kind: typeof v === 'number' ? 'number' : 'text', value: String(v ?? ''), label: `${LABEL[key] ?? human(key)}: ${(LABEL[k] ?? human(k)).toLowerCase()}` }));
|
|
53
|
+
if (typeof value === 'number') return { key, kind: 'number', value: String(value) };
|
|
54
|
+
if (typeof value === 'string') return { key, kind: key === 'image' || /\.(jpe?g|png|webp)$/i.test(value) ? 'image' : value.includes('\n') ? 'block' : 'text', value };
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
export function wordFields(block, cfg) {
|
|
58
|
+
// the content a block always shows (an optional need, such as a track's tempo, is a setting, not words)
|
|
59
|
+
const keys = new Set(block.needs?.filter((n) => !n.optional).flatMap((n) => Object.keys(n.fill ?? {})) ?? []);
|
|
60
|
+
for (const p of block.params ?? []) if (p.type === 'string' && p.group === 'main' && p.control === 'text') keys.add(p.name);
|
|
61
|
+
return [...keys].filter((k) => !SKIP.has(k) && k in cfg).flatMap((k) => [asField(k, cfg[k])].flat().filter(Boolean)).map((f) => ({ ...f, label: f.label ?? LABEL[f.key] ?? human(f.key) }));
|
|
62
|
+
}
|
|
63
|
+
/** A field's text back into its CONFIG value, shaped like the value it replaces. */
|
|
64
|
+
function fromField(key, text, cfg) {
|
|
65
|
+
const [k, sub] = key.split('.'), old = cfg[k];
|
|
66
|
+
if (sub) { const o = { ...(old ?? {}) }; o[sub] = typeof old?.[sub] === 'number' && Number.isFinite(Number(text)) ? Number(text) : text; return [k, o]; }
|
|
67
|
+
const f = asField(k, old);
|
|
68
|
+
const rows = String(text ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
69
|
+
if (!f || f.kind === 'text' || f.kind === 'block' || f.kind === 'image') return [k, String(text ?? '')];
|
|
70
|
+
if (f.kind === 'number') { const n = Number(text); if (!Number.isFinite(n)) throw new Error('a number, please'); return [k, n]; }
|
|
71
|
+
if (f.kind === 'lines' && f.objects) return [k, rows.map((t, i) => ({ check: true, text: t.replace(/^[✓✔√]\s*/, ''), ...(i === rows.length - 1 ? { result: true } : {}) }))];
|
|
72
|
+
if (f.kind === 'lines') return [k, rows.map((t) => (typeof old?.[0] === 'number' && Number.isFinite(Number(t)) ? Number(t) : t))];
|
|
73
|
+
return [k, rows.map((l) => {
|
|
74
|
+
const typed = l.split(/\s*\|\s*/); let j = 0;
|
|
75
|
+
return f.shape.map((t, i) => { if (f.hide[i]) return t === 'n' ? 0 : ''; const c = typed[j++]; return t === 'n' ? Number(String(c ?? '0').replace(/,/g, '')) || 0 : t === 'list' ? String(c ?? '').split(/\s*,\s*/).filter(Boolean) : c ?? ''; });
|
|
76
|
+
})];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// What of a block a person may tune: its numbers and its either/or choices. The setting a repeating block varies
|
|
80
|
+
// (a sentence's entrance) is its Motion, chosen by watching each; the rest are Tune.
|
|
81
|
+
const NOT_TUNED = new Set(['format', 'background', 'fps', 'codec', 'winner', 'mark', 'detail', 'highlight']);
|
|
82
|
+
export function tunables(block, cfg = {}) {
|
|
83
|
+
const content = new Set(block.needs?.filter((n) => !n.optional).flatMap((n) => Object.keys(n.fill ?? {})) ?? []);
|
|
84
|
+
return (block.params ?? []).filter((p) => !content.has(p.name) && !NOT_TUNED.has(p.name) && p.name !== block.repeat?.param && (p.type === 'enum' || p.type === 'number')).map((p) => {
|
|
85
|
+
const d = p.default, num = p.type === 'number', min = num ? p.min ?? +(d * 0.4).toFixed(2) : null, max = num ? p.max ?? +(d * 1.8).toFixed(2) : null;
|
|
86
|
+
return { name: p.name, label: (p.description ?? p.name).replace(/^./, (c) => c.toUpperCase()), kind: num ? 'number' : 'enum', options: p.options ?? null, min, max, step: num ? +Math.max((max - min) / 40, 0.01).toFixed(2) : null, default: d, value: cfg[p.name] ?? d };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── undo ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// Before a change, the files it will touch are kept; undo puts the last set back. Forty steps.
|
|
92
|
+
const histFile = (dir) => path.join(dir, '.history.json');
|
|
93
|
+
export function snapshot(cwd, dir, files, label) {
|
|
94
|
+
const h = readJson(histFile(dir), []);
|
|
95
|
+
h.push({ label, at: new Date().toISOString(), files: Object.fromEntries(files.map((f) => [path.relative(cwd, f), fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : null])) });
|
|
96
|
+
writeJson(histFile(dir), h.slice(-40));
|
|
97
|
+
}
|
|
98
|
+
export function undo(cwd, dir) {
|
|
99
|
+
const h = readJson(histFile(dir), []), last = h.pop();
|
|
100
|
+
if (!last) throw new Error('nothing to undo');
|
|
101
|
+
for (const [rel, content] of Object.entries(last.files)) { const f = path.join(cwd, rel); if (content === null) fs.rmSync(f, { force: true }); else { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, content); } }
|
|
102
|
+
writeJson(histFile(dir), h);
|
|
103
|
+
return last.label;
|
|
104
|
+
}
|
|
105
|
+
export const canUndo = (dir) => readJson(histFile(dir), []).at(-1)?.label ?? null;
|
|
106
|
+
|
|
107
|
+
// ── reading the video ──────────────────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** The parts as the editor shows them: each with its block, its words, motion, background, settings and join. */
|
|
110
|
+
export function videoView(cwd, dir, scenes) {
|
|
111
|
+
const chain = readJson(path.join(dir, 'chain.json'), null);
|
|
112
|
+
if (!chain) return null;
|
|
113
|
+
const byId = new Map(scenes.map((b) => [b.id, b]));
|
|
114
|
+
const parts = (chain.parts ?? []).filter((p) => p.dir && fs.existsSync(partFile(dir, p))).map((p, i, all) => {
|
|
115
|
+
const html = fs.readFileSync(partFile(dir, p), 'utf8'), cfg = readConfig(html), b = byId.get(p.id) ?? { id: p.id, params: [], needs: [] };
|
|
116
|
+
const motion = b.repeat ? { param: b.repeat.param, value: cfg[b.repeat.param] ?? null, options: (b.params ?? []).find((x) => x.name === b.repeat.param)?.options ?? [], previews: b.repeat.previews ?? {} } : null;
|
|
117
|
+
return {
|
|
118
|
+
dir: p.dir, id: p.id, title: b.title ?? p.id, kind: b.needs?.[0]?.kind ?? null, summary: b.summary ?? '', seconds: p.seconds ?? b.duration ?? 4, duration: b.duration ?? null,
|
|
119
|
+
join: i < all.length - 1 ? p.join ?? chain.join ?? 'whip' : null, shows: p.shows ?? [], what: p.what ?? '',
|
|
120
|
+
words: wordFields(b, cfg), motion, background: cfg.background ?? null, takesBackground: (b.params ?? []).some((x) => x.name === 'background'), tune: tunables(b, cfg),
|
|
121
|
+
mtime: Math.round(fs.statSync(partFile(dir, p)).mtimeMs),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
return { title: chain.title, format: chain.format, parts, total: +parts.reduce((t, p) => t + Number(p.seconds || 0), 0).toFixed(1) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A sentence made ready for any block that shows one: the words to mark (its last few, when none were chosen) and
|
|
128
|
+
* the text around them, worked out again from the sentence as it is now. */
|
|
129
|
+
function complete(i) {
|
|
130
|
+
if (i.kind !== 'line' || !i.text) return i;
|
|
131
|
+
let mark = i.mark && i.text.includes(i.mark) ? i.mark : null;
|
|
132
|
+
if (!mark) { const w = i.text.trim().split(/\s+/); mark = w.slice(-Math.max(1, Math.ceil(w.length / 3))).join(' '); }
|
|
133
|
+
const at = i.text.lastIndexOf(mark);
|
|
134
|
+
return { ...i, mark, before: i.text.slice(0, at).trimEnd(), after: i.text.slice(at + mark.length).trimStart() };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The reverse of a fill: which item fields a part's CONFIG holds, for the plain "$field" entries of its template. */
|
|
138
|
+
function unfill(tpl, cfg) {
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const [k, v] of Object.entries(tpl ?? {})) if (typeof v === 'string' && v.startsWith('$') && !v.startsWith('$brief.') && cfg[k] !== undefined && typeof cfg[k] !== 'object') out[v.slice(1)] = cfg[k];
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── changing it ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
147
|
+
async function install(cwd, dir, blockId, format) {
|
|
148
|
+
const { add, getItem } = await import('./registry.mjs');
|
|
149
|
+
const it = await getItem(blockId);
|
|
150
|
+
const nums = fs.readdirSync(dir).map((d) => Number(d.match(/^(\d+)-/)?.[1] ?? 0)), sub = `${pad(Math.max(0, ...nums) + 1)}-${blockId}`;
|
|
151
|
+
const hasFormat = (it.params ?? []).some((x) => x.name === 'format');
|
|
152
|
+
await add([blockId], { cwd, force: true, name: path.join(path.relative(path.join(cwd, 'pieces'), dir), sub), set: hasFormat && format ? [`format=${format}`] : [] });
|
|
153
|
+
return { sub, item: it };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One edit to the video, from the studio. Returns { say } (what happened, in the user's words) and what to redraw.
|
|
158
|
+
* a: { type, part?, key?, value?, at?, block?, to? }
|
|
159
|
+
*/
|
|
160
|
+
export async function editVideo(cwd, dir, a, scenes, brief = {}) {
|
|
161
|
+
const chainFile = path.join(dir, 'chain.json'), chain = readJson(chainFile, null);
|
|
162
|
+
if (!chain && !['build'].includes(a.type)) throw new Error('there is no video yet');
|
|
163
|
+
const find = () => { const p = chain.parts.find((x) => x.dir === a.part); if (!p) throw new Error('no such part'); return p; };
|
|
164
|
+
const byId = new Map(scenes.map((b) => [b.id, b]));
|
|
165
|
+
const keep = (files, label) => snapshot(cwd, dir, files, label);
|
|
166
|
+
const setCfg = (p, values) => { const f = partFile(dir, p); fs.writeFileSync(f, fillConfig(fs.readFileSync(f, 'utf8'), values)); };
|
|
167
|
+
const cfgOf = (p) => readConfig(fs.readFileSync(partFile(dir, p), 'utf8'));
|
|
168
|
+
const title = (p) => byId.get(p.id)?.title ?? p.id;
|
|
169
|
+
switch (a.type) {
|
|
170
|
+
case 'word': {
|
|
171
|
+
const p = find(); keep([partFile(dir, p)], `the words of ${title(p)}`);
|
|
172
|
+
const [k, v] = fromField(a.key, a.value, cfgOf(p)); setCfg(p, { [k]: v });
|
|
173
|
+
return { say: `Changed the words of ${title(p)}`, part: p.dir };
|
|
174
|
+
}
|
|
175
|
+
case 'motion': case 'tune': {
|
|
176
|
+
const p = find(), b = byId.get(p.id); keep([partFile(dir, p)], `${a.type === 'motion' ? 'how' : 'a setting of'} ${title(p)}`);
|
|
177
|
+
const t = a.type === 'motion' ? { name: b?.repeat?.param, kind: 'enum', options: (b?.params ?? []).find((x) => x.name === b?.repeat?.param)?.options } : tunables(b ?? {}, cfgOf(p)).find((x) => x.name === a.key);
|
|
178
|
+
if (!t?.name) throw new Error('that is not a setting of this part');
|
|
179
|
+
let v = a.value;
|
|
180
|
+
if (v === '' || v == null) v = (b.params ?? []).find((x) => x.name === t.name)?.default;
|
|
181
|
+
else if (t.kind === 'number') { v = Math.min(t.max, Math.max(t.min, Number(v))); if (!Number.isFinite(v)) throw new Error('a number, please'); }
|
|
182
|
+
else if (!t.options.includes(v)) throw new Error('not one of the choices');
|
|
183
|
+
setCfg(p, { [t.name]: v });
|
|
184
|
+
return { say: `${title(p)}: ${a.type === 'motion' ? 'arrives' : (t.label ?? t.name).toLowerCase()} ${v}`, part: p.dir };
|
|
185
|
+
}
|
|
186
|
+
case 'background': {
|
|
187
|
+
const takes = (p) => (byId.get(p.id)?.params ?? []).some((x) => x.name === 'background');
|
|
188
|
+
const targets = (a.all ? chain.parts.filter((x) => x.dir) : [find()]).filter(takes);
|
|
189
|
+
if (!targets.length) throw new Error('this part draws its own background');
|
|
190
|
+
keep(targets.map((p) => partFile(dir, p)), a.all ? 'the background of every part' : `the background of ${title(targets[0])}`);
|
|
191
|
+
for (const p of targets) setCfg(p, { background: a.value || null });
|
|
192
|
+
return { say: a.all ? `Background on every part: ${a.value || "the brand's first"}` : `${title(targets[0])}: background ${a.value || "the brand's first"}`, part: a.all ? null : targets[0].dir };
|
|
193
|
+
}
|
|
194
|
+
case 'seconds': {
|
|
195
|
+
const p = find(), n = Number(a.value); if (!Number.isFinite(n)) throw new Error('give a length in seconds');
|
|
196
|
+
keep([chainFile], `the length of ${title(p)}`); p.seconds = Math.max(1, Math.min(30, +n.toFixed(1))); writeJson(chainFile, chain);
|
|
197
|
+
return { say: `${title(p)}: ${p.seconds} s`, part: p.dir };
|
|
198
|
+
}
|
|
199
|
+
case 'join': {
|
|
200
|
+
const { JOINS } = await import('./chain.mjs'); if (!(a.value in JOINS)) throw new Error('bad transition');
|
|
201
|
+
keep([chainFile], 'a transition');
|
|
202
|
+
if (a.all) { for (const p of chain.parts) p.join = a.value; chain.join = a.value; } else find().join = a.value;
|
|
203
|
+
writeJson(chainFile, chain);
|
|
204
|
+
return { say: a.all ? `Every transition: ${a.value}` : `Transition after ${title(find())}: ${a.value}` };
|
|
205
|
+
}
|
|
206
|
+
case 'order': {
|
|
207
|
+
const by = new Map(chain.parts.map((x) => [x.dir, x]));
|
|
208
|
+
if (a.value.length !== chain.parts.length || a.value.some((d) => !by.has(d))) throw new Error('bad order');
|
|
209
|
+
keep([chainFile], 'the order'); chain.parts = a.value.map((d) => by.get(d)); writeJson(chainFile, chain);
|
|
210
|
+
return { say: 'New order' };
|
|
211
|
+
}
|
|
212
|
+
case 'remove': {
|
|
213
|
+
if (chain.parts.length < 2) throw new Error('a video needs one part at least');
|
|
214
|
+
const p = find(); keep([chainFile], `removing ${title(p)}`);
|
|
215
|
+
chain.parts = chain.parts.filter((x) => x !== p); writeJson(chainFile, chain);
|
|
216
|
+
return { say: `Removed ${title(p)}` };
|
|
217
|
+
}
|
|
218
|
+
case 'duplicate': {
|
|
219
|
+
const p = find(); keep([chainFile], `duplicating ${title(p)}`);
|
|
220
|
+
const nums = fs.readdirSync(dir).map((d) => Number(d.match(/^(\d+)-/)?.[1] ?? 0)), sub = `${pad(Math.max(0, ...nums) + 1)}-${p.id}`;
|
|
221
|
+
fs.mkdirSync(path.join(dir, sub), { recursive: true });
|
|
222
|
+
for (const f of fs.readdirSync(path.join(dir, p.dir))) if (fs.statSync(path.join(dir, p.dir, f)).isFile()) fs.copyFileSync(path.join(dir, p.dir, f), path.join(dir, sub, f));
|
|
223
|
+
chain.parts.splice(chain.parts.indexOf(p) + 1, 0, { ...p, dir: sub }); writeJson(chainFile, chain);
|
|
224
|
+
return { say: `Duplicated ${title(p)}`, part: sub };
|
|
225
|
+
}
|
|
226
|
+
case 'add': case 'swap': {
|
|
227
|
+
// a new block, filled with something true: the content of the part it replaces, else a kept inventory item it
|
|
228
|
+
// can show that is not in the video yet, else the block's own sample words for the user to replace
|
|
229
|
+
const b = byId.get(a.block); if (!b) throw new Error('no such block');
|
|
230
|
+
const old = a.type === 'swap' ? find() : null;
|
|
231
|
+
keep([chainFile], a.type === 'swap' ? `showing ${title(old)} another way` : `adding ${b.title}`);
|
|
232
|
+
const { sub } = await install(cwd, dir, b.id, chain.format);
|
|
233
|
+
const items = normalize(loadRaw(dir), { cwd }).items, shown = new Set(chain.parts.flatMap((x) => x.shows ?? []));
|
|
234
|
+
const need = b.needs?.[0], fits = (i) => need && i.kind === need.kind && (need.has ?? []).every((f) => i[f] !== undefined && i[f] !== '') && Object.entries(need.where ?? {}).every(([k, v]) => i[k] === v);
|
|
235
|
+
// what the old part says now (the user may have changed its words since): read back through its own fill
|
|
236
|
+
const back = old ? unfill(byId.get(old.id)?.needs?.[0]?.fill, cfgOf(old)) : {};
|
|
237
|
+
if (back.mark !== undefined && back.before !== undefined) back.text = [back.before, back.mark, back.after].filter(Boolean).join(' '); // a marked sentence, whole again
|
|
238
|
+
if (typeof back.text === 'string') back.text = back.text.replace(/\s+/g, ' ').trim();
|
|
239
|
+
const from = (old?.shows ?? []).map((id) => items.find((i) => i.id === id)).filter(Boolean).map((i, k) => (k === 0 ? complete({ ...i, ...back }) : i));
|
|
240
|
+
// showing the same thing another way keeps its content, even when the new way wants a little more of it
|
|
241
|
+
const same = from.length && from.every((i) => i.kind === need?.kind);
|
|
242
|
+
const pool = same ? from : [...items.filter((i) => fits(i) && !shown.has(i.id))];
|
|
243
|
+
const group = need ? pool.slice(0, need.count ?? 1) : [];
|
|
244
|
+
const part = { id: b.id, dir: sub, name: b.title, seconds: b.duration ?? 4, ...(group.length ? { shows: group.map((i) => i.id), what: group.map(describe).join(' · ') } : {}) };
|
|
245
|
+
if (group.length >= (need?.count ?? 1)) {
|
|
246
|
+
const fill = evalFill(need.fill, group, brief);
|
|
247
|
+
for (const n of b.needs.slice(1)) if (n.kind === 'name') Object.assign(fill, evalFill(n.fill, [], brief));
|
|
248
|
+
setCfg(part, fill);
|
|
249
|
+
} else if (old && byId.get(old.id)?.needs?.[0]?.kind === need?.kind) {
|
|
250
|
+
// the same kind of content: carry the words over field by field where the names match
|
|
251
|
+
const prev = cfgOf(old), next = cfgOf(part), carry = Object.fromEntries(Object.keys(next).filter((k) => k in prev && !['format'].includes(k)).map((k) => [k, prev[k]]));
|
|
252
|
+
setCfg(part, carry);
|
|
253
|
+
}
|
|
254
|
+
if (old) { const bg = cfgOf(old).background; if (bg) setCfg(part, { background: bg }); part.join = old.join; part.seconds = old.seconds; chain.parts.splice(chain.parts.indexOf(old), 1, part); }
|
|
255
|
+
else chain.parts.splice(Math.max(0, Math.min(chain.parts.length, Number(a.at ?? chain.parts.length))), 0, part);
|
|
256
|
+
writeJson(chainFile, chain);
|
|
257
|
+
return { say: old ? `${title(old)} is now ${b.title}` : `Added ${b.title}${group.length ? `, showing ${part.what}` : ': write its words'}`, part: sub };
|
|
258
|
+
}
|
|
259
|
+
case 'build': {
|
|
260
|
+
// the first version, from what the user kept: the planner's structure, installed with its content
|
|
261
|
+
const items = normalize(loadRaw(dir), { cwd }).items;
|
|
262
|
+
if (!items.length) throw new Error('keep something to show first');
|
|
263
|
+
const secs = Number(String(brief.format ?? '').match(/(\d+(?:\.\d+)?)\s*(?:s\b|sec|seconds?)/i)?.[1]) || 25;
|
|
264
|
+
const options = propose(items, scenes, { seconds: secs, brief });
|
|
265
|
+
if (!options.length) throw new Error('nothing kept can be shown by a ready-made part yet');
|
|
266
|
+
writePlan(dir, options, { seconds: secs });
|
|
267
|
+
const o = options.find((x) => x.letter === (a.value ?? 'A')) ?? options[0];
|
|
268
|
+
if (chain) keep([chainFile], 'building it again');
|
|
269
|
+
const f = String(brief.format ?? ''), format = /9\s*:\s*16/.test(f) ? '1080x1920' : /1\s*:\s*1/.test(f) ? '1080x1080' : chain?.format ?? '1920x1080';
|
|
270
|
+
const look = await ensureLook(cwd, brief.title ?? chain?.title);
|
|
271
|
+
const { initChain } = await import('./chain.mjs');
|
|
272
|
+
const parts = o.beats.map((bt) => ({ id: bt.id, name: bt.name, seconds: +(bt.to - bt.from).toFixed(1), what: bt.what, ...(bt.items.length ? { shows: bt.items } : {}) }));
|
|
273
|
+
const r = await initChain({ cwd, piece: path.relative(path.join(cwd, 'pieces'), dir), title: brief.title ?? chain?.title, parts, format, force: true });
|
|
274
|
+
r.chain.parts.forEach((p, i) => { if (p.dir && o.beats[i]?.fill && Object.keys(o.beats[i].fill).length) setCfg(p, o.beats[i].fill); });
|
|
275
|
+
return { say: `Built your video: ${o.title.toLowerCase()}, ${o.beats.length} parts${look ? ` (no look yet: ${look.join(', ')}; change it in Style)` : ''}`, options: options.map((x) => ({ letter: x.letter, title: x.title, why: x.why })) };
|
|
276
|
+
}
|
|
277
|
+
default: throw new Error(`unknown edit ${a.type}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── the style: the brand's colours, type and pace ──────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
export function styleView(cwd) {
|
|
284
|
+
const t = readJson(path.join(cwd, 'brand/tokens.json'), {}), m = readJson(path.join(cwd, 'brand/motion.json'), {});
|
|
285
|
+
const c = (k) => t.colors?.[k]?.hex ?? null;
|
|
286
|
+
const stamp = ['brand/tokens.json', 'brand/motion.json', 'brand/shader-set/set.json'].map((f) => { try { return Math.round(fs.statSync(path.join(cwd, f)).mtimeMs); } catch { return 0; } }).join('-');
|
|
287
|
+
// the brand's own pictures, for a part that shows one
|
|
288
|
+
const dir = path.join(cwd, 'brand/assets'), images = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /\.(jpe?g|png|webp)$/i.test(f) && !/wordmark|logo/i.test(f)).map((f) => `/brand/assets/${f}`) : [];
|
|
289
|
+
return { colors: { ground: c('ground'), figure: c('figure'), accent: c('accent') }, fonts: { display: t.fonts?.display?.family ?? null, text: t.fonts?.text?.family ?? null }, pace: m.pace ?? 'medium', stamp, images };
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* A project with no look yet (no colours in brand/tokens.json) renders grey on grey and calls itself "brand".
|
|
293
|
+
* Before a video is built, give it a quiet default named after the piece, which the user changes in Style:
|
|
294
|
+
* a palette, a font pair and one background. Returns what was installed, or null when it already had a look.
|
|
295
|
+
*/
|
|
296
|
+
export async function ensureLook(cwd, name) {
|
|
297
|
+
const t = readJson(path.join(cwd, 'brand/tokens.json'), {});
|
|
298
|
+
if (Object.keys(t.colors ?? {}).length) return null;
|
|
299
|
+
const { add } = await import('./registry.mjs');
|
|
300
|
+
await add(['graphite', 'geist-serif'], { cwd, ...(name ? { brand: name } : {}) });
|
|
301
|
+
const set = readJson(path.join(cwd, 'brand/shader-set/set.json'), {});
|
|
302
|
+
const installed = ['graphite', 'geist-serif'];
|
|
303
|
+
if (!(Array.isArray(set) ? set : set.entries ?? []).length) { await add(['mesh-graphite'], { cwd, palette: 'brand' }); installed.push('mesh-graphite'); }
|
|
304
|
+
return installed;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function editStyle(cwd, dir, a) {
|
|
308
|
+
const tokens = path.join(cwd, 'brand/tokens.json'), motion = path.join(cwd, 'brand/motion.json');
|
|
309
|
+
snapshot(cwd, dir, [tokens, motion, path.join(cwd, 'brand/fonts.css')], 'the style');
|
|
310
|
+
if (a.type === 'palette' || a.type === 'font') {
|
|
311
|
+
const { add } = await import('./registry.mjs');
|
|
312
|
+
await add([a.value], { cwd, force: true });
|
|
313
|
+
return { say: `${a.type === 'palette' ? 'Colours' : 'Type'}: ${a.value}` };
|
|
314
|
+
}
|
|
315
|
+
if (a.type === 'color') {
|
|
316
|
+
if (!/^#[0-9a-f]{6}$/i.test(a.value) || !['ground', 'figure', 'accent'].includes(a.key)) throw new Error('bad colour');
|
|
317
|
+
const t = readJson(tokens, {}); t.colors ??= {}; t.colors[a.key] = { ...(t.colors[a.key] ?? {}), hex: a.value.toUpperCase() }; writeJson(tokens, t);
|
|
318
|
+
return { say: `${{ ground: 'Background', figure: 'Text', accent: 'Accent' }[a.key]} colour ${a.value}` };
|
|
319
|
+
}
|
|
320
|
+
if (a.type === 'pace') {
|
|
321
|
+
if (!['calm', 'medium', 'fast'].includes(a.value)) throw new Error('bad pace');
|
|
322
|
+
const m = readJson(motion, {}); m.pace = a.value; writeJson(motion, m);
|
|
323
|
+
return { say: `Pace: ${a.value}` };
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`unknown style edit ${a.type}`);
|
|
326
|
+
}
|
package/lib/gather.mjs
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Gathering an inventory: reads what the user already has (their site, a README, the brand's Facts, a folder of
|
|
2
|
+
// assets) and proposes items for pieces/<piece>/inventory.json. Everything it finds is a proposal: it carries its
|
|
3
|
+
// source and waits for the user, who keeps or removes each one in the studio. Nothing here guesses a claim; it only
|
|
4
|
+
// copies what is written or shown, and says what a proposal still lacks (a command's real output, for one).
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
10
|
+
const slug = (s, n = 4) => clean(s).toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').trim().split(/\s+/).filter(Boolean).slice(0, n).join('-') || 'item';
|
|
11
|
+
const CLI = /^(npx|npm|pnpm|yarn|bun|bunx|pip|pipx|uv|brew|curl|wget|git|docker|cargo|go|deno|gem|apt|composer|make)\b/;
|
|
12
|
+
const NUM = /^([~≈<>+]?\$?)(\d[\d.,]*)\s?(%|[kKmMbB]|x|×|ms|s|min|h|hrs?|\+)?(\+?)$/;
|
|
13
|
+
const IMG = /\.(jpe?g|png|webp)$/i, VID = /\.(mp4|mov|webm)$/i, AUD = /\.(wav|mp3|m4a|aac|flac)$/i;
|
|
14
|
+
|
|
15
|
+
/** A number as written ("1,284", "2 min", "87%") into a figure or a share. */
|
|
16
|
+
function figureFrom(raw, label, source) {
|
|
17
|
+
const m = clean(raw).match(NUM); if (!m || !clean(label)) return null;
|
|
18
|
+
const n = Number(m[2].replace(/,/g, '')); if (!Number.isFinite(n)) return null;
|
|
19
|
+
if (m[3] === '%' && n >= 0 && n <= 100) return { kind: 'share', value: n, label: clean(label), source };
|
|
20
|
+
const unit = `${m[3] ? (/^[a-z]/i.test(m[3]) && m[3].length > 1 ? ` ${m[3]}` : m[3]) : ''}${m[4] ?? ''}`;
|
|
21
|
+
return { kind: 'figure', value: n, ...(unit ? { unit } : {}), label: clean(label), source };
|
|
22
|
+
}
|
|
23
|
+
const commandFrom = (line, source) => ({ kind: 'command', command: clean(line.replace(/^\$\s*/, '')), source });
|
|
24
|
+
// a command worth showing runs the product; cloning a repository or moving between folders shows nothing of it
|
|
25
|
+
const showable = (line) => { const c = line.replace(/^\$\s*/, ''); return CLI.test(c) && !/^(git\s+(clone|pull|checkout|init)|make\s*$|apt\b)/.test(c) && c.length <= 110; };
|
|
26
|
+
// a sentence a screen can hold; longer ones are for reading, not for showing
|
|
27
|
+
const LINE_MAX = 110;
|
|
28
|
+
|
|
29
|
+
// ── a web page ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
// runs in the page: what is written on it, as plain data
|
|
32
|
+
function readPage() {
|
|
33
|
+
const vis = (el) => { const r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) > 0.05; };
|
|
34
|
+
const txt = (el) => (el.innerText ?? '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
const all = (q) => [...document.querySelectorAll(q)].filter(vis);
|
|
36
|
+
const NUM = /^([~≈<>+]?\$?)(\d[\d.,]*)\s?(%|[kKmMbB]|x|×|ms|s|min|h|hrs?|\+)?(\+?)$/;
|
|
37
|
+
// figures: a short number set large, and the words next to it
|
|
38
|
+
const stats = [];
|
|
39
|
+
for (const el of all('body *')) {
|
|
40
|
+
if (el.children.length || stats.length >= 12) continue;
|
|
41
|
+
const t = txt(el); if (!t || t.length > 12 || !NUM.test(t) || parseFloat(getComputedStyle(el).fontSize) < 20) continue;
|
|
42
|
+
let label = '', up = el;
|
|
43
|
+
// the label: the words around the number, without what can be clicked (a button's text is not what the figure counts)
|
|
44
|
+
for (let i = 0; i < 3 && !label && up.parentElement; i++) {
|
|
45
|
+
up = up.parentElement;
|
|
46
|
+
let rest = txt(up); // innerText: only what is really drawn (a hidden twin of the number is not)
|
|
47
|
+
for (const b of up.querySelectorAll('button, a, [role="button"]')) rest = rest.replace(txt(b), '');
|
|
48
|
+
rest = rest.replace(t, '').replace(/\s+/g, ' ').trim();
|
|
49
|
+
if (rest.length >= 3 && rest.length <= 70 && /[a-z]{3}/i.test(rest)) label = rest;
|
|
50
|
+
}
|
|
51
|
+
// a lone word after a number is a step or a tab ("1 Brief"), not a figure
|
|
52
|
+
if (label && label.split(' ').length >= 2) stats.push([t, label]);
|
|
53
|
+
}
|
|
54
|
+
const pres = all('pre').map((p) => p.innerText.trim()).filter((t) => t && t.length <= 900).slice(0, 8);
|
|
55
|
+
const inline = all('code').filter((c) => !c.closest('pre')).map(txt).filter((t) => t.length >= 6 && t.length <= 140);
|
|
56
|
+
const lists = all('ul,ol').map((l) => [...l.children].filter((c) => c.tagName === 'LI').map(txt)).filter((it) => it.length >= 3 && it.length <= 6 && it.every((x) => x.length >= 2 && x.length <= 28));
|
|
57
|
+
const h1 = all('h1').map(txt).find(Boolean) ?? '';
|
|
58
|
+
const lead = all('main p, header p, section p, p').map(txt).find((t) => t.length >= 40 && t.length <= 240) ?? '';
|
|
59
|
+
const icon = document.querySelector('link[rel~="icon"][type="image/svg+xml"], link[rel~="icon"][href$=".svg"]')?.href ?? null;
|
|
60
|
+
return {
|
|
61
|
+
title: document.title, site: document.querySelector('meta[property="og:site_name"]')?.content ?? '', desc: document.querySelector('meta[name="description"]')?.content ?? '',
|
|
62
|
+
h1, lead, h2: all('h2').map(txt).filter((t) => t.length >= 10 && t.length <= 90).slice(0, 5), stats, pres, inline, lists: lists.slice(0, 3), icon,
|
|
63
|
+
height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Proposals from a live page: what it says, its figures, its commands, and screenshots of it on a desktop and a phone. */
|
|
68
|
+
export async function fromUrl(url, { cwd = process.cwd(), assets = 'brand/assets' } = {}) {
|
|
69
|
+
const { launch } = await import('./browser.mjs');
|
|
70
|
+
const u = new URL(url), where = `${u.host}${u.pathname === '/' ? '' : u.pathname}`, name = slug(where.replace(/[./]/g, ' '), 5);
|
|
71
|
+
const out = [], dir = path.join(cwd, assets);
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
const browser = await launch();
|
|
74
|
+
try {
|
|
75
|
+
const shot = async (file, viewport, mobile, maxH) => {
|
|
76
|
+
const ctx = await browser.newContext({ viewport, deviceScaleFactor: mobile ? 2 : 1, isMobile: mobile, hasTouch: mobile });
|
|
77
|
+
const page = await ctx.newPage();
|
|
78
|
+
await page.goto(url, { waitUntil: 'load', timeout: 45000 });
|
|
79
|
+
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
|
80
|
+
await page.waitForTimeout(900);
|
|
81
|
+
// numbers that count up on load are read once they stop moving: a figure caught mid-count would be a false claim
|
|
82
|
+
let data = await page.evaluate(readPage);
|
|
83
|
+
for (let i = 0; i < 10; i++) { await page.waitForTimeout(700); const again = await page.evaluate(readPage); const same = JSON.stringify(again.stats) === JSON.stringify(data.stats) && again.h1 === data.h1; data = again; if (same) break; }
|
|
84
|
+
await page.screenshot({ path: path.join(dir, file), type: 'jpeg', quality: 86, clip: { x: 0, y: 0, width: viewport.width, height: Math.min(data.height, maxH) } });
|
|
85
|
+
await ctx.close();
|
|
86
|
+
return data;
|
|
87
|
+
};
|
|
88
|
+
const d = await shot(`${name}-desktop.jpg`, { width: 1440, height: 900 }, false, 3200);
|
|
89
|
+
await shot(`${name}-phone.jpg`, { width: 390, height: 844 }, true, 2200);
|
|
90
|
+
const src = (part) => `${where}, ${part}`, siteName = clean(d.site) || clean(d.title).split(/\s[·|—–-]\s/)[0];
|
|
91
|
+
if (d.h1 && d.h1.length <= LINE_MAX) out.push({ kind: 'line', text: clean(d.h1), source: src('the headline') });
|
|
92
|
+
const about = [[clean(d.desc), 'its description'], [d.lead, 'the first paragraph']].find(([t]) => t && t !== clean(d.h1) && t.length <= LINE_MAX);
|
|
93
|
+
if (about) out.push({ kind: 'line', text: about[0], source: src(about[1]) });
|
|
94
|
+
for (const h of d.h2.filter((x) => x !== d.h1).slice(0, 2)) out.push({ kind: 'line', text: h, source: src('a heading') });
|
|
95
|
+
for (const [n, label] of d.stats.slice(0, 6)) { const f = figureFrom(n, label, src('a number on the page')); if (f) out.push(f); }
|
|
96
|
+
for (const pre of d.pres) {
|
|
97
|
+
const lines = pre.split('\n').map((l) => l.trim()).filter(Boolean), cmd = lines.find(showable);
|
|
98
|
+
if (cmd && lines.length <= 4) out.push(commandFrom(cmd, src('a command on the page')));
|
|
99
|
+
}
|
|
100
|
+
for (const c of d.inline.filter(showable).slice(0, 3)) out.push(commandFrom(c, src('a command on the page')));
|
|
101
|
+
for (const l of d.lists) out.push({ kind: 'words', items: l, source: src('a list') });
|
|
102
|
+
out.push({ kind: 'screen', image: `${assets}/${name}-desktop.jpg`, url: where, title: siteName, ...(d.h1 ? { caption: clean(d.h1) } : {}), device: 'desktop', source: `a screenshot of ${where}` });
|
|
103
|
+
out.push({ kind: 'screen', image: `${assets}/${name}-phone.jpg`, url: where, title: siteName, device: 'phone', source: `a screenshot of ${where} on a phone` });
|
|
104
|
+
if (d.icon) { try { const svg = await (await fetch(d.icon, { signal: AbortSignal.timeout(8000) })).text(); if (/<svg[\s>]/.test(svg) && svg.length < 20000) out.push({ kind: 'logo', svg: svg.trim(), name: siteName, source: src('its icon') }); } catch { /* no icon to read */ } }
|
|
105
|
+
} finally { await browser.close(); }
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── a markdown file (a README, a doc) ───────────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const EXT = { js: 'js', javascript: 'js', ts: 'ts', typescript: 'ts', tsx: 'tsx', jsx: 'jsx', py: 'py', python: 'py', rb: 'rb', go: 'go', rust: 'rs', rs: 'rs', json: 'json', yaml: 'yml', yml: 'yml', html: 'html', css: 'css' };
|
|
112
|
+
const bare = (s) => clean(s.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/<[^>]+>/g, '').replace(/[*_`]/g, ''));
|
|
113
|
+
|
|
114
|
+
export function fromMarkdown(file, { cwd = process.cwd() } = {}) {
|
|
115
|
+
const rel = path.relative(cwd, path.resolve(cwd, file)), md = fs.readFileSync(path.resolve(cwd, file), 'utf8'), out = [], src = (p) => `${rel}, ${p}`;
|
|
116
|
+
const lines = md.split('\n');
|
|
117
|
+
let lead = false, cmds = 0, codes = 0, figs = 0;
|
|
118
|
+
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
const l = lines[i];
|
|
120
|
+
const fence = l.match(/^```+\s*([\w+-]*)/);
|
|
121
|
+
if (fence) {
|
|
122
|
+
const body = []; i++;
|
|
123
|
+
while (i < lines.length && !/^```+\s*$/.test(lines[i])) body.push(lines[i++]);
|
|
124
|
+
const lang = fence[1].toLowerCase(), real = body.map((x) => x.trim()).filter((x) => x && !x.startsWith('#'));
|
|
125
|
+
const shell = ['sh', 'bash', 'shell', 'zsh', 'console', 'terminal'].includes(lang) || real.some((x) => x.startsWith('$ ')) || (lang === '' && real.every((x) => CLI.test(x)));
|
|
126
|
+
if (shell) { for (const c of real.filter(showable).slice(0, 2)) if (cmds++ < 4) out.push(commandFrom(c, src('a command'))); }
|
|
127
|
+
else if (EXT[lang] && real.length >= 3 && real.length <= 18 && codes++ < 2) out.push({ kind: 'code', file: `example${EXT[lang] ? `.${EXT[lang]}` : ''}`, code: body.join('\n').trim(), source: src('a code example') });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (/^\s*\|.+\|\s*$/.test(l) && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1] ?? '')) {
|
|
131
|
+
const rows = []; i += 2;
|
|
132
|
+
const cells = [];
|
|
133
|
+
while (i < lines.length && /^\s*\|.+\|\s*$/.test(lines[i])) { cells.push(lines[i].split('|').slice(1, -1).map(bare)); i++; }
|
|
134
|
+
// a first column that only counts the rows (1, 2, 3…) is not a name: the pair starts after it
|
|
135
|
+
const skip = cells.length && cells.every((c) => /^\d+\.?$/.test(c[0] ?? '')) ? 1 : 0;
|
|
136
|
+
for (const c of cells) if (c[skip] && c[skip + 1]) rows.push([c[skip], c[skip + 1]]);
|
|
137
|
+
i--; if (rows.length >= 2 && rows.length <= 8 && rows.every((r) => r[0].length <= 32 && r[1].length <= 32)) out.push({ kind: 'table', rows, source: src('a table') });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (/^\s*[-*+]\s+/.test(l)) {
|
|
141
|
+
const items = [];
|
|
142
|
+
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) items.push(bare(lines[i++].replace(/^\s*[-*+]\s+/, '')));
|
|
143
|
+
i--;
|
|
144
|
+
if (items.length >= 3 && items.length <= 6 && items.every((x) => x.length >= 2 && x.length <= 28)) out.push({ kind: 'words', items, source: src('a list') });
|
|
145
|
+
for (const it of items) { const m = it.match(/^([~≈<>+]?\$?\d[\d.,]*\s?(?:%|[kKmMbB]|x|×|ms|s|min|h|hrs?|\+)?\+?)\s+(.{3,60})$/); const f = m && figs < 6 ? figureFrom(m[1], m[2].replace(/[.;,]$/, ''), src('a list')) : null; if (f) { figs++; out.push(f); } }
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const t = bare(l);
|
|
149
|
+
if (!lead && t.length >= 30 && t.length <= LINE_MAX && !/^#/.test(l) && !/^\s*[>|]/.test(l)) { lead = true; out.push({ kind: 'line', text: t, source: src('the first paragraph') }); }
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The brand's Facts (IDENTITY.md): its true claims, as lines, figures and commands. */
|
|
155
|
+
export function fromFacts(file, { cwd = process.cwd() } = {}) {
|
|
156
|
+
const abs = path.resolve(cwd, file); if (!fs.existsSync(abs)) return [];
|
|
157
|
+
const md = fs.readFileSync(abs, 'utf8'), m = md.match(/^##\s+Facts\s*\n([\s\S]*?)(?=^##\s|(?![\s\S]))/m);
|
|
158
|
+
if (!m) return [];
|
|
159
|
+
const out = [], source = "your brand's facts";
|
|
160
|
+
for (const raw of m[1].split('\n').filter((l) => /^\s*[-*+]\s+/.test(l))) {
|
|
161
|
+
const l = raw.replace(/^\s*[-*+]\s+/, '');
|
|
162
|
+
for (const c of [...l.matchAll(/`([^`]+)`/g)].map((x) => x[1]).filter(showable)) out.push(commandFrom(c, source));
|
|
163
|
+
const t = bare(l).replace(/\.$/, ''), n = t.match(/^([~≈<>+]?\$?\d[\d.,]*\s?(?:%|[kKmMbB]|x|×|ms|s|min|h|hrs?|\+)?\+?)\s+(.{3,60})$/);
|
|
164
|
+
const f = n ? figureFrom(n[1], n[2], source) : null;
|
|
165
|
+
if (f) out.push(f); else if (t.length >= 12 && t.length <= LINE_MAX) out.push({ kind: 'line', text: t, source });
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── a folder of the user's own material ─────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
// width and height of a JPEG or PNG, read from its header (no image library needed)
|
|
173
|
+
function imageSize(file) {
|
|
174
|
+
try {
|
|
175
|
+
const b = fs.readFileSync(file);
|
|
176
|
+
if (b.readUInt32BE(0) === 0x89504e47) return [b.readUInt32BE(16), b.readUInt32BE(20)];
|
|
177
|
+
if (b.readUInt16BE(0) === 0xffd8) { let i = 2; while (i < b.length) { const marker = b.readUInt16BE(i), len = b.readUInt16BE(i + 2); if (marker >= 0xffc0 && marker <= 0xffcf && ![0xffc4, 0xffc8, 0xffcc].includes(marker)) return [b.readUInt16BE(i + 7), b.readUInt16BE(i + 5)]; i += 2 + len; } }
|
|
178
|
+
} catch { /* not readable */ }
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function fromFolder(folder, { cwd = process.cwd() } = {}) {
|
|
183
|
+
const abs = path.resolve(cwd, folder); if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return [];
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const f of fs.readdirSync(abs).sort()) {
|
|
186
|
+
const full = path.join(abs, f), rel = path.relative(cwd, full).split(path.sep).join('/');
|
|
187
|
+
if (f.startsWith('.') || fs.statSync(full).isDirectory()) continue;
|
|
188
|
+
const source = `your file ${f}`, base = f.replace(/\.\w+$/, '');
|
|
189
|
+
if (IMG.test(f)) {
|
|
190
|
+
const size = imageSize(full), tall = size && size[1] / size[0] > 1.5;
|
|
191
|
+
// a picture named like a capture, or shaped like a page, is the product; anything else is a photograph
|
|
192
|
+
if (/screen|shot|capture|site|page|app|ui|dashboard|desktop|phone|mobile/i.test(f) || (size && size[1] / size[0] > 1.9)) out.push({ kind: 'screen', image: rel, title: clean(base.replace(/[-_]+/g, ' ')), device: tall && size[0] < 900 ? 'phone' : 'desktop', source });
|
|
193
|
+
else if (!/wordmark|logo|favicon|icon/i.test(f)) out.push({ kind: 'photo', image: rel, source }); // the brand's own marks are not photographs
|
|
194
|
+
} else if (/\.svg$/i.test(f) && /logo|mark|icon|brand/i.test(f)) { const svg = fs.readFileSync(full, 'utf8').trim(); if (svg.length < 30000) out.push({ kind: 'logo', svg, source }); }
|
|
195
|
+
else if (VID.test(f)) out.push({ kind: 'clip', file: rel, source });
|
|
196
|
+
else if (AUD.test(f)) out.push({ kind: 'music', file: rel, source });
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── putting it together ─────────────────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** What makes two items the same thing, however they were found. */
|
|
204
|
+
export function keyOf(it) {
|
|
205
|
+
const k = { line: it.text, figure: `${it.value}|${it.label}`, share: `${it.value}|${it.label}`, change: `${it.from}|${it.to}`, command: it.command, code: it.code, screen: it.image, photo: it.image, clip: it.file, music: it.file ?? it.bpm, logo: (it.svg ?? '').slice(0, 200), words: (it.items ?? []).join('|'), table: JSON.stringify(it.rows ?? []) }[it.kind];
|
|
206
|
+
return `${it.kind}|${clean(k ?? JSON.stringify(it)).toLowerCase()}`;
|
|
207
|
+
}
|
|
208
|
+
const idFor = (it) => ({ line: slug(it.text ?? '', 3), figure: slug(it.label ?? '', 3), share: slug(it.label ?? '', 3), command: `run-${slug((it.command ?? '').replace(CLI, ''), 2)}`, screen: `${it.device ?? 'screen'}-${slug(it.url ?? it.title ?? it.image ?? '', 3)}`, photo: slug(path.basename(it.image ?? 'photo').replace(/\.\w+$/, ''), 3), clip: slug(path.basename(it.file ?? 'clip').replace(/\.\w+$/, ''), 3), words: slug((it.items ?? []).join(' '), 3), code: slug(it.file ?? 'code', 2), table: 'table', logo: 'logo', music: 'music' }[it.kind] ?? it.kind);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Gathers proposals from every source: a URL, a .md file, a folder, or "brand" (the brand's Facts and its assets).
|
|
212
|
+
* Returns what is new against `existing` (anything already there, kept or removed, is not proposed again).
|
|
213
|
+
* `plain` leaves commands and code out, for a brand whose audience does not code.
|
|
214
|
+
*/
|
|
215
|
+
export async function gather(sources, { cwd = process.cwd(), existing = [], plain = false, log = () => {} } = {}) {
|
|
216
|
+
const found = [], notes = [];
|
|
217
|
+
for (const s of sources) {
|
|
218
|
+
try {
|
|
219
|
+
let got;
|
|
220
|
+
if (/^https?:\/\//i.test(s)) { log(`reading ${s} …`); got = await fromUrl(s, { cwd }); }
|
|
221
|
+
else if (s === 'brand') got = [...fromFacts('brand/IDENTITY.md', { cwd }), ...fromFolder('brand/assets', { cwd })];
|
|
222
|
+
else if (!fs.existsSync(path.resolve(cwd, s))) { notes.push(`${s}: not found`); continue; }
|
|
223
|
+
else if (fs.statSync(path.resolve(cwd, s)).isDirectory()) got = fromFolder(s, { cwd });
|
|
224
|
+
else if (/IDENTITY\.md$/i.test(s)) got = fromFacts(s, { cwd });
|
|
225
|
+
else got = fromMarkdown(s, { cwd });
|
|
226
|
+
// a brand whose audience does not code: commands and code are left out, however many its files hold
|
|
227
|
+
if (plain) got = got.filter((i) => !['command', 'code', 'rewrite'].includes(i.kind));
|
|
228
|
+
notes.push(`${s}: ${got.length} found`);
|
|
229
|
+
found.push(...got);
|
|
230
|
+
} catch (e) { notes.push(`${s}: ${e.message.split('\n')[0]}`); }
|
|
231
|
+
}
|
|
232
|
+
const have = new Set(existing.map(keyOf)), ids = new Set(existing.map((i) => i.id)), fresh = [];
|
|
233
|
+
for (const it of found) {
|
|
234
|
+
const k = keyOf(it); if (have.has(k)) continue;
|
|
235
|
+
have.add(k);
|
|
236
|
+
let id = idFor(it), n = 2; while (ids.has(id)) id = `${idFor(it)}-${n++}`;
|
|
237
|
+
ids.add(id);
|
|
238
|
+
fresh.push({ id, ...it, status: 'proposed' });
|
|
239
|
+
}
|
|
240
|
+
return { fresh, notes };
|
|
241
|
+
}
|
package/lib/host.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The app the agent runs in, so the studio can send the user back to it ("your agent asked you something").
|
|
2
|
+
// On macOS the CLI walks up its own process tree to the first .app bundle (Claude, Cursor, a terminal…); the studio
|
|
3
|
+
// brings that app to the front with `open -a`. Anywhere, PAPER_MOTION_RETURN (or --return) gives a link instead:
|
|
4
|
+
// the page of a browser-based agent.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
export function hostApp({ url } = {}) {
|
|
9
|
+
const link = url ?? process.env.PAPER_MOTION_RETURN;
|
|
10
|
+
if (link && /^https?:\/\//i.test(link)) return { url: link };
|
|
11
|
+
if (process.platform !== 'darwin') return null;
|
|
12
|
+
// the topmost .app above this process is the window the user sees (Claude, Cursor, a terminal); bundles further
|
|
13
|
+
// down are helpers and command-line tools packed as apps
|
|
14
|
+
let pid = process.ppid, found = null;
|
|
15
|
+
for (let i = 0; i < 30 && pid > 1; i++) {
|
|
16
|
+
let out;
|
|
17
|
+
try { out = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { break; }
|
|
18
|
+
const m = out.match(/^(\d+)\s+(.*)$/);
|
|
19
|
+
if (!m) break;
|
|
20
|
+
const app = m[2].match(/^(.*?\.app)\//);
|
|
21
|
+
if (app) found = { app: path.basename(app[1], '.app'), path: app[1] };
|
|
22
|
+
pid = Number(m[1]);
|
|
23
|
+
}
|
|
24
|
+
return found;
|
|
25
|
+
}
|