@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
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, waitFor, act, fireEvent, screen } from '@testing-library/react'
3
+ import type { EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
4
+ import VideoEditor from '../VideoEditor'
5
+
6
+ // ── Save-error banner ────────────────────────────────────────────────────────
7
+ // `sync.lastError` (use-project-sync.ts) used to go unrendered in the video
8
+ // editor: a failed save rolled the edit back with no visible sign anything
9
+ // went wrong. CarouselEditor already surfaces it as a dismissible banner
10
+ // (CarouselEditor.tsx ~584-590); this mirrors that pattern in VideoEditor.
11
+ //
12
+ // "M drops a marker" (VideoEditor.keymap.test.tsx) is reused as the trigger —
13
+ // the simplest real `sync.mutate` call reachable without selecting a clip —
14
+ // so this test can focus on the banner itself rather than on how to cause a
15
+ // mutation.
16
+
17
+ function makeVideoProject(overrides: Partial<Project> = {}): Project {
18
+ return {
19
+ id: 'vid-1',
20
+ name: 'Test Video',
21
+ status: 'draft',
22
+ editingPrompt: '',
23
+ projectType: 'video',
24
+ settings: { resolution: [1080, 1920], fps: 30 },
25
+ tracks: [
26
+ [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
27
+ ],
28
+ audio: { tracks: [] },
29
+ assets: [],
30
+ ...overrides,
31
+ } as Project
32
+ }
33
+
34
+ function makeFailingSaveAdapter(): EditorAdapter<Project> {
35
+ return {
36
+ loadProject: vi.fn(async () => makeVideoProject()),
37
+ saveProject: vi.fn(async () => { throw new Error('save boom') }),
38
+ subscribe: () => () => {},
39
+ render: async function* (): AsyncIterable<RenderEvent> { yield { type: 'done', outputPath: '/out.mp4' } },
40
+ resolveImageSrc: (el: ImageElement) => el.src,
41
+ compileOverlay: vi.fn(async () => () => null),
42
+ listGlobalOverlays: vi.fn(async () => []),
43
+ listSystemOverlays: vi.fn(async () => []),
44
+ uploadFile: vi.fn(async () => '/path'),
45
+ fileUrl: (path: string) => path,
46
+ listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
47
+ restoreVersion: vi.fn(async () => makeVideoProject()),
48
+ getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
49
+ resolveCaptionTemplate: (style: string) => `/caption/${style}`,
50
+ getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
51
+ }
52
+ }
53
+
54
+ beforeEach(() => {
55
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
56
+ vi.spyOn(console, 'error').mockImplementation(() => {})
57
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
58
+ observe() {}
59
+ unobserve() {}
60
+ disconnect() {}
61
+ }
62
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
63
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
64
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
65
+ state = 'running'
66
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
67
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
68
+ get destination() { return {} }
69
+ close() {}
70
+ }
71
+ })
72
+ afterEach(() => vi.restoreAllMocks())
73
+
74
+ describe('VideoEditor — save-error banner', () => {
75
+ it('shows sync.lastError when a save rejects, and dismiss clears it', async () => {
76
+ const adapter = makeFailingSaveAdapter()
77
+ render(
78
+ <VideoEditor
79
+ project={makeVideoProject()}
80
+ adapter={adapter}
81
+ onProjectChange={vi.fn()}
82
+ slots={{ exportActions: <div /> }}
83
+ />,
84
+ )
85
+ await screen.findByLabelText('Preview axis')
86
+
87
+ // No error yet.
88
+ expect(screen.queryByText('save boom')).toBeNull()
89
+
90
+ // Drop a marker — the simplest sync.mutate a mounted editor can reach
91
+ // without first selecting a clip. Its save rejects per the adapter above.
92
+ await act(async () => {
93
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' }))
94
+ })
95
+
96
+ const banner = await waitFor(() => screen.getByText('save boom'))
97
+ expect(banner).toBeTruthy()
98
+ expect(adapter.saveProject).toHaveBeenCalledTimes(1)
99
+
100
+ const dismissBtn = screen.getByText('dismiss')
101
+ await act(async () => { fireEvent.click(dismissBtn) })
102
+
103
+ expect(screen.queryByText('save boom')).toBeNull()
104
+ })
105
+ })
@@ -163,8 +163,12 @@ describe('availableResolutionTiers', () => {
163
163
  })
164
164
 
165
165
  describe('availableFpsTiers', () => {
166
- it('returns [24, 30] when settings.fps is 30', () => {
167
- expect(availableFpsTiers(makeProject({ settings: { resolution: [1080, 1920], fps: 30 } }))).toEqual([24, 30])
166
+ it('returns [24, 30] when settings.fps is 30 and the project has a video item', () => {
167
+ const p = makeProject({
168
+ settings: { resolution: [1080, 1920], fps: 30 },
169
+ tracks: vtracks([videoClip()]),
170
+ })
171
+ expect(availableFpsTiers(p)).toEqual([24, 30])
168
172
  })
169
173
 
170
174
  it('returns [24, 30, 60] when settings.fps is 60', () => {
@@ -172,6 +176,27 @@ describe('availableFpsTiers', () => {
172
176
  24, 30, 60,
173
177
  ])
174
178
  })
179
+
180
+ it('offers every tier at fps 30 when the project has no video items (overlay/canvas-only)', () => {
181
+ const p = makeProject({
182
+ settings: { resolution: [1080, 1920], fps: 30 },
183
+ tracks: vtracks([{ id: 'o1', type: 'overlay', start: 0, end: 5 }]),
184
+ })
185
+ expect(availableFpsTiers(p)).toEqual([24, 30, 60])
186
+ })
187
+
188
+ it('offers every tier at fps 30 when tracks is empty (no items of any kind)', () => {
189
+ const p = makeProject({ settings: { resolution: [1080, 1920], fps: 30 } })
190
+ expect(availableFpsTiers(p)).toEqual([24, 30, 60])
191
+ })
192
+
193
+ it('still caps to [24, 30] for a video item missing source dims (has-video check ignores dims)', () => {
194
+ const p = makeProject({
195
+ settings: { resolution: [1080, 1920], fps: 30 },
196
+ tracks: vtracks([videoClip()]),
197
+ })
198
+ expect(availableFpsTiers(p)).toEqual([24, 30])
199
+ })
175
200
  })
176
201
 
177
202
  describe('currentResolutionTier', () => {
@@ -26,7 +26,7 @@ describe('useReportContext', () => {
26
26
  selectedIds: [], selectedCaptionId: null,
27
27
  }))
28
28
  expect(reportContext).toHaveBeenCalledWith('p1', {
29
- playheadSec: 4, selectedIds: [], selectedCaptionId: null,
29
+ playheadSec: 4, selectedIds: [], selectedCaptionId: null, playing: false,
30
30
  })
31
31
  })
32
32
 
@@ -71,6 +71,27 @@ describe('useReportContext', () => {
71
71
  expect(reportContext.mock.calls[0][1].selectedIds).toEqual(['c3'])
72
72
  })
73
73
 
74
+ it('reports play and pause immediately, carrying the playhead', () => {
75
+ const reportContext = vi.fn().mockResolvedValue(undefined)
76
+ const clock = createPlaybackClock()
77
+ const adapter = { reportContext } as never
78
+ const { rerender } = renderHook(
79
+ (props: { playing: boolean }) => useReportContext({
80
+ adapter, projectId: 'p1', clock,
81
+ selectedIds: [], selectedCaptionId: null, playing: props.playing,
82
+ }),
83
+ { initialProps: { playing: false } },
84
+ )
85
+ reportContext.mockClear()
86
+ rerender({ playing: true })
87
+ expect(reportContext).toHaveBeenCalledTimes(1)
88
+ expect(reportContext.mock.calls[0][1].playing).toBe(true)
89
+ act(() => { clock.set(2.5) })
90
+ rerender({ playing: false })
91
+ expect(reportContext).toHaveBeenCalledTimes(2)
92
+ expect(reportContext.mock.calls[1][1]).toMatchObject({ playing: false, playheadSec: 2.5 })
93
+ })
94
+
74
95
  it('swallows a rejected report — context sync never breaks the editor', async () => {
75
96
  const reportContext = vi.fn().mockRejectedValue(new Error('serve is down'))
76
97
  const clock = createPlaybackClock()
@@ -31,6 +31,21 @@ function maxSourceShortSide(project: Project): number | undefined {
31
31
  return max
32
32
  }
33
33
 
34
+ /** Whether the project has any video item at all (regardless of whether its
35
+ * source dims are known). Canvas/animation projects — overlays, images,
36
+ * audio, no footage — have no source fps to protect and render cleanly at
37
+ * any fps, so `availableFpsTiers` uses this (not `maxSourceShortSide`, which
38
+ * also returns undefined for a video item with unknown dims) to decide
39
+ * whether the fps cap applies at all. */
40
+ function hasVideoItem(project: Project): boolean {
41
+ for (const items of trackItems(project)) {
42
+ for (const item of items) {
43
+ if (item.type === 'video') return true
44
+ }
45
+ }
46
+ return false
47
+ }
48
+
34
49
  /** Converts a short-side tier to [w,h], preserving the project's aspect and
35
50
  * orientation. Long side is rounded to the nearest EVEN integer (encoders
36
51
  * demand even dims). */
@@ -79,8 +94,14 @@ export function availableResolutionTiers(project: Project): Array<[number, numbe
79
94
  }
80
95
 
81
96
  /** Every fps tier offerable in the export dialog, filtered to the source cap
82
- * and ordered ascending. Always non-empty. */
97
+ * and ordered ascending. Always non-empty.
98
+ *
99
+ * The cap only protects source footage, so a project with no video items at
100
+ * all — overlays/images/audio only, e.g. a canvas/animation project — has
101
+ * nothing to protect and offers every standard tier uncapped. */
83
102
  export function availableFpsTiers(project: Project): number[] {
103
+ if (!hasVideoItem(project)) return [...STANDARD_FPS_TIERS]
104
+
84
105
  const cap = maxExportFps(project)
85
106
  const tiers = STANDARD_FPS_TIERS.filter(f => f <= cap)
86
107
  return tiers.length > 0 ? tiers : [cap]
@@ -68,7 +68,7 @@ export interface ClipPropertiesPanelProps {
68
68
  }
69
69
 
70
70
  const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
71
- const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[var(--editor-text)]/55'
71
+ const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
72
72
 
73
73
  function basename(path: string): string {
74
74
  return path.split('/').pop() ?? path
@@ -107,7 +107,7 @@ function CollapsibleSection({
107
107
  type="button"
108
108
  aria-expanded={!collapsed}
109
109
  onClick={onToggle}
110
- className="flex items-center gap-1 rounded px-1 py-0.5 text-[var(--editor-text)]/60 transition-colors hover:text-[var(--editor-text)]"
110
+ className="flex items-center gap-1 rounded px-1 py-0.5 text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] transition-colors hover:text-[var(--editor-text)]"
111
111
  >
112
112
  {collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
113
113
  <span className={nested ? 'text-[11px] font-medium' : 'text-xs font-medium uppercase tracking-wide'}>{label}</span>
@@ -358,7 +358,7 @@ function ClipTabs({ item, onPreviewClip, onCommitClip, onChangeClip, transformSl
358
358
  <div className={`flex h-11 w-11 items-center justify-center rounded-full bg-indigo-500/10 ${mode === 'light' ? 'text-indigo-600' : 'text-indigo-400'}`}>
359
359
  <Crop size={20} />
360
360
  </div>
361
- <p className="max-w-[220px] text-xs leading-relaxed text-[var(--editor-text)]/60">
361
+ <p className="max-w-[220px] text-xs leading-relaxed text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
362
362
  Reframe this clip by cropping its source video. Pick the part of the frame to keep.
363
363
  </p>
364
364
  <button
@@ -678,7 +678,7 @@ export default function ClipPropertiesPanel({
678
678
  if (!selection) {
679
679
  return (
680
680
  <div className={SECTION_CLASS}>
681
- <div className="px-3 py-6 text-center text-[11px] text-[var(--editor-text)]/45">
681
+ <div className="px-3 py-6 text-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
682
682
  Select a clip to edit its properties.
683
683
  </div>
684
684
  </div>
@@ -65,7 +65,7 @@ export interface OverlayContentPanelProps {
65
65
  }
66
66
 
67
67
  const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
68
- const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]/55'
68
+ const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
69
69
 
70
70
  /**
71
71
  * Image prop control: a thumbnail preview plus a file picker that uploads the
@@ -128,16 +128,16 @@ function ImageField({
128
128
  className="h-14 w-14 shrink-0 rounded-md border border-[var(--editor-border)] object-cover bg-black/20"
129
129
  />
130
130
  ) : (
131
- <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[var(--editor-text)]/40">
131
+ <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">
132
132
  none
133
133
  </div>
134
134
  )}
135
135
  <div className="flex min-w-0 flex-1 flex-col gap-1">
136
- <span className="truncate font-mono text-xs text-[var(--editor-text)]/70" title={value}>
136
+ <span className="truncate font-mono text-xs text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]" title={value}>
137
137
  {value ? value.split('/').pop() : '—'}
138
138
  </span>
139
139
  {uploadFile ? (
140
- <label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/5">
140
+ <label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]">
141
141
  {busy ? 'Uploading…' : 'Change…'}
142
142
  <input
143
143
  type="file"
@@ -324,7 +324,7 @@ export default function OverlayContentPanel({
324
324
  <div className={SECTION_CLASS}>
325
325
  <div className="flex flex-col gap-3 p-2">
326
326
  {fields.length === 0 ? (
327
- <p className="px-1 py-4 text-center text-[11px] text-[var(--editor-text)]/45">
327
+ <p className="px-1 py-4 text-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
328
328
  {item
329
329
  ? 'This overlay has no editable content.'
330
330
  : 'Select an overlay to edit its content.'}
@@ -113,13 +113,18 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, speed = 1, isPlayi
113
113
  // already recurses — without this, image paths nested in array/object props
114
114
  // (e.g. players[].src, items[].src) reach <img> raw as /var/hub-scratch/... and
115
115
  // render blank in preview even though they render correctly in the final MP4.
116
- // Already-proxied /api/ URLs are left untouched (idempotent).
116
+ // Already-proxied /api/ URLs are left untouched (idempotent). On Windows an
117
+ // absolute path is a drive-letter one (C:\ or C:/); this runs in the browser,
118
+ // so that is a regex, not Node's path module. UNC (\\host) is not rewritten.
119
+ const DRIVE_ABS_PATH = /^[A-Za-z]:[\\/]/
120
+
117
121
  export function resolveOverlayPropPaths(
118
122
  value: unknown,
119
123
  fileUrl: (path: string) => string,
120
124
  ): unknown {
121
125
  if (typeof value === 'string') {
122
- return value.startsWith('/') && !value.startsWith('/api/') ? fileUrl(value) : value
126
+ const abs = (value.startsWith('/') && !value.startsWith('/api/')) || DRIVE_ABS_PATH.test(value)
127
+ return abs ? fileUrl(value) : value
123
128
  }
124
129
  if (Array.isArray(value)) {
125
130
  return value.map((v) => resolveOverlayPropPaths(v, fileUrl))
@@ -85,6 +85,11 @@ interface PreviewPlayerProps {
85
85
  * engine). Absent → no-op; nothing reads or writes it.
86
86
  */
87
87
  transportRef?: MutableRefObject<TransportHandle | null>
88
+ /**
89
+ * Called with the transport's playing state whenever it changes (and once on
90
+ * mount), from whichever playback path is active. Absent → no-op.
91
+ */
92
+ onPlayingChange?: (playing: boolean) => void
88
93
  /**
89
94
  * The audible drag-scrub source's engine seam — mirrors `transportRef`.
90
95
  * Absent → no-op; nothing reads or writes it.
@@ -314,6 +319,7 @@ function PreviewSurface({
314
319
  onCaptionSegmentChange,
315
320
  engine,
316
321
  transportRef,
322
+ onPlayingChange,
317
323
  scrubHandleRef,
318
324
  socialPreview,
319
325
  muted,
@@ -378,6 +384,14 @@ function PreviewSurface({
378
384
  return () => { transportRef.current = null }
379
385
  }, [transportRef, togglePlay, isPlaying, setRate])
380
386
 
387
+ // Push play/pause to the host. Held in a ref so a new callback identity
388
+ // never re-fires it; only a real `isPlaying` change does.
389
+ const onPlayingChangeRef = useRef(onPlayingChange)
390
+ onPlayingChangeRef.current = onPlayingChange
391
+ useEffect(() => {
392
+ onPlayingChangeRef.current?.(isPlaying)
393
+ }, [isPlaying])
394
+
381
395
  // The scrub seam: only the engine path can decode a grain, so the legacy
382
396
  // `<video>` fallback simply never fills this — that absence is the audible
383
397
  // drag-scrub's capability gate. `acquireDemux` is a stable-identity callback
@@ -145,7 +145,7 @@ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose,
145
145
  ref={popoverRef}
146
146
  role="menu"
147
147
  aria-label="Preview for social media"
148
- className="fixed z-[100] w-64 rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1"
148
+ className="fixed z-[100] w-64 rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1 text-[var(--editor-text)]"
149
149
  style={{
150
150
  left: position?.left ?? -9999,
151
151
  top: position?.top ?? -9999,
@@ -157,8 +157,8 @@ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose,
157
157
  }}
158
158
  >
159
159
  <p className="px-2 pt-1 pb-1.5">
160
- <span className="block text-[11px] font-semibold text-[var(--editor-text)]/90">Preview for social media</span>
161
- <span className="block text-[10px] text-[var(--editor-text)]/50">What you see may vary depending on your device.</span>
160
+ <span className="block text-[11px] font-semibold text-[color-mix(in_srgb,var(--editor-text)_90%,transparent)]">Preview for social media</span>
161
+ <span className="block text-[10px] text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">What you see may vary depending on your device.</span>
162
162
  </p>
163
163
 
164
164
  {PLATFORM_OPTIONS.map(opt => {
@@ -175,7 +175,7 @@ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose,
175
175
  className={`flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors ${
176
176
  active
177
177
  ? 'text-[var(--editor-text)] bg-sky-400/10'
178
- : 'text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/5'
178
+ : 'text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
179
179
  }`}
180
180
  >
181
181
  <span className="flex items-center gap-2">
@@ -199,11 +199,11 @@ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose,
199
199
  className={`flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors ${
200
200
  value === null
201
201
  ? 'text-[var(--editor-text)] bg-sky-400/10'
202
- : 'text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/5'
202
+ : 'text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
203
203
  }`}
204
204
  >
205
205
  <span className="flex items-center gap-2">
206
- <PlatformGlyph icon={Slash} badgeClassName="bg-[var(--editor-text)]/15" />
206
+ <PlatformGlyph icon={Slash} badgeClassName="bg-[color-mix(in_srgb,var(--editor-text)_15%,transparent)]" />
207
207
  <span>None</span>
208
208
  </span>
209
209
  {value === null && <Check size={13} className={mode === 'light' ? 'text-sky-600' : 'text-sky-400'} />}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `onPlayingChange` — PreviewPlayer pushes the ACTIVE transport's playing
3
+ * state to the host (VideoEditor feeds it to the agent context report). Driven
4
+ * through the same `transportRef` seam the keymap uses, on a canvas project so
5
+ * play/pause is a pure state flip with no media element in the way.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
8
+ import { render, act } from '@testing-library/react'
9
+ import type { MutableRefObject } from 'react'
10
+ import PreviewPlayer, { type TransportHandle } from '../PreviewPlayer'
11
+ import { createPlaybackClock } from '../../playback-clock'
12
+ import type { EditorProject as Project } from '../../../schema'
13
+
14
+ function makeCanvasProject(): Project {
15
+ return {
16
+ id: 'p-canvas',
17
+ status: 'draft',
18
+ settings: { resolution: [1080, 1920], fps: 30 },
19
+ tracks: [[{ id: 'o1', type: 'overlay', src: 'a.jsx', start: 0, end: 4, props: {} }]],
20
+ } as unknown as Project
21
+ }
22
+
23
+ beforeEach(() => {
24
+ vi.spyOn(console, 'error').mockImplementation(() => {})
25
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
26
+ observe() {}
27
+ unobserve() {}
28
+ disconnect() {}
29
+ }
30
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
31
+ state = 'running'
32
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
33
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
34
+ get destination() { return {} }
35
+ resume() { return Promise.resolve() }
36
+ close() {}
37
+ }
38
+ })
39
+ afterEach(() => { vi.restoreAllMocks() })
40
+
41
+ describe('PreviewPlayer onPlayingChange', () => {
42
+ it('reports paused on mount, then each real play/pause change', () => {
43
+ const transportRef: MutableRefObject<TransportHandle | null> = { current: null }
44
+ const onPlayingChange = vi.fn()
45
+ render(
46
+ <PreviewPlayer
47
+ project={makeCanvasProject()}
48
+ clock={createPlaybackClock(0)}
49
+ compileOverlay={async () => (() => null) as never}
50
+ fileUrl={(p) => p}
51
+ transportRef={transportRef}
52
+ onPlayingChange={onPlayingChange}
53
+ />,
54
+ )
55
+ expect(onPlayingChange.mock.calls).toEqual([[false]])
56
+ act(() => { transportRef.current!.togglePlay() })
57
+ expect(onPlayingChange.mock.lastCall).toEqual([true])
58
+ act(() => { transportRef.current!.togglePlay() })
59
+ expect(onPlayingChange.mock.lastCall).toEqual([false])
60
+ expect(onPlayingChange).toHaveBeenCalledTimes(3)
61
+ })
62
+ })
@@ -50,4 +50,25 @@ describe('resolveOverlayPropPaths', () => {
50
50
  expect(out.duration).toBe(90)
51
51
  expect(out.nested.deep[0].src).toBe(fileUrl('/var/x.png'))
52
52
  })
53
+
54
+ // Windows: an absolute image prop is a drive-letter path. It must reach
55
+ // fileUrl like a posix absolute path does; a UNC path stays refused.
56
+ it('rewrites drive-letter absolute paths in either slash style', () => {
57
+ expect(resolveOverlayPropPaths('C:\\a\\x.png', fileUrl)).toBe(fileUrl('C:\\a\\x.png'))
58
+ expect(resolveOverlayPropPaths('C:/a/x.png', fileUrl)).toBe(fileUrl('C:/a/x.png'))
59
+ expect(resolveOverlayPropPaths('d:\\x.png', fileUrl)).toBe(fileUrl('d:\\x.png'))
60
+ })
61
+
62
+ it('leaves UNC, drive-relative and relative paths untouched', () => {
63
+ for (const v of ['\\\\server\\x', 'C:x.png', 'a\\x.png', 'a/x.png']) {
64
+ expect(resolveOverlayPropPaths(v, fileUrl)).toBe(v)
65
+ }
66
+ })
67
+
68
+ it('keeps posix behaviour exactly: /abs and //host rewritten, /api/ untouched', () => {
69
+ expect(resolveOverlayPropPaths('/abs/x.png', fileUrl)).toBe(fileUrl('/abs/x.png'))
70
+ expect(resolveOverlayPropPaths('//host/x.png', fileUrl)).toBe(fileUrl('//host/x.png'))
71
+ expect(resolveOverlayPropPaths('/api/x', fileUrl)).toBe('/api/x')
72
+ expect(resolveOverlayPropPaths('/apix', fileUrl)).toBe(fileUrl('/apix'))
73
+ })
53
74
  })
@@ -22,7 +22,7 @@ export function EditableSegment({ seg, onEdit }: { seg: CaptionSegment; onEdit:
22
22
  suppressContentEditableWarning
23
23
  onBlur={handleBlur}
24
24
  onKeyDown={handleKeyDown}
25
- className="cursor-text rounded px-0.5 hover:bg-[var(--editor-text)]/5 focus:bg-[var(--editor-text)]/10 focus:outline-none focus:ring-1 focus:ring-purple-500/40"
25
+ className="cursor-text rounded px-0.5 hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)] focus:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] focus:outline-none focus:ring-1 focus:ring-purple-500/40"
26
26
  >
27
27
  {seg.text}
28
28
  </span>
@@ -34,14 +34,14 @@ export default function Scrubber({ onOpenGoToTime }: ScrubberProps) {
34
34
  /* `cursor-pointer`: the row is a strip of timeline background, and
35
35
  Timeline's container click seeks through it (see `handleContainerClick`),
36
36
  so it should look live rather than dead. */
37
- <div className="flex items-center justify-between text-[10px] font-mono text-[var(--editor-text)]/35 cursor-pointer">
37
+ <div className="flex items-center justify-between text-[10px] font-mono text-[color-mix(in_srgb,var(--editor-text)_35%,transparent)] cursor-pointer">
38
38
  {onOpenGoToTime ? (
39
39
  <Tooltip label="Go to time">
40
40
  <button
41
41
  type="button"
42
42
  aria-label="Go to time"
43
43
  onClick={(e) => { e.stopPropagation(); onOpenGoToTime() }}
44
- className="hover:text-[var(--editor-text)]/80 transition-colors"
44
+ className="hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] transition-colors"
45
45
  >
46
46
  {formatTime(currentTime)}
47
47
  </button>
@@ -52,9 +52,9 @@ export default function SpeedControl({
52
52
 
53
53
  return (
54
54
  <div className="flex flex-col gap-2">
55
- <div className={`flex items-center text-[11px] text-[var(--editor-text)]/70 ${label ? 'justify-between' : 'justify-end'}`}>
55
+ <div className={`flex items-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] ${label ? 'justify-between' : 'justify-end'}`}>
56
56
  {label && <span>{label}</span>}
57
- <span className="font-mono text-[10px] text-[var(--editor-text)]/50">{value.toFixed(2)}×</span>
57
+ <span className="font-mono text-[10px] text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">{value.toFixed(2)}×</span>
58
58
  </div>
59
59
  <Slider
60
60
  id={sliderId}
@@ -82,7 +82,7 @@ export default function SpeedControl({
82
82
  className={`rounded-md border px-2 py-0.5 text-[11px] font-mono transition-colors focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)] ${
83
83
  active
84
84
  ? 'border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)]'
85
- : 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
85
+ : 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
86
86
  }`}
87
87
  >
88
88
  {p}×
@@ -940,7 +940,7 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
940
940
  <div className="flex items-center justify-end gap-0.5 -mb-1 cursor-pointer">
941
941
  <Tooltip label="Zoom out">
942
942
  <button
943
- className="text-[11px] leading-none text-[var(--editor-text)]/45 hover:text-[var(--editor-text)]/80 w-5 h-5 flex items-center justify-center rounded hover:bg-[var(--editor-text)]/10 transition-colors"
943
+ className="text-[11px] leading-none text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] w-5 h-5 flex items-center justify-center rounded hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] transition-colors"
944
944
  aria-label="Zoom out"
945
945
  onClick={(e) => { e.stopPropagation(); zoomControls.zoomOut() }}
946
946
  >−</button>
@@ -948,7 +948,7 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
948
948
  {zoomControls.badge}
949
949
  <Tooltip label="Zoom in">
950
950
  <button
951
- className="text-[11px] leading-none text-[var(--editor-text)]/45 hover:text-[var(--editor-text)]/80 w-5 h-5 flex items-center justify-center rounded hover:bg-[var(--editor-text)]/10 transition-colors"
951
+ className="text-[11px] leading-none text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] w-5 h-5 flex items-center justify-center rounded hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] transition-colors"
952
952
  aria-label="Zoom in"
953
953
  onClick={(e) => { e.stopPropagation(); zoomControls.zoomIn() }}
954
954
  >+</button>
@@ -956,7 +956,7 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
956
956
  {zoomControls.showFit && (
957
957
  <Tooltip label="Fit to view" className="ml-0.5">
958
958
  <button
959
- className="text-[10px] text-[var(--editor-text)]/45 hover:text-[var(--editor-text)]/80 px-1.5 h-5 rounded hover:bg-[var(--editor-text)]/10 transition-colors"
959
+ className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] px-1.5 h-5 rounded hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] transition-colors"
960
960
  aria-label="Fit to view"
961
961
  onClick={(e) => { e.stopPropagation(); zoomControls.fit() }}
962
962
  >fit</button>
@@ -118,7 +118,7 @@ function RailCell({ height, accent, icon, label, action, dimmed, settingsButton
118
118
  icons could be clicked — a decorative-looking glyph that happened to
119
119
  open a dialog. Settings moved to the gear beside it. */}
120
120
  <Tooltip label={label}>
121
- <span aria-label={label} className="flex h-4 w-4 items-center justify-center text-[var(--editor-text)]/50">
121
+ <span aria-label={label} className="flex h-4 w-4 items-center justify-center text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">
122
122
  {icon}
123
123
  </span>
124
124
  </Tooltip>
@@ -144,8 +144,8 @@ function RailCell({ height, accent, icon, label, action, dimmed, settingsButton
144
144
  onClick={settingsButton.onClick}
145
145
  className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
146
146
  settingsButton.open
147
- ? 'text-[var(--editor-text)]/90 bg-[var(--editor-text)]/10'
148
- : 'text-[var(--editor-text)]/40 hover:text-[var(--editor-text)]/90 hover:bg-[var(--editor-text)]/10'
147
+ ? 'text-[color-mix(in_srgb,var(--editor-text)_90%,transparent)] bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)]'
148
+ : 'text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_90%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)]'
149
149
  }`}
150
150
  >
151
151
  <Settings2 size={13} />
@@ -179,7 +179,7 @@ function MuteToggle({ muted, onToggle, trackLabel, mode }: { muted: boolean; onT
179
179
  className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
180
180
  muted
181
181
  ? (mode === 'light' ? 'text-amber-600 hover:text-amber-700' : 'text-amber-400/90 hover:text-amber-300')
182
- : 'text-[var(--editor-text)]/35 hover:text-[var(--editor-text)]/80'
182
+ : 'text-[color-mix(in_srgb,var(--editor-text)_35%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]'
183
183
  }`}
184
184
  >
185
185
  {muted ? <VolumeX size={13} /> : <Volume2 size={13} />}
@@ -206,7 +206,7 @@ function MagnetToggle({ magnetic, onToggle, trackLabel, mode }: { magnetic: bool
206
206
  className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
207
207
  magnetic
208
208
  ? (mode === 'light' ? 'text-emerald-600 hover:text-emerald-700' : 'text-emerald-400/90 hover:text-emerald-300')
209
- : 'text-[var(--editor-text)]/35 hover:text-[var(--editor-text)]/80'
209
+ : 'text-[color-mix(in_srgb,var(--editor-text)_35%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]'
210
210
  }`}
211
211
  >
212
212
  <Magnet size={13} />
@@ -227,7 +227,7 @@ function SkipToggle({ enabled, onToggle, trackLabel, mode }: { enabled: boolean;
227
227
  onClick={onToggle}
228
228
  className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
229
229
  enabled
230
- ? 'text-[var(--editor-text)]/35 hover:text-[var(--editor-text)]/80'
230
+ ? 'text-[color-mix(in_srgb,var(--editor-text)_35%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]'
231
231
  : (mode === 'light' ? 'text-amber-600/90 hover:text-amber-700' : 'text-amber-400/80 hover:text-amber-300')
232
232
  }`}
233
233
  >