diff --git a/docs/docs/features/subtitle-generation.md b/docs/docs/features/subtitle-generation.md index 0bbce820..eacbe498 100644 --- a/docs/docs/features/subtitle-generation.md +++ b/docs/docs/features/subtitle-generation.md @@ -65,6 +65,15 @@ keywords: 当前版本暂不在一条龙向导、自定义配方及含配音 / 合成的流程中开放角色分离;这些流程会在角色元数据与配音音色映射完成后再接入。 +## 用参考文稿校正转写 + +课程、演讲或成片已有 TXT / Markdown 文稿时,可在任务的「参考文稿」中选择它。妙幕会在转写后按顺序对齐文稿与字幕,只把达到安全置信度的文本写回,并始终保留 ASR 生成的字幕条数和时间轴。 + +- 支持 UTF-8、UTF-16、GBK / GB18030 编码、最大 1 MiB 的 `.txt`、`.md`、`.markdown` +- 可跨过没有念出的标题、列表和舞台说明,并处理文稿与字幕分句粒度不一致 +- 低置信片段保持原识别文本;文稿为空、被移动或读取失败时整项安全回退,不会令转写任务失败 +- 任务轨道会显示已匹配条数及回退状态;匹配后的文本继续进入翻译、校对和导出 + ## GPU 加速 | 平台 | 加速后端 | diff --git a/main/helpers/atomicFile.ts b/main/helpers/atomicFile.ts new file mode 100644 index 00000000..08d1cf4e --- /dev/null +++ b/main/helpers/atomicFile.ts @@ -0,0 +1,67 @@ +import { randomUUID } from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; + +interface AtomicFileOperations { + open: typeof fs.open; + rename: typeof fs.rename; + rm: typeof fs.rm; +} + +export interface AtomicReplaceOptions { + signal?: AbortSignal; + /** Dependency seam for deterministic failure-path tests. */ + operations?: AtomicFileOperations; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + const error = new Error('Atomic file replacement cancelled'); + error.name = 'AbortError'; + throw error; +} + +/** + * Durably writes a sibling temporary file, closes it, then performs one atomic + * rename over the destination. We never move, truncate, or unlink the original + * first: if any pre-commit step or the rename fails, the destination is intact. + */ +export async function atomicReplaceTextFile( + targetPath: string, + content: string, + options: AtomicReplaceOptions = {}, +): Promise { + const operations = options.operations ?? fs; + const directory = path.dirname(targetPath); + const baseName = path.basename(targetPath); + const tempPath = path.join( + directory, + `.${baseName}.${process.pid}.${randomUUID()}.tmp`, + ); + let handle: Awaited> | null = null; + + try { + throwIfAborted(options.signal); + handle = await operations.open(tempPath, 'wx'); + await handle.writeFile(content, 'utf-8'); + await handle.sync(); + await handle.close(); + handle = null; + throwIfAborted(options.signal); + await operations.rename(tempPath, targetPath); + } catch (error) { + if (handle) { + try { + await handle.close(); + } catch { + // Preserve the first failure; cleanup remains best effort. + } + } + try { + await operations.rm(tempPath, { force: true }); + } catch { + // A leftover temp is safer than touching the original destination. + } + throw error; + } +} diff --git a/main/helpers/fileProcessor.ts b/main/helpers/fileProcessor.ts index 3dc83492..59cc51bc 100644 --- a/main/helpers/fileProcessor.ts +++ b/main/helpers/fileProcessor.ts @@ -29,6 +29,10 @@ import { runSubtitleRefineStage, settleSkippedRefineStage, } from './subtitleRefineStage'; +import { + runManuscriptMatchingStage, + settleSkippedManuscriptMatchStage, +} from './manuscriptMatchingStage'; import { runDubStage, rebuildDubTrackForFile } from './pipeline/dubStage'; import { runComposeStage } from './pipeline/composeStage'; import { @@ -277,6 +281,7 @@ export async function processFile( 'extractSubtitle', 'prepareSubtitle', 'refineSubtitle', + 'manuscriptMatch', 'translateSubtitle', 'speakerDiarization', 'dubbing', @@ -284,6 +289,7 @@ export async function processFile( 'extractAudioProgress', 'extractSubtitleProgress', 'refineSubtitleProgress', + 'manuscriptMatchProgress', 'translateSubtitleProgress', 'speakerDiarizationProgress', 'dubbingProgress', @@ -291,6 +297,8 @@ export async function processFile( 'extractAudioError', 'extractSubtitleError', 'refineSubtitleError', + 'manuscriptMatchError', + 'manuscriptMatchErrorDetail', 'translateSubtitleError', 'speakerDiarizationError', 'dubbingError', @@ -411,6 +419,8 @@ export async function processFile( }); // 首轮精修已写入 SRT;结算阶段态,避免 refine 格永久 pending。 settleSkippedRefineStage(event, file, formData); + // 首轮文稿匹配同样已写入 SRT,续跑不重新读取可能变化的外部文稿。 + settleSkippedManuscriptMatchStage(event, file, formData); } if (translationActive) { event.sender.send('taskFileChange', { @@ -450,6 +460,7 @@ export async function processFile( }); // 转写复用意味着首轮精修(若开启)已写入 srtForTranslate;结算阶段态。 settleSkippedRefineStage(event, file, formData); + settleSkippedManuscriptMatchStage(event, file, formData); } else if (!isSubtitleFile && shouldGenerateSubtitle) { const templateData = { fileName, @@ -529,6 +540,9 @@ export async function processFile( extractSubtitle: 'done', embeddedSubtitle: true, }); + // 精修与文稿匹配只作用于 ASR cue;内封字幕保持媒体原文,并结算可见阶段。 + settleSkippedRefineStage(event, file, formData); + settleSkippedManuscriptMatchStage(event, file, formData); usedEmbedded = true; } } catch (error) { @@ -582,6 +596,11 @@ export async function processFile( // 简繁归一/中文去标点与翻译之前;未开启或降级时字幕保持原样。 throwIfTaskCancelled(); await runSubtitleRefineStage(event, file, formData); + + // 参考文稿匹配:在 AI 精修之后、简繁归一与翻译之前执行。只改写高置信 + // cue 文本,时间轴不变;读取/对齐失败为非致命降级并保留原 ASR。 + throwIfTaskCancelled(); + await runManuscriptMatchingStage(event, file, formData); } catch (error) { if (isTaskCancelledError(error) || isTaskCancelled()) { // 用户取消:把本轮 loading 阶段回退为待处理 @@ -590,6 +609,7 @@ export async function processFile( extractAudio: '', extractSubtitle: '', refineSubtitle: '', + manuscriptMatch: '', }); throw new TaskCancelledError(); } diff --git a/main/helpers/ipcHandlers.ts b/main/helpers/ipcHandlers.ts index 36e7350c..5e581cf5 100644 --- a/main/helpers/ipcHandlers.ts +++ b/main/helpers/ipcHandlers.ts @@ -20,6 +20,12 @@ import { readProofreadDataFile, updateProofreadDataFromSubtitles, } from './proofreadData'; +import { + MANUSCRIPT_EXTENSIONS, + ManuscriptFileError, + readManuscriptFile, + toManuscriptSelectionPayload, +} from './manuscriptMatching'; // 定义支持的文件扩展名常量 export const MEDIA_EXTENSIONS = [ @@ -279,6 +285,34 @@ export function setupIpcHandlers(mainWindow: BrowserWindow) { event.sender.send('file-selected', allValidPaths.map(wrapFileObject)); }); + /** + * 参考文稿使用独立 invoke 通道,不复用 file-selected 广播,避免选中的 txt/md + * 被任务页误当成媒体或字幕追加到文件列表。主进程在返回路径前完成扩展名、大小、 + * 编码和非空校验;运行阶段会再次读取校验,以覆盖文件被移动/修改的情况。 + */ + ipcMain.handle('manuscript:select', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: [ + { + name: 'Reference Manuscript', + extensions: MANUSCRIPT_EXTENSIONS.map((ext) => ext.slice(1)), + }, + ], + }); + if (result.canceled || !result.filePaths[0]) return null; + try { + const manuscript = await readManuscriptFile(result.filePaths[0]); + return toManuscriptSelectionPayload(manuscript); + } catch (error) { + const code = + error instanceof ManuscriptFileError ? error.code : 'unreadable'; + const message = error instanceof Error ? error.message : String(error); + logMessage(`select manuscript failed (${code}): ${message}`, 'warning'); + return { errorCode: code, error: message }; + } + }); + ipcMain.on('openUrl', (event, url) => { shell.openExternal(url); }); diff --git a/main/helpers/ipcStoreHandlers.ts b/main/helpers/ipcStoreHandlers.ts index 185bc160..bcd340c2 100644 --- a/main/helpers/ipcStoreHandlers.ts +++ b/main/helpers/ipcStoreHandlers.ts @@ -26,6 +26,7 @@ import { rebuildAppMenu } from './menu'; import { shutdownPythonRuntime } from './pythonRuntime'; import { applyProxyFromSettings } from './network/proxyManager'; import { syncTaskPowerSaveBlocker } from './powerSaveManager'; +import { omitTaskManuscript } from '../../types/taskConfig'; import { isFactoryDefaultGgmlPath, resolveModelRoot, @@ -151,15 +152,22 @@ export function setupStoreHandlers() { }); // 用户配置相关处理 - ipcMain.on('setUserConfig', async (event, config) => { - store.set('userConfig', config); + ipcMain.on('setUserConfig', async (_event, config) => { + store.set('userConfig', omitTaskManuscript(config)); }); ipcMain.handle('getUserConfig', async () => { const storedConfig = store.get('userConfig'); + const reusableConfig = omitTaskManuscript(storedConfig); + if ( + storedConfig && + ('manuscriptPath' in storedConfig || 'manuscriptName' in storedConfig) + ) { + store.set('userConfig', reusableConfig); + } const merged: Record = { ...defaultUserConfig, - ...storedConfig, + ...reusableConfig, }; // 字幕效果默认档:缺省时按既有旋钮惰性推断(全新/默认→均衡;老用户自定义→对应档或 // custom,逐字保留行为)。在此补齐而非写 store 默认值,避免回灌覆盖老用户底层旋钮。 diff --git a/main/helpers/manuscriptMatching.ts b/main/helpers/manuscriptMatching.ts new file mode 100644 index 00000000..bb0544c4 --- /dev/null +++ b/main/helpers/manuscriptMatching.ts @@ -0,0 +1,1250 @@ +import fs from 'fs'; +import path from 'path'; + +export const MANUSCRIPT_EXTENSIONS = ['.txt', '.md', '.markdown'] as const; +export const MANUSCRIPT_MAX_BYTES = 1024 * 1024; +export const MANUSCRIPT_MAX_COMPARABLE_CHARS = 500_000; +export const MANUSCRIPT_MAX_UNITS = 20_000; + +export type ManuscriptFileErrorCode = + | 'unsupported' + | 'notFound' + | 'notFile' + | 'tooLarge' + | 'tooComplex' + | 'empty' + | 'invalidEncoding' + | 'unreadable'; + +export class ManuscriptFileError extends Error { + constructor( + public readonly code: ManuscriptFileErrorCode, + message: string, + ) { + super(message); + this.name = 'ManuscriptFileError'; + } +} + +export interface ManuscriptConfig { + path: string; + name: string; +} + +export interface LoadedManuscript extends ManuscriptConfig { + text: string; + /** Pre-segmented once during validated loading; never crosses the IPC boundary. */ + units: string[]; + size: number; + encoding: 'utf-8' | 'utf-16le' | 'utf-16be' | 'gb18030'; + characterCount: number; + comparableCharacterCount: number; +} + +/** Renderer 所需的最小 IPC 返回值;刻意不包含文稿正文。 */ +export interface ManuscriptSelectionPayload extends ManuscriptConfig { + size: number; + encoding: LoadedManuscript['encoding']; + characterCount: number; +} + +export interface ManuscriptMatchCue { + text: string; + [key: string]: unknown; +} + +export interface ManuscriptCueMatch { + cueStart: number; + cueCount: number; + manuscriptUnitStart: number; + manuscriptUnitCount: number; + confidence: number; + originalTexts: string[]; + replacementTexts: string[]; +} + +export interface ManuscriptMatchResult { + cues: T[]; + totalCues: number; + replacedCues: number; + matchedGroups: number; + averageConfidence: number; + manuscriptUnits: number; + matches: ManuscriptCueMatch[]; +} + +export interface ManuscriptMatchOptions { + signal?: AbortSignal; + /** Reuses units produced by readManuscriptFile to avoid parsing a large file twice. */ + manuscriptUnits?: string[]; +} + +interface ComparableChar { + value: string; + start: number; + end: number; +} + +interface ComparableSequence { + value: string; + length: number; + bigrams: Map; +} + +interface ManuscriptUnit { + text: string; + comparable: ComparableSequence; +} + +interface MatchCandidate { + cueCount: number; + manuscriptStart: number; + manuscriptCount: number; + similarity: number; + rank: number; +} + +const MAX_GROUP_SIZE = 3; +const LOCAL_LOOKAHEAD = 24; +const MAX_INDEXED_POSITIONS = 120; +const MAX_FAR_CANDIDATES = 60; +const MAX_UNORDERED_ORDERED_GAP = 0.08; +const MIN_SINGLE_CHARACTER_EDIT_LENGTH = 10; +const REORDER_NGRAM_SIZES = [2, 3] as const; +const MIN_REORDER_ANCHOR_DISPLACEMENT = 4; +const YIELD_EVERY_OPERATIONS = 2048; + +function manuscriptAbortError(): Error { + const error = new Error('Manuscript matching cancelled'); + error.name = 'AbortError'; + return error; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw manuscriptAbortError(); +} + +async function cooperativeYield(signal?: AbortSignal): Promise { + throwIfAborted(signal); + await new Promise((resolve) => setImmediate(resolve)); + throwIfAborted(signal); +} + +export function getManuscriptConfig( + formData?: Record, +): ManuscriptConfig | null { + const manuscriptPath = + typeof formData?.manuscriptPath === 'string' + ? formData.manuscriptPath.trim() + : ''; + if (!manuscriptPath) return null; + const configuredName = + typeof formData?.manuscriptName === 'string' + ? formData.manuscriptName.trim() + : ''; + return { + path: manuscriptPath, + name: configuredName || path.basename(manuscriptPath), + }; +} + +export function isSupportedManuscriptPath(filePath: string): boolean { + const extension = path.extname(filePath).toLowerCase(); + return MANUSCRIPT_EXTENSIONS.includes( + extension as (typeof MANUSCRIPT_EXTENSIONS)[number], + ); +} + +function decodeUtf16Be(buffer: Buffer): string { + const body = Buffer.from(buffer.subarray(2)); + for (let index = 0; index + 1 < body.length; index += 2) { + const first = body[index]; + body[index] = body[index + 1]; + body[index + 1] = first; + } + return body.toString('utf16le'); +} + +function decodeManuscriptBuffer(buffer: Buffer): { + text: string; + encoding: LoadedManuscript['encoding']; +} { + if (buffer[0] === 0xff && buffer[1] === 0xfe) { + return { + text: buffer.subarray(2).toString('utf16le'), + encoding: 'utf-16le', + }; + } + if (buffer[0] === 0xfe && buffer[1] === 0xff) { + return { text: decodeUtf16Be(buffer), encoding: 'utf-16be' }; + } + + const body = + buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf + ? buffer.subarray(3) + : buffer; + try { + return { + text: new TextDecoder('utf-8', { fatal: true }).decode(body), + encoding: 'utf-8', + }; + } catch { + // Windows 上的中文纯文本文稿仍常见 GBK/GB18030。WHATWG 的 gb18030 + // 解码器覆盖 GBK,作为 UTF-8 严格解码失败后的单一、确定性回退。 + try { + return { + text: new TextDecoder('gb18030', { fatal: true }).decode(body), + encoding: 'gb18030', + }; + } catch { + throw new ManuscriptFileError( + 'invalidEncoding', + 'Manuscript must be UTF-8, UTF-16, GBK, or GB18030 text', + ); + } + } +} + +/** + * 将 Markdown 转为适合字幕匹配的可见文本。这里刻意只剥离语法标记,保留标题、 + * 列表和代码块里的文字;未在音频里念出的结构文字会由单调对齐的 skip 路径跳过。 + */ +export function normalizeManuscriptText( + input: string, + markdown = false, +): string { + let text = input + .replace(/^\uFEFF/, '') + .replace(/\r\n?/g, '\n') + .replace(/\u0000/g, ''); + if (markdown) { + text = text + .replace(/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, '') + .replace(//g, ' ') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/^\s*```[^\n]*$/gm, '') + .replace(/`([^`]+)`/g, '$1') + .replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-+*]\s+|\d+[.)]\s+)/gm, '') + .replace(/(\*\*|__)(.*?)\1/g, '$2') + .replace(/(? line.replace(/[\t \u00a0]+/g, ' ').trim()) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export async function readManuscriptFile( + filePath: string, + options: { signal?: AbortSignal } = {}, +): Promise { + throwIfAborted(options.signal); + if (!isSupportedManuscriptPath(filePath)) { + throw new ManuscriptFileError( + 'unsupported', + `Unsupported manuscript extension: ${path.extname(filePath)}`, + ); + } + + let stat: fs.Stats; + try { + stat = await fs.promises.stat(filePath); + } catch (error) { + const code = + (error as NodeJS.ErrnoException)?.code === 'ENOENT' + ? 'notFound' + : 'unreadable'; + throw new ManuscriptFileError(code, `Cannot access manuscript: ${error}`); + } + if (!stat.isFile()) { + throw new ManuscriptFileError( + 'notFile', + 'The selected manuscript is not a file', + ); + } + if (stat.size > MANUSCRIPT_MAX_BYTES) { + throw new ManuscriptFileError( + 'tooLarge', + `Manuscript exceeds ${MANUSCRIPT_MAX_BYTES} bytes`, + ); + } + + let buffer: Buffer; + try { + buffer = await fs.promises.readFile(filePath, { + signal: options.signal, + }); + } catch (error) { + if (options.signal?.aborted) throw manuscriptAbortError(); + throw new ManuscriptFileError( + 'unreadable', + `Cannot read manuscript: ${error}`, + ); + } + // Recheck the bytes actually read: the file may have grown between stat/read. + if (buffer.length > MANUSCRIPT_MAX_BYTES) { + throw new ManuscriptFileError( + 'tooLarge', + `Manuscript exceeds ${MANUSCRIPT_MAX_BYTES} bytes`, + ); + } + throwIfAborted(options.signal); + const decoded = decodeManuscriptBuffer(buffer); + const extension = path.extname(filePath).toLowerCase(); + const text = normalizeManuscriptText( + decoded.text, + extension === '.md' || extension === '.markdown', + ); + const segmented = await segmentManuscriptWithMetrics(text, options.signal); + if (!text || segmented.comparableCharacterCount === 0) { + throw new ManuscriptFileError( + 'empty', + 'The manuscript contains no matchable text', + ); + } + return { + path: filePath, + name: path.basename(filePath), + text, + units: segmented.units, + size: buffer.length, + encoding: decoded.encoding, + characterCount: segmented.characterCount, + comparableCharacterCount: segmented.comparableCharacterCount, + }; +} + +export function toManuscriptSelectionPayload( + manuscript: LoadedManuscript, +): ManuscriptSelectionPayload { + return { + path: manuscript.path, + name: manuscript.name, + size: manuscript.size, + encoding: manuscript.encoding, + characterCount: manuscript.characterCount, + }; +} + +const MATCHABLE_CHARACTER = /[\p{L}\p{N}]/u; +const SOFT_SPLIT_CHARACTER = /[\s,,、::]/u; + +function comparableValues(rawCharacter: string): string[] { + const values: string[] = []; + const normalized = rawCharacter.normalize('NFKC').toLowerCase(); + for (const normalizedCharacter of normalized) { + if (MATCHABLE_CHARACTER.test(normalizedCharacter)) { + values.push(normalizedCharacter); + } + } + return values; +} + +function toComparableChars(text: string): ComparableChar[] { + const chars: ComparableChar[] = []; + const matcher = /./gu; + let match: RegExpExecArray | null; + while ((match = matcher.exec(text))) { + // 不使用 toLocaleLowerCase:土耳其语等系统区域会让相同文件产生不同匹配结果。 + for (const normalizedChar of comparableValues(match[0])) { + chars.push({ + value: normalizedChar, + start: match.index, + end: match.index + match[0].length, + }); + } + } + return chars; +} + +function makeComparable(text: string): ComparableSequence { + const symbols: string[] = []; + for (const rawCharacter of text) { + symbols.push(...comparableValues(rawCharacter)); + } + const value = symbols.join(''); + const bigrams = new Map(); + if (symbols.length === 1) { + bigrams.set(symbols[0], 1); + } else { + for (let index = 0; index + 1 < symbols.length; index += 1) { + const gram = symbols[index] + symbols[index + 1]; + bigrams.set(gram, (bigrams.get(gram) ?? 0) + 1); + } + } + return { value, length: symbols.length, bigrams }; +} + +function joinVisibleText(parts: string[]): string { + let output = ''; + for (const rawPart of parts) { + const part = rawPart.trim(); + if (!part) continue; + if (!output) { + output = part; + continue; + } + const last = output.at(-1) ?? ''; + const first = part.at(0) ?? ''; + const firstIsCjk = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test( + first, + ); + const visibleLatinBoundary = + !firstIsCjk && /[\p{L}\p{N}]/u.test(first) && !/\s/u.test(last); + output += `${visibleLatinBoundary ? ' ' : ''}${part}`; + } + return output; +} + +interface TextMetrics { + characterCount: number; + comparableCharacterCount: number; +} + +interface SegmentedManuscript extends TextMetrics { + units: string[]; +} + +async function measureText( + text: string, + signal?: AbortSignal, +): Promise { + let characterCount = 0; + let comparableCharacterCount = 0; + let rawIndex = 0; + let nextYield = YIELD_EVERY_OPERATIONS; + for (const rawCharacter of text) { + characterCount += 1; + comparableCharacterCount += comparableValues(rawCharacter).length; + if (comparableCharacterCount > MANUSCRIPT_MAX_COMPARABLE_CHARS) { + throw new ManuscriptFileError( + 'tooComplex', + `Manuscript exceeds ${MANUSCRIPT_MAX_COMPARABLE_CHARS} comparable characters`, + ); + } + rawIndex += rawCharacter.length; + if (rawIndex >= nextYield) { + await cooperativeYield(signal); + nextYield = rawIndex + YIELD_EVERY_OPERATIONS; + } + } + return { characterCount, comparableCharacterCount }; +} + +/** + * Splits one long sentence in linear time. Comparable offsets and soft + * boundaries are collected once; each subsequent cut advances monotonically. + */ +async function splitLongUnit( + input: string, + targetLength = 56, + signal?: AbortSignal, +): Promise { + const text = input.trim(); + if (!text) return []; + + const comparableEnds: number[] = []; + const boundaries: Array<{ comparableCount: number; rawEnd: number }> = []; + let rawIndex = 0; + let nextYield = YIELD_EVERY_OPERATIONS; + for (const rawCharacter of text) { + rawIndex += rawCharacter.length; + for (const _value of comparableValues(rawCharacter)) { + comparableEnds.push(rawIndex); + } + if (SOFT_SPLIT_CHARACTER.test(rawCharacter)) { + boundaries.push({ + comparableCount: comparableEnds.length, + rawEnd: rawIndex, + }); + } + if (rawIndex >= nextYield) { + await cooperativeYield(signal); + nextYield = rawIndex + YIELD_EVERY_OPERATIONS; + } + } + + const maxTailLength = Math.floor(targetLength * 1.5); + if (comparableEnds.length <= maxTailLength) { + return comparableEnds.length > 0 ? [text] : []; + } + + const minimumOffset = Math.max(1, Math.floor(targetLength * 0.55)); + const maximumOffset = Math.max(minimumOffset, Math.ceil(targetLength * 1.35)); + const parts: string[] = []; + let comparableStart = 0; + let rawStart = 0; + let boundaryStart = 0; + let nextSplitYield = YIELD_EVERY_OPERATIONS; + + while (comparableEnds.length - comparableStart > maxTailLength) { + const targetComparable = Math.min( + comparableEnds.length, + comparableStart + targetLength, + ); + const minimumComparable = comparableStart + minimumOffset; + const maximumComparable = Math.min( + comparableEnds.length, + comparableStart + maximumOffset, + ); + while ( + boundaryStart < boundaries.length && + boundaries[boundaryStart].comparableCount <= comparableStart + ) { + boundaryStart += 1; + } + + let bestBoundary: { comparableCount: number; rawEnd: number } | undefined; + for ( + let boundaryIndex = boundaryStart; + boundaryIndex < boundaries.length; + boundaryIndex += 1 + ) { + const boundary = boundaries[boundaryIndex]; + if (boundary.comparableCount > maximumComparable) break; + if (boundary.comparableCount < minimumComparable) continue; + if ( + !bestBoundary || + Math.abs(boundary.comparableCount - targetComparable) < + Math.abs(bestBoundary.comparableCount - targetComparable) + ) { + bestBoundary = boundary; + } + } + + const rawCut = + bestBoundary?.rawEnd ?? + comparableEnds[Math.max(comparableStart, targetComparable - 1)]; + if (!rawCut || rawCut <= rawStart) break; + const head = text.slice(rawStart, rawCut).trim(); + if (head) parts.push(head); + rawStart = rawCut; + while ( + comparableStart < comparableEnds.length && + comparableEnds[comparableStart] <= rawCut + ) { + comparableStart += 1; + } + if (comparableStart >= nextSplitYield) { + await cooperativeYield(signal); + nextSplitYield = comparableStart + YIELD_EVERY_OPERATIONS; + } + } + + const tail = text.slice(rawStart).trim(); + if (tail) parts.push(tail); + return parts; +} + +async function segmentManuscriptWithMetrics( + text: string, + signal?: AbortSignal, +): Promise { + throwIfAborted(signal); + const normalized = normalizeManuscriptText(text); + const metrics = await measureText(normalized, signal); + const units: string[] = []; + + const appendPiece = async (piece: string): Promise => { + for (const part of await splitLongUnit(piece, 56, signal)) { + if (units.length >= MANUSCRIPT_MAX_UNITS) { + throw new ManuscriptFileError( + 'tooComplex', + `Manuscript exceeds ${MANUSCRIPT_MAX_UNITS} matching units`, + ); + } + units.push(part); + } + }; + + let processed = 0; + let nextYield = YIELD_EVERY_OPERATIONS; + for (const paragraph of normalized.split(/\n+/)) { + let start = 0; + for (let index = 0; index < paragraph.length; index += 1) { + const current = paragraph[index]; + const next = paragraph[index + 1] ?? ''; + const hardBoundary = /[。!?!?;;]/u.test(current); + const englishPeriodBoundary = + current === '.' && (!next || /\s/u.test(next)); + if (hardBoundary || englishPeriodBoundary) { + const piece = paragraph.slice(start, index + 1).trim(); + if (piece) await appendPiece(piece); + start = index + 1; + while (start < paragraph.length && /\s/u.test(paragraph[start])) { + start += 1; + } + index = start - 1; + } + processed += 1; + if (processed >= nextYield) { + await cooperativeYield(signal); + nextYield = processed + YIELD_EVERY_OPERATIONS; + } + } + const tail = paragraph.slice(start).trim(); + if (tail) await appendPiece(tail); + } + return { ...metrics, units }; +} + +export async function segmentManuscript( + text: string, + options: { signal?: AbortSignal } = {}, +): Promise { + return (await segmentManuscriptWithMetrics(text, options.signal)).units; +} + +function diceSimilarity( + left: ComparableSequence, + right: ComparableSequence, +): number { + if (!left.length || !right.length) return 0; + if (left.value === right.value) return 1; + if (left.length === 1 || right.length === 1) { + return left.value === right.value ? 1 : 0; + } + const smaller = + left.bigrams.size <= right.bigrams.size ? left.bigrams : right.bigrams; + const larger = smaller === left.bigrams ? right.bigrams : left.bigrams; + let intersection = 0; + smaller.forEach((count, gram) => { + intersection += Math.min(count, larger.get(gram) ?? 0); + }); + const leftTotal = Math.max(1, left.length - 1); + const rightTotal = Math.max(1, right.length - 1); + const dice = (2 * intersection) / (leftTotal + rightTotal); + const lengthRatio = + Math.min(left.length, right.length) / Math.max(left.length, right.length); + return dice * 0.88 + lengthRatio * 0.12; +} + +function orderedBigrams(value: string): string[] { + const symbols = Array.from(value); + if (symbols.length <= 1) return symbols; + const grams = new Array(symbols.length - 1); + for (let index = 0; index + 1 < symbols.length; index += 1) { + grams[index] = symbols[index] + symbols[index + 1]; + } + return grams; +} + +/** + * Banded Levenshtein over the ordered bigram sequence. Dice is fast and useful + * for candidate discovery, but its multiset representation cannot distinguish + * reordered clauses. This gate restores order while limiting work to the edit + * band implied by the current confidence threshold. + */ +function orderedBigramSimilarity( + left: ComparableSequence, + right: ComparableSequence, + minimumSimilarity: number, +): number { + if (left.value === right.value) return 1; + const leftGrams = orderedBigrams(left.value); + const rightGrams = orderedBigrams(right.value); + const maximumComparableLength = Math.max(left.length, right.length); + if (maximumComparableLength === 0) return 0; + const maximumDistance = Math.floor( + (1 - minimumSimilarity) * maximumComparableLength, + ); + if (Math.abs(leftGrams.length - rightGrams.length) > maximumDistance) { + return 0; + } + + const infinity = + maximumDistance + Math.max(leftGrams.length, rightGrams.length) + 1; + let previous = new Int32Array(rightGrams.length + 1); + let current = new Int32Array(rightGrams.length + 1); + previous.fill(infinity); + for ( + let index = 0; + index <= Math.min(rightGrams.length, maximumDistance); + index += 1 + ) { + previous[index] = index; + } + + for (let leftIndex = 1; leftIndex <= leftGrams.length; leftIndex += 1) { + current.fill(infinity); + if (leftIndex <= maximumDistance) current[0] = leftIndex; + const from = Math.max(1, leftIndex - maximumDistance); + const to = Math.min(rightGrams.length, leftIndex + maximumDistance); + let rowMinimum = infinity; + for (let rightIndex = from; rightIndex <= to; rightIndex += 1) { + const substitution = + previous[rightIndex - 1] + + (leftGrams[leftIndex - 1] === rightGrams[rightIndex - 1] ? 0 : 1); + const distance = Math.min( + previous[rightIndex] + 1, + current[rightIndex - 1] + 1, + substitution, + ); + current[rightIndex] = distance; + rowMinimum = Math.min(rowMinimum, distance); + } + if (rowMinimum > maximumDistance) return 0; + [previous, current] = [current, previous]; + } + + const distance = previous[rightGrams.length]; + return distance <= maximumDistance + ? 1 - distance / maximumComparableLength + : 0; +} + +function uniqueNgramPositions( + value: string, + size: number, +): Map { + const symbols = Array.from(value); + const positions = new Map(); + for (let index = 0; index + size <= symbols.length; index += 1) { + const gram = symbols.slice(index, index + size).join(''); + positions.set(gram, positions.has(gram) ? null : index); + } + const unique = new Map(); + positions.forEach((position, gram) => { + if (position !== null) unique.set(gram, position); + }); + return unique; +} + +/** + * A real local swap supplies two directional anchors: an earlier source anchor + * moves right in the manuscript, a later source anchor moves left, and their + * mapped order crosses. A single coincidental anchor or monotonic ASR edits + * cannot satisfy that pair. Bigrams catch swapped two-character words while + * trigrams provide a stronger fallback when a bigram is repeated elsewhere. + * Single-character anchors are intentionally excluded because two ordinary + * substitutions are indistinguishable from swapping two isolated characters. + */ +function hasDirectionalUniqueNgramCrossing( + left: ComparableSequence, + right: ComparableSequence, + size: number, +): boolean { + const leftPositions = uniqueNgramPositions(left.value, size); + const rightPositions = uniqueNgramPositions(right.value, size); + const anchors = Array.from(leftPositions.entries()) + .map(([gram, leftPosition]) => ({ + leftPosition, + rightPosition: rightPositions.get(gram), + })) + .filter( + (anchor): anchor is { leftPosition: number; rightPosition: number } => + anchor.rightPosition !== undefined, + ) + .sort((leftAnchor, rightAnchor) => { + return leftAnchor.leftPosition - rightAnchor.leftPosition; + }); + + let furthestRightMovingPosition = -1; + for (const anchor of anchors) { + const displacement = anchor.rightPosition - anchor.leftPosition; + if ( + displacement <= -MIN_REORDER_ANCHOR_DISPLACEMENT && + furthestRightMovingPosition > anchor.rightPosition + ) { + return true; + } + if (displacement >= MIN_REORDER_ANCHOR_DISPLACEMENT) { + furthestRightMovingPosition = Math.max( + furthestRightMovingPosition, + anchor.rightPosition, + ); + } + } + return false; +} + +function hasSupportedLocalReordering( + left: ComparableSequence, + right: ComparableSequence, +): boolean { + return REORDER_NGRAM_SIZES.some((size) => { + return hasDirectionalUniqueNgramCrossing(left, right, size); + }); +} + +/** + * Exact one-character insertion, deletion, or substitution, computed in + * linear time. Requiring at least ten characters on the shorter side keeps + * very short utterances from receiving an overly permissive one-edit budget. + */ +function singleCharacterEditSimilarity( + left: ComparableSequence, + right: ComparableSequence, +): number | null { + if (Math.abs(left.length - right.length) > 1) return null; + if (Math.min(left.length, right.length) < MIN_SINGLE_CHARACTER_EDIT_LENGTH) { + return null; + } + if (left.length === right.length) { + const leftSymbols = Array.from(left.value); + const rightSymbols = Array.from(right.value); + let mismatches = 0; + for (let index = 0; index < leftSymbols.length; index += 1) { + if (leftSymbols[index] !== rightSymbols[index]) { + mismatches += 1; + if (mismatches > 1) return null; + } + } + return mismatches === 1 ? 1 - 1 / left.length : null; + } + const shorter = Array.from( + left.length < right.length ? left.value : right.value, + ); + const longer = Array.from( + left.length < right.length ? right.value : left.value, + ); + let shorterIndex = 0; + let longerIndex = 0; + let skipped = false; + while (shorterIndex < shorter.length && longerIndex < longer.length) { + if (shorter[shorterIndex] === longer[longerIndex]) { + shorterIndex += 1; + longerIndex += 1; + continue; + } + if (skipped) return null; + skipped = true; + longerIndex += 1; + } + // With a one-character length difference, an unmatched trailing character + // is the single edit when no earlier skip was needed. + return 1 - 1 / longer.length; +} + +function safeSimilarity( + left: ComparableSequence, + right: ComparableSequence, + threshold: number, +): number { + if (left.value === right.value) return 1; + const unordered = diceSimilarity(left, right); + const singleCharacterEdit = singleCharacterEditSimilarity(left, right); + if (singleCharacterEdit !== null) { + return Math.max(unordered, singleCharacterEdit); + } + // Position maps are only built for viable candidates. Ordinary + // low-confidence pairs leave through this fast path. + if (unordered < threshold) return unordered; + if (hasSupportedLocalReordering(left, right)) return 0; + const ordered = orderedBigramSimilarity(left, right, threshold); + if (unordered - ordered > MAX_UNORDERED_ORDERED_GAP) return 0; + return Math.min(unordered, ordered); +} + +/** + * 置信阈值依据: + * - 24+ 可比较字符有足够上下文,允许约 20% 的 ASR 字符/二元组误差(0.76); + * - 12–23 字符提高到 0.82; + * - 更短片段容易和口头禅/标题误撞,分别要求 0.88 / 0.94。 + * 同时对 0.94 以下候选要求至少 0.025 的次优间隔,避免重复句误配。 + */ +export function manuscriptConfidenceThreshold(evidenceLength: number): number { + if (evidenceLength >= 24) return 0.76; + if (evidenceLength >= 12) return 0.82; + if (evidenceLength >= 6) return 0.88; + return 0.94; +} + +function makeGroupComparable( + texts: string[], + start: number, + count: number, + cache: Map, +): ComparableSequence { + const key = `${start}:${count}`; + const cached = cache.get(key); + if (cached) return cached; + const value = makeComparable( + joinVisibleText(texts.slice(start, start + count)), + ); + cache.set(key, value); + return value; +} + +function uniqueTrigrams(value: string): string[] { + const grams = new Set(); + const symbols = Array.from(value); + if (symbols.length < 3) return []; + for (let index = 0; index + 2 < symbols.length; index += 1) { + grams.add(symbols[index] + symbols[index + 1] + symbols[index + 2]); + } + return Array.from(grams); +} + +async function buildTrigramIndex( + units: ManuscriptUnit[], + signal?: AbortSignal, +): Promise> { + const index = new Map(); + for (let unitIndex = 0; unitIndex < units.length; unitIndex += 1) { + const unit = units[unitIndex]; + for (const gram of uniqueTrigrams(unit.comparable.value)) { + const positions = index.get(gram) ?? []; + if (positions.length < MAX_INDEXED_POSITIONS) positions.push(unitIndex); + index.set(gram, positions); + } + if ((unitIndex + 1) % 256 === 0) await cooperativeYield(signal); + } + return index; +} + +function candidateStarts( + cueComparable: ComparableSequence, + cursor: number, + unitCount: number, + trigramIndex: Map, +): number[] { + const starts = new Set(); + for ( + let index = cursor; + index < Math.min(unitCount, cursor + LOCAL_LOOKAHEAD); + index += 1 + ) { + starts.add(index); + } + + const votes = new Map(); + const rareGrams = uniqueTrigrams(cueComparable.value) + .map((gram) => ({ gram, positions: trigramIndex.get(gram) ?? [] })) + .filter((item) => item.positions.length > 0) + .sort((left, right) => left.positions.length - right.positions.length) + .slice(0, 10); + for (const { positions } of rareGrams) { + for (const position of positions) { + for (let offset = 0; offset < MAX_GROUP_SIZE; offset += 1) { + const start = position - offset; + if (start >= cursor && start < unitCount) { + votes.set(start, (votes.get(start) ?? 0) + 1); + } + } + } + } + Array.from(votes.entries()) + .sort( + ([leftPos, leftVotes], [rightPos, rightVotes]) => + rightVotes - leftVotes || leftPos - rightPos, + ) + .slice(0, MAX_FAR_CANDIDATES) + .forEach(([position]) => starts.add(position)); + return Array.from(starts).sort((left, right) => left - right); +} + +function splitTextByWeights(text: string, weights: number[]): string[] | null { + if (weights.length === 1) return [text.trim()]; + const chars = toComparableChars(text); + if (chars.length < weights.length) return null; + const safeWeights = weights.map((weight) => Math.max(1, weight)); + const totalWeight = safeWeights.reduce((sum, weight) => sum + weight, 0); + const boundaries = [0]; + let cumulative = 0; + for (let index = 0; index + 1 < safeWeights.length; index += 1) { + cumulative += safeWeights[index]; + const desired = Math.round((cumulative / totalWeight) * chars.length); + const minimum = boundaries[index] + 1; + const maximum = chars.length - (safeWeights.length - index - 1); + boundaries.push(Math.max(minimum, Math.min(maximum, desired))); + } + boundaries.push(chars.length); + + const result: string[] = []; + for (let index = 0; index < weights.length; index += 1) { + const fromChar = boundaries[index]; + const toChar = boundaries[index + 1]; + const rawStart = fromChar === 0 ? 0 : chars[fromChar].start; + const rawEnd = toChar >= chars.length ? text.length : chars[toChar].start; + const piece = text.slice(rawStart, rawEnd).trim(); + if (!piece) return null; + result.push(piece); + } + return result; +} + +function roundConfidence(value: number): number { + return Math.round(value * 1000) / 1000; +} + +/** + * 按字幕与文稿顺序做单调匹配。算法只会向前移动文稿游标;每一步可合并最多三条 + * cue / 三个文稿单元,覆盖常见的分句粒度差异。局部窗口之外通过稀有三元组索引恢复 + * 锚点,因此文稿中的标题、舞台说明或 ASR 漏段不会让后续整体漂移。 + * + * 只有超过长度分级阈值且不存在近似等价次优位置的组才替换。未命中的 cue 原样复制, + * 返回对象也只修改 text 字段,调用方可据此保证时间轴不变。 + */ +export async function matchManuscriptToCues( + inputCues: T[], + manuscriptText: string, + options: ManuscriptMatchOptions = {}, +): Promise> { + throwIfAborted(options.signal); + await cooperativeYield(options.signal); + const cues = inputCues.map((cue) => ({ ...cue })); + const segmentedUnits = + options.manuscriptUnits ?? + (await segmentManuscript(manuscriptText, { signal: options.signal })); + if (segmentedUnits.length > MANUSCRIPT_MAX_UNITS) { + throw new ManuscriptFileError( + 'tooComplex', + `Manuscript exceeds ${MANUSCRIPT_MAX_UNITS} matching units`, + ); + } + const manuscriptUnits: ManuscriptUnit[] = []; + for (let index = 0; index < segmentedUnits.length; index += 1) { + const text = segmentedUnits[index]; + manuscriptUnits.push({ text, comparable: makeComparable(text) }); + if ((index + 1) % 256 === 0) await cooperativeYield(options.signal); + } + const result: ManuscriptMatchResult = { + cues, + totalCues: cues.length, + replacedCues: 0, + matchedGroups: 0, + averageConfidence: 0, + manuscriptUnits: manuscriptUnits.length, + matches: [], + }; + if (cues.length === 0 || manuscriptUnits.length === 0) return result; + + const cueTexts = cues.map((cue) => String(cue.text ?? '')); + const manuscriptTexts = manuscriptUnits.map((unit) => unit.text); + const cueCache = new Map(); + const manuscriptCache = new Map(); + manuscriptUnits.forEach((unit, index) => { + manuscriptCache.set(`${index}:1`, unit.comparable); + }); + const trigramIndex = await buildTrigramIndex(manuscriptUnits, options.signal); + + let cueIndex = 0; + let manuscriptCursor = 0; + let confidenceTotal = 0; + let comparisonsSinceYield = 0; + while (cueIndex < cues.length && manuscriptCursor < manuscriptUnits.length) { + throwIfAborted(options.signal); + if (cueIndex > 0 && cueIndex % 8 === 0) { + await cooperativeYield(options.signal); + } + // 同一文稿起点可能因 1↔多分组产生多个候选。先按起点去重,最终再从 + // “不同起点”里取次优,避免把同一位置的另一分组误当成歧义候选。 + const bestByManuscriptStart = new Map(); + + for ( + let cueCount = 1; + cueCount <= MAX_GROUP_SIZE && cueIndex + cueCount <= cues.length; + cueCount += 1 + ) { + const cueComparable = makeGroupComparable( + cueTexts, + cueIndex, + cueCount, + cueCache, + ); + if (!cueComparable.length) continue; + const threshold = manuscriptConfidenceThreshold(cueComparable.length); + const starts = candidateStarts( + cueComparable, + manuscriptCursor, + manuscriptUnits.length, + trigramIndex, + ); + for (const manuscriptStart of starts) { + for ( + let manuscriptCount = 1; + manuscriptCount <= MAX_GROUP_SIZE && + manuscriptStart + manuscriptCount <= manuscriptUnits.length; + manuscriptCount += 1 + ) { + const referenceComparable = makeGroupComparable( + manuscriptTexts, + manuscriptStart, + manuscriptCount, + manuscriptCache, + ); + const lengthRatio = + Math.min(cueComparable.length, referenceComparable.length) / + Math.max(cueComparable.length, referenceComparable.length); + if (lengthRatio < 0.45) continue; + const similarity = safeSimilarity( + cueComparable, + referenceComparable, + threshold, + ); + // 远距离锚点轻微降权,只负责打破相似候选平局;高置信远端仍可恢复。 + const skippedUnits = manuscriptStart - manuscriptCursor; + const rank = + similarity - + Math.min(skippedUnits, 400) * 0.00008 - + (cueCount + manuscriptCount - 2) * 0.002; + const candidate: MatchCandidate = { + cueCount, + manuscriptStart, + manuscriptCount, + similarity, + rank, + }; + const existingAtStart = bestByManuscriptStart.get(manuscriptStart); + if (!existingAtStart || candidate.rank > existingAtStart.rank) { + bestByManuscriptStart.set(manuscriptStart, candidate); + } + comparisonsSinceYield += 1; + if (comparisonsSinceYield >= 64) { + await cooperativeYield(options.signal); + comparisonsSinceYield = 0; + } + } + } + } + + const candidates = Array.from(bestByManuscriptStart.values()); + const best = + candidates.sort((left, right) => right.rank - left.rank)[0] ?? null; + if (!best) { + cueIndex += 1; + continue; + } + const secondAtDifferentPosition = + candidates + .filter( + (candidate) => candidate.manuscriptStart !== best.manuscriptStart, + ) + .sort((left, right) => right.similarity - left.similarity)[0] ?? null; + const bestCueComparable = makeGroupComparable( + cueTexts, + cueIndex, + best.cueCount, + cueCache, + ); + const threshold = manuscriptConfidenceThreshold(bestCueComparable.length); + const margin = + best.similarity - (secondAtDifferentPosition?.similarity ?? 0); + const unambiguous = best.similarity >= 0.94 || margin >= 0.025; + if (best.similarity < threshold || !unambiguous) { + cueIndex += 1; + continue; + } + + const referenceText = joinVisibleText( + manuscriptTexts.slice( + best.manuscriptStart, + best.manuscriptStart + best.manuscriptCount, + ), + ); + const cueWeights = cueTexts + .slice(cueIndex, cueIndex + best.cueCount) + .map((text) => Math.max(1, makeComparable(text).length)); + const replacements = splitTextByWeights(referenceText, cueWeights); + if (!replacements || replacements.length !== best.cueCount) { + cueIndex += 1; + continue; + } + + const originalTexts: string[] = []; + replacements.forEach((replacement, offset) => { + originalTexts.push(String(cues[cueIndex + offset].text ?? '')); + cues[cueIndex + offset].text = replacement; + }); + const match: ManuscriptCueMatch = { + cueStart: cueIndex, + cueCount: best.cueCount, + manuscriptUnitStart: best.manuscriptStart, + manuscriptUnitCount: best.manuscriptCount, + confidence: roundConfidence(best.similarity), + originalTexts, + replacementTexts: replacements, + }; + result.matches.push(match); + result.replacedCues += best.cueCount; + result.matchedGroups += 1; + confidenceTotal += best.similarity * best.cueCount; + cueIndex += best.cueCount; + manuscriptCursor = best.manuscriptStart + best.manuscriptCount; + if (cueIndex % 16 === 0) await cooperativeYield(options.signal); + } + + result.averageConfidence = + result.replacedCues > 0 + ? roundConfidence(confidenceTotal / result.replacedCues) + : 0; + return result; +} + +interface RawLineSpan { + text: string; + start: number; + end: number; +} + +function rawLineSpans(block: string): RawLineSpan[] { + const lines: RawLineSpan[] = []; + const lineBreak = /\r\n|\n|\r/g; + let start = 0; + let match: RegExpExecArray | null; + while ((match = lineBreak.exec(block))) { + lines.push({ + text: block.slice(start, match.index), + start, + end: match.index, + }); + start = match.index + match[0].length; + } + if (start < block.length) { + lines.push({ text: block.slice(start), start, end: block.length }); + } + return lines; +} + +/** + * Replaces only selected cue text spans in an SRT string. Sequence identifiers, + * timing lines, separators, line endings, and every unmatched cue remain byte + * for byte identical. + */ +export function replaceMatchedSrtCueTexts( + originalSrt: string, + replacements: ReadonlyMap, +): string { + if (replacements.size === 0) return originalSrt; + const parts = originalSrt.split(/((?:\r?\n){2,}|\r{2,})/); + let cueIndex = 0; + let applied = 0; + + for (let partIndex = 0; partIndex < parts.length; partIndex += 2) { + const block = parts[partIndex]; + const lines = rawLineSpans(block); + const nonEmptyLines = lines.filter((line) => line.text.trim() !== ''); + const timingIndex = nonEmptyLines.findIndex((line) => + line.text.includes('-->'), + ); + if (timingIndex < 0) continue; + const textLines = nonEmptyLines.slice(timingIndex + 1); + if (textLines.length === 0) continue; + + const replacement = replacements.get(cueIndex); + if (replacement !== undefined) { + const firstTextLine = textLines[0]; + const lastTextLine = textLines[textLines.length - 1]; + parts[partIndex] = + block.slice(0, firstTextLine.start) + + replacement + + block.slice(lastTextLine.end); + applied += 1; + } + cueIndex += 1; + } + + if (applied !== replacements.size) { + throw new Error( + `Could not safely locate all matched SRT cues (${applied}/${replacements.size})`, + ); + } + return parts.join(''); +} diff --git a/main/helpers/manuscriptMatchingStage.ts b/main/helpers/manuscriptMatchingStage.ts new file mode 100644 index 00000000..306949a3 --- /dev/null +++ b/main/helpers/manuscriptMatchingStage.ts @@ -0,0 +1,216 @@ +import fs from 'fs'; +import { parseSubtitleCues } from './subtitleFormats'; +import { logMessage } from './storeManager'; +import { atomicReplaceTextFile } from './atomicFile'; +import { + TaskCancelledError, + getTaskContext, + isTaskCancelledError, +} from './taskContext'; +import type { IFiles, ManuscriptMatchSummary } from '../../types'; +import { + ManuscriptFileError, + getManuscriptConfig, + matchManuscriptToCues, + readManuscriptFile, + replaceMatchedSrtCueTexts, +} from './manuscriptMatching'; + +interface TaskEvent { + sender: { send: (channel: string, ...args: unknown[]) => void }; +} + +function toSummary( + manuscriptName: string, + totalCues: number, + replacedCues: number, + matchedGroups: number, + averageConfidence: number, +): ManuscriptMatchSummary { + return { + manuscriptName, + totalCues, + replacedCues, + matchedGroups, + averageConfidence, + }; +} + +function sendStageState( + event: TaskEvent, + file: IFiles, + state: '' | 'loading' | 'done', +): void { + file.manuscriptMatch = state; + event.sender.send('taskFileChange', { ...file, manuscriptMatch: state }); +} + +function sendProgress(event: TaskEvent, file: IFiles, progress: number): void { + event.sender.send( + 'taskProgressChange', + file, + 'manuscriptMatch', + Math.max(0, Math.min(100, Math.round(progress))), + ); +} + +/** + * 续跑复用已经落盘的字幕时只回放完成态;首次文稿匹配结果已包含在 srtFile 中, + * 不应重复用可能已变化的外部文稿改写。 + */ +export function settleSkippedManuscriptMatchStage( + event: TaskEvent, + file: IFiles, + formData?: Record, +): void { + if (!getManuscriptConfig(formData)) return; + sendProgress(event, file, 100); + sendStageState(event, file, 'done'); +} + +/** + * ASR 后的非致命文稿匹配阶段。任何文件错误、空文稿、低置信度或写入失败都以 + * done + warning 结算并保留原字幕;只有高置信结果通过全部时间轴不变性检查后才写回。 + */ +export async function runManuscriptMatchingStage( + event: TaskEvent, + file: IFiles, + formData?: Record, +): Promise { + const config = getManuscriptConfig(formData); + if (!config || !file.srtFile || !fs.existsSync(file.srtFile)) return; + const signal = getTaskContext()?.signal; + + const degrade = ( + code: string, + detail: string, + summary?: ManuscriptMatchSummary, + ): void => { + file.manuscriptMatchError = code; + file.manuscriptMatchErrorDetail = detail; + if (summary) file.manuscriptMatchSummary = summary; + event.sender.send('taskFileChange', { ...file }); + sendProgress(event, file, 100); + sendStageState(event, file, 'done'); + logMessage( + `manuscript matching skipped (non-fatal, ${code}): ${detail} (${file.fileName})`, + 'warning', + ); + }; + + try { + delete file.manuscriptMatchError; + delete file.manuscriptMatchErrorDetail; + delete file.manuscriptMatchSummary; + sendStageState(event, file, 'loading'); + sendProgress(event, file, 0); + + const [originalSrt, manuscript] = await Promise.all([ + fs.promises.readFile(file.srtFile, 'utf-8'), + readManuscriptFile(config.path, { signal }), + ]); + if (signal?.aborted) throw new TaskCancelledError(); + sendProgress(event, file, 25); + + const parsed = parseSubtitleCues(originalSrt, 'srt'); + const originalTimes = parsed.map((cue) => [cue.startMs, cue.endMs]); + const sourceCues = parsed.map((cue) => ({ + startMs: cue.startMs, + endMs: cue.endMs, + // Comparable normalization ignores whitespace; retain the exact text so + // an unmatched cue can remain byte-for-byte untouched in the source SRT. + text: cue.text, + })); + if (sourceCues.length === 0) { + degrade( + 'noCues', + 'source subtitle has no cues', + toSummary(manuscript.name, 0, 0, 0, 0), + ); + return; + } + + const outcome = await matchManuscriptToCues(sourceCues, manuscript.text, { + signal, + manuscriptUnits: manuscript.units, + }); + const summary = toSummary( + manuscript.name, + outcome.totalCues, + outcome.replacedCues, + outcome.matchedGroups, + outcome.averageConfidence, + ); + file.manuscriptMatchSummary = summary; + sendProgress(event, file, 80); + if (signal?.aborted) throw new TaskCancelledError(); + + if (outcome.replacedCues === 0) { + degrade('noMatch', 'no high-confidence manuscript matches', summary); + return; + } + const timesUnchanged = outcome.cues.every( + (cue, index) => + cue.startMs === originalTimes[index]?.[0] && + cue.endMs === originalTimes[index]?.[1], + ); + if (outcome.cues.length !== parsed.length || !timesUnchanged) { + degrade( + 'timeline', + 'timeline invariant violated; original ASR kept', + summary, + ); + return; + } + + const replacements = new Map(); + for (const match of outcome.matches) { + match.replacementTexts.forEach((replacement, offset) => { + replacements.set(match.cueStart + offset, replacement); + }); + } + const output = replaceMatchedSrtCueTexts(originalSrt, replacements); + const writtenCues = parseSubtitleCues(output, 'srt'); + const writtenTimelineUnchanged = + writtenCues.length === parsed.length && + writtenCues.every( + (cue, index) => + cue.startMs === originalTimes[index]?.[0] && + cue.endMs === originalTimes[index]?.[1], + ); + if ( + replacements.size !== outcome.replacedCues || + !writtenTimelineUnchanged + ) { + degrade( + 'timeline', + 'safe SRT text replacement validation failed; original ASR kept', + summary, + ); + return; + } + + // Temp is flushed and closed before a single same-directory atomic rename. + // Any failure leaves the existing SRT untouched. + await atomicReplaceTextFile(file.srtFile, output, { signal }); + + event.sender.send('taskFileChange', { ...file }); + sendProgress(event, file, 100); + sendStageState(event, file, 'done'); + logMessage( + `manuscript matching done: ${outcome.replacedCues}/${outcome.totalCues} cues, ${outcome.matchedGroups} groups, avg confidence ${outcome.averageConfidence.toFixed(3)} (${file.fileName})`, + 'info', + ); + } catch (error) { + if (isTaskCancelledError(error) || signal?.aborted) { + sendStageState(event, file, ''); + throw error instanceof TaskCancelledError + ? error + : new TaskCancelledError(); + } + const code = + error instanceof ManuscriptFileError ? error.code : 'processing'; + const detail = error instanceof Error ? error.message : String(error); + degrade(code, detail); + } +} diff --git a/main/helpers/taskProcessor.ts b/main/helpers/taskProcessor.ts index e3ddb2c8..3b007570 100644 --- a/main/helpers/taskProcessor.ts +++ b/main/helpers/taskProcessor.ts @@ -248,7 +248,7 @@ async function startTaskRun( const pid = projectId || DEFAULT_PROJECT_ID; dispatchEvent = event; - // 任务级配置快照:带附加阶段(配音/合成)或角色分离的任务以创建时快照为准, + // 任务级配置快照:带附加阶段、参考文稿或角色分离的任务以创建时快照为准, // 重试/续跑不受此后全局设置变更影响;首次提交时把有效配置写入快照。 let formData = enforceSpeakerDiarizationTaskBoundary({ ...(incomingFormData || {}), diff --git a/main/helpers/workItemMigration.ts b/main/helpers/workItemMigration.ts index 15da10cc..22fa3604 100644 --- a/main/helpers/workItemMigration.ts +++ b/main/helpers/workItemMigration.ts @@ -9,6 +9,8 @@ import { const STAGE_KEYS = [ 'extractAudio', 'extractSubtitle', + 'refineSubtitle', + 'manuscriptMatch', 'translateSubtitle', 'prepareSubtitle', 'dubbing', diff --git a/package.json b/package.json index d9390506..04718424 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:custom-languages": "tsc scripts/test-custom-languages.ts --outDir node_modules/.cache/custom-language-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck && node node_modules/.cache/custom-language-tests/scripts/test-custom-languages.js", "test:refine": "tsc scripts/test-refine-units.ts --outDir node_modules/.cache/refine-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/refine-tests/scripts/test-refine-units.js", "test:speaker-diarization": "node scripts/test-speaker-diarization-config.cjs && tsc scripts/test-speaker-diarization.ts --outDir node_modules/.cache/speaker-diarization-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck && node node_modules/.cache/speaker-diarization-tests/scripts/test-speaker-diarization.js", + "test:manuscript": "tsc scripts/test-manuscript-matching.ts --outDir node_modules/.cache/manuscript-tests --module commonjs --moduleResolution node --target es2022 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/manuscript-tests/scripts/test-manuscript-matching.js", "longgap:gen": "tsc scripts/longgap/gen-audio.ts --outDir node_modules/.cache/longgap --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/longgap/scripts/longgap/gen-audio.js", "test:longgap": "tsc scripts/longgap/run.ts --outDir node_modules/.cache/longgap --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/longgap/scripts/longgap/run.js", "test:longgap:outcomes": "tsc scripts/longgap/run-outcomes.ts --outDir node_modules/.cache/longgap --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/longgap/scripts/longgap/run-outcomes.js", diff --git a/renderer/components/tasks/InlineConfigBar.tsx b/renderer/components/tasks/InlineConfigBar.tsx index 8a3c7257..0912f41b 100644 --- a/renderer/components/tasks/InlineConfigBar.tsx +++ b/renderer/components/tasks/InlineConfigBar.tsx @@ -14,6 +14,7 @@ import { AlertCircle, CheckCircle2, Download, Languages } from 'lucide-react'; import { Button } from '@/components/ui/button'; import Models from '@/components/Models'; import AiRefineControl from '@/components/tasks/AiRefineControl'; +import ManuscriptControl from '@/components/tasks/ManuscriptControl'; import { supportedLanguage } from 'lib/utils'; import { isProviderConfigured } from 'lib/providerUtils'; import { hasAnyModelAnyEngine } from 'lib/engineModels'; @@ -185,12 +186,15 @@ const InlineConfigBar: React.FC = ({ {/* AI 精修(外化到工具栏,openspec: add-ai-subtitle-refine):仅转写类任务展示 */} {typeDef.needsModel && ( - + <> + + + )} = ({ + form, + formData, +}) => { + const { t } = useTranslation('tasks'); + const [selecting, setSelecting] = useState(false); + const manuscriptPath = + typeof formData?.manuscriptPath === 'string' ? formData.manuscriptPath : ''; + const manuscriptName = + (typeof formData?.manuscriptName === 'string' && formData.manuscriptName) || + manuscriptPath.split(/[\\/]/).pop() || + ''; + + const setValue = (name: string, value: unknown) => + form.setValue(name, value, { shouldDirty: true }); + + const selectManuscript = async () => { + if (selecting) return; + setSelecting(true); + try { + const result = (await window?.ipc?.invoke( + 'manuscript:select', + )) as ManuscriptSelection | null; + if (!result) return; + if (result.errorCode || !result.path) { + const key = `manuscript.error.${result.errorCode || 'unreadable'}`; + toast.error( + t(key, { + defaultValue: result.error || t('manuscript.error.unreadable'), + }), + ); + return; + } + setValue('manuscriptPath', result.path); + setValue('manuscriptName', result.name || ''); + toast.success( + t('manuscript.selected', { + name: result.name || result.path, + }), + ); + } catch (error) { + toast.error( + t('manuscript.error.unreadable', { + detail: error instanceof Error ? error.message : String(error), + }), + ); + } finally { + setSelecting(false); + } + }; + + const clearManuscript = () => { + setValue('manuscriptPath', ''); + setValue('manuscriptName', ''); + toast.success(t('manuscript.cleared')); + }; + + return ( +
+ + {t('manuscript.label')} + + + + + + + + {manuscriptPath || t('manuscript.hint')} + + + + {manuscriptPath && ( + + )} +
+ ); +}; + +export default ManuscriptControl; diff --git a/renderer/components/tasks/SnapshotConfigBar.tsx b/renderer/components/tasks/SnapshotConfigBar.tsx index 1f62a81e..3ed77ae3 100644 --- a/renderer/components/tasks/SnapshotConfigBar.tsx +++ b/renderer/components/tasks/SnapshotConfigBar.tsx @@ -4,7 +4,14 @@ * 替代可编辑的 InlineConfigBar——避免全局配置与本任务无关却可改的误导。 */ import React, { useMemo } from 'react'; -import { AudioLines, Diamond, Film, Lock, Users } from 'lucide-react'; +import { + AudioLines, + Diamond, + FileText, + Film, + Lock, + Users, +} from 'lucide-react'; import { Tooltip, TooltipContent, @@ -243,6 +250,24 @@ const SnapshotConfigBar: React.FC = ({ /> )} + {needsTranscription && snapshot?.manuscriptPath && ( + + + + {snapshot.manuscriptName || + String(snapshot.manuscriptPath).split(/[\\/]/).pop()} + + + } + /> + )} + {snapshot?.sourceLanguage && ( string; + t: (key: string, options?: Record) => string; className?: string; }) { return ( @@ -107,6 +107,31 @@ export function RailChips({ } const stage = item.stage; const status = getStageStatus(file, stage.key); + const manuscriptSummary = + stage.key === 'manuscriptMatch' ? file?.manuscriptMatchSummary : null; + const manuscriptWarning = + stage.key === 'manuscriptMatch' && status === 'done' + ? file?.manuscriptMatchError + : ''; + const manuscriptWarningReason = manuscriptWarning + ? t(`manuscript.error.${manuscriptWarning}`, { + defaultValue: + file?.manuscriptMatchErrorDetail || manuscriptWarning, + }) + : ''; + const manuscriptTitle = manuscriptWarning + ? t('manuscript.stageWarning', { + reason: manuscriptWarningReason, + }) + : manuscriptSummary + ? t('manuscript.stageSummary', { + replaced: manuscriptSummary.replacedCues, + total: manuscriptSummary.totalCues, + confidence: Math.round( + Number(manuscriptSummary.averageConfidence || 0) * 100, + ), + }) + : undefined; return ( {index > 0 && } @@ -115,16 +140,28 @@ export function RailChips({ 'inline-flex items-center gap-1 text-xs whitespace-nowrap', status === 'pending' && 'text-faint', status === 'loading' && 'text-primary font-medium', - status === 'done' && 'text-success', + status === 'done' && + (manuscriptWarning ? 'text-warning' : 'text-success'), status === 'error' && 'text-destructive font-medium', )} + title={manuscriptTitle} > {status === 'loading' && ( )} - {status === 'done' && } + {status === 'done' && + (manuscriptWarning ? ( + + ) : ( + + ))} {status === 'error' && } {t(stage.labelKey)} + {status === 'done' && manuscriptSummary && ( + + {manuscriptSummary.replacedCues}/{manuscriptSummary.totalCues} + + )} {status === 'loading' && stage.key === 'extractSubtitle' && file.whisperBackend && ( diff --git a/renderer/components/tasks/stageUtils.ts b/renderer/components/tasks/stageUtils.ts index 24c71f70..3cf282c1 100644 --- a/renderer/components/tasks/stageUtils.ts +++ b/renderer/components/tasks/stageUtils.ts @@ -6,6 +6,7 @@ export type StageKey = | 'extractAudio' | 'extractSubtitle' | 'refineSubtitle' + | 'manuscriptMatch' | 'translateSubtitle' | 'speakerDiarization' | 'dubbing' @@ -42,6 +43,12 @@ export function getFileStages( ) { stages.push({ key: 'refineSubtitle', labelKey: 'stage.refine' }); } + if (formData?.manuscriptPath || file?.manuscriptMatch !== undefined) { + stages.push({ + key: 'manuscriptMatch', + labelKey: 'stage.manuscript', + }); + } } if (typeDef.hasTranslate && formData?.translateProvider !== '-1') { stages.push({ key: 'translateSubtitle', labelKey: 'stage.translate' }); @@ -225,12 +232,11 @@ export function isProofreadReady( const stages = getFileStages(file, typeDef, formData); if (typeDef.taskType === 'generateOnly') { if (file?.extractSubtitle !== 'done') return false; - // 精修会改写 srtFile:阶段在轨时须等 refine 完成,避免校对读到旧内容后被覆盖。 - if ( - stages.some((s) => s.key === 'refineSubtitle') && - file?.refineSubtitle !== 'done' - ) { - return false; + // 精修/文稿匹配会改写 srtFile:在轨时须全部完成,避免校对读到旧内容后被覆盖。 + for (const key of ['refineSubtitle', 'manuscriptMatch'] as const) { + if (stages.some((stage) => stage.key === key) && file?.[key] !== 'done') { + return false; + } } } else if (file?.translateSubtitle !== 'done') { return false; diff --git a/renderer/components/tasks/wizard/TaskWizard.tsx b/renderer/components/tasks/wizard/TaskWizard.tsx index 5326d6e5..ab094819 100644 --- a/renderer/components/tasks/wizard/TaskWizard.tsx +++ b/renderer/components/tasks/wizard/TaskWizard.tsx @@ -629,6 +629,8 @@ export default function TaskWizard() { compose, gates: _gates, taskType: _taskType, + manuscriptPath: _manuscriptPath, + manuscriptName: _manuscriptName, ...subtitleFields } = pendingRecipeConfig; form.reset({ ...form.getValues(), ...subtitleFields }); @@ -673,6 +675,13 @@ export default function TaskWizard() { label: t('stage.transcribe'), icon: Mic2, }); + if (formData?.manuscriptPath) { + list.push({ + key: 'manuscript', + label: t('stage.manuscript'), + icon: FileText, + }); + } } if (translateOn) { list.push({ @@ -688,7 +697,7 @@ export default function TaskWizard() { list.push({ key: 'compose', label: t('stage.compose'), icon: Film }); } return list; - }, [inputKind, translateOn, dubOn, videoOn, t]); + }, [inputKind, formData?.manuscriptPath, translateOn, dubOn, videoOn, t]); const blockers = useMemo(() => { const list: Array<{ key: string; text: string; href?: string }> = []; @@ -821,6 +830,9 @@ export default function TaskWizard() { delete config.dub; delete config.compose; delete config.gates; + // 参考文稿属于单次任务输入,不写入可复用配方,避免下次误用旧文件路径。 + delete config.manuscriptPath; + delete config.manuscriptName; if (dubOn && dubEngine) { config.dub = { engine: dubEngine, @@ -877,6 +889,9 @@ export default function TaskWizard() { const payload = stripSpeakerDiarizationConfig({ ...formData, taskType, + ...(inputKind !== 'media' + ? { manuscriptPath: '', manuscriptName: '' } + : {}), ...(appliedRecipeName ? { recipeName: appliedRecipeName } : {}), ...(sourceDownloadWorkItemId ? { sourceDownloadWorkItemId } : {}), translateProvider: translateOn ? formData?.translateProvider : '-1', diff --git a/renderer/hooks/useFormConfig.tsx b/renderer/hooks/useFormConfig.tsx index 3e8b8bcd..826aa4e7 100644 --- a/renderer/hooks/useFormConfig.tsx +++ b/renderer/hooks/useFormConfig.tsx @@ -2,6 +2,7 @@ import store from 'lib/store'; import { useState, useRef, useCallback, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { isEqual } from 'lodash'; +import { omitTaskManuscript } from '../../types/taskConfig'; export default function useFormConfig() { const form = useForm(); @@ -11,7 +12,12 @@ export default function useFormConfig() { useEffect(() => { (async () => { - const storeUserConfig = await window?.ipc?.invoke('getUserConfig'); + const persistedConfig = await window?.ipc?.invoke('getUserConfig'); + const storeUserConfig = omitTaskManuscript(persistedConfig); + if (!isEqual(storeUserConfig, persistedConfig)) { + window?.ipc?.send('setUserConfig', storeUserConfig); + store.setItem('userConfig', storeUserConfig); + } form.reset(storeUserConfig); setFormData(storeUserConfig); formDataRef.current = storeUserConfig; @@ -22,8 +28,9 @@ export default function useFormConfig() { if (!isEqual(values, formDataRef.current)) { formDataRef.current = values; setFormData(values); - window?.ipc?.send('setUserConfig', values); - store.setItem('userConfig', values); + const persistedValues = omitTaskManuscript(values); + window?.ipc?.send('setUserConfig', persistedValues); + store.setItem('userConfig', persistedValues); } }, []); diff --git a/renderer/hooks/useLocalFormConfig.tsx b/renderer/hooks/useLocalFormConfig.tsx index a8b95dea..d1e12a3e 100644 --- a/renderer/hooks/useLocalFormConfig.tsx +++ b/renderer/hooks/useLocalFormConfig.tsx @@ -6,6 +6,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { isEqual } from 'lodash'; +import { omitTaskManuscript } from '../../types/taskConfig'; export default function useLocalFormConfig() { const form = useForm(); @@ -15,7 +16,8 @@ export default function useLocalFormConfig() { useEffect(() => { (async () => { - const storeUserConfig = await window?.ipc?.invoke('getUserConfig'); + const persistedConfig = await window?.ipc?.invoke('getUserConfig'); + const storeUserConfig = omitTaskManuscript(persistedConfig); form.reset(storeUserConfig); setFormData(storeUserConfig); formDataRef.current = storeUserConfig; diff --git a/renderer/pages/[locale]/tasks/[type].tsx b/renderer/pages/[locale]/tasks/[type].tsx index a4e86c9b..96c4f123 100644 --- a/renderer/pages/[locale]/tasks/[type].tsx +++ b/renderer/pages/[locale]/tasks/[type].tsx @@ -103,14 +103,14 @@ export default function TaskPage() { const [taskStatus, setTaskStatus] = useState('idle'); const [advancedOpen, setAdvancedOpen] = useState(false); const [bannerDismissed, setBannerDismissed] = useState(false); - /** 向导任务的配置快照(含 dub/compose):阶段轨道与横幅按它渲染 */ + /** 固定任务的配置快照(附加阶段/参考文稿):阶段轨道与横幅按它渲染 */ const [configSnapshot, setConfigSnapshot] = useState(null); const [proofreadFile, setProofreadFile] = useState(null); const [isDragging, setIsDragging] = useState(false); const [viewMode, setViewMode] = useState<'list' | 'grid'>('list'); const { systemInfo, loaded: systemInfoLoaded } = useSystemInfo(); const { form, formData } = useFormConfig(); - /** 列表/横幅的有效配置:向导任务用快照(附加阶段轨道),否则用全局表单 */ + /** 列表/横幅的有效配置:固定任务用快照,否则使用当前表单。 */ const listFormData = configSnapshot ?? formData; /** 来自加载(而非用户/任务事件)的 files 引用,避免回写存储 */ const loadedFilesRef = useRef(null); @@ -209,7 +209,7 @@ export default function TaskPage() { nextFiles = project.files || []; name = project.name || null; } - // 配音/合成及说话者分离任务:按创建时配置快照渲染并固定重试参数。 + // 附加阶段、参考文稿及角色分离是任务级输入:创建后固定快照。 try { const workItem = await window?.ipc?.invoke('getWorkItem', q); const snap = workItem?.configSnapshot; @@ -365,6 +365,12 @@ export default function TaskPage() { setTaskStatus(status); }, []); + const handleTaskDispatched = useCallback((effectiveFormData: any) => { + if (isPinnedTaskConfigSnapshot(effectiveFormData)) { + setConfigSnapshot({ ...effectiveFormData }); + } + }, []); + const handleViewModeChange = useCallback((mode: 'list' | 'grid') => { setViewMode(mode); window?.ipc?.invoke('setSettings', { taskViewMode: mode }); @@ -372,7 +378,7 @@ export default function TaskPage() { const handleRetry = useCallback( (file: any) => { - // 向导任务重试携带配置快照(含 dub/compose),普通任务用全局表单 + // 固定任务重试携带其创建时快照,普通任务仍使用当前表单。 window?.ipc?.send('handleTask', { files: [file], formData: listFormData, @@ -769,7 +775,7 @@ export default function TaskPage() {
{configSnapshot ? ( - // 向导任务:配置随创建时快照固定,只读展示实际生效参数 + // 固定任务:配置随首次派发快照锁定,只读展示实际生效参数。 { - if (isPinnedTaskConfigSnapshot(snapshot)) { - setConfigSnapshot({ ...snapshot }); - } - }} + onTaskDispatched={handleTaskDispatched} autoStart={autoStartPending} />
diff --git a/renderer/public/locales/en/tasks.json b/renderer/public/locales/en/tasks.json index f2203b03..14c733ae 100644 --- a/renderer/public/locales/en/tasks.json +++ b/renderer/public/locales/en/tasks.json @@ -114,6 +114,30 @@ "style": "Output content", "format": "Subtitle format" }, + "manuscript": { + "label": "Reference script", + "select": "Choose TXT / Markdown", + "clear": "Clear reference script", + "hint": "Optional. Matches the script in order after transcription, replaces only high-confidence text, and always keeps the ASR timeline.", + "selected": "Reference script selected: {{name}}", + "cleared": "Reference script cleared", + "stageSummary": "Script matched {{replaced}}/{{total}} cues, {{confidence}}% average confidence", + "stageWarning": "Script matching safely fell back: {{reason}}", + "error": { + "unsupported": "Only TXT, MD, or Markdown reference scripts are supported", + "notFound": "The reference script no longer exists", + "notFile": "The selected item is not a file", + "tooLarge": "The reference script must be 1 MB or smaller", + "tooComplex": "The reference script has too much matchable or fragmented text", + "empty": "The reference script contains no matchable text", + "invalidEncoding": "The script encoding is unsupported; use UTF-8, UTF-16, GBK, or GB18030", + "unreadable": "The reference script could not be read", + "noCues": "The source subtitle has no timed cues to match", + "noMatch": "No match met the safety threshold; the original transcription was kept", + "timeline": "Timeline validation failed; the original transcription was kept", + "processing": "The script could not be processed; the original transcription was kept" + } + }, "notConfigured": " (not configured)", "providerGroup": { "configured": "Configured", @@ -281,6 +305,7 @@ "extract": "Extract", "transcribe": "Transcribe", "refine": "Refine", + "manuscript": "Script match", "translate": "Translate", "speakerDiarization": "Speaker diarization", "dubbing": "Dub", diff --git a/renderer/public/locales/zh/tasks.json b/renderer/public/locales/zh/tasks.json index 7baad7f2..04bf1ab8 100644 --- a/renderer/public/locales/zh/tasks.json +++ b/renderer/public/locales/zh/tasks.json @@ -114,6 +114,30 @@ "style": "输出内容", "format": "字幕格式" }, + "manuscript": { + "label": "参考文稿", + "select": "选择 TXT / Markdown", + "clear": "清除参考文稿", + "hint": "可选。转写后按顺序匹配参考文稿,只用高置信文本替换识别结果,始终保留 ASR 时间轴。", + "selected": "已选择参考文稿:{{name}}", + "cleared": "已清除参考文稿", + "stageSummary": "文稿匹配 {{replaced}}/{{total}} 条,平均置信度 {{confidence}}%", + "stageWarning": "文稿匹配已安全回退:{{reason}}", + "error": { + "unsupported": "仅支持 TXT、MD 或 Markdown 参考文稿", + "notFound": "参考文稿不存在或已被移动", + "notFile": "所选项目不是文件", + "tooLarge": "参考文稿不能超过 1 MB", + "tooComplex": "参考文稿可匹配文字过多或分段过于零碎", + "empty": "参考文稿中没有可匹配的文字", + "invalidEncoding": "无法识别文稿编码;请使用 UTF-8、UTF-16、GBK 或 GB18030", + "unreadable": "无法读取参考文稿", + "noCues": "源字幕中没有可匹配的时间轴条目", + "noMatch": "没有达到安全阈值的匹配,已保留原识别文本", + "timeline": "时间轴校验未通过,已保留原识别文本", + "processing": "处理文稿时出现问题,已保留原识别文本" + } + }, "notConfigured": "(未配置)", "providerGroup": { "configured": "已配置", @@ -281,6 +305,7 @@ "extract": "提取", "transcribe": "转写", "refine": "精修", + "manuscript": "文稿匹配", "translate": "翻译", "speakerDiarization": "角色分离", "dubbing": "配音", diff --git a/scripts/test-manuscript-matching.ts b/scripts/test-manuscript-matching.ts new file mode 100644 index 00000000..d26d4dc2 --- /dev/null +++ b/scripts/test-manuscript-matching.ts @@ -0,0 +1,664 @@ +/// +/** + * 文稿匹配纯逻辑 + 文件/IPC 契约测试(无 Electron、无网络)。 + * + * 覆盖: + * - task config 缺省关闭、路径快照解析 + * - Markdown 可见文本规范化与分段 + * - 单调对齐、分句粒度差异、远端锚点恢复、高/低置信安全边界 + * - 匹配只改 text,时间轴/额外 cue 字段不变 + * - UTF-8 / UTF-16 文稿读取、扩展名/空文稿校验 + * - IPC selection payload 不携带文稿正文 + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + MANUSCRIPT_MAX_BYTES, + MANUSCRIPT_MAX_COMPARABLE_CHARS, + MANUSCRIPT_MAX_UNITS, + ManuscriptFileError, + getManuscriptConfig, + matchManuscriptToCues, + normalizeManuscriptText, + readManuscriptFile, + replaceMatchedSrtCueTexts, + segmentManuscript, + toManuscriptSelectionPayload, +} from '../main/helpers/manuscriptMatching'; +import { atomicReplaceTextFile } from '../main/helpers/atomicFile'; +import { + isPinnedTaskConfigSnapshot, + omitTaskManuscript, +} from '../types/taskConfig'; + +let passed = 0; +let failed = 0; + +function eq(actual: unknown, expected: unknown, name: string): void { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson === expectedJson) { + passed += 1; + } else { + failed += 1; + console.error( + `✗ ${name}\n expected: ${expectedJson}\n actual: ${actualJson}`, + ); + } +} + +function ok(condition: boolean, name: string): void { + eq(Boolean(condition), true, name); +} + +function comparable(text: string): string { + return text + .normalize('NFKC') + .toLowerCase() + .replace(/[^\p{L}\p{N}]/gu, ''); +} + +async function expectFileError( + promise: Promise, + code: string, + name: string, +): Promise { + try { + await promise; + eq('resolved', code, name); + } catch (error) { + eq( + error instanceof ManuscriptFileError ? error.code : 'unknown', + code, + name, + ); + } +} + +async function run(): Promise { + // config / snapshot + eq(getManuscriptConfig(undefined), null, 'config: 缺省关闭'); + eq(getManuscriptConfig({ manuscriptPath: ' ' }), null, 'config: 空白关闭'); + eq( + getManuscriptConfig({ + manuscriptPath: ' C:\\scripts\\episode.md ', + manuscriptName: ' 第一期.md ', + }), + { path: 'C:\\scripts\\episode.md', name: '第一期.md' }, + 'config: 路径与显示名 trim 后进入任务快照', + ); + + // normalization / segmentation + const markdown = [ + '---', + 'title: hidden metadata', + '---', + '# 第一章', + '- 欢迎阅读 **SmartSub**。', + '[文档链接](https://example.com)不会保留网址。', + ].join('\n'); + const normalizedMarkdown = normalizeManuscriptText(markdown, true); + ok( + !normalizedMarkdown.includes('title: hidden'), + 'markdown: frontmatter 移除', + ); + ok(!normalizedMarkdown.includes('https://'), 'markdown: 链接 URL 移除'); + ok( + normalizedMarkdown.includes('欢迎阅读 SmartSub'), + 'markdown: 可见文本保留', + ); + eq( + (await segmentManuscript('第一句。Second sentence. 最后一段')).length, + 3, + 'segment: 中英文句末 + 尾段', + ); + + const longUnpunctuated = 'a'.repeat(128 * 1024); + const segmentStartedAt = Date.now(); + const longUnits = await segmentManuscript(longUnpunctuated); + const segmentElapsed = Date.now() - segmentStartedAt; + eq( + longUnits.join(''), + longUnpunctuated, + 'segment: 无标点长段线性切分且不丢字', + ); + ok( + segmentElapsed < 5000, + `performance: 128 KiB 无标点分段应在 5 秒内完成(实际 ${segmentElapsed}ms)`, + ); + + // exact/high-confidence matching with skipped title + const sourceCues = [ + { + text: '大家好欢迎收看本期视频', + startMs: 0, + endMs: 2100, + speaker: 'A', + }, + { + text: '今天我们介绍文稿匹配功嫩', + startMs: 2100, + endMs: 4800, + speaker: 'A', + }, + { + text: '这个功能会保留原来的时间轴', + startMs: 4800, + endMs: 7200, + speaker: 'B', + }, + ]; + const matched = await matchManuscriptToCues( + sourceCues, + [ + '第一章:产品介绍', + '大家好,欢迎收看本期视频。', + '今天我们介绍文稿匹配功能。', + '这个功能会保留原来的时间轴。', + ].join('\n'), + ); + eq(matched.replacedCues, 3, 'align: 标题可跳过,三条高置信替换'); + eq( + matched.cues.map((cue) => [cue.startMs, cue.endMs]), + sourceCues.map((cue) => [cue.startMs, cue.endMs]), + 'invariant: 时间轴完全不变', + ); + eq( + matched.cues.map((cue) => cue.speaker), + ['A', 'A', 'B'], + 'invariant: cue 额外字段保留', + ); + ok( + comparable(matched.cues.map((cue) => cue.text).join('')).includes( + comparable('文稿匹配功能'), + ), + 'align: ASR 错字由文稿纠正', + ); + + // two cues to one manuscript sentence + const regrouped = await matchManuscriptToCues( + [ + { text: 'hello world', startMs: 0, endMs: 1000 }, + { text: 'this is a test', startMs: 1000, endMs: 2200 }, + ], + 'Hello, world, this is a test.', + ); + eq(regrouped.replacedCues, 2, 'align: 2 cue ↔ 1 文稿单元'); + eq( + comparable(regrouped.cues.map((cue) => cue.text).join('')), + comparable('Hello, world, this is a test.'), + 'align: 重新分配文稿文本不丢字', + ); + ok( + regrouped.cues.every((cue) => cue.text.trim().length > 0), + 'align: 1 文稿单元按 cue 权重安全分配', + ); + + const mergedUnits = await matchManuscriptToCues( + [ + { + text: 'alpha beta gamma delta', + startMs: 0, + endMs: 2200, + }, + ], + 'Alpha beta. Gamma delta.', + ); + eq(mergedUnits.replacedCues, 1, 'align: 1 cue ↔ 2 文稿单元'); + ok( + mergedUnits.cues[0].text.includes('. Gamma'), + 'align: 英文文稿单元合并时保留可读空格', + ); + + // Far anchor recovery after many unspoken units. + const gapScript = [ + 'First spoken sentence.', + ...Array.from( + { length: 35 }, + (_, index) => `Unspoken stage direction number ${index}.`, + ), + 'Second spoken sentence after the long omitted section.', + ].join('\n'); + const recovered = await matchManuscriptToCues( + [ + { text: 'first spoken sentence', startMs: 0, endMs: 1000 }, + { + text: 'second spoken sentence after the long omitted section', + startMs: 9000, + endMs: 12000, + }, + ], + gapScript, + ); + eq(recovered.replacedCues, 2, 'align: 超过局部窗口后由稀有三元组锚点恢复'); + + const unrelated = [ + { text: 'completely unrelated recognition', startMs: 0, endMs: 1000 }, + ]; + const safeFallback = await matchManuscriptToCues( + unrelated, + '正确文稿描述的是另一段完全不同的内容。', + ); + eq(safeFallback.replacedCues, 0, 'safety: 低置信不替换'); + eq(safeFallback.cues, unrelated, 'safety: 低置信完整回退原 ASR'); + + const uniqueModerate = await matchManuscriptToCues( + [{ text: 'the product is redy for lunch today' }], + 'The project is ready for launch today.', + ); + eq( + uniqueModerate.replacedCues, + 1, + 'margin: 同一位置的不同分组不被误算成次优位置', + ); + const repeatedModerate = await matchManuscriptToCues( + [{ text: 'the product is redy for lunch today' }], + [ + 'The project is ready for launch today.', + 'A stage direction that is not spoken.', + 'The project is ready for launch today.', + ].join('\n'), + ); + eq( + repeatedModerate.replacedCues, + 0, + 'margin: 两个不同位置近似等价时安全拒配', + ); + + const localeStable = await matchManuscriptToCues( + [{ text: 'istanbul integration is deterministic' }], + 'Istanbul integration is deterministic.', + ); + eq(localeStable.replacedCues, 1, 'normalization: 大小写不随系统区域变化'); + + const reorderedClauses = await matchManuscriptToCues( + [ + { text: '我们先介绍中文', startMs: 0, endMs: 1000 }, + { text: '然后介绍英文最后总结', startMs: 1000, endMs: 2000 }, + ], + '我们先介绍英文然后介绍中文最后总结', + ); + eq( + reorderedClauses.replacedCues, + 0, + 'safety: bigram 集合相同但词序互换时不得替换', + ); + const longReorderedClauses = await matchManuscriptToCues( + [ + { + text: '在本课程的第一部分我们先介绍中文然后介绍英文最后总结全部内容谢谢大家', + }, + ], + '在本课程的第一部分我们先介绍英文然后介绍中文最后总结全部内容谢谢大家', + ); + eq( + longReorderedClauses.replacedCues, + 0, + 'safety: 长上下文不能稀释词序互换风险', + ); + const threeCueLocalReorder = await matchManuscriptToCues( + [ + { + text: '在这次完整详细的课程讲解当中我们会依次讨论所有重', + }, + { + text: '要内容并且先介绍中文然后介绍英文最后总结全部内容并', + }, + { + text: '给出实践建议方便大家理解和应用这些知识解决真实问题', + }, + ], + '在这次完整详细的课程讲解当中我们会依次讨论所有重要内容并且先介绍英文然后介绍中文最后总结全部内容并给出实践建议方便大家理解和应用这些知识解决真实问题', + ); + eq( + threeCueLocalReorder.replacedCues, + 0, + 'safety: 3 cue 长上下文中的局部词序互换不得被稀释', + ); + const longTwoCharacterWordReorder = await matchManuscriptToCues( + [ + { + text: '在这次完整详细的课程讲解当中我们会依次讨论所有重要内容并且先说明苹果然后结合案例详细讨论香蕉最后总结全部内容并给出实践建议方便大家理解和应用这些知识解决真实问题', + }, + ], + '在这次完整详细的课程讲解当中我们会依次讨论所有重要内容并且先说明香蕉然后结合案例详细讨论苹果最后总结全部内容并给出实践建议方便大家理解和应用这些知识解决真实问题', + ); + eq( + longTwoCharacterWordReorder.replacedCues, + 0, + 'safety: 长上下文中的两个双字词换位不得被稀释', + ); + const mediumTwoCharacterWordReorder = await matchManuscriptToCues( + [ + { + text: '今天我们会先分析需求然后逐步说明设计最后结合实例验证全部流程确保每一位学习者都能理解核心方法并正确应用', + }, + ], + '今天我们会先分析设计然后逐步说明需求最后结合实例验证全部流程确保每一位学习者都能理解核心方法并正确应用', + ); + eq( + mediumTwoCharacterWordReorder.replacedCues, + 0, + 'safety: 中等上下文中的两个双字词换位不得被稀释', + ); + const alignedTypo = await matchManuscriptToCues( + [{ text: '今天我们介少文稿匹配功能' }], + '今天我们介绍文稿匹配功能', + ); + eq( + alignedTypo.replacedCues, + 1, + 'order gate: 保留按原顺序的一处中段 ASR 错字校正能力', + ); + const tenCharacterSubstitution = await matchManuscriptToCues( + [{ text: '今天介绍文稿匹陪功能' }], + '今天介绍文稿匹配功能', + ); + eq( + tenCharacterSubstitution.replacedCues, + 1, + 'single edit: 10 字文本的一处替换与增删使用相同预算', + ); + const missingCharacter = await matchManuscriptToCues( + [{ text: '今天我们介绍文匹配功能' }], + '今天我们介绍文稿匹配功能', + ); + eq( + missingCharacter.replacedCues, + 1, + 'single edit: 短句漏识一个汉字仍可由文稿校正', + ); + const extraCharacter = await matchManuscriptToCues( + [{ text: '今天我们介绍文稿稿匹配功能' }], + '今天我们介绍文稿匹配功能', + ); + eq( + extraCharacter.replacedCues, + 1, + 'single edit: 短句多识一个汉字与漏识对称容错', + ); + const unsafeShortSingleEdit = await matchManuscriptToCues( + [{ text: '今天介绍文匹配功能' }], + '今天介绍文稿匹配功能', + ); + eq( + unsafeShortSingleEdit.replacedCues, + 0, + 'single edit safety: 过短文本不放宽一个汉字的编辑预算', + ); + const unsafeShortSubstitution = await matchManuscriptToCues( + [{ text: '今天介绍稿匹陪功能' }], + '今天介绍稿匹配功能', + ); + eq( + unsafeShortSubstitution.replacedCues, + 0, + 'single edit safety: 过短文本的一处替换同样不放宽', + ); + const repeatedNgramSingleEdit = await matchManuscriptToCues( + [{ text: '哈哈哈哈今天我们介绍文匹配功能哈哈哈哈' }], + '哈哈哈哈今天我们介绍文稿匹配功能哈哈哈哈', + ); + eq( + repeatedNgramSingleEdit.replacedCues, + 1, + 'order anchors: 重复三元组不应让正常单字符增删产生伪换序', + ); + // Two aligned substitutions in repeated content leave one coincidental + // off-LIS anchor (displacement 10), which is noise rather than a supported + // local reorder. + const coincidentalAnchorCrossing = await matchManuscriptToCues( + [{ text: '丁丙甲戊甲丙甲戊丙戊丁丙甲甲甲丙丁' }], + '丁丙甲戊乙丙甲戊丙戊丁丙甲甲甲丙甲', + ); + eq( + coincidentalAnchorCrossing.replacedCues, + 1, + 'order anchors: 单个偶然 crossing 不足以判定局部换序', + ); + const twoAlignedSubstitutions = await matchManuscriptToCues( + [{ text: '甲丙丙戊丁甲甲丙甲丙甲丁丙甲甲乙丙戊甲丁丙丙乙' }], + '甲乙丙戊丁甲甲丙甲丙甲丁丙甲甲乙甲戊甲丁丙丙乙', + ); + eq( + twoAlignedSubstitutions.replacedCues, + 1, + 'order anchors: 两处普通错字不得被误判为局部换序', + ); + + const cancellation = new AbortController(); + const cancellationPromise = matchManuscriptToCues( + Array.from({ length: 500 }, () => ({ + text: 'a repeated cue that requires matching work', + })), + 'a'.repeat(128 * 1024), + { signal: cancellation.signal }, + ); + setTimeout(() => cancellation.abort(), 0); + try { + await cancellationPromise; + eq('resolved', 'AbortError', 'cancel: 计算中应响应 AbortSignal'); + } catch (error) { + eq((error as Error).name, 'AbortError', 'cancel: 计算中响应 AbortSignal'); + } + + const originalSrt = [ + '7', + '00:00:00,000 --> 00:00:01,000', + 'unmatched first line', + 'unmatched second line', + '', + '9', + '00:00:01,000 --> 00:00:02,000', + 'old matched text', + '', + ].join('\r\n'); + const patchedSrt = replaceMatchedSrtCueTexts( + originalSrt, + new Map([[1, 'corrected matched text']]), + ); + eq( + patchedSrt, + originalSrt.replace('old matched text', 'corrected matched text'), + 'srt: 仅替换命中 cue,未命中多行/序号/时间/CRLF 原样保留', + ); + + eq( + omitTaskManuscript({ + model: 'base', + manuscriptPath: 'C:\\private\\episode.md', + manuscriptName: 'episode.md', + }), + { model: 'base' }, + 'config: 文稿路径不进入全局 userConfig', + ); + ok( + isPinnedTaskConfigSnapshot({ + manuscriptPath: 'C:\\private\\episode.md', + }), + 'config: 带文稿的任务快照固定', + ); + eq( + isPinnedTaskConfigSnapshot({ model: 'base' }), + false, + 'config: 普通任务配置仍可编辑', + ); + + // File + IPC contract. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'smartsub-manuscript-')); + try { + const utf8Path = path.join(tmpDir, 'episode.md'); + fs.writeFileSync(utf8Path, '# 标题\n\n大家好,欢迎收看。', 'utf-8'); + const utf8 = await readManuscriptFile(utf8Path); + eq(utf8.encoding, 'utf-8', 'file: UTF-8 识别'); + eq(utf8.text, '标题\n\n大家好,欢迎收看。', 'file: Markdown 读取并规范化'); + + const payload = toManuscriptSelectionPayload(utf8); + eq( + Object.prototype.hasOwnProperty.call(payload, 'text'), + false, + 'ipc: selection payload 不回传/持久化正文', + ); + eq( + Object.prototype.hasOwnProperty.call(payload, 'units'), + false, + 'ipc: selection payload 不回传预分段正文', + ); + eq(payload.path, utf8Path, 'ipc: selection payload 返回绝对路径'); + ok(payload.characterCount > 0 && payload.size > 0, 'ipc: 返回校验元数据'); + + const utf16Path = path.join(tmpDir, 'utf16.txt'); + const utf16Body = Buffer.from('UTF16 参考文稿', 'utf16le'); + fs.writeFileSync( + utf16Path, + Buffer.concat([Buffer.from([0xff, 0xfe]), utf16Body]), + ); + const utf16 = await readManuscriptFile(utf16Path); + eq(utf16.encoding, 'utf-16le', 'file: UTF-16LE BOM 识别'); + eq(utf16.text, 'UTF16 参考文稿', 'file: UTF-16LE 正文'); + + const utf16BePath = path.join(tmpDir, 'utf16be.txt'); + const utf16BeBody = Buffer.from('UTF16BE 文稿', 'utf16le'); + for (let index = 0; index + 1 < utf16BeBody.length; index += 2) { + const first = utf16BeBody[index]; + utf16BeBody[index] = utf16BeBody[index + 1]; + utf16BeBody[index + 1] = first; + } + fs.writeFileSync( + utf16BePath, + Buffer.concat([Buffer.from([0xfe, 0xff]), utf16BeBody]), + ); + const utf16Be = await readManuscriptFile(utf16BePath); + eq(utf16Be.encoding, 'utf-16be', 'file: UTF-16BE BOM 识别'); + eq(utf16Be.text, 'UTF16BE 文稿', 'file: UTF-16BE 正文'); + + const gb18030Path = path.join(tmpDir, 'gb18030.txt'); + // “中文文稿”的 GBK 字节;GB18030 解码器是 GBK 的超集。 + fs.writeFileSync( + gb18030Path, + Buffer.from([0xd6, 0xd0, 0xce, 0xc4, 0xce, 0xc4, 0xb8, 0xe5]), + ); + const gb18030 = await readManuscriptFile(gb18030Path); + eq(gb18030.encoding, 'gb18030', 'file: GBK/GB18030 回退识别'); + eq(gb18030.text, '中文文稿', 'file: GB18030 正文'); + + const oversizedPath = path.join(tmpDir, 'oversized.txt'); + fs.writeFileSync( + oversizedPath, + Buffer.alloc(MANUSCRIPT_MAX_BYTES + 1, 0x61), + ); + await expectFileError( + readManuscriptFile(oversizedPath), + 'tooLarge', + 'file: 1 MiB raw cap', + ); + + const grewAfterStatPath = path.join(tmpDir, 'grew-after-stat.txt'); + fs.writeFileSync(grewAfterStatPath, 'small', 'utf-8'); + const originalReadFile = fs.promises.readFile; + (fs.promises as any).readFile = async ( + requestedPath: fs.PathLike, + ...args: unknown[] + ) => + requestedPath === grewAfterStatPath + ? Buffer.alloc(MANUSCRIPT_MAX_BYTES + 1, 0x61) + : (originalReadFile as any)(requestedPath, ...args); + try { + await expectFileError( + readManuscriptFile(grewAfterStatPath), + 'tooLarge', + 'file: stat 后增长仍由实际 buffer 大小拒绝', + ); + } finally { + (fs.promises as any).readFile = originalReadFile; + } + + const tooManyCharactersPath = path.join(tmpDir, 'too-many-chars.txt'); + fs.writeFileSync( + tooManyCharactersPath, + 'a'.repeat(MANUSCRIPT_MAX_COMPARABLE_CHARS + 1), + 'utf-8', + ); + await expectFileError( + readManuscriptFile(tooManyCharactersPath), + 'tooComplex', + 'file: 规范化后 comparable 字符上限', + ); + + const tooManyUnitsPath = path.join(tmpDir, 'too-many-units.txt'); + fs.writeFileSync( + tooManyUnitsPath, + Array.from({ length: MANUSCRIPT_MAX_UNITS + 1 }, () => 'a.').join('\n'), + 'utf-8', + ); + await expectFileError( + readManuscriptFile(tooManyUnitsPath), + 'tooComplex', + 'file: 文稿分段单元上限', + ); + + const atomicPath = path.join(tmpDir, 'atomic.srt'); + fs.writeFileSync(atomicPath, 'original subtitle', 'utf-8'); + await atomicReplaceTextFile(atomicPath, 'replacement subtitle'); + eq( + fs.readFileSync(atomicPath, 'utf-8'), + 'replacement subtitle', + 'atomic: flush/close 后替换成功', + ); + fs.writeFileSync(atomicPath, 'must survive rename failure', 'utf-8'); + try { + await atomicReplaceTextFile(atomicPath, 'must not become visible', { + operations: { + open: fs.promises.open.bind(fs.promises), + rm: fs.promises.rm.bind(fs.promises), + rename: async () => { + throw new Error('simulated rename failure'); + }, + } as any, + }); + eq('resolved', 'rejected', 'atomic: rename failure should reject'); + } catch { + eq( + fs.readFileSync(atomicPath, 'utf-8'), + 'must survive rename failure', + 'atomic: rename failure leaves original untouched', + ); + } + eq( + fs + .readdirSync(tmpDir) + .some( + (name) => name.startsWith('.atomic.srt.') && name.endsWith('.tmp'), + ), + false, + 'atomic: failure cleans sibling temp', + ); + + const unsupportedPath = path.join(tmpDir, 'episode.rtf'); + fs.writeFileSync(unsupportedPath, 'text', 'utf-8'); + await expectFileError( + readManuscriptFile(unsupportedPath), + 'unsupported', + 'file: 非白名单扩展名拒绝', + ); + + const emptyPath = path.join(tmpDir, 'empty.txt'); + fs.writeFileSync(emptyPath, ' \r\n\t', 'utf-8'); + await expectFileError( + readManuscriptFile(emptyPath), + 'empty', + 'file: 空文稿拒绝', + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + + console.log(`\nmanuscript matching: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +void run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/types/taskConfig.ts b/types/taskConfig.ts new file mode 100644 index 00000000..8ed0aacd --- /dev/null +++ b/types/taskConfig.ts @@ -0,0 +1,19 @@ +/** + * Reference manuscripts are task inputs, not reusable user preferences. + * Keep these helpers dependency-free so main and renderer enforce the same rule. + */ +export function omitTaskManuscript< + T extends Record | null | undefined, +>(config: T): Record { + const source = (config || {}) as Record; + const { + manuscriptPath: _manuscriptPath, + manuscriptName: _manuscriptName, + ...rest + } = source; + return rest; +} + +// Keep the original module path available to manuscript-specific callers while +// sharing the canonical snapshot policy with the rest of the task pipeline. +export { isPinnedTaskConfigSnapshot } from './taskSnapshot'; diff --git a/types/taskSnapshot.ts b/types/taskSnapshot.ts index 3e6e8320..11d1d476 100644 --- a/types/taskSnapshot.ts +++ b/types/taskSnapshot.ts @@ -2,23 +2,29 @@ * 会把任务页切换为只读快照、并在重试时固定复用的配置。 * * 普通字幕任务仍沿用可编辑的全局配置;一旦启用角色分离,它和带配音/合成的 - * 流水线任务一样需要固定创建时配置,避免同一任务重试得到不同的说话者标签。 + * 流水线任务一样需要固定创建时配置;参考文稿作为一次性任务输入也必须固定。 */ export interface PinnableTaskConfigSnapshot { [key: string]: unknown; dub?: unknown; compose?: unknown; speakerDiarization?: unknown; + manuscriptPath?: unknown; } export function isPinnedTaskConfigSnapshot( snapshot: PinnableTaskConfigSnapshot | null | undefined, ): boolean { + const manuscriptPath = + typeof snapshot?.manuscriptPath === 'string' + ? snapshot.manuscriptPath.trim() + : ''; return Boolean( snapshot && (snapshot.dub || snapshot.compose || - snapshot.speakerDiarization === true), + snapshot.speakerDiarization === true || + manuscriptPath), ); } diff --git a/types/types.ts b/types/types.ts index 427843bc..5473e2d6 100644 --- a/types/types.ts +++ b/types/types.ts @@ -69,6 +69,16 @@ export interface ISystemInfo { /** 与 main/helpers/storagePaths.ts 的 StorageSource 对齐(types 层无法反向依赖 main)。 */ export type StoragePathSource = 'override' | 'storageRoot' | 'default'; +/** 单个文件的文稿匹配结果摘要(详细替换内容不落任务存储,避免泄露整篇文稿)。 */ +export interface ManuscriptMatchSummary { + manuscriptName: string; + totalCues: number; + replacedCues: number; + matchedGroups: number; + /** 已替换 cue 的平均相似度,0–1。 */ + averageConfidence: number; +} + export interface IFiles { uuid: string; filePath: string; @@ -96,6 +106,13 @@ export interface IFiles { proofreadDataFile?: string; /** 词级时间轴 sidecar(`.words.json`):AI 语义断句精确对齐用;无词级引擎缺省。 */ wordTimelineFile?: string; + /** ASR 后参考文稿匹配阶段;缺省不存在即功能关闭。 */ + manuscriptMatch?: '' | 'loading' | 'done'; + /** 稳定的非致命回退码,renderer 据此本地化;不会令任务失败。 */ + manuscriptMatchError?: string; + /** 仅供日志/tooltip 兜底的诊断细节,不参与本地化键。 */ + manuscriptMatchErrorDetail?: string; + manuscriptMatchSummary?: ManuscriptMatchSummary; /** 本次转写实际使用的后端标签(如 "CUDA 12.4.0" / "Vulkan" / "CPU") */ whisperBackend?: string; /** 该文件走了内封软字幕直提(跳过 ASR;角色分离开启时仍会抽音频):用于任务列表标识 */ @@ -242,4 +259,11 @@ export interface IFormData { speakerDiarizationCount?: number; /** 是否把 `[Speaker N]` 角色标签写入字幕交付物;缺省 false,仅存 sidecar metadata。 */ speakerDiarizationEmbedInSubtitle?: boolean; + /** + * ASR 参考文稿(TXT / Markdown)。路径存在即开启;只替换高置信匹配文本, + * 时间轴始终来自 ASR。缺省/空字符串关闭,保持旧任务与配方行为。 + */ + manuscriptPath?: string; + /** 创建快照时的显示名;运行时仍以 manuscriptPath 为唯一数据源。 */ + manuscriptName?: string; }