11
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
105
src/components/workflow/MixCutMaterialPickerDialog.vue
Normal file
105
src/components/workflow/MixCutMaterialPickerDialog.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { listMixCutMaterialLibrary } from "../../services/mixCutSettings.js";
|
||||
import { localMediaToPlayableUrl } from "../../services/materialMedia.js";
|
||||
|
||||
const visible = defineModel("visible", { type: Boolean, default: false });
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
|
||||
const materials = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
materials.value = await listMixCutMaterialLibrary();
|
||||
} catch {
|
||||
materials.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (open) refresh();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (visible.value) refresh();
|
||||
});
|
||||
|
||||
function previewSrc(item) {
|
||||
if (!item?.path) return "";
|
||||
try {
|
||||
return localMediaToPlayableUrl(item.path);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect(item) {
|
||||
emit("select", item);
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
const emptyText = computed(() =>
|
||||
loading.value ? "加载中…" : "素材库为空,请先在「素材管理」页上传",
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
header="选择素材库素材"
|
||||
:style="{ width: '720px' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div v-if="loading" class="py-10 text-center text-sm text-slate-400">
|
||||
正在加载素材库…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!materials.length"
|
||||
class="py-10 text-center text-sm text-slate-400"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid max-h-[70vh] grid-cols-3 gap-3 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="item in materials"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-700 bg-slate-900/50 p-2 text-left transition hover:border-blue-500/60 hover:bg-slate-800/80"
|
||||
@click="onSelect(item)"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex aspect-video items-center justify-center overflow-hidden rounded bg-slate-950"
|
||||
>
|
||||
<img
|
||||
v-if="item.type === 'image'"
|
||||
:src="previewSrc(item)"
|
||||
:alt="item.name"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="previewSrc(item)"
|
||||
class="h-full w-full object-cover"
|
||||
muted
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
<div class="truncate text-sm font-medium text-slate-200">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-slate-500">
|
||||
{{ item.type === "image" ? "图片" : "视频" }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
95
src/components/workflow/MixCutSettingDialog.vue
Normal file
95
src/components/workflow/MixCutSettingDialog.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
import MixCutSettingPanel from "./MixCutSettingPanel.vue";
|
||||
import {
|
||||
loadVideoMixCutSettings,
|
||||
saveVideoMixCutSettings,
|
||||
} from "../../services/mixCutSettings.js";
|
||||
|
||||
const emit = defineEmits(["feedback"]);
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const { mixCutModalVisible } = storeToRefs(workflow);
|
||||
|
||||
const panelRef = ref(null);
|
||||
const saving = ref(false);
|
||||
|
||||
async function loadPanelSettings() {
|
||||
const settings = await loadVideoMixCutSettings();
|
||||
if (workflow.pipInPicture && !settings.enabled) {
|
||||
settings.enabled = true;
|
||||
}
|
||||
panelRef.value?.applyIncomingSettings(settings);
|
||||
if (settings.mode === "subtitle") {
|
||||
await panelRef.value?.loadExistingSubtitleLines();
|
||||
}
|
||||
}
|
||||
|
||||
watch(mixCutModalVisible, (open) => {
|
||||
if (open) {
|
||||
loadPanelSettings();
|
||||
}
|
||||
});
|
||||
|
||||
function onClose() {
|
||||
workflow.closeMixCutDialog();
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (!panelRef.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = await panelRef.value.save();
|
||||
await saveVideoMixCutSettings(payload);
|
||||
workflow.pipInPicture = Boolean(payload.enabled);
|
||||
emit("feedback", { ok: true, message: "混剪配置保存成功!" });
|
||||
workflow.closeMixCutDialog();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
emit("feedback", { ok: false, message: `保存配置失败:${msg}` });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPanelFeedback(result) {
|
||||
if (result?.message) emit("feedback", result);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="mixCutModalVisible"
|
||||
modal
|
||||
header="画中画设置"
|
||||
:style="{ width: '760px' }"
|
||||
:draggable="false"
|
||||
class="mixcut-setting-dialog"
|
||||
@hide="onClose"
|
||||
>
|
||||
<div class="max-h-[70vh] space-y-4 overflow-y-auto p-4">
|
||||
<MixCutSettingPanel
|
||||
ref="panelRef"
|
||||
:store="workflow"
|
||||
@feedback="onPanelFeedback"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
label="取消"
|
||||
severity="secondary"
|
||||
text
|
||||
:disabled="saving"
|
||||
@click="onClose"
|
||||
/>
|
||||
<Button
|
||||
label="保存"
|
||||
:loading="saving"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
688
src/components/workflow/MixCutSettingPanel.vue
Normal file
688
src/components/workflow/MixCutSettingPanel.vue
Normal file
@@ -0,0 +1,688 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import MixCutMaterialPickerDialog from "./MixCutMaterialPickerDialog.vue";
|
||||
import {
|
||||
applyGlobalDisplayToAllMaterials,
|
||||
bindMaterialToSubtitleLine,
|
||||
bindMaterialToSubtitleLines,
|
||||
findMappingForLine,
|
||||
loadMixCutSubtitleRecords,
|
||||
openSubtitleFileAndParse,
|
||||
pickLocalMixCutMaterial,
|
||||
pickMaterialFolder,
|
||||
prepareMixCutSettingsForSave,
|
||||
removeMaterialFromSubtitleLine,
|
||||
} from "../../services/mixCutSettings.js";
|
||||
import {
|
||||
createDefaultGlobalDisplay,
|
||||
createDefaultMixCutSettings,
|
||||
MINIMIZED_POSITION_OPTIONS,
|
||||
PIP_POSITION_OPTIONS,
|
||||
SELECTION_MODE_OPTIONS,
|
||||
} from "../../utils/mixCutDefaults.js";
|
||||
import { formatRecordTimeRange } from "../../utils/srtParser.js";
|
||||
|
||||
const props = defineProps({
|
||||
store: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["saved", "feedback"]);
|
||||
|
||||
const settings = ref(createDefaultMixCutSettings());
|
||||
const globalDisplay = ref(createDefaultGlobalDisplay());
|
||||
const records = ref([]);
|
||||
const subtitleFilePath = ref("");
|
||||
const loadingSubtitles = ref(false);
|
||||
const selectedLineIndices = ref(new Set());
|
||||
|
||||
const materialPickerVisible = ref(false);
|
||||
/** @type {import('vue').Ref<{ type: string, lineIndex?: number, segment?: string, groupId?: string } | null>} */
|
||||
const materialPickTarget = ref(null);
|
||||
|
||||
const modeOptions = [
|
||||
{ label: "字幕模式", value: "subtitle" },
|
||||
{ label: "固定模式", value: "fixed" },
|
||||
{ label: "关键词模式", value: "keyword" },
|
||||
];
|
||||
|
||||
const displayModeOptions = [
|
||||
{ label: "全屏替换", value: "fullscreen" },
|
||||
{ label: "画中画", value: "pip" },
|
||||
];
|
||||
|
||||
const scaleModeOptions = [
|
||||
{ label: "适应", value: "fit" },
|
||||
{ label: "填充", value: "fill" },
|
||||
{ label: "拉伸", value: "stretch" },
|
||||
];
|
||||
|
||||
const shapeOptions = [
|
||||
{ label: "方形", value: "square" },
|
||||
{ label: "圆形", value: "circle" },
|
||||
];
|
||||
|
||||
const loadedLineCount = computed(() => records.value.length);
|
||||
const hasSelectedLines = computed(() => selectedLineIndices.value.size > 0);
|
||||
|
||||
function showFeedback(severity, message) {
|
||||
if (!message) return;
|
||||
emit("feedback", { ok: severity !== "error", message });
|
||||
}
|
||||
|
||||
function fileNameFromPath(path) {
|
||||
if (!path) return "";
|
||||
return path.split(/[/\\]/).pop() || path;
|
||||
}
|
||||
|
||||
function mappingForLine(lineIndex) {
|
||||
return findMappingForLine(settings.value, lineIndex);
|
||||
}
|
||||
|
||||
function materialLabelForLine(lineIndex) {
|
||||
const mapping = mappingForLine(lineIndex);
|
||||
if (!mapping?.materialPath) return "";
|
||||
return fileNameFromPath(mapping.materialPath);
|
||||
}
|
||||
|
||||
function isLineSelected(lineIndex) {
|
||||
return selectedLineIndices.value.has(lineIndex);
|
||||
}
|
||||
|
||||
function toggleLineSelection(lineIndex, checked) {
|
||||
const next = new Set(selectedLineIndices.value);
|
||||
if (checked) next.add(lineIndex);
|
||||
else next.delete(lineIndex);
|
||||
selectedLineIndices.value = next;
|
||||
}
|
||||
|
||||
function applyIncomingSettings(raw) {
|
||||
settings.value = { ...createDefaultMixCutSettings(), ...raw };
|
||||
records.value = Array.isArray(raw.records) ? [...raw.records] : [];
|
||||
subtitleFilePath.value = raw.subtitleFilePath || "";
|
||||
globalDisplay.value = {
|
||||
...createDefaultGlobalDisplay(),
|
||||
displayMode: raw.displayMode === "pip" ? "pip" : "fullscreen",
|
||||
};
|
||||
}
|
||||
|
||||
async function loadExistingSubtitleLines() {
|
||||
loadingSubtitles.value = true;
|
||||
try {
|
||||
const result = await loadMixCutSubtitleRecords({
|
||||
store: props.store,
|
||||
existingSettings: settings.value,
|
||||
});
|
||||
if (!result.ok) {
|
||||
showFeedback("warn", result.message || "加载字幕失败");
|
||||
return false;
|
||||
}
|
||||
records.value = result.records;
|
||||
if (result.srtPath) subtitleFilePath.value = result.srtPath;
|
||||
settings.value.records = result.records;
|
||||
if (result.srtPath) settings.value.subtitleFilePath = result.srtPath;
|
||||
const msg =
|
||||
result.message ||
|
||||
`字幕加载成功,共 ${result.records.length} 条`;
|
||||
showFeedback("success", msg);
|
||||
return true;
|
||||
} finally {
|
||||
loadingSubtitles.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onOpenSubtitleFile() {
|
||||
const result = await openSubtitleFileAndParse();
|
||||
if (!result.message && !result.ok) return;
|
||||
if (!result.ok) {
|
||||
showFeedback("error", result.message || "打开字幕文件失败");
|
||||
return;
|
||||
}
|
||||
records.value = result.records;
|
||||
subtitleFilePath.value = result.srtPath || "";
|
||||
settings.value.records = result.records;
|
||||
settings.value.subtitleFilePath = result.srtPath || "";
|
||||
showFeedback("success", `已加载 ${result.records.length} 行字幕`);
|
||||
}
|
||||
|
||||
function onApplyGlobalDisplay() {
|
||||
applyGlobalDisplayToAllMaterials(settings.value, globalDisplay.value);
|
||||
settings.value.displayMode = globalDisplay.value.displayMode;
|
||||
showFeedback("success", "全局设置已应用到所有素材");
|
||||
}
|
||||
|
||||
function openMaterialPicker(target) {
|
||||
materialPickTarget.value = target;
|
||||
materialPickerVisible.value = true;
|
||||
}
|
||||
|
||||
function onPickLibraryForLine(lineIndex) {
|
||||
openMaterialPicker({ type: "subtitle-line", lineIndex });
|
||||
}
|
||||
|
||||
async function onPickLocalForLine(lineIndex) {
|
||||
const path = await pickLocalMixCutMaterial();
|
||||
if (!path) return;
|
||||
bindMaterialToSubtitleLine(settings.value, lineIndex, path);
|
||||
showFeedback("success", "素材已选择");
|
||||
}
|
||||
|
||||
function onRemoveLineMaterial(lineIndex) {
|
||||
removeMaterialFromSubtitleLine(settings.value, lineIndex);
|
||||
showFeedback("success", "素材已删除");
|
||||
}
|
||||
|
||||
function onMaterialSelected(item) {
|
||||
const target = materialPickTarget.value;
|
||||
if (!target || !item?.path) return;
|
||||
|
||||
if (target.type === "subtitle-line") {
|
||||
const indices = hasSelectedLines.value
|
||||
? Array.from(selectedLineIndices.value)
|
||||
: [target.lineIndex];
|
||||
if (indices.length > 1) {
|
||||
bindMaterialToSubtitleLines(settings.value, indices, item.path);
|
||||
showFeedback("success", "已为选中的多条字幕绑定同一个素材");
|
||||
selectedLineIndices.value = new Set();
|
||||
} else {
|
||||
bindMaterialToSubtitleLine(settings.value, target.lineIndex, item.path);
|
||||
showFeedback("success", "素材已选择");
|
||||
}
|
||||
} else if (target.type === "fixed-segment") {
|
||||
const seg = settings.value.fixedModeConfig[target.segment];
|
||||
if (seg) {
|
||||
seg.specifiedMaterialPath = item.path;
|
||||
seg.selectionMode = "specified";
|
||||
}
|
||||
showFeedback("success", "素材已选择");
|
||||
} else if (target.type === "keyword-group") {
|
||||
const group = settings.value.keywordModeConfig.groups.find(
|
||||
(g) => g.groupId === target.groupId,
|
||||
);
|
||||
if (group) {
|
||||
group.specifiedMaterialPath = item.path;
|
||||
group.selectionMode = "specified";
|
||||
}
|
||||
showFeedback("success", "素材已选择");
|
||||
}
|
||||
|
||||
materialPickTarget.value = null;
|
||||
}
|
||||
|
||||
async function onPickFolderForSegment(segmentKey) {
|
||||
const path = await pickMaterialFolder();
|
||||
if (!path) return;
|
||||
settings.value.fixedModeConfig[segmentKey].materialFolderPath = path;
|
||||
}
|
||||
|
||||
async function onPickFolderForKeywordGroup(groupId) {
|
||||
const path = await pickMaterialFolder();
|
||||
if (!path) return;
|
||||
const group = settings.value.keywordModeConfig.groups.find(
|
||||
(g) => g.groupId === groupId,
|
||||
);
|
||||
if (group) group.materialFolderPath = path;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload = prepareMixCutSettingsForSave(settings.value, globalDisplay.value);
|
||||
payload.records = records.value;
|
||||
payload.subtitleFilePath = subtitleFilePath.value;
|
||||
emit("saved", payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => settings.value.mode,
|
||||
async (mode) => {
|
||||
if (mode === "subtitle" && !records.value.length) {
|
||||
await loadExistingSubtitleLines();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
applyIncomingSettings,
|
||||
loadExistingSubtitleLines,
|
||||
save,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mixcut-setting-panel space-y-6">
|
||||
<div>
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox v-model="settings.enabled" binary />
|
||||
<span class="font-medium text-slate-100">启用画中画混剪</span>
|
||||
</label>
|
||||
<p class="ml-6 mt-1 text-xs text-slate-500">
|
||||
勾选后,点击自动处理时将应用混剪效果
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr class="border-slate-700/80" />
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-200">混剪模式</label>
|
||||
<SelectButton
|
||||
v-model="settings.mode"
|
||||
:options="modeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr class="border-slate-700/80" />
|
||||
|
||||
<div
|
||||
class="global-display-panel rounded-lg border border-blue-500/35 p-4"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(18, 33, 58, 0.96) 0%,
|
||||
rgba(15, 23, 42, 0.98) 100%
|
||||
);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-semibold text-blue-100">
|
||||
全局显示模式(应用到所有素材)
|
||||
</h3>
|
||||
<Button
|
||||
label="应用到所有素材"
|
||||
size="small"
|
||||
@click="onApplyGlobalDisplay"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-slate-200">显示模式</label>
|
||||
<SelectButton
|
||||
v-model="globalDisplay.displayMode"
|
||||
:options="displayModeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="globalDisplay.displayMode === 'fullscreen'"
|
||||
class="ml-4 space-y-2 border-l-2 border-blue-400/35 pl-3"
|
||||
>
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox v-model="globalDisplay.originalVideoMinimized.enabled" binary />
|
||||
<span class="text-xs text-slate-200">原视频最小化</span>
|
||||
</label>
|
||||
<template v-if="globalDisplay.originalVideoMinimized.enabled">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">形状</label>
|
||||
<SelectButton
|
||||
v-model="globalDisplay.originalVideoMinimized.shape"
|
||||
:options="shapeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">位置</label>
|
||||
<Select
|
||||
v-model="globalDisplay.originalVideoMinimized.position"
|
||||
:options="MINIMIZED_POSITION_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="small"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">
|
||||
尺寸占比 {{ globalDisplay.originalVideoMinimized.sizePercent }}%
|
||||
</label>
|
||||
<Slider
|
||||
v-model="globalDisplay.originalVideoMinimized.sizePercent"
|
||||
:min="10"
|
||||
:max="50"
|
||||
:step="5"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="ml-4 space-y-2 border-l-2 border-blue-400/35 pl-3"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">位置</label>
|
||||
<Select
|
||||
v-model="globalDisplay.pipPosition"
|
||||
:options="PIP_POSITION_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="small"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">
|
||||
尺寸占比 {{ globalDisplay.pipSizePercent }}%
|
||||
</label>
|
||||
<Slider
|
||||
v-model="globalDisplay.pipSizePercent"
|
||||
:min="10"
|
||||
:max="80"
|
||||
:step="5"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">缩放模式</label>
|
||||
<SelectButton
|
||||
v-model="globalDisplay.pipScaleMode"
|
||||
:options="scaleModeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-xs text-slate-500">
|
||||
💡 提示:修改上述设置后,点击「应用到所有素材」按钮,将统一应用到固定模式、关键词模式和字幕模式的所有素材配置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr class="border-slate-700/80" />
|
||||
|
||||
<!-- 固定模式 -->
|
||||
<div v-if="settings.mode === 'fixed'" class="space-y-4">
|
||||
<div class="text-sm font-medium text-slate-200">固定模式配置</div>
|
||||
<div
|
||||
v-for="segment in [
|
||||
{ key: 'opening', label: '片头' },
|
||||
{ key: 'middle', label: '片中' },
|
||||
{ key: 'ending', label: '片尾' },
|
||||
]"
|
||||
:key="segment.key"
|
||||
class="space-y-3 rounded-lg border border-slate-700 p-4"
|
||||
>
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="settings.fixedModeConfig[segment.key].enabled"
|
||||
binary
|
||||
/>
|
||||
<span class="font-medium text-slate-200">{{ segment.label }}</span>
|
||||
</label>
|
||||
|
||||
<template v-if="settings.fixedModeConfig[segment.key].enabled">
|
||||
<div class="ml-6 space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">播放时长范围(秒)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<InputNumber
|
||||
v-model="settings.fixedModeConfig[segment.key].timeRange.durationMin"
|
||||
:min="0"
|
||||
:max="60"
|
||||
:step="0.5"
|
||||
size="small"
|
||||
class="w-24"
|
||||
/>
|
||||
<span class="text-xs text-slate-500">到</span>
|
||||
<InputNumber
|
||||
v-model="settings.fixedModeConfig[segment.key].timeRange.durationMax"
|
||||
:min="0.5"
|
||||
:max="60"
|
||||
:step="0.5"
|
||||
size="small"
|
||||
class="w-24"
|
||||
/>
|
||||
<span class="text-xs text-slate-500">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">素材文件夹</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
:model-value="settings.fixedModeConfig[segment.key].materialFolderPath"
|
||||
readonly
|
||||
size="small"
|
||||
class="flex-1"
|
||||
placeholder="请选择素材文件夹"
|
||||
/>
|
||||
<Button
|
||||
label="选择"
|
||||
size="small"
|
||||
@click="onPickFolderForSegment(segment.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">素材选择模式</label>
|
||||
<SelectButton
|
||||
v-model="settings.fixedModeConfig[segment.key].selectionMode"
|
||||
:options="SELECTION_MODE_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settings.fixedModeConfig[segment.key].selectionMode === 'specified'"
|
||||
>
|
||||
<label class="mb-1 block text-xs text-slate-400">素材库素材</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
:model-value="fileNameFromPath(settings.fixedModeConfig[segment.key].specifiedMaterialPath)"
|
||||
readonly
|
||||
size="small"
|
||||
class="flex-1"
|
||||
placeholder="请选择素材库素材"
|
||||
/>
|
||||
<Button
|
||||
label="素材库选择"
|
||||
size="small"
|
||||
@click="openMaterialPicker({ type: 'fixed-segment', segment: segment.key })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关键词模式 -->
|
||||
<div v-else-if="settings.mode === 'keyword'" class="space-y-4">
|
||||
<div class="text-sm font-medium text-slate-200">关键词模式配置</div>
|
||||
<p class="text-xs text-slate-500">
|
||||
关键词分组需在步骤 05 字幕设置中配置;此处为各分组指定混剪素材文件夹。
|
||||
</p>
|
||||
<div
|
||||
v-if="!settings.keywordModeConfig.groups.length"
|
||||
class="rounded-lg border border-dashed border-slate-700 py-8 text-center text-sm text-slate-500"
|
||||
>
|
||||
暂无关键词分组,请先在字幕步骤启用智能字幕并识别关键词
|
||||
</div>
|
||||
<div
|
||||
v-for="group in settings.keywordModeConfig.groups"
|
||||
:key="group.groupId"
|
||||
class="space-y-3 rounded-lg border border-slate-700 p-4"
|
||||
>
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox v-model="group.enabled" binary />
|
||||
<span class="font-medium text-slate-200">{{ group.groupName }}</span>
|
||||
<span v-if="!group.enabled" class="text-xs text-slate-500">(未启用)</span>
|
||||
</label>
|
||||
<template v-if="group.enabled">
|
||||
<div class="ml-6 space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">素材文件夹</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
:model-value="group.materialFolderPath"
|
||||
readonly
|
||||
size="small"
|
||||
class="flex-1"
|
||||
placeholder="请选择素材文件夹"
|
||||
/>
|
||||
<Button
|
||||
label="选择"
|
||||
size="small"
|
||||
@click="onPickFolderForKeywordGroup(group.groupId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-400">素材选择模式</label>
|
||||
<SelectButton
|
||||
v-model="group.selectionMode"
|
||||
:options="SELECTION_MODE_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="group.selectionMode === 'specified'">
|
||||
<label class="mb-1 block text-xs text-slate-400">素材库素材</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
:model-value="fileNameFromPath(group.specifiedMaterialPath)"
|
||||
readonly
|
||||
size="small"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button
|
||||
label="素材库选择"
|
||||
size="small"
|
||||
@click="openMaterialPicker({ type: 'keyword-group', groupId: group.groupId })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字幕模式 -->
|
||||
<div v-else class="space-y-4">
|
||||
<div class="text-sm font-medium text-slate-200">字幕模式配置</div>
|
||||
<p class="mb-3 text-xs text-slate-500">
|
||||
为每一行字幕(或多行字幕)指定插入的素材文件
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
label="加载已识别字幕"
|
||||
size="small"
|
||||
:loading="loadingSubtitles"
|
||||
@click="loadExistingSubtitleLines"
|
||||
/>
|
||||
<span v-if="loadedLineCount" class="text-xs text-slate-500">
|
||||
已加载 {{ loadedLineCount }} 行文案
|
||||
</span>
|
||||
<Button
|
||||
v-if="hasSelectedLines"
|
||||
label="给选中字幕选素材"
|
||||
size="small"
|
||||
outlined
|
||||
@click="openMaterialPicker({ type: 'subtitle-line', lineIndex: Array.from(selectedLineIndices)[0] })"
|
||||
/>
|
||||
<Button
|
||||
v-if="hasSelectedLines"
|
||||
label="清除选中"
|
||||
size="small"
|
||||
outlined
|
||||
severity="secondary"
|
||||
@click="selectedLineIndices = new Set()"
|
||||
/>
|
||||
<Button
|
||||
label="打开字幕文件"
|
||||
size="small"
|
||||
outlined
|
||||
@click="onOpenSubtitleFile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="subtitleFilePath" class="mt-1 text-xs text-slate-500">
|
||||
字幕文件:{{ subtitleFilePath }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
优先复用已生成字幕记录或 SRT,找不到时才会补生成字幕列表。
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="records.length"
|
||||
class="max-h-96 space-y-2 overflow-y-auto rounded-lg border border-slate-700 p-3"
|
||||
>
|
||||
<div class="mb-2 text-xs font-medium text-slate-400">为每行文案配置素材</div>
|
||||
<div
|
||||
v-for="(record, index) in records"
|
||||
:key="index"
|
||||
class="line-material-card rounded border border-slate-700/80 p-3 transition-colors hover:bg-slate-800/40"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Checkbox
|
||||
:model-value="isLineSelected(index)"
|
||||
binary
|
||||
@update:model-value="(val) => toggleLineSelection(index, val)"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-slate-500">第 {{ index + 1 }} 行</span>
|
||||
<span class="font-mono text-xs text-blue-400">
|
||||
{{ formatRecordTimeRange(record.start, record.end) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-2 text-sm text-slate-200">{{ record.text }}</div>
|
||||
<div
|
||||
v-if="materialLabelForLine(index)"
|
||||
class="flex items-center gap-1 text-xs text-emerald-400"
|
||||
>
|
||||
<i class="pi pi-check-circle" />
|
||||
<span class="max-w-xs truncate">{{ materialLabelForLine(index) }}</span>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
text
|
||||
severity="danger"
|
||||
aria-label="删除素材"
|
||||
@click="onRemoveLineMaterial(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
label="素材库选择"
|
||||
size="small"
|
||||
@click="onPickLibraryForLine(index)"
|
||||
/>
|
||||
<Button
|
||||
label="本地文件"
|
||||
size="small"
|
||||
outlined
|
||||
@click="onPickLocalForLine(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-xs text-slate-500">
|
||||
请先点击「加载已识别字幕」或「打开字幕文件」
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MixCutMaterialPickerDialog
|
||||
v-model:visible="materialPickerVisible"
|
||||
@select="onMaterialSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
import { revealLocalFileInFolder } from "../../services/fileExport.js";
|
||||
import MixCutSettingDialog from "./MixCutSettingDialog.vue";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const {
|
||||
@@ -51,7 +52,7 @@ async function onPickGreenScreen() {
|
||||
|
||||
async function onPickPipMaterial() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.choosePipMaterial());
|
||||
await workflow.choosePipMaterial();
|
||||
}
|
||||
|
||||
async function onOpenVideoInExplorer() {
|
||||
@@ -88,15 +89,15 @@ async function onOpenVideoInExplorer() {
|
||||
@click="onPickPipMaterial"
|
||||
/>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<!-- <label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="autoCutBreath" binary />
|
||||
自动剪气口
|
||||
</label>
|
||||
</label> -->
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<!-- <label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="greenScreen" binary />
|
||||
启动绿幕切换
|
||||
</label>
|
||||
</label> -->
|
||||
<div v-if="greenScreen" class="flex flex-col gap-1 pl-6">
|
||||
<Button
|
||||
:label="greenScreenFileName ? '重新选择背景图' : '上传替换图片'"
|
||||
@@ -161,4 +162,6 @@ async function onOpenVideoInExplorer() {
|
||||
</div>
|
||||
</div>
|
||||
</DashboardCard>
|
||||
|
||||
<MixCutSettingDialog @feedback="showFeedback" />
|
||||
</template>
|
||||
|
||||
379
src/services/mixCutSettings.js
Normal file
379
src/services/mixCutSettings.js
Normal file
@@ -0,0 +1,379 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { listMaterials } from "./materialDb.js";
|
||||
import {
|
||||
createDefaultMixCutSettings,
|
||||
normalizeMixCutSettings,
|
||||
} from "../utils/mixCutDefaults.js";
|
||||
import { parseSrtToRecords } from "../utils/srtParser.js";
|
||||
|
||||
export const MIX_CUT_CONFIG_KEY = "videoMixCutSettings";
|
||||
|
||||
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "webp", "bmp", "gif"]);
|
||||
|
||||
/**
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function loadVideoMixCutSettings() {
|
||||
try {
|
||||
const rows = await invoke("list_local_app_config");
|
||||
const row = Array.isArray(rows)
|
||||
? rows.find((r) => r.name === MIX_CUT_CONFIG_KEY)
|
||||
: null;
|
||||
if (!row?.value) return createDefaultMixCutSettings();
|
||||
return normalizeMixCutSettings(JSON.parse(row.value));
|
||||
} catch {
|
||||
return createDefaultMixCutSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
*/
|
||||
export async function saveVideoMixCutSettings(settings) {
|
||||
const payload = normalizeMixCutSettings(settings);
|
||||
await invoke("set_local_app_config", {
|
||||
name: MIX_CUT_CONFIG_KEY,
|
||||
value: JSON.stringify(payload),
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function isImageMaterialPath(filePath) {
|
||||
const ext = String(filePath || "")
|
||||
.split(".")
|
||||
.pop()
|
||||
?.toLowerCase();
|
||||
return IMAGE_EXT.has(ext || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function readTextFile(filePath) {
|
||||
const url = convertFileSrc(filePath);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`读取文件失败 (${res.status})`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} script
|
||||
* @param {number} durationSec
|
||||
* @returns {Array<{ text: string, start: number, end: number }>}
|
||||
*/
|
||||
export function recordsFromScriptEvenly(script, durationSec) {
|
||||
const lines = String(script || "")
|
||||
.split(/(?<=[。!?.!?])\s*|\n+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) return [];
|
||||
|
||||
const totalMs = Math.max(Math.round(durationSec * 1000), lines.length * 1500);
|
||||
const step = totalMs / lines.length;
|
||||
return lines.map((text, i) => ({
|
||||
text,
|
||||
start: Math.round(i * step),
|
||||
end: Math.round((i + 1) * step),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} options.store
|
||||
* @param {object} [options.existingSettings]
|
||||
*/
|
||||
export async function loadMixCutSubtitleRecords({ store, existingSettings }) {
|
||||
const settings = existingSettings || (await loadVideoMixCutSettings());
|
||||
|
||||
if (Array.isArray(settings.records) && settings.records.length) {
|
||||
return {
|
||||
ok: true,
|
||||
records: settings.records,
|
||||
srtPath: settings.subtitleFilePath || store.subtitleSrtPath || "",
|
||||
source: "config",
|
||||
};
|
||||
}
|
||||
|
||||
const srtCandidates = [
|
||||
settings.subtitleFilePath,
|
||||
store.subtitleSrtPath,
|
||||
].filter(Boolean);
|
||||
|
||||
for (const srtPath of srtCandidates) {
|
||||
try {
|
||||
const content = await readTextFile(srtPath);
|
||||
const records = parseSrtToRecords(content);
|
||||
if (records.length) {
|
||||
return { ok: true, records, srtPath, source: "srt" };
|
||||
}
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
|
||||
const videoPath = String(store.generatedVideoPath || "").trim();
|
||||
const script = String(store.scriptContent || "").trim();
|
||||
|
||||
if (script && videoPath) {
|
||||
try {
|
||||
const durationSec = await invoke("get_media_duration_seconds", {
|
||||
path: videoPath,
|
||||
});
|
||||
const records = recordsFromScriptEvenly(script, Number(durationSec) || 30);
|
||||
if (records.length) {
|
||||
return {
|
||||
ok: true,
|
||||
records,
|
||||
srtPath: "",
|
||||
source: "script",
|
||||
message: "已根据文案与视频时长生成字幕行(无 ASR 时间轴)",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
if (videoPath) {
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: "subtitle_bgm_generate.js",
|
||||
params: {
|
||||
inputVideo: videoPath,
|
||||
autoSubtitle: true,
|
||||
smartSubtitle: Boolean(store.smartSubtitle),
|
||||
scriptContent: script,
|
||||
recognizeOnly: true,
|
||||
bgmEnabled: false,
|
||||
},
|
||||
});
|
||||
if (result?.success && Array.isArray(result.records) && result.records.length) {
|
||||
return {
|
||||
ok: true,
|
||||
records: result.records,
|
||||
srtPath: result.srtPath || "",
|
||||
source: "asr",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "字幕识别失败,请检查模型配置"),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: `加载字幕失败:${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: "未找到字幕数据,请先在步骤 02 生成视频或打开字幕文件",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ ok: boolean, records?: object[], srtPath?: string, message?: string }>}
|
||||
*/
|
||||
export async function openSubtitleFileAndParse() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: "字幕文件", extensions: ["srt", "txt"] }],
|
||||
});
|
||||
if (selected == null) return { ok: false, message: "" };
|
||||
const path = Array.isArray(selected) ? selected[0] : String(selected);
|
||||
if (!path) return { ok: false, message: "" };
|
||||
|
||||
try {
|
||||
const content = await readTextFile(path);
|
||||
const records = parseSrtToRecords(content);
|
||||
if (!records.length) {
|
||||
return { ok: false, message: "字幕文件中没有有效内容" };
|
||||
}
|
||||
return { ok: true, records, srtPath: path };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: `读取字幕文件失败:${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickLocalMixCutMaterial() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "视频或图片",
|
||||
extensions: ["mp4", "mov", "webm", "mkv", "m4v", "jpg", "jpeg", "png", "webp", "gif"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickMaterialFolder() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Array<{ id: number, name: string, path: string, type: string }>>}
|
||||
*/
|
||||
export async function listMixCutMaterialLibrary() {
|
||||
return listMaterials();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
* @param {object} globalDisplay
|
||||
*/
|
||||
export function applyGlobalDisplayToAllMaterials(settings, globalDisplay) {
|
||||
const g = globalDisplay;
|
||||
const patch = {
|
||||
displayMode: g.displayMode,
|
||||
pipPosition: g.pipPosition,
|
||||
pipSizePercent: g.pipSizePercent,
|
||||
pipScaleMode: g.pipScaleMode,
|
||||
originalVideoMinimized: { ...g.originalVideoMinimized },
|
||||
};
|
||||
|
||||
["opening", "middle", "ending"].forEach((key) => {
|
||||
Object.assign(settings.fixedModeConfig[key], patch);
|
||||
});
|
||||
|
||||
settings.keywordModeConfig.groups.forEach((group) => {
|
||||
Object.assign(group, patch);
|
||||
});
|
||||
|
||||
settings.subtitleModeConfig.mappings.forEach((mapping) => {
|
||||
Object.assign(mapping, patch);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
* @param {number} lineIndex
|
||||
* @returns {object | null}
|
||||
*/
|
||||
export function findMappingForLine(settings, lineIndex) {
|
||||
return (
|
||||
settings.subtitleModeConfig.mappings.find((m) =>
|
||||
Array.isArray(m.subtitleIndices) && m.subtitleIndices.includes(lineIndex),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
* @param {number} lineIndex
|
||||
* @param {string} materialPath
|
||||
*/
|
||||
export function bindMaterialToSubtitleLine(settings, lineIndex, materialPath) {
|
||||
const mappings = settings.subtitleModeConfig.mappings;
|
||||
const existing = findMappingForLine(settings, lineIndex);
|
||||
const isImage = isImageMaterialPath(materialPath);
|
||||
|
||||
if (existing) {
|
||||
existing.materialPath = materialPath;
|
||||
existing.isImage = isImage;
|
||||
existing.selectionMode = "specified";
|
||||
return existing;
|
||||
}
|
||||
|
||||
const filtered = mappings.filter(
|
||||
(m) => !Array.isArray(m.subtitleIndices) || !m.subtitleIndices.includes(lineIndex),
|
||||
);
|
||||
const created = {
|
||||
subtitleIndices: [lineIndex],
|
||||
materialPath,
|
||||
isImage,
|
||||
selectionMode: "specified",
|
||||
displayMode: settings.displayMode || "fullscreen",
|
||||
pipPosition: "bottom-right",
|
||||
pipSizePercent: 30,
|
||||
pipScaleMode: "fit",
|
||||
originalVideoMinimized: {
|
||||
enabled: false,
|
||||
shape: "square",
|
||||
position: "bottom-right",
|
||||
sizePercent: 25,
|
||||
},
|
||||
};
|
||||
filtered.push(created);
|
||||
settings.subtitleModeConfig.mappings = filtered;
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
* @param {number[]} lineIndices
|
||||
* @param {string} materialPath
|
||||
*/
|
||||
export function bindMaterialToSubtitleLines(settings, lineIndices, materialPath) {
|
||||
const indices = [...new Set(lineIndices)].sort((a, b) => a - b);
|
||||
if (!indices.length) return;
|
||||
|
||||
settings.subtitleModeConfig.mappings = settings.subtitleModeConfig.mappings.filter(
|
||||
(m) => !m.subtitleIndices?.some((idx) => indices.includes(idx)),
|
||||
);
|
||||
|
||||
const isImage = isImageMaterialPath(materialPath);
|
||||
settings.subtitleModeConfig.mappings.push({
|
||||
subtitleIndices: indices,
|
||||
materialPath,
|
||||
isImage,
|
||||
selectionMode: "specified",
|
||||
displayMode: settings.displayMode || "fullscreen",
|
||||
pipPosition: "bottom-right",
|
||||
pipSizePercent: 30,
|
||||
pipScaleMode: "fit",
|
||||
originalVideoMinimized: {
|
||||
enabled: false,
|
||||
shape: "square",
|
||||
position: "bottom-right",
|
||||
sizePercent: 25,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
* @param {number} lineIndex
|
||||
*/
|
||||
export function removeMaterialFromSubtitleLine(settings, lineIndex) {
|
||||
settings.subtitleModeConfig.mappings = settings.subtitleModeConfig.mappings.filter(
|
||||
(m) => !m.subtitleIndices?.includes(lineIndex),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} settings
|
||||
*/
|
||||
export function prepareMixCutSettingsForSave(settings, globalDisplay) {
|
||||
const next = normalizeMixCutSettings(settings);
|
||||
next.fixedModeConfig.enabled = next.mode === "fixed";
|
||||
next.keywordModeConfig.enabled = next.mode === "keyword";
|
||||
next.subtitleModeConfig.enabled = next.mode === "subtitle";
|
||||
next.displayMode = globalDisplay.displayMode;
|
||||
if (next.enabled && next.subtitleModeConfig.mappings.some((m) => m.materialPath)) {
|
||||
next.simplePip = undefined;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -5,43 +5,16 @@ import { clearSubtitlePreview } from "./subtitleBgmGenerate.js";
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
|
||||
import { loadVideoMixCutSettings as loadMixCutFromService } from "./mixCutSettings.js";
|
||||
|
||||
const VIDEO_EDIT_SCRIPT = "video_edit_process.js";
|
||||
const MIX_CUT_CONFIG_KEY = "videoMixCutSettings";
|
||||
|
||||
/**
|
||||
* 从本地 SQLite 配置读取混剪设置(对齐 Electron videoMixCutSettings)
|
||||
* @returns {Promise<object | null>}
|
||||
*/
|
||||
export async function loadVideoMixCutSettings() {
|
||||
try {
|
||||
const rows = await invoke("list_local_app_config");
|
||||
const row = Array.isArray(rows)
|
||||
? rows.find((r) => r.name === MIX_CUT_CONFIG_KEY)
|
||||
: null;
|
||||
if (!row?.value) return null;
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存/合并 simplePip 到本地混剪配置
|
||||
* @param {object} simplePip
|
||||
*/
|
||||
export async function saveSimplePipConfig(simplePip) {
|
||||
const existing = (await loadVideoMixCutSettings()) || {};
|
||||
const next = {
|
||||
...existing,
|
||||
enabled: true,
|
||||
displayMode: existing.displayMode || "pip",
|
||||
simplePip,
|
||||
};
|
||||
await invoke("set_local_app_config", {
|
||||
name: MIX_CUT_CONFIG_KEY,
|
||||
value: JSON.stringify(next),
|
||||
});
|
||||
return next;
|
||||
return loadMixCutFromService();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,25 +36,6 @@ export async function pickGreenScreenBackground() {
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择画中画素材(视频或图片)
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickPipMaterial() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "视频或图片",
|
||||
extensions: ["mp4", "mov", "webm", "mkv", "m4v", "jpg", "jpeg", "png", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
function formatHistoryTime() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
@@ -109,14 +63,32 @@ export async function executeAutoProcessVideo(store) {
|
||||
let mixCutSettings = null;
|
||||
if (store.pipInPicture) {
|
||||
mixCutSettings = await loadVideoMixCutSettings();
|
||||
mixCutSettings = { ...mixCutSettings, enabled: true };
|
||||
const hasReplacements =
|
||||
Array.isArray(mixCutSettings?.replacements) &&
|
||||
mixCutSettings.replacements.length > 0;
|
||||
const hasSimple = Boolean(mixCutSettings?.simplePip?.materialPath);
|
||||
if (!hasReplacements && !hasSimple) {
|
||||
const hasSubtitleMappings =
|
||||
Array.isArray(mixCutSettings?.subtitleModeConfig?.mappings) &&
|
||||
mixCutSettings.subtitleModeConfig.mappings.some((m) => m.materialPath);
|
||||
const hasFixedSegments =
|
||||
mixCutSettings?.mode === "fixed" &&
|
||||
mixCutSettings?.fixedModeConfig?.enabled &&
|
||||
["opening", "middle", "ending"].some(
|
||||
(key) =>
|
||||
mixCutSettings.fixedModeConfig[key]?.enabled &&
|
||||
(mixCutSettings.fixedModeConfig[key]?.materialFolderPath ||
|
||||
mixCutSettings.fixedModeConfig[key]?.specifiedMaterialPath),
|
||||
);
|
||||
if (
|
||||
!hasReplacements &&
|
||||
!hasSimple &&
|
||||
!hasSubtitleMappings &&
|
||||
!hasFixedSegments
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已启用画中画,请先点击「选择画中画素材」",
|
||||
message: "已启用画中画,请先点击「选择画中画素材」并完成配置",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ import {
|
||||
import {
|
||||
executeAutoProcessVideo,
|
||||
pickGreenScreenBackground,
|
||||
pickPipMaterial,
|
||||
saveSimplePipConfig,
|
||||
} from "../services/videoEditProcess.js";
|
||||
import { executeGenerateTitleTags } from "../services/titleTagsGenerate.js";
|
||||
import {
|
||||
@@ -222,6 +220,9 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
/** 绿幕替换背景图本地路径 */
|
||||
greenScreenBackgroundPath: "",
|
||||
|
||||
/** 画中画混剪设置弹窗 */
|
||||
mixCutModalVisible: false,
|
||||
|
||||
/** 编辑后视频(与 generated 同步更新,便于后续步骤区分) */
|
||||
processedVideoPath: "",
|
||||
|
||||
@@ -583,6 +584,14 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
},
|
||||
|
||||
openMixCutDialog() {
|
||||
this.mixCutModalVisible = true;
|
||||
},
|
||||
|
||||
closeMixCutDialog() {
|
||||
this.mixCutModalVisible = false;
|
||||
},
|
||||
|
||||
selectVoice(voiceId) {
|
||||
|
||||
this.selectedVoiceId = voiceId;
|
||||
@@ -652,18 +661,8 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
},
|
||||
|
||||
async choosePipMaterial() {
|
||||
const path = await pickPipMaterial();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
await saveSimplePipConfig({
|
||||
materialPath: path,
|
||||
startTime: 0,
|
||||
displayMode: "pip",
|
||||
pipPosition: "bottom-right",
|
||||
pipSizePercent: 30,
|
||||
pipScaleMode: "fit",
|
||||
});
|
||||
this.pipInPicture = true;
|
||||
return { ok: true, message: "已选择画中画素材" };
|
||||
this.openMixCutDialog();
|
||||
return { ok: true, message: "" };
|
||||
},
|
||||
|
||||
async generateTitleTags() {
|
||||
|
||||
213
src/utils/mixCutDefaults.js
Normal file
213
src/utils/mixCutDefaults.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/** @typedef {'subtitle' | 'fixed' | 'keyword'} MixCutMode */
|
||||
|
||||
/**
|
||||
* @returns {{ enabled: boolean, shape: string, position: string, sizePercent: number }}
|
||||
*/
|
||||
export function createDefaultOriginalVideoMinimized() {
|
||||
return {
|
||||
enabled: false,
|
||||
shape: "square",
|
||||
position: "bottom-right",
|
||||
sizePercent: 25,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createDefaultSegmentConfig() {
|
||||
return {
|
||||
enabled: false,
|
||||
timeRange: { durationMin: 2, durationMax: 5 },
|
||||
materialFolderPath: "",
|
||||
specifiedMaterialPath: "",
|
||||
selectionMode: "random",
|
||||
displayMode: "fullscreen",
|
||||
pipPosition: "bottom-right",
|
||||
pipSizePercent: 30,
|
||||
pipScaleMode: "fit",
|
||||
originalVideoMinimized: createDefaultOriginalVideoMinimized(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createDefaultFixedModeConfig() {
|
||||
return {
|
||||
enabled: false,
|
||||
opening: createDefaultSegmentConfig(),
|
||||
middle: {
|
||||
...createDefaultSegmentConfig(),
|
||||
pipPosition: "bottom-left",
|
||||
pipSizePercent: 40,
|
||||
},
|
||||
ending: createDefaultSegmentConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createDefaultKeywordGroup(groupId, groupName) {
|
||||
return {
|
||||
groupId,
|
||||
groupName,
|
||||
enabled: false,
|
||||
materialFolderPath: "",
|
||||
specifiedMaterialPath: "",
|
||||
selectionMode: "random",
|
||||
durationMin: 2,
|
||||
durationMax: 5,
|
||||
displayMode: "fullscreen",
|
||||
pipPosition: "bottom-right",
|
||||
pipSizePercent: 30,
|
||||
pipScaleMode: "fit",
|
||||
originalVideoMinimized: createDefaultOriginalVideoMinimized(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createDefaultKeywordModeConfig() {
|
||||
return {
|
||||
enabled: false,
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createDefaultSubtitleModeConfig() {
|
||||
return {
|
||||
enabled: false,
|
||||
mappings: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createDefaultGlobalDisplay() {
|
||||
return {
|
||||
displayMode: "fullscreen",
|
||||
pipPosition: "bottom-right",
|
||||
pipSizePercent: 30,
|
||||
pipScaleMode: "fit",
|
||||
originalVideoMinimized: createDefaultOriginalVideoMinimized(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createDefaultMixCutSettings() {
|
||||
return {
|
||||
enabled: false,
|
||||
mode: "subtitle",
|
||||
displayMode: "pip",
|
||||
records: [],
|
||||
subtitleFilePath: "",
|
||||
fixedModeConfig: createDefaultFixedModeConfig(),
|
||||
keywordModeConfig: createDefaultKeywordModeConfig(),
|
||||
subtitleModeConfig: createDefaultSubtitleModeConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object | null | undefined} raw
|
||||
*/
|
||||
export function normalizeMixCutSettings(raw) {
|
||||
const base = createDefaultMixCutSettings();
|
||||
if (!raw || typeof raw !== "object") return base;
|
||||
|
||||
const mergeSegment = (target, source) => {
|
||||
if (!source || typeof source !== "object") return target;
|
||||
return {
|
||||
...target,
|
||||
...source,
|
||||
timeRange: { ...target.timeRange, ...(source.timeRange || {}) },
|
||||
originalVideoMinimized: {
|
||||
...target.originalVideoMinimized,
|
||||
...(source.originalVideoMinimized || {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const fixed = createDefaultFixedModeConfig();
|
||||
const fixedRaw = raw.fixedModeConfig || {};
|
||||
fixed.enabled = Boolean(fixedRaw.enabled);
|
||||
fixed.opening = mergeSegment(fixed.opening, fixedRaw.opening);
|
||||
fixed.middle = mergeSegment(fixed.middle, fixedRaw.middle);
|
||||
fixed.ending = mergeSegment(fixed.ending, fixedRaw.ending);
|
||||
|
||||
const keyword = createDefaultKeywordModeConfig();
|
||||
const keywordRaw = raw.keywordModeConfig || {};
|
||||
keyword.enabled = Boolean(keywordRaw.enabled);
|
||||
keyword.groups = Array.isArray(keywordRaw.groups)
|
||||
? keywordRaw.groups.map((g, i) => ({
|
||||
...createDefaultKeywordGroup(g.groupId || `group_${i}`, g.groupName || `分组 ${i + 1}`),
|
||||
...g,
|
||||
originalVideoMinimized: {
|
||||
...createDefaultOriginalVideoMinimized(),
|
||||
...(g.originalVideoMinimized || {}),
|
||||
},
|
||||
}))
|
||||
: [];
|
||||
|
||||
const subtitle = createDefaultSubtitleModeConfig();
|
||||
const subtitleRaw = raw.subtitleModeConfig || {};
|
||||
subtitle.enabled = Boolean(subtitleRaw.enabled);
|
||||
subtitle.mappings = Array.isArray(subtitleRaw.mappings)
|
||||
? subtitleRaw.mappings.map((m) => ({
|
||||
...m,
|
||||
subtitleIndices: Array.isArray(m.subtitleIndices) ? [...m.subtitleIndices] : [],
|
||||
originalVideoMinimized: {
|
||||
...createDefaultOriginalVideoMinimized(),
|
||||
...(m.originalVideoMinimized || {}),
|
||||
},
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
...base,
|
||||
...raw,
|
||||
enabled: Boolean(raw.enabled),
|
||||
mode: raw.mode === "fixed" || raw.mode === "keyword" ? raw.mode : "subtitle",
|
||||
displayMode: raw.displayMode === "fullscreen" ? "fullscreen" : "pip",
|
||||
records: Array.isArray(raw.records) ? raw.records : [],
|
||||
subtitleFilePath: String(raw.subtitleFilePath || ""),
|
||||
fixedModeConfig: fixed,
|
||||
keywordModeConfig: keyword,
|
||||
subtitleModeConfig: subtitle,
|
||||
simplePip: raw.simplePip,
|
||||
};
|
||||
}
|
||||
|
||||
export const PIP_POSITION_OPTIONS = [
|
||||
{ label: "左上角", value: "top-left" },
|
||||
{ label: "顶部居中", value: "top-center" },
|
||||
{ label: "右上角", value: "top-right" },
|
||||
{ label: "左侧居中", value: "center-left" },
|
||||
{ label: "正中间", value: "center" },
|
||||
{ label: "右侧居中", value: "center-right" },
|
||||
{ label: "左下角", value: "bottom-left" },
|
||||
{ label: "底部居中", value: "bottom-center" },
|
||||
{ label: "右下角", value: "bottom-right" },
|
||||
];
|
||||
|
||||
export const MINIMIZED_POSITION_OPTIONS = [
|
||||
{ label: "左上角", value: "top-left" },
|
||||
{ label: "右上角", value: "top-right" },
|
||||
{ label: "左下角", value: "bottom-left" },
|
||||
{ label: "右下角", value: "bottom-right" },
|
||||
{ label: "正中间", value: "center" },
|
||||
];
|
||||
|
||||
export const SELECTION_MODE_OPTIONS = [
|
||||
{ label: "随机", value: "random" },
|
||||
{ label: "顺序", value: "sequential" },
|
||||
{ label: "指定素材", value: "specified" },
|
||||
];
|
||||
72
src/utils/srtParser.js
Normal file
72
src/utils/srtParser.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 解析 SRT 字幕为混剪 records(start/end 单位:毫秒)
|
||||
* @param {string} content
|
||||
* @returns {Array<{ text: string, start: number, end: number }>}
|
||||
*/
|
||||
export function parseSrtToRecords(content) {
|
||||
const text = String(content || "").replace(/\uFEFF/g, "").trim();
|
||||
if (!text) return [];
|
||||
|
||||
const blocks = text.split(/\n\s*\n+/);
|
||||
const records = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
if (lines.length < 2) continue;
|
||||
|
||||
let timeLineIdx = 0;
|
||||
if (/^\d+$/.test(lines[0])) {
|
||||
timeLineIdx = 1;
|
||||
}
|
||||
const timeLine = lines[timeLineIdx];
|
||||
if (!timeLine || !timeLine.includes("-->")) continue;
|
||||
|
||||
const [startRaw, endRaw] = timeLine.split("-->").map((s) => s.trim());
|
||||
const start = parseSrtTimeToMs(startRaw);
|
||||
const end = parseSrtTimeToMs(endRaw);
|
||||
const body = lines.slice(timeLineIdx + 1).join("\n").trim();
|
||||
if (!body || !Number.isFinite(start) || !Number.isFinite(end)) continue;
|
||||
|
||||
records.push({
|
||||
text: body.replace(/<[^>]+>/g, ""),
|
||||
start,
|
||||
end: Math.max(end, start + 200),
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} raw
|
||||
* @returns {number}
|
||||
*/
|
||||
function parseSrtTimeToMs(raw) {
|
||||
const m = String(raw || "").trim().match(
|
||||
/(?:(\d+):)?(\d{2}):(\d{2})[.,](\d{1,3})/,
|
||||
);
|
||||
if (!m) return NaN;
|
||||
const h = Number(m[1] || 0);
|
||||
const min = Number(m[2]);
|
||||
const sec = Number(m[3]);
|
||||
const msPart = String(m[4]).padEnd(3, "0").slice(0, 3);
|
||||
return h * 3600000 + min * 60000 + sec * 1000 + Number(msPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} ms
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRecordTimeRange(msStart, msEnd) {
|
||||
return `${formatShortTime(msStart)} - ${formatShortTime(msEnd)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} ms
|
||||
*/
|
||||
function formatShortTime(ms) {
|
||||
const totalSec = Math.max(0, ms) / 1000;
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec - min * 60;
|
||||
return `${min}:${sec.toFixed(1)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user