This commit is contained in:
949036910@qq.com
2026-05-31 22:19:51 +08:00
parent 736f1f9014
commit 4120f37c94
8 changed files with 167 additions and 17 deletions

View File

@@ -8,11 +8,13 @@ import {
findMappingForLine,
loadMixCutSubtitleRecords,
openSubtitleFileAndParse,
persistSubtitleRecordsToMixCut,
pickLocalMixCutMaterial,
pickMaterialFolder,
prepareMixCutSettingsForSave,
removeMaterialFromSubtitleLine,
} from "../../services/mixCutSettings.js";
import { revealLocalFileInFolder } from "../../services/fileExport.js";
import {
createDefaultGlobalDisplay,
createDefaultMixCutSettings,
@@ -120,6 +122,10 @@ async function loadExistingSubtitleLines() {
if (result.srtPath) subtitleFilePath.value = result.srtPath;
settings.value.records = result.records;
if (result.srtPath) settings.value.subtitleFilePath = result.srtPath;
await persistSubtitleRecordsToMixCut(props.store, {
records: result.records,
srtPath: result.srtPath,
});
const msg =
result.message ||
`字幕加载成功,共 ${result.records.length}`;
@@ -131,6 +137,19 @@ async function loadExistingSubtitleLines() {
}
async function onOpenSubtitleFile() {
const currentPath = subtitleFilePath.value || props.store.subtitleSrtPath;
if (currentPath) {
try {
await revealLocalFileInFolder(currentPath);
} catch (err) {
showFeedback(
"error",
err instanceof Error ? err.message : "打开文件夹失败",
);
}
return;
}
const result = await openSubtitleFileAndParse();
if (!result.message && !result.ok) return;
if (!result.ok) {
@@ -141,6 +160,10 @@ async function onOpenSubtitleFile() {
subtitleFilePath.value = result.srtPath || "";
settings.value.records = result.records;
settings.value.subtitleFilePath = result.srtPath || "";
await persistSubtitleRecordsToMixCut(props.store, {
records: result.records,
srtPath: result.srtPath,
});
showFeedback("success", `已加载 ${result.records.length} 行字幕`);
}

View File

@@ -12,8 +12,9 @@ const {
greenScreen,
videoProcessing,
greenScreenBackgroundPath,
generatedVideoSrc,
generatedVideoPath,
processedVideoSrc,
processedVideoPath,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
@@ -56,12 +57,12 @@ async function onPickPipMaterial() {
}
async function onOpenVideoInExplorer() {
if (!generatedVideoPath.value) {
feedback.value = { severity: "error", message: "暂无视频文件" };
if (!processedVideoPath.value) {
feedback.value = { severity: "error", message: "暂无编辑后的视频文件" };
return;
}
try {
await revealLocalFileInFolder(generatedVideoPath.value);
await revealLocalFileInFolder(processedVideoPath.value);
} catch (err) {
feedback.value = {
severity: "error",
@@ -146,15 +147,15 @@ async function onOpenVideoInExplorer() {
size="small"
severity="secondary"
text
:disabled="!generatedVideoPath"
:disabled="!processedVideoPath"
aria-label="在资源管理器中打开"
@click="onOpenVideoInExplorer"
/>
</div>
<div class="dashboard-aspect-video">
<video
v-if="generatedVideoSrc"
:src="generatedVideoSrc"
v-if="processedVideoSrc"
:src="processedVideoSrc"
controls
class="h-full w-full rounded object-contain"
/>

View File

@@ -14,6 +14,7 @@ const {
subtitleTemplateId,
subtitleBgmGenerating,
generatedVideoPath,
processedVideoPath,
subtitlePreviewVideoPath,
subtitlePreviewVideoSrc,
bgmPreviewSrc,
@@ -22,7 +23,9 @@ const {
const feedback = ref({ severity: "", message: "" });
const templateDialogVisible = ref(false);
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
const hasSourceVideo = computed(() =>
Boolean(processedVideoPath.value || generatedVideoPath.value),
);
const hasSubtitleVideoPreview = computed(() =>
Boolean(subtitlePreviewVideoSrc.value),
);

View File

@@ -70,6 +70,108 @@ async function readTextFile(filePath) {
* @param {number} durationSec
* @returns {Array<{ text: string, start: number, end: number }>}
*/
/**
* @param {Array<object>} records
* @returns {Array<{ text: string, start: number, end: number }>}
*/
export function normalizeSubtitleRecords(records) {
if (!Array.isArray(records)) return [];
return records
.map((r) => ({
text: String(r.text || "").trim(),
start: Number(r.start ?? r.startTime ?? 0),
end: Number(r.end ?? r.endTime ?? 0),
}))
.filter((r) => r.text);
}
/**
* 将字幕 records 写入混剪配置(对齐 Electron generateVideo / loadScriptLines
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore> | null} store
* @param {{ records: object[], srtPath?: string }} payload
*/
export async function persistSubtitleRecordsToMixCut(store, { records, srtPath } = {}) {
const normalized = normalizeSubtitleRecords(records);
if (!normalized.length) return false;
const settings = await loadVideoMixCutSettings();
settings.records = normalized;
if (srtPath) settings.subtitleFilePath = srtPath;
await saveVideoMixCutSettings(settings);
if (store && srtPath) {
store.subtitleSrtPath = srtPath;
}
return true;
}
/**
* 对视频执行 ASR返回 records + srtPath不烧录字幕
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
* @param {string} [videoPath]
*/
export async function recognizeSubtitleFromVideo(store, videoPath) {
const inputVideo = String(videoPath || store?.generatedVideoPath || "").trim();
if (!inputVideo) {
return { ok: false, message: "缺少视频路径" };
}
const script = String(store?.scriptContent || "").trim();
try {
const result = await invoke("run_nodejs_script", {
scriptName: "subtitle_bgm_generate.js",
params: {
inputVideo,
autoSubtitle: true,
smartSubtitle: store?.smartSubtitle !== false,
scriptContent: script,
recognizeOnly: true,
bgmEnabled: false,
},
});
if (!result?.success || !Array.isArray(result.records) || !result.records.length) {
return {
ok: false,
message: String(result?.error || "字幕识别失败,请检查 ASR 模型配置"),
};
}
return {
ok: true,
records: normalizeSubtitleRecords(result.records),
srtPath: result.srtPath || "",
message: String(result.message || `字幕识别完成,共 ${result.records.length}`),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, message: `字幕识别失败:${msg}` };
}
}
/**
* 口播视频生成后自动生成字幕数据(对齐 Electron generateVideo → generateFullSubtitle
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
* @param {string} videoPath
*/
export async function autoGenerateSubtitleAfterTalkingVideo(store, videoPath) {
const result = await recognizeSubtitleFromVideo(store, videoPath);
if (!result.ok) return result;
await persistSubtitleRecordsToMixCut(store, {
records: result.records,
srtPath: result.srtPath,
});
return {
ok: true,
records: result.records,
srtPath: result.srtPath,
message: `字幕数据已生成,共 ${result.records.length}`,
};
}
export function recordsFromScriptEvenly(script, durationSec) {
const lines = String(script || "")
.split(/(?<=[。!?.!?])\s*|\n+/)
@@ -157,10 +259,13 @@ export async function loadMixCutSubtitleRecords({ store, existingSettings }) {
},
});
if (result?.success && Array.isArray(result.records) && result.records.length) {
const records = normalizeSubtitleRecords(result.records);
const srtPath = result.srtPath || "";
await persistSubtitleRecordsToMixCut(store, { records, srtPath });
return {
ok: true,
records: result.records,
srtPath: result.srtPath || "",
records,
srtPath,
source: "asr",
};
}

View File

@@ -38,7 +38,9 @@ export async function pickBgmFile() {
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
*/
export async function executeGenerateSubtitleAndBgm(store) {
const inputVideo = String(store.generatedVideoPath || "").trim();
const inputVideo = String(
store.processedVideoPath || store.generatedVideoPath || "",
).trim();
if (!inputVideo) {
return { ok: false, message: "请先在步骤 02 生成口播视频(步骤 03 编辑后亦可)" };
}

View File

@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { loadAppConfigMap } from "../config/videoPipeline.js";
import { useAvatarStore } from "../stores/avatar.js";
import { clearSubtitlePreview } from "./subtitleBgmGenerate.js";
import { autoGenerateSubtitleAfterTalkingVideo } from "./mixCutSettings.js";
import { importGeneratedVideo } from "./videoDb.js";
import { localVideoToPlayableUrl } from "./localVideo.js";
import { getMediaDurationSeconds } from "./mediaDuration.js";
@@ -93,10 +94,28 @@ export async function executeGenerateTalkingVideo(store) {
store.generatedVideoPath = record.filePath;
store.generatedVideoSrc = videoSrc;
store.currentVideoId = record.id;
store.processedVideoPath = "";
store.processedVideoSrc = "";
clearSubtitlePreview(store);
await store.refreshVideoHistory();
return { ok: true, message: "口播视频生成完成" };
let message = "口播视频生成完成";
try {
const subtitle = await autoGenerateSubtitleAfterTalkingVideo(
store,
record.filePath,
);
if (subtitle.ok) {
message = `口播视频生成完成,${subtitle.message}`;
} else if (subtitle.message) {
console.warn("[generateTalkingVideo] 字幕自动生成失败:", subtitle.message);
message = `口播视频生成完成(字幕识别跳过:${subtitle.message}`;
}
} catch (err) {
console.warn("[generateTalkingVideo] 字幕自动生成异常:", err);
}
return { ok: true, message };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err || "未知错误");
return { ok: false, message: `口播视频生成失败: ${msg}` };

View File

@@ -130,13 +130,10 @@ export async function executeAutoProcessVideo(store) {
);
const videoSrc = localVideoToPlayableUrl(record.filePath);
store.generatedVideoPath = record.filePath;
store.generatedVideoSrc = videoSrc;
// 编辑结果单独存放,不覆盖步骤 02 的原始口播视频
store.processedVideoPath = record.filePath;
store.processedVideoSrc = videoSrc;
store.currentVideoId = record.id;
clearSubtitlePreview(store);
await store.refreshVideoHistory();
return {
ok: true,

View File

@@ -223,7 +223,7 @@ export const useWorkflowStore = defineStore("workflow", {
/** 画中画混剪设置弹窗 */
mixCutModalVisible: false,
/** 编辑后视频(与 generated 同步更新,便于后续步骤区分 */
/** 步骤 03 编辑后视频(独立于步骤 02 原始口播,可反复重做编辑 */
processedVideoPath: "",
processedVideoSrc: "",