This commit is contained in:
th committed 2026-04-19 19:32:23 +02:00
1 parent 94e9846c13
commit e4aa0dce86
11 files changed
+2227 -32

No files matched your search

+198 -1
View File
@@ -2,6 +2,8 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhExport,
PhFolderOpen,
PhFloppyDisk,
PhGearSix,
PhPause,
PhPlay,
@@ -15,11 +17,32 @@ 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 WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import {
ProjectFileError,
downloadProject,
pickProjectFile,
readProjectFile,
} from './lib/projectFile'
import {
getLastOpenedId,
listRecentProjects,
saveProjectToStore,
setLastOpenedId,
type StoredProject,
} from './lib/projectStore'
import { useSceneStore } from './stores/scene'
const settingsOpen = ref(false)
const exportOpen = ref(false)
const testsOpen = ref(false)
const welcomeOpen = ref(false)
const loadError = ref<string | null>(null)
const savedToast = ref<string | null>(null)
const currentProjectId = ref<string | null>(null)
const currentProjectName = ref<string | null>(null)
usePlaybackLoop()
const store = useSceneStore()
@@ -32,6 +55,87 @@ function stop() {
store.pause()
}
async function saveProject() {
const scene = JSON.parse(JSON.stringify(store.scene))
let name = currentProjectName.value
if (!name) {
const suggested = `Projekt ${new Date().toLocaleDateString()}`
const entered = prompt('Projektname?', suggested)
if (entered === null) return
name = entered.trim() || suggested
}
const rec = await saveProjectToStore(scene, {
id: currentProjectId.value ?? undefined,
name,
})
currentProjectId.value = rec.id
currentProjectName.value = rec.name
setLastOpenedId(rec.id)
showSavedToast(`"${rec.name}" gespeichert`)
}
function exportProjectFile() {
const scene = JSON.parse(JSON.stringify(store.scene))
const base = currentProjectName.value ?? 'animatr'
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animatr'
downloadProject(scene, `${safe}.json`)
}
async function importFromFile() {
const file = await pickProjectFile()
if (!file) return
const hasContent = store.scene.layers.length > 0 || store.scene.assets.length > 0
if (hasContent) {
const ok = confirm(
'Aktuelles Projekt wird durch die geladene Datei ersetzt. Ungespeicherte Änderungen gehen verloren. Fortfahren?',
)
if (!ok) return
}
try {
const scene = await readProjectFile(file)
store.loadScene(scene)
currentProjectId.value = null
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
loadError.value = null
} catch (err) {
const msg =
err instanceof ProjectFileError ? err.message : (err as Error).message || String(err)
loadError.value = msg
setTimeout(() => {
loadError.value = null
}, 6000)
}
}
function openFromRecent(project: StoredProject) {
store.loadScene(JSON.parse(JSON.stringify(project.scene)))
currentProjectId.value = project.id
currentProjectName.value = project.name
setLastOpenedId(project.id)
loadError.value = null
}
function showSavedToast(msg: string) {
savedToast.value = msg
setTimeout(() => {
if (savedToast.value === msg) savedToast.value = null
}, 2500)
}
async function bootWelcome() {
const recents = await listRecentProjects()
if (recents.length > 0) {
welcomeOpen.value = true
const lastId = getLastOpenedId()
if (lastId) {
const last = recents.find((p) => p.id === lastId)
if (last) {
currentProjectName.value = last.name
}
}
}
}
function isEditable(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false
const tag = el.tagName
@@ -41,13 +145,21 @@ function isEditable(el: EventTarget | null): boolean {
}
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault()
testsOpen.value = true
return
}
if (e.code !== 'Space') return
if (isEditable(e.target)) return
e.preventDefault()
store.togglePlay()
}
onMounted(() => document.addEventListener('keydown', onKeyDown))
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
bootWelcome()
})
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</script>
@@ -79,6 +191,15 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
</div>
<div class="topbar-right">
<span v-if="currentProjectName" class="project-name" :title="currentProjectName">
{{ currentProjectName }}
</span>
<button class="icon-btn" title="Zuletzt verwendete Projekte" @click="welcomeOpen = true">
<PhFolderOpen :size="16" />
</button>
<button class="icon-btn" title="Projekt speichern (in Browser-Speicher)" @click="saveProject">
<PhFloppyDisk :size="16" />
</button>
<button class="icon-btn" title="Document Settings" @click="settingsOpen = true">
<PhGearSix :size="16" />
</button>
@@ -94,6 +215,23 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<footer class="timeline"><Timeline /></footer>
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<WelcomeDialog
v-model="welcomeOpen"
@open="openFromRecent"
@new="() => { currentProjectId = null; currentProjectName = null }"
@import="importFromFile"
@export="exportProjectFile"
/>
<Transition name="fade">
<div v-if="loadError" class="load-error" role="alert">
<strong>Projekt konnte nicht geladen werden.</strong>
<span>{{ loadError }}</span>
</div>
</Transition>
<Transition name="fade">
<div v-if="savedToast" class="saved-toast" role="status">{{ savedToast }}</div>
</Transition>
</div>
</template>
@@ -259,4 +397,63 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
border-top: 1px solid var(--border);
overflow: hidden;
}
.load-error {
position: fixed;
bottom: 240px;
left: 50%;
transform: translateX(-50%);
z-index: 1500;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
background: var(--danger-soft);
color: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
font-size: 12px;
max-width: 520px;
}
.saved-toast {
position: fixed;
bottom: 240px;
left: 50%;
transform: translateX(-50%);
z-index: 1500;
padding: 8px 14px;
background: var(--panel);
border: 1px solid var(--border-strong);
color: var(--text);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
font-size: 12px;
}
.project-name {
display: inline-flex;
align-items: center;
max-width: 220px;
padding: 4px 10px;
font-size: 11px;
color: var(--text-dim-strong);
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 4px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+13 -31
View File
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhArrowsSplit, PhCheck, PhCopy, PhMagnet, PhTrash } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
interface TimelineTrack {
@@ -51,44 +52,25 @@ const snapEnabled = ref(true)
const SNAP_PX = 8
function allKeyframeTimes(): number[] {
function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
const out: number[] = []
for (const layer of store.scene.layers) {
for (const k of layer.keyframes) out.push(k.time)
if (layer.positionKeyframes) for (const k of layer.positionKeyframes) out.push(k.time)
if (layer.rotationKeyframes) for (const k of layer.rotationKeyframes) out.push(k.time)
for (const k of layer.keyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
if (layer.positionKeyframes)
for (const k of layer.positionKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
}
return out
}
function snapTime(t: number, ignoreKeyframeId?: string): number {
if (!snapEnabled.value) return t
const pps = effectivePxPerSec.value
if (pps <= 0) return t
const thresholdTime = SNAP_PX / pps
let best = t
let bestDist = thresholdTime
const times = ignoreKeyframeId ? keyframeTimesExcept(ignoreKeyframeId) : allKeyframeTimes()
for (const kt of times) {
const d = Math.abs(kt - t)
if (d <= bestDist) {
bestDist = d
best = kt
}
}
return clamp(best, 0, duration.value)
}
function keyframeTimesExcept(id: string): number[] {
const out: number[] = []
for (const layer of store.scene.layers) {
for (const k of layer.keyframes) if (k.id !== id) out.push(k.time)
if (layer.positionKeyframes)
for (const k of layer.positionKeyframes) if (k.id !== id) out.push(k.time)
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== id) out.push(k.time)
}
return out
return snapToCandidates(t, allKeyframeTimes(ignoreKeyframeId), {
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
pixelThreshold: SNAP_PX,
duration: duration.value,
})
}
const TICK_CANDIDATES_SEC = [0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 300]
+370
View File
@@ -0,0 +1,370 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import {
PhDownloadSimple,
PhFileArrowUp,
PhFolderOpen,
PhPlus,
PhTrash,
PhX,
} from '@phosphor-icons/vue'
import {
deleteStoredProject,
listRecentProjects,
type StoredProject,
} from '@/lib/projectStore'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'open': [project: StoredProject]
'new': []
'import': []
'export': []
}>()
const recents = ref<StoredProject[]>([])
const loading = ref(false)
async function reload() {
loading.value = true
try {
recents.value = await listRecentProjects()
} finally {
loading.value = false
}
}
function formatDate(ts: number): string {
const d = new Date(ts)
const now = new Date()
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
if (sameDay) {
return `heute ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
}
return d.toLocaleString([], {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function layerCount(p: StoredProject): number {
return p.scene?.layers?.length ?? 0
}
function openProject(p: StoredProject) {
emit('open', p)
emit('update:modelValue', false)
}
async function removeProject(e: MouseEvent, p: StoredProject) {
e.stopPropagation()
if (!confirm(`"${p.name}" wirklich löschen?`)) return
await deleteStoredProject(p.id)
await reload()
}
function startFresh() {
emit('new')
emit('update:modelValue', false)
}
function importFile() {
emit('import')
emit('update:modelValue', false)
}
function exportFile() {
emit('export')
}
function close() {
emit('update:modelValue', false)
}
onMounted(() => {
if (props.modelValue) reload()
})
watch(
() => props.modelValue,
(v) => {
if (v) reload()
},
)
</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="hdr">
<div>
<h2>Willkommen bei Animatr</h2>
<p class="sub">Zuletzt bearbeitete Projekte</p>
</div>
<button class="close" title="Schließen" @click="close">
<PhX :size="14" weight="bold" />
</button>
</header>
<div class="body">
<div v-if="loading" class="empty">Laden…</div>
<div v-else-if="!recents.length" class="empty">
Keine gespeicherten Projekte vorhanden.
</div>
<ul v-else class="recents">
<li
v-for="p in recents"
:key="p.id"
class="recent"
@click="openProject(p)"
>
<div class="thumb">
<img v-if="p.thumbnail" :src="p.thumbnail" alt="" />
<PhFolderOpen v-else :size="18" />
</div>
<div class="meta">
<div class="name">{{ p.name }}</div>
<div class="sub-info">
{{ layerCount(p) }} Layer · {{ formatDate(p.savedAt) }}
</div>
</div>
<button
class="del"
title="Projekt löschen"
@click="removeProject($event, p)"
>
<PhTrash :size="13" />
</button>
</li>
</ul>
</div>
<footer class="ftr">
<button class="ftr-btn" @click="startFresh">
<PhPlus :size="13" weight="bold" />
Neues Projekt
</button>
<button class="ftr-btn" @click="importFile">
<PhFileArrowUp :size="13" />
Datei importieren…
</button>
<button class="ftr-btn" @click="exportFile">
<PhDownloadSimple :size="13" />
Aktuelles exportieren
</button>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 800;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.dialog {
width: min(560px, calc(100% - 32px));
max-height: calc(100vh - 64px);
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
overflow: hidden;
}
.hdr {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--border);
}
.hdr h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.sub {
margin: 2px 0 0;
font-size: 11px;
color: var(--text-dim);
}
.close {
width: 26px;
height: 26px;
padding: 0;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
}
.close:hover {
background: var(--panel-alt);
color: var(--text);
}
.body {
padding: 8px 12px;
overflow: auto;
flex: 1;
}
.empty {
padding: 26px 12px;
color: var(--text-dim);
font-size: 12px;
text-align: center;
font-style: italic;
}
.recents {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.recent {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: background 140ms var(--ease), border-color 140ms var(--ease);
}
.recent:hover {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.thumb {
width: 44px;
height: 44px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border-radius: var(--radius-sm);
color: var(--accent);
overflow: hidden;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.meta {
flex: 1;
min-width: 0;
}
.name {
font-size: 13px;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub-info {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
.del {
width: 26px;
height: 26px;
padding: 0;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 140ms var(--ease);
}
.recent:hover .del {
opacity: 1;
}
.del:hover {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
.ftr {
display: flex;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid var(--border);
background: var(--panel-alt);
}
.ftr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 12px;
}
.ftr-btn:hover {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 160ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+685
View File
@@ -0,0 +1,685 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhArrowClockwise, PhCheck, PhClipboardText, PhWarning, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { runAll, type TestResult } from './testRunner'
import { registerAllTests } from './tests'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const running = ref(false)
const done = ref(0)
const total = ref(0)
const results = ref<TestResult[]>([])
const startedAt = ref(0)
const finishedAt = ref(0)
const clipboardStatus = ref<'idle' | 'copied' | 'failed'>('idle')
const currentSuite = ref('')
const currentTest = ref('')
const progressPct = computed(() =>
total.value > 0 ? Math.round((done.value / total.value) * 100) : 0,
)
const passedSoFar = computed(() => results.value.filter((r) => r.ok).length)
const failedSoFar = computed(() => results.value.filter((r) => !r.ok).length)
function buildReport(): string {
const failures = results.value.filter((r) => !r.ok)
const pass = results.value.filter((r) => r.ok).length
const fail = failures.length
const durMs = Math.round(finishedAt.value - startedAt.value)
const lines: string[] = []
lines.push('# Animatr Test Report')
lines.push('')
lines.push(`**Run at:** ${new Date().toISOString()}`)
lines.push(`**Duration:** ${durMs} ms`)
lines.push(`**Result:** ${pass} passed, ${fail} failed / ${results.value.length} total`)
lines.push('')
lines.push('## Environment')
lines.push(`- URL: ${location.href}`)
lines.push(`- User Agent: ${navigator.userAgent}`)
lines.push(`- Viewport: ${window.innerWidth}×${window.innerHeight}`)
lines.push(`- DPR: ${window.devicePixelRatio}`)
lines.push(`- Platform: ${navigator.platform}`)
lines.push(`- Language: ${navigator.language}`)
lines.push('')
if (fail === 0) {
lines.push('All tests passed. ✅')
return lines.join('\n')
}
lines.push('## Failures')
lines.push('')
for (const f of failures) {
lines.push(`### [${f.suite}] › ${f.name}`)
lines.push(`_${Math.round(f.durationMs)} ms_`)
lines.push('')
lines.push('```')
lines.push(f.error ?? '(no message)')
if (f.stack && f.stack.trim()) {
lines.push('')
lines.push(f.stack)
}
lines.push('```')
lines.push('')
}
lines.push('## Passing suites')
const suites = new Map<string, { pass: number; total: number }>()
for (const r of results.value) {
const s = suites.get(r.suite) ?? { pass: 0, total: 0 }
s.total++
if (r.ok) s.pass++
suites.set(r.suite, s)
}
for (const [name, s] of suites.entries()) {
const mark = s.pass === s.total ? '✅' : '⚠️'
lines.push(`- ${mark} ${name} — ${s.pass}/${s.total}`)
}
return lines.join('\n')
}
async function copyReport() {
const report = buildReport()
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(report)
} else {
const ta = document.createElement('textarea')
ta.value = report
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
clipboardStatus.value = 'copied'
} catch (err) {
console.error('clipboard copy failed', err)
clipboardStatus.value = 'failed'
}
setTimeout(() => {
clipboardStatus.value = 'idle'
}, 3000)
}
const grouped = computed(() => {
const map = new Map<string, TestResult[]>()
for (const r of results.value) {
const arr = map.get(r.suite) ?? []
arr.push(r)
map.set(r.suite, arr)
}
return Array.from(map.entries()).map(([suite, tests]) => ({
suite,
tests,
ok: tests.every((t) => t.ok),
}))
})
const passed = computed(() => results.value.filter((r) => r.ok).length)
const failed = computed(() => results.value.filter((r) => !r.ok).length)
const totalMs = computed(() =>
Math.round(results.value.reduce((a, r) => a + r.durationMs, 0)),
)
async function run() {
if (running.value) return
running.value = true
results.value = []
done.value = 0
total.value = 0
currentSuite.value = ''
currentTest.value = ''
clipboardStatus.value = 'idle'
startedAt.value = performance.now()
const snapshot = JSON.parse(JSON.stringify(store.$state))
try {
results.value = await runAll(registerAllTests, {
onProgress: (d, t) => {
done.value = d
total.value = t
},
onTestStart: (suiteName, testName) => {
currentSuite.value = suiteName
currentTest.value = testName
},
interTestDelayMs: 80,
})
} finally {
store.$state = snapshot
finishedAt.value = performance.now()
running.value = false
}
if (results.value.some((r) => !r.ok)) {
await copyReport()
}
}
function close() {
emit('update:modelValue', false)
}
function onKeyDown(e: KeyboardEvent) {
if (!props.modelValue) return
if (e.key === 'Escape' && !running.value) close()
}
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
if (props.modelValue) run()
})
watch(
() => props.modelValue,
(v) => {
if (v && !running.value) run()
},
)
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</script>
<template>
<Teleport to="body">
<Transition name="hud-pop">
<div v-if="modelValue && running" class="hud" role="status" aria-live="polite">
<div class="hud-top">
<span class="hud-tag">Tests laufen</span>
<span class="hud-counts">
<span class="hud-count">{{ done }}/{{ total }}</span>
<span class="hud-chip ok" v-if="passedSoFar">{{ passedSoFar }} ✓</span>
<span class="hud-chip bad" v-if="failedSoFar">{{ failedSoFar }} ✗</span>
</span>
</div>
<div class="hud-current">
<span class="hud-suite">{{ currentSuite || '…' }}</span>
<span class="hud-sep">›</span>
<span class="hud-test">{{ currentTest || '…' }}</span>
</div>
<div class="hud-bar">
<div class="hud-bar-fill" :style="{ width: progressPct + '%' }"></div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="modelValue && !running" class="overlay" @mousedown.self="close">
<div class="panel" role="dialog" aria-modal="true">
<header class="hdr">
<div class="hdr-title">
<span>Test Runner</span>
<span v-if="results.length" class="hdr-status">
<span class="ok-badge" :class="{ bad: failed > 0 }">
{{ passed }}/{{ results.length }}
</span>
<span class="dim">{{ totalMs }} ms</span>
</span>
</div>
<div class="hdr-actions">
<button
class="tbtn"
:disabled="!results.length"
:title="clipboardStatus === 'copied' ? 'Report kopiert' : 'Report in Zwischenablage kopieren'"
:class="{ copied: clipboardStatus === 'copied', failed: clipboardStatus === 'failed' }"
@click="copyReport"
>
<PhClipboardText :size="14" />
</button>
<button class="tbtn" title="Erneut ausführen" @click="run">
<PhArrowClockwise :size="14" />
</button>
<button class="tbtn" title="Schließen" @click="close">
<PhX :size="14" weight="bold" />
</button>
</div>
</header>
<div v-if="clipboardStatus !== 'idle'" class="clip-banner" :class="clipboardStatus">
<span v-if="clipboardStatus === 'copied'">
Fehlerreport wurde in die Zwischenablage kopiert.
</span>
<span v-else>
Kopieren fehlgeschlagen — Report manuell über das Clipboard-Icon auslösen.
</span>
</div>
<div class="body">
<div v-if="!results.length" class="empty">
Keine Ergebnisse — „Erneut ausführen“ klicken oder <kbd>⌃⇧⌥T</kbd> drücken.
</div>
<div v-for="g in grouped" :key="g.suite" class="suite">
<div class="suite-hdr" :class="{ bad: !g.ok }">
<PhCheck v-if="g.ok" :size="12" weight="bold" class="icon ok" />
<PhWarning v-else :size="12" weight="fill" class="icon bad" />
<span class="suite-name">{{ g.suite }}</span>
<span class="suite-count">
{{ g.tests.filter((t) => t.ok).length }}/{{ g.tests.length }}
</span>
</div>
<ul class="tests">
<li v-for="t in g.tests" :key="t.name" class="test" :class="{ bad: !t.ok }">
<span class="test-dot" :class="{ ok: t.ok, bad: !t.ok }"></span>
<span class="test-name">{{ t.name }}</span>
<span class="test-dur">{{ Math.round(t.durationMs) }}ms</span>
<pre v-if="!t.ok" class="test-err">{{ t.error }}</pre>
</li>
</ul>
</div>
</div>
<footer class="ftr">
<span class="ftr-item">{{ results.length }} Tests</span>
<span class="ftr-item ok" v-if="passed">{{ passed }} grün</span>
<span class="ftr-item bad" v-if="failed">{{ failed }} rot</span>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: stretch;
justify-content: flex-end;
backdrop-filter: blur(2px);
}
.panel {
width: min(640px, 100%);
background: var(--panel);
border-left: 1px solid var(--border-strong);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
}
.hdr {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.hdr-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 13px;
}
.hdr-status {
font-size: 11px;
font-weight: 500;
color: var(--text-dim);
display: inline-flex;
gap: 6px;
align-items: center;
}
.hdr-status.running {
color: var(--accent);
}
.ok-badge {
padding: 2px 8px;
background: rgba(71, 191, 130, 0.18);
color: #47bf82;
border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.ok-badge.bad {
background: var(--danger-soft);
color: var(--danger);
}
.dim {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.hdr-actions {
display: flex;
gap: 4px;
}
.tbtn {
width: 28px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: var(--radius-sm);
}
.tbtn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.tbtn.copied {
background: rgba(71, 191, 130, 0.18);
color: #47bf82;
border-color: #47bf82;
}
.tbtn.failed {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
.clip-banner {
padding: 8px 14px;
font-size: 11px;
background: rgba(71, 191, 130, 0.14);
color: #47bf82;
border-bottom: 1px solid rgba(71, 191, 130, 0.3);
}
.clip-banner.failed {
background: var(--danger-soft);
color: var(--danger);
border-bottom-color: var(--danger);
}
.progress {
height: 2px;
background: var(--border);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 120ms linear;
}
.body {
flex: 1;
overflow: auto;
padding: 8px 10px 12px;
}
.empty {
color: var(--text-dim);
font-size: 12px;
padding: 20px 10px;
text-align: center;
}
.empty kbd {
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
font-family: ui-monospace, Menlo, monospace;
font-size: 11px;
}
.suite {
margin-top: 10px;
}
.suite-hdr {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
}
.suite-hdr.bad {
border-color: var(--danger);
}
.suite-name {
flex: 1;
}
.suite-count {
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-size: 11px;
}
.icon.ok {
color: #47bf82;
}
.icon.bad {
color: var(--danger);
}
.tests {
list-style: none;
margin: 0;
padding: 4px 0 0 14px;
}
.test {
display: grid;
grid-template-columns: 12px 1fr auto;
gap: 8px;
align-items: center;
padding: 3px 4px;
font-size: 12px;
color: var(--text-dim-strong);
}
.test.bad {
color: var(--text);
}
.test-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--border);
}
.test-dot.ok {
background: #47bf82;
}
.test-dot.bad {
background: var(--danger);
}
.test-dur {
font-size: 10px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.test-err {
grid-column: 2 / -1;
margin: 2px 0 4px;
padding: 6px 8px;
background: var(--danger-soft);
color: var(--danger);
border-radius: var(--radius-sm);
font-family: ui-monospace, Menlo, monospace;
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
}
.ftr {
display: flex;
gap: 12px;
padding: 10px 14px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-dim);
}
.ftr-item.ok {
color: #47bf82;
}
.ftr-item.bad {
color: var(--danger);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.hud {
position: fixed;
bottom: 240px;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
width: min(520px, calc(100vw - 24px));
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: 10px 12px 8px;
pointer-events: none;
font-size: 12px;
}
.hud-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.hud-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
}
.hud-tag::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: hud-pulse 900ms var(--ease) infinite;
}
@keyframes hud-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.7); }
}
.hud-counts {
display: inline-flex;
align-items: center;
gap: 6px;
font-variant-numeric: tabular-nums;
}
.hud-count {
color: var(--text-dim);
font-size: 11px;
}
.hud-chip {
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 500;
}
.hud-chip.ok {
background: rgba(71, 191, 130, 0.18);
color: #47bf82;
}
.hud-chip.bad {
background: var(--danger-soft);
color: var(--danger);
}
.hud-current {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text);
margin-bottom: 6px;
min-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hud-suite {
color: var(--text-dim);
flex-shrink: 0;
}
.hud-sep {
color: var(--text-dim);
opacity: 0.5;
}
.hud-test {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
}
.hud-bar {
height: 3px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.hud-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 120ms linear;
}
.hud-pop-enter-active,
.hud-pop-leave-active {
transition: opacity 140ms var(--ease), transform 160ms var(--ease);
}
.hud-pop-enter-from,
.hud-pop-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
</style>
+185
View File
@@ -0,0 +1,185 @@
export interface TestResult {
suite: string
name: string
ok: boolean
error?: string
stack?: string
durationMs: number
}
interface SuiteDef {
name: string
tests: { name: string; fn: () => void | Promise<void> }[]
beforeEach?: () => void | Promise<void>
afterEach?: () => void | Promise<void>
}
const suites: SuiteDef[] = []
let current: SuiteDef | null = null
export function suite(name: string, fn: () => void): void {
const prev = current
current = { name, tests: [] }
try {
fn()
suites.push(current)
} finally {
current = prev
}
}
export function test(name: string, fn: () => void | Promise<void>): void {
if (!current) throw new Error(`test("${name}") called outside of suite()`)
current.tests.push({ name, fn })
}
export function beforeEach(fn: () => void | Promise<void>): void {
if (!current) throw new Error('beforeEach outside of suite()')
current.beforeEach = fn
}
export function afterEach(fn: () => void | Promise<void>): void {
if (!current) throw new Error('afterEach outside of suite()')
current.afterEach = fn
}
class Expectation {
constructor(private value: unknown) {}
toBe(expected: unknown): void {
if (!Object.is(this.value, expected)) {
throw new Error(`expected ${this.fmt(expected)}, got ${this.fmt(this.value)}`)
}
}
toEqual(expected: unknown): void {
const a = JSON.stringify(this.value)
const b = JSON.stringify(expected)
if (a !== b) throw new Error(`expected ${b}, got ${a}`)
}
toBeCloseTo(expected: number, precision = 4): void {
const v = this.value as number
const eps = Math.pow(10, -precision) / 2
if (Math.abs(v - expected) > eps) {
throw new Error(`expected ≈${expected} (p=${precision}), got ${v}`)
}
}
toBeTruthy(): void {
if (!this.value) throw new Error(`expected truthy, got ${this.fmt(this.value)}`)
}
toBeFalsy(): void {
if (this.value) throw new Error(`expected falsy, got ${this.fmt(this.value)}`)
}
toBeNull(): void {
if (this.value !== null) throw new Error(`expected null, got ${this.fmt(this.value)}`)
}
toBeDefined(): void {
if (this.value === undefined) throw new Error('expected defined value, got undefined')
}
toBeGreaterThan(n: number): void {
if (!((this.value as number) > n)) throw new Error(`expected > ${n}, got ${this.value}`)
}
toBeLessThan(n: number): void {
if (!((this.value as number) < n)) throw new Error(`expected < ${n}, got ${this.value}`)
}
toContain(sub: string): void {
const s = this.value as string
if (!s || !s.includes(sub)) {
throw new Error(`expected string to contain ${JSON.stringify(sub)}`)
}
}
toHaveLength(n: number): void {
const l = (this.value as { length: number }).length
if (l !== n) throw new Error(`expected length ${n}, got ${l}`)
}
private fmt(v: unknown): string {
if (v === undefined) return 'undefined'
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
}
export function expect(value: unknown): Expectation {
return new Expectation(value)
}
function cleanStack(stack: string | undefined): string | undefined {
if (!stack) return undefined
const lines = stack.split('\n')
const filtered = lines.filter((l) => {
if (l.includes('/testRunner.ts')) return false
if (l.includes('/testRunner.js')) return false
if (l.includes('node_modules')) return false
return true
})
return filtered.slice(0, 10).join('\n')
}
export interface RunOptions {
onProgress?: (done: number, total: number) => void
onTestStart?: (suite: string, name: string) => void
interTestDelayMs?: number
}
export async function runAll(
register: () => void,
opts: RunOptions = {},
): Promise<TestResult[]> {
suites.length = 0
register()
const total = suites.reduce((a, s) => a + s.tests.length, 0)
const results: TestResult[] = []
let done = 0
const delay = Math.max(0, opts.interTestDelayMs ?? 0)
for (const s of suites) {
for (const t of s.tests) {
opts.onTestStart?.(s.name, t.name)
await new Promise((r) => requestAnimationFrame(() => r(null)))
const start = performance.now()
try {
if (s.beforeEach) await s.beforeEach()
await t.fn()
if (s.afterEach) await s.afterEach()
results.push({
suite: s.name,
name: t.name,
ok: true,
durationMs: performance.now() - start,
})
} catch (err) {
try {
if (s.afterEach) await s.afterEach()
} catch {
/* ignore cleanup errors if test already failed */
}
const e = err as Error
results.push({
suite: s.name,
name: t.name,
ok: false,
error: e.message || String(err),
stack: cleanStack(e.stack),
durationMs: performance.now() - start,
})
}
done++
opts.onProgress?.(done, total)
if (delay > 0) await new Promise((r) => setTimeout(r, delay))
else await new Promise((r) => setTimeout(r, 0))
}
}
return results
}
+525
View File
@@ -0,0 +1,525 @@
import { useSceneStore } from '@/stores/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import {
generateBundledScript,
generateRuntimeScript,
generateSceneScript,
} from '@/lib/exportPlayer'
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
import { expect, suite, test } from './testRunner'
function makeFreshStore() {
const s = useSceneStore()
s.$reset()
return s
}
function tick(ms = 40): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
export function registerAllTests(): void {
suite('scene store — defaults', () => {
test('starts with empty scene, playhead 0, not playing', () => {
const s = makeFreshStore()
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets).toHaveLength(0)
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
expect(s.scene.duration).toBe(10)
expect(s.scene.fps).toBe(60)
expect(s.selectedLayerId).toBeNull()
})
})
suite('scene store — playhead', () => {
test('setPlayhead clamps to [0, duration]', async () => {
const s = makeFreshStore()
s.setPlayhead(-5)
await tick()
expect(s.playhead).toBe(0)
s.setPlayhead(999)
await tick()
expect(s.playhead).toBe(s.scene.duration)
s.setPlayhead(3.5)
await tick()
expect(s.playhead).toBe(3.5)
})
test('setDuration pulls playhead back if past end', async () => {
const s = makeFreshStore()
s.setPlayhead(9)
await tick()
s.setDuration(5)
await tick()
expect(s.playhead).toBe(5)
})
test('setDuration clamps to min 0.1', () => {
const s = makeFreshStore()
s.setDuration(0)
expect(s.scene.duration).toBe(0.1)
})
})
suite('scene store — layers & selection', () => {
test('addLayer with rect asset creates layer + initial keyframe + selects it', async () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'rect',
name: 'TestRect',
width: 200,
height: 100,
fill: '#ff4455',
})
await tick()
const layer = s.addLayer(asset.id)
await tick()
expect(s.scene.layers).toHaveLength(1)
expect(s.selectedLayerId).toBe(layer.id)
expect(layer.keyframes).toHaveLength(1)
expect(layer.keyframes[0].time).toBe(0)
expect(layer.offsetX).toBe(100)
expect(layer.offsetY).toBe(50)
expect(layer.base.fill).toBe('#ff4455')
})
test('removeLayer deselects if it was selected', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'TestCircle', radius: 40, fill: '#44aaff' })
const l = s.addLayer(a.id)
await tick()
s.removeLayer(l.id)
await tick()
expect(s.scene.layers).toHaveLength(0)
expect(s.selectedLayerId).toBeNull()
})
test('selectLayer toggles with null', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'SelC', radius: 30, fill: '#00cc88' })
const l = s.addLayer(a.id)
await tick()
s.selectLayer(null)
await tick()
expect(s.selectedLayerId).toBeNull()
s.selectLayer(l.id)
await tick()
expect(s.selectedLayerId).toBe(l.id)
})
})
suite('scene store — keyframes', () => {
test('upsertKeyframe adds new keyframe sorted by time', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Kfs', width: 80, height: 80, fill: '#ffa500' })
const l = s.addLayer(a.id)
await tick()
s.upsertKeyframe(l.id, 2, { x: 600 })
await tick()
s.upsertKeyframe(l.id, 1, { x: 300 })
await tick()
const times = l.keyframes.map((k) => k.time)
expect(times).toEqual([0, 1, 2])
})
test('upsertKeyframe updates existing keyframe within epsilon', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Upd', width: 80, height: 80, fill: '#aa44ff' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 500 })
await tick()
const before = l.keyframes.length
s.upsertKeyframe(l.id, 1.0000001, { x: 900 })
await tick()
expect(l.keyframes).toHaveLength(before)
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.01)!
expect(kf.props.x).toBe(900)
})
test('setKeyframeTime clamps and sorts', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Clamp', width: 60, height: 60, fill: '#ff8844' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 3, { x: 400 })
s.upsertKeyframe(l.id, 5, { x: 800 })
await tick()
const target = l.keyframes.find((k) => k.time === 5)!
s.setKeyframeTime(l.id, target.id, -1)
await tick()
expect(target.time).toBe(0)
const times = l.keyframes.map((k) => k.time).sort((a, b) => a - b)
expect(times).toEqual(l.keyframes.map((k) => k.time))
})
test('setKeyframeTime clamps to duration upper bound', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Dur', width: 60, height: 60, fill: '#44ffaa' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 400 })
await tick()
const k = l.keyframes[l.keyframes.length - 1]
s.setKeyframeTime(l.id, k.id, 999)
await tick()
expect(k.time).toBe(s.scene.duration)
})
test('removeKeyframe removes by id', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rm', width: 50, height: 50, fill: '#ee4488' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 500 })
await tick()
const k = l.keyframes.find((k) => k.time === 2)!
s.removeKeyframe(l.id, k.id)
await tick()
expect(l.keyframes.find((x) => x.id === k.id)).toBe(undefined)
})
test('duplicateKeyframe creates offset copy', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Dup', width: 50, height: 50, fill: '#22aaee' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400 })
await tick()
const src = l.keyframes.find((k) => k.time === 1)!
const newId = s.duplicateKeyframe(l.id, src.id)
await tick()
expect(newId).toBeTruthy()
const dup = l.keyframes.find((k) => k.id === newId)!
expect(dup.props.x).toBe(400)
expect(dup.time).toBeGreaterThan(1)
})
test('setKeyframeEasing updates easing field', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Ease', width: 50, height: 50, fill: '#88cc44' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400 })
await tick()
const k = l.keyframes[l.keyframes.length - 1]
s.setKeyframeEasing(l.id, k.id, 'easeInOut')
expect(k.easing).toBe('easeInOut')
})
})
suite('scene store — separated tracks', () => {
test('setLayerSeparated splits x/y and rotation into dedicated tracks', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 60, height: 60, fill: '#ee66aa' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
await tick()
s.setLayerSeparated(l.id, true)
await tick()
expect(l.separated).toBe(true)
expect((l.positionKeyframes ?? []).length).toBeGreaterThan(0)
expect((l.rotationKeyframes ?? []).length).toBeGreaterThan(0)
for (const k of l.keyframes) {
expect((k.props as Record<string, unknown>).x).toBe(undefined)
expect((k.props as Record<string, unknown>).y).toBe(undefined)
expect((k.props as Record<string, unknown>).rotation).toBe(undefined)
}
})
test('setLayerSeparated(false) merges tracks back together', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Merge', width: 60, height: 60, fill: '#66ccee' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
await tick()
s.setLayerSeparated(l.id, true)
await tick()
s.setLayerSeparated(l.id, false)
await tick()
expect(l.separated).toBe(false)
expect(l.positionKeyframes).toBe(undefined)
expect(l.rotationKeyframes).toBe(undefined)
const at1 = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.001)!
expect((at1.props as Record<string, number>).x).toBe(400)
expect((at1.props as Record<string, number>).rotation).toBe(45)
})
})
suite('interpolation — sampleLayer', () => {
test('no keyframes → returns base', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
l.keyframes = []
const p = sampleLayer(l, 0.5)
expect(p.x).toBe(l.base.x)
expect(p.y).toBe(l.base.y)
})
test('linear midpoint is halfway', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
]
l.base = { ...l.base, x: 0, y: 0 }
const p = sampleLayer(l, 1)
expect(p.x).toBeCloseTo(50, 2)
})
test('easeIn midpoint is below linear midpoint', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'easeIn' },
]
const p = sampleLayer(l, 1)
expect(p.x).toBeLessThan(50)
expect(p.x).toBeGreaterThan(0)
})
test('clamps before first kf to first props, after last to last props', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
l.base = { ...l.base, x: 0 }
l.keyframes = [
{ id: 'k1', time: 1, props: { x: 10 }, easing: 'linear' },
{ id: 'k2', time: 2, props: { x: 20 }, easing: 'linear' },
]
expect(sampleLayer(l, 0).x).toBe(10)
expect(sampleLayer(l, 5).x).toBe(20)
})
})
suite('interpolation — color', () => {
test('lerpColor 0 returns start', () => {
expect(lerpColor('#000000', '#ffffff', 0)).toBe('#000000')
})
test('lerpColor 1 returns end', () => {
expect(lerpColor('#000000', '#ffffff', 1)).toBe('#ffffff')
})
test('lerpColor 0.5 is around middle gray', () => {
const mid = lerpColor('#000000', '#ffffff', 0.5)
expect(mid).toBe('#808080')
})
})
suite('timeline snap', () => {
test('disabled: returns t unchanged', () => {
const r = snapToCandidates(1.7, [2.0], {
enabled: false,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(1.7)
})
test('enabled: snaps to nearest candidate within threshold', () => {
const r = snapToCandidates(1.95, [2.0, 5.0], {
enabled: true,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(2.0)
})
test('enabled: leaves t alone if all candidates are outside threshold', () => {
const r = snapToCandidates(1.0, [2.0], {
enabled: true,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(1.0)
})
test('clamps result to [0, duration]', () => {
const r = snapToCandidates(15, [], {
enabled: true,
pxPerSec: 100,
pixelThreshold: 8,
duration: 10,
})
expect(r).toBe(10)
})
})
suite('export — runtime & scene split', () => {
test('generateRuntimeScript exposes AnimatrPlayer', () => {
const r = generateRuntimeScript()
expect(r).toContain('window.AnimatrPlayer')
expect(r).toContain('function mount')
expect(r).toContain('__animatrPending')
})
test('generateSceneScript embeds scene JSON and uses AnimatrPlayer', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'UniqRect77', width: 10, height: 10, fill: '#abc' })
s.addLayer(a.id)
const src = generateSceneScript(s.scene)
expect(src).toContain('AnimatrPlayer.mount')
expect(src).toContain('UniqRect77')
expect(src).toContain('__animatrPending')
})
test('scene script does NOT inline the mount implementation', () => {
const s = makeFreshStore()
const src = generateSceneScript(s.scene)
expect(src.indexOf('function mount')).toBe(-1)
})
test('generateBundledScript contains runtime + scene boot', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'BundlR', width: 10, height: 10, fill: '#123' })
s.addLayer(a.id)
const src = generateBundledScript(s.scene)
expect(src).toContain('function mount')
expect(src).toContain('AnimatrPlayer.mount')
expect(src).toContain('BundlR')
})
})
suite('playback — transport', () => {
test('togglePlay toggles playing state', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Play', width: 60, height: 60, fill: '#55cc66' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 500 })
await tick()
s.togglePlay()
await tick()
expect(s.playing).toBe(true)
s.togglePlay()
await tick()
expect(s.playing).toBe(false)
})
test('togglePlay rewinds if at end', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Rew', width: 60, height: 60, fill: '#cc5555' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 600 })
s.setPlayhead(s.effectiveEnd)
await tick()
s.togglePlay()
expect(s.playhead).toBe(0)
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('tick advances playhead while playing', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Tk', width: 60, height: 60, fill: '#9977dd' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 5, { x: 800 })
s.play()
s.tick(1)
s.pause()
expect(s.playhead).toBeCloseTo(1, 3)
await tick()
})
test('tick stops at effectiveEnd', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'End', width: 60, height: 60, fill: '#dd7799' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 400 })
s.play()
await tick()
s.tick(10)
await tick()
expect(s.playing).toBe(false)
expect(s.playhead).toBe(s.effectiveEnd)
})
})
suite('project file — save & load roundtrip', () => {
test('serialize + parse roundtrip preserves scene', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'PF', width: 80, height: 60, fill: '#66ddaa' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
s.setDuration(7)
await tick()
const text = serializeProject(s.scene)
const parsed = parseProject(text)
expect(parsed.duration).toBe(7)
expect(parsed.layers).toHaveLength(1)
expect(parsed.assets).toHaveLength(1)
expect(parsed.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400)
})
test('loadScene replaces store state visibly', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
s.addLayer(a.id)
await tick()
const dump = serializeProject(s.scene)
s.$reset()
await tick()
s.loadScene(parseProject(dump))
await tick()
expect(s.scene.layers).toHaveLength(1)
expect(s.scene.assets).toHaveLength(1)
expect(s.selectedLayerId).toBeNull()
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
})
test('parseProject rejects non-JSON', () => {
let caught: unknown = null
try {
parseProject('not json at all')
} catch (e) {
caught = e
}
expect(caught instanceof ProjectFileError).toBe(true)
})
test('parseProject rejects foreign JSON without magic', () => {
let caught: unknown = null
try {
parseProject(JSON.stringify({ foo: 'bar' }))
} catch (e) {
caught = e
}
expect(caught instanceof ProjectFileError).toBe(true)
})
test('parseProject rejects missing scene fields', () => {
let caught: unknown = null
try {
parseProject(JSON.stringify({ magic: 'animatr-project', fileVersion: 1, scene: {} }))
} catch (e) {
caught = e
}
expect(caught instanceof ProjectFileError).toBe(true)
})
})
suite('app smoke — live DOM', () => {
test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy()
expect(document.querySelector('.topbar')).toBeTruthy()
expect(document.querySelector('.timeline')).toBeTruthy()
expect(document.querySelector('.stage')).toBeTruthy()
})
test('timeline snap toggle button is present', () => {
expect(document.querySelector('.snap-btn')).toBeTruthy()
})
test('topbar has save & recents buttons', () => {
const saveBtn = document.querySelector('button[title^="Projekt speichern"]')
const recentsBtn = document.querySelector('button[title="Zuletzt verwendete Projekte"]')
expect(saveBtn).toBeTruthy()
expect(recentsBtn).toBeTruthy()
})
})
}
+91
View File
@@ -0,0 +1,91 @@
import type { Scene } from '@/types/scene'
const FILE_VERSION = 1
const MAGIC = 'animatr-project'
export interface ProjectFile {
magic: typeof MAGIC
fileVersion: number
savedAt: string
scene: Scene
}
export function serializeProject(scene: Scene): string {
const payload: ProjectFile = {
magic: MAGIC,
fileVersion: FILE_VERSION,
savedAt: new Date().toISOString(),
scene,
}
return JSON.stringify(payload, null, 2)
}
export class ProjectFileError extends Error {
constructor(message: string) {
super(message)
this.name = 'ProjectFileError'
}
}
export function parseProject(text: string): Scene {
let data: unknown
try {
data = JSON.parse(text)
} catch {
throw new ProjectFileError('Datei ist kein gültiges JSON.')
}
if (!data || typeof data !== 'object') {
throw new ProjectFileError('Datei ist leer oder kein Objekt.')
}
const obj = data as Record<string, unknown>
if (obj.magic !== MAGIC) {
throw new ProjectFileError('Keine Animatr-Projektdatei (magic fehlt).')
}
const scene = obj.scene
if (!scene || typeof scene !== 'object') {
throw new ProjectFileError('Projektdatei enthält keine Szene.')
}
const s = scene as Record<string, unknown>
if (!s.canvas || typeof s.canvas !== 'object') {
throw new ProjectFileError('Szenendaten unvollständig: canvas fehlt.')
}
if (!Array.isArray(s.layers)) {
throw new ProjectFileError('Szenendaten unvollständig: layers fehlt.')
}
if (!Array.isArray(s.assets)) {
throw new ProjectFileError('Szenendaten unvollständig: assets fehlt.')
}
return scene as Scene
}
export function downloadProject(scene: Scene, filename = 'projekt.animatr.json') {
const source = serializeProject(scene)
const blob = new Blob([source], { type: 'application/json' })
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)
}
export function pickProjectFile(): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json,application/json'
input.onchange = () => {
const f = input.files?.[0]
resolve(f ?? null)
}
input.oncancel = () => resolve(null)
input.click()
})
}
export async function readProjectFile(file: File): Promise<Scene> {
const text = await file.text()
return parseProject(text)
}
+124
View File
@@ -0,0 +1,124 @@
import type { Scene } from '@/types/scene'
const DB_NAME = 'animatr'
const DB_VERSION = 1
const STORE = 'projects'
const MAX_RECENT = 8
export interface StoredProject {
id: string
name: string
scene: Scene
savedAt: number
thumbnail?: string
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'id' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'))
})
return dbPromise
}
function tx<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((resolve, reject) => {
const t = db.transaction(STORE, mode)
const s = t.objectStore(STORE)
const req = fn(s)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'))
}),
)
}
function uid(): string {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
}
export async function listRecentProjects(): Promise<StoredProject[]> {
try {
const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>)
return (all ?? [])
.slice()
.sort((a, b) => b.savedAt - a.savedAt)
.slice(0, MAX_RECENT)
} catch {
return []
}
}
export interface SaveOptions {
id?: string
name?: string
thumbnail?: string
}
export async function saveProjectToStore(
scene: Scene,
opts: SaveOptions = {},
): Promise<StoredProject> {
const record: StoredProject = {
id: opts.id ?? uid(),
name: opts.name?.trim() || defaultName(),
scene: JSON.parse(JSON.stringify(scene)),
savedAt: Date.now(),
thumbnail: opts.thumbnail,
}
await tx('readwrite', (s) => s.put(record))
return record
}
export async function deleteStoredProject(id: string): Promise<void> {
await tx('readwrite', (s) => s.delete(id))
}
export async function getStoredProject(id: string): Promise<StoredProject | null> {
try {
const rec = await tx<StoredProject | undefined>('readonly', (s) =>
s.get(id) as IDBRequest<StoredProject | undefined>,
)
return rec ?? null
} catch {
return null
}
}
function defaultName(): string {
const d = new Date()
return `Projekt ${d.toLocaleDateString()} ${d.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}`
}
const LAST_OPEN_KEY = 'animatr:lastOpenedId'
export function setLastOpenedId(id: string | null): void {
try {
if (id === null) localStorage.removeItem(LAST_OPEN_KEY)
else localStorage.setItem(LAST_OPEN_KEY, id)
} catch {
/* ignore */
}
}
export function getLastOpenedId(): string | null {
try {
return localStorage.getItem(LAST_OPEN_KEY)
} catch {
return null
}
}
+26
View File
@@ -0,0 +1,26 @@
export interface SnapOptions {
enabled: boolean
pxPerSec: number
pixelThreshold: number
duration: number
}
export function snapToCandidates(
t: number,
candidates: readonly number[],
opts: SnapOptions,
): number {
if (!opts.enabled) return t
if (opts.pxPerSec <= 0) return t
const thresholdTime = opts.pixelThreshold / opts.pxPerSec
let best = t
let bestDist = thresholdTime
for (const kt of candidates) {
const d = Math.abs(kt - t)
if (d <= bestDist) {
bestDist = d
best = kt
}
}
return Math.max(0, Math.min(opts.duration, best))
}
+6
View File
@@ -363,6 +363,12 @@ export const useSceneStore = defineStore('scene', {
layer.separated = false
}
},
loadScene(scene: Scene) {
this.scene = scene
this.selectedLayerId = null
this.playhead = 0
this.playing = false
},
play() {
if (this.playing) return
this.playing = true
+4
View File
@@ -19,4 +19,8 @@ der playhead soll kein gedrehtes quadrat sein so wie die keyframes sondern ein a
baue eine frontent testing engine. mit shift+control+alt+t muss such die app dann selbst durchtesten. schreibe tests für die funktionen die wir bisher haben
fehlerreport mit allen relevanten informationen muss automatisch in die zwischenablage kopiert werden. schreib dir da alles rein was du zum fixen brauchst
rechtsklick auf keyframe muss die auswahl von "still frame" bieten. von einem ein still frame aus findet keine tween animation statt. das objekt bleibt einfach stehen. der keyframe wird als dot dargestellt statt als diamant
text-objekte muss man neben rect und kreis zur verfügung haben