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

No files matched your search

+460
View File
@@ -0,0 +1,460 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhCode,
PhDownloadSimple,
PhFileHtml,
PhFilmSlate,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
interface ProgressState {
progress: number
controller: AbortController
label: string
error: string | null
}
const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
function sceneClone() {
return JSON.parse(JSON.stringify(store.scene))
}
function exportJs() {
downloadExport(sceneClone())
close()
}
function exportHtml() {
downloadExampleHtml(sceneClone())
close()
}
async function exportWebm() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: 'Rendering WebM (Browser, Realtime)…',
error: null,
}
store.pause()
try {
await downloadSceneAsVideo(sceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportMp4Server() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: 'Rendering MP4 (Server)…',
error: null,
}
store.pause()
try {
await downloadFromServer(sceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
function cancel() {
progress.value?.controller.abort()
}
function dismissError() {
progress.value = null
}
const isLocked = () => !!progress.value && !progress.value.error
function close() {
if (isLocked()) return
emit('update:modelValue', false)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.modelValue) close()
}
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="modelValue" class="backdrop" @mousedown.self="close">
<div class="dialog" role="dialog" aria-modal="true">
<header class="dialog-header">
<h2>Export</h2>
<button class="close-btn" :disabled="isLocked()" title="Schließen" @click="close">
<PhX :size="14" weight="bold" />
</button>
</header>
<div v-if="!progress" class="options">
<button class="opt" @click="exportJs">
<div class="opt-icon"><PhCode :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
animation.js
<span class="badge">Web Embed</span>
</div>
<div class="opt-desc">Selbstständiges JS — in eine eigene Seite einbinden</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportHtml">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">Beispiel HTML</div>
<div class="opt-desc">Standalone HTML — Browser öffnen und fertig</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportMp4Server">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">Server · Frame-exakt</span>
</div>
<div class="opt-desc">H.264, beliebige Auflösung — benötigt Render-Server</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" :disabled="!videoSupported" @click="exportWebm">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">Browser · Realtime</span>
</div>
<div class="opt-desc">
{{ videoSupported ? 'Ohne Server — Aufnahme in Echtzeit' : 'Browser unterstützt MediaRecorder nicht' }}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
</div>
<div v-else-if="!progress.error" class="rendering">
<div class="r-label">
<PhFilmSlate :size="14" />
<span class="r-text">{{ progress.label }}</span>
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span>
</div>
<div class="r-bar">
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
</div>
<div class="r-actions">
<button class="secondary" @click="cancel">Abbrechen</button>
</div>
</div>
<div v-else class="error-view">
<div class="error-title">Export fehlgeschlagen</div>
<div class="error-msg">{{ progress.error }}</div>
<div class="error-actions">
<button class="primary" @click="dismissError">Zurück</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.dialog {
width: min(520px, calc(100% - 32px));
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
max-height: calc(100vh - 64px);
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.dialog-header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.close-btn {
width: 26px;
height: 26px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
}
.close-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.options {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
}
.opt {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
text-align: left;
transition: background 140ms var(--ease), border-color 140ms var(--ease),
transform 140ms var(--ease);
}
.opt:hover:not(:disabled) {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.opt:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.opt-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border-radius: var(--radius-sm);
color: var(--accent);
flex-shrink: 0;
}
.opt-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.opt-title {
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.opt-desc {
font-size: 11px;
color: var(--text-dim);
line-height: 1.35;
}
.opt-arrow {
color: var(--text-dim);
flex-shrink: 0;
}
.opt:hover:not(:disabled) .opt-arrow {
color: var(--accent);
}
.badge {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
background: var(--bg);
color: var(--text-dim);
border-radius: 999px;
}
.badge.accent {
background: var(--accent-soft);
color: var(--accent);
}
.rendering {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.r-label {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-dim-strong);
font-size: 12px;
}
.r-text {
flex: 1;
}
.r-percent {
color: var(--accent);
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.r-bar {
height: 6px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.r-bar-fill {
height: 100%;
background: var(--accent);
transition: width 140ms linear;
}
.r-actions {
display: flex;
justify-content: flex-end;
padding-top: 4px;
}
.r-actions button {
padding: 6px 14px;
border: 1px solid var(--border);
background: var(--panel-alt);
color: var(--text);
border-radius: var(--radius-sm);
}
.r-actions button:hover:not(:disabled) {
background: var(--panel-hover);
}
.error-view {
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.error-title {
color: var(--danger);
font-weight: 600;
font-size: 13px;
}
.error-msg {
font-size: 12px;
color: var(--text-dim-strong);
background: var(--danger-soft);
padding: 8px 10px;
border-radius: var(--radius-sm);
word-break: break-word;
user-select: text;
-webkit-user-select: text;
}
.error-actions {
display: flex;
justify-content: flex-end;
}
.error-actions .primary {
padding: 6px 14px;
background: var(--accent-soft);
border: 1px solid var(--accent);
color: var(--accent);
border-radius: var(--radius-sm);
}
.error-actions .primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>