Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/docs/features/subtitle-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ keywords:

当前版本暂不在一条龙向导、自定义配方及含配音 / 合成的流程中开放角色分离;这些流程会在角色元数据与配音音色映射完成后再接入。

## 用参考文稿校正转写

课程、演讲或成片已有 TXT / Markdown 文稿时,可在任务的「参考文稿」中选择它。妙幕会在转写后按顺序对齐文稿与字幕,只把达到安全置信度的文本写回,并始终保留 ASR 生成的字幕条数和时间轴。

- 支持 UTF-8、UTF-16、GBK / GB18030 编码、最大 1 MiB 的 `.txt`、`.md`、`.markdown`
- 可跨过没有念出的标题、列表和舞台说明,并处理文稿与字幕分句粒度不一致
- 低置信片段保持原识别文本;文稿为空、被移动或读取失败时整项安全回退,不会令转写任务失败
- 任务轨道会显示已匹配条数及回退状态;匹配后的文本继续进入翻译、校对和导出

## GPU 加速

| 平台 | 加速后端 |
Expand Down
67 changes: 67 additions & 0 deletions main/helpers/atomicFile.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<ReturnType<typeof fs.open>> | 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;
}
}
20 changes: 20 additions & 0 deletions main/helpers/fileProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -277,20 +281,24 @@ export async function processFile(
'extractSubtitle',
'prepareSubtitle',
'refineSubtitle',
'manuscriptMatch',
'translateSubtitle',
'speakerDiarization',
'dubbing',
'composeVideo',
'extractAudioProgress',
'extractSubtitleProgress',
'refineSubtitleProgress',
'manuscriptMatchProgress',
'translateSubtitleProgress',
'speakerDiarizationProgress',
'dubbingProgress',
'composeVideoProgress',
'extractAudioError',
'extractSubtitleError',
'refineSubtitleError',
'manuscriptMatchError',
'manuscriptMatchErrorDetail',
'translateSubtitleError',
'speakerDiarizationError',
'dubbingError',
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -450,6 +460,7 @@ export async function processFile(
});
// 转写复用意味着首轮精修(若开启)已写入 srtForTranslate;结算阶段态。
settleSkippedRefineStage(event, file, formData);
settleSkippedManuscriptMatchStage(event, file, formData);
} else if (!isSubtitleFile && shouldGenerateSubtitle) {
const templateData = {
fileName,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 阶段回退为待处理
Expand All @@ -590,6 +609,7 @@ export async function processFile(
extractAudio: '',
extractSubtitle: '',
refineSubtitle: '',
manuscriptMatch: '',
});
throw new TaskCancelledError();
}
Expand Down
34 changes: 34 additions & 0 deletions main/helpers/ipcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import {
readProofreadDataFile,
updateProofreadDataFromSubtitles,
} from './proofreadData';
import {
MANUSCRIPT_EXTENSIONS,
ManuscriptFileError,
readManuscriptFile,
toManuscriptSelectionPayload,
} from './manuscriptMatching';

// 定义支持的文件扩展名常量
export const MEDIA_EXTENSIONS = [
Expand Down Expand Up @@ -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);
});
Expand Down
14 changes: 11 additions & 3 deletions main/helpers/ipcStoreHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = {
...defaultUserConfig,
...storedConfig,
...reusableConfig,
};
// 字幕效果默认档:缺省时按既有旋钮惰性推断(全新/默认→均衡;老用户自定义→对应档或
// custom,逐字保留行为)。在此补齐而非写 store 默认值,避免回灌覆盖老用户底层旋钮。
Expand Down
Loading
Loading