@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
package/package.json
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type {
|
|
3
|
+
EditorAdapter,
|
|
4
|
+
RenderEvent,
|
|
5
|
+
RenderOptions,
|
|
6
|
+
VersionEntry,
|
|
7
|
+
WaveformChunk,
|
|
8
|
+
} from '../types'
|
|
9
|
+
import type { EditorProject, ImageElement } from '../schema'
|
|
10
|
+
|
|
11
|
+
// ── Video adapter contract ────────────────────────────────────────────────────
|
|
12
|
+
// The video editor adds four OPTIONAL adapter methods: listVersionHistory,
|
|
13
|
+
// restoreVersion, getWaveformChunks, clearOverlayCache. This file fails to
|
|
14
|
+
// compile if those methods are mistyped, and verifies that (a) an adapter
|
|
15
|
+
// implementing them type-checks and (b) one omitting them still type-checks.
|
|
16
|
+
|
|
17
|
+
const project: EditorProject = {
|
|
18
|
+
version: '1',
|
|
19
|
+
id: 'p1',
|
|
20
|
+
status: 'draft' as EditorProject['status'],
|
|
21
|
+
name: 'Fake',
|
|
22
|
+
workflow: 'video',
|
|
23
|
+
editingPrompt: '',
|
|
24
|
+
settings: { resolution: [1080, 1920] },
|
|
25
|
+
assets: [],
|
|
26
|
+
tracks: [[]],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const baseRequired = {
|
|
30
|
+
loadProject: async (_id: string): Promise<EditorProject> => project,
|
|
31
|
+
saveProject: async (_id: string, _p: EditorProject): Promise<void> => {},
|
|
32
|
+
subscribe: (_id: string, _onFrame: (p: EditorProject) => void): (() => void) => () => {},
|
|
33
|
+
render: async function* (_id: string, _opts?: RenderOptions): AsyncIterable<RenderEvent> {
|
|
34
|
+
yield { type: 'done', outputPath: '/out/p1.mp4' }
|
|
35
|
+
},
|
|
36
|
+
resolveImageSrc: (el: ImageElement): string => el.src,
|
|
37
|
+
compileOverlay: async (_template: string) => () => null,
|
|
38
|
+
listGlobalOverlays: async () => [],
|
|
39
|
+
listSystemOverlays: async () => [],
|
|
40
|
+
uploadFile: async (_file: File): Promise<string> => '/path',
|
|
41
|
+
fileUrl: (path: string): string => path,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// (a) Adapter implementing all four new optional methods.
|
|
45
|
+
function makeVideoAdapter(): EditorAdapter<EditorProject> {
|
|
46
|
+
return {
|
|
47
|
+
...baseRequired,
|
|
48
|
+
listVersionHistory: async (_id: string): Promise<VersionEntry[]> => [
|
|
49
|
+
{ hash: 'abc', message: 'init', timestamp: '2026-01-01T00:00:00Z' },
|
|
50
|
+
],
|
|
51
|
+
restoreVersion: async (_id: string, _hash: string): Promise<EditorProject> => project,
|
|
52
|
+
getWaveformChunks: async (
|
|
53
|
+
_projectId: string,
|
|
54
|
+
_trackId: string,
|
|
55
|
+
_trackSrc: string,
|
|
56
|
+
_chunkDurationS?: number,
|
|
57
|
+
): Promise<WaveformChunk[]> => [{ path: '/wf/0.png', start: 0, end: 15 }],
|
|
58
|
+
clearOverlayCache: (_src?: string): void => {},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// (b) Adapter omitting the new optional methods. Must still type-check.
|
|
63
|
+
function makeMinimalAdapter(): EditorAdapter<EditorProject> {
|
|
64
|
+
return { ...baseRequired }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('EditorAdapter video methods', () => {
|
|
68
|
+
it('an adapter implementing the new optional methods type-checks', async () => {
|
|
69
|
+
const a = makeVideoAdapter()
|
|
70
|
+
expect(typeof a.listVersionHistory).toBe('function')
|
|
71
|
+
expect(typeof a.restoreVersion).toBe('function')
|
|
72
|
+
expect(typeof a.getWaveformChunks).toBe('function')
|
|
73
|
+
expect(typeof a.clearOverlayCache).toBe('function')
|
|
74
|
+
|
|
75
|
+
const versions = await a.listVersionHistory!('p1')
|
|
76
|
+
expect(versions[0]).toMatchObject({ hash: 'abc', message: 'init' })
|
|
77
|
+
|
|
78
|
+
const chunks = await a.getWaveformChunks!('p1', 't1', 'a.mp3', 15)
|
|
79
|
+
expect(chunks[0]).toMatchObject({ path: '/wf/0.png', start: 0, end: 15 })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('an adapter omitting the new optional methods still type-checks', () => {
|
|
83
|
+
const a = makeMinimalAdapter()
|
|
84
|
+
expect(a.listVersionHistory).toBeUndefined()
|
|
85
|
+
expect(a.restoreVersion).toBeUndefined()
|
|
86
|
+
expect(a.getWaveformChunks).toBeUndefined()
|
|
87
|
+
expect(a.clearOverlayCache).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -151,7 +151,7 @@ function isTypingTarget(t: EventTarget | null): boolean {
|
|
|
151
151
|
|
|
152
152
|
// ── CarouselEditor ────────────────────────────────────────────────────────────
|
|
153
153
|
|
|
154
|
-
export default function CarouselEditor<P extends Project = Project>({ project: initialProject, adapter, onProjectChange, theme, slots }: Props<P>) {
|
|
154
|
+
export default function CarouselEditor<P extends Project = Project>({ project: initialProject, adapter, onProjectChange, theme, slots, hiddenElementIds, onToggleElementVisibility, onSelectionChange }: Props<P>) {
|
|
155
155
|
const state = useProjectState(adapter, initialProject.id, initialProject)
|
|
156
156
|
const project = state.project
|
|
157
157
|
const slides = project.slides ?? []
|
|
@@ -339,6 +339,12 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
339
339
|
const selectedSlide = slides.find(s => s.id === selectedSlideId)
|
|
340
340
|
const selectedElement = selectedSlide?.elements.find(el => el.id === selectedElementId)
|
|
341
341
|
|
|
342
|
+
// Notify the host of selection changes so it can drive selection-aware chrome
|
|
343
|
+
// (e.g. a regen action in a toolbar slot). Fires with the element or null.
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
onSelectionChange?.(selectedElement ?? null)
|
|
346
|
+
}, [selectedElement, onSelectionChange])
|
|
347
|
+
|
|
342
348
|
const [w, h] = project.settings.resolution
|
|
343
349
|
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
|
344
350
|
const [canvasContainerSize, setCanvasContainerSize] = useState<{ w: number; h: number }>({ w: 600, h: 700 })
|
|
@@ -387,21 +393,24 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
387
393
|
<span className="text-xs font-medium">Refresh</span>
|
|
388
394
|
</button>
|
|
389
395
|
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
396
|
+
<div className="absolute top-3 right-3 z-30 flex items-center gap-2">
|
|
397
|
+
{slots?.toolbarActions}
|
|
398
|
+
<button
|
|
399
|
+
onClick={handleRender}
|
|
400
|
+
disabled={rendering || project.status === 'pending' || slides.length === 0}
|
|
401
|
+
className="flex items-center gap-2 px-3 py-2 rounded-md border border-blue-500/50 bg-blue-600/80 text-white hover:bg-blue-600 hover:border-blue-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
402
|
+
title={
|
|
403
|
+
project.status === 'pending'
|
|
404
|
+
? 'Wait for the agent to finish before rendering'
|
|
405
|
+
: slides.length === 0
|
|
406
|
+
? 'Add slides before rendering'
|
|
407
|
+
: 'Render all slides as PNGs'
|
|
408
|
+
}
|
|
409
|
+
>
|
|
410
|
+
<Download size={18} />
|
|
411
|
+
<span className="text-xs font-medium">{rendering ? 'Starting…' : 'Render'}</span>
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
405
414
|
|
|
406
415
|
{project.status === 'pending' ? (
|
|
407
416
|
<div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
|
|
@@ -461,6 +470,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
461
470
|
updateImageCrop={state.updateImageCrop}
|
|
462
471
|
cropElementId={cropElementId}
|
|
463
472
|
onExitCrop={() => setCropElementId(null)}
|
|
473
|
+
hiddenElementIds={hiddenElementIds}
|
|
464
474
|
/>
|
|
465
475
|
</div>
|
|
466
476
|
<p className="flex-shrink-0 text-xs text-gray-500 text-center max-w-md">
|
|
@@ -505,6 +515,8 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
505
515
|
onReorderElement={handleReorderElement}
|
|
506
516
|
onEnterCrop={(_slideId, elementId) => { setSelectedElementId(elementId); setCropElementId(elementId) }}
|
|
507
517
|
updateOverlayProp={state.updateOverlayProp}
|
|
518
|
+
hiddenElementIds={hiddenElementIds}
|
|
519
|
+
onToggleElementVisibility={onToggleElementVisibility}
|
|
508
520
|
/>
|
|
509
521
|
{slots?.assetsPanel && (
|
|
510
522
|
<div className="border-t border-gray-800 flex flex-col overflow-hidden" style={{ minHeight: 180 }}>
|
|
@@ -106,6 +106,13 @@ interface Props {
|
|
|
106
106
|
// Crop mode is owned here but the entry trigger lives in the property panel.
|
|
107
107
|
cropElementId?: string | null
|
|
108
108
|
onExitCrop?: () => void
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Editor-only element ids to omit from this canvas (non-persisted). Used by
|
|
112
|
+
* the host's visibility toggle to hide a scrim/background while positioning
|
|
113
|
+
* overlays beneath it. Absent → all elements render.
|
|
114
|
+
*/
|
|
115
|
+
hiddenElementIds?: string[]
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
export default function SlideCanvas({
|
|
@@ -128,9 +135,11 @@ export default function SlideCanvas({
|
|
|
128
135
|
updateImageCrop,
|
|
129
136
|
cropElementId,
|
|
130
137
|
onExitCrop,
|
|
138
|
+
hiddenElementIds,
|
|
131
139
|
}: Props) {
|
|
132
140
|
const sid = slideId ?? slide.id
|
|
133
141
|
const resolveSrc = resolveImageSrc ?? ((el: ImageElement) => resolveAssetDefault(el.src))
|
|
142
|
+
const hiddenSet = hiddenElementIds && hiddenElementIds.length ? new Set(hiddenElementIds) : null
|
|
134
143
|
|
|
135
144
|
// Refs to each element wrapper so gesture previews can mutate DOM directly.
|
|
136
145
|
const wrapperRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
|
@@ -295,6 +304,7 @@ export default function SlideCanvas({
|
|
|
295
304
|
|
|
296
305
|
return (
|
|
297
306
|
<div
|
|
307
|
+
data-interactive={interactive ? 'true' : undefined}
|
|
298
308
|
style={{
|
|
299
309
|
width: displayW,
|
|
300
310
|
height: displayH,
|
|
@@ -353,6 +363,8 @@ export default function SlideCanvas({
|
|
|
353
363
|
)}
|
|
354
364
|
|
|
355
365
|
{slide.elements.map((element) => {
|
|
366
|
+
// Editor-only visibility: omit hidden elements from this canvas.
|
|
367
|
+
if (hiddenSet?.has(element.id)) return null
|
|
356
368
|
const isSelected = selectedElementId === element.id
|
|
357
369
|
const inCrop = cropState?.elementId === element.id
|
|
358
370
|
const isRotated = (element.rotation ?? 0) !== 0
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import { Crop } from 'lucide-react'
|
|
2
|
+
import { Crop, Eye, EyeOff } from 'lucide-react'
|
|
3
3
|
import type {
|
|
4
4
|
Project,
|
|
5
5
|
Slide,
|
|
@@ -34,6 +34,37 @@ interface Props {
|
|
|
34
34
|
updateOverlayProp?: (slideId: string, elementId: string, key: string, value: string) => Promise<void>
|
|
35
35
|
// Adapter supplies overlay-schema listing (global + profile-scoped).
|
|
36
36
|
adapter: EditorAdapter<Project>
|
|
37
|
+
// Editor-only element visibility (host-owned, non-persisted). When
|
|
38
|
+
// `onToggleElementVisibility` is supplied, an eye toggle is shown for the
|
|
39
|
+
// selected element; `hiddenElementIds` reflects the current hidden set.
|
|
40
|
+
hiddenElementIds?: string[]
|
|
41
|
+
onToggleElementVisibility?: (elementId: string) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Small eye toggle to hide/show the selected element in the editor preview only
|
|
45
|
+
// (never persisted). Absent host callback → not rendered.
|
|
46
|
+
function HideToggle({
|
|
47
|
+
elementId,
|
|
48
|
+
isHidden,
|
|
49
|
+
onToggle,
|
|
50
|
+
}: {
|
|
51
|
+
elementId: string
|
|
52
|
+
isHidden: boolean
|
|
53
|
+
onToggle?: (elementId: string) => void
|
|
54
|
+
}) {
|
|
55
|
+
if (!onToggle) return null
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={e => { e.stopPropagation(); onToggle(elementId) }}
|
|
60
|
+
title={isHidden ? 'Show in editor' : 'Hide from editor'}
|
|
61
|
+
aria-label={isHidden ? 'Show in editor' : 'Hide from editor'}
|
|
62
|
+
aria-pressed={isHidden}
|
|
63
|
+
className="text-gray-500 hover:text-white px-1"
|
|
64
|
+
>
|
|
65
|
+
{isHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
66
|
+
</button>
|
|
67
|
+
)
|
|
37
68
|
}
|
|
38
69
|
|
|
39
70
|
function numInput(
|
|
@@ -146,6 +177,8 @@ export default function SlidePropertyPanel({
|
|
|
146
177
|
onEnterCrop,
|
|
147
178
|
updateOverlayProp,
|
|
148
179
|
adapter,
|
|
180
|
+
hiddenElementIds,
|
|
181
|
+
onToggleElementVisibility,
|
|
149
182
|
}: Props) {
|
|
150
183
|
// Map of jsxPath → GlobalOverlay for overlay prop schemas
|
|
151
184
|
const [overlaySchemas, setOverlaySchemas] = useState<Map<string, GlobalOverlay>>(new Map())
|
|
@@ -225,7 +258,12 @@ export default function SlidePropertyPanel({
|
|
|
225
258
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
226
259
|
{element.type === 'image' ? 'Image' : 'Overlay'}
|
|
227
260
|
</span>
|
|
228
|
-
<div className="flex gap-1">
|
|
261
|
+
<div className="flex items-center gap-1">
|
|
262
|
+
<HideToggle
|
|
263
|
+
elementId={element.id}
|
|
264
|
+
isHidden={hiddenElementIds?.includes(element.id) ?? false}
|
|
265
|
+
onToggle={onToggleElementVisibility}
|
|
266
|
+
/>
|
|
229
267
|
<button
|
|
230
268
|
onClick={() => onReorderElement(slide.id, element.id, 'forward')}
|
|
231
269
|
className="text-xs text-gray-500 hover:text-white px-1"
|
|
@@ -232,4 +232,56 @@ describe('CarouselEditor — editor-core integration', () => {
|
|
|
232
232
|
expect(adapter.saveCalls.length).toBe(before)
|
|
233
233
|
document.body.removeChild(input)
|
|
234
234
|
})
|
|
235
|
+
|
|
236
|
+
// Visibility toggle: ids in `hiddenElementIds` are omitted from the interactive
|
|
237
|
+
// canvas (editor-only; the thumbnail and `saveProject` are untouched).
|
|
238
|
+
it('omits hidden elements from the interactive canvas', async () => {
|
|
239
|
+
const adapter = makeFakeAdapter()
|
|
240
|
+
const initial = makeProject({
|
|
241
|
+
slides: [
|
|
242
|
+
{
|
|
243
|
+
id: 'slide-0',
|
|
244
|
+
base_color: '#ffffff',
|
|
245
|
+
elements: [
|
|
246
|
+
{ id: 'el-a', type: 'image', src: 'a.png', x: 0, y: 0, w: 100, h: 100, rotation: 0 },
|
|
247
|
+
{ id: 'el-b', type: 'image', src: 'b.png', x: 10, y: 10, w: 100, h: 100, rotation: 0 },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
})
|
|
252
|
+
const { container } = render(
|
|
253
|
+
<CarouselEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} hiddenElementIds={['el-b']} />,
|
|
254
|
+
)
|
|
255
|
+
// Visible element renders in the interactive canvas; hidden one does not.
|
|
256
|
+
await waitFor(() => expect(container.querySelector('[data-interactive] [data-element-id="el-a"]')).not.toBeNull())
|
|
257
|
+
expect(container.querySelector('[data-interactive] [data-element-id="el-b"]')).toBeNull()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// onSelectionChange fires with the selected element, and with null on deselect.
|
|
261
|
+
it('fires onSelectionChange on select and deselect', async () => {
|
|
262
|
+
const adapter = makeFakeAdapter()
|
|
263
|
+
const initial = makeProject()
|
|
264
|
+
const onSelectionChange = vi.fn()
|
|
265
|
+
const { container } = render(
|
|
266
|
+
<CarouselEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} onSelectionChange={onSelectionChange} />,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
const lastSelection = () => {
|
|
270
|
+
const calls = onSelectionChange.mock.calls
|
|
271
|
+
return calls[calls.length - 1]?.[0]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const wrapper = await waitFor(() => findInteractiveWrapper('el-img'))
|
|
275
|
+
await act(async () => { fireEvent.click(wrapper) })
|
|
276
|
+
await waitFor(() => {
|
|
277
|
+
expect(lastSelection()?.id).toBe('el-img')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Click the interactive canvas background to clear selection.
|
|
281
|
+
const root = container.querySelector('[data-interactive]') as HTMLElement
|
|
282
|
+
await act(async () => { fireEvent.click(root) })
|
|
283
|
+
await waitFor(() => {
|
|
284
|
+
expect(lastSelection()).toBeNull()
|
|
285
|
+
})
|
|
286
|
+
})
|
|
235
287
|
})
|
package/src/index.ts
CHANGED
|
@@ -33,12 +33,25 @@ export type {
|
|
|
33
33
|
MediaItem,
|
|
34
34
|
GlobalOverlay,
|
|
35
35
|
GlobalOverlayProp,
|
|
36
|
+
VersionEntry,
|
|
37
|
+
WaveformChunk,
|
|
36
38
|
EditorAdapter,
|
|
37
39
|
EditorTheme,
|
|
38
40
|
EditorSlots,
|
|
39
41
|
CarouselEditorProps,
|
|
42
|
+
VideoEditorProps,
|
|
40
43
|
} from './types'
|
|
41
44
|
|
|
45
|
+
// ── Video editor pure helpers ─────────────────────────────────────────────────
|
|
46
|
+
export {
|
|
47
|
+
applyCutToTracks,
|
|
48
|
+
applyCutToItem,
|
|
49
|
+
collapseGaps,
|
|
50
|
+
splitAtTime,
|
|
51
|
+
} from './video/cuts'
|
|
52
|
+
export type { Cut } from './video/cuts'
|
|
53
|
+
export { getOverlayDesignCanvas } from './video/design-canvas'
|
|
54
|
+
|
|
42
55
|
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
43
56
|
export { defaultMontajTheme, applyTheme } from './theme'
|
|
44
57
|
|
|
@@ -95,6 +108,14 @@ export type { TextFormattingToolbarProps } from './text/TextFormattingToolbar'
|
|
|
95
108
|
export { OverlayPreview } from './preview/OverlayPreview'
|
|
96
109
|
export type { OverlayPreviewProps } from './preview/OverlayPreview'
|
|
97
110
|
|
|
111
|
+
// ── Video preview ─────────────────────────────────────────────────────────────
|
|
112
|
+
export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
|
|
113
|
+
export { default as CarouselPreview } from './video/preview/CarouselPreview'
|
|
114
|
+
export { default as OverlayItemsLayer } from './video/preview/OverlayItemsLayer'
|
|
115
|
+
export { useVideoPlayback } from './video/preview/useVideoPlayback'
|
|
116
|
+
export { useDragOverlay } from './video/preview/useDragOverlay'
|
|
117
|
+
export type { Corner, DragType } from './video/preview/useDragOverlay'
|
|
118
|
+
|
|
98
119
|
// ── Overlays ──────────────────────────────────────────────────────────────────
|
|
99
120
|
export {
|
|
100
121
|
STANDARD_TEXT_PROPS,
|
|
@@ -102,8 +123,9 @@ export {
|
|
|
102
123
|
readPropAsString,
|
|
103
124
|
} from './overlays/contract'
|
|
104
125
|
|
|
105
|
-
// ── Assembled
|
|
126
|
+
// ── Assembled editors ─────────────────────────────────────────────────────────
|
|
106
127
|
export { default as CarouselEditor } from './carousel/CarouselEditor'
|
|
128
|
+
export { default as VideoEditor } from './video/VideoEditor'
|
|
107
129
|
|
|
108
130
|
// ── Public carousel sub-components ────────────────────────────────────────────
|
|
109
131
|
// Hosts consume these beyond the assembled editor — Montaj's preview/caption
|
package/src/types.ts
CHANGED
|
@@ -99,6 +99,34 @@ export interface GlobalOverlay {
|
|
|
99
99
|
empty?: boolean
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// ── Version history (optional capability) ─────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A single entry in a project's version history. The editor-relevant slice of
|
|
106
|
+
* Montaj's `ProjectVersion` (ui/src/lib/types/schema.ts): a content-addressed
|
|
107
|
+
* `hash` to restore by, a human-readable `message`, and a `timestamp`. The
|
|
108
|
+
* adapter maps the host's richer shape down to this.
|
|
109
|
+
*/
|
|
110
|
+
export interface VersionEntry {
|
|
111
|
+
hash: string
|
|
112
|
+
message: string
|
|
113
|
+
timestamp: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Waveform chunks (optional capability) ─────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One rendered waveform-image chunk for an audio track. `path` is a
|
|
120
|
+
* host-resolvable image path (route through `fileUrl` to display); `start`/`end`
|
|
121
|
+
* are source-file seconds the chunk covers. Copied verbatim from Montaj's former
|
|
122
|
+
* `lib/audio-waveform.ts` so the package owns the shape the timeline consumes.
|
|
123
|
+
*/
|
|
124
|
+
export interface WaveformChunk {
|
|
125
|
+
path: string
|
|
126
|
+
start: number
|
|
127
|
+
end: number
|
|
128
|
+
}
|
|
129
|
+
|
|
102
130
|
// ── Media (optional capability) ───────────────────────────────────────────────
|
|
103
131
|
|
|
104
132
|
/**
|
|
@@ -244,6 +272,53 @@ export interface EditorAdapter<P extends Project = Project> {
|
|
|
244
272
|
* `listSystemOverlays()` + its `static-text` matcher.
|
|
245
273
|
*/
|
|
246
274
|
getDefaultTextOverlay?(): Promise<GlobalOverlay | null>
|
|
275
|
+
|
|
276
|
+
// ── Video editor capabilities (optional) ────────────────────────────────────
|
|
277
|
+
// Hosts driving the video editor implement these; carousel-only hosts omit
|
|
278
|
+
// them and the editor feature-detects their absence.
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Optional: list the project's version history, newest-first. Maps to
|
|
282
|
+
* Montaj's `GET /api/projects/:id/versions`, mapped down to `VersionEntry`.
|
|
283
|
+
*/
|
|
284
|
+
listVersionHistory?(id: string): Promise<VersionEntry[]>
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Optional: restore the project to a prior version by `hash`, returning the
|
|
288
|
+
* restored project. Maps to Montaj's
|
|
289
|
+
* `POST /api/projects/:id/versions/:hash/restore`.
|
|
290
|
+
*/
|
|
291
|
+
restoreVersion?(id: string, hash: string): Promise<P>
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Optional: produce rendered waveform-image chunks for an audio track. The
|
|
295
|
+
* editor passes the project id, the track id (used to namespace the output
|
|
296
|
+
* cache), the track's source path, and an optional chunk duration in seconds.
|
|
297
|
+
* The host renders/caches the chunks and returns their resolvable paths. Maps
|
|
298
|
+
* to Montaj's `waveform_image` step.
|
|
299
|
+
*/
|
|
300
|
+
getWaveformChunks?(
|
|
301
|
+
projectId: string,
|
|
302
|
+
trackId: string,
|
|
303
|
+
trackSrc: string,
|
|
304
|
+
chunkDurationS?: number,
|
|
305
|
+
): Promise<WaveformChunk[]>
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Optional: invalidate the host's compiled-overlay cache. When `src` is given,
|
|
309
|
+
* only that entry is dropped; hosts may treat a missing `src` as a no-op or a
|
|
310
|
+
* full clear. Maps to Montaj's `clearOverlayCache` in `lib/overlay-eval`.
|
|
311
|
+
*/
|
|
312
|
+
clearOverlayCache?(src?: string): void
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Optional: resolve the template identifier that `compileOverlay` should
|
|
316
|
+
* receive for a given caption style name. The mapping is host-specific —
|
|
317
|
+
* Montaj uses `/api/caption-template/<style>`; other hosts may differ.
|
|
318
|
+
* When absent the editor renders no captions (graceful no-op). Hosts without
|
|
319
|
+
* caption support omit this entirely.
|
|
320
|
+
*/
|
|
321
|
+
resolveCaptionTemplate?(style: string): string
|
|
247
322
|
}
|
|
248
323
|
|
|
249
324
|
// ── Theme ────────────────────────────────────────────────────────────────────
|
|
@@ -306,6 +381,15 @@ export interface EditorSlots {
|
|
|
306
381
|
* progress (Montaj feeds its SSE log line here); absent → default copy shows.
|
|
307
382
|
*/
|
|
308
383
|
pendingStatus?: ReactNode
|
|
384
|
+
/**
|
|
385
|
+
* Rendered in the right sidebar below the version-history panel — in the same
|
|
386
|
+
* position ReviewView showed "Previous runs". The host supplies the concrete
|
|
387
|
+
* Montaj run-snapshot list (reading `project.history: RunSnapshot[]` and
|
|
388
|
+
* offering a "Restore this run" action via `onProjectChange`). The package
|
|
389
|
+
* never reads `project.history` or `RunSnapshot` — those are host-only types.
|
|
390
|
+
* Absent → nothing is rendered in that slot.
|
|
391
|
+
*/
|
|
392
|
+
runHistory?: ReactNode
|
|
309
393
|
}
|
|
310
394
|
|
|
311
395
|
// ── Top-level component props ──────────────────────────────────────────────────
|
|
@@ -322,4 +406,81 @@ export interface CarouselEditorProps<P extends Project = Project> {
|
|
|
322
406
|
theme?: EditorTheme
|
|
323
407
|
slots?: EditorSlots
|
|
324
408
|
readOnly?: boolean
|
|
409
|
+
/**
|
|
410
|
+
* Editor-only set of element ids to hide from the interactive canvas. The host
|
|
411
|
+
* owns this state; the package never persists it (hidden elements are omitted
|
|
412
|
+
* from the canvas render only, never from `saveProject`). Lets a host
|
|
413
|
+
* temporarily hide a scrim/background to position overlays beneath it.
|
|
414
|
+
*/
|
|
415
|
+
hiddenElementIds?: string[]
|
|
416
|
+
/**
|
|
417
|
+
* Invoked when the user toggles the selected element's editor-visibility via
|
|
418
|
+
* the property-panel eye button. The host updates its hidden-set and reflects
|
|
419
|
+
* it back through `hiddenElementIds`. Absent → no eye toggle is rendered.
|
|
420
|
+
*/
|
|
421
|
+
onToggleElementVisibility?: (elementId: string) => void
|
|
422
|
+
/**
|
|
423
|
+
* Invoked whenever the selected element changes — with the element, or `null`
|
|
424
|
+
* when selection clears. Lets a host drive selection-aware chrome (e.g. a
|
|
425
|
+
* "regenerate image" action in a toolbar slot that targets the current
|
|
426
|
+
* selection). The package keeps owning selection state.
|
|
427
|
+
*/
|
|
428
|
+
onSelectionChange?: (element: CarouselElement | null) => void
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Props for the video editor component. Mirrors `CarouselEditorProps` —
|
|
433
|
+
* controlled `project` + `onProjectChange`, adapter-driven transport, optional
|
|
434
|
+
* theme/slots/readOnly — and adds `onBackToSetup`, the host-supplied callback
|
|
435
|
+
* the editor invokes when the user leaves the editor for the project's setup
|
|
436
|
+
* view.
|
|
437
|
+
*/
|
|
438
|
+
export interface VideoEditorProps<P extends Project = Project> {
|
|
439
|
+
project: P
|
|
440
|
+
adapter: EditorAdapter<P>
|
|
441
|
+
onProjectChange?: (p: P) => void
|
|
442
|
+
theme?: EditorTheme
|
|
443
|
+
slots?: EditorSlots
|
|
444
|
+
readOnly?: boolean
|
|
445
|
+
onBackToSetup?: () => void
|
|
446
|
+
|
|
447
|
+
// ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
|
|
448
|
+
// The clip/audio inspector and the subcut-regeneration tool read host-only
|
|
449
|
+
// fields (regenQueue, storyboard, the host's full Project) the package types
|
|
450
|
+
// don't know. The editor surfaces them as render-props it threads/renders so
|
|
451
|
+
// those components can stay host-side; the editor stays Montaj-agnostic.
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Render-prop seam for the host's clip/audio inspector (Montaj's
|
|
455
|
+
* ClipInspectModal). The editor owns the "which item is being inspected"
|
|
456
|
+
* state — it derives `ctx.item` from the timeline's `onInspectClip` /
|
|
457
|
+
* `onInspectAudio` callbacks (a Montaj-agnostic `{ kind, id }` selector, not
|
|
458
|
+
* a project entity) and passes a close callback. Absent → no inspector.
|
|
459
|
+
*/
|
|
460
|
+
renderClipInspector?: (ctx: {
|
|
461
|
+
item: { kind: 'clip' | 'audio'; id: string }
|
|
462
|
+
onClose: () => void
|
|
463
|
+
}) => ReactNode
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Render-prop seam for the host's subcut-regeneration tool (Montaj's
|
|
467
|
+
* SubcutRegenTool). Threaded straight through to the timeline, which owns the
|
|
468
|
+
* open/close trigger (the per-clip Scissors button). Called with the clip id
|
|
469
|
+
* and a close callback. Absent → the subcut tool isn't rendered.
|
|
470
|
+
*/
|
|
471
|
+
renderSubcutRegen?: (ctx: { clipId: string; onClose: () => void }) => ReactNode
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Host-computed gate for the per-clip subcut-regenerate affordance (Montaj:
|
|
475
|
+
* ai_video projects). Threaded to the timeline. The package never reads
|
|
476
|
+
* `projectType`.
|
|
477
|
+
*/
|
|
478
|
+
regenEnabled?: boolean
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Host-computed predicate driving the per-clip "queued" badge (Montaj:
|
|
482
|
+
* project.regenQueue membership). Threaded to the timeline. The package never
|
|
483
|
+
* reads `regenQueue`.
|
|
484
|
+
*/
|
|
485
|
+
isClipQueued?: (itemId: string) => boolean
|
|
325
486
|
}
|