@appshoteditor/shot-dsl 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/package.json +2 -2
- package/src/compose.ts +79 -11
package/README.md
CHANGED
|
@@ -174,11 +174,14 @@ Output shapes:
|
|
|
174
174
|
- **Callouts** (`screen.callout` crop, or `style.callouts: "auto"` from a focus band ≤ 50% tall): an
|
|
175
175
|
image layer with the SAME uploaded `src` + `cropX`/`cropY`, rounded, shadowed, magnified 1.6–2.2×
|
|
176
176
|
(down to 1.25× to fit), inside 3.5% side margins, between the text block and a 4% far-edge margin.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
The requested crop is automatically padded, on whichever sides have room inside the screenshot,
|
|
178
|
+
before it's magnified, so the rounded corners of the card don't clip content sitting at the
|
|
179
|
+
requested crop's own corners (e.g. a selection handle); a side with no room (flush against the
|
|
180
|
+
screenshot edge) can still clip there. It may cover its own source slice (the pop-out), but
|
|
181
|
+
it hides at most `CALLOUT_MAX_FOCUS_COVER` (35%) of the REST of the focus band: it is centred on its
|
|
182
|
+
source when that allows, else moved up/down or out to a side edge (breaking out of the device), else
|
|
183
|
+
made smaller; if nothing works it is skipped (`callout-skipped`). `report.screens[i].callout.focusCover`
|
|
184
|
+
is the share hidden. Never on zoom cards or a straddling device.
|
|
182
185
|
- **Mascots** (`plan.art` + `screen.mascot` / `style.hero.mascot`): an image layer (uploaded src,
|
|
183
186
|
`flipX` to face into the canvas when `faces` is given, shadow). Anchors `headline` / `device-top` /
|
|
184
187
|
`device-side` / `seam` are tried in order with fallbacks at 100/85/70% size; a placement must clear
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appshoteditor/shot-dsl",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "App Shot Editor layout DSL + device-frame geometry
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "App Shot Editor layout DSL + device-frame geometry — framework-free building blocks for composing editable App Store screenshot layouts. Intended for use via a bundler (Vite, esbuild, etc.).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.ts",
|
package/src/compose.ts
CHANGED
|
@@ -397,7 +397,8 @@ const BADGE_MAX_CHARS = 28;
|
|
|
397
397
|
|
|
398
398
|
// Frameless / zoom styling.
|
|
399
399
|
const FRAMELESS_WIDTH = 0.9; // × the layout's device width target (no bezel → a touch narrower)
|
|
400
|
-
const FRAMELESS_RADIUS = 0.1; // × rendered width
|
|
400
|
+
const FRAMELESS_RADIUS = 0.1; // × rendered width — fallback, and the cap for `frameCornerRadiusRatio`
|
|
401
|
+
const FRAMELESS_RADIUS_MIN = 0.03; // × rendered width — floor for a device with squarer screen corners (e.g. iPad)
|
|
401
402
|
const ZOOM_WIDTH = 0.88; // × W
|
|
402
403
|
const ZOOM_RADIUS = 0.05; // × W
|
|
403
404
|
const ZOOM_MIN_ASPECT = 0.5; // card height ≥ half its width
|
|
@@ -419,6 +420,15 @@ export const CALLOUT_MAG = { min: 1.6, max: 2.2, floor: 1.25 } as const;
|
|
|
419
420
|
*/
|
|
420
421
|
export const CALLOUT_MAX_FOCUS_COVER = 0.35;
|
|
421
422
|
const CALLOUT_RADIUS = 0.035; // × W
|
|
423
|
+
/**
|
|
424
|
+
* Padding added around the requested callout crop (screenshot px), as a multiple of the rounded
|
|
425
|
+
* mask's LOCAL corner radius (`CALLOUT_RADIUS * W`, converted to the crop's own pixel space — see
|
|
426
|
+
* `calloutGeometry`), so the rounded corners of the clip mask (`makeCalloutLayer`) don't cut into
|
|
427
|
+
* content at the requested crop's own corners (e.g. selection handles). The geometric minimum to
|
|
428
|
+
* keep a corner point inside a rounded corner of radius `rx` is `1 - 1/√2 ≈ 0.293`; the extra margin
|
|
429
|
+
* covers the fact that padding the crop shrinks the eventual magnification a little further.
|
|
430
|
+
*/
|
|
431
|
+
const CALLOUT_CORNER_PAD = 0.35;
|
|
422
432
|
// Auto crop: fractions of the screenshot width (left-aligned — UI rows start at the left, so a cut
|
|
423
433
|
// at the right loses a chevron, not the first letters of a label); card h ÷ w.
|
|
424
434
|
const CALLOUT_AUTO = { x: 0.02, w: 0.64, aspect: 0.4 };
|
|
@@ -1041,6 +1051,19 @@ function shadowFor(W: number, scale: number, look: Look, spec: { blur: number; o
|
|
|
1041
1051
|
return { color: look.shadowColor, blur: (spec.blur * W) / scale, offsetX: 0, offsetY: (spec.offsetY * W) / scale };
|
|
1042
1052
|
}
|
|
1043
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* Non-zoom frameless corner radius, as a ratio of the rendered width: the device's own screen corner
|
|
1056
|
+
* radius (relative to its screen width), clamped to [FRAMELESS_RADIUS_MIN, FRAMELESS_RADIUS]. A flat
|
|
1057
|
+
* FRAMELESS_RADIUS reads fine on an iPhone (its screen corners are nearly that round) but is far too
|
|
1058
|
+
* round on an iPad (squarer corners, wide screen) — round enough to eat into the status bar. Falls
|
|
1059
|
+
* back to FRAMELESS_RADIUS when the device is unknown.
|
|
1060
|
+
*/
|
|
1061
|
+
function frameCornerRadiusRatio(deviceId: string): number {
|
|
1062
|
+
const device = getDeviceFrame(deviceId);
|
|
1063
|
+
if (!device) return FRAMELESS_RADIUS;
|
|
1064
|
+
return Math.min(FRAMELESS_RADIUS, Math.max(FRAMELESS_RADIUS_MIN, device.cornerRadius / device.screenBounds.width));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1044
1067
|
/** Frameless / zoom subject: the uploaded screenshot as a plain image layer, rounded + shadowed. */
|
|
1045
1068
|
function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look): LayerJSON {
|
|
1046
1069
|
const id = generateLayerId();
|
|
@@ -1048,7 +1071,7 @@ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look):
|
|
|
1048
1071
|
const zoom = p.zoom;
|
|
1049
1072
|
const width = zoom ? zoom.cropW : shot.width;
|
|
1050
1073
|
const height = zoom ? zoom.cropH : shot.height;
|
|
1051
|
-
const radius = zoom ? ZOOM_RADIUS * r.W :
|
|
1074
|
+
const radius = zoom ? ZOOM_RADIUS * r.W : frameCornerRadiusRatio(r.plan.deviceId) * width * p.scale;
|
|
1052
1075
|
const fabricData: Record<string, unknown> = {
|
|
1053
1076
|
type: 'image',
|
|
1054
1077
|
src: shot.url,
|
|
@@ -1578,7 +1601,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
|
|
|
1578
1601
|
rects: TextRects;
|
|
1579
1602
|
text: string;
|
|
1580
1603
|
subText?: string;
|
|
1581
|
-
callout?: { rect: Rect; mag: number; crop: { x: number; y: number; w: number; h: number }; scale: number; focusCover: number };
|
|
1604
|
+
callout?: { rect: Rect; mag: number; crop: { x: number; y: number; w: number; h: number }; requested: { x: number; y: number; w: number; h: number }; scale: number; focusCover: number };
|
|
1582
1605
|
focusCore?: Rect;
|
|
1583
1606
|
mascot?: { rect: Rect; scale: number; flip: boolean; art: ComposeArt; seamPartner?: number; dx?: number };
|
|
1584
1607
|
}
|
|
@@ -2101,21 +2124,49 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
|
|
|
2101
2124
|
const req = r.calloutReq!;
|
|
2102
2125
|
const { W, H } = r;
|
|
2103
2126
|
const shot = r.plan.screenshot;
|
|
2104
|
-
const
|
|
2127
|
+
const requested = { x: req.x * shot.width, y: req.y * shot.height, w: req.w * shot.width, h: req.h * shot.height };
|
|
2105
2128
|
const scr = p.subject.screen;
|
|
2106
2129
|
// Canvas px per screenshot px as the editor fits it into the screen bounds (frameless: the image).
|
|
2107
2130
|
const screenScale = (p.scale * scr.width) / shot.width;
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
{ cx, cy: p.cy, scale: p.scale, angle: p.angle }
|
|
2112
|
-
);
|
|
2131
|
+
// Vertical band the card must fit in — computed before padding since it only depends on the text
|
|
2132
|
+
// layout, not the crop, and the padding estimate below needs it too (the same vertical fit cap
|
|
2133
|
+
// applies to both).
|
|
2113
2134
|
const texts = textRectList(rects);
|
|
2114
2135
|
const gap = CALLOUT_TEXT_GAP * H;
|
|
2115
2136
|
const [lo, hi] =
|
|
2116
2137
|
r.layout === 'text-bottom'
|
|
2117
2138
|
? [CALLOUT_EDGE * H, Math.min(...texts.map((t) => t.top)) - gap]
|
|
2118
2139
|
: [Math.max(...texts.map((t) => t.bottom)) + gap, H * (1 - CALLOUT_EDGE)];
|
|
2140
|
+
// Pad the requested crop on sides that have room inside the screenshot, so its own corners land
|
|
2141
|
+
// inside the rounded clip mask (`makeCalloutLayer`) instead of being cut off there. `pad` is sized
|
|
2142
|
+
// from a PRELIMINARY magnification: the same formula AND the same fit caps (side margins, vertical
|
|
2143
|
+
// room) used below, run on the unpadded crop — since the final mag depends on the padded crop's own
|
|
2144
|
+
// size, this is a deterministic chicken-and-egg break, not the exact final mag. The placement
|
|
2145
|
+
// search below can still shrink the final mag further (retrying smaller sizes when every position
|
|
2146
|
+
// hides too much of the focus band), so `makeCalloutLayer` also hard-caps the mask's own corner
|
|
2147
|
+
// radius as a backstop — the padding here just keeps that cap from binding in the common case.
|
|
2148
|
+
const baseWReq = requested.w * screenScale;
|
|
2149
|
+
const baseHReq = requested.h * screenScale;
|
|
2150
|
+
let prelimMag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseWReq));
|
|
2151
|
+
prelimMag = Math.min(prelimMag, ((1 - 2 * CALLOUT_MARGIN) * W) / baseWReq, (hi - lo) / baseHReq);
|
|
2152
|
+
// Guard against a degenerate (non-positive) estimate — e.g. no vertical room at all — blowing up
|
|
2153
|
+
// `pad`; the real computation below bails out (`callout-skipped`) in that same case anyway, so the
|
|
2154
|
+
// crop's exact shape no longer matters once that happens.
|
|
2155
|
+
prelimMag = Math.max(prelimMag, 1e-6);
|
|
2156
|
+
const pad = (CALLOUT_CORNER_PAD * CALLOUT_RADIUS * W) / (screenScale * prelimMag);
|
|
2157
|
+
const padX0 = Math.max(0, requested.x - pad);
|
|
2158
|
+
const padY0 = Math.max(0, requested.y - pad);
|
|
2159
|
+
const crop = {
|
|
2160
|
+
x: padX0,
|
|
2161
|
+
y: padY0,
|
|
2162
|
+
w: Math.min(shot.width, requested.x + requested.w + pad) - padX0,
|
|
2163
|
+
h: Math.min(shot.height, requested.y + requested.h + pad) - padY0
|
|
2164
|
+
};
|
|
2165
|
+
const centre = subjectPointToCanvas(
|
|
2166
|
+
p.subject,
|
|
2167
|
+
{ x: scr.x + ((crop.x + crop.w / 2) / shot.width) * scr.width, y: scr.y + ((crop.y + crop.h / 2) / shot.height) * scr.height },
|
|
2168
|
+
{ cx, cy: p.cy, scale: p.scale, angle: p.angle }
|
|
2169
|
+
);
|
|
2119
2170
|
const baseW = crop.w * screenScale;
|
|
2120
2171
|
const baseH = crop.h * screenScale;
|
|
2121
2172
|
let mag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseW));
|
|
@@ -2165,7 +2216,7 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
|
|
|
2165
2216
|
}
|
|
2166
2217
|
}
|
|
2167
2218
|
}
|
|
2168
|
-
if (bestRect) return { rect: bestRect, mag: k, crop, scale: screenScale * k, focusCover: cover(bestRect) };
|
|
2219
|
+
if (bestRect) return { rect: bestRect, mag: k, crop, requested, scale: screenScale * k, focusCover: cover(bestRect) };
|
|
2169
2220
|
if (k <= CALLOUT_MAG.floor) break;
|
|
2170
2221
|
}
|
|
2171
2222
|
warnings.push({
|
|
@@ -2178,7 +2229,24 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
|
|
|
2178
2229
|
|
|
2179
2230
|
function makeCalloutLayer(r: ResolvedScreen, c: NonNullable<ReturnType<typeof calloutGeometry>>, look: Look): LayerJSON {
|
|
2180
2231
|
const id = generateLayerId();
|
|
2181
|
-
|
|
2232
|
+
// Hard guarantee, independent of how `calloutGeometry`'s padding estimate turned out (it's only a
|
|
2233
|
+
// preliminary-magnification guess — the placement search can shrink the final mag further): the
|
|
2234
|
+
// mask's corner radius can never clip the REQUESTED crop's own corners. On each side that had room
|
|
2235
|
+
// to pad (not clamped flush to the screenshot edge — a clamped side had no padding and was always
|
|
2236
|
+
// going to clip there, mask or no mask), the actual padding applied is the gap between `crop` and
|
|
2237
|
+
// `requested`. The geometric minimum margin to keep a corner inside a rounded corner of local
|
|
2238
|
+
// radius `rx` is `(1 − 1/√2) · rx`, so `rx` is capped at the smallest such padding ÷ (1 − 1/√2) —
|
|
2239
|
+
// converted back to a canvas-unit radius via `c.scale`. If every side is clamped (the crop already
|
|
2240
|
+
// spans the screenshot), there's no padding to guarantee anything from, so the normal radius stands.
|
|
2241
|
+
const shot = r.plan.screenshot;
|
|
2242
|
+
const EPS = 1e-6;
|
|
2243
|
+
const pads: number[] = [];
|
|
2244
|
+
if (c.crop.x > EPS) pads.push(c.requested.x - c.crop.x);
|
|
2245
|
+
if (c.crop.y > EPS) pads.push(c.requested.y - c.crop.y);
|
|
2246
|
+
if (c.crop.x + c.crop.w < shot.width - EPS) pads.push(c.crop.x + c.crop.w - (c.requested.x + c.requested.w));
|
|
2247
|
+
if (c.crop.y + c.crop.h < shot.height - EPS) pads.push(c.crop.y + c.crop.h - (c.requested.y + c.requested.h));
|
|
2248
|
+
const CORNER_INSET = 1 - 1 / Math.SQRT2; // ≈0.293 — geometric minimum margin to keep a corner inside a rounded corner of radius rx
|
|
2249
|
+
const radius = pads.length ? Math.min(CALLOUT_RADIUS * r.W, (Math.min(...pads) / CORNER_INSET) * c.scale) : CALLOUT_RADIUS * r.W;
|
|
2182
2250
|
const fabricData: Record<string, unknown> = {
|
|
2183
2251
|
type: 'image',
|
|
2184
2252
|
src: r.plan.screenshot.url,
|