@bycrux/editor 1.2.2 → 1.4.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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/ControlsInfoModal.tsx +5 -5
  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/__tests__/portal-roots-set-editor-text.test.ts +55 -0
  6. package/src/carousel/AddElementMenu.tsx +1 -1
  7. package/src/carousel/CarouselEditor.tsx +11 -11
  8. package/src/carousel/CarouselRenderModal.tsx +14 -14
  9. package/src/carousel/OverlayPicker.tsx +4 -4
  10. package/src/carousel/SlidePropertyPanel.tsx +6 -6
  11. package/src/carousel/__tests__/CarouselEditor.test.tsx +12 -0
  12. package/src/components/FilmstripScrubber.tsx +1 -1
  13. package/src/crop/VideoSourceCropModal.tsx +5 -5
  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 +18 -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 +22 -22
  27. package/src/video/CaptionListPanel.test.tsx +8 -7
  28. package/src/video/CaptionListPanel.tsx +17 -17
  29. package/src/video/CaptionRegenModal.tsx +8 -8
  30. package/src/video/CaptionStyleGallery.tsx +1 -1
  31. package/src/video/CommandPalette.tsx +8 -8
  32. package/src/video/ImageToneMenu.tsx +13 -14
  33. package/src/video/OverlayInspector.tsx +7 -7
  34. package/src/video/RenderModal.tsx +76 -78
  35. package/src/video/VersionCompare.tsx +10 -10
  36. package/src/video/VersionPanel.tsx +2 -2
  37. package/src/video/VideoEditor.tsx +48 -23
  38. package/src/video/__tests__/RenderModal.hostTheme.test.tsx +85 -0
  39. package/src/video/__tests__/VideoEditor.context.test.tsx +6 -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__/export-limits.test.ts +27 -2
  43. package/src/video/__tests__/use-report-context.test.tsx +22 -1
  44. package/src/video/export-limits.ts +22 -1
  45. package/src/video/panels/ClipPropertiesPanel.tsx +4 -4
  46. package/src/video/panels/OverlayContentPanel.tsx +5 -5
  47. package/src/video/preview/OverlayItemsLayer.tsx +7 -2
  48. package/src/video/preview/PreviewPlayer.tsx +14 -0
  49. package/src/video/preview/SocialPreviewMenu.tsx +6 -6
  50. package/src/video/preview/__tests__/PreviewPlayer.playing.test.tsx +62 -0
  51. package/src/video/preview/__tests__/resolveOverlayPropPaths.test.ts +21 -0
  52. package/src/video/timeline/EditableSegment.tsx +1 -1
  53. package/src/video/timeline/Scrubber.tsx +2 -2
  54. package/src/video/timeline/SpeedControl.tsx +3 -3
  55. package/src/video/timeline/Timeline.tsx +3 -3
  56. package/src/video/timeline/TrackGutter.tsx +6 -6
  57. package/src/video/timeline/TrackSettingsPopover.tsx +4 -4
  58. package/src/video/timeline/VolumeControl.tsx +3 -3
  59. package/src/video/use-report-context.ts +10 -7
@@ -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'
@@ -212,6 +212,11 @@ export function exportDurationSec(project: { tracks?: unknown }): number {
212
212
  * prop, so a status transition (agent finishes → 'draft') must come from the
213
213
  * core's own stream, not the host re-rendering with a new prop.
214
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
+ *
215
220
  * ProjectHeader is lifted out (the host renders it in its shell). This component
216
221
  * renders: timeline + preview + version panel + render modal + the host-supplied
217
222
  * inspector/subcut render-prop seams + an optional back-to-setup affordance.
@@ -223,6 +228,8 @@ export default function VideoEditor<P extends Project = Project>({
223
228
  theme,
224
229
  slots,
225
230
  onBackToSetup,
231
+ pendingSurface = 'default',
232
+ onUserEdit,
226
233
  assetsPlacement = 'right',
227
234
  renderProgressView = 'phases',
228
235
  renderGenerationPanel,
@@ -245,7 +252,9 @@ export default function VideoEditor<P extends Project = Project>({
245
252
  // both the surface switch and the surface contents. `project` (the prop) is
246
253
  // only the initial value — after mount the core owns state and reconciles live
247
254
  // frames itself (video-shaped → default plain-replace reconcile).
248
- 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 })
249
258
 
250
259
  // Set for the duration of a caption drag gesture (ReviewSurface's
251
260
  // `handleProjectChange` → `commitTimelineEdit`), read by the lane-
@@ -375,9 +384,9 @@ export default function VideoEditor<P extends Project = Project>({
375
384
  // T7 — canvas-timeline filmstrips + hover-scrub; absent → Timeline renders none (graceful).
376
385
  const getFilmstrip = adapter.getFilmstrip
377
386
 
378
- if (isPending) {
387
+ if (isPending && pendingSurface !== 'host') {
379
388
  return (
380
- <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)]">
381
390
  <PendingSurface
382
391
  sync={sync}
383
392
  adapter={adapter}
@@ -394,7 +403,7 @@ export default function VideoEditor<P extends Project = Project>({
394
403
  }
395
404
 
396
405
  return (
397
- <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)]">
398
407
  <ReviewSurface
399
408
  sync={sync}
400
409
  captionGestureRef={captionGestureRef}
@@ -629,10 +638,10 @@ function PendingSurface<P extends Project>({
629
638
  <>
630
639
  <div className="flex flex-col items-center gap-2">
631
640
  <p className="text-[var(--editor-text)] text-lg font-semibold">Message your agent to start</p>
632
- <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>
633
642
  </div>
634
643
  {skillPath && (
635
- <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)]">
636
645
  {/* indigo-600 in light mode: the accent (indigo-500) is ~4.06:1 on
637
646
  `--editor-surface`, and 12px bold is still NORMAL text under WCAG
638
647
  (large starts at 18.66px bold), so it needs the 4.5:1 floor. The
@@ -835,6 +844,9 @@ function ReviewSurface<P extends Project>({
835
844
  // one caption id in `selectedIds` — taking the first is correct, the
836
845
  // preview only ever shows one box.
837
846
  const selectedCaptionId = selectedIds.find(id => captionIdSet.has(id)) ?? null
847
+ // Transport state for the context report. PreviewPlayer pushes it on a real
848
+ // play/pause change only, so this re-renders the surface per toggle, never per frame.
849
+ const [previewPlaying, setPreviewPlaying] = useState(false)
838
850
  // Publish what we are looking at, so an agent can resolve "this section".
839
851
  // No-ops entirely on a host that does not implement reportContext.
840
852
  useReportContext({
@@ -843,6 +855,7 @@ function ReviewSurface<P extends Project>({
843
855
  clock,
844
856
  selectedIds,
845
857
  selectedCaptionId,
858
+ playing: previewPlaying,
846
859
  })
847
860
  // Selecting a caption anywhere (timeline, preview box, or the caption list —
848
861
  // all funnel through `selectedIds` → `selectedCaptionId`) jumps the left
@@ -2106,6 +2119,7 @@ function ReviewSurface<P extends Project>({
2106
2119
  onCaptionSegmentChange={handleCaptionSegmentChange}
2107
2120
  engine={engine}
2108
2121
  transportRef={transportRef}
2122
+ onPlayingChange={setPreviewPlaying}
2109
2123
  scrubHandleRef={scrubHandleRef}
2110
2124
  hoverScrub={hoverScrub}
2111
2125
  // Social-media preview chrome (mirrors CapCut's "Preview your
@@ -2133,7 +2147,7 @@ function ReviewSurface<P extends Project>({
2133
2147
  <div className="shrink-0 flex items-center gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
2134
2148
  <span
2135
2149
  data-testid="preview-timecode"
2136
- className="mr-auto text-[10px] font-mono tabular-nums text-[var(--editor-text)]/60 select-none"
2150
+ className="mr-auto text-[10px] font-mono tabular-nums text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] select-none"
2137
2151
  >
2138
2152
  {/* The playhead can sit past `previewDuration` while parked in
2139
2153
  `getTotalDuration()`'s ~20% trailing headroom (drag room for
@@ -2155,7 +2169,7 @@ function ReviewSurface<P extends Project>({
2155
2169
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
2156
2170
  currentSocialPreview !== null
2157
2171
  ? (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')
2158
- : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2172
+ : 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
2159
2173
  }`}
2160
2174
  >
2161
2175
  {/* Trigger reflects the active selection: the platform's own
@@ -2180,7 +2194,7 @@ function ReviewSurface<P extends Project>({
2180
2194
  onClick={toggleFullscreen}
2181
2195
  aria-label="Toggle fullscreen"
2182
2196
  aria-pressed={isFullscreen}
2183
- 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)]"
2197
+ 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)]"
2184
2198
  >
2185
2199
  {isFullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
2186
2200
  </button>
@@ -2189,7 +2203,7 @@ function ReviewSurface<P extends Project>({
2189
2203
  </>
2190
2204
  ) : (
2191
2205
  <div className="flex-1 min-h-0 flex items-center justify-center p-2">
2192
- <p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
2206
+ <p className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm">No clips</p>
2193
2207
  </div>
2194
2208
  )}
2195
2209
  </div>
@@ -2234,7 +2248,7 @@ function ReviewSurface<P extends Project>({
2234
2248
  <button
2235
2249
  onClick={() => setShowControls(true)}
2236
2250
  aria-label="Editor controls & shortcuts"
2237
- 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"
2251
+ 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)]"
2238
2252
  >
2239
2253
  <HelpCircle size={14} />
2240
2254
  <span className="text-[10px] leading-none">Controls</span>
@@ -2245,7 +2259,7 @@ function ReviewSurface<P extends Project>({
2245
2259
  onClick={sync.undo}
2246
2260
  disabled={!sync.canUndo}
2247
2261
  aria-label="Undo"
2248
- 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"
2262
+ 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"
2249
2263
  >
2250
2264
  <Undo2 size={12} />
2251
2265
  </button>
@@ -2255,7 +2269,7 @@ function ReviewSurface<P extends Project>({
2255
2269
  onClick={sync.redo}
2256
2270
  disabled={!sync.canRedo}
2257
2271
  aria-label="Redo"
2258
- 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"
2272
+ 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"
2259
2273
  >
2260
2274
  <Redo2 size={12} />
2261
2275
  </button>
@@ -2268,7 +2282,7 @@ function ReviewSurface<P extends Project>({
2268
2282
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
2269
2283
  previewAxis
2270
2284
  ? (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')
2271
- : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2285
+ : 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
2272
2286
  }`}
2273
2287
  >
2274
2288
  <SeparatorVertical size={12} />
@@ -2282,7 +2296,7 @@ function ReviewSurface<P extends Project>({
2282
2296
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
2283
2297
  rippleMode
2284
2298
  ? (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')
2285
- : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2299
+ : 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
2286
2300
  }`}
2287
2301
  >
2288
2302
  <Magnet size={12} />
@@ -2299,7 +2313,7 @@ function ReviewSurface<P extends Project>({
2299
2313
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none ${
2300
2314
  cropMode
2301
2315
  ? (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')
2302
- : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2316
+ : 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
2303
2317
  }`}
2304
2318
  >
2305
2319
  <Crop size={12} />
@@ -2328,7 +2342,7 @@ function ReviewSurface<P extends Project>({
2328
2342
  <button
2329
2343
  onClick={handleAudioPolish}
2330
2344
  aria-label="Polish audio"
2331
- 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)]"
2345
+ 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)]"
2332
2346
  >
2333
2347
  <Wand2 size={12} />
2334
2348
  </button>
@@ -2342,7 +2356,7 @@ function ReviewSurface<P extends Project>({
2342
2356
  onClick={() => handleAudibleScrubChange(!currentAudibleScrub)}
2343
2357
  aria-label="Toggle audible drag-scrub"
2344
2358
  aria-pressed={currentAudibleScrub}
2345
- 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)]"
2359
+ 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)]"
2346
2360
  >
2347
2361
  {currentAudibleScrub ? <Ear size={12} /> : <EarOff size={12} />}
2348
2362
  </button>
@@ -2796,9 +2810,9 @@ function ReviewSurface<P extends Project>({
2796
2810
  package's generic centered default. */
2797
2811
  slots?.propertiesEmptyState ?? (
2798
2812
  <div className="flex-1 flex flex-col items-center justify-center gap-2.5 px-6 text-center">
2799
- <SquareDashedMousePointer size={26} className="text-[var(--editor-text)]/25" />
2800
- <p className="text-xs font-medium text-[var(--editor-text)]/60">Select an element</p>
2801
- <p className="text-[11px] text-[var(--editor-text)]/40">Choose a clip, overlay, or track to edit its properties.</p>
2813
+ <SquareDashedMousePointer size={26} className="text-[color-mix(in_srgb,var(--editor-text)_25%,transparent)]" />
2814
+ <p className="text-xs font-medium text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Select an element</p>
2815
+ <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>
2802
2816
  </div>
2803
2817
  )
2804
2818
  )}
@@ -2808,7 +2822,18 @@ function ReviewSurface<P extends Project>({
2808
2822
  )
2809
2823
 
2810
2824
  return (
2811
- <div className="flex flex-col flex-1 overflow-hidden">
2825
+ <div className="relative flex flex-col flex-1 overflow-hidden">
2826
+ {/* Save-error banner — a failed sync.mutate/commit rolls the edit back
2827
+ silently otherwise (see CarouselEditor's identical banner). Sits on
2828
+ the outermost container so it is visible above BOTH layout branches
2829
+ below (CapCut and classic) rather than needing its own copy in each. */}
2830
+ {sync.lastError && (
2831
+ <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">
2832
+ <AlertCircle size={14} />
2833
+ <span>{sync.lastError}</span>
2834
+ <button onClick={sync.clearError} className="ml-2 underline">dismiss</button>
2835
+ </div>
2836
+ )}
2812
2837
  {slots?.mediaPanel ? (
2813
2838
  /* CapCut layout (opt-in via slots.mediaPanel): three columns across the
2814
2839
  top — [left panel | preview | properties] — with a full-width timeline
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
2
+ import { render, screen, within, cleanup } from '@testing-library/react'
3
+ import type { EditorAdapter, Project, ImageElement } from '../../types'
4
+ import RenderModal from '../RenderModal'
5
+
6
+ // The Export dialog portals to document.body, outside the editor root. A host
7
+ // whose page text is dark (the desktop app's `text-gray-900`) must not leak
8
+ // that colour into the dialog: the portal root sets the editor's own text
9
+ // colour, and every control carries an explicit themed colour.
10
+
11
+ afterEach(cleanup)
12
+
13
+ const HOST_DARK_TEXT = 'rgb(17, 24, 39)'
14
+ let prevColor = ''
15
+ beforeEach(() => { prevColor = document.body.style.color; document.body.style.color = HOST_DARK_TEXT })
16
+ afterEach(() => { document.body.style.color = prevColor })
17
+
18
+ function adapter(): EditorAdapter<Project> {
19
+ return {
20
+ loadProject: vi.fn(),
21
+ saveProject: vi.fn(),
22
+ subscribe: () => () => {},
23
+ render: vi.fn(async function* () {}),
24
+ renderAsync: vi.fn(async () => ({ status: 'running' })),
25
+ getRenderStatus: vi.fn(async () => ({ status: 'running' as const, phase: 'rendering' as const })),
26
+ resolveImageSrc: (el: ImageElement) => el.src,
27
+ compileOverlay: vi.fn(async () => () => null),
28
+ listGlobalOverlays: vi.fn(async () => []),
29
+ listSystemOverlays: vi.fn(async () => []),
30
+ uploadFile: vi.fn(async () => ''),
31
+ fileUrl: (p: string) => `/files?path=${p}`,
32
+ } as unknown as EditorAdapter<Project>
33
+ }
34
+
35
+ function renderExport() {
36
+ return render(
37
+ <RenderModal
38
+ adapter={adapter()}
39
+ projectId="vid-1"
40
+ onClose={vi.fn()}
41
+ preRenderOptions={{
42
+ isHdr: false,
43
+ keeps: [{ start: 0, end: 12 }],
44
+ resolution: { value: [1080, 1920], available: [[720, 1280], [1080, 1920]], set: vi.fn() },
45
+ }}
46
+ />,
47
+ )
48
+ }
49
+
50
+ const THEMED_TEXT = /(^|\s)text-\[(var\(--editor-text\)|color-mix\(in_srgb,var\(--editor-text\)_\d+%,transparent\))\]/
51
+
52
+ describe('Export dialog in a dark-text host', () => {
53
+ it('the portal root sets the editor text colour', () => {
54
+ renderExport()
55
+ const cancel = screen.getByRole('button', { name: 'Cancel' })
56
+ const root = cancel.closest('.fixed')
57
+ expect(root).not.toBeNull()
58
+ expect(root!.className).toMatch(/(^|\s)text-\[var\(--editor-text\)\](\s|$)/)
59
+ })
60
+
61
+ it('Cancel has a non-empty label and an explicit themed text colour', () => {
62
+ renderExport()
63
+ const cancel = screen.getByRole('button', { name: 'Cancel' })
64
+ expect(cancel.textContent?.trim()).toBe('Cancel')
65
+ expect(cancel.className).toMatch(THEMED_TEXT)
66
+ })
67
+
68
+ it('every resolution tile, 720p included, shows its dims in a themed colour', () => {
69
+ renderExport()
70
+ const group = screen.getByRole('radiogroup', { name: 'Resolution' })
71
+ const tile720 = within(group).getByRole('radio', { name: /720p/ })
72
+ const dims720 = within(tile720).getByText('720 × 1280')
73
+ expect(dims720.className).toMatch(THEMED_TEXT)
74
+ const tile1080 = within(group).getByRole('radio', { name: /1080p/ })
75
+ expect(within(tile1080).getByText('1080 × 1920').className).toMatch(THEMED_TEXT)
76
+ })
77
+
78
+ it('the selected tile uses the editor accent, not a hard-coded violet', () => {
79
+ renderExport()
80
+ const group = screen.getByRole('radiogroup', { name: 'Resolution' })
81
+ const active = within(group).getByRole('radio', { checked: true })
82
+ expect(active.className).toContain('border-[var(--editor-accent)]')
83
+ expect(active.className).not.toMatch(/violet|indigo/)
84
+ })
85
+ })
@@ -22,6 +22,12 @@ describe('VideoEditor context reporting', () => {
22
22
  expect(call).toMatch(/adapter/)
23
23
  })
24
24
 
25
+ it('reports the transport state PreviewPlayer pushes on play/pause', () => {
26
+ const call = src.match(/useReportContext\(\{[\s\S]*?\}\)/)?.[0] ?? ''
27
+ expect(call).toMatch(/playing: previewPlaying/)
28
+ expect(src).toMatch(/onPlayingChange=\{setPreviewPlaying\}/)
29
+ })
30
+
25
31
  // D1: captions share `selectedIds` with clips/audio now, so `selectedCaptionId`
26
32
  // must be DERIVED from that array rather than tracked as its own `useState` —
27
33
  // a second selection model is exactly the bug a future refactor could
@@ -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
+ })