@elah/editor 0.4.0 → 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.
@@ -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 { resolveDrawRect, transformFromContainRect, computeContainViewport, } from '@elah/core';
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
- export function MediaTransformOverlay() {
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 r = resolveDrawRect(clip.transform, stage.width, stage.height, contentW, contentH);
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 dist = Math.hypot(e.clientX - g.centerClientX, e.clientY - g.centerClientY);
78
- const ratio = g.startDist > 0 ? dist / g.startDist : 1;
79
- const minScale = MIN_RENDER_PX / Math.max(1, Math.min(g.contentW, g.contentH));
80
- const maxScale = (MAX_RENDER_STAGE_MULTIPLE * Math.max(g.stageW, g.stageH)) /
81
- Math.max(1, Math.max(g.contentW, g.contentH));
82
- const next = clamp(g.base.scale * ratio, minScale, maxScale);
83
- engine.previewClip(g.id, g.trackId, { transform: { ...g.base, scale: next } });
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
- engine.commitInteraction(g.type === 'move' ? 'Move clip' : 'Resize clip');
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 rootRect = rootRef.current?.getBoundingClientRect();
126
- const centerClientX = (rootRect?.left ?? 0) + item.centerScreenX;
127
- const centerClientY = (rootRect?.top ?? 0) + item.centerScreenY;
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
- centerClientX,
140
- centerClientY,
141
- startDist: Math.hypot(e.clientX - centerClientX, e.clientY - centerClientY),
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 (_jsx("div", { style: boxStyle, onPointerDown: (e) => beginMove(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, children: selected &&
162
- CORNERS.map((corner) => (_jsx("div", { onPointerDown: (e) => beginResize(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, style: {
163
- position: 'absolute',
164
- ...corner.pos,
165
- width: 10,
166
- height: 10,
167
- background: 'var(--elah-selection-handle, #fff)',
168
- border: '1px solid var(--elah-selection-color, #4c9aff)',
169
- borderRadius: 2,
170
- cursor: corner.cursor,
171
- pointerEvents: 'auto',
172
- touchAction: 'none',
173
- } }, corner.key))) }, clip.id));
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 CORNERS = [
177
- { key: 'nw', pos: { left: -5, top: -5 }, cursor: 'nwse-resize' },
178
- { key: 'ne', pos: { right: -5, top: -5 }, cursor: 'nesw-resize' },
179
- { key: 'sw', pos: { left: -5, bottom: -5 }, cursor: 'nesw-resize' },
180
- { key: 'se', pos: { right: -5, bottom: -5 }, cursor: 'nwse-resize' },
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, useMediaLibraryStore } from '@elah/react';
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
- const warmImages = () => preloadProjectImages(engine.getProject());
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('change', warmImages);
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 = () => audio.preloadProjectAudio();
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('change', warmAudio);
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
- const prewarmScene = resolveTimeline(frame + PREWARM_HORIZON_FRAMES, project);
81
- renderer.prewarm(prewarmScene);
82
- rafId = requestAnimationFrame(tick);
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('change', warmAudio);
90
- engine.off('change', warmImages);
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 function ShapeOverlay(): import("react/jsx-runtime").JSX.Element;
1
+ export declare const ShapeOverlay: import("react").NamedExoticComponent<object>;