11111
This commit is contained in:
289
src/utils/subtitlePreviewStyle.js
Normal file
289
src/utils/subtitlePreviewStyle.js
Normal file
@@ -0,0 +1,289 @@
|
||||
/** 字幕模板预览样式(对齐 Electron SubtitlePreviewCard) */
|
||||
|
||||
const ACCENT_KEYWORDS = [
|
||||
"自己",
|
||||
"节奏",
|
||||
"答案",
|
||||
"努力",
|
||||
"温柔",
|
||||
"快乐",
|
||||
"热爱",
|
||||
"从容",
|
||||
"美好",
|
||||
"重点",
|
||||
"主动",
|
||||
"执行",
|
||||
"光",
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string | undefined} hex
|
||||
* @param {number | undefined} opacity
|
||||
*/
|
||||
export function colorWithOpacity(hex, opacity) {
|
||||
if (!hex || hex === "transparent" || opacity === undefined || opacity <= 0) {
|
||||
return "transparent";
|
||||
}
|
||||
const raw = hex.replace("#", "");
|
||||
const base = raw.length === 8 ? raw.slice(0, 6) : raw;
|
||||
if (base.length !== 6) return "transparent";
|
||||
const r = parseInt(base.slice(0, 2), 16);
|
||||
const g = parseInt(base.slice(2, 4), 16);
|
||||
const b = parseInt(base.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} color
|
||||
* @param {number} alpha
|
||||
*/
|
||||
function shadowColor(color, alpha = 1) {
|
||||
if (color === "transparent") return `rgba(0, 0, 0, ${alpha})`;
|
||||
const raw = color.replace("#", "");
|
||||
const base = raw.length === 8 ? raw.slice(0, 6) : raw;
|
||||
if (base.length !== 6) return `rgba(0, 0, 0, ${alpha})`;
|
||||
const r = parseInt(base.slice(0, 2), 16);
|
||||
const g = parseInt(base.slice(2, 4), 16);
|
||||
const b = parseInt(base.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown> | null | undefined} style
|
||||
* @param {{ previewScale?: number, minFontSize?: number, scaleMultiplier?: number, maxStrokeWidth?: number }} opts
|
||||
*/
|
||||
export function buildBaseTextStyle(style, opts = {}) {
|
||||
const scale = opts.previewScale ?? 0.33;
|
||||
const minFontSize = opts.minFontSize ?? 12;
|
||||
const scaleMultiplier = opts.scaleMultiplier ?? 1;
|
||||
const fontSize = Math.max(
|
||||
minFontSize,
|
||||
(Number(style?.fontSize) || 48) * scale * scaleMultiplier,
|
||||
);
|
||||
const outlineWidth = Math.max(0, (Number(style?.outlineWidth) || 0) * scale);
|
||||
const shadowOffset = Math.max(0, (Number(style?.shadowOffset) || 0) * scale);
|
||||
const shadowBlur = Math.max(0, (Number(style?.shadowBlur) || 0) * scale);
|
||||
const bg = colorWithOpacity(
|
||||
String(style?.backgroundColor || "transparent"),
|
||||
Number(style?.backgroundOpacity ?? 0),
|
||||
);
|
||||
const maxStrokeWidth = opts.maxStrokeWidth ?? 1.6;
|
||||
const stroke =
|
||||
outlineWidth > 0
|
||||
? Math.max(0.5, Math.min(maxStrokeWidth, outlineWidth * 0.55))
|
||||
: 0;
|
||||
const shadows = [];
|
||||
if (stroke > 0) {
|
||||
const c = shadowColor(String(style?.outlineColor || "#000000"), 0.78);
|
||||
const v = Math.max(0.65, Math.min(0.95, stroke));
|
||||
shadows.push(
|
||||
`${v}px 0 0 ${c}`,
|
||||
`-${v}px 0 0 ${c}`,
|
||||
`0 ${v}px 0 ${c}`,
|
||||
`0 -${v}px 0 ${c}`,
|
||||
);
|
||||
}
|
||||
if (shadowOffset > 0) {
|
||||
shadows.push(
|
||||
`${shadowOffset}px ${shadowOffset}px ${Math.max(1, shadowBlur)}px ${style?.shadowColor || "#000000"}`,
|
||||
);
|
||||
} else {
|
||||
shadows.push("0 1px 1px rgba(0, 0, 0, 0.18)");
|
||||
}
|
||||
return {
|
||||
fontFamily: style?.fontName ? `'${style.fontName}'` : "Microsoft YaHei UI",
|
||||
fontSize: `${fontSize}px`,
|
||||
color: String(style?.fontColor || "#FFFFFF"),
|
||||
backgroundColor: bg,
|
||||
padding: bg !== "transparent" ? "0.18em 0.44em" : "0",
|
||||
borderRadius: bg !== "transparent" ? "0.28em" : "0",
|
||||
boxDecorationBreak: "clone",
|
||||
WebkitBoxDecorationBreak: "clone",
|
||||
WebkitTextStroke: "0 transparent",
|
||||
paintOrder: "stroke fill",
|
||||
textShadow: shadows.length > 0 ? shadows.join(", ") : "none",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
textRendering: "optimizeLegibility",
|
||||
transform: "translateZ(0)",
|
||||
filter: "none",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
function textWeight(text) {
|
||||
return Array.from(text || "").reduce((sum, ch) => {
|
||||
if (/\s/.test(ch)) return sum + 0.28;
|
||||
if (/[A-Za-z0-9]/.test(ch)) return sum + 0.58;
|
||||
return sum + 1;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ text: string }>} segments
|
||||
* @param {boolean} hasTitle
|
||||
* @param {boolean} inlineEmphasis
|
||||
*/
|
||||
export function lineScaleFactor(segments, hasTitle, inlineEmphasis) {
|
||||
const weight = textWeight(segments.map((s) => s.text).join(""));
|
||||
if (hasTitle) {
|
||||
if (weight > 16) return 0.5;
|
||||
if (weight > 14) return 0.58;
|
||||
if (weight > 12) return 0.68;
|
||||
if (weight > 10) return 0.78;
|
||||
}
|
||||
if (weight > 18) return 0.6;
|
||||
if (weight > 16) return 0.7;
|
||||
if (weight > 14) return 0.8;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} style
|
||||
* @param {number} index
|
||||
* @param {number} total
|
||||
* @param {number} previewScale
|
||||
*/
|
||||
export function buildTitleLineStyle(style, index, total, previewScale = 0.33) {
|
||||
const lineSpacing = (Number(style?.lineSpacingPercent) || 4.5) * previewScale;
|
||||
const hasBg =
|
||||
style?.backgroundColor &&
|
||||
style.backgroundColor !== "transparent" &&
|
||||
(Number(style?.backgroundOpacity) ?? 0) > 0;
|
||||
const bg = colorWithOpacity(
|
||||
String(style?.backgroundColor || "transparent"),
|
||||
Number(style?.backgroundOpacity ?? 0),
|
||||
);
|
||||
const outline = Math.max(0, (Number(style?.outlineWidth) || 0) * previewScale * 1.7);
|
||||
const shadow = Math.max(0, (Number(style?.shadowOffset) || 0) * previewScale * 1.9);
|
||||
const padY = Math.max(4, Math.ceil((Number(style?.fontSize) || 56) * previewScale * 0.08));
|
||||
const padX = Math.ceil(outline + shadow + padY);
|
||||
const inner = Math.ceil(Math.max(outline * 0.85, shadow * 0.65, padY * 0.7));
|
||||
return {
|
||||
...buildBaseTextStyle(style, {
|
||||
previewScale,
|
||||
minFontSize: 12,
|
||||
scaleMultiplier: 0.62,
|
||||
maxStrokeWidth: 0.82,
|
||||
}),
|
||||
fontWeight: String(style?.fontWeight || "900"),
|
||||
marginBottom: index < total - 1 ? `${lineSpacing}px` : "0",
|
||||
lineHeight: "1.12",
|
||||
letterSpacing: "-0.045em",
|
||||
maxWidth: "100%",
|
||||
display: "block",
|
||||
width: "100%",
|
||||
whiteSpace: "nowrap",
|
||||
wordBreak: "keep-all",
|
||||
overflowWrap: "normal",
|
||||
overflow: "hidden",
|
||||
textAlign: "center",
|
||||
boxSizing: "border-box",
|
||||
backgroundColor: hasBg ? bg : "transparent",
|
||||
padding: hasBg
|
||||
? `calc(0.18em + ${Math.max(1, Math.ceil(inner * 0.45))}px) calc(0.22em + ${Math.max(2, Math.ceil(padX * 0.18))}px) calc(0.22em + ${Math.max(1, Math.ceil(inner * 0.45))}px)`
|
||||
: "0",
|
||||
borderRadius: hasBg ? "0.18em" : "0",
|
||||
boxShadow: "none",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
* @param {boolean} inlineEmphasis
|
||||
*/
|
||||
export function injectAccentMarkers(line, inlineEmphasis) {
|
||||
if (!line || line.includes("[") || line.includes("]")) return line;
|
||||
const keyword = ACCENT_KEYWORDS.find((k) => line.includes(k));
|
||||
if (keyword) return line.replace(keyword, `[${keyword}]`);
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length < 4) return trimmed;
|
||||
const len = trimmed.length >= 8 ? 3 : 2;
|
||||
const start = Math.max(1, Math.floor((trimmed.length - len) / 2));
|
||||
return `${trimmed.slice(0, start)}[${trimmed.slice(start, start + len)}]${trimmed.slice(start + len)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
* @param {boolean} inlineEmphasis
|
||||
* @returns {Array<{ text: string, accent: boolean }>}
|
||||
*/
|
||||
export function parseSubtitleLine(line, inlineEmphasis) {
|
||||
if (!inlineEmphasis) {
|
||||
return [{ text: line.replace(/\[|\]/g, ""), accent: false }];
|
||||
}
|
||||
const segments = [];
|
||||
const re = /(\[[^\]]+\])/g;
|
||||
let last = 0;
|
||||
let match;
|
||||
while ((match = re.exec(line)) !== null) {
|
||||
const before = line.slice(last, match.index);
|
||||
if (before) segments.push({ text: before, accent: false });
|
||||
const inner = match[0].slice(1, -1);
|
||||
if (inner) segments.push({ text: inner, accent: true });
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
const tail = line.slice(last);
|
||||
if (tail) segments.push({ text: tail, accent: false });
|
||||
return segments.length > 0
|
||||
? segments
|
||||
: [{ text: line.replace(/\[|\]/g, ""), accent: false }];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
*/
|
||||
export function resolveSubtitlePreviewImage(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path) || path.startsWith("data:")) return path;
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
if (normalized.includes("subtitle-preview-")) {
|
||||
const name = normalized.split("/").pop();
|
||||
return `/subtitle-templates/previews/${name}`;
|
||||
}
|
||||
if (normalized.startsWith("extra/common/cover-templates/")) {
|
||||
const name = normalized.split("/").pop();
|
||||
return `/subtitle-templates/previews/${name}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} template
|
||||
*/
|
||||
export function templateToFfmpegStyle(template) {
|
||||
const burn = buildSubtitleBurnStyle(template);
|
||||
if (!burn) return null;
|
||||
return {
|
||||
fontName: burn.fontName,
|
||||
fontSize: burn.fontSize,
|
||||
primaryColor: burn.fontColor,
|
||||
outlineColor: burn.outlineColor,
|
||||
outline: burn.outlineWidth,
|
||||
marginV: 40,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 烧录字幕参数(对齐 Electron ZimuShengcheng:PlayRes=视频尺寸、每行字数限制)
|
||||
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem | null | undefined} template
|
||||
*/
|
||||
export function buildSubtitleBurnStyle(template) {
|
||||
const style = template?.config?.subtitleStyle;
|
||||
if (!style) return null;
|
||||
const outlineWidth = Number(style.outlineWidth);
|
||||
return {
|
||||
fontName: String(style.fontName || "Microsoft YaHei"),
|
||||
fontSize: Number(style.fontSize) || 35,
|
||||
fontColor: String(style.fontColor || "#FFFFFF"),
|
||||
outlineColor: String(style.outlineColor || "#000000"),
|
||||
outlineWidth: Number.isFinite(outlineWidth) ? outlineWidth : 2,
|
||||
backgroundColor: String(style.backgroundColor || "transparent"),
|
||||
backgroundOpacity: Number(style.backgroundOpacity) ?? 0,
|
||||
position:
|
||||
template?.config?.subtitlePosition || style.position || "bottom",
|
||||
fontSizeScale: Number(template?.config?.subtitleFontSizeScale) || 100,
|
||||
maxCharsPerLine: 20,
|
||||
};
|
||||
}
|
||||
101
src/utils/subtitleTemplateDraft.js
Normal file
101
src/utils/subtitleTemplateDraft.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} value
|
||||
* @returns {T}
|
||||
*/
|
||||
export function deepClone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} template
|
||||
*/
|
||||
export function createEditDraft(template) {
|
||||
const draft = deepClone(template);
|
||||
if (!draft.config) draft.config = {};
|
||||
if (!draft.config.subtitleStyle) {
|
||||
draft.config.subtitleStyle = {
|
||||
fontName: "Microsoft YaHei",
|
||||
fontSize: 35,
|
||||
fontColor: "#FFFFFF",
|
||||
outlineColor: "#000000",
|
||||
outlineWidth: 0,
|
||||
backgroundColor: "transparent",
|
||||
backgroundOpacity: 0,
|
||||
position: "bottom",
|
||||
shadowOffset: 2,
|
||||
shadowBlur: 4,
|
||||
shadowColor: "#000000",
|
||||
};
|
||||
}
|
||||
if (draft.config.subtitleFontSizeScale == null) {
|
||||
draft.config.subtitleFontSizeScale = 100;
|
||||
}
|
||||
if (!draft.config.subtitlePosition) {
|
||||
draft.config.subtitlePosition = draft.config.subtitleStyle.position || "bottom";
|
||||
}
|
||||
if (!draft.config.titleSubtitleConfig) {
|
||||
draft.config.titleSubtitleConfig = {
|
||||
enabled: false,
|
||||
lines: [],
|
||||
maxCharsPerLine: 8,
|
||||
lineSpacingPercent: 5,
|
||||
topMarginPercent: 5,
|
||||
};
|
||||
}
|
||||
if (!Array.isArray(draft.config.keywordGroupsStyles)) {
|
||||
draft.config.keywordGroupsStyles = [];
|
||||
}
|
||||
if (draft.config.enableKeywordEffects == null) {
|
||||
draft.config.enableKeywordEffects = true;
|
||||
}
|
||||
if (!draft.config.keywordRenderMode) {
|
||||
draft.config.keywordRenderMode = "inline-emphasis";
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @param {number} maxChars
|
||||
*/
|
||||
export function splitTitleLines(title, maxChars = 8) {
|
||||
const text = String(title || "").trim();
|
||||
if (!text) return [];
|
||||
const lines = [];
|
||||
for (let i = 0; i < text.length; i += maxChars) {
|
||||
lines.push(text.slice(i, i + maxChars));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} draft
|
||||
* @param {string} [referenceTitle]
|
||||
*/
|
||||
export function syncTitleLinesFromReference(draft, referenceTitle) {
|
||||
const cfg = draft.config.titleSubtitleConfig;
|
||||
const max = cfg.maxCharsPerLine || 8;
|
||||
const parts = splitTitleLines(referenceTitle || cfg.sourceTitle || "", max);
|
||||
const previewTitles = draft.config.previewConfig?.titleTexts;
|
||||
const texts =
|
||||
previewTitles?.length > 0 ? previewTitles : parts.length ? parts : ["示例标题第一行", "示例标题第二行"];
|
||||
const baseLine = cfg.lines?.[0] || {
|
||||
fontName: "优设标题黑",
|
||||
fontSize: 80,
|
||||
fontColor: "#ffff00",
|
||||
backgroundColor: "transparent",
|
||||
backgroundOpacity: 0,
|
||||
outlineColor: "#000000",
|
||||
outlineWidth: 1,
|
||||
shadowColor: "#000000",
|
||||
shadowOffset: 2,
|
||||
};
|
||||
cfg.lines = texts.map((text, i) => ({
|
||||
...deepClone(cfg.lines?.[i] || baseLine),
|
||||
text,
|
||||
}));
|
||||
cfg.sourceTitle = referenceTitle || cfg.sourceTitle || texts.join("");
|
||||
if (!draft.config.previewConfig) draft.config.previewConfig = {};
|
||||
draft.config.previewConfig.titleTexts = texts;
|
||||
}
|
||||
71
src/utils/ttsOptions.js
Normal file
71
src/utils/ttsOptions.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/** TTS 参数构建(对齐 Electron generateAudio CosyVoice 分支) */
|
||||
|
||||
const DIALECT_LANGUAGES = new Set([
|
||||
"上海话",
|
||||
"北京话",
|
||||
"四川话",
|
||||
"南京话",
|
||||
"陕西话",
|
||||
"闽南语",
|
||||
"天津话",
|
||||
"粤语",
|
||||
]);
|
||||
|
||||
const LANGUAGE_TYPE_MAP = {
|
||||
英语: "en",
|
||||
法语: "fr",
|
||||
德语: "de",
|
||||
日语: "ja",
|
||||
韩语: "ko",
|
||||
俄语: "ru",
|
||||
意大利语: "it",
|
||||
西班牙语: "es",
|
||||
葡萄牙语: "pt",
|
||||
上海话: "Chinese",
|
||||
北京话: "Chinese",
|
||||
四川话: "Chinese",
|
||||
南京话: "Chinese",
|
||||
陕西话: "Chinese",
|
||||
闽南语: "Chinese",
|
||||
天津话: "Chinese",
|
||||
粤语: "Chinese",
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} voiceLanguage
|
||||
* @param {string} selectedVoiceId
|
||||
*/
|
||||
export function buildTtsInstruction(voiceLanguage, selectedVoiceId, voiceEmotion, speechRate) {
|
||||
const lang = String(voiceLanguage || "").trim();
|
||||
const isSystem = String(selectedVoiceId || "").startsWith("sys_");
|
||||
|
||||
let instruction = "请用自然、清晰、适合短视频配音的风格朗读。";
|
||||
|
||||
const emotion = String(voiceEmotion || "").trim();
|
||||
if (emotion && emotion !== "自然") {
|
||||
instruction = `请用${emotion}的情绪,自然、清晰、适合短视频口播的风格朗读。`;
|
||||
}
|
||||
|
||||
if (!isSystem && DIALECT_LANGUAGES.has(lang)) {
|
||||
return `请用${lang}表达。`;
|
||||
}
|
||||
if (lang && lang !== "中文" && lang !== "中文(普通话)") {
|
||||
return `请用${lang}表达。`;
|
||||
}
|
||||
|
||||
const rate = Number(speechRate);
|
||||
if (rate && rate !== 1 && Number.isFinite(rate)) {
|
||||
instruction += `语速约为正常的 ${rate} 倍。`;
|
||||
}
|
||||
|
||||
return instruction;
|
||||
}
|
||||
|
||||
/** @param {string} voiceLanguage */
|
||||
export function buildTtsLanguageType(voiceLanguage) {
|
||||
const lang = String(voiceLanguage || "").trim();
|
||||
if (!lang || lang === "中文" || lang === "中文(普通话)") {
|
||||
return undefined;
|
||||
}
|
||||
return LANGUAGE_TYPE_MAP[lang];
|
||||
}
|
||||
25
src/utils/voiceIdPrefix.js
Normal file
25
src/utils/voiceIdPrefix.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** 克隆音色 ID 前缀(对齐 Electron SoundPromptEditDialog) */
|
||||
const VOICE_ID_PREFIXES = ["qwen-tts-vc-", "cosyvoice-v3-flash-"];
|
||||
|
||||
/**
|
||||
* @param {string} voiceId
|
||||
*/
|
||||
export function stripVoiceIdPrefix(voiceId) {
|
||||
const raw = String(voiceId || "");
|
||||
const prefix = VOICE_ID_PREFIXES.find((p) => raw.startsWith(p));
|
||||
return prefix ? raw.slice(prefix.length) : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {string} [existingFullId]
|
||||
*/
|
||||
export function formatVoiceIdWithPrefix(value, existingFullId = "") {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
if (VOICE_ID_PREFIXES.some((p) => raw.startsWith(p))) return raw;
|
||||
const existing = String(existingFullId || "");
|
||||
const prefix =
|
||||
VOICE_ID_PREFIXES.find((p) => existing.startsWith(p)) || "qwen-tts-vc-";
|
||||
return `${prefix}${raw}`;
|
||||
}
|
||||
14
src/utils/voiceLabel.js
Normal file
14
src/utils/voiceLabel.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { findSystemVoice } from "../data/systemVoices.js";
|
||||
import { getCloneVoice } from "../services/soundPromptStorage.js";
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} voiceId
|
||||
*/
|
||||
export function getVoiceDisplayLabel(voiceId) {
|
||||
if (!voiceId) return "选择音色";
|
||||
if (voiceId.startsWith("sys_")) {
|
||||
return findSystemVoice(voiceId)?.title || "选择音色";
|
||||
}
|
||||
const clone = getCloneVoice(voiceId);
|
||||
return clone?.title || "选择音色";
|
||||
}
|
||||
Reference in New Issue
Block a user