11
This commit is contained in:
+54271
File diff suppressed because one or more lines are too long
+14319
File diff suppressed because one or more lines are too long
+30455
File diff suppressed because one or more lines are too long
+22098
File diff suppressed because one or more lines are too long
+225960
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "已启用画中画,请先点击「选择画中画素材」并完成配置",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+13
-14
@@ -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() {
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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