@bycrux/editor 0.11.2 → 1.0.1
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 +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- 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 +601 -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 +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- 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 +2229 -243
- 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/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -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,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react'
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react'
|
|
2
|
+
import { containsTime } from '@bycrux/timeline-core'
|
|
2
3
|
import { videoTransformContainerStyle, videoTransformBoxPct, type VideoTransform } from './transformStyle'
|
|
3
4
|
import type { EditorProject as Project } from '../../schema'
|
|
4
5
|
import type { OverlayFactory } from '../../types'
|
|
@@ -9,12 +10,49 @@ import type { OverlayChanges } from './useDragOverlay'
|
|
|
9
10
|
import type { CaptionEditPatch } from '../timeline/makeCaptionEdit'
|
|
10
11
|
import OverlayItemsLayer from './OverlayItemsLayer'
|
|
11
12
|
import { useVideoPlayback } from './useVideoPlayback'
|
|
13
|
+
import { useEnginePlayback, type EnginePlayback } from './useEnginePlayback'
|
|
14
|
+
import EngineSurface from './EngineSurface'
|
|
15
|
+
import { evaluateEngineEligibility } from '../../engine/eligibility'
|
|
16
|
+
import type { AcquiredDemux } from '../../engine'
|
|
12
17
|
import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
|
|
18
|
+
import { gateTimeSink, handOverToHover, useHoverScrubTime, type HoverScrub } from '../hover-scrub'
|
|
13
19
|
import { sourceCropVideoStyle } from './sourceCropStyle'
|
|
14
20
|
import CarouselPreview from './CarouselPreview'
|
|
21
|
+
import SocialSafeZoneOverlay, { type SocialPreviewPlatform } from './SocialSafeZoneOverlay'
|
|
15
22
|
|
|
16
23
|
// ---------------------------------------------------------------------------
|
|
17
24
|
|
|
25
|
+
/**
|
|
26
|
+
* SP5 T9 — the transport seam. The ONE cross-component API a host-level
|
|
27
|
+
* keymap needs to drive play/pause without knowing which playback path
|
|
28
|
+
* (legacy `<video>` slots or the WebCodecs engine) is actually running.
|
|
29
|
+
* `PreviewPlayer` fills it from whichever hook is active; see
|
|
30
|
+
* `PreviewSurface` below.
|
|
31
|
+
*/
|
|
32
|
+
export interface TransportHandle {
|
|
33
|
+
togglePlay: () => void
|
|
34
|
+
isPlaying: () => boolean
|
|
35
|
+
/** The J/K/L shuttle's live transport rate. Engine mode drives the engine's
|
|
36
|
+
* own rate (audible fast-forward); the legacy path has no rate knob, so it is
|
|
37
|
+
* a no-op there. */
|
|
38
|
+
setRate: (rate: number) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The audible drag-scrub source's (`../../engine/scrub-source.ts`) seam onto
|
|
43
|
+
* the engine's shared demux LRU — mirrors `TransportHandle`'s shape. Filled
|
|
44
|
+
* only on the WebCodecs engine path (see the effect beside `transportRef`'s,
|
|
45
|
+
* below); stays null on the legacy `<video>` fallback, since a fallback
|
|
46
|
+
* project's clips either lack a `proxySrc` or can't be engine-decoded — this
|
|
47
|
+
* absence IS the capability gate the scrubber needs.
|
|
48
|
+
*/
|
|
49
|
+
export interface ScrubHandle {
|
|
50
|
+
acquireDemux: (src: string) => Promise<AcquiredDemux>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Stable no-op for the legacy playback path, which has no transport-rate knob. */
|
|
54
|
+
const NO_RATE = (_rate: number) => {}
|
|
55
|
+
|
|
18
56
|
interface PreviewPlayerProps {
|
|
19
57
|
project: Project
|
|
20
58
|
clock: PlaybackClock
|
|
@@ -33,11 +71,195 @@ interface PreviewPlayerProps {
|
|
|
33
71
|
selectedCaptionId?: string
|
|
34
72
|
onSelectCaption?: (id: string | null) => void
|
|
35
73
|
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
74
|
+
/**
|
|
75
|
+
* SP4 — mirrors `VideoEditorProps['engine']` (see `../../types.ts`).
|
|
76
|
+
* Absent/`{enabled: false}` (default): no effect, the legacy `<video>` slots
|
|
77
|
+
* render exactly as before — the eligibility probe is never even run.
|
|
78
|
+
* `{enabled: true}` asks for the WebCodecs engine; whether this project
|
|
79
|
+
* actually gets it is decided by `useEngineMode` below.
|
|
80
|
+
*/
|
|
81
|
+
engine?: { enabled: boolean; debugHud?: boolean }
|
|
82
|
+
/**
|
|
83
|
+
* SP5 T9 — imperative play/pause handle for a host-level keymap/command
|
|
84
|
+
* palette. Filled from whichever playback hook is active (legacy or
|
|
85
|
+
* engine). Absent → no-op; nothing reads or writes it.
|
|
86
|
+
*/
|
|
87
|
+
transportRef?: MutableRefObject<TransportHandle | null>
|
|
88
|
+
/**
|
|
89
|
+
* The audible drag-scrub source's engine seam — mirrors `transportRef`.
|
|
90
|
+
* Absent → no-op; nothing reads or writes it.
|
|
91
|
+
*/
|
|
92
|
+
scrubHandleRef?: MutableRefObject<ScrubHandle | null>
|
|
93
|
+
/**
|
|
94
|
+
* The preview-axis override: while the pointer is over the timeline with the
|
|
95
|
+
* axis on, this holds the time under it and the preview paints THAT frame
|
|
96
|
+
* instead of the playhead's. Null (or the store absent) → the playhead's
|
|
97
|
+
* frame, which is every other moment and every other host.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately not folded into `clock`: the playhead must not follow the
|
|
100
|
+
* pointer, or the red line and the scrubber handle would chase the mouse and
|
|
101
|
+
* Space would start playing from wherever it happened to rest. See
|
|
102
|
+
* `../hover-scrub.ts`.
|
|
103
|
+
*/
|
|
104
|
+
hoverScrub?: HoverScrub
|
|
105
|
+
/**
|
|
106
|
+
* Social-platform preview chrome (mirrors CapCut's "Preview your video for
|
|
107
|
+
* social media" picker) — see `SocialSafeZoneOverlay`. Absent/falsy/
|
|
108
|
+
* unrecognized → no-op, same contract as that component's own `platform`
|
|
109
|
+
* prop, which this passes straight through. Threaded in here (rather than
|
|
110
|
+
* mounted by the host as a sibling of `PreviewPlayer`) so it lands INSIDE
|
|
111
|
+
* the same stacking context as the rest of the picture — see the z-index
|
|
112
|
+
* note at its render site below.
|
|
113
|
+
*/
|
|
114
|
+
socialPreview?: SocialPreviewPlatform | string | null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default function PreviewPlayer(props: PreviewPlayerProps) {
|
|
118
|
+
if (props.project.projectType === 'carousel') {
|
|
119
|
+
// CarouselPreview renders into normal document flow (no `isolation:
|
|
120
|
+
// isolate` stacking context of its own — see VideoPreviewPlayer/
|
|
121
|
+
// PreviewSurface below), so the social-preview overlay composes correctly
|
|
122
|
+
// as a plain sibling here; it only needs to move INSIDE the video path.
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<CarouselPreview project={props.project} />
|
|
126
|
+
<SocialSafeZoneOverlay platform={props.socialPreview} />
|
|
127
|
+
</>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return <VideoPreviewPlayer {...props} />
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Which playback path owns this project ────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
type EngineMode = 'legacy' | 'engine'
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Plan decision 2, at its call site.
|
|
139
|
+
*
|
|
140
|
+
* Eligibility is evaluated ONCE per project LOAD — re-run only when the project
|
|
141
|
+
* IDENTITY changes (a different `project.id`), never on an edit. Two
|
|
142
|
+
* consequences the plan states explicitly and this hook is the enforcement of:
|
|
143
|
+
*
|
|
144
|
+
* - a project that was eligible stays on the engine for the whole session,
|
|
145
|
+
* even if a clip added later has no proxy yet (that clip alone shows
|
|
146
|
+
* `EngineSurface`'s Preparing placeholder — the engine never hands a
|
|
147
|
+
* running project back to the legacy player);
|
|
148
|
+
* - a project that was INELIGIBLE stays on the legacy player for the whole
|
|
149
|
+
* session, even if its proxies finish encoding a minute later. A reload
|
|
150
|
+
* picks up engine mode. The alternative — swapping playback engines under a
|
|
151
|
+
* playing editor — is the mode-flapping the decision exists to forbid.
|
|
152
|
+
*
|
|
153
|
+
* Returns `null` while the (async) capability probe is still outstanding, which
|
|
154
|
+
* can only happen with the flag ON: with it off the answer is `'legacy'`
|
|
155
|
+
* synchronously, on the first render, with no probe and no console line.
|
|
156
|
+
*/
|
|
157
|
+
function useEngineMode(
|
|
158
|
+
engine: PreviewPlayerProps['engine'],
|
|
159
|
+
project: Project,
|
|
160
|
+
): EngineMode | null {
|
|
161
|
+
const enabled = !!engine?.enabled
|
|
162
|
+
const projectId = project.id
|
|
163
|
+
const [decision, setDecision] = useState<{ id: string; mode: EngineMode } | null>(null)
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!enabled) return
|
|
167
|
+
let cancelled = false
|
|
168
|
+
void evaluateEngineEligibility(project).then((result) => {
|
|
169
|
+
if (cancelled) return
|
|
170
|
+
if (!result.eligible) {
|
|
171
|
+
// ONE line, with the reason. Not a warning: falling back is the
|
|
172
|
+
// designed behavior, not a fault.
|
|
173
|
+
console.info(
|
|
174
|
+
`[montaj] playback engine unavailable for this project — using the legacy player (${result.reason ?? 'ineligible'})`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
setDecision({ id: projectId, mode: result.eligible ? 'engine' : 'legacy' })
|
|
178
|
+
})
|
|
179
|
+
return () => { cancelled = true }
|
|
180
|
+
// Deliberately NOT `[project]`: an edit must not re-evaluate. The `project`
|
|
181
|
+
// captured here is the one loaded under this id, which is what "evaluated
|
|
182
|
+
// once per project-load" means.
|
|
183
|
+
}, [enabled, projectId])
|
|
184
|
+
|
|
185
|
+
if (!enabled) return 'legacy'
|
|
186
|
+
if (!decision || decision.id !== projectId) return null
|
|
187
|
+
return decision.mode
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function VideoPreviewPlayer(props: PreviewPlayerProps) {
|
|
191
|
+
const { project, clock, engine } = props
|
|
192
|
+
// Subscribe to the playhead store. PreviewPlayer legitimately re-renders per
|
|
193
|
+
// tick — activeClip/cropStyle memos and the video/overlay/caption children all
|
|
194
|
+
// depend on the current time.
|
|
195
|
+
const playheadTime = usePlaybackTime(clock)
|
|
196
|
+
// Hover-scrub wins while it is set. Everything downstream — the playback
|
|
197
|
+
// hooks' seeking, the active-clip memo, the overlay and caption layers —
|
|
198
|
+
// reads this one number, so the previewed frame, the overlays drawn on it and
|
|
199
|
+
// the highlighted caption all describe the same instant.
|
|
200
|
+
const hoverTime = useHoverScrubTime(props.hoverScrub)
|
|
201
|
+
const currentTime = hoverTime ?? playheadTime
|
|
202
|
+
const mode = useEngineMode(engine, project)
|
|
203
|
+
|
|
204
|
+
// See `gateTimeSink` — never hand the hooks `clock.set` directly, or seeking
|
|
205
|
+
// to show a hovered frame writes that position back and the red playhead
|
|
206
|
+
// chases the yellow cursor.
|
|
207
|
+
const hoverScrub = props.hoverScrub
|
|
208
|
+
const timeSink = useMemo(() => gateTimeSink(clock, hoverScrub), [clock, hoverScrub])
|
|
209
|
+
|
|
210
|
+
const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
|
|
211
|
+
|
|
212
|
+
// Flag on, probe outstanding. Holding the frame for the (sub-frame, cached
|
|
213
|
+
// after the first project) probe is what keeps the legacy hook from mounting,
|
|
214
|
+
// loading a <video>, wiring Web Audio and tearing it all down again one tick
|
|
215
|
+
// later. The two playback hooks are DIFFERENT components on purpose: mounting
|
|
216
|
+
// one and only one of them is what makes a single hook call per path legal.
|
|
217
|
+
if (mode === null) {
|
|
218
|
+
return (
|
|
219
|
+
<div
|
|
220
|
+
className="relative bg-black h-full max-w-full overflow-hidden rounded"
|
|
221
|
+
style={{ aspectRatio: `${RENDER_W} / ${RENDER_H}`, isolation: 'isolate' }}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return mode === 'engine'
|
|
227
|
+
? <EnginePreview {...props} currentTime={currentTime} timeSink={timeSink} />
|
|
228
|
+
: <LegacyPreview {...props} currentTime={currentTime} timeSink={timeSink} />
|
|
36
229
|
}
|
|
37
230
|
|
|
38
|
-
|
|
231
|
+
/**
|
|
232
|
+
* The two playback paths, discriminated. `PreviewSurface` reads the shared keys
|
|
233
|
+
* off the union directly and narrows on `mode` for the surface itself; nothing
|
|
234
|
+
* else in the component tree knows which path is running.
|
|
235
|
+
*/
|
|
236
|
+
type PlaybackBinding =
|
|
237
|
+
| ({ mode: 'legacy' } & ReturnType<typeof useVideoPlayback>)
|
|
238
|
+
| ({ mode: 'engine' } & EnginePlayback)
|
|
239
|
+
|
|
240
|
+
type SurfaceProps = PreviewPlayerProps & {
|
|
241
|
+
currentTime: number
|
|
242
|
+
/** `clock.set`, gated on hover-preview — see `timeSink` above. Never pass
|
|
243
|
+
* `clock.set` directly here, or hover-seeking writes back into the clock. */
|
|
244
|
+
timeSink: (t: number) => void
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function LegacyPreview(props: SurfaceProps) {
|
|
248
|
+
const playback = useVideoPlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
|
|
249
|
+
return <PreviewSurface {...props} playback={{ mode: 'legacy', ...playback }} />
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function EnginePreview(props: SurfaceProps) {
|
|
253
|
+
const playback = useEnginePlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
|
|
254
|
+
return <PreviewSurface {...props} playback={{ mode: 'engine', ...playback }} />
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function PreviewSurface({
|
|
39
258
|
project,
|
|
40
259
|
clock,
|
|
260
|
+
hoverScrub,
|
|
261
|
+
currentTime,
|
|
262
|
+
playback,
|
|
41
263
|
selectedOverlayId,
|
|
42
264
|
onOverlayChange,
|
|
43
265
|
onEditOverlay,
|
|
@@ -49,16 +271,19 @@ export default function PreviewPlayer({
|
|
|
49
271
|
selectedCaptionId,
|
|
50
272
|
onSelectCaption,
|
|
51
273
|
onCaptionSegmentChange,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// depend on the current time.
|
|
58
|
-
const currentTime = usePlaybackTime(clock)
|
|
59
|
-
|
|
274
|
+
engine,
|
|
275
|
+
transportRef,
|
|
276
|
+
scrubHandleRef,
|
|
277
|
+
socialPreview,
|
|
278
|
+
}: SurfaceProps & { playback: PlaybackBinding }) {
|
|
60
279
|
const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
|
|
61
280
|
|
|
281
|
+
// Play from the yellow line, and bring the red one with it — see
|
|
282
|
+
// `handOverToHover`. No-op on every ordinary press of play.
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (playback.isPlaying) handOverToHover(clock, hoverScrub)
|
|
285
|
+
}, [playback.isPlaying, hoverScrub, clock])
|
|
286
|
+
|
|
62
287
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
63
288
|
const [renderScale, setRenderScale] = useState<number>(1)
|
|
64
289
|
// Frame pixel size — used to compute the sourceCrop CSS transform that mirrors
|
|
@@ -88,22 +313,39 @@ export default function PreviewPlayer({
|
|
|
88
313
|
} = useDragOverlay(containerRef, onOverlayChange)
|
|
89
314
|
|
|
90
315
|
const {
|
|
91
|
-
video0Ref,
|
|
92
|
-
video1Ref,
|
|
93
|
-
activeSlotRef,
|
|
94
|
-
activeSlot,
|
|
95
316
|
showVideo,
|
|
96
317
|
isPlaying,
|
|
97
|
-
setIsPlaying,
|
|
98
|
-
handleTimeUpdate,
|
|
99
|
-
handlePause,
|
|
100
|
-
handleEnded,
|
|
101
318
|
togglePlay,
|
|
102
319
|
isCanvasProject,
|
|
103
320
|
clips,
|
|
104
321
|
tracks0NonVideo,
|
|
105
322
|
overlayTracks,
|
|
106
|
-
} =
|
|
323
|
+
} = playback
|
|
324
|
+
|
|
325
|
+
// The transport seam (SP5 T9): fill the host's ref with the ACTIVE path's
|
|
326
|
+
// togglePlay/isPlaying, identically shaped for both legacy and engine
|
|
327
|
+
// playback — a host-level keymap never needs to know which one is running.
|
|
328
|
+
// `isPlaying` is exposed as a getter (not the boolean itself) so a poller
|
|
329
|
+
// (the J/K/L shuttle) always reads the current value, not a stale closure
|
|
330
|
+
// from the render that last wrote the ref.
|
|
331
|
+
// Engine mode carries the real rate knob; the legacy path has none (no-op).
|
|
332
|
+
const setRate = playback.mode === 'engine' ? playback.setRate : NO_RATE
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (!transportRef) return
|
|
335
|
+
transportRef.current = { togglePlay, isPlaying: () => isPlaying, setRate }
|
|
336
|
+
return () => { transportRef.current = null }
|
|
337
|
+
}, [transportRef, togglePlay, isPlaying, setRate])
|
|
338
|
+
|
|
339
|
+
// The scrub seam: only the engine path can decode a grain, so the legacy
|
|
340
|
+
// `<video>` fallback simply never fills this — that absence is the audible
|
|
341
|
+
// drag-scrub's capability gate. `acquireDemux` is a stable-identity callback
|
|
342
|
+
// (see `useEnginePlayback`), so this effect only re-runs on a mode change.
|
|
343
|
+
const acquireDemux = playback.mode === 'engine' ? playback.acquireDemux : undefined
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (!scrubHandleRef || !acquireDemux) return
|
|
346
|
+
scrubHandleRef.current = { acquireDemux }
|
|
347
|
+
return () => { scrubHandleRef.current = null }
|
|
348
|
+
}, [scrubHandleRef, acquireDemux])
|
|
107
349
|
|
|
108
350
|
const captionTrack = useMemo(() => project.captions, [project])
|
|
109
351
|
|
|
@@ -113,8 +355,19 @@ export default function PreviewPlayer({
|
|
|
113
355
|
// playhead (same selection the playback hook uses internally). Only the active
|
|
114
356
|
// <video> slot is opaque, so applying the active clip's crop to both slots is
|
|
115
357
|
// safe — the inactive slot is invisible.
|
|
358
|
+
//
|
|
359
|
+
// Activation is `containsTime` from @bycrux/timeline-core — the shared
|
|
360
|
+
// half-open `start <= t < end` predicate, so at an exact cut the LATER clip
|
|
361
|
+
// wins here exactly as it does in the renderer.
|
|
362
|
+
//
|
|
363
|
+
// The `?? clips[clips.length - 1]` tail is deliberately NOT part of the
|
|
364
|
+
// resolver: `resolveAt` returns an EMPTY Scene inside a gap or past the end of
|
|
365
|
+
// the timeline. Falling back to the last clip is editor-side PRESENTATION —
|
|
366
|
+
// it keeps the trailing frame's crop/transform on screen instead of snapping
|
|
367
|
+
// to an untransformed frame — so it stays local. See the `containsTime` note
|
|
368
|
+
// in timeline-core/index.d.ts.
|
|
116
369
|
const activeClip = useMemo(
|
|
117
|
-
() => clips.find(c =>
|
|
370
|
+
() => clips.find(c => containsTime(c.start, c.end, currentTime)) ?? clips[clips.length - 1],
|
|
118
371
|
[clips, currentTime],
|
|
119
372
|
)
|
|
120
373
|
const cropStyle = useMemo(() => {
|
|
@@ -211,42 +464,59 @@ export default function PreviewPlayer({
|
|
|
211
464
|
No clips
|
|
212
465
|
</div>
|
|
213
466
|
) : (
|
|
214
|
-
// Transform container — applies the active clip's scale/offset to
|
|
215
|
-
//
|
|
216
|
-
//
|
|
467
|
+
// Transform container — applies the active clip's scale/offset to the
|
|
468
|
+
// picture surface. On the legacy path that surface is the two <video>
|
|
469
|
+
// slots (the inactive one is invisible); on the engine path it is one
|
|
470
|
+
// canvas. The div itself, its styles and its z-order are IDENTICAL for
|
|
471
|
+
// both — the frame's overflow-hidden clips anything pushed outside, and
|
|
472
|
+
// the whole thing mirrors the renderer's crop→scale→position.
|
|
217
473
|
<div className="absolute inset-0" style={transformContainerStyle}>
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
474
|
+
{playback.mode === 'engine' ? (
|
|
475
|
+
<EngineSurface
|
|
476
|
+
attach={playback.attachCanvas}
|
|
477
|
+
picture={playback.status.picture}
|
|
478
|
+
reason={playback.status.reason}
|
|
479
|
+
debugHud={engine?.debugHud}
|
|
480
|
+
getStats={playback.getStats}
|
|
481
|
+
/>
|
|
482
|
+
) : (
|
|
483
|
+
<>
|
|
484
|
+
{/* Slot 0 */}
|
|
485
|
+
<video
|
|
486
|
+
ref={playback.video0Ref}
|
|
487
|
+
// Clips load cross-origin from R2; without this the media is CORS-tainted
|
|
488
|
+
// and the Web Audio createMediaElementSource graph outputs silence. R2
|
|
489
|
+
// sends Access-Control-Allow-Origin, so anonymous CORS keeps it audible.
|
|
490
|
+
crossOrigin="anonymous"
|
|
491
|
+
// Fetch enough to render the seeked poster frame on load (before play).
|
|
492
|
+
preload="auto"
|
|
493
|
+
onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
|
|
494
|
+
onTimeUpdate={() => { if (playback.activeSlotRef.current === 0) playback.handleTimeUpdate() }}
|
|
495
|
+
onEnded={() => { if (playback.activeSlotRef.current === 0) playback.handleEnded() }}
|
|
496
|
+
onError={() => playback.handleVideoError(0)}
|
|
497
|
+
onPlay={() => { if (playback.activeSlotRef.current === 0) playback.setIsPlaying(true) }}
|
|
498
|
+
onPause={() => { if (playback.activeSlotRef.current === 0) playback.handlePause() }}
|
|
499
|
+
playsInline
|
|
500
|
+
style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 0 ? 1 : 0, pointerEvents: playback.activeSlot === 0 ? 'auto' : 'none', zIndex: playback.activeSlot === 0 ? 1 : 0 }}
|
|
501
|
+
/>
|
|
502
|
+
{/* Slot 1 */}
|
|
503
|
+
<video
|
|
504
|
+
ref={playback.video1Ref}
|
|
505
|
+
// See slot 0: anonymous CORS so R2 cross-origin clips aren't tainted
|
|
506
|
+
// (which would mute the Web Audio graph).
|
|
507
|
+
crossOrigin="anonymous"
|
|
508
|
+
preload="auto"
|
|
509
|
+
onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
|
|
510
|
+
onTimeUpdate={() => { if (playback.activeSlotRef.current === 1) playback.handleTimeUpdate() }}
|
|
511
|
+
onEnded={() => { if (playback.activeSlotRef.current === 1) playback.handleEnded() }}
|
|
512
|
+
onError={() => playback.handleVideoError(1)}
|
|
513
|
+
onPlay={() => { if (playback.activeSlotRef.current === 1) playback.setIsPlaying(true) }}
|
|
514
|
+
onPause={() => { if (playback.activeSlotRef.current === 1) playback.handlePause() }}
|
|
515
|
+
playsInline
|
|
516
|
+
style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 1 ? 1 : 0, pointerEvents: playback.activeSlot === 1 ? 'auto' : 'none', zIndex: playback.activeSlot === 1 ? 1 : 0 }}
|
|
517
|
+
/>
|
|
518
|
+
</>
|
|
519
|
+
)}
|
|
250
520
|
</div>
|
|
251
521
|
)}
|
|
252
522
|
|
|
@@ -334,7 +604,7 @@ export default function PreviewPlayer({
|
|
|
334
604
|
fileUrl={fileUrl}
|
|
335
605
|
/>
|
|
336
606
|
|
|
337
|
-
{/* Audio elements are managed programmatically in
|
|
607
|
+
{/* Audio elements are managed programmatically in the playback hook */}
|
|
338
608
|
|
|
339
609
|
{/* Caption preview */}
|
|
340
610
|
{captionTrack && (
|
|
@@ -349,6 +619,17 @@ export default function PreviewPlayer({
|
|
|
349
619
|
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
350
620
|
/>
|
|
351
621
|
)}
|
|
622
|
+
|
|
623
|
+
{/* Social-preview chrome — see `SocialSafeZoneOverlay` and the
|
|
624
|
+
`socialPreview` prop doc above. Rendered HERE, inside this same
|
|
625
|
+
`isolation: isolate` container as the video/overlays/captions above
|
|
626
|
+
and the play glyph below, so its z-index (48) is compared directly
|
|
627
|
+
against theirs rather than being evaluated one stacking context up
|
|
628
|
+
(where a positive z-index sibling of an isolated box paints over
|
|
629
|
+
the ENTIRE box, glyph included, regardless of that box's own
|
|
630
|
+
internal ordering). No-ops on an absent/unrecognized platform, so
|
|
631
|
+
this is unconditional. */}
|
|
632
|
+
<SocialSafeZoneOverlay platform={socialPreview} />
|
|
352
633
|
</div>
|
|
353
634
|
)
|
|
354
635
|
}
|