init
This commit is contained in:
commit
c6863f2bfc
34 files changed
+6909
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user