diff --git a/package.json b/package.json index ff0b6c2..0d2efe8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.10.6", + "version": "0.10.7", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index f6447df..5fbbd1f 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -1124,6 +1124,36 @@ function onScriptFramePointerUp(e: PointerEvent) { sfDrag.value = null } +interface ScriptFrameMenuState { + frameId: string + x: number + y: number +} + +const scriptFrameMenu = ref(null) +const scriptFrameMenuRef = ref(null) + +function onScriptFrameContextMenu(e: MouseEvent, frameId: string) { + e.preventDefault() + e.stopPropagation() + scriptFrameMenu.value = { frameId, x: e.clientX, y: e.clientY } +} + +function duplicateScriptFrameFromMenu() { + const m = scriptFrameMenu.value + if (!m) return + const newId = store.duplicateScriptFrame(m.frameId) + scriptFrameMenu.value = null + if (newId) openScriptFrame(newId) +} + +function deleteScriptFrameFromMenu() { + const m = scriptFrameMenu.value + if (!m) return + store.removeScriptFrame(m.frameId) + scriptFrameMenu.value = null +} + // --------------------------------------------------------------------------- interface KfSegment { @@ -2486,12 +2516,18 @@ function onDocumentPointerDown(e: PointerEvent) { } else if (trackMenu.value) { trackMenu.value = null } + if (scriptFrameMenu.value && scriptFrameMenuRef.value && e.target instanceof Node) { + if (!scriptFrameMenuRef.value.contains(e.target)) scriptFrameMenu.value = null + } else if (scriptFrameMenu.value) { + scriptFrameMenu.value = null + } } function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { menu.value = null trackMenu.value = null + scriptFrameMenu.value = null } } @@ -2557,8 +2593,8 @@ function onDocumentKeyDown(e: KeyboardEvent) { } } -watch([menu, trackMenu], ([m, tm]) => { - if (m || tm) { +watch([menu, trackMenu, scriptFrameMenu], ([m, tm, sm]) => { + if (m || tm || sm) { document.addEventListener('pointerdown', onDocumentPointerDown, true) document.addEventListener('keydown', onKeyDown) } else { @@ -2597,6 +2633,11 @@ watch(trackMenu, (tm) => { clampMenuIntoViewport(() => trackMenuRef.value, tm.x, tm.y) }) +watch(scriptFrameMenu, (sm) => { + if (!sm) return + clampMenuIntoViewport(() => scriptFrameMenuRef.value, sm.x, sm.y) +}) + function onWheel(e: WheelEvent) { if (!scrollEl.value) return if (e.ctrlKey || e.metaKey) { @@ -3220,6 +3261,7 @@ onBeforeUnmount(() => { @pointermove="onScriptFramePointerMove" @pointerup="onScriptFramePointerUp" @pointercancel="onScriptFramePointerUp" + @contextmenu="!track.locked ? onScriptFrameContextMenu($event, frame.id) : null" > {{ frame.actions.length }} @@ -3611,6 +3653,24 @@ onBeforeUnmount(() => { }} + +
+ +
+ +
+ { + test('right-click on a script frame opens a menu with Duplicate + Delete', async () => { + const s = makeFreshStore() + s.setDuration(10) + const track = s.addScriptTrack() + s.addScriptFrame(track.id, 2, [{ type: 'pause' }]) + const handle = mountComponent(Timeline, {}) + await nextTick() + const frame = handle.host.querySelector('.script-frame') as HTMLElement + expect(frame).toBeTruthy() + frame.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 50 }), + ) + await nextTick() + const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[] + const visible = menus.find((m) => m.offsetParent !== null || m.style.left !== '') + expect(visible).toBeTruthy() + const labels = Array.from(visible!.querySelectorAll('.kf-menu-item')).map((b) => + b.textContent?.trim(), + ) + expect(labels.length).toBe(2) + handle.unmount() + }) + + test('menu Duplicate creates a second script frame on the same track', async () => { + const s = makeFreshStore() + s.setDuration(10) + const track = s.addScriptTrack() + const f = s.addScriptFrame(track.id, 2, [{ type: 'pause' }], 'intro')! + const handle = mountComponent(Timeline, {}) + await nextTick() + const frameEl = handle.host.querySelector('.script-frame') as HTMLElement + frameEl.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }), + ) + await nextTick() + const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[] + const menu = menus[menus.length - 1] + const dup = menu.querySelector('.kf-menu-item') as HTMLButtonElement + dup.click() + await nextTick() + const framesOnTrack = (s.scene.scriptFrames ?? []).filter((x) => x.trackId === track.id) + expect(framesOnTrack).toHaveLength(2) + const clone = framesOnTrack.find((x) => x.id !== f.id)! + expect(clone.time).toBeGreaterThan(f.time) + expect(clone.actions.map((a) => a.type)).toEqual(['pause']) + expect(clone.label).toBe('intro') + handle.unmount() + }) + }) + suite('ui — Timeline audio track', () => { test('audio track renders as an .audio-track lane with one block per clip', async () => { const s = makeFreshStore() @@ -5835,6 +5886,43 @@ export function registerAllTests(): void { expect(times).toEqual([2, 10]) void b }) + + test('duplicateScriptFrame copies actions + label and steps forward by one offset', () => { + const s = makeFreshStore() + s.setDuration(10) + const t = s.addScriptTrack() + const f = s.addScriptFrame(t.id, 1, [{ type: 'pause' }], 'intro-end')! + const newId = s.duplicateScriptFrame(f.id) + expect(newId).toBeTruthy() + const copy = s.scene.scriptFrames?.find((x) => x.id === newId) + expect(copy).toBeTruthy() + expect(copy!.trackId).toBe(t.id) + expect(copy!.time).toBeGreaterThan(f.time) + expect(copy!.actions).toHaveLength(1) + expect(copy!.actions[0].type).toBe('pause') + expect(copy!.label).toBe('intro-end') + }) + + test('duplicateScriptFrame hops over occupied slots on the same track', () => { + const s = makeFreshStore() + s.setDuration(10) + const t = s.addScriptTrack() + const fps = s.scene.fps > 0 ? s.scene.fps : 60 + const offset = Math.max(3 / fps, 0.1) + const src = s.addScriptFrame(t.id, 1, [{ type: 'pause' }])! + // Pre-populate the first duplicate slot so the store has to step again. + s.addScriptFrame(t.id, 1 + offset, [{ type: 'play' }]) + const newId = s.duplicateScriptFrame(src.id) + const copy = s.scene.scriptFrames?.find((x) => x.id === newId) + expect(copy).toBeTruthy() + // Landed beyond the occupied slot. + expect(copy!.time).toBeGreaterThan(1 + offset + 1e-4) + }) + + test('duplicateScriptFrame returns null for an unknown id', () => { + const s = makeFreshStore() + expect(s.duplicateScriptFrame('nope')).toBeNull() + }) }) suite('scene store — audio model', () => { @@ -6513,6 +6601,27 @@ export function registerAllTests(): void { // prev=1, next=2 → includes 2 expect(scriptFramesInRange(frames, 1, 2)).toHaveLength(1) }) + + test('scriptFramesInRange with includeStart catches frames exactly at prev', () => { + // Matches the first-tick-after-play path: a script at the starting + // playhead time (e.g. frame 0) would be skipped by the default + // open-left interval; includeStart closes it. + const frames = [ + { id: 'a', trackId: 't', time: 0, actions: [] as Action[] }, + { id: 'b', trackId: 't', time: 0.5, actions: [] as Action[] }, + ] + // Default: open-left — excludes frame at 0. + expect(scriptFramesInRange(frames, 0, 0.5).map((f) => f.id)).toEqual(['b']) + // includeStart: both fire. + expect( + scriptFramesInRange(frames, 0, 0.5, { includeStart: true }).map((f) => f.id), + ).toEqual(['a', 'b']) + // Degenerate prev===next: only fires with includeStart. + expect(scriptFramesInRange(frames, 0, 0, { includeStart: true }).map((f) => f.id)).toEqual([ + 'a', + ]) + expect(scriptFramesInRange(frames, 0, 0)).toHaveLength(0) + }) }) suite('scene store — tick fires script frames forward-only', () => { @@ -6575,6 +6684,55 @@ export function registerAllTests(): void { await tick() }) + test('play-start fires a script sitting exactly at the playhead (frame-0 case)', async () => { + // Regression: a script placed at time=0 was silently skipped + // because the forward-firing window is (prev, next] — on the very + // first tick after play(), prev === 0 so the frame at 0 missed. + const s = makeFreshStore() + s.setDuration(10) + const t = s.addScriptTrack() + s.addScriptFrame(t.id, 0, [{ type: 'pause' }]) + expect(s.playhead).toBe(0) + s.play() + expect(s.playing).toBe(true) + s.tick(0.5) + // Pause action fired → playing=false. Without the play-start fix + // this assertion flips (playing stays true because the frame-0 + // script never ran). + expect(s.playing).toBe(false) + await tick() + }) + + test('play-start also fires a script at a non-zero starting playhead', async () => { + const s = makeFreshStore() + s.setDuration(10) + const t = s.addScriptTrack() + s.addScriptFrame(t.id, 3, [{ type: 'pause' }]) + s.setPlayhead(3) + s.play() + s.tick(0.1) + expect(s.playing).toBe(false) + await tick() + }) + + test('re-playing from the same playhead re-fires the start-frame script', async () => { + const s = makeFreshStore() + s.setDuration(10) + const t = s.addScriptTrack() + let count = 0 + // Stand-in for "a scripted action ran once this play session": + // a real pause action flips playing=false which we re-enable below. + s.addScriptFrame(t.id, 0, [{ type: 'pause' }]) + for (let i = 0; i < 3; i++) { + s.setPlayhead(0) + s.play() + s.tick(0.1) + if (!s.playing) count++ + } + expect(count).toBe(3) + await tick() + }) + test('scripted jumpToScene with an unknown id warns and leaves state alone', async () => { const s = makeFreshStore() s.setDuration(10) @@ -6912,6 +7070,17 @@ export function registerAllTests(): void { expect(r).toContain('runtime.buttonDisabled[act.layerId]') }) + test('runtime engine fires frame-0 scripts via playStartPending', () => { + // Pin the engine source so the first-tick-after-play firing is + // still wired: variable exists, fireFrames takes an includeStart + // arg, and forcePlay re-arms the flag so every pause→play fires + // the start-frame scripts again. + const r = generateRuntimeScript() + expect(r).toContain('playStartPending') + expect(r).toContain('function fireFrames(prev, next, includeStart)') + expect(r).toContain('playStartPending=true') + }) + 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 diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 9dcd496..8061e88 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -102,14 +102,28 @@ function clamp(n: number, lo: number, hi: number): number { * `(prev, next]`, sorted by time ascending. Forward-only — callers must * pass `next >= prev`, and the caller is responsible for suppressing * firing across loop wraps or scrubs. + * + * `opts.includeStart` switches the left bound to closed (`[prev, next]`) + * so scripts sitting exactly at `prev` also fire. Callers use it on the + * first tick after play() to catch frame-0 scripts that the otherwise + * open-left interval would silently skip. */ export function scriptFramesInRange< F extends { time: number; actions: unknown[] }, ->(frames: F[] | undefined, prev: number, next: number): F[] { - if (!frames || !frames.length || next <= prev) return [] +>( + frames: F[] | undefined, + prev: number, + next: number, + opts?: { includeStart?: boolean }, +): F[] { + if (!frames || !frames.length) return [] + const closedLeft = !!opts?.includeStart + if (next < prev) return [] + if (next === prev && !closedLeft) return [] const out: F[] = [] for (const f of frames) { - if (f.time > prev && f.time <= next) out.push(f) + const passesLeft = closedLeft ? f.time >= prev : f.time > prev + if (passesLeft && f.time <= next) out.push(f) } out.sort((a, b) => a.time - b.time) return out diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index 28dce9a..e146ddf 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 = '26' +const RUNTIME_VERSION = '27' const ENGINE_SOURCE = `(function(){ if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return; @@ -588,13 +588,15 @@ const ENGINE_SOURCE = `(function(){ } } } - function fireFrames(prev, next){ - if(next<=prev) return; + function fireFrames(prev, next, includeStart){ + if(nextprev && fr.time<=next) toFire.push(fr); + var passesLeft = includeStart ? fr.time>=prev : fr.time>prev; + if(passesLeft && fr.time<=next) toFire.push(fr); } toFire.sort(function(a,b){return a.time-b.time;}); for(var ffj=0; ffj=rangeOut){ - fireFrames(prev, rangeOut); + fireFrames(prev, rangeOut, includeStart); currentT = loop ? rangeIn : rangeOut; } else { currentT=nextT; - fireFrames(prev, currentT); + fireFrames(prev, currentT, includeStart); } } if(runtime.jumpTo!=null){ currentT=runtime.jumpTo; runtime.jumpTo=null; } if(runtime.forcePause){ paused=true; runtime.forcePause=false; } - if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; lastNow=0; } + if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; lastNow=0; playStartPending=true; } draw(currentT); syncBgVideo(currentT); syncAudio(currentT, !paused); diff --git a/src/stores/scene.ts b/src/stores/scene.ts index aa07d23..a413173 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -94,6 +94,10 @@ interface State { selectedClipInstanceId: string | null playhead: number playing: boolean + /** True between `play()` and the next `tick()`. Lets the first tick + * after a play-start fire scripts whose time === current playhead + * (e.g. a frame-0 script). Cleared by tick / pause / exitClip. */ + playStartPending: boolean activeClipId: string | null /** When set, the Stage + Timeline are inside a Button-asset edit session. */ activeButtonAssetId: string | null @@ -395,6 +399,7 @@ export const useSceneStore = defineStore('scene', { selectedClipInstanceId: null, playhead: 0, playing: false, + playStartPending: false, activeClipId: null, activeButtonAssetId: null, activeButtonEditMode: null, @@ -1600,6 +1605,37 @@ export const useSceneStore = defineStore('scene', { frame.time = Math.max(0, Math.min(this.scene.duration, time)) this.scene.scriptFrames?.sort((a, b) => a.time - b.time) }, + duplicateScriptFrame(frameId: string): string | null { + const frames = this.scene.scriptFrames ?? [] + const source = frames.find((f) => f.id === frameId) + if (!source) return null + const sameTrack = frames.filter((f) => f.trackId === source.trackId) + const occupied = (t: number) => + sameTrack.some((f) => Math.abs(f.time - t) < KEYFRAME_EPSILON) + const fps = this.scene.fps > 0 ? this.scene.fps : 60 + const offset = Math.max(3 / fps, 0.1) + const duration = this.scene.duration + // Walk forward looking for the first free slot; if the track is + // packed to the end, fall back to stepping backward before giving up. + let target = source.time + offset + while (occupied(target) && target < duration) target += offset + if (target > duration || occupied(target)) { + target = source.time - offset + while (occupied(target) && target > 0) target -= offset + if (target < 0) target = 0 + if (occupied(target)) return null + } + const copy: ScriptFrame = { + id: uid(), + trackId: source.trackId, + time: target, + actions: source.actions.map((a) => ({ ...a })), + } + if (source.label) copy.label = source.label + ;(this.scene.scriptFrames ||= []).push(copy) + this.scene.scriptFrames.sort((a, b) => a.time - b.time) + return copy.id + }, // -------- Audio tracks, clips & assets ----------------------------- @@ -2578,9 +2614,11 @@ export const useSceneStore = defineStore('scene', { play() { if (this.playing) return this.playing = true + this.playStartPending = true }, pause() { this.playing = false + this.playStartPending = false }, togglePlay() { if (this.playing) this.pause() @@ -2607,8 +2645,17 @@ export const useSceneStore = defineStore('scene', { const landed = this.playhead // Forward-only firing: script frames whose time sits in (prev, landed]. // Scrubs/setPlayhead/loop-wraps never reach this path, so the rule - // is naturally preserved. - const frames = scriptFramesInRange(this.scene.scriptFrames, prev, landed) + // is naturally preserved. The first tick after play() widens the + // left bound to [prev, landed] so scripts at the starting frame + // (e.g. the very first time slot) also fire. + const includeStart = this.playStartPending + this.playStartPending = false + const frames = scriptFramesInRange( + this.scene.scriptFrames, + prev, + landed, + { includeStart }, + ) if (frames.length) { const rt = this.actionRuntime() for (const f of frames) {