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:
1 parent
0bbef66192
commit
34d1a89d56
6 files changed
+313
-15
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.10.6",
|
||||
"version": "0.10.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1124,6 +1124,36 @@ function onScriptFramePointerUp(e: PointerEvent) {
|
||||
sfDrag.value = null
|
||||
}
|
||||
|
||||
interface ScriptFrameMenuState {
|
||||
frameId: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const scriptFrameMenu = ref<ScriptFrameMenuState | null>(null)
|
||||
const scriptFrameMenuRef = ref<HTMLDivElement | null>(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"
|
||||
>
|
||||
<span class="script-frame-dot"></span>
|
||||
<span v-if="frame.actions.length > 1" class="script-frame-count">{{ frame.actions.length }}</span>
|
||||
@@ -3611,6 +3653,24 @@ onBeforeUnmount(() => {
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="scriptFrameMenu"
|
||||
ref="scriptFrameMenuRef"
|
||||
class="kf-menu"
|
||||
:style="{ left: scriptFrameMenu.x + 'px', top: scriptFrameMenu.y + 'px' }"
|
||||
>
|
||||
<button class="kf-menu-item" @click="duplicateScriptFrameFromMenu">
|
||||
<PhCopy :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.duplicate') }}
|
||||
</button>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item danger" @click="deleteScriptFrameFromMenu">
|
||||
<PhTrash :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScriptFrameDialog
|
||||
v-model="scriptDialogOpen"
|
||||
:frame-id="scriptDialogFrameId"
|
||||
|
||||
@@ -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
|
||||
|
||||
+17
-3
@@ -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
|
||||
|
||||
+15
-7
@@ -10,7 +10,7 @@ function isProject(p: ExportPayload): p is Project {
|
||||
return Array.isArray((p as Partial<Project>).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(next<prev) return;
|
||||
if(next===prev && !includeStart) return;
|
||||
var frames=scene.scriptFrames||[];
|
||||
var toFire=[];
|
||||
for(var ffi=0; ffi<frames.length; ffi++){
|
||||
var fr=frames[ffi];
|
||||
if(fr.time>prev && 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<toFire.length; ffj++){
|
||||
@@ -605,6 +607,10 @@ const ENGINE_SOURCE = `(function(){
|
||||
var currentT=rangeIn;
|
||||
var paused=false;
|
||||
var lastNow=0;
|
||||
// First tick after a play-start fires scripts at currentT too —
|
||||
// otherwise frame-0 (or wherever the playhead sits) scripts are
|
||||
// silently skipped by the open-left passage firing.
|
||||
var playStartPending=true;
|
||||
var rafId=0;
|
||||
var onFrame=opts.onFrame||null;
|
||||
var onJumpToScene=opts.onJumpToScene||null;
|
||||
@@ -625,17 +631,19 @@ const ENGINE_SOURCE = `(function(){
|
||||
if(!paused){
|
||||
var prev=currentT;
|
||||
var nextT=currentT+dt;
|
||||
var includeStart=playStartPending;
|
||||
playStartPending=false;
|
||||
if(nextT>=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);
|
||||
|
||||
+49
-2
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user