diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 16dcd8c..0604c83 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -23,7 +23,7 @@ import { useButtonRuntime, } from '@/composables/useButtonRuntime' import { evalAction } from '@/lib/actions' -import { lerpProps } from '@/lib/interpolate' +import { blendButtonStates } from '@/lib/buttonBlend' import { t } from '@/i18n' import { useKfSelection } from '@/composables/useKfSelection' import { showLocked } from '@/composables/useShowLocked' @@ -226,50 +226,15 @@ function sampleStateLayers( return out } -/** - * Blend the "from" state's layers into the "to" state's layers by layer - * name. Matched pairs lerp their props; unmatched from-layers fade out, - * unmatched to-layers fade in. - */ function blendStates( clip: ButtonClip, from: ButtonState, to: ButtonState, u: number, ): ButtonRenderItem['layers'] { - const fromLayers = clip.states[from] ?? [] - const toLayers = clip.states[to] ?? [] - const fromByName = new Map() - const toByName = new Map() - for (const l of fromLayers) fromByName.set(l.name, l) - for (const l of toLayers) toByName.set(l.name, l) - const seen = new Set() - const out: ButtonRenderItem['layers'] = [] - // Preserve to-state order primarily, with unmatched from-layers pinned - // underneath so the new state visually rises into place. - for (const l of fromLayers) { - if (toByName.has(l.name)) continue - const asset = store.scene.assets.find((a) => a.id === l.assetId) - if (!asset) continue - const base = sampleLayer(l, 0) - out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * (1 - u) } }) - } - for (const l of toLayers) { - const asset = store.scene.assets.find((a) => a.id === l.assetId) - if (!asset) continue - seen.add(l.name) - const fromLayer = fromByName.get(l.name) - if (fromLayer) { - const a = sampleLayer(fromLayer, 0) - const b = sampleLayer(l, 0) - out.push({ layer: l, asset, props: lerpProps(a, b, u) }) - } else { - const base = sampleLayer(l, 0) - out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * u } }) - } - } - void seen - return out + return blendButtonStates(clip, from, to, u, (id) => + store.scene.assets.find((a) => a.id === id), + ) } const activeButtonInstances = computed(() => { diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 07c0c6b..9dc6139 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -4,6 +4,7 @@ import { applyButtonStateChange, useButtonRuntime, } from '@/composables/useButtonRuntime' +import { blendButtonStates } from '@/lib/buttonBlend' import { buildMarkerMap, evalAction, @@ -11,7 +12,7 @@ import { type ActionRuntime, } from '@/lib/actions' import { parseScript } from '@/lib/scriptDsl' -import type { Action } from '@/types/scene' +import type { Action, Asset, ButtonClip, Layer } from '@/types/scene' import { sampleLayer, lerpColor } from '@/lib/interpolate' import { snapToCandidates } from '@/lib/timelineSnap' import { @@ -3627,4 +3628,337 @@ export function registerAllTests(): void { expect(s.scene.scriptTracks?.find((x) => x.id === t.id)?.locked).toBe(undefined) }) }) + + suite('macro-clips — store coverage gaps', () => { + test('removeMarker on unknown id is a no-op', () => { + const s = makeFreshStore() + s.addMarker(1, 'A') + s.removeMarker('no-such-id') + expect(s.scene.markers).toHaveLength(1) + }) + + test('setMarkerTime / setMarkerColor on unknown id are no-ops', () => { + const s = makeFreshStore() + s.setMarkerTime('ghost', 5) + s.setMarkerColor('ghost', '#ff0000') + expect(s.scene.markers).toHaveLength(0) + }) + + test('addMarker accepts an initial color', () => { + const s = makeFreshStore() + const m = s.addMarker(1, 'A', '#ff8800') + expect(m.color).toBe('#ff8800') + }) + + test('removeScriptFrame / setScriptFrameActions / setScriptFrameTime handle unknown ids', () => { + const s = makeFreshStore() + // None of these should throw or mutate anything. + s.removeScriptFrame('nope') + s.setScriptFrameActions('nope', [{ type: 'play' }]) + s.setScriptFrameTime('nope', 1) + expect(s.scene.scriptFrames).toEqual([]) + }) + + test('removeButtonInstance removes the instance but keeps the clip', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('B') + const ct = s.addClipTrack() + const inst = s.addButtonInstance(bc.id, ct.id, 0)! + s.removeButtonInstance(inst.id) + expect(s.scene.buttonInstances).toHaveLength(0) + expect(s.scene.buttonClips).toHaveLength(1) + }) + + test('addButtonInstance defaults x/y to stage center', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('B') + const ct = s.addClipTrack() + const inst = s.addButtonInstance(bc.id, ct.id, 0)! + expect(inst.x).toBe(s.scene.canvas.width / 2) + expect(inst.y).toBe(s.scene.canvas.height / 2) + }) + + test('setButtonInstancePosition updates x/y', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('B') + const ct = s.addClipTrack() + const inst = s.addButtonInstance(bc.id, ct.id, 0)! + s.setButtonInstancePosition(inst.id, 42, 99) + const got = s.scene.buttonInstances?.find((x) => x.id === inst.id) + expect(got?.x).toBe(42) + expect(got?.y).toBe(99) + }) + + test('setButtonInstanceLabel("") clears the label', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('B') + const ct = s.addClipTrack() + const inst = s.addButtonInstance(bc.id, ct.id, 0)! + s.setButtonInstanceLabel(inst.id, 'play') + expect(s.setButtonInstanceLabel(inst.id, '')).toBe(true) + const got = s.scene.buttonInstances?.find((x) => x.id === inst.id) + expect(got?.label).toBe(undefined) + }) + + test('setButtonClipTransitions stores list and deletes it when empty', () => { + const s = makeFreshStore() + const bc = s.addButtonClip('B') + s.setButtonClipTransitions(bc.id, [ + { from: 'normal', to: 'hover', duration: 0.2, easing: 'easeOut' }, + ]) + const got = () => s.scene.buttonClips?.find((c) => c.id === bc.id) + expect(got()?.transitions).toHaveLength(1) + s.setButtonClipTransitions(bc.id, []) + expect(got()?.transitions).toBe(undefined) + }) + }) + + suite('macro-clips — button runtime edge cases', () => { + test('applyButtonStateChange is a no-op when target equals current', () => { + const rt = useButtonRuntime() + rt.reset() + applyButtonStateChange( + 'b1', + 'normal', + [{ from: 'normal', to: 'normal', duration: 1, easing: 'linear' }], + undefined, + ) + expect(rt.getState('b1')).toBe('normal') + expect(rt.transition.has('b1')).toBe(false) + }) + + test('applyButtonStateChange honors persisted disabled flag as the from-state', () => { + const rt = useButtonRuntime() + rt.reset() + // persistedDisabled=true → current 'disabled'; transition from disabled + // to normal should fire if defined. + applyButtonStateChange( + 'b1', + 'normal', + [{ from: 'disabled', to: 'normal', duration: 0.1, easing: 'linear' }], + true, + ) + expect(rt.transition.has('b1')).toBe(true) + rt.endTransition('b1') + }) + + test('reset() clears state, disabled and transitions', () => { + const rt = useButtonRuntime() + rt.setState('b1', 'pressed') + rt.setDisabled('b2', true) + rt.startTransition('b1', 'normal', 'hover', 1, 'linear', performance.now()) + rt.reset() + expect(rt.getState('b1')).toBe('normal') + expect(rt.isDisabled('b2')).toBe(false) + expect(rt.transition.size).toBe(0) + }) + + test('store.actionRuntime warns + skips for unknown button refs', () => { + const s = makeFreshStore() + const warned: string[] = [] + const origWarn = console.warn + console.warn = (...a: unknown[]) => { warned.push(a.map(String).join(' ')) } + try { + const ar = s.actionRuntime() + ar.setButtonState('ghost', 'pressed') + ar.setButtonDisabled('ghost', true) + } finally { + console.warn = origWarn + } + expect(warned.length >= 2).toBe(true) + }) + }) + + suite('macro-clips — blendButtonStates', () => { + function makeLayer(name: string, fill: string): Layer { + return { + id: `l-${name}-${fill}`, + name, + assetId: 'a1', + visible: true, + base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, fill }, + keyframes: [ + { + id: `kf-${name}-${fill}`, + time: 0, + props: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, fill }, + easing: 'linear', + }, + ], + } + } + const asset: Asset = { + id: 'a1', + kind: 'rect', + name: 'face', + width: 40, + height: 20, + fill: '#000', + } + const assetBy = (id: string) => (id === 'a1' ? asset : undefined) + + test('matched layer interpolates props via lerpProps', () => { + const clip: ButtonClip = { + id: 'bc', + name: 'b', + states: { + normal: [makeLayer('Face', '#ff0000')], + hover: [makeLayer('Face', '#00ff00')], + pressed: [], + disabled: [], + }, + } + const out = blendButtonStates(clip, 'normal', 'hover', 0.5, assetBy) + expect(out).toHaveLength(1) + expect(out[0].layer.name).toBe('Face') + // At u=0.5 the fill should be the mid of #ff0000 → #00ff00 (roughly). + const f = out[0].props.fill! + // Red and green channels should each be around 0x7f-ish + const n = parseInt(f.replace('#', ''), 16) + const r = (n >> 16) & 255 + const g = (n >> 8) & 255 + expect(r > 100 && r < 160).toBe(true) + expect(g > 100 && g < 160).toBe(true) + }) + + test('unmatched from-layer fades out, unmatched to-layer fades in', () => { + const clip: ButtonClip = { + id: 'bc', + name: 'b', + states: { + normal: [makeLayer('Only-N', '#ff0000')], + hover: [makeLayer('Only-H', '#00ff00')], + pressed: [], + disabled: [], + }, + } + const u = 0.25 + const out = blendButtonStates(clip, 'normal', 'hover', u, assetBy) + // First: unmatched from-layer, opacity * (1 - u) = 0.75 + expect(out[0].layer.name).toBe('Only-N') + expect(out[0].props.opacity).toBeCloseTo(1 - u, 3) + // Second: unmatched to-layer, opacity * u = 0.25 + expect(out[1].layer.name).toBe('Only-H') + expect(out[1].props.opacity).toBeCloseTo(u, 3) + }) + + test('u=0 is pure from-state, u=1 is pure to-state (matched layer)', () => { + const clip: ButtonClip = { + id: 'bc', + name: 'b', + states: { + normal: [makeLayer('Face', '#ff0000')], + hover: [makeLayer('Face', '#00ff00')], + pressed: [], + disabled: [], + }, + } + const atZero = blendButtonStates(clip, 'normal', 'hover', 0, assetBy) + expect(atZero[0].props.fill).toBe('#ff0000') + const atOne = blendButtonStates(clip, 'normal', 'hover', 1, assetBy) + expect(atOne[0].props.fill).toBe('#00ff00') + }) + + test('skips layers whose asset id does not resolve', () => { + const clip: ButtonClip = { + id: 'bc', + name: 'b', + states: { + normal: [makeLayer('Face', '#ff0000')], + hover: [makeLayer('Face', '#00ff00')], + pressed: [], + disabled: [], + }, + } + const out = blendButtonStates(clip, 'normal', 'hover', 0.5, () => undefined) + expect(out).toHaveLength(0) + }) + }) + + suite('macro-clips — DSL edge cases', () => { + test('empty source yields no actions and no errors', () => { + const { actions, errors } = parseScript('') + expect(actions).toHaveLength(0) + expect(errors).toHaveLength(0) + }) + + test('only comments or whitespace yields no actions', () => { + const { actions, errors } = parseScript(' \n\n// note\n // more\n') + expect(actions).toHaveLength(0) + expect(errors).toHaveLength(0) + }) + + test('trailing blank lines do not produce phantom errors', () => { + const { actions, errors } = parseScript('play\n\n\n') + expect(actions).toHaveLength(1) + expect(errors).toHaveLength(0) + }) + + test('state command with wrong ref prefix reports error', () => { + const { errors } = parseScript('state label pressed') + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain('btn:') + }) + + test('disable without argument reports error', () => { + const { errors } = parseScript('disable') + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain('disable') + }) + + test('jump with empty @marker reports error', () => { + const { errors } = parseScript('jump @') + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain('marker') + }) + }) + + suite('macro-clips — tick firing robustness', () => { + test('multiple frames on different tracks fire in time order', async () => { + const s = makeFreshStore() + s.setDuration(10) + const t1 = s.addScriptTrack('A') + const t2 = s.addScriptTrack('B') + const log: number[] = [] + // Three frames: 0.2 (t2), 0.5 (t1), 0.8 (t2) + // We can verify ordering via jumpTo actions that push into a list… + // but evalAction dispatches to store.setPlayhead etc. We instead + // observe the jumpTo time the playhead ends up at after all fire. + s.addScriptFrame(t2.id, 0.2, [{ type: 'jumpTo', time: 1 }]) + s.addScriptFrame(t1.id, 0.5, [{ type: 'jumpTo', time: 2 }]) + s.addScriptFrame(t2.id, 0.8, [{ type: 'jumpTo', time: 3 }]) + s.play() + s.tick(1) // advances to 1, all three frames in (0, 1] + // The last fired wins — since frames fire sorted by time, the last + // action to run is jumpTo 3. + expect(s.playhead).toBe(3) + s.pause() + await tick() + void log + }) + + test('within a single frame, actions fire in list order', async () => { + const s = makeFreshStore() + s.setDuration(10) + const t = s.addScriptTrack() + s.addScriptFrame(t.id, 1, [ + { type: 'jumpTo', time: 5 }, + { type: 'jumpTo', time: 7 }, + { type: 'pause' }, + ]) + s.play() + s.tick(2) + expect(s.playhead).toBe(7) + expect(s.playing).toBe(false) + await tick() + }) + + test('scriptFramesInRange handles exactly-at-next inclusively', () => { + const frames = [{ id: 'a', trackId: 't', time: 1, actions: [] as Action[] }] + // next strictly equals frame.time → included (open-closed) + expect(scriptFramesInRange(frames, 0.5, 1)).toHaveLength(1) + // next strictly below → excluded + expect(scriptFramesInRange(frames, 0.5, 0.999)).toHaveLength(0) + }) + }) } diff --git a/src/lib/buttonBlend.ts b/src/lib/buttonBlend.ts new file mode 100644 index 0000000..62fbcfc --- /dev/null +++ b/src/lib/buttonBlend.ts @@ -0,0 +1,55 @@ +import type { AnimatableProps, Asset, ButtonClip, ButtonState, Layer } from '@/types/scene' +import { lerpProps, sampleLayer } from '@/lib/interpolate' + +export interface BlendedLayerRender { + layer: Layer + asset: Asset + props: AnimatableProps +} + +/** + * Blend the "from" state's layers into the "to" state's layers by layer + * name. Matched pairs lerp their sampled (t=0) props; unmatched + * from-layers fade out (opacity × (1 − u)), unmatched to-layers fade in + * (opacity × u). Used by the canvas renderer and the export runtime + * while a button state transition is in progress. + * + * `u` is expected to be post-easing progress ∈ [0, 1]. + */ +export function blendButtonStates( + clip: ButtonClip, + from: ButtonState, + to: ButtonState, + u: number, + assetBy: (id: string) => Asset | undefined, +): BlendedLayerRender[] { + const fromLayers = clip.states[from] ?? [] + const toLayers = clip.states[to] ?? [] + const fromByName = new Map() + const toByName = new Map() + for (const l of fromLayers) fromByName.set(l.name, l) + for (const l of toLayers) toByName.set(l.name, l) + const out: BlendedLayerRender[] = [] + // Unmatched from-layers render first (behind to-layers) and fade out. + for (const l of fromLayers) { + if (toByName.has(l.name)) continue + const asset = assetBy(l.assetId) + if (!asset) continue + const base = sampleLayer(l, 0) + out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * (1 - u) } }) + } + for (const l of toLayers) { + const asset = assetBy(l.assetId) + if (!asset) continue + const fromLayer = fromByName.get(l.name) + if (fromLayer) { + const a = sampleLayer(fromLayer, 0) + const b = sampleLayer(l, 0) + out.push({ layer: l, asset, props: lerpProps(a, b, u) }) + } else { + const base = sampleLayer(l, 0) + out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * u } }) + } + } + return out +}