@hyperframes/studio 0.5.5 → 0.6.0-alpha.1
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/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
- package/dist/assets/index-D04_ZoMm.js +107 -0
- package/dist/assets/index-UWFaHilT.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2621 -170
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +67 -0
- package/src/components/editor/PropertyPanel.tsx +2891 -207
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +872 -0
- package/src/components/editor/domEditing.ts +993 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +120 -0
- package/src/components/editor/manualEditingAvailability.ts +60 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1397 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.tsx +27 -4
- package/src/components/nle/NLEPreview.tsx +50 -5
- package/src/components/renders/RenderQueue.tsx +13 -62
- package/src/components/renders/useRenderQueue.ts +6 -30
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +140 -125
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.tsx +18 -2
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +103 -21
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-960mgQMI.js +0 -93
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
export type GradientKind = "linear" | "radial" | "conic";
|
|
2
|
+
|
|
3
|
+
export type RadialSizeKeyword =
|
|
4
|
+
| "closest-side"
|
|
5
|
+
| "closest-corner"
|
|
6
|
+
| "farthest-side"
|
|
7
|
+
| "farthest-corner";
|
|
8
|
+
|
|
9
|
+
export interface GradientStop {
|
|
10
|
+
color: string;
|
|
11
|
+
position: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GradientModel {
|
|
15
|
+
kind: GradientKind;
|
|
16
|
+
repeating: boolean;
|
|
17
|
+
angle: number;
|
|
18
|
+
centerX: number;
|
|
19
|
+
centerY: number;
|
|
20
|
+
shape: "circle" | "ellipse";
|
|
21
|
+
radialSize: RadialSizeKeyword;
|
|
22
|
+
stops: GradientStop[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const RADIAL_SIZE_KEYWORDS: RadialSizeKeyword[] = [
|
|
26
|
+
"closest-side",
|
|
27
|
+
"closest-corner",
|
|
28
|
+
"farthest-side",
|
|
29
|
+
"farthest-corner",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function isWhitespace(char: string | undefined): boolean {
|
|
33
|
+
return char === " " || char === "\n" || char === "\r" || char === "\t" || char === "\f";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isDigit(char: string | undefined): boolean {
|
|
37
|
+
return char != null && char >= "0" && char <= "9";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isSimpleNumber(value: string): boolean {
|
|
41
|
+
if (!value) return false;
|
|
42
|
+
let index = value[0] === "-" ? 1 : 0;
|
|
43
|
+
let digits = 0;
|
|
44
|
+
|
|
45
|
+
while (isDigit(value[index])) {
|
|
46
|
+
index += 1;
|
|
47
|
+
digits += 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (value[index] === ".") {
|
|
51
|
+
index += 1;
|
|
52
|
+
while (isDigit(value[index])) {
|
|
53
|
+
index += 1;
|
|
54
|
+
digits += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return digits > 0 && index === value.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseCssNumber(value: string | undefined): number | null {
|
|
62
|
+
if (!value) return null;
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
if (!isSimpleNumber(trimmed)) return null;
|
|
65
|
+
const parsed = Number(trimmed);
|
|
66
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function splitCssWhitespace(value: string): string[] {
|
|
70
|
+
const tokens: string[] = [];
|
|
71
|
+
let current = "";
|
|
72
|
+
|
|
73
|
+
for (const char of value) {
|
|
74
|
+
if (isWhitespace(char)) {
|
|
75
|
+
if (current) {
|
|
76
|
+
tokens.push(current);
|
|
77
|
+
current = "";
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
current += char;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (current) tokens.push(current);
|
|
85
|
+
return tokens;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasCssWord(value: string, word: string): boolean {
|
|
89
|
+
return splitCssWhitespace(value.toLowerCase()).includes(word);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parsePercentToken(value: string | undefined, fallback: number): number {
|
|
93
|
+
if (!value?.endsWith("%")) return fallback;
|
|
94
|
+
const parsed = parseCssNumber(value.slice(0, -1));
|
|
95
|
+
return parsed == null ? fallback : clamp(parsed, 0, 100);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseAngleToken(value: string | undefined): number | null {
|
|
99
|
+
const trimmed = value?.trim().toLowerCase();
|
|
100
|
+
if (!trimmed?.endsWith("deg")) return null;
|
|
101
|
+
return parseCssNumber(trimmed.slice(0, -3));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function trailingPercentStart(value: string): number | null {
|
|
105
|
+
if (!value.endsWith("%")) return null;
|
|
106
|
+
const withoutUnit = value.slice(0, -1).trimEnd();
|
|
107
|
+
let start = withoutUnit.length;
|
|
108
|
+
|
|
109
|
+
while (start > 0 && (isDigit(withoutUnit[start - 1]) || withoutUnit[start - 1] === ".")) {
|
|
110
|
+
start -= 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (start > 0 && withoutUnit[start - 1] === "-") {
|
|
114
|
+
start -= 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const token = withoutUnit.slice(start);
|
|
118
|
+
if (!isSimpleNumber(token)) return null;
|
|
119
|
+
if (start === 0 || !isWhitespace(withoutUnit[start - 1])) return null;
|
|
120
|
+
return start;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function clamp(value: number, min: number, max: number): number {
|
|
124
|
+
return Math.min(max, Math.max(min, value));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function round(value: number): number {
|
|
128
|
+
return Math.round(value * 100) / 100;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parsePercent(value: string | undefined, fallback: number): number {
|
|
132
|
+
const parsed = parseCssNumber(value);
|
|
133
|
+
return parsed == null ? fallback : clamp(parsed, 0, 100);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseColorStop(raw: string): { color: string; position: number | null } {
|
|
137
|
+
const trimmed = raw.trim();
|
|
138
|
+
const percentStart = trailingPercentStart(trimmed);
|
|
139
|
+
if (percentStart == null) return { color: trimmed, position: null };
|
|
140
|
+
|
|
141
|
+
const withoutUnit = trimmed.slice(0, -1).trimEnd();
|
|
142
|
+
return {
|
|
143
|
+
color: withoutUnit.slice(0, percentStart).trim(),
|
|
144
|
+
position: parsePercent(withoutUnit.slice(percentStart), 0),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeStops(stops: Array<{ color: string; position: number | null }>): GradientStop[] {
|
|
149
|
+
if (stops.length === 0) {
|
|
150
|
+
return [
|
|
151
|
+
{ color: "rgba(60, 230, 172, 0.18)", position: 0 },
|
|
152
|
+
{ color: "rgba(255, 255, 255, 0.04)", position: 100 },
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (stops.length === 1) {
|
|
157
|
+
return [
|
|
158
|
+
{ color: stops[0].color, position: 0 },
|
|
159
|
+
{ color: stops[0].color, position: 100 },
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const result = stops.map((stop, index) => ({
|
|
164
|
+
color: stop.color,
|
|
165
|
+
position: stop.position ?? (index / (stops.length - 1)) * 100,
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
return result.map((stop) => ({
|
|
169
|
+
color: stop.color,
|
|
170
|
+
position: round(clamp(stop.position, 0, 100)),
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function splitGradientArgs(value: string): string[] {
|
|
175
|
+
const parts: string[] = [];
|
|
176
|
+
let current = "";
|
|
177
|
+
let depth = 0;
|
|
178
|
+
|
|
179
|
+
for (const char of value) {
|
|
180
|
+
if (char === "(") depth += 1;
|
|
181
|
+
if (char === ")") depth = Math.max(0, depth - 1);
|
|
182
|
+
|
|
183
|
+
if (char === "," && depth === 0) {
|
|
184
|
+
if (current.trim()) parts.push(current.trim());
|
|
185
|
+
current = "";
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
current += char;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (current.trim()) parts.push(current.trim());
|
|
193
|
+
return parts;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function directionToAngle(value: string): number | null {
|
|
197
|
+
const normalized = value.trim().toLowerCase();
|
|
198
|
+
const map: Record<string, number> = {
|
|
199
|
+
"to top": 0,
|
|
200
|
+
"to top right": 45,
|
|
201
|
+
"to right top": 45,
|
|
202
|
+
"to right": 90,
|
|
203
|
+
"to bottom right": 135,
|
|
204
|
+
"to right bottom": 135,
|
|
205
|
+
"to bottom": 180,
|
|
206
|
+
"to bottom left": 225,
|
|
207
|
+
"to left bottom": 225,
|
|
208
|
+
"to left": 270,
|
|
209
|
+
"to top left": 315,
|
|
210
|
+
"to left top": 315,
|
|
211
|
+
};
|
|
212
|
+
return normalized in map ? map[normalized] : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseLinearArgs(parts: string[]): GradientModel {
|
|
216
|
+
const first = parts[0] ?? "";
|
|
217
|
+
const angleFromDirection = directionToAngle(first);
|
|
218
|
+
const parsedAngle = parseAngleToken(first);
|
|
219
|
+
const firstIsAngle = parsedAngle != null;
|
|
220
|
+
const angle = parsedAngle ?? angleFromDirection ?? 180;
|
|
221
|
+
const stopParts = firstIsAngle || angleFromDirection != null ? parts.slice(1) : parts;
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
kind: "linear",
|
|
225
|
+
repeating: false,
|
|
226
|
+
angle,
|
|
227
|
+
centerX: 50,
|
|
228
|
+
centerY: 50,
|
|
229
|
+
shape: "ellipse",
|
|
230
|
+
radialSize: "farthest-corner",
|
|
231
|
+
stops: normalizeStops(stopParts.map(parseColorStop)),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseRadialArgs(parts: string[]): GradientModel {
|
|
236
|
+
const first = parts[0] ?? "";
|
|
237
|
+
const firstLower = first.toLowerCase();
|
|
238
|
+
const hasConfig =
|
|
239
|
+
hasCssWord(firstLower, "at") ||
|
|
240
|
+
hasCssWord(firstLower, "circle") ||
|
|
241
|
+
hasCssWord(firstLower, "ellipse") ||
|
|
242
|
+
firstLower.includes("closest-") ||
|
|
243
|
+
firstLower.includes("farthest-");
|
|
244
|
+
const config = hasConfig ? first : "";
|
|
245
|
+
const stopParts = hasConfig ? parts.slice(1) : parts;
|
|
246
|
+
const configLower = config.toLowerCase();
|
|
247
|
+
const configTokens = splitCssWhitespace(configLower);
|
|
248
|
+
const atIndex = configTokens.indexOf("at");
|
|
249
|
+
|
|
250
|
+
const shape = hasCssWord(configLower, "circle") ? "circle" : "ellipse";
|
|
251
|
+
const radialSize =
|
|
252
|
+
RADIAL_SIZE_KEYWORDS.find((keyword) => configTokens.includes(keyword)) ?? "farthest-corner";
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
kind: "radial",
|
|
256
|
+
repeating: false,
|
|
257
|
+
angle: 180,
|
|
258
|
+
centerX: parsePercentToken(configTokens[atIndex + 1], 50),
|
|
259
|
+
centerY: parsePercentToken(configTokens[atIndex + 2], 50),
|
|
260
|
+
shape,
|
|
261
|
+
radialSize,
|
|
262
|
+
stops: normalizeStops(stopParts.map(parseColorStop)),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function parseConicArgs(parts: string[]): GradientModel {
|
|
267
|
+
const first = parts[0] ?? "";
|
|
268
|
+
const firstLower = first.toLowerCase();
|
|
269
|
+
const hasConfig = hasCssWord(firstLower, "from") || hasCssWord(firstLower, "at");
|
|
270
|
+
const config = hasConfig ? first : "";
|
|
271
|
+
const stopParts = hasConfig ? parts.slice(1) : parts;
|
|
272
|
+
const configTokens = splitCssWhitespace(config.toLowerCase());
|
|
273
|
+
const fromIndex = configTokens.indexOf("from");
|
|
274
|
+
const atIndex = configTokens.indexOf("at");
|
|
275
|
+
const angle = parseAngleToken(configTokens[fromIndex + 1]);
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
kind: "conic",
|
|
279
|
+
repeating: false,
|
|
280
|
+
angle: angle ?? 0,
|
|
281
|
+
centerX: parsePercentToken(configTokens[atIndex + 1], 50),
|
|
282
|
+
centerY: parsePercentToken(configTokens[atIndex + 2], 50),
|
|
283
|
+
shape: "ellipse",
|
|
284
|
+
radialSize: "farthest-corner",
|
|
285
|
+
stops: normalizeStops(stopParts.map(parseColorStop)),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function buildDefaultGradientModel(fallbackColor?: string): GradientModel {
|
|
290
|
+
return {
|
|
291
|
+
kind: "linear",
|
|
292
|
+
repeating: false,
|
|
293
|
+
angle: 135,
|
|
294
|
+
centerX: 50,
|
|
295
|
+
centerY: 50,
|
|
296
|
+
shape: "ellipse",
|
|
297
|
+
radialSize: "farthest-corner",
|
|
298
|
+
stops: normalizeStops([
|
|
299
|
+
{
|
|
300
|
+
color:
|
|
301
|
+
fallbackColor && fallbackColor !== "transparent"
|
|
302
|
+
? fallbackColor
|
|
303
|
+
: "rgba(60, 230, 172, 0.18)",
|
|
304
|
+
position: 0,
|
|
305
|
+
},
|
|
306
|
+
{ color: "rgba(255, 255, 255, 0.04)", position: 100 },
|
|
307
|
+
]),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function parseGradient(value: string | undefined): GradientModel | null {
|
|
312
|
+
if (!value || value === "none") return null;
|
|
313
|
+
const trimmed = value.trim();
|
|
314
|
+
const openParenIndex = trimmed.indexOf("(");
|
|
315
|
+
if (openParenIndex <= 0 || !trimmed.endsWith(")")) return null;
|
|
316
|
+
|
|
317
|
+
const functionName = trimmed.slice(0, openParenIndex).toLowerCase();
|
|
318
|
+
const kindByFunctionName: Record<string, { kind: GradientKind; repeating: boolean }> = {
|
|
319
|
+
"linear-gradient": { kind: "linear", repeating: false },
|
|
320
|
+
"radial-gradient": { kind: "radial", repeating: false },
|
|
321
|
+
"conic-gradient": { kind: "conic", repeating: false },
|
|
322
|
+
"repeating-linear-gradient": { kind: "linear", repeating: true },
|
|
323
|
+
"repeating-radial-gradient": { kind: "radial", repeating: true },
|
|
324
|
+
"repeating-conic-gradient": { kind: "conic", repeating: true },
|
|
325
|
+
};
|
|
326
|
+
const parsedFunction = kindByFunctionName[functionName];
|
|
327
|
+
if (!parsedFunction) return null;
|
|
328
|
+
|
|
329
|
+
const { kind, repeating } = parsedFunction;
|
|
330
|
+
const parts = splitGradientArgs(trimmed.slice(openParenIndex + 1, -1));
|
|
331
|
+
|
|
332
|
+
const parsed =
|
|
333
|
+
kind === "linear"
|
|
334
|
+
? parseLinearArgs(parts)
|
|
335
|
+
: kind === "radial"
|
|
336
|
+
? parseRadialArgs(parts)
|
|
337
|
+
: parseConicArgs(parts);
|
|
338
|
+
|
|
339
|
+
return { ...parsed, repeating };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function formatStop(stop: GradientStop): string {
|
|
343
|
+
return `${stop.color} ${round(stop.position)}%`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function serializeGradient(model: GradientModel): string {
|
|
347
|
+
const fn = `${model.repeating ? "repeating-" : ""}${model.kind}-gradient`;
|
|
348
|
+
const stops = model.stops.map(formatStop).join(", ");
|
|
349
|
+
|
|
350
|
+
if (model.kind === "linear") {
|
|
351
|
+
return `${fn}(${round(model.angle)}deg, ${stops})`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (model.kind === "radial") {
|
|
355
|
+
return `${fn}(${model.shape} ${model.radialSize} at ${round(model.centerX)}% ${round(
|
|
356
|
+
model.centerY,
|
|
357
|
+
)}%, ${stops})`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return `${fn}(from ${round(model.angle)}deg at ${round(model.centerX)}% ${round(
|
|
361
|
+
model.centerY,
|
|
362
|
+
)}%, ${stops})`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function blendChannel(start: number, end: number, ratio: number): number {
|
|
366
|
+
return Math.round(start + (end - start) * ratio);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function formatHex(channel: number): string {
|
|
370
|
+
return channel.toString(16).padStart(2, "0");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function interpolateGradientStopColor(model: GradientModel, position: number): string {
|
|
374
|
+
const clampedPosition = clamp(position, 0, 100);
|
|
375
|
+
const sortedStops = [...model.stops].sort((a, b) => a.position - b.position);
|
|
376
|
+
const exact = sortedStops.find((stop) => Math.abs(stop.position - clampedPosition) < 0.001);
|
|
377
|
+
if (exact) return exact.color;
|
|
378
|
+
|
|
379
|
+
const right = sortedStops.find((stop) => stop.position > clampedPosition) ?? sortedStops.at(-1);
|
|
380
|
+
const left =
|
|
381
|
+
[...sortedStops].reverse().find((stop) => stop.position < clampedPosition) ?? sortedStops[0];
|
|
382
|
+
if (!left || !right) return sortedStops[0]?.color ?? "rgba(255, 255, 255, 1)";
|
|
383
|
+
if (left === right) return left.color;
|
|
384
|
+
|
|
385
|
+
const leftColor = left.color;
|
|
386
|
+
const rightColor = right.color;
|
|
387
|
+
const leftParsed = leftColor ? parseColorString(leftColor) : null;
|
|
388
|
+
const rightParsed = rightColor ? parseColorString(rightColor) : null;
|
|
389
|
+
if (!leftParsed || !rightParsed) return left.color;
|
|
390
|
+
|
|
391
|
+
const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position);
|
|
392
|
+
const red = blendChannel(leftParsed.red, rightParsed.red, ratio);
|
|
393
|
+
const green = blendChannel(leftParsed.green, rightParsed.green, ratio);
|
|
394
|
+
const blue = blendChannel(leftParsed.blue, rightParsed.blue, ratio);
|
|
395
|
+
const alpha = round(leftParsed.alpha + (rightParsed.alpha - leftParsed.alpha) * ratio);
|
|
396
|
+
|
|
397
|
+
if (alpha >= 1) {
|
|
398
|
+
return `#${formatHex(red)}${formatHex(green)}${formatHex(blue)}`.toUpperCase();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function insertGradientStop(model: GradientModel, position: number): GradientModel {
|
|
405
|
+
const clampedPosition = round(clamp(position, 0, 100));
|
|
406
|
+
const color = interpolateGradientStopColor(model, clampedPosition);
|
|
407
|
+
const nextStops = [...model.stops, { color, position: clampedPosition }].sort(
|
|
408
|
+
(a, b) => a.position - b.position,
|
|
409
|
+
);
|
|
410
|
+
return {
|
|
411
|
+
...model,
|
|
412
|
+
stops: nextStops,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function parseColorString(
|
|
417
|
+
value: string,
|
|
418
|
+
): { red: number; green: number; blue: number; alpha: number } | null {
|
|
419
|
+
const trimmed = value.trim().toLowerCase();
|
|
420
|
+
if (trimmed === "transparent") {
|
|
421
|
+
return { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
|
|
425
|
+
if (hex) {
|
|
426
|
+
return {
|
|
427
|
+
red: Number.parseInt(hex[1].slice(0, 2), 16),
|
|
428
|
+
green: Number.parseInt(hex[1].slice(2, 4), 16),
|
|
429
|
+
blue: Number.parseInt(hex[1].slice(4, 6), 16),
|
|
430
|
+
alpha: 1,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const rgba = trimmed.match(
|
|
435
|
+
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
|
|
436
|
+
);
|
|
437
|
+
if (!rgba) return null;
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
red: Number.parseFloat(rgba[1]),
|
|
441
|
+
green: Number.parseFloat(rgba[2]),
|
|
442
|
+
blue: Number.parseFloat(rgba[3]),
|
|
443
|
+
alpha: rgba[4] != null ? Number.parseFloat(rgba[4]) : 1,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { resolveStudioBooleanEnvFlag } from "./manualEditingAvailability";
|
|
3
|
+
|
|
4
|
+
async function loadAvailabilityWithEnv(env: Record<string, string | undefined>) {
|
|
5
|
+
vi.resetModules();
|
|
6
|
+
vi.unstubAllEnvs();
|
|
7
|
+
for (const [key, value] of Object.entries(env)) {
|
|
8
|
+
if (value !== undefined) vi.stubEnv(key, value);
|
|
9
|
+
}
|
|
10
|
+
return import("./manualEditingAvailability");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("manual editing availability", () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.unstubAllEnvs();
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps alpha inspector and manual editing surfaces disabled by default", async () => {
|
|
20
|
+
const availability = await loadAvailabilityWithEnv({});
|
|
21
|
+
|
|
22
|
+
expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(false);
|
|
23
|
+
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(false);
|
|
24
|
+
expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
|
|
25
|
+
expect(availability.STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("enables inspector layers only through explicit opt-in flags", async () => {
|
|
29
|
+
const availability = await loadAvailabilityWithEnv({
|
|
30
|
+
VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "1",
|
|
31
|
+
VITE_STUDIO_ENABLE_TIMELINE_LAYER_INSPECTOR: "true",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
|
|
35
|
+
expect(availability.STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps timeline layer inspection off when the parent inspector flag is off", async () => {
|
|
39
|
+
const availability = await loadAvailabilityWithEnv({
|
|
40
|
+
VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
|
|
41
|
+
VITE_STUDIO_ENABLE_TIMELINE_LAYER_INSPECTOR: "true",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(false);
|
|
45
|
+
expect(availability.STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("enables feature flags with explicit truthy env values", () => {
|
|
49
|
+
expect(
|
|
50
|
+
resolveStudioBooleanEnvFlag(
|
|
51
|
+
{ VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING: "true" },
|
|
52
|
+
["VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING"],
|
|
53
|
+
false,
|
|
54
|
+
),
|
|
55
|
+
).toBe(true);
|
|
56
|
+
expect(
|
|
57
|
+
resolveStudioBooleanEnvFlag(
|
|
58
|
+
{ VITE_STUDIO_ENABLE_MOTION_PANEL: "1" },
|
|
59
|
+
["VITE_STUDIO_ENABLE_MOTION_PANEL"],
|
|
60
|
+
false,
|
|
61
|
+
),
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("disables feature flags with explicit falsy env values", () => {
|
|
66
|
+
expect(
|
|
67
|
+
resolveStudioBooleanEnvFlag(
|
|
68
|
+
{ VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING: "off" },
|
|
69
|
+
["VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING"],
|
|
70
|
+
true,
|
|
71
|
+
),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
expect(
|
|
74
|
+
resolveStudioBooleanEnvFlag(
|
|
75
|
+
{ VITE_STUDIO_ENABLE_MOTION_PANEL: "0" },
|
|
76
|
+
["VITE_STUDIO_ENABLE_MOTION_PANEL"],
|
|
77
|
+
true,
|
|
78
|
+
),
|
|
79
|
+
).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("supports legacy flag aliases after the preferred name", () => {
|
|
83
|
+
expect(
|
|
84
|
+
resolveStudioBooleanEnvFlag(
|
|
85
|
+
{ VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED: "yes" },
|
|
86
|
+
[
|
|
87
|
+
"VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING",
|
|
88
|
+
"VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED",
|
|
89
|
+
],
|
|
90
|
+
false,
|
|
91
|
+
),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
expect(
|
|
94
|
+
resolveStudioBooleanEnvFlag(
|
|
95
|
+
{ VITE_STUDIO_MOTION_PANEL_ENABLED: "enabled" },
|
|
96
|
+
["VITE_STUDIO_ENABLE_MOTION_PANEL", "VITE_STUDIO_MOTION_PANEL_ENABLED"],
|
|
97
|
+
false,
|
|
98
|
+
),
|
|
99
|
+
).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("lets preferred flag values override legacy aliases", () => {
|
|
103
|
+
expect(
|
|
104
|
+
resolveStudioBooleanEnvFlag(
|
|
105
|
+
{
|
|
106
|
+
VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "off",
|
|
107
|
+
VITE_STUDIO_INSPECTOR_PANELS_ENABLED: "on",
|
|
108
|
+
},
|
|
109
|
+
["VITE_STUDIO_ENABLE_INSPECTOR_PANELS", "VITE_STUDIO_INSPECTOR_PANELS_ENABLED"],
|
|
110
|
+
true,
|
|
111
|
+
),
|
|
112
|
+
).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("falls back for missing, empty, or unknown env values", () => {
|
|
116
|
+
expect(resolveStudioBooleanEnvFlag({}, ["MISSING"], false)).toBe(false);
|
|
117
|
+
expect(resolveStudioBooleanEnvFlag({ EMPTY: "" }, ["EMPTY"], true)).toBe(true);
|
|
118
|
+
expect(resolveStudioBooleanEnvFlag({ UNKNOWN: "maybe" }, ["UNKNOWN"], false)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type StudioFeatureFlagEnv = Record<string, boolean | string | undefined>;
|
|
2
|
+
|
|
3
|
+
export const STUDIO_PREVIEW_MANUAL_DRAGGING_ENV = "VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING";
|
|
4
|
+
export const STUDIO_INSPECTOR_PANELS_ENV = "VITE_STUDIO_ENABLE_INSPECTOR_PANELS";
|
|
5
|
+
export const STUDIO_MOTION_PANEL_ENV = "VITE_STUDIO_ENABLE_MOTION_PANEL";
|
|
6
|
+
export const STUDIO_TIMELINE_LAYER_INSPECTOR_ENV = "VITE_STUDIO_ENABLE_TIMELINE_LAYER_INSPECTOR";
|
|
7
|
+
|
|
8
|
+
const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
|
|
9
|
+
const FALSY_ENV_VALUES = new Set(["0", "false", "no", "off", "disabled"]);
|
|
10
|
+
|
|
11
|
+
export function resolveStudioBooleanEnvFlag(
|
|
12
|
+
env: StudioFeatureFlagEnv,
|
|
13
|
+
names: string[],
|
|
14
|
+
fallback: boolean,
|
|
15
|
+
): boolean {
|
|
16
|
+
for (const name of names) {
|
|
17
|
+
const value = env[name];
|
|
18
|
+
if (typeof value === "boolean") return value;
|
|
19
|
+
if (typeof value !== "string") continue;
|
|
20
|
+
|
|
21
|
+
const normalized = value.trim().toLowerCase();
|
|
22
|
+
if (!normalized) continue;
|
|
23
|
+
if (TRUTHY_ENV_VALUES.has(normalized)) return true;
|
|
24
|
+
if (FALSY_ENV_VALUES.has(normalized)) return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const env = import.meta.env as StudioFeatureFlagEnv;
|
|
31
|
+
|
|
32
|
+
export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag(
|
|
33
|
+
env,
|
|
34
|
+
[STUDIO_PREVIEW_MANUAL_DRAGGING_ENV, "VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED"],
|
|
35
|
+
false,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
|
|
39
|
+
env,
|
|
40
|
+
[STUDIO_INSPECTOR_PANELS_ENV, "VITE_STUDIO_INSPECTOR_PANELS_ENABLED"],
|
|
41
|
+
false,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
export const STUDIO_MOTION_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
45
|
+
env,
|
|
46
|
+
[STUDIO_MOTION_PANEL_ENV, "VITE_STUDIO_MOTION_PANEL_ENABLED"],
|
|
47
|
+
false,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export const STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED =
|
|
51
|
+
STUDIO_INSPECTOR_PANELS_ENABLED &&
|
|
52
|
+
resolveStudioBooleanEnvFlag(
|
|
53
|
+
env,
|
|
54
|
+
[STUDIO_TIMELINE_LAYER_INSPECTOR_ENV, "VITE_STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED"],
|
|
55
|
+
false,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export const STUDIO_MANUAL_EDITING_ENABLED = STUDIO_PREVIEW_MANUAL_EDITING_ENABLED;
|
|
59
|
+
|
|
60
|
+
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|