This commit is contained in:
th committed 2026-04-19 17:53:36 +02:00
commit c6863f2bfc
34 files changed
+6909

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
dist-ssr
*.local
.vite
.DS_Store
*.log
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+10
View File
@@ -0,0 +1,10 @@
services:
animatr-render:
build: ./server
ports:
- "6000:6000"
environment:
- NODE_ENV=production
- PORT=6000
shm_size: "2gb"
restart: unless-stopped
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Animatr</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1636
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "animatr",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
"konva": "^9.3.16",
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-konva": "^3.1.2"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules
npm-debug.log
.env
*.mp4
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-slim
RUN apt-get update && apt-get install -y \
chromium \
ffmpeg \
fonts-liberation \
fonts-noto-color-emoji \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PORT=6000
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 6000
CMD ["node", "server.js"]
+16
View File
@@ -0,0 +1,16 @@
{
"name": "animatr-server",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"puppeteer-core": "^23.11.1"
}
}
+144
View File
@@ -0,0 +1,144 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html,body { margin:0; padding:0; overflow:hidden; background:transparent; }
canvas { display:block; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(function(){
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(b,p){ var o={}; for(var k in b) o[k]=b[k]; if(p) for(var k2 in p) o[k2]=p[k2]; return o; }
function lerp(a,b,t){ return a+(b-a)*t; }
function parseHex(h){
if(!h) return null;
var s=String(h).trim().replace('#','');
if(s.length===3) s=s[0]+s[0]+s[1]+s[1]+s[2]+s[2];
if(s.length<6) return null;
var n=parseInt(s.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 o={
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) o.fill=lerpColor(a.fill,b.fill,t);
else if(b.fill) o.fill=b.fill;
else if(a.fill) o.fill=a.fill;
return o;
}
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(res){
var img=new Image();
img.onload=function(){ imgs[a.id]=img; res(); };
img.onerror=function(){ res(); };
img.src=a.src;
});
})).then(function(){ return imgs; });
}
var canvas=document.getElementById('c');
var ctx=canvas.getContext('2d');
var scene=null, images={};
function drawFrame(time){
if(!scene) return;
var lw=scene.canvas.width, 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);
var am={}; for(var i=0;i<scene.assets.length;i++) am[scene.assets[i].id]=scene.assets[i];
for(var j=0;j<scene.layers.length;j++){
var layer=scene.layers[j]; if(!layer.visible) continue;
var asset=am[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);
var fill = p.fill || asset.fill;
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=fill;
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
} else if(asset.kind==='circle'){
ctx.fillStyle=fill;
ctx.beginPath(); ctx.arc(0,0,asset.radius,0,Math.PI*2); ctx.fill();
} else if(asset.kind==='text'){
ctx.fillStyle=fill;
ctx.font=asset.fontSize+'px sans-serif';
ctx.textBaseline='top';
ctx.fillText(asset.text,0,0);
} else if(asset.kind==='image'){
var img=images[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();
}
window.__animatrInit=function(s){
scene=s;
canvas.width=s.canvas.width;
canvas.height=s.canvas.height;
document.body.style.width=s.canvas.width+'px';
document.body.style.height=s.canvas.height+'px';
return loadImages(s.assets).then(function(imgs){
images=imgs;
drawFrame(0);
return true;
});
};
window.__animatrSeek=function(time){ drawFrame(time); };
})();
</script>
</body>
</html>
+198
View File
@@ -0,0 +1,198 @@
import express from 'express'
import cors from 'cors'
import path from 'node:path'
import fs from 'node:fs/promises'
import os from 'node:os'
import { randomUUID } from 'node:crypto'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import puppeteer from 'puppeteer-core'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const PORT = Number(process.env.PORT || 6000)
const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
const RENDER_HTML = path.join(__dirname, 'render.html')
const app = express()
app.use(cors())
app.use(express.json({ limit: '200mb' }))
/** @type {Map<string, Job>} */
const jobs = new Map()
app.get('/health', (_req, res) => res.json({ ok: true }))
app.post('/render', (req, res) => {
const { scene, options } = req.body || {}
if (!scene || !scene.canvas || !Array.isArray(scene.layers)) {
return res.status(400).json({ error: 'invalid scene' })
}
const job = {
id: randomUUID(),
progress: 0,
done: false,
error: null,
filePath: null,
subscribers: new Set(),
abort: new AbortController(),
}
jobs.set(job.id, job)
res.json({ jobId: job.id })
runJob(job, scene, options || {}).catch((err) => {
job.error = err?.message || String(err)
job.done = true
notify(job)
})
})
app.get('/render/:id/events', (req, res) => {
const job = jobs.get(req.params.id)
if (!job) return res.status(404).end()
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const send = () => {
const payload = {
progress: job.progress,
done: job.done,
error: job.error,
url: job.done && !job.error ? `/render/${job.id}/file` : null,
}
res.write(`data: ${JSON.stringify(payload)}\n\n`)
}
job.subscribers.add(send)
send()
const keepalive = setInterval(() => res.write(': keepalive\n\n'), 15000)
req.on('close', () => {
job.subscribers.delete(send)
clearInterval(keepalive)
})
})
app.get('/render/:id/file', (req, res) => {
const job = jobs.get(req.params.id)
if (!job || !job.filePath || job.error) return res.status(404).end()
res.download(job.filePath, 'animation.mp4')
})
app.delete('/render/:id', (req, res) => {
const job = jobs.get(req.params.id)
if (!job) return res.status(404).end()
job.abort.abort()
res.json({ ok: true })
})
function notify(job) {
for (const sub of Array.from(job.subscribers)) {
try { sub() } catch { /* noop */ }
}
}
function effectiveEnd(scene) {
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)
}
async function runJob(job, scene, options) {
const fps = Number(options.fps || scene.fps || 60)
const sceneEnd = effectiveEnd(scene)
const duration = Number(options.duration || sceneEnd)
const totalFrames = Math.max(1, Math.round(duration * fps))
const crf = String(options.crf ?? 18)
const preset = String(options.preset ?? 'fast')
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-'))
const outPath = path.join(workDir, 'out.mp4')
const browser = await puppeteer.launch({
executablePath: CHROME_PATH,
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--hide-scrollbars',
],
defaultViewport: {
width: scene.canvas.width,
height: scene.canvas.height,
deviceScaleFactor: 1,
},
})
const ffmpeg = spawn('ffmpeg', [
'-y',
'-f', 'image2pipe',
'-framerate', String(fps),
'-i', '-',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
'-preset', preset,
'-crf', crf,
'-movflags', '+faststart',
outPath,
])
let ffmpegErr = ''
ffmpeg.stderr.on('data', (d) => { ffmpegErr += d.toString() })
const ffmpegDone = new Promise((resolve, reject) => {
ffmpeg.on('exit', (code) => {
if (code === 0) resolve()
else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`))
})
ffmpeg.on('error', reject)
})
try {
const page = await browser.newPage()
await page.goto('file://' + RENDER_HTML, { waitUntil: 'domcontentloaded' })
await page.evaluate((s) => window.__animatrInit(s), scene)
for (let i = 0; i < totalFrames; i++) {
if (job.abort.signal.aborted) {
try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
throw new Error('aborted')
}
const t = Math.min(i / fps, duration)
await page.evaluate((time) => window.__animatrSeek(time), t)
const buf = await page.screenshot({
type: 'png',
omitBackground: false,
clip: { x: 0, y: 0, width: scene.canvas.width, height: scene.canvas.height },
})
if (!ffmpeg.stdin.write(buf)) {
await new Promise((resolve) => ffmpeg.stdin.once('drain', resolve))
}
job.progress = (i + 1) / totalFrames
notify(job)
}
ffmpeg.stdin.end()
await ffmpegDone
job.filePath = outPath
job.done = true
notify(job)
setTimeout(() => cleanupJob(job.id, workDir).catch(() => {}), 10 * 60 * 1000)
} finally {
await browser.close().catch(() => {})
}
}
async function cleanupJob(jobId, workDir) {
jobs.delete(jobId)
await fs.rm(workDir, { recursive: true, force: true })
}
app.listen(PORT, () => {
console.log(`animatr render server listening on :${PORT}`)
console.log(`chromium: ${CHROME_PATH}`)
})
+262
View File
@@ -0,0 +1,262 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhExport,
PhGearSix,
PhPause,
PhPlay,
PhSkipBack,
PhSparkle,
PhStop,
} from '@phosphor-icons/vue'
import AssetSidebar from './components/AssetSidebar.vue'
import CanvasStage from './components/CanvasStage.vue'
import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import PropertiesPanel from './components/PropertiesPanel.vue'
import Timeline from './components/Timeline.vue'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useSceneStore } from './stores/scene'
const settingsOpen = ref(false)
const exportOpen = ref(false)
usePlaybackLoop()
const store = useSceneStore()
function rewind() {
store.setPlayhead(0)
}
function stop() {
store.pause()
}
function isEditable(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false
const tag = el.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (el.isContentEditable) return true
return false
}
function onKeyDown(e: KeyboardEvent) {
if (e.code !== 'Space') return
if (isEditable(e.target)) return
e.preventDefault()
store.togglePlay()
}
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</script>
<template>
<div class="app">
<header class="topbar">
<div class="brand-area">
<span class="brand">
<PhSparkle :size="16" weight="fill" class="brand-icon" />
Animatr
</span>
<span class="hint">PoC</span>
</div>
<div class="transport">
<button class="tbtn" title="Zum Anfang" @click="rewind">
<PhSkipBack :size="14" weight="fill" />
</button>
<button
class="tbtn primary"
:title="store.playing ? 'Pause (Space)' : 'Play (Space)'"
@click="store.togglePlay()"
>
<PhPause v-if="store.playing" :size="14" weight="fill" />
<PhPlay v-else :size="14" weight="fill" />
</button>
<button class="tbtn" title="Stop" @click="stop">
<PhStop :size="14" weight="fill" />
</button>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
</div>
<div class="topbar-right">
<button class="icon-btn" title="Document Settings" @click="settingsOpen = true">
<PhGearSix :size="16" />
</button>
<button class="export-btn" title="Exportieren" @click="exportOpen = true">
<PhExport :size="14" weight="bold" />
Export
</button>
</div>
</header>
<aside class="sidebar"><AssetSidebar /></aside>
<main class="stage"><CanvasStage /></main>
<aside class="properties"><PropertiesPanel /></aside>
<footer class="timeline"><Timeline /></footer>
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
</div>
</template>
<style scoped>
.app {
display: grid;
grid-template-columns: 260px 1fr 300px;
grid-template-rows: 48px 1fr 220px;
grid-template-areas:
'top top top'
'side stage props'
'time time time';
height: 100vh;
width: 100vw;
}
.topbar {
grid-area: top;
display: flex;
align-items: center;
padding: 0 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.brand-area {
flex: 1 1 0;
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
}
.brand {
font-weight: 600;
font-size: 14px;
letter-spacing: -0.01em;
display: inline-flex;
align-items: center;
gap: 6px;
}
.brand-icon {
color: var(--accent);
}
.hint {
color: var(--text-dim);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 10px;
}
.transport {
display: flex;
gap: 4px;
align-items: center;
}
.tbtn {
width: 32px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim-strong);
border-radius: var(--radius-sm);
}
.tbtn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.tbtn.primary {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.tbtn.primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
color: var(--accent);
}
.time {
margin-left: 10px;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-size: 11px;
min-width: 92px;
text-align: center;
}
.topbar-right {
flex: 1 1 0;
display: flex;
justify-content: flex-end;
gap: 6px;
}
.export-btn {
padding: 6px 12px;
border: 1px solid var(--accent);
color: var(--accent);
background: var(--accent-soft);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
}
.export-btn:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.icon-btn {
width: 30px;
height: 30px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
}
.icon-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.sidebar {
grid-area: side;
background: var(--panel);
border-right: 1px solid var(--border);
overflow: auto;
}
.stage {
grid-area: stage;
background: var(--bg);
overflow: hidden;
min-width: 0;
min-height: 0;
}
.properties {
grid-area: props;
background: var(--panel);
border-left: 1px solid var(--border);
overflow: auto;
}
.timeline {
grid-area: time;
background: var(--panel);
border-top: 1px solid var(--border);
overflow: hidden;
}
</style>
+275
View File
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
PhCircle,
PhImage,
PhPlus,
PhSquare,
PhTextT,
PhUploadSimple,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader'
import type { Asset } from '@/types/scene'
const store = useSceneStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
function addSampleRect() {
const asset = store.addAsset({
kind: 'rect',
name: 'Rect',
width: 160,
height: 100,
fill: '#6ea8ff',
})
store.addLayer(asset.id)
}
function addSampleCircle() {
const asset = store.addAsset({
kind: 'circle',
name: 'Circle',
radius: 60,
fill: '#ff7a59',
})
store.addLayer(asset.id)
}
async function handleFileSelected(event: Event) {
uploadError.value = null
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
try {
for (const file of files) await importFile(file)
} catch (err) {
uploadError.value = err instanceof Error ? err.message : String(err)
} finally {
if (fileInput.value) fileInput.value.value = ''
}
}
async function importFile(file: File) {
const dataUrl = await readAsDataUrl(file)
let width = 0
let height = 0
if (isSvgFile(file)) {
const text = await file.text()
const dims = parseSvgDimensions(text)
if (dims) {
width = dims.width
height = dims.height
}
}
if (!width || !height) {
const img = await loadImage(dataUrl)
width = img.naturalWidth || 300
height = img.naturalHeight || 300
}
const asset = store.addAsset({
kind: 'image',
name: file.name,
src: dataUrl,
width,
height,
})
store.addLayer(asset.id)
}
function triggerUpload() {
fileInput.value?.click()
}
const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
rect: PhSquare,
circle: PhCircle,
image: PhImage,
text: PhTextT,
}
const assetsWithUsage = computed(() =>
store.scene.assets.map((a) => ({
asset: a,
inUse: store.scene.layers.some((l) => l.assetId === a.id),
})),
)
</script>
<template>
<div class="panel">
<h3>Assets</h3>
<button class="upload-btn" @click="triggerUpload">
<PhUploadSimple :size="16" />
<span>SVG / Bild hinzufügen</span>
</button>
<div class="actions">
<button class="shape-btn" @click="addSampleRect">
<PhSquare :size="14" />
Rect
</button>
<button class="shape-btn" @click="addSampleCircle">
<PhCircle :size="14" />
Circle
</button>
</div>
<input
ref="fileInput"
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/webp,image/gif"
multiple
style="display: none"
@change="handleFileSelected"
/>
<p v-if="uploadError" class="error">{{ uploadError }}</p>
<h4>Bibliothek</h4>
<ul class="assets">
<li v-for="item in assetsWithUsage" :key="item.asset.id" :class="{ unused: !item.inUse }">
<component :is="assetIconMap[item.asset.kind]" :size="14" class="asset-icon" />
<span class="name" :title="item.asset.name">{{ item.asset.name }}</span>
<button
v-if="!item.inUse"
class="add-layer"
title="Als Layer einfügen"
@click="store.addLayer(item.asset.id)"
>
<PhPlus :size="12" weight="bold" />
</button>
</li>
<li v-if="!store.scene.assets.length" class="empty">keine Assets</li>
</ul>
</div>
</template>
<style scoped>
.panel {
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
h3 {
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
}
h4 {
margin: 6px 0 4px;
font-size: 10px;
text-transform: uppercase;
color: var(--text-dim);
letter-spacing: 0.08em;
font-weight: 500;
}
.upload-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
background: var(--accent-soft);
border: 1px dashed var(--accent);
color: var(--accent);
border-radius: var(--radius);
font-size: 12px;
font-weight: 500;
}
.upload-btn:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.actions {
display: flex;
gap: 6px;
}
.shape-btn {
flex: 1;
justify-content: center;
font-size: 12px;
}
.error {
color: var(--danger);
font-size: 11px;
margin: 2px 0 0;
padding: 6px 8px;
background: var(--danger-soft);
border-radius: var(--radius-sm);
}
.assets {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.assets li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--panel-alt);
font-size: 12px;
transition: background 140ms var(--ease);
}
.assets li:hover {
background: var(--panel-hover);
}
.assets li.unused {
opacity: 0.85;
}
.asset-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.add-layer {
width: 22px;
height: 22px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
}
.add-layer:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
}
.empty {
background: transparent !important;
color: var(--text-dim);
font-style: italic;
justify-content: center;
}
</style>
+581
View File
@@ -0,0 +1,581 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type Konva from 'konva'
import { PhCornersIn } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
const store = useSceneStore()
const container = ref<HTMLDivElement | null>(null)
const containerSize = ref({ width: 0, height: 0 })
const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
const zoom = ref(1)
const viewX = ref(0)
const viewY = ref(0)
let didInitialFit = false
const ZOOM_MIN = 0.05
const ZOOM_MAX = 20
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v))
}
function fitToContainer() {
const cw = containerSize.value.width
const ch = containerSize.value.height
const lw = store.scene.canvas.width
const lh = store.scene.canvas.height
if (!cw || !ch) return
const z = Math.min(cw / lw, ch / lh) * 0.92
zoom.value = z
viewX.value = (cw - lw * z) / 2
viewY.value = (ch - lh * z) / 2
}
function zoomToOne() {
const cw = containerSize.value.width
const ch = containerSize.value.height
const lw = store.scene.canvas.width
const lh = store.scene.canvas.height
if (!cw || !ch) return
zoom.value = 1
viewX.value = (cw - lw) / 2
viewY.value = (ch - lh) / 2
}
const stageConfig = computed(() => ({
width: containerSize.value.width,
height: containerSize.value.height,
}))
const viewConfig = computed(() => ({
x: viewX.value,
y: viewY.value,
scaleX: zoom.value,
scaleY: zoom.value,
}))
const backgroundConfig = computed(() => ({
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.height,
fill: store.scene.canvas.background,
}))
const canvasBorderConfig = computed(() => ({
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.height,
stroke: '#4a4a56',
strokeWidth: 1 / zoom.value,
listening: false,
}))
const zoomPercent = computed(() => Math.round(zoom.value * 100))
const assetMap = computed(() => {
const m = new Map<string, Asset>()
for (const a of store.scene.assets) m.set(a.id, a)
return m
})
const imageCache = ref<Record<string, HTMLImageElement>>({})
watch(
() => store.scene.assets.map((a) => (a.kind === 'image' ? `${a.id}:${a.src.length}` : null)),
() => {
for (const a of store.scene.assets) {
if (a.kind !== 'image') continue
if (imageCache.value[a.id]) continue
loadImage(a.src)
.then((img) => {
imageCache.value = { ...imageCache.value, [a.id]: img }
})
.catch((err) => {
console.error('image load failed', err)
})
}
},
{ immediate: true, deep: true },
)
interface RenderedLayer {
layer: Layer
asset: Asset
props: AnimatableProps
}
const rendered = computed<RenderedLayer[]>(() =>
store.scene.layers
.map((layer) => {
const asset = assetMap.value.get(layer.assetId)
if (!asset) return null
return {
layer,
asset,
props: sampleLayer(layer, store.playhead),
}
})
.filter((x): x is RenderedLayer => x !== null),
)
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
return {
id: layer.id,
x: props.x,
y: props.y,
scaleX: props.scaleX,
scaleY: props.scaleY,
rotation: props.rotation,
opacity: props.opacity,
visible: layer.visible,
draggable: true,
}
}
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
return {
...baseNodeConfig(layer, props),
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
fill: props.fill ?? asset.fill,
}
}
function circleConfig(
layer: Layer,
asset: Extract<Asset, { kind: 'circle' }>,
props: AnimatableProps,
) {
return {
...baseNodeConfig(layer, props),
radius: asset.radius,
fill: props.fill ?? asset.fill,
}
}
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
return {
...baseNodeConfig(layer, props),
text: asset.text,
fontSize: asset.fontSize,
fill: props.fill ?? asset.fill,
}
}
function imageConfig(
layer: Layer,
asset: Extract<Asset, { kind: 'image' }>,
props: AnimatableProps,
) {
const img = imageCache.value[asset.id]
if (!img) return null
return {
...baseNodeConfig(layer, props),
image: img,
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
}
}
interface PivotInfo {
layerId: string
sampled: AnimatableProps
}
const pivotInfo = computed<PivotInfo | null>(() => {
const id = store.selectedLayerId
if (!id) return null
const layer = store.scene.layers.find((l) => l.id === id)
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset || (asset.kind !== 'rect' && asset.kind !== 'image')) return null
return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) }
})
const pivotHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
return {
x: info.sampled.x,
y: info.sampled.y,
draggable: true,
name: 'pivot-handle',
}
})
const pivotRingConfig = computed(() => ({
radius: 8 / zoom.value,
stroke: '#6ea8ff',
strokeWidth: 1.5 / zoom.value,
fill: '#ffffff',
}))
const pivotDotConfig = computed(() => ({
radius: 2.5 / zoom.value,
fill: '#6ea8ff',
listening: false,
}))
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const info = pivotInfo.value
if (!info) return
const node = e.target
const dx = node.x() - info.sampled.x
const dy = node.y() - info.sampled.y
if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return
const theta = (info.sampled.rotation * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
const unrotDx = dx * c + dy * s
const unrotDy = -dx * s + dy * c
const invSX = info.sampled.scaleX !== 0 ? 1 / info.sampled.scaleX : 1
const invSY = info.sampled.scaleY !== 0 ? 1 / info.sampled.scaleY : 1
const localDx = unrotDx * invSX
const localDy = unrotDy * invSY
store.shiftLayerPivot(info.layerId, dx, dy, localDx, localDy)
}
const transformerConfig = {
rotateEnabled: true,
enabledAnchors: [
'top-left',
'top-center',
'top-right',
'middle-right',
'middle-left',
'bottom-left',
'bottom-center',
'bottom-right',
],
anchorSize: 9,
borderStroke: '#4f8cff',
anchorStroke: '#4f8cff',
anchorFill: '#ffffff',
padding: 2,
ignoreStroke: true,
}
function attachTransformer() {
const stage = stageRef.value?.getStage()
const tr = transformerRef.value?.getNode()
if (!stage || !tr) return
const id = store.selectedLayerId
if (!id) {
tr.nodes([])
tr.getLayer()?.batchDraw()
return
}
const node = stage.findOne<Konva.Node>(`#${id}`)
tr.nodes(node ? [node] : [])
tr.getLayer()?.batchDraw()
}
watch(
() => [store.selectedLayerId, rendered.value.length, store.playhead] as const,
async () => {
await nextTick()
attachTransformer()
},
{ immediate: true },
)
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
const target = e.target
const tr = transformerRef.value?.getNode()
if (tr) {
let p: Konva.Node | null = target
while (p) {
if (p === tr) return
p = p.getParent() ?? null
}
}
if (target.findAncestor?.('.pivot-handle')) return
if (target === target.getStage()) {
store.selectLayer(null)
return
}
const id = target.id?.()
if (!id) {
store.selectLayer(null)
return
}
const match = store.scene.layers.find((l) => l.id === id)
store.selectLayer(match ? match.id : null)
}
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.scene.layers.some((l) => l.id === id)) return
store.upsertKeyframe(id, store.playhead, {
x: node.x(),
y: node.y(),
})
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
if (!store.scene.layers.some((l) => l.id === id)) return
store.upsertKeyframe(id, store.playhead, {
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
})
}
let ro: ResizeObserver | null = null
function onWheel(e: WheelEvent) {
e.preventDefault()
const rect = container.value?.getBoundingClientRect()
if (!rect) return
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const logX = (sx - viewX.value) / zoom.value
const logY = (sy - viewY.value) / zoom.value
const factor = Math.pow(1.0015, -e.deltaY)
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
zoom.value = newZoom
viewX.value = sx - logX * newZoom
viewY.value = sy - logY * newZoom
}
let panning = false
let panStartX = 0
let panStartY = 0
let panStartViewX = 0
let panStartViewY = 0
let panPointerId = -1
function onContainerPointerDown(e: PointerEvent) {
if (e.button !== 1) return
e.preventDefault()
panning = true
panStartX = e.clientX
panStartY = e.clientY
panStartViewX = viewX.value
panStartViewY = viewY.value
panPointerId = e.pointerId
container.value?.setPointerCapture(e.pointerId)
if (container.value) container.value.style.cursor = 'grabbing'
}
function onContainerPointerMove(e: PointerEvent) {
if (!panning || e.pointerId !== panPointerId) return
viewX.value = panStartViewX + (e.clientX - panStartX)
viewY.value = panStartViewY + (e.clientY - panStartY)
}
function onContainerPointerUp(e: PointerEvent) {
if (e.pointerId !== panPointerId) return
panning = false
container.value?.releasePointerCapture?.(e.pointerId)
if (container.value) container.value.style.cursor = ''
}
function zoomBy(factor: number) {
const cw = containerSize.value.width
const ch = containerSize.value.height
if (!cw || !ch) return
const sx = cw / 2
const sy = ch / 2
const logX = (sx - viewX.value) / zoom.value
const logY = (sy - viewY.value) / zoom.value
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
zoom.value = newZoom
viewX.value = sx - logX * newZoom
viewY.value = sy - logY * newZoom
}
onMounted(() => {
if (!container.value) return
ro = new ResizeObserver((entries) => {
const rect = entries[0].contentRect
containerSize.value = { width: rect.width, height: rect.height }
if (!didInitialFit && rect.width > 0 && rect.height > 0) {
fitToContainer()
didInitialFit = true
}
})
ro.observe(container.value)
container.value.addEventListener('wheel', onWheel, { passive: false })
container.value.addEventListener('pointerdown', onContainerPointerDown)
container.value.addEventListener('pointermove', onContainerPointerMove)
container.value.addEventListener('pointerup', onContainerPointerUp)
container.value.addEventListener('pointercancel', onContainerPointerUp)
})
onBeforeUnmount(() => {
ro?.disconnect()
ro = null
container.value?.removeEventListener('wheel', onWheel)
container.value?.removeEventListener('pointerdown', onContainerPointerDown)
container.value?.removeEventListener('pointermove', onContainerPointerMove)
container.value?.removeEventListener('pointerup', onContainerPointerUp)
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
})
</script>
<template>
<div ref="container" class="stage-container">
<v-stage ref="stageRef" :config="stageConfig" @mousedown="handleStageMouseDown">
<v-layer>
<v-group :config="viewConfig">
<v-rect :config="backgroundConfig" />
<template v-for="item in rendered" :key="item.layer.id">
<v-rect
v-if="item.asset.kind === 'rect'"
:config="rectConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="item.asset.kind === 'circle'"
:config="circleConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="item.asset.kind === 'text'"
:config="textConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
:config="imageConfig(item.layer, item.asset, item.props)!"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
</template>
<v-rect :config="canvasBorderConfig" />
<v-group
v-if="pivotHandleConfig"
:config="pivotHandleConfig"
@dragend="onPivotDragEnd"
>
<v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" />
</v-group>
</v-group>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
</v-stage>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" title="Zoom out" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" title="Fit to view" @click="fitToContainer">
{{ zoomPercent }}%
</button>
<button class="zc-btn" title="Zoom in" @click="zoomBy(1.25)">+</button>
<div class="zc-sep"></div>
<button class="zc-btn wide" title="Zoom 1:1" @click="zoomToOne">1:1</button>
<button class="zc-btn" title="An Viewport anpassen" @click="fitToContainer">
<PhCornersIn :size="13" />
</button>
</div>
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
</div>
</template>
<style scoped>
.stage-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.dims {
position: absolute;
bottom: 10px;
right: 12px;
color: var(--text-dim);
font-size: 11px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.zoom-controls {
position: absolute;
bottom: 10px;
left: 12px;
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
user-select: none;
}
.zc-btn {
width: 26px;
height: 24px;
padding: 0;
justify-content: center;
background: transparent;
border: none;
color: var(--text-dim);
font-size: 13px;
line-height: 1;
border-radius: var(--radius-sm);
}
.zc-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.zc-btn.wide {
width: auto;
padding: 0 8px;
font-size: 11px;
font-weight: 500;
}
.zc-label {
min-width: 54px;
height: 24px;
padding: 0 8px;
justify-content: center;
background: transparent;
border: none;
color: var(--text-dim-strong);
font-variant-numeric: tabular-nums;
font-size: 11px;
border-radius: var(--radius-sm);
}
.zc-label:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.zc-sep {
width: 1px;
height: 16px;
background: var(--border);
margin: 0 2px;
}
</style>
+540
View File
@@ -0,0 +1,540 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhClock,
PhFrameCorners,
PhMoon,
PhPalette,
PhSun,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const { theme, setTheme } = useTheme()
type TabId = 'document' | 'timeline' | 'theme'
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', label: 'Document', icon: PhFrameCorners },
{ id: 'timeline', label: 'Timeline', icon: PhClock },
{ id: 'theme', label: 'UI Theme', icon: PhPalette },
]
const activeTab = ref<TabId>('document')
const width = computed({
get: () => store.scene.canvas.width,
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
})
const height = computed({
get: () => store.scene.canvas.height,
set: (v: number) => store.setCanvasSize(store.scene.canvas.width, v),
})
const background = computed({
get: () => store.scene.canvas.background,
set: (v: string) => store.setCanvasBackground(v),
})
const duration = computed({
get: () => store.scene.duration,
set: (v: number) => store.setDuration(v),
})
const fps = computed({
get: () => store.scene.fps,
set: (v: number) => store.setFps(v),
})
const presets: { label: string; w: number; h: number }[] = [
{ label: '16:9 · 1280×720', w: 1280, h: 720 },
{ label: '16:9 · 1920×1080', w: 1920, h: 1080 },
{ label: '1:1 · 1080×1080', w: 1080, h: 1080 },
{ label: '9:16 · 1080×1920', w: 1080, h: 1920 },
{ label: '4:5 · 1080×1350', w: 1080, h: 1350 },
]
function applyPreset(p: { w: number; h: number }) {
store.setCanvasSize(p.w, p.h)
}
function close() {
emit('update:modelValue', false)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.modelValue) close()
}
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
function applyTheme(t: Theme) {
setTheme(t)
}
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="modelValue" class="backdrop" @mousedown.self="close">
<div class="dialog" role="dialog" aria-modal="true">
<header class="dialog-header">
<h2>Settings</h2>
<button class="close-btn" title="Schließen" @click="close">
<PhX :size="14" weight="bold" />
</button>
</header>
<div class="body">
<nav class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
<component :is="tab.icon" :size="14" />
{{ tab.label }}
</button>
</nav>
<div class="content">
<section v-if="activeTab === 'document'">
<h3>Canvas</h3>
<div class="row">
<label>
Width
<input type="number" min="1" step="1" v-model.number="width" />
</label>
<label>
Height
<input type="number" min="1" step="1" v-model.number="height" />
</label>
</div>
<div class="presets">
<button
v-for="p in presets"
:key="p.label"
class="preset"
:class="{ active: width === p.w && height === p.h }"
@click="applyPreset(p)"
>
{{ p.label }}
</button>
</div>
<label class="stacked">
Background
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="background"
@input="background = ($event.target as HTMLInputElement).value"
/>
<span class="hex">{{ background.toUpperCase() }}</span>
</div>
</label>
</section>
<section v-else-if="activeTab === 'timeline'">
<h3>Timeline</h3>
<div class="row">
<label>
Duration (s)
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
</label>
<label>
FPS
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<p class="hint">
Die Timeline-Länge setzt die Ruler-Skala. Playback stoppt am letzten gesetzten Keyframe.
</p>
</section>
<section v-else-if="activeTab === 'theme'">
<h3>UI Theme</h3>
<div class="theme-grid">
<button
class="theme-card"
:class="{ active: theme === 'dark' }"
@click="applyTheme('dark')"
>
<div class="theme-preview theme-preview-dark">
<div class="tp-panel"></div>
<div class="tp-accent"></div>
</div>
<div class="theme-label">
<PhMoon :size="14" />
<span>Dark</span>
</div>
</button>
<button
class="theme-card"
:class="{ active: theme === 'light' }"
@click="applyTheme('light')"
>
<div class="theme-preview theme-preview-light">
<div class="tp-panel"></div>
<div class="tp-accent"></div>
</div>
<div class="theme-label">
<PhSun :size="14" />
<span>Light</span>
</div>
</button>
</div>
<p class="hint">Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.</p>
</section>
</div>
</div>
<footer class="dialog-footer">
<button class="primary" @click="close">Fertig</button>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.dialog {
width: min(620px, calc(100% - 32px));
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
max-height: calc(100vh - 64px);
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.dialog-header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.close-btn {
width: 26px;
height: 26px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
}
.close-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.body {
display: flex;
min-height: 340px;
}
.tabs {
width: 150px;
flex-shrink: 0;
border-right: 1px solid var(--border);
padding: 10px 8px;
display: flex;
flex-direction: column;
gap: 2px;
background: var(--panel-alt);
}
.tab {
justify-content: flex-start;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
font-size: 12px;
border-radius: var(--radius-sm);
}
.tab:hover:not(:disabled) {
background: var(--panel-hover);
color: var(--text);
}
.tab.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.content {
flex: 1;
padding: 16px 20px;
overflow: auto;
min-width: 0;
}
section {
display: flex;
flex-direction: column;
gap: 12px;
}
section h3 {
margin: 0;
font-size: 10px;
text-transform: uppercase;
color: var(--text-dim);
letter-spacing: 0.08em;
font-weight: 500;
}
.row {
display: flex;
gap: 10px;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1 1 0;
min-width: 0;
}
label.stacked {
flex: 0 0 auto;
}
input[type='number'] {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
text-transform: none;
letter-spacing: 0;
}
.presets {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.preset {
padding: 4px 10px;
font-size: 11px;
background: var(--panel-alt);
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 999px;
}
.preset:hover:not(:disabled) {
background: var(--panel-hover);
color: var(--text);
}
.preset.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.color-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
}
.color-swatch {
width: 36px;
height: 24px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
flex-shrink: 0;
}
.color-swatch::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-swatch::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
.color-swatch::-moz-color-swatch {
border: none;
border-radius: 3px;
}
.hex {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-dim-strong);
}
.hint {
font-size: 11px;
color: var(--text-dim);
font-style: italic;
margin: 0;
}
.theme-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.theme-card {
flex-direction: column;
padding: 12px;
gap: 10px;
background: var(--panel-alt);
border: 2px solid var(--border);
border-radius: var(--radius);
align-items: stretch;
}
.theme-card:hover:not(:disabled) {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.theme-card.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.theme-preview {
height: 74px;
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
gap: 6px;
padding: 8px;
}
.theme-preview-dark {
background: #17171b;
border: 1px solid #2e2e37;
}
.theme-preview-dark .tp-panel {
background: #272730;
flex: 1;
border-radius: 3px;
}
.theme-preview-dark .tp-accent {
background: #6ea8ff;
width: 22px;
border-radius: 3px;
}
.theme-preview-light {
background: #f3f3f6;
border: 1px solid #e2e2e7;
}
.theme-preview-light .tp-panel {
background: #ffffff;
flex: 1;
border-radius: 3px;
border: 1px solid #e2e2e7;
}
.theme-preview-light .tp-accent {
background: #2b6af5;
width: 22px;
border-radius: 3px;
}
.theme-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text);
font-weight: 500;
justify-content: center;
}
.theme-card.active .theme-label {
color: var(--accent);
}
.dialog-footer {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.primary {
padding: 6px 14px;
background: var(--accent-soft);
border: 1px solid var(--accent);
color: var(--accent);
border-radius: var(--radius-sm);
font-weight: 500;
}
.primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+460
View File
@@ -0,0 +1,460 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhCode,
PhDownloadSimple,
PhFileHtml,
PhFilmSlate,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
interface ProgressState {
progress: number
controller: AbortController
label: string
error: string | null
}
const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
function sceneClone() {
return JSON.parse(JSON.stringify(store.scene))
}
function exportJs() {
downloadExport(sceneClone())
close()
}
function exportHtml() {
downloadExampleHtml(sceneClone())
close()
}
async function exportWebm() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: 'Rendering WebM (Browser, Realtime)…',
error: null,
}
store.pause()
try {
await downloadSceneAsVideo(sceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportMp4Server() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: 'Rendering MP4 (Server)…',
error: null,
}
store.pause()
try {
await downloadFromServer(sceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
function cancel() {
progress.value?.controller.abort()
}
function dismissError() {
progress.value = null
}
const isLocked = () => !!progress.value && !progress.value.error
function close() {
if (isLocked()) return
emit('update:modelValue', false)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.modelValue) close()
}
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="modelValue" class="backdrop" @mousedown.self="close">
<div class="dialog" role="dialog" aria-modal="true">
<header class="dialog-header">
<h2>Export</h2>
<button class="close-btn" :disabled="isLocked()" title="Schließen" @click="close">
<PhX :size="14" weight="bold" />
</button>
</header>
<div v-if="!progress" class="options">
<button class="opt" @click="exportJs">
<div class="opt-icon"><PhCode :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
animation.js
<span class="badge">Web Embed</span>
</div>
<div class="opt-desc">Selbstständiges JS — in eine eigene Seite einbinden</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportHtml">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">Beispiel HTML</div>
<div class="opt-desc">Standalone HTML — Browser öffnen und fertig</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportMp4Server">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">Server · Frame-exakt</span>
</div>
<div class="opt-desc">H.264, beliebige Auflösung — benötigt Render-Server</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" :disabled="!videoSupported" @click="exportWebm">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">Browser · Realtime</span>
</div>
<div class="opt-desc">
{{ videoSupported ? 'Ohne Server — Aufnahme in Echtzeit' : 'Browser unterstützt MediaRecorder nicht' }}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
</div>
<div v-else-if="!progress.error" class="rendering">
<div class="r-label">
<PhFilmSlate :size="14" />
<span class="r-text">{{ progress.label }}</span>
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span>
</div>
<div class="r-bar">
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
</div>
<div class="r-actions">
<button class="secondary" @click="cancel">Abbrechen</button>
</div>
</div>
<div v-else class="error-view">
<div class="error-title">Export fehlgeschlagen</div>
<div class="error-msg">{{ progress.error }}</div>
<div class="error-actions">
<button class="primary" @click="dismissError">Zurück</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.dialog {
width: min(520px, calc(100% - 32px));
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
max-height: calc(100vh - 64px);
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.dialog-header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.close-btn {
width: 26px;
height: 26px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
}
.close-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.options {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
}
.opt {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
text-align: left;
transition: background 140ms var(--ease), border-color 140ms var(--ease),
transform 140ms var(--ease);
}
.opt:hover:not(:disabled) {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.opt:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.opt-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border-radius: var(--radius-sm);
color: var(--accent);
flex-shrink: 0;
}
.opt-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.opt-title {
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.opt-desc {
font-size: 11px;
color: var(--text-dim);
line-height: 1.35;
}
.opt-arrow {
color: var(--text-dim);
flex-shrink: 0;
}
.opt:hover:not(:disabled) .opt-arrow {
color: var(--accent);
}
.badge {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
background: var(--bg);
color: var(--text-dim);
border-radius: 999px;
}
.badge.accent {
background: var(--accent-soft);
color: var(--accent);
}
.rendering {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.r-label {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-dim-strong);
font-size: 12px;
}
.r-text {
flex: 1;
}
.r-percent {
color: var(--accent);
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.r-bar {
height: 6px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.r-bar-fill {
height: 100%;
background: var(--accent);
transition: width 140ms linear;
}
.r-actions {
display: flex;
justify-content: flex-end;
padding-top: 4px;
}
.r-actions button {
padding: 6px 14px;
border: 1px solid var(--border);
background: var(--panel-alt);
color: var(--text);
border-radius: var(--radius-sm);
}
.r-actions button:hover:not(:disabled) {
background: var(--panel-hover);
}
.error-view {
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.error-title {
color: var(--danger);
font-weight: 600;
font-size: 13px;
}
.error-msg {
font-size: 12px;
color: var(--text-dim-strong);
background: var(--danger-soft);
padding: 8px 10px;
border-radius: var(--radius-sm);
word-break: break-word;
user-select: text;
-webkit-user-select: text;
}
.error-actions {
display: flex;
justify-content: flex-end;
}
.error-actions .primary {
padding: 6px 14px;
background: var(--accent-soft);
border: 1px solid var(--accent);
color: var(--accent);
border-radius: var(--radius-sm);
}
.error-actions .primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+288
View File
@@ -0,0 +1,288 @@
<script setup lang="ts">
import { computed } from 'vue'
import { PhDiamond, PhTrash, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import type { AnimatableProps } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
const store = useSceneStore()
const selected = computed(() =>
store.scene.layers.find((l) => l.id === store.selectedLayerId) ?? null,
)
const sampled = computed<AnimatableProps | null>(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : null,
)
const hasKeyframeHere = computed(() => {
if (!selected.value) return false
return selected.value.keyframes.some((k) => Math.abs(k.time - store.playhead) < 1e-3)
})
const selectedAsset = computed(() => {
if (!selected.value) return null
return store.scene.assets.find((a) => a.id === selected.value!.assetId) ?? null
})
const canFill = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
})
const fillColor = computed<string>({
get: () => {
if (sampled.value?.fill) return sampled.value.fill
const a = selectedAsset.value
if (a && 'fill' in a) return a.fill
return '#000000'
},
set: (v: string) => {
if (!selected.value) return
store.upsertKeyframe(selected.value.id, store.playhead, { fill: v })
},
})
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
function bind(key: NumericKey, decimals = 0) {
const factor = 10 ** decimals
return computed({
get: () => (sampled.value ? Math.round(sampled.value[key] * factor) / factor : 0),
set: (v: number) => {
if (!selected.value) return
store.upsertKeyframe(selected.value.id, store.playhead, { [key]: v })
},
})
}
const x = bind('x')
const y = bind('y')
const scaleX = bind('scaleX', 2)
const scaleY = bind('scaleY', 2)
const rotation = bind('rotation')
const opacity = bind('opacity', 2)
function removeKeyframeHere() {
if (!selected.value) return
store.removeKeyframeAt(selected.value.id, store.playhead)
}
</script>
<template>
<div class="panel">
<h3>Properties</h3>
<template v-if="selected && sampled">
<label>Name<input v-model="selected.name" /></label>
<div class="kf-row" :class="{ on: hasKeyframeHere }">
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
<span class="kf-label">
{{ hasKeyframeHere ? 'Keyframe' : 'Interpoliert' }}
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" title="Keyframe hier löschen" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
</button>
</div>
<div class="row">
<label>X<input type="number" v-model.number="x" /></label>
<label>Y<input type="number" v-model.number="y" /></label>
</div>
<div class="row">
<label>Scale X<input type="number" step="0.1" v-model.number="scaleX" /></label>
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
</div>
<label>Rotation<input type="number" v-model.number="rotation" /></label>
<label v-if="canFill" class="color-label">
Color
<div class="color-row">
<input type="color" class="color-swatch" :value="fillColor" @input="fillColor = ($event.target as HTMLInputElement).value" />
<span class="color-hex">{{ fillColor.toUpperCase() }}</span>
</div>
</label>
<label class="slider-label">
Opacity
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
</label>
<button class="danger-btn" @click="store.removeLayer(selected.id)">
<PhTrash :size="13" />
Layer löschen
</button>
</template>
<p v-else class="hint">Kein Layer ausgewählt.</p>
</div>
</template>
<style scoped>
.panel {
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
h3 {
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
}
label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1 1 0;
min-width: 0;
}
input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
text-transform: none;
letter-spacing: 0;
}
.row {
display: flex;
gap: 8px;
}
.hint {
color: var(--text-dim);
font-size: 12px;
font-style: italic;
padding: 8px 0;
}
.kf-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
height: 30px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--text-dim);
transition: background 140ms var(--ease), border-color 140ms var(--ease);
}
.kf-row.on {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.kf-dot {
color: inherit;
flex-shrink: 0;
}
.kf-label {
flex: 1;
display: inline-flex;
gap: 4px;
align-items: baseline;
font-weight: 500;
}
.kf-time {
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-weight: 400;
}
.kf-row.on .kf-time {
color: var(--accent);
opacity: 0.8;
}
.kf-btn {
width: 20px;
height: 20px;
padding: 0;
justify-content: center;
background: transparent;
border: none;
color: inherit;
flex-shrink: 0;
}
.kf-btn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.2);
}
.color-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
}
.color-swatch {
width: 36px;
height: 24px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
flex-shrink: 0;
transition: border-color 140ms var(--ease);
}
.color-swatch:hover {
border-color: var(--border-strong);
}
.color-swatch::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-swatch::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
.color-swatch::-moz-color-swatch {
border: none;
border-radius: 3px;
}
.color-hex {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-dim-strong);
letter-spacing: 0.03em;
}
.danger-btn {
justify-content: center;
margin-top: 6px;
background: var(--danger-soft);
border-color: transparent;
color: var(--danger);
}
.danger-btn:hover:not(:disabled) {
background: rgba(255, 122, 122, 0.18);
border-color: var(--danger);
}
</style>
+228
View File
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: number
min?: number
max?: number
step?: number
format?: (v: number) => string
disabled?: boolean
}>(),
{
min: 0,
max: 1,
step: 0.01,
format: undefined,
disabled: false,
},
)
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
const trackRef = ref<HTMLDivElement | null>(null)
const dragging = ref(false)
const fillPercent = computed(() => {
const range = props.max - props.min
if (range <= 0) return 0
const clamped = Math.max(props.min, Math.min(props.max, props.modelValue))
return ((clamped - props.min) / range) * 100
})
const formattedValue = computed(() => {
if (props.format) return props.format(props.modelValue)
return String(props.modelValue)
})
function snap(value: number): number {
const s = props.step
if (s <= 0) return value
const snapped = Math.round(value / s) * s
const decimals = (s.toString().split('.')[1] || '').length
return decimals > 0 ? parseFloat(snapped.toFixed(decimals)) : snapped
}
function clamp(value: number): number {
return Math.max(props.min, Math.min(props.max, value))
}
function updateFromClientX(clientX: number) {
const track = trackRef.value
if (!track) return
const rect = track.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
const raw = props.min + ratio * (props.max - props.min)
emit('update:modelValue', clamp(snap(raw)))
}
function onPointerDown(e: PointerEvent) {
if (props.disabled) return
if (e.button !== 0) return
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
el.focus()
dragging.value = true
updateFromClientX(e.clientX)
}
function onPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
updateFromClientX(e.clientX)
}
function onPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
dragging.value = false
}
function onKeyDown(e: KeyboardEvent) {
if (props.disabled) return
const big = e.shiftKey ? 10 : 1
let next = props.modelValue
switch (e.key) {
case 'ArrowLeft':
case 'ArrowDown':
next -= props.step * big
break
case 'ArrowRight':
case 'ArrowUp':
next += props.step * big
break
case 'Home':
next = props.min
break
case 'End':
next = props.max
break
default:
return
}
e.preventDefault()
emit('update:modelValue', clamp(snap(next)))
}
</script>
<template>
<div
class="slider"
:class="{ disabled, dragging }"
:tabindex="disabled ? -1 : 0"
role="slider"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuenow="modelValue"
:aria-disabled="disabled"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@keydown="onKeyDown"
>
<div ref="trackRef" class="track">
<div class="track-bg"></div>
<div class="track-fill" :style="{ width: fillPercent + '%' }"></div>
<div class="thumb" :style="{ left: fillPercent + '%' }">
<div class="thumb-dot"></div>
</div>
</div>
<div class="value">{{ formattedValue }}</div>
</div>
</template>
<style scoped>
.slider {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 2px;
outline: none;
cursor: pointer;
user-select: none;
touch-action: none;
}
.slider.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.track {
position: relative;
flex: 1;
height: 14px;
display: flex;
align-items: center;
}
.track-bg {
position: absolute;
left: 0;
right: 0;
height: 4px;
background: var(--border);
border-radius: 999px;
}
.track-fill {
position: absolute;
left: 0;
height: 4px;
background: var(--accent);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.thumb-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text);
border: 2px solid var(--accent);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
transition: transform 140ms var(--ease), box-shadow 140ms var(--ease),
background 140ms var(--ease);
}
.slider:hover:not(.disabled) .thumb-dot {
transform: scale(1.08);
}
.slider:focus-visible:not(.disabled) .thumb-dot {
box-shadow: 0 0 0 3px var(--accent-soft-strong), 0 1px 4px rgba(0, 0, 0, 0.4);
}
.slider.dragging .thumb-dot {
transform: scale(1.15);
background: var(--accent);
}
.value {
min-width: 44px;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--text-dim-strong);
text-transform: none;
letter-spacing: 0;
}
.slider:hover:not(.disabled) .value {
color: var(--text);
}
</style>
+839
View File
@@ -0,0 +1,839 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhCheck, PhCopy, PhTrash } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import type { Easing, Keyframe } from '@/types/scene'
const store = useSceneStore()
const duration = computed(() => store.scene.duration)
const NAME_WIDTH = 140
const MIN_PX_PER_SEC = 2
const MAX_PX_PER_SEC = 2000
const scrollEl = ref<HTMLDivElement | null>(null)
const viewportWidth = ref(0)
const pxPerSec = ref(0)
const autoFit = ref(true)
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v))
}
const fitPxPerSec = computed(() => {
const avail = Math.max(60, viewportWidth.value - NAME_WIDTH)
const d = Math.max(0.1, duration.value)
return avail / d
})
const effectivePxPerSec = computed(() => {
if (autoFit.value || pxPerSec.value <= 0) return fitPxPerSec.value
return pxPerSec.value
})
const laneWidth = computed(() => duration.value * effectivePxPerSec.value)
const totalWidth = computed(() => NAME_WIDTH + laneWidth.value)
type DisplayMode = 'seconds' | 'frames'
const displayMode = ref<DisplayMode>('seconds')
const TICK_CANDIDATES_SEC = [0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 300]
const TICK_CANDIDATES_FRAMES = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800]
const fps = computed(() => (store.scene.fps > 0 ? store.scene.fps : 60))
const tickStep = computed(() => {
const pps = effectivePxPerSec.value
if (displayMode.value === 'frames') {
const pxPerFrame = pps / fps.value
for (const f of TICK_CANDIDATES_FRAMES) {
if (f * pxPerFrame >= 55) return f / fps.value
}
return TICK_CANDIDATES_FRAMES[TICK_CANDIDATES_FRAMES.length - 1] / fps.value
}
for (const s of TICK_CANDIDATES_SEC) {
if (s * pps >= 55) return s
}
return TICK_CANDIDATES_SEC[TICK_CANDIDATES_SEC.length - 1]
})
const ticks = computed(() => {
const d = duration.value
const step = tickStep.value
const out: number[] = []
for (let t = 0; t <= d + 1e-6; t += step) {
out.push(Math.round(t * 1000) / 1000)
}
if (out[out.length - 1] < d - 1e-6) out.push(d)
return out
})
function pxFromTime(t: number) {
return t * effectivePxPerSec.value
}
function formatTick(t: number) {
if (displayMode.value === 'frames') {
return `${Math.round(t * fps.value)}`
}
if (Number.isInteger(t)) return `${t}s`
return `${t.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}s`
}
function formatKfTime(t: number) {
if (displayMode.value === 'frames') return `${Math.round(t * fps.value)} fr`
return `${t.toFixed(2)}s`
}
function seekFromEvent(e: PointerEvent, el: HTMLElement) {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
store.setPlayhead(ratio * duration.value)
}
function onScrubPointerDown(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
seekFromEvent(e, el)
}
function onScrubPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
seekFromEvent(e, el)
}
function onScrubPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
function onLaneClick(e: MouseEvent) {
if (kfDrag.value) return
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
store.setPlayhead(ratio * duration.value)
}
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
e.stopPropagation()
if (kfDrag.value?.moved) {
kfDrag.value = null
return
}
kfDrag.value = null
store.selectLayer(layerId)
store.setPlayhead(time)
}
function onKeyframeDblClick(e: MouseEvent, layerId: string, time: number) {
e.stopPropagation()
if (kfDrag.value?.moved) return
store.removeKeyframeAt(layerId, time)
}
interface KfDrag {
layerId: string
keyframeId: string
lane: HTMLElement
pointerId: number
startX: number
moved: boolean
}
const kfDrag = ref<KfDrag | null>(null)
function onKeyframePointerDown(e: PointerEvent, layerId: string, keyframeId: string) {
if (e.button !== 0) return
const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement
if (!lane) return
e.stopPropagation()
kfEl.setPointerCapture(e.pointerId)
kfDrag.value = {
layerId,
keyframeId,
lane,
pointerId: e.pointerId,
startX: e.clientX,
moved: false,
}
store.selectLayer(layerId)
}
function onKeyframePointerMove(e: PointerEvent) {
const d = kfDrag.value
if (!d || d.pointerId !== e.pointerId) return
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
d.moved = true
const rect = d.lane.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const newTime = ratio * duration.value
store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
store.setPlayhead(newTime)
}
function onKeyframePointerUp(e: PointerEvent) {
const d = kfDrag.value
if (!d || d.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
if (!d.moved) {
kfDrag.value = null
}
}
interface KfSegment {
id: string
fromTime: number
toTime: number
easing: Easing
}
function segmentsOf(kfs: Keyframe[]): KfSegment[] {
if (kfs.length < 2) return []
const sorted = [...kfs].sort((a, b) => a.time - b.time)
const out: KfSegment[] = []
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
out.push({
id: `${a.id}>${b.id}`,
fromTime: a.time,
toTime: b.time,
easing: b.easing,
})
}
return out
}
function segmentStyle(seg: KfSegment) {
return {
left: `${pxFromTime(seg.fromTime)}px`,
width: `${pxFromTime(seg.toTime - seg.fromTime)}px`,
}
}
const EASING_OPTIONS: { value: Easing; label: string }[] = [
{ value: 'linear', label: 'Linear' },
{ value: 'easeIn', label: 'Ease In' },
{ value: 'easeOut', label: 'Ease Out' },
{ value: 'easeInOut', label: 'Ease In-Out' },
]
interface KfMenuState {
layerId: string
keyframeId: string
time: number
x: number
y: number
currentEasing: Easing
isFirst: boolean
}
const menu = ref<KfMenuState | null>(null)
const menuRef = ref<HTMLDivElement | null>(null)
function onKeyframeContextMenu(e: MouseEvent, layerId: string, keyframeId: string) {
e.preventDefault()
e.stopPropagation()
const layer = store.scene.layers.find((l) => l.id === layerId)
const kf = layer?.keyframes.find((k) => k.id === keyframeId)
if (!layer || !kf) return
const sorted = [...layer.keyframes].sort((a, b) => a.time - b.time)
menu.value = {
layerId,
keyframeId: kf.id,
time: kf.time,
x: e.clientX,
y: e.clientY,
currentEasing: kf.easing,
isFirst: sorted[0]?.id === kf.id,
}
store.selectLayer(layerId)
}
function applyEasing(easing: Easing) {
if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.time, easing)
menu.value = null
}
function deleteFromMenu() {
if (!menu.value) return
store.removeKeyframeAt(menu.value.layerId, menu.value.time)
menu.value = null
}
function duplicateFromMenu() {
if (!menu.value) return
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
if (newId) {
const layer = store.scene.layers.find((l) => l.id === menu.value!.layerId)
const newKf = layer?.keyframes.find((k) => k.id === newId)
if (newKf) store.setPlayhead(newKf.time)
}
menu.value = null
}
function onDocumentPointerDown(e: PointerEvent) {
if (!menu.value) return
if (menuRef.value && e.target instanceof Node && menuRef.value.contains(e.target)) return
menu.value = null
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') menu.value = null
}
watch(menu, (val) => {
if (val) {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
document.addEventListener('keydown', onKeyDown)
} else {
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
}
})
function onWheel(e: WheelEvent) {
if (!scrollEl.value) return
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const rect = scrollEl.value.getBoundingClientRect()
const cursorInViewport = e.clientX - rect.left
const laneCursor = cursorInViewport - NAME_WIDTH + scrollEl.value.scrollLeft
const currentPps = effectivePxPerSec.value
const timeAtCursor = Math.max(0, laneCursor) / currentPps
const factor = Math.pow(1.0015, -e.deltaY)
const newPps = clamp(currentPps * factor, MIN_PX_PER_SEC, MAX_PX_PER_SEC)
autoFit.value = false
pxPerSec.value = newPps
requestAnimationFrame(() => {
if (!scrollEl.value) return
const targetLane = timeAtCursor * newPps
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - NAME_WIDTH))
})
return
}
if (e.shiftKey) {
e.preventDefault()
scrollEl.value.scrollLeft += e.deltaY !== 0 ? e.deltaY : e.deltaX
}
}
let ro: ResizeObserver | null = null
onMounted(() => {
if (!scrollEl.value) return
ro = new ResizeObserver((entries) => {
const rect = entries[0].contentRect
viewportWidth.value = rect.width
})
ro.observe(scrollEl.value)
})
onBeforeUnmount(() => {
ro?.disconnect()
ro = null
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
})
</script>
<template>
<div class="timeline">
<div ref="scrollEl" class="tl-scroll" @wheel="onWheel">
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
<div class="scrub-bar">
<div class="name-spacer">
<div class="mode-toggle" title="Zeit-Einheit umschalten">
<button
class="mode-btn"
:class="{ active: displayMode === 'seconds' }"
@click="displayMode = 'seconds'"
>s</button>
<button
class="mode-btn"
:class="{ active: displayMode === 'frames' }"
@click="displayMode = 'frames'"
>fr</button>
</div>
</div>
<div
class="scrub-ruler"
:style="{ width: laneWidth + 'px' }"
@pointerdown="onScrubPointerDown"
@pointermove="onScrubPointerMove"
@pointerup="onScrubPointerUp"
@pointercancel="onScrubPointerUp"
>
<span
v-for="t in ticks"
:key="t"
class="tick"
:class="{ 'tick-start': t === 0, 'tick-end': Math.abs(t - duration) < 1e-6 }"
:style="{ left: pxFromTime(t) + 'px' }"
>
<span class="tick-line"></span>
<span class="tick-label">{{ formatTick(t) }}</span>
</span>
<span class="scrub-handle" :style="{ left: pxFromTime(store.playhead) + 'px' }">
<span class="scrub-handle-head"></span>
<span class="scrub-handle-line"></span>
</span>
</div>
</div>
<div class="tracks">
<div v-for="layer in store.scene.layers" :key="layer.id" class="track">
<span
class="track-name"
:class="{ selected: layer.id === store.selectedLayerId }"
@click="store.selectLayer(layer.id)"
>
{{ layer.name }}
</span>
<div class="track-lane" :style="{ width: laneWidth + 'px' }" @click="onLaneClick">
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(layer.keyframes)"
:key="seg.id"
class="kf-seg"
:class="`easing-${seg.easing}`"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in layer.keyframes"
:key="kf.id"
class="kf"
:style="{ left: pxFromTime(kf.time) + 'px' }"
:title="`Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`"
@pointerdown="onKeyframePointerDown($event, layer.id, kf.id)"
@pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp"
@pointercancel="onKeyframePointerUp"
@click="onKeyframeClick($event, layer.id, kf.time)"
@dblclick="onKeyframeDblClick($event, layer.id, kf.time)"
@contextmenu="onKeyframeContextMenu($event, layer.id, kf.id)"
/>
</div>
</div>
<p v-if="!store.scene.layers.length" class="empty">Noch keine Layer.</p>
</div>
</div>
</div>
<div
v-if="menu"
ref="menuRef"
class="kf-menu"
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
>
<div class="kf-menu-section">
<div class="kf-menu-header">Easing</div>
<button
v-for="opt in EASING_OPTIONS"
:key="opt.value"
class="kf-menu-item"
:class="{ active: menu.currentEasing === opt.value }"
:disabled="menu.isFirst"
@click="applyEasing(opt.value)"
>
<span class="check">
<PhCheck v-if="menu.currentEasing === opt.value" :size="12" weight="bold" />
</span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">Erster Keyframe — Easing ohne Effekt</div>
</div>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" />
Duplizieren
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<PhTrash :size="12" />
Löschen
</button>
</div>
</div>
</template>
<style scoped>
.timeline {
--track-name-width: 140px;
position: relative;
height: 100%;
overflow: hidden;
}
.tl-scroll {
width: 100%;
height: 100%;
overflow-x: auto;
overflow-y: auto;
}
.tl-content {
position: relative;
min-width: 100%;
display: flex;
flex-direction: column;
}
.scrub-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
height: 28px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.name-spacer {
position: sticky;
left: 0;
z-index: 2;
width: var(--track-name-width);
background: var(--panel);
border-right: 1px solid var(--border);
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0 10px;
}
.mode-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg);
}
.mode-btn {
padding: 3px 8px;
min-width: 22px;
background: transparent;
border: none;
border-radius: 0;
color: var(--text-dim);
font-size: 10px;
font-weight: 500;
text-transform: lowercase;
letter-spacing: 0.02em;
}
.mode-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.mode-btn.active {
background: var(--accent-soft);
color: var(--accent);
}
.mode-btn + .mode-btn {
border-left: 1px solid var(--border);
}
.scrub-ruler {
position: relative;
height: 100%;
cursor: ew-resize;
user-select: none;
touch-action: none;
flex-shrink: 0;
}
.tick {
position: absolute;
top: 0;
bottom: 0;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
color: var(--text-dim);
}
.tick.tick-start {
transform: translateX(0);
align-items: flex-start;
}
.tick.tick-end {
transform: translateX(-100%);
align-items: flex-end;
}
.tick-line {
width: 1px;
height: 6px;
background: var(--border);
}
.tick-label {
font-size: 10px;
line-height: 1;
padding-top: 3px;
font-variant-numeric: tabular-nums;
}
.scrub-handle {
position: absolute;
top: 0;
bottom: -1px;
transform: translateX(-50%);
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
}
.scrub-handle-head {
width: 11px;
height: 11px;
background: var(--accent);
border-radius: 2px;
transform: rotate(45deg);
margin-top: 2px;
box-shadow: 0 0 0 2px var(--panel), 0 2px 6px rgba(0, 0, 0, 0.4);
}
.scrub-handle-line {
flex: 1;
width: 1px;
background: var(--accent);
}
.tracks {
display: flex;
flex-direction: column;
}
.track {
display: flex;
align-items: center;
height: 30px;
border-bottom: 1px solid var(--border);
transition: background 140ms var(--ease);
}
.track-name {
position: sticky;
left: 0;
z-index: 3;
width: var(--track-name-width);
padding: 0 12px;
color: var(--text-dim);
font-size: 12px;
border-right: 1px solid var(--border);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
height: 100%;
display: flex;
align-items: center;
background: var(--panel);
transition: color 140ms var(--ease), background 140ms var(--ease);
}
.track-name:hover {
background: var(--panel-alt);
color: var(--text);
}
.track-name.selected {
color: var(--accent);
background: var(--accent-soft);
}
.track-lane {
position: relative;
height: 100%;
cursor: pointer;
flex-shrink: 0;
}
.playhead {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--accent);
pointer-events: none;
transform: translateX(-0.5px);
}
.kf-seg {
position: absolute;
top: 50%;
height: 3px;
margin-top: -1.5px;
border-radius: 999px;
pointer-events: none;
background: var(--accent);
opacity: 0.55;
}
.kf-seg.easing-linear {
background: var(--accent);
}
.kf-seg.easing-easeIn {
background: linear-gradient(
90deg,
rgba(110, 168, 255, 0.15) 0%,
rgba(110, 168, 255, 0.25) 30%,
rgba(110, 168, 255, 1) 100%
);
opacity: 0.9;
}
.kf-seg.easing-easeOut {
background: linear-gradient(
90deg,
rgba(110, 168, 255, 1) 0%,
rgba(110, 168, 255, 0.25) 70%,
rgba(110, 168, 255, 0.15) 100%
);
opacity: 0.9;
}
.kf-seg.easing-easeInOut {
background: linear-gradient(
90deg,
rgba(110, 168, 255, 0.15) 0%,
rgba(110, 168, 255, 1) 50%,
rgba(110, 168, 255, 0.15) 100%
);
opacity: 0.95;
}
.kf {
position: absolute;
top: 50%;
width: 11px;
height: 11px;
margin-left: -5.5px;
margin-top: -5.5px;
background: var(--accent);
transform: rotate(45deg);
border-radius: 2px;
cursor: grab;
touch-action: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
transition: background 120ms var(--ease), transform 120ms var(--ease);
z-index: 2;
}
.kf:hover {
background: #fff;
transform: rotate(45deg) scale(1.15);
}
.kf:active {
cursor: grabbing;
}
.empty {
color: var(--text-dim);
font-style: italic;
padding: 12px;
margin: 0;
font-size: 12px;
}
.kf-menu {
position: fixed;
z-index: 1000;
min-width: 180px;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: 5px;
font-size: 12px;
}
.kf-menu-section {
display: flex;
flex-direction: column;
}
.kf-menu-header {
padding: 6px 10px 2px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
}
.kf-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: transparent;
border: none;
color: var(--text);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
width: 100%;
}
.kf-menu-item:hover:not(:disabled) {
background: var(--panel-alt);
}
.kf-menu-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.kf-menu-item.active {
color: var(--accent);
}
.kf-menu-item.danger {
color: var(--danger);
}
.kf-menu-item.danger:hover:not(:disabled) {
background: var(--danger-soft);
}
.check {
width: 14px;
display: inline-flex;
justify-content: center;
color: var(--accent);
}
.kf-menu-note {
padding: 2px 10px 4px;
font-size: 10px;
font-style: italic;
color: var(--text-dim);
}
.kf-menu-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import { onBeforeUnmount, onMounted } from 'vue'
import { useSceneStore } from '@/stores/scene'
export function usePlaybackLoop() {
const store = useSceneStore()
let raf = 0
let last = 0
const loop = (now: number) => {
const dt = last === 0 ? 0 : (now - last) / 1000
last = now
if (store.playing) store.tick(dt)
raf = requestAnimationFrame(loop)
}
onMounted(() => {
raf = requestAnimationFrame(loop)
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf)
})
}
+35
View File
@@ -0,0 +1,35 @@
import { ref, watch } from 'vue'
export type Theme = 'dark' | 'light'
const STORAGE_KEY = 'animatr.theme'
function readInitial(): Theme {
if (typeof localStorage === 'undefined') return 'dark'
const v = localStorage.getItem(STORAGE_KEY)
return v === 'light' ? 'light' : 'dark'
}
const theme = ref<Theme>(readInitial())
function apply(t: Theme) {
if (typeof document === 'undefined') return
document.documentElement.dataset.theme = t
try {
localStorage.setItem(STORAGE_KEY, t)
} catch {
/* ignore quota */
}
}
apply(theme.value)
watch(theme, (t) => apply(t))
export function useTheme() {
return {
theme,
setTheme(t: Theme) {
theme.value = t
},
}
}
+326
View File
@@ -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>&lt;div style="width: 800px; height: 450px;"&gt;
&lt;script src="animation.js"&gt;&lt;/script&gt;
&lt;/div&gt;</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)
}
+53
View File
@@ -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')
}
+75
View File
@@ -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 }
}
+86
View File
@@ -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()
}
+119
View File
@@ -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
}
}
+135
View File
@@ -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)
}
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import VueKonva from 'vue-konva'
import App from './App.vue'
import './composables/useTheme'
import './styles/global.css'
const app = createApp(App)
app.use(createPinia())
app.use(VueKonva)
app.mount('#app')
+227
View File
@@ -0,0 +1,227 @@
import { defineStore } from 'pinia'
import type { AnimatableProps, Asset, Easing, Keyframe, Layer, Scene } from '@/types/scene'
import { DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
const uid = () => Math.random().toString(36).slice(2, 10)
type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : never) : never
const KEYFRAME_EPSILON = 1e-3
interface State {
scene: Scene
selectedLayerId: string | null
playhead: number
playing: boolean
}
export const useSceneStore = defineStore('scene', {
state: (): State => ({
scene: {
version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10,
fps: 60,
assets: [],
layers: [],
},
selectedLayerId: null,
playhead: 0,
playing: false,
}),
getters: {
lastKeyframeTime(state): number {
let max = 0
for (const layer of state.scene.layers) {
for (const kf of layer.keyframes) {
if (kf.time > max) max = kf.time
}
}
return Math.min(state.scene.duration, max)
},
effectiveEnd(state): number {
let max = 0
for (const layer of state.scene.layers) {
for (const kf of layer.keyframes) {
if (kf.time > max) max = kf.time
}
}
if (max <= 0) return state.scene.duration
return Math.min(max, state.scene.duration)
},
},
actions: {
addAsset(asset: AssetInit): Asset {
const created = { ...asset, id: uid() } as Asset
this.scene.assets.push(created)
return created
},
addLayer(assetId: string, name?: string): Layer {
const asset = this.scene.assets.find((a) => a.id === assetId)
const base: AnimatableProps = {
...DEFAULT_BASE_PROPS,
x: this.scene.canvas.width / 2,
y: this.scene.canvas.height / 2,
}
if (asset && 'fill' in asset) base.fill = asset.fill
const layer: Layer = {
id: uid(),
name: name ?? asset?.name ?? 'Layer',
assetId,
visible: true,
base,
keyframes: [
{
id: uid(),
time: 0,
props: { ...base },
easing: 'linear',
},
],
}
if (asset && (asset.kind === 'rect' || asset.kind === 'image')) {
layer.offsetX = asset.width / 2
layer.offsetY = asset.height / 2
}
this.scene.layers.push(layer)
this.selectedLayerId = layer.id
return layer
},
removeLayer(id: string) {
this.scene.layers = this.scene.layers.filter((l) => l.id !== id)
if (this.selectedLayerId === id) this.selectedLayerId = null
},
selectLayer(id: string | null) {
this.selectedLayerId = id
},
setPlayhead(t: number) {
this.playhead = Math.max(0, Math.min(this.scene.duration, t))
},
setDuration(seconds: number) {
this.scene.duration = Math.max(0.1, seconds)
if (this.playhead > this.scene.duration) this.playhead = this.scene.duration
},
setFps(fps: number) {
this.scene.fps = Math.max(1, Math.round(fps))
},
setCanvasSize(width: number, height: number) {
this.scene.canvas.width = Math.max(1, Math.round(width))
this.scene.canvas.height = Math.max(1, Math.round(height))
},
setCanvasBackground(color: string) {
this.scene.canvas.background = color
},
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const existing = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
if (existing) {
existing.props = { ...existing.props, ...props }
return
}
const snapshot = sampleLayer(layer, time)
const kf: Keyframe = {
id: uid(),
time,
props: { ...snapshot, ...props },
easing: 'linear',
}
layer.keyframes.push(kf)
layer.keyframes.sort((a, b) => a.time - b.time)
},
removeKeyframeAt(layerId: string, time: number) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
layer.keyframes = layer.keyframes.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON)
},
setKeyframeEasing(layerId: string, time: number, easing: Easing) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
if (kf) kf.easing = easing
},
setKeyframeTime(layerId: string, keyframeId: string, newTime: number) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => k.id === keyframeId)
if (!kf) return
kf.time = Math.max(0, Math.min(this.scene.duration, newTime))
layer.keyframes.sort((a, b) => a.time - b.time)
},
shiftLayerPivot(
layerId: string,
dWorldX: number,
dWorldY: number,
dLocalX: number,
dLocalY: number,
) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
layer.offsetX = (layer.offsetX ?? 0) + dLocalX
layer.offsetY = (layer.offsetY ?? 0) + dLocalY
layer.base.x += dWorldX
layer.base.y += dWorldY
for (const kf of layer.keyframes) {
if (kf.props.x !== undefined) kf.props.x += dWorldX
if (kf.props.y !== undefined) kf.props.y += dWorldY
}
},
duplicateKeyframe(layerId: string, keyframeId: string): string | null {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return null
const source = layer.keyframes.find((k) => k.id === keyframeId)
if (!source) return null
const fps = this.scene.fps > 0 ? this.scene.fps : 60
const offset = Math.max(3 / fps, 0.1)
const duration = this.scene.duration
const occupied = (t: number) =>
layer.keyframes.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON)
let target = source.time + offset
while (occupied(target) && target < duration) target += offset
if (target > duration) target = duration
if (occupied(target)) {
target = source.time - offset
while (occupied(target) && target > 0) target -= offset
if (target < 0) target = 0
if (occupied(target)) return null
}
const newKf: Keyframe = {
id: uid(),
time: target,
props: { ...source.props },
easing: source.easing,
}
layer.keyframes.push(newKf)
layer.keyframes.sort((a, b) => a.time - b.time)
return newKf.id
},
play() {
if (this.playing) return
this.playing = true
},
pause() {
this.playing = false
},
togglePlay() {
if (this.playing) this.pause()
else {
const end = this.effectiveEnd
if (end <= 0) return
if (this.playhead >= end) this.playhead = 0
this.play()
}
},
tick(deltaSeconds: number) {
if (!this.playing) return
const end = this.effectiveEnd
const next = this.playhead + deltaSeconds
if (next >= end) {
this.playhead = end
this.playing = false
} else {
this.playhead = next
}
},
},
})
+140
View File
@@ -0,0 +1,140 @@
:root {
--bg: #17171b;
--panel: #1f1f24;
--panel-alt: #272730;
--panel-hover: #2f2f3a;
--border: #2e2e37;
--border-strong: #3a3a46;
--text: #ececf1;
--text-dim: #9a9aa6;
--text-dim-strong: #c7c7d1;
--accent: #6ea8ff;
--accent-soft: rgba(110, 168, 255, 0.14);
--accent-soft-strong: rgba(110, 168, 255, 0.22);
--danger: #ff7a7a;
--danger-soft: rgba(255, 122, 122, 0.12);
--radius: 6px;
--radius-sm: 4px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 18px rgba(0, 0, 0, 0.35);
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
color-scheme: dark;
}
:root[data-theme='light'] {
--bg: #f3f3f6;
--panel: #ffffff;
--panel-alt: #f5f5f8;
--panel-hover: #eaeaf0;
--border: #e2e2e7;
--border-strong: #c9c9d0;
--text: #1c1c20;
--text-dim: #72727a;
--text-dim-strong: #3c3c42;
--accent: #2b6af5;
--accent-soft: rgba(43, 106, 245, 0.1);
--accent-soft-strong: rgba(43, 106, 245, 0.18);
--danger: #d63c3c;
--danger-soft: rgba(214, 60, 60, 0.09);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 8px 24px rgba(20, 25, 40, 0.12);
color-scheme: light;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
transition: background 180ms var(--ease), color 180ms var(--ease);
}
input,
textarea,
[contenteditable='true'] {
user-select: text;
-webkit-user-select: text;
}
button {
font: inherit;
color: inherit;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 5px 10px;
cursor: pointer;
transition: background 140ms var(--ease), border-color 140ms var(--ease),
color 140ms var(--ease), transform 120ms var(--ease);
display: inline-flex;
align-items: center;
gap: 6px;
}
button:hover:not(:disabled) {
background: var(--panel-hover);
border-color: var(--border-strong);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
input,
select,
textarea {
transition: border-color 140ms var(--ease), background 140ms var(--ease);
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent) !important;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 10px;
border: 2px solid var(--panel);
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-strong);
}
::selection {
background: var(--accent-soft-strong);
color: var(--text);
}
+90
View File
@@ -0,0 +1,90 @@
export type Easing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
export interface AnimatableProps {
x: number
y: number
scaleX: number
scaleY: number
rotation: number
opacity: number
fill?: string
}
export interface Keyframe {
id: string
time: number
props: Partial<AnimatableProps>
easing: Easing
}
export type AssetKind = 'image' | 'rect' | 'circle' | 'text'
export interface ImageAsset {
id: string
kind: 'image'
name: string
src: string
width: number
height: number
}
export interface RectAsset {
id: string
kind: 'rect'
name: string
width: number
height: number
fill: string
}
export interface CircleAsset {
id: string
kind: 'circle'
name: string
radius: number
fill: string
}
export interface TextAsset {
id: string
kind: 'text'
name: string
text: string
fontSize: number
fill: string
}
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset
export interface Layer {
id: string
name: string
assetId: string
visible: boolean
base: AnimatableProps
keyframes: Keyframe[]
offsetX?: number
offsetY?: number
}
export interface Scene {
version: 1
canvas: {
width: number
height: number
background: string
}
duration: number
fps: number
assets: Asset[]
layers: Layer[]
}
export const DEFAULT_BASE_PROPS: AnimatableProps = {
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
opacity: 1,
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})