@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.
- package/package.json +1 -1
- package/src/carousel/CarouselEditor.tsx +34 -14
- package/src/carousel/SlidePropertyPanel.tsx +1 -35
- package/src/index.ts +4 -0
- package/src/schema.ts +11 -3
- package/src/ui/SwatchInput.tsx +57 -0
- package/src/ui/index.ts +2 -0
- package/src/video/VideoEditor.tsx +107 -14
- package/src/video/__tests__/VideoEditor.test.tsx +34 -0
- package/src/video/__tests__/playback-clock.test.tsx +45 -0
- package/src/video/playback-clock.ts +31 -0
- package/src/video/preview/OverlayItemsLayer.tsx +36 -13
- package/src/video/preview/OverlayPropsModal.tsx +292 -0
- package/src/video/preview/PreviewPlayer.tsx +14 -6
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
- package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
- package/src/video/preview/overlay-prop-fields.ts +39 -0
- package/src/video/preview/useDragOverlay.ts +21 -1
- package/src/video/preview/useVideoPlayback.ts +12 -3
- package/src/video/timeline/AudioTrackRow.tsx +6 -8
- package/src/video/timeline/PlayheadLine.tsx +18 -0
- package/src/video/timeline/Scrubber.tsx +7 -5
- package/src/video/timeline/Timeline.tsx +64 -29
- package/src/video/timeline/TimelineContext.ts +2 -2
- package/src/video/timeline/TranscriptPanel.tsx +57 -0
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +85 -0
package/package.json
CHANGED
|
@@ -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
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 ??
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|