11
This commit is contained in:
@@ -16,6 +16,10 @@ const {
|
||||
generatedAudioSrc,
|
||||
currentAudioId,
|
||||
audioHistory,
|
||||
videoGenerating,
|
||||
generatedVideoSrc,
|
||||
currentVideoId,
|
||||
videoHistory,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
@@ -31,6 +35,19 @@ const audioHistoryOptions = computed(() =>
|
||||
|
||||
const hasAudio = computed(() => Boolean(generatedAudioSrc.value));
|
||||
|
||||
const videoHistoryOptions = computed(() =>
|
||||
videoHistory.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const hasVideo = computed(() => Boolean(generatedVideoSrc.value));
|
||||
|
||||
const canGenerateVideo = computed(
|
||||
() => hasAudio.value && Boolean(workflow.avatarSelect) && !videoGenerating.value,
|
||||
);
|
||||
|
||||
function onOpenVoiceManage() {
|
||||
workflow.openVoiceManageDialog();
|
||||
}
|
||||
@@ -51,9 +68,26 @@ async function onPickAudioHistory() {
|
||||
await workflow.pickAudioHistory(currentAudioId.value);
|
||||
}
|
||||
|
||||
async function onGenerateTalkingVideo() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await workflow.generateTalkingVideo();
|
||||
if (result?.message) {
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function onPickVideoHistory() {
|
||||
if (!currentVideoId.value) return;
|
||||
await workflow.pickVideoHistory(currentVideoId.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
avatarStore.refresh();
|
||||
workflow.refreshAudioHistory();
|
||||
workflow.refreshVideoHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -154,11 +188,38 @@ onMounted(() => {
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button label="生成口播视频" size="small" class="mt-3 w-full" :disabled="!hasAudio" />
|
||||
<Button
|
||||
label="生成口播视频"
|
||||
size="small"
|
||||
class="mt-3 w-full"
|
||||
:loading="videoGenerating"
|
||||
:disabled="!canGenerateVideo"
|
||||
@click="onGenerateTalkingVideo"
|
||||
/>
|
||||
<div class="mt-2">
|
||||
<div class="mb-1 text-xs text-slate-200">视频预览</div>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-slate-400">视频预览</span>
|
||||
<Select
|
||||
v-if="videoHistoryOptions.length"
|
||||
v-model="currentVideoId"
|
||||
:options="videoHistoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="历史记录"
|
||||
size="small"
|
||||
class="w-48"
|
||||
@update:model-value="onPickVideoHistory"
|
||||
/>
|
||||
<Select v-else placeholder="暂无历史记录" disabled size="small" class="w-40" />
|
||||
</div>
|
||||
<div class="dashboard-aspect-video">
|
||||
<span class="text-xs text-gray-400">暂无视频预览</span>
|
||||
<video
|
||||
v-if="hasVideo"
|
||||
:src="generatedVideoSrc"
|
||||
controls
|
||||
class="h-full w-full rounded object-contain"
|
||||
/>
|
||||
<span v-else class="text-xs text-gray-400">暂无视频预览</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
164
src/services/talkingVideoGenerate.js
Normal file
164
src/services/talkingVideoGenerate.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { loadAppConfigMap } from "../config/videoPipeline.js";
|
||||
import { useAvatarStore } from "../stores/avatar.js";
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
|
||||
const RUNNINGHUB_SCRIPT = "runninghub_generate.js";
|
||||
const INFINITETALK_API_SCRIPT = "infinitetalk_api_generate.js";
|
||||
const DIGITAL_HUMAN_SCRIPT = "digital_human_generate.js";
|
||||
|
||||
/**
|
||||
* 生成口播视频(对齐 Electron generateVideo + compshare:digitalHumanProcess)
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeGenerateTalkingVideo(store) {
|
||||
const audioPath = String(store.generatedAudioPath || "").trim();
|
||||
if (!audioPath) {
|
||||
return { ok: false, message: "请先生成语音或从历史记录选择音频" };
|
||||
}
|
||||
|
||||
const avatarId = store.avatarSelect;
|
||||
if (!avatarId) {
|
||||
return { ok: false, message: "请先选择形象" };
|
||||
}
|
||||
|
||||
const avatarStore = useAvatarStore();
|
||||
if (!avatarStore.templates.length) {
|
||||
await avatarStore.refresh();
|
||||
}
|
||||
const avatar = avatarStore.templates.find((t) => t.id === avatarId);
|
||||
if (!avatar?.filePath) {
|
||||
return { ok: false, message: "形象视频不存在,请在形象管理中重新导入" };
|
||||
}
|
||||
|
||||
const cfg = await loadAppConfigMap();
|
||||
const configMap = cfg.ok ? cfg.map : {};
|
||||
const scriptName = pickTalkingVideoScript(configMap);
|
||||
const modeCheck = validateVideoGenConfig(configMap, scriptName);
|
||||
if (!modeCheck.ok) {
|
||||
return { ok: false, message: modeCheck.message };
|
||||
}
|
||||
|
||||
store.videoGenerating = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName,
|
||||
params: {
|
||||
audioPath,
|
||||
avatarVideoPath: avatar.filePath,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.videoPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "口播视频生成失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
const displayName = `${avatar.name} · ${formatHistoryTime()}`;
|
||||
const record = await importGeneratedVideo(
|
||||
result.videoPath,
|
||||
displayName,
|
||||
avatarId,
|
||||
store.currentAudioId ?? null,
|
||||
);
|
||||
const videoSrc = localVideoToPlayableUrl(record.filePath);
|
||||
|
||||
store.generatedVideoPath = record.filePath;
|
||||
store.generatedVideoSrc = videoSrc;
|
||||
store.currentVideoId = record.id;
|
||||
await store.refreshVideoHistory();
|
||||
|
||||
return { ok: true, message: "口播视频生成完成" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `口播视频生成失败: ${msg}` };
|
||||
} finally {
|
||||
store.videoGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHistoryTime() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** @param {Record<string, string>} map */
|
||||
function pickTalkingVideoScript(map) {
|
||||
const mode = String(map.VIDEO_GEN_MODE || "").trim().toLowerCase();
|
||||
if (mode === "online") {
|
||||
return RUNNINGHUB_SCRIPT;
|
||||
}
|
||||
if (mode === "local_InfiniteTalk") {
|
||||
return INFINITETALK_API_SCRIPT;
|
||||
|
||||
}
|
||||
if (mode === "local" || mode === "api") {
|
||||
return DIGITAL_HUMAN_SCRIPT;
|
||||
}
|
||||
|
||||
const url = String(
|
||||
map.INFINITETALK_API_URL || map.DIGITAL_HUMAN_API_URL || "",
|
||||
).trim();
|
||||
if (url && /^https?:\/\//i.test(url)) {
|
||||
return INFINITETALK_API_SCRIPT;
|
||||
}
|
||||
if (map.INFINITETALK_USE_API === "1" || map.INFINITETALK_USE_API === "true") {
|
||||
return INFINITETALK_API_SCRIPT;
|
||||
}
|
||||
return DIGITAL_HUMAN_SCRIPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} map
|
||||
* @param {string} scriptName
|
||||
*/
|
||||
function validateVideoGenConfig(map, scriptName) {
|
||||
if (scriptName === RUNNINGHUB_SCRIPT) {
|
||||
if (!map.RUNNINGHUB_API_KEY) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "云端口播需配置 RUNNINGHUB_API_KEY",
|
||||
};
|
||||
}
|
||||
if (!map.RUNNINGHUB_WORKFLOW_ID) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "云端口播需配置 RUNNINGHUB_WORKFLOW_ID",
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
if (scriptName === DIGITAL_HUMAN_SCRIPT) {
|
||||
const apiUrl =
|
||||
map.DIGITAL_HUMAN_API_URL ||
|
||||
map.VIDEO_GEN_API_URL ||
|
||||
map.DIGITAL_HUMAN_GRADIO_URL;
|
||||
if (!apiUrl) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "请配置 DIGITAL_HUMAN_API_URL(Gradio 数字人地址)",
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
* @param {number} videoId
|
||||
*/
|
||||
export async function selectVideoHistory(store, videoId) {
|
||||
const item = store.videoHistory.find((h) => h.id === videoId);
|
||||
if (!item) return;
|
||||
store.currentVideoId = item.id;
|
||||
store.generatedVideoPath = item.filePath;
|
||||
store.generatedVideoSrc =
|
||||
item.videoSrc || localVideoToPlayableUrl(item.filePath);
|
||||
}
|
||||
@@ -21,8 +21,14 @@ import {
|
||||
executeGenerateSpeech,
|
||||
selectAudioHistory,
|
||||
} from "../services/voiceGenerate.js";
|
||||
import {
|
||||
executeGenerateTalkingVideo,
|
||||
selectVideoHistory,
|
||||
} from "../services/talkingVideoGenerate.js";
|
||||
import { listGeneratedAudios } from "../services/audioDb.js";
|
||||
import { listGeneratedVideos } from "../services/videoDb.js";
|
||||
import { localAudioToPlayableUrl } from "../services/localAudio.js";
|
||||
import { localVideoToPlayableUrl } from "../services/localVideo.js";
|
||||
|
||||
|
||||
|
||||
@@ -170,7 +176,16 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
/** @type {Array<{ id: number, name: string, filePath: string, createdAt: string, audioSrc?: string }>} */
|
||||
audioHistory: [],
|
||||
|
||||
videoGenerating: false,
|
||||
|
||||
generatedVideoPath: "",
|
||||
|
||||
generatedVideoSrc: "",
|
||||
|
||||
currentVideoId: null,
|
||||
|
||||
/** @type {Array<{ id: number, name: string, filePath: string, avatarId: number | null, audioId: number | null, createdAt: string, videoSrc?: string }>} */
|
||||
videoHistory: [],
|
||||
|
||||
// 03 视频编辑
|
||||
|
||||
@@ -476,6 +491,26 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
await selectAudioHistory(this, audioId);
|
||||
},
|
||||
|
||||
async generateTalkingVideo() {
|
||||
return executeGenerateTalkingVideo(this);
|
||||
},
|
||||
|
||||
async refreshVideoHistory() {
|
||||
try {
|
||||
const rows = await listGeneratedVideos();
|
||||
this.videoHistory = rows.map((row) => ({
|
||||
...row,
|
||||
videoSrc: localVideoToPlayableUrl(row.filePath),
|
||||
}));
|
||||
} catch {
|
||||
this.videoHistory = [];
|
||||
}
|
||||
},
|
||||
|
||||
async pickVideoHistory(videoId) {
|
||||
await selectVideoHistory(this, videoId);
|
||||
},
|
||||
|
||||
resetAll() {
|
||||
|
||||
this.$reset();
|
||||
|
||||
Reference in New Issue
Block a user