@appshoteditor/shot-dsl 0.2.0 → 0.4.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/src/frames.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getDeviceFrame, type DeviceFrame } from './device-frames';
2
- import type { LayerJSON } from './types';
2
+ import type { DeviceScreenshotJSON, LayerJSON } from './types';
3
3
  import { generateLayerId } from './validate';
4
4
 
5
5
  const DEFAULT_CANVAS_WIDTH = 280;
@@ -24,30 +24,31 @@ export function calculateDeviceScale(
24
24
  }
25
25
 
26
26
  /**
27
- * Build the paired { screenshot, frame } layers for a device mockup — a Fabric-free
28
- * reproduction of FabricCanvas.addDeviceFrame(). Place them in a screen's `layers`
29
- * array in this order (screenshot below the frame).
27
+ * Build a device-mockup layer — a Fabric-free reproduction of the frame half of
28
+ * FabricCanvas.addDeviceFrame(). The screenshot rides on the frame as
29
+ * `fabricData.screenshot` (see `DeviceScreenshotJSON`); the editor places + clips it
30
+ * under the frame on import (same path as dropping a screenshot onto a frame), so no
31
+ * screenshot geometry or clip path is emitted here.
30
32
  *
31
33
  * `screenshotWidth`/`screenshotHeight` are the screenshot's natural pixel size
32
- * (the composer supplies them from the uploaded asset's stored dimensions).
33
- *
34
- * NOTE: geometry matches the editor exactly; the precise Fabric serialization shape
35
- * (esp. clipPath) should be confirmed by round-tripping a composed device template
36
- * through the editor before the skill relies on it in production.
34
+ * (the composer supplies them from the uploaded asset's stored dimensions) and are
35
+ * required with `screenshotUrl` (throws otherwise). Omit `screenshotUrl` for an empty frame.
37
36
  */
38
- export function makeDeviceFrameLayers(opts: {
37
+ export function makeDeviceFrameLayer(opts: {
39
38
  deviceId: string;
40
- screenshotUrl: string;
41
- screenshotWidth: number;
42
- screenshotHeight: number;
39
+ screenshotUrl?: string;
40
+ screenshotWidth?: number;
41
+ screenshotHeight?: number;
43
42
  canvasWidth?: number;
44
43
  canvasHeight?: number;
45
44
  centerX?: number;
46
45
  centerY?: number;
47
46
  scale?: number;
48
47
  screenshotRotation?: number;
48
+ /** Clockwise tilt of the whole mockup in degrees (the editor rotates the screenshot with it). */
49
+ angle?: number;
49
50
  name?: string;
50
- }): { screenshot: LayerJSON; frame: LayerJSON } {
51
+ }): LayerJSON {
51
52
  const device = getDeviceFrame(opts.deviceId);
52
53
  if (!device) throw new Error(`Unknown device: ${opts.deviceId}`);
53
54
 
@@ -56,8 +57,83 @@ export function makeDeviceFrameLayers(opts: {
56
57
  const scale = opts.scale ?? calculateDeviceScale(device, canvasWidth, canvasHeight);
57
58
 
58
59
  const frameId = `device-frame-${generateLayerId()}`;
59
- const screenshotId = generateLayerId();
60
60
 
61
+ const fabricData: Record<string, unknown> = {
62
+ type: 'image',
63
+ src: device.frameAsset,
64
+ crossOrigin: 'anonymous',
65
+ left: opts.centerX ?? canvasWidth / 2,
66
+ top: opts.centerY ?? canvasHeight / 2,
67
+ width: device.imageDimensions.width,
68
+ height: device.imageDimensions.height,
69
+ scaleX: scale,
70
+ scaleY: scale,
71
+ originX: 'center',
72
+ originY: 'center',
73
+ layerId: frameId,
74
+ layerType: 'deviceFrame',
75
+ deviceFrameId: frameId,
76
+ layerRole: 'frame',
77
+ deviceId: opts.deviceId,
78
+ deviceScale: scale
79
+ };
80
+ if (opts.angle) fabricData.angle = opts.angle;
81
+
82
+ if (opts.screenshotUrl) {
83
+ const { screenshotWidth: width, screenshotHeight: height } = opts;
84
+ if (!(typeof width === 'number' && width > 0 && typeof height === 'number' && height > 0)) {
85
+ throw new Error('makeDeviceFrameLayer: screenshotWidth/screenshotHeight (> 0) are required with screenshotUrl');
86
+ }
87
+ const screenshot: DeviceScreenshotJSON = { src: opts.screenshotUrl, width, height };
88
+ if (opts.screenshotRotation) screenshot.rotation = opts.screenshotRotation;
89
+ fabricData.screenshot = screenshot;
90
+ }
91
+
92
+ return {
93
+ id: frameId,
94
+ name: opts.name ?? device.name,
95
+ type: 'device',
96
+ visible: true,
97
+ locked: false,
98
+ fabricData
99
+ };
100
+ }
101
+
102
+ /** Where a frame currently sits on the canvas (a Fabric object's relevant props). */
103
+ export interface DeviceFramePose {
104
+ left?: number;
105
+ top?: number;
106
+ scaleX?: number;
107
+ scaleY?: number;
108
+ angle?: number;
109
+ /** The frame's base scale when it was created (`fabricData.deviceScale`). */
110
+ deviceScale?: number;
111
+ }
112
+
113
+ /** Fabric props for a screenshot placed inside a frame; `clip` is a centered Rect in the image's local space. */
114
+ export interface ScreenshotPlacement {
115
+ left: number;
116
+ top: number;
117
+ scaleX: number;
118
+ scaleY: number;
119
+ angle: number;
120
+ clip: { width: number; height: number; rx: number; ry: number };
121
+ }
122
+
123
+ /**
124
+ * THE screenshot-in-frame geometry: fit a screenshot of natural size `natural` to the
125
+ * device's screen bounds for a frame at `frame`, optionally rotated by `rotation`
126
+ * (0/90/180/270 — 90/270 swap the effective dimensions), with a rounded clip matching
127
+ * `device.cornerRadius`. Used by the editor (FabricCanvas.updateDeviceFrameScreenshot)
128
+ * and the offscreen thumbnail/export renderers so all three place screenshots identically.
129
+ */
130
+ export function computeScreenshotPlacement(
131
+ device: DeviceFrame,
132
+ frame: DeviceFramePose,
133
+ natural: { width: number; height: number },
134
+ rotation = 0
135
+ ): ScreenshotPlacement {
136
+ const scale = frame.deviceScale || 0.15;
61
137
  const scaledImageWidth = device.imageDimensions.width * scale;
62
138
  const scaledImageHeight = device.imageDimensions.height * scale;
63
139
  const screenWidth = device.screenBounds.width * scale;
@@ -66,84 +142,51 @@ export function makeDeviceFrameLayers(opts: {
66
142
  const screenOffsetY = device.screenBounds.y * scale;
67
143
  const cornerRadius = device.cornerRadius * scale;
68
144
 
69
- const frameCenterX = opts.centerX ?? canvasWidth / 2;
70
- const frameCenterY = opts.centerY ?? canvasHeight / 2;
145
+ const naturalWidth = natural.width || 1;
146
+ const naturalHeight = natural.height || 1;
71
147
 
72
- // Screen center relative to the frame center (same formula as addDeviceFrame).
73
- const screenCenterX = frameCenterX - scaledImageWidth / 2 + screenOffsetX + screenWidth / 2;
74
- const screenCenterY = frameCenterY - scaledImageHeight / 2 + screenOffsetY + screenHeight / 2;
148
+ // When rotated 90 or 270, the effective dimensions are swapped.
149
+ const isRotated90or270 = rotation === 90 || rotation === 270;
150
+ const effectiveWidth = isRotated90or270 ? naturalHeight : naturalWidth;
151
+ const effectiveHeight = isRotated90or270 ? naturalWidth : naturalHeight;
75
152
 
76
- // Scale the screenshot to exactly fill the screen bounds.
77
- const imgScaleX = screenWidth / opts.screenshotWidth;
78
- const imgScaleY = screenHeight / opts.screenshotHeight;
153
+ // Scale to fit the screen bounds using the effective dimensions.
154
+ const imgScaleX = screenWidth / effectiveWidth;
155
+ const imgScaleY = screenHeight / effectiveHeight;
79
156
 
80
- const screenshot: LayerJSON = {
81
- id: screenshotId,
82
- name: opts.name ? `${opts.name} screenshot` : 'Screenshot',
83
- type: 'image',
84
- visible: true,
85
- locked: false,
86
- fabricData: {
87
- type: 'image',
88
- src: opts.screenshotUrl,
89
- crossOrigin: 'anonymous',
90
- left: screenCenterX,
91
- top: screenCenterY,
92
- width: opts.screenshotWidth,
93
- height: opts.screenshotHeight,
94
- scaleX: imgScaleX,
95
- scaleY: imgScaleY,
96
- originX: 'center',
97
- originY: 'center',
98
- selectable: false,
99
- evented: false,
100
- // clipPath is in the image's local (unscaled) coordinate space.
101
- clipPath: {
102
- type: 'Rect',
103
- width: opts.screenshotWidth,
104
- height: opts.screenshotHeight,
105
- rx: cornerRadius / imgScaleX,
106
- ry: cornerRadius / imgScaleY,
107
- left: 0,
108
- top: 0,
109
- originX: 'center',
110
- originY: 'center'
111
- },
112
- layerId: screenshotId,
113
- layerType: 'image',
114
- deviceFrameId: frameId,
115
- layerRole: 'screenshot',
116
- deviceId: opts.deviceId,
117
- screenshotRotation: opts.screenshotRotation ?? 0
118
- }
119
- };
157
+ const frameLeft = frame.left || 0;
158
+ const frameTop = frame.top || 0;
159
+ const frameScaleX = frame.scaleX || 1;
160
+ const frameScaleY = frame.scaleY || 1;
161
+ const frameAngle = frame.angle || 0;
120
162
 
121
- const frame: LayerJSON = {
122
- id: frameId,
123
- name: opts.name ?? device.name,
124
- type: 'device',
125
- visible: true,
126
- locked: false,
127
- fabricData: {
128
- type: 'image',
129
- src: device.frameAsset,
130
- crossOrigin: 'anonymous',
131
- left: frameCenterX,
132
- top: frameCenterY,
133
- width: device.imageDimensions.width,
134
- height: device.imageDimensions.height,
135
- scaleX: scale,
136
- scaleY: scale,
137
- originX: 'center',
138
- originY: 'center',
139
- layerId: frameId,
140
- layerType: 'deviceFrame',
141
- deviceFrameId: frameId,
142
- layerRole: 'frame',
143
- deviceId: opts.deviceId,
144
- deviceScale: scale
163
+ // Screen center offset from the frame center (at the frame's base scale), rotated with the frame.
164
+ const offsetX = -(scaledImageWidth / 2) + screenOffsetX + screenWidth / 2;
165
+ const offsetY = -(scaledImageHeight / 2) + screenOffsetY + screenHeight / 2;
166
+ const angleRad = (frameAngle * Math.PI) / 180;
167
+ const rotatedOffsetX = offsetX * Math.cos(angleRad) - offsetY * Math.sin(angleRad);
168
+ const rotatedOffsetY = offsetX * Math.sin(angleRad) + offsetY * Math.cos(angleRad);
169
+
170
+ // The frame's current scale relative to its base deviceScale (1 unless the user resized it).
171
+ const frameScaleRatioX = frameScaleX / scale;
172
+ const frameScaleRatioY = frameScaleY / scale;
173
+
174
+ return {
175
+ // The offset is already in base-scale canvas units, so it scales by the RATIO, not the
176
+ // absolute frame scale (≤0.2 editors multiplied by frameScale, shrinking off-centre screen
177
+ // offsets ~7× — visible on MacBooks, whose screen sits above the frame centre).
178
+ left: frameLeft + rotatedOffsetX * frameScaleRatioX,
179
+ top: frameTop + rotatedOffsetY * frameScaleRatioY,
180
+ scaleX: imgScaleX * frameScaleRatioX,
181
+ scaleY: imgScaleY * frameScaleRatioY,
182
+ // The screenshot's visual angle combines the frame rotation and its own rotation.
183
+ angle: frameAngle + rotation,
184
+ // The clipPath lives in the image's local (unscaled) coordinate space.
185
+ clip: {
186
+ width: naturalWidth,
187
+ height: naturalHeight,
188
+ rx: cornerRadius / imgScaleX,
189
+ ry: cornerRadius / imgScaleY
145
190
  }
146
191
  };
147
-
148
- return { screenshot, frame };
149
192
  }
package/src/index.ts CHANGED
@@ -6,3 +6,6 @@ export * from './builders';
6
6
  export * from './device-frames';
7
7
  export * from './frames';
8
8
  export * from './compose';
9
+ export * from './layout-system';
10
+ export * from './color';
11
+ export * from './variants';
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Set-wide layout geometry for the composer (shot-dsl 0.4.0): the no-tangent rule, focus-aware
3
+ * bleed, rotated bounding boxes and seam checks. Pure math — no DSL objects are built here, so every
4
+ * branch is unit-testable in isolation (see layout-system.test.ts).
5
+ *
6
+ * Vocabulary (all canvas units unless noted):
7
+ * - SUBJECT: what sits under the text — a device frame PNG, or the bare screenshot (frameless).
8
+ * `width/height` are its natural (unscaled) pixel size; `screen` is the screenshot's area inside it.
9
+ * - NEAR edge: the subject's edge facing the text block (top for `text-top`, bottom for `text-bottom`).
10
+ * FAR edge: the opposite one, which may bleed off the canvas.
11
+ * - OVERSHOOT: how far the FAR edge passes the canvas edge (> 0 = bleeds off, < 0 = gap).
12
+ */
13
+
14
+ /** The "pro rules" thresholds. */
15
+ export const NO_TANGENT = {
16
+ /** A subject that stays on-canvas must clear the edge by at least this × H. */
17
+ clearGap: 0.04,
18
+ /** A subject that bleeds must lose at least this × its own (rotated) height. */
19
+ minBleed: 0.12,
20
+ /** `bleed: "deep"` target, × the subject's height. */
21
+ deepBleed: 0.25,
22
+ /** The focus band must end at least this × H inside the canvas edge. */
23
+ focusSafe: 0.02,
24
+ /** Upper bound on the subject's (unrotated) width — targets are clamped to it too, × W. */
25
+ maxWidth: 0.9,
26
+ /**
27
+ * Horizontal no-tangent rule: the subject's VISIBLE extent keeps at least this × W from each side
28
+ * edge (a side bleed is only allowed as a deliberate panorama straddle across a seam).
29
+ */
30
+ sideMargin: 0.05,
31
+ /** The focus band's (rotated) corners stay at least this × W inside the side edges / seams. */
32
+ focusSideSafe: 0.02,
33
+ /** A clear-with-margin option that shrinks the subject below this × its natural scale is rejected. */
34
+ minClearScale: 0.6,
35
+ /** Below this × the layout's target scale the copy leaves no usable room (composeSet throws). */
36
+ minScale: 0.5
37
+ } as const;
38
+
39
+ const EPS = 1e-6;
40
+
41
+ export type BleedPreference = 'auto' | 'none' | 'deep';
42
+
43
+ /** Fractions of the screenshot height (0 = top, 1 = bottom) that must stay visible. */
44
+ export interface FocusBand {
45
+ top: number;
46
+ bottom: number;
47
+ }
48
+
49
+ export interface Subject {
50
+ width: number;
51
+ height: number;
52
+ /** Where the screenshot sits inside the subject (natural px). Frameless: the whole image. */
53
+ screen: { x: number; y: number; width: number; height: number };
54
+ }
55
+
56
+ export interface Point {
57
+ x: number;
58
+ y: number;
59
+ }
60
+
61
+ /** Axis-aligned bounding box of a w×h rectangle rotated by `angle` degrees. */
62
+ export function rotatedBox(width: number, height: number, angle = 0): { width: number; height: number } {
63
+ const r = (angle * Math.PI) / 180;
64
+ const c = Math.abs(Math.cos(r));
65
+ const s = Math.abs(Math.sin(r));
66
+ return { width: width * c + height * s, height: width * s + height * c };
67
+ }
68
+
69
+ /**
70
+ * Canvas position of a point given in the subject's natural px (from its top-left), for a subject
71
+ * drawn centre-origin at (cx, cy), scaled by `scale` and rotated clockwise by `angle` (Fabric).
72
+ */
73
+ export function subjectPointToCanvas(
74
+ subject: Subject,
75
+ p: Point,
76
+ pose: { cx: number; cy: number; scale: number; angle?: number }
77
+ ): Point {
78
+ const r = ((pose.angle ?? 0) * Math.PI) / 180;
79
+ const lx = (p.x - subject.width / 2) * pose.scale;
80
+ const ly = (p.y - subject.height / 2) * pose.scale;
81
+ return {
82
+ x: pose.cx + lx * Math.cos(r) - ly * Math.sin(r),
83
+ y: pose.cy + lx * Math.sin(r) + ly * Math.cos(r)
84
+ };
85
+ }
86
+
87
+ /** The focus band's four corners in the subject's natural px (full screen width, band height). */
88
+ export function focusCorners(subject: Subject, focus: FocusBand): Point[] {
89
+ const { x, y, width, height } = subject.screen;
90
+ const top = y + focus.top * height;
91
+ const bottom = y + focus.bottom * height;
92
+ return [
93
+ { x, y: top },
94
+ { x: x + width, y: top },
95
+ { x: x + width, y: bottom },
96
+ { x, y: bottom }
97
+ ];
98
+ }
99
+
100
+ /**
101
+ * How far (natural px, i.e. at scale 1) the focus band reaches from the subject's rotated-AABB NEAR
102
+ * edge toward its FAR edge. The subject keeps its focus visible iff
103
+ * `near + scale · focusReach ≤ H · (1 − focusSafe)`.
104
+ */
105
+ export function focusReach(subject: Subject, focus: FocusBand, angle: number, farEdge: 'bottom' | 'top'): number {
106
+ const box = rotatedBox(subject.width, subject.height, angle);
107
+ // Rotate about the centre at scale 1, measure y from the AABB centre.
108
+ const ys = focusCorners(subject, focus).map((p) => subjectPointToCanvas(subject, p, { cx: 0, cy: 0, scale: 1, angle }).y);
109
+ return farEdge === 'bottom' ? Math.max(...ys) + box.height / 2 : box.height / 2 - Math.min(...ys);
110
+ }
111
+
112
+ /** The subject's four corners (tl, tr, br, bl) on the canvas for a pose. */
113
+ export function subjectCorners(subject: Subject, pose: { cx: number; cy: number; scale: number; angle?: number }): Point[] {
114
+ return [
115
+ { x: 0, y: 0 },
116
+ { x: subject.width, y: 0 },
117
+ { x: subject.width, y: subject.height },
118
+ { x: 0, y: subject.height }
119
+ ].map((p) => subjectPointToCanvas(subject, p, pose));
120
+ }
121
+
122
+ /** Clip a convex polygon to the horizontal band y0 ≤ y ≤ y1 (Sutherland–Hodgman, two edges). */
123
+ export function clipPolygonToBand(points: Point[], y0: number, y1: number): Point[] {
124
+ const clip = (pts: Point[], inside: (p: Point) => boolean, cut: (a: Point, b: Point) => Point) => {
125
+ const out: Point[] = [];
126
+ pts.forEach((cur, i) => {
127
+ const prev = pts[(i + pts.length - 1) % pts.length];
128
+ if (inside(cur)) {
129
+ if (!inside(prev)) out.push(cut(prev, cur));
130
+ out.push(cur);
131
+ } else if (inside(prev)) out.push(cut(prev, cur));
132
+ });
133
+ return out;
134
+ };
135
+ const atY = (y: number) => (a: Point, b: Point): Point => ({ x: a.x + ((b.x - a.x) * (y - a.y)) / (b.y - a.y), y });
136
+ const top = clip(points, (p) => p.y >= y0, atY(y0));
137
+ return top.length ? clip(top, (p) => p.y <= y1, atY(y1)) : [];
138
+ }
139
+
140
+ /** Horizontal extent of the part of the subject that is actually on the canvas (null if none). */
141
+ export function visibleXExtent(
142
+ subject: Subject,
143
+ pose: { cx: number; cy: number; scale: number; angle?: number },
144
+ canvasHeight: number
145
+ ): { min: number; max: number } | null {
146
+ const poly = clipPolygonToBand(subjectCorners(subject, pose), 0, canvasHeight);
147
+ if (poly.length === 0) return null;
148
+ const xs = poly.map((p) => p.x);
149
+ return { min: Math.min(...xs), max: Math.max(...xs) };
150
+ }
151
+
152
+ /** Horizontal extent of the focus band's rotated corners. */
153
+ export function focusXExtent(
154
+ subject: Subject,
155
+ focus: FocusBand,
156
+ pose: { cx: number; cy: number; scale: number; angle?: number }
157
+ ): { min: number; max: number } {
158
+ const xs = focusCorners(subject, focus).map((p) => subjectPointToCanvas(subject, p, pose).x);
159
+ return { min: Math.min(...xs), max: Math.max(...xs) };
160
+ }
161
+
162
+ /** True when a far edge overshoot is in the forbidden "just touching" band. */
163
+ export function inTangentZone(overshoot: number, subjectHeight: number, canvasHeight: number): boolean {
164
+ return overshoot > -NO_TANGENT.clearGap * canvasHeight + EPS && overshoot < NO_TANGENT.minBleed * subjectHeight - EPS;
165
+ }
166
+
167
+ export interface VerticalInput {
168
+ canvasWidth: number;
169
+ canvasHeight: number;
170
+ /** Distance from the text-side canvas edge to where the subject's near edge may start. */
171
+ near: number;
172
+ /** The subject's near edge can't start closer than this (hero layouts push the device lower). */
173
+ minNear?: number;
174
+ /** Unrotated natural width (width targets are expressed on it, so a tilt doesn't shrink the device). */
175
+ baseWidth: number;
176
+ /** Rotated-AABB natural height (vertical extent). */
177
+ boxHeight: number;
178
+ /** Target rendered width, × W. */
179
+ targetWidth: number;
180
+ /** The natural scale is capped so no more than this fraction of the subject is off-canvas. */
181
+ maxBleed: number;
182
+ /** See `focusReach`; null = no focus band marked (unconstrained). */
183
+ focusReach: number | null;
184
+ bleed: BleedPreference;
185
+ /** Tilted subjects: prefer a decisive bleed whenever one is possible (tilt is paired with a bleed). */
186
+ preferBleed?: boolean;
187
+ /** Hard upper bound on the scale (e.g. from the horizontal rules); applied to every option. */
188
+ scaleCap?: number;
189
+ }
190
+
191
+ export interface VerticalResult {
192
+ scale: number;
193
+ /** Distance from the text-side edge to the subject's near edge. */
194
+ near: number;
195
+ /** Far edge overshoot (> 0 bleeds off the canvas). */
196
+ overshoot: number;
197
+ mode: 'clear' | 'bleed';
198
+ /** overshoot ÷ rendered subject height (negative when clear). */
199
+ bleedFraction: number;
200
+ /** Why this option was picked (debug/report aid). */
201
+ reason: string;
202
+ }
203
+
204
+ /**
205
+ * THE no-tangent solver. Given where the subject may start (below the set's reserved text area) and
206
+ * how big it wants to be, return a placement whose far edge either clears the canvas edge by
207
+ * ≥ `clearGap`·H or bleeds by ≥ `minBleed` of the subject's height — never in between — and never
208
+ * pushes the focus band off-canvas. Options:
209
+ * - 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
+ * `auto` → keep the natural placement when it already clears by the margin or is a focus-safe
215
+ * decisive bleed; otherwise (the tangent zone) PREFER the bleed option, and fall back to clear only
216
+ * when every ≥ `minBleed` bleed would crop the focus band. `preferBleed` (tilt) also turns a natural
217
+ * clear into a bleed.
218
+ */
219
+ export function solveVertical(inp: VerticalInput): VerticalResult {
220
+ const W = inp.canvasWidth;
221
+ const H = inp.canvasHeight;
222
+ const eh = inp.boxHeight;
223
+ const near0 = Math.max(inp.near, inp.minNear ?? 0);
224
+ const cap = inp.scaleCap ?? Infinity;
225
+ const sCap = Math.min((NO_TANGENT.maxWidth * W) / inp.baseWidth, cap);
226
+ const target = Math.min(inp.targetWidth, NO_TANGENT.maxWidth);
227
+ const s0 = Math.max(0, Math.min((target * W) / inp.baseWidth, (H - near0) / (1 - inp.maxBleed) / eh, cap));
228
+ const focusLimit = H * (1 - NO_TANGENT.focusSafe);
229
+
230
+ const overshoot = (s: number, a: number) => a + s * eh - H;
231
+ const focusOk = (s: number, a: number) => inp.focusReach == null || a + s * inp.focusReach <= focusLimit + EPS;
232
+ const make = (s: number, a: number, reason: string): VerticalResult => {
233
+ const o = overshoot(s, a);
234
+ return { scale: s, near: a, overshoot: o, mode: o > 0 ? 'bleed' : 'clear', bleedFraction: o / (s * eh), reason };
235
+ };
236
+
237
+ const clearOption = (): VerticalResult | null => {
238
+ const s = Math.min(s0, (H * (1 - NO_TANGENT.clearGap) - near0) / eh);
239
+ if (!(s > 0) || s < NO_TANGENT.minClearScale * s0 - EPS) return null;
240
+ return make(s, near0, s < s0 - EPS ? 'shrunk to clear the edge with a margin' : 'clears the edge');
241
+ };
242
+
243
+ /** Smallest move reaching overshoot ≥ t·height, then made focus-safe (null if impossible). */
244
+ const bleedOption = (t: number): VerticalResult | null => {
245
+ let s = s0;
246
+ let a = near0;
247
+ if (overshoot(s, a) < t * s * eh - EPS) {
248
+ const need = (H - a) / ((1 - t) * eh);
249
+ 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);
251
+ }
252
+ if (focusOk(s, a)) return make(s, a, `bleeds ${Math.round(t * 100)}%+`);
253
+ // 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.
255
+ if (inp.focusReach == null) return null;
256
+ const fr = inp.focusReach;
257
+ const sF = Math.min(s, Math.max(sCap, s0), (focusLimit - near0) / fr);
258
+ const denom = eh * (1 - NO_TANGENT.minBleed) - fr;
259
+ 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)));
261
+ if (overshoot(sF, aF) < NO_TANGENT.minBleed * sF * eh - EPS || !focusOk(sF, aF)) return null;
262
+ return make(sF, aF, 'bleed reduced to keep the focus band visible');
263
+ };
264
+
265
+ /** Last resort (never a tangent): clear with the margin however small that makes the subject. */
266
+ const forcedClear = () => make(Math.max(0, Math.min(s0, (H * (1 - NO_TANGENT.clearGap) - near0) / eh)), near0, 'forced clear');
267
+
268
+ if (inp.bleed === 'none') return clearOption() ?? forcedClear();
269
+
270
+ const naturalOver = overshoot(s0, near0);
271
+ const naturalClear = naturalOver <= -NO_TANGENT.clearGap * H + EPS;
272
+ const naturalBleed = naturalOver >= NO_TANGENT.minBleed * s0 * eh - EPS;
273
+
274
+ if (inp.bleed === 'deep') {
275
+ const t = Math.max(NO_TANGENT.deepBleed, naturalBleed ? naturalOver / (s0 * eh) : 0);
276
+ return bleedOption(t) ?? clearOption() ?? forcedClear();
277
+ }
278
+
279
+ // auto
280
+ if (naturalClear && !inp.preferBleed) return make(s0, near0, 'natural placement clears the edge');
281
+ if (naturalBleed && focusOk(s0, near0)) return make(s0, near0, 'natural placement bleeds decisively');
282
+ // In the tangent zone (or a tilted subject): resolve by bleeding decisively. Clear-with-margin
283
+ // only when no ≥ minBleed bleed keeps the focus band visible.
284
+ const bleed = bleedOption(NO_TANGENT.minBleed);
285
+ if (bleed) return inp.preferBleed ? { ...bleed, reason: `${bleed.reason} (tilt pairs with a bleed)` } : bleed;
286
+ if (naturalClear) return make(s0, near0, 'natural placement clears the edge');
287
+ return clearOption() ?? forcedClear();
288
+ }
289
+
290
+ /** Does the vertical line x = `seamX` pass through the polygon spanned by `points`? */
291
+ export function crossesVertical(points: Point[], seamX: number, margin = 0): boolean {
292
+ const xs = points.map((p) => p.x);
293
+ return Math.min(...xs) < seamX + margin && Math.max(...xs) > seamX - margin;
294
+ }
295
+
296
+ /** Do two axis-aligned rects overlap? */
297
+ export function rectsOverlap(
298
+ a: { left: number; top: number; right: number; bottom: number },
299
+ b: { left: number; top: number; right: number; bottom: number }
300
+ ): boolean {
301
+ return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
302
+ }
package/src/types.ts CHANGED
@@ -15,7 +15,42 @@ export type TemplateRole = 'placeholder' | 'editable' | 'fixed';
15
15
  /** Current ScreenLayersJSON schema version. */
16
16
  export const CURRENT_SCHEMA_VERSION = 2;
17
17
 
18
- /** JSON representation of a single layer for persistence + handoff. */
18
+ /**
19
+ * Screenshot carried BY a device-frame layer (`type: 'device'`), at `fabricData.screenshot`.
20
+ *
21
+ * Since shot-dsl 0.3.0 a device mockup is ONE layer: the frame, with its screenshot as a property —
22
+ * mirroring the editor, where a frame's screenshot is owned by the frame (it is not a panel layer).
23
+ * On import the editor places the image under the frame and clips it to the screen area with the
24
+ * same code path it uses when a user drops a screenshot onto a frame, so producers never compute
25
+ * screenshot geometry or clip paths themselves.
26
+ *
27
+ * Legacy (≤ 0.2.0) templates instead emitted a separate `type: 'image'` layer with
28
+ * `fabricData.layerRole === 'screenshot'` + a `deviceFrameId` matching its frame, placed directly
29
+ * below the frame. `validateTemplate` still accepts that shape and the editor converts it on import.
30
+ */
31
+ export interface DeviceScreenshotJSON {
32
+ /**
33
+ * Image URL. In handoffs this must be the uploaded-asset form `/api/screenshots/<id>/raw`
34
+ * (see `isUploadedScreenshotSrc`) — external / `data:` / protocol-relative URLs are rejected.
35
+ */
36
+ src: string;
37
+ /** Natural pixel size of the screenshot. */
38
+ width: number;
39
+ height: number;
40
+ /** Clockwise rotation inside the frame, in degrees (0 | 90 | 180 | 270). Default 0. */
41
+ rotation?: number;
42
+ }
43
+
44
+ /**
45
+ * JSON representation of a single layer for persistence + handoff.
46
+ *
47
+ * Order convention: in a DSL `ScreenLayersJSON.layers` array (templates, handoffs) layers are listed
48
+ * BOTTOM → TOP — index 0 is painted first (canvas add order), the last entry is frontmost. E.g. a
49
+ * composed screen is `[device, headline, subheadline]`; the built-in templates list shapes, then the
50
+ * device, then text. (The editor's layers PANEL is the reverse — top first — and so is the
51
+ * `layersJSON` the editor persists alongside a full `canvasJSON`; that pairing restores z-order from
52
+ * `canvasJSON`, never from this array.)
53
+ */
19
54
  export interface LayerJSON {
20
55
  id: string;
21
56
  name: string;
@@ -61,6 +96,7 @@ export interface ScreenLayersJSON {
61
96
  canvasHeight?: number;
62
97
  /** Device group (multi-device); optional/additive — absent ⇒ the editor infers it. */
63
98
  deviceClass?: DeviceClass;
99
+ /** BOTTOM → TOP (index 0 is painted first). See `LayerJSON`. */
64
100
  layers: LayerJSON[];
65
101
  background?: BackgroundJSON;
66
102
  }