@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,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
+ }