Repository navigation
Expand file tree
/
Copy pathprojectDefaults.ts
More file actions
345 lines (317 loc) · 12.9 KB
/
Copy pathprojectDefaults.ts
File metadata and controls
345 lines (317 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
import type { CursorKind } from "./cursor/cursorThemes";
/**
* The frame drawn around the recording, a project setting like the wallpaper. "none" draws
* nothing and renders exactly as before the setting existed.
*
* Two families, and the difference is not cosmetic: the window chrome is drawn FLAT in the
* screen's own plane (shader mode 14), while the three devices are modelled in real 3D around it
* (mode 17) — a body with thickness, a micro-chamfer and a bezel, ray-marched in the same camera
* as the footage. Which is why the camera can move around them.
*
* `monitor` is labelled "Screen" in the pane: the wire value names the object, the label names
* what a user calls it.
*/
export type RecordingFrame = "none" | "window" | "laptop" | "phone" | "monitor";
export const RECORDING_FRAMES = [
"none",
"window",
"laptop",
"phone",
"monitor",
] as const satisfies readonly RecordingFrame[];
/**
* The devices modelled in 3D, in menu order. They are the values that shader mode 17 draws;
* `window` is flat.
*/
export const DEVICE_FRAMES = [
"laptop",
"phone",
"monitor",
] as const satisfies readonly RecordingFrame[];
export function isRecordingFrame(value: unknown): value is RecordingFrame {
return typeof value === "string" && (RECORDING_FRAMES as readonly string[]).includes(value);
}
/**
* Light or dark, for EVERY frame: the window chrome and the three modelled devices alike. Light
* is a silver body with light chrome, dark a graphite body with dark chrome.
*/
export type FrameTheme = "light" | "dark";
export const FRAME_THEMES = ["light", "dark"] as const satisfies readonly FrameTheme[];
export function isFrameTheme(value: unknown): value is FrameTheme {
return value === "light" || value === "dark";
}
/**
* Reads a stored `frame` value, including the two the theme used to be baked into.
*
* `window-light` / `window-dark` were one setting doing two jobs. They are split here rather
* than by a document migration pass, so a project written by an older build opens with the same
* frame AND the same theme without being rewritten — and a project that a newer build wrote
* still opens, frameless, instead of failing.
*
* Returns `null` for a value this build does not know, which the caller reads as "no frame".
*/
export function readRecordingFrame(
value: unknown,
): { frame: RecordingFrame; theme?: FrameTheme } | null {
if (value === "window-light") return { frame: "window", theme: "light" };
if (value === "window-dark") return { frame: "window", theme: "dark" };
return isRecordingFrame(value) ? { frame: value } : null;
}
/**
* Where the picture-in-picture camera sits: a corner or the middle of an edge, always the
* same distance from the border of the frame. Never a free position: a camera dropped
* anywhere else ended up against an edge or over the middle of the screen.
*/
export const WEBCAM_ANCHORS = [
"top-left",
"top",
"top-right",
"left",
"right",
"bottom-left",
"bottom",
"bottom-right",
] as const;
export type WebcamAnchor = (typeof WEBCAM_ANCHORS)[number];
export function isWebcamAnchor(value: unknown): value is WebcamAnchor {
return (WEBCAM_ANCHORS as readonly unknown[]).includes(value);
}
/** The anchor grid, row by row. The middle cell is no place for a camera. */
export const WEBCAM_ANCHOR_GRID = [
["top-left", "top", "top-right"],
["left", null, "right"],
["bottom-left", "bottom", "bottom-right"],
] as const satisfies readonly (readonly (WebcamAnchor | null)[])[];
/**
* The anchor nearest a point of the frame, in fractions of it: the cell of a 3x3 grid, and
* from the middle cell the nearer edge. What the camera snaps to when it is dragged, and what
* a free position stored by an older build reads as.
*/
export function webcamAnchorAt(cx: number, cy: number): WebcamAnchor {
const cell = (v: number) => (v < 1 / 3 ? 0 : v > 2 / 3 ? 2 : 1);
let col = cell(cx);
let row = cell(cy);
if (col === 1 && row === 1) {
if (Math.abs(cy - 0.5) >= Math.abs(cx - 0.5)) row = cy < 0.5 ? 0 : 2;
else col = cx < 0.5 ? 0 : 2;
}
return WEBCAM_ANCHOR_GRID[row][col] ?? "bottom-right";
}
/** An anchor as the share of the free room the camera leaves it, per axis: 0, 0.5 or 1. */
export function webcamAnchorFractions(anchor: WebcamAnchor): [number, number] {
const fx = anchor.endsWith("left") ? 0 : anchor.endsWith("right") ? 1 : 0.5;
const fy = anchor.startsWith("top") ? 0 : anchor.startsWith("bottom") ? 1 : 0.5;
return [fx, fy];
}
/** Reads a stored anchor, falling back to the free `{ cx, cy }` position older builds stored. */
export function readWebcamAnchor(anchor: unknown, legacyPosition: unknown): WebcamAnchor {
if (isWebcamAnchor(anchor)) return anchor;
const p = legacyPosition as { cx?: unknown; cy?: unknown } | null | undefined;
if (p && typeof p.cx === "number" && typeof p.cy === "number") {
return webcamAnchorAt(p.cx, p.cy);
}
return "bottom-right";
}
/**
* The camera's proportions: a square crop, or the camera's own ("rectangle", shown as
* "Original": a portrait camera stays portrait). Its roundness is a setting of its own,
* `webcamRoundness`.
*/
export type WebcamMask = "rectangle" | "square";
/**
* The camera's corner rounding, 0 square to 1 fully round, as a fraction of half the camera's
* short side. A fraction, so the same value draws the same shape at any size and resolution;
* at 1 a square camera is a circle. Each proportion starts at its own: a square takes more
* rounding than a wide camera before it reads as a blob.
*/
export const DEFAULT_WEBCAM_ROUNDNESS: Record<WebcamMask, number> = { square: 0.7, rectangle: 0.4 };
/** What a stored `rectangle` or `square` drew before the roundness was a setting. */
const LEGACY_WEBCAM_ROUNDNESS = 0.3;
/**
* The picture-in-picture camera's size, in percent of the frame's short side: the camera's
* long side, so a 16:9 camera at 60 is 34% of a 1080p frame's height. Below 15 a face no
* longer reads, and less still once a zoom shrinks it; older builds allowed 10, read back as 15.
*/
export const WEBCAM_SIZE_MIN = 15;
export const WEBCAM_SIZE_MAX = 60;
/**
* Reads a stored camera shape and roundness. `circle` and `rounded` were a proportion and a
* rounding folded into one value; they split here into the two settings, the way
* `readRecordingFrame` splits the old window themes. A stored roundness wins; with no shape
* stored at all, both are the factory default.
*/
export function readWebcamMask(
shape: unknown,
roundness: unknown,
): { shape: WebcamMask; roundness: number } {
const proportion: WebcamMask =
shape === "square" || shape === "circle"
? "square"
: shape === "rectangle" || shape === "rounded"
? "rectangle"
: DEFAULT_PROJECT_APPEARANCE.webcamMaskShape;
const fromShape =
shape === "circle"
? 1
: shape === "rounded"
? 0.6
: shape === "rectangle" || shape === "square"
? LEGACY_WEBCAM_ROUNDNESS
: DEFAULT_WEBCAM_ROUNDNESS[proportion];
return {
shape: proportion,
roundness:
typeof roundness === "number" && Number.isFinite(roundness)
? Math.min(1, Math.max(0, roundness))
: fromShape,
};
}
export interface ProjectAppearanceDefaults {
wallpaper: string;
wallpaperMotion: "none" | "drift" | "aurora" | "waves";
frame: RecordingFrame;
/** Light or dark, for whichever frame is on. Inert with `frame: "none"`. */
frameTheme: FrameTheme;
aspectRatio: `${number}:${number}` | "auto" | "native";
shadowIntensity: number;
/** 0 sharp to 1, how much the wallpaper behind the recording is blurred. */
backgroundBlur: number;
motionBlurAmount: number;
/** Defocus a 3D-tilted screen by its depth; inert on flat zooms. */
depthOfField: boolean;
borderRadius: number;
padding: number;
webcamLayoutPreset: "picture-in-picture" | "vertical-stack" | "dual-frame" | "no-webcam";
/** The camera's proportions: cropped square or its own ("rectangle"). See `readWebcamMask`. */
webcamMaskShape: WebcamMask;
/** 0 square corners to 1 fully round. See `DEFAULT_WEBCAM_ROUNDNESS`. */
webcamRoundness: number;
webcamMirrored: boolean;
webcamReactiveZoom: boolean;
webcamSizePreset: number;
webcamAnchor: WebcamAnchor;
webcamBackgroundMode: "none" | "transparent" | "blur" | "custom";
webcamWallpaper: string;
webcamBlurIntensity: number;
cursor: {
size: number;
smoothing: number;
motionBlur: number;
clickBounce: number;
model3d: boolean;
asArrow: CursorKind[];
clickImpact: boolean;
autoHide: boolean;
};
cursorShow: boolean;
cursorAutoHide: boolean;
cursorTheme: string;
autoFocusAll: boolean;
}
/** The factory appearance every new project starts from. */
export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = {
wallpaper: "/wallpapers/wallpaper11.jpg",
wallpaperMotion: "none",
frame: "none",
frameTheme: "light",
// Auto: the frame follows the recording, its crop, the camera layout and the padding.
// Documents from before it stored no ratio and read 16:9; the v8 upgrader pins that.
aspectRatio: "auto",
// 0.2 peaked at 9% opacity: a shadow nobody could see. 0.6 is 27%, the card lifts off the
// wallpaper without a halo.
shadowIntensity: 0.6,
backgroundBlur: 0,
motionBlurAmount: 0.2,
// On: it only acts on tilted zooms, where the blur already scales with the real angle.
depthOfField: true,
borderRadius: 40,
padding: 50,
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "square",
webcamRoundness: DEFAULT_WEBCAM_ROUNDNESS.square,
webcamMirrored: false,
webcamReactiveZoom: true,
webcamSizePreset: 40,
webcamAnchor: "bottom-right",
webcamBackgroundMode: "none",
webcamWallpaper: "/wallpapers/wallpaper11.jpg",
webcamBlurIntensity: 0.5,
cursor: {
// 3.6 draws a 98 px arrow in a 1080p export, about five times the system one: the cursor
// effects are part of the product, and a smaller arrow plays them down. 6 reaches 164 px.
size: 3.6,
smoothing: 0.67,
motionBlur: 0.35,
// A light tap: 2.5 squashed the arrow to 40% and threw it to 140% in 260 ms on every click.
clickBounce: 1,
model3d: false,
// The arrow and the hand, the two cursors a viewer follows; every other one is drawn as the
// arrow until picked (`CURSOR_KINDS`). A test holds this to every kind but the hand.
asArrow: ["text", "grab", "resize", "busy", "crosshair", "not-allowed", "help", "up-arrow"],
clickImpact: false,
autoHide: false,
},
cursorShow: true,
cursorAutoHide: false,
cursorTheme: "default",
autoFocusAll: false,
};
/**
* The range of every number a project stores for its look and its speed regions, in stored
* units. One table, and everything that takes such a value in reads it: `getEditorSettings`
* clamps a stored value into it, a style preset is clamped by it, the AI agent is held to it, and
* the sliders offer exactly these ranges. A bound that lived only in a slider was one the agent,
* a preset or a hand-edited project went straight past.
*
* Import-free on purpose, like the rest of this module: the main process reads it too.
*/
export const SETTING_BOUNDS = {
shadowIntensity: [0, 1],
backgroundBlur: [0, 1],
motionBlurAmount: [0, 1],
// `ROUNDNESS_SLIDER_MAX_PX` (src/native/paramUnits.ts); a test holds the two together.
borderRadius: [0, 64],
padding: [0, 100],
webcamSizePreset: [WEBCAM_SIZE_MIN, WEBCAM_SIZE_MAX],
webcamRoundness: [0, 1],
webcamBlurIntensity: [0, 1],
// 1.5 is the floor: nothing smaller reads in a demo. At 6 the arrow is 164 px tall in a
// 1080p export, eight times the system one.
cursorSize: [1.5, 6],
cursorSmoothing: [0, 1],
cursorMotionBlur: [0, 1],
// Past about 4.2 the arrow shrank to nothing on every click.
cursorClickBounce: [0, 2],
// 16 is Chromium's `playbackRate` ceiling: past it the preview could not show what the
// export rendered.
playbackSpeed: [0.25, 16],
// Text annotations, in pixels at 1080 (see annotationScale.ts).
annotationFontSize: [8, 200],
} as const satisfies Record<string, readonly [number, number]>;
export type SettingBound = keyof typeof SETTING_BOUNDS;
/** A value clamped into its bound. */
export function clampToBound(value: number, bound: SettingBound): number {
const [min, max] = SETTING_BOUNDS[bound];
return Math.min(max, Math.max(min, value));
}
/**
* The background blur was an on/off switch (`showBlur`) before it was an amount. On reads as the
* one amount it had: the compositor draws exactly the old blur at this value.
*/
export const LEGACY_BACKGROUND_BLUR_ON = 0.5;
/** `backgroundBlur`, or the old `showBlur` switch it replaced when only that one is stored. */
export function readBackgroundBlur(
stored: { backgroundBlur?: unknown; showBlur?: unknown } | null | undefined,
fallback: number,
): number {
if (stored?.backgroundBlur === undefined && typeof stored?.showBlur === "boolean") {
return stored.showBlur ? LEGACY_BACKGROUND_BLUR_ON : 0;
}
return readBounded(stored?.backgroundBlur, "backgroundBlur", fallback);
}
/** A stored value read into its bound: anything but a finite number reads as `fallback`. */
export function readBounded(value: unknown, bound: SettingBound, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? clampToBound(value, bound)
: fallback;
}