From 8970f51fc0dcf6c5f8bbcec17cc910909cd9aa94 Mon Sep 17 00:00:00 2001 From: "949036910@qq.com" <> Date: Mon, 1 Jun 2026 00:48:33 +0800 Subject: [PATCH] 11 --- scripts/nodejs/pipeline_native.js | 68 ++++++-- scripts/nodejs/subtitle_bgm_generate.js | 200 +++++++++++++++++++----- 2 files changed, 216 insertions(+), 52 deletions(-) diff --git a/scripts/nodejs/pipeline_native.js b/scripts/nodejs/pipeline_native.js index df0b734..099466d 100644 --- a/scripts/nodejs/pipeline_native.js +++ b/scripts/nodejs/pipeline_native.js @@ -264,6 +264,42 @@ function parseTranscriptionText(trans) { return fullText; } +/** @returns {Array<{ text: string, start: number, end: number }>} start/end 毫秒 */ +function normalizeAsrTimeToMs(value) { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return 0; + // 对齐 Electron generateAssSubtitle:>100 视为毫秒,否则视为秒 + if (n > 100) return Math.round(n); + return Math.round(n * 1000); +} + +function normalizeAsrRecord(record) { + const text = String(record?.text || record?.sentence || "").trim(); + if (!text) return null; + let start = normalizeAsrTimeToMs(record.start ?? record.begin ?? record.beginTime ?? 0); + let end = normalizeAsrTimeToMs(record.end ?? record.endTime ?? start + 2000); + if (end <= start) end = start + 2000; + return { text, start, end }; +} + +/** @returns {Array<{ text: string, start: number, end: number }>} */ +function normalizeAsrRecords(records) { + if (!Array.isArray(records)) return []; + const normalized = records.map(normalizeAsrRecord).filter(Boolean); + const MIN_GAP_MS = 50; + for (let i = 0; i < normalized.length - 1; i++) { + const current = normalized[i]; + const next = normalized[i + 1]; + if (current.end >= next.start) { + current.end = Math.max(current.start + MIN_GAP_MS, next.start - MIN_GAP_MS); + } + if (current.end <= current.start) { + current.end = current.start + MIN_GAP_MS; + } + } + return normalized; +} + /** @returns {Array<{ text: string, start: number, end: number }>} start/end 毫秒 */ function parseTranscriptionSentences(trans) { const records = []; @@ -273,14 +309,16 @@ function parseTranscriptionSentences(trans) { for (const s of t.sentences) { const text = String(s.text || "").trim(); if (!text) continue; - const start = Number(s.begin_time ?? s.start_time ?? 0); - const end = Number(s.end_time ?? s.end_time ?? start + 2000); + const start = normalizeAsrTimeToMs(s.begin_time ?? s.start_time ?? 0); + const end = normalizeAsrTimeToMs( + s.end_time ?? s.end ?? start + 2000, + ); records.push({ text, start, end }); } } } } - return records; + return normalizeAsrRecords(records); } async function fetchJson(url, options = {}) { @@ -624,15 +662,21 @@ async function localAsr(audioPath, info) { data.records || []; const sentences = Array.isArray(records) - ? records - .map((r) => { - const text = String(r.text || "").trim(); - if (!text) return null; - const start = Number(r.start ?? r.begin_time ?? 0); - const end = Number(r.end ?? r.end_time ?? start + 2000); - return { text, start, end }; - }) - .filter(Boolean) + ? normalizeAsrRecords( + records + .map((r) => { + const text = String(r.text || "").trim(); + if (!text) return null; + const start = normalizeAsrTimeToMs( + r.start ?? r.begin_time ?? 0, + ); + const end = normalizeAsrTimeToMs( + r.end ?? r.end_time ?? start + 2000, + ); + return { text, start, end }; + }) + .filter(Boolean), + ) : []; const text = sentences.length ? sentences.map((r) => r.text).join("") diff --git a/scripts/nodejs/subtitle_bgm_generate.js b/scripts/nodejs/subtitle_bgm_generate.js index 7f118f5..ebd3a03 100644 --- a/scripts/nodejs/subtitle_bgm_generate.js +++ b/scripts/nodejs/subtitle_bgm_generate.js @@ -116,23 +116,117 @@ function splitScriptLines(text) { .filter(Boolean); } -function alignScriptToRecords(script, records) { - const lines = splitScriptLines(script); - if (!lines.length || !records.length) return records; +function normalizeAsrTimeToMs(value) { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return 0; + if (n > 100) return Math.round(n); + return Math.round(n * 1000); +} - if (lines.length === records.length) { - return records.map((r, i) => ({ ...r, text: lines[i] })); +function normalizeAsrRecords(records) { + if (!Array.isArray(records)) return []; + const normalized = records + .map((record) => { + const text = String(record?.text || record?.sentence || "").trim(); + if (!text) return null; + let start = normalizeAsrTimeToMs( + record.start ?? record.begin ?? record.beginTime ?? 0, + ); + let end = normalizeAsrTimeToMs( + record.end ?? record.endTime ?? start + 2000, + ); + if (end <= start) end = start + 2000; + return { text, start, end }; + }) + .filter(Boolean); + const MIN_GAP_MS = 50; + for (let i = 0; i < normalized.length - 1; i++) { + const current = normalized[i]; + const next = normalized[i + 1]; + if (current.end >= next.start) { + current.end = Math.max(current.start + MIN_GAP_MS, next.start - MIN_GAP_MS); + } + if (current.end <= current.start) { + current.end = current.start + MIN_GAP_MS; + } + } + return normalized; +} + +/** 对齐 Electron smartLineBreak:最多 2 行,优先在标点处换行 */ +function smartLineBreak(text, maxCharsPerLine = 18) { + const lines = []; + const punctuations = [",", "。", "!", "?", ";", ":", ",", ".", "!", "?", ";", ":"]; + const MAX_LINES = 2; + let currentLine = ""; + let pureTextLength = 0; + let i = 0; + const max = Math.max(8, Math.min(30, Number(maxCharsPerLine) || 18)); + + while (i < text.length) { + if (lines.length >= MAX_LINES) { + currentLine += text[i]; + i += 1; + continue; + } + currentLine += text[i]; + pureTextLength += 1; + const isPunctuation = punctuations.includes(text[i]); + const isNearMaxLength = pureTextLength >= max * 0.8; + if (isPunctuation && isNearMaxLength && lines.length < MAX_LINES - 1) { + lines.push(currentLine.trim()); + currentLine = ""; + pureTextLength = 0; + } else if (pureTextLength >= max && lines.length < MAX_LINES - 1) { + lines.push(currentLine.trim()); + currentLine = ""; + pureTextLength = 0; + } + i += 1; + } + if (currentLine.trim()) lines.push(currentLine.trim()); + return lines.slice(0, MAX_LINES); +} + +function cleanSubtitleDisplayText(text) { + return String(text || "") + .replace(/[,。!?;:、""''()《》【】…—·,.!?;:'"()[\]{}<>]/g, "") + .trim(); +} + +function alignScriptToRecords(script, records, videoDurationSec = 0) { + const lines = splitScriptLines(script); + const normalized = normalizeAsrRecords(records); + if (!lines.length) return normalized; + if (!normalized.length) { + return recordsFromTextEvenly(script, videoDurationSec || 30); } - const startMs = records[0].start; - const endMs = records[records.length - 1].end; - const total = Math.max(endMs - startMs, lines.length * 2000); - const step = total / lines.length; - return lines.map((text, i) => ({ - text, - start: Math.round(startMs + i * step), - end: Math.round(startMs + (i + 1) * step), - })); + if (lines.length === normalized.length) { + return normalized.map((r, i) => ({ ...r, text: lines[i] })); + } + + const startMs = normalized[0].start; + let endMs = normalized[normalized.length - 1].end; + const durationMs = Math.max(0, Math.round(Number(videoDurationSec) * 1000)); + if (durationMs > 0 && (endMs - startMs < 1000 || endMs < durationMs * 0.2)) { + endMs = durationMs; + } + const totalMs = Math.max(endMs - startMs, lines.length * 2000); + const totalChars = lines.reduce((sum, line) => sum + line.length, 0) || lines.length; + let cursor = startMs; + + return lines.map((text, index) => { + const isLast = index === lines.length - 1; + const weight = text.length / totalChars; + const dur = isLast + ? Math.max(1500, endMs - cursor) + : Math.max(1500, Math.round(totalMs * weight)); + const start = cursor; + const end = isLast ? endMs : Math.min(cursor + dur, endMs); + cursor = end; + return { text, start, end: Math.max(end, start + 500) }; + }); } function recordsFromTextEvenly(text, durationSec) { @@ -155,14 +249,16 @@ function formatSrtTime(ms) { return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(msPart).padStart(3, "0")}`; } -function buildSrtContent(records, maxCharsPerLine = 20) { +function buildSrtContent(records, maxCharsPerLine = 18) { return records .map((r, i) => { const start = formatSrtTime(r.start); - const end = formatSrtTime(Math.max(r.end, r.start + 200)); + const end = formatSrtTime(Math.max(r.end, r.start + 500)); const text = wrapSubtitleText(r.text, maxCharsPerLine); + if (!text) return ""; return `${i + 1}\n${start} --> ${end}\n${text}\n`; }) + .filter(Boolean) .join("\n"); } @@ -170,16 +266,24 @@ function escapeSubPath(filePath) { return filePath.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'"); } -/** 对齐 Electron maxCharsPerLine=20,避免单行过长铺满画面 */ -function wrapSubtitleText(text, maxCharsPerLine = 20) { - const raw = String(text || "").trim(); - if (!raw) return ""; - const max = Math.max(8, Math.min(30, Number(maxCharsPerLine) || 20)); - const lines = []; - for (let i = 0; i < raw.length; i += max) { - lines.push(raw.slice(i, i + max)); +/** 对齐 Electron maxCharsPerLine + smartLineBreak;SRT 用换行,ASS 经 assEscapeText 转 \\N */ +function wrapSubtitleText(text, maxCharsPerLine = 18) { + const cleaned = cleanSubtitleDisplayText(text); + if (!cleaned) return ""; + return smartLineBreak(cleaned, maxCharsPerLine).join("\n"); +} + +function logSubtitle(level, msg, fields) { + const tag = `[autoGenerateSubtitleAndBGM] ${msg}`; + if (globalThis.__native?.log) { + globalThis.__native.log(level, tag, fields ?? null); + return; } - return lines.join("\n"); + if (fields != null) { + if (level === "error") console.error(tag, fields); + else console.log(tag, fields); + } else if (level === "error") console.error(tag); + else console.log(tag); } function hexToAssColor(hex) { @@ -216,21 +320,21 @@ function normalizeBurnStyle(style) { backgroundOpacity: Number(s.backgroundOpacity) ?? 0, position: String(s.position || "bottom"), fontSizeScale: Number(s.fontSizeScale) || 100, - maxCharsPerLine: Number(s.maxCharsPerLine) || 20, + maxCharsPerLine: Number(s.maxCharsPerLine) || 18, hasBg, }; } function resolveAssLayout(position, videoHeight) { const h = Math.max(360, Number(videoHeight) || 1080); - const marginV = Math.round(48 * (h / 1080)); + const marginV = 80; if (position === "top") { - return { alignment: 8, marginV }; + return { alignment: 8, marginL: 120, marginR: 120, marginV }; } if (position === "center") { - return { alignment: 5, marginV: 0 }; + return { alignment: 5, marginL: 120, marginR: 120, marginV: 0 }; } - return { alignment: 2, marginV }; + return { alignment: 2, marginL: 120, marginR: 120, marginV }; } function buildAssContent(records, styleInput, videoWidth, videoHeight) { @@ -240,9 +344,13 @@ function buildAssContent(records, styleInput, videoWidth, videoHeight) { const scale = Math.max(0.2, Math.min(2, style.fontSizeScale / 100)); const fontSize = Math.max(14, Math.round(style.fontSize * scale)); const outline = Math.max(0, Math.round(style.outlineWidth)); - const { alignment, marginV } = resolveAssLayout(style.position, height); + const { alignment, marginL, marginR, marginV } = resolveAssLayout( + style.position, + height, + ); const hasBg = style.hasBg; - const borderStyle = hasBg ? 4 : 1; + const borderStyle = hasBg ? 3 : 1; + const finalOutline = hasBg ? 1 : outline; const backColour = hasBg ? hexToAssColor(style.backgroundColor) : "&H00000000"; @@ -260,7 +368,7 @@ function buildAssContent(records, styleInput, videoWidth, videoHeight) { "", "[V4+ Styles]", "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", - `Style: Default,${style.fontName},${fontSize},${primary},${primary},${outlineColour},${backColour},0,0,0,0,100,100,0,0,${borderStyle},${outline},0,${alignment},20,20,${marginV},1`, + `Style: Default,${style.fontName},${fontSize},${primary},${primary},${outlineColour},${backColour},0,0,0,0,100,100,0,0,${borderStyle},${finalOutline},0,${alignment},${marginL},${marginR},${marginV},1`, "", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", @@ -268,10 +376,11 @@ function buildAssContent(records, styleInput, videoWidth, videoHeight) { const events = records.map((r) => { const start = formatAssTime(r.start); - const end = formatAssTime(Math.max(r.end, r.start + 200)); + const end = formatAssTime(Math.max(r.end, r.start + 500)); const text = assEscapeText(wrapSubtitleText(r.text, style.maxCharsPerLine)); + if (!text) return ""; return `Dialogue: 0,${start},${end},Default,,0,0,0,,${text}`; - }); + }).filter(Boolean); return `${header.join("\n")}\n${events.join("\n")}\n`; } @@ -398,16 +507,27 @@ globalThis.__nodejsMain = async function main(params) { globalThis.__native?.emitProgress?.("正在提取音频…"); const audioPath = await pipelineNative.ffmpegExtractAudio(current); const scriptContent = String(p.scriptContent || "").trim(); + const videoDuration = probeVideoDuration(current); + logSubtitle("info", "视频时长(秒)", { videoDuration }); const { records: rawRecords } = await runAsr(audioPath, modelConfig, scriptContent); + logSubtitle("info", "ASR 原始句数", { count: rawRecords?.length ?? 0 }); - let records = rawRecords; + let records = normalizeAsrRecords(rawRecords); + logSubtitle("info", "时间轴归一化后", { + count: records.length, + spanMs: + records.length > 1 + ? records[records.length - 1].end - records[0].start + : 0, + }); if (p.smartSubtitle !== false && scriptContent) { - records = alignScriptToRecords(scriptContent, records); + records = alignScriptToRecords(scriptContent, records, videoDuration); + logSubtitle("info", "智能字幕对齐后", { count: records.length }); } if (!records.length) { - const duration = probeVideoDuration(current); const fallbackText = scriptContent || " "; - records = recordsFromTextEvenly(fallbackText, duration || 30); + records = recordsFromTextEvenly(fallbackText, videoDuration || 30); + logSubtitle("info", "按文案均分时间轴", { count: records.length }); } if (!records.length) { return { success: false, error: "未识别到语音内容,无法生成字幕" }; @@ -417,7 +537,7 @@ globalThis.__nodejsMain = async function main(params) { p.subtitleStyle && typeof p.subtitleStyle === "object" ? p.subtitleStyle : null; - const maxChars = burnStyle?.maxCharsPerLine || 20; + const maxChars = burnStyle?.maxCharsPerLine || 18; srtPath = makeOutputPath("subtitle", "srt"); fs.writeFileSync(srtPath, buildSrtContent(records, maxChars), "utf8");