@open-assets/open-assets 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -25
- package/bin/open-assets.mjs +2 -3
- package/lib/init.mjs +1 -1
- package/lib/lockfile.mjs +6 -3
- package/lib/manifest.mjs +5 -17
- package/lib/render.mjs +333 -176
- package/lib/renderer.mjs +251 -88
- package/lib/server.mjs +20 -12
- package/package.json +3 -3
- package/public/img/banner/1280.png +0 -0
- package/public/img/examples/tax-days-icon.png +0 -0
- package/public/img/examples/tax-days.png +0 -0
- package/public/img/examples/thoughtful-icon.png +0 -0
- package/public/img/examples/thoughtful.png +0 -0
- package/skills/open-assets/SKILL.md +48 -36
- package/public/img/banner/1280/banner.png +0 -0
package/lib/render.mjs
CHANGED
|
@@ -1,24 +1,66 @@
|
|
|
1
|
-
import { resolve, join } from "path";
|
|
2
|
-
import { mkdirSync,
|
|
1
|
+
import { resolve, join, relative } from "path";
|
|
2
|
+
import { mkdirSync, copyFileSync, existsSync } from "fs";
|
|
3
|
+
import { writeFile } from "fs/promises";
|
|
4
|
+
import { cpus } from "os";
|
|
3
5
|
import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } from "./manifest.mjs";
|
|
4
6
|
import { computeExportChecksum } from "./lockfile.mjs";
|
|
5
7
|
import { loadLocalizations, resolveStrings, getLocales, getDirection } from "./localization.mjs";
|
|
6
8
|
|
|
9
|
+
// Chromium serializes the capture path, so a unit's screenshots cannot overlap
|
|
10
|
+
// each other no matter how many workers ask. A second worker still pays off: it
|
|
11
|
+
// navigates and lays out its page while the first is capturing. A third has
|
|
12
|
+
// nothing left to hide, and each one past that adds a renderer process and a
|
|
13
|
+
// full set of framebuffers while screenshot latency grows in proportion.
|
|
14
|
+
//
|
|
15
|
+
// Measured on a 10-core M-series Mac, 8 templates, median of 5 runs:
|
|
16
|
+
// 4 App Store sizes 1:9.2s 2:9.4s 3:9.7s 4:9.6s 6:9.9s 8:10.4s
|
|
17
|
+
// 1 small size 1:2.0s 2:1.8s 3:2.0s 4:2.0s 6:2.5s 8:3.5s
|
|
18
|
+
const CONCURRENCY_CAP = 2;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pick how many render units run concurrently.
|
|
22
|
+
*
|
|
23
|
+
* The smallest of three ceilings wins: the work actually available, the CPU
|
|
24
|
+
* count (leaving a core for this process and Chromium's browser process), and
|
|
25
|
+
* the cap above. OPEN_ASSETS_CONCURRENCY overrides the result, which is the
|
|
26
|
+
* escape hatch for templates heavy enough to change the arithmetic.
|
|
27
|
+
*
|
|
28
|
+
* @param {unknown[]} units - Render units to be drained by the worker pool
|
|
29
|
+
* @param {{ override?: string|number, cores?: number }} [opts]
|
|
30
|
+
* @returns {number}
|
|
31
|
+
*/
|
|
32
|
+
export function resolveConcurrency(units, opts = {}) {
|
|
33
|
+
if (units.length === 0) return 1;
|
|
34
|
+
|
|
35
|
+
const raw = opts.override ?? process.env.OPEN_ASSETS_CONCURRENCY;
|
|
36
|
+
const override = parseInt(raw, 10);
|
|
37
|
+
if (Number.isFinite(override) && override > 0) {
|
|
38
|
+
return Math.min(override, units.length);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cores = opts.cores ?? cpus().length;
|
|
42
|
+
return Math.max(1, Math.min(units.length, cores - 1, CONCURRENCY_CAP));
|
|
43
|
+
}
|
|
44
|
+
|
|
7
45
|
/**
|
|
8
46
|
* @typedef {object} RenderDeps
|
|
9
|
-
* @property {(projectDir: string, htmlFile: string,
|
|
47
|
+
* @property {(projectDir: string, htmlFile: string, sourceW: number, sourceH: number, variants: {width: number, height: number, format?: string}[], options?: object) => Promise<Buffer[]>} renderVariants
|
|
10
48
|
* @property {(projectDir: string, collection: object, outputConfig: object) => Promise<string>} runXcodeOutput
|
|
11
49
|
* @property {() => Promise<void>} closeBrowser
|
|
12
50
|
* @property {(projectDir: string) => object} readLockfile
|
|
13
51
|
* @property {(projectDir: string, data: object) => void} writeLockfile
|
|
14
52
|
* @property {(filePath: string) => string|null} computeChecksum
|
|
15
|
-
* @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string) => boolean} isUpToDate
|
|
53
|
+
* @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string, projectDir?: string) => boolean} isUpToDate
|
|
16
54
|
* @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string) => void} recordExport
|
|
17
55
|
*/
|
|
18
56
|
|
|
19
57
|
/**
|
|
20
58
|
* Render assets according to the manifest and options.
|
|
21
59
|
*
|
|
60
|
+
* Work is grouped into units of one (template, locale) pair: each unit
|
|
61
|
+
* navigates its page once and screenshots every export size from that single
|
|
62
|
+
* load. Units render concurrently on a small internal pool.
|
|
63
|
+
*
|
|
22
64
|
* @param {string} projectDir - Absolute path to the project directory
|
|
23
65
|
* @param {object} manifest - Parsed manifest object
|
|
24
66
|
* @param {object} options - Render options from CLI
|
|
@@ -30,8 +72,9 @@ import { loadLocalizations, resolveStrings, getLocales, getDirection } from "./l
|
|
|
30
72
|
* @param {string} [options.format] - Output format override (png or jpg)
|
|
31
73
|
* @param {string|number} [options.width] - Custom width
|
|
32
74
|
* @param {string|number} [options.height] - Custom height
|
|
33
|
-
* @param {boolean} [options.force] -
|
|
75
|
+
* @param {boolean} [options.force] - Re-render all assets, ignoring the incremental cache
|
|
34
76
|
* @param {string} [options.output] - Output directory path
|
|
77
|
+
* @param {string|number} [options.renderTimeout] - Puppeteer render timeout in ms
|
|
35
78
|
* @param {boolean} [options.quiet] - Suppress logs
|
|
36
79
|
* @param {RenderDeps} deps - Injected dependencies
|
|
37
80
|
* @param {(msg: string) => void} [log] - Optional log function
|
|
@@ -54,216 +97,330 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
|
|
|
54
97
|
const outputDir = resolve(options.output || "./exports");
|
|
55
98
|
mkdirSync(outputDir, { recursive: true });
|
|
56
99
|
|
|
100
|
+
const renderTimeout = options.renderTimeout
|
|
101
|
+
? parseInt(options.renderTimeout, 10)
|
|
102
|
+
: undefined;
|
|
103
|
+
|
|
57
104
|
const lockData = deps.readLockfile(projectDir);
|
|
58
105
|
const results = [];
|
|
59
106
|
let skipped = 0;
|
|
60
107
|
const startTime = Date.now();
|
|
61
108
|
|
|
62
|
-
for
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const { sizes: sizesToRender, warning } = resolveRenderSizes(col, {
|
|
69
|
-
width: options.width,
|
|
70
|
-
height: options.height,
|
|
71
|
-
size: options.size,
|
|
72
|
-
force: options.force,
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
if (warning) {
|
|
76
|
-
_log(` Warning: ${warning}`);
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (sizesToRender.length === 0) {
|
|
81
|
-
_log(` No export sizes found for collection "${col.id}"`);
|
|
82
|
-
continue;
|
|
109
|
+
// Source checksums are memoized for the run: a template is hashed once no
|
|
110
|
+
// matter how many sizes and locales it renders to.
|
|
111
|
+
const checksumCache = new Map();
|
|
112
|
+
const checksumOf = (srcPath) => {
|
|
113
|
+
if (!checksumCache.has(srcPath)) {
|
|
114
|
+
checksumCache.set(srcPath, deps.computeChecksum(srcPath));
|
|
83
115
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
116
|
+
return checksumCache.get(srcPath);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// The lockfile and browser are handled in finally so a mid-run failure
|
|
120
|
+
// still persists completed work and never leaks a Chromium process.
|
|
121
|
+
try {
|
|
122
|
+
for (const col of collections) {
|
|
123
|
+
const sourceW = col.sourceSize.width;
|
|
124
|
+
const sourceH = col.sourceSize.height;
|
|
125
|
+
|
|
126
|
+
const templates = filterTemplates(col.templates, options.template);
|
|
127
|
+
|
|
128
|
+
const { sizes: sizesToRender, warning } = resolveRenderSizes(col, {
|
|
129
|
+
width: options.width,
|
|
130
|
+
height: options.height,
|
|
131
|
+
size: options.size,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (warning) {
|
|
135
|
+
_log(` Warning: ${warning}`);
|
|
136
|
+
continue;
|
|
98
137
|
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const singleTemplate = templates.length === 1;
|
|
102
|
-
const multipleSizes = sizesToRender.length > 1 || options.force;
|
|
103
|
-
// Single-template collections use flat layout with entry.name as filename
|
|
104
|
-
// Multi-template collections use subdirs per size
|
|
105
|
-
const useSubDirs = multipleSizes && !singleTemplate;
|
|
106
|
-
const hasLocales = locales.length > 0 && locales[0] !== null;
|
|
107
138
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const localization = locale ? { strings, locale, direction } : undefined;
|
|
112
|
-
|
|
113
|
-
if (locale) {
|
|
114
|
-
_log(`\n Locale: ${locale} (${direction})`);
|
|
139
|
+
if (sizesToRender.length === 0) {
|
|
140
|
+
_log(` No export sizes found for collection "${col.id}"`);
|
|
141
|
+
continue;
|
|
115
142
|
}
|
|
116
143
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
: join(outputDir, col.id);
|
|
144
|
+
// Resolve locales for this collection
|
|
145
|
+
let locales = [null]; // null = no localization
|
|
146
|
+
let localizations = null;
|
|
147
|
+
|
|
148
|
+
if (col.localizations) {
|
|
149
|
+
localizations = loadLocalizations(projectDir, col.localizations);
|
|
150
|
+
locales = getLocales(localizations, col.locales);
|
|
151
|
+
if (options.locale) {
|
|
152
|
+
locales = locales.filter((l) => l === options.locale);
|
|
153
|
+
if (locales.length === 0) {
|
|
154
|
+
_log(` Warning: locale "${options.locale}" not found in collection "${col.id}", skipping`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
131
157
|
}
|
|
132
|
-
|
|
158
|
+
}
|
|
133
159
|
|
|
134
|
-
|
|
135
|
-
|
|
160
|
+
// Layout is decided by the collection's full template list, not the
|
|
161
|
+
// filtered one, so --template renders land in the same paths as full runs
|
|
162
|
+
const singleTemplate = col.templates.length === 1;
|
|
163
|
+
// Single-template collections use flat layout with entry.name as filename
|
|
164
|
+
// Multi-template collections use subdirs per size
|
|
165
|
+
const useSubDirs = !singleTemplate;
|
|
166
|
+
const hasLocales = locales.length > 0 && locales[0] !== null;
|
|
167
|
+
|
|
168
|
+
// Enumerate render units: one per (locale, template), each carrying
|
|
169
|
+
// its non-skipped size variants. Skip checks run up front so units
|
|
170
|
+
// whose variants are all cached never open a page.
|
|
171
|
+
const units = [];
|
|
172
|
+
|
|
173
|
+
for (const locale of locales) {
|
|
174
|
+
const strings = locale ? resolveStrings(localizations, locale) : null;
|
|
175
|
+
const direction = locale ? getDirection(locale) : null;
|
|
176
|
+
const localization = locale ? { strings, locale, direction } : undefined;
|
|
177
|
+
|
|
178
|
+
if (locale) {
|
|
179
|
+
_log(`\n Locale: ${locale} (${direction})`);
|
|
136
180
|
}
|
|
137
181
|
|
|
138
182
|
for (const template of templates) {
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
? entry.outFile.split(".").pop().toLowerCase()
|
|
142
|
-
: "png";
|
|
143
|
-
const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
|
|
144
|
-
const defaultExt = format === "jpeg" ? "jpg" : "png";
|
|
145
|
-
|
|
146
|
-
// Single-template: use entry.name as filename (e.g. logo-1024.png)
|
|
147
|
-
// Multi-template: use template.name as filename (e.g. 01-hero.png)
|
|
148
|
-
const fileName = singleTemplate && multipleSizes
|
|
149
|
-
? `${entry.name}.${defaultExt}`
|
|
150
|
-
: `${template.name}.${defaultExt}`;
|
|
151
|
-
|
|
152
|
-
let outPath;
|
|
153
|
-
if (entry.outFile) {
|
|
154
|
-
let resolved = entry.outFile.replace(/\{template\}/g, template.name);
|
|
155
|
-
if (locale) {
|
|
156
|
-
resolved = resolved.replace(/\{locale\}/g, locale);
|
|
157
|
-
}
|
|
158
|
-
outPath = resolve(projectDir, resolved);
|
|
159
|
-
} else {
|
|
160
|
-
outPath = join(sizeDir, fileName);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Asset key and variant key include locale for cache isolation
|
|
183
|
+
const srcPath = resolve(projectDir, template.src);
|
|
184
|
+
const checksum = checksumOf(srcPath);
|
|
164
185
|
const assetKey = locale
|
|
165
186
|
? `${col.id}/${locale}/${template.name}`
|
|
166
187
|
: `${col.id}/${template.name}`;
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
188
|
+
|
|
189
|
+
const variants = [];
|
|
190
|
+
|
|
191
|
+
for (const entry of sizesToRender) {
|
|
192
|
+
const w = entry.size.width;
|
|
193
|
+
const h = entry.size.height;
|
|
194
|
+
|
|
195
|
+
// Build output directory: insert locale level when localizations are active
|
|
196
|
+
let sizeDir;
|
|
197
|
+
if (hasLocales) {
|
|
198
|
+
sizeDir = useSubDirs
|
|
199
|
+
? join(outputDir, col.id, locale, entry.name)
|
|
200
|
+
: join(outputDir, col.id, locale);
|
|
201
|
+
} else {
|
|
202
|
+
sizeDir = useSubDirs
|
|
203
|
+
? join(outputDir, col.id, entry.name)
|
|
204
|
+
: join(outputDir, col.id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Determine output format from outFile extension or default to png
|
|
208
|
+
const outExt = entry.outFile
|
|
209
|
+
? entry.outFile.split(".").pop().toLowerCase()
|
|
210
|
+
: "png";
|
|
211
|
+
const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
|
|
212
|
+
const defaultExt = format === "jpeg" ? "jpg" : "png";
|
|
213
|
+
|
|
214
|
+
// Single-template: use entry.name as filename (e.g. logo/1024.png)
|
|
215
|
+
// Multi-template: use template.name as filename (e.g. 01-hero.png)
|
|
216
|
+
const fileName = singleTemplate
|
|
217
|
+
? `${entry.name}.${defaultExt}`
|
|
218
|
+
: `${template.name}.${defaultExt}`;
|
|
219
|
+
|
|
220
|
+
let outPath;
|
|
221
|
+
if (entry.outFile) {
|
|
222
|
+
let resolved = entry.outFile.replace(/\{template\}/g, template.name);
|
|
223
|
+
if (locale) {
|
|
224
|
+
resolved = resolved.replace(/\{locale\}/g, locale);
|
|
225
|
+
}
|
|
226
|
+
outPath = resolve(projectDir, resolved);
|
|
227
|
+
// Create the destination directory now, before any render or
|
|
228
|
+
// SVG source copy targets it.
|
|
229
|
+
mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
|
|
230
|
+
} else {
|
|
231
|
+
mkdirSync(sizeDir, { recursive: true });
|
|
232
|
+
outPath = join(sizeDir, fileName);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const exportCksum = computeExportChecksum(entry, locale);
|
|
236
|
+
// Lockfile paths are stored relative to the project so the cache
|
|
237
|
+
// is portable across machines. The variant is keyed by output path,
|
|
238
|
+
// so the same template rendered to different destinations gets its
|
|
239
|
+
// own cache entry per destination.
|
|
240
|
+
const lockOutPath = relative(projectDir, outPath);
|
|
241
|
+
const variantKey = lockOutPath;
|
|
242
|
+
|
|
243
|
+
// Skip if unchanged
|
|
244
|
+
if (
|
|
245
|
+
!options.force &&
|
|
246
|
+
checksum &&
|
|
247
|
+
deps.isUpToDate(lockData, assetKey, variantKey, checksum, lockOutPath, exportCksum, projectDir)
|
|
248
|
+
) {
|
|
249
|
+
_log(
|
|
250
|
+
` Skipping ${template.name}${locale ? ` [${locale}]` : ""} at ${w}x${h} (unchanged)`
|
|
251
|
+
);
|
|
252
|
+
skipped++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
variants.push({
|
|
257
|
+
w,
|
|
258
|
+
h,
|
|
259
|
+
format,
|
|
260
|
+
defaultExt,
|
|
261
|
+
outPath,
|
|
262
|
+
lockOutPath,
|
|
263
|
+
exportCksum,
|
|
264
|
+
});
|
|
183
265
|
}
|
|
184
266
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
267
|
+
if (variants.length > 0) {
|
|
268
|
+
units.push({
|
|
269
|
+
template,
|
|
270
|
+
locale,
|
|
271
|
+
localization,
|
|
272
|
+
srcPath,
|
|
273
|
+
checksum,
|
|
274
|
+
assetKey,
|
|
275
|
+
variants,
|
|
276
|
+
logs: [],
|
|
277
|
+
results: [],
|
|
278
|
+
completed: false,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
188
283
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
284
|
+
// Drain units through a small worker pool. Logs are buffered per unit
|
|
285
|
+
// and flushed in enumeration order so output stays deterministic.
|
|
286
|
+
let nextUnit = 0;
|
|
287
|
+
let nextFlush = 0;
|
|
288
|
+
const errors = [];
|
|
289
|
+
|
|
290
|
+
const flushLogs = () => {
|
|
291
|
+
while (nextFlush < units.length && units[nextFlush].completed) {
|
|
292
|
+
for (const line of units[nextFlush].logs) _log(line);
|
|
293
|
+
nextFlush++;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const runUnit = async (unit) => {
|
|
298
|
+
const isSvg = unit.template.src.endsWith(".svg");
|
|
299
|
+
unit.logs.push(
|
|
300
|
+
` Rendering ${unit.template.name}${unit.locale ? ` [${unit.locale}]` : ""} (${unit.variants.length} size${unit.variants.length === 1 ? "" : "s"})...`
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const buffers = await deps.renderVariants(
|
|
304
|
+
projectDir,
|
|
305
|
+
unit.template.src,
|
|
306
|
+
sourceW,
|
|
307
|
+
sourceH,
|
|
308
|
+
unit.variants.map((v) => ({ width: v.w, height: v.h, format: v.format })),
|
|
309
|
+
{
|
|
310
|
+
background: col.background,
|
|
311
|
+
localization: unit.localization,
|
|
312
|
+
timeout: renderTimeout,
|
|
192
313
|
}
|
|
314
|
+
);
|
|
193
315
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
316
|
+
for (let i = 0; i < unit.variants.length; i++) {
|
|
317
|
+
const v = unit.variants[i];
|
|
318
|
+
const buffer = buffers[i];
|
|
319
|
+
|
|
320
|
+
if (isSvg && v.w === sourceW && v.h === sourceH) {
|
|
321
|
+
copyFileSync(unit.srcPath, v.outPath.replace(`.${v.defaultExt}`, ".svg"));
|
|
197
322
|
}
|
|
198
323
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
sourceW,
|
|
205
|
-
sourceH,
|
|
206
|
-
{ format, background: col.background, localization }
|
|
207
|
-
);
|
|
208
|
-
writeFileSync(outPath, buffer);
|
|
209
|
-
results.push({
|
|
324
|
+
await writeFile(v.outPath, buffer);
|
|
325
|
+
// The output write above must complete before the variant is
|
|
326
|
+
// marked rendered; recordExport keys off this flag.
|
|
327
|
+
v.rendered = true;
|
|
328
|
+
unit.results.push({
|
|
210
329
|
collection: col.id,
|
|
211
|
-
template: template.name,
|
|
212
|
-
locale: locale || undefined,
|
|
213
|
-
path: outPath,
|
|
214
|
-
width: w,
|
|
215
|
-
height: h,
|
|
330
|
+
template: unit.template.name,
|
|
331
|
+
locale: unit.locale || undefined,
|
|
332
|
+
path: v.outPath,
|
|
333
|
+
width: v.w,
|
|
334
|
+
height: v.h,
|
|
216
335
|
size: buffer.length,
|
|
217
336
|
});
|
|
218
|
-
|
|
219
|
-
|
|
337
|
+
unit.logs.push(` → ${v.outPath} (${v.w}x${v.h})`);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const workers = Array.from(
|
|
342
|
+
{ length: resolveConcurrency(units) },
|
|
343
|
+
async () => {
|
|
344
|
+
while (nextUnit < units.length && errors.length === 0) {
|
|
345
|
+
const unit = units[nextUnit++];
|
|
346
|
+
try {
|
|
347
|
+
await runUnit(unit);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
unit.logs.push(` Error rendering ${unit.template.name}${unit.locale ? ` [${unit.locale}]` : ""}: ${err.message}`);
|
|
350
|
+
errors.push(err);
|
|
351
|
+
}
|
|
352
|
+
unit.completed = true;
|
|
353
|
+
flushLogs();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
await Promise.all(workers);
|
|
358
|
+
flushLogs();
|
|
359
|
+
|
|
360
|
+
// Collect results and lockfile records in enumeration order so the
|
|
361
|
+
// output arrays and lockfile bytes are stable across runs.
|
|
362
|
+
for (const unit of units) {
|
|
363
|
+
results.push(...unit.results);
|
|
364
|
+
if (!unit.checksum) continue;
|
|
365
|
+
for (const v of unit.variants) {
|
|
366
|
+
if (v.rendered) {
|
|
367
|
+
deps.recordExport(lockData, unit.assetKey, v.lockOutPath, unit.checksum, v.lockOutPath, v.exportCksum);
|
|
220
368
|
}
|
|
221
|
-
_log(` → ${outPath}`);
|
|
222
369
|
}
|
|
223
370
|
}
|
|
224
|
-
}
|
|
225
371
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
372
|
+
if (errors.length > 0) {
|
|
373
|
+
throw errors[0];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Handle outputs (xcode, copy-source, etc.)
|
|
377
|
+
const outputs = flatOutputs(col);
|
|
378
|
+
if (outputs.length > 0) {
|
|
379
|
+
for (const output of outputs) {
|
|
380
|
+
if (output.type === "xcode") {
|
|
381
|
+
_log(`\n Exporting to Xcode: ${output.path}`);
|
|
382
|
+
const path = await deps.runXcodeOutput(projectDir, col, output);
|
|
383
|
+
results.push({ collection: col.id, type: "xcode", path });
|
|
384
|
+
} else if (output.type === "copy-source") {
|
|
385
|
+
const format = output.format || "svg";
|
|
386
|
+
const copyDir = join(outputDir, col.id);
|
|
387
|
+
mkdirSync(copyDir, { recursive: true });
|
|
388
|
+
const matchingTemplates = templates.filter((t) => t.src.endsWith(`.${format}`));
|
|
389
|
+
// Naming follows the collection's full match count so --template
|
|
390
|
+
// runs produce the same filenames as full runs
|
|
391
|
+
const collectionMatches = col.templates.filter((t) => t.src.endsWith(`.${format}`));
|
|
392
|
+
for (const template of matchingTemplates) {
|
|
393
|
+
const srcPath = resolve(projectDir, template.src);
|
|
394
|
+
// Use entry name when single match, template name when multiple
|
|
395
|
+
const baseName = output.name && collectionMatches.length === 1
|
|
396
|
+
? output.name
|
|
397
|
+
: template.name;
|
|
398
|
+
const destPath = join(copyDir, `${baseName}.${format}`);
|
|
399
|
+
if (existsSync(srcPath)) {
|
|
400
|
+
copyFileSync(srcPath, destPath);
|
|
401
|
+
results.push({
|
|
402
|
+
collection: col.id,
|
|
403
|
+
template: template.name,
|
|
404
|
+
type: `copy-${format}`,
|
|
405
|
+
path: destPath,
|
|
406
|
+
});
|
|
407
|
+
_log(` → ${destPath}`);
|
|
408
|
+
}
|
|
255
409
|
}
|
|
256
410
|
}
|
|
257
411
|
}
|
|
258
412
|
}
|
|
259
413
|
}
|
|
414
|
+
} finally {
|
|
415
|
+
// Persist whatever completed, even when a render failed mid-run
|
|
416
|
+
deps.writeLockfile(projectDir, lockData);
|
|
417
|
+
try {
|
|
418
|
+
await deps.closeBrowser();
|
|
419
|
+
} catch {
|
|
420
|
+
// A browser-close failure must not mask the original render error
|
|
421
|
+
}
|
|
260
422
|
}
|
|
261
423
|
|
|
262
|
-
// Persist lockfile
|
|
263
|
-
deps.writeLockfile(projectDir, lockData);
|
|
264
|
-
|
|
265
|
-
await deps.closeBrowser();
|
|
266
|
-
|
|
267
424
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
268
425
|
return { results, skipped, elapsed: `${elapsed}s` };
|
|
269
426
|
}
|