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",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocknote/xl-docx-exporter": "^0.47.3",
|
"@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/core": "^7.18.0",
|
||||||
"@milkdown/crepe": "^7.18.0",
|
"@milkdown/crepe": "^7.18.0",
|
||||||
"@milkdown/kit": "^7.18.0",
|
"@milkdown/kit": "^7.18.0",
|
||||||
@@ -34,6 +30,7 @@
|
|||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
|
"tui-color-picker": "^2.2.8",
|
||||||
"tui-image-editor": "^3.15.3",
|
"tui-image-editor": "^3.15.3",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
@@ -1212,54 +1209,6 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@flatten-js/interval-tree": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.3.tgz",
|
||||||
|
|||||||
+2
-4
@@ -12,10 +12,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocknote/xl-docx-exporter": "^0.47.3",
|
"@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/core": "^7.18.0",
|
||||||
"@milkdown/crepe": "^7.18.0",
|
"@milkdown/crepe": "^7.18.0",
|
||||||
"@milkdown/kit": "^7.18.0",
|
"@milkdown/kit": "^7.18.0",
|
||||||
@@ -37,6 +33,8 @@
|
|||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
|
"tui-color-picker": "^2.2.8",
|
||||||
|
"tui-image-editor": "^3.15.3",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
+15
-443
@@ -2,10 +2,11 @@
|
|||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
|
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
|
||||||
import { canPlayVideoNatively, editVideoBlob, isVideoFile, transcodeVideoToMp4 } from '../utils/ffmpeg'
|
|
||||||
import ImageEditorComponent from './ImageEditorComponent.vue'
|
import ImageEditorComponent from './ImageEditorComponent.vue'
|
||||||
import UniverPreview from './UniverPreview.vue'
|
import UniverPreview from './UniverPreview.vue'
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogv', 'ogg', 'mov', 'm4v'])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
node: { type: Object, default: null },
|
node: { type: Object, default: null },
|
||||||
breadcrumb: { type: Array, default: () => [] },
|
breadcrumb: { type: Array, default: () => [] },
|
||||||
@@ -25,26 +26,11 @@ const md = new MarkdownIt({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const basePreviewUrl = ref('')
|
const basePreviewUrl = ref('')
|
||||||
const editedVideoUrl = ref('')
|
|
||||||
const editedVideoBlob = ref(null)
|
|
||||||
const imageEditorRef = ref(null)
|
const imageEditorRef = ref(null)
|
||||||
const imageEditorError = ref('')
|
const imageEditorError = ref('')
|
||||||
const isEditingImage = ref(false)
|
const isEditingImage = ref(false)
|
||||||
const isSavingImage = ref(false)
|
const isSavingImage = ref(false)
|
||||||
const isTranscodingVideo = ref(false)
|
|
||||||
const transcodeProgress = ref(0)
|
|
||||||
const videoPreviewError = ref('')
|
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 isRoot = computed(() => !props.node)
|
||||||
const isFolder = computed(() => props.node?.type === 'folder')
|
const isFolder = computed(() => props.node?.type === 'folder')
|
||||||
@@ -62,7 +48,10 @@ const isPdf = computed(() => {
|
|||||||
const mime = String(props.node?.mimeType || '')
|
const mime = String(props.node?.mimeType || '')
|
||||||
return mime === 'application/pdf' || fileExt.value === 'pdf'
|
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 isText = computed(() => {
|
||||||
const mime = String(props.node?.mimeType || '')
|
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)
|
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
|
const count = lineCount.value
|
||||||
return count ? `${count} 行` : '二进制文件'
|
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 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 = '') {
|
function revokeUrl(url = '') {
|
||||||
if (!url) return
|
if (!url) return
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearEditedVideoResult() {
|
|
||||||
revokeUrl(editedVideoUrl.value)
|
|
||||||
editedVideoUrl.value = ''
|
|
||||||
editedVideoBlob.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearBasePreview() {
|
function clearBasePreview() {
|
||||||
revokeUrl(basePreviewUrl.value)
|
revokeUrl(basePreviewUrl.value)
|
||||||
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 = '') {
|
function assignBasePreviewUrl(url = '') {
|
||||||
clearBasePreview()
|
clearBasePreview()
|
||||||
basePreviewUrl.value = url
|
basePreviewUrl.value = url
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVideoRangeFromDuration(duration = 0) {
|
|
||||||
videoDurationSeconds.value = duration
|
|
||||||
videoCurrentTime.value = 0
|
|
||||||
clipStartSeconds.value = 0
|
|
||||||
clipEndSeconds.value = roundSeconds(duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[fileBlob, () => props.node?.id],
|
[fileBlob, () => props.node?.id],
|
||||||
async ([blob]) => {
|
([blob]) => {
|
||||||
const currentTaskId = ++previewTaskId
|
|
||||||
clearBasePreview()
|
clearBasePreview()
|
||||||
resetVideoPreviewState()
|
videoPreviewError.value = ''
|
||||||
resetVideoEditState()
|
|
||||||
|
|
||||||
if (!(blob instanceof Blob)) return
|
if (!(blob instanceof Blob)) return
|
||||||
|
|
||||||
if (!isVideo.value) {
|
|
||||||
assignBasePreviewUrl(URL.createObjectURL(blob))
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -245,9 +123,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
previewTaskId += 1
|
|
||||||
clearBasePreview()
|
clearBasePreview()
|
||||||
clearEditedVideoResult()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatBytes(bytes = 0) {
|
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) {
|
function downloadBlob(blob, fileName) {
|
||||||
if (!(blob instanceof Blob)) return
|
if (!(blob instanceof Blob)) return
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -356,79 +218,8 @@ function downloadBlob(blob, fileName) {
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVideoLoadedMetadata(event) {
|
function handleVideoError() {
|
||||||
const duration = Number(event.target?.duration || 0)
|
videoPreviewError.value = '当前浏览器无法直接播放这个视频格式,请下载后使用本地播放器打开。'
|
||||||
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'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText() {
|
async function copyText() {
|
||||||
@@ -536,9 +327,7 @@ function downloadFile() {
|
|||||||
<span class="file-meta">{{ fileSizeLabel }}</span>
|
<span class="file-meta">{{ fileSizeLabel }}</span>
|
||||||
<span class="file-meta">{{ locLabel }}</span>
|
<span class="file-meta">{{ locLabel }}</span>
|
||||||
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</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="isVideo && videoPreviewError" class="truncated-pill">视频预览不可用</span>
|
||||||
<span v-else-if="isApplyingVideoEdit" class="truncated-pill">编辑处理中 {{ videoEditProgress }}%</span>
|
|
||||||
<span v-else-if="isUsingTranscodedVideo" class="truncated-pill">已转为 MP4 预览</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -591,16 +380,7 @@ function downloadFile() {
|
|||||||
|
|
||||||
<div v-else-if="isVideo" class="content-preview">
|
<div v-else-if="isVideo" class="content-preview">
|
||||||
<div class="preview-surface video-surface">
|
<div class="preview-surface video-surface">
|
||||||
<div v-if="isTranscodingVideo" class="video-state-card">
|
<div v-if="videoPreviewError" class="video-state-card video-state-error">
|
||||||
<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">
|
|
||||||
<h3>视频预览暂时不可用</h3>
|
<h3>视频预览暂时不可用</h3>
|
||||||
<p>{{ videoPreviewError }}</p>
|
<p>{{ videoPreviewError }}</p>
|
||||||
<p>原始文件仍保存在浏览器本地,你可以继续下载后使用本地播放器打开。</p>
|
<p>原始文件仍保存在浏览器本地,你可以继续下载后使用本地播放器打开。</p>
|
||||||
@@ -608,66 +388,12 @@ function downloadFile() {
|
|||||||
|
|
||||||
<video
|
<video
|
||||||
v-else-if="objectUrl"
|
v-else-if="objectUrl"
|
||||||
ref="videoElement"
|
|
||||||
class="video-player"
|
class="video-player"
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
:src="objectUrl"
|
:src="objectUrl"
|
||||||
@loadedmetadata="handleVideoLoadedMetadata"
|
@error="handleVideoError"
|
||||||
@timeupdate="handleVideoTimeUpdate"
|
|
||||||
></video>
|
></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -677,6 +403,7 @@ function downloadFile() {
|
|||||||
|
|
||||||
<div v-else-if="isOffice && fileBlob && officeFormat" class="content-preview">
|
<div v-else-if="isOffice && fileBlob && officeFormat" class="content-preview">
|
||||||
<UniverPreview
|
<UniverPreview
|
||||||
|
:key="`${node?.id || node?.name || 'office'}:${officeFormat}:${fileBlob?.size || 0}`"
|
||||||
:fileBlob="fileBlob"
|
:fileBlob="fileBlob"
|
||||||
:fileName="node.name"
|
:fileName="node.name"
|
||||||
:format="officeFormat"
|
:format="officeFormat"
|
||||||
@@ -1081,144 +808,6 @@ function downloadFile() {
|
|||||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
|
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 {
|
.primary-btn {
|
||||||
border-color: #0969da;
|
border-color: #0969da;
|
||||||
background: #0969da;
|
background: #0969da;
|
||||||
@@ -1259,23 +848,6 @@ function downloadFile() {
|
|||||||
border-color: color-mix(in srgb, #cf222e 40%, var(--github-border) 60%);
|
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 {
|
.pdf-frame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 78vh;
|
min-height: 78vh;
|
||||||
|
|||||||
@@ -141,6 +141,21 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div v-if="uploadProgress" class="upload-progress-overlay">
|
<div v-if="uploadProgress" class="upload-progress-overlay">
|
||||||
@@ -277,6 +292,12 @@ const createExportName = () => {
|
|||||||
return `save${datePart}${timePart}`
|
return `save${datePart}${timePart}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearEditor = () => {
|
||||||
|
if (crepe && crepe.editor) {
|
||||||
|
crepe.editor.action(replaceAll(''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buildDocxBlob = async (markdown) => {
|
const buildDocxBlob = async (markdown) => {
|
||||||
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
|
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
|
||||||
const children = []
|
const children = []
|
||||||
@@ -1319,6 +1340,15 @@ for (const url of Array.from(objectUrls)) {
|
|||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-actions-fixed {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 99999;
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -96,6 +96,21 @@ const retry = () => {
|
|||||||
initializeUniver();
|
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 computePresetFromFormat = (fmt) => {
|
||||||
const lower = (fmt || '').toLowerCase();
|
const lower = (fmt || '').toLowerCase();
|
||||||
if (lower === 'docx') return OfficePresetType.DOCS;
|
if (lower === 'docx') return OfficePresetType.DOCS;
|
||||||
@@ -105,6 +120,7 @@ const computePresetFromFormat = (fmt) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initializeUniver = async () => {
|
const initializeUniver = async () => {
|
||||||
|
destroyUniver(true);
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
@@ -162,10 +178,21 @@ const loadDocumentIntoUniver = async (instance, blob, fileName, fmt) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const destroyUniver = () => {
|
const destroyUniver = (clearMount = false, skipDispose = false) => {
|
||||||
if (univerInstance.value?.univer) {
|
const currentUniver = univerInstance.value?.univer;
|
||||||
univerInstance.value.univer.dispose();
|
|
||||||
univerInstance.value = null;
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
themeObserver?.disconnect();
|
themeObserver?.disconnect();
|
||||||
destroyUniver();
|
destroyUniver(false, props.format === 'xlsx');
|
||||||
|
if (props.format === 'xlsx') {
|
||||||
|
clearDetachedPopups();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
|
|
||||||
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
|
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
|
||||||
const FALLBACK_LEAF_TEXT = '\n'
|
const FALLBACK_LEAF_TEXT = '\n'
|
||||||
|
const CONTEXT_SEPARATOR = '\n\n'
|
||||||
|
const MAX_SUFFIX_RATIO = 0.35
|
||||||
|
|
||||||
function serializeRangeToMarkdown(
|
function serializeRangeToMarkdown(
|
||||||
doc: ProseNode,
|
doc: ProseNode,
|
||||||
@@ -33,6 +35,75 @@ function serializeRangeToMarkdown(
|
|||||||
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
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) {
|
function buildDocContext(doc: ProseNode, excludePos?: number) {
|
||||||
const blocks: string[] = []
|
const blocks: string[] = []
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
@@ -112,16 +183,37 @@ class DocBlockNodeView implements NodeView {
|
|||||||
const before = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer))
|
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 after = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, pos + this.node.nodeSize, doc.content.size, schema, this.serializer))
|
||||||
const docContext = buildDocContext(doc, pos)
|
const docContext = buildDocContext(doc, pos)
|
||||||
const mergedPrefix = [docContext, before, payload.prefix].filter(Boolean).join('\n\n')
|
const prefixParts = [docContext, before, payload.prefix].filter(Boolean)
|
||||||
const mergedSuffix = [payload.suffix, after].filter(Boolean).join('\n\n')
|
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) {
|
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 {
|
return {
|
||||||
prefix: mergedPrefix.slice(0, DOC_CONTEXT_LIMIT),
|
prefix: fitPrefixSections(prefixParts, prefixBudget),
|
||||||
suffix: '',
|
suffix: fitSuffixSections(suffixParts, suffixBudget),
|
||||||
languageId: payload.languageId,
|
languageId: payload.languageId,
|
||||||
blocked: true,
|
blocked: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prefix: mergedPrefix,
|
prefix: mergedPrefix,
|
||||||
suffix: mergedSuffix,
|
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 { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
const crossOriginIsolationHeaders = {
|
|
||||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
|
||||||
'Cross-Origin-Opener-Policy': 'same-origin'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
headers: crossOriginIsolationHeaders,
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/v1': {
|
'/v1': {
|
||||||
target: 'https://api.imageteach.tech:8002',
|
target: 'https://api.imageteach.tech:8002',
|
||||||
@@ -19,9 +13,6 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
preview: {
|
|
||||||
headers: crossOriginIsolationHeaders
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
Reference in New Issue
Block a user