@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.
Files changed (54) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/carousel/AddElementMenu.tsx +211 -0
  7. package/src/carousel/CarouselEditor.tsx +529 -0
  8. package/src/carousel/CarouselRenderModal.tsx +243 -0
  9. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  10. package/src/carousel/OverlayPicker.tsx +145 -0
  11. package/src/carousel/SlideCanvas.tsx +588 -0
  12. package/src/carousel/SlidePropertyPanel.tsx +349 -0
  13. package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
  14. package/src/crop/CanvasCropOverlay.tsx +193 -0
  15. package/src/crop/__tests__/crop-math.test.ts +174 -0
  16. package/src/crop/crop-math.ts +125 -0
  17. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  18. package/src/gestures/helpers/drag.ts +24 -0
  19. package/src/gestures/helpers/element-transform.ts +15 -0
  20. package/src/gestures/helpers/resize.ts +60 -0
  21. package/src/gestures/helpers/rotate.ts +44 -0
  22. package/src/gestures/helpers/snap.ts +64 -0
  23. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  24. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  25. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  26. package/src/gestures/index.ts +16 -0
  27. package/src/index.ts +112 -0
  28. package/src/overlays/contract.ts +41 -0
  29. package/src/preview/OverlayPreview.tsx +196 -0
  30. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  31. package/src/schema.ts +194 -0
  32. package/src/state/__tests__/project-reducer.test.ts +957 -0
  33. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  34. package/src/state/mutation-queue.ts +62 -0
  35. package/src/state/project-reducer.ts +328 -0
  36. package/src/state/use-project-state.ts +442 -0
  37. package/src/test-setup.ts +1 -0
  38. package/src/text/FontPicker.tsx +218 -0
  39. package/src/text/InlineTextEditor.tsx +92 -0
  40. package/src/text/TextFormattingToolbar.tsx +248 -0
  41. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  42. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  43. package/src/theme.ts +93 -0
  44. package/src/types.ts +325 -0
  45. package/src/ui/__tests__/button.test.tsx +17 -0
  46. package/src/ui/badge.tsx +32 -0
  47. package/src/ui/button.tsx +32 -0
  48. package/src/ui/index.ts +16 -0
  49. package/src/ui/input.tsx +15 -0
  50. package/src/ui/label.tsx +10 -0
  51. package/src/ui/select.tsx +23 -0
  52. package/src/ui/switch.tsx +31 -0
  53. package/src/ui/textarea.tsx +15 -0
  54. 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
+ }