fix: masks inside buttons render across all paths
Buttons used to ignore `isMask` on every render path — the mask shape drew as normal content instead of clipping the button's other layers. Every entry point now routes button children through `drawMaskedGroup`: - `drawLayers` nested-button branch - new `drawButtonFrame` (used by the mask-preview overlay in button-edit mode so the preview reflects the button's own stage instead of the main scene) - `renderButtonThumbnail` so sidebar previews match the stage - exported-runtime button branch mirrors the split; bumped `RUNTIME_VERSION` to '11' so cached players reload Also: - Timeline clip-track dropzone hides until the library has at least one clip, and the hint stays centred in the visible ruler area instead of drifting with horizontal scroll. - Regression tests pin positive/negative button masks in `drawButtonFrame` and verify `drawSceneFrame` applies button-internal masks when the button sits on the main stage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c3764201b8
commit
f29dc448fb
6 files changed
+232
-30
No files matched your search
@@ -16,7 +16,7 @@ import { useSceneStore } from '@/stores/scene'
|
|||||||
import { isLayerActiveAt, sampleLayer } from '@/lib/interpolate'
|
import { isLayerActiveAt, sampleLayer } from '@/lib/interpolate'
|
||||||
import { importImageFile, loadImage } from '@/lib/fileLoader'
|
import { importImageFile, loadImage } from '@/lib/fileLoader'
|
||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
|
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||||
import type {
|
import type {
|
||||||
Action,
|
Action,
|
||||||
AnimatableProps,
|
AnimatableProps,
|
||||||
@@ -2697,9 +2697,24 @@ function drawPreview() {
|
|||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.translate(viewX.value, viewY.value)
|
ctx.translate(viewX.value, viewY.value)
|
||||||
ctx.scale(zoom.value, zoom.value)
|
ctx.scale(zoom.value, zoom.value)
|
||||||
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value, {
|
// In button-edit mode the preview should reflect the *button*'s stage,
|
||||||
crop: cropObjects.value,
|
// not the main scene behind it. `store.playhead` is already pinned to
|
||||||
})
|
// BUTTON_STATE_TIMES[activeButtonState] (or to normal in hit mode), so
|
||||||
|
// we can pass it straight through.
|
||||||
|
if (store.activeButtonAsset) {
|
||||||
|
drawButtonFrame(
|
||||||
|
ctx,
|
||||||
|
store.activeButtonAsset,
|
||||||
|
store.scene,
|
||||||
|
store.playhead,
|
||||||
|
previewImages.value,
|
||||||
|
{ background: '#1a1a1f' },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value, {
|
||||||
|
crop: cropObjects.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,30 @@ const duration = computed(() =>
|
|||||||
store.activeButtonAssetId ? 5 : store.activeDuration,
|
store.activeButtonAssetId ? 5 : store.activeDuration,
|
||||||
)
|
)
|
||||||
const inClipEdit = computed(() => store.activeClipId !== null)
|
const inClipEdit = computed(() => store.activeClipId !== null)
|
||||||
|
|
||||||
|
// Only surface the "drop a clip to add a new track" dropzone when the user
|
||||||
|
// has at least one clip in the library — otherwise the hint is confusing
|
||||||
|
// (you can't drop what you don't have).
|
||||||
|
const showClipDropzone = computed(
|
||||||
|
() =>
|
||||||
|
!inClipEdit.value &&
|
||||||
|
!store.activeButtonAssetId &&
|
||||||
|
(store.scene.clips?.length ?? 0) > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep the dropzone hint horizontally centred in the *visible* ruler area
|
||||||
|
// (right of the sticky name-spacer) regardless of horizontal scroll. The
|
||||||
|
// dropzone itself is as wide as .tl-content, so its natural flex-center
|
||||||
|
// is not at the visual center — we position the hint absolutely at
|
||||||
|
// `scrollLeft + trackNameWidth + (visibleRulerWidth / 2)`.
|
||||||
|
const dropzoneHintStyle = computed(() => {
|
||||||
|
const tn = trackNameWidth.value
|
||||||
|
const vp = viewportWidth.value
|
||||||
|
if (vp <= 0) return {}
|
||||||
|
const visibleRulerW = Math.max(0, vp - tn)
|
||||||
|
const leftPx = scrollLeft.value + tn + visibleRulerW / 2
|
||||||
|
return { left: `${leftPx}px` }
|
||||||
|
})
|
||||||
const clipInstancesByTrack = computed<Record<string, ClipInstance[]>>(() => {
|
const clipInstancesByTrack = computed<Record<string, ClipInstance[]>>(() => {
|
||||||
const out: Record<string, ClipInstance[]> = {}
|
const out: Record<string, ClipInstance[]> = {}
|
||||||
for (const ci of store.scene.clipInstances ?? []) {
|
for (const ci of store.scene.clipInstances ?? []) {
|
||||||
@@ -2710,7 +2734,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ store.activeButtonAssetId ? t('timeline.emptyButtonState') : t('timeline.noTracks') }}
|
{{ store.activeButtonAssetId ? t('timeline.emptyButtonState') : t('timeline.noTracks') }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-if="!inClipEdit && !store.activeButtonAssetId"
|
v-if="showClipDropzone"
|
||||||
class="track clip-track-dropzone"
|
class="track clip-track-dropzone"
|
||||||
:class="{ hot: clipDragOverDropzone }"
|
:class="{ hot: clipDragOverDropzone }"
|
||||||
@dragenter="onNewTrackDragEnter"
|
@dragenter="onNewTrackDragEnter"
|
||||||
@@ -2718,7 +2742,9 @@ onBeforeUnmount(() => {
|
|||||||
@dragleave="onNewTrackDragLeave"
|
@dragleave="onNewTrackDragLeave"
|
||||||
@drop="onNewTrackDrop"
|
@drop="onNewTrackDrop"
|
||||||
>
|
>
|
||||||
<span class="dropzone-hint">{{ t('timeline.dropNewTrackHint') }}</span>
|
<span class="dropzone-hint" :style="dropzoneHintStyle">
|
||||||
|
{{ t('timeline.dropNewTrackHint') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4118,6 +4144,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clip-track-dropzone {
|
.clip-track-dropzone {
|
||||||
|
position: relative;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -4138,9 +4165,12 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropzone-hint {
|
.dropzone-hint {
|
||||||
position: sticky;
|
position: absolute;
|
||||||
left: 50%;
|
top: 50%;
|
||||||
transform: translateX(-50%);
|
/* `left` is driven from a computed so the hint stays in the centre of
|
||||||
|
the *visible* ruler area (i.e. centred in the user's viewport, right
|
||||||
|
of the sticky name-spacer) regardless of horizontal scroll. */
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-2
@@ -7,7 +7,8 @@ import {
|
|||||||
type ActionRuntime,
|
type ActionRuntime,
|
||||||
} from '@/lib/actions'
|
} from '@/lib/actions'
|
||||||
import { parseScript } from '@/lib/scriptDsl'
|
import { parseScript } from '@/lib/scriptDsl'
|
||||||
import type { Action, HitShape } from '@/types/scene'
|
import type { Action, ButtonAsset, HitShape } from '@/types/scene'
|
||||||
|
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||||
import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate'
|
import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate'
|
||||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +21,7 @@ import { createHistory } from '@/composables/useHistory'
|
|||||||
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
||||||
import { APP_NAME, APP_VERSION } from '@/version'
|
import { APP_NAME, APP_VERSION } from '@/version'
|
||||||
import type { PolygonAsset } from '@/types/scene'
|
import type { PolygonAsset } from '@/types/scene'
|
||||||
import { drawSceneFrame, renderButtonThumbnail } from '@/lib/render'
|
import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/render'
|
||||||
import { t as i18nT } from '@/i18n'
|
import { t as i18nT } from '@/i18n'
|
||||||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||||||
import {
|
import {
|
||||||
@@ -3772,6 +3773,95 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('button asset — mask rendering', () => {
|
||||||
|
// Regression: masks inside buttons used to be ignored by every render
|
||||||
|
// path — the button branch in drawLayers called drawLayers recursively
|
||||||
|
// without the drawMaskedGroup split, so `isMask` layers drew as normal
|
||||||
|
// content. That made mask-preview / thumbnail / main-stage / exported
|
||||||
|
// runtime all show the mask shape instead of clipping. All paths now
|
||||||
|
// route button layers through drawMaskedGroup; these tests pin that.
|
||||||
|
function setupMaskedButton(mode: 'positive' | 'negative') {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const btn = s.addButtonAsset('B', 100, 100)
|
||||||
|
s.enterButtonAssetEdit(btn.id)
|
||||||
|
// Content: full-canvas red rect. addLayer already keyframes it at
|
||||||
|
// (canvas.width/2, canvas.height/2) at BUTTON_STATE_TIMES.normal,
|
||||||
|
// so we don't need to set anything else.
|
||||||
|
const red = s.addAsset({
|
||||||
|
kind: 'rect',
|
||||||
|
name: 'R',
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
fill: '#ff0000',
|
||||||
|
})
|
||||||
|
s.addLayer(red.id)!
|
||||||
|
// Mask: small circle at canvas center.
|
||||||
|
const mask = s.addAsset({
|
||||||
|
kind: 'circle',
|
||||||
|
name: 'M',
|
||||||
|
radius: 20,
|
||||||
|
fill: '#ffffff',
|
||||||
|
})
|
||||||
|
const maskLayer = s.addLayer(mask.id)!
|
||||||
|
s.setLayerMask(maskLayer.id, true, mode)
|
||||||
|
s.exitButtonAssetEdit()
|
||||||
|
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
|
||||||
|
return { store: s, button }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('drawButtonFrame applies a positive mask inside the button', () => {
|
||||||
|
const { store, button } = setupMaskedButton('positive')
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 100
|
||||||
|
canvas.height = 100
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
|
||||||
|
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||||||
|
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||||||
|
expect(inside[0] > 200).toBe(true)
|
||||||
|
expect(inside[3] > 200).toBe(true)
|
||||||
|
expect(outside[3]).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drawButtonFrame applies a negative mask inside the button', () => {
|
||||||
|
const { store, button } = setupMaskedButton('negative')
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 100
|
||||||
|
canvas.height = 100
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
|
||||||
|
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||||||
|
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||||||
|
// Inside mask: punched out
|
||||||
|
expect(inside[3]).toBe(0)
|
||||||
|
// Outside mask: red content still there
|
||||||
|
expect(outside[0] > 200).toBe(true)
|
||||||
|
expect(outside[3] > 200).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drawSceneFrame applies button-internal masks when the button is placed on the main stage', () => {
|
||||||
|
const { store, button } = setupMaskedButton('positive')
|
||||||
|
// Size the main scene to match the button exactly so coordinates line up.
|
||||||
|
store.scene.canvas.width = 100
|
||||||
|
store.scene.canvas.height = 100
|
||||||
|
store.scene.canvas.background = 'transparent'
|
||||||
|
// Place the button as a layer on the main stage, centered.
|
||||||
|
store.addLayer(button.id)!
|
||||||
|
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 the circular mask: red content visible
|
||||||
|
expect(inside[0] > 200).toBe(true)
|
||||||
|
expect(inside[3] > 200).toBe(true)
|
||||||
|
// Outside the circle: masked away (alpha 0)
|
||||||
|
expect(outside[3]).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('i18n — track-menu rename/delete labels', () => {
|
suite('i18n — track-menu rename/delete labels', () => {
|
||||||
// Regression: the script-track context menu used to surface the raw
|
// Regression: the script-track context menu used to surface the raw
|
||||||
// keys ("timeline.renameScriptTrack" / ".deleteScriptTrack") because
|
// keys ("timeline.renameScriptTrack" / ".deleteScriptTrack") because
|
||||||
|
|||||||
+34
-5
@@ -1,6 +1,6 @@
|
|||||||
import type { Scene } from '@/types/scene'
|
import type { Scene } from '@/types/scene'
|
||||||
|
|
||||||
const RUNTIME_VERSION = '10'
|
const RUNTIME_VERSION = '11'
|
||||||
|
|
||||||
const ENGINE_SOURCE = `(function(){
|
const ENGINE_SOURCE = `(function(){
|
||||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||||
@@ -444,11 +444,11 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
}
|
}
|
||||||
if(closed) tctx.closePath();
|
if(closed) tctx.closePath();
|
||||||
}
|
}
|
||||||
function drawMaskShape(ctxm,layer,time){
|
function drawMaskShape(ctxm,layer,time,alwaysActive){
|
||||||
var asset=assetMap[layer.assetId];
|
var asset=assetMap[layer.assetId];
|
||||||
if(!asset) return;
|
if(!asset) return;
|
||||||
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
|
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
|
||||||
if(!layerActiveAt(layer,time)) return;
|
if(!alwaysActive && !layerActiveAt(layer,time)) return;
|
||||||
var p=sample(layer,time);
|
var p=sample(layer,time);
|
||||||
ctxm.save();
|
ctxm.save();
|
||||||
ctxm.translate(p.x,p.y);
|
ctxm.translate(p.x,p.y);
|
||||||
@@ -617,8 +617,37 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
var bState=resolveButtonState(layer);
|
var bState=resolveButtonState(layer);
|
||||||
var stateTime=bState==='hover'?2:(bState==='pressed'?3:(bState==='disabled'?4:1));
|
var stateTime=bState==='hover'?2:(bState==='pressed'?3:(bState==='disabled'?4:1));
|
||||||
var bLayers=asset.layers||[];
|
var bLayers=asset.layers||[];
|
||||||
for(var bli=bLayers.length-1;bli>=0;bli--){
|
var bContent=[], bMasks=[];
|
||||||
drawLayer(bLayers[bli], stateTime, true);
|
for(var bli=0;bli<bLayers.length;bli++){
|
||||||
|
if(bLayers[bli].isMask) bMasks.push(bLayers[bli]); else bContent.push(bLayers[bli]);
|
||||||
|
}
|
||||||
|
if(!bMasks.length){
|
||||||
|
for(var bli2=bContent.length-1;bli2>=0;bli2--){
|
||||||
|
drawLayer(bContent[bli2], stateTime, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var bcanvas=document.createElement('canvas');
|
||||||
|
bcanvas.width=canvas.width; bcanvas.height=canvas.height;
|
||||||
|
var bctx=bcanvas.getContext('2d');
|
||||||
|
var btm=ctx.getTransform();
|
||||||
|
bctx.setTransform(btm.a,btm.b,btm.c,btm.d,btm.e,btm.f);
|
||||||
|
var savedBCtx=ctx;
|
||||||
|
ctx=bctx;
|
||||||
|
for(var bli3=bContent.length-1;bli3>=0;bli3--){
|
||||||
|
drawLayer(bContent[bli3], stateTime, true);
|
||||||
|
}
|
||||||
|
for(var bmk=0;bmk<bMasks.length;bmk++){
|
||||||
|
var bML=bMasks[bmk];
|
||||||
|
bctx.save();
|
||||||
|
bctx.globalCompositeOperation=(bML.maskMode==='negative'?'destination-out':'destination-in');
|
||||||
|
drawMaskShape(bctx,bML,stateTime,true);
|
||||||
|
bctx.restore();
|
||||||
|
}
|
||||||
|
ctx=savedBCtx;
|
||||||
|
ctx.save();
|
||||||
|
ctx.setTransform(1,0,0,1,0,0);
|
||||||
|
ctx.drawImage(bcanvas,0,0);
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.shadowColor='transparent';
|
ctx.shadowColor='transparent';
|
||||||
|
|||||||
+37
-7
@@ -241,7 +241,7 @@ function drawLayers(
|
|||||||
const offY = layer.offsetY ?? asset.canvas.height / 2
|
const offY = layer.offsetY ?? asset.canvas.height / 2
|
||||||
ctx.translate(-offX, -offY)
|
ctx.translate(-offX, -offY)
|
||||||
const bs = buttonStates?.get(layer.id) ?? 'normal'
|
const bs = buttonStates?.get(layer.id) ?? 'normal'
|
||||||
drawLayers(ctx, asset.layers, assetMap, images, BUTTON_STATE_TIMES[bs], undefined, true)
|
drawMaskedGroup(ctx, asset.layers, assetMap, images, BUTTON_STATE_TIMES[bs], undefined, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearShadow(ctx)
|
clearShadow(ctx)
|
||||||
@@ -254,6 +254,7 @@ function drawMaskShape(
|
|||||||
layer: Layer,
|
layer: Layer,
|
||||||
assetMap: Map<string, Asset>,
|
assetMap: Map<string, Asset>,
|
||||||
time: number,
|
time: number,
|
||||||
|
alwaysActive?: boolean,
|
||||||
) {
|
) {
|
||||||
const asset = assetMap.get(layer.assetId)
|
const asset = assetMap.get(layer.assetId)
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
@@ -264,7 +265,7 @@ function drawMaskShape(
|
|||||||
asset.kind !== 'polygon'
|
asset.kind !== 'polygon'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if (!isLayerActiveAt(layer, time)) return
|
if (!alwaysActive && !isLayerActiveAt(layer, time)) return
|
||||||
const p = sampleLayer(layer, time)
|
const p = sampleLayer(layer, time)
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.translate(p.x, p.y)
|
ctx.translate(p.x, p.y)
|
||||||
@@ -307,11 +308,12 @@ function drawMaskedGroup(
|
|||||||
images: Record<string, HTMLImageElement>,
|
images: Record<string, HTMLImageElement>,
|
||||||
time: number,
|
time: number,
|
||||||
buttonStates?: Map<string, ButtonState>,
|
buttonStates?: Map<string, ButtonState>,
|
||||||
|
alwaysActive?: boolean,
|
||||||
) {
|
) {
|
||||||
const masks = children.filter((l) => l.isMask)
|
const masks = children.filter((l) => l.isMask)
|
||||||
const content = children.filter((l) => !l.isMask)
|
const content = children.filter((l) => !l.isMask)
|
||||||
if (masks.length === 0) {
|
if (masks.length === 0) {
|
||||||
drawLayers(ctx, children, assetMap, images, time, buttonStates)
|
drawLayers(ctx, children, assetMap, images, time, buttonStates, alwaysActive)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,16 +324,17 @@ function drawMaskedGroup(
|
|||||||
off.height = h
|
off.height = h
|
||||||
const offCtx = off.getContext('2d')
|
const offCtx = off.getContext('2d')
|
||||||
if (!offCtx) {
|
if (!offCtx) {
|
||||||
drawLayers(ctx, content, assetMap, images, time, buttonStates)
|
drawLayers(ctx, content, assetMap, images, time, buttonStates, alwaysActive)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const m = ctx.getTransform()
|
const m = ctx.getTransform()
|
||||||
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
|
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
|
||||||
drawLayers(offCtx, content, assetMap, images, time, buttonStates)
|
drawLayers(offCtx, content, assetMap, images, time, buttonStates, alwaysActive)
|
||||||
for (const mask of masks) {
|
for (const mask of masks) {
|
||||||
|
if (!alwaysActive && !isLayerActiveAt(mask, time)) continue
|
||||||
offCtx.save()
|
offCtx.save()
|
||||||
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
||||||
drawMaskShape(offCtx, mask, assetMap, time)
|
drawMaskShape(offCtx, mask, assetMap, time, alwaysActive)
|
||||||
offCtx.restore()
|
offCtx.restore()
|
||||||
}
|
}
|
||||||
ctx.save()
|
ctx.save()
|
||||||
@@ -481,6 +484,33 @@ export function renderButtonThumbnail(
|
|||||||
ctx.scale(scale, scale)
|
ctx.scale(scale, scale)
|
||||||
const assetMap = new Map<string, Asset>()
|
const assetMap = new Map<string, Asset>()
|
||||||
for (const a of scene.assets) assetMap.set(a.id, a)
|
for (const a of scene.assets) assetMap.set(a.id, a)
|
||||||
drawLayers(ctx, button.layers, assetMap, images, BUTTON_STATE_TIMES.normal, undefined, true)
|
drawMaskedGroup(ctx, button.layers, assetMap, images, BUTTON_STATE_TIMES.normal, undefined, true)
|
||||||
return canvas.toDataURL('image/png')
|
return canvas.toDataURL('image/png')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a single button-asset frame into the supplied context. Used by the
|
||||||
|
* mask-preview overlay in button-edit mode so the preview reflects the
|
||||||
|
* button's own stage instead of the main scene. The caller is responsible
|
||||||
|
* for applying viewport translate/zoom before calling.
|
||||||
|
*/
|
||||||
|
export function drawButtonFrame(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
button: ButtonAsset,
|
||||||
|
scene: Scene,
|
||||||
|
time: number,
|
||||||
|
images: Record<string, HTMLImageElement>,
|
||||||
|
options?: { background?: string },
|
||||||
|
): void {
|
||||||
|
const { width: w, height: h } = button.canvas
|
||||||
|
if (w <= 0 || h <= 0) return
|
||||||
|
ctx.save()
|
||||||
|
if (options?.background !== undefined) {
|
||||||
|
ctx.fillStyle = options.background
|
||||||
|
ctx.fillRect(0, 0, w, h)
|
||||||
|
}
|
||||||
|
const assetMap = new Map<string, Asset>()
|
||||||
|
for (const a of scene.assets) assetMap.set(a.id, a)
|
||||||
|
drawMaskedGroup(ctx, button.layers, assetMap, images, time, undefined, true)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
@@ -114,21 +114,29 @@ die states sind verschiedene zeitleisten innerhalb des buttons die umgeschaltet
|
|||||||
also ich würde stark bevorzugen wenn du den code ließt statt immer den vite dev zu nehmen
|
also ich würde stark bevorzugen wenn du den code ließt statt immer den vite dev zu nehmen
|
||||||
ich kann das visuell kontrollieren und dir screenshots von den sachen machen. und der handle ist genau wie vorher
|
ich kann das visuell kontrollieren und dir screenshots von den sachen machen. und der handle ist genau wie vorher
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
diese spur darf nur erscheinen wenn man überhaupt schon mindestens einen clip hat.
|
diese spur darf nur erscheinen wenn man überhaupt schon mindestens einen clip hat.
|
||||||
<div data-v-228c0a9d="" class="track clip-track-dropzone"><span data-v-228c0a9d="" class="dropzone-hint">Drop a clip here to add a new track</span></div>
|
<div data-v-228c0a9d="" class="track clip-track-dropzone"><span data-v-228c0a9d="" class="dropzone-hint">Drop a clip here to add a new track</span></div>
|
||||||
und der text muss immer zentriert stehen bleiben und darf nicht horizontal mit der timeline mitgescrollt werden
|
und der text muss immer zentriert stehen bleiben und darf nicht horizontal mit der timeline mitgescrollt werden
|
||||||
|
|
||||||
|
|
||||||
im button editor zeigt das preview mit angewendeten masken die hauptbühne an - nicht die button-bühne
|
im button editor zeigt das preview mit angewendeten masken die hauptbühne an - nicht die button-bühne
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
die farbe einer gruppe muss automatisch auf alle kinder angewendet werden. es sei denn man färbt einen track HINTERHER anders ein
|
||||||
|
|
||||||
|
im button dürfen keyframes nur auf den 4 states einrasten
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen
|
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen
|
||||||
Reference in new issue
Block a user