@makuraryu/yonde 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,15 +7,15 @@ Yonde(読んで)是一个配置驱动的 Bun CLI:把日文文本翻译成
7
7
  需要 [Bun](https://bun.sh/) 1.3 或更新版本。生成 MP3 时还需要 `ffmpeg`;只运行翻译阶段则不需要。
8
8
 
9
9
  ```bash
10
- bunx github:Makuraryu/Yonde init
10
+ bunx @makuraryu/yonde@latest init
11
11
 
12
12
  # 默认配置从这个环境变量读取 DeepSeek API Key
13
13
  export YONDE_API_KEY="your-api-key"
14
14
 
15
- bunx github:Makuraryu/Yonde input.txt
15
+ bunx @makuraryu/yonde@latest input.txt
16
16
  ```
17
17
 
18
- 这会直接从公开 GitHub 仓库的默认分支安装并运行,不需要 npm 发布。版本记录可在 [GitHub Releases](https://github.com/Makuraryu/Yonde/releases) 查看。
18
+ `bunx` 会从 npm 获取最新版并直接运行,无需全局安装。也可以使用 `bunx github:Makuraryu/Yonde` 试用 GitHub 默认分支上的未发布代码。版本记录见 [GitHub Releases](https://github.com/Makuraryu/Yonde/releases)
19
19
 
20
20
  ## 命令
21
21
 
@@ -29,21 +29,21 @@ yonde config check [--config <配置文件>]
29
29
 
30
30
  ```bash
31
31
  # 翻译并生成音频
32
- bunx github:Makuraryu/Yonde input.txt
32
+ bunx @makuraryu/yonde@latest input.txt
33
33
 
34
34
  # 只翻译,或使用已有翻译生成音频
35
- bunx github:Makuraryu/Yonde input.txt --stage translate
36
- bunx github:Makuraryu/Yonde input.txt --stage audio
35
+ bunx @makuraryu/yonde@latest input.txt --stage translate
36
+ bunx @makuraryu/yonde@latest input.txt --stage audio
37
37
 
38
38
  # 指定配置和输出目录
39
- bunx github:Makuraryu/Yonde input.txt --config ./custom.toml --output-dir ./build
39
+ bunx @makuraryu/yonde@latest input.txt --config ./custom.toml --output-dir ./build
40
40
 
41
41
  # 检查最终合并后的配置
42
- bunx github:Makuraryu/Yonde config check
43
- bunx github:Makuraryu/Yonde config check --config ./custom.toml
42
+ bunx @makuraryu/yonde@latest config check
43
+ bunx @makuraryu/yonde@latest config check --config ./custom.toml
44
44
 
45
45
  # 查看完整帮助
46
- bunx github:Makuraryu/Yonde --help
46
+ bunx @makuraryu/yonde@latest --help
47
47
  ```
48
48
 
49
49
  `init` 默认创建 `./yonde.toml`,且不会覆盖已有文件。
@@ -148,6 +148,12 @@ package_asset = "uisfx/sounds/cinematic/select.mp3"
148
148
 
149
149
  翻译缓存指纹包含输入内容、语言、模型、端点、提示词版本和分句规则,但不包含 API Key。音频缓存指纹包含文本、音色、语言、语速和音调。仅调整朗读顺序时,Yonde 会复用已有语音并重新合并。
150
150
 
151
+ 翻译、语音生成和最终 MP3 合并都会显示单行进度条,包括完成比例、数量、耗时和 ETA。非交互终端按 5% 里程碑输出,避免日志刷屏。
152
+
153
+ 所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3 采用无损快速拼接,不再把数小时音频完整重编码。
154
+
155
+ 最终合并使用状态文件保护原子写入。若进程在 ffmpeg 已完成后、最终重命名前退出,下次运行会直接恢复成品;若在合并中途退出,只会重做最终合并,已经生成的翻译和 TTS 缓存不会丢失。
156
+
151
157
  ## 本地开发
152
158
 
153
159
  ```bash
@@ -159,7 +165,7 @@ bun run check
159
165
  bun run src/main.ts --help
160
166
  ```
161
167
 
162
- ## npm 发布(可选)
168
+ ## npm 发布
163
169
 
164
170
  ```bash
165
171
  bun run check
@@ -168,7 +174,7 @@ npm pack --dry-run
168
174
  npm publish --access public
169
175
  ```
170
176
 
171
- Yonde 不依赖 npm 发布即可通过上面的 `github:Makuraryu/Yonde` 包标识运行。npm 发布只是提供更短的 registry 包名。
177
+ 发布后可通过 `bunx @makuraryu/yonde@latest` 直接运行;GitHub 包标识适合测试尚未发布的默认分支。
172
178
 
173
179
  ## License
174
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makuraryu/yonde",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Config-driven Bun CLI for creating bilingual Japanese listening scripts and MP3 audio",
5
5
  "type": "module",
6
6
  "bin": {
package/src/audio.ts CHANGED
@@ -1,16 +1,34 @@
1
1
  import { EdgeTTS } from "node-edge-tts";
2
- import { copyFile, lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
2
+ import { lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { basename, dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import type { AppConfig, VoiceProfile } from "./config";
7
7
  import type { TranslatedParagraph } from "./text";
8
8
  import { splitForTts } from "./text";
9
- import { atomicWrite, writeJson } from "./state";
9
+ import { ProgressBar } from "./progress";
10
+ import { atomicWrite, readJson, writeJson } from "./state";
10
11
 
11
12
  type AudioItem = { kind: string; text?: string; path: string };
12
13
  type AudioSpec = { kind: string; text: string; profile: VoiceProfile };
13
14
  type PlannedItem = { type: "audio"; spec: AudioSpec } | { type: "separator" };
15
+ type SynthesisResult = { path: string; cached: boolean };
16
+ type AudioManifest = {
17
+ version: 3;
18
+ mergeFingerprint: string;
19
+ profiles: AppConfig["audio"]["profiles"];
20
+ paragraphSequence: string[];
21
+ sentenceSequence: string[];
22
+ separator: AppConfig["audio"]["separator"];
23
+ items: AudioItem[];
24
+ };
25
+ type MergeState = {
26
+ version: 1;
27
+ fingerprint: string;
28
+ temporary: string;
29
+ status: "merging" | "complete";
30
+ expectedSeconds: number;
31
+ };
14
32
 
15
33
  async function fileIsUsable(path: string): Promise<boolean> {
16
34
  try {
@@ -29,11 +47,11 @@ function specHash(spec: AudioSpec): string {
29
47
  .slice(0, 24);
30
48
  }
31
49
 
32
- async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string): Promise<string> {
50
+ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string): Promise<SynthesisResult> {
33
51
  const hash = specHash(spec);
34
52
  const safeId = spec.kind.replace(/[^a-zA-Z0-9_-]/g, "_");
35
53
  const output = join(cacheDir, `${safeId}-${hash}.mp3`);
36
- if (await fileIsUsable(output)) return output;
54
+ if (await fileIsUsable(output)) return { path: output, cached: true };
37
55
 
38
56
  const temporary = `${output}.${process.pid}.${randomUUID()}.part.mp3`;
39
57
  if (!/[\p{L}\p{N}]/u.test(spec.text)) {
@@ -43,7 +61,7 @@ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string)
43
61
  ]);
44
62
  if (await silence.exited !== 0) throw new Error(`无法为纯标点片段生成静音: ${spec.text}`);
45
63
  await rename(temporary, output);
46
- return output;
64
+ return { path: output, cached: false };
47
65
  }
48
66
  for (let attempt = 1; attempt <= 6; attempt += 1) {
49
67
  try {
@@ -60,7 +78,7 @@ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string)
60
78
  await tts.ttsPromise(spec.text, temporary);
61
79
  if (!(await fileIsUsable(temporary))) throw new Error("TTS 输出为空");
62
80
  await rename(temporary, output);
63
- return output;
81
+ return { path: output, cached: false };
64
82
  } catch (error) {
65
83
  await rm(temporary, { force: true });
66
84
  if (attempt === 6) throw new Error(`${spec.kind} 生成失败(${spec.text.slice(0, 40)}): ${error instanceof Error ? error.message : String(error)}`);
@@ -70,21 +88,18 @@ async function synthesize(spec: AudioSpec, cacheDir: string, ffmpegPath: string)
70
88
  throw new Error("无法生成语音");
71
89
  }
72
90
 
73
- async function runPool<T>(jobs: Array<() => Promise<T>>, concurrency: number): Promise<T[]> {
91
+ async function runPool<T>(jobs: Array<() => Promise<T>>, concurrency: number, onCompleted: (value: T) => void): Promise<T[]> {
74
92
  const results = new Array<T>(jobs.length);
75
93
  let next = 0;
76
- let completed = 0;
77
94
  async function worker() {
78
95
  while (true) {
79
96
  const index = next++;
80
97
  if (index >= jobs.length) return;
81
98
  results[index] = await jobs[index]();
82
- completed += 1;
83
- process.stdout.write(`\r[TTS] ${completed}/${jobs.length}`);
99
+ onCompleted(results[index]);
84
100
  }
85
101
  }
86
102
  await Promise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, worker));
87
- if (jobs.length) process.stdout.write("\n");
88
103
  return results;
89
104
  }
90
105
 
@@ -92,24 +107,123 @@ function escapeConcatPath(path: string): string {
92
107
  return path.replace(/'/g, "'\\''");
93
108
  }
94
109
 
95
- async function runFfmpeg(items: AudioItem[], outputPath: string, stateDir: string, ffmpegPath: string): Promise<void> {
110
+ export function audioMergeFingerprint(value: Omit<AudioManifest, "version" | "mergeFingerprint">): string {
111
+ return new Bun.CryptoHasher("sha256").update(JSON.stringify(value)).digest("hex");
112
+ }
113
+
114
+ export function mergeStateCanRecover(value: unknown, fingerprint: string, outputPath: string): value is MergeState {
115
+ if (!value || typeof value !== "object") return false;
116
+ const state = value as Partial<MergeState>;
117
+ const prefix = `${basename(outputPath)}.`;
118
+ return state.version === 1
119
+ && state.status === "complete"
120
+ && state.fingerprint === fingerprint
121
+ && typeof state.temporary === "string"
122
+ && dirname(state.temporary) === dirname(outputPath)
123
+ && basename(state.temporary).startsWith(prefix)
124
+ && basename(state.temporary).endsWith(".tmp.mp3");
125
+ }
126
+
127
+ async function temporaryOutputs(outputPath: string): Promise<string[]> {
128
+ const prefix = `${basename(outputPath)}.`;
129
+ return (await readdir(dirname(outputPath)))
130
+ .filter((entry) => entry.startsWith(prefix) && entry.endsWith(".tmp.mp3"))
131
+ .map((entry) => join(dirname(outputPath), entry));
132
+ }
133
+
134
+ async function estimateDurationSeconds(items: AudioItem[]): Promise<number> {
135
+ const sizes = new Map<string, number>();
136
+ let bytes = 0;
137
+ for (const item of items) {
138
+ let size = sizes.get(item.path);
139
+ if (size === undefined) {
140
+ size = (await lstat(item.path)).size;
141
+ sizes.set(item.path, size);
142
+ }
143
+ bytes += size;
144
+ }
145
+ return Math.max(1, bytes * 8 / 96_000);
146
+ }
147
+
148
+ async function recoverCompletedMerge(outputPath: string, stateDir: string, fingerprint: string): Promise<boolean> {
149
+ const statePath = join(stateDir, "audio-merge-state.json");
150
+ const state = await readJson<unknown>(statePath);
151
+ if (!mergeStateCanRecover(state, fingerprint, outputPath) || !(await fileIsUsable(state.temporary))) return false;
152
+ await rename(state.temporary, outputPath);
153
+ await rm(statePath, { force: true });
154
+ for (const stale of await temporaryOutputs(outputPath)) await rm(stale, { force: true });
155
+ const progress = new ProgressBar("恢复", 1);
156
+ progress.finish("已恢复完成的合并结果");
157
+ return true;
158
+ }
159
+
160
+ async function readFfmpegProgress(stream: ReadableStream<Uint8Array>, progress: ProgressBar, totalSeconds: number): Promise<void> {
161
+ const reader = stream.getReader();
162
+ const decoder = new TextDecoder();
163
+ let pending = "";
164
+ while (true) {
165
+ const { done, value } = await reader.read();
166
+ if (done) break;
167
+ pending += decoder.decode(value, { stream: true });
168
+ const lines = pending.split(/\r?\n/);
169
+ pending = lines.pop() ?? "";
170
+ for (const line of lines) {
171
+ if (!line.startsWith("out_time_us=")) continue;
172
+ const seconds = Number(line.slice("out_time_us=".length)) / 1_000_000;
173
+ if (Number.isFinite(seconds)) progress.update(Math.min(seconds, totalSeconds), `音频 ${Math.floor(seconds / 60)} 分钟`);
174
+ }
175
+ }
176
+ }
177
+
178
+ async function runFfmpeg(
179
+ items: AudioItem[],
180
+ outputPath: string,
181
+ stateDir: string,
182
+ ffmpegPath: string,
183
+ fingerprint: string,
184
+ ): Promise<void> {
96
185
  const listPath = join(stateDir, "concat.txt");
97
186
  const paths = items.map((item) => relative(stateDir, item.path));
98
187
  if (paths.some((path) => path.startsWith("..") || /[\r\n\0]/.test(path))) throw new Error("音频缓存路径超出状态目录或含控制字符");
99
188
  await atomicWrite(listPath, paths.map((path) => `file '${escapeConcatPath(path)}'`).join("\n") + "\n");
100
189
  const temporary = `${outputPath}.${process.pid}.${randomUUID()}.tmp.mp3`;
190
+ const mergeStatePath = join(stateDir, "audio-merge-state.json");
191
+ const expectedSeconds = await estimateDurationSeconds(items);
192
+ const progress = new ProgressBar("合并", expectedSeconds);
101
193
  await rm(temporary, { force: true });
194
+ await writeJson(mergeStatePath, {
195
+ version: 1,
196
+ fingerprint,
197
+ temporary,
198
+ status: "merging",
199
+ expectedSeconds,
200
+ } satisfies MergeState);
102
201
  const processResult = Bun.spawn([
103
202
  ffmpegPath, "-hide_banner", "-loglevel", "error", "-y", "-f", "concat", "-safe", "1", "-i", listPath,
104
- "-af", "aresample=24000,aformat=sample_fmts=s16:channel_layouts=mono",
105
- "-c:a", "libmp3lame", "-b:a", "96k", temporary,
106
- ], { stdout: "inherit", stderr: "inherit" });
203
+ "-c:a", "copy", "-progress", "pipe:1", "-nostats", temporary,
204
+ ], { stdout: "pipe", stderr: "pipe" });
205
+ const progressReader = readFfmpegProgress(processResult.stdout, progress, expectedSeconds);
206
+ const stderrReader = new Response(processResult.stderr).text();
107
207
  const exitCode = await processResult.exited;
208
+ await progressReader;
209
+ const stderr = await stderrReader;
108
210
  if (exitCode !== 0) {
211
+ progress.fail(`退出码 ${exitCode}`);
109
212
  await rm(temporary, { force: true });
110
- throw new Error(`ffmpeg 合并失败,退出码 ${exitCode}`);
213
+ await rm(mergeStatePath, { force: true });
214
+ const detail = stderr.trim().split("\n").at(-1);
215
+ throw new Error(`ffmpeg 合并失败,退出码 ${exitCode}${detail ? `: ${detail}` : ""}`);
111
216
  }
217
+ await writeJson(mergeStatePath, {
218
+ version: 1,
219
+ fingerprint,
220
+ temporary,
221
+ status: "complete",
222
+ expectedSeconds,
223
+ } satisfies MergeState);
224
+ progress.finish(`${items.length} 个片段`);
112
225
  await rename(temporary, outputPath);
226
+ await rm(mergeStatePath, { force: true });
113
227
  }
114
228
 
115
229
  function profileText(profile: VoiceProfile, source: string, target: string): string {
@@ -162,10 +276,6 @@ export async function buildAudio(
162
276
  for (const entry of await readdir(cacheDir)) if (entry.endsWith(".part.mp3")) await rm(join(cacheDir, entry), { force: true });
163
277
  const outputDir = dirname(outputPath);
164
278
  await mkdir(outputDir, { recursive: true });
165
- const stalePrefix = `${basename(outputPath)}.`;
166
- for (const entry of await readdir(outputDir)) {
167
- if (entry.startsWith(stalePrefix) && entry.endsWith(".tmp.mp3")) await rm(join(outputDir, entry), { force: true });
168
- }
169
279
 
170
280
  const plan = buildPlan(paragraphs, config);
171
281
  const uniqueSpecs = new Map<string, AudioSpec>();
@@ -174,13 +284,23 @@ export async function buildAudio(
174
284
 
175
285
  let separatorPath: string | undefined;
176
286
  if (plan.some((item) => item.type === "separator")) {
177
- separatorPath = join(stateDir, "separator.mp3");
287
+ separatorPath = join(stateDir, "separator-24khz-96k-mono.mp3");
178
288
  if (!(await fileIsUsable(separatorPath))) {
179
289
  const source = resolveSeparatorAsset(config.audio.separator.packageAsset);
180
290
  if (!(await fileIsUsable(source))) throw new Error(`找不到可用的分隔音效: ${source}`);
181
- const temporarySeparator = `${separatorPath}.${randomUUID()}.tmp`;
291
+ const temporarySeparator = `${separatorPath}.${randomUUID()}.tmp.mp3`;
182
292
  try {
183
- await copyFile(source, temporarySeparator);
293
+ const conversion = Bun.spawn([
294
+ ffmpegPath, "-hide_banner", "-loglevel", "error", "-y", "-i", source,
295
+ "-ar", "24000", "-ac", "1", "-c:a", "libmp3lame", "-b:a", "96k", temporarySeparator,
296
+ ], { stdout: "ignore", stderr: "pipe" });
297
+ const stderrReader = new Response(conversion.stderr).text();
298
+ const exitCode = await conversion.exited;
299
+ const stderr = await stderrReader;
300
+ if (exitCode !== 0 || !(await fileIsUsable(temporarySeparator))) {
301
+ const detail = stderr.trim().split("\n").at(-1);
302
+ throw new Error(`分隔音效标准化失败${detail ? `: ${detail}` : ""}`);
303
+ }
184
304
  await rename(temporarySeparator, separatorPath);
185
305
  } catch (error) {
186
306
  await rm(temporarySeparator, { force: true });
@@ -190,21 +310,43 @@ export async function buildAudio(
190
310
  }
191
311
 
192
312
  const entries = [...uniqueSpecs.entries()];
193
- console.log(`[TTS] ${entries.length} 个唯一片段,并发 ${config.audio.concurrency}(已有缓存会跳过)`);
194
- const paths = await runPool(entries.map(([, spec]) => () => synthesize(spec, cacheDir, ffmpegPath)), config.audio.concurrency);
195
- const generated = new Map(entries.map(([hash], index) => [hash, paths[index]]));
313
+ const synthesisProgress = new ProgressBar("语音", entries.length);
314
+ let completed = 0;
315
+ let cached = 0;
316
+ let results: SynthesisResult[];
317
+ try {
318
+ results = await runPool(
319
+ entries.map(([, spec]) => () => synthesize(spec, cacheDir, ffmpegPath)),
320
+ config.audio.concurrency,
321
+ (result) => {
322
+ completed += 1;
323
+ if (result.cached) cached += 1;
324
+ if (completed < entries.length) synthesisProgress.update(completed, `缓存 ${cached} · 新生成 ${completed - cached}`);
325
+ },
326
+ );
327
+ synthesisProgress.finish(`缓存 ${cached} · 新生成 ${completed - cached}`);
328
+ } catch (error) {
329
+ synthesisProgress.fail(`完成 ${completed}/${entries.length}`);
330
+ throw error;
331
+ }
332
+ const generated = new Map(entries.map(([hash], index) => [hash, results[index].path]));
196
333
  const items: AudioItem[] = plan.map((item) => item.type === "separator"
197
334
  ? { kind: "separator", path: separatorPath! }
198
335
  : { kind: item.spec.kind, text: item.spec.text, path: generated.get(specHash(item.spec))! });
199
336
 
200
- await writeJson(join(stateDir, "audio-manifest.json"), {
201
- version: 2,
337
+ const manifestBody = {
202
338
  profiles: config.audio.profiles,
203
339
  paragraphSequence: config.audio.paragraphSequence,
204
340
  sentenceSequence: config.audio.sentenceSequence,
205
341
  separator: config.audio.separator,
206
342
  items,
207
- });
208
- console.log(`[合并] ${items.length} 个音频片段`);
209
- await runFfmpeg(items, outputPath, stateDir, ffmpegPath);
343
+ };
344
+ const mergeFingerprint = audioMergeFingerprint(manifestBody);
345
+ if (await recoverCompletedMerge(outputPath, stateDir, mergeFingerprint)) return;
346
+ const stale = await temporaryOutputs(outputPath);
347
+ if (stale.length) console.warn(`发现 ${stale.length} 个未完成的合并临时文件,将保留语音缓存并重新合并。`);
348
+ for (const path of stale) await rm(path, { force: true });
349
+ const manifest: AudioManifest = { version: 3, mergeFingerprint, ...manifestBody };
350
+ await writeJson(join(stateDir, "audio-manifest.json"), manifest);
351
+ await runFfmpeg(items, outputPath, stateDir, ffmpegPath, mergeFingerprint);
210
352
  }
package/src/main.ts CHANGED
@@ -11,7 +11,7 @@ type Stage = "translate" | "audio" | "all";
11
11
  type RunArgs = { command: "run"; input: string; stage: Stage; outputDir?: string; configPath?: string };
12
12
  type CliArgs = RunArgs | { command: "init"; path: string } | { command: "config-check"; configPath?: string } | { command: "help" } | { command: "version" };
13
13
 
14
- const VERSION = "0.3.3";
14
+ const VERSION = "0.4.0";
15
15
 
16
16
  export const HELP_TEXT = `Yonde ${VERSION} — 配置驱动的双语听力材料生成器
17
17
 
@@ -37,11 +37,11 @@ export const HELP_TEXT = `Yonde ${VERSION} — 配置驱动的双语听力材料
37
37
  > ~/.config/yonde/config.toml > 内置默认值
38
38
 
39
39
  示例:
40
- bunx github:Makuraryu/Yonde input.txt
41
- bunx github:Makuraryu/Yonde input.txt --stage translate
42
- bunx github:Makuraryu/Yonde input.txt --config ./yonde.toml
43
- bunx github:Makuraryu/Yonde config check
44
- bunx github:Makuraryu/Yonde init
40
+ bunx @makuraryu/yonde@latest input.txt
41
+ bunx @makuraryu/yonde@latest input.txt --stage translate
42
+ bunx @makuraryu/yonde@latest input.txt --config ./yonde.toml
43
+ bunx @makuraryu/yonde@latest config check
44
+ bunx @makuraryu/yonde@latest init
45
45
 
46
46
  环境变量:
47
47
  YONDE_API_KEY 默认翻译 API Key
@@ -0,0 +1,102 @@
1
+ type ProgressFormat = {
2
+ label: string;
3
+ current: number;
4
+ total: number;
5
+ elapsedMs: number;
6
+ detail?: string;
7
+ done?: boolean;
8
+ failed?: boolean;
9
+ barWidth?: number;
10
+ color?: boolean;
11
+ };
12
+
13
+ function duration(seconds: number): string {
14
+ if (!Number.isFinite(seconds) || seconds < 0) return "--:--";
15
+ const value = Math.round(seconds);
16
+ const hours = Math.floor(value / 3600);
17
+ const minutes = Math.floor((value % 3600) / 60);
18
+ const remainder = value % 60;
19
+ return hours > 0
20
+ ? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
21
+ : `${minutes}:${String(remainder).padStart(2, "0")}`;
22
+ }
23
+
24
+ function paint(value: string, code: number, enabled: boolean): string {
25
+ return enabled ? `\u001b[${code}m${value}\u001b[0m` : value;
26
+ }
27
+
28
+ export function formatProgress(options: ProgressFormat): string {
29
+ const total = Math.max(0, options.total);
30
+ const current = Math.max(0, Math.min(options.current, total || options.current));
31
+ const ratio = total > 0 ? Math.min(1, current / total) : 1;
32
+ const width = Math.max(8, options.barWidth ?? 24);
33
+ const filled = Math.min(width, Math.floor(ratio * width));
34
+ const head = filled < width && current > 0 ? "╸" : "";
35
+ const bar = "━".repeat(filled) + head + "─".repeat(Math.max(0, width - filled - head.length));
36
+ const percent = `${Math.round(ratio * 100)}%`.padStart(4);
37
+ const count = total > 0 ? `${Math.round(current)}/${Math.round(total)}` : `${Math.round(current)}`;
38
+ const elapsed = duration(options.elapsedMs / 1000);
39
+ const etaSeconds = current > 0 && current < total ? (options.elapsedMs / 1000) * (total - current) / current : 0;
40
+ const eta = current > 0 && current < total ? ` 余 ${duration(etaSeconds)}` : "";
41
+ const icon = options.failed ? paint("✗", 31, Boolean(options.color)) : options.done ? paint("✓", 32, Boolean(options.color)) : paint("●", 36, Boolean(options.color));
42
+ const coloredBar = paint(bar, options.failed ? 31 : options.done ? 32 : 36, Boolean(options.color));
43
+ const detail = options.detail ? ` ${options.detail}` : "";
44
+ return `${icon} ${options.label.padEnd(4, " ")} ${coloredBar} ${percent} ${count} ${elapsed}${eta}${detail}`;
45
+ }
46
+
47
+ export class ProgressBar {
48
+ private current: number;
49
+ private readonly startedAt = Date.now();
50
+ private lastRenderedAt = 0;
51
+ private lastBucket = -1;
52
+
53
+ constructor(
54
+ private readonly label: string,
55
+ private readonly total: number,
56
+ initial = 0,
57
+ private readonly stream: NodeJS.WriteStream = process.stdout,
58
+ ) {
59
+ this.current = initial;
60
+ this.render(undefined, true);
61
+ }
62
+
63
+ update(current: number, detail?: string, force = false): void {
64
+ this.current = current;
65
+ this.render(detail, force);
66
+ }
67
+
68
+ finish(detail?: string): void {
69
+ this.current = this.total;
70
+ this.render(detail, true, true);
71
+ }
72
+
73
+ fail(detail?: string): void {
74
+ this.render(detail, true, false, true);
75
+ }
76
+
77
+ private render(detail?: string, force = false, done = false, failed = false): void {
78
+ const now = Date.now();
79
+ const ratio = this.total > 0 ? Math.min(1, this.current / this.total) : 1;
80
+ const bucket = Math.floor(ratio * 20);
81
+ const interactive = Boolean(this.stream.isTTY);
82
+ if (!force && interactive && now - this.lastRenderedAt < 80) return;
83
+ if (!force && !interactive && bucket === this.lastBucket) return;
84
+ this.lastRenderedAt = now;
85
+ this.lastBucket = bucket;
86
+ const terminalWidth = interactive ? this.stream.columns ?? 100 : 100;
87
+ const barWidth = Math.max(10, Math.min(30, terminalWidth - 66));
88
+ const line = formatProgress({
89
+ label: this.label,
90
+ current: this.current,
91
+ total: this.total,
92
+ elapsedMs: now - this.startedAt,
93
+ detail,
94
+ done,
95
+ failed,
96
+ barWidth,
97
+ color: interactive && !process.env.NO_COLOR,
98
+ });
99
+ if (interactive) this.stream.write(`\r\u001b[2K${line}${done || failed ? "\n" : ""}`);
100
+ else this.stream.write(`${line}\n`);
101
+ }
102
+ }
package/src/translate.ts CHANGED
@@ -3,6 +3,7 @@ import { resolveApiKey } from "./config";
3
3
  import type { GlossaryEntry, TranslationState } from "./state";
4
4
  import { writeJson } from "./state";
5
5
  import type { Paragraph, TranslatedParagraph } from "./text";
6
+ import { ProgressBar } from "./progress";
6
7
 
7
8
  type ChatCompletionResponse = {
8
9
  choices?: Array<{ message?: { content?: string } }>;
@@ -145,31 +146,37 @@ async function translateOne(
145
146
  export async function translateAll(state: TranslationState, statePath: string, config: AppConfig): Promise<TranslationState> {
146
147
  const apiKey = resolveApiKey(config);
147
148
  if (!apiKey) throw new Error(`缺少 API Key;请设置环境变量 ${config.translation.api.apiKeyEnv},或在 translation.api.api_key 中配置`);
149
+ const progress = new ProgressBar("翻译", state.paragraphs.length, state.translated.length);
148
150
 
149
- for (let index = state.translated.length; index < state.paragraphs.length; index += 1) {
150
- const paragraph = state.paragraphs[index];
151
- process.stdout.write(`[翻译 ${index + 1}/${state.paragraphs.length}] ${paragraph.original.slice(0, 28)}… `);
152
- let result: ModelTranslation | undefined;
153
- let lastError: unknown;
154
- for (let attempt = 1; attempt <= 4; attempt += 1) {
155
- try {
156
- result = await translateOne(paragraph, state.translated, state.glossary, apiKey, config);
157
- break;
158
- } catch (error) {
159
- lastError = error;
160
- if (attempt < 4) {
161
- process.stdout.write(`重试 ${attempt}/3 `);
162
- await Bun.sleep(1000 * 2 ** (attempt - 1));
151
+ try {
152
+ for (let index = state.translated.length; index < state.paragraphs.length; index += 1) {
153
+ const paragraph = state.paragraphs[index];
154
+ let result: ModelTranslation | undefined;
155
+ let lastError: unknown;
156
+ for (let attempt = 1; attempt <= 4; attempt += 1) {
157
+ try {
158
+ result = await translateOne(paragraph, state.translated, state.glossary, apiKey, config);
159
+ break;
160
+ } catch (error) {
161
+ lastError = error;
162
+ if (attempt < 4) {
163
+ progress.update(index, `第 ${index + 1} 段 · 重试 ${attempt}/3`, true);
164
+ await Bun.sleep(1000 * 2 ** (attempt - 1));
165
+ }
163
166
  }
164
167
  }
168
+ if (!result) throw lastError;
169
+ state.translated.push({ ...paragraph, translations: result.translations.map((item) => item.trim()) });
170
+ state.glossary = mergeGlossary(state.glossary, result.glossaryUpdates ?? []);
171
+ state.complete = state.translated.length === state.paragraphs.length;
172
+ state.updatedAt = new Date().toISOString();
173
+ await writeJson(statePath, state);
174
+ if (!state.complete) progress.update(state.translated.length, `术语 ${state.glossary.length}`);
165
175
  }
166
- if (!result) throw lastError;
167
- state.translated.push({ ...paragraph, translations: result.translations.map((item) => item.trim()) });
168
- state.glossary = mergeGlossary(state.glossary, result.glossaryUpdates ?? []);
169
- state.complete = state.translated.length === state.paragraphs.length;
170
- state.updatedAt = new Date().toISOString();
171
- await writeJson(statePath, state);
172
- console.log("完成");
176
+ progress.finish(state.translated.length ? `术语 ${state.glossary.length}` : "无段落");
177
+ return state;
178
+ } catch (error) {
179
+ progress.fail(`停在 ${state.translated.length}/${state.paragraphs.length}`);
180
+ throw error;
173
181
  }
174
- return state;
175
182
  }