From 0a56aa9515dbf9ee463cd4c081a89f21b423e8ed Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:46:39 +0800 Subject: [PATCH 1/2] feat(proofread): support speaker role management --- docs/docs/features/proofreading.md | 8 + docs/docs/features/subtitle-generation.md | 2 +- main/helpers/ipcHandlers.ts | 63 ++- main/helpers/proofreadData.ts | 100 +++-- package.json | 1 + .../components/proofread/ProofreadEditor.tsx | 39 ++ .../proofread/SpeakerCueControl.tsx | 181 ++++++++ .../components/proofread/SpeakerToolbar.tsx | 396 ++++++++++++++++++ renderer/components/subtitle/SubtitleList.tsx | 108 ++++- renderer/hooks/useStandaloneSubtitles.ts | 236 ++++++++++- renderer/hooks/useSubtitleHistory.ts | 114 ++++- renderer/hooks/useSubtitles.ts | 2 + renderer/public/locales/en/home.json | 34 ++ renderer/public/locales/zh/home.json | 34 ++ scripts/test-proofread-speakers.ts | 238 +++++++++++ types/proofreadData.ts | 320 ++++++++++++++ 16 files changed, 1785 insertions(+), 91 deletions(-) create mode 100644 renderer/components/proofread/SpeakerCueControl.tsx create mode 100644 renderer/components/proofread/SpeakerToolbar.tsx create mode 100644 scripts/test-proofread-speakers.ts create mode 100644 types/proofreadData.ts diff --git a/docs/docs/features/proofreading.md b/docs/docs/features/proofreading.md index d2fd5a50..7e4c6afd 100644 --- a/docs/docs/features/proofreading.md +++ b/docs/docs/features/proofreading.md @@ -32,6 +32,14 @@ keywords: [字幕校对, 字幕编辑, 校对台, AI 润色, 字幕修改, 时 常用快捷键:`↑↓` 上一条 / 下一条,`Tab` 在原文与译文间切换,`⌘S` 保存,`?` 查看全部快捷键。 +## 校正角色归属 + +启用[角色分离](./subtitle-generation#角色分离)的任务会在每条字幕旁显示角色名和颜色。点击角色标签即可重新分配当前字幕、补充多人重叠角色或指定主要角色;没有可靠识别结果的字幕会显示为「未分配」。 + +角色工具栏支持按角色、未分配和多人重叠筛选,也可以统一重命名、换色、移动全部字幕或合并误拆角色。角色名称和归属独立保存在校对数据中,相关修改都可撤销 / 重做,不会改动字幕正文或时间轴。 + +保存时默认不把角色名写入字幕文件;需要交付带角色标记的字幕时,可勾选「在字幕中写入角色名」,导出内容会使用当前角色名称。 + ## AI 帮你改 - **AI 优化**:选中单条让 AI 润色措辞 diff --git a/docs/docs/features/subtitle-generation.md b/docs/docs/features/subtitle-generation.md index eacbe498..162ad4c6 100644 --- a/docs/docs/features/subtitle-generation.md +++ b/docs/docs/features/subtitle-generation.md @@ -61,7 +61,7 @@ keywords: 标准的「转写」和「转写 + 翻译」任务可在高级设置中开启**角色分离**。它会在转写、精修与翻译完成后,本地分析整段音频,把不同角色按时间轴对齐到每条字幕;处理耗时与音频长度大致成正比。 -首次使用前需在「资源中心 → 本地多模型引擎」确认运行库可用并下载角色分离模型。角色信息默认只保存在应用内校对数据中,不会改变导出的字幕;需要交付可读标签时,可同时开启「将角色标签写入字幕文件」,输出 `[Speaker 1]` 等前缀。 +首次使用前需在「资源中心 → 本地多模型引擎」确认运行库可用并下载角色分离模型。角色信息默认只保存在应用内校对数据中,不会改变导出的字幕;需要交付可读标签时,可同时开启「将角色标签写入字幕文件」,初始输出使用 `[Speaker 1]` 等前缀。进入[校对台](./proofreading#校正角色归属)后可以把编号改为真实角色名、纠正归属,并决定保存时是否写入当前角色名称。 当前版本暂不在一条龙向导、自定义配方及含配音 / 合成的流程中开放角色分离;这些流程会在角色元数据与配音音色映射完成后再接入。 diff --git a/main/helpers/ipcHandlers.ts b/main/helpers/ipcHandlers.ts index 5e581cf5..c97a1bef 100644 --- a/main/helpers/ipcHandlers.ts +++ b/main/helpers/ipcHandlers.ts @@ -20,6 +20,10 @@ import { readProofreadDataFile, updateProofreadDataFromSubtitles, } from './proofreadData'; +import { + prefixTextWithSpeakerNames, + type SpeakerInfo, +} from '../../types/proofreadData'; import { MANUSCRIPT_EXTENSIONS, ManuscriptFileError, @@ -164,18 +168,33 @@ function buildSubtitleFileContent( filePath: string, subtitles: any[], contentType = 'source', + speakerOptions?: { + speakers?: SpeakerInfo[]; + embedSpeakerNames?: boolean; + }, ): string { const format = detectSubtitleFormat(filePath); + const withSpeakerPrefix = (subtitle: any, text: string): string => { + if (!speakerOptions?.embedSpeakerNames) return text; + return prefixTextWithSpeakerNames( + text, + subtitle, + speakerOptions.speakers || [], + ); + }; const buildText = (subtitle): string => { + let text: string; if (contentType === 'source') { - return subtitle.sourceContent ?? ''; + text = subtitle.sourceContent ?? ''; + } else { + const template = + CONTENT_TEMPLATES[contentType] || CONTENT_TEMPLATES.onlyTranslate; + text = renderTemplate(template, { + sourceContent: subtitle.sourceContent ?? '', + targetContent: subtitle.targetContent ?? '', + }).replace(/\n+$/, ''); } - const template = - CONTENT_TEMPLATES[contentType] || CONTENT_TEMPLATES.onlyTranslate; - return renderTemplate(template, { - sourceContent: subtitle.sourceContent ?? '', - targetContent: subtitle.targetContent ?? '', - }).replace(/\n+$/, ''); + return withSpeakerPrefix(subtitle, text); }; return ( @@ -218,8 +237,17 @@ async function writeSubtitleFile( filePath: string, subtitles: any[], contentType = 'source', + speakerOptions?: { + speakers?: SpeakerInfo[]; + embedSpeakerNames?: boolean; + }, ): Promise { - const content = buildSubtitleFileContent(filePath, subtitles, contentType); + const content = buildSubtitleFileContent( + filePath, + subtitles, + contentType, + speakerOptions, + ); await backupSubtitleFile(filePath); await fs.promises.writeFile(filePath, content, 'utf-8'); logMessage(`保存字幕文件成功: ${filePath}`, 'info'); @@ -386,7 +414,10 @@ export function setupIpcHandlers(mainWindow: BrowserWindow) { return []; } const proofreadData = await readProofreadDataFile(filePath); - return proofreadDataToSubtitleRows(proofreadData); + return { + subtitles: proofreadDataToSubtitleRows(proofreadData), + speakers: proofreadData.speakers, + }; } catch (error) { logMessage(`读取校对中间态错误: ${error.message}`, 'error'); return []; @@ -447,10 +478,14 @@ export function setupIpcHandlers(mainWindow: BrowserWindow) { { proofreadDataFile, subtitles, + speakers = [], + embedSpeakerNames = false, outputs = [], }: { proofreadDataFile: string; subtitles: any[]; + speakers?: SpeakerInfo[]; + embedSpeakerNames?: boolean; outputs: { filePath?: string; contentType?: string }[]; }, ) => { @@ -461,7 +496,11 @@ export function setupIpcHandlers(mainWindow: BrowserWindow) { ); } - await updateProofreadDataFromSubtitles(proofreadDataFile, subtitles); + const updated = await updateProofreadDataFromSubtitles( + proofreadDataFile, + subtitles, + speakers, + ); const rendered = new Set(); for (const output of outputs) { @@ -474,6 +513,10 @@ export function setupIpcHandlers(mainWindow: BrowserWindow) { filePath, subtitles, output.contentType || 'source', + { + speakers: updated.speakers, + embedSpeakerNames, + }, ); } diff --git a/main/helpers/proofreadData.ts b/main/helpers/proofreadData.ts index f1c141c8..7b401d63 100644 --- a/main/helpers/proofreadData.ts +++ b/main/helpers/proofreadData.ts @@ -12,38 +12,26 @@ import { import { logMessage } from './storeManager'; import { speakerIdsForCues, + stripSpeakerLabelPrefix, type SpeakerDiarizationSegment, } from './speakerDiarization/alignment'; import { mergeSpeakerIds, realignSpeakerIdsForCue, } from '../../types/speakerDiarization'; +import { + PROOFREAD_DATA_VERSION, + normalizePrimarySpeakerId, + normalizeProofreadData, + normalizeSpeakerIds, + normalizeSpeakerRoster, + type ProofreadDataCue, + type ProofreadDataFileV2, + type SpeakerInfo, +} from '../../types/proofreadData'; -export interface ProofreadDataCue { - id: string; - startMs: number; - endMs: number; - source: string; - target: string; - /** 一基角色编号,与可选文本标签 `[Speaker N]` 的 N 一致。 */ - speakerIds?: number[]; -} - -export interface ProofreadDataFile { - version: 1; - meta: { - createdAt: string; - updatedAt: string; - sourceLanguage?: string; - targetLanguage?: string; - translateContent?: string; - outputFormat?: string; - sourceFile?: string; - targetFile?: string; - finalTargetFile?: string; - }; - cues: ProofreadDataCue[]; -} +export type { ProofreadDataCue, SpeakerInfo } from '../../types/proofreadData'; +export type ProofreadDataFile = ProofreadDataFileV2; export interface ProofreadSubtitleRow { id: string; @@ -55,6 +43,7 @@ export interface ProofreadSubtitleRow { endTimeInSeconds: number; isEditing: boolean; speakerIds?: number[]; + primarySpeakerId?: number; } export type ProofreadDataWriteResult = @@ -125,14 +114,23 @@ function buildCues( targetByTime.get(sourceEntry.startEndTime) || targetEntries[index]; const { startMs, endMs } = parseStartEndTime(sourceEntry.startEndTime); + const assignedSpeakerIds = normalizeSpeakerIds(speakerIds[index]); + const cleanSpeakerPrefix = assignedSpeakerIds.length > 0; const cue: ProofreadDataCue = { id: sourceEntry.id || String(index + 1), startMs, endMs, - source: entryText(sourceEntry), - target: entryText(targetEntry), + source: cleanSpeakerPrefix + ? stripSpeakerLabelPrefix(entryText(sourceEntry)) + : entryText(sourceEntry), + target: cleanSpeakerPrefix + ? stripSpeakerLabelPrefix(entryText(targetEntry)) + : entryText(targetEntry), }; - if (speakerIds[index]?.length) cue.speakerIds = speakerIds[index]; + if (assignedSpeakerIds.length) { + cue.speakerIds = assignedSpeakerIds; + cue.primarySpeakerId = assignedSpeakerIds[0]; + } return cue; }); } @@ -170,8 +168,9 @@ export async function writeProofreadDataFromFiles({ const targetEntries = await readSubtitleEntries(targetFile); const now = new Date().toISOString(); + const cues = buildCues(sourceEntries, targetEntries, speakerSegments); const proofreadData: ProofreadDataFile = { - version: 1, + version: PROOFREAD_DATA_VERSION, meta: { createdAt: now, updatedAt: now, @@ -183,7 +182,8 @@ export async function writeProofreadDataFromFiles({ targetFile, finalTargetFile, }, - cues: buildCues(sourceEntries, targetEntries, speakerSegments), + speakers: normalizeSpeakerRoster([], cues), + cues, }; const proofreadDataFile = getProofreadDataPath(file); @@ -211,11 +211,27 @@ export async function readProofreadDataFile( filePath: string, ): Promise { const content = await fs.promises.readFile(filePath, 'utf-8'); - const parsed = JSON.parse(content) as ProofreadDataFile; - if (parsed?.version !== 1 || !Array.isArray(parsed.cues)) { + try { + const raw = JSON.parse(content); + const normalized = normalizeProofreadData(raw); + // v1 was created while technical labels could still be embedded in the + // source/target text. Keep migration idempotent and only strip labels from + // cues that already carry structured speaker assignments. + if (raw?.version === 1) { + normalized.cues = normalized.cues.map((cue) => + cue.speakerIds?.length + ? { + ...cue, + source: stripSpeakerLabelPrefix(cue.source), + target: stripSpeakerLabelPrefix(cue.target), + } + : cue, + ); + } + return normalized; + } catch { throw new Error(`Invalid proofread data file: ${filePath}`); } - return parsed; } export function proofreadDataToSubtitleRows( @@ -235,6 +251,9 @@ export function proofreadDataToSubtitleRows( endTimeInSeconds: cue.endMs / 1000, isEditing: false, ...(cue.speakerIds?.length ? { speakerIds: [...cue.speakerIds] } : {}), + ...(cue.primarySpeakerId + ? { primarySpeakerId: cue.primarySpeakerId } + : {}), }; }); } @@ -242,16 +261,19 @@ export function proofreadDataToSubtitleRows( export async function updateProofreadDataFromSubtitles( filePath: string, subtitles: ProofreadSubtitleRow[], + speakers?: SpeakerInfo[], ): Promise { const existing = await readProofreadDataFile(filePath); const existingById = new Map(existing.cues.map((cue) => [cue.id, cue])); const now = new Date().toISOString(); const updated: ProofreadDataFile = { ...existing, + version: PROOFREAD_DATA_VERSION, meta: { ...existing.meta, updatedAt: now, }, + speakers: normalizeSpeakerRoster(speakers || existing.speakers, subtitles), cues: subtitles.map((subtitle, index) => { const { startMs, endMs } = parseStartEndTime(subtitle.startEndTime); const source = @@ -268,13 +290,23 @@ export async function updateProofreadDataFromSubtitles( subtitle.speakerIds || previous?.speakerIds, ) : mergeSpeakerIds(subtitle.speakerIds || previous?.speakerIds); + const normalizedSpeakerIds = normalizeSpeakerIds(speakerIds); + const primarySpeakerId = normalizePrimarySpeakerId( + subtitle.primarySpeakerId || previous?.primarySpeakerId, + normalizedSpeakerIds, + ); return { id: subtitle.id || String(index + 1), startMs, endMs, source, target: subtitle.targetContent ?? '', - ...(speakerIds?.length ? { speakerIds: [...speakerIds] } : {}), + ...(normalizedSpeakerIds.length + ? { + speakerIds: normalizedSpeakerIds, + primarySpeakerId, + } + : {}), }; }), }; diff --git a/package.json b/package.json index 04718424..91dababe 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:proofread-speakers": "tsc scripts/test-proofread-speakers.ts --outDir node_modules/.cache/proofread-speaker-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck && node node_modules/.cache/proofread-speaker-tests/scripts/test-proofread-speakers.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", diff --git a/renderer/components/proofread/ProofreadEditor.tsx b/renderer/components/proofread/ProofreadEditor.tsx index aa5dde07..b97658b4 100644 --- a/renderer/components/proofread/ProofreadEditor.tsx +++ b/renderer/components/proofread/ProofreadEditor.tsx @@ -28,6 +28,7 @@ import VideoPlayer from '../subtitle/VideoPlayer'; import VideoInfo from '../subtitle/VideoInfo'; import SubtitleList from '../subtitle/SubtitleList'; import SubtitleEditToolbar from '../subtitle/SubtitleEditToolbar'; +import SpeakerToolbar, { type SpeakerFilter } from './SpeakerToolbar'; interface PendingFile { id: string; @@ -77,6 +78,9 @@ export default function ProofreadEditor({ mergedSubtitles, updateSubtitles, getSubtitles, + speakers, + embedSpeakerNames, + hasSpeakerData, videoPath, currentSubtitleIndex, setCurrentSubtitleIndex, @@ -101,6 +105,13 @@ export default function ProofreadEditor({ handleSplitSubtitle, handleDeleteSubtitle, handleTimeChange, + handleSetCueSpeakers, + handleCreateSpeaker, + handleRenameSpeaker, + handleSetSpeakerColor, + handleMoveSpeaker, + handleDeleteSpeaker, + handleEmbedSpeakerNamesChange, // 光标位置 handleCursorPositionChange, getCursorPosition, @@ -144,6 +155,15 @@ export default function ProofreadEditor({ const [videoCollapsed, setVideoCollapsed] = useState(false); const [expandAll, setExpandAll] = useState(false); const [fontScale, setFontScale] = useState<'s' | 'm' | 'l'>('m'); + const [speakerFilter, setSpeakerFilter] = useState('all'); + + useEffect(() => { + if (!speakerFilter.startsWith('speaker:')) return; + const speakerId = Number(speakerFilter.slice('speaker:'.length)); + if (!speakers.some((speaker) => speaker.id === speakerId)) { + setSpeakerFilter('all'); + } + }, [speakerFilter, speakers]); // 读取持久化偏好(仅客户端,避免 SSR 不一致) useEffect(() => { @@ -385,6 +405,21 @@ export default function ProofreadEditor({ {/* 编辑工具栏 */} + {hasSpeakerData && ( + handleCreateSpeaker()} + onRenameSpeaker={handleRenameSpeaker} + onSetSpeakerColor={handleSetSpeakerColor} + onMoveSpeaker={handleMoveSpeaker} + onDeleteSpeaker={handleDeleteSpeaker} + /> + )} diff --git a/renderer/components/proofread/SpeakerCueControl.tsx b/renderer/components/proofread/SpeakerCueControl.tsx new file mode 100644 index 00000000..0ef3747f --- /dev/null +++ b/renderer/components/proofread/SpeakerCueControl.tsx @@ -0,0 +1,181 @@ +import React, { useMemo } from 'react'; +import { Crown, UserRoundPlus, Users, X } from 'lucide-react'; +import { useTranslation } from 'next-i18next'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import type { Subtitle } from '../../hooks/useSubtitles'; +import { + normalizePrimarySpeakerId, + normalizeSpeakerIds, + type SpeakerInfo, +} from '../../../types/proofreadData'; + +interface SpeakerCueControlProps { + subtitle: Subtitle; + index: number; + speakers: SpeakerInfo[]; + onChange: ( + index: number, + speakerIds: number[], + primarySpeakerId?: number, + ) => void; + onCreate: (index: number) => number; +} + +export default function SpeakerCueControl({ + subtitle, + index, + speakers, + onChange, + onCreate, +}: SpeakerCueControlProps) { + const { t } = useTranslation('home'); + const ids = useMemo( + () => normalizeSpeakerIds(subtitle.speakerIds), + [subtitle.speakerIds], + ); + const primary = normalizePrimarySpeakerId(subtitle.primarySpeakerId, ids); + const assigned = ids + .map((id) => speakers.find((speaker) => speaker.id === id)) + .filter((speaker): speaker is SpeakerInfo => Boolean(speaker)); + + const toggleSpeaker = (speakerId: number, checked: boolean) => { + const next = checked + ? normalizeSpeakerIds([...ids, speakerId]) + : ids.filter((id) => id !== speakerId); + onChange( + index, + next, + checked + ? primary || speakerId + : primary === speakerId + ? next[0] + : primary, + ); + }; + + const makePrimary = (speakerId: number) => { + onChange(index, normalizeSpeakerIds([...ids, speakerId]), speakerId); + }; + + return ( + + + + + event.stopPropagation()} + > +
+

{t('speakers.assignCue')}

+

+ {t('speakers.primaryHint')} +

+
+
+ {speakers.map((speaker) => { + const checked = ids.includes(speaker.id); + return ( +
+ + toggleSpeaker(speaker.id, value === true) + } + /> + + + {speaker.displayName} + + +
+ ); + })} +
+
+ + +
+
+
+ ); +} diff --git a/renderer/components/proofread/SpeakerToolbar.tsx b/renderer/components/proofread/SpeakerToolbar.tsx new file mode 100644 index 00000000..ed1a367e --- /dev/null +++ b/renderer/components/proofread/SpeakerToolbar.tsx @@ -0,0 +1,396 @@ +import React, { useMemo, useState } from 'react'; +import { + Filter, + Merge, + Palette, + Trash2, + UserRoundPlus, + Users, +} from 'lucide-react'; +import { useTranslation } from 'next-i18next'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { Subtitle } from '../../hooks/useSubtitles'; +import { + SPEAKER_COLOR_PALETTE, + countSpeakerCues, + type SpeakerInfo, +} from '../../../types/proofreadData'; + +export type SpeakerFilter = + | 'all' + | 'unassigned' + | 'overlap' + | `speaker:${number}`; + +interface PendingBulkAction { + mode: 'move' | 'merge'; + sourceId: number; + targetId: number; + count: number; +} + +interface SpeakerToolbarProps { + speakers: SpeakerInfo[]; + subtitles: Subtitle[]; + filter: SpeakerFilter; + onFilterChange: (filter: SpeakerFilter) => void; + embedSpeakerNames: boolean; + onEmbedSpeakerNamesChange: (enabled: boolean) => void; + onCreateSpeaker: () => number; + onRenameSpeaker: (speakerId: number, displayName: string) => boolean; + onSetSpeakerColor: (speakerId: number, color: string) => void; + onMoveSpeaker: ( + sourceId: number, + targetId: number, + removeSource: boolean, + ) => void; + onDeleteSpeaker: (speakerId: number) => boolean; +} + +export default function SpeakerToolbar({ + speakers, + subtitles, + filter, + onFilterChange, + embedSpeakerNames, + onEmbedSpeakerNamesChange, + onCreateSpeaker, + onRenameSpeaker, + onSetSpeakerColor, + onMoveSpeaker, + onDeleteSpeaker, +}: SpeakerToolbarProps) { + const { t } = useTranslation('home'); + const [managerOpen, setManagerOpen] = useState(false); + const [targetBySpeaker, setTargetBySpeaker] = useState< + Record + >({}); + const [pendingAction, setPendingAction] = useState( + null, + ); + + const counts = useMemo( + () => + new Map( + speakers.map((speaker) => [ + speaker.id, + countSpeakerCues(subtitles, speaker.id), + ]), + ), + [speakers, subtitles], + ); + + const targetFor = (sourceId: number): number | undefined => { + const selected = targetBySpeaker[sourceId]; + if ( + selected !== undefined && + selected !== sourceId && + speakers.some((speaker) => speaker.id === selected) + ) { + return selected; + } + return speakers.find((speaker) => speaker.id !== sourceId)?.id; + }; + + const requestBulkAction = ( + mode: PendingBulkAction['mode'], + sourceId: number, + ) => { + const targetId = targetFor(sourceId); + if (!targetId) return; + setPendingAction({ + mode, + sourceId, + targetId, + count: counts.get(sourceId) || 0, + }); + }; + + const confirmBulkAction = () => { + if (!pendingAction) return; + onMoveSpeaker( + pendingAction.sourceId, + pendingAction.targetId, + pendingAction.mode === 'merge', + ); + setPendingAction(null); + }; + + const speakerName = (id: number): string => + speakers.find((speaker) => speaker.id === id)?.displayName || String(id); + + return ( + <> +
+ + {t('speakers.label')} + + + +
+ + + + + {t('speakers.manageTitle')} + {t('speakers.manageDesc')} + +
+ {speakers.map((speaker) => { + const count = counts.get(speaker.id) || 0; + const targetId = targetFor(speaker.id); + return ( +
+
+ + { + if (event.key === 'Enter') event.currentTarget.blur(); + }} + onBlur={(event) => { + if (!onRenameSpeaker(speaker.id, event.target.value)) { + event.target.value = speaker.displayName; + } + }} + /> + + {t('speakers.cueCount', { count })} + + +
+
+ + {SPEAKER_COLOR_PALETTE.map((color, colorIndex) => ( +
+
+ {speakers.length > 1 && targetId && ( + <> + + + + + )} + +
+
+ ); + })} + +
+
+
+ + { + if (!open) setPendingAction(null); + }} + > + + + + {pendingAction?.mode === 'merge' + ? t('speakers.mergeConfirmTitle') + : t('speakers.moveConfirmTitle')} + + + {pendingAction + ? t( + pendingAction.mode === 'merge' + ? 'speakers.mergeConfirmDesc' + : 'speakers.moveConfirmDesc', + { + source: speakerName(pendingAction.sourceId), + target: speakerName(pendingAction.targetId), + count: pendingAction.count, + }, + ) + : ''} + + + + {t('cancel')} + + {t('confirm')} + + + + + + ); +} diff --git a/renderer/components/subtitle/SubtitleList.tsx b/renderer/components/subtitle/SubtitleList.tsx index 74eac125..9737d401 100644 --- a/renderer/components/subtitle/SubtitleList.tsx +++ b/renderer/components/subtitle/SubtitleList.tsx @@ -34,6 +34,12 @@ import { Subtitle } from '../../hooks/useSubtitles'; import { useTranslation } from 'next-i18next'; import TimeRangeEditor from './TimeRangeEditor'; import type { RetranslateControl } from '../../hooks/useRetranslateFailed'; +import SpeakerCueControl from '../proofread/SpeakerCueControl'; +import type { SpeakerFilter } from '../proofread/SpeakerToolbar'; +import { + normalizeSpeakerIds, + type SpeakerInfo, +} from '../../../types/proofreadData'; interface SubtitleListProps { mergedSubtitles: Subtitle[]; @@ -66,6 +72,14 @@ interface SubtitleListProps { /** 视图偏好由父级(编辑工具栏)统一控制 */ expandAll: boolean; fontScale: 's' | 'm' | 'l'; + speakers?: SpeakerInfo[]; + speakerFilter?: SpeakerFilter; + onCueSpeakersChange?: ( + index: number, + speakerIds: number[], + primarySpeakerId?: number, + ) => void; + onCreateSpeaker?: (index: number) => number; } interface RowLabels { @@ -132,6 +146,14 @@ interface SubtitleRowProps { startSec: number, endSec: number, ) => string | null; + speakers: SpeakerInfo[]; + showSpeakerControl: boolean; + onCueSpeakersChange?: ( + index: number, + speakerIds: number[], + primarySpeakerId?: number, + ) => void; + onCreateSpeaker?: (index: number) => number; } // 行组件:紧凑单行(默认) / 展开编辑(当前行) @@ -157,6 +179,10 @@ const SubtitleRow = memo(function SubtitleRow({ onSplit, onDelete, onTimeCommit, + speakers, + showSpeakerControl, + onCueSpeakersChange, + onCreateSpeaker, }: SubtitleRowProps) { // 失败行降噪:左缘红条 + ⚠,不再整行红底 const failedEdge = isFailed @@ -189,6 +215,15 @@ const SubtitleRow = memo(function SubtitleRow({ #{subtitle.id} {compactTime(subtitle.startTimeInSeconds)}→ {compactTime(subtitle.endTimeInSeconds)} + {showSpeakerControl && onCueSpeakersChange && onCreateSpeaker && ( + + )} @@ -237,6 +272,15 @@ const SubtitleRow = memo(function SubtitleRow({ : '' } /> + {showSpeakerControl && onCueSpeakersChange && onCreateSpeaker && ( + + )}
@@ -355,6 +399,10 @@ const SubtitleList: React.FC = ({ onMergeRange, expandAll, fontScale, + speakers = [], + speakerFilter = 'all', + onCueSpeakersChange, + onCreateSpeaker, }) => { const { t } = useTranslation('home'); @@ -388,14 +436,35 @@ const SubtitleList: React.FC = ({ : -1; } + const speakerFilteredIndices = useMemo(() => { + if (speakerFilter === 'all') return null; + return mergedSubtitles + .map((subtitle, index) => { + const ids = normalizeSpeakerIds(subtitle.speakerIds); + if (speakerFilter === 'unassigned') + return ids.length === 0 ? index : -1; + if (speakerFilter === 'overlap') return ids.length > 1 ? index : -1; + const speakerId = Number(speakerFilter.slice('speaker:'.length)); + return ids.includes(speakerId) ? index : -1; + }) + .filter((index) => index >= 0); + }, [mergedSubtitles, speakerFilter]); + // 过滤映射:null = 不过滤(虚拟索引即真实索引) let displayIndices: number[] | null = null; if (failedOnly) { const pinned = pinnedIndexRef.current; - displayIndices = + const failedDisplayIndices = pinned >= 0 && !failedIndices.includes(pinned) ? [...failedIndices, pinned].sort((a, b) => a - b) : failedIndices; + displayIndices = speakerFilteredIndices + ? failedDisplayIndices.filter((index) => + speakerFilteredIndices.includes(index), + ) + : failedDisplayIndices; + } else if (speakerFilteredIndices) { + displayIndices = speakerFilteredIndices; } const displayCount = displayIndices ? displayIndices.length @@ -456,13 +525,14 @@ const SubtitleList: React.FC = ({ // 多选区间(归一化 [lo, hi],含两端);anchor 为最后一次普通点击的行 const [selRange, setSelRange] = useState<[number, number] | null>(null); const selAnchorRef = useRef(-1); - const selectionEnabled = !!onMergeRange; + const selectionEnabled = + !!onMergeRange && !failedOnly && speakerFilter === 'all'; // 行点击:普通点击 = 选中 + 展开 + 视频跳转(沿用现有联动)并清选区; // Shift+点击 = 仅扩展选区(不展开不跳转);失败过滤视图下退化为普通点击 const onRowClick = useCallback( (index: number, shiftKey: boolean) => { - if (shiftKey && selectionEnabled && !failedOnly) { + if (shiftKey && selectionEnabled) { const anchor = selAnchorRef.current >= 0 ? selAnchorRef.current @@ -480,7 +550,7 @@ const SubtitleList: React.FC = ({ } latestRef.current.handleSubtitleClick(index); }, - [selectionEnabled, failedOnly], + [selectionEnabled], ); // Esc 清除选区 @@ -615,7 +685,7 @@ const SubtitleList: React.FC = ({ return () => cancelAnimationFrame(frame); // displayIndices 内容随失败行变化,仅在当前行/过滤开关变化时重定位 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentSubtitleIndex, failedOnly, virtualizer]); + }, [currentSubtitleIndex, failedOnly, speakerFilter, virtualizer]); const virtualItems = virtualizer.getVirtualItems(); @@ -764,18 +834,22 @@ const SubtitleList: React.FC = ({ {/* 字幕列表(虚拟化;只看失败时为过滤视图) */}
- {failedOnly && displayCount === 0 ? ( + {displayCount === 0 ? (
- {t('failedAllClear')} - + + {failedOnly ? t('failedAllClear') : t('speakers.filterEmpty')} + + {failedOnly && ( + + )}
) : (
= ({ onSplit={onSplit} onDelete={onDelete} onTimeCommit={onTimeCommit} + speakers={speakers} + showSpeakerControl={speakers.length > 0} + onCueSpeakersChange={onCueSpeakersChange} + onCreateSpeaker={onCreateSpeaker} />
); diff --git a/renderer/hooks/useStandaloneSubtitles.ts b/renderer/hooks/useStandaloneSubtitles.ts index ae15e32f..a548a18e 100644 --- a/renderer/hooks/useStandaloneSubtitles.ts +++ b/renderer/hooks/useStandaloneSubtitles.ts @@ -10,6 +10,18 @@ import { useTranslation } from 'next-i18next'; import { Subtitle, SubtitleStats, PlayerSubtitleTrack } from './useSubtitles'; import { useSubtitleHistory, computeRangeDiff } from './useSubtitleHistory'; import { mergeSpeakerIds } from '../../types/speakerDiarization'; +import { + SPEAKER_COLOR_PALETTE, + countSpeakerCues, + createDefaultSpeaker, + moveSpeakerAssignments, + nextSpeakerId, + normalizePrimarySpeakerId, + normalizeSpeakerAssignment, + sanitizeSpeakerDisplayName, + speakerListsEqual, + type SpeakerInfo, +} from '../../types/proofreadData'; interface StandaloneSubtitlesConfig { videoPath?: string; @@ -75,6 +87,9 @@ export const useStandaloneSubtitles = ( PlayerSubtitleTrack[] >([]); const [isLoading, setIsLoading] = useState(false); + const [speakers, setSpeakers] = useState([]); + const speakersRef = useRef([]); + const [embedSpeakerNames, setEmbedSpeakerNames] = useState(false); // 撤销/重做历史(命令模式:区间 diff 命令栈) const history = useSubtitleHistory(); @@ -105,6 +120,11 @@ export const useStandaloneSubtitles = ( setMergedSubtitles(next); }, []); + const applySpeakers = useCallback((next: SpeakerInfo[]) => { + speakersRef.current = next; + setSpeakers(next); + }, []); + // 读取最新字幕数组(异步流程结束后回填用,避免拿到过期快照) const getSubtitles = useCallback(() => subtitlesRef.current, []); @@ -123,18 +143,32 @@ export const useStandaloneSubtitles = ( const readProofreadDataFile = async ( filePath: string, - ): Promise => { + ): Promise<{ subtitles: Subtitle[]; speakers: SpeakerInfo[] }> => { try { - const result: Subtitle[] = await window.ipc.invoke( - 'readProofreadDataFile', - { - filePath, - }, + const result = await window.ipc.invoke('readProofreadDataFile', { + filePath, + }); + if (Array.isArray(result)) { + return { subtitles: result, speakers: [] }; + } + const localizedSpeakers = (result?.speakers || []).map( + (speaker: SpeakerInfo) => + speaker.autoName + ? { + ...speaker, + displayName: t('speakers.defaultName', { + number: speaker.id, + }), + } + : speaker, ); - return result; + return { + subtitles: Array.isArray(result?.subtitles) ? result.subtitles : [], + speakers: localizedSpeakers, + }; } catch (error) { console.error('Error reading proofread data file:', error); - return []; + return { subtitles: [], speakers: [] }; } }; @@ -182,9 +216,11 @@ export const useStandaloneSubtitles = ( const playerTracks: PlayerSubtitleTrack[] = []; // 读取源字幕 - const proofreadDataSubtitles = config.proofreadDataFile + const proofreadData = config.proofreadDataFile ? await readProofreadDataFile(config.proofreadDataFile) - : []; + : { subtitles: [], speakers: [] }; + const proofreadDataSubtitles = proofreadData.subtitles; + applySpeakers(proofreadData.speakers); const sourceSubtitles = proofreadDataSubtitles.length > 0 ? proofreadDataSubtitles @@ -272,7 +308,14 @@ export const useStandaloneSubtitles = ( } finally { setIsLoading(false); } - }, [config, shouldShowTranslation, t, applySubtitles, history.reset]); + }, [ + config, + shouldShowTranslation, + t, + applySubtitles, + applySpeakers, + history.reset, + ]); // 加载文件 useEffect(() => { @@ -390,6 +433,8 @@ export const useStandaloneSubtitles = ( const result = await window.ipc.invoke('saveProofreadDataAndRender', { proofreadDataFile: config.proofreadDataFile, subtitles: mergedSubtitles, + speakers: speakersRef.current, + embedSpeakerNames, outputs: outputs.filter((output) => output.filePath), }); @@ -534,22 +579,24 @@ export const useStandaloneSubtitles = ( // 撤销:先提交合并窗口(保证「最后一次输入」也可撤销),再应用区间命令 const handleUndo = useCallback(() => { flushPendingEdit(); - const next = history.undo(subtitlesRef.current); + const next = history.undo(subtitlesRef.current, speakersRef.current); if (next) { - applySubtitles(renormalizeIds(next)); + applySubtitles(renormalizeIds(next.subtitles)); + applySpeakers(next.speakers); setIsDirty(true); } - }, [applySubtitles, flushPendingEdit, history.undo]); + }, [applySpeakers, applySubtitles, flushPendingEdit, history.undo]); // 重做:合并窗口若有内容会作为新命令清空 redo 分支(与主流编辑器一致) const handleRedo = useCallback(() => { flushPendingEdit(); - const next = history.redo(subtitlesRef.current); + const next = history.redo(subtitlesRef.current, speakersRef.current); if (next) { - applySubtitles(renormalizeIds(next)); + applySubtitles(renormalizeIds(next.subtitles)); + applySpeakers(next.speakers); setIsDirty(true); } - }, [applySubtitles, flushPendingEdit, history.redo]); + }, [applySpeakers, applySubtitles, flushPendingEdit, history.redo]); // 是否可以撤销/重做(合并窗口中有未提交输入也算可撤销) const canUndo = history.canUndo || pendingEditRef.current !== null; @@ -643,6 +690,12 @@ export const useStandaloneSubtitles = ( const startTime = toMerge[0].startTimeInSeconds || 0; const endTime = toMerge[toMerge.length - 1].endTimeInSeconds || 0; + const mergedSpeakerIds = mergeSpeakerIds( + ...toMerge.map((s) => s.speakerIds), + ); + const preferredPrimary = toMerge.find((subtitle) => + mergedSpeakerIds.includes(subtitle.primarySpeakerId || -1), + )?.primarySpeakerId; const merged: Subtitle = { ...toMerge[0], sourceContent: mergedContent, @@ -651,7 +704,15 @@ export const useStandaloneSubtitles = ( startEndTime: `${secondsToTime(startTime)} --> ${secondsToTime(endTime)}`, startTimeInSeconds: startTime, endTimeInSeconds: endTime, - speakerIds: mergeSpeakerIds(...toMerge.map((s) => s.speakerIds)), + ...(mergedSpeakerIds.length + ? { + speakerIds: mergedSpeakerIds, + primarySpeakerId: normalizePrimarySpeakerId( + preferredPrimary, + mergedSpeakerIds, + ), + } + : { speakerIds: undefined, primarySpeakerId: undefined }), }; history.push({ start: startIndex, removed: toMerge, inserted: [merged] }); @@ -690,6 +751,133 @@ export const useStandaloneSubtitles = ( [applySubtitles, flushPendingEdit, history.push, t], ); + const commitSpeakerDocument = useCallback( + (nextSubtitles: Subtitle[], nextSpeakers: SpeakerInfo[]) => { + flushPendingEdit(); + const currentSubtitles = subtitlesRef.current; + const currentSpeakers = speakersRef.current; + if ( + !computeRangeDiff(currentSubtitles, nextSubtitles) && + speakerListsEqual(currentSpeakers, nextSpeakers) + ) { + return false; + } + history.pushDocument( + currentSubtitles, + nextSubtitles, + currentSpeakers, + nextSpeakers, + ); + applySubtitles(nextSubtitles); + applySpeakers(nextSpeakers); + setIsDirty(true); + return true; + }, + [applySpeakers, applySubtitles, flushPendingEdit, history.pushDocument], + ); + + const handleSetCueSpeakers = useCallback( + (index: number, speakerIds: number[], primarySpeakerId?: number) => { + const current = subtitlesRef.current; + const row = current[index]; + if (!row) return; + const updated = normalizeSpeakerAssignment({ + ...row, + speakerIds, + primarySpeakerId, + }); + const next = current.slice(); + next[index] = updated; + commitSpeakerDocument(next, speakersRef.current); + }, + [commitSpeakerDocument], + ); + + const handleCreateSpeaker = useCallback( + (cueIndex?: number): number => { + const id = nextSpeakerId(speakersRef.current); + const speaker = createDefaultSpeaker( + id, + t('speakers.defaultName', { number: id }), + ); + const nextSpeakers = [...speakersRef.current, speaker]; + let nextSubtitles = subtitlesRef.current; + if (cueIndex !== undefined && nextSubtitles[cueIndex]) { + nextSubtitles = nextSubtitles.slice(); + nextSubtitles[cueIndex] = normalizeSpeakerAssignment({ + ...nextSubtitles[cueIndex], + speakerIds: [id], + primarySpeakerId: id, + }); + } + commitSpeakerDocument(nextSubtitles, nextSpeakers); + return id; + }, + [commitSpeakerDocument, t], + ); + + const handleRenameSpeaker = useCallback( + (speakerId: number, displayName: string): boolean => { + const normalized = sanitizeSpeakerDisplayName(displayName); + if (!normalized) return false; + const next = speakersRef.current.map((speaker) => + speaker.id === speakerId + ? { ...speaker, displayName: normalized, autoName: false } + : speaker, + ); + commitSpeakerDocument(subtitlesRef.current, next); + return true; + }, + [commitSpeakerDocument], + ); + + const handleSetSpeakerColor = useCallback( + (speakerId: number, color: string) => { + if (!SPEAKER_COLOR_PALETTE.includes(color as any)) return; + const next = speakersRef.current.map((speaker) => + speaker.id === speakerId ? { ...speaker, color } : speaker, + ); + commitSpeakerDocument(subtitlesRef.current, next); + }, + [commitSpeakerDocument], + ); + + const handleMoveSpeaker = useCallback( + (sourceId: number, targetId: number, removeSource: boolean) => { + if (sourceId === targetId) return; + const nextSubtitles = moveSpeakerAssignments( + subtitlesRef.current, + sourceId, + targetId, + ); + const nextSpeakers = removeSource + ? speakersRef.current.filter((speaker) => speaker.id !== sourceId) + : speakersRef.current; + commitSpeakerDocument(nextSubtitles, nextSpeakers); + }, + [commitSpeakerDocument], + ); + + const handleDeleteSpeaker = useCallback( + (speakerId: number): boolean => { + if (countSpeakerCues(subtitlesRef.current, speakerId) > 0) return false; + const next = speakersRef.current.filter( + (speaker) => speaker.id !== speakerId, + ); + commitSpeakerDocument(subtitlesRef.current, next); + return true; + }, + [commitSpeakerDocument], + ); + + const handleEmbedSpeakerNamesChange = useCallback((enabled: boolean) => { + setEmbedSpeakerNames((current) => { + if (current === enabled) return current; + setIsDirty(true); + return enabled; + }); + }, []); + // 拆分字幕(区间命令:1 行 → 2 行;支持自定义时间拆分点) const handleSplitSubtitle = useCallback( (index: number, splitPoint: number, splitTime?: number) => { @@ -770,6 +958,11 @@ export const useStandaloneSubtitles = ( setMergedSubtitles, updateSubtitles, getSubtitles, + speakers, + embedSpeakerNames, + hasSpeakerData: + speakers.length > 0 || + mergedSubtitles.some((subtitle) => subtitle.speakerIds?.length), videoPath, currentSubtitleIndex, setCurrentSubtitleIndex, @@ -796,6 +989,13 @@ export const useStandaloneSubtitles = ( handleSplitSubtitle, handleDeleteSubtitle, handleTimeChange, + handleSetCueSpeakers, + handleCreateSpeaker, + handleRenameSpeaker, + handleSetSpeakerColor, + handleMoveSpeaker, + handleDeleteSpeaker, + handleEmbedSpeakerNamesChange, // 光标位置 handleCursorPositionChange, getCursorPosition, diff --git a/renderer/hooks/useSubtitleHistory.ts b/renderer/hooks/useSubtitleHistory.ts index 3f537fbc..28cfb998 100644 --- a/renderer/hooks/useSubtitleHistory.ts +++ b/renderer/hooks/useSubtitleHistory.ts @@ -6,6 +6,12 @@ import { useCallback, useRef, useState } from 'react'; import { Subtitle } from './useSubtitles'; +import { + normalizePrimarySpeakerId, + normalizeSpeakerIds, + speakerListsEqual, + type SpeakerInfo, +} from '../../types/proofreadData'; export interface RangeCommand { /** 区间起点(应用前数组中的下标) */ @@ -18,13 +24,36 @@ export interface RangeCommand { const MAX_HISTORY = 200; +interface ProofreadCommand { + range?: RangeCommand; + speakersBefore?: SpeakerInfo[]; + speakersAfter?: SpeakerInfo[]; +} + +export interface ProofreadHistoryState { + subtitles: Subtitle[]; + speakers: SpeakerInfo[]; +} + +const speakerAssignmentEquals = (a: Subtitle, b: Subtitle): boolean => { + const left = normalizeSpeakerIds(a.speakerIds); + const right = normalizeSpeakerIds(b.speakerIds); + return ( + left.length === right.length && + left.every((id, index) => id === right[index]) && + normalizePrimarySpeakerId(a.primarySpeakerId, left) === + normalizePrimarySpeakerId(b.primarySpeakerId, right) + ); +}; + /** 行内容等价(用于批量操作的最小区间 diff 计算) */ export const subtitleRowEquals = (a: Subtitle, b: Subtitle): boolean => a === b || (a.id === b.id && a.startEndTime === b.startEndTime && (a.sourceContent ?? '') === (b.sourceContent ?? '') && - (a.targetContent ?? '') === (b.targetContent ?? '')); + (a.targetContent ?? '') === (b.targetContent ?? '') && + speakerAssignmentEquals(a, b)); /** * 计算 before → after 的最小连续区间 diff;无变化返回 null。 @@ -62,7 +91,7 @@ export const computeRangeDiff = ( }; export function useSubtitleHistory() { - const commandsRef = useRef([]); + const commandsRef = useRef([]); const cursorRef = useRef(0); // 仅用于在栈变化后触发重渲染,让 canUndo/canRedo 反映最新值 const [, setVersion] = useState(0); @@ -72,7 +101,36 @@ export function useSubtitleHistory() { (cmd: RangeCommand) => { // 新命令入栈:丢弃 redo 分支 const cmds = commandsRef.current.slice(0, cursorRef.current); - cmds.push(cmd); + cmds.push({ range: cmd }); + while (cmds.length > MAX_HISTORY) cmds.shift(); + commandsRef.current = cmds; + cursorRef.current = cmds.length; + bump(); + }, + [bump], + ); + + const pushDocument = useCallback( + ( + beforeSubtitles: Subtitle[], + afterSubtitles: Subtitle[], + beforeSpeakers: SpeakerInfo[], + afterSpeakers: SpeakerInfo[], + ) => { + const range = + computeRangeDiff(beforeSubtitles, afterSubtitles) || undefined; + const speakersChanged = !speakerListsEqual(beforeSpeakers, afterSpeakers); + if (!range && !speakersChanged) return; + const cmds = commandsRef.current.slice(0, cursorRef.current); + cmds.push({ + range, + ...(speakersChanged + ? { + speakersBefore: beforeSpeakers.map((speaker) => ({ ...speaker })), + speakersAfter: afterSpeakers.map((speaker) => ({ ...speaker })), + } + : {}), + }); while (cmds.length > MAX_HISTORY) cmds.shift(); commandsRef.current = cmds; cursorRef.current = cmds.length; @@ -87,45 +145,75 @@ export function useSubtitleHistory() { bump(); }, [bump]); - /** 应用撤销:返回新数组;无可撤销或数据漂移时返回 null */ + /** 应用撤销:同步恢复字幕区间和角色名册。 */ const undo = useCallback( - (current: Subtitle[]): Subtitle[] | null => { + ( + current: Subtitle[], + currentSpeakers: SpeakerInfo[], + ): ProofreadHistoryState | null => { if (cursorRef.current <= 0) return null; const cmd = commandsRef.current[cursorRef.current - 1]; + const range = cmd.range; // 区间防御:命令与当前数组不再吻合时清栈,避免错位应用 - if (cmd.start < 0 || cmd.start + cmd.inserted.length > current.length) { + if ( + range && + (range.start < 0 || + range.start + range.inserted.length > current.length) + ) { reset(); return null; } const next = current.slice(); - next.splice(cmd.start, cmd.inserted.length, ...cmd.removed); + if (range) { + next.splice(range.start, range.inserted.length, ...range.removed); + } cursorRef.current -= 1; bump(); - return next; + return { + subtitles: next, + speakers: (cmd.speakersBefore || currentSpeakers).map((speaker) => ({ + ...speaker, + })), + }; }, [bump, reset], ); - /** 应用重做:返回新数组;无可重做或数据漂移时返回 null */ + /** 应用重做:同步恢复字幕区间和角色名册。 */ const redo = useCallback( - (current: Subtitle[]): Subtitle[] | null => { + ( + current: Subtitle[], + currentSpeakers: SpeakerInfo[], + ): ProofreadHistoryState | null => { if (cursorRef.current >= commandsRef.current.length) return null; const cmd = commandsRef.current[cursorRef.current]; - if (cmd.start < 0 || cmd.start + cmd.removed.length > current.length) { + const range = cmd.range; + if ( + range && + (range.start < 0 || range.start + range.removed.length > current.length) + ) { reset(); return null; } const next = current.slice(); - next.splice(cmd.start, cmd.removed.length, ...cmd.inserted); + if (range) { + next.splice(range.start, range.removed.length, ...range.inserted); + } cursorRef.current += 1; bump(); - return next; + return { + subtitles: next, + speakers: (cmd.speakersAfter || currentSpeakers).map((speaker) => ({ + ...speaker, + })), + }; }, [bump, reset], ); return { push, + pushDocument, undo, redo, reset, diff --git a/renderer/hooks/useSubtitles.ts b/renderer/hooks/useSubtitles.ts index 20d7b1fa..7381c040 100644 --- a/renderer/hooks/useSubtitles.ts +++ b/renderer/hooks/useSubtitles.ts @@ -17,6 +17,8 @@ export interface Subtitle { isEditing?: boolean; /** 一基角色编号;校对合并/拆分时随 cue 一起保留。 */ speakerIds?: number[]; + /** 显式主要角色;多角色时不依赖 ID 排序推断。 */ + primarySpeakerId?: number; } export interface SubtitleStats { diff --git a/renderer/public/locales/en/home.json b/renderer/public/locales/en/home.json index 5ff84f9a..68fc6eee 100644 --- a/renderer/public/locales/en/home.json +++ b/renderer/public/locales/en/home.json @@ -313,6 +313,40 @@ "errorConvertToVTT": "Failed to convert subtitle to VTT: {{file}}", "proofreadPageTitle": "Proofread Subtitles", "proofreadPageDesc": "Import subtitles for line-by-line refinement with AI batch optimization", + "speakers": { + "label": "Roles", + "defaultName": "Role {{number}}", + "unassigned": "Unassigned", + "overlap": "Multiple speakers", + "editCue": "Edit this subtitle's roles", + "assignCue": "Assign roles", + "primaryHint": "Keep multiple roles when needed; the crown marks the primary role.", + "toggleAssignment": "Toggle {{name}} for this subtitle", + "setPrimary": "Set as primary role", + "setPrimaryFor": "Set {{name}} as the primary role", + "setUnassigned": "Set as unassigned", + "newSpeaker": "New role", + "filter": "Filter roles", + "filterAll": "All subtitles", + "filterEmpty": "No subtitles match the current role filter", + "manage": "Manage roles", + "manageTitle": "Manage roles", + "manageDesc": "Role names and colors are saved as structured metadata and do not modify subtitle text.", + "embedOnSave": "Write role names into subtitles when saving (off by default)", + "renameFor": "Rename {{name}}", + "cueCount": "{{count}} subtitles", + "locate": "Show only this role", + "colorChoice": "Color {{number}}", + "colorFor": "Set {{name}} to palette color {{number}}", + "moveAll": "Move all subtitles", + "merge": "Merge roles", + "deleteEmpty": "Delete empty role", + "deleteNonEmptyHint": "This role still has subtitles; move or merge them first", + "moveConfirmTitle": "Move this role's subtitles?", + "moveConfirmDesc": "Move {{count}} subtitles from {{source}} to {{target}}. The source role remains empty and the action can be undone.", + "mergeConfirmTitle": "Merge these roles?", + "mergeConfirmDesc": "Move {{count}} subtitles from {{source}} to {{target}}, then delete the source role. Subtitle text and timing remain unchanged, and the action can be undone." + }, "cloudConsent": { "title": "Upload audio to cloud ASR?", "description": "This task uses a cloud transcription engine. The audio track of each file will be extracted and uploaded to the third-party endpoint you configured. Don't use it for sensitive content, and mind the provider's usage costs.", diff --git a/renderer/public/locales/zh/home.json b/renderer/public/locales/zh/home.json index ccbe12b6..f28c1978 100644 --- a/renderer/public/locales/zh/home.json +++ b/renderer/public/locales/zh/home.json @@ -313,6 +313,40 @@ "errorConvertToVTT": "字幕转换 VTT 失败:{{file}}", "proofreadPageTitle": "校对字幕", "proofreadPageDesc": "导入字幕逐条精修,支持 AI 批量优化", + "speakers": { + "label": "角色", + "defaultName": "角色 {{number}}", + "unassigned": "未分配", + "overlap": "多人重叠", + "editCue": "修改这条字幕的角色", + "assignCue": "分配角色", + "primaryHint": "可保留多个角色;王冠表示主要角色。", + "toggleAssignment": "切换 {{name}} 的字幕归属", + "setPrimary": "设为主要角色", + "setPrimaryFor": "将 {{name}} 设为主要角色", + "setUnassigned": "设为未分配", + "newSpeaker": "新建角色", + "filter": "筛选角色", + "filterAll": "全部字幕", + "filterEmpty": "当前角色筛选下没有字幕", + "manage": "角色管理", + "manageTitle": "管理角色", + "manageDesc": "角色名称和颜色作为结构化元数据保存,不会改写字幕正文。", + "embedOnSave": "保存时在字幕中写入角色名(默认不写)", + "renameFor": "重命名 {{name}}", + "cueCount": "{{count}} 条字幕", + "locate": "只看该角色", + "colorChoice": "颜色 {{number}}", + "colorFor": "将 {{name}} 的颜色设为色板 {{number}}", + "moveAll": "移动全部字幕", + "merge": "合并角色", + "deleteEmpty": "删除空角色", + "deleteNonEmptyHint": "该角色仍有字幕,请先移动或合并", + "moveConfirmTitle": "移动角色字幕?", + "moveConfirmDesc": "将 {{source}} 的 {{count}} 条字幕移到 {{target}}。原角色会保留为空角色,可以撤销。", + "mergeConfirmTitle": "合并这两个角色?", + "mergeConfirmDesc": "将 {{source}} 的 {{count}} 条字幕归到 {{target}},然后删除原角色。字幕正文和时间轴不变,可以撤销。" + }, "cloudConsent": { "title": "上传音频到云端听写?", "description": "本次任务使用云端转写引擎,会提取每个文件的音轨并上传到你配置的第三方服务端点。请勿用于敏感内容,并注意服务商的用量费用。", diff --git a/scripts/test-proofread-speakers.ts b/scripts/test-proofread-speakers.ts new file mode 100644 index 00000000..047c483a --- /dev/null +++ b/scripts/test-proofread-speakers.ts @@ -0,0 +1,238 @@ +import { + PROOFREAD_DATA_VERSION, + SPEAKER_COLOR_PALETTE, + countSpeakerCues, + createDefaultSpeaker, + moveSpeakerAssignments, + nextSpeakerId, + normalizeProofreadData, + normalizeSpeakerAssignment, + normalizeSpeakerIds, + orderedSpeakerIds, + prefixTextWithSpeakerNames, + sanitizeSpeakerDisplayName, + speakerListsEqual, + type ProofreadDataCue, + type SpeakerInfo, +} from '../types/proofreadData'; + +let passed = 0; +let failed = 0; + +function check(condition: unknown, message: string): void { + if (condition) { + passed += 1; + return; + } + failed += 1; + console.error(`✗ ${message}`); +} + +function equal(actual: unknown, expected: unknown, message: string): void { + check( + JSON.stringify(actual) === JSON.stringify(expected), + `${message}\n expected ${JSON.stringify(expected)}\n received ${JSON.stringify(actual)}`, + ); +} + +const meta = { + createdAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-05T00:00:00.000Z', +}; + +const migrated = normalizeProofreadData({ + version: 1, + meta, + cues: [ + { + id: '1', + startMs: 0, + endMs: 1000, + source: 'hello', + target: '', + speakerIds: [2, 1, 2, -1], + }, + { + id: '2', + startMs: 1000, + endMs: 2000, + source: 'world', + target: '', + }, + ], +}); + +equal(migrated.version, PROOFREAD_DATA_VERSION, 'v1 migrates to v2'); +equal( + migrated.speakers.map((speaker) => speaker.id), + [1, 2], + 'v1 migration creates a sorted roster for referenced IDs', +); +equal( + migrated.cues[0].speakerIds, + [2, 1], + 'cue assignment preserves semantic order while removing duplicates', +); +equal( + migrated.cues[0].primarySpeakerId, + 2, + 'v1 migration makes the original first role explicit primary', +); +check( + migrated.speakers.every((speaker) => speaker.autoName), + 'generated roster names remain localizable', +); +check( + migrated.speakers.every((speaker) => + SPEAKER_COLOR_PALETTE.includes(speaker.color as any), + ), + 'generated speakers use the stable palette', +); + +const normalizedV2 = normalizeProofreadData({ + version: 2, + meta, + speakers: [ + { id: 2, displayName: 'Host', color: '#dc2626' }, + { id: 2, displayName: 'Duplicate', color: '#2563eb' }, + { id: 4, displayName: ' Guest ', color: 'invalid' }, + ], + cues: [ + { + id: '1', + startMs: 0, + endMs: 1000, + source: 'x', + target: '', + speakerIds: [4, 3], + primarySpeakerId: 99, + }, + ], +}); + +equal( + normalizedV2.speakers.map((speaker) => speaker.id), + [2, 4, 3], + 'v2 keeps roster order, removes duplicates and appends missing references', +); +equal( + normalizedV2.speakers[0].displayName, + 'Host', + 'renamed speaker survives normalization', +); +equal( + normalizedV2.speakers[1].displayName, + 'Guest', + 'speaker names are trimmed', +); +equal( + normalizedV2.cues[0].primarySpeakerId, + 4, + 'invalid explicit primary falls back to first assigned role', +); + +equal( + normalizeSpeakerIds([3, 1, 3, 0, 2.5, 2]), + [3, 1, 2], + 'speaker ID normalization is stable and rejects invalid IDs', +); +equal( + normalizeSpeakerAssignment({ speakerIds: [], primarySpeakerId: 2 }), + {}, + 'unassigned cues do not retain a fake primary role', +); +equal( + orderedSpeakerIds({ speakerIds: [1, 3, 2], primarySpeakerId: 3 }), + [3, 1, 2], + 'primary role renders first without mutating full assignment', +); + +const cues: ProofreadDataCue[] = [ + { + id: '1', + startMs: 0, + endMs: 1000, + source: 'a', + target: '', + speakerIds: [1, 2], + primarySpeakerId: 1, + }, + { + id: '2', + startMs: 1000, + endMs: 2000, + source: 'b', + target: '', + speakerIds: [1], + primarySpeakerId: 1, + }, + { + id: '3', + startMs: 2000, + endMs: 3000, + source: 'c', + target: '', + }, +]; +const moved = moveSpeakerAssignments(cues, 1, 2); +equal( + moved[0].speakerIds, + [2], + 'moving a role deduplicates overlap with the target', +); +equal( + moved[0].primarySpeakerId, + 2, + 'moving a primary role promotes the target', +); +equal(moved[1].speakerIds, [2], 'moving a role updates every associated cue'); +equal(moved[2], cues[2], 'moving a role preserves unrelated cue content'); +equal( + countSpeakerCues(cues, 1), + 2, + 'role cue count includes overlap cues once', +); + +const roster: SpeakerInfo[] = [ + createDefaultSpeaker(1, 'Host]'), + { ...createDefaultSpeaker(2, 'Guest'), autoName: false }, +]; +equal(nextSpeakerId(roster), 3, 'next role ID follows used IDs'); +equal( + nextSpeakerId([createDefaultSpeaker(1), createDefaultSpeaker(3)]), + 2, + 'next role ID fills stable gaps', +); +equal( + prefixTextWithSpeakerNames( + 'Hello', + { speakerIds: [1, 2], primarySpeakerId: 2 }, + roster, + ), + '[Guest + Host]] Hello', + 'deliverable prefix uses primary-first display names and escapes brackets', +); +equal( + prefixTextWithSpeakerNames('Hello', {}, roster), + 'Hello', + 'unassigned text is never prefixed', +); +equal( + sanitizeSpeakerDisplayName(' Host\n\tName '), + 'Host Name', + 'speaker names remove control characters and collapse whitespace', +); +check( + speakerListsEqual( + roster, + roster.map((speaker) => ({ ...speaker })), + ), + 'speaker roster equality accepts immutable clones', +); +check( + !speakerListsEqual(roster, [{ ...roster[0], color: '#16a34a' }, roster[1]]), + 'speaker roster equality detects color changes for undo history', +); + +console.log(`proofread speakers: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exitCode = 1; diff --git a/types/proofreadData.ts b/types/proofreadData.ts new file mode 100644 index 00000000..df5f9adb --- /dev/null +++ b/types/proofreadData.ts @@ -0,0 +1,320 @@ +/** + * Shared proofread sidecar contract and speaker-domain helpers. + * + * Keep this module dependency-free: main, renderer, dubbing and focused Node + * tests all consume the same normalization rules. + */ + +export const PROOFREAD_DATA_VERSION = 2 as const; + +export const SPEAKER_COLOR_PALETTE = [ + '#2563eb', + '#dc2626', + '#16a34a', + '#9333ea', + '#ea580c', + '#0891b2', + '#c026d3', + '#4f46e5', +] as const; + +export interface SpeakerInfo { + /** Stable, one-based identity. Renaming never changes it. */ + id: number; + displayName: string; + color: string; + /** The UI may localize an automatically generated name without losing intent. */ + autoName?: boolean; +} + +export interface ProofreadDataMeta { + createdAt: string; + updatedAt: string; + sourceLanguage?: string; + targetLanguage?: string; + translateContent?: string; + outputFormat?: string; + sourceFile?: string; + targetFile?: string; + finalTargetFile?: string; +} + +export interface ProofreadDataCue { + id: string; + startMs: number; + endMs: number; + source: string; + target: string; + /** Complete role assignment; absence/empty means the product state "unassigned". */ + speakerIds?: number[]; + /** Explicit primary role. Never infer priority from sorted IDs after editing. */ + primarySpeakerId?: number; +} + +export interface ProofreadDataFileV1 { + version: 1; + meta: ProofreadDataMeta; + cues: ProofreadDataCue[]; +} + +export interface ProofreadDataFileV2 { + version: typeof PROOFREAD_DATA_VERSION; + meta: ProofreadDataMeta; + speakers: SpeakerInfo[]; + cues: ProofreadDataCue[]; +} + +export type ProofreadDataFileInput = ProofreadDataFileV1 | ProofreadDataFileV2; + +export interface SpeakerAssignableCue { + speakerIds?: readonly number[]; + primarySpeakerId?: number; +} + +export function isValidSpeakerId(value: unknown): value is number { + return Number.isInteger(value) && Number(value) > 0; +} + +/** Deduplicate positive integer IDs while preserving their semantic order. */ +export function normalizeSpeakerIds( + values: readonly unknown[] | null | undefined, +): number[] { + const result: number[] = []; + const seen = new Set(); + for (const value of values || []) { + if (!isValidSpeakerId(value) || seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +} + +export function normalizePrimarySpeakerId( + primarySpeakerId: unknown, + speakerIds: readonly number[], +): number | undefined { + return isValidSpeakerId(primarySpeakerId) && + speakerIds.includes(primarySpeakerId) + ? primarySpeakerId + : speakerIds[0]; +} + +export function defaultSpeakerColor(id: number): string { + return SPEAKER_COLOR_PALETTE[ + (Math.max(1, id) - 1) % SPEAKER_COLOR_PALETTE.length + ]; +} + +export function defaultSpeakerName(id: number): string { + return `Speaker ${id}`; +} + +export function createDefaultSpeaker( + id: number, + displayName = defaultSpeakerName(id), +): SpeakerInfo { + return { + id, + displayName, + color: defaultSpeakerColor(id), + autoName: true, + }; +} + +export function sanitizeSpeakerDisplayName(value: unknown): string { + return String(value ?? '') + .replace(/[\x00-\x1f\x7f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 40); +} + +function normalizeSpeakerInfo(value: unknown): SpeakerInfo | null { + if (!value || typeof value !== 'object') return null; + const input = value as Partial; + if (!isValidSpeakerId(input.id)) return null; + const name = sanitizeSpeakerDisplayName(input.displayName); + const color = + typeof input.color === 'string' && + SPEAKER_COLOR_PALETTE.includes(input.color as any) + ? input.color + : defaultSpeakerColor(input.id); + return { + id: input.id, + displayName: name || defaultSpeakerName(input.id), + color, + ...(input.autoName === true ? { autoName: true } : {}), + }; +} + +export function normalizeSpeakerAssignment( + cue: T, +): T { + const speakerIds = normalizeSpeakerIds(cue.speakerIds); + const primarySpeakerId = normalizePrimarySpeakerId( + cue.primarySpeakerId, + speakerIds, + ); + const result = { ...cue } as T & { + speakerIds?: number[]; + primarySpeakerId?: number; + }; + if (speakerIds.length) { + result.speakerIds = speakerIds; + result.primarySpeakerId = primarySpeakerId; + } else { + delete result.speakerIds; + delete result.primarySpeakerId; + } + return result; +} + +export function orderedSpeakerIds(cue: SpeakerAssignableCue): number[] { + const ids = normalizeSpeakerIds(cue.speakerIds); + const primary = normalizePrimarySpeakerId(cue.primarySpeakerId, ids); + return primary ? [primary, ...ids.filter((id) => id !== primary)] : ids; +} + +/** Render user-facing names only at the deliverable boundary. */ +export function prefixTextWithSpeakerNames( + text: string, + cue: SpeakerAssignableCue, + speakers: readonly SpeakerInfo[], +): string { + const ids = orderedSpeakerIds(cue); + if (!ids.length) return text; + const speakerById = new Map(speakers.map((speaker) => [speaker.id, speaker])); + const names = ids.map((id) => { + const name = speakerById.get(id)?.displayName || defaultSpeakerName(id); + return name.replace(/\[/g, '[').replace(/\]/g, ']'); + }); + return `[${names.join(' + ')}] ${text}`.trimEnd(); +} + +export function normalizeSpeakerRoster( + speakers: readonly unknown[] | null | undefined, + cues: readonly SpeakerAssignableCue[], +): SpeakerInfo[] { + const roster: SpeakerInfo[] = []; + const seen = new Set(); + for (const raw of speakers || []) { + const speaker = normalizeSpeakerInfo(raw); + if (!speaker || seen.has(speaker.id)) continue; + seen.add(speaker.id); + roster.push(speaker); + } + const referenced = new Set(); + for (const cue of cues) { + for (const id of normalizeSpeakerIds(cue.speakerIds)) referenced.add(id); + } + for (const id of Array.from(referenced).sort((a, b) => a - b)) { + if (seen.has(id)) continue; + seen.add(id); + roster.push(createDefaultSpeaker(id)); + } + return roster; +} + +/** Accept v1/v2 sidecars and return the single canonical v2 shape. */ +export function normalizeProofreadData(input: unknown): ProofreadDataFileV2 { + if (!input || typeof input !== 'object') { + throw new Error('Invalid proofread data'); + } + const raw = input as Partial & { + version?: unknown; + cues?: unknown; + meta?: unknown; + speakers?: unknown; + }; + if ((raw.version !== 1 && raw.version !== 2) || !Array.isArray(raw.cues)) { + throw new Error('Unsupported proofread data version'); + } + const cues = raw.cues.map((value, index) => { + const cue = (value || {}) as Partial; + const normalized = normalizeSpeakerAssignment({ + id: String(cue.id || index + 1), + startMs: Number.isFinite(cue.startMs) ? Number(cue.startMs) : 0, + endMs: Number.isFinite(cue.endMs) ? Number(cue.endMs) : 0, + source: String(cue.source ?? ''), + target: String(cue.target ?? ''), + speakerIds: cue.speakerIds, + primarySpeakerId: cue.primarySpeakerId, + }); + return normalized as ProofreadDataCue; + }); + const metaInput = + raw.meta && typeof raw.meta === 'object' + ? (raw.meta as Partial) + : {}; + const now = new Date(0).toISOString(); + const meta: ProofreadDataMeta = { + ...metaInput, + createdAt: String(metaInput.createdAt || now), + updatedAt: String(metaInput.updatedAt || metaInput.createdAt || now), + }; + return { + version: PROOFREAD_DATA_VERSION, + meta, + speakers: normalizeSpeakerRoster( + raw.version === 2 && Array.isArray(raw.speakers) ? raw.speakers : [], + cues, + ), + cues, + }; +} + +export function nextSpeakerId(speakers: readonly SpeakerInfo[]): number { + const used = new Set(speakers.map((speaker) => speaker.id)); + let id = 1; + while (used.has(id)) id += 1; + return id; +} + +export function countSpeakerCues( + cues: readonly SpeakerAssignableCue[], + speakerId: number, +): number { + return cues.filter((cue) => + normalizeSpeakerIds(cue.speakerIds).includes(speakerId), + ).length; +} + +/** Move every source assignment to target, preserving overlap and explicit primary. */ +export function moveSpeakerAssignments( + cues: readonly T[], + sourceId: number, + targetId: number, +): T[] { + if (sourceId === targetId) return cues.map((cue) => ({ ...cue })); + return cues.map((cue) => { + const ids = normalizeSpeakerIds(cue.speakerIds); + if (!ids.includes(sourceId)) return { ...cue }; + const replaced = normalizeSpeakerIds( + ids.map((id) => (id === sourceId ? targetId : id)), + ); + return normalizeSpeakerAssignment({ + ...cue, + speakerIds: replaced, + primarySpeakerId: + cue.primarySpeakerId === sourceId ? targetId : cue.primarySpeakerId, + }); + }); +} + +export function speakerListsEqual( + left: readonly SpeakerInfo[], + right: readonly SpeakerInfo[], +): boolean { + return ( + left.length === right.length && + left.every((speaker, index) => { + const other = right[index]; + return ( + speaker.id === other?.id && + speaker.displayName === other.displayName && + speaker.color === other.color && + Boolean(speaker.autoName) === Boolean(other.autoName) + ); + }) + ); +} From 7fd226647c6d3d1c43a639e76ae2ff56c74a6dfd Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:34:14 +0800 Subject: [PATCH 2/2] fix(proofread): persist manual speaker assignments --- main/helpers/proofreadData.ts | 37 +++++++++++++--- renderer/hooks/useStandaloneSubtitles.ts | 18 +++++++- renderer/hooks/useSubtitleHistory.ts | 3 +- renderer/hooks/useSubtitles.ts | 2 + scripts/test-proofread-speakers.ts | 55 ++++++++++++++++++++++-- types/proofreadData.ts | 25 +++++++++-- 6 files changed, 125 insertions(+), 15 deletions(-) diff --git a/main/helpers/proofreadData.ts b/main/helpers/proofreadData.ts index 7b401d63..7c7dd03b 100644 --- a/main/helpers/proofreadData.ts +++ b/main/helpers/proofreadData.ts @@ -21,10 +21,12 @@ import { } from '../../types/speakerDiarization'; import { PROOFREAD_DATA_VERSION, + hasExplicitSpeakerAssignment, normalizePrimarySpeakerId, normalizeProofreadData, normalizeSpeakerIds, normalizeSpeakerRoster, + shouldRealignSpeakerAssignment, type ProofreadDataCue, type ProofreadDataFileV2, type SpeakerInfo, @@ -44,6 +46,7 @@ export interface ProofreadSubtitleRow { isEditing: boolean; speakerIds?: number[]; primarySpeakerId?: number; + speakerAssignmentSource?: 'manual'; } export type ProofreadDataWriteResult = @@ -250,10 +253,15 @@ export function proofreadDataToSubtitleRows( startTimeInSeconds: cue.startMs / 1000, endTimeInSeconds: cue.endMs / 1000, isEditing: false, - ...(cue.speakerIds?.length ? { speakerIds: [...cue.speakerIds] } : {}), + ...(hasExplicitSpeakerAssignment(cue) + ? { speakerIds: [...(cue.speakerIds || [])] } + : {}), ...(cue.primarySpeakerId ? { primarySpeakerId: cue.primarySpeakerId } : {}), + ...(cue.speakerAssignmentSource === 'manual' + ? { speakerAssignmentSource: 'manual' as const } + : {}), }; }); } @@ -282,17 +290,29 @@ export async function updateProofreadDataFromSubtitles( const timingChanged = Boolean( previous && (previous.startMs !== startMs || previous.endMs !== endMs), ); - const speakerIds = timingChanged + const hasCurrentAssignment = hasExplicitSpeakerAssignment(subtitle); + const currentSpeakerIds = hasCurrentAssignment + ? subtitle.speakerIds + : previous?.speakerIds; + const speakerAssignmentSource = + subtitle.speakerAssignmentSource || previous?.speakerAssignmentSource; + // Timing-based realignment is only valid for automatic assignments. + // Once the user has corrected a cue, the current row (including an + // explicit empty array) is authoritative across timing edits and saves. + const speakerIds = shouldRealignSpeakerAssignment( + timingChanged, + speakerAssignmentSource, + ) ? realignSpeakerIdsForCue( startMs, endMs, existing.cues, - subtitle.speakerIds || previous?.speakerIds, + currentSpeakerIds, ) - : mergeSpeakerIds(subtitle.speakerIds || previous?.speakerIds); + : mergeSpeakerIds(currentSpeakerIds); const normalizedSpeakerIds = normalizeSpeakerIds(speakerIds); const primarySpeakerId = normalizePrimarySpeakerId( - subtitle.primarySpeakerId || previous?.primarySpeakerId, + subtitle.primarySpeakerId ?? previous?.primarySpeakerId, normalizedSpeakerIds, ); return { @@ -301,12 +321,15 @@ export async function updateProofreadDataFromSubtitles( endMs, source, target: subtitle.targetContent ?? '', - ...(normalizedSpeakerIds.length + ...(normalizedSpeakerIds.length || speakerAssignmentSource === 'manual' ? { speakerIds: normalizedSpeakerIds, - primarySpeakerId, + ...(primarySpeakerId ? { primarySpeakerId } : {}), } : {}), + ...(speakerAssignmentSource === 'manual' + ? { speakerAssignmentSource: 'manual' as const } + : {}), }; }), }; diff --git a/renderer/hooks/useStandaloneSubtitles.ts b/renderer/hooks/useStandaloneSubtitles.ts index a548a18e..a590e751 100644 --- a/renderer/hooks/useStandaloneSubtitles.ts +++ b/renderer/hooks/useStandaloneSubtitles.ts @@ -18,6 +18,7 @@ import { nextSpeakerId, normalizePrimarySpeakerId, normalizeSpeakerAssignment, + normalizeSpeakerIds, sanitizeSpeakerDisplayName, speakerListsEqual, type SpeakerInfo, @@ -713,6 +714,11 @@ export const useStandaloneSubtitles = ( ), } : { speakerIds: undefined, primarySpeakerId: undefined }), + ...(toMerge.some( + (subtitle) => subtitle.speakerAssignmentSource === 'manual', + ) + ? { speakerAssignmentSource: 'manual' as const } + : {}), }; history.push({ start: startIndex, removed: toMerge, inserted: [merged] }); @@ -785,6 +791,7 @@ export const useStandaloneSubtitles = ( ...row, speakerIds, primarySpeakerId, + speakerAssignmentSource: 'manual' as const, }); const next = current.slice(); next[index] = updated; @@ -808,6 +815,7 @@ export const useStandaloneSubtitles = ( ...nextSubtitles[cueIndex], speakerIds: [id], primarySpeakerId: id, + speakerAssignmentSource: 'manual' as const, }); } commitSpeakerDocument(nextSubtitles, nextSpeakers); @@ -845,10 +853,18 @@ export const useStandaloneSubtitles = ( const handleMoveSpeaker = useCallback( (sourceId: number, targetId: number, removeSource: boolean) => { if (sourceId === targetId) return; + const currentSubtitles = subtitlesRef.current; + const affected = currentSubtitles.map((subtitle) => + normalizeSpeakerIds(subtitle.speakerIds).includes(sourceId), + ); const nextSubtitles = moveSpeakerAssignments( - subtitlesRef.current, + currentSubtitles, sourceId, targetId, + ).map((subtitle, index) => + affected[index] + ? { ...subtitle, speakerAssignmentSource: 'manual' as const } + : subtitle, ); const nextSpeakers = removeSource ? speakersRef.current.filter((speaker) => speaker.id !== sourceId) diff --git a/renderer/hooks/useSubtitleHistory.ts b/renderer/hooks/useSubtitleHistory.ts index 28cfb998..d7f3cc46 100644 --- a/renderer/hooks/useSubtitleHistory.ts +++ b/renderer/hooks/useSubtitleHistory.ts @@ -42,7 +42,8 @@ const speakerAssignmentEquals = (a: Subtitle, b: Subtitle): boolean => { left.length === right.length && left.every((id, index) => id === right[index]) && normalizePrimarySpeakerId(a.primarySpeakerId, left) === - normalizePrimarySpeakerId(b.primarySpeakerId, right) + normalizePrimarySpeakerId(b.primarySpeakerId, right) && + a.speakerAssignmentSource === b.speakerAssignmentSource ); }; diff --git a/renderer/hooks/useSubtitles.ts b/renderer/hooks/useSubtitles.ts index 7381c040..15348d3a 100644 --- a/renderer/hooks/useSubtitles.ts +++ b/renderer/hooks/useSubtitles.ts @@ -19,6 +19,8 @@ export interface Subtitle { speakerIds?: number[]; /** 显式主要角色;多角色时不依赖 ID 排序推断。 */ primarySpeakerId?: number; + /** 人工角色归属在后续时间调整时保持优先。 */ + speakerAssignmentSource?: 'manual'; } export interface SubtitleStats { diff --git a/scripts/test-proofread-speakers.ts b/scripts/test-proofread-speakers.ts index 047c483a..f3097c9c 100644 --- a/scripts/test-proofread-speakers.ts +++ b/scripts/test-proofread-speakers.ts @@ -3,6 +3,7 @@ import { SPEAKER_COLOR_PALETTE, countSpeakerCues, createDefaultSpeaker, + hasExplicitSpeakerAssignment, moveSpeakerAssignments, nextSpeakerId, normalizeProofreadData, @@ -11,6 +12,7 @@ import { orderedSpeakerIds, prefixTextWithSpeakerNames, sanitizeSpeakerDisplayName, + shouldRealignSpeakerAssignment, speakerListsEqual, type ProofreadDataCue, type SpeakerInfo, @@ -88,6 +90,10 @@ check( ), 'generated speakers use the stable palette', ); +check( + !Object.prototype.hasOwnProperty.call(migrated.cues[1], 'speakerIds'), + 'migration keeps missing role metadata distinct from explicit unassigned', +); const normalizedV2 = normalizeProofreadData({ version: 2, @@ -137,9 +143,13 @@ equal( 'speaker ID normalization is stable and rejects invalid IDs', ); equal( - normalizeSpeakerAssignment({ speakerIds: [], primarySpeakerId: 2 }), - {}, - 'unassigned cues do not retain a fake primary role', + normalizeSpeakerAssignment({ + speakerIds: [], + primarySpeakerId: 2, + speakerAssignmentSource: 'manual' as const, + }), + { speakerIds: [], speakerAssignmentSource: 'manual' }, + 'explicit unassigned survives normalization without a fake primary role', ); equal( orderedSpeakerIds({ speakerIds: [1, 3, 2], primarySpeakerId: 3 }), @@ -234,5 +244,44 @@ check( 'speaker roster equality detects color changes for undo history', ); +const explicitUnassigned = normalizeProofreadData({ + version: 2, + meta, + speakers: roster, + cues: [ + { + id: '1', + startMs: 0, + endMs: 1000, + source: 'Hello', + target: '', + speakerIds: [], + speakerAssignmentSource: 'manual', + }, + ], +}); +equal( + explicitUnassigned.cues[0].speakerIds, + [], + 'sidecar normalization persists explicit unassigned', +); +equal( + explicitUnassigned.cues[0].speakerAssignmentSource, + 'manual', + 'sidecar normalization persists manual assignment precedence', +); +check( + hasExplicitSpeakerAssignment(explicitUnassigned.cues[0]), + 'explicit unassigned remains distinguishable after reopening', +); +check( + !shouldRealignSpeakerAssignment(true, 'manual'), + 'timing edits never realign a manual role correction', +); +check( + shouldRealignSpeakerAssignment(true), + 'timing edits can still realign untouched automatic assignments', +); + console.log(`proofread speakers: ${passed} passed, ${failed} failed`); if (failed > 0) process.exitCode = 1; diff --git a/types/proofreadData.ts b/types/proofreadData.ts index df5f9adb..33db700f 100644 --- a/types/proofreadData.ts +++ b/types/proofreadData.ts @@ -45,10 +45,12 @@ export interface ProofreadDataCue { endMs: number; source: string; target: string; - /** Complete role assignment; absence/empty means the product state "unassigned". */ + /** Complete assignment; [] is explicit unassigned, absence means no role metadata. */ speakerIds?: number[]; /** Explicit primary role. Never infer priority from sorted IDs after editing. */ primarySpeakerId?: number; + /** Manual assignments remain authoritative when cue timing is edited later. */ + speakerAssignmentSource?: 'manual'; } export interface ProofreadDataFileV1 { @@ -71,6 +73,17 @@ export interface SpeakerAssignableCue { primarySpeakerId?: number; } +export function hasExplicitSpeakerAssignment(value: object): boolean { + return Object.prototype.hasOwnProperty.call(value, 'speakerIds'); +} + +export function shouldRealignSpeakerAssignment( + timingChanged: boolean, + source?: 'manual', +): boolean { + return timingChanged && source !== 'manual'; +} + export function isValidSpeakerId(value: unknown): value is number { return Number.isInteger(value) && Number(value) > 0; } @@ -150,6 +163,7 @@ function normalizeSpeakerInfo(value: unknown): SpeakerInfo | null { export function normalizeSpeakerAssignment( cue: T, ): T { + const hasExplicitAssignment = hasExplicitSpeakerAssignment(cue); const speakerIds = normalizeSpeakerIds(cue.speakerIds); const primarySpeakerId = normalizePrimarySpeakerId( cue.primarySpeakerId, @@ -163,7 +177,8 @@ export function normalizeSpeakerAssignment( result.speakerIds = speakerIds; result.primarySpeakerId = primarySpeakerId; } else { - delete result.speakerIds; + if (hasExplicitAssignment) result.speakerIds = []; + else delete result.speakerIds; delete result.primarySpeakerId; } return result; @@ -231,14 +246,18 @@ export function normalizeProofreadData(input: unknown): ProofreadDataFileV2 { } const cues = raw.cues.map((value, index) => { const cue = (value || {}) as Partial; + const hasExplicitAssignment = hasExplicitSpeakerAssignment(cue); const normalized = normalizeSpeakerAssignment({ id: String(cue.id || index + 1), startMs: Number.isFinite(cue.startMs) ? Number(cue.startMs) : 0, endMs: Number.isFinite(cue.endMs) ? Number(cue.endMs) : 0, source: String(cue.source ?? ''), target: String(cue.target ?? ''), - speakerIds: cue.speakerIds, + ...(hasExplicitAssignment ? { speakerIds: cue.speakerIds } : {}), primarySpeakerId: cue.primarySpeakerId, + ...(cue.speakerAssignmentSource === 'manual' + ? { speakerAssignmentSource: 'manual' as const } + : {}), }); return normalized as ProofreadDataCue; });