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