@bycrux/editor 1.2.0 → 1.2.2

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.
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest'
2
2
  import { render, screen, waitFor, cleanup } from '@testing-library/react'
3
- import type { CaptionEvent, EditorAdapter, ImageElement, Project } from '../../types'
3
+ import type { CaptionEvent, CaptionProfileDefaults, EditorAdapter, ImageElement, Project } from '../../types'
4
4
  import type { Captions } from '../../schema'
5
5
  import CaptionRegenModal from '../CaptionRegenModal'
6
6
 
@@ -45,7 +45,9 @@ describe('CaptionRegenModal', () => {
45
45
  )
46
46
 
47
47
  await waitFor(() => expect(screen.getByText(/transcribing audio/i)).toBeTruthy())
48
- await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions))
48
+ // Second argument is the profile defaults the host resolved — `null` on
49
+ // this adapter, which implements no `getCaptionProfileDefaults`.
50
+ await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, null))
49
51
  })
50
52
 
51
53
  it('shows an error message verbatim on error', async () => {
@@ -92,3 +94,160 @@ describe('CaptionRegenModal', () => {
92
94
  expect(screen.queryByText(/caption rows/i)).toBeNull()
93
95
  })
94
96
  })
97
+
98
+ // ── Profile-seeded caption defaults ──────────────────────────────────────────
99
+ //
100
+ // `adapter.getCaptionProfileDefaults` is an OPTIONAL host seam: this package
101
+ // owns no notion of what a profile is beyond `Project.profile`'s bare name, so
102
+ // a host that can resolve one answers, and a host that cannot omits the method
103
+ // entirely. Every assertion below therefore comes in a pair — the seam present
104
+ // and the seam absent — because the absent case is the one every existing host
105
+ // (Hub, Los Parceros, the OSS `serve` UI) is in today, and it must keep
106
+ // producing the byte-identical call it produced before this seam existed.
107
+
108
+ /** Adapter whose `generateCaptions` is a spy, so the opts argument is readable. */
109
+ function makeSpyAdapter() {
110
+ const adapter = makeAdapter()
111
+ const generateCaptions = vi.fn(async function* (): AsyncIterable<CaptionEvent> {
112
+ yield { type: 'done', captions: doneCaptions }
113
+ })
114
+ adapter.generateCaptions = generateCaptions
115
+ return { adapter, generateCaptions }
116
+ }
117
+
118
+ const PROFILE_DEFAULTS: CaptionProfileDefaults = {
119
+ style: 'karaoke',
120
+ fontFamily: '"Inter", system-ui, sans-serif',
121
+ color: '#112233',
122
+ }
123
+
124
+ describe('CaptionRegenModal — profile-seeded caption defaults', () => {
125
+ it('seeds the generation style from the defaults the host resolves for the project profile', async () => {
126
+ const { adapter, generateCaptions } = makeSpyAdapter()
127
+ const getCaptionProfileDefaults = vi.fn(async () => PROFILE_DEFAULTS)
128
+ adapter.getCaptionProfileDefaults = getCaptionProfileDefaults
129
+
130
+ render(
131
+ <CaptionRegenModal
132
+ adapter={adapter}
133
+ projectId="vid-1"
134
+ profile="sam"
135
+ existingRowCount={1}
136
+ onDone={vi.fn()}
137
+ onClose={vi.fn()}
138
+ />,
139
+ )
140
+
141
+ await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1', { style: 'karaoke' }))
142
+ expect(getCaptionProfileDefaults).toHaveBeenCalledWith('sam')
143
+ })
144
+
145
+ it('hands the resolved defaults to onDone alongside the fresh captions', async () => {
146
+ // The host merges font/color onto the track at its own apply seam; the
147
+ // modal resolves the profile ONCE and passes what it got, so the host
148
+ // never repeats the round trip (and can never race a second answer).
149
+ const { adapter } = makeSpyAdapter()
150
+ adapter.getCaptionProfileDefaults = vi.fn(async () => PROFILE_DEFAULTS)
151
+ const onDone = vi.fn()
152
+
153
+ render(
154
+ <CaptionRegenModal
155
+ adapter={adapter}
156
+ projectId="vid-1"
157
+ profile="sam"
158
+ existingRowCount={1}
159
+ onDone={onDone}
160
+ onClose={vi.fn()}
161
+ />,
162
+ )
163
+
164
+ await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, PROFILE_DEFAULTS))
165
+ })
166
+
167
+ it('passes no opts at all when the host implements no caption-defaults seam', async () => {
168
+ // Today's behaviour for every existing host, pinned: a bare one-argument
169
+ // call, not `(id, undefined)` and not `(id, {})`.
170
+ const { adapter, generateCaptions } = makeSpyAdapter()
171
+ const onDone = vi.fn()
172
+
173
+ render(
174
+ <CaptionRegenModal
175
+ adapter={adapter}
176
+ projectId="vid-1"
177
+ profile="sam"
178
+ existingRowCount={1}
179
+ onDone={onDone}
180
+ onClose={vi.fn()}
181
+ />,
182
+ )
183
+
184
+ await waitFor(() => expect(onDone).toHaveBeenCalled())
185
+ expect(generateCaptions).toHaveBeenCalledWith('vid-1')
186
+ expect(onDone).toHaveBeenCalledWith(doneCaptions, null)
187
+ })
188
+
189
+ it('never asks for defaults when the project has no profile', async () => {
190
+ const { adapter, generateCaptions } = makeSpyAdapter()
191
+ const getCaptionProfileDefaults = vi.fn(async () => PROFILE_DEFAULTS)
192
+ adapter.getCaptionProfileDefaults = getCaptionProfileDefaults
193
+
194
+ render(
195
+ <CaptionRegenModal
196
+ adapter={adapter}
197
+ projectId="vid-1"
198
+ existingRowCount={1}
199
+ onDone={vi.fn()}
200
+ onClose={vi.fn()}
201
+ />,
202
+ )
203
+
204
+ await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1'))
205
+ expect(getCaptionProfileDefaults).not.toHaveBeenCalled()
206
+ })
207
+
208
+ it('still generates when the caption-defaults seam rejects', async () => {
209
+ // Seeding is a convenience. A profile lookup that 500s must never be the
210
+ // reason a user cannot regenerate their captions.
211
+ const { adapter, generateCaptions } = makeSpyAdapter()
212
+ adapter.getCaptionProfileDefaults = vi.fn(async () => { throw new Error('boom') })
213
+ const onDone = vi.fn()
214
+
215
+ render(
216
+ <CaptionRegenModal
217
+ adapter={adapter}
218
+ projectId="vid-1"
219
+ profile="sam"
220
+ existingRowCount={1}
221
+ onDone={onDone}
222
+ onClose={vi.fn()}
223
+ />,
224
+ )
225
+
226
+ await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1'))
227
+ await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, null))
228
+ expect(screen.queryByText('boom')).toBeNull()
229
+ })
230
+
231
+ it('passes no style when the profile resolves without one', async () => {
232
+ // A profile that sets only a font and a color must not send `style:
233
+ // undefined` — the host route reads presence, not value.
234
+ const { adapter, generateCaptions } = makeSpyAdapter()
235
+ const fontOnly: CaptionProfileDefaults = { fontFamily: '"Inter", system-ui, sans-serif' }
236
+ adapter.getCaptionProfileDefaults = vi.fn(async () => fontOnly)
237
+ const onDone = vi.fn()
238
+
239
+ render(
240
+ <CaptionRegenModal
241
+ adapter={adapter}
242
+ projectId="vid-1"
243
+ profile="sam"
244
+ existingRowCount={1}
245
+ onDone={onDone}
246
+ onClose={vi.fn()}
247
+ />,
248
+ )
249
+
250
+ await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1'))
251
+ await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, fontOnly))
252
+ })
253
+ })
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { render, screen, waitFor, fireEvent } from '@testing-library/react'
3
3
  import type { CaptionEvent, EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
4
+ import type { Captions } from '../../schema'
4
5
  import VideoEditor from '../VideoEditor'
5
6
 
6
7
  // ── T1 — host-driven caption trigger seam ────────────────────────────────────
@@ -132,3 +133,104 @@ describe('VideoEditor — host-driven caption trigger seam', () => {
132
133
  await waitFor(() => expect(screen.getByText('Regenerating captions…')).toBeTruthy())
133
134
  })
134
135
  })
136
+
137
+ // ── Profile-seeded caption defaults, through the real apply seam ─────────────
138
+ //
139
+ // The unit-level rules live in `captionProfileDefaults.test.ts`; what these
140
+ // two prove is the WIRING — that `project.profile` reaches the host seam, and
141
+ // that the merged track is what `applyExternal` lands on the project. There is
142
+ // no spy for `applyExternal` (it is internal to `useProjectSync`, and it
143
+ // deliberately calls neither `onProjectChange` nor `adapter.saveProject` — see
144
+ // VideoEditor.captionDelete.test.tsx's note), so the assertion reads the
145
+ // caption color back out of the Format tab's swatch, which renders
146
+ // `project.captions.color` and falls back to '#ffffff' when unset.
147
+
148
+ function readCaptionColor(): string {
149
+ fireEvent.click(screen.getByRole('button', { name: 'Format' }))
150
+ return (screen.getByLabelText('Caption text color') as HTMLInputElement).value
151
+ }
152
+
153
+ /** Adapter whose regen stream terminates with the given track. */
154
+ function makeRegenAdapter(captions: Captions) {
155
+ const adapter = makeFakeAdapter()
156
+ adapter.generateCaptions = vi.fn(async function* (): AsyncIterable<CaptionEvent> {
157
+ yield { type: 'done', captions }
158
+ })
159
+ return adapter
160
+ }
161
+
162
+ describe('VideoEditor — profile-seeded caption defaults', () => {
163
+ it('merges the profile font/color onto a server track that set neither', async () => {
164
+ const adapter = makeRegenAdapter({
165
+ style: 'pop',
166
+ segments: [{ id: 'cap-new', text: 'fresh', start: 0, end: 1, words: [{ word: 'fresh', start: 0, end: 1 }] }],
167
+ })
168
+ const getCaptionProfileDefaults = vi.fn(async () => ({
169
+ style: 'karaoke',
170
+ fontFamily: '"Inter", system-ui, sans-serif',
171
+ color: '#112233',
172
+ }))
173
+ ;(adapter as unknown as { getCaptionProfileDefaults: unknown }).getCaptionProfileDefaults = getCaptionProfileDefaults
174
+
175
+ render(
176
+ <VideoEditor
177
+ project={makeVideoProject({ profile: 'sam' })}
178
+ adapter={adapter}
179
+ onProjectChange={vi.fn()}
180
+ />,
181
+ )
182
+
183
+ fireEvent.click(await screen.findByText('Regenerate captions'))
184
+
185
+ // The modal closes itself once the host's onDone runs, which is also when
186
+ // the merged track has landed.
187
+ await waitFor(() => expect(screen.queryByText('Regenerating captions…')).toBeNull())
188
+ expect(getCaptionProfileDefaults).toHaveBeenCalledWith('sam')
189
+ expect(adapter.generateCaptions).toHaveBeenCalledWith('vid-1', { style: 'karaoke' })
190
+ expect(readCaptionColor()).toBe('#112233')
191
+ })
192
+
193
+ it('leaves a color the server response already set alone', async () => {
194
+ const adapter = makeRegenAdapter({
195
+ style: 'pop',
196
+ color: '#ff0000',
197
+ segments: [{ id: 'cap-new', text: 'fresh', start: 0, end: 1, words: [{ word: 'fresh', start: 0, end: 1 }] }],
198
+ })
199
+ ;(adapter as unknown as { getCaptionProfileDefaults: unknown }).getCaptionProfileDefaults =
200
+ vi.fn(async () => ({ color: '#112233' }))
201
+
202
+ render(
203
+ <VideoEditor
204
+ project={makeVideoProject({ profile: 'sam' })}
205
+ adapter={adapter}
206
+ onProjectChange={vi.fn()}
207
+ />,
208
+ )
209
+
210
+ fireEvent.click(await screen.findByText('Regenerate captions'))
211
+ await waitFor(() => expect(screen.queryByText('Regenerating captions…')).toBeNull())
212
+ expect(readCaptionColor()).toBe('#ff0000')
213
+ })
214
+
215
+ it('applies the server track untouched on a host with no caption-defaults seam', async () => {
216
+ // Every host shipping today is in this case; the regen call must stay the
217
+ // bare one-argument call it has always been.
218
+ const adapter = makeRegenAdapter({
219
+ style: 'pop',
220
+ segments: [{ id: 'cap-new', text: 'fresh', start: 0, end: 1, words: [{ word: 'fresh', start: 0, end: 1 }] }],
221
+ })
222
+
223
+ render(
224
+ <VideoEditor
225
+ project={makeVideoProject({ profile: 'sam' })}
226
+ adapter={adapter}
227
+ onProjectChange={vi.fn()}
228
+ />,
229
+ )
230
+
231
+ fireEvent.click(await screen.findByText('Regenerate captions'))
232
+ await waitFor(() => expect(screen.queryByText('Regenerating captions…')).toBeNull())
233
+ expect(adapter.generateCaptions).toHaveBeenCalledWith('vid-1')
234
+ expect(readCaptionColor()).toBe('#ffffff')
235
+ })
236
+ })
@@ -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
+ }
@@ -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
- ? 'text-[var(--editor-accent-text)] bg-[var(--editor-accent)]/10'
152
- : 'text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:bg-[var(--editor-text)]/5',
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 && (
@@ -112,6 +112,47 @@ interface PreviewPlayerProps {
112
112
  * note at its render site below.
113
113
  */
114
114
  socialPreview?: SocialPreviewPlatform | string | null
115
+ /**
116
+ * A second rider on this same pin bump (operator decision, 2026-09-20) —
117
+ * not font work, riding because it is the same package and the same
118
+ * release. Silences every audio path this component owns. Default `false`
119
+ * — today's behavior, unchanged, for every existing host.
120
+ *
121
+ * Exists for hosts that mount a live timeline preview somewhere audio isn't
122
+ * wanted (the project-card hover preview: moving the pointer across a grid
123
+ * must not play each project's audio in turn). `<video muted>` is NOT
124
+ * sufficient on its own — see `ensureVideoGain` and the "Multi-track audio
125
+ * management" section in `useVideoPlayback.ts`: once a slot or a lane is
126
+ * wired `MediaElementSource → GainNode → ctx.destination`, the element's
127
+ * own `muted`/`volume` stop having any audible effect and the GainNode is
128
+ * the only real lever. This prop zeroes every such GainNode (video slots
129
+ * AND background audio-track lanes) via `mutedRef` in `useVideoPlayback.ts`
130
+ * — the `muted` attribute set on the `<video>` slots below is defense in
131
+ * depth for the brief pre-wire window, not the mechanism.
132
+ *
133
+ * Read the scope of that precisely: this silences every audio path these
134
+ * hooks INSTANTIATE, which is not the same as every path reachable through
135
+ * a seam this component EXPOSES. The audible drag-scrub source
136
+ * (`engine/scrub-source.ts`) owns its own gain → destination chain on the
137
+ * same shared AudioContext, and `muted` does not touch it — it is
138
+ * constructed and driven by `VideoEditor`, and only reached here via the
139
+ * `ScrubHandle` seam below. That is sound today because a scrub needs a
140
+ * timeline-drag gesture that a thumbnail-hover mount has no UI for, so a
141
+ * silent host cannot reach it. A host that both passes `muted` and wires
142
+ * scrubbing would hear it, and would be right to call that a bug.
143
+ *
144
+ * Engine-mode (`engine.enabled`) coverage is PARTIAL: `useEnginePlayback.ts`
145
+ * zeroes the same background-lane GainNodes, but track-0 video-item audio
146
+ * is not reached, because this rider did not extend to the engine's own
147
+ * clock. It is a scope boundary, NOT a hard one — the clip's level already
148
+ * rides a per-session output `GainNode` with a live `MasterClock.setVolume`
149
+ * lever wired through `engine/index.ts`, so closing it means pushing 0 down
150
+ * that existing path. See that hook's own comment, which spells out why and
151
+ * corrects a stale claim in its file header about PCM-level scaling. No
152
+ * current host combines `engine.enabled` with `muted`, so this is a
153
+ * documented gap, not a live bug.
154
+ */
155
+ muted?: boolean
115
156
  }
116
157
 
117
158
  export default function PreviewPlayer(props: PreviewPlayerProps) {
@@ -245,12 +286,12 @@ type SurfaceProps = PreviewPlayerProps & {
245
286
  }
246
287
 
247
288
  function LegacyPreview(props: SurfaceProps) {
248
- const playback = useVideoPlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
289
+ const playback = useVideoPlayback(props.project, props.currentTime, props.timeSink, props.fileUrl, !!props.muted)
249
290
  return <PreviewSurface {...props} playback={{ mode: 'legacy', ...playback }} />
250
291
  }
251
292
 
252
293
  function EnginePreview(props: SurfaceProps) {
253
- const playback = useEnginePlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
294
+ const playback = useEnginePlayback(props.project, props.currentTime, props.timeSink, props.fileUrl, !!props.muted)
254
295
  return <PreviewSurface {...props} playback={{ mode: 'engine', ...playback }} />
255
296
  }
256
297
 
@@ -275,6 +316,7 @@ function PreviewSurface({
275
316
  transportRef,
276
317
  scrubHandleRef,
277
318
  socialPreview,
319
+ muted,
278
320
  }: SurfaceProps & { playback: PlaybackBinding }) {
279
321
  const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
280
322
 
@@ -497,6 +539,12 @@ function PreviewSurface({
497
539
  onPlay={() => { if (playback.activeSlotRef.current === 0) playback.setIsPlaying(true) }}
498
540
  onPause={() => { if (playback.activeSlotRef.current === 0) playback.handlePause() }}
499
541
  playsInline
542
+ // Defense in depth, not the mechanism — see the `muted` prop
543
+ // doc above. Once `ensureVideoGain` wires this element through
544
+ // Web Audio (on first play), this attribute stops having any
545
+ // audible effect; the GainNode zeroed via `mutedRef` in
546
+ // `useVideoPlayback.ts` is what actually silences it.
547
+ muted={!!muted}
500
548
  style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 0 ? 1 : 0, pointerEvents: playback.activeSlot === 0 ? 'auto' : 'none', zIndex: playback.activeSlot === 0 ? 1 : 0 }}
501
549
  />
502
550
  {/* Slot 1 */}
@@ -513,6 +561,8 @@ function PreviewSurface({
513
561
  onPlay={() => { if (playback.activeSlotRef.current === 1) playback.setIsPlaying(true) }}
514
562
  onPause={() => { if (playback.activeSlotRef.current === 1) playback.handlePause() }}
515
563
  playsInline
564
+ // See slot 0.
565
+ muted={!!muted}
516
566
  style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 1 ? 1 : 0, pointerEvents: playback.activeSlot === 1 ? 'auto' : 'none', zIndex: playback.activeSlot === 1 ? 1 : 0 }}
517
567
  />
518
568
  </>