@appshoteditor/shot-dsl 0.5.0 → 0.5.2

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 CHANGED
@@ -64,7 +64,9 @@ validateTemplate(template); // { valid: true, errors: [] }
64
64
  bleeds decisively: it grows toward the 90%-of-W cap, then shifts down. It clears with the
65
65
  margin only when every ≥ 12% bleed would crop its focus band.
66
66
  - `none`: always fully visible.
67
- - `deep`: bleed ≥ 25%.
67
+ - `deep`: grows the subject toward a 25% bleed (still capped at 90% W). When growth alone can't
68
+ reach 25% (the size cap bites first), it shifts down too — but never further than a 12% bleed
69
+ needs, even though the target is 25%: it never shifts deeper just to force that target.
68
70
  3. **No side tangents.** No subject is wider than 90% of W (`NO_TANGENT.maxWidth`). The part that is
69
71
  actually on the canvas keeps ≥ 5% of W from both side edges. The only side bleed is a deliberate
70
72
  panorama straddle across a seam.
@@ -174,11 +176,14 @@ Output shapes:
174
176
  - **Callouts** (`screen.callout` crop, or `style.callouts: "auto"` from a focus band ≤ 50% tall): an
175
177
  image layer with the SAME uploaded `src` + `cropX`/`cropY`, rounded, shadowed, magnified 1.6–2.2×
176
178
  (down to 1.25× to fit), inside 3.5% side margins, between the text block and a 4% far-edge margin.
177
- It may cover its own source slice (the pop-out), but it hides at most `CALLOUT_MAX_FOCUS_COVER`
178
- (35%) of the REST of the focus band: it is centred on its source when that allows, else moved
179
- up/down or out to a side edge (breaking out of the device), else made smaller; if nothing works it
180
- is skipped (`callout-skipped`). `report.screens[i].callout.focusCover` is the share hidden. Never on
181
- zoom cards or a straddling device.
179
+ The requested crop is automatically padded, on whichever sides have room inside the screenshot,
180
+ before it's magnified, so the rounded corners of the card don't clip content sitting at the
181
+ requested crop's own corners (e.g. a selection handle); a side with no room (flush against the
182
+ screenshot edge) can still clip there. It may cover its own source slice (the pop-out), but
183
+ it hides at most `CALLOUT_MAX_FOCUS_COVER` (35%) of the REST of the focus band: it is centred on its
184
+ source when that allows, else moved up/down or out to a side edge (breaking out of the device), else
185
+ made smaller; if nothing works it is skipped (`callout-skipped`). `report.screens[i].callout.focusCover`
186
+ is the share hidden. Never on zoom cards or a straddling device.
182
187
  - **Mascots** (`plan.art` + `screen.mascot` / `style.hero.mascot`): an image layer (uploaded src,
183
188
  `flipX` to face into the canvas when `faces` is given, shadow). Anchors `headline` / `device-top` /
184
189
  `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.0",
4
- "description": "App Shot Editor layout DSL + device-frame geometry \u2014 framework-free building blocks for composing editable App Store screenshot layouts. Intended for use via a bundler (Vite, esbuild, etc.).",
3
+ "version": "0.5.2",
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
@@ -295,6 +295,12 @@ export interface ComposeScreenMetrics {
295
295
  headlineSize: number;
296
296
  /** Headline box top ÷ H (identical across a set by construction). */
297
297
  headlineTop: number;
298
+ /**
299
+ * Bottom of the reserved text block (headline + subheadline + badge row) ÷ H. For `text-bottom`
300
+ * layouts the text block sits near the canvas bottom (not the top), so `top − textBottom` (the
301
+ * subject's top minus this) isn't a meaningful "gap under the text" for that layout.
302
+ */
303
+ textBottom: number;
298
304
  tilt: number;
299
305
  /** Horizontal centre ÷ W (≠ 0.5 for a straddling panorama device). */
300
306
  centerX: number;
@@ -397,7 +403,8 @@ const BADGE_MAX_CHARS = 28;
397
403
 
398
404
  // Frameless / zoom styling.
399
405
  const FRAMELESS_WIDTH = 0.9; // × the layout's device width target (no bezel → a touch narrower)
400
- const FRAMELESS_RADIUS = 0.1; // × rendered width
406
+ const FRAMELESS_RADIUS = 0.1; // × rendered width — fallback, and the cap for `frameCornerRadiusRatio`
407
+ const FRAMELESS_RADIUS_MIN = 0.03; // × rendered width — floor for a device with squarer screen corners (e.g. iPad)
401
408
  const ZOOM_WIDTH = 0.88; // × W
402
409
  const ZOOM_RADIUS = 0.05; // × W
403
410
  const ZOOM_MIN_ASPECT = 0.5; // card height ≥ half its width
@@ -419,6 +426,15 @@ export const CALLOUT_MAG = { min: 1.6, max: 2.2, floor: 1.25 } as const;
419
426
  */
420
427
  export const CALLOUT_MAX_FOCUS_COVER = 0.35;
421
428
  const CALLOUT_RADIUS = 0.035; // × W
429
+ /**
430
+ * Padding added around the requested callout crop (screenshot px), as a multiple of the rounded
431
+ * mask's LOCAL corner radius (`CALLOUT_RADIUS * W`, converted to the crop's own pixel space — see
432
+ * `calloutGeometry`), so the rounded corners of the clip mask (`makeCalloutLayer`) don't cut into
433
+ * content at the requested crop's own corners (e.g. selection handles). The geometric minimum to
434
+ * keep a corner point inside a rounded corner of radius `rx` is `1 - 1/√2 ≈ 0.293`; the extra margin
435
+ * covers the fact that padding the crop shrinks the eventual magnification a little further.
436
+ */
437
+ const CALLOUT_CORNER_PAD = 0.35;
422
438
  // Auto crop: fractions of the screenshot width (left-aligned — UI rows start at the left, so a cut
423
439
  // at the right loses a chevron, not the first letters of a label); card h ÷ w.
424
440
  const CALLOUT_AUTO = { x: 0.02, w: 0.64, aspect: 0.4 };
@@ -1041,6 +1057,19 @@ function shadowFor(W: number, scale: number, look: Look, spec: { blur: number; o
1041
1057
  return { color: look.shadowColor, blur: (spec.blur * W) / scale, offsetX: 0, offsetY: (spec.offsetY * W) / scale };
1042
1058
  }
1043
1059
 
1060
+ /**
1061
+ * Non-zoom frameless corner radius, as a ratio of the rendered width: the device's own screen corner
1062
+ * radius (relative to its screen width), clamped to [FRAMELESS_RADIUS_MIN, FRAMELESS_RADIUS]. A flat
1063
+ * FRAMELESS_RADIUS reads fine on an iPhone (its screen corners are nearly that round) but is far too
1064
+ * round on an iPad (squarer corners, wide screen) — round enough to eat into the status bar. Falls
1065
+ * back to FRAMELESS_RADIUS when the device is unknown.
1066
+ */
1067
+ function frameCornerRadiusRatio(deviceId: string): number {
1068
+ const device = getDeviceFrame(deviceId);
1069
+ if (!device) return FRAMELESS_RADIUS;
1070
+ return Math.min(FRAMELESS_RADIUS, Math.max(FRAMELESS_RADIUS_MIN, device.cornerRadius / device.screenBounds.width));
1071
+ }
1072
+
1044
1073
  /** Frameless / zoom subject: the uploaded screenshot as a plain image layer, rounded + shadowed. */
1045
1074
  function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look): LayerJSON {
1046
1075
  const id = generateLayerId();
@@ -1048,7 +1077,7 @@ function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement, look: Look):
1048
1077
  const zoom = p.zoom;
1049
1078
  const width = zoom ? zoom.cropW : shot.width;
1050
1079
  const height = zoom ? zoom.cropH : shot.height;
1051
- const radius = zoom ? ZOOM_RADIUS * r.W : FRAMELESS_RADIUS * width * p.scale;
1080
+ const radius = zoom ? ZOOM_RADIUS * r.W : frameCornerRadiusRatio(r.plan.deviceId) * width * p.scale;
1052
1081
  const fabricData: Record<string, unknown> = {
1053
1082
  type: 'image',
1054
1083
  src: shot.url,
@@ -1578,7 +1607,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
1578
1607
  rects: TextRects;
1579
1608
  text: string;
1580
1609
  subText?: string;
1581
- callout?: { rect: Rect; mag: number; crop: { x: number; y: number; w: number; h: number }; scale: number; focusCover: number };
1610
+ 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
1611
  focusCore?: Rect;
1583
1612
  mascot?: { rect: Rect; scale: number; flip: boolean; art: ComposeArt; seamPartner?: number; dx?: number };
1584
1613
  }
@@ -2000,6 +2029,7 @@ export function composeSet(plan: ComposePlan): { template: Template; report: Com
2000
2029
  tangent: inTangentZone(overshoot, p.boxHeight, H),
2001
2030
  headlineSize: typo.headlineSize / W,
2002
2031
  headlineTop: g.headlineTop / H,
2032
+ textBottom: (g.areaTop + typo.textArea) / H,
2003
2033
  tilt: p.angle,
2004
2034
  centerX: cx / W,
2005
2035
  role: r.role,
@@ -2101,21 +2131,49 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
2101
2131
  const req = r.calloutReq!;
2102
2132
  const { W, H } = r;
2103
2133
  const shot = r.plan.screenshot;
2104
- const crop = { x: req.x * shot.width, y: req.y * shot.height, w: req.w * shot.width, h: req.h * shot.height };
2134
+ const requested = { x: req.x * shot.width, y: req.y * shot.height, w: req.w * shot.width, h: req.h * shot.height };
2105
2135
  const scr = p.subject.screen;
2106
2136
  // Canvas px per screenshot px as the editor fits it into the screen bounds (frameless: the image).
2107
2137
  const screenScale = (p.scale * scr.width) / shot.width;
2108
- const centre = subjectPointToCanvas(
2109
- p.subject,
2110
- { x: scr.x + ((crop.x + crop.w / 2) / shot.width) * scr.width, y: scr.y + ((crop.y + crop.h / 2) / shot.height) * scr.height },
2111
- { cx, cy: p.cy, scale: p.scale, angle: p.angle }
2112
- );
2138
+ // Vertical band the card must fit in — computed before padding since it only depends on the text
2139
+ // layout, not the crop, and the padding estimate below needs it too (the same vertical fit cap
2140
+ // applies to both).
2113
2141
  const texts = textRectList(rects);
2114
2142
  const gap = CALLOUT_TEXT_GAP * H;
2115
2143
  const [lo, hi] =
2116
2144
  r.layout === 'text-bottom'
2117
2145
  ? [CALLOUT_EDGE * H, Math.min(...texts.map((t) => t.top)) - gap]
2118
2146
  : [Math.max(...texts.map((t) => t.bottom)) + gap, H * (1 - CALLOUT_EDGE)];
2147
+ // Pad the requested crop on sides that have room inside the screenshot, so its own corners land
2148
+ // inside the rounded clip mask (`makeCalloutLayer`) instead of being cut off there. `pad` is sized
2149
+ // from a PRELIMINARY magnification: the same formula AND the same fit caps (side margins, vertical
2150
+ // room) used below, run on the unpadded crop — since the final mag depends on the padded crop's own
2151
+ // size, this is a deterministic chicken-and-egg break, not the exact final mag. The placement
2152
+ // search below can still shrink the final mag further (retrying smaller sizes when every position
2153
+ // hides too much of the focus band), so `makeCalloutLayer` also hard-caps the mask's own corner
2154
+ // radius as a backstop — the padding here just keeps that cap from binding in the common case.
2155
+ const baseWReq = requested.w * screenScale;
2156
+ const baseHReq = requested.h * screenScale;
2157
+ let prelimMag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseWReq));
2158
+ prelimMag = Math.min(prelimMag, ((1 - 2 * CALLOUT_MARGIN) * W) / baseWReq, (hi - lo) / baseHReq);
2159
+ // Guard against a degenerate (non-positive) estimate — e.g. no vertical room at all — blowing up
2160
+ // `pad`; the real computation below bails out (`callout-skipped`) in that same case anyway, so the
2161
+ // crop's exact shape no longer matters once that happens.
2162
+ prelimMag = Math.max(prelimMag, 1e-6);
2163
+ const pad = (CALLOUT_CORNER_PAD * CALLOUT_RADIUS * W) / (screenScale * prelimMag);
2164
+ const padX0 = Math.max(0, requested.x - pad);
2165
+ const padY0 = Math.max(0, requested.y - pad);
2166
+ const crop = {
2167
+ x: padX0,
2168
+ y: padY0,
2169
+ w: Math.min(shot.width, requested.x + requested.w + pad) - padX0,
2170
+ h: Math.min(shot.height, requested.y + requested.h + pad) - padY0
2171
+ };
2172
+ const centre = subjectPointToCanvas(
2173
+ p.subject,
2174
+ { x: scr.x + ((crop.x + crop.w / 2) / shot.width) * scr.width, y: scr.y + ((crop.y + crop.h / 2) / shot.height) * scr.height },
2175
+ { cx, cy: p.cy, scale: p.scale, angle: p.angle }
2176
+ );
2119
2177
  const baseW = crop.w * screenScale;
2120
2178
  const baseH = crop.h * screenScale;
2121
2179
  let mag = Math.min(CALLOUT_MAG.max, Math.max(CALLOUT_MAG.min, (CALLOUT_TARGET_WIDTH * W) / baseW));
@@ -2165,7 +2223,7 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
2165
2223
  }
2166
2224
  }
2167
2225
  }
2168
- if (bestRect) return { rect: bestRect, mag: k, crop, scale: screenScale * k, focusCover: cover(bestRect) };
2226
+ if (bestRect) return { rect: bestRect, mag: k, crop, requested, scale: screenScale * k, focusCover: cover(bestRect) };
2169
2227
  if (k <= CALLOUT_MAG.floor) break;
2170
2228
  }
2171
2229
  warnings.push({
@@ -2178,7 +2236,24 @@ function calloutGeometry(r: ResolvedScreen, p: Placement, cx: number, rects: Tex
2178
2236
 
2179
2237
  function makeCalloutLayer(r: ResolvedScreen, c: NonNullable<ReturnType<typeof calloutGeometry>>, look: Look): LayerJSON {
2180
2238
  const id = generateLayerId();
2181
- const radius = CALLOUT_RADIUS * r.W;
2239
+ // Hard guarantee, independent of how `calloutGeometry`'s padding estimate turned out (it's only a
2240
+ // preliminary-magnification guess — the placement search can shrink the final mag further): the
2241
+ // mask's corner radius can never clip the REQUESTED crop's own corners. On each side that had room
2242
+ // to pad (not clamped flush to the screenshot edge — a clamped side had no padding and was always
2243
+ // going to clip there, mask or no mask), the actual padding applied is the gap between `crop` and
2244
+ // `requested`. The geometric minimum margin to keep a corner inside a rounded corner of local
2245
+ // radius `rx` is `(1 − 1/√2) · rx`, so `rx` is capped at the smallest such padding ÷ (1 − 1/√2) —
2246
+ // converted back to a canvas-unit radius via `c.scale`. If every side is clamped (the crop already
2247
+ // spans the screenshot), there's no padding to guarantee anything from, so the normal radius stands.
2248
+ const shot = r.plan.screenshot;
2249
+ const EPS = 1e-6;
2250
+ const pads: number[] = [];
2251
+ if (c.crop.x > EPS) pads.push(c.requested.x - c.crop.x);
2252
+ if (c.crop.y > EPS) pads.push(c.requested.y - c.crop.y);
2253
+ if (c.crop.x + c.crop.w < shot.width - EPS) pads.push(c.crop.x + c.crop.w - (c.requested.x + c.requested.w));
2254
+ if (c.crop.y + c.crop.h < shot.height - EPS) pads.push(c.crop.y + c.crop.h - (c.requested.y + c.requested.h));
2255
+ const CORNER_INSET = 1 - 1 / Math.SQRT2; // ≈0.293 — geometric minimum margin to keep a corner inside a rounded corner of radius rx
2256
+ const radius = pads.length ? Math.min(CALLOUT_RADIUS * r.W, (Math.min(...pads) / CORNER_INSET) * c.scale) : CALLOUT_RADIUS * r.W;
2182
2257
  const fabricData: Record<string, unknown> = {
2183
2258
  type: 'image',
2184
2259
  src: r.plan.screenshot.url,
@@ -17,7 +17,11 @@ export const NO_TANGENT = {
17
17
  clearGap: 0.04,
18
18
  /** A subject that bleeds must lose at least this × its own (rotated) height. */
19
19
  minBleed: 0.12,
20
- /** `bleed: "deep"` target, × the subject's height. */
20
+ /**
21
+ * `bleed: "deep"` target, × the subject's height. Reached by growing the subject (up to
22
+ * `maxWidth`·W) — never by shifting it down past a decisive (`minBleed`) overshoot, since a
23
+ * further down-shift would just be empty space between the text and the subject.
24
+ */
21
25
  deepBleed: 0.25,
22
26
  /** The focus band must end at least this × H inside the canvas edge. */
23
27
  focusSafe: 0.02,
@@ -207,10 +211,15 @@ export interface VerticalResult {
207
211
  * ≥ `clearGap`·H or bleeds by ≥ `minBleed` of the subject's height — never in between — and never
208
212
  * pushes the focus band off-canvas. Options:
209
213
  * - CLEAR: keep the near edge, shrink until the far edge clears with the margin.
210
- * - BLEED: grow (up to `maxWidth`·W), then shift toward the far edge, until the overshoot reaches
211
- * the target; if that hides the focus band, the largest focus-safe bleed ≥ `minBleed` is used,
212
- * else the bleed option is unavailable.
213
- * `none` → always clear. `deep` → bleed ≥ `deepBleed` (or the most the focus allows), else clear.
214
+ * - BLEED: grow (up to its size cap) toward the target overshoot; only when growth alone can't get
215
+ * there (the size cap bites first) does the subject shift toward the far edge, and then only far
216
+ * enough for a decisive (`minBleed`) overshoot — never further just to reach a deeper target, which
217
+ * would otherwise leave empty space between the text and the subject. If the result hides the focus
218
+ * band, the placement instead targets the smallest decisive bleed (`minBleed`) that stays focus-safe
219
+ * — or near0 itself, when near0 already sits further down than that — else the bleed option is
220
+ * unavailable.
221
+ * `none` → always clear. `deep` → grow toward `deepBleed` (≥ `minBleed` at the width cap, ≥ `deepBleed`
222
+ * whenever growth alone reaches it), or the most the focus allows, else clear.
214
223
  * `auto` → keep the natural placement when it already clears by the margin or is a focus-safe
215
224
  * decisive bleed; otherwise (the tangent zone) PREFER the bleed option, and fall back to clear only
216
225
  * when every ≥ `minBleed` bleed would crop the focus band. `preferBleed` (tilt) also turns a natural
@@ -240,24 +249,36 @@ export function solveVertical(inp: VerticalInput): VerticalResult {
240
249
  return make(s, near0, s < s0 - EPS ? 'shrunk to clear the edge with a margin' : 'clears the edge');
241
250
  };
242
251
 
243
- /** Smallest move reaching overshoot ≥ t·height, then made focus-safe (null if impossible). */
252
+ /**
253
+ * Smallest move reaching overshoot ≥ t·height, then made focus-safe (null if impossible). Growing
254
+ * the subject (up to its size cap — the width cap, or a tighter `scaleCap` when one binds) is
255
+ * always tried first. Only when that growth is capped short of t does the subject shift toward the
256
+ * far edge — and even then, only far enough for a decisive (`minBleed`) overshoot, never all the
257
+ * way to t: a deeper target like `deepBleed` is something to grow toward, not something worth
258
+ * shifting the subject away from the text to force.
259
+ */
244
260
  const bleedOption = (t: number): VerticalResult | null => {
245
261
  let s = s0;
246
262
  let a = near0;
247
263
  if (overshoot(s, a) < t * s * eh - EPS) {
248
264
  const need = (H - a) / ((1 - t) * eh);
249
265
  s = Math.max(s0, Math.min(need, Math.max(sCap, s0)));
250
- if (overshoot(s, a) < t * s * eh - EPS) a = H - s * eh * (1 - t);
266
+ if (overshoot(s, a) < NO_TANGENT.minBleed * s * eh - EPS) a = H - s * eh * (1 - NO_TANGENT.minBleed);
251
267
  }
252
- if (focusOk(s, a)) return make(s, a, `bleeds ${Math.round(t * 100)}%+`);
268
+ const reason =
269
+ overshoot(s, a) >= t * s * eh - EPS
270
+ ? `bleeds ${Math.round(t * 100)}%+`
271
+ : `grows to its size cap (no gap forced to reach ${Math.round(t * 100)}%+)`;
272
+ if (focusOk(s, a)) return make(s, a, reason);
253
273
  // Focus-constrained: keep the scale (≤ what the focus allows at near0) and bleed only as deep
254
- // as the focus band allows — accepted if that is still a decisive (≥ minBleed) bleed.
274
+ // as the focus band allows — accepted if that is still a decisive (≥ minBleed) bleed. The shift
275
+ // itself only ever targets minBleed (never t), for the same no-gap reason as above.
255
276
  if (inp.focusReach == null) return null;
256
277
  const fr = inp.focusReach;
257
278
  const sF = Math.min(s, Math.max(sCap, s0), (focusLimit - near0) / fr);
258
279
  const denom = eh * (1 - NO_TANGENT.minBleed) - fr;
259
280
  if (!(sF > 0) || denom <= 0 || sF < (H - focusLimit) / denom - EPS) return null;
260
- const aF = Math.max(near0, Math.min(focusLimit - sF * fr, H - sF * eh * (1 - t)));
281
+ const aF = Math.max(near0, Math.min(focusLimit - sF * fr, H - sF * eh * (1 - NO_TANGENT.minBleed)));
261
282
  if (overshoot(sF, aF) < NO_TANGENT.minBleed * sF * eh - EPS || !focusOk(sF, aF)) return null;
262
283
  return make(sF, aF, 'bleed reduced to keep the focus band visible');
263
284
  };