@cardanvil/frame-kit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +75 -1
  2. package/dist/authoring/collectorInfo.d.ts +17 -0
  3. package/dist/authoring/collectorInfo.js +62 -0
  4. package/dist/authoring/index.d.ts +2 -0
  5. package/dist/authoring/index.js +2 -0
  6. package/dist/authoring/layoutDefaults.d.ts +20 -2
  7. package/dist/authoring/layoutDefaults.js +48 -13
  8. package/dist/authoring/opacityStops.d.ts +8 -0
  9. package/dist/authoring/opacityStops.js +17 -0
  10. package/dist/cli/index.d.ts +1 -0
  11. package/dist/cli/index.js +1 -0
  12. package/dist/cli/loadFrame.js +1 -1
  13. package/dist/cli/main.js +72 -0
  14. package/dist/cli/studio/boxSets.js +11 -8
  15. package/dist/cli/studio/serve.js +4 -1
  16. package/dist/cli/trim.d.ts +62 -0
  17. package/dist/cli/trim.js +251 -0
  18. package/dist/layout/border.d.ts +27 -0
  19. package/dist/layout/border.js +31 -0
  20. package/dist/layout/boxOverrides.d.ts +28 -0
  21. package/dist/layout/boxOverrides.js +95 -0
  22. package/dist/layout/color.d.ts +23 -0
  23. package/dist/layout/color.js +53 -0
  24. package/dist/layout/cutouts.d.ts +28 -0
  25. package/dist/layout/cutouts.js +29 -0
  26. package/dist/layout/decorations.d.ts +1 -6
  27. package/dist/layout/decorations.js +7 -11
  28. package/dist/layout/frameLayers.d.ts +29 -2
  29. package/dist/layout/frameLayers.js +145 -3
  30. package/dist/layout/index.d.ts +17 -6
  31. package/dist/layout/index.js +17 -6
  32. package/dist/layout/paint.d.ts +36 -0
  33. package/dist/layout/paint.js +148 -0
  34. package/dist/layout/powerToughnessRow.d.ts +26 -0
  35. package/dist/layout/powerToughnessRow.js +13 -0
  36. package/dist/layout/resolveFrameAsset.d.ts +6 -0
  37. package/dist/layout/resolveFrameAsset.js +8 -0
  38. package/dist/layout/rulesSlide.d.ts +13 -0
  39. package/dist/layout/rulesSlide.js +33 -0
  40. package/dist/layout/settings.d.ts +26 -0
  41. package/dist/layout/settings.js +62 -0
  42. package/dist/layout/textStyle.d.ts +29 -0
  43. package/dist/layout/textStyle.js +28 -0
  44. package/dist/layout/turn.d.ts +54 -0
  45. package/dist/layout/turn.js +107 -0
  46. package/dist/manifest/contract.d.ts +6074 -9392
  47. package/dist/manifest/contract.js +46 -1
  48. package/dist/manifest/testFrame.js +9 -0
  49. package/dist/schema/collectorInfo.d.ts +95 -0
  50. package/dist/schema/collectorInfo.js +80 -0
  51. package/dist/schema/condition.d.ts +67 -0
  52. package/dist/schema/condition.js +47 -0
  53. package/dist/schema/frame.d.ts +17291 -25435
  54. package/dist/schema/frame.js +402 -14
  55. package/dist/schema/frameAssets.d.ts +53 -33
  56. package/dist/schema/frameAssets.js +77 -5
  57. package/dist/schema/index.d.ts +3 -0
  58. package/dist/schema/index.js +3 -0
  59. package/dist/schema/layout.d.ts +29 -4
  60. package/dist/schema/layout.js +27 -2
  61. package/dist/schema/paint.d.ts +202 -0
  62. package/dist/schema/paint.js +109 -0
  63. package/dist/studio/assets/index-BnhNwm_r.js +74 -0
  64. package/dist/studio/index.html +1 -1
  65. package/package.json +5 -3
  66. package/dist/studio/assets/index-CNzyV22G.js +0 -76
package/README.md CHANGED
@@ -43,6 +43,30 @@ export const myFrame = {
43
43
  `as const satisfies Frame` is the idiom: `satisfies` checks the object against the contract, `as const`
44
44
  keeps the literal types.
45
45
 
46
+ ### The collector line
47
+
48
+ What the collector box prints is data too: its `lines`, each a row of elements (`rarity`, `cardNumber`,
49
+ `setCode`, `artist`, …) with their own spacing, sizing, fonts and alignment. `withCollectorInfoDefaults`
50
+ fills in `defaultCollectorInfoLines` unless the bounds bring their own. Pass `retroCollectorInfoLines`
51
+ for the Retro frame's centered artist and creator lines, or write your own:
52
+
53
+ ```ts
54
+ const collectorBounds = {
55
+ x: 460,
56
+ y: 3880,
57
+ width: 2330,
58
+ height: 200,
59
+ fontSize: 58,
60
+ lines: [
61
+ { elements: [{ type: "artist", prefix: "Illus. " }], align: "center" },
62
+ {
63
+ elements: [{ type: "setCode", spacingAfter: 24 }, { type: "cardNumber" }],
64
+ align: "center",
65
+ },
66
+ ],
67
+ } as const satisfies CollectorInfoBox;
68
+ ```
69
+
46
70
  ## Entry points
47
71
 
48
72
  | Import | What it gives you |
@@ -58,6 +82,7 @@ frame-kit validate # check every frame, write nothing
58
82
  frame-kit build --out dist # check, then pack each one
59
83
  frame-kit build --out dist --watch # …and repack whenever a source changes
60
84
  frame-kit schema meta # a format as JSON Schema
85
+ frame-kit trim --shared a.png b.png # cut art down to what is visible
61
86
  frame-kit --help
62
87
  ```
63
88
 
@@ -129,6 +154,31 @@ Packaging needs Vite — it is what turns `import w from "./w.png"` into a file
129
154
  so it is an **optional** peer dependency, loaded only when packaging runs. The
130
155
  schemas above work under plain Node and in a browser without it.
131
156
 
157
+ ### `frame-kit trim`
158
+
159
+ ```bash
160
+ frame-kit trim [--shared] [--out <dir>] [--dry-run] [--json] <png...>
161
+ ```
162
+
163
+ Cuts PNGs down to their visible pixels and prints where each now sits on the
164
+ sheet. Art is authored on the full sheet so it lines up by itself, but a
165
+ renderer decodes, caches and composites every pixel of an image, transparent or
166
+ not — a Saga banner is under 5% of its sheet, and trimmed it costs a twentieth
167
+ as much on every card.
168
+
169
+ `--shared` cuts every image given to one rectangle, the smallest holding all of
170
+ their visible pixels, so images drawn together — a banner and the masks cut from
171
+ it — keep one position. Put that position in the frame wherever a field asks for
172
+ one, such as `saga.bannerOrigin`; only fields that take a position can use
173
+ trimmed art.
174
+
175
+ The pixels kept are byte-for-byte the originals, and the chunks that tell a
176
+ decoder how to show them — colour profile, gamma — are carried over, so the art
177
+ draws exactly as before. Each trimmed image also records its position in an
178
+ `oFFs` chunk, so trimming it again reports where it sits on the sheet rather
179
+ than in itself. Without `--out` each image replaces its original. Only 8-bit
180
+ PNGs are trimmed: decoding 16 bits a channel would lose detail.
181
+
132
182
  ### `frame-kit studio`
133
183
 
134
184
  ```bash
@@ -155,7 +205,7 @@ that are not written as literals are reported as such rather than edited.
155
205
 
156
206
  ### `@cardanvil/frame-kit/layout`
157
207
 
158
- How a frame's art is chosen and placed, as pure functions:
208
+ How a frame is drawn for one card, as pure functions:
159
209
 
160
210
  - `selectFrameLayers(details, options)` — the base frame and section overlays a
161
211
  set of `FrameDetails` selects. Split out of Card Anvil's `getFrameLayers`, so
@@ -170,6 +220,30 @@ How a frame's art is chosen and placed, as pure functions:
170
220
  middle of the card.
171
221
  - `resolveFrameAsset` / `resolveBaseFrame` — exact per-family lookup, and the
172
222
  base-frame fallback chain.
223
+ - `frameCutoutMasks(input)` — the masks cut out of a face's frame body, in the
224
+ renderer's order: "No Border", the legendary crown's space, the extended-art
225
+ silhouette, the colorless nickname banner.
226
+ - `ringMaskFor(masks, useFullBorder)` and `borderStateFor(input)` — the mask a
227
+ border color recolors through, and the border state that results:
228
+ `default`, `light`, `dark` or `none`.
229
+ - `resolveBoxOverrides(boxes, state)` — a box set with each text box's
230
+ `overrides` merged in for one render's border state and template settings,
231
+ so everything that draws a frame agrees on what a condition matches.
232
+ `overrideMatches` tests a single rule, and
233
+ `resolveSecondFaceBoxOverrides` does the same for a `secondFaceBoxes` set.
234
+ - `turnPlacement(sheet, rotation)` / `turnMatrix(sheet, rotation)` — where a
235
+ turned box set (one with a `rotation`) sits on the sheet, as a node's
236
+ position and rotation or as a matrix. `turnedSheetSize` is the sheet as such
237
+ a set measures it, `turnedBoundsOnSheet` one of its boxes back on the sheet
238
+ as printed, and `turnedVectorOnSheet` / `sheetVectorInTurned` a direction
239
+ taken one way or the other.
240
+ - `templateSettingDefaults(frame)`, `mergeTemplateSettings` and
241
+ `layoutTemplateSettings(settings, layout)` — a frame's settings the way Card
242
+ Anvil stores them, and the values in effect for one layout.
243
+ - `textOutlineFor(box)` / `textShadowFor(box)` — how a plain-text box is
244
+ outlined or shadowed.
245
+ - `getReadableTextColor` and friends — the contrast rule that decides whether
246
+ a border color reads as light or dark.
173
247
 
174
248
  Card Anvil still owns the other half — reading a Scryfall card into
175
249
  `FrameDetails` — because that is where the Scryfall types live.
@@ -0,0 +1,17 @@
1
+ import { CollectorInfoLine } from "../schema/collectorInfo.js";
2
+ /**
3
+ * Default collector info layout with two lines, left-aligned.
4
+ * Line 1: rarity | cardNumber | creatorName
5
+ * Line 2: setCode | separator | language | artistSymbol | artist
6
+ *
7
+ * `withCollectorInfoDefaults` gives these to collector info unless its bounds
8
+ * bring their own `lines`, and the renderer falls back to them for a collector
9
+ * box without any.
10
+ */
11
+ export declare const defaultCollectorInfoLines: readonly CollectorInfoLine[];
12
+ /**
13
+ * Retro collector info layout: just artist (top line) and creator name
14
+ * (bottom line), both centered in the box instead of flowing from its left
15
+ * edge like the default lines do.
16
+ */
17
+ export declare const retroCollectorInfoLines: readonly CollectorInfoLine[];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Default collector info layout with two lines, left-aligned.
3
+ * Line 1: rarity | cardNumber | creatorName
4
+ * Line 2: setCode | separator | language | artistSymbol | artist
5
+ *
6
+ * `withCollectorInfoDefaults` gives these to collector info unless its bounds
7
+ * bring their own `lines`, and the renderer falls back to them for a collector
8
+ * box without any.
9
+ */
10
+ export const defaultCollectorInfoLines = [
11
+ {
12
+ elements: [
13
+ { type: "rarity", spacingAfter: 34 }, // Extra space before card number
14
+ { type: "cardNumber", spacingAfter: 40, letterSpacing: 8 }, // Extra space before creator name
15
+ { type: "creatorName", minXFromElement: "artistSymbol" },
16
+ ],
17
+ spacing: 10, // Space between elements
18
+ },
19
+ {
20
+ elements: [
21
+ { type: "setCode", spacingAfter: 24, letterSpacing: 6 }, // Extra space before language
22
+ {
23
+ type: "separator",
24
+ useManaFont: true,
25
+ manaSymbol: "•",
26
+ fontSizeMultiplierDot: 1.9,
27
+ fontSizeMultiplierStar: 1.4,
28
+ yOffsetDot: 5,
29
+ yOffsetStar: 20,
30
+ spacingAfter: 10,
31
+ },
32
+ { type: "language", spacingAfter: 38, letterSpacing: 4 }, // Extra space before artist symbol
33
+ {
34
+ type: "artistSymbol",
35
+ useManaFont: true,
36
+ manaSymbol: "a",
37
+ fontSizeMultiplier: 1.24,
38
+ },
39
+ { type: "artist", fontSizeMultiplier: 1.1, letterSpacing: 0 },
40
+ ],
41
+ spacing: 10,
42
+ },
43
+ ];
44
+ /**
45
+ * Retro collector info layout: just artist (top line) and creator name
46
+ * (bottom line), both centered in the box instead of flowing from its left
47
+ * edge like the default lines do.
48
+ */
49
+ export const retroCollectorInfoLines = [
50
+ {
51
+ elements: [{ type: "artist", prefix: "Illus. " }],
52
+ align: "center",
53
+ fontSizeMultiplier: 1.5,
54
+ fontFamily: "MPlantin",
55
+ },
56
+ {
57
+ elements: [{ type: "creatorName" }],
58
+ align: "center",
59
+ fontSizeMultiplier: 1.3,
60
+ fontFamily: "MPlantin",
61
+ },
62
+ ];
@@ -1,2 +1,4 @@
1
+ export * from "./collectorInfo.js";
1
2
  export * from "./layoutDefaults.js";
2
3
  export * from "./omit.js";
4
+ export * from "./opacityStops.js";
@@ -1,2 +1,4 @@
1
+ export * from "./collectorInfo.js";
1
2
  export * from "./layoutDefaults.js";
2
3
  export * from "./omit.js";
4
+ export * from "./opacityStops.js";
@@ -1,6 +1,24 @@
1
- import { LayoutConfig, TemplateSettingsConfig, TextBox } from "../schema/frame.js";
1
+ import { CollectorInfoBox, LayoutConfig, TemplateSettingsConfig, TextBoxOverride } from "../schema/frame.js";
2
+ /**
3
+ * `overrides` for text printed on the frame's outer border ring — on most
4
+ * frames, the collector line. The text turns black once a light border color
5
+ * is picked, and gains a black outline when "No Border" cuts the ring away and
6
+ * leaves it over the art.
7
+ *
8
+ * `withCollectorInfoDefaults` gives these to collector info unless its bounds
9
+ * bring their own.
10
+ */
11
+ export declare const onBorderTextOverrides: readonly TextBoxOverride[];
2
12
  /**
3
13
  * Applies default collector info bounds to a layout config if not specified.
14
+ *
15
+ * The collector info this fills in is taken to sit on the border ring, so it
16
+ * gets `onBorderTextOverrides` — unless `defaultCollectorInfoBounds` brings its
17
+ * own `overrides`, with `[]` for a collector line printed where the border
18
+ * settings never reach. It prints `defaultCollectorInfoLines` unless the bounds
19
+ * bring their own `lines` — `retroCollectorInfoLines`, or a layout of the
20
+ * frame's own. A collector box the layout defines itself is left exactly as
21
+ * written.
4
22
  */
5
- export declare function withCollectorInfoDefaults<T extends LayoutConfig>(config: T, defaultCollectorInfoBounds: TextBox): T;
23
+ export declare function withCollectorInfoDefaults<T extends LayoutConfig>(config: T, defaultCollectorInfoBounds: CollectorInfoBox): T;
6
24
  export declare function withSettingsDefaults<T extends LayoutConfig>(config: T, defaultSettings: TemplateSettingsConfig): T;
@@ -1,62 +1,97 @@
1
+ import { defaultCollectorInfoLines } from "./collectorInfo.js";
2
+ /**
3
+ * `overrides` for text printed on the frame's outer border ring — on most
4
+ * frames, the collector line. The text turns black once a light border color
5
+ * is picked, and gains a black outline when "No Border" cuts the ring away and
6
+ * leaves it over the art.
7
+ *
8
+ * `withCollectorInfoDefaults` gives these to collector info unless its bounds
9
+ * bring their own.
10
+ */
11
+ export const onBorderTextOverrides = [
12
+ {
13
+ when: { border: "none" },
14
+ style: { outlineColor: "black", outlineWidth: 19 },
15
+ },
16
+ { when: { border: "light" }, style: { color: "black" } },
17
+ ];
1
18
  /**
2
19
  * Applies default collector info bounds to a layout config if not specified.
20
+ *
21
+ * The collector info this fills in is taken to sit on the border ring, so it
22
+ * gets `onBorderTextOverrides` — unless `defaultCollectorInfoBounds` brings its
23
+ * own `overrides`, with `[]` for a collector line printed where the border
24
+ * settings never reach. It prints `defaultCollectorInfoLines` unless the bounds
25
+ * bring their own `lines` — `retroCollectorInfoLines`, or a layout of the
26
+ * frame's own. A collector box the layout defines itself is left exactly as
27
+ * written.
3
28
  */
4
29
  export function withCollectorInfoDefaults(config, defaultCollectorInfoBounds) {
30
+ const collectorInfo = {
31
+ ...defaultCollectorInfoBounds,
32
+ overrides: defaultCollectorInfoBounds.overrides ?? onBorderTextOverrides,
33
+ lines: defaultCollectorInfoBounds.lines ?? defaultCollectorInfoLines,
34
+ };
5
35
  return {
6
36
  ...config,
7
37
  boxes: {
8
38
  ...config.boxes,
9
- collectorInfo: config.boxes.collectorInfo ?? defaultCollectorInfoBounds,
39
+ collectorInfo: config.boxes.collectorInfo ?? collectorInfo,
10
40
  },
11
41
  tallBoxes: config.tallBoxes
12
42
  ? {
13
43
  ...config.tallBoxes,
14
- collectorInfo: config.tallBoxes.collectorInfo ?? defaultCollectorInfoBounds,
44
+ collectorInfo: config.tallBoxes.collectorInfo ?? collectorInfo,
45
+ }
46
+ : undefined,
47
+ // Planeswalkers with two abilities swap `boxes` for these (see
48
+ // renderCardToCanvas), which would otherwise drop collector info.
49
+ twoAbilityBoxes: config.twoAbilityBoxes
50
+ ? {
51
+ ...config.twoAbilityBoxes,
52
+ collectorInfo: config.twoAbilityBoxes.collectorInfo ?? collectorInfo,
15
53
  }
16
54
  : undefined,
17
55
  backBoxes: config.backBoxes
18
56
  ? {
19
57
  ...config.backBoxes,
20
- collectorInfo: config.backBoxes.collectorInfo ?? defaultCollectorInfoBounds,
58
+ collectorInfo: config.backBoxes.collectorInfo ?? collectorInfo,
21
59
  }
22
60
  : undefined,
23
61
  sagaFrontBoxes: config.sagaFrontBoxes
24
62
  ? {
25
63
  ...config.sagaFrontBoxes,
26
- collectorInfo: config.sagaFrontBoxes.collectorInfo ?? defaultCollectorInfoBounds,
64
+ collectorInfo: config.sagaFrontBoxes.collectorInfo ?? collectorInfo,
27
65
  }
28
66
  : undefined,
29
67
  sagaBackBoxes: config.sagaBackBoxes
30
68
  ? {
31
69
  ...config.sagaBackBoxes,
32
- collectorInfo: config.sagaBackBoxes.collectorInfo ?? defaultCollectorInfoBounds,
70
+ collectorInfo: config.sagaBackBoxes.collectorInfo ?? collectorInfo,
33
71
  }
34
72
  : undefined,
35
73
  creatureBoxes: config.creatureBoxes
36
74
  ? {
37
75
  ...config.creatureBoxes,
38
- collectorInfo: config.creatureBoxes.collectorInfo ?? defaultCollectorInfoBounds,
76
+ collectorInfo: config.creatureBoxes.collectorInfo ?? collectorInfo,
39
77
  }
40
78
  : undefined,
41
79
  backCreatureBoxes: config.backCreatureBoxes
42
80
  ? {
43
81
  ...config.backCreatureBoxes,
44
- collectorInfo: config.backCreatureBoxes.collectorInfo ??
45
- defaultCollectorInfoBounds,
82
+ collectorInfo: config.backCreatureBoxes.collectorInfo ?? collectorInfo,
46
83
  }
47
84
  : undefined,
48
85
  sagaFrontCreatureBoxes: config.sagaFrontCreatureBoxes
49
86
  ? {
50
87
  ...config.sagaFrontCreatureBoxes,
51
- collectorInfo: config.sagaFrontCreatureBoxes.collectorInfo ??
52
- defaultCollectorInfoBounds,
88
+ collectorInfo: config.sagaFrontCreatureBoxes.collectorInfo ?? collectorInfo,
53
89
  }
54
90
  : undefined,
55
91
  sagaBackCreatureBoxes: config.sagaBackCreatureBoxes
56
92
  ? {
57
93
  ...config.sagaBackCreatureBoxes,
58
- collectorInfo: config.sagaBackCreatureBoxes.collectorInfo ??
59
- defaultCollectorInfoBounds,
94
+ collectorInfo: config.sagaBackCreatureBoxes.collectorInfo ?? collectorInfo,
60
95
  }
61
96
  : undefined,
62
97
  // Saga layouts swap `boxes` for `flipsidePtBoxes` on face 0 when the
@@ -65,7 +100,7 @@ export function withCollectorInfoDefaults(config, defaultCollectorInfoBounds) {
65
100
  flipsidePtBoxes: config.flipsidePtBoxes
66
101
  ? {
67
102
  ...config.flipsidePtBoxes,
68
- collectorInfo: config.flipsidePtBoxes.collectorInfo ?? defaultCollectorInfoBounds,
103
+ collectorInfo: config.flipsidePtBoxes.collectorInfo ?? collectorInfo,
69
104
  }
70
105
  : undefined,
71
106
  };
@@ -0,0 +1,8 @@
1
+ import type { OpacityStop } from "../schema/paint.js";
2
+ /**
3
+ * Opacity stops sampled from `curve`, which maps 0–1 along a gradient to an
4
+ * opacity 0–1 — for writing a gradient's `opacity` from a formula. The stops
5
+ * are spaced evenly, `count` of them (at least two). A canvas draws straight
6
+ * lines between them, so a tight curve wants more.
7
+ */
8
+ export declare function opacityStopsFrom(curve: (t: number) => number, count?: number): OpacityStop[];
@@ -0,0 +1,17 @@
1
+ const round = (value, places) => Number(value.toFixed(places));
2
+ /**
3
+ * Opacity stops sampled from `curve`, which maps 0–1 along a gradient to an
4
+ * opacity 0–1 — for writing a gradient's `opacity` from a formula. The stops
5
+ * are spaced evenly, `count` of them (at least two). A canvas draws straight
6
+ * lines between them, so a tight curve wants more.
7
+ */
8
+ export function opacityStopsFrom(curve, count = 24) {
9
+ const n = Math.max(2, Math.round(count));
10
+ return Array.from({ length: n }, (_, i) => {
11
+ const at = i / (n - 1);
12
+ return {
13
+ at: round(at, 6),
14
+ opacity: round(Math.min(1, Math.max(0, curve(at))), 4),
15
+ };
16
+ });
17
+ }
@@ -16,3 +16,4 @@ export { type FrameServer, loadFrameExport, startFrameServer, } from "./loadFram
16
16
  export { type Problem, Reporter, type Severity, formatProblem, inGitHubActions, } from "./report.js";
17
17
  export { SCHEMA_NAMES, SHIPPED_SCHEMAS, type SchemaName, schemaFilename, toJsonSchema, } from "./schema.js";
18
18
  export { stableStringify } from "./stableJson.js";
19
+ export { type Pixels, type Rect, type TrimOptions, type TrimResult, type TrimmedImage, cropPixels, opaqueBounds, trimImages, unionBounds, } from "./trim.js";
package/dist/cli/index.js CHANGED
@@ -16,3 +16,4 @@ export { loadFrameExport, startFrameServer, } from "./loadFrame.js";
16
16
  export { Reporter, formatProblem, inGitHubActions, } from "./report.js";
17
17
  export { SCHEMA_NAMES, SHIPPED_SCHEMAS, schemaFilename, toJsonSchema, } from "./schema.js";
18
18
  export { stableStringify } from "./stableJson.js";
19
+ export { cropPixels, opaqueBounds, trimImages, unionBounds, } from "./trim.js";
@@ -32,7 +32,7 @@ async function importVite() {
32
32
  * loop would appear to run and do nothing.
33
33
  */
34
34
  export function invalidateFrameModules(frameServer) {
35
- // Both supported Vite majors expose the SSR module graph here; the legacy
35
+ // Every supported Vite major exposes the SSR module graph here; the legacy
36
36
  // top-level accessor is deprecated.
37
37
  frameServer.server.environments.ssr.moduleGraph.invalidateAll();
38
38
  }
package/dist/cli/main.js CHANGED
@@ -10,6 +10,7 @@ import { Reporter, inGitHubActions } from "./report.js";
10
10
  import { SCHEMA_NAMES, toJsonSchema } from "./schema.js";
11
11
  import { stableStringify } from "./stableJson.js";
12
12
  import { runStudio } from "./studio/serve.js";
13
+ import { trimImages } from "./trim.js";
13
14
  import { watchFrames } from "./watch.js";
14
15
  const USAGE = `frame-kit — package Card Anvil frames
15
16
 
@@ -19,6 +20,8 @@ Usage:
19
20
  frame-kit schema <name> [--out <file>] print a format as JSON Schema
20
21
  (${SCHEMA_NAMES.join(" | ")})
21
22
  frame-kit studio [options] open the visual box editor
23
+ frame-kit trim <png...> [options] cut images down to their visible
24
+ pixels, and say where they sit
22
25
  frame-kit --help
23
26
  frame-kit --version
24
27
 
@@ -37,6 +40,12 @@ build only:
37
40
  in the index
38
41
  --watch rebuild a frame whenever its source changes
39
42
 
43
+ trim only:
44
+ --shared cut every image to one rectangle holding all of their
45
+ visible pixels, so they keep one position on the sheet
46
+ --out <dir> write trimmed images here instead of replacing them
47
+ --dry-run say what would be cut, write nothing
48
+
40
49
  studio only:
41
50
  --port <n> port to serve on (default: 4620)
42
51
  --host <h> interface to bind (default: 127.0.0.1)
@@ -45,6 +54,10 @@ studio only:
45
54
  reloading (only inside the frames workspace)
46
55
  --no-write serve read-only; refuse every edit
47
56
 
57
+ A trimmed image keeps its pixels exactly, and records where it sits on the
58
+ sheet so a second trim still reports that position. Put the position in the
59
+ frame where the field asks for one, such as a Saga's bannerOrigin.
60
+
48
61
  The studio edits the literals in your source when you drag a box, so run it on
49
62
  a clean working tree. It needs TypeScript installed to find them.
50
63
 
@@ -174,6 +187,62 @@ async function runBuild(root, only, values) {
174
187
  }
175
188
  return reporter.ok ? EXIT.ok : EXIT.failed;
176
189
  }
190
+ function describeRect(rect) {
191
+ return `${String(rect.width)} × ${String(rect.height)} at (${String(rect.x)}, ${String(rect.y)})`;
192
+ }
193
+ async function runTrim(argv) {
194
+ let parsed;
195
+ try {
196
+ parsed = parseArgs({
197
+ args: argv,
198
+ options: {
199
+ shared: { type: "boolean" },
200
+ out: { type: "string" },
201
+ "dry-run": { type: "boolean" },
202
+ json: { type: "boolean" },
203
+ },
204
+ allowPositionals: true,
205
+ strict: true,
206
+ });
207
+ }
208
+ catch (cause) {
209
+ console.error(`${cause instanceof Error ? cause.message : String(cause)}\n\n${USAGE}`);
210
+ return EXIT.usage;
211
+ }
212
+ const { values, positionals: files } = parsed;
213
+ if (files.length === 0) {
214
+ console.error(`trim needs at least one PNG.\n\n${USAGE}`);
215
+ return EXIT.usage;
216
+ }
217
+ const result = await trimImages(files, {
218
+ shared: values.shared === true,
219
+ ...(values.out !== undefined ? { outDir: values.out } : {}),
220
+ dryRun: values["dry-run"] === true,
221
+ });
222
+ if (values.json) {
223
+ console.log(JSON.stringify(result, null, 2));
224
+ return EXIT.ok;
225
+ }
226
+ for (const image of result.images) {
227
+ const from = `${String(image.width)} × ${String(image.height)}`;
228
+ const what = image.kept
229
+ ? `${from} → ${describeRect(image.kept)}`
230
+ : `${from}, nothing visible, left as it is`;
231
+ const nothingToCut = image.kept?.width === image.width && image.kept.height === image.height;
232
+ const action = image.written
233
+ ? ""
234
+ : nothingToCut
235
+ ? " (already trimmed)"
236
+ : values["dry-run"] && image.kept
237
+ ? " (dry run)"
238
+ : "";
239
+ console.log(`${image.file} ${what}${action}`);
240
+ }
241
+ if (result.shared) {
242
+ console.log(`\nEvery image was cut to ${describeRect(result.shared)} on the sheet.`);
243
+ }
244
+ return EXIT.ok;
245
+ }
177
246
  function isSchemaName(name) {
178
247
  return SCHEMA_NAMES.includes(name);
179
248
  }
@@ -224,6 +293,9 @@ async function main(argv) {
224
293
  if (command === "studio") {
225
294
  return await runStudio(argv.slice(1));
226
295
  }
296
+ if (command === "trim") {
297
+ return await runTrim(argv.slice(1));
298
+ }
227
299
  if (command !== "validate" && command !== "build") {
228
300
  console.error(`Unknown command "${command}".\n\n${USAGE}`);
229
301
  return EXIT.usage;
@@ -1,4 +1,4 @@
1
- import { CardBoxesSchema, LayoutConfigSchema } from "../../schema/frame.js";
1
+ import { CardBoxesSchema, LayoutConfigSchema, SecondFaceBoxesSchema, } from "../../schema/frame.js";
2
2
  import { FrameAssetsSchema, LayoutMasksSchema, } from "../../schema/frameAssets.js";
3
3
  /** Strips `.optional()` and friends to reach the schema that carries identity. */
4
4
  function unwrap(schema) {
@@ -23,12 +23,16 @@ function unwrap(schema) {
23
23
  }
24
24
  const KIND_BY_SCHEMA = new Map([
25
25
  [CardBoxesSchema, "boxes"],
26
+ // A second face's set is boxes like any other, only fewer of them.
27
+ [SecondFaceBoxesSchema, "boxes"],
26
28
  [FrameAssetsSchema, "assets"],
27
29
  [LayoutMasksSchema, "masks"],
28
30
  ]);
29
31
  /**
30
- * Whether a schema is a plain object of numbers — a layout's render knobs
31
- * (`planeswalkerConfig`, `sagaConfig`, `crownConfig`, `nicknameConfig`).
32
+ * Whether a schema is an object of numbers — a layout's render knobs
33
+ * (`planeswalkerConfig`, `sagaConfig`, `sectionConfig`, `crownConfig`,
34
+ * `nicknameConfig`, `powerToughnessRow`). A group may also hold objects, such
35
+ * as `planeswalkerConfig.abilityDivider`; the studio edits only its numbers.
32
36
  *
33
37
  * Recognised by shape rather than by name, so a knob group added to the
34
38
  * contract is editable in the studio without a change here. Records and enums
@@ -41,11 +45,10 @@ function isNumericObject(schema) {
41
45
  return false;
42
46
  }
43
47
  const fields = Object.values(node.shape);
44
- return (fields.length > 0 &&
45
- fields.every((field) => {
46
- const inner = unwrap(field).inner.def;
47
- return inner.type === "number" || inner.type === "boolean";
48
- }));
48
+ const types = fields.map((field) => unwrap(field).inner.def.type);
49
+ const isKnob = (type) => type === "number" || type === "boolean";
50
+ return (types.some(isKnob) &&
51
+ types.every((type) => isKnob(type) || type === "object"));
49
52
  }
50
53
  /**
51
54
  * Every box / asset / mask / knob slot the frame contract defines, read off
@@ -117,7 +117,10 @@ async function startUiDevServer(srcDir, httpServer) {
117
117
  const server = await vite.createServer({
118
118
  root: srcDir,
119
119
  appType: "spa",
120
- server: { middlewareMode: true, hmr: { server: httpServer } },
120
+ // `ws.server` is Vite 8's spelling (`hmr.server` is deprecated there, and
121
+ // Vite 7 has no `ws` options at all). Only the workspace runs --dev, and it
122
+ // pins Vite 8, so the published peer range never reaches this line.
123
+ server: { middlewareMode: true, ws: { server: httpServer } },
121
124
  });
122
125
  return {
123
126
  middlewares: (req, res) => {
@@ -0,0 +1,62 @@
1
+ /** A rectangle in pixels. */
2
+ export interface Rect {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+ /** Decoded pixels, four bytes (RGBA) a pixel, row after row. */
9
+ export interface Pixels {
10
+ width: number;
11
+ height: number;
12
+ data: Uint8Array;
13
+ }
14
+ /**
15
+ * The smallest rectangle holding every pixel that is not fully transparent,
16
+ * or `undefined` when the image has none.
17
+ */
18
+ export declare function opaqueBounds({ width, height, data, }: Pixels): Rect | undefined;
19
+ /** The smallest rectangle holding all of `rects`, or `undefined` for none. */
20
+ export declare function unionBounds(rects: readonly Rect[]): Rect | undefined;
21
+ /** `pixels` cut down to `rect`, which must lie inside them. */
22
+ export declare function cropPixels(pixels: Pixels, rect: Rect): Pixels;
23
+ export interface TrimOptions {
24
+ /**
25
+ * Cut every image to one rectangle: the smallest holding all of their
26
+ * visible pixels. They must all be the same size, and they then share one
27
+ * position on the sheet — which is what images drawn together, such as a
28
+ * banner and the masks cut from it, need.
29
+ */
30
+ shared?: boolean;
31
+ /**
32
+ * Where trimmed images are written, under their own names. Without it each
33
+ * one replaces its original.
34
+ */
35
+ outDir?: string;
36
+ /** Work everything out but write nothing. */
37
+ dryRun?: boolean;
38
+ }
39
+ export interface TrimmedImage {
40
+ file: string;
41
+ /** Where it was written; `undefined` when nothing was. */
42
+ written: string | undefined;
43
+ /** Its size before trimming. */
44
+ width: number;
45
+ height: number;
46
+ /**
47
+ * The part kept, with `x` and `y` on the sheet. `undefined` when the image
48
+ * has no visible pixel, and is left as it was.
49
+ */
50
+ kept: Rect | undefined;
51
+ }
52
+ export interface TrimResult {
53
+ images: TrimmedImage[];
54
+ /** With `shared`: the one rectangle every image was cut to, on the sheet. */
55
+ shared: Rect | undefined;
56
+ }
57
+ /**
58
+ * Trims each PNG in `files` to its visible pixels, or with `shared` all of
59
+ * them to one rectangle, and reports where each now sits on its sheet. An
60
+ * image already as small as it can be is not rewritten.
61
+ */
62
+ export declare function trimImages(files: readonly string[], options?: TrimOptions): Promise<TrimResult>;