完善视频处理进度交互,增加批量任务能力,优化大量细节

This commit is contained in:
YILS
2025-08-07 16:41:34 +08:00
parent 353b296507
commit d9320a6d46
11 changed files with 305 additions and 152 deletions

View File

@@ -58,14 +58,14 @@
## 🗺️ 路线图 ## 🗺️ 路线图
**当前正在积极开发中,即将发布第一个版本,喜欢可以点个 Star 支持一下!** **喜欢可以点个 Star 支持一下**
下面是已实现和计划中的功能: 下面是已实现和计划中的功能:
- [x] 文案生成,兼容通用的 OpenAI 接口格式 - [x] 文案生成,兼容通用的 OpenAI 接口格式
- [x] 语音合成支持EdgeTTS - [x] 语音合成支持EdgeTTS
- [x] 视频剪辑,文案、视频、音频、字幕合成,自动混剪 - [x] 视频剪辑,文案、视频、音频、字幕合成,自动混剪
- [ ] 批量处理,支持一个批量任务,按预设自动持续合成视频 - [x] 批量处理,支持一个批量任务,按预设自动持续合成视频
- [ ] 字幕特效,支持多种字幕样式和特效 - [ ] 字幕特效,支持多种字幕样式和特效

View File

@@ -1,6 +1,12 @@
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { app } from 'electron'
// import packageJson from '~/package.json'
/**
* 生成有序的唯一文件名,用于处理文件已存在的情况
*/
export function generateUniqueFileName(filePath: string): string { export function generateUniqueFileName(filePath: string): string {
const dir = path.dirname(filePath) const dir = path.dirname(filePath)
const ext = path.extname(filePath) const ext = path.extname(filePath)
@@ -14,3 +20,10 @@ export function generateUniqueFileName(filePath: string): string {
} }
return newPath return newPath
} }
/**
* 获取软件的临时文件存储路径
*/
export function getAppTempPath() {
return path.join(app.getPath('temp'), app.name).replace(/\\/g, '/')
}

View File

@@ -1,6 +1,5 @@
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { app } from 'electron'
import { EdgeTTS } from '../lib/edge-tts' import { EdgeTTS } from '../lib/edge-tts'
import { parseBuffer } from 'music-metadata' import { parseBuffer } from 'music-metadata'
import { import {
@@ -8,14 +7,31 @@ import {
EdgeTtsSynthesizeToFileParams, EdgeTtsSynthesizeToFileParams,
EdgeTtsSynthesizeToFileResult, EdgeTtsSynthesizeToFileResult,
} from './types' } from './types'
import { getAppTempPath } from '../lib/tools'
import { app } from 'electron'
const edgeTts = new EdgeTTS() const edgeTts = new EdgeTTS()
const setupTime = new Date().getTime() const setupTime = new Date().getTime()
export function getTempTtsVoiceFilePath() { export function getTempTtsVoiceFilePath() {
return path.join(app.getPath('temp'), `temp-tts-voice-${setupTime}.mp3`).replace(/\\/g, '/') return path.join(getAppTempPath(), `temp-tts-voice-${setupTime}.mp3`).replace(/\\/g, '/')
} }
export function clearCurrentTtsFiles() {
const voicePath = getTempTtsVoiceFilePath()
if (fs.existsSync(voicePath)) {
fs.unlinkSync(voicePath)
}
const srtPath = path.join(path.dirname(voicePath), path.basename(voicePath, '.mp3') + '.srt')
if (fs.existsSync(srtPath)) {
fs.unlinkSync(srtPath)
}
}
app.on('before-quit', () => {
clearCurrentTtsFiles()
})
export function edgeTtsGetVoiceList() { export function edgeTtsGetVoiceList() {
return edgeTts.getVoices() return edgeTts.getVoices()
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 381 KiB

View File

@@ -1,7 +1,7 @@
{ {
"name": "short-video-factory", "name": "short-video-factory",
"description": "短视频工厂一键生成产品营销与泛内容短视频AI批量自动剪辑", "description": "短视频工厂一键生成产品营销与泛内容短视频AI批量自动剪辑",
"version": "0.6.6", "version": "0.7.0",
"author": { "author": {
"name": "YILS", "name": "YILS",
"developer": "YILS", "developer": "YILS",

View File

@@ -62,6 +62,7 @@ export const useAppStore = defineStore(
outputFileName: '', outputFileName: '',
outputFileExt: '.mp4', outputFileExt: '.mp4',
}) })
const autoBatch = ref(false)
const renderStatus = ref(RenderStatus.None) const renderStatus = ref(RenderStatus.None)
const updateRenderConfig = (newConfig: typeof renderConfig.value) => { const updateRenderConfig = (newConfig: typeof renderConfig.value) => {
renderConfig.value = newConfig renderConfig.value = newConfig
@@ -89,6 +90,7 @@ export const useAppStore = defineStore(
tryListeningText, tryListeningText,
renderConfig, renderConfig,
autoBatch,
renderStatus, renderStatus,
updateRenderConfig, updateRenderConfig,
updateRenderStatus, updateRenderStatus,
@@ -96,7 +98,7 @@ export const useAppStore = defineStore(
}, },
{ {
persist: { persist: {
omit: ['genderList', 'speedList'], omit: ['genderList', 'speedList', 'autoBatch', 'renderStatus'],
}, },
}, },
) )

View File

@@ -21,7 +21,14 @@
> >
生成 生成
</v-btn> </v-btn>
<v-btn v-else prepend-icon="mdi-stop" color="red" stacked @click="handleStopGenerate"> <v-btn
v-else
prepend-icon="mdi-stop"
color="red"
stacked
:disabled="disabled"
@click="handleStopGenerate"
>
停止 停止
</v-btn> </v-btn>
@@ -107,9 +114,9 @@ defineProps<{
const outputText = ref('') const outputText = ref('')
const isGenerating = ref(false) const isGenerating = ref(false)
const abortController = ref<AbortController | null>(null) const abortController = ref<AbortController | null>(null)
const handleGenerate = async () => { const handleGenerate = async (oprions?: { noToast?: boolean }) => {
if (!appStore.prompt) { if (!appStore.prompt) {
toast.warning('提示词不能为空') !oprions?.noToast && toast.warning('提示词不能为空')
throw new Error('提示词不能为空') throw new Error('提示词不能为空')
} }
@@ -134,17 +141,19 @@ const handleGenerate = async () => {
for await (const textPart of result.textStream) { for await (const textPart of result.textStream) {
outputText.value += textPart outputText.value += textPart
} }
return outputText.value
} catch (error) { } catch (error) {
console.log(`error`, error) console.log(`error`, error)
// @ts-ignore // @ts-ignore
if (error?.name !== 'AbortError' && error?.error?.name !== 'AbortError') { if (error?.name !== 'AbortError' && error?.error?.name !== 'AbortError') {
// @ts-ignore // @ts-ignore
const errorMessage = error?.message || error?.error?.message const errorMessage = error?.message || error?.error?.message
toast.error( !oprions?.noToast &&
`生成失败,请检查大模型配置是否正确\n${errorMessage ? '错误信息:“' + errorMessage + '”' : ''}`, toast.error(
) `生成失败,请检查大模型配置是否正确\n${errorMessage ? '错误信息:“' + errorMessage + '”' : ''}`,
)
throw error
} }
throw error
} finally { } finally {
abortController.value = null abortController.value = null
isGenerating.value = false isGenerating.value = false
@@ -204,8 +213,12 @@ const handleTestConfig = async () => {
const getCurrentOutputText = () => { const getCurrentOutputText = () => {
return outputText.value return outputText.value
} }
// 清空文案
const clearOutputText = () => {
outputText.value = ''
}
defineExpose({ handleGenerate, getCurrentOutputText }) defineExpose({ handleGenerate, handleStopGenerate, getCurrentOutputText, clearOutputText })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,55 +1,58 @@
<template> <template>
<div> <div>
<v-sheet class="h-fit p-2" border rounded> <v-form :disabled="disabled">
<v-combobox <v-sheet class="h-fit p-2" border rounded>
v-model="appStore.language" <v-combobox
density="comfortable" v-model="appStore.language"
label="语言" density="comfortable"
:items="appStore.languageList" label="语言"
no-data-text="无数据" :items="appStore.languageList"
@update:model-value="clearVoice" no-data-text="无数据"
></v-combobox> @update:model-value="clearVoice"
<v-select ></v-combobox>
v-model="appStore.gender" <v-select
density="comfortable" v-model="appStore.gender"
label="性别" density="comfortable"
:items="appStore.genderList" label="性别"
item-title="label" :items="appStore.genderList"
item-value="value" item-title="label"
@update:model-value="clearVoice" item-value="value"
></v-select> @update:model-value="clearVoice"
<v-select ></v-select>
v-model="appStore.voice" <v-select
density="comfortable" v-model="appStore.voice"
label="声音" density="comfortable"
:items="filteredVoicesList" label="声音"
item-title="FriendlyName" :items="filteredVoicesList"
return-object item-title="FriendlyName"
no-data-text="请先选择语言和性别" return-object
></v-select> no-data-text="请先选择语言和性别"
<v-select ></v-select>
v-model="appStore.speed" <v-select
density="comfortable" v-model="appStore.speed"
label="语速" density="comfortable"
:items="appStore.speedList" label="语速"
item-title="label" :items="appStore.speedList"
item-value="value" item-title="label"
></v-select> item-value="value"
<v-text-field ></v-select>
v-model="appStore.tryListeningText" <v-text-field
density="comfortable" v-model="appStore.tryListeningText"
label="试听文本" density="comfortable"
></v-text-field> label="试听文本"
<v-btn ></v-text-field>
class="mb-2" <v-btn
prepend-icon="mdi-volume-high" class="mb-2"
block prepend-icon="mdi-volume-high"
:loading="tryListeningLoading" block
@click="handleTryListening" :loading="tryListeningLoading"
> :disabled="disabled"
试听 @click="handleTryListening"
</v-btn> >
</v-sheet> 试听
</v-btn>
</v-sheet>
</v-form>
</div> </div>
</template> </template>
@@ -61,6 +64,10 @@ import { useToast } from 'vue-toastification'
const toast = useToast() const toast = useToast()
const appStore = useAppStore() const appStore = useAppStore()
defineProps<{
disabled?: boolean
}>()
const configValid = () => { const configValid = () => {
if (!appStore.voice) { if (!appStore.voice) {
toast.warning('请选择一个声音') toast.warning('请选择一个声音')
@@ -140,7 +147,6 @@ const synthesizedSpeechToFile = async (option: { text: string; withCaption?: boo
return result return result
} catch (error) { } catch (error) {
console.log('语音合成失败', error) console.log('语音合成失败', error)
toast.error('语音合成失败,请检查网络')
throw error throw error
} }
} }

View File

@@ -1,59 +1,66 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<v-sheet class="h-full p-2 flex flex-col" border rounded> <v-form class="w-full h-full" :disabled="disabled">
<div class="flex gap-2 mb-2"> <v-sheet class="h-full p-2 flex flex-col" border rounded>
<v-text-field <div class="flex gap-2 mb-2">
v-model="appStore.videoAssetsFolder" <v-text-field
label="分镜视频素材文件夹" v-model="appStore.videoAssetsFolder"
density="compact" label="分镜视频素材文件夹"
hide-details density="compact"
readonly hide-details
> readonly
</v-text-field>
<v-btn class="mt-[2px]" prepend-icon="mdi-folder-open" @click="handleSelectFolder">
选择
</v-btn>
</div>
<div class="flex-1 h-0 w-full border">
<div
v-if="videoAssets.length"
class="w-full max-h-full overflow-y-auto grid grid-cols-3 gap-2 p-2"
>
<div
v-for="(item, index) in videoAssets"
:key="index"
class="w-full h-full max-h-[200px]"
> >
<VideoAutoPreview </v-text-field>
:asset="item" <v-btn
@loaded=" class="mt-[2px]"
(info) => { prepend-icon="mdi-folder-open"
videoInfoList[index] = info :disabled="disabled"
} @click="handleSelectFolder"
" >
/> 选择
</div> </v-btn>
</div> </div>
<v-empty-state
v-else
headline="暂无内容"
text="从上面选择一个包含足够分镜素材的文件夹"
></v-empty-state>
</div>
<div class="my-2"> <div class="flex-1 h-0 w-full border">
<v-btn <div
block v-if="videoAssets.length"
prepend-icon="mdi-refresh" class="w-full max-h-full overflow-y-auto grid grid-cols-3 gap-2 p-2"
:disabled="!appStore.videoAssetsFolder" >
:loading="refreshAssetsLoading" <div
@click="refreshAssets" class="w-full h-full max-h-[200px]"
> v-for="(item, index) in videoAssets"
刷新素材库 :key="index"
</v-btn> >
</div> <VideoAutoPreview
</v-sheet> :asset="item"
@loaded="
(info) => {
videoInfoList[index] = info
}
"
/>
</div>
</div>
<v-empty-state
v-else
headline="暂无内容"
text="从上面选择一个包含足够分镜素材的文件夹"
></v-empty-state>
</div>
<div class="my-2">
<v-btn
block
prepend-icon="mdi-refresh"
:disabled="disabled || !appStore.videoAssetsFolder"
:loading="refreshAssetsLoading"
@click="refreshAssets"
>
刷新素材库
</v-btn>
</div>
</v-sheet>
</v-form>
</div> </div>
</template> </template>
@@ -69,6 +76,10 @@ import random from 'random'
const toast = useToast() const toast = useToast()
const appStore = useAppStore() const appStore = useAppStore()
defineProps<{
disabled?: boolean
}>()
// 选择文件夹 // 选择文件夹
const handleSelectFolder = async () => { const handleSelectFolder = async () => {
const folderPath = await window.electron.selectFolder({ const folderPath = await window.electron.selectFolder({

View File

@@ -1,30 +1,46 @@
<template> <template>
<div class="h-0 flex-1 relative"> <div class="h-0 flex-1 relative">
<div class="absolute top-2/12 w-full flex justify-center"> <div class="absolute top-1/12 w-full flex justify-center cursor-default select-none">
<v-chip> 空闲可以开始合成 </v-chip> <v-chip v-if="appStore.renderStatus === RenderStatus.None"> 空闲可以开始合成 </v-chip>
<!-- <v-chip variant="elevated"> 正在使用 AI 大模型生成文案 </v-chip> --> <v-chip v-if="appStore.renderStatus === RenderStatus.GenerateText" variant="elevated">
<!-- <v-chip variant="elevated"> 正在使用 TTS 合成语音 </v-chip> --> 正在使用 AI 大模型生成文案
<!-- <v-chip variant="elevated"> 正在处理分镜素材 </v-chip> --> </v-chip>
<!-- <v-chip variant="elevated"> 正在渲染视频 </v-chip> --> <v-chip v-if="appStore.renderStatus === RenderStatus.SynthesizedSpeech" variant="elevated">
<!-- <v-chip variant="elevated" color="success"> 渲染成功可以开始下一个 </v-chip> --> 正在使用 TTS 合成语音
<!-- <v-chip variant="elevated" color="error"> 渲染失败请重新尝试 </v-chip> --> </v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.SegmentVideo" variant="elevated">
正在处理分镜素材
</v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.Rendering" variant="elevated">
正在渲染视频
</v-chip>
<v-chip
v-if="appStore.renderStatus === RenderStatus.Completed"
variant="elevated"
color="success"
>
渲染成功可以开始下一个
</v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.Failed" variant="elevated" color="error">
渲染失败请重新尝试
</v-chip>
</div> </div>
<v-sheet <v-sheet class="h-full p-2 pt-4 flex flex-col gap-6 items-center justify-center" border rounded>
class="h-full p-2 pt-4 flex flex-col gap-6 items-center justify-between" <div class="w-full h-[120px] flex gap-10 items-center justify-center">
border <div class="flex flex-col gap-4">
rounded <v-progress-circular
> color="indigo"
<div class="w-full h-0 flex-1 flex gap-10 items-center justify-center"> v-model="renderProgress"
<v-progress-circular :indeterminate="taskInProgress && appStore.renderStatus !== RenderStatus.Rendering"
color="primary" :size="96"
v-model="renderProgress" :width="8"
:size="96" >
:width="8" </v-progress-circular>
></v-progress-circular> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<v-btn <v-btn
class="" v-if="!taskInProgress"
size="x-large" size="x-large"
color="deep-purple-accent-3" color="deep-purple-accent-3"
prepend-icon="mdi-rocket-launch" prepend-icon="mdi-rocket-launch"
@@ -32,9 +48,18 @@
> >
开始合成 开始合成
</v-btn> </v-btn>
<v-btn
v-else
size="x-large"
color="red"
prepend-icon="mdi-stop"
@click="emit('cancelRender')"
>
停止合成
</v-btn>
<v-dialog v-model="configDialogShow" max-width="600" persistent> <v-dialog v-model="configDialogShow" max-width="600" persistent>
<template v-slot:activator="{ props: activatorProps }"> <template v-slot:activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps"> 合成配置 </v-btn> <v-btn v-bind="activatorProps" :disabled="taskInProgress"> 合成配置 </v-btn>
</template> </template>
<v-card prepend-icon="mdi-text-box-edit-outline" title="配置合成选项"> <v-card prepend-icon="mdi-text-box-edit-outline" title="配置合成选项">
@@ -118,6 +143,17 @@
</v-dialog> </v-dialog>
</div> </div>
</div> </div>
<div class="w-full flex justify-center">
<v-switch
v-model="appStore.autoBatch"
label="自动批量合成"
color="indigo"
density="compact"
hide-details
:disabled="taskInProgress"
></v-switch>
</div>
</v-sheet> </v-sheet>
<div class="absolute bottom-2 w-full flex justify-center text-sm"> <div class="absolute bottom-2 w-full flex justify-center text-sm">
@@ -129,15 +165,24 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, toRaw, nextTick } from 'vue' import { ref, toRaw, nextTick, computed } from 'vue'
import { useAppStore } from '@/store' import { RenderStatus, useAppStore } from '@/store'
const appStore = useAppStore() const appStore = useAppStore()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'renderVideo'): void (e: 'renderVideo'): void
(e: 'cancelRender'): void
}>() }>()
const taskInProgress = computed(() => {
return (
appStore.renderStatus !== RenderStatus.None &&
appStore.renderStatus !== RenderStatus.Completed &&
appStore.renderStatus !== RenderStatus.Failed
)
})
const renderProgress = ref(0) const renderProgress = ref(0)
window.ipcRenderer.on('render-video-progress', (_, progress: number) => { window.ipcRenderer.on('render-video-progress', (_, progress: number) => {
renderProgress.value = progress renderProgress.value = progress

View File

@@ -12,11 +12,17 @@
/> />
</div> </div>
<div class="w-1/3 h-full"> <div class="w-1/3 h-full">
<VideoManage ref="VideoManageInstance" /> <VideoManage
ref="VideoManageInstance"
:disabled="appStore.renderStatus === RenderStatus.SegmentVideo"
/>
</div> </div>
<div class="w-1/3 h-full flex flex-col gap-3"> <div class="w-1/3 h-full flex flex-col gap-3">
<TtsControl ref="TtsControlInstance" /> <TtsControl
<VideoRender @render-video="handleRenderVideo" /> ref="TtsControlInstance"
:disabled="appStore.renderStatus === RenderStatus.SynthesizedSpeech"
/>
<VideoRender @render-video="handleRenderVideo" @cancel-render="handleCancelRender" />
</div> </div>
</div> </div>
</div> </div>
@@ -71,33 +77,43 @@ const handleRenderVideo = async () => {
try { try {
// 获取文案 // 获取文案
appStore.updateRenderStatus(RenderStatus.GenerateText) appStore.updateRenderStatus(RenderStatus.GenerateText)
const text = TextGenerateInstance.value?.getCurrentOutputText() const text =
if (!text) { TextGenerateInstance.value?.getCurrentOutputText() ||
toast.warning('请先生成文案') (await TextGenerateInstance.value?.handleGenerate())!
return
}
// TTS合成语音 // TTS合成语音
// @ts-ignore
if (appStore.renderStatus !== RenderStatus.GenerateText) {
return
}
appStore.updateRenderStatus(RenderStatus.SynthesizedSpeech) appStore.updateRenderStatus(RenderStatus.SynthesizedSpeech)
const ttsResult = await TtsControlInstance.value?.synthesizedSpeechToFile({ const ttsResult = await TtsControlInstance.value?.synthesizedSpeechToFile({
text, text,
withCaption: true, withCaption: true,
}) })
if (ttsResult?.duration === undefined) { if (ttsResult?.duration === undefined) {
toast.warning('语音合成失败,音频文件损坏') throw new Error('语音合成失败,音频文件损坏')
return
} }
if (ttsResult?.duration === 0) { if (ttsResult?.duration === 0) {
toast.warning('语音时长为0秒可能文案为空') throw new Error('语音时长为0秒检查TTS语音合成配置及网络连接是否正常')
return
} }
// 获取视频片段 // 获取视频片段
// @ts-ignore
if (appStore.renderStatus !== RenderStatus.SynthesizedSpeech) {
return
}
appStore.updateRenderStatus(RenderStatus.SegmentVideo)
const videoSegments = VideoManageInstance.value?.getVideoSegments({ const videoSegments = VideoManageInstance.value?.getVideoSegments({
duration: ttsResult.duration, duration: ttsResult.duration,
})! })!
await new Promise((resolve) => setTimeout(resolve, random.integer(1000, 3000)))
// 合成视频 // 合成视频
// @ts-ignore
if (appStore.renderStatus !== RenderStatus.SegmentVideo) {
return
}
appStore.updateRenderStatus(RenderStatus.Rendering) appStore.updateRenderStatus(RenderStatus.Rendering)
await window.electron.renderVideo({ await window.electron.renderVideo({
...videoSegments, ...videoSegments,
@@ -118,8 +134,16 @@ const handleRenderVideo = async () => {
toast.success('视频合成成功') toast.success('视频合成成功')
appStore.updateRenderStatus(RenderStatus.Completed) appStore.updateRenderStatus(RenderStatus.Completed)
if (appStore.autoBatch) {
toast.info('开始合成下一个')
TextGenerateInstance.value?.clearOutputText()
handleRenderVideo()
}
} catch (error) { } catch (error) {
console.error('视频合成失败:', error) console.error('视频合成失败:', error)
if (appStore.renderStatus === RenderStatus.None) return
// @ts-ignore // @ts-ignore
const errorMessage = error?.message || error?.error?.message const errorMessage = error?.message || error?.error?.message
toast.error( toast.error(
@@ -128,6 +152,29 @@ const handleRenderVideo = async () => {
appStore.updateRenderStatus(RenderStatus.Failed) appStore.updateRenderStatus(RenderStatus.Failed)
} }
} }
const handleCancelRender = () => {
console.log('视频合成终止')
switch (appStore.renderStatus) {
case RenderStatus.GenerateText:
TextGenerateInstance.value?.handleStopGenerate()
break
case RenderStatus.SynthesizedSpeech:
break
case RenderStatus.SegmentVideo:
break
case RenderStatus.Rendering:
window.ipcRenderer.send('cancel-render-video')
break
default:
break
}
appStore.updateRenderStatus(RenderStatus.None)
toast.info('视频合成已终止')
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>