@appshoteditor/shot-dsl 0.1.2 → 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 CHANGED
@@ -10,11 +10,17 @@ 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`, `makeDeviceFrameLayers`, `calculateDeviceScale`.
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`.
20
+ Per screen: `headline`, optional `subheadline` / `headlineColor` / `subheadlineColor`, and `layout`
21
+ (`text-top` default, `text-bottom`, `device-bleed` — see `COMPOSE_LAYOUTS`). All geometry (device
22
+ scale/position, font sizes, padding) is derived from the canvas size, so a plan composes to the
23
+ same proportions at 280×608 editor units or 1320×2868 native pixels.
18
24
 
19
25
  ```ts
20
26
  import { composeTemplate, validateTemplate } from '@appshoteditor/shot-dsl';
@@ -24,6 +30,8 @@ const template = composeTemplate({
24
30
  screens: [
25
31
  {
26
32
  headline: 'Track every workout',
33
+ subheadline: 'Sets, reps and rest — logged for you',
34
+ layout: 'text-top',
27
35
  background: { type: 'gradient', gradient: { type: 'linear', colorStops: [/* … */] } },
28
36
  deviceId: 'iphone_16_pro',
29
37
  screenshot: { url: '…', width: 1179, height: 2556 }
@@ -33,6 +41,69 @@ const template = composeTemplate({
33
41
  validateTemplate(template); // { valid: true, errors: [] }
34
42
  ```
35
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
+
36
107
  Zero runtime dependencies. The `schemaVersion` is the compatibility contract between producers and the editor.
37
108
 
38
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.1.2",
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
- import type { BackgroundJSON, Template } from './types';
1
+ import type { BackgroundJSON, LayerJSON, Template } from './types';
2
2
  import { makeTextLayer, makeScreen, makeTemplate } from './builders';
3
- import { makeDeviceFrameLayers } from './frames';
3
+ import { makeDeviceFrameLayer } from './frames';
4
4
  import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
5
5
 
6
6
  /**
@@ -22,10 +22,27 @@ function canvasDimsForDevice(deviceId: string): { width: number; height: number
22
22
  }
23
23
  }
24
24
 
25
+ /**
26
+ * Screen layout variants:
27
+ * - `text-top` (default): headline (+ subheadline) at the top, device below, bleeding off the bottom.
28
+ * - `text-bottom`: device at the top bleeding off the TOP edge, text block anchored to the bottom.
29
+ * - `device-bleed`: text at the top, an oversized device (~95% of the width) bleeding heavily off
30
+ * the bottom — the "hero" look.
31
+ */
32
+ export type ComposeLayout = 'text-top' | 'text-bottom' | 'device-bleed';
33
+
34
+ export const COMPOSE_LAYOUTS: readonly ComposeLayout[] = ['text-top', 'text-bottom', 'device-bleed'];
35
+
25
36
  /** One screen's worth of plan input (the skill decides these per benefit). */
26
37
  export interface ComposeScreenPlan {
27
38
  headline: string;
28
39
  headlineColor?: string;
40
+ /** Optional short supporting line under the headline (smaller, slightly muted). */
41
+ subheadline?: string;
42
+ /** Defaults to `headlineColor` (rendered at reduced opacity). */
43
+ subheadlineColor?: string;
44
+ /** Defaults to `text-top`. */
45
+ layout?: ComposeLayout;
29
46
  background: BackgroundJSON;
30
47
  screenshot: { url: string; width: number; height: number };
31
48
  deviceId: string;
@@ -38,11 +55,103 @@ export interface ComposePlan {
38
55
  canvasHeight?: number;
39
56
  }
40
57
 
58
+ // ---------------------------------------------------------------------------
59
+ // Layout proportions. EVERYTHING below is a fraction of the canvas — there are no absolute pixel
60
+ // constants, so the same plan composes to a proportionally identical layout at 280×608 (editor
61
+ // units) or 1320×2868 (native iPhone 6.9" pixels).
62
+ // ---------------------------------------------------------------------------
63
+
41
64
  /**
42
- * Deterministically assemble a Template from a plan. Each screen gets a background,
43
- * a device-framed screenshot sitting in the lower ~60%, and a headline across the
44
- * top (marked editable so the user can tweak it in the editor). Claude decides the
45
- * plan (which benefit, copy, device, palette); this turns it into valid DSL.
65
+ * Typographic unit. On portrait phone canvases this is the canvas width; on squatter canvases
66
+ * (tablet, laptop) it's capped by the height so text doesn't swallow a landscape canvas.
67
+ */
68
+ const TYPE_UNIT_HEIGHT_CAP = 0.55; // unit = min(W, 0.55·H)
69
+ const HEADLINE_SIZE = 0.085; // × unit — for headlines that fit in ≤ 2 lines
70
+ const HEADLINE_SIZE_LONG = 0.072; // × unit — fallback when the headline would wrap to 3+ lines
71
+ const HEADLINE_LINE_HEIGHT = 1.1;
72
+ const SUBHEADLINE_RATIO = 0.55; // subheadline size ÷ headline size
73
+ const SUBHEADLINE_LINE_HEIGHT = 1.25;
74
+ const SUBHEADLINE_OPACITY = 0.85;
75
+ const TEXT_WIDTH = 0.84; // × W → 8% side padding each side
76
+ /** Rough average glyph advance for Inter at heavy weights, as a fraction of the font size. */
77
+ const AVG_CHAR_WIDTH = 0.58;
78
+
79
+ const EDGE_MARGIN = 0.055; // × H — gap between the text block and the canvas edge
80
+ const TEXT_GAP = 0.3; // × headline font size — headline ↔ subheadline gap
81
+ const DEVICE_GAP = 0.04; // × unit — text block ↔ device gap
82
+
83
+ interface LayoutSpec {
84
+ /** Target rendered device width as a fraction of the canvas width. */
85
+ deviceWidth: number;
86
+ /** Max fraction of the device's height allowed off-canvas (bounds the scale on squat canvases). */
87
+ maxBleed: number;
88
+ /** Device can't start above this fraction of H (text-top variants) — pushes the hero lower. */
89
+ minDeviceTop: number;
90
+ }
91
+
92
+ const LAYOUTS: Record<ComposeLayout, LayoutSpec> = {
93
+ 'text-top': { deviceWidth: 0.86, maxBleed: 0.2, minDeviceTop: 0 },
94
+ 'text-bottom': { deviceWidth: 0.9, maxBleed: 0.2, minDeviceTop: 0 },
95
+ 'device-bleed': { deviceWidth: 0.95, maxBleed: 0.4, minDeviceTop: 0.28 }
96
+ };
97
+
98
+ /** Greedy word-wrap estimate of how many lines `text` takes at `fontSize` in a box `width` wide. */
99
+ function estimateLines(text: string, fontSize: number, width: number): number {
100
+ const maxChars = Math.max(1, Math.floor(width / (fontSize * AVG_CHAR_WIDTH)));
101
+ let lines = 0;
102
+ for (const paragraph of text.split('\n')) {
103
+ let current = 0;
104
+ lines++;
105
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
106
+ const len = word.length;
107
+ if (current === 0) {
108
+ current = len;
109
+ } else if (current + 1 + len <= maxChars) {
110
+ current += 1 + len;
111
+ } else {
112
+ lines++;
113
+ current = len;
114
+ }
115
+ // A single word longer than a line wraps mid-word in Fabric's Textbox.
116
+ while (current > maxChars) {
117
+ lines++;
118
+ current -= maxChars;
119
+ }
120
+ }
121
+ }
122
+ return Math.max(1, lines);
123
+ }
124
+
125
+ interface TextBlock {
126
+ headlineSize: number;
127
+ headlineHeight: number;
128
+ subSize: number;
129
+ subHeight: number;
130
+ gap: number;
131
+ height: number;
132
+ }
133
+
134
+ function measureTextBlock(screen: ComposeScreenPlan, unit: number, textWidth: number): TextBlock {
135
+ let headlineSize = unit * HEADLINE_SIZE;
136
+ if (estimateLines(screen.headline, headlineSize, textWidth) > 2) headlineSize = unit * HEADLINE_SIZE_LONG;
137
+ const headlineHeight =
138
+ estimateLines(screen.headline, headlineSize, textWidth) * headlineSize * HEADLINE_LINE_HEIGHT;
139
+
140
+ const hasSub = !!screen.subheadline?.trim();
141
+ const subSize = headlineSize * SUBHEADLINE_RATIO;
142
+ const subHeight = hasSub
143
+ ? estimateLines(screen.subheadline!, subSize, textWidth) * subSize * SUBHEADLINE_LINE_HEIGHT
144
+ : 0;
145
+ const gap = hasSub ? headlineSize * TEXT_GAP : 0;
146
+ return { headlineSize, headlineHeight, subSize, subHeight, gap, height: headlineHeight + gap + subHeight };
147
+ }
148
+
149
+ /**
150
+ * Deterministically assemble a Template from a plan. Each screen gets a background, a large
151
+ * device frame carrying its screenshot and a bold headline (+ optional subheadline), arranged per the screen's
152
+ * `layout`. All geometry is derived from the canvas size. Text layers are marked editable so the
153
+ * user can tweak them in the editor. Claude decides the plan (benefit, copy, device, palette,
154
+ * layout); this turns it into valid DSL.
46
155
  */
47
156
  export function composeTemplate(plan: ComposePlan): Template {
48
157
  const screens = plan.screens.map((screen) => {
@@ -50,45 +159,101 @@ export function composeTemplate(plan: ComposePlan): Template {
50
159
  // old single-size behavior), else derive from the screen's device class so a mixed-device
51
160
  // plan gets the correct aspect per screen.
52
161
  const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
53
- const { width: canvasWidth, height: canvasHeight } = explicit
162
+ const { width: W, height: H } = explicit
54
163
  ? { width: plan.canvasWidth ?? 280, height: plan.canvasHeight ?? 600 }
55
164
  : canvasDimsForDevice(screen.deviceId);
56
165
 
57
- const { screenshot, frame } = makeDeviceFrameLayers({
166
+ const layout: ComposeLayout = screen.layout ?? 'text-top';
167
+ const spec = LAYOUTS[layout] ?? LAYOUTS['text-top'];
168
+ const unit = Math.min(W, H * TYPE_UNIT_HEIGHT_CAP);
169
+ const textWidth = W * TEXT_WIDTH;
170
+ const margin = H * EDGE_MARGIN;
171
+ const deviceGap = unit * DEVICE_GAP;
172
+ const block = measureTextBlock(screen, unit, textWidth);
173
+
174
+ // Text block vertical extent.
175
+ const blockTop = layout === 'text-bottom' ? H - margin - block.height : margin;
176
+
177
+ // Device size: target a fraction of the canvas width, but never let more than `maxBleed` of
178
+ // the device fall off-canvas (keeps squat tablet/laptop canvases sane).
179
+ const device = getDeviceFrame(screen.deviceId);
180
+ if (!device) throw new Error(`Unknown device: ${screen.deviceId}`);
181
+ const { width: fw, height: fh } = device.imageDimensions;
182
+ let scale: number;
183
+ let centerY: number;
184
+ if (layout === 'text-bottom') {
185
+ const deviceBottom = blockTop - deviceGap;
186
+ scale = Math.min((W * spec.deviceWidth) / fw, deviceBottom / (1 - spec.maxBleed) / fh);
187
+ centerY = deviceBottom - (fh * scale) / 2; // top edge bleeds off the top when tall
188
+ } else {
189
+ const deviceTop = Math.max(margin + block.height + deviceGap, H * spec.minDeviceTop);
190
+ scale = Math.min((W * spec.deviceWidth) / fw, (H - deviceTop) / (1 - spec.maxBleed) / fh);
191
+ centerY = deviceTop + (fh * scale) / 2; // bottom edge bleeds off the bottom when tall
192
+ }
193
+
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({
58
197
  deviceId: screen.deviceId,
59
198
  screenshotUrl: screen.screenshot.url,
60
199
  screenshotWidth: screen.screenshot.width,
61
200
  screenshotHeight: screen.screenshot.height,
62
- canvasWidth,
63
- canvasHeight,
64
- centerY: canvasHeight * 0.6 // sit the device lower, leaving room for the headline
201
+ canvasWidth: W,
202
+ canvasHeight: H,
203
+ centerX: W / 2,
204
+ centerY,
205
+ scale
65
206
  });
66
207
 
67
- // Center origin (editor convention): left/top are the box CENTER. Center the
68
- // headline horizontally and sit it near the top, leaving room for the device.
208
+ // Center origin (editor convention): left/top are the box CENTER.
209
+ const headlineColor = screen.headlineColor ?? '#ffffff';
69
210
  const headline = makeTextLayer({
70
211
  text: screen.headline,
71
- left: canvasWidth / 2,
72
- top: canvasHeight * 0.12,
73
- width: canvasWidth * 0.84,
74
- fontSize: 26,
212
+ left: W / 2,
213
+ top: blockTop + block.headlineHeight / 2,
214
+ width: textWidth,
215
+ fontSize: block.headlineSize,
75
216
  fontWeight: '800',
76
- fill: screen.headlineColor ?? '#ffffff',
217
+ lineHeight: HEADLINE_LINE_HEIGHT,
218
+ fill: headlineColor,
77
219
  textAlign: 'center',
78
220
  name: 'Headline',
79
221
  templateRole: 'editable',
80
222
  templateKey: 'headline'
81
223
  });
82
224
 
83
- // z-order: screenshot (bottom) -> frame -> headline (top)
225
+ // BOTTOM → TOP (canvas add order): device -> headline -> subheadline.
226
+ const layers: LayerJSON[] = [frame, headline];
227
+
228
+ if (screen.subheadline?.trim()) {
229
+ const sub = makeTextLayer({
230
+ text: screen.subheadline,
231
+ left: W / 2,
232
+ top: blockTop + block.headlineHeight + block.gap + block.subHeight / 2,
233
+ width: textWidth,
234
+ fontSize: block.subSize,
235
+ fontWeight: '500',
236
+ lineHeight: SUBHEADLINE_LINE_HEIGHT,
237
+ fill: screen.subheadlineColor ?? headlineColor,
238
+ textAlign: 'center',
239
+ name: 'Subheadline',
240
+ templateRole: 'editable',
241
+ templateKey: 'subheadline'
242
+ });
243
+ // When it inherits the headline color, mute it slightly (standard Fabric `opacity`, editable
244
+ // in the editor). An explicit subheadlineColor is used as-is.
245
+ if (!screen.subheadlineColor) (sub.fabricData as Record<string, unknown>).opacity = SUBHEADLINE_OPACITY;
246
+ layers.push(sub);
247
+ }
248
+
84
249
  return makeScreen({
85
250
  background: screen.background,
86
- canvasWidth,
87
- canvasHeight,
251
+ canvasWidth: W,
252
+ canvasHeight: H,
88
253
  // Tag the device GROUP (multi-device) so a mixed plan lands as separate sidebar groups in
89
254
  // the editor instead of relying on frame inference. Absent ⇒ editor infers it.
90
255
  deviceClass: deviceClassForDeviceId(screen.deviceId) ?? undefined,
91
- layers: [screenshot, frame, headline]
256
+ layers
92
257
  });
93
258
  });
94
259
 
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 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;
@@ -47,7 +46,7 @@ export function makeDeviceFrameLayers(opts: {
47
46
  scale?: number;
48
47
  screenshotRotation?: number;
49
48
  name?: string;
50
- }): { screenshot: LayerJSON; frame: LayerJSON } {
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 frameCenterX = opts.centerX ?? canvasWidth / 2;
70
- const frameCenterY = opts.centerY ?? canvasHeight / 2;
142
+ const naturalWidth = natural.width || 1;
143
+ const naturalHeight = natural.height || 1;
71
144
 
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;
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 screenshot to exactly fill the screen bounds.
77
- const imgScaleX = screenWidth / opts.screenshotWidth;
78
- const imgScaleY = screenHeight / opts.screenshotHeight;
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 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
- };
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
- 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
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
- /** 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
  }
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
- if (!isValidLayerJSON(layer)) errors.push(`screens[${i}].layers[${j}] is invalid`);
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
  }