@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.
- package/package.json +1 -1
- package/src/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/index.ts +23 -1
- package/src/types.ts +141 -0
- package/src/video/RenderModal.tsx +252 -0
- package/src/video/VersionPanel.tsx +83 -0
- package/src/video/VideoEditor.tsx +508 -0
- package/src/video/__tests__/VideoEditor.test.tsx +213 -0
- package/src/video/__tests__/captionRepair.test.ts +134 -0
- package/src/video/__tests__/cuts.test.ts +198 -0
- package/src/video/captionRepair.ts +41 -0
- package/src/video/cuts.ts +369 -0
- package/src/video/design-canvas.ts +11 -0
- package/src/video/preview/CaptionPreview.tsx +83 -0
- package/src/video/preview/CarouselPreview.tsx +35 -0
- package/src/video/preview/OverlayItemsLayer.tsx +603 -0
- package/src/video/preview/PreviewPlayer.tsx +178 -0
- package/src/video/preview/useDragOverlay.ts +167 -0
- package/src/video/preview/useVideoPlayback.ts +761 -0
- package/src/video/timeline/AudioTrackRow.tsx +406 -0
- package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
- package/src/video/timeline/EditableSegment.tsx +30 -0
- package/src/video/timeline/Scrubber.tsx +184 -0
- package/src/video/timeline/Timeline.tsx +375 -0
- package/src/video/timeline/TimelineContext.ts +25 -0
- package/src/video/timeline/TranscriptModal.tsx +63 -0
- package/src/video/timeline/TranscriptPanel.tsx +86 -0
- package/src/video/timeline/VisualTrackRow.tsx +293 -0
- package/src/video/timeline/makeCaptionEdit.ts +32 -0
- package/src/video/timeline/multiSelectOps.ts +157 -0
- package/src/video/timeline/useItemDragDrop.ts +190 -0
- package/src/video/timeline/useTimelineZoom.ts +48 -0
- 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
|
+
}
|