From 34d1a89d56f44df6c3022c469beda6379b74002c Mon Sep 17 00:00:00 2001
From: th
Date: Wed, 22 Apr 2026 19:39:26 +0200
Subject: [PATCH] 0.10.7: fire scripts at play-start + right-click duplicate
script frame
Co-Authored-By: Claude Opus 4.7 (1M context)
---
package.json | 2 +-
src/components/Timeline.vue | 64 +++++++++++++-
src/devtools/tests.ts | 169 ++++++++++++++++++++++++++++++++++++
src/lib/actions.ts | 20 ++++-
src/lib/exportPlayer.ts | 22 +++--
src/stores/scene.ts | 51 ++++++++++-
6 files changed, 313 insertions(+), 15 deletions(-)
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) {
|