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() { 仅预览前 2MB - 视频转换中 {{ transcodeProgress }}% - 编辑处理中 {{ videoEditProgress }}% - 已转为 MP4 预览 + 视频预览不可用 @@ -591,16 +380,7 @@ function downloadFile() {
当前格式需要先转换为浏览器兼容的 MP4,转换完成后会自动开始播放。
-{{ videoPreviewError }}
原始文件仍保存在浏览器本地,你可以继续下载后使用本地播放器打开。
@@ -608,66 +388,12 @@ function downloadFile() { - -裁剪片段、静音导出,并把结果另存为新的 MP4 文件。
-