mirror of
https://github.com/YILS-LIN/short-video-factory.git
synced 2025-11-25 03:15:03 +08:00
完善视频处理进度交互,增加批量任务能力,优化大量细节
This commit is contained in:
@@ -58,14 +58,14 @@
|
|||||||
|
|
||||||
## 🗺️ 路线图
|
## 🗺️ 路线图
|
||||||
|
|
||||||
**当前正在积极开发中,即将发布第一个版本,喜欢可以点个 Star 支持一下!**
|
**喜欢可以点个 Star 支持一下哦!**
|
||||||
|
|
||||||
下面是已实现和计划中的功能:
|
下面是已实现和计划中的功能:
|
||||||
|
|
||||||
- [x] 文案生成,兼容通用的 OpenAI 接口格式
|
- [x] 文案生成,兼容通用的 OpenAI 接口格式
|
||||||
- [x] 语音合成,支持EdgeTTS
|
- [x] 语音合成,支持EdgeTTS
|
||||||
- [x] 视频剪辑,文案、视频、音频、字幕合成,自动混剪
|
- [x] 视频剪辑,文案、视频、音频、字幕合成,自动混剪
|
||||||
- [ ] 批量处理,支持一个批量任务,按预设自动持续合成视频
|
- [x] 批量处理,支持一个批量任务,按预设自动持续合成视频
|
||||||
- [ ] 字幕特效,支持多种字幕样式和特效
|
- [ ] 字幕特效,支持多种字幕样式和特效
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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, '/')
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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",
|
||||||
|
|||||||
@@ -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'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user