@bycrux/editor 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  7. package/src/carousel/AddElementMenu.tsx +211 -0
  8. package/src/carousel/CarouselEditor.tsx +545 -0
  9. package/src/carousel/CarouselRenderModal.tsx +243 -0
  10. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  11. package/src/carousel/OverlayPicker.tsx +145 -0
  12. package/src/carousel/ReadOnlySlide.tsx +90 -0
  13. package/src/carousel/SlideCanvas.tsx +637 -0
  14. package/src/carousel/SlidePropertyPanel.tsx +387 -0
  15. package/src/carousel/__tests__/CarouselEditor.test.tsx +291 -0
  16. package/src/carousel/__tests__/ReadOnlySlide.test.tsx +139 -0
  17. package/src/carousel/__tests__/SlideCanvasCrop.test.tsx +95 -0
  18. package/src/carousel/__tests__/SlideCanvasFonts.test.tsx +82 -0
  19. package/src/crop/CanvasCropOverlay.tsx +193 -0
  20. package/src/crop/__tests__/crop-math.test.ts +174 -0
  21. package/src/crop/crop-math.ts +125 -0
  22. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  23. package/src/gestures/helpers/drag.ts +24 -0
  24. package/src/gestures/helpers/element-transform.ts +15 -0
  25. package/src/gestures/helpers/resize.ts +60 -0
  26. package/src/gestures/helpers/rotate.ts +44 -0
  27. package/src/gestures/helpers/snap.ts +64 -0
  28. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  29. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  30. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  31. package/src/gestures/index.ts +16 -0
  32. package/src/index.ts +136 -0
  33. package/src/lib/google-fonts.ts +28 -0
  34. package/src/overlays/contract.ts +41 -0
  35. package/src/preview/OverlayPreview.tsx +196 -0
  36. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  37. package/src/schema.ts +201 -0
  38. package/src/state/__tests__/project-reducer.test.ts +957 -0
  39. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  40. package/src/state/mutation-queue.ts +62 -0
  41. package/src/state/project-reducer.ts +328 -0
  42. package/src/state/use-project-state.ts +442 -0
  43. package/src/test-setup.ts +1 -0
  44. package/src/text/FontPicker.tsx +218 -0
  45. package/src/text/InlineTextEditor.tsx +92 -0
  46. package/src/text/TextFormattingToolbar.tsx +248 -0
  47. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  48. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  49. package/src/theme.ts +93 -0
  50. package/src/types.ts +486 -0
  51. package/src/ui/__tests__/button.test.tsx +17 -0
  52. package/src/ui/badge.tsx +32 -0
  53. package/src/ui/button.tsx +32 -0
  54. package/src/ui/index.ts +16 -0
  55. package/src/ui/input.tsx +15 -0
  56. package/src/ui/label.tsx +10 -0
  57. package/src/ui/select.tsx +23 -0
  58. package/src/ui/switch.tsx +31 -0
  59. package/src/ui/textarea.tsx +15 -0
  60. package/src/ui/utils.ts +7 -0
  61. package/src/video/RenderModal.tsx +252 -0
  62. package/src/video/VersionPanel.tsx +83 -0
  63. package/src/video/VideoEditor.tsx +508 -0
  64. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  65. package/src/video/__tests__/captionRepair.test.ts +134 -0
  66. package/src/video/__tests__/cuts.test.ts +198 -0
  67. package/src/video/captionRepair.ts +41 -0
  68. package/src/video/cuts.ts +369 -0
  69. package/src/video/design-canvas.ts +11 -0
  70. package/src/video/preview/CaptionPreview.tsx +83 -0
  71. package/src/video/preview/CarouselPreview.tsx +35 -0
  72. package/src/video/preview/OverlayItemsLayer.tsx +584 -0
  73. package/src/video/preview/PreviewPlayer.tsx +178 -0
  74. package/src/video/preview/useDragOverlay.ts +167 -0
  75. package/src/video/preview/useVideoPlayback.ts +761 -0
  76. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  77. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  78. package/src/video/timeline/EditableSegment.tsx +30 -0
  79. package/src/video/timeline/Scrubber.tsx +184 -0
  80. package/src/video/timeline/Timeline.tsx +375 -0
  81. package/src/video/timeline/TimelineContext.ts +25 -0
  82. package/src/video/timeline/TranscriptModal.tsx +63 -0
  83. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  84. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  85. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  86. package/src/video/timeline/multiSelectOps.ts +157 -0
  87. package/src/video/timeline/useItemDragDrop.ts +190 -0
  88. package/src/video/timeline/useTimelineZoom.ts +48 -0
  89. package/src/video/timeline/utils.ts +17 -0
@@ -0,0 +1,584 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { EditorProject as Project, VisualItem } from '../../schema'
3
+ import type { OverlayFactory } from '../../types'
4
+ import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
5
+ import { getOverlayDesignCanvas } from '../design-canvas'
6
+ import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
7
+ import type { Corner } from './useDragOverlay'
8
+ import type { useDragOverlay } from './useDragOverlay'
9
+
10
+ const VIDEO_PRELOAD_S = 0.4 // mount this many seconds before item.start so the frame is ready
11
+
12
+ // Synced video overlay — seeks to the correct position within the item's inPoint/outPoint range
13
+ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted, visible }: {
14
+ src: string; currentTime: number; itemStart: number; inPoint: number
15
+ isPlaying: boolean; muted?: boolean; visible: boolean
16
+ }) {
17
+ const ref = useRef<HTMLVideoElement>(null)
18
+ // Refs so the onSeeked handler can read current playback intent without stale closures
19
+ const isPlayingRef = useRef(isPlaying)
20
+ const visibleRef = useRef(visible)
21
+ useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying])
22
+ useEffect(() => { visibleRef.current = visible }, [visible])
23
+
24
+ // On mount: seek to the frame that will be shown at itemStart so it's ready when it becomes visible.
25
+ // Do NOT call play() here — the play/pause effect handles that and runs on mount too.
26
+ // Calling play() from both effects simultaneously while the WebM is still buffering causes both
27
+ // play() promises to abort each other, leaving the video in a silent play-pending state.
28
+ useEffect(() => {
29
+ const v = ref.current
30
+ if (!v) return
31
+ const target = Math.max(inPoint, inPoint + (currentTime - itemStart))
32
+ v.currentTime = target
33
+ }, [])
34
+
35
+ // On scrub (large jump): re-seek — but only once the video has data.
36
+ // While playing, only re-seek on large jumps (>1.5s) to avoid chasing gap-clock drift.
37
+ // The gap clock and video playback rate diverge slightly; a 0.3s threshold fires too often
38
+ // and causes cascading re-seeks that skip the video forward until it ends prematurely.
39
+ useEffect(() => {
40
+ const v = ref.current
41
+ if (!v) return
42
+ if (v.readyState < 2) return
43
+ const target = inPoint + (currentTime - itemStart)
44
+ const drift = Math.abs(v.currentTime - target)
45
+ if (!v.paused && drift < 1.5) return
46
+ if (drift > 0.3) {
47
+ v.currentTime = Math.max(inPoint, target)
48
+ }
49
+ }, [currentTime, itemStart, inPoint])
50
+
51
+ // Play/pause sync — only play when visible; pause when pre-loading or past end
52
+ useEffect(() => {
53
+ const v = ref.current
54
+ if (!v) return
55
+ if (isPlaying && visible) {
56
+ v.play().catch(() => {})
57
+ } else {
58
+ v.pause()
59
+ }
60
+ }, [isPlaying, visible])
61
+
62
+ return (
63
+ <video
64
+ ref={ref}
65
+ src={src}
66
+ muted={muted}
67
+ preload="auto"
68
+ onSeeked={() => {
69
+ // After a mid-clip seek the browser may have paused to buffer — restart if we should be playing
70
+ const v = ref.current
71
+ if (!v) return
72
+ if (isPlayingRef.current && visibleRef.current && v.paused) {
73
+ v.play().catch(() => {})
74
+ }
75
+ }}
76
+ playsInline
77
+ className="absolute inset-0 w-full h-full object-contain pointer-events-none"
78
+ style={{ opacity: visible ? 1 : 0 }}
79
+ />
80
+ )
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // CustomOverlay: fetches, compiles, and renders a custom JSX overlay file
85
+ //
86
+ // Live overlay-edit reload behavior (Montaj host):
87
+ // - When `watchFile` is provided, it opens a watch subscription on the
88
+ // template's `src` path. On change: calls `clearOverlayCache?.(src)` then
89
+ // recompiles via `compileOverlay(src)`.
90
+ // - When `watchFile` is absent (non-Montaj host), the component only compiles
91
+ // once on `src` change (static preview — graceful, no error).
92
+ // - No raw /api/files/stream EventSource is ever opened in this package.
93
+ // ---------------------------------------------------------------------------
94
+
95
+ interface CustomOverlayProps {
96
+ src: string
97
+ props: Record<string, unknown>
98
+ frame: number
99
+ fps: number
100
+ durationFrames: number
101
+ googleFonts?: string[]
102
+ compileOverlay: (src: string) => Promise<OverlayFactory>
103
+ clearOverlayCache?: (src?: string) => void
104
+ watchFile?: (path: string, onChange: () => void) => () => void
105
+ fileUrl: (path: string) => string
106
+ }
107
+
108
+ function CustomOverlay({
109
+ src,
110
+ props,
111
+ frame,
112
+ fps,
113
+ durationFrames,
114
+ googleFonts,
115
+ compileOverlay,
116
+ clearOverlayCache,
117
+ watchFile,
118
+ fileUrl,
119
+ }: CustomOverlayProps) {
120
+ const [factory, setFactory] = useState<OverlayFactory | null>(null)
121
+ const [error, setError] = useState<string | null>(null)
122
+
123
+ const compile = useCallback(() => {
124
+ clearOverlayCache?.(src)
125
+ compileOverlay(src)
126
+ .then((f) => setFactory(() => f))
127
+ .catch((e) => setError(String(e)))
128
+ }, [src, compileOverlay, clearOverlayCache])
129
+
130
+ useEffect(() => { compile() }, [compile])
131
+
132
+ // Inject Google Fonts declared on the overlay item so the preview renders
133
+ // with the same font metrics as the renderer (bundle.js does the same in
134
+ // generateHtml). Without this, preview falls back to sans-serif and authors
135
+ // get a misleadingly narrow preview of text that will overflow at render.
136
+ useEffect(() => { ensureGoogleFontsLoaded(googleFonts) }, [googleFonts])
137
+
138
+ // Live overlay-edit reload via injected watchFile (Montaj host).
139
+ // When watchFile is absent (non-Montaj), this effect is a no-op — static preview.
140
+ useEffect(() => {
141
+ if (!watchFile) return
142
+ const unwatch = watchFile(src, () => compile())
143
+ return () => unwatch()
144
+ }, [src, watchFile, compile])
145
+
146
+ if (error) {
147
+ return (
148
+ <div className="absolute bottom-4 left-4 right-4 pointer-events-none">
149
+ <div className="bg-red-950/80 border border-red-700 text-red-300 text-xs px-3 py-2 rounded font-mono truncate">
150
+ overlay error: {src.split('/').pop()}
151
+ </div>
152
+ </div>
153
+ )
154
+ }
155
+
156
+ if (!factory) return null
157
+
158
+ const resolvedProps = Object.fromEntries(
159
+ Object.entries(props).map(([k, v]) => [
160
+ k,
161
+ typeof v === 'string' && v.startsWith('/') && !v.startsWith('/api/')
162
+ ? fileUrl(v)
163
+ : v,
164
+ ]),
165
+ )
166
+
167
+ const element = factory(frame, fps, durationFrames, resolvedProps)
168
+ if (!element) return null
169
+
170
+ return <div className="absolute inset-0 pointer-events-none">{element}</div>
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Corner handle — L-shaped bracket that stays a fixed visual size
175
+ // ---------------------------------------------------------------------------
176
+
177
+ function CornerHandle({ corner, scale, onMouseDown }: {
178
+ corner: Corner
179
+ scale: number
180
+ onMouseDown: (e: React.MouseEvent) => void
181
+ }) {
182
+ const cursorClass = {
183
+ nw: 'cursor-nw-resize', ne: 'cursor-ne-resize',
184
+ sw: 'cursor-sw-resize', se: 'cursor-se-resize',
185
+ }[corner]
186
+
187
+ const posClass = {
188
+ nw: 'top-0 left-0', ne: 'top-0 right-0',
189
+ sw: 'bottom-0 left-0', se: 'bottom-0 right-0',
190
+ }[corner]
191
+
192
+ // L-shaped bracket: show only the two relevant border sides
193
+ const borderClass = {
194
+ nw: 'border-t-2 border-l-2',
195
+ ne: 'border-t-2 border-r-2',
196
+ sw: 'border-b-2 border-l-2',
197
+ se: 'border-b-2 border-r-2',
198
+ }[corner]
199
+
200
+ // Inverse scale so handle stays constant visual size; origin at the corner itself
201
+ const origin = `${corner.includes('n') ? 'top' : 'bottom'} ${corner.includes('w') ? 'left' : 'right'}`
202
+
203
+ return (
204
+ <div
205
+ className={`absolute w-5 h-5 border-amber-400 z-50 ${cursorClass} ${posClass} ${borderClass}`}
206
+ style={{ transformOrigin: origin, transform: `scale(${1 / scale})` }}
207
+ onMouseDown={onMouseDown}
208
+ />
209
+ )
210
+ }
211
+
212
+ function RotateHandle({ scale, onMouseDown }: {
213
+ scale: number
214
+ onMouseDown: (e: React.MouseEvent) => void
215
+ }) {
216
+ return (
217
+ <div
218
+ className="absolute top-0 left-1/2 z-50 cursor-grab flex flex-col items-center"
219
+ style={{ transform: `translateX(-50%) translateY(-100%) scale(${1 / scale})`, transformOrigin: 'bottom center' }}
220
+ onMouseDown={onMouseDown}
221
+ >
222
+ <div className="w-4 h-4 rounded-full border-2 border-amber-400 bg-black/60" />
223
+ <div className="w-px h-3 bg-amber-400" />
224
+ </div>
225
+ )
226
+ }
227
+
228
+ // Segmented control for an image item's object-fit. Appears below the selected
229
+ // image's bounding box; counter-scales so it stays a constant size regardless of
230
+ // the item's scale. 'fill' is the legacy stretch behavior (kept for opt-in).
231
+ const FIT_OPTIONS: Array<'cover' | 'contain' | 'fill'> = ['cover', 'contain', 'fill']
232
+ function FitControl({ value, scale, onChange }: {
233
+ value: 'cover' | 'contain' | 'fill'
234
+ scale: number
235
+ onChange: (fit: 'cover' | 'contain' | 'fill') => void
236
+ }) {
237
+ return (
238
+ <div
239
+ className="absolute bottom-0 left-1/2 z-50 flex gap-px rounded bg-black/70 border border-amber-400/50 overflow-hidden"
240
+ style={{ transform: `translateX(-50%) translateY(140%) scale(${1 / scale})`, transformOrigin: 'top center' }}
241
+ onMouseDown={(e) => e.stopPropagation()}
242
+ >
243
+ {FIT_OPTIONS.map(opt => (
244
+ <button
245
+ key={opt}
246
+ type="button"
247
+ onClick={(e) => { e.stopPropagation(); onChange(opt) }}
248
+ className={`px-2 py-1 text-[11px] font-mono capitalize ${
249
+ value === opt ? 'bg-amber-400 text-black' : 'text-gray-300 hover:bg-white/10'
250
+ }`}
251
+ >
252
+ {opt}
253
+ </button>
254
+ ))}
255
+ </div>
256
+ )
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+
261
+ interface OverlayItemsLayerProps {
262
+ project: Project
263
+ currentTime: number
264
+ isPlaying: boolean
265
+ isCanvasProject: boolean
266
+ overlayTracks: VisualItem[][]
267
+ tracks0NonVideo: VisualItem[]
268
+ renderScale: number
269
+ selectedOverlayId?: string
270
+ onOverlayChange?: (id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) => void
271
+ containerRef: React.RefObject<HTMLDivElement | null>
272
+ // from useDragOverlay
273
+ dragState: ReturnType<typeof useDragOverlay>['dragState']
274
+ setDragState: ReturnType<typeof useDragOverlay>['setDragState']
275
+ liveOffset: ReturnType<typeof useDragOverlay>['liveOffset']
276
+ liveScale: ReturnType<typeof useDragOverlay>['liveScale']
277
+ liveRotation: ReturnType<typeof useDragOverlay>['liveRotation']
278
+ snapGuides: ReturnType<typeof useDragOverlay>['snapGuides']
279
+ snapRotation: ReturnType<typeof useDragOverlay>['snapRotation']
280
+ // Adapter-injected overlay capabilities
281
+ compileOverlay: (src: string) => Promise<OverlayFactory>
282
+ clearOverlayCache?: (src?: string) => void
283
+ watchFile?: (path: string, onChange: () => void) => () => void
284
+ fileUrl: (path: string) => string
285
+ }
286
+
287
+ export default function OverlayItemsLayer({
288
+ project,
289
+ currentTime,
290
+ isPlaying,
291
+ isCanvasProject,
292
+ overlayTracks,
293
+ tracks0NonVideo,
294
+ renderScale,
295
+ selectedOverlayId,
296
+ onOverlayChange,
297
+ containerRef,
298
+ dragState,
299
+ setDragState,
300
+ liveOffset,
301
+ liveScale,
302
+ liveRotation,
303
+ snapGuides,
304
+ snapRotation,
305
+ compileOverlay,
306
+ clearOverlayCache,
307
+ watchFile,
308
+ fileUrl,
309
+ }: OverlayItemsLayerProps) {
310
+ const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
311
+
312
+ return (
313
+ <>
314
+ {/* tracks[0] non-video items (background images) — rendered with drag support at base z-level */}
315
+ {!isCanvasProject && tracks0NonVideo.map((item) => {
316
+ if (item.type !== 'image' || !item.src) return null
317
+ const visible = currentTime >= item.start && currentTime < item.end
318
+ if (!visible) return null
319
+ const isSel = selectedOverlayId === item.id
320
+ const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? item.offsetX ?? 0
321
+ const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? item.offsetY ?? 0
322
+ const scale = (liveScale?.id === item.id ? liveScale.scale : null) ?? item.scale ?? 1
323
+ const rotation = (liveRotation?.id === item.id ? liveRotation.rotation : null) ?? item.rotation ?? 0
324
+ const wrapperStyle: React.CSSProperties = {
325
+ transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${scale})`,
326
+ transformOrigin: 'center center',
327
+ // Raise above play/pause div (z=10) when selected so pointer events land here
328
+ zIndex: isSel ? 11 : 2,
329
+ opacity: item.opacity ?? 1,
330
+ }
331
+ const wrapperClass = `absolute inset-0 ${
332
+ isSel
333
+ ? `${dragState?.type === 'move' ? 'cursor-grabbing' : 'cursor-grab'} ring-1 ring-inset ring-amber-400/40`
334
+ : 'pointer-events-none'
335
+ }`
336
+ function startMove(e: React.MouseEvent) {
337
+ if (!isSel) return
338
+ e.stopPropagation()
339
+ setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation })
340
+ }
341
+ const handles = isSel && (
342
+ <>
343
+ {(['nw', 'ne', 'sw', 'se'] as Corner[]).map(c => (
344
+ <CornerHandle key={c} corner={c} scale={scale} onMouseDown={(e) => {
345
+ e.stopPropagation()
346
+ setDragState({ id: item.id, type: `resize-${c}`, initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation })
347
+ }} />
348
+ ))}
349
+ <RotateHandle scale={scale} onMouseDown={(e) => {
350
+ e.stopPropagation()
351
+ const rect = containerRef.current?.getBoundingClientRect()
352
+ if (!rect) return
353
+ const cx = rect.left + rect.width * (0.5 + offsetX / 100)
354
+ const cy = rect.top + rect.height * (0.5 + offsetY / 100)
355
+ const initAngle = Math.atan2(e.clientY - cy, e.clientX - cx)
356
+ setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation, cx, cy, initAngle })
357
+ }} />
358
+ </>
359
+ )
360
+ return (
361
+ <div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove}>
362
+ <img
363
+ src={fileUrl(item.src)}
364
+ draggable={false}
365
+ className="absolute inset-0 w-full h-full pointer-events-none"
366
+ style={{ objectFit: item.fit ?? 'cover' }}
367
+ />
368
+ {handles}
369
+ {isSel && onOverlayChange && (
370
+ <FitControl value={item.fit ?? 'cover'} scale={scale} onChange={(fit) => onOverlayChange(item.id, { fit })} />
371
+ )}
372
+ </div>
373
+ )
374
+ })}
375
+
376
+ {/* All interactive tracks — in canvas mode this includes track 0; otherwise overlays only */}
377
+ {(isCanvasProject ? project.tracks ?? [] : overlayTracks).map((trackItems, trackIdx) =>
378
+ trackItems.map((item) => {
379
+ const visible = currentTime >= item.start && currentTime < item.end
380
+ // Pre-mount video items slightly before their start so the frame is ready (no flash)
381
+ const mounted = item.type === 'video'
382
+ ? currentTime >= item.start - VIDEO_PRELOAD_S && currentTime < item.end
383
+ : visible
384
+ if (!mounted) return null
385
+
386
+ const isSel = selectedOverlayId === item.id
387
+ const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? item.offsetX ?? 0
388
+ const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? item.offsetY ?? 0
389
+ const scale = (liveScale?.id === item.id ? liveScale.scale : null) ?? item.scale ?? 1
390
+ const rotation = (liveRotation?.id === item.id ? liveRotation.rotation : null) ?? item.rotation ?? 0
391
+
392
+ function startMove(e: React.MouseEvent) {
393
+ if (!isSel) return
394
+ e.stopPropagation()
395
+ setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation })
396
+ }
397
+
398
+ function startResize(corner: Corner) {
399
+ return (e: React.MouseEvent) => {
400
+ e.stopPropagation()
401
+ setDragState({ id: item.id, type: `resize-${corner}`, initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation })
402
+ }
403
+ }
404
+
405
+ function startRotate(e: React.MouseEvent) {
406
+ e.stopPropagation()
407
+ const rect = containerRef.current?.getBoundingClientRect()
408
+ if (!rect) return
409
+ const cx = rect.left + rect.width * (0.5 + offsetX / 100)
410
+ const cy = rect.top + rect.height * (0.5 + offsetY / 100)
411
+ const initAngle = Math.atan2(e.clientY - cy, e.clientX - cx)
412
+ setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation, cx, cy, initAngle })
413
+ }
414
+
415
+ // zIndex: canvas mode track 0 sits just above the play-toggle div (10), others stack above
416
+ const zIndex = isCanvasProject ? trackIdx + 11 : trackIdx + 12
417
+
418
+ const wrapperStyle: React.CSSProperties = {
419
+ transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${scale})`,
420
+ transformOrigin: 'center center',
421
+ zIndex,
422
+ opacity: item.opacity ?? 1,
423
+ }
424
+
425
+ const wrapperClass = `absolute inset-0 ${
426
+ isSel
427
+ ? `${dragState?.type === 'move' ? 'cursor-grabbing' : 'cursor-grab'} ring-1 ring-inset ring-amber-400/40`
428
+ : 'pointer-events-none'
429
+ }`
430
+
431
+ const handles = isSel && (
432
+ <>
433
+ {(['nw', 'ne', 'sw', 'se'] as Corner[]).map(c => (
434
+ <CornerHandle key={c} corner={c} scale={scale} onMouseDown={startResize(c)} />
435
+ ))}
436
+ <RotateHandle scale={scale} onMouseDown={startRotate} />
437
+ </>
438
+ )
439
+
440
+ // Image items
441
+ if (item.type === 'image' && item.src) {
442
+ return (
443
+ <div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove}>
444
+ <img
445
+ src={fileUrl(item.src)}
446
+ draggable={false}
447
+ className="absolute inset-0 w-full h-full pointer-events-none"
448
+ style={{ objectFit: item.fit ?? 'cover' }}
449
+ />
450
+ {handles}
451
+ {isSel && onOverlayChange && (
452
+ <FitControl value={item.fit ?? 'cover'} scale={scale} onChange={(fit) => onOverlayChange(item.id, { fit })} />
453
+ )}
454
+ </div>
455
+ )
456
+ }
457
+
458
+ // Video items (preview uses raw src; remove_bg compositing only happens at final render)
459
+ if (item.type === 'video' && item.src) {
460
+ return (
461
+ <div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove}>
462
+ <OverlayVideo
463
+ src={fileUrl(item.nobg_preview_src ?? item.src)}
464
+ currentTime={currentTime}
465
+ itemStart={item.start}
466
+ inPoint={item.inPoint ?? 0}
467
+ isPlaying={isPlaying}
468
+ muted={item.muted}
469
+ visible={visible}
470
+ key={`vid-${item.id}`}
471
+ />
472
+ {handles}
473
+ </div>
474
+ )
475
+ }
476
+
477
+ // JSX overlays
478
+ if (item.type === 'overlay' && item.src) {
479
+ const fps = project.settings?.fps ?? 30
480
+ const frame = Math.round((currentTime - item.start) * fps)
481
+ const durationFrames = Math.round((item.end - item.start) * fps)
482
+ return (
483
+ <div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove}>
484
+ {/* Render at native 1080×1920 then scale down to match container */}
485
+ <div style={{
486
+ position: 'absolute', top: 0, left: 0,
487
+ width: RENDER_W, height: RENDER_H,
488
+ transform: `scale(${renderScale})`, transformOrigin: 'top left',
489
+ pointerEvents: 'none',
490
+ }}>
491
+ <OverlayErrorBoundary
492
+ label={item.src.split('/').pop() ?? item.src}
493
+ watchPath={item.src}
494
+ watchFile={watchFile}
495
+ >
496
+ <CustomOverlay
497
+ src={item.src}
498
+ props={item.props ?? {}}
499
+ frame={frame}
500
+ fps={fps}
501
+ durationFrames={durationFrames}
502
+ googleFonts={item.googleFonts}
503
+ compileOverlay={compileOverlay}
504
+ clearOverlayCache={clearOverlayCache}
505
+ watchFile={watchFile}
506
+ fileUrl={fileUrl}
507
+ />
508
+ </OverlayErrorBoundary>
509
+ </div>
510
+ {handles}
511
+ </div>
512
+ )
513
+ }
514
+
515
+ // Legacy text overlays
516
+ const pos = (item.position as string) ?? 'bottom-left'
517
+ const posClass: Record<string, string> = {
518
+ 'top-left': 'top-[8%] left-[4%]',
519
+ 'top-center': 'top-[8%] left-1/2 -translate-x-1/2',
520
+ 'top-right': 'top-[8%] right-[4%]',
521
+ 'center': 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
522
+ 'bottom-left': 'bottom-[8%] left-[4%]',
523
+ 'bottom-center': 'bottom-[8%] left-1/2 -translate-x-1/2',
524
+ 'bottom-right': 'bottom-[8%] right-[4%]',
525
+ }
526
+ return (
527
+ <div
528
+ key={item.id}
529
+ className={`absolute ${isSel ? 'cursor-grab ring-1 ring-amber-400/40' : 'pointer-events-none'} ${posClass[pos] ?? posClass['bottom-left']}`}
530
+ style={wrapperStyle}
531
+ onMouseDown={startMove}
532
+ >
533
+ {!!item.text && (
534
+ <span className="bg-black/70 text-white text-sm font-bold px-3 py-1.5 rounded">
535
+ {item.text as string}
536
+ </span>
537
+ )}
538
+ {handles}
539
+ </div>
540
+ )
541
+ })
542
+ )}
543
+
544
+ {/* Center snap guide lines */}
545
+ {dragState?.type === 'move' && snapGuides.x && (
546
+ <div className="absolute top-0 bottom-0 left-1/2 w-px bg-amber-400 pointer-events-none z-50"
547
+ style={{ transform: 'translateX(-50%)' }} />
548
+ )}
549
+ {dragState?.type === 'move' && snapGuides.y && (
550
+ <div className="absolute left-0 right-0 top-1/2 h-px bg-amber-400 pointer-events-none z-50"
551
+ style={{ transform: 'translateY(-50%)' }} />
552
+ )}
553
+ {/* Edge guide lines — always visible during a move drag as reference frame */}
554
+ {dragState?.type === 'move' && <div className="absolute top-0 bottom-0 left-0 w-px bg-amber-400/30 pointer-events-none z-50" />}
555
+ {dragState?.type === 'move' && <div className="absolute top-0 bottom-0 right-0 w-px bg-amber-400/30 pointer-events-none z-50" />}
556
+ {dragState?.type === 'move' && <div className="absolute left-0 right-0 top-0 h-px bg-amber-400/30 pointer-events-none z-50" />}
557
+ {dragState?.type === 'move' && <div className="absolute left-0 right-0 bottom-0 h-px bg-amber-400/30 pointer-events-none z-50" />}
558
+ {/* Edge snap highlight — brighten when snapping to an edge */}
559
+ {dragState?.type === 'move' && snapGuides.left && <div className="absolute top-0 bottom-0 left-0 w-px bg-amber-400 pointer-events-none z-50" />}
560
+ {dragState?.type === 'move' && snapGuides.right && <div className="absolute top-0 bottom-0 right-0 w-px bg-amber-400 pointer-events-none z-50" />}
561
+ {dragState?.type === 'move' && snapGuides.top && <div className="absolute left-0 right-0 top-0 h-px bg-amber-400 pointer-events-none z-50" />}
562
+ {dragState?.type === 'move' && snapGuides.bottom && <div className="absolute left-0 right-0 bottom-0 h-px bg-amber-400 pointer-events-none z-50" />}
563
+ {/* Rotation snap guide — line through center at the snapped angle */}
564
+ {dragState?.type === 'rotate' && snapRotation !== null && (
565
+ <div className="absolute inset-0 pointer-events-none z-50">
566
+ <svg width="100%" height="100%" overflow="visible">
567
+ <line
568
+ x1="50%" y1="50%"
569
+ x2={`calc(50% + 200% * ${Math.cos((snapRotation - 90) * Math.PI / 180)})`}
570
+ y2={`calc(50% + 200% * ${Math.sin((snapRotation - 90) * Math.PI / 180)})`}
571
+ stroke="rgb(251 191 36)" strokeWidth="1" strokeDasharray="4 3" opacity="0.8"
572
+ />
573
+ <line
574
+ x1="50%" y1="50%"
575
+ x2={`calc(50% - 200% * ${Math.cos((snapRotation - 90) * Math.PI / 180)})`}
576
+ y2={`calc(50% - 200% * ${Math.sin((snapRotation - 90) * Math.PI / 180)})`}
577
+ stroke="rgb(251 191 36)" strokeWidth="1" strokeDasharray="4 3" opacity="0.8"
578
+ />
579
+ </svg>
580
+ </div>
581
+ )}
582
+ </>
583
+ )
584
+ }