From 6dc99338536ad45094a2513e8c86830244954a1c Mon Sep 17 00:00:00 2001 From: ydy0615 Date: Sun, 3 May 2026 19:14:55 +0800 Subject: [PATCH] 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. --- package-lock.json | 53 +--- package.json | 6 +- src/components/FileContent.vue | 460 ++---------------------------- src/components/MilkdownEditor.vue | 30 ++ src/components/UniverPreview.vue | 40 ++- src/plugins/docBlockPlugin.ts | 102 ++++++- src/utils/ffmpeg.js | 230 --------------- vite.config.js | 9 - 8 files changed, 181 insertions(+), 749 deletions(-) delete mode 100644 src/utils/ffmpeg.js diff --git a/package-lock.json b/package-lock.json index 9f0622f..af0d6f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 606d08d..d35a6b8 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/components/FileContent.vue b/src/components/FileContent.vue index 5716a3a..816fcd6 100644 --- a/src/components/FileContent.vue +++ b/src/components/FileContent.vue @@ -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() { {{ fileSizeLabel }} {{ locLabel }} 仅预览前 2MB - 视频转换中 {{ transcodeProgress }}% - 编辑处理中 {{ videoEditProgress }}% - 已转为 MP4 预览 + 视频预览不可用 @@ -591,16 +380,7 @@ function downloadFile() {
-
-

正在准备视频预览

-

当前格式需要先转换为浏览器兼容的 MP4,转换完成后会自动开始播放。

-
- -
- {{ transcodeProgress }}% -
- -
+

视频预览暂时不可用

{{ videoPreviewError }}

原始文件仍保存在浏览器本地,你可以继续下载后使用本地播放器打开。

@@ -608,66 +388,12 @@ function downloadFile() { - -
-
-
-

基础编辑

-

裁剪片段、静音导出,并把结果另存为新的 MP4 文件。

-
- -
- 总时长 {{ formatDuration(videoDurationSeconds) }} - 当前播放 {{ formatDuration(videoCurrentTime) }} - 片段时长 {{ formatDuration(selectedClipDuration) }} -
-
- -
- - - -
- -
- - -
- - - -
{{ videoEditError }}
- -
-
- -
- {{ videoEditProgress }}% -
- -
- - - -
-
@@ -677,6 +403,7 @@ function downloadFile() {
+ +
+ +
@@ -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; diff --git a/src/components/UniverPreview.vue b/src/components/UniverPreview.vue index a724dc3..9d6b0d0 100644 --- a/src/components/UniverPreview.vue +++ b/src/components/UniverPreview.vue @@ -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(); + } }); diff --git a/src/plugins/docBlockPlugin.ts b/src/plugins/docBlockPlugin.ts index c4b150e..155ba82 100644 --- a/src/plugins/docBlockPlugin.ts +++ b/src/plugins/docBlockPlugin.ts @@ -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, diff --git a/src/utils/ffmpeg.js b/src/utils/ffmpeg.js deleted file mode 100644 index 61171ab..0000000 --- a/src/utils/ffmpeg.js +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 656e2ef..0bdd7e4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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: {