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

+78 -22
View File
@@ -86,6 +86,33 @@
var canvas=document.getElementById('c');
var ctx=canvas.getContext('2d');
var scene=null, images={};
function drawMaskShape(ctxm, layer, am, time){
var asset=am[layer.assetId];
if(!asset) return;
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text') return;
var p=sample(layer,time);
ctxm.save();
ctxm.translate(p.x,p.y);
ctxm.rotate(p.rotation*Math.PI/180);
ctxm.scale(p.scaleX,p.scaleY);
ctxm.fillStyle='#000000';
if(asset.kind==='rect'){
var mrX=layer.offsetX!=null?layer.offsetX:asset.width/2;
var mrY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctxm.fillRect(-mrX,-mrY,asset.width,asset.height);
} else if(asset.kind==='circle'){
var mcX=layer.offsetX!=null?layer.offsetX:0;
var mcY=layer.offsetY!=null?layer.offsetY:0;
ctxm.beginPath(); ctxm.arc(-mcX,-mcY,asset.radius,0,Math.PI*2); ctxm.fill();
} else {
var mtX=layer.offsetX!=null?layer.offsetX:0;
var mtY=layer.offsetY!=null?layer.offsetY:0;
ctxm.font=asset.fontSize+'px sans-serif';
ctxm.textBaseline='top';
ctxm.fillText(asset.text,-mtX,-mtY);
}
ctxm.restore();
}
function drawLayerNode(layer, am, time){
var asset=am[layer.assetId];
if(!asset || !layer.visible) return;
@@ -164,31 +191,60 @@
var rawOrder=scene.trackOrder||[];
var groupById2={}; var groups2=scene.groups||[];
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
var order=[];
for(var roi=0;roi<rawOrder.length;roi++){
var rid=rawOrder[roi];
var grp=groupById2[rid];
if(grp){ for(var gc=0;gc<grp.childIds.length;gc++) order.push(grp.childIds[gc]); }
else order.push(rid);
}
if(!order.length){ for(var oj=0;oj<scene.layers.length;oj++) order.push(scene.layers[oj].id); }
for(var oi=order.length-1;oi>=0;oi--){
var id=order[oi];
if(!rawOrder.length){ rawOrder=[]; for(var oj=0;oj<scene.layers.length;oj++) rawOrder.push(scene.layers[oj].id); }
for(var oi=rawOrder.length-1;oi>=0;oi--){
var id=rawOrder[oi];
var lyr=layerById[id];
if(lyr){ drawLayerNode(lyr, am, time); continue; }
var tInsts=instByTrack[id]||[];
for(var ti=0;ti<tInsts.length;ti++){
var inst=tInsts[ti];
var clip=clipById[inst.clipId];
if(!clip) continue;
var tS=inst.trimStart||0;
var tE=inst.trimEnd||0;
var visible=Math.max(0, clip.duration - tS - tE);
var offset=time - inst.startTime;
if(offset<0 || offset>visible) continue;
var localT = tS + offset;
for(var cl=clip.layers.length-1; cl>=0; cl--){
drawLayerNode(clip.layers[cl], am, localT);
if(tInsts.length){
for(var ti=0;ti<tInsts.length;ti++){
var inst=tInsts[ti];
var clipRef=clipById[inst.clipId];
if(!clipRef) continue;
var tS=inst.trimStart||0;
var tE=inst.trimEnd||0;
var visibleClip=Math.max(0, clipRef.duration - tS - tE);
var offsetIntoBlock=time - inst.startTime;
if(offsetIntoBlock<0 || offsetIntoBlock>visibleClip) continue;
var localT = tS + offsetIntoBlock;
for(var cll=clipRef.layers.length-1; cll>=0; cll--){
drawLayerNode(clipRef.layers[cll], am, localT);
}
}
continue;
}
var grp=groupById2[id];
if(grp){
var gContent=[], gMasks=[];
for(var ch=grp.childIds.length-1;ch>=0;ch--){
var cl0=layerById[grp.childIds[ch]];
if(!cl0) continue;
if(cl0.isMask) gMasks.push(cl0); else gContent.push(cl0);
}
if(!gMasks.length){
for(var gi3=0;gi3<gContent.length;gi3++) drawLayerNode(gContent[gi3], am, time);
} else {
var mcanvas=document.createElement('canvas');
mcanvas.width=canvas.width; mcanvas.height=canvas.height;
var mctx=mcanvas.getContext('2d');
var tm=ctx.getTransform();
mctx.setTransform(tm.a,tm.b,tm.c,tm.d,tm.e,tm.f);
var savedCtx=ctx;
ctx=mctx;
for(var gi4=0;gi4<gContent.length;gi4++) drawLayerNode(gContent[gi4], am, time);
for(var gm=0;gm<gMasks.length;gm++){
var mL=gMasks[gm];
mctx.save();
mctx.globalCompositeOperation=(mL.maskMode==='negative'?'destination-out':'destination-in');
drawMaskShape(mctx, mL, am, time);
mctx.restore();
}
ctx=savedCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(mcanvas,0,0);
ctx.restore();
}
}
}
+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 {
+134 -1
View File
@@ -33,6 +33,7 @@ interface MountedHandle {
interface RecordingCtx {
ctx: CanvasRenderingContext2D
filterSets: string[]
compositeOps: string[]
}
function makeRecordingCtx(): RecordingCtx {
@@ -41,9 +42,11 @@ function makeRecordingCtx(): RecordingCtx {
canvas.height = 200
const real = canvas.getContext('2d')!
const filterSets: string[] = []
const compositeOps: string[] = []
const proxy = new Proxy(real, {
set(target, prop, value) {
if (prop === 'filter') filterSets.push(value as string)
if (prop === 'globalCompositeOperation') compositeOps.push(value as string)
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
return true
},
@@ -55,7 +58,7 @@ function makeRecordingCtx(): RecordingCtx {
return v
},
})
return { ctx: proxy as CanvasRenderingContext2D, filterSets }
return { ctx: proxy as CanvasRenderingContext2D, filterSets, compositeOps }
}
function mountComponent(
@@ -1571,6 +1574,136 @@ export function registerAllTests(): void {
})
})
suite('mask layers — store', () => {
test('setLayerMask(true) enables positive mask by default', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('positive')
})
test('setLayerMask(true, "negative") stores negative mode', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'N', radius: 20, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(false) clears mask flags', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true, 'negative')
s.setLayerMask(l.id, false)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(undefined)
expect(layer.maskMode).toBe(undefined)
})
test('setLayerMaskMode flips mode without changing isMask', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
s.setLayerMaskMode(l.id, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
})
suite('mask layers — rendering', () => {
function setupMaskScene(mode: 'positive' | 'negative') {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
// Content: full-canvas red rect
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
// Mask: small circle centered at (50,50)
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, mode)
const group = s.addTrackGroup('G')
s.moveTrackIntoGroup(maskLayer.id, group.id)
s.moveTrackIntoGroup(contentLayer.id, group.id)
return { store: s }
}
test('positive mask keeps content inside the mask shape and clears outside', () => {
const { store } = setupMaskScene('positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: red content visible
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask: cleared (alpha 0)
expect(outside[3]).toBe(0)
})
test('negative mask punches a hole in the content where the mask overlaps', () => {
const { store } = setupMaskScene('negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: hole (alpha 0)
expect(inside[3]).toBe(0)
// Outside mask: red content still there
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
test('layer outside a masked group is not affected by the mask', () => {
const { store } = setupMaskScene('positive')
// Add a green rect layer OUTSIDE the group
const outer = store.addAsset({
kind: 'rect',
name: 'Outer',
width: 20,
height: 20,
fill: '#00ff00',
})
const outerLayer = store.addLayer(outer.id)
store.upsertKeyframe(outerLayer.id, 0, { x: 90, y: 90 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
// The outer layer renders fully regardless of mask
const outerPx = ctx.getImageData(90, 90, 1, 1).data
expect(outerPx[1] > 200).toBe(true)
expect(outerPx[3] > 200).toBe(true)
})
})
suite('scene store — shiftKeyframesPosition', () => {
test('shifts x/y of a single keyframe by delta', async () => {
const s = makeFreshStore()
+10
View File
@@ -102,6 +102,14 @@ export const en = {
shadowAngle: 'Shadow angle',
shadowDistance: 'Shadow distance',
shadowColor: 'Shadow color',
mask: 'Mask',
maskUse: 'Use as mask',
maskMode: 'Mask mode',
maskPositive: 'Positive',
maskNegative: 'Negative',
maskHint: 'Masks everything below it in the same group.',
maskActive: 'Mask is active — affects layers below it in its group.',
maskNeedsGroup: 'Add this layer to a track group to activate masking. Right-click a track and choose "Add group".',
},
fullscreen: {
@@ -182,6 +190,8 @@ export const en = {
fitToView: 'Fit to view',
cropOn: 'Show stage only (crop)',
cropOff: 'Show everything',
maskPreviewOn: 'Preview masks (render view)',
maskPreviewOff: 'Back to edit view',
},
settings: {
+80 -22
View File
@@ -140,6 +140,35 @@ const ENGINE_SOURCE = `(function(){
draw(t);
requestAnimationFrame(frame);
}
function drawMaskShape(ctxm,layer,time){
var asset=assetMap[layer.assetId];
if(!asset) return;
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text') return;
var p=sample(layer,time);
ctxm.save();
ctxm.translate(p.x,p.y);
ctxm.rotate(p.rotation*Math.PI/180);
ctxm.scale(p.scaleX,p.scaleY);
ctxm.fillStyle='#000000';
if(asset.kind==='rect'){
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
} else if(asset.kind==='circle'){
var mcOff=layer.offsetX!=null?layer.offsetX:0;
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.beginPath();
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
ctxm.fill();
} else {
var mtOff=layer.offsetX!=null?layer.offsetX:0;
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.font=asset.fontSize+'px sans-serif';
ctxm.textBaseline='top';
ctxm.fillText(asset.text,-mtOff,-mtOffY);
}
ctxm.restore();
}
function drawLayer(layer,time){
var asset=assetMap[layer.assetId];
if(!asset||!layer.visible)return;
@@ -233,31 +262,60 @@ const ENGINE_SOURCE = `(function(){
var rawOrder=scene.trackOrder||[];
var groupById2={}; var groups2=scene.groups||[];
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
var order=[];
for(var roi=0;roi<rawOrder.length;roi++){
var rid=rawOrder[roi];
var grp=groupById2[rid];
if(grp){ for(var gc=0;gc<grp.childIds.length;gc++) order.push(grp.childIds[gc]); }
else order.push(rid);
}
if(!order.length){for(var lj=0;lj<scene.layers.length;lj++)order.push(scene.layers[lj].id);}
for(var oi=order.length-1;oi>=0;oi--){
var id=order[oi];
if(!rawOrder.length){ rawOrder=[]; for(var lj=0;lj<scene.layers.length;lj++) rawOrder.push(scene.layers[lj].id); }
for(var oi=rawOrder.length-1;oi>=0;oi--){
var id=rawOrder[oi];
var lyr=layerById[id];
if(lyr){ drawLayer(lyr,time); continue; }
var tInsts=instByTrack[id]||[];
for(var ti=0;ti<tInsts.length;ti++){
var inst2=tInsts[ti];
var clip2=clipById2[inst2.clipId];
if(!clip2)continue;
var tS=inst2.trimStart||0;
var tE=inst2.trimEnd||0;
var visible=Math.max(0,clip2.duration-tS-tE);
var offset=time-inst2.startTime;
if(offset<0||offset>visible)continue;
var localT=tS+offset;
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
drawLayer(clip2.layers[cl2],localT);
if(tInsts.length){
for(var ti=0;ti<tInsts.length;ti++){
var inst2=tInsts[ti];
var clip2=clipById2[inst2.clipId];
if(!clip2)continue;
var tS=inst2.trimStart||0;
var tE=inst2.trimEnd||0;
var visibleClip=Math.max(0,clip2.duration-tS-tE);
var offset=time-inst2.startTime;
if(offset<0||offset>visibleClip)continue;
var localT=tS+offset;
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
drawLayer(clip2.layers[cl2],localT);
}
}
continue;
}
var grp=groupById2[id];
if(grp){
var gContent=[], gMasks=[];
for(var ch=grp.childIds.length-1;ch>=0;ch--){
var cl0=layerById[grp.childIds[ch]];
if(!cl0) continue;
if(cl0.isMask) gMasks.push(cl0); else gContent.push(cl0);
}
if(!gMasks.length){
for(var gi3=0;gi3<gContent.length;gi3++) drawLayer(gContent[gi3],time);
} else {
var mcanvas=document.createElement('canvas');
mcanvas.width=canvas.width; mcanvas.height=canvas.height;
var mctx=mcanvas.getContext('2d');
var tm=ctx.getTransform();
mctx.setTransform(tm.a,tm.b,tm.c,tm.d,tm.e,tm.f);
var savedCtx=ctx;
ctx=mctx;
for(var gi4=0;gi4<gContent.length;gi4++) drawLayer(gContent[gi4],time);
for(var gm=0;gm<gMasks.length;gm++){
var mL=gMasks[gm];
mctx.save();
mctx.globalCompositeOperation=(mL.maskMode==='negative'?'destination-out':'destination-in');
drawMaskShape(mctx,mL,time);
mctx.restore();
}
ctx=savedCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(mcanvas,0,0);
ctx.restore();
}
}
}
+101 -14
View File
@@ -1,6 +1,5 @@
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
import { sampleLayer } from './interpolate'
import { expandedTrackOrder } from './sceneOrder'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
const result: Record<string, HTMLImageElement> = {}
@@ -145,6 +144,80 @@ function drawLayers(
}
}
function drawMaskShape(
ctx: CanvasRenderingContext2D,
layer: Layer,
assetMap: Map<string, Asset>,
time: number,
) {
const asset = assetMap.get(layer.assetId)
if (!asset) return
if (asset.kind !== 'rect' && asset.kind !== 'circle' && asset.kind !== 'text') return
const p = sampleLayer(layer, time)
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate((p.rotation * Math.PI) / 180)
ctx.scale(p.scaleX, p.scaleY)
ctx.fillStyle = '#000000'
if (asset.kind === 'rect') {
const offX = layer.offsetX ?? asset.width / 2
const offY = layer.offsetY ?? asset.height / 2
ctx.fillRect(-offX, -offY, asset.width, asset.height)
} else if (asset.kind === 'circle') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.beginPath()
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
ctx.fill()
} else {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.font = `${asset.fontSize}px sans-serif`
ctx.textBaseline = 'top'
ctx.fillText(asset.text, -offX, -offY)
}
ctx.restore()
}
function drawMaskedGroup(
ctx: CanvasRenderingContext2D,
children: Layer[],
assetMap: Map<string, Asset>,
images: Record<string, HTMLImageElement>,
time: number,
) {
const masks = children.filter((l) => l.isMask)
const content = children.filter((l) => !l.isMask)
if (masks.length === 0) {
drawLayers(ctx, children, assetMap, images, time)
return
}
const w = ctx.canvas.width
const h = ctx.canvas.height
const off = document.createElement('canvas')
off.width = w
off.height = h
const offCtx = off.getContext('2d')
if (!offCtx) {
drawLayers(ctx, content, assetMap, images, time)
return
}
const m = ctx.getTransform()
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
drawLayers(offCtx, content, assetMap, images, time)
for (const mask of masks) {
offCtx.save()
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
drawMaskShape(offCtx, mask, assetMap, time)
offCtx.restore()
}
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.drawImage(off, 0, 0)
ctx.restore()
}
export function drawSceneFrame(
ctx: CanvasRenderingContext2D,
scene: Scene,
@@ -174,27 +247,41 @@ export function drawSceneFrame(
}
const clipById = new Map<string, NonNullable<typeof scene.clips>[number]>()
for (const c of scene.clips ?? []) clipById.set(c.id, c)
const groupById = new Map<string, NonNullable<typeof scene.groups>[number]>()
for (const g of scene.groups ?? []) groupById.set(g.id, g)
const order = expandedTrackOrder(scene).length
? expandedTrackOrder(scene)
const rawOrder = scene.trackOrder?.length
? scene.trackOrder
: scene.layers.map((l) => l.id)
for (let i = order.length - 1; i >= 0; i--) {
const id = order[i]
for (let i = rawOrder.length - 1; i >= 0; i--) {
const id = rawOrder[i]
const layer = layerById.get(id)
if (layer) {
drawLayers(ctx, [layer], assetMap, images, time)
continue
}
const instances = instancesByTrack.get(id) ?? []
for (const inst of instances) {
const clip = clipById.get(inst.clipId)
if (!clip) continue
const trimStart = inst.trimStart ?? 0
const trimEnd = inst.trimEnd ?? 0
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = time - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
drawLayers(ctx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
if (instances.length) {
for (const inst of instances) {
const clip = clipById.get(inst.clipId)
if (!clip) continue
const trimStart = inst.trimStart ?? 0
const trimEnd = inst.trimEnd ?? 0
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = time - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
drawLayers(ctx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
}
continue
}
const group = groupById.get(id)
if (group) {
const childLayers: Layer[] = []
for (let k = group.childIds.length - 1; k >= 0; k--) {
const cl = layerById.get(group.childIds[k])
if (cl) childLayers.push(cl)
}
drawMaskedGroup(ctx, childLayers, assetMap, images, time)
}
}
+18
View File
@@ -8,6 +8,7 @@ import type {
Easing,
Keyframe,
Layer,
MaskMode,
Scene,
TrackGroup,
} from '@/types/scene'
@@ -464,6 +465,23 @@ export const useSceneStore = defineStore('scene', {
kf.props = { ...kf.props, x: curX + dx, y: curY + dy }
}
},
setLayerMask(layerId: string, isMask: boolean, mode?: MaskMode) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
if (isMask) {
layer.isMask = true
if (mode) layer.maskMode = mode
else if (!layer.maskMode) layer.maskMode = 'positive'
} else {
delete layer.isMask
delete layer.maskMode
}
},
setLayerMaskMode(layerId: string, mode: MaskMode) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
layer.maskMode = mode
},
setTrackLocked(trackId: string, locked: boolean) {
const layer = this.activeLayers.find((l) => l.id === trackId)
if (layer) {
+4
View File
@@ -65,6 +65,8 @@ export interface TextAsset {
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset
export type MaskMode = 'positive' | 'negative'
export interface Layer {
id: string
name: string
@@ -79,6 +81,8 @@ export interface Layer {
offsetY?: number
color?: string | null
locked?: boolean
isMask?: boolean
maskMode?: MaskMode
}
export interface Clip {