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 { storeToRefs } from "pinia";
|
||||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||||
import { revealLocalFileInFolder } from "../../services/fileExport.js";
|
import { revealLocalFileInFolder } from "../../services/fileExport.js";
|
||||||
|
import MixCutSettingDialog from "./MixCutSettingDialog.vue";
|
||||||
|
|
||||||
const workflow = useWorkflowStore();
|
const workflow = useWorkflowStore();
|
||||||
const {
|
const {
|
||||||
@@ -51,7 +52,7 @@ async function onPickGreenScreen() {
|
|||||||
|
|
||||||
async function onPickPipMaterial() {
|
async function onPickPipMaterial() {
|
||||||
feedback.value = { severity: "", message: "" };
|
feedback.value = { severity: "", message: "" };
|
||||||
showFeedback(await workflow.choosePipMaterial());
|
await workflow.choosePipMaterial();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenVideoInExplorer() {
|
async function onOpenVideoInExplorer() {
|
||||||
@@ -88,15 +89,15 @@ async function onOpenVideoInExplorer() {
|
|||||||
@click="onPickPipMaterial"
|
@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 />
|
<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 />
|
<Checkbox v-model="greenScreen" binary />
|
||||||
启动绿幕切换
|
启动绿幕切换
|
||||||
</label>
|
</label> -->
|
||||||
<div v-if="greenScreen" class="flex flex-col gap-1 pl-6">
|
<div v-if="greenScreen" class="flex flex-col gap-1 pl-6">
|
||||||
<Button
|
<Button
|
||||||
:label="greenScreenFileName ? '重新选择背景图' : '上传替换图片'"
|
:label="greenScreenFileName ? '重新选择背景图' : '上传替换图片'"
|
||||||
@@ -161,4 +162,6 @@ async function onOpenVideoInExplorer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
|
|
||||||
|
<MixCutSettingDialog @feedback="showFeedback" />
|
||||||
</template>
|
</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 { importGeneratedVideo } from "./videoDb.js";
|
||||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||||
|
|
||||||
|
import { loadVideoMixCutSettings as loadMixCutFromService } from "./mixCutSettings.js";
|
||||||
|
|
||||||
const VIDEO_EDIT_SCRIPT = "video_edit_process.js";
|
const VIDEO_EDIT_SCRIPT = "video_edit_process.js";
|
||||||
const MIX_CUT_CONFIG_KEY = "videoMixCutSettings";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从本地 SQLite 配置读取混剪设置(对齐 Electron videoMixCutSettings)
|
* 从本地 SQLite 配置读取混剪设置(对齐 Electron videoMixCutSettings)
|
||||||
* @returns {Promise<object | null>}
|
* @returns {Promise<object | null>}
|
||||||
*/
|
*/
|
||||||
export async function loadVideoMixCutSettings() {
|
export async function loadVideoMixCutSettings() {
|
||||||
try {
|
return loadMixCutFromService();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,25 +36,6 @@ export async function pickGreenScreenBackground() {
|
|||||||
return String(selected);
|
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() {
|
function formatHistoryTime() {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
const p = (n) => String(n).padStart(2, "0");
|
const p = (n) => String(n).padStart(2, "0");
|
||||||
@@ -109,14 +63,32 @@ export async function executeAutoProcessVideo(store) {
|
|||||||
let mixCutSettings = null;
|
let mixCutSettings = null;
|
||||||
if (store.pipInPicture) {
|
if (store.pipInPicture) {
|
||||||
mixCutSettings = await loadVideoMixCutSettings();
|
mixCutSettings = await loadVideoMixCutSettings();
|
||||||
|
mixCutSettings = { ...mixCutSettings, enabled: true };
|
||||||
const hasReplacements =
|
const hasReplacements =
|
||||||
Array.isArray(mixCutSettings?.replacements) &&
|
Array.isArray(mixCutSettings?.replacements) &&
|
||||||
mixCutSettings.replacements.length > 0;
|
mixCutSettings.replacements.length > 0;
|
||||||
const hasSimple = Boolean(mixCutSettings?.simplePip?.materialPath);
|
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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "已启用画中画,请先点击「选择画中画素材」",
|
message: "已启用画中画,请先点击「选择画中画素材」并完成配置",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-14
@@ -28,8 +28,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
executeAutoProcessVideo,
|
executeAutoProcessVideo,
|
||||||
pickGreenScreenBackground,
|
pickGreenScreenBackground,
|
||||||
pickPipMaterial,
|
|
||||||
saveSimplePipConfig,
|
|
||||||
} from "../services/videoEditProcess.js";
|
} from "../services/videoEditProcess.js";
|
||||||
import { executeGenerateTitleTags } from "../services/titleTagsGenerate.js";
|
import { executeGenerateTitleTags } from "../services/titleTagsGenerate.js";
|
||||||
import {
|
import {
|
||||||
@@ -222,6 +220,9 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
/** 绿幕替换背景图本地路径 */
|
/** 绿幕替换背景图本地路径 */
|
||||||
greenScreenBackgroundPath: "",
|
greenScreenBackgroundPath: "",
|
||||||
|
|
||||||
|
/** 画中画混剪设置弹窗 */
|
||||||
|
mixCutModalVisible: false,
|
||||||
|
|
||||||
/** 编辑后视频(与 generated 同步更新,便于后续步骤区分) */
|
/** 编辑后视频(与 generated 同步更新,便于后续步骤区分) */
|
||||||
processedVideoPath: "",
|
processedVideoPath: "",
|
||||||
|
|
||||||
@@ -583,6 +584,14 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openMixCutDialog() {
|
||||||
|
this.mixCutModalVisible = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeMixCutDialog() {
|
||||||
|
this.mixCutModalVisible = false;
|
||||||
|
},
|
||||||
|
|
||||||
selectVoice(voiceId) {
|
selectVoice(voiceId) {
|
||||||
|
|
||||||
this.selectedVoiceId = voiceId;
|
this.selectedVoiceId = voiceId;
|
||||||
@@ -652,18 +661,8 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async choosePipMaterial() {
|
async choosePipMaterial() {
|
||||||
const path = await pickPipMaterial();
|
this.openMixCutDialog();
|
||||||
if (!path) return { ok: false, message: "" };
|
return { ok: true, message: "" };
|
||||||
await saveSimplePipConfig({
|
|
||||||
materialPath: path,
|
|
||||||
startTime: 0,
|
|
||||||
displayMode: "pip",
|
|
||||||
pipPosition: "bottom-right",
|
|
||||||
pipSizePercent: 30,
|
|
||||||
pipScaleMode: "fit",
|
|
||||||
});
|
|
||||||
this.pipInPicture = true;
|
|
||||||
return { ok: true, message: "已选择画中画素材" };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateTitleTags() {
|
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