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",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.0",
|
"version": "0.9.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const markerNames = computed(() =>
|
|||||||
(store.scene.markers ?? []).map((m) => m.name),
|
(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'][] = [
|
const ACTION_TYPES: Action['type'][] = [
|
||||||
'play',
|
'play',
|
||||||
'pause',
|
'pause',
|
||||||
@@ -26,6 +30,7 @@ const ACTION_TYPES: Action['type'][] = [
|
|||||||
'jumpTo',
|
'jumpTo',
|
||||||
'jumpToFrame',
|
'jumpToFrame',
|
||||||
'jumpToMarker',
|
'jumpToMarker',
|
||||||
|
'jumpToScene',
|
||||||
'script',
|
'script',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -45,6 +50,14 @@ function makeDefault(type: Action['type']): Action {
|
|||||||
return { type: 'jumpToFrame', frame: 0 }
|
return { type: 'jumpToFrame', frame: 0 }
|
||||||
case 'jumpToMarker':
|
case 'jumpToMarker':
|
||||||
return { type: 'jumpToMarker', name: markerNames.value[0] ?? '' }
|
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':
|
case 'script':
|
||||||
return { type: 'script', source: '' }
|
return { type: 'script', source: '' }
|
||||||
}
|
}
|
||||||
@@ -152,6 +165,18 @@ function onTypeChange(index: number, newType: Action['type']) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div v-else-if="a.type === 'script'" class="params">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
{{ t('actions.param.source') }}
|
{{ t('actions.param.source') }}
|
||||||
|
|||||||
@@ -42,17 +42,53 @@ async function startPreview() {
|
|||||||
const end = rangeOut.value
|
const end = rangeOut.value
|
||||||
const head = store.playhead
|
const head = store.playhead
|
||||||
const initialTime = head < start || head >= end ? start : head
|
const initialTime = head < start || head >= end ? start : head
|
||||||
player = mountPlayer(el, store.scene, {
|
mountAt(el, initialTime, true)
|
||||||
loop: true,
|
|
||||||
onFrame(time: number, paused: boolean) {
|
|
||||||
localTime.value = time
|
|
||||||
playing.value = !paused
|
|
||||||
},
|
|
||||||
})
|
|
||||||
player.seek(initialTime)
|
|
||||||
requestBrowserFullscreen()
|
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() {
|
function stopPreview() {
|
||||||
destroyPlayer()
|
destroyPlayer()
|
||||||
playing.value = false
|
playing.value = false
|
||||||
|
|||||||
+312
-3
@@ -41,6 +41,8 @@ import IconButton from '@/components/ui/IconButton.vue'
|
|||||||
import Toast from '@/components/ui/Toast.vue'
|
import Toast from '@/components/ui/Toast.vue'
|
||||||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||||
import ClipartDrawer from '@/components/ClipartDrawer.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 { vTooltip } from '@/directives/tooltip'
|
||||||
import { expect, suite, test } from './testRunner'
|
import { expect, suite, test } from './testRunner'
|
||||||
|
|
||||||
@@ -1280,22 +1282,40 @@ export function registerAllTests(): void {
|
|||||||
expect(s.scene.layers[0].id).toBe(l2.id)
|
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 s = makeFreshStore()
|
||||||
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
|
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
|
||||||
const l = s.addLayer(a.id)!
|
const l = s.addLayer(a.id)!
|
||||||
s.selectLayer(l.id)
|
s.selectLayer(l.id)
|
||||||
s.play()
|
s.pause()
|
||||||
const clip = s.addClip('C')
|
const clip = s.addClip('C')
|
||||||
s.activeClipId = clip.id
|
s.activeClipId = clip.id
|
||||||
const two = s.addScene()
|
const two = s.addScene()
|
||||||
// addScene already routed through switchScene — cross-check.
|
// addScene already routed through switchScene — cross-check.
|
||||||
expect(s.selectedLayerId).toBeNull()
|
expect(s.selectedLayerId).toBeNull()
|
||||||
expect(s.selectedLayerIds).toHaveLength(0)
|
expect(s.selectedLayerIds).toHaveLength(0)
|
||||||
expect(s.playing).toBe(false)
|
|
||||||
expect(s.activeClipId).toBeNull()
|
expect(s.activeClipId).toBeNull()
|
||||||
expect(s.activeButtonAssetId).toBeNull()
|
expect(s.activeButtonAssetId).toBeNull()
|
||||||
expect(s.project.activeSceneId).toBe(two.id)
|
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', () => {
|
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', () => {
|
suite('polygon — vertex bezier actions', () => {
|
||||||
function makeSquare() {
|
function makeSquare() {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
@@ -4811,6 +5044,40 @@ export function registerAllTests(): void {
|
|||||||
expect(rt.warnings[0]).toContain('missing')
|
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)', () => {
|
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
|
||||||
const rt = makeMockRuntime()
|
const rt = makeMockRuntime()
|
||||||
evalAction({ type: 'script', source: 'play\npause' }, rt)
|
evalAction({ type: 'script', source: 'play\npause' }, rt)
|
||||||
@@ -4898,6 +5165,48 @@ export function registerAllTests(): void {
|
|||||||
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
|
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
|
||||||
await tick()
|
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', () => {
|
suite('scriptDsl — parser', () => {
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export const en = {
|
|||||||
moveUp: 'Move up',
|
moveUp: 'Move up',
|
||||||
moveDown: 'Move down',
|
moveDown: 'Move down',
|
||||||
noMarkers: 'No markers defined',
|
noMarkers: 'No markers defined',
|
||||||
|
noScenes: 'No other scenes defined',
|
||||||
type: {
|
type: {
|
||||||
play: 'Play',
|
play: 'Play',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
@@ -475,12 +476,14 @@ export const en = {
|
|||||||
jumpTo: 'Jump to time',
|
jumpTo: 'Jump to time',
|
||||||
jumpToFrame: 'Jump to frame',
|
jumpToFrame: 'Jump to frame',
|
||||||
jumpToMarker: 'Jump to marker',
|
jumpToMarker: 'Jump to marker',
|
||||||
|
jumpToScene: 'Jump to scene',
|
||||||
script: 'Run script',
|
script: 'Run script',
|
||||||
},
|
},
|
||||||
param: {
|
param: {
|
||||||
time: 'Seconds',
|
time: 'Seconds',
|
||||||
frame: 'Frame',
|
frame: 'Frame',
|
||||||
marker: 'Marker',
|
marker: 'Marker',
|
||||||
|
scene: 'Scene',
|
||||||
source: 'Script source',
|
source: 'Script source',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ export interface ActionRuntime {
|
|||||||
playbackOut: number
|
playbackOut: number
|
||||||
duration: number
|
duration: number
|
||||||
markers: Record<string, 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
|
setPlayhead(t: number): void
|
||||||
play(): void
|
play(): void
|
||||||
pause(): 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
|
warn(msg: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +57,18 @@ export function evalAction(action: Action, rt: ActionRuntime): void {
|
|||||||
rt.setPlayhead(clamp(t, 0, rt.duration))
|
rt.setPlayhead(clamp(t, 0, rt.duration))
|
||||||
return
|
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': {
|
case 'script': {
|
||||||
const parsed = parseScript(action.source)
|
const parsed = parseScript(action.source)
|
||||||
if (parsed.errors.length) {
|
if (parsed.errors.length) {
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Scene } from '@/types/scene'
|
import type { Scene } from '@/types/scene'
|
||||||
|
|
||||||
const RUNTIME_VERSION = '17'
|
const RUNTIME_VERSION = '19'
|
||||||
|
|
||||||
const ENGINE_SOURCE = `(function(){
|
const ENGINE_SOURCE = `(function(){
|
||||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
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));
|
runtime.jumpTo=Math.max(0,Math.min(runtime.duration,mt));
|
||||||
return;
|
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': {
|
case 'script': {
|
||||||
var parsed=parseScriptSource(act.source);
|
var parsed=parseScriptSource(act.source);
|
||||||
if(parsed.errors.length){
|
if(parsed.errors.length){
|
||||||
@@ -381,6 +390,7 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
var lastNow=0;
|
var lastNow=0;
|
||||||
var rafId=0;
|
var rafId=0;
|
||||||
var onFrame=opts.onFrame||null;
|
var onFrame=opts.onFrame||null;
|
||||||
|
var onJumpToScene=opts.onJumpToScene||null;
|
||||||
var destroyed=false;
|
var destroyed=false;
|
||||||
function frame(now){
|
function frame(now){
|
||||||
if(destroyed) return;
|
if(destroyed) return;
|
||||||
@@ -866,6 +876,13 @@ export interface PlayerHandle {
|
|||||||
export interface MountOptions {
|
export interface MountOptions {
|
||||||
loop?: boolean
|
loop?: boolean
|
||||||
onFrame?: (time: number, paused: boolean) => void
|
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
|
// 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,
|
/** Activate a page by id. Clears any drill-down state (clip-edit,
|
||||||
* button-edit, selections, drawing) so the new page isn't shown
|
* 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 {
|
switchScene(id: string): boolean {
|
||||||
if (!this.project.scenes.find((s) => s.id === id)) return false
|
if (!this.project.scenes.find((s) => s.id === id)) return false
|
||||||
this.project.activeSceneId = id
|
this.project.activeSceneId = id
|
||||||
@@ -1812,7 +1815,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
this.drawingMode = null
|
this.drawingMode = null
|
||||||
this.drawingAssetId = null
|
this.drawingAssetId = null
|
||||||
this.drawingLayerId = null
|
this.drawingLayerId = null
|
||||||
this.playing = false
|
this.playing = true
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
renameScene(id: string, name: string): boolean {
|
renameScene(id: string, name: string): boolean {
|
||||||
@@ -2401,6 +2404,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
playbackOut: self.playbackOut,
|
playbackOut: self.playbackOut,
|
||||||
duration: self.scene.duration,
|
duration: self.scene.duration,
|
||||||
markers: buildMarkerMap(self.scene.markers),
|
markers: buildMarkerMap(self.scene.markers),
|
||||||
|
sceneIds: new Set(self.project.scenes.map((s) => s.id)),
|
||||||
setPlayhead(t: number) {
|
setPlayhead(t: number) {
|
||||||
self.setPlayhead(t)
|
self.setPlayhead(t)
|
||||||
},
|
},
|
||||||
@@ -2410,6 +2414,9 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
pause() {
|
pause() {
|
||||||
self.pause()
|
self.pause()
|
||||||
},
|
},
|
||||||
|
switchScene(sceneId: string) {
|
||||||
|
self.switchScene(sceneId)
|
||||||
|
},
|
||||||
warn(msg: string) {
|
warn(msg: string) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[ruwio]', msg)
|
console.warn('[ruwio]', msg)
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export type Action =
|
|||||||
| { type: 'jumpTo'; time: number }
|
| { type: 'jumpTo'; time: number }
|
||||||
| { type: 'jumpToFrame'; frame: number }
|
| { type: 'jumpToFrame'; frame: number }
|
||||||
| { type: 'jumpToMarker'; name: string }
|
| { 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 }
|
| { type: 'script'; source: string }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user