capture-flow 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +238 -2
- package/bin/capture-flow.mjs +12 -0
- package/package.json +55 -4
- package/src/build.ts +186 -0
- package/src/capture.ts +77 -0
- package/src/cli.ts +139 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +98 -0
- package/src/render.ts +75 -0
- package/src/verify.ts +90 -0
- package/vendor/wajha/LICENSE +21 -0
- package/vendor/wajha/NOTICE +21 -0
- package/vendor/wajha/engine/background.ts +55 -0
- package/vendor/wajha/engine/bidi.ts +589 -0
- package/vendor/wajha/engine/cache.ts +96 -0
- package/vendor/wajha/engine/color.ts +125 -0
- package/vendor/wajha/engine/devices/android.ts +53 -0
- package/vendor/wajha/engine/devices/browser.ts +170 -0
- package/vendor/wajha/engine/devices/finish.ts +154 -0
- package/vendor/wajha/engine/devices/geometry.ts +326 -0
- package/vendor/wajha/engine/devices/index.ts +143 -0
- package/vendor/wajha/engine/devices/ipad.ts +21 -0
- package/vendor/wajha/engine/devices/iphone.ts +55 -0
- package/vendor/wajha/engine/devices/macbook.ts +187 -0
- package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
- package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
- package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
- package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
- package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
- package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
- package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
- package/vendor/wajha/engine/devices/mock/art.ts +557 -0
- package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
- package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
- package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
- package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
- package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
- package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
- package/vendor/wajha/engine/devices/paths.ts +113 -0
- package/vendor/wajha/engine/devices/screen.ts +75 -0
- package/vendor/wajha/engine/devices/shadow.ts +124 -0
- package/vendor/wajha/engine/devices/slab.ts +200 -0
- package/vendor/wajha/engine/devices/vision.ts +43 -0
- package/vendor/wajha/engine/devices/watch.ts +14 -0
- package/vendor/wajha/engine/geometry.ts +152 -0
- package/vendor/wajha/engine/images.ts +96 -0
- package/vendor/wajha/engine/index.ts +20 -0
- package/vendor/wajha/engine/linebreak.ts +226 -0
- package/vendor/wajha/engine/markup.ts +84 -0
- package/vendor/wajha/engine/mock.ts +158 -0
- package/vendor/wajha/engine/paint.ts +474 -0
- package/vendor/wajha/engine/patterns.ts +263 -0
- package/vendor/wajha/engine/render.ts +364 -0
- package/vendor/wajha/engine/shapes.ts +260 -0
- package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
- package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
- package/vendor/wajha/engine/text.ts +694 -0
- package/vendor/wajha/engine/types.ts +190 -0
- package/vendor/wajha/export/paths.ts +197 -0
- package/vendor/wajha/export/png.ts +223 -0
- package/vendor/wajha/export/types.ts +60 -0
- package/vendor/wajha/model/adapt.ts +889 -0
- package/vendor/wajha/model/catalog.ts +369 -0
- package/vendor/wajha/model/clone.ts +58 -0
- package/vendor/wajha/model/colorMath.ts +128 -0
- package/vendor/wajha/model/defaults.ts +386 -0
- package/vendor/wajha/model/geometry.ts +95 -0
- package/vendor/wajha/model/ids.ts +13 -0
- package/vendor/wajha/model/instantiate.ts +280 -0
- package/vendor/wajha/model/mirror.ts +43 -0
- package/vendor/wajha/model/resolve.ts +87 -0
- package/vendor/wajha/model/rules.ts +249 -0
- package/vendor/wajha/model/stub.ts +4 -0
- package/vendor/wajha/model/types.ts +456 -0
- package/vendor/wajha/model/typography.ts +28 -0
- package/vendor/wajha/model/validate.ts +917 -0
- package/vendor/wajha/templates/builtin/aurora.ts +163 -0
- package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
- package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
- package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
- package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
- package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
- package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
- package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
- package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
- package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
- package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
- package/vendor/wajha/templates/builtin/noor.ts +107 -0
- package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
- package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
- package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
- package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
- package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
- package/vendor/wajha/templates/builtin/sage.ts +94 -0
- package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
- package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
- package/vendor/wajha/templates/index.ts +55 -0
- package/vendor/wajha/templates/kit.ts +761 -0
- package/vendor/wajha/ui/sortShots.ts +239 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factories for new elements, themes and projects. Owned by the model/store
|
|
3
|
+
* work package; signatures are a contract used across the app.
|
|
4
|
+
*
|
|
5
|
+
* Default layouts are composed in u (fractions of the canvas short side):
|
|
6
|
+
* on portrait canvases the headline starts 0.11u from the top, the subtitle
|
|
7
|
+
* sits below two headline lines, and the device starts below two subtitle
|
|
8
|
+
* lines and bleeds off the bottom edge. Wide canvases (Mac, feature
|
|
9
|
+
* graphic) put the copy on the left half and the device on the right half.
|
|
10
|
+
*/
|
|
11
|
+
import { FAMILIES } from './catalog';
|
|
12
|
+
import { backgroundTone, pickAccent, prefersLightText, withAlpha } from './colorMath';
|
|
13
|
+
import { deviceHeightFactor, familyMetrics } from './geometry';
|
|
14
|
+
import { newId } from './ids';
|
|
15
|
+
import { RANGES, clampToRange, cleanName, uniqueFamilies, uniqueLanguages } from './rules';
|
|
16
|
+
import type {
|
|
17
|
+
Background,
|
|
18
|
+
DeviceElement,
|
|
19
|
+
ExportSettings,
|
|
20
|
+
FamilyId,
|
|
21
|
+
FrameStyle,
|
|
22
|
+
ImageElement,
|
|
23
|
+
LangCode,
|
|
24
|
+
Project,
|
|
25
|
+
Shadow,
|
|
26
|
+
ShapeElement,
|
|
27
|
+
Slide,
|
|
28
|
+
SlideLayout,
|
|
29
|
+
TextElement,
|
|
30
|
+
Theme,
|
|
31
|
+
} from './types';
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_FONT = 'Inter Variable';
|
|
34
|
+
export const DEFAULT_PROJECT_NAME = 'Untitled';
|
|
35
|
+
export const DEFAULT_FAMILY: FamilyId = 'iphone';
|
|
36
|
+
export const DEFAULT_LANGUAGE: LangCode = 'en-US';
|
|
37
|
+
/** Slides in a blank project. */
|
|
38
|
+
export const BLANK_SLIDE_COUNT = 3;
|
|
39
|
+
|
|
40
|
+
export type TextRole = 'headline' | 'subtitle' | 'caption';
|
|
41
|
+
|
|
42
|
+
/* Composition, in u ------------------------------------------------ */
|
|
43
|
+
|
|
44
|
+
const TALL = {
|
|
45
|
+
headTop: 0.11,
|
|
46
|
+
headSize: 0.075,
|
|
47
|
+
headLine: 1.08,
|
|
48
|
+
subSize: 0.04,
|
|
49
|
+
subLine: 1.25,
|
|
50
|
+
gapHeadSub: 0.06,
|
|
51
|
+
gapSubDevice: 0.07,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const WIDE = {
|
|
55
|
+
headSize: 0.06,
|
|
56
|
+
subSize: 0.032,
|
|
57
|
+
gapHeadSub: 0.045,
|
|
58
|
+
/** Copy column: x center and width as fractions of the canvas width. */
|
|
59
|
+
columnX: 0.28,
|
|
60
|
+
columnW: 0.44,
|
|
61
|
+
/** Device area: x center and maximum width as fractions of the canvas width. */
|
|
62
|
+
deviceX: 0.74,
|
|
63
|
+
deviceW: 0.42,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Top of the subtitle on portrait canvases: two headline lines plus a gap below the headline top. */
|
|
67
|
+
const tallSubTop = () => TALL.headTop + 2 * TALL.headSize * TALL.headLine + TALL.gapHeadSub;
|
|
68
|
+
/** Top of the device on portrait canvases. */
|
|
69
|
+
const tallDeviceTop = () => tallSubTop() + 2 * TALL.subSize * TALL.subLine + TALL.gapSubDevice;
|
|
70
|
+
|
|
71
|
+
/** Top of the copy block on wide canvases (in u), centered vertically. */
|
|
72
|
+
function wideCopyTop(hu: number): number {
|
|
73
|
+
const block = 2 * WIDE.headSize * TALL.headLine + WIDE.gapHeadSub + 2 * WIDE.subSize * TALL.subLine;
|
|
74
|
+
return Math.max(0.04, hu / 2 - block / 2);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const round = (v: number) => Math.round(v * 1e6) / 1e6;
|
|
78
|
+
|
|
79
|
+
/* Theme ------------------------------------------------------------- */
|
|
80
|
+
|
|
81
|
+
/** Deep violet "aurora" mesh: violet, magenta and blue glows with a warm pink corner. */
|
|
82
|
+
export function defaultBackground(): Background {
|
|
83
|
+
return {
|
|
84
|
+
fill: {
|
|
85
|
+
type: 'mesh',
|
|
86
|
+
base: '#130b33',
|
|
87
|
+
points: [
|
|
88
|
+
{ x: 0.12, y: 0.06, color: '#6c47ff', radius: 0.62 },
|
|
89
|
+
{ x: 0.95, y: 0.28, color: '#b43ce6', radius: 0.5 },
|
|
90
|
+
{ x: 0.06, y: 0.74, color: '#3150ff', radius: 0.52 },
|
|
91
|
+
{ x: 0.92, y: 0.98, color: '#ff4f9a', radius: 0.42 },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
pattern: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The theme of blank projects: the violet mesh, Inter everywhere, light mock screens. */
|
|
99
|
+
export function defaultTheme(): Theme {
|
|
100
|
+
return {
|
|
101
|
+
background: defaultBackground(),
|
|
102
|
+
panorama: false,
|
|
103
|
+
fonts: { heading: DEFAULT_FONT, body: DEFAULT_FONT },
|
|
104
|
+
// Accent first (mock screens use it), then supporting hues, then light and dark neutrals.
|
|
105
|
+
palette: ['#7c5cff', '#ff5c9d', '#ffc46b', '#3dd6c6', '#ffffff', '#130b33'],
|
|
106
|
+
mockDark: false,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** PNG into store folders, every default size and language. */
|
|
111
|
+
export function defaultExportSettings(): ExportSettings {
|
|
112
|
+
return { sizes: [], languages: [], format: 'png', jpegQuality: 0.92, structure: 'stores' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Text, secondary text and highlight colours that read well on the theme background. */
|
|
116
|
+
export function textColors(theme: Theme): { text: string; secondary: string; caption: string; highlight: string } {
|
|
117
|
+
const tone = backgroundTone(theme?.background);
|
|
118
|
+
const light = prefersLightText(tone);
|
|
119
|
+
const text = light ? '#ffffff' : '#14121f';
|
|
120
|
+
const palette = Array.isArray(theme?.palette) ? theme.palette : [];
|
|
121
|
+
const highlight = pickAccent(palette, tone, text, light ? '#ffc46b' : '#6a4cff');
|
|
122
|
+
return { text, secondary: withAlpha(text, 0.75), caption: withAlpha(text, 0.7), highlight };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Elements ---------------------------------------------------------- */
|
|
126
|
+
|
|
127
|
+
const fontsOf = (theme: Theme) => ({
|
|
128
|
+
heading: typeof theme?.fonts?.heading === 'string' && theme.fonts.heading ? theme.fonts.heading : DEFAULT_FONT,
|
|
129
|
+
body: typeof theme?.fonts?.body === 'string' && theme.fonts.body ? theme.fonts.body : DEFAULT_FONT,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** A text element preset for the given role, sized for `family`'s canvas. */
|
|
133
|
+
export function newTextElement(role: TextRole, family: FamilyId, theme: Theme): TextElement {
|
|
134
|
+
const m = familyMetrics(family);
|
|
135
|
+
const colors = textColors(theme);
|
|
136
|
+
const fonts = fontsOf(theme);
|
|
137
|
+
const maxW = 0.92 * m.wu;
|
|
138
|
+
const base = {
|
|
139
|
+
id: newId('e'),
|
|
140
|
+
kind: 'text' as const,
|
|
141
|
+
rotation: 0,
|
|
142
|
+
opacity: 1,
|
|
143
|
+
style: {
|
|
144
|
+
font: fonts.body,
|
|
145
|
+
weight: 500,
|
|
146
|
+
size: 0.04,
|
|
147
|
+
lineHeight: 1.25,
|
|
148
|
+
letterSpacing: 0,
|
|
149
|
+
color: colors.text,
|
|
150
|
+
highlight: colors.highlight,
|
|
151
|
+
highlightMode: 'color' as const,
|
|
152
|
+
align: 'center' as const,
|
|
153
|
+
transform: 'none' as const,
|
|
154
|
+
maxLines: 3,
|
|
155
|
+
shadow: null,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (role === 'caption') {
|
|
160
|
+
return {
|
|
161
|
+
...base,
|
|
162
|
+
key: 'caption',
|
|
163
|
+
x: 0.5,
|
|
164
|
+
y: 0.5,
|
|
165
|
+
width: round(Math.min(0.6, maxW)),
|
|
166
|
+
valign: 'middle',
|
|
167
|
+
style: {
|
|
168
|
+
...base.style,
|
|
169
|
+
weight: 700,
|
|
170
|
+
size: m.wide ? 0.026 : 0.03,
|
|
171
|
+
lineHeight: 1.3,
|
|
172
|
+
letterSpacing: 0.08,
|
|
173
|
+
color: colors.caption,
|
|
174
|
+
transform: 'uppercase',
|
|
175
|
+
maxLines: 2,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const headline = role === 'headline';
|
|
181
|
+
const size = m.wide ? (headline ? WIDE.headSize : WIDE.subSize) : headline ? TALL.headSize : TALL.subSize;
|
|
182
|
+
let x = 0.5;
|
|
183
|
+
let width = headline ? 0.86 : 0.8;
|
|
184
|
+
let topU: number;
|
|
185
|
+
if (m.wide) {
|
|
186
|
+
x = WIDE.columnX;
|
|
187
|
+
width = WIDE.columnW * m.wu;
|
|
188
|
+
const top = wideCopyTop(m.hu);
|
|
189
|
+
topU = headline ? top : top + 2 * WIDE.headSize * TALL.headLine + WIDE.gapHeadSub;
|
|
190
|
+
} else {
|
|
191
|
+
topU = headline ? TALL.headTop : tallSubTop();
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
...base,
|
|
195
|
+
key: role,
|
|
196
|
+
x,
|
|
197
|
+
y: round(topU / m.hu),
|
|
198
|
+
width: round(Math.min(width, maxW)),
|
|
199
|
+
valign: 'top',
|
|
200
|
+
style: {
|
|
201
|
+
...base.style,
|
|
202
|
+
font: headline ? fonts.heading : fonts.body,
|
|
203
|
+
weight: headline ? 800 : 500,
|
|
204
|
+
size,
|
|
205
|
+
lineHeight: headline ? TALL.headLine : TALL.subLine,
|
|
206
|
+
letterSpacing: headline ? -0.02 : 0,
|
|
207
|
+
color: headline ? colors.text : colors.secondary,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const DEVICE_STYLE: FrameStyle = { color: '#3a3a3f', finish: 'metal', bezel: 'black' };
|
|
213
|
+
|
|
214
|
+
function deviceShadow(theme: Theme): Shadow {
|
|
215
|
+
// Dark backgrounds need a deeper shadow to separate the device; light ones a soft tinted one.
|
|
216
|
+
const light = !prefersLightText(backgroundTone(theme?.background));
|
|
217
|
+
return light ? { color: '#1a103340', blur: 0.06, x: 0, y: 0.025 } : { color: '#00000073', blur: 0.07, x: 0, y: 0.03 };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The family's default device, sized so it sits nicely: on phones about
|
|
222
|
+
* 0.78u wide below the copy, bleeding a little off the bottom; on tablets
|
|
223
|
+
* 0.80u; on wide canvases on the right half (phones bleed, laptops center).
|
|
224
|
+
*/
|
|
225
|
+
export function newDeviceElement(family: FamilyId, theme: Theme): DeviceElement {
|
|
226
|
+
const fam = FAMILIES[family] ?? FAMILIES[DEFAULT_FAMILY];
|
|
227
|
+
const m = familyMetrics(fam.id);
|
|
228
|
+
const factor = deviceHeightFactor(fam.defaultFrame, fam.screenAspect);
|
|
229
|
+
let width: number;
|
|
230
|
+
let x = 0.5;
|
|
231
|
+
let yU: number;
|
|
232
|
+
if (m.wide) {
|
|
233
|
+
x = WIDE.deviceX;
|
|
234
|
+
const maxW = WIDE.deviceW * m.wu;
|
|
235
|
+
if (maxW * factor > 0.88 * m.hu) {
|
|
236
|
+
// Portrait device on a landscape canvas: start near the top and bleed off the bottom.
|
|
237
|
+
const top = 0.12 * m.hu;
|
|
238
|
+
width = Math.min(maxW, (m.hu - top) / 0.8 / factor);
|
|
239
|
+
yU = top + (width * factor) / 2;
|
|
240
|
+
} else {
|
|
241
|
+
width = maxW;
|
|
242
|
+
yU = m.hu / 2;
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
const top = tallDeviceTop();
|
|
246
|
+
if (fam.aspectClass === 'tablet') {
|
|
247
|
+
width = 0.8;
|
|
248
|
+
} else {
|
|
249
|
+
// About 5 % of the device hidden below the edge, within a sensible width.
|
|
250
|
+
width = Math.min(0.78, Math.max(0.62, (m.hu - top) / 0.95 / factor));
|
|
251
|
+
}
|
|
252
|
+
yU = top + (width * factor) / 2;
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
id: newId('e'),
|
|
256
|
+
kind: 'device',
|
|
257
|
+
x,
|
|
258
|
+
y: round(yU / m.hu),
|
|
259
|
+
rotation: 0,
|
|
260
|
+
opacity: 1,
|
|
261
|
+
frame: fam.defaultFrame,
|
|
262
|
+
style: { ...DEVICE_STYLE },
|
|
263
|
+
width: round(width),
|
|
264
|
+
slot: 'main',
|
|
265
|
+
cornerRadius: 0.06,
|
|
266
|
+
shadow: deviceShadow(theme),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A centered decorative shape in the theme's accent colour; blobs get a random seed and a soft glow. */
|
|
271
|
+
export function newShapeElement(shape: ShapeElement['shape'], theme: Theme): ShapeElement {
|
|
272
|
+
const palette = Array.isArray(theme?.palette) ? theme.palette : [];
|
|
273
|
+
const color = palette.find((c) => typeof c === 'string' && /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(c)) ?? '#7c5cff';
|
|
274
|
+
const base = {
|
|
275
|
+
id: newId('e'),
|
|
276
|
+
kind: 'shape' as const,
|
|
277
|
+
x: 0.5,
|
|
278
|
+
y: 0.5,
|
|
279
|
+
rotation: 0,
|
|
280
|
+
opacity: 1,
|
|
281
|
+
fill: { type: 'solid' as const, color },
|
|
282
|
+
radius: 0,
|
|
283
|
+
stroke: 0,
|
|
284
|
+
blur: 0,
|
|
285
|
+
seed: 0,
|
|
286
|
+
};
|
|
287
|
+
switch (shape) {
|
|
288
|
+
case 'rect':
|
|
289
|
+
return { ...base, shape, width: 0.5, height: 0.32, radius: 0.18 };
|
|
290
|
+
case 'ring':
|
|
291
|
+
return { ...base, shape, width: 0.45, height: 0.45, stroke: 0.025 };
|
|
292
|
+
case 'blob':
|
|
293
|
+
// A soft glow reads as a decorative light, which is how blobs are used.
|
|
294
|
+
return { ...base, shape, width: 0.55, height: 0.55, blur: 0.08, opacity: 0.85, seed: Math.floor(Math.random() * 2147483647) };
|
|
295
|
+
case 'circle':
|
|
296
|
+
default:
|
|
297
|
+
return { ...base, shape: 'circle', width: 0.4, height: 0.4 };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** An image element whose larger side is about 0.6u; `aspect` is height / width. */
|
|
302
|
+
export function newImageElement(assetId: string, aspect: number): ImageElement {
|
|
303
|
+
const a = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
|
|
304
|
+
const width = clampToRange(a >= 1 ? 0.6 / a : 0.6, { min: 0.1, max: RANGES.imageWidth.max });
|
|
305
|
+
return {
|
|
306
|
+
id: newId('e'),
|
|
307
|
+
kind: 'image',
|
|
308
|
+
x: 0.5,
|
|
309
|
+
y: 0.5,
|
|
310
|
+
rotation: 0,
|
|
311
|
+
opacity: 1,
|
|
312
|
+
assetId,
|
|
313
|
+
width: round(width),
|
|
314
|
+
cornerRadius: 0.04,
|
|
315
|
+
shadow: null,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* Layouts, slides, projects ----------------------------------------- */
|
|
320
|
+
|
|
321
|
+
export interface DefaultLayoutIds {
|
|
322
|
+
device: string;
|
|
323
|
+
headline: string;
|
|
324
|
+
subtitle: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Stable ids for derived default layouts, so selection survives re-renders. */
|
|
328
|
+
export const DEFAULT_LAYOUT_IDS: DefaultLayoutIds = { device: 'device', headline: 'headline', subtitle: 'subtitle' };
|
|
329
|
+
|
|
330
|
+
/** Headline + subtitle + device for `family`; the device is drawn first (below the copy). */
|
|
331
|
+
export function defaultLayout(family: FamilyId, theme: Theme, ids: DefaultLayoutIds = DEFAULT_LAYOUT_IDS): SlideLayout {
|
|
332
|
+
return {
|
|
333
|
+
background: null,
|
|
334
|
+
elements: [
|
|
335
|
+
{ ...newDeviceElement(family, theme), id: ids.device },
|
|
336
|
+
{ ...newTextElement('headline', family, theme), id: ids.headline },
|
|
337
|
+
{ ...newTextElement('subtitle', family, theme), id: ids.subtitle },
|
|
338
|
+
],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function familiesOrDefault(families: readonly FamilyId[]): FamilyId[] {
|
|
343
|
+
const out = uniqueFamilies(families);
|
|
344
|
+
return out.length ? out : [DEFAULT_FAMILY];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function languagesOrDefault(languages: readonly LangCode[]): LangCode[] {
|
|
348
|
+
const out = uniqueLanguages(languages);
|
|
349
|
+
return out.length ? out : [DEFAULT_LANGUAGE];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** An empty slide with a default layout for every family in `families`. */
|
|
353
|
+
export function newSlide(families: FamilyId[], theme: Theme, languages: LangCode[]): Slide {
|
|
354
|
+
// One id per role, shared by every family, so an element keeps its identity across families.
|
|
355
|
+
const ids: DefaultLayoutIds = { device: newId('e'), headline: newId('e'), subtitle: newId('e') };
|
|
356
|
+
const layouts: Slide['layouts'] = {};
|
|
357
|
+
for (const f of familiesOrDefault(families)) layouts[f] = defaultLayout(f, theme, ids);
|
|
358
|
+
const texts: Slide['texts'] = {};
|
|
359
|
+
for (const lang of languagesOrDefault(languages)) texts[lang] = { headline: '', subtitle: '' };
|
|
360
|
+
return { id: newId('s'), texts, shots: {}, layouts };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A blank project (no template). */
|
|
364
|
+
export function blankProject(name: string, families: FamilyId[], languages: LangCode[]): Project {
|
|
365
|
+
const fams = familiesOrDefault(families);
|
|
366
|
+
const langs = languagesOrDefault(languages);
|
|
367
|
+
const theme = defaultTheme();
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
const slides: Slide[] = [];
|
|
370
|
+
for (let i = 0; i < BLANK_SLIDE_COUNT; i++) slides.push(newSlide(fams, theme, langs));
|
|
371
|
+
return {
|
|
372
|
+
format: 'wajha.project',
|
|
373
|
+
version: 1,
|
|
374
|
+
id: newId(),
|
|
375
|
+
name: (typeof name === 'string' ? cleanName(name) : '') || DEFAULT_PROJECT_NAME,
|
|
376
|
+
createdAt: now,
|
|
377
|
+
updatedAt: now,
|
|
378
|
+
templateId: null,
|
|
379
|
+
theme,
|
|
380
|
+
families: fams,
|
|
381
|
+
languages: langs,
|
|
382
|
+
slides,
|
|
383
|
+
assets: {},
|
|
384
|
+
export: defaultExportSettings(),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas and device size estimates shared by the default layouts and
|
|
3
|
+
* adaptLayout. The model never imports the engine, so device heights are
|
|
4
|
+
* estimated from the screen aspect plus a per-frame bezel allowance. The
|
|
5
|
+
* allowances err on the large side: a fitted device may end up a little
|
|
6
|
+
* smaller than it could be, never taller than planned.
|
|
7
|
+
*/
|
|
8
|
+
import { FAMILIES, FAMILY_ORDER } from './catalog';
|
|
9
|
+
import type { DeviceFrameId, FamilyId } from './types';
|
|
10
|
+
|
|
11
|
+
export interface CanvasMetrics {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
/** min(width, height) in pixels; every length in a layout is a fraction of it. */
|
|
15
|
+
u: number;
|
|
16
|
+
/** Canvas width and height in u. */
|
|
17
|
+
wu: number;
|
|
18
|
+
hu: number;
|
|
19
|
+
/** Landscape enough for side-by-side layouts (Mac, feature graphic). */
|
|
20
|
+
wide: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Width / height above which a canvas counts as wide. */
|
|
24
|
+
export const WIDE_RATIO = 1.15;
|
|
25
|
+
|
|
26
|
+
const positive = (v: unknown, fallback: number): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback);
|
|
27
|
+
|
|
28
|
+
/** Metrics for any canvas shape; broken sizes count as 1 px so nothing divides by zero. */
|
|
29
|
+
export function canvasMetrics(shape: { width: number; height: number } | null | undefined): CanvasMetrics {
|
|
30
|
+
const width = positive(shape?.width, 1);
|
|
31
|
+
const height = positive(shape?.height, 1);
|
|
32
|
+
const u = Math.min(width, height);
|
|
33
|
+
return { width, height, u, wu: width / u, hu: height / u, wide: width > height * WIDE_RATIO };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Metrics of a family's design canvas (unknown families measure like the iPhone). */
|
|
37
|
+
export function familyMetrics(family: FamilyId): CanvasMetrics {
|
|
38
|
+
return canvasMetrics((FAMILIES[family] ?? FAMILIES.iphone).design);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Extra outer height per unit of device width, on top of the screen aspect. */
|
|
42
|
+
export const BEZEL_ALLOWANCE: Readonly<Record<DeviceFrameId, number>> = {
|
|
43
|
+
iphone: 0.06,
|
|
44
|
+
'android-phone': 0.06,
|
|
45
|
+
ipad: 0.08,
|
|
46
|
+
'android-tablet': 0.08,
|
|
47
|
+
// Lid bezel plus the aluminium base.
|
|
48
|
+
macbook: 0.07,
|
|
49
|
+
// Case and black border around the display.
|
|
50
|
+
watch: 0.2,
|
|
51
|
+
tv: 0.03,
|
|
52
|
+
// Glass rim plus the grab bar under the window.
|
|
53
|
+
vision: 0.06,
|
|
54
|
+
// Title bar and URL bar.
|
|
55
|
+
browser: 0.09,
|
|
56
|
+
none: 0,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Estimated outer height / width of a device whose screen has `screenAspect` (height / width). */
|
|
60
|
+
export function deviceHeightFactor(frame: DeviceFrameId, screenAspect: number): number {
|
|
61
|
+
const aspect = Math.min(5, Math.max(0.2, positive(screenAspect, 2)));
|
|
62
|
+
return aspect + (BEZEL_ALLOWANCE[frame] ?? 0.06);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Frames that are some family's default; adaptLayout swaps these for the target family's. */
|
|
66
|
+
const DEFAULT_FRAMES: ReadonlySet<DeviceFrameId> = new Set(FAMILY_ORDER.map((f) => FAMILIES[f].defaultFrame));
|
|
67
|
+
|
|
68
|
+
/** True for frames that are some family's default ("browser" and "none" are not). */
|
|
69
|
+
export function isFamilyDefaultFrame(frame: DeviceFrameId): boolean {
|
|
70
|
+
return DEFAULT_FRAMES.has(frame);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The family whose design canvas `shape` is: an exact size match first,
|
|
75
|
+
* else the closest aspect ratio (earlier families win ties).
|
|
76
|
+
*/
|
|
77
|
+
export function familyForCanvas(shape: { width: number; height: number }): FamilyId {
|
|
78
|
+
const m = canvasMetrics(shape);
|
|
79
|
+
for (const f of FAMILY_ORDER) {
|
|
80
|
+
const d = FAMILIES[f].design;
|
|
81
|
+
if (d.width === m.width && d.height === m.height) return f;
|
|
82
|
+
}
|
|
83
|
+
const target = Math.log(m.width / m.height);
|
|
84
|
+
let best: FamilyId = 'iphone';
|
|
85
|
+
let bestDiff = Infinity;
|
|
86
|
+
for (const f of FAMILY_ORDER) {
|
|
87
|
+
const d = FAMILIES[f].design;
|
|
88
|
+
const diff = Math.abs(Math.log(d.width / d.height) - target);
|
|
89
|
+
if (diff < bestDiff - 1e-9) {
|
|
90
|
+
bestDiff = diff;
|
|
91
|
+
best = f;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return best;
|
|
95
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Random 12-character id. Uses crypto.getRandomValues, which (unlike
|
|
5
|
+
* crypto.randomUUID) also works outside secure contexts.
|
|
6
|
+
*/
|
|
7
|
+
export function newId(prefix = ''): string {
|
|
8
|
+
const bytes = new Uint8Array(12);
|
|
9
|
+
crypto.getRandomValues(bytes);
|
|
10
|
+
let out = prefix;
|
|
11
|
+
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
|
|
12
|
+
return out;
|
|
13
|
+
}
|