diff --git a/server/render.html b/server/render.html index fed9d3a..16e773f 100644 --- a/server/render.html +++ b/server/render.html @@ -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=0;oi--){ - var id=order[oi]; + if(!rawOrder.length){ rawOrder=[]; for(var oj=0;oj=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;tivisible) 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;tivisibleClip) 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 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(null) +const previewImages = ref>({}) let didInitialFit = false const ZOOM_MIN = 0.05 @@ -251,6 +255,15 @@ function shadowCfg(props: AnimatableProps): Record { } 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(() => { +
@@ -987,6 +1062,15 @@ onBeforeUnmount(() => { > +
{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}
@@ -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; diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index 863b604..1d00c6c 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -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({ get: () => { const a = blurAsset.value @@ -255,6 +266,43 @@ function removeKeyframeHere() { +
+

{{ t('properties.mask') }}

+ +
+ + +
+

+ {{ t('properties.maskActive') }} +

+

+ {{ t('properties.maskNeedsGroup') }} +

+

{{ t('properties.maskHint') }}

+
+