11
This commit is contained in:
32
src/components/cover/CoverCollapsibleSection.vue
Normal file
32
src/components/cover/CoverCollapsibleSection.vue
Normal 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>
|
||||
326
src/components/cover/CoverEditorPreview.vue
Normal file
326
src/components/cover/CoverEditorPreview.vue
Normal 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>
|
||||
221
src/components/cover/CoverPreviewCard.vue
Normal file
221
src/components/cover/CoverPreviewCard.vue
Normal 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>
|
||||
100
src/components/cover/CoverShadowLayersEditor.vue
Normal file
100
src/components/cover/CoverShadowLayersEditor.vue
Normal 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>
|
||||
455
src/components/cover/CoverTemplateDialog.vue
Normal file
455
src/components/cover/CoverTemplateDialog.vue
Normal 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>
|
||||
546
src/components/cover/CoverTemplateEditor.vue
Normal file
546
src/components/cover/CoverTemplateEditor.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<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>
|
||||
v-model:selected-id="coverTemplateId"
|
||||
@apply="onSettingsApplied"
|
||||
/>
|
||||
</DashboardCard>
|
||||
</template>
|
||||
|
||||
168
src/config/coverTemplateDefaults.js
Normal file
168
src/config/coverTemplateDefaults.js
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? "图片文件已选择,将直接用于模板生成"
|
||||
: "视频文件已选择",
|
||||
};
|
||||
}
|
||||
|
||||
222
src/services/coverTemplateCatalog.js
Normal file
222
src/services/coverTemplateCatalog.js
Normal 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",
|
||||
});
|
||||
}
|
||||
@@ -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 生成标题" };
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
198
src/utils/coverPreviewLayout.js
Normal file
198
src/utils/coverPreviewLayout.js
Normal 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,
|
||||
};
|
||||
}
|
||||
113
src/utils/coverTemplateDraft.js
Normal file
113
src/utils/coverTemplateDraft.js
Normal 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;
|
||||
}
|
||||
83
src/utils/coverTextSplit.js
Normal file
83
src/utils/coverTextSplit.js
Normal 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 生成多条标题建议(换行分隔)。
|
||||
* - 1~5 行:取第一行作为封面/发布用标题(其余为备选,可在步骤 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;
|
||||
}
|
||||
Reference in New Issue
Block a user