0.10.0: audio tracks — assets, per-scene lanes, editor + export playback

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 11:53:41 +02:00
1 parent b9f2ba9e47
commit 04f867547d
14 files changed
+2057 -42

No files matched your search

+99 -6
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '19'
const RUNTIME_VERSION = '20'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -153,6 +153,61 @@ const ENGINE_SOURCE = `(function(){
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
container.appendChild(bgVideo);
}
// --- Audio pool ---
var audioPool={};
var audioAssetsById={};
(scene.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; });
function disposeAudioClip(clipId){
var entry=audioPool[clipId];
if(!entry) return;
entry.el.pause();
entry.el.src='';
if(entry.el.parentNode) entry.el.parentNode.removeChild(entry.el);
delete audioPool[clipId];
}
function disposeAllAudio(){
for(var k in audioPool){ if(Object.prototype.hasOwnProperty.call(audioPool,k)) disposeAudioClip(k); }
}
function syncAudio(t, isPlaying){
for(var ci=0;ci<audioClips.length;ci++){
var clip=audioClips[ci];
var asset=audioAssetsById[clip.audioAssetId];
if(!asset || !asset.src){ disposeAudioClip(clip.id); continue; }
var entry=audioPool[clip.id];
if(!entry || entry.assetId !== clip.audioAssetId){
if(entry) disposeAudioClip(clip.id);
var el=document.createElement('audio');
el.preload='auto';
el.src=asset.src;
el.style.display='none';
container.appendChild(el);
entry={ el: el, assetId: clip.audioAssetId };
audioPool[clip.id]=entry;
}
var track=audioTracksById[clip.audioTrackId];
var trimS=clip.trimStart||0;
var trimE=clip.trimEnd||0;
var winLen=Math.max(0, asset.duration - trimS - trimE);
var inWindow=t>=clip.startTime && t<(clip.startTime+winLen);
var shouldPlay=isPlaying && inWindow && !(track && track.muted);
if(shouldPlay){
var audioTime=t - clip.startTime + trimS;
if(entry.el.paused){
try { entry.el.currentTime=audioTime; } catch(e) {}
var p=entry.el.play();
if(p && typeof p.catch==='function') p.catch(function(){});
} else {
var drift=Math.abs(entry.el.currentTime - audioTime);
if(drift > 0.25){ try { entry.el.currentTime=audioTime; } catch(e) {} }
}
} else if(!entry.el.paused){
entry.el.pause();
}
}
}
var canvas=document.createElement('canvas');
canvas.style.cssText='display:block;width:100%;height:100%;position:relative;z-index:1;';
container.appendChild(canvas);
@@ -397,6 +452,7 @@ const ENGINE_SOURCE = `(function(){
if(rangeLen<=0){
draw(currentT);
syncBgVideo(currentT);
syncAudio(currentT, !paused);
if(onFrame) onFrame(currentT, paused);
rafId=requestAnimationFrame(frame);
return;
@@ -420,6 +476,7 @@ const ENGINE_SOURCE = `(function(){
if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; lastNow=0; }
draw(currentT);
syncBgVideo(currentT);
syncAudio(currentT, !paused);
if(onFrame) onFrame(currentT, paused);
rafId=requestAnimationFrame(frame);
}
@@ -825,6 +882,7 @@ const ENGINE_SOURCE = `(function(){
ro.disconnect();
canvas.remove();
if(bgVideo) bgVideo.remove();
disposeAllAudio();
}
};
}
@@ -908,16 +966,51 @@ export function mountPlayer(
// ---- Export helpers ----
function jsonForExport(scene: Scene): string {
// Data-URL background videos are stripped from the export — local video
// files can't be embedded reasonably. The user needs to host the media
// externally and set a URL in settings before exporting.
// 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.
const warnings: string[] = []
let clone: Scene = scene
const bg = scene.backgroundVideo
if (bg && bg.src && bg.src.startsWith('data:')) {
warnings.push('background video')
const { backgroundVideo, ...rest } = scene
void backgroundVideo
return JSON.stringify(rest)
clone = rest as Scene
}
return JSON.stringify(scene)
const audioAssets = clone.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) =>
a.kind === 'audio' && a.src && a.src.startsWith('data:')
? { ...a, src: '' }
: a,
),
}
}
if (warnings.length > 0 && typeof window !== 'undefined' && window.console) {
window.console.warn(
'[ruwio] export: stripped ' +
warnings.join(', ') +
' — host the files externally and set their src fields before exporting.',
)
}
return JSON.stringify(clone)
}
export interface ExportOptions {