@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,369 @@
1
+ import type { Project } from '../types'
2
+ import type { VisualItem, AudioTrack, CaptionSegment, Word } from '../schema'
3
+
4
+ /** A time range to excise from the timeline. */
5
+ export interface Cut {
6
+ start: number
7
+ end: number
8
+ }
9
+
10
+ // ── ID generation ───────────────────────────────────────────────────────────
11
+
12
+ function uniqueId(base: string): string {
13
+ return `${base}_split_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
14
+ }
15
+
16
+ // ── Single base-clip helpers ────────────────────────────────────────────────
17
+
18
+ function trimClipEnd(item: VisualItem, at: number): VisualItem {
19
+ const trimmedDur = at - item.start
20
+ return {
21
+ ...item,
22
+ end: at,
23
+ ...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + trimmedDur } : {}),
24
+ }
25
+ }
26
+
27
+ function trimClipStart(item: VisualItem, at: number): VisualItem {
28
+ // Lift-style: clip start advances to the cut end; inPoint advances by the same amount.
29
+ // The resulting gap before the clip's new start position is intentional.
30
+ const sourceOffset = at - item.start
31
+ return {
32
+ ...item,
33
+ start: at,
34
+ ...(item.inPoint !== undefined ? { inPoint: (item.inPoint ?? 0) + sourceOffset } : {}),
35
+ }
36
+ }
37
+
38
+ function splitClip(item: VisualItem, cut: Cut): [VisualItem, VisualItem] {
39
+ const leftDur = cut.start - item.start
40
+ const rightSourceOffset = cut.end - item.start
41
+
42
+ const left: VisualItem = {
43
+ ...item,
44
+ end: cut.start,
45
+ ...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + leftDur } : {}),
46
+ }
47
+ const right: VisualItem = {
48
+ ...item,
49
+ id: uniqueId(item.id),
50
+ start: cut.end, // lift: right fragment stays at its original timeline position
51
+ ...(item.inPoint !== undefined ? { inPoint: (item.inPoint ?? 0) + rightSourceOffset } : {}),
52
+ }
53
+ return [left, right]
54
+ }
55
+
56
+ function applyCutToBaseClip(item: VisualItem, cut: Cut): VisualItem[] {
57
+ const { start: A, end: B } = cut
58
+
59
+ if (item.end <= A) return [item] // fully before — unchanged
60
+ if (item.start >= B) return [item] // fully after — unchanged (lift, no shift)
61
+ if (item.start >= A && item.end <= B) return [] // fully within — deleted
62
+
63
+ if (item.start < A && item.end <= B) return [trimClipEnd(item, A)] // overlaps left
64
+ if (item.start >= A && item.start < B) return [trimClipStart(item, B)] // overlaps right
65
+ return splitClip(item, cut) // spans
66
+ }
67
+
68
+ // ── Caption helpers (captions shift — they're anchored to audio timing) ────
69
+
70
+ function applyCutToWords(words: Word[], cut: Cut): Word[] {
71
+ const cutDur = cut.end - cut.start
72
+ return words
73
+ .filter(w => !(w.start >= cut.start && w.end <= cut.end))
74
+ .map(w => {
75
+ if (w.end <= cut.start) return w
76
+ if (w.start >= cut.end) return { ...w, start: w.start - cutDur, end: w.end - cutDur }
77
+ if (w.start < cut.start) return { ...w, end: cut.start }
78
+ return { ...w, start: cut.start, end: w.end - cutDur }
79
+ })
80
+ .filter(w => w.end > w.start)
81
+ }
82
+
83
+ function applyCutToCaptions(segments: CaptionSegment[], cut: Cut): CaptionSegment[] {
84
+ const cutDur = cut.end - cut.start
85
+ const result: CaptionSegment[] = []
86
+
87
+ for (const seg of segments) {
88
+ if (seg.end <= cut.start) { result.push(seg); continue }
89
+ if (seg.start >= cut.end) {
90
+ result.push({
91
+ ...seg,
92
+ start: seg.start - cutDur,
93
+ end: seg.end - cutDur,
94
+ words: seg.words?.map(w => ({ ...w, start: w.start - cutDur, end: w.end - cutDur })),
95
+ })
96
+ continue
97
+ }
98
+ if (seg.start >= cut.start && seg.end <= cut.end) continue // deleted
99
+
100
+ // Partial overlap or spanning: trim to the kept portion
101
+ const newStart = seg.start < cut.start ? seg.start : cut.start
102
+ const newEnd = seg.end > cut.end ? seg.end - cutDur : cut.start
103
+ if (newEnd <= newStart) continue
104
+ result.push({
105
+ ...seg,
106
+ start: newStart,
107
+ end: newEnd,
108
+ words: seg.words ? applyCutToWords(seg.words, cut).filter(w => w.end > w.start) : undefined,
109
+ })
110
+ }
111
+ return result
112
+ }
113
+
114
+ // ── Per-item collapse cut ────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Collapse a single item around a cut.
118
+ * `cut` must already be clamped to [item.start, item.end].
119
+ * Right fragment starts at cut.start (item shrinks; gap appears at item tail).
120
+ */
121
+ function cutSingleItem(item: VisualItem, cut: Cut): VisualItem[] {
122
+ const inPoint = item.inPoint ?? 0
123
+ const outPoint = item.outPoint ?? (inPoint + (item.end - item.start))
124
+
125
+ const physStart = inPoint + (cut.start - item.start)
126
+ const physEnd = inPoint + (cut.end - item.start)
127
+
128
+ const result: VisualItem[] = []
129
+
130
+ if (physStart > inPoint) {
131
+ result.push({
132
+ ...item,
133
+ end: cut.start,
134
+ ...(item.outPoint !== undefined ? { outPoint: physStart } : {}),
135
+ })
136
+ }
137
+ if (outPoint - physEnd > 0.001) {
138
+ result.push({
139
+ ...item,
140
+ id: uniqueId(item.id),
141
+ start: cut.start,
142
+ end: cut.start + (outPoint - physEnd),
143
+ ...(item.inPoint !== undefined ? { inPoint: physEnd } : {}),
144
+ })
145
+ }
146
+
147
+ return result
148
+ }
149
+
150
+ // ── Public API ──────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Apply a lift-style cut to a project.
154
+ *
155
+ * Only tracks[0] (primary clips) and captions are mutated.
156
+ * tracks[1+] overlay items are passed through unchanged — their start/end are
157
+ * absolute and they intentionally sit over any gap left in the primary track.
158
+ *
159
+ * Returns a new Project — no re-encoding, pure JSON.
160
+ */
161
+ export function applyCutToTracks<P extends Project>(project: P, cut: Cut): P {
162
+ if (cut.end <= cut.start) return project
163
+
164
+ const [primaryTrack = [], ...overlayTracks] = project.tracks ?? []
165
+ const newPrimaryTrack = primaryTrack.flatMap(item => applyCutToBaseClip(item, cut))
166
+
167
+ const newCaptions = project.captions
168
+ ? { ...project.captions, segments: applyCutToCaptions(project.captions.segments, cut) }
169
+ : project.captions
170
+
171
+ return { ...project, tracks: [newPrimaryTrack, ...overlayTracks], captions: newCaptions }
172
+ }
173
+
174
+ /**
175
+ * Close all gaps between primary clips by shifting each clip left to butt
176
+ * against the previous one. Captions and all other tracks are remapped to
177
+ * follow using the same shifts.
178
+ *
179
+ * The primary track is the first track containing video clips; falls back to
180
+ * tracks[0] if no video track exists.
181
+ *
182
+ * Returns the same project reference if no gaps exist (safe to call always).
183
+ */
184
+ export function collapseGaps<P extends Project>(project: P): P {
185
+ const tracks = project.tracks ?? []
186
+
187
+ const primaryIdx = tracks.findIndex(t => t.some(c => c.type === 'video'))
188
+ const effectiveIdx = primaryIdx >= 0 ? primaryIdx : 0
189
+
190
+ const primaryTrack = tracks[effectiveIdx] ?? []
191
+ if (primaryTrack.length < 2) return project
192
+
193
+ const sorted = [...primaryTrack].sort((a, b) => a.start - b.start)
194
+
195
+ let cursor = sorted[0].start
196
+ let anyGap = false
197
+ const shifts: Array<{ oldStart: number; oldEnd: number; delta: number }> = []
198
+
199
+ const compacted = sorted.map(clip => {
200
+ const duration = clip.end - clip.start
201
+ const delta = cursor - clip.start
202
+ if (delta !== 0) anyGap = true
203
+ shifts.push({ oldStart: clip.start, oldEnd: clip.end, delta })
204
+ const out = { ...clip, start: cursor, end: cursor + duration }
205
+ cursor += duration
206
+ return out
207
+ })
208
+
209
+ if (!anyGap) return project
210
+
211
+ function applyShift(start: number, end: number): number {
212
+ const mid = (start + end) / 2
213
+ const entry = shifts.find(s => mid >= s.oldStart && mid < s.oldEnd)
214
+ return entry?.delta ?? 0
215
+ }
216
+
217
+ const newTracks = tracks.map((track, i) => {
218
+ if (i === effectiveIdx) return compacted
219
+ return track.map(clip => {
220
+ const d = applyShift(clip.start, clip.end)
221
+ if (d === 0) return clip
222
+ return { ...clip, start: clip.start + d, end: clip.end + d }
223
+ })
224
+ })
225
+
226
+ let newCaptions = project.captions
227
+ if (newCaptions) {
228
+ const segments = newCaptions.segments.map(seg => {
229
+ const d = applyShift(seg.start, seg.end)
230
+ if (d === 0) return seg
231
+ return {
232
+ ...seg,
233
+ start: seg.start + d,
234
+ end: seg.end + d,
235
+ words: seg.words?.map(w => ({ ...w, start: w.start + d, end: w.end + d })),
236
+ }
237
+ })
238
+ newCaptions = { ...newCaptions, segments }
239
+ }
240
+
241
+ return { ...project, tracks: newTracks, captions: newCaptions }
242
+ }
243
+
244
+ /**
245
+ * Apply a collapse-style cut to a single item identified by `itemId`.
246
+ *
247
+ * - If the item is in `tracks[0]`, captions are adjusted for the clamped cut,
248
+ * but only for segments within [item.start, item.end] — other clips' captions
249
+ * are left untouched.
250
+ * - If the item is in an overlay track, captions are not touched.
251
+ * - If `itemId` is not found, the project is returned unchanged.
252
+ *
253
+ * Returns a new Project — no re-encoding, pure JSON.
254
+ */
255
+ export function applyCutToItem<P extends Project>(project: P, itemId: string, cut: Cut): P {
256
+ if (cut.end <= cut.start) return project
257
+
258
+ const [primaryTrack = [], ...overlayTracks] = project.tracks ?? []
259
+
260
+ // ── Primary track ──
261
+ const primaryIdx = primaryTrack.findIndex(item => item.id === itemId)
262
+ if (primaryIdx !== -1) {
263
+ const item = primaryTrack[primaryIdx]
264
+ const cutStart = Math.max(cut.start, item.start)
265
+ const cutEnd = Math.min(cut.end, item.end)
266
+ if (cutEnd <= cutStart) return project
267
+
268
+ const clamped = { start: cutStart, end: cutEnd }
269
+ const newPrimary = [
270
+ ...primaryTrack.slice(0, primaryIdx),
271
+ ...cutSingleItem(item, clamped),
272
+ ...primaryTrack.slice(primaryIdx + 1),
273
+ ]
274
+ // Only adjust captions within this clip's timeline window [item.start, item.end].
275
+ // Captions belonging to other clips must not be shifted — applyCutToCaptions shifts
276
+ // everything after cutEnd, which would misalign adjacent clips.
277
+ let newCaptions = project.captions
278
+ if (newCaptions) {
279
+ const inner = newCaptions.segments.filter(s => s.end > item.start && s.start < item.end)
280
+ const outer = newCaptions.segments.filter(s => !(s.end > item.start && s.start < item.end))
281
+ const adjusted = applyCutToCaptions(inner, clamped)
282
+ const merged = [...adjusted, ...outer].sort((a, b) => a.start - b.start)
283
+ newCaptions = { ...newCaptions, segments: merged }
284
+ }
285
+
286
+ return { ...project, tracks: [newPrimary, ...overlayTracks], captions: newCaptions }
287
+ }
288
+
289
+ // ── Overlay tracks ──
290
+ for (let ti = 0; ti < overlayTracks.length; ti++) {
291
+ const track = overlayTracks[ti]
292
+ const itemIdx = track.findIndex(item => item.id === itemId)
293
+ if (itemIdx === -1) continue
294
+
295
+ const item = track[itemIdx]
296
+ const cutStart = Math.max(cut.start, item.start)
297
+ const cutEnd = Math.min(cut.end, item.end)
298
+ if (cutEnd <= cutStart) return project
299
+
300
+ const clamped = { start: cutStart, end: cutEnd }
301
+ const newTrack = [
302
+ ...track.slice(0, itemIdx),
303
+ ...cutSingleItem(item, clamped),
304
+ ...track.slice(itemIdx + 1),
305
+ ]
306
+ const newOverlays = overlayTracks.map((t, i) => (i === ti ? newTrack : t))
307
+ return { ...project, tracks: [primaryTrack, ...newOverlays] }
308
+ }
309
+
310
+ return project // itemId not found
311
+ }
312
+
313
+ // ── Audio split helper ────────────────────────────────────────────────────
314
+
315
+ function splitAudioTrack(track: AudioTrack, at: number): [AudioTrack, AudioTrack] {
316
+ const inPoint = track.inPoint ?? 0
317
+ const sourceOffset = at - track.start
318
+
319
+ const left: AudioTrack = {
320
+ ...track,
321
+ end: at,
322
+ outPoint: inPoint + sourceOffset,
323
+ }
324
+ const right: AudioTrack = {
325
+ ...track,
326
+ id: uniqueId(track.id),
327
+ start: at,
328
+ inPoint: inPoint + sourceOffset,
329
+ }
330
+ return [left, right]
331
+ }
332
+
333
+ /**
334
+ * Split a clip at a single point in time, producing two adjacent clips with no gap.
335
+ *
336
+ * - If `itemId` is provided, only that item is split (must contain `at`).
337
+ * Works for both visual items (tracks[][]) and audio tracks (audio.tracks[]).
338
+ * - If `itemId` is null, every clip across all tracks that contains `at` is split.
339
+ * - Returns the same project reference if nothing was split.
340
+ */
341
+ export function splitAtTime<P extends Project>(project: P, at: number, itemId: string | null): P {
342
+ let changed = false
343
+
344
+ const newTracks = (project.tracks ?? []).map(track => {
345
+ const next = track.flatMap(item => {
346
+ if (itemId !== null && item.id !== itemId) return [item]
347
+ if (at <= item.start || at >= item.end) return [item] // playhead not inside this clip
348
+ changed = true
349
+ return splitClip(item, { start: at, end: at })
350
+ })
351
+ return next
352
+ })
353
+
354
+ // Also split audio tracks
355
+ const audioTracks = project.audio?.tracks ?? []
356
+ const newAudioTracks = audioTracks.flatMap(track => {
357
+ if (itemId !== null && track.id !== itemId) return [track]
358
+ if (at <= track.start || at >= track.end) return [track]
359
+ changed = true
360
+ return splitAudioTrack(track, at)
361
+ })
362
+
363
+ if (!changed) return project
364
+ return {
365
+ ...project,
366
+ tracks: newTracks,
367
+ audio: { ...project.audio, tracks: newAudioTracks },
368
+ }
369
+ }
@@ -0,0 +1,11 @@
1
+ // Overlay design canvas — must match montaj_assets/render/render.js.
2
+ // Overlays are authored in 1080-short-edge design coordinates regardless of
3
+ // the project's output resolution; the renderer upscales to settings.resolution
4
+ // at compose time via pixelRatio. The preview mirrors that here.
5
+ export function getOverlayDesignCanvas(
6
+ resolution: [number, number] | undefined | null,
7
+ ): [number, number] {
8
+ const [w, h] = resolution ?? [1080, 1920]
9
+ const ratio = 1080 / Math.min(w, h)
10
+ return [Math.round((w * ratio) / 2) * 2, Math.round((h * ratio) / 2) * 2]
11
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * CaptionPreview — renders the active caption style on top of the video player.
3
+ *
4
+ * Loads the exact same JSX template used by the render engine
5
+ * (render/templates/captions/<style>.jsx) so preview and final output
6
+ * are a single source of truth. Receives `compileOverlay` as a prop from
7
+ * PreviewPlayer (sourced from adapter.compileOverlay) so the package has no
8
+ * direct dependency on the host's overlay-eval module.
9
+ *
10
+ * The caption layer is sized at the native render resolution (1080 × 1920) and
11
+ * scaled down to fit the player via ResizeObserver so pixel values are 1:1 with
12
+ * the render output.
13
+ */
14
+
15
+ import { useEffect, useRef, useState } from 'react'
16
+ import type { Captions } from '../../schema'
17
+ import type { OverlayFactory } from '../../types'
18
+ import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
19
+
20
+ const RENDER_W = 1080
21
+ const RENDER_H = 1920
22
+
23
+ interface CaptionPreviewProps {
24
+ track: Captions
25
+ currentTime: number
26
+ fps: number
27
+ compileOverlay: (src: string) => Promise<OverlayFactory>
28
+ resolveCaptionTemplate?: (style: string) => string
29
+ }
30
+
31
+ export default function CaptionPreview({ track, currentTime, fps, compileOverlay, resolveCaptionTemplate }: CaptionPreviewProps) {
32
+ const wrapRef = useRef<HTMLDivElement>(null)
33
+ const [scale, setScale] = useState<number | null>(null)
34
+ const [factory, setFactory] = useState<OverlayFactory | null>(null)
35
+
36
+ // Scale the 1080×1920 render layer to fit the actual player size
37
+ useEffect(() => {
38
+ const el = wrapRef.current
39
+ if (!el) return
40
+ const obs = new ResizeObserver(([entry]) => {
41
+ setScale(entry.contentRect.width / RENDER_W)
42
+ })
43
+ obs.observe(el)
44
+ return () => obs.disconnect()
45
+ }, [])
46
+
47
+ // Load the render-engine template for the active style.
48
+ // If the host did not supply resolveCaptionTemplate, render nothing (graceful
49
+ // no-op — host does not support captions).
50
+ useEffect(() => {
51
+ setFactory(null)
52
+ if (!resolveCaptionTemplate) return
53
+ const templateSrc = resolveCaptionTemplate(track.style)
54
+ compileOverlay(templateSrc)
55
+ .then(f => setFactory(() => f))
56
+ .catch(e => console.warn('[CaptionPreview] failed to load template:', e))
57
+ }, [track.style, compileOverlay, resolveCaptionTemplate])
58
+
59
+ const frame = Math.round(currentTime * fps)
60
+ const lastSeg = track.segments[track.segments.length - 1]
61
+ const element = (factory && scale !== null)
62
+ ? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments })
63
+ : null
64
+
65
+ return (
66
+ <div ref={wrapRef} className="absolute inset-0 pointer-events-none overflow-hidden">
67
+ <OverlayErrorBoundary label={`caption: ${track.style}`} resetKey={track.style}>
68
+ {element && scale !== null && (
69
+ <div style={{
70
+ position: 'absolute',
71
+ top: 0, left: 0,
72
+ width: RENDER_W,
73
+ height: RENDER_H,
74
+ transform: `scale(${scale})`,
75
+ transformOrigin: 'top left',
76
+ }}>
77
+ {element}
78
+ </div>
79
+ )}
80
+ </OverlayErrorBoundary>
81
+ </div>
82
+ )
83
+ }
@@ -0,0 +1,35 @@
1
+ import type { EditorProject as Project } from '../../schema'
2
+ import SlideCanvas from '../../carousel/SlideCanvas'
3
+
4
+ interface Props {
5
+ project: Project
6
+ }
7
+
8
+ export default function CarouselPreview({ project }: Props) {
9
+ const slides = project.slides ?? []
10
+ const [w, h] = project.settings.resolution
11
+ const targetWidth = 240
12
+ const scale = targetWidth / w
13
+
14
+ return (
15
+ <div className="grid grid-cols-3 gap-3 p-4 overflow-y-auto">
16
+ {slides.map((slide, i) => (
17
+ <div key={slide.id} className="flex flex-col items-center">
18
+ <SlideCanvas
19
+ slide={slide}
20
+ width={w}
21
+ height={h}
22
+ interactive={false}
23
+ scale={scale}
24
+ />
25
+ <div className="text-xs text-gray-500 mt-1">Slide {i + 1}</div>
26
+ </div>
27
+ ))}
28
+ {slides.length === 0 && (
29
+ <div className="col-span-3 text-center text-gray-600 text-sm py-8">
30
+ No slides yet
31
+ </div>
32
+ )}
33
+ </div>
34
+ )
35
+ }