@idle-screens/schema 3.10.0 → 3.11.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/dist/index.js CHANGED
@@ -85,6 +85,7 @@ var LIMITS = {
85
85
  maxLayerTransformScale: 8,
86
86
  maxLayerTransformRotate: 3600,
87
87
  // degrees
88
+ maxGroups: 16,
88
89
  maxEmitEvery: 6e5,
89
90
  // ms — ten minutes; longer silences than that are `life.enter`
90
91
  minEmitLife: 500,
@@ -110,7 +111,7 @@ var LIMITS = {
110
111
 
111
112
  // src/steer.ts
112
113
  var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
113
- var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport", "finish"]);
114
+ var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport", "finish", "groups"]);
114
115
  function resolveSpecPath(spec, path) {
115
116
  if (!spec || typeof spec !== "object" || !path) return null;
116
117
  const parts = path.split(".");
@@ -124,6 +125,8 @@ function resolveSpecPath(spec, path) {
124
125
  return null;
125
126
  }
126
127
  }
128
+ if (parts[0] === "layers" && parts.length === 3 && parts[2] === "group") return null;
129
+ if (parts.length >= 2 && parts[parts.length - 1] === "origin" && parts[parts.length - 2] === "transform") return null;
127
130
  let node = spec;
128
131
  for (let i = 0; i < parts.length - 1; i++) {
129
132
  if (node === null || typeof node !== "object") return null;
@@ -176,6 +179,10 @@ function alignPaintFields(a, b) {
176
179
  if (!x || !y || typeof x !== "object" || typeof y !== "object") continue;
177
180
  let nx = x;
178
181
  let ny = y;
182
+ if (x.group === void 0 !== (y.group === void 0)) {
183
+ if (x.group === void 0) nx = { ...nx, group: y.group };
184
+ else ny = { ...ny, group: x.group };
185
+ }
179
186
  if (x.opacity === void 0 !== (y.opacity === void 0)) {
180
187
  if (x.opacity === void 0) nx = { ...nx, opacity: 1 };
181
188
  else ny = { ...ny, opacity: 1 };
@@ -206,7 +213,39 @@ function alignPaintFields(a, b) {
206
213
  if (nx !== x) (outA ??= la.slice())[i] = nx;
207
214
  if (ny !== y) (outB ??= lb.slice())[i] = ny;
208
215
  }
209
- return [outA ? { ...a, layers: outA } : a, outB ? { ...b, layers: outB } : b];
216
+ let ra = outA ? { ...a, layers: outA } : a;
217
+ let rb = outB ? { ...b, layers: outB } : b;
218
+ const ga = a.groups;
219
+ const gb = b.groups;
220
+ if (ga || gb) {
221
+ const na = { ...ga ?? {} };
222
+ const nb = { ...gb ?? {} };
223
+ let changed = !ga || !gb;
224
+ for (const name of /* @__PURE__ */ new Set([...Object.keys(na), ...Object.keys(nb)])) {
225
+ const x = na[name] ?? {};
226
+ const y = nb[name] ?? {};
227
+ if (!na[name] || !nb[name]) changed = true;
228
+ let nx = x;
229
+ let ny = y;
230
+ if (x.opacity === void 0 !== (y.opacity === void 0)) {
231
+ if (x.opacity === void 0) nx = { ...nx, opacity: 1 };
232
+ else ny = { ...ny, opacity: 1 };
233
+ changed = true;
234
+ }
235
+ if (!x.transform !== !y.transform) {
236
+ if (!x.transform) nx = { ...nx, transform: {} };
237
+ else ny = { ...ny, transform: {} };
238
+ changed = true;
239
+ }
240
+ na[name] = nx;
241
+ nb[name] = ny;
242
+ }
243
+ if (changed) {
244
+ ra = { ...ra, groups: na };
245
+ rb = { ...rb, groups: nb };
246
+ }
247
+ }
248
+ return [ra, rb];
210
249
  }
211
250
  function applyDeltasToSpec(spec, deltas) {
212
251
  const copy = JSON.parse(JSON.stringify(spec));
@@ -315,7 +354,8 @@ function morphNothingMorphable(a, b, opts = {}) {
315
354
  }
316
355
  function steerablePaths(spec) {
317
356
  if (!spec || typeof spec !== "object") return [];
318
- const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
357
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer", "group", "origin"]);
358
+ const SKIP_ROOT = /* @__PURE__ */ new Set(["timeline"]);
319
359
  const INDEXED = /* @__PURE__ */ new Set(["layers", "stops", "bands"]);
320
360
  const layers = spec.layers;
321
361
  const shadowedRootKeys = new Set(
@@ -333,8 +373,8 @@ function steerablePaths(spec) {
333
373
  }
334
374
  if (node && typeof node === "object") {
335
375
  for (const [k, v] of Object.entries(node)) {
336
- if (SKIP.has(k)) continue;
337
- if (!prefix && shadowedRootKeys.has(k)) continue;
376
+ if (SKIP.has(k) && prefix !== "groups") continue;
377
+ if (!prefix && (shadowedRootKeys.has(k) || SKIP_ROOT.has(k))) continue;
338
378
  walk(v, prefix ? `${prefix}.${k}` : k, k);
339
379
  }
340
380
  return;
@@ -634,7 +674,7 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
634
674
  var isStr = (v) => typeof v === "string";
635
675
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
636
676
  var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
637
- var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting", "finish", "timeline"]);
677
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting", "finish", "timeline", "groups"]);
638
678
  var KNOWN_FINISH = /* @__PURE__ */ new Set(["grain", "dither", "animate"]);
639
679
  var KNOWN_LAYER = /* @__PURE__ */ new Set([
640
680
  "count",
@@ -659,9 +699,12 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
659
699
  "clock",
660
700
  "rotate",
661
701
  "opacity",
662
- "transform"
702
+ "transform",
703
+ "group"
663
704
  ]);
664
- var KNOWN_TRANSFORM = /* @__PURE__ */ new Set(["x", "y", "scale", "scaleX", "rotate"]);
705
+ var KNOWN_TRANSFORM = /* @__PURE__ */ new Set(["x", "y", "scale", "scaleX", "rotate", "origin"]);
706
+ var KNOWN_GROUP = /* @__PURE__ */ new Set(["transform", "opacity"]);
707
+ var GROUP_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
665
708
  var KNOWN_POSITION = /* @__PURE__ */ new Set(["x", "y", "dx", "dy"]);
666
709
  var KNOWN_TIMELINE = /* @__PURE__ */ new Set(["loop", "duration", "keys"]);
667
710
  var KNOWN_TIMELINE_KEY = /* @__PURE__ */ new Set(["t", "path", "value", "ease", "dur"]);
@@ -733,12 +776,15 @@ function validateSpecPaths(spec, paths) {
733
776
  const layers = /* @__PURE__ */ new Set();
734
777
  let background = false;
735
778
  let finish = false;
779
+ let groups = false;
736
780
  for (const p of paths) {
737
781
  if (/^layers\.\d+(\.(key|count|motion)(\.|$)|$)/.test(p)) return validateSpec(spec).valid;
782
+ if (p === "groups") return validateSpec(spec).valid;
738
783
  const [head, idx] = p.split(".");
739
784
  if (head === "layers" && idx !== void 0 && /^\d+$/.test(idx)) layers.add(Number(idx));
740
785
  else if (head === "background") background = true;
741
786
  else if (head === "finish") finish = true;
787
+ else if (head === "groups") groups = true;
742
788
  else return validateSpec(spec).valid;
743
789
  }
744
790
  let ok = true;
@@ -755,6 +801,7 @@ function validateSpecPaths(spec, paths) {
755
801
  }
756
802
  if (background && spec.background !== void 0) validateBackground(spec.background, err, warn);
757
803
  if (finish && spec.finish !== void 0) validateFinish(spec.finish, "finish", err, warn);
804
+ if (groups && spec.groups !== void 0) validateGroups(spec.groups, err, warn, spec);
758
805
  if (layers.size > 0) {
759
806
  const total = spec.layers.reduce((n, l) => n + (isObj(l) && isNum(l.count) ? l.count : 0), 0);
760
807
  if (total > LIMITS.maxTotal) ok = false;
@@ -801,6 +848,7 @@ function validateSpecCore(spec) {
801
848
  }
802
849
  if (spec.background !== void 0) validateBackground(spec.background, err, warn);
803
850
  if (spec.finish !== void 0) validateFinish(spec.finish, "finish", err, warn);
851
+ if (spec.groups !== void 0) validateGroups(spec.groups, err, warn, spec);
804
852
  if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
805
853
  err("layers", "must be a non-empty array");
806
854
  } else {
@@ -815,6 +863,41 @@ function validateSpecCore(spec) {
815
863
  if (spec.timeline !== void 0 && errors.length === 0) validateTimeline(spec, err, warn);
816
864
  return { valid: errors.length === 0, errors, warnings };
817
865
  }
866
+ function validateTransform(tf, path, err, warn, spec, anchorOk) {
867
+ if (!isObj(tf)) return err(path, "must be an object {x?, y?, scale?, scaleX?, rotate?, origin?}");
868
+ for (const k of unknownKeys(tf, KNOWN_TRANSFORM)) warn(`${path}.${k}`, "unknown-property", `unknown transform property '${k}' \u2014 will be ignored`);
869
+ const [off, unit] = offsetBound(spec);
870
+ const sc = LIMITS.maxLayerTransformScale;
871
+ for (const axis of ["x", "y"]) {
872
+ if (tf[axis] !== void 0 && (!isNum(tf[axis]) || Math.abs(tf[axis]) > off)) err(`${path}.${axis}`, `must be a number within \xB1${off} (${unit})`);
873
+ }
874
+ if (tf.scale !== void 0 && (!isNum(tf.scale) || tf.scale < 0 || tf.scale > sc)) err(`${path}.scale`, `must be a number 0..${sc}`);
875
+ if (tf.scaleX !== void 0 && (!isNum(tf.scaleX) || Math.abs(tf.scaleX) > sc)) err(`${path}.scaleX`, `must be a number within \xB1${sc} (negative mirrors)`);
876
+ if (tf.rotate !== void 0 && (!isNum(tf.rotate) || Math.abs(tf.rotate) > LIMITS.maxLayerTransformRotate)) {
877
+ err(`${path}.rotate`, `must be a number of degrees within \xB1${LIMITS.maxLayerTransformRotate}`);
878
+ }
879
+ if (tf.origin !== void 0) {
880
+ if (tf.origin !== "viewport" && tf.origin !== "anchor") err(`${path}.origin`, "must be 'viewport' | 'anchor'");
881
+ else if (tf.origin === "anchor" && !anchorOk) err(`${path}.origin`, "a group transform always turns about the viewport centre \u2014 'anchor' is for a layer's own transform");
882
+ }
883
+ }
884
+ function validateGroups(groups, err, warn, spec) {
885
+ if (!isObj(groups)) return err("groups", "must be an object of named groups {name: {transform?, opacity?}}");
886
+ const names = Object.keys(groups);
887
+ if (names.length > LIMITS.maxGroups) err("groups", `at most ${LIMITS.maxGroups} groups`);
888
+ for (const name of names) {
889
+ const p = `groups.${name}`;
890
+ if (!GROUP_NAME.test(name)) err(p, "group names are 1\u201332 letters, digits, - or _, starting with a letter");
891
+ const g = groups[name];
892
+ if (!isObj(g)) {
893
+ err(p, "must be an object {transform?, opacity?}");
894
+ continue;
895
+ }
896
+ for (const k of unknownKeys(g, KNOWN_GROUP)) warn(`${p}.${k}`, "unknown-property", `unknown group property '${k}' \u2014 will be ignored`);
897
+ if (g.opacity !== void 0 && (!isNum(g.opacity) || g.opacity < 0 || g.opacity > 1)) err(`${p}.opacity`, "must be a number 0..1");
898
+ if (g.transform !== void 0) validateTransform(g.transform, `${p}.transform`, err, warn, spec, false);
899
+ }
900
+ }
818
901
  function validateTimeline(spec, errOuter, warn) {
819
902
  let failed = false;
820
903
  const err = (p, m) => {
@@ -856,10 +939,13 @@ function validateTimeline(spec, errOuter, warn) {
856
939
  if (!isStr(key.path) || key.path.trim() === "") {
857
940
  err(`${p}.path`, "must be a non-empty dot-path");
858
941
  ok = false;
942
+ } else if (/\.group$/.test(key.path) || /\.transform\.origin$/.test(key.path)) {
943
+ err(`${p}.path`, `'${key.path}' is an enum that can only step \u2014 group membership and a transform's origin are fixed per scene`);
944
+ ok = false;
859
945
  } else if (!cp) {
860
946
  err(`${p}.path`, `'${key.path}' does not resolve on the spec \u2014 a key can only animate a field the spec already has`);
861
947
  ok = false;
862
- } else if (cp.split(".").includes("timeline") || cp === "schemaVersion" || cp === "id" || cp === "label" || cp === "seed") {
948
+ } else if (cp === "timeline" || cp.startsWith("timeline.") || cp === "schemaVersion" || cp === "id" || cp === "label" || cp === "seed") {
863
949
  err(`${p}.path`, `'${key.path}' is not animatable`);
864
950
  ok = false;
865
951
  }
@@ -872,6 +958,12 @@ function validateTimeline(spec, errOuter, warn) {
872
958
  for (const e of r.errors) err(`${p}.value`, `applied at '${key.path}' \u2192 ${e.path || "<root>"}: ${e.message}`);
873
959
  return;
874
960
  }
961
+ if (isObj(key.value) && cp.endsWith("transform")) {
962
+ const before = readSpecPath(base, cp);
963
+ const o0 = isObj(before) ? before.origin ?? "viewport" : "viewport";
964
+ const o1 = key.value.origin ?? "viewport";
965
+ if (o0 !== o1) warn(`${p}.value`, "timeline-origin-steps", `this key changes the transform's origin (${String(o0)} \u2192 ${String(o1)}): the pivot switches on the key's first frame, so the layer jumps \u2014 keep one origin per layer`);
966
+ }
875
967
  if (structuralSignature(applied) !== baseSig) {
876
968
  warn(`${p}.path`, "timeline-structural-key", `'${key.path}' is structural (placement, count, motion, size\u2026): the scene rebuilds at this key and entities re-seed \u2014 a pop, not a glide. Animate paint (colour, opacity, transform, polygon points) instead`);
877
969
  }
@@ -1039,22 +1131,11 @@ function validateLayer(layer, path, err, warn, spec) {
1039
1131
  if (layer.opacity !== void 0 && (!isNum(layer.opacity) || layer.opacity < 0 || layer.opacity > 1)) {
1040
1132
  err(`${path}.opacity`, "must be a number 0..1");
1041
1133
  }
1042
- if (layer.transform !== void 0) {
1043
- const tf = layer.transform;
1044
- if (!isObj(tf)) err(`${path}.transform`, "must be an object {x?, y?, scale?, scaleX?, rotate?}");
1045
- else {
1046
- for (const k of unknownKeys(tf, KNOWN_TRANSFORM)) warn(`${path}.transform.${k}`, "unknown-property", `unknown transform property '${k}' \u2014 will be ignored`);
1047
- const [off, unit] = offsetBound(spec);
1048
- const sc = LIMITS.maxLayerTransformScale;
1049
- for (const axis of ["x", "y"]) {
1050
- if (tf[axis] !== void 0 && (!isNum(tf[axis]) || Math.abs(tf[axis]) > off)) err(`${path}.transform.${axis}`, `must be a number within \xB1${off} (${unit})`);
1051
- }
1052
- if (tf.scale !== void 0 && (!isNum(tf.scale) || tf.scale < 0 || tf.scale > sc)) err(`${path}.transform.scale`, `must be a number 0..${sc}`);
1053
- if (tf.scaleX !== void 0 && (!isNum(tf.scaleX) || Math.abs(tf.scaleX) > sc)) err(`${path}.transform.scaleX`, `must be a number within \xB1${sc} (negative mirrors)`);
1054
- if (tf.rotate !== void 0 && (!isNum(tf.rotate) || Math.abs(tf.rotate) > LIMITS.maxLayerTransformRotate)) {
1055
- err(`${path}.transform.rotate`, `must be a number of degrees within \xB1${LIMITS.maxLayerTransformRotate}`);
1056
- }
1057
- }
1134
+ if (layer.transform !== void 0) validateTransform(layer.transform, `${path}.transform`, err, warn, spec, true);
1135
+ if (layer.group !== void 0) {
1136
+ const groups = isObj(spec) && isObj(spec.groups) ? spec.groups : void 0;
1137
+ if (!isStr(layer.group)) err(`${path}.group`, "must be the name of a `groups` entry");
1138
+ else if (!groups || !Object.prototype.hasOwnProperty.call(groups, layer.group) || !isObj(groups[layer.group])) err(`${path}.group`, `'${layer.group}' is not in \`groups\` \u2014 declare it there first`);
1058
1139
  }
1059
1140
  if (layer.position !== void 0) {
1060
1141
  if (!isObj(layer.position) || !isNum(layer.position.x) || !isNum(layer.position.y)) {
@@ -2688,6 +2769,67 @@ import {
2688
2769
  createRng
2689
2770
  } from "@idle-screens/core";
2690
2771
 
2772
+ // src/paint.ts
2773
+ function layerAnchor(layer, w, h, unit) {
2774
+ const p = layer.position;
2775
+ if (p) return { x: p.x * w + (p.dx ?? 0) * unit, y: p.y * h + (p.dy ?? 0) * unit };
2776
+ const rx = layer.region?.x ?? [0, 1];
2777
+ const ry = layer.region?.y ?? [0, 1];
2778
+ return { x: (rx[0] + rx[1]) / 2 * w, y: (ry[0] + ry[1]) / 2 * h };
2779
+ }
2780
+ function transformOrigin(tf, layer, w, h, unit) {
2781
+ return tf.origin === "anchor" && layer ? layerAnchor(layer, w, h, unit) : { x: w / 2, y: h / 2 };
2782
+ }
2783
+ function transformPoint(tf, p, w, h, unit, origin = { x: w / 2, y: h / 2 }) {
2784
+ const s = tf.scale ?? 1;
2785
+ const sx = s * (tf.scaleX ?? 1);
2786
+ let x = (p.x - origin.x) * sx;
2787
+ let y = (p.y - origin.y) * s;
2788
+ if (tf.rotate) {
2789
+ const r = tf.rotate * Math.PI / 180;
2790
+ const c = Math.cos(r);
2791
+ const n = Math.sin(r);
2792
+ [x, y] = [x * c - y * n, x * n + y * c];
2793
+ }
2794
+ return { x: x + origin.x + (tf.x ?? 0) * unit, y: y + origin.y + (tf.y ?? 0) * unit };
2795
+ }
2796
+ function transformSize(tf) {
2797
+ if (!tf) return 1;
2798
+ const s = tf.scale ?? 1;
2799
+ return s * Math.sqrt(Math.abs(tf.scaleX ?? 1));
2800
+ }
2801
+ function groupOf(spec, layer) {
2802
+ return layer.group === void 0 ? void 0 : spec.groups?.[layer.group];
2803
+ }
2804
+ var clamp01 = (v) => v <= 0 ? 0 : v >= 1 ? 1 : v;
2805
+ function paintOpacity(spec, layer) {
2806
+ const own = layer.opacity === void 0 ? 1 : clamp01(layer.opacity);
2807
+ const g = groupOf(spec, layer);
2808
+ return g?.opacity === void 0 ? own : own * clamp01(g.opacity);
2809
+ }
2810
+ function paintSize(spec, layer) {
2811
+ return transformSize(layer.transform) * transformSize(groupOf(spec, layer)?.transform);
2812
+ }
2813
+ function paintMap(spec, layer, w, h, unit) {
2814
+ const tf = layer.transform;
2815
+ const gtf = groupOf(spec, layer)?.transform;
2816
+ if (!tf && !gtf) return null;
2817
+ const o = tf ? transformOrigin(tf, layer, w, h, unit) : null;
2818
+ return (p) => {
2819
+ const q = tf ? transformPoint(tf, p, w, h, unit, o) : p;
2820
+ return gtf ? transformPoint(gtf, q, w, h, unit) : q;
2821
+ };
2822
+ }
2823
+ function mapBox(map, box) {
2824
+ const pts = [[box.x0, box.y0], [box.x1, box.y0], [box.x0, box.y1], [box.x1, box.y1]].map(([x, y]) => map({ x, y }));
2825
+ return {
2826
+ x0: Math.min(...pts.map((p) => p.x)),
2827
+ y0: Math.min(...pts.map((p) => p.y)),
2828
+ x1: Math.max(...pts.map((p) => p.x)),
2829
+ y1: Math.max(...pts.map((p) => p.y))
2830
+ };
2831
+ }
2832
+
2691
2833
  // src/field.ts
2692
2834
  var TWO_PI = Math.PI * 2;
2693
2835
  var FIELD_BUCKET_MS = 100;
@@ -2697,7 +2839,7 @@ var WARP_GAIN = 2;
2697
2839
  var DRIFT_GAIN = 1;
2698
2840
  var FIELD_DRIFT_DEFAULT_AMOUNT = 0.3;
2699
2841
  var CONTRAST = [1, 1.45, 1.8, 1.95, 2];
2700
- var clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2842
+ var clamp012 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2701
2843
  function latticeHash(ix, iy, seed) {
2702
2844
  let h = Math.imul(ix | 0, 668265261) ^ Math.imul(iy | 0, 374761393) ^ Math.imul(seed | 0, 2654435761);
2703
2845
  h = Math.imul(h ^ h >>> 15, 2246822507);
@@ -2760,7 +2902,7 @@ function fieldAt(u, v, t, cfg, seed) {
2760
2902
  y += warp * WARP_GAIN * qy;
2761
2903
  }
2762
2904
  const raw = fbm(x, y, s, octaves);
2763
- return clamp01(0.5 + (raw - 0.5) * CONTRAST[octaves]);
2905
+ return clamp012(0.5 + (raw - 0.5) * CONTRAST[octaves]);
2764
2906
  }
2765
2907
  function hexToRgb255(hex) {
2766
2908
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
@@ -2776,10 +2918,10 @@ function fieldRgb(value, bands, quantize) {
2776
2918
  const q = Math.round(quantize ?? n);
2777
2919
  let f;
2778
2920
  if (q >= 2) {
2779
- const level = Math.min(q - 1, Math.floor(clamp01(value) * q));
2921
+ const level = Math.min(q - 1, Math.floor(clamp012(value) * q));
2780
2922
  f = level * (n - 1) / (q - 1);
2781
2923
  } else {
2782
- f = clamp01(value) * (n - 1);
2924
+ f = clamp012(value) * (n - 1);
2783
2925
  }
2784
2926
  const i = Math.min(n - 2, Math.floor(f));
2785
2927
  const k = f - i;
@@ -2802,9 +2944,9 @@ var DITHER_TILE = 8;
2802
2944
  var GRAIN_ALPHA = 0.35;
2803
2945
  var DITHER_ALPHA = 0.25;
2804
2946
  var FINISH_ANIMATE_HZ = 12;
2805
- var clamp012 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2947
+ var clamp013 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
2806
2948
  function finishStrength(f) {
2807
- return { grain: clamp012(f?.grain ?? 0), dither: clamp012(f?.dither ?? 0) };
2949
+ return { grain: clamp013(f?.grain ?? 0), dither: clamp013(f?.dither ?? 0) };
2808
2950
  }
2809
2951
  function grainTilePixels(seed) {
2810
2952
  const px = new Uint8ClampedArray(GRAIN_TILE * GRAIN_TILE * 4);
@@ -2983,10 +3125,10 @@ function manifestFor(spec) {
2983
3125
  workerReady: true
2984
3126
  };
2985
3127
  }
2986
- var clamp013 = (v) => v <= 0 ? 0 : v >= 1 ? 1 : v;
3128
+ var clamp014 = (v) => v <= 0 ? 0 : v >= 1 ? 1 : v;
2987
3129
  var EXPIRED = /* @__PURE__ */ Symbol("expired");
2988
3130
  function overrideHeldAt(o, t) {
2989
- const k = o.dur > 0 ? easeSmooth(clamp013((t - o.t0) / o.dur)) : 1;
3131
+ const k = o.dur > 0 ? easeSmooth(clamp014((t - o.t0) / o.dur)) : 1;
2990
3132
  return k >= 1 ? o.to : lerpValue(o.from, o.to, k, false, o.leaf);
2991
3133
  }
2992
3134
  function overrideValueAt(o, t, timelineValue) {
@@ -3015,10 +3157,10 @@ function applyOverridesAt(resolved, overrides, t, expired) {
3015
3157
  function overridesStayValid(authored, overrides, now) {
3016
3158
  const paths = [...overrides.keys()];
3017
3159
  if (paths.length === 0) return true;
3018
- const groupOf = (p) => p.split(".").slice(0, p.startsWith("layers.") ? 2 : 1).join(".");
3019
- const groups = [...new Set(paths.map(groupOf))];
3020
- const full = paths.some((p) => /^layers\.\d+(\.(key|count|motion)(\.|$)|$)/.test(p) || !/^(layers|background|finish)(\.|$)/.test(p));
3021
- const only = full ? void 0 : (p) => groups.includes(groupOf(p));
3160
+ const groupOf2 = (p) => p.split(".").slice(0, p.startsWith("layers.") ? 2 : 1).join(".");
3161
+ const groups = [...new Set(paths.map(groupOf2))];
3162
+ const full = paths.some((p) => /^layers\.\d+(\.(key|count|motion)(\.|$)|$)/.test(p) || p === "groups" || !/^(layers|background|finish|groups)(\.|$)/.test(p));
3163
+ const only = full ? void 0 : (p) => groups.includes(groupOf2(p));
3022
3164
  const seen = /* @__PURE__ */ new Set();
3023
3165
  for (const t of [now, ...timelineSampleTimesAfter(authored, now, false, only)]) {
3024
3166
  const held = /* @__PURE__ */ new Map();
@@ -3727,13 +3869,17 @@ var SpecInstance = class {
3727
3869
  for (let li = 0; li < this.layers.length; li++) {
3728
3870
  const built = this.layers[li];
3729
3871
  const opacity = built.layer.opacity;
3730
- const lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp013(opacity);
3872
+ const group = built.layer.group === void 0 ? void 0 : this.effSpec.groups?.[built.layer.group];
3873
+ let lifeA = opacity === void 0 ? lifeAlphaAt(built.layer.life, t) : lifeAlphaAt(built.layer.life, t) * clamp014(opacity);
3874
+ if (group?.opacity !== void 0) lifeA *= clamp014(group.opacity);
3731
3875
  if (lifeA <= 0) continue;
3732
3876
  const parentE = this.parentEntityFor(built);
3733
3877
  const tf = built.layer.transform;
3734
- if (tf) {
3878
+ const gtf = group?.transform;
3879
+ if (tf || gtf) {
3735
3880
  ctx.save();
3736
- this.applyLayerTransform(tf);
3881
+ if (gtf) this.applyLayerTransform(gtf, null);
3882
+ if (tf) this.applyLayerTransform(tf, built.layer);
3737
3883
  }
3738
3884
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
3739
3885
  this.drawLinks(built, t, lifeA, parentE);
@@ -3748,17 +3894,20 @@ var SpecInstance = class {
3748
3894
  if (aOut > 0) this.drawEntity(built, e, t, lifeA, parentE, { sprite: override.outgoing, alpha: aOut });
3749
3895
  if (aIn > 0) this.drawEntity(built, e, t, lifeA, parentE, { alpha: aIn });
3750
3896
  }
3751
- if (tf) ctx.restore();
3897
+ if (tf || gtf) ctx.restore();
3752
3898
  }
3753
3899
  ctx.globalAlpha = 1;
3754
3900
  ctx.globalCompositeOperation = "source-over";
3755
3901
  }
3756
- /** A layer `transform`, about the viewport centre, in min(w,h) units (px under `units: 'px'`). */
3757
- applyLayerTransform(tf) {
3902
+ /**
3903
+ * A `transform`, in min(w,h) units (px under `units: 'px'`), about the
3904
+ * viewport centre — or, with `origin: 'anchor'`, about `layer`'s own anchor
3905
+ * (a group's transform passes no layer: always the viewport centre).
3906
+ */
3907
+ applyLayerTransform(tf, layer) {
3758
3908
  const { ctx, w, h } = this;
3759
3909
  const unit = this.effSpec.units === "px" ? 1 : Math.min(w, h);
3760
- const cx = w / 2;
3761
- const cy = h / 2;
3910
+ const { x: cx, y: cy } = transformOrigin(tf, layer, w, h, unit);
3762
3911
  ctx.translate(cx + (tf.x ?? 0) * unit, cy + (tf.y ?? 0) * unit);
3763
3912
  if (tf.rotate) ctx.rotate(tf.rotate * Math.PI / 180);
3764
3913
  const s = tf.scale ?? 1;
@@ -4943,42 +5092,13 @@ function seamsWorthWarning(layer, c, entityCount) {
4943
5092
  return layer.blend !== "lighter" && layer.blend !== "screen";
4944
5093
  }
4945
5094
 
4946
- // src/paint.ts
4947
- function transformPoint(tf, p, w, h, unit) {
4948
- const s = tf.scale ?? 1;
4949
- const sx = s * (tf.scaleX ?? 1);
4950
- let x = (p.x - w / 2) * sx;
4951
- let y = (p.y - h / 2) * s;
4952
- if (tf.rotate) {
4953
- const r = tf.rotate * Math.PI / 180;
4954
- const c = Math.cos(r);
4955
- const n = Math.sin(r);
4956
- [x, y] = [x * c - y * n, x * n + y * c];
4957
- }
4958
- return { x: x + w / 2 + (tf.x ?? 0) * unit, y: y + h / 2 + (tf.y ?? 0) * unit };
4959
- }
4960
- function transformSize(tf) {
4961
- if (!tf) return 1;
4962
- const s = tf.scale ?? 1;
4963
- return s * Math.sqrt(Math.abs(tf.scaleX ?? 1));
4964
- }
4965
- var layerOpacity = (layer) => layer.opacity === void 0 ? 1 : Math.max(0, Math.min(1, layer.opacity));
4966
- function transformBox(tf, box, w, h, unit) {
4967
- const pts = [[box.x0, box.y0], [box.x1, box.y0], [box.x0, box.y1], [box.x1, box.y1]].map(([x, y]) => transformPoint(tf, { x, y }, w, h, unit));
4968
- return {
4969
- x0: Math.min(...pts.map((p) => p.x)),
4970
- y0: Math.min(...pts.map((p) => p.y)),
4971
- x1: Math.max(...pts.map((p) => p.x)),
4972
- y1: Math.max(...pts.map((p) => p.y))
4973
- };
4974
- }
4975
-
4976
5095
  // src/advise.ts
4977
5096
  var LOW_CONTRAST_FLOOR = 0.05;
4978
5097
  var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
4979
5098
  var READ_LEGIBILITY_FLOOR = 4.5;
4980
5099
  var READ_SAFE_AREA = 0.05;
4981
5100
  function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5101
+ const hidden = hiddenLayers(spec);
4982
5102
  spec = resolveTimelineAt(spec, opts.t ?? COHESION_T);
4983
5103
  const warnings = [];
4984
5104
  const w = viewport.width;
@@ -5004,6 +5124,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5004
5124
  let textLayerCount = 0;
5005
5125
  let motionLayerCount = 0;
5006
5126
  for (let li = 0; li < spec.layers.length; li++) {
5127
+ if (hidden?.[li]) continue;
5007
5128
  const layer = spec.layers[li];
5008
5129
  const entities = allEntities[li];
5009
5130
  totalEntities += entities.length;
@@ -5070,8 +5191,10 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5070
5191
  }
5071
5192
  let totalCoverage = 0;
5072
5193
  for (let li = 0; li < spec.layers.length; li++) {
5194
+ if (hidden?.[li]) continue;
5073
5195
  const layer = spec.layers[li];
5074
5196
  const entities = allEntities[li];
5197
+ const paintA = layer.opacity === void 0 && layer.group === void 0 ? 1 : paintOpacity(spec, layer);
5075
5198
  for (const e of entities) {
5076
5199
  const r = e.size / 2;
5077
5200
  let pixArea;
@@ -5096,7 +5219,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5096
5219
  const g2 = (ga * ga + ga * gb + gb * gb) / 3;
5097
5220
  duty = Math.min(layer.emit.life, layer.emit.every) / layer.emit.every * 0.5 * g2;
5098
5221
  }
5099
- totalCoverage += pixArea * e.alpha * duty / (w * h);
5222
+ totalCoverage += paintA === 1 ? pixArea * e.alpha * duty / (w * h) : pixArea * e.alpha * duty * paintA / (w * h);
5100
5223
  }
5101
5224
  if (layer.links) {
5102
5225
  const positions = entities.map((e) => positionAt(e, 0, w, h));
@@ -5105,7 +5228,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5105
5228
  const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
5106
5229
  const lwPx = (layer.links.width ?? defaultWidth) * scale;
5107
5230
  const la = layer.links.alpha ?? 1;
5108
- for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
5231
+ for (const edge of edges) totalCoverage += paintA === 1 ? edge.dist * lwPx * la / (w * h) : edge.dist * lwPx * la * paintA / (w * h);
5109
5232
  }
5110
5233
  }
5111
5234
  const density = spec.density ?? "normal";
@@ -5148,6 +5271,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5148
5271
  }
5149
5272
  }
5150
5273
  for (let li = 0; li < spec.layers.length; li++) {
5274
+ if (hidden?.[li]) continue;
5151
5275
  const layer = spec.layers[li];
5152
5276
  if (!layer.links || layer.links.mode === "chain") continue;
5153
5277
  const entities = allEntities[li];
@@ -5164,6 +5288,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5164
5288
  }
5165
5289
  }
5166
5290
  for (let li = 0; li < spec.layers.length; li++) {
5291
+ if (hidden?.[li]) continue;
5167
5292
  const layer = spec.layers[li];
5168
5293
  if (layer.motion.type === "static") continue;
5169
5294
  const entities = allEntities[li];
@@ -5180,7 +5305,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5180
5305
  });
5181
5306
  }
5182
5307
  }
5183
- const composed = spec.layers.map((l, li) => ({ l, ents: allEntities[li] })).filter(({ l }) => !["bounce", "warp", "path"].includes(l.motion.type)).filter(({ l }) => !(l.motion.type === "orbit" && l.motion.center && "layer" in l.motion.center));
5308
+ const composed = spec.layers.map((l, li) => ({ l, li, ents: allEntities[li] })).filter(({ li }) => !hidden?.[li]).filter(({ l }) => !["bounce", "warp", "path"].includes(l.motion.type)).filter(({ l }) => !(l.motion.type === "orbit" && l.motion.center && "layer" in l.motion.center));
5184
5309
  const composedCount = composed.reduce((s, c) => s + c.ents.length, 0);
5185
5310
  if (composedCount >= 8) {
5186
5311
  const positions = composed.flatMap(({ ents }) => ents.map((e) => positionAt(e, 0, w, h)));
@@ -5198,6 +5323,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5198
5323
  }
5199
5324
  const textBoxes = [];
5200
5325
  for (let li = 0; li < spec.layers.length; li++) {
5326
+ if (hidden?.[li]) continue;
5201
5327
  const layer = spec.layers[li];
5202
5328
  const s = layer.sprite;
5203
5329
  if (layer.motion.type !== "static") continue;
@@ -5206,9 +5332,10 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
5206
5332
  for (const e of allEntities[li]) {
5207
5333
  const p = positionAt(e, 0, w, h);
5208
5334
  const raw = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
5209
- const box = layer.transform ? { ...raw, ...transformBox(layer.transform, raw, w, h, scale) } : raw;
5335
+ const map = paintMap(spec, layer, w, h, scale);
5336
+ const box = map ? { ...raw, ...mapBox(map, raw) } : raw;
5210
5337
  const baseAlpha = Math.max(0, Math.min(1, e.alpha - e.pulseAmp)) * (s.kind === "textBlock" ? s.opacity ?? 1 : 1);
5211
- const alpha = layer.opacity === void 0 ? baseAlpha : baseAlpha * layerOpacity(layer);
5338
+ const alpha = layer.opacity === void 0 && layer.group === void 0 ? baseAlpha : baseAlpha * paintOpacity(spec, layer);
5212
5339
  textBoxes.push({ li, label, alpha, ...box });
5213
5340
  }
5214
5341
  }
@@ -5331,14 +5458,15 @@ function brightestAdditivePlate(spec, allEntities, ground, box, w, h) {
5331
5458
  if (blend !== "lighter" && blend !== "screen") continue;
5332
5459
  if (lj === box.li) continue;
5333
5460
  const entities = allEntities[lj];
5461
+ const moved = layer.transform !== void 0 || groupOf(spec, layer)?.transform !== void 0;
5334
5462
  for (const e of entities) {
5335
5463
  const hex = spriteHex(layer, e);
5336
5464
  if (hex === null) continue;
5337
5465
  const growScale = (e.motion === "warp" ? WARP_MAX_SCALE : 1) * (e.emit ? Math.max(e.emit.growFrom, e.emit.growTo) : 1);
5338
5466
  const maxDim = Math.max(e.size, e.size2 ?? 0) * (1 + e.growAmp) * growScale;
5339
5467
  if (maxDim < box.fs) continue;
5340
- if (!layer.transform && !entityReachesBox(e, maxDim / 2, box, w, h)) continue;
5341
- const a = layer.opacity === void 0 ? Math.min(1, e.alpha + e.pulseAmp) : Math.min(1, e.alpha + e.pulseAmp) * layerOpacity(layer);
5468
+ if (!moved && !entityReachesBox(e, maxDim / 2, box, w, h)) continue;
5469
+ const a = layer.opacity === void 0 && layer.group === void 0 ? Math.min(1, e.alpha + e.pulseAmp) : Math.min(1, e.alpha + e.pulseAmp) * paintOpacity(spec, layer);
5342
5470
  if (a <= 0) continue;
5343
5471
  const plate = additivePlate(ground, hexRgb(hex), a, blend);
5344
5472
  const lum = relativeLuminance(plate);
@@ -5386,6 +5514,26 @@ function textBlockBoxAt(s, p, w, h) {
5386
5514
  const { dx, dy } = textBlockAnchorOffset(s, maxWPx, maxLineW, totalH);
5387
5515
  return { x0: x0 + dx, y0: p.y + dy, x1: x0 + dx + maxLineW, y1: p.y + dy + totalH, fs: fsPx };
5388
5516
  }
5517
+ function hiddenLayers(spec) {
5518
+ if (!spec.groups && !spec.layers.some((l) => l.opacity !== void 0)) return null;
5519
+ const tracks = spec.timeline ? timelineTracks(spec) : null;
5520
+ const pinnedZero = (path, base, ancestors) => {
5521
+ if (base !== 0) return false;
5522
+ if (!tracks) return true;
5523
+ for (const [p, keys] of tracks) {
5524
+ if (p === path && keys.some((k) => k.value !== 0)) return false;
5525
+ if (ancestors.includes(p)) return false;
5526
+ }
5527
+ return true;
5528
+ };
5529
+ const out = spec.layers.map((l, i) => {
5530
+ const lp = `layers.${i}`;
5531
+ if (pinnedZero(`${lp}.opacity`, l.opacity, ["layers", lp])) return true;
5532
+ const g = l.group !== void 0 && spec.groups && Object.prototype.hasOwnProperty.call(spec.groups, l.group) ? spec.groups[l.group] : void 0;
5533
+ return !!g && pinnedZero(`groups.${l.group}.opacity`, g.opacity, ["groups", `groups.${l.group}`]);
5534
+ });
5535
+ return out.some(Boolean) ? out : null;
5536
+ }
5389
5537
  function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
5390
5538
  const warnings = [];
5391
5539
  for (let i = 0; i < seq.segments.length; i++) {
@@ -5405,17 +5553,17 @@ function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
5405
5553
  });
5406
5554
  }
5407
5555
  for (let i = 0; i < seq.segments.length - 1; i++) {
5556
+ const tr = seq.segments[i].transition;
5408
5557
  const lumaA = backgroundLuma(resolveTimelineAt(seq.segments[i].scene, seq.segments[i].duration ?? 0));
5409
5558
  const lumaB = backgroundLuma(resolveTimelineAt(seq.segments[i + 1].scene, 0));
5410
5559
  const delta = Math.abs(lumaA - lumaB);
5411
- if (delta > 0.5) {
5560
+ if (delta > 0.5 && tr?.type !== "fade") {
5412
5561
  warnings.push({
5413
5562
  path: `segments[${i}]`,
5414
5563
  code: "boundary-luminance-jump",
5415
- message: `background luminance jumps ${delta.toFixed(2)} at boundary ${i}\u2192${i + 1} \u2014 may flash on cut`
5564
+ message: `background luminance jumps ${delta.toFixed(2)} at boundary ${i}\u2192${i + 1} \u2014 may flash on cut${tr === void 0 || tr.type === "cut" ? `; declare transition: { type: 'fade', dur } on segments[${i}] to ramp it` : ""}`
5416
5565
  });
5417
5566
  }
5418
- const tr = seq.segments[i].transition;
5419
5567
  if (tr?.type === "morph") {
5420
5568
  const sigA = structuralSignature(seq.segments[i].scene);
5421
5569
  const sigB = structuralSignature(seq.segments[i + 1].scene);
@@ -5553,9 +5701,10 @@ function buildScene(spec, opts) {
5553
5701
  for (const { layer, entities } of layers) if (layer.key) byKey.set(layer.key, entities);
5554
5702
  let transforms = null;
5555
5703
  for (const { layer, entities } of layers) {
5556
- if (!layer.transform) continue;
5704
+ const map = paintMap(spec, layer, w, h, scale);
5705
+ if (!map) continue;
5557
5706
  transforms ??= /* @__PURE__ */ new Map();
5558
- for (const e of entities) transforms.set(e, layer.transform);
5707
+ for (const e of entities) transforms.set(e, map);
5559
5708
  }
5560
5709
  return { w, h, scale, seed, layers, byKey, transforms };
5561
5710
  }
@@ -5569,8 +5718,8 @@ function posOf(scene, e, t) {
5569
5718
  p.y += pp.y;
5570
5719
  }
5571
5720
  }
5572
- const tf = scene.transforms?.get(e);
5573
- return tf ? transformPoint(tf, p, scene.w, scene.h, scene.scale) : p;
5721
+ const map = scene.transforms?.get(e);
5722
+ return map ? map(p) : p;
5574
5723
  }
5575
5724
  function textBox(s, e, p) {
5576
5725
  const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
@@ -5680,6 +5829,37 @@ function distanceToOutline(pts, x, y) {
5680
5829
  }
5681
5830
  return Math.sqrt(best);
5682
5831
  }
5832
+ function rotatedBoxCoverage(cx, cy, hx, hy, rot, x0, y0, x1, y1) {
5833
+ const cr = Math.cos(rot), sr = Math.sin(rot);
5834
+ let poly = [[-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy]].map(([x, y]) => [cx + x * cr - y * sr, cy + x * sr + y * cr]);
5835
+ const clip = (inside, cut) => {
5836
+ const out = [];
5837
+ for (let i = 0; i < poly.length; i++) {
5838
+ const a = poly[i], b = poly[(i + 1) % poly.length];
5839
+ if (inside(a)) {
5840
+ out.push(a);
5841
+ if (!inside(b)) out.push(cut(a, b));
5842
+ } else if (inside(b)) out.push(cut(a, b));
5843
+ }
5844
+ poly = out;
5845
+ };
5846
+ const atX = (x) => (a, b) => [x, a[1] + (x - a[0]) / (b[0] - a[0]) * (b[1] - a[1])];
5847
+ const atY = (y) => (a, b) => [a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]), y];
5848
+ clip((p) => p[0] >= x0, atX(x0));
5849
+ if (!poly.length) return 0;
5850
+ clip((p) => p[0] <= x1, atX(x1));
5851
+ if (!poly.length) return 0;
5852
+ clip((p) => p[1] >= y0, atY(y0));
5853
+ if (!poly.length) return 0;
5854
+ clip((p) => p[1] <= y1, atY(y1));
5855
+ if (!poly.length) return 0;
5856
+ let area = 0;
5857
+ for (let i = 0; i < poly.length; i++) {
5858
+ const a = poly[i], b = poly[(i + 1) % poly.length];
5859
+ area += a[0] * b[1] - b[0] * a[1];
5860
+ }
5861
+ return Math.abs(area) / 2 / ((x1 - x0) * (y1 - y0));
5862
+ }
5683
5863
  function luminanceGrid(spec, opts = {}) {
5684
5864
  spec = resolveTimelineAt(spec, opts.t ?? 5e3);
5685
5865
  const scene = buildScene(spec, opts);
@@ -5839,7 +6019,7 @@ function luminanceGrid(spec, opts = {}) {
5839
6019
  };
5840
6020
  const splatPass = (tPass, alphaScale) => {
5841
6021
  for (const { layer, entities } of scene.layers) {
5842
- const lifeA = layer.opacity === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * layerOpacity(layer);
6022
+ const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, tPass) : lifeAlphaAt(layer.life, tPass) * paintOpacity(spec, layer);
5843
6023
  if (lifeA <= 0) continue;
5844
6024
  for (const e of entities) {
5845
6025
  splatTrail(layer, e, tPass, lifeA, alphaScale);
@@ -5847,7 +6027,7 @@ function luminanceGrid(spec, opts = {}) {
5847
6027
  if (a <= 4e-3) continue;
5848
6028
  const lum = spriteLuma(layer, e);
5849
6029
  const p = posOf(scene, e, tPass);
5850
- const sz = layer.transform ? sizeAt(e, tPass) * transformSize(layer.transform) : sizeAt(e, tPass);
6030
+ const sz = layer.transform || layer.group !== void 0 ? sizeAt(e, tPass) * paintSize(spec, layer) : sizeAt(e, tPass);
5851
6031
  const s = layer.sprite;
5852
6032
  if (s.kind === "polygon" && sz >= POLYGON_OUTLINE_CELLS * Math.min(cellW, cellH)) {
5853
6033
  splatPolygonOutline(layer, s, e, p, sz, a, lum, tPass);
@@ -5902,6 +6082,7 @@ function luminanceGrid(spec, opts = {}) {
5902
6082
  let centerY = p.y;
5903
6083
  let halfX = sz / 2;
5904
6084
  let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
6085
+ let boxRot = 0, boxHx = 0, boxHy = 0;
5905
6086
  if (s.kind === "text") {
5906
6087
  const box = textBox(s, e, p);
5907
6088
  centerX = box.cx;
@@ -5924,8 +6105,23 @@ function luminanceGrid(spec, opts = {}) {
5924
6105
  const sr = Math.sin(rot);
5925
6106
  centerX = p.x + box.cx * cr - box.cy * sr;
5926
6107
  centerY = p.y + box.cx * sr + box.cy * cr;
6108
+ boxRot = rot;
6109
+ boxHx = box.halfX;
6110
+ boxHy = box.halfY;
5927
6111
  halfX = Math.abs(box.halfX * cr) + Math.abs(box.halfY * sr);
5928
6112
  halfY = Math.abs(box.halfX * sr) + Math.abs(box.halfY * cr);
6113
+ } else if (s.kind === "rect") {
6114
+ const rot = rotationAt(e, tPass);
6115
+ if (rot) {
6116
+ boxRot = rot;
6117
+ boxHx = halfX;
6118
+ boxHy = halfY;
6119
+ const cr = Math.abs(Math.cos(rot));
6120
+ const sr = Math.abs(Math.sin(rot));
6121
+ const hx = halfX * cr + halfY * sr;
6122
+ halfY = halfX * sr + halfY * cr;
6123
+ halfX = hx;
6124
+ }
5929
6125
  }
5930
6126
  const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
5931
6127
  const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
@@ -5939,7 +6135,10 @@ function luminanceGrid(spec, opts = {}) {
5939
6135
  const r0 = Math.max(0, Math.floor((centerY - reachY) / cellH));
5940
6136
  const r1 = Math.min(rows - 1, Math.floor((centerY + reachY) / cellH));
5941
6137
  const inkWeight = (s.kind === "text" || s.kind === "emoji" || s.kind === "textBlock" ? 0.55 : 1) * (s.kind === "textBlock" ? textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1) : 1);
6138
+ const byArea = s.kind === "rect" || s.kind === "bar";
5942
6139
  for (let r = r0; r <= r1; r++) {
6140
+ const fy = byArea ? Math.max(0, Math.min((r + 1) * cellH, centerY + halfY) - Math.max(r * cellH, centerY - halfY)) / cellH : 1;
6141
+ if (fy <= 0) continue;
5943
6142
  for (let c = c0; c <= c1; c++) {
5944
6143
  const dx = (c + 0.5) * cellW - centerX;
5945
6144
  const dy = (r + 0.5) * cellH - centerY;
@@ -5963,6 +6162,14 @@ function luminanceGrid(spec, opts = {}) {
5963
6162
  wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
5964
6163
  }
5965
6164
  compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
6165
+ } else if (byArea) {
6166
+ if (boxRot) {
6167
+ const cov = rotatedBoxCoverage(centerX, centerY, boxHx, boxHy, boxRot, c * cellW, r * cellH, (c + 1) * cellW, (r + 1) * cellH);
6168
+ if (cov > 0) compose(r * cols + c, lum, a * shapeWeight * cov, layer.blend);
6169
+ } else {
6170
+ const fx = Math.max(0, Math.min((c + 1) * cellW, centerX + halfX) - Math.max(c * cellW, centerX - halfX)) / cellW;
6171
+ if (fx > 0) compose(r * cols + c, lum, a * shapeWeight * fx * fy, layer.blend);
6172
+ }
5966
6173
  } else {
5967
6174
  compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
5968
6175
  }
@@ -6161,8 +6368,9 @@ function rawDominance(spec, opts = {}) {
6161
6368
  return Math.sqrt(dx * dx + dy * dy);
6162
6369
  };
6163
6370
  const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
6164
- const lifeA = layer.opacity === void 0 ? lifeAlphaAt(layer.life, t) : lifeAlphaAt(layer.life, t) * layerOpacity(layer);
6165
- const areaScale = layer.transform ? transformSize(layer.transform) ** 2 : 1;
6371
+ const lifeA = layer.opacity === void 0 && layer.group === void 0 ? lifeAlphaAt(layer.life, t) : lifeAlphaAt(layer.life, t) * paintOpacity(spec, layer);
6372
+ const painted = layer.transform !== void 0 || layer.group !== void 0;
6373
+ const areaScale = painted ? paintSize(spec, layer) ** 2 : 1;
6166
6374
  let area = 0;
6167
6375
  let lumAcc = 0;
6168
6376
  for (const e of entities) {
@@ -6191,7 +6399,7 @@ function rawDominance(spec, opts = {}) {
6191
6399
  const box = textBlockBox(s, e, { x: 0, y: 0 }, w, h);
6192
6400
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55 * textBlockRevealFraction(s, w, h, t) * (s.opacity ?? 1);
6193
6401
  } else entArea = sz * sz * 0.55;
6194
- area += layer.transform ? entArea * areaScale * a : entArea * a;
6402
+ area += painted ? entArea * areaScale * a : entArea * a;
6195
6403
  if (layer.trail) {
6196
6404
  const fade = layer.trail.fade ?? 1;
6197
6405
  const span = sweptDistance(e, layer.trail.length);