@appshoteditor/shot-dsl 0.3.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 +140 -6
- package/package.json +5 -2
- package/src/color.ts +76 -0
- package/src/compose.ts +1271 -108
- package/src/frames.ts +3 -0
- package/src/index.ts +3 -0
- package/src/layout-system.ts +302 -0
- package/src/variants.ts +125 -0
package/src/compose.ts
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
import type { BackgroundJSON, LayerJSON, Template } from './types';
|
|
2
|
-
import { makeTextLayer, makeScreen, makeTemplate } from './builders';
|
|
1
|
+
import type { BackgroundJSON, ColorStop, LayerJSON, Template } from './types';
|
|
2
|
+
import { makeTextLayer, makeShapeLayer, makeScreen, makeTemplate } from './builders';
|
|
3
3
|
import { makeDeviceFrameLayer } from './frames';
|
|
4
4
|
import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
|
|
5
|
+
import { generateLayerId } from './validate';
|
|
6
|
+
import {
|
|
7
|
+
NO_TANGENT,
|
|
8
|
+
focusCorners,
|
|
9
|
+
focusReach,
|
|
10
|
+
focusXExtent,
|
|
11
|
+
inTangentZone,
|
|
12
|
+
rectsOverlap,
|
|
13
|
+
rotatedBox,
|
|
14
|
+
solveVertical,
|
|
15
|
+
subjectPointToCanvas,
|
|
16
|
+
visibleXExtent,
|
|
17
|
+
type BleedPreference,
|
|
18
|
+
type FocusBand,
|
|
19
|
+
type Subject,
|
|
20
|
+
type VerticalResult
|
|
21
|
+
} from './layout-system';
|
|
22
|
+
import { darken, isHexColor, lighten, mixHex, readableTextOn, rgba } from './color';
|
|
5
23
|
|
|
6
24
|
/**
|
|
7
25
|
* Editor-unit canvas dimensions for a device, by frame class — so a plan that mixes iPhone, iPad,
|
|
@@ -9,7 +27,7 @@ import { getDeviceFrame, deviceClassForDeviceId } from './device-frames';
|
|
|
9
27
|
* Page Setup presets (phone 280×608, tablet 450×600, laptop/desktop 608×380). Export scales these
|
|
10
28
|
* up to the real App Store pixel sizes.
|
|
11
29
|
*/
|
|
12
|
-
function canvasDimsForDevice(deviceId: string): { width: number; height: number } {
|
|
30
|
+
export function canvasDimsForDevice(deviceId: string): { width: number; height: number } {
|
|
13
31
|
switch (getDeviceFrame(deviceId)?.category) {
|
|
14
32
|
case 'tablet':
|
|
15
33
|
return { width: 450, height: 600 };
|
|
@@ -24,15 +42,82 @@ function canvasDimsForDevice(deviceId: string): { width: number; height: number
|
|
|
24
42
|
|
|
25
43
|
/**
|
|
26
44
|
* Screen layout variants:
|
|
27
|
-
* - `text-top` (default): headline (+ subheadline) at the top, device below
|
|
28
|
-
* - `text-bottom`: device at the top bleeding off the TOP edge, text block
|
|
29
|
-
* - `device-bleed`: text at the top,
|
|
30
|
-
* the bottom — the "hero" look.
|
|
45
|
+
* - `text-top` (default): headline (+ subheadline) at the top, device below.
|
|
46
|
+
* - `text-bottom`: device at the top (bleeding off the TOP edge when it bleeds), text block at the bottom.
|
|
47
|
+
* - `device-bleed`: text at the top, the device (≤ 90% of the width) pushed low and bleeding more — the "hero" look.
|
|
31
48
|
*/
|
|
32
49
|
export type ComposeLayout = 'text-top' | 'text-bottom' | 'device-bleed';
|
|
33
|
-
|
|
34
50
|
export const COMPOSE_LAYOUTS: readonly ComposeLayout[] = ['text-top', 'text-bottom', 'device-bleed'];
|
|
35
51
|
|
|
52
|
+
/**
|
|
53
|
+
* How a screenshot is presented:
|
|
54
|
+
* - `device` (default): inside a device frame mockup.
|
|
55
|
+
* - `frameless`: the bare screenshot with rounded corners + a soft shadow.
|
|
56
|
+
* - `zoom`: a magnified crop of the screen's selling UI (`crop`, else the `focus` band), rounded + shadow.
|
|
57
|
+
*/
|
|
58
|
+
export type ComposePresentation = 'device' | 'frameless' | 'zoom';
|
|
59
|
+
export const COMPOSE_PRESENTATIONS: readonly ComposePresentation[] = ['device', 'frameless', 'zoom'];
|
|
60
|
+
|
|
61
|
+
/** `auto` (default): the no-tangent rule picks clear-with-margin or a decisive bleed. */
|
|
62
|
+
export type ComposeBleed = BleedPreference;
|
|
63
|
+
export const COMPOSE_BLEEDS: readonly ComposeBleed[] = ['auto', 'none', 'deep'];
|
|
64
|
+
|
|
65
|
+
/** Fonts the editor offers (Inter is web-loaded; the rest are system fonts). */
|
|
66
|
+
export const COMPOSE_FONTS: readonly string[] = [
|
|
67
|
+
'Inter',
|
|
68
|
+
'Arial',
|
|
69
|
+
'Helvetica',
|
|
70
|
+
'Georgia',
|
|
71
|
+
'Times New Roman',
|
|
72
|
+
'Courier New',
|
|
73
|
+
'Verdana',
|
|
74
|
+
'Trebuchet MS',
|
|
75
|
+
'Impact',
|
|
76
|
+
'Comic Sans MS'
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** Fractions (0–1) of the screenshot: a crop rectangle for `zoom`. */
|
|
80
|
+
export interface ComposeCrop {
|
|
81
|
+
x: number;
|
|
82
|
+
y: number;
|
|
83
|
+
w: number;
|
|
84
|
+
h: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Set-wide background system, used for screens that omit `background`. */
|
|
88
|
+
export interface ComposePalette {
|
|
89
|
+
/** `family`: every screen shares one gradient through `colors`. `sequence`: screen i uses colors[i % n]. */
|
|
90
|
+
mode: 'family' | 'sequence';
|
|
91
|
+
colors: string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ComposePanorama {
|
|
95
|
+
/** Runs of adjacent screen indices sharing one continuous background, e.g. [[0, 1], [2, 3]]. */
|
|
96
|
+
spans: number[][];
|
|
97
|
+
/**
|
|
98
|
+
* Devices that cross a seam into the next screen: `true` = the first screen of EVERY span, or a
|
|
99
|
+
* list of screen indices (each must be the first screen of a span). Default none. More than one
|
|
100
|
+
* straddle per set is allowed but lint-warned (`panorama-straddle-count`).
|
|
101
|
+
*/
|
|
102
|
+
straddle?: boolean | number[];
|
|
103
|
+
/** Soft decorative circles straddling each seam. Default `orbs`. */
|
|
104
|
+
decoration?: 'orbs' | 'none';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Plan-level style (all optional; an empty style composes exactly like a plan without one). */
|
|
108
|
+
export interface ComposeStyle {
|
|
109
|
+
presentation?: ComposePresentation;
|
|
110
|
+
/** Degrees (clockwise) for the accented screens listed in `tiltScreens`. Default 0. */
|
|
111
|
+
tilt?: number;
|
|
112
|
+
/** Screen indices that get `tilt`. Default: none. */
|
|
113
|
+
tiltScreens?: number[];
|
|
114
|
+
bleed?: ComposeBleed;
|
|
115
|
+
palette?: ComposePalette;
|
|
116
|
+
panorama?: ComposePanorama;
|
|
117
|
+
/** Font family for all text (one of COMPOSE_FONTS). Default Inter. */
|
|
118
|
+
font?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
36
121
|
/** One screen's worth of plan input (the skill decides these per benefit). */
|
|
37
122
|
export interface ComposeScreenPlan {
|
|
38
123
|
headline: string;
|
|
@@ -43,9 +128,23 @@ export interface ComposeScreenPlan {
|
|
|
43
128
|
subheadlineColor?: string;
|
|
44
129
|
/** Defaults to `text-top`. */
|
|
45
130
|
layout?: ComposeLayout;
|
|
46
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Required unless the plan has `style.palette`. In a panorama span every screen shows the span's
|
|
133
|
+
* FIRST screen's background.
|
|
134
|
+
*/
|
|
135
|
+
background?: BackgroundJSON;
|
|
47
136
|
screenshot: { url: string; width: number; height: number };
|
|
48
137
|
deviceId: string;
|
|
138
|
+
/** Fractions of the screenshot height that must stay visible (the selling UI). */
|
|
139
|
+
focus?: FocusBand;
|
|
140
|
+
/** Zoom crop (fractions of the screenshot); defaults to the focus band. */
|
|
141
|
+
crop?: ComposeCrop;
|
|
142
|
+
/** Overrides `style.presentation` for this screen. */
|
|
143
|
+
presentation?: ComposePresentation;
|
|
144
|
+
/** Overrides the style tilt for this screen (degrees, clockwise). */
|
|
145
|
+
tilt?: number;
|
|
146
|
+
/** Short social-proof pill above the headline, e.g. "Teacher-approved". */
|
|
147
|
+
badge?: string;
|
|
49
148
|
}
|
|
50
149
|
|
|
51
150
|
export interface ComposePlan {
|
|
@@ -53,6 +152,59 @@ export interface ComposePlan {
|
|
|
53
152
|
screens: ComposeScreenPlan[];
|
|
54
153
|
canvasWidth?: number;
|
|
55
154
|
canvasHeight?: number;
|
|
155
|
+
style?: ComposeStyle;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ComposeWarning {
|
|
159
|
+
/** Screen index, when the warning is about one screen. */
|
|
160
|
+
screen?: number;
|
|
161
|
+
code:
|
|
162
|
+
| 'headline-words'
|
|
163
|
+
| 'headline-lines'
|
|
164
|
+
| 'subheadline-lines'
|
|
165
|
+
| 'tilt-count'
|
|
166
|
+
| 'panorama-seam'
|
|
167
|
+
| 'straddle-skipped'
|
|
168
|
+
| 'tilt-reduced'
|
|
169
|
+
| 'panorama-straddle-count'
|
|
170
|
+
| 'zoom-focus-cropped'
|
|
171
|
+
| 'badge-long';
|
|
172
|
+
message: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Per-screen geometry summary (canvas-relative), for reports and tests. */
|
|
176
|
+
export interface ComposeScreenMetrics {
|
|
177
|
+
index: number;
|
|
178
|
+
presentation: ComposePresentation;
|
|
179
|
+
layout: ComposeLayout;
|
|
180
|
+
/** Screens with the same group share scale, baseline and far-edge treatment. */
|
|
181
|
+
group: string;
|
|
182
|
+
/** Rendered subject scale (device scaleX; image scaleX for frameless/zoom). */
|
|
183
|
+
scale: number;
|
|
184
|
+
/** Rendered (unrotated) subject width ÷ W. */
|
|
185
|
+
widthFraction: number;
|
|
186
|
+
/** Subject's rotated bounding-box top / bottom ÷ H. */
|
|
187
|
+
top: number;
|
|
188
|
+
bottom: number;
|
|
189
|
+
/** Far-edge overshoot ÷ H (> 0 bleeds off the canvas). */
|
|
190
|
+
overshoot: number;
|
|
191
|
+
/** Far-edge overshoot ÷ the subject's rendered (rotated) height. */
|
|
192
|
+
bleedFraction: number;
|
|
193
|
+
mode: 'clear' | 'bleed';
|
|
194
|
+
/** True if the far edge sits in the forbidden "just touching" band (never, by construction). */
|
|
195
|
+
tangent: boolean;
|
|
196
|
+
/** Headline font size ÷ W. */
|
|
197
|
+
headlineSize: number;
|
|
198
|
+
/** Headline box top ÷ H (identical across a set by construction). */
|
|
199
|
+
headlineTop: number;
|
|
200
|
+
tilt: number;
|
|
201
|
+
/** Horizontal centre ÷ W (≠ 0.5 for a straddling panorama device). */
|
|
202
|
+
centerX: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface ComposeReport {
|
|
206
|
+
warnings: ComposeWarning[];
|
|
207
|
+
screens: ComposeScreenMetrics[];
|
|
56
208
|
}
|
|
57
209
|
|
|
58
210
|
// ---------------------------------------------------------------------------
|
|
@@ -66,20 +218,73 @@ export interface ComposePlan {
|
|
|
66
218
|
* (tablet, laptop) it's capped by the height so text doesn't swallow a landscape canvas.
|
|
67
219
|
*/
|
|
68
220
|
const TYPE_UNIT_HEIGHT_CAP = 0.55; // unit = min(W, 0.55·H)
|
|
69
|
-
|
|
70
|
-
const
|
|
221
|
+
/** × unit — ONE headline size for the whole set (copy that doesn't fit is a lint warning, never a shrink). */
|
|
222
|
+
export const HEADLINE_SIZE = 0.085;
|
|
71
223
|
const HEADLINE_LINE_HEIGHT = 1.1;
|
|
72
224
|
const SUBHEADLINE_RATIO = 0.55; // subheadline size ÷ headline size
|
|
73
225
|
const SUBHEADLINE_LINE_HEIGHT = 1.25;
|
|
74
226
|
const SUBHEADLINE_OPACITY = 0.85;
|
|
75
227
|
const TEXT_WIDTH = 0.84; // × W → 8% side padding each side
|
|
76
|
-
/**
|
|
77
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Conservative per-font text metrics for line-wrap estimates (and so the reserved text area):
|
|
230
|
+
* px per character "unit" (see `charUnits`; 1 unit ≈ an average lowercase letter) × the font size.
|
|
231
|
+
* Calibrated from node-canvas measurements of macOS system fonts (bold, mixed case, ALL CAPS,
|
|
232
|
+
* narrow/wide stress strings) + ~10% headroom, so an estimate of N lines never renders as N+1;
|
|
233
|
+
* see src/lib/utils/composeFonts.canvas.test.ts in the app. Inter (web font, not installed for the
|
|
234
|
+
* measurement) is set ~10% wider than Arial. Courier New is monospace: every character is 1 unit.
|
|
235
|
+
*/
|
|
236
|
+
export const FONT_CHAR_WIDTH: Readonly<Record<string, number>> = {
|
|
237
|
+
Inter: 0.66,
|
|
238
|
+
Arial: 0.64,
|
|
239
|
+
Helvetica: 0.64,
|
|
240
|
+
Georgia: 0.72,
|
|
241
|
+
'Times New Roman': 0.63,
|
|
242
|
+
'Courier New': 0.66,
|
|
243
|
+
Verdana: 0.76,
|
|
244
|
+
'Trebuchet MS': 0.68,
|
|
245
|
+
Impact: 0.64,
|
|
246
|
+
'Comic Sans MS': 0.76
|
|
247
|
+
};
|
|
248
|
+
const MONOSPACE_FONTS = new Set(['Courier New']);
|
|
249
|
+
const AVG_CHAR_WIDTH = FONT_CHAR_WIDTH.Inter;
|
|
250
|
+
|
|
251
|
+
/** A font name (→ FONT_CHAR_WIDTH) or a raw px-per-unit factor. */
|
|
252
|
+
export type TextMetrics = string | number;
|
|
253
|
+
const metricsOf = (m: TextMetrics) =>
|
|
254
|
+
typeof m === 'number' ? { charWidth: m, mono: false } : { charWidth: FONT_CHAR_WIDTH[m] ?? AVG_CHAR_WIDTH, mono: MONOSPACE_FONTS.has(m) };
|
|
78
255
|
|
|
79
256
|
const EDGE_MARGIN = 0.055; // × H — gap between the text block and the canvas edge
|
|
80
257
|
const TEXT_GAP = 0.3; // × headline font size — headline ↔ subheadline gap
|
|
81
258
|
const DEVICE_GAP = 0.04; // × unit — text block ↔ device gap
|
|
82
259
|
|
|
260
|
+
/** Copy rules (lint). */
|
|
261
|
+
export const COPY_RULES = { headlineMaxWords: 5, headlineMaxLines: 2, subheadlineMaxLines: 1, maxTiltedScreens: 2 } as const;
|
|
262
|
+
|
|
263
|
+
// Badge pill (social proof), reserved as a row above the headline set-wide when any screen has one.
|
|
264
|
+
const BADGE_FONT = 0.36; // × headline size
|
|
265
|
+
const BADGE_PAD_X = 1.1; // × badge font
|
|
266
|
+
const BADGE_HEIGHT = 2.1; // × badge font
|
|
267
|
+
const BADGE_GAP = 0.45; // × headline size — badge ↔ headline
|
|
268
|
+
const BADGE_MAX_CHARS = 28;
|
|
269
|
+
|
|
270
|
+
// Frameless / zoom styling.
|
|
271
|
+
const FRAMELESS_WIDTH = 0.9; // × the layout's device width target (no bezel → a touch narrower)
|
|
272
|
+
const FRAMELESS_RADIUS = 0.1; // × rendered width
|
|
273
|
+
const ZOOM_WIDTH = 0.88; // × W
|
|
274
|
+
const ZOOM_RADIUS = 0.05; // × W
|
|
275
|
+
const ZOOM_MAX_MAG = 2; // focus-derived zoom: at most 2× the full-width fit
|
|
276
|
+
const ZOOM_MIN_ASPECT = 0.5; // card height ≥ half its width
|
|
277
|
+
const SHADOW = { color: 'rgba(0,0,0,0.28)', blur: 0.07, offsetY: 0.025 }; // blur/offset × W
|
|
278
|
+
|
|
279
|
+
// Panorama.
|
|
280
|
+
const STRADDLE_OVERLAP = 0.18; // × the device's rendered bounding-box width that crosses the seam
|
|
281
|
+
const STRADDLE_MIN = 0.06; // below this a straddle is not worth it (skipped + warning)
|
|
282
|
+
const MAX_STRADDLES = 1; // seam crossings per set before a lint warning
|
|
283
|
+
const ORB_RADIUS = 0.34; // × W
|
|
284
|
+
const ORB_Y = 0.7; // × H
|
|
285
|
+
const ORB_MIN_RADIUS = 0.08; // × W — smaller than this, the orb is dropped
|
|
286
|
+
const ORB_TEXT_GAP = 0.02; // × H — orbs keep this far from a text block
|
|
287
|
+
|
|
83
288
|
interface LayoutSpec {
|
|
84
289
|
/** Target rendered device width as a fraction of the canvas width. */
|
|
85
290
|
deviceWidth: number;
|
|
@@ -92,146 +297,1014 @@ interface LayoutSpec {
|
|
|
92
297
|
const LAYOUTS: Record<ComposeLayout, LayoutSpec> = {
|
|
93
298
|
'text-top': { deviceWidth: 0.86, maxBleed: 0.2, minDeviceTop: 0 },
|
|
94
299
|
'text-bottom': { deviceWidth: 0.9, maxBleed: 0.2, minDeviceTop: 0 },
|
|
95
|
-
|
|
300
|
+
// Capped at NO_TANGENT.maxWidth (90% W — a 5% side margin); the hero differs by sitting lower.
|
|
301
|
+
'device-bleed': { deviceWidth: 0.9, maxBleed: 0.4, minDeviceTop: 0.28 }
|
|
96
302
|
};
|
|
97
303
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Relative advance of a character (1 = an average lowercase letter). Capitals and m/w are wide,
|
|
306
|
+
* i/l/punctuation narrow — so ALL-CAPS or "Mm Ww" copy isn't under-estimated.
|
|
307
|
+
*/
|
|
308
|
+
function charUnits(ch: string): number {
|
|
309
|
+
if (ch === ' ') return 0.5;
|
|
310
|
+
if ("iljI.,:;!|'’".includes(ch)) return 0.5;
|
|
311
|
+
if ('ftr()[]-–'.includes(ch)) return 0.7;
|
|
312
|
+
if ('mwMW'.includes(ch)) return 1.55;
|
|
313
|
+
if (ch >= 'A' && ch <= 'Z') return 1.3;
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Estimated rendered width of `text` (single line) — the width model behind `estimateLines`. */
|
|
318
|
+
export function estimateTextWidth(text: string, fontSize: number, metrics: TextMetrics = 'Inter'): number {
|
|
319
|
+
const { charWidth, mono } = metricsOf(metrics);
|
|
320
|
+
return [...text].reduce((sum, ch) => sum + (mono ? 1 : charUnits(ch)), 0) * fontSize * charWidth;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Greedy word-wrap estimate (Fabric Textbox semantics: wrap at spaces, break over-long words) of how
|
|
325
|
+
* many lines `text` takes at `fontSize` in a box `width` wide, in the font `metrics` (a COMPOSE_FONTS
|
|
326
|
+
* name, or a raw px-per-unit factor).
|
|
327
|
+
*/
|
|
328
|
+
export function estimateLines(text: string, fontSize: number, width: number, metrics: TextMetrics = 'Inter'): number {
|
|
329
|
+
const { charWidth, mono } = metricsOf(metrics);
|
|
330
|
+
const unit = fontSize * charWidth;
|
|
331
|
+
const maxUnits = Math.max(1, width / unit);
|
|
332
|
+
const cu = (ch: string) => (mono ? 1 : charUnits(ch));
|
|
333
|
+
const units = (w: string) => [...w].reduce((sum, ch) => sum + cu(ch), 0);
|
|
101
334
|
let lines = 0;
|
|
102
335
|
for (const paragraph of text.split('\n')) {
|
|
103
336
|
let current = 0;
|
|
104
337
|
lines++;
|
|
105
338
|
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
106
|
-
const len = word
|
|
339
|
+
const len = units(word);
|
|
107
340
|
if (current === 0) {
|
|
108
341
|
current = len;
|
|
109
|
-
} else if (current +
|
|
110
|
-
current +=
|
|
342
|
+
} else if (current + cu(' ') + len <= maxUnits) {
|
|
343
|
+
current += cu(' ') + len;
|
|
111
344
|
} else {
|
|
112
345
|
lines++;
|
|
113
346
|
current = len;
|
|
114
347
|
}
|
|
115
348
|
// A single word longer than a line wraps mid-word in Fabric's Textbox.
|
|
116
|
-
while (current >
|
|
349
|
+
while (current > maxUnits) {
|
|
117
350
|
lines++;
|
|
118
|
-
current -=
|
|
351
|
+
current -= maxUnits;
|
|
119
352
|
}
|
|
120
353
|
}
|
|
121
354
|
}
|
|
122
355
|
return Math.max(1, lines);
|
|
123
356
|
}
|
|
124
357
|
|
|
125
|
-
|
|
358
|
+
/** Set typography for one canvas size. */
|
|
359
|
+
interface Typography {
|
|
360
|
+
W: number;
|
|
361
|
+
H: number;
|
|
362
|
+
unit: number;
|
|
126
363
|
headlineSize: number;
|
|
127
|
-
headlineHeight: number;
|
|
128
364
|
subSize: number;
|
|
365
|
+
textWidth: number;
|
|
366
|
+
margin: number;
|
|
367
|
+
deviceGap: number;
|
|
368
|
+
/** Reserved badge row (0 when no screen of this size has a badge). */
|
|
369
|
+
badgeRow: number;
|
|
370
|
+
badgeFont: number;
|
|
371
|
+
/** The set font (text metrics: FONT_CHAR_WIDTH). */
|
|
372
|
+
font: string;
|
|
373
|
+
/** Reserved text-block height = the TALLEST block among screens of this size. */
|
|
374
|
+
textArea: number;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
interface TextBlock {
|
|
378
|
+
headlineLines: number;
|
|
379
|
+
headlineHeight: number;
|
|
380
|
+
subLines: number;
|
|
129
381
|
subHeight: number;
|
|
130
382
|
gap: number;
|
|
131
383
|
height: number;
|
|
132
384
|
}
|
|
133
385
|
|
|
134
|
-
function measureTextBlock(screen: ComposeScreenPlan,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const headlineHeight =
|
|
138
|
-
estimateLines(screen.headline, headlineSize, textWidth) * headlineSize * HEADLINE_LINE_HEIGHT;
|
|
139
|
-
|
|
386
|
+
function measureTextBlock(screen: ComposeScreenPlan, t: Omit<Typography, 'textArea'>): TextBlock {
|
|
387
|
+
const headlineLines = estimateLines(screen.headline, t.headlineSize, t.textWidth, t.font);
|
|
388
|
+
const headlineHeight = headlineLines * t.headlineSize * HEADLINE_LINE_HEIGHT;
|
|
140
389
|
const hasSub = !!screen.subheadline?.trim();
|
|
141
|
-
const
|
|
142
|
-
const subHeight =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
390
|
+
const subLines = hasSub ? estimateLines(screen.subheadline!, t.subSize, t.textWidth, t.font) : 0;
|
|
391
|
+
const subHeight = subLines * t.subSize * SUBHEADLINE_LINE_HEIGHT;
|
|
392
|
+
const gap = hasSub ? t.headlineSize * TEXT_GAP : 0;
|
|
393
|
+
return { headlineLines, headlineHeight, subLines, subHeight, gap, height: t.badgeRow + headlineHeight + gap + subHeight };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Everything decided per screen before layers are built. */
|
|
397
|
+
interface ResolvedScreen {
|
|
398
|
+
index: number;
|
|
399
|
+
plan: ComposeScreenPlan;
|
|
400
|
+
W: number;
|
|
401
|
+
H: number;
|
|
402
|
+
layout: ComposeLayout;
|
|
403
|
+
presentation: ComposePresentation;
|
|
404
|
+
tilt: number;
|
|
405
|
+
background: BackgroundJSON;
|
|
406
|
+
backgroundFromStyle: boolean;
|
|
407
|
+
typo: Typography;
|
|
408
|
+
block: TextBlock;
|
|
409
|
+
group: string;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
413
|
+
|
|
414
|
+
function normalizeFocus(focus: FocusBand | undefined): FocusBand | undefined {
|
|
415
|
+
if (!focus || typeof focus.top !== 'number' || typeof focus.bottom !== 'number') return undefined;
|
|
416
|
+
const top = clamp01(Math.min(focus.top, focus.bottom));
|
|
417
|
+
const bottom = clamp01(Math.max(focus.top, focus.bottom));
|
|
418
|
+
return bottom > top ? { top, bottom } : undefined;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** A background's colour stops (solid → a subtle two-stop ramp around it). */
|
|
422
|
+
function backgroundStops(bg: BackgroundJSON): ColorStop[] {
|
|
423
|
+
const stops = bg.gradient?.colorStops;
|
|
424
|
+
if (bg.type === 'gradient' && stops && stops.length > 0) return stops;
|
|
425
|
+
// v1 gradients list bare `colors` (evenly spaced), like the editor's migrateScreenLayersJSON.
|
|
426
|
+
const v1 = bg.gradient?.colors?.filter(isHexColor) ?? [];
|
|
427
|
+
if (bg.type === 'gradient' && v1.length > 0) return evenStops(v1.length === 1 ? [v1[0], v1[0]] : v1);
|
|
428
|
+
const c = isHexColor(bg.color) ? bg.color : '#1F2937';
|
|
429
|
+
return [
|
|
430
|
+
{ offset: 0, color: lighten(c, 0.14) },
|
|
431
|
+
{ offset: 1, color: darken(c, 0.14) }
|
|
432
|
+
];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function evenStops(colors: string[]): ColorStop[] {
|
|
436
|
+
return colors.map((color, i) => ({ offset: colors.length > 1 ? i / (colors.length - 1) : 0, color }));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const linear = (colorStops: ColorStop[]): BackgroundJSON => ({ type: 'gradient', gradient: { type: 'linear', colorStops } });
|
|
440
|
+
|
|
441
|
+
/** Background from the plan-level palette (null when there is none / it's unusable). */
|
|
442
|
+
function paletteBackground(palette: ComposePalette | undefined, index: number): BackgroundJSON | null {
|
|
443
|
+
const colors = (palette?.colors ?? []).filter(isHexColor);
|
|
444
|
+
if (!palette || colors.length === 0) return null;
|
|
445
|
+
if (palette.mode === 'sequence') {
|
|
446
|
+
const c = colors[index % colors.length];
|
|
447
|
+
return linear([
|
|
448
|
+
{ offset: 0, color: lighten(c, 0.16) },
|
|
449
|
+
{ offset: 1, color: c }
|
|
450
|
+
]);
|
|
451
|
+
}
|
|
452
|
+
return linear(evenStops(colors.length === 1 ? [colors[0], darken(colors[0], 0.22)] : colors));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Colour of a ramp of `stops` at parameter t ∈ [0, 1] (non-hex stops are ignored). */
|
|
456
|
+
function colorAt(stops: ColorStop[], t: number): string {
|
|
457
|
+
const sorted = stops.filter((c) => isHexColor(c.color)).sort((a, b) => a.offset - b.offset);
|
|
458
|
+
if (sorted.length === 0) return '#1F2937';
|
|
459
|
+
if (t <= sorted[0].offset) return sorted[0].color;
|
|
460
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
461
|
+
const a = sorted[i - 1];
|
|
462
|
+
const b = sorted[i];
|
|
463
|
+
if (t <= b.offset) return mixHex(a.color, b.color, b.offset > a.offset ? (t - a.offset) / (b.offset - a.offset) : 1);
|
|
464
|
+
}
|
|
465
|
+
return sorted[sorted.length - 1].color;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
type Coords = { x1: number; y1: number; x2: number; y2: number };
|
|
469
|
+
|
|
470
|
+
/** Ramp parameter (0–1, padded at the ends) of point (x, y) on a linear gradient from p1 to p2. */
|
|
471
|
+
function rampT(c: Coords, x: number, y: number): number {
|
|
472
|
+
const dx = c.x2 - c.x1;
|
|
473
|
+
const dy = c.y2 - c.y1;
|
|
474
|
+
const len2 = dx * dx + dy * dy || 1;
|
|
475
|
+
return clamp01(((x - c.x1) * dx + (y - c.y1) * dy) / len2);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Where the editor draws an angle-based gradient over a w×h box (mirrors `deserializeBackground`). */
|
|
479
|
+
function angleCoords(angle: number | undefined, w: number, h: number): Coords {
|
|
480
|
+
const rad = (((angle || 180) - 90) * Math.PI) / 180;
|
|
481
|
+
return {
|
|
482
|
+
x1: (0.5 + Math.cos(rad) * 0.5) * w,
|
|
483
|
+
y1: (0.5 + Math.sin(rad) * 0.5) * h,
|
|
484
|
+
x2: (0.5 - Math.cos(rad) * 0.5) * w,
|
|
485
|
+
y2: (0.5 - Math.sin(rad) * 0.5) * h
|
|
486
|
+
};
|
|
147
487
|
}
|
|
148
488
|
|
|
149
489
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
490
|
+
* Colour of a LINEAR screen background at canvas point (x, y), placed exactly as the editor draws it
|
|
491
|
+
* (mirrors `deserializeBackground`: explicit coords, else `angle` — default 180°, i.e. offset 0 at
|
|
492
|
+
* the BOTTOM edge). Radial gradients are not modelled (they are sampled as if linear): auto-contrast
|
|
493
|
+
* only ever samples palette backgrounds, which are always linear, and panorama spans, which reject
|
|
494
|
+
* radial backgrounds.
|
|
155
495
|
*/
|
|
156
|
-
export function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// plan gets the correct aspect per screen.
|
|
161
|
-
const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
|
|
162
|
-
const { width: W, height: H } = explicit
|
|
163
|
-
? { width: plan.canvasWidth ?? 280, height: plan.canvasHeight ?? 600 }
|
|
164
|
-
: canvasDimsForDevice(screen.deviceId);
|
|
496
|
+
export function sampleBackground(bg: BackgroundJSON, W: number, H: number, x: number, y: number): string {
|
|
497
|
+
if (bg.type !== 'gradient' || !bg.gradient) return isHexColor(bg.color) ? bg.color : '#1F2937';
|
|
498
|
+
return colorAt(backgroundStops(bg), rampT(bg.gradient.coords ?? angleCoords(bg.gradient.angle, W, H), x, y));
|
|
499
|
+
}
|
|
165
500
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
501
|
+
/** What a panorama span paints across its N·W × H rect. */
|
|
502
|
+
type SpanFill = { kind: 'solid'; color: string } | { kind: 'linear'; stops: ColorStop[]; coords: Coords };
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The span-start screen's background, stretched across the whole span, keeping its colours, stop
|
|
506
|
+
* offsets and `angle` (an angle-less gradient runs diagonally corner to corner; a solid colour stays
|
|
507
|
+
* solid). Rejects what can't run across screens: radial gradients and explicit per-screen `coords`.
|
|
508
|
+
*/
|
|
509
|
+
function spanFillFor(bg: BackgroundJSON, first: number, N: number, W: number, H: number): SpanFill {
|
|
510
|
+
if (bg.type !== 'gradient' || !bg.gradient) return { kind: 'solid', color: isHexColor(bg.color) ? bg.color : '#1F2937' };
|
|
511
|
+
const at = `panorama span starting at screen ${first + 1}`;
|
|
512
|
+
if (bg.gradient.type === 'radial') {
|
|
513
|
+
throw new Error(`${at}: a radial gradient background can't run across screens — use a linear gradient or a solid colour`);
|
|
514
|
+
}
|
|
515
|
+
if (bg.gradient.coords) {
|
|
516
|
+
throw new Error(`${at}: explicit gradient \`coords\` are per-screen and can't span screens — use \`angle\` (or omit it) instead`);
|
|
517
|
+
}
|
|
518
|
+
const stops = backgroundStops(bg);
|
|
519
|
+
const coords = bg.gradient.angle != null ? angleCoords(bg.gradient.angle, N * W, H) : { x1: 0, y1: 0, x2: N * W, y2: H };
|
|
520
|
+
return { kind: 'linear', stops, coords };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Colour of a span fill at point (x, y) of span screen k. */
|
|
524
|
+
function samplePanorama(fill: SpanFill, k: number, W: number, x: number, y: number): string {
|
|
525
|
+
return fill.kind === 'solid' ? fill.color : colorAt(fill.stops, rampT(fill.coords, k * W + x, y));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The subject (what is placed under the text) of a framed / frameless screen. */
|
|
529
|
+
function subjectFor(r: ResolvedScreen): Subject {
|
|
530
|
+
if (r.presentation === 'device') {
|
|
531
|
+
const device = getDeviceFrame(r.plan.deviceId);
|
|
532
|
+
if (!device) throw new Error(`Unknown device: ${r.plan.deviceId}`);
|
|
533
|
+
const { width, height } = device.imageDimensions;
|
|
534
|
+
return { width, height, screen: { ...device.screenBounds } };
|
|
535
|
+
}
|
|
536
|
+
const { width, height } = r.plan.screenshot;
|
|
537
|
+
return { width, height, screen: { x: 0, y: 0, width, height } };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
interface Placement {
|
|
541
|
+
cx: number;
|
|
542
|
+
cy: number;
|
|
543
|
+
scale: number;
|
|
544
|
+
angle: number;
|
|
545
|
+
/** Rendered rotated bounding box. */
|
|
546
|
+
boxWidth: number;
|
|
547
|
+
boxHeight: number;
|
|
548
|
+
vertical: VerticalResult;
|
|
549
|
+
subject: Subject;
|
|
550
|
+
/** Straddlers only: validated rightward shift that makes the device cross the seam. */
|
|
551
|
+
straddleDx?: number;
|
|
552
|
+
/** zoom only */
|
|
553
|
+
zoom?: { cropX: number; cropY: number; cropW: number; cropH: number; focusCropped: boolean };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Near-edge start for a screen's subject: below (or above, for text-bottom) the reserved text area. */
|
|
557
|
+
function nearStart(t: Typography): number {
|
|
558
|
+
return t.margin + t.textArea + t.deviceGap;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Fit a zoom crop (natural px) to the card aspect `A` = height / width, centred on the requested
|
|
563
|
+
* region. An explicit `crop` keeps its full width (the model chose it); a focus-derived band is
|
|
564
|
+
* zoomed into its centre, up to ZOOM_MAX_MAG× the full-width fit. The height always covers the
|
|
565
|
+
* requested band when the image allows (else `focusCropped`).
|
|
566
|
+
*/
|
|
567
|
+
function fitCrop(
|
|
568
|
+
shot: { width: number; height: number },
|
|
569
|
+
req: { x: number; y: number; w: number; h: number },
|
|
570
|
+
A: number,
|
|
571
|
+
explicit: boolean
|
|
572
|
+
): { cropX: number; cropY: number; cropW: number; cropH: number; focusCropped: boolean } {
|
|
573
|
+
const minW = explicit ? req.w : shot.width / ZOOM_MAX_MAG;
|
|
574
|
+
let w = Math.min(shot.width, Math.max(minW, req.h / A));
|
|
575
|
+
let h = w * A;
|
|
576
|
+
if (h > shot.height) {
|
|
577
|
+
h = shot.height;
|
|
578
|
+
w = h / A;
|
|
579
|
+
}
|
|
580
|
+
const focusCropped = h < req.h - 0.5 || (explicit && w < req.w - 0.5);
|
|
581
|
+
const cx = req.x + req.w / 2;
|
|
582
|
+
const cy = req.y + req.h / 2;
|
|
583
|
+
const cropX = Math.min(Math.max(0, cx - w / 2), shot.width - w);
|
|
584
|
+
const cropY = Math.min(Math.max(0, cy - h / 2), shot.height - h);
|
|
585
|
+
return { cropX, cropY, cropW: w, cropH: h, focusCropped };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const HORIZONTAL_EPS = 1e-9;
|
|
589
|
+
/** A tilted device may shrink to this × its straight scale to keep its margins; else the tilt is reduced. */
|
|
590
|
+
const TILT_MIN_SCALE = 0.8;
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The horizontal no-tangent + focus rules for one pose: the subject's VISIBLE extent keeps
|
|
594
|
+
* `sideMargin`·W from both side edges (the right edge is waived for a deliberate straddle), and
|
|
595
|
+
* every focus band's rotated corners stay `focusSideSafe`·W inside the edges.
|
|
596
|
+
*/
|
|
597
|
+
function horizontalOk(
|
|
598
|
+
subject: Subject,
|
|
599
|
+
pose: { cx: number; cy: number; scale: number; angle: number },
|
|
600
|
+
W: number,
|
|
601
|
+
H: number,
|
|
602
|
+
focuses: FocusBand[],
|
|
603
|
+
straddleRight = false
|
|
604
|
+
): boolean {
|
|
605
|
+
const eps = HORIZONTAL_EPS * W;
|
|
606
|
+
const ext = visibleXExtent(subject, pose, H);
|
|
607
|
+
if (ext) {
|
|
608
|
+
const m = NO_TANGENT.sideMargin * W;
|
|
609
|
+
if (ext.min < m - eps) return false;
|
|
610
|
+
if (!straddleRight && ext.max > W - m + eps) return false;
|
|
611
|
+
}
|
|
612
|
+
const fs = NO_TANGENT.focusSideSafe * W;
|
|
613
|
+
return focuses.every((f) => {
|
|
614
|
+
const fx = focusXExtent(subject, f, pose);
|
|
615
|
+
return fx.min >= fs - eps && fx.max <= W - fs + eps;
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Rightward shift for a panorama straddle: the visible device crosses the seam by STRADDLE_OVERLAP
|
|
621
|
+
* of its visible width, limited so every focus band stays `focusSideSafe`·W inside the seam. Null
|
|
622
|
+
* when that leaves less than a decisive STRADDLE_MIN crossing (or would move the device left).
|
|
623
|
+
*/
|
|
624
|
+
function straddleShift(
|
|
625
|
+
subject: Subject,
|
|
626
|
+
pose: { cx: number; cy: number; scale: number; angle: number },
|
|
627
|
+
W: number,
|
|
628
|
+
H: number,
|
|
629
|
+
focuses: FocusBand[]
|
|
630
|
+
): number | null {
|
|
631
|
+
const ext = visibleXExtent(subject, pose, H);
|
|
632
|
+
if (!ext) return null;
|
|
633
|
+
const visW = ext.max - ext.min;
|
|
634
|
+
let dx = W + STRADDLE_OVERLAP * visW - ext.max;
|
|
635
|
+
for (const f of focuses) dx = Math.min(dx, W * (1 - NO_TANGENT.focusSideSafe) - focusXExtent(subject, f, pose).max);
|
|
636
|
+
const overlap = ext.max + dx - W;
|
|
637
|
+
return dx > 0 && overlap >= STRADDLE_MIN * visW ? dx : null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const screensOf = (members: ResolvedScreen[]) => members.map((m) => m.index + 1).join(', ');
|
|
641
|
+
|
|
642
|
+
/** The copy is the cause: the device gets < `minScale` of what it would get with no copy at all. */
|
|
643
|
+
const copyTooLong = (members: ResolvedScreen[], W: number, H: number) =>
|
|
644
|
+
new Error(
|
|
645
|
+
`copy too long for this canvas (${W}×${H}, screen ${screensOf(members)}): the text leaves the device ` +
|
|
646
|
+
`less than ${Math.round(NO_TANGENT.minScale * 100)}% of the room it gets with no copy — cut the subheadline or headline`
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
/** The canvas is the cause: even with no copy the subject can't reach a usable size. */
|
|
650
|
+
const canvasMismatch = (members: ResolvedScreen[], W: number, H: number, what: string, pct: number) =>
|
|
651
|
+
new Error(
|
|
652
|
+
`${what} doesn't fit a ${W}×${H} canvas (screen ${screensOf(members)}): even with no copy it renders at only ` +
|
|
653
|
+
`${Math.round(pct * 100)}% of its target width — use the device's own canvas (omit canvasWidth/canvasHeight) or another device`
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
/** Below this × its target width even with NO copy, the canvas (not the copy) is the problem. */
|
|
657
|
+
const MIN_EMPTY_COPY_FIT = 0.3;
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Pose of every screen in a group (they share scale, baseline and far-edge treatment).
|
|
661
|
+
* Vertical: `solveVertical` (no tangent, focus visible). Horizontal: the pose must pass
|
|
662
|
+
* `horizontalOk` for every member; if the tilt makes it too wide, the scale is capped (by at most
|
|
663
|
+
* TILT_MIN_SCALE), then the tilt is reduced in quarter steps down to 0 (a straight device ≤ 90% W
|
|
664
|
+
* always fits), with a `tilt-reduced` warning. Zoom cards follow the same ladder, keeping their
|
|
665
|
+
* aspect. Throws `copyTooLong` when the copy leaves < minScale of the no-copy fit, and
|
|
666
|
+
* `canvasMismatch` when even the no-copy fit is unusable (N1).
|
|
667
|
+
*/
|
|
668
|
+
function placeGroup(
|
|
669
|
+
members: ResolvedScreen[],
|
|
670
|
+
bleed: BleedPreference,
|
|
671
|
+
warnings: ComposeWarning[],
|
|
672
|
+
/** Every member is a requested panorama straddler: the right side margin is waived (it crosses the seam). */
|
|
673
|
+
straddleRight = false
|
|
674
|
+
): Map<number, Placement> {
|
|
675
|
+
const out = new Map<number, Placement>();
|
|
676
|
+
const first = members[0];
|
|
677
|
+
const { W, H, typo, layout, presentation, tilt } = first;
|
|
678
|
+
const spec = LAYOUTS[layout] ?? LAYOUTS['text-top'];
|
|
679
|
+
const farEdge = layout === 'text-bottom' ? 'top' : 'bottom';
|
|
680
|
+
const near = nearStart(typo);
|
|
681
|
+
const reduced = (to: number) => {
|
|
682
|
+
if (to === tilt) return;
|
|
683
|
+
warnings.push({
|
|
684
|
+
screen: first.index,
|
|
685
|
+
code: 'tilt-reduced',
|
|
686
|
+
message: `screen ${members.map((m) => m.index + 1).join(', ')}: tilt reduced from ${tilt}° to ${Math.round(to * 10) / 10}° so the device and its focus band keep their side margins`
|
|
687
|
+
});
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
if (presentation === 'zoom') {
|
|
691
|
+
// One card box for the group. Straight, it spans the full remaining height (clear of the far
|
|
692
|
+
// edge by the margin) at ZOOM_WIDTH·W, never flatter than ZOOM_MIN_ASPECT. That ASPECT is kept at
|
|
693
|
+
// every tilt: a tilted card scales down (≤ TILT_MIN_SCALE) until its rotated bounding box fits
|
|
694
|
+
// the remaining height and the 90%-W side margins; beyond that the tilt is reduced (quarter
|
|
695
|
+
// steps, `tilt-reduced`), and a straight card always fits.
|
|
696
|
+
const avail = H * (1 - NO_TANGENT.clearGap) - near;
|
|
697
|
+
const availEmpty = H * (1 - NO_TANGENT.clearGap) - (typo.margin + typo.deviceGap);
|
|
698
|
+
if (!(avail > 0) || avail < NO_TANGENT.minScale * availEmpty) throw copyTooLong(members, W, H);
|
|
699
|
+
let baseW = ZOOM_WIDTH * W;
|
|
700
|
+
const baseH = avail;
|
|
701
|
+
if (baseH < baseW * ZOOM_MIN_ASPECT) {
|
|
702
|
+
baseW = baseH / ZOOM_MIN_ASPECT;
|
|
703
|
+
}
|
|
704
|
+
const aspect = baseH / baseW;
|
|
705
|
+
const maxBoxWidth = (1 - 2 * NO_TANGENT.sideMargin) * W;
|
|
706
|
+
const fitAt = (angle: number) => {
|
|
707
|
+
const rad = (angle * Math.PI) / 180;
|
|
708
|
+
const sin = Math.abs(Math.sin(rad));
|
|
709
|
+
const cos = Math.abs(Math.cos(rad));
|
|
710
|
+
const k = Math.min(1, maxBoxWidth / (baseW * (cos + aspect * sin)), avail / (baseW * (sin + aspect * cos)));
|
|
711
|
+
return { k, boxW: k * baseW, boxH: k * baseW * aspect };
|
|
712
|
+
};
|
|
713
|
+
let angleUsed = 0;
|
|
714
|
+
let fit = fitAt(0);
|
|
715
|
+
for (const angle of tilt === 0 ? [0] : [tilt, tilt * 0.75, tilt * 0.5, tilt * 0.25, 0]) {
|
|
716
|
+
const f = fitAt(angle);
|
|
717
|
+
if (angle === 0 || f.k >= TILT_MIN_SCALE - 1e-9) {
|
|
718
|
+
angleUsed = angle;
|
|
719
|
+
fit = f;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
reduced(angleUsed);
|
|
724
|
+
const tiltUsed = angleUsed;
|
|
725
|
+
const { boxW, boxH } = fit;
|
|
726
|
+
const box = rotatedBox(boxW, boxH, tiltUsed);
|
|
727
|
+
for (const r of members) {
|
|
728
|
+
const shot = r.plan.screenshot;
|
|
729
|
+
const focus = normalizeFocus(r.plan.focus);
|
|
730
|
+
const crop = r.plan.crop;
|
|
731
|
+
const req = crop
|
|
732
|
+
? { x: clamp01(crop.x) * shot.width, y: clamp01(crop.y) * shot.height, w: Math.max(1, clamp01(crop.w) * shot.width), h: Math.max(1, clamp01(crop.h) * shot.height) }
|
|
733
|
+
: focus
|
|
734
|
+
? { x: 0, y: focus.top * shot.height, w: shot.width, h: (focus.bottom - focus.top) * shot.height }
|
|
735
|
+
: { x: 0, y: 0, w: shot.width, h: Math.min(shot.height, shot.width * (boxH / boxW)) };
|
|
736
|
+
const zoom = fitCrop(shot, req, boxH / boxW, !!crop);
|
|
737
|
+
const scale = boxW / zoom.cropW;
|
|
738
|
+
const nearEdge = near;
|
|
739
|
+
const cy = farEdge === 'bottom' ? nearEdge + box.height / 2 : H - nearEdge - box.height / 2;
|
|
740
|
+
const overshoot = nearEdge + box.height - H;
|
|
741
|
+
out.set(r.index, {
|
|
742
|
+
cx: W / 2,
|
|
743
|
+
cy,
|
|
744
|
+
scale,
|
|
745
|
+
angle: tiltUsed,
|
|
746
|
+
boxWidth: box.width,
|
|
747
|
+
boxHeight: box.height,
|
|
748
|
+
vertical: { scale, near: nearEdge, overshoot, mode: 'clear', bleedFraction: overshoot / box.height, reason: 'zoom card clears the edge' },
|
|
749
|
+
subject: { width: zoom.cropW, height: zoom.cropH, screen: { x: 0, y: 0, width: zoom.cropW, height: zoom.cropH } },
|
|
750
|
+
zoom
|
|
751
|
+
});
|
|
192
752
|
}
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
193
755
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
756
|
+
const subject = subjectFor(first);
|
|
757
|
+
const targetWidth = Math.min(spec.deviceWidth * (presentation === 'frameless' ? FRAMELESS_WIDTH : 1), NO_TANGENT.maxWidth);
|
|
758
|
+
const targetScale = (targetWidth * W) / subject.width;
|
|
759
|
+
// Every member's focus band is honoured by the shared pose (the most restrictive one decides).
|
|
760
|
+
const focuses = members.map((r) => normalizeFocus(r.plan.focus)).filter((f): f is FocusBand => !!f);
|
|
761
|
+
const solve = (angle: number, scaleCap?: number, nearOverride?: number) => {
|
|
762
|
+
const box = rotatedBox(subject.width, subject.height, angle);
|
|
763
|
+
const reaches = focuses.map((f) => focusReach(subject, f, angle, farEdge));
|
|
764
|
+
const vertical = solveVertical({
|
|
201
765
|
canvasWidth: W,
|
|
202
766
|
canvasHeight: H,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
767
|
+
near: nearOverride ?? near,
|
|
768
|
+
minNear: spec.minDeviceTop * H,
|
|
769
|
+
baseWidth: subject.width,
|
|
770
|
+
boxHeight: box.height,
|
|
771
|
+
targetWidth,
|
|
772
|
+
maxBleed: spec.maxBleed,
|
|
773
|
+
focusReach: reaches.length ? Math.max(...reaches) : null,
|
|
774
|
+
bleed,
|
|
775
|
+
preferBleed: angle !== 0,
|
|
776
|
+
scaleCap
|
|
206
777
|
});
|
|
778
|
+
const s = vertical.scale;
|
|
779
|
+
const cy = farEdge === 'bottom' ? vertical.near + (s * box.height) / 2 : H - vertical.near - (s * box.height) / 2;
|
|
780
|
+
return { vertical, box, cy, angle };
|
|
781
|
+
};
|
|
207
782
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
783
|
+
// L7 / N1: the room gate is relative to what the SAME subject gets on this canvas with NO copy
|
|
784
|
+
// (height-limited on landscape canvases), so a tall device on a squat canvas isn't blamed on the
|
|
785
|
+
// copy. Too small even with no copy ⇒ the canvas/device aspect is the problem.
|
|
786
|
+
const straight = solve(0);
|
|
787
|
+
const empty = solve(0, undefined, typo.margin + typo.deviceGap).vertical.scale;
|
|
788
|
+
if (!(empty >= MIN_EMPTY_COPY_FIT * targetScale - 1e-9)) {
|
|
789
|
+
throw canvasMismatch(members, W, H, presentation === 'device' ? `device ${first.plan.deviceId}` : 'this screenshot', empty / targetScale);
|
|
790
|
+
}
|
|
791
|
+
const minScale = NO_TANGENT.minScale * empty;
|
|
792
|
+
if (!(straight.vertical.scale >= minScale - 1e-9)) throw copyTooLong(members, W, H);
|
|
793
|
+
|
|
794
|
+
// A tilt may cost at most TILT_MIN_SCALE of the straight size; beyond that the tilt is reduced
|
|
795
|
+
// (in quarter steps down to 0) rather than shrinking the device further.
|
|
796
|
+
const tiltFloor = Math.max(minScale, TILT_MIN_SCALE * straight.vertical.scale);
|
|
797
|
+
// Search: tilt candidates (reduced in quarter steps), each with a shrinking scale cap. A straddler
|
|
798
|
+
// is judged AFTER its seam shift (right margin waived; left margin + focus vs seam checked); if no
|
|
799
|
+
// straddling pose exists it is placed as a normal screen (the caller then skips the straddle).
|
|
800
|
+
const search = (asStraddle: boolean) => {
|
|
801
|
+
for (const angle of tilt === 0 ? [0] : [tilt, tilt * 0.75, tilt * 0.5, tilt * 0.25, 0]) {
|
|
802
|
+
const floor = angle === 0 ? minScale : tiltFloor;
|
|
803
|
+
let cap: number | undefined;
|
|
804
|
+
for (let i = 0; i < 400; i++) {
|
|
805
|
+
const sol = solve(angle, cap);
|
|
806
|
+
const pose = { cx: W / 2, cy: sol.cy, scale: sol.vertical.scale, angle };
|
|
807
|
+
if (sol.vertical.scale >= floor - 1e-9) {
|
|
808
|
+
if (!asStraddle && horizontalOk(subject, pose, W, H, focuses)) return { sol, dx: undefined as number | undefined };
|
|
809
|
+
if (asStraddle) {
|
|
810
|
+
const dx = straddleShift(subject, pose, W, H, focuses);
|
|
811
|
+
if (dx != null && horizontalOk(subject, { ...pose, cx: pose.cx + dx }, W, H, focuses, true)) return { sol, dx };
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const next = sol.vertical.scale * 0.985;
|
|
815
|
+
if (next < floor) break;
|
|
816
|
+
cap = next;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return null;
|
|
820
|
+
};
|
|
821
|
+
// A straight, centred device at ≤ 90% W always passes; `straight` is the defensive fallback.
|
|
822
|
+
const found = (straddleRight ? search(true) : null) ?? search(false) ?? { sol: straight, dx: undefined };
|
|
823
|
+
const chosen = found.sol;
|
|
824
|
+
reduced(chosen.angle);
|
|
825
|
+
const s = chosen.vertical.scale;
|
|
826
|
+
for (const r of members) {
|
|
827
|
+
out.set(r.index, {
|
|
828
|
+
cx: W / 2,
|
|
829
|
+
cy: chosen.cy,
|
|
830
|
+
scale: s,
|
|
831
|
+
angle: chosen.angle,
|
|
832
|
+
boxWidth: s * chosen.box.width,
|
|
833
|
+
boxHeight: s * chosen.box.height,
|
|
834
|
+
vertical: chosen.vertical,
|
|
835
|
+
subject,
|
|
836
|
+
straddleDx: found.dx
|
|
223
837
|
});
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function shadowFor(W: number, scale: number) {
|
|
843
|
+
// Fabric scales shadow blur/offset by the object's scale (the editor's shadow controls use the
|
|
844
|
+
// same convention), so express them in the image's own units.
|
|
845
|
+
return { color: SHADOW.color, blur: (SHADOW.blur * W) / scale, offsetX: 0, offsetY: (SHADOW.offsetY * W) / scale };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Frameless / zoom subject: the uploaded screenshot as a plain image layer, rounded + shadowed. */
|
|
849
|
+
function makeScreenshotImageLayer(r: ResolvedScreen, p: Placement): LayerJSON {
|
|
850
|
+
const id = generateLayerId();
|
|
851
|
+
const shot = r.plan.screenshot;
|
|
852
|
+
const zoom = p.zoom;
|
|
853
|
+
const width = zoom ? zoom.cropW : shot.width;
|
|
854
|
+
const height = zoom ? zoom.cropH : shot.height;
|
|
855
|
+
const radius = zoom ? ZOOM_RADIUS * r.W : FRAMELESS_RADIUS * width * p.scale;
|
|
856
|
+
const fabricData: Record<string, unknown> = {
|
|
857
|
+
type: 'image',
|
|
858
|
+
src: shot.url,
|
|
859
|
+
crossOrigin: 'anonymous',
|
|
860
|
+
left: p.cx,
|
|
861
|
+
top: p.cy,
|
|
862
|
+
width,
|
|
863
|
+
height,
|
|
864
|
+
scaleX: p.scale,
|
|
865
|
+
scaleY: p.scale,
|
|
866
|
+
originX: 'center',
|
|
867
|
+
originY: 'center',
|
|
868
|
+
// Rounded corners in the image's local (unscaled) space — same shape the editor's corner
|
|
869
|
+
// radius control writes (`imageCornerRadius` is in canvas units).
|
|
870
|
+
clipPath: {
|
|
871
|
+
type: 'Rect',
|
|
872
|
+
left: 0,
|
|
873
|
+
top: 0,
|
|
874
|
+
width,
|
|
875
|
+
height,
|
|
876
|
+
rx: radius / p.scale,
|
|
877
|
+
ry: radius / p.scale,
|
|
878
|
+
originX: 'center',
|
|
879
|
+
originY: 'center'
|
|
880
|
+
},
|
|
881
|
+
imageCornerRadius: radius,
|
|
882
|
+
shadow: shadowFor(r.W, p.scale),
|
|
883
|
+
layerId: id,
|
|
884
|
+
layerType: 'image'
|
|
885
|
+
};
|
|
886
|
+
if (zoom) {
|
|
887
|
+
fabricData.cropX = zoom.cropX;
|
|
888
|
+
fabricData.cropY = zoom.cropY;
|
|
889
|
+
}
|
|
890
|
+
if (p.angle) fabricData.angle = p.angle;
|
|
891
|
+
return {
|
|
892
|
+
id,
|
|
893
|
+
name: zoom ? 'Screenshot (zoom)' : 'Screenshot',
|
|
894
|
+
type: 'image',
|
|
895
|
+
visible: true,
|
|
896
|
+
locked: false,
|
|
897
|
+
fabricData
|
|
898
|
+
};
|
|
899
|
+
}
|
|
224
900
|
|
|
225
|
-
|
|
226
|
-
|
|
901
|
+
function makeSubjectLayer(r: ResolvedScreen, p: Placement, cx = p.cx, name?: string): LayerJSON {
|
|
902
|
+
if (r.presentation === 'device') {
|
|
903
|
+
// One device layer; the screenshot rides on it as `fabricData.screenshot` and the editor
|
|
904
|
+
// places + clips it under the frame on import (see DeviceScreenshotJSON).
|
|
905
|
+
return makeDeviceFrameLayer({
|
|
906
|
+
deviceId: r.plan.deviceId,
|
|
907
|
+
screenshotUrl: r.plan.screenshot.url,
|
|
908
|
+
screenshotWidth: r.plan.screenshot.width,
|
|
909
|
+
screenshotHeight: r.plan.screenshot.height,
|
|
910
|
+
canvasWidth: r.W,
|
|
911
|
+
canvasHeight: r.H,
|
|
912
|
+
centerX: cx,
|
|
913
|
+
centerY: p.cy,
|
|
914
|
+
scale: p.scale,
|
|
915
|
+
angle: p.angle,
|
|
916
|
+
name
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const layer = makeScreenshotImageLayer(r, p);
|
|
920
|
+
(layer.fabricData as Record<string, unknown>).left = cx;
|
|
921
|
+
if (name) layer.name = name;
|
|
922
|
+
return layer;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Canvas corners of a screen's focus band for a given pose (null when no focus is marked). */
|
|
926
|
+
function focusPolygon(r: ResolvedScreen, p: Placement, cx: number) {
|
|
927
|
+
const focus = normalizeFocus(r.plan.focus);
|
|
928
|
+
if (!focus || p.zoom) return null;
|
|
929
|
+
return focusCorners(p.subject, focus).map((pt) => subjectPointToCanvas(p.subject, pt, { cx, cy: p.cy, scale: p.scale, angle: p.angle }));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
interface TextRects {
|
|
933
|
+
badge?: { left: number; top: number; right: number; bottom: number };
|
|
934
|
+
headline: { left: number; top: number; right: number; bottom: number };
|
|
935
|
+
sub?: { left: number; top: number; right: number; bottom: number };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function validateSpans(plan: ComposePlan, dims: Array<{ W: number; H: number }>): number[][] {
|
|
939
|
+
const spans = plan.style?.panorama?.spans ?? [];
|
|
940
|
+
const seen = new Set<number>();
|
|
941
|
+
for (const span of spans) {
|
|
942
|
+
if (!Array.isArray(span) || span.length < 2) throw new Error('panorama span must list at least 2 screens');
|
|
943
|
+
span.forEach((idx, k) => {
|
|
944
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= plan.screens.length) throw new Error(`panorama span index ${idx} is out of range`);
|
|
945
|
+
if (k > 0 && idx !== span[k - 1] + 1) throw new Error('panorama span screens must be adjacent and ascending');
|
|
946
|
+
if (seen.has(idx)) throw new Error(`screen ${idx} is in more than one panorama span`);
|
|
947
|
+
seen.add(idx);
|
|
948
|
+
if (dims[idx].W !== dims[span[0]].W || dims[idx].H !== dims[span[0]].H) {
|
|
949
|
+
throw new Error('panorama span screens must share one canvas size');
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
const straddle = plan.style?.panorama?.straddle;
|
|
954
|
+
if (Array.isArray(straddle)) {
|
|
955
|
+
for (const idx of straddle) {
|
|
956
|
+
if (!spans.some((span) => span[0] === idx)) throw new Error(`panorama straddle ${idx} is not the first screen of a span`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return spans;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Two-pass SET layout: measure every text block → one type size + a text area reserved for the
|
|
964
|
+
* tallest block (per canvas size) → one subject scale/baseline per group (canvas, presentation,
|
|
965
|
+
* subject, layout, tilt) → the no-tangent bleed rule (respecting `focus`) → per-screen layers.
|
|
966
|
+
* Copy length never moves or resizes anything; copy-rule violations come back as `report.warnings`.
|
|
967
|
+
*/
|
|
968
|
+
export function composeSet(plan: ComposePlan): { template: Template; report: ComposeReport } {
|
|
969
|
+
const style = plan.style ?? {};
|
|
970
|
+
const warnings: ComposeWarning[] = [];
|
|
971
|
+
const bleedPref: BleedPreference = (COMPOSE_BLEEDS as readonly string[]).includes(style.bleed ?? '') ? style.bleed! : 'auto';
|
|
972
|
+
const font = style.font && COMPOSE_FONTS.includes(style.font) ? style.font : 'Inter';
|
|
973
|
+
const tiltScreens = new Set(style.tiltScreens ?? []);
|
|
974
|
+
|
|
975
|
+
// Per-screen canvas dims: honor explicit plan-level dims (backward compatible), else derive from
|
|
976
|
+
// the screen's device class so a mixed-device plan gets the correct aspect per screen.
|
|
977
|
+
const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
|
|
978
|
+
const dims = plan.screens.map((screen) => {
|
|
979
|
+
if (!getDeviceFrame(screen.deviceId)) throw new Error(`Unknown device: ${screen.deviceId}`);
|
|
980
|
+
const { width, height } = explicit
|
|
981
|
+
? { width: plan.canvasWidth ?? 280, height: plan.canvasHeight ?? 600 }
|
|
982
|
+
: canvasDimsForDevice(screen.deviceId);
|
|
983
|
+
return { W: width, H: height };
|
|
984
|
+
});
|
|
985
|
+
const spans = validateSpans(plan, dims);
|
|
986
|
+
const spanOf = new Map<number, { span: number[]; k: number }>();
|
|
987
|
+
for (const span of spans) span.forEach((idx, k) => spanOf.set(idx, { span, k }));
|
|
988
|
+
|
|
989
|
+
// Pass 1: set typography per canvas size (tallest text block wins).
|
|
990
|
+
const typoByDims = new Map<string, Typography>();
|
|
991
|
+
const dimsKey = (d: { W: number; H: number }) => `${d.W}x${d.H}`;
|
|
992
|
+
plan.screens.forEach((_, i) => {
|
|
993
|
+
const key = dimsKey(dims[i]);
|
|
994
|
+
if (typoByDims.has(key)) return;
|
|
995
|
+
const { W, H } = dims[i];
|
|
996
|
+
const unit = Math.min(W, H * TYPE_UNIT_HEIGHT_CAP);
|
|
997
|
+
const headlineSize = unit * HEADLINE_SIZE;
|
|
998
|
+
const hasBadge = plan.screens.some((s, j) => dimsKey(dims[j]) === key && !!s.badge?.trim());
|
|
999
|
+
const badgeFont = headlineSize * BADGE_FONT;
|
|
1000
|
+
typoByDims.set(key, {
|
|
1001
|
+
W,
|
|
1002
|
+
H,
|
|
1003
|
+
unit,
|
|
1004
|
+
headlineSize,
|
|
1005
|
+
subSize: headlineSize * SUBHEADLINE_RATIO,
|
|
1006
|
+
textWidth: W * TEXT_WIDTH,
|
|
1007
|
+
margin: H * EDGE_MARGIN,
|
|
1008
|
+
deviceGap: unit * DEVICE_GAP,
|
|
1009
|
+
badgeFont,
|
|
1010
|
+
font,
|
|
1011
|
+
badgeRow: hasBadge ? badgeFont * BADGE_HEIGHT + headlineSize * BADGE_GAP : 0,
|
|
1012
|
+
textArea: 0
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
const resolved: ResolvedScreen[] = plan.screens.map((screen, i) => {
|
|
1017
|
+
const typo = typoByDims.get(dimsKey(dims[i]))!;
|
|
1018
|
+
const block = measureTextBlock(screen, typo);
|
|
1019
|
+
typo.textArea = Math.max(typo.textArea, block.height);
|
|
1020
|
+
const presentation: ComposePresentation = (COMPOSE_PRESENTATIONS as readonly string[]).includes(screen.presentation ?? '')
|
|
1021
|
+
? screen.presentation!
|
|
1022
|
+
: (COMPOSE_PRESENTATIONS as readonly string[]).includes(style.presentation ?? '')
|
|
1023
|
+
? style.presentation!
|
|
1024
|
+
: 'device';
|
|
1025
|
+
let layout: ComposeLayout = screen.layout && LAYOUTS[screen.layout] ? screen.layout : 'text-top';
|
|
1026
|
+
if (presentation === 'zoom' && layout === 'device-bleed') layout = 'text-top';
|
|
1027
|
+
const rawTilt = typeof screen.tilt === 'number' ? screen.tilt : tiltScreens.has(i) ? (style.tilt ?? 0) : 0;
|
|
1028
|
+
const tilt = Number.isFinite(rawTilt) ? Math.max(-30, Math.min(30, rawTilt)) : 0;
|
|
1029
|
+
const fromPalette = screen.background ? null : paletteBackground(style.palette, i);
|
|
1030
|
+
const background = screen.background ?? fromPalette ?? { type: 'solid', color: '#1F2937' };
|
|
1031
|
+
const backgroundFromStyle = !screen.background;
|
|
1032
|
+
const subjectKey =
|
|
1033
|
+
presentation === 'device'
|
|
1034
|
+
? screen.deviceId
|
|
1035
|
+
: presentation === 'frameless'
|
|
1036
|
+
? `shot:${screen.screenshot.width}x${screen.screenshot.height}`
|
|
1037
|
+
: 'card';
|
|
1038
|
+
const group = `${dims[i].W}x${dims[i].H}|${presentation}|${subjectKey}|${layout}|tilt:${tilt}`;
|
|
1039
|
+
return {
|
|
1040
|
+
index: i,
|
|
1041
|
+
plan: screen,
|
|
1042
|
+
W: dims[i].W,
|
|
1043
|
+
H: dims[i].H,
|
|
1044
|
+
layout,
|
|
1045
|
+
presentation,
|
|
1046
|
+
tilt,
|
|
1047
|
+
background,
|
|
1048
|
+
backgroundFromStyle,
|
|
1049
|
+
typo,
|
|
1050
|
+
block,
|
|
1051
|
+
group
|
|
1052
|
+
};
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// Requested straddlers: the first screen of a span, per `panorama.straddle` (never zoom cards).
|
|
1056
|
+
// They get their own group (their pose is judged after the seam shift).
|
|
1057
|
+
const straddleSpec = style.panorama?.straddle;
|
|
1058
|
+
const wantsStraddle = new Set(
|
|
1059
|
+
straddleSpec
|
|
1060
|
+
? spans.map((span) => span[0]).filter((i) => (Array.isArray(straddleSpec) ? straddleSpec.includes(i) : true) && resolved[i].presentation !== 'zoom')
|
|
1061
|
+
: []
|
|
1062
|
+
);
|
|
1063
|
+
for (const i of wantsStraddle) resolved[i].group += '|straddle';
|
|
1064
|
+
|
|
1065
|
+
// Pass 2: one pose per group.
|
|
1066
|
+
const groups = new Map<string, ResolvedScreen[]>();
|
|
1067
|
+
for (const r of resolved) groups.set(r.group, [...(groups.get(r.group) ?? []), r]);
|
|
1068
|
+
const placements = new Map<number, Placement>();
|
|
1069
|
+
for (const members of groups.values()) {
|
|
1070
|
+
const asStraddle = members.every((m) => wantsStraddle.has(m.index));
|
|
1071
|
+
for (const [i, p] of placeGroup(members, bleedPref, warnings, asStraddle)) placements.set(i, p);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Panorama: straddling devices (first screen of a span) cross into the next screen.
|
|
1075
|
+
const straddle = new Map<number, { cx: number; into: number }>();
|
|
1076
|
+
if (wantsStraddle.size) {
|
|
1077
|
+
for (const span of spans) {
|
|
1078
|
+
const i = span[0];
|
|
1079
|
+
if (!wantsStraddle.has(i)) continue;
|
|
1080
|
+
const r = resolved[i];
|
|
1081
|
+
const p = placements.get(i)!;
|
|
1082
|
+
if (r.presentation === 'zoom') continue;
|
|
1083
|
+
const W = r.W;
|
|
1084
|
+
// placeGroup found (or failed to find) a pose that straddles within the side / seam rules.
|
|
1085
|
+
const dx = p.straddleDx;
|
|
1086
|
+
const focus = normalizeFocus(r.plan.focus);
|
|
1087
|
+
if (dx == null) {
|
|
1088
|
+
warnings.push({
|
|
1089
|
+
screen: i,
|
|
1090
|
+
code: 'straddle-skipped',
|
|
1091
|
+
message: `screen ${i + 1}: device kept inside its screen — crossing the seam would put its focus band across it (or break the side margin)`
|
|
1092
|
+
});
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (!focus) {
|
|
1096
|
+
warnings.push({
|
|
1097
|
+
screen: i,
|
|
1098
|
+
code: 'panorama-seam',
|
|
1099
|
+
message: `screen ${i + 1}: the device crosses the seam into screen ${i + 2} but has no \`focus\` band marked — mark one so the selling UI provably stays on screen ${i + 1}`
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
straddle.set(i, { cx: W / 2 + dx, into: span[1] });
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Span fills (validated up front, so a bad span background fails before any layer is built).
|
|
1107
|
+
const spanFills = new Map<number, SpanFill>();
|
|
1108
|
+
for (const span of spans) {
|
|
1109
|
+
const r0 = resolved[span[0]];
|
|
1110
|
+
spanFills.set(span[0], spanFillFor(r0.background, span[0], span.length, r0.W, r0.H));
|
|
1111
|
+
}
|
|
1112
|
+
/** Vertical band of a screen's reserved text block (with a small breathing gap). */
|
|
1113
|
+
const textBand = (r: ResolvedScreen) => {
|
|
1114
|
+
const top = r.layout === 'text-bottom' ? r.H - r.typo.margin - r.typo.textArea : r.typo.margin;
|
|
1115
|
+
const gap = ORB_TEXT_GAP * r.H;
|
|
1116
|
+
return { top: top - gap, bottom: top + r.typo.textArea + gap };
|
|
1117
|
+
};
|
|
1118
|
+
/**
|
|
1119
|
+
* The orb on seam `seam` of a span: centred on ORB_Y·H with ORB_RADIUS·W, but moved/shrunk so it
|
|
1120
|
+
* never overlaps either neighbour's text block (small landscape canvases); null when there's no room.
|
|
1121
|
+
*/
|
|
1122
|
+
const orbFor = (span: number[], seam: number): { cy: number; r: number } | null => {
|
|
1123
|
+
const a = resolved[span[seam - 1]];
|
|
1124
|
+
const b = resolved[span[seam]];
|
|
1125
|
+
const H = a.H;
|
|
1126
|
+
const blocks = [textBand(a), textBand(b)].sort((x, y) => x.top - y.top);
|
|
1127
|
+
const free: Array<{ lo: number; hi: number }> = [];
|
|
1128
|
+
let cursor = 0;
|
|
1129
|
+
for (const t of blocks) {
|
|
1130
|
+
if (t.top > cursor) free.push({ lo: cursor, hi: t.top });
|
|
1131
|
+
cursor = Math.max(cursor, t.bottom);
|
|
1132
|
+
}
|
|
1133
|
+
if (cursor < H) free.push({ lo: cursor, hi: H });
|
|
1134
|
+
const want = ORB_Y * H;
|
|
1135
|
+
const pick = free.find((f) => f.lo <= want && want <= f.hi) ?? free.sort((x, y) => y.hi - y.lo - (x.hi - x.lo))[0];
|
|
1136
|
+
if (!pick) return null;
|
|
1137
|
+
// The orb may run off the canvas edge (it's decoration), but never into a text block.
|
|
1138
|
+
const lo = pick.lo === 0 ? -Infinity : pick.lo;
|
|
1139
|
+
const hi = pick.hi === H ? Infinity : pick.hi;
|
|
1140
|
+
const r = Math.min(ORB_RADIUS * a.W, (hi - lo) / 2);
|
|
1141
|
+
if (!(r >= ORB_MIN_RADIUS * a.W)) return null;
|
|
1142
|
+
const cy = Math.min(Math.max(want, lo + r), hi - r);
|
|
1143
|
+
return { cy, r };
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
// Pass 3: layers per screen.
|
|
1147
|
+
const metrics: ComposeScreenMetrics[] = [];
|
|
1148
|
+
const textRectsByScreen = new Map<number, TextRects>();
|
|
1149
|
+
const screens = resolved.map((r) => {
|
|
1150
|
+
const { W, H, typo, block, plan: screen } = r;
|
|
1151
|
+
const p = placements.get(r.index)!;
|
|
1152
|
+
const layers: LayerJSON[] = [];
|
|
1153
|
+
let background = r.background;
|
|
1154
|
+
|
|
1155
|
+
// Panorama background + seam decoration (BOTTOM of the stack).
|
|
1156
|
+
const inSpan = spanOf.get(r.index);
|
|
1157
|
+
if (inSpan) {
|
|
1158
|
+
const { span, k } = inSpan;
|
|
1159
|
+
const N = span.length;
|
|
1160
|
+
// The span's FIRST screen's (already resolved) background runs across the whole span.
|
|
1161
|
+
const fill = spanFills.get(span[0])!;
|
|
1162
|
+
background = { type: 'solid', color: fill.kind === 'solid' ? fill.color : fill.stops[0].color };
|
|
1163
|
+
const bg = makeShapeLayer({
|
|
1164
|
+
shape: 'rectangle',
|
|
1165
|
+
left: (N * W) / 2 - k * W,
|
|
1166
|
+
top: H / 2,
|
|
1167
|
+
width: N * W,
|
|
1168
|
+
height: H,
|
|
1169
|
+
name: `Panorama background (${k + 1}/${N})`,
|
|
1170
|
+
locked: true
|
|
1171
|
+
});
|
|
1172
|
+
Object.assign(bg.fabricData as Record<string, unknown>, {
|
|
1173
|
+
fill:
|
|
1174
|
+
fill.kind === 'solid'
|
|
1175
|
+
? fill.color
|
|
1176
|
+
: {
|
|
1177
|
+
type: 'linear',
|
|
1178
|
+
gradientUnits: 'pixels',
|
|
1179
|
+
coords: { ...fill.coords },
|
|
1180
|
+
colorStops: fill.stops.map((s) => ({ offset: s.offset, color: s.color })),
|
|
1181
|
+
offsetX: 0,
|
|
1182
|
+
offsetY: 0
|
|
1183
|
+
},
|
|
1184
|
+
selectable: false,
|
|
1185
|
+
evented: false
|
|
1186
|
+
});
|
|
1187
|
+
layers.push(bg);
|
|
1188
|
+
if ((style.panorama?.decoration ?? 'orbs') === 'orbs') {
|
|
1189
|
+
// A soft orb centred on each seam touching this screen, kept clear of both text blocks.
|
|
1190
|
+
for (let seam = 1; seam < N; seam++) {
|
|
1191
|
+
if (seam !== k && seam !== k + 1) continue;
|
|
1192
|
+
const orb = orbFor(span, seam);
|
|
1193
|
+
if (!orb) continue;
|
|
1194
|
+
layers.push(
|
|
1195
|
+
makeShapeLayer({
|
|
1196
|
+
shape: 'circle',
|
|
1197
|
+
left: seam * W - k * W,
|
|
1198
|
+
top: orb.cy,
|
|
1199
|
+
radius: orb.r,
|
|
1200
|
+
fill: 'rgba(255,255,255,0.14)',
|
|
1201
|
+
name: 'Panorama orb'
|
|
1202
|
+
})
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// A device straddling in from the previous screen sits under this screen's own subject.
|
|
1209
|
+
for (const [from, s] of straddle) {
|
|
1210
|
+
if (s.into !== r.index) continue;
|
|
1211
|
+
const fr = resolved[from];
|
|
1212
|
+
const fp = placements.get(from)!;
|
|
1213
|
+
const cont = makeSubjectLayer(fr, fp, s.cx - W, `${getDeviceFrame(fr.plan.deviceId)?.name ?? 'Screenshot'} (continued)`);
|
|
1214
|
+
layers.push(cont);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const cx = straddle.get(r.index)?.cx ?? p.cx;
|
|
1218
|
+
layers.push(makeSubjectLayer(r, p, cx));
|
|
1219
|
+
|
|
1220
|
+
// Text block: badge row → headline → subheadline, anchored to the reserved text area.
|
|
1221
|
+
const areaTop = r.layout === 'text-bottom' ? H - typo.margin - typo.textArea : typo.margin;
|
|
1222
|
+
const rects: TextRects = {
|
|
1223
|
+
headline: { left: (W - typo.textWidth) / 2, top: 0, right: (W + typo.textWidth) / 2, bottom: 0 }
|
|
1224
|
+
};
|
|
1225
|
+
// Auto text colour from the background ACTUALLY behind the text block (after the panorama
|
|
1226
|
+
// swap): sample the span gradient / screen background over the block. An explicit
|
|
1227
|
+
// headlineColor wins; explicit (non-palette) backgrounds outside a span keep the white default.
|
|
1228
|
+
const blockBottom = areaTop + typo.badgeRow + block.headlineHeight + block.gap + block.subHeight;
|
|
1229
|
+
const samples: string[] = [];
|
|
1230
|
+
for (const fx of [0.1, 0.5, 0.9]) {
|
|
1231
|
+
for (const fy of [0, 0.5, 1]) {
|
|
1232
|
+
const x = fx * W;
|
|
1233
|
+
const y = areaTop + fy * (blockBottom - areaTop);
|
|
1234
|
+
samples.push(inSpan ? samplePanorama(spanFills.get(inSpan.span[0])!, inSpan.k, W, x, y) : sampleBackground(r.background, W, H, x, y));
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const headlineColor = screen.headlineColor ?? (inSpan || r.backgroundFromStyle ? readableTextOn(samples) : '#ffffff');
|
|
1238
|
+
if (screen.badge?.trim()) {
|
|
1239
|
+
const text = screen.badge.trim();
|
|
1240
|
+
if (text.length > BADGE_MAX_CHARS) {
|
|
1241
|
+
warnings.push({ screen: r.index, code: 'badge-long', message: `screen ${r.index + 1}: badge "${text}" is long — keep it to 1–3 words` });
|
|
1242
|
+
}
|
|
1243
|
+
const fontSize = typo.badgeFont;
|
|
1244
|
+
const pillH = fontSize * BADGE_HEIGHT;
|
|
1245
|
+
const pillW = Math.min(typo.textWidth, estimateTextWidth(text, fontSize, typo.font) + 2 * BADGE_PAD_X * fontSize);
|
|
1246
|
+
const cy = areaTop + pillH / 2;
|
|
1247
|
+
const pill = makeShapeLayer({
|
|
1248
|
+
shape: 'rectangle',
|
|
1249
|
+
left: W / 2,
|
|
1250
|
+
top: cy,
|
|
1251
|
+
width: pillW,
|
|
1252
|
+
height: pillH,
|
|
1253
|
+
rx: pillH / 2,
|
|
1254
|
+
ry: pillH / 2,
|
|
1255
|
+
fill: rgba(isHexColor(headlineColor) ? headlineColor : '#ffffff', 0.18),
|
|
1256
|
+
name: 'Badge'
|
|
1257
|
+
});
|
|
1258
|
+
const label = makeTextLayer({
|
|
1259
|
+
text,
|
|
1260
|
+
left: W / 2,
|
|
1261
|
+
top: cy,
|
|
1262
|
+
width: pillW,
|
|
1263
|
+
fontSize,
|
|
1264
|
+
fontFamily: font,
|
|
1265
|
+
fontWeight: '700',
|
|
1266
|
+
lineHeight: 1,
|
|
1267
|
+
fill: headlineColor,
|
|
1268
|
+
textAlign: 'center',
|
|
1269
|
+
name: 'Badge text',
|
|
1270
|
+
templateRole: 'editable',
|
|
1271
|
+
templateKey: 'badge'
|
|
1272
|
+
});
|
|
1273
|
+
layers.push(pill, label);
|
|
1274
|
+
rects.badge = { left: W / 2 - pillW / 2, top: areaTop, right: W / 2 + pillW / 2, bottom: areaTop + pillH };
|
|
1275
|
+
}
|
|
1276
|
+
const headlineTop = areaTop + typo.badgeRow;
|
|
1277
|
+
rects.headline.top = headlineTop;
|
|
1278
|
+
rects.headline.bottom = headlineTop + block.headlineHeight;
|
|
1279
|
+
|
|
1280
|
+
// Center origin (editor convention): left/top are the box CENTER.
|
|
1281
|
+
layers.push(
|
|
1282
|
+
makeTextLayer({
|
|
1283
|
+
text: screen.headline,
|
|
1284
|
+
left: W / 2,
|
|
1285
|
+
top: headlineTop + block.headlineHeight / 2,
|
|
1286
|
+
width: typo.textWidth,
|
|
1287
|
+
fontSize: typo.headlineSize,
|
|
1288
|
+
fontFamily: font,
|
|
1289
|
+
fontWeight: '800',
|
|
1290
|
+
lineHeight: HEADLINE_LINE_HEIGHT,
|
|
1291
|
+
fill: headlineColor,
|
|
1292
|
+
textAlign: 'center',
|
|
1293
|
+
name: 'Headline',
|
|
1294
|
+
templateRole: 'editable',
|
|
1295
|
+
templateKey: 'headline'
|
|
1296
|
+
})
|
|
1297
|
+
);
|
|
227
1298
|
|
|
228
1299
|
if (screen.subheadline?.trim()) {
|
|
1300
|
+
const subTop = headlineTop + block.headlineHeight + block.gap;
|
|
229
1301
|
const sub = makeTextLayer({
|
|
230
1302
|
text: screen.subheadline,
|
|
231
1303
|
left: W / 2,
|
|
232
|
-
top:
|
|
233
|
-
width: textWidth,
|
|
234
|
-
fontSize:
|
|
1304
|
+
top: subTop + block.subHeight / 2,
|
|
1305
|
+
width: typo.textWidth,
|
|
1306
|
+
fontSize: typo.subSize,
|
|
1307
|
+
fontFamily: font,
|
|
235
1308
|
fontWeight: '500',
|
|
236
1309
|
lineHeight: SUBHEADLINE_LINE_HEIGHT,
|
|
237
1310
|
fill: screen.subheadlineColor ?? headlineColor,
|
|
@@ -244,10 +1317,34 @@ export function composeTemplate(plan: ComposePlan): Template {
|
|
|
244
1317
|
// in the editor). An explicit subheadlineColor is used as-is.
|
|
245
1318
|
if (!screen.subheadlineColor) (sub.fabricData as Record<string, unknown>).opacity = SUBHEADLINE_OPACITY;
|
|
246
1319
|
layers.push(sub);
|
|
1320
|
+
rects.sub = { left: rects.headline.left, top: subTop, right: rects.headline.right, bottom: subTop + block.subHeight };
|
|
247
1321
|
}
|
|
1322
|
+
textRectsByScreen.set(r.index, rects);
|
|
1323
|
+
|
|
1324
|
+
const top = p.cy - p.boxHeight / 2;
|
|
1325
|
+
const bottom = p.cy + p.boxHeight / 2;
|
|
1326
|
+
const overshoot = r.layout === 'text-bottom' ? -top : bottom - H;
|
|
1327
|
+
metrics.push({
|
|
1328
|
+
index: r.index,
|
|
1329
|
+
presentation: r.presentation,
|
|
1330
|
+
layout: r.layout,
|
|
1331
|
+
group: r.group,
|
|
1332
|
+
scale: p.scale,
|
|
1333
|
+
widthFraction: (p.subject.width * p.scale) / W,
|
|
1334
|
+
top: top / H,
|
|
1335
|
+
bottom: bottom / H,
|
|
1336
|
+
overshoot: overshoot / H,
|
|
1337
|
+
bleedFraction: p.boxHeight > 0 ? overshoot / p.boxHeight : 0,
|
|
1338
|
+
mode: overshoot > 0 ? 'bleed' : 'clear',
|
|
1339
|
+
tangent: inTangentZone(overshoot, p.boxHeight, H),
|
|
1340
|
+
headlineSize: typo.headlineSize / W,
|
|
1341
|
+
headlineTop: headlineTop / H,
|
|
1342
|
+
tilt: p.angle,
|
|
1343
|
+
centerX: cx / W
|
|
1344
|
+
});
|
|
248
1345
|
|
|
249
1346
|
return makeScreen({
|
|
250
|
-
background
|
|
1347
|
+
background,
|
|
251
1348
|
canvasWidth: W,
|
|
252
1349
|
canvasHeight: H,
|
|
253
1350
|
// Tag the device GROUP (multi-device) so a mixed plan lands as separate sidebar groups in
|
|
@@ -257,5 +1354,71 @@ export function composeTemplate(plan: ComposePlan): Template {
|
|
|
257
1354
|
});
|
|
258
1355
|
});
|
|
259
1356
|
|
|
260
|
-
|
|
1357
|
+
// Copy + composition lint (warnings, never errors).
|
|
1358
|
+
resolved.forEach((r) => {
|
|
1359
|
+
const words = r.plan.headline.trim().split(/\s+/).filter(Boolean).length;
|
|
1360
|
+
const n = r.index + 1;
|
|
1361
|
+
if (words > COPY_RULES.headlineMaxWords) {
|
|
1362
|
+
warnings.push({ screen: r.index, code: 'headline-words', message: `screen ${n}: headline has ${words} words (aim for 3–5) — cut, don't shrink` });
|
|
1363
|
+
}
|
|
1364
|
+
if (r.block.headlineLines > COPY_RULES.headlineMaxLines) {
|
|
1365
|
+
warnings.push({
|
|
1366
|
+
screen: r.index,
|
|
1367
|
+
code: 'headline-lines',
|
|
1368
|
+
message: `screen ${n}: headline wraps to ~${r.block.headlineLines} lines at the set size (max 2) — it grows the text area for EVERY screen`
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
if (r.block.subLines > COPY_RULES.subheadlineMaxLines) {
|
|
1372
|
+
warnings.push({
|
|
1373
|
+
screen: r.index,
|
|
1374
|
+
code: 'subheadline-lines',
|
|
1375
|
+
message: `screen ${n}: subheadline wraps to ~${r.block.subLines} lines (max 1) — shorten it or drop it`
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
const p = placements.get(r.index)!;
|
|
1379
|
+
if (p.zoom?.focusCropped) {
|
|
1380
|
+
warnings.push({ screen: r.index, code: 'zoom-focus-cropped', message: `screen ${n}: the zoom card can't show the whole focus band — mark a tighter \`crop\`` });
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
if (straddle.size > MAX_STRADDLES) {
|
|
1384
|
+
warnings.push({
|
|
1385
|
+
code: 'panorama-straddle-count',
|
|
1386
|
+
message: `${straddle.size} devices cross a seam — keep it to ${MAX_STRADDLES} per set (usually the hero) so the set stays calm`
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
const tilted = resolved.filter((r) => r.tilt !== 0).length;
|
|
1390
|
+
if (tilted > COPY_RULES.maxTiltedScreens) {
|
|
1391
|
+
warnings.push({ code: 'tilt-count', message: `${tilted} screens are tilted — rotation is an accent: use it on at most ${COPY_RULES.maxTiltedScreens}` });
|
|
1392
|
+
}
|
|
1393
|
+
// Seam rule: nothing crossing a seam may touch a headline, and no focus band may cross a seam.
|
|
1394
|
+
for (const [from, s] of straddle) {
|
|
1395
|
+
const fr = resolved[from];
|
|
1396
|
+
const fp = placements.get(from)!;
|
|
1397
|
+
for (const [screenIdx, cx] of [
|
|
1398
|
+
[from, s.cx],
|
|
1399
|
+
[s.into, s.cx - fr.W]
|
|
1400
|
+
] as const) {
|
|
1401
|
+
const box = { left: cx - fp.boxWidth / 2, right: cx + fp.boxWidth / 2, top: fp.cy - fp.boxHeight / 2, bottom: fp.cy + fp.boxHeight / 2 };
|
|
1402
|
+
const t = textRectsByScreen.get(screenIdx)!;
|
|
1403
|
+
if ([t.headline, t.sub, t.badge].some((rect) => rect && rectsOverlap(rect, box))) {
|
|
1404
|
+
warnings.push({ screen: screenIdx, code: 'panorama-seam', message: `screen ${screenIdx + 1}: the straddling device overlaps the text` });
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const poly = focusPolygon(fr, fp, s.cx);
|
|
1408
|
+
if (poly && Math.max(...poly.map((pt) => pt.x)) > fr.W) {
|
|
1409
|
+
warnings.push({ screen: from, code: 'panorama-seam', message: `screen ${from + 1}: the seam crosses the focus band` });
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
metrics.sort((a, b) => a.index - b.index);
|
|
1414
|
+
return { template: makeTemplate({ name: plan.name, screens, tags: ['generated'] }), report: { warnings, screens: metrics } };
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* Deterministically assemble a Template from a plan (see `composeSet` for the set-wide layout rules
|
|
1419
|
+
* and the lint report). Text layers are marked editable so the user can tweak them in the editor.
|
|
1420
|
+
* Claude decides the plan (benefit, copy, device, palette, layout, style); this turns it into valid DSL.
|
|
1421
|
+
*/
|
|
1422
|
+
export function composeTemplate(plan: ComposePlan): Template {
|
|
1423
|
+
return composeSet(plan).template;
|
|
261
1424
|
}
|