@elah/editor 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +240 -179
- package/dist/editor/AssetPanel/AssetPanel.js +1 -1
- package/dist/editor/EditorProvider.js +25 -11
- package/dist/editor/Preview/MediaTransformOverlay.d.ts +1 -1
- package/dist/editor/Preview/MediaTransformOverlay.js +245 -44
- package/dist/editor/Preview/Preview.js +99 -18
- package/dist/editor/Preview/PreviewLoadingOverlay.d.ts +1 -0
- package/dist/editor/Preview/PreviewLoadingOverlay.js +18 -0
- package/dist/editor/Preview/ShapeOverlay.d.ts +1 -1
- package/dist/editor/Preview/ShapeOverlay.js +83 -37
- package/dist/editor/Preview/StageBorder.js +2 -2
- package/dist/editor/Preview/TextOverlay.d.ts +1 -1
- package/dist/editor/Preview/TextOverlay.js +143 -34
- package/dist/editor/SourcePanel/SourcePanel.js +1 -1
- package/dist/index.d.ts +25 -12
- package/dist/index.js +15 -9
- package/dist/styles.css +1 -1
- package/package.json +4 -4
- package/src/styles/tokens.css +11 -0
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
-
import {
|
|
2
|
+
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { Crop as CropIcon, Move as MoveIcon } from 'lucide-react';
|
|
4
|
+
import { resolveDrawRect, transformFromContainRect, computeContainViewport, normalizeCrop, } from '@elah/core';
|
|
4
5
|
import { useTimelineEngine, useSelectionStore, usePlaybackStore, useMediaLibraryStore, } from '@elah/react';
|
|
5
6
|
import { useResolvedScene } from '../useResolvedScene';
|
|
6
7
|
const MIN_BOX_PX = 28;
|
|
7
8
|
const BOX_PAD = 0;
|
|
8
9
|
const MIN_RENDER_PX = 16;
|
|
9
10
|
const MAX_RENDER_STAGE_MULTIPLE = 8;
|
|
11
|
+
const MIN_CROP_FRACTION = 0.05;
|
|
10
12
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
11
13
|
const clamp01 = (v) => clamp(v, 0, 1);
|
|
12
|
-
|
|
14
|
+
function rotateLocalToStage(v, rotation) {
|
|
15
|
+
const c = Math.cos(rotation);
|
|
16
|
+
const s = Math.sin(rotation);
|
|
17
|
+
return { x: v.x * c - v.y * s, y: v.x * s + v.y * c };
|
|
18
|
+
}
|
|
19
|
+
function rotateStageToLocal(v, rotation) {
|
|
20
|
+
return rotateLocalToStage(v, -rotation);
|
|
21
|
+
}
|
|
22
|
+
export const MediaTransformOverlay = memo(function MediaTransformOverlay() {
|
|
13
23
|
const engine = useTimelineEngine();
|
|
14
24
|
const scene = useResolvedScene();
|
|
15
25
|
const isPlaying = usePlaybackStore((s) => s.isPlaying);
|
|
@@ -20,6 +30,10 @@ export function MediaTransformOverlay() {
|
|
|
20
30
|
const rootRef = useRef(null);
|
|
21
31
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
22
32
|
const gestureRef = useRef(null);
|
|
33
|
+
const [mode, setMode] = useState('resize');
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setMode('resize');
|
|
36
|
+
}, [selectedClipIds]);
|
|
23
37
|
useLayoutEffect(() => {
|
|
24
38
|
const el = rootRef.current;
|
|
25
39
|
if (!el)
|
|
@@ -36,13 +50,14 @@ export function MediaTransformOverlay() {
|
|
|
36
50
|
const items = useMemo(() => {
|
|
37
51
|
if (fit.width <= 0)
|
|
38
52
|
return [];
|
|
39
|
-
const clips = [...scene.videos, ...scene.images];
|
|
53
|
+
const clips = [...scene.videos, ...scene.images].sort((a, b) => a.zIndex - b.zIndex);
|
|
40
54
|
return clips.map((clip) => {
|
|
41
55
|
const assetId = engine.findClip(clip.id)?.clip.assetId;
|
|
42
56
|
const asset = assetId ? assets[assetId] : undefined;
|
|
43
57
|
const contentW = asset?.width ?? stage.width;
|
|
44
58
|
const contentH = asset?.height ?? stage.height;
|
|
45
|
-
const
|
|
59
|
+
const crop = normalizeCrop(clip.crop);
|
|
60
|
+
const r = resolveDrawRect(clip.transform, stage.width, stage.height, contentW, contentH, clip.crop);
|
|
46
61
|
const centerScreenX = fit.x + (r.x + r.width / 2) * scale;
|
|
47
62
|
const centerScreenY = fit.y + (r.y + r.height / 2) * scale;
|
|
48
63
|
const rawW = r.width * scale;
|
|
@@ -51,13 +66,19 @@ export function MediaTransformOverlay() {
|
|
|
51
66
|
const h = Math.max(rawH, MIN_BOX_PX);
|
|
52
67
|
const left = rawW < MIN_BOX_PX ? centerScreenX - w / 2 : fit.x + r.x * scale;
|
|
53
68
|
const top = rawH < MIN_BOX_PX ? centerScreenY - h / 2 : fit.y + r.y * scale;
|
|
69
|
+
const fullW = r.width / crop.width;
|
|
70
|
+
const fullH = r.height / crop.height;
|
|
54
71
|
return {
|
|
55
72
|
clip,
|
|
56
73
|
contentW,
|
|
57
74
|
contentH,
|
|
75
|
+
crop,
|
|
76
|
+
fullW,
|
|
77
|
+
fullH,
|
|
58
78
|
rect: { left, top, width: w, height: h },
|
|
59
79
|
centerScreenX,
|
|
60
80
|
centerScreenY,
|
|
81
|
+
rotation: r.rotation,
|
|
61
82
|
};
|
|
62
83
|
});
|
|
63
84
|
}, [scene.videos, scene.images, assets, engine, stage.width, stage.height, fit, scale]);
|
|
@@ -73,14 +94,86 @@ export function MediaTransformOverlay() {
|
|
|
73
94
|
const y = clamp01(g.base.y + dy / g.stageH);
|
|
74
95
|
engine.previewClip(g.id, g.trackId, { transform: { ...g.base, x, y } });
|
|
75
96
|
}
|
|
97
|
+
else if (g.type === 'resize') {
|
|
98
|
+
const h = g.handle;
|
|
99
|
+
const halfW0 = g.halfW0;
|
|
100
|
+
const halfH0 = g.halfH0;
|
|
101
|
+
const rotation = g.base.rotation;
|
|
102
|
+
const dxStage = (e.clientX - g.startClientX) / scale;
|
|
103
|
+
const dyStage = (e.clientY - g.startClientY) / scale;
|
|
104
|
+
const local = rotateStageToLocal({ x: dxStage, y: dyStage }, rotation);
|
|
105
|
+
const minHalf = MIN_RENDER_PX / 2;
|
|
106
|
+
const maxHalf = (MAX_RENDER_STAGE_MULTIPLE * Math.max(g.stageW, g.stageH)) / 2;
|
|
107
|
+
let newHalfW = h.sx !== 0 ? clamp(halfW0 + h.sx * local.x, minHalf, maxHalf) : halfW0;
|
|
108
|
+
let newHalfH = h.sy !== 0 ? clamp(halfH0 + h.sy * local.y, minHalf, maxHalf) : halfH0;
|
|
109
|
+
if (e.shiftKey) {
|
|
110
|
+
const ratioW = newHalfW / halfW0;
|
|
111
|
+
const ratioH = newHalfH / halfH0;
|
|
112
|
+
const ratio = h.sx !== 0 && h.sy !== 0 ? Math.max(ratioW, ratioH) : h.sx !== 0 ? ratioW : ratioH;
|
|
113
|
+
newHalfW = clamp(halfW0 * ratio, minHalf, maxHalf);
|
|
114
|
+
newHalfH = clamp(halfH0 * ratio, minHalf, maxHalf);
|
|
115
|
+
}
|
|
116
|
+
const rotatedHandleVec = rotateLocalToStage({ x: h.sx * newHalfW, y: h.sy * newHalfH }, rotation);
|
|
117
|
+
const centerX = g.anchorStageX + rotatedHandleVec.x;
|
|
118
|
+
const centerY = g.anchorStageY + rotatedHandleVec.y;
|
|
119
|
+
const scaleX = (newHalfW * 2) / Math.max(1, g.contentW * g.base.scale);
|
|
120
|
+
const scaleY = (newHalfH * 2) / Math.max(1, g.contentH * g.base.scale);
|
|
121
|
+
engine.previewClip(g.id, g.trackId, {
|
|
122
|
+
transform: {
|
|
123
|
+
...g.base,
|
|
124
|
+
x: centerX / g.stageW,
|
|
125
|
+
y: centerY / g.stageH,
|
|
126
|
+
scaleX,
|
|
127
|
+
scaleY,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
else if (g.type === 'crop-resize') {
|
|
132
|
+
const h = g.handle;
|
|
133
|
+
const baseCrop = g.baseCrop;
|
|
134
|
+
const fullW = g.fullW;
|
|
135
|
+
const fullH = g.fullH;
|
|
136
|
+
const rotation = g.base.rotation;
|
|
137
|
+
const dxStage = (e.clientX - g.startClientX) / scale;
|
|
138
|
+
const dyStage = (e.clientY - g.startClientY) / scale;
|
|
139
|
+
const local = rotateStageToLocal({ x: dxStage, y: dyStage }, rotation);
|
|
140
|
+
const visW0 = fullW * baseCrop.width;
|
|
141
|
+
const visH0 = fullH * baseCrop.height;
|
|
142
|
+
const minVisW = MIN_CROP_FRACTION * fullW;
|
|
143
|
+
const minVisH = MIN_CROP_FRACTION * fullH;
|
|
144
|
+
const newVisW = h.sx !== 0 ? clamp(visW0 + h.sx * local.x, minVisW, fullW) : visW0;
|
|
145
|
+
const newVisH = h.sy !== 0 ? clamp(visH0 + h.sy * local.y, minVisH, fullH) : visH0;
|
|
146
|
+
const newCropW = newVisW / fullW;
|
|
147
|
+
const newCropH = newVisH / fullH;
|
|
148
|
+
let newCropX = baseCrop.x;
|
|
149
|
+
if (h.sx === -1)
|
|
150
|
+
newCropX = baseCrop.x + (baseCrop.width - newCropW);
|
|
151
|
+
newCropX = clamp(newCropX, 0, 1 - newCropW);
|
|
152
|
+
let newCropY = baseCrop.y;
|
|
153
|
+
if (h.sy === -1)
|
|
154
|
+
newCropY = baseCrop.y + (baseCrop.height - newCropH);
|
|
155
|
+
newCropY = clamp(newCropY, 0, 1 - newCropH);
|
|
156
|
+
const rotatedHandleVec = rotateLocalToStage({ x: h.sx * (newVisW / 2), y: h.sy * (newVisH / 2) }, rotation);
|
|
157
|
+
const centerX = g.anchorStageX + rotatedHandleVec.x;
|
|
158
|
+
const centerY = g.anchorStageY + rotatedHandleVec.y;
|
|
159
|
+
engine.previewClip(g.id, g.trackId, {
|
|
160
|
+
crop: normalizeCrop({ x: newCropX, y: newCropY, width: newCropW, height: newCropH }),
|
|
161
|
+
transform: { ...g.base, x: centerX / g.stageW, y: centerY / g.stageH },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
76
164
|
else {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
165
|
+
const baseCrop = g.baseCrop;
|
|
166
|
+
const fullW = g.fullW;
|
|
167
|
+
const fullH = g.fullH;
|
|
168
|
+
const rotation = g.base.rotation;
|
|
169
|
+
const dxStage = (e.clientX - g.startClientX) / scale;
|
|
170
|
+
const dyStage = (e.clientY - g.startClientY) / scale;
|
|
171
|
+
const local = rotateStageToLocal({ x: dxStage, y: dyStage }, rotation);
|
|
172
|
+
const cropX = clamp(baseCrop.x - local.x / fullW, 0, 1 - baseCrop.width);
|
|
173
|
+
const cropY = clamp(baseCrop.y - local.y / fullH, 0, 1 - baseCrop.height);
|
|
174
|
+
engine.previewClip(g.id, g.trackId, {
|
|
175
|
+
crop: { ...baseCrop, x: cropX, y: cropY },
|
|
176
|
+
});
|
|
84
177
|
}
|
|
85
178
|
}, [engine, scale]);
|
|
86
179
|
const endGesture = useCallback((e) => {
|
|
@@ -92,10 +185,11 @@ export function MediaTransformOverlay() {
|
|
|
92
185
|
if (target.hasPointerCapture?.(e.pointerId)) {
|
|
93
186
|
target.releasePointerCapture(e.pointerId);
|
|
94
187
|
}
|
|
95
|
-
|
|
188
|
+
const label = g.type === 'move' ? 'Move clip' : g.type === 'resize' ? 'Resize clip' : 'Crop clip';
|
|
189
|
+
engine.commitInteraction(label);
|
|
96
190
|
}, [engine]);
|
|
97
191
|
const baseTransformFor = useCallback((item) => item.clip.transform ??
|
|
98
|
-
transformFromContainRect(item.contentW, item.contentH, stage.width, stage.height), [stage.width, stage.height]);
|
|
192
|
+
transformFromContainRect(item.contentW * item.crop.width, item.contentH * item.crop.height, stage.width, stage.height), [stage.width, stage.height]);
|
|
99
193
|
const beginMove = useCallback((e, item) => {
|
|
100
194
|
e.stopPropagation();
|
|
101
195
|
if (!selectedClipIds.has(item.clip.id))
|
|
@@ -112,23 +206,79 @@ export function MediaTransformOverlay() {
|
|
|
112
206
|
stageH: stage.height,
|
|
113
207
|
startClientX: e.clientX,
|
|
114
208
|
startClientY: e.clientY,
|
|
115
|
-
centerClientX: 0,
|
|
116
|
-
centerClientY: 0,
|
|
117
|
-
startDist: 0,
|
|
118
209
|
};
|
|
119
210
|
}, [selectedClipIds, selectClip, baseTransformFor, stage.width, stage.height]);
|
|
120
|
-
const beginResize = useCallback((e, item) => {
|
|
211
|
+
const beginResize = useCallback((e, item, handle) => {
|
|
121
212
|
e.stopPropagation();
|
|
122
213
|
if (!selectedClipIds.has(item.clip.id))
|
|
123
214
|
selectClip(item.clip.id);
|
|
124
215
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
216
|
+
const base = baseTransformFor(item);
|
|
217
|
+
const halfW0 = (item.contentW * base.scale * (base.scaleX ?? 1)) / 2;
|
|
218
|
+
const halfH0 = (item.contentH * base.scale * (base.scaleY ?? 1)) / 2;
|
|
219
|
+
const centerX = base.x * stage.width;
|
|
220
|
+
const centerY = base.y * stage.height;
|
|
221
|
+
const rotatedAnchor = rotateLocalToStage({ x: -handle.sx * halfW0, y: -handle.sy * halfH0 }, base.rotation);
|
|
128
222
|
gestureRef.current = {
|
|
129
223
|
type: 'resize',
|
|
130
224
|
id: item.clip.id,
|
|
131
225
|
trackId: item.clip.trackId,
|
|
226
|
+
base,
|
|
227
|
+
contentW: item.contentW,
|
|
228
|
+
contentH: item.contentH,
|
|
229
|
+
stageW: stage.width,
|
|
230
|
+
stageH: stage.height,
|
|
231
|
+
startClientX: e.clientX,
|
|
232
|
+
startClientY: e.clientY,
|
|
233
|
+
handle: { sx: handle.sx, sy: handle.sy },
|
|
234
|
+
anchorStageX: centerX + rotatedAnchor.x,
|
|
235
|
+
anchorStageY: centerY + rotatedAnchor.y,
|
|
236
|
+
halfW0,
|
|
237
|
+
halfH0,
|
|
238
|
+
};
|
|
239
|
+
}, [selectedClipIds, selectClip, baseTransformFor, stage.width, stage.height]);
|
|
240
|
+
const beginCropResize = useCallback((e, item, handle) => {
|
|
241
|
+
e.stopPropagation();
|
|
242
|
+
if (!selectedClipIds.has(item.clip.id))
|
|
243
|
+
selectClip(item.clip.id);
|
|
244
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
245
|
+
const base = baseTransformFor(item);
|
|
246
|
+
const baseCrop = item.crop;
|
|
247
|
+
const visW0 = item.fullW * baseCrop.width;
|
|
248
|
+
const visH0 = item.fullH * baseCrop.height;
|
|
249
|
+
const halfW0 = visW0 / 2;
|
|
250
|
+
const halfH0 = visH0 / 2;
|
|
251
|
+
const centerX = base.x * stage.width;
|
|
252
|
+
const centerY = base.y * stage.height;
|
|
253
|
+
const rotatedAnchor = rotateLocalToStage({ x: -handle.sx * halfW0, y: -handle.sy * halfH0 }, base.rotation);
|
|
254
|
+
gestureRef.current = {
|
|
255
|
+
type: 'crop-resize',
|
|
256
|
+
id: item.clip.id,
|
|
257
|
+
trackId: item.clip.trackId,
|
|
258
|
+
base,
|
|
259
|
+
contentW: item.contentW,
|
|
260
|
+
contentH: item.contentH,
|
|
261
|
+
stageW: stage.width,
|
|
262
|
+
stageH: stage.height,
|
|
263
|
+
startClientX: e.clientX,
|
|
264
|
+
startClientY: e.clientY,
|
|
265
|
+
handle: { sx: handle.sx, sy: handle.sy },
|
|
266
|
+
anchorStageX: centerX + rotatedAnchor.x,
|
|
267
|
+
anchorStageY: centerY + rotatedAnchor.y,
|
|
268
|
+
baseCrop,
|
|
269
|
+
fullW: item.fullW,
|
|
270
|
+
fullH: item.fullH,
|
|
271
|
+
};
|
|
272
|
+
}, [selectedClipIds, selectClip, baseTransformFor, stage.width, stage.height]);
|
|
273
|
+
const beginCropPan = useCallback((e, item) => {
|
|
274
|
+
e.stopPropagation();
|
|
275
|
+
if (!selectedClipIds.has(item.clip.id))
|
|
276
|
+
selectClip(item.clip.id);
|
|
277
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
278
|
+
gestureRef.current = {
|
|
279
|
+
type: 'crop-pan',
|
|
280
|
+
id: item.clip.id,
|
|
281
|
+
trackId: item.clip.trackId,
|
|
132
282
|
base: baseTransformFor(item),
|
|
133
283
|
contentW: item.contentW,
|
|
134
284
|
contentH: item.contentH,
|
|
@@ -136,15 +286,16 @@ export function MediaTransformOverlay() {
|
|
|
136
286
|
stageH: stage.height,
|
|
137
287
|
startClientX: e.clientX,
|
|
138
288
|
startClientY: e.clientY,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
289
|
+
baseCrop: item.crop,
|
|
290
|
+
fullW: item.fullW,
|
|
291
|
+
fullH: item.fullH,
|
|
142
292
|
};
|
|
143
293
|
}, [selectedClipIds, selectClip, baseTransformFor, stage.width, stage.height]);
|
|
144
294
|
return (_jsxs("div", { ref: rootRef, className: "absolute inset-0 z-[2] pointer-events-none overflow-hidden", children: [!isPlaying && ownsSelection && (_jsx("div", { className: "absolute inset-0 pointer-events-auto", onPointerDown: () => clearSelection() })), !isPlaying &&
|
|
145
295
|
items.map((item) => {
|
|
146
296
|
const { clip, rect } = item;
|
|
147
297
|
const selected = selectedClipIds.has(clip.id);
|
|
298
|
+
const cropping = selected && mode === 'crop';
|
|
148
299
|
const boxStyle = {
|
|
149
300
|
position: 'absolute',
|
|
150
301
|
left: rect.left - BOX_PAD,
|
|
@@ -154,28 +305,78 @@ export function MediaTransformOverlay() {
|
|
|
154
305
|
boxSizing: 'border-box',
|
|
155
306
|
border: selected ? '1px solid var(--elah-selection-color, #4c9aff)' : '1px solid transparent',
|
|
156
307
|
borderRadius: 2,
|
|
157
|
-
cursor: 'move',
|
|
308
|
+
cursor: cropping ? 'grab' : 'move',
|
|
158
309
|
pointerEvents: 'auto',
|
|
159
310
|
touchAction: 'none',
|
|
311
|
+
transform: item.rotation ? `rotate(${item.rotation}rad)` : undefined,
|
|
312
|
+
zIndex: clip.zIndex,
|
|
160
313
|
};
|
|
161
|
-
return (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
314
|
+
return (_jsxs("div", { children: [_jsxs("div", { style: boxStyle, onPointerDown: (e) => (cropping ? beginCropPan(e, item) : beginMove(e, item)), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, children: [cropping && item.crop.width < 1 - 1e-3 && (_jsx("div", { style: {
|
|
315
|
+
position: 'absolute',
|
|
316
|
+
left: -(item.crop.x / item.crop.width) * rect.width,
|
|
317
|
+
top: -(item.crop.y / item.crop.height) * rect.height,
|
|
318
|
+
width: rect.width / item.crop.width,
|
|
319
|
+
height: rect.height / item.crop.height,
|
|
320
|
+
border: '1px dashed var(--elah-selection-color, #4c9aff)',
|
|
321
|
+
opacity: 0.4,
|
|
322
|
+
pointerEvents: 'none',
|
|
323
|
+
} })), selected &&
|
|
324
|
+
HANDLES.map((handle) => (_jsx("div", { onPointerDown: (e) => cropping ? beginCropResize(e, item, handle) : beginResize(e, item, handle), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, style: {
|
|
325
|
+
position: 'absolute',
|
|
326
|
+
...handle.pos,
|
|
327
|
+
width: 10,
|
|
328
|
+
height: 10,
|
|
329
|
+
background: 'var(--elah-selection-handle, #fff)',
|
|
330
|
+
border: '1px solid var(--elah-selection-color, #4c9aff)',
|
|
331
|
+
borderRadius: 2,
|
|
332
|
+
cursor: handle.cursor,
|
|
333
|
+
pointerEvents: 'auto',
|
|
334
|
+
touchAction: 'none',
|
|
335
|
+
} }, handle.key)))] }), selected && (_jsx(ModeToggle, { mode: mode, onChange: setMode, left: item.centerScreenX, top: rect.top, zIndex: clip.zIndex + 1 }))] }, clip.id));
|
|
174
336
|
})] }));
|
|
175
|
-
}
|
|
176
|
-
const
|
|
177
|
-
{ key: 'nw', pos: { left: -5, top: -5 }, cursor: 'nwse-resize' },
|
|
178
|
-
{ key: '
|
|
179
|
-
{ key: '
|
|
180
|
-
{ key: '
|
|
337
|
+
});
|
|
338
|
+
const HANDLES = [
|
|
339
|
+
{ key: 'nw', sx: -1, sy: -1, pos: { left: -5, top: -5 }, cursor: 'nwse-resize' },
|
|
340
|
+
{ key: 'n', sx: 0, sy: -1, pos: { left: '50%', top: -5, transform: 'translateX(-50%)' }, cursor: 'ns-resize' },
|
|
341
|
+
{ key: 'ne', sx: 1, sy: -1, pos: { right: -5, top: -5 }, cursor: 'nesw-resize' },
|
|
342
|
+
{ key: 'e', sx: 1, sy: 0, pos: { right: -5, top: '50%', transform: 'translateY(-50%)' }, cursor: 'ew-resize' },
|
|
343
|
+
{ key: 'se', sx: 1, sy: 1, pos: { right: -5, bottom: -5 }, cursor: 'nwse-resize' },
|
|
344
|
+
{ key: 's', sx: 0, sy: 1, pos: { left: '50%', bottom: -5, transform: 'translateX(-50%)' }, cursor: 'ns-resize' },
|
|
345
|
+
{ key: 'sw', sx: -1, sy: 1, pos: { left: -5, bottom: -5 }, cursor: 'nesw-resize' },
|
|
346
|
+
{ key: 'w', sx: -1, sy: 0, pos: { left: -5, top: '50%', transform: 'translateY(-50%)' }, cursor: 'ew-resize' },
|
|
181
347
|
];
|
|
348
|
+
function ModeToggle({ mode, onChange, left, top, zIndex, }) {
|
|
349
|
+
const segment = (value, Icon, label) => {
|
|
350
|
+
const active = mode === value;
|
|
351
|
+
return (_jsxs("button", { type: "button", onPointerDown: (e) => e.stopPropagation(), onClick: () => onChange(value), title: label, "aria-pressed": active, style: {
|
|
352
|
+
display: 'flex',
|
|
353
|
+
alignItems: 'center',
|
|
354
|
+
gap: 4,
|
|
355
|
+
border: 'none',
|
|
356
|
+
borderRadius: 5,
|
|
357
|
+
padding: '3px 8px',
|
|
358
|
+
fontSize: 11,
|
|
359
|
+
lineHeight: 1,
|
|
360
|
+
fontWeight: 500,
|
|
361
|
+
cursor: 'pointer',
|
|
362
|
+
color: active ? 'var(--elah-popover-accent-text, #a5b4fc)' : 'var(--elah-text-muted, #9ca3af)',
|
|
363
|
+
background: active ? 'var(--elah-selection-color, #4c9aff)' : 'transparent',
|
|
364
|
+
opacity: active ? 1 : 0.9,
|
|
365
|
+
}, children: [_jsx(Icon, { size: 12 }), label] }, value));
|
|
366
|
+
};
|
|
367
|
+
return (_jsxs("div", { className: "pointer-events-auto", onPointerDown: (e) => e.stopPropagation(), style: {
|
|
368
|
+
position: 'absolute',
|
|
369
|
+
left,
|
|
370
|
+
top: top - 8,
|
|
371
|
+
transform: 'translate(-50%, -100%)',
|
|
372
|
+
display: 'flex',
|
|
373
|
+
gap: 2,
|
|
374
|
+
padding: 2,
|
|
375
|
+
borderRadius: 7,
|
|
376
|
+
background: 'var(--elah-popover-bg, #1a1f2b)',
|
|
377
|
+
border: '1px solid var(--elah-popover-border, #2d3548)',
|
|
378
|
+
boxShadow: 'var(--elah-popover-shadow, 0 8px 32px rgba(0, 0, 0, 0.6))',
|
|
379
|
+
zIndex,
|
|
380
|
+
whiteSpace: 'nowrap',
|
|
381
|
+
}, children: [segment('resize', MoveIcon, 'Resize'), segment('crop', CropIcon, 'Crop')] }));
|
|
382
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef, useEffect, useImperativeHandle, useRef, } from 'react';
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react';
|
|
3
3
|
import { GpuRenderer } from '@elah/core';
|
|
4
4
|
import { resolveTimeline } from '@elah/core';
|
|
5
|
-
import { useTimelineEngine, usePlaybackEngine
|
|
6
|
-
import { AudioPlaybackController, preloadProjectImages, warmImageSrc } from '@elah/core';
|
|
5
|
+
import { useTimelineEngine, usePlaybackEngine } from '@elah/react';
|
|
6
|
+
import { AudioPlaybackController, PerfSummary, clipLoadStore, preloadProjectImages, warmImageSrc, warmVideoSrc, } from '@elah/core';
|
|
7
|
+
import { useMediaLibraryStore, usePlaybackStore } from '@elah/react';
|
|
7
8
|
import { cn } from '@elah/timeline';
|
|
8
9
|
import { TextOverlay } from './TextOverlay';
|
|
9
10
|
import { ShapeOverlay } from './ShapeOverlay';
|
|
10
11
|
import { MediaTransformOverlay } from './MediaTransformOverlay';
|
|
12
|
+
import { PreviewLoadingOverlay } from './PreviewLoadingOverlay';
|
|
11
13
|
import { TransitionOverlay } from './TransitionOverlay';
|
|
12
14
|
import { StageBorder } from './StageBorder';
|
|
13
15
|
export const Preview = forwardRef(function Preview({ demuxerFactory, debug = false, probeLayer = false, clearColor, preserveDrawingBuffer, enableAudio = true, audioResolver, style, className, }, ref) {
|
|
@@ -16,6 +18,8 @@ export const Preview = forwardRef(function Preview({ demuxerFactory, debug = fal
|
|
|
16
18
|
const transitionOverlayRef = useRef(null);
|
|
17
19
|
const engine = useTimelineEngine();
|
|
18
20
|
const playback = usePlaybackEngine();
|
|
21
|
+
const [glState, setGlState] = useState('ok');
|
|
22
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
19
23
|
useImperativeHandle(ref, () => ({
|
|
20
24
|
getCanvas: () => rendererRef.current?.getCanvas() ?? null,
|
|
21
25
|
getRenderer: () => rendererRef.current,
|
|
@@ -24,18 +28,31 @@ export const Preview = forwardRef(function Preview({ demuxerFactory, debug = fal
|
|
|
24
28
|
const container = containerRef.current;
|
|
25
29
|
if (!container)
|
|
26
30
|
return;
|
|
31
|
+
let dirty = true;
|
|
32
|
+
const markDirty = () => {
|
|
33
|
+
dirty = true;
|
|
34
|
+
};
|
|
27
35
|
const renderer = new GpuRenderer({
|
|
28
36
|
probeLayer,
|
|
29
37
|
demuxerFactory,
|
|
30
38
|
preserveDrawingBuffer,
|
|
31
39
|
...(clearColor ? { clearColor } : {}),
|
|
40
|
+
onContextLost: () => setGlState('lost'),
|
|
41
|
+
onContextRestored: () => {
|
|
42
|
+
setGlState('ok');
|
|
43
|
+
markDirty();
|
|
44
|
+
},
|
|
45
|
+
onContextUnrecoverable: () => setGlState('failed'),
|
|
46
|
+
onClipLoad: (clipId, state) => clipLoadStore.getState().set(clipId, state),
|
|
32
47
|
});
|
|
33
48
|
renderer.mount(container);
|
|
34
49
|
renderer.setDebug(debug);
|
|
35
50
|
rendererRef.current = renderer;
|
|
51
|
+
setGlState('ok');
|
|
36
52
|
const resize = () => {
|
|
37
53
|
const dpr = window.devicePixelRatio ?? 1;
|
|
38
54
|
renderer.resize(container.clientWidth, container.clientHeight, dpr);
|
|
55
|
+
markDirty();
|
|
39
56
|
};
|
|
40
57
|
const observer = new ResizeObserver(resize);
|
|
41
58
|
observer.observe(container);
|
|
@@ -45,13 +62,36 @@ export const Preview = forwardRef(function Preview({ demuxerFactory, debug = fal
|
|
|
45
62
|
: null;
|
|
46
63
|
audio?.start();
|
|
47
64
|
let warmAudio = null;
|
|
48
|
-
|
|
65
|
+
let warmAudioOnLoad = null;
|
|
66
|
+
const warmImages = (clip) => {
|
|
67
|
+
if (clip) {
|
|
68
|
+
if (clip.type === 'image' && typeof clip.src === 'string')
|
|
69
|
+
warmImageSrc(clip.src);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
preloadProjectImages(engine.getProject());
|
|
73
|
+
};
|
|
74
|
+
const warmImagesOnLoad = () => warmImages();
|
|
49
75
|
warmImages();
|
|
50
|
-
engine.on('
|
|
76
|
+
engine.on('clip:added', warmImages);
|
|
77
|
+
engine.on('clip:updated', warmImages);
|
|
78
|
+
engine.on('project:loaded', warmImagesOnLoad);
|
|
79
|
+
engine.on('change', markDirty);
|
|
80
|
+
const unsubPlayback = usePlaybackStore.subscribe(markDirty);
|
|
51
81
|
if (audio) {
|
|
52
|
-
warmAudio = () =>
|
|
82
|
+
warmAudio = (clip) => {
|
|
83
|
+
if (clip) {
|
|
84
|
+
if (clip.type === 'audio' && typeof clip.src === 'string')
|
|
85
|
+
audio.warmAudioSrc(clip.src);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
audio.preloadProjectAudio();
|
|
89
|
+
};
|
|
90
|
+
warmAudioOnLoad = () => warmAudio?.();
|
|
53
91
|
warmAudio();
|
|
54
|
-
engine.on('
|
|
92
|
+
engine.on('clip:added', warmAudio);
|
|
93
|
+
engine.on('clip:updated', warmAudio);
|
|
94
|
+
engine.on('project:loaded', warmAudioOnLoad);
|
|
55
95
|
}
|
|
56
96
|
const warmedAssetIds = new Set();
|
|
57
97
|
const unsubMediaLibrary = useMediaLibraryStore.subscribe((state) => {
|
|
@@ -64,35 +104,76 @@ export const Preview = forwardRef(function Preview({ demuxerFactory, debug = fal
|
|
|
64
104
|
audio?.warmAudioSrc(asset.src);
|
|
65
105
|
else if (asset?.kind === 'image')
|
|
66
106
|
warmImageSrc(asset.src);
|
|
107
|
+
else if (asset?.kind === 'video')
|
|
108
|
+
warmVideoSrc(asset.src);
|
|
67
109
|
}
|
|
68
110
|
});
|
|
69
111
|
let rafId = 0;
|
|
70
112
|
const PREWARM_HORIZON_FRAMES = 30;
|
|
113
|
+
const PAUSED_PREWARM_INTERVAL_MS = 500;
|
|
114
|
+
let lastPrewarmAt = 0;
|
|
115
|
+
const perf = new PerfSummary();
|
|
116
|
+
const unsubPerfPlayback = usePlaybackStore.subscribe(() => perf.count('playbackNotify'));
|
|
117
|
+
const countTracks = () => perf.count('engineChange');
|
|
118
|
+
engine.on('change', countTracks);
|
|
71
119
|
const tick = () => {
|
|
120
|
+
rafId = requestAnimationFrame(tick);
|
|
121
|
+
if (renderer.isContextLost)
|
|
122
|
+
return;
|
|
123
|
+
const playing = usePlaybackStore.getState().isPlaying;
|
|
124
|
+
const now = performance.now();
|
|
125
|
+
const hasLoading = Object.values(clipLoadStore.getState().byClipId).some((s) => s === 'loading');
|
|
126
|
+
if (!playing && !dirty && !hasLoading) {
|
|
127
|
+
if (now - lastPrewarmAt >= PAUSED_PREWARM_INTERVAL_MS) {
|
|
128
|
+
lastPrewarmAt = now;
|
|
129
|
+
const frame = Math.floor(playback.getFrameAt());
|
|
130
|
+
const project = engine.getProject();
|
|
131
|
+
renderer.prewarm(resolveTimeline(frame + PREWARM_HORIZON_FRAMES, project));
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
dirty = false;
|
|
72
136
|
const frame = Math.floor(playback.getFrameAt());
|
|
73
137
|
const project = engine.getProject();
|
|
74
|
-
const scene = resolveTimeline(frame, project);
|
|
138
|
+
const scene = perf.measure('resolve', () => resolveTimeline(frame, project));
|
|
75
139
|
const canvas = renderer.getCanvas();
|
|
76
140
|
if (canvas)
|
|
77
141
|
transitionOverlayRef.current?.captureIfNewTransition(scene, canvas);
|
|
78
|
-
renderer.render(scene);
|
|
142
|
+
perf.measure('render', () => renderer.render(scene));
|
|
79
143
|
transitionOverlayRef.current?.update(scene);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
144
|
+
lastPrewarmAt = now;
|
|
145
|
+
perf.measure('prewarm', () => {
|
|
146
|
+
const prewarmScene = resolveTimeline(frame + PREWARM_HORIZON_FRAMES, project);
|
|
147
|
+
renderer.prewarm(prewarmScene);
|
|
148
|
+
});
|
|
149
|
+
perf.endTick(performance.now() - now, { fps: renderer.fps });
|
|
83
150
|
};
|
|
84
151
|
rafId = requestAnimationFrame(tick);
|
|
85
152
|
return () => {
|
|
86
153
|
cancelAnimationFrame(rafId);
|
|
87
154
|
observer.disconnect();
|
|
88
|
-
if (warmAudio)
|
|
89
|
-
engine.off('
|
|
90
|
-
|
|
155
|
+
if (warmAudio) {
|
|
156
|
+
engine.off('clip:added', warmAudio);
|
|
157
|
+
engine.off('clip:updated', warmAudio);
|
|
158
|
+
}
|
|
159
|
+
if (warmAudioOnLoad)
|
|
160
|
+
engine.off('project:loaded', warmAudioOnLoad);
|
|
161
|
+
engine.off('clip:added', warmImages);
|
|
162
|
+
engine.off('clip:updated', warmImages);
|
|
163
|
+
engine.off('project:loaded', warmImagesOnLoad);
|
|
164
|
+
engine.off('change', markDirty);
|
|
165
|
+
unsubPlayback();
|
|
166
|
+
unsubPerfPlayback();
|
|
167
|
+
engine.off('change', countTracks);
|
|
91
168
|
unsubMediaLibrary();
|
|
92
169
|
audio?.destroy();
|
|
93
170
|
renderer.dispose();
|
|
94
171
|
rendererRef.current = null;
|
|
172
|
+
clipLoadStore.getState().clear();
|
|
95
173
|
};
|
|
96
|
-
}, [engine, playback, demuxerFactory, debug, probeLayer, preserveDrawingBuffer, enableAudio, audioResolver]);
|
|
97
|
-
return (_jsxs("div", { ref: containerRef, className: cn('relative w-full h-full bg-[var(--elah-preview-bg,#06070A)]', className), style: style, children: [_jsx(StageBorder, {}), _jsx(TransitionOverlay, { ref: transitionOverlayRef }), _jsx(MediaTransformOverlay, {}), _jsx(ShapeOverlay, {}), _jsx(TextOverlay, {})] }))
|
|
174
|
+
}, [engine, playback, demuxerFactory, debug, probeLayer, preserveDrawingBuffer, enableAudio, audioResolver, reloadKey]);
|
|
175
|
+
return (_jsxs("div", { ref: containerRef, className: cn('relative w-full h-full bg-[var(--elah-preview-bg,#06070A)]', className), style: style, children: [_jsx(StageBorder, {}), _jsx(TransitionOverlay, { ref: transitionOverlayRef }), _jsx(MediaTransformOverlay, {}), _jsx(ShapeOverlay, {}), _jsx(TextOverlay, {}), _jsx(PreviewLoadingOverlay, {}), glState !== 'ok' && (_jsx("div", { className: "absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[var(--elah-overlay-scrim,rgba(0,0,0,0.7))] text-[color:var(--elah-overlay-text,#ffffff)] text-sm", "data-testid": "preview-gl-recovery", children: glState === 'lost' ? (_jsx("span", { children: "Recovering preview\u2026" })) : (_jsxs(_Fragment, { children: [_jsx("span", { children: "The preview stopped rendering (graphics context lost)." }), _jsx("button", { type: "button", className: "rounded-md border border-[color:var(--elah-overlay-border,rgba(255,255,255,0.3))] bg-[var(--elah-overlay-bg,rgba(255,255,255,0.1))] px-3 py-1.5 hover:bg-[var(--elah-overlay-bg-hover,rgba(255,255,255,0.2))]", onClick: () => {
|
|
176
|
+
setGlState('ok');
|
|
177
|
+
setReloadKey((k) => k + 1);
|
|
178
|
+
}, children: "Reload preview" })] })) }))] }));
|
|
98
179
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function PreviewLoadingOverlay(): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useClipLoadStore } from '@elah/react';
|
|
3
|
+
function summarise(byClipId) {
|
|
4
|
+
let sawLoading = false;
|
|
5
|
+
for (const state of Object.values(byClipId)) {
|
|
6
|
+
if (state === 'error')
|
|
7
|
+
return 'error';
|
|
8
|
+
if (state === 'loading')
|
|
9
|
+
sawLoading = true;
|
|
10
|
+
}
|
|
11
|
+
return sawLoading ? 'loading' : null;
|
|
12
|
+
}
|
|
13
|
+
export function PreviewLoadingOverlay() {
|
|
14
|
+
const state = useClipLoadStore((s) => summarise(s.byClipId));
|
|
15
|
+
if (state === null)
|
|
16
|
+
return null;
|
|
17
|
+
return (_jsx("div", { className: "pointer-events-none absolute inset-0 z-[9] flex flex-col items-center justify-center gap-2", "data-testid": "preview-media-loading", role: "status", "aria-live": "polite", children: state === 'loading' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "h-7 w-7 animate-spin rounded-full border-2 border-[color:var(--elah-spinner-track,rgba(255,255,255,0.25))] border-t-[color:var(--elah-spinner-head,rgba(255,255,255,0.9))]", "aria-hidden": "true" }), _jsx("span", { className: "sr-only", children: "Loading media" })] })) : (_jsx("span", { className: "rounded-md bg-[var(--elah-overlay-scrim,rgba(0,0,0,0.7))] px-3 py-1.5 text-sm text-[color:var(--elah-overlay-text,#ffffff)]", children: "Couldn't load this video" })) }));
|
|
18
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare
|
|
1
|
+
export declare const ShapeOverlay: import("react").NamedExoticComponent<object>;
|