0.10.3: preloader + multi-scene export parity, jumpToScene rewinds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
87de1344c4
commit
fcb8edbb88
6 files changed
+461
-102
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.10.2",
|
||||
"version": "0.10.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -47,7 +47,34 @@ const hasHiddenLayers = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
function sceneClone() {
|
||||
function projectClone() {
|
||||
// Project-shaped payload for standalone static exports — the
|
||||
// embedded runtime handles multi-scene playback natively.
|
||||
const project = JSON.parse(JSON.stringify(store.project))
|
||||
if (!skipHidden.value) return project
|
||||
for (const page of project.scenes) {
|
||||
page.layers = page.layers.filter((l: Layer) => !l.editorHidden)
|
||||
}
|
||||
if (Array.isArray(project.clips)) {
|
||||
for (const clip of project.clips) {
|
||||
clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(project.assets)) {
|
||||
for (const asset of project.assets as ButtonAsset[]) {
|
||||
if (asset && asset.kind === 'button' && Array.isArray(asset.layers)) {
|
||||
asset.layers = asset.layers.filter((l: Layer) => !l.editorHidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
function activeSceneClone() {
|
||||
// Scene-shaped payload for linear render paths (webm/mp4 server).
|
||||
// Video is a single timeline — we only render the currently active
|
||||
// page. A user who wants a different scene rendered needs to switch
|
||||
// it in the drawer first.
|
||||
const scene = JSON.parse(JSON.stringify(store.scene))
|
||||
if (!skipHidden.value) return scene
|
||||
scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden)
|
||||
@@ -67,7 +94,7 @@ function sceneClone() {
|
||||
}
|
||||
|
||||
function exportScene() {
|
||||
downloadSceneExport(sceneClone())
|
||||
downloadSceneExport(projectClone())
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
@@ -77,7 +104,7 @@ function exportRuntime() {
|
||||
}
|
||||
|
||||
function exportHtml() {
|
||||
downloadExampleHtml(sceneClone())
|
||||
downloadExampleHtml(projectClone())
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
@@ -92,7 +119,7 @@ async function exportWebm() {
|
||||
}
|
||||
store.pause()
|
||||
try {
|
||||
await downloadSceneAsVideo(sceneClone(), {
|
||||
await downloadSceneAsVideo(activeSceneClone(), {
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
@@ -120,7 +147,7 @@ async function exportMp4Server() {
|
||||
}
|
||||
store.pause()
|
||||
try {
|
||||
await downloadFromServer(sceneClone(), {
|
||||
await downloadFromServer(activeSceneClone(), {
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
|
||||
@@ -56,17 +56,17 @@ async function startPreview() {
|
||||
* 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, {
|
||||
player = mountPlayer(el, store.project, {
|
||||
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)
|
||||
// Route the runtime's jump into the store and restart the
|
||||
// target scene at its in-point — script/button jumps are
|
||||
// "transition to new chapter", not "keep scrubbing".
|
||||
store.switchScene(sceneId, { resetPlayhead: true })
|
||||
},
|
||||
})
|
||||
player.seek(time)
|
||||
@@ -79,10 +79,12 @@ watch(
|
||||
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
|
||||
// Seek the remounted player to whatever `store.playhead` is after
|
||||
// the switch. Drawer clicks leave it unchanged; scripted/button
|
||||
// jumps reset it to the in-point via resetPlayhead. Reading the
|
||||
// store (not the old player's currentT) means both paths land on
|
||||
// the expected frame.
|
||||
const keepTime = store.playhead
|
||||
destroyPlayer()
|
||||
nextTick(() => {
|
||||
if (!props.modelValue) return
|
||||
|
||||
+60
-5
@@ -1018,6 +1018,48 @@ export function registerAllTests(): void {
|
||||
expect(r).toContain('ctx.scale(isx2,isy2)')
|
||||
})
|
||||
|
||||
test('runtime embeds a preloader (spinner + Promise.all) before starting the frame loop', () => {
|
||||
// Loading big images, video metadata and audio `canplaythrough`
|
||||
// must settle before the rAF kicks off — otherwise the first few
|
||||
// frames stutter while the browser decodes. Structural pins:
|
||||
// - A spinner <style> is injected.
|
||||
// - A preloader overlay is appended.
|
||||
// - Image/audio/video promises are awaited together.
|
||||
// - canplaythrough is the event we wait on for audio.
|
||||
const r = generateRuntimeScript()
|
||||
expect(r).toContain('ruwio-spinner-style')
|
||||
expect(r).toContain('ruwio-preloader')
|
||||
expect(r).toContain('Promise.all')
|
||||
expect(r).toContain('canplaythrough')
|
||||
expect(r).toContain('preloadAudioClips')
|
||||
expect(r).toContain('preloadBgVideos')
|
||||
})
|
||||
|
||||
test('runtime honours separated position / rotation keyframes', () => {
|
||||
// Parity regression: the editor samples separated position and
|
||||
// rotation tracks via `sampleLayer()` when `layer.separated`;
|
||||
// the runtime used to only look at the main keyframes array.
|
||||
// Animations set up with Separate-toggled layers played flat
|
||||
// in exports. Pin the helper and the per-branch merge.
|
||||
const r = generateRuntimeScript()
|
||||
expect(r).toContain('sampleKeyframeSet')
|
||||
expect(r).toContain('layer.separated')
|
||||
expect(r).toContain('layer.positionKeyframes')
|
||||
expect(r).toContain('layer.rotationKeyframes')
|
||||
})
|
||||
|
||||
test('runtime supports multi-scene switching (jumpToScene + applySceneSwitch)', () => {
|
||||
// Exported bundles now carry the whole Project and handle
|
||||
// jumpToScene internally. The runtime must expose a scene-
|
||||
// switch helper and NOT fall through to the "unsupported"
|
||||
// warning message anymore.
|
||||
const r = generateRuntimeScript()
|
||||
expect(r).toContain('applySceneSwitch')
|
||||
expect(r).toContain('project.scenes')
|
||||
expect(r).toContain('currentSceneId')
|
||||
expect(r.indexOf('does not support multi-scene')).toBe(-1)
|
||||
})
|
||||
|
||||
test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
|
||||
// Regression: the runtime used to fall back to `effectiveEnd(scene)`
|
||||
// — the time of the last keyframe — when scene.outTime was unset.
|
||||
@@ -1356,6 +1398,19 @@ export function registerAllTests(): void {
|
||||
expect(s.project.activeSceneId).toBe(two.id)
|
||||
})
|
||||
|
||||
test('switchScene with resetPlayhead: true rewinds to the in-point', () => {
|
||||
// Script and button jumps should restart the target scene at
|
||||
// its in-point — otherwise chapter-like transitions play from
|
||||
// wherever the previous scene was paused.
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const two = s.addScene('Two')
|
||||
s.setPlayhead(4.5)
|
||||
expect(s.playhead).toBe(4.5)
|
||||
s.switchScene(two.id, { resetPlayhead: true })
|
||||
expect(s.playhead).toBe(0)
|
||||
})
|
||||
|
||||
test('switchScene leaves playback state alone — editor navigation does not force play', () => {
|
||||
// The editor treats scene-switch as a navigation gesture. The
|
||||
// fullscreen preview is the one that auto-plays on switch (via
|
||||
@@ -6381,11 +6436,10 @@ export function registerAllTests(): void {
|
||||
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.
|
||||
test('a scripted jumpToScene switches the scene, keeps playing, and rewinds to the in-point', async () => {
|
||||
// Chapter-transition semantics: the incoming scene starts at
|
||||
// the project's in-point no matter where the outgoing scene was
|
||||
// paused. Keeps playback running across the switch.
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const aId = s.project.activeSceneId
|
||||
@@ -6397,6 +6451,7 @@ export function registerAllTests(): void {
|
||||
s.tick(3)
|
||||
expect(s.project.activeSceneId).toBe(bId)
|
||||
expect(s.playing).toBe(true)
|
||||
expect(s.playhead).toBe(s.playbackIn)
|
||||
await tick()
|
||||
})
|
||||
|
||||
|
||||
+343
-76
@@ -1,6 +1,16 @@
|
||||
import type { Scene } from '@/types/scene'
|
||||
import type { Project, Scene } from '@/types/scene'
|
||||
|
||||
const RUNTIME_VERSION = '20'
|
||||
/** Input accepted by every export helper below. A Project exports all
|
||||
* its scenes (jumpToScene works in the bundle); a single Scene is
|
||||
* wrapped into a one-page Project for back-compat with the fullscreen
|
||||
* preview callers. */
|
||||
type ExportPayload = Project | Scene
|
||||
|
||||
function isProject(p: ExportPayload): p is Project {
|
||||
return Array.isArray((p as Partial<Project>).scenes)
|
||||
}
|
||||
|
||||
const RUNTIME_VERSION = '24'
|
||||
|
||||
const ENGINE_SOURCE = `(function(){
|
||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||
@@ -86,23 +96,26 @@ const ENGINE_SOURCE = `(function(){
|
||||
else if(a.shadowColor) out.shadowColor=a.shadowColor;
|
||||
return out;
|
||||
}
|
||||
function sample(layer,time){
|
||||
var kfs=layer.keyframes.slice().sort(function(a,b){return a.time-b.time;});
|
||||
if(!kfs.length)return merge(layer.base);
|
||||
if(time<=kfs[0].time)return merge(layer.base,kfs[0].props);
|
||||
// Interpolate a single keyframe track against 'base' at 'time'.
|
||||
// Mirrors the editor's sampleKeyframeSet so separated position /
|
||||
// rotation tracks play identically.
|
||||
function sampleKeyframeSet(kfsIn, base, time){
|
||||
var kfs=(kfsIn||[]).slice().sort(function(a,b){return a.time-b.time;});
|
||||
if(!kfs.length) return merge(base);
|
||||
if(time<=kfs[0].time) return merge(base,kfs[0].props);
|
||||
var last=kfs[kfs.length-1];
|
||||
if(time>=last.time)return merge(layer.base,last.props);
|
||||
if(time>=last.time) return merge(base,last.props);
|
||||
for(var i=0;i<kfs.length-1;i++){
|
||||
var a=kfs[i],b=kfs[i+1];
|
||||
if(time>=a.time&&time<=b.time){
|
||||
if(a.hold){
|
||||
if(time>=b.time)return merge(layer.base,b.props);
|
||||
return merge(layer.base,a.props);
|
||||
if(time>=b.time) return merge(base,b.props);
|
||||
return merge(base,a.props);
|
||||
}
|
||||
var raw=(time-a.time)/(b.time-a.time);
|
||||
var t=easing(b.easing,raw);
|
||||
var pa=merge(layer.base,a.props);
|
||||
var pb=merge(layer.base,b.props);
|
||||
var pa=merge(base,a.props);
|
||||
var pb=merge(base,b.props);
|
||||
var outSample=lerpProps(pa,pb,t);
|
||||
if(b.ctrlX!=null && b.ctrlY!=null){
|
||||
var om=1-t;
|
||||
@@ -112,7 +125,23 @@ const ENGINE_SOURCE = `(function(){
|
||||
return outSample;
|
||||
}
|
||||
}
|
||||
return merge(layer.base);
|
||||
return merge(base);
|
||||
}
|
||||
function sample(layer,time){
|
||||
var main=sampleKeyframeSet(layer.keyframes, layer.base, time);
|
||||
if(!layer.separated) return main;
|
||||
// Separated mode: position and rotation come from their own tracks
|
||||
// if present, merged back over the main-track values. This matches
|
||||
// the editor's sampleLayer() so animations on either side of the
|
||||
// Separate-toggle play identically.
|
||||
var pos = layer.positionKeyframes ? sampleKeyframeSet(layer.positionKeyframes, layer.base, time) : main;
|
||||
var rot = layer.rotationKeyframes ? sampleKeyframeSet(layer.rotationKeyframes, layer.base, time) : main;
|
||||
var outSep={};
|
||||
for(var k in main) outSep[k]=main[k];
|
||||
outSep.x=pos.x;
|
||||
outSep.y=pos.y;
|
||||
outSep.rotation=rot.rotation;
|
||||
return outSep;
|
||||
}
|
||||
|
||||
function loadImages(assets){
|
||||
@@ -128,10 +157,80 @@ const ENGINE_SOURCE = `(function(){
|
||||
})).then(function(){return imgs;});
|
||||
}
|
||||
|
||||
function mount(container,scene,opts){
|
||||
function mount(container,payload,opts){
|
||||
if(!container)return;
|
||||
opts=opts||{};
|
||||
var loop=opts.loop!==false;
|
||||
|
||||
// Accept either a Project (multi-scene payload used by standalone
|
||||
// exports) or a legacy Scene (used by the in-editor fullscreen
|
||||
// preview via store.scene). Internally everything is a Project.
|
||||
var project, currentSceneId;
|
||||
function mergeSceneView(p, page){
|
||||
return {
|
||||
canvas: p.canvas,
|
||||
duration: p.duration,
|
||||
fps: p.fps,
|
||||
inTime: p.inTime,
|
||||
outTime: p.outTime,
|
||||
assets: p.assets,
|
||||
clips: p.clips,
|
||||
layers: page.layers || [],
|
||||
clipInstances: page.clipInstances || [],
|
||||
clipTracks: page.clipTracks || [],
|
||||
scriptTracks: page.scriptTracks || [],
|
||||
scriptFrames: page.scriptFrames || [],
|
||||
markers: page.markers || [],
|
||||
groups: page.groups || [],
|
||||
trackOrder: page.trackOrder || [],
|
||||
backgroundVideo: page.backgroundVideo,
|
||||
audioTracks: page.audioTracks || [],
|
||||
audioClips: page.audioClips || []
|
||||
};
|
||||
}
|
||||
if(payload && payload.scenes && payload.scenes.length){
|
||||
project=payload;
|
||||
var initial=null;
|
||||
for(var si=0; si<project.scenes.length; si++){
|
||||
if(project.scenes[si].id === project.activeSceneId){ initial=project.scenes[si]; break; }
|
||||
}
|
||||
if(!initial) initial=project.scenes[0];
|
||||
currentSceneId=initial.id;
|
||||
} else {
|
||||
// Legacy single-scene payload — wrap it so the rest of the
|
||||
// engine can operate uniformly.
|
||||
var legacy=payload || {};
|
||||
project={
|
||||
canvas: legacy.canvas,
|
||||
duration: legacy.duration,
|
||||
fps: legacy.fps,
|
||||
inTime: legacy.inTime,
|
||||
outTime: legacy.outTime,
|
||||
assets: legacy.assets || [],
|
||||
clips: legacy.clips || [],
|
||||
scenes: [{
|
||||
id: '__legacy__', name: '',
|
||||
layers: legacy.layers || [],
|
||||
clipInstances: legacy.clipInstances || [],
|
||||
clipTracks: legacy.clipTracks || [],
|
||||
scriptTracks: legacy.scriptTracks || [],
|
||||
scriptFrames: legacy.scriptFrames || [],
|
||||
markers: legacy.markers || [],
|
||||
groups: legacy.groups || [],
|
||||
trackOrder: legacy.trackOrder || [],
|
||||
backgroundVideo: legacy.backgroundVideo,
|
||||
audioTracks: legacy.audioTracks || [],
|
||||
audioClips: legacy.audioClips || []
|
||||
}],
|
||||
activeSceneId: '__legacy__'
|
||||
};
|
||||
currentSceneId='__legacy__';
|
||||
}
|
||||
var scene=mergeSceneView(project, project.scenes[0]);
|
||||
for(var fi=0; fi<project.scenes.length; fi++){
|
||||
if(project.scenes[fi].id===currentSceneId){ scene=mergeSceneView(project, project.scenes[fi]); break; }
|
||||
}
|
||||
|
||||
// Honour explicit in/out points. When outTime isn't set, play the
|
||||
// full timeline (scene.duration) — matches the editor's playbackOut
|
||||
// getter. Using the last keyframe as a fallback made still-frame
|
||||
@@ -142,21 +241,31 @@ const ENGINE_SOURCE = `(function(){
|
||||
var sceneEnd=rangeOut;
|
||||
var cs=getComputedStyle(container);
|
||||
if(cs.position==='static')container.style.position='relative';
|
||||
var bgVideo=null;
|
||||
if(scene.backgroundVideo && scene.backgroundVideo.src){
|
||||
bgVideo=document.createElement('video');
|
||||
bgVideo.src=scene.backgroundVideo.src;
|
||||
bgVideo.muted=true;
|
||||
bgVideo.playsInline=true;
|
||||
bgVideo.loop=!!loop;
|
||||
bgVideo.preload='auto';
|
||||
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
|
||||
container.appendChild(bgVideo);
|
||||
|
||||
// Background videos: create one <video> element per scene that has
|
||||
// a bg video, preload them all, toggle display on scene-switch so
|
||||
// the active one plays and the others idle without reloading.
|
||||
var bgVideos={};
|
||||
for(var bvi=0; bvi<project.scenes.length; bvi++){
|
||||
var bvs=project.scenes[bvi];
|
||||
if(bvs.backgroundVideo && bvs.backgroundVideo.src){
|
||||
var bv=document.createElement('video');
|
||||
bv.src=bvs.backgroundVideo.src;
|
||||
bv.muted=true;
|
||||
bv.playsInline=true;
|
||||
bv.loop=!!loop;
|
||||
bv.preload='auto';
|
||||
bv.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
|
||||
if(bvs.id !== currentSceneId) bv.style.display='none';
|
||||
container.appendChild(bv);
|
||||
bgVideos[bvs.id]=bv;
|
||||
}
|
||||
}
|
||||
var bgVideo=bgVideos[currentSceneId] || null;
|
||||
// --- Audio pool ---
|
||||
var audioPool={};
|
||||
var audioAssetsById={};
|
||||
(scene.assets||[]).forEach(function(a){ if(a.kind==='audio') audioAssetsById[a.id]=a; });
|
||||
(project.assets||[]).forEach(function(a){ if(a.kind==='audio') audioAssetsById[a.id]=a; });
|
||||
var audioClips=scene.audioClips||[];
|
||||
var audioTracksById={};
|
||||
(scene.audioTracks||[]).forEach(function(t){ audioTracksById[t.id]=t; });
|
||||
@@ -253,6 +362,50 @@ const ENGINE_SOURCE = `(function(){
|
||||
};
|
||||
(scene.markers||[]).forEach(function(mk){ runtime.markers[mk.name]=mk.time; });
|
||||
|
||||
/**
|
||||
* Switch the live scene view to the given scene page. All cross-
|
||||
* scene state (markers, audio lookup maps, active bg video,
|
||||
* button runtime state) is rebuilt. Currently-playing audio
|
||||
* clips pause immediately — the next syncAudio tick resumes
|
||||
* whatever overlaps the playhead on the new page.
|
||||
*/
|
||||
function applySceneSwitch(newId){
|
||||
var next=null;
|
||||
for(var ssi=0; ssi<project.scenes.length; ssi++){
|
||||
if(project.scenes[ssi].id === newId){ next=project.scenes[ssi]; break; }
|
||||
}
|
||||
if(!next) return false;
|
||||
for(var ap in audioPool){
|
||||
if(Object.prototype.hasOwnProperty.call(audioPool,ap) && audioPool[ap].el && !audioPool[ap].el.paused){
|
||||
audioPool[ap].el.pause();
|
||||
}
|
||||
}
|
||||
currentSceneId=newId;
|
||||
scene=mergeSceneView(project, next);
|
||||
audioClips=scene.audioClips||[];
|
||||
audioTracksById={};
|
||||
(scene.audioTracks||[]).forEach(function(t){ audioTracksById[t.id]=t; });
|
||||
runtime.markers={};
|
||||
(scene.markers||[]).forEach(function(m){ runtime.markers[m.name]=m.time; });
|
||||
runtime.buttonStates={};
|
||||
runtime.buttonDisabled={};
|
||||
for(var bvk in bgVideos){
|
||||
if(Object.prototype.hasOwnProperty.call(bgVideos,bvk)){
|
||||
if(bvk === currentSceneId){ bgVideos[bvk].style.display=''; }
|
||||
else { bgVideos[bvk].style.display='none'; try { bgVideos[bvk].pause(); } catch(e){} }
|
||||
}
|
||||
}
|
||||
bgVideo=bgVideos[currentSceneId] || null;
|
||||
bgStarted=false;
|
||||
// Restart the incoming scene at the project in-point — matches
|
||||
// the editor store-side switchScene with resetPlayhead=true.
|
||||
// Script or button jumps transition between scenes; the target
|
||||
// should play from frame 0 instead of inheriting the paused
|
||||
// frame of the previous scene.
|
||||
runtime.jumpTo=runtime.rangeIn;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Populated once per draw(); used for pointer hit-testing.
|
||||
var buttonInstancesInFrame=[];
|
||||
function resolveButtonState(layer){
|
||||
@@ -406,12 +559,13 @@ const ENGINE_SOURCE = `(function(){
|
||||
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.
|
||||
// Host-driven swap (fullscreen preview routes through the
|
||||
// store and re-mounts). Fall back to the runtime's own
|
||||
// multi-scene switch for standalone bundles.
|
||||
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');
|
||||
if(!applySceneSwitch(act.sceneId)){
|
||||
runtime.warn('jumpToScene: unknown scene id "'+act.sceneId+'"');
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'script': {
|
||||
@@ -860,8 +1014,75 @@ const ENGINE_SOURCE = `(function(){
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
loadImages(scene.assets).then(function(loaded){
|
||||
imgs=loaded;
|
||||
// --- Preloader ---
|
||||
// Overlay a spinner while images, audio and background video
|
||||
// finish loading. All clips get their <audio> element created
|
||||
// up-front (and waited on via canplaythrough) so playback starts
|
||||
// without stutter. Timeouts cap each pending asset at 10s to
|
||||
// keep broken URLs from freezing the stage forever.
|
||||
if(!document.getElementById('ruwio-spinner-style')){
|
||||
var spinnerStyle=document.createElement('style');
|
||||
spinnerStyle.id='ruwio-spinner-style';
|
||||
spinnerStyle.textContent='@keyframes ruwio-spin{to{transform:rotate(360deg);}}';
|
||||
document.head.appendChild(spinnerStyle);
|
||||
}
|
||||
var preloader=document.createElement('div');
|
||||
preloader.className='ruwio-preloader';
|
||||
preloader.style.cssText='position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.35);z-index:100;';
|
||||
var spinnerDot=document.createElement('div');
|
||||
spinnerDot.style.cssText='width:36px;height:36px;border:3px solid rgba(255,255,255,0.3);border-top-color:rgba(255,255,255,0.9);border-radius:50%;animation:ruwio-spin 1s linear infinite;';
|
||||
preloader.appendChild(spinnerDot);
|
||||
container.appendChild(preloader);
|
||||
|
||||
function waitForElement(el, readyEvent){
|
||||
return new Promise(function(resolve){
|
||||
var settled=false;
|
||||
var finish=function(){ if(!settled){ settled=true; resolve(); } };
|
||||
el.addEventListener(readyEvent, finish, { once:true });
|
||||
el.addEventListener('error', finish, { once:true });
|
||||
setTimeout(finish, 10000);
|
||||
});
|
||||
}
|
||||
function preloadAudioClips(){
|
||||
// Preload EVERY clip across every scene so jumpToScene doesn't
|
||||
// stutter. audioPool is keyed by clip id, so these elements are
|
||||
// reused verbatim by syncAudio.
|
||||
var proms=[];
|
||||
for(var spi=0; spi<project.scenes.length; spi++){
|
||||
var spage=project.scenes[spi];
|
||||
var sclips=spage.audioClips||[];
|
||||
for(var pi=0;pi<sclips.length;pi++){
|
||||
var c=sclips[pi];
|
||||
var a=audioAssetsById[c.audioAssetId];
|
||||
if(!a || !a.src) continue;
|
||||
var el=document.createElement('audio');
|
||||
el.preload='auto';
|
||||
el.src=a.src;
|
||||
el.style.display='none';
|
||||
container.appendChild(el);
|
||||
audioPool[c.id]={ el: el, assetId: c.audioAssetId };
|
||||
proms.push(waitForElement(el, 'canplaythrough'));
|
||||
try { el.load(); } catch(e) {}
|
||||
}
|
||||
}
|
||||
return proms;
|
||||
}
|
||||
function preloadBgVideos(){
|
||||
var proms=[];
|
||||
for(var bvk in bgVideos){
|
||||
if(Object.prototype.hasOwnProperty.call(bgVideos,bvk)){
|
||||
proms.push(waitForElement(bgVideos[bvk], 'loadedmetadata'));
|
||||
}
|
||||
}
|
||||
return proms;
|
||||
}
|
||||
Promise.all([
|
||||
loadImages(project.assets),
|
||||
Promise.all(preloadBgVideos()),
|
||||
Promise.all(preloadAudioClips()),
|
||||
]).then(function(results){
|
||||
imgs=results[0];
|
||||
if(preloader.parentNode) preloader.parentNode.removeChild(preloader);
|
||||
rafId=requestAnimationFrame(frame);
|
||||
});
|
||||
return {
|
||||
@@ -881,7 +1102,10 @@ const ENGINE_SOURCE = `(function(){
|
||||
canvas.removeEventListener('mouseup', onStageUp);
|
||||
ro.disconnect();
|
||||
canvas.remove();
|
||||
if(bgVideo) bgVideo.remove();
|
||||
for(var dbvk in bgVideos){
|
||||
if(Object.prototype.hasOwnProperty.call(bgVideos,dbvk)) bgVideos[dbvk].remove();
|
||||
}
|
||||
if(preloader && preloader.parentNode) preloader.parentNode.removeChild(preloader);
|
||||
disposeAllAudio();
|
||||
}
|
||||
};
|
||||
@@ -938,8 +1162,9 @@ export interface MountOptions {
|
||||
* 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. */
|
||||
* re-mounting with the new active page). Exported bundles omit
|
||||
* this — the runtime then switches scenes internally by walking
|
||||
* `project.scenes` and toggling the preloaded audio/video pools. */
|
||||
onJumpToScene?: (sceneId: string) => void
|
||||
}
|
||||
|
||||
@@ -956,46 +1181,41 @@ function ensureEngine() {
|
||||
|
||||
export function mountPlayer(
|
||||
container: HTMLElement,
|
||||
scene: Scene,
|
||||
payload: ExportPayload,
|
||||
opts?: MountOptions,
|
||||
): PlayerHandle {
|
||||
ensureEngine()
|
||||
return cachedApi.mount(container, JSON.parse(JSON.stringify(scene)), opts)
|
||||
return cachedApi.mount(container, JSON.parse(JSON.stringify(payload)), opts)
|
||||
}
|
||||
|
||||
// ---- Export helpers ----
|
||||
|
||||
function jsonForExport(scene: Scene): string {
|
||||
function jsonForExport(payload: ExportPayload): string {
|
||||
// Data-URL background videos and audio assets are stripped from the
|
||||
// export — local media can't be embedded reasonably, and doing so
|
||||
// would bloat the bundle into uselessness. The user has to host the
|
||||
// file externally and point `backgroundVideo.src` / `AudioAsset.src`
|
||||
// at that URL before exporting.
|
||||
// files externally and point `backgroundVideo.src` / `AudioAsset.src`
|
||||
// at real URLs before exporting.
|
||||
//
|
||||
// We always emit a Project. A legacy single Scene gets wrapped in a
|
||||
// one-page Project so the runtime can do its multi-scene plumbing
|
||||
// uniformly.
|
||||
const project: Project = isProject(payload)
|
||||
? (payload as Project)
|
||||
: sceneToLegacyProject(payload as Scene)
|
||||
|
||||
const warnings: string[] = []
|
||||
let clone: Scene = scene
|
||||
let out: Project = project
|
||||
|
||||
const bg = scene.backgroundVideo
|
||||
if (bg && bg.src && bg.src.startsWith('data:')) {
|
||||
warnings.push('background video')
|
||||
const { backgroundVideo, ...rest } = scene
|
||||
void backgroundVideo
|
||||
clone = rest as Scene
|
||||
}
|
||||
|
||||
const audioAssets = clone.assets.filter(
|
||||
// Strip data-URL audio assets at the shared asset list.
|
||||
const dataAudio = out.assets.filter(
|
||||
(a) => a.kind === 'audio' && a.src && a.src.startsWith('data:'),
|
||||
)
|
||||
if (audioAssets.length > 0) {
|
||||
warnings.push(
|
||||
`${audioAssets.length} audio asset(s) without external URL`,
|
||||
)
|
||||
// Replace the `src` with an empty string so the exported bundle's
|
||||
// lookups still find the asset record (duration, name) and the
|
||||
// runtime can render an empty block rather than crashing on a
|
||||
// missing asset.
|
||||
clone = {
|
||||
...clone,
|
||||
assets: clone.assets.map((a) =>
|
||||
if (dataAudio.length > 0) {
|
||||
warnings.push(`${dataAudio.length} audio asset(s) without external URL`)
|
||||
out = {
|
||||
...out,
|
||||
assets: out.assets.map((a) =>
|
||||
a.kind === 'audio' && a.src && a.src.startsWith('data:')
|
||||
? { ...a, src: '' }
|
||||
: a,
|
||||
@@ -1003,6 +1223,21 @@ function jsonForExport(scene: Scene): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Strip data-URL backgroundVideos from every scene.
|
||||
const strippedScenes = out.scenes.map((page) => {
|
||||
const bg = page.backgroundVideo
|
||||
if (bg && bg.src && bg.src.startsWith('data:')) {
|
||||
const { backgroundVideo, ...rest } = page
|
||||
void backgroundVideo
|
||||
warnings.push(`background video on scene "${page.name}"`)
|
||||
return rest as typeof page
|
||||
}
|
||||
return page
|
||||
})
|
||||
if (strippedScenes.some((s, i) => s !== out.scenes[i])) {
|
||||
out = { ...out, scenes: strippedScenes }
|
||||
}
|
||||
|
||||
if (warnings.length > 0 && typeof window !== 'undefined' && window.console) {
|
||||
window.console.warn(
|
||||
'[ruwio] export: stripped ' +
|
||||
@@ -1010,7 +1245,38 @@ function jsonForExport(scene: Scene): string {
|
||||
' — host the files externally and set their src fields before exporting.',
|
||||
)
|
||||
}
|
||||
return JSON.stringify(clone)
|
||||
return JSON.stringify(out)
|
||||
}
|
||||
|
||||
function sceneToLegacyProject(scene: Scene): Project {
|
||||
return {
|
||||
version: 1,
|
||||
canvas: scene.canvas,
|
||||
duration: scene.duration,
|
||||
fps: scene.fps,
|
||||
inTime: scene.inTime,
|
||||
outTime: scene.outTime,
|
||||
assets: scene.assets,
|
||||
clips: scene.clips ?? [],
|
||||
scenes: [
|
||||
{
|
||||
id: '__legacy__',
|
||||
name: 'Scene',
|
||||
layers: scene.layers,
|
||||
clipInstances: scene.clipInstances ?? [],
|
||||
clipTracks: scene.clipTracks ?? [],
|
||||
scriptTracks: scene.scriptTracks ?? [],
|
||||
scriptFrames: scene.scriptFrames ?? [],
|
||||
markers: scene.markers ?? [],
|
||||
groups: scene.groups ?? [],
|
||||
trackOrder: scene.trackOrder ?? [],
|
||||
backgroundVideo: scene.backgroundVideo,
|
||||
audioTracks: scene.audioTracks ?? [],
|
||||
audioClips: scene.audioClips ?? [],
|
||||
},
|
||||
],
|
||||
activeSceneId: '__legacy__',
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
@@ -1024,8 +1290,8 @@ export function generateRuntimeScript(): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function generateSceneScript(scene: Scene, options: ExportOptions = {}): string {
|
||||
const sceneJson = jsonForExport(scene)
|
||||
export function generateSceneScript(payload: ExportPayload, options: ExportOptions = {}): string {
|
||||
const sceneJson = jsonForExport(payload)
|
||||
const optsJson = JSON.stringify({ loop: options.loop !== false })
|
||||
return (
|
||||
'/* RUWIO scene — generated ' + new Date().toISOString() + '\n' +
|
||||
@@ -1035,17 +1301,17 @@ export function generateSceneScript(scene: Scene, options: ExportOptions = {}):
|
||||
)
|
||||
}
|
||||
|
||||
export function generateBundledScript(scene: Scene, options: ExportOptions = {}): string {
|
||||
export function generateBundledScript(payload: ExportPayload, options: ExportOptions = {}): string {
|
||||
return (
|
||||
'/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' +
|
||||
ENGINE_SOURCE +
|
||||
'\n' +
|
||||
generateSceneScriptBody(scene, options)
|
||||
generateSceneScriptBody(payload, options)
|
||||
)
|
||||
}
|
||||
|
||||
function generateSceneScriptBody(scene: Scene, options: ExportOptions): string {
|
||||
const sceneJson = jsonForExport(scene)
|
||||
function generateSceneScriptBody(payload: ExportPayload, options: ExportOptions): string {
|
||||
const sceneJson = jsonForExport(payload)
|
||||
const optsJson = JSON.stringify({ loop: options.loop !== false })
|
||||
return SCENE_BOOT_SOURCE.replace('__SCENE__', sceneJson).replace('__OPTIONS__', optsJson)
|
||||
}
|
||||
@@ -1054,18 +1320,19 @@ export function downloadRuntime(filename = 'ruwio-runtime.js') {
|
||||
triggerDownload(generateRuntimeScript(), filename, 'application/javascript')
|
||||
}
|
||||
|
||||
export function downloadSceneExport(scene: Scene, filename = 'animation.js') {
|
||||
triggerDownload(generateSceneScript(scene), filename, 'application/javascript')
|
||||
export function downloadSceneExport(payload: ExportPayload, filename = 'animation.js') {
|
||||
triggerDownload(generateSceneScript(payload), filename, 'application/javascript')
|
||||
}
|
||||
|
||||
export function downloadBundledExport(scene: Scene, filename = 'animation-bundled.js') {
|
||||
triggerDownload(generateBundledScript(scene), filename, 'application/javascript')
|
||||
export function downloadBundledExport(payload: ExportPayload, filename = 'animation-bundled.js') {
|
||||
triggerDownload(generateBundledScript(payload), filename, 'application/javascript')
|
||||
}
|
||||
|
||||
export function generateExampleHtml(scene: Scene, options: ExportOptions = {}): string {
|
||||
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
|
||||
const runtime = ENGINE_SOURCE
|
||||
const sceneBoot = generateSceneScriptBody(scene, options)
|
||||
const aspect = `${scene.canvas.width} / ${scene.canvas.height}`
|
||||
const sceneBoot = generateSceneScriptBody(payload, options)
|
||||
const canvas = isProject(payload) ? payload.canvas : payload.canvas
|
||||
const aspect = `${canvas.width} / ${canvas.height}`
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -1159,8 +1426,8 @@ ${sceneBoot}
|
||||
`
|
||||
}
|
||||
|
||||
export function downloadExampleHtml(scene: Scene, filename = 'animation-example.html') {
|
||||
const source = generateExampleHtml(scene)
|
||||
export function downloadExampleHtml(payload: ExportPayload, filename = 'animation-example.html') {
|
||||
const source = generateExampleHtml(payload)
|
||||
triggerDownload(source, filename, 'text/html')
|
||||
}
|
||||
|
||||
|
||||
+14
-6
@@ -1973,11 +1973,13 @@ 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.
|
||||
* Playback state is left alone — in the editor, flipping scenes
|
||||
* is a navigation gesture and shouldn't force play. The fullscreen
|
||||
* preview takes care of auto-playing on its own (the activeSceneId
|
||||
* watcher re-mounts the preview player with play enabled). */
|
||||
switchScene(id: string): boolean {
|
||||
* Playback state is left alone by default — in the editor,
|
||||
* flipping scenes is a navigation gesture and shouldn't force
|
||||
* play or rewind. Script-fired or button-fired jumps use
|
||||
* `{ resetPlayhead: true }` instead so the incoming scene starts
|
||||
* at its own in-point rather than inheriting the previous
|
||||
* scene's paused frame. */
|
||||
switchScene(id: string, options?: { resetPlayhead?: boolean }): boolean {
|
||||
if (!this.project.scenes.find((s) => s.id === id)) return false
|
||||
this.project.activeSceneId = id
|
||||
this.activeClipId = null
|
||||
@@ -1990,6 +1992,9 @@ export const useSceneStore = defineStore('scene', {
|
||||
this.drawingMode = null
|
||||
this.drawingAssetId = null
|
||||
this.drawingLayerId = null
|
||||
if (options?.resetPlayhead) {
|
||||
this.playhead = this.playbackIn
|
||||
}
|
||||
return true
|
||||
},
|
||||
renameScene(id: string, name: string): boolean {
|
||||
@@ -2623,7 +2628,10 @@ export const useSceneStore = defineStore('scene', {
|
||||
self.pause()
|
||||
},
|
||||
switchScene(sceneId: string) {
|
||||
self.switchScene(sceneId)
|
||||
// Script/button jumps restart the target scene at its in-point.
|
||||
// Drawer clicks go through store.switchScene directly and keep
|
||||
// the current playhead — navigation vs. transition.
|
||||
self.switchScene(sceneId, { resetPlayhead: true })
|
||||
},
|
||||
warn(msg: string) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
Reference in new issue
Block a user