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.maskUse') }} + + + + + {{ t('properties.maskPositive') }} + + + + {{ t('properties.maskNegative') }} + + + + {{ t('properties.maskActive') }} + + + {{ t('properties.maskNeedsGroup') }} + + {{ t('properties.maskHint') }} + + {{ 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; diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index b5d83eb..85f2b9c 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -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() + 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(() => { 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(null) const dropIndex = ref(null) +const dropWithinGroupId = ref(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(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" > { :size="11" class="name-badge" /> + ↳ {{ track.label }} @@ -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" > { @drop="onGroupBodyDrop($event, track.groupId)" @dragleave="onGroupBodyDragLeave" > + {{ t('timeline.groupEmpty') }} + + + @@ -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 { diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 7e2ea57..045f0af 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -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)[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() diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 047b9a7..9c646f2 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -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: { diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index 6137868..e73b0bf 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -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=0;oi--){ - var id=order[oi]; + if(!rawOrder.length){ rawOrder=[]; for(var lj=0;lj=0;oi--){ + var id=rawOrder[oi]; var lyr=layerById[id]; if(lyr){ drawLayer(lyr,time); continue; } var tInsts=instByTrack[id]||[]; - for(var ti=0;tivisible)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;tivisibleClip)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> { const result: Record = {} @@ -145,6 +144,80 @@ function drawLayers( } } +function drawMaskShape( + ctx: CanvasRenderingContext2D, + layer: Layer, + assetMap: Map, + 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, + images: Record, + 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[number]>() for (const c of scene.clips ?? []) clipById.set(c.id, c) + const groupById = new Map[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) } } diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 179b3ed..808d0c3 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -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) { diff --git a/src/types/scene.ts b/src/types/scene.ts index a92c52f..acd6b24 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -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 {
+ {{ t('properties.maskActive') }} +
+ {{ t('properties.maskNeedsGroup') }} +
{{ t('properties.maskHint') }}