0.10.7: fire scripts at play-start + right-click duplicate script frame

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 19:39:26 +02:00
1 parent 0bbef66192
commit 34d1a89d56
6 files changed
+313 -15

No files matched your search

+169
View File
@@ -4330,6 +4330,57 @@ export function registerAllTests(): void {
})
})
suite('ui — Timeline script-frame context menu', () => {
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