0.10.4: button still-frames render only on their own state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fcb8edbb88
commit
8895fc76ef
8 files changed
+311
-23
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.10.3",
|
||||
"version": "0.10.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
PhTrash,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { isLayerActiveAt, sampleLayer } from '@/lib/interpolate'
|
||||
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from '@/lib/interpolate'
|
||||
import { importImageFile, loadImage } from '@/lib/fileLoader'
|
||||
import { useClipartStore } from '@/stores/clipart'
|
||||
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||
@@ -501,6 +501,9 @@ function buttonStackForTime(
|
||||
const layers = asset.layers.slice().reverse()
|
||||
for (const l of layers) {
|
||||
if (!l.visible) continue
|
||||
// Button-state visibility: still-frame kfs render only on their
|
||||
// own state, not held into neighbouring states.
|
||||
if (!isButtonLayerActiveAt(l, stateTime)) continue
|
||||
const a = assetMap.value.get(l.assetId)
|
||||
if (!a) continue
|
||||
out.push({ layer: l, asset: a, props: sampleLayer(l, stateTime) })
|
||||
@@ -754,7 +757,9 @@ function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps
|
||||
const stackItems = computed<StackItem[]>(() => {
|
||||
// Button-asset edit: show only the active state's layer stack. In the
|
||||
// 'hit' cell show the normal state's layers as an onion-skin
|
||||
// underneath the hit-area gizmos.
|
||||
// underneath the hit-area gizmos. Visibility follows the
|
||||
// button-specific rule: a still-frame kf renders only on its own
|
||||
// state, not held into subsequent states.
|
||||
if (store.activeButtonAssetId) {
|
||||
const out: StackItem[] = []
|
||||
const ba = store.activeButtonAsset
|
||||
@@ -764,6 +769,7 @@ const stackItems = computed<StackItem[]>(() => {
|
||||
for (const layer of layers) {
|
||||
if (!layer.visible) continue
|
||||
if (layer.editorHidden) continue
|
||||
if (!isButtonLayerActiveAt(layer, store.playhead)) continue
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (!asset) continue
|
||||
const props = applyLivePose(sampleLayer(layer, store.playhead), layer.id)
|
||||
|
||||
@@ -1138,11 +1138,33 @@ interface KfSegment {
|
||||
tail?: boolean
|
||||
}
|
||||
|
||||
function segmentsOf(kfs: Keyframe[], duration: number): KfSegment[] {
|
||||
function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSegment[] {
|
||||
if (kfs.length === 0) return []
|
||||
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
||||
const out: KfSegment[] = []
|
||||
|
||||
// Button-state timelines have discrete state frames rather than a
|
||||
// continuous timeline. A still-frame kf only renders on its own
|
||||
// state; the layer is NOT held into the following states. The
|
||||
// connector line must stop at the still, not span past it.
|
||||
if (buttonMode) {
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i]
|
||||
const b = sorted[i + 1]
|
||||
// Stills terminate any interpolation — no connector emerges from
|
||||
// a hold kf toward the next state.
|
||||
if (a.hold) continue
|
||||
out.push({
|
||||
id: `${a.id}>${b.id}`,
|
||||
fromTime: a.time,
|
||||
toTime: b.time,
|
||||
easing: b.easing,
|
||||
held: false,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Single keyframe means the layer is always visible (no animation
|
||||
// range), so the line should span the entire timeline as a solid
|
||||
// dim "on-stage" indicator — same treatment as a still-frame tail.
|
||||
@@ -2953,7 +2975,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||
<span
|
||||
v-for="seg in segmentsOf(track.kfs, duration)"
|
||||
v-for="seg in segmentsOf(track.kfs, duration, !!store.activeButtonAssetId)"
|
||||
:key="seg.id"
|
||||
class="kf-seg"
|
||||
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
|
||||
|
||||
+186
-1
@@ -12,7 +12,7 @@ import {
|
||||
type ActionRuntime,
|
||||
} from '@/lib/actions'
|
||||
import { parseScript } from '@/lib/scriptDsl'
|
||||
import type { Action, ButtonAsset, HitShape } from '@/types/scene'
|
||||
import type { Action, ButtonAsset, HitShape, Layer } from '@/types/scene'
|
||||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||
import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
@@ -6697,6 +6697,191 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('button asset — still-frame visibility gate', () => {
|
||||
// Button assets have 4 discrete state times (1,2,3,4). A still-frame
|
||||
// (hold) keyframe must only be visible on its exact state frame —
|
||||
// not held forward to the neighbours as on regular timelines.
|
||||
test('isButtonLayerActiveAt: hold kf at t=2 only visible at t=2', async () => {
|
||||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||||
const layer: Layer = {
|
||||
id: 'l1',
|
||||
name: '',
|
||||
assetId: 'a',
|
||||
visible: true,
|
||||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{
|
||||
id: 'k1',
|
||||
time: 2,
|
||||
props: { x: 10 },
|
||||
easing: 'linear',
|
||||
hold: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
|
||||
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
|
||||
expect(isButtonLayerActiveAt(layer, 4)).toBe(false)
|
||||
})
|
||||
|
||||
test('isButtonLayerActiveAt: non-hold kfs at t=1 and t=4 still interpolate between states', async () => {
|
||||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||||
const layer: Layer = {
|
||||
id: 'l',
|
||||
name: '',
|
||||
assetId: 'a',
|
||||
visible: true,
|
||||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ id: 'k1', time: 1, props: { x: 0 }, easing: 'linear' },
|
||||
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
|
||||
],
|
||||
}
|
||||
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
|
||||
})
|
||||
|
||||
test('isButtonLayerActiveAt: a hold kf followed by a regular kf shows only on both exact frames', async () => {
|
||||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||||
const layer: Layer = {
|
||||
id: 'l',
|
||||
name: '',
|
||||
assetId: 'a',
|
||||
visible: true,
|
||||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ id: 'k1', time: 2, props: { x: 0 }, easing: 'linear', hold: true },
|
||||
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
|
||||
],
|
||||
}
|
||||
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
|
||||
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
|
||||
// hold at t=2 → the region (2, 4) is "holding in silence", invisible.
|
||||
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
|
||||
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
|
||||
})
|
||||
|
||||
test('isButtonLayerActiveAt: no keyframes → always visible (static layer)', async () => {
|
||||
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
|
||||
const layer: Layer = {
|
||||
id: 'l',
|
||||
name: '',
|
||||
assetId: 'a',
|
||||
visible: true,
|
||||
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [],
|
||||
}
|
||||
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
|
||||
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
|
||||
})
|
||||
|
||||
test('runtime engine embeds button-specific still-frame visibility', () => {
|
||||
const r = generateRuntimeScript()
|
||||
expect(r).toContain('buttonLayerActiveAt')
|
||||
})
|
||||
|
||||
test('timeline segments skip the connector line before a still-frame kf in button mode', async () => {
|
||||
// In button-edit the state frames are discrete: the connector
|
||||
// line that spans between kfs on a regular timeline must NOT
|
||||
// be drawn from a preceding kf into a hold kf — that would
|
||||
// suggest the layer is on-screen across the intervening states.
|
||||
// Pin the button-mode branch of segmentsOf structurally: three
|
||||
// kfs, middle one hold → no segment coming INTO the still.
|
||||
const { default: T } = await import('@/components/Timeline.vue')
|
||||
void T
|
||||
// Reach into the private helper via a minimal contract: mount
|
||||
// Timeline inside a button-asset-edit context and inspect the
|
||||
// rendered .kf-seg elements for the track.
|
||||
const s = makeFreshStore()
|
||||
const btn = s.addButtonAsset('B', 100, 100)
|
||||
s.enterButtonAssetEdit(btn.id)
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
|
||||
const layer = s.addLayer(a.id)!
|
||||
// Lay out three kfs: normal (non-hold), hover (hold), disabled (non-hold).
|
||||
layer.keyframes = [
|
||||
{ id: 'k1', time: BUTTON_STATE_TIMES.normal, props: {}, easing: 'linear' },
|
||||
{ id: 'k2', time: BUTTON_STATE_TIMES.hover, props: {}, easing: 'linear', hold: true },
|
||||
{ id: 'k3', time: BUTTON_STATE_TIMES.disabled, props: {}, easing: 'linear' },
|
||||
]
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const segs = handle.host.querySelectorAll('.kf-seg')
|
||||
// Expected segments in button-mode:
|
||||
// 1→2 (non-hold kf1 to hold kf2): drawn (interpolation into still)
|
||||
// …actually no: hold k2 terminates *emerging* lines, not
|
||||
// incoming. Only the OUT-segments from a hold are skipped.
|
||||
// 2→3 (hold kf2 to non-hold kf3): SKIPPED (no line out of still).
|
||||
expect(segs.length).toBe(1)
|
||||
s.exitButtonAssetEdit()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('addLayer in button-edit seeds the initial kf on the ACTIVE state, not always normal', () => {
|
||||
const s = makeFreshStore()
|
||||
const btn = s.addButtonAsset('B', 100, 100)
|
||||
s.enterButtonAssetEdit(btn.id)
|
||||
s.setActiveButtonState('disabled')
|
||||
const asset = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||||
const layer = s.addLayer(asset.id)!
|
||||
expect(layer.keyframes).toHaveLength(1)
|
||||
expect(layer.keyframes[0].time).toBe(BUTTON_STATE_TIMES.disabled)
|
||||
s.exitButtonAssetEdit()
|
||||
void btn
|
||||
})
|
||||
|
||||
test('drawButtonFrame renders a hold-kf layer ONLY on its own state frame', () => {
|
||||
// End-to-end UI test: construct a button with a single rect
|
||||
// whose layer has a hold-keyframe at the hover state. Then
|
||||
// rasterise `drawButtonFrame` against each of the 4 state times
|
||||
// and verify the rect's centre pixel is red only at hover and
|
||||
// transparent on every other state.
|
||||
const s = makeFreshStore()
|
||||
const btn = s.addButtonAsset('B', 100, 100)
|
||||
s.enterButtonAssetEdit(btn.id)
|
||||
const red = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'R',
|
||||
width: 100,
|
||||
height: 100,
|
||||
fill: '#ff0000',
|
||||
})
|
||||
const layer = s.addLayer(red.id)!
|
||||
// Replace the default normal-state kf with a single hold-kf at hover.
|
||||
layer.keyframes = [
|
||||
{
|
||||
id: 'k',
|
||||
time: BUTTON_STATE_TIMES.hover,
|
||||
props: {
|
||||
x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1,
|
||||
},
|
||||
easing: 'linear',
|
||||
hold: true,
|
||||
},
|
||||
]
|
||||
s.exitButtonAssetEdit()
|
||||
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
|
||||
|
||||
function probe(stateTime: number): boolean {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawButtonFrame(ctx, button, s.scene, stateTime, {})
|
||||
const data = ctx.getImageData(50, 50, 1, 1).data
|
||||
// Red + opaque = visible. Any other reading (alpha 0, or colour
|
||||
// washed out) → not rendered.
|
||||
return data[0] > 200 && data[3] > 200
|
||||
}
|
||||
expect(probe(BUTTON_STATE_TIMES.normal)).toBe(false)
|
||||
expect(probe(BUTTON_STATE_TIMES.hover)).toBe(true)
|
||||
expect(probe(BUTTON_STATE_TIMES.pressed)).toBe(false)
|
||||
expect(probe(BUTTON_STATE_TIMES.disabled)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
suite('button asset — data model + edit mode', () => {
|
||||
test('addButtonAsset creates asset with empty layers and hitArea', () => {
|
||||
const s = makeFreshStore()
|
||||
|
||||
+32
-5
@@ -10,7 +10,7 @@ function isProject(p: ExportPayload): p is Project {
|
||||
return Array.isArray((p as Partial<Project>).scenes)
|
||||
}
|
||||
|
||||
const RUNTIME_VERSION = '24'
|
||||
const RUNTIME_VERSION = '25'
|
||||
|
||||
const ENGINE_SOURCE = `(function(){
|
||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||
@@ -660,11 +660,13 @@ const ENGINE_SOURCE = `(function(){
|
||||
}
|
||||
if(closed) tctx.closePath();
|
||||
}
|
||||
function drawMaskShape(ctxm,layer,time,alwaysActive){
|
||||
function drawMaskShape(ctxm,layer,time,buttonMode){
|
||||
var asset=assetMap[layer.assetId];
|
||||
if(!asset) return;
|
||||
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
|
||||
if(!alwaysActive && !layerActiveAt(layer,time)) return;
|
||||
if(buttonMode){
|
||||
if(!buttonLayerActiveAt(layer,time)) return;
|
||||
} else if(!layerActiveAt(layer,time)) return;
|
||||
var p=sample(layer,time);
|
||||
ctxm.save();
|
||||
ctxm.translate(p.x,p.y);
|
||||
@@ -719,10 +721,35 @@ const ENGINE_SOURCE = `(function(){
|
||||
var EPS=1e-6;
|
||||
return time>=mn-EPS && time<=mx+EPS;
|
||||
}
|
||||
function drawLayer(layer,time,alwaysActive){
|
||||
// Button-asset visibility: a still-frame (hold) kf shows ONLY on
|
||||
// its own state frame, not on the state frames that follow. Mirrors
|
||||
// isButtonLayerActiveAt in interpolate.ts.
|
||||
function buttonLayerActiveAt(layer,time){
|
||||
var EPS=1e-6;
|
||||
var kfs=[];
|
||||
var mk=layer.keyframes||[];
|
||||
for(var bki=0;bki<mk.length;bki++) kfs.push(mk[bki]);
|
||||
var pk=layer.positionKeyframes||[];
|
||||
for(var bkj=0;bkj<pk.length;bkj++) kfs.push(pk[bkj]);
|
||||
var rk=layer.rotationKeyframes||[];
|
||||
for(var bkl=0;bkl<rk.length;bkl++) kfs.push(rk[bkl]);
|
||||
if(!kfs.length) return true;
|
||||
kfs.sort(function(x,y){return x.time-y.time;});
|
||||
for(var bkm=0;bkm<kfs.length;bkm++){
|
||||
if(Math.abs(kfs[bkm].time-time)<EPS) return true;
|
||||
}
|
||||
for(var bkn=0;bkn<kfs.length-1;bkn++){
|
||||
var a=kfs[bkn], b=kfs[bkn+1];
|
||||
if(time>a.time+EPS && time<b.time-EPS) return !a.hold;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function drawLayer(layer,time,buttonMode){
|
||||
var asset=assetMap[layer.assetId];
|
||||
if(!asset||!layer.visible)return;
|
||||
if(!alwaysActive&&!layerActiveAt(layer,time))return;
|
||||
if(buttonMode){
|
||||
if(!buttonLayerActiveAt(layer,time)) return;
|
||||
} else if(!layerActiveAt(layer,time)) return;
|
||||
var p=sample(layer,time);
|
||||
ctx.save();
|
||||
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
||||
|
||||
@@ -174,6 +174,42 @@ export function isLayerActiveAt(layer: Layer, time: number): boolean {
|
||||
return time >= range.min - EPSILON && time <= range.max + EPSILON
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility gate for layers INSIDE a button asset. The 4 discrete
|
||||
* state times (normal / hover / pressed / disabled) are treated as
|
||||
* exact frames — a still-frame (hold) keyframe renders only on its own
|
||||
* state and is invisible on neighbours, not "held forward until the
|
||||
* next keyframe" as on a regular timeline.
|
||||
*
|
||||
* Rules:
|
||||
* - No keyframes → static from base, always visible.
|
||||
* - `time` matches a keyframe's time → visible.
|
||||
* - `time` sits strictly between two adjacent keyframes → visible
|
||||
* only when the left bracket is NOT a hold (i.e. an interpolation
|
||||
* region, not a held-into-silence region).
|
||||
* - Before the first keyframe or after the last → hidden.
|
||||
*/
|
||||
export function isButtonLayerActiveAt(layer: Layer, time: number): boolean {
|
||||
const EPSILON = 1e-6
|
||||
const kfs: Keyframe[] = []
|
||||
for (const k of layer.keyframes) kfs.push(k)
|
||||
if (layer.positionKeyframes) for (const k of layer.positionKeyframes) kfs.push(k)
|
||||
if (layer.rotationKeyframes) for (const k of layer.rotationKeyframes) kfs.push(k)
|
||||
if (kfs.length === 0) return true
|
||||
kfs.sort((a, b) => a.time - b.time)
|
||||
for (const kf of kfs) {
|
||||
if (Math.abs(kf.time - time) < EPSILON) return true
|
||||
}
|
||||
for (let i = 0; i < kfs.length - 1; i++) {
|
||||
const a = kfs[i]
|
||||
const b = kfs[i + 1]
|
||||
if (time > a.time + EPSILON && time < b.time - EPSILON) {
|
||||
return !a.hold
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
|
||||
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
|
||||
if (!layer.separated) return main
|
||||
|
||||
+17
-11
@@ -1,6 +1,6 @@
|
||||
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene } from '@/types/scene'
|
||||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||
import { isLayerActiveAt, sampleLayer } from './interpolate'
|
||||
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate'
|
||||
|
||||
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
||||
const result: Record<string, HTMLImageElement> = {}
|
||||
@@ -131,7 +131,7 @@ function drawLayers(
|
||||
images: Record<string, HTMLImageElement>,
|
||||
time: number,
|
||||
buttonStates?: Map<string, ButtonState>,
|
||||
alwaysActive?: boolean,
|
||||
buttonMode?: boolean,
|
||||
) {
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i]
|
||||
@@ -142,7 +142,9 @@ function drawLayers(
|
||||
// NOT read this flag, so fullscreen preview and exports keep
|
||||
// rendering the layer.
|
||||
if (layer.editorHidden) continue
|
||||
if (!alwaysActive && !isLayerActiveAt(layer, time)) continue
|
||||
if (buttonMode) {
|
||||
if (!isButtonLayerActiveAt(layer, time)) continue
|
||||
} else if (!isLayerActiveAt(layer, time)) continue
|
||||
const asset = assetMap.get(layer.assetId)
|
||||
if (!asset) continue
|
||||
const p = sampleLayer(layer, time)
|
||||
@@ -260,7 +262,7 @@ function drawMaskShape(
|
||||
layer: Layer,
|
||||
assetMap: Map<string, Asset>,
|
||||
time: number,
|
||||
alwaysActive?: boolean,
|
||||
buttonMode?: boolean,
|
||||
) {
|
||||
const asset = assetMap.get(layer.assetId)
|
||||
if (!asset) return
|
||||
@@ -271,7 +273,9 @@ function drawMaskShape(
|
||||
asset.kind !== 'polygon'
|
||||
)
|
||||
return
|
||||
if (!alwaysActive && !isLayerActiveAt(layer, time)) return
|
||||
if (buttonMode) {
|
||||
if (!isButtonLayerActiveAt(layer, time)) return
|
||||
} else if (!isLayerActiveAt(layer, time)) return
|
||||
const p = sampleLayer(layer, time)
|
||||
ctx.save()
|
||||
ctx.translate(p.x, p.y)
|
||||
@@ -314,12 +318,12 @@ function drawMaskedGroup(
|
||||
images: Record<string, HTMLImageElement>,
|
||||
time: number,
|
||||
buttonStates?: Map<string, ButtonState>,
|
||||
alwaysActive?: boolean,
|
||||
buttonMode?: boolean,
|
||||
) {
|
||||
const masks = children.filter((l) => l.isMask)
|
||||
const content = children.filter((l) => !l.isMask)
|
||||
if (masks.length === 0) {
|
||||
drawLayers(ctx, children, assetMap, images, time, buttonStates, alwaysActive)
|
||||
drawLayers(ctx, children, assetMap, images, time, buttonStates, buttonMode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -330,17 +334,19 @@ function drawMaskedGroup(
|
||||
off.height = h
|
||||
const offCtx = off.getContext('2d')
|
||||
if (!offCtx) {
|
||||
drawLayers(ctx, content, assetMap, images, time, buttonStates, alwaysActive)
|
||||
drawLayers(ctx, content, assetMap, images, time, buttonStates, buttonMode)
|
||||
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, buttonStates, alwaysActive)
|
||||
drawLayers(offCtx, content, assetMap, images, time, buttonStates, buttonMode)
|
||||
for (const mask of masks) {
|
||||
if (!alwaysActive && !isLayerActiveAt(mask, time)) continue
|
||||
if (buttonMode) {
|
||||
if (!isButtonLayerActiveAt(mask, time)) continue
|
||||
} else if (!isLayerActiveAt(mask, time)) continue
|
||||
offCtx.save()
|
||||
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
||||
drawMaskShape(offCtx, mask, assetMap, time, alwaysActive)
|
||||
drawMaskShape(offCtx, mask, assetMap, time, buttonMode)
|
||||
offCtx.restore()
|
||||
}
|
||||
ctx.save()
|
||||
|
||||
+7
-1
@@ -533,7 +533,13 @@ export const useSceneStore = defineStore('scene', {
|
||||
y: canvas.height / 2,
|
||||
}
|
||||
if (asset && 'fill' in asset) base.fill = asset.fill
|
||||
const initialTime = this.activeButtonAssetId ? BUTTON_STATE_TIMES.normal : 0
|
||||
// In button-edit mode, seed the initial keyframe on the state
|
||||
// the user is currently viewing — not always on 'normal'. Drops
|
||||
// in the 'disabled' pane then stay there; the layer doesn't
|
||||
// silently pop onto the normal frame.
|
||||
const initialTime = this.activeButtonAssetId
|
||||
? BUTTON_STATE_TIMES[this.activeButtonState ?? 'normal']
|
||||
: 0
|
||||
const layer: Layer = {
|
||||
id: uid(),
|
||||
name: name ?? asset?.name ?? 'Layer',
|
||||
|
||||
Reference in new issue
Block a user