bak
This commit is contained in:
1 parent
43933f93d1
commit
03b1147a93
10 files changed
+804
-80
No files matched your search
+78
-22
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user