407 lines
9.2 KiB
Vue
407 lines
9.2 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import {
|
|
PhCode,
|
|
PhDownloadSimple,
|
|
PhEngine,
|
|
PhFileHtml,
|
|
PhFilmSlate,
|
|
} from '@phosphor-icons/vue'
|
|
import { useSceneStore } from '@/stores/scene'
|
|
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
|
|
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
|
import { downloadFromServer } from '@/lib/serverExport'
|
|
import { Modal } from '@/components/ui'
|
|
import { t } from '@/i18n'
|
|
|
|
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 exportScene() {
|
|
downloadSceneExport(sceneClone())
|
|
emit('update:modelValue', false)
|
|
}
|
|
|
|
function exportRuntime() {
|
|
downloadRuntime()
|
|
emit('update:modelValue', false)
|
|
}
|
|
|
|
function exportHtml() {
|
|
downloadExampleHtml(sceneClone())
|
|
emit('update:modelValue', false)
|
|
}
|
|
|
|
async function exportWebm() {
|
|
if (progress.value) return
|
|
const controller = new AbortController()
|
|
progress.value = {
|
|
progress: 0,
|
|
controller,
|
|
label: t('export.labelWebm'),
|
|
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: t('export.labelMp4'),
|
|
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 = computed(() => !!progress.value && !progress.value.error)
|
|
|
|
function onUpdate(v: boolean) {
|
|
if (!v && isLocked.value) return
|
|
emit('update:modelValue', v)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Modal
|
|
:model-value="modelValue"
|
|
:title="t('export.title')"
|
|
size="md"
|
|
:dismissable="!isLocked"
|
|
:close-on-backdrop="!isLocked"
|
|
:close-on-esc="!isLocked"
|
|
@update:model-value="onUpdate"
|
|
>
|
|
<div v-if="!progress" class="options">
|
|
<button class="opt" @click="exportRuntime">
|
|
<div class="opt-icon"><PhEngine :size="20" /></div>
|
|
<div class="opt-text">
|
|
<div class="opt-title">
|
|
animatr-runtime.js
|
|
<span class="badge">{{ t('export.runtimeBadge') }}</span>
|
|
</div>
|
|
<div class="opt-desc">{{ t('export.runtimeDesc') }}</div>
|
|
</div>
|
|
<PhDownloadSimple :size="14" class="opt-arrow" />
|
|
</button>
|
|
|
|
<button class="opt" @click="exportScene">
|
|
<div class="opt-icon"><PhCode :size="20" /></div>
|
|
<div class="opt-text">
|
|
<div class="opt-title">
|
|
animation.js
|
|
<span class="badge">{{ t('export.sceneBadge') }}</span>
|
|
</div>
|
|
<div class="opt-desc">{{ t('export.sceneDesc') }}</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">{{ t('export.exampleTitle') }}</div>
|
|
<div class="opt-desc">{{ t('export.exampleDesc') }}</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">{{ t('export.mp4Badge') }}</span>
|
|
</div>
|
|
<div class="opt-desc">{{ t('export.mp4Desc') }}</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">{{ t('export.webmBadge') }}</span>
|
|
</div>
|
|
<div class="opt-desc">
|
|
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
|
|
</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">{{ t('export.cancel') }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="error-view">
|
|
<div class="error-title">{{ t('export.failed') }}</div>
|
|
<div class="error-msg">{{ progress.error }}</div>
|
|
<div class="error-actions">
|
|
<button class="primary" @click="dismissError">{{ t('export.back') }}</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.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);
|
|
}
|
|
</style>
|