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
+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')
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user