@bycrux/editor 0.12.0 → 1.0.2
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 +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +161 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
import { memo } from 'react'
|
|
2
|
-
import { Volume2, VolumeX, Info, Scissors, Pencil } from 'lucide-react'
|
|
3
|
-
import type { VisualItem } from '../../schema'
|
|
4
|
-
import type { Project } from '../../types'
|
|
5
|
-
import { collapseGaps } from '../cuts'
|
|
6
|
-
import { pct, ratioFromClientX, trackRow, trackRowTall } from './utils'
|
|
7
|
-
import { useTimelineContext } from './TimelineContext'
|
|
8
|
-
import PlayheadLine from './PlayheadLine'
|
|
9
|
-
import { useItemDragDrop } from './useItemDragDrop'
|
|
10
|
-
import type { Draggable, DragEventContext } from './useItemDragDrop'
|
|
11
|
-
import { applyMuteToSelection, applyResizeDeltaToSelection, deleteSelection } from './multiSelectOps'
|
|
12
|
-
|
|
13
|
-
interface VisualTrackRowProps {
|
|
14
|
-
trackItems: VisualItem[]
|
|
15
|
-
trackIdx: number
|
|
16
|
-
project: Project
|
|
17
|
-
/** Unified multi-selection. First entry is the primary; isSel checks membership. */
|
|
18
|
-
selectedIds: string[]
|
|
19
|
-
rippleMode: boolean
|
|
20
|
-
onProjectChange?: (p: Project) => void
|
|
21
|
-
onOverlayEdit?: (p: Project) => void
|
|
22
|
-
/** Open the overlay props dialog for an overlay item (VideoEditor owns it). */
|
|
23
|
-
onEditOverlay?: (id: string) => void
|
|
24
|
-
/** Click handler — additive when shift/meta is held. */
|
|
25
|
-
onSelectItem: (id: string | null, additive: boolean) => void
|
|
26
|
-
onInspectClip?: (id: string) => void
|
|
27
|
-
subcutClipId: string | null
|
|
28
|
-
setSubcutClipId: (id: string | null) => void
|
|
29
|
-
/** Host-computed gate for the subcut-regenerate affordance (Montaj: project
|
|
30
|
-
* is an ai_video). When false/undefined the Scissors button is hidden — the
|
|
31
|
-
* package stays agnostic of Montaj's projectType. */
|
|
32
|
-
regenEnabled?: boolean
|
|
33
|
-
/** Host-computed predicate: is this clip already queued for regeneration?
|
|
34
|
-
* (Montaj: project.regenQueue has an entry for this clipId.) Drives the
|
|
35
|
-
* "queued" badge. */
|
|
36
|
-
isClipQueued?: (itemId: string) => boolean
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const trackColors = [
|
|
40
|
-
{ bg: 'bg-slate-600/80', bgHov: 'hover:bg-slate-500/80', bgSel: 'bg-slate-500/90', ring: 'ring-slate-300/80', border: 'border-slate-400/50', text: 'text-slate-200', resHov: 'hover:bg-slate-300/40' },
|
|
41
|
-
{ bg: 'bg-sky-700/80', bgHov: 'hover:bg-sky-600/80', bgSel: 'bg-sky-600/90', ring: 'ring-sky-300/80', border: 'border-sky-400/50', text: 'text-sky-200', resHov: 'hover:bg-sky-300/40' },
|
|
42
|
-
{ bg: 'bg-violet-700/80', bgHov: 'hover:bg-violet-600/80', bgSel: 'bg-violet-600/90', ring: 'ring-violet-300/80', border: 'border-violet-400/50', text: 'text-violet-200', resHov: 'hover:bg-violet-300/40' },
|
|
43
|
-
{ bg: 'bg-emerald-700/80',bgHov: 'hover:bg-emerald-600/80',bgSel: 'bg-emerald-600/90',ring: 'ring-emerald-300/80',border: 'border-emerald-400/50',text: 'text-emerald-200',resHov: 'hover:bg-emerald-300/40'},
|
|
44
|
-
{ bg: 'bg-rose-700/80', bgHov: 'hover:bg-rose-600/80', bgSel: 'bg-rose-600/90', ring: 'ring-rose-300/80', border: 'border-rose-400/50', text: 'text-rose-200', resHov: 'hover:bg-rose-300/40' },
|
|
45
|
-
{ bg: 'bg-amber-700/60', bgHov: 'hover:bg-amber-700/80', bgSel: 'bg-amber-600/80', ring: 'ring-amber-400/80', border: 'border-amber-500/50', text: 'text-amber-200', resHov: 'hover:bg-amber-300/40' },
|
|
46
|
-
]
|
|
47
|
-
|
|
48
|
-
function VisualTrackRow({
|
|
49
|
-
trackItems,
|
|
50
|
-
trackIdx,
|
|
51
|
-
project,
|
|
52
|
-
selectedIds,
|
|
53
|
-
rippleMode,
|
|
54
|
-
onProjectChange,
|
|
55
|
-
onOverlayEdit,
|
|
56
|
-
onEditOverlay,
|
|
57
|
-
onSelectItem,
|
|
58
|
-
onInspectClip,
|
|
59
|
-
subcutClipId,
|
|
60
|
-
setSubcutClipId,
|
|
61
|
-
regenEnabled,
|
|
62
|
-
isClipQueued,
|
|
63
|
-
}: VisualTrackRowProps) {
|
|
64
|
-
const { totalDuration, snapBoundaries, scrollRef, scrubberRef, clock, markers, setMarkers, selection, overlayDraggedRef, zoomRef } = useTimelineContext()
|
|
65
|
-
const tc = trackColors[trackIdx % trackColors.length]
|
|
66
|
-
const markerActive = markers[0] !== null || selection !== null
|
|
67
|
-
const primarySelectedId = selectedIds[0] ?? null
|
|
68
|
-
const dimmed = markerActive && primarySelectedId !== null && !trackItems.some(i => i.id === primarySelectedId)
|
|
69
|
-
|
|
70
|
-
const { beginDrag, beginResize } = useItemDragDrop({
|
|
71
|
-
totalDuration,
|
|
72
|
-
snapBoundaries,
|
|
73
|
-
scrollRef,
|
|
74
|
-
zoomRef,
|
|
75
|
-
draggedFlagRef: overlayDraggedRef,
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
function handleItemResizeStart(e: React.MouseEvent, item: VisualItem, edge: 'start' | 'end') {
|
|
79
|
-
if (!onProjectChange) return
|
|
80
|
-
let lastUpdated = project
|
|
81
|
-
const origStart = item.start
|
|
82
|
-
const origEnd = item.end
|
|
83
|
-
const multiTargets = selectedIds.length > 1 && selectedIds.includes(item.id)
|
|
84
|
-
|
|
85
|
-
beginResize(e, item as Draggable, edge, {
|
|
86
|
-
onLivePreview: ({ item: resized }: DragEventContext) => {
|
|
87
|
-
// First: apply the hook's resize (which has clamping, snap, in/outPoint
|
|
88
|
-
// math) to the originator clip.
|
|
89
|
-
let next: Project = {
|
|
90
|
-
...project,
|
|
91
|
-
tracks: (project.tracks ?? []).map(track =>
|
|
92
|
-
track.map(ov => ov.id !== item.id ? ov : { ...ov, start: resized.start, end: resized.end, inPoint: resized.inPoint, outPoint: resized.outPoint })
|
|
93
|
-
),
|
|
94
|
-
}
|
|
95
|
-
// Then: propagate the same delta to every other selected item across
|
|
96
|
-
// visual + audio tracks.
|
|
97
|
-
if (multiTargets) {
|
|
98
|
-
const dStart = edge === 'start' ? resized.start - origStart : 0
|
|
99
|
-
const dEnd = edge === 'end' ? resized.end - origEnd : 0
|
|
100
|
-
next = applyResizeDeltaToSelection(next, item.id, selectedIds, edge, { dStart, dEnd })
|
|
101
|
-
}
|
|
102
|
-
if (rippleMode) next = collapseGaps(next)
|
|
103
|
-
lastUpdated = next
|
|
104
|
-
onProjectChange!(next)
|
|
105
|
-
},
|
|
106
|
-
onCommit: () => {
|
|
107
|
-
onOverlayEdit?.(lastUpdated)
|
|
108
|
-
},
|
|
109
|
-
})
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function handleDeleteOverlay(id: string) {
|
|
113
|
-
if (!onProjectChange) return
|
|
114
|
-
// If part of a multi-selection, delete the whole selection; otherwise just
|
|
115
|
-
// this item. Matches the Delete-key behavior in Timeline.handleKeyDown.
|
|
116
|
-
const ids = selectedIds.length > 1 && selectedIds.includes(id) ? selectedIds : [id]
|
|
117
|
-
let updated = deleteSelection(project, ids)
|
|
118
|
-
if (rippleMode) updated = collapseGaps(updated)
|
|
119
|
-
onProjectChange(updated)
|
|
120
|
-
onOverlayEdit?.(updated)
|
|
121
|
-
onSelectItem(null, false)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function handleToggleMute(item: VisualItem) {
|
|
125
|
-
if (!onProjectChange) return
|
|
126
|
-
const newMuted = !item.muted
|
|
127
|
-
// If clicked item is in a multi-selection, set every selected item's mute
|
|
128
|
-
// state to the new target (uniform end state, no surprise mixed state).
|
|
129
|
-
const targetIds = selectedIds.length > 1 && selectedIds.includes(item.id) ? selectedIds : [item.id]
|
|
130
|
-
const updated = applyMuteToSelection(project, targetIds, newMuted)
|
|
131
|
-
onProjectChange(updated)
|
|
132
|
-
onOverlayEdit?.(updated)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function handleOverlayDragStart(e: React.MouseEvent, item: VisualItem, sourceTrackIdx: number) {
|
|
136
|
-
if ((e.target as HTMLElement).classList.contains('cursor-ew-resize')) return
|
|
137
|
-
if (!onProjectChange) return
|
|
138
|
-
const projectChange = onProjectChange
|
|
139
|
-
const ROW_HEIGHT_PX = 24
|
|
140
|
-
let lastUpdated = project
|
|
141
|
-
|
|
142
|
-
beginDrag(e, item as Draggable, {
|
|
143
|
-
onLivePreview: ({ item: moved, dy }: DragEventContext) => {
|
|
144
|
-
const trackDelta = Math.round(dy / ROW_HEIGHT_PX)
|
|
145
|
-
const targetIdx = Math.max(0, sourceTrackIdx - trackDelta)
|
|
146
|
-
const duration = moved.end - moved.start
|
|
147
|
-
const overlapMin = duration * 0.3
|
|
148
|
-
|
|
149
|
-
function hasOverlap(track: VisualItem[]): boolean {
|
|
150
|
-
return track.some(ov => {
|
|
151
|
-
if (ov.id === item.id) return false
|
|
152
|
-
return Math.min(moved.end, ov.end) - Math.max(moved.start, ov.start) > overlapMin
|
|
153
|
-
})
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const tracks = lastUpdated.tracks ?? []
|
|
157
|
-
let bestIdx = targetIdx
|
|
158
|
-
outer: for (let delta = 0; delta <= tracks.length; delta++) {
|
|
159
|
-
for (const i of delta === 0 ? [targetIdx] : [targetIdx - delta, targetIdx + delta]) {
|
|
160
|
-
if (i < 0) continue
|
|
161
|
-
const candidateTrack = i < tracks.length ? tracks[i] : []
|
|
162
|
-
if (!hasOverlap(candidateTrack)) { bestIdx = i; break outer }
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const removed = tracks.map(t => t.filter(ov => ov.id !== item.id))
|
|
167
|
-
const movedItem = { ...item, start: moved.start, end: moved.end }
|
|
168
|
-
const final = bestIdx >= removed.length
|
|
169
|
-
? [...removed, [movedItem]]
|
|
170
|
-
: removed.map((t, i) => i === bestIdx ? [...t, movedItem] : t)
|
|
171
|
-
|
|
172
|
-
const next = { ...lastUpdated, tracks: final.filter(t => t.length > 0) }
|
|
173
|
-
projectChange(next)
|
|
174
|
-
lastUpdated = next
|
|
175
|
-
},
|
|
176
|
-
onCommit: () => {
|
|
177
|
-
onOverlayEdit?.(lastUpdated)
|
|
178
|
-
},
|
|
179
|
-
})
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function handleScrubDoubleClick(e: React.MouseEvent<HTMLDivElement>) {
|
|
183
|
-
if (totalDuration === 0) return
|
|
184
|
-
e.preventDefault()
|
|
185
|
-
const t = ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
|
|
186
|
-
setMarkers(([a, b]) => {
|
|
187
|
-
if (a === null) return [t, null] // place first marker
|
|
188
|
-
if (b === null) return [a, t] // place second → selection complete
|
|
189
|
-
return [t, null] // reset: start fresh with new first marker
|
|
190
|
-
})
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function handleTrackClick(e: React.MouseEvent) {
|
|
194
|
-
e.stopPropagation()
|
|
195
|
-
if (totalDuration === 0) return
|
|
196
|
-
const clickedTime = ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
|
|
197
|
-
const rect = scrubberRef.current?.getBoundingClientRect()
|
|
198
|
-
const snapThreshold = rect ? (8 / rect.width) * totalDuration : 0
|
|
199
|
-
const boundaries = snapBoundaries
|
|
200
|
-
for (const b of boundaries) {
|
|
201
|
-
if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
|
|
202
|
-
}
|
|
203
|
-
clock.set(clickedTime)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return (
|
|
207
|
-
<div className={`${trackIdx === 0 ? trackRowTall : trackRow} transition-opacity ${dimmed ? 'opacity-30 pointer-events-none' : ''}`} onClick={handleTrackClick} onDoubleClick={handleScrubDoubleClick}>
|
|
208
|
-
{trackItems.map((item) => {
|
|
209
|
-
const isSel = selectedIds.includes(item.id)
|
|
210
|
-
return (
|
|
211
|
-
<div
|
|
212
|
-
key={item.id}
|
|
213
|
-
className={`absolute top-0 bottom-0 flex items-center overflow-hidden cursor-grab active:cursor-grabbing
|
|
214
|
-
${isSel ? `${tc.bgSel} ring-1 ring-inset ${tc.ring}` : `${tc.bg} ${tc.bgHov}`}
|
|
215
|
-
border-r ${tc.border}`}
|
|
216
|
-
style={{ left: `${pct(item.start, totalDuration)}%`, width: `${pct(item.end - item.start, totalDuration)}%` }}
|
|
217
|
-
onClick={(e) => {
|
|
218
|
-
e.stopPropagation()
|
|
219
|
-
if (overlayDraggedRef.current) return
|
|
220
|
-
const additive = e.shiftKey || e.metaKey || e.ctrlKey
|
|
221
|
-
onSelectItem(item.id, additive)
|
|
222
|
-
// Only seek playhead on a plain single-select click (not on
|
|
223
|
-
// additive shift-clicks, which shouldn't disrupt scrubbing).
|
|
224
|
-
if (!additive && !isSel) clock.set(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
|
|
225
|
-
}}
|
|
226
|
-
onDoubleClick={(e) => {
|
|
227
|
-
e.stopPropagation()
|
|
228
|
-
if (onInspectClip) onInspectClip(item.id)
|
|
229
|
-
}}
|
|
230
|
-
onMouseDown={(e) => handleOverlayDragStart(e, item, trackIdx)}
|
|
231
|
-
>
|
|
232
|
-
<div
|
|
233
|
-
className={`absolute left-0 top-0 bottom-0 w-2.5 cursor-ew-resize z-10 ${tc.resHov}`}
|
|
234
|
-
onMouseDown={(e) => handleItemResizeStart(e, item, 'start')}
|
|
235
|
-
/>
|
|
236
|
-
<span className={`text-[10px] ${tc.text} truncate flex-1 min-w-0 pl-3`}>
|
|
237
|
-
▪ {item.type}
|
|
238
|
-
{project.renderMode === 'ffmpeg-drawtext' && trackIdx > 0 && (
|
|
239
|
-
<span className="ml-1.5 text-amber-400/60">preview</span>
|
|
240
|
-
)}
|
|
241
|
-
{isClipQueued?.(item.id) && (
|
|
242
|
-
<span className="ml-1.5 text-amber-300/80 font-medium">queued</span>
|
|
243
|
-
)}
|
|
244
|
-
</span>
|
|
245
|
-
{item.type === 'video' && (
|
|
246
|
-
<button
|
|
247
|
-
className={`shrink-0 mr-3 z-10 cursor-pointer transition-opacity ${item.muted ? 'opacity-30 hover:opacity-60' : 'opacity-50 hover:opacity-90'} ${tc.text}`}
|
|
248
|
-
onClick={(e) => { e.stopPropagation(); handleToggleMute(item) }}
|
|
249
|
-
title={item.muted ? 'Unmute' : 'Mute'}
|
|
250
|
-
>
|
|
251
|
-
{item.muted ? <VolumeX size={10} /> : <Volume2 size={10} />}
|
|
252
|
-
</button>
|
|
253
|
-
)}
|
|
254
|
-
{isSel && onInspectClip && item.type === 'video' && (
|
|
255
|
-
<button
|
|
256
|
-
className={`shrink-0 ml-1 z-10 cursor-pointer opacity-50 hover:opacity-100 ${tc.text}`}
|
|
257
|
-
onClick={(e) => { e.stopPropagation(); onInspectClip(item.id) }}
|
|
258
|
-
title="Inspect generation"
|
|
259
|
-
><Info size={10} /></button>
|
|
260
|
-
)}
|
|
261
|
-
{isSel && regenEnabled && item.generation && (item.end - item.start) >= 3 && (
|
|
262
|
-
<button
|
|
263
|
-
className={`shrink-0 ml-1 z-10 cursor-pointer opacity-50 hover:opacity-100 ${tc.text}`}
|
|
264
|
-
onClick={(e) => { e.stopPropagation(); setSubcutClipId(subcutClipId === item.id ? null : item.id) }}
|
|
265
|
-
title="Subcut regenerate"
|
|
266
|
-
><Scissors size={10} /></button>
|
|
267
|
-
)}
|
|
268
|
-
{isSel && onEditOverlay && item.type === 'overlay' && !!item.src && (
|
|
269
|
-
<button
|
|
270
|
-
className={`shrink-0 ml-1 z-10 cursor-pointer opacity-60 hover:opacity-100 ${tc.text}`}
|
|
271
|
-
onClick={(e) => { e.stopPropagation(); onEditOverlay(item.id) }}
|
|
272
|
-
title="Edit overlay"
|
|
273
|
-
><Pencil size={10} /></button>
|
|
274
|
-
)}
|
|
275
|
-
{isSel && (
|
|
276
|
-
<button
|
|
277
|
-
className={`shrink-0 ml-1 mr-3 z-10 cursor-pointer opacity-60 hover:opacity-100 ${tc.text} text-[11px] leading-none`}
|
|
278
|
-
onClick={(e) => { e.stopPropagation(); handleDeleteOverlay(item.id) }}
|
|
279
|
-
title="Delete"
|
|
280
|
-
>×</button>
|
|
281
|
-
)}
|
|
282
|
-
<div
|
|
283
|
-
className={`absolute right-0 top-0 bottom-0 w-2.5 cursor-ew-resize z-10 ${tc.resHov}`}
|
|
284
|
-
onMouseDown={(e) => handleItemResizeStart(e, item, 'end')}
|
|
285
|
-
/>
|
|
286
|
-
</div>
|
|
287
|
-
)
|
|
288
|
-
})}
|
|
289
|
-
<PlayheadLine />
|
|
290
|
-
{selection && (
|
|
291
|
-
<div
|
|
292
|
-
className="absolute inset-y-0 bg-red-500/20 pointer-events-none"
|
|
293
|
-
style={{ left: `${pct(selection.start, totalDuration)}%`, width: `${pct(selection.end - selection.start, totalDuration)}%` }}
|
|
294
|
-
/>
|
|
295
|
-
)}
|
|
296
|
-
</div>
|
|
297
|
-
)
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export default memo(VisualTrackRow)
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
/// <reference types="vitest/globals" />
|
|
2
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
-
import { createPlaybackClock } from '../../playback-clock'
|
|
4
|
-
import { TimelineContext, type TimelineContextValue } from '../TimelineContext'
|
|
5
|
-
import CaptionTrackRow from '../CaptionTrackRow'
|
|
6
|
-
import type { CaptionSegment } from '../../../schema'
|
|
7
|
-
|
|
8
|
-
// Content width matches totalDuration 1:1 (1000px / 100s) so a clientX delta
|
|
9
|
-
// of N px is exactly N/10 seconds — makes the drag-math assertions readable.
|
|
10
|
-
const RECT = {
|
|
11
|
-
width: 1000, height: 40, left: 0, top: 0, right: 1000, bottom: 40, x: 0, y: 0,
|
|
12
|
-
toJSON: () => ({}),
|
|
13
|
-
} as DOMRect
|
|
14
|
-
|
|
15
|
-
function makeCtx(overrides: Partial<TimelineContextValue> = {}): TimelineContextValue {
|
|
16
|
-
return {
|
|
17
|
-
totalDuration: 100,
|
|
18
|
-
contentDuration: 100,
|
|
19
|
-
snapBoundaries: [],
|
|
20
|
-
zoom: 1,
|
|
21
|
-
zoomRef: { current: 1 },
|
|
22
|
-
scrollRef: { current: { getBoundingClientRect: () => RECT } as unknown as HTMLDivElement },
|
|
23
|
-
scrubberRef: { current: null },
|
|
24
|
-
overlayDraggedRef: { current: false },
|
|
25
|
-
clock: createPlaybackClock(),
|
|
26
|
-
markers: [null, null],
|
|
27
|
-
setMarkers: () => {},
|
|
28
|
-
selection: null,
|
|
29
|
-
...overrides,
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const seg = (over: Partial<CaptionSegment> = {}): CaptionSegment => ({
|
|
34
|
-
id: 'cap-0', text: 'hello world', start: 10, end: 20, ...over,
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
test('empty state renders when captionTrack is absent', () => {
|
|
38
|
-
render(
|
|
39
|
-
<TimelineContext.Provider value={makeCtx()}>
|
|
40
|
-
<CaptionTrackRow captionTrack={undefined} fps={30} selectedCaptionId={null} />
|
|
41
|
-
</TimelineContext.Provider>,
|
|
42
|
-
)
|
|
43
|
-
expect(screen.getByText('Captions')).toBeTruthy()
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('empty state renders when captionTrack has zero segments', () => {
|
|
47
|
-
render(
|
|
48
|
-
<TimelineContext.Provider value={makeCtx()}>
|
|
49
|
-
<CaptionTrackRow captionTrack={{ style: 'clean', segments: [] }} fps={30} selectedCaptionId={null} />
|
|
50
|
-
</TimelineContext.Provider>,
|
|
51
|
-
)
|
|
52
|
-
expect(screen.getByText('Captions')).toBeTruthy()
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('clicking a segment selects it and seeks the playhead into it', () => {
|
|
56
|
-
const ctx = makeCtx()
|
|
57
|
-
const onSelectCaption = vi.fn()
|
|
58
|
-
render(
|
|
59
|
-
<TimelineContext.Provider value={ctx}>
|
|
60
|
-
<CaptionTrackRow
|
|
61
|
-
captionTrack={{ style: 'clean', segments: [seg()] }}
|
|
62
|
-
fps={30}
|
|
63
|
-
selectedCaptionId={null}
|
|
64
|
-
onSelectCaption={onSelectCaption}
|
|
65
|
-
/>
|
|
66
|
-
</TimelineContext.Provider>,
|
|
67
|
-
)
|
|
68
|
-
// The row itself (trackRow) also carries `cursor-pointer`, so anchor off the
|
|
69
|
-
// segment's text and walk up to its block instead of querying the class.
|
|
70
|
-
const block = screen.getByText('hello world').parentElement as HTMLDivElement
|
|
71
|
-
fireEvent.click(block)
|
|
72
|
-
expect(onSelectCaption).toHaveBeenCalledWith('cap-0')
|
|
73
|
-
expect(ctx.clock.get()).toBeCloseTo(10 + 0.5 / 30)
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
// Regression guard: the seek must survive CaptionPreview's frame quantization.
|
|
77
|
-
// The preview computes `t = Math.round(currentTime * fps) / fps` before running
|
|
78
|
-
// the templates' `t >= start && t < end` test, so seeking to a `start` that is
|
|
79
|
-
// not frame-aligned used to round DOWN into the PREVIOUS segment — the operator
|
|
80
|
-
// clicked a caption in the timeline and the preview drew its box over the one
|
|
81
|
-
// before it. Whisper emits arbitrary floats, so this hit about half of all
|
|
82
|
-
// segments.
|
|
83
|
-
test('the seek lands inside the clicked segment even when its start is not frame-aligned', () => {
|
|
84
|
-
const fps = 30
|
|
85
|
-
// 3.44 * 30 = 103.2 → frame 103 → t = 3.4333, which is BEFORE 3.44.
|
|
86
|
-
const segments: CaptionSegment[] = [
|
|
87
|
-
{ id: 'cap-0', text: 'first', start: 0, end: 3.44 },
|
|
88
|
-
{ id: 'cap-1', text: 'second', start: 3.44, end: 6.02 },
|
|
89
|
-
]
|
|
90
|
-
const ctx = makeCtx()
|
|
91
|
-
render(
|
|
92
|
-
<TimelineContext.Provider value={ctx}>
|
|
93
|
-
<CaptionTrackRow
|
|
94
|
-
captionTrack={{ style: 'clean', segments }}
|
|
95
|
-
fps={fps}
|
|
96
|
-
selectedCaptionId={null}
|
|
97
|
-
onSelectCaption={vi.fn()}
|
|
98
|
-
/>
|
|
99
|
-
</TimelineContext.Provider>,
|
|
100
|
-
)
|
|
101
|
-
fireEvent.click(screen.getByText('second').parentElement as HTMLDivElement)
|
|
102
|
-
|
|
103
|
-
const t = Math.round(ctx.clock.get() * fps) / fps
|
|
104
|
-
const active = segments.find((s) => t >= s.start && t < s.end)
|
|
105
|
-
expect(active?.id).toBe('cap-1')
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
// Regression guard: `steps/lyrics/caption.py` emits segments with no `id`
|
|
109
|
-
// field at all — ids are only minted later by VideoEditor's
|
|
110
|
-
// backfillCaptionIds effect. On first render `live` is null, so `live?.id`
|
|
111
|
-
// is undefined; an id-less segment's `seg.id` is also undefined, and
|
|
112
|
-
// undefined === undefined used to make `isLive` true and dereference `live!`
|
|
113
|
-
// while it was still null. This is the normal shape of every real project on
|
|
114
|
-
// its first paint, not an edge case.
|
|
115
|
-
test('renders without throwing when segments have no id field (pre-backfill shape)', () => {
|
|
116
|
-
const segments = [
|
|
117
|
-
{ text: 'hello world', start: 10, end: 20 },
|
|
118
|
-
] as unknown as CaptionSegment[]
|
|
119
|
-
expect(() =>
|
|
120
|
-
render(
|
|
121
|
-
<TimelineContext.Provider value={makeCtx()}>
|
|
122
|
-
<CaptionTrackRow
|
|
123
|
-
captionTrack={{ style: 'clean', segments }}
|
|
124
|
-
fps={30}
|
|
125
|
-
selectedCaptionId={null}
|
|
126
|
-
/>
|
|
127
|
-
</TimelineContext.Provider>,
|
|
128
|
-
),
|
|
129
|
-
).not.toThrow()
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
describe('edge-drag retiming', () => {
|
|
133
|
-
// Regression guard for the single easiest way to break this feature: a
|
|
134
|
-
// retime patch must carry ONLY the dragged edge. If `text` ever leaked in,
|
|
135
|
-
// makeCaptionEdit would respread word timings and destroy per-word timing.
|
|
136
|
-
it('dragging the right edge sends { end } only — never text/words/other fields', () => {
|
|
137
|
-
const ctx = makeCtx()
|
|
138
|
-
const onCaptionSegmentChange = vi.fn()
|
|
139
|
-
const { container } = render(
|
|
140
|
-
<TimelineContext.Provider value={ctx}>
|
|
141
|
-
<CaptionTrackRow
|
|
142
|
-
captionTrack={{ style: 'clean', segments: [seg()] }}
|
|
143
|
-
fps={30}
|
|
144
|
-
selectedCaptionId={null}
|
|
145
|
-
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
146
|
-
/>
|
|
147
|
-
</TimelineContext.Provider>,
|
|
148
|
-
)
|
|
149
|
-
const handles = container.querySelectorAll('.cursor-ew-resize')
|
|
150
|
-
const rightHandle = handles[1] as HTMLDivElement
|
|
151
|
-
|
|
152
|
-
fireEvent.mouseDown(rightHandle, { clientX: 500 })
|
|
153
|
-
fireEvent.mouseMove(document, { clientX: 600 }) // +100px = +10s
|
|
154
|
-
// Not committed until mouseup — retiming must not fire on every tick
|
|
155
|
-
// (unlike VisualTrackRow, onCaptionSegmentChange pushes an undo entry and
|
|
156
|
-
// queues a save PER CALL).
|
|
157
|
-
expect(onCaptionSegmentChange).not.toHaveBeenCalled()
|
|
158
|
-
|
|
159
|
-
fireEvent.mouseUp(document)
|
|
160
|
-
expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
|
|
161
|
-
const [id, patch] = onCaptionSegmentChange.mock.calls[0]
|
|
162
|
-
expect(id).toBe('cap-0')
|
|
163
|
-
expect(patch).toEqual({ end: 30 })
|
|
164
|
-
expect(Object.keys(patch)).toEqual(['end'])
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
// `useItemDragDrop.beginResize` has no travel threshold (unlike `beginDrag`)
|
|
168
|
-
// and calls onCommit unconditionally on mouseup, so a bare click on the 6px
|
|
169
|
-
// edge handle used to commit the edge's unchanged value — one undo entry and
|
|
170
|
-
// one queued save for a project that did not change.
|
|
171
|
-
it('a press-and-release on an edge handle with no movement commits nothing', () => {
|
|
172
|
-
const ctx = makeCtx()
|
|
173
|
-
const onCaptionSegmentChange = vi.fn()
|
|
174
|
-
const { container } = render(
|
|
175
|
-
<TimelineContext.Provider value={ctx}>
|
|
176
|
-
<CaptionTrackRow
|
|
177
|
-
captionTrack={{ style: 'clean', segments: [seg()] }}
|
|
178
|
-
fps={30}
|
|
179
|
-
selectedCaptionId={null}
|
|
180
|
-
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
181
|
-
/>
|
|
182
|
-
</TimelineContext.Provider>,
|
|
183
|
-
)
|
|
184
|
-
const rightHandle = container.querySelectorAll('.cursor-ew-resize')[1] as HTMLDivElement
|
|
185
|
-
|
|
186
|
-
fireEvent.mouseDown(rightHandle, { clientX: 500 })
|
|
187
|
-
fireEvent.mouseUp(document)
|
|
188
|
-
expect(onCaptionSegmentChange).not.toHaveBeenCalled()
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
// A drag that returns to where it started is likewise a no-op — the edge
|
|
192
|
-
// value is what matters, not whether the pointer moved.
|
|
193
|
-
it('a drag that ends back at the original edge commits nothing', () => {
|
|
194
|
-
const ctx = makeCtx()
|
|
195
|
-
const onCaptionSegmentChange = vi.fn()
|
|
196
|
-
const { container } = render(
|
|
197
|
-
<TimelineContext.Provider value={ctx}>
|
|
198
|
-
<CaptionTrackRow
|
|
199
|
-
captionTrack={{ style: 'clean', segments: [seg()] }}
|
|
200
|
-
fps={30}
|
|
201
|
-
selectedCaptionId={null}
|
|
202
|
-
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
203
|
-
/>
|
|
204
|
-
</TimelineContext.Provider>,
|
|
205
|
-
)
|
|
206
|
-
const rightHandle = container.querySelectorAll('.cursor-ew-resize')[1] as HTMLDivElement
|
|
207
|
-
|
|
208
|
-
fireEvent.mouseDown(rightHandle, { clientX: 500 })
|
|
209
|
-
fireEvent.mouseMove(document, { clientX: 600 })
|
|
210
|
-
fireEvent.mouseMove(document, { clientX: 500 })
|
|
211
|
-
fireEvent.mouseUp(document)
|
|
212
|
-
expect(onCaptionSegmentChange).not.toHaveBeenCalled()
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
it('dragging the left edge sends { start } only', () => {
|
|
216
|
-
const ctx = makeCtx()
|
|
217
|
-
const onCaptionSegmentChange = vi.fn()
|
|
218
|
-
const { container } = render(
|
|
219
|
-
<TimelineContext.Provider value={ctx}>
|
|
220
|
-
<CaptionTrackRow
|
|
221
|
-
captionTrack={{ style: 'clean', segments: [seg()] }}
|
|
222
|
-
fps={30}
|
|
223
|
-
selectedCaptionId={null}
|
|
224
|
-
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
225
|
-
/>
|
|
226
|
-
</TimelineContext.Provider>,
|
|
227
|
-
)
|
|
228
|
-
const handles = container.querySelectorAll('.cursor-ew-resize')
|
|
229
|
-
const leftHandle = handles[0] as HTMLDivElement
|
|
230
|
-
|
|
231
|
-
fireEvent.mouseDown(leftHandle, { clientX: 500 })
|
|
232
|
-
fireEvent.mouseMove(document, { clientX: 400 }) // -100px = -10s
|
|
233
|
-
fireEvent.mouseUp(document)
|
|
234
|
-
|
|
235
|
-
expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
|
|
236
|
-
const [id, patch] = onCaptionSegmentChange.mock.calls[0]
|
|
237
|
-
expect(id).toBe('cap-0')
|
|
238
|
-
expect(patch).toEqual({ start: 0 })
|
|
239
|
-
expect(Object.keys(patch)).toEqual(['start'])
|
|
240
|
-
})
|
|
241
|
-
})
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/// <reference types="vitest/globals" />
|
|
2
|
-
import { render, act } from '@testing-library/react'
|
|
3
|
-
import { createPlaybackClock } from '../../playback-clock'
|
|
4
|
-
import { TimelineContext, type TimelineContextValue } from '../TimelineContext'
|
|
5
|
-
import PlayheadLine from '../PlayheadLine'
|
|
6
|
-
|
|
7
|
-
function makeCtx(overrides: Partial<TimelineContextValue> = {}): TimelineContextValue {
|
|
8
|
-
return {
|
|
9
|
-
totalDuration: 10,
|
|
10
|
-
contentDuration: 10,
|
|
11
|
-
snapBoundaries: [],
|
|
12
|
-
zoom: 1,
|
|
13
|
-
zoomRef: { current: 1 },
|
|
14
|
-
scrollRef: { current: null },
|
|
15
|
-
scrubberRef: { current: null },
|
|
16
|
-
overlayDraggedRef: { current: false },
|
|
17
|
-
clock: createPlaybackClock(),
|
|
18
|
-
markers: [null, null],
|
|
19
|
-
setMarkers: () => {},
|
|
20
|
-
selection: null,
|
|
21
|
-
...overrides,
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
test('PlayheadLine moves on clock.set without re-rendering a sibling row', () => {
|
|
26
|
-
const ctx = makeCtx()
|
|
27
|
-
let siblingRenders = 0
|
|
28
|
-
|
|
29
|
-
function Sibling() {
|
|
30
|
-
siblingRenders++
|
|
31
|
-
return null
|
|
32
|
-
}
|
|
33
|
-
function Root() {
|
|
34
|
-
return (
|
|
35
|
-
<TimelineContext.Provider value={ctx}>
|
|
36
|
-
<PlayheadLine />
|
|
37
|
-
<Sibling />
|
|
38
|
-
</TimelineContext.Provider>
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const { container } = render(<Root />)
|
|
43
|
-
const before = siblingRenders
|
|
44
|
-
|
|
45
|
-
act(() => { ctx.clock.set(4) })
|
|
46
|
-
|
|
47
|
-
const line = container.querySelector('div') as HTMLDivElement
|
|
48
|
-
expect(line.style.left).toBe('40%')
|
|
49
|
-
expect(siblingRenders).toBe(before)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test('renders nothing when totalDuration is 0', () => {
|
|
53
|
-
const ctx = makeCtx({ totalDuration: 0 })
|
|
54
|
-
const { container } = render(
|
|
55
|
-
<TimelineContext.Provider value={ctx}>
|
|
56
|
-
<PlayheadLine />
|
|
57
|
-
</TimelineContext.Provider>,
|
|
58
|
-
)
|
|
59
|
-
expect(container.firstChild).toBeNull()
|
|
60
|
-
})
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
-
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
|
3
|
-
import type { Project } from '../../../types'
|
|
4
|
-
import type { CaptionSegment } from '../../../schema'
|
|
5
|
-
import TranscriptModal from '../TranscriptModal'
|
|
6
|
-
|
|
7
|
-
afterEach(() => cleanup())
|
|
8
|
-
|
|
9
|
-
function makeProject(segments: CaptionSegment[]): Project {
|
|
10
|
-
return { id: 'p1', captions: { style: 'word-by-word', segments } } as unknown as Project
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe('TranscriptModal caption text edits', () => {
|
|
14
|
-
// Regression: makeCaptionEdit fires every callback it's given with the same
|
|
15
|
-
// updated project. TranscriptModal used to pass BOTH onProjectChange and
|
|
16
|
-
// onCaptionEdit into makeCaptionEdit for a text edit, and — at this
|
|
17
|
-
// component's real call site (VideoEditor's ReviewSurface, via Timeline) —
|
|
18
|
-
// both land on sync.mutate, so one text edit produced two undo entries and
|
|
19
|
-
// two queued saves. Only onCaptionEdit (the commit channel) should fire;
|
|
20
|
-
// onProjectChange is no longer even an accepted prop (see TranscriptModal.tsx).
|
|
21
|
-
it('a completed text edit produces exactly one commit, not two', () => {
|
|
22
|
-
const onCaptionEdit = vi.fn()
|
|
23
|
-
const project = makeProject([{ id: 'cap-0', text: 'hello world', start: 0, end: 2 }])
|
|
24
|
-
render(
|
|
25
|
-
<TranscriptModal
|
|
26
|
-
captionTrack={project.captions}
|
|
27
|
-
currentTime={0}
|
|
28
|
-
project={project}
|
|
29
|
-
onCaptionEdit={onCaptionEdit}
|
|
30
|
-
onClose={() => {}}
|
|
31
|
-
/>,
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
const span = screen.getByText('hello world')
|
|
35
|
-
span.textContent = 'hello there'
|
|
36
|
-
fireEvent.blur(span)
|
|
37
|
-
|
|
38
|
-
expect(onCaptionEdit).toHaveBeenCalledTimes(1)
|
|
39
|
-
expect(onCaptionEdit.mock.calls[0][0].captions.segments[0].text).toBe('hello there')
|
|
40
|
-
})
|
|
41
|
-
})
|