This commit is contained in:
th committed 2026-04-20 01:05:27 +02:00
1 parent 43933f93d1
commit 03b1147a93
10 files changed
+804 -80

No files matched your search

+92 -1
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type Konva from 'konva'
import { PhCornersIn, PhCrop } from '@phosphor-icons/vue'
import { PhCornersIn, PhCrop, PhMaskHappy, PhPencilSimple } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
@@ -22,6 +23,9 @@ const zoom = ref(1)
const viewX = ref(0)
const viewY = ref(0)
const cropObjects = ref(false)
const maskPreview = ref(false)
const previewCanvasEl = ref<HTMLCanvasElement | null>(null)
const previewImages = ref<Record<string, HTMLImageElement>>({})
let didInitialFit = false
const ZOOM_MIN = 0.05
@@ -251,6 +255,15 @@ function shadowCfg(props: AnimatableProps): Record<string, unknown> {
}
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
const maskGhost = layer.isMask
? {
opacity: props.opacity * 0.45,
stroke: layer.maskMode === 'negative' ? '#ff6b6b' : '#6ea8ff',
strokeWidth: 1.5,
dash: [6, 4],
strokeScaleEnabled: false,
}
: {}
return {
id: layer.id,
x: props.x,
@@ -262,6 +275,7 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
visible: layer.visible,
draggable: true,
...shadowCfg(props),
...maskGhost,
}
}
@@ -825,6 +839,62 @@ function onContainerDrop(e: DragEvent) {
}
}
function resizePreviewCanvas() {
const canvas = previewCanvasEl.value
if (!canvas) return
const dpr = window.devicePixelRatio || 1
const cw = containerSize.value.width
const ch = containerSize.value.height
canvas.width = Math.max(1, Math.round(cw * dpr))
canvas.height = Math.max(1, Math.round(ch * dpr))
canvas.style.width = `${cw}px`
canvas.style.height = `${ch}px`
}
function drawPreview() {
const canvas = previewCanvasEl.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const cw = containerSize.value.width
const ch = containerSize.value.height
if (!cw || !ch) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, cw, ch)
ctx.save()
ctx.translate(viewX.value, viewY.value)
ctx.scale(zoom.value, zoom.value)
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value)
ctx.restore()
}
watch(
[
() => store.playhead,
() => store.scene,
() => containerSize.value,
zoom,
viewX,
viewY,
],
async () => {
if (!maskPreview.value) return
await nextTick()
resizePreviewCanvas()
drawPreview()
},
{ deep: true },
)
watch(maskPreview, async (on) => {
if (!on) return
previewImages.value = await loadSceneImages(store.scene)
await nextTick()
resizePreviewCanvas()
drawPreview()
})
function zoomBy(factor: number) {
const cw = containerSize.value.width
const ch = containerSize.value.height
@@ -966,6 +1036,11 @@ onBeforeUnmount(() => {
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
</v-stage>
<canvas
v-show="maskPreview"
ref="previewCanvasEl"
class="mask-preview-canvas"
></canvas>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
@@ -987,6 +1062,15 @@ onBeforeUnmount(() => {
>
<PhCrop :size="13" />
</button>
<button
class="zc-btn"
:class="{ active: maskPreview }"
:title="maskPreview ? t('canvas.maskPreviewOff') : t('canvas.maskPreviewOn')"
@click="maskPreview = !maskPreview"
>
<PhMaskHappy v-if="!maskPreview" :size="13" />
<PhPencilSimple v-else :size="13" />
</button>
</div>
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
@@ -1001,6 +1085,13 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.mask-preview-canvas {
position: absolute;
inset: 0;
pointer-events: none;
background: var(--bg);
}
.dims {
position: absolute;
bottom: 10px;
+107
View File
@@ -39,6 +39,17 @@ const blurAsset = computed(() => {
return a && (a.kind === 'rect' || a.kind === 'circle') ? a : null
})
const canBeMask = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
})
const maskInGroup = computed(() => {
if (!selected.value) return false
const id = selected.value.id
return (store.scene.groups ?? []).some((g) => g.childIds.includes(id))
})
const blurValue = computed<number>({
get: () => {
const a = blurAsset.value
@@ -255,6 +266,43 @@ function removeKeyframeHere() {
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
</label>
<section v-if="canBeMask" class="mask-group">
<h4>{{ t('properties.mask') }}</h4>
<label class="mask-toggle">
<input
type="checkbox"
:checked="!!selected.isMask"
@change="store.setLayerMask(selected.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ t('properties.maskUse') }}</span>
</label>
<div v-if="selected.isMask" class="mask-mode-row">
<label class="mask-radio">
<input
type="radio"
:checked="(selected.maskMode ?? 'positive') === 'positive'"
@change="store.setLayerMaskMode(selected.id, 'positive')"
/>
<span>{{ t('properties.maskPositive') }}</span>
</label>
<label class="mask-radio">
<input
type="radio"
:checked="selected.maskMode === 'negative'"
@change="store.setLayerMaskMode(selected.id, 'negative')"
/>
<span>{{ t('properties.maskNegative') }}</span>
</label>
</div>
<p v-if="selected.isMask && maskInGroup" class="status-active">
{{ t('properties.maskActive') }}
</p>
<p v-if="selected.isMask && !maskInGroup" class="status-warn">
{{ t('properties.maskNeedsGroup') }}
</p>
<p v-if="selected.isMask" class="hint">{{ t('properties.maskHint') }}</p>
</section>
<button class="danger-btn" @click="store.removeLayer(selected.id)">
<PhTrash :size="13" />
{{ t('properties.deleteLayer') }}
@@ -332,6 +380,65 @@ input {
font-weight: 500;
}
.mask-group {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.mask-group h4 {
margin: 0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
font-weight: 500;
}
.mask-toggle,
.mask-radio {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text);
text-transform: none;
letter-spacing: 0;
cursor: pointer;
flex: 0 0 auto;
}
.mask-mode-row {
display: flex;
gap: 16px;
}
.mask-toggle input,
.mask-radio input {
width: auto;
margin: 0;
}
.status-active,
.status-warn {
margin: 0;
font-size: 11px;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.status-active {
color: #6ea8ff;
background: color-mix(in srgb, #6ea8ff 14%, transparent);
}
.status-warn {
color: #ffb84d;
background: color-mix(in srgb, #ffb84d 16%, transparent);
}
.kf-row {
display: flex;
align-items: center;
+180 -20
View File
@@ -12,6 +12,7 @@ import {
PhLock,
PhLockOpen,
PhMagnet,
PhMaskHappy,
PhPauseCircle,
PhTrash,
} from '@phosphor-icons/vue'
@@ -41,6 +42,8 @@ interface TimelineLayerTrack {
isParent: boolean
orderIndex: number
inGroup: boolean
parentGroupId?: string
groupChildIndex?: number
}
interface TimelineClipTrack {
@@ -49,6 +52,8 @@ interface TimelineClipTrack {
name: string
orderIndex: number
inGroup: boolean
parentGroupId?: string
groupChildIndex?: number
}
interface TimelineGroupTrack {
@@ -240,6 +245,30 @@ function pxFromTime(t: number) {
return t * effectivePxPerSec.value
}
function groupKeyframeTimes(groupId: string): number[] {
const group = groupById.value.get(groupId)
if (!group) return []
const times: number[] = []
const layerById = new Map<string, Layer>()
for (const l of store.scene.layers) layerById.set(l.id, l)
for (const childId of group.childIds) {
const layer = layerById.get(childId)
if (layer) {
for (const k of layer.keyframes) times.push(k.time)
for (const k of layer.positionKeyframes ?? []) times.push(k.time)
for (const k of layer.rotationKeyframes ?? []) times.push(k.time)
continue
}
const ct = clipTrackById.value.get(childId)
if (ct) {
for (const ci of clipInstancesByTrack.value[ct.id] ?? []) {
times.push(ci.startTime)
}
}
}
return times
}
function formatTick(t: number) {
if (displayMode.value === 'frames') {
return `${Math.round(t * fps.value)}`
@@ -340,13 +369,21 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
store.removeKeyframe(layerId, keyframeId)
}
function emitLayerTrack(layer: Layer, orderIndex: number, inGroup: boolean, out: TimelineTrack[]) {
function emitLayerTrack(
layer: Layer,
orderIndex: number,
inGroup: boolean,
out: TimelineTrack[],
parentGroupId?: string,
groupChildIndex?: number,
) {
const base = { type: 'layer' as const, layerId: layer.id, layer, orderIndex, inGroup, parentGroupId, groupChildIndex }
if (!layer.separated) {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
out.push({ ...base, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true })
} else {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex, inGroup })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex, inGroup })
out.push({ ...base, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true })
out.push({ ...base, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false })
out.push({ ...base, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false })
}
}
@@ -384,17 +421,25 @@ const tracks = computed<TimelineTrack[]>(() => {
childCount: grp.childIds.length,
})
if (!grp.collapsed) {
for (const cid of grp.childIds) {
grp.childIds.forEach((cid, childIndex) => {
const cl = layerById.get(cid)
if (cl) {
emitLayerTrack(cl, -1, true, out)
continue
emitLayerTrack(cl, -1, true, out, grp.id, childIndex)
return
}
const cct = clipTrackById.value.get(cid)
if (cct) {
out.push({ type: 'clip', clipTrackId: cct.id, name: cct.name ?? t('timeline.clipTrackName'), orderIndex: -1, inGroup: true })
out.push({
type: 'clip',
clipTrackId: cct.id,
name: cct.name ?? t('timeline.clipTrackName'),
orderIndex: -1,
inGroup: true,
parentGroupId: grp.id,
groupChildIndex: childIndex,
})
}
}
})
}
}
})
@@ -412,16 +457,44 @@ function isTrackHead(track: TimelineTrack): boolean {
return true
}
function trackChildIndex(track: TimelineTrack): number | null {
if (track.type === 'layer' || track.type === 'clip') {
return track.groupChildIndex ?? null
}
return null
}
function trackParentGroupId(track: TimelineTrack): string | null {
if (track.type === 'layer' || track.type === 'clip') {
return track.parentGroupId ?? null
}
return null
}
function isDropBefore(track: TimelineTrack): boolean {
if (dropIndex.value === null) return false
if (!isTrackHead(track)) return false
if (dragTrackId.value === trackDndId(track)) return false
if (dropWithinGroupId.value) {
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
return dropIndex.value === trackChildIndex(track)
}
if (track.type !== 'group' && track.inGroup) return false
return dropIndex.value === track.orderIndex
}
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
if (dropIndex.value === null) return false
if (dragTrackId.value === trackDndId(track)) return false
if (dropWithinGroupId.value) {
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
const ci = trackChildIndex(track)
if (ci === null) return false
if (dropIndex.value !== ci + 1) return false
if (nextTrack && trackChildIndex(nextTrack) === ci) return false
return true
}
if (track.type !== 'group' && track.inGroup) return false
if (dropIndex.value !== track.orderIndex + 1) return false
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
return true
@@ -727,6 +800,7 @@ const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
const dragTrackId = ref<string | null>(null)
const dropIndex = ref<number | null>(null)
const dropWithinGroupId = ref<string | null>(null)
function onTrackDragStart(e: DragEvent, trackId: string) {
if (!e.dataTransfer) return
@@ -748,6 +822,45 @@ function onTrackDragOver(e: DragEvent, orderIndex: number) {
const rect = el.getBoundingClientRect()
const midY = rect.top + rect.height / 2
dropIndex.value = e.clientY < midY ? orderIndex : orderIndex + 1
dropWithinGroupId.value = null
}
function onGroupChildDragOver(e: DragEvent, groupId: string, childIndex: number) {
if (!dragTrackId.value) return
if (dragTrackId.value === groupId) return
// Groups themselves cannot be nested inside another group
if (groupById.value.has(dragTrackId.value)) return
if (!e.dataTransfer) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const midY = rect.top + rect.height / 2
dropIndex.value = e.clientY < midY ? childIndex : childIndex + 1
dropWithinGroupId.value = groupId
dropGroupId.value = null
}
function onGroupChildDrop(e: DragEvent) {
e.preventDefault()
const srcId = dragTrackId.value
const target = dropIndex.value
const groupId = dropWithinGroupId.value
dragTrackId.value = null
dropIndex.value = null
dropWithinGroupId.value = null
dropGroupId.value = null
if (!srcId || target === null || !groupId) return
if (srcId === groupId) return
if (groupById.value.has(srcId)) return
const group = groupById.value.get(groupId)
if (!group) return
const from = group.childIds.indexOf(srcId)
let to = target
// If source is already inside this group and before the target index,
// removal will shift subsequent positions down by one.
if (from >= 0 && from < to) to -= 1
store.moveTrackIntoGroup(srcId, groupId, to)
}
function onTrackDragLeave() {
@@ -773,6 +886,7 @@ function onTrackDragEnd() {
dragTrackId.value = null
dropIndex.value = null
dropGroupId.value = null
dropWithinGroupId.value = null
}
const dropGroupId = ref<string | null>(null)
@@ -1470,16 +1584,26 @@ onBeforeUnmount(() => {
:class="{
'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
'drop-before': !track.inGroup && isDropBefore(track),
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragTrackId === track.layerId,
colored: !!track.layer.color,
locked: !!track.layer.locked,
'in-group': track.inGroup,
}"
:style="trackColorStyle(track.layer.color)"
@dragover="track.isParent && !track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
@drop="track.isParent && !track.inGroup ? onTrackDrop($event) : null"
@dragover="
track.isParent
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex))
: null
"
@drop="
track.isParent
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
: null
"
@dragleave="onTrackDragLeave"
>
<span
@@ -1506,6 +1630,12 @@ onBeforeUnmount(() => {
:size="11"
class="name-badge"
/>
<PhMaskHappy
v-if="track.isParent && track.layer.isMask"
:size="11"
class="name-badge"
:class="{ 'mask-negative': track.layer.maskMode === 'negative' }"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
{{ track.label }}
</span>
@@ -1556,16 +1686,20 @@ onBeforeUnmount(() => {
v-else-if="track.type === 'clip'"
class="track clip-track"
:class="{
'drop-before': !track.inGroup && isDropBefore(track),
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.clipTrackId,
colored: !!clipTrackById.get(track.clipTrackId)?.color,
locked: !!clipTrackById.get(track.clipTrackId)?.locked,
'in-group': track.inGroup,
}"
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
@dragover="!track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
@drop="!track.inGroup ? onTrackDrop($event) : null"
@dragover="
track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex)
"
@drop="track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event)"
@dragleave="onTrackDragLeave"
>
<span
@@ -1683,9 +1817,18 @@ onBeforeUnmount(() => {
@drop="onGroupBodyDrop($event, track.groupId)"
@dragleave="onGroupBodyDragLeave"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span v-if="track.collapsed && track.childCount === 0" class="group-empty-hint">
{{ t('timeline.groupEmpty') }}
</span>
<template v-if="track.collapsed">
<span
v-for="(time, kfIdx) in groupKeyframeTimes(track.groupId)"
:key="`gkf-${kfIdx}`"
class="group-kf-dot"
:style="{ left: pxFromTime(time) + 'px' }"
/>
</template>
</div>
</div>
</template>
@@ -2289,6 +2432,10 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.name-badge.mask-negative {
color: #ff6b6b;
}
.drag-handle {
color: var(--text-dim);
margin-right: 4px;
@@ -2649,12 +2796,25 @@ onBeforeUnmount(() => {
font-style: italic;
}
.group-kf-dot {
position: absolute;
top: 50%;
width: 5px;
height: 5px;
margin-left: -2.5px;
margin-top: -2.5px;
border-radius: 50%;
background: var(--track-color, var(--accent));
opacity: 0.75;
pointer-events: none;
}
.track.in-group .track-name {
padding-left: 22px;
padding-left: 38px;
}
.track.in-group.sub-track .track-name {
padding-left: 34px;
padding-left: 50px;
}
.clip-track-dropzone {