@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.
Files changed (56) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +4 -4
  3. package/src/__tests__/no-opacity-modifier-on-editor-vars.test.ts +84 -0
  4. package/src/__tests__/node-shims.d.ts +19 -0
  5. package/src/carousel/AddElementMenu.tsx +1 -1
  6. package/src/carousel/CarouselEditor.tsx +11 -11
  7. package/src/carousel/CarouselRenderModal.tsx +11 -11
  8. package/src/carousel/OverlayPicker.tsx +3 -3
  9. package/src/carousel/SlidePropertyPanel.tsx +6 -6
  10. package/src/carousel/__tests__/CarouselEditor.test.tsx +12 -0
  11. package/src/components/FilmstripScrubber.tsx +1 -1
  12. package/src/crop/VideoSourceCropModal.tsx +4 -4
  13. package/src/index.ts +1 -0
  14. package/src/state/__tests__/use-project-sync.test.tsx +76 -0
  15. package/src/state/use-project-sync.ts +21 -1
  16. package/src/text/FontPicker.tsx +3 -3
  17. package/src/text/TextFormattingToolbar.tsx +3 -3
  18. package/src/types.ts +84 -0
  19. package/src/ui/Loader.tsx +1 -1
  20. package/src/ui/NumberField.tsx +3 -3
  21. package/src/ui/SwatchInput.tsx +1 -1
  22. package/src/ui/Tooltip.tsx +1 -1
  23. package/src/ui/input.tsx +1 -1
  24. package/src/ui/label.tsx +1 -1
  25. package/src/ui/textarea.tsx +1 -1
  26. package/src/video/AudioPolishModal.tsx +21 -21
  27. package/src/video/CaptionListPanel.test.tsx +8 -7
  28. package/src/video/CaptionListPanel.tsx +17 -17
  29. package/src/video/CaptionRegenModal.tsx +41 -15
  30. package/src/video/CaptionStyleGallery.tsx +1 -1
  31. package/src/video/CommandPalette.tsx +7 -7
  32. package/src/video/ImageToneMenu.tsx +7 -7
  33. package/src/video/OverlayInspector.tsx +7 -7
  34. package/src/video/RenderModal.tsx +52 -52
  35. package/src/video/VersionCompare.tsx +7 -7
  36. package/src/video/VersionPanel.tsx +2 -2
  37. package/src/video/VideoEditor.tsx +55 -25
  38. package/src/video/__tests__/CaptionRegenModal.test.tsx +161 -2
  39. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +102 -0
  40. package/src/video/__tests__/VideoEditor.pendingSurface.test.tsx +233 -0
  41. package/src/video/__tests__/VideoEditor.saveError.test.tsx +105 -0
  42. package/src/video/__tests__/captionProfileDefaults.test.ts +121 -0
  43. package/src/video/captionProfileDefaults.ts +86 -0
  44. package/src/video/panels/ClipPropertiesPanel.tsx +4 -4
  45. package/src/video/panels/LeftPanelTabs.tsx +20 -2
  46. package/src/video/panels/OverlayContentPanel.tsx +5 -5
  47. package/src/video/preview/OverlayItemsLayer.tsx +7 -2
  48. package/src/video/preview/SocialPreviewMenu.tsx +5 -5
  49. package/src/video/preview/__tests__/resolveOverlayPropPaths.test.ts +21 -0
  50. package/src/video/timeline/EditableSegment.tsx +1 -1
  51. package/src/video/timeline/Scrubber.tsx +2 -2
  52. package/src/video/timeline/SpeedControl.tsx +3 -3
  53. package/src/video/timeline/Timeline.tsx +3 -3
  54. package/src/video/timeline/TrackGutter.tsx +6 -6
  55. package/src/video/timeline/TrackSettingsPopover.tsx +3 -3
  56. package/src/video/timeline/VolumeControl.tsx +3 -3
@@ -1,5 +1,5 @@
1
1
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
2
- import { Captions, Crop, Ear, EarOff, Film, HelpCircle, History, Magnet, Maximize2, Minimize2, Redo2, SeparatorVertical, Smartphone, SquareDashedMousePointer, Undo2, Wand2 } from 'lucide-react'
2
+ import { AlertCircle, Captions, Crop, Ear, EarOff, Film, HelpCircle, History, Magnet, Maximize2, Minimize2, Redo2, SeparatorVertical, Smartphone, SquareDashedMousePointer, Undo2, Wand2 } from 'lucide-react'
3
3
  import type { Project, VideoEditorProps } from '../types'
4
4
  import type { AudioTrack, VisualItem } from '../schema'
5
5
  import { useProjectSync, type UseProjectSync } from '../state/use-project-sync'
@@ -14,6 +14,7 @@ import { collapseGaps, rippleDelete, splitAtTime } from './cuts'
14
14
  import { addMarker } from './timeline/markers'
15
15
  import { repairCaptionWords } from './captionRepair'
16
16
  import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
17
+ import { mergeCaptionProfileDefaults } from './captionProfileDefaults'
17
18
  import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
18
19
  import { visualDuration } from '@bycrux/timeline-core'
19
20
  import { audioEnd, computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems, withEnabledItemTracks } from './timeline/timeline-model'
@@ -211,6 +212,11 @@ export function exportDurationSec(project: { tracks?: unknown }): number {
211
212
  * prop, so a status transition (agent finishes → 'draft') must come from the
212
213
  * core's own stream, not the host re-rendering with a new prop.
213
214
  *
215
+ * `pendingSurface="host"` skips the pending surface: a pending project renders
216
+ * the review surface (which never branches on status), and the host draws its
217
+ * own gate over it. `onUserEdit` reports the user's own edits (never SSE frames)
218
+ * so such a host can react when the user edits while an agent is working.
219
+ *
214
220
  * ProjectHeader is lifted out (the host renders it in its shell). This component
215
221
  * renders: timeline + preview + version panel + render modal + the host-supplied
216
222
  * inspector/subcut render-prop seams + an optional back-to-setup affordance.
@@ -222,6 +228,8 @@ export default function VideoEditor<P extends Project = Project>({
222
228
  theme,
223
229
  slots,
224
230
  onBackToSetup,
231
+ pendingSurface = 'default',
232
+ onUserEdit,
225
233
  assetsPlacement = 'right',
226
234
  renderProgressView = 'phases',
227
235
  renderGenerationPanel,
@@ -244,7 +252,9 @@ export default function VideoEditor<P extends Project = Project>({
244
252
  // both the surface switch and the surface contents. `project` (the prop) is
245
253
  // only the initial value — after mount the core owns state and reconciles live
246
254
  // frames itself (video-shaped → default plain-replace reconcile).
247
- const sync = useProjectSync<P>(adapter, project.id, project)
255
+ // `onUserEdit` rides the core's `onLocalEdit` (held in a ref there, so a new
256
+ // identity per render is harmless): fired for user edits only, never SSE.
257
+ const sync = useProjectSync<P>(adapter, project.id, project, { onLocalEdit: onUserEdit })
248
258
 
249
259
  // Set for the duration of a caption drag gesture (ReviewSurface's
250
260
  // `handleProjectChange` → `commitTimelineEdit`), read by the lane-
@@ -374,9 +384,9 @@ export default function VideoEditor<P extends Project = Project>({
374
384
  // T7 — canvas-timeline filmstrips + hover-scrub; absent → Timeline renders none (graceful).
375
385
  const getFilmstrip = adapter.getFilmstrip
376
386
 
377
- if (isPending) {
387
+ if (isPending && pendingSurface !== 'host') {
378
388
  return (
379
- <div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
389
+ <div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)] text-[var(--editor-text)]">
380
390
  <PendingSurface
381
391
  sync={sync}
382
392
  adapter={adapter}
@@ -393,7 +403,7 @@ export default function VideoEditor<P extends Project = Project>({
393
403
  }
394
404
 
395
405
  return (
396
- <div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
406
+ <div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)] text-[var(--editor-text)]">
397
407
  <ReviewSurface
398
408
  sync={sync}
399
409
  captionGestureRef={captionGestureRef}
@@ -628,10 +638,10 @@ function PendingSurface<P extends Project>({
628
638
  <>
629
639
  <div className="flex flex-col items-center gap-2">
630
640
  <p className="text-[var(--editor-text)] text-lg font-semibold">Message your agent to start</p>
631
- <p className="text-[var(--editor-text)]/60 text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
641
+ <p className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
632
642
  </div>
633
643
  {skillPath && (
634
- <div className="w-full rounded-xl border-2 border-[var(--editor-accent)]/50 bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[var(--editor-accent)]/10">
644
+ <div className="w-full rounded-xl border-2 border-[color-mix(in_srgb,var(--editor-accent)_50%,transparent)] bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[color-mix(in_srgb,var(--editor-accent)_10%,transparent)]">
635
645
  {/* indigo-600 in light mode: the accent (indigo-500) is ~4.06:1 on
636
646
  `--editor-surface`, and 12px bold is still NORMAL text under WCAG
637
647
  (large starts at 18.66px bold), so it needs the 4.5:1 floor. The
@@ -2132,7 +2142,7 @@ function ReviewSurface<P extends Project>({
2132
2142
  <div className="shrink-0 flex items-center gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
2133
2143
  <span
2134
2144
  data-testid="preview-timecode"
2135
- className="mr-auto text-[10px] font-mono tabular-nums text-[var(--editor-text)]/60 select-none"
2145
+ className="mr-auto text-[10px] font-mono tabular-nums text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] select-none"
2136
2146
  >
2137
2147
  {/* The playhead can sit past `previewDuration` while parked in
2138
2148
  `getTotalDuration()`'s ~20% trailing headroom (drag room for
@@ -2154,7 +2164,7 @@ function ReviewSurface<P extends Project>({
2154
2164
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
2155
2165
  currentSocialPreview !== null
2156
2166
  ? (timelineMode === 'light' ? 'text-sky-600 bg-sky-400/15 hover:bg-sky-400/25' : 'text-sky-400 bg-sky-400/15 hover:bg-sky-400/25')
2157
- : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2167
+ : 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
2158
2168
  }`}
2159
2169
  >
2160
2170
  {/* Trigger reflects the active selection: the platform's own
@@ -2179,7 +2189,7 @@ function ReviewSurface<P extends Project>({
2179
2189
  onClick={toggleFullscreen}
2180
2190
  aria-label="Toggle fullscreen"
2181
2191
  aria-pressed={isFullscreen}
2182
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
2192
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]"
2183
2193
  >
2184
2194
  {isFullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
2185
2195
  </button>
@@ -2188,7 +2198,7 @@ function ReviewSurface<P extends Project>({
2188
2198
  </>
2189
2199
  ) : (
2190
2200
  <div className="flex-1 min-h-0 flex items-center justify-center p-2">
2191
- <p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
2201
+ <p className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm">No clips</p>
2192
2202
  </div>
2193
2203
  )}
2194
2204
  </div>
@@ -2233,7 +2243,7 @@ function ReviewSurface<P extends Project>({
2233
2243
  <button
2234
2244
  onClick={() => setShowControls(true)}
2235
2245
  aria-label="Editor controls & shortcuts"
2236
- className="flex items-center gap-1 px-1.5 h-5 rounded transition-colors text-[var(--editor-text)]/75 bg-transparent hover:text-[var(--editor-text)] hover:bg-[var(--editor-text)]/10"
2246
+ className="flex items-center gap-1 px-1.5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_75%,transparent)] bg-transparent hover:text-[var(--editor-text)] hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)]"
2237
2247
  >
2238
2248
  <HelpCircle size={14} />
2239
2249
  <span className="text-[10px] leading-none">Controls</span>
@@ -2244,7 +2254,7 @@ function ReviewSurface<P extends Project>({
2244
2254
  onClick={sync.undo}
2245
2255
  disabled={!sync.canUndo}
2246
2256
  aria-label="Undo"
2247
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
2257
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
2248
2258
  >
2249
2259
  <Undo2 size={12} />
2250
2260
  </button>
@@ -2254,7 +2264,7 @@ function ReviewSurface<P extends Project>({
2254
2264
  onClick={sync.redo}
2255
2265
  disabled={!sync.canRedo}
2256
2266
  aria-label="Redo"
2257
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
2267
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
2258
2268
  >
2259
2269
  <Redo2 size={12} />
2260
2270
  </button>
@@ -2267,7 +2277,7 @@ function ReviewSurface<P extends Project>({
2267
2277
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
2268
2278
  previewAxis
2269
2279
  ? (timelineMode === 'light' ? 'text-yellow-700 bg-yellow-400/15 hover:bg-yellow-400/25' : 'text-yellow-400 bg-yellow-400/15 hover:bg-yellow-400/25')
2270
- : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2280
+ : 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
2271
2281
  }`}
2272
2282
  >
2273
2283
  <SeparatorVertical size={12} />
@@ -2281,7 +2291,7 @@ function ReviewSurface<P extends Project>({
2281
2291
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
2282
2292
  rippleMode
2283
2293
  ? (timelineMode === 'light' ? 'text-teal-600 bg-teal-400/15 hover:bg-teal-400/25' : 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25')
2284
- : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2294
+ : 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
2285
2295
  }`}
2286
2296
  >
2287
2297
  <Magnet size={12} />
@@ -2298,7 +2308,7 @@ function ReviewSurface<P extends Project>({
2298
2308
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none ${
2299
2309
  cropMode
2300
2310
  ? (timelineMode === 'light' ? 'text-amber-600 bg-amber-400/15 hover:bg-amber-400/25' : 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25')
2301
- : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2311
+ : 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
2302
2312
  }`}
2303
2313
  >
2304
2314
  <Crop size={12} />
@@ -2327,7 +2337,7 @@ function ReviewSurface<P extends Project>({
2327
2337
  <button
2328
2338
  onClick={handleAudioPolish}
2329
2339
  aria-label="Polish audio"
2330
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
2340
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]"
2331
2341
  >
2332
2342
  <Wand2 size={12} />
2333
2343
  </button>
@@ -2341,7 +2351,7 @@ function ReviewSurface<P extends Project>({
2341
2351
  onClick={() => handleAudibleScrubChange(!currentAudibleScrub)}
2342
2352
  aria-label="Toggle audible drag-scrub"
2343
2353
  aria-pressed={currentAudibleScrub}
2344
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
2354
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]"
2345
2355
  >
2346
2356
  {currentAudibleScrub ? <Ear size={12} /> : <EarOff size={12} />}
2347
2357
  </button>
@@ -2795,9 +2805,9 @@ function ReviewSurface<P extends Project>({
2795
2805
  package's generic centered default. */
2796
2806
  slots?.propertiesEmptyState ?? (
2797
2807
  <div className="flex-1 flex flex-col items-center justify-center gap-2.5 px-6 text-center">
2798
- <SquareDashedMousePointer size={26} className="text-[var(--editor-text)]/25" />
2799
- <p className="text-xs font-medium text-[var(--editor-text)]/60">Select an element</p>
2800
- <p className="text-[11px] text-[var(--editor-text)]/40">Choose a clip, overlay, or track to edit its properties.</p>
2808
+ <SquareDashedMousePointer size={26} className="text-[color-mix(in_srgb,var(--editor-text)_25%,transparent)]" />
2809
+ <p className="text-xs font-medium text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Select an element</p>
2810
+ <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">Choose a clip, overlay, or track to edit its properties.</p>
2801
2811
  </div>
2802
2812
  )
2803
2813
  )}
@@ -2807,7 +2817,18 @@ function ReviewSurface<P extends Project>({
2807
2817
  )
2808
2818
 
2809
2819
  return (
2810
- <div className="flex flex-col flex-1 overflow-hidden">
2820
+ <div className="relative flex flex-col flex-1 overflow-hidden">
2821
+ {/* Save-error banner — a failed sync.mutate/commit rolls the edit back
2822
+ silently otherwise (see CarouselEditor's identical banner). Sits on
2823
+ the outermost container so it is visible above BOTH layout branches
2824
+ below (CapCut and classic) rather than needing its own copy in each. */}
2825
+ {sync.lastError && (
2826
+ <div className="absolute top-3 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 px-3 py-2 rounded-md border border-red-500/40 bg-red-950/80 text-red-200 text-xs">
2827
+ <AlertCircle size={14} />
2828
+ <span>{sync.lastError}</span>
2829
+ <button onClick={sync.clearError} className="ml-2 underline">dismiss</button>
2830
+ </div>
2831
+ )}
2811
2832
  {slots?.mediaPanel ? (
2812
2833
  /* CapCut layout (opt-in via slots.mediaPanel): three columns across the
2813
2834
  top — [left panel | preview | properties] — with a full-width timeline
@@ -2954,10 +2975,19 @@ function ReviewSurface<P extends Project>({
2954
2975
  <CaptionRegenModal
2955
2976
  adapter={adapter}
2956
2977
  projectId={project.id}
2978
+ profile={project.profile}
2957
2979
  existingRowCount={maxCaptionLane(project.captions?.segments ?? []) + 1}
2958
2980
  onClose={() => setRegenCaptionsOpen(false)}
2959
- onDone={(captions) => {
2960
- sync.applyExternal({ ...syncProjectRef.current, captions } as P)
2981
+ onDone={(captions, profileDefaults) => {
2982
+ // The modal resolved the profile once, before the stream; we fold
2983
+ // its font/color onto the fresh track here rather than there
2984
+ // because this is the seam that decides what lands on the project
2985
+ // — and the merge only ever fills fields the host left unset, so
2986
+ // a host that starts returning them keeps authoring them.
2987
+ // `mergeCaptionProfileDefaults` returns `captions` itself when
2988
+ // there is nothing to seed, which is every host without the seam.
2989
+ const seeded = mergeCaptionProfileDefaults(captions, profileDefaults)
2990
+ sync.applyExternal({ ...syncProjectRef.current, captions: seeded } as P)
2961
2991
  setRegenCaptionsOpen(false)
2962
2992
  }}
2963
2993
  mode={timelineMode}
@@ -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
+ })