11
This commit is contained in:
@@ -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} 行字幕`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 编辑后亦可)" };
|
||||
}
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -223,7 +223,7 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
/** 画中画混剪设置弹窗 */
|
||||
mixCutModalVisible: false,
|
||||
|
||||
/** 编辑后视频(与 generated 同步更新,便于后续步骤区分) */
|
||||
/** 步骤 03 编辑后视频(独立于步骤 02 原始口播,可反复重做编辑) */
|
||||
processedVideoPath: "",
|
||||
|
||||
processedVideoSrc: "",
|
||||
|
||||
Reference in New Issue
Block a user