paper-motion 0.3.0 → 0.4.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/CHANGELOG.md +124 -0
- package/bin/pamo.mjs +222 -21
- 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 +95 -18
- package/lib/customize.mjs +40 -2
- package/lib/edit.mjs +342 -0
- package/lib/gather.mjs +241 -0
- package/lib/host.mjs +25 -0
- package/lib/inventory.mjs +531 -0
- package/lib/piece.mjs +93 -17
- package/lib/registry.mjs +22 -6
- package/lib/render.mjs +13 -9
- package/lib/studio.mjs +347 -33
- package/package.json +1 -1
- package/runtime/index.js +10 -1
- package/runtime/player.js +18 -6
- 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 +455 -327
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/chain.mjs
CHANGED
|
@@ -13,16 +13,29 @@
|
|
|
13
13
|
// whip the frame slides out sideways with a motion smear and the next slides in (0.35 s, the default)
|
|
14
14
|
// dissolve a cross-fade (0.5 s)
|
|
15
15
|
// dip out to the brand's ground color and back in (0.5 s)
|
|
16
|
+
// or a transition from the registry the user chose: wipe, push, zoom-through, iris, crossfade, slices, flash,
|
|
17
|
+
// scan-reveal, mask-reveal, glitch, cells, flip-3d, whip-pan (see MOVE_JOINS)
|
|
16
18
|
// Each part plays from `from` (default 0) for `seconds` (default: the whole scene). A part cut before its
|
|
17
19
|
// land (the beat a block declares with B.cue(s, 'land', t)) gets a warning with the length that keeps it.
|
|
20
|
+
import crypto from 'node:crypto';
|
|
18
21
|
import fs from 'node:fs';
|
|
19
22
|
import os from 'node:os';
|
|
20
23
|
import path from 'node:path';
|
|
21
24
|
import { spawn, spawnSync } from 'node:child_process';
|
|
25
|
+
import { lengthRange } from './inventory.mjs';
|
|
22
26
|
|
|
23
27
|
export const JOINS = { cut: 0, whip: 0.35, dissolve: 0.5, dip: 0.5 };
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
// the registry's transitions the user may have chosen, as joins between parts: [ffmpeg xfade transition, seconds].
|
|
29
|
+
// So a chosen transition plays where one part becomes the next (it can also be built inside a part, by hand).
|
|
30
|
+
export const MOVE_JOINS = {
|
|
31
|
+
crossfade: ['fade', 0.5], wipe: ['wipeleft', 0.5], push: ['slideleft', 0.45], 'zoom-through': ['zoomin', 0.5],
|
|
32
|
+
iris: ['circleopen', 0.55], slices: ['hrslice', 0.5], flash: ['fadewhite', 0.4], 'scan-reveal': ['wipedown', 0.5],
|
|
33
|
+
'mask-reveal': ['radial', 0.55], glitch: ['pixelize', 0.35], cells: ['dissolve', 0.5], 'flip-3d': ['squeezeh', 0.45],
|
|
34
|
+
'whip-pan': ['slideleft', 0.35],
|
|
35
|
+
};
|
|
36
|
+
for (const [k, [, d]] of Object.entries(MOVE_JOINS)) JOINS[k] = d;
|
|
37
|
+
JOINS.whip = 0.35; // the engine's own whip (slide + smear) wins over the registry's whip move of the same name
|
|
38
|
+
const OVERLAP = new Set(['whip', 'dissolve', ...Object.keys(MOVE_JOINS)]); // joins that need the outgoing part to run on under the next
|
|
26
39
|
|
|
27
40
|
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
28
41
|
const pad = (i) => String(i + 1).padStart(2, '0');
|
|
@@ -54,8 +67,10 @@ export async function structureFromBoard(ref, option, { site }) {
|
|
|
54
67
|
if (String(ref).includes('#')) board = await decodeBoard(String(ref).slice(String(ref).indexOf('#') + 1));
|
|
55
68
|
else {
|
|
56
69
|
const id = String(ref).replace(/\/$/, '').split('/').pop();
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
// a full link says where its board lives; a bare id is looked up on the site (--site, or the default)
|
|
71
|
+
const from = /^https?:\/\//i.test(String(ref)) ? new URL(String(ref)).origin : site;
|
|
72
|
+
const res = await fetch(`${from}/api/boards/${id}`);
|
|
73
|
+
if (!res.ok) throw new Error(`board ${id}: ${res.status} from ${from} (expired or never stored there: pass the board's full link, the long #… link, or --site <url>)`);
|
|
59
74
|
board = (await res.json()).board;
|
|
60
75
|
}
|
|
61
76
|
const opts = board?.options ?? [];
|
|
@@ -141,28 +156,76 @@ export function plan(chain, infos) {
|
|
|
141
156
|
const info = infos[i], D = info.config.duration, from = p.from ?? 0;
|
|
142
157
|
const join = i === 0 ? 'cut' : (p.join ?? chain.join ?? 'whip');
|
|
143
158
|
if (!JOINS.hasOwnProperty(join)) throw new Error(`part ${i + 1}: join "${join}" (use ${Object.keys(JOINS).join(', ')})`);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
159
|
+
// a part plays its whole scene: another length plays it faster or slower (never cut, never looped), within limits
|
|
160
|
+
const natural = +(D - from).toFixed(3), range = lengthRange(natural);
|
|
161
|
+
let seconds = p.seconds ?? natural;
|
|
162
|
+
if (seconds < range.min - 1e-6 || seconds > range.max + 1e-6) {
|
|
163
|
+
const held = Math.min(range.max, Math.max(range.min, seconds));
|
|
164
|
+
warnings.push(`${p.dir}: ${seconds} s would play its ${natural} s scene too ${seconds < range.min ? 'fast' : 'slow'} (it takes ${range.min}–${range.max} s); it plays ${held} s`);
|
|
165
|
+
seconds = held;
|
|
166
|
+
}
|
|
167
|
+
return { ...p, from, seconds, join, D, natural, rate: natural / seconds };
|
|
150
168
|
});
|
|
151
|
-
// the outgoing part
|
|
169
|
+
// under an overlapping join the outgoing part holds its last frame while the next one comes in
|
|
152
170
|
for (const [i, p] of parts.entries()) {
|
|
153
171
|
const next = parts[i + 1], d = next && OVERLAP.has(next.join) ? JOINS[next.join] : 0;
|
|
154
|
-
p.
|
|
172
|
+
p.out = +(p.seconds + d).toFixed(3);
|
|
155
173
|
}
|
|
156
174
|
return { parts, warnings, total: parts.reduce((a, p) => a + p.seconds, 0) };
|
|
157
175
|
}
|
|
158
176
|
|
|
177
|
+
// ── is a joined render still current, and is one being made right now ──────────────────────────────────────
|
|
178
|
+
/**
|
|
179
|
+
* A fingerprint of everything a joined render shows: the order, lengths and joins in chain.json, each part's
|
|
180
|
+
* files, the brand's tokens and backgrounds, the project's moves. A render made from another fingerprint is stale.
|
|
181
|
+
*/
|
|
182
|
+
export function chainSig(cwd, dir) {
|
|
183
|
+
const h = crypto.createHash('sha1');
|
|
184
|
+
const chain = readJson(path.join(dir, 'chain.json'), { parts: [] });
|
|
185
|
+
h.update(JSON.stringify([chain.format ?? null, chain.join ?? null, (chain.parts ?? []).map((p) => [p.dir, p.id, p.seconds ?? null, p.from ?? null, p.join ?? null])]));
|
|
186
|
+
const add = (f) => {
|
|
187
|
+
try {
|
|
188
|
+
const st = fs.statSync(f); if (st.isDirectory()) return;
|
|
189
|
+
h.update(path.basename(f));
|
|
190
|
+
if (/\.(html|json|js|mjs|css|svg)$/.test(f)) h.update(fs.readFileSync(f)); else h.update(`${st.size}:${Math.round(st.mtimeMs)}`);
|
|
191
|
+
} catch { /* gone: nothing to add */ }
|
|
192
|
+
};
|
|
193
|
+
const all = (d, skip = () => false) => { try { for (const f of fs.readdirSync(d).sort()) if (!skip(f)) add(path.join(d, f)); } catch { /* no such folder */ } };
|
|
194
|
+
for (const p of chain.parts ?? []) if (p.dir) all(path.join(dir, p.dir), (f) => f.endsWith('.md'));
|
|
195
|
+
for (const f of ['brand/tokens.json', 'brand/shader-set/set.json', 'brand/sound.json']) add(path.join(cwd, f));
|
|
196
|
+
all(path.join(cwd, 'moves'));
|
|
197
|
+
return h.digest('hex').slice(0, 16);
|
|
198
|
+
}
|
|
199
|
+
const sigFile = (dir) => path.join(dir, 'renders', '.sig.json');
|
|
200
|
+
/** What each joined render in renders/ was made from: { "<file>": { sig, sfx, audio } }. */
|
|
201
|
+
export const readSigs = (dir) => readJson(sigFile(dir), {});
|
|
202
|
+
|
|
203
|
+
// the join in progress, for the studio's "updating" notice and so two joins of the same file never run at once
|
|
204
|
+
const jobFile = (dir) => path.join(dir, '.job.json');
|
|
205
|
+
/** The join running on this piece right now (its process still alive), or null. */
|
|
206
|
+
export function readJob(dir) {
|
|
207
|
+
const j = readJson(jobFile(dir), null);
|
|
208
|
+
if (!j) return null;
|
|
209
|
+
try { process.kill(j.pid, 0); return j; } catch { return null; }
|
|
210
|
+
}
|
|
211
|
+
const writeJob = (dir, j) => { try { fs.writeFileSync(jobFile(dir), JSON.stringify({ pid: process.pid, at: Date.now(), ...j })); } catch { /* read-only: no notice */ } };
|
|
212
|
+
const endJob = (dir) => { try { if (readJson(jobFile(dir), {}).pid === process.pid) fs.rmSync(jobFile(dir)); } catch { /* already gone */ } };
|
|
213
|
+
|
|
159
214
|
/**
|
|
160
215
|
* Renders every part in the brand's look and joins them into one file.
|
|
161
216
|
* Must run from the project root (where brand/ is): scenes load the brand from /brand.
|
|
162
217
|
*/
|
|
163
218
|
export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur = 1, dpr = 1, audio, sfx = true, draft = false, onStep = () => {} }) {
|
|
164
219
|
if (draft) { dpr = 0.5; motionBlur = 1; }
|
|
165
|
-
|
|
220
|
+
// the piece's own draft and final are tracked (what they were made from, and that they are being made);
|
|
221
|
+
// a render to another file (--out) is the caller's business
|
|
222
|
+
const home = pieceDir(piece, cwd), kind = out ? null : draft ? 'assembly' : 'final', sig = kind ? chainSig(cwd, home) : null;
|
|
223
|
+
if (kind) {
|
|
224
|
+
const busy = readJob(home);
|
|
225
|
+
if (busy && busy.pid !== process.pid && busy.kind === kind) throw new Error(`this piece's ${kind === 'assembly' ? 'draft' : 'final'} is already being rendered (${busy.of ? `part ${busy.part} of ${busy.of}` : 'starting'}): wait for it, then look at piece status`);
|
|
226
|
+
writeJob(home, { kind });
|
|
227
|
+
}
|
|
228
|
+
try { return await withChain({ cwd, piece }, async ({ dir, url, parts, warnings, total, config }) => {
|
|
166
229
|
const { renderVideo } = await import('./render.mjs');
|
|
167
230
|
for (const w of warnings) onStep({ warning: w });
|
|
168
231
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-chain-'));
|
|
@@ -170,8 +233,9 @@ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur
|
|
|
170
233
|
const files = [];
|
|
171
234
|
for (const [i, p] of parts.entries()) {
|
|
172
235
|
onStep({ part: i + 1, of: parts.length, dir: p.dir });
|
|
236
|
+
if (kind) writeJob(home, { kind, part: i + 1, of: parts.length });
|
|
173
237
|
const f = path.join(tmp, `${pad(i)}.mov`);
|
|
174
|
-
await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.
|
|
238
|
+
await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.D, rate: p.rate, seconds: p.out, sfx, motionBlur, dpr });
|
|
175
239
|
files.push(f);
|
|
176
240
|
}
|
|
177
241
|
const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), sound = readJson(path.join(cwd, 'brand/sound.json'), {});
|
|
@@ -180,10 +244,12 @@ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur
|
|
|
180
244
|
out = path.resolve(cwd, out ?? path.join(dir, 'renders', `${path.basename(dir)}${draft ? '-draft' : ''}.mp4`));
|
|
181
245
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
182
246
|
onStep({ joining: true });
|
|
247
|
+
if (kind) writeJob(home, { kind, part: parts.length, of: parts.length, joining: true });
|
|
183
248
|
const j = await join({ files, parts, out, ground, fps, music: audio, lufs: sound.loudness ?? -16 });
|
|
249
|
+
if (kind) fs.writeFileSync(sigFile(home), JSON.stringify({ ...readSigs(home), [path.basename(out)]: { sig, sfx, ...(audio ? { audio } : {}) } }, null, 2) + '\n');
|
|
184
250
|
return { out, total: j.seconds, planned: total, parts: parts.length, warnings, W, H };
|
|
185
251
|
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
186
|
-
});
|
|
252
|
+
}); } finally { if (kind) endJob(home); }
|
|
187
253
|
}
|
|
188
254
|
|
|
189
255
|
/**
|
|
@@ -205,7 +271,7 @@ export async function renderDrafts({ cwd = process.cwd(), piece, only, onStep =
|
|
|
205
271
|
mark(p.dir, { status: 'rendering' });
|
|
206
272
|
const out = path.join(dir, 'drafts', `${p.dir}.mp4`);
|
|
207
273
|
try {
|
|
208
|
-
await renderVideo({ url: url(p), out, from: p.from, to: p.
|
|
274
|
+
await renderVideo({ url: url(p), out, from: p.from, to: p.D, rate: p.rate, seconds: p.seconds, dpr: 0.5, motionBlur: 1, crf: 26 });
|
|
209
275
|
} catch (e) { mark(p.dir, { status: 'review', error: String(e.message ?? e).slice(0, 300) }); throw e; }
|
|
210
276
|
const v = (readPiece(dir)?.parts?.[p.dir]?.v ?? 0) + 1;
|
|
211
277
|
mark(p.dir, { status: 'review', v, error: undefined, note: undefined });
|
|
@@ -233,9 +299,20 @@ async function withChain({ cwd, piece }, fn) {
|
|
|
233
299
|
try {
|
|
234
300
|
const browser = await launch(), infos = [];
|
|
235
301
|
try { for (const p of chain.parts) infos.push(await sceneInfo(browser, url(p))); } finally { await browser.close(); }
|
|
302
|
+
// what each scene really lasts (some depend on their content: one beat per word), for the studio's length slider
|
|
303
|
+
try {
|
|
304
|
+
fs.writeFileSync(path.join(dir, '.lengths.json'), JSON.stringify(Object.fromEntries(chain.parts.map((p, i) => [p.dir, {
|
|
305
|
+
duration: infos[i].config.duration, mtime: Math.round(fs.statSync(path.join(dir, p.dir, 'scene.html')).mtimeMs),
|
|
306
|
+
}])), null, 2) + '\n');
|
|
307
|
+
} catch { /* the slider falls back to the block's usual length */ }
|
|
236
308
|
const sizes = new Set(infos.map((x) => `${x.config.width}x${x.config.height}`));
|
|
237
309
|
if (sizes.size > 1) throw new Error(`parts come in different sizes (${[...sizes].join(', ')}): set the same format in each CONFIG`);
|
|
238
|
-
|
|
310
|
+
const planned = plan(chain, infos);
|
|
311
|
+
// the length the brief asked for ("unos 20 s", "~20s", "16:9 · 20 s"): say so when the chain drifts from it
|
|
312
|
+
const brief = readJson(path.join(dir, 'piece.json'), null)?.brief?.format ?? '';
|
|
313
|
+
const want = Number(String(brief).match(/(\d+(?:[.,]\d+)?)\s*(?:s\b|sec|seg)/i)?.[1]?.replace(',', '.'));
|
|
314
|
+
if (want > 0 && Math.abs(planned.total - want) > Math.max(1.5, want * 0.08)) planned.warnings.push(`the chain runs ${planned.total.toFixed(1)} s and the brief asks for about ${want} s: change some parts' seconds in chain.json, or the brief`);
|
|
315
|
+
return await fn({ dir, chain, url, config: infos[0].config, ...planned });
|
|
239
316
|
} finally { await server.close(); }
|
|
240
317
|
}
|
|
241
318
|
|
|
@@ -264,7 +341,7 @@ async function join({ files, parts, out, ground, fps, music, lufs }) {
|
|
|
264
341
|
const j = parts[i].join, d = JOINS[j];
|
|
265
342
|
if (OVERLAP.has(j)) {
|
|
266
343
|
const off = Math.max(0, len - d);
|
|
267
|
-
g.push(`${v}${V[i]}xfade=transition=${j === 'whip' ? 'slideleft' : 'fade'}:duration=${d}:offset=${off.toFixed(3)}[x${i}]`);
|
|
344
|
+
g.push(`${v}${V[i]}xfade=transition=${j === 'whip' ? 'slideleft' : MOVE_JOINS[j]?.[0] ?? 'fade'}:duration=${d}:offset=${off.toFixed(3)}[x${i}]`);
|
|
268
345
|
g.push(`${a}${A[i]}acrossfade=d=${d}:c1=tri:c2=tri[y${i}]`);
|
|
269
346
|
if (j === 'whip') smears.push([off + d * 0.12, off + d * 0.88]);
|
|
270
347
|
len = off + lens[i].duration;
|
package/lib/customize.mjs
CHANGED
|
@@ -63,8 +63,15 @@ export function recolor(item, pal) {
|
|
|
63
63
|
return atLuminance(near.h, y) + (m[2] ? m[2] : '');
|
|
64
64
|
};
|
|
65
65
|
const out = {};
|
|
66
|
+
// the color behind everything takes the brand's ground exactly (its alpha kept), so the frame edge matches the brand
|
|
67
|
+
const ground = typeof pal?.ground === 'string' ? pal.ground : pal?.ground?.hex;
|
|
68
|
+
const behind = (name, v) => {
|
|
69
|
+
if (!/back/i.test(name) || !HEX.test(ground ?? '')) return null;
|
|
70
|
+
const m = typeof v === 'string' && v.match(HEX);
|
|
71
|
+
return m ? ground.slice(0, 7) + (m[2] ?? '') : null;
|
|
72
|
+
};
|
|
66
73
|
for (const p of paramsOf(item)) {
|
|
67
|
-
if (p.type === 'color') out[p.name] = one(p.default);
|
|
74
|
+
if (p.type === 'color') out[p.name] = behind(p.name, p.default) ?? one(p.default);
|
|
68
75
|
else if (p.type === 'colors' && Array.isArray(p.default)) out[p.name] = p.default.map(one);
|
|
69
76
|
}
|
|
70
77
|
return out;
|
|
@@ -124,7 +131,38 @@ function backgroundEntry(item, values) {
|
|
|
124
131
|
function paletteTokens(item, values) {
|
|
125
132
|
const e = item.install.entry ?? {};
|
|
126
133
|
const colors = Object.fromEntries(Object.entries(e.colors ?? {}).map(([role, c]) => [role, { ...c, hex: values[role] ?? c.hex }]));
|
|
127
|
-
return { ...e, colors, ramp:
|
|
134
|
+
return { ...e, colors, ramp: tunedRamp(e, colors, values.ramp) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The ramp after the colors were tuned: steps equal to a role's old color take its new one; the steps between keep
|
|
139
|
+
* their tone and take the hue of the nearest new color. A ramp given explicitly wins.
|
|
140
|
+
*/
|
|
141
|
+
function tunedRamp(e, colors, given) {
|
|
142
|
+
const ramp = e.ramp;
|
|
143
|
+
if (!Array.isArray(ramp) || (Array.isArray(given) && !same(given, ramp))) return given ?? ramp;
|
|
144
|
+
const old = Object.fromEntries(Object.entries(e.colors ?? {}).map(([role, c]) => [String(c.hex).toLowerCase(), role]));
|
|
145
|
+
const fresh = Object.values(colors).map((c) => c.hex).filter((h) => HEX.test(h ?? ''));
|
|
146
|
+
if (Object.entries(e.colors ?? {}).every(([role, c]) => colors[role].hex === c.hex)) return ramp;
|
|
147
|
+
// a ramp from one role to another (ground → accent, the usual): the steps between are mixed again from the new ends,
|
|
148
|
+
// at the same place along the way (by tone), so the ramp keeps its hue
|
|
149
|
+
const a0 = old[String(ramp[0]).toLowerCase()], a1 = old[String(ramp.at(-1)).toLowerCase()];
|
|
150
|
+
if (a0 && a1 && a0 !== a1) {
|
|
151
|
+
const [y0, y1] = [lum(ramp[0]), lum(ramp.at(-1))], [n0, n1] = [srgb(colors[a0].hex).map(lin), srgb(colors[a1].hex).map(lin)];
|
|
152
|
+
return ramp.map((h, i) => {
|
|
153
|
+
if (i === 0) return colors[a0].hex;
|
|
154
|
+
if (i === ramp.length - 1) return colors[a1].hex;
|
|
155
|
+
const t = Math.min(1, Math.max(0, (lum(h) - y0) / ((y1 - y0) || 1)));
|
|
156
|
+
return hex(n0.map((c, k) => c + t * (n1[k] - c)));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const ladder = fresh.map((h) => ({ h, y: lum(h) }));
|
|
160
|
+
return ramp.map((h) => {
|
|
161
|
+
const role = old[String(h).toLowerCase()];
|
|
162
|
+
if (role) return colors[role].hex;
|
|
163
|
+
const y = lum(h), near = ladder.reduce((a, b) => (Math.abs(b.y - y) < Math.abs(a.y - y) ? b : a));
|
|
164
|
+
return atLuminance(near.h, y);
|
|
165
|
+
});
|
|
128
166
|
}
|
|
129
167
|
|
|
130
168
|
function fontTokens(item, values) {
|