atris 3.22.0 → 3.24.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/atris.md +1 -1
- package/bin/atris.js +13 -1
- package/commands/card.js +121 -0
- package/commands/reel.js +128 -0
- package/lib/card.js +120 -0
- package/lib/reel.js +52 -0
- package/package.json +1 -1
package/atris.md
CHANGED
|
@@ -67,7 +67,7 @@ What you ship should not read as generated. The test: if someone said "an AI mad
|
|
|
67
67
|
- **Refuse the wells** (named so you can): purple/indigo gradients, gradient-filled text, glassmorphism, Inter/Roboto defaults, claude-beige, neon-on-dark, hero-metric rows, identical card grids, eyebrow/tracked-caps labels, pulsing live-dots, em dashes.
|
|
68
68
|
- **Commit to constraints.** One distinctive font, one accent hue, a small spacing scale. Taste is subtraction, not addition.
|
|
69
69
|
- **Generate it right.** `atris deck` (slides), `atris deck from <doc.md> --html` (a web page from a plain doc, in the web app's design tokens), `atris site <dir>` (a whole markdown folder into a navigable site), and `atris recap --html` (a memory-updates page) all apply the system by default: own backgrounds and fonts, never the tool's stock template. Output as an AppBlock with `--block` to drop into a web app.
|
|
70
|
-
- **Compound it.** A new tell becomes a project rule in `.atris/slop.rules.json` (`atris slop rules --add`), and a project's brand lives in `.atris/theme.json` (`atris theme create` builds your own by feel, or `atris theme init` scaffolds one) so every deck, page, and site is on-brand by default
|
|
70
|
+
- **Compound it.** A new tell becomes a project rule in `.atris/slop.rules.json` (`atris slop rules --add`), and a project's brand lives in `.atris/theme.json` (`atris theme create` builds your own by feel, or `atris theme init` scaffolds one) so every deck, page, and site is on-brand by default, and one line of text becomes an on-brand image with `atris card` or a short video with `atris reel`. The gate and the look grow per project instead of leaning on memory. Taste lives in code, not vibes.
|
|
71
71
|
|
|
72
72
|
## voice
|
|
73
73
|
|
package/bin/atris.js
CHANGED
|
@@ -365,6 +365,8 @@ function showHelp() {
|
|
|
365
365
|
console.log(' deck - Generate a premium Google Slides deck from a content spec');
|
|
366
366
|
console.log(' site - Build a beautiful static site from a folder of markdown');
|
|
367
367
|
console.log(' theme - Brand themes: "theme create" builds your own by feel (deck/html/site)');
|
|
368
|
+
console.log(' card - One line of text into an on-brand image (uses your theme)');
|
|
369
|
+
console.log(' reel - One line of text into a short on-brand video (animated card)');
|
|
368
370
|
console.log(' run - Auto-chain plan→do→review (autonomous loop, auto-pushes)');
|
|
369
371
|
console.log(' run logs - Browse glass run logs (phase reasoning persisted to disk)');
|
|
370
372
|
console.log(' run search - Search phase reasoning across all run logs');
|
|
@@ -830,7 +832,7 @@ if (command === '2' && ['fast', 'pro'].includes(String(firstCommandArg || '').to
|
|
|
830
832
|
const knownCommands = ['init', 'log', 'now', 'radar', 'ctop', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
|
|
831
833
|
'activate', '_activate', 'agent', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
832
834
|
'clean', 'verify', 'search', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'plugin', 'experiments', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
|
|
833
|
-
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'slop', 'deck', 'site', 'theme', 'improve', 'xp', 'play', 'gm', 'x', 'recap',
|
|
835
|
+
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'slop', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'xp', 'play', 'gm', 'x', 'recap',
|
|
834
836
|
'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
835
837
|
'fork', 'browse', 'publish', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'compile', 'spaceship'];
|
|
836
838
|
|
|
@@ -1364,6 +1366,16 @@ if (command === 'init') {
|
|
|
1364
1366
|
Promise.resolve(require('../commands/theme').run(process.argv.slice(3)))
|
|
1365
1367
|
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
1366
1368
|
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
1369
|
+
} else if (command === 'card') {
|
|
1370
|
+
// Card: one line of text into an on-brand image (uses your theme + the design system).
|
|
1371
|
+
Promise.resolve(require('../commands/card').run(process.argv.slice(3)))
|
|
1372
|
+
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
1373
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
1374
|
+
} else if (command === 'reel') {
|
|
1375
|
+
// Reel: one line of text into a short on-brand video (an animated card; frames via Chrome + ffmpeg).
|
|
1376
|
+
Promise.resolve(require('../commands/reel').run(process.argv.slice(3)))
|
|
1377
|
+
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
1378
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
1367
1379
|
} else if (command === 'aeo') {
|
|
1368
1380
|
// AEO: AI Engine Optimization — credit-metered citation drafting against the customer workspace.
|
|
1369
1381
|
Promise.resolve(require('../commands/aeo').run(process.argv.slice(3)))
|
package/commands/card.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// atris card — one line of text -> a beautiful, on-brand image (your theme).
|
|
2
|
+
//
|
|
3
|
+
// atris card "Ship faster" --kind statement --theme brand --size og
|
|
4
|
+
// atris card "It just works" --kind quote --by "a happy user"
|
|
5
|
+
// atris card --kind stat --number "10x" --label "faster reviews"
|
|
6
|
+
//
|
|
7
|
+
// Writes an .html (always) and a .png (when headless Chrome is available).
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
13
|
+
const { buildCard, SIZES, KINDS } = require('../lib/card');
|
|
14
|
+
|
|
15
|
+
function parseFlags(argv) {
|
|
16
|
+
const flags = {}; const pos = [];
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const a = argv[i];
|
|
19
|
+
if (a === '--html-only') { flags.htmlOnly = true; continue; }
|
|
20
|
+
if (a.startsWith('--')) {
|
|
21
|
+
const key = a.slice(2);
|
|
22
|
+
const next = argv[i + 1];
|
|
23
|
+
if (next != null && !next.startsWith('--')) { flags[key] = next; i++; } else flags[key] = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
pos.push(a);
|
|
27
|
+
}
|
|
28
|
+
return { flags, pos };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// find an installed Chrome/Chromium without adding a dependency
|
|
32
|
+
function findChrome() {
|
|
33
|
+
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
|
|
34
|
+
const macApps = [
|
|
35
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
36
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
37
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
38
|
+
];
|
|
39
|
+
for (const p of macApps) if (fs.existsSync(p)) return p;
|
|
40
|
+
for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']) {
|
|
41
|
+
const r = spawnSync('command', ['-v', name], { shell: true, encoding: 'utf8' });
|
|
42
|
+
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderPng(chrome, html, width, height, outPng) {
|
|
48
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-card-'));
|
|
49
|
+
const htmlFile = path.join(tmp, 'card.html');
|
|
50
|
+
fs.writeFileSync(htmlFile, html);
|
|
51
|
+
const args = [
|
|
52
|
+
'--headless=new', '--disable-gpu', '--hide-scrollbars',
|
|
53
|
+
'--force-device-scale-factor=2',
|
|
54
|
+
`--window-size=${width},${height}`,
|
|
55
|
+
'--virtual-time-budget=4000',
|
|
56
|
+
`--screenshot=${outPng}`,
|
|
57
|
+
`file://${htmlFile}`,
|
|
58
|
+
];
|
|
59
|
+
execFileSync(chrome, args, { stdio: 'ignore', timeout: 60000 });
|
|
60
|
+
return fs.existsSync(outPng);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function run(argv) {
|
|
64
|
+
const { flags, pos } = parseFlags(argv);
|
|
65
|
+
|
|
66
|
+
if (pos[0] === 'help' || flags.help) {
|
|
67
|
+
console.log(`\n atris card — one line of text into an on-brand image\n
|
|
68
|
+
atris card "Your headline" [--kind statement|quote|stat] [--theme <name>] [--size og|wide|square|story]
|
|
69
|
+
flags: --sub --kicker --by --number --label --brand --version --out <file.png> --html-only\n
|
|
70
|
+
examples:
|
|
71
|
+
atris card "Design that builds itself" --kicker "Atris v3.23.0" --theme brand
|
|
72
|
+
atris card "It just works." --kind quote --by "a founder" --size square
|
|
73
|
+
atris card --kind stat --number "1260" --label "tests, all green"\n`);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const text = pos.join(' ').trim();
|
|
78
|
+
const kind = flags.kind || 'statement';
|
|
79
|
+
if (!KINDS.includes(kind)) { console.error(` unknown kind "${kind}". try: ${KINDS.join(', ')}`); return 2; }
|
|
80
|
+
if (flags.size && !SIZES[flags.size]) { console.error(` unknown size "${flags.size}". try: ${Object.keys(SIZES).join(', ')}`); return 2; }
|
|
81
|
+
if (kind === 'stat' && !flags.number && !text) { console.error(' stat cards need --number (e.g. --number "10x")'); return 2; }
|
|
82
|
+
if (kind !== 'stat' && !text) { console.error(' give the card some text: atris card "Your headline"'); return 2; }
|
|
83
|
+
|
|
84
|
+
const spec = {
|
|
85
|
+
kind, text, headline: text,
|
|
86
|
+
theme: flags.theme, size: flags.size,
|
|
87
|
+
sub: flags.sub, kicker: flags.kicker, by: flags.by,
|
|
88
|
+
number: flags.number, label: flags.label,
|
|
89
|
+
brand: flags.brand, version: flags.version,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
let card;
|
|
93
|
+
try { card = buildCard(spec); }
|
|
94
|
+
catch (e) { console.error(` could not build card: ${e.message}`); return 1; }
|
|
95
|
+
|
|
96
|
+
const base = (flags.out ? String(flags.out).replace(/\.png$/i, '') : `card-${kind}-${card.theme}-${card.size}`);
|
|
97
|
+
const outPng = path.resolve(`${base}.png`);
|
|
98
|
+
const outHtml = path.resolve(`${base}.html`);
|
|
99
|
+
fs.writeFileSync(outHtml, card.html);
|
|
100
|
+
|
|
101
|
+
if (flags.htmlOnly) {
|
|
102
|
+
console.log(`\n ✓ ${card.kind} card (${card.width}x${card.height}, theme ${card.theme})\n html: ${outHtml}\n open it, or render a png with Chrome installed.\n`);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const chrome = findChrome();
|
|
107
|
+
if (!chrome) {
|
|
108
|
+
console.log(`\n ✓ wrote html: ${outHtml}\n no Chrome found for png. open the html, or set CHROME_PATH and re-run.\n`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
renderPng(chrome, card.html, card.width, card.height, outPng);
|
|
113
|
+
console.log(`\n ✓ ${card.kind} card, ${card.width}x${card.height}, theme ${card.theme}\n ${outPng}\n`);
|
|
114
|
+
return 0;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
console.log(`\n ! png render failed (${e.message.split('\n')[0]})\n html is ready: ${outHtml}\n`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { run };
|
package/commands/reel.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// atris reel — one line of text into a short, on-brand video (an animated card).
|
|
2
|
+
//
|
|
3
|
+
// atris reel "Ship faster" --theme brand --size square
|
|
4
|
+
// atris reel "It just works." --kind quote --by "a founder" --seconds 3
|
|
5
|
+
//
|
|
6
|
+
// Renders frames with headless Chrome (the same one `atris card` uses) and encodes
|
|
7
|
+
// with ffmpeg. No new dependency. Falls back to a card image if either is missing.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const { execFile, spawnSync } = require('child_process');
|
|
13
|
+
const { promisify } = require('util');
|
|
14
|
+
const pexec = promisify(execFile);
|
|
15
|
+
const { buildReelFrame, reelFrames } = require('../lib/reel');
|
|
16
|
+
const { SIZES, KINDS } = require('../lib/card');
|
|
17
|
+
|
|
18
|
+
function parseFlags(argv) {
|
|
19
|
+
const flags = {}; const pos = [];
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (a.startsWith('--')) {
|
|
23
|
+
const key = a.slice(2); const next = argv[i + 1];
|
|
24
|
+
if (next != null && !next.startsWith('--')) { flags[key] = next; i++; } else flags[key] = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
pos.push(a);
|
|
28
|
+
}
|
|
29
|
+
return { flags, pos };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findBin(macApps, names) {
|
|
33
|
+
for (const p of macApps) if (fs.existsSync(p)) return p;
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const r = spawnSync('command', ['-v', name], { shell: true, encoding: 'utf8' });
|
|
36
|
+
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const findChrome = () => (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH))
|
|
41
|
+
? process.env.CHROME_PATH
|
|
42
|
+
: findBin([
|
|
43
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
44
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
45
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
46
|
+
], ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']);
|
|
47
|
+
const findFfmpeg = () => findBin(['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg'], ['ffmpeg']);
|
|
48
|
+
|
|
49
|
+
async function renderFrames(chrome, ts, spec, dir, w, h, onDone) {
|
|
50
|
+
const pad = (i) => String(i).padStart(4, '0');
|
|
51
|
+
let idx = 0; let done = 0;
|
|
52
|
+
async function worker() {
|
|
53
|
+
while (idx < ts.length) {
|
|
54
|
+
const i = idx++;
|
|
55
|
+
const { html } = buildReelFrame(spec, ts[i]);
|
|
56
|
+
const hf = path.join(dir, `f-${pad(i)}.html`);
|
|
57
|
+
const pf = path.join(dir, `f-${pad(i)}.png`);
|
|
58
|
+
fs.writeFileSync(hf, html);
|
|
59
|
+
await pexec(chrome, [
|
|
60
|
+
'--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1',
|
|
61
|
+
`--window-size=${w},${h}`, '--virtual-time-budget=2200',
|
|
62
|
+
`--user-data-dir=${path.join(dir, 'ud-' + i)}`,
|
|
63
|
+
`--screenshot=${pf}`, `file://${hf}`,
|
|
64
|
+
], { timeout: 60000 });
|
|
65
|
+
onDone && onDone(++done, ts.length);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const conc = Math.min(5, ts.length);
|
|
69
|
+
await Promise.all(Array.from({ length: conc }, worker));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function run(argv) {
|
|
73
|
+
const { flags, pos } = parseFlags(argv);
|
|
74
|
+
if (pos[0] === 'help' || flags.help) {
|
|
75
|
+
console.log(`\n atris reel — one line of text into a short on-brand video\n
|
|
76
|
+
atris reel "Your headline" [--kind statement|quote|stat] [--theme <name>] [--size square|og|wide|story] [--seconds 2.6]
|
|
77
|
+
flags: --sub --kicker --by --number --label --brand --version --out <file.mp4>\n`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const text = pos.join(' ').trim();
|
|
82
|
+
const kind = flags.kind || 'statement';
|
|
83
|
+
if (!KINDS.includes(kind)) { console.error(` unknown kind "${kind}". try: ${KINDS.join(', ')}`); return 2; }
|
|
84
|
+
const size = flags.size || 'square';
|
|
85
|
+
if (!SIZES[size]) { console.error(` unknown size "${size}". try: ${Object.keys(SIZES).join(', ')}`); return 2; }
|
|
86
|
+
if (kind === 'stat' && !flags.number && !text) { console.error(' stat reels need --number'); return 2; }
|
|
87
|
+
if (kind !== 'stat' && !text) { console.error(' give the reel some text: atris reel "Your headline"'); return 2; }
|
|
88
|
+
|
|
89
|
+
const seconds = Math.max(1, Math.min(8, parseFloat(flags.seconds) || 2.6));
|
|
90
|
+
const fps = 20;
|
|
91
|
+
const spec = {
|
|
92
|
+
kind, text, headline: text, theme: flags.theme, size,
|
|
93
|
+
sub: flags.sub, kicker: flags.kicker, by: flags.by,
|
|
94
|
+
number: flags.number, label: flags.label, brand: flags.brand, version: flags.version,
|
|
95
|
+
};
|
|
96
|
+
const { w, h } = SIZES[size];
|
|
97
|
+
const base = flags.out ? String(flags.out).replace(/\.mp4$/i, '') : `reel-${kind}-${flags.theme || 'atris'}-${size}`;
|
|
98
|
+
const outMp4 = path.resolve(`${base}.mp4`);
|
|
99
|
+
|
|
100
|
+
const chrome = findChrome();
|
|
101
|
+
if (!chrome) { console.error('\n no Chrome found to render frames. install Chrome or set CHROME_PATH.\n'); return 1; }
|
|
102
|
+
const ffmpeg = findFfmpeg();
|
|
103
|
+
if (!ffmpeg) { console.error('\n no ffmpeg found to encode the video. install ffmpeg (brew install ffmpeg).\n'); return 1; }
|
|
104
|
+
|
|
105
|
+
const ts = reelFrames(seconds, fps);
|
|
106
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-reel-'));
|
|
107
|
+
console.log(`\n rendering ${ts.length} frames (${seconds}s, ${size})...`);
|
|
108
|
+
try {
|
|
109
|
+
await renderFrames(chrome, ts, spec, dir, w, h, (d, n) => {
|
|
110
|
+
if (d === n || d % 10 === 0) process.stdout.write(`\r frames ${d}/${n} `);
|
|
111
|
+
});
|
|
112
|
+
process.stdout.write('\n encoding...\n');
|
|
113
|
+
await pexec(ffmpeg, [
|
|
114
|
+
'-y', '-framerate', String(fps), '-i', path.join(dir, 'f-%04d.png'),
|
|
115
|
+
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', outMp4,
|
|
116
|
+
]);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error(`\n render failed: ${String(e.message).split('\n')[0]}`); return 1;
|
|
119
|
+
} finally {
|
|
120
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!fs.existsSync(outMp4)) { console.error(' no video produced'); return 1; }
|
|
124
|
+
console.log(`\n ✓ ${kind} reel, ${w}x${h}, ${seconds}s, theme ${flags.theme || 'atris'}\n ${outMp4}\n`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { run, findChrome, findFfmpeg };
|
package/lib/card.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// atris card — turn one line of text into a beautiful, on-brand image.
|
|
2
|
+
// Pure: spec -> self-contained HTML string. Reuses the design system: the same
|
|
3
|
+
// themes as deck/site/html (incl. your .atris/theme.json brand), one accent,
|
|
4
|
+
// Fraunces display, restraint. Render to PNG with headless Chrome (commands/card.js).
|
|
5
|
+
//
|
|
6
|
+
// Kinds: statement (kicker + headline + sub), quote (quote + attribution),
|
|
7
|
+
// stat (big number + label). Sizes: og, wide, square, story.
|
|
8
|
+
|
|
9
|
+
const { mergedThemes } = require('./theme');
|
|
10
|
+
const { THEMES: HTML_THEMES, rich, esc } = require('./html-render');
|
|
11
|
+
|
|
12
|
+
const SIZES = {
|
|
13
|
+
og: { w: 1200, h: 630 }, // OpenGraph / link card
|
|
14
|
+
wide: { w: 1920, h: 1080 }, // slide / banner
|
|
15
|
+
square: { w: 1080, h: 1080 }, // feed
|
|
16
|
+
story: { w: 1080, h: 1920 }, // story / reel
|
|
17
|
+
};
|
|
18
|
+
const KINDS = ['statement', 'quote', 'stat'];
|
|
19
|
+
|
|
20
|
+
const plainLen = (s) => String(s == null ? '' : s).replace(/\*\*/g, '').length;
|
|
21
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
22
|
+
|
|
23
|
+
// shrink a display line so long text still fits the card
|
|
24
|
+
function fit(base, text, refChars) {
|
|
25
|
+
return Math.round(base * clamp(refChars / Math.max(plainLen(text), 1), 0.5, 1));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fontStack(name, fallback) {
|
|
29
|
+
return `'${String(name || fallback).replace(/'/g, '')}', '${fallback}', ${fallback === 'Fraunces' ? 'Georgia, serif' : 'system-ui, sans-serif'}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildCard(spec = {}, opts = {}) {
|
|
33
|
+
const size = SIZES[spec.size] || SIZES.og;
|
|
34
|
+
const themes = opts.themes || mergedThemes(HTML_THEMES, opts.root || process.cwd());
|
|
35
|
+
const theme = themes[spec.theme] || HTML_THEMES.atris;
|
|
36
|
+
const c = theme.color;
|
|
37
|
+
const f = theme.fonts || {};
|
|
38
|
+
const kind = KINDS.includes(spec.kind) ? spec.kind : 'statement';
|
|
39
|
+
const u = Math.min(size.w, size.h);
|
|
40
|
+
|
|
41
|
+
const pad = Math.round(u * 0.088);
|
|
42
|
+
const px = {
|
|
43
|
+
kicker: Math.round(u * 0.030),
|
|
44
|
+
sub: Math.round(u * 0.040),
|
|
45
|
+
foot: Math.round(u * 0.028),
|
|
46
|
+
rule: Math.round(u * 0.010),
|
|
47
|
+
gap: Math.round(u * 0.030),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const brand = spec.brand || 'Atris';
|
|
51
|
+
const version = spec.version || '';
|
|
52
|
+
const display = fontStack(f.display, 'Fraunces');
|
|
53
|
+
const body = fontStack(f.body, 'Outfit');
|
|
54
|
+
const grotesk = "'Space Grotesk', system-ui, sans-serif";
|
|
55
|
+
const monoFont = "'IBM Plex Mono', ui-monospace, monospace";
|
|
56
|
+
|
|
57
|
+
const foot = `<div class="foot"><span class="mark">${esc(brand)}</span>${version ? `<span class="ver">${esc(version)}</span>` : ''}</div>`;
|
|
58
|
+
const kickerHtml = spec.kicker ? `<div class="kicker">${rich(spec.kicker)}</div>` : '';
|
|
59
|
+
const subHtml = spec.sub ? `<p class="sub">${rich(spec.sub)}</p>` : '';
|
|
60
|
+
|
|
61
|
+
let main = '';
|
|
62
|
+
let extraCss = '';
|
|
63
|
+
if (kind === 'quote') {
|
|
64
|
+
const text = spec.text || spec.headline || 'Your quote here';
|
|
65
|
+
const qSize = fit(Math.round(u * 0.090), text, 64);
|
|
66
|
+
main = `<div class="rule"></div>
|
|
67
|
+
<div class="qmark">“</div>
|
|
68
|
+
<blockquote class="qtext">${rich(text)}</blockquote>
|
|
69
|
+
${spec.by ? `<div class="by">${rich(spec.by)}</div>` : ''}`;
|
|
70
|
+
extraCss = `
|
|
71
|
+
.qmark{font-family:${display};color:${c.accent};font-size:${Math.round(u * 0.20)}px;line-height:.6;height:${Math.round(u * 0.10)}px}
|
|
72
|
+
.qtext{font-family:${display};font-weight:600;font-size:${qSize}px;line-height:1.08;letter-spacing:-1px;margin:${px.gap}px 0}
|
|
73
|
+
.by{font-family:${monoFont};font-size:${px.foot}px;color:${c.soft}}`;
|
|
74
|
+
} else if (kind === 'stat') {
|
|
75
|
+
const number = spec.number || spec.headline || '42';
|
|
76
|
+
const numSize = fit(Math.round(u * 0.34), number, 6);
|
|
77
|
+
main = `<div class="rule"></div>
|
|
78
|
+
${kickerHtml}
|
|
79
|
+
<div class="big">${rich(number)}</div>
|
|
80
|
+
${spec.label ? `<div class="statlabel">${rich(spec.label)}</div>` : ''}
|
|
81
|
+
${subHtml}`;
|
|
82
|
+
extraCss = `
|
|
83
|
+
.big{font-family:${grotesk};font-weight:600;color:${c.accent};font-size:${numSize}px;line-height:.92;letter-spacing:-2px}
|
|
84
|
+
.statlabel{font-family:${display};font-weight:600;font-size:${Math.round(u * 0.058)}px;color:${c.ink};margin-top:${Math.round(px.gap * 0.5)}px}`;
|
|
85
|
+
} else {
|
|
86
|
+
const headline = spec.headline || spec.text || 'Your headline here';
|
|
87
|
+
const hSize = fit(Math.round(u * 0.135), headline, 34);
|
|
88
|
+
main = `<div class="rule"></div>
|
|
89
|
+
${kickerHtml}
|
|
90
|
+
<h1 class="headline">${rich(headline)}</h1>
|
|
91
|
+
${subHtml}`;
|
|
92
|
+
extraCss = `
|
|
93
|
+
.headline{font-family:${display};font-weight:600;font-size:${hSize}px;line-height:1.02;letter-spacing:-1.5px}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const html = `<!doctype html>
|
|
97
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
98
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
99
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
100
|
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Outfit:wght@400;500;600&family=Space+Grotesk:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
101
|
+
<style>
|
|
102
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
103
|
+
html,body{width:${size.w}px;height:${size.h}px;overflow:hidden}
|
|
104
|
+
body{background:${c.bg};color:${c.ink};font-family:${body};-webkit-font-smoothing:antialiased}
|
|
105
|
+
.card{width:${size.w}px;height:${size.h}px;padding:${pad}px;display:flex;flex-direction:column;justify-content:center;position:relative}
|
|
106
|
+
.rule{width:${Math.round(u * 0.085)}px;height:${px.rule}px;background:${c.accent};border-radius:${px.rule}px;margin-bottom:${Math.round(px.gap * 0.9)}px}
|
|
107
|
+
.kicker{font-family:${monoFont};font-size:${px.kicker}px;letter-spacing:2px;color:${c.soft};margin-bottom:${Math.round(px.gap * 0.6)}px}
|
|
108
|
+
.sub{font-size:${px.sub}px;line-height:1.4;color:${c.soft};margin-top:${px.gap}px;max-width:88%}
|
|
109
|
+
.foot{position:absolute;left:${pad}px;right:${pad}px;bottom:${pad}px;display:flex;align-items:baseline;justify-content:space-between}
|
|
110
|
+
.mark{font-family:${display};font-weight:600;font-size:${Math.round(u * 0.034)}px}
|
|
111
|
+
.ver{font-family:${monoFont};font-size:${px.foot}px;color:${c.soft};letter-spacing:1px}
|
|
112
|
+
.accent{color:${c.accent}}
|
|
113
|
+
${extraCss}
|
|
114
|
+
</style></head>
|
|
115
|
+
<body><div class="card">${main}${foot}</div></body></html>`;
|
|
116
|
+
|
|
117
|
+
return { html, width: size.w, height: size.h, kind, theme: spec.theme || 'atris', size: spec.size || 'og' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { buildCard, SIZES, KINDS };
|
package/lib/reel.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// atris reel — a card, animated. A reel is the card from lib/card.js rendered at
|
|
2
|
+
// progress t in [0,1]: each element fades and rises in on a staggered schedule.
|
|
3
|
+
// Pure: (spec, t) -> HTML for that single frame. commands/reel.js screenshots the
|
|
4
|
+
// frames (same Chrome as card) and ffmpeg-encodes them. No new dependency.
|
|
5
|
+
|
|
6
|
+
const { buildCard } = require('./card');
|
|
7
|
+
|
|
8
|
+
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
|
9
|
+
const easeOutCubic = (p) => 1 - Math.pow(1 - clamp01(p), 3);
|
|
10
|
+
// reveal progress of an element whose window is [a,b], at global time t
|
|
11
|
+
const win = (t, a, b) => easeOutCubic((t - a) / (b - a));
|
|
12
|
+
|
|
13
|
+
// staggered reveal windows by element class. A kind only has some of these;
|
|
14
|
+
// overriding a class that isn't present is harmless.
|
|
15
|
+
const STAGGER = [
|
|
16
|
+
['.rule', 0.00, 0.16],
|
|
17
|
+
['.kicker', 0.10, 0.30],
|
|
18
|
+
['.qmark', 0.10, 0.30],
|
|
19
|
+
['.headline', 0.18, 0.46],
|
|
20
|
+
['.qtext', 0.20, 0.50],
|
|
21
|
+
['.big', 0.16, 0.46],
|
|
22
|
+
['.statlabel', 0.34, 0.58],
|
|
23
|
+
['.sub', 0.40, 0.62],
|
|
24
|
+
['.by', 0.46, 0.66],
|
|
25
|
+
['.foot', 0.58, 0.80],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// CSS that places every element at its reveal state for time t (one-shot, no loops)
|
|
29
|
+
function revealCss(t, dist = 16) {
|
|
30
|
+
const tt = clamp01(t);
|
|
31
|
+
let css = '';
|
|
32
|
+
for (const [sel, a, b] of STAGGER) {
|
|
33
|
+
const p = win(tt, a, b);
|
|
34
|
+
css += `${sel}{opacity:${p.toFixed(3)};transform:translateY(${((1 - p) * dist).toFixed(2)}px)}`;
|
|
35
|
+
}
|
|
36
|
+
return css;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// HTML for a single reel frame at time t. Reuses the card, appends the reveal styles.
|
|
40
|
+
function buildReelFrame(spec, t, opts = {}) {
|
|
41
|
+
const card = buildCard(spec, opts);
|
|
42
|
+
const overrides = `<style id="reel">${revealCss(t)}</style>`;
|
|
43
|
+
return { html: card.html.replace('</head>', `${overrides}</head>`), width: card.width, height: card.height };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// the list of t values for a reel of `seconds` at `fps` (>=2 frames)
|
|
47
|
+
function reelFrames(seconds = 2.6, fps = 20) {
|
|
48
|
+
const n = Math.max(2, Math.round(seconds * fps));
|
|
49
|
+
return Array.from({ length: n }, (_, i) => i / (n - 1));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { buildReelFrame, revealCss, reelFrames, win, STAGGER };
|