@devbycrux/editor 0.1.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/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/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +529 -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/SlideCanvas.tsx +588 -0
- package/src/carousel/SlidePropertyPanel.tsx +349 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -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 +112 -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 +194 -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 +325 -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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor-core/preview/OverlayPreview — unit tests.
|
|
3
|
+
*
|
|
4
|
+
* The overlay compiler is injected via the `compileOverlay` prop so no module
|
|
5
|
+
* mock is needed. Each test passes its own fake compiler directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
10
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
11
|
+
import type { OverlayFactory } from '../../types'
|
|
12
|
+
|
|
13
|
+
// ── Import component under test ──────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
import { OverlayPreview } from '../OverlayPreview'
|
|
16
|
+
|
|
17
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Factory that returns a simple div with a test id. */
|
|
20
|
+
const trivialFactory: OverlayFactory = (_frame, _fps, _duration, _props) =>
|
|
21
|
+
React.createElement('div', { 'data-testid': 'overlay-output' }, 'hello overlay')
|
|
22
|
+
|
|
23
|
+
/** A compileOverlay prop that always resolves with the given factory. */
|
|
24
|
+
const makeCompiler =
|
|
25
|
+
(factory: OverlayFactory) =>
|
|
26
|
+
(_template: string): Promise<OverlayFactory> =>
|
|
27
|
+
Promise.resolve(factory)
|
|
28
|
+
|
|
29
|
+
/** A compileOverlay prop that always rejects with the given error. */
|
|
30
|
+
const makeFailingCompiler =
|
|
31
|
+
(message: string) =>
|
|
32
|
+
(_template: string): Promise<OverlayFactory> =>
|
|
33
|
+
Promise.reject(new Error(message))
|
|
34
|
+
|
|
35
|
+
/** A compileOverlay prop that never resolves (simulates in-flight). */
|
|
36
|
+
const pendingCompiler = (_template: string): Promise<OverlayFactory> =>
|
|
37
|
+
new Promise(() => {})
|
|
38
|
+
|
|
39
|
+
const DEFAULT_PROPS = {
|
|
40
|
+
template: '/path/to/overlay.jsx',
|
|
41
|
+
props: { text: 'hi' },
|
|
42
|
+
frame: 0,
|
|
43
|
+
fps: 30,
|
|
44
|
+
duration: 60,
|
|
45
|
+
compileOverlay: makeCompiler(trivialFactory),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe('OverlayPreview', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.restoreAllMocks()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('shows a loading state while compiling', async () => {
|
|
60
|
+
render(<OverlayPreview {...DEFAULT_PROPS} compileOverlay={pendingCompiler} />)
|
|
61
|
+
|
|
62
|
+
expect(screen.getByRole('status')).toBeInTheDocument()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('renders the factory output when compilation succeeds', async () => {
|
|
66
|
+
render(<OverlayPreview {...DEFAULT_PROPS} />)
|
|
67
|
+
|
|
68
|
+
await waitFor(() =>
|
|
69
|
+
expect(screen.getByTestId('overlay-output')).toBeInTheDocument(),
|
|
70
|
+
)
|
|
71
|
+
expect(screen.getByTestId('overlay-output')).toHaveTextContent('hello overlay')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('passes frame/fps/duration/props to the factory', async () => {
|
|
75
|
+
const factorySpy = vi.fn(trivialFactory)
|
|
76
|
+
|
|
77
|
+
render(
|
|
78
|
+
<OverlayPreview
|
|
79
|
+
template="/t.jsx"
|
|
80
|
+
props={{ color: 'red' }}
|
|
81
|
+
frame={12}
|
|
82
|
+
fps={24}
|
|
83
|
+
duration={90}
|
|
84
|
+
compileOverlay={makeCompiler(factorySpy)}
|
|
85
|
+
/>,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
await waitFor(() => expect(factorySpy).toHaveBeenCalled())
|
|
89
|
+
expect(factorySpy).toHaveBeenCalledWith(12, 24, 90, expect.objectContaining({ color: 'red' }))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('surfaces an error badge when compileOverlay rejects', async () => {
|
|
93
|
+
render(
|
|
94
|
+
<OverlayPreview
|
|
95
|
+
{...DEFAULT_PROPS}
|
|
96
|
+
compileOverlay={makeFailingCompiler('bad JSX syntax')}
|
|
97
|
+
/>,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
await waitFor(() =>
|
|
101
|
+
expect(screen.getByRole('alert')).toBeInTheDocument(),
|
|
102
|
+
)
|
|
103
|
+
const alert = screen.getByRole('alert')
|
|
104
|
+
expect(alert).toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('surfaces an error badge when the factory throws at render time', async () => {
|
|
108
|
+
const throwingFactory: OverlayFactory = () => {
|
|
109
|
+
throw new Error('runtime render error')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
render(
|
|
113
|
+
<OverlayPreview
|
|
114
|
+
{...DEFAULT_PROPS}
|
|
115
|
+
compileOverlay={makeCompiler(throwingFactory)}
|
|
116
|
+
/>,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
await waitFor(() =>
|
|
120
|
+
expect(screen.getByRole('alert')).toBeInTheDocument(),
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('re-compiles when template changes', async () => {
|
|
125
|
+
const factory1: OverlayFactory = () =>
|
|
126
|
+
React.createElement('div', { 'data-testid': 'v1' }, 'v1')
|
|
127
|
+
const factory2: OverlayFactory = () =>
|
|
128
|
+
React.createElement('div', { 'data-testid': 'v2' }, 'v2')
|
|
129
|
+
|
|
130
|
+
const compilerSpy = vi.fn()
|
|
131
|
+
compilerSpy.mockResolvedValueOnce(factory1).mockResolvedValueOnce(factory2)
|
|
132
|
+
|
|
133
|
+
const { rerender } = render(
|
|
134
|
+
<OverlayPreview {...DEFAULT_PROPS} template="/overlay-v1.jsx" compileOverlay={compilerSpy} />,
|
|
135
|
+
)
|
|
136
|
+
await waitFor(() => expect(screen.getByTestId('v1')).toBeInTheDocument())
|
|
137
|
+
|
|
138
|
+
rerender(<OverlayPreview {...DEFAULT_PROPS} template="/overlay-v2.jsx" compileOverlay={compilerSpy} />)
|
|
139
|
+
await waitFor(() => expect(screen.getByTestId('v2')).toBeInTheDocument())
|
|
140
|
+
|
|
141
|
+
expect(compilerSpy).toHaveBeenCalledTimes(2)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('accepts a custom loading node', async () => {
|
|
145
|
+
render(
|
|
146
|
+
<OverlayPreview
|
|
147
|
+
{...DEFAULT_PROPS}
|
|
148
|
+
compileOverlay={pendingCompiler}
|
|
149
|
+
loading={<div data-testid="custom-loading">Loading…</div>}
|
|
150
|
+
/>,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
expect(screen.getByTestId('custom-loading')).toBeInTheDocument()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('accepts a custom errorState node', async () => {
|
|
157
|
+
render(
|
|
158
|
+
<OverlayPreview
|
|
159
|
+
{...DEFAULT_PROPS}
|
|
160
|
+
compileOverlay={makeFailingCompiler('boom')}
|
|
161
|
+
errorState={<div data-testid="custom-error">Error!</div>}
|
|
162
|
+
/>,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
await waitFor(() =>
|
|
166
|
+
expect(screen.getByTestId('custom-error')).toBeInTheDocument(),
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
})
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Editor-facing schema for the @devbycrux/editor package.
|
|
2
|
+
//
|
|
3
|
+
// These types describe the slice of a Montaj project the carousel editor reads
|
|
4
|
+
// and writes. They are intentionally self-contained: the package owns no
|
|
5
|
+
// pipeline/agent types and depends on nothing from Montaj. The host app
|
|
6
|
+
// (Montaj, Hub, …) extends EditorProject with its own pipeline fields.
|
|
7
|
+
|
|
8
|
+
export interface Word {
|
|
9
|
+
word: string
|
|
10
|
+
start: number
|
|
11
|
+
end: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AudioTrack {
|
|
15
|
+
id: string
|
|
16
|
+
type?: 'voiceover' | 'music' | 'sfx' | 'audio'
|
|
17
|
+
src: string
|
|
18
|
+
start: number // position on project timeline (seconds)
|
|
19
|
+
end: number
|
|
20
|
+
volume?: number // 0.0–2.0, default 1.0
|
|
21
|
+
inPoint?: number // offset into source file (seconds)
|
|
22
|
+
outPoint?: number // end offset in source file (seconds)
|
|
23
|
+
label?: string // display name, defaults to filename
|
|
24
|
+
muted?: boolean
|
|
25
|
+
ducking?: {
|
|
26
|
+
enabled: boolean
|
|
27
|
+
depth?: number // dB, default -12
|
|
28
|
+
attack?: number // seconds, default 0.3
|
|
29
|
+
release?: number // seconds, default 0.5
|
|
30
|
+
}
|
|
31
|
+
fadeIn?: number // fade-in duration in seconds (0 = no fade)
|
|
32
|
+
fadeOut?: number // fade-out duration in seconds (0 = no fade)
|
|
33
|
+
sourceDuration?: number // intrinsic duration of the source file in seconds
|
|
34
|
+
lane?: number // visual grouping — tracks sharing a lane render in the same row
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CaptionSegment {
|
|
38
|
+
id?: string
|
|
39
|
+
text: string
|
|
40
|
+
start: number
|
|
41
|
+
end: number
|
|
42
|
+
words?: Word[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Captions {
|
|
46
|
+
style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle'
|
|
47
|
+
segments: CaptionSegment[]
|
|
48
|
+
// ffmpeg-drawtext render params — ignored by JSX preview, used by render.js ffmpeg branch
|
|
49
|
+
position?: 'center' | 'top-left' | 'bottom-left'
|
|
50
|
+
color?: string
|
|
51
|
+
fontsize?: number
|
|
52
|
+
bgColor?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface VisualItem {
|
|
56
|
+
id: string
|
|
57
|
+
type: 'overlay' | 'image' | 'video'
|
|
58
|
+
src?: string
|
|
59
|
+
start: number
|
|
60
|
+
end: number
|
|
61
|
+
sourceDuration?: number // video type only — used for right-edge drag guard
|
|
62
|
+
inPoint?: number // video type only
|
|
63
|
+
outPoint?: number // video type only
|
|
64
|
+
loop?: boolean // video type only — loop source clip within project window
|
|
65
|
+
transition?: { type: string; duration: number } // video type only — transition into next clip
|
|
66
|
+
offsetX?: number
|
|
67
|
+
offsetY?: number
|
|
68
|
+
scale?: number
|
|
69
|
+
opacity?: number // 0.0–1.0
|
|
70
|
+
fit?: 'cover' | 'contain' | 'fill' // image type only — how the source fills its box. Default 'cover' (AR-preserving fill+crop). 'contain' letterboxes; 'fill' is legacy stretch (no AR).
|
|
71
|
+
volume?: number // video audio level 0.0–2.0, default 1.0 (ignored for images)
|
|
72
|
+
rotation?: number // degrees, clockwise
|
|
73
|
+
opaque?: boolean // legacy boolean kept for old overlay items
|
|
74
|
+
props?: Record<string, unknown> // overlay type only
|
|
75
|
+
googleFonts?: string[] // overlay type only — Google Fonts family specs (e.g. ["Syne:wght@800"])
|
|
76
|
+
remove_bg?: boolean // video type only
|
|
77
|
+
nobg_src?: string // video type only — ProRes 4444 .mov for final render
|
|
78
|
+
nobg_preview_src?: string // video type only — VP9 WebM with alpha for browser preview
|
|
79
|
+
muted?: boolean // video type only — suppress audio in preview and render
|
|
80
|
+
generation?: { // ai_video only — frozen provenance from Kling generation
|
|
81
|
+
// Single-shot fields (present when multiShot is falsy).
|
|
82
|
+
sceneId?: string
|
|
83
|
+
prompt?: string
|
|
84
|
+
refImages?: string[]
|
|
85
|
+
duration?: number
|
|
86
|
+
// Shared fields.
|
|
87
|
+
provider?: string
|
|
88
|
+
model?: string
|
|
89
|
+
attempts?: Array<{ ts: string; prompt: string; src: string }>
|
|
90
|
+
eval?: {
|
|
91
|
+
pass: boolean
|
|
92
|
+
scores: Record<string, number>
|
|
93
|
+
attempt: number
|
|
94
|
+
}
|
|
95
|
+
// Multi-shot / batched fields. When multiShot is true, the clip represents a
|
|
96
|
+
// batch of up to 6 scenes generated in ONE Kling call. The outer sceneId/
|
|
97
|
+
// prompt/refImages fields are replaced by batchShots[] which carries the
|
|
98
|
+
// per-scene mapping inside the concatenated output video.
|
|
99
|
+
multiShot?: boolean
|
|
100
|
+
shotType?: 'customize' | 'intelligence'
|
|
101
|
+
batchShots?: Array<{
|
|
102
|
+
sceneId: string
|
|
103
|
+
index: number // 1-based, matches Kling's multi_prompt[].index
|
|
104
|
+
prompt: string // combined prompt for this shot (styleAnchor + scene prose + tokens)
|
|
105
|
+
start: number // shot start, seconds, RELATIVE to the batch clip
|
|
106
|
+
end: number // shot end, seconds, RELATIVE to the batch clip
|
|
107
|
+
duration: number
|
|
108
|
+
}>
|
|
109
|
+
}
|
|
110
|
+
// Legacy fields for old text overlay items (pre-schema migration)
|
|
111
|
+
position?: string
|
|
112
|
+
text?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface Asset {
|
|
116
|
+
id: string
|
|
117
|
+
src: string
|
|
118
|
+
type: 'image'
|
|
119
|
+
name?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Carousel types ─────────────────────────────────────────────────────────
|
|
123
|
+
export interface ImageElement {
|
|
124
|
+
id: string
|
|
125
|
+
type: 'image'
|
|
126
|
+
src: string
|
|
127
|
+
x: number
|
|
128
|
+
y: number
|
|
129
|
+
w: number
|
|
130
|
+
h: number
|
|
131
|
+
rotation: number
|
|
132
|
+
/**
|
|
133
|
+
* Optional non-destructive crop expressed as a sub-rectangle of the source
|
|
134
|
+
* image in 0–1 fractions. The editor (mission-control) is the sole enforcer
|
|
135
|
+
* of the aspect-lock invariant (crop pixel aspect == element pixel aspect);
|
|
136
|
+
* the server validates only bounds. The renderer's object-fit: cover acts as
|
|
137
|
+
* a graceful-degradation safety net when the invariant is violated by a
|
|
138
|
+
* manual project.json edit. Absent = no crop = current behavior.
|
|
139
|
+
*/
|
|
140
|
+
crop?: { x: number; y: number; w: number; h: number }
|
|
141
|
+
/**
|
|
142
|
+
* Optional passthrough field used by host apps (e.g. Hub) to link this
|
|
143
|
+
* image element to an external media record. Montaj preserves the value
|
|
144
|
+
* through load→save round-trips but never interprets it; the host app's
|
|
145
|
+
* adapter (e.g. resolveImageSrc) is responsible for resolving the actual URL.
|
|
146
|
+
*/
|
|
147
|
+
mediaId?: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface OverlayElement {
|
|
151
|
+
id: string
|
|
152
|
+
type: 'overlay'
|
|
153
|
+
overlay: { template: string; props: Record<string, unknown> }
|
|
154
|
+
frame: number
|
|
155
|
+
x: number
|
|
156
|
+
y: number
|
|
157
|
+
w: number
|
|
158
|
+
h: number
|
|
159
|
+
rotation: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type CarouselElement = ImageElement | OverlayElement
|
|
163
|
+
|
|
164
|
+
export interface Slide {
|
|
165
|
+
id: string
|
|
166
|
+
base_color: string
|
|
167
|
+
elements: CarouselElement[]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The editor-facing view of a Montaj project. Captures only the fields the
|
|
172
|
+
* carousel editor reads or writes. Field types mirror the host Project
|
|
173
|
+
* interface exactly so that a full host Project is assignable to EditorProject.
|
|
174
|
+
*
|
|
175
|
+
* The index signature lets host-only / pipeline fields (workflow, storyboard,
|
|
176
|
+
* regenQueue, version, …) pass through at the type level without the package
|
|
177
|
+
* needing to know about them.
|
|
178
|
+
*/
|
|
179
|
+
export interface EditorProject {
|
|
180
|
+
id: string
|
|
181
|
+
status: 'pending' | 'storyboard_ready' | 'draft' | 'final'
|
|
182
|
+
settings: { resolution: [number, number]; fps?: number; brandKit?: string }
|
|
183
|
+
name?: string | null
|
|
184
|
+
editingPrompt?: string
|
|
185
|
+
slides?: Slide[]
|
|
186
|
+
tracks?: VisualItem[][]
|
|
187
|
+
captions?: Captions
|
|
188
|
+
audio?: { tracks: AudioTrack[] }
|
|
189
|
+
assets?: Asset[]
|
|
190
|
+
carousel?: { aspect: string }
|
|
191
|
+
profile?: string
|
|
192
|
+
// Host-only / pipeline fields pass through at the type level.
|
|
193
|
+
[key: string]: unknown
|
|
194
|
+
}
|