diff --git a/package.json b/package.json index 8f5c960..dcc0e7f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.10.3", + "version": "0.10.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index fbb6dcd..05f42d3 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -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(() => { // 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(() => { 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) diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index a448ed9..50e4b45 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -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(() => { > { + // 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() diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index a9a437e..a4fe768 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -10,7 +10,7 @@ function isProject(p: ExportPayload): p is Project { return Array.isArray((p as Partial).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;bkia.time+EPS && 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 diff --git a/src/lib/render.ts b/src/lib/render.ts index 5daa46d..415e655 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -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> { const result: Record = {} @@ -131,7 +131,7 @@ function drawLayers( images: Record, time: number, buttonStates?: Map, - 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, 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, time: number, buttonStates?: Map, - 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() diff --git a/src/stores/scene.ts b/src/stores/scene.ts index f91b8ea..92b4f94 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -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',