@bycrux/editor 0.4.1
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/README.md +165 -0
- package/package.json +46 -0
- package/src/__tests__/adapter-contract.test.ts +123 -0
- package/src/__tests__/adapter.test.ts +185 -0
- package/src/__tests__/schema.test.ts +104 -0
- package/src/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +545 -0
- package/src/carousel/CarouselRenderModal.tsx +243 -0
- package/src/carousel/OverlayErrorBoundary.tsx +99 -0
- package/src/carousel/OverlayPicker.tsx +145 -0
- package/src/carousel/ReadOnlySlide.tsx +90 -0
- package/src/carousel/SlideCanvas.tsx +637 -0
- package/src/carousel/SlidePropertyPanel.tsx +387 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +291 -0
- package/src/carousel/__tests__/ReadOnlySlide.test.tsx +139 -0
- package/src/carousel/__tests__/SlideCanvasCrop.test.tsx +95 -0
- package/src/carousel/__tests__/SlideCanvasFonts.test.tsx +82 -0
- package/src/crop/CanvasCropOverlay.tsx +193 -0
- package/src/crop/__tests__/crop-math.test.ts +174 -0
- package/src/crop/crop-math.ts +125 -0
- package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
- package/src/gestures/helpers/drag.ts +24 -0
- package/src/gestures/helpers/element-transform.ts +15 -0
- package/src/gestures/helpers/resize.ts +60 -0
- package/src/gestures/helpers/rotate.ts +44 -0
- package/src/gestures/helpers/snap.ts +64 -0
- package/src/gestures/hooks/useOverlayDrag.ts +106 -0
- package/src/gestures/hooks/useOverlayResize.ts +67 -0
- package/src/gestures/hooks/useOverlayRotate.ts +64 -0
- package/src/gestures/index.ts +16 -0
- package/src/index.ts +136 -0
- package/src/lib/google-fonts.ts +28 -0
- package/src/overlays/contract.ts +41 -0
- package/src/preview/OverlayPreview.tsx +196 -0
- package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
- package/src/schema.ts +201 -0
- package/src/state/__tests__/project-reducer.test.ts +957 -0
- package/src/state/__tests__/use-project-state.test.tsx +258 -0
- package/src/state/mutation-queue.ts +62 -0
- package/src/state/project-reducer.ts +328 -0
- package/src/state/use-project-state.ts +442 -0
- package/src/test-setup.ts +1 -0
- package/src/text/FontPicker.tsx +218 -0
- package/src/text/InlineTextEditor.tsx +92 -0
- package/src/text/TextFormattingToolbar.tsx +248 -0
- package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
- package/src/theme.ts +93 -0
- package/src/types.ts +486 -0
- package/src/ui/__tests__/button.test.tsx +17 -0
- package/src/ui/badge.tsx +32 -0
- package/src/ui/button.tsx +32 -0
- package/src/ui/index.ts +16 -0
- package/src/ui/input.tsx +15 -0
- package/src/ui/label.tsx +10 -0
- package/src/ui/select.tsx +23 -0
- package/src/ui/switch.tsx +31 -0
- package/src/ui/textarea.tsx +15 -0
- package/src/ui/utils.ts +7 -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 +584 -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,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
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import type { Project, CarouselElement, OverlayElement, EditorAdapter } from '../types'
|
|
3
|
+
import { Button } from '../ui'
|
|
4
|
+
import OverlayPicker from './OverlayPicker'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
project: Project
|
|
8
|
+
selectedSlideId: string | null
|
|
9
|
+
adapter: EditorAdapter<Project>
|
|
10
|
+
onAddElement: (slideId: string, element: CarouselElement) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function AddElementMenu({ project, selectedSlideId, adapter, onAddElement }: Props) {
|
|
14
|
+
const [showPrompt, setShowPrompt] = useState(false)
|
|
15
|
+
const [prompt, setPrompt] = useState('')
|
|
16
|
+
const [generating, setGenerating] = useState(false)
|
|
17
|
+
const [genError, setGenError] = useState<string | null>(null)
|
|
18
|
+
const [showOverlayPicker, setShowOverlayPicker] = useState(false)
|
|
19
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
20
|
+
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
21
|
+
const [addingText, setAddingText] = useState(false)
|
|
22
|
+
const [textError, setTextError] = useState<string | null>(null)
|
|
23
|
+
|
|
24
|
+
const disabled = selectedSlideId === null
|
|
25
|
+
|
|
26
|
+
// Add a static-text overlay at parity with mission-control's "add text".
|
|
27
|
+
// Resolves the host's default text overlay via the adapter (the package does
|
|
28
|
+
// not know the host's overlay naming) and seeds the standard text contract
|
|
29
|
+
// props from its declared defaults.
|
|
30
|
+
async function handleAddText() {
|
|
31
|
+
if (!selectedSlideId || !adapter.getDefaultTextOverlay) return
|
|
32
|
+
setAddingText(true)
|
|
33
|
+
setTextError(null)
|
|
34
|
+
try {
|
|
35
|
+
const tpl = await adapter.getDefaultTextOverlay()
|
|
36
|
+
if (!tpl) throw new Error('static-text overlay template not found')
|
|
37
|
+
const props: Record<string, unknown> = Object.fromEntries(
|
|
38
|
+
tpl.props.filter(p => p.default !== undefined).map(p => [p.name, p.default]),
|
|
39
|
+
)
|
|
40
|
+
if (typeof props.text !== 'string') props.text = 'Your text here'
|
|
41
|
+
const [fullW, fullH] = project.settings.resolution
|
|
42
|
+
const elementW = Math.round(fullW * 0.7)
|
|
43
|
+
const elementH = Math.round(fullH * 0.18)
|
|
44
|
+
const element: OverlayElement = {
|
|
45
|
+
id: crypto.randomUUID(),
|
|
46
|
+
type: 'overlay',
|
|
47
|
+
overlay: { template: tpl.jsxPath, props },
|
|
48
|
+
frame: 0,
|
|
49
|
+
x: Math.round(fullW / 2 - elementW / 2),
|
|
50
|
+
y: Math.round(fullH / 2 - elementH / 2),
|
|
51
|
+
w: elementW,
|
|
52
|
+
h: elementH,
|
|
53
|
+
rotation: 0,
|
|
54
|
+
}
|
|
55
|
+
onAddElement(selectedSlideId, element)
|
|
56
|
+
} catch (e) {
|
|
57
|
+
setTextError(e instanceof Error ? e.message : String(e))
|
|
58
|
+
} finally {
|
|
59
|
+
setAddingText(false)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleGenerate() {
|
|
64
|
+
if (!selectedSlideId || !prompt.trim() || !adapter.generateImage) return
|
|
65
|
+
setGenerating(true)
|
|
66
|
+
setGenError(null)
|
|
67
|
+
try {
|
|
68
|
+
const [w] = project.settings.resolution
|
|
69
|
+
const result = await adapter.generateImage(prompt.trim(), project.id)
|
|
70
|
+
const elementW = Math.round(w * 0.8)
|
|
71
|
+
const elementH = elementW
|
|
72
|
+
const [fullW, fullH] = project.settings.resolution
|
|
73
|
+
const element: CarouselElement = {
|
|
74
|
+
id: crypto.randomUUID(),
|
|
75
|
+
type: 'image',
|
|
76
|
+
src: result.path,
|
|
77
|
+
x: Math.round(fullW / 2 - elementW / 2),
|
|
78
|
+
y: Math.round(fullH / 2 - elementH / 2),
|
|
79
|
+
w: elementW,
|
|
80
|
+
h: elementH,
|
|
81
|
+
rotation: 0,
|
|
82
|
+
}
|
|
83
|
+
onAddElement(selectedSlideId, element)
|
|
84
|
+
setShowPrompt(false)
|
|
85
|
+
setPrompt('')
|
|
86
|
+
} catch (e) {
|
|
87
|
+
setGenError(e instanceof Error ? e.message : String(e))
|
|
88
|
+
} finally {
|
|
89
|
+
setGenerating(false)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
94
|
+
const file = e.target.files?.[0]
|
|
95
|
+
if (!file || !selectedSlideId) return
|
|
96
|
+
setUploadError(null)
|
|
97
|
+
try {
|
|
98
|
+
// Shared upload (no projectId) — matches the pre-extraction carousel
|
|
99
|
+
// behavior. The adapter stores it in a shared location, not project-local.
|
|
100
|
+
const uploadedPath = await adapter.uploadFile(file)
|
|
101
|
+
const [fullW, fullH] = project.settings.resolution
|
|
102
|
+
const elementW = Math.round(fullW * 0.8)
|
|
103
|
+
const elementH = elementW
|
|
104
|
+
const element: CarouselElement = {
|
|
105
|
+
id: crypto.randomUUID(),
|
|
106
|
+
type: 'image',
|
|
107
|
+
src: uploadedPath,
|
|
108
|
+
x: Math.round(fullW / 2 - elementW / 2),
|
|
109
|
+
y: Math.round(fullH / 2 - elementH / 2),
|
|
110
|
+
w: elementW,
|
|
111
|
+
h: elementH,
|
|
112
|
+
rotation: 0,
|
|
113
|
+
}
|
|
114
|
+
onAddElement(selectedSlideId, element)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
setUploadError(err instanceof Error ? err.message : String(err))
|
|
117
|
+
} finally {
|
|
118
|
+
// Reset so the same file can be re-picked
|
|
119
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex flex-col gap-2">
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
{adapter.generateImage && (
|
|
127
|
+
<Button
|
|
128
|
+
size="sm"
|
|
129
|
+
variant="outline"
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
onClick={() => { setShowPrompt(p => !p); setGenError(null) }}
|
|
132
|
+
className="text-xs"
|
|
133
|
+
>
|
|
134
|
+
+ AI Image
|
|
135
|
+
</Button>
|
|
136
|
+
)}
|
|
137
|
+
<Button
|
|
138
|
+
size="sm"
|
|
139
|
+
variant="outline"
|
|
140
|
+
disabled={disabled}
|
|
141
|
+
onClick={() => fileInputRef.current?.click()}
|
|
142
|
+
className="text-xs"
|
|
143
|
+
>
|
|
144
|
+
+ Upload Image
|
|
145
|
+
</Button>
|
|
146
|
+
<input
|
|
147
|
+
ref={fileInputRef}
|
|
148
|
+
type="file"
|
|
149
|
+
accept="image/*"
|
|
150
|
+
className="hidden"
|
|
151
|
+
onChange={handleFileUpload}
|
|
152
|
+
/>
|
|
153
|
+
{adapter.getDefaultTextOverlay && (
|
|
154
|
+
<Button
|
|
155
|
+
size="sm"
|
|
156
|
+
variant="outline"
|
|
157
|
+
disabled={disabled || addingText}
|
|
158
|
+
onClick={handleAddText}
|
|
159
|
+
className="text-xs"
|
|
160
|
+
>
|
|
161
|
+
{addingText ? 'Adding…' : '+ Text'}
|
|
162
|
+
</Button>
|
|
163
|
+
)}
|
|
164
|
+
<Button
|
|
165
|
+
size="sm"
|
|
166
|
+
variant="outline"
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
onClick={() => setShowOverlayPicker(true)}
|
|
169
|
+
className="text-xs"
|
|
170
|
+
>
|
|
171
|
+
+ Overlay
|
|
172
|
+
</Button>
|
|
173
|
+
</div>
|
|
174
|
+
{uploadError && <div className="text-xs text-red-400">{uploadError}</div>}
|
|
175
|
+
{textError && <div className="text-xs text-red-400">{textError}</div>}
|
|
176
|
+
|
|
177
|
+
{showPrompt && !disabled && (
|
|
178
|
+
<div className="flex flex-col gap-2 p-3 bg-gray-800 border border-gray-700 rounded-lg">
|
|
179
|
+
<textarea
|
|
180
|
+
className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-xs text-white placeholder-gray-500 resize-none focus:outline-none focus:border-gray-500"
|
|
181
|
+
rows={3}
|
|
182
|
+
placeholder="Describe the image to generate…"
|
|
183
|
+
value={prompt}
|
|
184
|
+
onChange={e => setPrompt(e.target.value)}
|
|
185
|
+
disabled={generating}
|
|
186
|
+
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleGenerate() }}
|
|
187
|
+
/>
|
|
188
|
+
{genError && <div className="text-xs text-red-400">{genError}</div>}
|
|
189
|
+
<div className="flex gap-2">
|
|
190
|
+
<Button size="sm" onClick={handleGenerate} disabled={generating || !prompt.trim()} className="text-xs">
|
|
191
|
+
{generating ? 'Generating…' : 'Generate'}
|
|
192
|
+
</Button>
|
|
193
|
+
<Button size="sm" variant="outline" onClick={() => { setShowPrompt(false); setGenError(null) }} disabled={generating} className="text-xs">
|
|
194
|
+
Cancel
|
|
195
|
+
</Button>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
<OverlayPicker
|
|
201
|
+
open={showOverlayPicker}
|
|
202
|
+
onClose={() => setShowOverlayPicker(false)}
|
|
203
|
+
project={project}
|
|
204
|
+
adapter={adapter}
|
|
205
|
+
onPick={element => {
|
|
206
|
+
if (selectedSlideId) onAddElement(selectedSlideId, element)
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|