@devbycrux/editor 0.1.0 → 0.3.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/carousel/CarouselEditor.tsx +28 -16
- package/src/carousel/SlideCanvas.tsx +12 -0
- package/src/carousel/SlidePropertyPanel.tsx +40 -2
- package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
- package/src/index.ts +23 -1
- package/src/types.ts +161 -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,190 @@
|
|
|
1
|
+
import type React from 'react'
|
|
2
|
+
|
|
3
|
+
export interface Draggable {
|
|
4
|
+
id: string
|
|
5
|
+
start: number
|
|
6
|
+
end: number
|
|
7
|
+
inPoint?: number
|
|
8
|
+
outPoint?: number
|
|
9
|
+
type?: string
|
|
10
|
+
sourceDuration?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DragEventContext {
|
|
14
|
+
/** The item with updated start/end (and inPoint/outPoint for resize) */
|
|
15
|
+
item: Draggable
|
|
16
|
+
/** Horizontal delta in timeline seconds from initial position */
|
|
17
|
+
dx: number
|
|
18
|
+
/** Vertical delta in raw pixels from initial position */
|
|
19
|
+
dy: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseItemDragDropConfig {
|
|
23
|
+
totalDuration: number
|
|
24
|
+
snapBoundaries: number[]
|
|
25
|
+
snapThresholdPx?: number // default 8
|
|
26
|
+
/** Element used for getBoundingClientRect() → pixel-to-time conversion */
|
|
27
|
+
scrollRef: React.RefObject<HTMLDivElement | null>
|
|
28
|
+
/** Timeline zoom factor — content width is zoom × scrollRef.width. Required so
|
|
29
|
+
* pixel-to-time conversion matches the visible scale (without this, drag/resize
|
|
30
|
+
* is `zoom`× too sensitive when zoomed in). */
|
|
31
|
+
zoomRef?: React.RefObject<number>
|
|
32
|
+
draggedFlagRef?: React.MutableRefObject<boolean> // sets true during drag (click suppression)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useItemDragDrop(config: UseItemDragDropConfig) {
|
|
36
|
+
const {
|
|
37
|
+
totalDuration,
|
|
38
|
+
snapBoundaries,
|
|
39
|
+
snapThresholdPx = 8,
|
|
40
|
+
scrollRef,
|
|
41
|
+
zoomRef,
|
|
42
|
+
draggedFlagRef,
|
|
43
|
+
} = config
|
|
44
|
+
|
|
45
|
+
function beginResize(
|
|
46
|
+
e: React.MouseEvent,
|
|
47
|
+
item: Draggable,
|
|
48
|
+
edge: 'start' | 'end',
|
|
49
|
+
callbacks: {
|
|
50
|
+
onLivePreview: (ctx: DragEventContext) => void
|
|
51
|
+
onCommit: () => void
|
|
52
|
+
},
|
|
53
|
+
): void {
|
|
54
|
+
e.stopPropagation()
|
|
55
|
+
e.preventDefault()
|
|
56
|
+
|
|
57
|
+
const initX = e.clientX
|
|
58
|
+
const initTime = edge === 'start' ? item.start : item.end
|
|
59
|
+
|
|
60
|
+
function computeResized(moveE: MouseEvent): Draggable {
|
|
61
|
+
if (!scrollRef.current) return item
|
|
62
|
+
const rect = scrollRef.current.getBoundingClientRect()
|
|
63
|
+
const contentWidth = rect.width * (zoomRef?.current ?? 1)
|
|
64
|
+
const dt = ((moveE.clientX - initX) / contentWidth) * totalDuration
|
|
65
|
+
const raw = Math.max(0, Math.min(totalDuration, initTime + dt))
|
|
66
|
+
|
|
67
|
+
// Snap to any boundary within threshold
|
|
68
|
+
const snapThreshold = (snapThresholdPx / contentWidth) * totalDuration
|
|
69
|
+
let t = raw
|
|
70
|
+
let bestDist = snapThreshold
|
|
71
|
+
for (const b of snapBoundaries) {
|
|
72
|
+
const d = Math.abs(raw - b)
|
|
73
|
+
if (d < bestDist) {
|
|
74
|
+
bestDist = d
|
|
75
|
+
t = b
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (edge === 'start') {
|
|
80
|
+
const newStart = Math.min(t, item.end - 0.1)
|
|
81
|
+
if (item.type !== 'video') return { ...item, start: newStart }
|
|
82
|
+
const dtActual = newStart - item.start
|
|
83
|
+
return {
|
|
84
|
+
...item,
|
|
85
|
+
start: newStart,
|
|
86
|
+
inPoint: Math.min(
|
|
87
|
+
Math.max(0, (item.inPoint ?? 0) + dtActual),
|
|
88
|
+
(item.outPoint ?? (item.inPoint ?? 0) + (item.end - item.start)) - 0.1,
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
const newEnd = Math.max(t, item.start + 0.1)
|
|
93
|
+
if (item.type !== 'video') return { ...item, end: newEnd }
|
|
94
|
+
const origOut = item.outPoint ?? (item.inPoint ?? 0) + (item.end - item.start)
|
|
95
|
+
const dtActual = newEnd - item.end
|
|
96
|
+
return {
|
|
97
|
+
...item,
|
|
98
|
+
end: newEnd,
|
|
99
|
+
outPoint: Math.max(
|
|
100
|
+
(item.inPoint ?? 0) + 0.1,
|
|
101
|
+
Math.min(origOut + dtActual, item.sourceDuration ?? Infinity),
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onMove(moveE: MouseEvent) {
|
|
108
|
+
const resized = computeResized(moveE)
|
|
109
|
+
callbacks.onLivePreview({ item: resized, dx: 0, dy: 0 })
|
|
110
|
+
}
|
|
111
|
+
function onUp() {
|
|
112
|
+
callbacks.onCommit()
|
|
113
|
+
document.removeEventListener('mousemove', onMove)
|
|
114
|
+
document.removeEventListener('mouseup', onUp)
|
|
115
|
+
}
|
|
116
|
+
document.addEventListener('mousemove', onMove)
|
|
117
|
+
document.addEventListener('mouseup', onUp)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function beginDrag(
|
|
121
|
+
e: React.MouseEvent,
|
|
122
|
+
item: Draggable,
|
|
123
|
+
callbacks: {
|
|
124
|
+
onLivePreview: (ctx: DragEventContext) => void
|
|
125
|
+
onCommit: () => void
|
|
126
|
+
},
|
|
127
|
+
): void {
|
|
128
|
+
e.stopPropagation()
|
|
129
|
+
|
|
130
|
+
const initX = e.clientX
|
|
131
|
+
const initY = e.clientY
|
|
132
|
+
const initStart = item.start
|
|
133
|
+
const initEnd = item.end
|
|
134
|
+
const duration = initEnd - initStart
|
|
135
|
+
|
|
136
|
+
function onMove(moveE: MouseEvent) {
|
|
137
|
+
if (draggedFlagRef) draggedFlagRef.current = true
|
|
138
|
+
|
|
139
|
+
const rect = scrollRef.current?.getBoundingClientRect()
|
|
140
|
+
const contentWidth = rect ? rect.width * (zoomRef?.current ?? 1) : 0
|
|
141
|
+
const dx = contentWidth ? ((moveE.clientX - initX) / contentWidth) * totalDuration : 0
|
|
142
|
+
const dy = moveE.clientY - initY
|
|
143
|
+
|
|
144
|
+
const rawStart = Math.max(0, Math.min(totalDuration - duration, initStart + dx))
|
|
145
|
+
const rawEnd = rawStart + duration
|
|
146
|
+
|
|
147
|
+
// Snap leading/trailing edge to nearest item edge within threshold
|
|
148
|
+
const snapThreshold = contentWidth ? (snapThresholdPx / contentWidth) * totalDuration : 0
|
|
149
|
+
let newStart = rawStart
|
|
150
|
+
let newEnd = rawEnd
|
|
151
|
+
let bestDist = snapThreshold
|
|
152
|
+
for (const b of snapBoundaries) {
|
|
153
|
+
const dEnd = Math.abs(rawEnd - b)
|
|
154
|
+
if (dEnd < bestDist) {
|
|
155
|
+
bestDist = dEnd
|
|
156
|
+
newStart = b - duration
|
|
157
|
+
newEnd = b
|
|
158
|
+
}
|
|
159
|
+
const dStart = Math.abs(rawStart - b)
|
|
160
|
+
if (dStart < bestDist) {
|
|
161
|
+
bestDist = dStart
|
|
162
|
+
newStart = b
|
|
163
|
+
newEnd = b + duration
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
newStart = Math.max(0, Math.min(totalDuration - duration, newStart))
|
|
167
|
+
newEnd = newStart + duration
|
|
168
|
+
|
|
169
|
+
const movedItem = { ...item, start: newStart, end: newEnd }
|
|
170
|
+
callbacks.onLivePreview({ item: movedItem, dx, dy })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function onUp() {
|
|
174
|
+
document.removeEventListener('mousemove', onMove)
|
|
175
|
+
document.removeEventListener('mouseup', onUp)
|
|
176
|
+
if (draggedFlagRef && draggedFlagRef.current) {
|
|
177
|
+
callbacks.onCommit()
|
|
178
|
+
// reset after click event fires
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
draggedFlagRef!.current = false
|
|
181
|
+
}, 0)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
document.addEventListener('mousemove', onMove)
|
|
186
|
+
document.addEventListener('mouseup', onUp)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { beginDrag, beginResize }
|
|
190
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export function useTimelineZoom(totalDuration: number) {
|
|
4
|
+
const [zoom, setZoom] = useState(1)
|
|
5
|
+
const zoomRef = useRef(zoom)
|
|
6
|
+
zoomRef.current = zoom
|
|
7
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
8
|
+
const pendingScrollRef = useRef<number | null>(null)
|
|
9
|
+
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
if (pendingScrollRef.current !== null && scrollRef.current) {
|
|
12
|
+
scrollRef.current.scrollLeft = pendingScrollRef.current
|
|
13
|
+
pendingScrollRef.current = null
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
function zoomTo(newZoom: number, pivotClientX?: number) {
|
|
18
|
+
const clamped = Math.max(1, Math.min(20, newZoom))
|
|
19
|
+
if (!scrollRef.current || totalDuration === 0) { setZoom(clamped); return }
|
|
20
|
+
const container = scrollRef.current
|
|
21
|
+
const containerWidth = container.clientWidth
|
|
22
|
+
const currentScrollLeft = container.scrollLeft
|
|
23
|
+
const rect = container.getBoundingClientRect()
|
|
24
|
+
const pivotOffset = pivotClientX !== undefined
|
|
25
|
+
? Math.max(0, Math.min(containerWidth, pivotClientX - rect.left))
|
|
26
|
+
: containerWidth / 2
|
|
27
|
+
const pivotPct = Math.max(0, Math.min(1,
|
|
28
|
+
(currentScrollLeft + pivotOffset) / (containerWidth * zoomRef.current)
|
|
29
|
+
))
|
|
30
|
+
pendingScrollRef.current = Math.max(0, pivotPct * containerWidth * clamped - pivotOffset)
|
|
31
|
+
setZoom(clamped)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function handleTimelineWheel(e: React.WheelEvent) {
|
|
35
|
+
if (e.ctrlKey || e.metaKey) {
|
|
36
|
+
e.preventDefault()
|
|
37
|
+
// Multiplicative step so perceived speed is consistent across zoom levels.
|
|
38
|
+
// deltaY ≈ 100 per mouse-wheel tick → factor ≈ 0.82 (≈ 18% zoom change).
|
|
39
|
+
const factor = Math.exp(-e.deltaY * 0.002)
|
|
40
|
+
zoomTo(zoomRef.current * factor, e.clientX)
|
|
41
|
+
} else if (e.altKey) {
|
|
42
|
+
e.preventDefault()
|
|
43
|
+
if (scrollRef.current) scrollRef.current.scrollLeft += e.deltaY
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { zoom, zoomRef, scrollRef, pendingScrollRef, zoomTo, handleTimelineWheel }
|
|
48
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function formatTime(s: number): string {
|
|
2
|
+
const m = Math.floor(s / 60)
|
|
3
|
+
const sec = (s % 60).toFixed(1)
|
|
4
|
+
return `${m}:${sec.padStart(4, '0')}`
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function pct(t: number, totalDuration: number): number {
|
|
8
|
+
return totalDuration > 0 ? (t / totalDuration) * 100 : 0
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ratioFromClientX(clientX: number, scrubberRect: DOMRect): number {
|
|
12
|
+
const x = clientX - scrubberRect.left
|
|
13
|
+
return Math.max(0, Math.min(1, x / scrubberRect.width))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const trackRow = 'relative h-10 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer'
|
|
17
|
+
export const trackRowTall = 'relative h-14 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer'
|