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