0.9.1: jumpToScene action + auto-play on scene switch
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b57994c754
commit
b9f2ba9e47
9 files changed
+436
-15
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -19,6 +19,10 @@ const markerNames = computed(() =>
|
||||
(store.scene.markers ?? []).map((m) => m.name),
|
||||
)
|
||||
|
||||
const sceneOptions = computed(() =>
|
||||
store.project.scenes.map((s) => ({ id: s.id, name: s.name })),
|
||||
)
|
||||
|
||||
const ACTION_TYPES: Action['type'][] = [
|
||||
'play',
|
||||
'pause',
|
||||
@@ -26,6 +30,7 @@ const ACTION_TYPES: Action['type'][] = [
|
||||
'jumpTo',
|
||||
'jumpToFrame',
|
||||
'jumpToMarker',
|
||||
'jumpToScene',
|
||||
'script',
|
||||
]
|
||||
|
||||
@@ -45,6 +50,14 @@ function makeDefault(type: Action['type']): Action {
|
||||
return { type: 'jumpToFrame', frame: 0 }
|
||||
case 'jumpToMarker':
|
||||
return { type: 'jumpToMarker', name: markerNames.value[0] ?? '' }
|
||||
case 'jumpToScene': {
|
||||
// Default to the first scene that ISN'T the current one so the
|
||||
// action is immediately useful; fall back to the first if there's
|
||||
// only one scene.
|
||||
const others = sceneOptions.value.filter((s) => s.id !== store.project.activeSceneId)
|
||||
const target = others[0] ?? sceneOptions.value[0]
|
||||
return { type: 'jumpToScene', sceneId: target?.id ?? '' }
|
||||
}
|
||||
case 'script':
|
||||
return { type: 'script', source: '' }
|
||||
}
|
||||
@@ -152,6 +165,18 @@ function onTypeChange(index: number, newType: Action['type']) {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="a.type === 'jumpToScene'" class="params">
|
||||
<label>
|
||||
{{ t('actions.param.scene') }}
|
||||
<select
|
||||
:value="a.sceneId"
|
||||
@change="setAt(i, { type: 'jumpToScene', sceneId: ($event.target as HTMLSelectElement).value })"
|
||||
>
|
||||
<option v-if="!sceneOptions.length" value="" disabled>{{ t('actions.noScenes') }}</option>
|
||||
<option v-for="s in sceneOptions" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="a.type === 'script'" class="params">
|
||||
<label class="block">
|
||||
{{ t('actions.param.source') }}
|
||||
|
||||
@@ -42,17 +42,53 @@ async function startPreview() {
|
||||
const end = rangeOut.value
|
||||
const head = store.playhead
|
||||
const initialTime = head < start || head >= end ? start : head
|
||||
player = mountPlayer(el, store.scene, {
|
||||
loop: true,
|
||||
onFrame(time: number, paused: boolean) {
|
||||
localTime.value = time
|
||||
playing.value = !paused
|
||||
},
|
||||
})
|
||||
player.seek(initialTime)
|
||||
mountAt(el, initialTime, true)
|
||||
requestBrowserFullscreen()
|
||||
}
|
||||
|
||||
/** Mount (or re-mount) the player against `store.scene` and seek to `time`.
|
||||
* Used both by the initial `startPreview` and when a scripted / button
|
||||
* `jumpToScene` swaps the active page under us — we blow away the old
|
||||
* player and rebuild at the same playhead so visual continuity stays
|
||||
* intact across the switch. */
|
||||
function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) {
|
||||
player = mountPlayer(el, store.scene, {
|
||||
loop: true,
|
||||
onFrame(t: number, paused: boolean) {
|
||||
localTime.value = t
|
||||
playing.value = !paused
|
||||
},
|
||||
onJumpToScene(sceneId: string) {
|
||||
// Route the runtime's jump into the store. The watcher below
|
||||
// picks up the activeSceneId change and re-mounts the player
|
||||
// against the new page at the current time.
|
||||
store.switchScene(sceneId)
|
||||
},
|
||||
})
|
||||
player.seek(time)
|
||||
if (!wasPlaying) player.pause()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.project.activeSceneId,
|
||||
() => {
|
||||
if (!props.modelValue) return
|
||||
const el = frameEl.value
|
||||
if (!el) return
|
||||
// Every scene switch auto-plays on the new page — matches the
|
||||
// store-level switchScene contract. We keep the current playhead
|
||||
// so animation continuity stays intact across the swap.
|
||||
const keepTime = player ? player.getTime() : store.playhead
|
||||
destroyPlayer()
|
||||
nextTick(() => {
|
||||
if (!props.modelValue) return
|
||||
const still = frameEl.value
|
||||
if (!still) return
|
||||
mountAt(still, keepTime, true)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function stopPreview() {
|
||||
destroyPlayer()
|
||||
playing.value = false
|
||||
|
||||
+312
-3
@@ -41,6 +41,8 @@ import IconButton from '@/components/ui/IconButton.vue'
|
||||
import Toast from '@/components/ui/Toast.vue'
|
||||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
||||
import SceneDrawer from '@/components/SceneDrawer.vue'
|
||||
import ActionEditor from '@/components/ActionEditor.vue'
|
||||
import { vTooltip } from '@/directives/tooltip'
|
||||
import { expect, suite, test } from './testRunner'
|
||||
|
||||
@@ -1280,22 +1282,40 @@ export function registerAllTests(): void {
|
||||
expect(s.scene.layers[0].id).toBe(l2.id)
|
||||
})
|
||||
|
||||
test('switchScene clears editor context (selection, clip-edit, playback, drawing)', () => {
|
||||
test('switchScene clears editor context (selection, clip-edit, drawing) and auto-plays', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.selectLayer(l.id)
|
||||
s.play()
|
||||
s.pause()
|
||||
const clip = s.addClip('C')
|
||||
s.activeClipId = clip.id
|
||||
const two = s.addScene()
|
||||
// addScene already routed through switchScene — cross-check.
|
||||
expect(s.selectedLayerId).toBeNull()
|
||||
expect(s.selectedLayerIds).toHaveLength(0)
|
||||
expect(s.playing).toBe(false)
|
||||
expect(s.activeClipId).toBeNull()
|
||||
expect(s.activeButtonAssetId).toBeNull()
|
||||
expect(s.project.activeSceneId).toBe(two.id)
|
||||
// Switching always auto-plays on the new page — even if the
|
||||
// editor was paused beforehand.
|
||||
expect(s.playing).toBe(true)
|
||||
})
|
||||
|
||||
test('switchScene auto-plays even when the editor was paused', () => {
|
||||
const s = makeFreshStore()
|
||||
s.pause()
|
||||
const second = s.addScene('Two')
|
||||
const firstId = s.project.scenes[0].id
|
||||
s.pause()
|
||||
expect(s.playing).toBe(false)
|
||||
s.switchScene(firstId)
|
||||
expect(s.playing).toBe(true)
|
||||
expect(s.project.activeSceneId).toBe(firstId)
|
||||
// And the inverse direction — Two plays too.
|
||||
s.pause()
|
||||
s.switchScene(second.id)
|
||||
expect(s.playing).toBe(true)
|
||||
})
|
||||
|
||||
test('switchScene returns false for an unknown id and leaves state alone', () => {
|
||||
@@ -3818,6 +3838,219 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — SceneDrawer', () => {
|
||||
async function settlePromptCancel() {
|
||||
// The drawer's `+` button dispatches addScene and then kicks off a
|
||||
// promptDialog for renaming. Tests don't render DialogsHost, so
|
||||
// we resolve the pending prompt explicitly to keep the dialog
|
||||
// queue empty between tests.
|
||||
const { cancelDialog } = await import('@/components/ui/dialogs')
|
||||
cancelDialog()
|
||||
}
|
||||
|
||||
test('renders one .scene-row per page, with .active on the current one', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.addScene('Two')
|
||||
s.addScene('Three')
|
||||
const activeId = s.project.activeSceneId
|
||||
const handle = mountComponent(SceneDrawer, {})
|
||||
await nextTick()
|
||||
const rows = handle.host.querySelectorAll('.scene-row')
|
||||
expect(rows.length).toBe(3)
|
||||
// Order matches `project.scenes`.
|
||||
const names = Array.from(rows).map((r) => r.querySelector('.scene-name')?.textContent?.trim())
|
||||
expect(names).toEqual(s.project.scenes.map((p) => p.name))
|
||||
// Exactly one row is .active, and it's the active scene.
|
||||
const active = Array.from(rows).filter((r) => r.classList.contains('active'))
|
||||
expect(active.length).toBe(1)
|
||||
const activeName = active[0].querySelector('.scene-name')?.textContent?.trim()
|
||||
expect(activeName).toBe(s.project.scenes.find((p) => p.id === activeId)!.name)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('layer-count badge reflects each scene\'s layer count', async () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' })
|
||||
s.addLayer(asset.id)
|
||||
s.addLayer(asset.id)
|
||||
const two = s.addScene('Two')
|
||||
s.addLayer(asset.id) // lands on Two (active after addScene)
|
||||
// Render AFTER state is set up so the component's reactive read
|
||||
// captures the final layer counts.
|
||||
const handle = mountComponent(SceneDrawer, {})
|
||||
await nextTick()
|
||||
const rows = handle.host.querySelectorAll('.scene-row')
|
||||
expect(rows.length).toBe(2)
|
||||
const metas = Array.from(rows).map((r) =>
|
||||
r.querySelector('.scene-meta')?.textContent?.trim(),
|
||||
)
|
||||
// First row = Scene 1 (2 layers), second row = Two (1 layer).
|
||||
expect(metas).toEqual(['2', '1'])
|
||||
void two
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('clicking a non-active row switches to that page and auto-plays', async () => {
|
||||
const s = makeFreshStore()
|
||||
const firstId = s.project.activeSceneId
|
||||
const second = s.addScene('Two')
|
||||
// Go back to Scene 1 and pause so we can observe the auto-play flip.
|
||||
s.switchScene(firstId)
|
||||
s.pause()
|
||||
const handle = mountComponent(SceneDrawer, {})
|
||||
await nextTick()
|
||||
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
|
||||
// Row index 1 = Two (non-active).
|
||||
rows[1].click()
|
||||
await nextTick()
|
||||
expect(s.project.activeSceneId).toBe(second.id)
|
||||
expect(s.playing).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('clicking the already-active row is a no-op', async () => {
|
||||
const s = makeFreshStore()
|
||||
const firstId = s.project.activeSceneId
|
||||
s.pause()
|
||||
const handle = mountComponent(SceneDrawer, {})
|
||||
await nextTick()
|
||||
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
|
||||
// Row index 0 = the active one.
|
||||
rows[0].click()
|
||||
await nextTick()
|
||||
// Still paused — no sneaky re-switch triggered auto-play.
|
||||
expect(s.playing).toBe(false)
|
||||
expect(s.project.activeSceneId).toBe(firstId)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('+ button appends a new scene and activates it', async () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.project.scenes).toHaveLength(1)
|
||||
const handle = mountComponent(SceneDrawer, {})
|
||||
await nextTick()
|
||||
const addBtn = handle.host.querySelector('.add-btn') as HTMLButtonElement
|
||||
expect(addBtn).toBeTruthy()
|
||||
addBtn.click()
|
||||
// Let addScene + the nextTick inside addScene resolve.
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
expect(s.project.scenes).toHaveLength(2)
|
||||
expect(s.project.activeSceneId).toBe(s.project.scenes[1].id)
|
||||
// Clean up the pending rename prompt.
|
||||
await settlePromptCancel()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('right-click on a row surfaces the context menu', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.addScene('Two')
|
||||
const handle = mountComponent(SceneDrawer, {})
|
||||
await nextTick()
|
||||
expect(handle.host.querySelector('.scene-menu')).toBeNull()
|
||||
const row = handle.host.querySelector('.scene-row') as HTMLElement
|
||||
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||||
await nextTick()
|
||||
expect(handle.host.querySelector('.scene-menu')).toBeTruthy()
|
||||
// Delete option is disabled only when there's just one scene left.
|
||||
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
|
||||
expect(deleteBtn).toBeTruthy()
|
||||
expect(deleteBtn.disabled).toBe(false)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('context menu disables Delete when only one scene remains', async () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.project.scenes.length).toBe(1)
|
||||
const handle = mountComponent(SceneDrawer, {})
|
||||
await nextTick()
|
||||
const row = handle.host.querySelector('.scene-row') as HTMLElement
|
||||
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||||
await nextTick()
|
||||
const deleteBtn = handle.host.querySelector('.scene-menu .menu-item.danger') as HTMLButtonElement
|
||||
expect(deleteBtn).toBeTruthy()
|
||||
expect(deleteBtn.disabled).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — ActionEditor (jumpToScene)', () => {
|
||||
test('jumpToScene appears in the type dropdown', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.addScene('Two')
|
||||
const handle = mountComponent(
|
||||
ActionEditor,
|
||||
{ actions: [{ type: 'pause' } as const] },
|
||||
)
|
||||
await nextTick()
|
||||
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||||
expect(select).toBeTruthy()
|
||||
const values = Array.from(select.options).map((o) => o.value)
|
||||
expect(values.includes('jumpToScene')).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('switching a row to jumpToScene seeds it with the first non-active scene', async () => {
|
||||
const s = makeFreshStore()
|
||||
const activeId = s.project.activeSceneId
|
||||
const other = s.addScene('Other')
|
||||
s.switchScene(activeId) // pull the active marker back to Scene 1
|
||||
let latest: unknown = null
|
||||
const handle = mountComponent(ActionEditor, {
|
||||
actions: [{ type: 'pause' } as const],
|
||||
'onUpdate:actions': (v: unknown) => {
|
||||
latest = v
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||||
select.value = 'jumpToScene'
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(Array.isArray(latest)).toBe(true)
|
||||
const arr = latest as unknown[]
|
||||
expect(arr.length).toBe(1)
|
||||
const next = arr[0] as { type: string; sceneId?: string }
|
||||
expect(next.type).toBe('jumpToScene')
|
||||
// Defaults to the first non-active scene so the action is useful out of the box.
|
||||
expect(next.sceneId).toBe(other.id)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('scene dropdown enumerates every scene by name, emits sceneId on change', async () => {
|
||||
const s = makeFreshStore()
|
||||
const firstId = s.project.scenes[0].id
|
||||
const second = s.addScene('Two')
|
||||
const third = s.addScene('Three')
|
||||
// Active is now Three. The seed action points at Two; changing
|
||||
// to Three via the dropdown should emit { sceneId: third.id }.
|
||||
let latest: unknown = null
|
||||
const handle = mountComponent(ActionEditor, {
|
||||
actions: [{ type: 'jumpToScene', sceneId: second.id } as const],
|
||||
'onUpdate:actions': (v: unknown) => {
|
||||
latest = v
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
// Find the scene dropdown (second select on the row — the first
|
||||
// is the action-type select).
|
||||
const selects = handle.host.querySelectorAll('select')
|
||||
expect(selects.length).toBe(2)
|
||||
const sceneSelect = selects[1] as HTMLSelectElement
|
||||
const sceneValues = Array.from(sceneSelect.options).map((o) => o.value)
|
||||
expect(sceneValues).toEqual([firstId, second.id, third.id])
|
||||
const sceneLabels = Array.from(sceneSelect.options).map((o) => o.textContent?.trim())
|
||||
expect(sceneLabels).toEqual(['Scene 1', 'Two', 'Three'])
|
||||
sceneSelect.value = third.id
|
||||
sceneSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await nextTick()
|
||||
const next = (latest as unknown[])[0] as { type: string; sceneId: string }
|
||||
expect(next.type).toBe('jumpToScene')
|
||||
expect(next.sceneId).toBe(third.id)
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('polygon — vertex bezier actions', () => {
|
||||
function makeSquare() {
|
||||
const s = makeFreshStore()
|
||||
@@ -4811,6 +5044,40 @@ export function registerAllTests(): void {
|
||||
expect(rt.warnings[0]).toContain('missing')
|
||||
})
|
||||
|
||||
test('jumpToScene dispatches switchScene on the runtime', () => {
|
||||
let switched: string | null = null
|
||||
const rt = makeMockRuntime({
|
||||
sceneIds: new Set(['s-a', 's-b']),
|
||||
switchScene(id: string) {
|
||||
switched = id
|
||||
},
|
||||
})
|
||||
evalAction({ type: 'jumpToScene', sceneId: 's-b' }, rt)
|
||||
expect(switched).toBe('s-b')
|
||||
expect(rt.warnings.length).toBe(0)
|
||||
})
|
||||
|
||||
test('jumpToScene warns on unknown sceneId and does not switch', () => {
|
||||
let switched: string | null = null
|
||||
const rt = makeMockRuntime({
|
||||
sceneIds: new Set(['s-a']),
|
||||
switchScene(id: string) {
|
||||
switched = id
|
||||
},
|
||||
})
|
||||
evalAction({ type: 'jumpToScene', sceneId: 's-ghost' }, rt)
|
||||
expect(switched).toBe(null)
|
||||
expect(rt.warnings.length).toBe(1)
|
||||
expect(rt.warnings[0]).toContain('s-ghost')
|
||||
})
|
||||
|
||||
test('jumpToScene warns if runtime has no switchScene handler (e.g. exported bundle)', () => {
|
||||
const rt = makeMockRuntime() // no switchScene, no sceneIds
|
||||
evalAction({ type: 'jumpToScene', sceneId: 's-x' }, rt)
|
||||
expect(rt.warnings.length).toBe(1)
|
||||
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
|
||||
})
|
||||
|
||||
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
|
||||
const rt = makeMockRuntime()
|
||||
evalAction({ type: 'script', source: 'play\npause' }, rt)
|
||||
@@ -4898,6 +5165,48 @@ export function registerAllTests(): void {
|
||||
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
|
||||
await tick()
|
||||
})
|
||||
|
||||
test('a scripted jumpToScene switches the active scene and keeps playing', async () => {
|
||||
// User-facing contract: a script track fires a scene-switch in the
|
||||
// middle of a running timeline — the active scene flips and the
|
||||
// timeline keeps running on the new page. All pages share
|
||||
// duration/fps, so the playhead stays put and continues onward.
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const aId = s.project.activeSceneId
|
||||
const bId = s.addScene('B').id
|
||||
s.switchScene(aId)
|
||||
const track = s.addScriptTrack()
|
||||
s.addScriptFrame(track.id, 3, [{ type: 'jumpToScene', sceneId: bId }])
|
||||
s.play()
|
||||
s.tick(3)
|
||||
expect(s.project.activeSceneId).toBe(bId)
|
||||
expect(s.playing).toBe(true)
|
||||
await tick()
|
||||
})
|
||||
|
||||
test('scripted jumpToScene with an unknown id warns and leaves state alone', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const aId = s.project.activeSceneId
|
||||
const track = s.addScriptTrack()
|
||||
s.addScriptFrame(track.id, 2, [{ type: 'jumpToScene', sceneId: 'bogus-id' }])
|
||||
s.play()
|
||||
const warnings: string[] = []
|
||||
const origWarn = console.warn
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push(args.map(String).join(' '))
|
||||
}
|
||||
try {
|
||||
s.tick(2)
|
||||
} finally {
|
||||
console.warn = origWarn
|
||||
}
|
||||
expect(s.project.activeSceneId).toBe(aId)
|
||||
expect(s.playing).toBe(true)
|
||||
expect(warnings.some((w) => w.includes('bogus-id'))).toBe(true)
|
||||
await tick()
|
||||
})
|
||||
})
|
||||
|
||||
suite('scriptDsl — parser', () => {
|
||||
|
||||
@@ -468,6 +468,7 @@ export const en = {
|
||||
moveUp: 'Move up',
|
||||
moveDown: 'Move down',
|
||||
noMarkers: 'No markers defined',
|
||||
noScenes: 'No other scenes defined',
|
||||
type: {
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
@@ -475,12 +476,14 @@ export const en = {
|
||||
jumpTo: 'Jump to time',
|
||||
jumpToFrame: 'Jump to frame',
|
||||
jumpToMarker: 'Jump to marker',
|
||||
jumpToScene: 'Jump to scene',
|
||||
script: 'Run script',
|
||||
},
|
||||
param: {
|
||||
time: 'Seconds',
|
||||
frame: 'Frame',
|
||||
marker: 'Marker',
|
||||
scene: 'Scene',
|
||||
source: 'Script source',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,17 @@ export interface ActionRuntime {
|
||||
playbackOut: number
|
||||
duration: number
|
||||
markers: Record<string, number>
|
||||
/** Set of valid scene ids the runtime knows about. Used so jumpToScene
|
||||
* can warn on stale references (e.g., the target scene was deleted)
|
||||
* instead of silently failing. */
|
||||
sceneIds?: Set<string>
|
||||
setPlayhead(t: number): void
|
||||
play(): void
|
||||
pause(): void
|
||||
/** Switch the active scene page. Implementations should preserve the
|
||||
* current playback state so a script-fired jump doesn't surprise-pause
|
||||
* the timeline. */
|
||||
switchScene?(sceneId: string): void
|
||||
warn(msg: string): void
|
||||
}
|
||||
|
||||
@@ -49,6 +57,18 @@ export function evalAction(action: Action, rt: ActionRuntime): void {
|
||||
rt.setPlayhead(clamp(t, 0, rt.duration))
|
||||
return
|
||||
}
|
||||
case 'jumpToScene': {
|
||||
if (!rt.switchScene) {
|
||||
rt.warn('jumpToScene: runtime does not support scene switching')
|
||||
return
|
||||
}
|
||||
if (rt.sceneIds && !rt.sceneIds.has(action.sceneId)) {
|
||||
rt.warn(`jumpToScene: unknown scene id "${action.sceneId}"`)
|
||||
return
|
||||
}
|
||||
rt.switchScene(action.sceneId)
|
||||
return
|
||||
}
|
||||
case 'script': {
|
||||
const parsed = parseScript(action.source)
|
||||
if (parsed.errors.length) {
|
||||
|
||||
+18
-1
@@ -1,6 +1,6 @@
|
||||
import type { Scene } from '@/types/scene'
|
||||
|
||||
const RUNTIME_VERSION = '17'
|
||||
const RUNTIME_VERSION = '19'
|
||||
|
||||
const ENGINE_SOURCE = `(function(){
|
||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||
@@ -350,6 +350,15 @@ const ENGINE_SOURCE = `(function(){
|
||||
runtime.jumpTo=Math.max(0,Math.min(runtime.duration,mt));
|
||||
return;
|
||||
}
|
||||
case 'jumpToScene': {
|
||||
// Exports only carry one scene at a time, so jumpToScene is a
|
||||
// warn-and-continue in standalone bundles. The in-editor
|
||||
// fullscreen preview passes an onJumpToScene hook which routes
|
||||
// the jump back to the store and re-mounts at the same time.
|
||||
if (onJumpToScene){ try { onJumpToScene(act.sceneId); } catch(e){ runtime.warn('jumpToScene: '+((e&&e.message)||String(e))); } return; }
|
||||
runtime.warn('jumpToScene: exported runtime does not support multi-scene playback');
|
||||
return;
|
||||
}
|
||||
case 'script': {
|
||||
var parsed=parseScriptSource(act.source);
|
||||
if(parsed.errors.length){
|
||||
@@ -381,6 +390,7 @@ const ENGINE_SOURCE = `(function(){
|
||||
var lastNow=0;
|
||||
var rafId=0;
|
||||
var onFrame=opts.onFrame||null;
|
||||
var onJumpToScene=opts.onJumpToScene||null;
|
||||
var destroyed=false;
|
||||
function frame(now){
|
||||
if(destroyed) return;
|
||||
@@ -866,6 +876,13 @@ export interface PlayerHandle {
|
||||
export interface MountOptions {
|
||||
loop?: boolean
|
||||
onFrame?: (time: number, paused: boolean) => void
|
||||
/** In-editor hook used by the fullscreen preview: a scripted (or
|
||||
* button-click) `jumpToScene` calls this instead of the runtime's
|
||||
* own warn-and-skip path. The host is responsible for swapping the
|
||||
* Scene the player renders (typically by mutating the store and
|
||||
* re-mounting with the new active page). Exported bundles omit this
|
||||
* — they carry a single scene and warn on jumpToScene. */
|
||||
onJumpToScene?: (sceneId: string) => void
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
+9
-2
@@ -1798,7 +1798,10 @@ export const useSceneStore = defineStore('scene', {
|
||||
},
|
||||
/** Activate a page by id. Clears any drill-down state (clip-edit,
|
||||
* button-edit, selections, drawing) so the new page isn't shown
|
||||
* through the lens of a context that belongs to another page. */
|
||||
* through the lens of a context that belongs to another page.
|
||||
* Every switch auto-starts playback — clicking a scene in the
|
||||
* drawer or having a script/button jump to one always hands the
|
||||
* user a running timeline on the new page. */
|
||||
switchScene(id: string): boolean {
|
||||
if (!this.project.scenes.find((s) => s.id === id)) return false
|
||||
this.project.activeSceneId = id
|
||||
@@ -1812,7 +1815,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
this.drawingMode = null
|
||||
this.drawingAssetId = null
|
||||
this.drawingLayerId = null
|
||||
this.playing = false
|
||||
this.playing = true
|
||||
return true
|
||||
},
|
||||
renameScene(id: string, name: string): boolean {
|
||||
@@ -2401,6 +2404,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
playbackOut: self.playbackOut,
|
||||
duration: self.scene.duration,
|
||||
markers: buildMarkerMap(self.scene.markers),
|
||||
sceneIds: new Set(self.project.scenes.map((s) => s.id)),
|
||||
setPlayhead(t: number) {
|
||||
self.setPlayhead(t)
|
||||
},
|
||||
@@ -2410,6 +2414,9 @@ export const useSceneStore = defineStore('scene', {
|
||||
pause() {
|
||||
self.pause()
|
||||
},
|
||||
switchScene(sceneId: string) {
|
||||
self.switchScene(sceneId)
|
||||
},
|
||||
warn(msg: string) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[ruwio]', msg)
|
||||
|
||||
@@ -269,6 +269,10 @@ export type Action =
|
||||
| { type: 'jumpTo'; time: number }
|
||||
| { type: 'jumpToFrame'; frame: number }
|
||||
| { type: 'jumpToMarker'; name: string }
|
||||
/** Switch the active scene page. Stored by stable id so scene renames
|
||||
* don't break existing actions. Playback state is preserved across
|
||||
* the jump — all pages share the same timeline. */
|
||||
| { type: 'jumpToScene'; sceneId: string }
|
||||
| { type: 'script'; source: string }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user