1.2.11: motion-path overlay (toggle in stage view-options) + type-check cleanup
Motion-path overlay
-------------------
A toggleable always-on visualization of the selected layer's
position-keyframe trajectory. Pairs with the existing kf-selection-
driven path that only appears when a keyframe is actively selected.
- src/composables/useShowMotionPaths.ts: shared ref + localStorage
('animatr:showMotionPaths'), default off.
- CanvasStage.vue: new motionPathOverlay computed builds dashed
segments + a dot per keyframe for store.selectedLayerId, but only
when the toggle is on AND no kf is explicitly selected (the kf
view wins — it's richer with drag handles). Color: lilac
(#a47bff) so it doesn't collide with the blue kf-selection path.
- Click a dot → seek the playhead to that keyframe's time. Cheap
navigation aid for layers with many keyframes.
- Toggle button placed in the stage's existing zoom/view-controls
toolbar (alongside crop / mask preview / centre crosshair / stage
preview), not the app topbar — that's where the canvas-scoped
view modifiers already live.
Tests (4 new under "motion-path overlay — toggle + selected-layer rendering"):
- showMotionPaths persists to localStorage.
- Toggle on with two position kfs renders the overlay; toggling
off returns to a clean canvas.
- kf-selection path takes precedence over the toggle (mount smoke
with both paths active simultaneously).
- The motion-paths button in .zoom-controls flips the ref.
Type-check cleanup
------------------
The 7 pre-existing TS errors that piled up over the last few patch
bumps. None affected runtime, but vue-tsc was complaining:
- src/lib/clipBounds.ts: assetFootprint read width/height directly
off ButtonAsset, which actually has canvas.width/canvas.height.
Split the branch and added the same handling for SvgAsset.
- src/lib/render.ts: instancesByTrack was typed via
`typeof scene.clipInstances` (which is optional) → values were
ClipInstance[] | undefined. Wrapped in NonNullable.
- src/lib/ruwFile.ts: BackgroundVideo has no `name` field. Synthesised
one from page.id so the extracted file gets a readable filename
(`scene-bg-${page.id}`).
- src/devtools/tests.ts (2 sites): polygon test fixtures missed the
required `fill` field — added `fill: '#0f0'`.
- src/devtools/tests.ts (1 site): TS 5 widens
`Uint8Array<ArrayBufferLike>` past BlobPart's union; cast to
unknown as BlobPart at the new File call.
`npm run type-check` is now clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9e25cf155a
commit
a2fdd1684a
10 files changed
+278
-6
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
PhCursorClick,
|
||||
PhFrameCorners,
|
||||
PhMaskHappy,
|
||||
PhPath,
|
||||
PhPencilSimple,
|
||||
PhPolygon,
|
||||
PhSlidersHorizontal,
|
||||
@@ -49,6 +50,7 @@ import { t } from '@/i18n'
|
||||
import { useDeselectAll } from '@/composables/useDeselectAll'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
import { showLocked } from '@/composables/useShowLocked'
|
||||
import { showMotionPaths } from '@/composables/useShowMotionPaths'
|
||||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||||
import { CLIP_FALLBACK_BOUNDS, computeClipBounds, type Rect } from '@/lib/clipBounds'
|
||||
|
||||
@@ -2662,6 +2664,89 @@ const animationPath = computed<PathOverlay | null>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Motion-path overlay for the currently-selected layer (toggle-
|
||||
* driven, off by default). Renders the layer's position-keyframe
|
||||
* path even when no keyframe is explicitly selected — handy for
|
||||
* visualizing the trajectory while tweaking other props. Skipped
|
||||
* when `animationPath` is already producing one (the kf-selection
|
||||
* view wins, since it offers richer interaction). */
|
||||
interface MotionPathOverlay {
|
||||
layerId: string
|
||||
segments: { points: number[] }[]
|
||||
dots: { keyframeId: string; time: number; x: number; y: number }[]
|
||||
}
|
||||
|
||||
const motionPathOverlay = computed<MotionPathOverlay | null>(() => {
|
||||
if (!showMotionPaths.value) return null
|
||||
if (animationPath.value) return null
|
||||
const id = store.selectedLayerId
|
||||
if (!id) return null
|
||||
const layer = store.activeLayers.find((l) => l.id === id)
|
||||
if (!layer) return null
|
||||
const arr = layer.separated ? layer.positionKeyframes : layer.keyframes
|
||||
if (!arr || arr.length < 2) return null
|
||||
const sorted = [...arr]
|
||||
.filter((k) => k.kind !== 'end')
|
||||
.sort((a, b) => a.time - b.time)
|
||||
if (sorted.length < 2) return null
|
||||
const fps = store.scene.fps || 60
|
||||
const segments: { points: number[] }[] = []
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const flat = sampleSegment(layer, sorted[i].time, sorted[i + 1].time, fps)
|
||||
if (flat.length >= 4) segments.push({ points: flat })
|
||||
}
|
||||
if (!segments.length) return null
|
||||
const dots = sorted.map((k) => {
|
||||
const s = sampleLayer(layer, k.time)
|
||||
return { keyframeId: k.id, time: k.time, x: s.x, y: s.y }
|
||||
})
|
||||
return { layerId: layer.id, segments, dots }
|
||||
})
|
||||
|
||||
const motionPathLines = computed(() => {
|
||||
const path = motionPathOverlay.value
|
||||
if (!path) return []
|
||||
return path.segments.map((seg, i) => ({
|
||||
key: `mp-${i}`,
|
||||
config: {
|
||||
points: seg.points,
|
||||
stroke: '#a47bff',
|
||||
strokeWidth: 1.25 / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
listening: false,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
opacity: 0.65,
|
||||
dash: [5 / zoom.value, 4 / zoom.value],
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
const motionPathDots = computed(() => {
|
||||
const path = motionPathOverlay.value
|
||||
if (!path) return []
|
||||
return path.dots.map((d) => ({
|
||||
key: `mp-dot-${d.keyframeId}`,
|
||||
config: {
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
radius: 4 / zoom.value,
|
||||
fill: '#a47bff',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: 1.25 / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
name: 'motion-path-dot',
|
||||
},
|
||||
time: d.time,
|
||||
}))
|
||||
})
|
||||
|
||||
function onMotionPathDotClick(time: number) {
|
||||
// Click a dot → seek the playhead to that keyframe's time. Cheap
|
||||
// navigation aid for layers with many keyframes.
|
||||
store.setPlayhead(time)
|
||||
}
|
||||
|
||||
const animationGhostSegments = computed(() => {
|
||||
const path = animationPath.value
|
||||
if (!path) return []
|
||||
@@ -2890,6 +2975,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
|
||||
if (target.name?.() === 'tangent-handle') return
|
||||
if (target.name?.() === 'path-frame-dot') return
|
||||
if (target.name?.() === 'motion-path-dot') return
|
||||
if (vertexMenu.value) vertexMenu.value = null
|
||||
if (target === target.getStage()) {
|
||||
deselectAll()
|
||||
@@ -4563,6 +4649,17 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
|
||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
|
||||
/>
|
||||
<v-line
|
||||
v-for="line in motionPathLines"
|
||||
:key="line.key"
|
||||
:config="line.config"
|
||||
/>
|
||||
<v-circle
|
||||
v-for="d in motionPathDots"
|
||||
:key="d.key"
|
||||
:config="d.config"
|
||||
@mousedown.stop="onMotionPathDotClick(d.time)"
|
||||
/>
|
||||
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
|
||||
<v-circle
|
||||
v-for="d in drawingDotsConfig"
|
||||
@@ -4661,6 +4758,14 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
>
|
||||
<PhCursorClick :size="13" />
|
||||
</button>
|
||||
<button
|
||||
class="zc-btn motion-paths-btn"
|
||||
:class="{ active: showMotionPaths }"
|
||||
v-tooltip="{ label: t('canvas.motionPaths.label'), help: t('canvas.motionPaths.help') }"
|
||||
@click="showMotionPaths = !showMotionPaths"
|
||||
>
|
||||
<PhPath :size="13" :weight="showMotionPaths ? 'fill' : 'regular'" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dims">{{ effectiveCanvas.width }} × {{ effectiveCanvas.height }}</div>
|
||||
|
||||
Reference in new issue
Block a user