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:
thandClaude Opus 4.7 committed 2026-04-22 18:32:18 +02:00
1 parent fcb8edbb88
commit 8895fc76ef
8 files changed
+311 -23

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
"version": "0.10.3",
"version": "0.10.4",
"type": "module",
"scripts": {
"dev": "vite",
+8 -2
View File
@@ -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)
+24 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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',