@natsuneko-laboratory/fleet-renderer-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kanon Mochizuki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.cjs ADDED
@@ -0,0 +1,250 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/constants.ts
3
+ /** Fleet canvases are always rendered/edited at a fixed 9:16 portrait aspect ratio. */
4
+ const FLEET_ASPECT_RATIO = 9 / 16;
5
+ /** Reference output resolution used by the server-side renderer (also a good default preview canvas size). */
6
+ const FLEET_CANVAS_WIDTH = 1080;
7
+ const FLEET_CANVAS_HEIGHT = 1920;
8
+ /** Scale bounds for text and sticker layers, matching the server-enforced schema. */
9
+ const FLEET_TEXT_STICKER_SCALE_MIN = .1;
10
+ const FLEET_TEXT_STICKER_SCALE_MAX = 5;
11
+ /** Scale bounds for the media (background) layer — allowed to zoom out further to support crop/pan editing. */
12
+ const FLEET_MEDIA_SCALE_MIN = .05;
13
+ const FLEET_MEDIA_SCALE_MAX = 5;
14
+ /** Rotation range exposed by the compose UI (the server schema itself allows -360..360). */
15
+ const FLEET_ROTATION_UI_MIN = -180;
16
+ const FLEET_ROTATION_UI_MAX = 180;
17
+ /** Server-enforced maximums (`FLEET_SCHEMA` in teyvat's `foreign-model`). */
18
+ const FLEET_MAX_TEXTS = 10;
19
+ const FLEET_MAX_STICKERS = 20;
20
+ const FLEET_TEXT_MAX_LENGTH = 500;
21
+ const FLEET_DEFAULT_BACKGROUND_COLORS = [
22
+ "#000000",
23
+ "#1a1a2e",
24
+ "#0d3b66",
25
+ "#1b4332",
26
+ "#7b2d8b",
27
+ "#c0392b",
28
+ "#e67e22",
29
+ "#ffffff"
30
+ ];
31
+ const FLEET_DEFAULT_PLACEMENT = {
32
+ posX: .5,
33
+ posY: .5,
34
+ scale: 1,
35
+ rotation: 0
36
+ };
37
+ /** Minimum on-screen sizes (px) so text/stickers stay legible on small containers. Set to `0` to disable. */
38
+ const FLEET_DEFAULT_MIN_FONT_SIZE_PX = 14;
39
+ const FLEET_DEFAULT_MIN_STICKER_SIZE_PX = 28;
40
+ /** Fixed floors (px) for a text layer's background "pill" padding/corner radius. */
41
+ const FLEET_TEXT_PADDING_VERTICAL_PX = 8;
42
+ const FLEET_TEXT_PADDING_HORIZONTAL_PX = 12;
43
+ const FLEET_TEXT_BORDER_RADIUS_PX = 10;
44
+ //#endregion
45
+ //#region src/fonts.ts
46
+ /**
47
+ * Canonical font mapping for the three shipped Fleet text styles' font. Unifies three previously
48
+ * divergent naming schemes (RN font family strings, Tailwind font classes, satori-registered names).
49
+ * `serif` intentionally maps to the same font as `default` until a dedicated serif asset exists.
50
+ */
51
+ const FLEET_FONTS = {
52
+ default: {
53
+ style: "default",
54
+ weight: 400,
55
+ reactNativeFontFamily: "Noto Sans JP Regular",
56
+ cssFontFamily: "Noto Sans JP",
57
+ satoriFontName: "Noto Sans JP"
58
+ },
59
+ bold: {
60
+ style: "bold",
61
+ weight: 700,
62
+ reactNativeFontFamily: "Noto Sans JP Bold",
63
+ cssFontFamily: "Noto Sans JP",
64
+ satoriFontName: "Noto Sans JP"
65
+ },
66
+ serif: {
67
+ style: "serif",
68
+ weight: 400,
69
+ reactNativeFontFamily: "Noto Sans JP Regular",
70
+ cssFontFamily: "Noto Sans JP",
71
+ satoriFontName: "Noto Sans JP"
72
+ },
73
+ handwriting: {
74
+ style: "handwriting",
75
+ weight: 400,
76
+ reactNativeFontFamily: "HunyaJi-Re",
77
+ cssFontFamily: "HunyaJi-Re",
78
+ satoriFontName: "HunyaJi-Re"
79
+ }
80
+ };
81
+ const getFleetFontDescriptor = (style) => FLEET_FONTS[style];
82
+ //#endregion
83
+ //#region src/container-units.ts
84
+ /**
85
+ * CSS container-query-unit equivalents (`cqw`/`cqh`/`cqmin`/`cqmax`) computed from a raw
86
+ * `{ width, height }` pair, so the same formulas work whether the size comes from a browser
87
+ * `ResizeObserver`, React Native's `onLayout`, or a fixed server-side render target.
88
+ */
89
+ const createContainerUnits = (container) => ({
90
+ cqw: (value) => container.width * value / 100,
91
+ cqh: (value) => container.height * value / 100,
92
+ cqmin: (value) => Math.min(container.width, container.height) * value / 100,
93
+ cqmax: (value) => Math.max(container.width, container.height) * value / 100
94
+ });
95
+ //#endregion
96
+ //#region src/placement.ts
97
+ /**
98
+ * Converts a normalized `FleetPlacement` into a container-relative pixel transform:
99
+ * `translate = (pos - 0.5) * containerSize`. This is the transform every layer kind
100
+ * (media/text/sticker) applies identically, anchored at the layer's own center.
101
+ */
102
+ const placementToPixelTransform = (placement, container) => ({
103
+ translateX: (placement.posX - .5) * container.width,
104
+ translateY: (placement.posY - .5) * container.height,
105
+ scale: placement.scale,
106
+ rotationDeg: placement.rotation
107
+ });
108
+ /**
109
+ * Inverse of {@link placementToPixelTransform}'s translation: converts a container-relative pixel
110
+ * offset (e.g. accumulated from a drag gesture, starting at the container center) back into a
111
+ * normalized `FleetPlacement`. Used by editors while a layer is being dragged/pinched/rotated.
112
+ */
113
+ const pixelOffsetToPlacement = (offsetXPx, offsetYPx, container, scale, rotation) => ({
114
+ posX: container.width > 0 ? offsetXPx / container.width + .5 : .5,
115
+ posY: container.height > 0 ? offsetYPx / container.height + .5 : .5,
116
+ scale,
117
+ rotation
118
+ });
119
+ /**
120
+ * Radius (px) of the circle that fully contains a `width` x `height` box at the given `scale`,
121
+ * regardless of rotation — i.e. the box's diagonal half-length. Rotation-safe by construction,
122
+ * since a box's bounding circle doesn't change as it spins about its own center.
123
+ */
124
+ const computeBoundingRadiusPx = (box, scale) => Math.hypot(box.width / 2, box.height / 2) * scale;
125
+ /**
126
+ * Pulls a layer's center inward, per axis, so its bounding circle never crosses the canvas edge.
127
+ * If the circle is larger than the canvas along an axis, that axis is centered instead of clamped.
128
+ */
129
+ const clampCenterToCanvas = ({ centerXPx, centerYPx, radiusPx, container }) => {
130
+ const clampAxis = (center, size) => {
131
+ if (radiusPx * 2 >= size) return size / 2;
132
+ return Math.min(Math.max(center, radiusPx), size - radiusPx);
133
+ };
134
+ return {
135
+ x: clampAxis(centerXPx, container.width),
136
+ y: clampAxis(centerYPx, container.height)
137
+ };
138
+ };
139
+ /**
140
+ * Convenience wrapper combining {@link placementToPixelTransform}, {@link computeBoundingRadiusPx},
141
+ * and {@link clampCenterToCanvas}: returns a `FleetPlacement` whose `posX`/`posY` have been pulled
142
+ * inward so the box never visually clips the canvas edge. Scale/rotation pass through unchanged.
143
+ */
144
+ const clampPlacementToCanvas = (placement, box, container) => {
145
+ const transform = placementToPixelTransform(placement, container);
146
+ const radiusPx = computeBoundingRadiusPx(box, placement.scale);
147
+ const centerXPx = container.width / 2 + transform.translateX;
148
+ const centerYPx = container.height / 2 + transform.translateY;
149
+ const clamped = clampCenterToCanvas({
150
+ centerXPx,
151
+ centerYPx,
152
+ radiusPx,
153
+ container
154
+ });
155
+ return {
156
+ ...placement,
157
+ posX: container.width > 0 ? clamped.x / container.width : placement.posX,
158
+ posY: container.height > 0 ? clamped.y / container.height : placement.posY
159
+ };
160
+ };
161
+ //#endregion
162
+ //#region src/layout.ts
163
+ /**
164
+ * The core layout engine: takes a platform-agnostic `FleetContentData` document plus the current
165
+ * container size, and returns fully-resolved pixel geometry for every layer (position, size, font
166
+ * size, padding, border radius). Adapters only need to map this to their own render primitives —
167
+ * no positioning/sizing math should live in an adapter.
168
+ */
169
+ const resolveFleetLayout = (content, container, options = {}) => {
170
+ const units = createContainerUnits(container);
171
+ const minFontSizePx = options.minFontSizePx ?? 14;
172
+ const minStickerSizePx = options.minStickerSizePx ?? 28;
173
+ return {
174
+ backgroundColor: content.backgroundColor,
175
+ container,
176
+ media: resolveMediaLayer(content.media, container, units),
177
+ texts: content.texts.map((text, index) => resolveTextLayer(text, index, container, units, minFontSizePx)),
178
+ stickers: content.stickers.map((sticker, index) => resolveStickerLayer(sticker, index, container, units, minStickerSizePx))
179
+ };
180
+ };
181
+ const resolveMediaLayer = (media, container, units) => {
182
+ if (!media) return null;
183
+ const placement = media.placement ?? FLEET_DEFAULT_PLACEMENT;
184
+ const widthPx = units.cqw(100);
185
+ const heightPx = widthPx * (media.height ?? container.height) / (media.width ?? container.width);
186
+ return {
187
+ type: "media",
188
+ transform: placementToPixelTransform(placement, container),
189
+ widthPx,
190
+ heightPx,
191
+ url: media.url,
192
+ alt: media.alt
193
+ };
194
+ };
195
+ const resolveTextLayer = (text, index, container, units, minFontSizePx) => {
196
+ const fontSizePx = Math.max(units.cqh(3), minFontSizePx);
197
+ return {
198
+ type: "text",
199
+ key: text.id ?? `${index}-${text.body}`,
200
+ transform: placementToPixelTransform(text, container),
201
+ body: text.body,
202
+ textAlignment: text.textAlignment,
203
+ color: text.color,
204
+ backgroundColor: text.backgroundColor ?? "transparent",
205
+ font: FLEET_FONTS[text.textStyle],
206
+ fontSizePx,
207
+ lineHeightPx: fontSizePx * 1.25,
208
+ paddingVerticalPx: Math.max(units.cqh(1.2), 8),
209
+ paddingHorizontalPx: Math.max(units.cqw(3.2), 12),
210
+ borderRadiusPx: Math.max(units.cqw(1.5), 10),
211
+ maxWidthPx: units.cqw(90)
212
+ };
213
+ };
214
+ const resolveStickerLayer = (sticker, index, container, units, minStickerSizePx) => ({
215
+ type: "sticker",
216
+ key: sticker.id ?? `${index}-${sticker.emoji}`,
217
+ transform: placementToPixelTransform(sticker, container),
218
+ emoji: sticker.emoji,
219
+ imageUrl: sticker.imageUrl,
220
+ sizePx: Math.max(units.cqw(8), minStickerSizePx)
221
+ });
222
+ //#endregion
223
+ exports.FLEET_ASPECT_RATIO = FLEET_ASPECT_RATIO;
224
+ exports.FLEET_CANVAS_HEIGHT = FLEET_CANVAS_HEIGHT;
225
+ exports.FLEET_CANVAS_WIDTH = FLEET_CANVAS_WIDTH;
226
+ exports.FLEET_DEFAULT_BACKGROUND_COLORS = FLEET_DEFAULT_BACKGROUND_COLORS;
227
+ exports.FLEET_DEFAULT_MIN_FONT_SIZE_PX = FLEET_DEFAULT_MIN_FONT_SIZE_PX;
228
+ exports.FLEET_DEFAULT_MIN_STICKER_SIZE_PX = FLEET_DEFAULT_MIN_STICKER_SIZE_PX;
229
+ exports.FLEET_DEFAULT_PLACEMENT = FLEET_DEFAULT_PLACEMENT;
230
+ exports.FLEET_FONTS = FLEET_FONTS;
231
+ exports.FLEET_MAX_STICKERS = FLEET_MAX_STICKERS;
232
+ exports.FLEET_MAX_TEXTS = FLEET_MAX_TEXTS;
233
+ exports.FLEET_MEDIA_SCALE_MAX = FLEET_MEDIA_SCALE_MAX;
234
+ exports.FLEET_MEDIA_SCALE_MIN = FLEET_MEDIA_SCALE_MIN;
235
+ exports.FLEET_ROTATION_UI_MAX = FLEET_ROTATION_UI_MAX;
236
+ exports.FLEET_ROTATION_UI_MIN = FLEET_ROTATION_UI_MIN;
237
+ exports.FLEET_TEXT_BORDER_RADIUS_PX = FLEET_TEXT_BORDER_RADIUS_PX;
238
+ exports.FLEET_TEXT_MAX_LENGTH = FLEET_TEXT_MAX_LENGTH;
239
+ exports.FLEET_TEXT_PADDING_HORIZONTAL_PX = FLEET_TEXT_PADDING_HORIZONTAL_PX;
240
+ exports.FLEET_TEXT_PADDING_VERTICAL_PX = FLEET_TEXT_PADDING_VERTICAL_PX;
241
+ exports.FLEET_TEXT_STICKER_SCALE_MAX = FLEET_TEXT_STICKER_SCALE_MAX;
242
+ exports.FLEET_TEXT_STICKER_SCALE_MIN = FLEET_TEXT_STICKER_SCALE_MIN;
243
+ exports.clampCenterToCanvas = clampCenterToCanvas;
244
+ exports.clampPlacementToCanvas = clampPlacementToCanvas;
245
+ exports.computeBoundingRadiusPx = computeBoundingRadiusPx;
246
+ exports.createContainerUnits = createContainerUnits;
247
+ exports.getFleetFontDescriptor = getFleetFontDescriptor;
248
+ exports.pixelOffsetToPlacement = pixelOffsetToPlacement;
249
+ exports.placementToPixelTransform = placementToPixelTransform;
250
+ exports.resolveFleetLayout = resolveFleetLayout;
@@ -0,0 +1,235 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * A Fleet text layer's visual style. `serif` currently has no dedicated font asset upstream and
4
+ * falls back to the same font as `default` — kept as a distinct value since the data model (and
5
+ * the eventual dedicated font) already exists.
6
+ */
7
+ type FleetTextStyle = "default" | "bold" | "serif" | "handwriting";
8
+ type FleetTextAlignment = "left" | "center" | "right";
9
+ /**
10
+ * Normalized position/transform shared by every layer kind (media, text, sticker).
11
+ * `posX`/`posY` are fractional coordinates where `0.5` is the container center; a layer's own
12
+ * center is placed at that fraction, then scaled and rotated about its own center.
13
+ *
14
+ * Unlike text/sticker layers (which are expected to stay within `[0, 1]`), media placement is
15
+ * intentionally allowed to range outside `[0, 1]` to support pannable/zoomable background crop.
16
+ */
17
+ interface FleetPlacement {
18
+ posX: number;
19
+ posY: number;
20
+ scale: number;
21
+ rotation: number;
22
+ }
23
+ interface FleetMediaEntity {
24
+ url: string;
25
+ alt?: string;
26
+ /** Intrinsic width/height, used to preserve aspect ratio. Falls back to the container's own aspect ratio when absent. */
27
+ width?: number;
28
+ height?: number;
29
+ placement?: FleetPlacement;
30
+ }
31
+ interface FleetTextEntity extends FleetPlacement {
32
+ id?: string;
33
+ body: string;
34
+ textStyle: FleetTextStyle;
35
+ textAlignment: FleetTextAlignment;
36
+ color: string;
37
+ /** Hex color or `"transparent"`. */
38
+ backgroundColor?: string;
39
+ }
40
+ interface FleetStickerEntity extends FleetPlacement {
41
+ id?: string;
42
+ /** Unicode emoji codepoints, or a reaction "symbol" resolved to an image via `resolveStickerImageUrl`. */
43
+ emoji: string;
44
+ /** Pre-resolved image URL, when the consumer already knows it. */
45
+ imageUrl?: string;
46
+ }
47
+ /** The platform-agnostic "Fleet document" — everything needed to lay out and render a single Fleet. */
48
+ interface FleetContentData {
49
+ backgroundColor: string;
50
+ media: FleetMediaEntity | null;
51
+ texts: FleetTextEntity[];
52
+ stickers: FleetStickerEntity[];
53
+ }
54
+ interface ContainerSize {
55
+ width: number;
56
+ height: number;
57
+ }
58
+ //#endregion
59
+ //#region src/constants.d.ts
60
+ /** Fleet canvases are always rendered/edited at a fixed 9:16 portrait aspect ratio. */
61
+ declare const FLEET_ASPECT_RATIO: number;
62
+ /** Reference output resolution used by the server-side renderer (also a good default preview canvas size). */
63
+ declare const FLEET_CANVAS_WIDTH = 1080;
64
+ declare const FLEET_CANVAS_HEIGHT = 1920;
65
+ /** Scale bounds for text and sticker layers, matching the server-enforced schema. */
66
+ declare const FLEET_TEXT_STICKER_SCALE_MIN = 0.1;
67
+ declare const FLEET_TEXT_STICKER_SCALE_MAX = 5;
68
+ /** Scale bounds for the media (background) layer — allowed to zoom out further to support crop/pan editing. */
69
+ declare const FLEET_MEDIA_SCALE_MIN = 0.05;
70
+ declare const FLEET_MEDIA_SCALE_MAX = 5;
71
+ /** Rotation range exposed by the compose UI (the server schema itself allows -360..360). */
72
+ declare const FLEET_ROTATION_UI_MIN = -180;
73
+ declare const FLEET_ROTATION_UI_MAX = 180;
74
+ /** Server-enforced maximums (`FLEET_SCHEMA` in teyvat's `foreign-model`). */
75
+ declare const FLEET_MAX_TEXTS = 10;
76
+ declare const FLEET_MAX_STICKERS = 20;
77
+ declare const FLEET_TEXT_MAX_LENGTH = 500;
78
+ declare const FLEET_DEFAULT_BACKGROUND_COLORS: readonly string[];
79
+ declare const FLEET_DEFAULT_PLACEMENT: {
80
+ readonly posX: 0.5;
81
+ readonly posY: 0.5;
82
+ readonly scale: 1;
83
+ readonly rotation: 0;
84
+ };
85
+ /** Minimum on-screen sizes (px) so text/stickers stay legible on small containers. Set to `0` to disable. */
86
+ declare const FLEET_DEFAULT_MIN_FONT_SIZE_PX = 14;
87
+ declare const FLEET_DEFAULT_MIN_STICKER_SIZE_PX = 28;
88
+ /** Fixed floors (px) for a text layer's background "pill" padding/corner radius. */
89
+ declare const FLEET_TEXT_PADDING_VERTICAL_PX = 8;
90
+ declare const FLEET_TEXT_PADDING_HORIZONTAL_PX = 12;
91
+ declare const FLEET_TEXT_BORDER_RADIUS_PX = 10;
92
+ //#endregion
93
+ //#region src/fonts.d.ts
94
+ interface FleetFontDescriptor {
95
+ style: FleetTextStyle;
96
+ weight: 400 | 700;
97
+ /** React Native `fontFamily` value (installed font name). */
98
+ reactNativeFontFamily: string;
99
+ /** CSS `font-family` value for web. */
100
+ cssFontFamily: string;
101
+ /** Font name to register/reference when rendering via satori. */
102
+ satoriFontName: string;
103
+ }
104
+ /**
105
+ * Canonical font mapping for the three shipped Fleet text styles' font. Unifies three previously
106
+ * divergent naming schemes (RN font family strings, Tailwind font classes, satori-registered names).
107
+ * `serif` intentionally maps to the same font as `default` until a dedicated serif asset exists.
108
+ */
109
+ declare const FLEET_FONTS: Record<FleetTextStyle, FleetFontDescriptor>;
110
+ declare const getFleetFontDescriptor: (style: FleetTextStyle) => FleetFontDescriptor;
111
+ //#endregion
112
+ //#region src/container-units.d.ts
113
+ interface ContainerUnits {
114
+ /** `value`% of the container width, in px. */
115
+ cqw: (value: number) => number;
116
+ /** `value`% of the container height, in px. */
117
+ cqh: (value: number) => number;
118
+ /** `value`% of `min(width, height)`, in px. */
119
+ cqmin: (value: number) => number;
120
+ /** `value`% of `max(width, height)`, in px. */
121
+ cqmax: (value: number) => number;
122
+ }
123
+ /**
124
+ * CSS container-query-unit equivalents (`cqw`/`cqh`/`cqmin`/`cqmax`) computed from a raw
125
+ * `{ width, height }` pair, so the same formulas work whether the size comes from a browser
126
+ * `ResizeObserver`, React Native's `onLayout`, or a fixed server-side render target.
127
+ */
128
+ declare const createContainerUnits: (container: ContainerSize) => ContainerUnits;
129
+ //#endregion
130
+ //#region src/placement.d.ts
131
+ interface PixelTransform {
132
+ /** Translation from container center, in px. */
133
+ translateX: number;
134
+ translateY: number;
135
+ scale: number;
136
+ rotationDeg: number;
137
+ }
138
+ /**
139
+ * Converts a normalized `FleetPlacement` into a container-relative pixel transform:
140
+ * `translate = (pos - 0.5) * containerSize`. This is the transform every layer kind
141
+ * (media/text/sticker) applies identically, anchored at the layer's own center.
142
+ */
143
+ declare const placementToPixelTransform: (placement: FleetPlacement, container: ContainerSize) => PixelTransform;
144
+ /**
145
+ * Inverse of {@link placementToPixelTransform}'s translation: converts a container-relative pixel
146
+ * offset (e.g. accumulated from a drag gesture, starting at the container center) back into a
147
+ * normalized `FleetPlacement`. Used by editors while a layer is being dragged/pinched/rotated.
148
+ */
149
+ declare const pixelOffsetToPlacement: (offsetXPx: number, offsetYPx: number, container: ContainerSize, scale: number, rotation: number) => FleetPlacement;
150
+ interface BoundingBoxPx {
151
+ width: number;
152
+ height: number;
153
+ }
154
+ /**
155
+ * Radius (px) of the circle that fully contains a `width` x `height` box at the given `scale`,
156
+ * regardless of rotation — i.e. the box's diagonal half-length. Rotation-safe by construction,
157
+ * since a box's bounding circle doesn't change as it spins about its own center.
158
+ */
159
+ declare const computeBoundingRadiusPx: (box: BoundingBoxPx, scale: number) => number;
160
+ interface ClampCenterToCanvasOptions {
161
+ centerXPx: number;
162
+ centerYPx: number;
163
+ radiusPx: number;
164
+ container: ContainerSize;
165
+ }
166
+ /**
167
+ * Pulls a layer's center inward, per axis, so its bounding circle never crosses the canvas edge.
168
+ * If the circle is larger than the canvas along an axis, that axis is centered instead of clamped.
169
+ */
170
+ declare const clampCenterToCanvas: ({ centerXPx, centerYPx, radiusPx, container }: ClampCenterToCanvasOptions) => {
171
+ x: number;
172
+ y: number;
173
+ };
174
+ /**
175
+ * Convenience wrapper combining {@link placementToPixelTransform}, {@link computeBoundingRadiusPx},
176
+ * and {@link clampCenterToCanvas}: returns a `FleetPlacement` whose `posX`/`posY` have been pulled
177
+ * inward so the box never visually clips the canvas edge. Scale/rotation pass through unchanged.
178
+ */
179
+ declare const clampPlacementToCanvas: (placement: FleetPlacement, box: BoundingBoxPx, container: ContainerSize) => FleetPlacement;
180
+ //#endregion
181
+ //#region src/layout.d.ts
182
+ interface ResolveFleetLayoutOptions {
183
+ /** Minimum text font size in px (floor). Set to `0` to disable. */
184
+ minFontSizePx?: number;
185
+ /** Minimum sticker size in px (floor). Set to `0` to disable. */
186
+ minStickerSizePx?: number;
187
+ }
188
+ interface ResolvedFleetMediaLayer {
189
+ type: "media";
190
+ transform: PixelTransform;
191
+ widthPx: number;
192
+ heightPx: number;
193
+ url: string;
194
+ alt?: string;
195
+ }
196
+ interface ResolvedFleetTextLayer {
197
+ type: "text";
198
+ key: string;
199
+ transform: PixelTransform;
200
+ body: string;
201
+ textAlignment: FleetTextAlignment;
202
+ color: string;
203
+ backgroundColor: string;
204
+ font: FleetFontDescriptor;
205
+ fontSizePx: number;
206
+ lineHeightPx: number;
207
+ paddingVerticalPx: number;
208
+ paddingHorizontalPx: number;
209
+ borderRadiusPx: number;
210
+ maxWidthPx: number;
211
+ }
212
+ interface ResolvedFleetStickerLayer {
213
+ type: "sticker";
214
+ key: string;
215
+ transform: PixelTransform;
216
+ emoji: string;
217
+ imageUrl?: string;
218
+ sizePx: number;
219
+ }
220
+ interface ResolvedFleetLayout {
221
+ backgroundColor: string;
222
+ container: ContainerSize;
223
+ media: ResolvedFleetMediaLayer | null;
224
+ texts: ResolvedFleetTextLayer[];
225
+ stickers: ResolvedFleetStickerLayer[];
226
+ }
227
+ /**
228
+ * The core layout engine: takes a platform-agnostic `FleetContentData` document plus the current
229
+ * container size, and returns fully-resolved pixel geometry for every layer (position, size, font
230
+ * size, padding, border radius). Adapters only need to map this to their own render primitives —
231
+ * no positioning/sizing math should live in an adapter.
232
+ */
233
+ declare const resolveFleetLayout: (content: FleetContentData, container: ContainerSize, options?: ResolveFleetLayoutOptions) => ResolvedFleetLayout;
234
+ //#endregion
235
+ export { BoundingBoxPx, ClampCenterToCanvasOptions, ContainerSize, ContainerUnits, FLEET_ASPECT_RATIO, FLEET_CANVAS_HEIGHT, FLEET_CANVAS_WIDTH, FLEET_DEFAULT_BACKGROUND_COLORS, FLEET_DEFAULT_MIN_FONT_SIZE_PX, FLEET_DEFAULT_MIN_STICKER_SIZE_PX, FLEET_DEFAULT_PLACEMENT, FLEET_FONTS, FLEET_MAX_STICKERS, FLEET_MAX_TEXTS, FLEET_MEDIA_SCALE_MAX, FLEET_MEDIA_SCALE_MIN, FLEET_ROTATION_UI_MAX, FLEET_ROTATION_UI_MIN, FLEET_TEXT_BORDER_RADIUS_PX, FLEET_TEXT_MAX_LENGTH, FLEET_TEXT_PADDING_HORIZONTAL_PX, FLEET_TEXT_PADDING_VERTICAL_PX, FLEET_TEXT_STICKER_SCALE_MAX, FLEET_TEXT_STICKER_SCALE_MIN, FleetContentData, FleetFontDescriptor, FleetMediaEntity, FleetPlacement, FleetStickerEntity, FleetTextAlignment, FleetTextEntity, FleetTextStyle, PixelTransform, ResolveFleetLayoutOptions, ResolvedFleetLayout, ResolvedFleetMediaLayer, ResolvedFleetStickerLayer, ResolvedFleetTextLayer, clampCenterToCanvas, clampPlacementToCanvas, computeBoundingRadiusPx, createContainerUnits, getFleetFontDescriptor, pixelOffsetToPlacement, placementToPixelTransform, resolveFleetLayout };
@@ -0,0 +1,235 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * A Fleet text layer's visual style. `serif` currently has no dedicated font asset upstream and
4
+ * falls back to the same font as `default` — kept as a distinct value since the data model (and
5
+ * the eventual dedicated font) already exists.
6
+ */
7
+ type FleetTextStyle = "default" | "bold" | "serif" | "handwriting";
8
+ type FleetTextAlignment = "left" | "center" | "right";
9
+ /**
10
+ * Normalized position/transform shared by every layer kind (media, text, sticker).
11
+ * `posX`/`posY` are fractional coordinates where `0.5` is the container center; a layer's own
12
+ * center is placed at that fraction, then scaled and rotated about its own center.
13
+ *
14
+ * Unlike text/sticker layers (which are expected to stay within `[0, 1]`), media placement is
15
+ * intentionally allowed to range outside `[0, 1]` to support pannable/zoomable background crop.
16
+ */
17
+ interface FleetPlacement {
18
+ posX: number;
19
+ posY: number;
20
+ scale: number;
21
+ rotation: number;
22
+ }
23
+ interface FleetMediaEntity {
24
+ url: string;
25
+ alt?: string;
26
+ /** Intrinsic width/height, used to preserve aspect ratio. Falls back to the container's own aspect ratio when absent. */
27
+ width?: number;
28
+ height?: number;
29
+ placement?: FleetPlacement;
30
+ }
31
+ interface FleetTextEntity extends FleetPlacement {
32
+ id?: string;
33
+ body: string;
34
+ textStyle: FleetTextStyle;
35
+ textAlignment: FleetTextAlignment;
36
+ color: string;
37
+ /** Hex color or `"transparent"`. */
38
+ backgroundColor?: string;
39
+ }
40
+ interface FleetStickerEntity extends FleetPlacement {
41
+ id?: string;
42
+ /** Unicode emoji codepoints, or a reaction "symbol" resolved to an image via `resolveStickerImageUrl`. */
43
+ emoji: string;
44
+ /** Pre-resolved image URL, when the consumer already knows it. */
45
+ imageUrl?: string;
46
+ }
47
+ /** The platform-agnostic "Fleet document" — everything needed to lay out and render a single Fleet. */
48
+ interface FleetContentData {
49
+ backgroundColor: string;
50
+ media: FleetMediaEntity | null;
51
+ texts: FleetTextEntity[];
52
+ stickers: FleetStickerEntity[];
53
+ }
54
+ interface ContainerSize {
55
+ width: number;
56
+ height: number;
57
+ }
58
+ //#endregion
59
+ //#region src/constants.d.ts
60
+ /** Fleet canvases are always rendered/edited at a fixed 9:16 portrait aspect ratio. */
61
+ declare const FLEET_ASPECT_RATIO: number;
62
+ /** Reference output resolution used by the server-side renderer (also a good default preview canvas size). */
63
+ declare const FLEET_CANVAS_WIDTH = 1080;
64
+ declare const FLEET_CANVAS_HEIGHT = 1920;
65
+ /** Scale bounds for text and sticker layers, matching the server-enforced schema. */
66
+ declare const FLEET_TEXT_STICKER_SCALE_MIN = 0.1;
67
+ declare const FLEET_TEXT_STICKER_SCALE_MAX = 5;
68
+ /** Scale bounds for the media (background) layer — allowed to zoom out further to support crop/pan editing. */
69
+ declare const FLEET_MEDIA_SCALE_MIN = 0.05;
70
+ declare const FLEET_MEDIA_SCALE_MAX = 5;
71
+ /** Rotation range exposed by the compose UI (the server schema itself allows -360..360). */
72
+ declare const FLEET_ROTATION_UI_MIN = -180;
73
+ declare const FLEET_ROTATION_UI_MAX = 180;
74
+ /** Server-enforced maximums (`FLEET_SCHEMA` in teyvat's `foreign-model`). */
75
+ declare const FLEET_MAX_TEXTS = 10;
76
+ declare const FLEET_MAX_STICKERS = 20;
77
+ declare const FLEET_TEXT_MAX_LENGTH = 500;
78
+ declare const FLEET_DEFAULT_BACKGROUND_COLORS: readonly string[];
79
+ declare const FLEET_DEFAULT_PLACEMENT: {
80
+ readonly posX: 0.5;
81
+ readonly posY: 0.5;
82
+ readonly scale: 1;
83
+ readonly rotation: 0;
84
+ };
85
+ /** Minimum on-screen sizes (px) so text/stickers stay legible on small containers. Set to `0` to disable. */
86
+ declare const FLEET_DEFAULT_MIN_FONT_SIZE_PX = 14;
87
+ declare const FLEET_DEFAULT_MIN_STICKER_SIZE_PX = 28;
88
+ /** Fixed floors (px) for a text layer's background "pill" padding/corner radius. */
89
+ declare const FLEET_TEXT_PADDING_VERTICAL_PX = 8;
90
+ declare const FLEET_TEXT_PADDING_HORIZONTAL_PX = 12;
91
+ declare const FLEET_TEXT_BORDER_RADIUS_PX = 10;
92
+ //#endregion
93
+ //#region src/fonts.d.ts
94
+ interface FleetFontDescriptor {
95
+ style: FleetTextStyle;
96
+ weight: 400 | 700;
97
+ /** React Native `fontFamily` value (installed font name). */
98
+ reactNativeFontFamily: string;
99
+ /** CSS `font-family` value for web. */
100
+ cssFontFamily: string;
101
+ /** Font name to register/reference when rendering via satori. */
102
+ satoriFontName: string;
103
+ }
104
+ /**
105
+ * Canonical font mapping for the three shipped Fleet text styles' font. Unifies three previously
106
+ * divergent naming schemes (RN font family strings, Tailwind font classes, satori-registered names).
107
+ * `serif` intentionally maps to the same font as `default` until a dedicated serif asset exists.
108
+ */
109
+ declare const FLEET_FONTS: Record<FleetTextStyle, FleetFontDescriptor>;
110
+ declare const getFleetFontDescriptor: (style: FleetTextStyle) => FleetFontDescriptor;
111
+ //#endregion
112
+ //#region src/container-units.d.ts
113
+ interface ContainerUnits {
114
+ /** `value`% of the container width, in px. */
115
+ cqw: (value: number) => number;
116
+ /** `value`% of the container height, in px. */
117
+ cqh: (value: number) => number;
118
+ /** `value`% of `min(width, height)`, in px. */
119
+ cqmin: (value: number) => number;
120
+ /** `value`% of `max(width, height)`, in px. */
121
+ cqmax: (value: number) => number;
122
+ }
123
+ /**
124
+ * CSS container-query-unit equivalents (`cqw`/`cqh`/`cqmin`/`cqmax`) computed from a raw
125
+ * `{ width, height }` pair, so the same formulas work whether the size comes from a browser
126
+ * `ResizeObserver`, React Native's `onLayout`, or a fixed server-side render target.
127
+ */
128
+ declare const createContainerUnits: (container: ContainerSize) => ContainerUnits;
129
+ //#endregion
130
+ //#region src/placement.d.ts
131
+ interface PixelTransform {
132
+ /** Translation from container center, in px. */
133
+ translateX: number;
134
+ translateY: number;
135
+ scale: number;
136
+ rotationDeg: number;
137
+ }
138
+ /**
139
+ * Converts a normalized `FleetPlacement` into a container-relative pixel transform:
140
+ * `translate = (pos - 0.5) * containerSize`. This is the transform every layer kind
141
+ * (media/text/sticker) applies identically, anchored at the layer's own center.
142
+ */
143
+ declare const placementToPixelTransform: (placement: FleetPlacement, container: ContainerSize) => PixelTransform;
144
+ /**
145
+ * Inverse of {@link placementToPixelTransform}'s translation: converts a container-relative pixel
146
+ * offset (e.g. accumulated from a drag gesture, starting at the container center) back into a
147
+ * normalized `FleetPlacement`. Used by editors while a layer is being dragged/pinched/rotated.
148
+ */
149
+ declare const pixelOffsetToPlacement: (offsetXPx: number, offsetYPx: number, container: ContainerSize, scale: number, rotation: number) => FleetPlacement;
150
+ interface BoundingBoxPx {
151
+ width: number;
152
+ height: number;
153
+ }
154
+ /**
155
+ * Radius (px) of the circle that fully contains a `width` x `height` box at the given `scale`,
156
+ * regardless of rotation — i.e. the box's diagonal half-length. Rotation-safe by construction,
157
+ * since a box's bounding circle doesn't change as it spins about its own center.
158
+ */
159
+ declare const computeBoundingRadiusPx: (box: BoundingBoxPx, scale: number) => number;
160
+ interface ClampCenterToCanvasOptions {
161
+ centerXPx: number;
162
+ centerYPx: number;
163
+ radiusPx: number;
164
+ container: ContainerSize;
165
+ }
166
+ /**
167
+ * Pulls a layer's center inward, per axis, so its bounding circle never crosses the canvas edge.
168
+ * If the circle is larger than the canvas along an axis, that axis is centered instead of clamped.
169
+ */
170
+ declare const clampCenterToCanvas: ({ centerXPx, centerYPx, radiusPx, container }: ClampCenterToCanvasOptions) => {
171
+ x: number;
172
+ y: number;
173
+ };
174
+ /**
175
+ * Convenience wrapper combining {@link placementToPixelTransform}, {@link computeBoundingRadiusPx},
176
+ * and {@link clampCenterToCanvas}: returns a `FleetPlacement` whose `posX`/`posY` have been pulled
177
+ * inward so the box never visually clips the canvas edge. Scale/rotation pass through unchanged.
178
+ */
179
+ declare const clampPlacementToCanvas: (placement: FleetPlacement, box: BoundingBoxPx, container: ContainerSize) => FleetPlacement;
180
+ //#endregion
181
+ //#region src/layout.d.ts
182
+ interface ResolveFleetLayoutOptions {
183
+ /** Minimum text font size in px (floor). Set to `0` to disable. */
184
+ minFontSizePx?: number;
185
+ /** Minimum sticker size in px (floor). Set to `0` to disable. */
186
+ minStickerSizePx?: number;
187
+ }
188
+ interface ResolvedFleetMediaLayer {
189
+ type: "media";
190
+ transform: PixelTransform;
191
+ widthPx: number;
192
+ heightPx: number;
193
+ url: string;
194
+ alt?: string;
195
+ }
196
+ interface ResolvedFleetTextLayer {
197
+ type: "text";
198
+ key: string;
199
+ transform: PixelTransform;
200
+ body: string;
201
+ textAlignment: FleetTextAlignment;
202
+ color: string;
203
+ backgroundColor: string;
204
+ font: FleetFontDescriptor;
205
+ fontSizePx: number;
206
+ lineHeightPx: number;
207
+ paddingVerticalPx: number;
208
+ paddingHorizontalPx: number;
209
+ borderRadiusPx: number;
210
+ maxWidthPx: number;
211
+ }
212
+ interface ResolvedFleetStickerLayer {
213
+ type: "sticker";
214
+ key: string;
215
+ transform: PixelTransform;
216
+ emoji: string;
217
+ imageUrl?: string;
218
+ sizePx: number;
219
+ }
220
+ interface ResolvedFleetLayout {
221
+ backgroundColor: string;
222
+ container: ContainerSize;
223
+ media: ResolvedFleetMediaLayer | null;
224
+ texts: ResolvedFleetTextLayer[];
225
+ stickers: ResolvedFleetStickerLayer[];
226
+ }
227
+ /**
228
+ * The core layout engine: takes a platform-agnostic `FleetContentData` document plus the current
229
+ * container size, and returns fully-resolved pixel geometry for every layer (position, size, font
230
+ * size, padding, border radius). Adapters only need to map this to their own render primitives —
231
+ * no positioning/sizing math should live in an adapter.
232
+ */
233
+ declare const resolveFleetLayout: (content: FleetContentData, container: ContainerSize, options?: ResolveFleetLayoutOptions) => ResolvedFleetLayout;
234
+ //#endregion
235
+ export { BoundingBoxPx, ClampCenterToCanvasOptions, ContainerSize, ContainerUnits, FLEET_ASPECT_RATIO, FLEET_CANVAS_HEIGHT, FLEET_CANVAS_WIDTH, FLEET_DEFAULT_BACKGROUND_COLORS, FLEET_DEFAULT_MIN_FONT_SIZE_PX, FLEET_DEFAULT_MIN_STICKER_SIZE_PX, FLEET_DEFAULT_PLACEMENT, FLEET_FONTS, FLEET_MAX_STICKERS, FLEET_MAX_TEXTS, FLEET_MEDIA_SCALE_MAX, FLEET_MEDIA_SCALE_MIN, FLEET_ROTATION_UI_MAX, FLEET_ROTATION_UI_MIN, FLEET_TEXT_BORDER_RADIUS_PX, FLEET_TEXT_MAX_LENGTH, FLEET_TEXT_PADDING_HORIZONTAL_PX, FLEET_TEXT_PADDING_VERTICAL_PX, FLEET_TEXT_STICKER_SCALE_MAX, FLEET_TEXT_STICKER_SCALE_MIN, FleetContentData, FleetFontDescriptor, FleetMediaEntity, FleetPlacement, FleetStickerEntity, FleetTextAlignment, FleetTextEntity, FleetTextStyle, PixelTransform, ResolveFleetLayoutOptions, ResolvedFleetLayout, ResolvedFleetMediaLayer, ResolvedFleetStickerLayer, ResolvedFleetTextLayer, clampCenterToCanvas, clampPlacementToCanvas, computeBoundingRadiusPx, createContainerUnits, getFleetFontDescriptor, pixelOffsetToPlacement, placementToPixelTransform, resolveFleetLayout };
package/dist/index.mjs ADDED
@@ -0,0 +1,222 @@
1
+ //#region src/constants.ts
2
+ /** Fleet canvases are always rendered/edited at a fixed 9:16 portrait aspect ratio. */
3
+ const FLEET_ASPECT_RATIO = 9 / 16;
4
+ /** Reference output resolution used by the server-side renderer (also a good default preview canvas size). */
5
+ const FLEET_CANVAS_WIDTH = 1080;
6
+ const FLEET_CANVAS_HEIGHT = 1920;
7
+ /** Scale bounds for text and sticker layers, matching the server-enforced schema. */
8
+ const FLEET_TEXT_STICKER_SCALE_MIN = .1;
9
+ const FLEET_TEXT_STICKER_SCALE_MAX = 5;
10
+ /** Scale bounds for the media (background) layer — allowed to zoom out further to support crop/pan editing. */
11
+ const FLEET_MEDIA_SCALE_MIN = .05;
12
+ const FLEET_MEDIA_SCALE_MAX = 5;
13
+ /** Rotation range exposed by the compose UI (the server schema itself allows -360..360). */
14
+ const FLEET_ROTATION_UI_MIN = -180;
15
+ const FLEET_ROTATION_UI_MAX = 180;
16
+ /** Server-enforced maximums (`FLEET_SCHEMA` in teyvat's `foreign-model`). */
17
+ const FLEET_MAX_TEXTS = 10;
18
+ const FLEET_MAX_STICKERS = 20;
19
+ const FLEET_TEXT_MAX_LENGTH = 500;
20
+ const FLEET_DEFAULT_BACKGROUND_COLORS = [
21
+ "#000000",
22
+ "#1a1a2e",
23
+ "#0d3b66",
24
+ "#1b4332",
25
+ "#7b2d8b",
26
+ "#c0392b",
27
+ "#e67e22",
28
+ "#ffffff"
29
+ ];
30
+ const FLEET_DEFAULT_PLACEMENT = {
31
+ posX: .5,
32
+ posY: .5,
33
+ scale: 1,
34
+ rotation: 0
35
+ };
36
+ /** Minimum on-screen sizes (px) so text/stickers stay legible on small containers. Set to `0` to disable. */
37
+ const FLEET_DEFAULT_MIN_FONT_SIZE_PX = 14;
38
+ const FLEET_DEFAULT_MIN_STICKER_SIZE_PX = 28;
39
+ /** Fixed floors (px) for a text layer's background "pill" padding/corner radius. */
40
+ const FLEET_TEXT_PADDING_VERTICAL_PX = 8;
41
+ const FLEET_TEXT_PADDING_HORIZONTAL_PX = 12;
42
+ const FLEET_TEXT_BORDER_RADIUS_PX = 10;
43
+ //#endregion
44
+ //#region src/fonts.ts
45
+ /**
46
+ * Canonical font mapping for the three shipped Fleet text styles' font. Unifies three previously
47
+ * divergent naming schemes (RN font family strings, Tailwind font classes, satori-registered names).
48
+ * `serif` intentionally maps to the same font as `default` until a dedicated serif asset exists.
49
+ */
50
+ const FLEET_FONTS = {
51
+ default: {
52
+ style: "default",
53
+ weight: 400,
54
+ reactNativeFontFamily: "Noto Sans JP Regular",
55
+ cssFontFamily: "Noto Sans JP",
56
+ satoriFontName: "Noto Sans JP"
57
+ },
58
+ bold: {
59
+ style: "bold",
60
+ weight: 700,
61
+ reactNativeFontFamily: "Noto Sans JP Bold",
62
+ cssFontFamily: "Noto Sans JP",
63
+ satoriFontName: "Noto Sans JP"
64
+ },
65
+ serif: {
66
+ style: "serif",
67
+ weight: 400,
68
+ reactNativeFontFamily: "Noto Sans JP Regular",
69
+ cssFontFamily: "Noto Sans JP",
70
+ satoriFontName: "Noto Sans JP"
71
+ },
72
+ handwriting: {
73
+ style: "handwriting",
74
+ weight: 400,
75
+ reactNativeFontFamily: "HunyaJi-Re",
76
+ cssFontFamily: "HunyaJi-Re",
77
+ satoriFontName: "HunyaJi-Re"
78
+ }
79
+ };
80
+ const getFleetFontDescriptor = (style) => FLEET_FONTS[style];
81
+ //#endregion
82
+ //#region src/container-units.ts
83
+ /**
84
+ * CSS container-query-unit equivalents (`cqw`/`cqh`/`cqmin`/`cqmax`) computed from a raw
85
+ * `{ width, height }` pair, so the same formulas work whether the size comes from a browser
86
+ * `ResizeObserver`, React Native's `onLayout`, or a fixed server-side render target.
87
+ */
88
+ const createContainerUnits = (container) => ({
89
+ cqw: (value) => container.width * value / 100,
90
+ cqh: (value) => container.height * value / 100,
91
+ cqmin: (value) => Math.min(container.width, container.height) * value / 100,
92
+ cqmax: (value) => Math.max(container.width, container.height) * value / 100
93
+ });
94
+ //#endregion
95
+ //#region src/placement.ts
96
+ /**
97
+ * Converts a normalized `FleetPlacement` into a container-relative pixel transform:
98
+ * `translate = (pos - 0.5) * containerSize`. This is the transform every layer kind
99
+ * (media/text/sticker) applies identically, anchored at the layer's own center.
100
+ */
101
+ const placementToPixelTransform = (placement, container) => ({
102
+ translateX: (placement.posX - .5) * container.width,
103
+ translateY: (placement.posY - .5) * container.height,
104
+ scale: placement.scale,
105
+ rotationDeg: placement.rotation
106
+ });
107
+ /**
108
+ * Inverse of {@link placementToPixelTransform}'s translation: converts a container-relative pixel
109
+ * offset (e.g. accumulated from a drag gesture, starting at the container center) back into a
110
+ * normalized `FleetPlacement`. Used by editors while a layer is being dragged/pinched/rotated.
111
+ */
112
+ const pixelOffsetToPlacement = (offsetXPx, offsetYPx, container, scale, rotation) => ({
113
+ posX: container.width > 0 ? offsetXPx / container.width + .5 : .5,
114
+ posY: container.height > 0 ? offsetYPx / container.height + .5 : .5,
115
+ scale,
116
+ rotation
117
+ });
118
+ /**
119
+ * Radius (px) of the circle that fully contains a `width` x `height` box at the given `scale`,
120
+ * regardless of rotation — i.e. the box's diagonal half-length. Rotation-safe by construction,
121
+ * since a box's bounding circle doesn't change as it spins about its own center.
122
+ */
123
+ const computeBoundingRadiusPx = (box, scale) => Math.hypot(box.width / 2, box.height / 2) * scale;
124
+ /**
125
+ * Pulls a layer's center inward, per axis, so its bounding circle never crosses the canvas edge.
126
+ * If the circle is larger than the canvas along an axis, that axis is centered instead of clamped.
127
+ */
128
+ const clampCenterToCanvas = ({ centerXPx, centerYPx, radiusPx, container }) => {
129
+ const clampAxis = (center, size) => {
130
+ if (radiusPx * 2 >= size) return size / 2;
131
+ return Math.min(Math.max(center, radiusPx), size - radiusPx);
132
+ };
133
+ return {
134
+ x: clampAxis(centerXPx, container.width),
135
+ y: clampAxis(centerYPx, container.height)
136
+ };
137
+ };
138
+ /**
139
+ * Convenience wrapper combining {@link placementToPixelTransform}, {@link computeBoundingRadiusPx},
140
+ * and {@link clampCenterToCanvas}: returns a `FleetPlacement` whose `posX`/`posY` have been pulled
141
+ * inward so the box never visually clips the canvas edge. Scale/rotation pass through unchanged.
142
+ */
143
+ const clampPlacementToCanvas = (placement, box, container) => {
144
+ const transform = placementToPixelTransform(placement, container);
145
+ const radiusPx = computeBoundingRadiusPx(box, placement.scale);
146
+ const centerXPx = container.width / 2 + transform.translateX;
147
+ const centerYPx = container.height / 2 + transform.translateY;
148
+ const clamped = clampCenterToCanvas({
149
+ centerXPx,
150
+ centerYPx,
151
+ radiusPx,
152
+ container
153
+ });
154
+ return {
155
+ ...placement,
156
+ posX: container.width > 0 ? clamped.x / container.width : placement.posX,
157
+ posY: container.height > 0 ? clamped.y / container.height : placement.posY
158
+ };
159
+ };
160
+ //#endregion
161
+ //#region src/layout.ts
162
+ /**
163
+ * The core layout engine: takes a platform-agnostic `FleetContentData` document plus the current
164
+ * container size, and returns fully-resolved pixel geometry for every layer (position, size, font
165
+ * size, padding, border radius). Adapters only need to map this to their own render primitives —
166
+ * no positioning/sizing math should live in an adapter.
167
+ */
168
+ const resolveFleetLayout = (content, container, options = {}) => {
169
+ const units = createContainerUnits(container);
170
+ const minFontSizePx = options.minFontSizePx ?? 14;
171
+ const minStickerSizePx = options.minStickerSizePx ?? 28;
172
+ return {
173
+ backgroundColor: content.backgroundColor,
174
+ container,
175
+ media: resolveMediaLayer(content.media, container, units),
176
+ texts: content.texts.map((text, index) => resolveTextLayer(text, index, container, units, minFontSizePx)),
177
+ stickers: content.stickers.map((sticker, index) => resolveStickerLayer(sticker, index, container, units, minStickerSizePx))
178
+ };
179
+ };
180
+ const resolveMediaLayer = (media, container, units) => {
181
+ if (!media) return null;
182
+ const placement = media.placement ?? FLEET_DEFAULT_PLACEMENT;
183
+ const widthPx = units.cqw(100);
184
+ const heightPx = widthPx * (media.height ?? container.height) / (media.width ?? container.width);
185
+ return {
186
+ type: "media",
187
+ transform: placementToPixelTransform(placement, container),
188
+ widthPx,
189
+ heightPx,
190
+ url: media.url,
191
+ alt: media.alt
192
+ };
193
+ };
194
+ const resolveTextLayer = (text, index, container, units, minFontSizePx) => {
195
+ const fontSizePx = Math.max(units.cqh(3), minFontSizePx);
196
+ return {
197
+ type: "text",
198
+ key: text.id ?? `${index}-${text.body}`,
199
+ transform: placementToPixelTransform(text, container),
200
+ body: text.body,
201
+ textAlignment: text.textAlignment,
202
+ color: text.color,
203
+ backgroundColor: text.backgroundColor ?? "transparent",
204
+ font: FLEET_FONTS[text.textStyle],
205
+ fontSizePx,
206
+ lineHeightPx: fontSizePx * 1.25,
207
+ paddingVerticalPx: Math.max(units.cqh(1.2), 8),
208
+ paddingHorizontalPx: Math.max(units.cqw(3.2), 12),
209
+ borderRadiusPx: Math.max(units.cqw(1.5), 10),
210
+ maxWidthPx: units.cqw(90)
211
+ };
212
+ };
213
+ const resolveStickerLayer = (sticker, index, container, units, minStickerSizePx) => ({
214
+ type: "sticker",
215
+ key: sticker.id ?? `${index}-${sticker.emoji}`,
216
+ transform: placementToPixelTransform(sticker, container),
217
+ emoji: sticker.emoji,
218
+ imageUrl: sticker.imageUrl,
219
+ sizePx: Math.max(units.cqw(8), minStickerSizePx)
220
+ });
221
+ //#endregion
222
+ export { FLEET_ASPECT_RATIO, FLEET_CANVAS_HEIGHT, FLEET_CANVAS_WIDTH, FLEET_DEFAULT_BACKGROUND_COLORS, FLEET_DEFAULT_MIN_FONT_SIZE_PX, FLEET_DEFAULT_MIN_STICKER_SIZE_PX, FLEET_DEFAULT_PLACEMENT, FLEET_FONTS, FLEET_MAX_STICKERS, FLEET_MAX_TEXTS, FLEET_MEDIA_SCALE_MAX, FLEET_MEDIA_SCALE_MIN, FLEET_ROTATION_UI_MAX, FLEET_ROTATION_UI_MIN, FLEET_TEXT_BORDER_RADIUS_PX, FLEET_TEXT_MAX_LENGTH, FLEET_TEXT_PADDING_HORIZONTAL_PX, FLEET_TEXT_PADDING_VERTICAL_PX, FLEET_TEXT_STICKER_SCALE_MAX, FLEET_TEXT_STICKER_SCALE_MIN, clampCenterToCanvas, clampPlacementToCanvas, computeBoundingRadiusPx, createContainerUnits, getFleetFontDescriptor, pixelOffsetToPlacement, placementToPixelTransform, resolveFleetLayout };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@natsuneko-laboratory/fleet-renderer-core",
3
+ "version": "0.1.0",
4
+ "description": "Platform-agnostic types, constants, and layout engine for Fleet rendering",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "keywords": [
19
+ "fleet",
20
+ "catalyst",
21
+ "renderer",
22
+ "layout"
23
+ ],
24
+ "repository": {
25
+ "url": "https://github.com/mika-f/fleet-renderer/"
26
+ },
27
+ "license": "MIT",
28
+ "devDependencies": {
29
+ "tsdown": "^0.22.7",
30
+ "typescript": "^5.6.0",
31
+ "vitest": "^2.0.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "tsdown src/index.ts --format cjs,esm --dts",
38
+ "dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
39
+ "test": "vitest run",
40
+ "lint": "tsc --noEmit"
41
+ }
42
+ }