init
This commit is contained in:
commit
c6863f2bfc
34 files changed
+6909
No files matched your search
@@ -0,0 +1,326 @@
|
||||
import type { Scene } from '@/types/scene'
|
||||
|
||||
const RUNTIME_SOURCE = `(function(){
|
||||
var SCENE = __SCENE__;
|
||||
var OPTIONS = __OPTIONS__;
|
||||
|
||||
function easing(name, t){
|
||||
if(name==='easeIn')return t*t;
|
||||
if(name==='easeOut')return 1-(1-t)*(1-t);
|
||||
if(name==='easeInOut')return t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2;
|
||||
return t;
|
||||
}
|
||||
function merge(base,partial){
|
||||
var o={};for(var k in base)o[k]=base[k];
|
||||
if(partial)for(var k2 in partial)o[k2]=partial[k2];
|
||||
return o;
|
||||
}
|
||||
function lerp(a,b,t){return a+(b-a)*t;}
|
||||
function parseHex(hex){
|
||||
if(!hex)return null;
|
||||
var h=String(hex).trim().replace('#','');
|
||||
if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
|
||||
if(h.length<6)return null;
|
||||
var n=parseInt(h.slice(0,6),16);
|
||||
if(isNaN(n))return null;
|
||||
return [(n>>16)&255,(n>>8)&255,n&255];
|
||||
}
|
||||
function formatHex(r,g,b){
|
||||
function c(v){v=Math.round(v);if(v<0)v=0;if(v>255)v=255;var s=v.toString(16);return s.length<2?'0'+s:s;}
|
||||
return '#'+c(r)+c(g)+c(b);
|
||||
}
|
||||
function lerpColor(a,b,t){
|
||||
var pa=parseHex(a),pb=parseHex(b);
|
||||
if(!pa||!pb)return t<0.5?a:b;
|
||||
return formatHex(lerp(pa[0],pb[0],t),lerp(pa[1],pb[1],t),lerp(pa[2],pb[2],t));
|
||||
}
|
||||
function lerpProps(a,b,t){
|
||||
var out={
|
||||
x:lerp(a.x,b.x,t),
|
||||
y:lerp(a.y,b.y,t),
|
||||
scaleX:lerp(a.scaleX,b.scaleX,t),
|
||||
scaleY:lerp(a.scaleY,b.scaleY,t),
|
||||
rotation:lerp(a.rotation,b.rotation,t),
|
||||
opacity:lerp(a.opacity,b.opacity,t)
|
||||
};
|
||||
if(a.fill&&b.fill)out.fill=lerpColor(a.fill,b.fill,t);
|
||||
else if(b.fill)out.fill=b.fill;
|
||||
else if(a.fill)out.fill=a.fill;
|
||||
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);
|
||||
var last=kfs[kfs.length-1];
|
||||
if(time>=last.time)return merge(layer.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){
|
||||
var raw=(time-a.time)/(b.time-a.time);
|
||||
var t=easing(b.easing,raw);
|
||||
return lerpProps(merge(layer.base,a.props),merge(layer.base,b.props),t);
|
||||
}
|
||||
}
|
||||
return merge(layer.base);
|
||||
}
|
||||
|
||||
function loadImages(assets){
|
||||
var imgs={};
|
||||
var list=assets.filter(function(a){return a.kind==='image';});
|
||||
return Promise.all(list.map(function(a){
|
||||
return new Promise(function(resolve){
|
||||
var img=new Image();
|
||||
img.onload=function(){imgs[a.id]=img;resolve();};
|
||||
img.onerror=function(){resolve();};
|
||||
img.src=a.src;
|
||||
});
|
||||
})).then(function(){return imgs;});
|
||||
}
|
||||
|
||||
function effectiveEnd(scene){
|
||||
var max=0;
|
||||
for(var i=0;i<scene.layers.length;i++){
|
||||
var kfs=scene.layers[i].keyframes;
|
||||
for(var j=0;j<kfs.length;j++){ if(kfs[j].time>max) max=kfs[j].time; }
|
||||
}
|
||||
if(max<=0) return scene.duration;
|
||||
return Math.min(max,scene.duration);
|
||||
}
|
||||
function mount(container,scene,opts){
|
||||
if(!container)return;
|
||||
opts=opts||{};
|
||||
var loop=opts.loop!==false;
|
||||
var sceneEnd=effectiveEnd(scene);
|
||||
var cs=getComputedStyle(container);
|
||||
if(cs.position==='static')container.style.position='relative';
|
||||
var canvas=document.createElement('canvas');
|
||||
canvas.style.cssText='display:block;width:100%;height:100%;';
|
||||
container.appendChild(canvas);
|
||||
var ctx=canvas.getContext('2d');
|
||||
var dpr=window.devicePixelRatio||1;
|
||||
var size={w:0,h:0};
|
||||
function resize(){
|
||||
var r=container.getBoundingClientRect();
|
||||
size.w=r.width;size.h=r.height;
|
||||
canvas.width=Math.max(1,Math.round(r.width*dpr));
|
||||
canvas.height=Math.max(1,Math.round(r.height*dpr));
|
||||
}
|
||||
var ro=new ResizeObserver(resize);
|
||||
ro.observe(container);
|
||||
resize();
|
||||
var assetMap={};
|
||||
scene.assets.forEach(function(a){assetMap[a.id]=a;});
|
||||
var imgs={};
|
||||
var start=0;
|
||||
function frame(now){
|
||||
if(!start)start=now;
|
||||
var t=(now-start)/1000;
|
||||
if(sceneEnd<=0){ draw(0); requestAnimationFrame(frame); return; }
|
||||
if(loop) t=t%sceneEnd;
|
||||
else if(t>sceneEnd) t=sceneEnd;
|
||||
draw(t);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
function draw(time){
|
||||
var lw=scene.canvas.width,lh=scene.canvas.height;
|
||||
var cw=size.w,ch=size.h;
|
||||
if(!cw||!ch)return;
|
||||
var s=Math.min(cw/lw,ch/lh);
|
||||
var ox=(cw-lw*s)/2;
|
||||
var oy=(ch-lh*s)/2;
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||
ctx.clearRect(0,0,cw,ch);
|
||||
ctx.save();
|
||||
ctx.translate(ox,oy);
|
||||
ctx.scale(s,s);
|
||||
ctx.beginPath();
|
||||
ctx.rect(0,0,lw,lh);
|
||||
ctx.clip();
|
||||
ctx.fillStyle=scene.canvas.background;
|
||||
ctx.fillRect(0,0,lw,lh);
|
||||
for(var i=0;i<scene.layers.length;i++){
|
||||
var layer=scene.layers[i];
|
||||
if(!layer.visible)continue;
|
||||
var asset=assetMap[layer.assetId];
|
||||
if(!asset)continue;
|
||||
var p=sample(layer,time);
|
||||
ctx.save();
|
||||
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
||||
ctx.translate(p.x,p.y);
|
||||
ctx.rotate(p.rotation*Math.PI/180);
|
||||
ctx.scale(p.scaleX,p.scaleY);
|
||||
if(asset.kind==='rect'){
|
||||
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||
ctx.fillStyle=p.fill||asset.fill;
|
||||
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
|
||||
}else if(asset.kind==='circle'){
|
||||
ctx.fillStyle=p.fill||asset.fill;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0,0,asset.radius,0,Math.PI*2);
|
||||
ctx.fill();
|
||||
}else if(asset.kind==='text'){
|
||||
ctx.fillStyle=p.fill||asset.fill;
|
||||
ctx.font=asset.fontSize+'px sans-serif';
|
||||
ctx.textBaseline='top';
|
||||
ctx.fillText(asset.text,0,0);
|
||||
}else if(asset.kind==='image'){
|
||||
var img=imgs[asset.id];
|
||||
if(img){
|
||||
var iOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||
var iOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
loadImages(scene.assets).then(function(loaded){
|
||||
imgs=loaded;
|
||||
requestAnimationFrame(frame);
|
||||
});
|
||||
}
|
||||
|
||||
var script=document.currentScript;
|
||||
var target=null;
|
||||
if(script&&script.dataset&&script.dataset.target){
|
||||
target=document.querySelector(script.dataset.target);
|
||||
}else if(script&&script.parentElement){
|
||||
target=script.parentElement;
|
||||
}
|
||||
var autoOpts={};
|
||||
if(script&&script.dataset){
|
||||
if(script.dataset.loop==='false')autoOpts.loop=false;
|
||||
}
|
||||
if(target)mount(target,SCENE,Object.assign({},OPTIONS,autoOpts));
|
||||
var api={mount:function(c,opts){return mount(c,SCENE,opts||{});}};
|
||||
if(typeof window!=='undefined'){
|
||||
window.AnimatrPlayer=window.AnimatrPlayer||api;
|
||||
}
|
||||
})();
|
||||
`
|
||||
|
||||
function jsonForExport(scene: Scene): string {
|
||||
return JSON.stringify(scene)
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
loop?: boolean
|
||||
}
|
||||
|
||||
export function generateExportScript(scene: Scene, options: ExportOptions = {}): string {
|
||||
const sceneJson = jsonForExport(scene)
|
||||
const optsJson = JSON.stringify({ loop: options.loop !== false })
|
||||
return (
|
||||
'/* Animatr export — generated ' +
|
||||
new Date().toISOString() +
|
||||
' */\n' +
|
||||
RUNTIME_SOURCE.replace('__SCENE__', sceneJson).replace('__OPTIONS__', optsJson)
|
||||
)
|
||||
}
|
||||
|
||||
export function downloadExport(scene: Scene, filename = 'animation.js') {
|
||||
const source = generateExportScript(scene)
|
||||
triggerDownload(source, filename, 'application/javascript')
|
||||
}
|
||||
|
||||
export function generateExampleHtml(scene: Scene, options: ExportOptions = {}): string {
|
||||
const script = generateExportScript(scene, options)
|
||||
const aspect = `${scene.canvas.width} / ${scene.canvas.height}`
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Animatr — Beispiel</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #1e1e22; color: #e5e5ea; }
|
||||
pre { background: #26262c !important; color: #e5e5ea; }
|
||||
.stage { background: #26262c; box-shadow: 0 0 0 1px #3a3a42; }
|
||||
}
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
p { margin: 0; color: #6e6e73; font-size: 13px; text-align: center; }
|
||||
.stage {
|
||||
width: min(100%, 960px);
|
||||
aspect-ratio: ${aspect};
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.08);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.usage {
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
}
|
||||
.usage h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
color: #6e6e73;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
pre {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Animatr Export</h1>
|
||||
<p>Diese HTML-Datei enthält Runtime + Szene inline — einfach im Browser öffnen.</p>
|
||||
|
||||
<div class="stage">
|
||||
<script>
|
||||
${script}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div class="usage">
|
||||
<h2>Einbinden in eine eigene Seite</h2>
|
||||
<pre><div style="width: 800px; height: 450px;">
|
||||
<script src="animation.js"></script>
|
||||
</div></pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
export function downloadExampleHtml(scene: Scene, filename = 'animation-example.html') {
|
||||
const source = generateExampleHtml(scene)
|
||||
triggerDownload(source, filename, 'text/html')
|
||||
}
|
||||
|
||||
function triggerDownload(source: string, filename: string, mime: string) {
|
||||
const blob = new Blob([source], { type: mime })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export function readAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error(`failed to load image: ${src.slice(0, 60)}…`))
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
export interface Dimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function parseSvgDimensions(svgText: string): Dimensions | null {
|
||||
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml')
|
||||
const svg = doc.documentElement
|
||||
if (!svg || svg.nodeName !== 'svg') return null
|
||||
|
||||
const parseLen = (attr: string | null) => {
|
||||
if (!attr) return 0
|
||||
const num = parseFloat(attr)
|
||||
return Number.isFinite(num) ? num : 0
|
||||
}
|
||||
|
||||
let w = parseLen(svg.getAttribute('width'))
|
||||
let h = parseLen(svg.getAttribute('height'))
|
||||
|
||||
if (!w || !h) {
|
||||
const vb = svg.getAttribute('viewBox')?.trim().split(/[\s,]+/).map(Number)
|
||||
if (vb && vb.length === 4 && vb.every((n) => Number.isFinite(n))) {
|
||||
if (!w) w = vb[2]
|
||||
if (!h) h = vb[3]
|
||||
}
|
||||
}
|
||||
|
||||
if (!w || !h) return null
|
||||
return { width: w, height: h }
|
||||
}
|
||||
|
||||
export function isSvgFile(file: File): boolean {
|
||||
return file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
|
||||
|
||||
const easings: Record<Easing, (t: number) => number> = {
|
||||
linear: (t) => t,
|
||||
easeIn: (t) => t * t,
|
||||
easeOut: (t) => 1 - (1 - t) ** 2,
|
||||
easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2),
|
||||
}
|
||||
|
||||
function merge(base: AnimatableProps, partial: Partial<AnimatableProps>): AnimatableProps {
|
||||
return { ...base, ...partial }
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
|
||||
function parseHex(hex: string): [number, number, number] | null {
|
||||
const h = hex.trim().replace('#', '')
|
||||
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
|
||||
if (full.length < 6) return null
|
||||
const n = parseInt(full.slice(0, 6), 16)
|
||||
if (Number.isNaN(n)) return null
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
}
|
||||
|
||||
function formatHex(r: number, g: number, b: number): string {
|
||||
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
|
||||
const h = (v: number) => clamp(v).toString(16).padStart(2, '0')
|
||||
return `#${h(r)}${h(g)}${h(b)}`
|
||||
}
|
||||
|
||||
export function lerpColor(a: string, b: string, t: number): string {
|
||||
const pa = parseHex(a)
|
||||
const pb = parseHex(b)
|
||||
if (!pa || !pb) return t < 0.5 ? a : b
|
||||
return formatHex(lerp(pa[0], pb[0], t), lerp(pa[1], pb[1], t), lerp(pa[2], pb[2], t))
|
||||
}
|
||||
|
||||
function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): AnimatableProps {
|
||||
const out: AnimatableProps = {
|
||||
x: lerp(a.x, b.x, t),
|
||||
y: lerp(a.y, b.y, t),
|
||||
scaleX: lerp(a.scaleX, b.scaleX, t),
|
||||
scaleY: lerp(a.scaleY, b.scaleY, t),
|
||||
rotation: lerp(a.rotation, b.rotation, t),
|
||||
opacity: lerp(a.opacity, b.opacity, t),
|
||||
}
|
||||
if (a.fill && b.fill) out.fill = lerpColor(a.fill, b.fill, t)
|
||||
else if (b.fill) out.fill = b.fill
|
||||
else if (a.fill) out.fill = a.fill
|
||||
return out
|
||||
}
|
||||
|
||||
export function sortedKeyframes(layer: Layer): Keyframe[] {
|
||||
return [...layer.keyframes].sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
|
||||
const kfs = sortedKeyframes(layer)
|
||||
if (!kfs.length) return { ...layer.base }
|
||||
if (time <= kfs[0].time) return merge(layer.base, kfs[0].props)
|
||||
const last = kfs[kfs.length - 1]
|
||||
if (time >= last.time) return merge(layer.base, last.props)
|
||||
for (let i = 0; i < kfs.length - 1; i++) {
|
||||
const a = kfs[i]
|
||||
const b = kfs[i + 1]
|
||||
if (time >= a.time && time <= b.time) {
|
||||
const raw = (time - a.time) / (b.time - a.time)
|
||||
const t = easings[b.easing](raw)
|
||||
return lerpProps(merge(layer.base, a.props), merge(layer.base, b.props), t)
|
||||
}
|
||||
}
|
||||
return { ...layer.base }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Asset, Scene } from '@/types/scene'
|
||||
import { sampleLayer } from './interpolate'
|
||||
|
||||
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
||||
const result: Record<string, HTMLImageElement> = {}
|
||||
const images = scene.assets.filter(
|
||||
(a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image',
|
||||
)
|
||||
await Promise.all(
|
||||
images.map(
|
||||
(a) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
result[a.id] = img
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => resolve()
|
||||
img.src = a.src
|
||||
}),
|
||||
),
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export function drawSceneFrame(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
scene: Scene,
|
||||
time: number,
|
||||
images: Record<string, HTMLImageElement>,
|
||||
) {
|
||||
const lw = scene.canvas.width
|
||||
const lh = scene.canvas.height
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, lw, lh)
|
||||
ctx.clip()
|
||||
ctx.fillStyle = scene.canvas.background
|
||||
ctx.fillRect(0, 0, lw, lh)
|
||||
|
||||
const assetMap = new Map<string, Asset>()
|
||||
for (const a of scene.assets) assetMap.set(a.id, a)
|
||||
|
||||
for (const layer of scene.layers) {
|
||||
if (!layer.visible) continue
|
||||
const asset = assetMap.get(layer.assetId)
|
||||
if (!asset) continue
|
||||
const p = sampleLayer(layer, time)
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha *= p.opacity
|
||||
ctx.translate(p.x, p.y)
|
||||
ctx.rotate((p.rotation * Math.PI) / 180)
|
||||
ctx.scale(p.scaleX, p.scaleY)
|
||||
|
||||
if (asset.kind === 'rect') {
|
||||
const offX = layer.offsetX ?? asset.width / 2
|
||||
const offY = layer.offsetY ?? asset.height / 2
|
||||
ctx.fillStyle = p.fill ?? asset.fill
|
||||
ctx.fillRect(-offX, -offY, asset.width, asset.height)
|
||||
} else if (asset.kind === 'circle') {
|
||||
ctx.fillStyle = p.fill ?? asset.fill
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, asset.radius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
} else if (asset.kind === 'text') {
|
||||
ctx.fillStyle = p.fill ?? asset.fill
|
||||
ctx.font = `${asset.fontSize}px sans-serif`
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.fillText(asset.text, 0, 0)
|
||||
} else if (asset.kind === 'image') {
|
||||
const img = images[asset.id]
|
||||
if (img) {
|
||||
const offX = layer.offsetX ?? asset.width / 2
|
||||
const offY = layer.offsetY ?? asset.height / 2
|
||||
ctx.drawImage(img, -offX, -offY, asset.width, asset.height)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Scene } from '@/types/scene'
|
||||
|
||||
const DEFAULT_SERVER = 'http://localhost:6000'
|
||||
const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER
|
||||
|
||||
export interface ServerRenderOptions {
|
||||
onProgress?: (progress: number) => void
|
||||
signal?: AbortSignal
|
||||
fps?: number
|
||||
crf?: number
|
||||
preset?: string
|
||||
}
|
||||
|
||||
export interface ServerRenderResult {
|
||||
blob: Blob
|
||||
extension: string
|
||||
}
|
||||
|
||||
export function getServerUrl(): string {
|
||||
return SERVER_URL
|
||||
}
|
||||
|
||||
export async function renderOnServer(
|
||||
scene: Scene,
|
||||
opts: ServerRenderOptions = {},
|
||||
): Promise<ServerRenderResult> {
|
||||
const baseUrl = SERVER_URL
|
||||
const startRes = await fetch(`${baseUrl}/render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scene,
|
||||
options: {
|
||||
fps: opts.fps,
|
||||
crf: opts.crf,
|
||||
preset: opts.preset,
|
||||
},
|
||||
}),
|
||||
signal: opts.signal,
|
||||
})
|
||||
if (!startRes.ok) {
|
||||
const body = await startRes.text().catch(() => '')
|
||||
throw new Error(`Server start fehlgeschlagen (${startRes.status}): ${body.slice(0, 200)}`)
|
||||
}
|
||||
const { jobId } = (await startRes.json()) as { jobId: string }
|
||||
if (!jobId) throw new Error('Server lieferte keine jobId')
|
||||
|
||||
const downloadUrl = await new Promise<string>((resolve, reject) => {
|
||||
const es = new EventSource(`${baseUrl}/render/${jobId}/events`)
|
||||
const onAbort = () => {
|
||||
try {
|
||||
es.close()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
fetch(`${baseUrl}/render/${jobId}`, { method: 'DELETE' }).catch(() => {})
|
||||
reject(new DOMException('aborted', 'AbortError'))
|
||||
}
|
||||
if (opts.signal) {
|
||||
if (opts.signal.aborted) return onAbort()
|
||||
opts.signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
es.onmessage = (evt) => {
|
||||
let data: {
|
||||
progress?: number
|
||||
done?: boolean
|
||||
error?: string | null
|
||||
url?: string | null
|
||||
}
|
||||
try {
|
||||
data = JSON.parse(evt.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (typeof data.progress === 'number') opts.onProgress?.(data.progress)
|
||||
if (data.done) {
|
||||
es.close()
|
||||
opts.signal?.removeEventListener('abort', onAbort)
|
||||
if (data.error) reject(new Error(data.error))
|
||||
else if (data.url) resolve(`${baseUrl}${data.url}`)
|
||||
else reject(new Error('Server meldete fertig ohne Download-URL'))
|
||||
}
|
||||
}
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
opts.signal?.removeEventListener('abort', onAbort)
|
||||
reject(new Error('Verbindung zum Render-Server verloren'))
|
||||
}
|
||||
})
|
||||
|
||||
const fileRes = await fetch(downloadUrl, { signal: opts.signal })
|
||||
if (!fileRes.ok) throw new Error(`Download fehlgeschlagen (${fileRes.status})`)
|
||||
const blob = await fileRes.blob()
|
||||
return { blob, extension: 'mp4' }
|
||||
}
|
||||
|
||||
export async function downloadFromServer(
|
||||
scene: Scene,
|
||||
opts: ServerRenderOptions = {},
|
||||
): Promise<void> {
|
||||
const { blob, extension } = await renderOnServer(scene, opts)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `animation.${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
export async function isServerReachable(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/health`, { method: 'GET' })
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Scene } from '@/types/scene'
|
||||
import { drawSceneFrame, loadSceneImages } from './render'
|
||||
|
||||
function effectiveEnd(scene: Scene): number {
|
||||
let max = 0
|
||||
for (const layer of scene.layers) {
|
||||
for (const kf of layer.keyframes) {
|
||||
if (kf.time > max) max = kf.time
|
||||
}
|
||||
}
|
||||
if (max <= 0) return scene.duration
|
||||
return Math.min(max, scene.duration)
|
||||
}
|
||||
|
||||
export interface VideoExportResult {
|
||||
blob: Blob
|
||||
mimeType: string
|
||||
extension: string
|
||||
}
|
||||
|
||||
export interface VideoExportOptions {
|
||||
onProgress?: (progress: number) => void
|
||||
signal?: AbortSignal
|
||||
bitsPerSecond?: number
|
||||
}
|
||||
|
||||
const MIME_CANDIDATES = [
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
'video/mp4;codecs=h264',
|
||||
'video/mp4',
|
||||
]
|
||||
|
||||
export function pickMimeType(): string | null {
|
||||
if (typeof MediaRecorder === 'undefined') return null
|
||||
for (const mime of MIME_CANDIDATES) {
|
||||
if (MediaRecorder.isTypeSupported?.(mime)) return mime
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function renderSceneToVideo(
|
||||
scene: Scene,
|
||||
opts: VideoExportOptions = {},
|
||||
): Promise<VideoExportResult> {
|
||||
const mimeType = pickMimeType()
|
||||
if (!mimeType) {
|
||||
throw new Error('MediaRecorder wird von diesem Browser nicht unterstützt.')
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = scene.canvas.width
|
||||
canvas.height = scene.canvas.height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('2D context unavailable')
|
||||
|
||||
const images = await loadSceneImages(scene)
|
||||
|
||||
drawSceneFrame(ctx, scene, 0, images)
|
||||
|
||||
const stream = canvas.captureStream(scene.fps)
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: opts.bitsPerSecond ?? 8_000_000,
|
||||
})
|
||||
|
||||
const chunks: Blob[] = []
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) chunks.push(e.data)
|
||||
}
|
||||
|
||||
const stopped = new Promise<void>((resolve) => {
|
||||
recorder.onstop = () => resolve()
|
||||
})
|
||||
|
||||
recorder.start()
|
||||
const startedAt = performance.now()
|
||||
const renderDuration = effectiveEnd(scene)
|
||||
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
let raf = 0
|
||||
const step = () => {
|
||||
if (opts.signal?.aborted) {
|
||||
cancelAnimationFrame(raf)
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
reject(new DOMException('aborted', 'AbortError'))
|
||||
return
|
||||
}
|
||||
const elapsed = (performance.now() - startedAt) / 1000
|
||||
const t = Math.min(elapsed, renderDuration)
|
||||
drawSceneFrame(ctx, scene, t, images)
|
||||
opts.onProgress?.(renderDuration > 0 ? Math.min(1, t / renderDuration) : 1)
|
||||
if (elapsed >= renderDuration) {
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(step)
|
||||
}
|
||||
raf = requestAnimationFrame(step)
|
||||
})
|
||||
|
||||
await finished
|
||||
await stopped
|
||||
|
||||
const blob = new Blob(chunks, { type: mimeType })
|
||||
const extension = mimeType.includes('mp4') ? 'mp4' : 'webm'
|
||||
return { blob, mimeType, extension }
|
||||
}
|
||||
|
||||
export async function downloadSceneAsVideo(
|
||||
scene: Scene,
|
||||
opts: VideoExportOptions = {},
|
||||
): Promise<void> {
|
||||
const { blob, extension } = await renderSceneToVideo(scene, opts)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `animation.${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
Reference in new issue
Block a user