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",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.3",
|
"version": "0.10.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
PhTrash,
|
PhTrash,
|
||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
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 { importImageFile, loadImage } from '@/lib/fileLoader'
|
||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
|
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||||
@@ -501,6 +501,9 @@ function buttonStackForTime(
|
|||||||
const layers = asset.layers.slice().reverse()
|
const layers = asset.layers.slice().reverse()
|
||||||
for (const l of layers) {
|
for (const l of layers) {
|
||||||
if (!l.visible) continue
|
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)
|
const a = assetMap.value.get(l.assetId)
|
||||||
if (!a) continue
|
if (!a) continue
|
||||||
out.push({ layer: l, asset: a, props: sampleLayer(l, stateTime) })
|
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[]>(() => {
|
const stackItems = computed<StackItem[]>(() => {
|
||||||
// Button-asset edit: show only the active state's layer stack. In the
|
// 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
|
// '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) {
|
if (store.activeButtonAssetId) {
|
||||||
const out: StackItem[] = []
|
const out: StackItem[] = []
|
||||||
const ba = store.activeButtonAsset
|
const ba = store.activeButtonAsset
|
||||||
@@ -764,6 +769,7 @@ const stackItems = computed<StackItem[]>(() => {
|
|||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
if (!layer.visible) continue
|
if (!layer.visible) continue
|
||||||
if (layer.editorHidden) continue
|
if (layer.editorHidden) continue
|
||||||
|
if (!isButtonLayerActiveAt(layer, store.playhead)) continue
|
||||||
const asset = assetMap.value.get(layer.assetId)
|
const asset = assetMap.value.get(layer.assetId)
|
||||||
if (!asset) continue
|
if (!asset) continue
|
||||||
const props = applyLivePose(sampleLayer(layer, store.playhead), layer.id)
|
const props = applyLivePose(sampleLayer(layer, store.playhead), layer.id)
|
||||||
|
|||||||
@@ -1138,11 +1138,33 @@ interface KfSegment {
|
|||||||
tail?: boolean
|
tail?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentsOf(kfs: Keyframe[], duration: number): KfSegment[] {
|
function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSegment[] {
|
||||||
if (kfs.length === 0) return []
|
if (kfs.length === 0) return []
|
||||||
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
||||||
const out: KfSegment[] = []
|
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
|
// Single keyframe means the layer is always visible (no animation
|
||||||
// range), so the line should span the entire timeline as a solid
|
// range), so the line should span the entire timeline as a solid
|
||||||
// dim "on-stage" indicator — same treatment as a still-frame tail.
|
// 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 class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||||
<span
|
<span
|
||||||
v-for="seg in segmentsOf(track.kfs, duration)"
|
v-for="seg in segmentsOf(track.kfs, duration, !!store.activeButtonAssetId)"
|
||||||
:key="seg.id"
|
:key="seg.id"
|
||||||
class="kf-seg"
|
class="kf-seg"
|
||||||
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
|
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
|
||||||
|
|||||||
+186
-1
@@ -12,7 +12,7 @@ import {
|
|||||||
type ActionRuntime,
|
type ActionRuntime,
|
||||||
} from '@/lib/actions'
|
} from '@/lib/actions'
|
||||||
import { parseScript } from '@/lib/scriptDsl'
|
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 { 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'
|
||||||
@@ -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', () => {
|
suite('button asset — data model + edit mode', () => {
|
||||||
test('addButtonAsset creates asset with empty layers and hitArea', () => {
|
test('addButtonAsset creates asset with empty layers and hitArea', () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
|
|||||||
+32
-5
@@ -10,7 +10,7 @@ function isProject(p: ExportPayload): p is Project {
|
|||||||
return Array.isArray((p as Partial<Project>).scenes)
|
return Array.isArray((p as Partial<Project>).scenes)
|
||||||
}
|
}
|
||||||
|
|
||||||
const RUNTIME_VERSION = '24'
|
const RUNTIME_VERSION = '25'
|
||||||
|
|
||||||
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;
|
||||||
@@ -660,11 +660,13 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
}
|
}
|
||||||
if(closed) tctx.closePath();
|
if(closed) tctx.closePath();
|
||||||
}
|
}
|
||||||
function drawMaskShape(ctxm,layer,time,alwaysActive){
|
function drawMaskShape(ctxm,layer,time,buttonMode){
|
||||||
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(!alwaysActive && !layerActiveAt(layer,time)) return;
|
if(buttonMode){
|
||||||
|
if(!buttonLayerActiveAt(layer,time)) return;
|
||||||
|
} else if(!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);
|
||||||
@@ -719,10 +721,35 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
var EPS=1e-6;
|
var EPS=1e-6;
|
||||||
return time>=mn-EPS && time<=mx+EPS;
|
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];
|
var asset=assetMap[layer.assetId];
|
||||||
if(!asset||!layer.visible)return;
|
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);
|
var p=sample(layer,time);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
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
|
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 {
|
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
|
||||||
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
|
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
|
||||||
if (!layer.separated) return main
|
if (!layer.separated) return main
|
||||||
|
|||||||
+17
-11
@@ -1,6 +1,6 @@
|
|||||||
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene } from '@/types/scene'
|
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene } from '@/types/scene'
|
||||||
import { BUTTON_STATE_TIMES } 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>> {
|
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
||||||
const result: Record<string, HTMLImageElement> = {}
|
const result: Record<string, HTMLImageElement> = {}
|
||||||
@@ -131,7 +131,7 @@ function drawLayers(
|
|||||||
images: Record<string, HTMLImageElement>,
|
images: Record<string, HTMLImageElement>,
|
||||||
time: number,
|
time: number,
|
||||||
buttonStates?: Map<string, ButtonState>,
|
buttonStates?: Map<string, ButtonState>,
|
||||||
alwaysActive?: boolean,
|
buttonMode?: boolean,
|
||||||
) {
|
) {
|
||||||
for (let i = layers.length - 1; i >= 0; i--) {
|
for (let i = layers.length - 1; i >= 0; i--) {
|
||||||
const layer = layers[i]
|
const layer = layers[i]
|
||||||
@@ -142,7 +142,9 @@ function drawLayers(
|
|||||||
// NOT read this flag, so fullscreen preview and exports keep
|
// NOT read this flag, so fullscreen preview and exports keep
|
||||||
// rendering the layer.
|
// rendering the layer.
|
||||||
if (layer.editorHidden) continue
|
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)
|
const asset = assetMap.get(layer.assetId)
|
||||||
if (!asset) continue
|
if (!asset) continue
|
||||||
const p = sampleLayer(layer, time)
|
const p = sampleLayer(layer, time)
|
||||||
@@ -260,7 +262,7 @@ function drawMaskShape(
|
|||||||
layer: Layer,
|
layer: Layer,
|
||||||
assetMap: Map<string, Asset>,
|
assetMap: Map<string, Asset>,
|
||||||
time: number,
|
time: number,
|
||||||
alwaysActive?: boolean,
|
buttonMode?: boolean,
|
||||||
) {
|
) {
|
||||||
const asset = assetMap.get(layer.assetId)
|
const asset = assetMap.get(layer.assetId)
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
@@ -271,7 +273,9 @@ function drawMaskShape(
|
|||||||
asset.kind !== 'polygon'
|
asset.kind !== 'polygon'
|
||||||
)
|
)
|
||||||
return
|
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)
|
const p = sampleLayer(layer, time)
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.translate(p.x, p.y)
|
ctx.translate(p.x, p.y)
|
||||||
@@ -314,12 +318,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,
|
buttonMode?: 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, alwaysActive)
|
drawLayers(ctx, children, assetMap, images, time, buttonStates, buttonMode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,17 +334,19 @@ 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, alwaysActive)
|
drawLayers(ctx, content, assetMap, images, time, buttonStates, buttonMode)
|
||||||
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, alwaysActive)
|
drawLayers(offCtx, content, assetMap, images, time, buttonStates, buttonMode)
|
||||||
for (const mask of masks) {
|
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.save()
|
||||||
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
||||||
drawMaskShape(offCtx, mask, assetMap, time, alwaysActive)
|
drawMaskShape(offCtx, mask, assetMap, time, buttonMode)
|
||||||
offCtx.restore()
|
offCtx.restore()
|
||||||
}
|
}
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
|||||||
+7
-1
@@ -533,7 +533,13 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
y: canvas.height / 2,
|
y: canvas.height / 2,
|
||||||
}
|
}
|
||||||
if (asset && 'fill' in asset) base.fill = asset.fill
|
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 = {
|
const layer: Layer = {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
name: name ?? asset?.name ?? 'Layer',
|
name: name ?? asset?.name ?? 'Layer',
|
||||||
|
|||||||
Reference in new issue
Block a user