@idle-screens/schema 3.16.0 → 3.16.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/FORMAT.md CHANGED
@@ -394,7 +394,8 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
394
394
  `"evenodd"` are SVG's own rules — use them for pasted SVG, for overlap
395
395
  patterns (a rosette of overlapping circles wants `evenodd`), and when a
396
396
  same-colour shape sits inside another on purpose (`auto` would cut it).
397
- - **`gradient`** replaces the flat colour of the fill:
397
+ - **`gradient`** replaces the flat colour of the fill (the sprite still
398
+ needs a `color` or `colors`; a gradient does not stand in for it):
398
399
  `{ "type": "linear", "from": [0, -1], "to": [0, 1], "stops": [{ "at": 0, "color": "#2c6a3a" }, { "at": 1, "color": "#7ab070", "opacity": 0.6 }] }`
399
400
  or `{ "type": "radial", "center": [0, 0], "radius": 1, "focus": [-0.3, -0.4], "stops": [...] }`
400
401
  (2..8 stops, `at` 0..1 non-decreasing, optional per-stop `opacity`).
@@ -424,7 +425,9 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
424
425
  (1..256), drawn back to front, each with its own `path`, `color`
425
426
  (default: the entity's colour, so `colors` palettes still pick per
426
427
  entity), `fillRule`, `fill`, `gradient`, `stroke` and `opacity`. With
427
- `parts`, put that paint on the parts, never on the sprite. One layer
428
+ `parts`, put that paint on the parts, never on the sprite. The sprite
429
+ still needs its own `color` (or a `colors` palette): parts without a
430
+ colour inherit it, and it is required even when every part sets one. One layer
428
431
  holds a whole illustration, and a counted layer reuses it: a balloon with
429
432
  gores, shading, ropes and basket × 7 drifting entities, all parts moving
430
433
  as one.
@@ -1143,7 +1146,13 @@ moves and scales its positions and sizes (sprite rotation is not modelled).
1143
1146
  `loop-seam` fires when a scene declares a looping timeline and the rest
1144
1147
  of the scene repeats too, but on a period the lap does not divide — so
1145
1148
  one lap can never come round to its own first frame (it names the period
1146
- to make the lap a multiple of). A scene whose other motion never repeats
1149
+ to make the lap a multiple of). `spin`, `rock`, `pulse` and `grow` are left
1150
+ out of that period: they run on scene time and change smoothly, so nothing
1151
+ jumps at the wrap, and a lap off their period still loops seamlessly on
1152
+ screen (only an exact one-lap export would differ). Keyed paths are
1153
+ continuous at the wrap by construction — each lap enters from the last
1154
+ key's value — unless a key cuts there on purpose (`t: 0, dur: 0`) or a
1155
+ glide overruns the lap (`timeline-glide-overruns-lap` warns). A scene whose other motion never repeats
1147
1156
  (drifting stars, wandering motes) is not judged: that is ambient by
1148
1157
  design. `scenePeriod(spec)` is the analysis behind it — the least common
1149
1158
  multiple of every periodic part (pulse, grow, rock, spin, cycle, orbit,
package/dist/index.d.ts CHANGED
@@ -2757,17 +2757,31 @@ interface ScenePeriod {
2757
2757
  period: number | null;
2758
2758
  /** One line per part that sets or breaks the period, e.g. `layers[2] pulse: 4000 ms`. */
2759
2759
  parts: string[];
2760
+ /** What drives each of `parts`, index for index: `pulse`, `grow`, `rock`, `spin`, `path`, `orbit`, `emit`, `cycle`, `caret`, `background`, `timeline`. */
2761
+ kinds: PeriodKind[];
2760
2762
  /** Why it is null (empty otherwise). */
2761
2763
  reasons: string[];
2762
2764
  }
2763
- /** The repeat period of `spec` — see the module comment. */
2764
- declare function scenePeriod(spec: SaverSpec): ScenePeriod;
2765
+ type PeriodKind = 'timeline' | 'background' | 'pulse' | 'grow' | 'rock' | 'emit' | 'spin' | 'cycle' | 'caret' | 'path' | 'orbit';
2766
+ /**
2767
+ * Periodic parts that run on scene time and change smoothly, so a looping
2768
+ * timeline's wrap never makes them jump: they carry on through it. A lap
2769
+ * that does not divide their period still loops seamlessly on screen — only
2770
+ * an exact export of one lap would differ (F145, X725, X823).
2771
+ */
2772
+ declare const SEAMLESS_PERIOD_KINDS: ReadonlySet<PeriodKind>;
2773
+ /**
2774
+ * The repeat period of `spec` — see the module comment. `skip` leaves out the
2775
+ * periodic parts of those kinds, as if they were static (the loop-seam
2776
+ * advisory passes `SEAMLESS_PERIOD_KINDS`).
2777
+ */
2778
+ declare function scenePeriod(spec: SaverSpec, skip?: ReadonlySet<PeriodKind>): ScenePeriod;
2765
2779
  /**
2766
2780
  * The period of everything in the scene except its timeline — what a
2767
2781
  * looping timeline's lap has to be a multiple of for one lap to loop
2768
2782
  * seamlessly.
2769
2783
  */
2770
- declare function periodWithoutTimeline(spec: SaverSpec): ScenePeriod;
2784
+ declare function periodWithoutTimeline(spec: SaverSpec, skip?: ReadonlySet<PeriodKind>): ScenePeriod;
2771
2785
  /**
2772
2786
  * How many-fold rotationally symmetric a sprite's drawing is about the point
2773
2787
  * `spin` turns it on: a spinning layer looks the same again after 360°/n,
@@ -3300,5 +3314,5 @@ declare function pathFrame(s: PolygonSprite, r: number, w: number, h: number): {
3300
3314
  uniform: boolean;
3301
3315
  };
3302
3316
 
3303
- export { AQUARIUM_SPEC, AURORA_SPEC, COHESION_T, COMETS_SPEC, CONSTELLATION_SPEC, DASHBOARD_SPEC, DEFAULT_KEY_DUR, DITHER_ALPHA, DITHER_TILE, EXAMPLE_BY_ID, EXAMPLE_SEQUENCES, EXAMPLE_SPECS, FACETS_SPEC, FIELD_BUCKET_MS, FIELD_DRIFT_DEFAULT_AMOUNT, FIELD_RASTER_SHORT_SIDE, FIELD_RASTER_SHORT_SIDE_LOW, FINISH_ANIMATE_HZ, GRAIN_ALPHA, GRAIN_TILE, HAIKU_SPEC, LANTERNS_SPEC, LIMITS, LOBBY_TALK_SPEC, MATRIX_RAIN_SPEC, MAX_PATH_POINTS, MAX_SCENE_PERIOD, MOTION_FIELDS, MURMUR_SPEC, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, PHASE_DUET_SPEC, PINGS_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, RAIN_SPEC, RELAY_BOARD_SPEC, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SHARD_FALL_SPEC, SIGNAL_BOARD_SPEC, SNOWFALL_SPEC, SPARSE_NIGHT_SPEC, STEER_EASES, THERMAL_FIELD_SPEC, THREE_MOVEMENTS_SEQUENCE, TYPED_CUE_SPEC, WARP_MAX_SCALE, WARP_NEAR, WARP_TUNNEL_SPEC, WEB_WORK_SPEC, adviseSequence, adviseSpec, alphaAt, applyDeltasShared, applyDeltasToSpec, assertValidSequence, assertValidSpec, bayerMatrix, bayerTilePixels, bedRenderSeed, breakTextBlock, buildEntities, buriedAreaOf, canMorph, canWrapMorph, canonicalSpecPath, cohesionOf, compileSaver, compileSequence, createFinishPass, createSceneCanvas, describeScene, diffScenes, dominanceRanking, drawSpan, easeGlide, easeSmooth, emitEnvelope, emitWindow, entityScales, fieldAt, fieldOctaves, fieldRgb, fieldRgbAt, fieldSampleTime, finishStrength, flashSamplingForecast, flattenPath, grainOffset, grainTilePixels, graphemeClusters, hasHotSwapSequence, headingAt, hexToRgb255, ignoredPropertyPaths, isCombiningKana, isFullWidth, isPathPolygon, latticeHash, layerCellFootprints, layerCohesion, lerpPathD, lerpSpec, lerpValue, lifeAlphaAt, lifeStaggerAt, linkEdges, linkMaxDistPx, linkPairs, luminanceGrid, manifestFor, morphChainRoot, morphNothingMorphable, motionStats, nextKeyAfter, normalizeSeed, pathFrame, pathParts, perceiveScene, perceiveSequenceFrame, periodWithoutTimeline, pickScreenOp, positionAt, presentWithFinish, readSpecPath, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, resolveTimelineAt, revealState, rgb255Luma, rotationAt, sceneColour, scenePeriod, seamCauseOf, seamsWorthWarning, segmentRenderSeed, segmentStart, sequenceSteerablePaths, sequenceSwapCompatible, sequenceWantsFinish, sizeAt, spinSymmetry, spriteIndexAt, spriteVariants, steerablePaths, strokeDrawnSamples, strokeLines, structuralSignature, textBlockAnchorOffset, textBlockText, textMetricsClassFor, textSprites, textStringsDiffer, textStringsOf, textWidthEm, timelineFlashReport, timelineLocalTime, timelineSampleTimes, timelineSampleTimesAfter, timelineTracks, trimLines, trimPolyline, validateSequence, validateSpec, validateSpecPaths, wipeMask, withoutTimeline };
3304
- export type { BackgroundDrift, BackgroundSpec, BandSpec, CapabilityTier, CohesionRead, CycleSpec, DominanceEntry, Entity, FieldBackground, FieldDrift, FinishPass, FinishSpec, FlashSamplingForecast, FlattenedPath, GlideEase, GradientStop, IdleSequence, LayerCohesion, LayerGroup, LayerMotionStats, LayerSnapshot, LayerSpec, LayerTransform, LinkEdge, LuminanceGrid, LuminanceGridOptions, MotionEase, MotionSpec, PathGradient, PathGradientStop, PathPart, PathStroke, PerceiveOptions, PerceiveSequenceOptions, Placed, ResolveOptions, ResolvedPart, ResolvedSegment, RevealState, SaverSpec, SceneColour, SceneDescription, SceneDiff, ScenePerception, ScenePeriod, SceneSnapshot, SchemaExample, ScreenOp, SequenceExample, SequenceFramePerception, SequenceMountContext, SequenceSaverInstance, SequenceSaverPlugin, SequenceSegment, SequenceTransition, SpecError, SpecMountContext, SpecWarning, SpriteSpec, SteerDelta, StrokeDraw, TextBlockAnchor, TextBlockLine, TextMetricsClass, TextNumber, TextRevealInput, TextRevealSpec, TextRole, TextSpriteInfo, Timeline, TimelineFlashReport, TimelineKey, ValidationResult, WanderOsc, WarningBox, WipeTransition };
3317
+ export { AQUARIUM_SPEC, AURORA_SPEC, COHESION_T, COMETS_SPEC, CONSTELLATION_SPEC, DASHBOARD_SPEC, DEFAULT_KEY_DUR, DITHER_ALPHA, DITHER_TILE, EXAMPLE_BY_ID, EXAMPLE_SEQUENCES, EXAMPLE_SPECS, FACETS_SPEC, FIELD_BUCKET_MS, FIELD_DRIFT_DEFAULT_AMOUNT, FIELD_RASTER_SHORT_SIDE, FIELD_RASTER_SHORT_SIDE_LOW, FINISH_ANIMATE_HZ, GRAIN_ALPHA, GRAIN_TILE, HAIKU_SPEC, LANTERNS_SPEC, LIMITS, LOBBY_TALK_SPEC, MATRIX_RAIN_SPEC, MAX_PATH_POINTS, MAX_SCENE_PERIOD, MOTION_FIELDS, MURMUR_SPEC, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, PHASE_DUET_SPEC, PINGS_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, RAIN_SPEC, RELAY_BOARD_SPEC, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SEAMLESS_PERIOD_KINDS, SHARD_FALL_SPEC, SIGNAL_BOARD_SPEC, SNOWFALL_SPEC, SPARSE_NIGHT_SPEC, STEER_EASES, THERMAL_FIELD_SPEC, THREE_MOVEMENTS_SEQUENCE, TYPED_CUE_SPEC, WARP_MAX_SCALE, WARP_NEAR, WARP_TUNNEL_SPEC, WEB_WORK_SPEC, adviseSequence, adviseSpec, alphaAt, applyDeltasShared, applyDeltasToSpec, assertValidSequence, assertValidSpec, bayerMatrix, bayerTilePixels, bedRenderSeed, breakTextBlock, buildEntities, buriedAreaOf, canMorph, canWrapMorph, canonicalSpecPath, cohesionOf, compileSaver, compileSequence, createFinishPass, createSceneCanvas, describeScene, diffScenes, dominanceRanking, drawSpan, easeGlide, easeSmooth, emitEnvelope, emitWindow, entityScales, fieldAt, fieldOctaves, fieldRgb, fieldRgbAt, fieldSampleTime, finishStrength, flashSamplingForecast, flattenPath, grainOffset, grainTilePixels, graphemeClusters, hasHotSwapSequence, headingAt, hexToRgb255, ignoredPropertyPaths, isCombiningKana, isFullWidth, isPathPolygon, latticeHash, layerCellFootprints, layerCohesion, lerpPathD, lerpSpec, lerpValue, lifeAlphaAt, lifeStaggerAt, linkEdges, linkMaxDistPx, linkPairs, luminanceGrid, manifestFor, morphChainRoot, morphNothingMorphable, motionStats, nextKeyAfter, normalizeSeed, pathFrame, pathParts, perceiveScene, perceiveSequenceFrame, periodWithoutTimeline, pickScreenOp, positionAt, presentWithFinish, readSpecPath, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, resolveTimelineAt, revealState, rgb255Luma, rotationAt, sceneColour, scenePeriod, seamCauseOf, seamsWorthWarning, segmentRenderSeed, segmentStart, sequenceSteerablePaths, sequenceSwapCompatible, sequenceWantsFinish, sizeAt, spinSymmetry, spriteIndexAt, spriteVariants, steerablePaths, strokeDrawnSamples, strokeLines, structuralSignature, textBlockAnchorOffset, textBlockText, textMetricsClassFor, textSprites, textStringsDiffer, textStringsOf, textWidthEm, timelineFlashReport, timelineLocalTime, timelineSampleTimes, timelineSampleTimesAfter, timelineTracks, trimLines, trimPolyline, validateSequence, validateSpec, validateSpecPaths, wipeMask, withoutTimeline };
3318
+ export type { BackgroundDrift, BackgroundSpec, BandSpec, CapabilityTier, CohesionRead, CycleSpec, DominanceEntry, Entity, FieldBackground, FieldDrift, FinishPass, FinishSpec, FlashSamplingForecast, FlattenedPath, GlideEase, GradientStop, IdleSequence, LayerCohesion, LayerGroup, LayerMotionStats, LayerSnapshot, LayerSpec, LayerTransform, LinkEdge, LuminanceGrid, LuminanceGridOptions, MotionEase, MotionSpec, PathGradient, PathGradientStop, PathPart, PathStroke, PerceiveOptions, PerceiveSequenceOptions, PeriodKind, Placed, ResolveOptions, ResolvedPart, ResolvedSegment, RevealState, SaverSpec, SceneColour, SceneDescription, SceneDiff, ScenePerception, ScenePeriod, SceneSnapshot, SchemaExample, ScreenOp, SequenceExample, SequenceFramePerception, SequenceMountContext, SequenceSaverInstance, SequenceSaverPlugin, SequenceSegment, SequenceTransition, SpecError, SpecMountContext, SpecWarning, SpriteSpec, SteerDelta, StrokeDraw, TextBlockAnchor, TextBlockLine, TextMetricsClass, TextNumber, TextRevealInput, TextRevealSpec, TextRole, TextSpriteInfo, Timeline, TimelineFlashReport, TimelineKey, ValidationResult, WanderOsc, WarningBox, WipeTransition };
package/dist/index.js CHANGED
@@ -1602,6 +1602,11 @@ function pathDrawCost(s) {
1602
1602
  return c;
1603
1603
  }
1604
1604
 
1605
+ // src/validate-hints.ts
1606
+ var SCALAR_ONLY = /not a \[min,max\] range/;
1607
+ var SCALAR_BOUND = /^must be (?:>|an? (?:positive |single |finite |whole )?(?:number|integer)\b|between -?\d|-?\d+(?:\.\d+)?\.\.-?\d+(?:\.\d+)?(?: \(|$))/;
1608
+ var scalarWanted = (m) => SCALAR_ONLY.test(m) || !m.includes("[") && SCALAR_BOUND.test(m);
1609
+
1605
1610
  // src/validate.ts
1606
1611
  var HEX2 = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
1607
1612
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -1734,6 +1739,15 @@ var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
1734
1739
  var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
1735
1740
  var KNOWN_BG_FIELD = /* @__PURE__ */ new Set(["type", "scale", "octaves", "warp", "quantize", "bands", "drift", "seed"]);
1736
1741
  var KNOWN_FIELD_DRIFT = /* @__PURE__ */ new Set(["period", "amount"]);
1742
+ var KNOWN_BG_STOP = /* @__PURE__ */ new Set(["at", "color"]);
1743
+ var KNOWN_BAND = /* @__PURE__ */ new Set(["color", "height"]);
1744
+ var KNOWN_BG_DRIFT = /* @__PURE__ */ new Set(["period", "amount"]);
1745
+ var KNOWN_PATH_STROKE = /* @__PURE__ */ new Set(["color", "width", "opacity", "join", "cap", "draw"]);
1746
+ var KNOWN_PATH_GRADIENT = {
1747
+ linear: /* @__PURE__ */ new Set(["type", "from", "to", "stops", "units"]),
1748
+ radial: /* @__PURE__ */ new Set(["type", "center", "radius", "focus", "stops", "units"])
1749
+ };
1750
+ var KNOWN_PATH_STOP = /* @__PURE__ */ new Set(["at", "color", "opacity"]);
1737
1751
  var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key", "emit", "clock", "life", "layout", "rotate", "rock"]);
1738
1752
  function unknownKeys(obj, known) {
1739
1753
  return Object.keys(obj).filter((k) => !known.has(k));
@@ -1751,6 +1765,50 @@ function positiveScalar(v, path, err, msg = "must be > 0") {
1751
1765
  if (Array.isArray(v)) err(path, "must be a single number > 0, not a [min,max] range");
1752
1766
  else if (!isNum(v) || v <= 0) err(path, msg);
1753
1767
  }
1768
+ function valueAtPath(root, path) {
1769
+ let v = root;
1770
+ for (const seg of path.match(/[^.[\]]+/g) ?? []) {
1771
+ if (Array.isArray(v) && /^\d+$/.test(seg)) v = v[Number(seg)];
1772
+ else if (isObj(v)) v = v[seg];
1773
+ else return void 0;
1774
+ }
1775
+ return v;
1776
+ }
1777
+ function echo(v) {
1778
+ if (isNum(v)) return String(v);
1779
+ if (Array.isArray(v) && v.length <= 4 && v.every(isNum)) return `[${v.join(", ")}]`;
1780
+ const s = JSON.stringify(v) ?? String(v);
1781
+ return s.length > 48 ? `${s.slice(0, 45)}\u2026` : s;
1782
+ }
1783
+ var RANGE_WANTED = /^must be a \[min,max\] range/;
1784
+ function shapeHint(spec, path, message) {
1785
+ if (message.includes(" \u2014 got ")) return "";
1786
+ const v = valueAtPath(spec, path);
1787
+ if (v === void 0) return "";
1788
+ if (RANGE_WANTED.test(message)) {
1789
+ if (isNum(v)) {
1790
+ const fits = message.includes("within 0..1") ? v >= 0 && v <= 1 : message.includes(">= 0") ? v >= 0 : !message.includes("positive") || v > 0;
1791
+ return fits ? ` \u2014 got ${v}; write [${v}, ${v}]` : ` \u2014 got ${v}; a range is two numbers, [min, max]`;
1792
+ }
1793
+ if (isObj(v) && ("min" in v || "max" in v)) {
1794
+ return ` \u2014 got {min, max}; write [${isNum(v.min) ? v.min : "min"}, ${isNum(v.max) ? v.max : "max"}]`;
1795
+ }
1796
+ if (Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] > v[1]) {
1797
+ return ` \u2014 got ${echo(v)}; the smaller number comes first: write [${v[1]}, ${v[0]}]`;
1798
+ }
1799
+ return ` \u2014 got ${echo(v)}`;
1800
+ }
1801
+ if (Array.isArray(v) && scalarWanted(message)) {
1802
+ const segs = path.match(/[^.[\]]+/g) ?? [];
1803
+ const key = segs[segs.length - 1] ?? path;
1804
+ const parentKey = segs[segs.length - 2];
1805
+ const parent = valueAtPath(spec, path.slice(0, path.lastIndexOf(".")));
1806
+ const kind = isObj(parent) && isStr(parent.kind) ? parent.kind : void 0;
1807
+ const noun = kind ? `${/^[aeiou]/i.test(kind) ? "an" : "a"} ${kind}'s \`${key}\`` : parentKey && !/^\d+$/.test(parentKey) && parentKey !== "sprite" ? `\`${parentKey}.${key}\`` : `\`${key}\``;
1808
+ return ` \u2014 got ${echo(v)}; ${noun} is one number`;
1809
+ }
1810
+ return "";
1811
+ }
1754
1812
  function normalizeColors(spec) {
1755
1813
  if (!isObj(spec) || !Array.isArray(spec.layers)) return;
1756
1814
  for (const layer of spec.layers) {
@@ -1819,7 +1877,7 @@ function specPathsValid(spec, paths) {
1819
1877
  function validateSpecCore(spec, checkApplied = true) {
1820
1878
  const errors = [];
1821
1879
  const warnings = [];
1822
- const err = (path, message) => void errors.push({ path, message });
1880
+ const err = (path, message) => void errors.push({ path, message: message + shapeHint(spec, path, message) });
1823
1881
  const warn = (path, code, message) => void warnings.push({ path, code, message });
1824
1882
  if (!isObj(spec)) return { valid: false, errors: [{ path: "", message: "spec must be an object" }], warnings: [] };
1825
1883
  if (spec.schemaVersion !== SCHEMA_VERSION) err("schemaVersion", `must be ${SCHEMA_VERSION}`);
@@ -2048,9 +2106,12 @@ function validateTimelineKeys(spec, errOuter, warn, checkApplied) {
2048
2106
  return;
2049
2107
  }
2050
2108
  }
2051
- function color(v, path, err) {
2052
- if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
2109
+ function color(v, path, err, missing = "is required (a hex colour like #0a3a52)") {
2110
+ if (v === void 0) err(path, missing);
2111
+ else if (!isStr(v) || !HEX2.test(v)) err(path, "must be a hex colour like #0a3a52");
2053
2112
  }
2113
+ var SPRITE_COLOR_MISSING = "`color` is required (or a `colors` palette)";
2114
+ var POLYGON_COLOR_MISSING = `${SPRITE_COLOR_MISSING}; parts without their own colour inherit it, and a gradient or stroke does not stand in for it`;
2054
2115
  function validateFinish(f, path, err, warn) {
2055
2116
  if (!isObj(f)) return err(path, "must be an object");
2056
2117
  for (const k of ["grain", "dither"]) {
@@ -2075,6 +2136,7 @@ function validateBackground(bg, err, warn) {
2075
2136
  bg.stops.forEach((s, i) => {
2076
2137
  if (!isObj(s) || !isNum(s.at) || s.at < 0 || s.at > 1) err(`background.stops[${i}].at`, "must be 0..1");
2077
2138
  color(isObj(s) ? s.color : void 0, `background.stops[${i}].color`, err);
2139
+ if (isObj(s)) for (const k of unknownKeys(s, KNOWN_BG_STOP)) warn(`background.stops[${i}].${k}`, "unknown-property", `unknown stop property '${k}' \u2014 will be ignored`);
2078
2140
  });
2079
2141
  }
2080
2142
  if (bg.drift !== void 0) {
@@ -2086,6 +2148,7 @@ function validateBackground(bg, err, warn) {
2086
2148
  if (bg.drift.amount !== void 0 && (!isNum(bg.drift.amount) || bg.drift.amount <= 0 || bg.drift.amount > LIMITS.maxDriftAmount)) {
2087
2149
  err("background.drift.amount", `must be > 0 and <= ${LIMITS.maxDriftAmount}`);
2088
2150
  }
2151
+ for (const k of unknownKeys(bg.drift, KNOWN_BG_DRIFT)) warn(`background.drift.${k}`, "unknown-property", `unknown drift property '${k}' \u2014 will be ignored`);
2089
2152
  }
2090
2153
  }
2091
2154
  if (bg.band !== void 0) {
@@ -2093,6 +2156,7 @@ function validateBackground(bg, err, warn) {
2093
2156
  else {
2094
2157
  color(bg.band.color, "background.band.color", err);
2095
2158
  positiveScalar(bg.band.height, "background.band.height", err);
2159
+ for (const k of unknownKeys(bg.band, KNOWN_BAND)) warn(`background.band.${k}`, "unknown-property", `unknown band property '${k}' \u2014 will be ignored`);
2096
2160
  }
2097
2161
  }
2098
2162
  for (const k of unknownKeys(bg, KNOWN_BG_GRADIENT)) {
@@ -2500,19 +2564,19 @@ function validateSprite(sprite, path, err, warn, spec) {
2500
2564
  } else if (sprite.kind === "circle") {
2501
2565
  knownSet = KNOWN_CIRCLE;
2502
2566
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, rangeMsg(spec));
2503
- color(sprite.color, `${path}.color`, err);
2567
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2504
2568
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
2505
2569
  validatePalette(sprite, path, err);
2506
2570
  } else if (sprite.kind === "ring") {
2507
2571
  knownSet = KNOWN_RING;
2508
2572
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, rangeMsg(spec));
2509
- color(sprite.color, `${path}.color`, err);
2573
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2510
2574
  if (sprite.width !== void 0) positiveScalar(sprite.width, `${path}.width`, err);
2511
2575
  validatePalette(sprite, path, err);
2512
2576
  } else if (sprite.kind === "streak") {
2513
2577
  knownSet = KNOWN_STREAK;
2514
2578
  if (!isRange(sprite.length) || sprite.length[0] <= 0) err(`${path}.length`, rangeMsg(spec));
2515
- color(sprite.color, `${path}.color`, err);
2579
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2516
2580
  if (sprite.width !== void 0) positiveScalar(sprite.width, `${path}.width`, err);
2517
2581
  validatePalette(sprite, path, err);
2518
2582
  } else if (sprite.kind === "rect") {
@@ -2524,7 +2588,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2524
2588
  if (sprite.feather !== void 0 && (!isNum(sprite.feather) || sprite.feather < 0 || sprite.feather > 1)) {
2525
2589
  err(`${path}.feather`, "must be 0..1 (fraction of the half-size that fades out)");
2526
2590
  }
2527
- color(sprite.color, `${path}.color`, err);
2591
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2528
2592
  validatePalette(sprite, path, err);
2529
2593
  } else if (sprite.kind === "bar") {
2530
2594
  knownSet = KNOWN_BAR;
@@ -2537,7 +2601,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2537
2601
  if (sprite.direction !== void 0 && !["right", "left", "up", "down"].includes(sprite.direction)) {
2538
2602
  err(`${path}.direction`, "must be 'right' | 'left' | 'up' | 'down'");
2539
2603
  }
2540
- color(sprite.color, `${path}.color`, err);
2604
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2541
2605
  validatePalette(sprite, path, err);
2542
2606
  } else if (sprite.kind === "polygon") {
2543
2607
  knownSet = KNOWN_POLYGON;
@@ -2581,7 +2645,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2581
2645
  if (sprite.origin !== void 0 && !(Array.isArray(sprite.origin) && sprite.origin.length === 2 && sprite.origin.every((v) => isNum(v) && Math.abs(v) <= 1))) err(`${path}.origin`, "must be [x, y] in the unit box (-1..1): the point placed at the position");
2582
2646
  }
2583
2647
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
2584
- color(sprite.color, `${path}.color`, err);
2648
+ color(sprite.color, `${path}.color`, err, POLYGON_COLOR_MISSING);
2585
2649
  validatePalette(sprite, path, err);
2586
2650
  } else if (sprite.kind === "stroke") {
2587
2651
  knownSet = KNOWN_STROKE;
@@ -2592,7 +2656,7 @@ function validateSprite(sprite, path, err, warn, spec) {
2592
2656
  if (sprite.taper !== void 0 && typeof sprite.taper !== "boolean") err(`${path}.taper`, "must be a boolean");
2593
2657
  if (sprite.orient !== void 0 && typeof sprite.orient !== "boolean") err(`${path}.orient`, "must be a boolean");
2594
2658
  if (sprite.draw !== void 0) validateDraw(sprite.draw, `${path}.draw`, err, warn);
2595
- color(sprite.color, `${path}.color`, err);
2659
+ color(sprite.color, `${path}.color`, err, SPRITE_COLOR_MISSING);
2596
2660
  validatePalette(sprite, path, err);
2597
2661
  } else if (sprite.kind === "textBlock") {
2598
2662
  knownSet = KNOWN_TEXT_BLOCK;
@@ -2903,10 +2967,10 @@ function validatePathPiece(piece, path, err, warn) {
2903
2967
  function validatePiecePaint(piece, path, err, warn) {
2904
2968
  if (piece.fill !== void 0 && typeof piece.fill !== "boolean") err(`${path}.fill`, "must be a boolean");
2905
2969
  if (piece.fill === false && piece.stroke === void 0) err(`${path}.fill`, "fill: false draws nothing without a stroke");
2906
- if (piece.gradient !== void 0) validatePathGradient(piece.gradient, `${path}.gradient`, err);
2970
+ if (piece.gradient !== void 0) validatePathGradient(piece.gradient, `${path}.gradient`, err, warn);
2907
2971
  if (piece.stroke !== void 0) validatePathStroke(piece.stroke, `${path}.stroke`, err, warn);
2908
2972
  }
2909
- function validatePathGradient(g, path, err) {
2973
+ function validatePathGradient(g, path, err, warn) {
2910
2974
  if (!isObj(g)) return err(path, "must be { type: 'linear', from, to, stops } or { type: 'radial', center, radius, stops }");
2911
2975
  if (g.type === "linear") {
2912
2976
  if (!isPair(g.from)) err(`${path}.from`, "must be [x, y] in unit-box coordinates (-2..2)");
@@ -2918,6 +2982,7 @@ function validatePathGradient(g, path, err) {
2918
2982
  } else {
2919
2983
  return err(`${path}.type`, "must be 'linear' | 'radial'");
2920
2984
  }
2985
+ for (const k of unknownKeys(g, KNOWN_PATH_GRADIENT[g.type])) warn(`${path}.${k}`, "unknown-property", `unknown ${g.type} gradient property '${k}' \u2014 will be ignored`);
2921
2986
  if (g.units !== void 0 && g.units !== "box" && g.units !== "part") err(`${path}.units`, "must be 'box' (the drawing's unit box, default) | 'part' (the part's own box, -1..1)");
2922
2987
  if (!Array.isArray(g.stops) || g.stops.length < 2 || g.stops.length > 8) return err(`${path}.stops`, "must be 2..8 stops");
2923
2988
  let last = -Infinity;
@@ -2927,16 +2992,18 @@ function validatePathGradient(g, path, err) {
2927
2992
  else last = st.at;
2928
2993
  color(st.color, `${path}.stops[${i}].color`, err);
2929
2994
  if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.stops[${i}].opacity`, "must be 0..1");
2995
+ for (const k of unknownKeys(st, KNOWN_PATH_STOP)) warn(`${path}.stops[${i}].${k}`, "unknown-property", `unknown stop property '${k}' \u2014 will be ignored`);
2930
2996
  });
2931
2997
  }
2932
2998
  function validatePathStroke(st, path, err, warn) {
2933
2999
  if (!isObj(st)) return err(path, "must be { color, width, opacity?, join?, cap?, draw? }");
2934
- color(st.color, `${path}.color`, err);
3000
+ color(st.color, `${path}.color`, err, "`color` is required on a stroke (a hex colour like #0a3a52); a stroke does not inherit the entity colour");
2935
3001
  if (!isNum(st.width) || st.width <= 0 || st.width > 0.5) err(`${path}.width`, "must be > 0 and <= 0.5 (unit-box units: 0.01 is 1% of the drawing's half-size)");
2936
3002
  if (st.opacity !== void 0 && (!isNum(st.opacity) || st.opacity < 0 || st.opacity > 1)) err(`${path}.opacity`, "must be 0..1");
2937
3003
  if (st.join !== void 0 && !["round", "miter", "bevel"].includes(st.join)) err(`${path}.join`, "must be 'round' | 'miter' | 'bevel'");
2938
3004
  if (st.cap !== void 0 && !["round", "butt", "square"].includes(st.cap)) err(`${path}.cap`, "must be 'round' | 'butt' | 'square'");
2939
3005
  if (st.draw !== void 0) validateDraw(st.draw, `${path}.draw`, err, warn);
3006
+ for (const k of unknownKeys(st, KNOWN_PATH_STROKE)) warn(`${path}.${k}`, "unknown-property", `unknown stroke property '${k}' \u2014 will be ignored`);
2940
3007
  }
2941
3008
  function validateWipe(tr, path, err, warn) {
2942
3009
  if (tr.shape !== void 0 && tr.shape !== "circle" && tr.shape !== "line") err(`${path}.shape`, "must be 'circle' | 'line'");
@@ -3566,6 +3633,7 @@ function lifeStaggerAt(life, t, i) {
3566
3633
  }
3567
3634
  function wrap(v, min, max) {
3568
3635
  const range2 = max - min;
3636
+ if (range2 <= 0) return min;
3569
3637
  return ((v - min) % range2 + range2) % range2 + min;
3570
3638
  }
3571
3639
  function reflect(v, min, max) {
@@ -5430,14 +5498,14 @@ var SpecInstance = class {
5430
5498
  * entity as it was at `t` (rotation, heading, values), but at the echo's
5431
5499
  * position, size and alpha.
5432
5500
  */
5433
- drawEntity(built, e, t, lifeA, parentE, paint, echo) {
5501
+ drawEntity(built, e, t, lifeA, parentE, paint, echo2) {
5434
5502
  const { ctx } = this;
5435
- const p = echo ? { x: echo.x, y: echo.y, flip: false } : this.entityPos(e, t, parentE);
5503
+ const p = echo2 ? { x: echo2.x, y: echo2.y, flip: false } : this.entityPos(e, t, parentE);
5436
5504
  const sprite = paint?.sprite ?? built.layer.sprite;
5437
- const sz = echo ? echo.size : sizeAt(e, t);
5505
+ const sz = echo2 ? echo2.size : sizeAt(e, t);
5438
5506
  const rot = rotationAt(e, t);
5439
5507
  const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
5440
- ctx.globalAlpha = echo ? echo.alpha : alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
5508
+ ctx.globalAlpha = echo2 ? echo2.alpha : alphaAt(e, t) * lifeA * (paint?.alpha ?? 1);
5441
5509
  if (sprite.kind === "ring") {
5442
5510
  const r = sz / 2;
5443
5511
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -8841,6 +8909,7 @@ function flashSamplingForecast(spec) {
8841
8909
 
8842
8910
  // src/period.ts
8843
8911
  var MAX_SCENE_PERIOD = 36e5;
8912
+ var SEAMLESS_PERIOD_KINDS = /* @__PURE__ */ new Set(["spin", "rock", "pulse", "grow"]);
8844
8913
  var PERIOD_LEAF = /(^|\.)(period|rate|every|duration|spin|speed|blink|drift)$/;
8845
8914
  var gcd = (a, b) => {
8846
8915
  while (b) [a, b] = [b, a % b];
@@ -8858,27 +8927,27 @@ function toRatio(ms) {
8858
8927
  return null;
8859
8928
  }
8860
8929
  var range = (r) => r === void 0 ? null : typeof r === "number" ? r : r[0] === r[1] ? r[0] : NaN;
8861
- function scenePeriod(spec) {
8930
+ function scenePeriod(spec, skip) {
8862
8931
  const parts = [];
8863
8932
  const reasons = [];
8864
- const add = (label, ms) => {
8865
- if (!(ms > 0) || !Number.isFinite(ms)) return;
8933
+ const add = (label, ms, kind) => {
8934
+ if (!(ms > 0) || !Number.isFinite(ms) || skip?.has(kind)) return;
8866
8935
  const ratio = toRatio(ms);
8867
8936
  if (!ratio) reasons.push(`${label} repeats every ${Number(ms.toFixed(3))} ms, which has no exact ratio to combine`);
8868
- else parts.push([label, ratio[0], ratio[1]]);
8937
+ else parts.push([label, ratio[0], ratio[1], kind]);
8869
8938
  };
8870
8939
  const tl = spec.timeline;
8871
8940
  if (tl && tl.keys.length > 0) {
8872
8941
  const keyed = tl.keys.filter((k) => PERIOD_LEAF.test(String(k.path)));
8873
8942
  if (keyed.length > 0) reasons.push(`timeline keys animate ${[...new Set(keyed.map((k) => String(k.path)))].join(", ")}, which sets the scene's own period`);
8874
- if (tl.loop && (tl.duration ?? 0) > 0) add("timeline", tl.duration);
8943
+ if (tl.loop && (tl.duration ?? 0) > 0) add("timeline", tl.duration, "timeline");
8875
8944
  else reasons.push("timeline does not loop (it plays once, then holds)");
8876
8945
  }
8877
8946
  const bg = spec.background;
8878
8947
  if (bg && bg.type !== "solid" && bg.drift && !(bg.type === "field" && bg.drift.amount === 0)) {
8879
8948
  const { period } = bg.drift;
8880
8949
  const r = bg.type === "field" ? toRatio(period) : null;
8881
- add("background drift", r ? r[0] / gcd(r[0], FIELD_BUCKET_MS) * FIELD_BUCKET_MS : period);
8950
+ add("background drift", r ? r[0] / gcd(r[0], FIELD_BUCKET_MS) * FIELD_BUCKET_MS : period, "background");
8882
8951
  }
8883
8952
  if (spec.finish?.animate && ((spec.finish.grain ?? 0) > 0 || (spec.finish.dither ?? 0) > 0)) reasons.push("finish.animate steps the grain without repeating");
8884
8953
  spec.layers.forEach((layer, i) => {
@@ -8887,53 +8956,58 @@ function scenePeriod(spec) {
8887
8956
  const life = layer.life;
8888
8957
  const staggered = (life?.stagger ?? 0) > 0;
8889
8958
  if (life && (life.exit !== void 0 || life.enter !== void 0 && ((life.enter ?? 0) > 0 || (life.fade ?? 1e3) > 0 || staggered))) reasons.push(`${at} life builds in or out once`);
8890
- if (layer.pulse && layer.pulse.amp !== 0) add(`${at} pulse`, layer.pulse.period / rate);
8891
- if (layer.grow) add(`${at} grow`, layer.grow.period / rate);
8892
- if (layer.rock) add(`${at} rock`, layer.rock.period / rate);
8893
- if (layer.emit) add(`${at} emit`, layer.emit.every);
8959
+ if (layer.pulse && layer.pulse.amp !== 0) add(`${at} pulse`, layer.pulse.period / rate, "pulse");
8960
+ if (layer.grow) add(`${at} grow`, layer.grow.period / rate, "grow");
8961
+ if (layer.rock) add(`${at} rock`, layer.rock.period / rate, "rock");
8962
+ if (layer.emit) add(`${at} emit`, layer.emit.every, "emit");
8894
8963
  const spin = range(layer.spin);
8895
8964
  const fold = spin !== null && spin !== 0 ? spinSymmetry(layer.sprite) : 1;
8896
8965
  if (spin !== null && fold !== Infinity) {
8897
8966
  if (Number.isNaN(spin)) reasons.push(`${at} spin range gives each entity its own speed`);
8898
- else if (spin !== 0) add(`${at} spin${fold > 1 ? ` (${fold}-fold)` : ""}`, 36e4 / Math.abs(spin) / fold);
8967
+ else if (spin !== 0) add(`${at} spin${fold > 1 ? ` (${fold}-fold)` : ""}`, 36e4 / Math.abs(spin) / fold, "spin");
8899
8968
  }
8900
8969
  const sp = layer.sprite;
8901
- if ((sp.kind === "emoji" || sp.kind === "text") && sp.cycle && spriteVariants(sp) > 1) add(`${at} cycle`, sp.cycle.period * spriteVariants(sp) / rate);
8970
+ if ((sp.kind === "emoji" || sp.kind === "text") && sp.cycle && spriteVariants(sp) > 1) add(`${at} cycle`, sp.cycle.period * spriteVariants(sp) / rate, "cycle");
8902
8971
  if (sp.kind === "textBlock" && sp.reveal) {
8903
8972
  if ((sp.reveal.speed ?? 0) > 0) reasons.push(`${at} reveal types itself once`);
8904
8973
  if (sp.reveal.caret) {
8905
8974
  const hz = Math.min(3, sp.reveal.caret === true ? 1.2 : sp.reveal.caret.blink ?? 1.2);
8906
- if (hz > 0) add(`${at} caret`, 1e3 / hz);
8975
+ if (hz > 0) add(`${at} caret`, 1e3 / hz, "caret");
8907
8976
  }
8908
8977
  }
8909
8978
  const m = layer.motion;
8910
- if (m.type === "path") add(`${at} path`, m.duration);
8979
+ if (m.type === "path") add(`${at} path`, m.duration, "path");
8911
8980
  else if (m.type === "orbit") {
8912
8981
  const s = range(m.speed);
8913
8982
  if (s === null || Number.isNaN(s)) reasons.push(`${at} orbit speed range gives each entity its own lap`);
8914
- else if (s !== 0) add(`${at} orbit`, 36e4 / Math.abs(s));
8983
+ else if (s !== 0) add(`${at} orbit`, 36e4 / Math.abs(s), "orbit");
8915
8984
  } else if (m.type !== "static") {
8916
8985
  if (m.type === "drift" && m.ease?.type === "settle" && !layer.emit) reasons.push(`${at} drift settles once`);
8917
8986
  else reasons.push(`${at} ${m.type} motion travels at seeded per-entity speeds`);
8918
8987
  }
8919
8988
  });
8920
8989
  const labelled = parts.map(([l, n, d]) => `${l}: ${Number((n / d).toFixed(3))} ms`);
8921
- if (reasons.length > 0) return { period: null, parts: labelled, reasons };
8922
- if (parts.length === 0) return { period: 0, parts: labelled, reasons };
8990
+ const kinds = parts.map(([, , , k]) => k);
8991
+ if (reasons.length > 0) return { period: null, parts: labelled, kinds, reasons };
8992
+ const combined = combinePeriods(parts);
8993
+ return { ...combined, parts: labelled, kinds };
8994
+ }
8995
+ function combinePeriods(parts) {
8996
+ if (parts.length === 0) return { period: 0, reasons: [] };
8923
8997
  let num = 1;
8924
8998
  let den = 0;
8925
8999
  for (const [, n, d] of parts) {
8926
9000
  num = num / gcd(num, n) * n;
8927
9001
  den = gcd(den, d);
8928
9002
  if (num / den > MAX_SCENE_PERIOD) {
8929
- return { period: null, parts: labelled, reasons: [`its parts share no cycle shorter than ${MAX_SCENE_PERIOD / 6e4} min`] };
9003
+ return { period: null, reasons: [`its parts share no cycle shorter than ${MAX_SCENE_PERIOD / 6e4} min`] };
8930
9004
  }
8931
9005
  }
8932
- if (num % den !== 0) return { period: null, parts: labelled, reasons: [`its parts repeat together every ${Number((num / den).toFixed(3))} ms, not a whole number of milliseconds`] };
8933
- return { period: num / den, parts: labelled, reasons };
9006
+ if (num % den !== 0) return { period: null, reasons: [`its parts repeat together every ${Number((num / den).toFixed(3))} ms, not a whole number of milliseconds`] };
9007
+ return { period: num / den, reasons: [] };
8934
9008
  }
8935
- function periodWithoutTimeline(spec) {
8936
- return scenePeriod(withoutTimeline(spec));
9009
+ function periodWithoutTimeline(spec, skip) {
9010
+ return scenePeriod(withoutTimeline(spec), skip);
8937
9011
  }
8938
9012
  var MAX_SYMMETRY = 64;
8939
9013
  var MAX_SYMMETRY_POINTS = 8192;
@@ -9270,14 +9344,15 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
9270
9344
  if (authored.timeline?.loop && (authored.timeline.duration ?? 0) > 0) {
9271
9345
  const lap = Math.round(authored.timeline.duration);
9272
9346
  const rest = periodWithoutTimeline(authored);
9273
- if (rest.period !== null && rest.period > 0 && lap % rest.period !== 0) {
9347
+ const seam = rest.period !== null && rest.period > 0 && lap % rest.period !== 0 ? periodWithoutTimeline(authored, SEAMLESS_PERIOD_KINDS) : null;
9348
+ if (seam && seam.period !== null && seam.period > 0 && lap % seam.period !== 0) {
9274
9349
  let a = lap;
9275
- let b = rest.period;
9350
+ let b = seam.period;
9276
9351
  while (b) [a, b] = [b, a % b];
9277
9352
  warnings.push({
9278
9353
  path: "timeline.duration",
9279
9354
  code: "loop-seam",
9280
- message: `the timeline loops every ${lap} ms but the rest of the scene repeats every ${rest.period} ms (${rest.parts.join(", ")}), so the whole scene only repeats every ${lap / a * rest.period} ms \u2014 make the lap a multiple of ${rest.period} ms for one lap to loop`
9355
+ message: `the timeline loops every ${lap} ms but ${seam.parts.join(", ")} repeat${seam.parts.length === 1 ? "s" : ""} every ${seam.period} ms, so the scene only comes round to its first frame every ${lap / a * seam.period} ms \u2014 make the lap a multiple of ${seam.period} ms for one lap to loop`
9281
9356
  });
9282
9357
  }
9283
9358
  }
@@ -12385,6 +12460,7 @@ export {
12385
12460
  SAKURA_SPEC,
12386
12461
  SCHEMA_EXAMPLES,
12387
12462
  SCHEMA_VERSION,
12463
+ SEAMLESS_PERIOD_KINDS,
12388
12464
  SHARD_FALL_SPEC,
12389
12465
  SIGNAL_BOARD_SPEC,
12390
12466
  SNOWFALL_SPEC,