@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,178 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { EditorProject as Project } from '../../schema'
|
|
3
|
+
import type { OverlayFactory } from '../../types'
|
|
4
|
+
import CaptionPreview from './CaptionPreview'
|
|
5
|
+
import { getOverlayDesignCanvas } from '../design-canvas'
|
|
6
|
+
import { useDragOverlay } from './useDragOverlay'
|
|
7
|
+
import OverlayItemsLayer from './OverlayItemsLayer'
|
|
8
|
+
import { useVideoPlayback } from './useVideoPlayback'
|
|
9
|
+
import CarouselPreview from './CarouselPreview'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
interface PreviewPlayerProps {
|
|
14
|
+
project: Project
|
|
15
|
+
currentTime: number
|
|
16
|
+
onTimeUpdate: (t: number) => void
|
|
17
|
+
selectedOverlayId?: string
|
|
18
|
+
onOverlayChange?: (id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) => void
|
|
19
|
+
// Adapter-injected capabilities
|
|
20
|
+
compileOverlay: (src: string) => Promise<OverlayFactory>
|
|
21
|
+
clearOverlayCache?: (src?: string) => void
|
|
22
|
+
watchFile?: (path: string, onChange: () => void) => () => void
|
|
23
|
+
fileUrl: (path: string) => string
|
|
24
|
+
resolveCaptionTemplate?: (style: string) => string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function PreviewPlayer({
|
|
28
|
+
project,
|
|
29
|
+
currentTime,
|
|
30
|
+
onTimeUpdate,
|
|
31
|
+
selectedOverlayId,
|
|
32
|
+
onOverlayChange,
|
|
33
|
+
compileOverlay,
|
|
34
|
+
clearOverlayCache,
|
|
35
|
+
watchFile,
|
|
36
|
+
fileUrl,
|
|
37
|
+
resolveCaptionTemplate,
|
|
38
|
+
}: PreviewPlayerProps) {
|
|
39
|
+
if (project.projectType === 'carousel') return <CarouselPreview project={project} />
|
|
40
|
+
|
|
41
|
+
const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
|
|
42
|
+
|
|
43
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
44
|
+
const [renderScale, setRenderScale] = useState<number>(1)
|
|
45
|
+
|
|
46
|
+
// Track container size to scale overlay components from 1080×1920 → preview size
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const el = containerRef.current
|
|
49
|
+
if (!el) return
|
|
50
|
+
const obs = new ResizeObserver(([entry]) => {
|
|
51
|
+
setRenderScale(entry.contentRect.width / RENDER_W)
|
|
52
|
+
})
|
|
53
|
+
obs.observe(el)
|
|
54
|
+
return () => obs.disconnect()
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
// ── Drag state ────────────────────────────────────────────────────────────
|
|
58
|
+
const {
|
|
59
|
+
dragState, setDragState,
|
|
60
|
+
liveOffset, liveScale, liveRotation,
|
|
61
|
+
snapGuides, snapRotation,
|
|
62
|
+
} = useDragOverlay(containerRef, onOverlayChange)
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
video0Ref,
|
|
66
|
+
video1Ref,
|
|
67
|
+
activeSlotRef,
|
|
68
|
+
activeSlot,
|
|
69
|
+
showVideo,
|
|
70
|
+
isPlaying,
|
|
71
|
+
setIsPlaying,
|
|
72
|
+
handleTimeUpdate,
|
|
73
|
+
handlePause,
|
|
74
|
+
handleEnded,
|
|
75
|
+
togglePlay,
|
|
76
|
+
isCanvasProject,
|
|
77
|
+
clips,
|
|
78
|
+
tracks0NonVideo,
|
|
79
|
+
overlayTracks,
|
|
80
|
+
} = useVideoPlayback(project, currentTime, onTimeUpdate, fileUrl)
|
|
81
|
+
|
|
82
|
+
const captionTrack = useMemo(() => project.captions, [project])
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div ref={containerRef} className="relative bg-black h-full max-w-full overflow-hidden rounded" style={{ aspectRatio: `${RENDER_W} / ${RENDER_H}`, isolation: 'isolate' }}>
|
|
86
|
+
{isCanvasProject ? (
|
|
87
|
+
<div className="absolute inset-0 cursor-pointer" style={{ zIndex: 10 }} onClick={togglePlay} />
|
|
88
|
+
) : clips.length === 0 ? (
|
|
89
|
+
<div className="absolute inset-0 flex items-center justify-center text-gray-600 text-sm">
|
|
90
|
+
No clips
|
|
91
|
+
</div>
|
|
92
|
+
) : (
|
|
93
|
+
<>
|
|
94
|
+
{/* Slot 0 */}
|
|
95
|
+
<video
|
|
96
|
+
ref={video0Ref}
|
|
97
|
+
className="absolute inset-0 w-full h-full object-contain"
|
|
98
|
+
onTimeUpdate={() => { if (activeSlotRef.current === 0) handleTimeUpdate() }}
|
|
99
|
+
onEnded={() => { if (activeSlotRef.current === 0) handleEnded() }}
|
|
100
|
+
onPlay={() => { if (activeSlotRef.current === 0) setIsPlaying(true) }}
|
|
101
|
+
onPause={() => { if (activeSlotRef.current === 0) handlePause() }}
|
|
102
|
+
playsInline
|
|
103
|
+
style={{ opacity: showVideo && activeSlot === 0 ? 1 : 0, pointerEvents: activeSlot === 0 ? 'auto' : 'none', zIndex: activeSlot === 0 ? 1 : 0 }}
|
|
104
|
+
/>
|
|
105
|
+
{/* Slot 1 */}
|
|
106
|
+
<video
|
|
107
|
+
ref={video1Ref}
|
|
108
|
+
className="absolute inset-0 w-full h-full object-contain"
|
|
109
|
+
onTimeUpdate={() => { if (activeSlotRef.current === 1) handleTimeUpdate() }}
|
|
110
|
+
onEnded={() => { if (activeSlotRef.current === 1) handleEnded() }}
|
|
111
|
+
onPlay={() => { if (activeSlotRef.current === 1) setIsPlaying(true) }}
|
|
112
|
+
onPause={() => { if (activeSlotRef.current === 1) handlePause() }}
|
|
113
|
+
playsInline
|
|
114
|
+
style={{ opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
|
|
115
|
+
/>
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
{/* Montaj play/pause control — covers the active video area */}
|
|
121
|
+
{!isCanvasProject && clips.length > 0 && (
|
|
122
|
+
<div
|
|
123
|
+
className="absolute inset-0 cursor-pointer"
|
|
124
|
+
style={{ zIndex: 10 }}
|
|
125
|
+
onClick={togglePlay}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Play button overlay — shown when paused */}
|
|
130
|
+
{!isPlaying && (clips.length > 0 || isCanvasProject) && (
|
|
131
|
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ zIndex: 100 }}>
|
|
132
|
+
<div className="w-14 h-14 rounded-full bg-black/50 flex items-center justify-center">
|
|
133
|
+
<svg className="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
|
134
|
+
<path d="M8 5v14l11-7z" />
|
|
135
|
+
</svg>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
<OverlayItemsLayer
|
|
141
|
+
project={project}
|
|
142
|
+
currentTime={currentTime}
|
|
143
|
+
isPlaying={isPlaying}
|
|
144
|
+
isCanvasProject={isCanvasProject}
|
|
145
|
+
overlayTracks={overlayTracks}
|
|
146
|
+
tracks0NonVideo={tracks0NonVideo}
|
|
147
|
+
renderScale={renderScale}
|
|
148
|
+
selectedOverlayId={selectedOverlayId}
|
|
149
|
+
onOverlayChange={onOverlayChange}
|
|
150
|
+
containerRef={containerRef}
|
|
151
|
+
dragState={dragState}
|
|
152
|
+
setDragState={setDragState}
|
|
153
|
+
liveOffset={liveOffset}
|
|
154
|
+
liveScale={liveScale}
|
|
155
|
+
liveRotation={liveRotation}
|
|
156
|
+
snapGuides={snapGuides}
|
|
157
|
+
snapRotation={snapRotation}
|
|
158
|
+
compileOverlay={compileOverlay}
|
|
159
|
+
clearOverlayCache={clearOverlayCache}
|
|
160
|
+
watchFile={watchFile}
|
|
161
|
+
fileUrl={fileUrl}
|
|
162
|
+
/>
|
|
163
|
+
|
|
164
|
+
{/* Audio elements are managed programmatically in useVideoPlayback */}
|
|
165
|
+
|
|
166
|
+
{/* Caption preview */}
|
|
167
|
+
{captionTrack && (
|
|
168
|
+
<CaptionPreview
|
|
169
|
+
track={captionTrack}
|
|
170
|
+
currentTime={currentTime}
|
|
171
|
+
fps={project.settings?.fps ?? 30}
|
|
172
|
+
compileOverlay={compileOverlay}
|
|
173
|
+
resolveCaptionTemplate={resolveCaptionTemplate}
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export type Corner = 'nw' | 'ne' | 'sw' | 'se'
|
|
4
|
+
export type DragType = 'move' | `resize-${Corner}` | 'rotate'
|
|
5
|
+
|
|
6
|
+
const SNAP_THRESHOLD = 2.5 // % of container
|
|
7
|
+
const ROT_SNAP_ANGLES = [0, 90, 180, 270]
|
|
8
|
+
const ROT_ATTRACT_DEG = 5 // snap in within ±5°
|
|
9
|
+
const ROT_RELEASE_DEG = 8 // break free after ±8°
|
|
10
|
+
|
|
11
|
+
interface DragState {
|
|
12
|
+
id: string
|
|
13
|
+
type: DragType
|
|
14
|
+
initX: number
|
|
15
|
+
initY: number
|
|
16
|
+
initOffsetX: number
|
|
17
|
+
initOffsetY: number
|
|
18
|
+
initScale: number
|
|
19
|
+
initRotation: number
|
|
20
|
+
// rotate-specific: center of element in page coords and initial angle
|
|
21
|
+
cx?: number
|
|
22
|
+
cy?: number
|
|
23
|
+
initAngle?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useDragOverlay(
|
|
27
|
+
containerRef: React.RefObject<HTMLDivElement | null>,
|
|
28
|
+
onOverlayChange?: (id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) => void,
|
|
29
|
+
) {
|
|
30
|
+
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
31
|
+
|
|
32
|
+
const [liveOffset, setLiveOffset] = useState<{ id: string; x: number; y: number } | null>(null)
|
|
33
|
+
const [liveScale, setLiveScale] = useState<{ id: string; scale: number } | null>(null)
|
|
34
|
+
const [liveRotation, setLiveRotation] = useState<{ id: string; rotation: number } | null>(null)
|
|
35
|
+
const liveOffsetRef = useRef<typeof liveOffset>(null)
|
|
36
|
+
const liveScaleRef = useRef<typeof liveScale>(null)
|
|
37
|
+
const liveRotationRef = useRef<typeof liveRotation>(null)
|
|
38
|
+
|
|
39
|
+
// Snap guide visibility
|
|
40
|
+
const [snapGuides, setSnapGuides] = useState({ x: false, y: false, left: false, right: false, top: false, bottom: false })
|
|
41
|
+
const [snapRotation, setSnapRotation] = useState<number | null>(null)
|
|
42
|
+
const prevSnapRef = useRef({ x: false, y: false, left: false, right: false, top: false, bottom: false })
|
|
43
|
+
const prevSnapRotRef = useRef<number | null>(null)
|
|
44
|
+
|
|
45
|
+
useEffect(() => { liveOffsetRef.current = liveOffset }, [liveOffset])
|
|
46
|
+
useEffect(() => { liveScaleRef.current = liveScale }, [liveScale])
|
|
47
|
+
useEffect(() => { liveRotationRef.current = liveRotation }, [liveRotation])
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!dragState) return
|
|
51
|
+
|
|
52
|
+
function onMove(e: MouseEvent) {
|
|
53
|
+
const rect = containerRef.current?.getBoundingClientRect()
|
|
54
|
+
if (!rect || !dragState) return
|
|
55
|
+
|
|
56
|
+
const dx = ((e.clientX - dragState.initX) / rect.width) * 100 // %
|
|
57
|
+
const dy = ((e.clientY - dragState.initY) / rect.height) * 100 // %
|
|
58
|
+
|
|
59
|
+
if (dragState.type === 'move') {
|
|
60
|
+
const rawX = dragState.initOffsetX + dx
|
|
61
|
+
const rawY = dragState.initOffsetY + dy
|
|
62
|
+
|
|
63
|
+
// Edge snap positions depend on scale.
|
|
64
|
+
// Element is inset-0 (fills container) then scaled from center.
|
|
65
|
+
// Left edge hits screen left / right edge hits screen right when offsetX = ±(0.5 - s/2)*100.
|
|
66
|
+
// For scale=1 this is 0 (same as center snap), so edge snap only activates for scaled-down items.
|
|
67
|
+
const s = dragState.initScale
|
|
68
|
+
const edgeX = (0.5 - s / 2) * 100 // offset where element edge meets screen edge
|
|
69
|
+
const edgeY = (0.5 - s / 2) * 100
|
|
70
|
+
const hasEdgeX = edgeX > SNAP_THRESHOLD // skip if too close to center snap
|
|
71
|
+
const hasEdgeY = edgeY > SNAP_THRESHOLD
|
|
72
|
+
|
|
73
|
+
const snapX = Math.abs(rawX) < SNAP_THRESHOLD
|
|
74
|
+
const snapY = Math.abs(rawY) < SNAP_THRESHOLD
|
|
75
|
+
const snapLeft = hasEdgeX && !snapX && Math.abs(rawX - (-edgeX)) < SNAP_THRESHOLD
|
|
76
|
+
const snapRight = hasEdgeX && !snapX && Math.abs(rawX - edgeX) < SNAP_THRESHOLD
|
|
77
|
+
const snapTop = hasEdgeY && !snapY && Math.abs(rawY - (-edgeY)) < SNAP_THRESHOLD
|
|
78
|
+
const snapBottom = hasEdgeY && !snapY && Math.abs(rawY - edgeY) < SNAP_THRESHOLD
|
|
79
|
+
|
|
80
|
+
// Haptic on snap entry
|
|
81
|
+
if (snapX && !prevSnapRef.current.x) navigator.vibrate?.(10)
|
|
82
|
+
if (snapY && !prevSnapRef.current.y) navigator.vibrate?.(10)
|
|
83
|
+
if (snapLeft && !prevSnapRef.current.left) navigator.vibrate?.(10)
|
|
84
|
+
if (snapRight && !prevSnapRef.current.right) navigator.vibrate?.(10)
|
|
85
|
+
if (snapTop && !prevSnapRef.current.top) navigator.vibrate?.(10)
|
|
86
|
+
if (snapBottom && !prevSnapRef.current.bottom) navigator.vibrate?.(10)
|
|
87
|
+
prevSnapRef.current = { x: snapX, y: snapY, left: snapLeft, right: snapRight, top: snapTop, bottom: snapBottom }
|
|
88
|
+
|
|
89
|
+
setSnapGuides({ x: snapX, y: snapY, left: snapLeft, right: snapRight, top: snapTop, bottom: snapBottom })
|
|
90
|
+
const finalX = snapX ? 0 : snapLeft ? -edgeX : snapRight ? edgeX : rawX
|
|
91
|
+
const finalY = snapY ? 0 : snapTop ? -edgeY : snapBottom ? edgeY : rawY
|
|
92
|
+
const next = { id: dragState.id, x: finalX, y: finalY }
|
|
93
|
+
setLiveOffset(next)
|
|
94
|
+
liveOffsetRef.current = next
|
|
95
|
+
} else if (dragState.type === 'rotate') {
|
|
96
|
+
const curAngle = Math.atan2(e.clientY - dragState.cy!, e.clientX - dragState.cx!)
|
|
97
|
+
const delta = (curAngle - dragState.initAngle!) * (180 / Math.PI)
|
|
98
|
+
const raw = ((dragState.initRotation + delta) % 360 + 360) % 360
|
|
99
|
+
|
|
100
|
+
// Snap to 90° increments with attract/release hysteresis
|
|
101
|
+
let snapped: number | null = null
|
|
102
|
+
if (prevSnapRotRef.current !== null) {
|
|
103
|
+
const diff = Math.abs(((raw - prevSnapRotRef.current) + 180) % 360 - 180)
|
|
104
|
+
if (diff < ROT_RELEASE_DEG) snapped = prevSnapRotRef.current
|
|
105
|
+
}
|
|
106
|
+
if (snapped === null) {
|
|
107
|
+
for (const angle of ROT_SNAP_ANGLES) {
|
|
108
|
+
const diff = Math.abs(((raw - angle) + 180) % 360 - 180)
|
|
109
|
+
if (diff < ROT_ATTRACT_DEG) { snapped = angle; break }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (snapped !== prevSnapRotRef.current) {
|
|
113
|
+
if (snapped !== null) navigator.vibrate?.(10)
|
|
114
|
+
prevSnapRotRef.current = snapped
|
|
115
|
+
setSnapRotation(snapped)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const finalRotation = snapped ?? raw
|
|
119
|
+
const next = { id: dragState.id, rotation: finalRotation }
|
|
120
|
+
setLiveRotation(next)
|
|
121
|
+
liveRotationRef.current = next
|
|
122
|
+
} else {
|
|
123
|
+
// Resize from corner
|
|
124
|
+
const corner = dragState.type.slice(7) as Corner // 'resize-se' → 'se'
|
|
125
|
+
const sx = corner.includes('e') ? 1 : -1
|
|
126
|
+
const sy = corner.includes('s') ? 1 : -1
|
|
127
|
+
const delta = (dx * sx + dy * sy) / 100
|
|
128
|
+
const newScale = Math.max(0.1, dragState.initScale * (1 + delta))
|
|
129
|
+
const next = { id: dragState.id, scale: newScale }
|
|
130
|
+
setLiveScale(next)
|
|
131
|
+
liveScaleRef.current = next
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function onUp() {
|
|
136
|
+
const lo = liveOffsetRef.current
|
|
137
|
+
const ls = liveScaleRef.current
|
|
138
|
+
const lr = liveRotationRef.current
|
|
139
|
+
const changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number } = {}
|
|
140
|
+
if (lo) { changes.offsetX = lo.x; changes.offsetY = lo.y }
|
|
141
|
+
if (ls) { changes.scale = ls.scale }
|
|
142
|
+
if (lr) { changes.rotation = lr.rotation }
|
|
143
|
+
if (Object.keys(changes).length) onOverlayChange?.(dragState!.id, changes)
|
|
144
|
+
setDragState(null)
|
|
145
|
+
setLiveOffset(null)
|
|
146
|
+
setLiveScale(null)
|
|
147
|
+
setLiveRotation(null)
|
|
148
|
+
setSnapGuides({ x: false, y: false, left: false, right: false, top: false, bottom: false })
|
|
149
|
+
setSnapRotation(null)
|
|
150
|
+
prevSnapRef.current = { x: false, y: false, left: false, right: false, top: false, bottom: false }
|
|
151
|
+
prevSnapRotRef.current = null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
document.addEventListener('mousemove', onMove)
|
|
155
|
+
document.addEventListener('mouseup', onUp)
|
|
156
|
+
return () => {
|
|
157
|
+
document.removeEventListener('mousemove', onMove)
|
|
158
|
+
document.removeEventListener('mouseup', onUp)
|
|
159
|
+
}
|
|
160
|
+
}, [dragState])
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
dragState, setDragState,
|
|
164
|
+
liveOffset, liveScale, liveRotation,
|
|
165
|
+
snapGuides, snapRotation,
|
|
166
|
+
}
|
|
167
|
+
}
|