@appshoteditor/shot-dsl 0.2.0 → 0.3.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 +67 -2
- package/package.json +1 -1
- package/src/compose.ts +7 -5
- package/src/frames.ts +129 -89
- package/src/types.ts +37 -1
- package/src/validate.ts +200 -1
package/README.md
CHANGED
|
@@ -10,10 +10,12 @@ composer — so a layout composed by the skill renders identically in the editor
|
|
|
10
10
|
## What's inside
|
|
11
11
|
|
|
12
12
|
- **Types** — `Template`, `ScreenLayersJSON`, `LayerJSON`, `BackgroundJSON`, `CURRENT_SCHEMA_VERSION`.
|
|
13
|
-
- **Validators** — `validateTemplate()` (strict, error-reporting), `isValidTemplate`, `migrateScreenLayersJSON
|
|
13
|
+
- **Validators** — `validateTemplate()` (strict, error-reporting), `isValidTemplate`, `migrateScreenLayersJSON`,
|
|
14
|
+
`validateDeviceScreenshot`, `isUploadedScreenshotSrc`.
|
|
14
15
|
- **Builders** — `makeTextLayer`, `makeImageLayer`, `makeShapeLayer`, `makeSolid/GradientBackground`,
|
|
15
16
|
`makeScreen`, `makeTemplate`.
|
|
16
|
-
- **Device geometry** — `deviceFrames`, `getDeviceFrame`, `
|
|
17
|
+
- **Device geometry** — `deviceFrames`, `getDeviceFrame`, `makeDeviceFrameLayer`, `calculateDeviceScale`,
|
|
18
|
+
`computeScreenshotPlacement` (the screenshot-in-frame fit/clip math the editor uses).
|
|
17
19
|
- **Composer** — `composeTemplate(plan)`: a benefit/screenshot plan → a validated, device-framed `Template`.
|
|
18
20
|
Per screen: `headline`, optional `subheadline` / `headlineColor` / `subheadlineColor`, and `layout`
|
|
19
21
|
(`text-top` default, `text-bottom`, `device-bleed` — see `COMPOSE_LAYOUTS`). All geometry (device
|
|
@@ -39,6 +41,69 @@ const template = composeTemplate({
|
|
|
39
41
|
validateTemplate(template); // { valid: true, errors: [] }
|
|
40
42
|
```
|
|
41
43
|
|
|
44
|
+
## Layer order
|
|
45
|
+
|
|
46
|
+
A screen's `layers` array is **bottom → top**: index 0 is painted first (canvas add order), the last
|
|
47
|
+
entry is frontmost. A composed screen is `[device, headline, subheadline?]`; the editor's built-in
|
|
48
|
+
templates list background shapes, then the device, then text. (The editor's layers *panel* shows the
|
|
49
|
+
reverse — top first.)
|
|
50
|
+
|
|
51
|
+
## Device screenshots (0.3.0)
|
|
52
|
+
|
|
53
|
+
A device mockup is **one** layer. Its screenshot is a property of the device layer, at
|
|
54
|
+
`fabricData.screenshot`:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"id": "device-frame-layer-1790080000000-abc123def",
|
|
59
|
+
"name": "iPhone 17 Pro Max",
|
|
60
|
+
"type": "device",
|
|
61
|
+
"visible": true,
|
|
62
|
+
"locked": false,
|
|
63
|
+
"fabricData": {
|
|
64
|
+
"type": "image",
|
|
65
|
+
"src": "/devices/iphone-17-pro-max.webp",
|
|
66
|
+
"left": 140, "top": 438.69, "width": 1520, "height": 3068,
|
|
67
|
+
"scaleX": 0.175, "scaleY": 0.175, "originX": "center", "originY": "center",
|
|
68
|
+
"layerId": "device-frame-layer-1790080000000-abc123def",
|
|
69
|
+
"layerType": "deviceFrame",
|
|
70
|
+
"layerRole": "frame",
|
|
71
|
+
"deviceFrameId": "device-frame-layer-1790080000000-abc123def",
|
|
72
|
+
"deviceId": "iphone_17_pro_max",
|
|
73
|
+
"deviceScale": 0.175,
|
|
74
|
+
"screenshot": { "src": "/api/screenshots/<asset-id>/raw", "width": 1320, "height": 2868 }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`screenshot` is `{ src, width, height, rotation? }` (`DeviceScreenshotJSON`; `rotation` ∈ 0/90/180/270).
|
|
80
|
+
Producers emit **no** screenshot geometry or clip path: the editor places the image under the frame
|
|
81
|
+
and clips it to the screen with the same code path it uses when a user drops a screenshot onto a
|
|
82
|
+
frame (`computeScreenshotPlacement`), so the screenshot belongs to the frame (it is not a separate
|
|
83
|
+
layer). `validateTemplate` requires `src` to be an uploaded-asset URL (`/api/screenshots/<id>/raw`) —
|
|
84
|
+
external, protocol-relative and `data:` URLs are rejected. It also requires every device layer to have a
|
|
85
|
+
known `deviceId` and a frame-asset `src` (`/devices/<name>.webp|png`), and rejects duplicate layer ids
|
|
86
|
+
or `deviceFrameId`s within a screen. Every other image reference in a handoff (image layers, pattern
|
|
87
|
+
`fill.source`, nested objects) must be an uploaded screenshot or a `/devices/` asset. Frame metadata
|
|
88
|
+
(`layerRole: 'frame'`, `deviceFrameId`, `deviceId`, `deviceScale`) is only allowed on device layers,
|
|
89
|
+
`layerRole: 'screenshot'` only on legacy image layers, and editor-internal props
|
|
90
|
+
(`EDITOR_INTERNAL_PROPS`, e.g. `pendingScreenshot`) are rejected everywhere, as are `__proto__` /
|
|
91
|
+
`constructor` / `prototype` keys at any depth (`FORBIDDEN_KEYS`).
|
|
92
|
+
|
|
93
|
+
`computeScreenshotPlacement` centres the screenshot on the frame's screen bounds; the screen-centre
|
|
94
|
+
offset scales with the frame's scale *relative to* `deviceScale` (0.2.x editors multiplied by the
|
|
95
|
+
absolute frame scale, misplacing off-centre screens such as MacBooks by a few canvas units).
|
|
96
|
+
|
|
97
|
+
**Legacy (≤ 0.2.0):** screenshots were a separate `type: 'image'` layer with
|
|
98
|
+
`fabricData.layerRole: 'screenshot'` and a `deviceFrameId` matching its frame, placed directly below
|
|
99
|
+
the frame. `validateTemplate` still accepts that shape (its `src` must also be an uploaded-asset URL)
|
|
100
|
+
and the editor converts it on import.
|
|
101
|
+
`makeDeviceFrameLayers()` (which returned `{ screenshot, frame }`) was replaced by
|
|
102
|
+
`makeDeviceFrameLayer()` (returns the single device layer).
|
|
103
|
+
|
|
104
|
+
> Consumers: a template in the 0.3.0 shape needs an editor that understands `fabricData.screenshot`
|
|
105
|
+
> (appshoteditor.com with the matching importer).
|
|
106
|
+
|
|
42
107
|
Zero runtime dependencies. The `schemaVersion` is the compatibility contract between producers and the editor.
|
|
43
108
|
|
|
44
109
|
> Intended for use via a bundler (Vite, esbuild, etc.). Device `frameAsset` values are URL paths the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appshoteditor/shot-dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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",
|
package/src/compose.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BackgroundJSON, LayerJSON, Template } from './types';
|
|
2
2
|
import { makeTextLayer, makeScreen, makeTemplate } from './builders';
|
|
3
|
-
import {
|
|
3
|
+
import { makeDeviceFrameLayer } from './frames';
|
|
4
4
|
import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -148,7 +148,7 @@ function measureTextBlock(screen: ComposeScreenPlan, unit: number, textWidth: nu
|
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
150
|
* Deterministically assemble a Template from a plan. Each screen gets a background, a large
|
|
151
|
-
* device
|
|
151
|
+
* device frame carrying its screenshot and a bold headline (+ optional subheadline), arranged per the screen's
|
|
152
152
|
* `layout`. All geometry is derived from the canvas size. Text layers are marked editable so the
|
|
153
153
|
* user can tweak them in the editor. Claude decides the plan (benefit, copy, device, palette,
|
|
154
154
|
* layout); this turns it into valid DSL.
|
|
@@ -191,7 +191,9 @@ export function composeTemplate(plan: ComposePlan): Template {
|
|
|
191
191
|
centerY = deviceTop + (fh * scale) / 2; // bottom edge bleeds off the bottom when tall
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
|
|
194
|
+
// One device layer; the screenshot rides on it as `fabricData.screenshot` and the editor
|
|
195
|
+
// places + clips it under the frame on import (see DeviceScreenshotJSON).
|
|
196
|
+
const frame = makeDeviceFrameLayer({
|
|
195
197
|
deviceId: screen.deviceId,
|
|
196
198
|
screenshotUrl: screen.screenshot.url,
|
|
197
199
|
screenshotWidth: screen.screenshot.width,
|
|
@@ -220,7 +222,8 @@ export function composeTemplate(plan: ComposePlan): Template {
|
|
|
220
222
|
templateKey: 'headline'
|
|
221
223
|
});
|
|
222
224
|
|
|
223
|
-
|
|
225
|
+
// BOTTOM → TOP (canvas add order): device -> headline -> subheadline.
|
|
226
|
+
const layers: LayerJSON[] = [frame, headline];
|
|
224
227
|
|
|
225
228
|
if (screen.subheadline?.trim()) {
|
|
226
229
|
const sub = makeTextLayer({
|
|
@@ -243,7 +246,6 @@ export function composeTemplate(plan: ComposePlan): Template {
|
|
|
243
246
|
layers.push(sub);
|
|
244
247
|
}
|
|
245
248
|
|
|
246
|
-
// z-order: screenshot (bottom) -> frame -> headline -> subheadline (top)
|
|
247
249
|
return makeScreen({
|
|
248
250
|
background: screen.background,
|
|
249
251
|
canvasWidth: W,
|
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,22 +24,21 @@ 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;
|
|
@@ -47,7 +46,7 @@ export function makeDeviceFrameLayers(opts: {
|
|
|
47
46
|
scale?: number;
|
|
48
47
|
screenshotRotation?: number;
|
|
49
48
|
name?: string;
|
|
50
|
-
}):
|
|
49
|
+
}): LayerJSON {
|
|
51
50
|
const device = getDeviceFrame(opts.deviceId);
|
|
52
51
|
if (!device) throw new Error(`Unknown device: ${opts.deviceId}`);
|
|
53
52
|
|
|
@@ -56,8 +55,82 @@ export function makeDeviceFrameLayers(opts: {
|
|
|
56
55
|
const scale = opts.scale ?? calculateDeviceScale(device, canvasWidth, canvasHeight);
|
|
57
56
|
|
|
58
57
|
const frameId = `device-frame-${generateLayerId()}`;
|
|
59
|
-
const screenshotId = generateLayerId();
|
|
60
58
|
|
|
59
|
+
const fabricData: Record<string, unknown> = {
|
|
60
|
+
type: 'image',
|
|
61
|
+
src: device.frameAsset,
|
|
62
|
+
crossOrigin: 'anonymous',
|
|
63
|
+
left: opts.centerX ?? canvasWidth / 2,
|
|
64
|
+
top: opts.centerY ?? canvasHeight / 2,
|
|
65
|
+
width: device.imageDimensions.width,
|
|
66
|
+
height: device.imageDimensions.height,
|
|
67
|
+
scaleX: scale,
|
|
68
|
+
scaleY: scale,
|
|
69
|
+
originX: 'center',
|
|
70
|
+
originY: 'center',
|
|
71
|
+
layerId: frameId,
|
|
72
|
+
layerType: 'deviceFrame',
|
|
73
|
+
deviceFrameId: frameId,
|
|
74
|
+
layerRole: 'frame',
|
|
75
|
+
deviceId: opts.deviceId,
|
|
76
|
+
deviceScale: scale
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (opts.screenshotUrl) {
|
|
80
|
+
const { screenshotWidth: width, screenshotHeight: height } = opts;
|
|
81
|
+
if (!(typeof width === 'number' && width > 0 && typeof height === 'number' && height > 0)) {
|
|
82
|
+
throw new Error('makeDeviceFrameLayer: screenshotWidth/screenshotHeight (> 0) are required with screenshotUrl');
|
|
83
|
+
}
|
|
84
|
+
const screenshot: DeviceScreenshotJSON = { src: opts.screenshotUrl, width, height };
|
|
85
|
+
if (opts.screenshotRotation) screenshot.rotation = opts.screenshotRotation;
|
|
86
|
+
fabricData.screenshot = screenshot;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
id: frameId,
|
|
91
|
+
name: opts.name ?? device.name,
|
|
92
|
+
type: 'device',
|
|
93
|
+
visible: true,
|
|
94
|
+
locked: false,
|
|
95
|
+
fabricData
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where a frame currently sits on the canvas (a Fabric object's relevant props). */
|
|
100
|
+
export interface DeviceFramePose {
|
|
101
|
+
left?: number;
|
|
102
|
+
top?: number;
|
|
103
|
+
scaleX?: number;
|
|
104
|
+
scaleY?: number;
|
|
105
|
+
angle?: number;
|
|
106
|
+
/** The frame's base scale when it was created (`fabricData.deviceScale`). */
|
|
107
|
+
deviceScale?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Fabric props for a screenshot placed inside a frame; `clip` is a centered Rect in the image's local space. */
|
|
111
|
+
export interface ScreenshotPlacement {
|
|
112
|
+
left: number;
|
|
113
|
+
top: number;
|
|
114
|
+
scaleX: number;
|
|
115
|
+
scaleY: number;
|
|
116
|
+
angle: number;
|
|
117
|
+
clip: { width: number; height: number; rx: number; ry: number };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* THE screenshot-in-frame geometry: fit a screenshot of natural size `natural` to the
|
|
122
|
+
* device's screen bounds for a frame at `frame`, optionally rotated by `rotation`
|
|
123
|
+
* (0/90/180/270 — 90/270 swap the effective dimensions), with a rounded clip matching
|
|
124
|
+
* `device.cornerRadius`. Used by the editor (FabricCanvas.updateDeviceFrameScreenshot)
|
|
125
|
+
* and the offscreen thumbnail/export renderers so all three place screenshots identically.
|
|
126
|
+
*/
|
|
127
|
+
export function computeScreenshotPlacement(
|
|
128
|
+
device: DeviceFrame,
|
|
129
|
+
frame: DeviceFramePose,
|
|
130
|
+
natural: { width: number; height: number },
|
|
131
|
+
rotation = 0
|
|
132
|
+
): ScreenshotPlacement {
|
|
133
|
+
const scale = frame.deviceScale || 0.15;
|
|
61
134
|
const scaledImageWidth = device.imageDimensions.width * scale;
|
|
62
135
|
const scaledImageHeight = device.imageDimensions.height * scale;
|
|
63
136
|
const screenWidth = device.screenBounds.width * scale;
|
|
@@ -66,84 +139,51 @@ export function makeDeviceFrameLayers(opts: {
|
|
|
66
139
|
const screenOffsetY = device.screenBounds.y * scale;
|
|
67
140
|
const cornerRadius = device.cornerRadius * scale;
|
|
68
141
|
|
|
69
|
-
const
|
|
70
|
-
const
|
|
142
|
+
const naturalWidth = natural.width || 1;
|
|
143
|
+
const naturalHeight = natural.height || 1;
|
|
71
144
|
|
|
72
|
-
//
|
|
73
|
-
const
|
|
74
|
-
const
|
|
145
|
+
// When rotated 90 or 270, the effective dimensions are swapped.
|
|
146
|
+
const isRotated90or270 = rotation === 90 || rotation === 270;
|
|
147
|
+
const effectiveWidth = isRotated90or270 ? naturalHeight : naturalWidth;
|
|
148
|
+
const effectiveHeight = isRotated90or270 ? naturalWidth : naturalHeight;
|
|
75
149
|
|
|
76
|
-
// Scale the
|
|
77
|
-
const imgScaleX = screenWidth /
|
|
78
|
-
const imgScaleY = screenHeight /
|
|
150
|
+
// Scale to fit the screen bounds using the effective dimensions.
|
|
151
|
+
const imgScaleX = screenWidth / effectiveWidth;
|
|
152
|
+
const imgScaleY = screenHeight / effectiveHeight;
|
|
79
153
|
|
|
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
|
-
};
|
|
154
|
+
const frameLeft = frame.left || 0;
|
|
155
|
+
const frameTop = frame.top || 0;
|
|
156
|
+
const frameScaleX = frame.scaleX || 1;
|
|
157
|
+
const frameScaleY = frame.scaleY || 1;
|
|
158
|
+
const frameAngle = frame.angle || 0;
|
|
120
159
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
// Screen center offset from the frame center (at the frame's base scale), rotated with the frame.
|
|
161
|
+
const offsetX = -(scaledImageWidth / 2) + screenOffsetX + screenWidth / 2;
|
|
162
|
+
const offsetY = -(scaledImageHeight / 2) + screenOffsetY + screenHeight / 2;
|
|
163
|
+
const angleRad = (frameAngle * Math.PI) / 180;
|
|
164
|
+
const rotatedOffsetX = offsetX * Math.cos(angleRad) - offsetY * Math.sin(angleRad);
|
|
165
|
+
const rotatedOffsetY = offsetX * Math.sin(angleRad) + offsetY * Math.cos(angleRad);
|
|
166
|
+
|
|
167
|
+
// The frame's current scale relative to its base deviceScale (1 unless the user resized it).
|
|
168
|
+
const frameScaleRatioX = frameScaleX / scale;
|
|
169
|
+
const frameScaleRatioY = frameScaleY / scale;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
// The offset is already in base-scale canvas units, so it scales by the RATIO, not the
|
|
173
|
+
// absolute frame scale (≤0.2 editors multiplied by frameScale, shrinking off-centre screen
|
|
174
|
+
// offsets ~7× — visible on MacBooks, whose screen sits above the frame centre).
|
|
175
|
+
left: frameLeft + rotatedOffsetX * frameScaleRatioX,
|
|
176
|
+
top: frameTop + rotatedOffsetY * frameScaleRatioY,
|
|
177
|
+
scaleX: imgScaleX * frameScaleRatioX,
|
|
178
|
+
scaleY: imgScaleY * frameScaleRatioY,
|
|
179
|
+
// The screenshot's visual angle combines the frame rotation and its own rotation.
|
|
180
|
+
angle: frameAngle + rotation,
|
|
181
|
+
// The clipPath lives in the image's local (unscaled) coordinate space.
|
|
182
|
+
clip: {
|
|
183
|
+
width: naturalWidth,
|
|
184
|
+
height: naturalHeight,
|
|
185
|
+
rx: cornerRadius / imgScaleX,
|
|
186
|
+
ry: cornerRadius / imgScaleY
|
|
145
187
|
}
|
|
146
188
|
};
|
|
147
|
-
|
|
148
|
-
return { screenshot, frame };
|
|
149
189
|
}
|
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
|
}
|
package/src/validate.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LayerJSON, ScreenLayersJSON, Template } from './types';
|
|
2
2
|
import { CURRENT_SCHEMA_VERSION } from './types';
|
|
3
|
+
import { getDeviceFrame } from './device-frames';
|
|
3
4
|
|
|
4
5
|
const LAYER_TYPES = ['background', 'text', 'image', 'device', 'shape'];
|
|
5
6
|
|
|
@@ -83,6 +84,194 @@ export function generateLayerId(): string {
|
|
|
83
84
|
return `layer-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The only screenshot URL form a device layer's `fabricData.screenshot.src` may carry: a
|
|
89
|
+
* same-origin, root-relative reference to the user's uploaded asset (`/api/screenshots/<id>/raw`,
|
|
90
|
+
* which the Worker serves owner-only from private R2). Rejects external, protocol-relative,
|
|
91
|
+
* `data:`/`javascript:` and path-traversal URLs, so a handoff can't make the editor fetch
|
|
92
|
+
* arbitrary third-party images.
|
|
93
|
+
*/
|
|
94
|
+
export const UPLOADED_SCREENSHOT_SRC = /^\/api\/screenshots\/[A-Za-z0-9_-]{1,128}\/raw$/;
|
|
95
|
+
|
|
96
|
+
export function isUploadedScreenshotSrc(src: unknown): src is string {
|
|
97
|
+
return typeof src === 'string' && UPLOADED_SCREENSHOT_SRC.test(src);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A device layer's own `fabricData.src` — the frame PNG/WebP the editor serves from `static/devices/`
|
|
102
|
+
* (see `DeviceFrame.frameAsset`; the built-in templates still reference legacy `.png` names).
|
|
103
|
+
* Single path segment, no dots besides the extension, so no traversal.
|
|
104
|
+
*/
|
|
105
|
+
export const DEVICE_FRAME_SRC = /^\/devices\/[a-z0-9][a-z0-9-]*\.(?:webp|png)$/;
|
|
106
|
+
|
|
107
|
+
export function isDeviceFrameSrc(src: unknown): src is string {
|
|
108
|
+
return typeof src === 'string' && DEVICE_FRAME_SRC.test(src);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const SCREENSHOT_ROTATIONS = [0, 90, 180, 270];
|
|
112
|
+
const SCREENSHOT_KEYS = new Set(['src', 'width', 'height', 'rotation']);
|
|
113
|
+
|
|
114
|
+
/** Problems with a device layer's `fabricData.screenshot` (see `DeviceScreenshotJSON`); empty ⇒ valid. */
|
|
115
|
+
export function validateDeviceScreenshot(value: unknown, at = 'screenshot'): string[] {
|
|
116
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return [`${at} must be an object`];
|
|
117
|
+
const errors: string[] = [];
|
|
118
|
+
const shot = value as Record<string, unknown>;
|
|
119
|
+
for (const key of Object.keys(shot)) {
|
|
120
|
+
if (!SCREENSHOT_KEYS.has(key)) errors.push(`${at}.${key} is not allowed`);
|
|
121
|
+
}
|
|
122
|
+
if (!isUploadedScreenshotSrc(shot.src)) {
|
|
123
|
+
errors.push(`${at}.src must be an uploaded screenshot URL (/api/screenshots/<id>/raw)`);
|
|
124
|
+
}
|
|
125
|
+
for (const dim of ['width', 'height'] as const) {
|
|
126
|
+
const n = shot[dim];
|
|
127
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) {
|
|
128
|
+
errors.push(`${at}.${dim} must be a positive number`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (shot.rotation !== undefined && !SCREENSHOT_ROTATIONS.includes(shot.rotation as number)) {
|
|
132
|
+
errors.push(`${at}.rotation must be one of 0, 90, 180, 270`);
|
|
133
|
+
}
|
|
134
|
+
return errors;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Editor-internal Fabric properties that drive loading/placement at runtime. Never legitimate in a
|
|
139
|
+
* handoff: e.g. `pendingScreenshot` makes the editor (re)load an image URL as a frame's screenshot,
|
|
140
|
+
* which would bypass the `screenshot.src` rules below.
|
|
141
|
+
*/
|
|
142
|
+
export const EDITOR_INTERNAL_PROPS = [
|
|
143
|
+
'pendingScreenshot',
|
|
144
|
+
'_lastFrameScaleX',
|
|
145
|
+
'_lastFrameScaleY',
|
|
146
|
+
'_userScale',
|
|
147
|
+
'_userOffsetX',
|
|
148
|
+
'_userOffsetY'
|
|
149
|
+
] as const;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Keys that JSON.parse keeps as OWN properties but that change meaning once copied/enlivened
|
|
153
|
+
* (`__proto__` smuggles properties past `in`/dot checks; `constructor` breaks Fabric's enliven).
|
|
154
|
+
*/
|
|
155
|
+
export const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'] as const;
|
|
156
|
+
|
|
157
|
+
/** Problems for every forbidden key anywhere inside `value` (own keys only, depth-capped). */
|
|
158
|
+
export function findForbiddenKeys(value: unknown, at: string, depth = 0): string[] {
|
|
159
|
+
if (depth > 32 || !value || typeof value !== 'object') return [];
|
|
160
|
+
const errors: string[] = [];
|
|
161
|
+
const entries = Array.isArray(value)
|
|
162
|
+
? value.map((v, k) => [`[${k}]`, v] as const)
|
|
163
|
+
: Object.keys(value).map((k) => [`.${k}`, (value as Record<string, unknown>)[k]] as const);
|
|
164
|
+
for (const [suffix, v] of entries) {
|
|
165
|
+
const key = suffix.startsWith('.') ? suffix.slice(1) : null;
|
|
166
|
+
if (key !== null && (FORBIDDEN_KEYS as readonly string[]).includes(key)) {
|
|
167
|
+
errors.push(`${at}${suffix} is not allowed`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
errors.push(...findForbiddenKeys(v, `${at}${suffix}`, depth + 1));
|
|
171
|
+
}
|
|
172
|
+
return errors;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Any `src` / `source` (image layers, pattern fills, group children, clip paths…) must be one of these. */
|
|
176
|
+
function isAllowedImageRef(value: unknown): boolean {
|
|
177
|
+
return isUploadedScreenshotSrc(value) || isDeviceFrameSrc(value);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Collect problems with every `src`/`source` value anywhere inside `value`. */
|
|
181
|
+
function checkImageRefs(value: unknown, at: string, errors: string[], depth = 0): void {
|
|
182
|
+
if (depth > 32) {
|
|
183
|
+
errors.push(`${at} is nested too deeply`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
value.forEach((v, k) => checkImageRefs(v, `${at}[${k}]`, errors, depth + 1));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!value || typeof value !== 'object') return;
|
|
191
|
+
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
|
192
|
+
if ((key === 'src' || key === 'source') && !isAllowedImageRef(v)) {
|
|
193
|
+
errors.push(
|
|
194
|
+
`${at}.${key} must be an uploaded screenshot (/api/screenshots/<id>/raw) or a device frame asset (/devices/<name>.webp|png)`
|
|
195
|
+
);
|
|
196
|
+
} else if (v && typeof v === 'object') {
|
|
197
|
+
checkImageRefs(v, `${at}.${key}`, errors, depth + 1);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Per-layer fabricData rules for handoffs (see validateTemplate). */
|
|
203
|
+
function validateLayerFabricData(
|
|
204
|
+
layer: LayerJSON,
|
|
205
|
+
fd: Record<string, unknown>,
|
|
206
|
+
at: string,
|
|
207
|
+
frameIds: Set<string>
|
|
208
|
+
): string[] {
|
|
209
|
+
const errors: string[] = [];
|
|
210
|
+
const fdAt = `${at}.fabricData`;
|
|
211
|
+
|
|
212
|
+
for (const prop of EDITOR_INTERNAL_PROPS) {
|
|
213
|
+
if (prop in fd) errors.push(`${fdAt}.${prop} is editor-internal and not allowed`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Frame role/metadata only on device layers; screenshot role only on a legacy image layer.
|
|
217
|
+
const isLegacyScreenshot = fd.layerRole === 'screenshot';
|
|
218
|
+
if (layer.type === 'device') {
|
|
219
|
+
if (fd.layerRole !== undefined && fd.layerRole !== 'frame') {
|
|
220
|
+
errors.push(`${fdAt}.layerRole must be "frame" on a device layer`);
|
|
221
|
+
}
|
|
222
|
+
if (typeof fd.deviceId !== 'string' || !getDeviceFrame(fd.deviceId)) {
|
|
223
|
+
errors.push(`${fdAt}.deviceId must be a known device id`);
|
|
224
|
+
}
|
|
225
|
+
if (!isDeviceFrameSrc(fd.src)) {
|
|
226
|
+
errors.push(`${fdAt}.src must be a device frame asset (/devices/<name>.webp|png)`);
|
|
227
|
+
}
|
|
228
|
+
if (typeof fd.deviceFrameId === 'string' && fd.deviceFrameId) {
|
|
229
|
+
if (frameIds.has(fd.deviceFrameId)) {
|
|
230
|
+
errors.push(`${fdAt}.deviceFrameId "${fd.deviceFrameId}" is duplicated in this screen`);
|
|
231
|
+
}
|
|
232
|
+
frameIds.add(fd.deviceFrameId);
|
|
233
|
+
}
|
|
234
|
+
} else if (isLegacyScreenshot) {
|
|
235
|
+
// Legacy (≤0.2.0) separate screenshot layer: still accepted, but only in its real shape and
|
|
236
|
+
// held to the same uploaded-asset src rule as the 0.3.0 property.
|
|
237
|
+
if (layer.type !== 'image' || typeof fd.type !== 'string' || fd.type.toLowerCase() !== 'image') {
|
|
238
|
+
errors.push(`${fdAt}.layerRole "screenshot" is only allowed on image layers`);
|
|
239
|
+
}
|
|
240
|
+
if (!isUploadedScreenshotSrc(fd.src)) {
|
|
241
|
+
errors.push(`${fdAt}.src must be an uploaded screenshot URL (/api/screenshots/<id>/raw)`);
|
|
242
|
+
}
|
|
243
|
+
if (fd.deviceId !== undefined && (typeof fd.deviceId !== 'string' || !getDeviceFrame(fd.deviceId))) {
|
|
244
|
+
errors.push(`${fdAt}.deviceId must be a known device id`);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
if (fd.layerRole !== undefined) errors.push(`${fdAt}.layerRole is only allowed on device / legacy screenshot layers`);
|
|
248
|
+
for (const key of ['deviceFrameId', 'deviceId', 'deviceScale']) {
|
|
249
|
+
if (fd[key] !== undefined) errors.push(`${fdAt}.${key} is only allowed on device / legacy screenshot layers`);
|
|
250
|
+
}
|
|
251
|
+
if (fd.layerType === 'deviceFrame') errors.push(`${fdAt}.layerType "deviceFrame" is only allowed on device layers`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Device-owned screenshot (0.3.0+).
|
|
255
|
+
if (fd.screenshot !== undefined) {
|
|
256
|
+
if (layer.type !== 'device') errors.push(`${fdAt}.screenshot is only allowed on device layers`);
|
|
257
|
+
errors.push(...validateDeviceScreenshot(fd.screenshot, `${fdAt}.screenshot`));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Every other image reference (image layers, pattern fills, nested objects): same-origin
|
|
261
|
+
// uploaded screenshots or device frame assets only. (Top-level device/screenshot srcs were
|
|
262
|
+
// checked above with their stricter rule; skip them here to avoid duplicate messages.)
|
|
263
|
+
const { src: _src, screenshot: _screenshot, ...rest } = fd;
|
|
264
|
+
void _screenshot;
|
|
265
|
+
if (layer.type !== 'device' && !isLegacyScreenshot && _src !== undefined && !isAllowedImageRef(_src)) {
|
|
266
|
+
errors.push(
|
|
267
|
+
`${fdAt}.src must be an uploaded screenshot (/api/screenshots/<id>/raw) or a device frame asset (/devices/<name>.webp|png)`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
checkImageRefs(rest, fdAt, errors);
|
|
271
|
+
|
|
272
|
+
return errors;
|
|
273
|
+
}
|
|
274
|
+
|
|
86
275
|
export interface ValidationResult {
|
|
87
276
|
valid: boolean;
|
|
88
277
|
errors: string[];
|
|
@@ -115,8 +304,18 @@ export function validateTemplate(data: unknown): ValidationResult {
|
|
|
115
304
|
if (s.schemaVersion > CURRENT_SCHEMA_VERSION) {
|
|
116
305
|
errors.push(`screens[${i}] has unsupported schemaVersion ${s.schemaVersion}`);
|
|
117
306
|
}
|
|
307
|
+
const layerIds = new Set<string>();
|
|
308
|
+
const frameIds = new Set<string>();
|
|
118
309
|
s.layers.forEach((layer, j) => {
|
|
119
|
-
|
|
310
|
+
const at = `screens[${i}].layers[${j}]`;
|
|
311
|
+
if (!isValidLayerJSON(layer)) errors.push(`${at} is invalid`);
|
|
312
|
+
errors.push(...findForbiddenKeys(layer, at));
|
|
313
|
+
if (layerIds.has(layer.id)) errors.push(`${at}.id "${layer.id}" is duplicated in this screen`);
|
|
314
|
+
layerIds.add(layer.id);
|
|
315
|
+
|
|
316
|
+
const fd = layer.fabricData as Record<string, unknown> | null;
|
|
317
|
+
if (!fd || typeof fd !== 'object') return;
|
|
318
|
+
errors.push(...validateLayerFabricData(layer, fd, at, frameIds));
|
|
120
319
|
});
|
|
121
320
|
});
|
|
122
321
|
}
|