This commit is contained in:
949036910@qq.com
2026-05-30 18:45:08 +08:00
commit 16b9a007a6
150 changed files with 26852 additions and 0 deletions

View 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 ZimuShengchengPlayRes=视频尺寸、每行字数限制)
* @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,
};
}

View 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
View 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];
}

View 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
View 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 || "选择音色";
}