@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/README.md +206 -7
- package/package.json +5 -2
- package/src/color.ts +76 -0
- package/src/compose.ts +1272 -107
- package/src/frames.ts +132 -89
- package/src/index.ts +3 -0
- package/src/layout-system.ts +302 -0
- package/src/types.ts +37 -1
- package/src/validate.ts +200 -1
- package/src/variants.ts +125 -0
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
37
|
+
export function makeDeviceFrameLayer(opts: {
|
|
39
38
|
deviceId: string;
|
|
40
|
-
screenshotUrl
|
|
41
|
-
screenshotWidth
|
|
42
|
-
screenshotHeight
|
|
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
|
-
}):
|
|
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
|
|
70
|
-
const
|
|
145
|
+
const naturalWidth = natural.width || 1;
|
|
146
|
+
const naturalHeight = natural.height || 1;
|
|
71
147
|
|
|
72
|
-
//
|
|
73
|
-
const
|
|
74
|
-
const
|
|
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
|
|
77
|
-
const imgScaleX = screenWidth /
|
|
78
|
-
const imgScaleY = screenHeight /
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
@@ -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
|
-
/**
|
|
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
|
}
|