@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 +21 -0
- package/dist/index.cjs +250 -0
- package/dist/index.d.cts +235 -0
- package/dist/index.d.mts +235 -0
- package/dist/index.mjs +222 -0
- package/package.json +42 -0
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.mts
ADDED
|
@@ -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
|
+
}
|