@open-assets/open-assets 0.5.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 CHANGED
@@ -316,9 +316,18 @@ Options:
316
316
  | `-o, --output <dir>` | `OPEN_ASSETS_OUTPUT` | `./exports` | Output directory |
317
317
  | `--config <path>` | `OPEN_ASSETS_CONFIG` | `assets.json` | Path to config file |
318
318
  | `--render-timeout <ms>` | `OPEN_ASSETS_RENDER_TIMEOUT` | `30000` | Puppeteer render timeout |
319
+ | — | `OPEN_ASSETS_CONCURRENCY` | auto | Templates rendered at once (see below) |
319
320
  | `--json` | — | — | Output results as JSON |
320
321
  | `-q, --quiet` | `OPEN_ASSETS_QUIET` | `false` | Suppress progress logs |
321
322
 
323
+ **Concurrency**: each template is loaded once per locale and screenshotted at
324
+ every export size from that single page load. A couple of templates render at a
325
+ time, which is where the throughput ceiling sits: Chromium serializes the
326
+ capture path, so extra workers queue behind each other instead of adding speed.
327
+ The default is `min(cores - 1, 2)`. Raise `OPEN_ASSETS_CONCURRENCY` only if your
328
+ templates are unusually slow to lay out relative to how long they take to
329
+ capture, and measure before keeping the change.
330
+
322
331
  **Selective export**: flags compose naturally:
323
332
  ```bash
324
333
  # Single template at a single device size
package/lib/render.mjs CHANGED
@@ -6,9 +6,41 @@ import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } f
6
6
  import { computeExportChecksum } from "./lockfile.mjs";
7
7
  import { loadLocalizations, resolveStrings, getLocales, getDirection } from "./localization.mjs";
8
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));
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
+ }
12
44
 
13
45
  /**
14
46
  * @typedef {object} RenderDeps
@@ -307,7 +339,7 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
307
339
  };
308
340
 
309
341
  const workers = Array.from(
310
- { length: Math.min(CONCURRENCY, units.length) },
342
+ { length: resolveConcurrency(units) },
311
343
  async () => {
312
344
  while (nextUnit < units.length && errors.length === 0) {
313
345
  const unit = units[nextUnit++];
package/lib/renderer.mjs CHANGED
@@ -45,6 +45,10 @@ export function warmupBrowser() {
45
45
  return getBrowser();
46
46
  }
47
47
 
48
+ // CDP sessions used to keep pooled pages renderable while they sit in the
49
+ // background. Keyed weakly so a closed page's session is not retained.
50
+ const pageSessions = new WeakMap();
51
+
48
52
  async function acquirePage() {
49
53
  const browser = await getBrowser();
50
54
  while (pagePool.length > 0) {
@@ -52,7 +56,39 @@ async function acquirePage() {
52
56
  // Drop pages tied to a closed or replaced browser instance
53
57
  if (!page.isClosed() && page.browser() === browser) return page;
54
58
  }
55
- return browser.newPage();
59
+ // Every page gets its own browser context, which puts it in its own window.
60
+ // Pages sharing a context are tabs in one window, and headless Chrome only
61
+ // drives the rendering pipeline for the active tab: the rest are frozen, so
62
+ // anything that waits on that pipeline (timers, document.fonts.ready,
63
+ // img.decode()) never settles and the render hangs until protocolTimeout.
64
+ const context = await browser.createBrowserContext();
65
+ const page = await context.newPage();
66
+ // Separate windows still compete for focus, so tell each page to consider
67
+ // itself focused. Without this a page can be treated as occluded and stall
68
+ // the same way a background tab does.
69
+ try {
70
+ const session = await page.createCDPSession();
71
+ await session.send("Emulation.setFocusEmulationEnabled", { enabled: true });
72
+ pageSessions.set(page, session);
73
+ } catch {
74
+ // Best-effort: the browser context already prevents the hard stall.
75
+ }
76
+ return page;
77
+ }
78
+
79
+ /**
80
+ * Assert that a page is in the "active" lifecycle state. Chromium may freeze a
81
+ * page that has been idle in the pool; a frozen page runs no timers, so the
82
+ * readiness barrier in settle() would never resolve.
83
+ */
84
+ async function activatePage(page) {
85
+ const session = pageSessions.get(page);
86
+ if (!session) return;
87
+ try {
88
+ await session.send("Page.setWebLifecycleState", { state: "active" });
89
+ } catch {
90
+ // Best-effort; not all builds implement this on every target.
91
+ }
56
92
  }
57
93
 
58
94
  function releasePage(page) {
@@ -114,6 +150,7 @@ export async function renderVariants(
114
150
  ) {
115
151
  const page = await acquirePage();
116
152
  try {
153
+ await activatePage(page);
117
154
  const first = variants[0];
118
155
  await page.setViewport({
119
156
  width: first.width,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-assets/open-assets",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Screenshots, icons, and marketing assets, made a breeze in your git repo. Design in HTML/CSS/SVG, render at every size, export anywhere. Like Storybook for marketing assets.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -201,6 +201,8 @@ Export entries with a `type` field are post-render actions that run after every
201
201
  | `--json` | Output results as JSON |
202
202
  | `-q, --quiet` | Suppress progress logs |
203
203
 
204
+ Set `OPEN_ASSETS_CONCURRENCY` to change how many templates render at once. It defaults to `min(cores - 1, 2)`; higher values queue behind Chromium's capture path rather than going faster.
205
+
204
206
  Flags compose naturally:
205
207
  - `--collection screenshots --template 01-hero --size iphone-6.9` → single template, single size
206
208
  - `--collection screenshots --template 01-hero` → one template, all sizes
@@ -208,7 +210,7 @@ Flags compose naturally:
208
210
 
209
211
  #### Performance: prefer targeted renders when iterating
210
212
 
211
- A plain `render` is incremental: unchanged templates are skipped via `assets.lock`, so it is usually fast. The renderer also loads each template once per locale and screenshots every export size from that single page load, with several templates rendering in parallel. Still, `--force` re-renders **every collection** in `assets.json` (icons, splash, badges, screenshots, etc.), so when you're iterating on a single asset, scope the render:
213
+ A plain `render` is incremental: unchanged templates are skipped via `assets.lock`, so it is usually fast. The renderer also loads each template once per locale and screenshots every export size from that single page load, with a couple of templates rendering concurrently. Still, `--force` re-renders **every collection** in `assets.json` (icons, splash, badges, screenshots, etc.), so when you're iterating on a single asset, scope the render:
212
214
 
213
215
  ```bash
214
216
  # Iterating on one icon (fastest)