@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/frames.ts
CHANGED
|
@@ -45,6 +45,8 @@ export function makeDeviceFrameLayer(opts: {
|
|
|
45
45
|
centerY?: number;
|
|
46
46
|
scale?: number;
|
|
47
47
|
screenshotRotation?: number;
|
|
48
|
+
/** Clockwise tilt of the whole mockup in degrees (the editor rotates the screenshot with it). */
|
|
49
|
+
angle?: number;
|
|
48
50
|
name?: string;
|
|
49
51
|
}): LayerJSON {
|
|
50
52
|
const device = getDeviceFrame(opts.deviceId);
|
|
@@ -75,6 +77,7 @@ export function makeDeviceFrameLayer(opts: {
|
|
|
75
77
|
deviceId: opts.deviceId,
|
|
76
78
|
deviceScale: scale
|
|
77
79
|
};
|
|
80
|
+
if (opts.angle) fabricData.angle = opts.angle;
|
|
78
81
|
|
|
79
82
|
if (opts.screenshotUrl) {
|
|
80
83
|
const { screenshotWidth: width, screenshotHeight: height } = opts;
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set-wide layout geometry for the composer (shot-dsl 0.4.0): the no-tangent rule, focus-aware
|
|
3
|
+
* bleed, rotated bounding boxes and seam checks. Pure math — no DSL objects are built here, so every
|
|
4
|
+
* branch is unit-testable in isolation (see layout-system.test.ts).
|
|
5
|
+
*
|
|
6
|
+
* Vocabulary (all canvas units unless noted):
|
|
7
|
+
* - SUBJECT: what sits under the text — a device frame PNG, or the bare screenshot (frameless).
|
|
8
|
+
* `width/height` are its natural (unscaled) pixel size; `screen` is the screenshot's area inside it.
|
|
9
|
+
* - NEAR edge: the subject's edge facing the text block (top for `text-top`, bottom for `text-bottom`).
|
|
10
|
+
* FAR edge: the opposite one, which may bleed off the canvas.
|
|
11
|
+
* - OVERSHOOT: how far the FAR edge passes the canvas edge (> 0 = bleeds off, < 0 = gap).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The "pro rules" thresholds. */
|
|
15
|
+
export const NO_TANGENT = {
|
|
16
|
+
/** A subject that stays on-canvas must clear the edge by at least this × H. */
|
|
17
|
+
clearGap: 0.04,
|
|
18
|
+
/** A subject that bleeds must lose at least this × its own (rotated) height. */
|
|
19
|
+
minBleed: 0.12,
|
|
20
|
+
/** `bleed: "deep"` target, × the subject's height. */
|
|
21
|
+
deepBleed: 0.25,
|
|
22
|
+
/** The focus band must end at least this × H inside the canvas edge. */
|
|
23
|
+
focusSafe: 0.02,
|
|
24
|
+
/** Upper bound on the subject's (unrotated) width — targets are clamped to it too, × W. */
|
|
25
|
+
maxWidth: 0.9,
|
|
26
|
+
/**
|
|
27
|
+
* Horizontal no-tangent rule: the subject's VISIBLE extent keeps at least this × W from each side
|
|
28
|
+
* edge (a side bleed is only allowed as a deliberate panorama straddle across a seam).
|
|
29
|
+
*/
|
|
30
|
+
sideMargin: 0.05,
|
|
31
|
+
/** The focus band's (rotated) corners stay at least this × W inside the side edges / seams. */
|
|
32
|
+
focusSideSafe: 0.02,
|
|
33
|
+
/** A clear-with-margin option that shrinks the subject below this × its natural scale is rejected. */
|
|
34
|
+
minClearScale: 0.6,
|
|
35
|
+
/** Below this × the layout's target scale the copy leaves no usable room (composeSet throws). */
|
|
36
|
+
minScale: 0.5
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
const EPS = 1e-6;
|
|
40
|
+
|
|
41
|
+
export type BleedPreference = 'auto' | 'none' | 'deep';
|
|
42
|
+
|
|
43
|
+
/** Fractions of the screenshot height (0 = top, 1 = bottom) that must stay visible. */
|
|
44
|
+
export interface FocusBand {
|
|
45
|
+
top: number;
|
|
46
|
+
bottom: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Subject {
|
|
50
|
+
width: number;
|
|
51
|
+
height: number;
|
|
52
|
+
/** Where the screenshot sits inside the subject (natural px). Frameless: the whole image. */
|
|
53
|
+
screen: { x: number; y: number; width: number; height: number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Point {
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Axis-aligned bounding box of a w×h rectangle rotated by `angle` degrees. */
|
|
62
|
+
export function rotatedBox(width: number, height: number, angle = 0): { width: number; height: number } {
|
|
63
|
+
const r = (angle * Math.PI) / 180;
|
|
64
|
+
const c = Math.abs(Math.cos(r));
|
|
65
|
+
const s = Math.abs(Math.sin(r));
|
|
66
|
+
return { width: width * c + height * s, height: width * s + height * c };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Canvas position of a point given in the subject's natural px (from its top-left), for a subject
|
|
71
|
+
* drawn centre-origin at (cx, cy), scaled by `scale` and rotated clockwise by `angle` (Fabric).
|
|
72
|
+
*/
|
|
73
|
+
export function subjectPointToCanvas(
|
|
74
|
+
subject: Subject,
|
|
75
|
+
p: Point,
|
|
76
|
+
pose: { cx: number; cy: number; scale: number; angle?: number }
|
|
77
|
+
): Point {
|
|
78
|
+
const r = ((pose.angle ?? 0) * Math.PI) / 180;
|
|
79
|
+
const lx = (p.x - subject.width / 2) * pose.scale;
|
|
80
|
+
const ly = (p.y - subject.height / 2) * pose.scale;
|
|
81
|
+
return {
|
|
82
|
+
x: pose.cx + lx * Math.cos(r) - ly * Math.sin(r),
|
|
83
|
+
y: pose.cy + lx * Math.sin(r) + ly * Math.cos(r)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The focus band's four corners in the subject's natural px (full screen width, band height). */
|
|
88
|
+
export function focusCorners(subject: Subject, focus: FocusBand): Point[] {
|
|
89
|
+
const { x, y, width, height } = subject.screen;
|
|
90
|
+
const top = y + focus.top * height;
|
|
91
|
+
const bottom = y + focus.bottom * height;
|
|
92
|
+
return [
|
|
93
|
+
{ x, y: top },
|
|
94
|
+
{ x: x + width, y: top },
|
|
95
|
+
{ x: x + width, y: bottom },
|
|
96
|
+
{ x, y: bottom }
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How far (natural px, i.e. at scale 1) the focus band reaches from the subject's rotated-AABB NEAR
|
|
102
|
+
* edge toward its FAR edge. The subject keeps its focus visible iff
|
|
103
|
+
* `near + scale · focusReach ≤ H · (1 − focusSafe)`.
|
|
104
|
+
*/
|
|
105
|
+
export function focusReach(subject: Subject, focus: FocusBand, angle: number, farEdge: 'bottom' | 'top'): number {
|
|
106
|
+
const box = rotatedBox(subject.width, subject.height, angle);
|
|
107
|
+
// Rotate about the centre at scale 1, measure y from the AABB centre.
|
|
108
|
+
const ys = focusCorners(subject, focus).map((p) => subjectPointToCanvas(subject, p, { cx: 0, cy: 0, scale: 1, angle }).y);
|
|
109
|
+
return farEdge === 'bottom' ? Math.max(...ys) + box.height / 2 : box.height / 2 - Math.min(...ys);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The subject's four corners (tl, tr, br, bl) on the canvas for a pose. */
|
|
113
|
+
export function subjectCorners(subject: Subject, pose: { cx: number; cy: number; scale: number; angle?: number }): Point[] {
|
|
114
|
+
return [
|
|
115
|
+
{ x: 0, y: 0 },
|
|
116
|
+
{ x: subject.width, y: 0 },
|
|
117
|
+
{ x: subject.width, y: subject.height },
|
|
118
|
+
{ x: 0, y: subject.height }
|
|
119
|
+
].map((p) => subjectPointToCanvas(subject, p, pose));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Clip a convex polygon to the horizontal band y0 ≤ y ≤ y1 (Sutherland–Hodgman, two edges). */
|
|
123
|
+
export function clipPolygonToBand(points: Point[], y0: number, y1: number): Point[] {
|
|
124
|
+
const clip = (pts: Point[], inside: (p: Point) => boolean, cut: (a: Point, b: Point) => Point) => {
|
|
125
|
+
const out: Point[] = [];
|
|
126
|
+
pts.forEach((cur, i) => {
|
|
127
|
+
const prev = pts[(i + pts.length - 1) % pts.length];
|
|
128
|
+
if (inside(cur)) {
|
|
129
|
+
if (!inside(prev)) out.push(cut(prev, cur));
|
|
130
|
+
out.push(cur);
|
|
131
|
+
} else if (inside(prev)) out.push(cut(prev, cur));
|
|
132
|
+
});
|
|
133
|
+
return out;
|
|
134
|
+
};
|
|
135
|
+
const atY = (y: number) => (a: Point, b: Point): Point => ({ x: a.x + ((b.x - a.x) * (y - a.y)) / (b.y - a.y), y });
|
|
136
|
+
const top = clip(points, (p) => p.y >= y0, atY(y0));
|
|
137
|
+
return top.length ? clip(top, (p) => p.y <= y1, atY(y1)) : [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Horizontal extent of the part of the subject that is actually on the canvas (null if none). */
|
|
141
|
+
export function visibleXExtent(
|
|
142
|
+
subject: Subject,
|
|
143
|
+
pose: { cx: number; cy: number; scale: number; angle?: number },
|
|
144
|
+
canvasHeight: number
|
|
145
|
+
): { min: number; max: number } | null {
|
|
146
|
+
const poly = clipPolygonToBand(subjectCorners(subject, pose), 0, canvasHeight);
|
|
147
|
+
if (poly.length === 0) return null;
|
|
148
|
+
const xs = poly.map((p) => p.x);
|
|
149
|
+
return { min: Math.min(...xs), max: Math.max(...xs) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Horizontal extent of the focus band's rotated corners. */
|
|
153
|
+
export function focusXExtent(
|
|
154
|
+
subject: Subject,
|
|
155
|
+
focus: FocusBand,
|
|
156
|
+
pose: { cx: number; cy: number; scale: number; angle?: number }
|
|
157
|
+
): { min: number; max: number } {
|
|
158
|
+
const xs = focusCorners(subject, focus).map((p) => subjectPointToCanvas(subject, p, pose).x);
|
|
159
|
+
return { min: Math.min(...xs), max: Math.max(...xs) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True when a far edge overshoot is in the forbidden "just touching" band. */
|
|
163
|
+
export function inTangentZone(overshoot: number, subjectHeight: number, canvasHeight: number): boolean {
|
|
164
|
+
return overshoot > -NO_TANGENT.clearGap * canvasHeight + EPS && overshoot < NO_TANGENT.minBleed * subjectHeight - EPS;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface VerticalInput {
|
|
168
|
+
canvasWidth: number;
|
|
169
|
+
canvasHeight: number;
|
|
170
|
+
/** Distance from the text-side canvas edge to where the subject's near edge may start. */
|
|
171
|
+
near: number;
|
|
172
|
+
/** The subject's near edge can't start closer than this (hero layouts push the device lower). */
|
|
173
|
+
minNear?: number;
|
|
174
|
+
/** Unrotated natural width (width targets are expressed on it, so a tilt doesn't shrink the device). */
|
|
175
|
+
baseWidth: number;
|
|
176
|
+
/** Rotated-AABB natural height (vertical extent). */
|
|
177
|
+
boxHeight: number;
|
|
178
|
+
/** Target rendered width, × W. */
|
|
179
|
+
targetWidth: number;
|
|
180
|
+
/** The natural scale is capped so no more than this fraction of the subject is off-canvas. */
|
|
181
|
+
maxBleed: number;
|
|
182
|
+
/** See `focusReach`; null = no focus band marked (unconstrained). */
|
|
183
|
+
focusReach: number | null;
|
|
184
|
+
bleed: BleedPreference;
|
|
185
|
+
/** Tilted subjects: prefer a decisive bleed whenever one is possible (tilt is paired with a bleed). */
|
|
186
|
+
preferBleed?: boolean;
|
|
187
|
+
/** Hard upper bound on the scale (e.g. from the horizontal rules); applied to every option. */
|
|
188
|
+
scaleCap?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface VerticalResult {
|
|
192
|
+
scale: number;
|
|
193
|
+
/** Distance from the text-side edge to the subject's near edge. */
|
|
194
|
+
near: number;
|
|
195
|
+
/** Far edge overshoot (> 0 bleeds off the canvas). */
|
|
196
|
+
overshoot: number;
|
|
197
|
+
mode: 'clear' | 'bleed';
|
|
198
|
+
/** overshoot ÷ rendered subject height (negative when clear). */
|
|
199
|
+
bleedFraction: number;
|
|
200
|
+
/** Why this option was picked (debug/report aid). */
|
|
201
|
+
reason: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* THE no-tangent solver. Given where the subject may start (below the set's reserved text area) and
|
|
206
|
+
* how big it wants to be, return a placement whose far edge either clears the canvas edge by
|
|
207
|
+
* ≥ `clearGap`·H or bleeds by ≥ `minBleed` of the subject's height — never in between — and never
|
|
208
|
+
* pushes the focus band off-canvas. Options:
|
|
209
|
+
* - CLEAR: keep the near edge, shrink until the far edge clears with the margin.
|
|
210
|
+
* - BLEED: grow (up to `maxWidth`·W), then shift toward the far edge, until the overshoot reaches
|
|
211
|
+
* the target; if that hides the focus band, the largest focus-safe bleed ≥ `minBleed` is used,
|
|
212
|
+
* else the bleed option is unavailable.
|
|
213
|
+
* `none` → always clear. `deep` → bleed ≥ `deepBleed` (or the most the focus allows), else clear.
|
|
214
|
+
* `auto` → keep the natural placement when it already clears by the margin or is a focus-safe
|
|
215
|
+
* decisive bleed; otherwise (the tangent zone) PREFER the bleed option, and fall back to clear only
|
|
216
|
+
* when every ≥ `minBleed` bleed would crop the focus band. `preferBleed` (tilt) also turns a natural
|
|
217
|
+
* clear into a bleed.
|
|
218
|
+
*/
|
|
219
|
+
export function solveVertical(inp: VerticalInput): VerticalResult {
|
|
220
|
+
const W = inp.canvasWidth;
|
|
221
|
+
const H = inp.canvasHeight;
|
|
222
|
+
const eh = inp.boxHeight;
|
|
223
|
+
const near0 = Math.max(inp.near, inp.minNear ?? 0);
|
|
224
|
+
const cap = inp.scaleCap ?? Infinity;
|
|
225
|
+
const sCap = Math.min((NO_TANGENT.maxWidth * W) / inp.baseWidth, cap);
|
|
226
|
+
const target = Math.min(inp.targetWidth, NO_TANGENT.maxWidth);
|
|
227
|
+
const s0 = Math.max(0, Math.min((target * W) / inp.baseWidth, (H - near0) / (1 - inp.maxBleed) / eh, cap));
|
|
228
|
+
const focusLimit = H * (1 - NO_TANGENT.focusSafe);
|
|
229
|
+
|
|
230
|
+
const overshoot = (s: number, a: number) => a + s * eh - H;
|
|
231
|
+
const focusOk = (s: number, a: number) => inp.focusReach == null || a + s * inp.focusReach <= focusLimit + EPS;
|
|
232
|
+
const make = (s: number, a: number, reason: string): VerticalResult => {
|
|
233
|
+
const o = overshoot(s, a);
|
|
234
|
+
return { scale: s, near: a, overshoot: o, mode: o > 0 ? 'bleed' : 'clear', bleedFraction: o / (s * eh), reason };
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const clearOption = (): VerticalResult | null => {
|
|
238
|
+
const s = Math.min(s0, (H * (1 - NO_TANGENT.clearGap) - near0) / eh);
|
|
239
|
+
if (!(s > 0) || s < NO_TANGENT.minClearScale * s0 - EPS) return null;
|
|
240
|
+
return make(s, near0, s < s0 - EPS ? 'shrunk to clear the edge with a margin' : 'clears the edge');
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** Smallest move reaching overshoot ≥ t·height, then made focus-safe (null if impossible). */
|
|
244
|
+
const bleedOption = (t: number): VerticalResult | null => {
|
|
245
|
+
let s = s0;
|
|
246
|
+
let a = near0;
|
|
247
|
+
if (overshoot(s, a) < t * s * eh - EPS) {
|
|
248
|
+
const need = (H - a) / ((1 - t) * eh);
|
|
249
|
+
s = Math.max(s0, Math.min(need, Math.max(sCap, s0)));
|
|
250
|
+
if (overshoot(s, a) < t * s * eh - EPS) a = H - s * eh * (1 - t);
|
|
251
|
+
}
|
|
252
|
+
if (focusOk(s, a)) return make(s, a, `bleeds ${Math.round(t * 100)}%+`);
|
|
253
|
+
// Focus-constrained: keep the scale (≤ what the focus allows at near0) and bleed only as deep
|
|
254
|
+
// as the focus band allows — accepted if that is still a decisive (≥ minBleed) bleed.
|
|
255
|
+
if (inp.focusReach == null) return null;
|
|
256
|
+
const fr = inp.focusReach;
|
|
257
|
+
const sF = Math.min(s, Math.max(sCap, s0), (focusLimit - near0) / fr);
|
|
258
|
+
const denom = eh * (1 - NO_TANGENT.minBleed) - fr;
|
|
259
|
+
if (!(sF > 0) || denom <= 0 || sF < (H - focusLimit) / denom - EPS) return null;
|
|
260
|
+
const aF = Math.max(near0, Math.min(focusLimit - sF * fr, H - sF * eh * (1 - t)));
|
|
261
|
+
if (overshoot(sF, aF) < NO_TANGENT.minBleed * sF * eh - EPS || !focusOk(sF, aF)) return null;
|
|
262
|
+
return make(sF, aF, 'bleed reduced to keep the focus band visible');
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** Last resort (never a tangent): clear with the margin however small that makes the subject. */
|
|
266
|
+
const forcedClear = () => make(Math.max(0, Math.min(s0, (H * (1 - NO_TANGENT.clearGap) - near0) / eh)), near0, 'forced clear');
|
|
267
|
+
|
|
268
|
+
if (inp.bleed === 'none') return clearOption() ?? forcedClear();
|
|
269
|
+
|
|
270
|
+
const naturalOver = overshoot(s0, near0);
|
|
271
|
+
const naturalClear = naturalOver <= -NO_TANGENT.clearGap * H + EPS;
|
|
272
|
+
const naturalBleed = naturalOver >= NO_TANGENT.minBleed * s0 * eh - EPS;
|
|
273
|
+
|
|
274
|
+
if (inp.bleed === 'deep') {
|
|
275
|
+
const t = Math.max(NO_TANGENT.deepBleed, naturalBleed ? naturalOver / (s0 * eh) : 0);
|
|
276
|
+
return bleedOption(t) ?? clearOption() ?? forcedClear();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// auto
|
|
280
|
+
if (naturalClear && !inp.preferBleed) return make(s0, near0, 'natural placement clears the edge');
|
|
281
|
+
if (naturalBleed && focusOk(s0, near0)) return make(s0, near0, 'natural placement bleeds decisively');
|
|
282
|
+
// In the tangent zone (or a tilted subject): resolve by bleeding decisively. Clear-with-margin
|
|
283
|
+
// only when no ≥ minBleed bleed keeps the focus band visible.
|
|
284
|
+
const bleed = bleedOption(NO_TANGENT.minBleed);
|
|
285
|
+
if (bleed) return inp.preferBleed ? { ...bleed, reason: `${bleed.reason} (tilt pairs with a bleed)` } : bleed;
|
|
286
|
+
if (naturalClear) return make(s0, near0, 'natural placement clears the edge');
|
|
287
|
+
return clearOption() ?? forcedClear();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Does the vertical line x = `seamX` pass through the polygon spanned by `points`? */
|
|
291
|
+
export function crossesVertical(points: Point[], seamX: number, margin = 0): boolean {
|
|
292
|
+
const xs = points.map((p) => p.x);
|
|
293
|
+
return Math.min(...xs) < seamX + margin && Math.max(...xs) > seamX - margin;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Do two axis-aligned rects overlap? */
|
|
297
|
+
export function rectsOverlap(
|
|
298
|
+
a: { left: number; top: number; right: number; bottom: number },
|
|
299
|
+
b: { left: number; top: number; right: number; bottom: number }
|
|
300
|
+
): boolean {
|
|
301
|
+
return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
|
302
|
+
}
|
package/src/variants.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { canvasDimsForDevice, type ComposePlan, type ComposeScreenPlan, type ComposeStyle } from './compose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Three DISTINCT concepts from one plan — Product Page Optimization test candidates, not tweaks:
|
|
5
|
+
* - **A Framed**: device mockups, straight, one shared layout.
|
|
6
|
+
* - **B Frameless**: bare rounded screenshots; screens with a `crop` (or a tight `focus` band) become
|
|
7
|
+
* magnified zoom cards of that UI.
|
|
8
|
+
* - **C Panorama**: adjacent pairs share one continuous background + seam orbs; only the tilted hero
|
|
9
|
+
* (screen 1) straddles its seam — one crossing per set, rotation stays an accent.
|
|
10
|
+
*
|
|
11
|
+
* KEPT from the input (every concept): name (+ suffix), canvas size, copy (headline / subheadline /
|
|
12
|
+
* badge), colours (headlineColor / subheadlineColor), `background`, `deviceId`, `screenshot`,
|
|
13
|
+
* `focus`, `crop`, and the style's `palette`, `font` and `bleed` preference (default `auto`).
|
|
14
|
+
*
|
|
15
|
+
* OVERRIDDEN (the concept decides these):
|
|
16
|
+
* - every screen's `layout` → `text-top`: screens only share one device scale + baseline when they
|
|
17
|
+
* share a layout, and that shared system is what makes a set look designed;
|
|
18
|
+
* - per-screen `presentation` / `tilt` are dropped; `style.presentation` is `device` (A, C) or
|
|
19
|
+
* `frameless` (B, with `zoom` on screens that have a `crop` or a focus band ≤ 45% tall);
|
|
20
|
+
* - A and B are straight and without panorama (`tilt`, `tiltScreens`, `panorama` removed);
|
|
21
|
+
* - C tilts screen 1 only (`tiltScreens: [0]`, the input's non-zero `style.tilt` or 8°) and uses the
|
|
22
|
+
* input's `panorama.spans` if it has them (else adjacent pairs of the same canvas size); its
|
|
23
|
+
* `straddle` is the input's if set, else the hero only; `decoration` defaults to `orbs`.
|
|
24
|
+
*/
|
|
25
|
+
export type VariantKey = 'A' | 'B' | 'C';
|
|
26
|
+
|
|
27
|
+
export interface ComposeVariant {
|
|
28
|
+
key: VariantKey;
|
|
29
|
+
label: string;
|
|
30
|
+
plan: ComposePlan;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const VARIANT_LABELS: Record<VariantKey, string> = { A: 'Framed', B: 'Frameless', C: 'Panorama' };
|
|
34
|
+
|
|
35
|
+
/** Tilt (degrees) the panorama concept gives its hero screen. */
|
|
36
|
+
export const VARIANT_HERO_TILT = 8;
|
|
37
|
+
/** A focus band at most this tall (fraction of the screenshot) is worth a zoom card in concept B. */
|
|
38
|
+
export const VARIANT_ZOOM_MAX_FOCUS = 0.45;
|
|
39
|
+
|
|
40
|
+
const suffix = (name: string, key: VariantKey) => `${name} — ${key} ${VARIANT_LABELS[key]}`;
|
|
41
|
+
|
|
42
|
+
/** Copy a screen without the per-screen style knobs a concept decides (layout/presentation/tilt). */
|
|
43
|
+
function baseScreen(screen: ComposeScreenPlan): ComposeScreenPlan {
|
|
44
|
+
const copy: ComposeScreenPlan = JSON.parse(JSON.stringify(screen));
|
|
45
|
+
delete copy.layout;
|
|
46
|
+
delete copy.presentation;
|
|
47
|
+
delete copy.tilt;
|
|
48
|
+
return { ...copy, layout: 'text-top' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function baseStyle(style: ComposeStyle | undefined): ComposeStyle {
|
|
52
|
+
const out: ComposeStyle = { bleed: style?.bleed ?? 'auto' };
|
|
53
|
+
if (style?.palette) out.palette = JSON.parse(JSON.stringify(style.palette));
|
|
54
|
+
if (style?.font) out.font = style.font;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Adjacent pairs [0,1], [2,3], … of screens that share a canvas size (a lone last screen stays single). */
|
|
59
|
+
export function panoramaPairs(plan: ComposePlan): number[][] {
|
|
60
|
+
const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
|
|
61
|
+
const key = (s: ComposeScreenPlan) => {
|
|
62
|
+
if (explicit) return 'explicit';
|
|
63
|
+
const d = canvasDimsForDevice(s.deviceId);
|
|
64
|
+
return `${d.width}x${d.height}`;
|
|
65
|
+
};
|
|
66
|
+
const spans: number[][] = [];
|
|
67
|
+
for (let i = 0; i + 1 < plan.screens.length; ) {
|
|
68
|
+
if (key(plan.screens[i]) === key(plan.screens[i + 1])) {
|
|
69
|
+
spans.push([i, i + 1]);
|
|
70
|
+
i += 2;
|
|
71
|
+
} else {
|
|
72
|
+
i += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return spans;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function makeVariants(plan: ComposePlan): ComposeVariant[] {
|
|
79
|
+
const common = { canvasWidth: plan.canvasWidth, canvasHeight: plan.canvasHeight };
|
|
80
|
+
const strip = <T extends object>(o: T): T => JSON.parse(JSON.stringify(o)); // drops undefined keys
|
|
81
|
+
|
|
82
|
+
const a: ComposePlan = strip({
|
|
83
|
+
...common,
|
|
84
|
+
name: suffix(plan.name, 'A'),
|
|
85
|
+
style: { ...baseStyle(plan.style), presentation: 'device' },
|
|
86
|
+
screens: plan.screens.map(baseScreen)
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const b: ComposePlan = strip({
|
|
90
|
+
...common,
|
|
91
|
+
name: suffix(plan.name, 'B'),
|
|
92
|
+
style: { ...baseStyle(plan.style), presentation: 'frameless' },
|
|
93
|
+
screens: plan.screens.map((screen) => {
|
|
94
|
+
const s = baseScreen(screen);
|
|
95
|
+
const f = screen.focus;
|
|
96
|
+
const tight = !!f && Math.abs(f.bottom - f.top) <= VARIANT_ZOOM_MAX_FOCUS;
|
|
97
|
+
if (screen.crop || tight) s.presentation = 'zoom';
|
|
98
|
+
return s;
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const pano = plan.style?.panorama;
|
|
103
|
+
const spans = pano?.spans?.length ? JSON.parse(JSON.stringify(pano.spans)) : panoramaPairs(plan);
|
|
104
|
+
const heroStraddle = spans.some((span: number[]) => span[0] === 0) ? [0] : [];
|
|
105
|
+
const c: ComposePlan = strip({
|
|
106
|
+
...common,
|
|
107
|
+
name: suffix(plan.name, 'C'),
|
|
108
|
+
style: {
|
|
109
|
+
...baseStyle(plan.style),
|
|
110
|
+
presentation: 'device',
|
|
111
|
+
tilt: plan.style?.tilt ? plan.style.tilt : VARIANT_HERO_TILT,
|
|
112
|
+
tiltScreens: [0],
|
|
113
|
+
// Only the hero straddles by default (≤ 1 seam crossing per set); other spans keep the
|
|
114
|
+
// continuous background + orbs with centred, straight devices.
|
|
115
|
+
panorama: { spans, straddle: pano?.straddle ?? heroStraddle, decoration: pano?.decoration ?? 'orbs' }
|
|
116
|
+
},
|
|
117
|
+
screens: plan.screens.map(baseScreen)
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
{ key: 'A', label: VARIANT_LABELS.A, plan: a },
|
|
122
|
+
{ key: 'B', label: VARIANT_LABELS.B, plan: b },
|
|
123
|
+
{ key: 'C', label: VARIANT_LABELS.C, plan: c }
|
|
124
|
+
];
|
|
125
|
+
}
|