@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Source-capped export resolution/fps ceiling for a project. Pure — the export
|
|
2
|
+
// dialog uses this to decide which resolution/fps tiers are worth offering, so
|
|
3
|
+
// we never upscale past what the source footage actually carries.
|
|
4
|
+
|
|
5
|
+
import type { EditorProject as Project } from '../schema'
|
|
6
|
+
import { trackItems } from './timeline/timeline-model'
|
|
7
|
+
|
|
8
|
+
/** Short-side "class" a tier is named after — 720p / 1080p / 1440p / 2160p. */
|
|
9
|
+
export const STANDARD_RESOLUTION_TIERS = [720, 1080, 1440, 2160] as const
|
|
10
|
+
|
|
11
|
+
export const STANDARD_FPS_TIERS = [24, 30, 60] as const
|
|
12
|
+
|
|
13
|
+
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Max, across video items with known source dims, of `min(sourceWidth, sourceHeight)`.
|
|
16
|
+
* Undefined when no video item carries usable dims. Goes through `trackItems()`
|
|
17
|
+
* (not `project.tracks` directly) so a project still on disk in the legacy
|
|
18
|
+
* `VisualItem[][]` shape — a bare array of item arrays, with no `.items` to
|
|
19
|
+
* read — is tolerated the same way every other reader tolerates it. */
|
|
20
|
+
function maxSourceShortSide(project: Project): number | undefined {
|
|
21
|
+
let max: number | undefined
|
|
22
|
+
for (const items of trackItems(project)) {
|
|
23
|
+
for (const item of items) {
|
|
24
|
+
if (item.type !== 'video') continue
|
|
25
|
+
const { sourceWidth: sw, sourceHeight: sh } = item
|
|
26
|
+
if (!sw || !sh || sw <= 0 || sh <= 0) continue
|
|
27
|
+
const short = Math.min(sw, sh)
|
|
28
|
+
if (max === undefined || short > max) max = short
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return max
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Converts a short-side tier to [w,h], preserving the project's aspect and
|
|
35
|
+
* orientation. Long side is rounded to the nearest EVEN integer (encoders
|
|
36
|
+
* demand even dims). */
|
|
37
|
+
function tierToResolution(tier: number, project: Project): [number, number] {
|
|
38
|
+
const [pw, ph] = project.settings.resolution
|
|
39
|
+
const shortP = Math.min(pw, ph)
|
|
40
|
+
const longP = Math.max(pw, ph)
|
|
41
|
+
if (shortP === 0) return [tier, tier]
|
|
42
|
+
|
|
43
|
+
const ratio = longP / shortP
|
|
44
|
+
const longSide = Math.round((tier * ratio) / 2) * 2
|
|
45
|
+
return pw <= ph ? [tier, longSide] : [longSide, tier]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** Source-capped export resolution ceiling as [w,h]. Falls back to
|
|
51
|
+
* `project.settings.resolution` verbatim when no video clip carries source
|
|
52
|
+
* dims, or when the source is smaller than the smallest standard tier. */
|
|
53
|
+
export function maxExportResolution(project: Project): [number, number] {
|
|
54
|
+
const maxShort = maxSourceShortSide(project)
|
|
55
|
+
if (maxShort === undefined) return project.settings.resolution
|
|
56
|
+
|
|
57
|
+
const tier = [...STANDARD_RESOLUTION_TIERS].reverse().find(t => t <= maxShort)
|
|
58
|
+
if (tier === undefined) return project.settings.resolution
|
|
59
|
+
|
|
60
|
+
return tierToResolution(tier, project)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Source-capped export fps ceiling. No per-clip source-fps exists in the
|
|
64
|
+
* schema, so this is just the project's own setting (default 30). */
|
|
65
|
+
export function maxExportFps(project: Project): number {
|
|
66
|
+
return project.settings.fps ?? 30
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every resolution tier offerable in the export dialog, as [w,h] preserving
|
|
70
|
+
* project aspect, filtered to the source cap and ordered ascending. Always
|
|
71
|
+
* non-empty — falls back to the project's current resolution when the source
|
|
72
|
+
* is below every standard tier. */
|
|
73
|
+
export function availableResolutionTiers(project: Project): Array<[number, number]> {
|
|
74
|
+
const maxShort = maxSourceShortSide(project)
|
|
75
|
+
const capShort = maxShort ?? Math.min(...project.settings.resolution)
|
|
76
|
+
|
|
77
|
+
const tiers = STANDARD_RESOLUTION_TIERS.filter(t => t <= capShort).map(t => tierToResolution(t, project))
|
|
78
|
+
return tiers.length > 0 ? tiers : [project.settings.resolution]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every fps tier offerable in the export dialog, filtered to the source cap
|
|
82
|
+
* and ordered ascending. Always non-empty. */
|
|
83
|
+
export function availableFpsTiers(project: Project): number[] {
|
|
84
|
+
const cap = maxExportFps(project)
|
|
85
|
+
const tiers = STANDARD_FPS_TIERS.filter(f => f <= cap)
|
|
86
|
+
return tiers.length > 0 ? tiers : [cap]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The tier from `availableResolutionTiers` matching the project's CURRENT
|
|
90
|
+
* `settings.resolution` (by short-side), or the nearest smaller one.
|
|
91
|
+
* Convenience for the export dialog's default selection. */
|
|
92
|
+
export function currentResolutionTier(project: Project): [number, number] | undefined {
|
|
93
|
+
const tiers = availableResolutionTiers(project)
|
|
94
|
+
if (tiers.length === 0) return undefined
|
|
95
|
+
|
|
96
|
+
const currentShort = Math.min(...project.settings.resolution)
|
|
97
|
+
const exact = tiers.find(([w, h]) => Math.min(w, h) === currentShort)
|
|
98
|
+
if (exact) return exact
|
|
99
|
+
|
|
100
|
+
const smaller = [...tiers].reverse().find(([w, h]) => Math.min(w, h) <= currentShort)
|
|
101
|
+
return smaller ?? tiers[0] // current is below every offered tier — nearest is the smallest one
|
|
102
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import type { PlaybackClock } from './playback-clock'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The preview-axis override: "show the frame at THIS time instead of the
|
|
6
|
+
* playhead's", or null for "show the playhead's frame".
|
|
7
|
+
*
|
|
8
|
+
* Separate from the playback clock on purpose. The clock is the truth — it is
|
|
9
|
+
* where playback resumes, what the red playhead draws from, and what the
|
|
10
|
+
* scrubber and transport move. Hover-scrubbing must not touch any of that; it
|
|
11
|
+
* only changes which frame the preview paints, and only while the pointer is
|
|
12
|
+
* over the timeline. Writing hover into the clock instead would drag the red
|
|
13
|
+
* playhead and the scrubber handle around with the mouse, and Space would then
|
|
14
|
+
* start playing from wherever the pointer happened to rest.
|
|
15
|
+
*
|
|
16
|
+
* An external store rather than React state because it is written on every
|
|
17
|
+
* mousemove: only `PreviewPlayer` subscribes, so the editor shell, the timeline
|
|
18
|
+
* and the caption row don't re-render as the pointer travels.
|
|
19
|
+
*/
|
|
20
|
+
export interface HoverScrub {
|
|
21
|
+
get(): number | null
|
|
22
|
+
set(t: number | null): void
|
|
23
|
+
subscribe(cb: () => void): () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createHoverScrub(): HoverScrub {
|
|
27
|
+
let time: number | null = null
|
|
28
|
+
const subs = new Set<() => void>()
|
|
29
|
+
return {
|
|
30
|
+
get: () => time,
|
|
31
|
+
set(t) {
|
|
32
|
+
// The no-op guard matters: `clock.subscribe` clears this store on every
|
|
33
|
+
// playback tick (see VideoEditor), which would otherwise notify ~60
|
|
34
|
+
// times a second forever.
|
|
35
|
+
if (t === time) return
|
|
36
|
+
time = t
|
|
37
|
+
subs.forEach((cb) => cb())
|
|
38
|
+
},
|
|
39
|
+
subscribe(cb) {
|
|
40
|
+
subs.add(cb)
|
|
41
|
+
return () => { subs.delete(cb) }
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Stable snapshot for an absent store, so `useSyncExternalStore` isn't handed
|
|
47
|
+
* a fresh closure per render when a host omits hover-scrubbing entirely. */
|
|
48
|
+
const NO_SUBSCRIBE = () => () => {}
|
|
49
|
+
const NO_TIME = () => null
|
|
50
|
+
|
|
51
|
+
export function useHoverScrubTime(store?: HoverScrub): number | null {
|
|
52
|
+
return useSyncExternalStore(
|
|
53
|
+
store?.subscribe ?? NO_SUBSCRIBE,
|
|
54
|
+
store?.get ?? NO_TIME,
|
|
55
|
+
store?.get ?? NO_TIME,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The playback→editor half of the clock bridge, gated on hover-preview.
|
|
61
|
+
*
|
|
62
|
+
* Showing a hovered frame means SEEKING the source to it, and a seek runs a
|
|
63
|
+
* tick that reports the new position back (`useEnginePlayback`'s `emitTime`,
|
|
64
|
+
* the legacy hook's `onTimeUpdate`). Wired straight to `clock.set`, that makes
|
|
65
|
+
* the red playhead chase the yellow cursor across the timeline — the preview
|
|
66
|
+
* would be honest but the playhead would have silently moved, which is the one
|
|
67
|
+
* thing the preview axis promises it will not do.
|
|
68
|
+
*
|
|
69
|
+
* So while an override stands the picture is display-only and the clock is left
|
|
70
|
+
* alone. `handOverToHover` drops the override the moment playback starts, so
|
|
71
|
+
* playback's own emissions always get through.
|
|
72
|
+
*/
|
|
73
|
+
export function gateTimeSink(clock: PlaybackClock, hover?: HoverScrub): (t: number) => void {
|
|
74
|
+
return (t: number) => {
|
|
75
|
+
if (hover?.get() != null) return
|
|
76
|
+
clock.set(t)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Play from the yellow line. Called when playback starts: the source is already
|
|
82
|
+
* parked on the hovered frame (that is how it got on screen), so playback
|
|
83
|
+
* continues from there — this makes the RED playhead say so too, instead of
|
|
84
|
+
* leaving it stranded where it sat before the hover.
|
|
85
|
+
*
|
|
86
|
+
* Writing the clock is what makes the hand-over safe rather than cosmetic: with
|
|
87
|
+
* the override gone the preview falls back to the clock, and a clock still
|
|
88
|
+
* holding the pre-hover position reads as an external scrub and seeks the
|
|
89
|
+
* engine backwards mid-playback (`useEnginePlayback`'s scrub effect has no
|
|
90
|
+
* transport guard). Setting it to the hovered time keeps mirror and clock
|
|
91
|
+
* agreeing, so that seek never fires.
|
|
92
|
+
*
|
|
93
|
+
* Returns whether a hand-over happened — false when nothing was hovered, which
|
|
94
|
+
* is every ordinary press of play.
|
|
95
|
+
*/
|
|
96
|
+
export function handOverToHover(clock: PlaybackClock, hover?: HoverScrub): boolean {
|
|
97
|
+
const t = hover?.get()
|
|
98
|
+
if (t == null) return false
|
|
99
|
+
hover!.set(null)
|
|
100
|
+
clock.set(t)
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import type { EasingName, Keyframe, KeyframeProp, KeyframeTrack, VisualItem } from '../schema'
|
|
2
|
+
import { geometryAt, normalizeTrack } from '@bycrux/timeline-core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* keyframeOps — the shared, DOM-free keyframe-mutation surface for SP9b.
|
|
6
|
+
*
|
|
7
|
+
* Every export below is a pure function shaped `(item, ...) => VisualItem`:
|
|
8
|
+
* none of them touch React, the DOM, or the app store. Callers (an overlay
|
|
9
|
+
* inspector panel and a canvas timeline keyframe strip, in later phases) are
|
|
10
|
+
* expected to feed the returned item straight into a `sync.mutate` project
|
|
11
|
+
* update. The input `item`, its `keyframes` array, and its point objects are
|
|
12
|
+
* never mutated — every write returns fresh objects/arrays instead.
|
|
13
|
+
*
|
|
14
|
+
* The invariant this module exists to protect: every `KeyframeTrack` it
|
|
15
|
+
* writes has `points` ascending by `t` with no duplicate `t` — the invariant
|
|
16
|
+
* `@bycrux/timeline-core`'s `sampleTrack` assumes and does not itself enforce
|
|
17
|
+
* (see that package's `src/curves.js` module header). Every mutating export
|
|
18
|
+
* below builds its raw (possibly out-of-order, possibly duplicate-`t`) points
|
|
19
|
+
* array and pipes it through `normalizeTrack` before it is ever installed on
|
|
20
|
+
* the returned item — `withTrack`, at the bottom of the "writing" section, is
|
|
21
|
+
* the single place a track is actually written, so that holds by
|
|
22
|
+
* construction rather than by every export remembering to do it.
|
|
23
|
+
*
|
|
24
|
+
* No easing/interpolation math lives here. Curve evaluation stays in
|
|
25
|
+
* `@bycrux/timeline-core` (`sampleTrack`, `geometryAt`) so the preview and
|
|
26
|
+
* the render bake cannot drift from each other or from this module.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* THE single gate on which items support keyframing. Every call site that used
|
|
31
|
+
* to spell `item.type === 'overlay'` inline routes through here instead, so
|
|
32
|
+
* the set of keyframeable kinds is defined in exactly one place.
|
|
33
|
+
*
|
|
34
|
+
* VIDEO, IMAGE AND OVERLAY, since SP9d. It was overlay-only for a real render
|
|
35
|
+
* reason, not a UI preference: the ffmpeg composite emitted ONE static box per
|
|
36
|
+
* segment and had no per-frame hook, while overlays escaped that only because
|
|
37
|
+
* they are captured frame-by-frame in a browser. That changed when
|
|
38
|
+
* `encode-segment.js` learned to compile a curve into a time-varying ffmpeg
|
|
39
|
+
* expression (`animatedGeometry`), so a clip's position, scale and rotation now
|
|
40
|
+
* animate in the export exactly as they do in the preview.
|
|
41
|
+
*
|
|
42
|
+
* Keyframeability is now PER PROPERTY PER KIND, though — a clip can animate
|
|
43
|
+
* position but not opacity — so an item-level yes/no is no longer the whole
|
|
44
|
+
* answer. Use {@link canKeyframeProp} wherever a specific property is in hand;
|
|
45
|
+
* this predicate answers only "can ANY property on this item be keyframed".
|
|
46
|
+
*
|
|
47
|
+
* A type predicate, not a plain `boolean`: call sites used to spell
|
|
48
|
+
* `!item || item.type !== 'overlay'`, which narrowed `item`'s nullability
|
|
49
|
+
* through the guard for free. Returning `item is VisualItem` keeps that
|
|
50
|
+
* narrowing available through this one call instead. It is deliberately
|
|
51
|
+
* `VisualItem`, not some overlay-only subtype — `VisualItem` is a monolithic
|
|
52
|
+
* interface with `type` as a plain field rather than a discriminant, so
|
|
53
|
+
* there is no narrower shape to assert; this is the strongest claim TS can
|
|
54
|
+
* check.
|
|
55
|
+
*/
|
|
56
|
+
export function canKeyframe(item: VisualItem | null | undefined): item is VisualItem {
|
|
57
|
+
return !!item && (item.type === 'overlay' || item.type === 'video' || item.type === 'image')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether ONE property on ONE item can be keyframed.
|
|
62
|
+
*
|
|
63
|
+
* Overlays: everything. Clips (video/image): everything EXCEPT `opacity`.
|
|
64
|
+
*
|
|
65
|
+
* The opacity exclusion is a hard limit of the render, not a scope decision.
|
|
66
|
+
* A clip's transform reaches ffmpeg as a filter expression, and ffmpeg happily
|
|
67
|
+
* evaluates expressions for `overlay`'s x/y, `scale`'s w/h and `rotate`'s
|
|
68
|
+
* angle. Its alpha control does not play along: `colorchannelmixer` declares
|
|
69
|
+
* `aa` as a `<double>`, which accepts a literal number and nothing else — no
|
|
70
|
+
* expression, at any evaluation mode. (The `T` flag ffmpeg prints beside it is
|
|
71
|
+
* `AV_OPT_FLAG_RUNTIME_PARAM`, i.e. settable via `sendcmd`/`zmq`; it is not
|
|
72
|
+
* expression support, and it has been misread as such before.) There is
|
|
73
|
+
* therefore no way to fade a clip through the ffmpeg path at all.
|
|
74
|
+
*
|
|
75
|
+
* Overlays are exempt because they never touch that filter: they are baked
|
|
76
|
+
* frame-by-frame in a browser, where opacity is just another CSS value.
|
|
77
|
+
*
|
|
78
|
+
* Closing this gap needs the per-frame browser bake extended to video — decode
|
|
79
|
+
* every frame of the animated span and composite it the way overlays already
|
|
80
|
+
* are. That was measured at 14-33x the expression path's render time and is
|
|
81
|
+
* explicitly out of scope; see docs/RENDER.md.
|
|
82
|
+
*/
|
|
83
|
+
export function canKeyframeProp(item: VisualItem | null | undefined, prop: KeyframeProp): boolean {
|
|
84
|
+
if (!canKeyframe(item)) return false
|
|
85
|
+
if (item.type === 'overlay') return true
|
|
86
|
+
return prop !== 'opacity'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Reading ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** The track for `prop` on `item`, or `undefined` if the item isn't
|
|
92
|
+
* keyframed on that prop at all. */
|
|
93
|
+
export function trackFor(item: VisualItem, prop: KeyframeProp): KeyframeTrack | undefined {
|
|
94
|
+
return item.keyframes?.find(track => track.prop === prop)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True only when `prop` has a track AND that track has at least one point.
|
|
99
|
+
* An empty track never lingers in a well-formed item (see `withTrack`), but
|
|
100
|
+
* this checks the point count anyway rather than assuming that invariant
|
|
101
|
+
* holds for every possible caller or hand-edited project.json.
|
|
102
|
+
*/
|
|
103
|
+
export function hasKeyframes(item: VisualItem, prop: KeyframeProp): boolean {
|
|
104
|
+
const track = trackFor(item, prop)
|
|
105
|
+
return !!track && track.points.length > 0
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True when `item` has ANY non-empty keyframe track, on any prop. */
|
|
109
|
+
export function isKeyframed(item: VisualItem): boolean {
|
|
110
|
+
return (item.keyframes ?? []).some(track => track.points.length > 0)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether `item` scales UNIFORMLY — i.e. carries no per-axis scale AT ALL,
|
|
115
|
+
* neither a static `scaleX`/`scaleY` scalar nor a keyframe track for either.
|
|
116
|
+
*
|
|
117
|
+
* ABSENCE is the test, deliberately, and not `scaleX === scaleY`: an overlay
|
|
118
|
+
* the operator unlocked on purpose and happens to have left at 120%/120% is
|
|
119
|
+
* authored per-axis, and an equality test would silently re-lock it the moment
|
|
120
|
+
* the two numbers met.
|
|
121
|
+
*/
|
|
122
|
+
export function isUniformScale(item: VisualItem): boolean {
|
|
123
|
+
return (
|
|
124
|
+
item.scaleX === undefined && item.scaleY === undefined &&
|
|
125
|
+
!hasKeyframes(item, 'scaleX') && !hasKeyframes(item, 'scaleY')
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The two orders {@link transformProps} chooses between. Position first,
|
|
130
|
+
* scale, then rotation and opacity — the order the inspector header's
|
|
131
|
+
* all-props actions walk them, kept identical for both shapes. */
|
|
132
|
+
const UNIFORM_TRANSFORM_PROPS: readonly KeyframeProp[] = ['offsetX', 'offsetY', 'scale', 'rotation', 'opacity']
|
|
133
|
+
const PER_AXIS_TRANSFORM_PROPS: readonly KeyframeProp[] = ['offsetX', 'offsetY', 'scaleX', 'scaleY', 'rotation', 'opacity']
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The transform props that are AUTHORITATIVE for `item` — the set that any
|
|
137
|
+
* "do this to EVERY transform prop" action must walk, and the whole reason
|
|
138
|
+
* {@link isUniformScale} exists.
|
|
139
|
+
*
|
|
140
|
+
* Never a flat list of all seven, and never one fixed list of five. The scale
|
|
141
|
+
* props form a fallback chain — `sampleTrack(scaleX) ?? item.scaleX ??
|
|
142
|
+
* <the resolved scale>` (see `geometry.js`'s non-uniform section) — so a
|
|
143
|
+
* per-axis value SHADOWS the uniform one, and getting this set wrong breaks a
|
|
144
|
+
* keyframe-everything action in one of two symmetric ways:
|
|
145
|
+
*
|
|
146
|
+
* - Handing `scaleX`/`scaleY` to a UNIFORM item seeds one-point (i.e.
|
|
147
|
+
* constant) per-axis tracks. Those immediately shadow the `scale` track,
|
|
148
|
+
* and the overlay's uniform zoom silently stops happening — nothing on
|
|
149
|
+
* screen says why, and the damage is invisible until the operator scrubs.
|
|
150
|
+
* - Handing `scale` to a PER-AXIS item writes a prop that `scaleX`/`scaleY`
|
|
151
|
+
* already shadow, so the gesture appears to do nothing at all.
|
|
152
|
+
*
|
|
153
|
+
* Both the inspector's header actions and the canvas timeline's
|
|
154
|
+
* double-click-to-key gesture read this, so the rule is defined once. It used
|
|
155
|
+
* to be a hand-maintained constant in each of them; two copies of a rule whose
|
|
156
|
+
* failure mode is a silent frozen animation is exactly the kind of thing that
|
|
157
|
+
* drifts. Do NOT reintroduce a local copy.
|
|
158
|
+
*
|
|
159
|
+
* The result is ALSO filtered by {@link canKeyframeProp}, which is what keeps a
|
|
160
|
+
* clip's un-animatable `opacity` out of every "do this to every transform prop"
|
|
161
|
+
* action. That matters in both directions and both are easy to get wrong:
|
|
162
|
+
* double-clicking a video would otherwise write an opacity track the renderer
|
|
163
|
+
* silently ignores, and the inspector's header diamond — which lights only when
|
|
164
|
+
* EVERY prop in this list is keyed at the playhead — could then never light on a
|
|
165
|
+
* clip at all, because the one prop it waits for can never be keyed.
|
|
166
|
+
*/
|
|
167
|
+
export function transformProps(item: VisualItem): readonly KeyframeProp[] {
|
|
168
|
+
const base = isUniformScale(item) ? UNIFORM_TRANSFORM_PROPS : PER_AXIS_TRANSFORM_PROPS
|
|
169
|
+
return base.filter(prop => canKeyframeProp(item, prop))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `prop`'s value at item-relative `localT`: the sampled curve when `prop` is
|
|
174
|
+
* keyframed, else the item's static scalar, else the prop's default. This
|
|
175
|
+
* delegates to {@link geometryAt} — the SAME function the preview and the
|
|
176
|
+
* render bake sample from — rather than re-deriving defaults or calling
|
|
177
|
+
* `sampleTrack` directly, so the defaults (scale 1, offsetX/offsetY/rotation
|
|
178
|
+
* 0, opacity 1) live in exactly one place and cannot drift from what
|
|
179
|
+
* actually gets painted. `item.type` is passed through as the `kind` rather
|
|
180
|
+
* than a hardcoded `'overlay'`: `geometryAt`'s `kind` only selects `fit`,
|
|
181
|
+
* which isn't a keyframeable prop, so every one of the five reads is
|
|
182
|
+
* identical either way — but this way the function never lies about what
|
|
183
|
+
* kind of item it's reading.
|
|
184
|
+
*/
|
|
185
|
+
export function valueAt(item: VisualItem, prop: KeyframeProp, localT: number): number {
|
|
186
|
+
return geometryAt(item, item.type, localT)[prop]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Writing ──────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Install `track` as the sole track for `prop` on a NEW item, or remove
|
|
193
|
+
* `prop`'s track entirely when `track` is undefined or empty. This is the
|
|
194
|
+
* single place `item.keyframes` is ever written, so the invariants every
|
|
195
|
+
* mutating export below depends on hold by construction:
|
|
196
|
+
* - removing the last point of a track removes the track;
|
|
197
|
+
* - removing the last track removes `item.keyframes` itself (`undefined`,
|
|
198
|
+
* never a lingering `[]` — downstream code treats "no keyframes" as the
|
|
199
|
+
* static path, and `[]` must behave identically to absent).
|
|
200
|
+
*/
|
|
201
|
+
function withTrack(item: VisualItem, prop: KeyframeProp, track: KeyframeTrack | undefined): VisualItem {
|
|
202
|
+
const existing = item.keyframes ?? []
|
|
203
|
+
const idx = existing.findIndex(t => t.prop === prop)
|
|
204
|
+
|
|
205
|
+
if (!track || track.points.length === 0) {
|
|
206
|
+
if (idx < 0) return item // prop already had no track — no-op
|
|
207
|
+
const others = existing.filter(t => t.prop !== prop)
|
|
208
|
+
if (others.length === 0) {
|
|
209
|
+
const next = { ...item }
|
|
210
|
+
delete next.keyframes
|
|
211
|
+
return next
|
|
212
|
+
}
|
|
213
|
+
return { ...item, keyframes: others }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const next = idx < 0 ? [...existing, track] : existing.map((t, i) => (i === idx ? track : t))
|
|
217
|
+
return { ...item, keyframes: next }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Write `value` into `prop`'s own static scalar field on a new item. Used
|
|
221
|
+
* only by `disableKeyframing`, once keyframing is turned off. An exhaustive
|
|
222
|
+
* switch (no `default`) rather than a computed property, so adding a new
|
|
223
|
+
* `KeyframeProp` without a case here is a compile error, not a silent gap. */
|
|
224
|
+
function withStaticValue(item: VisualItem, prop: KeyframeProp, value: number): VisualItem {
|
|
225
|
+
switch (prop) {
|
|
226
|
+
case 'offsetX': return { ...item, offsetX: value }
|
|
227
|
+
case 'offsetY': return { ...item, offsetY: value }
|
|
228
|
+
case 'scale': return { ...item, scale: value }
|
|
229
|
+
case 'scaleX': return { ...item, scaleX: value }
|
|
230
|
+
case 'scaleY': return { ...item, scaleY: value }
|
|
231
|
+
case 'rotation': return { ...item, rotation: value }
|
|
232
|
+
case 'opacity': return { ...item, opacity: value }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Add or replace the keyframe at `t` on `prop`'s track, creating the track
|
|
238
|
+
* if `item` isn't keyframed on `prop` yet. Replacing an existing point at
|
|
239
|
+
* `t` preserves its `easing` unless a new one is passed. Non-finite `t` or
|
|
240
|
+
* `value` are ignored — `item` is returned unchanged rather than writing a
|
|
241
|
+
* malformed point.
|
|
242
|
+
*/
|
|
243
|
+
export function setKeyframe(
|
|
244
|
+
item: VisualItem,
|
|
245
|
+
prop: KeyframeProp,
|
|
246
|
+
t: number,
|
|
247
|
+
value: number,
|
|
248
|
+
easing?: EasingName,
|
|
249
|
+
): VisualItem {
|
|
250
|
+
if (!Number.isFinite(t) || !Number.isFinite(value)) return item
|
|
251
|
+
|
|
252
|
+
const existing = trackFor(item, prop)
|
|
253
|
+
const existingPoint = existing?.points.find(p => p.t === t)
|
|
254
|
+
const resolvedEasing = easing ?? existingPoint?.easing
|
|
255
|
+
const point: Keyframe = resolvedEasing === undefined ? { t, value } : { t, value, easing: resolvedEasing }
|
|
256
|
+
|
|
257
|
+
// Appended, not spliced in place: normalizeTrack's stable sort + last-wins
|
|
258
|
+
// de-duplication is what actually resolves a collision at `t`, so the new
|
|
259
|
+
// point only has to be LAST in authoring order among any duplicates.
|
|
260
|
+
const rawPoints = existing ? [...existing.points, point] : [point]
|
|
261
|
+
return withTrack(item, prop, normalizeTrack({ prop, points: rawPoints }))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Remove the keyframe at `t` on `prop`'s track. Removing the last point
|
|
266
|
+
* removes the whole track; removing the last track removes `item.keyframes`
|
|
267
|
+
* entirely (see `withTrack`).
|
|
268
|
+
*/
|
|
269
|
+
export function removeKeyframe(item: VisualItem, prop: KeyframeProp, t: number): VisualItem {
|
|
270
|
+
const track = trackFor(item, prop)
|
|
271
|
+
if (!track) return item
|
|
272
|
+
|
|
273
|
+
const points = track.points.filter(p => p.t !== t)
|
|
274
|
+
if (points.length === track.points.length) return item // t wasn't present — no-op
|
|
275
|
+
if (points.length === 0) return withTrack(item, prop, undefined)
|
|
276
|
+
|
|
277
|
+
return withTrack(item, prop, normalizeTrack({ prop, points }))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Remove every keyframe sitting at `t`, across all props — the whole diamond
|
|
282
|
+
* the operator sees, since one diamond on the strip is the UNION of every prop
|
|
283
|
+
* keyed at that instant (`keyframeUnionTimes`).
|
|
284
|
+
*
|
|
285
|
+
* The last-point branch is the reason this exists rather than callers looping
|
|
286
|
+
* `removeKeyframe`. `removeKeyframe` on a track's ONLY point drops the track
|
|
287
|
+
* without writing the sampled value into the item's static scalar, so the
|
|
288
|
+
* overlay snaps back to whatever stale value was sitting there from before
|
|
289
|
+
* keyframing was switched on. `disableKeyframing` samples the curve FIRST and
|
|
290
|
+
* writes it, so nothing moves. Every removal path must take that branch, or
|
|
291
|
+
* the two disagree — which is exactly what happened between the canvas
|
|
292
|
+
* right-click menu and the properties panel before this helper existed.
|
|
293
|
+
*
|
|
294
|
+
* Returns the SAME item when no prop has a point at `t`, so callers can use
|
|
295
|
+
* reference equality to skip a no-op commit.
|
|
296
|
+
*/
|
|
297
|
+
export function removeKeyframesAt(item: VisualItem, t: number): VisualItem {
|
|
298
|
+
if (!Number.isFinite(t)) return item
|
|
299
|
+
|
|
300
|
+
const props = (item.keyframes ?? [])
|
|
301
|
+
.filter(track => track.points.some(p => p.t === t))
|
|
302
|
+
.map(track => track.prop)
|
|
303
|
+
if (props.length === 0) return item
|
|
304
|
+
|
|
305
|
+
let next = item
|
|
306
|
+
for (const prop of props) {
|
|
307
|
+
const points = trackFor(next, prop)?.points ?? []
|
|
308
|
+
next = points.length > 1
|
|
309
|
+
? removeKeyframe(next, prop, t)
|
|
310
|
+
: disableKeyframing(next, prop, t)
|
|
311
|
+
}
|
|
312
|
+
return next
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Retime the keyframe at `fromT` to `toT`, preserving its value and easing.
|
|
317
|
+
* If `toT` collides with an existing keyframe, the MOVED one wins: it is
|
|
318
|
+
* appended after the rest of the points before normalizing, and
|
|
319
|
+
* `normalizeTrack`'s last-wins de-duplication (stable sort, so the later
|
|
320
|
+
* authoring-order entry survives a tie at the same `t`) always keeps the
|
|
321
|
+
* moved point in that case. Non-finite `fromT`/`toT` are ignored.
|
|
322
|
+
*/
|
|
323
|
+
export function moveKeyframe(item: VisualItem, prop: KeyframeProp, fromT: number, toT: number): VisualItem {
|
|
324
|
+
if (!Number.isFinite(fromT) || !Number.isFinite(toT)) return item
|
|
325
|
+
|
|
326
|
+
const track = trackFor(item, prop)
|
|
327
|
+
const point = track?.points.find(p => p.t === fromT)
|
|
328
|
+
if (!track || !point) return item
|
|
329
|
+
|
|
330
|
+
const moved: Keyframe = { ...point, t: toT }
|
|
331
|
+
const rest = track.points.filter(p => p.t !== fromT)
|
|
332
|
+
return withTrack(item, prop, normalizeTrack({ prop, points: [...rest, moved] }))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Set the OUTGOING easing (see `Keyframe.easing`'s doc comment) on the
|
|
336
|
+
* keyframe at `t`. No-op if `prop` has no track or no point at `t`. */
|
|
337
|
+
export function setKeyframeEasing(item: VisualItem, prop: KeyframeProp, t: number, easing: EasingName): VisualItem {
|
|
338
|
+
const track = trackFor(item, prop)
|
|
339
|
+
const point = track?.points.find(p => p.t === t)
|
|
340
|
+
if (!track || !point) return item
|
|
341
|
+
|
|
342
|
+
const points = track.points.map(p => (p.t === t ? { ...p, easing } : p))
|
|
343
|
+
return withTrack(item, prop, normalizeTrack({ prop, points }))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Turn keyframing ON for `prop`: seed a single keyframe at `atT` whose value
|
|
348
|
+
* is the item's CURRENT value for that prop (via {@link valueAt}), so
|
|
349
|
+
* switching keyframing on never moves the overlay.
|
|
350
|
+
*
|
|
351
|
+
* NO-OP, by construction, when `prop` already has keyframes
|
|
352
|
+
* (`hasKeyframes(item, prop)`): "turn this on" applied to something already
|
|
353
|
+
* on must never destroy the operator's existing animation. A caller with a
|
|
354
|
+
* genuinely destructive intent — discard the current track and start over —
|
|
355
|
+
* expresses that explicitly as `disableKeyframing` followed by
|
|
356
|
+
* `enableKeyframing`, two calls, not a single one that quietly does both. Do
|
|
357
|
+
* NOT "simplify" this back into an unconditional reset: a diamond-toggle UI,
|
|
358
|
+
* a defensive re-render, or a future "enable all props" action can all call
|
|
359
|
+
* this on an already-keyframed prop, and silently replacing a multi-point
|
|
360
|
+
* curve with one seeded point is invisible data loss until the operator
|
|
361
|
+
* scrubs. Non-finite `atT` is also ignored.
|
|
362
|
+
*/
|
|
363
|
+
export function enableKeyframing(item: VisualItem, prop: KeyframeProp, atT: number): VisualItem {
|
|
364
|
+
if (!Number.isFinite(atT)) return item
|
|
365
|
+
if (hasKeyframes(item, prop)) return item
|
|
366
|
+
|
|
367
|
+
const value = valueAt(item, prop, atT)
|
|
368
|
+
return withTrack(item, prop, normalizeTrack({ prop, points: [{ t: atT, value }] }))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Turn keyframing OFF for `prop`: remove its track entirely and write the
|
|
373
|
+
* value the curve held at `atT` into the item's static scalar, so the
|
|
374
|
+
* overlay does not jump the instant keyframing is switched off (the
|
|
375
|
+
* CapCut-style behaviour this is modelled on). The value is read via
|
|
376
|
+
* {@link valueAt} BEFORE the track is removed — `valueAt` needs the track
|
|
377
|
+
* still in place to sample it. Non-finite `atT` is ignored.
|
|
378
|
+
*/
|
|
379
|
+
export function disableKeyframing(item: VisualItem, prop: KeyframeProp, atT: number): VisualItem {
|
|
380
|
+
if (!Number.isFinite(atT)) return item
|
|
381
|
+
|
|
382
|
+
const value = valueAt(item, prop, atT)
|
|
383
|
+
return withStaticValue(withTrack(item, prop, undefined), prop, value)
|
|
384
|
+
}
|