@bycrux/editor 1.2.1 → 1.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 +2 -2
- package/src/ControlsInfoModal.tsx +4 -4
- package/src/__tests__/no-opacity-modifier-on-editor-vars.test.ts +84 -0
- package/src/__tests__/node-shims.d.ts +19 -0
- package/src/carousel/AddElementMenu.tsx +1 -1
- package/src/carousel/CarouselEditor.tsx +11 -11
- package/src/carousel/CarouselRenderModal.tsx +11 -11
- package/src/carousel/OverlayPicker.tsx +3 -3
- package/src/carousel/SlidePropertyPanel.tsx +6 -6
- package/src/carousel/__tests__/CarouselEditor.test.tsx +12 -0
- package/src/components/FilmstripScrubber.tsx +1 -1
- package/src/crop/VideoSourceCropModal.tsx +4 -4
- package/src/index.ts +1 -0
- package/src/state/__tests__/use-project-sync.test.tsx +76 -0
- package/src/state/use-project-sync.ts +21 -1
- package/src/text/FontPicker.tsx +3 -3
- package/src/text/TextFormattingToolbar.tsx +3 -3
- package/src/types.ts +84 -0
- package/src/ui/Loader.tsx +1 -1
- package/src/ui/NumberField.tsx +3 -3
- package/src/ui/SwatchInput.tsx +1 -1
- package/src/ui/Tooltip.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/label.tsx +1 -1
- package/src/ui/textarea.tsx +1 -1
- package/src/video/AudioPolishModal.tsx +21 -21
- package/src/video/CaptionListPanel.test.tsx +8 -7
- package/src/video/CaptionListPanel.tsx +17 -17
- package/src/video/CaptionRegenModal.tsx +41 -15
- package/src/video/CaptionStyleGallery.tsx +1 -1
- package/src/video/CommandPalette.tsx +7 -7
- package/src/video/ImageToneMenu.tsx +7 -7
- package/src/video/OverlayInspector.tsx +7 -7
- package/src/video/RenderModal.tsx +52 -52
- package/src/video/VersionCompare.tsx +7 -7
- package/src/video/VersionPanel.tsx +2 -2
- package/src/video/VideoEditor.tsx +55 -25
- package/src/video/__tests__/CaptionRegenModal.test.tsx +161 -2
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +102 -0
- package/src/video/__tests__/VideoEditor.pendingSurface.test.tsx +233 -0
- package/src/video/__tests__/VideoEditor.saveError.test.tsx +105 -0
- package/src/video/__tests__/captionProfileDefaults.test.ts +121 -0
- package/src/video/captionProfileDefaults.ts +86 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +4 -4
- package/src/video/panels/LeftPanelTabs.tsx +20 -2
- package/src/video/panels/OverlayContentPanel.tsx +5 -5
- package/src/video/preview/OverlayItemsLayer.tsx +7 -2
- package/src/video/preview/SocialPreviewMenu.tsx +5 -5
- package/src/video/preview/__tests__/resolveOverlayPropPaths.test.ts +21 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +2 -2
- package/src/video/timeline/SpeedControl.tsx +3 -3
- package/src/video/timeline/Timeline.tsx +3 -3
- package/src/video/timeline/TrackGutter.tsx +6 -6
- package/src/video/timeline/TrackSettingsPopover.tsx +3 -3
- package/src/video/timeline/VolumeControl.tsx +3 -3
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor, act } from '@testing-library/react'
|
|
3
|
+
import type {
|
|
4
|
+
EditorAdapter,
|
|
5
|
+
ImageElement,
|
|
6
|
+
Project,
|
|
7
|
+
RenderEvent,
|
|
8
|
+
VersionEntry,
|
|
9
|
+
WaveformChunk,
|
|
10
|
+
} from '../../types'
|
|
11
|
+
import VideoEditor from '../VideoEditor'
|
|
12
|
+
|
|
13
|
+
// `pendingSurface` and `onUserEdit`: a host that owns the pending gate
|
|
14
|
+
// (`'host'`) gets the full review editor while `status === 'pending'`, and a
|
|
15
|
+
// signal that tells its own user's edits apart from an agent's SSE writes.
|
|
16
|
+
// Adapter helpers copied from VideoEditor.test.tsx.
|
|
17
|
+
|
|
18
|
+
// ── Fake adapter ──────────────────────────────────────────────────────────────
|
|
19
|
+
// Full EditorAdapter with the video-editor capabilities VideoEditor threads:
|
|
20
|
+
// listVersionHistory / restoreVersion / getWaveformChunks / compileOverlay /
|
|
21
|
+
// fileUrl / resolveCaptionTemplate. No host (`@/`) modules are mocked — the
|
|
22
|
+
// package owns the assembled editor.
|
|
23
|
+
|
|
24
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
25
|
+
return {
|
|
26
|
+
id: 'vid-1',
|
|
27
|
+
name: 'Test Video',
|
|
28
|
+
status: 'draft',
|
|
29
|
+
editingPrompt: '',
|
|
30
|
+
projectType: 'video',
|
|
31
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
32
|
+
tracks: [
|
|
33
|
+
[
|
|
34
|
+
{
|
|
35
|
+
id: 'clip-0',
|
|
36
|
+
type: 'video',
|
|
37
|
+
src: 'a.mp4',
|
|
38
|
+
start: 0,
|
|
39
|
+
end: 4,
|
|
40
|
+
inPoint: 0,
|
|
41
|
+
outPoint: 4,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
],
|
|
45
|
+
audio: { tracks: [] },
|
|
46
|
+
assets: [],
|
|
47
|
+
...overrides,
|
|
48
|
+
} as Project
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface FakeAdapter extends EditorAdapter<Project> {
|
|
52
|
+
saveCalls: Array<{ id: string; project: Project }>
|
|
53
|
+
/** Push a server-authored SSE frame to every active subscriber. */
|
|
54
|
+
emit: (project: Project) => void
|
|
55
|
+
/** When true, saveProject() blocks until flushSaves() so a save stays pending. */
|
|
56
|
+
setHoldSaves: (hold: boolean) => void
|
|
57
|
+
/** Resolve every held saveProject() promise. */
|
|
58
|
+
flushSaves: () => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeFakeAdapter(): FakeAdapter {
|
|
62
|
+
const saveCalls: Array<{ id: string; project: Project }> = []
|
|
63
|
+
let subscribers: Array<(project: Project) => void> = []
|
|
64
|
+
let holdSaves = false
|
|
65
|
+
let saveResolvers: Array<() => void> = []
|
|
66
|
+
return {
|
|
67
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
68
|
+
saveProject: vi.fn(async (id: string, project: Project) => {
|
|
69
|
+
saveCalls.push({ id, project })
|
|
70
|
+
if (holdSaves) await new Promise<void>((resolve) => saveResolvers.push(resolve))
|
|
71
|
+
}),
|
|
72
|
+
// Capture the sync core's frame callback so a test can drive SSE frames.
|
|
73
|
+
subscribe: (_id: string, onFrame: (project: Project) => void) => {
|
|
74
|
+
subscribers.push(onFrame)
|
|
75
|
+
return () => { subscribers = subscribers.filter((s) => s !== onFrame) }
|
|
76
|
+
},
|
|
77
|
+
render: async function* (): AsyncIterable<RenderEvent> {
|
|
78
|
+
yield { type: 'done', outputPath: '/out.mp4' }
|
|
79
|
+
},
|
|
80
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
81
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
82
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
83
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
84
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
85
|
+
fileUrl: (path: string) => path,
|
|
86
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
87
|
+
restoreVersion: vi.fn(async (_id: string, _hash: string) => makeVideoProject()),
|
|
88
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
89
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
90
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
91
|
+
saveCalls,
|
|
92
|
+
emit: (project: Project) => { for (const s of [...subscribers]) s(project) },
|
|
93
|
+
setHoldSaves: (hold: boolean) => { holdSaves = hold },
|
|
94
|
+
flushSaves: () => { const r = saveResolvers; saveResolvers = []; r.forEach((res) => res()) },
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
// The caption panel now splits into Style / Captions sub-tabs and defaults
|
|
100
|
+
// to Style; these tests want the transcript + Regenerate trigger visible, so
|
|
101
|
+
// pin the sub-tab to 'captions' (usePersistentState reads this at mount).
|
|
102
|
+
window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
|
|
103
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
104
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
105
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
106
|
+
observe() {}
|
|
107
|
+
unobserve() {}
|
|
108
|
+
disconnect() {}
|
|
109
|
+
}
|
|
110
|
+
// jsdom doesn't implement media element playback.
|
|
111
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
112
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
113
|
+
// jsdom has no Web Audio API; the video player wires per-clip gain through it.
|
|
114
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
115
|
+
state = 'running'
|
|
116
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
117
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
118
|
+
get destination() { return {} }
|
|
119
|
+
close() {}
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
afterEach(() => vi.restoreAllMocks())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
function pendingProject(overrides: Partial<Project> = {}): Project {
|
|
126
|
+
return makeVideoProject({ status: 'pending', tracks: [{ id: 'trk-0', items: [] }], ...overrides } as Partial<Project>)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('VideoEditor — pendingSurface', () => {
|
|
130
|
+
it('default: a pending project shows the built-in pending surface, not the review chrome', async () => {
|
|
131
|
+
const adapter = makeFakeAdapter()
|
|
132
|
+
const { findByTestId, queryByText, container } = render(
|
|
133
|
+
<VideoEditor
|
|
134
|
+
project={pendingProject()}
|
|
135
|
+
adapter={adapter}
|
|
136
|
+
onProjectChange={vi.fn()}
|
|
137
|
+
slots={{ pendingStatus: <div data-testid="pending" /> }}
|
|
138
|
+
/>,
|
|
139
|
+
)
|
|
140
|
+
await findByTestId('pending')
|
|
141
|
+
expect(container.textContent).toContain('project id:')
|
|
142
|
+
expect(queryByText('Render →')).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("'host': a pending project renders the review editor and none of the pending surface", async () => {
|
|
146
|
+
const adapter = makeFakeAdapter()
|
|
147
|
+
const { findByText, queryByTestId, container } = render(
|
|
148
|
+
<VideoEditor
|
|
149
|
+
project={makeVideoProject({ status: 'pending' })}
|
|
150
|
+
adapter={adapter}
|
|
151
|
+
onProjectChange={vi.fn()}
|
|
152
|
+
pendingSurface="host"
|
|
153
|
+
onBackToSetup={vi.fn()}
|
|
154
|
+
slots={{ pendingStatus: <div data-testid="pending" /> }}
|
|
155
|
+
/>,
|
|
156
|
+
)
|
|
157
|
+
await findByText('Render →')
|
|
158
|
+
expect(queryByTestId('pending')).toBeNull()
|
|
159
|
+
expect(container.textContent).not.toContain('project id:')
|
|
160
|
+
expect(container.textContent).not.toMatch(/Back to setup/i)
|
|
161
|
+
// The skill-path fetch lives in the pending surface only.
|
|
162
|
+
expect(adapter.getInfo).not.toHaveBeenCalled()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("'host': a pending project with zero-duration placeholders renders without throwing", async () => {
|
|
166
|
+
const adapter = makeFakeAdapter()
|
|
167
|
+
const project = makeVideoProject({
|
|
168
|
+
status: 'pending',
|
|
169
|
+
tracks: [
|
|
170
|
+
[
|
|
171
|
+
{ id: 'ph-0', type: 'video', src: 'a.mp4', start: 0, end: 0 },
|
|
172
|
+
{ id: 'ph-1', type: 'video', src: 'b.mp4' },
|
|
173
|
+
],
|
|
174
|
+
],
|
|
175
|
+
} as unknown as Partial<Project>)
|
|
176
|
+
const { findByText } = render(
|
|
177
|
+
<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} pendingSurface="host" />,
|
|
178
|
+
)
|
|
179
|
+
await findByText('Render →')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("'host': an SSE frame flipping pending to draft keeps the same surface mounted", async () => {
|
|
183
|
+
const adapter = makeFakeAdapter()
|
|
184
|
+
const initial = makeVideoProject({ status: 'pending' })
|
|
185
|
+
const onProjectChange = vi.fn()
|
|
186
|
+
const { findByText } = render(
|
|
187
|
+
<VideoEditor project={initial} adapter={adapter} onProjectChange={onProjectChange} pendingSurface="host" />,
|
|
188
|
+
)
|
|
189
|
+
const before = await findByText('Render →')
|
|
190
|
+
await act(async () => { adapter.emit({ ...initial, status: 'draft' }) })
|
|
191
|
+
await waitFor(() => {
|
|
192
|
+
expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'draft' }))
|
|
193
|
+
})
|
|
194
|
+
const after = await findByText('Render →')
|
|
195
|
+
expect(after).toBe(before)
|
|
196
|
+
expect(before.isConnected).toBe(true)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('VideoEditor — onUserEdit', () => {
|
|
201
|
+
it('fires once for a user edit and once for undo, never for an SSE frame', async () => {
|
|
202
|
+
const adapter = makeFakeAdapter()
|
|
203
|
+
const initial = makeVideoProject({ status: 'draft' })
|
|
204
|
+
const onUserEdit = vi.fn()
|
|
205
|
+
const { findByText } = render(
|
|
206
|
+
<VideoEditor
|
|
207
|
+
project={initial}
|
|
208
|
+
adapter={adapter}
|
|
209
|
+
onProjectChange={vi.fn()}
|
|
210
|
+
onUserEdit={onUserEdit}
|
|
211
|
+
slots={{ exportActions: <div /> }}
|
|
212
|
+
/>,
|
|
213
|
+
)
|
|
214
|
+
const renderBtn = await findByText('Render →')
|
|
215
|
+
// Let mount-time normalization settle: it goes through applyExternal.
|
|
216
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
217
|
+
expect(onUserEdit).not.toHaveBeenCalled()
|
|
218
|
+
|
|
219
|
+
// Render flips status to 'final' through sync.mutate: a user edit.
|
|
220
|
+
await act(async () => { renderBtn.click() })
|
|
221
|
+
expect(onUserEdit).toHaveBeenCalledTimes(1)
|
|
222
|
+
|
|
223
|
+
await act(async () => {
|
|
224
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
225
|
+
})
|
|
226
|
+
expect(onUserEdit).toHaveBeenCalledTimes(2)
|
|
227
|
+
|
|
228
|
+
await waitFor(() => expect(adapter.saveCalls.length).toBe(2))
|
|
229
|
+
await act(async () => { adapter.emit({ ...initial, name: 'Agent rename' }) })
|
|
230
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
231
|
+
expect(onUserEdit).toHaveBeenCalledTimes(2)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor, act, fireEvent, screen } from '@testing-library/react'
|
|
3
|
+
import type { EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
|
|
4
|
+
import VideoEditor from '../VideoEditor'
|
|
5
|
+
|
|
6
|
+
// ── Save-error banner ────────────────────────────────────────────────────────
|
|
7
|
+
// `sync.lastError` (use-project-sync.ts) used to go unrendered in the video
|
|
8
|
+
// editor: a failed save rolled the edit back with no visible sign anything
|
|
9
|
+
// went wrong. CarouselEditor already surfaces it as a dismissible banner
|
|
10
|
+
// (CarouselEditor.tsx ~584-590); this mirrors that pattern in VideoEditor.
|
|
11
|
+
//
|
|
12
|
+
// "M drops a marker" (VideoEditor.keymap.test.tsx) is reused as the trigger —
|
|
13
|
+
// the simplest real `sync.mutate` call reachable without selecting a clip —
|
|
14
|
+
// so this test can focus on the banner itself rather than on how to cause a
|
|
15
|
+
// mutation.
|
|
16
|
+
|
|
17
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
18
|
+
return {
|
|
19
|
+
id: 'vid-1',
|
|
20
|
+
name: 'Test Video',
|
|
21
|
+
status: 'draft',
|
|
22
|
+
editingPrompt: '',
|
|
23
|
+
projectType: 'video',
|
|
24
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
25
|
+
tracks: [
|
|
26
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
27
|
+
],
|
|
28
|
+
audio: { tracks: [] },
|
|
29
|
+
assets: [],
|
|
30
|
+
...overrides,
|
|
31
|
+
} as Project
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeFailingSaveAdapter(): EditorAdapter<Project> {
|
|
35
|
+
return {
|
|
36
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
37
|
+
saveProject: vi.fn(async () => { throw new Error('save boom') }),
|
|
38
|
+
subscribe: () => () => {},
|
|
39
|
+
render: async function* (): AsyncIterable<RenderEvent> { yield { type: 'done', outputPath: '/out.mp4' } },
|
|
40
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
41
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
42
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
43
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
44
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
45
|
+
fileUrl: (path: string) => path,
|
|
46
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
47
|
+
restoreVersion: vi.fn(async () => makeVideoProject()),
|
|
48
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
49
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
50
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
56
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
57
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
58
|
+
observe() {}
|
|
59
|
+
unobserve() {}
|
|
60
|
+
disconnect() {}
|
|
61
|
+
}
|
|
62
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
63
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
64
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
65
|
+
state = 'running'
|
|
66
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
67
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
68
|
+
get destination() { return {} }
|
|
69
|
+
close() {}
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
afterEach(() => vi.restoreAllMocks())
|
|
73
|
+
|
|
74
|
+
describe('VideoEditor — save-error banner', () => {
|
|
75
|
+
it('shows sync.lastError when a save rejects, and dismiss clears it', async () => {
|
|
76
|
+
const adapter = makeFailingSaveAdapter()
|
|
77
|
+
render(
|
|
78
|
+
<VideoEditor
|
|
79
|
+
project={makeVideoProject()}
|
|
80
|
+
adapter={adapter}
|
|
81
|
+
onProjectChange={vi.fn()}
|
|
82
|
+
slots={{ exportActions: <div /> }}
|
|
83
|
+
/>,
|
|
84
|
+
)
|
|
85
|
+
await screen.findByLabelText('Preview axis')
|
|
86
|
+
|
|
87
|
+
// No error yet.
|
|
88
|
+
expect(screen.queryByText('save boom')).toBeNull()
|
|
89
|
+
|
|
90
|
+
// Drop a marker — the simplest sync.mutate a mounted editor can reach
|
|
91
|
+
// without first selecting a clip. Its save rejects per the adapter above.
|
|
92
|
+
await act(async () => {
|
|
93
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' }))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const banner = await waitFor(() => screen.getByText('save boom'))
|
|
97
|
+
expect(banner).toBeTruthy()
|
|
98
|
+
expect(adapter.saveProject).toHaveBeenCalledTimes(1)
|
|
99
|
+
|
|
100
|
+
const dismissBtn = screen.getByText('dismiss')
|
|
101
|
+
await act(async () => { fireEvent.click(dismissBtn) })
|
|
102
|
+
|
|
103
|
+
expect(screen.queryByText('save boom')).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import type { EditorAdapter, Project } from '../../types'
|
|
3
|
+
import type { Captions } from '../../schema'
|
|
4
|
+
import { mergeCaptionProfileDefaults, resolveCaptionProfileDefaults } from '../captionProfileDefaults'
|
|
5
|
+
|
|
6
|
+
// A freshly transcribed track as the host routes actually return one: a bare
|
|
7
|
+
// `{ style, segments }` with no text styling at all (montaj's own
|
|
8
|
+
// `steps/caption/caption.py` builds exactly this shape). Every field the
|
|
9
|
+
// profile can seed is therefore absent here, which is the whole reason seeding
|
|
10
|
+
// is worth doing.
|
|
11
|
+
const FRESH: Captions = {
|
|
12
|
+
style: 'pop',
|
|
13
|
+
segments: [{ text: 'hola', start: 0, end: 1, words: [] }],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function adapterWith(
|
|
17
|
+
getCaptionProfileDefaults?: EditorAdapter<Project>['getCaptionProfileDefaults'],
|
|
18
|
+
): EditorAdapter<Project> {
|
|
19
|
+
return { getCaptionProfileDefaults } as unknown as EditorAdapter<Project>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('mergeCaptionProfileDefaults', () => {
|
|
23
|
+
it('fills the fields the server left unset', () => {
|
|
24
|
+
const merged = mergeCaptionProfileDefaults(FRESH, {
|
|
25
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
26
|
+
color: '#112233',
|
|
27
|
+
})
|
|
28
|
+
expect(merged.fontFamily).toBe('"Inter", system-ui, sans-serif')
|
|
29
|
+
expect(merged.color).toBe('#112233')
|
|
30
|
+
expect(merged.segments).toBe(FRESH.segments)
|
|
31
|
+
expect(merged.style).toBe('pop')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('never overwrites a color the server response already set', () => {
|
|
35
|
+
// The route returns a bare track today. If it ever starts carrying a
|
|
36
|
+
// color, that value is authored downstream of the profile and wins.
|
|
37
|
+
const withColor: Captions = { ...FRESH, color: '#ff0000' }
|
|
38
|
+
const merged = mergeCaptionProfileDefaults(withColor, { color: '#112233' })
|
|
39
|
+
expect(merged.color).toBe('#ff0000')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('never overwrites a fontFamily the server response already set', () => {
|
|
43
|
+
const withFont: Captions = { ...FRESH, fontFamily: '"Figtree", system-ui, sans-serif' }
|
|
44
|
+
const merged = mergeCaptionProfileDefaults(withFont, {
|
|
45
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
46
|
+
googleFonts: ['Inter:wght@700'],
|
|
47
|
+
})
|
|
48
|
+
expect(merged.fontFamily).toBe('"Figtree", system-ui, sans-serif')
|
|
49
|
+
expect(merged.googleFonts).toBeUndefined()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('carries googleFonts along with a seeded fontFamily', () => {
|
|
53
|
+
// `fontFamily` and `googleFonts` travel together (see the `Captions` doc
|
|
54
|
+
// comment): a family whose file is not also fetched renders as the
|
|
55
|
+
// fallback face, in the preview AND the export. Seeding one without the
|
|
56
|
+
// other is the silent half-failure this pair exists to avoid.
|
|
57
|
+
const merged = mergeCaptionProfileDefaults(FRESH, {
|
|
58
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
59
|
+
googleFonts: ['Inter:wght@700'],
|
|
60
|
+
})
|
|
61
|
+
expect(merged.googleFonts).toEqual(['Inter:wght@700'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('does not inject googleFonts when the family was not seeded', () => {
|
|
65
|
+
const withFont: Captions = { ...FRESH, fontFamily: '"Figtree", system-ui, sans-serif' }
|
|
66
|
+
const merged = mergeCaptionProfileDefaults(withFont, { googleFonts: ['Inter:wght@700'] })
|
|
67
|
+
expect(merged.googleFonts).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns the very same object when there is nothing to seed', () => {
|
|
71
|
+
// Reference identity, not deep equality: the caller feeds the result
|
|
72
|
+
// straight to `applyExternal`, and a fresh object there is a state change
|
|
73
|
+
// the editor has to reconcile for no reason.
|
|
74
|
+
expect(mergeCaptionProfileDefaults(FRESH, null)).toBe(FRESH)
|
|
75
|
+
expect(mergeCaptionProfileDefaults(FRESH, undefined)).toBe(FRESH)
|
|
76
|
+
expect(mergeCaptionProfileDefaults(FRESH, {})).toBe(FRESH)
|
|
77
|
+
expect(mergeCaptionProfileDefaults(FRESH, { style: 'karaoke' })).toBe(FRESH)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('never writes the profile style onto the track', () => {
|
|
81
|
+
// `style` seeds the GENERATION request, not the returned track: the host
|
|
82
|
+
// reports the style it actually transcribed with, and that report wins.
|
|
83
|
+
const merged = mergeCaptionProfileDefaults(FRESH, { style: 'karaoke', color: '#112233' })
|
|
84
|
+
expect(merged.style).toBe('pop')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('treats an empty-string default as nothing to seed', () => {
|
|
88
|
+
// A host backed by a nullable column can hand back `''` for "unset".
|
|
89
|
+
expect(mergeCaptionProfileDefaults(FRESH, { fontFamily: '', color: '' })).toBe(FRESH)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('resolveCaptionProfileDefaults', () => {
|
|
94
|
+
it('returns null when the host implements no seam', async () => {
|
|
95
|
+
expect(await resolveCaptionProfileDefaults(adapterWith(), 'sam')).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns null when the project has no profile', async () => {
|
|
99
|
+
const seam = vi.fn(async () => ({ color: '#112233' }))
|
|
100
|
+
expect(await resolveCaptionProfileDefaults(adapterWith(seam), undefined)).toBeNull()
|
|
101
|
+
expect(await resolveCaptionProfileDefaults(adapterWith(seam), '')).toBeNull()
|
|
102
|
+
expect(seam).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns null rather than throwing when the seam rejects', async () => {
|
|
106
|
+
const seam = vi.fn(async () => { throw new Error('boom') })
|
|
107
|
+
await expect(resolveCaptionProfileDefaults(adapterWith(seam), 'sam')).resolves.toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns null rather than throwing when the seam throws synchronously', async () => {
|
|
111
|
+
const seam = vi.fn(() => { throw new Error('boom') }) as unknown as EditorAdapter<Project>['getCaptionProfileDefaults']
|
|
112
|
+
await expect(resolveCaptionProfileDefaults(adapterWith(seam), 'sam')).resolves.toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('passes the profile name through and returns what the host answered', async () => {
|
|
116
|
+
const answer = { style: 'karaoke', color: '#112233' }
|
|
117
|
+
const seam = vi.fn(async () => answer)
|
|
118
|
+
expect(await resolveCaptionProfileDefaults(adapterWith(seam), 'sam')).toBe(answer)
|
|
119
|
+
expect(seam).toHaveBeenCalledWith('sam')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two halves of profile-seeded caption defaults: ask the host what a
|
|
3
|
+
* profile looks like, and fold the answer onto a freshly transcribed track.
|
|
4
|
+
*
|
|
5
|
+
* Both live here rather than inside the two components that use them because
|
|
6
|
+
* they are used at two different moments of one flow — `CaptionRegenModal`
|
|
7
|
+
* resolves the defaults *before* the stream starts (it needs the style to put
|
|
8
|
+
* on the request), and `VideoEditor` merges them *after* it ends (at the one
|
|
9
|
+
* seam that applies server-authored captions outside the undo stack). The
|
|
10
|
+
* resolution happens once, in the modal, and rides along to the merge on the
|
|
11
|
+
* `onDone` callback; neither side repeats the round trip, so they cannot
|
|
12
|
+
* disagree about what the profile said.
|
|
13
|
+
*
|
|
14
|
+
* NOT to be confused with `captionStyleDefaults.ts` next door, which is a
|
|
15
|
+
* hand-copied mirror of each render TEMPLATE's own parameter defaults. These
|
|
16
|
+
* are the HOST's defaults for one named profile, and the editor knows nothing
|
|
17
|
+
* about where they come from.
|
|
18
|
+
*/
|
|
19
|
+
import type { CaptionProfileDefaults, EditorAdapter, Project } from '../types'
|
|
20
|
+
import type { Captions } from '../schema'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ask the host for `profile`'s caption defaults, best-effort.
|
|
24
|
+
*
|
|
25
|
+
* Returns `null` — never throws, never rejects — when the project has no
|
|
26
|
+
* profile, when the host implements no `getCaptionProfileDefaults`, or when
|
|
27
|
+
* that call fails in any way. Seeding is a convenience laid over the user's
|
|
28
|
+
* actual request (regenerate my captions); a profile lookup that 500s must not
|
|
29
|
+
* be the reason that request cannot be made. The synchronous `try` matters as
|
|
30
|
+
* much as the `.catch`: an adapter method that throws before returning its
|
|
31
|
+
* promise would otherwise escape past the await.
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveCaptionProfileDefaults<P extends Project = Project>(
|
|
34
|
+
adapter: EditorAdapter<P>,
|
|
35
|
+
profile: string | undefined | null,
|
|
36
|
+
): Promise<CaptionProfileDefaults | null> {
|
|
37
|
+
if (!profile || !adapter.getCaptionProfileDefaults) return null
|
|
38
|
+
try {
|
|
39
|
+
return (await adapter.getCaptionProfileDefaults(profile)) ?? null
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fold `defaults` onto a freshly transcribed `captions` track, filling only
|
|
47
|
+
* the fields the host's response left unset.
|
|
48
|
+
*
|
|
49
|
+
* Three rules, each load-bearing:
|
|
50
|
+
*
|
|
51
|
+
* - **The server's value always wins.** The caption route returns a bare
|
|
52
|
+
* `{ style, segments }` today, so in practice every seedable field is
|
|
53
|
+
* absent — but a profile default is the weakest possible authority on a
|
|
54
|
+
* track's look, below anything the host chose to state, and the day the
|
|
55
|
+
* route starts carrying a color is not the day this should start
|
|
56
|
+
* clobbering it.
|
|
57
|
+
* - **`style` is never written onto the track.** It seeds the generation
|
|
58
|
+
* REQUEST (`GenerateCaptionsOptions.style`); what comes back reports the
|
|
59
|
+
* style actually transcribed with, and that report is the truth.
|
|
60
|
+
* - **`googleFonts` rides with `fontFamily` or not at all.** Seeding a
|
|
61
|
+
* family without its font spec renders the fallback face with nothing on
|
|
62
|
+
* screen to say so (see `Captions.fontFamily`), and seeding a spec for a
|
|
63
|
+
* family we did not apply just fetches bytes no glyph uses.
|
|
64
|
+
*
|
|
65
|
+
* Returns the ORIGINAL object by reference when there is nothing to seed. The
|
|
66
|
+
* result goes straight to `applyExternal`, and a fresh object there is a state
|
|
67
|
+
* change the editor reconciles — and, downstream, a project that differs from
|
|
68
|
+
* one produced before this feature existed, for no reason a user could name.
|
|
69
|
+
*/
|
|
70
|
+
export function mergeCaptionProfileDefaults(
|
|
71
|
+
captions: Captions,
|
|
72
|
+
defaults: CaptionProfileDefaults | null | undefined,
|
|
73
|
+
): Captions {
|
|
74
|
+
if (!defaults) return captions
|
|
75
|
+
|
|
76
|
+
const patch: Partial<Captions> = {}
|
|
77
|
+
if (captions.fontFamily == null && defaults.fontFamily) {
|
|
78
|
+
patch.fontFamily = defaults.fontFamily
|
|
79
|
+
if (captions.googleFonts == null && defaults.googleFonts?.length) {
|
|
80
|
+
patch.googleFonts = defaults.googleFonts
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (captions.color == null && defaults.color) patch.color = defaults.color
|
|
84
|
+
|
|
85
|
+
return Object.keys(patch).length === 0 ? captions : { ...captions, ...patch }
|
|
86
|
+
}
|
|
@@ -68,7 +68,7 @@ export interface ClipPropertiesPanelProps {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
|
|
71
|
-
const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[var(--editor-text)]
|
|
71
|
+
const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
|
|
72
72
|
|
|
73
73
|
function basename(path: string): string {
|
|
74
74
|
return path.split('/').pop() ?? path
|
|
@@ -107,7 +107,7 @@ function CollapsibleSection({
|
|
|
107
107
|
type="button"
|
|
108
108
|
aria-expanded={!collapsed}
|
|
109
109
|
onClick={onToggle}
|
|
110
|
-
className="flex items-center gap-1 rounded px-1 py-0.5 text-[var(--editor-text)]
|
|
110
|
+
className="flex items-center gap-1 rounded px-1 py-0.5 text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] transition-colors hover:text-[var(--editor-text)]"
|
|
111
111
|
>
|
|
112
112
|
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
113
113
|
<span className={nested ? 'text-[11px] font-medium' : 'text-xs font-medium uppercase tracking-wide'}>{label}</span>
|
|
@@ -358,7 +358,7 @@ function ClipTabs({ item, onPreviewClip, onCommitClip, onChangeClip, transformSl
|
|
|
358
358
|
<div className={`flex h-11 w-11 items-center justify-center rounded-full bg-indigo-500/10 ${mode === 'light' ? 'text-indigo-600' : 'text-indigo-400'}`}>
|
|
359
359
|
<Crop size={20} />
|
|
360
360
|
</div>
|
|
361
|
-
<p className="max-w-[220px] text-xs leading-relaxed text-[var(--editor-text)]
|
|
361
|
+
<p className="max-w-[220px] text-xs leading-relaxed text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
|
|
362
362
|
Reframe this clip by cropping its source video. Pick the part of the frame to keep.
|
|
363
363
|
</p>
|
|
364
364
|
<button
|
|
@@ -678,7 +678,7 @@ export default function ClipPropertiesPanel({
|
|
|
678
678
|
if (!selection) {
|
|
679
679
|
return (
|
|
680
680
|
<div className={SECTION_CLASS}>
|
|
681
|
-
<div className="px-3 py-6 text-center text-[11px] text-[var(--editor-text)]
|
|
681
|
+
<div className="px-3 py-6 text-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
|
|
682
682
|
Select a clip to edit its properties.
|
|
683
683
|
</div>
|
|
684
684
|
</div>
|
|
@@ -148,8 +148,26 @@ export default function LeftPanelTabs({ tabs, defaultTabId, storageKey = DEFAULT
|
|
|
148
148
|
selected
|
|
149
149
|
// Label is ~10px accent TEXT → the AA-safe accent-text token
|
|
150
150
|
// (indigo-600 in light); the tint fill keeps the plain accent.
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
//
|
|
152
|
+
// The tint uses `color-mix(...)` baked directly into the
|
|
153
|
+
// arbitrary value, NOT Tailwind's `/NN` opacity modifier —
|
|
154
|
+
// that modifier only works when Tailwind itself can parse
|
|
155
|
+
// the color to inject an alpha channel, which it cannot do
|
|
156
|
+
// for an arbitrary `var(...)` reference. `text-[var(--x)]/60`
|
|
157
|
+
// silently emits NO utility at all (no build error, no
|
|
158
|
+
// fallback), in this package's own Tailwind build and in a
|
|
159
|
+
// host's. Measured: building this component under
|
|
160
|
+
// montaj-app/desktop/ui's Tailwind (v3.4) produced zero
|
|
161
|
+
// `editor-text`/`editor-accent` rules for any `/NN`-modified
|
|
162
|
+
// class here, while the identical `color-mix(in_srgb,
|
|
163
|
+
// var(--editor-text)_60%,transparent)` arbitrary value
|
|
164
|
+
// compiled correctly in both builds. That silent failure is
|
|
165
|
+
// exactly what made the rail's inactive items (all but the
|
|
166
|
+
// selected tab) unreadable — no color rule at all, so the
|
|
167
|
+
// button fell back to inherited/default text color against
|
|
168
|
+
// the editor's dark background.
|
|
169
|
+
? 'text-[var(--editor-accent-text)] bg-[color-mix(in_srgb,var(--editor-accent)_10%,transparent)]'
|
|
170
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]',
|
|
153
171
|
)}
|
|
154
172
|
>
|
|
155
173
|
{selected && (
|
|
@@ -65,7 +65,7 @@ export interface OverlayContentPanelProps {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
|
|
68
|
-
const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]
|
|
68
|
+
const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Image prop control: a thumbnail preview plus a file picker that uploads the
|
|
@@ -128,16 +128,16 @@ function ImageField({
|
|
|
128
128
|
className="h-14 w-14 shrink-0 rounded-md border border-[var(--editor-border)] object-cover bg-black/20"
|
|
129
129
|
/>
|
|
130
130
|
) : (
|
|
131
|
-
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[var(--editor-text)]
|
|
131
|
+
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">
|
|
132
132
|
none
|
|
133
133
|
</div>
|
|
134
134
|
)}
|
|
135
135
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
136
|
-
<span className="truncate font-mono text-xs text-[var(--editor-text)]
|
|
136
|
+
<span className="truncate font-mono text-xs text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]" title={value}>
|
|
137
137
|
{value ? value.split('/').pop() : '—'}
|
|
138
138
|
</span>
|
|
139
139
|
{uploadFile ? (
|
|
140
|
-
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[var(--editor-text)]
|
|
140
|
+
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]">
|
|
141
141
|
{busy ? 'Uploading…' : 'Change…'}
|
|
142
142
|
<input
|
|
143
143
|
type="file"
|
|
@@ -324,7 +324,7 @@ export default function OverlayContentPanel({
|
|
|
324
324
|
<div className={SECTION_CLASS}>
|
|
325
325
|
<div className="flex flex-col gap-3 p-2">
|
|
326
326
|
{fields.length === 0 ? (
|
|
327
|
-
<p className="px-1 py-4 text-center text-[11px] text-[var(--editor-text)]
|
|
327
|
+
<p className="px-1 py-4 text-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
|
|
328
328
|
{item
|
|
329
329
|
? 'This overlay has no editable content.'
|
|
330
330
|
: 'Select an overlay to edit its content.'}
|