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:
thandClaude Opus 4.7 committed 2026-04-22 07:44:43 +02:00
1 parent b57994c754
commit b9f2ba9e47
9 files changed
+436 -15

No files matched your search

+1 -1
View File
@@ -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",
+25
View File
@@ -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') }}
+44 -8
View File
@@ -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
View File
@@ -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', () => {
+3
View File
@@ -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',
}, },
}, },
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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 }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------