@bycrux/editor 0.8.9 → 0.10.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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/carousel/CarouselEditor.tsx +34 -14
  3. package/src/carousel/SlidePropertyPanel.tsx +1 -35
  4. package/src/index.ts +4 -0
  5. package/src/schema.ts +11 -3
  6. package/src/ui/SwatchInput.tsx +57 -0
  7. package/src/ui/index.ts +2 -0
  8. package/src/video/VideoEditor.tsx +107 -14
  9. package/src/video/__tests__/VideoEditor.test.tsx +34 -0
  10. package/src/video/__tests__/playback-clock.test.tsx +45 -0
  11. package/src/video/playback-clock.ts +31 -0
  12. package/src/video/preview/OverlayItemsLayer.tsx +36 -13
  13. package/src/video/preview/OverlayPropsModal.tsx +292 -0
  14. package/src/video/preview/PreviewPlayer.tsx +14 -6
  15. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
  16. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
  17. package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
  18. package/src/video/preview/overlay-prop-fields.ts +39 -0
  19. package/src/video/preview/useDragOverlay.ts +21 -1
  20. package/src/video/preview/useVideoPlayback.ts +12 -3
  21. package/src/video/timeline/AudioTrackRow.tsx +6 -8
  22. package/src/video/timeline/PlayheadLine.tsx +18 -0
  23. package/src/video/timeline/Scrubber.tsx +7 -5
  24. package/src/video/timeline/Timeline.tsx +64 -29
  25. package/src/video/timeline/TimelineContext.ts +2 -2
  26. package/src/video/timeline/TranscriptPanel.tsx +57 -0
  27. package/src/video/timeline/VisualTrackRow.tsx +21 -14
  28. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
  29. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +85 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.8.9",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
- import { RefreshCw, AlertCircle, Download, Info } from 'lucide-react'
2
+ import { RefreshCw, AlertCircle, Download, Info, Undo2, Redo2 } from 'lucide-react'
3
3
  import type { Project, Slide, CarouselElement, ImageElement, CarouselEditorProps, OverlayFactory } from '../types'
4
4
  import { applyTheme, defaultMontajTheme } from '../theme'
5
5
  import { useProjectState } from '../state/use-project-state'
@@ -409,19 +409,39 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
409
409
  {/* TOOLBAR ROW: Refresh on the left; host toolbar actions + Render on the
410
410
  right. Pinned (shrink-0) above the scrolling canvas area. */}
411
411
  <div className="flex-shrink-0 flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--editor-border)]">
412
- <button
413
- onClick={handleRefresh}
414
- disabled={refreshing}
415
- className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
416
- refreshState === 'err'
417
- ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
418
- : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
419
- }`}
420
- title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
421
- >
422
- {refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
423
- <span className="text-xs font-medium">Refresh</span>
424
- </button>
412
+ <div className="flex items-center gap-2">
413
+ <button
414
+ onClick={handleRefresh}
415
+ disabled={refreshing}
416
+ className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
417
+ refreshState === 'err'
418
+ ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
419
+ : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
420
+ }`}
421
+ title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
422
+ >
423
+ {refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
424
+ <span className="text-xs font-medium">Refresh</span>
425
+ </button>
426
+ <button
427
+ onClick={() => state.undo()}
428
+ disabled={!state.canUndo}
429
+ className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
430
+ title="Undo (Cmd/Ctrl+Z)"
431
+ aria-label="Undo"
432
+ >
433
+ <Undo2 size={18} />
434
+ </button>
435
+ <button
436
+ onClick={() => state.redo()}
437
+ disabled={!state.canRedo}
438
+ className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
439
+ title="Redo (Cmd/Ctrl+Shift+Z)"
440
+ aria-label="Redo"
441
+ >
442
+ <Redo2 size={18} />
443
+ </button>
444
+ </div>
425
445
 
426
446
  <div className="flex items-center gap-2">
427
447
  {slots?.toolbarActions}
@@ -9,7 +9,7 @@ import type {
9
9
  GlobalOverlayProp,
10
10
  EditorAdapter,
11
11
  } from '../types'
12
- import { Button, cn, inspectorInputClass } from '../ui'
12
+ import { Button, cn, inspectorInputClass, SwatchInput } from '../ui'
13
13
  import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
14
14
 
15
15
  function parseNumber(v: string): number | null {
@@ -173,40 +173,6 @@ function PropEditor({
173
173
  )
174
174
  }
175
175
 
176
- // Color control that actually reads as one: a filled rounded swatch (the live
177
- // value) sitting next to the hex string, the whole row clickable to open the
178
- // native color picker. The transparent <input type="color"> overlays the
179
- // swatch so the OS picker anchors there.
180
- function SwatchInput({
181
- value,
182
- onChange,
183
- ariaLabel,
184
- }: {
185
- value: string
186
- onChange: (v: string) => void
187
- ariaLabel: string
188
- }) {
189
- return (
190
- <div className="flex items-center gap-2.5">
191
- <label className="relative h-7 w-7 shrink-0 cursor-pointer">
192
- <span
193
- className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
194
- style={{ backgroundColor: value }}
195
- aria-hidden
196
- />
197
- <input
198
- type="color"
199
- value={value}
200
- onChange={e => onChange(e.target.value)}
201
- className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
202
- aria-label={ariaLabel}
203
- />
204
- </label>
205
- <span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
206
- </div>
207
- )
208
- }
209
-
210
176
  export default function SlidePropertyPanel({
211
177
  project,
212
178
  slide,
package/src/index.ts CHANGED
@@ -112,6 +112,10 @@ export type { TextFormattingToolbarProps } from './text/TextFormattingToolbar'
112
112
  export { OverlayPreview } from './preview/OverlayPreview'
113
113
  export type { OverlayPreviewProps } from './preview/OverlayPreview'
114
114
 
115
+ // ── Playback clock (external playhead store) ──────────────────────────────────
116
+ export { createPlaybackClock, usePlaybackTime } from './video/playback-clock'
117
+ export type { PlaybackClock } from './video/playback-clock'
118
+
115
119
  // ── Video preview ─────────────────────────────────────────────────────────────
116
120
  export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
117
121
  export { default as CarouselPreview } from './video/preview/CarouselPreview'
package/src/schema.ts CHANGED
@@ -45,12 +45,20 @@ export interface CaptionSegment {
45
45
  export interface Captions {
46
46
  style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle' | 'highlight-box' | 'outline' | 'clean'
47
47
  segments: CaptionSegment[]
48
- // ffmpeg-drawtext render params — ignored by JSX preview, used by render.js ffmpeg branch
48
+ // `color` and `fontsize` are read by BOTH paths: the JSX browser preview /
49
+ // Puppeteer render (spread into the caption template props; `fontsize`→`fontSize`)
50
+ // and the ffmpeg-drawtext render branch. `position` and `bgColor` are consumed
51
+ // only by the ffmpeg-drawtext branch and ignored by the JSX preview.
49
52
  position?: 'center' | 'top-left' | 'bottom-left'
50
- color?: string
53
+ color?: string // base caption text color
51
54
  fontsize?: number
52
55
  bgColor?: string
53
- accentColor?: string // active-word/box accent color — highlight-box, outline
56
+ // Per-style accent color fields, each read by the matching JSX caption template.
57
+ // A single UI control writes whichever one the active style uses (see TranscriptPanel).
58
+ accentColor?: string // active-word/box accent — highlight-box, outline
59
+ highlightColor?: string // active word — karaoke
60
+ activeColor?: string // active word — pop
61
+ backgroundColor?: string// text box background — subtitle
54
62
  googleFonts?: string[] // Google Fonts family specs for the caption template (e.g. ["Figtree:wght@700"])
55
63
  }
56
64
 
@@ -0,0 +1,57 @@
1
+ import { cn } from './utils'
2
+
3
+ // Color control that actually reads as one: a filled rounded swatch (the live
4
+ // value) with the native <input type="color"> overlaid transparently so the OS
5
+ // picker anchors on the swatch. Optionally shows the hex string beside it.
6
+ //
7
+ // `onChange` fires live on every pick (drive a cheap preview). `onCommit`, if
8
+ // given, fires on blur (when the OS picker closes) — use it to persist once
9
+ // instead of on every intermediate value, mirroring a slider's drag→commit.
10
+ export interface SwatchInputProps {
11
+ value: string
12
+ onChange: (v: string) => void
13
+ /** Fired on blur (picker closed) — for callers that preview live via onChange
14
+ * and persist only on commit. Omit to treat every onChange as final. */
15
+ onCommit?: (v: string) => void
16
+ ariaLabel: string
17
+ /** Render the hex string next to the swatch. Default true. */
18
+ showValue?: boolean
19
+ /** Swatch size. `sm` (h-5) suits compact toolbars; `md` (h-7) is the default. */
20
+ size?: 'sm' | 'md'
21
+ /** Tooltip on the row. */
22
+ title?: string
23
+ }
24
+
25
+ export function SwatchInput({
26
+ value,
27
+ onChange,
28
+ onCommit,
29
+ ariaLabel,
30
+ showValue = true,
31
+ size = 'md',
32
+ title,
33
+ }: SwatchInputProps) {
34
+ const box = size === 'sm' ? 'h-5 w-5' : 'h-7 w-7'
35
+ return (
36
+ <div className="flex items-center gap-2.5" title={title}>
37
+ <label className={cn('relative shrink-0 cursor-pointer', box)}>
38
+ <span
39
+ className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
40
+ style={{ backgroundColor: value }}
41
+ aria-hidden
42
+ />
43
+ <input
44
+ type="color"
45
+ value={value}
46
+ onChange={e => onChange(e.target.value)}
47
+ onBlur={e => onCommit?.(e.target.value)}
48
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
49
+ aria-label={ariaLabel}
50
+ />
51
+ </label>
52
+ {showValue && (
53
+ <span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
54
+ )}
55
+ </div>
56
+ )
57
+ }
package/src/ui/index.ts CHANGED
@@ -9,6 +9,8 @@ export type { InputProps } from './input'
9
9
  export { Label } from './label'
10
10
  export { Select } from './select'
11
11
  export type { SelectProps } from './select'
12
+ export { SwatchInput } from './SwatchInput'
13
+ export type { SwatchInputProps } from './SwatchInput'
12
14
  export { Switch } from './switch'
13
15
  export { Textarea } from './textarea'
14
16
  export type { TextareaProps } from './textarea'
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { Crop, Info, Magnet } from 'lucide-react'
2
+ import { Crop, Info, Magnet, Pencil, Undo2 } from 'lucide-react'
3
3
  import type { Project, VideoEditorProps } from '../types'
4
4
  import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
5
5
  import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
@@ -9,9 +9,12 @@ import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './c
9
9
  import { repairCaptionWords } from './captionRepair'
10
10
  import Timeline from './timeline/Timeline'
11
11
  import PreviewPlayer from './preview/PreviewPlayer'
12
+ import { createPlaybackClock, type PlaybackClock } from './playback-clock'
13
+ import type { OverlayChanges } from './preview/useDragOverlay'
12
14
  import VersionPanel from './VersionPanel'
13
15
  import RenderModal from './RenderModal'
14
16
  import CaptionRegenModal from './CaptionRegenModal'
17
+ import OverlayPropsModal from './preview/OverlayPropsModal'
15
18
 
16
19
  // Generic over the host's concrete project type `P` (default = the package's
17
20
  // own `Project`). Montaj passes its richer Project; the index signature on
@@ -140,7 +143,11 @@ function PendingSurface<P extends Project>({
140
143
  getWaveformChunks,
141
144
  resolveFilePath,
142
145
  }: SurfaceProps<P> & { onBackToSetup?: () => void }) {
143
- const [currentTime, setCurrentTime] = useState(0)
146
+ // The playhead lives in an external store (not useState) so ~60Hz ticks only
147
+ // re-render the leaves that display time — not this whole surface.
148
+ const clockRef = useRef<PlaybackClock | null>(null)
149
+ if (!clockRef.current) clockRef.current = createPlaybackClock()
150
+ const clock = clockRef.current
144
151
  const [skillPath, setSkillPath] = useState<string | null>(null)
145
152
  const [copied, setCopied] = useState(false)
146
153
  const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
@@ -177,8 +184,7 @@ function PendingSurface<P extends Project>({
177
184
  {hasTrimmedClips ? (
178
185
  <PreviewPlayer
179
186
  project={project}
180
- currentTime={currentTime}
181
- onTimeUpdate={setCurrentTime}
187
+ clock={clock}
182
188
  compileOverlay={adapter.compileOverlay}
183
189
  clearOverlayCache={adapter.clearOverlayCache}
184
190
  watchFile={adapter.watchFile}
@@ -238,8 +244,7 @@ function PendingSurface<P extends Project>({
238
244
  <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
239
245
  <Timeline
240
246
  project={project}
241
- currentTime={currentTime}
242
- onTimeUpdate={setCurrentTime}
247
+ clock={clock}
243
248
  getWaveformChunks={getWaveformChunks}
244
249
  resolveFilePath={resolveFilePath}
245
250
  onSaveProject={(p) => adapter.saveProject(p.id, p as P)}
@@ -280,7 +285,12 @@ function ReviewSurface<P extends Project>({
280
285
  regenEnabled?: boolean
281
286
  isClipQueued?: (itemId: string) => boolean
282
287
  }) {
283
- const [currentTime, setCurrentTime] = useState(0)
288
+ // Playhead in an external store, not useState — ~60Hz ticks re-render only the
289
+ // leaves that display time (preview, scrubber, transcript) instead of the whole
290
+ // review surface (toolbar + timeline + every context consumer).
291
+ const clockRef = useRef<PlaybackClock | null>(null)
292
+ if (!clockRef.current) clockRef.current = createPlaybackClock()
293
+ const clock = clockRef.current
284
294
  const [canUndo, setCanUndo] = useState(false)
285
295
  const historyRef = useRef<P[]>([])
286
296
  // Multi-select: all currently-selected timeline item ids. Single-select
@@ -343,6 +353,55 @@ function ReviewSurface<P extends Project>({
343
353
  if (!cropTarget && cropMode) setCropMode(false)
344
354
  }, [cropTarget, cropMode])
345
355
 
356
+ // Overlay props dialog — opened from the preview (double-click), the controls
357
+ // bar, or the timeline block. VideoEditor owns the state so all three surfaces
358
+ // share one modal. Edits ride handleOverlayChange (history + save for free).
359
+ const [editingOverlayId, setEditingOverlayId] = useState<string | null>(null)
360
+ // Project snapshot taken when the dialog opens, so history/undo and Cancel
361
+ // revert to the pre-edit state even though edits preview live in between.
362
+ const editOriginalRef = useRef<P | null>(null)
363
+ const requestEditOverlay = useCallback((id: string) => {
364
+ editOriginalRef.current = projectRef.current
365
+ setEditingOverlayId(id)
366
+ }, [])
367
+ const allVisualItems = (project.tracks ?? []).flat()
368
+ const editingOverlayItem = editingOverlayId
369
+ ? allVisualItems.find(i => i.id === editingOverlayId) ?? null
370
+ : null
371
+
372
+ function withItemProps(id: string, nextProps: Record<string, unknown>): P {
373
+ return {
374
+ ...project,
375
+ tracks: (project.tracks ?? []).map(track =>
376
+ track.map(item => (item.id !== id ? item : { ...item, props: nextProps })),
377
+ ),
378
+ } as P
379
+ }
380
+ // Live preview: reflect the in-progress edit via onProjectChange only — no
381
+ // history push, no save — so the overlay re-renders as the operator tweaks.
382
+ function previewOverlayProps(id: string, nextProps: Record<string, unknown>) {
383
+ onProjectChange(withItemProps(id, nextProps))
384
+ }
385
+ // Commit on Save: snapshot the pre-edit project for undo, then persist.
386
+ function commitOverlayEdit(id: string, nextProps: Record<string, unknown>) {
387
+ if (editOriginalRef.current) pushHistory(editOriginalRef.current)
388
+ const updated = withItemProps(id, nextProps)
389
+ onProjectChange(updated)
390
+ save(updated)
391
+ editOriginalRef.current = null
392
+ setEditingOverlayId(null)
393
+ }
394
+ // Cancel/Esc/close: discard the live preview by restoring the snapshot.
395
+ function cancelOverlayEdit() {
396
+ if (editOriginalRef.current) onProjectChange(editOriginalRef.current)
397
+ editOriginalRef.current = null
398
+ setEditingOverlayId(null)
399
+ }
400
+ // The primary-selected JSX overlay, if any — drives the controls-bar edit button.
401
+ const selectedOverlayItem = primarySelectedId
402
+ ? allVisualItems.find(i => i.id === primarySelectedId && i.type === 'overlay' && !!i.src) ?? null
403
+ : null
404
+
346
405
  function pushHistory(prev: P) {
347
406
  historyRef.current = [...historyRef.current.slice(-49), prev]
348
407
  setCanUndo(true)
@@ -377,7 +436,7 @@ function ReviewSurface<P extends Project>({
377
436
  setSelectedIds([])
378
437
  }
379
438
 
380
- function handleOverlayChange(id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill'; sourceCrop?: { x: number; y: number; w: number; h: number }; sourceWidth?: number; sourceHeight?: number }) {
439
+ function handleOverlayChange(id: string, changes: OverlayChanges) {
381
440
  pushHistory(project)
382
441
  const updated = {
383
442
  ...project,
@@ -390,7 +449,7 @@ function ReviewSurface<P extends Project>({
390
449
  }
391
450
 
392
451
  function handleSplit(at?: number) {
393
- const updated = splitAtTime(project, at ?? currentTime, primarySelectedId ?? null)
452
+ const updated = splitAtTime(project, at ?? clock.get(), primarySelectedId ?? null)
394
453
  if (updated === project) return
395
454
  pushHistory(project)
396
455
  onProjectChange(updated as P)
@@ -420,7 +479,7 @@ function ReviewSurface<P extends Project>({
420
479
  }
421
480
  document.addEventListener('keydown', onKey)
422
481
  return () => document.removeEventListener('keydown', onKey)
423
- }, [project, currentTime, primarySelectedId, canUndo])
482
+ }, [project, primarySelectedId, canUndo])
424
483
 
425
484
  async function handleRestoreVersion(hash: string) {
426
485
  if (!adapter.restoreVersion) return
@@ -449,10 +508,10 @@ function ReviewSurface<P extends Project>({
449
508
  >
450
509
  <PreviewPlayer
451
510
  project={project}
452
- currentTime={currentTime}
453
- onTimeUpdate={setCurrentTime}
511
+ clock={clock}
454
512
  selectedOverlayId={primarySelectedId ?? undefined}
455
513
  onOverlayChange={handleOverlayChange}
514
+ onEditOverlay={requestEditOverlay}
456
515
  compileOverlay={adapter.compileOverlay}
457
516
  clearOverlayCache={adapter.clearOverlayCache}
458
517
  watchFile={adapter.watchFile}
@@ -475,6 +534,15 @@ function ReviewSurface<P extends Project>({
475
534
  >
476
535
  <Info size={12} />
477
536
  </button>
537
+ <button
538
+ onClick={handleUndo}
539
+ disabled={!canUndo}
540
+ title="Undo (Cmd/Ctrl+Z)"
541
+ aria-label="Undo"
542
+ 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-30 disabled:cursor-not-allowed"
543
+ >
544
+ <Undo2 size={12} />
545
+ </button>
478
546
  <button
479
547
  onClick={() => handleSplit()}
480
548
  title="Split at playhead (S) — selected item or all clips"
@@ -515,6 +583,16 @@ function ReviewSurface<P extends Project>({
515
583
  >
516
584
  <Crop size={12} />
517
585
  </button>
586
+ {selectedOverlayItem && (
587
+ <button
588
+ onClick={() => requestEditOverlay(selectedOverlayItem.id)}
589
+ title="Edit overlay — text, colors, and other properties"
590
+ aria-label="Edit overlay"
591
+ 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)]"
592
+ >
593
+ <Pencil size={12} />
594
+ </button>
595
+ )}
518
596
  {/* Default placement. A host that sets onProvideRenderTrigger renders
519
597
  Render in its own chrome instead, so the toolbar button is hidden. */}
520
598
  {!onProvideRenderTrigger && (
@@ -530,11 +608,11 @@ function ReviewSurface<P extends Project>({
530
608
  <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
531
609
  <Timeline
532
610
  project={project}
533
- currentTime={currentTime}
534
- onTimeUpdate={setCurrentTime}
611
+ clock={clock}
535
612
  onProjectChange={handleProjectChange}
536
613
  onCaptionEdit={(p) => { onProjectChange(p as P); save(p as P) }}
537
614
  onOverlayEdit={(p) => { onProjectChange(p as P); save(p as P) }}
615
+ onEditOverlay={requestEditOverlay}
538
616
  selectedIds={selectedIds}
539
617
  onSelectIds={setSelectedIds}
540
618
  onSplit={handleSplit}
@@ -661,6 +739,21 @@ function ReviewSurface<P extends Project>({
661
739
  />
662
740
  )}
663
741
 
742
+ {/* Overlay props dialog — edits the selected overlay's primitive props
743
+ (text, colors, numbers, toggles). Opened from the preview double-click,
744
+ the controls bar, or a timeline block. Rides handleOverlayChange, so
745
+ edits preview live and undo like any other change. */}
746
+ {editingOverlayItem && (
747
+ <OverlayPropsModal
748
+ itemProps={editingOverlayItem.props ?? {}}
749
+ fileUrl={adapter.fileUrl}
750
+ uploadFile={(file) => adapter.uploadFile(file, project.id)}
751
+ onPreview={(next) => previewOverlayProps(editingOverlayItem.id, next)}
752
+ onSave={(next) => commitOverlayEdit(editingOverlayItem.id, next)}
753
+ onClose={cancelOverlayEdit}
754
+ />
755
+ )}
756
+
664
757
  {/* Clip / audio inspector — host-rendered via render-prop seam. */}
665
758
  {inspecting && renderClipInspector?.({
666
759
  item: inspecting,
@@ -8,6 +8,8 @@ import type {
8
8
  VersionEntry,
9
9
  WaveformChunk,
10
10
  } from '../../types'
11
+ import type { VisualItem } from '../../schema'
12
+ import type { OverlayChanges } from '../preview/useDragOverlay'
11
13
  import VideoEditor from '../VideoEditor'
12
14
 
13
15
  // ── Fake adapter ──────────────────────────────────────────────────────────────
@@ -210,4 +212,36 @@ describe('VideoEditor — editor-package integration', () => {
210
212
  // The copy-able prompt text should include the skill path
211
213
  await findByText(/skills\/video-skill\.md/i)
212
214
  })
215
+
216
+ // `handleOverlayChange` (VideoEditor.tsx) is a private closure whose `changes`
217
+ // param is typed as `OverlayChanges` (useDragOverlay.ts) and whose body is
218
+ // exactly `{ ...item, ...changes }`. No control in the preview layer currently
219
+ // drives a `props` payload through it — the crop modal only ever sends
220
+ // sourceCrop/sourceWidth/sourceHeight, and drag/resize/rotate only ever send
221
+ // offsetX/offsetY/scale/rotation — so there is no DOM path in this harness
222
+ // that reaches a `props` change via a mounted <VideoEditor>. This test instead
223
+ // exercises the real merge contract directly: `changes` is typed against the
224
+ // actual `OverlayChanges` export (so `props` only compiles once useDragOverlay
225
+ // declares it), and the assertion applies the identical spread
226
+ // `handleOverlayChange` performs.
227
+ it('handleOverlayChange merges a props payload into the matching item without touching other fields', () => {
228
+ const item: VisualItem = {
229
+ id: 'overlay-1',
230
+ type: 'overlay',
231
+ src: 'overlay.jsx',
232
+ start: 0,
233
+ end: 4,
234
+ offsetX: 5,
235
+ offsetY: 10,
236
+ props: { text: 'Old text' },
237
+ }
238
+ const changes: OverlayChanges = { props: { text: 'New text' } }
239
+
240
+ // Mirrors VideoEditor.tsx handleOverlayChange's item-update line exactly.
241
+ const merged = { ...item, ...changes }
242
+
243
+ expect(merged.props).toEqual({ text: 'New text' })
244
+ expect(merged.offsetX).toBe(5)
245
+ expect(merged.offsetY).toBe(10)
246
+ })
213
247
  })
@@ -0,0 +1,45 @@
1
+ import { render, act, screen } from '@testing-library/react'
2
+ import { createPlaybackClock, usePlaybackTime } from '../playback-clock'
3
+
4
+ test('set notifies subscribers and get returns latest', () => {
5
+ const clock = createPlaybackClock()
6
+ let seen = -1
7
+ const unsub = clock.subscribe(() => { seen = clock.get() })
8
+ clock.set(1.5)
9
+ expect(seen).toBe(1.5)
10
+ expect(clock.get()).toBe(1.5)
11
+ unsub()
12
+ clock.set(2)
13
+ expect(seen).toBe(1.5)
14
+ })
15
+
16
+ test('set with an unchanged value does not notify', () => {
17
+ const clock = createPlaybackClock()
18
+ let calls = 0
19
+ clock.subscribe(() => calls++)
20
+ clock.set(1); clock.set(1)
21
+ expect(calls).toBe(1)
22
+ })
23
+
24
+ test('only subscribing components re-render on tick', () => {
25
+ const clock = createPlaybackClock()
26
+ let siblingRenders = 0
27
+
28
+ function TimeDisplay() {
29
+ const t = usePlaybackTime(clock)
30
+ return <span data-testid="t">{t}</span>
31
+ }
32
+ function Sibling() {
33
+ siblingRenders++
34
+ return null
35
+ }
36
+ function Root() {
37
+ return (<><TimeDisplay /><Sibling /></>)
38
+ }
39
+
40
+ render(<Root />)
41
+ const before = siblingRenders
42
+ act(() => { clock.set(3.25) })
43
+ expect(screen.getByTestId('t').textContent).toBe('3.25')
44
+ expect(siblingRenders).toBe(before)
45
+ })
@@ -0,0 +1,31 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ export interface PlaybackClock {
4
+ get(): number
5
+ set(t: number): void
6
+ subscribe(cb: () => void): () => void
7
+ }
8
+
9
+ /** External store for the playhead. Written ~60Hz by the playback engine;
10
+ * only components that render the time subscribe (usePlaybackTime). Event
11
+ * handlers that just need "time right now" call clock.get() — no re-renders. */
12
+ export function createPlaybackClock(initial = 0): PlaybackClock {
13
+ let time = initial
14
+ const subs = new Set<() => void>()
15
+ return {
16
+ get: () => time,
17
+ set(t) {
18
+ if (t === time) return
19
+ time = t
20
+ subs.forEach((cb) => cb())
21
+ },
22
+ subscribe(cb) {
23
+ subs.add(cb)
24
+ return () => { subs.delete(cb) }
25
+ },
26
+ }
27
+ }
28
+
29
+ export function usePlaybackTime(clock: PlaybackClock): number {
30
+ return useSyncExternalStore(clock.subscribe, clock.get, clock.get)
31
+ }