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