@makuraryu/yonde 0.4.1 → 0.4.2
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 +1 -1
- package/package.json +1 -1
- package/src/audio.ts +39 -11
- package/src/main.ts +1 -1
package/README.md
CHANGED
|
@@ -152,7 +152,7 @@ package_asset = "uisfx/sounds/cinematic/select.mp3"
|
|
|
152
152
|
|
|
153
153
|
翻译、语音生成和最终 MP3 合并都会显示单行进度条,包括完成比例、数量、耗时和 ETA。非交互终端按 5% 里程碑输出,避免日志刷屏。
|
|
154
154
|
|
|
155
|
-
所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3
|
|
155
|
+
所有语音和分隔音效会统一为 24 kHz、单声道、96 kbps,最终 MP3 采用无损快速拼接,不再把数小时音频完整重编码。合并中的大临时文件写在本机临时目录,完成后再一次性写入目标位置,避免 iCloud 持续同步一个不断增长的文件。
|
|
156
156
|
|
|
157
157
|
最终合并使用状态文件保护原子写入。若进程在 ffmpeg 已完成后、最终重命名前退出,下次运行会直接恢复成品;若在合并中途退出,只会重做最终合并,已经生成的翻译和 TTS 缓存不会丢失。
|
|
158
158
|
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EdgeTTS } from "node-edge-tts";
|
|
2
|
-
import { lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { copyFile, lstat, mkdir, mkdtemp, readdir, rename, rm } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
4
5
|
import { basename, dirname, join, relative } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import type { AppConfig, VoiceProfile } from "./config";
|
|
@@ -118,17 +119,22 @@ export function audioMergeFingerprint(value: Omit<AudioManifest, "version" | "me
|
|
|
118
119
|
return new Bun.CryptoHasher("sha256").update(JSON.stringify(value)).digest("hex");
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
+
function mergeTemporaryIsSafe(value: unknown, outputPath: string): value is MergeState {
|
|
122
123
|
if (!value || typeof value !== "object") return false;
|
|
123
124
|
const state = value as Partial<MergeState>;
|
|
124
|
-
|
|
125
|
+
return typeof state.temporary === "string"
|
|
126
|
+
&& dirname(dirname(state.temporary)) === tmpdir()
|
|
127
|
+
&& basename(dirname(state.temporary)).startsWith("yonde-merge-")
|
|
128
|
+
&& basename(state.temporary) === `${basename(outputPath)}.tmp.mp3`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function mergeStateCanRecover(value: unknown, fingerprint: string, outputPath: string): value is MergeState {
|
|
132
|
+
if (!mergeTemporaryIsSafe(value, outputPath)) return false;
|
|
133
|
+
const state = value as Partial<MergeState>;
|
|
125
134
|
return state.version === 1
|
|
126
135
|
&& state.status === "complete"
|
|
127
136
|
&& state.fingerprint === fingerprint
|
|
128
|
-
&& typeof state.
|
|
129
|
-
&& dirname(state.temporary) === dirname(outputPath)
|
|
130
|
-
&& basename(state.temporary).startsWith(prefix)
|
|
131
|
-
&& basename(state.temporary).endsWith(".tmp.mp3");
|
|
137
|
+
&& typeof state.expectedSeconds === "number";
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
async function temporaryOutputs(outputPath: string): Promise<string[]> {
|
|
@@ -152,11 +158,23 @@ async function estimateDurationSeconds(items: AudioItem[]): Promise<number> {
|
|
|
152
158
|
return Math.max(1, bytes * 8 / 96_000);
|
|
153
159
|
}
|
|
154
160
|
|
|
161
|
+
async function installMergedOutput(source: string, outputPath: string): Promise<void> {
|
|
162
|
+
const staged = `${outputPath}.${process.pid}.${randomUUID()}.tmp.mp3`;
|
|
163
|
+
try {
|
|
164
|
+
await copyFile(source, staged);
|
|
165
|
+
await rename(staged, outputPath);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await rm(staged, { force: true });
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
155
172
|
async function recoverCompletedMerge(outputPath: string, stateDir: string, fingerprint: string): Promise<boolean> {
|
|
156
173
|
const statePath = join(stateDir, "audio-merge-state.json");
|
|
157
174
|
const state = await readJson<unknown>(statePath);
|
|
158
175
|
if (!mergeStateCanRecover(state, fingerprint, outputPath) || !(await fileIsUsable(state.temporary))) return false;
|
|
159
|
-
await
|
|
176
|
+
await installMergedOutput(state.temporary, outputPath);
|
|
177
|
+
await rm(dirname(state.temporary), { recursive: true, force: true });
|
|
160
178
|
await rm(statePath, { force: true });
|
|
161
179
|
for (const stale of await temporaryOutputs(outputPath)) await rm(stale, { force: true });
|
|
162
180
|
const progress = new ProgressBar("恢复", 1);
|
|
@@ -164,6 +182,13 @@ async function recoverCompletedMerge(outputPath: string, stateDir: string, finge
|
|
|
164
182
|
return true;
|
|
165
183
|
}
|
|
166
184
|
|
|
185
|
+
async function discardStaleMerge(outputPath: string, stateDir: string): Promise<void> {
|
|
186
|
+
const statePath = join(stateDir, "audio-merge-state.json");
|
|
187
|
+
const state = await readJson<unknown>(statePath);
|
|
188
|
+
if (mergeTemporaryIsSafe(state, outputPath)) await rm(dirname(state.temporary), { recursive: true, force: true });
|
|
189
|
+
await rm(statePath, { force: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
167
192
|
async function readFfmpegProgress(stream: ReadableStream<Uint8Array>, progress: ProgressBar, totalSeconds: number): Promise<void> {
|
|
168
193
|
const reader = stream.getReader();
|
|
169
194
|
const decoder = new TextDecoder();
|
|
@@ -193,7 +218,8 @@ async function runFfmpeg(
|
|
|
193
218
|
const paths = items.map((item) => relative(stateDir, item.path));
|
|
194
219
|
if (paths.some((path) => path.startsWith("..") || /[\r\n\0]/.test(path))) throw new Error("音频缓存路径超出状态目录或含控制字符");
|
|
195
220
|
await atomicWrite(listPath, paths.map((path) => `file '${escapeConcatPath(path)}'`).join("\n") + "\n");
|
|
196
|
-
const
|
|
221
|
+
const temporaryDir = await mkdtemp(join(tmpdir(), "yonde-merge-"));
|
|
222
|
+
const temporary = join(temporaryDir, `${basename(outputPath)}.tmp.mp3`);
|
|
197
223
|
const mergeStatePath = join(stateDir, "audio-merge-state.json");
|
|
198
224
|
const expectedSeconds = await estimateDurationSeconds(items);
|
|
199
225
|
const progress = new ProgressBar("合并", expectedSeconds);
|
|
@@ -216,7 +242,7 @@ async function runFfmpeg(
|
|
|
216
242
|
const stderr = await stderrReader;
|
|
217
243
|
if (exitCode !== 0) {
|
|
218
244
|
progress.fail(`退出码 ${exitCode}`);
|
|
219
|
-
await rm(
|
|
245
|
+
await rm(temporaryDir, { recursive: true, force: true });
|
|
220
246
|
await rm(mergeStatePath, { force: true });
|
|
221
247
|
const detail = stderr.trim().split("\n").at(-1);
|
|
222
248
|
throw new Error(`ffmpeg 合并失败,退出码 ${exitCode}${detail ? `: ${detail}` : ""}`);
|
|
@@ -229,7 +255,8 @@ async function runFfmpeg(
|
|
|
229
255
|
expectedSeconds,
|
|
230
256
|
} satisfies MergeState);
|
|
231
257
|
progress.finish(`${items.length} 个片段`);
|
|
232
|
-
await
|
|
258
|
+
await installMergedOutput(temporary, outputPath);
|
|
259
|
+
await rm(temporaryDir, { recursive: true, force: true });
|
|
233
260
|
await rm(mergeStatePath, { force: true });
|
|
234
261
|
}
|
|
235
262
|
|
|
@@ -352,6 +379,7 @@ export async function buildAudio(
|
|
|
352
379
|
};
|
|
353
380
|
const mergeFingerprint = audioMergeFingerprint(manifestBody);
|
|
354
381
|
if (await recoverCompletedMerge(outputPath, stateDir, mergeFingerprint)) return;
|
|
382
|
+
await discardStaleMerge(outputPath, stateDir);
|
|
355
383
|
const stale = await temporaryOutputs(outputPath);
|
|
356
384
|
if (stale.length) console.warn(`发现 ${stale.length} 个未完成的合并临时文件,将保留语音缓存并重新合并。`);
|
|
357
385
|
for (const path of stale) await rm(path, { force: true });
|
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.4.
|
|
14
|
+
const VERSION = "0.4.2";
|
|
15
15
|
|
|
16
16
|
export const HELP_TEXT = `Yonde ${VERSION} — 配置驱动的双语听力材料生成器
|
|
17
17
|
|