bak
This commit is contained in:
1 parent
43933f93d1
commit
03b1147a93
10 files changed
+781
-57
No files matched your search
+74
-18
@@ -86,6 +86,33 @@
|
|||||||
var canvas=document.getElementById('c');
|
var canvas=document.getElementById('c');
|
||||||
var ctx=canvas.getContext('2d');
|
var ctx=canvas.getContext('2d');
|
||||||
var scene=null, images={};
|
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){
|
function drawLayerNode(layer, am, time){
|
||||||
var asset=am[layer.assetId];
|
var asset=am[layer.assetId];
|
||||||
if(!asset || !layer.visible) return;
|
if(!asset || !layer.visible) return;
|
||||||
@@ -164,31 +191,60 @@
|
|||||||
var rawOrder=scene.trackOrder||[];
|
var rawOrder=scene.trackOrder||[];
|
||||||
var groupById2={}; var groups2=scene.groups||[];
|
var groupById2={}; var groups2=scene.groups||[];
|
||||||
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
|
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
|
||||||
var order=[];
|
if(!rawOrder.length){ rawOrder=[]; for(var oj=0;oj<scene.layers.length;oj++) rawOrder.push(scene.layers[oj].id); }
|
||||||
for(var roi=0;roi<rawOrder.length;roi++){
|
for(var oi=rawOrder.length-1;oi>=0;oi--){
|
||||||
var rid=rawOrder[roi];
|
var id=rawOrder[oi];
|
||||||
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];
|
|
||||||
var lyr=layerById[id];
|
var lyr=layerById[id];
|
||||||
if(lyr){ drawLayerNode(lyr, am, time); continue; }
|
if(lyr){ drawLayerNode(lyr, am, time); continue; }
|
||||||
var tInsts=instByTrack[id]||[];
|
var tInsts=instByTrack[id]||[];
|
||||||
|
if(tInsts.length){
|
||||||
for(var ti=0;ti<tInsts.length;ti++){
|
for(var ti=0;ti<tInsts.length;ti++){
|
||||||
var inst=tInsts[ti];
|
var inst=tInsts[ti];
|
||||||
var clip=clipById[inst.clipId];
|
var clipRef=clipById[inst.clipId];
|
||||||
if(!clip) continue;
|
if(!clipRef) continue;
|
||||||
var tS=inst.trimStart||0;
|
var tS=inst.trimStart||0;
|
||||||
var tE=inst.trimEnd||0;
|
var tE=inst.trimEnd||0;
|
||||||
var visible=Math.max(0, clip.duration - tS - tE);
|
var visibleClip=Math.max(0, clipRef.duration - tS - tE);
|
||||||
var offset=time - inst.startTime;
|
var offsetIntoBlock=time - inst.startTime;
|
||||||
if(offset<0 || offset>visible) continue;
|
if(offsetIntoBlock<0 || offsetIntoBlock>visibleClip) continue;
|
||||||
var localT = tS + offset;
|
var localT = tS + offsetIntoBlock;
|
||||||
for(var cl=clip.layers.length-1; cl>=0; cl--){
|
for(var cll=clipRef.layers.length-1; cll>=0; cll--){
|
||||||
drawLayerNode(clip.layers[cl], am, localT);
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import type Konva from 'konva'
|
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 { useSceneStore } from '@/stores/scene'
|
||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import { loadImage } from '@/lib/fileLoader'
|
import { loadImage } from '@/lib/fileLoader'
|
||||||
|
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||||
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { useKfSelection } from '@/composables/useKfSelection'
|
import { useKfSelection } from '@/composables/useKfSelection'
|
||||||
@@ -22,6 +23,9 @@ const zoom = ref(1)
|
|||||||
const viewX = ref(0)
|
const viewX = ref(0)
|
||||||
const viewY = ref(0)
|
const viewY = ref(0)
|
||||||
const cropObjects = ref(false)
|
const cropObjects = ref(false)
|
||||||
|
const maskPreview = ref(false)
|
||||||
|
const previewCanvasEl = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const previewImages = ref<Record<string, HTMLImageElement>>({})
|
||||||
let didInitialFit = false
|
let didInitialFit = false
|
||||||
|
|
||||||
const ZOOM_MIN = 0.05
|
const ZOOM_MIN = 0.05
|
||||||
@@ -251,6 +255,15 @@ function shadowCfg(props: AnimatableProps): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
|
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 {
|
return {
|
||||||
id: layer.id,
|
id: layer.id,
|
||||||
x: props.x,
|
x: props.x,
|
||||||
@@ -262,6 +275,7 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
|
|||||||
visible: layer.visible,
|
visible: layer.visible,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
...shadowCfg(props),
|
...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) {
|
function zoomBy(factor: number) {
|
||||||
const cw = containerSize.value.width
|
const cw = containerSize.value.width
|
||||||
const ch = containerSize.value.height
|
const ch = containerSize.value.height
|
||||||
@@ -966,6 +1036,11 @@ onBeforeUnmount(() => {
|
|||||||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||||||
</v-layer>
|
</v-layer>
|
||||||
</v-stage>
|
</v-stage>
|
||||||
|
<canvas
|
||||||
|
v-show="maskPreview"
|
||||||
|
ref="previewCanvasEl"
|
||||||
|
class="mask-preview-canvas"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
<div class="zoom-controls" @pointerdown.stop>
|
<div class="zoom-controls" @pointerdown.stop>
|
||||||
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
|
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
|
||||||
@@ -987,6 +1062,15 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<PhCrop :size="13" />
|
<PhCrop :size="13" />
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
|
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
|
||||||
@@ -1001,6 +1085,13 @@ onBeforeUnmount(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mask-preview-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
.dims {
|
.dims {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ const blurAsset = computed(() => {
|
|||||||
return a && (a.kind === 'rect' || a.kind === 'circle') ? a : null
|
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>({
|
const blurValue = computed<number>({
|
||||||
get: () => {
|
get: () => {
|
||||||
const a = blurAsset.value
|
const a = blurAsset.value
|
||||||
@@ -255,6 +266,43 @@ function removeKeyframeHere() {
|
|||||||
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
||||||
</label>
|
</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)">
|
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
||||||
<PhTrash :size="13" />
|
<PhTrash :size="13" />
|
||||||
{{ t('properties.deleteLayer') }}
|
{{ t('properties.deleteLayer') }}
|
||||||
@@ -332,6 +380,65 @@ input {
|
|||||||
font-weight: 500;
|
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 {
|
.kf-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+180
-20
@@ -12,6 +12,7 @@ import {
|
|||||||
PhLock,
|
PhLock,
|
||||||
PhLockOpen,
|
PhLockOpen,
|
||||||
PhMagnet,
|
PhMagnet,
|
||||||
|
PhMaskHappy,
|
||||||
PhPauseCircle,
|
PhPauseCircle,
|
||||||
PhTrash,
|
PhTrash,
|
||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
@@ -41,6 +42,8 @@ interface TimelineLayerTrack {
|
|||||||
isParent: boolean
|
isParent: boolean
|
||||||
orderIndex: number
|
orderIndex: number
|
||||||
inGroup: boolean
|
inGroup: boolean
|
||||||
|
parentGroupId?: string
|
||||||
|
groupChildIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimelineClipTrack {
|
interface TimelineClipTrack {
|
||||||
@@ -49,6 +52,8 @@ interface TimelineClipTrack {
|
|||||||
name: string
|
name: string
|
||||||
orderIndex: number
|
orderIndex: number
|
||||||
inGroup: boolean
|
inGroup: boolean
|
||||||
|
parentGroupId?: string
|
||||||
|
groupChildIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimelineGroupTrack {
|
interface TimelineGroupTrack {
|
||||||
@@ -240,6 +245,30 @@ function pxFromTime(t: number) {
|
|||||||
return t * effectivePxPerSec.value
|
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) {
|
function formatTick(t: number) {
|
||||||
if (displayMode.value === 'frames') {
|
if (displayMode.value === 'frames') {
|
||||||
return `${Math.round(t * fps.value)}`
|
return `${Math.round(t * fps.value)}`
|
||||||
@@ -340,13 +369,21 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
|
|||||||
store.removeKeyframe(layerId, keyframeId)
|
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) {
|
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 {
|
} else {
|
||||||
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 })
|
||||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex, inGroup })
|
out.push({ ...base, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false })
|
||||||
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.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,17 +421,25 @@ const tracks = computed<TimelineTrack[]>(() => {
|
|||||||
childCount: grp.childIds.length,
|
childCount: grp.childIds.length,
|
||||||
})
|
})
|
||||||
if (!grp.collapsed) {
|
if (!grp.collapsed) {
|
||||||
for (const cid of grp.childIds) {
|
grp.childIds.forEach((cid, childIndex) => {
|
||||||
const cl = layerById.get(cid)
|
const cl = layerById.get(cid)
|
||||||
if (cl) {
|
if (cl) {
|
||||||
emitLayerTrack(cl, -1, true, out)
|
emitLayerTrack(cl, -1, true, out, grp.id, childIndex)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
const cct = clipTrackById.value.get(cid)
|
const cct = clipTrackById.value.get(cid)
|
||||||
if (cct) {
|
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
|
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 {
|
function isDropBefore(track: TimelineTrack): boolean {
|
||||||
if (dropIndex.value === null) return false
|
if (dropIndex.value === null) return false
|
||||||
if (!isTrackHead(track)) return false
|
if (!isTrackHead(track)) return false
|
||||||
if (dragTrackId.value === trackDndId(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
|
return dropIndex.value === track.orderIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
|
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
|
||||||
if (dropIndex.value === null) return false
|
if (dropIndex.value === null) return false
|
||||||
if (dragTrackId.value === trackDndId(track)) 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 (dropIndex.value !== track.orderIndex + 1) return false
|
||||||
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
|
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
|
||||||
return true
|
return true
|
||||||
@@ -727,6 +800,7 @@ const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
|
|||||||
|
|
||||||
const dragTrackId = ref<string | null>(null)
|
const dragTrackId = ref<string | null>(null)
|
||||||
const dropIndex = ref<number | null>(null)
|
const dropIndex = ref<number | null>(null)
|
||||||
|
const dropWithinGroupId = ref<string | null>(null)
|
||||||
|
|
||||||
function onTrackDragStart(e: DragEvent, trackId: string) {
|
function onTrackDragStart(e: DragEvent, trackId: string) {
|
||||||
if (!e.dataTransfer) return
|
if (!e.dataTransfer) return
|
||||||
@@ -748,6 +822,45 @@ function onTrackDragOver(e: DragEvent, orderIndex: number) {
|
|||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
const midY = rect.top + rect.height / 2
|
const midY = rect.top + rect.height / 2
|
||||||
dropIndex.value = e.clientY < midY ? orderIndex : orderIndex + 1
|
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() {
|
function onTrackDragLeave() {
|
||||||
@@ -773,6 +886,7 @@ function onTrackDragEnd() {
|
|||||||
dragTrackId.value = null
|
dragTrackId.value = null
|
||||||
dropIndex.value = null
|
dropIndex.value = null
|
||||||
dropGroupId.value = null
|
dropGroupId.value = null
|
||||||
|
dropWithinGroupId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropGroupId = ref<string | null>(null)
|
const dropGroupId = ref<string | null>(null)
|
||||||
@@ -1470,16 +1584,26 @@ onBeforeUnmount(() => {
|
|||||||
:class="{
|
:class="{
|
||||||
'sub-track': !track.isParent,
|
'sub-track': !track.isParent,
|
||||||
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
|
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
|
||||||
'drop-before': !track.inGroup && isDropBefore(track),
|
'drop-before': isDropBefore(track),
|
||||||
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
|
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||||
dragging: track.isParent && dragTrackId === track.layerId,
|
dragging: track.isParent && dragTrackId === track.layerId,
|
||||||
colored: !!track.layer.color,
|
colored: !!track.layer.color,
|
||||||
locked: !!track.layer.locked,
|
locked: !!track.layer.locked,
|
||||||
'in-group': track.inGroup,
|
'in-group': track.inGroup,
|
||||||
}"
|
}"
|
||||||
:style="trackColorStyle(track.layer.color)"
|
:style="trackColorStyle(track.layer.color)"
|
||||||
@dragover="track.isParent && !track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
|
@dragover="
|
||||||
@drop="track.isParent && !track.inGroup ? onTrackDrop($event) : null"
|
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"
|
@dragleave="onTrackDragLeave"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -1506,6 +1630,12 @@ onBeforeUnmount(() => {
|
|||||||
:size="11"
|
:size="11"
|
||||||
class="name-badge"
|
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>
|
<span v-if="!track.isParent" class="sub-indent">↳</span>
|
||||||
{{ track.label }}
|
{{ track.label }}
|
||||||
</span>
|
</span>
|
||||||
@@ -1556,16 +1686,20 @@ onBeforeUnmount(() => {
|
|||||||
v-else-if="track.type === 'clip'"
|
v-else-if="track.type === 'clip'"
|
||||||
class="track clip-track"
|
class="track clip-track"
|
||||||
:class="{
|
:class="{
|
||||||
'drop-before': !track.inGroup && isDropBefore(track),
|
'drop-before': isDropBefore(track),
|
||||||
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
|
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||||
dragging: dragTrackId === track.clipTrackId,
|
dragging: dragTrackId === track.clipTrackId,
|
||||||
colored: !!clipTrackById.get(track.clipTrackId)?.color,
|
colored: !!clipTrackById.get(track.clipTrackId)?.color,
|
||||||
locked: !!clipTrackById.get(track.clipTrackId)?.locked,
|
locked: !!clipTrackById.get(track.clipTrackId)?.locked,
|
||||||
'in-group': track.inGroup,
|
'in-group': track.inGroup,
|
||||||
}"
|
}"
|
||||||
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
|
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
|
||||||
@dragover="!track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
|
@dragover="
|
||||||
@drop="!track.inGroup ? onTrackDrop($event) : null"
|
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"
|
@dragleave="onTrackDragLeave"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -1683,9 +1817,18 @@ onBeforeUnmount(() => {
|
|||||||
@drop="onGroupBodyDrop($event, track.groupId)"
|
@drop="onGroupBodyDrop($event, track.groupId)"
|
||||||
@dragleave="onGroupBodyDragLeave"
|
@dragleave="onGroupBodyDragLeave"
|
||||||
>
|
>
|
||||||
|
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||||
<span v-if="track.collapsed && track.childCount === 0" class="group-empty-hint">
|
<span v-if="track.collapsed && track.childCount === 0" class="group-empty-hint">
|
||||||
{{ t('timeline.groupEmpty') }}
|
{{ t('timeline.groupEmpty') }}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -2289,6 +2432,10 @@ onBeforeUnmount(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.name-badge.mask-negative {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
@@ -2649,12 +2796,25 @@ onBeforeUnmount(() => {
|
|||||||
font-style: italic;
|
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 {
|
.track.in-group .track-name {
|
||||||
padding-left: 22px;
|
padding-left: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track.in-group.sub-track .track-name {
|
.track.in-group.sub-track .track-name {
|
||||||
padding-left: 34px;
|
padding-left: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clip-track-dropzone {
|
.clip-track-dropzone {
|
||||||
|
|||||||
+134
-1
@@ -33,6 +33,7 @@ interface MountedHandle {
|
|||||||
interface RecordingCtx {
|
interface RecordingCtx {
|
||||||
ctx: CanvasRenderingContext2D
|
ctx: CanvasRenderingContext2D
|
||||||
filterSets: string[]
|
filterSets: string[]
|
||||||
|
compositeOps: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRecordingCtx(): RecordingCtx {
|
function makeRecordingCtx(): RecordingCtx {
|
||||||
@@ -41,9 +42,11 @@ function makeRecordingCtx(): RecordingCtx {
|
|||||||
canvas.height = 200
|
canvas.height = 200
|
||||||
const real = canvas.getContext('2d')!
|
const real = canvas.getContext('2d')!
|
||||||
const filterSets: string[] = []
|
const filterSets: string[] = []
|
||||||
|
const compositeOps: string[] = []
|
||||||
const proxy = new Proxy(real, {
|
const proxy = new Proxy(real, {
|
||||||
set(target, prop, value) {
|
set(target, prop, value) {
|
||||||
if (prop === 'filter') filterSets.push(value as string)
|
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
|
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
@@ -55,7 +58,7 @@ function makeRecordingCtx(): RecordingCtx {
|
|||||||
return v
|
return v
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return { ctx: proxy as CanvasRenderingContext2D, filterSets }
|
return { ctx: proxy as CanvasRenderingContext2D, filterSets, compositeOps }
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountComponent(
|
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', () => {
|
suite('scene store — shiftKeyframesPosition', () => {
|
||||||
test('shifts x/y of a single keyframe by delta', async () => {
|
test('shifts x/y of a single keyframe by delta', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ export const en = {
|
|||||||
shadowAngle: 'Shadow angle',
|
shadowAngle: 'Shadow angle',
|
||||||
shadowDistance: 'Shadow distance',
|
shadowDistance: 'Shadow distance',
|
||||||
shadowColor: 'Shadow color',
|
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: {
|
fullscreen: {
|
||||||
@@ -182,6 +190,8 @@ export const en = {
|
|||||||
fitToView: 'Fit to view',
|
fitToView: 'Fit to view',
|
||||||
cropOn: 'Show stage only (crop)',
|
cropOn: 'Show stage only (crop)',
|
||||||
cropOff: 'Show everything',
|
cropOff: 'Show everything',
|
||||||
|
maskPreviewOn: 'Preview masks (render view)',
|
||||||
|
maskPreviewOff: 'Back to edit view',
|
||||||
},
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
+70
-12
@@ -140,6 +140,35 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
draw(t);
|
draw(t);
|
||||||
requestAnimationFrame(frame);
|
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){
|
function drawLayer(layer,time){
|
||||||
var asset=assetMap[layer.assetId];
|
var asset=assetMap[layer.assetId];
|
||||||
if(!asset||!layer.visible)return;
|
if(!asset||!layer.visible)return;
|
||||||
@@ -233,33 +262,62 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
var rawOrder=scene.trackOrder||[];
|
var rawOrder=scene.trackOrder||[];
|
||||||
var groupById2={}; var groups2=scene.groups||[];
|
var groupById2={}; var groups2=scene.groups||[];
|
||||||
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
|
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
|
||||||
var order=[];
|
if(!rawOrder.length){ rawOrder=[]; for(var lj=0;lj<scene.layers.length;lj++) rawOrder.push(scene.layers[lj].id); }
|
||||||
for(var roi=0;roi<rawOrder.length;roi++){
|
for(var oi=rawOrder.length-1;oi>=0;oi--){
|
||||||
var rid=rawOrder[roi];
|
var id=rawOrder[oi];
|
||||||
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];
|
|
||||||
var lyr=layerById[id];
|
var lyr=layerById[id];
|
||||||
if(lyr){ drawLayer(lyr,time); continue; }
|
if(lyr){ drawLayer(lyr,time); continue; }
|
||||||
var tInsts=instByTrack[id]||[];
|
var tInsts=instByTrack[id]||[];
|
||||||
|
if(tInsts.length){
|
||||||
for(var ti=0;ti<tInsts.length;ti++){
|
for(var ti=0;ti<tInsts.length;ti++){
|
||||||
var inst2=tInsts[ti];
|
var inst2=tInsts[ti];
|
||||||
var clip2=clipById2[inst2.clipId];
|
var clip2=clipById2[inst2.clipId];
|
||||||
if(!clip2)continue;
|
if(!clip2)continue;
|
||||||
var tS=inst2.trimStart||0;
|
var tS=inst2.trimStart||0;
|
||||||
var tE=inst2.trimEnd||0;
|
var tE=inst2.trimEnd||0;
|
||||||
var visible=Math.max(0,clip2.duration-tS-tE);
|
var visibleClip=Math.max(0,clip2.duration-tS-tE);
|
||||||
var offset=time-inst2.startTime;
|
var offset=time-inst2.startTime;
|
||||||
if(offset<0||offset>visible)continue;
|
if(offset<0||offset>visibleClip)continue;
|
||||||
var localT=tS+offset;
|
var localT=tS+offset;
|
||||||
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
|
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
|
||||||
drawLayer(clip2.layers[cl2],localT);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-5
@@ -1,6 +1,5 @@
|
|||||||
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
|
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
|
||||||
import { sampleLayer } from './interpolate'
|
import { sampleLayer } from './interpolate'
|
||||||
import { expandedTrackOrder } from './sceneOrder'
|
|
||||||
|
|
||||||
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
||||||
const result: 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(
|
export function drawSceneFrame(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
@@ -174,18 +247,21 @@ export function drawSceneFrame(
|
|||||||
}
|
}
|
||||||
const clipById = new Map<string, NonNullable<typeof scene.clips>[number]>()
|
const clipById = new Map<string, NonNullable<typeof scene.clips>[number]>()
|
||||||
for (const c of scene.clips ?? []) clipById.set(c.id, c)
|
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
|
const rawOrder = scene.trackOrder?.length
|
||||||
? expandedTrackOrder(scene)
|
? scene.trackOrder
|
||||||
: scene.layers.map((l) => l.id)
|
: scene.layers.map((l) => l.id)
|
||||||
for (let i = order.length - 1; i >= 0; i--) {
|
for (let i = rawOrder.length - 1; i >= 0; i--) {
|
||||||
const id = order[i]
|
const id = rawOrder[i]
|
||||||
const layer = layerById.get(id)
|
const layer = layerById.get(id)
|
||||||
if (layer) {
|
if (layer) {
|
||||||
drawLayers(ctx, [layer], assetMap, images, time)
|
drawLayers(ctx, [layer], assetMap, images, time)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const instances = instancesByTrack.get(id) ?? []
|
const instances = instancesByTrack.get(id) ?? []
|
||||||
|
if (instances.length) {
|
||||||
for (const inst of instances) {
|
for (const inst of instances) {
|
||||||
const clip = clipById.get(inst.clipId)
|
const clip = clipById.get(inst.clipId)
|
||||||
if (!clip) continue
|
if (!clip) continue
|
||||||
@@ -196,6 +272,17 @@ export function drawSceneFrame(
|
|||||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
||||||
drawLayers(ctx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
Easing,
|
Easing,
|
||||||
Keyframe,
|
Keyframe,
|
||||||
Layer,
|
Layer,
|
||||||
|
MaskMode,
|
||||||
Scene,
|
Scene,
|
||||||
TrackGroup,
|
TrackGroup,
|
||||||
} from '@/types/scene'
|
} from '@/types/scene'
|
||||||
@@ -464,6 +465,23 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
kf.props = { ...kf.props, x: curX + dx, y: curY + dy }
|
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) {
|
setTrackLocked(trackId: string, locked: boolean) {
|
||||||
const layer = this.activeLayers.find((l) => l.id === trackId)
|
const layer = this.activeLayers.find((l) => l.id === trackId)
|
||||||
if (layer) {
|
if (layer) {
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export interface TextAsset {
|
|||||||
|
|
||||||
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset
|
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset
|
||||||
|
|
||||||
|
export type MaskMode = 'positive' | 'negative'
|
||||||
|
|
||||||
export interface Layer {
|
export interface Layer {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -79,6 +81,8 @@ export interface Layer {
|
|||||||
offsetY?: number
|
offsetY?: number
|
||||||
color?: string | null
|
color?: string | null
|
||||||
locked?: boolean
|
locked?: boolean
|
||||||
|
isMask?: boolean
|
||||||
|
maskMode?: MaskMode
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Clip {
|
export interface Clip {
|
||||||
|
|||||||
Reference in new issue
Block a user