11
This commit is contained in:
@@ -8,11 +8,13 @@ import {
|
|||||||
findMappingForLine,
|
findMappingForLine,
|
||||||
loadMixCutSubtitleRecords,
|
loadMixCutSubtitleRecords,
|
||||||
openSubtitleFileAndParse,
|
openSubtitleFileAndParse,
|
||||||
|
persistSubtitleRecordsToMixCut,
|
||||||
pickLocalMixCutMaterial,
|
pickLocalMixCutMaterial,
|
||||||
pickMaterialFolder,
|
pickMaterialFolder,
|
||||||
prepareMixCutSettingsForSave,
|
prepareMixCutSettingsForSave,
|
||||||
removeMaterialFromSubtitleLine,
|
removeMaterialFromSubtitleLine,
|
||||||
} from "../../services/mixCutSettings.js";
|
} from "../../services/mixCutSettings.js";
|
||||||
|
import { revealLocalFileInFolder } from "../../services/fileExport.js";
|
||||||
import {
|
import {
|
||||||
createDefaultGlobalDisplay,
|
createDefaultGlobalDisplay,
|
||||||
createDefaultMixCutSettings,
|
createDefaultMixCutSettings,
|
||||||
@@ -120,6 +122,10 @@ async function loadExistingSubtitleLines() {
|
|||||||
if (result.srtPath) subtitleFilePath.value = result.srtPath;
|
if (result.srtPath) subtitleFilePath.value = result.srtPath;
|
||||||
settings.value.records = result.records;
|
settings.value.records = result.records;
|
||||||
if (result.srtPath) settings.value.subtitleFilePath = result.srtPath;
|
if (result.srtPath) settings.value.subtitleFilePath = result.srtPath;
|
||||||
|
await persistSubtitleRecordsToMixCut(props.store, {
|
||||||
|
records: result.records,
|
||||||
|
srtPath: result.srtPath,
|
||||||
|
});
|
||||||
const msg =
|
const msg =
|
||||||
result.message ||
|
result.message ||
|
||||||
`字幕加载成功,共 ${result.records.length} 条`;
|
`字幕加载成功,共 ${result.records.length} 条`;
|
||||||
@@ -131,6 +137,19 @@ async function loadExistingSubtitleLines() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenSubtitleFile() {
|
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();
|
const result = await openSubtitleFileAndParse();
|
||||||
if (!result.message && !result.ok) return;
|
if (!result.message && !result.ok) return;
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -141,6 +160,10 @@ async function onOpenSubtitleFile() {
|
|||||||
subtitleFilePath.value = result.srtPath || "";
|
subtitleFilePath.value = result.srtPath || "";
|
||||||
settings.value.records = result.records;
|
settings.value.records = result.records;
|
||||||
settings.value.subtitleFilePath = result.srtPath || "";
|
settings.value.subtitleFilePath = result.srtPath || "";
|
||||||
|
await persistSubtitleRecordsToMixCut(props.store, {
|
||||||
|
records: result.records,
|
||||||
|
srtPath: result.srtPath,
|
||||||
|
});
|
||||||
showFeedback("success", `已加载 ${result.records.length} 行字幕`);
|
showFeedback("success", `已加载 ${result.records.length} 行字幕`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ const {
|
|||||||
greenScreen,
|
greenScreen,
|
||||||
videoProcessing,
|
videoProcessing,
|
||||||
greenScreenBackgroundPath,
|
greenScreenBackgroundPath,
|
||||||
generatedVideoSrc,
|
|
||||||
generatedVideoPath,
|
generatedVideoPath,
|
||||||
|
processedVideoSrc,
|
||||||
|
processedVideoPath,
|
||||||
} = storeToRefs(workflow);
|
} = storeToRefs(workflow);
|
||||||
|
|
||||||
const feedback = ref({ severity: "", message: "" });
|
const feedback = ref({ severity: "", message: "" });
|
||||||
@@ -56,12 +57,12 @@ async function onPickPipMaterial() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenVideoInExplorer() {
|
async function onOpenVideoInExplorer() {
|
||||||
if (!generatedVideoPath.value) {
|
if (!processedVideoPath.value) {
|
||||||
feedback.value = { severity: "error", message: "暂无视频文件" };
|
feedback.value = { severity: "error", message: "暂无编辑后的视频文件" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await revealLocalFileInFolder(generatedVideoPath.value);
|
await revealLocalFileInFolder(processedVideoPath.value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
feedback.value = {
|
feedback.value = {
|
||||||
severity: "error",
|
severity: "error",
|
||||||
@@ -146,15 +147,15 @@ async function onOpenVideoInExplorer() {
|
|||||||
size="small"
|
size="small"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
text
|
text
|
||||||
:disabled="!generatedVideoPath"
|
:disabled="!processedVideoPath"
|
||||||
aria-label="在资源管理器中打开"
|
aria-label="在资源管理器中打开"
|
||||||
@click="onOpenVideoInExplorer"
|
@click="onOpenVideoInExplorer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-aspect-video">
|
<div class="dashboard-aspect-video">
|
||||||
<video
|
<video
|
||||||
v-if="generatedVideoSrc"
|
v-if="processedVideoSrc"
|
||||||
:src="generatedVideoSrc"
|
:src="processedVideoSrc"
|
||||||
controls
|
controls
|
||||||
class="h-full w-full rounded object-contain"
|
class="h-full w-full rounded object-contain"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const {
|
|||||||
subtitleTemplateId,
|
subtitleTemplateId,
|
||||||
subtitleBgmGenerating,
|
subtitleBgmGenerating,
|
||||||
generatedVideoPath,
|
generatedVideoPath,
|
||||||
|
processedVideoPath,
|
||||||
subtitlePreviewVideoPath,
|
subtitlePreviewVideoPath,
|
||||||
subtitlePreviewVideoSrc,
|
subtitlePreviewVideoSrc,
|
||||||
bgmPreviewSrc,
|
bgmPreviewSrc,
|
||||||
@@ -22,7 +23,9 @@ const {
|
|||||||
const feedback = ref({ severity: "", message: "" });
|
const feedback = ref({ severity: "", message: "" });
|
||||||
const templateDialogVisible = ref(false);
|
const templateDialogVisible = ref(false);
|
||||||
|
|
||||||
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
const hasSourceVideo = computed(() =>
|
||||||
|
Boolean(processedVideoPath.value || generatedVideoPath.value),
|
||||||
|
);
|
||||||
const hasSubtitleVideoPreview = computed(() =>
|
const hasSubtitleVideoPreview = computed(() =>
|
||||||
Boolean(subtitlePreviewVideoSrc.value),
|
Boolean(subtitlePreviewVideoSrc.value),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,6 +70,108 @@ async function readTextFile(filePath) {
|
|||||||
* @param {number} durationSec
|
* @param {number} durationSec
|
||||||
* @returns {Array<{ text: string, start: number, end: number }>}
|
* @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) {
|
export function recordsFromScriptEvenly(script, durationSec) {
|
||||||
const lines = String(script || "")
|
const lines = String(script || "")
|
||||||
.split(/(?<=[。!?.!?])\s*|\n+/)
|
.split(/(?<=[。!?.!?])\s*|\n+/)
|
||||||
@@ -157,10 +259,13 @@ export async function loadMixCutSubtitleRecords({ store, existingSettings }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result?.success && Array.isArray(result.records) && result.records.length) {
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
records: result.records,
|
records,
|
||||||
srtPath: result.srtPath || "",
|
srtPath,
|
||||||
source: "asr",
|
source: "asr",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export async function pickBgmFile() {
|
|||||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||||
*/
|
*/
|
||||||
export async function executeGenerateSubtitleAndBgm(store) {
|
export async function executeGenerateSubtitleAndBgm(store) {
|
||||||
const inputVideo = String(store.generatedVideoPath || "").trim();
|
const inputVideo = String(
|
||||||
|
store.processedVideoPath || store.generatedVideoPath || "",
|
||||||
|
).trim();
|
||||||
if (!inputVideo) {
|
if (!inputVideo) {
|
||||||
return { ok: false, message: "请先在步骤 02 生成口播视频(步骤 03 编辑后亦可)" };
|
return { ok: false, message: "请先在步骤 02 生成口播视频(步骤 03 编辑后亦可)" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { loadAppConfigMap } from "../config/videoPipeline.js";
|
import { loadAppConfigMap } from "../config/videoPipeline.js";
|
||||||
import { useAvatarStore } from "../stores/avatar.js";
|
import { useAvatarStore } from "../stores/avatar.js";
|
||||||
import { clearSubtitlePreview } from "./subtitleBgmGenerate.js";
|
import { clearSubtitlePreview } from "./subtitleBgmGenerate.js";
|
||||||
|
import { autoGenerateSubtitleAfterTalkingVideo } from "./mixCutSettings.js";
|
||||||
import { importGeneratedVideo } from "./videoDb.js";
|
import { importGeneratedVideo } from "./videoDb.js";
|
||||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||||
import { getMediaDurationSeconds } from "./mediaDuration.js";
|
import { getMediaDurationSeconds } from "./mediaDuration.js";
|
||||||
@@ -93,10 +94,28 @@ export async function executeGenerateTalkingVideo(store) {
|
|||||||
store.generatedVideoPath = record.filePath;
|
store.generatedVideoPath = record.filePath;
|
||||||
store.generatedVideoSrc = videoSrc;
|
store.generatedVideoSrc = videoSrc;
|
||||||
store.currentVideoId = record.id;
|
store.currentVideoId = record.id;
|
||||||
|
store.processedVideoPath = "";
|
||||||
|
store.processedVideoSrc = "";
|
||||||
clearSubtitlePreview(store);
|
clearSubtitlePreview(store);
|
||||||
await store.refreshVideoHistory();
|
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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||||
return { ok: false, message: `口播视频生成失败: ${msg}` };
|
return { ok: false, message: `口播视频生成失败: ${msg}` };
|
||||||
|
|||||||
@@ -130,13 +130,10 @@ export async function executeAutoProcessVideo(store) {
|
|||||||
);
|
);
|
||||||
const videoSrc = localVideoToPlayableUrl(record.filePath);
|
const videoSrc = localVideoToPlayableUrl(record.filePath);
|
||||||
|
|
||||||
store.generatedVideoPath = record.filePath;
|
// 编辑结果单独存放,不覆盖步骤 02 的原始口播视频
|
||||||
store.generatedVideoSrc = videoSrc;
|
|
||||||
store.processedVideoPath = record.filePath;
|
store.processedVideoPath = record.filePath;
|
||||||
store.processedVideoSrc = videoSrc;
|
store.processedVideoSrc = videoSrc;
|
||||||
store.currentVideoId = record.id;
|
|
||||||
clearSubtitlePreview(store);
|
clearSubtitlePreview(store);
|
||||||
await store.refreshVideoHistory();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
/** 画中画混剪设置弹窗 */
|
/** 画中画混剪设置弹窗 */
|
||||||
mixCutModalVisible: false,
|
mixCutModalVisible: false,
|
||||||
|
|
||||||
/** 编辑后视频(与 generated 同步更新,便于后续步骤区分) */
|
/** 步骤 03 编辑后视频(独立于步骤 02 原始口播,可反复重做编辑) */
|
||||||
processedVideoPath: "",
|
processedVideoPath: "",
|
||||||
|
|
||||||
processedVideoSrc: "",
|
processedVideoSrc: "",
|
||||||
|
|||||||
Reference in New Issue
Block a user