This commit is contained in:
949036910@qq.com
2026-06-02 21:52:14 +08:00
parent ca62a4ad6a
commit e294caf462
17 changed files with 2562 additions and 96 deletions

View File

@@ -0,0 +1,32 @@
<script setup>
import { ref } from "vue";
const props = defineProps({
title: { type: String, required: true },
defaultOpen: { type: Boolean, default: true },
headerExtra: { type: Boolean, default: false },
});
const open = ref(props.defaultOpen);
</script>
<template>
<div class="rounded-lg border border-slate-600/60 bg-slate-800/40">
<div
class="flex cursor-pointer items-center justify-between p-3 transition-colors hover:bg-slate-700/30"
@click="open = !open"
>
<div class="flex flex-1 items-center gap-3">
<h4 class="text-sm font-semibold text-slate-100">{{ title }}</h4>
<slot v-if="headerExtra" name="header-extra" @click.stop />
</div>
<i
class="pi text-slate-400 transition-transform"
:class="open ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</div>
<div v-show="open" class="space-y-3 border-t border-slate-600/40 px-3 pb-3 pt-2">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,326 @@
<script setup>
import { computed, ref } from "vue";
import {
COVER_CANVAS_HEIGHT,
COVER_CANVAS_WIDTH,
COVER_PREVIEW_SCALE,
} from "../../config/coverTemplateDefaults.js";
import {
layoutPersonBox,
layoutSubtitle,
layoutTitle,
} from "../../utils/coverPreviewLayout.js";
const props = defineProps({
config: { type: Object, required: true },
});
const emit = defineEmits([
"updatePersonPosition",
"updateTitlePosition",
"updateSubtitlePosition",
]);
const previewW = COVER_CANVAS_WIDTH * COVER_PREVIEW_SCALE;
const previewH = COVER_CANVAS_HEIGHT * COVER_PREVIEW_SCALE;
const dragTarget = ref(null);
const dragEl = ref(null);
const dragStart = ref(null);
const isDragging = ref(false);
const showPerson = computed(() => props.config.extractPerson !== false);
const titleLayout = computed(() => {
const cfg = {
...props.config,
titleText: props.config.titleText?.trim() || "主标题",
};
return layoutTitle(cfg, COVER_PREVIEW_SCALE);
});
const subtitleLayout = computed(() => {
const cfg = {
...props.config,
subtitleText: props.config.subtitleText?.trim() || "副标题",
};
return layoutSubtitle(cfg, COVER_PREVIEW_SCALE);
});
const personBox = computed(() =>
layoutPersonBox(props.config, COVER_PREVIEW_SCALE),
);
function titleShadowCss(layers) {
const active = (layers || []).filter((l) => l?.enabled !== false);
if (!active.length) return "none";
return active
.map((l) => {
const a = (Number(l.opacity) ?? 100) / 100;
const hex = String(l.color || "#000").replace("#", "");
const r = parseInt(hex.slice(0, 2), 16) || 0;
const g = parseInt(hex.slice(2, 4), 16) || 0;
const b = parseInt(hex.slice(4, 6), 16) || 0;
return `rgba(${r},${g},${b},${a}) ${l.offsetX || 0}px ${l.offsetY || 0}px ${l.blur || 0}px`;
})
.join(", ");
}
/**
* @param {"title"|"subtitle"} prefix
*/
function charStyle(prefix, item, fontSize) {
const c = props.config;
const layers =
prefix === "title" ? c.titleShadowLayers : c.subtitleShadowLayers;
const hasText = Boolean(String(c[`${prefix}Text`] || "").trim());
return {
left: `${item.x}px`,
top: `${item.y}px`,
transform: "translate(-50%, -50%)",
fontFamily: `${c[`${prefix}FontFamily`] || "Microsoft YaHei"}, sans-serif`,
fontSize: `${fontSize}px`,
fontWeight: c[`${prefix}FontWeight`] || 500,
color: hasText ? c[`${prefix}Color`] || "#fff" : "rgb(148 163 184)",
WebkitTextStroke: c[`${prefix}StrokeWidth`]
? `${c[`${prefix}StrokeWidth`]}px ${c[`${prefix}StrokeColor`] || "#000"}`
: undefined,
textShadow: titleShadowCss(layers),
lineHeight: 1,
whiteSpace: "pre",
};
}
function boxOverlayStyle(box, rotation = 0) {
return {
left: `${box.left}px`,
top: `${box.top}px`,
width: `${Math.max(box.width, 24)}px`,
height: `${Math.max(box.height, 24)}px`,
transform: rotation ? `rotate(${rotation}deg)` : undefined,
transformOrigin: "center center",
};
}
function getPos(posKey) {
const pos = props.config[posKey];
if (pos && typeof pos === "object" && "x" in pos && "y" in pos) {
return { x: Number(pos.x) || 50, y: Number(pos.y) || 50 };
}
return { x: 50, y: 50 };
}
function emitPosition(target, pos) {
const clamped = {
x: Math.max(0, Math.min(100, Math.round(pos.x * 10) / 10)),
y: Math.max(0, Math.min(100, Math.round(pos.y * 10) / 10)),
};
if (target === "person") emit("updatePersonPosition", clamped);
else if (target === "title") emit("updateTitlePosition", clamped);
else if (target === "subtitle") emit("updateSubtitlePosition", clamped);
}
function onPointerDown(event, target) {
const el = event.currentTarget;
if (!(el instanceof HTMLElement)) return;
event.preventDefault();
event.stopPropagation();
const posKey =
target === "person"
? "personPosition"
: target === "title"
? "titlePosition"
: "subtitlePosition";
dragTarget.value = target;
dragEl.value = el;
isDragging.value = true;
dragStart.value = {
clientX: event.clientX,
clientY: event.clientY,
pos: getPos(posKey),
};
try {
el.setPointerCapture(event.pointerId);
} catch {
/* 部分环境不支持 */
}
window.addEventListener("pointermove", onPointerMove, { passive: false });
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
}
function onPointerMove(event) {
if (!dragTarget.value || !dragStart.value) return;
event.preventDefault();
const dx = event.clientX - dragStart.value.clientX;
const dy = event.clientY - dragStart.value.clientY;
const nx = dragStart.value.pos.x + (dx / previewW) * 100;
const ny = dragStart.value.pos.y + (dy / previewH) * 100;
emitPosition(dragTarget.value, { x: nx, y: ny });
}
function onPointerUp(event) {
if (dragEl.value && event?.pointerId != null) {
try {
dragEl.value.releasePointerCapture(event.pointerId);
} catch {
/* ignore */
}
}
dragTarget.value = null;
dragEl.value = null;
dragStart.value = null;
isDragging.value = false;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
}
const personBorderStyle = computed(() => {
const c = props.config;
if (!c.personBorderEnabled) return {};
const w = Math.max(2, (c.personBorderWidth || 4) * COVER_PREVIEW_SCALE * 0.15);
const color = c.personBorderColor || "#fff";
if (c.personBorderStyle === "dashed") {
return { outline: `${w}px dashed ${color}`, outlineOffset: "2px" };
}
return { outline: `${w}px solid ${color}`, outlineOffset: "2px" };
});
function overlayClass(target) {
const active = isDragging.value && dragTarget.value === target;
return [
"cover-drag-overlay",
active ? "cover-drag-overlay--active" : "",
];
}
</script>
<template>
<div class="flex h-full flex-col">
<div class="mb-3 flex justify-end gap-2">
<slot name="actions" />
</div>
<div
class="flex flex-1 items-center justify-center overflow-visible rounded-lg bg-slate-700/50 p-4"
>
<div
class="cover-preview-canvas relative bg-gradient-to-b from-slate-500 to-slate-800 shadow-lg"
:style="{ width: `${previewW}px`, height: `${previewH}px` }"
>
<!-- 主标题逐字定位 Python /竖排一致 -->
<span
v-for="(item, i) in titleLayout.chars"
:key="`t-${i}`"
class="pointer-events-none absolute select-none"
:style="charStyle('title', item, titleLayout.fontSize)"
>{{ item.char }}</span>
<!-- 副标题 -->
<span
v-for="(item, i) in subtitleLayout.chars"
:key="`s-${i}`"
class="pointer-events-none absolute select-none"
:style="charStyle('subtitle', item, subtitleLayout.fontSize)"
>{{ item.char }}</span>
<!-- 人像拖拽区 -->
<div
v-if="showPerson"
:class="overlayClass('person')"
class="cover-drag-overlay cover-drag-overlay--person"
:style="{
...boxOverlayStyle(personBox),
...personBorderStyle,
zIndex: 71,
}"
@pointerdown="onPointerDown($event, 'person')"
>
<div
class="pointer-events-none flex h-full flex-col items-center justify-center gap-1 p-2 text-center"
>
<span class="text-[11px] font-medium text-blue-100">人像</span>
<span class="text-[10px] text-blue-200/80">拖拽移动</span>
</div>
</div>
<!-- 主标题拖拽区 -->
<div
:class="overlayClass('title')"
class="cover-drag-overlay cover-drag-overlay--title"
:style="{
...boxOverlayStyle(titleLayout.box, config.titleRotation || 0),
zIndex: 80,
}"
@pointerdown="onPointerDown($event, 'title')"
/>
<!-- 副标题拖拽区 -->
<div
:class="overlayClass('subtitle')"
class="cover-drag-overlay cover-drag-overlay--subtitle"
:style="{
...boxOverlayStyle(
subtitleLayout.box,
config.subtitleRotation || 0,
),
zIndex: 75,
}"
@pointerdown="onPointerDown($event, 'subtitle')"
/>
</div>
</div>
<p class="mt-2 text-center text-xs text-slate-500">
在预览区按住蓝色/绿色框拖拽调整位置左侧数值会同步更新
</p>
</div>
</template>
<style scoped>
.cover-preview-canvas {
touch-action: none;
user-select: none;
}
.cover-drag-overlay {
position: absolute;
cursor: grab;
touch-action: none;
user-select: none;
border: 2px dashed;
border-radius: 6px;
box-sizing: border-box;
}
.cover-drag-overlay:active,
.cover-drag-overlay--active {
cursor: grabbing;
}
.cover-drag-overlay--person {
border-color: rgb(96 165 250 / 0.9);
background: rgb(59 130 246 / 0.25);
}
.cover-drag-overlay--title {
border-color: rgb(52 211 153 / 0.85);
background: rgb(16 185 129 / 0.12);
}
.cover-drag-overlay--subtitle {
border-color: rgb(250 204 21 / 0.85);
background: rgb(234 179 8 / 0.1);
}
.cover-drag-overlay--active {
box-shadow:
0 0 0 2px rgb(255 255 255 / 0.5),
0 0 0 4px rgb(59 130 246 / 0.45);
}
</style>

View File

@@ -0,0 +1,221 @@
<script setup>
import { computed } from "vue";
import { isAdvancedCoverConfig } from "../../config/coverTemplates.js";
const props = defineProps({
template: { type: Object, required: true },
isSelected: { type: Boolean, default: false },
isSystem: { type: Boolean, default: false },
});
const emit = defineEmits(["select", "edit", "clone", "export", "delete"]);
const cfg = computed(() => props.template?.config || {});
const previewStyle = computed(() => {
const c = cfg.value;
const pos = c.titlePosition || "bottom";
const align =
pos === "top" ? "flex-start" : pos === "center" ? "center" : "flex-end";
const accent = c.titleFontColor || c.titleColor || "#ffffff";
const advanced = isAdvancedCoverConfig(c);
return {
justifyContent: align,
background: advanced
? "linear-gradient(160deg, #1e293b 0%, #334155 45%, #0f172a 100%)"
: "linear-gradient(180deg, #475569 0%, #1e293b 55%, #0f172a 100%)",
"--title-color": accent,
};
});
const titleSample = computed(() => {
const c = cfg.value;
if (c.extractPerson) return "人物封面";
if (c.titleStrokeWidth) return "描边标题";
return "封面标题";
});
const metaLine = computed(() => {
const c = cfg.value;
const parts = [];
if (c.extractPerson) parts.push("抠图");
if (c.backgroundBlurEnabled || c.blurBackground) parts.push("虚化");
if (c.titleStrokeWidth) parts.push("描边");
if (!parts.length) parts.push("快速");
return parts.join(" · ");
});
function onThumbError(event) {
event.target.style.display = "none";
}
</script>
<template>
<div
class="cover-template-item"
:class="{ selected: isSelected }"
role="button"
tabindex="0"
@click="emit('select', template)"
@keydown.enter="emit('select', template)"
>
<div class="template-thumbnail">
<img
v-if="template.thumbnailPath"
:src="template.thumbnailPath"
:alt="template.name"
@error="onThumbError"
/>
<div v-else class="placeholder" :style="previewStyle">
<span class="sample-title">{{ titleSample }}</span>
<span class="sample-meta">{{ metaLine }}</span>
</div>
</div>
<div class="template-details">
<div class="template-name">
{{ template.name }}
<span v-if="isSystem" class="system-tag">系统模板</span>
</div>
<div class="template-info">
<span v-if="template.description">{{ template.description }}</span>
<span v-if="template.createdAt">
创建: {{ new Date(template.createdAt).toLocaleDateString("zh-CN") }}
</span>
</div>
</div>
<div class="action-buttons" @click.stop>
<Button label="编辑" size="small" severity="secondary" rounded @click="emit('edit', template)" />
<Button label="复制" size="small" severity="secondary" rounded @click="emit('clone', template)" />
<Button label="导出" size="small" severity="secondary" rounded @click="emit('export', template)" />
<Button
v-if="!isSystem"
label="删除"
size="small"
severity="danger"
rounded
text
@click="emit('delete', template)"
/>
</div>
</div>
</template>
<style scoped>
.cover-template-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 2px solid rgb(51 65 85 / 0.6);
border-radius: 12px;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
min-width: 0;
}
.cover-template-item:hover {
border-color: rgb(96 165 250 / 0.7);
background-color: rgb(30 58 138 / 0.15);
}
.cover-template-item.selected {
border-color: rgb(59 130 246);
background-color: rgb(30 64 175 / 0.25);
}
.template-thumbnail {
width: 100%;
aspect-ratio: 9 / 16;
min-height: 200px;
max-height: min(46vh, 360px);
border-radius: 10px;
background-color: rgb(15 23 42);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.template-thumbnail img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 12% 8% 14%;
box-sizing: border-box;
}
.sample-title {
font-size: clamp(14px, 4vw, 20px);
font-weight: 700;
color: var(--title-color, #fff);
text-shadow: 0 2px 8px rgb(0 0 0 / 0.5);
text-align: center;
line-height: 1.3;
}
.sample-meta {
margin-top: 8px;
font-size: 11px;
color: rgb(148 163 184);
}
.template-details {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.template-name {
font-size: 13px;
font-weight: 600;
color: rgb(226 232 240);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
line-height: 1.35;
}
.system-tag {
color: rgb(250 204 21);
font-size: 10px;
background: rgb(250 204 21 / 0.12);
padding: 2px 6px;
border-radius: 999px;
white-space: nowrap;
}
.template-info {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: rgb(100 116 139);
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
}
.action-buttons :deep(.p-button) {
flex: 1;
min-width: 64px;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,100 @@
<script setup>
const layers = defineModel("layers", { type: Array, default: () => [] });
const presets = [
{
label: "经典三层",
layers: [
{ enabled: true, offsetX: -2, offsetY: -2, blur: 12, color: "#000000", opacity: 100 },
{ enabled: true, offsetX: 2, offsetY: 2, blur: 16, color: "#000000", opacity: 100 },
{ enabled: true, offsetX: 0, offsetY: 0, blur: 14, color: "#000000", opacity: 100 },
],
},
{
label: "柔和双层",
layers: [
{ enabled: true, offsetX: -1, offsetY: -1, blur: 4, color: "#FFFFFF", opacity: 60 },
{ enabled: true, offsetX: 2, offsetY: 2, blur: 2, color: "#000000", opacity: 65 },
],
},
];
function addLayer() {
layers.value = [
...(layers.value || []),
{ enabled: true, offsetX: 0, offsetY: 0, blur: 8, color: "#000000", opacity: 100 },
];
}
function removeLayer(index) {
layers.value = (layers.value || []).filter((_, i) => i !== index);
}
function applyPreset(preset) {
layers.value = preset.layers.map((l) => ({ ...l }));
}
</script>
<template>
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-medium text-slate-200">多层阴影</span>
<div class="flex flex-wrap gap-1">
<Button label="添加阴影层" size="small" @click="addLayer" />
<Button
v-for="p in presets"
:key="p.label"
:label="p.label"
size="small"
severity="secondary"
outlined
@click="applyPreset(p)"
/>
</div>
</div>
<div
v-for="(layer, index) in layers"
:key="index"
class="rounded-lg border border-slate-600/50 bg-slate-900/50 p-3"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-slate-300">阴影层 {{ index + 1 }}</span>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="layer.enabled" />
<Button
icon="pi pi-trash"
size="small"
severity="danger"
text
@click="removeLayer(index)"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="mb-0.5 block text-xs text-slate-500">X: {{ layer.offsetX }}px</label>
<input v-model.number="layer.offsetX" type="range" min="-20" max="20" step="1" class="w-full" />
</div>
<div>
<label class="mb-0.5 block text-xs text-slate-500">Y: {{ layer.offsetY }}px</label>
<input v-model.number="layer.offsetY" type="range" min="-20" max="20" step="1" class="w-full" />
</div>
</div>
<div class="mt-2">
<label class="mb-0.5 block text-xs text-slate-500">模糊: {{ layer.blur }}px</label>
<input v-model.number="layer.blur" type="range" min="0" max="30" step="1" class="w-full" />
</div>
<div class="mt-2 grid grid-cols-2 gap-2">
<div>
<label class="mb-0.5 block text-xs text-slate-500">颜色</label>
<input v-model="layer.color" type="color" class="h-7 w-full cursor-pointer rounded border border-slate-600" />
</div>
<div>
<label class="mb-0.5 block text-xs text-slate-500">透明度: {{ layer.opacity }}%</label>
<input v-model.number="layer.opacity" type="range" min="0" max="100" step="5" class="w-full" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,455 @@
<script setup>
import { computed, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import CoverPreviewCard from "./CoverPreviewCard.vue";
import CoverTemplateEditor from "./CoverTemplateEditor.vue";
import {
cloneCoverTemplate,
createBlankCoverTemplate,
downloadCoverTemplatesJson,
getCoverTemplateById,
isSystemCoverTemplateId,
loadAllCoverTemplates,
loadCustomCoverTemplates,
parseImportedCoverTemplates,
saveCustomCoverTemplates,
upsertCustomCoverTemplate,
} from "../../services/coverTemplateCatalog.js";
import { createCoverEditDraft } from "../../utils/coverTemplateDraft.js";
import { chooseCoverMaterial } from "../../services/coverGenerate.js";
const visible = defineModel("visible", { type: Boolean, default: false });
const props = defineProps({
selectedId: { type: String, default: "" },
});
const emit = defineEmits(["apply", "update:selectedId"]);
const workflow = useWorkflowStore();
const { coverCustomVideoPath, titleGenerated } = storeToRefs(workflow);
const templates = ref([]);
const loading = ref(false);
const applying = ref(false);
const feedback = ref({ severity: "", message: "" });
const fileInputRef = ref(null);
const pendingSelectedId = ref("");
const editMode = ref(false);
const editingDraft = ref(null);
const selectedTemplateId = computed({
get: () => pendingSelectedId.value || props.selectedId,
set: (id) => {
pendingSelectedId.value = id;
emit("update:selectedId", id);
},
});
const materialFileName = computed(() => {
const p = String(coverCustomVideoPath.value || "").trim();
if (!p) return "";
const parts = p.replace(/\\/g, "/").split("/");
return parts[parts.length - 1] || p;
});
function showMsg(severity, message) {
feedback.value = { severity, message };
}
async function refreshTemplates() {
loading.value = true;
feedback.value = { severity: "", message: "" };
try {
templates.value = loadAllCoverTemplates();
if (!pendingSelectedId.value && props.selectedId) {
pendingSelectedId.value = props.selectedId;
}
if (!selectedTemplateId.value && templates.value.length) {
selectedTemplateId.value = templates.value[0].id;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showMsg("error", `加载模板失败:${msg}`);
templates.value = [];
} finally {
loading.value = false;
}
}
watch(visible, (open) => {
if (open) {
editMode.value = false;
editingDraft.value = null;
pendingSelectedId.value = props.selectedId || workflow.coverTemplateId || "default";
refreshTemplates();
}
});
function selectTemplate(template) {
selectedTemplateId.value = template.id;
}
async function onPickMaterial() {
feedback.value = { severity: "", message: "" };
const result = await chooseCoverMaterial(workflow);
if (result?.message) showMsg(result.ok ? "success" : "error", result.message);
}
function onClearMaterial() {
workflow.coverCustomVideoPath = "";
showMsg("success", "已清除自定义素材");
}
function onExportAll() {
downloadCoverTemplatesJson(templates.value, "cover-templates-all.json");
showMsg("success", `已导出全部模板(共 ${templates.value.length} 个)`);
}
function onExportOne(template) {
downloadCoverTemplatesJson(
[template],
`cover-template-${(template.name || template.id).replace(/[\s/\\:*?"<>|]/g, "_")}.json`,
);
showMsg("success", `已导出「${template.name}`);
}
function onImportClick() {
fileInputRef.value?.click();
}
async function onImportFile(event) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const imported = await parseImportedCoverTemplates(file);
const custom = loadCustomCoverTemplates();
const merged = [...custom];
for (const item of imported) {
const idx = merged.findIndex((t) => t.id === item.id);
if (idx >= 0) merged[idx] = item;
else merged.push(item);
}
saveCustomCoverTemplates(merged);
await refreshTemplates();
showMsg("success", `已导入 ${imported.length} 个模板`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showMsg("error", `导入失败:${msg}`);
}
}
function onNewTemplate() {
enterEditMode(createBlankCoverTemplate(), true);
}
function onClone(template) {
const created = cloneCoverTemplate(template);
enterEditMode(created, true);
showMsg("success", `已复制为「${created.name}」,请编辑后保存`);
}
function onDelete(template) {
if (isSystemCoverTemplateId(template.id)) {
showMsg("error", "系统模板不可删除");
return;
}
if (!window.confirm(`确定删除模板「${template.name}」?`)) return;
const custom = loadCustomCoverTemplates().filter((t) => t.id !== template.id);
saveCustomCoverTemplates(custom);
if (selectedTemplateId.value === template.id) {
selectedTemplateId.value = "default";
}
refreshTemplates();
showMsg("success", "模板已删除");
}
function enterEditMode(template, isNew = false) {
const base = isNew ? template : JSON.parse(JSON.stringify(template));
editingDraft.value = createCoverEditDraft(base, titleGenerated.value || "");
if (isNew && editingDraft.value) {
editingDraft.value.id = base.id || `cover_custom_${Date.now()}`;
}
editMode.value = true;
feedback.value = { severity: "", message: "" };
}
function onEdit(template) {
if (isSystemCoverTemplateId(template.id)) {
const cloned = cloneCoverTemplate(template);
enterEditMode(cloned, true);
showMsg("success", "系统模板已复制为可编辑副本,保存后将加入自定义模板");
return;
}
enterEditMode(template);
}
function cancelEdit() {
editMode.value = false;
editingDraft.value = null;
}
function confirmSaveTemplate() {
const name = String(editingDraft.value?.name || "").trim();
if (!name) {
showMsg("error", "请输入模板名称");
return;
}
const saved = upsertCustomCoverTemplate({
...editingDraft.value,
name,
config: { ...editingDraft.value.config },
});
selectedTemplateId.value = saved.id;
editMode.value = false;
editingDraft.value = null;
refreshTemplates();
showMsg("success", `模板「${saved.name}」已保存`);
}
async function onApply() {
if (!selectedTemplateId.value) {
showMsg("error", "请选择一个模板");
return;
}
applying.value = true;
try {
const tpl = getCoverTemplateById(selectedTemplateId.value);
if (!tpl) {
showMsg("error", "获取模板失败");
return;
}
workflow.coverTemplateId = tpl.id;
workflow.coverTemplate = JSON.parse(JSON.stringify(tpl));
emit("apply", tpl);
visible.value = false;
showMsg("success", "封面配置已应用");
} finally {
applying.value = false;
}
}
function closeDialog() {
visible.value = false;
}
</script>
<template>
<Dialog
v-model:visible="visible"
header="封面设置"
modal
:style="{ width: editMode ? 'min(1280px, 98vw)' : 'min(1120px, 96vw)' }"
:content-style="{ padding: 0 }"
:closable="!applying"
>
<div class="cover-settings-container">
<template v-if="!editMode">
<div class="control-bar">
<div class="control-left">
<h3 class="control-title">素材和模板</h3>
</div>
<div class="control-actions">
<Button
label="选择素材文件"
size="small"
icon="pi pi-upload"
@click="onPickMaterial"
/>
<Button
v-if="coverCustomVideoPath"
label="清除"
size="small"
severity="danger"
outlined
icon="pi pi-times"
@click="onClearMaterial"
/>
<span class="divider" aria-hidden="true" />
<Button
label="导出全部"
size="small"
severity="secondary"
icon="pi pi-download"
:disabled="!templates.length"
@click="onExportAll"
/>
<Button
label="导入模板"
size="small"
outlined
icon="pi pi-upload"
@click="onImportClick"
/>
<Button
label="创建新模板"
size="small"
icon="pi pi-plus"
@click="onNewTemplate"
/>
</div>
</div>
<p v-if="materialFileName" class="selected-file">
已选择素材: {{ materialFileName }}
</p>
<p
v-if="feedback.message && !editMode"
class="mx-4 text-sm"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<div class="section">
<div v-if="loading" class="py-12 text-center text-sm text-slate-400">
正在加载封面模板
</div>
<div v-else-if="templates.length" class="template-list">
<CoverPreviewCard
v-for="tpl in templates"
:key="tpl.id"
:template="tpl"
:is-selected="selectedTemplateId === tpl.id"
:is-system="tpl.is_system === 1 || tpl.isSystem || isSystemCoverTemplateId(tpl.id)"
@select="selectTemplate"
@edit="onEdit"
@clone="onClone"
@export="onExportOne"
@delete="onDelete"
/>
</div>
<div v-else class="empty-state">
<p class="mb-3 text-slate-400">暂无模板</p>
<Button label="创建第一个模板" size="small" @click="onNewTemplate" />
</div>
</div>
<div class="dialog-footer">
<Button label="取消" size="small" severity="secondary" text @click="closeDialog" />
<Button
label="应用配置"
size="small"
icon="pi pi-check"
:loading="applying"
@click="onApply"
/>
</div>
</template>
<template v-else>
<div class="min-h-0 flex-1 overflow-hidden px-2 py-2">
<CoverTemplateEditor
v-model:draft="editingDraft"
:reference-text="titleGenerated || ''"
@save="confirmSaveTemplate"
@cancel="cancelEdit"
/>
</div>
</template>
</div>
<input
ref="fileInputRef"
type="file"
accept="application/json,.json"
class="hidden"
@change="onImportFile"
/>
</Dialog>
</template>
<style scoped>
.cover-settings-container {
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(92vh, 900px);
overflow: hidden;
}
.control-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px 8px;
border-bottom: 1px solid rgb(51 65 85 / 0.5);
flex-shrink: 0;
flex-wrap: wrap;
}
.control-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: rgb(226 232 240);
}
.control-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.divider {
width: 1px;
height: 20px;
background: rgb(71 85 105);
margin: 0 2px;
}
.selected-file {
padding: 0 16px;
font-size: 12px;
color: rgb(52 211 153);
flex-shrink: 0;
}
.section {
flex: 1;
overflow: hidden;
padding: 0 16px 8px;
min-height: 0;
}
.template-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
max-height: min(62vh, 560px);
overflow-y: auto;
padding-right: 6px;
align-content: start;
}
.template-list::-webkit-scrollbar {
width: 6px;
}
.template-list::-webkit-scrollbar-thumb {
background: rgb(71 85 105);
border-radius: 3px;
}
.empty-state {
padding: 48px 16px;
text-align: center;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 16px 14px;
border-top: 1px solid rgb(51 65 85 / 0.5);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,546 @@
<script setup>
import { computed, watch } from "vue";
import CoverCollapsibleSection from "./CoverCollapsibleSection.vue";
import CoverShadowLayersEditor from "./CoverShadowLayersEditor.vue";
import CoverEditorPreview from "./CoverEditorPreview.vue";
import { COVER_FONT_OPTIONS } from "../../config/coverTemplateDefaults.js";
import { syncCoverTextsFromReference } from "../../utils/coverTemplateDraft.js";
const draft = defineModel("draft", { type: Object, required: true });
const props = defineProps({
referenceText: { type: String, default: "" },
});
const emit = defineEmits(["save", "cancel"]);
const config = computed({
get: () => draft.value?.config || {},
set: (val) => {
if (draft.value) draft.value.config = val;
},
});
watch(
() => [
props.referenceText,
config.value.autoSplitText,
config.value.titleMaxLength,
config.value.subtitleMaxLength,
],
() => {
if (draft.value) syncCoverTextsFromReference(draft.value, props.referenceText);
},
{ immediate: true },
);
function updatePosition(key, pos) {
if (!draft.value) return;
const next = { ...draft.value.config };
next[key] = { x: pos.x, y: pos.y };
draft.value.config = next;
}
function onSave() {
emit("save");
}
function onCancel() {
emit("cancel");
}
</script>
<template>
<div v-if="draft" class="flex h-[80vh] gap-4 overflow-hidden">
<!-- 左侧设置 -->
<div class="w-80 shrink-0 overflow-y-auto pr-1">
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium text-slate-200">模板名称</label>
<InputText v-model="draft.name" class="w-full" placeholder="输入模板名称" />
</div>
<CoverCollapsibleSection title="基础设置">
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-sm text-slate-300">背景模糊</label>
<ToggleSwitch v-model="config.backgroundBlurEnabled" />
</div>
<div v-if="config.backgroundBlurEnabled">
<label class="mb-1 block text-xs text-slate-400">
模糊度: {{ config.backgroundBlurIntensity }}
</label>
<input
v-model.number="config.backgroundBlurIntensity"
type="range"
min="1"
max="100"
step="1"
class="w-full"
/>
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-sm text-slate-300">人物描边</label>
<ToggleSwitch v-model="config.personBorderEnabled" />
</div>
<div v-if="config.personBorderEnabled" class="space-y-2">
<div>
<label class="mb-1 block text-xs text-slate-400">描边样式</label>
<div class="flex gap-2">
<Button
label="实线"
size="small"
:severity="config.personBorderStyle === 'solid' ? 'primary' : 'secondary'"
:outlined="config.personBorderStyle !== 'solid'"
@click="config.personBorderStyle = 'solid'"
/>
<Button
label="虚线"
size="small"
:severity="config.personBorderStyle === 'dashed' ? 'primary' : 'secondary'"
:outlined="config.personBorderStyle !== 'dashed'"
@click="config.personBorderStyle = 'dashed'"
/>
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">描边颜色</label>
<div class="flex gap-2">
<input
v-model="config.personBorderColor"
type="color"
class="h-8 w-12 cursor-pointer rounded border border-slate-600"
/>
<InputText v-model="config.personBorderColor" class="flex-1 text-sm" />
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">
描边粗细: {{ config.personBorderWidth }}px
</label>
<input
v-model.number="config.personBorderWidth"
type="range"
min="2"
max="200"
step="2"
class="w-full"
/>
</div>
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-sm text-slate-300">文字自动拆分</label>
<ToggleSwitch v-model="config.autoSplitText" />
</div>
<div v-if="config.autoSplitText" class="space-y-2">
<div>
<label class="mb-1 block text-xs text-slate-400">
主标题最大字数: {{ config.titleMaxLength }}
</label>
<input
v-model.number="config.titleMaxLength"
type="range"
min="1"
max="20"
step="1"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">
副标题最大字数: {{ config.subtitleMaxLength }}
</label>
<input
v-model.number="config.subtitleMaxLength"
type="range"
min="1"
max="20"
step="1"
class="w-full"
/>
</div>
</div>
</div>
</CoverCollapsibleSection>
<CoverCollapsibleSection title="人像设置">
<div>
<label class="mb-1 block text-xs text-slate-400">大小: {{ config.personSize }}%</label>
<input
v-model.number="config.personSize"
type="range"
min="10"
max="100"
step="5"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">位置</label>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-slate-500">X: {{ config.personPosition?.x }}%</label>
<InputNumber
v-model="config.personPosition.x"
:min="0"
:max="100"
class="w-full"
/>
</div>
<div>
<label class="text-xs text-slate-500">Y: {{ config.personPosition?.y }}%</label>
<InputNumber
v-model="config.personPosition.y"
:min="0"
:max="100"
class="w-full"
/>
</div>
</div>
</div>
</CoverCollapsibleSection>
<CoverCollapsibleSection title="背景设置" :header-extra="true">
<template #header-extra>
<div class="flex items-center gap-2" @click.stop>
<span class="text-xs text-slate-400">
{{ config.backgroundEnabled ? "已开启" : "已关闭" }}
</span>
<ToggleSwitch v-model="config.backgroundEnabled" />
</div>
</template>
<div v-if="config.backgroundEnabled" class="space-y-2">
<div>
<label class="mb-1 block text-xs text-slate-400">背景缩放: {{ config.backgroundSize }}%</label>
<input
v-model.number="config.backgroundSize"
type="range"
min="50"
max="150"
step="5"
class="w-full"
/>
</div>
</div>
</CoverCollapsibleSection>
<CoverCollapsibleSection title="主标题" :default-open="true">
<div>
<label class="mb-1 block text-sm text-slate-300">文字内容</label>
<div
class="flex min-h-10 items-center rounded border border-slate-600 bg-slate-900/50 px-3 py-2 text-sm text-slate-300"
>
{{ config.titleText || "(暂无,请在步骤 04 生成标题)" }}
</div>
<p class="mt-1 text-xs text-slate-500">
在步骤 04 设置文案此处显示拆分结果
</p>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">字体</label>
<Select
v-model="config.titleFontFamily"
:options="COVER_FONT_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">字号: {{ config.titleFontSize }}px</label>
<input
v-model.number="config.titleFontSize"
type="range"
min="20"
max="500"
step="5"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">字重: {{ config.titleFontWeight }}</label>
<input
v-model.number="config.titleFontWeight"
type="range"
min="100"
max="1000"
step="50"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">文字方向</label>
<div class="flex gap-4 text-sm text-slate-300">
<label class="flex items-center gap-1">
<RadioButton v-model="config.titleDirection" input-id="title-h" value="horizontal" />
<span for="title-h">横排</span>
</label>
<label class="flex items-center gap-1">
<RadioButton v-model="config.titleDirection" input-id="title-v" value="vertical" />
<span for="title-v">竖排</span>
</label>
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">
每行字数: {{ config.titleMaxCharsPerLine }}
</label>
<input
v-model.number="config.titleMaxCharsPerLine"
type="range"
min="1"
max="50"
step="1"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">
字符间距: {{ config.titleCharSpacing }}px
</label>
<input
v-model.number="config.titleCharSpacing"
type="range"
min="-50"
max="100"
step="1"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">
行间距: {{ config.titleLineSpacing }}px
</label>
<input
v-model.number="config.titleLineSpacing"
type="range"
min="0"
max="300"
step="1"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">颜色</label>
<input v-model="config.titleColor" type="color" class="h-8 w-full cursor-pointer rounded border border-slate-600" />
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">描边颜色</label>
<input v-model="config.titleStrokeColor" type="color" class="h-8 w-full cursor-pointer rounded border border-slate-600" />
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">
描边粗细: {{ config.titleStrokeWidth }}px
</label>
<input
v-model.number="config.titleStrokeWidth"
type="range"
min="0"
max="10"
step="1"
class="w-full"
/>
</div>
<CoverShadowLayersEditor v-model:layers="config.titleShadowLayers" />
<div>
<label class="mb-1 block text-xs text-slate-400">位置</label>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-slate-500">X: {{ config.titlePosition?.x }}%</label>
<InputNumber v-model="config.titlePosition.x" :min="0" :max="100" class="w-full" />
</div>
<div>
<label class="text-xs text-slate-500">Y: {{ config.titlePosition?.y }}%</label>
<InputNumber v-model="config.titlePosition.y" :min="0" :max="100" class="w-full" />
</div>
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">旋转角度: {{ config.titleRotation }}°</label>
<input
v-model.number="config.titleRotation"
type="range"
min="0"
max="360"
step="1"
class="w-full"
/>
</div>
<div class="border-t border-slate-600/50 pt-3">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm text-slate-300">文字背景</span>
<ToggleSwitch v-model="config.titleBackgroundEnabled" />
</div>
<div v-if="config.titleBackgroundEnabled" class="space-y-2">
<input v-model="config.titleBackgroundColor" type="color" class="h-8 w-full rounded border border-slate-600" />
<label class="text-xs text-slate-400">不透明度: {{ config.titleBackgroundOpacity }}%</label>
<input
v-model.number="config.titleBackgroundOpacity"
type="range"
min="0"
max="100"
step="5"
class="w-full"
/>
</div>
</div>
</CoverCollapsibleSection>
<CoverCollapsibleSection title="副标题" :default-open="false">
<div>
<label class="mb-1 block text-sm text-slate-300">文字内容</label>
<div
class="rounded border border-slate-600 bg-slate-900/50 px-3 py-2 text-sm text-slate-300"
>
{{ config.subtitleText || "(暂无)" }}
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">字体</label>
<Select
v-model="config.subtitleFontFamily"
:options="COVER_FONT_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">字号: {{ config.subtitleFontSize }}px</label>
<input
v-model.number="config.subtitleFontSize"
type="range"
min="12"
max="400"
step="5"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">字重: {{ config.subtitleFontWeight }}</label>
<input
v-model.number="config.subtitleFontWeight"
type="range"
min="100"
max="1000"
step="50"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">文字方向</label>
<div class="flex gap-4 text-sm text-slate-300">
<label class="flex items-center gap-1">
<RadioButton v-model="config.subtitleDirection" input-id="sub-h" value="horizontal" />
横排
</label>
<label class="flex items-center gap-1">
<RadioButton v-model="config.subtitleDirection" input-id="sub-v" value="vertical" />
竖排
</label>
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">
每行字数: {{ config.subtitleMaxCharsPerLine }}
</label>
<input
v-model.number="config.subtitleMaxCharsPerLine"
type="range"
min="1"
max="50"
step="1"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">颜色</label>
<input v-model="config.subtitleColor" type="color" class="h-8 w-full rounded border border-slate-600" />
</div>
<CoverShadowLayersEditor v-model:layers="config.subtitleShadowLayers" />
<div>
<label class="mb-1 block text-xs text-slate-400">位置</label>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-slate-500">X: {{ config.subtitlePosition?.x }}%</label>
<InputNumber v-model="config.subtitlePosition.x" :min="0" :max="100" class="w-full" />
</div>
<div>
<label class="text-xs text-slate-500">Y: {{ config.subtitlePosition?.y }}%</label>
<InputNumber v-model="config.subtitlePosition.y" :min="0" :max="100" class="w-full" />
</div>
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">旋转: {{ config.subtitleRotation }}°</label>
<input
v-model.number="config.subtitleRotation"
type="range"
min="0"
max="360"
step="1"
class="w-full"
/>
</div>
</CoverCollapsibleSection>
<CoverCollapsibleSection title="蒙版" :header-extra="true" :default-open="false">
<template #header-extra>
<ToggleSwitch v-model="config.maskEnabled" @click.stop />
</template>
<p v-if="!config.maskEnabled" class="text-xs text-slate-500">开启后可配置蒙版生成时生效</p>
<div v-else class="space-y-2">
<div>
<label class="mb-1 block text-xs text-slate-400">大小: {{ config.maskSize }}%</label>
<input v-model.number="config.maskSize" type="range" min="10" max="200" class="w-full" />
</div>
</div>
</CoverCollapsibleSection>
</div>
</div>
<!-- 右侧预览 -->
<div class="flex min-w-0 flex-1 flex-col">
<CoverEditorPreview
:config="config"
@update-person-position="updatePosition('personPosition', $event)"
@update-title-position="updatePosition('titlePosition', $event)"
@update-subtitle-position="updatePosition('subtitlePosition', $event)"
>
<template #actions>
<Button label="取消" severity="secondary" outlined @click="onCancel" />
<Button label="保存模板" icon="pi pi-save" @click="onSave" />
</template>
</CoverEditorPreview>
</div>
</div>
</template>

View File

@@ -95,7 +95,7 @@ async function onGenerateTitleTags() {
<div class="dashboard-field-label mb-1 flex items-center justify-between gap-2">
<span>生成的标题可编辑</span>
<span v-if="titleLineCount > 1" class="text-xs font-normal text-slate-500">
{{ titleLineCount }} 条建议可选用其一或自行修改
{{ titleLineCount }} 条建议封面/发布取首条超过 5 条时随机取一行
</span>
</div>
<Textarea

View File

@@ -2,7 +2,7 @@
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import { COVER_TEMPLATES } from "../../config/coverTemplates.js";
import CoverTemplateDialog from "../cover/CoverTemplateDialog.vue";
const workflow = useWorkflowStore();
const {
@@ -17,11 +17,6 @@ const {
const feedback = ref({ severity: "", message: "" });
const settingsVisible = ref(false);
const templateOptions = COVER_TEMPLATES.map((t) => ({
label: t.label,
value: t.id,
}));
const hasSourceVideo = computed(
() => Boolean(processedVideoPath.value || generatedVideoPath.value),
);
@@ -45,17 +40,10 @@ async function onGenerateCover() {
showFeedback(await workflow.generateCover());
}
async function onPickMaterial() {
feedback.value = { severity: "", message: "" };
const result = await workflow.chooseCoverMaterial();
if (result?.message) showFeedback(result);
}
function applySettings() {
settingsVisible.value = false;
function onSettingsApplied() {
feedback.value = {
severity: "success",
message: "封面设置已保存,可点击「自动生成封面」",
message: "封面设置已应用,可点击「自动生成封面」",
};
}
</script>
@@ -78,6 +66,19 @@ function applySettings() {
@click="settingsVisible = true"
/>
<p
v-if="workflow.coverTemplate?.name || workflow.coverTemplateLabel"
class="mt-2 truncate text-xs text-slate-400"
>
当前模板{{ workflow.coverTemplate?.name || workflow.coverTemplateLabel }}
</p>
<p
v-if="workflow.coverMaterialFileName"
class="mt-0.5 truncate text-xs text-emerald-400/90"
>
素材{{ workflow.coverMaterialFileName }}
</p>
<p
v-if="!hasSourceVideo"
class="mt-2 text-xs text-amber-400/90"
@@ -112,67 +113,10 @@ function applySettings() {
</div>
</div>
<Dialog
<CoverTemplateDialog
v-model:visible="settingsVisible"
header="封面设置"
modal
:style="{ width: '24rem' }"
>
<div class="flex flex-col gap-3">
<div>
<div class="dashboard-field-label mb-2">样式模板</div>
<div class="flex flex-col gap-2">
<div
v-for="opt in templateOptions"
:key="opt.value"
class="flex items-center gap-2"
>
<RadioButton
v-model="coverTemplateId"
:input-id="`cover-tpl-${opt.value}`"
:value="opt.value"
v-model:selected-id="coverTemplateId"
@apply="onSettingsApplied"
/>
<label :for="`cover-tpl-${opt.value}`" class="cursor-pointer text-sm">
{{ opt.label }}
</label>
</div>
</div>
</div>
<div>
<div class="dashboard-field-label mb-1">封面素材视频可选</div>
<p class="dashboard-muted mb-2 text-xs">
不选择时从当前口播视频第 3 秒抽帧可上传其他视频作为封面底图
虚化/抠图类模板需 bundled Python 运行时首次较慢
</p>
<div class="flex flex-wrap gap-2">
<Button
label="选择视频"
size="small"
outlined
:disabled="coverGenerating"
@click="onPickMaterial"
/>
<Button
v-if="workflow.coverCustomVideoPath"
label="清除"
size="small"
severity="secondary"
text
@click="workflow.coverCustomVideoPath = ''"
/>
</div>
<p
v-if="workflow.coverMaterialFileName"
class="mt-1 truncate text-xs text-slate-400"
>
{{ workflow.coverMaterialFileName }}
</p>
</div>
</div>
<template #footer>
<Button label="确定" size="small" @click="applySettings" />
</template>
</Dialog>
</DashboardCard>
</template>

View File

@@ -0,0 +1,168 @@
/** 与 Electron CoverCustomEditor 默认配置对齐advanced_cover_generator.py */
export const COVER_CANVAS_WIDTH = 1080;
export const COVER_CANVAS_HEIGHT = 1440;
export const COVER_PREVIEW_SCALE = 0.4;
export const COVER_FONT_OPTIONS = [
{ label: "黑体", value: "SimHei" },
{ label: "宋体", value: "SimSun" },
{ label: "微软雅黑", value: "Microsoft YaHei" },
{ label: "Arial", value: "Arial" },
{ label: "Times New Roman", value: "Times New Roman" },
];
/**
* @param {Record<string, unknown>} [overrides]
*/
export function createDefaultCoverTemplateConfig(overrides = {}) {
return {
backgroundEnabled: true,
backgroundBlurEnabled: true,
backgroundBlurIntensity: 44,
blurBackground: true,
extractPerson: true,
personBorderEnabled: true,
personBorderStyle: "solid",
personBorderColor: "#FFFFFF",
personBorderWidth: 8,
personBorderDashLength: null,
personBorderGapLength: null,
personSize: 50,
personPosition: { x: 54.6, y: 34.2 },
personRotation: 0,
backgroundSize: 100,
backgroundPosition: { x: 50, y: 50 },
autoSplitText: true,
titleMaxLength: 14,
subtitleMaxLength: 16,
titleText: "",
titleFontFamily: "Microsoft YaHei",
titleFontSize: 110,
titleFontWeight: 100,
titleColor: "#FFFFFF",
titleStrokeColor: "#000000",
titleStrokeWidth: 0,
titleShadowColor: "#000000",
titleShadowOffsetX: 0,
titleShadowOffsetY: 0,
titleShadowBlur: 0,
titleShadowEnabled: false,
titleShadowLayers: [
{ enabled: true, offsetX: -2, offsetY: -2, blur: 12, color: "#000000", opacity: 100 },
{ enabled: true, offsetX: 2, offsetY: 2, blur: 16, color: "#000000", opacity: 100 },
{ enabled: true, offsetX: 0, offsetY: 0, blur: 14, color: "#000000", opacity: 100 },
],
titlePosition: { x: 39.8, y: 69.4 },
titleRotation: 0,
titleDirection: "horizontal",
titleCharSpacing: 12,
titleLineSpacing: 118,
titleMaxCharsPerLine: 10,
titleBackgroundEnabled: false,
titleBackgroundColor: "#000000",
titleBackgroundOpacity: 70,
titleBackgroundShape: "rectangle",
titleBackgroundSize: { width: 30, height: 10 },
titleBackgroundPosition: { x: 50, y: 30 },
titleBackgroundPoints: [],
titleBackgroundRadius: 10,
titleBackgroundRotation: 0,
subtitleText: "",
subtitleFontFamily: "Microsoft YaHei",
subtitleFontSize: 117,
subtitleFontWeight: 500,
subtitleColor: "#EAF273",
subtitleStrokeColor: "#000000",
subtitleStrokeWidth: 0,
subtitleShadowColor: "#000000",
subtitleShadowOffsetX: 0,
subtitleShadowOffsetY: 0,
subtitleShadowBlur: 0,
subtitleShadowEnabled: false,
subtitleShadowLayers: [
{ enabled: true, offsetX: -2, offsetY: -2, blur: 2, color: "#FFFFFF", opacity: 60 },
{ enabled: true, offsetX: 2, offsetY: 2, blur: 0, color: "#000000", opacity: 65 },
{ enabled: true, offsetX: 0, offsetY: 0, blur: 4, color: "#000000", opacity: 50 },
],
subtitlePosition: { x: 58.8, y: 77.8 },
subtitleRotation: 0,
subtitleDirection: "horizontal",
subtitleCharSpacing: 12,
subtitleLineSpacing: 72,
subtitleMaxCharsPerLine: 15,
subtitleBackgroundEnabled: false,
subtitleBackgroundColor: "#000000",
subtitleBackgroundOpacity: 70,
subtitleBackgroundShape: "rectangle",
subtitleBackgroundSize: { width: 30, height: 10 },
subtitleBackgroundPosition: { x: 50, y: 60 },
subtitleBackgroundPoints: [],
subtitleBackgroundRadius: 10,
subtitleBackgroundRotation: 0,
maskEnabled: false,
maskImagePath: undefined,
maskSize: 100,
maskPosition: { x: 50, y: 50 },
maskColor: undefined,
maskOpacity: 100,
maskShape: "rectangle",
...overrides,
};
}
/**
* 简易预设 → 高级编辑器配置
* @param {Record<string, unknown>} simple
*/
export function migrateSimpleCoverConfig(simple) {
if (!simple || typeof simple !== "object") {
return createDefaultCoverTemplateConfig();
}
if (simple.titlePosition && typeof simple.titlePosition === "object") {
return createDefaultCoverTemplateConfig({ ...simple });
}
const pos = String(simple.titlePosition || "bottom");
const titleY = pos === "top" ? 22 : pos === "center" ? 50 : 80;
const base = createDefaultCoverTemplateConfig({
backgroundBlurEnabled: Boolean(simple.backgroundBlurEnabled ?? simple.blurBackground),
blurBackground: Boolean(simple.backgroundBlurEnabled ?? simple.blurBackground),
extractPerson: Boolean(simple.extractPerson),
personBorderEnabled: Boolean(simple.personOutlineColor || simple.personOutlineWidth),
personBorderColor: simple.personOutlineColor || "#FFD700",
personBorderWidth: simple.personOutlineWidth ?? 8,
personSize: simple.personSize ?? 80,
titleFontSize: simple.titleFontSize ?? 110,
titleColor: simple.titleFontColor || simple.titleColor || "#FFFFFF",
titleFontFamily: simple.titleFontFamily || "Microsoft YaHei",
titleStrokeWidth: simple.titleStrokeWidth ?? 0,
titleStrokeColor: simple.titleStrokeColor || "#000000",
titlePosition: { x: 50, y: titleY },
subtitlePosition: { x: 50, y: Math.min(titleY + 12, 92) },
});
return base;
}
/**
* @param {Record<string, unknown>} config
*/
export function isFullCoverTemplateConfig(config) {
return Boolean(
config?.titlePosition &&
typeof config.titlePosition === "object" &&
"x" in config.titlePosition,
);
}

View File

@@ -1,5 +1,7 @@
/** 封面样式预设(简易 ffmpeg + 高级 Python 模板) */
import { resolveStep4TitleForCover } from "../utils/coverTextSplit.js";
export const COVER_TEMPLATES = [
{
id: "default",
@@ -75,11 +77,15 @@ export function getCoverTemplate(templateId) {
*/
export function isAdvancedCoverConfig(config) {
if (!config || typeof config !== "object") return false;
if (config.titlePosition && typeof config.titlePosition === "object") {
return true;
}
return (
config.blurBackground !== undefined ||
config.extractPerson !== undefined ||
config.personOutlineColor !== undefined ||
config.personOutlineWidth !== undefined ||
config.personBorderEnabled !== undefined ||
config.maskImagePath !== undefined ||
Boolean(config.titleFontFamily) ||
config.titleBackgroundEnabled !== undefined ||
@@ -102,11 +108,8 @@ export function buildCoverEffectStyle(tpl, extra = {}) {
}
/**
* @param {string} titleGenerated
* @param {string} titleGenerated 步骤 04 标题(可多行建议)
*/
export function pickCoverTitleText(titleGenerated) {
const raw = String(titleGenerated || "").trim();
if (!raw) return "";
const line = raw.split(/\r?\n/)[0].trim();
return line.slice(0, 80);
return resolveStep4TitleForCover(titleGenerated);
}

View File

@@ -6,19 +6,45 @@ import {
getCoverTemplate,
pickCoverTitleText,
} from "../config/coverTemplates.js";
import { getCoverTemplateById } from "./coverTemplateCatalog.js";
import { normalizeCoverConfigForGenerator } from "../utils/coverTemplateDraft.js";
import { splitCoverReferenceText } from "../utils/coverTextSplit.js";
const COVER_SCRIPT = "cover_generate.js";
const MATERIAL_IMAGE_EXT = new Set(["jpg", "jpeg", "png", "bmp", "webp"]);
/**
* @param {string} filePath
*/
export function isCoverMaterialImage(filePath) {
const ext = String(filePath || "").split(".").pop()?.toLowerCase() || "";
return MATERIAL_IMAGE_EXT.has(ext);
}
/**
* @returns {Promise<string | null>}
*/
export async function pickCoverMaterialVideo() {
export async function pickCoverMaterialFile() {
const selected = await open({
multiple: false,
filters: [
{
name: "视频",
extensions: ["mp4", "mov", "mkv", "avi", "webm"],
name: "素材文件",
extensions: [
"mp4",
"avi",
"mov",
"mkv",
"webm",
"flv",
"wmv",
"jpg",
"jpeg",
"png",
"bmp",
"webp",
],
},
],
});
@@ -27,6 +53,9 @@ export async function pickCoverMaterialVideo() {
return String(selected);
}
/** @deprecated 使用 pickCoverMaterialFile */
export const pickCoverMaterialVideo = pickCoverMaterialFile;
/**
* @param {string} filePath
*/
@@ -53,8 +82,24 @@ export async function executeGenerateCover(store) {
return { ok: false, message: "请先在步骤 04 生成标题文字" };
}
const tpl = getCoverTemplate(store.coverTemplateId || "default");
const effectStyle = buildCoverEffectStyle(tpl, {
const stored = store.coverTemplate;
const catalogTpl = getCoverTemplateById(store.coverTemplateId || "default");
const presetTpl = getCoverTemplate(store.coverTemplateId || "default");
let rawStyle =
stored?.config && typeof stored.config === "object"
? { ...stored.config }
: catalogTpl?.config
? { ...catalogTpl.config }
: { ...presetTpl };
const split = splitCoverReferenceText(store.titleGenerated, {
autoSplit: rawStyle.autoSplitText !== false,
titleMaxLength: Number(rawStyle.titleMaxLength) || 14,
subtitleMaxLength: Number(rawStyle.subtitleMaxLength) || 16,
});
const effectStyle = normalizeCoverConfigForGenerator({
...rawStyle,
titleText: rawStyle.titleText || split.title || titleText,
subtitleText: rawStyle.subtitleText || split.subtitle,
customVideoPath: store.coverCustomVideoPath || "",
});
@@ -106,8 +151,14 @@ export async function executeGenerateCover(store) {
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
*/
export async function chooseCoverMaterial(store) {
const path = await pickCoverMaterialVideo();
const path = await pickCoverMaterialFile();
if (!path) return { ok: false, message: "" };
store.coverCustomVideoPath = path;
return { ok: true, message: "已选择封面素材视频" };
const isImage = isCoverMaterialImage(path);
return {
ok: true,
message: isImage
? "图片文件已选择,将直接用于模板生成"
: "视频文件已选择",
};
}

View File

@@ -0,0 +1,222 @@
import { COVER_TEMPLATES } from "../config/coverTemplates.js";
import {
createDefaultCoverTemplateConfig,
isFullCoverTemplateConfig,
migrateSimpleCoverConfig,
} from "../config/coverTemplateDefaults.js";
const STORAGE_KEY = "aiclient_custom_cover_templates";
/**
* @typedef {Object} CoverTemplateItem
* @property {string} id
* @property {string} name
* @property {string} [description]
* @property {Record<string, unknown>} config
* @property {boolean} [isSystem]
* @property {number} [is_system]
* @property {number} [createdAt]
* @property {number} [updatedAt]
* @property {string} [thumbnailPath]
*/
/**
* @param {typeof COVER_TEMPLATES[number]} tpl
*/
function systemTemplateFromPreset(tpl) {
const { id, label, ...simple } = tpl;
const config = ["advanced_blur", "advanced_outline", "pil_stroke"].includes(id)
? migrateSimpleCoverConfig(simple)
: simple;
return {
id,
name: label,
description: describeCoverPreset(tpl),
config,
isSystem: true,
is_system: 1,
createdAt: 0,
updatedAt: 0,
};
}
/**
* 新建空白高级模板
*/
export function createBlankCoverTemplate() {
const now = Date.now();
return {
id: `cover_custom_${now}`,
name: "新模板",
description: "",
config: createDefaultCoverTemplateConfig(),
isSystem: false,
is_system: 0,
createdAt: now,
updatedAt: now,
};
}
/**
* @param {typeof COVER_TEMPLATES[number]} tpl
*/
function describeCoverPreset(tpl) {
if (tpl.extractPerson) return "人物抠图与高级排版";
if (tpl.titleStrokeWidth) return "描边标题PIL";
if (tpl.titlePosition === "top") return "顶部标题ffmpeg 快速)";
if (tpl.titlePosition === "center") return "居中标题ffmpeg 快速)";
return "底部标题ffmpeg 快速)";
}
/**
* @returns {CoverTemplateItem[]}
*/
export function loadSystemCoverTemplates() {
return COVER_TEMPLATES.map(systemTemplateFromPreset);
}
/**
* @returns {CoverTemplateItem[]}
*/
export function loadCustomCoverTemplates() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/**
* @param {CoverTemplateItem[]} templates
*/
export function saveCustomCoverTemplates(templates) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
}
/**
* @returns {CoverTemplateItem[]}
*/
export function loadAllCoverTemplates() {
return [...loadSystemCoverTemplates(), ...loadCustomCoverTemplates()];
}
/**
* @param {string} id
* @returns {CoverTemplateItem | null}
*/
export function getCoverTemplateById(id) {
return loadAllCoverTemplates().find((t) => t.id === id) || null;
}
/**
* @param {string} id
*/
export function isSystemCoverTemplateId(id) {
return COVER_TEMPLATES.some((t) => t.id === id);
}
/**
* @param {CoverTemplateItem} template
* @returns {CoverTemplateItem}
*/
export function upsertCustomCoverTemplate(template) {
const custom = loadCustomCoverTemplates();
const now = Date.now();
const config = isFullCoverTemplateConfig(template.config)
? template.config
: migrateSimpleCoverConfig(template.config || {});
const item = {
...template,
config,
isSystem: false,
is_system: 0,
createdAt: template.createdAt || now,
updatedAt: now,
};
const idx = custom.findIndex((t) => t.id === item.id);
if (idx >= 0) custom[idx] = item;
else custom.unshift(item);
saveCustomCoverTemplates(custom);
return item;
}
/**
* @param {CoverTemplateItem} template
*/
export function cloneCoverTemplate(template) {
const now = Date.now();
return {
...JSON.parse(JSON.stringify(template)),
id: `cover_custom_${now}`,
name: `${template.name} 副本`,
isSystem: false,
is_system: 0,
createdAt: now,
updatedAt: now,
};
}
/**
* @param {CoverTemplateItem[]} templates
*/
export function downloadCoverTemplatesJson(
templates,
filename = "cover-templates-all.json",
) {
const blob = new Blob(
[
JSON.stringify(
{
version: "1.0.0",
exportedAt: new Date().toISOString(),
coverTemplates: templates,
},
null,
2,
),
],
{ type: "application/json" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* @param {File} file
* @returns {Promise<CoverTemplateItem[]>}
*/
export async function parseImportedCoverTemplates(file) {
const text = await file.text();
const data = JSON.parse(text);
const list = data.coverTemplates || data.templates || data;
if (!Array.isArray(list)) throw new Error("无效的模板文件格式");
return list.map((t, i) => ({
...t,
id: t.id || `cover_imported_${Date.now()}_${i}`,
isSystem: false,
is_system: 0,
createdAt: t.createdAt || Date.now(),
updatedAt: t.updatedAt || Date.now(),
}));
}
/**
* @param {number} [ts]
*/
export function formatCoverTemplateDate(ts) {
if (!ts) return "";
return new Date(ts).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}

View File

@@ -1,5 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { resolveStep4TitleForCover } from "../utils/coverTextSplit.js";
export const PUBLISH_PLATFORM_DEFS = [
{ key: "douyin", label: "抖音" },
{ key: "kuaishou", label: "快手" },
@@ -181,7 +183,7 @@ export async function executePublishVideo(store, { autoPublish = true } = {}) {
return { ok: false, message: "请先在步骤 06 生成封面" };
}
const title = String(store.titleGenerated || "").trim();
const title = resolveStep4TitleForCover(store.titleGenerated);
if (!title) {
return { ok: false, message: "请先在步骤 04 生成标题" };
}

View File

@@ -39,7 +39,7 @@ import {
chooseCoverMaterial,
executeGenerateCover,
} from "../services/coverGenerate.js";
import { COVER_TEMPLATES } from "../config/coverTemplates.js";
import { getCoverTemplateById } from "../services/coverTemplateCatalog.js";
import {
executeOneClickPipeline,
stopOneClickPipeline,
@@ -301,7 +301,10 @@ export const useWorkflowStore = defineStore("workflow", {
coverTemplateId: "default",
/** 封面素材视频(可选,覆盖口播视频抽帧 */
/** 当前选中的完整封面模板(封面设置弹窗应用后写入 */
coverTemplate: null,
/** 封面素材视频/图片(可选,覆盖口播视频抽帧) */
coverCustomVideoPath: "",
@@ -378,10 +381,9 @@ export const useWorkflowStore = defineStore("workflow", {
},
coverTemplateLabel: (state) => {
const tpl = COVER_TEMPLATES.find(
(t) => t.id === (state.coverTemplateId || "default"),
);
return tpl?.label || "未选择";
if (state.coverTemplate?.name) return state.coverTemplate.name;
const tpl = getCoverTemplateById(state.coverTemplateId || "default");
return tpl?.name || "未选择";
},
coverMaterialFileName: (state) => {

View File

@@ -0,0 +1,198 @@
/**
* 封面编辑器预览布局(对齐 Electron CoverCustomEditor + advanced_cover_generator.py
*/
import {
COVER_CANVAS_HEIGHT,
COVER_CANVAS_WIDTH,
COVER_PREVIEW_SCALE,
} from "../config/coverTemplateDefaults.js";
/**
* @param {string} text
* @param {number} maxPerLine
*/
export function splitCoverTitleLines(text, maxPerLine) {
const raw = String(text || "");
if (!raw || maxPerLine < 1) return [];
const lines = [];
for (let i = 0; i < raw.length; i += maxPerLine) {
lines.push(raw.slice(i, i + maxPerLine));
}
return lines;
}
/**
* @param {{ x: number, y: number }} pos
*/
function pctToPx(pos, canvasW, canvasH) {
return {
x: (Number(pos?.x ?? 50) / 100) * canvasW,
y: (Number(pos?.y ?? 50) / 100) * canvasH,
};
}
/**
* 横排标题逐字坐标(与 Python 横排分支一致)
* @param {Record<string, unknown>} config
* @param {number} [scale]
*/
export function layoutHorizontalTitle(config, scale = COVER_PREVIEW_SCALE) {
const canvasW = COVER_CANVAS_WIDTH * scale;
const canvasH = COVER_CANVAS_HEIGHT * scale;
const fontSize = (Number(config.titleFontSize) || 60) * scale;
const charSpacing =
config.titleCharSpacing != null
? Number(config.titleCharSpacing)
: Math.round(fontSize * 0.2);
const lineSpacing =
config.titleLineSpacing != null
? Number(config.titleLineSpacing)
: Math.round(fontSize * 1.2);
const maxChars = Number(config.titleMaxCharsPerLine) || 10;
const text = String(config.titleText || "");
const lines = splitCoverTitleLines(text, maxChars);
const center = pctToPx(config.titlePosition, canvasW, canvasH);
let { x: textX, y: textY } = center;
// 与 advanced_cover_generator.py TITLE HEIGHT ADJUST 一致(整段 mm 包围盒高度)
textY -= fontSize / 2;
const totalHeight =
lines.length > 0
? (lines.length - 1) * (fontSize + lineSpacing) + fontSize
: fontSize;
const maxLineChars = Math.max(...lines.map((l) => l.length), 1);
const totalWidth =
(maxLineChars - 1) * (fontSize + charSpacing) + fontSize;
const startX = textX - totalWidth / 2;
const startY = textY - totalHeight / 2;
const chars = [];
for (let row = 0; row < lines.length; row += 1) {
const line = lines[row];
const lineY = startY + row * (fontSize + lineSpacing) + fontSize / 2;
for (let col = 0; col < line.length; col += 1) {
chars.push({
char: line[col],
x: startX + col * (fontSize + charSpacing) + fontSize / 2,
y: lineY,
});
}
}
return {
chars,
fontSize,
box: {
left: startX,
top: startY,
width: totalWidth,
height: totalHeight,
},
};
}
/**
* 竖排标题逐字坐标
*/
export function layoutVerticalTitle(config, scale = COVER_PREVIEW_SCALE) {
const canvasW = COVER_CANVAS_WIDTH * scale;
const canvasH = COVER_CANVAS_HEIGHT * scale;
const fontSize = (Number(config.titleFontSize) || 60) * scale;
const charSpacing =
config.titleCharSpacing != null
? Number(config.titleCharSpacing)
: Math.round(fontSize * 0.2);
const lineSpacing =
config.titleLineSpacing != null
? Number(config.titleLineSpacing)
: Math.round(fontSize * 1.2);
const maxChars = Number(config.titleMaxCharsPerLine) || 10;
const text = String(config.titleText || "");
const center = pctToPx(config.titlePosition, canvasW, canvasH);
let { x: textX, y: textY } = center;
textY -= fontSize / 2;
const cols = splitCoverTitleLines(text, maxChars);
const totalWidth =
cols.length > 0 ? (cols.length - 1) * lineSpacing + fontSize : fontSize;
const maxColChars = Math.max(...cols.map((c) => c.length), 1);
const totalHeight =
(maxColChars - 1) * (fontSize + charSpacing) + fontSize;
const startX = textX - totalWidth / 2;
const startY = textY - totalHeight / 2;
const chars = [];
for (let col = 0; col < cols.length; col += 1) {
const column = cols[col];
const colX = startX + col * lineSpacing + fontSize / 2;
for (let row = 0; row < column.length; row += 1) {
chars.push({
char: column[row],
x: colX,
y: startY + row * (fontSize + charSpacing) + fontSize / 2,
});
}
}
return {
chars,
fontSize,
box: { left: startX, top: startY, width: totalWidth, height: totalHeight },
};
}
/**
* @param {Record<string, unknown>} config
*/
export function layoutTitle(config, scale = COVER_PREVIEW_SCALE) {
if (config.titleDirection === "vertical") {
return layoutVerticalTitle(config, scale);
}
return layoutHorizontalTitle(config, scale);
}
/**
* @param {Record<string, unknown>} config
*/
export function layoutSubtitle(config, scale = COVER_PREVIEW_SCALE) {
const subConfig = {
...config,
titleText: config.subtitleText,
titleFontSize: config.subtitleFontSize,
titleFontFamily: config.subtitleFontFamily,
titleFontWeight: config.subtitleFontWeight,
titleColor: config.subtitleColor,
titleStrokeWidth: config.subtitleStrokeWidth,
titleStrokeColor: config.subtitleStrokeColor,
titleShadowLayers: config.subtitleShadowLayers,
titlePosition: config.subtitlePosition,
titleDirection: config.subtitleDirection,
titleCharSpacing: config.subtitleCharSpacing,
titleLineSpacing: config.subtitleLineSpacing,
titleMaxCharsPerLine: config.subtitleMaxCharsPerLine,
titleRotation: config.subtitleRotation,
};
return layoutTitle(subConfig, scale);
}
/**
* 人像占位框(中心锚点 + personSize 相对画布高度)
*/
export function layoutPersonBox(config, scale = COVER_PREVIEW_SCALE) {
const canvasW = COVER_CANVAS_WIDTH * scale;
const canvasH = COVER_CANVAS_HEIGHT * scale;
const center = pctToPx(config.personPosition, canvasW, canvasH);
const sizePct = Number(config.personSize) || 50;
const h = (canvasH * sizePct) / 100;
const w = h * 0.75;
return {
left: center.x - w / 2,
top: center.y - h / 2,
width: w,
height: h,
};
}

View File

@@ -0,0 +1,113 @@
import {
createDefaultCoverTemplateConfig,
isFullCoverTemplateConfig,
migrateSimpleCoverConfig,
} from "../config/coverTemplateDefaults.js";
import { splitCoverReferenceText } from "./coverTextSplit.js";
/**
* @param {{ id?: string, name?: string, description?: string, config?: Record<string, unknown> }} template
* @param {string} [referenceText]
*/
export function createCoverEditDraft(template, referenceText = "") {
const baseConfig = isFullCoverTemplateConfig(template?.config)
? createDefaultCoverTemplateConfig({ ...template.config })
: migrateSimpleCoverConfig(template?.config || {});
const draft = {
id: template?.id || "",
name: template?.name || "新模板",
description: template?.description || "",
config: baseConfig,
};
syncCoverTextsFromReference(draft, referenceText);
return draft;
}
/**
* @param {{ config: Record<string, unknown> }} draft
* @param {string} referenceText
*/
export function syncCoverTextsFromReference(draft, referenceText) {
const cfg = draft.config;
const { title, subtitle } = splitCoverReferenceText(referenceText, {
autoSplit: cfg.autoSplitText !== false,
titleMaxLength: Number(cfg.titleMaxLength) || 14,
subtitleMaxLength: Number(cfg.subtitleMaxLength) || 16,
});
cfg.titleText = title;
cfg.subtitleText = subtitle;
}
/**
* 与 Electron CoverCustomEditor 保存时一致的 titles 结构
* @param {Record<string, unknown>} config
*/
export function buildCoverTitlesConfig(config) {
const c = config || {};
return {
main: {
text: c.titleText || "",
maxLength: c.titleMaxCharsPerLine ?? 10,
fontSize: c.titleFontSize,
fontFamily: c.titleFontFamily || "Microsoft YaHei",
fontWeight: c.titleFontWeight ?? 700,
color: c.titleColor || "#FFFFFF",
strokeColor: c.titleStrokeColor || "#000000",
strokeWidth: c.titleStrokeWidth ?? 0,
shadowColor: c.titleShadowColor || "#000000",
shadowOffsetX: c.titleShadowOffsetX ?? 0,
shadowOffsetY: c.titleShadowOffsetY ?? 0,
shadowBlur: c.titleShadowBlur ?? 0,
shadowEnabled: Boolean(c.titleShadowEnabled),
shadowLayers: Array.isArray(c.titleShadowLayers) ? c.titleShadowLayers : [],
position: c.titlePosition || { x: 50, y: 80 },
direction: c.titleDirection || "horizontal",
charSpacing: c.titleCharSpacing,
lineSpacing: c.titleLineSpacing,
rotation: c.titleRotation ?? 0,
backgroundRotation: c.titleBackgroundRotation ?? 0,
},
sub: {
text: c.subtitleText || "",
maxLength: c.subtitleMaxCharsPerLine ?? 15,
fontSize: c.subtitleFontSize,
fontFamily: c.subtitleFontFamily || "Microsoft YaHei",
fontWeight: c.subtitleFontWeight ?? 500,
color: c.subtitleColor || "#FFFFFF",
strokeColor: c.subtitleStrokeColor || "#000000",
strokeWidth: c.subtitleStrokeWidth ?? 0,
shadowColor: c.subtitleShadowColor || "#000000",
shadowOffsetX: c.subtitleShadowOffsetX ?? 0,
shadowOffsetY: c.subtitleShadowOffsetY ?? 0,
shadowBlur: c.subtitleShadowBlur ?? 0,
shadowEnabled: Boolean(c.subtitleShadowEnabled),
shadowLayers: Array.isArray(c.subtitleShadowLayers)
? c.subtitleShadowLayers
: [],
position: c.subtitlePosition || { x: 50, y: 90 },
direction: c.subtitleDirection || "horizontal",
charSpacing: c.subtitleCharSpacing,
lineSpacing: c.subtitleLineSpacing,
rotation: c.subtitleRotation ?? 0,
backgroundRotation: c.subtitleBackgroundRotation ?? 0,
},
};
}
/**
* 导出给 Python 生成器的配置(补全兼容字段)
* @param {Record<string, unknown>} config
*/
export function normalizeCoverConfigForGenerator(config) {
const c = { ...config };
c.blurBackground = Boolean(c.backgroundBlurEnabled ?? c.blurBackground);
c.extractPerson = c.extractPerson !== false;
if (c.personBorderEnabled && !c.personOutlineColor) {
c.personOutlineColor = c.personBorderColor;
c.personOutlineWidth = c.personBorderWidth;
}
c.titles = buildCoverTitlesConfig(c);
return c;
}

View File

@@ -0,0 +1,83 @@
/** 步骤 04 默认建议条数 */
export const STEP4_TITLE_SUGGESTION_COUNT = 5;
/**
* 解析步骤 04 标题文本为行列表
* @param {string} titleGenerated
*/
export function parseStep4TitleLines(titleGenerated) {
return String(titleGenerated || "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
}
/**
* 步骤 04 生成多条标题建议(换行分隔)。
* - 15 行:取第一行作为封面/发布用标题(其余为备选,可在步骤 04 调整顺序)
* - 超过 5 行:随机选取一行
* @param {string} titleGenerated
* @param {{ random?: () => number }} [opts] 便于测试注入
*/
export function resolveStep4TitleForCover(titleGenerated, opts = {}) {
const lines = parseStep4TitleLines(titleGenerated);
if (!lines.length) return "";
const random = opts.random ?? Math.random;
const picked =
lines.length > STEP4_TITLE_SUGGESTION_COUNT
? lines[Math.floor(random() * lines.length)]
: lines[0];
return picked.slice(0, 80);
}
/**
* 封面标题/副标题拆分:先解析步骤 04 单行标题,再按模板规则拆主/副标题
* @param {string} text
* @param {{ autoSplit?: boolean, titleMaxLength?: number, subtitleMaxLength?: number }} opts
*/
export function splitCoverReferenceText(
text,
{ autoSplit = true, titleMaxLength = 14, subtitleMaxLength = 16 } = {},
) {
const single = resolveStep4TitleForCover(text);
if (!single) return { title: "", subtitle: "" };
if (!autoSplit || single.length <= titleMaxLength) {
return { title: single.slice(0, titleMaxLength), subtitle: "" };
}
const breakAt = findSplitIndex(single, titleMaxLength);
return {
title: single.slice(0, breakAt).trim().slice(0, titleMaxLength),
subtitle: single.slice(breakAt).trim().slice(0, subtitleMaxLength),
};
}
/**
* @param {string} text
* @param {number} maxLen
*/
function findSplitIndex(text, maxLen) {
const punct = /[,。!?;、\s]/;
for (let i = Math.min(maxLen, text.length - 1); i > 2; i -= 1) {
if (punct.test(text[i])) return i + 1;
}
return Math.min(maxLen, Math.ceil(text.length / 2));
}
/**
* 按每行字数折行(预览用)
* @param {string} text
* @param {number} maxPerLine
*/
export function wrapCoverLine(text, maxPerLine) {
const raw = String(text || "");
if (!raw || maxPerLine < 1) return [];
const lines = [];
for (let i = 0; i < raw.length; i += maxPerLine) {
lines.push(raw.slice(i, i + maxPerLine));
}
return lines;
}