This commit is contained in:
th committed 2026-04-20 00:07:08 +02:00
1 parent 6b6157d786
commit 3ef5fb5a2d
19 files changed
+1244 -94

No files matched your search

+231 -29
View File
@@ -7,8 +7,10 @@ import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
const store = useSceneStore()
const { selectedKfs } = useKfSelection()
const container = ref<HTMLDivElement | null>(null)
const containerSize = ref({ width: 0, height: 0 })
@@ -135,6 +137,19 @@ type StackItem =
| { kind: 'layer'; item: RenderedLayer }
| { kind: 'clip'; item: RenderedClipLayer }
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
const live = liveLayerPose.value
if (!live || live.layerId !== layerId) return props
return {
...props,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
}
}
const stackItems = computed<StackItem[]>(() => {
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
if (store.activeClipId) {
@@ -145,7 +160,11 @@ const stackItems = computed<StackItem[]>(() => {
if (!asset) continue
out.push({
kind: 'layer',
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
}
return out
@@ -173,7 +192,11 @@ const stackItems = computed<StackItem[]>(() => {
if (!asset) continue
out.push({
kind: 'layer',
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
continue
}
@@ -208,6 +231,23 @@ const stackItems = computed<StackItem[]>(() => {
return out
})
function shadowCfg(props: AnimatableProps): Record<string, unknown> {
const opacity = props.shadowOpacity ?? 0
if (opacity <= 0) return {}
const blur = props.shadowBlur ?? 0
const distance = props.shadowDistance ?? 0
if (blur <= 0 && distance <= 0) return {}
const angle = props.shadowAngle ?? 90
const rad = (angle * Math.PI) / 180
return {
shadowColor: props.shadowColor ?? '#000000',
shadowBlur: blur,
shadowOpacity: opacity,
shadowOffsetX: Math.cos(rad) * distance,
shadowOffsetY: Math.sin(rad) * distance,
}
}
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
return {
id: layer.id,
@@ -219,18 +259,60 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
opacity: props.opacity,
visible: layer.visible,
draggable: true,
...shadowCfg(props),
}
}
type KonvaCtx = {
beginPath: () => void
closePath: () => void
rect: (x: number, y: number, w: number, h: number) => void
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
fillStrokeShape: (shape: unknown) => void
save: () => void
restore: () => void
setAttr: (attr: string, value: unknown) => void
}
type KonvaShape = { width(): number; height(): number; radius(): number; attrs: Record<string, unknown> }
function makeRectSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.rect(0, 0, shape.width(), shape.height())
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function makeCircleSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.arc(0, 0, shape.radius(), 0, Math.PI * 2, false)
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
return {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
fill: props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
return cfg
}
function circleConfig(
@@ -238,13 +320,17 @@ function circleConfig(
asset: Extract<Asset, { kind: 'circle' }>,
props: AnimatableProps,
) {
return {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
return cfg
}
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
@@ -287,30 +373,39 @@ function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps
visible: layer.visible,
draggable: false,
listening: false,
...shadowCfg(props),
}
}
function clipRectConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'rect' }>
return {
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
width: asset.width,
height: asset.height,
offsetX: item.layer.offsetX ?? asset.width / 2,
offsetY: item.layer.offsetY ?? asset.height / 2,
fill: item.props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
return cfg
}
function clipCircleConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'circle' }>
return {
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
radius: asset.radius,
offsetX: item.layer.offsetX ?? 0,
offsetY: item.layer.offsetY ?? 0,
fill: item.props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
return cfg
}
function clipTextConfig(item: RenderedClipLayer) {
@@ -344,6 +439,17 @@ interface PivotInfo {
sampled: AnimatableProps
}
interface LiveLayerPose {
layerId: string
x: number
y: number
scaleX: number
scaleY: number
rotation: number
}
const liveLayerPose = ref<LiveLayerPose | null>(null)
const pivotInfo = computed<PivotInfo | null>(() => {
const id = store.selectedLayerId
if (!id) return null
@@ -351,7 +457,22 @@ const pivotInfo = computed<PivotInfo | null>(() => {
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset) return null
return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) }
const sampled = sampleLayer(layer, store.playhead)
const live = liveLayerPose.value
if (live && live.layerId === id) {
return {
layerId: layer.id,
sampled: {
...sampled,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
},
}
}
return { layerId: layer.id, sampled }
})
const pivotHandleConfig = computed(() => {
@@ -441,6 +562,31 @@ watch(
{ immediate: true },
)
function updateLivePose(node: Konva.Node, layerId: string) {
liveLayerPose.value = {
layerId,
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
}
}
function handleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
function handleTransform(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
const target = e.target
const tr = transformerRef.value?.getNode()
@@ -469,11 +615,21 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
store.upsertKeyframe(id, store.playhead, {
x: node.x(),
y: node.y(),
})
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
const newX = node.x()
const newY = node.y()
if (selectedKfs.value.length > 0) {
const samp = sampleLayer(layer, store.playhead)
const dx = newX - samp.x
const dy = newY - samp.y
if (dx !== 0 || dy !== 0) {
store.shiftKeyframesPosition(selectedKfs.value, dx, dy)
}
} else {
store.upsertKeyframe(id, store.playhead, { x: newX, y: newY })
}
liveLayerPose.value = null
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
@@ -487,13 +643,24 @@ function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
scaleY: node.scaleY(),
rotation: node.rotation(),
})
liveLayerPose.value = null
}
const ROT_ARM_PX = 44
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
const rotationHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
if (rotationHandlePos.value) {
return {
x: rotationHandlePos.value.x,
y: rotationHandlePos.value.y,
draggable: true,
name: 'rotation-handle',
}
}
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
return {
@@ -507,15 +674,19 @@ const rotationHandleConfig = computed(() => {
const rotationArmConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
let endX: number
let endY: number
if (rotationHandlePos.value) {
endX = rotationHandlePos.value.x
endY = rotationHandlePos.value.y
} else {
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
endX = info.sampled.x + arm * Math.sin(rad)
endY = info.sampled.y - arm * Math.cos(rad)
}
return {
points: [
info.sampled.x,
info.sampled.y,
info.sampled.x + arm * Math.sin(rad),
info.sampled.y - arm * Math.cos(rad),
],
points: [info.sampled.x, info.sampled.y, endX, endY],
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
@@ -537,11 +708,33 @@ function onRotationHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const dx = node.x() - info.sampled.x
const dy = node.y() - info.sampled.y
if (dx === 0 && dy === 0) return
const rad = Math.atan2(dx, -dy)
let deg = (rad * 180) / Math.PI
if (deg > 180) deg -= 360
if (deg < -180) deg += 360
store.upsertKeyframe(info.layerId, store.playhead, { rotation: deg })
const raw = (Math.atan2(dx, -dy) * 180) / Math.PI
// Snap to the 360°-equivalent nearest to the previous rotation so small
// motions don't flip a full turn across the ±180° boundary.
const prev = liveLayerPose.value?.rotation ?? info.sampled.rotation
let deg = raw
while (deg - prev > 180) deg -= 360
while (deg - prev < -180) deg += 360
const base = liveLayerPose.value ?? {
layerId: info.layerId,
x: info.sampled.x,
y: info.sampled.y,
scaleX: info.sampled.scaleX,
scaleY: info.sampled.scaleY,
rotation: info.sampled.rotation,
}
liveLayerPose.value = { ...base, layerId: info.layerId, rotation: deg }
rotationHandlePos.value = { x: node.x(), y: node.y() }
}
function onRotationHandleDragEnd() {
const info = pivotInfo.value
const live = liveLayerPose.value
if (info && live && live.layerId === info.layerId) {
store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation })
}
rotationHandlePos.value = null
liveLayerPose.value = null
}
let ro: ResizeObserver | null = null
@@ -699,25 +892,33 @@ onBeforeUnmount(() => {
<v-rect
v-if="entry.item.asset.kind === 'rect'"
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
</template>
@@ -755,6 +956,7 @@ onBeforeUnmount(() => {
v-if="rotationHandleConfig"
:config="rotationHandleConfig"
@dragmove="onRotationHandleDragMove"
@dragend="onRotationHandleDragEnd"
>
<v-circle :config="rotationKnobConfig" />
</v-group>