@appshoteditor/shot-dsl 0.3.0 → 0.5.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 +219 -6
- package/package.json +9 -3
- package/src/color.ts +76 -0
- package/src/compose.ts +2141 -123
- package/src/decor.ts +137 -0
- package/src/frames.ts +3 -0
- package/src/index.ts +6 -0
- package/src/layout-system.ts +302 -0
- package/src/palette.ts +186 -0
- package/src/typography.ts +200 -0
- package/src/validate.ts +46 -2
- package/src/variants.ts +222 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line breaking by meaning (shot-dsl 0.5.0). The composer never lets Fabric's greedy wrap decide a
|
|
3
|
+
* headline's shape:
|
|
4
|
+
* - an explicit `\n` in the copy is ALWAYS a line break — lines the author broke are never merged;
|
|
5
|
+
* - a line (explicit or not) that doesn't fit the box is broken into the SAME number of lines the
|
|
6
|
+
* greedy wrap would use, but balanced — minimal raggedness, no single short word stranded on the
|
|
7
|
+
* last line, breaks preferred after punctuation and never right after a short function word.
|
|
8
|
+
* The chosen breaks are emitted as `\n` in the Textbox text, so the editor shows exactly these lines
|
|
9
|
+
* (each fits the box by the conservative width model, so Fabric never re-wraps them).
|
|
10
|
+
*
|
|
11
|
+
* CJK (and other scripts written without spaces) may break between any two of their characters:
|
|
12
|
+
* such characters are tokens of their own, joined without a space.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Width of one line (canvas units) for the font model in use. */
|
|
16
|
+
export type LineWidth = (line: string) => number;
|
|
17
|
+
|
|
18
|
+
/** Words that read badly at the END of a line ("Hear the / word"). */
|
|
19
|
+
const WEAK_ENDINGS = new Set([
|
|
20
|
+
'a',
|
|
21
|
+
'an',
|
|
22
|
+
'the',
|
|
23
|
+
'to',
|
|
24
|
+
'of',
|
|
25
|
+
'and',
|
|
26
|
+
'or',
|
|
27
|
+
'for',
|
|
28
|
+
'in',
|
|
29
|
+
'on',
|
|
30
|
+
'at',
|
|
31
|
+
'by',
|
|
32
|
+
'with',
|
|
33
|
+
'your',
|
|
34
|
+
'my',
|
|
35
|
+
'our',
|
|
36
|
+
'their',
|
|
37
|
+
'is',
|
|
38
|
+
'it’s',
|
|
39
|
+
"it's",
|
|
40
|
+
'&'
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const PUNCT_END = /[.,:;!?—–)。,、!?]$/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Beyond this many tokens a line is broken greedily (linear) instead of balanced (the balancing DP is
|
|
47
|
+
* O(tokens² · lines)). Real headlines are ≤ ~12 words; this only keeps absurd copy fast — it fails
|
|
48
|
+
* the copy-length rules anyway.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_BALANCED_TOKENS = 40;
|
|
51
|
+
|
|
52
|
+
/** Ideographs, kana, Hangul, fullwidth forms: break anywhere between them; about 1 em wide. */
|
|
53
|
+
export function isWideChar(ch: string): boolean {
|
|
54
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
55
|
+
return (
|
|
56
|
+
(c >= 0x1100 && c <= 0x115f) || // Hangul Jamo
|
|
57
|
+
(c >= 0x2e80 && c <= 0x303e) || // CJK radicals, punctuation
|
|
58
|
+
(c >= 0x3041 && c <= 0x33ff) || // kana, CJK symbols
|
|
59
|
+
(c >= 0x3400 && c <= 0x4dbf) || // CJK ext A
|
|
60
|
+
(c >= 0x4e00 && c <= 0x9fff) || // CJK unified
|
|
61
|
+
(c >= 0xa960 && c <= 0xa97f) ||
|
|
62
|
+
(c >= 0xac00 && c <= 0xd7a3) || // Hangul syllables
|
|
63
|
+
(c >= 0xf900 && c <= 0xfaff) ||
|
|
64
|
+
(c >= 0xfe30 && c <= 0xfe4f) ||
|
|
65
|
+
(c >= 0xff00 && c <= 0xff60) || // fullwidth forms
|
|
66
|
+
(c >= 0xffe0 && c <= 0xffe6) ||
|
|
67
|
+
(c >= 0x20000 && c <= 0x3fffd)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Token {
|
|
72
|
+
text: string;
|
|
73
|
+
/** A space precedes this token when it follows another on the same line. */
|
|
74
|
+
space: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Words split at spaces; runs of wide characters split per character (no space between them). */
|
|
78
|
+
function tokenize(line: string): Token[] {
|
|
79
|
+
const tokens: Token[] = [];
|
|
80
|
+
for (const word of line.split(/\s+/).filter(Boolean)) {
|
|
81
|
+
let buf = '';
|
|
82
|
+
let first = true;
|
|
83
|
+
const flush = () => {
|
|
84
|
+
if (!buf) return;
|
|
85
|
+
tokens.push({ text: buf, space: first });
|
|
86
|
+
first = false;
|
|
87
|
+
buf = '';
|
|
88
|
+
};
|
|
89
|
+
for (const ch of word) {
|
|
90
|
+
if (isWideChar(ch)) {
|
|
91
|
+
flush();
|
|
92
|
+
tokens.push({ text: ch, space: first });
|
|
93
|
+
first = false;
|
|
94
|
+
} else buf += ch;
|
|
95
|
+
}
|
|
96
|
+
flush();
|
|
97
|
+
}
|
|
98
|
+
return tokens;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const join = (tokens: Token[], i: number, j: number) => tokens.slice(i, j).reduce((s, t, k) => s + (k > 0 && t.space ? ' ' : '') + t.text, '');
|
|
102
|
+
|
|
103
|
+
/** Greedy wrap at token boundaries — the line COUNT to balance to (and the fallback breaking). */
|
|
104
|
+
function greedy(tokens: Token[], maxWidth: number, width: LineWidth): Array<[number, number]> {
|
|
105
|
+
const lines: Array<[number, number]> = [];
|
|
106
|
+
let start = 0;
|
|
107
|
+
for (let j = 1; j < tokens.length; j++) {
|
|
108
|
+
if (width(join(tokens, start, j + 1)) > maxWidth) {
|
|
109
|
+
lines.push([start, j]);
|
|
110
|
+
start = j;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (tokens.length) lines.push([start, tokens.length]);
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Balanced break of one line into its greedy line count (greedy past MAX_BALANCED_TOKENS). A single
|
|
119
|
+
* token wider than the box stays on its own line (the caller reports it — see `overlongWords`).
|
|
120
|
+
*/
|
|
121
|
+
function balanceLine(line: string, maxWidth: number, width: LineWidth): string[] {
|
|
122
|
+
const tokens = tokenize(line);
|
|
123
|
+
if (tokens.length === 0) return [''];
|
|
124
|
+
if (width(join(tokens, 0, tokens.length)) <= maxWidth) return [join(tokens, 0, tokens.length)];
|
|
125
|
+
const g = greedy(tokens, maxWidth, width);
|
|
126
|
+
const n = g.length;
|
|
127
|
+
const T = tokens.length;
|
|
128
|
+
if (n === 1 || T > MAX_BALANCED_TOKENS || tokens.some((t) => width(t.text) > maxWidth)) return g.map(([i, j]) => join(tokens, i, j));
|
|
129
|
+
|
|
130
|
+
const cost = (i: number, j: number, last: boolean): number => {
|
|
131
|
+
const w = width(join(tokens, i, j));
|
|
132
|
+
if (w > maxWidth) return Infinity;
|
|
133
|
+
const slack = (maxWidth - w) / maxWidth;
|
|
134
|
+
let c = slack * slack;
|
|
135
|
+
const lastWord = tokens[j - 1].text;
|
|
136
|
+
const words = j - i;
|
|
137
|
+
if (!last) {
|
|
138
|
+
if (PUNCT_END.test(lastWord)) c -= 0.12;
|
|
139
|
+
if (WEAK_ENDINGS.has(lastWord.toLowerCase())) c += 0.35;
|
|
140
|
+
// A lone word on an inner line ("See / their progress") reads as stranded too.
|
|
141
|
+
if (words === 1 && T > 2) c += 0.6;
|
|
142
|
+
} else if (words === 1 && T > 1) {
|
|
143
|
+
// A single word alone on the last line (an orphan) — heavily penalised.
|
|
144
|
+
c += 1.2;
|
|
145
|
+
}
|
|
146
|
+
return c;
|
|
147
|
+
};
|
|
148
|
+
// DP over break positions: best[k][j] = min cost of tokens[0..j) in k lines.
|
|
149
|
+
const best: number[][] = Array.from({ length: n + 1 }, () => new Array(T + 1).fill(Infinity));
|
|
150
|
+
const from: number[][] = Array.from({ length: n + 1 }, () => new Array(T + 1).fill(-1));
|
|
151
|
+
best[0][0] = 0;
|
|
152
|
+
for (let k = 1; k <= n; k++) {
|
|
153
|
+
for (let j = k; j <= T; j++) {
|
|
154
|
+
for (let i = k - 1; i < j; i++) {
|
|
155
|
+
if (best[k - 1][i] === Infinity) continue;
|
|
156
|
+
const c = best[k - 1][i] + cost(i, j, k === n && j === T);
|
|
157
|
+
if (c < best[k][j]) {
|
|
158
|
+
best[k][j] = c;
|
|
159
|
+
from[k][j] = i;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (best[n][T] === Infinity) return g.map(([i, j]) => join(tokens, i, j));
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
for (let k = n, j = T; k > 0; k--) {
|
|
167
|
+
const i = from[k][j];
|
|
168
|
+
lines.unshift(join(tokens, i, j));
|
|
169
|
+
j = i;
|
|
170
|
+
}
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The lines `text` is set in: every explicit `\n` line is kept as written, and only a line that
|
|
176
|
+
* doesn't fit the box is broken (balanced) — never merged with its neighbours.
|
|
177
|
+
*/
|
|
178
|
+
export function breakLines(text: string, maxWidth: number, width: LineWidth): string[] {
|
|
179
|
+
return text
|
|
180
|
+
.replace(/\r/g, '')
|
|
181
|
+
.split('\n')
|
|
182
|
+
.flatMap((line) => balanceLine(line.trim().replace(/\s+/g, ' '), maxWidth, width));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Tokens (words, or single wide characters) wider than the box. Fabric's Textbox never breaks inside
|
|
187
|
+
* a word — it widens the box to the longest word, which would overflow the side margin — so the
|
|
188
|
+
* composer rejects such copy with a clear error.
|
|
189
|
+
*/
|
|
190
|
+
export function overlongWords(text: string, maxWidth: number, width: LineWidth): string[] {
|
|
191
|
+
return tokenize(text.replace(/\n/g, ' ')).filter((t) => width(t.text) > maxWidth).map((t) => t.text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** True when the LAST line of `lines` is one short word while the block has more than one line. */
|
|
195
|
+
export function hasOrphan(lines: string[]): boolean {
|
|
196
|
+
if (lines.length < 2) return false;
|
|
197
|
+
const last = lines[lines.length - 1].trim().split(/\s+/).filter(Boolean);
|
|
198
|
+
const prev = lines[lines.length - 2].trim().split(/\s+/).filter(Boolean);
|
|
199
|
+
return last.length === 1 && [...last[0]].length > 0 && !isWideChar([...last[0]][0]) && prev.length > 1;
|
|
200
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -199,12 +199,38 @@ function checkImageRefs(value: unknown, at: string, errors: string[], depth = 0)
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
const DEVICE_SHADOW_KEYS = new Set(['color', 'blur', 'offsetX', 'offsetY']);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Problems with a device layer's `fabricData.deviceShadow` (shot-dsl 0.5.0): `{ color, blur, offsetX,
|
|
206
|
+
* offsetY }` in canvas units — a colour string (≤ 64 chars) and finite numbers, blur in
|
|
207
|
+
* [0, canvasWidth] and offsets within ±canvasWidth (the composer emits ~7.5% / 3% of W).
|
|
208
|
+
*/
|
|
209
|
+
export function validateDeviceShadow(value: unknown, canvasWidth: number, at = 'deviceShadow'): string[] {
|
|
210
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return [`${at} must be an object`];
|
|
211
|
+
const errors: string[] = [];
|
|
212
|
+
const v = value as Record<string, unknown>;
|
|
213
|
+
for (const key of Object.keys(v)) if (!DEVICE_SHADOW_KEYS.has(key)) errors.push(`${at}.${key} is not allowed`);
|
|
214
|
+
if (typeof v.color !== 'string' || !v.color || v.color.length > 64) errors.push(`${at}.color must be a colour string (≤ 64 chars)`);
|
|
215
|
+
const limit = canvasWidth > 0 && Number.isFinite(canvasWidth) ? canvasWidth : 4096;
|
|
216
|
+
const num = (key: string, min: number) => {
|
|
217
|
+
const n = v[key];
|
|
218
|
+
if (n === undefined && key !== 'blur') return;
|
|
219
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n < min || n > limit) errors.push(`${at}.${key} must be a number from ${min} to ${limit}`);
|
|
220
|
+
};
|
|
221
|
+
num('blur', 0);
|
|
222
|
+
num('offsetX', -limit);
|
|
223
|
+
num('offsetY', -limit);
|
|
224
|
+
return errors;
|
|
225
|
+
}
|
|
226
|
+
|
|
202
227
|
/** Per-layer fabricData rules for handoffs (see validateTemplate). */
|
|
203
228
|
function validateLayerFabricData(
|
|
204
229
|
layer: LayerJSON,
|
|
205
230
|
fd: Record<string, unknown>,
|
|
206
231
|
at: string,
|
|
207
|
-
frameIds: Set<string
|
|
232
|
+
frameIds: Set<string>,
|
|
233
|
+
canvasWidth = 0
|
|
208
234
|
): string[] {
|
|
209
235
|
const errors: string[] = [];
|
|
210
236
|
const fdAt = `${at}.fabricData`;
|
|
@@ -251,6 +277,12 @@ function validateLayerFabricData(
|
|
|
251
277
|
if (fd.layerType === 'deviceFrame') errors.push(`${fdAt}.layerType "deviceFrame" is only allowed on device layers`);
|
|
252
278
|
}
|
|
253
279
|
|
|
280
|
+
// Device drop shadow (0.5.0).
|
|
281
|
+
if (fd.deviceShadow !== undefined) {
|
|
282
|
+
if (layer.type !== 'device') errors.push(`${fdAt}.deviceShadow is only allowed on device layers`);
|
|
283
|
+
errors.push(...validateDeviceShadow(fd.deviceShadow, canvasWidth, `${fdAt}.deviceShadow`));
|
|
284
|
+
}
|
|
285
|
+
|
|
254
286
|
// Device-owned screenshot (0.3.0+).
|
|
255
287
|
if (fd.screenshot !== undefined) {
|
|
256
288
|
if (layer.type !== 'device') errors.push(`${fdAt}.screenshot is only allowed on device layers`);
|
|
@@ -272,6 +304,18 @@ function validateLayerFabricData(
|
|
|
272
304
|
return errors;
|
|
273
305
|
}
|
|
274
306
|
|
|
307
|
+
/**
|
|
308
|
+
* The handoff API's size cap: `POST /api/handoffs` rejects a body over this many bytes (413). The
|
|
309
|
+
* body is `JSON.stringify({ template })`. Producers (the skill's `lint` / `publish`) check
|
|
310
|
+
* `handoffBytes` against it before any network call.
|
|
311
|
+
*/
|
|
312
|
+
export const MAX_HANDOFF_BYTES = 256 * 1024;
|
|
313
|
+
|
|
314
|
+
/** UTF-8 size of the handoff request body for `template` (what the server measures). */
|
|
315
|
+
export function handoffBytes(template: unknown): number {
|
|
316
|
+
return new TextEncoder().encode(JSON.stringify({ template })).length;
|
|
317
|
+
}
|
|
318
|
+
|
|
275
319
|
export interface ValidationResult {
|
|
276
320
|
valid: boolean;
|
|
277
321
|
errors: string[];
|
|
@@ -315,7 +359,7 @@ export function validateTemplate(data: unknown): ValidationResult {
|
|
|
315
359
|
|
|
316
360
|
const fd = layer.fabricData as Record<string, unknown> | null;
|
|
317
361
|
if (!fd || typeof fd !== 'object') return;
|
|
318
|
-
errors.push(...validateLayerFabricData(layer, fd, at, frameIds));
|
|
362
|
+
errors.push(...validateLayerFabricData(layer, fd, at, frameIds, s.canvasWidth ?? 0));
|
|
319
363
|
});
|
|
320
364
|
});
|
|
321
365
|
}
|
package/src/variants.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canvasDimsForDevice,
|
|
3
|
+
type ComposeHero,
|
|
4
|
+
type ComposeMascot,
|
|
5
|
+
type ComposePalette,
|
|
6
|
+
type ComposePlan,
|
|
7
|
+
type ComposeScreenPlan,
|
|
8
|
+
type ComposeStyle
|
|
9
|
+
} from './compose';
|
|
10
|
+
import { isHexColor } from './color';
|
|
11
|
+
import type { Motif } from './decor';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Three DISTINCT art directions from one plan — Product Page Optimization test candidates, not tweaks
|
|
15
|
+
* (shot-dsl 0.5.0):
|
|
16
|
+
* - **A Brand Classic**: device frames on the vivid tonal brand palette; hero with the mascot + badge;
|
|
17
|
+
* callouts on the selling screens (explicit, else from a tight `focus`); an accent rhythm (every 4th screen text-bottom); shadows.
|
|
18
|
+
* - **B Clean Frameless**: pale tonal backgrounds with dark text; big full-width frameless
|
|
19
|
+
* screenshots bleeding deep off the bottom (a zoom card only where the plan marks an explicit
|
|
20
|
+
* `crop`); magnified callouts (explicit, else derived from a tight `focus`); hero with large type
|
|
21
|
+
* + the mascot.
|
|
22
|
+
* - **C Story Panorama**: the deep tonal palette as ONE continuous scene across triples/pairs of
|
|
23
|
+
* screens with a flowing motif (`style.panorama.decoration`, default `wave`), the mascot
|
|
24
|
+
* travelling across the seams, a tilted hero and deeper bleeds.
|
|
25
|
+
*
|
|
26
|
+
* KEPT from the input (every concept): name (+ suffix), canvas size, copy (headline / subheadline /
|
|
27
|
+
* badge), colours (headlineColor / subheadlineColor), `background`, `deviceId`, `screenshot`,
|
|
28
|
+
* `focus`, `crop`, `callout`, per-screen `mascot`, `art`, and the style's `font` and `bleed`
|
|
29
|
+
* preference (default `auto`; B and C use `deep` unless the input says `none`).
|
|
30
|
+
*
|
|
31
|
+
* BRAND COLOURS: `style.palette.colors` (the first is the base, a tonal palette's second is the
|
|
32
|
+
* accent); without a palette, the first screen's background colour. Concepts then use
|
|
33
|
+
* `mode: "tonal"` with their own tone. Without any brand colour, the input palette/backgrounds stay.
|
|
34
|
+
*
|
|
35
|
+
* OVERRIDDEN (the concept decides these):
|
|
36
|
+
* - every screen's `layout` → `text-top` (one shared system; the hero and rhythm accents differ);
|
|
37
|
+
* - per-screen `presentation` / `tilt` are dropped;
|
|
38
|
+
* - `hero`, `rhythm`, `callouts`, `tilt`/`tiltScreens` and `panorama` are set per concept (C keeps the
|
|
39
|
+
* input's `panorama.spans` / `decoration` when given).
|
|
40
|
+
*/
|
|
41
|
+
export type VariantKey = 'A' | 'B' | 'C';
|
|
42
|
+
|
|
43
|
+
export interface ComposeVariant {
|
|
44
|
+
key: VariantKey;
|
|
45
|
+
label: string;
|
|
46
|
+
plan: ComposePlan;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const VARIANT_LABELS: Record<VariantKey, string> = { A: 'Brand Classic', B: 'Clean Frameless', C: 'Story Panorama' };
|
|
50
|
+
|
|
51
|
+
/** Tilt (degrees) the panorama concept gives its hero screen. */
|
|
52
|
+
export const VARIANT_HERO_TILT = 8;
|
|
53
|
+
/** Rhythm period of concept A (every 4th screen is an accent). */
|
|
54
|
+
export const VARIANT_RHYTHM_EVERY = 4;
|
|
55
|
+
|
|
56
|
+
const suffix = (name: string, key: VariantKey) => `${name} — ${key} ${VARIANT_LABELS[key]}`;
|
|
57
|
+
|
|
58
|
+
/** Copy a screen without the per-screen style knobs a concept decides (layout/presentation/tilt). */
|
|
59
|
+
function baseScreen(screen: ComposeScreenPlan): ComposeScreenPlan {
|
|
60
|
+
const copy: ComposeScreenPlan = JSON.parse(JSON.stringify(screen));
|
|
61
|
+
delete copy.layout;
|
|
62
|
+
delete copy.presentation;
|
|
63
|
+
delete copy.tilt;
|
|
64
|
+
return { ...copy, layout: 'text-top' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The plan's brand colours: [base, accent?] (see module doc), or null. */
|
|
68
|
+
export function brandColors(plan: ComposePlan): { base: string; accent?: string } | null {
|
|
69
|
+
const pal = plan.style?.palette;
|
|
70
|
+
const colors = (pal?.colors ?? []).filter(isHexColor);
|
|
71
|
+
if (colors.length) return { base: colors[0], accent: pal?.mode === 'tonal' ? colors[1] : undefined };
|
|
72
|
+
const bg = plan.screens[0]?.background;
|
|
73
|
+
const first = bg?.type === 'gradient' ? (bg.gradient?.colorStops?.[0]?.color ?? bg.gradient?.colors?.[0]) : bg?.color;
|
|
74
|
+
return isHexColor(first) ? { base: first } : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function tonal(plan: ComposePlan, tone: 'light' | 'vivid' | 'deep'): ComposePalette | undefined {
|
|
78
|
+
const brand = brandColors(plan);
|
|
79
|
+
if (!brand) return plan.style?.palette ? JSON.parse(JSON.stringify(plan.style.palette)) : undefined;
|
|
80
|
+
return { mode: 'tonal', colors: brand.accent ? [brand.base, brand.accent] : [brand.base], tone };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function baseStyle(plan: ComposePlan): ComposeStyle {
|
|
84
|
+
const style = plan.style;
|
|
85
|
+
const out: ComposeStyle = { bleed: style?.bleed ?? 'auto' };
|
|
86
|
+
if (style?.font) out.font = style.font;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The hero mascot: the input hero's, else the first art, anchored by the headline. */
|
|
91
|
+
function heroMascot(plan: ComposePlan, size?: number): ComposeMascot | undefined {
|
|
92
|
+
const given = plan.style?.hero && typeof plan.style.hero === 'object' ? plan.style.hero.mascot : undefined;
|
|
93
|
+
if (given) return { ...given };
|
|
94
|
+
const art = plan.art?.[0];
|
|
95
|
+
return art ? { art: art.id, anchor: 'headline', ...(size ? { size } : {}) } : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The input's hero screen index (-1 when the input turns the hero off). */
|
|
99
|
+
function heroIndexOf(plan: ComposePlan): number {
|
|
100
|
+
const h = plan.style?.hero;
|
|
101
|
+
if (h === false) return -1;
|
|
102
|
+
const i = h && typeof h === 'object' && Number.isFinite(h.screen) ? Math.floor(h.screen!) : 0;
|
|
103
|
+
return Math.min(Math.max(0, i), Math.max(0, plan.screens.length - 1));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The concept's hero: the input's (incl. `screen`) + the concept's overrides; `false` stays off. */
|
|
107
|
+
function hero(plan: ComposePlan, extra: ComposeHero, mascotSize?: number): ComposeHero | false {
|
|
108
|
+
if (plan.style?.hero === false) return false;
|
|
109
|
+
const input = plan.style?.hero && typeof plan.style.hero === 'object' ? plan.style.hero : {};
|
|
110
|
+
const out: ComposeHero = { ...JSON.parse(JSON.stringify(input)), ...extra };
|
|
111
|
+
const mascot = heroMascot(plan, mascotSize);
|
|
112
|
+
if (mascot) out.mascot = mascot;
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Adjacent pairs [0,1], [2,3], … of screens that share a canvas size (a lone last screen stays single). */
|
|
117
|
+
export function panoramaPairs(plan: ComposePlan): number[][] {
|
|
118
|
+
return panoramaRuns(plan, [2]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Runs of adjacent same-canvas screens with lengths cycling through `sizes` (e.g. [3, 2] → triples
|
|
123
|
+
* and pairs), never leaving a lone screen when a shorter run fits.
|
|
124
|
+
*/
|
|
125
|
+
export function panoramaRuns(plan: ComposePlan, sizes: number[] = [3, 2]): number[][] {
|
|
126
|
+
const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
|
|
127
|
+
const key = (s: ComposeScreenPlan) => {
|
|
128
|
+
if (explicit) return 'explicit';
|
|
129
|
+
const d = canvasDimsForDevice(s.deviceId);
|
|
130
|
+
return `${d.width}x${d.height}`;
|
|
131
|
+
};
|
|
132
|
+
const spans: number[][] = [];
|
|
133
|
+
let cycle = 0;
|
|
134
|
+
for (let i = 0; i < plan.screens.length; ) {
|
|
135
|
+
let len = 1;
|
|
136
|
+
while (i + len < plan.screens.length && key(plan.screens[i + len]) === key(plan.screens[i])) len++;
|
|
137
|
+
// `len` = same-canvas run from i; take the cycle's size, or the largest that fits.
|
|
138
|
+
let take = Math.min(sizes[cycle % sizes.length], len);
|
|
139
|
+
// Don't strand a single screen after this span when a smaller span would pair it up.
|
|
140
|
+
if (len - take === 1 && take > 2) take = 2;
|
|
141
|
+
if (take >= 2) {
|
|
142
|
+
spans.push(Array.from({ length: take }, (_, k) => i + k));
|
|
143
|
+
cycle++;
|
|
144
|
+
i += take;
|
|
145
|
+
} else i += 1;
|
|
146
|
+
}
|
|
147
|
+
return spans;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function makeVariants(plan: ComposePlan): ComposeVariant[] {
|
|
151
|
+
const common = { canvasWidth: plan.canvasWidth, canvasHeight: plan.canvasHeight, art: plan.art };
|
|
152
|
+
const strip = <T extends object>(o: T): T => JSON.parse(JSON.stringify(o)); // drops undefined keys
|
|
153
|
+
const badge = (plan.style?.hero && typeof plan.style.hero === 'object' ? plan.style.hero.badge : undefined) ?? undefined;
|
|
154
|
+
|
|
155
|
+
const a: ComposePlan = strip({
|
|
156
|
+
...common,
|
|
157
|
+
name: suffix(plan.name, 'A'),
|
|
158
|
+
style: {
|
|
159
|
+
...baseStyle(plan),
|
|
160
|
+
presentation: 'device',
|
|
161
|
+
palette: tonal(plan, 'vivid'),
|
|
162
|
+
hero: hero(plan, { badge }),
|
|
163
|
+
rhythm: { every: VARIANT_RHYTHM_EVERY, treatment: 'text-bottom' },
|
|
164
|
+
callouts: 'auto'
|
|
165
|
+
},
|
|
166
|
+
screens: plan.screens.map(baseScreen)
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const b: ComposePlan = strip({
|
|
170
|
+
...common,
|
|
171
|
+
name: suffix(plan.name, 'B'),
|
|
172
|
+
style: {
|
|
173
|
+
...baseStyle(plan),
|
|
174
|
+
// Big screenshots bleeding decisively off the bottom: the editorial "clean" look.
|
|
175
|
+
bleed: plan.style?.bleed === 'none' ? 'none' : 'deep',
|
|
176
|
+
presentation: 'frameless',
|
|
177
|
+
palette: tonal(plan, 'light'),
|
|
178
|
+
hero: hero(plan, { badge, scale: 1.3 }),
|
|
179
|
+
callouts: 'auto'
|
|
180
|
+
},
|
|
181
|
+
screens: plan.screens.map((screen) => {
|
|
182
|
+
const s = baseScreen(screen);
|
|
183
|
+
// B1: only an explicit `crop` becomes a zoom card; everything else shows the full screen.
|
|
184
|
+
if (screen.crop) s.presentation = 'zoom';
|
|
185
|
+
return s;
|
|
186
|
+
})
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const pano = plan.style?.panorama;
|
|
190
|
+
const spans: number[][] = pano?.spans?.length ? JSON.parse(JSON.stringify(pano.spans)) : panoramaRuns(plan);
|
|
191
|
+
const decoration: Motif = pano?.decoration && pano.decoration !== 'orbs' ? pano.decoration : 'wave';
|
|
192
|
+
const art = plan.art?.[0];
|
|
193
|
+
const heroIndex = heroIndexOf(plan);
|
|
194
|
+
const c: ComposePlan = strip({
|
|
195
|
+
...common,
|
|
196
|
+
name: suffix(plan.name, 'C'),
|
|
197
|
+
style: {
|
|
198
|
+
...baseStyle(plan),
|
|
199
|
+
bleed: plan.style?.bleed === 'none' ? 'none' : 'deep',
|
|
200
|
+
presentation: 'device',
|
|
201
|
+
palette: tonal(plan, 'deep'),
|
|
202
|
+
hero: hero(plan, { badge, tilt: plan.style?.tilt ? plan.style.tilt : VARIANT_HERO_TILT }, 0.26),
|
|
203
|
+
callouts: 'none',
|
|
204
|
+
// The continuous scene carries the story; a straddle only when the input asks for one
|
|
205
|
+
// (a straddle needs ≥ 18% of the device on the next screen, which a focus band rarely allows).
|
|
206
|
+
panorama: { spans, ...(pano?.straddle !== undefined ? { straddle: pano.straddle } : {}), decoration }
|
|
207
|
+
},
|
|
208
|
+
screens: plan.screens.map((screen, i) => {
|
|
209
|
+
const s = baseScreen(screen);
|
|
210
|
+
// The mascot travels: it crosses the first seam of every span except the hero's.
|
|
211
|
+
const span = spans.find((sp) => sp[0] === i);
|
|
212
|
+
if (art && span && !span.includes(heroIndex) && !s.mascot) s.mascot = { art: art.id, anchor: 'seam', size: 0.2 };
|
|
213
|
+
return s;
|
|
214
|
+
})
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return [
|
|
218
|
+
{ key: 'A', label: VARIANT_LABELS.A, plan: a },
|
|
219
|
+
{ key: 'B', label: VARIANT_LABELS.B, plan: b },
|
|
220
|
+
{ key: 'C', label: VARIANT_LABELS.C, plan: c }
|
|
221
|
+
];
|
|
222
|
+
}
|