feedback5 WH: 自选 art — fetch-assets plans every data/backups.json unit research 07 lacks (the 72 owned 6★: avatar / portrait with E2, battle Spine Front / Back, every skill icon and skill sound, sub-profession icon, from 07's URL patterns), the 自选 summons as tokens, and a type icon per module of chess.json / backups.json (manifest modules, the loadout tiles' fallback); --add-only downloads only missing files and never rewrites, re-downloads or deletes an existing one (worktrees sharing public/assets)
Downloaded 1,295 files, 127.2 MB (133,426,089 bytes) into the shared public/assets; no existing file changed or removed. data/assets.json only gains entries: 72 chars (all with avatar, portrait, Front and Back Spine), 38 tokens (avatars; no Spine — upstream has skin variants only), 208 skill icons, 10 sub-profession icons, 102 SFX units, 131 module type icons.
This commit is contained in:
1 parent
42920c62c5
commit
9db99aad00
7 files changed
+231
-32
No files matched your search
@@ -10,7 +10,8 @@
|
||||
// - Idempotent: an existing file is kept when its size matches the ledger entry
|
||||
// of a previous download or the expected byte count from research, or (when
|
||||
// neither is known) when it passes format validation. The ledger lives in
|
||||
// .cache/assets-ledger.json.
|
||||
// .cache/assets-ledger.json. `keepExisting` (fetch-assets --add-only) keeps
|
||||
// every existing file as it is; `written` = the files this run wrote.
|
||||
|
||||
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -39,17 +40,21 @@ export class Downloader {
|
||||
* @param {number} [o.retries]
|
||||
* @param {number} [o.timeoutMs]
|
||||
* @param {boolean} [o.force] re-download even when files exist
|
||||
* @param {boolean} [o.keepExisting] never re-download or rewrite an existing file (a checkout sharing public/assets)
|
||||
* @param {(msg:string)=>void} [o.log]
|
||||
* @param {typeof fetch} [o.fetchImpl]
|
||||
* @param {number} [o.backoffMs] base retry delay (doubles per attempt)
|
||||
*/
|
||||
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
|
||||
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, keepExisting = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
|
||||
this.root = root;
|
||||
this.ledgerPath = ledgerPath;
|
||||
this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16));
|
||||
this.retries = Math.max(1, Number(retries) || 3);
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.force = force;
|
||||
this.force = force && !keepExisting;
|
||||
this.keepExisting = !!keepExisting;
|
||||
/** @type {Set<string>} the files (paths under root) this run wrote */
|
||||
this.written = new Set();
|
||||
this.log = log;
|
||||
this.fetch = fetchImpl;
|
||||
this.backoffMs = Math.max(0, Number(backoffMs) || 0);
|
||||
@@ -87,6 +92,7 @@ export class Downloader {
|
||||
let st;
|
||||
try { st = await stat(abs); } catch { return -1; }
|
||||
if (!st.isFile() || st.size <= 0) return -1;
|
||||
if (this.keepExisting) return st.size;
|
||||
const led = this.ledger.files[job.rel];
|
||||
if (job.mutable) {
|
||||
// Post-processed files (atlases) change size; validate content instead.
|
||||
@@ -162,6 +168,7 @@ export class Downloader {
|
||||
return { status: 'error', bytes: 0, error: `write failed: ${e.message}` };
|
||||
}
|
||||
this.ledger.files[job.rel] = { url: src, bytes: r.buf.length };
|
||||
this.written.add(job.rel);
|
||||
this.dirty++;
|
||||
const sizeChanged = !!(job.bytes && job.bytes !== r.buf.length && !job.mutable);
|
||||
return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged };
|
||||
|
||||
+52
-7
@@ -11,7 +11,10 @@
|
||||
// audio_data.json and Ark-Models' models_data.json.
|
||||
//
|
||||
// Scope (research 07 §1, DESIGN §0): all 138 pool charIds (incl. backup
|
||||
// operators), the 20 pool tokens, every enemy that can appear in an
|
||||
// operators), the 自选 owned-6★ picks of data/backups.json (`extraOperators`:
|
||||
// research 07 does not list them — their URLs follow its patterns) and their
|
||||
// summons, the module type icons of every module the data offers
|
||||
// (`moduleTypes`), the 20 pool tokens, every enemy that can appear in an
|
||||
// act2autochess match (07 enemy list ∪ act1autochess wave/boss levels used by
|
||||
// act2 modes ∪ bosses ∪ their summons ∪ enemy units spawned by operator kits),
|
||||
// the 23 bonds, 59 shop items, 40 bands,
|
||||
@@ -165,6 +168,32 @@ function walkKeys(node, add) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A research-07-shaped operator record of a character research 07 does not list (the 自选 owned-6★ picks): every URL
|
||||
* from the patterns of 07-assets.json `meta.patterns` — avatar / portrait (E0–E1 and E2), the default-skin battle Spine
|
||||
* Front / Back, the skill icons, the sub-profession icon. No expected byte counts (the downloader validates the files);
|
||||
* a file the mirrors lack is a miss the client falls back from (the E2 art to the E0–E1 one, a missing Back to Front).
|
||||
* @param {string} id charId
|
||||
* @param {{ subProfessionId?: string|null, nationId?: string|null, skills?: Array<{ index: number, skillId: string, iconId?: string|null }> }} x
|
||||
*/
|
||||
export function patternOperator(id, x = {}) {
|
||||
const sp = (side) => {
|
||||
const b = `${RAW.fexli}spine/${id}/${id}/${side}/${id}`;
|
||||
return { skel: `${b}.skel`, atlas: `${b}.atlas`, png: `${b}.png` };
|
||||
};
|
||||
return {
|
||||
name: x.name ?? null, subProfessionId: x.subProfessionId ?? null, nationId: x.nationId ?? null, chess: [],
|
||||
avatar: { e0e1: { url: `${RAW.yuanyan}avatar/${id}.png` }, e2: { url: `${RAW.yuanyan}avatar/${id}_2.png` } },
|
||||
portrait: { e0e1: { url: `${RAW.yuanyan}portrait/${id}_1.png` }, e2: { url: `${RAW.yuanyan}portrait/${id}_2.png` } },
|
||||
skills: (x.skills || []).map((s) => ({
|
||||
index: s.index, skillId: s.skillId, iconId: s.iconId || s.skillId,
|
||||
icon: { url: `${RAW.yuanyan}skill/skill_icon_${encodeURIComponent(s.iconId || s.skillId)}.png` },
|
||||
})),
|
||||
battleSpine: { front: sp('Front'), back: sp('Back'), note: null },
|
||||
subProfessionIcon: x.subProfessionId ? joinUrl(RAW.aa2, `arts/ui/subprofessionicon/sub_${x.subProfessionId}_icon.png`) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// id sets
|
||||
|
||||
@@ -249,9 +278,14 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
|
||||
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
|
||||
* local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
|
||||
* gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
|
||||
* @param {Record<string, any>} [p.extraOperators] charId → { subProfessionId, nationId, skills: [{ index, skillId, iconId }] }
|
||||
* of characters research 07 does not list (the 自选 owned-6★ picks, data/backups.json `units`): planned like the pool
|
||||
* operators from patternOperator
|
||||
* @param {string[]} [p.moduleTypes] module type icon ids (`typeIcon`, e.g. 'sol-x') → manifest `modules[typeIcon]`, the
|
||||
* official type icon (arts/ui/uniequiptype) the 干员调配 / 自选 module tiles draw when the local-client art lacks it
|
||||
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
|
||||
*/
|
||||
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {} }) {
|
||||
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, extraOperators = {}, moduleTypes = [] }) {
|
||||
const notes = [];
|
||||
/** @type {Map<string, any>} */
|
||||
const models = new Map();
|
||||
@@ -280,9 +314,13 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
const skills = {};
|
||||
const skillsById = {};
|
||||
const unitsSfx = {};
|
||||
const charIds = Object.keys(assets07?.operators || {}).sort();
|
||||
const known = assets07?.operators || {};
|
||||
const extraOps = {};
|
||||
for (const [id, x] of Object.entries(extraOperators || {})) if (/^char_\d+_[a-z0-9]+$/i.test(id) && !known[id]) extraOps[id] = patternOperator(id, x);
|
||||
const operators = { ...known, ...extraOps };
|
||||
const charIds = Object.keys(operators).sort();
|
||||
for (const id of charIds) {
|
||||
const o = assets07.operators[id];
|
||||
const o = operators[id];
|
||||
// DESIGN §16 operator loadouts: any skill of the character can be equipped — the icons, skill SFX and Spine skill
|
||||
// clips of every skill index (the pool's primary index first, as before)
|
||||
const idx0 = skillIdx.get(id) || [0];
|
||||
@@ -448,7 +486,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
for (const p of [...PROFESSIONS, 'token']) {
|
||||
prof.battlecard[p] = leaf(alt(`prof/battlecard_${p}.png`, joinUrl(RAW.aa2, `arts/ui/[uc]battlecommon/ui_battle_new/battlecard/icon_profession_${p}.png`)));
|
||||
}
|
||||
for (const o of Object.values(assets07?.operators || {})) {
|
||||
for (const o of Object.values(operators)) {
|
||||
const sub = o.subProfessionId;
|
||||
if (typeof sub === 'string' && sub && !prof.sub[sub] && o.subProfessionIcon) prof.sub[sub] = leaf(alt(`prof/sub/${safeName(sub)}.png`, o.subProfessionIcon));
|
||||
}
|
||||
@@ -463,7 +501,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
for (const [group, entries] of Object.entries(assets07?.autochessUi || {})) {
|
||||
for (const [key, url] of Object.entries(entries || {})) addUi(group, key, url);
|
||||
}
|
||||
const nations = new Set(Object.values(assets07?.operators || {}).map((o) => o.nationId).filter(Boolean));
|
||||
const nations = new Set(Object.values(operators).map((o) => o.nationId).filter(Boolean));
|
||||
const logos = new Set(['logo_rhodes', ...[...nations].map((n) => `logo_${n}`)]);
|
||||
for (const b of Object.values(assets07?.bonds || {})) if (b.fallbackCampLogo) logos.add(urlBase(b.fallbackCampLogo).replace(/\.png$/i, ''));
|
||||
for (const [src, group] of Object.entries(ARTS_GROUPS)) {
|
||||
@@ -517,8 +555,15 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
const sfxBattle = {};
|
||||
for (const [name, spec] of Object.entries(BATTLE_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxBattle[name] = l; else notes.push(`battle SFX ${name}: no sound`); }
|
||||
|
||||
// --- module type icons ----------------------------------------------------
|
||||
const modules = {};
|
||||
for (const t of [...new Set(moduleTypes || [])].filter((x) => typeof x === 'string' && /^[a-z0-9-]+$/i.test(x)).sort()) {
|
||||
// the client's file name, else its lower-case form (the type id of a few modules is mixed case: WAH-Y → wah-y.png)
|
||||
modules[t] = leaf(alt(`module/${safeName(t)}.png`, [...new Set([t, t.toLowerCase()])].map((n) => joinUrl(RAW.aa2, `arts/ui/uniequiptype/${n}.png`))));
|
||||
}
|
||||
|
||||
const template = {
|
||||
chars, enemies, tokens, bonds, items, bands, skills, skillsById, ui, prof,
|
||||
chars, enemies, tokens, bonds, items, bands, skills, skillsById, modules, ui, prof,
|
||||
audio: { bgm, bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))), sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx } },
|
||||
};
|
||||
return { template, models, notes };
|
||||
|
||||
@@ -123,10 +123,12 @@ export async function loadLocalEnemySpines(path) {
|
||||
* @param {import('./downloader.mjs').Downloader} o.dl
|
||||
* @param {string} o.cachePath parse cache JSON path
|
||||
* @param {boolean} [o.download] false = only post-process what is on disk
|
||||
* @param {(rel: string) => boolean} [o.writable] whether a file on disk may be rewritten (normalized atlas) or deleted
|
||||
* (corrupt skeleton); fetch-assets --add-only allows only the files this run downloaded
|
||||
* @param {(m:string)=>void} [o.log]
|
||||
* @returns {Promise<{ entries: Map<string, SpineEntry>, problems: string[] }>}
|
||||
*/
|
||||
export async function processModels(models, { root, dl, cachePath, download = true, log = console.log }) {
|
||||
export async function processModels(models, { root, dl, cachePath, download = true, writable = () => true, log = console.log }) {
|
||||
const problems = [];
|
||||
const list = [...models.values()];
|
||||
if (download) {
|
||||
@@ -180,6 +182,7 @@ export async function processModels(models, { root, dl, cachePath, download = tr
|
||||
const norm = normalizeAtlas(atlasText, { pageSize: (p) => sizes.get(p) || null, pma: !!m.pma, renamePage: (p) => safeName(p) });
|
||||
if (norm.missingSize.length) { problems.push(`${m.key}: cannot size pages ${norm.missingSize.join(',')}`); continue; }
|
||||
if (norm.changed) {
|
||||
if (!writable(m.atlas.rel)) { problems.push(`${m.key}: atlas needs normalizing, left as it is (an existing file)`); continue; }
|
||||
await writeFile(atlasAbs + '.tmp', norm.text);
|
||||
await rename(atlasAbs + '.tmp', atlasAbs);
|
||||
}
|
||||
@@ -196,6 +199,7 @@ export async function processModels(models, { root, dl, cachePath, download = tr
|
||||
} catch (e) {
|
||||
// A corrupt skeleton would otherwise be kept forever (its size matches the ledger):
|
||||
// delete it and forget it so the next online run downloads it again.
|
||||
if (!writable(m.skel.rel)) { problems.push(`${m.key}: skel parse failed (${e.message}); an existing file, left as it is`); continue; }
|
||||
problems.push(`${m.key}: skel parse failed (${e.message}); deleted, re-run to re-download`);
|
||||
try { await unlink(skelAbs); } catch { /* ignore */ }
|
||||
if (dl?.ledger?.files) delete dl.ledger.files[m.skel.rel];
|
||||
|
||||
+61
-15
@@ -24,9 +24,21 @@
|
||||
// the entries it would drop and exits 1; --allow-shrink (or --prune) writes the
|
||||
// smaller manifest.
|
||||
//
|
||||
// Beyond research 07's ids the plan covers what data/*.json adds: spawnable
|
||||
// enemies / tokens (data/enemies.json, data/tokens.json), the 自选 owned-6★ picks
|
||||
// of data/backups.json `units` (art from 07's URL patterns) and their summons
|
||||
// (`tokens`), and the module type icons of every module in data/chess.json /
|
||||
// data/backups.json (manifest `modules`).
|
||||
//
|
||||
// --add-only: for a checkout whose public/assets / public/fonts are shared with
|
||||
// another one (a git worktree): download only the files missing on disk, never
|
||||
// re-download, rewrite or delete an existing file (atlases already on disk are
|
||||
// left as they are; fonts are not rebuilt — the manifest keeps its current
|
||||
// `fonts`).
|
||||
//
|
||||
// Usage: node tools/fetch-assets.mjs [--concurrency=16] [--force] [--offline]
|
||||
// [--dry-run] [--refresh-index] [--prune]
|
||||
// [--allow-shrink] [--local-spines] [--help]
|
||||
// [--allow-shrink] [--add-only] [--local-spines] [--help]
|
||||
|
||||
import { readFile, writeFile, mkdir, rename, readdir, unlink } from 'node:fs/promises';
|
||||
import { existsSync, realpathSync } from 'node:fs';
|
||||
@@ -59,6 +71,8 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
|
||||
(public/assets/local/** of tools/local-extract is never deleted); implies --allow-shrink
|
||||
--allow-shrink write data/assets.json even when it loses entries the current one has
|
||||
(without it such a run keeps the current manifest, lists the entries and exits 1)
|
||||
--add-only download only files missing on disk; never re-download, rewrite or delete an existing
|
||||
file, no font rebuild (a worktree sharing public/assets and public/fonts)
|
||||
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} from the enemy models extracted
|
||||
by tools/local-extract/extract.py (public/assets/local/spine/enemy/)
|
||||
--help this text`;
|
||||
@@ -66,10 +80,10 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
|
||||
/**
|
||||
* Parse CLI flags.
|
||||
* @param {string[]} argv
|
||||
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, localSpines:boolean, help:boolean}}
|
||||
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, addOnly:boolean, localSpines:boolean, help:boolean}}
|
||||
*/
|
||||
export function parseArgs(argv) {
|
||||
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, localSpines: false, help: false };
|
||||
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, addOnly: false, localSpines: false, help: false };
|
||||
for (const a of argv) {
|
||||
const [k, v] = a.split('=');
|
||||
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
|
||||
@@ -79,13 +93,38 @@ export function parseArgs(argv) {
|
||||
else if (k === '--refresh-index') o.refreshIndex = true;
|
||||
else if (k === '--prune') o.prune = true;
|
||||
else if (k === '--allow-shrink') o.allowShrink = true;
|
||||
else if (k === '--add-only') o.addOnly = true;
|
||||
else if (k === '--local-spines') o.localSpines = true;
|
||||
else if (k === '--help' || k === '-h') o.help = true;
|
||||
else throw new Error(`unknown option ${a}\n${HELP}`);
|
||||
}
|
||||
if (o.addOnly && (o.prune || o.force)) throw new Error(`--add-only never deletes or rewrites files: not with --prune / --force\n${HELP}`);
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* What data/backups.json and data/chess.json add to the asset plan: `extraOperators` — every unit of backups.json
|
||||
* (`{ name, subProfessionId, nationId, skills: [{ index, skillId, iconId }] }`; buildPlan plans those research 07 lacks,
|
||||
* the 自选 owned-6★ picks), `tokenIds` — the summons of the 自选 picks (backups.json `tokens`), `moduleTypes` — the type
|
||||
* icon of every module a chess or a unit form offers.
|
||||
* @param {any} backups data/backups.json (or null)
|
||||
* @param {any} chess data/chess.json (or null)
|
||||
*/
|
||||
export function dataExtras(backups, chess) {
|
||||
const extraOperators = {};
|
||||
const moduleTypes = new Set();
|
||||
for (const [id, u] of Object.entries(backups?.units || {})) {
|
||||
const skills = new Map();
|
||||
for (const f of Object.values(u.forms || {})) {
|
||||
for (const sk of f.skills || []) if (!skills.has(sk.index)) skills.set(sk.index, { index: sk.index, skillId: sk.skillId, iconId: sk.iconId || sk.skillId });
|
||||
for (const m of f.modules || []) if (m.typeIcon) moduleTypes.add(m.typeIcon);
|
||||
}
|
||||
extraOperators[id] = { name: u.name, subProfessionId: u.subProfessionId, nationId: u.nationId, skills: [...skills.values()].sort((a, b) => a.index - b.index) };
|
||||
}
|
||||
for (const c of Object.values(chess || {})) for (const m of c?.modules || []) if (m.typeIcon) moduleTypes.add(m.typeIcon);
|
||||
return { extraOperators, tokenIds: Object.keys(backups?.tokens || {}).sort(), moduleTypes: [...moduleTypes].sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The shrink guard of data/assets.json (header): the entries of the current manifest `prev` that `next` would drop,
|
||||
* and whether `next` may be written — always when nothing is dropped (or there is no current manifest), otherwise
|
||||
@@ -156,6 +195,7 @@ function countStats(m, bytes, files) {
|
||||
items: Object.keys(m.items || {}).length,
|
||||
bands: Object.keys(m.bands || {}).length,
|
||||
skills: Object.keys(m.skills || {}).length,
|
||||
modules: Object.keys(m.modules || {}).length,
|
||||
ui: Object.keys(m.ui || {}).length,
|
||||
sfxUnits: Object.keys(m.audio?.sfx?.units || {}).length,
|
||||
};
|
||||
@@ -222,17 +262,20 @@ async function main() {
|
||||
const audio = indexAudio(audioData);
|
||||
// The game data built by tools/build-data.mjs (when present) may reference more
|
||||
// spawnable enemies/tokens than research lists (e.g. 机变 enemy swaps): cover them too.
|
||||
const [dataEnemies, dataTokens, dataBosses] = await Promise.all(
|
||||
['data/enemies.json', 'data/tokens.json', 'data/bosses.json'].map((f) => readJson(f).catch(() => null)));
|
||||
const [dataEnemies, dataTokens, dataBosses, dataBackups, dataChess] = await Promise.all(
|
||||
['data/enemies.json', 'data/tokens.json', 'data/bosses.json', 'data/backups.json', 'data/chess.json'].map((f) => readJson(f).catch(() => null)));
|
||||
const extras = dataExtras(dataBackups, dataChess);
|
||||
const extraHandbook = {};
|
||||
for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId;
|
||||
const localEnemySpines = await syncLocalEnemySpines(opts);
|
||||
const plan = buildPlan({
|
||||
assets07, ops03, enemies05, maps05, audio, modelsData,
|
||||
extraEnemyIds: Object.keys(dataEnemies || {}),
|
||||
extraTokenIds: Object.keys(dataTokens || {}),
|
||||
extraTokenIds: [...Object.keys(dataTokens || {}), ...extras.tokenIds],
|
||||
extraHandbook,
|
||||
localEnemySpines,
|
||||
extraOperators: extras.extraOperators,
|
||||
moduleTypes: extras.moduleTypes,
|
||||
});
|
||||
const leaves = collectLeaves(plan.template);
|
||||
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
|
||||
@@ -246,26 +289,32 @@ async function main() {
|
||||
|
||||
const dl = new Downloader({
|
||||
root: ASSETS, ledgerPath: join(CACHE, 'assets-ledger.json'),
|
||||
concurrency: opts.concurrency, force: opts.force, log,
|
||||
concurrency: opts.concurrency, force: opts.force, keepExisting: opts.addOnly, log,
|
||||
});
|
||||
await dl.loadLedger();
|
||||
const downloadErrors = opts.offline ? [] : await downloadLeaves(leaves, dl, ASSETS, 'files');
|
||||
|
||||
// Fonts
|
||||
let current = null;
|
||||
if (existsSync(MANIFEST)) {
|
||||
try { current = JSON.parse(await readFile(MANIFEST, 'utf8')); } catch (e) { log(`[manifest] the current ${relative(ROOT, MANIFEST)} is unreadable (${e.message}): replaced`); }
|
||||
}
|
||||
|
||||
// Fonts (--add-only: not rebuilt — public/fonts may be another checkout's; the manifest keeps its current entry)
|
||||
let fontErrors = [];
|
||||
if (!opts.offline) {
|
||||
if (!opts.offline && !opts.addOnly) {
|
||||
const fdl = new Downloader({ root: FONTS, ledgerPath: join(CACHE, 'fonts-ledger.json'), concurrency: 4, force: opts.force, log });
|
||||
await fdl.loadLedger();
|
||||
await fdl.run(fontJobs(), 'fonts');
|
||||
dl.totals.bytesDownloaded += fdl.totals.bytesDownloaded;
|
||||
for (const k of ['ok', 'skip', 'miss', 'error']) dl.totals[k] += fdl.totals[k];
|
||||
}
|
||||
const fonts = await buildFonts(FONTS, log);
|
||||
const fonts = opts.addOnly ? { files: {}, errors: [] } : await buildFonts(FONTS, log);
|
||||
fontErrors = fonts.errors;
|
||||
|
||||
// Spine
|
||||
const spine = await processModels(plan.models, {
|
||||
root: ASSETS, dl, cachePath: join(CACHE, 'spine-info.json'), download: !opts.offline, log,
|
||||
...(opts.addOnly ? { writable: (rel) => dl.written.has(rel) } : null),
|
||||
});
|
||||
|
||||
// Manifest
|
||||
@@ -274,7 +323,8 @@ async function main() {
|
||||
tidyManifest(body);
|
||||
const fontFaces = {};
|
||||
for (const [name, f] of Object.entries(fonts.files)) fontFaces[name] = f;
|
||||
body.fonts = existsSync(join(FONTS, 'fonts.css')) ? { css: '/fonts/fonts.css', faces: fontFaces } : { faces: fontFaces };
|
||||
body.fonts = opts.addOnly && current?.fonts ? current.fonts
|
||||
: existsSync(join(FONTS, 'fonts.css')) ? { css: '/fonts/fonts.css', faces: fontFaces } : { faces: fontFaces };
|
||||
const bytes = totalBytes(ASSETS, resolved.files);
|
||||
const manifest = {
|
||||
version: MANIFEST_VERSION,
|
||||
@@ -283,10 +333,6 @@ async function main() {
|
||||
stats: countStats(body, bytes, resolved.files.size),
|
||||
...body,
|
||||
};
|
||||
let current = null;
|
||||
if (existsSync(MANIFEST)) {
|
||||
try { current = JSON.parse(await readFile(MANIFEST, 'utf8')); } catch (e) { log(`[manifest] the current ${relative(ROOT, MANIFEST)} is unreadable (${e.message}): replaced`); }
|
||||
}
|
||||
const guard = shrinkGuard(current, manifest, opts);
|
||||
if (guard.write) await writeJsonAtomic(MANIFEST, manifest);
|
||||
|
||||
|
||||
Reference in new issue
Block a user