refactor: remove FFmpeg dependencies and related video processing logic
- Deleted FFmpeg related packages from package.json and package-lock.json. - Removed video transcoding and editing functionalities from FileContent.vue. - Simplified video handling logic and error management in FileContent.vue. - Added a clear button in MilkdownEditor.vue for clearing the editor content. - Enhanced UniverPreview.vue to clear detached popups and mount nodes on destroy. - Updated docBlockPlugin.ts to improve context handling for document blocks. - Cleaned up vite.config.js by removing cross-origin isolation headers.
This commit is contained in:
Generated
+1
-52
@@ -9,10 +9,6 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@blocknote/xl-docx-exporter": "^0.47.3",
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
"@ffmpeg/core-mt": "^0.12.10",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@milkdown/core": "^7.18.0",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
@@ -34,6 +30,7 @@
|
||||
"mermaid": "^11.12.3",
|
||||
"pinia": "^2.3.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"tui-color-picker": "^2.2.8",
|
||||
"tui-image-editor": "^3.15.3",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
@@ -1212,54 +1209,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/core": {
|
||||
"version": "0.12.10",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
|
||||
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/core-mt": {
|
||||
"version": "0.12.10",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz",
|
||||
"integrity": "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/ffmpeg": {
|
||||
"version": "0.12.15",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
|
||||
"integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ffmpeg/types": "^0.12.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/types": {
|
||||
"version": "0.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz",
|
||||
"integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/util": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz",
|
||||
"integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@flatten-js/interval-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.3.tgz",
|
||||
|
||||
+2
-4
@@ -12,10 +12,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/xl-docx-exporter": "^0.47.3",
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
"@ffmpeg/core-mt": "^0.12.10",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@milkdown/core": "^7.18.0",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
@@ -37,6 +33,8 @@
|
||||
"mermaid": "^11.12.3",
|
||||
"pinia": "^2.3.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"tui-color-picker": "^2.2.8",
|
||||
"tui-image-editor": "^3.15.3",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
|
||||
+16
-444
@@ -2,10 +2,11 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
|
||||
import { canPlayVideoNatively, editVideoBlob, isVideoFile, transcodeVideoToMp4 } from '../utils/ffmpeg'
|
||||
import ImageEditorComponent from './ImageEditorComponent.vue'
|
||||
import UniverPreview from './UniverPreview.vue'
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogv', 'ogg', 'mov', 'm4v'])
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null },
|
||||
breadcrumb: { type: Array, default: () => [] },
|
||||
@@ -25,26 +26,11 @@ const md = new MarkdownIt({
|
||||
})
|
||||
|
||||
const basePreviewUrl = ref('')
|
||||
const editedVideoUrl = ref('')
|
||||
const editedVideoBlob = ref(null)
|
||||
const imageEditorRef = ref(null)
|
||||
const imageEditorError = ref('')
|
||||
const isEditingImage = ref(false)
|
||||
const isSavingImage = ref(false)
|
||||
const isTranscodingVideo = ref(false)
|
||||
const transcodeProgress = ref(0)
|
||||
const videoPreviewError = ref('')
|
||||
const isUsingTranscodedVideo = ref(false)
|
||||
const isApplyingVideoEdit = ref(false)
|
||||
const videoEditProgress = ref(0)
|
||||
const videoEditError = ref('')
|
||||
const videoElement = ref(null)
|
||||
const videoDurationSeconds = ref(0)
|
||||
const videoCurrentTime = ref(0)
|
||||
const clipStartSeconds = ref(0)
|
||||
const clipEndSeconds = ref(0)
|
||||
const muteVideoAudio = ref(false)
|
||||
let previewTaskId = 0
|
||||
|
||||
const isRoot = computed(() => !props.node)
|
||||
const isFolder = computed(() => props.node?.type === 'folder')
|
||||
@@ -62,7 +48,10 @@ const isPdf = computed(() => {
|
||||
const mime = String(props.node?.mimeType || '')
|
||||
return mime === 'application/pdf' || fileExt.value === 'pdf'
|
||||
})
|
||||
const isVideo = computed(() => isVideoFile(props.node))
|
||||
const isVideo = computed(() => {
|
||||
const mime = String(props.node?.mimeType || '').toLowerCase()
|
||||
return mime.startsWith('video/') || VIDEO_EXTENSIONS.has(fileExt.value)
|
||||
})
|
||||
const isText = computed(() => {
|
||||
const mime = String(props.node?.mimeType || '')
|
||||
return Boolean(props.node?.content || props.node?.previewText) || mime.startsWith('text/') || mime.includes('json') || ['txt', 'json', 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'htm', 'py', 'vue', 'xml', 'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg', 'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp', 'go', 'rs'].includes(fileExt.value)
|
||||
@@ -93,144 +82,33 @@ const locLabel = computed(() => {
|
||||
const count = lineCount.value
|
||||
return count ? `${count} 行` : '二进制文件'
|
||||
})
|
||||
const objectUrl = computed(() => editedVideoUrl.value || basePreviewUrl.value)
|
||||
const objectUrl = computed(() => basePreviewUrl.value)
|
||||
const canEditImage = computed(() => isImage.value && typeof props.updateFile === 'function')
|
||||
const hasEditedVideo = computed(() => editedVideoBlob.value instanceof Blob)
|
||||
const normalizedClipStart = computed(() => clampNumber(clipStartSeconds.value, 0, videoDurationSeconds.value || 0))
|
||||
const normalizedClipEnd = computed(() => resolveClipEnd(clipEndSeconds.value, normalizedClipStart.value, videoDurationSeconds.value || 0))
|
||||
const selectedClipDuration = computed(() => Math.max(0, normalizedClipEnd.value - normalizedClipStart.value))
|
||||
const hasPendingVideoEdit = computed(() => {
|
||||
if (!isVideo.value || !videoDurationSeconds.value) return false
|
||||
const duration = videoDurationSeconds.value
|
||||
return normalizedClipStart.value > 0.05 || Math.abs(normalizedClipEnd.value - duration) > 0.05 || muteVideoAudio.value
|
||||
})
|
||||
|
||||
function clampNumber(value, min, max) {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return min
|
||||
return Math.min(Math.max(numeric, min), max)
|
||||
}
|
||||
|
||||
function roundSeconds(value = 0) {
|
||||
return Math.round((Number(value) || 0) * 10) / 10
|
||||
}
|
||||
|
||||
function resolveClipEnd(value, start, duration) {
|
||||
if (!duration) return 0
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return duration
|
||||
return clampNumber(numeric, start, duration)
|
||||
}
|
||||
|
||||
function stripExtension(name = '') {
|
||||
return String(name).replace(/\.[^.]+$/, '')
|
||||
}
|
||||
|
||||
function buildEditedVideoName(name = 'video.mp4') {
|
||||
return `${stripExtension(name)}-edited.mp4`
|
||||
}
|
||||
|
||||
function revokeUrl(url = '') {
|
||||
if (!url) return
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function clearEditedVideoResult() {
|
||||
revokeUrl(editedVideoUrl.value)
|
||||
editedVideoUrl.value = ''
|
||||
editedVideoBlob.value = null
|
||||
}
|
||||
|
||||
function clearBasePreview() {
|
||||
revokeUrl(basePreviewUrl.value)
|
||||
basePreviewUrl.value = ''
|
||||
}
|
||||
|
||||
function resetVideoPreviewState() {
|
||||
isTranscodingVideo.value = false
|
||||
transcodeProgress.value = 0
|
||||
videoPreviewError.value = ''
|
||||
isUsingTranscodedVideo.value = false
|
||||
}
|
||||
|
||||
function resetVideoEditState() {
|
||||
isApplyingVideoEdit.value = false
|
||||
videoEditProgress.value = 0
|
||||
videoEditError.value = ''
|
||||
videoDurationSeconds.value = 0
|
||||
videoCurrentTime.value = 0
|
||||
clipStartSeconds.value = 0
|
||||
clipEndSeconds.value = 0
|
||||
muteVideoAudio.value = false
|
||||
clearEditedVideoResult()
|
||||
}
|
||||
|
||||
function normalizeVideoEditRange() {
|
||||
if (!videoDurationSeconds.value) {
|
||||
clipStartSeconds.value = 0
|
||||
clipEndSeconds.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
clipStartSeconds.value = roundSeconds(normalizedClipStart.value)
|
||||
clipEndSeconds.value = roundSeconds(normalizedClipEnd.value)
|
||||
}
|
||||
|
||||
function assignBasePreviewUrl(url = '') {
|
||||
clearBasePreview()
|
||||
basePreviewUrl.value = url
|
||||
}
|
||||
|
||||
function setVideoRangeFromDuration(duration = 0) {
|
||||
videoDurationSeconds.value = duration
|
||||
videoCurrentTime.value = 0
|
||||
clipStartSeconds.value = 0
|
||||
clipEndSeconds.value = roundSeconds(duration)
|
||||
}
|
||||
|
||||
watch(
|
||||
[fileBlob, () => props.node?.id],
|
||||
async ([blob]) => {
|
||||
const currentTaskId = ++previewTaskId
|
||||
([blob]) => {
|
||||
clearBasePreview()
|
||||
resetVideoPreviewState()
|
||||
resetVideoEditState()
|
||||
videoPreviewError.value = ''
|
||||
|
||||
if (!(blob instanceof Blob)) return
|
||||
|
||||
if (!isVideo.value) {
|
||||
assignBasePreviewUrl(URL.createObjectURL(blob))
|
||||
return
|
||||
}
|
||||
|
||||
if (canPlayVideoNatively(props.node)) {
|
||||
assignBasePreviewUrl(URL.createObjectURL(blob))
|
||||
return
|
||||
}
|
||||
|
||||
isTranscodingVideo.value = true
|
||||
try {
|
||||
const transcodedBlob = await transcodeVideoToMp4(blob, props.node?.name || 'video', {
|
||||
onProgress(progress) {
|
||||
if (currentTaskId !== previewTaskId) return
|
||||
transcodeProgress.value = Math.max(transcodeProgress.value, Math.round(progress * 100))
|
||||
}
|
||||
})
|
||||
|
||||
if (currentTaskId !== previewTaskId) return
|
||||
|
||||
assignBasePreviewUrl(URL.createObjectURL(transcodedBlob))
|
||||
isUsingTranscodedVideo.value = true
|
||||
} catch (error) {
|
||||
if (currentTaskId !== previewTaskId) return
|
||||
videoPreviewError.value = error instanceof Error && error.message
|
||||
? error.message
|
||||
: '当前视频暂时无法在浏览器内转换预览'
|
||||
} finally {
|
||||
if (currentTaskId === previewTaskId) {
|
||||
isTranscodingVideo.value = false
|
||||
}
|
||||
}
|
||||
assignBasePreviewUrl(URL.createObjectURL(blob))
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -245,9 +123,7 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
previewTaskId += 1
|
||||
clearBasePreview()
|
||||
clearEditedVideoResult()
|
||||
})
|
||||
|
||||
function formatBytes(bytes = 0) {
|
||||
@@ -330,20 +206,6 @@ async function saveImageEdits() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds = 0) {
|
||||
const total = Math.max(0, Number(seconds) || 0)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
const paddedSeconds = secs >= 10 ? secs.toFixed(1).padStart(4, '0') : secs.toFixed(1).padStart(3, '0')
|
||||
|
||||
if (hours > 0) {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
|
||||
}
|
||||
|
||||
return `${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
|
||||
}
|
||||
|
||||
function downloadBlob(blob, fileName) {
|
||||
if (!(blob instanceof Blob)) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -356,79 +218,8 @@ function downloadBlob(blob, fileName) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
function handleVideoLoadedMetadata(event) {
|
||||
const duration = Number(event.target?.duration || 0)
|
||||
if (!Number.isFinite(duration)) return
|
||||
setVideoRangeFromDuration(duration)
|
||||
}
|
||||
|
||||
function handleVideoTimeUpdate(event) {
|
||||
const currentTime = Number(event.target?.currentTime || 0)
|
||||
if (!Number.isFinite(currentTime)) return
|
||||
videoCurrentTime.value = currentTime
|
||||
}
|
||||
|
||||
function setClipStartFromCurrentTime() {
|
||||
if (!videoElement.value) return
|
||||
clipStartSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
|
||||
if (clipEndSeconds.value < clipStartSeconds.value) {
|
||||
clipEndSeconds.value = roundSeconds(videoDurationSeconds.value || clipStartSeconds.value)
|
||||
}
|
||||
normalizeVideoEditRange()
|
||||
}
|
||||
|
||||
function setClipEndFromCurrentTime() {
|
||||
if (!videoElement.value) return
|
||||
clipEndSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
|
||||
if (clipEndSeconds.value < clipStartSeconds.value) {
|
||||
clipStartSeconds.value = roundSeconds(Math.max(0, clipEndSeconds.value - 0.1))
|
||||
}
|
||||
normalizeVideoEditRange()
|
||||
}
|
||||
|
||||
async function applyVideoEdits() {
|
||||
if (!isVideo.value) return
|
||||
normalizeVideoEditRange()
|
||||
|
||||
const sourceBlob = hasEditedVideo.value ? editedVideoBlob.value : fileBlob.value
|
||||
if (!(sourceBlob instanceof Blob) || !videoDurationSeconds.value || !hasPendingVideoEdit.value) return
|
||||
|
||||
isApplyingVideoEdit.value = true
|
||||
videoEditProgress.value = 0
|
||||
videoEditError.value = ''
|
||||
|
||||
try {
|
||||
const editedBlob = await editVideoBlob(sourceBlob, hasEditedVideo.value ? buildEditedVideoName(props.node?.name || 'video.mp4') : props.node?.name || 'video.mp4', {
|
||||
startTime: normalizedClipStart.value,
|
||||
endTime: normalizedClipEnd.value,
|
||||
muteAudio: muteVideoAudio.value,
|
||||
onProgress(progress) {
|
||||
videoEditProgress.value = Math.max(videoEditProgress.value, Math.round(progress * 100))
|
||||
}
|
||||
})
|
||||
|
||||
clearEditedVideoResult()
|
||||
editedVideoBlob.value = editedBlob
|
||||
editedVideoUrl.value = URL.createObjectURL(editedBlob)
|
||||
} catch (error) {
|
||||
videoEditError.value = error instanceof Error && error.message
|
||||
? error.message
|
||||
: '视频编辑失败'
|
||||
} finally {
|
||||
isApplyingVideoEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function restoreOriginalVideo() {
|
||||
clearEditedVideoResult()
|
||||
videoEditError.value = ''
|
||||
videoEditProgress.value = 0
|
||||
muteVideoAudio.value = false
|
||||
}
|
||||
|
||||
function downloadEditedVideo() {
|
||||
if (!(editedVideoBlob.value instanceof Blob)) return
|
||||
downloadBlob(editedVideoBlob.value, buildEditedVideoName(props.node?.name || 'video.mp4'))
|
||||
function handleVideoError() {
|
||||
videoPreviewError.value = '当前浏览器无法直接播放这个视频格式,请下载后使用本地播放器打开。'
|
||||
}
|
||||
|
||||
async function copyText() {
|
||||
@@ -536,9 +327,7 @@ function downloadFile() {
|
||||
<span class="file-meta">{{ fileSizeLabel }}</span>
|
||||
<span class="file-meta">{{ locLabel }}</span>
|
||||
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</span>
|
||||
<span v-else-if="isTranscodingVideo" class="truncated-pill">视频转换中 {{ transcodeProgress }}%</span>
|
||||
<span v-else-if="isApplyingVideoEdit" class="truncated-pill">编辑处理中 {{ videoEditProgress }}%</span>
|
||||
<span v-else-if="isUsingTranscodedVideo" class="truncated-pill">已转为 MP4 预览</span>
|
||||
<span v-else-if="isVideo && videoPreviewError" class="truncated-pill">视频预览不可用</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -591,16 +380,7 @@ function downloadFile() {
|
||||
|
||||
<div v-else-if="isVideo" class="content-preview">
|
||||
<div class="preview-surface video-surface">
|
||||
<div v-if="isTranscodingVideo" class="video-state-card">
|
||||
<h3>正在准备视频预览</h3>
|
||||
<p>当前格式需要先转换为浏览器兼容的 MP4,转换完成后会自动开始播放。</p>
|
||||
<div class="video-progress-track" role="progressbar" :aria-valuenow="transcodeProgress" aria-valuemin="0" aria-valuemax="100">
|
||||
<span class="video-progress-bar" :style="{ width: `${transcodeProgress}%` }"></span>
|
||||
</div>
|
||||
<strong>{{ transcodeProgress }}%</strong>
|
||||
</div>
|
||||
|
||||
<div v-else-if="videoPreviewError" class="video-state-card video-state-error">
|
||||
<div v-if="videoPreviewError" class="video-state-card video-state-error">
|
||||
<h3>视频预览暂时不可用</h3>
|
||||
<p>{{ videoPreviewError }}</p>
|
||||
<p>原始文件仍保存在浏览器本地,你可以继续下载后使用本地播放器打开。</p>
|
||||
@@ -608,66 +388,12 @@ function downloadFile() {
|
||||
|
||||
<video
|
||||
v-else-if="objectUrl"
|
||||
ref="videoElement"
|
||||
class="video-player"
|
||||
controls
|
||||
playsinline
|
||||
:src="objectUrl"
|
||||
@loadedmetadata="handleVideoLoadedMetadata"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@error="handleVideoError"
|
||||
></video>
|
||||
|
||||
<section v-if="objectUrl" class="video-edit-panel">
|
||||
<div class="video-edit-header">
|
||||
<div>
|
||||
<h3>基础编辑</h3>
|
||||
<p>裁剪片段、静音导出,并把结果另存为新的 MP4 文件。</p>
|
||||
</div>
|
||||
|
||||
<div class="video-edit-meta">
|
||||
<span>总时长 {{ formatDuration(videoDurationSeconds) }}</span>
|
||||
<span>当前播放 {{ formatDuration(videoCurrentTime) }}</span>
|
||||
<span>片段时长 {{ formatDuration(selectedClipDuration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-edit-grid">
|
||||
<label class="video-field">
|
||||
<span>开始时间(秒)</span>
|
||||
<input v-model.number="clipStartSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
|
||||
</label>
|
||||
|
||||
<label class="video-field">
|
||||
<span>结束时间(秒)</span>
|
||||
<input v-model.number="clipEndSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="video-quick-actions">
|
||||
<button class="ghost-btn" type="button" @click="setClipStartFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为开始</button>
|
||||
<button class="ghost-btn" type="button" @click="setClipEndFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为结束</button>
|
||||
</div>
|
||||
|
||||
<label class="video-checkbox">
|
||||
<input v-model="muteVideoAudio" type="checkbox" />
|
||||
<span>静音导出(移除音轨)</span>
|
||||
</label>
|
||||
|
||||
<div v-if="videoEditError" class="video-inline-error">{{ videoEditError }}</div>
|
||||
|
||||
<div v-if="isApplyingVideoEdit" class="video-edit-progress-shell">
|
||||
<div class="video-progress-track" role="progressbar" :aria-valuenow="videoEditProgress" aria-valuemin="0" aria-valuemax="100">
|
||||
<span class="video-progress-bar" :style="{ width: `${videoEditProgress}%` }"></span>
|
||||
</div>
|
||||
<strong>{{ videoEditProgress }}%</strong>
|
||||
</div>
|
||||
|
||||
<div class="video-edit-actions">
|
||||
<button class="action-btn primary-btn" type="button" @click="applyVideoEdits" :disabled="isApplyingVideoEdit || !hasPendingVideoEdit">生成编辑预览</button>
|
||||
<button class="action-btn" type="button" @click="downloadEditedVideo" :disabled="!hasEditedVideo">下载编辑结果</button>
|
||||
<button class="action-btn" type="button" @click="restoreOriginalVideo" :disabled="!hasEditedVideo">恢复原片</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -677,6 +403,7 @@ function downloadFile() {
|
||||
|
||||
<div v-else-if="isOffice && fileBlob && officeFormat" class="content-preview">
|
||||
<UniverPreview
|
||||
:key="`${node?.id || node?.name || 'office'}:${officeFormat}:${fileBlob?.size || 0}`"
|
||||
:fileBlob="fileBlob"
|
||||
:fileName="node.name"
|
||||
:format="officeFormat"
|
||||
@@ -1081,144 +808,6 @@ function downloadFile() {
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.video-edit-panel {
|
||||
width: min(1100px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--github-bg) 94%, white 6%);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.video-edit-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.video-edit-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.video-edit-header p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--github-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.video-edit-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.video-edit-meta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 999px;
|
||||
background: var(--github-hover);
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.video-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-field input {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 10px;
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text);
|
||||
}
|
||||
|
||||
.video-quick-actions,
|
||||
.video-edit-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.video-quick-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-btn:hover:enabled {
|
||||
background: var(--github-hover);
|
||||
color: var(--github-text);
|
||||
}
|
||||
|
||||
.ghost-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.video-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
color: var(--github-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-inline-error {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, #cf222e 35%, var(--github-border) 65%);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, #cf222e 8%, var(--github-bg) 92%);
|
||||
color: #cf222e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-edit-progress-shell {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.video-edit-progress-shell strong {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.video-edit-actions {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
border-color: #0969da;
|
||||
background: #0969da;
|
||||
@@ -1259,23 +848,6 @@ function downloadFile() {
|
||||
border-color: color-mix(in srgb, #cf222e 40%, var(--github-border) 60%);
|
||||
}
|
||||
|
||||
.video-progress-track {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
margin-top: 18px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--github-hover);
|
||||
}
|
||||
|
||||
.video-progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #0969da, #2da44e);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.pdf-frame {
|
||||
width: 100%;
|
||||
min-height: 78vh;
|
||||
|
||||
@@ -141,6 +141,21 @@
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="top-actions-fixed">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
aria-label="清除"
|
||||
title="清除"
|
||||
@click="clearEditor"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
<span class="btn-tooltip">清除文档</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadProgress" class="upload-progress-overlay">
|
||||
@@ -277,6 +292,12 @@ const createExportName = () => {
|
||||
return `save${datePart}${timePart}`
|
||||
}
|
||||
|
||||
const clearEditor = () => {
|
||||
if (crepe && crepe.editor) {
|
||||
crepe.editor.action(replaceAll(''))
|
||||
}
|
||||
}
|
||||
|
||||
const buildDocxBlob = async (markdown) => {
|
||||
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
|
||||
const children = []
|
||||
@@ -1319,6 +1340,15 @@ for (const url of Array.from(objectUrls)) {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.top-actions-fixed {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
|
||||
@@ -96,6 +96,21 @@ const retry = () => {
|
||||
initializeUniver();
|
||||
};
|
||||
|
||||
const clearMountNode = () => {
|
||||
if (!univerContainer.value) return;
|
||||
univerContainer.value.replaceChildren();
|
||||
univerContainer.value.removeAttribute('style');
|
||||
};
|
||||
|
||||
const clearDetachedPopups = () => {
|
||||
Array.from(document.body.children).forEach((child) => {
|
||||
if (!(child instanceof HTMLElement)) return;
|
||||
if (child.querySelector('.univer-popup')) {
|
||||
child.remove();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const computePresetFromFormat = (fmt) => {
|
||||
const lower = (fmt || '').toLowerCase();
|
||||
if (lower === 'docx') return OfficePresetType.DOCS;
|
||||
@@ -105,6 +120,7 @@ const computePresetFromFormat = (fmt) => {
|
||||
};
|
||||
|
||||
const initializeUniver = async () => {
|
||||
destroyUniver(true);
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
@@ -162,10 +178,21 @@ const loadDocumentIntoUniver = async (instance, blob, fileName, fmt) => {
|
||||
}
|
||||
};
|
||||
|
||||
const destroyUniver = () => {
|
||||
if (univerInstance.value?.univer) {
|
||||
univerInstance.value.univer.dispose();
|
||||
univerInstance.value = null;
|
||||
const destroyUniver = (clearMount = false, skipDispose = false) => {
|
||||
const currentUniver = univerInstance.value?.univer;
|
||||
univerInstance.value = null;
|
||||
|
||||
if (currentUniver && !skipDispose) {
|
||||
try {
|
||||
currentUniver.dispose();
|
||||
} catch (e) {
|
||||
console.warn('[Univer] 销毁实例时出现异常,已回退为强制清空挂载节点', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (clearMount) {
|
||||
clearMountNode();
|
||||
clearDetachedPopups();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -184,7 +211,10 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
themeObserver?.disconnect();
|
||||
destroyUniver();
|
||||
destroyUniver(false, props.format === 'xlsx');
|
||||
if (props.format === 'xlsx') {
|
||||
clearDetachedPopups();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
|
||||
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
|
||||
const FALLBACK_LEAF_TEXT = '\n'
|
||||
const CONTEXT_SEPARATOR = '\n\n'
|
||||
const MAX_SUFFIX_RATIO = 0.35
|
||||
|
||||
function serializeRangeToMarkdown(
|
||||
doc: ProseNode,
|
||||
@@ -33,6 +35,75 @@ function serializeRangeToMarkdown(
|
||||
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
||||
}
|
||||
|
||||
function getJoinedLength(parts: string[]) {
|
||||
let total = 0
|
||||
let hasContent = false
|
||||
for (const part of parts) {
|
||||
if (!part) continue
|
||||
if (hasContent) total += CONTEXT_SEPARATOR.length
|
||||
total += part.length
|
||||
hasContent = true
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function takeTail(text: string, limit: number) {
|
||||
if (!text || limit <= 0) return ''
|
||||
if (text.length <= limit) return text
|
||||
return text.slice(-limit)
|
||||
}
|
||||
|
||||
function takeHead(text: string, limit: number) {
|
||||
if (!text || limit <= 0) return ''
|
||||
if (text.length <= limit) return text
|
||||
return text.slice(0, limit)
|
||||
}
|
||||
|
||||
function fitPrefixSections(parts: string[], limit: number) {
|
||||
if (limit <= 0) return ''
|
||||
|
||||
const fitted: string[] = []
|
||||
let remaining = limit
|
||||
|
||||
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
||||
const part = parts[index]
|
||||
if (!part || remaining <= 0) continue
|
||||
|
||||
const separatorCost = fitted.length > 0 ? CONTEXT_SEPARATOR.length : 0
|
||||
if (remaining <= separatorCost) break
|
||||
|
||||
const nextPart = takeTail(part, remaining - separatorCost)
|
||||
if (!nextPart) continue
|
||||
|
||||
fitted.unshift(nextPart)
|
||||
remaining -= nextPart.length + separatorCost
|
||||
}
|
||||
|
||||
return fitted.join(CONTEXT_SEPARATOR)
|
||||
}
|
||||
|
||||
function fitSuffixSections(parts: string[], limit: number) {
|
||||
if (limit <= 0) return ''
|
||||
|
||||
const fitted: string[] = []
|
||||
let remaining = limit
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || remaining <= 0) continue
|
||||
|
||||
const separatorCost = fitted.length > 0 ? CONTEXT_SEPARATOR.length : 0
|
||||
if (remaining <= separatorCost) break
|
||||
|
||||
const nextPart = takeHead(part, remaining - separatorCost)
|
||||
if (!nextPart) continue
|
||||
|
||||
fitted.push(nextPart)
|
||||
remaining -= nextPart.length + separatorCost
|
||||
}
|
||||
|
||||
return fitted.join(CONTEXT_SEPARATOR)
|
||||
}
|
||||
|
||||
function buildDocContext(doc: ProseNode, excludePos?: number) {
|
||||
const blocks: string[] = []
|
||||
doc.descendants((node, pos) => {
|
||||
@@ -112,16 +183,37 @@ class DocBlockNodeView implements NodeView {
|
||||
const before = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer))
|
||||
const after = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, pos + this.node.nodeSize, doc.content.size, schema, this.serializer))
|
||||
const docContext = buildDocContext(doc, pos)
|
||||
const mergedPrefix = [docContext, before, payload.prefix].filter(Boolean).join('\n\n')
|
||||
const mergedSuffix = [payload.suffix, after].filter(Boolean).join('\n\n')
|
||||
const prefixParts = [docContext, before, payload.prefix].filter(Boolean)
|
||||
const suffixParts = [payload.suffix, after].filter(Boolean)
|
||||
const mergedPrefix = prefixParts.join(CONTEXT_SEPARATOR)
|
||||
const mergedSuffix = suffixParts.join(CONTEXT_SEPARATOR)
|
||||
|
||||
if (mergedPrefix.length + mergedSuffix.length > DOC_CONTEXT_LIMIT) {
|
||||
const prefixCapacity = getJoinedLength(prefixParts)
|
||||
const suffixCapacity = getJoinedLength(suffixParts)
|
||||
const maxSuffixBudget = Math.min(suffixCapacity, Math.floor(DOC_CONTEXT_LIMIT * MAX_SUFFIX_RATIO))
|
||||
|
||||
let suffixBudget = maxSuffixBudget
|
||||
let prefixBudget = DOC_CONTEXT_LIMIT - suffixBudget
|
||||
|
||||
if (prefixCapacity < prefixBudget) {
|
||||
const transferable = prefixBudget - prefixCapacity
|
||||
suffixBudget = Math.min(suffixCapacity, suffixBudget + transferable)
|
||||
prefixBudget = DOC_CONTEXT_LIMIT - suffixBudget
|
||||
} else if (suffixCapacity < suffixBudget) {
|
||||
const transferable = suffixBudget - suffixCapacity
|
||||
prefixBudget = Math.min(prefixCapacity, prefixBudget + transferable)
|
||||
suffixBudget = DOC_CONTEXT_LIMIT - prefixBudget
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: mergedPrefix.slice(0, DOC_CONTEXT_LIMIT),
|
||||
suffix: '',
|
||||
prefix: fitPrefixSections(prefixParts, prefixBudget),
|
||||
suffix: fitSuffixSections(suffixParts, suffixBudget),
|
||||
languageId: payload.languageId,
|
||||
blocked: true,
|
||||
blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: mergedPrefix,
|
||||
suffix: mergedSuffix,
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import coreURL from '@ffmpeg/core?url'
|
||||
import wasmURL from '@ffmpeg/core/wasm?url'
|
||||
import classWorkerURL from '@ffmpeg/ffmpeg/worker?url'
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
'mp4',
|
||||
'webm',
|
||||
'ogv',
|
||||
'ogg',
|
||||
'mov',
|
||||
'm4v',
|
||||
'avi',
|
||||
'mkv',
|
||||
'flv',
|
||||
'wmv',
|
||||
'3gp',
|
||||
'm2ts',
|
||||
'mts',
|
||||
'ts'
|
||||
])
|
||||
|
||||
const FORCED_TRANSCODE_EXTENSIONS = new Set([
|
||||
'3gp',
|
||||
'avi',
|
||||
'flv',
|
||||
'm2ts',
|
||||
'mkv',
|
||||
'mts',
|
||||
'ts',
|
||||
'wmv'
|
||||
])
|
||||
|
||||
const VIDEO_MIME_BY_EXTENSION = {
|
||||
m4v: 'video/x-m4v',
|
||||
mov: 'video/quicktime',
|
||||
mp4: 'video/mp4',
|
||||
ogg: 'video/ogg',
|
||||
ogv: 'video/ogg',
|
||||
webm: 'video/webm'
|
||||
}
|
||||
|
||||
let ffmpegInstancePromise = null
|
||||
let progressHandler = null
|
||||
let transcodeQueue = Promise.resolve()
|
||||
|
||||
function getFileExtension(name = '') {
|
||||
const parts = String(name).split('.')
|
||||
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
||||
}
|
||||
|
||||
function getNodeMimeType(node) {
|
||||
return String(node?.mimeType || '').toLowerCase()
|
||||
}
|
||||
|
||||
function formatFfmpegTime(seconds = 0) {
|
||||
const totalSeconds = Math.max(0, Number(seconds) || 0)
|
||||
return totalSeconds.toFixed(3)
|
||||
}
|
||||
|
||||
function createVideoProbe() {
|
||||
if (typeof document === 'undefined') return null
|
||||
return document.createElement('video')
|
||||
}
|
||||
|
||||
function toAbsoluteURL(url) {
|
||||
if (!url || typeof location === 'undefined') {
|
||||
return url
|
||||
}
|
||||
|
||||
return new URL(url, location.href).href
|
||||
}
|
||||
|
||||
function getLoadConfigs() {
|
||||
return [{
|
||||
classWorkerURL: toAbsoluteURL(classWorkerURL),
|
||||
coreURL: toAbsoluteURL(coreURL),
|
||||
wasmURL: toAbsoluteURL(wasmURL)
|
||||
}]
|
||||
}
|
||||
|
||||
async function loadFFmpegRuntime(FFmpeg, fetchFile) {
|
||||
let lastError = null
|
||||
|
||||
for (const loadConfig of getLoadConfigs()) {
|
||||
const ffmpeg = new FFmpeg()
|
||||
ffmpeg.on('progress', ({ progress }) => {
|
||||
if (typeof progressHandler === 'function') {
|
||||
progressHandler(Math.max(0, Math.min(1, progress || 0)))
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await ffmpeg.load(loadConfig)
|
||||
return { ffmpeg, fetchFile }
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('FFmpeg 初始化失败')
|
||||
}
|
||||
|
||||
async function getFFmpegInstance() {
|
||||
if (!ffmpegInstancePromise) {
|
||||
ffmpegInstancePromise = Promise.all([
|
||||
import('@ffmpeg/ffmpeg'),
|
||||
import('@ffmpeg/util')
|
||||
]).then(([{ FFmpeg }, { fetchFile }]) => loadFFmpegRuntime(FFmpeg, fetchFile))
|
||||
}
|
||||
|
||||
return ffmpegInstancePromise
|
||||
}
|
||||
|
||||
function buildMp4Command(inputName, outputName, options = {}) {
|
||||
const startTime = Math.max(0, Number(options.startTime) || 0)
|
||||
const endTime = Number.isFinite(options.endTime) ? Number(options.endTime) : null
|
||||
const duration = endTime === null ? null : Math.max(0.05, endTime - startTime)
|
||||
const args = []
|
||||
|
||||
if (startTime > 0) {
|
||||
args.push('-ss', formatFfmpegTime(startTime))
|
||||
}
|
||||
|
||||
args.push('-i', inputName)
|
||||
|
||||
if (duration !== null) {
|
||||
args.push('-t', formatFfmpegTime(duration))
|
||||
}
|
||||
|
||||
if (options.preferEncoding !== false) {
|
||||
args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p')
|
||||
if (options.muteAudio) {
|
||||
args.push('-an')
|
||||
} else {
|
||||
args.push('-c:a', 'aac')
|
||||
}
|
||||
} else if (options.muteAudio) {
|
||||
args.push('-an')
|
||||
}
|
||||
|
||||
args.push('-movflags', '+faststart', '-y', outputName)
|
||||
return args
|
||||
}
|
||||
|
||||
async function executePreferredMp4Command(ffmpeg, inputName, outputName, options = {}) {
|
||||
try {
|
||||
await ffmpeg.exec(buildMp4Command(inputName, outputName, {
|
||||
...options,
|
||||
preferEncoding: true
|
||||
}))
|
||||
} catch {
|
||||
await ffmpeg.exec(buildMp4Command(inputName, outputName, {
|
||||
...options,
|
||||
preferEncoding: false
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function runMp4Job(blob, fileName, options = {}) {
|
||||
const { ffmpeg, fetchFile } = await getFFmpegInstance()
|
||||
const ext = getFileExtension(fileName) || 'bin'
|
||||
const inputName = `input-${Date.now()}.${ext}`
|
||||
const outputName = `output-${Date.now()}.mp4`
|
||||
progressHandler = typeof options.onProgress === 'function' ? options.onProgress : null
|
||||
|
||||
try {
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(blob))
|
||||
await executePreferredMp4Command(ffmpeg, inputName, outputName, options)
|
||||
const data = await ffmpeg.readFile(outputName)
|
||||
return new Blob([data.buffer], { type: 'video/mp4' })
|
||||
} finally {
|
||||
progressHandler = null
|
||||
await ffmpeg.deleteFile?.(inputName)
|
||||
await ffmpeg.deleteFile?.(outputName)
|
||||
}
|
||||
}
|
||||
|
||||
export function isVideoFile(node) {
|
||||
const ext = getFileExtension(node?.name)
|
||||
const mime = getNodeMimeType(node)
|
||||
return mime.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
export function canPlayVideoNatively(node) {
|
||||
if (!isVideoFile(node)) return false
|
||||
|
||||
const ext = getFileExtension(node?.name)
|
||||
if (FORCED_TRANSCODE_EXTENSIONS.has(ext)) return false
|
||||
|
||||
const mime = getNodeMimeType(node) || VIDEO_MIME_BY_EXTENSION[ext] || ''
|
||||
if (!mime) return ['mp4', 'ogv', 'ogg', 'webm'].includes(ext)
|
||||
|
||||
const video = createVideoProbe()
|
||||
if (!video) return ['mp4', 'ogv', 'ogg', 'webm'].includes(ext)
|
||||
return video.canPlayType(mime) !== ''
|
||||
}
|
||||
|
||||
export async function transcodeVideoToMp4(blob, fileName, options = {}) {
|
||||
const run = async () => {
|
||||
try {
|
||||
return await runMp4Job(blob, fileName, options)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: '浏览器内视频转换失败'
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const result = transcodeQueue.then(run, run)
|
||||
transcodeQueue = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function editVideoBlob(blob, fileName, options = {}) {
|
||||
const run = async () => {
|
||||
try {
|
||||
return await runMp4Job(blob, fileName, options)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: '浏览器内视频编辑失败'
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const result = transcodeQueue.then(run, run)
|
||||
transcodeQueue = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
const crossOriginIsolationHeaders = {
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin'
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
headers: crossOriginIsolationHeaders,
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: 'https://api.imageteach.tech:8002',
|
||||
@@ -19,9 +13,6 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
headers: crossOriginIsolationHeaders
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user