@hyperframes/studio 0.4.38 → 0.5.0-alpha.10
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/index-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +1431 -196
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +445 -0
- package/src/components/editor/PropertyPanel.tsx +2466 -206
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +537 -0
- package/src/components/editor/domEditing.ts +762 -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/nle/NLELayout.tsx +17 -47
- package/src/components/nle/NLEPreview.tsx +50 -5
- 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 +34 -55
- package/src/icons/SystemIcons.tsx +0 -2
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.tsx +18 -70
- package/src/player/components/PlayerControls.tsx +44 -3
- package/src/player/components/Timeline.test.ts +12 -0
- package/src/player/components/Timeline.tsx +51 -20
- package/src/player/components/TimelineClip.tsx +20 -7
- package/src/player/components/timelineEditing.test.ts +2 -4
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +59 -0
- package/src/player/hooks/useTimelinePlayer.ts +74 -32
- package/src/player/lib/time.test.ts +1 -11
- package/src/player/lib/time.ts +0 -6
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -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/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/dist/assets/index-18P_dZeo.js +0 -93
- package/dist/assets/index-BLrgRQSu.css +0 -1
- package/src/utils/frameCapture.test.ts +0 -26
- package/src/utils/frameCapture.ts +0 -38
|
@@ -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
|
+
}
|
|
@@ -5,10 +5,6 @@ import type { TimelineElement } from "../../player";
|
|
|
5
5
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
6
6
|
import { NLEPreview } from "./NLEPreview";
|
|
7
7
|
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
|
8
|
-
import {
|
|
9
|
-
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
10
|
-
getTimelineToggleTitle,
|
|
11
|
-
} from "../../utils/timelineDiscovery";
|
|
12
8
|
|
|
13
9
|
interface NLELayoutProps {
|
|
14
10
|
projectId: string;
|
|
@@ -89,7 +85,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
89
85
|
togglePlay,
|
|
90
86
|
seek,
|
|
91
87
|
onIframeLoad: baseOnIframeLoad,
|
|
92
|
-
refreshPlayer,
|
|
93
88
|
saveSeekPosition,
|
|
94
89
|
} = useTimelinePlayer();
|
|
95
90
|
|
|
@@ -103,13 +98,15 @@ export const NLELayout = memo(function NLELayout({
|
|
|
103
98
|
usePlayerStore.getState().reset();
|
|
104
99
|
}
|
|
105
100
|
|
|
106
|
-
//
|
|
101
|
+
// Save seek position before the Player component creates a new player
|
|
102
|
+
// on refreshKey change. The Player handles the actual reload via the
|
|
103
|
+
// dual-player crossfade; we just need to persist the current time.
|
|
107
104
|
const prevRefreshKeyRef = useRef(refreshKey);
|
|
108
105
|
useEffect(() => {
|
|
109
106
|
if (refreshKey === prevRefreshKeyRef.current) return;
|
|
110
107
|
prevRefreshKeyRef.current = refreshKey;
|
|
111
|
-
|
|
112
|
-
}, [refreshKey,
|
|
108
|
+
saveSeekPosition();
|
|
109
|
+
}, [refreshKey, saveSeekPosition]);
|
|
113
110
|
|
|
114
111
|
// Wrap onIframeLoad to also notify parent of iframe ref
|
|
115
112
|
const onIframeLoad = useCallback(() => {
|
|
@@ -201,7 +198,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
201
198
|
|
|
202
199
|
// Resizable timeline height
|
|
203
200
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
204
|
-
const isTimelineVisible = timelineVisible ?? true;
|
|
205
201
|
const isDragging = useRef(false);
|
|
206
202
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
207
203
|
|
|
@@ -209,6 +205,10 @@ export const NLELayout = memo(function NLELayout({
|
|
|
209
205
|
const currentLevel = compositionStack[compositionStack.length - 1];
|
|
210
206
|
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
|
211
207
|
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
onIframeRef?.(iframeRef.current);
|
|
210
|
+
}, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
|
|
211
|
+
|
|
212
212
|
// Save master seek position before drilling down so we can restore it on back-navigation.
|
|
213
213
|
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
|
|
214
214
|
const masterSeekRef = useRef(0);
|
|
@@ -371,11 +371,16 @@ export const NLELayout = memo(function NLELayout({
|
|
|
371
371
|
onNavigate={handleNavigateComposition}
|
|
372
372
|
/>
|
|
373
373
|
)}
|
|
374
|
-
<PlayerControls
|
|
374
|
+
<PlayerControls
|
|
375
|
+
onTogglePlay={togglePlay}
|
|
376
|
+
onSeek={seek}
|
|
377
|
+
timelineVisible={timelineVisible ?? true}
|
|
378
|
+
onToggleTimeline={onToggleTimeline}
|
|
379
|
+
/>
|
|
375
380
|
</div>
|
|
376
381
|
</div>
|
|
377
382
|
|
|
378
|
-
{
|
|
383
|
+
{(timelineVisible ?? true) && (
|
|
379
384
|
<>
|
|
380
385
|
{/* Resize divider */}
|
|
381
386
|
<div
|
|
@@ -417,42 +422,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
417
422
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
418
423
|
</div>
|
|
419
424
|
</>
|
|
420
|
-
)
|
|
421
|
-
<div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
|
|
422
|
-
<div className="flex h-10 items-center justify-between px-3">
|
|
423
|
-
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
424
|
-
Timeline
|
|
425
|
-
</div>
|
|
426
|
-
<button
|
|
427
|
-
type="button"
|
|
428
|
-
onClick={onToggleTimeline}
|
|
429
|
-
className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
|
|
430
|
-
title={getTimelineToggleTitle(false)}
|
|
431
|
-
aria-label="Show timeline editor"
|
|
432
|
-
>
|
|
433
|
-
<svg
|
|
434
|
-
width="13"
|
|
435
|
-
height="13"
|
|
436
|
-
viewBox="0 0 24 24"
|
|
437
|
-
fill="none"
|
|
438
|
-
stroke="currentColor"
|
|
439
|
-
strokeWidth="1.7"
|
|
440
|
-
strokeLinecap="round"
|
|
441
|
-
strokeLinejoin="round"
|
|
442
|
-
aria-hidden="true"
|
|
443
|
-
>
|
|
444
|
-
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
445
|
-
<path d="M7 9h10" />
|
|
446
|
-
<path d="M8 5h8" />
|
|
447
|
-
</svg>
|
|
448
|
-
<span>Show</span>
|
|
449
|
-
<span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
|
|
450
|
-
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
451
|
-
</span>
|
|
452
|
-
</button>
|
|
453
|
-
</div>
|
|
454
|
-
</div>
|
|
455
|
-
) : null}
|
|
425
|
+
)}
|
|
456
426
|
</div>
|
|
457
427
|
);
|
|
458
428
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, type Ref } from "react";
|
|
1
|
+
import { memo, useRef, useState, type Ref } from "react";
|
|
2
2
|
import { Player } from "../../player";
|
|
3
3
|
|
|
4
4
|
interface NLEPreviewProps {
|
|
@@ -21,6 +21,17 @@ export function getPreviewPlayerKey({
|
|
|
21
21
|
return directUrl ?? projectId;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Manages the composition preview with crossfade on reload.
|
|
26
|
+
*
|
|
27
|
+
* When refreshKey changes, a new Player is mounted alongside the old one.
|
|
28
|
+
* The old Player stays visible (opacity 1) until the new one fires onLoad,
|
|
29
|
+
* at which point the old is removed. This avoids the flash that a simple
|
|
30
|
+
* key-swap remount would cause.
|
|
31
|
+
*
|
|
32
|
+
* Uses the render-time state adjustment pattern (React-sanctioned) to detect
|
|
33
|
+
* refreshKey changes — no useEffect needed.
|
|
34
|
+
*/
|
|
24
35
|
export const NLEPreview = memo(function NLEPreview({
|
|
25
36
|
projectId,
|
|
26
37
|
iframeRef,
|
|
@@ -29,22 +40,56 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
29
40
|
directUrl,
|
|
30
41
|
refreshKey,
|
|
31
42
|
}: NLEPreviewProps) {
|
|
32
|
-
const
|
|
43
|
+
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
|
|
44
|
+
const prevRefreshKeyRef = useRef(refreshKey);
|
|
45
|
+
const [retiringKey, setRetiringKey] = useState<string | null>(null);
|
|
46
|
+
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
47
|
+
|
|
48
|
+
// Detect refreshKey change during render (React-sanctioned derived state pattern).
|
|
49
|
+
// When the key changes, the current active player becomes the retiring player
|
|
50
|
+
// and a new active player is mounted alongside it.
|
|
51
|
+
if (refreshKey !== prevRefreshKeyRef.current) {
|
|
52
|
+
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
|
|
53
|
+
prevRefreshKeyRef.current = refreshKey;
|
|
54
|
+
setRetiringKey(oldKey);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const activeKey = `${baseKey}:${refreshKey ?? 0}`;
|
|
58
|
+
|
|
59
|
+
const handleNewPlayerLoad = () => {
|
|
60
|
+
onIframeLoad();
|
|
61
|
+
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
|
|
62
|
+
retiringTimerRef.current = setTimeout(() => {
|
|
63
|
+
setRetiringKey(null);
|
|
64
|
+
retiringTimerRef.current = null;
|
|
65
|
+
}, 160);
|
|
66
|
+
};
|
|
33
67
|
|
|
34
68
|
return (
|
|
35
69
|
<div className="flex flex-col h-full min-h-0">
|
|
36
70
|
<div
|
|
37
|
-
className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
|
|
71
|
+
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
|
|
38
72
|
tabIndex={0}
|
|
39
73
|
aria-label="Composition preview"
|
|
40
74
|
>
|
|
75
|
+
{retiringKey && (
|
|
76
|
+
<Player
|
|
77
|
+
key={retiringKey}
|
|
78
|
+
projectId={directUrl ? undefined : projectId}
|
|
79
|
+
directUrl={directUrl}
|
|
80
|
+
onLoad={() => {}}
|
|
81
|
+
portrait={portrait}
|
|
82
|
+
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
|
|
83
|
+
/>
|
|
84
|
+
)}
|
|
41
85
|
<Player
|
|
42
|
-
key={
|
|
86
|
+
key={activeKey}
|
|
43
87
|
ref={iframeRef}
|
|
44
88
|
projectId={directUrl ? undefined : projectId}
|
|
45
89
|
directUrl={directUrl}
|
|
46
|
-
onLoad={onIframeLoad}
|
|
90
|
+
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
|
|
47
91
|
portrait={portrait}
|
|
92
|
+
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
|
48
93
|
/>
|
|
49
94
|
</div>
|
|
50
95
|
</div>
|
|
@@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react";
|
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
3
|
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
5
6
|
|
|
6
7
|
interface AssetsTabProps {
|
|
7
8
|
projectId: string;
|
|
@@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
298
299
|
);
|
|
299
300
|
|
|
300
301
|
const handleCopyPath = useCallback(async (path: string) => {
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
const copied = await copyTextToClipboard(path);
|
|
303
|
+
if (copied) {
|
|
303
304
|
setCopiedPath(path);
|
|
304
305
|
setTimeout(() => setCopiedPath(null), 1500);
|
|
305
|
-
} catch {
|
|
306
|
-
// ignore
|
|
307
306
|
}
|
|
308
307
|
}, []);
|
|
309
308
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveCompositionPreviewScale } from "./CompositionsTab";
|
|
2
|
+
import { resolveCompositionPreviewScale, resolveThumbnailSeekTime } from "./CompositionsTab";
|
|
3
3
|
|
|
4
4
|
describe("resolveCompositionPreviewScale", () => {
|
|
5
5
|
it("scales a 16:9 stage to fit the composition card", () => {
|
|
@@ -35,3 +35,18 @@ describe("resolveCompositionPreviewScale", () => {
|
|
|
35
35
|
).toBeCloseTo(80 / 1920);
|
|
36
36
|
});
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
describe("resolveThumbnailSeekTime", () => {
|
|
40
|
+
it("uses the default 3s frame for compositions longer than 3s", () => {
|
|
41
|
+
expect(resolveThumbnailSeekTime(6)).toBe(3);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("uses the midpoint for compositions shorter than 3s", () => {
|
|
45
|
+
expect(resolveThumbnailSeekTime(2)).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to the default 3s frame when duration is unknown", () => {
|
|
49
|
+
expect(resolveThumbnailSeekTime(null)).toBe(3);
|
|
50
|
+
expect(resolveThumbnailSeekTime(Number.NaN)).toBe(3);
|
|
51
|
+
});
|
|
52
|
+
});
|