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

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] 语音合成支持EdgeTTS
- [x] 视频剪辑,文案、视频、音频、字幕合成,自动混剪
- [ ] 批量处理,支持一个批量任务,按预设自动持续合成视频
- [x] 批量处理,支持一个批量任务,按预设自动持续合成视频
- [ ] 字幕特效,支持多种字幕样式和特效

View File

@@ -1,6 +1,12 @@
import fs from 'node:fs'
import path from 'node:path'
import { app } from 'electron'
// import packageJson from '~/package.json'
/**
* 生成有序的唯一文件名,用于处理文件已存在的情况
*/
export function generateUniqueFileName(filePath: string): string {
const dir = path.dirname(filePath)
const ext = path.extname(filePath)
@@ -14,3 +20,10 @@ export function generateUniqueFileName(filePath: string): string {
}
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 path from 'node:path'
import { app } from 'electron'
import { EdgeTTS } from '../lib/edge-tts'
import { parseBuffer } from 'music-metadata'
import {
@@ -8,14 +7,31 @@ import {
EdgeTtsSynthesizeToFileParams,
EdgeTtsSynthesizeToFileResult,
} from './types'
import { getAppTempPath } from '../lib/tools'
import { app } from 'electron'
const edgeTts = new EdgeTTS()
const setupTime = new Date().getTime()
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() {
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",
"description": "短视频工厂一键生成产品营销与泛内容短视频AI批量自动剪辑",
"version": "0.6.6",
"version": "0.7.0",
"author": {
"name": "YILS",
"developer": "YILS",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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